lemma-sdk 0.2.34 → 0.2.35
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/README.md +56 -135
- package/dist/openapi_client/models/DatastoreDataType.d.ts +2 -0
- package/dist/openapi_client/models/DatastoreDataType.js +2 -0
- package/dist/react/index.d.ts +14 -0
- package/dist/react/index.js +7 -0
- package/dist/react/sql-utils.d.ts +8 -0
- package/dist/react/sql-utils.js +62 -0
- package/dist/react/useCreateFolder.d.ts +22 -0
- package/dist/react/useCreateFolder.js +47 -0
- package/dist/react/useDatastoreQuery.d.ts +22 -0
- package/dist/react/useDatastoreQuery.js +67 -0
- package/dist/react/useDeleteFile.d.ts +19 -0
- package/dist/react/useDeleteFile.js +51 -0
- package/dist/react/useGlobalSearch.d.ts +62 -0
- package/dist/react/useGlobalSearch.js +170 -0
- package/dist/react/useRecordAggregates.d.ts +35 -0
- package/dist/react/useRecordAggregates.js +126 -0
- package/dist/react/useTaskSession.js +3 -2
- package/dist/react/useUpdateFile.d.ts +29 -0
- package/dist/react/useUpdateFile.js +51 -0
- package/dist/react/useUploadFile.d.ts +24 -0
- package/dist/react/useUploadFile.js +46 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# Lemma TypeScript SDK
|
|
2
2
|
|
|
3
|
-
`lemma-sdk` is the headless TypeScript SDK for Lemma. Use `lemma-sdk` for the core client, `lemma-sdk/react`
|
|
3
|
+
`lemma-sdk` is the headless TypeScript SDK for Lemma. Use `lemma-sdk` for the core client and shared helpers, and use `lemma-sdk/react` as the main app-building surface for hooks and auth primitives.
|
|
4
4
|
|
|
5
|
-
`AuthGuard` intentionally stays in `lemma-sdk/react`.
|
|
5
|
+
`AuthGuard` intentionally stays in `lemma-sdk/react`. The product direction is hooks-first and shell-agnostic. The registry remains available, but it is intentionally small and no longer the center of the recommended development model. See [docs/headless-first-direction.md](docs/headless-first-direction.md).
|
|
6
6
|
|
|
7
7
|
## Install
|
|
8
8
|
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
npm install lemma-sdk
|
|
11
11
|
```
|
|
12
12
|
|
|
13
|
-
If your app
|
|
13
|
+
If your app wants stock Lemma UI installs, configure the registry with:
|
|
14
14
|
|
|
15
15
|
```bash
|
|
16
16
|
npx lemma-sdk init-shadcn
|
|
@@ -74,10 +74,10 @@ import {
|
|
|
74
74
|
| Area | Hooks | Stability | Use when |
|
|
75
75
|
| --- | --- | --- | --- |
|
|
76
76
|
| Auth | `AuthGuard`, `useAuth`, `useCurrentUser`, `usePodAccess` | Stable | Gate an app, read signed-in user state, or request pod access. |
|
|
77
|
-
| Tables | `useTables`, `useTable`, `useRecords`, `useRecord`, `useJoinedRecords`, `useRelatedRecords`, `useReverseRelatedRecords`, `useReferencingRecords` | Stable | Build custom table browsers, details views, related-record views, and
|
|
77
|
+
| Tables | `useTables`, `useTable`, `useRecords`, `useRecord`, `useJoinedRecords`, `useRelatedRecords`, `useReverseRelatedRecords`, `useReferencingRecords`, `useDatastoreQuery`, `useRecordAggregates` | Stable | Build custom table browsers, details views, related-record views, raw SQL-backed reads, and chart/KPI queries. |
|
|
78
78
|
| Record mutations | `useCreateRecord`, `useUpdateRecord`, `useDeleteRecord`, `useBulkRecords` | Stable | Create, update, delete, or bulk-delete rows from headless UI. Function-backed mutations via `createVia`/`updateVia` options. |
|
|
79
79
|
| Record forms | `useRecordSchema`, `useRecordForm`, `useForeignKeyOptions`, `useSchemaForm` | Stable | Render schema-driven record forms, enum fields, and foreign-key selectors. `useRecordForm` is the canonical table-bound form hook; `useSchemaForm` remains available for raw JSON-schema flows. |
|
|
80
|
-
| Files | `useFiles`, `useFile`, `useFileSearch`, `useFileTree`, `useFilePreview` | Stable | Browse datastore folders,
|
|
80
|
+
| Files | `useFiles`, `useFile`, `useUploadFile`, `useUpdateFile`, `useDeleteFile`, `useCreateFolder`, `useFileSearch`, `useFileTree`, `useFilePreview`, `useGlobalSearch` | Stable | Browse datastore folders, mutate file state, search indexed files, load directory trees, preview content, and compose multi-source desk search. |
|
|
81
81
|
| Assistant | `useConversations`, `useConversation`, `useConversationMessages`, `useAssistantRun`, `useAssistantSession`, `useAssistantRuntime`, `useAssistantController` | Stable except controller/runtime | Build custom chat, conversation lists, streaming output, and final-output views. |
|
|
82
82
|
| Agents | `useAgentRun`, `useAgentRuns`, `useAgentInputSchema`, `useTaskSession` | Stable except raw session | Start agent tasks, submit follow-up input, read task history, and inspect input/output schemas. |
|
|
83
83
|
| Workflows | `useWorkflowStart`, `useWorkflowRun`, `useWorkflowRuns`, `useWorkflowResume` | Stable | Start, poll, resume, cancel, retry, and inspect workflow runs. |
|
|
@@ -85,6 +85,13 @@ import {
|
|
|
85
85
|
| Functions | `useFunctionRun`, `useFunctionRuns`, `useFunctionSession` | Stable except raw session | Run functions, poll function runs, and list function history. |
|
|
86
86
|
| Members and org | `useMembers`, `useAddPodMember`, `useUpdatePodMemberRole`, `useRemovePodMember`, `useOrganizationMembers` | Stable | Read pod and organization members, add existing org members into a pod, update pod roles, and remove pod access. The current checked-in client does not yet expose direct email-to-pod invites. |
|
|
87
87
|
|
|
88
|
+
### Headless Helpers
|
|
89
|
+
|
|
90
|
+
Alongside hooks, `lemma-sdk` exports shared headless helpers for common desk logic:
|
|
91
|
+
|
|
92
|
+
- record display helpers: `formatRecordDisplayValue`, `humanizeRecordFieldName`, `detectRecordStatusColumn`
|
|
93
|
+
- form/schema helpers: `buildRecordSchemaFields`, `buildSchemaFormFields`
|
|
94
|
+
|
|
88
95
|
### Common Hook Shapes
|
|
89
96
|
|
|
90
97
|
For business-facing examples and a decision guide mapping "I want to..." to the right hook, see [docs/hooks-guide.md](docs/hooks-guide.md).
|
|
@@ -209,44 +216,33 @@ function WorkflowButton({ client }: { client: LemmaClient }) {
|
|
|
209
216
|
}
|
|
210
217
|
```
|
|
211
218
|
|
|
212
|
-
##
|
|
219
|
+
## Registry
|
|
213
220
|
|
|
214
|
-
|
|
221
|
+
The registry is optional UI scaffolding, not the default product story. Most desks should be built from hooks and app-local UI.
|
|
222
|
+
|
|
223
|
+
If you still want registry installs, they remain available:
|
|
215
224
|
|
|
216
225
|
After running `npx lemma-sdk init-shadcn`, install blocks like:
|
|
217
226
|
|
|
218
227
|
```bash
|
|
219
|
-
npx shadcn@latest add @lemma/lemma-
|
|
220
|
-
npx shadcn@latest add @lemma/lemma-detail-panel
|
|
221
|
-
npx shadcn@latest add @lemma/lemma-record-form
|
|
222
|
-
npx shadcn@latest add @lemma/lemma-global-search
|
|
223
|
-
npx shadcn@latest add @lemma/lemma-file-browser
|
|
224
|
-
npx shadcn@latest add @lemma/lemma-file-viewer
|
|
228
|
+
npx shadcn@latest add @lemma/lemma-assistant-experience
|
|
225
229
|
npx shadcn@latest add @lemma/lemma-document-workspace
|
|
226
|
-
npx shadcn@latest add @lemma/lemma-
|
|
227
|
-
npx shadcn@latest add @lemma/lemma-document-viewer
|
|
228
|
-
npx shadcn@latest add @lemma/lemma-document-editor
|
|
230
|
+
npx shadcn@latest add @lemma/lemma-global-search
|
|
229
231
|
npx shadcn@latest add @lemma/lemma-members
|
|
230
|
-
npx shadcn@latest add @lemma/lemma-
|
|
231
|
-
npx shadcn@latest add @lemma/lemma-insights
|
|
232
|
-
npx shadcn@latest add @lemma/lemma-assistant-experience
|
|
232
|
+
npx shadcn@latest add @lemma/lemma-action-surface
|
|
233
233
|
```
|
|
234
234
|
|
|
235
|
-
|
|
235
|
+
The registry is intentionally small now. It currently ships 5 published blocks.
|
|
236
236
|
|
|
237
237
|
Current registry items:
|
|
238
238
|
|
|
239
239
|
| Area | Items |
|
|
240
240
|
| --- | --- |
|
|
241
241
|
| Assistant | `lemma-assistant-experience` |
|
|
242
|
-
|
|
|
243
|
-
|
|
|
244
|
-
|
|
|
245
|
-
|
|
|
246
|
-
| People | `lemma-members`, `lemma-notification-bell`, `lemma-user-menu` |
|
|
247
|
-
| Analytics | `lemma-insights` |
|
|
248
|
-
| Collaboration | `lemma-activity-feed`, `lemma-comments` |
|
|
249
|
-
| Automation | `lemma-workflow-runner` |
|
|
242
|
+
| Documents | `lemma-document-workspace` |
|
|
243
|
+
| Search | `lemma-global-search` |
|
|
244
|
+
| People | `lemma-members` |
|
|
245
|
+
| Automation | `lemma-action-surface` |
|
|
250
246
|
|
|
251
247
|
The registry is currently served from jsDelivr against this public repo:
|
|
252
248
|
|
|
@@ -255,60 +251,13 @@ The registry is currently served from jsDelivr against this public repo:
|
|
|
255
251
|
|
|
256
252
|
For more stable installs, pin the registry URL to a tag or commit SHA instead of `@main`.
|
|
257
253
|
|
|
258
|
-
|
|
254
|
+
Published blocks:
|
|
259
255
|
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
The records blocks are meant to be configured with props before you reach for a fork.
|
|
267
|
-
|
|
268
|
-
`lemma-records-view` supports:
|
|
269
|
-
|
|
270
|
-
- `preset="triage" | "issues" | "crm" | "docs"` for opinionated defaults without installing duplicate workflow blocks
|
|
271
|
-
- `defaultView` and `availableViews` across `grid`, `list`, `grouped`, `kanban`, `linear`, `calendar`, `timeline`, and `matrix`
|
|
272
|
-
- `tableName`, `visibleColumns`, and `hiddenFields` for schema-aware display
|
|
273
|
-
- `pinnedColumns`, `columnWidths`, `columnLabels`, `primaryField`, `defaultSort`, and `paginationMode` for stronger operator-table defaults
|
|
274
|
-
- `groupBy`, `calendarField`, `timelineField`, `matrixRowsBy`, and `matrixColumnsBy` for consolidated view configuration
|
|
275
|
-
- `renderCell` and `renderCard` for custom record rendering
|
|
276
|
-
- `foreignKeyLabels` for human-readable FK values in cards, detail views, and create/edit forms
|
|
277
|
-
- `detailTabs`, `detailFieldGroups`, `detailRelatedRecords`, `detailSectionLabels`, and `detailSectionVisibility` for canonical detail composition
|
|
278
|
-
- `quickActions`, `bulkActions`, `detailActions`, `quickActionMode`, and `onQuickActionSuccess` for direct, function, or workflow-backed actions
|
|
279
|
-
- `onCreateOptions` and `onUpdateOptions` for function-backed mutations, including conditional field and section visibility in the create sheet
|
|
280
|
-
- `createMode="sheet" | "modal" | "page"` and `detailMode="sheet" | "modal" | "page" | "inline"` for app-specific interaction patterns
|
|
281
|
-
- `headerActions`, `emptyState`, `onRecordClick`, `renderFilesTab`, `renderCommentsTab`, and `renderActivityTab` for app-specific extensions
|
|
282
|
-
- `appearance="default" | "minimal" | "borderless" | "contained"` and `density="compact" | "comfortable" | "spacious"` for host-level block chrome; `minimal` is the cardless mode
|
|
283
|
-
|
|
284
|
-
`lemma-detail-panel` supports:
|
|
285
|
-
|
|
286
|
-
- standalone record detail rendering outside the full records workspace, using the same canonical detail internals as `lemma-records-view`
|
|
287
|
-
- `mode`, `variant`, and `layout` controls for embedded, sheet, modal, or full-page use
|
|
288
|
-
- built-in detail tabs plus custom `tabs`, `relatedRecords`, and `renderFiles` / `renderComments` / `renderActivity` sections
|
|
289
|
-
- direct, function-backed, and workflow-aware `actions`, plus `updateVia` / `updateFunctionName` for inline edits
|
|
290
|
-
- shared `appearance`, `density`, and `radius` controls for use in inline, sheet, modal, or page layouts
|
|
291
|
-
|
|
292
|
-
`lemma-record-form` supports:
|
|
293
|
-
|
|
294
|
-
- `mode="inline" | "modal" | "sheet"`
|
|
295
|
-
- `submitVia="direct" | "function"` and `submitFunctionName`
|
|
296
|
-
- `submitFunctionInput` when the backing function expects a different payload shape
|
|
297
|
-
- `hiddenFields`, `visibleFields`, `fieldOrder`, and `fieldGroups`
|
|
298
|
-
- searchable FK inputs through the shared `record-form-fields` control layer
|
|
299
|
-
- `fieldVisibility` and `sectionVisibility` for conditional forms
|
|
300
|
-
- `foreignKeyLabels` for FK select labels
|
|
301
|
-
- `initialValues`, `onSuccess`, and `onClose`
|
|
302
|
-
- `appearance`, `density`, and `radius` using the same values as `lemma-records-view`
|
|
303
|
-
|
|
304
|
-
`lemma-insights` supports:
|
|
305
|
-
|
|
306
|
-
- table-backed count, sum, average, and grouped chart cards
|
|
307
|
-
- bar, line, area, pie, and funnel charts with count/sum/avg aggregation
|
|
308
|
-
- `aggregationMode="client" | "function"` with optional `aggregateFunctionName` for shared server-side aggregation
|
|
309
|
-
- chart descriptions, value/category formatters, limits, sorting, empty states, and optional footers
|
|
310
|
-
- function-backed stats and charts
|
|
311
|
-
- shared `appearance`, `density`, `radius`, and card-column controls
|
|
256
|
+
- `lemma-assistant-experience` for the hardest assistant/chat runtime surface
|
|
257
|
+
- `lemma-document-workspace` for rich document/file create-read-edit-preview flows
|
|
258
|
+
- `lemma-global-search` for a stock command-bar style omnibox
|
|
259
|
+
- `lemma-members` for stock pod membership management
|
|
260
|
+
- `lemma-action-surface` for long-running function/workflow/agent launches
|
|
312
261
|
|
|
313
262
|
`lemma-global-search` supports:
|
|
314
263
|
|
|
@@ -318,26 +267,12 @@ The records blocks are meant to be configured with props before you reach for a
|
|
|
318
267
|
- `minQueryLength`, `debounceMs`, `appearance`, `density`, trigger label, and placeholder customization
|
|
319
268
|
- assistant handoff by `assistantName`, with optional query/results message shaping and conversation routing
|
|
320
269
|
|
|
321
|
-
Navigation blocks support:
|
|
322
|
-
|
|
323
|
-
- route, record, and file-path breadcrumb builders through `lemma-breadcrumbs`
|
|
324
|
-
- self-referential page hierarchies through `lemma-page-tree`, with selection, expansion, and create/reorder hooks
|
|
325
|
-
|
|
326
|
-
File blocks support:
|
|
327
|
-
|
|
328
|
-
- datastore folder navigation and path breadcrumbs through `lemma-file-browser`
|
|
329
|
-
- pod-level file browsing and search, not only record-linked attachments
|
|
330
|
-
- upload, download, search, rename, move, folder creation, picker mode, and composition-friendly link actions
|
|
331
|
-
- selection-aware file browsing so `lemma-file-browser` can drive a paired workspace preview
|
|
332
|
-
- image, PDF, text, markdown, converted HTML, metadata, and breadcrumb-aware previews through `lemma-file-viewer`
|
|
333
|
-
|
|
334
270
|
Document blocks support:
|
|
335
271
|
|
|
336
|
-
- Notion/Coda-style block documents through `lemma-document-workspace`, with
|
|
337
|
-
-
|
|
338
|
-
-
|
|
339
|
-
-
|
|
340
|
-
- a clean separation between richer document UX and the lower-level pod file workspace, while keeping pod files as the primary document store and avoiding embedded-first document editing
|
|
272
|
+
- Notion/Coda-style block documents through `lemma-document-workspace`, with ProseKit JSON content, page/modal modes, title and summary chrome, file/reference/assistant blocks, save state, metadata, backlinks, and assistant-context rails
|
|
273
|
+
- pod-file-native creation, reading, editing, and preview through one workspace, with folder targeting, title/summary setup, pod-file metadata, and `mode="page" | "modal"`
|
|
274
|
+
- non-document file previews through the same workspace, including image, PDF, text, markdown, converted HTML, and download fallback behavior
|
|
275
|
+
- records and attachments should pass pod file paths into `lemma-document-workspace`; records should not own document bodies directly
|
|
341
276
|
|
|
342
277
|
People blocks support:
|
|
343
278
|
|
|
@@ -348,50 +283,22 @@ People blocks support:
|
|
|
348
283
|
|
|
349
284
|
Workflow primitives support:
|
|
350
285
|
|
|
351
|
-
-
|
|
352
|
-
-
|
|
353
|
-
- workflow
|
|
354
|
-
- reusable history and collaboration surfaces through `lemma-activity-feed` and `lemma-comments`
|
|
355
|
-
- table-backed defaults with escape hatches for custom action payloads and render slots
|
|
356
|
-
|
|
357
|
-
`lemma-markdown-editor` supports:
|
|
358
|
-
|
|
359
|
-
- write, preview, and split modes
|
|
360
|
-
- controlled and uncontrolled values
|
|
361
|
-
- GitHub-flavored markdown preview via `react-markdown` and `remark-gfm`
|
|
362
|
-
- a lightweight interim editing lane for plain markdown notes beside the richer block-native `lemma-document-workspace`
|
|
286
|
+
- direct, function-backed, workflow-backed, and agent-backed launches through `lemma-action-surface`
|
|
287
|
+
- inline, row, and panel presentation modes for long-running actions with inspectable progress
|
|
288
|
+
- native workflow/file surfaces plus app-local record UIs where tables are the actual product data model
|
|
363
289
|
|
|
364
290
|
Assistant blocks support:
|
|
365
291
|
|
|
366
292
|
- assistant-name-first configuration through `assistantName`
|
|
367
293
|
- shared `appearance` and `density` controls on the assistant experience surface
|
|
368
294
|
- `chromeStyle`, `statusPlacement`, `radius`, model picker, conversation list, and render overrides for deeper customization
|
|
369
|
-
|
|
370
|
-
Shell blocks support:
|
|
371
|
-
|
|
372
|
-
- `lemma-notification-bell` for unread counts, popover inboxes, and mark-as-read flows
|
|
373
|
-
- `lemma-user-menu` for current-user presentation, custom menu actions, and sign-out affordances
|
|
295
|
+
- bounded default heights for `page` and `side-panel` modes so the message viewport scrolls instead of stretching with content; pass `className="h-full min-h-0"` inside an explicit-height parent when you want a fill-layout assistant like inbox CRM
|
|
374
296
|
|
|
375
297
|
```tsx
|
|
376
|
-
import {
|
|
298
|
+
import { LemmaAssistantExperience } from "@/components/lemma/assistant/assistant-experience";
|
|
299
|
+
import { LemmaActionSurface } from "@/components/lemma/lemma-action-surface";
|
|
377
300
|
import { LemmaGlobalSearch } from "@/components/lemma/lemma-global-search";
|
|
378
301
|
|
|
379
|
-
<LemmaRecordsView
|
|
380
|
-
client={client}
|
|
381
|
-
podId={podId}
|
|
382
|
-
tableName="deals"
|
|
383
|
-
preset="crm"
|
|
384
|
-
hiddenFields={["id", "created_at", "updated_at"]}
|
|
385
|
-
foreignKeyLabels={{ company_id: "name" }}
|
|
386
|
-
appearance="minimal"
|
|
387
|
-
density="compact"
|
|
388
|
-
createMode="sheet"
|
|
389
|
-
onCreateOptions={{
|
|
390
|
-
submitVia: "function",
|
|
391
|
-
submitFunctionName: "create-deal",
|
|
392
|
-
}}
|
|
393
|
-
/>;
|
|
394
|
-
|
|
395
302
|
<LemmaGlobalSearch
|
|
396
303
|
client={client}
|
|
397
304
|
podId={podId}
|
|
@@ -416,6 +323,20 @@ import { LemmaGlobalSearch } from "@/components/lemma/lemma-global-search";
|
|
|
416
323
|
appearance="minimal"
|
|
417
324
|
density="compact"
|
|
418
325
|
/>;
|
|
326
|
+
|
|
327
|
+
<LemmaActionSurface
|
|
328
|
+
client={client}
|
|
329
|
+
podId={podId}
|
|
330
|
+
label="Run triage"
|
|
331
|
+
kind="workflow"
|
|
332
|
+
workflowName="triage-lead"
|
|
333
|
+
/>;
|
|
334
|
+
|
|
335
|
+
<LemmaAssistantExperience
|
|
336
|
+
client={client}
|
|
337
|
+
assistantName="sales-copilot"
|
|
338
|
+
density="compact"
|
|
339
|
+
/>;
|
|
419
340
|
```
|
|
420
341
|
|
|
421
342
|
## Auth
|
|
@@ -455,14 +376,14 @@ npm run build
|
|
|
455
376
|
npm run registry:build
|
|
456
377
|
```
|
|
457
378
|
|
|
458
|
-
To build the
|
|
379
|
+
To build the single local sandbox app:
|
|
459
380
|
|
|
460
381
|
```bash
|
|
461
382
|
cd examples/inbox-crm
|
|
462
383
|
npm run build
|
|
463
384
|
```
|
|
464
385
|
|
|
465
|
-
`examples/inbox-crm`
|
|
386
|
+
`examples/inbox-crm` is the only kept example app in this repo. It is a local sandbox for visualizing the current direction, not a promise that every copied component inside it is a published registry block.
|
|
466
387
|
|
|
467
388
|
This repo includes:
|
|
468
389
|
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
export declare enum DatastoreDataType {
|
|
5
5
|
TEXT = "TEXT",
|
|
6
|
+
FILE_PATH = "FILE_PATH",
|
|
6
7
|
INTEGER = "INTEGER",
|
|
7
8
|
FLOAT = "FLOAT",
|
|
8
9
|
BOOLEAN = "BOOLEAN",
|
|
@@ -10,6 +11,7 @@ export declare enum DatastoreDataType {
|
|
|
10
11
|
DATE = "DATE",
|
|
11
12
|
DATETIME = "DATETIME",
|
|
12
13
|
UUID = "UUID",
|
|
14
|
+
USER = "USER",
|
|
13
15
|
VECTOR = "VECTOR",
|
|
14
16
|
SERIAL = "SERIAL",
|
|
15
17
|
ENUM = "ENUM"
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
export var DatastoreDataType;
|
|
9
9
|
(function (DatastoreDataType) {
|
|
10
10
|
DatastoreDataType["TEXT"] = "TEXT";
|
|
11
|
+
DatastoreDataType["FILE_PATH"] = "FILE_PATH";
|
|
11
12
|
DatastoreDataType["INTEGER"] = "INTEGER";
|
|
12
13
|
DatastoreDataType["FLOAT"] = "FLOAT";
|
|
13
14
|
DatastoreDataType["BOOLEAN"] = "BOOLEAN";
|
|
@@ -15,6 +16,7 @@ export var DatastoreDataType;
|
|
|
15
16
|
DatastoreDataType["DATE"] = "DATE";
|
|
16
17
|
DatastoreDataType["DATETIME"] = "DATETIME";
|
|
17
18
|
DatastoreDataType["UUID"] = "UUID";
|
|
19
|
+
DatastoreDataType["USER"] = "USER";
|
|
18
20
|
DatastoreDataType["VECTOR"] = "VECTOR";
|
|
19
21
|
DatastoreDataType["SERIAL"] = "SERIAL";
|
|
20
22
|
DatastoreDataType["ENUM"] = "ENUM";
|
package/dist/react/index.d.ts
CHANGED
|
@@ -38,18 +38,32 @@ export { useFiles } from "./useFiles.js";
|
|
|
38
38
|
export type { UseFilesOptions, UseFilesResult } from "./useFiles.js";
|
|
39
39
|
export { useFile } from "./useFile.js";
|
|
40
40
|
export type { UseFileOptions, UseFileResult } from "./useFile.js";
|
|
41
|
+
export { useDatastoreQuery } from "./useDatastoreQuery.js";
|
|
42
|
+
export type { UseDatastoreQueryOptions, UseDatastoreQueryResult, } from "./useDatastoreQuery.js";
|
|
43
|
+
export { useUploadFile } from "./useUploadFile.js";
|
|
44
|
+
export type { UploadFileInput, UseUploadFileOptions, UseUploadFileResult, } from "./useUploadFile.js";
|
|
45
|
+
export { useUpdateFile } from "./useUpdateFile.js";
|
|
46
|
+
export type { UpdateFileInput, UseUpdateFileOptions, UseUpdateFileResult, } from "./useUpdateFile.js";
|
|
47
|
+
export { useDeleteFile } from "./useDeleteFile.js";
|
|
48
|
+
export type { UseDeleteFileOptions, UseDeleteFileResult } from "./useDeleteFile.js";
|
|
49
|
+
export { useCreateFolder } from "./useCreateFolder.js";
|
|
50
|
+
export type { CreateFolderInput, UseCreateFolderOptions, UseCreateFolderResult, } from "./useCreateFolder.js";
|
|
41
51
|
export { useFileSearch } from "./useFileSearch.js";
|
|
42
52
|
export type { UseFileSearchOptions, UseFileSearchResult } from "./useFileSearch.js";
|
|
43
53
|
export { useFileTree } from "./useFileTree.js";
|
|
44
54
|
export type { UseFileTreeOptions, UseFileTreeResult } from "./useFileTree.js";
|
|
45
55
|
export { useFilePreview } from "./useFilePreview.js";
|
|
46
56
|
export type { FilePreviewMode, UseFilePreviewOptions, UseFilePreviewResult, } from "./useFilePreview.js";
|
|
57
|
+
export { useGlobalSearch } from "./useGlobalSearch.js";
|
|
58
|
+
export type { GlobalSearchFileResult, GlobalSearchFilesSource, GlobalSearchRecordResult, GlobalSearchResult, GlobalSearchTableSource, UseGlobalSearchOptions, UseGlobalSearchResult, } from "./useGlobalSearch.js";
|
|
47
59
|
export { useTables } from "./useTables.js";
|
|
48
60
|
export type { UseTablesOptions, UseTablesResult } from "./useTables.js";
|
|
49
61
|
export { useTable } from "./useTable.js";
|
|
50
62
|
export type { UseTableOptions, UseTableResult } from "./useTable.js";
|
|
51
63
|
export { useRecords } from "./useRecords.js";
|
|
52
64
|
export type { UseRecordsOptions, UseRecordsResult } from "./useRecords.js";
|
|
65
|
+
export { useRecordAggregates } from "./useRecordAggregates.js";
|
|
66
|
+
export type { RecordAggregateMetric, RecordAggregateOrderBy, UseRecordAggregatesOptions, UseRecordAggregatesResult, } from "./useRecordAggregates.js";
|
|
53
67
|
export { useRecord } from "./useRecord.js";
|
|
54
68
|
export type { UseRecordOptions, UseRecordResult } from "./useRecord.js";
|
|
55
69
|
export { useCreateRecord } from "./useCreateRecord.js";
|
package/dist/react/index.js
CHANGED
|
@@ -18,12 +18,19 @@ export { useCurrentUser } from "./useCurrentUser.js";
|
|
|
18
18
|
export { usePodAccess } from "./usePodAccess.js";
|
|
19
19
|
export { useFiles } from "./useFiles.js";
|
|
20
20
|
export { useFile } from "./useFile.js";
|
|
21
|
+
export { useDatastoreQuery } from "./useDatastoreQuery.js";
|
|
22
|
+
export { useUploadFile } from "./useUploadFile.js";
|
|
23
|
+
export { useUpdateFile } from "./useUpdateFile.js";
|
|
24
|
+
export { useDeleteFile } from "./useDeleteFile.js";
|
|
25
|
+
export { useCreateFolder } from "./useCreateFolder.js";
|
|
21
26
|
export { useFileSearch } from "./useFileSearch.js";
|
|
22
27
|
export { useFileTree } from "./useFileTree.js";
|
|
23
28
|
export { useFilePreview } from "./useFilePreview.js";
|
|
29
|
+
export { useGlobalSearch } from "./useGlobalSearch.js";
|
|
24
30
|
export { useTables } from "./useTables.js";
|
|
25
31
|
export { useTable } from "./useTable.js";
|
|
26
32
|
export { useRecords } from "./useRecords.js";
|
|
33
|
+
export { useRecordAggregates } from "./useRecordAggregates.js";
|
|
27
34
|
export { useRecord } from "./useRecord.js";
|
|
28
35
|
export { useCreateRecord } from "./useCreateRecord.js";
|
|
29
36
|
export { useUpdateRecord } from "./useUpdateRecord.js";
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { RecordFilter } from "../types.js";
|
|
2
|
+
export declare function quoteIdentifierPath(value: string): string;
|
|
3
|
+
export declare function isSimpleIdentifierPath(value: string): boolean;
|
|
4
|
+
export declare function renderIdentifierPath(value: string): string;
|
|
5
|
+
export declare function escapeSqlString(value: string): string;
|
|
6
|
+
export declare function encodeSqlValue(value: unknown): string;
|
|
7
|
+
export declare function renderRecordFilter(filter: RecordFilter): string;
|
|
8
|
+
export declare function renderRecordFilters(filters?: RecordFilter[]): string;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
function quoteIdentifierPart(value) {
|
|
2
|
+
return `"${value.replace(/"/g, "\"\"")}"`;
|
|
3
|
+
}
|
|
4
|
+
export function quoteIdentifierPath(value) {
|
|
5
|
+
return value
|
|
6
|
+
.split(".")
|
|
7
|
+
.map((part) => (part === "*" ? part : quoteIdentifierPart(part)))
|
|
8
|
+
.join(".");
|
|
9
|
+
}
|
|
10
|
+
export function isSimpleIdentifierPath(value) {
|
|
11
|
+
return /^[A-Za-z_][A-Za-z0-9_$]*(\.(\*|[A-Za-z_][A-Za-z0-9_$]*))*$/.test(value);
|
|
12
|
+
}
|
|
13
|
+
export function renderIdentifierPath(value) {
|
|
14
|
+
return isSimpleIdentifierPath(value) ? quoteIdentifierPath(value) : value;
|
|
15
|
+
}
|
|
16
|
+
export function escapeSqlString(value) {
|
|
17
|
+
return value.replace(/'/g, "''");
|
|
18
|
+
}
|
|
19
|
+
export function encodeSqlValue(value) {
|
|
20
|
+
if (value === null || typeof value === "undefined")
|
|
21
|
+
return "NULL";
|
|
22
|
+
if (typeof value === "boolean")
|
|
23
|
+
return value ? "TRUE" : "FALSE";
|
|
24
|
+
if (typeof value === "number") {
|
|
25
|
+
if (!Number.isFinite(value)) {
|
|
26
|
+
throw new Error("SQL values must be finite numbers.");
|
|
27
|
+
}
|
|
28
|
+
return String(value);
|
|
29
|
+
}
|
|
30
|
+
if (typeof value === "bigint")
|
|
31
|
+
return String(value);
|
|
32
|
+
if (value instanceof Date)
|
|
33
|
+
return `'${escapeSqlString(value.toISOString())}'`;
|
|
34
|
+
if (Array.isArray(value)) {
|
|
35
|
+
return `(${value.map((entry) => encodeSqlValue(entry)).join(", ")})`;
|
|
36
|
+
}
|
|
37
|
+
if (typeof value === "object") {
|
|
38
|
+
return `'${escapeSqlString(JSON.stringify(value))}'`;
|
|
39
|
+
}
|
|
40
|
+
return `'${escapeSqlString(String(value))}'`;
|
|
41
|
+
}
|
|
42
|
+
export function renderRecordFilter(filter) {
|
|
43
|
+
const field = filter.field?.trim();
|
|
44
|
+
if (!field) {
|
|
45
|
+
throw new Error("Record filters require a field.");
|
|
46
|
+
}
|
|
47
|
+
const operator = filter.op.trim().toUpperCase();
|
|
48
|
+
const lhs = renderIdentifierPath(field);
|
|
49
|
+
const values = Array.isArray(filter.values) ? filter.values : undefined;
|
|
50
|
+
if ((operator === "IN" || operator === "NOT IN") && values) {
|
|
51
|
+
return `${lhs} ${operator} ${encodeSqlValue(values)}`;
|
|
52
|
+
}
|
|
53
|
+
if ((operator === "IS" || operator === "IS NOT") && typeof filter.value === "undefined") {
|
|
54
|
+
return `${lhs} ${operator} NULL`;
|
|
55
|
+
}
|
|
56
|
+
return `${lhs} ${operator} ${encodeSqlValue(filter.value)}`;
|
|
57
|
+
}
|
|
58
|
+
export function renderRecordFilters(filters) {
|
|
59
|
+
if (!filters?.length)
|
|
60
|
+
return "";
|
|
61
|
+
return filters.map((filter) => `(${renderRecordFilter(filter)})`).join(" AND ");
|
|
62
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { LemmaClient } from "../client.js";
|
|
2
|
+
import type { FileResponse } from "../types.js";
|
|
3
|
+
export interface CreateFolderInput {
|
|
4
|
+
directoryPath?: string;
|
|
5
|
+
parentId?: string;
|
|
6
|
+
description?: string;
|
|
7
|
+
}
|
|
8
|
+
export interface UseCreateFolderOptions {
|
|
9
|
+
client: LemmaClient;
|
|
10
|
+
podId?: string;
|
|
11
|
+
enabled?: boolean;
|
|
12
|
+
onSuccess?: (folder: FileResponse) => void;
|
|
13
|
+
onError?: (error: unknown) => void;
|
|
14
|
+
}
|
|
15
|
+
export interface UseCreateFolderResult<TFile extends FileResponse = FileResponse> {
|
|
16
|
+
createdFolder: TFile | null;
|
|
17
|
+
isSubmitting: boolean;
|
|
18
|
+
error: Error | null;
|
|
19
|
+
createFolder: (name: string, options?: CreateFolderInput) => Promise<TFile | null>;
|
|
20
|
+
reset: () => void;
|
|
21
|
+
}
|
|
22
|
+
export declare function useCreateFolder<TFile extends FileResponse = FileResponse>({ client, podId, enabled, onSuccess, onError, }: UseCreateFolderOptions): UseCreateFolderResult<TFile>;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
2
|
+
import { normalizeError, resolvePodClient } from "./utils.js";
|
|
3
|
+
export function useCreateFolder({ client, podId, enabled = true, onSuccess, onError, }) {
|
|
4
|
+
const [createdFolder, setCreatedFolder] = useState(null);
|
|
5
|
+
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
6
|
+
const [error, setError] = useState(null);
|
|
7
|
+
const onSuccessRef = useRef(onSuccess);
|
|
8
|
+
const onErrorRef = useRef(onError);
|
|
9
|
+
useEffect(() => { onSuccessRef.current = onSuccess; }, [onSuccess]);
|
|
10
|
+
useEffect(() => { onErrorRef.current = onError; }, [onError]);
|
|
11
|
+
const createFolder = useCallback(async (name, options = {}) => {
|
|
12
|
+
const trimmedName = name.trim();
|
|
13
|
+
if (!enabled || trimmedName.length === 0) {
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
setIsSubmitting(true);
|
|
17
|
+
setError(null);
|
|
18
|
+
try {
|
|
19
|
+
const scopedClient = resolvePodClient(client, podId);
|
|
20
|
+
const nextFolder = await scopedClient.files.folder.create(trimmedName, options);
|
|
21
|
+
setCreatedFolder(nextFolder);
|
|
22
|
+
onSuccessRef.current?.(nextFolder);
|
|
23
|
+
return nextFolder;
|
|
24
|
+
}
|
|
25
|
+
catch (createError) {
|
|
26
|
+
const normalized = normalizeError(createError, "Failed to create folder.");
|
|
27
|
+
setError(normalized);
|
|
28
|
+
onErrorRef.current?.(createError);
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
finally {
|
|
32
|
+
setIsSubmitting(false);
|
|
33
|
+
}
|
|
34
|
+
}, [client, enabled, podId]);
|
|
35
|
+
const reset = useCallback(() => {
|
|
36
|
+
setCreatedFolder(null);
|
|
37
|
+
setError(null);
|
|
38
|
+
setIsSubmitting(false);
|
|
39
|
+
}, []);
|
|
40
|
+
return useMemo(() => ({
|
|
41
|
+
createdFolder,
|
|
42
|
+
isSubmitting,
|
|
43
|
+
error,
|
|
44
|
+
createFolder,
|
|
45
|
+
reset,
|
|
46
|
+
}), [createFolder, createdFolder, error, isSubmitting, reset]);
|
|
47
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { LemmaClient } from "../client.js";
|
|
2
|
+
import type { DatastoreQueryResponse } from "../types.js";
|
|
3
|
+
export interface UseDatastoreQueryOptions {
|
|
4
|
+
client: LemmaClient;
|
|
5
|
+
podId?: string;
|
|
6
|
+
query?: string | null;
|
|
7
|
+
enabled?: boolean;
|
|
8
|
+
autoLoad?: boolean;
|
|
9
|
+
}
|
|
10
|
+
export interface UseDatastoreQueryResult<TRow extends Record<string, unknown> = Record<string, unknown>> {
|
|
11
|
+
response: DatastoreQueryResponse | null;
|
|
12
|
+
items: TRow[];
|
|
13
|
+
total: number;
|
|
14
|
+
sql: string;
|
|
15
|
+
isLoading: boolean;
|
|
16
|
+
error: Error | null;
|
|
17
|
+
refresh: (overrides?: {
|
|
18
|
+
query?: string | null;
|
|
19
|
+
}) => Promise<TRow[]>;
|
|
20
|
+
reset: () => void;
|
|
21
|
+
}
|
|
22
|
+
export declare function useDatastoreQuery<TRow extends Record<string, unknown> = Record<string, unknown>>({ client, podId, query, enabled, autoLoad, }: UseDatastoreQueryOptions): UseDatastoreQueryResult<TRow>;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
2
|
+
import { normalizeError, resolvePodClient } from "./utils.js";
|
|
3
|
+
export function useDatastoreQuery({ client, podId, query = null, enabled = true, autoLoad = true, }) {
|
|
4
|
+
const [response, setResponse] = useState(null);
|
|
5
|
+
const [sql, setSql] = useState("");
|
|
6
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
7
|
+
const [error, setError] = useState(null);
|
|
8
|
+
const trimmedQuery = typeof query === "string" ? query.trim() : "";
|
|
9
|
+
const isEnabled = enabled && trimmedQuery.length > 0;
|
|
10
|
+
const reset = useCallback(() => {
|
|
11
|
+
setResponse(null);
|
|
12
|
+
setSql("");
|
|
13
|
+
setError(null);
|
|
14
|
+
setIsLoading(false);
|
|
15
|
+
}, []);
|
|
16
|
+
const refresh = useCallback(async (overrides = {}, signal) => {
|
|
17
|
+
const nextQuery = typeof overrides.query === "string"
|
|
18
|
+
? overrides.query.trim()
|
|
19
|
+
: trimmedQuery;
|
|
20
|
+
if (!enabled || nextQuery.length === 0) {
|
|
21
|
+
reset();
|
|
22
|
+
return [];
|
|
23
|
+
}
|
|
24
|
+
setIsLoading(true);
|
|
25
|
+
setError(null);
|
|
26
|
+
setSql(nextQuery);
|
|
27
|
+
try {
|
|
28
|
+
const scopedClient = resolvePodClient(client, podId);
|
|
29
|
+
const nextResponse = await scopedClient.datastore.query(nextQuery);
|
|
30
|
+
if (signal?.aborted)
|
|
31
|
+
return [];
|
|
32
|
+
setResponse(nextResponse);
|
|
33
|
+
return (nextResponse.items ?? []);
|
|
34
|
+
}
|
|
35
|
+
catch (queryError) {
|
|
36
|
+
if (signal?.aborted)
|
|
37
|
+
return [];
|
|
38
|
+
setError(normalizeError(queryError, "Failed to execute datastore query."));
|
|
39
|
+
setResponse(null);
|
|
40
|
+
return [];
|
|
41
|
+
}
|
|
42
|
+
finally {
|
|
43
|
+
if (!signal?.aborted)
|
|
44
|
+
setIsLoading(false);
|
|
45
|
+
}
|
|
46
|
+
}, [client, enabled, podId, reset, trimmedQuery]);
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
if (!isEnabled || !autoLoad) {
|
|
49
|
+
if (!isEnabled)
|
|
50
|
+
reset();
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
const controller = new AbortController();
|
|
54
|
+
void refresh({}, controller.signal);
|
|
55
|
+
return () => controller.abort();
|
|
56
|
+
}, [autoLoad, isEnabled, refresh, reset]);
|
|
57
|
+
return useMemo(() => ({
|
|
58
|
+
response,
|
|
59
|
+
items: (response?.items ?? []),
|
|
60
|
+
total: response?.total ?? 0,
|
|
61
|
+
sql,
|
|
62
|
+
isLoading,
|
|
63
|
+
error,
|
|
64
|
+
refresh,
|
|
65
|
+
reset,
|
|
66
|
+
}), [error, isLoading, refresh, reset, response, sql]);
|
|
67
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { LemmaClient } from "../client.js";
|
|
2
|
+
export interface UseDeleteFileOptions {
|
|
3
|
+
client: LemmaClient;
|
|
4
|
+
podId?: string;
|
|
5
|
+
path?: string | null;
|
|
6
|
+
enabled?: boolean;
|
|
7
|
+
onSuccess?: (path: string) => void;
|
|
8
|
+
onError?: (error: unknown) => void;
|
|
9
|
+
}
|
|
10
|
+
export interface UseDeleteFileResult {
|
|
11
|
+
deletedPath: string | null;
|
|
12
|
+
isSubmitting: boolean;
|
|
13
|
+
error: Error | null;
|
|
14
|
+
remove: (overrides?: {
|
|
15
|
+
path?: string | null;
|
|
16
|
+
}) => Promise<boolean>;
|
|
17
|
+
reset: () => void;
|
|
18
|
+
}
|
|
19
|
+
export declare function useDeleteFile({ client, podId, path, enabled, onSuccess, onError, }: UseDeleteFileOptions): UseDeleteFileResult;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
2
|
+
import { normalizeError, resolvePodClient } from "./utils.js";
|
|
3
|
+
export function useDeleteFile({ client, podId, path = null, enabled = true, onSuccess, onError, }) {
|
|
4
|
+
const [deletedPath, setDeletedPath] = useState(null);
|
|
5
|
+
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
6
|
+
const [error, setError] = useState(null);
|
|
7
|
+
const onSuccessRef = useRef(onSuccess);
|
|
8
|
+
const onErrorRef = useRef(onError);
|
|
9
|
+
useEffect(() => { onSuccessRef.current = onSuccess; }, [onSuccess]);
|
|
10
|
+
useEffect(() => { onErrorRef.current = onError; }, [onError]);
|
|
11
|
+
const trimmedPath = typeof path === "string" ? path.trim() : "";
|
|
12
|
+
const isEnabled = enabled && trimmedPath.length > 0;
|
|
13
|
+
const remove = useCallback(async (overrides = {}) => {
|
|
14
|
+
const nextPath = typeof overrides.path === "string"
|
|
15
|
+
? overrides.path.trim()
|
|
16
|
+
: trimmedPath;
|
|
17
|
+
if (!isEnabled || nextPath.length === 0) {
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
setIsSubmitting(true);
|
|
21
|
+
setError(null);
|
|
22
|
+
try {
|
|
23
|
+
const scopedClient = resolvePodClient(client, podId);
|
|
24
|
+
await scopedClient.files.delete(nextPath);
|
|
25
|
+
setDeletedPath(nextPath);
|
|
26
|
+
onSuccessRef.current?.(nextPath);
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
catch (removeError) {
|
|
30
|
+
const normalized = normalizeError(removeError, "Failed to delete file.");
|
|
31
|
+
setError(normalized);
|
|
32
|
+
onErrorRef.current?.(removeError);
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
finally {
|
|
36
|
+
setIsSubmitting(false);
|
|
37
|
+
}
|
|
38
|
+
}, [client, isEnabled, podId, trimmedPath]);
|
|
39
|
+
const reset = useCallback(() => {
|
|
40
|
+
setDeletedPath(null);
|
|
41
|
+
setError(null);
|
|
42
|
+
setIsSubmitting(false);
|
|
43
|
+
}, []);
|
|
44
|
+
return useMemo(() => ({
|
|
45
|
+
deletedPath,
|
|
46
|
+
isSubmitting,
|
|
47
|
+
error,
|
|
48
|
+
remove,
|
|
49
|
+
reset,
|
|
50
|
+
}), [deletedPath, error, isSubmitting, remove, reset]);
|
|
51
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { LemmaClient } from "../client.js";
|
|
2
|
+
import type { FileSearchResultSchema, RecordFilter, SearchMethod } from "../types.js";
|
|
3
|
+
export interface GlobalSearchTableSource {
|
|
4
|
+
key?: string;
|
|
5
|
+
tableName: string;
|
|
6
|
+
label?: string;
|
|
7
|
+
searchFields: string[];
|
|
8
|
+
displayField?: string;
|
|
9
|
+
subtitleField?: string;
|
|
10
|
+
limit?: number;
|
|
11
|
+
filters?: RecordFilter[];
|
|
12
|
+
}
|
|
13
|
+
export interface GlobalSearchFilesSource {
|
|
14
|
+
enabled?: boolean;
|
|
15
|
+
label?: string;
|
|
16
|
+
limit?: number;
|
|
17
|
+
searchMethod?: SearchMethod;
|
|
18
|
+
}
|
|
19
|
+
export interface GlobalSearchRecordResult {
|
|
20
|
+
kind: "record";
|
|
21
|
+
sourceKey: string;
|
|
22
|
+
sourceLabel: string;
|
|
23
|
+
tableName: string;
|
|
24
|
+
id: string;
|
|
25
|
+
title: string;
|
|
26
|
+
subtitle: string | null;
|
|
27
|
+
record: Record<string, unknown>;
|
|
28
|
+
}
|
|
29
|
+
export interface GlobalSearchFileResult {
|
|
30
|
+
kind: "file";
|
|
31
|
+
sourceKey: string;
|
|
32
|
+
sourceLabel: string;
|
|
33
|
+
path: string;
|
|
34
|
+
title: string;
|
|
35
|
+
subtitle: string | null;
|
|
36
|
+
result: FileSearchResultSchema;
|
|
37
|
+
}
|
|
38
|
+
export type GlobalSearchResult = GlobalSearchRecordResult | GlobalSearchFileResult;
|
|
39
|
+
export interface UseGlobalSearchOptions {
|
|
40
|
+
client: LemmaClient;
|
|
41
|
+
podId?: string;
|
|
42
|
+
query?: string;
|
|
43
|
+
tables?: GlobalSearchTableSource[];
|
|
44
|
+
files?: GlobalSearchFilesSource | false;
|
|
45
|
+
enabled?: boolean;
|
|
46
|
+
autoLoad?: boolean;
|
|
47
|
+
minQueryLength?: number;
|
|
48
|
+
}
|
|
49
|
+
export interface UseGlobalSearchResult {
|
|
50
|
+
results: GlobalSearchResult[];
|
|
51
|
+
recordResults: GlobalSearchRecordResult[];
|
|
52
|
+
fileResults: GlobalSearchFileResult[];
|
|
53
|
+
totalResults: number;
|
|
54
|
+
sourceErrors: Record<string, Error>;
|
|
55
|
+
isLoading: boolean;
|
|
56
|
+
error: Error | null;
|
|
57
|
+
search: (overrides?: {
|
|
58
|
+
query?: string;
|
|
59
|
+
}) => Promise<GlobalSearchResult[]>;
|
|
60
|
+
reset: () => void;
|
|
61
|
+
}
|
|
62
|
+
export declare function useGlobalSearch({ client, podId, query, tables, files, enabled, autoLoad, minQueryLength, }: UseGlobalSearchOptions): UseGlobalSearchResult;
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
2
|
+
import { escapeSqlString, quoteIdentifierPath, renderIdentifierPath, renderRecordFilters } from "./sql-utils.js";
|
|
3
|
+
import { normalizeError, resolvePodClient, stringifyComparable } from "./utils.js";
|
|
4
|
+
function buildTableSearchQuery(source, query) {
|
|
5
|
+
const fields = Array.from(new Set([
|
|
6
|
+
"id",
|
|
7
|
+
...source.searchFields,
|
|
8
|
+
source.displayField ?? "id",
|
|
9
|
+
source.subtitleField ?? "",
|
|
10
|
+
].filter((value) => value.trim().length > 0)));
|
|
11
|
+
const searchClauses = source.searchFields.map((field) => `${renderIdentifierPath(field)} ILIKE '%${escapeSqlString(query)}%'`);
|
|
12
|
+
const filterClause = renderRecordFilters(source.filters);
|
|
13
|
+
return [
|
|
14
|
+
`SELECT ${fields.map((field) => renderIdentifierPath(field)).join(", ")}`,
|
|
15
|
+
`FROM ${quoteIdentifierPath(source.tableName)}`,
|
|
16
|
+
`WHERE (${searchClauses.join(" OR ")})${filterClause ? ` AND ${filterClause}` : ""}`,
|
|
17
|
+
`LIMIT ${source.limit ?? 8}`,
|
|
18
|
+
].join(" ");
|
|
19
|
+
}
|
|
20
|
+
function readString(value) {
|
|
21
|
+
if (typeof value === "string" && value.trim().length > 0)
|
|
22
|
+
return value;
|
|
23
|
+
if (typeof value === "number" || typeof value === "boolean")
|
|
24
|
+
return String(value);
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
export function useGlobalSearch({ client, podId, query = "", tables = [], files, enabled = true, autoLoad = true, minQueryLength = 1, }) {
|
|
28
|
+
const [results, setResults] = useState([]);
|
|
29
|
+
const [sourceErrors, setSourceErrors] = useState({});
|
|
30
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
31
|
+
const [error, setError] = useState(null);
|
|
32
|
+
const trimmedQuery = query.trim();
|
|
33
|
+
const tablesKey = stringifyComparable(tables);
|
|
34
|
+
const filesKey = stringifyComparable(files);
|
|
35
|
+
const stableTables = useMemo(() => tables, [tablesKey]);
|
|
36
|
+
const stableFiles = useMemo(() => files, [filesKey]);
|
|
37
|
+
const reset = useCallback(() => {
|
|
38
|
+
setResults([]);
|
|
39
|
+
setSourceErrors({});
|
|
40
|
+
setError(null);
|
|
41
|
+
setIsLoading(false);
|
|
42
|
+
}, []);
|
|
43
|
+
const search = useCallback(async (overrides = {}, signal) => {
|
|
44
|
+
const nextQuery = (overrides.query ?? trimmedQuery).trim();
|
|
45
|
+
if (!enabled || nextQuery.length < minQueryLength) {
|
|
46
|
+
reset();
|
|
47
|
+
return [];
|
|
48
|
+
}
|
|
49
|
+
setIsLoading(true);
|
|
50
|
+
setError(null);
|
|
51
|
+
try {
|
|
52
|
+
const scopedClient = resolvePodClient(client, podId);
|
|
53
|
+
const searchTasks = [];
|
|
54
|
+
stableTables.forEach((source, index) => {
|
|
55
|
+
const sourceKey = source.key?.trim() || source.tableName;
|
|
56
|
+
searchTasks.push((async () => {
|
|
57
|
+
try {
|
|
58
|
+
const sql = buildTableSearchQuery(source, nextQuery);
|
|
59
|
+
const response = await scopedClient.datastore.query(sql);
|
|
60
|
+
const rows = response.items ?? [];
|
|
61
|
+
const mapped = rows.map((record) => {
|
|
62
|
+
const displayField = source.displayField ?? source.searchFields[0] ?? "id";
|
|
63
|
+
const subtitleField = source.subtitleField;
|
|
64
|
+
const id = readString(record.id) ?? `${source.tableName}-${index}`;
|
|
65
|
+
return {
|
|
66
|
+
kind: "record",
|
|
67
|
+
sourceKey,
|
|
68
|
+
sourceLabel: source.label ?? source.tableName,
|
|
69
|
+
tableName: source.tableName,
|
|
70
|
+
id,
|
|
71
|
+
title: readString(record[displayField]) ?? id,
|
|
72
|
+
subtitle: subtitleField ? readString(record[subtitleField]) : null,
|
|
73
|
+
record,
|
|
74
|
+
};
|
|
75
|
+
});
|
|
76
|
+
return { key: sourceKey, results: mapped };
|
|
77
|
+
}
|
|
78
|
+
catch (tableError) {
|
|
79
|
+
return {
|
|
80
|
+
key: sourceKey,
|
|
81
|
+
results: [],
|
|
82
|
+
error: normalizeError(tableError, `Failed to search ${source.tableName}.`),
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
})());
|
|
86
|
+
});
|
|
87
|
+
if (stableFiles !== false && stableFiles?.enabled !== false) {
|
|
88
|
+
searchTasks.push((async () => {
|
|
89
|
+
const sourceKey = "files";
|
|
90
|
+
try {
|
|
91
|
+
const response = await scopedClient.files.search(nextQuery, {
|
|
92
|
+
limit: stableFiles?.limit ?? 8,
|
|
93
|
+
searchMethod: stableFiles?.searchMethod,
|
|
94
|
+
});
|
|
95
|
+
const mapped = (response.results ?? []).map((result) => ({
|
|
96
|
+
kind: "file",
|
|
97
|
+
sourceKey,
|
|
98
|
+
sourceLabel: stableFiles?.label ?? "Files",
|
|
99
|
+
path: result.path,
|
|
100
|
+
title: result.path.split("/").filter(Boolean).pop() || result.path,
|
|
101
|
+
subtitle: typeof result.content === "string" && result.content.trim().length > 0
|
|
102
|
+
? result.content.slice(0, 160)
|
|
103
|
+
: null,
|
|
104
|
+
result,
|
|
105
|
+
}));
|
|
106
|
+
return { key: sourceKey, results: mapped };
|
|
107
|
+
}
|
|
108
|
+
catch (fileError) {
|
|
109
|
+
return {
|
|
110
|
+
key: sourceKey,
|
|
111
|
+
results: [],
|
|
112
|
+
error: normalizeError(fileError, "Failed to search files."),
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
})());
|
|
116
|
+
}
|
|
117
|
+
const settled = await Promise.all(searchTasks);
|
|
118
|
+
if (signal?.aborted)
|
|
119
|
+
return [];
|
|
120
|
+
const nextErrors = {};
|
|
121
|
+
const nextResults = settled.flatMap((entry) => {
|
|
122
|
+
if (entry.error) {
|
|
123
|
+
nextErrors[entry.key] = entry.error;
|
|
124
|
+
}
|
|
125
|
+
return entry.results;
|
|
126
|
+
});
|
|
127
|
+
setSourceErrors(nextErrors);
|
|
128
|
+
setResults(nextResults);
|
|
129
|
+
return nextResults;
|
|
130
|
+
}
|
|
131
|
+
catch (searchError) {
|
|
132
|
+
if (signal?.aborted)
|
|
133
|
+
return [];
|
|
134
|
+
const normalized = normalizeError(searchError, "Failed to run global search.");
|
|
135
|
+
setError(normalized);
|
|
136
|
+
setResults([]);
|
|
137
|
+
return [];
|
|
138
|
+
}
|
|
139
|
+
finally {
|
|
140
|
+
if (!signal?.aborted)
|
|
141
|
+
setIsLoading(false);
|
|
142
|
+
}
|
|
143
|
+
}, [client, enabled, minQueryLength, podId, reset, stableFiles, stableTables, trimmedQuery]);
|
|
144
|
+
useEffect(() => {
|
|
145
|
+
if (!enabled || !autoLoad)
|
|
146
|
+
return;
|
|
147
|
+
if (trimmedQuery.length < minQueryLength) {
|
|
148
|
+
reset();
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
const controller = new AbortController();
|
|
152
|
+
void search({}, controller.signal);
|
|
153
|
+
return () => controller.abort();
|
|
154
|
+
}, [autoLoad, enabled, minQueryLength, reset, search, trimmedQuery]);
|
|
155
|
+
return useMemo(() => {
|
|
156
|
+
const recordResults = results.filter((entry) => entry.kind === "record");
|
|
157
|
+
const fileResults = results.filter((entry) => entry.kind === "file");
|
|
158
|
+
return {
|
|
159
|
+
results,
|
|
160
|
+
recordResults,
|
|
161
|
+
fileResults,
|
|
162
|
+
totalResults: results.length,
|
|
163
|
+
sourceErrors,
|
|
164
|
+
isLoading,
|
|
165
|
+
error,
|
|
166
|
+
search,
|
|
167
|
+
reset,
|
|
168
|
+
};
|
|
169
|
+
}, [error, isLoading, reset, results, search, sourceErrors]);
|
|
170
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { LemmaClient } from "../client.js";
|
|
2
|
+
import type { RecordFilter } from "../types.js";
|
|
3
|
+
export interface RecordAggregateMetric {
|
|
4
|
+
key: string;
|
|
5
|
+
op: "count" | "sum" | "avg" | "min" | "max";
|
|
6
|
+
field?: string;
|
|
7
|
+
distinct?: boolean;
|
|
8
|
+
}
|
|
9
|
+
export interface RecordAggregateOrderBy {
|
|
10
|
+
field: string;
|
|
11
|
+
direction?: "asc" | "desc";
|
|
12
|
+
}
|
|
13
|
+
export interface UseRecordAggregatesOptions {
|
|
14
|
+
client: LemmaClient;
|
|
15
|
+
podId?: string;
|
|
16
|
+
tableName: string;
|
|
17
|
+
metrics: RecordAggregateMetric[];
|
|
18
|
+
groupBy?: string | string[];
|
|
19
|
+
filters?: RecordFilter[];
|
|
20
|
+
limit?: number;
|
|
21
|
+
offset?: number;
|
|
22
|
+
orderBy?: RecordAggregateOrderBy[];
|
|
23
|
+
enabled?: boolean;
|
|
24
|
+
autoLoad?: boolean;
|
|
25
|
+
}
|
|
26
|
+
export interface UseRecordAggregatesResult<TRow extends Record<string, unknown> = Record<string, unknown>> {
|
|
27
|
+
rows: TRow[];
|
|
28
|
+
row: TRow | null;
|
|
29
|
+
total: number;
|
|
30
|
+
sql: string;
|
|
31
|
+
isLoading: boolean;
|
|
32
|
+
error: Error | null;
|
|
33
|
+
refresh: () => Promise<TRow[]>;
|
|
34
|
+
}
|
|
35
|
+
export declare function useRecordAggregates<TRow extends Record<string, unknown> = Record<string, unknown>>({ client, podId, tableName, metrics, groupBy, filters, limit, offset, orderBy, enabled, autoLoad, }: UseRecordAggregatesOptions): UseRecordAggregatesResult<TRow>;
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
2
|
+
import { encodeSqlValue, quoteIdentifierPath, renderIdentifierPath, renderRecordFilters } from "./sql-utils.js";
|
|
3
|
+
import { normalizeError, resolvePodClient, stringifyComparable } from "./utils.js";
|
|
4
|
+
function buildMetricExpression(metric) {
|
|
5
|
+
const fn = metric.op.toUpperCase();
|
|
6
|
+
if (metric.op === "count" && !metric.field) {
|
|
7
|
+
return `${fn}(*) AS ${quoteIdentifierPath(metric.key)}`;
|
|
8
|
+
}
|
|
9
|
+
if (!metric.field) {
|
|
10
|
+
throw new Error(`Aggregate metric "${metric.key}" requires a field.`);
|
|
11
|
+
}
|
|
12
|
+
const renderedField = renderIdentifierPath(metric.field);
|
|
13
|
+
const distinct = metric.distinct ? "DISTINCT " : "";
|
|
14
|
+
return `${fn}(${distinct}${renderedField}) AS ${quoteIdentifierPath(metric.key)}`;
|
|
15
|
+
}
|
|
16
|
+
function buildRecordAggregatesQuery({ tableName, metrics, groupBy, filters, limit, offset, orderBy, }) {
|
|
17
|
+
const groups = (Array.isArray(groupBy) ? groupBy : groupBy ? [groupBy] : [])
|
|
18
|
+
.map((field) => field.trim())
|
|
19
|
+
.filter((field) => field.length > 0);
|
|
20
|
+
const selectParts = [
|
|
21
|
+
...groups.map((field) => `${renderIdentifierPath(field)} AS ${quoteIdentifierPath(field)}`),
|
|
22
|
+
...metrics.map((metric) => buildMetricExpression(metric)),
|
|
23
|
+
];
|
|
24
|
+
const whereClause = renderRecordFilters(filters);
|
|
25
|
+
const orderClause = orderBy?.length
|
|
26
|
+
? ` ORDER BY ${orderBy.map((entry) => `${renderIdentifierPath(entry.field)} ${(entry.direction ?? "desc").toUpperCase()}`).join(", ")}`
|
|
27
|
+
: "";
|
|
28
|
+
const groupClause = groups.length
|
|
29
|
+
? ` GROUP BY ${groups.map((field) => renderIdentifierPath(field)).join(", ")}`
|
|
30
|
+
: "";
|
|
31
|
+
const limitClause = typeof limit === "number" ? ` LIMIT ${encodeSqlValue(limit)}` : "";
|
|
32
|
+
const offsetClause = typeof offset === "number" ? ` OFFSET ${encodeSqlValue(offset)}` : "";
|
|
33
|
+
return [
|
|
34
|
+
`SELECT ${selectParts.join(", ")}`,
|
|
35
|
+
`FROM ${quoteIdentifierPath(tableName)}`,
|
|
36
|
+
whereClause ? `WHERE ${whereClause}` : "",
|
|
37
|
+
groupClause,
|
|
38
|
+
orderClause,
|
|
39
|
+
limitClause,
|
|
40
|
+
offsetClause,
|
|
41
|
+
].filter(Boolean).join(" ");
|
|
42
|
+
}
|
|
43
|
+
export function useRecordAggregates({ client, podId, tableName, metrics, groupBy, filters = [], limit, offset, orderBy, enabled = true, autoLoad = true, }) {
|
|
44
|
+
const [rows, setRows] = useState([]);
|
|
45
|
+
const [total, setTotal] = useState(0);
|
|
46
|
+
const [sql, setSql] = useState("");
|
|
47
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
48
|
+
const [error, setError] = useState(null);
|
|
49
|
+
const metricsKey = stringifyComparable(metrics);
|
|
50
|
+
const filtersKey = stringifyComparable(filters);
|
|
51
|
+
const groupByKey = stringifyComparable(groupBy);
|
|
52
|
+
const orderByKey = stringifyComparable(orderBy);
|
|
53
|
+
const stableMetrics = useMemo(() => metrics, [metricsKey]);
|
|
54
|
+
const stableFilters = useMemo(() => filters, [filtersKey]);
|
|
55
|
+
const stableGroupBy = useMemo(() => groupBy, [groupByKey]);
|
|
56
|
+
const stableOrderBy = useMemo(() => orderBy, [orderByKey]);
|
|
57
|
+
const trimmedTableName = tableName.trim();
|
|
58
|
+
const isEnabled = enabled && trimmedTableName.length > 0 && stableMetrics.length > 0;
|
|
59
|
+
const refresh = useCallback(async (signal) => {
|
|
60
|
+
if (!isEnabled) {
|
|
61
|
+
setRows([]);
|
|
62
|
+
setTotal(0);
|
|
63
|
+
setSql("");
|
|
64
|
+
setError(null);
|
|
65
|
+
setIsLoading(false);
|
|
66
|
+
return [];
|
|
67
|
+
}
|
|
68
|
+
setIsLoading(true);
|
|
69
|
+
setError(null);
|
|
70
|
+
try {
|
|
71
|
+
const scopedClient = resolvePodClient(client, podId);
|
|
72
|
+
const nextSql = buildRecordAggregatesQuery({
|
|
73
|
+
tableName: trimmedTableName,
|
|
74
|
+
metrics: stableMetrics,
|
|
75
|
+
groupBy: stableGroupBy,
|
|
76
|
+
filters: stableFilters,
|
|
77
|
+
limit,
|
|
78
|
+
offset,
|
|
79
|
+
orderBy: stableOrderBy,
|
|
80
|
+
});
|
|
81
|
+
setSql(nextSql);
|
|
82
|
+
const response = await scopedClient.datastore.query(nextSql);
|
|
83
|
+
if (signal?.aborted)
|
|
84
|
+
return [];
|
|
85
|
+
const nextRows = (response.items ?? []);
|
|
86
|
+
setRows(nextRows);
|
|
87
|
+
setTotal(response.total ?? nextRows.length);
|
|
88
|
+
return nextRows;
|
|
89
|
+
}
|
|
90
|
+
catch (aggregateError) {
|
|
91
|
+
if (signal?.aborted)
|
|
92
|
+
return [];
|
|
93
|
+
const normalized = normalizeError(aggregateError, "Failed to load record aggregates.");
|
|
94
|
+
setError(normalized);
|
|
95
|
+
return [];
|
|
96
|
+
}
|
|
97
|
+
finally {
|
|
98
|
+
if (!signal?.aborted)
|
|
99
|
+
setIsLoading(false);
|
|
100
|
+
}
|
|
101
|
+
}, [client, isEnabled, limit, offset, podId, stableFilters, stableGroupBy, stableMetrics, stableOrderBy, trimmedTableName]);
|
|
102
|
+
useEffect(() => {
|
|
103
|
+
if (!isEnabled) {
|
|
104
|
+
setRows([]);
|
|
105
|
+
setTotal(0);
|
|
106
|
+
setSql("");
|
|
107
|
+
setError(null);
|
|
108
|
+
setIsLoading(false);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
if (!autoLoad)
|
|
112
|
+
return;
|
|
113
|
+
const controller = new AbortController();
|
|
114
|
+
void refresh(controller.signal);
|
|
115
|
+
return () => controller.abort();
|
|
116
|
+
}, [autoLoad, isEnabled, refresh]);
|
|
117
|
+
return useMemo(() => ({
|
|
118
|
+
rows,
|
|
119
|
+
row: rows[0] ?? null,
|
|
120
|
+
total,
|
|
121
|
+
sql,
|
|
122
|
+
isLoading,
|
|
123
|
+
error,
|
|
124
|
+
refresh,
|
|
125
|
+
}), [error, isLoading, refresh, rows, sql, total]);
|
|
126
|
+
}
|
|
@@ -30,12 +30,13 @@ export function useTaskSession({ client, podId, taskId: externalTaskId = null, a
|
|
|
30
30
|
}
|
|
31
31
|
}, []);
|
|
32
32
|
const setTaskId = useCallback((nextTaskId) => {
|
|
33
|
-
abortRef.current?.abort();
|
|
34
|
-
abortRef.current = null;
|
|
35
33
|
setTaskIdState((currentTaskId) => {
|
|
36
34
|
if (currentTaskId === nextTaskId) {
|
|
37
35
|
return currentTaskId;
|
|
38
36
|
}
|
|
37
|
+
abortRef.current?.abort();
|
|
38
|
+
abortRef.current = null;
|
|
39
|
+
taskIdRef.current = nextTaskId;
|
|
39
40
|
setError(null);
|
|
40
41
|
setIsStreaming(false);
|
|
41
42
|
if (!nextTaskId) {
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { LemmaClient } from "../client.js";
|
|
2
|
+
import type { FileResponse } from "../types.js";
|
|
3
|
+
export interface UpdateFileInput {
|
|
4
|
+
file?: Blob;
|
|
5
|
+
name?: string;
|
|
6
|
+
description?: string;
|
|
7
|
+
directoryPath?: string;
|
|
8
|
+
parentId?: string;
|
|
9
|
+
newPath?: string;
|
|
10
|
+
searchEnabled?: boolean;
|
|
11
|
+
}
|
|
12
|
+
export interface UseUpdateFileOptions {
|
|
13
|
+
client: LemmaClient;
|
|
14
|
+
podId?: string;
|
|
15
|
+
path?: string | null;
|
|
16
|
+
enabled?: boolean;
|
|
17
|
+
onSuccess?: (file: FileResponse) => void;
|
|
18
|
+
onError?: (error: unknown) => void;
|
|
19
|
+
}
|
|
20
|
+
export interface UseUpdateFileResult<TFile extends FileResponse = FileResponse> {
|
|
21
|
+
updatedFile: TFile | null;
|
|
22
|
+
isSubmitting: boolean;
|
|
23
|
+
error: Error | null;
|
|
24
|
+
update: (input?: UpdateFileInput, overrides?: {
|
|
25
|
+
path?: string | null;
|
|
26
|
+
}) => Promise<TFile | null>;
|
|
27
|
+
reset: () => void;
|
|
28
|
+
}
|
|
29
|
+
export declare function useUpdateFile<TFile extends FileResponse = FileResponse>({ client, podId, path, enabled, onSuccess, onError, }: UseUpdateFileOptions): UseUpdateFileResult<TFile>;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
2
|
+
import { normalizeError, resolvePodClient } from "./utils.js";
|
|
3
|
+
export function useUpdateFile({ client, podId, path = null, enabled = true, onSuccess, onError, }) {
|
|
4
|
+
const [updatedFile, setUpdatedFile] = useState(null);
|
|
5
|
+
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
6
|
+
const [error, setError] = useState(null);
|
|
7
|
+
const onSuccessRef = useRef(onSuccess);
|
|
8
|
+
const onErrorRef = useRef(onError);
|
|
9
|
+
useEffect(() => { onSuccessRef.current = onSuccess; }, [onSuccess]);
|
|
10
|
+
useEffect(() => { onErrorRef.current = onError; }, [onError]);
|
|
11
|
+
const trimmedPath = typeof path === "string" ? path.trim() : "";
|
|
12
|
+
const isEnabled = enabled && trimmedPath.length > 0;
|
|
13
|
+
const update = useCallback(async (input = {}, overrides = {}) => {
|
|
14
|
+
const nextPath = typeof overrides.path === "string"
|
|
15
|
+
? overrides.path.trim()
|
|
16
|
+
: trimmedPath;
|
|
17
|
+
if (!isEnabled || nextPath.length === 0) {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
setIsSubmitting(true);
|
|
21
|
+
setError(null);
|
|
22
|
+
try {
|
|
23
|
+
const scopedClient = resolvePodClient(client, podId);
|
|
24
|
+
const nextFile = await scopedClient.files.update(nextPath, input);
|
|
25
|
+
setUpdatedFile(nextFile);
|
|
26
|
+
onSuccessRef.current?.(nextFile);
|
|
27
|
+
return nextFile;
|
|
28
|
+
}
|
|
29
|
+
catch (updateError) {
|
|
30
|
+
const normalized = normalizeError(updateError, "Failed to update file.");
|
|
31
|
+
setError(normalized);
|
|
32
|
+
onErrorRef.current?.(updateError);
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
finally {
|
|
36
|
+
setIsSubmitting(false);
|
|
37
|
+
}
|
|
38
|
+
}, [client, isEnabled, podId, trimmedPath]);
|
|
39
|
+
const reset = useCallback(() => {
|
|
40
|
+
setUpdatedFile(null);
|
|
41
|
+
setError(null);
|
|
42
|
+
setIsSubmitting(false);
|
|
43
|
+
}, []);
|
|
44
|
+
return useMemo(() => ({
|
|
45
|
+
updatedFile,
|
|
46
|
+
isSubmitting,
|
|
47
|
+
error,
|
|
48
|
+
update,
|
|
49
|
+
reset,
|
|
50
|
+
}), [error, isSubmitting, reset, update, updatedFile]);
|
|
51
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { LemmaClient } from "../client.js";
|
|
2
|
+
import type { FileResponse } from "../types.js";
|
|
3
|
+
export interface UploadFileInput {
|
|
4
|
+
name?: string;
|
|
5
|
+
directoryPath?: string;
|
|
6
|
+
parentId?: string;
|
|
7
|
+
searchEnabled?: boolean;
|
|
8
|
+
description?: string;
|
|
9
|
+
}
|
|
10
|
+
export interface UseUploadFileOptions {
|
|
11
|
+
client: LemmaClient;
|
|
12
|
+
podId?: string;
|
|
13
|
+
enabled?: boolean;
|
|
14
|
+
onSuccess?: (file: FileResponse) => void;
|
|
15
|
+
onError?: (error: unknown) => void;
|
|
16
|
+
}
|
|
17
|
+
export interface UseUploadFileResult<TFile extends FileResponse = FileResponse> {
|
|
18
|
+
uploadedFile: TFile | null;
|
|
19
|
+
isSubmitting: boolean;
|
|
20
|
+
error: Error | null;
|
|
21
|
+
upload: (file: Blob, options?: UploadFileInput) => Promise<TFile | null>;
|
|
22
|
+
reset: () => void;
|
|
23
|
+
}
|
|
24
|
+
export declare function useUploadFile<TFile extends FileResponse = FileResponse>({ client, podId, enabled, onSuccess, onError, }: UseUploadFileOptions): UseUploadFileResult<TFile>;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
2
|
+
import { normalizeError, resolvePodClient } from "./utils.js";
|
|
3
|
+
export function useUploadFile({ client, podId, enabled = true, onSuccess, onError, }) {
|
|
4
|
+
const [uploadedFile, setUploadedFile] = useState(null);
|
|
5
|
+
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
6
|
+
const [error, setError] = useState(null);
|
|
7
|
+
const onSuccessRef = useRef(onSuccess);
|
|
8
|
+
const onErrorRef = useRef(onError);
|
|
9
|
+
useEffect(() => { onSuccessRef.current = onSuccess; }, [onSuccess]);
|
|
10
|
+
useEffect(() => { onErrorRef.current = onError; }, [onError]);
|
|
11
|
+
const upload = useCallback(async (file, options = {}) => {
|
|
12
|
+
if (!enabled) {
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
setIsSubmitting(true);
|
|
16
|
+
setError(null);
|
|
17
|
+
try {
|
|
18
|
+
const scopedClient = resolvePodClient(client, podId);
|
|
19
|
+
const nextFile = await scopedClient.files.upload(file, options);
|
|
20
|
+
setUploadedFile(nextFile);
|
|
21
|
+
onSuccessRef.current?.(nextFile);
|
|
22
|
+
return nextFile;
|
|
23
|
+
}
|
|
24
|
+
catch (uploadError) {
|
|
25
|
+
const normalized = normalizeError(uploadError, "Failed to upload file.");
|
|
26
|
+
setError(normalized);
|
|
27
|
+
onErrorRef.current?.(uploadError);
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
finally {
|
|
31
|
+
setIsSubmitting(false);
|
|
32
|
+
}
|
|
33
|
+
}, [client, enabled, podId]);
|
|
34
|
+
const reset = useCallback(() => {
|
|
35
|
+
setUploadedFile(null);
|
|
36
|
+
setError(null);
|
|
37
|
+
setIsSubmitting(false);
|
|
38
|
+
}, []);
|
|
39
|
+
return useMemo(() => ({
|
|
40
|
+
uploadedFile,
|
|
41
|
+
isSubmitting,
|
|
42
|
+
error,
|
|
43
|
+
upload,
|
|
44
|
+
reset,
|
|
45
|
+
}), [error, isSubmitting, reset, uploadedFile, upload]);
|
|
46
|
+
}
|