@xemahq/ui-kernel 0.1.12 → 0.3.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 +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/lib/biome-host/errors.d.ts +2 -0
- package/dist/lib/biome-host/errors.d.ts.map +1 -0
- package/dist/lib/biome-host/errors.js +146 -0
- package/dist/lib/biome-host/errors.js.map +1 -0
- package/dist/lib/biome-host/host-bridge.d.ts +8 -0
- package/dist/lib/biome-host/host-bridge.d.ts.map +1 -1
- package/dist/lib/biome-host/host-bridge.js.map +1 -1
- package/dist/lib/biome-host/index.d.ts +4 -0
- package/dist/lib/biome-host/index.d.ts.map +1 -1
- package/dist/lib/biome-host/index.js +4 -0
- package/dist/lib/biome-host/index.js.map +1 -1
- package/dist/lib/biome-host/realtime-hooks.d.ts +5 -0
- package/dist/lib/biome-host/realtime-hooks.d.ts.map +1 -0
- package/dist/lib/biome-host/realtime-hooks.js +28 -0
- package/dist/lib/biome-host/realtime-hooks.js.map +1 -0
- package/dist/lib/biome-host/realtime-port.d.ts +30 -0
- package/dist/lib/biome-host/realtime-port.d.ts.map +1 -0
- package/dist/lib/biome-host/realtime-port.js +3 -0
- package/dist/lib/biome-host/realtime-port.js.map +1 -0
- package/dist/lib/biome-host/response-envelope.d.ts +3 -0
- package/dist/lib/biome-host/response-envelope.d.ts.map +1 -0
- package/dist/lib/biome-host/response-envelope.js +25 -0
- package/dist/lib/biome-host/response-envelope.js.map +1 -0
- package/dist/lib/capabilities/capability-provider.d.ts +15 -0
- package/dist/lib/capabilities/capability-provider.d.ts.map +1 -0
- package/dist/lib/capabilities/capability-provider.js +36 -0
- package/dist/lib/capabilities/capability-provider.js.map +1 -0
- package/dist/lib/capabilities/index.d.ts +4 -0
- package/dist/lib/capabilities/index.d.ts.map +1 -0
- package/dist/lib/capabilities/index.js +20 -0
- package/dist/lib/capabilities/index.js.map +1 -0
- package/dist/lib/capabilities/types.d.ts +18 -0
- package/dist/lib/capabilities/types.d.ts.map +1 -0
- package/dist/lib/capabilities/types.js +3 -0
- package/dist/lib/capabilities/types.js.map +1 -0
- package/dist/lib/capabilities/use-capability.d.ts +18 -0
- package/dist/lib/capabilities/use-capability.d.ts.map +1 -0
- package/dist/lib/capabilities/use-capability.js +21 -0
- package/dist/lib/capabilities/use-capability.js.map +1 -0
- package/dist/ui/chrome/AsyncBoundary.d.ts +22 -0
- package/dist/ui/chrome/AsyncBoundary.d.ts.map +1 -0
- package/dist/ui/chrome/AsyncBoundary.js +23 -0
- package/dist/ui/chrome/AsyncBoundary.js.map +1 -0
- package/dist/ui/chrome/EmptyState.d.ts +34 -0
- package/dist/ui/chrome/EmptyState.d.ts.map +1 -0
- package/dist/ui/chrome/EmptyState.js +27 -0
- package/dist/ui/chrome/EmptyState.js.map +1 -0
- package/dist/ui/chrome/ErrorCard.d.ts +11 -0
- package/dist/ui/chrome/ErrorCard.d.ts.map +1 -0
- package/dist/ui/chrome/ErrorCard.js +14 -0
- package/dist/ui/chrome/ErrorCard.js.map +1 -0
- package/dist/ui/chrome/LoadingState.d.ts +10 -0
- package/dist/ui/chrome/LoadingState.d.ts.map +1 -0
- package/dist/ui/chrome/LoadingState.js +17 -0
- package/dist/ui/chrome/LoadingState.js.map +1 -0
- package/dist/ui/chrome/PageHeader.d.ts +20 -0
- package/dist/ui/chrome/PageHeader.d.ts.map +1 -0
- package/dist/ui/chrome/PageHeader.js +26 -0
- package/dist/ui/chrome/PageHeader.js.map +1 -0
- package/dist/ui/chrome/StateCard.d.ts +24 -0
- package/dist/ui/chrome/StateCard.d.ts.map +1 -0
- package/dist/ui/chrome/StateCard.js +17 -0
- package/dist/ui/chrome/StateCard.js.map +1 -0
- package/dist/ui/cn.d.ts +3 -0
- package/dist/ui/cn.d.ts.map +1 -0
- package/dist/ui/cn.js +18 -0
- package/dist/ui/cn.js.map +1 -0
- package/dist/ui/index.d.ts +33 -0
- package/dist/ui/index.d.ts.map +1 -0
- package/dist/ui/index.js +61 -0
- package/dist/ui/index.js.map +1 -0
- package/dist/ui/primitives/alert-dialog.d.ts +21 -0
- package/dist/ui/primitives/alert-dialog.d.ts.map +1 -0
- package/dist/ui/primitives/alert-dialog.js +72 -0
- package/dist/ui/primitives/alert-dialog.js.map +1 -0
- package/dist/ui/primitives/badge.d.ts +10 -0
- package/dist/ui/primitives/badge.d.ts.map +1 -0
- package/dist/ui/primitives/badge.js +60 -0
- package/dist/ui/primitives/badge.js.map +1 -0
- package/dist/ui/primitives/button.d.ts +12 -0
- package/dist/ui/primitives/button.d.ts.map +1 -0
- package/dist/ui/primitives/button.js +71 -0
- package/dist/ui/primitives/button.js.map +1 -0
- package/dist/ui/primitives/card.d.ts +9 -0
- package/dist/ui/primitives/card.d.ts.map +1 -0
- package/dist/ui/primitives/card.js +58 -0
- package/dist/ui/primitives/card.js.map +1 -0
- package/dist/ui/primitives/checkbox.d.ts +5 -0
- package/dist/ui/primitives/checkbox.d.ts.map +1 -0
- package/dist/ui/primitives/checkbox.js +45 -0
- package/dist/ui/primitives/checkbox.js.map +1 -0
- package/dist/ui/primitives/collapsible.d.ts +6 -0
- package/dist/ui/primitives/collapsible.d.ts.map +1 -0
- package/dist/ui/primitives/collapsible.js +44 -0
- package/dist/ui/primitives/collapsible.js.map +1 -0
- package/dist/ui/primitives/dialog.d.ts +22 -0
- package/dist/ui/primitives/dialog.d.ts.map +1 -0
- package/dist/ui/primitives/dialog.js +68 -0
- package/dist/ui/primitives/dialog.js.map +1 -0
- package/dist/ui/primitives/dropdown-menu.d.ts +28 -0
- package/dist/ui/primitives/dropdown-menu.d.ts.map +1 -0
- package/dist/ui/primitives/dropdown-menu.js +83 -0
- package/dist/ui/primitives/dropdown-menu.js.map +1 -0
- package/dist/ui/primitives/input.d.ts +4 -0
- package/dist/ui/primitives/input.d.ts.map +1 -0
- package/dist/ui/primitives/input.js +45 -0
- package/dist/ui/primitives/input.js.map +1 -0
- package/dist/ui/primitives/label.d.ts +6 -0
- package/dist/ui/primitives/label.d.ts.map +1 -0
- package/dist/ui/primitives/label.js +46 -0
- package/dist/ui/primitives/label.js.map +1 -0
- package/dist/ui/primitives/overflow-tabs.d.ts +18 -0
- package/dist/ui/primitives/overflow-tabs.d.ts.map +1 -0
- package/dist/ui/primitives/overflow-tabs.js +84 -0
- package/dist/ui/primitives/overflow-tabs.js.map +1 -0
- package/dist/ui/primitives/popover.d.ts +9 -0
- package/dist/ui/primitives/popover.d.ts.map +1 -0
- package/dist/ui/primitives/popover.js +48 -0
- package/dist/ui/primitives/popover.js.map +1 -0
- package/dist/ui/primitives/radio-group.d.ts +6 -0
- package/dist/ui/primitives/radio-group.d.ts.map +1 -0
- package/dist/ui/primitives/radio-group.js +52 -0
- package/dist/ui/primitives/radio-group.js.map +1 -0
- package/dist/ui/primitives/resizable.d.ts +12 -0
- package/dist/ui/primitives/resizable.d.ts.map +1 -0
- package/dist/ui/primitives/resizable.js +18 -0
- package/dist/ui/primitives/resizable.js.map +1 -0
- package/dist/ui/primitives/scroll-area.d.ts +6 -0
- package/dist/ui/primitives/scroll-area.d.ts.map +1 -0
- package/dist/ui/primitives/scroll-area.js +47 -0
- package/dist/ui/primitives/scroll-area.js.map +1 -0
- package/dist/ui/primitives/select.d.ts +14 -0
- package/dist/ui/primitives/select.d.ts.map +1 -0
- package/dist/ui/primitives/select.js +71 -0
- package/dist/ui/primitives/select.js.map +1 -0
- package/dist/ui/primitives/separator.d.ts +5 -0
- package/dist/ui/primitives/separator.d.ts.map +1 -0
- package/dist/ui/primitives/separator.js +44 -0
- package/dist/ui/primitives/separator.js.map +1 -0
- package/dist/ui/primitives/sheet.d.ts +26 -0
- package/dist/ui/primitives/sheet.d.ts.map +1 -0
- package/dist/ui/primitives/sheet.js +82 -0
- package/dist/ui/primitives/sheet.js.map +1 -0
- package/dist/ui/primitives/skeleton.d.ts +13 -0
- package/dist/ui/primitives/skeleton.d.ts.map +1 -0
- package/dist/ui/primitives/skeleton.js +29 -0
- package/dist/ui/primitives/skeleton.js.map +1 -0
- package/dist/ui/primitives/switch.d.ts +5 -0
- package/dist/ui/primitives/switch.d.ts.map +1 -0
- package/dist/ui/primitives/switch.js +44 -0
- package/dist/ui/primitives/switch.js.map +1 -0
- package/dist/ui/primitives/table.d.ts +11 -0
- package/dist/ui/primitives/table.d.ts.map +1 -0
- package/dist/ui/primitives/table.js +64 -0
- package/dist/ui/primitives/table.js.map +1 -0
- package/dist/ui/primitives/tabs.d.ts +8 -0
- package/dist/ui/primitives/tabs.d.ts.map +1 -0
- package/dist/ui/primitives/tabs.js +52 -0
- package/dist/ui/primitives/tabs.js.map +1 -0
- package/dist/ui/primitives/tag-multi-select.d.ts +19 -0
- package/dist/ui/primitives/tag-multi-select.d.ts.map +1 -0
- package/dist/ui/primitives/tag-multi-select.js +92 -0
- package/dist/ui/primitives/tag-multi-select.js.map +1 -0
- package/dist/ui/primitives/textarea.d.ts +5 -0
- package/dist/ui/primitives/textarea.d.ts.map +1 -0
- package/dist/ui/primitives/textarea.js +45 -0
- package/dist/ui/primitives/textarea.js.map +1 -0
- package/dist/ui/primitives/tooltip.d.ts +8 -0
- package/dist/ui/primitives/tooltip.d.ts.map +1 -0
- package/dist/ui/primitives/tooltip.js +50 -0
- package/dist/ui/primitives/tooltip.js.map +1 -0
- package/package.json +24 -1
- package/src/index.ts +1 -0
- package/src/lib/biome-host/errors.ts +220 -0
- package/src/lib/biome-host/host-bridge.ts +54 -0
- package/src/lib/biome-host/index.ts +4 -0
- package/src/lib/biome-host/realtime-hooks.ts +74 -0
- package/src/lib/biome-host/realtime-port.ts +109 -0
- package/src/lib/biome-host/response-envelope.ts +69 -0
- package/src/lib/capabilities/capability-provider.tsx +95 -0
- package/src/lib/capabilities/index.ts +16 -0
- package/src/lib/capabilities/types.ts +69 -0
- package/src/lib/capabilities/use-capability.ts +72 -0
- package/src/ui/chrome/AsyncBoundary.tsx +66 -0
- package/src/ui/chrome/EmptyState.tsx +184 -0
- package/src/ui/chrome/ErrorCard.tsx +63 -0
- package/src/ui/chrome/LoadingState.tsx +61 -0
- package/src/ui/chrome/PageHeader.tsx +137 -0
- package/src/ui/chrome/StateCard.tsx +150 -0
- package/src/ui/cn.ts +32 -0
- package/src/ui/index.ts +53 -0
- package/src/ui/primitives/alert-dialog.tsx +104 -0
- package/src/ui/primitives/badge.tsx +32 -0
- package/src/ui/primitives/button.tsx +47 -0
- package/src/ui/primitives/card.tsx +43 -0
- package/src/ui/primitives/checkbox.tsx +26 -0
- package/src/ui/primitives/collapsible.tsx +9 -0
- package/src/ui/primitives/dialog.tsx +103 -0
- package/src/ui/primitives/dropdown-menu.tsx +179 -0
- package/src/ui/primitives/input.tsx +22 -0
- package/src/ui/primitives/label.tsx +17 -0
- package/src/ui/primitives/overflow-tabs.tsx +281 -0
- package/src/ui/primitives/popover.tsx +33 -0
- package/src/ui/primitives/radio-group.tsx +36 -0
- package/src/ui/primitives/resizable.tsx +67 -0
- package/src/ui/primitives/scroll-area.tsx +38 -0
- package/src/ui/primitives/select.tsx +143 -0
- package/src/ui/primitives/separator.tsx +20 -0
- package/src/ui/primitives/sheet.tsx +107 -0
- package/src/ui/primitives/skeleton.tsx +99 -0
- package/src/ui/primitives/switch.tsx +27 -0
- package/src/ui/primitives/table.tsx +72 -0
- package/src/ui/primitives/tabs.tsx +53 -0
- package/src/ui/primitives/tag-multi-select.tsx +241 -0
- package/src/ui/primitives/textarea.tsx +21 -0
- package/src/ui/primitives/tooltip.tsx +30 -0
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Frontend mirror of the backend `ResponseEnvelopeInterceptor`.
|
|
3
|
+
*
|
|
4
|
+
* Every 2xx body the platform returns is wrapped as `{ data: T }`, or
|
|
5
|
+
* `{ data: T[], pagination }` for a paginated list (single source of truth:
|
|
6
|
+
* `@xemahq/platform-common`'s `ResponseEnvelopeInterceptor`). Orval's
|
|
7
|
+
* `custom-fetch` mutators usually peel that envelope at the HTTP boundary, but
|
|
8
|
+
* spec drift (a controller typed `T[]` that emits `{ data }`), public clients
|
|
9
|
+
* that DON'T peel, and transient partial responses leave a `.data`-wrapped or
|
|
10
|
+
* mis-shaped value where biome code expects a bare value/array — and a raw
|
|
11
|
+
* `.map` on it white-screens the route.
|
|
12
|
+
*
|
|
13
|
+
* These are the ONE shared, pure unwrap helpers — no host context, no fetch.
|
|
14
|
+
* Biomes (and the host shell's own `unwrap-list.ts`) should adopt these instead
|
|
15
|
+
* of re-implementing the guard. This is the shared helper only; adopters are
|
|
16
|
+
* migrated incrementally, not in a blanket rewrite.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Resilient list unwrap for DISPLAY surfaces — normalises a React-Query `.data`
|
|
21
|
+
* to an array so the page renders its empty/error state instead of crashing:
|
|
22
|
+
*
|
|
23
|
+
* - already an array → returned as-is
|
|
24
|
+
* - `{ data: T[] }` envelope → `value.data`
|
|
25
|
+
* - anything else → `[]`
|
|
26
|
+
*
|
|
27
|
+
* Deliberately NON-throwing: the query's own `isError`/`isLoading` flags drive
|
|
28
|
+
* the visible error/empty UI; this only guards the render path. For
|
|
29
|
+
* mutation/single-resource flows where an unexpected shape is a real bug, use
|
|
30
|
+
* {@link unwrapData} (fail-fast) instead.
|
|
31
|
+
*/
|
|
32
|
+
export function unwrapList<T>(value: unknown): T[] {
|
|
33
|
+
if (Array.isArray(value)) {
|
|
34
|
+
return value as T[];
|
|
35
|
+
}
|
|
36
|
+
if (
|
|
37
|
+
value !== null &&
|
|
38
|
+
typeof value === 'object' &&
|
|
39
|
+
Array.isArray((value as { data?: unknown }).data)
|
|
40
|
+
) {
|
|
41
|
+
return (value as { data: T[] }).data;
|
|
42
|
+
}
|
|
43
|
+
return [];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Fail-fast single-resource unwrap. Returns `value.data` when the value is a
|
|
48
|
+
* `{ data }` envelope; returns the value unchanged when it is already the
|
|
49
|
+
* unwrapped resource (an Orval client that peeled the envelope at the boundary).
|
|
50
|
+
*
|
|
51
|
+
* Use this on mutation / single-resource flows where the response SHOULD be a
|
|
52
|
+
* concrete object — unlike {@link unwrapList}, it does not silently coerce, so
|
|
53
|
+
* a genuinely-unexpected shape surfaces as the value rather than being masked.
|
|
54
|
+
*
|
|
55
|
+
* A paginated envelope (`{ data, pagination }`) is returned INTACT — callers
|
|
56
|
+
* that need both fields read them off the returned object; peeling only `data`
|
|
57
|
+
* would drop the pagination cursor.
|
|
58
|
+
*/
|
|
59
|
+
export function unwrapData<T>(value: unknown): T {
|
|
60
|
+
if (
|
|
61
|
+
value !== null &&
|
|
62
|
+
typeof value === 'object' &&
|
|
63
|
+
'data' in (value as Record<string, unknown>) &&
|
|
64
|
+
!('pagination' in (value as Record<string, unknown>))
|
|
65
|
+
) {
|
|
66
|
+
return (value as { data: T }).data;
|
|
67
|
+
}
|
|
68
|
+
return value as T;
|
|
69
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { useQuery } from '@tanstack/react-query';
|
|
2
|
+
import { createContext, useContext, type ReactNode } from 'react';
|
|
3
|
+
|
|
4
|
+
import { useHostBridge } from '../biome-host/host-bridge';
|
|
5
|
+
|
|
6
|
+
import type { CapabilityPort, MeCapabilitiesSnapshot } from './types';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The value exposed by {@link CapabilityProvider} through React context.
|
|
10
|
+
* `snapshot` is `undefined` until the first successful fetch; `isLoading`
|
|
11
|
+
* and `error` let callers distinguish "still unknown" and "fetch failed"
|
|
12
|
+
* from "explicitly denied" — the gate hook must NOT treat an errored fetch
|
|
13
|
+
* as "no capabilities".
|
|
14
|
+
*/
|
|
15
|
+
export interface CapabilityContextValue {
|
|
16
|
+
readonly snapshot: MeCapabilitiesSnapshot | undefined;
|
|
17
|
+
readonly isLoading: boolean;
|
|
18
|
+
readonly error: unknown;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const CapabilityContext = createContext<CapabilityContextValue | null>(null);
|
|
22
|
+
CapabilityContext.displayName = 'CapabilityContext';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Stable query-key root for the capability snapshot. Folded with the active
|
|
26
|
+
* org so each org caches independently and a re-fetch fires when the org
|
|
27
|
+
* changes — same tenant-isolation discipline as `useScopedQuery`.
|
|
28
|
+
*/
|
|
29
|
+
const CAPABILITIES_QUERY_ROOT = ['xema', 'capabilities', 'me'] as const;
|
|
30
|
+
|
|
31
|
+
export interface CapabilityProviderProps {
|
|
32
|
+
readonly children: ReactNode;
|
|
33
|
+
/**
|
|
34
|
+
* Override the host-provided port. Defaults to `bridge.capabilities`. Exists
|
|
35
|
+
* for tests and for hosts that compose the provider outside a bridge subtree;
|
|
36
|
+
* production wiring reads it OFF the bridge so biomes need no extra prop.
|
|
37
|
+
*/
|
|
38
|
+
readonly port?: CapabilityPort;
|
|
39
|
+
/**
|
|
40
|
+
* Override the active org. Defaults to `null` (the host's
|
|
41
|
+
* currently-selected org). When provided, the port is asked for that org and
|
|
42
|
+
* the cache key includes it.
|
|
43
|
+
*/
|
|
44
|
+
readonly orgId?: string | null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Fetches the caller's server-authoritative capability set once per active org
|
|
49
|
+
* and exposes it (plus loading/error) to descendants via React context.
|
|
50
|
+
*
|
|
51
|
+
* Wiring: the {@link CapabilityPort} is read OFF `HostBridge.capabilities`
|
|
52
|
+
* (not prop-drilled) — the same decoupling the rest of the kernel uses to
|
|
53
|
+
* reach the host. The active org is read from the bridge's auth source. The
|
|
54
|
+
* bridge's `QueryClient` (TanStack Query) owns caching/refetch; the kernel
|
|
55
|
+
* rolls no bespoke state. The query re-runs when the org changes because the
|
|
56
|
+
* org is part of the query key.
|
|
57
|
+
*
|
|
58
|
+
* Rendering is fail-soft (callers can show a loading state), but failures are
|
|
59
|
+
* SURFACED via `error` rather than masquerading as an empty capability set.
|
|
60
|
+
*/
|
|
61
|
+
export function CapabilityProvider(props: CapabilityProviderProps): JSX.Element {
|
|
62
|
+
const bridge = useHostBridge();
|
|
63
|
+
const port = props.port ?? bridge.capabilities;
|
|
64
|
+
const orgId = props.orgId !== undefined ? props.orgId : bridge.auth.getOrgId();
|
|
65
|
+
|
|
66
|
+
const query = useQuery<MeCapabilitiesSnapshot>({
|
|
67
|
+
queryKey: [...CAPABILITIES_QUERY_ROOT, orgId],
|
|
68
|
+
queryFn: () => port.list(orgId ? { orgId } : undefined),
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
const value: CapabilityContextValue = {
|
|
72
|
+
snapshot: query.data,
|
|
73
|
+
isLoading: query.isLoading,
|
|
74
|
+
error: query.error,
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
return <CapabilityContext.Provider value={value}>{props.children}</CapabilityContext.Provider>;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Internal accessor for the capability context. Throws fail-fast if used
|
|
82
|
+
* outside {@link CapabilityProvider} — a missing provider is a wiring bug,
|
|
83
|
+
* not a "deny everything" condition.
|
|
84
|
+
*/
|
|
85
|
+
export function useCapabilityContext(): CapabilityContextValue {
|
|
86
|
+
const ctx = useContext(CapabilityContext);
|
|
87
|
+
if (!ctx) {
|
|
88
|
+
throw new Error(
|
|
89
|
+
'[xema-ui-kernel] useCapability()/useCapabilities() called outside ' +
|
|
90
|
+
'<CapabilityProvider>. The host (or biome shell) must mount the ' +
|
|
91
|
+
'capability provider above any capability-gated subtree.',
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
return ctx;
|
|
95
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host-agnostic capabilities SDK for `@xemahq/ui-kernel`.
|
|
3
|
+
*
|
|
4
|
+
* Lets a biome UI gate on SERVER-AUTHORITATIVE capabilities (the caller's
|
|
5
|
+
* effective set from `GET /bff/me/capabilities`) instead of cosmetic role
|
|
6
|
+
* booleans. The kernel defines the contract and the React surface; it never
|
|
7
|
+
* fetches — the HOST implements {@link CapabilityPort} (hung off
|
|
8
|
+
* `HostBridge.capabilities`) and maps its Orval client response onto the
|
|
9
|
+
* local {@link MeCapabilitiesSnapshot} shape.
|
|
10
|
+
*
|
|
11
|
+
* Biome usage:
|
|
12
|
+
* import { CapabilityProvider, useCapability } from '@xemahq/ui-kernel';
|
|
13
|
+
*/
|
|
14
|
+
export * from './types';
|
|
15
|
+
export * from './capability-provider';
|
|
16
|
+
export * from './use-capability';
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { ExecutionEnvironmentKind } from '@xemahq/kernel-contracts/execution-environment';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* One server-authoritative capability the caller holds, mirroring the
|
|
5
|
+
* FROZEN `GET /bff/me/capabilities` wire entry.
|
|
6
|
+
*
|
|
7
|
+
* These types are defined LOCALLY in the kernel — deliberately NOT imported
|
|
8
|
+
* from a backend DTO or an Orval client — so `@xemahq/ui-kernel` stays
|
|
9
|
+
* host-agnostic (no fetch lib, no generated client). The concrete host
|
|
10
|
+
* (`submodules/xema-host-web`) maps its client response onto this shape when
|
|
11
|
+
* it implements {@link CapabilityPort}.
|
|
12
|
+
*/
|
|
13
|
+
export interface CapabilityEntry {
|
|
14
|
+
/**
|
|
15
|
+
* The capability reference (e.g. `capability:session.launch`). Matched
|
|
16
|
+
* verbatim by {@link useCapability}; the kernel does not parse it.
|
|
17
|
+
*/
|
|
18
|
+
readonly ref: string;
|
|
19
|
+
/**
|
|
20
|
+
* The execution environments in which the caller holds this capability.
|
|
21
|
+
* A gate that names an `environment` is allowed only if it appears here.
|
|
22
|
+
*/
|
|
23
|
+
readonly environments: ExecutionEnvironmentKind[];
|
|
24
|
+
/**
|
|
25
|
+
* When `true`, holding the capability still requires a human approval step
|
|
26
|
+
* before the side-effect runs — so a UI affordance should route through the
|
|
27
|
+
* approval flow rather than acting directly.
|
|
28
|
+
*/
|
|
29
|
+
readonly approvalRequired: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The caller's effective capability set for one active org, mirroring the
|
|
34
|
+
* FROZEN `MeCapabilitiesResponse` wire shape.
|
|
35
|
+
*/
|
|
36
|
+
export interface MeCapabilitiesSnapshot {
|
|
37
|
+
/** The subject (user) the snapshot was resolved for. */
|
|
38
|
+
readonly subjectId: string;
|
|
39
|
+
/** The org the snapshot is scoped to. */
|
|
40
|
+
readonly orgId: string;
|
|
41
|
+
/** Every capability the subject effectively holds in this org. */
|
|
42
|
+
readonly capabilities: CapabilityEntry[];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Host-implemented port that fetches the caller's effective capability set.
|
|
47
|
+
*
|
|
48
|
+
* The kernel never calls an API directly — the HOST implements this against
|
|
49
|
+
* its Orval client (`GET /bff/me/capabilities`) and maps the response onto
|
|
50
|
+
* {@link MeCapabilitiesSnapshot}. The implementation is hung off
|
|
51
|
+
* {@link HostBridge.capabilities}; {@link CapabilityProvider} reads it from
|
|
52
|
+
* the bridge and uses the bridge's `QueryClient` for caching/refetch.
|
|
53
|
+
*/
|
|
54
|
+
export interface CapabilityPort {
|
|
55
|
+
/**
|
|
56
|
+
* Return the caller's effective capability set for the active org.
|
|
57
|
+
*
|
|
58
|
+
* @param options.orgId The org to resolve for. When omitted the host
|
|
59
|
+
* resolves against its currently-selected org.
|
|
60
|
+
* @param options.environment Optional environment hint the host MAY pass to
|
|
61
|
+
* the backend to narrow the set. Callers gate by environment client-side
|
|
62
|
+
* via {@link useCapability}, so a host MAY ignore this and return the full
|
|
63
|
+
* set; it exists so a host can request a pre-filtered snapshot.
|
|
64
|
+
*/
|
|
65
|
+
list(options?: {
|
|
66
|
+
orgId?: string;
|
|
67
|
+
environment?: ExecutionEnvironmentKind;
|
|
68
|
+
}): Promise<MeCapabilitiesSnapshot>;
|
|
69
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type { ExecutionEnvironmentKind } from '@xemahq/kernel-contracts/execution-environment';
|
|
2
|
+
|
|
3
|
+
import { useCapabilityContext, type CapabilityContextValue } from './capability-provider';
|
|
4
|
+
import type { MeCapabilitiesSnapshot } from './types';
|
|
5
|
+
|
|
6
|
+
export interface UseCapabilityOptions {
|
|
7
|
+
/**
|
|
8
|
+
* Gate against a specific execution environment. When set, the capability
|
|
9
|
+
* counts only if the snapshot lists it for this environment.
|
|
10
|
+
*/
|
|
11
|
+
readonly environment?: ExecutionEnvironmentKind;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface CapabilityDecision {
|
|
15
|
+
/**
|
|
16
|
+
* `true` only when the caller holds the capability, holds it in the named
|
|
17
|
+
* environment (if one was given), AND it does NOT require approval. A gate
|
|
18
|
+
* that should let the user act directly checks this.
|
|
19
|
+
*/
|
|
20
|
+
readonly allowed: boolean;
|
|
21
|
+
/**
|
|
22
|
+
* `true` when the caller holds the capability (in the named environment, if
|
|
23
|
+
* given) but it requires a human approval step first — render the
|
|
24
|
+
* approval-routed affordance instead of acting directly.
|
|
25
|
+
*/
|
|
26
|
+
readonly approvalRequired: boolean;
|
|
27
|
+
/** The snapshot is still being fetched; treat as "unknown", not "denied". */
|
|
28
|
+
readonly isLoading: boolean;
|
|
29
|
+
/** The fetch failed; surfaced so callers never conflate failure with denial. */
|
|
30
|
+
readonly error: unknown;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Server-authoritative capability gate.
|
|
35
|
+
*
|
|
36
|
+
* Resolves `ref` against the cached {@link CapabilityProvider} snapshot:
|
|
37
|
+
* - `allowed` = entry exists AND (no environment asked OR listed for it) AND NOT approvalRequired.
|
|
38
|
+
* - `approvalRequired` = entry exists AND (no environment asked OR listed for it) AND approvalRequired.
|
|
39
|
+
*
|
|
40
|
+
* While the snapshot is loading, `allowed` is `false` and `isLoading` is
|
|
41
|
+
* `true` — callers gate on `isLoading` to distinguish "unknown" from "denied".
|
|
42
|
+
* On error, `allowed` is `false` and `error` is set; the hook never silently
|
|
43
|
+
* degrades a failed fetch into a confident denial.
|
|
44
|
+
*/
|
|
45
|
+
export function useCapability(ref: string, opts?: UseCapabilityOptions): CapabilityDecision {
|
|
46
|
+
const { snapshot, isLoading, error } = useCapabilityContext();
|
|
47
|
+
|
|
48
|
+
const entry = snapshot?.capabilities.find((c) => c.ref === ref);
|
|
49
|
+
const inEnvironment =
|
|
50
|
+
!!entry && (!opts?.environment || entry.environments.includes(opts.environment));
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
allowed: inEnvironment && !entry.approvalRequired,
|
|
54
|
+
approvalRequired: inEnvironment && entry.approvalRequired,
|
|
55
|
+
isLoading,
|
|
56
|
+
error,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Accessor for the raw capability snapshot plus its loading/error state.
|
|
62
|
+
* Use when a component needs to enumerate capabilities rather than gate on a
|
|
63
|
+
* single `ref`.
|
|
64
|
+
*/
|
|
65
|
+
export function useCapabilities(): {
|
|
66
|
+
snapshot: MeCapabilitiesSnapshot | undefined;
|
|
67
|
+
isLoading: boolean;
|
|
68
|
+
error: unknown;
|
|
69
|
+
} {
|
|
70
|
+
const ctx: CapabilityContextValue = useCapabilityContext();
|
|
71
|
+
return { snapshot: ctx.snapshot, isLoading: ctx.isLoading, error: ctx.error };
|
|
72
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { LucideIcon } from 'lucide-react';
|
|
2
|
+
import type { ReactNode } from 'react';
|
|
3
|
+
|
|
4
|
+
import ErrorCard, { type ErrorMessageFormatter } from './ErrorCard';
|
|
5
|
+
import EmptyState from './EmptyState';
|
|
6
|
+
import LoadingState, { type LoadingStateVariant } from './LoadingState';
|
|
7
|
+
|
|
8
|
+
interface AsyncBoundaryEmpty {
|
|
9
|
+
icon: LucideIcon;
|
|
10
|
+
title: string;
|
|
11
|
+
description?: ReactNode;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface AsyncBoundaryProps {
|
|
15
|
+
/** Async in flight — renders the loading affordance. */
|
|
16
|
+
isLoading: boolean;
|
|
17
|
+
/** Resolved error, if any — renders an `ErrorCard`. */
|
|
18
|
+
error?: Error | string | unknown | null;
|
|
19
|
+
/**
|
|
20
|
+
* When true, the resolved data is empty — renders an `EmptyState`. Only
|
|
21
|
+
* checked when `empty` config is supplied; otherwise children render.
|
|
22
|
+
*/
|
|
23
|
+
isEmpty?: boolean;
|
|
24
|
+
/** Empty-state config; omit to never render an empty state. */
|
|
25
|
+
empty?: AsyncBoundaryEmpty;
|
|
26
|
+
/** Retry handler threaded to `ErrorCard`. */
|
|
27
|
+
onRetry?: () => void;
|
|
28
|
+
/** Loading affordance variant. */
|
|
29
|
+
loadingVariant?: LoadingStateVariant;
|
|
30
|
+
/** Host error decoder threaded to `ErrorCard`. */
|
|
31
|
+
formatError?: ErrorMessageFormatter;
|
|
32
|
+
children: ReactNode;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Declarative loading / error / empty wrapper. Collapses the
|
|
37
|
+
* `if (isLoading) … if (error) … if (empty) … else children` ladder that
|
|
38
|
+
* every biome list/detail surface re-implements into one component built on
|
|
39
|
+
* the shared `LoadingState` / `ErrorCard` / `EmptyState` primitives.
|
|
40
|
+
*
|
|
41
|
+
* Precedence: loading → error → empty → children.
|
|
42
|
+
*/
|
|
43
|
+
export default function AsyncBoundary({
|
|
44
|
+
isLoading,
|
|
45
|
+
error,
|
|
46
|
+
isEmpty,
|
|
47
|
+
empty,
|
|
48
|
+
onRetry,
|
|
49
|
+
loadingVariant,
|
|
50
|
+
formatError,
|
|
51
|
+
children,
|
|
52
|
+
}: Readonly<AsyncBoundaryProps>) {
|
|
53
|
+
if (isLoading) {
|
|
54
|
+
return <LoadingState variant={loadingVariant} />;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (error) {
|
|
58
|
+
return <ErrorCard error={error} onRetry={onRetry} formatError={formatError} />;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (isEmpty && empty) {
|
|
62
|
+
return <EmptyState icon={empty.icon} title={empty.title} description={empty.description} />;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return <>{children}</>;
|
|
66
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import type { LucideIcon } from 'lucide-react';
|
|
2
|
+
import type { ComponentType, ReactNode } from 'react';
|
|
3
|
+
|
|
4
|
+
import { cn } from '../cn';
|
|
5
|
+
import { Button } from '../primitives/button';
|
|
6
|
+
import { Card, CardContent } from '../primitives/card';
|
|
7
|
+
|
|
8
|
+
export type EmptyStateVariant = 'not-configured' | 'no-results' | 'disabled-context';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Visual density.
|
|
12
|
+
* - `comfortable` (default): hero empty state for full-page surfaces —
|
|
13
|
+
* big 64px icon, generous padding, hero copy. Use when the page has
|
|
14
|
+
* nothing else to render.
|
|
15
|
+
* - `dense`: inline empty state for surfaces nested inside other
|
|
16
|
+
* content (tabs, sub-panels). Smaller icon, tighter padding, no
|
|
17
|
+
* dashed card frame — reads as a calm placeholder, not a banner.
|
|
18
|
+
* - `inline`: single-line text-only message, no icon, no card. Use
|
|
19
|
+
* inside list sub-sections where even the dense frame is too much.
|
|
20
|
+
*/
|
|
21
|
+
export type EmptyStateSize = 'comfortable' | 'dense' | 'inline';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Polymorphic link element. The host shell renders Next.js `<Link>`;
|
|
25
|
+
* pass it via `linkComponent` to get client-side routing. Defaults to a
|
|
26
|
+
* plain anchor so the primitive stays host-framework-agnostic.
|
|
27
|
+
*/
|
|
28
|
+
export type EmptyStateLinkComponent = ComponentType<{
|
|
29
|
+
href: string;
|
|
30
|
+
children: ReactNode;
|
|
31
|
+
}>;
|
|
32
|
+
|
|
33
|
+
const DefaultLink: EmptyStateLinkComponent = ({ href, children }) => (
|
|
34
|
+
<a href={href}>{children}</a>
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
interface EmptyStateAction {
|
|
38
|
+
label: string;
|
|
39
|
+
onClick: () => void;
|
|
40
|
+
icon?: LucideIcon;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface EmptyStateSecondaryAction {
|
|
44
|
+
label: string;
|
|
45
|
+
href: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface EmptyStateProps {
|
|
49
|
+
icon: LucideIcon;
|
|
50
|
+
title: string;
|
|
51
|
+
description?: ReactNode;
|
|
52
|
+
/** Optional illustration or extra content above the title */
|
|
53
|
+
illustration?: ReactNode;
|
|
54
|
+
/** Visual variant — drives default iconBg/iconColor unless overridden. */
|
|
55
|
+
variant?: EmptyStateVariant;
|
|
56
|
+
size?: EmptyStateSize;
|
|
57
|
+
/** Icon tint — overrides variant defaults */
|
|
58
|
+
iconBg?: string;
|
|
59
|
+
iconColor?: string;
|
|
60
|
+
action?: EmptyStateAction;
|
|
61
|
+
secondaryAction?: EmptyStateSecondaryAction;
|
|
62
|
+
/** Host link component for `secondaryAction.href` (defaults to `<a>`). */
|
|
63
|
+
linkComponent?: EmptyStateLinkComponent;
|
|
64
|
+
className?: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const VARIANT_DEFAULTS: Record<EmptyStateVariant, { iconBg: string; iconColor: string }> = {
|
|
68
|
+
'not-configured': { iconBg: 'bg-info/10', iconColor: 'text-info' },
|
|
69
|
+
'no-results': { iconBg: 'bg-warning/10', iconColor: 'text-warning' },
|
|
70
|
+
'disabled-context': { iconBg: 'bg-muted', iconColor: 'text-ink-3' },
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
export default function EmptyState({
|
|
74
|
+
icon: Icon,
|
|
75
|
+
title,
|
|
76
|
+
description,
|
|
77
|
+
illustration,
|
|
78
|
+
variant = 'not-configured',
|
|
79
|
+
size = 'comfortable',
|
|
80
|
+
iconBg,
|
|
81
|
+
iconColor,
|
|
82
|
+
action,
|
|
83
|
+
secondaryAction,
|
|
84
|
+
linkComponent,
|
|
85
|
+
className,
|
|
86
|
+
}: Readonly<EmptyStateProps>) {
|
|
87
|
+
const Link = linkComponent ?? DefaultLink;
|
|
88
|
+
const defaults = VARIANT_DEFAULTS[variant];
|
|
89
|
+
const resolvedIconBg = iconBg ?? defaults.iconBg;
|
|
90
|
+
const resolvedIconColor = iconColor ?? defaults.iconColor;
|
|
91
|
+
|
|
92
|
+
if (size === 'inline') {
|
|
93
|
+
return (
|
|
94
|
+
<div className={cn('flex items-center justify-center px-3 py-6 text-center text-body-2 text-ink-4', className)}>
|
|
95
|
+
<div className="max-w-sm space-y-1">
|
|
96
|
+
<p className="text-ink-3">{title}</p>
|
|
97
|
+
{description && <p className="text-ink-4">{description}</p>}
|
|
98
|
+
{(action || secondaryAction) && (
|
|
99
|
+
<div className="mt-3 flex items-center justify-center gap-2">
|
|
100
|
+
{action && (
|
|
101
|
+
<Button size="sm" variant="outline" onClick={action.onClick} className="h-7 gap-1.5 text-body-2">
|
|
102
|
+
{action.icon && <action.icon className="h-3.5 w-3.5" />}
|
|
103
|
+
{action.label}
|
|
104
|
+
</Button>
|
|
105
|
+
)}
|
|
106
|
+
{secondaryAction && (
|
|
107
|
+
<Button asChild size="sm" variant="ghost" className="h-7 px-2 text-body-2">
|
|
108
|
+
<Link href={secondaryAction.href}>{secondaryAction.label}</Link>
|
|
109
|
+
</Button>
|
|
110
|
+
)}
|
|
111
|
+
</div>
|
|
112
|
+
)}
|
|
113
|
+
</div>
|
|
114
|
+
</div>
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (size === 'dense') {
|
|
119
|
+
return (
|
|
120
|
+
<div
|
|
121
|
+
className={cn(
|
|
122
|
+
'flex flex-col items-center justify-center px-4 py-8 text-center',
|
|
123
|
+
className,
|
|
124
|
+
)}
|
|
125
|
+
>
|
|
126
|
+
{illustration ?? (
|
|
127
|
+
<div className={cn('h-9 w-9 rounded-xl flex items-center justify-center mb-3', resolvedIconBg)}>
|
|
128
|
+
<Icon className={cn('h-4 w-4', resolvedIconColor)} />
|
|
129
|
+
</div>
|
|
130
|
+
)}
|
|
131
|
+
<p className="text-body-1 font-semibold text-ink">{title}</p>
|
|
132
|
+
{description && (
|
|
133
|
+
<p className="mt-1 max-w-md text-body-2 leading-snug text-ink-3">{description}</p>
|
|
134
|
+
)}
|
|
135
|
+
{(action || secondaryAction) && (
|
|
136
|
+
<div className="mt-3 flex items-center gap-2">
|
|
137
|
+
{action && (
|
|
138
|
+
<Button size="sm" variant="outline" onClick={action.onClick} className="h-7 gap-1.5 text-body-2">
|
|
139
|
+
{action.icon && <action.icon className="h-3.5 w-3.5" />}
|
|
140
|
+
{action.label}
|
|
141
|
+
</Button>
|
|
142
|
+
)}
|
|
143
|
+
{secondaryAction && (
|
|
144
|
+
<Button asChild size="sm" variant="ghost" className="h-7 px-2 text-body-2">
|
|
145
|
+
<Link href={secondaryAction.href}>{secondaryAction.label}</Link>
|
|
146
|
+
</Button>
|
|
147
|
+
)}
|
|
148
|
+
</div>
|
|
149
|
+
)}
|
|
150
|
+
</div>
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return (
|
|
155
|
+
<Card className={cn('border-2 border-dashed border-border/50 rounded-xl', className)}>
|
|
156
|
+
<CardContent className="flex flex-col items-center justify-center py-16 text-center">
|
|
157
|
+
{illustration ?? (
|
|
158
|
+
<div className={`h-16 w-16 rounded-2xl flex items-center justify-center mb-5 ${resolvedIconBg}`}>
|
|
159
|
+
<Icon className={`h-7 w-7 ${resolvedIconColor}`} />
|
|
160
|
+
</div>
|
|
161
|
+
)}
|
|
162
|
+
<p className="text-subtitle font-semibold text-ink">{title}</p>
|
|
163
|
+
{description && (
|
|
164
|
+
<p className="text-body-1 mt-2 max-w-sm text-ink-3 leading-relaxed">{description}</p>
|
|
165
|
+
)}
|
|
166
|
+
{(action || secondaryAction) && (
|
|
167
|
+
<div className="mt-6 flex items-center gap-3">
|
|
168
|
+
{action && (
|
|
169
|
+
<Button size="sm" variant="outline" onClick={action.onClick} className="gap-2 h-10 px-5">
|
|
170
|
+
{action.icon && <action.icon className="h-4 w-4" />}
|
|
171
|
+
{action.label}
|
|
172
|
+
</Button>
|
|
173
|
+
)}
|
|
174
|
+
{secondaryAction && (
|
|
175
|
+
<Button asChild size="sm" variant="ghost" className="h-10 px-3">
|
|
176
|
+
<Link href={secondaryAction.href}>{secondaryAction.label}</Link>
|
|
177
|
+
</Button>
|
|
178
|
+
)}
|
|
179
|
+
</div>
|
|
180
|
+
)}
|
|
181
|
+
</CardContent>
|
|
182
|
+
</Card>
|
|
183
|
+
);
|
|
184
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { AlertCircle, RefreshCw } from 'lucide-react';
|
|
2
|
+
|
|
3
|
+
import { getUserFacingErrorMessage } from '../../lib/biome-host/errors';
|
|
4
|
+
import { Button } from '../primitives/button';
|
|
5
|
+
import { Card, CardContent } from '../primitives/card';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Host-agnostic error formatter. The default is the canonical, envelope-aware
|
|
9
|
+
* {@link getUserFacingErrorMessage} decoder — biomes get rich error copy with
|
|
10
|
+
* zero wiring. Pass `formatError` only to override with a host-specific decoder
|
|
11
|
+
* (extra codes, i18n); the canonical default already handles Orval envelopes,
|
|
12
|
+
* `ApiClientError`, workflow error codes, and "failed to fetch".
|
|
13
|
+
*/
|
|
14
|
+
export type ErrorMessageFormatter = (error: unknown, fallback: string) => string;
|
|
15
|
+
|
|
16
|
+
const defaultFormatError: ErrorMessageFormatter = (error, fallback) =>
|
|
17
|
+
getUserFacingErrorMessage(error, fallback);
|
|
18
|
+
|
|
19
|
+
interface ErrorCardProps {
|
|
20
|
+
error: Error | string | unknown;
|
|
21
|
+
title?: string;
|
|
22
|
+
onRetry?: () => void;
|
|
23
|
+
/**
|
|
24
|
+
* Optional host error-decoder. Defaults to a plain message extractor.
|
|
25
|
+
* The host shell should pass `getUserFacingErrorMessage` to keep its
|
|
26
|
+
* envelope-aware messaging.
|
|
27
|
+
*/
|
|
28
|
+
formatError?: ErrorMessageFormatter;
|
|
29
|
+
fallbackMessage?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export default function ErrorCard({
|
|
33
|
+
error,
|
|
34
|
+
title = 'Something went wrong',
|
|
35
|
+
onRetry,
|
|
36
|
+
formatError = defaultFormatError,
|
|
37
|
+
fallbackMessage = 'Unable to load this content right now.',
|
|
38
|
+
}: ErrorCardProps) {
|
|
39
|
+
const message = formatError(error, fallbackMessage);
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<Card className="border-destructive/25 bg-destructive/[0.03] rounded-xl">
|
|
43
|
+
<CardContent className="flex flex-col items-center py-14 text-center">
|
|
44
|
+
<div className="h-14 w-14 rounded-2xl bg-destructive/10 flex items-center justify-center mb-4">
|
|
45
|
+
<AlertCircle className="h-6 w-6 text-destructive" />
|
|
46
|
+
</div>
|
|
47
|
+
<p className="text-subtitle font-semibold text-ink">{title}</p>
|
|
48
|
+
<p className="text-body-1 text-ink-3 mt-2 max-w-md leading-relaxed">{message}</p>
|
|
49
|
+
{onRetry && (
|
|
50
|
+
<Button
|
|
51
|
+
size="sm"
|
|
52
|
+
variant="outline"
|
|
53
|
+
onClick={onRetry}
|
|
54
|
+
className="mt-5 gap-2 h-10 border-destructive/20 text-destructive hover:bg-destructive/5"
|
|
55
|
+
>
|
|
56
|
+
<RefreshCw className="h-4 w-4" />
|
|
57
|
+
Retry
|
|
58
|
+
</Button>
|
|
59
|
+
)}
|
|
60
|
+
</CardContent>
|
|
61
|
+
</Card>
|
|
62
|
+
);
|
|
63
|
+
}
|