Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
3df0be7
feat(mosaic): wire user profile account section to Clerk
alexcarpenter Sep 21, 2026
5d54a39
refactor(mosaic): use shared snakeToCamel
alexcarpenter Sep 21, 2026
3fd8b31
refactor(mosaic): move save result helpers to utils
alexcarpenter Sep 21, 2026
b116533
fix(mosaic): make save errors localizable and rethrow unexpected errors
alexcarpenter Sep 21, 2026
5c9d800
chore(repo): add empty changeset
alexcarpenter Sep 21, 2026
30a2e4b
refactor(mosaic): make unexpected save errors explicit and drop dead …
alexcarpenter Sep 21, 2026
3f7110b
refactor(mosaic): drop reverification from the account section
alexcarpenter Sep 22, 2026
6600231
fix(mosaic): clear a rejected picture pick when removing
alexcarpenter Sep 22, 2026
c3fd5a1
fix(mosaic): offer to set a username the user does not have
alexcarpenter Sep 22, 2026
467fe63
fix(mosaic): hide contacts the instance does not collect
alexcarpenter Sep 22, 2026
3b0d8c7
fix(mosaic): always separate the account section's contact cards
alexcarpenter Sep 22, 2026
43c425c
fix(mosaic): drop the name row's description when there is no name
alexcarpenter Sep 22, 2026
bb77fa5
fix(mosaic): offer to set a name the user does not have
alexcarpenter Sep 22, 2026
e51c8fb
refactor(mosaic): carry save failures on one rejection
alexcarpenter Sep 23, 2026
b247231
fix(mosaic): offer to add the name and username the user has not set
alexcarpenter Sep 23, 2026
ab6e113
fix(repo): move the swingset fixtures onto the save rejection
alexcarpenter Sep 23, 2026
79310f3
fix(mosaic): drop the removed save result from the panel view tests
alexcarpenter Sep 23, 2026
ad289e8
test(mosaic): leave the row gating to the model test
alexcarpenter Sep 23, 2026
a66d666
fix(mosaic): hold the name save until the name changes
alexcarpenter Sep 23, 2026
b7248e3
chore(repo): ignore the vitest cache directory
alexcarpenter Sep 23, 2026
584760c
feat(mosaic): state why a section row offers no action
alexcarpenter Sep 23, 2026
5d8de00
feat(mosaic): show the picked avatar while it uploads
alexcarpenter Sep 23, 2026
46493e6
fix(mosaic): name the username length the instance actually requires
alexcarpenter Sep 23, 2026
3910ab4
feat(mosaic): name the connection managing a row's value
alexcarpenter Sep 23, 2026
07b30e1
refactor(mosaic): move the legacy save error to the password section
alexcarpenter Sep 24, 2026
6dad14a
feat(mosaic): localize save errors in useForm
alexcarpenter Sep 25, 2026
957a663
refactor(mosaic): drive the name form with useForm
alexcarpenter Sep 25, 2026
8e13b12
refactor(mosaic): drive the username form with useForm
alexcarpenter Sep 25, 2026
ee8be8b
fix(mosaic): show the generic message and log unrecognized save error…
alexcarpenter Sep 25, 2026
0ca71a2
Merge remote-tracking branch 'origin/main' into carp/mosaic-user-prof…
alexcarpenter Sep 30, 2026
22d36ac
Merge remote-tracking branch 'origin/main' into carp/mosaic-user-prof…
alexcarpenter Oct 2, 2026
76680ca
fix(mosaic): scope account-section saves to the user they were compos…
alexcarpenter Oct 2, 2026
685081d
Merge remote-tracking branch 'origin/main' into carp/mosaic-user-prof…
alexcarpenter Oct 2, 2026
8c22ddf
Merge remote-tracking branch 'origin/main' into carp/mosaic-user-prof…
alexcarpenter Oct 2, 2026
bfaae6a
Merge remote-tracking branch 'origin/main' into carp/mosaic-user-prof…
alexcarpenter Oct 2, 2026
6fe38ad
style(mosaic): format the name dialog's field-only banner test
alexcarpenter Oct 2, 2026
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
2 changes: 2 additions & 0 deletions .changeset/mosaic-user-profile-account-wireup.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
28 changes: 25 additions & 3 deletions packages/mosaic/src/components/form/form.machine.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import type { LocalizableError } from '../../localization';
import { setup } from '../../machine/setup';
import type { TransitionResult } from '../../machine/types';
import { SaveError } from '../../utils/form-error';
import { keysOf, mapKeys } from '../../utils/object';
import type { FieldFeedback, FormError, FormFieldErrors } from './form-submit-error';
import { FormSubmitError } from './form-submit-error';
Expand Down Expand Up @@ -33,6 +35,7 @@ export interface FormDeps<TValues extends object> {
onSubmit: (values: TValues) => Promise<unknown>;
canSubmit: (values: TValues) => boolean;
fallbackMessage: string;
errorText: (error: LocalizableError) => string;
}

