From d3177429756506221e4646969dff4841506a1a0d Mon Sep 17 00:00:00 2001 From: balibabu Date: Fri, 10 Jul 2026 15:44:04 +0800 Subject: [PATCH] Feat: Rewrite wiki template with reui (#16797) --- web/package.json | 2 +- web/scripts/prepare.js | 33 ++ web/src/components/reui/stepper.tsx | 488 ++++++++++++++++++ web/src/locales/en.ts | 1 - .../components/template-stepper.tsx | 109 ++++ .../create-next/index.tsx | 49 +- .../use-compilation-template-group-form.ts | 1 + web/vite.config.ts | 4 +- 8 files changed, 648 insertions(+), 39 deletions(-) create mode 100644 web/scripts/prepare.js create mode 100644 web/src/components/reui/stepper.tsx create mode 100644 web/src/pages/user-setting/compilation-templates/create-next/components/template-stepper.tsx diff --git a/web/package.json b/web/package.json index 6597b0e400..ae3dee7983 100644 --- a/web/package.json +++ b/web/package.json @@ -11,7 +11,7 @@ "format": "prettier --write \"src/**/*.{ts,tsx}\"", "format:check": "prettier --check \"src/**/*.{ts,tsx}\"", "lint": "eslint src --ext .ts,.tsx --report-unused-disable-directives", - "prepare": "cd .. && git rev-parse --git-dir >/dev/null 2>&1 && lefthook install || true", + "prepare": "node scripts/prepare.js", "preview": "vite preview", "storybook": "storybook dev -p 6006", "test": "jest --no-cache --coverage", diff --git a/web/scripts/prepare.js b/web/scripts/prepare.js new file mode 100644 index 0000000000..2d679a8cd4 --- /dev/null +++ b/web/scripts/prepare.js @@ -0,0 +1,33 @@ +import { execSync } from 'node:child_process'; +import { dirname, join } from 'node:path'; +import process from 'node:process'; +import { fileURLToPath } from 'node:url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +const repoRoot = dirname(__dirname); +const isWindows = process.platform === 'win32'; + +// Use the locally installed lefthook binary to avoid relying on a global install or PATH. +const lefthookBin = join( + repoRoot, + 'node_modules', + '.bin', + `lefthook${isWindows ? '.cmd' : ''}`, +); + +try { + // Verify we are inside a Git repository first. + execSync('git rev-parse --git-dir', { + cwd: repoRoot, + stdio: 'ignore', + }); + + // Install lefthook hooks from the repository root. + execSync(`"${lefthookBin}" install`, { + cwd: repoRoot, + stdio: 'inherit', + }); +} catch { + // Silently ignore failures (not a Git repo or lefthook install failed) so npm install is not interrupted. +} diff --git a/web/src/components/reui/stepper.tsx b/web/src/components/reui/stepper.tsx new file mode 100644 index 0000000000..bb20901049 --- /dev/null +++ b/web/src/components/reui/stepper.tsx @@ -0,0 +1,488 @@ +/* eslint-disable react-hooks/exhaustive-deps */ + +'use client'; + +import { + Children, + createContext, + HTMLAttributes, + isValidElement, + ReactElement, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; + +import { cn } from '@/lib/utils'; + +// Types +type StepperOrientation = 'horizontal' | 'vertical'; +type StepState = 'active' | 'completed' | 'inactive' | 'loading'; +type StepIndicators = { + active?: React.ReactNode; + completed?: React.ReactNode; + inactive?: React.ReactNode; + loading?: React.ReactNode; +}; + +interface StepperContextValue { + activeStep: number; + setActiveStep: (step: number) => void; + stepsCount: number; + orientation: StepperOrientation; + registerTrigger: (node: HTMLButtonElement | null) => void; + triggerNodes: HTMLButtonElement[]; + focusNext: (currentIdx: number) => void; + focusPrev: (currentIdx: number) => void; + focusFirst: () => void; + focusLast: () => void; + indicators: StepIndicators; +} + +interface StepItemContextValue { + step: number; + state: StepState; + isDisabled: boolean; + isLoading: boolean; +} + +const StepperContext = createContext( + undefined, +); +const StepItemContext = createContext( + undefined, +); + +function useStepper() { + const ctx = useContext(StepperContext); + if (!ctx) throw new Error('useStepper must be used within a Stepper'); + return ctx; +} + +function useStepItem() { + const ctx = useContext(StepItemContext); + if (!ctx) throw new Error('useStepItem must be used within a StepperItem'); + return ctx; +} + +interface StepperProps extends HTMLAttributes { + defaultValue?: number; + value?: number; + onValueChange?: (value: number) => void; + orientation?: StepperOrientation; + indicators?: StepIndicators; +} + +function Stepper({ + defaultValue = 1, + value, + onValueChange, + orientation = 'horizontal', + className, + children, + indicators = {}, + ...props +}: StepperProps) { + const [activeStep, setActiveStep] = useState(defaultValue); + const [triggerNodes, setTriggerNodes] = useState([]); + + // Register/unregister triggers + const registerTrigger = useCallback((node: HTMLButtonElement | null) => { + setTriggerNodes((prev) => { + if (node && !prev.includes(node)) { + return [...prev, node]; + } else if (!node && prev.includes(node!)) { + return prev.filter((n) => n !== node); + } else { + return prev; + } + }); + }, []); + + const handleSetActiveStep = useCallback( + (step: number) => { + if (value === undefined) { + setActiveStep(step); + } + onValueChange?.(step); + }, + [value, onValueChange], + ); + + const currentStep = value ?? activeStep; + + // Keyboard navigation logic + const focusTrigger = (idx: number) => { + if (triggerNodes[idx]) triggerNodes[idx].focus(); + }; + const focusNext = (currentIdx: number) => + focusTrigger((currentIdx + 1) % triggerNodes.length); + const focusPrev = (currentIdx: number) => + focusTrigger((currentIdx - 1 + triggerNodes.length) % triggerNodes.length); + const focusFirst = () => focusTrigger(0); + const focusLast = () => focusTrigger(triggerNodes.length - 1); + + // Context value + const contextValue = useMemo( + () => ({ + activeStep: currentStep, + setActiveStep: handleSetActiveStep, + stepsCount: Children.toArray(children).filter( + (child): child is ReactElement => + isValidElement(child) && + (child.type as { displayName?: string }).displayName === + 'StepperItem', + ).length, + orientation, + registerTrigger, + focusNext, + focusPrev, + focusFirst, + focusLast, + triggerNodes, + indicators, + }), + [ + currentStep, + handleSetActiveStep, + children, + orientation, + registerTrigger, + triggerNodes, + ], + ); + + return ( + +
+ {children} +
+
+ ); +} + +interface StepperItemProps extends React.HTMLAttributes { + step: number; + completed?: boolean; + disabled?: boolean; + loading?: boolean; +} + +function StepperItem({ + step, + completed = false, + disabled = false, + loading = false, + className, + children, + ...props +}: StepperItemProps) { + const { activeStep } = useStepper(); + + const state: StepState = + completed || step < activeStep + ? 'completed' + : activeStep === step + ? 'active' + : 'inactive'; + + const isLoading = loading && step === activeStep; + + return ( + +
+ {children} +
+
+ ); +} + +interface StepperTriggerProps extends React.ButtonHTMLAttributes { + asChild?: boolean; +} + +function StepperTrigger({ + asChild = false, + className, + children, + tabIndex, + ...props +}: StepperTriggerProps) { + const { state, isLoading } = useStepItem(); + const stepperCtx = useStepper(); + const { + setActiveStep, + activeStep, + registerTrigger, + triggerNodes, + focusNext, + focusPrev, + focusFirst, + focusLast, + } = stepperCtx; + const { step, isDisabled } = useStepItem(); + const isSelected = activeStep === step; + const id = `stepper-tab-${step}`; + const panelId = `stepper-panel-${step}`; + + // Register this trigger for keyboard navigation + const btnRef = useRef(null); + useEffect(() => { + if (btnRef.current) { + registerTrigger(btnRef.current); + } + }, [btnRef.current]); + + // Find our index among triggers for navigation + const myIdx = useMemo( + () => + triggerNodes.findIndex((n: HTMLButtonElement) => n === btnRef.current), + [triggerNodes, btnRef.current], + ); + + const handleKeyDown = (e: React.KeyboardEvent) => { + switch (e.key) { + case 'ArrowRight': + case 'ArrowDown': + e.preventDefault(); + if (myIdx !== -1 && focusNext) focusNext(myIdx); + break; + case 'ArrowLeft': + case 'ArrowUp': + e.preventDefault(); + if (myIdx !== -1 && focusPrev) focusPrev(myIdx); + break; + case 'Home': + e.preventDefault(); + if (focusFirst) focusFirst(); + break; + case 'End': + e.preventDefault(); + if (focusLast) focusLast(); + break; + case 'Enter': + case ' ': + e.preventDefault(); + setActiveStep(step); + break; + } + }; + + if (asChild) { + return ( + + {children} + + ); + } + + return ( + + ); +} + +function StepperIndicator({ + children, + className, +}: React.ComponentProps<'div'>) { + const { state, isLoading } = useStepItem(); + const { indicators } = useStepper(); + + return ( +
+
+ {indicators && + ((isLoading && indicators.loading) || + (state === 'completed' && indicators.completed) || + (state === 'active' && indicators.active) || + (state === 'inactive' && indicators.inactive)) + ? (isLoading && indicators.loading) || + (state === 'completed' && indicators.completed) || + (state === 'active' && indicators.active) || + (state === 'inactive' && indicators.inactive) + : children} +
+
+ ); +} + +function StepperSeparator({ className }: React.ComponentProps<'div'>) { + const { state } = useStepItem(); + + return ( +
+ ); +} + +function StepperTitle({ children, className }: React.ComponentProps<'h3'>) { + const { state } = useStepItem(); + + return ( +

+ {children} +

+ ); +} + +function StepperDescription({ + children, + className, +}: React.ComponentProps<'div'>) { + const { state } = useStepItem(); + + return ( +
+ {children} +
+ ); +} + +function StepperNav({ children, className }: React.ComponentProps<'nav'>) { + const { activeStep, orientation } = useStepper(); + + return ( + + ); +} + +function StepperPanel({ children, className }: React.ComponentProps<'div'>) { + const { activeStep } = useStepper(); + + return ( +
+ {children} +
+ ); +} + +interface StepperContentProps extends React.ComponentProps<'div'> { + value: number; + forceMount?: boolean; +} + +function StepperContent({ + value, + forceMount, + children, + className, +}: StepperContentProps) { + const { activeStep } = useStepper(); + const isActive = value === activeStep; + + if (!forceMount && !isActive) { + return null; + } + + return ( + + ); +} + +export { + Stepper, + StepperContent, + StepperDescription, + StepperIndicator, + StepperItem, + StepperNav, + StepperPanel, + StepperSeparator, + StepperTitle, + StepperTrigger, + useStepItem, + useStepper, + type StepperContentProps, + type StepperItemProps, + type StepperProps, + type StepperTriggerProps, +}; diff --git a/web/src/locales/en.ts b/web/src/locales/en.ts index 5089549781..ab7ebffd66 100644 --- a/web/src/locales/en.ts +++ b/web/src/locales/en.ts @@ -526,7 +526,6 @@ Example: A 1 KB message with 1024-dim embedding uses ~9 KB. The 5 MB default lim directoryNamePlaceholder: 'Instance name', directoryRule: 'Rule', directoryRulePlaceholder: 'Input', - entity: 'Entity', selectArtifact: 'Select an item from the contents to view details', sourceDocuments: 'Source documents', compilationTitleSuffix: "' dataset", diff --git a/web/src/pages/user-setting/compilation-templates/create-next/components/template-stepper.tsx b/web/src/pages/user-setting/compilation-templates/create-next/components/template-stepper.tsx new file mode 100644 index 0000000000..8c1f7d5621 --- /dev/null +++ b/web/src/pages/user-setting/compilation-templates/create-next/components/template-stepper.tsx @@ -0,0 +1,109 @@ +import { + Stepper, + StepperDescription, + StepperIndicator, + StepperItem, + StepperNav, + StepperSeparator, + StepperTitle, + StepperTrigger, +} from '@/components/reui/stepper'; +import { cn } from '@/lib/utils'; +import { Cog, FileText, Palette } from 'lucide-react'; +import { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; + +interface TemplateStepperProps { + activeStep: number; + isArtifacts: boolean; + onStepChange: (step: number) => void; +} + +export function TemplateStepper({ + activeStep, + isArtifacts, + onStepChange, +}: TemplateStepperProps) { + const { t } = useTranslation(); + + const steps = useMemo( + () => [ + { + id: 'basic-info', + title: t('setting.basicInfo'), + description: t('setting.basicInfoDescription'), + icon: FileText, + }, + { + id: 'configuration', + title: t('setting.templateWizardConfiguration'), + description: t('setting.templateWizardConfigurationDescription'), + icon: Cog, + }, + ...(isArtifacts + ? [ + { + id: 'blueprints', + title: t('setting.blueprints'), + description: t('setting.blueprintsDescription'), + icon: Palette, + }, + ] + : []), + ], + [isArtifacts, t], + ); + + return ( + + + {steps.map((step, index) => { + const stepNumber = index + 1; + const isActive = stepNumber === activeStep; + const Icon = step.icon; + const isLast = index === steps.length - 1; + + return ( + activeStep} + > + + + + + + {step.title} + + + {step.description} + + + {!isLast && ( + + )} + + ); + })} + + + ); +} diff --git a/web/src/pages/user-setting/compilation-templates/create-next/index.tsx b/web/src/pages/user-setting/compilation-templates/create-next/index.tsx index 3484aecf10..457d4ad6c7 100644 --- a/web/src/pages/user-setting/compilation-templates/create-next/index.tsx +++ b/web/src/pages/user-setting/compilation-templates/create-next/index.tsx @@ -1,5 +1,4 @@ import BackButton from '@/components/back-button'; -import { CustomTimeline } from '@/components/originui/timeline'; import { Button } from '@/components/ui/button'; import { Form } from '@/components/ui/form'; import { @@ -9,7 +8,7 @@ import { } from '@/components/ui/resizable'; import { CompilationTemplateKind } from '@/constants/compilation'; import { Routes } from '@/routes'; -import { useCallback, useMemo, useState } from 'react'; +import { useCallback, useState } from 'react'; import { useFieldArray, useWatch } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; @@ -17,6 +16,7 @@ import { BasicInfoStep } from './components/basic-info-step'; import { BlueprintsStep } from './components/blueprints-step'; import { TemplateConfiguration } from './components/template-configuration'; import { TemplateSidebar } from './components/template-sidebar'; +import { TemplateStepper } from './components/template-stepper'; import { useCreateNextCompilationTemplateGroup } from './hooks/use-create-next-compilation-template-group'; export default function CreateNextCompilationTemplate() { @@ -39,29 +39,14 @@ export default function CreateNextCompilationTemplate() { const isArtifacts = selectedKind === CompilationTemplateKind.Artifacts; - const timelineNodes = useMemo( - () => [ - { - id: 'basic-info', - title: t('setting.basicInfo'), - content: t('setting.basicInfoDescription'), - }, - { - id: 'configuration', - title: t('setting.templateWizardConfiguration'), - content: t('setting.templateWizardConfigurationDescription'), - }, - ...(isArtifacts - ? [ - { - id: 'blueprints', - title: t('setting.blueprints'), - content: t('setting.blueprintsDescription'), - }, - ] - : []), - ], - [isArtifacts, t], + const handleStepChange = useCallback( + (step: number) => { + // Allow clicking back to completed steps only. + if (step < activeStep) { + setActiveStep(step); + } + }, + [activeStep], ); const handleNext = useCallback(async () => { @@ -85,7 +70,7 @@ export default function CreateNextCompilationTemplate() { return (
-
+
@@ -97,16 +82,10 @@ export default function CreateNextCompilationTemplate() {
- { - // Allow clicking back to completed steps only. - if (step < activeStep) { - setActiveStep(step); - } - }} - orientation="horizontal" + isArtifacts={isArtifacts} + onStepChange={handleStepChange} />
diff --git a/web/src/pages/user-setting/compilation-templates/edit-template/hooks/use-compilation-template-group-form.ts b/web/src/pages/user-setting/compilation-templates/edit-template/hooks/use-compilation-template-group-form.ts index 01b4524ff3..3601964640 100644 --- a/web/src/pages/user-setting/compilation-templates/edit-template/hooks/use-compilation-template-group-form.ts +++ b/web/src/pages/user-setting/compilation-templates/edit-template/hooks/use-compilation-template-group-form.ts @@ -37,6 +37,7 @@ export const useCompilationTemplateGroupForm = ({ const form = useForm({ resolver: zodResolver(buildFormSchema(t)), defaultValues, + mode: 'onChange', }); useEffect(() => { diff --git a/web/vite.config.ts b/web/vite.config.ts index 1113a92b84..3fad555d1d 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -64,12 +64,12 @@ export default defineConfig(({ mode }) => { ws: true, }, '/api': { - target: 'http://127.0.0.1:9386/', + target: 'http://127.0.0.1:9380/', changeOrigin: true, ws: true, }, '/v1': { - target: 'http://127.0.0.1:9386/', + target: 'http://127.0.0.1:9380/', changeOrigin: true, ws: true, },