@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.
- package/GUIDE.md +34 -14
- package/package.json +1 -1
- package/src/bin/sfa-kit-bundle.js +16 -8
- package/src/bundle.d.ts +1 -2
- package/src/index.d.ts +1 -1
- package/src/lib/auth/resolve-api-base.d.ts +6 -4
- package/src/lib/auth/resolve-api-base.js +24 -5
- package/src/lib/auth/useEmbedSession.d.ts +2 -1
- package/src/lib/auth/useEmbedSession.js +196 -26
- 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 +10 -0
- 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/provider/SfaClient.d.ts +13 -0
- package/src/lib/provider/SfaClient.js +25 -8
- package/src/lib/provider/SfaProvider.d.ts +2 -2
- package/src/lib/provider/SfaProvider.js +1 -1
- package/src/lib/provider/types.d.ts +12 -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
|
@@ -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,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 (
|
|
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
|
}
|
|
@@ -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
|
-
|
|
394
|
-
|
|
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?:
|
|
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:
|
|
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?:
|
|
21
|
+
readonly onAuthError?: SfaAuthErrorHandler;
|
|
11
22
|
}
|
|
12
23
|
/**
|
|
13
24
|
* @description Normalized field kinds aligned with keystone's 10-type model.
|