Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,31 @@ describe('useFormEditor', () => {
expect(result.current.reset).toBe(reset0);
});

it('seeds defined defaultValues while data is still loading (controlled from first render)', () => {
const { result, rerender } = renderHook(
({ data }: { data: Form | undefined }) =>
useFormEditor<Form>({
data,
defaultValues: { name: '', color: '' },
schema,
save: vi.fn().mockResolvedValue(undefined),
}),
{ initialProps: { data: undefined as Form | undefined } },
);

// Loading, but the controlled fields already read defined values rather
// than undefined — no uncontrolled→controlled churn when data arrives.
expect(result.current.isLoading).toBe(true);
expect(result.current.form.getValues('name')).toBe('');
expect(result.current.form.getValues('color')).toBe('');
expect(result.current.isDirty).toBe(false);

rerender({ data: { name: 'A', color: '#FF0000' } });
expect(result.current.isLoading).toBe(false);
expect(result.current.form.getValues('name')).toBe('A');
expect(result.current.isDirty).toBe(false);
});

it('reports isValid:false for schema-invalid input', async () => {
const { result } = mount({ name: 'A', color: '#FF0000' });
act(() => result.current.form.setValue('name', '', { shouldDirty: true }));
Expand Down
11 changes: 10 additions & 1 deletion services/platform/app/components/ui/editor/use-form-editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,14 @@ interface UseFormEditorArgs<T extends FieldValues> {
* want to clobber — the Convex baseline-drift fix).
*/
data: T | undefined;
/**
* Stable, fully-defined initial values for the very first render, before the
* async `data` resolves. RHF reads `defaultValues` once at mount, so seeding
* it here keeps controlled inputs (Select/Switch) controlled from the first
* render instead of mounting with `undefined` and tripping React's
* uncontrolled→controlled warning when `data` arrives.
*/
defaultValues?: DefaultValues<T>;
/** Optional Zod schema; when present, drives validation + `isValid`. */
schema?: AnyZodSchema;
/** Persists the form values. Throw to keep `isDirty` true. */
Expand Down Expand Up @@ -60,13 +68,14 @@ interface FormEditor<T extends FieldValues> extends EditorController {
*/
export function useFormEditor<T extends FieldValues>({
data,
defaultValues,
schema,
save,
mapServerError,
}: UseFormEditorArgs<T>): FormEditor<T> {
const form = useForm<T>({
// oxlint-disable-next-line typescript/no-unsafe-type-assertion -- T extends FieldValues
defaultValues: data as DefaultValues<T> | undefined,
defaultValues: (data ?? defaultValues) as DefaultValues<T> | undefined,
resolver: schema
? // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- zodResolver returns Resolver<unknown,…>; widen
(zodResolver(schema) as unknown as Resolver<T>)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,36 @@ interface SsoFormData {
excludeGroups: string;
}

/**
* Fully-defined empty form state used as RHF's initial `defaultValues` while
* the connection config is still loading. Every field is defined (empty
* strings for text/Selects, booleans for Switches) so the controlled Select
* and Switch inputs are controlled from the first render — without this they
* would mount with `undefined` and log React's uncontrolled→controlled warning
* once the seeded `data` resolves.
*/
const EMPTY_FORM_DATA: SsoFormData = {
protocol: 'entra-id',
displayName: '',
domain: '',
issuer: '',
clientId: '',
clientSecret: '',
scopes: '',
pkce: true,
authzEndpoint: '',
tokenEndpoint: '',
userinfoEndpoint: '',
domainHint: '',
idpEntityId: '',
idpSsoUrl: '',
idpCertificate: '',
defaultRole: 'member',
autoRole: false,
autoTeam: false,
excludeGroups: '',
};

const isOidcProtocol = (p: UiProtocol): p is Exclude<UiProtocol, 'saml'> =>
p !== 'saml';

Expand Down Expand Up @@ -404,7 +434,12 @@ export function EnterpriseSsoForm({ organizationId, config }: Props) {
[config, organizationId, t, toast, upsertOidc, upsertSaml],
);

const editor = useFormEditor<SsoFormData>({ data, schema, save });
const editor = useFormEditor<SsoFormData>({
data,
defaultValues: EMPTY_FORM_DATA,
schema,
save,
});

useRegisterActiveEditor(editor);

Expand Down