Skip to content

Commit 7a01077

Browse files
feat(mosaic): Wire up password section (#9930)
1 parent c6e11c9 commit 7a01077

39 files changed

Lines changed: 1800 additions & 680 deletions
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
---
2+
---

‎packages/mosaic/src/__tests__/feature/fake-fapi.ts‎

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ export interface FakeFapiState {
3939
suggestions: OrganizationSuggestionJSON[];
4040
apiKeys: ApiKeyJSON[];
4141
verification: FakeVerificationState;
42+
passwordUpdates: URLSearchParams[];
4243
}
4344

4445
export type FakeFapiSeed = Partial<Omit<FakeFapiState, 'verification'>> & {
@@ -100,6 +101,7 @@ export function serveFapi(seed: FakeFapiSeed = {}): FakeFapiState {
100101
invitations: [],
101102
suggestions: [],
102103
apiKeys: [],
104+
passwordUpdates: [],
103105
...rest,
104106
verification: createVerificationState(verification),
105107
};
@@ -144,6 +146,19 @@ export function serveFapi(seed: FakeFapiSeed = {}): FakeFapiState {
144146
state.client = { ...state.client, sessions, last_active_session_id: sessions[0]?.id ?? null };
145147
return envelope({ ...session, status: 'removed' }, state.client);
146148
}),
149+
http.post(fapiUrl('/v1/me/change_password'), async ({ request }) => {
150+
const session = findSession(state, state.client.last_active_session_id);
151+
if (!session) {
152+
return missing();
153+
}
154+
state.passwordUpdates.push(new URLSearchParams(await request.text()));
155+
const updatedUser = { ...session.user, password_enabled: true };
156+
state.client = {
157+
...state.client,
158+
sessions: state.client.sessions.map(item => (item.id === session.id ? { ...item, user: updatedUser } : item)),
159+
};
160+
return envelope(updatedUser, state.client);
161+
}),
147162
http.post(fapiUrl('/v1/client/sessions'), ({ request }) => {
148163
if (new URL(request.url).searchParams.get('_method') !== 'DELETE') {
149164
return undefined;
@@ -224,7 +239,7 @@ export function serveFapi(seed: FakeFapiSeed = {}): FakeFapiState {
224239
export interface HeldRequests {
225240
requests: Request[];
226241
release: () => void;
227-
fail: (code?: string) => void;
242+
fail: (code?: string, longMessage?: string, paramName?: string) => void;
228243
}
229244

230245
interface Hold {
@@ -266,6 +281,21 @@ export function holdRequests(method: 'get' | 'post', path: string): HeldRequests
266281
return {
267282
requests,
268283
release: () => settle(undefined),
269-
fail: (code = 'form_param_invalid') => settle(error(code)),
284+
fail: (code = 'form_param_invalid', longMessage = code, paramName?: string) =>
285+
settle(
286+
HttpResponse.json(
287+
{
288+
errors: [
289+
{
290+
code,
291+
message: code,
292+
long_message: longMessage,
293+
...(paramName ? { meta: { param_name: paramName } } : {}),
294+
},
295+
],
296+
},
297+
{ status: 400 },
298+
),
299+
),
270300
};
271301
}

‎packages/mosaic/src/__tests__/feature/fapi.ts‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import type {
77
ClientJSON,
88
DisplayConfigJSON,
99
EmailAddressJSON,
10+
EnterpriseAccountJSON,
1011
EnvironmentJSON,
1112
OAuthProviders,
1213
OrganizationJSON,
@@ -271,6 +272,44 @@ export function fapiUser(overrides: Partial<UserJSON> & Pick<UserJSON, 'id'>): U
271272
};
272273
}
273274

275+
export function fapiEnterpriseAccount(
276+
overrides: Partial<EnterpriseAccountJSON> & Pick<EnterpriseAccountJSON, 'id'>,
277+
): EnterpriseAccountJSON {
278+
return {
279+
object: 'enterprise_account',
280+
active: true,
281+
email_address: 'sso@example.com',
282+
enterprise_connection: {
283+
object: 'enterprise_connection',
284+
id: 'sso_1',
285+
active: true,
286+
allow_idp_initiated: false,
287+
allow_subdomains: false,
288+
disable_additional_identifications: true,
289+
domain: 'example.com',
290+
logo_public_url: null,
291+
name: 'Company SSO',
292+
protocol: 'saml',
293+
provider: 'saml_okta',
294+
sync_user_attributes: false,
295+
allow_organization_account_linking: false,
296+
created_at: createdAt,
297+
updated_at: createdAt,
298+
enterprise_connection_id: 'sso_1',
299+
},
300+
first_name: null,
301+
last_name: null,
302+
protocol: 'saml',
303+
provider: 'saml_okta',
304+
provider_user_id: null,
305+
public_metadata: {},
306+
verification: null,
307+
last_authenticated_at: null,
308+
enterprise_connection_id: 'sso_1',
309+
...overrides,
310+
};
311+
}
312+
274313
export function fapiSession(overrides: Partial<SessionJSON> & Pick<SessionJSON, 'id' | 'user'>): SessionJSON {
275314
const { user } = overrides;
276315
const primaryEmail = user.email_addresses.find(email => email.id === user.primary_email_address_id);

‎packages/mosaic/src/components/field/field.test.tsx‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { renderToString } from 'react-dom/server';
77
import { afterEach, describe, expect, it, vi } from 'vitest';
88

99
import { Checkbox } from '../checkbox';
10+
import type { FieldFeedback } from '../form';
1011
import { Input } from '../input';
1112
import { Select } from '../select';
1213
import { Field } from './field';
@@ -37,6 +38,41 @@ function stubPrototype(target: object, name: string, descriptor: PropertyDescrip
3738
}
3839

3940
describe('Mosaic Field', () => {
41+
it.each<{ type: FieldFeedback['type']; slot: string }>([
42+
{ type: 'error', slot: 'cl-field-error' },
43+
{ type: 'success', slot: 'cl-field-success' },
44+
{ type: 'info', slot: 'cl-field-info' },
45+
{ type: 'warning', slot: 'cl-field-info' },
46+
])('associates $type feedback with its control', ({ type, slot }) => {
47+
render(
48+
<Field.Root>
49+
<Field.Label>Password</Field.Label>
50+
<Input />
51+
<Field.Feedback feedback={{ type, message: 'Password feedback' }} />
52+
</Field.Root>,
53+
);
54+
expect(screen.getByRole('textbox', { name: 'Password' })).toHaveAccessibleDescription('Password feedback');
55+
expect(screen.getByText('Password feedback').closest('p')).toHaveClass(slot);
56+
expect(screen.getByRole('status')).toHaveTextContent('Password feedback');
57+
});
58+
it('associates text-only info feedback with the control in a live region', () => {
59+
render(
60+
<Field.Root>
61+
<Field.Label>Password</Field.Label>
62+
<Input />
63+
<Field.Message>
64+
<Field.Hint>Try a longer password.</Field.Hint>
65+
</Field.Message>
66+
</Field.Root>,
67+
);
68+
69+
expect(screen.getByRole('textbox', { name: 'Password' })).toHaveAccessibleDescription('Try a longer password.');
70+
expect(screen.getByRole('status')).toHaveTextContent('Try a longer password.');
71+
const info = screen.getByText('Try a longer password.').closest('p');
72+
expect(info).toHaveClass('cl-field-info');
73+
expect(info?.querySelector('svg')).toBeNull();
74+
});
75+
4076
afterEach(() => {
4177
restores.splice(0).forEach(restore => restore());
4278
});

‎packages/mosaic/src/components/field/field.tsx‎

Lines changed: 50 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { feedbackHeight, feedbackStyles } from '../../utils/feedback.styles';
1111
import { reset } from '../../utils/reset.styles';
1212
import { sizes as typographySizes, styles as typographyStyles } from '../../utils/typography.styles';
1313
import { visuallyHidden } from '../../utils/visually-hidden.styles';
14+
import type { FieldFeedback as FormFieldFeedback } from '../form';
1415
import type { FieldOrientation } from './field.context';
1516
import {
1617
FieldMessageProvider,
@@ -202,18 +203,19 @@ const Message = React.forwardRef<HTMLDivElement, FieldMessageProps>(function Mos
202203
return <FieldMessageProvider register={register}>{rendered}</FieldMessageProvider>;
203204
});
204205

205-
type FieldFeedbackKind = 'error' | 'success';
206+
type FieldFeedbackKind = 'error' | 'success' | 'info';
206207

207-
const FEEDBACK: Record<FieldFeedbackKind, { slot: string; icon: IconName; color: stylex.StyleXStyles }> = {
208+
const FEEDBACK: Record<FieldFeedbackKind, { slot: string; icon?: IconName; color: stylex.StyleXStyles }> = {
208209
error: { slot: 'field-error', icon: 'exclamation-circle', color: feedbackStyles.error },
209210
success: { slot: 'field-success', icon: 'checkmark', color: feedbackStyles.success },
211+
info: { slot: 'field-info', color: feedbackStyles.info },
210212
};
211213

212-
interface FieldFeedbackProps extends MosaicComponentProps<'p'> {
214+
interface FieldFeedbackPartProps extends MosaicComponentProps<'p'> {
213215
kind: FieldFeedbackKind;
214216
}
215217

216-
const FieldFeedback = React.forwardRef<HTMLParagraphElement, FieldFeedbackProps>(function MosaicFieldFeedback(
218+
const FieldFeedback = React.forwardRef<HTMLParagraphElement, FieldFeedbackPartProps>(function MosaicFieldFeedback(
217219
{ render, xstyle, id: idProp, children, kind, ...rest },
218220
ref,
219221
) {
@@ -240,7 +242,7 @@ const FieldFeedback = React.forwardRef<HTMLParagraphElement, FieldFeedbackProps>
240242
rest,
241243
),
242244
id,
243-
children: <FeedbackBody icon={icon}>{message}</FeedbackBody>,
245+
children: icon ? <FeedbackBody icon={icon}>{message}</FeedbackBody> : message,
244246
},
245247
});
246248
});
@@ -271,5 +273,47 @@ const FieldSuccess = React.forwardRef<HTMLParagraphElement, FieldSuccessProps>(f
271273
);
272274
});
273275

276+
export type FieldHintProps = MosaicComponentProps<'p'>;
277+
278+
const FieldHint = React.forwardRef<HTMLParagraphElement, FieldHintProps>(function MosaicFieldHint(props, ref) {
279+
return (
280+
<FieldFeedback
281+
ref={ref}
282+
kind='info'
283+
{...props}
284+
/>
285+
);
286+
});
287+
288+
export interface FieldFeedbackProps extends Omit<FieldMessageProps, 'children'> {
289+
feedback?: FormFieldFeedback;
290+
}
291+
292+
const Feedback = React.forwardRef<HTMLDivElement, FieldFeedbackProps>(function MosaicFieldFeedbackValue(
293+
{ feedback, ...props },
294+
ref,
295+
) {
296+
return (
297+
<Message
298+
ref={ref}
299+
{...props}
300+
>
301+
<FieldError>{feedback?.type === 'error' ? feedback.message : null}</FieldError>
302+
<FieldHint>{feedback?.type === 'info' || feedback?.type === 'warning' ? feedback.message : null}</FieldHint>
303+
<FieldSuccess>{feedback?.type === 'success' ? feedback.message : null}</FieldSuccess>
304+
</Message>
305+
);
306+
});
307+
274308
/** Styled parts for composing an automatically associated single-control field. */
275-
export const Field = { Root, Label, Content, Description, Message, Error: FieldError, Success: FieldSuccess };
309+
export const Field = {
310+
Root,
311+
Label,
312+
Content,
313+
Description,
314+
Message,
315+
Feedback,
316+
Error: FieldError,
317+
Success: FieldSuccess,
318+
Hint: FieldHint,
319+
};

‎packages/mosaic/src/components/field/index.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ export { Field } from './field';
22
export type {
33
FieldDescriptionProps,
44
FieldErrorProps,
5+
FieldFeedbackProps,
6+
FieldHintProps,
57
FieldLabelProps,
68
FieldMessageProps,
79
FieldRootProps,

‎packages/mosaic/src/components/form/use-form.edit-password.test.ts‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,24 @@ function useEditPasswordForm(onSubmit: (values: EditPasswordValues) => Promise<v
4545
}
4646

4747
describe('useForm: edit password', () => {
48+
it('reports submission immediately within the submit event', async () => {
49+
let finish = () => {};
50+
const onSubmit = () =>
51+
new Promise<void>(resolve => {
52+
finish = resolve;
53+
});
54+
const { result } = renderHook(() => useForm({ initialValues: { password: 'secret' }, onSubmit }));
55+
const form = result.current;
56+
act(() => {
57+
form.submit();
58+
expect(form.isSubmitting).toBe(true);
59+
});
60+
await act(async () => {
61+
finish();
62+
await flush();
63+
});
64+
expect(form.isSubmitting).toBe(false);
65+
});
4866
it('walks a user from a weak password to a saved one', async () => {
4967
const onSubmit = vi.fn(() => Promise.resolve());
5068
const { result } = renderHook(() => useEditPasswordForm(onSubmit));

‎packages/mosaic/src/components/form/use-form.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -191,7 +191,10 @@ export function useForm<TValues extends object>(options: UseFormOptions<TValues>
191191
values,
192192
fields,
193193
error: context.error?.message,
194-
isSubmitting,
194+
get isSubmitting() {
195+
const current = actor.getSnapshot();
196+
return current.value === 'submitting' || current.context.submitQueued;
197+
},
195198
isDirty: keysOf(values).some(name => fields[name].isDirty),
196199
canSubmit: !isSubmitting && isValid(context),
197200
register,

0 commit comments

Comments
 (0)