-
Notifications
You must be signed in to change notification settings - Fork 10
feat: add Onboarding stepper component #101
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
mrabbani
wants to merge
13
commits into
main
Choose a base branch
from
feat/plugin-ui-onboarding-component
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
13 commits
Select commit
Hold shift + click to select a range
8cd3aec
feat(onboarding): add pure step-navigation helpers
mrabbani 46eb8d7
feat(onboarding): add Onboarding component types
mrabbani bc9c017
feat(onboarding): add default step indicator (horizontal/vertical)
mrabbani 952029e
feat(onboarding): add default step footer navigation
mrabbani be8b302
refactor(onboarding): drop unused dirty from default footer destructure
mrabbani 7d8e077
feat(onboarding): add step body reusing SettingsContent
mrabbani d443495
feat(onboarding): add Onboarding root wiring provider + stepper chrome
mrabbani c22c34e
fix(onboarding): await step save before navigating/completing
mrabbani 7ccfe90
refactor(onboarding): honest async handler types, drop no-op onChange…
mrabbani 81485a8
feat(onboarding): export Onboarding from package entry
mrabbani 33ae439
feat(onboarding): add Storybook stories and test script
mrabbani 94fe03e
docs(onboarding): document globally-unique field id requirement
mrabbani 76aa151
feat(onboarding): add NoIndicator story demonstrating suppressed step…
mrabbani File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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> | ||
| ), | ||
| }, | ||
| }; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; | ||
|
|
||
| // 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'; | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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(); | ||
| } ); | ||
| } ); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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)} />; | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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> | ||
| ); | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Guard against empty
stepsarray.If
stepsis empty or the first step lacks anid,internalActivewill be set to an empty string (line 28), which is then passed asinitialPagetoSettingsProvider(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