@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,68 @@
1
+ import type { ComponentType } from 'react';
2
+ import type { FieldType } from '../types.js';
3
+
4
+ export interface FieldDisplayProps<V = unknown> {
5
+ /** Value to display. The component's `V` generic narrows this. */
6
+ value: V;
7
+ /** Dotted/bracketed path from the document root, e.g. `users.alice`
8
+ * or `tags[0]`. Forwarded so consumer styles can target nested
9
+ * positions via `[data-field-path="users.alice"]`. */
10
+ path?: string;
11
+ /** Recursive editors (Map, Array) need the registry to dispatch
12
+ * on their children. Leaf editors (String, Number, …) can ignore
13
+ * this prop. Required-but-optional because the consumer of the
14
+ * component (`<FieldRenderer>`, `<DocumentPreview>`) always
15
+ * threads it through. */
16
+ fieldEditors?: FieldEditorRegistry;
17
+ }
18
+
19
+ /**
20
+ * Props passed to a per-type `Edit` component. The leaf editors
21
+ * (string, number, …) consume this directly. Map/array editing is
22
+ * handled by the `<DocumentEditor>` compound component itself, not
23
+ * by individual editors — Firestore's container shapes are special
24
+ * enough that pushing them through the registry costs more than
25
+ * it's worth.
26
+ */
27
+ export interface FieldEditProps<V = unknown> {
28
+ /** Current value. */
29
+ value: V;
30
+ /** Commit a new value. The hook wires this to the reducer's
31
+ * `setValue` action. */
32
+ onChange: (next: V) => void;
33
+ /** Validation error attached by the reducer. Editors render it
34
+ * inline alongside the input. */
35
+ error?: string;
36
+ /** Dotted/bracketed path from the document root. */
37
+ path?: string;
38
+ }
39
+
40
+ /**
41
+ * Contract for one Firestore value type. `Display` (read-mode) is
42
+ * required; `Edit` + `validate` + `defaultValue` are required for
43
+ * leaf types that participate in M3's editor. Map/array contracts
44
+ * supply only `Display` — their edit affordances come from the
45
+ * `<DocumentEditor>` compound component.
46
+ */
47
+ export interface FieldEditorContract<V = unknown> {
48
+ type: FieldType;
49
+ Display: ComponentType<FieldDisplayProps<V>>;
50
+ Edit?: ComponentType<FieldEditProps<V>>;
51
+ }
52
+
53
+ /**
54
+ * Map of field-type to editor contract. `Partial<…>` so consumers
55
+ * can override one type without re-supplying the rest — the merge
56
+ * happens at the `<DocumentPreview>` boundary.
57
+ *
58
+ * The stored value type is `FieldEditorContract<any>` rather than
59
+ * `FieldEditorContract<unknown>` because each per-type contract
60
+ * narrows its generic (e.g., `FieldEditorContract<Timestamp>` for
61
+ * timestamp) and TypeScript's `ComponentType` is invariant in
62
+ * props. `any` at the registry layer means the type-safety lives
63
+ * at the per-contract definition site, not in the dispatch map.
64
+ * `FieldRenderer` narrows back from `unknown` -> the right contract
65
+ * via `inferType` at dispatch time.
66
+ */
67
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
68
+ export type FieldEditorRegistry = Partial<Record<FieldType, FieldEditorContract<any>>>;
@@ -0,0 +1,142 @@
1
+ import { useState } from 'react';
2
+ import { asVectorView, type VectorView } from '../types.js';
3
+ import type { FieldEditorContract, FieldDisplayProps, FieldEditProps } from './types.js';
4
+
5
+ /** How many leading components to show in the truncated preview. The
6
+ * mock (`c-data.html`) renders four then an ellipsis. */
7
+ const PREVIEW_COUNT = 4;
8
+
9
+ /**
10
+ * Render a `[a, b, c, … ]` preview of the first {@link PREVIEW_COUNT}
11
+ * components. A vector that's already short enough is shown in full
12
+ * without the trailing ellipsis.
13
+ */
14
+ function previewText(values: number[]): string {
15
+ if (values.length <= PREVIEW_COUNT) {
16
+ return `[${values.join(', ')}]`;
17
+ }
18
+ const head = values.slice(0, PREVIEW_COUNT).join(', ');
19
+ return `[${head}, …]`;
20
+ }
21
+
22
+ /**
23
+ * Read view for a vector (embedding). Mirrors the mock: a
24
+ * `vector · <dims>` type chip plus a truncated value preview. The full
25
+ * 768-float array is never rendered inline — it's noise and a perf
26
+ * hazard. `data-dimension` carries the dimension for styling/queries.
27
+ */
28
+ function VectorDisplay({ value, path }: FieldDisplayProps<unknown>) {
29
+ const view = asVectorView(value);
30
+ if (!view) {
31
+ // Should be unreachable: the renderer only dispatches here when
32
+ // `inferType` already classified the value as `vector`. Render a
33
+ // defensive empty marker rather than throwing.
34
+ return (
35
+ <span data-pyric-field-type="vector" data-pyric-field-path={path} />
36
+ );
37
+ }
38
+ return (
39
+ <span
40
+ data-pyric-field-type="vector"
41
+ data-pyric-field-path={path}
42
+ data-dimension={String(view.dimension)}
43
+ >
44
+ <span data-pyric-vector-dims>{`vector · ${view.dimension}`}</span>
45
+ <span data-pyric-vector-preview>{previewText(view.values)}</span>
46
+ </span>
47
+ );
48
+ }
49
+
50
+ /**
51
+ * Result of parsing the raw-replace textarea. `ok` carries the new
52
+ * wire-sentinel value to commit; otherwise `error` is a human message.
53
+ * Exported (and pure) so the parse/validation contract is unit-testable
54
+ * without the JSDOM text-input event path, which is broken under this
55
+ * repo's bun:test + JSDOM setup (see DocumentEditor.test.tsx note).
56
+ */
57
+ export type ParsedVectorInput =
58
+ | { ok: true; value: { __type__: '__vector__'; value: number[] } }
59
+ | { ok: false; error: string };
60
+
61
+ /**
62
+ * Parse the textarea contents into a wire-sentinel vector, or an error.
63
+ * Accepts only a JSON array of numbers — the whole-value replace
64
+ * contract. Anything else (bad JSON, non-array, non-numeric element)
65
+ * is rejected and the previous value is kept by the caller.
66
+ */
67
+ export function parseVectorInput(text: string): ParsedVectorInput {
68
+ let parsed: unknown;
69
+ try {
70
+ parsed = JSON.parse(text);
71
+ } catch {
72
+ return { ok: false, error: 'Invalid JSON' };
73
+ }
74
+ if (!Array.isArray(parsed) || !parsed.every((n) => typeof n === 'number')) {
75
+ return { ok: false, error: 'Expected a JSON array of numbers' };
76
+ }
77
+ return { ok: true, value: { __type__: '__vector__', value: parsed } };
78
+ }
79
+
80
+ /**
81
+ * Edit affordance for a vector. Deliberately NOT a per-element grid —
82
+ * a 768-dim embedding isn't hand-tuned float by float. The contract is
83
+ * "replace the whole value": paste a JSON number array and commit. The
84
+ * dimension + a note are shown so the editor is honest about what it is.
85
+ *
86
+ * On commit, we emit the value back in the same wire-sentinel shape the
87
+ * read side already understands (`{__type__:'__vector__', value}`), so a
88
+ * round-trip through `inferType` re-classifies it as `vector`. Invalid
89
+ * JSON / non-numeric input leaves the previous value in place (same
90
+ * forgiving stance as the bytes + geopoint editors).
91
+ */
92
+ function VectorEdit({ value, onChange, error, path }: FieldEditProps<unknown>) {
93
+ const view = asVectorView(value);
94
+ const initial = view ? JSON.stringify(view.values) : '[]';
95
+ const [draft, setDraft] = useState(initial);
96
+ const [parseError, setParseError] = useState<string | undefined>(undefined);
97
+
98
+ const commit = (text: string) => {
99
+ setDraft(text);
100
+ const result = parseVectorInput(text);
101
+ if (!result.ok) {
102
+ setParseError(result.error);
103
+ return;
104
+ }
105
+ setParseError(undefined);
106
+ // Re-emit in the wire-sentinel shape so the read side + inferType
107
+ // recognize it as a vector again without depending on a backend
108
+ // VectorValue class being importable here.
109
+ onChange(result.value);
110
+ };
111
+
112
+ const dims = view ? view.dimension : 0;
113
+ const shown = parseError ?? error;
114
+
115
+ return (
116
+ <label
117
+ data-pyric-field-type="vector"
118
+ data-pyric-field-path={path}
119
+ data-dimension={String(dims)}
120
+ data-pyric-error={shown ? '' : undefined}
121
+ >
122
+ <span data-pyric-vector-dims>{`vector · ${dims}`}</span>
123
+ <span data-pyric-vector-note>Replace whole. Paste a JSON number array.</span>
124
+ <textarea
125
+ data-pyric-vector-raw
126
+ value={draft}
127
+ onChange={(e) => commit(e.target.value)}
128
+ aria-invalid={shown ? 'true' : undefined}
129
+ aria-label="Vector value (JSON number array)"
130
+ />
131
+ {shown ? <span data-pyric-error-message>{shown}</span> : null}
132
+ </label>
133
+ );
134
+ }
135
+
136
+ export const vectorEditor: FieldEditorContract<unknown> = {
137
+ type: 'vector',
138
+ Display: VectorDisplay,
139
+ Edit: VectorEdit,
140
+ };
141
+
142
+ export type { VectorView };
@@ -0,0 +1,86 @@
1
+ import { createContext, createElement, useContext, type ReactNode } from 'react';
2
+ import {
3
+ addDoc,
4
+ collection,
5
+ deleteDoc,
6
+ doc,
7
+ getDoc,
8
+ getDocs,
9
+ limit,
10
+ onSnapshot,
11
+ query,
12
+ setDoc,
13
+ startAfter,
14
+ } from 'pyric/firestore';
15
+
16
+ /**
17
+ * The modular Firestore functions the data hooks call, as an INJECTABLE bundle.
18
+ *
19
+ * WHY: the hooks default to the in-process `pyric/firestore` API, but Pyric
20
+ * Studio's served mode drives the SAME ops over a SharedWorker via a PARALLEL
21
+ * modular client (`@pyric/cli/serve/worker`: its own `collection`/`getDocs`/...
22
+ * over a `MessagePort`, and a `ClientDb` that is not a `pyric/firestore`
23
+ * `Firestore`). Statically importing the in-process fns hardwires the hooks to
24
+ * the in-page sandbox; reading them from this context lets a consumer inject the
25
+ * worker client's fns so the hooks operate on the live worker backend without
26
+ * the hooks (or the components) knowing which backend they hit.
27
+ *
28
+ * The bundle is typed to the in-process signatures. A worker bundle is adapted
29
+ * (cast) to this shape at the Studio boundary: the worker handles + snapshots
30
+ * are runtime-compatible at the surface the hooks use (`.id` / `.data()` /
31
+ * `.docs` / `.ref`), which is the contract function-injection relies on.
32
+ *
33
+ * Default = the real `pyric/firestore` fns, so every existing consumer (the
34
+ * dev-seed review build, tests, any app embedding `@pyric/ui`) is unchanged: no
35
+ * provider needed unless you are swapping the backend.
36
+ */
37
+ export type FirestoreApi = Pick<
38
+ typeof import('pyric/firestore'),
39
+ | 'addDoc'
40
+ | 'collection'
41
+ | 'deleteDoc'
42
+ | 'doc'
43
+ | 'getDoc'
44
+ | 'getDocs'
45
+ | 'limit'
46
+ | 'onSnapshot'
47
+ | 'query'
48
+ | 'setDoc'
49
+ | 'startAfter'
50
+ >;
51
+
52
+ const inProcessFirestoreApi: FirestoreApi = {
53
+ addDoc,
54
+ collection,
55
+ deleteDoc,
56
+ doc,
57
+ getDoc,
58
+ getDocs,
59
+ limit,
60
+ onSnapshot,
61
+ query,
62
+ setDoc,
63
+ startAfter,
64
+ };
65
+
66
+ const FirestoreApiContext = createContext<FirestoreApi>(inProcessFirestoreApi);
67
+
68
+ /** Read the active Firestore API bundle (defaults to in-process `pyric/firestore`). */
69
+ export function useFirestoreApi(): FirestoreApi {
70
+ return useContext(FirestoreApiContext);
71
+ }
72
+
73
+ /**
74
+ * Provide a Firestore API bundle to the subtree. Pyric Studio wraps its data
75
+ * surface with this, supplying the in-process bundle for dev-seed review and the
76
+ * SharedWorker client bundle under `pyric dev --ui`.
77
+ */
78
+ export function FirestoreApiProvider({
79
+ value,
80
+ children,
81
+ }: {
82
+ value: FirestoreApi;
83
+ children: ReactNode;
84
+ }) {
85
+ return createElement(FirestoreApiContext.Provider, { value }, children);
86
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Coerce an arbitrary thrown value into an `Error` with a useful
3
+ * `.message`. The naive `new Error(String(err))` produces the literal
4
+ * string `"[object Object]"` when `err` is a non-Error object shape
5
+ * — which is exactly what Firestore's `onSnapshot` error callback
6
+ * receives for `FirestoreError`-like values (`{ code, message, …}`).
7
+ *
8
+ * Strategy:
9
+ * - `Error` → returned unchanged.
10
+ * - `string` → `new Error(value)`.
11
+ * - `{ message }` → `new Error(message)` (preserves Firestore's
12
+ * stringified message), with `code` appended when present.
13
+ * - anything else → `new Error(JSON.stringify(value))` (no more
14
+ * "[object Object]").
15
+ */
16
+ export function coerceError(value: unknown): Error {
17
+ if (value instanceof Error) return value;
18
+ if (typeof value === 'string') return new Error(value);
19
+ if (value && typeof value === 'object') {
20
+ const obj = value as { message?: unknown; code?: unknown };
21
+ const msg = typeof obj.message === 'string' ? obj.message : null;
22
+ const code = typeof obj.code === 'string' ? obj.code : null;
23
+ if (msg && code) return new Error(`[${code}] ${msg}`);
24
+ if (msg) return new Error(msg);
25
+ if (code) return new Error(code);
26
+ try {
27
+ return new Error(JSON.stringify(value));
28
+ } catch {
29
+ // Cyclic objects — bail to a type tag rather than crash.
30
+ return new Error(Object.prototype.toString.call(value));
31
+ }
32
+ }
33
+ return new Error(String(value));
34
+ }
@@ -0,0 +1,46 @@
1
+ export { useFirestoreDoc, type SubscriptionState } from './useFirestoreDoc.js';
2
+ export { useFirestoreCollection } from './useFirestoreCollection.js';
3
+ export {
4
+ useDocumentEditor,
5
+ type UseDocumentEditorOptions,
6
+ type UseDocumentEditorResult,
7
+ } from './useDocumentEditor.js';
8
+ export {
9
+ useCollectionList,
10
+ type UseCollectionListOptions,
11
+ type UseCollectionListResult,
12
+ } from './useCollectionList.js';
13
+ export {
14
+ useDocumentList,
15
+ type UseDocumentListOptions,
16
+ type UseDocumentListResult,
17
+ } from './useDocumentList.js';
18
+ export {
19
+ useDocumentSubcollections,
20
+ type ListSubcollections,
21
+ type UseDocumentSubcollectionsOptions,
22
+ type UseDocumentSubcollectionsResult,
23
+ } from './useDocumentSubcollections.js';
24
+ export {
25
+ useRecursiveDelete,
26
+ type RecursiveDeleteImpl,
27
+ type RecursiveDeleteProgress,
28
+ type UseRecursiveDeleteResult,
29
+ } from './useRecursiveDelete.js';
30
+ export {
31
+ useReferencePicker,
32
+ type BrowseLocation,
33
+ type UseReferencePickerOptions,
34
+ type UseReferencePickerResult,
35
+ } from './useReferencePicker.js';
36
+ export {
37
+ useQueryBuilder,
38
+ QUERY_OPS,
39
+ MULTI_VALUE_OPS,
40
+ type QueryOp,
41
+ type QueryCondition,
42
+ type QueryBuilderState,
43
+ type QueryBuilderActions,
44
+ type UseQueryBuilderOptions,
45
+ type UseQueryBuilderResult,
46
+ } from './useQueryBuilder.js';
@@ -0,0 +1,102 @@
1
+ import { useCallback, useEffect, useRef, useState } from 'react';
2
+ import {
3
+ collection as collFn,
4
+ doc as docFn,
5
+ setDoc,
6
+ type CollectionReference,
7
+ type DocumentReference,
8
+ type Firestore,
9
+ } from 'pyric/firestore';
10
+
11
+ export interface UseCollectionListOptions {
12
+ firestore: Firestore;
13
+ /** Parent document, or `null`/`undefined` for root collections. */
14
+ parent?: DocumentReference | null;
15
+ /**
16
+ * Injected collection-listing function. The library doesn't ship
17
+ * a default — the modular Web SDK doesn't expose `listCollections`
18
+ * on the client. Sandbox-backed apps usually wire
19
+ * `pyric/sandbox`'s in-process listing; production apps either
20
+ * pass a known list (e.g. from a schema) or call a server proxy.
21
+ */
22
+ listCollections: (
23
+ firestore: Firestore,
24
+ parent: DocumentReference | null | undefined,
25
+ ) => Promise<CollectionReference[]>;
26
+ }
27
+
28
+ export interface UseCollectionListResult {
29
+ collections: CollectionReference[];
30
+ isLoading: boolean;
31
+ error: Error | undefined;
32
+ /** Re-run the listing function. */
33
+ refresh: () => void;
34
+ /**
35
+ * Create a new collection by writing its first document. Firestore
36
+ * collections don't exist independently of their documents —
37
+ * `setDoc` on the first child path materializes the collection.
38
+ */
39
+ createCollection: (
40
+ collectionId: string,
41
+ firstDoc: { id: string; data: Record<string, unknown> },
42
+ ) => Promise<DocumentReference>;
43
+ }
44
+
45
+ /**
46
+ * Operational read + create for collections under a parent (or root).
47
+ * Listing is injected because the modular Web SDK doesn't expose a
48
+ * native `listCollections` on the client — see options docs.
49
+ */
50
+ export function useCollectionList({
51
+ firestore,
52
+ parent,
53
+ listCollections,
54
+ }: UseCollectionListOptions): UseCollectionListResult {
55
+ const [collections, setCollections] = useState<CollectionReference[]>([]);
56
+ const [isLoading, setIsLoading] = useState(true);
57
+ const [error, setError] = useState<Error | undefined>(undefined);
58
+ const [tick, setTick] = useState(0);
59
+
60
+ // Stable-ref the injected lister so re-renders with a fresh
61
+ // closure identity don't loop the effect — see useReferencePicker
62
+ // for the same pattern + rationale.
63
+ const listCollectionsRef = useRef(listCollections);
64
+ listCollectionsRef.current = listCollections;
65
+
66
+ useEffect(() => {
67
+ let cancelled = false;
68
+ setIsLoading(true);
69
+ setError(undefined);
70
+ listCollectionsRef.current(firestore, parent)
71
+ .then((list) => {
72
+ if (cancelled) return;
73
+ setCollections(list);
74
+ setIsLoading(false);
75
+ })
76
+ .catch((e) => {
77
+ if (cancelled) return;
78
+ setError(e instanceof Error ? e : new Error(String(e)));
79
+ setIsLoading(false);
80
+ });
81
+ return () => {
82
+ cancelled = true;
83
+ };
84
+ }, [firestore, parent, tick]);
85
+
86
+ const refresh = useCallback(() => setTick((n) => n + 1), []);
87
+
88
+ const createCollection = useCallback<UseCollectionListResult['createCollection']>(
89
+ async (collectionId, firstDoc) => {
90
+ const parentColl = parent
91
+ ? collFn(parent, collectionId)
92
+ : collFn(firestore, collectionId);
93
+ const ref = docFn(parentColl, firstDoc.id);
94
+ await setDoc(ref, firstDoc.data);
95
+ setTick((n) => n + 1);
96
+ return ref;
97
+ },
98
+ [firestore, parent],
99
+ );
100
+
101
+ return { collections, isLoading, error, refresh, createCollection };
102
+ }
@@ -0,0 +1,161 @@
1
+ import { useCallback, useMemo, useReducer, useRef } from 'react';
2
+ import type { FieldType } from '../types.js';
3
+ import { initState, reducer } from '../reducers/documentEditor.js';
4
+ import { treeToData } from '../reducers/tree.js';
5
+ import type {
6
+ DocumentEditorAction,
7
+ DocumentEditorState,
8
+ } from '../reducers/types.js';
9
+
10
+ export interface UseDocumentEditorOptions {
11
+ /** Initial document data — the same shape a `DocumentSnapshot.data()`
12
+ * call returns. */
13
+ initial?: Record<string, unknown>;
14
+ }
15
+
16
+ export interface UseDocumentEditorResult extends DocumentEditorState {
17
+ /** Convenience: `errorCount === 0`. */
18
+ isValid: boolean;
19
+ /** `true` once any modifying action has fired since the last
20
+ * `reset`. Cleared by `reset`. Does NOT clear when the user
21
+ * manually re-enters the original values — checking that would
22
+ * require a full serialization comparison on every dispatch. */
23
+ isDirty: boolean;
24
+ /** Raw dispatch — drops to the reducer-action surface. Prefer the
25
+ * named helpers below. */
26
+ dispatch: (action: DocumentEditorAction) => void;
27
+ /** Update a leaf value. */
28
+ setValue: (nodeId: string, value: unknown) => void;
29
+ /** Switch a node's type. Map/array nodes drop their children. */
30
+ setType: (nodeId: string, newType: FieldType) => void;
31
+ /** Set a map-child's key. */
32
+ setKey: (nodeId: string, key: string) => void;
33
+ /** Append a child to a map. */
34
+ addMapEntry: (parentId: string, key: string, childType: FieldType) => void;
35
+ /** Append a child to an array. Nested arrays are silently
36
+ * rejected by the reducer (Firestore disallows them). */
37
+ addArrayEntry: (parentId: string, childType: FieldType) => void;
38
+ /** Remove a node (and all its descendants). Removing the root is
39
+ * a no-op. */
40
+ remove: (nodeId: string) => void;
41
+ /** Restore the tree to its initial state. Clears `isDirty`. */
42
+ reset: () => void;
43
+ /** Replace the editor with a newly delivered snapshot and adopt it as the
44
+ * clean baseline. Intended for live document viewers. */
45
+ replaceData: (data: Record<string, unknown>) => void;
46
+ /** Mark one node touched (dispatch on blur). Gates error display —
47
+ * a freshly-added row stays quiet until the user leaves it. */
48
+ touch: (nodeId: string) => void;
49
+ /** Mark every node touched (dispatch on a submit attempt) so any
50
+ * hidden errors surface at once. */
51
+ touchAll: () => void;
52
+ /** Serialize the tree back to a Firestore-shaped object suitable
53
+ * for `setDoc` / `updateDoc`. */
54
+ toData: () => Record<string, unknown>;
55
+ }
56
+
57
+ /**
58
+ * Headless document editor. Owns the entire edit state for one
59
+ * document via a pure reducer. Consumers either render the bundled
60
+ * `<DocumentEditor>` compound component over this hook, or render
61
+ * their own tree using the returned state.
62
+ *
63
+ * The hook builds its tree from `initial` on first mount. Changing
64
+ * `initial` later does NOT rebuild the tree; live viewers explicitly call
65
+ * `replaceData()` when a newer snapshot should become the clean baseline.
66
+ * This matches the firebase-tools-ui pattern of treating the editor as a
67
+ * stateful workspace while still allowing snapshot-driven reconciliation.
68
+ */
69
+ export function useDocumentEditor(
70
+ options: UseDocumentEditorOptions = {},
71
+ ): UseDocumentEditorResult {
72
+ const initial = options.initial ?? {};
73
+ // `useRef` so the initial snapshot is computed once. The reducer
74
+ // owns the live tree from there on.
75
+ const initialRef = useRef<DocumentEditorState | null>(null);
76
+ if (initialRef.current == null) {
77
+ initialRef.current = initState(initial);
78
+ }
79
+
80
+ const [state, dispatch] = useReducer(reducer, initialRef.current);
81
+
82
+ const setValue = useCallback(
83
+ (nodeId: string, value: unknown) =>
84
+ dispatch({ type: 'setValue', nodeId, value }),
85
+ [],
86
+ );
87
+ const setType = useCallback(
88
+ (nodeId: string, newType: FieldType) =>
89
+ dispatch({ type: 'setType', nodeId, newType }),
90
+ [],
91
+ );
92
+ const setKey = useCallback(
93
+ (nodeId: string, key: string) => dispatch({ type: 'setKey', nodeId, key }),
94
+ [],
95
+ );
96
+ const addMapEntry = useCallback(
97
+ (parentId: string, key: string, childType: FieldType) =>
98
+ dispatch({ type: 'addMapEntry', parentId, key, childType }),
99
+ [],
100
+ );
101
+ const addArrayEntry = useCallback(
102
+ (parentId: string, childType: FieldType) =>
103
+ dispatch({ type: 'addArrayEntry', parentId, childType }),
104
+ [],
105
+ );
106
+ const remove = useCallback(
107
+ (nodeId: string) => dispatch({ type: 'remove', nodeId }),
108
+ [],
109
+ );
110
+ const reset = useCallback(() => dispatch({ type: 'reset' }), []);
111
+ const replaceData = useCallback(
112
+ (data: Record<string, unknown>) => dispatch({ type: 'replaceData', data }),
113
+ [],
114
+ );
115
+ const touch = useCallback(
116
+ (nodeId: string) => dispatch({ type: 'touch', nodeId }),
117
+ [],
118
+ );
119
+ const touchAll = useCallback(() => dispatch({ type: 'touchAll' }), []);
120
+
121
+ const toData = useCallback(() => treeToData(state.tree), [state.tree]);
122
+
123
+ const isDirty = state.tree !== state.initial;
124
+ const isValid = state.errorCount === 0;
125
+
126
+ return useMemo<UseDocumentEditorResult>(
127
+ () => ({
128
+ ...state,
129
+ isDirty,
130
+ isValid,
131
+ dispatch,
132
+ setValue,
133
+ setType,
134
+ setKey,
135
+ addMapEntry,
136
+ addArrayEntry,
137
+ remove,
138
+ reset,
139
+ replaceData,
140
+ touch,
141
+ touchAll,
142
+ toData,
143
+ }),
144
+ [
145
+ state,
146
+ isDirty,
147
+ isValid,
148
+ setValue,
149
+ setType,
150
+ setKey,
151
+ addMapEntry,
152
+ addArrayEntry,
153
+ remove,
154
+ reset,
155
+ replaceData,
156
+ touch,
157
+ touchAll,
158
+ toData,
159
+ ],
160
+ );
161
+ }