@geniee-inc/sfa-kit 1.17.0 → 1.19.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.
Files changed (39) hide show
  1. package/GUIDE.md +34 -14
  2. package/package.json +1 -1
  3. package/src/bin/sfa-kit-bundle.js +16 -8
  4. package/src/bundle.d.ts +1 -2
  5. package/src/index.d.ts +1 -1
  6. package/src/lib/auth/resolve-api-base.d.ts +6 -4
  7. package/src/lib/auth/resolve-api-base.js +24 -5
  8. package/src/lib/auth/useEmbedSession.d.ts +2 -1
  9. package/src/lib/auth/useEmbedSession.js +196 -26
  10. package/src/lib/bundle/build-static-bundle.d.ts +37 -11
  11. package/src/lib/bundle/build-static-bundle.js +75 -35
  12. package/src/lib/bundle/request-signature.d.ts +13 -1
  13. package/src/lib/bundle/request-signature.js +21 -6
  14. package/src/lib/components/FieldRenderer.d.ts +2 -1
  15. package/src/lib/components/FieldRenderer.js +32 -19
  16. package/src/lib/components/RecordList.js +10 -0
  17. package/src/lib/components/RecordView.js +27 -7
  18. package/src/lib/components/fieldDisplayText.d.ts +11 -0
  19. package/src/lib/components/fieldDisplayText.js +51 -0
  20. package/src/lib/hooks/useFieldTypes.d.ts +5 -0
  21. package/src/lib/hooks/useFieldTypes.js +5 -0
  22. package/src/lib/hooks/useObjectCatalog.d.ts +6 -0
  23. package/src/lib/hooks/useObjectCatalog.js +6 -0
  24. package/src/lib/hooks/useRecord.d.ts +20 -0
  25. package/src/lib/hooks/useRecord.js +53 -0
  26. package/src/lib/provider/SfaClient.d.ts +13 -0
  27. package/src/lib/provider/SfaClient.js +25 -8
  28. package/src/lib/provider/SfaProvider.d.ts +2 -2
  29. package/src/lib/provider/SfaProvider.js +1 -1
  30. package/src/lib/provider/types.d.ts +12 -1
  31. package/src/lib/signing/compute-signature.d.ts +4 -2
  32. package/src/lib/signing/compute-signature.js +4 -2
  33. package/src/lib/signing/create-manifest.d.ts +8 -6
  34. package/src/lib/signing/create-manifest.js +8 -6
  35. package/src/lib/signing/index.d.ts +1 -1
  36. package/src/lib/signing/types.d.ts +33 -4
  37. package/src/lib/version.d.ts +1 -1
  38. package/src/lib/version.js +1 -1
  39. package/src/signing.d.ts +2 -2
@@ -6,6 +6,15 @@ import { __awaiter } from "tslib";
6
6
  const HEX_DIGEST_PATTERN = /^[0-9a-f]{64}$/;
7
7
  /** Matches the timeout the documented Python flow uses for the same call. */
8
8
  const SIGN_TIMEOUT_MS = 30000;
9
+ /**
10
+ * Whether `value` has the shape Keystone gives `content_hash` and `signature`.
11
+ *
12
+ * NOTE: shared with the builder's previous-build check, so the shape the
13
+ * signer accepts and the shape a rebuild recognises cannot drift apart.
14
+ */
15
+ export function isHexDigest(value) {
16
+ return typeof value === 'string' && HEX_DIGEST_PATTERN.test(value);
17
+ }
9
18
  /**
10
19
  * @description Asks Keystone to sign a bundle, returning the signature.
11
20
  *
@@ -14,6 +23,11 @@ const SIGN_TIMEOUT_MS = 30000;
14
23
  * per project while the key must be identical across all of them (SFAJAI-186).
15
24
  * The caller computes `contentHash` locally and Keystone signs it.
16
25
  *
26
+ * Without `studioVersion`, Keystone signs `contentHash` alone. With it, Keystone
27
+ * returns the legacy signature over `"{studio_version}:{content_hash}"`, and the
28
+ * caller must write the same `studio_version` into the manifest for it to
29
+ * verify.
30
+ *
17
31
  * NOTE: every failure throws. An unsigned bundle uploads successfully while
18
32
  * Keystone's verifier runs in `disabled` mode, so returning "no signature" here
19
33
  * would produce an artifact that looks fine and starts being rejected the day
@@ -24,7 +38,7 @@ const SIGN_TIMEOUT_MS = 30000;
24
38
  */
