@gntik-ai/templates 0.6.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +118 -5
- package/dist/index.js +322 -5
- package/dist/index.js.map +1 -1
- package/package.json +4 -4
- package/src/create-wizard/Flow.test.tsx +592 -0
- package/src/create-wizard/Flow.tsx +199 -0
- package/src/create-wizard/Page.tsx +33 -2
- package/src/create-wizard/Page.types.test.tsx +52 -0
- package/src/create-wizard/examples/ControlledError.tsx +6 -0
- package/src/create-wizard/examples/ControlledIdle.tsx +6 -0
- package/src/create-wizard/examples/ControlledSubmitting.tsx +6 -0
- package/src/create-wizard/examples/ControlledSuccess.tsx +6 -0
- package/src/create-wizard/examples/FlowExample.tsx +60 -0
- package/src/create-wizard/examples/FlowExamples.test.tsx +57 -0
- package/src/create-wizard/examples/Invite.test.tsx +47 -0
- package/src/create-wizard/examples/Invite.tsx +114 -0
- package/src/create-wizard/examples/UncontrolledError.tsx +5 -0
- package/src/create-wizard/examples/UncontrolledPending.tsx +5 -0
- package/src/create-wizard/examples/UncontrolledSuccess.tsx +5 -0
- package/src/create-wizard/examples/labels.ts +29 -0
- package/src/create-wizard/flow-types.ts +84 -0
- package/src/create-wizard/template.meta.ts +63 -4
- package/src/create-wizard/useSubmitState.ts +67 -0
- package/src/index.ts +8 -0
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { DescriptionListCard } from '@gntik-ai/blocks';
|
|
2
|
+
import { Heading, Link, LiveAnnouncer, Stack, WizardLayout, useAnnounce, useHasLiveAnnouncer } from '@gntik-ai/ui';
|
|
3
|
+
import { useEffect, useEffectEvent, useId, useLayoutEffect, useRef, useState } from 'react';
|
|
4
|
+
import { useSubmitState } from './useSubmitState';
|
|
5
|
+
import type { CreateWizardFlowProps } from './flow-types';
|
|
6
|
+
export type {
|
|
7
|
+
CreateWizardFlowProps,
|
|
8
|
+
CreateWizardFlowLabels,
|
|
9
|
+
CreateWizardFlowStep,
|
|
10
|
+
CreateWizardFieldErrors,
|
|
11
|
+
CreateWizardSubmitState,
|
|
12
|
+
} from './flow-types';
|
|
13
|
+
|
|
14
|
+
/** Additive caller-defined flow; the legacy page retains its original props and defaults. */
|
|
15
|
+
export function CreateWizardFlow<V extends object>(props: CreateWizardFlowProps<V>) {
|
|
16
|
+
return useHasLiveAnnouncer() ? (
|
|
17
|
+
<Flow {...props} />
|
|
18
|
+
) : (
|
|
19
|
+
<LiveAnnouncer>
|
|
20
|
+
<Flow {...props} />
|
|
21
|
+
</LiveAnnouncer>
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function Flow<V extends object>(props: CreateWizardFlowProps<V>) {
|
|
26
|
+
const { flowSteps, initialValues, summary, labels, onExit } = props;
|
|
27
|
+
const { state, finish, reset, successText, errorText } = useSubmitState(props);
|
|
28
|
+
const id = useId();
|
|
29
|
+
const announce = useAnnounce();
|
|
30
|
+
const focused = useRef<HTMLElement | null>(null);
|
|
31
|
+
const [step, setStep] = useState(() =>
|
|
32
|
+
props.submitState && props.submitState.status !== 'idle' ? flowSteps.length : 0,
|
|
33
|
+
);
|
|
34
|
+
const [values, setValues] = useState(initialValues);
|
|
35
|
+
const [visited, setVisited] = useState<ReadonlySet<string>>(new Set());
|
|
36
|
+
const [invalidPress, setInvalidPress] = useState(0);
|
|
37
|
+
const body = useRef<HTMLElement>(null);
|
|
38
|
+
const editFocus = useRef(false);
|
|
39
|
+
const current = flowSteps[step];
|
|
40
|
+
const validation = current?.validate?.(values);
|
|
41
|
+
const fieldErrors = current?.readOnly ? {} : (validation?.fieldErrors ?? {});
|
|
42
|
+
const errors = current && visited.has(current.id) ? fieldErrors : {};
|
|
43
|
+
const blocking = validation?.blocking;
|
|
44
|
+
const locked = state.status === 'submitting' || state.status === 'success';
|
|
45
|
+
const resource = state.status === 'success' && state.result != null ? props.resourceHref?.(state.result) : undefined;
|
|
46
|
+
const headingId = `${id}-heading`;
|
|
47
|
+
const steps = [...flowSteps.map(({ id, label }) => ({ id, label })), { id: `${id}-review`, label: labels.review }];
|
|
48
|
+
|
|
49
|
+
const active = props.mode !== 'overlay' || props.open === true;
|
|
50
|
+
useEffect(() => {
|
|
51
|
+
if (!active || !blocking) return;
|
|
52
|
+
const frame = requestAnimationFrame(() => announce(blocking));
|
|
53
|
+
return () => cancelAnimationFrame(frame);
|
|
54
|
+
}, [active, step, blocking, announce]);
|
|
55
|
+
const announceStep = useEffectEvent(() => {
|
|
56
|
+
if (!blocking && (current || state.status === 'idle'))
|
|
57
|
+
announce(
|
|
58
|
+
labels.stepAnnouncement({
|
|
59
|
+
position: step + 1,
|
|
60
|
+
total: flowSteps.length + 1,
|
|
61
|
+
label: current?.label ?? labels.review,
|
|
62
|
+
}),
|
|
63
|
+
);
|
|
64
|
+
});
|
|
65
|
+
useEffect(() => {
|
|
66
|
+
if (!active || props.mode !== 'overlay') return;
|
|
67
|
+
const frame = requestAnimationFrame(() => announceStep());
|
|
68
|
+
return () => cancelAnimationFrame(frame);
|
|
69
|
+
}, [active, step, props.mode]);
|
|
70
|
+
useLayoutEffect(() => {
|
|
71
|
+
const previous = focused.current;
|
|
72
|
+
if (!active || !previous) return;
|
|
73
|
+
const disabled = previous.matches(':disabled');
|
|
74
|
+
const removed = !previous.isConnected;
|
|
75
|
+
if ((disabled || removed) && (document.activeElement === previous || document.activeElement === document.body))
|
|
76
|
+
(state.status === 'success'
|
|
77
|
+
? (body.current?.querySelector<HTMLElement>('[data-flow-success] a[href]') ??
|
|
78
|
+
body.current?.querySelector<HTMLElement>('[data-flow-success] h2'))
|
|
79
|
+
: body.current?.querySelector<HTMLElement>('h1')
|
|
80
|
+
)?.focus();
|
|
81
|
+
});
|
|
82
|
+
useLayoutEffect(() => {
|
|
83
|
+
if (invalidPress) body.current?.querySelector<HTMLElement>('[aria-invalid="true"]')?.focus();
|
|
84
|
+
}, [invalidPress]);
|
|
85
|
+
useLayoutEffect(() => {
|
|
86
|
+
if (editFocus.current) {
|
|
87
|
+
body.current?.querySelector<HTMLElement>('h1')?.focus();
|
|
88
|
+
editFocus.current = false;
|
|
89
|
+
}
|
|
90
|
+
}, [step]);
|
|
91
|
+
|
|
92
|
+
const go = (next: number) => {
|
|
93
|
+
if (locked || (next > step && blocking)) return;
|
|
94
|
+
if (next > step && current) setVisited((previous) => new Set([...previous, current.id]));
|
|
95
|
+
if (next > step && current && Object.values(fieldErrors).some(Boolean)) {
|
|
96
|
+
setInvalidPress((press) => press + 1);
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
setStep(next);
|
|
100
|
+
};
|
|
101
|
+
const rows = current ? [] : summary(values);
|
|
102
|
+
return (
|
|
103
|
+
<WizardLayout
|
|
104
|
+
mode={props.mode}
|
|
105
|
+
size={props.size}
|
|
106
|
+
open={props.open}
|
|
107
|
+
onOpenChange={props.onOpenChange}
|
|
108
|
+
description={props.description}
|
|
109
|
+
returnFocusRef={props.returnFocusRef}
|
|
110
|
+
closeOnInteractOutside={props.closeOnInteractOutside}
|
|
111
|
+
confirmExit={props.confirmExit}
|
|
112
|
+
fullScreen
|
|
113
|
+
title={labels.title}
|
|
114
|
+
steps={steps}
|
|
115
|
+
current={step}
|
|
116
|
+
onFocusCapture={(event) => {
|
|
117
|
+
if (event.target instanceof HTMLElement) focused.current = event.target;
|
|
118
|
+
}}
|
|
119
|
+
onStepChange={go}
|
|
120
|
+
stepsInteractive={false}
|
|
121
|
+
nextDisabled={Boolean(blocking) || state.status === 'success'}
|
|
122
|
+
backDisabled={locked}
|
|
123
|
+
onExit={() => {
|
|
124
|
+
reset();
|
|
125
|
+
setValues(initialValues);
|
|
126
|
+
setStep(0);
|
|
127
|
+
setVisited(new Set());
|
|
128
|
+
setInvalidPress(0);
|
|
129
|
+
onExit();
|
|
130
|
+
}}
|
|
131
|
+
onFinish={() => void finish(values)}
|
|
132
|
+
nextLoading={state.status === 'submitting'}
|
|
133
|
+
exitLabel={labels.exit}
|
|
134
|
+
backLabel={labels.back}
|
|
135
|
+
nextLabel={labels.next}
|
|
136
|
+
finishLabel={state.status === 'submitting' ? labels.pending : labels.finish}
|
|
137
|
+
pendingLabel={labels.pending}
|
|
138
|
+
cancelLabel={labels.cancel}
|
|
139
|
+
stepsLabel={labels.steps}
|
|
140
|
+
stepsSummaryLabel={labels.stepsSummary}
|
|
141
|
+
skipLinkLabel={labels.skipLink}
|
|
142
|
+
stepStatusText={labels.stepStatusText}
|
|
143
|
+
formatStepCount={labels.formatStepCount}
|
|
144
|
+
stepAnnouncement={() => ''}
|
|
145
|
+
exitTitle={labels.exitTitle}
|
|
146
|
+
exitDescription={labels.exitDescription}
|
|
147
|
+
exitConfirmLabel={labels.exitConfirm}
|
|
148
|
+
exitCancelLabel={labels.exitCancel}
|
|
149
|
+
>
|
|
150
|
+
<section ref={body} aria-labelledby={headingId}>
|
|
151
|
+
<Stack gap={6}>
|
|
152
|
+
<Heading level={1} id={headingId} tabIndex={-1} size="lg">
|
|
153
|
+
{current?.label ?? labels.reviewTitle}
|
|
154
|
+
</Heading>
|
|
155
|
+
{current
|
|
156
|
+
? current.render({
|
|
157
|
+
values,
|
|
158
|
+
setValue: (key, value) => setValues((previous) => ({ ...previous, [key]: value })),
|
|
159
|
+
errors,
|
|
160
|
+
})
|
|
161
|
+
: flowSteps.map((source, index) => (
|
|
162
|
+
<DescriptionListCard
|
|
163
|
+
key={source.id}
|
|
164
|
+
titleAs="h2"
|
|
165
|
+
title={source.label}
|
|
166
|
+
description=""
|
|
167
|
+
editLabel={labels.edit(source.label)}
|
|
168
|
+
onEdit={
|
|
169
|
+
locked
|
|
170
|
+
? undefined
|
|
171
|
+
: () => {
|
|
172
|
+
editFocus.current = true;
|
|
173
|
+
go(index);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
items={rows
|
|
177
|
+
.filter((row) => row.stepId === source.id)
|
|
178
|
+
.map((row, rowIndex) => ({
|
|
179
|
+
id: `${source.id}-${rowIndex}`,
|
|
180
|
+
label: row.label,
|
|
181
|
+
value: row.value || labels.emptyValue,
|
|
182
|
+
}))}
|
|
183
|
+
/>
|
|
184
|
+
))}
|
|
185
|
+
{state.status === 'error' && <p className="text-destructive-text">{errorText}</p>}
|
|
186
|
+
{state.status === 'success' && (
|
|
187
|
+
<div data-flow-success>
|
|
188
|
+
<Heading level={2} tabIndex={-1}>
|
|
189
|
+
{successText}
|
|
190
|
+
</Heading>
|
|
191
|
+
{resource && <Link href={resource}>{labels.openResource}</Link>}
|
|
192
|
+
</div>
|
|
193
|
+
)}
|
|
194
|
+
{blocking && <p className="text-destructive-text">{blocking}</p>}
|
|
195
|
+
</Stack>
|
|
196
|
+
</section>
|
|
197
|
+
</WizardLayout>
|
|
198
|
+
);
|
|
199
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { CreateWizardFlow, type CreateWizardFlowProps } from './Flow';
|
|
1
2
|
import { DescriptionListCard } from '@gntik-ai/blocks';
|
|
2
3
|
import {
|
|
3
4
|
Checkbox,
|
|
@@ -18,7 +19,7 @@ import {
|
|
|
18
19
|
type StepItem,
|
|
19
20
|
type WizardLayoutProps,
|
|
20
21
|
} from '@gntik-ai/ui';
|
|
21
|
-
import { useId, useState, type ReactNode } from 'react';
|
|
22
|
+
import { useId, useState, type ReactNode, type ReactElement } from 'react';
|
|
22
23
|
import {
|
|
23
24
|
PROJECT_NAME_RE,
|
|
24
25
|
wizardCopy,
|
|
@@ -73,7 +74,7 @@ function StepIntro({ id, title, children }: { id: string; title: string; childre
|
|
|
73
74
|
}
|
|
74
75
|
|
|
75
76
|
/** Multi-step create flow in the focus-mode WizardLayout, ending in a review of every answer. */
|
|
76
|
-
|
|
77
|
+
function LegacyCreateWizardPage({
|
|
77
78
|
title = 'New project',
|
|
78
79
|
steps = wizardSteps,
|
|
79
80
|
initialValues = wizardInitialValues,
|
|
@@ -244,3 +245,33 @@ export default function CreateWizardPage({
|
|
|
244
245
|
</WizardLayout>
|
|
245
246
|
);
|
|
246
247
|
}
|
|
248
|
+
|
|
249
|
+
/** flowSteps selects the additive variant; legacy props cannot be combined with it. */
|
|
250
|
+
export default function CreateWizardPage(
|
|
251
|
+
props: Partial<CreateWizardProps> & {
|
|
252
|
+
flowSteps?: never;
|
|
253
|
+
labels?: never;
|
|
254
|
+
summary?: never;
|
|
255
|
+
submitState?: never;
|
|
256
|
+
onComplete?: never;
|
|
257
|
+
resourceHref?: never;
|
|
258
|
+
errorMessage?: never;
|
|
259
|
+
},
|
|
260
|
+
): ReactElement;
|
|
261
|
+
export default function CreateWizardPage<V extends object>(props: CreateWizardFlowProps<V>): ReactElement;
|
|
262
|
+
export default function CreateWizardPage<V extends object>(
|
|
263
|
+
props:
|
|
264
|
+
| CreateWizardFlowProps<V>
|
|
265
|
+
| (Partial<CreateWizardProps> & {
|
|
266
|
+
flowSteps?: never;
|
|
267
|
+
labels?: never;
|
|
268
|
+
summary?: never;
|
|
269
|
+
submitState?: never;
|
|
270
|
+
onComplete?: never;
|
|
271
|
+
resourceHref?: never;
|
|
272
|
+
errorMessage?: never;
|
|
273
|
+
}),
|
|
274
|
+
) {
|
|
275
|
+
if (props.flowSteps !== undefined) return <CreateWizardFlow {...props} />;
|
|
276
|
+
return <LegacyCreateWizardPage {...props} />;
|
|
277
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { expectTypeOf } from 'vitest';
|
|
2
|
+
import type { ReactElement } from 'react';
|
|
3
|
+
import CreateWizardPage from './Page';
|
|
4
|
+
import type { CreateWizardFlowProps } from './Flow';
|
|
5
|
+
import { flowLabels } from './examples/labels';
|
|
6
|
+
|
|
7
|
+
it('preserves legacy async callback contextual typing', () => {
|
|
8
|
+
const navigate = () => {};
|
|
9
|
+
const page = <CreateWizardPage onFinish={() => new Promise<void>((resolve) => resolve()).then(() => navigate())} />;
|
|
10
|
+
expectTypeOf(page).toMatchTypeOf<ReactElement>();
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('keeps legacy props separate from caller-defined flow props', () => {
|
|
14
|
+
// Compile-only public API checks; these functions are never invoked.
|
|
15
|
+
const check = (flow: CreateWizardFlowProps<{ name: string }>) => {
|
|
16
|
+
// @ts-expect-error flow mode cannot mix with the legacy copy model
|
|
17
|
+
CreateWizardPage({ ...flow, copy: {} });
|
|
18
|
+
// @ts-expect-error flow mode cannot mix with legacy Stepper-only steps
|
|
19
|
+
CreateWizardPage({ ...flow, steps: [] });
|
|
20
|
+
};
|
|
21
|
+
expectTypeOf(check).toBeFunction();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('infers caller value keys and setter types through the public page', () => {
|
|
25
|
+
const page = (
|
|
26
|
+
<CreateWizardPage
|
|
27
|
+
initialValues={{ email: '', count: 0 }}
|
|
28
|
+
labels={flowLabels}
|
|
29
|
+
onExit={() => {}}
|
|
30
|
+
flowSteps={[
|
|
31
|
+
{
|
|
32
|
+
id: 'recipient',
|
|
33
|
+
label: 'Recipient',
|
|
34
|
+
render: ({ values, setValue }) => {
|
|
35
|
+
expectTypeOf(values.email).toEqualTypeOf<string>();
|
|
36
|
+
expectTypeOf(values.count).toEqualTypeOf<number>();
|
|
37
|
+
// @ts-expect-error caller numeric values reject strings
|
|
38
|
+
setValue('count', 'wrong type');
|
|
39
|
+
// @ts-expect-error setters accept caller keys only
|
|
40
|
+
setValue('unknown', 'value');
|
|
41
|
+
return values.email;
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
]}
|
|
45
|
+
summary={(values) => [{ stepId: 'recipient', label: 'Email', value: values.email }]}
|
|
46
|
+
onFinish={async (values) => {
|
|
47
|
+
expectTypeOf(values.email).toEqualTypeOf<string>();
|
|
48
|
+
}}
|
|
49
|
+
/>
|
|
50
|
+
);
|
|
51
|
+
expectTypeOf(page).toMatchTypeOf<ReactElement>();
|
|
52
|
+
});
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import FlowExample from './FlowExample';
|
|
2
|
+
|
|
3
|
+
/** A snapshot of caller-owned submit state. See template.meta.ts for the abort-aware handler. */
|
|
4
|
+
export default function ControlledSuccess() {
|
|
5
|
+
return <FlowExample submitState={{ status: 'success', result: '#created-resource' }} />;
|
|
6
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { Field, FieldLabel, Input, LiveAnnouncer } from '@gntik-ai/ui';
|
|
2
|
+
import { useState } from 'react';
|
|
3
|
+
import CreateWizardPage from '../Page';
|
|
4
|
+
import type { CreateWizardSubmitState } from '../Flow';
|
|
5
|
+
import { flowLabels } from './labels';
|
|
6
|
+
|
|
7
|
+
/** Keep the announcer at page level if onComplete closes the wizard. */
|
|
8
|
+
export default function FlowExample({
|
|
9
|
+
outcome = 'success',
|
|
10
|
+
submitState,
|
|
11
|
+
}: {
|
|
12
|
+
outcome?: 'pending' | 'success' | 'error';
|
|
13
|
+
submitState?: CreateWizardSubmitState;
|
|
14
|
+
}) {
|
|
15
|
+
const [controlled] = useState(() => submitState !== undefined);
|
|
16
|
+
const [callerState, setCallerState] = useState<CreateWizardSubmitState>(submitState ?? { status: 'idle' });
|
|
17
|
+
const target = submitState?.status === 'submitting' ? 'pending' : submitState?.status === 'error' ? 'error' : outcome;
|
|
18
|
+
return (
|
|
19
|
+
<LiveAnnouncer>
|
|
20
|
+
<CreateWizardPage
|
|
21
|
+
labels={flowLabels}
|
|
22
|
+
initialValues={{ name: 'Example resource' }}
|
|
23
|
+
flowSteps={[
|
|
24
|
+
{
|
|
25
|
+
id: 'details',
|
|
26
|
+
label: 'Resource details',
|
|
27
|
+
render: ({ values, setValue }) => (
|
|
28
|
+
<Field>
|
|
29
|
+
<FieldLabel>Resource name</FieldLabel>
|
|
30
|
+
<Input value={values.name} onValueChange={(value) => setValue('name', value)} />
|
|
31
|
+
</Field>
|
|
32
|
+
),
|
|
33
|
+
},
|
|
34
|
+
]}
|
|
35
|
+
summary={(values) => [{ stepId: 'details', label: 'Resource name', value: values.name }]}
|
|
36
|
+
submitState={controlled ? callerState : undefined}
|
|
37
|
+
resourceHref={(result) => (typeof result === 'string' ? result : undefined)}
|
|
38
|
+
onExit={() => {
|
|
39
|
+
if (controlled) setCallerState({ status: 'idle' });
|
|
40
|
+
}}
|
|
41
|
+
onFinish={async (_values, { signal }) => {
|
|
42
|
+
if (controlled) setCallerState({ status: 'submitting' });
|
|
43
|
+
try {
|
|
44
|
+
// Commit the caller-owned pending state before this example transport settles.
|
|
45
|
+
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
|
|
46
|
+
if (signal.aborted) return;
|
|
47
|
+
if (target === 'pending') return await new Promise<unknown>(() => {});
|
|
48
|
+
if (target === 'error') throw new Error('Example transport failure');
|
|
49
|
+
const result = '#created-resource';
|
|
50
|
+
if (controlled && !signal.aborted) setCallerState({ status: 'success', result });
|
|
51
|
+
return result;
|
|
52
|
+
} catch (error: unknown) {
|
|
53
|
+
if (controlled && !signal.aborted) setCallerState({ status: 'error', error });
|
|
54
|
+
throw error;
|
|
55
|
+
}
|
|
56
|
+
}}
|
|
57
|
+
/>
|
|
58
|
+
</LiveAnnouncer>
|
|
59
|
+
);
|
|
60
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { render, screen, waitFor } from '@testing-library/react';
|
|
2
|
+
import userEvent from '@testing-library/user-event';
|
|
3
|
+
import Pending from './UncontrolledPending';
|
|
4
|
+
import Success from './UncontrolledSuccess';
|
|
5
|
+
import ErrorExample from './UncontrolledError';
|
|
6
|
+
import ControlledIdle from './ControlledIdle';
|
|
7
|
+
import ControlledSubmitting from './ControlledSubmitting';
|
|
8
|
+
import ControlledSuccess from './ControlledSuccess';
|
|
9
|
+
import ControlledError from './ControlledError';
|
|
10
|
+
import { expectNoAxeViolations } from '../../test/a11y';
|
|
11
|
+
|
|
12
|
+
it.each([
|
|
13
|
+
['pending', Pending],
|
|
14
|
+
['success', Success],
|
|
15
|
+
['error', ErrorExample],
|
|
16
|
+
['idle', ControlledIdle],
|
|
17
|
+
['submitting', ControlledSubmitting],
|
|
18
|
+
['success', ControlledSuccess],
|
|
19
|
+
['error', ControlledError],
|
|
20
|
+
] as const)('walks and renders the %s example without accessibility violations', async (state, Example) => {
|
|
21
|
+
const user = userEvent.setup();
|
|
22
|
+
const { container } = render(<Example />);
|
|
23
|
+
if (screen.queryByRole('button', { name: 'Next' })) {
|
|
24
|
+
await expectNoAxeViolations(container);
|
|
25
|
+
await user.click(screen.getByRole('button', { name: 'Next' }));
|
|
26
|
+
await expectNoAxeViolations(container);
|
|
27
|
+
await user.click(screen.getByRole('button', { name: 'Create resource' }));
|
|
28
|
+
}
|
|
29
|
+
if (state === 'pending' || state === 'submitting')
|
|
30
|
+
expect(screen.getByRole('button', { name: 'Creating resource…' })).toHaveAttribute('aria-disabled', 'true');
|
|
31
|
+
if (state === 'success') expect(await screen.findByRole('heading', { name: 'Resource created' })).toBeInTheDocument();
|
|
32
|
+
if (state === 'error')
|
|
33
|
+
expect(await screen.findByText('Could not create the resource. Try again.', { selector: 'p' })).toBeInTheDocument();
|
|
34
|
+
await expectNoAxeViolations(container);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('resets a controlled example to idle when exit is confirmed', async () => {
|
|
38
|
+
const user = userEvent.setup();
|
|
39
|
+
render(<ControlledSubmitting />);
|
|
40
|
+
await user.click(screen.getByRole('button', { name: 'Exit' }));
|
|
41
|
+
await user.click(await screen.findByRole('button', { name: 'Leave' }));
|
|
42
|
+
const next = await screen.findByRole('button', { name: 'Next' });
|
|
43
|
+
expect(next).not.toHaveAttribute('aria-disabled', 'true');
|
|
44
|
+
await user.click(next);
|
|
45
|
+
expect(screen.getByRole('heading', { name: 'Review resource' })).toBeInTheDocument();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('passes through controlled submitting before announcing completion', async () => {
|
|
49
|
+
const user = userEvent.setup();
|
|
50
|
+
const { container } = render(<ControlledIdle />);
|
|
51
|
+
await user.click(screen.getByRole('button', { name: 'Next' }));
|
|
52
|
+
await user.click(screen.getByRole('button', { name: 'Create resource' }));
|
|
53
|
+
await screen.findByRole('heading', { name: 'Resource created' });
|
|
54
|
+
await waitFor(() =>
|
|
55
|
+
expect(container.querySelector('[data-live-announcer="polite"]')).toHaveTextContent('Resource created'),
|
|
56
|
+
);
|
|
57
|
+
});
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { render, screen, waitFor, within } from '@testing-library/react';
|
|
2
|
+
import userEvent from '@testing-library/user-event';
|
|
3
|
+
import Invite from './Invite';
|
|
4
|
+
|
|
5
|
+
it('invites one recipient with quota revalidation, three roles and a 500-character optional message', async () => {
|
|
6
|
+
const user = userEvent.setup();
|
|
7
|
+
const onFinish = vi.fn(async () => undefined);
|
|
8
|
+
const { rerender } = render(<Invite members={{ count: 3, limit: 3 }} onFinish={onFinish} />);
|
|
9
|
+
expect(screen.getByText('Example team')).toBeInTheDocument();
|
|
10
|
+
expect(screen.queryByRole('textbox')).not.toBeInTheDocument();
|
|
11
|
+
await user.click(screen.getByRole('button', { name: 'Next' }));
|
|
12
|
+
expect(screen.getByRole('button', { name: 'Next' })).toBeDisabled();
|
|
13
|
+
await waitFor(() =>
|
|
14
|
+
expect(document.querySelector('[data-live-announcer="polite"]')).toHaveTextContent(
|
|
15
|
+
'Your member limit has been reached.',
|
|
16
|
+
),
|
|
17
|
+
);
|
|
18
|
+
rerender(<Invite members={{ count: 2, limit: 3 }} onFinish={onFinish} />);
|
|
19
|
+
expect(screen.getByRole('button', { name: 'Next' })).toBeEnabled();
|
|
20
|
+
const email = screen.getByRole('textbox', { name: 'Email address' });
|
|
21
|
+
await user.type(email, 'bad@');
|
|
22
|
+
await user.click(screen.getByRole('button', { name: 'Next' }));
|
|
23
|
+
expect(email).toHaveFocus();
|
|
24
|
+
expect(screen.getByText('Enter a valid email address.')).toBeInTheDocument();
|
|
25
|
+
await user.clear(email);
|
|
26
|
+
await user.type(email, 'person@example.com');
|
|
27
|
+
await user.click(screen.getByRole('button', { name: 'Next' }));
|
|
28
|
+
await user.click(screen.getByRole('combobox', { name: 'Role' }));
|
|
29
|
+
expect(within(await screen.findByRole('listbox')).getAllByRole('option')).toHaveLength(3);
|
|
30
|
+
for (const role of ['Owner', 'Admin', 'Member'])
|
|
31
|
+
expect(screen.getByRole('option', { name: role })).toBeInTheDocument();
|
|
32
|
+
await user.click(screen.getByRole('option', { name: 'Admin' }));
|
|
33
|
+
await user.click(screen.getByRole('button', { name: 'Next' }));
|
|
34
|
+
const message = screen.getByRole('textbox', { name: 'Message (optional)' });
|
|
35
|
+
expect(message).toHaveAttribute('maxlength', '500');
|
|
36
|
+
await user.click(message);
|
|
37
|
+
await user.paste('Welcome to the team.');
|
|
38
|
+
await user.click(screen.getByRole('button', { name: 'Next' }));
|
|
39
|
+
expect(screen.getByText('person@example.com')).toBeInTheDocument();
|
|
40
|
+
expect(screen.getByText('Admin')).toBeInTheDocument();
|
|
41
|
+
expect(screen.getByText('Welcome to the team.')).toBeInTheDocument();
|
|
42
|
+
await user.click(screen.getByRole('button', { name: 'Send invitation' }));
|
|
43
|
+
expect(onFinish).toHaveBeenCalledExactlyOnceWith(
|
|
44
|
+
{ workspaceId: 'example-team', email: 'person@example.com', role: 'admin', message: 'Welcome to the team.' },
|
|
45
|
+
{ signal: expect.any(AbortSignal) },
|
|
46
|
+
);
|
|
47
|
+
});
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { Field, FieldError, FieldLabel, Input, LiveAnnouncer, SimpleSelect, Textarea } from '@gntik-ai/ui';
|
|
2
|
+
import CreateWizardPage from '../Page';
|
|
3
|
+
import type { CreateWizardFlowProps, CreateWizardFlowStep } from '../Flow';
|
|
4
|
+
import { flowLabels } from './labels';
|
|
5
|
+
|
|
6
|
+
interface InviteValues {
|
|
7
|
+
workspaceId: string;
|
|
8
|
+
email: string;
|
|
9
|
+
role: string;
|
|
10
|
+
message: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// Same single-address format and three-role semantics as InviteMembersDialog.
|
|
14
|
+
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
15
|
+
const copy = {
|
|
16
|
+
...flowLabels,
|
|
17
|
+
title: 'Invite a member',
|
|
18
|
+
finish: 'Send invitation',
|
|
19
|
+
pending: 'Sending invitation…',
|
|
20
|
+
reviewTitle: 'Review invitation',
|
|
21
|
+
steps: 'Invitation progress',
|
|
22
|
+
stepsSummary: 'Invitation progress summary',
|
|
23
|
+
success: () => 'Invitation sent',
|
|
24
|
+
errorFallback: 'Could not send the invitation. Try again.',
|
|
25
|
+
openResource: 'Open member',
|
|
26
|
+
workspace: 'Workspace',
|
|
27
|
+
workspaceValue: 'Example team',
|
|
28
|
+
email: 'Email address',
|
|
29
|
+
emailError: 'Enter a valid email address.',
|
|
30
|
+
quota: 'Your member limit has been reached.',
|
|
31
|
+
role: 'Role',
|
|
32
|
+
rolePlaceholder: 'Choose a role',
|
|
33
|
+
message: 'Message (optional)',
|
|
34
|
+
roleItems: [
|
|
35
|
+
{ value: 'owner', label: 'Owner' },
|
|
36
|
+
{ value: 'admin', label: 'Admin' },
|
|
37
|
+
{ value: 'member', label: 'Member' },
|
|
38
|
+
],
|
|
39
|
+
};
|
|
40
|
+
const initialValues: InviteValues = { workspaceId: 'example-team', email: '', role: 'member', message: '' };
|
|
41
|
+
const submit = async (values: InviteValues) => ({ email: values.email });
|
|
42
|
+
|
|
43
|
+
/** Generic single-recipient invite; members.count models a caller-owned quota. */
|
|
44
|
+
export default function Invite({
|
|
45
|
+
members = { count: 1, limit: 5 },
|
|
46
|
+
onFinish = submit,
|
|
47
|
+
}: {
|
|
48
|
+
members?: { count: number; limit: number };
|
|
49
|
+
onFinish?: CreateWizardFlowProps<InviteValues>['onFinish'];
|
|
50
|
+
}) {
|
|
51
|
+
const flowSteps: CreateWizardFlowStep<InviteValues>[] = [
|
|
52
|
+
{ id: 'workspace', label: copy.workspace, readOnly: true, render: () => <p>{copy.workspaceValue}</p> },
|
|
53
|
+
{
|
|
54
|
+
id: 'email',
|
|
55
|
+
label: copy.email,
|
|
56
|
+
validate: (values) => ({
|
|
57
|
+
fieldErrors: EMAIL_RE.test(values.email) ? {} : { email: copy.emailError },
|
|
58
|
+
blocking: members.count >= members.limit ? copy.quota : undefined,
|
|
59
|
+
}),
|
|
60
|
+
render: ({ values, setValue, errors }) => (
|
|
61
|
+
<Field invalid={Boolean(errors.email)}>
|
|
62
|
+
<FieldLabel>{copy.email}</FieldLabel>
|
|
63
|
+
<Input type="email" value={values.email} onValueChange={(value) => setValue('email', value)} />
|
|
64
|
+
<FieldError match={Boolean(errors.email)}>{errors.email}</FieldError>
|
|
65
|
+
</Field>
|
|
66
|
+
),
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
id: 'role',
|
|
70
|
+
label: copy.role,
|
|
71
|
+
render: ({ values, setValue }) => (
|
|
72
|
+
<SimpleSelect
|
|
73
|
+
label={copy.role}
|
|
74
|
+
placeholder={copy.rolePlaceholder}
|
|
75
|
+
items={copy.roleItems}
|
|
76
|
+
value={values.role}
|
|
77
|
+
onValueChange={(value) => value && setValue('role', value)}
|
|
78
|
+
/>
|
|
79
|
+
),
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
id: 'message',
|
|
83
|
+
label: copy.message,
|
|
84
|
+
render: ({ values, setValue }) => (
|
|
85
|
+
<Field>
|
|
86
|
+
<FieldLabel>{copy.message}</FieldLabel>
|
|
87
|
+
<Textarea
|
|
88
|
+
rows={4}
|
|
89
|
+
maxLength={500}
|
|
90
|
+
value={values.message}
|
|
91
|
+
onValueChange={(value) => setValue('message', value)}
|
|
92
|
+
/>
|
|
93
|
+
</Field>
|
|
94
|
+
),
|
|
95
|
+
},
|
|
96
|
+
];
|
|
97
|
+
return (
|
|
98
|
+
<LiveAnnouncer>
|
|
99
|
+
<CreateWizardPage
|
|
100
|
+
labels={copy}
|
|
101
|
+
initialValues={initialValues}
|
|
102
|
+
flowSteps={flowSteps}
|
|
103
|
+
summary={(values) => [
|
|
104
|
+
{ stepId: 'workspace', label: copy.workspace, value: copy.workspaceValue },
|
|
105
|
+
{ stepId: 'email', label: copy.email, value: values.email },
|
|
106
|
+
{ stepId: 'role', label: copy.role, value: copy.roleItems.find((role) => role.value === values.role)?.label },
|
|
107
|
+
{ stepId: 'message', label: copy.message, value: values.message },
|
|
108
|
+
]}
|
|
109
|
+
onFinish={onFinish}
|
|
110
|
+
onExit={() => {}}
|
|
111
|
+
/>
|
|
112
|
+
</LiveAnnouncer>
|
|
113
|
+
);
|
|
114
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { CreateWizardFlowLabels } from '../Flow';
|
|
2
|
+
|
|
3
|
+
/** A page namespace: every template-owned string is supplied here. */
|
|
4
|
+
export const flowLabels: CreateWizardFlowLabels = {
|
|
5
|
+
title: 'Create resource',
|
|
6
|
+
exit: 'Exit',
|
|
7
|
+
cancel: 'Cancel',
|
|
8
|
+
back: 'Back',
|
|
9
|
+
next: 'Next',
|
|
10
|
+
finish: 'Create resource',
|
|
11
|
+
pending: 'Creating resource…',
|
|
12
|
+
review: 'Review',
|
|
13
|
+
reviewTitle: 'Review resource',
|
|
14
|
+
edit: (label) => `Edit ${label}`,
|
|
15
|
+
emptyValue: 'Not provided',
|
|
16
|
+
success: () => 'Resource created',
|
|
17
|
+
errorFallback: 'Could not create the resource. Try again.',
|
|
18
|
+
openResource: 'Open resource',
|
|
19
|
+
exitTitle: 'Leave this flow?',
|
|
20
|
+
exitDescription: 'Your answers will be discarded.',
|
|
21
|
+
exitConfirm: 'Leave',
|
|
22
|
+
exitCancel: 'Stay',
|
|
23
|
+
steps: 'Creation progress',
|
|
24
|
+
stepsSummary: 'Creation progress summary',
|
|
25
|
+
skipLink: 'Skip to step',
|
|
26
|
+
stepStatusText: { complete: 'completed', current: 'current', upcoming: 'upcoming', error: 'error' },
|
|
27
|
+
formatStepCount: (position, total) => `Step ${position} of ${total}`,
|
|
28
|
+
stepAnnouncement: ({ position, total, label }) => `Step ${position} of ${total}: ${label}`,
|
|
29
|
+
};
|