@tai42/studio-sdk 0.12.0 → 0.13.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/components/code-block.d.ts.map +1 -1
- package/dist/components/code-block.js +26 -13
- package/dist/components/code-block.js.map +1 -1
- package/dist/components/components.css +20 -0
- package/dist/components/copy-field.d.ts.map +1 -1
- package/dist/components/copy-field.js +8 -73
- package/dist/components/copy-field.js.map +1 -1
- package/dist/components/date-range-picker.d.ts +73 -0
- package/dist/components/date-range-picker.d.ts.map +1 -0
- package/dist/components/date-range-picker.js +146 -0
- package/dist/components/date-range-picker.js.map +1 -0
- package/dist/components/dialog.d.ts +19 -1
- package/dist/components/dialog.d.ts.map +1 -1
- package/dist/components/dialog.js +16 -13
- package/dist/components/dialog.js.map +1 -1
- package/dist/components/dirty-guard.d.ts +28 -0
- package/dist/components/dirty-guard.d.ts.map +1 -0
- package/dist/components/dirty-guard.js +93 -0
- package/dist/components/dirty-guard.js.map +1 -0
- package/dist/components/extension-picker.d.ts.map +1 -1
- package/dist/components/extension-picker.js +7 -0
- package/dist/components/extension-picker.js.map +1 -1
- package/dist/components/form-dialog.d.ts +45 -0
- package/dist/components/form-dialog.d.ts.map +1 -0
- package/dist/components/form-dialog.js +71 -0
- package/dist/components/form-dialog.js.map +1 -0
- package/dist/components/json-tree.d.ts +7 -1
- package/dist/components/json-tree.d.ts.map +1 -1
- package/dist/components/json-tree.js +300 -13
- package/dist/components/json-tree.js.map +1 -1
- package/dist/components/markdown.d.ts +60 -0
- package/dist/components/markdown.d.ts.map +1 -0
- package/dist/components/markdown.js +435 -0
- package/dist/components/markdown.js.map +1 -0
- package/dist/components/tabs.d.ts +3 -1
- package/dist/components/tabs.d.ts.map +1 -1
- package/dist/components/tabs.js +2 -2
- package/dist/components/tabs.js.map +1 -1
- package/dist/hooks/useApi.d.ts +56 -28
- package/dist/hooks/useApi.d.ts.map +1 -1
- package/dist/hooks/useClipboardCopy.d.ts +26 -0
- package/dist/hooks/useClipboardCopy.d.ts.map +1 -0
- package/dist/hooks/useClipboardCopy.js +72 -0
- package/dist/hooks/useClipboardCopy.js.map +1 -0
- package/dist/index.d.ts +9 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -1
- package/dist/index.js.map +1 -1
- package/dist/navigation/context.d.ts +13 -1
- package/dist/navigation/context.d.ts.map +1 -1
- package/dist/navigation/context.js +205 -7
- package/dist/navigation/context.js.map +1 -1
- package/dist/navigation/index.d.ts +2 -2
- package/dist/navigation/index.d.ts.map +1 -1
- package/dist/navigation/index.js +1 -1
- package/dist/navigation/index.js.map +1 -1
- package/dist/navigation/types.d.ts +11 -0
- package/dist/navigation/types.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/components/code-block.tsx +72 -9
- package/src/components/components.css +20 -0
- package/src/components/copy-field.tsx +25 -81
- package/src/components/date-range-picker.tsx +256 -0
- package/src/components/dialog.tsx +59 -18
- package/src/components/dirty-guard.tsx +153 -0
- package/src/components/extension-picker.tsx +12 -0
- package/src/components/form-dialog.tsx +133 -0
- package/src/components/json-tree.tsx +477 -42
- package/src/components/markdown.tsx +552 -0
- package/src/components/tabs.tsx +10 -1
- package/src/hooks/useClipboardCopy.ts +105 -0
- package/src/index.ts +18 -0
- package/src/navigation/context.tsx +245 -7
- package/src/navigation/index.ts +2 -0
- package/src/navigation/types.ts +12 -0
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `DateRangePicker` — the compact time-window control observability screens share.
|
|
3
|
+
* It emits ONE of two shapes:
|
|
4
|
+
*
|
|
5
|
+
* - a relative token from a PRESET chip, `{ kind: 'relative', token }` — the backend
|
|
6
|
+
* resolves `\d+[hdw]` tokens, so a preset stays a token rather than freezing to an
|
|
7
|
+
* instant at click time;
|
|
8
|
+
* - an ABSOLUTE range from the two native `datetime-local` inputs,
|
|
9
|
+
* `{ kind: 'absolute', from, to }` with ISO instants — native inputs keep the
|
|
10
|
+
* control inside the platform date UI, no third-party calendar.
|
|
11
|
+
*
|
|
12
|
+
* Absolute ranges are normalized by {@link normalizeCustomRange}; the active window
|
|
13
|
+
* is rendered by {@link formatRangeLabel}, always with the YEAR present.
|
|
14
|
+
*/
|
|
15
|
+
import { useState, type CSSProperties, type ReactNode } from 'react';
|
|
16
|
+
|
|
17
|
+
import { Field } from './field';
|
|
18
|
+
import { TextInput } from './inputs';
|
|
19
|
+
import { Button } from './primitives';
|
|
20
|
+
|
|
21
|
+
/** A relative preset: a `\d+[hdw]` token the backend resolves, plus its chip label. */
|
|
22
|
+
export interface DateRangePreset {
|
|
23
|
+
readonly token: string;
|
|
24
|
+
readonly label: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The presets a picker offers when the caller names none. */
|
|
28
|
+
export const DEFAULT_DATE_RANGE_PRESETS: readonly DateRangePreset[] = [
|
|
29
|
+
{ token: '24h', label: 'Last 24 hours' },
|
|
30
|
+
{ token: '7d', label: 'Last 7 days' },
|
|
31
|
+
{ token: '30d', label: 'Last 30 days' },
|
|
32
|
+
{ token: '90d', label: 'Last 90 days' },
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* A named time window: either a relative token the backend resolves against its
|
|
37
|
+
* own clock, or an absolute pair of ISO instants.
|
|
38
|
+
*/
|
|
39
|
+
export type DateRangeValue =
|
|
40
|
+
| { readonly kind: 'relative'; readonly token: string }
|
|
41
|
+
| { readonly kind: 'absolute'; readonly from: string; readonly to: string };
|
|
42
|
+
|
|
43
|
+
const RELATIVE_TOKEN = /^(\d+)([hdw])$/;
|
|
44
|
+
|
|
45
|
+
const UNIT_NOUN: Record<string, string> = { h: 'hour', d: 'day', w: 'week' };
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* A relative token as a readable phrase (`7d` → `Last 7 days`). A token outside the
|
|
49
|
+
* `\d+[hdw]` grammar raises rather than rendering a silent placeholder.
|
|
50
|
+
*/
|
|
51
|
+
function relativePhrase(token: string): string {
|
|
52
|
+
const match = RELATIVE_TOKEN.exec(token);
|
|
53
|
+
if (match === null) {
|
|
54
|
+
throw new Error(`Invalid relative range token: ${token}`);
|
|
55
|
+
}
|
|
56
|
+
const amount = Number(match[1]);
|
|
57
|
+
const noun = UNIT_NOUN[match[2] ?? ''] ?? '';
|
|
58
|
+
return `Last ${String(amount)} ${noun}${amount === 1 ? '' : 's'}`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The active window as human text: a relative token as its preset label (or a
|
|
63
|
+
* derived phrase), an absolute range as both instants with the YEAR present.
|
|
64
|
+
*/
|
|
65
|
+
const ABSOLUTE_FORMAT = new Intl.DateTimeFormat(undefined, {
|
|
66
|
+
year: 'numeric',
|
|
67
|
+
month: 'short',
|
|
68
|
+
day: 'numeric',
|
|
69
|
+
hour: '2-digit',
|
|
70
|
+
minute: '2-digit',
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
export function formatRangeLabel(
|
|
74
|
+
value: DateRangeValue,
|
|
75
|
+
presets: readonly DateRangePreset[] = DEFAULT_DATE_RANGE_PRESETS,
|
|
76
|
+
): string {
|
|
77
|
+
if (value.kind === 'relative') {
|
|
78
|
+
const preset = presets.find((candidate) => candidate.token === value.token);
|
|
79
|
+
return preset?.label ?? relativePhrase(value.token);
|
|
80
|
+
}
|
|
81
|
+
const from = new Date(value.from);
|
|
82
|
+
const to = new Date(value.to);
|
|
83
|
+
return `${ABSOLUTE_FORMAT.format(from)} – ${ABSOLUTE_FORMAT.format(to)}`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** A `datetime-local` string parsed as a local instant, or a loud error on garbage. */
|
|
87
|
+
function parseLocalInput(value: string, field: string): Date {
|
|
88
|
+
const parsed = new Date(value);
|
|
89
|
+
if (Number.isNaN(parsed.getTime())) {
|
|
90
|
+
throw new Error(`Invalid ${field} datetime: ${value}`);
|
|
91
|
+
}
|
|
92
|
+
return parsed;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function pad(value: number): string {
|
|
96
|
+
return String(value).padStart(2, '0');
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** A local instant as the `YYYY-MM-DDTHH:mm` a `datetime-local` input reads. */
|
|
100
|
+
function toLocalInputValue(date: Date): string {
|
|
101
|
+
return (
|
|
102
|
+
`${String(date.getFullYear())}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}` +
|
|
103
|
+
`T${pad(date.getHours())}:${pad(date.getMinutes())}`
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* A raw from/to pair resolved to a clean absolute range. Order matters: SWAP the
|
|
109
|
+
* reversed pair first (earlier instant becomes start), THEN cap both ends at `now` —
|
|
110
|
+
* capping first can leave a future instant on the post-swap end. The end is
|
|
111
|
+
* INCLUSIVE of its minute, extended to `:59.999`, so `23:59` reaches the day's end.
|
|
112
|
+
*/
|
|
113
|
+
export function normalizeCustomRange(
|
|
114
|
+
fromInput: string,
|
|
115
|
+
toInput: string,
|
|
116
|
+
now: Date,
|
|
117
|
+
): { readonly from: string; readonly to: string } {
|
|
118
|
+
let start = parseLocalInput(fromInput, 'from');
|
|
119
|
+
let end = parseLocalInput(toInput, 'to');
|
|
120
|
+
|
|
121
|
+
if (start.getTime() > end.getTime()) {
|
|
122
|
+
[start, end] = [end, start];
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Inclusive end: extend the selected minute to its final millisecond.
|
|
126
|
+
end = new Date(end);
|
|
127
|
+
end.setSeconds(59, 999);
|
|
128
|
+
|
|
129
|
+
const nowMs = now.getTime();
|
|
130
|
+
if (start.getTime() > nowMs) start = new Date(nowMs);
|
|
131
|
+
if (end.getTime() > nowMs) end = new Date(nowMs);
|
|
132
|
+
|
|
133
|
+
return { from: start.toISOString(), to: end.toISOString() };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export interface DateRangePickerProps {
|
|
137
|
+
readonly value: DateRangeValue;
|
|
138
|
+
readonly onValueChange: (value: DateRangeValue) => void;
|
|
139
|
+
/** The preset chips to offer. Defaults to {@link DEFAULT_DATE_RANGE_PRESETS}. */
|
|
140
|
+
readonly presets?: readonly DateRangePreset[];
|
|
141
|
+
/** The control group's accessible name. */
|
|
142
|
+
readonly 'aria-label'?: string;
|
|
143
|
+
readonly disabled?: boolean;
|
|
144
|
+
/**
|
|
145
|
+
* The clock the absolute-range clamp reads. Defaults to the wall clock; a caller
|
|
146
|
+
* (or a test) injects a fixed instant to pin the "no future" boundary.
|
|
147
|
+
*/
|
|
148
|
+
readonly now?: () => Date;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const CUSTOM_ROW_STYLE: CSSProperties = { alignItems: 'flex-end' };
|
|
152
|
+
const FIELD_STYLE: CSSProperties = { flex: 1 };
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* The range control: a row of preset chips over the two `datetime-local` inputs and
|
|
156
|
+
* an Apply action, with the active window echoed as text below.
|
|
157
|
+
*
|
|
158
|
+
* Controlled: the caller owns `value` and receives the next window through
|
|
159
|
+
* `onValueChange` — a preset click emits its token immediately, while the custom
|
|
160
|
+
* inputs hold a working draft until Apply commits the normalized absolute range.
|
|
161
|
+
*
|
|
162
|
+
* The drafts track the controlled value, re-seeded on a NEW absolute `value` (keyed
|
|
163
|
+
* on `from`/`to`), so edits against an unchanged value are preserved but a genuinely
|
|
164
|
+
* different value replaces an in-progress draft.
|
|
165
|
+
*/
|
|
166
|
+
export function DateRangePicker({
|
|
167
|
+
value,
|
|
168
|
+
onValueChange,
|
|
169
|
+
presets = DEFAULT_DATE_RANGE_PRESETS,
|
|
170
|
+
'aria-label': ariaLabel = 'Date range',
|
|
171
|
+
disabled = false,
|
|
172
|
+
now = () => new Date(),
|
|
173
|
+
}: DateRangePickerProps): ReactNode {
|
|
174
|
+
const nextFrom = value.kind === 'absolute' ? value.from : null;
|
|
175
|
+
const nextTo = value.kind === 'absolute' ? value.to : null;
|
|
176
|
+
|
|
177
|
+
const [fromDraft, setFromDraft] = useState(() =>
|
|
178
|
+
nextFrom !== null ? toLocalInputValue(new Date(nextFrom)) : '',
|
|
179
|
+
);
|
|
180
|
+
const [toDraft, setToDraft] = useState(() =>
|
|
181
|
+
nextTo !== null ? toLocalInputValue(new Date(nextTo)) : '',
|
|
182
|
+
);
|
|
183
|
+
// The absolute ends that last seeded the drafts (null while a preset is active).
|
|
184
|
+
// Comparing against the incoming ends re-seeds only when the caller swaps in a
|
|
185
|
+
// different absolute value, leaving drafts typed against an unchanged value alone.
|
|
186
|
+
const [seeded, setSeeded] = useState<{ from: string | null; to: string | null }>(() => ({
|
|
187
|
+
from: nextFrom,
|
|
188
|
+
to: nextTo,
|
|
189
|
+
}));
|
|
190
|
+
|
|
191
|
+
if (seeded.from !== nextFrom || seeded.to !== nextTo) {
|
|
192
|
+
setSeeded({ from: nextFrom, to: nextTo });
|
|
193
|
+
if (nextFrom !== null && nextTo !== null) {
|
|
194
|
+
setFromDraft(toLocalInputValue(new Date(nextFrom)));
|
|
195
|
+
setToDraft(toLocalInputValue(new Date(nextTo)));
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const activeToken = value.kind === 'relative' ? value.token : null;
|
|
200
|
+
const canApply = !disabled && fromDraft !== '' && toDraft !== '';
|
|
201
|
+
|
|
202
|
+
const applyCustom = (): void => {
|
|
203
|
+
onValueChange({ kind: 'absolute', ...normalizeCustomRange(fromDraft, toDraft, now()) });
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
return (
|
|
207
|
+
<div role="group" aria-label={ariaLabel} className="tai-stack tai-stack-3">
|
|
208
|
+
<div className="tai-row">
|
|
209
|
+
{presets.map((preset) => (
|
|
210
|
+
<button
|
|
211
|
+
key={preset.token}
|
|
212
|
+
type="button"
|
|
213
|
+
className="tai-chip"
|
|
214
|
+
aria-pressed={activeToken === preset.token}
|
|
215
|
+
onClick={() => {
|
|
216
|
+
onValueChange({ kind: 'relative', token: preset.token });
|
|
217
|
+
}}
|
|
218
|
+
disabled={disabled}
|
|
219
|
+
>
|
|
220
|
+
{preset.label}
|
|
221
|
+
</button>
|
|
222
|
+
))}
|
|
223
|
+
</div>
|
|
224
|
+
|
|
225
|
+
<div className="tai-row" style={CUSTOM_ROW_STYLE}>
|
|
226
|
+
<Field label="From" style={FIELD_STYLE}>
|
|
227
|
+
<TextInput
|
|
228
|
+
type="datetime-local"
|
|
229
|
+
value={fromDraft}
|
|
230
|
+
onChange={(event) => {
|
|
231
|
+
setFromDraft(event.target.value);
|
|
232
|
+
}}
|
|
233
|
+
disabled={disabled}
|
|
234
|
+
/>
|
|
235
|
+
</Field>
|
|
236
|
+
<Field label="To" style={FIELD_STYLE}>
|
|
237
|
+
<TextInput
|
|
238
|
+
type="datetime-local"
|
|
239
|
+
value={toDraft}
|
|
240
|
+
onChange={(event) => {
|
|
241
|
+
setToDraft(event.target.value);
|
|
242
|
+
}}
|
|
243
|
+
disabled={disabled}
|
|
244
|
+
/>
|
|
245
|
+
</Field>
|
|
246
|
+
<Button type="button" variant="primary" onClick={applyCustom} disabled={!canApply}>
|
|
247
|
+
Apply range
|
|
248
|
+
</Button>
|
|
249
|
+
</div>
|
|
250
|
+
|
|
251
|
+
<p role="status" className="tai-muted" style={{ margin: 0 }}>
|
|
252
|
+
{formatRangeLabel(value, presets)}
|
|
253
|
+
</p>
|
|
254
|
+
</div>
|
|
255
|
+
);
|
|
256
|
+
}
|
|
@@ -1,16 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `Dialog` — a design-system wrapper over Radix Dialog: an accessible modal (role
|
|
3
|
-
* `dialog`, focus trap, Escape to close) labelled by its title. Modality
|
|
4
|
-
*
|
|
5
|
-
* by an `aria-modal` attribute — the panel ships none.
|
|
3
|
+
* `dialog`, focus trap, Escape to close) labelled by its title. Modality comes from
|
|
4
|
+
* the focus trap and Radix marking the page inert, not an `aria-modal` attribute.
|
|
6
5
|
*
|
|
7
|
-
* Radix owns every modal behaviour
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* `useModalFocusReturn`, which stands down whenever a trigger IS rendered.
|
|
6
|
+
* Radix owns every modal behaviour (focus trap, scroll lock, Escape, focus return
|
|
7
|
+
* to the trigger); this component adds only the design-system surface (`tai-overlay`
|
|
8
|
+
* scrim, `tai-dialog` panel). The one gap Radix leaves — focus return for a dialog
|
|
9
|
+
* with no trigger — is filled by `useModalFocusReturn`, which stands down when a
|
|
10
|
+
* trigger IS rendered. The `fullscreen`, `contentClassName` and `chromeless` opt-ins
|
|
11
|
+
* (see the props) reshape the panel without giving up any Radix behaviour.
|
|
14
12
|
*/
|
|
15
13
|
import * as RadixDialog from '@radix-ui/react-dialog';
|
|
16
14
|
import type { ReactElement, ReactNode } from 'react';
|
|
@@ -35,6 +33,24 @@ export interface DialogProps {
|
|
|
35
33
|
readonly open?: boolean;
|
|
36
34
|
readonly defaultOpen?: boolean;
|
|
37
35
|
readonly onOpenChange?: (open: boolean) => void;
|
|
36
|
+
/**
|
|
37
|
+
* Fill the viewport edge to edge instead of centring a fixed-size panel. The
|
|
38
|
+
* overlay, the focus trap and every other Radix behaviour are unchanged.
|
|
39
|
+
*/
|
|
40
|
+
readonly fullscreen?: boolean;
|
|
41
|
+
/**
|
|
42
|
+
* A class merged onto `RadixDialog.Content` after the surface classes, for a
|
|
43
|
+
* host that must reach the content element itself — e.g. a plugin hanging its
|
|
44
|
+
* CSS-scoping root on the panel so its styles apply inside the Radix portal.
|
|
45
|
+
*/
|
|
46
|
+
readonly contentClassName?: string;
|
|
47
|
+
/**
|
|
48
|
+
* Drop the forced visual `Title` and the `tai-stack` children wrapper, rendering
|
|
49
|
+
* `children` directly for a chrome-free content mode. The `title` is still
|
|
50
|
+
* required and still names the dialog — it renders visually-hidden — so the
|
|
51
|
+
* accessible name never depends on the visible chrome.
|
|
52
|
+
*/
|
|
53
|
+
readonly chromeless?: boolean;
|
|
38
54
|
}
|
|
39
55
|
|
|
40
56
|
export function Dialog({
|
|
@@ -45,9 +61,17 @@ export function Dialog({
|
|
|
45
61
|
open,
|
|
46
62
|
defaultOpen,
|
|
47
63
|
onOpenChange,
|
|
64
|
+
fullscreen = false,
|
|
65
|
+
contentClassName,
|
|
66
|
+
chromeless = false,
|
|
48
67
|
}: DialogProps) {
|
|
49
68
|
const focusReturn = useModalFocusReturn(trigger !== undefined);
|
|
50
69
|
if (trigger !== undefined) assertSlotElement(trigger, 'Dialog `trigger`');
|
|
70
|
+
|
|
71
|
+
const contentClasses = ['tai-dialog'];
|
|
72
|
+
if (fullscreen) contentClasses.push('tai-dialog-fullscreen');
|
|
73
|
+
if (contentClassName !== undefined) contentClasses.push(contentClassName);
|
|
74
|
+
|
|
51
75
|
return (
|
|
52
76
|
<RadixDialog.Root open={open} defaultOpen={defaultOpen} onOpenChange={onOpenChange}>
|
|
53
77
|
{trigger !== undefined ? <RadixDialog.Trigger asChild>{trigger}</RadixDialog.Trigger> : null}
|
|
@@ -58,17 +82,34 @@ export function Dialog({
|
|
|
58
82
|
Passing the prop as `undefined` is Radix's opt-out — it has to be
|
|
59
83
|
ABSENT when a description IS rendered, or it would clear the wiring. */}
|
|
60
84
|
<RadixDialog.Content
|
|
61
|
-
className=
|
|
85
|
+
className={contentClasses.join(' ')}
|
|
62
86
|
{...focusReturn}
|
|
63
87
|
{...(description === undefined ? { 'aria-describedby': undefined } : {})}
|
|
64
88
|
>
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
89
|
+
{/* The title always names the dialog; `chromeless` only hides it, moving
|
|
90
|
+
it behind `tai-visually-hidden` so the accessible name is unchanged. */}
|
|
91
|
+
<RadixDialog.Title className={chromeless ? 'tai-visually-hidden' : 'tai-dialog-title'}>
|
|
92
|
+
{title}
|
|
93
|
+
</RadixDialog.Title>
|
|
94
|
+
{chromeless ? (
|
|
95
|
+
<>
|
|
96
|
+
{description !== undefined ? (
|
|
97
|
+
<RadixDialog.Description className="tai-visually-hidden">
|
|
98
|
+
{description}
|
|
99
|
+
</RadixDialog.Description>
|
|
100
|
+
) : null}
|
|
101
|
+
{children}
|
|
102
|
+
</>
|
|
103
|
+
) : (
|
|
104
|
+
<div className="tai-stack">
|
|
105
|
+
{description !== undefined ? (
|
|
106
|
+
<RadixDialog.Description className="tai-muted">
|
|
107
|
+
{description}
|
|
108
|
+
</RadixDialog.Description>
|
|
109
|
+
) : null}
|
|
110
|
+
{children}
|
|
111
|
+
</div>
|
|
112
|
+
)}
|
|
72
113
|
</RadixDialog.Content>
|
|
73
114
|
</RadixDialog.Portal>
|
|
74
115
|
</RadixDialog.Root>
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unsaved-changes guard for tabbed editors. `Tabs` unmount inactive panels, so a
|
|
3
|
+
* tab switch tears an editor down and drops its edits. One registry closes the gap:
|
|
4
|
+
* editors report dirtiness via {@link useRegisterDirty}; {@link GuardedTabs} holds a
|
|
5
|
+
* switch away from a dirty editor behind a confirm; the same registry arms
|
|
6
|
+
* {@link useNavigationGuard}, so a route navigation confirms through the SAME dialog
|
|
7
|
+
* and a full-page unload raises the native prompt. The dialog is one shared
|
|
8
|
+
* {@link ConfirmDialog} driven by a pending-promise resolver, so the guard's
|
|
9
|
+
* `Promise<boolean>` handler and the tab switch both settle against it.
|
|
10
|
+
*/
|
|
11
|
+
import {
|
|
12
|
+
createContext,
|
|
13
|
+
useCallback,
|
|
14
|
+
useContext,
|
|
15
|
+
useEffect,
|
|
16
|
+
useId,
|
|
17
|
+
useRef,
|
|
18
|
+
useState,
|
|
19
|
+
type ReactNode,
|
|
20
|
+
} from 'react';
|
|
21
|
+
|
|
22
|
+
import { ConfirmDialog } from './confirm-dialog';
|
|
23
|
+
import { Tabs } from './tabs';
|
|
24
|
+
import type { TabItem } from './tabs';
|
|
25
|
+
import { useNavigationGuard } from '../navigation';
|
|
26
|
+
|
|
27
|
+
interface DirtyGuardValue {
|
|
28
|
+
/** Register (or clear) one editor's dirty state, keyed by a stable id. */
|
|
29
|
+
readonly setDirty: (id: string, dirty: boolean) => void;
|
|
30
|
+
/**
|
|
31
|
+
* Resolve `true` when it is safe to leave (nothing dirty, or the operator
|
|
32
|
+
* confirmed the discard); `false` to stay put.
|
|
33
|
+
*/
|
|
34
|
+
readonly confirmLeaveIfDirty: () => Promise<boolean>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const DirtyGuardContext = createContext<DirtyGuardValue | null>(null);
|
|
38
|
+
|
|
39
|
+
function DirtyGuardProvider({ children }: { readonly children: ReactNode }): ReactNode {
|
|
40
|
+
const dirtyIds = useRef<Set<string>>(new Set());
|
|
41
|
+
// Mirror the registry's non-empty state into React so the navigation guard's
|
|
42
|
+
// `when` re-arms as editors go dirty/clean.
|
|
43
|
+
const [anyDirty, setAnyDirty] = useState(false);
|
|
44
|
+
const [open, setOpen] = useState(false);
|
|
45
|
+
const resolverRef = useRef<((ok: boolean) => void) | null>(null);
|
|
46
|
+
|
|
47
|
+
const setDirty = useCallback((id: string, dirty: boolean): void => {
|
|
48
|
+
if (dirty) dirtyIds.current.add(id);
|
|
49
|
+
else dirtyIds.current.delete(id);
|
|
50
|
+
setAnyDirty(dirtyIds.current.size > 0);
|
|
51
|
+
}, []);
|
|
52
|
+
|
|
53
|
+
const confirmLeaveIfDirty = useCallback((): Promise<boolean> => {
|
|
54
|
+
if (dirtyIds.current.size === 0) return Promise.resolve(true);
|
|
55
|
+
return new Promise<boolean>((resolve) => {
|
|
56
|
+
// Settle a still-pending confirm as "stay" before it is orphaned by the new one.
|
|
57
|
+
resolverRef.current?.(false);
|
|
58
|
+
resolverRef.current = resolve;
|
|
59
|
+
setOpen(true);
|
|
60
|
+
});
|
|
61
|
+
}, []);
|
|
62
|
+
|
|
63
|
+
const settle = useCallback((ok: boolean): void => {
|
|
64
|
+
setOpen(false);
|
|
65
|
+
const resolve = resolverRef.current;
|
|
66
|
+
resolverRef.current = null;
|
|
67
|
+
resolve?.(ok);
|
|
68
|
+
}, []);
|
|
69
|
+
|
|
70
|
+
// The route navigation guard is armed from the SAME registry, so it confirms
|
|
71
|
+
// through this dialog and any armed guard raises the native unload prompt.
|
|
72
|
+
useNavigationGuard(anyDirty, confirmLeaveIfDirty);
|
|
73
|
+
|
|
74
|
+
return (
|
|
75
|
+
<DirtyGuardContext.Provider value={{ setDirty, confirmLeaveIfDirty }}>
|
|
76
|
+
{children}
|
|
77
|
+
{open ? (
|
|
78
|
+
<ConfirmDialog
|
|
79
|
+
title="Discard unsaved changes?"
|
|
80
|
+
confirmLabel="Discard changes"
|
|
81
|
+
pendingLabel="Discarding"
|
|
82
|
+
confirmVariant="danger"
|
|
83
|
+
isPending={false}
|
|
84
|
+
onConfirm={() => {
|
|
85
|
+
settle(true);
|
|
86
|
+
}}
|
|
87
|
+
onClose={() => {
|
|
88
|
+
settle(false);
|
|
89
|
+
}}
|
|
90
|
+
>
|
|
91
|
+
<p style={{ margin: 0 }}>This editor has unsaved changes. Leaving now discards them.</p>
|
|
92
|
+
</ConfirmDialog>
|
|
93
|
+
) : null}
|
|
94
|
+
</DirtyGuardContext.Provider>
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Report an editor's dirty state to the enclosing {@link GuardedTabs}. A no-op
|
|
100
|
+
* outside a provider (an editor rendered standalone, e.g. in isolation tests) so
|
|
101
|
+
* the hook is always safe to call.
|
|
102
|
+
*/
|
|
103
|
+
export function useRegisterDirty(dirty: boolean): void {
|
|
104
|
+
const ctx = useContext(DirtyGuardContext);
|
|
105
|
+
const id = useId();
|
|
106
|
+
useEffect(() => {
|
|
107
|
+
ctx?.setDirty(id, dirty);
|
|
108
|
+
}, [ctx, id, dirty]);
|
|
109
|
+
// Clear the registration on unmount (a tab switch unmounts the editor) so a
|
|
110
|
+
// torn-down editor never leaves the guard armed.
|
|
111
|
+
useEffect(() => () => ctx?.setDirty(id, false), [ctx, id]);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function GuardedTabsInner({
|
|
115
|
+
items,
|
|
116
|
+
defaultValue,
|
|
117
|
+
}: {
|
|
118
|
+
readonly items: readonly TabItem[];
|
|
119
|
+
readonly defaultValue?: string;
|
|
120
|
+
}): ReactNode {
|
|
121
|
+
const ctx = useContext(DirtyGuardContext);
|
|
122
|
+
const [active, setActive] = useState<string | undefined>(defaultValue ?? items[0]?.value);
|
|
123
|
+
// The tab set is dynamic (plugin/capability-gated tabs). If the active value is
|
|
124
|
+
// no longer present, fall back to the first tab rather than render no panel.
|
|
125
|
+
const effective = items.some((item) => item.value === active) ? active : items[0]?.value;
|
|
126
|
+
|
|
127
|
+
const onValueChange = (next: string): void => {
|
|
128
|
+
void ctx?.confirmLeaveIfDirty().then((ok) => {
|
|
129
|
+
if (ok) setActive(next);
|
|
130
|
+
});
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
return <Tabs items={items} value={effective} onValueChange={onValueChange} />;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* A drop-in replacement for {@link Tabs} that guards a switch away from a dirty
|
|
138
|
+
* editor. Wraps the tabs in a {@link DirtyGuardProvider}, so an editor rendered in
|
|
139
|
+
* any panel can arm the guard through {@link useRegisterDirty}.
|
|
140
|
+
*/
|
|
141
|
+
export function GuardedTabs({
|
|
142
|
+
items,
|
|
143
|
+
defaultValue,
|
|
144
|
+
}: {
|
|
145
|
+
readonly items: readonly TabItem[];
|
|
146
|
+
readonly defaultValue?: string;
|
|
147
|
+
}): ReactNode {
|
|
148
|
+
return (
|
|
149
|
+
<DirtyGuardProvider>
|
|
150
|
+
<GuardedTabsInner items={items} defaultValue={defaultValue} />
|
|
151
|
+
</DirtyGuardProvider>
|
|
152
|
+
);
|
|
153
|
+
}
|
|
@@ -25,6 +25,7 @@ import type { Extension } from '@tai42/api-client';
|
|
|
25
25
|
import { Badge } from './badge';
|
|
26
26
|
import { Checkbox } from './checkbox';
|
|
27
27
|
import { groupByKind, kindVariant } from './extension-grouping';
|
|
28
|
+
import { EmptyState } from './primitives';
|
|
28
29
|
|
|
29
30
|
export interface ExtensionPickerProps {
|
|
30
31
|
/** The extension catalog (`GET /api/extensions`) the checklist is drawn from. */
|
|
@@ -47,6 +48,17 @@ export function ExtensionPicker({
|
|
|
47
48
|
const groups = groupByKind(available);
|
|
48
49
|
const selected = new Set(value);
|
|
49
50
|
|
|
51
|
+
// An empty catalog renders the shared EmptyState HERE — inside the picker — so
|
|
52
|
+
// every call site inherits it rather than each guarding an empty checklist (a
|
|
53
|
+
// bare div that reads as broken). No provider has published an extension yet.
|
|
54
|
+
if (available.length === 0) {
|
|
55
|
+
return (
|
|
56
|
+
<div data-testid={idPrefix} className="tai-stack">
|
|
57
|
+
<EmptyState title="No extensions are available." />
|
|
58
|
+
</div>
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
50
62
|
/**
|
|
51
63
|
* Toggle a single extension. For a NON-STACKABLE kind (`backend`), checking one
|
|
52
64
|
* first strips any other selected member of that kind before appending, so the
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `FormDialog` — a modal whose body is a real `<form>`, so implicit submission
|
|
3
|
+
* gives Enter-in-a-field → submit. Composes {@link Dialog} and owns the submit
|
|
4
|
+
* lifecycle: a `type="submit"` default button, a busy state that disables it and
|
|
5
|
+
* shows a Spinner while the action runs, and a close-on-success-ONLY async contract.
|
|
6
|
+
*
|
|
7
|
+
* The contract: `onSubmit` may return a promise; the dialog closes (via `onClose`)
|
|
8
|
+
* only when it RESOLVES. A rejection or synchronous throw keeps the dialog open and
|
|
9
|
+
* renders the failure through `ErrorState`, so the operator can retry the filled fields.
|
|
10
|
+
*
|
|
11
|
+
* The caller owns mount/unmount: mount only while active, and any close gesture
|
|
12
|
+
* (Cancel, Escape, overlay) calls `onClose`. `submitDisabled` blocks submission by
|
|
13
|
+
* click AND by Enter — a disabled default button is not an implicit-submission target.
|
|
14
|
+
*/
|
|
15
|
+
import { useEffect, useRef, useState, type ReactNode, type SyntheticEvent } from 'react';
|
|
16
|
+
|
|
17
|
+
import { Dialog } from './dialog';
|
|
18
|
+
import { Button, ErrorState, Spinner } from './primitives';
|
|
19
|
+
import { errorMessage } from '../errors';
|
|
20
|
+
|
|
21
|
+
export interface FormDialogProps {
|
|
22
|
+
readonly title: string;
|
|
23
|
+
readonly description?: string;
|
|
24
|
+
/** The submit button's resting label (e.g. `Create scope`). */
|
|
25
|
+
readonly submitLabel: string;
|
|
26
|
+
/** The Spinner's accessible label while the action runs (e.g. `Creating`). */
|
|
27
|
+
readonly pendingLabel: string;
|
|
28
|
+
/**
|
|
29
|
+
* The action the form performs. A returned promise is awaited: the dialog
|
|
30
|
+
* closes only when it RESOLVES; a rejection (or a synchronous throw) keeps the
|
|
31
|
+
* dialog open and renders the failure loudly. A `void` return closes the
|
|
32
|
+
* dialog synchronously.
|
|
33
|
+
*/
|
|
34
|
+
readonly onSubmit: () => void | Promise<void>;
|
|
35
|
+
/**
|
|
36
|
+
* Called on a close gesture (Cancel, Escape, overlay) and on a successful
|
|
37
|
+
* submit — at most once per dialog lifecycle. A close landing mid-submit wins:
|
|
38
|
+
* the later resolution does not fire a second `onClose`.
|
|
39
|
+
*/
|
|
40
|
+
readonly onClose: () => void;
|
|
41
|
+
/** Blocks submission by click and by Enter while incomplete (a disabled default button is not an implicit-submission target). */
|
|
42
|
+
readonly submitDisabled?: boolean;
|
|
43
|
+
readonly submitVariant?: 'primary' | 'danger';
|
|
44
|
+
/** The Cancel button's label. */
|
|
45
|
+
readonly cancelLabel?: string;
|
|
46
|
+
/** The form body — the caller's fields. */
|
|
47
|
+
readonly children: ReactNode;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function FormDialog({
|
|
51
|
+
title,
|
|
52
|
+
description,
|
|
53
|
+
submitLabel,
|
|
54
|
+
pendingLabel,
|
|
55
|
+
onSubmit,
|
|
56
|
+
onClose,
|
|
57
|
+
submitDisabled = false,
|
|
58
|
+
submitVariant = 'primary',
|
|
59
|
+
cancelLabel = 'Cancel',
|
|
60
|
+
children,
|
|
61
|
+
}: FormDialogProps): ReactNode {
|
|
62
|
+
const [isPending, setIsPending] = useState(false);
|
|
63
|
+
const [submitError, setSubmitError] = useState<unknown>(null);
|
|
64
|
+
// Latches on the first close gesture so `onClose` fires at most once per lifecycle;
|
|
65
|
+
// a close mid-submit wins, and the later resolution must not close again.
|
|
66
|
+
const closedRef = useRef(false);
|
|
67
|
+
// Guards post-await setters from touching state after a mid-submit close unmounts.
|
|
68
|
+
const mountedRef = useRef(true);
|
|
69
|
+
useEffect(() => {
|
|
70
|
+
mountedRef.current = true;
|
|
71
|
+
return () => {
|
|
72
|
+
mountedRef.current = false;
|
|
73
|
+
};
|
|
74
|
+
}, []);
|
|
75
|
+
|
|
76
|
+
function close(): void {
|
|
77
|
+
if (closedRef.current) return;
|
|
78
|
+
closedRef.current = true;
|
|
79
|
+
onClose();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function handleSubmit(event: SyntheticEvent<HTMLFormElement>): void {
|
|
83
|
+
// SPA: the form never navigates, so the default GET is prevented.
|
|
84
|
+
event.preventDefault();
|
|
85
|
+
// Also covers a programmatic `requestSubmit` and a second Enter mid-flight, so the
|
|
86
|
+
// action fires at most once per run.
|
|
87
|
+
if (isPending || submitDisabled) return;
|
|
88
|
+
setSubmitError(null);
|
|
89
|
+
setIsPending(true);
|
|
90
|
+
// `Promise.resolve().then(onSubmit)` routes a synchronous throw into the rejection
|
|
91
|
+
// path too, so a sync failure surfaces like an async one.
|
|
92
|
+
Promise.resolve()
|
|
93
|
+
.then(() => onSubmit())
|
|
94
|
+
.then(
|
|
95
|
+
() => {
|
|
96
|
+
// A mid-submit close already settled the lifecycle; don't close or set state again.
|
|
97
|
+
if (closedRef.current || !mountedRef.current) return;
|
|
98
|
+
setIsPending(false);
|
|
99
|
+
close();
|
|
100
|
+
},
|
|
101
|
+
(reason: unknown) => {
|
|
102
|
+
if (closedRef.current || !mountedRef.current) return;
|
|
103
|
+
setIsPending(false);
|
|
104
|
+
setSubmitError(reason);
|
|
105
|
+
},
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return (
|
|
110
|
+
<Dialog
|
|
111
|
+
title={title}
|
|
112
|
+
description={description}
|
|
113
|
+
open
|
|
114
|
+
onOpenChange={(next) => {
|
|
115
|
+
if (!next) close();
|
|
116
|
+
}}
|
|
117
|
+
>
|
|
118
|
+
<form onSubmit={handleSubmit}>
|
|
119
|
+
{children}
|
|
120
|
+
{submitError != null ? <ErrorState message={errorMessage(submitError)} /> : null}
|
|
121
|
+
<div className="tai-dialog-actions">
|
|
122
|
+
<Button type="button" onClick={close}>
|
|
123
|
+
{cancelLabel}
|
|
124
|
+
</Button>
|
|
125
|
+
<Button type="submit" variant={submitVariant} disabled={isPending || submitDisabled}>
|
|
126
|
+
{isPending ? <Spinner label={pendingLabel} /> : null}
|
|
127
|
+
{submitLabel}
|
|
128
|
+
</Button>
|
|
129
|
+
</div>
|
|
130
|
+
</form>
|
|
131
|
+
</Dialog>
|
|
132
|
+
);
|
|
133
|
+
}
|