@tai42/studio-sdk 0.12.0 → 0.14.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.
Files changed (75) hide show
  1. package/dist/components/code-block.d.ts.map +1 -1
  2. package/dist/components/code-block.js +26 -13
  3. package/dist/components/code-block.js.map +1 -1
  4. package/dist/components/components.css +20 -0
  5. package/dist/components/copy-field.d.ts.map +1 -1
  6. package/dist/components/copy-field.js +8 -73
  7. package/dist/components/copy-field.js.map +1 -1
  8. package/dist/components/date-range-picker.d.ts +73 -0
  9. package/dist/components/date-range-picker.d.ts.map +1 -0
  10. package/dist/components/date-range-picker.js +146 -0
  11. package/dist/components/date-range-picker.js.map +1 -0
  12. package/dist/components/dialog.d.ts +19 -1
  13. package/dist/components/dialog.d.ts.map +1 -1
  14. package/dist/components/dialog.js +16 -13
  15. package/dist/components/dialog.js.map +1 -1
  16. package/dist/components/dirty-guard.d.ts +28 -0
  17. package/dist/components/dirty-guard.d.ts.map +1 -0
  18. package/dist/components/dirty-guard.js +93 -0
  19. package/dist/components/dirty-guard.js.map +1 -0
  20. package/dist/components/extension-picker.d.ts.map +1 -1
  21. package/dist/components/extension-picker.js +7 -0
  22. package/dist/components/extension-picker.js.map +1 -1
  23. package/dist/components/form-dialog.d.ts +45 -0
  24. package/dist/components/form-dialog.d.ts.map +1 -0
  25. package/dist/components/form-dialog.js +71 -0
  26. package/dist/components/form-dialog.js.map +1 -0
  27. package/dist/components/json-tree.d.ts +7 -1
  28. package/dist/components/json-tree.d.ts.map +1 -1
  29. package/dist/components/json-tree.js +300 -13
  30. package/dist/components/json-tree.js.map +1 -1
  31. package/dist/components/markdown.d.ts +60 -0
  32. package/dist/components/markdown.d.ts.map +1 -0
  33. package/dist/components/markdown.js +435 -0
  34. package/dist/components/markdown.js.map +1 -0
  35. package/dist/components/tabs.d.ts +3 -1
  36. package/dist/components/tabs.d.ts.map +1 -1
  37. package/dist/components/tabs.js +2 -2
  38. package/dist/components/tabs.js.map +1 -1
  39. package/dist/hooks/useApi.d.ts +56 -28
  40. package/dist/hooks/useApi.d.ts.map +1 -1
  41. package/dist/hooks/useClipboardCopy.d.ts +26 -0
  42. package/dist/hooks/useClipboardCopy.d.ts.map +1 -0
  43. package/dist/hooks/useClipboardCopy.js +72 -0
  44. package/dist/hooks/useClipboardCopy.js.map +1 -0
  45. package/dist/index.d.ts +9 -2
  46. package/dist/index.d.ts.map +1 -1
  47. package/dist/index.js +5 -1
  48. package/dist/index.js.map +1 -1
  49. package/dist/navigation/context.d.ts +21 -1
  50. package/dist/navigation/context.d.ts.map +1 -1
  51. package/dist/navigation/context.js +229 -7
  52. package/dist/navigation/context.js.map +1 -1
  53. package/dist/navigation/index.d.ts +2 -2
  54. package/dist/navigation/index.d.ts.map +1 -1
  55. package/dist/navigation/index.js +1 -1
  56. package/dist/navigation/index.js.map +1 -1
  57. package/dist/navigation/types.d.ts +11 -0
  58. package/dist/navigation/types.d.ts.map +1 -1
  59. package/package.json +2 -2
  60. package/src/components/code-block.tsx +72 -9
  61. package/src/components/components.css +20 -0
  62. package/src/components/copy-field.tsx +25 -81
  63. package/src/components/date-range-picker.tsx +256 -0
  64. package/src/components/dialog.tsx +59 -18
  65. package/src/components/dirty-guard.tsx +153 -0
  66. package/src/components/extension-picker.tsx +12 -0
  67. package/src/components/form-dialog.tsx +133 -0
  68. package/src/components/json-tree.tsx +477 -42
  69. package/src/components/markdown.tsx +552 -0
  70. package/src/components/tabs.tsx +10 -1
  71. package/src/hooks/useClipboardCopy.ts +105 -0
  72. package/src/index.ts +19 -0
  73. package/src/navigation/context.tsx +277 -7
  74. package/src/navigation/index.ts +3 -0
  75. package/src/navigation/types.ts +12 -0
