@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,242 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
|
+
import type {
|
|
3
|
+
CollectionReference,
|
|
4
|
+
DocumentReference,
|
|
5
|
+
Query,
|
|
6
|
+
QueryDocumentSnapshot,
|
|
7
|
+
} from 'pyric/firestore';
|
|
8
|
+
import { useFirestoreApi } from '../firestoreApi.js';
|
|
9
|
+
|
|
10
|
+
export interface UseDocumentListOptions {
|
|
11
|
+
collection: CollectionReference;
|
|
12
|
+
/** Optional filter / sort. If omitted, the raw collection is used. */
|
|
13
|
+
query?: Query;
|
|
14
|
+
/** Page size for cursor-based pagination. Default 50. */
|
|
15
|
+
pageSize?: number;
|
|
16
|
+
/**
|
|
17
|
+
* `paged` preserves the historical get-based cursor behavior. `live` keeps
|
|
18
|
+
* the currently requested window under an `onSnapshot` subscription and
|
|
19
|
+
* grows that window when `loadMore` is requested. Default `paged`.
|
|
20
|
+
*/
|
|
21
|
+
mode?: 'paged' | 'live';
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface UseDocumentListResult {
|
|
25
|
+
documents: QueryDocumentSnapshot[];
|
|
26
|
+
isLoading: boolean;
|
|
27
|
+
error: Error | undefined;
|
|
28
|
+
/** Identifies the active live subscription. Consumers that diff result
|
|
29
|
+
* snapshots can include this in their scope so a re-subscription (including
|
|
30
|
+
* load-more) establishes a silent baseline instead of looking like writes. */
|
|
31
|
+
subscriptionGeneration: number;
|
|
32
|
+
/** True if there might be another page. The hook tracks this via
|
|
33
|
+
* the last fetch's length === pageSize. */
|
|
34
|
+
hasMore: boolean;
|
|
35
|
+
/** Fetch the next page; live mode grows and re-establishes its window. */
|
|
36
|
+
loadMore: () => void;
|
|
37
|
+
/** Create a document. If `id` is null, Firestore generates one
|
|
38
|
+
* via `addDoc`. With `onExisting: 'fail'` (CREATE semantics, the
|
|
39
|
+
* admin `create()` analog) an id that already exists rejects with
|
|
40
|
+
* `code: 'already-exists'` instead of silently overwriting —
|
|
41
|
+
* checked against the BACKEND (a `getDoc` probe), not any loaded
|
|
42
|
+
* page, so it is honest beyond pagination. Default: 'overwrite'
|
|
43
|
+
* (plain `setDoc`, the historical behavior). */
|
|
44
|
+
createDocument: (
|
|
45
|
+
id: string | null,
|
|
46
|
+
data: Record<string, unknown>,
|
|
47
|
+
opts?: { onExisting?: 'overwrite' | 'fail' },
|
|
48
|
+
) => Promise<DocumentReference>;
|
|
49
|
+
deleteDocument: (ref: DocumentReference) => Promise<void>;
|
|
50
|
+
/** Re-establish the active read/subscription. Useful after the consumer
|
|
51
|
+
* mutates data outside this hook. */
|
|
52
|
+
refresh: () => void;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Paginated document list with two acquisition strategies. The default
|
|
57
|
+
* `paged` mode uses `startAfter` and accumulates one-shot reads. `live` keeps
|
|
58
|
+
* the requested prefix under one `onSnapshot` listener; loading more grows
|
|
59
|
+
* that prefix and establishes a new subscription baseline.
|
|
60
|
+
*/
|
|
61
|
+
export function useDocumentList({
|
|
62
|
+
collection,
|
|
63
|
+
query,
|
|
64
|
+
pageSize = 50,
|
|
65
|
+
mode = 'paged',
|
|
66
|
+
}: UseDocumentListOptions): UseDocumentListResult {
|
|
67
|
+
const [documents, setDocuments] = useState<QueryDocumentSnapshot[]>([]);
|
|
68
|
+
const [isLoading, setIsLoading] = useState(true);
|
|
69
|
+
const [error, setError] = useState<Error | undefined>(undefined);
|
|
70
|
+
const [hasMore, setHasMore] = useState(false);
|
|
71
|
+
const [tick, setTick] = useState(0);
|
|
72
|
+
const [requestedCount, setRequestedCount] = useState(pageSize);
|
|
73
|
+
const nextSubscriptionGeneration = useRef(0);
|
|
74
|
+
const [subscriptionGeneration, setSubscriptionGeneration] = useState(0);
|
|
75
|
+
|
|
76
|
+
// The modular fns, injected: in-process `pyric/firestore` by default, or the
|
|
77
|
+
// SharedWorker client bundle when a consumer (Pyric Studio served mode) wraps
|
|
78
|
+
// the tree in a `FirestoreApiProvider`. Stable across renders per provider.
|
|
79
|
+
const {
|
|
80
|
+
addDoc,
|
|
81
|
+
deleteDoc,
|
|
82
|
+
doc: docFn,
|
|
83
|
+
getDoc,
|
|
84
|
+
getDocs,
|
|
85
|
+
limit: limitFn,
|
|
86
|
+
onSnapshot,
|
|
87
|
+
query: queryFn,
|
|
88
|
+
setDoc,
|
|
89
|
+
startAfter: startAfterFn,
|
|
90
|
+
} = useFirestoreApi();
|
|
91
|
+
|
|
92
|
+
useEffect(() => {
|
|
93
|
+
setRequestedCount(pageSize);
|
|
94
|
+
}, [collection, mode, pageSize, query]);
|
|
95
|
+
|
|
96
|
+
useEffect(() => {
|
|
97
|
+
let cancelled = false;
|
|
98
|
+
if (mode === 'paged') setDocuments([]);
|
|
99
|
+
setHasMore(false);
|
|
100
|
+
setIsLoading(true);
|
|
101
|
+
setError(undefined);
|
|
102
|
+
const baseQuery = query ?? collection;
|
|
103
|
+
const fetchCount = mode === 'live' ? requestedCount : pageSize;
|
|
104
|
+
const pagedQuery = queryFn(baseQuery, limitFn(fetchCount));
|
|
105
|
+
const accept = (snap: { readonly docs: readonly QueryDocumentSnapshot[] }) => {
|
|
106
|
+
if (cancelled) return;
|
|
107
|
+
setDocuments([...snap.docs]);
|
|
108
|
+
setHasMore(snap.docs.length === fetchCount);
|
|
109
|
+
setIsLoading(false);
|
|
110
|
+
};
|
|
111
|
+
const reject = (e: unknown) => {
|
|
112
|
+
if (cancelled) return;
|
|
113
|
+
setError(e instanceof Error ? e : new Error(String(e)));
|
|
114
|
+
setIsLoading(false);
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
if (mode === 'live') {
|
|
118
|
+
const generation = ++nextSubscriptionGeneration.current;
|
|
119
|
+
const unsubscribe = onSnapshot(
|
|
120
|
+
pagedQuery,
|
|
121
|
+
(snap: unknown) => {
|
|
122
|
+
setSubscriptionGeneration(generation);
|
|
123
|
+
accept(snap as { readonly docs: readonly QueryDocumentSnapshot[] });
|
|
124
|
+
},
|
|
125
|
+
reject,
|
|
126
|
+
);
|
|
127
|
+
return () => {
|
|
128
|
+
cancelled = true;
|
|
129
|
+
unsubscribe();
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
getDocs(pagedQuery)
|
|
134
|
+
.then((snap) => {
|
|
135
|
+
accept(snap);
|
|
136
|
+
})
|
|
137
|
+
.catch(reject);
|
|
138
|
+
return () => {
|
|
139
|
+
cancelled = true;
|
|
140
|
+
};
|
|
141
|
+
}, [
|
|
142
|
+
collection,
|
|
143
|
+
getDocs,
|
|
144
|
+
limitFn,
|
|
145
|
+
mode,
|
|
146
|
+
onSnapshot,
|
|
147
|
+
pageSize,
|
|
148
|
+
query,
|
|
149
|
+
queryFn,
|
|
150
|
+
requestedCount,
|
|
151
|
+
tick,
|
|
152
|
+
]);
|
|
153
|
+
|
|
154
|
+
const loadMore = useCallback(() => {
|
|
155
|
+
if (!hasMore || isLoading) return;
|
|
156
|
+
if (mode === 'live') {
|
|
157
|
+
setIsLoading(true);
|
|
158
|
+
setRequestedCount((count) => count + pageSize);
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
const last = documents[documents.length - 1];
|
|
162
|
+
if (!last) return;
|
|
163
|
+
setIsLoading(true);
|
|
164
|
+
const baseQuery = query ?? collection;
|
|
165
|
+
const pagedQuery = queryFn(baseQuery, startAfterFn(last), limitFn(pageSize));
|
|
166
|
+
getDocs(pagedQuery)
|
|
167
|
+
.then((snap) => {
|
|
168
|
+
setDocuments((prev) => [...prev, ...snap.docs]);
|
|
169
|
+
setHasMore(snap.docs.length === pageSize);
|
|
170
|
+
setIsLoading(false);
|
|
171
|
+
})
|
|
172
|
+
.catch((e) => {
|
|
173
|
+
setError(e instanceof Error ? e : new Error(String(e)));
|
|
174
|
+
setIsLoading(false);
|
|
175
|
+
});
|
|
176
|
+
}, [
|
|
177
|
+
collection,
|
|
178
|
+
documents,
|
|
179
|
+
getDocs,
|
|
180
|
+
hasMore,
|
|
181
|
+
isLoading,
|
|
182
|
+
limitFn,
|
|
183
|
+
mode,
|
|
184
|
+
pageSize,
|
|
185
|
+
query,
|
|
186
|
+
queryFn,
|
|
187
|
+
startAfterFn,
|
|
188
|
+
]);
|
|
189
|
+
|
|
190
|
+
const createDocument = useCallback<UseDocumentListResult['createDocument']>(
|
|
191
|
+
async (id, data, opts) => {
|
|
192
|
+
if (id == null) {
|
|
193
|
+
const ref = await addDoc(collection, data);
|
|
194
|
+
if (mode === 'paged') setTick((n) => n + 1);
|
|
195
|
+
return ref;
|
|
196
|
+
}
|
|
197
|
+
const ref = docFn(collection, id);
|
|
198
|
+
if (opts?.onExisting === 'fail') {
|
|
199
|
+
// CREATE semantics without a batch/create primitive on FirestoreApi:
|
|
200
|
+
// probe the backend, then write. The probe is authoritative for the
|
|
201
|
+
// whole collection (not just a loaded page); the tiny read-then-write
|
|
202
|
+
// window is acceptable for the dev-sandbox surfaces this backs.
|
|
203
|
+
const existing = await getDoc(ref);
|
|
204
|
+
// `exists` is a method on the modular SDK snapshot but a boolean on
|
|
205
|
+
// some compat-shaped snapshots this bundle may be adapted over.
|
|
206
|
+
const exists =
|
|
207
|
+
typeof existing.exists === 'function' ? existing.exists() : Boolean(existing.exists);
|
|
208
|
+
if (exists) {
|
|
209
|
+
const err = new Error(`Document "${id}" already exists.`) as Error & { code: string };
|
|
210
|
+
err.code = 'already-exists';
|
|
211
|
+
throw err;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
await setDoc(ref, data);
|
|
215
|
+
if (mode === 'paged') setTick((n) => n + 1);
|
|
216
|
+
return ref;
|
|
217
|
+
},
|
|
218
|
+
[addDoc, collection, docFn, getDoc, mode, setDoc],
|
|
219
|
+
);
|
|
220
|
+
|
|
221
|
+
const deleteDocument = useCallback<UseDocumentListResult['deleteDocument']>(
|
|
222
|
+
async (ref) => {
|
|
223
|
+
await deleteDoc(ref);
|
|
224
|
+
if (mode === 'paged') setTick((n) => n + 1);
|
|
225
|
+
},
|
|
226
|
+
[deleteDoc, mode],
|
|
227
|
+
);
|
|
228
|
+
|
|
229
|
+
const refresh = useCallback(() => setTick((n) => n + 1), []);
|
|
230
|
+
|
|
231
|
+
return {
|
|
232
|
+
documents,
|
|
233
|
+
isLoading,
|
|
234
|
+
error,
|
|
235
|
+
subscriptionGeneration,
|
|
236
|
+
hasMore,
|
|
237
|
+
loadMore,
|
|
238
|
+
createDocument,
|
|
239
|
+
deleteDocument,
|
|
240
|
+
refresh,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'react';
|
|
2
|
+
import type {
|
|
3
|
+
CollectionReference,
|
|
4
|
+
DocumentReference,
|
|
5
|
+
Firestore,
|
|
6
|
+
} from 'pyric/firestore';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Lister for a document's own subcollections. Same injected-lister
|
|
10
|
+
* shape as `useCollectionList` / `ReferencePicker` use — the modular
|
|
11
|
+
* Web SDK doesn't expose a native `listCollections` on the client, so
|
|
12
|
+
* the caller wires it (sandbox in-process listing, a server proxy, or
|
|
13
|
+
* a known schema list).
|
|
14
|
+
*/
|
|
15
|
+
export type ListSubcollections = (
|
|
16
|
+
firestore: Firestore,
|
|
17
|
+
parent: DocumentReference,
|
|
18
|
+
) => Promise<CollectionReference[]>;
|
|
19
|
+
|
|
20
|
+
export interface UseDocumentSubcollectionsOptions {
|
|
21
|
+
firestore: Firestore;
|
|
22
|
+
/** The document whose subcollections to list. When `null`/`undefined`
|
|
23
|
+
* the hook stays idle (empty, not loading) — used when the preview
|
|
24
|
+
* has no ref to drill from. */
|
|
25
|
+
documentRef: DocumentReference | null | undefined;
|
|
26
|
+
listSubcollections: ListSubcollections;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface UseDocumentSubcollectionsResult {
|
|
30
|
+
subcollections: CollectionReference[];
|
|
31
|
+
isLoading: boolean;
|
|
32
|
+
error: Error | undefined;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Read a single document's subcollection list. A thin specialization of
|
|
37
|
+
* the `useCollectionList` pattern, scoped to one parent document and
|
|
38
|
+
* read-only (no create — that lives in `useCollectionList`).
|
|
39
|
+
*/
|
|
40
|
+
export function useDocumentSubcollections({
|
|
41
|
+
firestore,
|
|
42
|
+
documentRef,
|
|
43
|
+
listSubcollections,
|
|
44
|
+
}: UseDocumentSubcollectionsOptions): UseDocumentSubcollectionsResult {
|
|
45
|
+
const [subcollections, setSubcollections] = useState<CollectionReference[]>([]);
|
|
46
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
47
|
+
const [error, setError] = useState<Error | undefined>(undefined);
|
|
48
|
+
|
|
49
|
+
// Stable-ref the injected lister so a fresh closure identity each
|
|
50
|
+
// render doesn't loop the effect — same pattern as useCollectionList.
|
|
51
|
+
const listRef = useRef(listSubcollections);
|
|
52
|
+
listRef.current = listSubcollections;
|
|
53
|
+
|
|
54
|
+
// Key the effect on the ref's path (stable across snapshot identity
|
|
55
|
+
// churn) rather than the ref object, which the SDK may re-create.
|
|
56
|
+
const path = documentRef?.path;
|
|
57
|
+
|
|
58
|
+
useEffect(() => {
|
|
59
|
+
if (!documentRef) {
|
|
60
|
+
setSubcollections([]);
|
|
61
|
+
setIsLoading(false);
|
|
62
|
+
setError(undefined);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
let cancelled = false;
|
|
66
|
+
setIsLoading(true);
|
|
67
|
+
setError(undefined);
|
|
68
|
+
listRef.current(firestore, documentRef)
|
|
69
|
+
.then((list) => {
|
|
70
|
+
if (cancelled) return;
|
|
71
|
+
setSubcollections(list);
|
|
72
|
+
setIsLoading(false);
|
|
73
|
+
})
|
|
74
|
+
.catch((e) => {
|
|
75
|
+
if (cancelled) return;
|
|
76
|
+
setError(e instanceof Error ? e : new Error(String(e)));
|
|
77
|
+
setIsLoading(false);
|
|
78
|
+
});
|
|
79
|
+
return () => {
|
|
80
|
+
cancelled = true;
|
|
81
|
+
};
|
|
82
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
83
|
+
}, [firestore, path]);
|
|
84
|
+
|
|
85
|
+
return { subcollections, isLoading, error };
|
|
86
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
import type { Query, QuerySnapshot } from 'pyric/firestore';
|
|
3
|
+
import { coerceError } from './coerceError.js';
|
|
4
|
+
import type { SubscriptionState } from './useFirestoreDoc.js';
|
|
5
|
+
import { useFirestoreApi } from '../firestoreApi.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Subscribe to a Firestore query (a `Query` from `pyric/firestore`'s
|
|
9
|
+
* modular surface, including any `CollectionReference`, which extends
|
|
10
|
+
* `Query`). Returns `{ data, error, isLoading }`.
|
|
11
|
+
*
|
|
12
|
+
* Null/undefined query short-circuits to idle. Cleanup is automatic
|
|
13
|
+
* on unmount or query change. `Query` objects don't have a stable
|
|
14
|
+
* structural identity, so the consumer must memoize at the call site
|
|
15
|
+
* — pass the same instance across renders to avoid re-subscribing.
|
|
16
|
+
*/
|
|
17
|
+
export function useFirestoreCollection(
|
|
18
|
+
query: Query | null | undefined,
|
|
19
|
+
): SubscriptionState<QuerySnapshot> {
|
|
20
|
+
const { onSnapshot } = useFirestoreApi();
|
|
21
|
+
const [state, setState] = useState<SubscriptionState<QuerySnapshot>>(() => ({
|
|
22
|
+
data: undefined,
|
|
23
|
+
error: undefined,
|
|
24
|
+
isLoading: query != null,
|
|
25
|
+
}));
|
|
26
|
+
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
if (!query) {
|
|
29
|
+
setState({ data: undefined, error: undefined, isLoading: false });
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
setState((prev) => ({ data: prev.data, error: undefined, isLoading: true }));
|
|
34
|
+
|
|
35
|
+
const unsubscribe = onSnapshot(
|
|
36
|
+
query,
|
|
37
|
+
(snap) =>
|
|
38
|
+
setState({ data: snap as QuerySnapshot, error: undefined, isLoading: false }),
|
|
39
|
+
(err) =>
|
|
40
|
+
setState({
|
|
41
|
+
data: undefined,
|
|
42
|
+
error: coerceError(err),
|
|
43
|
+
isLoading: false,
|
|
44
|
+
}),
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
return unsubscribe;
|
|
48
|
+
}, [onSnapshot, query]);
|
|
49
|
+
|
|
50
|
+
return state;
|
|
51
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
import type { DocumentReference, DocumentSnapshot } from 'pyric/firestore';
|
|
3
|
+
import { coerceError } from './coerceError.js';
|
|
4
|
+
import { useFirestoreApi } from '../firestoreApi.js';
|
|
5
|
+
|
|
6
|
+
export interface SubscriptionState<T> {
|
|
7
|
+
data: T | undefined;
|
|
8
|
+
error: Error | undefined;
|
|
9
|
+
isLoading: boolean;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Subscribe to a single Firestore document. Returns `{ data, error,
|
|
14
|
+
* isLoading }`. Null/undefined ref short-circuits to an idle state
|
|
15
|
+
* (`data: undefined, error: undefined, isLoading: false`) — useful
|
|
16
|
+
* for conditional rendering before a ref is known.
|
|
17
|
+
*
|
|
18
|
+
* Cleanup is automatic on unmount or ref change. Memoize the ref at
|
|
19
|
+
* the call site; this hook's effect re-runs on identity change.
|
|
20
|
+
*/
|
|
21
|
+
export function useFirestoreDoc(
|
|
22
|
+
ref: DocumentReference | null | undefined,
|
|
23
|
+
): SubscriptionState<DocumentSnapshot> {
|
|
24
|
+
const { onSnapshot } = useFirestoreApi();
|
|
25
|
+
const [state, setState] = useState<SubscriptionState<DocumentSnapshot>>(() => ({
|
|
26
|
+
data: undefined,
|
|
27
|
+
error: undefined,
|
|
28
|
+
isLoading: ref != null,
|
|
29
|
+
}));
|
|
30
|
+
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
if (!ref) {
|
|
33
|
+
setState({ data: undefined, error: undefined, isLoading: false });
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
setState((prev) => ({ data: prev.data, error: undefined, isLoading: true }));
|
|
38
|
+
|
|
39
|
+
const unsubscribe = onSnapshot(
|
|
40
|
+
ref,
|
|
41
|
+
(snap) =>
|
|
42
|
+
setState({ data: snap as DocumentSnapshot, error: undefined, isLoading: false }),
|
|
43
|
+
(err) =>
|
|
44
|
+
setState({
|
|
45
|
+
data: undefined,
|
|
46
|
+
error: coerceError(err),
|
|
47
|
+
isLoading: false,
|
|
48
|
+
}),
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
return unsubscribe;
|
|
52
|
+
}, [onSnapshot, ref]);
|
|
53
|
+
|
|
54
|
+
return state;
|
|
55
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { useCallback, useMemo, useState } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
limit as limitFn,
|
|
4
|
+
orderBy as orderByFn,
|
|
5
|
+
query as queryFn,
|
|
6
|
+
where as whereFn,
|
|
7
|
+
type CollectionReference,
|
|
8
|
+
type OrderDirection,
|
|
9
|
+
type Query,
|
|
10
|
+
type WhereFilterOp,
|
|
11
|
+
} from 'pyric/firestore';
|
|
12
|
+
|
|
13
|
+
export type QueryOp = WhereFilterOp;
|
|
14
|
+
|
|
15
|
+
export const QUERY_OPS: readonly QueryOp[] = [
|
|
16
|
+
'==',
|
|
17
|
+
'!=',
|
|
18
|
+
'<',
|
|
19
|
+
'<=',
|
|
20
|
+
'>',
|
|
21
|
+
'>=',
|
|
22
|
+
'in',
|
|
23
|
+
'not-in',
|
|
24
|
+
'array-contains',
|
|
25
|
+
'array-contains-any',
|
|
26
|
+
] as const;
|
|
27
|
+
|
|
28
|
+
/** Ops that accept an array of values. The value editor in the
|
|
29
|
+
* bundled <QueryBuilder> parses the input as JSON for these. */
|
|
30
|
+
export const MULTI_VALUE_OPS: ReadonlySet<QueryOp> = new Set<QueryOp>([
|
|
31
|
+
'in',
|
|
32
|
+
'not-in',
|
|
33
|
+
'array-contains-any',
|
|
34
|
+
]);
|
|
35
|
+
|
|
36
|
+
export interface QueryCondition {
|
|
37
|
+
id: string;
|
|
38
|
+
field: string;
|
|
39
|
+
op: QueryOp;
|
|
40
|
+
value: unknown;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface QueryBuilderState {
|
|
44
|
+
conditions: QueryCondition[];
|
|
45
|
+
orderBy?: { field: string; direction: OrderDirection };
|
|
46
|
+
limit?: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface QueryBuilderActions {
|
|
50
|
+
addCondition: (c?: Partial<Omit<QueryCondition, 'id'>>) => void;
|
|
51
|
+
updateCondition: (
|
|
52
|
+
id: string,
|
|
53
|
+
patch: Partial<Omit<QueryCondition, 'id'>>,
|
|
54
|
+
) => void;
|
|
55
|
+
removeCondition: (id: string) => void;
|
|
56
|
+
setOrderBy: (orderBy?: { field: string; direction: OrderDirection }) => void;
|
|
57
|
+
setLimit: (limit?: number) => void;
|
|
58
|
+
reset: () => void;
|
|
59
|
+
/**
|
|
60
|
+
* Compose the state into a Firestore `Query`. Returns the base
|
|
61
|
+
* collection when there are no conditions / orderBy / limit.
|
|
62
|
+
* Conditions with empty `field` are skipped — the builder UI
|
|
63
|
+
* lets users add a row before they've filled it in.
|
|
64
|
+
*/
|
|
65
|
+
buildQuery: (base: CollectionReference | Query) => Query;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export type UseQueryBuilderResult = QueryBuilderState & QueryBuilderActions;
|
|
69
|
+
|
|
70
|
+
const EMPTY_STATE: QueryBuilderState = { conditions: [] };
|
|
71
|
+
|
|
72
|
+
export interface UseQueryBuilderOptions {
|
|
73
|
+
/** Pre-populate the builder. */
|
|
74
|
+
initial?: Partial<QueryBuilderState>;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Headless query-builder state machine. Single-level — no nested
|
|
79
|
+
* `and()`/`or()` groups in v1. Consumers compose the state into a
|
|
80
|
+
* Firestore `Query` via `buildQuery(base)` and feed that into
|
|
81
|
+
* `useDocumentList` / `useFirestoreCollection`.
|
|
82
|
+
*/
|
|
83
|
+
export function useQueryBuilder(
|
|
84
|
+
options: UseQueryBuilderOptions = {},
|
|
85
|
+
): UseQueryBuilderResult {
|
|
86
|
+
const [state, setState] = useState<QueryBuilderState>(() => ({
|
|
87
|
+
...EMPTY_STATE,
|
|
88
|
+
...options.initial,
|
|
89
|
+
conditions: options.initial?.conditions ?? [],
|
|
90
|
+
}));
|
|
91
|
+
|
|
92
|
+
const addCondition = useCallback<QueryBuilderActions['addCondition']>((c) => {
|
|
93
|
+
setState((prev) => ({
|
|
94
|
+
...prev,
|
|
95
|
+
conditions: [
|
|
96
|
+
...prev.conditions,
|
|
97
|
+
{
|
|
98
|
+
id: crypto.randomUUID(),
|
|
99
|
+
field: c?.field ?? '',
|
|
100
|
+
op: c?.op ?? '==',
|
|
101
|
+
value: c?.value ?? '',
|
|
102
|
+
},
|
|
103
|
+
],
|
|
104
|
+
}));
|
|
105
|
+
}, []);
|
|
106
|
+
|
|
107
|
+
const updateCondition = useCallback<QueryBuilderActions['updateCondition']>(
|
|
108
|
+
(id, patch) => {
|
|
109
|
+
setState((prev) => ({
|
|
110
|
+
...prev,
|
|
111
|
+
conditions: prev.conditions.map((c) =>
|
|
112
|
+
c.id === id ? { ...c, ...patch } : c,
|
|
113
|
+
),
|
|
114
|
+
}));
|
|
115
|
+
},
|
|
116
|
+
[],
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
const removeCondition = useCallback<QueryBuilderActions['removeCondition']>(
|
|
120
|
+
(id) => {
|
|
121
|
+
setState((prev) => ({
|
|
122
|
+
...prev,
|
|
123
|
+
conditions: prev.conditions.filter((c) => c.id !== id),
|
|
124
|
+
}));
|
|
125
|
+
},
|
|
126
|
+
[],
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
const setOrderBy = useCallback<QueryBuilderActions['setOrderBy']>((next) => {
|
|
130
|
+
setState((prev) => ({ ...prev, orderBy: next }));
|
|
131
|
+
}, []);
|
|
132
|
+
|
|
133
|
+
const setLimit = useCallback<QueryBuilderActions['setLimit']>((next) => {
|
|
134
|
+
setState((prev) => ({ ...prev, limit: next }));
|
|
135
|
+
}, []);
|
|
136
|
+
|
|
137
|
+
const reset = useCallback(() => {
|
|
138
|
+
setState(EMPTY_STATE);
|
|
139
|
+
}, []);
|
|
140
|
+
|
|
141
|
+
const buildQuery = useCallback<QueryBuilderActions['buildQuery']>(
|
|
142
|
+
(base) => {
|
|
143
|
+
const constraints = [] as ReturnType<typeof whereFn>[];
|
|
144
|
+
const orderByConstraints = [] as ReturnType<typeof orderByFn>[];
|
|
145
|
+
const limitConstraints = [] as ReturnType<typeof limitFn>[];
|
|
146
|
+
|
|
147
|
+
for (const cond of state.conditions) {
|
|
148
|
+
if (!cond.field) continue;
|
|
149
|
+
constraints.push(whereFn(cond.field, cond.op, cond.value));
|
|
150
|
+
}
|
|
151
|
+
if (state.orderBy && state.orderBy.field) {
|
|
152
|
+
orderByConstraints.push(
|
|
153
|
+
orderByFn(state.orderBy.field, state.orderBy.direction),
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
if (typeof state.limit === 'number' && state.limit > 0) {
|
|
157
|
+
limitConstraints.push(limitFn(state.limit));
|
|
158
|
+
}
|
|
159
|
+
const all = [...constraints, ...orderByConstraints, ...limitConstraints];
|
|
160
|
+
if (all.length === 0) return base as Query;
|
|
161
|
+
return queryFn(base, ...all);
|
|
162
|
+
},
|
|
163
|
+
[state],
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
return useMemo(
|
|
167
|
+
() => ({
|
|
168
|
+
...state,
|
|
169
|
+
addCondition,
|
|
170
|
+
updateCondition,
|
|
171
|
+
removeCondition,
|
|
172
|
+
setOrderBy,
|
|
173
|
+
setLimit,
|
|
174
|
+
reset,
|
|
175
|
+
buildQuery,
|
|
176
|
+
}),
|
|
177
|
+
[
|
|
178
|
+
state,
|
|
179
|
+
addCondition,
|
|
180
|
+
updateCondition,
|
|
181
|
+
removeCondition,
|
|
182
|
+
setOrderBy,
|
|
183
|
+
setLimit,
|
|
184
|
+
reset,
|
|
185
|
+
buildQuery,
|
|
186
|
+
],
|
|
187
|
+
);
|
|
188
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { useCallback, useRef, useState } from 'react';
|
|
2
|
+
import type { CollectionReference, DocumentReference } from 'pyric/firestore';
|
|
3
|
+
|
|
4
|
+
export interface RecursiveDeleteProgress {
|
|
5
|
+
/** Total nodes deleted so far. */
|
|
6
|
+
deletedCount: number;
|
|
7
|
+
/** True for the final emission. */
|
|
8
|
+
done: boolean;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Implementation injected by the consumer. The library doesn't ship
|
|
13
|
+
* one — sandbox-backed apps usually walk `pyric/sandbox`'s in-process
|
|
14
|
+
* tree; production apps usually call a Cloud Function. Either way,
|
|
15
|
+
* `start` returns an async iterator emitting progress.
|
|
16
|
+
*/
|
|
17
|
+
export interface RecursiveDeleteImpl {
|
|
18
|
+
start: (
|
|
19
|
+
target: DocumentReference | CollectionReference,
|
|
20
|
+
) => AsyncIterableIterator<RecursiveDeleteProgress>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface UseRecursiveDeleteResult {
|
|
24
|
+
/** Run the delete. Resolves when the iterator signals `done`. */
|
|
25
|
+
delete: (target: DocumentReference | CollectionReference) => Promise<void>;
|
|
26
|
+
/** Number of nodes deleted in the current/last run. */
|
|
27
|
+
progress: number;
|
|
28
|
+
/** True while an iteration is in flight. */
|
|
29
|
+
isRunning: boolean;
|
|
30
|
+
/** Error thrown by the iterator, if any. Cleared at the start of
|
|
31
|
+
* the next call. */
|
|
32
|
+
error: Error | undefined;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Drive a {@link RecursiveDeleteImpl} from a React component. Tracks
|
|
37
|
+
* progress + running state so the consumer can render a progress
|
|
38
|
+
* indicator. Errors are caught and surfaced via the returned state,
|
|
39
|
+
* not thrown.
|
|
40
|
+
*
|
|
41
|
+
* Stale-run protection: if the component remounts (or the user
|
|
42
|
+
* cancels and starts a new run) before a previous iteration
|
|
43
|
+
* finishes, the older run's progress updates are dropped via a
|
|
44
|
+
* generation token.
|
|
45
|
+
*/
|
|
46
|
+
export function useRecursiveDelete(
|
|
47
|
+
impl: RecursiveDeleteImpl,
|
|
48
|
+
): UseRecursiveDeleteResult {
|
|
49
|
+
const [progress, setProgress] = useState(0);
|
|
50
|
+
const [isRunning, setIsRunning] = useState(false);
|
|
51
|
+
const [error, setError] = useState<Error | undefined>(undefined);
|
|
52
|
+
const generationRef = useRef(0);
|
|
53
|
+
|
|
54
|
+
const del = useCallback(
|
|
55
|
+
async (target: DocumentReference | CollectionReference) => {
|
|
56
|
+
const myGen = ++generationRef.current;
|
|
57
|
+
setProgress(0);
|
|
58
|
+
setError(undefined);
|
|
59
|
+
setIsRunning(true);
|
|
60
|
+
try {
|
|
61
|
+
for await (const evt of impl.start(target)) {
|
|
62
|
+
if (myGen !== generationRef.current) return;
|
|
63
|
+
setProgress(evt.deletedCount);
|
|
64
|
+
if (evt.done) break;
|
|
65
|
+
}
|
|
66
|
+
} catch (e) {
|
|
67
|
+
if (myGen !== generationRef.current) return;
|
|
68
|
+
setError(e instanceof Error ? e : new Error(String(e)));
|
|
69
|
+
} finally {
|
|
70
|
+
if (myGen === generationRef.current) setIsRunning(false);
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
[impl],
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
return { delete: del, progress, isRunning, error };
|
|
77
|
+
}
|