@pyric/ui 0.1.0-alpha.11 → 0.1.0-alpha.13
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/package.json +5 -4
- package/src/agents/ContextWindowUsage.tsx +514 -0
- package/src/agents/EmptyState.tsx +27 -0
- package/src/agents/Fold.tsx +64 -0
- package/src/agents/Modal.tsx +65 -0
- package/src/agents/PulsingDot.tsx +28 -0
- package/src/agents/inbrowser-agent-usage.d.ts +141 -0
- package/src/agents/index.ts +28 -0
- package/src/auth/authApi.ts +72 -0
- package/src/auth/claims.ts +63 -0
- package/src/auth/components/AuthProviderToggles.tsx +147 -0
- package/src/auth/components/AuthSignInHelper.tsx +197 -0
- package/src/auth/components/AuthUserForm.tsx +328 -0
- package/src/auth/components/AuthUserList.tsx +219 -0
- package/src/auth/components/ClaimsField.tsx +50 -0
- package/src/auth/components/confirmActions.tsx +114 -0
- package/src/auth/controller.ts +173 -0
- package/src/auth/hooks/index.ts +36 -0
- package/src/auth/hooks/useAuthFlowHelper.ts +55 -0
- package/src/auth/hooks/useAuthProviderConfig.ts +139 -0
- package/src/auth/hooks/useAuthUserEditor.ts +76 -0
- package/src/auth/hooks/useAuthUsers.ts +154 -0
- package/src/auth/index.ts +42 -0
- package/src/auth/providers.ts +28 -0
- package/src/auth/reducers/userEditor.ts +186 -0
- package/src/events/components/ActivityActionItems.tsx +136 -0
- package/src/events/components/ActivityGrid.tsx +197 -0
- package/src/events/components/ActivityGridRow.tsx +65 -0
- package/src/events/components/ProposedChangeDiff.tsx +175 -0
- package/src/events/components/format.ts +21 -0
- package/src/events/components/index.ts +17 -0
- package/src/events/digest.ts +630 -0
- package/src/events/hooks/index.ts +9 -0
- package/src/events/hooks/useActivityDigest.ts +42 -0
- package/src/events/hooks/useActivityStream.ts +86 -0
- package/src/events/index.ts +39 -0
- package/src/events/types.ts +152 -0
- package/src/firestore/components/CollectionList.tsx +78 -0
- package/src/firestore/components/DeleteWithConfirm.tsx +88 -0
- package/src/firestore/components/DocumentEditor.tsx +350 -0
- package/src/firestore/components/DocumentList.tsx +217 -0
- package/src/firestore/components/DocumentPreview.tsx +265 -0
- package/src/firestore/components/FieldRenderer.tsx +25 -0
- package/src/firestore/components/QueryBuilder.tsx +181 -0
- package/src/firestore/components/ReferencePicker.tsx +212 -0
- package/src/firestore/components/TreeEntry.tsx +58 -0
- package/src/firestore/components/context.ts +25 -0
- package/src/firestore/fieldEditors/array.tsx +30 -0
- package/src/firestore/fieldEditors/boolean.tsx +39 -0
- package/src/firestore/fieldEditors/bytes.tsx +53 -0
- package/src/firestore/fieldEditors/geopoint.tsx +71 -0
- package/src/firestore/fieldEditors/map.tsx +33 -0
- package/src/firestore/fieldEditors/null.tsx +27 -0
- package/src/firestore/fieldEditors/number.tsx +43 -0
- package/src/firestore/fieldEditors/reference.tsx +87 -0
- package/src/firestore/fieldEditors/registry.ts +45 -0
- package/src/firestore/fieldEditors/string.tsx +33 -0
- package/src/firestore/fieldEditors/timestamp.tsx +75 -0
- package/src/firestore/fieldEditors/types.ts +68 -0
- package/src/firestore/fieldEditors/vector.tsx +142 -0
- package/src/firestore/firestoreApi.ts +86 -0
- package/src/firestore/hooks/coerceError.ts +34 -0
- package/src/firestore/hooks/index.ts +46 -0
- package/src/firestore/hooks/useCollectionList.ts +102 -0
- package/src/firestore/hooks/useDocumentEditor.ts +161 -0
- package/src/firestore/hooks/useDocumentList.ts +242 -0
- package/src/firestore/hooks/useDocumentSubcollections.ts +86 -0
- package/src/firestore/hooks/useFirestoreCollection.ts +51 -0
- package/src/firestore/hooks/useFirestoreDoc.ts +55 -0
- package/src/firestore/hooks/useQueryBuilder.ts +188 -0
- package/src/firestore/hooks/useRecursiveDelete.ts +77 -0
- package/src/firestore/hooks/useReferencePicker.ts +228 -0
- package/src/firestore/import/parseImport.ts +137 -0
- package/src/firestore/index.ts +87 -0
- package/src/firestore/reducers/defaults.ts +38 -0
- package/src/firestore/reducers/documentEditor.ts +239 -0
- package/src/firestore/reducers/tree.ts +140 -0
- package/src/firestore/reducers/types.ts +92 -0
- package/src/firestore/reducers/validation.ts +129 -0
- package/src/firestore/types.ts +231 -0
- package/src/firestore/validation/ids.ts +45 -0
- package/src/firestore/valueEquality.ts +43 -0
- package/src/index.ts +10 -0
- package/src/primitives/Badge.tsx +40 -0
- package/src/primitives/ConfirmDialog.tsx +137 -0
- package/src/primitives/CopyButton.tsx +62 -0
- package/src/primitives/JsonView.tsx +151 -0
- package/src/primitives/SegmentedControl.tsx +72 -0
- package/src/primitives/Toast.tsx +161 -0
- package/src/primitives/VirtualList.tsx +104 -0
- package/src/primitives/hooks/useContainerSize.ts +53 -0
- package/src/primitives/hooks/useUpdateHighlights.ts +109 -0
- package/src/primitives/index.ts +39 -0
- package/src/primitives/useConfirm.tsx +113 -0
- package/src/rtdb/components/RtdbPathBar.tsx +135 -0
- package/src/rtdb/components/RtdbTree.tsx +409 -0
- package/src/rtdb/editor.ts +79 -0
- package/src/rtdb/hooks/useRtdbTree.ts +137 -0
- package/src/rtdb/index.ts +66 -0
- package/src/rtdb/pathInput.ts +47 -0
- package/src/rtdb/reducers/tree.ts +191 -0
- package/src/rtdb/rtdbApi.ts +23 -0
- package/src/rtdb/values.ts +188 -0
- package/src/rules/components/DenialInspector.tsx +227 -0
- package/src/rules/components/format.ts +171 -0
- package/src/rules/components/index.ts +12 -0
- package/src/rules/components/scope.ts +75 -0
- package/src/rules/hooks/index.ts +5 -0
- package/src/rules/hooks/useDenialTrace.ts +100 -0
- package/src/rules/index.ts +24 -0
- package/src/rules/types.ts +91 -0
- package/src/storage/collisionRename.ts +114 -0
- package/src/storage/components/DeleteSelectionWithConfirm.tsx +193 -0
- package/src/storage/components/ObjectBrowser.tsx +219 -0
- package/src/storage/components/ObjectInspector.tsx +169 -0
- package/src/storage/components/PathBreadcrumb.tsx +84 -0
- package/src/storage/components/UploadDropzone.tsx +182 -0
- package/src/storage/folderPlaceholder.ts +40 -0
- package/src/storage/hooks/index.ts +59 -0
- package/src/storage/hooks/useMetadataEditor.ts +329 -0
- package/src/storage/hooks/useObjectUpload.ts +262 -0
- package/src/storage/hooks/usePathState.ts +94 -0
- package/src/storage/hooks/useStorageDelete.ts +195 -0
- package/src/storage/hooks/useStorageList.ts +261 -0
- package/src/storage/hooks/useStorageObject.ts +162 -0
- package/src/storage/hooks/useStorageRulesGate.ts +270 -0
- package/src/storage/hooks/useStorageSelection.ts +90 -0
- package/src/storage/index.ts +59 -0
- package/src/storage/pendingPrefixes.ts +125 -0
- package/src/storage/previews.tsx +120 -0
- package/src/storage/storageApi.ts +54 -0
- package/src/traffic/components/RuleHeatmap.tsx +97 -0
- package/src/traffic/components/TrafficDetail.tsx +160 -0
- package/src/traffic/components/TrafficGroupRow.tsx +91 -0
- package/src/traffic/components/TrafficLineChart.tsx +139 -0
- package/src/traffic/components/TrafficLog.tsx +175 -0
- package/src/traffic/components/TrafficMetricCards.tsx +77 -0
- package/src/traffic/components/TrafficRow.tsx +69 -0
- package/src/traffic/components/TrafficStats.tsx +73 -0
- package/src/traffic/components/TrafficTimeline.tsx +289 -0
- package/src/traffic/components/format.ts +22 -0
- package/src/traffic/components/index.ts +22 -0
- package/src/traffic/hooks/index.ts +60 -0
- package/src/traffic/hooks/useRuleHeatmap.ts +92 -0
- package/src/traffic/hooks/useTrafficBuckets.ts +146 -0
- package/src/traffic/hooks/useTrafficFilter.ts +74 -0
- package/src/traffic/hooks/useTrafficGroups.ts +126 -0
- package/src/traffic/hooks/useTrafficMetrics.ts +250 -0
- package/src/traffic/hooks/useTrafficMonitor.ts +111 -0
- package/src/traffic/hooks/useTrafficStats.ts +77 -0
- package/src/traffic/index.ts +13 -0
- package/src/traffic/types.ts +85 -0
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createContext,
|
|
3
|
+
useCallback,
|
|
4
|
+
useContext,
|
|
5
|
+
useMemo,
|
|
6
|
+
useRef,
|
|
7
|
+
useState,
|
|
8
|
+
type ReactNode,
|
|
9
|
+
} from 'react';
|
|
10
|
+
import { ConfirmDialog } from './ConfirmDialog.js';
|
|
11
|
+
|
|
12
|
+
export interface ConfirmOptions {
|
|
13
|
+
title: string;
|
|
14
|
+
body?: ReactNode;
|
|
15
|
+
destructive?: boolean;
|
|
16
|
+
confirmLabel?: string;
|
|
17
|
+
cancelLabel?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type ConfirmFn = (options: ConfirmOptions) => Promise<boolean>;
|
|
21
|
+
|
|
22
|
+
const Ctx = createContext<ConfirmFn | null>(null);
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Imperative confirmation hook. Returns a function that opens the
|
|
26
|
+
* provider-managed dialog and resolves to `true` on confirm, `false`
|
|
27
|
+
* on cancel/dismiss.
|
|
28
|
+
*
|
|
29
|
+
* const confirm = useConfirm();
|
|
30
|
+
* const ok = await confirm({ title: 'Delete?', destructive: true });
|
|
31
|
+
* if (ok) await deleteDoc(ref);
|
|
32
|
+
*
|
|
33
|
+
* Requires a `<ConfirmProvider>` ancestor.
|
|
34
|
+
*/
|
|
35
|
+
export function useConfirm(): ConfirmFn {
|
|
36
|
+
const fn = useContext(Ctx);
|
|
37
|
+
if (!fn) {
|
|
38
|
+
throw new Error('useConfirm: missing <ConfirmProvider> ancestor');
|
|
39
|
+
}
|
|
40
|
+
return fn;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface PendingConfirm {
|
|
44
|
+
options: ConfirmOptions;
|
|
45
|
+
resolve: (ok: boolean) => void;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface ConfirmProviderProps {
|
|
49
|
+
children: ReactNode;
|
|
50
|
+
/** Forwarded to the rendered `<ConfirmDialog>`. */
|
|
51
|
+
dialogClassName?: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Mounts a single managed `<ConfirmDialog>` and exposes the
|
|
56
|
+
* imperative `confirm()` API through context. Scoped — multiple
|
|
57
|
+
* providers can coexist in different subtrees, each managing its
|
|
58
|
+
* own dialog.
|
|
59
|
+
*/
|
|
60
|
+
export function ConfirmProvider({ children, dialogClassName }: ConfirmProviderProps) {
|
|
61
|
+
const [pending, setPending] = useState<PendingConfirm | null>(null);
|
|
62
|
+
// Track which resolution path fired so the close handler doesn't
|
|
63
|
+
// double-resolve when both onConfirm + onOpenChange(false) trigger.
|
|
64
|
+
const resolvedRef = useRef(false);
|
|
65
|
+
|
|
66
|
+
const confirm = useCallback<ConfirmFn>((options) => {
|
|
67
|
+
return new Promise<boolean>((resolve) => {
|
|
68
|
+
resolvedRef.current = false;
|
|
69
|
+
setPending({ options, resolve });
|
|
70
|
+
});
|
|
71
|
+
}, []);
|
|
72
|
+
|
|
73
|
+
const handleConfirm = useCallback(() => {
|
|
74
|
+
if (!pending) return;
|
|
75
|
+
if (resolvedRef.current) return;
|
|
76
|
+
resolvedRef.current = true;
|
|
77
|
+
pending.resolve(true);
|
|
78
|
+
setPending(null);
|
|
79
|
+
}, [pending]);
|
|
80
|
+
|
|
81
|
+
const handleOpenChange = useCallback(
|
|
82
|
+
(open: boolean) => {
|
|
83
|
+
if (open) return;
|
|
84
|
+
if (!pending) return;
|
|
85
|
+
if (resolvedRef.current) return;
|
|
86
|
+
resolvedRef.current = true;
|
|
87
|
+
pending.resolve(false);
|
|
88
|
+
setPending(null);
|
|
89
|
+
},
|
|
90
|
+
[pending],
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
const ctxValue = useMemo(() => confirm, [confirm]);
|
|
94
|
+
|
|
95
|
+
return (
|
|
96
|
+
<Ctx.Provider value={ctxValue}>
|
|
97
|
+
{children}
|
|
98
|
+
{pending ? (
|
|
99
|
+
<ConfirmDialog
|
|
100
|
+
open
|
|
101
|
+
onOpenChange={handleOpenChange}
|
|
102
|
+
title={pending.options.title}
|
|
103
|
+
body={pending.options.body}
|
|
104
|
+
destructive={pending.options.destructive}
|
|
105
|
+
confirmLabel={pending.options.confirmLabel}
|
|
106
|
+
cancelLabel={pending.options.cancelLabel}
|
|
107
|
+
onConfirm={handleConfirm}
|
|
108
|
+
className={dialogClassName}
|
|
109
|
+
/>
|
|
110
|
+
) : null}
|
|
111
|
+
</Ctx.Provider>
|
|
112
|
+
);
|
|
113
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { useEffect, useRef, useState, type FormEvent, type ReactNode } from 'react';
|
|
2
|
+
import { parseRtdbPathInput, rtdbCrumbs } from '../pathInput.js';
|
|
3
|
+
import { normalizeRtdbPath } from '../values.js';
|
|
4
|
+
|
|
5
|
+
export interface RtdbPathBarProps {
|
|
6
|
+
/** Current absolute database path (`'/'` for root). */
|
|
7
|
+
path: string;
|
|
8
|
+
/** Fired with the parsed absolute path on crumb click or input submit. */
|
|
9
|
+
onNavigate: (path: string) => void;
|
|
10
|
+
/** Root crumb label — the database/instance identity (e.g. the sandbox
|
|
11
|
+
* slug). Default `'/'`. */
|
|
12
|
+
rootLabel?: ReactNode;
|
|
13
|
+
/** Text shown before the input while editing (the non-editable URL part). */
|
|
14
|
+
inputPrefix?: string;
|
|
15
|
+
className?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The editable path bar of the RTDB viewer — the interaction form of the
|
|
20
|
+
* Firebase console / firebase-tools-ui database URL bar (clean-room
|
|
21
|
+
* adaptation): in DISPLAY mode the path renders as clickable crumbs
|
|
22
|
+
* (root → … → current) plus an edit affordance; EDIT mode swaps in a text
|
|
23
|
+
* input seeded with the current path — Enter navigates, Escape or blur
|
|
24
|
+
* cancels. Pasted full URLs and missing leading slashes are tolerated
|
|
25
|
+
* (`parseRtdbPathInput`).
|
|
26
|
+
*
|
|
27
|
+
* Headless. Consumers style via:
|
|
28
|
+
* - `[data-pyric-ui="rtdb-path-bar"]` — the root (`data-rtdb-editing` when editing)
|
|
29
|
+
* - `[data-rtdb-crumb]` / `[data-rtdb-crumb-root]` / `[data-pyric-current]`
|
|
30
|
+
* - `[data-rtdb-crumb-separator]`
|
|
31
|
+
* - `[data-rtdb-path-edit]` — the edit button
|
|
32
|
+
* - `[data-rtdb-path-form]`, `[data-rtdb-path-prefix]`, `[data-rtdb-path-input]`
|
|
33
|
+
*/
|
|
34
|
+
export function RtdbPathBar({
|
|
35
|
+
path,
|
|
36
|
+
onNavigate,
|
|
37
|
+
rootLabel = '/',
|
|
38
|
+
inputPrefix,
|
|
39
|
+
className,
|
|
40
|
+
}: RtdbPathBarProps) {
|
|
41
|
+
const normalized = normalizeRtdbPath(path);
|
|
42
|
+
const [editing, setEditing] = useState(false);
|
|
43
|
+
// UNCONTROLLED input, read at submit: the input mounts fresh per edit
|
|
44
|
+
// session (display mode unmounts it) seeded with the current path, so a
|
|
45
|
+
// cancelled draft never leaks into the next session.
|
|
46
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
47
|
+
|
|
48
|
+
useEffect(() => {
|
|
49
|
+
if (editing) inputRef.current?.select();
|
|
50
|
+
}, [editing]);
|
|
51
|
+
|
|
52
|
+
const submit = (e: FormEvent) => {
|
|
53
|
+
e.preventDefault();
|
|
54
|
+
onNavigate(parseRtdbPathInput(inputRef.current?.value ?? ''));
|
|
55
|
+
setEditing(false);
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const cancel = () => setEditing(false);
|
|
59
|
+
|
|
60
|
+
const crumbs = rtdbCrumbs(normalized);
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
<nav
|
|
64
|
+
className={className}
|
|
65
|
+
data-pyric-ui="rtdb-path-bar"
|
|
66
|
+
data-rtdb-editing={editing ? '' : undefined}
|
|
67
|
+
aria-label="Database path"
|
|
68
|
+
>
|
|
69
|
+
{editing ? (
|
|
70
|
+
<form data-rtdb-path-form onSubmit={submit}>
|
|
71
|
+
{inputPrefix ? <span data-rtdb-path-prefix>{inputPrefix}</span> : null}
|
|
72
|
+
<input
|
|
73
|
+
ref={inputRef}
|
|
74
|
+
data-rtdb-path-input
|
|
75
|
+
aria-label="Database path"
|
|
76
|
+
autoFocus
|
|
77
|
+
defaultValue={normalized}
|
|
78
|
+
onBlur={cancel}
|
|
79
|
+
onKeyDown={(e) => {
|
|
80
|
+
if (e.key === 'Escape') cancel();
|
|
81
|
+
}}
|
|
82
|
+
spellCheck={false}
|
|
83
|
+
/>
|
|
84
|
+
</form>
|
|
85
|
+
) : (
|
|
86
|
+
<>
|
|
87
|
+
<ol data-rtdb-crumb-list>
|
|
88
|
+
<li data-rtdb-crumb-item>
|
|
89
|
+
<button
|
|
90
|
+
type="button"
|
|
91
|
+
data-rtdb-crumb
|
|
92
|
+
data-rtdb-crumb-root
|
|
93
|
+
data-pyric-current={crumbs.length === 0 ? '' : undefined}
|
|
94
|
+
aria-current={crumbs.length === 0 ? 'page' : undefined}
|
|
95
|
+
onClick={() => onNavigate('/')}
|
|
96
|
+
>
|
|
97
|
+
{rootLabel}
|
|
98
|
+
</button>
|
|
99
|
+
</li>
|
|
100
|
+
{crumbs.map((crumb, i) => {
|
|
101
|
+
const isCurrent = i === crumbs.length - 1;
|
|
102
|
+
return (
|
|
103
|
+
<li key={crumb.path} data-rtdb-crumb-item>
|
|
104
|
+
<span aria-hidden data-rtdb-crumb-separator>
|
|
105
|
+
/
|
|
106
|
+
</span>
|
|
107
|
+
<button
|
|
108
|
+
type="button"
|
|
109
|
+
data-rtdb-crumb
|
|
110
|
+
data-pyric-current={isCurrent ? '' : undefined}
|
|
111
|
+
aria-current={isCurrent ? 'page' : undefined}
|
|
112
|
+
onClick={() => onNavigate(crumb.path)}
|
|
113
|
+
>
|
|
114
|
+
{crumb.label}
|
|
115
|
+
</button>
|
|
116
|
+
</li>
|
|
117
|
+
);
|
|
118
|
+
})}
|
|
119
|
+
</ol>
|
|
120
|
+
<button
|
|
121
|
+
type="button"
|
|
122
|
+
data-rtdb-path-edit
|
|
123
|
+
aria-label="Edit path"
|
|
124
|
+
title="Edit path"
|
|
125
|
+
onClick={() => setEditing(true)}
|
|
126
|
+
>
|
|
127
|
+
<svg aria-hidden viewBox="0 0 16 16" width="12" height="12" fill="currentColor">
|
|
128
|
+
<path d="M11.7 1.3a1 1 0 0 1 1.4 0l1.6 1.6a1 1 0 0 1 0 1.4l-8.5 8.5-3.4 1.1a.5.5 0 0 1-.63-.63l1.1-3.4 8.43-8.57ZM10.9 3.5l1.6 1.6 1.2-1.2-1.6-1.6-1.2 1.2Z" />
|
|
129
|
+
</svg>
|
|
130
|
+
</button>
|
|
131
|
+
</>
|
|
132
|
+
)}
|
|
133
|
+
</nav>
|
|
134
|
+
);
|
|
135
|
+
}
|
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
import { useRef, useState, type FormEvent, type ReactNode } from 'react';
|
|
2
|
+
import type { RtdbApi } from '../rtdbApi.js';
|
|
3
|
+
import type { RtdbTreeController } from '../hooks/useRtdbTree.js';
|
|
4
|
+
import {
|
|
5
|
+
RTDB_EDITOR_TYPES,
|
|
6
|
+
coerceRtdbEditorValue,
|
|
7
|
+
formatRtdbEditorValue,
|
|
8
|
+
inferRtdbEditorType,
|
|
9
|
+
rtdbKeyInputError,
|
|
10
|
+
type RtdbEditorType,
|
|
11
|
+
} from '../editor.js';
|
|
12
|
+
import {
|
|
13
|
+
formatRtdbValueLabel,
|
|
14
|
+
hasRtdbChildren,
|
|
15
|
+
joinRtdbPath,
|
|
16
|
+
rtdbPathSegments,
|
|
17
|
+
rtdbValueKind,
|
|
18
|
+
} from '../values.js';
|
|
19
|
+
|
|
20
|
+
export interface RtdbTreeProps {
|
|
21
|
+
/** The live tree controller from `useRtdbTree`. */
|
|
22
|
+
tree: RtdbTreeController;
|
|
23
|
+
/** Mutation backend (admin lens in Studio). */
|
|
24
|
+
api: RtdbApi;
|
|
25
|
+
/** Key-click navigation: re-roots the viewer at the clicked node (wire to
|
|
26
|
+
* the same path state as the path bar). */
|
|
27
|
+
onNavigate?: (path: string) => void;
|
|
28
|
+
/** Label for the view-root row when the root is `'/'` — the database /
|
|
29
|
+
* instance identity. Default `'/'`. */
|
|
30
|
+
rootLabel?: ReactNode;
|
|
31
|
+
className?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The RTDB data tree — the interaction form of the Firebase console /
|
|
36
|
+
* firebase-tools-ui database viewer (clean-room adaptation of the
|
|
37
|
+
* NodeContainer/NodeParent/NodeLeaf split):
|
|
38
|
+
*
|
|
39
|
+
* - parents render a caret (expand/collapse), leaves a `key: value` row;
|
|
40
|
+
* - keys navigate (re-root the view), carets only toggle;
|
|
41
|
+
* - hover/focus reveals per-node actions: `+` add child, `×` delete —
|
|
42
|
+
* delete flips to an INLINE two-step confirm (no modal, C3);
|
|
43
|
+
* - leaf values are click-to-edit inline (type select: string / number /
|
|
44
|
+
* boolean / JSON);
|
|
45
|
+
* - wide levels page at `tree.pageSize` with a "show more" row (the console's
|
|
46
|
+
* form — see `reducers/tree.ts` for why paging over virtualization).
|
|
47
|
+
*
|
|
48
|
+
* Headless: consumers style `[data-pyric-ui="rtdb-tree"]` and the
|
|
49
|
+
* `data-rtdb-*` attributes (`node`, `row`, `caret`, `key`, `sep`, `value`,
|
|
50
|
+
* `actions`, `action-add`, `action-delete`, `confirm`, `editor`, `children`,
|
|
51
|
+
* `show-more`, `error`, `loading`). An empty root renders the console's
|
|
52
|
+
* classic form — `<root>: null` — not an instructional empty state.
|
|
53
|
+
*/
|
|
54
|
+
export function RtdbTree({ tree, api, onNavigate, rootLabel = '/', className }: RtdbTreeProps) {
|
|
55
|
+
const { state } = tree;
|
|
56
|
+
const rootSegments = rtdbPathSegments(state.path);
|
|
57
|
+
const rootKey: ReactNode =
|
|
58
|
+
rootSegments.length === 0 ? rootLabel : rootSegments[rootSegments.length - 1];
|
|
59
|
+
|
|
60
|
+
return (
|
|
61
|
+
<div className={className} data-pyric-ui="rtdb-tree">
|
|
62
|
+
{state.status === 'loading' ? (
|
|
63
|
+
<p data-rtdb-loading>Loading…</p>
|
|
64
|
+
) : state.status === 'error' ? (
|
|
65
|
+
<p role="alert" data-rtdb-error>
|
|
66
|
+
{state.error}
|
|
67
|
+
</p>
|
|
68
|
+
) : (
|
|
69
|
+
<Node
|
|
70
|
+
tree={tree}
|
|
71
|
+
api={api}
|
|
72
|
+
onNavigate={onNavigate}
|
|
73
|
+
path={state.path}
|
|
74
|
+
label={rootKey}
|
|
75
|
+
isViewRoot
|
|
76
|
+
/>
|
|
77
|
+
)}
|
|
78
|
+
</div>
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
interface NodeProps {
|
|
83
|
+
tree: RtdbTreeController;
|
|
84
|
+
api: RtdbApi;
|
|
85
|
+
onNavigate?: (path: string) => void;
|
|
86
|
+
/** Absolute database path of this node. */
|
|
87
|
+
path: string;
|
|
88
|
+
/** The key text (or the root label node for the view root at `'/'`). */
|
|
89
|
+
label: ReactNode;
|
|
90
|
+
isViewRoot?: boolean;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function Node({ tree, api, onNavigate, path, label, isViewRoot }: NodeProps) {
|
|
94
|
+
const value = tree.valueAt(path);
|
|
95
|
+
const isParent = hasRtdbChildren(value);
|
|
96
|
+
const expanded = tree.isExpanded(path);
|
|
97
|
+
const update = tree.updateAt(path);
|
|
98
|
+
|
|
99
|
+
const [adding, setAdding] = useState(false);
|
|
100
|
+
const [editing, setEditing] = useState(false);
|
|
101
|
+
const [confirming, setConfirming] = useState(false);
|
|
102
|
+
const [actionError, setActionError] = useState<string | null>(null);
|
|
103
|
+
|
|
104
|
+
const run = async (op: () => Promise<void>, done: () => void) => {
|
|
105
|
+
try {
|
|
106
|
+
await op();
|
|
107
|
+
setActionError(null);
|
|
108
|
+
done();
|
|
109
|
+
} catch (e) {
|
|
110
|
+
setActionError(e instanceof Error ? e.message : String(e));
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const openAdd = () => {
|
|
115
|
+
setAdding(true);
|
|
116
|
+
setConfirming(false);
|
|
117
|
+
if (isParent && !expanded) tree.toggle(path);
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
const children = isParent && expanded ? tree.childrenAt(path) : null;
|
|
121
|
+
|
|
122
|
+
return (
|
|
123
|
+
<div
|
|
124
|
+
data-rtdb-node
|
|
125
|
+
data-rtdb-kind={isParent ? 'parent' : 'leaf'}
|
|
126
|
+
data-rtdb-view-root={isViewRoot ? '' : undefined}
|
|
127
|
+
data-rtdb-expanded={isParent && expanded ? '' : undefined}
|
|
128
|
+
>
|
|
129
|
+
<div
|
|
130
|
+
data-rtdb-row
|
|
131
|
+
data-pyric-update={update?.kind}
|
|
132
|
+
data-pyric-update-cycle={update?.cycle}
|
|
133
|
+
>
|
|
134
|
+
{isParent && !isViewRoot ? (
|
|
135
|
+
<button
|
|
136
|
+
type="button"
|
|
137
|
+
data-rtdb-caret
|
|
138
|
+
aria-expanded={expanded}
|
|
139
|
+
aria-label={expanded ? 'Collapse' : 'Expand'}
|
|
140
|
+
onClick={() => tree.toggle(path)}
|
|
141
|
+
>
|
|
142
|
+
<svg aria-hidden viewBox="0 0 8 8" width="8" height="8" fill="currentColor">
|
|
143
|
+
<path d="M1 0l6 4-6 4z" />
|
|
144
|
+
</svg>
|
|
145
|
+
</button>
|
|
146
|
+
) : (
|
|
147
|
+
<span aria-hidden data-rtdb-caret-spacer />
|
|
148
|
+
)}
|
|
149
|
+
|
|
150
|
+
<button
|
|
151
|
+
type="button"
|
|
152
|
+
data-rtdb-key
|
|
153
|
+
aria-label="Key name"
|
|
154
|
+
onClick={() => onNavigate?.(path)}
|
|
155
|
+
>
|
|
156
|
+
{label}
|
|
157
|
+
</button>
|
|
158
|
+
|
|
159
|
+
{!isParent ? (
|
|
160
|
+
<>
|
|
161
|
+
<span aria-hidden data-rtdb-sep>
|
|
162
|
+
:
|
|
163
|
+
</span>
|
|
164
|
+
<button
|
|
165
|
+
type="button"
|
|
166
|
+
data-rtdb-value
|
|
167
|
+
data-rtdb-type={rtdbValueKind(value)}
|
|
168
|
+
title="Edit value"
|
|
169
|
+
onClick={() => {
|
|
170
|
+
setEditing(true);
|
|
171
|
+
setConfirming(false);
|
|
172
|
+
}}
|
|
173
|
+
>
|
|
174
|
+
{formatRtdbValueLabel(value)}
|
|
175
|
+
</button>
|
|
176
|
+
</>
|
|
177
|
+
) : null}
|
|
178
|
+
|
|
179
|
+
<span data-rtdb-actions>
|
|
180
|
+
{confirming ? (
|
|
181
|
+
<span data-rtdb-confirm>
|
|
182
|
+
<span data-rtdb-confirm-label>Delete{isParent ? ' subtree' : ''}?</span>
|
|
183
|
+
<button
|
|
184
|
+
type="button"
|
|
185
|
+
data-rtdb-confirm-yes
|
|
186
|
+
onClick={() =>
|
|
187
|
+
void run(
|
|
188
|
+
() => api.remove(path),
|
|
189
|
+
() => setConfirming(false),
|
|
190
|
+
)
|
|
191
|
+
}
|
|
192
|
+
>
|
|
193
|
+
Delete
|
|
194
|
+
</button>
|
|
195
|
+
<button type="button" data-rtdb-confirm-no onClick={() => setConfirming(false)}>
|
|
196
|
+
Cancel
|
|
197
|
+
</button>
|
|
198
|
+
</span>
|
|
199
|
+
) : (
|
|
200
|
+
<>
|
|
201
|
+
<button
|
|
202
|
+
type="button"
|
|
203
|
+
data-rtdb-action-add
|
|
204
|
+
aria-label={`Add child to ${pathLabel(path)}`}
|
|
205
|
+
title="Add child"
|
|
206
|
+
onClick={openAdd}
|
|
207
|
+
>
|
|
208
|
+
+
|
|
209
|
+
</button>
|
|
210
|
+
<button
|
|
211
|
+
type="button"
|
|
212
|
+
data-rtdb-action-delete
|
|
213
|
+
aria-label={`Delete ${pathLabel(path)}`}
|
|
214
|
+
title="Delete"
|
|
215
|
+
onClick={() => setConfirming(true)}
|
|
216
|
+
>
|
|
217
|
+
×
|
|
218
|
+
</button>
|
|
219
|
+
</>
|
|
220
|
+
)}
|
|
221
|
+
</span>
|
|
222
|
+
</div>
|
|
223
|
+
|
|
224
|
+
{actionError ? (
|
|
225
|
+
<p role="alert" data-rtdb-error>
|
|
226
|
+
{actionError}
|
|
227
|
+
</p>
|
|
228
|
+
) : null}
|
|
229
|
+
|
|
230
|
+
{editing ? (
|
|
231
|
+
<ValueEditor
|
|
232
|
+
initialType={inferRtdbEditorType(value)}
|
|
233
|
+
initialText={formatRtdbEditorValue(value, inferRtdbEditorType(value))}
|
|
234
|
+
submitLabel="Save"
|
|
235
|
+
onCancel={() => setEditing(false)}
|
|
236
|
+
onSubmit={(next) =>
|
|
237
|
+
run(
|
|
238
|
+
() => api.set(path, next),
|
|
239
|
+
() => setEditing(false),
|
|
240
|
+
)
|
|
241
|
+
}
|
|
242
|
+
/>
|
|
243
|
+
) : null}
|
|
244
|
+
|
|
245
|
+
{adding ? (
|
|
246
|
+
<ValueEditor
|
|
247
|
+
withKey
|
|
248
|
+
initialType="string"
|
|
249
|
+
initialText=""
|
|
250
|
+
submitLabel="Add"
|
|
251
|
+
onCancel={() => setAdding(false)}
|
|
252
|
+
onSubmit={(next, key) =>
|
|
253
|
+
run(
|
|
254
|
+
() => api.set(joinRtdbPath(path, key!), next),
|
|
255
|
+
() => setAdding(false),
|
|
256
|
+
)
|
|
257
|
+
}
|
|
258
|
+
/>
|
|
259
|
+
) : null}
|
|
260
|
+
|
|
261
|
+
{children ? (
|
|
262
|
+
<ul data-rtdb-children>
|
|
263
|
+
{children.entries.map(([key]) => (
|
|
264
|
+
<li key={key}>
|
|
265
|
+
<Node
|
|
266
|
+
tree={tree}
|
|
267
|
+
api={api}
|
|
268
|
+
onNavigate={onNavigate}
|
|
269
|
+
path={joinRtdbPath(path, key)}
|
|
270
|
+
label={key}
|
|
271
|
+
/>
|
|
272
|
+
</li>
|
|
273
|
+
))}
|
|
274
|
+
{children.hiddenCount > 0 ? (
|
|
275
|
+
<li data-rtdb-show-more-item>
|
|
276
|
+
<button type="button" data-rtdb-show-more onClick={() => tree.showMore(path)}>
|
|
277
|
+
Show more ({children.hiddenCount} hidden)
|
|
278
|
+
</button>
|
|
279
|
+
</li>
|
|
280
|
+
) : null}
|
|
281
|
+
</ul>
|
|
282
|
+
) : null}
|
|
283
|
+
</div>
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** Last segment for aria labels; `'/'` reads as "root". */
|
|
288
|
+
function pathLabel(path: string): string {
|
|
289
|
+
const segments = rtdbPathSegments(path);
|
|
290
|
+
return segments.length === 0 ? 'root' : segments[segments.length - 1];
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
interface ValueEditorProps {
|
|
294
|
+
/** Add mode: include the child-key input. */
|
|
295
|
+
withKey?: boolean;
|
|
296
|
+
initialType: RtdbEditorType;
|
|
297
|
+
initialText: string;
|
|
298
|
+
submitLabel: string;
|
|
299
|
+
onSubmit: (value: unknown, key?: string) => Promise<void> | void;
|
|
300
|
+
onCancel: () => void;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Inline key/value editor row (add + edit flows). Escape cancels.
|
|
305
|
+
*
|
|
306
|
+
* The key + value fields are UNCONTROLLED and read at submit — validation
|
|
307
|
+
* happens on submit (errors render inline), so per-keystroke state buys
|
|
308
|
+
* nothing. Only the type select is controlled (it decides whether the value
|
|
309
|
+
* control is a text input or a true/false select).
|
|
310
|
+
*/
|
|
311
|
+
function ValueEditor({
|
|
312
|
+
withKey,
|
|
313
|
+
initialType,
|
|
314
|
+
initialText,
|
|
315
|
+
submitLabel,
|
|
316
|
+
onSubmit,
|
|
317
|
+
onCancel,
|
|
318
|
+
}: ValueEditorProps) {
|
|
319
|
+
const keyRef = useRef<HTMLInputElement>(null);
|
|
320
|
+
const valueRef = useRef<HTMLInputElement | HTMLSelectElement>(null);
|
|
321
|
+
const [type, setType] = useState<RtdbEditorType>(initialType);
|
|
322
|
+
const [error, setError] = useState<string | null>(null);
|
|
323
|
+
|
|
324
|
+
const submit = (e: FormEvent) => {
|
|
325
|
+
e.preventDefault();
|
|
326
|
+
const key = keyRef.current?.value ?? '';
|
|
327
|
+
if (withKey) {
|
|
328
|
+
const keyError = rtdbKeyInputError(key);
|
|
329
|
+
if (keyError) {
|
|
330
|
+
setError(keyError);
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
const coerced = coerceRtdbEditorValue(type, valueRef.current?.value ?? '');
|
|
335
|
+
if (!coerced.ok) {
|
|
336
|
+
setError(coerced.error);
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
setError(null);
|
|
340
|
+
void onSubmit(coerced.value, withKey ? key.trim() : undefined);
|
|
341
|
+
};
|
|
342
|
+
|
|
343
|
+
const onEscape = (e: { key: string }) => {
|
|
344
|
+
if (e.key === 'Escape') onCancel();
|
|
345
|
+
};
|
|
346
|
+
|
|
347
|
+
return (
|
|
348
|
+
<form data-rtdb-editor onSubmit={submit}>
|
|
349
|
+
{withKey ? (
|
|
350
|
+
<input
|
|
351
|
+
ref={keyRef}
|
|
352
|
+
data-rtdb-editor-key
|
|
353
|
+
aria-label="Child key"
|
|
354
|
+
placeholder="key"
|
|
355
|
+
autoFocus
|
|
356
|
+
onKeyDown={onEscape}
|
|
357
|
+
spellCheck={false}
|
|
358
|
+
/>
|
|
359
|
+
) : null}
|
|
360
|
+
<select
|
|
361
|
+
data-rtdb-editor-type
|
|
362
|
+
aria-label="Value type"
|
|
363
|
+
value={type}
|
|
364
|
+
onChange={(e) => setType(e.currentTarget.value as RtdbEditorType)}
|
|
365
|
+
onKeyDown={onEscape}
|
|
366
|
+
>
|
|
367
|
+
{RTDB_EDITOR_TYPES.map((t) => (
|
|
368
|
+
<option key={t} value={t}>
|
|
369
|
+
{t}
|
|
370
|
+
</option>
|
|
371
|
+
))}
|
|
372
|
+
</select>
|
|
373
|
+
{type === 'boolean' ? (
|
|
374
|
+
<select
|
|
375
|
+
ref={valueRef as { current: HTMLSelectElement | null }}
|
|
376
|
+
data-rtdb-editor-value
|
|
377
|
+
aria-label="Value"
|
|
378
|
+
defaultValue={initialText.trim().toLowerCase() === 'true' ? 'true' : 'false'}
|
|
379
|
+
onKeyDown={onEscape}
|
|
380
|
+
>
|
|
381
|
+
<option value="true">true</option>
|
|
382
|
+
<option value="false">false</option>
|
|
383
|
+
</select>
|
|
384
|
+
) : (
|
|
385
|
+
<input
|
|
386
|
+
ref={valueRef as { current: HTMLInputElement | null }}
|
|
387
|
+
data-rtdb-editor-value
|
|
388
|
+
aria-label="Value"
|
|
389
|
+
placeholder={type === 'json' ? '{ "key": "value" }' : 'value'}
|
|
390
|
+
autoFocus={!withKey}
|
|
391
|
+
defaultValue={initialText}
|
|
392
|
+
onKeyDown={onEscape}
|
|
393
|
+
spellCheck={false}
|
|
394
|
+
/>
|
|
395
|
+
)}
|
|
396
|
+
<button type="submit" data-rtdb-editor-save>
|
|
397
|
+
{submitLabel}
|
|
398
|
+
</button>
|
|
399
|
+
<button type="button" data-rtdb-editor-cancel onClick={onCancel}>
|
|
400
|
+
Cancel
|
|
401
|
+
</button>
|
|
402
|
+
{error ? (
|
|
403
|
+
<span role="alert" data-rtdb-editor-error>
|
|
404
|
+
{error}
|
|
405
|
+
</span>
|
|
406
|
+
) : null}
|
|
407
|
+
</form>
|
|
408
|
+
);
|
|
409
|
+
}
|