package/src/index.ts CHANGED
@@ -80,6 +80,8 @@ export {
80
80
  useAppNavigate,
81
81
  useResolvePath,
82
82
  usePluginNavigation,
83
+ useNavigationGuard,
84
+ useNavigationGate,
83
85
  } from './navigation';
84
86
  export type {
85
87
  AppLinkProps,
@@ -89,6 +91,7 @@ export type {
89
91
  PageProps,
90
92
  NavigationContextValue,
91
93
  PluginSearch,
94
+ NavigationGuardHandler,
92
95
  } from './navigation';
93
96
 
94
97
  // -- Utilities ---------------------------------------------------------------
@@ -148,6 +151,9 @@ export { Dialog } from './components/dialog';
148
151
  export type { DialogProps } from './components/dialog';
149
152
  export { ConfirmDialog } from './components/confirm-dialog';
150
153
  export type { ConfirmDialogProps } from './components/confirm-dialog';
154
+ export { GuardedTabs, useRegisterDirty } from './components/dirty-guard';
155
+ export { FormDialog } from './components/form-dialog';
156
+ export type { FormDialogProps } from './components/form-dialog';
151
157
  export { Drawer } from './components/drawer';
152
158
  export type { DrawerProps } from './components/drawer';
153
159
  export { Tooltip } from './components/tooltip';
@@ -212,6 +218,19 @@ export type {
212
218
  } from './components/folder-nav';
213
219
  export { CodeBlock } from './components/code-block';
214
220
  export type { CodeBlockProps } from './components/code-block';
221
+ export { Markdown, parseMarkdown } from './components/markdown';
222
+ export type { MarkdownProps, MarkdownBlock, HeadingLevel } from './components/markdown';
223
+ export {
224
+ DateRangePicker,
225
+ formatRangeLabel,
226
+ normalizeCustomRange,
227
+ DEFAULT_DATE_RANGE_PRESETS,
228
+ } from './components/date-range-picker';
229
+ export type {
230
+ DateRangePickerProps,
231
+ DateRangePreset,
232
+ DateRangeValue,
233
+ } from './components/date-range-picker';
215
234
  export { FleetReport } from './components/fleet-report';
216
235
  export type { FleetReportProps } from './components/fleet-report';
217
236
  export { ExternalLinkButton, isSafeHttpUrl } from './components/external-link';
@@ -3,20 +3,109 @@ import {
3
3
  createElement,
4
4
  useCallback,
5
5
  useContext,
6
+ useEffect,
7
+ useMemo,
8
+ useRef,
9
+ useState,
6
10
  type AriaAttributes,
7
11
  type MouseEvent,
8
12
  type ReactNode,
9
13
  } from 'react';
10
14
 
11
- import type { NavigationContextValue, RouteSearch, RouteToken } from './types';
15
+ import type {
16
+ NavigationContextValue,
17
+ NavigationGuardHandler,
18
+ RouteSearch,
19
+ RouteToken,
20
+ } from './types';
21
+
22
+ /** Shell-provided navigation; no default, so use outside {@link NavigationProvider} throws. */
23
+ const NavigationContext = createContext<NavigationContextValue | null>(null);
12
24
 
13
25
  /**
14
- * Shell-provided navigation. The context has no default: a feature rendered
15
- * outside the shell's {@link NavigationProvider} is a wiring bug, so
16
- * {@link useNavigation} raises loudly rather than silently no-op'ing a
17
- * transition.
26
+ * One armed navigation guard. The registry holds this exact object (not a
27
+ * snapshot), so flipping `when` or swapping `handler` on re-render takes effect
28
+ * without re-registering.
18
29
  */
19
- const NavigationContext = createContext<NavigationContextValue | null>(null);
30
+ interface GuardEntry {
31
+ when: boolean;
32
+ handler: NavigationGuardHandler;
33
+ }
34
+
35
+ /**
36
+ * The guards active under one {@link NavigationProvider} — the single registry
37
+ * every SDK-controlled navigation consults, so feature and plugin-page guards
38
+ * compose against the same set. `run` stops at the first veto (at most one confirm
39
+ * dialog); `subscribe` re-evaluates browser-level listeners when the armed set changes.
40
+ */
41
+ class NavigationGuardRegistry {
42
+ private readonly guards = new Set<GuardEntry>();
43
+ private readonly listeners = new Set<() => void>();
44
+
45
+ /** Register a guard; returns the unregister function. */
46
+ add(entry: GuardEntry): () => void {
47
+ this.guards.add(entry);
48
+ this.emit();
49
+ return () => {
50
+ this.guards.delete(entry);
51
+ this.emit();
52
+ };
53
+ }
54
+
55
+ /** Notify subscribers that a guard's `when` may have changed. */
56
+ touch(): void {
57
+ this.emit();
58
+ }
59
+
60
+ subscribe(listener: () => void): () => void {
61
+ this.listeners.add(listener);
62
+ return () => {
63
+ this.listeners.delete(listener);
64
+ };
65
+ }
66
+
67
+ hasArmedGuards(): boolean {
68
+ for (const entry of this.guards) {
69
+ if (entry.when) return true;
70
+ }
71
+ return false;
72
+ }
73
+
74
+ /** Resolve `true` only if every armed guard allows the navigation. */
75
+ async run(): Promise<boolean> {
76
+ for (const entry of this.guards) {
77
+ if (!entry.when) continue;
78
+ const allowed = await entry.handler();
79
+ if (!allowed) return false;
80
+ }
81
+ return true;
82
+ }
83
+
84
+ private emit(): void {
85
+ for (const listener of this.listeners) listener();
86
+ }
87
+ }
88
+
89
+ /**
90
+ * Carries the provider's guard registry to {@link useNavigationGuard}. Separate from
91
+ * {@link NavigationContext} so the shell-supplied value stays exactly the shell's
92
+ * contract — the registry is SDK-owned.
93
+ */
94
+ const GuardRegistryContext = createContext<NavigationGuardRegistry | null>(null);
95
+
96
+ /** Records an approved raw-path transition as the committed history entry, so a later
97
+ * back/forward restores to it exactly as it does after a token navigation. */
98
+ const CommitHrefContext = createContext<((href: string) => void) | null>(null);
99
+
100
+ function useGuardRegistry(): NavigationGuardRegistry {
101
+ const registry = useContext(GuardRegistryContext);
102
+ if (registry === null) {
103
+ throw new Error(
104
+ 'useNavigationGuard must be used within a <NavigationProvider> (the shell provides it).',
105
+ );
106
+ }
107
+ return registry;
108
+ }
20
109
 
21
110
  export function NavigationProvider({
22
111
  value,
@@ -25,7 +114,144 @@ export function NavigationProvider({
25
114
  value: NavigationContextValue;
26
115
  children: ReactNode;
27
116
  }): ReactNode {
28
- return createElement(NavigationContext.Provider, { value }, children);
117
+ const registryRef = useRef<NavigationGuardRegistry | null>(null);
118
+ registryRef.current ??= new NavigationGuardRegistry();
119
+ const registry = registryRef.current;
120
+
121
+ // The last committed history entry the SDK observed (URL + `history.state`): what a
122
+ // canceled back/forward restores to. URL changes made outside the SDK's navigate
123
+ // entry points are unobservable by design at this layer.
124
+ const committedRef = useRef<{ href: string; state: unknown } | null>(null);
125
+
126
+ // Mirror the registry's armed state into React so the browser listeners attach only
127
+ // while at least one guard is armed.
128
+ const [armed, setArmed] = useState(false);
129
+ useEffect(() => {
130
+ setArmed(registry.hasArmedGuards());
131
+ return registry.subscribe(() => {
132
+ setArmed(registry.hasArmedGuards());
133
+ });
134
+ }, [registry]);
135
+
136
+ useEffect(() => {
137
+ if (!armed) return;
138
+
139
+ // The protected entry (URL + state); a back/forward landing elsewhere is first
140
+ // canceled back to here while the guard is consulted.
141
+ committedRef.current = { href: window.location.href, state: window.history.state };
142
+ // Set while replaying a guard-approved back/forward, so its `popstate` passes
143
+ // through instead of being re-intercepted.
144
+ let bypass = false;
145
+ // Latched while a decision is pending, so a second Back mid-decision keeps canceling
146
+ // without starting a concurrent `registry.run()` — one gesture, one dialog.
147
+ let pending = false;
148
+ // Restore pushes stacked for the pending decision, so an approved replay unwinds
149
+ // exactly that far in one `go(-depth)`. Reset per decision.
150
+ let restores = 0;
151
+
152
+ const onPopState = () => {
153
+ if (bypass) {
154
+ bypass = false;
155
+ committedRef.current = { href: window.location.href, state: window.history.state };
156
+ return;
157
+ }
158
+ if (!registry.hasArmedGuards()) {
159
+ committedRef.current = { href: window.location.href, state: window.history.state };
160
+ return;
161
+ }
162
+ const committed = committedRef.current;
163
+ if (committed === null) return;
164
+ // Cancel the browser's move by pushing the committed entry on top of wherever it
165
+ // landed. popstate exposes no direction/distance and the router owns
166
+ // `history.state`, so restore can only push, not delete. A vetoed forward thus
167
+ // strands a stub entry — an accepted limit the next pushState truncates.
168
+ window.history.pushState(committed.state, '', committed.href);
169
+ restores += 1;
170
+ if (pending) return;
171
+ pending = true;
172
+ void registry.run().then((allowed) => {
173
+ pending = false;
174
+ const depth = restores;
175
+ restores = 0;
176
+ if (!allowed) return;
177
+ bypass = true;
178
+ window.history.go(-depth);
179
+ });
180
+ };
181
+
182
+ const onBeforeUnload = (event: BeforeUnloadEvent) => {
183
+ event.preventDefault();
184
+ // Browsers below the floor (Chrome 108) gate the prompt on a set `returnValue`,
185
+ // not `preventDefault` alone; the string is ignored. Typed deprecated, still required.
186
+ // eslint-disable-next-line @typescript-eslint/no-deprecated -- required beforeunload trigger for the supported browser range.
187
+ event.returnValue = '';
188
+ };
189
+
190
+ window.addEventListener('popstate', onPopState);
191
+ window.addEventListener('beforeunload', onBeforeUnload);
192
+ return () => {
193
+ committedRef.current = null;
194
+ window.removeEventListener('popstate', onPopState);
195
+ window.removeEventListener('beforeunload', onBeforeUnload);
196
+ };
197
+ }, [armed, registry]);
198
+
199
+ // Wrap the shell's transitions so an armed guard is consulted before they commit.
200
+ // With no guard armed the call stays synchronous — identical to the raw shell value.
201
+ const guarded = useMemo<NavigationContextValue>(() => {
202
+ const navigate: NavigationContextValue['navigate'] = (token, search) => {
203
+ if (!registry.hasArmedGuards()) {
204
+ value.navigate(token, search);
205
+ return;
206
+ }
207
+ void registry.run().then((allowed) => {
208
+ if (!allowed) return;
209
+ value.navigate(token, search);
210
+ // Advance the committed entry to the resolved destination (the router may commit
211
+ // async, so `window.location` would be stale). Its router state is unobservable
212
+ // here, hence `state: null`.
213
+ if (committedRef.current !== null) {
214
+ committedRef.current = { href: value.resolvePath(token, search), state: null };
215
+ }
216
+ });
217
+ };
218
+ const navigatePlugin: NavigationContextValue['navigatePlugin'] = (
219
+ pluginId,
220
+ pagePath,
221
+ params,
222
+ search,
223
+ ) => {
224
+ if (!registry.hasArmedGuards()) {
225
+ value.navigatePlugin(pluginId, pagePath, params, search);
226
+ return;
227
+ }
228
+ void registry.run().then((allowed) => {
229
+ if (!allowed) return;
230
+ value.navigatePlugin(pluginId, pagePath, params, search);
231
+ if (committedRef.current !== null) {
232
+ committedRef.current = {
233
+ href: value.resolvePluginPath(pluginId, pagePath, params, search),
234
+ state: null,
235
+ };
236
+ }
237
+ });
238
+ };
239
+ return { ...value, navigate, navigatePlugin };
240
+ }, [value, registry]);
241
+
242
+ const commitHref = useCallback((href: string) => {
243
+ if (committedRef.current !== null) committedRef.current = { href, state: null };
244
+ }, []);
245
+
246
+ return createElement(
247
+ GuardRegistryContext.Provider,
248
+ { value: registry },
249
+ createElement(
250
+ CommitHrefContext.Provider,
251
+ { value: commitHref },
252
+ createElement(NavigationContext.Provider, { value: guarded }, children),
253
+ ),
254
+ );
29
255
  }
30
256
 
31
257
  function useNavigation(): NavigationContextValue {
@@ -48,6 +274,50 @@ export function useResolvePath(): NavigationContextValue['resolvePath'] {
48
274
  return useNavigation().resolvePath;
49
275
  }
50
276
 
277
+ /**
278
+ * Block navigation away from the current page while `when` is true. `handler` is
279
+ * consulted before any SDK-controlled navigation commits — a route-token navigate,
280
+ * a plugin navigate, or a browser back/forward — and the navigation proceeds only if
281
+ * it resolves `true`; a promise lets the handler drive a confirm dialog. A full-page
282
+ * unload (tab close / refresh) is additionally covered by a native `beforeunload`
283
+ * prompt that fires whenever any guard is armed.
284
+ *
285
+ * Guards compose against the provider's shared registry: a feature and a plugin page
286
+ * may each arm one, and any veto blocks.
287
+ */
288
+ export function useNavigationGuard(when: boolean, handler: NavigationGuardHandler): void {
289
+ const registry = useGuardRegistry();
290
+ const entryRef = useRef<GuardEntry>({ when, handler });
291
+ // Keep the registered entry pointing at the latest inputs without re-registering.
292
+ entryRef.current.when = when;
293
+ entryRef.current.handler = handler;
294
+
295
+ useEffect(() => registry.add(entryRef.current), [registry]);
296
+ useEffect(() => {
297
+ registry.touch();
298
+ }, [registry, when]);
299
+ }
300
+
301
+ /**
302
+ * Consult the armed navigation guards before a transition the shell drives itself
303
+ * (a chrome link whose target is outside the route-token map). Resolves `true` when
304
+ * the navigation may proceed. Token and plugin navigations already run this gate
305
+ * inside {@link NavigationProvider}; this is the escape hatch for the shell's own
306
+ * raw-path links, without which such a link would bypass every armed guard.
307
+ */
308
+ export function useNavigationGate(): (href: string) => Promise<boolean> {
309
+ const registry = useGuardRegistry();
310
+ const commitHref = useContext(CommitHrefContext);
311
+ return useCallback(
312
+ async (href: string) => {
313
+ if (registry.hasArmedGuards() && !(await registry.run())) return false;
314
+ commitHref?.(href);
315
+ return true;
316
+ },
317
+ [registry, commitHref],
318
+ );
319
+ }
320
+
51
321
  /**
52
322
  * Plugin-page navigation: `navigatePlugin` drives a client-side transition to a
53
323
  * runtime plugin path, `resolvePluginPath` yields its href. These are the ONLY
@@ -4,6 +4,8 @@ export {
4
4
  useAppNavigate,
5
5
  useResolvePath,
6
6
  usePluginNavigation,
7
+ useNavigationGuard,
8
+ useNavigationGate,
7
9
  } from './context';
8
10
  export type { AppLinkProps } from './context';
9
11
  export type {
@@ -13,4 +15,5 @@ export type {
13
15
  PageProps,
14
16
  NavigationContextValue,
15
17
  PluginSearch,
18
+ NavigationGuardHandler,
16
19
  } from './types';
@@ -113,3 +113,15 @@ export interface NavigationContextValue {
113
113
  search?: PluginSearch,
114
114
  ) => string;
115
115
  }
116
+
117
+ /**
118
+ * A navigation guard's decision function, run while armed before any SDK-controlled
119
+ * navigation commits ({@link NavigationContextValue.navigate},
120
+ * {@link NavigationContextValue.navigatePlugin}, or a browser back/forward). Returns
121
+ * `true` to proceed, `false` to veto; a promise lets it await a confirm dialog.
122
+ *
123
+ * Guards compose — navigation proceeds only if every armed guard allows, and the first
124
+ * veto blocks (at most one dialog). A full-page unload can't await, so it is covered by
125
+ * a `beforeunload` prompt independent of this handler.
126
+ */
127
+ export type NavigationGuardHandler = () => boolean | Promise<boolean>;