@gntik-ai/blocks 0.6.0 → 0.8.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 +202 -74
- package/dist/index.js +849 -283
- 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/forms/CredentialDisclosureDialog/CredentialDisclosureDialog.additional.test.tsx +467 -0
- package/src/forms/CredentialDisclosureDialog/CredentialDisclosureDialog.tsx +175 -106
- package/src/forms/CredentialDisclosureDialog/IssueOnClick.test.tsx +56 -0
- package/src/forms/CredentialDisclosureDialog/SecretCopy.tsx +83 -0
- package/src/forms/CredentialDisclosureDialog/block.meta.ts +1 -1
- package/src/forms/CredentialDisclosureDialog/examples/Inline.tsx +19 -0
- package/src/forms/CredentialDisclosureDialog/examples/IssueOnClick.tsx +87 -0
- package/src/forms/CredentialDisclosureDialog/examples/MultiSecret.tsx +29 -0
- package/src/forms/CredentialDisclosureDialog/focus.ts +18 -0
- package/src/forms/CredentialDisclosureDialog/types.ts +5 -0
- 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/src/test/CredentialDisclosureDialog.compatibility.tsx +30 -0
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Trash2 } from '@gntik-ai/icons';
|
|
1
|
+
import { Lock, Trash2 } from '@gntik-ai/icons';
|
|
2
2
|
import {
|
|
3
3
|
AlertDialog,
|
|
4
4
|
AlertDialogCancel,
|
|
@@ -11,9 +11,46 @@ import {
|
|
|
11
11
|
Button,
|
|
12
12
|
Field,
|
|
13
13
|
FieldLabel,
|
|
14
|
+
EmptyState,
|
|
14
15
|
Input,
|
|
16
|
+
useI18n,
|
|
15
17
|
} from '@gntik-ai/ui';
|
|
16
|
-
import { useState, type
|
|
18
|
+
import { useLayoutEffect, useRef, useState, type ReactNode, type RefObject } from 'react';
|
|
19
|
+
import { ErrorPanel } from '../ErrorPanel/ErrorPanel';
|
|
20
|
+
import { NotFoundEmpty } from '../EmptyStates/EmptyStates';
|
|
21
|
+
|
|
22
|
+
export type ConfirmDestructiveFailure =
|
|
23
|
+
| { kind: 'server'; title: string; message?: string; status?: number; requestId?: string }
|
|
24
|
+
| { kind: 'network'; title: string; message?: string }
|
|
25
|
+
| { kind: 'no-access' | 'not-found'; title: string; description?: string };
|
|
26
|
+
|
|
27
|
+
function normalizeFailure(value: unknown): ConfirmDestructiveFailure | null {
|
|
28
|
+
if (!value || typeof value !== 'object' || !('kind' in value) || !('title' in value)) return null;
|
|
29
|
+
if (typeof value.title !== 'string' || !value.title.trim()) return null;
|
|
30
|
+
const title = value.title;
|
|
31
|
+
const message = 'message' in value && typeof value.message === 'string' ? value.message : undefined;
|
|
32
|
+
if (value.kind === 'server') {
|
|
33
|
+
return {
|
|
34
|
+
kind: 'server',
|
|
35
|
+
title,
|
|
36
|
+
message,
|
|
37
|
+
status:
|
|
38
|
+
'status' in value && typeof value.status === 'number' && Number.isFinite(value.status)
|
|
39
|
+
? value.status
|
|
40
|
+
: undefined,
|
|
41
|
+
requestId: 'requestId' in value && typeof value.requestId === 'string' ? value.requestId : undefined,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
if (value.kind === 'network') return { kind: 'network', title, message };
|
|
45
|
+
if (value.kind === 'no-access' || value.kind === 'not-found') {
|
|
46
|
+
return {
|
|
47
|
+
kind: value.kind,
|
|
48
|
+
title,
|
|
49
|
+
description: 'description' in value && typeof value.description === 'string' ? value.description : undefined,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
17
54
|
|
|
18
55
|
export interface ConfirmDestructiveProps {
|
|
19
56
|
/** Exact name the user must type to unlock the action. */
|
|
@@ -33,6 +70,12 @@ export interface ConfirmDestructiveProps {
|
|
|
33
70
|
onOpenChange?: (open: boolean) => void;
|
|
34
71
|
/** Runs on confirm. Returning a promise keeps the dialog open with a spinner until it settles; a rejection shows its message. */
|
|
35
72
|
onConfirm?: () => void | Promise<void>;
|
|
73
|
+
/** Extra fields inside the form, outside its accessible description. */
|
|
74
|
+
children?: ReactNode;
|
|
75
|
+
/** Additional consumer validity; the exact typed-name match is always required. */
|
|
76
|
+
canConfirm?: boolean;
|
|
77
|
+
/** Focus destination when there is no internal trigger. Defaults to the opener. */
|
|
78
|
+
returnFocusRef?: RefObject<HTMLElement | null>;
|
|
36
79
|
}
|
|
37
80
|
|
|
38
81
|
/**
|
|
@@ -55,47 +98,143 @@ export function ConfirmDestructive({
|
|
|
55
98
|
defaultOpen = false,
|
|
56
99
|
onOpenChange,
|
|
57
100
|
onConfirm,
|
|
101
|
+
children,
|
|
102
|
+
canConfirm = true,
|
|
103
|
+
returnFocusRef,
|
|
58
104
|
}: ConfirmDestructiveProps) {
|
|
105
|
+
const { t } = useI18n();
|
|
59
106
|
const [innerOpen, setInnerOpen] = useState(defaultOpen);
|
|
60
107
|
const [typed, setTyped] = useState('');
|
|
61
108
|
const [pending, setPending] = useState(false);
|
|
109
|
+
const pendingRef = useRef(false);
|
|
110
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
111
|
+
const popupRef = useRef<HTMLDivElement>(null);
|
|
112
|
+
const panelRef = useRef<HTMLDivElement>(null);
|
|
113
|
+
const cancelRef = useRef<HTMLButtonElement>(null);
|
|
114
|
+
const closeRef = useRef<HTMLButtonElement>(null);
|
|
115
|
+
const focusedControl = useRef<HTMLElement | null>(null);
|
|
116
|
+
const triggerRef = useRef<HTMLButtonElement>(null);
|
|
117
|
+
const previousFocus = useRef<HTMLElement | null>(null);
|
|
62
118
|
const [error, setError] = useState<string | null>(null);
|
|
119
|
+
const [announcementId, setAnnouncementId] = useState(0);
|
|
120
|
+
const [failure, setFailure] = useState<ConfirmDestructiveFailure | null>(null);
|
|
63
121
|
const isOpen = open ?? innerOpen;
|
|
122
|
+
const [previousOpen, setPreviousOpen] = useState(isOpen);
|
|
123
|
+
const session = useRef(0);
|
|
124
|
+
// Reset controlled closes during render so a reopened form never paints old values.
|
|
125
|
+
if (previousOpen !== isOpen) {
|
|
126
|
+
setPreviousOpen(isOpen);
|
|
127
|
+
if (!isOpen) {
|
|
128
|
+
setTyped('');
|
|
129
|
+
setError(null);
|
|
130
|
+
setFailure(null);
|
|
131
|
+
setPending(false);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
useLayoutEffect(() => {
|
|
135
|
+
session.current = session.current + 1;
|
|
136
|
+
pendingRef.current = false;
|
|
137
|
+
focusedControl.current = null;
|
|
138
|
+
if (isOpen && document.activeElement instanceof HTMLElement) previousFocus.current = document.activeElement;
|
|
139
|
+
return () => {
|
|
140
|
+
session.current++;
|
|
141
|
+
};
|
|
142
|
+
}, [isOpen]);
|
|
64
143
|
const label = confirmLabel ?? `Delete ${resourceType}`;
|
|
65
144
|
const matches = typed === resourceName;
|
|
145
|
+
const terminal = failure?.kind === 'no-access' || failure?.kind === 'not-found' ? failure : null;
|
|
146
|
+
|
|
147
|
+
useLayoutEffect(() => {
|
|
148
|
+
if (!isOpen || !popupRef.current) return;
|
|
149
|
+
const popup = popupRef.current;
|
|
150
|
+
const available = (element: HTMLElement | null, keepLoading = false) =>
|
|
151
|
+
element?.isConnected &&
|
|
152
|
+
popup.contains(element) &&
|
|
153
|
+
!element.closest('[hidden], [inert], [aria-hidden="true"]') &&
|
|
154
|
+
(!element.matches(':disabled, [aria-disabled="true"]') ||
|
|
155
|
+
(keepLoading && element.getAttribute('aria-busy') === 'true'));
|
|
156
|
+
const restore = () => {
|
|
157
|
+
if (!focusedControl.current || available(focusedControl.current, true)) return;
|
|
158
|
+
// This composition disables secondary actions and details, so Retry is the last panel button.
|
|
159
|
+
const retry = panelRef.current?.querySelectorAll<HTMLButtonElement>('button');
|
|
160
|
+
const target = [
|
|
161
|
+
retry?.item(retry.length - 1) ?? null,
|
|
162
|
+
inputRef.current,
|
|
163
|
+
cancelRef.current,
|
|
164
|
+
closeRef.current,
|
|
165
|
+
].find((element) => available(element));
|
|
166
|
+
target?.focus();
|
|
167
|
+
};
|
|
168
|
+
restore();
|
|
169
|
+
const observer = new MutationObserver(restore);
|
|
170
|
+
observer.observe(popup, {
|
|
171
|
+
childList: true,
|
|
172
|
+
subtree: true,
|
|
173
|
+
attributes: true,
|
|
174
|
+
attributeFilter: ['disabled', 'aria-disabled', 'hidden', 'inert', 'aria-hidden'],
|
|
175
|
+
});
|
|
176
|
+
return () => observer.disconnect();
|
|
177
|
+
});
|
|
66
178
|
|
|
67
179
|
const setOpen = (next: boolean) => {
|
|
68
180
|
if (!next) {
|
|
181
|
+
session.current++;
|
|
182
|
+
pendingRef.current = false;
|
|
183
|
+
setPending(false);
|
|
69
184
|
setTyped('');
|
|
70
185
|
setError(null);
|
|
186
|
+
setFailure(null);
|
|
71
187
|
}
|
|
72
188
|
setInnerOpen(next);
|
|
73
189
|
onOpenChange?.(next);
|
|
74
190
|
};
|
|
75
191
|
|
|
76
|
-
const confirm = async (
|
|
77
|
-
|
|
78
|
-
|
|
192
|
+
const confirm = async () => {
|
|
193
|
+
if (!isOpen || !matches || canConfirm === false || pendingRef.current || terminal) return;
|
|
194
|
+
const attemptSession = session.current;
|
|
195
|
+
pendingRef.current = true;
|
|
79
196
|
setPending(true);
|
|
80
197
|
setError(null);
|
|
81
198
|
try {
|
|
82
199
|
await onConfirm?.();
|
|
200
|
+
if (session.current !== attemptSession) return;
|
|
201
|
+
pendingRef.current = false;
|
|
83
202
|
setPending(false);
|
|
84
203
|
setOpen(false);
|
|
85
204
|
} catch (err) {
|
|
205
|
+
if (session.current !== attemptSession) return;
|
|
206
|
+
pendingRef.current = false;
|
|
86
207
|
setPending(false);
|
|
87
|
-
|
|
208
|
+
const next = normalizeFailure(err);
|
|
209
|
+
setAnnouncementId((id) => id + 1);
|
|
210
|
+
setFailure(next);
|
|
211
|
+
setError(
|
|
212
|
+
next
|
|
213
|
+
? [next.title, 'message' in next ? next.message : 'description' in next ? next.description : undefined]
|
|
214
|
+
.filter(Boolean)
|
|
215
|
+
.join(' ')
|
|
216
|
+
: err instanceof Error
|
|
217
|
+
? err.message
|
|
218
|
+
: 'Something went wrong. Try again.',
|
|
219
|
+
);
|
|
88
220
|
}
|
|
89
221
|
};
|
|
90
222
|
|
|
91
223
|
return (
|
|
92
224
|
<AlertDialog open={isOpen} onOpenChange={(next) => !pending && setOpen(next)}>
|
|
93
225
|
{triggerLabel !== null && (
|
|
94
|
-
<AlertDialogTrigger render={<Button variant="destructive" icon={Trash2} />}>
|
|
226
|
+
<AlertDialogTrigger ref={triggerRef} render={<Button variant="destructive" icon={Trash2} />}>
|
|
95
227
|
{triggerLabel ?? label}
|
|
96
228
|
</AlertDialogTrigger>
|
|
97
229
|
)}
|
|
98
|
-
<AlertDialogContent
|
|
230
|
+
<AlertDialogContent
|
|
231
|
+
ref={popupRef}
|
|
232
|
+
initialFocus={inputRef}
|
|
233
|
+
finalFocus={() => triggerRef.current ?? returnFocusRef?.current ?? previousFocus.current ?? true}
|
|
234
|
+
onFocusCapture={(event) => {
|
|
235
|
+
if (event.target !== event.currentTarget) focusedControl.current = event.target;
|
|
236
|
+
}}
|
|
237
|
+
>
|
|
99
238
|
<AlertDialogHeader tone="destructive">
|
|
100
239
|
<AlertDialogTitle>{title ?? `Delete ${resourceType}?`}</AlertDialogTitle>
|
|
101
240
|
<AlertDialogDescription>
|
|
@@ -107,36 +246,88 @@ export function ConfirmDestructive({
|
|
|
107
246
|
)}
|
|
108
247
|
</AlertDialogDescription>
|
|
109
248
|
</AlertDialogHeader>
|
|
110
|
-
{consequences.length > 0 && (
|
|
249
|
+
{!terminal && consequences.length > 0 && (
|
|
111
250
|
<ul className="mt-4 list-disc space-y-1 rounded-md border border-destructive/25 bg-destructive/5 py-2.5 pe-3 ps-8 text-[12.5px] text-muted-foreground">
|
|
112
251
|
{consequences.map((c) => (
|
|
113
252
|
<li key={c}>{c}</li>
|
|
114
253
|
))}
|
|
115
254
|
</ul>
|
|
116
255
|
)}
|
|
117
|
-
<form
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
256
|
+
<form
|
|
257
|
+
onSubmit={(e) => {
|
|
258
|
+
e.preventDefault();
|
|
259
|
+
void confirm();
|
|
260
|
+
}}
|
|
261
|
+
className="mt-4"
|
|
262
|
+
>
|
|
263
|
+
{terminal ? (
|
|
264
|
+
terminal.kind === 'no-access' ? (
|
|
265
|
+
<EmptyState icon={Lock} title={terminal.title} description={terminal.description ?? null} />
|
|
266
|
+
) : (
|
|
267
|
+
<NotFoundEmpty title={terminal.title} description={terminal.description ?? null} />
|
|
268
|
+
)
|
|
269
|
+
) : (
|
|
270
|
+
<>
|
|
271
|
+
{children}
|
|
272
|
+
<Field>
|
|
273
|
+
<FieldLabel>
|
|
274
|
+
Type <span className="font-mono font-semibold text-foreground">{resourceName}</span> to confirm
|
|
275
|
+
</FieldLabel>
|
|
276
|
+
<Input
|
|
277
|
+
ref={inputRef}
|
|
278
|
+
value={typed}
|
|
279
|
+
onChange={(e) => setTyped(e.target.value)}
|
|
280
|
+
autoComplete="off"
|
|
281
|
+
spellCheck={false}
|
|
282
|
+
readOnly={pending}
|
|
283
|
+
/>
|
|
284
|
+
</Field>
|
|
285
|
+
{(failure?.kind === 'server' || failure?.kind === 'network') && (
|
|
286
|
+
<div ref={panelRef}>
|
|
287
|
+
<ErrorPanel
|
|
288
|
+
title={failure.title}
|
|
289
|
+
message={failure.message ?? null}
|
|
290
|
+
code={failure.kind === 'server' ? (failure.status ?? null) : null}
|
|
291
|
+
requestId={failure.kind === 'server' ? (failure.requestId ?? '') : ''}
|
|
292
|
+
details=""
|
|
293
|
+
secondaryAction={null}
|
|
294
|
+
retrying={pending}
|
|
295
|
+
retryLabel={t('error.retry')}
|
|
296
|
+
onRetry={() => confirm()}
|
|
297
|
+
announce={false}
|
|
298
|
+
className="mt-4"
|
|
299
|
+
/>
|
|
300
|
+
</div>
|
|
301
|
+
)}
|
|
302
|
+
</>
|
|
303
|
+
)}
|
|
304
|
+
{error !== null && (
|
|
305
|
+
<p
|
|
306
|
+
key={announcementId}
|
|
307
|
+
role="alert"
|
|
308
|
+
className={failure ? 'sr-only' : 'mt-2 text-[12.5px] text-destructive-text'}
|
|
309
|
+
>
|
|
132
310
|
{error}
|
|
133
311
|
</p>
|
|
134
312
|
)}
|
|
135
313
|
<AlertDialogFooter className="mt-5">
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
314
|
+
{terminal ? (
|
|
315
|
+
<AlertDialogCancel ref={closeRef}>{t('common.close')}</AlertDialogCancel>
|
|
316
|
+
) : (
|
|
317
|
+
<>
|
|
318
|
+
<AlertDialogCancel ref={cancelRef} disabled={pending}>
|
|
319
|
+
{t('common.cancel')}
|
|
320
|
+
</AlertDialogCancel>
|
|
321
|
+
<Button
|
|
322
|
+
type="submit"
|
|
323
|
+
variant="destructive"
|
|
324
|
+
loading={pending}
|
|
325
|
+
disabled={!matches || canConfirm === false}
|
|
326
|
+
>
|
|
327
|
+
{label}
|
|
328
|
+
</Button>
|
|
329
|
+
</>
|
|
330
|
+
)}
|
|
140
331
|
</AlertDialogFooter>
|
|
141
332
|
</form>
|
|
142
333
|
</AlertDialogContent>
|
|
@@ -5,6 +5,6 @@ export const meta: BlockMeta = {
|
|
|
5
5
|
family: 'feedback',
|
|
6
6
|
status: 'beta',
|
|
7
7
|
description:
|
|
8
|
-
'AlertDialog for irreversible actions
|
|
9
|
-
uses: ['AlertDialog', 'Button', 'Field', 'Input'],
|
|
8
|
+
'AlertDialog for irreversible actions with exact typed-name and optional canConfirm gating, extra form fields, typed failure states, guarded retry and focus return. See the plain deletion and secret revocation examples.',
|
|
9
|
+
uses: ['AlertDialog', 'Button', 'Field', 'Input', 'EmptyState'],
|
|
10
10
|
};
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { ConfirmDestructiveFailure } from '../ConfirmDestructive';
|
|
2
|
+
import SecretRevocation from './SecretRevocation';
|
|
3
|
+
|
|
4
|
+
// Consumers map HTTP and fetch failures; the dialog never infers a state from a status.
|
|
5
|
+
const server: ConfirmDestructiveFailure = {
|
|
6
|
+
kind: 'server',
|
|
7
|
+
title: 'Could not revoke version',
|
|
8
|
+
message: 'Try again after checking the service.',
|
|
9
|
+
status: 500,
|
|
10
|
+
requestId: 'req_revoke_7',
|
|
11
|
+
};
|
|
12
|
+
const network: ConfirmDestructiveFailure = {
|
|
13
|
+
kind: 'network',
|
|
14
|
+
title: 'Connection lost',
|
|
15
|
+
message: 'Check your connection and try again.',
|
|
16
|
+
};
|
|
17
|
+
const noAccess: ConfirmDestructiveFailure = {
|
|
18
|
+
kind: 'no-access',
|
|
19
|
+
title: 'You cannot revoke this version',
|
|
20
|
+
description: 'Ask an administrator to review your permissions.',
|
|
21
|
+
};
|
|
22
|
+
const notFound: ConfirmDestructiveFailure = {
|
|
23
|
+
kind: 'not-found',
|
|
24
|
+
title: 'Version not found',
|
|
25
|
+
description: 'This version has already been removed.',
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export function ServerFailure() {
|
|
29
|
+
return <SecretRevocation onConfirm={() => Promise.reject(server)} />;
|
|
30
|
+
}
|
|
31
|
+
export function NetworkFailure() {
|
|
32
|
+
return <SecretRevocation onConfirm={() => Promise.reject(network)} />;
|
|
33
|
+
}
|
|
34
|
+
export function NoAccessFailure() {
|
|
35
|
+
return <SecretRevocation onConfirm={() => Promise.reject(noAccess)} />;
|
|
36
|
+
}
|
|
37
|
+
export function NotFoundFailure() {
|
|
38
|
+
return <SecretRevocation onConfirm={() => Promise.reject(notFound)} />;
|
|
39
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { Checkbox, Field, FieldLabel, Stack, Textarea } from '@gntik-ai/ui';
|
|
2
|
+
import { useState } from 'react';
|
|
3
|
+
import { ConfirmDestructive } from '../ConfirmDestructive';
|
|
4
|
+
|
|
5
|
+
/** Justification is optional here; use canConfirm to require it for a different policy. */
|
|
6
|
+
export default function SecretRevocation({ onConfirm }: { onConfirm?: () => void | Promise<void> }) {
|
|
7
|
+
const [justification, setJustification] = useState('');
|
|
8
|
+
const [forceRevoke, setForceRevoke] = useState(false);
|
|
9
|
+
return (
|
|
10
|
+
<ConfirmDestructive
|
|
11
|
+
resourceName="version-7"
|
|
12
|
+
resourceType="secret version"
|
|
13
|
+
title="Revoke secret version?"
|
|
14
|
+
confirmLabel="Revoke version"
|
|
15
|
+
description="This is the last valid version. Revoking it may interrupt services that still use it."
|
|
16
|
+
consequences={['Services must switch to a valid version before this one is revoked.']}
|
|
17
|
+
onConfirm={onConfirm}
|
|
18
|
+
onOpenChange={(open) => {
|
|
19
|
+
if (!open) {
|
|
20
|
+
setJustification('');
|
|
21
|
+
setForceRevoke(false);
|
|
22
|
+
}
|
|
23
|
+
}}
|
|
24
|
+
>
|
|
25
|
+
<Stack gap={3} className="mb-4">
|
|
26
|
+
<Field>
|
|
27
|
+
<FieldLabel>Justification</FieldLabel>
|
|
28
|
+
<Textarea value={justification} onChange={(event) => setJustification(event.target.value)} />
|
|
29
|
+
</Field>
|
|
30
|
+
<Checkbox label="Force revocation" checked={forceRevoke} onCheckedChange={setForceRevoke} />
|
|
31
|
+
</Stack>
|
|
32
|
+
</ConfirmDestructive>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
@@ -1,9 +1,19 @@
|
|
|
1
1
|
import { render, screen } from '@testing-library/react';
|
|
2
2
|
import userEvent from '@testing-library/user-event';
|
|
3
3
|
import { expectNoAxeViolations } from '../../test/a11y';
|
|
4
|
-
import { FirstRunEmpty, NoAccessEmpty, NoResultsEmpty } from './EmptyStates';
|
|
4
|
+
import { FirstRunEmpty, NoAccessEmpty, NoResultsEmpty, NotFoundEmpty } from './EmptyStates';
|
|
5
5
|
|
|
6
6
|
describe('EmptyStates', () => {
|
|
7
|
+
it('renders not found copy without actions or live regions, and accepts an action', async () => {
|
|
8
|
+
const { container, rerender } = render(<NotFoundEmpty title="Resource missing" description="It was deleted." />);
|
|
9
|
+
expect(screen.getByRole('heading', { name: 'Resource missing' })).toBeVisible();
|
|
10
|
+
expect(screen.getByText('It was deleted.')).toBeVisible();
|
|
11
|
+
expect(screen.queryByRole('button')).not.toBeInTheDocument();
|
|
12
|
+
expect(container.querySelector('[role="alert"], [role="status"], [aria-live]')).toBeNull();
|
|
13
|
+
await expectNoAxeViolations(container);
|
|
14
|
+
rerender(<NotFoundEmpty action={<button>Go back</button>} />);
|
|
15
|
+
expect(screen.getByRole('button', { name: 'Go back' })).toBeVisible();
|
|
16
|
+
});
|
|
7
17
|
it('renders the three defaults', async () => {
|
|
8
18
|
const { container } = render(
|
|
9
19
|
<>
|
|
@@ -2,7 +2,7 @@ import { Check, FolderPlus, Lock, SearchX } from '@gntik-ai/icons';
|
|
|
2
2
|
import type { LucideIcon } from '@gntik-ai/icons';
|
|
3
3
|
import { Button, EmptyState, Link, cn } from '@gntik-ai/ui';
|
|
4
4
|
import { useState, type ReactNode } from 'react';
|
|
5
|
-
import { firstRunSteps, noAccessSample, noResultsSample } from './fixtures';
|
|
5
|
+
import { firstRunSteps, noAccessSample, noResultsSample, notFoundSample } from './fixtures';
|
|
6
6
|
|
|
7
7
|
interface SharedEmptyProps {
|
|
8
8
|
/** Heading level that fits the page outline. */
|
|
@@ -12,6 +12,36 @@ interface SharedEmptyProps {
|
|
|
12
12
|
className?: string;
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
+
export interface NotFoundEmptyProps extends SharedEmptyProps {
|
|
16
|
+
icon?: LucideIcon;
|
|
17
|
+
title?: ReactNode;
|
|
18
|
+
description?: ReactNode;
|
|
19
|
+
action?: ReactNode;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Missing resource state; consumers may supply an optional way back. */
|
|
23
|
+
export function NotFoundEmpty({
|
|
24
|
+
icon = SearchX,
|
|
25
|
+
title = notFoundSample.title,
|
|
26
|
+
description = notFoundSample.description,
|
|
27
|
+
action,
|
|
28
|
+
titleAs,
|
|
29
|
+
bordered = true,
|
|
30
|
+
className,
|
|
31
|
+
}: NotFoundEmptyProps) {
|
|
32
|
+
return (
|
|
33
|
+
<EmptyState
|
|
34
|
+
icon={icon}
|
|
35
|
+
title={title}
|
|
36
|
+
description={description}
|
|
37
|
+
primaryAction={action}
|
|
38
|
+
titleAs={titleAs}
|
|
39
|
+
bordered={bordered}
|
|
40
|
+
className={cn('mx-auto', className)}
|
|
41
|
+
/>
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
15
45
|
export interface FirstRunEmptyProps extends SharedEmptyProps {
|
|
16
46
|
icon?: LucideIcon;
|
|
17
47
|
title?: ReactNode;
|
|
@@ -5,6 +5,6 @@ export const meta: BlockMeta = {
|
|
|
5
5
|
family: 'feedback',
|
|
6
6
|
status: 'beta',
|
|
7
7
|
description:
|
|
8
|
-
'
|
|
8
|
+
'Four canonical empty states: first run (with checklist), no results (clear filters/search), no access (request access) and not found (optional action).',
|
|
9
9
|
uses: ['EmptyState', 'Button', 'Link'],
|
|
10
10
|
};
|
|
@@ -4,3 +4,8 @@ export const firstRunSteps = ['Connect a Git repository', 'Configure build setti
|
|
|
4
4
|
export const noResultsSample = { query: 'billing-api', filterCount: 2, entity: 'projects' } as const;
|
|
5
5
|
|
|
6
6
|
export const noAccessSample = { resource: 'Billing', owner: 'an organization admin' } as const;
|
|
7
|
+
|
|
8
|
+
export const notFoundSample = {
|
|
9
|
+
title: 'Resource not found',
|
|
10
|
+
description: 'This resource may have been deleted or moved.',
|
|
11
|
+
} as const;
|
|
@@ -4,6 +4,13 @@ import { expectNoAxeViolations } from '../../test/a11y';
|
|
|
4
4
|
import { ErrorPanel } from './ErrorPanel';
|
|
5
5
|
|
|
6
6
|
describe('ErrorPanel', () => {
|
|
7
|
+
it('allows its caller to own announcements while preserving the default status region', () => {
|
|
8
|
+
const { rerender } = render(<ErrorPanel />);
|
|
9
|
+
expect(screen.getByRole('status')).toBeInTheDocument();
|
|
10
|
+
rerender(<ErrorPanel announce={false} />);
|
|
11
|
+
expect(screen.queryByRole('status')).not.toBeInTheDocument();
|
|
12
|
+
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
|
|
13
|
+
});
|
|
7
14
|
it('accepts a null code to hide the fixture chip', () => {
|
|
8
15
|
render(<ErrorPanel code={null} />);
|
|
9
16
|
expect(screen.queryByText('503')).not.toBeInTheDocument();
|
|
@@ -24,6 +24,8 @@ export interface ErrorPanelProps {
|
|
|
24
24
|
secondaryAction?: FeedbackAction | null;
|
|
25
25
|
titleAs?: 'h2' | 'h3';
|
|
26
26
|
className?: string;
|
|
27
|
+
/** Disable the status region when a parent owns announcements. */
|
|
28
|
+
announce?: boolean;
|
|
27
29
|
}
|
|
28
30
|
|
|
29
31
|
/** Error summary for a failed load or action: what happened, request id, retry and technical details. */
|
|
@@ -40,6 +42,7 @@ export function ErrorPanel({
|
|
|
40
42
|
secondaryAction = { label: 'Contact support', variant: 'ghost' },
|
|
41
43
|
titleAs: Heading = 'h3',
|
|
42
44
|
className,
|
|
45
|
+
announce = true,
|
|
43
46
|
}: ErrorPanelProps) {
|
|
44
47
|
const { t } = useI18n();
|
|
45
48
|
const retryLabel = retryLabelProp ?? t('error.retry');
|
|
@@ -136,9 +139,11 @@ export function ErrorPanel({
|
|
|
136
139
|
</CollapsiblePanel>
|
|
137
140
|
</Collapsible>
|
|
138
141
|
)}
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
+
{announce && (
|
|
143
|
+
<p role="status" className="sr-only">
|
|
144
|
+
{busy ? t('error.retryingStatus') : copied ? t('error.requestIdCopied') : ''}
|
|
145
|
+
</p>
|
|
146
|
+
)}
|
|
142
147
|
</div>
|
|
143
148
|
</div>
|
|
144
149
|
</section>
|