@gntik-ai/blocks 0.6.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +178 -70
- package/dist/index.js +621 -200
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/feedback/ConfirmDestructive/ConfirmDestructive.examples.test.tsx +25 -0
- package/src/feedback/ConfirmDestructive/ConfirmDestructive.extended.test.tsx +252 -0
- package/src/feedback/ConfirmDestructive/ConfirmDestructive.focus.test.tsx +178 -0
- package/src/feedback/ConfirmDestructive/ConfirmDestructive.tsx +219 -28
- package/src/feedback/ConfirmDestructive/block.meta.ts +2 -2
- package/src/feedback/ConfirmDestructive/examples/FailureStates.tsx +39 -0
- package/src/feedback/ConfirmDestructive/examples/PlainDeletion.tsx +6 -0
- package/src/feedback/ConfirmDestructive/examples/SecretRevocation.tsx +34 -0
- package/src/feedback/EmptyStates/EmptyStates.test.tsx +11 -1
- package/src/feedback/EmptyStates/EmptyStates.tsx +31 -1
- package/src/feedback/EmptyStates/block.meta.ts +1 -1
- package/src/feedback/EmptyStates/fixtures.ts +5 -0
- package/src/feedback/ErrorPanel/ErrorPanel.test.tsx +7 -0
- package/src/feedback/ErrorPanel/ErrorPanel.tsx +8 -3
- package/src/tables/CursorPagerFooter/CursorPagerFooter.examples.test.tsx +33 -0
- package/src/tables/CursorPagerFooter/CursorPagerFooter.test.tsx +51 -0
- package/src/tables/CursorPagerFooter/CursorPagerFooter.tsx +10 -0
- package/src/tables/CursorPagerFooter/block.meta.ts +23 -0
- package/src/tables/CursorPagerFooter/examples/Busy.tsx +7 -0
- package/src/tables/CursorPagerFooter/examples/CursorStack.tsx +40 -0
- package/src/tables/CursorPagerFooter/examples/Hidden.tsx +16 -0
- package/src/tables/CursorPagerFooter/examples/RowsPerPage.tsx +21 -0
- package/src/tables/CursorPagerFooter/fixtures.ts +13 -0
- package/src/tables/DataTable/DataTable.server-pagination.test.tsx +173 -0
- package/src/tables/DataTable/DataTable.tsx +20 -12
- package/src/tables/DataTable/block.meta.ts +2 -1
- package/src/tables/DataTable/data-table-props.ts +27 -4
- package/src/tables/DataTable/examples/server-pagination.tsx +68 -0
- package/src/tables/LoadMoreFooter/LoadMoreFooter.announcements.test.tsx +106 -0
- package/src/tables/LoadMoreFooter/LoadMoreFooter.examples.test.tsx +36 -0
- package/src/tables/LoadMoreFooter/LoadMoreFooter.labels.test.tsx +71 -0
- package/src/tables/LoadMoreFooter/LoadMoreFooter.test.tsx +184 -0
- package/src/tables/LoadMoreFooter/LoadMoreFooter.tsx +151 -0
- package/src/tables/LoadMoreFooter/block.meta.ts +28 -0
- package/src/tables/LoadMoreFooter/examples/cards.tsx +22 -0
- package/src/tables/LoadMoreFooter/examples/cursor.tsx +25 -0
- package/src/tables/LoadMoreFooter/examples/exhausted.tsx +6 -0
- package/src/tables/LoadMoreFooter/examples/failed.tsx +13 -0
- package/src/tables/LoadMoreFooter/examples/flag.tsx +33 -0
- package/src/tables/LoadMoreFooter/examples/useMockCursor.ts +30 -0
- package/src/tables/LoadMoreFooter/fixtures.ts +9 -0
- package/src/tables/LoadMoreFooter/useLoadMoreFooter.ts +56 -0
- package/src/tables/index.ts +4 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gntik-ai/blocks",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "gntik-ui blocks: page chrome, data display, tables, forms, feedback, billing, team and builder sections composed from @gntik-ai/ui.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
"@gntik-ai/flow": "0.5.0",
|
|
41
41
|
"@gntik-ai/icons": "0.5.0",
|
|
42
42
|
"@gntik-ai/tokens": "0.2.1",
|
|
43
|
-
"@gntik-ai/ui": "0.
|
|
43
|
+
"@gntik-ai/ui": "0.7.0",
|
|
44
44
|
"lucide-react": "^1.48.0"
|
|
45
45
|
},
|
|
46
46
|
"devDependencies": {
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { render, screen } from '@testing-library/react';
|
|
2
|
+
import userEvent from '@testing-library/user-event';
|
|
3
|
+
import SecretRevocation from './examples/SecretRevocation';
|
|
4
|
+
import { ServerFailure } from './examples/FailureStates';
|
|
5
|
+
|
|
6
|
+
it('documents optional justification and force revocation without changing typed-name safety', async () => {
|
|
7
|
+
const user = userEvent.setup();
|
|
8
|
+
render(<SecretRevocation />);
|
|
9
|
+
await user.click(screen.getByRole('button', { name: 'Revoke version' }));
|
|
10
|
+
expect(await screen.findByRole('textbox', { name: 'Justification' })).toHaveValue('');
|
|
11
|
+
expect(screen.getByRole('checkbox', { name: 'Force revocation' })).not.toBeChecked();
|
|
12
|
+
expect(screen.getByRole('alertdialog')).toHaveAccessibleDescription(/last valid version/);
|
|
13
|
+
expect(screen.getByRole('button', { name: 'Revoke version' })).toBeDisabled();
|
|
14
|
+
await user.type(screen.getByRole('textbox', { name: 'Type version-7 to confirm' }), 'version-7');
|
|
15
|
+
expect(screen.getByRole('button', { name: 'Revoke version' })).toBeEnabled();
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('demonstrates a server failure with no duplicate live announcements', async () => {
|
|
19
|
+
const user = userEvent.setup();
|
|
20
|
+
render(<ServerFailure />);
|
|
21
|
+
await user.click(screen.getByRole('button', { name: 'Revoke version' }));
|
|
22
|
+
await user.type(await screen.findByRole('textbox', { name: 'Type version-7 to confirm' }), 'version-7{Enter}');
|
|
23
|
+
expect(await screen.findByRole('region', { name: 'Could not revoke version' })).toBeVisible();
|
|
24
|
+
expect(screen.getAllByRole('alert')).toHaveLength(1);
|
|
25
|
+
});
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
2
|
+
import userEvent from '@testing-library/user-event';
|
|
3
|
+
import { ConfirmDestructive, type ConfirmDestructiveFailure } from './ConfirmDestructive';
|
|
4
|
+
|
|
5
|
+
function propContract() {
|
|
6
|
+
// @ts-expect-error validity must be a boolean
|
|
7
|
+
<ConfirmDestructive canConfirm="true" />;
|
|
8
|
+
// @ts-expect-error return focus requires an element ref
|
|
9
|
+
<ConfirmDestructive returnFocusRef={{ current: 'trigger' }} />;
|
|
10
|
+
// @ts-expect-error failures require a title
|
|
11
|
+
const missingTitle: ConfirmDestructiveFailure = { kind: 'server' };
|
|
12
|
+
// @ts-expect-error status is numeric
|
|
13
|
+
const stringStatus: ConfirmDestructiveFailure = { kind: 'server', title: 'Failed', status: '500' };
|
|
14
|
+
// @ts-expect-error network failures do not carry server metadata
|
|
15
|
+
const networkStatus: ConfirmDestructiveFailure = { kind: 'network', title: 'Offline', status: 500 };
|
|
16
|
+
void [missingTitle, stringStatus, networkStatus];
|
|
17
|
+
}
|
|
18
|
+
void propContract;
|
|
19
|
+
|
|
20
|
+
function deferred() {
|
|
21
|
+
let resolve!: () => void;
|
|
22
|
+
let reject!: (reason: unknown) => void;
|
|
23
|
+
const promise = new Promise<void>((yes, no) => {
|
|
24
|
+
resolve = yes;
|
|
25
|
+
reject = no;
|
|
26
|
+
});
|
|
27
|
+
return { promise, resolve, reject };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe('ConfirmDestructive extensions', () => {
|
|
31
|
+
it('inserts a fresh alert for each identical synchronous failure', async () => {
|
|
32
|
+
const user = userEvent.setup();
|
|
33
|
+
render(
|
|
34
|
+
<ConfirmDestructive
|
|
35
|
+
defaultOpen
|
|
36
|
+
triggerLabel={null}
|
|
37
|
+
resourceName="db"
|
|
38
|
+
onConfirm={() => {
|
|
39
|
+
throw new Error('Failed');
|
|
40
|
+
}}
|
|
41
|
+
/>,
|
|
42
|
+
);
|
|
43
|
+
await user.type(await screen.findByRole('textbox'), 'db{Enter}');
|
|
44
|
+
const first = await screen.findByRole('alert');
|
|
45
|
+
await user.click(screen.getByRole('button', { name: 'Delete project' }));
|
|
46
|
+
expect(await screen.findByRole('alert')).not.toBe(first);
|
|
47
|
+
});
|
|
48
|
+
it.each(['resolve', 'reject'] as const)(
|
|
49
|
+
'ignores a stale %s after controlled close and reopen',
|
|
50
|
+
async (settlement) => {
|
|
51
|
+
const user = userEvent.setup();
|
|
52
|
+
const old = deferred();
|
|
53
|
+
const current = deferred();
|
|
54
|
+
const onConfirm = vi.fn().mockReturnValueOnce(old.promise).mockReturnValueOnce(current.promise);
|
|
55
|
+
const onOpenChange = vi.fn();
|
|
56
|
+
const props = { triggerLabel: null, resourceName: 'db', onConfirm, onOpenChange };
|
|
57
|
+
const { rerender } = render(<ConfirmDestructive {...props} open />);
|
|
58
|
+
await user.type(await screen.findByRole('textbox'), 'db{Enter}');
|
|
59
|
+
rerender(<ConfirmDestructive {...props} open={false} />);
|
|
60
|
+
await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument());
|
|
61
|
+
rerender(<ConfirmDestructive {...props} open />);
|
|
62
|
+
expect(await screen.findByRole('textbox')).toHaveValue('');
|
|
63
|
+
await user.type(screen.getByRole('textbox'), 'db{Enter}');
|
|
64
|
+
expect(onConfirm).toHaveBeenCalledTimes(2);
|
|
65
|
+
await act(async () => (settlement === 'resolve' ? old.resolve() : old.reject(new Error('Old failure'))));
|
|
66
|
+
expect(screen.getByRole('alertdialog')).toBeVisible();
|
|
67
|
+
expect(screen.getByRole('textbox')).toHaveValue('db');
|
|
68
|
+
expect(screen.getByRole('button', { name: 'Delete project' })).toHaveAttribute('aria-busy', 'true');
|
|
69
|
+
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
|
|
70
|
+
expect(onOpenChange).not.toHaveBeenCalled();
|
|
71
|
+
await act(async () => current.reject(new Error('Current failure')));
|
|
72
|
+
expect(await screen.findByRole('alert')).toHaveTextContent('Current failure');
|
|
73
|
+
},
|
|
74
|
+
);
|
|
75
|
+
it('keeps Retry mounted and focused while pending, guards every attempt, and announces identical failures once each', async () => {
|
|
76
|
+
const user = userEvent.setup();
|
|
77
|
+
const attempt = deferred();
|
|
78
|
+
const failure = { kind: 'server', title: 'Failed' };
|
|
79
|
+
const onConfirm = vi
|
|
80
|
+
.fn()
|
|
81
|
+
.mockRejectedValueOnce(failure)
|
|
82
|
+
.mockReturnValueOnce(attempt.promise)
|
|
83
|
+
.mockRejectedValue(failure);
|
|
84
|
+
const { rerender } = render(
|
|
85
|
+
<ConfirmDestructive defaultOpen triggerLabel={null} resourceName="db" onConfirm={onConfirm} />,
|
|
86
|
+
);
|
|
87
|
+
await user.type(await screen.findByRole('textbox'), 'db{Enter}');
|
|
88
|
+
const retry = await screen.findByRole('button', { name: 'Try again' });
|
|
89
|
+
let inserted = 0;
|
|
90
|
+
const observer = new MutationObserver((records) => {
|
|
91
|
+
for (const record of records)
|
|
92
|
+
for (const node of record.addedNodes) {
|
|
93
|
+
if (node instanceof HTMLElement && node.matches('[role="alert"]')) inserted++;
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
observer.observe(screen.getByRole('alertdialog'), { childList: true, subtree: true });
|
|
97
|
+
await user.click(retry);
|
|
98
|
+
expect(retry).toBeInTheDocument();
|
|
99
|
+
expect(retry).toHaveFocus();
|
|
100
|
+
expect(retry).toHaveAttribute('aria-busy', 'true');
|
|
101
|
+
expect(screen.getByRole('textbox')).toHaveAttribute('readonly');
|
|
102
|
+
expect(screen.getByRole('textbox')).toBeEnabled();
|
|
103
|
+
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
|
|
104
|
+
fireEvent.click(retry);
|
|
105
|
+
fireEvent.submit(screen.getByRole('textbox').closest('form')!);
|
|
106
|
+
expect(onConfirm).toHaveBeenCalledTimes(2);
|
|
107
|
+
await act(async () => attempt.reject(failure));
|
|
108
|
+
expect(await screen.findByRole('alert')).toHaveTextContent('Failed');
|
|
109
|
+
expect(retry).toHaveFocus();
|
|
110
|
+
await user.click(retry);
|
|
111
|
+
await waitFor(() => expect(inserted).toBe(2));
|
|
112
|
+
await user.clear(screen.getByRole('textbox'));
|
|
113
|
+
await user.click(retry);
|
|
114
|
+
expect(onConfirm).toHaveBeenCalledTimes(3);
|
|
115
|
+
await user.type(screen.getByRole('textbox'), 'db');
|
|
116
|
+
rerender(
|
|
117
|
+
<ConfirmDestructive defaultOpen triggerLabel={null} resourceName="db" canConfirm={false} onConfirm={onConfirm} />,
|
|
118
|
+
);
|
|
119
|
+
await user.click(retry);
|
|
120
|
+
expect(onConfirm).toHaveBeenCalledTimes(3);
|
|
121
|
+
observer.disconnect();
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it.each([
|
|
125
|
+
{ kind: 'server', title: 'Failed', message: { error: 'x' }, status: '500', requestId: 7 },
|
|
126
|
+
{ kind: 'server', title: 'Failed', status: Infinity },
|
|
127
|
+
{ kind: 'network', title: 'Failed' },
|
|
128
|
+
])('drops malformed or absent optional fields: %j', async (failure) => {
|
|
129
|
+
render(
|
|
130
|
+
<ConfirmDestructive
|
|
131
|
+
defaultOpen
|
|
132
|
+
triggerLabel={null}
|
|
133
|
+
resourceName="db"
|
|
134
|
+
onConfirm={() => Promise.reject(failure)}
|
|
135
|
+
/>,
|
|
136
|
+
);
|
|
137
|
+
await userEvent.setup().type(await screen.findByRole('textbox'), 'db{Enter}');
|
|
138
|
+
const panel = await screen.findByRole('region', { name: 'Failed' });
|
|
139
|
+
expect(panel).not.toHaveTextContent(/500|Infinity|Request ID|service didn’t respond/);
|
|
140
|
+
expect(screen.getByRole('alert')).toHaveTextContent(/^Failed$/);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it.each([7, '', ' '])('uses generic fallback for invalid title %j', async (title) => {
|
|
144
|
+
render(
|
|
145
|
+
<ConfirmDestructive
|
|
146
|
+
defaultOpen
|
|
147
|
+
triggerLabel={null}
|
|
148
|
+
resourceName="db"
|
|
149
|
+
onConfirm={() => Promise.reject({ kind: 'server', title })}
|
|
150
|
+
/>,
|
|
151
|
+
);
|
|
152
|
+
await userEvent.setup().type(await screen.findByRole('textbox'), 'db{Enter}');
|
|
153
|
+
expect(await screen.findByRole('alert')).toHaveTextContent('Something went wrong. Try again.');
|
|
154
|
+
expect(screen.queryByRole('region')).not.toBeInTheDocument();
|
|
155
|
+
});
|
|
156
|
+
it.each(['no-access', 'not-found'] as const)('replaces interactive content with %s and only Close', async (kind) => {
|
|
157
|
+
const user = userEvent.setup();
|
|
158
|
+
render(
|
|
159
|
+
<ConfirmDestructive
|
|
160
|
+
defaultOpen
|
|
161
|
+
triggerLabel={null}
|
|
162
|
+
resourceName="db"
|
|
163
|
+
onConfirm={() => Promise.reject({ kind, title: 'Unavailable', description: 'Return to the list.' })}
|
|
164
|
+
>
|
|
165
|
+
<label>
|
|
166
|
+
Justification
|
|
167
|
+
<textarea />
|
|
168
|
+
</label>
|
|
169
|
+
</ConfirmDestructive>,
|
|
170
|
+
);
|
|
171
|
+
await user.type(await screen.findByRole('textbox', { name: 'Type db to confirm' }), 'db{Enter}');
|
|
172
|
+
expect(await screen.findByRole('heading', { name: 'Unavailable' })).toBeVisible();
|
|
173
|
+
expect(screen.getByText('Return to the list.')).toBeVisible();
|
|
174
|
+
expect(screen.queryByRole('textbox')).not.toBeInTheDocument();
|
|
175
|
+
expect(screen.queryByRole('region')).not.toBeInTheDocument();
|
|
176
|
+
expect(screen.getAllByRole('button')).toHaveLength(1);
|
|
177
|
+
expect(screen.getByRole('button', { name: 'Close' })).toBeVisible();
|
|
178
|
+
expect(screen.getAllByRole('alert')).toHaveLength(1);
|
|
179
|
+
expect(screen.getByRole('alert')).toHaveTextContent('Unavailable Return to the list.');
|
|
180
|
+
expect(screen.queryByRole('status')).not.toBeInTheDocument();
|
|
181
|
+
});
|
|
182
|
+
it.each(['server', 'network'] as const)(
|
|
183
|
+
'preserves fields and renders %s failures without sample copy',
|
|
184
|
+
async (kind) => {
|
|
185
|
+
const user = userEvent.setup();
|
|
186
|
+
render(
|
|
187
|
+
<ConfirmDestructive
|
|
188
|
+
defaultOpen
|
|
189
|
+
triggerLabel={null}
|
|
190
|
+
resourceName="db"
|
|
191
|
+
onConfirm={() =>
|
|
192
|
+
Promise.reject({
|
|
193
|
+
kind,
|
|
194
|
+
title: 'Action failed',
|
|
195
|
+
message: 'Try later.',
|
|
196
|
+
status: 500,
|
|
197
|
+
requestId: 'req_actual',
|
|
198
|
+
})
|
|
199
|
+
}
|
|
200
|
+
>
|
|
201
|
+
<label>
|
|
202
|
+
Justification
|
|
203
|
+
<textarea />
|
|
204
|
+
</label>
|
|
205
|
+
</ConfirmDestructive>,
|
|
206
|
+
);
|
|
207
|
+
await user.type(await screen.findByRole('textbox', { name: 'Justification' }), 'Keep this');
|
|
208
|
+
await user.type(screen.getByRole('textbox', { name: 'Type db to confirm' }), 'db{Enter}');
|
|
209
|
+
expect(await screen.findByRole('region', { name: 'Action failed' })).toHaveTextContent('Try later.');
|
|
210
|
+
expect(screen.getAllByRole('alert')).toHaveLength(1);
|
|
211
|
+
expect(screen.getByRole('alert')).toHaveTextContent('Action failed Try later.');
|
|
212
|
+
expect(screen.queryByRole('status')).not.toBeInTheDocument();
|
|
213
|
+
expect(screen.getByRole('textbox', { name: 'Justification' })).toHaveValue('Keep this');
|
|
214
|
+
expect(screen.getByRole('textbox', { name: 'Type db to confirm' })).toHaveValue('db');
|
|
215
|
+
expect(screen.queryByText('Contact support')).not.toBeInTheDocument();
|
|
216
|
+
expect(screen.queryByText('Technical details')).not.toBeInTheDocument();
|
|
217
|
+
expect(screen.queryByText('req_8f2c41d07a9b')).not.toBeInTheDocument();
|
|
218
|
+
expect(screen.getByRole('button', { name: 'Try again' })).toBeEnabled();
|
|
219
|
+
if (kind === 'server') {
|
|
220
|
+
expect(screen.getByText('500')).toBeVisible();
|
|
221
|
+
expect(screen.getByText('req_actual')).toBeVisible();
|
|
222
|
+
} else {
|
|
223
|
+
expect(screen.queryByText('500')).not.toBeInTheDocument();
|
|
224
|
+
expect(screen.queryByText('req_actual')).not.toBeInTheDocument();
|
|
225
|
+
}
|
|
226
|
+
},
|
|
227
|
+
);
|
|
228
|
+
it('places extra fields before the typed field, outside the description, and gates confirmation', async () => {
|
|
229
|
+
const user = userEvent.setup();
|
|
230
|
+
const onConfirm = vi.fn();
|
|
231
|
+
const { rerender } = render(
|
|
232
|
+
<ConfirmDestructive defaultOpen triggerLabel={null} resourceName="db" canConfirm={false} onConfirm={onConfirm}>
|
|
233
|
+
<label>
|
|
234
|
+
Justification
|
|
235
|
+
<textarea />
|
|
236
|
+
</label>
|
|
237
|
+
</ConfirmDestructive>,
|
|
238
|
+
);
|
|
239
|
+
const extra = await screen.findByRole('textbox', { name: 'Justification' });
|
|
240
|
+
const typed = screen.getByRole('textbox', { name: 'Type db to confirm' });
|
|
241
|
+
const dialog = screen.getByRole('alertdialog');
|
|
242
|
+
expect(extra.closest('form')).toBe(typed.closest('form'));
|
|
243
|
+
expect(extra.compareDocumentPosition(typed) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
|
244
|
+
expect(document.getElementById(dialog.getAttribute('aria-describedby')!)?.contains(extra)).toBe(false);
|
|
245
|
+
await user.type(typed, 'db{Enter}');
|
|
246
|
+
expect(screen.getByRole('button', { name: 'Delete project' })).toBeDisabled();
|
|
247
|
+
expect(onConfirm).not.toHaveBeenCalled();
|
|
248
|
+
rerender(<ConfirmDestructive defaultOpen triggerLabel={null} resourceName="db" onConfirm={onConfirm} />);
|
|
249
|
+
await user.click(screen.getByRole('button', { name: 'Delete project' }));
|
|
250
|
+
expect(onConfirm).toHaveBeenCalledTimes(1);
|
|
251
|
+
});
|
|
252
|
+
});
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { act, render, screen, waitFor } from '@testing-library/react';
|
|
2
|
+
import userEvent from '@testing-library/user-event';
|
|
3
|
+
import { useRef, useState } from 'react';
|
|
4
|
+
import { I18nProvider } from '@gntik-ai/ui';
|
|
5
|
+
import { ConfirmDestructive } from './ConfirmDestructive';
|
|
6
|
+
|
|
7
|
+
function deferred() {
|
|
8
|
+
let resolve!: () => void;
|
|
9
|
+
let reject!: (reason: unknown) => void;
|
|
10
|
+
const promise = new Promise<void>((yes, no) => {
|
|
11
|
+
resolve = yes;
|
|
12
|
+
reject = no;
|
|
13
|
+
});
|
|
14
|
+
return { promise, resolve, reject };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe('ConfirmDestructive focus', () => {
|
|
18
|
+
it('initially focuses the typed input even with a focusable slot and preserves focus during Enter submission', async () => {
|
|
19
|
+
const attempt = deferred();
|
|
20
|
+
render(
|
|
21
|
+
<ConfirmDestructive defaultOpen triggerLabel={null} resourceName="db" onConfirm={() => attempt.promise}>
|
|
22
|
+
<label>
|
|
23
|
+
Justification
|
|
24
|
+
<textarea />
|
|
25
|
+
</label>
|
|
26
|
+
</ConfirmDestructive>,
|
|
27
|
+
);
|
|
28
|
+
const typed = await screen.findByRole('textbox', { name: 'Type db to confirm' });
|
|
29
|
+
await waitFor(() => expect(typed).toHaveFocus());
|
|
30
|
+
await userEvent.setup().type(typed, 'db{Enter}');
|
|
31
|
+
expect(typed).toHaveFocus();
|
|
32
|
+
await act(async () => attempt.reject(new Error('Failed')));
|
|
33
|
+
expect(typed).toHaveFocus();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it.each(['no-access', 'not-found'] as const)('moves focus to Close after %s removes the input', async (kind) => {
|
|
37
|
+
render(
|
|
38
|
+
<ConfirmDestructive
|
|
39
|
+
defaultOpen
|
|
40
|
+
triggerLabel={null}
|
|
41
|
+
resourceName="db"
|
|
42
|
+
onConfirm={() => Promise.reject({ kind, title: 'Unavailable' })}
|
|
43
|
+
/>,
|
|
44
|
+
);
|
|
45
|
+
await userEvent.setup().type(await screen.findByRole('textbox'), 'db{Enter}');
|
|
46
|
+
expect(await screen.findByRole('button', { name: 'Close' })).toHaveFocus();
|
|
47
|
+
expect(screen.getByRole('alert')).toHaveTextContent(/^Unavailable$/);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('moves from a removed Retry to the input on generic failure', async () => {
|
|
51
|
+
const user = userEvent.setup();
|
|
52
|
+
const onConfirm = vi
|
|
53
|
+
.fn()
|
|
54
|
+
.mockRejectedValueOnce({ kind: 'server', title: 'Failed' })
|
|
55
|
+
.mockRejectedValue(new Error('Generic'));
|
|
56
|
+
render(<ConfirmDestructive defaultOpen triggerLabel={null} resourceName="db" onConfirm={onConfirm} />);
|
|
57
|
+
await user.type(await screen.findByRole('textbox'), 'db{Enter}');
|
|
58
|
+
await user.click(await screen.findByRole('button', { name: 'Try again' }));
|
|
59
|
+
expect(await screen.findByRole('alert')).toHaveTextContent('Generic');
|
|
60
|
+
expect(screen.getByRole('textbox')).toHaveFocus();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('moves from a removed request-id copy button to Retry', async () => {
|
|
64
|
+
const user = userEvent.setup();
|
|
65
|
+
const attempt = deferred();
|
|
66
|
+
const onConfirm = vi
|
|
67
|
+
.fn()
|
|
68
|
+
.mockRejectedValueOnce({ kind: 'server', title: 'Failed', requestId: 'req_1' })
|
|
69
|
+
.mockReturnValue(attempt.promise);
|
|
70
|
+
render(<ConfirmDestructive defaultOpen triggerLabel={null} resourceName="db" onConfirm={onConfirm} />);
|
|
71
|
+
await user.type(await screen.findByRole('textbox'), 'db{Enter}');
|
|
72
|
+
await user.click(await screen.findByRole('button', { name: 'Try again' }));
|
|
73
|
+
act(() => screen.getByRole('button', { name: 'Copy request ID' }).focus());
|
|
74
|
+
await act(async () => attempt.reject({ kind: 'server', title: 'Still failed' }));
|
|
75
|
+
expect(await screen.findByRole('button', { name: 'Try again' })).toHaveFocus();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('moves a newly disabled Confirm to the input without moving unrelated focus', async () => {
|
|
79
|
+
const user = userEvent.setup();
|
|
80
|
+
const { rerender } = render(<ConfirmDestructive defaultOpen triggerLabel={null} resourceName="db" />);
|
|
81
|
+
await user.type(await screen.findByRole('textbox'), 'db');
|
|
82
|
+
act(() => screen.getByRole('button', { name: 'Delete project' }).focus());
|
|
83
|
+
rerender(<ConfirmDestructive defaultOpen triggerLabel={null} resourceName="db" canConfirm={false} />);
|
|
84
|
+
expect(screen.getByRole('textbox')).toHaveFocus();
|
|
85
|
+
act(() => screen.getByRole('button', { name: 'Cancel' }).focus());
|
|
86
|
+
rerender(<ConfirmDestructive defaultOpen triggerLabel={null} resourceName="db" canConfirm />);
|
|
87
|
+
expect(screen.getByRole('button', { name: 'Cancel' })).toHaveFocus();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('skips a busy Retry when a slot control becomes disabled', async () => {
|
|
91
|
+
const user = userEvent.setup();
|
|
92
|
+
const attempt = deferred();
|
|
93
|
+
const onConfirm = vi
|
|
94
|
+
.fn()
|
|
95
|
+
.mockRejectedValueOnce({ kind: 'network', title: 'Offline' })
|
|
96
|
+
.mockReturnValue(attempt.promise);
|
|
97
|
+
const props = { defaultOpen: true, triggerLabel: null, resourceName: 'db', onConfirm };
|
|
98
|
+
const { rerender } = render(
|
|
99
|
+
<ConfirmDestructive {...props}>
|
|
100
|
+
<button>Extra control</button>
|
|
101
|
+
</ConfirmDestructive>,
|
|
102
|
+
);
|
|
103
|
+
await user.type(await screen.findByRole('textbox'), 'db{Enter}');
|
|
104
|
+
await user.click(await screen.findByRole('button', { name: 'Try again' }));
|
|
105
|
+
act(() => screen.getByRole('button', { name: 'Extra control' }).focus());
|
|
106
|
+
rerender(
|
|
107
|
+
<ConfirmDestructive {...props}>
|
|
108
|
+
<button disabled>Extra control</button>
|
|
109
|
+
</ConfirmDestructive>,
|
|
110
|
+
);
|
|
111
|
+
expect(screen.getByRole('textbox')).toHaveFocus();
|
|
112
|
+
await act(async () => attempt.reject(new Error('Failed')));
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
function FocusReturn({
|
|
117
|
+
target,
|
|
118
|
+
outcome,
|
|
119
|
+
}: {
|
|
120
|
+
target: 'internal' | 'ref' | 'opener';
|
|
121
|
+
outcome: 'Cancel' | 'Close' | 'Escape' | 'success';
|
|
122
|
+
}) {
|
|
123
|
+
const [open, setOpen] = useState(false);
|
|
124
|
+
const returnFocusRef = useRef<HTMLButtonElement>(null);
|
|
125
|
+
return (
|
|
126
|
+
<>
|
|
127
|
+
<button ref={returnFocusRef}>Return here</button>
|
|
128
|
+
<button onClick={() => setOpen(true)}>Open confirmation</button>
|
|
129
|
+
<ConfirmDestructive
|
|
130
|
+
open={open}
|
|
131
|
+
onOpenChange={setOpen}
|
|
132
|
+
triggerLabel={target === 'internal' ? 'Internal trigger' : null}
|
|
133
|
+
returnFocusRef={target === 'ref' ? returnFocusRef : undefined}
|
|
134
|
+
resourceName="db"
|
|
135
|
+
onConfirm={() =>
|
|
136
|
+
outcome === 'Close' ? Promise.reject({ kind: 'not-found', title: 'Gone' }) : Promise.resolve()
|
|
137
|
+
}
|
|
138
|
+
/>
|
|
139
|
+
</>
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
for (const target of ['internal', 'ref', 'opener'] as const) {
|
|
144
|
+
it.each(['Cancel', 'Close', 'Escape', 'success'] as const)(`returns focus to ${target} after %s`, async (outcome) => {
|
|
145
|
+
const user = userEvent.setup();
|
|
146
|
+
render(<FocusReturn target={target} outcome={outcome} />);
|
|
147
|
+
const opener = screen.getByRole('button', {
|
|
148
|
+
name: target === 'internal' ? 'Internal trigger' : 'Open confirmation',
|
|
149
|
+
});
|
|
150
|
+
const destination = target === 'ref' ? screen.getByRole('button', { name: 'Return here' }) : opener;
|
|
151
|
+
await user.click(opener);
|
|
152
|
+
const typed = await screen.findByRole('textbox');
|
|
153
|
+
if (outcome === 'success' || outcome === 'Close') {
|
|
154
|
+
await user.type(typed, 'db{Enter}');
|
|
155
|
+
if (outcome === 'Close') await user.click(await screen.findByRole('button', { name: 'Close' }));
|
|
156
|
+
} else if (outcome === 'Escape') await user.keyboard('{Escape}');
|
|
157
|
+
else await user.click(screen.getByRole('button', { name: 'Cancel' }));
|
|
158
|
+
await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument());
|
|
159
|
+
await waitFor(() => expect(destination).toHaveFocus());
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
it('uses the existing Spanish Cancel, Retry and Close messages', async () => {
|
|
164
|
+
const user = userEvent.setup();
|
|
165
|
+
const onConfirm = vi
|
|
166
|
+
.fn()
|
|
167
|
+
.mockRejectedValueOnce({ kind: 'network', title: 'Offline' })
|
|
168
|
+
.mockRejectedValue({ kind: 'no-access', title: 'Unavailable' });
|
|
169
|
+
render(
|
|
170
|
+
<I18nProvider locale="es">
|
|
171
|
+
<ConfirmDestructive defaultOpen triggerLabel={null} resourceName="db" onConfirm={onConfirm} />
|
|
172
|
+
</I18nProvider>,
|
|
173
|
+
);
|
|
174
|
+
expect(await screen.findByRole('button', { name: 'Cancelar' })).toBeVisible();
|
|
175
|
+
await user.type(screen.getByRole('textbox'), 'db{Enter}');
|
|
176
|
+
await user.click(await screen.findByRole('button', { name: 'Reintentar' }));
|
|
177
|
+
expect(await screen.findByRole('button', { name: 'Cerrar' })).toHaveFocus();
|
|
178
|
+
});
|