Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
66 changes: 66 additions & 0 deletions src/components/onboarding/Onboarding.stories.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof Onboarding>;

export default meta;

type Story = StoryObj<typeof meta>;

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 }) => (
<div className="flex gap-4 p-4">
{s.map((step) => (
<button key={step.id} onClick={() => onStepClick(step.id)} className={step.active ? 'font-bold underline' : ''}>
{step.label}
</button>
))}
</div>
),
},
};
150 changes: 150 additions & 0 deletions src/components/onboarding/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
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<string>(activeStepId ?? steps[0]?.id ?? '');
// Steps may load after mount, so fall back to the first one until a step is chosen.
const active = activeStepId ?? (internalActive || steps[0]?.id || '');

// 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<SettingsElement[]>(() => {
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 (
<SettingsProvider
schema={schema}
values={values}
onChange={onChange}
onSave={onStepSave}
loading={loading}
hookPrefix={hookPrefix}
applyFilters={applyFilters}
initialPage={active}
// The footer saves a step before leaving it, so the unsaved-changes dialog would only block navigation.
confirmOnLeave={false}
>
<OnboardingInner
steps={steps}
active={active}
orientation={orientation}
className={className}
goTo={goTo}
onSkip={onSkip}
onComplete={onComplete}
renderStepIndicator={renderStepIndicator}
renderFooter={renderFooter}
/>
</SettingsProvider>
);
}

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 (!active || 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 (
<div
data-testid="onboarding-root"
className={cn('rounded-lg border border-border bg-background overflow-hidden', horizontal ? 'flex flex-col' : 'flex flex-row', className)}
>
{renderStepIndicator ? renderStepIndicator(indicatorProps) : <StepIndicator {...indicatorProps} />}
<div className="flex flex-1 min-w-0 flex-col">
<StepBody />
{renderFooter ? renderFooter(footerProps) : <StepFooter {...footerProps} />}
</div>
</div>
);
}

export type { OnboardingProps, OnboardingStep } from './onboarding-types';
29 changes: 29 additions & 0 deletions src/components/onboarding/onboarding-navigation.test.ts
Original file line number Diff line number Diff line change
@@ -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();
} );
} );
26 changes: 26 additions & 0 deletions src/components/onboarding/onboarding-navigation.ts
Original file line number Diff line number Diff line change
@@ -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;
}
63 changes: 63 additions & 0 deletions src/components/onboarding/onboarding-types.ts
Original file line number Diff line number Diff line change
@@ -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<void>; // saves current step, then advances
onFinish: () => void | Promise<void>; // saves last step, then completes
}

export interface OnboardingProps {
steps: OnboardingStep[];
values?: Record<string, any>;
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<string, any>, flatValues: Record<string, any>) => void | Promise<void>;
onStepChange?: (stepId: string) => void;
onSkip?: (stepId: string) => void;
onComplete?: () => void;
renderStepIndicator?: (props: StepIndicatorRenderProps) => ReactNode;
renderFooter?: (props: StepFooterRenderProps) => ReactNode;
}
6 changes: 6 additions & 0 deletions src/components/onboarding/step-body.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { SettingsContent } from '../settings/settings-content';
import { cn } from '@/lib/utils';

export function StepBody({ className }: { className?: string }) {
return <SettingsContent className={cn('flex-1', className)} />;
}
37 changes: 37 additions & 0 deletions src/components/onboarding/step-footer.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div
data-testid="onboarding-step-footer"
className="sticky bottom-0 flex items-center justify-between gap-3 border-t border-border bg-background px-6 py-3"
>
<div>
{!isFirst && (
<Button variant="ghost" onClick={onBack} data-testid="onboarding-back">
Back
</Button>
)}
</div>
<div className="flex items-center gap-2">
{skippable && !isLast && (
<Button variant="outline" onClick={onSkip} data-testid="onboarding-skip">
Skip
</Button>
)}
{isLast ? (
<Button onClick={onFinish} disabled={hasErrors} data-testid="onboarding-finish">
Finish
</Button>
) : (
<Button onClick={onNext} disabled={hasErrors} data-testid="onboarding-next">
Continue
</Button>
)}
</div>
</div>
);
}
Loading
Loading