export interface FormContext<TValues extends object> extends FormDeps<TValues> {
Expand Down Expand Up @@ -130,13 +133,32 @@ function displayableFields<TValues extends object>(
return result;
}

function savedFieldErrors<TValues extends object>(
context: FormContext<TValues>,
fields: Partial<Record<string, LocalizableError>> | undefined,
): FormFieldErrors<TValues> {
const result: FormFieldErrors<TValues> = {};
for (const name of keysOf(context.values)) {
const error = typeof name === 'string' ? fields?.[name] : undefined;
if (error !== undefined) {
result[name] = context.errorText(error);
}
}
return result;
}

function toFormError<TValues extends object>(cause: unknown, context: FormContext<TValues>): FormError<TValues> {
if (!(cause instanceof FormSubmitError) && !(cause instanceof SaveError)) {
console.error(cause);
return { message: context.fallbackMessage };
}
const error: FormError<TValues> =
cause instanceof FormSubmitError
? { message: cause.banner, fields: displayableFields(context, cause.fields) }
: cause instanceof Error
? { message: cause.message }
: {};
: {
message: cause.formError.global ? context.errorText(cause.formError.global) : undefined,
fields: savedFieldErrors(context, cause.formError.fields),
};
const visible = (error.message ?? '') !== '' || keysOf(error.fields ?? {}).length > 0;
return visible ? error : { ...error, message: context.fallbackMessage };
}
Expand Down
72 changes: 44 additions & 28 deletions packages/mosaic/src/components/form/use-form.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { act, renderHook } from '@testing-library/react';
import { describe, expect, expectTypeOf, it, vi } from 'vitest';

import { deferred, tick } from '../../__tests__/async';
import { SaveError } from '../../utils/form-error';
import type { FieldFeedback } from './form-submit-error';
import { FormSubmitError } from './form-submit-error';
import { useForm } from './use-form';
Expand Down Expand Up @@ -93,6 +94,25 @@ describe('useForm', () => {
expect(result.current.fields.username.feedback).toBeUndefined();
});

it('localizes a SaveError onto the form message and field feedback', async () => {
const failure = new SaveError<'username'>({
global: { code: 'unknown_code', message: 'Could not save' },
fields: { username: { code: 'generic' } },
});
const { result } = renderHook(() =>
useForm({ initialValues: { username: 'alex' }, onSubmit: () => Promise.reject(failure) }),
);
await act(async () => {
result.current.submit();
await tick();
});
expect(result.current.error).toBe('Could not save');
expect(result.current.fields.username.feedback).toEqual({
type: 'error',
message: 'Something went wrong. Please try again.',
});
});

it('maps a fields-only FormSubmitError onto field feedback with no form message', async () => {
const failure = new FormSubmitError({ fields: { username: 'Taken', bio: 'Too long' } });
expect(failure.message).toBe('Taken Too long');
Expand All @@ -108,36 +128,32 @@ describe('useForm', () => {
expect(result.current.fields.bio.feedback).toEqual({ type: 'error', message: 'Too long' });
});

it('shows only the message for a plain Error and a generic message otherwise', async () => {
const plain = renderHook(() =>
useForm({ initialValues: { username: '' }, onSubmit: () => Promise.reject(new Error('Nope')) }),
);
await act(async () => {
plain.result.current.submit();
await tick();
});
expect(plain.result.current.error).toBe('Nope');

const cause: unknown = 'boom';
const unknown = renderHook(() =>
useForm({
initialValues: { username: '' },
onSubmit: async () => {
await Promise.resolve();
throw cause;
},
}),
);
await act(async () => {
unknown.result.current.submit();
await tick();
});
expect(unknown.result.current.error).toBe('Something went wrong. Please try again.');
it('shows the generic message and logs the cause for an unrecognized rejection', async () => {
const log = vi.spyOn(console, 'error').mockImplementation(() => undefined);
const causes: unknown[] = [new Error('Nope'), 'boom'];
for (const cause of causes) {
const { result } = renderHook(() =>
useForm({
initialValues: { username: '' },
onSubmit: async () => {
await Promise.resolve();
throw cause;
},
}),
);
await act(async () => {
result.current.submit();
await tick();
});
expect(result.current.error).toBe('Something went wrong. Please try again.');
expect(log).toHaveBeenCalledWith(cause);
}
log.mockRestore();
});

it('falls back to the generic message when a submit error has nothing to show', async () => {
const empty = renderHook(() =>
useForm({ initialValues: { username: '' }, onSubmit: () => Promise.reject(new Error()) }),
useForm({ initialValues: { username: '' }, onSubmit: () => Promise.reject(new SaveError({})) }),
);
await act(async () => {
empty.result.current.submit();
Expand Down Expand Up @@ -173,7 +189,7 @@ describe('useForm', () => {
useForm({
initialValues: { username: '' },
onSubmit: () => {
throw new Error('Nope');
throw new FormSubmitError({ message: 'Nope' });
},
}),
);
Expand Down Expand Up @@ -201,7 +217,7 @@ describe('useForm', () => {
const { result } = renderHook(() =>
useForm({
initialValues: { username: '' },
onSubmit: () => (fail ? Promise.reject(new Error('Nope')) : Promise.resolve()),
onSubmit: () => (fail ? Promise.reject(new FormSubmitError({ message: 'Nope' })) : Promise.resolve()),
}),
);
await act(async () => {
Expand Down
4 changes: 3 additions & 1 deletion packages/mosaic/src/components/form/use-form.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useCallback, useId, useRef } from 'react';

import { useMessages } from '../../localization';
import { useErrorText, useMessages } from '../../localization';
import type { StateMachine } from '../../machine/types';
import { useMachine } from '../../machine/use-machine';
import { keysOf, mapKeys } from '../../utils/object';
Expand Down Expand Up @@ -84,6 +84,7 @@ const always = () => true;
export function useForm<TValues extends object>(options: UseFormOptions<TValues>): UseFormResult<TValues> {
const id = useId();
const m = useMessages('form');
const errorText = useErrorText();
const elements = useRef(new Map<keyof TValues, HTMLElement>());
const refs = useRef(new Map<keyof TValues, ElementRef>());

Expand All @@ -93,6 +94,7 @@ export function useForm<TValues extends object>(options: UseFormOptions<TValues>
onSubmit: options.onSubmit,
canSubmit: options.canSubmit ?? always,
fallbackMessage: m.error,
errorText,
};
const machineRef = useRef<StateMachine<FormContext<TValues>, FormEvent<TValues>> | null>(null);
if (machineRef.current === null) {
Expand Down
1 change: 1 addition & 0 deletions packages/mosaic/src/components/section/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export type {
SectionLabelProps,
SectionMediaProps,
SectionMediaSize,
SectionNoteProps,
SectionRootProps,
SectionRowProps,
SectionTitleProps,
Expand Down
18 changes: 18 additions & 0 deletions packages/mosaic/src/components/section/section.styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,24 @@ export const styles = stylex.create({
flexShrink: 0,
justifyContent: 'flex-end',
},
note: {
alignItems: 'center',
color: colorVars['--cl-color-foreground-secondary'],
columnGap: space['1'],
display: 'flex',
flexShrink: 0,
fontSize: typeScaleVars['--cl-text-xs-size'],
fontWeight: fontWeightVars['--cl-font-normal'],
lineHeight: typeScaleVars['--cl-text-xs-leading'],
},
noteIcon: {
alignItems: 'center',
display: 'flex',
flexShrink: 0,
justifyContent: 'center',
height: space['4'],
width: space['4'],
},
actionsWrap: {
justifyContent: { [compact]: 'flex-start', default: 'flex-end' },
width: { [compact]: '100%', default: null },
Expand Down
40 changes: 40 additions & 0 deletions packages/mosaic/src/components/section/section.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -340,6 +340,46 @@ describe('Section', () => {
await waitFor(() => expect(screen.queryByTestId('error')).not.toBeInTheDocument());
});

it('states why a row has no action, with the leading glyph in its own slot', () => {
render(
<Section.Root>
<Section.Group>
<Section.Row>
<Section.Item data-testid='item'>
<Section.Content>
<Section.Label>Name</Section.Label>
</Section.Content>
<Section.Note
data-testid='note'
icon={
<img
alt=''
src='/okta.svg'
/>
}
>
Managed by Okta
</Section.Note>
</Section.Item>
</Section.Row>
</Section.Group>
</Section.Root>,
);

const note = screen.getByTestId('note');
expect(note).toHaveClass('cl-section-note');
expect(note).toHaveTextContent('Managed by Okta');
expect(note.querySelector('.cl-section-note-icon')).toContainElement(screen.getByRole('presentation'));
// Holds the trailing slot itself, so it needs no Section.Actions around it.
expect(note.parentElement).toBe(screen.getByTestId('item'));
});

it('leaves out the glyph slot when the note carries no icon', () => {
render(<Section.Note data-testid='note'>Managed by Acme SSO</Section.Note>);

expect(screen.getByTestId('note').querySelector('.cl-section-note-icon')).toBeNull();
});

it('marks a wrapping item for its theme hook', () => {
render(
<Section.Root>
Expand Down
29 changes: 29 additions & 0 deletions packages/mosaic/src/components/section/section.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export type SectionContentProps = MosaicComponentProps<'div'>;
export type SectionLabelProps = MosaicComponentProps<'div'>;
export type SectionDescriptionProps = MosaicComponentProps<'div'>;
export type SectionActionsProps = MosaicComponentProps<'div'>;
export type SectionNoteProps = MosaicComponentProps<'div'> & { icon?: React.ReactNode };
export type SectionErrorProps = MosaicComponentProps<'p'>;

const mediaSizes = {
Expand Down Expand Up @@ -271,6 +272,33 @@ const Actions = React.forwardRef<HTMLDivElement, SectionActionsProps>(function S
});
});

/**
* Static text that takes an action's place in a row, stating why it offers none. Sits directly in
* `Section.Item` where a `Section.Actions` would; it holds its own trailing position, so it does not
* need one. `icon` renders into a fixed leading slot, sized to the text, for a logo or a lock.
*/
const Note = React.forwardRef<HTMLDivElement, SectionNoteProps>(function SectionNote(
{ icon, children, render, xstyle, ...rest },
ref,
) {
return useRender({
defaultTagName: 'div',
render,
ref,
props: {
...mergeStyleProps(themeProps('section-note'), stylex.props(reset.base, styles.note, xstyle), rest),
children: (
<>
{icon ? (
<span {...mergeStyleProps(themeProps('section-note-icon'), stylex.props(styles.noteIcon))}>{icon}</span>
) : null}
{children}
</>
),
},
});
});

/**
* A row-level message, mirroring `Field.Error` for a row that holds no form control. Place it as a
* sibling of `Section.Item` inside `Section.Row`, not inside `Section.Content`: the item stays a
Expand Down Expand Up @@ -346,5 +374,6 @@ export const Section = {
Label,
Description,
Actions,
Note,
Error: SectionError,
};
Loading
Loading