@geniee-inc/sfa-kit 1.6.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 +585 -0
- package/LICENSE.md +21 -0
- package/README.md +31 -0
- package/package.json +36 -0
- package/src/index.d.ts +29 -0
- package/src/index.js +21 -0
- package/src/lib/auth/DevToolbar.d.ts +38 -0
- package/src/lib/auth/DevToolbar.js +271 -0
- package/src/lib/auth/SfaLoginGate.d.ts +53 -0
- package/src/lib/auth/SfaLoginGate.js +462 -0
- package/src/lib/auth/StandalonePreviewContext.d.ts +27 -0
- package/src/lib/auth/StandalonePreviewContext.js +14 -0
- package/src/lib/auth/embed-protocol.d.ts +40 -0
- package/src/lib/auth/embed-protocol.js +56 -0
- package/src/lib/auth/exchange-code.d.ts +14 -0
- package/src/lib/auth/exchange-code.js +45 -0
- package/src/lib/auth/keystone-auth.d.ts +16 -0
- package/src/lib/auth/keystone-auth.js +84 -0
- package/src/lib/auth/session-store.d.ts +19 -0
- package/src/lib/auth/session-store.js +73 -0
- package/src/lib/auth/types.d.ts +33 -0
- package/src/lib/auth/types.js +1 -0
- package/src/lib/auth/useEmbedContext.d.ts +22 -0
- package/src/lib/auth/useEmbedContext.js +31 -0
- package/src/lib/components/EmptyState.d.ts +18 -0
- package/src/lib/components/EmptyState.js +36 -0
- package/src/lib/components/ErrorBoundary.d.ts +31 -0
- package/src/lib/components/ErrorBoundary.js +100 -0
- package/src/lib/components/FieldRenderer.d.ts +11 -0
- package/src/lib/components/FieldRenderer.js +57 -0
- package/src/lib/components/RecordList.d.ts +20 -0
- package/src/lib/components/RecordList.js +139 -0
- package/src/lib/components/RecordView.d.ts +14 -0
- package/src/lib/components/RecordView.js +153 -0
- package/src/lib/errors.d.ts +15 -0
- package/src/lib/errors.js +15 -0
- package/src/lib/hooks/useCreateRecord.d.ts +17 -0
- package/src/lib/hooks/useCreateRecord.js +48 -0
- package/src/lib/hooks/useDeleteRecord.d.ts +16 -0
- package/src/lib/hooks/useDeleteRecord.js +48 -0
- package/src/lib/hooks/useDescribe.d.ts +16 -0
- package/src/lib/hooks/useDescribe.js +40 -0
- package/src/lib/hooks/useFieldTypes.d.ts +15 -0
- package/src/lib/hooks/useFieldTypes.js +39 -0
- package/src/lib/hooks/useObjectCatalog.d.ts +15 -0
- package/src/lib/hooks/useObjectCatalog.js +39 -0
- package/src/lib/hooks/useRecord.d.ts +15 -0
- package/src/lib/hooks/useRecord.js +49 -0
- package/src/lib/hooks/useRecordList.d.ts +19 -0
- package/src/lib/hooks/useRecordList.js +82 -0
- package/src/lib/hooks/useRelatedRecords.d.ts +65 -0
- package/src/lib/hooks/useRelatedRecords.js +227 -0
- package/src/lib/hooks/useUpdateRecord.d.ts +23 -0
- package/src/lib/hooks/useUpdateRecord.js +55 -0
- package/src/lib/provider/SfaClient.d.ts +98 -0
- package/src/lib/provider/SfaClient.js +688 -0
- package/src/lib/provider/SfaProvider.d.ts +38 -0
- package/src/lib/provider/SfaProvider.js +44 -0
- package/src/lib/provider/types.d.ts +184 -0
- package/src/lib/provider/types.js +1 -0
- package/src/lib/provider/useSfaClient.d.ts +11 -0
- package/src/lib/provider/useSfaClient.js +24 -0
- package/src/lib/theme.d.ts +55 -0
- package/src/lib/theme.js +68 -0
- package/src/lib/version.d.ts +1 -0
- package/src/lib/version.js +1 -0
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { __awaiter } from "tslib";
|
|
2
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
3
|
+
import { ERR_EMPTY_RECORD_ID } from '../errors';
|
|
4
|
+
import { useSfaClient } from '../provider/useSfaClient';
|
|
5
|
+
/**
|
|
6
|
+
* @description Fetches a single record from the SFA/CRM API.
|
|
7
|
+
*/
|
|
8
|
+
export function useRecord(collection, recordId, options) {
|
|
9
|
+
var _a;
|
|
10
|
+
const client = useSfaClient();
|
|
11
|
+
const enabled = (_a = options === null || options === void 0 ? void 0 : options.enabled) !== null && _a !== void 0 ? _a : true;
|
|
12
|
+
const [record, setRecord] = useState(null);
|
|
13
|
+
const [isLoading, setIsLoading] = useState(enabled && !!collection && !!recordId);
|
|
14
|
+
const [error, setError] = useState(null);
|
|
15
|
+
const fetchId = useRef(0);
|
|
16
|
+
const doFetch = useCallback(() => __awaiter(this, void 0, void 0, function* () {
|
|
17
|
+
if (!enabled || !collection)
|
|
18
|
+
return;
|
|
19
|
+
if (!recordId) {
|
|
20
|
+
fetchId.current += 1;
|
|
21
|
+
setRecord(null);
|
|
22
|
+
setIsLoading(false);
|
|
23
|
+
setError(new Error(ERR_EMPTY_RECORD_ID));
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
fetchId.current += 1;
|
|
27
|
+
const currentFetchId = fetchId.current;
|
|
28
|
+
setIsLoading(true);
|
|
29
|
+
setError(null);
|
|
30
|
+
try {
|
|
31
|
+
const result = yield client.get(collection, recordId);
|
|
32
|
+
if (currentFetchId === fetchId.current) {
|
|
33
|
+
setRecord(result);
|
|
34
|
+
setIsLoading(false);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
catch (e) {
|
|
38
|
+
if (currentFetchId === fetchId.current) {
|
|
39
|
+
setError(e instanceof Error ? e : new Error(String(e)));
|
|
40
|
+
setRecord(null);
|
|
41
|
+
setIsLoading(false);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}), [client, collection, recordId, enabled]);
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
doFetch();
|
|
47
|
+
}, [doFetch]);
|
|
48
|
+
return { record, isLoading, error, refetch: doFetch };
|
|
49
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { SfaListParams, SfaRecord } from '../provider/types';
|
|
2
|
+
interface UseRecordListOptions {
|
|
3
|
+
readonly enabled?: boolean;
|
|
4
|
+
}
|
|
5
|
+
interface UseRecordListResult {
|
|
6
|
+
readonly items: readonly SfaRecord[];
|
|
7
|
+
readonly totalCount: number;
|
|
8
|
+
readonly hasMore: boolean;
|
|
9
|
+
readonly pageIndex: number;
|
|
10
|
+
readonly isLoading: boolean;
|
|
11
|
+
readonly error: Error | null;
|
|
12
|
+
readonly refetch: () => void;
|
|
13
|
+
readonly goToPage: (page: number) => void;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* @description Fetches a paginated list of records from the SFA/CRM API.
|
|
17
|
+
*/
|
|
18
|
+
export declare function useRecordList(collection: string, params?: SfaListParams, options?: UseRecordListOptions): UseRecordListResult;
|
|
19
|
+
export {};
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { __awaiter } from "tslib";
|
|
2
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
3
|
+
import { useSfaClient } from '../provider/useSfaClient';
|
|
4
|
+
/**
|
|
5
|
+
* @description Fetches a paginated list of records from the SFA/CRM API.
|
|
6
|
+
*/
|
|
7
|
+
export function useRecordList(collection, params, options) {
|
|
8
|
+
var _a, _b;
|
|
9
|
+
const client = useSfaClient();
|
|
10
|
+
const limit = (_a = params === null || params === void 0 ? void 0 : params.limit) !== null && _a !== void 0 ? _a : 20;
|
|
11
|
+
const [items, setItems] = useState([]);
|
|
12
|
+
const [totalCount, setTotalCount] = useState(0);
|
|
13
|
+
const [hasMore, setHasMore] = useState(false);
|
|
14
|
+
// offset is a record offset, not a page number: pageIndex = floor(offset / limit)
|
|
15
|
+
const [pageIndex, setPageIndex] = useState((params === null || params === void 0 ? void 0 : params.offset) != null && limit > 0 ? Math.floor(params.offset / limit) : 0);
|
|
16
|
+
const enabled = (_b = options === null || options === void 0 ? void 0 : options.enabled) !== null && _b !== void 0 ? _b : true;
|
|
17
|
+
const [isLoading, setIsLoading] = useState(enabled && !!collection);
|
|
18
|
+
const [error, setError] = useState(null);
|
|
19
|
+
const fetchId = useRef(0);
|
|
20
|
+
const sortBy = params === null || params === void 0 ? void 0 : params.sortBy;
|
|
21
|
+
const sortOrder = params === null || params === void 0 ? void 0 : params.sortOrder;
|
|
22
|
+
const filterKey = useMemo(() => ((params === null || params === void 0 ? void 0 : params.filter) ? JSON.stringify(params.filter) : ''), [params === null || params === void 0 ? void 0 : params.filter]);
|
|
23
|
+
const filterRef = useRef(params === null || params === void 0 ? void 0 : params.filter);
|
|
24
|
+
filterRef.current = params === null || params === void 0 ? void 0 : params.filter;
|
|
25
|
+
// Track cursors for each page so goToPage can navigate backwards.
|
|
26
|
+
const cursorMapRef = useRef(new Map());
|
|
27
|
+
const doFetch = useCallback((page) => __awaiter(this, void 0, void 0, function* () {
|
|
28
|
+
if (!collection || !enabled)
|
|
29
|
+
return;
|
|
30
|
+
fetchId.current += 1;
|
|
31
|
+
const currentFetchId = fetchId.current;
|
|
32
|
+
setIsLoading(true);
|
|
33
|
+
setError(null);
|
|
34
|
+
try {
|
|
35
|
+
const cursor = cursorMapRef.current.get(page);
|
|
36
|
+
const result = yield client.list(collection, Object.assign({ limit, offset: page, sortBy,
|
|
37
|
+
sortOrder, filter: filterRef.current }, (cursor ? { cursor } : {})));
|
|
38
|
+
if (currentFetchId === fetchId.current) {
|
|
39
|
+
setItems(result.items);
|
|
40
|
+
setTotalCount(result.totalCount);
|
|
41
|
+
setHasMore(result.hasMore);
|
|
42
|
+
setPageIndex(result.pageIndex);
|
|
43
|
+
setIsLoading(false);
|
|
44
|
+
// Store cursor for the next page so goToPage can use it.
|
|
45
|
+
if (result.nextCursor) {
|
|
46
|
+
cursorMapRef.current.set(page + 1, result.nextCursor);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
catch (e) {
|
|
51
|
+
if (currentFetchId === fetchId.current) {
|
|
52
|
+
setError(e instanceof Error ? e : new Error(String(e)));
|
|
53
|
+
setItems([]);
|
|
54
|
+
setIsLoading(false);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}),
|
|
58
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps -- filterKey triggers re-fetch when filter object changes
|
|
59
|
+
[client, collection, enabled, limit, sortBy, sortOrder, filterKey]);
|
|
60
|
+
useEffect(() => {
|
|
61
|
+
cursorMapRef.current.clear();
|
|
62
|
+
setPageIndex(0);
|
|
63
|
+
doFetch(0);
|
|
64
|
+
}, [doFetch]);
|
|
65
|
+
const goToPage = useCallback((page) => {
|
|
66
|
+
setPageIndex(page);
|
|
67
|
+
doFetch(page);
|
|
68
|
+
}, [doFetch]);
|
|
69
|
+
const refetch = useCallback(() => {
|
|
70
|
+
doFetch(pageIndex);
|
|
71
|
+
}, [doFetch, pageIndex]);
|
|
72
|
+
return {
|
|
73
|
+
items,
|
|
74
|
+
totalCount,
|
|
75
|
+
hasMore,
|
|
76
|
+
pageIndex,
|
|
77
|
+
isLoading,
|
|
78
|
+
error,
|
|
79
|
+
refetch,
|
|
80
|
+
goToPage,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { SfaRecord, SfaRelationDescriptor } from '../provider/types';
|
|
2
|
+
/** @description Parameters for narrowing, sorting, and filtering related records. */
|
|
3
|
+
export interface UseRelatedRecordsParams {
|
|
4
|
+
readonly limit?: number;
|
|
5
|
+
readonly sortBy?: string;
|
|
6
|
+
readonly sortOrder?: 'asc' | 'desc';
|
|
7
|
+
/**
|
|
8
|
+
* @description Extra filter constraints merged alongside the auto-built
|
|
9
|
+
* relationship filter. Accepts any react-querybuilder-shaped group or rule.
|
|
10
|
+
*/
|
|
11
|
+
readonly additionalFilter?: Record<string, unknown>;
|
|
12
|
+
}
|
|
13
|
+
/** @description Options controlling whether the hook fetches. */
|
|
14
|
+
export interface UseRelatedRecordsOptions {
|
|
15
|
+
readonly enabled?: boolean;
|
|
16
|
+
}
|
|
17
|
+
/** @description Return value of useRelatedRecords: paginated items, loading/error state, and the resolved relationship descriptor. */
|
|
18
|
+
export interface UseRelatedRecordsResult {
|
|
19
|
+
readonly items: readonly SfaRecord[];
|
|
20
|
+
readonly totalCount: number;
|
|
21
|
+
readonly hasMore: boolean;
|
|
22
|
+
readonly pageIndex: number;
|
|
23
|
+
readonly isLoading: boolean;
|
|
24
|
+
readonly error: Error | null;
|
|
25
|
+
readonly refetch: () => void;
|
|
26
|
+
readonly goToPage: (page: number) => void;
|
|
27
|
+
readonly relationship: SfaRelationDescriptor | null;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* @description Thrown when the describe metadata for parentCollection
|
|
31
|
+
* contains no relationship pointing at the requested relationTarget.
|
|
32
|
+
*/
|
|
33
|
+
export declare class RelationNotFoundError extends Error {
|
|
34
|
+
readonly parentCollection: string;
|
|
35
|
+
readonly relationTarget: string;
|
|
36
|
+
readonly availableTargets: readonly string[];
|
|
37
|
+
constructor(parentCollection: string, relationTarget: string, availableTargets: readonly string[]);
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* @description Finds the first relationship descriptor whose `target`
|
|
41
|
+
* matches relationTarget, falling back to a match on `name`.
|
|
42
|
+
*/
|
|
43
|
+
export declare function resolveRelation(relationships: readonly SfaRelationDescriptor[], relationTarget: string): SfaRelationDescriptor | undefined;
|
|
44
|
+
/**
|
|
45
|
+
* @description Builds a react-querybuilder-shaped filter that constrains
|
|
46
|
+
* `viaField` to the given parent id(s). A single string uses `eq`;
|
|
47
|
+
* an array uses `in` (batch fetch for the grandchild pattern).
|
|
48
|
+
*/
|
|
49
|
+
export declare function buildRelationFilter(viaField: string, parentId: string | readonly string[], additionalFilter?: Record<string, unknown>): Record<string, unknown>;
|
|
50
|
+
/**
|
|
51
|
+
* @description Fetches child records scoped to a parent by automatically
|
|
52
|
+
* resolving the join from describe metadata. The AI agent never needs to
|
|
53
|
+
* hand-wire foreign keys: pass the parent collection, a parent id (or array
|
|
54
|
+
* of ids for the grandchild batch pattern), and the target collection.
|
|
55
|
+
*
|
|
56
|
+
* Resolution is direction-agnostic: the hook first checks the parent's
|
|
57
|
+
* describe for a forward relationship to the target; if none is found it
|
|
58
|
+
* falls back to describing the target and looking for a reverse
|
|
59
|
+
* relationship back to the parent. This handles org schemas that only
|
|
60
|
+
* declare the FK on the "many" (child) side.
|
|
61
|
+
*
|
|
62
|
+
* Composes useDescribe (relationship lookup) and useRecordList (fetch +
|
|
63
|
+
* cursor pagination) internally.
|
|
64
|
+
*/
|
|
65
|
+
export declare function useRelatedRecords(parentCollection: string, parentId: string | readonly string[], relationTarget: string, params?: UseRelatedRecordsParams, options?: UseRelatedRecordsOptions): UseRelatedRecordsResult;
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import { useMemo, useRef } from 'react';
|
|
2
|
+
import { useDescribe } from './useDescribe';
|
|
3
|
+
import { useRecordList } from './useRecordList';
|
|
4
|
+
// ── Error ──
|
|
5
|
+
/**
|
|
6
|
+
* @description Thrown when the describe metadata for parentCollection
|
|
7
|
+
* contains no relationship pointing at the requested relationTarget.
|
|
8
|
+
*/
|
|
9
|
+
export class RelationNotFoundError extends Error {
|
|
10
|
+
constructor(parentCollection, relationTarget, availableTargets) {
|
|
11
|
+
const available = availableTargets.length > 0 ? availableTargets.join(', ') : 'none';
|
|
12
|
+
super(`No relationship from "${parentCollection}" to "${relationTarget}". ` +
|
|
13
|
+
`Available targets: ${available}.`);
|
|
14
|
+
this.name = 'RelationNotFoundError';
|
|
15
|
+
this.parentCollection = parentCollection;
|
|
16
|
+
this.relationTarget = relationTarget;
|
|
17
|
+
this.availableTargets = availableTargets;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
// ── Helpers ──
|
|
21
|
+
/**
|
|
22
|
+
* @description Finds the first relationship descriptor whose `target`
|
|
23
|
+
* matches relationTarget, falling back to a match on `name`.
|
|
24
|
+
*/
|
|
25
|
+
export function resolveRelation(relationships, relationTarget) {
|
|
26
|
+
var _a;
|
|
27
|
+
return ((_a = relationships.find(r => r.target === relationTarget)) !== null && _a !== void 0 ? _a : relationships.find(r => r.name === relationTarget));
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* @description Builds a react-querybuilder-shaped filter that constrains
|
|
31
|
+
* `viaField` to the given parent id(s). A single string uses `eq`;
|
|
32
|
+
* an array uses `in` (batch fetch for the grandchild pattern).
|
|
33
|
+
*/
|
|
34
|
+
export function buildRelationFilter(viaField, parentId, additionalFilter) {
|
|
35
|
+
const isArray = Array.isArray(parentId);
|
|
36
|
+
const relationRule = {
|
|
37
|
+
field: viaField,
|
|
38
|
+
operator: isArray ? 'in' : 'eq',
|
|
39
|
+
value: parentId,
|
|
40
|
+
};
|
|
41
|
+
const rules = [relationRule];
|
|
42
|
+
if (additionalFilter) {
|
|
43
|
+
rules.push(additionalFilter);
|
|
44
|
+
}
|
|
45
|
+
return { combinator: 'and', rules };
|
|
46
|
+
}
|
|
47
|
+
// ── Hook ──
|
|
48
|
+
/**
|
|
49
|
+
* @description Fetches child records scoped to a parent by automatically
|
|
50
|
+
* resolving the join from describe metadata. The AI agent never needs to
|
|
51
|
+
* hand-wire foreign keys: pass the parent collection, a parent id (or array
|
|
52
|
+
* of ids for the grandchild batch pattern), and the target collection.
|
|
53
|
+
*
|
|
54
|
+
* Resolution is direction-agnostic: the hook first checks the parent's
|
|
55
|
+
* describe for a forward relationship to the target; if none is found it
|
|
56
|
+
* falls back to describing the target and looking for a reverse
|
|
57
|
+
* relationship back to the parent. This handles org schemas that only
|
|
58
|
+
* declare the FK on the "many" (child) side.
|
|
59
|
+
*
|
|
60
|
+
* Composes useDescribe (relationship lookup) and useRecordList (fetch +
|
|
61
|
+
* cursor pagination) internally.
|
|
62
|
+
*/
|
|
63
|
+
export function useRelatedRecords(parentCollection, parentId, relationTarget, params, options) {
|
|
64
|
+
var _a, _b, _c;
|
|
65
|
+
const enabled = (_a = options === null || options === void 0 ? void 0 : options.enabled) !== null && _a !== void 0 ? _a : true;
|
|
66
|
+
const hasParentId = Array.isArray(parentId)
|
|
67
|
+
? parentId.length > 0
|
|
68
|
+
: !!parentId;
|
|
69
|
+
// ── Step 1: Describe the parent to discover relationships ──
|
|
70
|
+
// Describe is decoupled from hasParentId so it prefetches eagerly.
|
|
71
|
+
// In the grandchild pattern the second hook's parentId starts as []
|
|
72
|
+
// while the first hook fetches; starting describe early lets both
|
|
73
|
+
// metadata calls overlap instead of running in sequence.
|
|
74
|
+
const { object: parentObject, isLoading: isParentDescribeLoading, error: parentDescribeError, } = useDescribe(parentCollection, {
|
|
75
|
+
enabled,
|
|
76
|
+
});
|
|
77
|
+
// Only trust parentObject while it matches the current collection;
|
|
78
|
+
// useDescribe keeps the previous object around while a new key is loading.
|
|
79
|
+
const parentObjectMatches = (parentObject === null || parentObject === void 0 ? void 0 : parentObject.key) === parentCollection;
|
|
80
|
+
// ── Step 2: Resolve the relationship ──
|
|
81
|
+
// Try the parent's describe first (forward: parent declares a
|
|
82
|
+
// relationship to the child). If that misses, fall back to the
|
|
83
|
+
// child's describe and look for a reverse relationship back to the
|
|
84
|
+
// parent. Some org schemas only declare the FK on the "many" side;
|
|
85
|
+
// this two-pass lookup keeps callers direction-agnostic.
|
|
86
|
+
const forwardRelationship = useMemo(() => {
|
|
87
|
+
var _a;
|
|
88
|
+
return parentObject && parentObjectMatches
|
|
89
|
+
? (_a = resolveRelation(parentObject.relationships, relationTarget)) !== null && _a !== void 0 ? _a : null
|
|
90
|
+
: null;
|
|
91
|
+
}, [parentObject, parentObjectMatches, relationTarget]);
|
|
92
|
+
// ── Step 2b: Reverse-lookup fallback via the child's describe ──
|
|
93
|
+
const needsReverseLookup = enabled &&
|
|
94
|
+
parentObjectMatches &&
|
|
95
|
+
!isParentDescribeLoading &&
|
|
96
|
+
!forwardRelationship;
|
|
97
|
+
const { object: childObject, isLoading: isChildDescribeLoading, error: childDescribeError, } = useDescribe(relationTarget, {
|
|
98
|
+
enabled: needsReverseLookup,
|
|
99
|
+
});
|
|
100
|
+
const childObjectMatches = (childObject === null || childObject === void 0 ? void 0 : childObject.key) === relationTarget;
|
|
101
|
+
const reverseRelationship = useMemo(() => {
|
|
102
|
+
var _a;
|
|
103
|
+
return needsReverseLookup && childObject && childObjectMatches
|
|
104
|
+
? (_a = resolveRelation(childObject.relationships, parentCollection)) !== null && _a !== void 0 ? _a : null
|
|
105
|
+
: null;
|
|
106
|
+
}, [needsReverseLookup, childObject, childObjectMatches, parentCollection]);
|
|
107
|
+
const relationship = forwardRelationship !== null && forwardRelationship !== void 0 ? forwardRelationship : reverseRelationship;
|
|
108
|
+
// ── Step 3: Surface a clear error when the relation is absent ──
|
|
109
|
+
// Only error once both forward and reverse lookups have definitively failed.
|
|
110
|
+
const relationError = useMemo(() => {
|
|
111
|
+
if (relationship)
|
|
112
|
+
return null;
|
|
113
|
+
if (!parentObjectMatches)
|
|
114
|
+
return null;
|
|
115
|
+
if (isParentDescribeLoading)
|
|
116
|
+
return null;
|
|
117
|
+
if (needsReverseLookup && (isChildDescribeLoading || !childObjectMatches))
|
|
118
|
+
return null;
|
|
119
|
+
return new RelationNotFoundError(parentCollection, relationTarget, parentObject.relationships.map(r => r.target));
|
|
120
|
+
}, [
|
|
121
|
+
relationship,
|
|
122
|
+
parentObjectMatches,
|
|
123
|
+
isParentDescribeLoading,
|
|
124
|
+
needsReverseLookup,
|
|
125
|
+
isChildDescribeLoading,
|
|
126
|
+
childObjectMatches,
|
|
127
|
+
parentCollection,
|
|
128
|
+
relationTarget,
|
|
129
|
+
parentObject,
|
|
130
|
+
]);
|
|
131
|
+
// ── Step 4: Build the relationship filter ──
|
|
132
|
+
// Stabilize array/object identity via serialized keys so downstream
|
|
133
|
+
// useRecordList only re-fetches when the actual values change.
|
|
134
|
+
const parentIdRef = useRef(parentId);
|
|
135
|
+
parentIdRef.current = parentId;
|
|
136
|
+
const parentIdKey = Array.isArray(parentId)
|
|
137
|
+
? JSON.stringify(parentId)
|
|
138
|
+
: parentId;
|
|
139
|
+
const additionalFilterRef = useRef(params === null || params === void 0 ? void 0 : params.additionalFilter);
|
|
140
|
+
additionalFilterRef.current = params === null || params === void 0 ? void 0 : params.additionalFilter;
|
|
141
|
+
const additionalFilterKey = (params === null || params === void 0 ? void 0 : params.additionalFilter)
|
|
142
|
+
? JSON.stringify(params.additionalFilter)
|
|
143
|
+
: '';
|
|
144
|
+
const filter = useMemo(() => relationship
|
|
145
|
+
? buildRelationFilter(relationship.viaField, parentIdRef.current, additionalFilterRef.current)
|
|
146
|
+
: undefined,
|
|
147
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps -- parentIdKey and additionalFilterKey are stable serialized proxies for ref values
|
|
148
|
+
[relationship, parentIdKey, additionalFilterKey]);
|
|
149
|
+
// ── Step 5: Delegate to useRecordList ──
|
|
150
|
+
const listParams = useMemo(() => {
|
|
151
|
+
var _a;
|
|
152
|
+
return filter
|
|
153
|
+
? {
|
|
154
|
+
limit: (_a = params === null || params === void 0 ? void 0 : params.limit) !== null && _a !== void 0 ? _a : 20,
|
|
155
|
+
sortBy: params === null || params === void 0 ? void 0 : params.sortBy,
|
|
156
|
+
sortOrder: params === null || params === void 0 ? void 0 : params.sortOrder,
|
|
157
|
+
filter,
|
|
158
|
+
}
|
|
159
|
+
: undefined;
|
|
160
|
+
}, [filter, params === null || params === void 0 ? void 0 : params.limit, params === null || params === void 0 ? void 0 : params.sortBy, params === null || params === void 0 ? void 0 : params.sortOrder]);
|
|
161
|
+
// When using a reverse relationship, relationship.target points back at
|
|
162
|
+
// the *parent* — not the child we want to query. Always prefer the
|
|
163
|
+
// forward relationship's target (which IS the child); otherwise fall
|
|
164
|
+
// back to the caller-supplied relationTarget.
|
|
165
|
+
const targetCollection = (_b = forwardRelationship === null || forwardRelationship === void 0 ? void 0 : forwardRelationship.target) !== null && _b !== void 0 ? _b : relationTarget;
|
|
166
|
+
const recordList = useRecordList(targetCollection, listParams, {
|
|
167
|
+
enabled: enabled &&
|
|
168
|
+
hasParentId &&
|
|
169
|
+
parentObjectMatches &&
|
|
170
|
+
!!relationship &&
|
|
171
|
+
!relationError,
|
|
172
|
+
});
|
|
173
|
+
// Detect any change to the list query identifiers so we can reset the
|
|
174
|
+
// "fetch has started" marker before useRecordList's effect flips isLoading
|
|
175
|
+
// back on. This prevents one render frame where isLoading is false while
|
|
176
|
+
// the previous fetch's items are still on display.
|
|
177
|
+
const listQueryKey = useMemo(() => JSON.stringify({
|
|
178
|
+
collection: targetCollection,
|
|
179
|
+
params: listParams,
|
|
180
|
+
relationTarget,
|
|
181
|
+
}), [targetCollection, listParams, relationTarget]);
|
|
182
|
+
// ── Step 6: Merge state ──
|
|
183
|
+
// listShouldBeActive covers the one-frame gap where useRecordList's
|
|
184
|
+
// enabled just flipped to true but its internal isLoading hasn't been
|
|
185
|
+
// set yet (useState initial value is only evaluated at mount).
|
|
186
|
+
// listQueryKey resets the marker whenever the query identity changes
|
|
187
|
+
// (parentId / filter / limit / sort / relationTarget) so we never return
|
|
188
|
+
// stale items with isLoading: false while a new fetch is about to start.
|
|
189
|
+
const listShouldBeActive = enabled &&
|
|
190
|
+
hasParentId &&
|
|
191
|
+
parentObjectMatches &&
|
|
192
|
+
!!relationship &&
|
|
193
|
+
!relationError;
|
|
194
|
+
const listHasStartedRef = useRef(false);
|
|
195
|
+
const prevListShouldBeActiveRef = useRef(listShouldBeActive);
|
|
196
|
+
const prevListQueryKeyRef = useRef(listQueryKey);
|
|
197
|
+
if (listShouldBeActive &&
|
|
198
|
+
(!prevListShouldBeActiveRef.current ||
|
|
199
|
+
prevListQueryKeyRef.current !== listQueryKey)) {
|
|
200
|
+
listHasStartedRef.current = false;
|
|
201
|
+
}
|
|
202
|
+
prevListShouldBeActiveRef.current = listShouldBeActive;
|
|
203
|
+
prevListQueryKeyRef.current = listQueryKey;
|
|
204
|
+
if (listShouldBeActive && recordList.isLoading) {
|
|
205
|
+
listHasStartedRef.current = true;
|
|
206
|
+
}
|
|
207
|
+
const isDescribeLoading = isParentDescribeLoading ||
|
|
208
|
+
(needsReverseLookup &&
|
|
209
|
+
!childDescribeError &&
|
|
210
|
+
(!childObjectMatches || isChildDescribeLoading));
|
|
211
|
+
const isLoading = isDescribeLoading ||
|
|
212
|
+
(listShouldBeActive &&
|
|
213
|
+
(recordList.isLoading || !listHasStartedRef.current));
|
|
214
|
+
const describeError = parentDescribeError !== null && parentDescribeError !== void 0 ? parentDescribeError : (needsReverseLookup ? childDescribeError : null);
|
|
215
|
+
const error = (_c = describeError !== null && describeError !== void 0 ? describeError : relationError) !== null && _c !== void 0 ? _c : recordList.error;
|
|
216
|
+
return {
|
|
217
|
+
items: recordList.items,
|
|
218
|
+
totalCount: recordList.totalCount,
|
|
219
|
+
hasMore: recordList.hasMore,
|
|
220
|
+
pageIndex: recordList.pageIndex,
|
|
221
|
+
isLoading,
|
|
222
|
+
error,
|
|
223
|
+
refetch: recordList.refetch,
|
|
224
|
+
goToPage: recordList.goToPage,
|
|
225
|
+
relationship,
|
|
226
|
+
};
|
|
227
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { SfaRecord } from '../provider/types';
|
|
2
|
+
export interface SfaConflictDetail {
|
|
3
|
+
/** The current version of the record on the server. */
|
|
4
|
+
readonly currentRecord: SfaRecord;
|
|
5
|
+
}
|
|
6
|
+
export interface UseUpdateRecordResult {
|
|
7
|
+
/** Call to update a record. Requires the record's current version for optimistic locking. */
|
|
8
|
+
readonly update: (recordId: string, fields: Record<string, unknown>, version: string) => Promise<SfaRecord>;
|
|
9
|
+
/** The most recently updated record, or null before the first update. */
|
|
10
|
+
readonly data: SfaRecord | null;
|
|
11
|
+
readonly isLoading: boolean;
|
|
12
|
+
readonly error: Error | null;
|
|
13
|
+
/** Non-null when the last update failed with a 412 conflict. Contains the server's current record. */
|
|
14
|
+
readonly conflict: SfaConflictDetail | null;
|
|
15
|
+
/** Reset data, error, and conflict state to initial values. */
|
|
16
|
+
readonly reset: () => void;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* @description Imperative hook for updating a single record with optimistic
|
|
20
|
+
* locking. Surfaces 412 conflicts as a dedicated `conflict` state so
|
|
21
|
+
* components can show "someone else updated this" with the current record.
|
|
22
|
+
*/
|
|
23
|
+
export declare function useUpdateRecord(collection: string): UseUpdateRecordResult;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { __awaiter } from "tslib";
|
|
2
|
+
import { useCallback, useRef, useState } from 'react';
|
|
3
|
+
import { ERR_READ_ONLY } from '../errors';
|
|
4
|
+
import { SfaConflictError } from '../provider/SfaClient';
|
|
5
|
+
import { useSfaContext } from '../provider/useSfaClient';
|
|
6
|
+
/**
|
|
7
|
+
* @description Imperative hook for updating a single record with optimistic
|
|
8
|
+
* locking. Surfaces 412 conflicts as a dedicated `conflict` state so
|
|
9
|
+
* components can show "someone else updated this" with the current record.
|
|
10
|
+
*/
|
|
11
|
+
export function useUpdateRecord(collection) {
|
|
12
|
+
const { client, readOnly } = useSfaContext();
|
|
13
|
+
const [data, setData] = useState(null);
|
|
14
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
15
|
+
const [error, setError] = useState(null);
|
|
16
|
+
const [conflict, setConflict] = useState(null);
|
|
17
|
+
const callId = useRef(0);
|
|
18
|
+
const update = useCallback((recordId, fields, version) => __awaiter(this, void 0, void 0, function* () {
|
|
19
|
+
callId.current += 1;
|
|
20
|
+
const currentCallId = callId.current;
|
|
21
|
+
if (readOnly) {
|
|
22
|
+
throw new Error(ERR_READ_ONLY);
|
|
23
|
+
}
|
|
24
|
+
setIsLoading(true);
|
|
25
|
+
setError(null);
|
|
26
|
+
setConflict(null);
|
|
27
|
+
try {
|
|
28
|
+
const record = yield client.update(collection, recordId, fields, version);
|
|
29
|
+
if (currentCallId === callId.current) {
|
|
30
|
+
setData(record);
|
|
31
|
+
setIsLoading(false);
|
|
32
|
+
}
|
|
33
|
+
return record;
|
|
34
|
+
}
|
|
35
|
+
catch (e) {
|
|
36
|
+
if (currentCallId === callId.current) {
|
|
37
|
+
if (e instanceof SfaConflictError && e.currentRecord) {
|
|
38
|
+
setConflict({ currentRecord: e.currentRecord });
|
|
39
|
+
}
|
|
40
|
+
const err = e instanceof Error ? e : new Error(String(e));
|
|
41
|
+
setError(err);
|
|
42
|
+
setIsLoading(false);
|
|
43
|
+
}
|
|
44
|
+
throw e;
|
|
45
|
+
}
|
|
46
|
+
}), [client, collection, readOnly]);
|
|
47
|
+
const reset = useCallback(() => {
|
|
48
|
+
callId.current += 1;
|
|
49
|
+
setData(null);
|
|
50
|
+
setError(null);
|
|
51
|
+
setConflict(null);
|
|
52
|
+
setIsLoading(false);
|
|
53
|
+
}, []);
|
|
54
|
+
return { update, data, isLoading, error, conflict, reset };
|
|
55
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import type { SfaApiMode, SfaClientConfig, SfaClientInterface, SfaCreateOptions, SfaDescribeResult, SfaFieldError, SfaFieldTypeCatalog, SfaListParams, SfaListResponse, SfaObjectCatalog, SfaRecord } from './types';
|
|
2
|
+
/**
|
|
3
|
+
* @description API client for SFA/CRM data operations via keystone.
|
|
4
|
+
* Authenticates with a JWT Bearer token.
|
|
5
|
+
*
|
|
6
|
+
* In direct mode the client talks to keystone's v1 REST API.
|
|
7
|
+
* In proxy mode the client relays through a local Next.js API route.
|
|
8
|
+
*/
|
|
9
|
+
export declare class SfaClient implements SfaClientInterface {
|
|
10
|
+
private readonly apiBase;
|
|
11
|
+
private readonly auth;
|
|
12
|
+
private readonly organizationId;
|
|
13
|
+
private readonly onAuthError;
|
|
14
|
+
readonly mode: SfaApiMode;
|
|
15
|
+
constructor(config: SfaClientConfig);
|
|
16
|
+
private resolveBase;
|
|
17
|
+
get(collection: string, recordId: string): Promise<SfaRecord>;
|
|
18
|
+
list(collection: string, params?: SfaListParams): Promise<SfaListResponse>;
|
|
19
|
+
describe(objectKey: string): Promise<SfaDescribeResult>;
|
|
20
|
+
listObjects(): Promise<SfaObjectCatalog>;
|
|
21
|
+
listFieldTypes(): Promise<SfaFieldTypeCatalog>;
|
|
22
|
+
private assertDirectMode;
|
|
23
|
+
/** @description Throws if recordId is empty to prevent malformed API requests. */
|
|
24
|
+
private assertRecordId;
|
|
25
|
+
/**
|
|
26
|
+
* @description Creates a new record. Optionally accepts an idempotency key
|
|
27
|
+
* to prevent duplicate creates on retries.
|
|
28
|
+
*/
|
|
29
|
+
create(collection: string, fields: Record<string, unknown>, options?: SfaCreateOptions): Promise<SfaRecord>;
|
|
30
|
+
/**
|
|
31
|
+
* @description Updates a record with optimistic locking. The version token
|
|
32
|
+
* (from a prior GET) is sent as the If-Match header; a 412 response means
|
|
33
|
+
* another write landed first.
|
|
34
|
+
*/
|
|
35
|
+
update(collection: string, recordId: string, fields: Record<string, unknown>, version: string): Promise<SfaRecord>;
|
|
36
|
+
/**
|
|
37
|
+
* @description Deletes a record with optimistic locking. Returns void on
|
|
38
|
+
* success (204). Throws SfaConflictError on 412 if the record was modified
|
|
39
|
+
* since the version was obtained.
|
|
40
|
+
*/
|
|
41
|
+
delete(collection: string, recordId: string, version: string): Promise<void>;
|
|
42
|
+
/** @description Returns the objects metadata path for the current mode. */
|
|
43
|
+
private objectsPath;
|
|
44
|
+
/** @description Returns the field-types catalog path for the current mode. */
|
|
45
|
+
private fieldTypesPath;
|
|
46
|
+
private applyAuth;
|
|
47
|
+
/**
|
|
48
|
+
* @description GET request with auth headers. Works for all read-only
|
|
49
|
+
* endpoints in both direct and proxy modes.
|
|
50
|
+
*/
|
|
51
|
+
private fetchGet;
|
|
52
|
+
/**
|
|
53
|
+
* @description POST request with auth headers and a JSON body.
|
|
54
|
+
* Body is sent directly (not wrapped in { data: ... }).
|
|
55
|
+
*/
|
|
56
|
+
private postJson;
|
|
57
|
+
/**
|
|
58
|
+
* @description Generic fetch with a configurable HTTP method and optional
|
|
59
|
+
* body / extra headers. Returns the raw Response so callers can inspect
|
|
60
|
+
* status codes, headers, or handle empty bodies (204).
|
|
61
|
+
*/
|
|
62
|
+
private fetchWithBody;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* @description Error from the SFA API with parsed Keystone error details.
|
|
66
|
+
* Keystone returns structured errors with `detail`, `code`, and `title` fields.
|
|
67
|
+
* The message prefers the human-readable `detail` over the raw status text.
|
|
68
|
+
* The raw body is truncated to limit exposure of record data via error
|
|
69
|
+
* monitoring services.
|
|
70
|
+
*/
|
|
71
|
+
export declare class SfaApiError extends Error {
|
|
72
|
+
readonly status: number;
|
|
73
|
+
readonly statusText: string;
|
|
74
|
+
readonly url: string;
|
|
75
|
+
readonly detail: string | null;
|
|
76
|
+
readonly code: string | null;
|
|
77
|
+
readonly body: string;
|
|
78
|
+
constructor(status: number, statusText: string, body: string, url: string);
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* @description Thrown on 412 Precondition Failed. The record was modified by
|
|
82
|
+
* another writer since the caller's version was obtained. The response body
|
|
83
|
+
* contains the current record so the UI can show what changed.
|
|
84
|
+
*/
|
|
85
|
+
export declare class SfaConflictError extends SfaApiError {
|
|
86
|
+
readonly currentRecord: SfaRecord | null;
|
|
87
|
+
constructor(status: number, statusText: string, body: string, url: string, currentRecord: SfaRecord | null);
|
|
88
|
+
static fromBody(status: number, statusText: string, body: string, url: string): SfaConflictError;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* @description Thrown on 422 with code VALIDATION_FAILED. Exposes per-field
|
|
92
|
+
* errors so the UI can highlight which fields need correction.
|
|
93
|
+
*/
|
|
94
|
+
export declare class SfaValidationError extends SfaApiError {
|
|
95
|
+
readonly fieldErrors: readonly SfaFieldError[];
|
|
96
|
+
constructor(status: number, statusText: string, body: string, url: string, fieldErrors: readonly SfaFieldError[]);
|
|
97
|
+
static fromBody(status: number, statusText: string, body: string, url: string): SfaValidationError;
|
|
98
|
+
}
|