@pyric/ui 0.1.0-alpha.11 → 0.1.0-alpha.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +5 -4
- package/src/agents/ContextWindowUsage.tsx +514 -0
- package/src/agents/EmptyState.tsx +27 -0
- package/src/agents/Fold.tsx +64 -0
- package/src/agents/Modal.tsx +65 -0
- package/src/agents/PulsingDot.tsx +28 -0
- package/src/agents/inbrowser-agent-usage.d.ts +141 -0
- package/src/agents/index.ts +28 -0
- package/src/auth/authApi.ts +72 -0
- package/src/auth/claims.ts +63 -0
- package/src/auth/components/AuthProviderToggles.tsx +147 -0
- package/src/auth/components/AuthSignInHelper.tsx +197 -0
- package/src/auth/components/AuthUserForm.tsx +328 -0
- package/src/auth/components/AuthUserList.tsx +219 -0
- package/src/auth/components/ClaimsField.tsx +50 -0
- package/src/auth/components/confirmActions.tsx +114 -0
- package/src/auth/controller.ts +173 -0
- package/src/auth/hooks/index.ts +36 -0
- package/src/auth/hooks/useAuthFlowHelper.ts +55 -0
- package/src/auth/hooks/useAuthProviderConfig.ts +139 -0
- package/src/auth/hooks/useAuthUserEditor.ts +76 -0
- package/src/auth/hooks/useAuthUsers.ts +154 -0
- package/src/auth/index.ts +42 -0
- package/src/auth/providers.ts +28 -0
- package/src/auth/reducers/userEditor.ts +186 -0
- package/src/events/components/ActivityActionItems.tsx +136 -0
- package/src/events/components/ActivityGrid.tsx +197 -0
- package/src/events/components/ActivityGridRow.tsx +65 -0
- package/src/events/components/ProposedChangeDiff.tsx +175 -0
- package/src/events/components/format.ts +21 -0
- package/src/events/components/index.ts +17 -0
- package/src/events/digest.ts +630 -0
- package/src/events/hooks/index.ts +9 -0
- package/src/events/hooks/useActivityDigest.ts +42 -0
- package/src/events/hooks/useActivityStream.ts +86 -0
- package/src/events/index.ts +39 -0
- package/src/events/types.ts +152 -0
- package/src/firestore/components/CollectionList.tsx +78 -0
- package/src/firestore/components/DeleteWithConfirm.tsx +88 -0
- package/src/firestore/components/DocumentEditor.tsx +350 -0
- package/src/firestore/components/DocumentList.tsx +217 -0
- package/src/firestore/components/DocumentPreview.tsx +265 -0
- package/src/firestore/components/FieldRenderer.tsx +25 -0
- package/src/firestore/components/QueryBuilder.tsx +181 -0
- package/src/firestore/components/ReferencePicker.tsx +212 -0
- package/src/firestore/components/TreeEntry.tsx +58 -0
- package/src/firestore/components/context.ts +25 -0
- package/src/firestore/fieldEditors/array.tsx +30 -0
- package/src/firestore/fieldEditors/boolean.tsx +39 -0
- package/src/firestore/fieldEditors/bytes.tsx +53 -0
- package/src/firestore/fieldEditors/geopoint.tsx +71 -0
- package/src/firestore/fieldEditors/map.tsx +33 -0
- package/src/firestore/fieldEditors/null.tsx +27 -0
- package/src/firestore/fieldEditors/number.tsx +43 -0
- package/src/firestore/fieldEditors/reference.tsx +87 -0
- package/src/firestore/fieldEditors/registry.ts +45 -0
- package/src/firestore/fieldEditors/string.tsx +33 -0
- package/src/firestore/fieldEditors/timestamp.tsx +75 -0
- package/src/firestore/fieldEditors/types.ts +68 -0
- package/src/firestore/fieldEditors/vector.tsx +142 -0
- package/src/firestore/firestoreApi.ts +86 -0
- package/src/firestore/hooks/coerceError.ts +34 -0
- package/src/firestore/hooks/index.ts +46 -0
- package/src/firestore/hooks/useCollectionList.ts +102 -0
- package/src/firestore/hooks/useDocumentEditor.ts +161 -0
- package/src/firestore/hooks/useDocumentList.ts +242 -0
- package/src/firestore/hooks/useDocumentSubcollections.ts +86 -0
- package/src/firestore/hooks/useFirestoreCollection.ts +51 -0
- package/src/firestore/hooks/useFirestoreDoc.ts +55 -0
- package/src/firestore/hooks/useQueryBuilder.ts +188 -0
- package/src/firestore/hooks/useRecursiveDelete.ts +77 -0
- package/src/firestore/hooks/useReferencePicker.ts +228 -0
- package/src/firestore/import/parseImport.ts +137 -0
- package/src/firestore/index.ts +87 -0
- package/src/firestore/reducers/defaults.ts +38 -0
- package/src/firestore/reducers/documentEditor.ts +239 -0
- package/src/firestore/reducers/tree.ts +140 -0
- package/src/firestore/reducers/types.ts +92 -0
- package/src/firestore/reducers/validation.ts +129 -0
- package/src/firestore/types.ts +231 -0
- package/src/firestore/validation/ids.ts +45 -0
- package/src/firestore/valueEquality.ts +43 -0
- package/src/index.ts +10 -0
- package/src/primitives/Badge.tsx +40 -0
- package/src/primitives/ConfirmDialog.tsx +137 -0
- package/src/primitives/CopyButton.tsx +62 -0
- package/src/primitives/JsonView.tsx +151 -0
- package/src/primitives/SegmentedControl.tsx +72 -0
- package/src/primitives/Toast.tsx +161 -0
- package/src/primitives/VirtualList.tsx +104 -0
- package/src/primitives/hooks/useContainerSize.ts +53 -0
- package/src/primitives/hooks/useUpdateHighlights.ts +109 -0
- package/src/primitives/index.ts +39 -0
- package/src/primitives/useConfirm.tsx +113 -0
- package/src/rtdb/components/RtdbPathBar.tsx +135 -0
- package/src/rtdb/components/RtdbTree.tsx +409 -0
- package/src/rtdb/editor.ts +79 -0
- package/src/rtdb/hooks/useRtdbTree.ts +137 -0
- package/src/rtdb/index.ts +66 -0
- package/src/rtdb/pathInput.ts +47 -0
- package/src/rtdb/reducers/tree.ts +191 -0
- package/src/rtdb/rtdbApi.ts +23 -0
- package/src/rtdb/values.ts +188 -0
- package/src/rules/components/DenialInspector.tsx +227 -0
- package/src/rules/components/format.ts +171 -0
- package/src/rules/components/index.ts +12 -0
- package/src/rules/components/scope.ts +75 -0
- package/src/rules/hooks/index.ts +5 -0
- package/src/rules/hooks/useDenialTrace.ts +100 -0
- package/src/rules/index.ts +24 -0
- package/src/rules/types.ts +91 -0
- package/src/storage/collisionRename.ts +114 -0
- package/src/storage/components/DeleteSelectionWithConfirm.tsx +193 -0
- package/src/storage/components/ObjectBrowser.tsx +219 -0
- package/src/storage/components/ObjectInspector.tsx +169 -0
- package/src/storage/components/PathBreadcrumb.tsx +84 -0
- package/src/storage/components/UploadDropzone.tsx +182 -0
- package/src/storage/folderPlaceholder.ts +40 -0
- package/src/storage/hooks/index.ts +59 -0
- package/src/storage/hooks/useMetadataEditor.ts +329 -0
- package/src/storage/hooks/useObjectUpload.ts +262 -0
- package/src/storage/hooks/usePathState.ts +94 -0
- package/src/storage/hooks/useStorageDelete.ts +195 -0
- package/src/storage/hooks/useStorageList.ts +261 -0
- package/src/storage/hooks/useStorageObject.ts +162 -0
- package/src/storage/hooks/useStorageRulesGate.ts +270 -0
- package/src/storage/hooks/useStorageSelection.ts +90 -0
- package/src/storage/index.ts +59 -0
- package/src/storage/pendingPrefixes.ts +125 -0
- package/src/storage/previews.tsx +120 -0
- package/src/storage/storageApi.ts +54 -0
- package/src/traffic/components/RuleHeatmap.tsx +97 -0
- package/src/traffic/components/TrafficDetail.tsx +160 -0
- package/src/traffic/components/TrafficGroupRow.tsx +91 -0
- package/src/traffic/components/TrafficLineChart.tsx +139 -0
- package/src/traffic/components/TrafficLog.tsx +175 -0
- package/src/traffic/components/TrafficMetricCards.tsx +77 -0
- package/src/traffic/components/TrafficRow.tsx +69 -0
- package/src/traffic/components/TrafficStats.tsx +73 -0
- package/src/traffic/components/TrafficTimeline.tsx +289 -0
- package/src/traffic/components/format.ts +22 -0
- package/src/traffic/components/index.ts +22 -0
- package/src/traffic/hooks/index.ts +60 -0
- package/src/traffic/hooks/useRuleHeatmap.ts +92 -0
- package/src/traffic/hooks/useTrafficBuckets.ts +146 -0
- package/src/traffic/hooks/useTrafficFilter.ts +74 -0
- package/src/traffic/hooks/useTrafficGroups.ts +126 -0
- package/src/traffic/hooks/useTrafficMetrics.ts +250 -0
- package/src/traffic/hooks/useTrafficMonitor.ts +111 -0
- package/src/traffic/hooks/useTrafficStats.ts +77 -0
- package/src/traffic/index.ts +13 -0
- package/src/traffic/types.ts +85 -0
|
@@ -0,0 +1,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
|
+
}
|