@geniee-inc/sfa-kit 1.18.0 → 1.19.2

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 (40) hide show
  1. package/GUIDE.md +35 -15
  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/lib/auth/DevToolbar.js +3 -6
  6. package/src/lib/auth/SfaLoginGate.js +17 -8
  7. package/src/lib/auth/StandalonePreviewContext.d.ts +3 -3
  8. package/src/lib/auth/StandalonePreviewContext.js +1 -1
  9. package/src/lib/auth/useEmbedContext.d.ts +3 -1
  10. package/src/lib/auth/useEmbedContext.js +3 -1
  11. package/src/lib/bundle/build-static-bundle.d.ts +37 -11
  12. package/src/lib/bundle/build-static-bundle.js +75 -35
  13. package/src/lib/bundle/request-signature.d.ts +13 -1
  14. package/src/lib/bundle/request-signature.js +21 -6
  15. package/src/lib/components/FieldRenderer.d.ts +2 -1
  16. package/src/lib/components/FieldRenderer.js +32 -19
  17. package/src/lib/components/RecordList.js +32 -4
  18. package/src/lib/components/RecordView.js +27 -7
  19. package/src/lib/components/fieldDisplayText.d.ts +11 -0
  20. package/src/lib/components/fieldDisplayText.js +51 -0
  21. package/src/lib/hooks/useFieldTypes.d.ts +5 -0
  22. package/src/lib/hooks/useFieldTypes.js +5 -0
  23. package/src/lib/hooks/useObjectCatalog.d.ts +6 -0
  24. package/src/lib/hooks/useObjectCatalog.js +6 -0
  25. package/src/lib/hooks/useRecord.d.ts +20 -0
  26. package/src/lib/hooks/useRecord.js +53 -0
  27. package/src/lib/hooks/useRecordList.d.ts +7 -1
  28. package/src/lib/hooks/useRecordList.js +3 -3
  29. package/src/lib/hooks/useRelatedRecords.d.ts +5 -1
  30. package/src/lib/provider/SfaClient.js +13 -8
  31. package/src/lib/provider/types.d.ts +7 -1
  32. package/src/lib/signing/compute-signature.d.ts +4 -2
  33. package/src/lib/signing/compute-signature.js +4 -2
  34. package/src/lib/signing/create-manifest.d.ts +8 -6
  35. package/src/lib/signing/create-manifest.js +8 -6
  36. package/src/lib/signing/index.d.ts +1 -1
  37. package/src/lib/signing/types.d.ts +33 -4
  38. package/src/lib/version.d.ts +1 -1
  39. package/src/lib/version.js +1 -1
  40. package/src/signing.d.ts +2 -2
