From 9eb45510007f41074297aab98c6d8fc571e3c9a0 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Wed, 28 Oct 2026 20:19:49 -0400 Subject: [PATCH 1/3] refactor(mosaic): hold create API key dialog state in a machine --- .changeset/mosaic-create-api-key-machine.md | 2 + .../__tests__/api-keys-table.feature.test.tsx | 17 ++++ .../api-keys/create-api-key.controller.ts | 78 ++++++++++++++----- 3 files changed, 78 insertions(+), 19 deletions(-) create mode 100644 .changeset/mosaic-create-api-key-machine.md diff --git a/.changeset/mosaic-create-api-key-machine.md b/.changeset/mosaic-create-api-key-machine.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/mosaic-create-api-key-machine.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/mosaic/src/features/api-keys/__tests__/api-keys-table.feature.test.tsx b/packages/mosaic/src/features/api-keys/__tests__/api-keys-table.feature.test.tsx index 44d86062fd8..aaf7b4fa610 100644 --- a/packages/mosaic/src/features/api-keys/__tests__/api-keys-table.feature.test.tsx +++ b/packages/mosaic/src/features/api-keys/__tests__/api-keys-table.feature.test.tsx @@ -484,6 +484,23 @@ describe('APIKeysTable', () => { expect(screen.getByRole('alertdialog', { name: 'Copy your API Key' })).toBeVisible(); }); + it('keeps the secret open on Escape while a retried copy is pending', async () => { + const { user } = await renderTable(); + const dialog = await openCreate(user); + await fillCreate(user, dialog, 'Deploy', 'Never'); + await user.click(within(dialog).getByRole('button', { name: 'Add API Key' })); + await within(dialog).findByRole('textbox', { name: 'API key' }); + vi.spyOn(navigator.clipboard, 'writeText').mockRejectedValueOnce(new Error('blocked')); + await user.click(within(dialog).getByRole('button', { name: 'Copy and close' })); + await waitFor(() => expect(within(dialog).getByRole('alert')).toHaveTextContent('Could not copy')); + vi.spyOn(navigator.clipboard, 'writeText').mockImplementation(() => new Promise(() => undefined)); + + await user.click(within(dialog).getByRole('button', { name: 'Copy and close' })); + await user.keyboard('{Escape}'); + + expect(screen.getByRole('alertdialog', { name: 'Copy your API Key' })).toBeVisible(); + }); + it('closes the secret on Escape after a failed copy', async () => { const { user } = await renderTable(); const dialog = await openCreate(user); diff --git a/packages/mosaic/src/features/api-keys/create-api-key.controller.ts b/packages/mosaic/src/features/api-keys/create-api-key.controller.ts index 051eddde363..c195ce95e6c 100644 --- a/packages/mosaic/src/features/api-keys/create-api-key.controller.ts +++ b/packages/mosaic/src/features/api-keys/create-api-key.controller.ts @@ -1,9 +1,11 @@ import type { MouseEventHandler } from 'react'; -import { useRef, useState } from 'react'; +import { useRef } from 'react'; import { useForm } from '../../components/form'; import { useNow } from '../../hooks/use-now'; import { useLocale } from '../../localization'; +import { setup } from '../../machine/setup'; +import { useMachine } from '../../machine/use-machine'; import { formatDate } from '../../utils/format-date'; import { getExpirationDate } from './api-keys-table.format'; import type { APIKeysTableMessages } from './api-keys-table.types'; @@ -28,6 +30,50 @@ export interface CreateAPIKeyController { dialog: CreateAPIKeyDialogProps; } +interface Context { + secret: string | null; + copyFailed: boolean; +} + +type Event = + | { type: 'OPEN' } + | { type: 'CREATED'; secret: string } + | { type: 'COPY' } + | { type: 'COPIED' } + | { type: 'COPY_FAILED' } + | { type: 'CLOSE' }; + +const { createMachine, assign } = setup(); + +const machine = createMachine({ + id: 'createAPIKey', + initial: 'closed', + context: { secret: null, copyFailed: false }, + states: { + closed: { + on: { OPEN: { target: 'form', actions: assign(() => ({ secret: null, copyFailed: false })) } }, + }, + form: { + on: { + CREATED: { target: 'secret', actions: assign((_, event) => ({ secret: event.secret })) }, + CLOSE: 'closed', + }, + }, + secret: { + on: { COPY: 'copying' }, + }, + copying: { + on: { + COPIED: { target: 'attempted', actions: assign(() => ({ copyFailed: false })) }, + COPY_FAILED: { target: 'attempted', actions: assign(() => ({ copyFailed: true })) }, + }, + }, + attempted: { + on: { COPY: 'copying', CLOSE: 'closed' }, + }, + }, +}); + export function useCreateAPIKeyController({ messages, showDescription, @@ -36,10 +82,8 @@ export function useCreateAPIKeyController({ const locale = useLocale(); const now = useNow({ updateInterval: 60_000 }); const trigger = useRef(null); - const [open, setOpen] = useState(false); - const [secret, setSecret] = useState(null); - const [copyError, setCopyError] = useState(null); - const [hasTriedCopy, setHasTriedCopy] = useState(false); + const [snapshot, send] = useMachine(machine); + const { secret, copyFailed } = snapshot.context; const form = useForm({ initialValues, @@ -57,7 +101,7 @@ export function useCreateAPIKeyController({ }, onSuccess: secret => { if (secret !== undefined) { - setSecret(secret); + send({ type: 'CREATED', secret }); } }, }); @@ -69,17 +113,14 @@ export function useCreateAPIKeyController({ onOpen: event => { trigger.current = event.currentTarget; form.reset(); - setSecret(null); - setCopyError(null); - setHasTriedCopy(false); - setOpen(true); + send({ type: 'OPEN' }); }, dialog: { messages, - open, + open: snapshot.value !== 'closed', onOpenChange: next => { - if (!form.isSubmitting && (secret === null || hasTriedCopy)) { - setOpen(next); + if (!next && !form.isSubmitting) { + send({ type: 'CLOSE' }); } }, finalFocus: trigger, @@ -87,22 +128,21 @@ export function useCreateAPIKeyController({ showDescription, expirationDateLabel: expirationDate ? formatDate(expirationDate, locale) : null, secret, - copyError, + copyError: copyFailed ? messages.copyError : null, onCopy: async close => { + send({ type: 'COPY' }); try { await navigator.clipboard.writeText(secret ?? ''); } catch (error) { - setHasTriedCopy(true); - setCopyError(messages.copyError); + send({ type: 'COPY_FAILED' }); if (!close) { throw error; } return; } - setHasTriedCopy(true); - setCopyError(null); + send({ type: 'COPIED' }); if (close) { - setOpen(false); + send({ type: 'CLOSE' }); } }, }, From adb48eb9b677464642436b51d49f602771834d8b Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Fri, 30 Oct 2026 15:12:50 -0400 Subject: [PATCH 2/3] fix(mosaic): let the last copy result decide the create API key copy error --- .../__tests__/api-keys-table.feature.test.tsx | 20 +++++++++++++++++++ .../api-keys/create-api-key.controller.ts | 10 +++++----- 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/packages/mosaic/src/features/api-keys/__tests__/api-keys-table.feature.test.tsx b/packages/mosaic/src/features/api-keys/__tests__/api-keys-table.feature.test.tsx index aaf7b4fa610..034aa0ace36 100644 --- a/packages/mosaic/src/features/api-keys/__tests__/api-keys-table.feature.test.tsx +++ b/packages/mosaic/src/features/api-keys/__tests__/api-keys-table.feature.test.tsx @@ -4,6 +4,7 @@ import userEvent from '@testing-library/user-event'; import { http, HttpResponse } from 'msw'; import { describe, expect, it, vi } from 'vitest'; +import { deferred } from '../../../__tests__/async'; import { openDialog } from '../../../__tests__/feature/dialog'; import { type FakeFapiSeed, fapiUrl, holdRequests, serveFapi, worker } from '../../../__tests__/feature/fake-fapi'; import { @@ -501,6 +502,25 @@ describe('APIKeysTable', () => { expect(screen.getByRole('alertdialog', { name: 'Copy your API Key' })).toBeVisible(); }); + it('clears the copy error when an overlapping copy succeeds after one fails', async () => { + const { user } = await renderTable(); + const dialog = await openCreate(user); + await fillCreate(user, dialog, 'Deploy', 'Never'); + await user.click(within(dialog).getByRole('button', { name: 'Add API Key' })); + await within(dialog).findByRole('textbox', { name: 'API key' }); + const first = deferred(); + const second = deferred(); + vi.spyOn(navigator.clipboard, 'writeText').mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise); + + await user.click(within(dialog).getByRole('button', { name: 'Copy API key' })); + await user.click(within(dialog).getByRole('button', { name: 'Copy API key' })); + first.reject(new Error('blocked')); + await waitFor(() => expect(within(dialog).getByRole('alert')).toHaveTextContent('Could not copy')); + second.resolve(); + + await waitFor(() => expect(within(dialog).getByRole('alert')).not.toHaveTextContent('Could not copy')); + }); + it('closes the secret on Escape after a failed copy', async () => { const { user } = await renderTable(); const dialog = await openCreate(user); diff --git a/packages/mosaic/src/features/api-keys/create-api-key.controller.ts b/packages/mosaic/src/features/api-keys/create-api-key.controller.ts index c195ce95e6c..c2b8d8ab746 100644 --- a/packages/mosaic/src/features/api-keys/create-api-key.controller.ts +++ b/packages/mosaic/src/features/api-keys/create-api-key.controller.ts @@ -45,6 +45,9 @@ type Event = const { createMachine, assign } = setup(); +const copied = { target: 'attempted', actions: assign(() => ({ copyFailed: false })) }; +const failed = { target: 'attempted', actions: assign(() => ({ copyFailed: true })) }; + const machine = createMachine({ id: 'createAPIKey', initial: 'closed', @@ -63,13 +66,10 @@ const machine = createMachine({ on: { COPY: 'copying' }, }, copying: { - on: { - COPIED: { target: 'attempted', actions: assign(() => ({ copyFailed: false })) }, - COPY_FAILED: { target: 'attempted', actions: assign(() => ({ copyFailed: true })) }, - }, + on: { COPIED: copied, COPY_FAILED: failed }, }, attempted: { - on: { COPY: 'copying', CLOSE: 'closed' }, + on: { COPY: 'copying', COPIED: copied, COPY_FAILED: failed, CLOSE: 'closed' }, }, }, }); From 0af0ad49afb69a7ac26ff69a725f0e9287b43a3f Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Fri, 30 Oct 2026 15:37:01 -0400 Subject: [PATCH 3/3] refactor(mosaic): write create API key copy transitions inline so targets stay typed --- .../api-keys/create-api-key.controller.ts | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/packages/mosaic/src/features/api-keys/create-api-key.controller.ts b/packages/mosaic/src/features/api-keys/create-api-key.controller.ts index c2b8d8ab746..8d60dc8b43f 100644 --- a/packages/mosaic/src/features/api-keys/create-api-key.controller.ts +++ b/packages/mosaic/src/features/api-keys/create-api-key.controller.ts @@ -45,8 +45,8 @@ type Event = const { createMachine, assign } = setup(); -const copied = { target: 'attempted', actions: assign(() => ({ copyFailed: false })) }; -const failed = { target: 'attempted', actions: assign(() => ({ copyFailed: true })) }; +const clearCopyError = assign(() => ({ copyFailed: false })); +const setCopyError = assign(() => ({ copyFailed: true })); const machine = createMachine({ id: 'createAPIKey', @@ -66,10 +66,18 @@ const machine = createMachine({ on: { COPY: 'copying' }, }, copying: { - on: { COPIED: copied, COPY_FAILED: failed }, + on: { + COPIED: { target: 'attempted', actions: clearCopyError }, + COPY_FAILED: { target: 'attempted', actions: setCopyError }, + }, }, attempted: { - on: { COPY: 'copying', COPIED: copied, COPY_FAILED: failed, CLOSE: 'closed' }, + on: { + COPY: 'copying', + COPIED: { target: 'attempted', actions: clearCopyError }, + COPY_FAILED: { target: 'attempted', actions: setCopyError }, + CLOSE: 'closed', + }, }, }, });