@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.
Files changed (152) hide show
  1. package/package.json +5 -4
  2. package/src/agents/ContextWindowUsage.tsx +514 -0
  3. package/src/agents/EmptyState.tsx +27 -0
  4. package/src/agents/Fold.tsx +64 -0
  5. package/src/agents/Modal.tsx +65 -0
  6. package/src/agents/PulsingDot.tsx +28 -0
  7. package/src/agents/inbrowser-agent-usage.d.ts +141 -0
  8. package/src/agents/index.ts +28 -0
  9. package/src/auth/authApi.ts +72 -0
  10. package/src/auth/claims.ts +63 -0
  11. package/src/auth/components/AuthProviderToggles.tsx +147 -0
  12. package/src/auth/components/AuthSignInHelper.tsx +197 -0
  13. package/src/auth/components/AuthUserForm.tsx +328 -0
  14. package/src/auth/components/AuthUserList.tsx +219 -0
  15. package/src/auth/components/ClaimsField.tsx +50 -0
  16. package/src/auth/components/confirmActions.tsx +114 -0
  17. package/src/auth/controller.ts +173 -0
  18. package/src/auth/hooks/index.ts +36 -0
  19. package/src/auth/hooks/useAuthFlowHelper.ts +55 -0
  20. package/src/auth/hooks/useAuthProviderConfig.ts +139 -0
  21. package/src/auth/hooks/useAuthUserEditor.ts +76 -0
  22. package/src/auth/hooks/useAuthUsers.ts +154 -0
  23. package/src/auth/index.ts +42 -0
  24. package/src/auth/providers.ts +28 -0
  25. package/src/auth/reducers/userEditor.ts +186 -0
  26. package/src/events/components/ActivityActionItems.tsx +136 -0
  27. package/src/events/components/ActivityGrid.tsx +197 -0
  28. package/src/events/components/ActivityGridRow.tsx +65 -0
  29. package/src/events/components/ProposedChangeDiff.tsx +175 -0
  30. package/src/events/components/format.ts +21 -0
  31. package/src/events/components/index.ts +17 -0
  32. package/src/events/digest.ts +630 -0
  33. package/src/events/hooks/index.ts +9 -0
  34. package/src/events/hooks/useActivityDigest.ts +42 -0
  35. package/src/events/hooks/useActivityStream.ts +86 -0
  36. package/src/events/index.ts +39 -0
  37. package/src/events/types.ts +152 -0
  38. package/src/firestore/components/CollectionList.tsx +78 -0
  39. package/src/firestore/components/DeleteWithConfirm.tsx +88 -0
  40. package/src/firestore/components/DocumentEditor.tsx +350 -0
  41. package/src/firestore/components/DocumentList.tsx +217 -0
  42. package/src/firestore/components/DocumentPreview.tsx +265 -0
  43. package/src/firestore/components/FieldRenderer.tsx +25 -0
  44. package/src/firestore/components/QueryBuilder.tsx +181 -0
  45. package/src/firestore/components/ReferencePicker.tsx +212 -0
  46. package/src/firestore/components/TreeEntry.tsx +58 -0
  47. package/src/firestore/components/context.ts +25 -0
  48. package/src/firestore/fieldEditors/array.tsx +30 -0
  49. package/src/firestore/fieldEditors/boolean.tsx +39 -0
  50. package/src/firestore/fieldEditors/bytes.tsx +53 -0
  51. package/src/firestore/fieldEditors/geopoint.tsx +71 -0
  52. package/src/firestore/fieldEditors/map.tsx +33 -0
  53. package/src/firestore/fieldEditors/null.tsx +27 -0
  54. package/src/firestore/fieldEditors/number.tsx +43 -0
  55. package/src/firestore/fieldEditors/reference.tsx +87 -0
  56. package/src/firestore/fieldEditors/registry.ts +45 -0
  57. package/src/firestore/fieldEditors/string.tsx +33 -0
  58. package/src/firestore/fieldEditors/timestamp.tsx +75 -0
  59. package/src/firestore/fieldEditors/types.ts +68 -0
  60. package/src/firestore/fieldEditors/vector.tsx +142 -0
  61. package/src/firestore/firestoreApi.ts +86 -0
  62. package/src/firestore/hooks/coerceError.ts +34 -0
  63. package/src/firestore/hooks/index.ts +46 -0
  64. package/src/firestore/hooks/useCollectionList.ts +102 -0
  65. package/src/firestore/hooks/useDocumentEditor.ts +161 -0
  66. package/src/firestore/hooks/useDocumentList.ts +242 -0
  67. package/src/firestore/hooks/useDocumentSubcollections.ts +86 -0
  68. package/src/firestore/hooks/useFirestoreCollection.ts +51 -0
  69. package/src/firestore/hooks/useFirestoreDoc.ts +55 -0
  70. package/src/firestore/hooks/useQueryBuilder.ts +188 -0
  71. package/src/firestore/hooks/useRecursiveDelete.ts +77 -0
  72. package/src/firestore/hooks/useReferencePicker.ts +228 -0
  73. package/src/firestore/import/parseImport.ts +137 -0
  74. package/src/firestore/index.ts +87 -0
  75. package/src/firestore/reducers/defaults.ts +38 -0
  76. package/src/firestore/reducers/documentEditor.ts +239 -0
  77. package/src/firestore/reducers/tree.ts +140 -0
  78. package/src/firestore/reducers/types.ts +92 -0
  79. package/src/firestore/reducers/validation.ts +129 -0
  80. package/src/firestore/types.ts +231 -0
  81. package/src/firestore/validation/ids.ts +45 -0
  82. package/src/firestore/valueEquality.ts +43 -0
  83. package/src/index.ts +10 -0
  84. package/src/primitives/Badge.tsx +40 -0
  85. package/src/primitives/ConfirmDialog.tsx +137 -0
  86. package/src/primitives/CopyButton.tsx +62 -0
  87. package/src/primitives/JsonView.tsx +151 -0
  88. package/src/primitives/SegmentedControl.tsx +72 -0
  89. package/src/primitives/Toast.tsx +161 -0
  90. package/src/primitives/VirtualList.tsx +104 -0
  91. package/src/primitives/hooks/useContainerSize.ts +53 -0
  92. package/src/primitives/hooks/useUpdateHighlights.ts +109 -0
  93. package/src/primitives/index.ts +39 -0
  94. package/src/primitives/useConfirm.tsx +113 -0
  95. package/src/rtdb/components/RtdbPathBar.tsx +135 -0
  96. package/src/rtdb/components/RtdbTree.tsx +409 -0
  97. package/src/rtdb/editor.ts +79 -0
  98. package/src/rtdb/hooks/useRtdbTree.ts +137 -0
  99. package/src/rtdb/index.ts +66 -0
  100. package/src/rtdb/pathInput.ts +47 -0
  101. package/src/rtdb/reducers/tree.ts +191 -0
  102. package/src/rtdb/rtdbApi.ts +23 -0
  103. package/src/rtdb/values.ts +188 -0
  104. package/src/rules/components/DenialInspector.tsx +227 -0
  105. package/src/rules/components/format.ts +171 -0
  106. package/src/rules/components/index.ts +12 -0
  107. package/src/rules/components/scope.ts +75 -0
  108. package/src/rules/hooks/index.ts +5 -0
  109. package/src/rules/hooks/useDenialTrace.ts +100 -0
  110. package/src/rules/index.ts +24 -0
  111. package/src/rules/types.ts +91 -0
  112. package/src/storage/collisionRename.ts +114 -0
  113. package/src/storage/components/DeleteSelectionWithConfirm.tsx +193 -0
  114. package/src/storage/components/ObjectBrowser.tsx +219 -0
  115. package/src/storage/components/ObjectInspector.tsx +169 -0
  116. package/src/storage/components/PathBreadcrumb.tsx +84 -0
  117. package/src/storage/components/UploadDropzone.tsx +182 -0
  118. package/src/storage/folderPlaceholder.ts +40 -0
  119. package/src/storage/hooks/index.ts +59 -0
  120. package/src/storage/hooks/useMetadataEditor.ts +329 -0
  121. package/src/storage/hooks/useObjectUpload.ts +262 -0
  122. package/src/storage/hooks/usePathState.ts +94 -0
  123. package/src/storage/hooks/useStorageDelete.ts +195 -0
  124. package/src/storage/hooks/useStorageList.ts +261 -0
  125. package/src/storage/hooks/useStorageObject.ts +162 -0
  126. package/src/storage/hooks/useStorageRulesGate.ts +270 -0
  127. package/src/storage/hooks/useStorageSelection.ts +90 -0
  128. package/src/storage/index.ts +59 -0
  129. package/src/storage/pendingPrefixes.ts +125 -0
  130. package/src/storage/previews.tsx +120 -0
  131. package/src/storage/storageApi.ts +54 -0
  132. package/src/traffic/components/RuleHeatmap.tsx +97 -0
  133. package/src/traffic/components/TrafficDetail.tsx +160 -0
  134. package/src/traffic/components/TrafficGroupRow.tsx +91 -0
  135. package/src/traffic/components/TrafficLineChart.tsx +139 -0
  136. package/src/traffic/components/TrafficLog.tsx +175 -0
  137. package/src/traffic/components/TrafficMetricCards.tsx +77 -0
  138. package/src/traffic/components/TrafficRow.tsx +69 -0
  139. package/src/traffic/components/TrafficStats.tsx +73 -0
  140. package/src/traffic/components/TrafficTimeline.tsx +289 -0
  141. package/src/traffic/components/format.ts +22 -0
  142. package/src/traffic/components/index.ts +22 -0
  143. package/src/traffic/hooks/index.ts +60 -0
  144. package/src/traffic/hooks/useRuleHeatmap.ts +92 -0
  145. package/src/traffic/hooks/useTrafficBuckets.ts +146 -0
  146. package/src/traffic/hooks/useTrafficFilter.ts +74 -0
  147. package/src/traffic/hooks/useTrafficGroups.ts +126 -0
  148. package/src/traffic/hooks/useTrafficMetrics.ts +250 -0
  149. package/src/traffic/hooks/useTrafficMonitor.ts +111 -0
  150. package/src/traffic/hooks/useTrafficStats.ts +77 -0
  151. package/src/traffic/index.ts +13 -0
  152. 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
+ }