@@ -1,3 +1,10 @@
1
+ /**
2
+ * Whether `value` has the shape Keystone gives `content_hash` and `signature`.
3
+ *
4
+ * NOTE: shared with the builder's previous-build check, so the shape the
5
+ * signer accepts and the shape a rebuild recognises cannot drift apart.
6
+ */
7
+ export declare function isHexDigest(value: unknown): value is string;
1
8
  /**
2
9
  * @description Asks Keystone to sign a bundle, returning the signature.
3
10
  *
@@ -6,6 +13,11 @@
6
13
  * per project while the key must be identical across all of them (SFAJAI-186).
7
14
  * The caller computes `contentHash` locally and Keystone signs it.
8
15
  *
16
+ * Without `studioVersion`, Keystone signs `contentHash` alone. With it, Keystone
17
+ * returns the legacy signature over `"{studio_version}:{content_hash}"`, and the
18
+ * caller must write the same `studio_version` into the manifest for it to
19
+ * verify.
20
+ *
9
21
  * NOTE: every failure throws. An unsigned bundle uploads successfully while
10
22
  * Keystone's verifier runs in `disabled` mode, so returning "no signature" here
11
23
  * would produce an artifact that looks fine and starts being rejected the day
@@ -17,6 +29,6 @@
17
29
  export declare function requestSignature({ apiBase, accessToken, studioVersion, contentHash, }: {
18
30
  readonly apiBase: string;
19
31
  readonly accessToken: string;
20
- readonly studioVersion: string;
32
+ readonly studioVersion?: string;
21
33
  readonly contentHash: string;
22
34
  }): Promise<string>;
@@ -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,11 +27,38 @@ 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
- const totalPages = Math.max(1, Math.ceil(totalCount / pageSize));
40
+ // NOTE: totalCount is null when it is not known — the backend reports no
41
+ // total (Keystone), or the count is not in yet. Then show the page number and
42
+ // the range on screen without a total
31
43
  const rangeStart = pageIndex * pageSize + 1;
32
- const rangeEnd = Math.min((pageIndex + 1) * pageSize, totalCount);
33
- return (_jsxs("div", { style: styles.container, children: [_jsxs("div", { style: styles.header, children: [_jsx("span", { style: styles.collectionName, children: collection }), !isLoading && _jsxs("span", { style: styles.count, children: [totalCount, " records"] })] }), _jsx("div", { style: styles.tableWrapper, children: _jsxs("table", { style: styles.table, children: [_jsx("thead", { children: _jsx("tr", { children: resolvedColumns.map(col => {
44
+ const rangeEnd = totalCount == null
45
+ ? pageIndex * pageSize + items.length
46
+ : Math.min((pageIndex + 1) * pageSize, totalCount);
47
+ const pageLabel = totalCount == null
48
+ ? `Page ${pageIndex + 1}`
49
+ : `Page ${pageIndex + 1} of ${Math.max(1, Math.ceil(totalCount / pageSize))}`;
50
+ const rangeLabel = totalCount == null
51
+ ? `Showing ${rangeStart}-${rangeEnd}`
52
+ : `Showing ${rangeStart}-${rangeEnd} of ${totalCount}`;
53
+ // NOTE: a later page that came back empty (records removed between page
54
+ // turns) keeps the pager so Prev can lead back, also while that page turn
55
+ // loads. No page position is stated there — see hasRecordsOnScreen
56
+ const hasPages = (totalCount !== null && totalCount !== void 0 ? totalCount : items.length) > 0 || pageIndex > 0;
57
+ // NOTE: with no records on screen there is no range or page position to state.
58
+ // While loading the table shows the loading row instead of the items, and
59
+ // pageIndex already names the page being fetched
60
+ const hasRecordsOnScreen = !isLoading && items.length > 0;
61
+ return (_jsxs("div", { style: styles.container, children: [_jsxs("div", { style: styles.header, children: [_jsx("span", { style: styles.collectionName, children: collection }), !isLoading && totalCount != null && (_jsxs("span", { style: styles.count, children: [totalCount, " records"] }))] }), _jsx("div", { style: styles.tableWrapper, children: _jsxs("table", { style: styles.table, children: [_jsx("thead", { children: _jsx("tr", { children: resolvedColumns.map(col => {
34
62
  var _a;
35
63
  return (_jsx("th", { style: Object.assign(Object.assign({}, styles.th), { width: col.width }), children: (_a = col.header) !== null && _a !== void 0 ? _a : col.fieldName }, col.fieldName));
36
64
  }) }) }), _jsxs("tbody", { children: [isLoading && (_jsx("tr", { children: _jsx("td", { colSpan: resolvedColumns.length, style: styles.loadingCell, children: "Loading..." }) })), !isLoading && items.length === 0 && (_jsx("tr", { children: _jsx("td", { colSpan: resolvedColumns.length, style: styles.emptyCell, children: "No records found" }) })), !isLoading &&
@@ -38,7 +66,7 @@ export function RecordList({ collection, columns, params, pageSize = 20, showPag
38
66
  items.map(record => (_jsx("tr", { style: onRowClick ? styles.clickableRow : styles.row, onClick: onRowClick ? () => onRowClick(record) : undefined, children: resolvedColumns.map(col => {
39
67
  const fieldValue = record.fields[col.fieldName];
40
68
  return (_jsx("td", { style: styles.td, children: fieldValue ? (_jsx(FieldRenderer, { fieldValue: fieldValue, fieldName: col.fieldName })) : (_jsx("span", { style: styles.emptyValue, children: "\u2014" })) }, col.fieldName));
41
- }) }, record.record_id)))] })] }) }), showPagination && totalCount > 0 && (_jsxs("div", { style: styles.pagination, children: [_jsx("button", { style: styles.pageButton, disabled: pageIndex === 0 || isLoading, onClick: () => goToPage(pageIndex - 1), type: "button", children: "Prev" }), _jsxs("span", { style: styles.pageInfo, children: ["Page ", pageIndex + 1, " of ", totalPages] }), _jsx("button", { style: styles.pageButton, disabled: !hasMore || isLoading, onClick: () => goToPage(pageIndex + 1), type: "button", children: "Next" }), _jsxs("span", { style: styles.rangeInfo, children: ["Showing ", rangeStart, "-", rangeEnd, " of ", totalCount] })] })), _jsxs("div", { style: styles.versionFooter, children: ["@geniee-inc/sfa-kit v", SFA_KIT_VERSION] })] }));
69
+ }) }, record.record_id)))] })] }) }), showPagination && hasPages && (_jsxs("div", { style: styles.pagination, children: [_jsx("button", { style: styles.pageButton, disabled: pageIndex === 0 || isLoading, onClick: () => goToPage(pageIndex - 1), type: "button", children: "Prev" }), hasRecordsOnScreen && (_jsx("span", { style: styles.pageInfo, children: pageLabel })), _jsx("button", { style: styles.pageButton, disabled: !hasMore || isLoading, onClick: () => goToPage(pageIndex + 1), type: "button", children: "Next" }), hasRecordsOnScreen && (_jsx("span", { style: styles.rangeInfo, children: rangeLabel }))] })), _jsxs("div", { style: styles.versionFooter, children: ["@geniee-inc/sfa-kit v", SFA_KIT_VERSION] })] }));
42
70
  }
43
71
  function autoDetectColumns(items) {
44
72
  const first = items[0];
@@ -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
  }
@@ -26,7 +26,13 @@ interface UseRecordListResult {
26
26
  * it without checking `status` turns a failed fetch into a confident zero.
27
27
  */
