@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.
- package/GUIDE.md +35 -15
- package/package.json +1 -1
- package/src/bin/sfa-kit-bundle.js +16 -8
- package/src/bundle.d.ts +1 -2
- package/src/lib/auth/DevToolbar.js +3 -6
- package/src/lib/auth/SfaLoginGate.js +17 -8
- package/src/lib/auth/StandalonePreviewContext.d.ts +3 -3
- package/src/lib/auth/StandalonePreviewContext.js +1 -1
- package/src/lib/auth/useEmbedContext.d.ts +3 -1
- package/src/lib/auth/useEmbedContext.js +3 -1
- package/src/lib/bundle/build-static-bundle.d.ts +37 -11
- package/src/lib/bundle/build-static-bundle.js +75 -35
- package/src/lib/bundle/request-signature.d.ts +13 -1
- package/src/lib/bundle/request-signature.js +21 -6
- package/src/lib/components/FieldRenderer.d.ts +2 -1
- package/src/lib/components/FieldRenderer.js +32 -19
- package/src/lib/components/RecordList.js +32 -4
- package/src/lib/components/RecordView.js +27 -7
- package/src/lib/components/fieldDisplayText.d.ts +11 -0
- package/src/lib/components/fieldDisplayText.js +51 -0
- package/src/lib/hooks/useFieldTypes.d.ts +5 -0
- package/src/lib/hooks/useFieldTypes.js +5 -0
- package/src/lib/hooks/useObjectCatalog.d.ts +6 -0
- package/src/lib/hooks/useObjectCatalog.js +6 -0
- package/src/lib/hooks/useRecord.d.ts +20 -0
- package/src/lib/hooks/useRecord.js +53 -0
- package/src/lib/hooks/useRecordList.d.ts +7 -1
- package/src/lib/hooks/useRecordList.js +3 -3
- package/src/lib/hooks/useRelatedRecords.d.ts +5 -1
- package/src/lib/provider/SfaClient.js +13 -8
- package/src/lib/provider/types.d.ts +7 -1
- package/src/lib/signing/compute-signature.d.ts +4 -2
- package/src/lib/signing/compute-signature.js +4 -2
- package/src/lib/signing/create-manifest.d.ts +8 -6
- package/src/lib/signing/create-manifest.js +8 -6
- package/src/lib/signing/index.d.ts +1 -1
- package/src/lib/signing/types.d.ts +33 -4
- package/src/lib/version.d.ts +1 -1
- package/src/lib/version.js +1 -1
- 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
|
|
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 (!
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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 (!
|
|
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
|
|
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
|
|
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
|
-
|
|
8
|
-
|
|
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
|
-
|
|
43
|
-
|
|
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
|
-
|
|
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 =
|
|
33
|
-
|
|
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 &&
|
|
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 (
|
|
21
|
-
(_a = onErrorRef.current) === null || _a === void 0 ? void 0 : _a.call(onErrorRef,
|
|
22
|
-
}, [
|
|
23
|
-
if (
|
|
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
|
|
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
|
-
|
|
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 => {
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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:
|
|
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
|
-
|
|
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
|
/**
|