25
39
  export function requestSignature(_a) {
26
40
  return __awaiter(this, arguments, void 0, function* ({ apiBase, accessToken, studioVersion, contentHash, }) {
27
- if (!HEX_DIGEST_PATTERN.test(contentHash)) {
41
+ if (!isHexDigest(contentHash)) {
28
42
  throw new Error(`content_hash must be a 64-character hex digest, got ${JSON.stringify(contentHash)}`);
29
43
  }
30
44
  // 末尾のスラッシュを落とす。付いたままだと //v1/pages/sign になり、多くの
@@ -44,10 +58,11 @@ export function requestSignature(_a) {
44
58
  'Content-Type': 'application/json',
45
59
  Authorization: `Bearer ${accessToken}`,
46
60
  },
47
- body: JSON.stringify({
48
- studio_version: studioVersion,
49
- content_hash: contentHash,
50
- }),
61
+ // NOTE: the field is left out, not sent empty. Keystone picks the format
62
+ // by whether studio_version is present, as it does for the manifest.
63
+ body: JSON.stringify(studioVersion == null
64
+ ? { content_hash: contentHash }
65
+ : { studio_version: studioVersion, content_hash: contentHash }),
51
66
  })
52
67
  .catch((thrown) => {
53
68
  throw new Error(`POST ${url} could not be reached ` +
@@ -72,7 +87,7 @@ export function requestSignature(_a) {
72
87
  // error page echoed into this field would otherwise be written into
73
88
  // manifest.json, upload fine while the verifier is `disabled`, and fail only
74
89
  // once it is switched to `enforced`.
75
- if (!HEX_DIGEST_PATTERN.test(payload.signature)) {
90
+ if (!isHexDigest(payload.signature)) {
76
91
  throw new Error(`POST ${url} returned ${JSON.stringify(payload.signature)}, which is ` +
77
92
  'not an HMAC-SHA-256 hex digest. The bundle was not signed.');
78
93
  }
@@ -5,7 +5,8 @@ interface FieldRendererProps {
5
5
  }
6
6
  /**
7
7
  * @description Renders a single field value based on its normalized kind.
8
- * Uses `label` for display (pre-formatted by the API), falls back to `value`.
8
+ * Uses `label` for display (pre-formatted by the API), falls back to a plain
9
+ * readable `value` — never a code or an id.
9
10
  */
10
11
  export declare function FieldRenderer({ fieldValue, fieldName }: FieldRendererProps): JSX.Element;
11
12
  export {};
@@ -1,36 +1,28 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { toFieldDisplayText } from './fieldDisplayText';
2
3
  /**
3
4
  * @description Renders a single field value based on its normalized kind.
4
- * Uses `label` for display (pre-formatted by the API), falls back to `value`.
5
+ * Uses `label` for display (pre-formatted by the API), falls back to a plain
6
+ * readable `value` — never a code or an id.
5
7
  */
6
8
  export function FieldRenderer({ fieldValue, fieldName }) {
7
- var _a;
8
- const display = (_a = fieldValue.label) !== null && _a !== void 0 ? _a : formatFallback(fieldValue);
9
+ const display = toFieldDisplayText(fieldValue);
10
+ // NOTE: every branch carries `data-field`, so a page can find a field by name
11
+ // whatever its value looks like — empty, multi-line or a link
9
12
  if (display == null || display === '') {
10
- return _jsx("span", { style: styles.empty, children: "\u2014" });
13
+ return (_jsx("span", { "data-field": fieldName, style: styles.empty, children: "\u2014" }));
11
14
  }
12
15
  if (fieldValue.kind === 'text' && typeof fieldValue.value === 'string') {
13
16
  if (isMultiline(fieldValue.value)) {
14
- return _jsx("span", { style: styles.preWrap, children: display });
17
+ return (_jsx("span", { "data-field": fieldName, style: styles.preWrap, children: display }));
15
18
  }
16
19
  const link = detectLink(fieldValue.value);
17
20
  if (link) {
18
- return (_jsx("a", { href: link.href, style: styles.link, target: "_blank", rel: "noopener noreferrer", children: display }));
21
+ return (_jsx("a", { "data-field": fieldName, href: link.href, style: styles.link, target: "_blank", rel: "noopener noreferrer", children: display }));
19
22
  }
20
23
  }
21
24
  return _jsx("span", { "data-field": fieldName, children: display });
22
25
  }
23
- function formatFallback(field) {
24
- if (field.value == null)
25
- return null;
26
- if (typeof field.value === 'number')
27
- return field.value.toLocaleString('ja-JP');
28
- if (typeof field.value === 'string')
29
- return field.value;
30
- if (Array.isArray(field.value))
31
- return field.value.join(', ');
32
- return String(field.value);
33
- }
34
26
  function isMultiline(value) {
35
27
  return value.includes('\n');
36
28
  }
@@ -39,10 +31,31 @@ function detectLink(value) {
39
31
  return { href: value };
40
32
  if (/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value))
41
33
  return { href: `mailto:${value}` };
42
- if (/^[\d+][\d\-().+\s]{7,}$/.test(value))
43
- return { href: `tel:${value.replace(/[\s()-]/g, '')}` };
34
+ const phoneNumber = toPhoneNumber(value);
35
+ if (phoneNumber)
36
+ return { href: `tel:${phoneNumber}` };
44
37
  return null;
45
38
  }
39
+ /**
40
+ * The number to dial when `value` reads as a phone number, or null.
41
+ *
42
+ * NOTE: digits and dashes alone also make up postal codes (100-0001) and dates
43
+ * (2026-09-28). A Japanese number starts with a single 0 and has 10 or 11
44
+ * digits; an international one starts with + and has 8 to 15 (E.164 caps it at
45
+ * 15). A leading 00 is a zero-padded code, not a number to dial
46
+ */
47
+ function toPhoneNumber(value) {
48
+ if (!/^(?:\+|\(?0)[\d\s().-]+$/.test(value))
49
+ return null;
50
+ const digits = value.replace(/\D/g, '');
51
+ if (value.startsWith('+')) {
52
+ return digits.length >= 8 && digits.length <= 15 ? `+${digits}` : null;
53
+ }
54
+ // NOTE: checked on the digits, so separators cannot hide the second 0
55
+ if (digits.startsWith('00'))
56
+ return null;
57
+ return digits.length === 10 || digits.length === 11 ? digits : null;
58
+ }
46
59
  const styles = {
47
60
  empty: {
48
61
  color: '#94a3b8',
@@ -3,6 +3,7 @@ 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 { EmptyState } from './EmptyState';
6
7
  import { FetchErrorState } from './FetchErrorState';
7
8
  import { FieldRenderer } from './FieldRenderer';
8
9
  const MAX_AUTO_COLUMNS = 8;
@@ -26,6 +27,15 @@ export function RecordList({ collection, columns, params, pageSize = 20, showPag
26
27
  if (status === 'error') {
27
28
  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] })] }));
28
29
  }
30
+ // NOTE: on 'idle' nothing was asked — this component never disables the hook,
31
+ // so it only happens while `collection` is empty. "0 records" and "No records
32
+ // found" would state something the server never answered, and an empty table
33
+ // would look broken. Say what is missing instead — the input, not the data —
34
+ // as RecordView does for a missing record. From here on the status is
35
+ // 'loading' or 'success', so `!isLoading` means the items came from the server
36
+ if (status === 'idle') {
37
+ return (_jsxs("div", { style: styles.container, children: [_jsx(EmptyState, { message: "\u30AA\u30D6\u30B8\u30A7\u30AF\u30C8\u304C\u6307\u5B9A\u3055\u308C\u3066\u3044\u307E\u305B\u3093" }), _jsxs("div", { style: styles.versionFooter, children: ["@geniee-inc/sfa-kit v", SFA_KIT_VERSION] })] }));
38
+ }
29
39
  const resolvedColumns = columns !== null && columns !== void 0 ? columns : autoDetectColumns(items);
30
40
  const totalPages = Math.max(1, Math.ceil(totalCount / pageSize));
31
41
  const rangeStart = pageIndex * pageSize + 1;
@@ -3,7 +3,9 @@ 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 { EmptyState } from './EmptyState';
6
7
  import { FetchErrorState } from './FetchErrorState';
8
+ import { toFieldDisplayText } from './fieldDisplayText';
7
9
  import { FieldRenderer } from './FieldRenderer';
8
10
  /**
9
11
  * @description Fetches and displays a single SFA/CRM record in a label-value grid.
@@ -11,26 +13,41 @@ import { FieldRenderer } from './FieldRenderer';
11
13
  export function RecordView({ collection, recordId, title, fields, fieldGroups, onError, errorMessage, }) {
12
14
  var _a;
13
15
  const { record, isLoading, error, refetch } = useRecord(collection, recordId);
16
+ // NOTE: useRecord reports an empty record id as an error so a page using the
17
+ // hook alone can say so, but to this component it is "no record specified",
18
+ // not a failed fetch: nothing was sent, and a retry would only repeat it.
19
+ // The screen and onError both read this, so a page never toasts a failure
20
+ // next to the "not specified" state
21
+ const fetchError = recordId === '' ? null : error;
14
22
  // Use a ref so inline onError callbacks don't re-fire the effect on
15
23
  // every parent render when error is present.
16
24
  const onErrorRef = useRef(onError);
17
25
  onErrorRef.current = onError;
18
26
  useEffect(() => {
19
27
  var _a;
20
- if (error)
21
- (_a = onErrorRef.current) === null || _a === void 0 ? void 0 : _a.call(onErrorRef, error);
22
- }, [error]);
23
- if (error) {
28
+ if (fetchError)
29
+ (_a = onErrorRef.current) === null || _a === void 0 ? void 0 : _a.call(onErrorRef, fetchError);
30
+ }, [fetchError]);
31
+ if (fetchError) {
24
32
  // NOTE: `title` is the label the page already shows as the heading, so it
25
33
  // names the record without exposing `collection` — the object key the API
26
34
  // is addressed with. The derived heading is unavailable here: it reads the
27
35
  // record, and on a failure there is none.
28
36
  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] })] }));
29
37
  }
30
- if (isLoading || !record) {
38
+ if (isLoading) {
31
39
  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] })] }));
32
40
  }
33
- const displayTitle = (_a = title !== null && title !== void 0 ? title : record.title) !== null && _a !== void 0 ? _a : buildTitle(record.fields, record.titleFields);
41
+ // NOTE: nothing is loading and no fetch failed, yet there is no record. A
42
+ // fetch that finds none throws, so this means no record was specified — no
43
+ // collection or no record id yet. "Loading..." would never go away
44
+ if (!record) {
45
+ return (_jsxs("div", { style: styles.container, children: [_jsx(EmptyState, { message: "\u30EC\u30B3\u30FC\u30C9\u304C\u6307\u5B9A\u3055\u308C\u3066\u3044\u307E\u305B\u3093" }), _jsxs("div", { style: styles.versionFooter, children: ["@geniee-inc/sfa-kit v", SFA_KIT_VERSION] })] }));
46
+ }
47
+ // NOTE: an empty title from the API names nothing, so build one from
48
+ // `titleFields` as if it were missing
49
+ const recordTitle = record.title === '' ? null : record.title;
50
+ const displayTitle = (_a = title !== null && title !== void 0 ? title : recordTitle) !== null && _a !== void 0 ? _a : buildTitle(record.fields, record.titleFields);
34
51
  const hasGroups = fieldGroups && fieldGroups.length > 0;
35
52
  return (_jsxs("div", { style: styles.container, children: [displayTitle && _jsx("h2", { style: styles.title, children: displayTitle }), _jsxs("div", { style: styles.meta, children: [collection, " / ", record.record_id] }), hasGroups
36
53
  ? renderFieldGroups(record.fields, fieldGroups)
@@ -65,7 +82,10 @@ function renderFieldGroups(mapping, groups) {
65
82
  }
66
83
  function buildTitle(mapping, titleFields) {
67
84
  return titleFields
68
- .map(name => { var _a; return (_a = mapping[name]) === null || _a === void 0 ? void 0 : _a.label; })
85
+ .map(name => {
86
+ const field = mapping[name];
87
+ return field ? toFieldDisplayText(field) : null;
88
+ })
69
89
  .filter(Boolean)
70
90
  .join(' ');
71
91
  }
@@ -0,0 +1,11 @@
1
+ import type { SfaFieldValue } from '../provider/types';
2
+ /**
3
+ * @description The text a field shows to a reader — its label, or a plain
4
+ * readable value when the label is empty or missing — or null.
5
+ *
6
+ * NOTE: never a code or an id. FieldRenderer and the heading RecordView builds
7
+ * from `titleFields` both use this, so a field shows the same thing in its row
8
+ * and in the heading, and a reference whose label is missing puts its raw id
9
+ * in neither
10
+ */
11
+ export declare function toFieldDisplayText(field: SfaFieldValue): string | null;
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Kinds whose `value` reads as it is. The others hold a code or an id, and
3
+ * only `label` names it
4
+ */
5
+ const READABLE_VALUE_KINDS = new Set([
6
+ 'text',
7
+ 'number',
8
+ 'date',
9
+ 'datetime',
10
+ 'computed',
11
+ ]);
12
+ /**
13
+ * Whether `value` can be shown to a reader as it is: a number, a boolean, or a
14
+ * string that is not markup
15
+ *
16
+ * NOTE: Chikyu also sends kinds it has no mapping for as 'text' — an image
17
+ * field holds HTML, others may hold an object — so the kind alone does not
18
+ * promise readable text
19
+ */
20
+ function isPlainValue(value) {
21
+ if (typeof value === 'number' || typeof value === 'boolean')
22
+ return true;
23
+ return typeof value === 'string' && !/<[a-z!/][^>]*>/i.test(value);
24
+ }
25
+ /**
26
+ * @description The text a field shows to a reader — its label, or a plain
27
+ * readable value when the label is empty or missing — or null.
28
+ *
29
+ * NOTE: never a code or an id. FieldRenderer and the heading RecordView builds
30
+ * from `titleFields` both use this, so a field shows the same thing in its row
31
+ * and in the heading, and a reference whose label is missing puts its raw id
32
+ * in neither
33
+ */
34
+ export function toFieldDisplayText(field) {
35
+ if (field.label != null && field.label !== '')
36
+ return field.label;
37
+ // NOTE: an empty or missing label carries no formatting, so a plain readable
38
+ // value shows instead. Anything else stays empty: a choice or a reference
39
+ // holds a code or an id (Chikyu leaves the caption out of fields it does not
40
+ // display, and a master-backed select holds an id), and markup or an object
41
+ // means nothing to a reader
42
+ return READABLE_VALUE_KINDS.has(field.kind) && isPlainValue(field.value)
43
+ ? formatPlainValue(field.value)
44
+ : null;
45
+ }
46
+ /** A plain value as text: a number formatted for ja-JP, the rest as it is */
47
+ function formatPlainValue(value) {
48
+ return typeof value === 'number'
49
+ ? value.toLocaleString('ja-JP')
50
+ : String(value);
51
+ }
@@ -10,6 +10,11 @@ interface UseFieldTypesResult {
10
10
  }
11
11
  /**
12
12
  * @description Fetches the static field-type catalog from Keystone.
13
+ *
14
+ * NOTE: a failed refetch keeps the field types already shown and sets `error`
15
+ * beside them. The catalog almost never changes, so a transient failure must
16
+ * not empty the pickers built from it. useRecord drops its record instead: a
17
+ * record does change, and a stale one would pass for the current one.
13
18
  */
14
19
  export declare function useFieldTypes(options?: UseFieldTypesOptions): UseFieldTypesResult;
15
20
  export {};
@@ -2,6 +2,11 @@ import { useCallback, useEffect, useRef, useState } from 'react';
2
2
  import { useSfaClient } from '../provider/useSfaClient';
3
3
  /**
4
4
  * @description Fetches the static field-type catalog from Keystone.
5
+ *
6
+ * NOTE: a failed refetch keeps the field types already shown and sets `error`
7
+ * beside them. The catalog almost never changes, so a transient failure must
8
+ * not empty the pickers built from it. useRecord drops its record instead: a
9
+ * record does change, and a stale one would pass for the current one.
5
10
  */
6
11
  export function useFieldTypes(options) {
7
12
  var _a;
@@ -10,6 +10,12 @@ interface UseObjectCatalogResult {
10
10
  }
11
11
  /**
12
12
  * @description Fetches the list of all objects available in the org.
13
+ *
14
+ * NOTE: a failed refetch keeps the objects already shown and sets `error`
15
+ * beside them. The catalog almost never changes, so a transient failure must
16
+ * not empty the object picker built from it. useRecord drops its record
17
+ * instead: a record does change, and a stale one would pass for the current
18
+ * one.
13
19
  */
14
20
  export declare function useObjectCatalog(options?: UseObjectCatalogOptions): UseObjectCatalogResult;
15
21
  export {};
@@ -2,6 +2,12 @@ import { useCallback, useEffect, useRef, useState } from 'react';
2
2
  import { useSfaClient } from '../provider/useSfaClient';
3
3
  /**
4
4
  * @description Fetches the list of all objects available in the org.
5
+ *
6
+ * NOTE: a failed refetch keeps the objects already shown and sets `error`
7
+ * beside them. The catalog almost never changes, so a transient failure must
8
+ * not empty the object picker built from it. useRecord drops its record
9
+ * instead: a record does change, and a stale one would pass for the current
10
+ * one.
5
11
  */
6
12
  export function useObjectCatalog(options) {
7
13
  var _a;
@@ -10,6 +10,26 @@ interface UseRecordResult {
10
10
  }
11
11
  /**
12
12
  * @description Fetches a single record from the SFA/CRM API.
13
+ *
14
+ * NOTE: a failed refetch drops the record already shown. A record changes, so
15
+ * an earlier copy next to the error would pass for the current one and could
16
+ * be edited from. The catalog hooks keep their data on a failure instead,
17
+ * because what they hold almost never changes.
18
+ *
19
+ * NOTE: an empty `collection` means there is nothing to ask yet — the embed
20
+ * context has not arrived, say — and is treated like `enabled: false`: no
21
+ * request and no error. An error would flash on every page that is still
22
+ * waiting, and useRecordList and useDescribe stay idle the same way. An empty
23
+ * `recordId` with a collection given is different, and does set `error`: the
24
+ * object is known but no record is picked, which the message asks the reader to
25
+ * fix, and an empty id that reached the API was answered with a 503.
26
+ *
27
+ * NOTE: the result always describes the `collection` / `recordId` / client
28
+ * passed right now. When they change, the state still answers the old ones
29
+ * until the fetch for the new ones starts, so it reports no record and no
30
+ * error — with `isLoading` when a fetch is due — instead of the old answer,
31
+ * the way useRecordList reports 'loading' before its fetch settles. Disabling
32
+ * without changing them keeps the record already fetched.
13
33
  */
14
34
  export declare function useRecord(collection: string, recordId: string, options?: UseRecordOptions): UseRecordResult;
15
35
  export {};
@@ -4,6 +4,26 @@ import { ERR_EMPTY_RECORD_ID } from '../errors';
4
4
  import { useSfaClient } from '../provider/useSfaClient';
5
5
  /**
6
6
  * @description Fetches a single record from the SFA/CRM API.
7
+ *
8
+ * NOTE: a failed refetch drops the record already shown. A record changes, so
9
+ * an earlier copy next to the error would pass for the current one and could
10
+ * be edited from. The catalog hooks keep their data on a failure instead,
11
+ * because what they hold almost never changes.
12
+ *
13
+ * NOTE: an empty `collection` means there is nothing to ask yet — the embed
14
+ * context has not arrived, say — and is treated like `enabled: false`: no
15
+ * request and no error. An error would flash on every page that is still
16
+ * waiting, and useRecordList and useDescribe stay idle the same way. An empty
17
+ * `recordId` with a collection given is different, and does set `error`: the
18
+ * object is known but no record is picked, which the message asks the reader to
19
+ * fix, and an empty id that reached the API was answered with a 503.
20
+ *
21
+ * NOTE: the result always describes the `collection` / `recordId` / client
22
+ * passed right now. When they change, the state still answers the old ones
23
+ * until the fetch for the new ones starts, so it reports no record and no
24
+ * error — with `isLoading` when a fetch is due — instead of the old answer,
25
+ * the way useRecordList reports 'loading' before its fetch settles. Disabling
26
+ * without changing them keeps the record already fetched.
7
27
  */
8
28
  export function useRecord(collection, recordId, options) {
9
29
  var _a;
@@ -13,9 +33,19 @@ export function useRecord(collection, recordId, options) {
13
33
  const [isLoading, setIsLoading] = useState(enabled && !!collection && !!recordId);
14
34
  const [error, setError] = useState(null);
15
35
  const fetchId = useRef(0);
36
+ // NOTE: which inputs the state above answers. Set whenever doFetch starts
37
+ // answering a new question, so a render can tell the state is about inputs
38
+ // that are no longer passed
39
+ const answeredRef = useRef(null);
16
40
  const doFetch = useCallback(() => __awaiter(this, void 0, void 0, function* () {
17
41
  if (!enabled || !collection)
18
42
  return;
43
+ const answered = answeredRef.current;
44
+ const isNewQuestion = answered == null ||
45
+ answered.client !== client ||
46
+ answered.collection !== collection ||
47
+ answered.recordId !== recordId;
48
+ answeredRef.current = { client, collection, recordId };
19
49
  if (!recordId) {
20
50
  fetchId.current += 1;
21
51
  setRecord(null);
@@ -25,6 +55,10 @@ export function useRecord(collection, recordId, options) {
25
55
  }
26
56
  fetchId.current += 1;
27
57
  const currentFetchId = fetchId.current;
58
+ // NOTE: a refetch of the same record keeps it on screen while it loads; a
59
+ // record answering other inputs must not stand in for the new one
60
+ if (isNewQuestion)
61
+ setRecord(null);
28
62
  setIsLoading(true);
29
63
  setError(null);
30
64
  try {
@@ -45,5 +79,24 @@ export function useRecord(collection, recordId, options) {
45
79
  useEffect(() => {
46
80
  doFetch();
47
81
  }, [doFetch]);
82
+ const answered = answeredRef.current;
83
+ const answersCurrentInputs = answered != null &&
84
+ answered.client === client &&
85
+ answered.collection === collection &&
86
+ answered.recordId === recordId;
87
+ // NOTE: the state answers other inputs — the fetch for these has not started
88
+ // yet, or nothing will be asked (no collection, or disabled after the inputs
89
+ // changed). Either way the old record and error answer something else. It
90
+ // is loading only when a fetch is due; an empty record id gets its error from
91
+ // the effect. Disabling without changing the inputs keeps what was fetched,
92
+ // as useRecordList does
93
+ if (!answersCurrentInputs) {
94
+ return {
95
+ record: null,
96
+ isLoading: enabled && !!collection && !!recordId,
97
+ error: null,
98
+ refetch: doFetch,
99
+ };
100
+ }
48
101
  return { record, isLoading, error, refetch: doFetch };
49
102
  }
@@ -124,6 +124,19 @@ export declare class SfaClient implements SfaClientInterface {
124
124
  private objectsPath;
125
125
  /** @description Returns the field-types catalog path for the current mode. */
126
126
  private fieldTypesPath;
127
+ /**
128
+ * @description Sets the auth headers, returning the token it used.
129
+ *
130
+ * NOTE: the caller keeps that token so a 401 can name it. Reading `this.auth`
131
+ * at the 401 would give the same value today — `auth` is readonly and
132
+ * `SfaProvider` builds a new client per token — but returning it from here
133
+ * keeps the answer true by construction.
134
+ *
135
+ * NOTE: this owns the `Authorization` header. It runs after any caller
136
+ * headers are spread in, so a caller-supplied one is overwritten — and if
137
+ * the client has no auth, the returned `undefined` would not describe it.
138
+ * Callers pass `If-Match` and the like, never `Authorization`.
139
+ */
127
140
  private applyAuth;
128
141
  /**
129
142
  * @description GET request with auth headers. Works for all read-only
@@ -388,14 +388,31 @@ export class SfaClient {
388
388
  return this.mode === 'direct' ? '/v1/field-types' : '/field-types';
389
389
  }
390
390
  // ── Private transport ──
391
+ /**
392
+ * @description Sets the auth headers, returning the token it used.
393
+ *
394
+ * NOTE: the caller keeps that token so a 401 can name it. Reading `this.auth`
395
+ * at the 401 would give the same value today — `auth` is readonly and
396
+ * `SfaProvider` builds a new client per token — but returning it from here
397
+ * keeps the answer true by construction.
398
+ *
399
+ * NOTE: this owns the `Authorization` header. It runs after any caller
400
+ * headers are spread in, so a caller-supplied one is overwritten — and if
401
+ * the client has no auth, the returned `undefined` would not describe it.
402
+ * Callers pass `If-Match` and the like, never `Authorization`.
403
+ */
391
404
  applyAuth(headers) {
392
405
  var _a;
393
- if ((_a = this.auth) === null || _a === void 0 ? void 0 : _a.accessToken) {
394
- headers['Authorization'] = `Bearer ${this.auth.accessToken}`;
406
+ const accessToken = (_a = this.auth) === null || _a === void 0 ? void 0 : _a.accessToken;
407
+ if (accessToken) {
408
+ headers['Authorization'] = `Bearer ${accessToken}`;
395
409
  }
396
410
  if (this.organizationId) {
397
411
  headers['organization-id'] = this.organizationId;
398
412
  }
413
+ // NOTE: 空文字は「トークンを送っていない」。値として返すと、受け手は
414
+ // 差し替え済みの遅れ着信と区別できず、認証なしの 401 を握り潰す
415
+ return accessToken || undefined;
399
416
  }
400
417
  /**
401
418
  * @description GET request with auth headers. Works for all read-only
@@ -408,7 +425,7 @@ export class SfaClient {
408
425
  const headers = {
409
426
  'Content-Type': 'application/json',
410
427
  };
411
- this.applyAuth(headers);
428
+ const sentToken = this.applyAuth(headers);
412
429
  const response = yield globalThis.fetch(url, {
413
430
  method: 'GET',
414
431
  headers,
@@ -416,7 +433,7 @@ export class SfaClient {
416
433
  });
417
434
  if (!response.ok) {
418
435
  if (response.status === 401)
419
- (_a = this.onAuthError) === null || _a === void 0 ? void 0 : _a.call(this);
436
+ (_a = this.onAuthError) === null || _a === void 0 ? void 0 : _a.call(this, sentToken);
420
437
  const text = yield response.text().catch(() => '');
421
438
  throw new SfaApiError(response.status, response.statusText, text, url);
422
439
  }
@@ -434,7 +451,7 @@ export class SfaClient {
434
451
  const headers = {
435
452
  'Content-Type': 'application/json',
436
453
  };
437
- this.applyAuth(headers);
454
+ const sentToken = this.applyAuth(headers);
438
455
  const response = yield globalThis.fetch(url, {
439
456
  method: 'POST',
440
457
  headers,
@@ -443,7 +460,7 @@ export class SfaClient {
443
460
  });
444
461
  if (!response.ok) {
445
462
  if (response.status === 401)
446
- (_a = this.onAuthError) === null || _a === void 0 ? void 0 : _a.call(this);
463
+ (_a = this.onAuthError) === null || _a === void 0 ? void 0 : _a.call(this, sentToken);
447
464
  const text = yield response.text().catch(() => '');
448
465
  throw new SfaApiError(response.status, response.statusText, text, url);
449
466
  }
@@ -460,7 +477,7 @@ export class SfaClient {
460
477
  var _a;
461
478
  const url = `${this.apiBase}${path}`;
462
479
  const headers = Object.assign({ 'Content-Type': 'application/json' }, options === null || options === void 0 ? void 0 : options.headers);
463
- this.applyAuth(headers);
480
+ const sentToken = this.applyAuth(headers);
464
481
  const response = yield globalThis.fetch(url, {
465
482
  method,
466
483
  headers,
@@ -469,7 +486,7 @@ export class SfaClient {
469
486
  });
470
487
  if (!response.ok) {
471
488
  if (response.status === 401)
472
- (_a = this.onAuthError) === null || _a === void 0 ? void 0 : _a.call(this);
489
+ (_a = this.onAuthError) === null || _a === void 0 ? void 0 : _a.call(this, sentToken);
473
490
  const text = yield response.text().catch(() => '');
474
491
  if (response.status === 412) {
475
492
  throw SfaConflictError.fromBody(response.status, response.statusText, text, url);
@@ -1,5 +1,5 @@
1
1
  import type { ReactNode } from 'react';
2
- import type { SfaApiMode, SfaAuth, SfaClientInterface } from './types';
2
+ import type { SfaApiMode, SfaAuth, SfaAuthErrorHandler, SfaClientInterface } from './types';
3
3
  export interface SfaContextValue {
4
4
  readonly client: SfaClientInterface;
5
5
  readonly auth: SfaAuth | undefined;
@@ -14,7 +14,7 @@ interface SfaProviderProps {
14
14
  readonly auth?: SfaAuth;
15
15
  readonly mode?: SfaApiMode;
16
16
  readonly organizationId?: string;
17
- readonly onAuthError?: () => void;
17
+ readonly onAuthError?: SfaAuthErrorHandler;
18
18
  /**
19
19
  * Disable mutation hooks (create/update/delete). Defaults to false.
20
20
  *
@@ -38,7 +38,7 @@ export function SfaProvider({ apiBase, auth, mode, organizationId, onAuthError,
38
38
  auth: stableAuth,
39
39
  mode: resolvedMode,
40
40
  organizationId: resolvedOrgId,
41
- onAuthError: () => { var _a; return (_a = onAuthErrorRef.current) === null || _a === void 0 ? void 0 : _a.call(onAuthErrorRef); },
41
+ onAuthError: rejectedToken => { var _a; return (_a = onAuthErrorRef.current) === null || _a === void 0 ? void 0 : _a.call(onAuthErrorRef, rejectedToken); },
42
42
  }), [apiBase, stableAuth, resolvedMode, resolvedOrgId]);
43
43
  const value = useMemo(() => ({
44
44
  client,
@@ -2,12 +2,23 @@ export interface SfaAuth {
2
2
  readonly accessToken: string;
3
3
  }
4
4
  export type SfaApiMode = 'direct' | 'proxy';
5
+ /**
6
+ * @description Called when a request came back 401, naming the token it was
7
+ * sent with.
8
+ *
9
+ * `rejectedToken` is the access token that request carried, or `undefined`
10
+ * when it went out unauthenticated. A caller that swaps tokens needs it to
11
+ * tell a late reply about a token it has already replaced from a rejection of
12
+ * the one it holds now: the first is nothing to act on, the second means the
13
+ * session has to be re-established.
14
+ */
15
+ export type SfaAuthErrorHandler = (rejectedToken: string | undefined) => void;
5
16
  export interface SfaClientConfig {
6
17
  readonly apiBase: string;
7
18
  readonly auth?: SfaAuth;
8
19
  readonly mode: SfaApiMode;
9
20
  readonly organizationId?: string;
10
- readonly onAuthError?: () => void;
21
+ readonly onAuthError?: SfaAuthErrorHandler;
11
22
  }
12
23
  /**
13
24
  * @description Normalized field kinds aligned with keystone's 10-type model.