@pyric/ui 0.1.0-alpha.11 → 0.1.0-alpha.12
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,265 @@
|
|
|
1
|
+
import { useMemo, useState, type ReactNode } from 'react';
|
|
2
|
+
import type {
|
|
3
|
+
CollectionReference,
|
|
4
|
+
DocumentReference,
|
|
5
|
+
DocumentSnapshot,
|
|
6
|
+
Firestore,
|
|
7
|
+
} from 'pyric/firestore';
|
|
8
|
+
import { FieldRenderer } from './FieldRenderer.js';
|
|
9
|
+
import { inferType } from '../types.js';
|
|
10
|
+
import { mergeFieldEditors } from '../fieldEditors/registry.js';
|
|
11
|
+
import type { FieldEditorRegistry } from '../fieldEditors/types.js';
|
|
12
|
+
import { DisplayContextProvider, type DisplayContextValue } from './context.js';
|
|
13
|
+
import { useContainerSize } from '../../primitives/hooks/useContainerSize.js';
|
|
14
|
+
import {
|
|
15
|
+
useDocumentSubcollections,
|
|
16
|
+
type ListSubcollections,
|
|
17
|
+
} from '../hooks/useDocumentSubcollections.js';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* `DocumentSnapshot.exists` is a method on the firebase/firestore
|
|
21
|
+
* `QueryDocumentSnapshot` (`.exists()`) and a getter/property on the
|
|
22
|
+
* Admin chainable adapter. `pyric/firestore` unions the two; this
|
|
23
|
+
* helper handles either.
|
|
24
|
+
*/
|
|
25
|
+
function snapshotExists(snapshot: DocumentSnapshot): boolean {
|
|
26
|
+
const e = (snapshot as { exists: boolean | (() => boolean) }).exists;
|
|
27
|
+
return typeof e === 'function' ? e.call(snapshot) : e;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface DocumentPreviewProps {
|
|
31
|
+
/** Snapshot from `useFirestoreDoc` or `getDoc`. When `null` /
|
|
32
|
+
* `undefined`, renders {@link emptyState}. */
|
|
33
|
+
snapshot: DocumentSnapshot | null | undefined;
|
|
34
|
+
/** Override or extend the built-in field editors. Merged on top
|
|
35
|
+
* of {@link defaultFieldEditors} — only the keys you provide
|
|
36
|
+
* override. */
|
|
37
|
+
fieldEditors?: FieldEditorRegistry;
|
|
38
|
+
/** Content rendered when the snapshot is missing or
|
|
39
|
+
* `!exists()`. Defaults to `null` (renders nothing). */
|
|
40
|
+
emptyState?: ReactNode;
|
|
41
|
+
/** Forwarded to the root `<div>`. */
|
|
42
|
+
className?: string;
|
|
43
|
+
/**
|
|
44
|
+
* Fired when a reference field is clicked. When supplied, the
|
|
45
|
+
* reference Display renders as an interactive `<button>` (with
|
|
46
|
+
* `data-pyric-clickable`); when omitted, it stays inert as a
|
|
47
|
+
* `<span>`. Consumers wire navigation here — the library does
|
|
48
|
+
* not depend on a router.
|
|
49
|
+
*/
|
|
50
|
+
onReferenceClick?: (ref: DocumentReference) => void;
|
|
51
|
+
/**
|
|
52
|
+
* The document's own reference. Required to surface its
|
|
53
|
+
* subcollections — `DocumentSnapshot` (as typed by `pyric/firestore`)
|
|
54
|
+
* doesn't expose `.ref`, so the consumer threads the ref it already
|
|
55
|
+
* holds from fetching the doc. Without it (or without
|
|
56
|
+
* {@link listSubcollections}) the Subcollections section is omitted.
|
|
57
|
+
*/
|
|
58
|
+
documentRef?: DocumentReference | null;
|
|
59
|
+
/**
|
|
60
|
+
* Firestore handle passed to {@link listSubcollections}. Required
|
|
61
|
+
* alongside `documentRef` + `listSubcollections` to surface the
|
|
62
|
+
* Subcollections section (the same explicit-handle shape
|
|
63
|
+
* `ReferencePicker` uses).
|
|
64
|
+
*/
|
|
65
|
+
firestore?: Firestore;
|
|
66
|
+
/**
|
|
67
|
+
* Injected lister for the document's subcollections. The modular Web
|
|
68
|
+
* SDK has no client-side `listCollections`; sandbox-backed apps wire
|
|
69
|
+
* `pyric/sandbox`'s in-process listing, production apps pass a known
|
|
70
|
+
* list or a server proxy. Same shape `ReferencePicker` /
|
|
71
|
+
* `useCollectionList` use. Omit to hide the Subcollections section.
|
|
72
|
+
*/
|
|
73
|
+
listSubcollections?: ListSubcollections;
|
|
74
|
+
/**
|
|
75
|
+
* Fired when a subcollection's drill affordance is activated. Receives
|
|
76
|
+
* the subcollection's `CollectionReference` (its `.path` is the
|
|
77
|
+
* navigate target). Consumers wire navigation here.
|
|
78
|
+
*/
|
|
79
|
+
onSubcollectionClick?: (collection: CollectionReference) => void;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Read-only renderer for a Firestore document. Iterates top-level
|
|
84
|
+
* fields in lexicographic order; each field dispatches through the
|
|
85
|
+
* field-editor registry on its inferred type.
|
|
86
|
+
*
|
|
87
|
+
* Headless — no shipped CSS. Consumers style via `className` on the
|
|
88
|
+
* root and `[data-pyric-field-type]` / `[data-pyric-field-path]`
|
|
89
|
+
* attribute selectors on the per-field nodes.
|
|
90
|
+
*
|
|
91
|
+
* Editing arrives in M3 (`<DocumentEditor>`). M2 only displays.
|
|
92
|
+
*/
|
|
93
|
+
export function DocumentPreview({
|
|
94
|
+
snapshot,
|
|
95
|
+
fieldEditors,
|
|
96
|
+
emptyState = null,
|
|
97
|
+
className,
|
|
98
|
+
onReferenceClick,
|
|
99
|
+
documentRef,
|
|
100
|
+
firestore,
|
|
101
|
+
listSubcollections,
|
|
102
|
+
onSubcollectionClick,
|
|
103
|
+
}: DocumentPreviewProps) {
|
|
104
|
+
const displayCtx = useMemo<DisplayContextValue>(
|
|
105
|
+
() => ({ onReferenceClick }),
|
|
106
|
+
[onReferenceClick],
|
|
107
|
+
);
|
|
108
|
+
const { ref: rootRef, size } = useContainerSize<HTMLDivElement>();
|
|
109
|
+
|
|
110
|
+
if (!snapshot || !snapshotExists(snapshot)) {
|
|
111
|
+
return <>{emptyState}</>;
|
|
112
|
+
}
|
|
113
|
+
const data = snapshot.data() ?? {};
|
|
114
|
+
const entries = Object.entries(data).sort(([a], [b]) => a.localeCompare(b));
|
|
115
|
+
const registry = mergeFieldEditors(fieldEditors);
|
|
116
|
+
|
|
117
|
+
return (
|
|
118
|
+
<DisplayContextProvider value={displayCtx}>
|
|
119
|
+
<div
|
|
120
|
+
ref={rootRef}
|
|
121
|
+
className={className}
|
|
122
|
+
data-pyric-ui="document-preview"
|
|
123
|
+
data-doc-id={snapshot.id}
|
|
124
|
+
data-size={size}
|
|
125
|
+
>
|
|
126
|
+
{entries.map(([key, value]) => (
|
|
127
|
+
<FieldEntry key={key} fieldKey={key} value={value} registry={registry} />
|
|
128
|
+
))}
|
|
129
|
+
{firestore && documentRef && listSubcollections ? (
|
|
130
|
+
<SubcollectionsSection
|
|
131
|
+
firestore={firestore}
|
|
132
|
+
documentRef={documentRef}
|
|
133
|
+
listSubcollections={listSubcollections}
|
|
134
|
+
onSubcollectionClick={onSubcollectionClick}
|
|
135
|
+
/>
|
|
136
|
+
) : null}
|
|
137
|
+
</div>
|
|
138
|
+
</DisplayContextProvider>
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* One top-level document field. A scalar renders `key value` in the field grid;
|
|
144
|
+
* a map/array renders a disclosure (chevron + key) that collapses its value, so
|
|
145
|
+
* top-level containers toggle just like the nested tree does. A chevron gutter
|
|
146
|
+
* sits on scalars too (empty) so every key aligns.
|
|
147
|
+
*/
|
|
148
|
+
function FieldEntry({
|
|
149
|
+
fieldKey,
|
|
150
|
+
value,
|
|
151
|
+
registry,
|
|
152
|
+
}: {
|
|
153
|
+
fieldKey: string;
|
|
154
|
+
value: unknown;
|
|
155
|
+
registry: FieldEditorRegistry;
|
|
156
|
+
}) {
|
|
157
|
+
const type = inferType(value);
|
|
158
|
+
const nested = type === 'map' || type === 'array';
|
|
159
|
+
const [expanded, setExpanded] = useState(true);
|
|
160
|
+
|
|
161
|
+
if (!nested) {
|
|
162
|
+
return (
|
|
163
|
+
<div data-pyric-field-entry data-field-name={fieldKey}>
|
|
164
|
+
<span data-pyric-field-keycell>
|
|
165
|
+
<span data-pyric-tree-chevron data-pyric-tree-chevron-empty aria-hidden="true" />
|
|
166
|
+
<span data-pyric-field-key>{fieldKey}</span>
|
|
167
|
+
</span>
|
|
168
|
+
<FieldRenderer value={value} path={fieldKey} fieldEditors={registry} />
|
|
169
|
+
</div>
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return (
|
|
174
|
+
<div data-pyric-field-entry data-field-name={fieldKey} data-pyric-field-nested>
|
|
175
|
+
<button
|
|
176
|
+
type="button"
|
|
177
|
+
data-pyric-field-keycell
|
|
178
|
+
data-pyric-field-toggle
|
|
179
|
+
aria-expanded={expanded}
|
|
180
|
+
onClick={() => setExpanded((e) => !e)}
|
|
181
|
+
>
|
|
182
|
+
<span data-pyric-tree-chevron aria-hidden="true">
|
|
183
|
+
›
|
|
184
|
+
</span>
|
|
185
|
+
<span data-pyric-field-key>{fieldKey}</span>
|
|
186
|
+
</button>
|
|
187
|
+
{expanded ? <FieldRenderer value={value} path={fieldKey} fieldEditors={registry} /> : null}
|
|
188
|
+
</div>
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
interface SubcollectionsSectionProps {
|
|
193
|
+
firestore: Firestore;
|
|
194
|
+
documentRef: DocumentReference;
|
|
195
|
+
listSubcollections: ListSubcollections;
|
|
196
|
+
onSubcollectionClick?: (collection: CollectionReference) => void;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* The "Subcollections" section under a document's fields. Lists the
|
|
201
|
+
* document's OWN subcollections (name + a drill affordance), mirroring
|
|
202
|
+
* the `c-data` mock. Reuses the injected-lister pattern; emits the
|
|
203
|
+
* subcollection ref through {@link onSubcollectionClick} for navigation.
|
|
204
|
+
*
|
|
205
|
+
* Renders nothing once loaded if the document has no subcollections —
|
|
206
|
+
* an empty section is noise. While loading it carries
|
|
207
|
+
* `data-pyric-loading` so consumers can show a placeholder.
|
|
208
|
+
*/
|
|
209
|
+
function SubcollectionsSection({
|
|
210
|
+
firestore,
|
|
211
|
+
documentRef,
|
|
212
|
+
listSubcollections,
|
|
213
|
+
onSubcollectionClick,
|
|
214
|
+
}: SubcollectionsSectionProps) {
|
|
215
|
+
const { subcollections, isLoading } = useDocumentSubcollections({
|
|
216
|
+
firestore,
|
|
217
|
+
documentRef,
|
|
218
|
+
listSubcollections,
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
if (!isLoading && subcollections.length === 0) return null;
|
|
222
|
+
|
|
223
|
+
return (
|
|
224
|
+
<section
|
|
225
|
+
data-pyric-ui="subcollections"
|
|
226
|
+
data-pyric-loading={isLoading ? '' : undefined}
|
|
227
|
+
>
|
|
228
|
+
<div data-pyric-subcollections-group>Subcollections</div>
|
|
229
|
+
<ul data-pyric-subcollections-list>
|
|
230
|
+
{subcollections.map((coll) => (
|
|
231
|
+
<li
|
|
232
|
+
key={coll.path}
|
|
233
|
+
data-pyric-subcollection
|
|
234
|
+
data-pyric-collection-id={coll.id}
|
|
235
|
+
data-pyric-collection-path={coll.path}
|
|
236
|
+
>
|
|
237
|
+
{onSubcollectionClick ? (
|
|
238
|
+
<button
|
|
239
|
+
type="button"
|
|
240
|
+
data-pyric-subcollection-row
|
|
241
|
+
data-pyric-clickable
|
|
242
|
+
onClick={() => onSubcollectionClick(coll)}
|
|
243
|
+
aria-label={`Open subcollection ${coll.id}`}
|
|
244
|
+
>
|
|
245
|
+
<span data-pyric-subcollection-key>{coll.id}</span>
|
|
246
|
+
<span data-pyric-subcollection-type>subcollection</span>
|
|
247
|
+
<span data-pyric-subcollection-drill aria-hidden="true">
|
|
248
|
+
›
|
|
249
|
+
</span>
|
|
250
|
+
</button>
|
|
251
|
+
) : (
|
|
252
|
+
<div data-pyric-subcollection-row>
|
|
253
|
+
<span data-pyric-subcollection-key>{coll.id}</span>
|
|
254
|
+
<span data-pyric-subcollection-type>subcollection</span>
|
|
255
|
+
<span data-pyric-subcollection-drill aria-hidden="true">
|
|
256
|
+
›
|
|
257
|
+
</span>
|
|
258
|
+
</div>
|
|
259
|
+
)}
|
|
260
|
+
</li>
|
|
261
|
+
))}
|
|
262
|
+
</ul>
|
|
263
|
+
</section>
|
|
264
|
+
);
|
|
265
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { inferType } from '../types.js';
|
|
2
|
+
import type { FieldEditorRegistry } from '../fieldEditors/types.js';
|
|
3
|
+
|
|
4
|
+
export interface FieldRendererProps {
|
|
5
|
+
value: unknown;
|
|
6
|
+
path?: string;
|
|
7
|
+
/** Registry to dispatch through. Pass the merged registry — this
|
|
8
|
+
* component does not fall back to defaults on its own (to avoid
|
|
9
|
+
* a circular import with the editors). `<DocumentPreview>` is
|
|
10
|
+
* the entry point that merges user overrides into defaults. */
|
|
11
|
+
fieldEditors: FieldEditorRegistry;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Dispatches a single value to its registered display component.
|
|
16
|
+
* Recursive editors (Map, Array) re-enter through this component
|
|
17
|
+
* for their children, threading the same registry.
|
|
18
|
+
*/
|
|
19
|
+
export function FieldRenderer({ value, path, fieldEditors }: FieldRendererProps) {
|
|
20
|
+
const type = inferType(value);
|
|
21
|
+
const contract = fieldEditors[type];
|
|
22
|
+
if (!contract) return null;
|
|
23
|
+
const { Display } = contract;
|
|
24
|
+
return <Display value={value} path={path} fieldEditors={fieldEditors} />;
|
|
25
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import {
|
|
2
|
+
useQueryBuilder,
|
|
3
|
+
QUERY_OPS,
|
|
4
|
+
MULTI_VALUE_OPS,
|
|
5
|
+
type QueryOp,
|
|
6
|
+
type UseQueryBuilderOptions,
|
|
7
|
+
type UseQueryBuilderResult,
|
|
8
|
+
} from '../hooks/useQueryBuilder.js';
|
|
9
|
+
|
|
10
|
+
export interface QueryBuilderProps {
|
|
11
|
+
/** Drives the hook used internally. Both the state and the
|
|
12
|
+
* composed Query are exposed via `onChange`. */
|
|
13
|
+
initial?: UseQueryBuilderOptions['initial'];
|
|
14
|
+
/** Fired on every state change with the latest builder API. The
|
|
15
|
+
* parent typically calls `builder.buildQuery(collection)` and
|
|
16
|
+
* feeds the result into `useDocumentList` / `useFirestoreCollection`. */
|
|
17
|
+
onChange?: (builder: UseQueryBuilderResult) => void;
|
|
18
|
+
className?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Default visible composition over `useQueryBuilder`. Renders the
|
|
23
|
+
* condition list + orderBy + limit form. Headless — every node
|
|
24
|
+
* carries `data-pyric-*` for styling.
|
|
25
|
+
*
|
|
26
|
+
* Values are JSON-parsed on input. `42`, `"text"`, `true`, `null`,
|
|
27
|
+
* and `[1, 2, 3]` (for `in`/`not-in`/`array-contains-any`) all
|
|
28
|
+
* work; raw strings that aren't JSON-parsable fall through as-is.
|
|
29
|
+
* For non-JSON Firestore values (Timestamp, GeoPoint, Reference,
|
|
30
|
+
* Bytes), consumers either use the hook directly with their own
|
|
31
|
+
* value editors or swap the rendered component out.
|
|
32
|
+
*/
|
|
33
|
+
export function QueryBuilder({ initial, onChange, className }: QueryBuilderProps) {
|
|
34
|
+
const builder = useQueryBuilder({ initial });
|
|
35
|
+
|
|
36
|
+
// Pipe state changes back to the consumer. We don't memoize on
|
|
37
|
+
// the builder object itself since the hook already memoizes
|
|
38
|
+
// internally; calling onChange every render is cheap and matches
|
|
39
|
+
// the `<DocumentEditor>` pattern.
|
|
40
|
+
if (onChange) onChange(builder);
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<div className={className} data-pyric-ui="query-builder">
|
|
44
|
+
<ul data-pyric-query-conditions>
|
|
45
|
+
{builder.conditions.map((cond) => (
|
|
46
|
+
<li key={cond.id} data-pyric-query-condition>
|
|
47
|
+
<input
|
|
48
|
+
type="text"
|
|
49
|
+
value={cond.field}
|
|
50
|
+
placeholder="field"
|
|
51
|
+
onChange={(e) =>
|
|
52
|
+
builder.updateCondition(cond.id, { field: e.target.value })
|
|
53
|
+
}
|
|
54
|
+
data-pyric-query-field
|
|
55
|
+
aria-label="Field"
|
|
56
|
+
/>
|
|
57
|
+
<select
|
|
58
|
+
value={cond.op}
|
|
59
|
+
onChange={(e) =>
|
|
60
|
+
builder.updateCondition(cond.id, { op: e.target.value as QueryOp })
|
|
61
|
+
}
|
|
62
|
+
data-pyric-query-op
|
|
63
|
+
aria-label="Operator"
|
|
64
|
+
>
|
|
65
|
+
{QUERY_OPS.map((op) => (
|
|
66
|
+
<option key={op} value={op}>
|
|
67
|
+
{op}
|
|
68
|
+
</option>
|
|
69
|
+
))}
|
|
70
|
+
</select>
|
|
71
|
+
<input
|
|
72
|
+
type="text"
|
|
73
|
+
value={stringifyValue(cond.value)}
|
|
74
|
+
placeholder={MULTI_VALUE_OPS.has(cond.op) ? '[1, 2]' : 'value'}
|
|
75
|
+
onChange={(e) =>
|
|
76
|
+
builder.updateCondition(cond.id, { value: parseValue(e.target.value) })
|
|
77
|
+
}
|
|
78
|
+
data-pyric-query-value
|
|
79
|
+
aria-label="Value"
|
|
80
|
+
/>
|
|
81
|
+
<button
|
|
82
|
+
type="button"
|
|
83
|
+
onClick={() => builder.removeCondition(cond.id)}
|
|
84
|
+
data-pyric-query-remove
|
|
85
|
+
aria-label="Remove condition"
|
|
86
|
+
>
|
|
87
|
+
×
|
|
88
|
+
</button>
|
|
89
|
+
</li>
|
|
90
|
+
))}
|
|
91
|
+
</ul>
|
|
92
|
+
<button
|
|
93
|
+
type="button"
|
|
94
|
+
onClick={() => builder.addCondition()}
|
|
95
|
+
data-pyric-query-add-condition
|
|
96
|
+
>
|
|
97
|
+
+ Add condition
|
|
98
|
+
</button>
|
|
99
|
+
|
|
100
|
+
<div data-pyric-query-modifiers>
|
|
101
|
+
<label data-pyric-query-orderby>
|
|
102
|
+
<span>orderBy</span>
|
|
103
|
+
<input
|
|
104
|
+
type="text"
|
|
105
|
+
value={builder.orderBy?.field ?? ''}
|
|
106
|
+
placeholder="field"
|
|
107
|
+
onChange={(e) => {
|
|
108
|
+
const field = e.target.value;
|
|
109
|
+
if (!field) {
|
|
110
|
+
builder.setOrderBy(undefined);
|
|
111
|
+
} else {
|
|
112
|
+
builder.setOrderBy({
|
|
113
|
+
field,
|
|
114
|
+
direction: builder.orderBy?.direction ?? 'asc',
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
}}
|
|
118
|
+
data-pyric-query-orderby-field
|
|
119
|
+
/>
|
|
120
|
+
<select
|
|
121
|
+
value={builder.orderBy?.direction ?? 'asc'}
|
|
122
|
+
onChange={(e) => {
|
|
123
|
+
if (!builder.orderBy?.field) return;
|
|
124
|
+
builder.setOrderBy({
|
|
125
|
+
field: builder.orderBy.field,
|
|
126
|
+
direction: e.target.value as 'asc' | 'desc',
|
|
127
|
+
});
|
|
128
|
+
}}
|
|
129
|
+
disabled={!builder.orderBy?.field}
|
|
130
|
+
data-pyric-query-orderby-direction
|
|
131
|
+
>
|
|
132
|
+
<option value="asc">asc</option>
|
|
133
|
+
<option value="desc">desc</option>
|
|
134
|
+
</select>
|
|
135
|
+
</label>
|
|
136
|
+
<label data-pyric-query-limit>
|
|
137
|
+
<span>limit</span>
|
|
138
|
+
<input
|
|
139
|
+
type="number"
|
|
140
|
+
min={0}
|
|
141
|
+
value={builder.limit ?? ''}
|
|
142
|
+
placeholder="∞"
|
|
143
|
+
onChange={(e) => {
|
|
144
|
+
const v = e.target.value;
|
|
145
|
+
if (!v) {
|
|
146
|
+
builder.setLimit(undefined);
|
|
147
|
+
} else {
|
|
148
|
+
const n = parseInt(v, 10);
|
|
149
|
+
builder.setLimit(Number.isFinite(n) ? n : undefined);
|
|
150
|
+
}
|
|
151
|
+
}}
|
|
152
|
+
data-pyric-query-limit-input
|
|
153
|
+
/>
|
|
154
|
+
</label>
|
|
155
|
+
</div>
|
|
156
|
+
</div>
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function parseValue(raw: string): unknown {
|
|
161
|
+
if (raw === '') return '';
|
|
162
|
+
try {
|
|
163
|
+
return JSON.parse(raw);
|
|
164
|
+
} catch {
|
|
165
|
+
// Not JSON — return the raw string. Firestore `where` will
|
|
166
|
+
// refuse mistyped values at query time; the builder doesn't
|
|
167
|
+
// gate on type because some workflows (e.g. searching by a
|
|
168
|
+
// numeric-string id) are deliberate.
|
|
169
|
+
return raw;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function stringifyValue(v: unknown): string {
|
|
174
|
+
if (typeof v === 'string') return v;
|
|
175
|
+
if (v === undefined) return '';
|
|
176
|
+
try {
|
|
177
|
+
return JSON.stringify(v);
|
|
178
|
+
} catch {
|
|
179
|
+
return String(v);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import type {
|
|
3
|
+
CollectionReference,
|
|
4
|
+
DocumentReference,
|
|
5
|
+
Firestore,
|
|
6
|
+
} from 'pyric/firestore';
|
|
7
|
+
import {
|
|
8
|
+
useReferencePicker,
|
|
9
|
+
type BrowseLocation,
|
|
10
|
+
} from '../hooks/useReferencePicker.js';
|
|
11
|
+
|
|
12
|
+
export interface ReferencePickerProps {
|
|
13
|
+
firestore: Firestore;
|
|
14
|
+
/** Initial path text. */
|
|
15
|
+
initialPath?: string;
|
|
16
|
+
/**
|
|
17
|
+
* Lister for subcollections. Required — see
|
|
18
|
+
* `useReferencePicker` docs for the rationale.
|
|
19
|
+
*/
|
|
20
|
+
listCollections: (
|
|
21
|
+
firestore: Firestore,
|
|
22
|
+
parent: DocumentReference | null,
|
|
23
|
+
) => Promise<CollectionReference[]>;
|
|
24
|
+
/** Fired when the user commits a picked reference (browse pick OR
|
|
25
|
+
* a valid manually-typed path with the Commit button). */
|
|
26
|
+
onPick?: (ref: DocumentReference) => void;
|
|
27
|
+
/** Forwarded to the root. */
|
|
28
|
+
className?: string;
|
|
29
|
+
/** Label for the path text input. Default 'Document path'. */
|
|
30
|
+
pathLabel?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Visible reference picker — text input + browseable panel.
|
|
35
|
+
*
|
|
36
|
+
* Two ways to commit a reference:
|
|
37
|
+
*
|
|
38
|
+
* 1. Type a path, then click Commit (enabled only when the path
|
|
39
|
+
* parses to a valid `DocumentReference`).
|
|
40
|
+
* 2. Drill into a collection in the panel and click a document
|
|
41
|
+
* row.
|
|
42
|
+
*
|
|
43
|
+
* Either path fires `onPick(ref)`. Headless — emits structural
|
|
44
|
+
* `data-pyric-*` for styling.
|
|
45
|
+
*/
|
|
46
|
+
export function ReferencePicker({
|
|
47
|
+
firestore,
|
|
48
|
+
initialPath,
|
|
49
|
+
listCollections,
|
|
50
|
+
onPick,
|
|
51
|
+
className,
|
|
52
|
+
pathLabel = 'Document path',
|
|
53
|
+
}: ReferencePickerProps) {
|
|
54
|
+
const picker = useReferencePicker({ firestore, listCollections, initialPath });
|
|
55
|
+
const [browseOpen, setBrowseOpen] = useState(false);
|
|
56
|
+
|
|
57
|
+
return (
|
|
58
|
+
<div className={className} data-pyric-ui="reference-picker">
|
|
59
|
+
<label data-pyric-reference-path-label>
|
|
60
|
+
{pathLabel}
|
|
61
|
+
<input
|
|
62
|
+
type="text"
|
|
63
|
+
value={picker.pathInput}
|
|
64
|
+
onChange={(e) => picker.setPathInput(e.target.value)}
|
|
65
|
+
placeholder="users/alice"
|
|
66
|
+
data-pyric-reference-path-input
|
|
67
|
+
aria-invalid={picker.error ? 'true' : undefined}
|
|
68
|
+
/>
|
|
69
|
+
</label>
|
|
70
|
+
{picker.error ? (
|
|
71
|
+
<span data-pyric-error-message>{picker.error}</span>
|
|
72
|
+
) : null}
|
|
73
|
+
<div data-pyric-reference-actions>
|
|
74
|
+
<button
|
|
75
|
+
type="button"
|
|
76
|
+
data-pyric-reference-browse-toggle
|
|
77
|
+
aria-expanded={browseOpen}
|
|
78
|
+
onClick={() => setBrowseOpen((o) => !o)}
|
|
79
|
+
>
|
|
80
|
+
{browseOpen ? 'Close browser' : 'Browse'}
|
|
81
|
+
</button>
|
|
82
|
+
<button
|
|
83
|
+
type="button"
|
|
84
|
+
data-pyric-reference-commit
|
|
85
|
+
disabled={!picker.reference}
|
|
86
|
+
onClick={() => {
|
|
87
|
+
if (picker.reference) onPick?.(picker.reference);
|
|
88
|
+
}}
|
|
89
|
+
>
|
|
90
|
+
Commit
|
|
91
|
+
</button>
|
|
92
|
+
</div>
|
|
93
|
+
{browseOpen ? (
|
|
94
|
+
<BrowsePanel
|
|
95
|
+
browseLocation={picker.browseLocation}
|
|
96
|
+
canDrillBack={picker.canDrillBack}
|
|
97
|
+
collections={picker.collections}
|
|
98
|
+
documents={picker.documents}
|
|
99
|
+
isLoading={picker.isLoading}
|
|
100
|
+
drillIntoCollection={picker.drillIntoCollection}
|
|
101
|
+
drillIntoDocument={picker.drillIntoDocument}
|
|
102
|
+
drillBack={picker.drillBack}
|
|
103
|
+
onPick={(ref) => {
|
|
104
|
+
picker.pick(ref);
|
|
105
|
+
onPick?.(ref);
|
|
106
|
+
}}
|
|
107
|
+
/>
|
|
108
|
+
) : null}
|
|
109
|
+
</div>
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
interface BrowsePanelProps {
|
|
114
|
+
browseLocation: BrowseLocation;
|
|
115
|
+
canDrillBack: boolean;
|
|
116
|
+
collections: CollectionReference[];
|
|
117
|
+
documents: ReturnType<typeof useReferencePicker>['documents'];
|
|
118
|
+
isLoading: boolean;
|
|
119
|
+
drillIntoCollection: (ref: CollectionReference) => void;
|
|
120
|
+
drillIntoDocument: (ref: DocumentReference) => void;
|
|
121
|
+
drillBack: () => void;
|
|
122
|
+
onPick: (ref: DocumentReference) => void;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function BrowsePanel({
|
|
126
|
+
browseLocation,
|
|
127
|
+
canDrillBack,
|
|
128
|
+
collections,
|
|
129
|
+
documents,
|
|
130
|
+
isLoading,
|
|
131
|
+
drillIntoCollection,
|
|
132
|
+
drillIntoDocument,
|
|
133
|
+
drillBack,
|
|
134
|
+
onPick,
|
|
135
|
+
}: BrowsePanelProps) {
|
|
136
|
+
const here = describeLocation(browseLocation);
|
|
137
|
+
return (
|
|
138
|
+
<div
|
|
139
|
+
data-pyric-ui="reference-browse-panel"
|
|
140
|
+
data-pyric-loading={isLoading ? '' : undefined}
|
|
141
|
+
>
|
|
142
|
+
<div data-pyric-browse-header>
|
|
143
|
+
<button
|
|
144
|
+
type="button"
|
|
145
|
+
data-pyric-browse-back
|
|
146
|
+
onClick={drillBack}
|
|
147
|
+
disabled={!canDrillBack}
|
|
148
|
+
aria-label="Back"
|
|
149
|
+
>
|
|
150
|
+
←
|
|
151
|
+
</button>
|
|
152
|
+
<span data-pyric-browse-location>{here}</span>
|
|
153
|
+
</div>
|
|
154
|
+
{browseLocation.kind === 'root' || browseLocation.kind === 'document' ? (
|
|
155
|
+
<ul data-pyric-browse-collections>
|
|
156
|
+
{collections.map((c) => (
|
|
157
|
+
<li
|
|
158
|
+
key={c.path}
|
|
159
|
+
data-pyric-browse-entry
|
|
160
|
+
data-pyric-entry-kind="collection"
|
|
161
|
+
data-pyric-collection-id={c.id}
|
|
162
|
+
>
|
|
163
|
+
<button
|
|
164
|
+
type="button"
|
|
165
|
+
onClick={() => drillIntoCollection(c)}
|
|
166
|
+
data-pyric-browse-select
|
|
167
|
+
>
|
|
168
|
+
{c.id}/
|
|
169
|
+
</button>
|
|
170
|
+
</li>
|
|
171
|
+
))}
|
|
172
|
+
</ul>
|
|
173
|
+
) : (
|
|
174
|
+
<ul data-pyric-browse-documents>
|
|
175
|
+
{documents.map((d) => {
|
|
176
|
+
const ref = (d as unknown as { ref: DocumentReference }).ref;
|
|
177
|
+
return (
|
|
178
|
+
<li
|
|
179
|
+
key={ref.path}
|
|
180
|
+
data-pyric-browse-entry
|
|
181
|
+
data-pyric-entry-kind="document"
|
|
182
|
+
data-pyric-document-id={d.id}
|
|
183
|
+
>
|
|
184
|
+
<button
|
|
185
|
+
type="button"
|
|
186
|
+
onClick={() => onPick(ref)}
|
|
187
|
+
data-pyric-browse-pick
|
|
188
|
+
>
|
|
189
|
+
{d.id}
|
|
190
|
+
</button>
|
|
191
|
+
<button
|
|
192
|
+
type="button"
|
|
193
|
+
onClick={() => drillIntoDocument(ref)}
|
|
194
|
+
data-pyric-browse-drill
|
|
195
|
+
aria-label="Drill into subcollections"
|
|
196
|
+
>
|
|
197
|
+
→
|
|
198
|
+
</button>
|
|
199
|
+
</li>
|
|
200
|
+
);
|
|
201
|
+
})}
|
|
202
|
+
</ul>
|
|
203
|
+
)}
|
|
204
|
+
</div>
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function describeLocation(loc: BrowseLocation): string {
|
|
209
|
+
if (loc.kind === 'root') return '/';
|
|
210
|
+
if (loc.kind === 'document') return `/${loc.ref.path}`;
|
|
211
|
+
return `/${loc.ref.path}`;
|
|
212
|
+
}
|