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..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 { @@ -484,6 +485,42 @@ 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('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 051eddde363..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 @@ -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,58 @@ 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 clearCopyError = assign(() => ({ copyFailed: false })); +const setCopyError = assign(() => ({ copyFailed: true })); + +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: clearCopyError }, + COPY_FAILED: { target: 'attempted', actions: setCopyError }, + }, + }, + attempted: { + on: { + COPY: 'copying', + COPIED: { target: 'attempted', actions: clearCopyError }, + COPY_FAILED: { target: 'attempted', actions: setCopyError }, + CLOSE: 'closed', + }, + }, + }, +}); + export function useCreateAPIKeyController({ messages, showDescription, @@ -36,10 +90,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 +109,7 @@ export function useCreateAPIKeyController({ }, onSuccess: secret => { if (secret !== undefined) { - setSecret(secret); + send({ type: 'CREATED', secret }); } }, }); @@ -69,17 +121,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 +136,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' }); } }, },