@geniee-inc/sfa-kit 1.11.0 → 1.13.0
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/GUIDE.md +244 -1
- package/package.json +1 -1
- package/src/index.d.ts +10 -2
- package/src/index.js +6 -1
- package/src/lib/auth/StandalonePreviewContext.d.ts +1 -0
- package/src/lib/auth/embed-protocol.d.ts +1 -0
- package/src/lib/auth/embed-protocol.js +2 -1
- package/src/lib/components/ErrorSnackbar.d.ts +53 -0
- package/src/lib/components/ErrorSnackbar.js +112 -0
- package/src/lib/hooks/splitBulkRows.d.ts +30 -0
- package/src/lib/hooks/splitBulkRows.js +42 -0
- package/src/lib/hooks/useBulkCreateRecords.d.ts +56 -0
- package/src/lib/hooks/useBulkCreateRecords.js +41 -0
- package/src/lib/hooks/useBulkDeleteRecords.d.ts +50 -0
- package/src/lib/hooks/useBulkDeleteRecords.js +36 -0
- package/src/lib/hooks/useBulkUpdateRecords.d.ts +55 -0
- package/src/lib/hooks/useBulkUpdateRecords.js +37 -0
- package/src/lib/hooks/useBulkWrite.d.ts +55 -0
- package/src/lib/hooks/useBulkWrite.js +160 -0
- package/src/lib/hooks/useRecordList.js +12 -6
- package/src/lib/provider/SfaClient.d.ts +111 -3
- package/src/lib/provider/SfaClient.js +440 -29
- package/src/lib/provider/SfaProvider.js +24 -17
- package/src/lib/provider/types.d.ts +86 -2
- package/src/lib/version.d.ts +1 -1
- package/src/lib/version.js +1 -1
package/GUIDE.md
CHANGED
|
@@ -263,6 +263,219 @@ Check `status` for **every** hook on the page, not just the first one. Each rela
|
|
|
263
263
|
|
|
264
264
|
Returns `{ items, totalCount, hasMore, pageIndex, status, goToPage, isLoading, error, refetch, relationship }`.
|
|
265
265
|
|
|
266
|
+
### Bulk writes
|
|
267
|
+
|
|
268
|
+
`useBulkCreateRecords`, `useBulkUpdateRecords` and `useBulkDeleteRecords` write
|
|
269
|
+
many records of one object in a single call — for grid edits and other
|
|
270
|
+
multi-row saves, instead of looping the single-record hooks. They share the
|
|
271
|
+
contract below; the three sections after it cover only what differs.
|
|
272
|
+
|
|
273
|
+
**A bulk call is best-effort per row.** The API answers `200` and reports every
|
|
274
|
+
row's outcome — there is no all-or-nothing mode, so a batch can be partly
|
|
275
|
+
successful and nothing is rolled back. That is why per-row failures come back
|
|
276
|
+
as data rather than thrown errors: throwing would discard the ids of the rows
|
|
277
|
+
that did land, and those are not recoverable anywhere else.
|
|
278
|
+
|
|
279
|
+
- `results` — one entry per submitted row, carrying `index` (its position in
|
|
280
|
+
the request), `status`, `record` and `error`. Each entry is placed by its
|
|
281
|
+
`index`, never by the order it arrived in, so `results[n]` is the outcome of
|
|
282
|
+
the row that was sent at position `n`. A response that cannot be paired with
|
|
283
|
+
the request — fewer entries than rows, an index missing, not a whole
|
|
284
|
+
position, repeated, or past the end — is refused rather than returned: some
|
|
285
|
+
submitted row would have no reported outcome, and showing the rest as the
|
|
286
|
+
whole picture leaves it looking neither written nor retryable. Re-read the
|
|
287
|
+
records to find out what landed
|
|
288
|
+
- `failed` — the rows that did not land **and can be sent again**: `conflict`,
|
|
289
|
+
`invalid` and `not_attempted`. Retry exactly these. What makes that safe is
|
|
290
|
+
that they did not apply; there is no server-side de-duplication to fall back
|
|
291
|
+
on
|
|
292
|
+
- `unresolved` — the rows that may already be written but cannot be reported
|
|
293
|
+
either way. They keep whatever `id` arrived. **Re-read these; never retry
|
|
294
|
+
them**: a create that already landed would be written twice
|
|
295
|
+
- `isLoading` — a call is in flight. Starting a second call supersedes the
|
|
296
|
+
first: whichever was started last owns the state, so a slower earlier batch
|
|
297
|
+
cannot publish outcomes for rows the caller has moved on from
|
|
298
|
+
- `error` — set only when the whole call was rejected: row count out of range,
|
|
299
|
+
a response that does not answer for every row, auth, a 4xx/5xx. Individual
|
|
300
|
+
row failures are never reported here. The call **also rejects**, the same as
|
|
301
|
+
the single-record write hooks, so catch it to keep the rejection out of the
|
|
302
|
+
console; what to show the reader comes from `error`
|
|
303
|
+
- `reset` — clears results and error
|
|
304
|
+
|
|
305
|
+
A `501` inside that error is not a hiccup: the object does not offer bulk
|
|
306
|
+
writes at all. Waiting or retrying will never make it work, so it arrives as
|
|
307
|
+
`SfaBulkUnsupportedError` — a `SfaApiError` carrying `status` 501 and a
|
|
308
|
+
`userMessage` that says the operation is unavailable rather than asking for
|
|
309
|
+
another try. `ErrorSnackbar` shows that text as it stands; a page writing its
|
|
310
|
+
own wording can tell the case apart with `instanceof` or `status`.
|
|
311
|
+
|
|
312
|
+
Everything the hook reports describes the object it was written for, on the
|
|
313
|
+
visit it was written on. Pass a different collection, or let the provider
|
|
314
|
+
rebuild its client — a token refresh or an org switch does — and results,
|
|
315
|
+
loading and error read empty again rather than carrying the previous object's
|
|
316
|
+
rows over. Coming back to that object does not bring them back either: they
|
|
317
|
+
belong to the visit that ended. A batch that was already in flight answers into
|
|
318
|
+
nothing, then and later.
|
|
319
|
+
|
|
320
|
+
Read-only mode is not reported through `error`: the call throws instead,
|
|
321
|
+
because it is a provider setting rather than an outcome of the request.
|
|
322
|
+
|
|
323
|
+
A call takes 1..`SFA_BULK_MAX_ROWS` (25) rows. The API itself accepts up to
|
|
324
|
+
100; 25 is the kit's own limit, set by how long a call takes — the server
|
|
325
|
+
writes one row at a time, under a budget of its own, and a larger batch would
|
|
326
|
+
outlast it and then the load balancer. A larger selection is refused before a
|
|
327
|
+
request goes out rather than being cut off mid-write. Split bigger selections
|
|
328
|
+
into batches; each batch is its own call.
|
|
329
|
+
|
|
330
|
+
A bulk write is also given a longer fetch timeout than other calls, so the
|
|
331
|
+
browser waits for the server to finish rather than dropping an answer that
|
|
332
|
+
names the rows already written. Should a call time out anyway, treat the whole
|
|
333
|
+
batch the way you treat `unresolved`: the server may have written some of it
|
|
334
|
+
and the answer is gone, so re-read the records rather than sending them
|
|
335
|
+
again.
|
|
336
|
+
|
|
337
|
+
An oversized selection is refused with `SfaBulkRowCountError`, which carries
|
|
338
|
+
`limit`, `submitted` and a ready `userMessage` in Japanese. Show that message
|
|
339
|
+
rather than writing the sentence again in the page, where it would drift from
|
|
340
|
+
the limit the kit enforces. It lands in the hook's `error` like any whole-call
|
|
341
|
+
failure, so showing it takes no state of your own — hand `error` to
|
|
342
|
+
`ErrorSnackbar` and clear it with `reset`:
|
|
343
|
+
|
|
344
|
+
```tsx
|
|
345
|
+
import { ErrorSnackbar, useBulkUpdateRecords } from '@geniee-inc/sfa-kit';
|
|
346
|
+
|
|
347
|
+
const { bulkUpdate, error, reset } = useBulkUpdateRecords('opportunity');
|
|
348
|
+
|
|
349
|
+
// the call rejects as well as setting `error`, so catch it to keep the
|
|
350
|
+
// rejection from reaching the console
|
|
351
|
+
const save = () => void bulkUpdate(edited).catch(() => undefined);
|
|
352
|
+
|
|
353
|
+
<ErrorSnackbar error={error} onClose={reset} />;
|
|
354
|
+
```
|
|
355
|
+
|
|
356
|
+
`ErrorSnackbar` shows the error's `userMessage` when there is one and a general
|
|
357
|
+
line otherwise; an error's `message` is never shown, being English wording
|
|
358
|
+
written for the log. Pass `message` to say something more specific.
|
|
359
|
+
|
|
360
|
+
The same bar reports how a partly successful batch ended — the counts, and the
|
|
361
|
+
action left to take:
|
|
362
|
+
|
|
363
|
+
```tsx
|
|
364
|
+
return (
|
|
365
|
+
<>
|
|
366
|
+
<OpportunityGrid results={results} />
|
|
367
|
+
{/* `error` is null here: the call worked, some rows did not. The bar
|
|
368
|
+
appears because there is a message to show.
|
|
369
|
+
|
|
370
|
+
Not while the retry runs: `results` are kept during a call, so the bar
|
|
371
|
+
and its action would stay on screen, and a second press would send the
|
|
372
|
+
rows again. Nothing de-duplicates them — a repeated create writes
|
|
373
|
+
twice. */}
|
|
374
|
+
{!isLoading && (
|
|
375
|
+
<ErrorSnackbar
|
|
376
|
+
error={null}
|
|
377
|
+
message={`${results.length} 件中 ${updated.length} 件を保存しました。${failed.length} 件は保存されていません。`}
|
|
378
|
+
onRetry={() => void bulkUpdate(rowsFor(failed)).catch(() => undefined)}
|
|
379
|
+
onClose={reset}
|
|
380
|
+
/>
|
|
381
|
+
)}
|
|
382
|
+
</>
|
|
383
|
+
);
|
|
384
|
+
```
|
|
385
|
+
|
|
386
|
+
Counts only: which row failed and why belongs in the grid, where the reader
|
|
387
|
+
acts on it — `results` carries each row's `index` and its per-field errors.
|
|
388
|
+
Offer `onRetry` for `failed` rows; `unresolved` rows are re-read instead, so
|
|
389
|
+
label the action accordingly (`retryLabel`).
|
|
390
|
+
|
|
391
|
+
A row counts as written only when it carries what a write produces: the record
|
|
392
|
+
for a create or an update, the id for a delete. Anything short of that is
|
|
393
|
+
`failed` or `unresolved`, and which one is decided by what the answer says,
|
|
394
|
+
not by elimination. Three statuses mean the row did not apply, and only those
|
|
395
|
+
may be sent again: `conflict`, `invalid`, and `not_attempted` — the last for a
|
|
396
|
+
row the call ran out of budget before starting, which is resent exactly as it
|
|
397
|
+
was. A `conflict` carries the server's current record: retry with the
|
|
398
|
+
`version` it reports, or re-read first if none arrived.
|
|
399
|
+
|
|
400
|
+
Everything else is `unresolved`: `indeterminate`, the server saying it cannot
|
|
401
|
+
tell whether the row applied; a claimed success missing what it should carry —
|
|
402
|
+
no record, an unreadable one, no `version`, an empty id; and any answer the kit
|
|
403
|
+
cannot place, such as a status outside the contract.
|
|
404
|
+
|
|
405
|
+
Every submitted row is therefore in exactly one of the success list
|
|
406
|
+
(`created` / `updated` / `deletedIds`), `failed`, or `unresolved`.
|
|
407
|
+
|
|
408
|
+
### useBulkCreateRecords
|
|
409
|
+
|
|
410
|
+
```tsx
|
|
411
|
+
import { useBulkCreateRecords, SFA_BULK_MAX_ROWS } from '@geniee-inc/sfa-kit';
|
|
412
|
+
|
|
413
|
+
const { bulkCreate, created, failed, isLoading, error } =
|
|
414
|
+
useBulkCreateRecords('opportunity');
|
|
415
|
+
|
|
416
|
+
try {
|
|
417
|
+
await bulkCreate([
|
|
418
|
+
{ company_id: 'cmp_88', stage: '3', amount: 5300000 },
|
|
419
|
+
{ company_id: 'cmp_90', stage: '1', amount: 120000 },
|
|
420
|
+
]);
|
|
421
|
+
} catch {
|
|
422
|
+
// the whole call was rejected; `error` holds it for rendering
|
|
423
|
+
}
|
|
424
|
+
```
|
|
425
|
+
|
|
426
|
+
Each row is a field map, like a single create.
|
|
427
|
+
|
|
428
|
+
- `created` — the records that were written, in submission order
|
|
429
|
+
|
|
430
|
+
Returns `{ bulkCreate, results, created, failed, unresolved, isLoading, error, reset }`.
|
|
431
|
+
|
|
432
|
+
### useBulkUpdateRecords
|
|
433
|
+
|
|
434
|
+
The save action of a grid edit: each row carries its own version.
|
|
435
|
+
|
|
436
|
+
```tsx
|
|
437
|
+
const { bulkUpdate, updated, conflicts, failed } =
|
|
438
|
+
useBulkUpdateRecords('opportunity');
|
|
439
|
+
|
|
440
|
+
await bulkUpdate([
|
|
441
|
+
{ id: 'opp_1', version: 'v1', fields: { stage: '4' } },
|
|
442
|
+
{ id: 'opp_2', version: 'v7', fields: { amount: 90000 } },
|
|
443
|
+
]);
|
|
444
|
+
```
|
|
445
|
+
|
|
446
|
+
A row whose `version` is stale comes back as `conflict` and is **left
|
|
447
|
+
unchanged**, while the rest of the batch applies. Its result carries the
|
|
448
|
+
server's current `record`, which is what the reader reconciles against — retry
|
|
449
|
+
with the version that record reports, never by re-sending the stale one.
|
|
450
|
+
|
|
451
|
+
- `updated` — the records as they now stand on the server, in submission order
|
|
452
|
+
- `conflicts` — the rows a concurrent write got to first, each with the current
|
|
453
|
+
`record`. Also present in `failed`
|
|
454
|
+
|
|
455
|
+
Returns the same shape, with `updated` and `conflicts` in place of `created`.
|
|
456
|
+
|
|
457
|
+
### useBulkDeleteRecords
|
|
458
|
+
|
|
459
|
+
```tsx
|
|
460
|
+
const { bulkDelete, deletedIds, conflicts, failed } =
|
|
461
|
+
useBulkDeleteRecords('opportunity');
|
|
462
|
+
|
|
463
|
+
await bulkDelete([
|
|
464
|
+
{ id: 'opp_1', version: 'v1' },
|
|
465
|
+
{ id: 'opp_2', version: 'v7' },
|
|
466
|
+
]);
|
|
467
|
+
```
|
|
468
|
+
|
|
469
|
+
The same optimistic concurrency: a stale row is reported as `conflict` and
|
|
470
|
+
stays in place. A partly failed batch has already removed records, so reconcile
|
|
471
|
+
from `deletedIds` rather than assume the whole selection is gone.
|
|
472
|
+
|
|
473
|
+
- `deletedIds` — what was actually removed, in submission order. A delete
|
|
474
|
+
answers with no record, so the id comes from the row itself
|
|
475
|
+
|
|
476
|
+
Returns the same shape, with `deletedIds` and `conflicts` in place of
|
|
477
|
+
`created`.
|
|
478
|
+
|
|
266
479
|
### useDescribe
|
|
267
480
|
|
|
268
481
|
Discovers object schema — fields, types, relationships, permissions. Use this to build dynamic UIs that adapt to any object.
|
|
@@ -302,6 +515,8 @@ const record = await client.get('companies', 'abc123');
|
|
|
302
515
|
const list = await client.list('companies', { limit: 10 });
|
|
303
516
|
```
|
|
304
517
|
|
|
518
|
+
`SfaClientInterface` describes the shape of the client the provider hands out — it is not an extension point. Methods are added to it as the API grows, so implementing it yourself would break on any release that covers a new endpoint. Take the client from `useSfaClient()`; to stand one in for a test, mock the hook rather than the interface.
|
|
519
|
+
|
|
305
520
|
## Record shape
|
|
306
521
|
|
|
307
522
|
All hooks return records in a normalized shape:
|
|
@@ -385,7 +600,34 @@ The server's error text is deliberately not shown: it is wording the reader cann
|
|
|
385
600
|
|
|
386
601
|
`refetch` retries whichever step failed. On `useRelatedRecords` that includes a failed describe: retrying only the list would do nothing, because without relationship metadata the list never ran.
|
|
387
602
|
|
|
388
|
-
|
|
603
|
+
Three baseline components for resilient pages:
|
|
604
|
+
|
|
605
|
+
### ErrorSnackbar
|
|
606
|
+
|
|
607
|
+
A bar at the bottom of the page saying why a write did not happen, and how a
|
|
608
|
+
partly successful one ended. Renders nothing until there is something to say —
|
|
609
|
+
an error or a `message` — so a page can hand it the state it keeps the failure
|
|
610
|
+
in.
|
|
611
|
+
|
|
612
|
+
```tsx
|
|
613
|
+
<ErrorSnackbar error={failure} onClose={reset} />
|
|
614
|
+
```
|
|
615
|
+
|
|
616
|
+
- `error` — the error as caught. Its `userMessage` is shown when it has one
|
|
617
|
+
(the row count refusal and the 501 do), a general line otherwise — and that
|
|
618
|
+
line says to try again later, which is why the failures where waiting cannot
|
|
619
|
+
help carry their own text. An error's `message` is never shown: it is
|
|
620
|
+
English wording written for the log
|
|
621
|
+
- `message` — overrides both, for when the page can be more specific. A bulk
|
|
622
|
+
write passes its counts here
|
|
623
|
+
- `onRetry`, `retryLabel` — an optional action beside the text, for the rows
|
|
624
|
+
in `failed`. Name it for what it does: `unresolved` rows are re-read, not
|
|
625
|
+
retried. Take the bar down while the retry runs: a second press sends the
|
|
626
|
+
same rows again, and nothing de-duplicates them
|
|
627
|
+
- `onClose` — clears it. The bar stays until dismissed: a reader told to
|
|
628
|
+
reduce a selection has to read a number before acting on it
|
|
629
|
+
|
|
630
|
+
Use `FetchErrorState` for a failed read and this for a failed write.
|
|
389
631
|
|
|
390
632
|
### ErrorBoundary
|
|
391
633
|
|
|
@@ -728,6 +970,7 @@ Returns `null` for unsupported collections (e.g. `schedules`).
|
|
|
728
970
|
- Do NOT hardcode `defaultOrganizationId` as a number literal in JSX. Use `Number(process.env.NEXT_PUBLIC_DEFAULT_ORG_ID)` and set the value in `.env`.
|
|
729
971
|
- Do NOT embed service account credentials in generated page source. Discovery credentials (used to query the schema at build time) must never appear in deployed code.
|
|
730
972
|
- Do NOT call the API directly with fetch. Use the provided hooks or `useSfaClient()`.
|
|
973
|
+
- Do NOT loop single writes to save many rows. Use the bulk hooks: a loop sends one request per row, and a failure part-way through leaves the caller with no report of what landed.
|
|
731
974
|
- Do NOT add Route Handlers (app/api/) for SFA data. The kit calls the API from the browser.
|
|
732
975
|
- Do NOT hardcode API base URLs, API keys, or credentials anywhere.
|
|
733
976
|
- Do NOT add record detail links automatically. They are opt-in: generate them only when the user asks (e.g. "make the names clickable", "add links to records"), or ask the user for confirmation before adding them. In standalone preview mode, links are unavailable (`useRecordDetailUrl` returns `null`). The `schedules` collection does not have a standard record detail page; if asked, tell the user that schedule links are not yet supported.
|
package/package.json
CHANGED
package/src/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export { SfaApiError, SfaConflictError, SfaValidationError, } from './lib/provider/SfaClient';
|
|
1
|
+
export { SfaApiError, SfaBulkRowCountError, SfaBulkUnsupportedError, SfaConflictError, SfaValidationError, } from './lib/provider/SfaClient';
|
|
2
2
|
export { SfaProvider } from './lib/provider/SfaProvider';
|
|
3
3
|
export { useSfaClient } from './lib/provider/useSfaClient';
|
|
4
4
|
export { SfaLoginGate } from './lib/auth/SfaLoginGate';
|
|
@@ -15,6 +15,12 @@ export { useObjectCatalog } from './lib/hooks/useObjectCatalog';
|
|
|
15
15
|
export { useFieldTypes } from './lib/hooks/useFieldTypes';
|
|
16
16
|
export { useCreateRecord } from './lib/hooks/useCreateRecord';
|
|
17
17
|
export type { UseCreateRecordResult } from './lib/hooks/useCreateRecord';
|
|
18
|
+
export { useBulkCreateRecords } from './lib/hooks/useBulkCreateRecords';
|
|
19
|
+
export type { UseBulkCreateRecordsResult } from './lib/hooks/useBulkCreateRecords';
|
|
20
|
+
export { useBulkUpdateRecords } from './lib/hooks/useBulkUpdateRecords';
|
|
21
|
+
export type { UseBulkUpdateRecordsResult } from './lib/hooks/useBulkUpdateRecords';
|
|
22
|
+
export { useBulkDeleteRecords } from './lib/hooks/useBulkDeleteRecords';
|
|
23
|
+
export type { UseBulkDeleteRecordsResult } from './lib/hooks/useBulkDeleteRecords';
|
|
18
24
|
export { useUpdateRecord } from './lib/hooks/useUpdateRecord';
|
|
19
25
|
export type { UseUpdateRecordResult, SfaConflictDetail, } from './lib/hooks/useUpdateRecord';
|
|
20
26
|
export { useDeleteRecord } from './lib/hooks/useDeleteRecord';
|
|
@@ -27,7 +33,9 @@ export { RecordList } from './lib/components/RecordList';
|
|
|
27
33
|
export { ErrorBoundary } from './lib/components/ErrorBoundary';
|
|
28
34
|
export { EmptyState } from './lib/components/EmptyState';
|
|
29
35
|
export { FetchErrorState } from './lib/components/FetchErrorState';
|
|
30
|
-
export
|
|
36
|
+
export { ErrorSnackbar } from './lib/components/ErrorSnackbar';
|
|
37
|
+
export type { SfaAuth, SfaApiMode, SfaClientConfig, SfaRecord, SfaListResponse, SfaListParams, SfaFieldValue, SfaFieldKind, SfaFieldValueMapping, FieldGroupSpec, FieldGroupLayout, SfaFieldDescriptor, SfaFieldOption, SfaRelationDescriptor, SfaObjectDetail, SfaObjectSummary, SfaObjectCatalog, SfaObjectPermissions, SfaObjectOrigin, SfaRelationshipKind, SfaCardinality, SfaDescribeResult, SfaFieldTypeDescriptor, SfaFieldTypeCatalog, SfaFilterOperator, SfaCreateOptions, SfaFieldError, SfaBulkRowStatus, SfaBulkRowResult, SfaBulkRowError, SfaBulkUpdateRow, SfaBulkDeleteRow, } from './lib/provider/types';
|
|
38
|
+
export { SFA_BULK_MAX_ROWS } from './lib/provider/SfaClient';
|
|
31
39
|
export type { SfaSession, AuthResult, OrganizationChoice, } from './lib/auth/types';
|
|
32
40
|
export { buildRecordDetailUrl, resolveHostOrigin, } from './lib/navigation/record-detail-url';
|
|
33
41
|
export type { RecordDetailUrlParams, RecordDetailLink, } from './lib/navigation/record-detail-url';
|
package/src/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export { SfaApiError, SfaConflictError, SfaValidationError, } from './lib/provider/SfaClient';
|
|
1
|
+
export { SfaApiError, SfaBulkRowCountError, SfaBulkUnsupportedError, SfaConflictError, SfaValidationError, } from './lib/provider/SfaClient';
|
|
2
2
|
export { SfaProvider } from './lib/provider/SfaProvider';
|
|
3
3
|
export { useSfaClient } from './lib/provider/useSfaClient';
|
|
4
4
|
export { SfaLoginGate } from './lib/auth/SfaLoginGate';
|
|
@@ -11,6 +11,9 @@ export { useDescribe } from './lib/hooks/useDescribe';
|
|
|
11
11
|
export { useObjectCatalog } from './lib/hooks/useObjectCatalog';
|
|
12
12
|
export { useFieldTypes } from './lib/hooks/useFieldTypes';
|
|
13
13
|
export { useCreateRecord } from './lib/hooks/useCreateRecord';
|
|
14
|
+
export { useBulkCreateRecords } from './lib/hooks/useBulkCreateRecords';
|
|
15
|
+
export { useBulkUpdateRecords } from './lib/hooks/useBulkUpdateRecords';
|
|
16
|
+
export { useBulkDeleteRecords } from './lib/hooks/useBulkDeleteRecords';
|
|
14
17
|
export { useUpdateRecord } from './lib/hooks/useUpdateRecord';
|
|
15
18
|
export { useDeleteRecord } from './lib/hooks/useDeleteRecord';
|
|
16
19
|
export { useRelatedRecords, RelationNotFoundError, resolveRelation, buildRelationFilter, } from './lib/hooks/useRelatedRecords';
|
|
@@ -20,5 +23,7 @@ export { RecordList } from './lib/components/RecordList';
|
|
|
20
23
|
export { ErrorBoundary } from './lib/components/ErrorBoundary';
|
|
21
24
|
export { EmptyState } from './lib/components/EmptyState';
|
|
22
25
|
export { FetchErrorState } from './lib/components/FetchErrorState';
|
|
26
|
+
export { ErrorSnackbar } from './lib/components/ErrorSnackbar';
|
|
27
|
+
export { SFA_BULK_MAX_ROWS } from './lib/provider/SfaClient';
|
|
23
28
|
export { buildRecordDetailUrl, resolveHostOrigin, } from './lib/navigation/record-detail-url';
|
|
24
29
|
export { useRecordDetailUrl } from './lib/hooks/useRecordDetailUrl';
|
|
@@ -15,6 +15,7 @@ export interface EmbedReadyMessage {
|
|
|
15
15
|
export interface EmbedContext {
|
|
16
16
|
readonly object_key: string;
|
|
17
17
|
readonly record_id?: string;
|
|
18
|
+
readonly list_id?: string;
|
|
18
19
|
}
|
|
19
20
|
/** Sent by the host to deliver a single-use exchange code. */
|
|
20
21
|
export interface EmbedCodeMessage {
|
|
@@ -33,7 +33,8 @@ export function parseInboundMessage(event, trustedOrigin) {
|
|
|
33
33
|
const ctx = rawContext;
|
|
34
34
|
if (typeof ctx['object_key'] === 'string' &&
|
|
35
35
|
ctx['object_key'].length > 0) {
|
|
36
|
-
const context = { object_key: ctx['object_key'] }
|
|
36
|
+
const context = Object.assign({ object_key: ctx['object_key'] }, (typeof ctx['list_id'] === 'string' &&
|
|
37
|
+
ctx['list_id'].length > 0 && { list_id: ctx['list_id'] }));
|
|
37
38
|
if (typeof ctx['record_id'] === 'string' &&
|
|
38
39
|
ctx['record_id'].length > 0) {
|
|
39
40
|
return Object.assign(Object.assign({}, result), { context: Object.assign(Object.assign({}, context), { record_id: ctx['record_id'] }) });
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
interface ErrorSnackbarProps {
|
|
2
|
+
/**
|
|
3
|
+
* The error to announce, as caught. A page can hand this the same state it
|
|
4
|
+
* stores the failure in; pass null when there is none and say what happened
|
|
5
|
+
* through `message` instead, as a partly successful batch does.
|
|
6
|
+
*/
|
|
7
|
+
readonly error: unknown;
|
|
8
|
+
/** Dismisses it. Clear the error the page is holding. */
|
|
9
|
+
readonly onClose: () => void;
|
|
10
|
+
/**
|
|
11
|
+
* Overrides the text. Use when the page can say something more specific
|
|
12
|
+
* than the kit can — which object was being saved, or how a bulk write
|
|
13
|
+
* ended: `8 件中 5 件を保存しました。2 件は保存されていません。` An empty
|
|
14
|
+
* string counts as nothing said, and the error is used instead.
|
|
15
|
+
*/
|
|
16
|
+
readonly message?: string;
|
|
17
|
+
/**
|
|
18
|
+
* Adds an action next to the text. A bulk write hands this the retry of
|
|
19
|
+
* its `failed` rows; omit it when there is nothing to offer.
|
|
20
|
+
*/
|
|
21
|
+
readonly onRetry?: () => void;
|
|
22
|
+
/** Label for that action. */
|
|
23
|
+
readonly retryLabel?: string;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* @description A bar that tells the reader why a write did not happen.
|
|
27
|
+
*
|
|
28
|
+
* The text comes from the error's `userMessage` when it has one — the row
|
|
29
|
+
* count refusal and the 501 that says an object has no bulk write do, since
|
|
30
|
+
* the general line asks for another try and neither is worth retrying — and
|
|
31
|
+
* falls back to that general line otherwise. An error's
|
|
32
|
+
* `message` is never shown: it is written for the log, in English, and names
|
|
33
|
+
* things the reader cannot act on.
|
|
34
|
+
*
|
|
35
|
+
* ```tsx
|
|
36
|
+
* const [failure, setFailure] = useState<unknown>(null);
|
|
37
|
+
*
|
|
38
|
+
* const save = async () => {
|
|
39
|
+
* try {
|
|
40
|
+
* await bulkUpdate(edited);
|
|
41
|
+
* } catch (error) {
|
|
42
|
+
* setFailure(error);
|
|
43
|
+
* }
|
|
44
|
+
* };
|
|
45
|
+
*
|
|
46
|
+
* <ErrorSnackbar error={failure} onClose={() => setFailure(null)} />;
|
|
47
|
+
* ```
|
|
48
|
+
*
|
|
49
|
+
* NOTE: it stays until dismissed. A reader told to reduce a selection has to
|
|
50
|
+
* read a number and then act on it, which a bar that times out takes away.
|
|
51
|
+
*/
|
|
52
|
+
export declare function ErrorSnackbar({ error, onClose, message, onRetry, retryLabel, }: ErrorSnackbarProps): JSX.Element | null;
|
|
53
|
+
export {};
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { color, font, radius, space } from '../theme';
|
|
3
|
+
/** Shown when the error carries nothing a reader can act on. */
|
|
4
|
+
const FALLBACK_MESSAGE = '保存できませんでした。時間をおいて再度お試しください。';
|
|
5
|
+
/**
|
|
6
|
+
* @description A bar that tells the reader why a write did not happen.
|
|
7
|
+
*
|
|
8
|
+
* The text comes from the error's `userMessage` when it has one — the row
|
|
9
|
+
* count refusal and the 501 that says an object has no bulk write do, since
|
|
10
|
+
* the general line asks for another try and neither is worth retrying — and
|
|
11
|
+
* falls back to that general line otherwise. An error's
|
|
12
|
+
* `message` is never shown: it is written for the log, in English, and names
|
|
13
|
+
* things the reader cannot act on.
|
|
14
|
+
*
|
|
15
|
+
* ```tsx
|
|
16
|
+
* const [failure, setFailure] = useState<unknown>(null);
|
|
17
|
+
*
|
|
18
|
+
* const save = async () => {
|
|
19
|
+
* try {
|
|
20
|
+
* await bulkUpdate(edited);
|
|
21
|
+
* } catch (error) {
|
|
22
|
+
* setFailure(error);
|
|
23
|
+
* }
|
|
24
|
+
* };
|
|
25
|
+
*
|
|
26
|
+
* <ErrorSnackbar error={failure} onClose={() => setFailure(null)} />;
|
|
27
|
+
* ```
|
|
28
|
+
*
|
|
29
|
+
* NOTE: it stays until dismissed. A reader told to reduce a selection has to
|
|
30
|
+
* read a number and then act on it, which a bar that times out takes away.
|
|
31
|
+
*/
|
|
32
|
+
export function ErrorSnackbar({ error, onClose, message, onRetry, retryLabel = '再試行', }) {
|
|
33
|
+
// NOTE: a partly successful batch has no error at all — the call worked,
|
|
34
|
+
// some rows did not. The bar still has something to say, so what decides
|
|
35
|
+
// whether it appears is the text, not the error.
|
|
36
|
+
//
|
|
37
|
+
// NOTE: an empty string is a page with nothing to say — a summary built from
|
|
38
|
+
// a list that turned out empty. Taken as given it would draw a bar carrying
|
|
39
|
+
// a warning sign and no words, and announce nothing to a screen reader
|
|
40
|
+
const pageText = message != null && message.trim() !== '' ? message : null;
|
|
41
|
+
const text = pageText !== null && pageText !== void 0 ? pageText : (error == null ? null : readUserMessage(error));
|
|
42
|
+
if (text == null)
|
|
43
|
+
return null;
|
|
44
|
+
return (_jsxs("div", { style: styles.bar, children: [_jsxs("span", { style: styles.text, role: "alert", children: [_jsx("span", { "aria-hidden": "true", children: "\u26A0" }), " ", text] }), onRetry && (_jsx("button", { type: "button", style: styles.retry, onClick: onRetry, children: retryLabel })), _jsx("button", { type: "button", style: styles.close, onClick: onClose, "aria-label": "\u9589\u3058\u308B", children: _jsx("span", { "aria-hidden": "true", children: "\u00D7" }) })] }));
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* @description The reader-facing text of an error, if it has one.
|
|
48
|
+
*
|
|
49
|
+
* NOTE: reads the field rather than testing for a class, so an error type
|
|
50
|
+
* added later carries its own wording here without changing this component —
|
|
51
|
+
* and this stays a presentational component with nothing to know about the
|
|
52
|
+
* provider layer.
|
|
53
|
+
*/
|
|
54
|
+
function readUserMessage(error) {
|
|
55
|
+
if (error != null && typeof error === 'object' && 'userMessage' in error) {
|
|
56
|
+
const carried = error.userMessage;
|
|
57
|
+
if (typeof carried === 'string' && carried !== '')
|
|
58
|
+
return carried;
|
|
59
|
+
}
|
|
60
|
+
return FALLBACK_MESSAGE;
|
|
61
|
+
}
|
|
62
|
+
const styles = {
|
|
63
|
+
bar: {
|
|
64
|
+
position: 'fixed',
|
|
65
|
+
left: '50%',
|
|
66
|
+
bottom: space.lg,
|
|
67
|
+
transform: 'translateX(-50%)',
|
|
68
|
+
zIndex: 1000,
|
|
69
|
+
display: 'flex',
|
|
70
|
+
alignItems: 'center',
|
|
71
|
+
gap: space.md,
|
|
72
|
+
maxWidth: 'calc(100vw - 32px)',
|
|
73
|
+
padding: `${space.sm}px ${space.md}px`,
|
|
74
|
+
fontFamily: font.family,
|
|
75
|
+
fontSize: font.sizeMd,
|
|
76
|
+
color: color.textPrimary,
|
|
77
|
+
backgroundColor: color.errorLight,
|
|
78
|
+
border: `1px solid ${color.error}`,
|
|
79
|
+
borderRadius: radius.sm,
|
|
80
|
+
},
|
|
81
|
+
text: {
|
|
82
|
+
fontWeight: font.weightMedium,
|
|
83
|
+
},
|
|
84
|
+
retry: {
|
|
85
|
+
flexShrink: 0,
|
|
86
|
+
padding: 0,
|
|
87
|
+
fontFamily: font.family,
|
|
88
|
+
fontSize: font.sizeMd,
|
|
89
|
+
color: color.primary,
|
|
90
|
+
backgroundColor: 'transparent',
|
|
91
|
+
border: 'none',
|
|
92
|
+
textDecoration: 'underline',
|
|
93
|
+
cursor: 'pointer',
|
|
94
|
+
},
|
|
95
|
+
close: {
|
|
96
|
+
flexShrink: 0,
|
|
97
|
+
// NOTE: 記号は小さいので、押せる範囲は文字サイズではなく箱で確保する
|
|
98
|
+
display: 'flex',
|
|
99
|
+
alignItems: 'center',
|
|
100
|
+
justifyContent: 'center',
|
|
101
|
+
width: space.lg,
|
|
102
|
+
height: space.lg,
|
|
103
|
+
padding: 0,
|
|
104
|
+
fontFamily: font.family,
|
|
105
|
+
fontSize: font.sizeXl,
|
|
106
|
+
lineHeight: 1,
|
|
107
|
+
color: color.textSecondary,
|
|
108
|
+
backgroundColor: 'transparent',
|
|
109
|
+
border: 'none',
|
|
110
|
+
cursor: 'pointer',
|
|
111
|
+
},
|
|
112
|
+
};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { SfaBulkRowResult, SfaBulkRowStatus } from '../provider/types';
|
|
2
|
+
export interface BulkRowSplit<T> {
|
|
3
|
+
/** What each row that landed produced, in submission order. */
|
|
4
|
+
readonly landed: readonly T[];
|
|
5
|
+
/** May already be applied. Re-read; retrying is what writes a duplicate. */
|
|
6
|
+
readonly unresolved: readonly SfaBulkRowResult[];
|
|
7
|
+
/** The rows a concurrent write got to first. Also present in `failed`. */
|
|
8
|
+
readonly conflicts: readonly SfaBulkRowResult[];
|
|
9
|
+
/**
|
|
10
|
+
* The rows that did not land and can be sent again. A `conflict` is one of
|
|
11
|
+
* them, and also appears in `conflicts`.
|
|
12
|
+
*/
|
|
13
|
+
readonly failed: readonly SfaBulkRowResult[];
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* @description Splits per-row outcomes by what the caller may do with them.
|
|
17
|
+
*
|
|
18
|
+
* NOTE: `failed` is decided by what the answer says, never by exclusion.
|
|
19
|
+
* `conflict`, `invalid` and `not_attempted` all mean the row did not apply, so
|
|
20
|
+
* those are the ones safe to send again — a row the call never started is as
|
|
21
|
+
* unwritten as one the server rejected. Anything else the kit cannot place —
|
|
22
|
+
* `indeterminate`, no record, no version, a status outside the contract or
|
|
23
|
+
* belonging to another operation — may already be written, and a retry would
|
|
24
|
+
* write it twice.
|
|
25
|
+
*
|
|
26
|
+
* NOTE: one pass, so a row that claims success without carrying it falls into
|
|
27
|
+
* `unresolved` rather than out of every list. Order follows `results`, which
|
|
28
|
+
* the client has already aligned with the request.
|
|
29
|
+
*/
|
|
30
|
+
export declare function splitBulkRows<T>(results: readonly SfaBulkRowResult[], landedStatus: SfaBulkRowStatus, read: (row: SfaBulkRowResult) => T | null): BulkRowSplit<T>;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/** The outcomes that mean the row did not apply, so sending it again is safe. */
|
|
2
|
+
const RETRYABLE_STATUSES = new Set([
|
|
3
|
+
'conflict',
|
|
4
|
+
'invalid',
|
|
5
|
+
'not_attempted',
|
|
6
|
+
]);
|
|
7
|
+
/**
|
|
8
|
+
* @description Splits per-row outcomes by what the caller may do with them.
|
|
9
|
+
*
|
|
10
|
+
* NOTE: `failed` is decided by what the answer says, never by exclusion.
|
|
11
|
+
* `conflict`, `invalid` and `not_attempted` all mean the row did not apply, so
|
|
12
|
+
* those are the ones safe to send again — a row the call never started is as
|
|
13
|
+
* unwritten as one the server rejected. Anything else the kit cannot place —
|
|
14
|
+
* `indeterminate`, no record, no version, a status outside the contract or
|
|
15
|
+
* belonging to another operation — may already be written, and a retry would
|
|
16
|
+
* write it twice.
|
|
17
|
+
*
|
|
18
|
+
* NOTE: one pass, so a row that claims success without carrying it falls into
|
|
19
|
+
* `unresolved` rather than out of every list. Order follows `results`, which
|
|
20
|
+
* the client has already aligned with the request.
|
|
21
|
+
*/
|
|
22
|
+
export function splitBulkRows(results, landedStatus, read) {
|
|
23
|
+
const landed = [];
|
|
24
|
+
const unresolved = [];
|
|
25
|
+
const conflicts = [];
|
|
26
|
+
const failed = [];
|
|
27
|
+
for (const row of results) {
|
|
28
|
+
const value = row.status === landedStatus ? read(row) : null;
|
|
29
|
+
if (value != null) {
|
|
30
|
+
landed.push(value);
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
if (RETRYABLE_STATUSES.has(row.status)) {
|
|
34
|
+
failed.push(row);
|
|
35
|
+
if (row.status === 'conflict')
|
|
36
|
+
conflicts.push(row);
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
unresolved.push(row);
|
|
40
|
+
}
|
|
41
|
+
return { landed, unresolved, conflicts, failed };
|
|
42
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { SfaBulkRowResult, SfaRecord } from '../provider/types';
|
|
2
|
+
export interface UseBulkCreateRecordsResult {
|
|
3
|
+
/**
|
|
4
|
+
* Call to create many records at once. Each entry is a field map, like a
|
|
5
|
+
* single create. Resolves with one result per submitted row.
|
|
6
|
+
*/
|
|
7
|
+
readonly bulkCreate: (rows: readonly Record<string, unknown>[]) => Promise<readonly SfaBulkRowResult[]>;
|
|
8
|
+
/**
|
|
9
|
+
* Every row's outcome from the last call, aligned to the submitted rows by
|
|
10
|
+
* `index`. Empty before the first call, and empty again once `collection`
|
|
11
|
+
* or the provider's client changes — and it stays that way if they change
|
|
12
|
+
* back: these outcomes describe the object they were written for, on the
|
|
13
|
+
* visit they were written on.
|
|
14
|
+
*/
|
|
15
|
+
readonly results: readonly SfaBulkRowResult[];
|
|
16
|
+
/** The records that were created, in submission order. */
|
|
17
|
+
readonly created: readonly SfaRecord[];
|
|
18
|
+
/**
|
|
19
|
+
* The rows that did not land and can be sent again, with their `index` and
|
|
20
|
+
* `error`. A batch can be partly successful, so this being non-empty does
|
|
21
|
+
* not mean nothing was written — retry exactly these rows.
|
|
22
|
+
*/
|
|
23
|
+
readonly failed: readonly SfaBulkRowResult[];
|
|
24
|
+
/**
|
|
25
|
+
* The rows that may already be created but cannot be reported either way.
|
|
26
|
+
* They keep whatever `id` arrived. Re-read these; retrying one that landed
|
|
27
|
+
* writes a second record.
|
|
28
|
+
*/
|
|
29
|
+
readonly unresolved: readonly SfaBulkRowResult[];
|
|
30
|
+
readonly isLoading: boolean;
|
|
31
|
+
/**
|
|
32
|
+
* Set when the whole call was rejected — row count out of range, a response
|
|
33
|
+
* that does not answer for every row, auth, or a 4xx/5xx from the API. Rows
|
|
34
|
+
* that failed individually are reported in `failed`, not here, and read-only
|
|
35
|
+
* mode throws synchronously instead: it is a provider setting, not an
|
|
36
|
+
* outcome of this call.
|
|
37
|
+
*/
|
|
38
|
+
readonly error: Error | null;
|
|
39
|
+
/** Reset results and error state to initial values. */
|
|
40
|
+
readonly reset: () => void;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* @description Imperative hook for creating many records of one object in a
|
|
44
|
+
* single call. Like the single-record hooks it does not fetch on mount.
|
|
45
|
+
*
|
|
46
|
+
* A bulk call is best-effort per row: the API answers 200 and reports each
|
|
47
|
+
* row's outcome, with no all-or-nothing mode. So a partly failed batch still
|
|
48
|
+
* created records, and their ids are only available here — which is why
|
|
49
|
+
* per-row failures are returned as data rather than thrown. `error` is for
|
|
50
|
+
* the call as a whole.
|
|
51
|
+
*
|
|
52
|
+
* Retry only the rows in `failed`: those did not apply, which is what makes
|
|
53
|
+
* resending them safe. Re-read the rows in `unresolved` instead — nothing
|
|
54
|
+
* protects against writing them twice.
|
|
55
|
+
*/
|
|
56
|
+
export declare function useBulkCreateRecords(collection: string): UseBulkCreateRecordsResult;
|