@geniee-inc/sfa-kit 1.9.0 → 1.10.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 +97 -8
- package/package.json +1 -1
- package/src/index.d.ts +2 -0
- package/src/index.js +1 -0
- package/src/lib/components/FetchErrorState.d.ts +32 -0
- package/src/lib/components/FetchErrorState.js +47 -0
- package/src/lib/components/RecordList.d.ts +10 -1
- package/src/lib/components/RecordList.js +7 -12
- package/src/lib/components/RecordView.d.ts +10 -1
- package/src/lib/components/RecordView.js +8 -11
- package/src/lib/hooks/useDescribe.d.ts +5 -0
- package/src/lib/hooks/useDescribe.js +59 -1
- package/src/lib/hooks/useRecordList.d.ts +23 -0
- package/src/lib/hooks/useRecordList.js +198 -22
- package/src/lib/hooks/useRelatedRecords.d.ts +11 -0
- package/src/lib/hooks/useRelatedRecords.js +44 -4
- package/src/lib/provider/SfaClient.js +10 -0
- package/src/lib/version.d.ts +1 -1
- package/src/lib/version.js +1 -1
package/GUIDE.md
CHANGED
|
@@ -18,7 +18,7 @@ The package ships built JavaScript and TypeScript declarations only. It contains
|
|
|
18
18
|
|
|
19
19
|
```ts
|
|
20
20
|
import pkg from '@geniee-inc/sfa-kit/package.json';
|
|
21
|
-
// pkg.version -> '1.
|
|
21
|
+
// pkg.version -> '1.10.0'
|
|
22
22
|
```
|
|
23
23
|
|
|
24
24
|
Two additional subpaths are available: `@geniee-inc/sfa-kit/signing` exposes bundle-signing utilities (`computeContentHash`, `computeSignature`, `createManifest`, `createManifestFromDirectory`) for Node.js environments, and `./package.json` exposes the manifest. Internal modules cannot be imported directly.
|
|
@@ -137,10 +137,23 @@ Returns `{ record: SfaRecord | null, isLoading, error, refetch }`.
|
|
|
137
137
|
Fetches a paginated list of records. Supports cursor-based pagination (Keystone API).
|
|
138
138
|
|
|
139
139
|
```tsx
|
|
140
|
-
import { useRecordList } from '@geniee-inc/sfa-kit';
|
|
141
|
-
|
|
142
|
-
const {
|
|
143
|
-
|
|
140
|
+
import { useRecordList, FetchErrorState } from '@geniee-inc/sfa-kit';
|
|
141
|
+
|
|
142
|
+
const {
|
|
143
|
+
items,
|
|
144
|
+
totalCount,
|
|
145
|
+
hasMore,
|
|
146
|
+
pageIndex,
|
|
147
|
+
goToPage,
|
|
148
|
+
status,
|
|
149
|
+
isLoading,
|
|
150
|
+
refetch,
|
|
151
|
+
} = useRecordList('companies');
|
|
152
|
+
|
|
153
|
+
// `items` describes the server only when the fetch succeeded. Render the
|
|
154
|
+
// failure before the list, or a rate limit shows up as "0 records".
|
|
155
|
+
if (status === 'error') return <FetchErrorState onRetry={refetch} />;
|
|
156
|
+
if (isLoading) return <p>Loading...</p>;
|
|
144
157
|
|
|
145
158
|
// With params
|
|
146
159
|
const result = useRecordList('companies', {
|
|
@@ -150,6 +163,10 @@ const result = useRecordList('companies', {
|
|
|
150
163
|
});
|
|
151
164
|
```
|
|
152
165
|
|
|
166
|
+
**Starting position** — `offset` is a record position (`limit` 20 with `offset` 40 starts on page 2) and `cursor` names the same point opaquely. Both describe the _first_ page only: from there `goToPage` computes offsets from the page number and reuses the cursors the server hands back. Changing either starts a new query, so `status` reads `'loading'` with no items until the fetch from the new position settles.
|
|
167
|
+
|
|
168
|
+
Against Keystone (`apiBase` pointing at the API directly) pagination is **cursor-only** — `records/query` takes no offset. Pages are reached in order: the first page needs no cursor, and each response carries the cursor for the next one. Asking to start at `offset > 0` without the cursor for that page fails with an explanatory error rather than quietly answering with the first page, so `status` reads `'error'` and the caller can retry or start from the beginning.
|
|
169
|
+
|
|
153
170
|
**Filters** must use react-querybuilder shape with `combinator` and `rules`. The backend rejects any other format with a 422.
|
|
154
171
|
|
|
155
172
|
```tsx
|
|
@@ -164,6 +181,14 @@ const result = useRecordList('companies', {
|
|
|
164
181
|
});
|
|
165
182
|
```
|
|
166
183
|
|
|
184
|
+
**`status`** is the one field to branch on, and it always describes the query the hook is being asked about right now: `'idle'` (nothing will be fetched and nothing is known), `'loading'` (a fetch is in flight, or the query just changed and its fetch has not settled), `'success'` (the fetch for _this_ query completed and `items` came from the server), `'error'` (the fetch for _this_ query failed). Changing `collection`, `filter`, `sortBy`, `limit`, `offset` or `cursor` starts a new query: from that render on, `status` is `'loading'` and `items` is empty until the new fetch settles — the previous query's records, counts and error are never published as this query's answer. The client counts as part of the query: `SfaProvider` builds a new one whenever the access token, `organizationId`, `apiBase`, `mode` or `readOnly` change, so the same collection and filter can address a different organization. A failed fetch keeps the last successful `items` rather than emptying them, so an error never fabricates a zero — but it also means `items.length === 0` is only a real "the server has none" when `status === 'success'`.
|
|
185
|
+
|
|
186
|
+
`refetch` retries what the reader was trying to reach: after a failed page turn it asks for the page that failed, not the one still on screen. `pageIndex` follows the records being displayed, so the two differ only while an error is showing; once a fetch succeeds they are the same again and `refetch` targets the current page.
|
|
187
|
+
|
|
188
|
+
Disabling the hook, or clearing `collection`, discards any request still in flight: a response that lands after the caller stopped asking never becomes the result, and the `'loading'` a discarded refetch set does not outlive it — the hook goes back to how the query last ended.
|
|
189
|
+
|
|
190
|
+
Returns `{ items, totalCount, hasMore, pageIndex, status, isLoading, error, refetch, goToPage }`.
|
|
191
|
+
|
|
167
192
|
For cross-object queries (fetching child records of a parent), prefer `useRelatedRecords` which builds the filter automatically from describe metadata.
|
|
168
193
|
|
|
169
194
|
### useRelatedRecords
|
|
@@ -232,14 +257,18 @@ const { items, isLoading, error } = useRelatedRecords(
|
|
|
232
257
|
);
|
|
233
258
|
```
|
|
234
259
|
|
|
235
|
-
**Error handling:** If the relationship doesn't exist in the parent's metadata,
|
|
260
|
+
**Error handling:** `status` and `error` cover all three steps — a failed describe, a missing relationship, and a failed list fetch. If the relationship doesn't exist in the parent's metadata, `error` is a `RelationNotFoundError` with an `availableTargets` list so the caller can self-correct.
|
|
261
|
+
|
|
262
|
+
Check `status` for **every** hook on the page, not just the first one. Each related-records hook fails independently: with several fetches firing on mount, one can hit a rate limit or a timeout while the others succeed, and the failed one renders as an empty section unless its own status is read.
|
|
236
263
|
|
|
237
|
-
Returns `{ items, totalCount, hasMore, pageIndex, goToPage, isLoading, error, refetch, relationship }`.
|
|
264
|
+
Returns `{ items, totalCount, hasMore, pageIndex, status, goToPage, isLoading, error, refetch, relationship }`.
|
|
238
265
|
|
|
239
266
|
### useDescribe
|
|
240
267
|
|
|
241
268
|
Discovers object schema — fields, types, relationships, permissions. Use this to build dynamic UIs that adapt to any object.
|
|
242
269
|
|
|
270
|
+
`object`, `isLoading` and `error` always describe the key passed right now, through the client in use right now. Changing either reports `isLoading` with a `null` object until the new fetch settles, so one object's metadata — or one organization's schema — is never attributed to another.
|
|
271
|
+
|
|
243
272
|
```tsx
|
|
244
273
|
import { useDescribe } from '@geniee-inc/sfa-kit';
|
|
245
274
|
|
|
@@ -299,6 +328,63 @@ Access field values: `record.fields['company_name']?.value` for the raw value, `
|
|
|
299
328
|
|
|
300
329
|
## Error handling
|
|
301
330
|
|
|
331
|
+
### Fetch errors are returned, not thrown
|
|
332
|
+
|
|
333
|
+
Every data hook reports a failed fetch through its own `status` / `error`. Nothing throws, so `ErrorBoundary` does not see these — the page must branch on them itself. Skipping that check is the one mistake that is invisible in review: the page renders, with an empty list and a count of 0, and looks exactly like a record that genuinely has no children.
|
|
334
|
+
|
|
335
|
+
Branch in this order, for **every** hook on the page:
|
|
336
|
+
|
|
337
|
+
```tsx
|
|
338
|
+
import {
|
|
339
|
+
useRelatedRecords,
|
|
340
|
+
FetchErrorState,
|
|
341
|
+
EmptyState,
|
|
342
|
+
} from '@geniee-inc/sfa-kit';
|
|
343
|
+
|
|
344
|
+
const { items, status, refetch } = useRelatedRecords(
|
|
345
|
+
'companies',
|
|
346
|
+
companyId,
|
|
347
|
+
'tasks'
|
|
348
|
+
);
|
|
349
|
+
|
|
350
|
+
// 1. Failure first: never fall through to a count or an empty list
|
|
351
|
+
if (status === 'error')
|
|
352
|
+
return <FetchErrorState onRetry={refetch} message="タスクの取得に失敗" />;
|
|
353
|
+
|
|
354
|
+
// 2. Nothing was fetched yet
|
|
355
|
+
if (status === 'loading' || status === 'idle') return <p>Loading...</p>;
|
|
356
|
+
|
|
357
|
+
// 3. Only now is an empty list a fact about the server
|
|
358
|
+
if (items.length === 0) return <EmptyState message="タスクはありません" />;
|
|
359
|
+
|
|
360
|
+
return (
|
|
361
|
+
<>
|
|
362
|
+
<h2>タスク ({items.length})</h2>
|
|
363
|
+
{items.map(task => (
|
|
364
|
+
<p key={task.record_id}>{task.title ?? task.record_id}</p>
|
|
365
|
+
))}
|
|
366
|
+
</>
|
|
367
|
+
);
|
|
368
|
+
```
|
|
369
|
+
|
|
370
|
+
Reading `items` before step 1 is what turns a rate limit into a confident `タスク (0)`. Each hook fails on its own, so one section can fail while the rest of the page renders normally — a shared `if (aError || bError)` at the top hides which one it was and blanks the sections that did load.
|
|
371
|
+
|
|
372
|
+
Two components exist for the two states so they are never confused:
|
|
373
|
+
|
|
374
|
+
### FetchErrorState
|
|
375
|
+
|
|
376
|
+
The state for `status === 'error'`. Renders `⚠ 取得失敗` and, when given `onRetry`, a `再取得` link that refetches in place.
|
|
377
|
+
|
|
378
|
+
```tsx
|
|
379
|
+
<FetchErrorState onRetry={refetch} message="タスクの取得に失敗" />
|
|
380
|
+
```
|
|
381
|
+
|
|
382
|
+
Props: `{ onRetry?: () => void, message?: string, retryLabel?: string }`. Without `onRetry` no link is rendered — a dead control is worse than none.
|
|
383
|
+
|
|
384
|
+
The server's error text is deliberately not shown: it is wording the reader cannot act on. Pages that need it still have `error` from the hook, and `RecordList` / `RecordView` report it through their `onError` prop.
|
|
385
|
+
|
|
386
|
+
`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
|
+
|
|
302
388
|
Two baseline components for resilient pages:
|
|
303
389
|
|
|
304
390
|
### ErrorBoundary
|
|
@@ -315,7 +401,7 @@ import { ErrorBoundary } from '@geniee-inc/sfa-kit';
|
|
|
315
401
|
|
|
316
402
|
### EmptyState
|
|
317
403
|
|
|
318
|
-
Placeholder for pages with no data.
|
|
404
|
+
Placeholder for pages with no data. Use it only once `status === 'success'` — before that an empty list means "not known", and "no data" states a fact the page never got back from the server.
|
|
319
405
|
|
|
320
406
|
```tsx
|
|
321
407
|
import { EmptyState } from '@geniee-inc/sfa-kit';
|
|
@@ -383,6 +469,9 @@ Props:
|
|
|
383
469
|
- `onRowClick` (optional) - `(record) => void` callback.
|
|
384
470
|
- `showPagination` (optional) - Default true.
|
|
385
471
|
- `params` (optional) - `{ limit?, offset?, sortBy?, sortOrder?, filter?, cursor? }`.
|
|
472
|
+
- `errorMessage` (optional) - what to say when the fetch fails, e.g. `"タスクの取得に失敗"`. `RecordList` has nothing to fall back on, so on a page with several sections pass the label that section already displays — a failure then says which one it was. `collection` is never used for this: it is the object key the API is addressed with (`business_discussions`), not something to show a reader.
|
|
473
|
+
|
|
474
|
+
`RecordView` takes the same prop and, without it, names its `title` — the label it already shows as the heading — so a page that passed `title="会社"` gets `⚠ 会社の取得に失敗` for free.
|
|
386
475
|
|
|
387
476
|
### FieldRenderer
|
|
388
477
|
|
package/package.json
CHANGED
package/src/index.d.ts
CHANGED
|
@@ -9,6 +9,7 @@ export type { EmbedContext } from './lib/auth/embed-protocol';
|
|
|
9
9
|
export type { StandalonePreviewState, StandalonePreviewControls, } from './lib/auth/StandalonePreviewContext';
|
|
10
10
|
export { useRecord } from './lib/hooks/useRecord';
|
|
11
11
|
export { useRecordList } from './lib/hooks/useRecordList';
|
|
12
|
+
export type { SfaListStatus } from './lib/hooks/useRecordList';
|
|
12
13
|
export { useDescribe } from './lib/hooks/useDescribe';
|
|
13
14
|
export { useObjectCatalog } from './lib/hooks/useObjectCatalog';
|
|
14
15
|
export { useFieldTypes } from './lib/hooks/useFieldTypes';
|
|
@@ -25,6 +26,7 @@ export { RecordView } from './lib/components/RecordView';
|
|
|
25
26
|
export { RecordList } from './lib/components/RecordList';
|
|
26
27
|
export { ErrorBoundary } from './lib/components/ErrorBoundary';
|
|
27
28
|
export { EmptyState } from './lib/components/EmptyState';
|
|
29
|
+
export { FetchErrorState } from './lib/components/FetchErrorState';
|
|
28
30
|
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, } from './lib/provider/types';
|
|
29
31
|
export type { SfaSession, AuthResult, OrganizationChoice, } from './lib/auth/types';
|
|
30
32
|
export { buildRecordDetailUrl, resolveHostOrigin, } from './lib/navigation/record-detail-url';
|
package/src/index.js
CHANGED
|
@@ -19,5 +19,6 @@ export { RecordView } from './lib/components/RecordView';
|
|
|
19
19
|
export { RecordList } from './lib/components/RecordList';
|
|
20
20
|
export { ErrorBoundary } from './lib/components/ErrorBoundary';
|
|
21
21
|
export { EmptyState } from './lib/components/EmptyState';
|
|
22
|
+
export { FetchErrorState } from './lib/components/FetchErrorState';
|
|
22
23
|
export { buildRecordDetailUrl, resolveHostOrigin, } from './lib/navigation/record-detail-url';
|
|
23
24
|
export { useRecordDetailUrl } from './lib/hooks/useRecordDetailUrl';
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
interface FetchErrorStateProps {
|
|
2
|
+
/** Runs the fetch again. Pass the hook's `refetch`. */
|
|
3
|
+
readonly onRetry?: () => void;
|
|
4
|
+
/** Label text after the warning mark. Override to name what failed. */
|
|
5
|
+
readonly message?: string;
|
|
6
|
+
readonly retryLabel?: string;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* @description The state to render when a data hook reports
|
|
10
|
+
* `status === 'error'`. Says the fetch failed and offers a retry, so a rate
|
|
11
|
+
* limit or a dropped connection is never shown as an empty list.
|
|
12
|
+
*
|
|
13
|
+
* Do not use `EmptyState` for this: "no data" claims something the page
|
|
14
|
+
* never got back from the server, and gives the user nothing to act on.
|
|
15
|
+
*
|
|
16
|
+
* The underlying error is not shown: it is server wording the reader cannot
|
|
17
|
+
* act on. Pages that need it still have `error` from the hook, and
|
|
18
|
+
* RecordList / RecordView report it through their `onError` prop.
|
|
19
|
+
*
|
|
20
|
+
* ```tsx
|
|
21
|
+
* const { items, status, refetch } = useRelatedRecords(
|
|
22
|
+
* 'companies',
|
|
23
|
+
* companyId,
|
|
24
|
+
* 'tasks'
|
|
25
|
+
* );
|
|
26
|
+
*
|
|
27
|
+
* if (status === 'error')
|
|
28
|
+
* return <FetchErrorState onRetry={refetch} message="タスクの取得に失敗" />;
|
|
29
|
+
* ```
|
|
30
|
+
*/
|
|
31
|
+
export declare function FetchErrorState({ onRetry, message, retryLabel, }: FetchErrorStateProps): JSX.Element;
|
|
32
|
+
export {};
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { color, font, space, baseContainerStyle } from '../theme';
|
|
3
|
+
/**
|
|
4
|
+
* @description The state to render when a data hook reports
|
|
5
|
+
* `status === 'error'`. Says the fetch failed and offers a retry, so a rate
|
|
6
|
+
* limit or a dropped connection is never shown as an empty list.
|
|
7
|
+
*
|
|
8
|
+
* Do not use `EmptyState` for this: "no data" claims something the page
|
|
9
|
+
* never got back from the server, and gives the user nothing to act on.
|
|
10
|
+
*
|
|
11
|
+
* The underlying error is not shown: it is server wording the reader cannot
|
|
12
|
+
* act on. Pages that need it still have `error` from the hook, and
|
|
13
|
+
* RecordList / RecordView report it through their `onError` prop.
|
|
14
|
+
*
|
|
15
|
+
* ```tsx
|
|
16
|
+
* const { items, status, refetch } = useRelatedRecords(
|
|
17
|
+
* 'companies',
|
|
18
|
+
* companyId,
|
|
19
|
+
* 'tasks'
|
|
20
|
+
* );
|
|
21
|
+
*
|
|
22
|
+
* if (status === 'error')
|
|
23
|
+
* return <FetchErrorState onRetry={refetch} message="タスクの取得に失敗" />;
|
|
24
|
+
* ```
|
|
25
|
+
*/
|
|
26
|
+
export function FetchErrorState({ onRetry, message = '取得失敗', retryLabel = '再取得', }) {
|
|
27
|
+
return (_jsxs("div", { style: styles.container, children: [_jsxs("p", { style: styles.message, role: "alert", children: [_jsx("span", { "aria-hidden": "true", children: "\u26A0" }), " ", message] }), onRetry && (_jsx("button", { type: "button", style: styles.retryLink, onClick: onRetry, children: retryLabel }))] }));
|
|
28
|
+
}
|
|
29
|
+
const styles = {
|
|
30
|
+
container: Object.assign(Object.assign({}, baseContainerStyle), { display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', padding: space.lg, textAlign: 'center' }),
|
|
31
|
+
message: {
|
|
32
|
+
fontSize: font.sizeMd,
|
|
33
|
+
fontWeight: font.weightMedium,
|
|
34
|
+
color: color.error,
|
|
35
|
+
margin: `0 0 ${space.sm}px`,
|
|
36
|
+
},
|
|
37
|
+
retryLink: {
|
|
38
|
+
padding: 0,
|
|
39
|
+
fontFamily: font.family,
|
|
40
|
+
fontSize: font.sizeMd,
|
|
41
|
+
color: color.primary,
|
|
42
|
+
backgroundColor: 'transparent',
|
|
43
|
+
border: 'none',
|
|
44
|
+
textDecoration: 'underline',
|
|
45
|
+
cursor: 'pointer',
|
|
46
|
+
},
|
|
47
|
+
};
|
|
@@ -12,9 +12,18 @@ interface RecordListProps {
|
|
|
12
12
|
readonly showPagination?: boolean;
|
|
13
13
|
readonly onRowClick?: (record: SfaRecord) => void;
|
|
14
14
|
readonly onError?: (error: Error) => void;
|
|
15
|
+
/**
|
|
16
|
+
* What to say when the fetch fails. Defaults to a generic wording.
|
|
17
|
+
*
|
|
18
|
+
* NOTE: the component cannot name the records itself — `collection` is the
|
|
19
|
+
* object key the API is addressed with (`business_discussions`), not
|
|
20
|
+
* something to show a reader. A page with several sections should pass the
|
|
21
|
+
* label it already displays, so a failure says which section it was.
|
|
22
|
+
*/
|
|
23
|
+
readonly errorMessage?: string;
|
|
15
24
|
}
|
|
16
25
|
/**
|
|
17
26
|
* @description Fetches and displays a paginated list of SFA/CRM records as a table.
|
|
18
27
|
*/
|
|
19
|
-
export declare function RecordList({ collection, columns, params, pageSize, showPagination, onRowClick, onError, }: RecordListProps): JSX.Element;
|
|
28
|
+
export declare function RecordList({ collection, columns, params, pageSize, showPagination, onRowClick, onError, errorMessage, }: RecordListProps): JSX.Element;
|
|
20
29
|
export {};
|
|
@@ -3,14 +3,15 @@ import { useEffect, useMemo, useRef } from 'react';
|
|
|
3
3
|
import { useRecordList } from '../hooks/useRecordList';
|
|
4
4
|
import { color, font, radius, space, baseContainerStyle, baseButtonStyle, } from '../theme';
|
|
5
5
|
import { SFA_KIT_VERSION } from '../version';
|
|
6
|
+
import { FetchErrorState } from './FetchErrorState';
|
|
6
7
|
import { FieldRenderer } from './FieldRenderer';
|
|
7
8
|
const MAX_AUTO_COLUMNS = 8;
|
|
8
9
|
/**
|
|
9
10
|
* @description Fetches and displays a paginated list of SFA/CRM records as a table.
|
|
10
11
|
*/
|
|
11
|
-
export function RecordList({ collection, columns, params, pageSize = 20, showPagination = true, onRowClick, onError, }) {
|
|
12
|
+
export function RecordList({ collection, columns, params, pageSize = 20, showPagination = true, onRowClick, onError, errorMessage, }) {
|
|
12
13
|
const listParams = useMemo(() => (Object.assign(Object.assign({}, params), { limit: pageSize })), [params, pageSize]);
|
|
13
|
-
const { items, totalCount, hasMore, pageIndex, isLoading, error, goToPage } = useRecordList(collection, listParams);
|
|
14
|
+
const { items, totalCount, hasMore, pageIndex, status, isLoading, error, refetch, goToPage, } = useRecordList(collection, listParams);
|
|
14
15
|
// Use a ref so inline onError callbacks don't re-fire the effect on
|
|
15
16
|
// every parent render when error is present.
|
|
16
17
|
const onErrorRef = useRef(onError);
|
|
@@ -20,8 +21,10 @@ export function RecordList({ collection, columns, params, pageSize = 20, showPag
|
|
|
20
21
|
if (error)
|
|
21
22
|
(_a = onErrorRef.current) === null || _a === void 0 ? void 0 : _a.call(onErrorRef, error);
|
|
22
23
|
}, [error]);
|
|
23
|
-
|
|
24
|
-
|
|
24
|
+
// A failed fetch gets its own state with a retry, never the empty table:
|
|
25
|
+
// "0 records" would state something the server never answered.
|
|
26
|
+
if (status === 'error') {
|
|
27
|
+
return (_jsxs("div", { style: styles.container, children: [_jsx(FetchErrorState, { message: errorMessage, onRetry: refetch }), _jsxs("div", { style: styles.versionFooter, children: ["@geniee-inc/sfa-kit v", SFA_KIT_VERSION] })] }));
|
|
25
28
|
}
|
|
26
29
|
const resolvedColumns = columns !== null && columns !== void 0 ? columns : autoDetectColumns(items);
|
|
27
30
|
const totalPages = Math.max(1, Math.ceil(totalCount / pageSize));
|
|
@@ -122,14 +125,6 @@ const styles = {
|
|
|
122
125
|
fontSize: font.sizeSm,
|
|
123
126
|
color: color.textMuted,
|
|
124
127
|
},
|
|
125
|
-
error: {
|
|
126
|
-
padding: space.sm,
|
|
127
|
-
backgroundColor: color.errorLight,
|
|
128
|
-
border: `1px solid ${color.error}`,
|
|
129
|
-
borderRadius: radius.sm,
|
|
130
|
-
color: color.error,
|
|
131
|
-
fontSize: font.sizeMd,
|
|
132
|
-
},
|
|
133
128
|
versionFooter: {
|
|
134
129
|
textAlign: 'right',
|
|
135
130
|
fontSize: font.sizeXs,
|
|
@@ -6,9 +6,18 @@ interface RecordViewProps {
|
|
|
6
6
|
readonly fields?: readonly string[];
|
|
7
7
|
readonly fieldGroups?: readonly FieldGroupSpec[];
|
|
8
8
|
readonly onError?: (error: Error) => void;
|
|
9
|
+
/**
|
|
10
|
+
* What to say when the fetch fails. Falls back to naming `title` when one is
|
|
11
|
+
* given, and to a generic wording otherwise.
|
|
12
|
+
*
|
|
13
|
+
* NOTE: `collection` is never used for this — it is the object key the API is
|
|
14
|
+
* addressed with, not something to show a reader. See RecordList, which has
|
|
15
|
+
* no title to fall back on and so needs this prop to name its section.
|
|
16
|
+
*/
|
|
17
|
+
readonly errorMessage?: string;
|
|
9
18
|
}
|
|
10
19
|
/**
|
|
11
20
|
* @description Fetches and displays a single SFA/CRM record in a label-value grid.
|
|
12
21
|
*/
|
|
13
|
-
export declare function RecordView({ collection, recordId, title, fields, fieldGroups, onError, }: RecordViewProps): JSX.Element;
|
|
22
|
+
export declare function RecordView({ collection, recordId, title, fields, fieldGroups, onError, errorMessage, }: RecordViewProps): JSX.Element;
|
|
14
23
|
export {};
|
|
@@ -3,13 +3,14 @@ import { useEffect, useRef } from 'react';
|
|
|
3
3
|
import { useRecord } from '../hooks/useRecord';
|
|
4
4
|
import { color, font, radius, space, baseContainerStyle } from '../theme';
|
|
5
5
|
import { SFA_KIT_VERSION } from '../version';
|
|
6
|
+
import { FetchErrorState } from './FetchErrorState';
|
|
6
7
|
import { FieldRenderer } from './FieldRenderer';
|
|
7
8
|
/**
|
|
8
9
|
* @description Fetches and displays a single SFA/CRM record in a label-value grid.
|
|
9
10
|
*/
|
|
10
|
-
export function RecordView({ collection, recordId, title, fields, fieldGroups, onError, }) {
|
|
11
|
+
export function RecordView({ collection, recordId, title, fields, fieldGroups, onError, errorMessage, }) {
|
|
11
12
|
var _a;
|
|
12
|
-
const { record, isLoading, error } = useRecord(collection, recordId);
|
|
13
|
+
const { record, isLoading, error, refetch } = useRecord(collection, recordId);
|
|
13
14
|
// Use a ref so inline onError callbacks don't re-fire the effect on
|
|
14
15
|
// every parent render when error is present.
|
|
15
16
|
const onErrorRef = useRef(onError);
|
|
@@ -20,7 +21,11 @@ export function RecordView({ collection, recordId, title, fields, fieldGroups, o
|
|
|
20
21
|
(_a = onErrorRef.current) === null || _a === void 0 ? void 0 : _a.call(onErrorRef, error);
|
|
21
22
|
}, [error]);
|
|
22
23
|
if (error) {
|
|
23
|
-
|
|
24
|
+
// NOTE: `title` is the label the page already shows as the heading, so it
|
|
25
|
+
// names the record without exposing `collection` — the object key the API
|
|
26
|
+
// is addressed with. The derived heading is unavailable here: it reads the
|
|
27
|
+
// record, and on a failure there is none.
|
|
28
|
+
return (_jsxs("div", { style: styles.container, children: [_jsx(FetchErrorState, { message: errorMessage !== null && errorMessage !== void 0 ? errorMessage : (title ? `${title}の取得に失敗` : undefined), onRetry: refetch }), _jsxs("div", { style: styles.versionFooter, children: ["@geniee-inc/sfa-kit v", SFA_KIT_VERSION] })] }));
|
|
24
29
|
}
|
|
25
30
|
if (isLoading || !record) {
|
|
26
31
|
return (_jsxs("div", { style: styles.container, children: [_jsx("div", { style: styles.loading, children: "Loading..." }), _jsxs("div", { style: styles.versionFooter, children: ["@geniee-inc/sfa-kit v", SFA_KIT_VERSION] })] }));
|
|
@@ -130,14 +135,6 @@ const styles = {
|
|
|
130
135
|
color: color.textMuted,
|
|
131
136
|
fontSize: font.sizeLg,
|
|
132
137
|
},
|
|
133
|
-
error: {
|
|
134
|
-
padding: space.sm,
|
|
135
|
-
backgroundColor: color.errorLight,
|
|
136
|
-
border: `1px solid ${color.error}`,
|
|
137
|
-
borderRadius: radius.sm,
|
|
138
|
-
color: color.error,
|
|
139
|
-
fontSize: font.sizeMd,
|
|
140
|
-
},
|
|
141
138
|
empty: {
|
|
142
139
|
padding: space.lg,
|
|
143
140
|
textAlign: 'center',
|
|
@@ -11,6 +11,11 @@ interface UseDescribeResult {
|
|
|
11
11
|
/**
|
|
12
12
|
* @description Fetches metadata for an object: fields, types, relationships, permissions.
|
|
13
13
|
* The AI agent calls this to discover schema before rendering.
|
|
14
|
+
*
|
|
15
|
+
* `object`, `isLoading` and `error` always describe the `objectKey` being asked
|
|
16
|
+
* about right now, through the client in use right now. Until a fetch for that
|
|
17
|
+
* pair settles they read as loading, so one object's metadata — or one
|
|
18
|
+
* organization's — is never reported as another's.
|
|
14
19
|
*/
|
|
15
20
|
export declare function useDescribe(objectKey: string, options?: UseDescribeOptions): UseDescribeResult;
|
|
16
21
|
export {};
|
|
@@ -3,6 +3,11 @@ import { useSfaClient } from '../provider/useSfaClient';
|
|
|
3
3
|
/**
|
|
4
4
|
* @description Fetches metadata for an object: fields, types, relationships, permissions.
|
|
5
5
|
* The AI agent calls this to discover schema before rendering.
|
|
6
|
+
*
|
|
7
|
+
* `object`, `isLoading` and `error` always describe the `objectKey` being asked
|
|
8
|
+
* about right now, through the client in use right now. Until a fetch for that
|
|
9
|
+
* pair settles they read as loading, so one object's metadata — or one
|
|
10
|
+
* organization's — is never reported as another's.
|
|
6
11
|
*/
|
|
7
12
|
export function useDescribe(objectKey, options) {
|
|
8
13
|
var _a;
|
|
@@ -12,6 +17,18 @@ export function useDescribe(objectKey, options) {
|
|
|
12
17
|
const [isLoading, setIsLoading] = useState(enabled && !!objectKey);
|
|
13
18
|
const [error, setError] = useState(null);
|
|
14
19
|
const fetchIdRef = useRef(0);
|
|
20
|
+
/**
|
|
21
|
+
* The key and client whose fetch has settled — succeeded or failed. The state
|
|
22
|
+
* below only describes objectKey once both match.
|
|
23
|
+
*
|
|
24
|
+
* NOTE: the client is part of it, for the reason spelled out in
|
|
25
|
+
* useRecordList — SfaProvider rebuilds it when the organization or token
|
|
26
|
+
* changes, so the same key can describe another organization's object.
|
|
27
|
+
*/
|
|
28
|
+
const settledRef = useRef(null);
|
|
29
|
+
// What the metadata in state describes, so a failure can tell "keep what we
|
|
30
|
+
// have" from "what we have describes something else".
|
|
31
|
+
const loadedRef = useRef(null);
|
|
15
32
|
const doFetch = useCallback(() => {
|
|
16
33
|
if (!objectKey || !enabled)
|
|
17
34
|
return;
|
|
@@ -23,18 +40,59 @@ export function useDescribe(objectKey, options) {
|
|
|
23
40
|
.then(result => {
|
|
24
41
|
if (fetchId !== fetchIdRef.current)
|
|
25
42
|
return;
|
|
43
|
+
// NOTE: refs before setState — see useRecordList for why.
|
|
44
|
+
settledRef.current = { client, objectKey };
|
|
45
|
+
loadedRef.current = { client, objectKey };
|
|
26
46
|
setObject(result.object);
|
|
27
47
|
setIsLoading(false);
|
|
28
48
|
})
|
|
29
49
|
.catch((err) => {
|
|
30
50
|
if (fetchId !== fetchIdRef.current)
|
|
31
51
|
return;
|
|
52
|
+
// Read before writing: this is what the metadata in state describes.
|
|
53
|
+
const loaded = loadedRef.current;
|
|
54
|
+
const describesSameThing = loaded != null &&
|
|
55
|
+
loaded.client === client &&
|
|
56
|
+
loaded.objectKey === objectKey;
|
|
57
|
+
if (!describesSameThing)
|
|
58
|
+
loadedRef.current = null;
|
|
59
|
+
settledRef.current = { client, objectKey };
|
|
32
60
|
setError(err instanceof Error ? err : new Error(String(err)));
|
|
33
61
|
setIsLoading(false);
|
|
62
|
+
// Metadata for another key or organization must not survive: it would
|
|
63
|
+
// describe one object as if it were another. A failure on the same
|
|
64
|
+
// question keeps it, so a transient error does not blank a form that
|
|
65
|
+
// was already rendering.
|
|
66
|
+
if (loaded != null && !describesSameThing)
|
|
67
|
+
setObject(null);
|
|
34
68
|
});
|
|
35
69
|
}, [client, objectKey, enabled]);
|
|
36
70
|
useEffect(() => {
|
|
71
|
+
// Invalidate anything still in flight before deciding what to do next.
|
|
72
|
+
// doFetch cannot do this itself: with no objectKey, or while disabled, it
|
|
73
|
+
// returns before reaching its own bump, so a response that lands after the
|
|
74
|
+
// caller stopped asking would still pass the id check and be recorded.
|
|
75
|
+
fetchIdRef.current += 1;
|
|
76
|
+
if (!objectKey || !enabled) {
|
|
77
|
+
// A discarded refetch would otherwise leave isLoading true with nothing
|
|
78
|
+
// to resolve it. The metadata already in hand still stands.
|
|
79
|
+
setIsLoading(false);
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
37
82
|
doFetch();
|
|
38
|
-
}, [doFetch]);
|
|
83
|
+
}, [doFetch, objectKey, enabled]);
|
|
84
|
+
// Until a fetch for the current key and client settles, the state describes
|
|
85
|
+
// what was asked before it.
|
|
86
|
+
const settled = settledRef.current;
|
|
87
|
+
if (settled == null ||
|
|
88
|
+
settled.client !== client ||
|
|
89
|
+
settled.objectKey !== objectKey) {
|
|
90
|
+
return {
|
|
91
|
+
object: null,
|
|
92
|
+
isLoading: enabled && !!objectKey,
|
|
93
|
+
error: null,
|
|
94
|
+
refetch: doFetch,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
39
97
|
return { object, isLoading, error, refetch: doFetch };
|
|
40
98
|
}
|
|
@@ -2,11 +2,34 @@ import type { SfaListParams, SfaRecord } from '../provider/types';
|
|
|
2
2
|
interface UseRecordListOptions {
|
|
3
3
|
readonly enabled?: boolean;
|
|
4
4
|
}
|
|
5
|
+
/**
|
|
6
|
+
* @description Lifecycle of a list fetch. `items` only describes the server
|
|
7
|
+
* when this is `'success'`.
|
|
8
|
+
*
|
|
9
|
+
* - `idle`: nothing will be fetched and nothing is known — no collection, or
|
|
10
|
+
* disabled before anything settled. A hook disabled *after* a successful
|
|
11
|
+
* fetch keeps reporting `'success'`, because its `items` did come from the
|
|
12
|
+
* server
|
|
13
|
+
* - `loading`: a fetch is in flight, or the query just changed and the fetch
|
|
14
|
+
* for it has not settled yet
|
|
15
|
+
* - `success`: the last fetch for *this* query completed and `items` came
|
|
16
|
+
* from the server
|
|
17
|
+
* - `error`: the last fetch for *this* query failed. `items` is the last
|
|
18
|
+
* success of the same query, or empty when there was none
|
|
19
|
+
*/
|
|
20
|
+
export type SfaListStatus = 'idle' | 'loading' | 'success' | 'error';
|
|
5
21
|
interface UseRecordListResult {
|
|
22
|
+
/**
|
|
23
|
+
* @description Records from the last successful fetch. An empty array is
|
|
24
|
+
* only meaningful when `status` is `'success'`: on `'idle'`, `'loading'`
|
|
25
|
+
* and `'error'` it means "not known", not "the server has none". Rendering
|
|
26
|
+
* it without checking `status` turns a failed fetch into a confident zero.
|
|
27
|
+
*/
|
|
6
28
|
readonly items: readonly SfaRecord[];
|
|
7
29
|
readonly totalCount: number;
|
|
8
30
|
readonly hasMore: boolean;
|
|
9
31
|
readonly pageIndex: number;
|
|
32
|
+
readonly status: SfaListStatus;
|
|
10
33
|
readonly isLoading: boolean;
|
|
11
34
|
readonly error: Error | null;
|
|
12
35
|
readonly refetch: () => void;
|
|
@@ -1,20 +1,36 @@
|
|
|
1
1
|
import { __awaiter } from "tslib";
|
|
2
2
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
3
3
|
import { useSfaClient } from '../provider/useSfaClient';
|
|
4
|
+
/** Stable identity so an unanswered query never re-renders on a fresh array. */
|
|
5
|
+
const NO_ITEMS = [];
|
|
4
6
|
/**
|
|
5
7
|
* @description Fetches a paginated list of records from the SFA/CRM API.
|
|
6
8
|
*/
|
|
7
9
|
export function useRecordList(collection, params, options) {
|
|
8
|
-
var _a, _b;
|
|
10
|
+
var _a, _b, _c;
|
|
9
11
|
const client = useSfaClient();
|
|
10
12
|
const limit = (_a = params === null || params === void 0 ? void 0 : params.limit) !== null && _a !== void 0 ? _a : 20;
|
|
11
13
|
const [items, setItems] = useState([]);
|
|
12
14
|
const [totalCount, setTotalCount] = useState(0);
|
|
13
15
|
const [hasMore, setHasMore] = useState(false);
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
16
|
+
/**
|
|
17
|
+
* Where the caller asked the listing to start.
|
|
18
|
+
*
|
|
19
|
+
* `offset` is a record position, so the page it names is
|
|
20
|
+
* `floor(offset / limit)`. `cursor` addresses the same starting point
|
|
21
|
+
* opaquely and takes precedence over `offset` on the server. Both are only
|
|
22
|
+
* about the *first* page: from there the hook pages on its own, computing
|
|
23
|
+
* offsets from the page number and reusing cursors the server handed back.
|
|
24
|
+
*/
|
|
25
|
+
const initialOffset = (_b = params === null || params === void 0 ? void 0 : params.offset) !== null && _b !== void 0 ? _b : 0;
|
|
26
|
+
const initialCursor = params === null || params === void 0 ? void 0 : params.cursor;
|
|
27
|
+
const initialPage = limit > 0 ? Math.floor(initialOffset / limit) : 0;
|
|
28
|
+
const [pageIndex, setPageIndex] = useState(initialPage);
|
|
29
|
+
const enabled = (_c = options === null || options === void 0 ? void 0 : options.enabled) !== null && _c !== void 0 ? _c : true;
|
|
30
|
+
// NOTE: the seed is never published. Until a fetch settles for the current
|
|
31
|
+
// query the reported status is derived below, so this only matters after the
|
|
32
|
+
// first settle — by which time a fetch has set it.
|
|
33
|
+
const [status, setStatus] = useState('idle');
|
|
18
34
|
const [error, setError] = useState(null);
|
|
19
35
|
const fetchId = useRef(0);
|
|
20
36
|
const sortBy = params === null || params === void 0 ? void 0 : params.sortBy;
|
|
@@ -22,60 +38,220 @@ export function useRecordList(collection, params, options) {
|
|
|
22
38
|
const filterKey = useMemo(() => ((params === null || params === void 0 ? void 0 : params.filter) ? JSON.stringify(params.filter) : ''), [params === null || params === void 0 ? void 0 : params.filter]);
|
|
23
39
|
const filterRef = useRef(params === null || params === void 0 ? void 0 : params.filter);
|
|
24
40
|
filterRef.current = params === null || params === void 0 ? void 0 : params.filter;
|
|
41
|
+
// What identifies the query, page navigation aside: paging stays within one
|
|
42
|
+
// query, but the position the caller starts from does not — the same
|
|
43
|
+
// collection and filter read from a different offset or cursor is a
|
|
44
|
+
// different question, and asking it has to start a new fetch.
|
|
45
|
+
const queryKey = useMemo(() => JSON.stringify({
|
|
46
|
+
collection,
|
|
47
|
+
limit,
|
|
48
|
+
sortBy,
|
|
49
|
+
sortOrder,
|
|
50
|
+
filterKey,
|
|
51
|
+
initialOffset,
|
|
52
|
+
initialCursor,
|
|
53
|
+
}), [
|
|
54
|
+
collection,
|
|
55
|
+
limit,
|
|
56
|
+
sortBy,
|
|
57
|
+
sortOrder,
|
|
58
|
+
filterKey,
|
|
59
|
+
initialOffset,
|
|
60
|
+
initialCursor,
|
|
61
|
+
]);
|
|
62
|
+
/**
|
|
63
|
+
* Which client, query and page the records in state came from, so a failure
|
|
64
|
+
* can tell "keep what we have" from "what we have answers something else".
|
|
65
|
+
*
|
|
66
|
+
* NOTE: the client is part of the question. SfaProvider builds a new
|
|
67
|
+
* SfaClient whenever the accessToken, organizationId, apiBase, mode or
|
|
68
|
+
* readOnly change, so the same collection and filter can address a different
|
|
69
|
+
* organization. Comparing the query alone would publish one organization's
|
|
70
|
+
* records as the answer for another, and keep them through a failure.
|
|
71
|
+
*/
|
|
72
|
+
const loadedRef = useRef(null);
|
|
73
|
+
// Which query last completed and how it ended, so a request discarded
|
|
74
|
+
// mid-flight can fall back to it instead of leaving 'loading' with nothing
|
|
75
|
+
// to resolve it. The query is part of it: falling back to how *another*
|
|
76
|
+
// query ended would present its outcome as this one's answer.
|
|
77
|
+
const settledRef = useRef(null);
|
|
78
|
+
// The page of the last request that failed, kept apart from pageIndex:
|
|
79
|
+
// pageIndex follows the records on screen, this follows what the reader was
|
|
80
|
+
// trying to reach. Without it, retrying after a failed page turn re-fetches
|
|
81
|
+
// the page already displayed — the error clears and the reader stays put.
|
|
82
|
+
const failedPageRef = useRef(null);
|
|
25
83
|
// Track cursors for each page so goToPage can navigate backwards.
|
|
26
84
|
const cursorMapRef = useRef(new Map());
|
|
27
85
|
const doFetch = useCallback((page) => __awaiter(this, void 0, void 0, function* () {
|
|
86
|
+
var _a;
|
|
28
87
|
if (!collection || !enabled)
|
|
29
88
|
return;
|
|
30
89
|
fetchId.current += 1;
|
|
31
90
|
const currentFetchId = fetchId.current;
|
|
32
|
-
|
|
91
|
+
setStatus('loading');
|
|
33
92
|
setError(null);
|
|
34
93
|
try {
|
|
35
|
-
|
|
36
|
-
|
|
94
|
+
// The caller's cursor and exact offset belong to the page they named.
|
|
95
|
+
// Every other page is reached with what the server handed back, or with
|
|
96
|
+
// an offset computed from the page number.
|
|
97
|
+
const cursor = (_a = cursorMapRef.current.get(page)) !== null && _a !== void 0 ? _a : (page === initialPage ? initialCursor : undefined);
|
|
98
|
+
const result = yield client.list(collection, Object.assign({ limit,
|
|
99
|
+
// NOTE: offset is a record position, not a page number. Sending the
|
|
100
|
+
// page number asks for records 1..limit for page 1: it overlaps
|
|
101
|
+
// page 0 and comes back reporting pageIndex 0, so the list never
|
|
102
|
+
// advances. SfaClient derives pageIndex from this offset even when
|
|
103
|
+
// the server paginates by cursor, so the two have to agree.
|
|
104
|
+
offset: page === initialPage ? initialOffset : page * limit, sortBy,
|
|
37
105
|
sortOrder, filter: filterRef.current }, (cursor ? { cursor } : {})));
|
|
38
106
|
if (currentFetchId === fetchId.current) {
|
|
107
|
+
// NOTE: the refs are written before the setState calls. Outside an
|
|
108
|
+
// event handler React 18 in legacy mode re-renders on every setState
|
|
109
|
+
// instead of batching, so a ref assigned afterwards is still stale
|
|
110
|
+
// in the renders those updates produce.
|
|
111
|
+
loadedRef.current = { client, queryKey, page: result.pageIndex };
|
|
112
|
+
settledRef.current = { client, queryKey, status: 'success' };
|
|
113
|
+
// Displayed page and target page are the same again
|
|
114
|
+
failedPageRef.current = null;
|
|
39
115
|
setItems(result.items);
|
|
40
116
|
setTotalCount(result.totalCount);
|
|
41
117
|
setHasMore(result.hasMore);
|
|
42
118
|
setPageIndex(result.pageIndex);
|
|
43
|
-
|
|
44
|
-
// Store
|
|
119
|
+
setStatus('success');
|
|
120
|
+
// Store cursors for adjacent pages so goToPage can navigate both ways.
|
|
45
121
|
if (result.nextCursor) {
|
|
46
122
|
cursorMapRef.current.set(page + 1, result.nextCursor);
|
|
47
123
|
}
|
|
124
|
+
if (page > 0 && result.prevCursor) {
|
|
125
|
+
cursorMapRef.current.set(page - 1, result.prevCursor);
|
|
126
|
+
}
|
|
48
127
|
}
|
|
49
128
|
}
|
|
50
129
|
catch (e) {
|
|
51
130
|
if (currentFetchId === fetchId.current) {
|
|
131
|
+
settledRef.current = { client, queryKey, status: 'error' };
|
|
132
|
+
failedPageRef.current = page;
|
|
52
133
|
setError(e instanceof Error ? e : new Error(String(e)));
|
|
53
|
-
|
|
54
|
-
|
|
134
|
+
setStatus('error');
|
|
135
|
+
const loaded = loadedRef.current;
|
|
136
|
+
// NOTE: compared against the captured client / queryKey rather than
|
|
137
|
+
// the render-time helper, so this closure depends only on values
|
|
138
|
+
// already in the dependency list below
|
|
139
|
+
const answersSameQuestion = loaded != null &&
|
|
140
|
+
loaded.client === client &&
|
|
141
|
+
loaded.queryKey === queryKey;
|
|
142
|
+
if (answersSameQuestion) {
|
|
143
|
+
// Keep the records from the last successful fetch of this same
|
|
144
|
+
// query. Emptying them would make a rate limit or a dropped
|
|
145
|
+
// connection indistinguishable from "the server has no records",
|
|
146
|
+
// and a caller that renders items without reading status would
|
|
147
|
+
// show a confident zero. pageIndex goes back to the page those
|
|
148
|
+
// records came from, so what it says matches what is on screen —
|
|
149
|
+
// goToPage advanced it before the fetch failed. The page that
|
|
150
|
+
// failed stays in failedPageRef, which is what refetch retries.
|
|
151
|
+
setPageIndex(loaded.page);
|
|
152
|
+
}
|
|
153
|
+
else if (loaded != null) {
|
|
154
|
+
// The records in state answer a different question (another
|
|
155
|
+
// parent, another filter, another organization). Keeping them
|
|
156
|
+
// would show one record's children — or one organization's data —
|
|
157
|
+
// under another, so drop them and let status report them unknown.
|
|
158
|
+
setItems([]);
|
|
159
|
+
setTotalCount(0);
|
|
160
|
+
setHasMore(false);
|
|
161
|
+
loadedRef.current = null;
|
|
162
|
+
}
|
|
55
163
|
}
|
|
56
164
|
}
|
|
57
165
|
}),
|
|
166
|
+
// NOTE: queryKey is read inside but left out on purpose — it is a useMemo
|
|
167
|
+
// over exactly the values below, so it cannot change without one of them
|
|
168
|
+
// changing and recreating this callback. Anything added to queryKey's deps
|
|
169
|
+
// must be added here too, or the closure keeps a stale key.
|
|
58
170
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- filterKey triggers re-fetch when filter object changes
|
|
59
|
-
[
|
|
171
|
+
[
|
|
172
|
+
client,
|
|
173
|
+
collection,
|
|
174
|
+
enabled,
|
|
175
|
+
limit,
|
|
176
|
+
sortBy,
|
|
177
|
+
sortOrder,
|
|
178
|
+
filterKey,
|
|
179
|
+
initialOffset,
|
|
180
|
+
initialCursor,
|
|
181
|
+
initialPage,
|
|
182
|
+
]);
|
|
60
183
|
useEffect(() => {
|
|
184
|
+
// Invalidate anything still in flight before deciding what to do next.
|
|
185
|
+
// doFetch cannot do this itself: while disabled or without a collection it
|
|
186
|
+
// returns before reaching its own bump, so a response that lands after the
|
|
187
|
+
// caller stopped asking would still pass the id check and be published.
|
|
188
|
+
fetchId.current += 1;
|
|
61
189
|
cursorMapRef.current.clear();
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
190
|
+
// A new question has no failed page yet.
|
|
191
|
+
failedPageRef.current = null;
|
|
192
|
+
if (!collection) {
|
|
193
|
+
// Nothing to ask about, and the records in hand answer another
|
|
194
|
+
// collection — see the 'idle' contract on SfaListStatus.
|
|
195
|
+
setStatus('idle');
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
if (!enabled) {
|
|
199
|
+
// A discarded refetch would otherwise leave 'loading' with nothing to
|
|
200
|
+
// resolve it. Fall back to how *this* query last ended — its records are
|
|
201
|
+
// still in hand. Nothing settled for it means nothing is known: idle.
|
|
202
|
+
// NOTE: compared against the captured client / queryKey rather than the
|
|
203
|
+
// render-time helper, so this effect depends only on what is already in
|
|
204
|
+
// its dependency list
|
|
205
|
+
const settled = settledRef.current;
|
|
206
|
+
const answersSameQuestion = settled != null &&
|
|
207
|
+
settled.client === client &&
|
|
208
|
+
settled.queryKey === queryKey;
|
|
209
|
+
// eslint-disable-next-line react-you-might-not-need-an-effect/no-pass-data-to-parent
|
|
210
|
+
setStatus(answersSameQuestion ? settled.status : 'idle');
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
setPageIndex(initialPage);
|
|
214
|
+
doFetch(initialPage);
|
|
215
|
+
}, [doFetch, collection, enabled, client, queryKey, initialPage]);
|
|
65
216
|
const goToPage = useCallback((page) => {
|
|
66
217
|
setPageIndex(page);
|
|
67
218
|
doFetch(page);
|
|
68
219
|
}, [doFetch]);
|
|
69
220
|
const refetch = useCallback(() => {
|
|
70
|
-
|
|
221
|
+
var _a;
|
|
222
|
+
// Retry what the reader was trying to reach. After a success the two are
|
|
223
|
+
// the same, so an ordinary refetch targets the page on screen.
|
|
224
|
+
doFetch((_a = failedPageRef.current) !== null && _a !== void 0 ? _a : pageIndex);
|
|
71
225
|
}, [doFetch, pageIndex]);
|
|
226
|
+
/**
|
|
227
|
+
* The state above only answers `queryKey` once a fetch for it has settled:
|
|
228
|
+
* at mount nothing has, and right after the query changes it still holds the
|
|
229
|
+
* previous one's items, counts, error and status. Publishing those presents
|
|
230
|
+
* one query's answer as another's — the same lie as showing a failure as 0.
|
|
231
|
+
* Until this query settles, report only what is known.
|
|
232
|
+
*/
|
|
233
|
+
const settled = settledRef.current;
|
|
234
|
+
const isSettledForCurrentQuery = settled != null &&
|
|
235
|
+
settled.client === client &&
|
|
236
|
+
settled.queryKey === queryKey;
|
|
237
|
+
const willFetch = enabled && !!collection;
|
|
238
|
+
const reported = isSettledForCurrentQuery
|
|
239
|
+
? { items, totalCount, hasMore, status, error }
|
|
240
|
+
: {
|
|
241
|
+
items: NO_ITEMS,
|
|
242
|
+
totalCount: 0,
|
|
243
|
+
hasMore: false,
|
|
244
|
+
status: willFetch ? 'loading' : 'idle',
|
|
245
|
+
error: null,
|
|
246
|
+
};
|
|
72
247
|
return {
|
|
73
|
-
items,
|
|
74
|
-
totalCount,
|
|
75
|
-
hasMore,
|
|
248
|
+
items: reported.items,
|
|
249
|
+
totalCount: reported.totalCount,
|
|
250
|
+
hasMore: reported.hasMore,
|
|
76
251
|
pageIndex,
|
|
77
|
-
|
|
78
|
-
|
|
252
|
+
status: reported.status,
|
|
253
|
+
isLoading: reported.status === 'loading',
|
|
254
|
+
error: reported.error,
|
|
79
255
|
refetch,
|
|
80
256
|
goToPage,
|
|
81
257
|
};
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { SfaRecord, SfaRelationDescriptor } from '../provider/types';
|
|
2
|
+
import type { SfaListStatus } from './useRecordList';
|
|
2
3
|
/** @description Parameters for narrowing, sorting, and filtering related records. */
|
|
3
4
|
export interface UseRelatedRecordsParams {
|
|
4
5
|
readonly limit?: number;
|
|
@@ -16,10 +17,20 @@ export interface UseRelatedRecordsOptions {
|
|
|
16
17
|
}
|
|
17
18
|
/** @description Return value of useRelatedRecords: paginated items, loading/error state, and the resolved relationship descriptor. */
|
|
18
19
|
export interface UseRelatedRecordsResult {
|
|
20
|
+
/**
|
|
21
|
+
* @description Records from the last successful fetch. An empty array is
|
|
22
|
+
* only meaningful when `status` is `'success'` — see useRecordList.
|
|
23
|
+
*/
|
|
19
24
|
readonly items: readonly SfaRecord[];
|
|
20
25
|
readonly totalCount: number;
|
|
21
26
|
readonly hasMore: boolean;
|
|
22
27
|
readonly pageIndex: number;
|
|
28
|
+
/**
|
|
29
|
+
* @description Lifecycle across all three steps (describe, relationship
|
|
30
|
+
* resolution, list fetch). `'error'` covers a failed describe, a missing
|
|
31
|
+
* relationship, and a failed list fetch.
|
|
32
|
+
*/
|
|
33
|
+
readonly status: SfaListStatus;
|
|
23
34
|
readonly isLoading: boolean;
|
|
24
35
|
readonly error: Error | null;
|
|
25
36
|
readonly refetch: () => void;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useMemo, useRef } from 'react';
|
|
1
|
+
import { useCallback, useMemo, useRef } from 'react';
|
|
2
2
|
import { useDescribe } from './useDescribe';
|
|
3
3
|
import { useRecordList } from './useRecordList';
|
|
4
4
|
// ── Error ──
|
|
@@ -71,7 +71,7 @@ export function useRelatedRecords(parentCollection, parentId, relationTarget, pa
|
|
|
71
71
|
// In the grandchild pattern the second hook's parentId starts as []
|
|
72
72
|
// while the first hook fetches; starting describe early lets both
|
|
73
73
|
// metadata calls overlap instead of running in sequence.
|
|
74
|
-
const { object: parentObject, isLoading: isParentDescribeLoading, error: parentDescribeError, } = useDescribe(parentCollection, {
|
|
74
|
+
const { object: parentObject, isLoading: isParentDescribeLoading, error: parentDescribeError, refetch: refetchParentDescribe, } = useDescribe(parentCollection, {
|
|
75
75
|
enabled,
|
|
76
76
|
});
|
|
77
77
|
// Only trust parentObject while it matches the current collection;
|
|
@@ -94,7 +94,7 @@ export function useRelatedRecords(parentCollection, parentId, relationTarget, pa
|
|
|
94
94
|
parentObjectMatches &&
|
|
95
95
|
!isParentDescribeLoading &&
|
|
96
96
|
!forwardRelationship;
|
|
97
|
-
const { object: childObject, isLoading: isChildDescribeLoading, error: childDescribeError, } = useDescribe(relationTarget, {
|
|
97
|
+
const { object: childObject, isLoading: isChildDescribeLoading, error: childDescribeError, refetch: refetchChildDescribe, } = useDescribe(relationTarget, {
|
|
98
98
|
enabled: needsReverseLookup,
|
|
99
99
|
});
|
|
100
100
|
const childObjectMatches = (childObject === null || childObject === void 0 ? void 0 : childObject.key) === relationTarget;
|
|
@@ -213,14 +213,54 @@ export function useRelatedRecords(parentCollection, parentId, relationTarget, pa
|
|
|
213
213
|
(recordList.isLoading || !listHasStartedRef.current));
|
|
214
214
|
const describeError = parentDescribeError !== null && parentDescribeError !== void 0 ? parentDescribeError : (needsReverseLookup ? childDescribeError : null);
|
|
215
215
|
const error = (_c = describeError !== null && describeError !== void 0 ? describeError : relationError) !== null && _c !== void 0 ? _c : recordList.error;
|
|
216
|
+
// Report the merged lifecycle, not just the list's. A failed describe or an
|
|
217
|
+
// unresolved relationship also leaves items empty, and a caller that reads
|
|
218
|
+
// items without status would render that as "no related records".
|
|
219
|
+
const status = (() => {
|
|
220
|
+
if (error)
|
|
221
|
+
return 'error';
|
|
222
|
+
if (isLoading)
|
|
223
|
+
return 'loading';
|
|
224
|
+
if (!listShouldBeActive)
|
|
225
|
+
return 'idle';
|
|
226
|
+
return recordList.status;
|
|
227
|
+
})();
|
|
228
|
+
// Retry whatever failed. The list is only enabled once a relationship is
|
|
229
|
+
// resolved, so while it is not, refetching the list issues no request at all
|
|
230
|
+
// and a retry control wired to it does nothing. Everything before the list
|
|
231
|
+
// comes from describe, so that is what gets retried — including
|
|
232
|
+
// RelationNotFoundError, which clears if the schema gains the relationship.
|
|
233
|
+
const refetchList = recordList.refetch;
|
|
234
|
+
const refetch = useCallback(() => {
|
|
235
|
+
if (relationship == null || parentDescribeError != null) {
|
|
236
|
+
refetchParentDescribe();
|
|
237
|
+
if (needsReverseLookup)
|
|
238
|
+
refetchChildDescribe();
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
if (needsReverseLookup && childDescribeError != null) {
|
|
242
|
+
refetchChildDescribe();
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
refetchList();
|
|
246
|
+
}, [
|
|
247
|
+
relationship,
|
|
248
|
+
parentDescribeError,
|
|
249
|
+
refetchParentDescribe,
|
|
250
|
+
needsReverseLookup,
|
|
251
|
+
childDescribeError,
|
|
252
|
+
refetchChildDescribe,
|
|
253
|
+
refetchList,
|
|
254
|
+
]);
|
|
216
255
|
return {
|
|
217
256
|
items: recordList.items,
|
|
218
257
|
totalCount: recordList.totalCount,
|
|
219
258
|
hasMore: recordList.hasMore,
|
|
220
259
|
pageIndex: recordList.pageIndex,
|
|
260
|
+
status,
|
|
221
261
|
isLoading,
|
|
222
262
|
error,
|
|
223
|
-
refetch
|
|
263
|
+
refetch,
|
|
224
264
|
goToPage: recordList.goToPage,
|
|
225
265
|
relationship,
|
|
226
266
|
};
|
|
@@ -64,6 +64,16 @@ export class SfaClient {
|
|
|
64
64
|
// offset is a record offset, not a page number: pageIndex = floor(offset / pageSize)
|
|
65
65
|
const pageIndex = pageSize > 0 ? Math.floor(offset / pageSize) : 0;
|
|
66
66
|
if (this.mode === 'direct') {
|
|
67
|
+
// Keystone paginates by cursor only: records/query takes no offset, so a
|
|
68
|
+
// later page can be reached only with the cursor that leads to it.
|
|
69
|
+
// Answering such a request with the first page while reporting the
|
|
70
|
+
// requested pageIndex would claim data the server never returned, so say
|
|
71
|
+
// it cannot be served instead.
|
|
72
|
+
if (offset > 0 && !(params === null || params === void 0 ? void 0 : params.cursor)) {
|
|
73
|
+
throw new Error(`Cannot start at record ${offset}: this API paginates by cursor. ` +
|
|
74
|
+
'Reach later pages with goToPage from the first one, or pass the ' +
|
|
75
|
+
'cursor for the page you want.');
|
|
76
|
+
}
|
|
67
77
|
const body = {
|
|
68
78
|
limit: pageSize,
|
|
69
79
|
};
|
package/src/lib/version.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const SFA_KIT_VERSION = "1.
|
|
1
|
+
export declare const SFA_KIT_VERSION = "1.10.0";
|
package/src/lib/version.js
CHANGED