@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,350 @@
|
|
|
1
|
+
import { createContext, useContext, useEffect, useRef, type ReactNode } from 'react';
|
|
2
|
+
import { useDocumentEditor, type UseDocumentEditorResult } from '../hooks/useDocumentEditor.js';
|
|
3
|
+
import { mergeFieldEditors } from '../fieldEditors/registry.js';
|
|
4
|
+
import type { FieldEditorRegistry } from '../fieldEditors/types.js';
|
|
5
|
+
import type { FieldNode } from '../reducers/types.js';
|
|
6
|
+
import type { FieldType } from '../types.js';
|
|
7
|
+
import { useContainerSize } from '../../primitives/hooks/useContainerSize.js';
|
|
8
|
+
|
|
9
|
+
interface EditorContext {
|
|
10
|
+
editor: UseDocumentEditorResult;
|
|
11
|
+
fieldEditors: FieldEditorRegistry;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const Ctx = createContext<EditorContext | null>(null);
|
|
15
|
+
|
|
16
|
+
function useEditorContext(): EditorContext {
|
|
17
|
+
const ctx = useContext(Ctx);
|
|
18
|
+
if (!ctx) {
|
|
19
|
+
throw new Error(
|
|
20
|
+
'DocumentEditor.* components must render inside <DocumentEditor.Root>',
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
return ctx;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface DocumentEditorRootProps {
|
|
27
|
+
/** Initial document data. Built into the editor's tree on first
|
|
28
|
+
* mount; later changes don't rebuild — call `editor.reset()` to
|
|
29
|
+
* re-initialize from a fresh `initial`. */
|
|
30
|
+
initial?: Record<string, unknown>;
|
|
31
|
+
/** Override or extend the built-in field editors. */
|
|
32
|
+
fieldEditors?: FieldEditorRegistry;
|
|
33
|
+
/** Called on every state change with the latest editor state. The
|
|
34
|
+
* parent typically watches `state.isValid` + `state.isDirty` to
|
|
35
|
+
* enable/disable a Save button. */
|
|
36
|
+
onChange?: (state: UseDocumentEditorResult) => void;
|
|
37
|
+
children: ReactNode;
|
|
38
|
+
className?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Wires the `useDocumentEditor` hook + field-editor registry into a
|
|
43
|
+
* React context so `<DocumentEditor.Fields>` and `<DocumentEditor.AddField>`
|
|
44
|
+
* can render the tree without prop drilling.
|
|
45
|
+
*
|
|
46
|
+
* Pattern: hook + compound component. Consumers wanting full control
|
|
47
|
+
* over the layout call `useDocumentEditor` directly and render their
|
|
48
|
+
* own tree; consumers wanting the default rendering use this.
|
|
49
|
+
*/
|
|
50
|
+
export function DocumentEditorRoot({
|
|
51
|
+
initial,
|
|
52
|
+
fieldEditors,
|
|
53
|
+
onChange,
|
|
54
|
+
children,
|
|
55
|
+
className,
|
|
56
|
+
}: DocumentEditorRootProps) {
|
|
57
|
+
const editor = useDocumentEditor({ initial });
|
|
58
|
+
const registry = mergeFieldEditors(fieldEditors);
|
|
59
|
+
|
|
60
|
+
// Hold the latest onChange in a ref so the effect's deps don't
|
|
61
|
+
// include the callback — avoids re-firing onChange on every parent
|
|
62
|
+
// re-render that produces a new function identity.
|
|
63
|
+
const onChangeRef = useRef(onChange);
|
|
64
|
+
onChangeRef.current = onChange;
|
|
65
|
+
useEffect(() => {
|
|
66
|
+
onChangeRef.current?.(editor);
|
|
67
|
+
}, [editor]);
|
|
68
|
+
|
|
69
|
+
const { ref: rootRef, size } = useContainerSize<HTMLDivElement>();
|
|
70
|
+
|
|
71
|
+
return (
|
|
72
|
+
<Ctx.Provider value={{ editor, fieldEditors: registry }}>
|
|
73
|
+
<div
|
|
74
|
+
ref={rootRef}
|
|
75
|
+
className={className}
|
|
76
|
+
data-pyric-ui="document-editor"
|
|
77
|
+
data-pyric-is-valid={editor.isValid ? '' : undefined}
|
|
78
|
+
data-pyric-is-dirty={editor.isDirty ? '' : undefined}
|
|
79
|
+
data-size={size}
|
|
80
|
+
>
|
|
81
|
+
{children}
|
|
82
|
+
</div>
|
|
83
|
+
</Ctx.Provider>
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Read access to the underlying editor state from inside a Root. */
|
|
88
|
+
export function useDocumentEditorContext(): UseDocumentEditorResult {
|
|
89
|
+
return useEditorContext().editor;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Renders the top-level fields of the document. For finer control,
|
|
94
|
+
* a consumer can call `useDocumentEditorContext()` and render the
|
|
95
|
+
* tree themselves.
|
|
96
|
+
*/
|
|
97
|
+
export function DocumentEditorFields() {
|
|
98
|
+
const { editor } = useEditorContext();
|
|
99
|
+
// Stable INSERTION order, never re-sorted by the (currently being
|
|
100
|
+
// typed) key — `childIds` already preserves the order fields were
|
|
101
|
+
// added in, which is exactly what must stay stable while a row's
|
|
102
|
+
// name is edited. Sorting here was the cause of rows visibly
|
|
103
|
+
// reordering mid-keystroke.
|
|
104
|
+
const rootChildren = editor.tree.childIds[editor.tree.rootId] ?? [];
|
|
105
|
+
|
|
106
|
+
return (
|
|
107
|
+
<div data-pyric-editor-fields>
|
|
108
|
+
{rootChildren.map((id) => (
|
|
109
|
+
<Field key={id} nodeId={id} path={fieldPath(editor, id)} />
|
|
110
|
+
))}
|
|
111
|
+
<AddMapEntry parentId={editor.tree.rootId} />
|
|
112
|
+
</div>
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function fieldPath(editor: UseDocumentEditorResult, nodeId: string): string {
|
|
117
|
+
const segments: string[] = [];
|
|
118
|
+
let current: FieldNode | undefined = editor.tree.nodes[nodeId];
|
|
119
|
+
while (current && current.parentId != null) {
|
|
120
|
+
const parent: FieldNode | undefined = editor.tree.nodes[current.parentId];
|
|
121
|
+
if (!parent) break;
|
|
122
|
+
if (parent.type === 'array') {
|
|
123
|
+
const idx = (editor.tree.childIds[parent.id] ?? []).indexOf(current.id);
|
|
124
|
+
segments.unshift(`[${idx}]`);
|
|
125
|
+
} else {
|
|
126
|
+
segments.unshift(current.key ?? '');
|
|
127
|
+
}
|
|
128
|
+
current = parent;
|
|
129
|
+
}
|
|
130
|
+
// Join — array index segments don't take a leading dot.
|
|
131
|
+
return segments.reduce((acc, seg) => {
|
|
132
|
+
if (seg.startsWith('[')) return `${acc}${seg}`;
|
|
133
|
+
return acc ? `${acc}.${seg}` : seg;
|
|
134
|
+
}, '');
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
interface FieldProps {
|
|
138
|
+
nodeId: string;
|
|
139
|
+
path: string;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function Field({ nodeId, path }: FieldProps) {
|
|
143
|
+
const { editor, fieldEditors } = useEditorContext();
|
|
144
|
+
const node = editor.tree.nodes[nodeId];
|
|
145
|
+
if (!node) return null;
|
|
146
|
+
|
|
147
|
+
if (node.type === 'map') {
|
|
148
|
+
return <MapField node={node} path={path} />;
|
|
149
|
+
}
|
|
150
|
+
if (node.type === 'array') {
|
|
151
|
+
return <ArrayField node={node} path={path} />;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const contract = fieldEditors[node.type];
|
|
155
|
+
const EditComponent = contract?.Edit;
|
|
156
|
+
if (!EditComponent) return null;
|
|
157
|
+
|
|
158
|
+
// Errors are computed on every keystroke (so Save can gate on
|
|
159
|
+
// `isValid`), but only DISPLAYED once the field has been touched
|
|
160
|
+
// (blurred) or a submit attempt swept the tree — otherwise a
|
|
161
|
+
// freshly-added empty row would show "required" before the user
|
|
162
|
+
// has typed anything.
|
|
163
|
+
const showError = node.touched ? node.error : undefined;
|
|
164
|
+
|
|
165
|
+
return (
|
|
166
|
+
<div
|
|
167
|
+
data-pyric-field-entry
|
|
168
|
+
data-field-name={node.key ?? undefined}
|
|
169
|
+
data-pyric-error={showError ? '' : undefined}
|
|
170
|
+
onBlur={() => editor.touch(node.id)}
|
|
171
|
+
>
|
|
172
|
+
<FieldChrome node={node} path={path} />
|
|
173
|
+
<EditComponent
|
|
174
|
+
value={node.value as never}
|
|
175
|
+
path={path}
|
|
176
|
+
error={showError}
|
|
177
|
+
onChange={(v: unknown) => editor.setValue(node.id, v)}
|
|
178
|
+
/>
|
|
179
|
+
</div>
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Per-field chrome: the field's key input (for map children), the
|
|
185
|
+
* type selector, and the remove button. Inline at every non-root
|
|
186
|
+
* node so consumers can hide individual pieces via `data-*` CSS.
|
|
187
|
+
*/
|
|
188
|
+
function FieldChrome({ node, path }: { node: FieldNode; path: string }) {
|
|
189
|
+
const { editor } = useEditorContext();
|
|
190
|
+
const parent = node.parentId != null ? editor.tree.nodes[node.parentId] : null;
|
|
191
|
+
const inMap = parent?.type === 'map';
|
|
192
|
+
const isRoot = node.id === editor.tree.rootId;
|
|
193
|
+
|
|
194
|
+
if (isRoot) return null;
|
|
195
|
+
|
|
196
|
+
return (
|
|
197
|
+
<div data-pyric-field-chrome data-pyric-field-path={path}>
|
|
198
|
+
{inMap ? (
|
|
199
|
+
<input
|
|
200
|
+
type="text"
|
|
201
|
+
value={node.key ?? ''}
|
|
202
|
+
onChange={(e) => editor.setKey(node.id, e.target.value)}
|
|
203
|
+
aria-label="Field name"
|
|
204
|
+
data-pyric-field-key-input
|
|
205
|
+
aria-invalid={node.touched && node.error ? 'true' : undefined}
|
|
206
|
+
/>
|
|
207
|
+
) : null}
|
|
208
|
+
<TypeSelect node={node} />
|
|
209
|
+
<button
|
|
210
|
+
type="button"
|
|
211
|
+
onClick={() => editor.remove(node.id)}
|
|
212
|
+
data-pyric-remove
|
|
213
|
+
aria-label="Remove field"
|
|
214
|
+
>
|
|
215
|
+
×
|
|
216
|
+
</button>
|
|
217
|
+
</div>
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const ALL_TYPES: FieldType[] = [
|
|
222
|
+
'string',
|
|
223
|
+
'number',
|
|
224
|
+
'boolean',
|
|
225
|
+
'null',
|
|
226
|
+
'timestamp',
|
|
227
|
+
'geopoint',
|
|
228
|
+
'reference',
|
|
229
|
+
'bytes',
|
|
230
|
+
'vector',
|
|
231
|
+
'map',
|
|
232
|
+
'array',
|
|
233
|
+
];
|
|
234
|
+
|
|
235
|
+
function TypeSelect({ node }: { node: FieldNode }) {
|
|
236
|
+
const { editor } = useEditorContext();
|
|
237
|
+
const parent = node.parentId != null ? editor.tree.nodes[node.parentId] : null;
|
|
238
|
+
// Firestore disallows nested arrays — filter that option out
|
|
239
|
+
// when the parent is an array.
|
|
240
|
+
const options = ALL_TYPES.filter((t) => !(parent?.type === 'array' && t === 'array'));
|
|
241
|
+
|
|
242
|
+
return (
|
|
243
|
+
<select
|
|
244
|
+
value={node.type}
|
|
245
|
+
onChange={(e) => editor.setType(node.id, e.target.value as FieldType)}
|
|
246
|
+
aria-label="Field type"
|
|
247
|
+
data-pyric-field-type-select
|
|
248
|
+
>
|
|
249
|
+
{options.map((t) => (
|
|
250
|
+
<option key={t} value={t}>
|
|
251
|
+
{t}
|
|
252
|
+
</option>
|
|
253
|
+
))}
|
|
254
|
+
</select>
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
interface ContainerFieldProps {
|
|
259
|
+
node: FieldNode;
|
|
260
|
+
path: string;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function MapField({ node, path }: ContainerFieldProps) {
|
|
264
|
+
const { editor } = useEditorContext();
|
|
265
|
+
// Stable insertion order — same rationale as `DocumentEditorFields`.
|
|
266
|
+
const children = editor.tree.childIds[node.id] ?? [];
|
|
267
|
+
|
|
268
|
+
return (
|
|
269
|
+
<div
|
|
270
|
+
data-pyric-field-entry
|
|
271
|
+
data-pyric-field-type="map"
|
|
272
|
+
data-field-name={node.key ?? undefined}
|
|
273
|
+
>
|
|
274
|
+
{node.id !== editor.tree.rootId ? <FieldChrome node={node} path={path} /> : null}
|
|
275
|
+
<div data-pyric-map-children>
|
|
276
|
+
{children.map((childId) => {
|
|
277
|
+
const child = editor.tree.nodes[childId];
|
|
278
|
+
if (!child) return null;
|
|
279
|
+
const childPath = path ? `${path}.${child.key ?? ''}` : child.key ?? '';
|
|
280
|
+
return <Field key={childId} nodeId={childId} path={childPath} />;
|
|
281
|
+
})}
|
|
282
|
+
</div>
|
|
283
|
+
<AddMapEntry parentId={node.id} />
|
|
284
|
+
</div>
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function ArrayField({ node, path }: ContainerFieldProps) {
|
|
289
|
+
const { editor } = useEditorContext();
|
|
290
|
+
const children = editor.tree.childIds[node.id] ?? [];
|
|
291
|
+
|
|
292
|
+
return (
|
|
293
|
+
<div
|
|
294
|
+
data-pyric-field-entry
|
|
295
|
+
data-pyric-field-type="array"
|
|
296
|
+
data-field-name={node.key ?? undefined}
|
|
297
|
+
>
|
|
298
|
+
<FieldChrome node={node} path={path} />
|
|
299
|
+
<ol data-pyric-array-children>
|
|
300
|
+
{children.map((childId, idx) => {
|
|
301
|
+
const childPath = path ? `${path}[${idx}]` : `[${idx}]`;
|
|
302
|
+
return (
|
|
303
|
+
<li key={childId} data-field-index={String(idx)}>
|
|
304
|
+
<Field nodeId={childId} path={childPath} />
|
|
305
|
+
</li>
|
|
306
|
+
);
|
|
307
|
+
})}
|
|
308
|
+
</ol>
|
|
309
|
+
<AddArrayEntry parentId={node.id} />
|
|
310
|
+
</div>
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function AddMapEntry({ parentId }: { parentId: string }) {
|
|
315
|
+
const { editor } = useEditorContext();
|
|
316
|
+
return (
|
|
317
|
+
<button
|
|
318
|
+
type="button"
|
|
319
|
+
data-pyric-add-map-entry
|
|
320
|
+
onClick={() => editor.addMapEntry(parentId, '', 'string')}
|
|
321
|
+
>
|
|
322
|
+
+ Add field
|
|
323
|
+
</button>
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function AddArrayEntry({ parentId }: { parentId: string }) {
|
|
328
|
+
const { editor } = useEditorContext();
|
|
329
|
+
return (
|
|
330
|
+
<button
|
|
331
|
+
type="button"
|
|
332
|
+
data-pyric-add-array-entry
|
|
333
|
+
onClick={() => editor.addArrayEntry(parentId, 'string')}
|
|
334
|
+
>
|
|
335
|
+
+ Add item
|
|
336
|
+
</button>
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Compound root export. Consumers pattern-match via dot access:
|
|
342
|
+
*
|
|
343
|
+
* <DocumentEditor.Root initial={data} onChange={…}>
|
|
344
|
+
* <DocumentEditor.Fields />
|
|
345
|
+
* </DocumentEditor.Root>
|
|
346
|
+
*/
|
|
347
|
+
export const DocumentEditor = {
|
|
348
|
+
Root: DocumentEditorRoot,
|
|
349
|
+
Fields: DocumentEditorFields,
|
|
350
|
+
};
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { useMemo, type ReactNode } from 'react';
|
|
2
|
+
import type { DocumentReference, QueryDocumentSnapshot } from 'pyric/firestore';
|
|
3
|
+
import { VirtualList } from '../../primitives/VirtualList.js';
|
|
4
|
+
import { useUpdateHighlights } from '../../primitives/hooks/useUpdateHighlights.js';
|
|
5
|
+
import { firestoreValuesEqual } from '../valueEquality.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Both backends carry `.ref` on snapshots at runtime, but the
|
|
9
|
+
* `pyric/firestore` modular interface omits it. Pull it through
|
|
10
|
+
* a structural cast — safe because the property is guaranteed by
|
|
11
|
+
* the underlying SDKs.
|
|
12
|
+
*/
|
|
13
|
+
function snapshotRef(snap: QueryDocumentSnapshot): DocumentReference {
|
|
14
|
+
return (snap as unknown as { ref: DocumentReference }).ref;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface DocumentListProps {
|
|
18
|
+
documents: QueryDocumentSnapshot[];
|
|
19
|
+
isLoading?: boolean;
|
|
20
|
+
error?: Error;
|
|
21
|
+
hasMore?: boolean;
|
|
22
|
+
onSelect?: (ref: DocumentReference) => void;
|
|
23
|
+
/** Fired when the user requests another page. Wire to the hook's
|
|
24
|
+
* `loadMore`. The component will not render a Load More button
|
|
25
|
+
* when this is undefined. */
|
|
26
|
+
onLoadMore?: () => void;
|
|
27
|
+
/**
|
|
28
|
+
* Optional renderer for the row label. Default renders the doc id.
|
|
29
|
+
* Override to show a field value alongside, an icon, etc.
|
|
30
|
+
*/
|
|
31
|
+
renderLabel?: (doc: QueryDocumentSnapshot) => ReactNode;
|
|
32
|
+
/**
|
|
33
|
+
* Optional per-row action(s), rendered as a SIBLING of the row's
|
|
34
|
+
* select button inside the entry (not nested in it — so the action
|
|
35
|
+
* carries its own click handling without an invalid button-in-button).
|
|
36
|
+
* Used for row-level affordances like delete.
|
|
37
|
+
*/
|
|
38
|
+
renderRowAction?: (doc: QueryDocumentSnapshot) => ReactNode;
|
|
39
|
+
emptyState?: ReactNode;
|
|
40
|
+
className?: string;
|
|
41
|
+
/** Stable collection/query identity used to reset update highlighting when
|
|
42
|
+
* the rendered list changes scope. */
|
|
43
|
+
updateScope?: string;
|
|
44
|
+
/**
|
|
45
|
+
* Above this row count, the list switches to virtualization via
|
|
46
|
+
* `<VirtualList>`. Default 100. Set to `Infinity` to disable
|
|
47
|
+
* (e.g. when measuring layout shifts is more important than
|
|
48
|
+
* scroll perf).
|
|
49
|
+
*/
|
|
50
|
+
virtualizeThreshold?: number;
|
|
51
|
+
/**
|
|
52
|
+
* Estimated row height when virtualizing. Default 36 — matches
|
|
53
|
+
* a single-line text button at 13px font + ~10px padding. Pass
|
|
54
|
+
* a function for variable sizing (TanStack measures the actual
|
|
55
|
+
* height via ResizeObserver after the first paint anyway).
|
|
56
|
+
*/
|
|
57
|
+
rowHeight?: number | ((index: number) => number);
|
|
58
|
+
/**
|
|
59
|
+
* Pixel height the virtualized scroll container fills. Only
|
|
60
|
+
* applies when `documents.length > virtualizeThreshold`. Default
|
|
61
|
+
* `'60vh'` — consumers usually constrain via their layout.
|
|
62
|
+
*/
|
|
63
|
+
virtualizedHeight?: number | string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Headless document list. Below `virtualizeThreshold`, renders a
|
|
68
|
+
* plain `<ul>`; above it, switches to TanStack-Virtual via
|
|
69
|
+
* `<VirtualList>` so 10k-doc collections don't bloat the DOM.
|
|
70
|
+
*
|
|
71
|
+
* The hook owns pagination state; this component just renders. The
|
|
72
|
+
* Load More button only renders when `hasMore` is true AND
|
|
73
|
+
* `onLoadMore` is provided. Consumers wanting infinite scroll
|
|
74
|
+
* trigger `onLoadMore` from a sentinel `IntersectionObserver` in
|
|
75
|
+
* their own code — the component doesn't bake that policy in.
|
|
76
|
+
*/
|
|
77
|
+
export function DocumentList({
|
|
78
|
+
documents,
|
|
79
|
+
isLoading,
|
|
80
|
+
error,
|
|
81
|
+
hasMore,
|
|
82
|
+
onSelect,
|
|
83
|
+
onLoadMore,
|
|
84
|
+
renderLabel,
|
|
85
|
+
renderRowAction,
|
|
86
|
+
emptyState,
|
|
87
|
+
className,
|
|
88
|
+
updateScope = 'document-list',
|
|
89
|
+
virtualizeThreshold = 100,
|
|
90
|
+
rowHeight = 36,
|
|
91
|
+
virtualizedHeight = '60vh',
|
|
92
|
+
}: DocumentListProps) {
|
|
93
|
+
const updateEntries = useMemo(
|
|
94
|
+
() =>
|
|
95
|
+
new Map(
|
|
96
|
+
documents.map((snapshot) => [snapshotRef(snapshot).path, snapshot.data()] as const),
|
|
97
|
+
),
|
|
98
|
+
[documents],
|
|
99
|
+
);
|
|
100
|
+
const updates = useUpdateHighlights({
|
|
101
|
+
scope: updateScope,
|
|
102
|
+
entries: updateEntries,
|
|
103
|
+
equals: firestoreValuesEqual,
|
|
104
|
+
ready: !isLoading,
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
if (error) {
|
|
108
|
+
return (
|
|
109
|
+
<div
|
|
110
|
+
className={className}
|
|
111
|
+
data-pyric-ui="document-list"
|
|
112
|
+
data-pyric-error=""
|
|
113
|
+
role="alert"
|
|
114
|
+
>
|
|
115
|
+
{error.message}
|
|
116
|
+
</div>
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
if (isLoading && documents.length === 0) {
|
|
120
|
+
return (
|
|
121
|
+
<div
|
|
122
|
+
className={className}
|
|
123
|
+
data-pyric-ui="document-list"
|
|
124
|
+
data-pyric-loading=""
|
|
125
|
+
/>
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
if (documents.length === 0) {
|
|
129
|
+
return (
|
|
130
|
+
<div className={className} data-pyric-ui="document-list" data-pyric-empty="">
|
|
131
|
+
{emptyState}
|
|
132
|
+
</div>
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const virtualized = documents.length > virtualizeThreshold;
|
|
137
|
+
|
|
138
|
+
return (
|
|
139
|
+
<div
|
|
140
|
+
className={className}
|
|
141
|
+
data-pyric-ui="document-list"
|
|
142
|
+
data-pyric-virtualized={virtualized ? '' : undefined}
|
|
143
|
+
>
|
|
144
|
+
{virtualized ? (
|
|
145
|
+
<VirtualList
|
|
146
|
+
items={documents}
|
|
147
|
+
estimateSize={rowHeight}
|
|
148
|
+
height={virtualizedHeight}
|
|
149
|
+
getItemKey={(doc) => snapshotRef(doc).path}
|
|
150
|
+
renderItem={(doc) => {
|
|
151
|
+
const update = updates.get(snapshotRef(doc).path);
|
|
152
|
+
return (
|
|
153
|
+
<div
|
|
154
|
+
data-pyric-document-entry
|
|
155
|
+
data-pyric-document-id={doc.id}
|
|
156
|
+
data-pyric-update={update?.kind}
|
|
157
|
+
data-pyric-update-cycle={update?.cycle}
|
|
158
|
+
>
|
|
159
|
+
<button
|
|
160
|
+
type="button"
|
|
161
|
+
onClick={() => onSelect?.(snapshotRef(doc))}
|
|
162
|
+
data-pyric-document-select
|
|
163
|
+
data-pyric-update={update?.kind}
|
|
164
|
+
data-pyric-update-cycle={update?.cycle}
|
|
165
|
+
>
|
|
166
|
+
{renderLabel ? renderLabel(doc) : doc.id}
|
|
167
|
+
</button>
|
|
168
|
+
{renderRowAction ? (
|
|
169
|
+
<span data-pyric-document-action>{renderRowAction(doc)}</span>
|
|
170
|
+
) : null}
|
|
171
|
+
</div>
|
|
172
|
+
);
|
|
173
|
+
}}
|
|
174
|
+
/>
|
|
175
|
+
) : (
|
|
176
|
+
<ul data-pyric-document-list-items>
|
|
177
|
+
{documents.map((doc) => {
|
|
178
|
+
const ref = snapshotRef(doc);
|
|
179
|
+
const update = updates.get(ref.path);
|
|
180
|
+
return (
|
|
181
|
+
<li
|
|
182
|
+
key={ref.path}
|
|
183
|
+
data-pyric-document-entry
|
|
184
|
+
data-pyric-document-id={doc.id}
|
|
185
|
+
data-pyric-update={update?.kind}
|
|
186
|
+
data-pyric-update-cycle={update?.cycle}
|
|
187
|
+
>
|
|
188
|
+
<button
|
|
189
|
+
type="button"
|
|
190
|
+
onClick={() => onSelect?.(ref)}
|
|
191
|
+
data-pyric-document-select
|
|
192
|
+
data-pyric-update={update?.kind}
|
|
193
|
+
data-pyric-update-cycle={update?.cycle}
|
|
194
|
+
>
|
|
195
|
+
{renderLabel ? renderLabel(doc) : doc.id}
|
|
196
|
+
</button>
|
|
197
|
+
{renderRowAction ? (
|
|
198
|
+
<span data-pyric-document-action>{renderRowAction(doc)}</span>
|
|
199
|
+
) : null}
|
|
200
|
+
</li>
|
|
201
|
+
);
|
|
202
|
+
})}
|
|
203
|
+
</ul>
|
|
204
|
+
)}
|
|
205
|
+
{hasMore && onLoadMore ? (
|
|
206
|
+
<button
|
|
207
|
+
type="button"
|
|
208
|
+
onClick={onLoadMore}
|
|
209
|
+
data-pyric-load-more
|
|
210
|
+
disabled={isLoading}
|
|
211
|
+
>
|
|
212
|
+
Load more
|
|
213
|
+
</button>
|
|
214
|
+
) : null}
|
|
215
|
+
</div>
|
|
216
|
+
);
|
|
217
|
+
}
|