Skip to content

Commit b5803ea

Browse files
refactor(mosaic): hold create API key dialog state in a machine (#10516)
1 parent 7615c3b commit b5803ea

3 files changed

Lines changed: 106 additions & 19 deletions

File tree

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

‎packages/mosaic/src/features/api-keys/__tests__/api-keys-table.feature.test.tsx‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import userEvent from '@testing-library/user-event';
44
import { http, HttpResponse } from 'msw';
55
import { describe, expect, it, vi } from 'vitest';
66

7+
import { deferred } from '../../../__tests__/async';
78
import { openDialog } from '../../../__tests__/feature/dialog';
89
import { type FakeFapiSeed, fapiUrl, holdRequests, serveFapi, worker } from '../../../__tests__/feature/fake-fapi';
910
import {
@@ -484,6 +485,42 @@ describe('APIKeysTable', () => {
484485
expect(screen.getByRole('alertdialog', { name: 'Copy your API Key' })).toBeVisible();
485486
});
486487

488+
it('keeps the secret open on Escape while a retried copy is pending', async () => {
489+
const { user } = await renderTable();
490+
const dialog = await openCreate(user);
491+
await fillCreate(user, dialog, 'Deploy', 'Never');
492+
await user.click(within(dialog).getByRole('button', { name: 'Add API Key' }));
493+
await within(dialog).findByRole('textbox', { name: 'API key' });
494+
vi.spyOn(navigator.clipboard, 'writeText').mockRejectedValueOnce(new Error('blocked'));
495+
await user.click(within(dialog).getByRole('button', { name: 'Copy and close' }));
496+
await waitFor(() => expect(within(dialog).getByRole('alert')).toHaveTextContent('Could not copy'));
497+
vi.spyOn(navigator.clipboard, 'writeText').mockImplementation(() => new Promise<void>(() => undefined));
498+
499+
await user.click(within(dialog).getByRole('button', { name: 'Copy and close' }));
500+
await user.keyboard('{Escape}');
501+
502+
expect(screen.getByRole('alertdialog', { name: 'Copy your API Key' })).toBeVisible();
503+
});
504+
505+
it('clears the copy error when an overlapping copy succeeds after one fails', async () => {
506+
const { user } = await renderTable();
507+
const dialog = await openCreate(user);
508+
await fillCreate(user, dialog, 'Deploy', 'Never');
509+
await user.click(within(dialog).getByRole('button', { name: 'Add API Key' }));
510+
await within(dialog).findByRole('textbox', { name: 'API key' });
511+
const first = deferred<void>();
512+
const second = deferred<void>();
513+
vi.spyOn(navigator.clipboard, 'writeText').mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise);
514+
515+
await user.click(within(dialog).getByRole('button', { name: 'Copy API key' }));
516+
await user.click(within(dialog).getByRole('button', { name: 'Copy API key' }));
517+
first.reject(new Error('blocked'));
518+
await waitFor(() => expect(within(dialog).getByRole('alert')).toHaveTextContent('Could not copy'));
519+
second.resolve();
520+
521+
await waitFor(() => expect(within(dialog).getByRole('alert')).not.toHaveTextContent('Could not copy'));
522+
});
523+
487524
it('closes the secret on Escape after a failed copy', async () => {
488525
const { user } = await renderTable();
489526
const dialog = await openCreate(user);

‎packages/mosaic/src/features/api-keys/create-api-key.controller.ts‎

Lines changed: 67 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import type { MouseEventHandler } from 'react';
2-
import { useRef, useState } from 'react';
2+
import { useRef } from 'react';
33

44
import { useForm } from '../../components/form';
55
import { useNow } from '../../hooks/use-now';
66
import { useLocale } from '../../localization';
7+
import { setup } from '../../machine/setup';
8+
import { useMachine } from '../../machine/use-machine';
79
import { formatDate } from '../../utils/format-date';
810
import { getExpirationDate } from './api-keys-table.format';
911
import type { APIKeysTableMessages } from './api-keys-table.types';
@@ -28,6 +30,58 @@ export interface CreateAPIKeyController {
2830
dialog: CreateAPIKeyDialogProps;
2931
}
3032

33+
interface Context {
34+
secret: string | null;
35+
copyFailed: boolean;
36+
}
37+
38+
type Event =
39+
| { type: 'OPEN' }
40+
| { type: 'CREATED'; secret: string }
41+
| { type: 'COPY' }
42+
| { type: 'COPIED' }
43+
| { type: 'COPY_FAILED' }
44+
| { type: 'CLOSE' };
45+
46+
const { createMachine, assign } = setup<Context, Event>();
47+
48+
const clearCopyError = assign(() => ({ copyFailed: false }));
49+
const setCopyError = assign(() => ({ copyFailed: true }));
50+
51+
const machine = createMachine({
52+
id: 'createAPIKey',
53+
initial: 'closed',
54+
context: { secret: null, copyFailed: false },
55+
states: {
56+
closed: {
57+
on: { OPEN: { target: 'form', actions: assign(() => ({ secret: null, copyFailed: false })) } },
58+
},
59+
form: {
60+
on: {
61+
CREATED: { target: 'secret', actions: assign((_, event) => ({ secret: event.secret })) },
62+
CLOSE: 'closed',
63+
},
64+
},
65+
secret: {
66+
on: { COPY: 'copying' },
67+
},
68+
copying: {
69+
on: {
70+
COPIED: { target: 'attempted', actions: clearCopyError },
71+
COPY_FAILED: { target: 'attempted', actions: setCopyError },
72+
},
73+
},
74+
attempted: {
75+
on: {
76+
COPY: 'copying',
77+
COPIED: { target: 'attempted', actions: clearCopyError },
78+
COPY_FAILED: { target: 'attempted', actions: setCopyError },
79+
CLOSE: 'closed',
80+
},
81+
},
82+
},
83+
});
84+
3185
export function useCreateAPIKeyController({
3286
messages,
3387
showDescription,
@@ -36,10 +90,8 @@ export function useCreateAPIKeyController({
3690
const locale = useLocale();
3791
const now = useNow({ updateInterval: 60_000 });
3892
const trigger = useRef<HTMLButtonElement | null>(null);
39-
const [open, setOpen] = useState(false);
40-
const [secret, setSecret] = useState<string | null>(null);
41-
const [copyError, setCopyError] = useState<string | null>(null);
42-
const [hasTriedCopy, setHasTriedCopy] = useState(false);
93+
const [snapshot, send] = useMachine(machine);
94+
const { secret, copyFailed } = snapshot.context;
4395

4496
const form = useForm({
4597
initialValues,
@@ -57,7 +109,7 @@ export function useCreateAPIKeyController({
57109
},
58110
onSuccess: secret => {
59111
if (secret !== undefined) {
60-
setSecret(secret);
112+
send({ type: 'CREATED', secret });
61113
}
62114
},
63115
});
@@ -69,40 +121,36 @@ export function useCreateAPIKeyController({
69121
onOpen: event => {
70122
trigger.current = event.currentTarget;
71123
form.reset();
72-
setSecret(null);
73-
setCopyError(null);
74-
setHasTriedCopy(false);
75-
setOpen(true);
124+
send({ type: 'OPEN' });
76125
},
77126
dialog: {
78127
messages,
79-
open,
128+
open: snapshot.value !== 'closed',
80129
onOpenChange: next => {
81-
if (!form.isSubmitting && (secret === null || hasTriedCopy)) {
82-
setOpen(next);
130+
if (!next && !form.isSubmitting) {
131+
send({ type: 'CLOSE' });
83132
}
84133
},
85134
finalFocus: trigger,
86135
form,
87136
showDescription,
88137
expirationDateLabel: expirationDate ? formatDate(expirationDate, locale) : null,
89138
secret,
90-
copyError,
139+
copyError: copyFailed ? messages.copyError : null,
91140
onCopy: async close => {
141+
send({ type: 'COPY' });
92142
try {
93143
await navigator.clipboard.writeText(secret ?? '');
94144
} catch (error) {
95-
setHasTriedCopy(true);
96-
setCopyError(messages.copyError);
145+
send({ type: 'COPY_FAILED' });
97146
if (!close) {
98147
throw error;
99148
}
100149
return;
101150
}
102-
setHasTriedCopy(true);
103-
setCopyError(null);
151+
send({ type: 'COPIED' });
104152
if (close) {
105-
setOpen(false);
153+
send({ type: 'CLOSE' });
106154
}
107155
},
108156
},

0 commit comments

Comments
 (0)