28
28
  readonly items: readonly SfaRecord[];
29
- readonly totalCount: number;
29
+ /**
30
+ * @description Records across all pages, or null when not known: the backend
31
+ * does not report it (Keystone), or no fetch has succeeded since the query
32
+ * last changed — an earlier answer to the same query does not count. Like
33
+ * `items`, read it together with `status`
34
+ */
35
+ readonly totalCount: number | null;
30
36
  readonly hasMore: boolean;
31
37
  readonly pageIndex: number;
32
38
  readonly status: SfaListStatus;
@@ -14,7 +14,7 @@ export function useRecordList(collection, params, options) {
14
14
  const listId = (_a = params === null || params === void 0 ? void 0 : params.listId) !== null && _a !== void 0 ? _a : embedCtx === null || embedCtx === void 0 ? void 0 : embedCtx.list_id;
15
15
  const limit = (_b = params === null || params === void 0 ? void 0 : params.limit) !== null && _b !== void 0 ? _b : 20;
16
16
  const [items, setItems] = useState([]);
17
- const [totalCount, setTotalCount] = useState(0);
17
+ const [totalCount, setTotalCount] = useState(null);
18
18
  const [hasMore, setHasMore] = useState(false);
19
19
  /**
20
20
  * Where the caller asked the listing to start.
@@ -161,7 +161,7 @@ export function useRecordList(collection, params, options) {
161
161
  // would show one record's children — or one organization's data —
162
162
  // under another, so drop them and let status report them unknown.
163
163
  setItems([]);
164
- setTotalCount(0);
164
+ setTotalCount(null);
165
165
  setHasMore(false);
166
166
  loadedRef.current = null;
167
167
  }
@@ -245,7 +245,7 @@ export function useRecordList(collection, params, options) {
245
245
  ? { items, totalCount, hasMore, status, error }
246
246
  : {
247
247
  items: NO_ITEMS,
248
- totalCount: 0,
248
+ totalCount: null,
249
249
  hasMore: false,
250
250
  status: willFetch ? 'loading' : 'idle',
251
251
  error: null,
@@ -22,7 +22,11 @@ export interface UseRelatedRecordsResult {
22
22
  * only meaningful when `status` is `'success'` — see useRecordList.
23
23
  */
24
24
  readonly items: readonly SfaRecord[];
25
- readonly totalCount: number;
25
+ /**
26
+ * @description Records across all pages, or null when not known — see
27
+ * useRecordList
28
+ */
29
+ readonly totalCount: number | null;
26
30
  readonly hasMore: boolean;
27
31
  readonly pageIndex: number;
28
32
  /**