Skip to content

Commit 4d9496d

Browse files
feat(ui): Add wizard emcn component
1 parent 1563cbc commit 4d9496d

3 files changed

Lines changed: 241 additions & 120 deletions

File tree

  • apps/sim
    • app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/slack-setup-wizard
    • components/emcn/components

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/slack-setup-wizard/slack-setup-wizard.tsx

Lines changed: 46 additions & 120 deletions
Original file line numberDiff line numberDiff line change
@@ -3,18 +3,7 @@
33
import { type ReactNode, useCallback, useMemo, useState } from 'react'
44
import { Check, ChevronRight, Clipboard, Info } from 'lucide-react'
55
import { useShallow } from 'zustand/react/shallow'
6-
import {
7-
Button,
8-
Checkbox,
9-
Input,
10-
Label,
11-
Modal,
12-
ModalBody,
13-
ModalContent,
14-
ModalFooter,
15-
ModalHeader,
16-
Tooltip,
17-
} from '@/components/emcn'
6+
import { Checkbox, Input, Label, Tooltip, Wizard } from '@/components/emcn'
187
import { cn } from '@/lib/core/utils/cn'
198
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
209
import { useWebhookManagement } from '@/hooks/use-webhook-management'
@@ -36,16 +25,6 @@ const GROUP_LABELS: Record<SlackCapabilityGroup, string> = {
3625

3726
const GROUP_ORDER: readonly SlackCapabilityGroup[] = ['trigger', 'action'] as const
3827

39-
const STEP_TITLES = [
40-
'Configure your bot',
41-
'Create the app in Slack',
42-
'Paste your Signing Secret',
43-
'Install and paste your Bot Token',
44-
'All set',
45-
] as const
46-
47-
const STEP_COUNT = STEP_TITLES.length
48-
4928
const MODAL_HEIGHT_CLASS = 'h-[580px]'
5029

5130
interface SlackSetupWizardProps {
@@ -144,106 +123,53 @@ function WizardModal({ blockId, open, onOpenChange, isPreview, disabled }: Wizar
144123
[onOpenChange]
145124
)
146125

147-
const handleBack = useCallback(() => {
148-
setStep((s) => Math.max(0, s - 1))
149-
}, [])
150-
151-
const handleNext = useCallback(() => {
152-
setStep((s) => Math.min(STEP_COUNT - 1, s + 1))
153-
}, [])
154-
155-
const handleDone = useCallback(() => {
156-
handleOpenChange(false)
157-
}, [handleOpenChange])
158-
159126
return (
160-
<Modal open={open} onOpenChange={handleOpenChange}>
161-
<ModalContent size='lg' className={MODAL_HEIGHT_CLASS}>
162-
<ModalHeader>
163-
<div className='flex items-baseline justify-between gap-3'>
164-
<span>{STEP_TITLES[step]}</span>
165-
<span className='font-normal text-[var(--text-muted)] text-xs'>
166-
Step {step + 1} of {STEP_COUNT}
167-
</span>
168-
</div>
169-
</ModalHeader>
170-
171-
<StepProgress current={step} />
172-
173-
<ModalBody>
174-
{step === 0 && (
175-
<StepConfigure
176-
blockId={blockId}
177-
appName={displayAppName}
178-
onAppNameChange={(v) => {
179-
if (!controlsDisabled) setAppName(v)
180-
}}
181-
selected={selected}
182-
disabled={controlsDisabled}
183-
/>
184-
)}
185-
{step === 1 && <StepCreate manifestJson={manifestJson} canCopy={canCopy} />}
186-
{step === 2 && (
187-
<StepSecret
188-
blockId={blockId}
189-
value={signingSecret ?? ''}
190-
onChange={(v) => {
191-
if (!controlsDisabled) setSigningSecret(v)
192-
}}
193-
disabled={controlsDisabled}
194-
/>
195-
)}
196-
{step === 3 && (
197-
<StepToken
198-
blockId={blockId}
199-
value={botToken ?? ''}
200-
onChange={(v) => {
201-
if (!controlsDisabled) setBotToken(v)
202-
}}
203-
disabled={controlsDisabled}
204-
/>
205-
)}
206-
{step === 4 && (
207-
<StepDone hasSigningSecret={Boolean(signingSecret)} hasBotToken={Boolean(botToken)} />
208-
)}
209-
</ModalBody>
210-
211-
<ModalFooter>
212-
<Button variant='default' onClick={handleBack} disabled={step === 0}>
213-
Back
214-
</Button>
215-
{step < STEP_COUNT - 1 ? (
216-
<Button variant='primary' onClick={handleNext}>
217-
Next
218-
</Button>
219-
) : (
220-
<Button variant='primary' onClick={handleDone}>
221-
Done
222-
</Button>
223-
)}
224-
</ModalFooter>
225-
</ModalContent>
226-
</Modal>
227-
)
228-
}
229-
230-
interface StepProgressProps {
231-
current: number
232-
}
233-
234-
function StepProgress({ current }: StepProgressProps) {
235-
return (
236-
<div className='flex gap-1.5 px-6 pb-4'>
237-
{STEP_TITLES.map((title, i) => (
238-
<div
239-
key={title}
240-
className={cn(
241-
'h-1 flex-1 rounded-full transition-colors',
242-
i <= current ? 'bg-[var(--brand-secondary)]' : 'bg-[var(--surface-5)]'
243-
)}
127+
<Wizard
128+
open={open}
129+
onOpenChange={handleOpenChange}
130+
currentStep={step}
131+
onStepChange={setStep}
132+
size='lg'
133+
height={MODAL_HEIGHT_CLASS}
134+
>
135+
<Wizard.Step title='Configure your bot'>
136+
<StepConfigure
137+
blockId={blockId}
138+
appName={displayAppName}
139+
onAppNameChange={(v) => {
140+
if (!controlsDisabled) setAppName(v)
141+
}}
142+
selected={selected}
143+
disabled={controlsDisabled}
244144
/>
245-
))}
246-
</div>
145+
</Wizard.Step>
146+
<Wizard.Step title='Create the app in Slack'>
147+
<StepCreate manifestJson={manifestJson} canCopy={canCopy} />
148+
</Wizard.Step>
149+
<Wizard.Step title='Paste your Signing Secret'>
150+
<StepSecret
151+
blockId={blockId}
152+
value={signingSecret ?? ''}
153+
onChange={(v) => {
154+
if (!controlsDisabled) setSigningSecret(v)
155+
}}
156+
disabled={controlsDisabled}
157+
/>
158+
</Wizard.Step>
159+
<Wizard.Step title='Install and paste your Bot Token'>
160+
<StepToken
161+
blockId={blockId}
162+
value={botToken ?? ''}
163+
onChange={(v) => {
164+
if (!controlsDisabled) setBotToken(v)
165+
}}
166+
disabled={controlsDisabled}
167+
/>
168+
</Wizard.Step>
169+
<Wizard.Step title='All set'>
170+
<StepDone hasSigningSecret={Boolean(signingSecret)} hasBotToken={Boolean(botToken)} />
171+
</Wizard.Step>
172+
</Wizard>
247173
)
248174
}
249175

apps/sim/components/emcn/components/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,3 +159,4 @@ export {
159159
type TourTooltipPlacement,
160160
type TourTooltipProps,
161161
} from './tour-tooltip/tour-tooltip'
162+
export { Wizard, type WizardProps, type WizardStepProps } from './wizard/wizard'
Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
'use client'
2+
3+
import * as React from 'react'
4+
import { cn } from '@/lib/core/utils/cn'
5+
import { Button } from '../button/button'
6+
import {
7+
Modal,
8+
ModalBody,
9+
ModalContent,
10+
ModalFooter,
11+
ModalHeader,
12+
type ModalSize,
13+
} from '../modal/modal'
14+
15+
/**
16+
* A multi-step modal wizard primitive.
17+
*
18+
* @remarks
19+
* Wraps the emcn Modal with a step progress bar, a shared Back / Next / Done
20+
* footer, and declarative `Wizard.Step` children. Step state is controlled
21+
* from the outside so the consumer can hydrate from persisted state, reset
22+
* on close, or jump around imperatively.
23+
*
24+
* @example
25+
* ```tsx
26+
* const [open, setOpen] = useState(false)
27+
* const [step, setStep] = useState(0)
28+
*
29+
* <Wizard
30+
* open={open}
31+
* onOpenChange={(next) => { if (!next) setStep(0); setOpen(next) }}
32+
* currentStep={step}
33+
* onStepChange={setStep}
34+
* size='lg'
35+
* height='h-[580px]'
36+
* >
37+
* <Wizard.Step title='Configure'>
38+
* <ConfigureForm />
39+
* </Wizard.Step>
40+
* <Wizard.Step title='Review' canAdvance={isValid}>
41+
* <Review />
42+
* </Wizard.Step>
43+
* <Wizard.Step title='Done'>
44+
* <DoneSummary />
45+
* </Wizard.Step>
46+
* </Wizard>
47+
* ```
48+
*/
49+
50+
export interface WizardStepProps {
51+
/** Title shown in the modal header when this step is active. */
52+
title: string
53+
/** Step body. Rendered inside the modal body when this step is active. */
54+
children: React.ReactNode
55+
/**
56+
* When false, the Next button on this step is disabled. Lets the consumer
57+
* gate progression on validation or async state.
58+
* @default true
59+
*/
60+
canAdvance?: boolean
61+
}
62+
63+
/**
64+
* Declares one step in the wizard. Carries metadata via props; the actual
65+
* children are extracted and rendered by the `Wizard` root.
66+
*/
67+
const Step: React.FC<WizardStepProps> = ({ children }) => <>{children}</>
68+
Step.displayName = 'Wizard.Step'
69+
70+
const STEP_DISPLAY_NAME = 'Wizard.Step'
71+
72+
function isStepElement(node: React.ReactNode): node is React.ReactElement<WizardStepProps> {
73+
if (!React.isValidElement(node)) return false
74+
const type = node.type as { displayName?: string } | string
75+
return typeof type !== 'string' && type?.displayName === STEP_DISPLAY_NAME
76+
}
77+
78+
export interface WizardProps {
79+
/** Whether the wizard modal is open. */
80+
open: boolean
81+
/** Called when the modal's open state changes. */
82+
onOpenChange: (next: boolean) => void
83+
/** Zero-indexed current step. */
84+
currentStep: number
85+
/** Called with the new step index when the user clicks Back / Next. */
86+
onStepChange: (next: number) => void
87+
/**
88+
* Modal size variant. Matches `ModalContent` sizes.
89+
* @default 'lg'
90+
*/
91+
size?: ModalSize
92+
/**
93+
* Optional fixed height for the modal content. Pass a Tailwind class
94+
* (e.g. `h-[580px]`) to keep the modal a stable size across steps.
95+
*/
96+
height?: string
97+
/**
98+
* Called when the user clicks Done on the final step. Fires before the
99+
* modal closes; the wizard will close the modal itself after.
100+
*/
101+
onComplete?: () => void
102+
/** One or more `<Wizard.Step>` elements. Non-step children are ignored. */
103+
children: React.ReactNode
104+
/** Label for the Back button. @default 'Back' */
105+
backLabel?: string
106+
/** Label for the Next button. @default 'Next' */
107+
nextLabel?: string
108+
/** Label for the Done button on the final step. @default 'Done' */
109+
doneLabel?: string
110+
}
111+
112+
const WizardRoot: React.FC<WizardProps> = ({
113+
open,
114+
onOpenChange,
115+
currentStep,
116+
onStepChange,
117+
size = 'lg',
118+
height,
119+
onComplete,
120+
children,
121+
backLabel = 'Back',
122+
nextLabel = 'Next',
123+
doneLabel = 'Done',
124+
}) => {
125+
const steps = React.Children.toArray(children).filter(isStepElement)
126+
const total = steps.length
127+
const clamped = Math.min(Math.max(0, currentStep), Math.max(0, total - 1))
128+
const activeStep = steps[clamped]
129+
const canAdvance = activeStep?.props.canAdvance ?? true
130+
const isLast = total > 0 && clamped === total - 1
131+
132+
const handleBack = React.useCallback(() => {
133+
onStepChange(Math.max(0, clamped - 1))
134+
}, [clamped, onStepChange])
135+
136+
const handleNext = React.useCallback(() => {
137+
onStepChange(Math.min(total - 1, clamped + 1))
138+
}, [clamped, total, onStepChange])
139+
140+
const handleDone = React.useCallback(() => {
141+
onComplete?.()
142+
onOpenChange(false)
143+
}, [onComplete, onOpenChange])
144+
145+
if (total === 0) return null
146+
147+
return (
148+
<Modal open={open} onOpenChange={onOpenChange}>
149+
<ModalContent size={size} className={height}>
150+
<ModalHeader>
151+
<div className='flex items-baseline justify-between gap-3'>
152+
<span>{activeStep?.props.title}</span>
153+
<span className='font-normal text-[var(--text-muted)] text-xs'>
154+
Step {clamped + 1} of {total}
155+
</span>
156+
</div>
157+
</ModalHeader>
158+
159+
<div className='flex gap-1.5 px-6 pb-4'>
160+
{steps.map((step, i) => (
161+
<div
162+
key={step.props.title}
163+
className={cn(
164+
'h-1 flex-1 rounded-full transition-colors',
165+
i <= clamped ? 'bg-[var(--brand-secondary)]' : 'bg-[var(--surface-5)]'
166+
)}
167+
/>
168+
))}
169+
</div>
170+
171+
<ModalBody>{activeStep}</ModalBody>
172+
173+
<ModalFooter>
174+
<Button variant='default' onClick={handleBack} disabled={clamped === 0}>
175+
{backLabel}
176+
</Button>
177+
{isLast ? (
178+
<Button variant='primary' onClick={handleDone}>
179+
{doneLabel}
180+
</Button>
181+
) : (
182+
<Button variant='primary' onClick={handleNext} disabled={!canAdvance}>
183+
{nextLabel}
184+
</Button>
185+
)}
186+
</ModalFooter>
187+
</ModalContent>
188+
</Modal>
189+
)
190+
}
191+
192+
export const Wizard = Object.assign(WizardRoot, {
193+
Step,
194+
})

0 commit comments

Comments
 (0)