Skip to content
Open
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>
),
},
};
147 changes: 147 additions & 0 deletions src/components/onboarding/index.tsx
Original file line number Diff line number Diff line change
@@ -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<string>(activeStepId ?? steps[0]?.id ?? '');
const active = activeStepId ?? internalActive;
Comment on lines +28 to +29

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Guard against empty steps array.

If steps is empty or the first step lacks an id, internalActive will be set to an empty string (line 28), which is then passed as initialPage to SettingsProvider (line 63). This misconfiguration could cause the provider to operate in an invalid state.

🛡️ Proposed guard
 }: OnboardingProps) {
+    if (!steps.length) {
+        console.error('Onboarding: steps array cannot be empty');
+        return null;
+    }
     const [internalActive, setInternalActive] = useState<string>(activeStepId ?? steps[0]?.id ?? '');
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/onboarding/index.tsx` around lines 28 - 29, The code sets
internalActive to an empty string when steps is empty which can produce an
invalid initialPage for SettingsProvider; update the initializer and the value
passed to SettingsProvider to prefer a real step id or undefined instead of ''.
For example, initialize internalActive using a safe lookup: useState<string |
undefined>(() => activeStepId ?? steps.find(s => s?.id)?.id), keep const active
= activeStepId ?? internalActive, and when providing initialPage to
SettingsProvider pass active (which will be undefined if no valid id) or compute
const initialPage = active ?? steps.find(s => s?.id)?.id and pass that; ensure
you reference internalActive, setInternalActive, active, activeStepId, steps and
SettingsProvider when making the change.


// 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}
>
<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 (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