diff --git a/package.json b/package.json index de04e5a..872a8a9 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,7 @@ "build-storybook": "storybook build", "lint": "eslint src --ext .ts,.tsx", "typecheck": "tsc --noEmit", + "test": "wp-scripts test-unit-js", "prepare": "npm run build" }, "peerDependencies": { diff --git a/src/components/onboarding/Onboarding.stories.tsx b/src/components/onboarding/Onboarding.stories.tsx new file mode 100644 index 0000000..39c6a52 --- /dev/null +++ b/src/components/onboarding/Onboarding.stories.tsx @@ -0,0 +1,66 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { fn } from 'storybook/test'; +import { Onboarding } from './index'; +import type { SettingsElement } from '../settings/settings-types'; + +const stepSchema = (pageId: string, fieldId: string, label: string): SettingsElement[] => [ + { id: pageId, type: 'page', label }, + { id: `${pageId}_section`, type: 'section', page_id: pageId, label }, + { + id: fieldId, type: 'field', variant: 'switch', section_id: `${pageId}_section`, + label: `${label} toggle`, default: 'on', + enable_state: { value: 'on', title: 'Enabled' }, disable_state: { value: 'off', title: 'Disabled' }, + } as SettingsElement, +]; + +const steps = [ + { id: 'basic', label: 'Basic', schema: stepSchema('basic', 'basic_toggle', 'Basic'), skippable: false, completed: true }, + { id: 'commission', label: 'Commission', schema: stepSchema('commission', 'commission_toggle', 'Commission'), skippable: true }, + { id: 'withdraw', label: 'Withdraw', schema: stepSchema('withdraw', 'withdraw_toggle', 'Withdraw'), skippable: true }, +]; + +const meta = { + title: 'Components/Onboarding', + component: Onboarding, + parameters: { layout: 'fullscreen' }, + tags: ['autodocs'], + args: { + onStepSave: fn(), + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Horizontal: Story = { + args: { steps, orientation: 'horizontal' }, +}; + +export const Vertical: Story = { + args: { steps, orientation: 'vertical' }, +}; + +export const NoIndicator: Story = { + args: { + steps, + orientation: 'horizontal', + renderStepIndicator: () => null, + }, +}; + +export const CustomIndicator: Story = { + args: { + steps, + orientation: 'horizontal', + renderStepIndicator: ({ steps: s, onStepClick }) => ( +
+ {s.map((step) => ( + + ))} +
+ ), + }, +}; diff --git a/src/components/onboarding/index.tsx b/src/components/onboarding/index.tsx new file mode 100644 index 0000000..361c219 --- /dev/null +++ b/src/components/onboarding/index.tsx @@ -0,0 +1,147 @@ +import { useMemo, useState } from 'react'; +import { cn } from '@/lib/utils'; +import { SettingsProvider, useSettings } from '../settings/settings-context'; +import type { SettingsElement } from '../settings/settings-types'; +import { StepIndicator } from './step-indicator'; +import { StepFooter } from './step-footer'; +import { StepBody } from './step-body'; +import { isFirstStep, isLastStep, nextStepId, prevStepId } from './onboarding-navigation'; +import type { OnboardingProps } from './onboarding-types'; + +export function Onboarding({ + steps, + values, + activeStepId, + orientation = 'horizontal', + hookPrefix = 'plugin_ui', + applyFilters, + loading = false, + className, + onChange, + onStepSave, + onStepChange, + onSkip, + onComplete, + renderStepIndicator, + renderFooter, +}: OnboardingProps) { + const [internalActive, setInternalActive] = useState(activeStepId ?? steps[0]?.id ?? ''); + const active = activeStepId ?? internalActive; + + // Merge every step's page subtree into one schema; force hide_save on each + // page so SettingsContent suppresses its own save button (footer owns it). + // NOTE: callers should pass a stable `steps` reference (useMemo or a + // module-level constant) to avoid rebuilding the merged schema each render. + const schema = useMemo(() => { + const out: SettingsElement[] = []; + for (const step of steps) { + for (const el of step.schema) { + if (el.type === 'page' && el.id === step.id) { + out.push({ ...el, hide_save: true }); + } else { + out.push(el); + } + } + } + return out; + }, [steps]); + + const goTo = (id: string) => { + if (activeStepId === undefined) setInternalActive(id); + onStepChange?.(id); + }; + + return ( + + + + ); +} + +function OnboardingInner({ + steps, active, orientation, className, goTo, onSkip, onComplete, renderStepIndicator, renderFooter, +}: { + steps: OnboardingProps['steps']; + active: string; + orientation: 'horizontal' | 'vertical'; + className?: string; + goTo: (id: string) => void; + onSkip?: OnboardingProps['onSkip']; + onComplete?: OnboardingProps['onComplete']; + renderStepIndicator?: OnboardingProps['renderStepIndicator']; + renderFooter?: OnboardingProps['renderFooter']; +}) { + const { setActivePage, getPageValues, isPageDirty, hasScopeErrors, save } = useSettings(); + + const activeStep = steps.find((s) => s.id === active) ?? steps[0]; + const isFirst = isFirstStep(steps, active); + const isLast = isLastStep(steps, active); + + const navigate = (id: string | null) => { + if (!id) return; + setActivePage(id); + goTo(id); + }; + + const persist = async () => { + if (hasScopeErrors(active) || !save) return; + await save(active, getPageValues(active)); // routes to onStepSave(stepId, tree, flat) + }; + + const indicatorProps = { + steps: steps.map((s, index) => ({ + id: s.id, label: s.label, completed: Boolean(s.completed), active: s.id === active, index, + })), + orientation, + onStepClick: (id: string) => navigate(id), + }; + + const footerProps = { + activeStepId: active, + isFirst, + isLast, + skippable: Boolean(activeStep?.skippable), + dirty: isPageDirty(active), + hasErrors: hasScopeErrors(active), + onBack: () => navigate(prevStepId(steps, active)), + onSkip: () => { onSkip?.(active); navigate(nextStepId(steps, active)); }, + onNext: async () => { await persist(); navigate(nextStepId(steps, active)); }, + onFinish: async () => { await persist(); onComplete?.(); }, + }; + + const horizontal = orientation === 'horizontal'; + + return ( +
+ {renderStepIndicator ? renderStepIndicator(indicatorProps) : } +
+ + {renderFooter ? renderFooter(footerProps) : } +
+
+ ); +} + +export type { OnboardingProps, OnboardingStep } from './onboarding-types'; diff --git a/src/components/onboarding/onboarding-navigation.test.ts b/src/components/onboarding/onboarding-navigation.test.ts new file mode 100644 index 0000000..922db32 --- /dev/null +++ b/src/components/onboarding/onboarding-navigation.test.ts @@ -0,0 +1,29 @@ +import { describe, it, expect } from '@jest/globals'; +import { stepIndexOf, nextStepId, prevStepId, isFirstStep, isLastStep } from './onboarding-navigation'; + +const steps = [ { id: 'basic' }, { id: 'commission' }, { id: 'withdraw' }, { id: 'appearance' } ]; + +describe( 'onboarding-navigation', () => { + it( 'resolves index of a step id', () => { + expect( stepIndexOf( steps, 'withdraw' ) ).toBe( 2 ); + expect( stepIndexOf( steps, 'missing' ) ).toBe( -1 ); + } ); + + it( 'advances and retreats within bounds', () => { + expect( nextStepId( steps, 'basic' ) ).toBe( 'commission' ); + expect( nextStepId( steps, 'appearance' ) ).toBeNull(); // already last + expect( prevStepId( steps, 'commission' ) ).toBe( 'basic' ); + expect( prevStepId( steps, 'basic' ) ).toBeNull(); // already first + } ); + + it( 'reports boundaries', () => { + expect( isFirstStep( steps, 'basic' ) ).toBe( true ); + expect( isLastStep( steps, 'appearance' ) ).toBe( true ); + expect( isLastStep( steps, 'withdraw' ) ).toBe( false ); + } ); + + it( 'treats an unknown active id as no movement', () => { + expect( nextStepId( steps, 'missing' ) ).toBeNull(); + expect( prevStepId( steps, 'missing' ) ).toBeNull(); + } ); +} ); diff --git a/src/components/onboarding/onboarding-navigation.ts b/src/components/onboarding/onboarding-navigation.ts new file mode 100644 index 0000000..5ecfdaf --- /dev/null +++ b/src/components/onboarding/onboarding-navigation.ts @@ -0,0 +1,26 @@ +export type StepRef = { id: string }; + +export function stepIndexOf( steps: StepRef[], id: string ): number { + return steps.findIndex( ( s ) => s.id === id ); +} + +export function nextStepId( steps: StepRef[], current: string ): string | null { + const i = stepIndexOf( steps, current ); + if ( i < 0 || i >= steps.length - 1 ) return null; + return steps[ i + 1 ].id; +} + +export function prevStepId( steps: StepRef[], current: string ): string | null { + const i = stepIndexOf( steps, current ); + if ( i <= 0 ) return null; + return steps[ i - 1 ].id; +} + +export function isFirstStep( steps: StepRef[], id: string ): boolean { + return stepIndexOf( steps, id ) === 0; +} + +export function isLastStep( steps: StepRef[], id: string ): boolean { + const i = stepIndexOf( steps, id ); + return i >= 0 && i === steps.length - 1; +} diff --git a/src/components/onboarding/onboarding-types.ts b/src/components/onboarding/onboarding-types.ts new file mode 100644 index 0000000..d583ecc --- /dev/null +++ b/src/components/onboarding/onboarding-types.ts @@ -0,0 +1,63 @@ +// ============================================ +// Onboarding Component Types +// ============================================ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import type { ReactNode } from 'react'; +import type { SettingsElement } from '../settings/settings-types'; +import type { ApplyFiltersFunction } from '../settings/settings-context'; + +export interface OnboardingStep { + id: string; // also the page id inside schema + label?: string; + description?: string; + icon?: string; + /** + * The step's settings elements as a page subtree: + * `[{ id, type: 'page' }, ...sections, ...fields]`. + * + * NOTE: all steps are merged into ONE flat provider schema, so field ids + * must be globally unique across the ENTIRE wizard (not just within a step). + * Duplicate field ids across steps collide silently in the shared values map. + */ + schema: SettingsElement[]; + skippable?: boolean; + completed?: boolean; +} + +export interface StepIndicatorRenderProps { + steps: Array<{ id: string; label?: string; completed?: boolean; active: boolean; index: number; }>; + orientation: 'horizontal' | 'vertical'; + onStepClick: (stepId: string) => void; +} + +export interface StepFooterRenderProps { + activeStepId: string; + isFirst: boolean; + isLast: boolean; + skippable: boolean; + dirty: boolean; + hasErrors: boolean; + onBack: () => void; + onSkip: () => void; + onNext: () => void | Promise; // saves current step, then advances + onFinish: () => void | Promise; // saves last step, then completes +} + +export interface OnboardingProps { + steps: OnboardingStep[]; + values?: Record; + activeStepId?: string; // controlled; falls back to first step + orientation?: 'horizontal' | 'vertical'; // default 'horizontal' + hookPrefix?: string; // default 'plugin_ui' + applyFilters?: ApplyFiltersFunction; + loading?: boolean; + className?: string; + onChange?: (stepId: string, key: string, value: any) => void; + onStepSave?: (stepId: string, treeValues: Record, flatValues: Record) => void | Promise; + onStepChange?: (stepId: string) => void; + onSkip?: (stepId: string) => void; + onComplete?: () => void; + renderStepIndicator?: (props: StepIndicatorRenderProps) => ReactNode; + renderFooter?: (props: StepFooterRenderProps) => ReactNode; +} diff --git a/src/components/onboarding/step-body.tsx b/src/components/onboarding/step-body.tsx new file mode 100644 index 0000000..3206b04 --- /dev/null +++ b/src/components/onboarding/step-body.tsx @@ -0,0 +1,6 @@ +import { SettingsContent } from '../settings/settings-content'; +import { cn } from '@/lib/utils'; + +export function StepBody({ className }: { className?: string }) { + return ; +} diff --git a/src/components/onboarding/step-footer.tsx b/src/components/onboarding/step-footer.tsx new file mode 100644 index 0000000..27a4850 --- /dev/null +++ b/src/components/onboarding/step-footer.tsx @@ -0,0 +1,37 @@ +import { Button } from '../ui/button'; +import type { StepFooterRenderProps } from './onboarding-types'; + +export function StepFooter({ + isFirst, isLast, skippable, hasErrors, onBack, onSkip, onNext, onFinish, +}: StepFooterRenderProps) { + return ( +
+
+ {!isFirst && ( + + )} +
+
+ {skippable && !isLast && ( + + )} + {isLast ? ( + + ) : ( + + )} +
+
+ ); +} diff --git a/src/components/onboarding/step-indicator.tsx b/src/components/onboarding/step-indicator.tsx new file mode 100644 index 0000000..db73bbd --- /dev/null +++ b/src/components/onboarding/step-indicator.tsx @@ -0,0 +1,42 @@ +import { cn } from '@/lib/utils'; +import { Check } from 'lucide-react'; +import type { StepIndicatorRenderProps } from './onboarding-types'; + +export function StepIndicator({ steps, orientation, onStepClick }: StepIndicatorRenderProps) { + const horizontal = orientation === 'horizontal'; + return ( +
    + {steps.map((step) => ( +
  1. + +
  2. + ))} +
+ ); +} diff --git a/src/index.ts b/src/index.ts index c0e94a7..1ca0353 100644 --- a/src/index.ts +++ b/src/index.ts @@ -386,6 +386,15 @@ export { } from './components/settings'; export { SettingsSkeleton } from './components/settings/settings-skeleton'; +// Onboarding (schema-driven setup wizard) +export { Onboarding } from './components/onboarding'; +export type { + OnboardingProps, + OnboardingStep, + StepIndicatorRenderProps, + StepFooterRenderProps, +} from './components/onboarding/onboarding-types'; + // ============================================ // Theme Presets // ============================================