@bsuite/page-builder 0.6.3 → 0.7.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/dist/PageGridLayout.d.ts +1 -1
- package/dist/PageGridLayout.d.ts.map +1 -1
- package/dist/PageGridLayout.js +89 -3
- package/dist/RelationshipField.d.ts +77 -0
- package/dist/RelationshipField.d.ts.map +1 -0
- package/dist/RelationshipField.js +158 -0
- package/dist/index.d.ts +4 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/relationshipCatalog.d.ts +22 -0
- package/dist/relationshipCatalog.d.ts.map +1 -0
- package/dist/relationshipCatalog.js +26 -0
- package/dist/types.d.ts +86 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
package/dist/PageGridLayout.d.ts
CHANGED
|
@@ -2,5 +2,5 @@ import React from 'react';
|
|
|
2
2
|
import 'react-grid-layout/css/styles.css';
|
|
3
3
|
import 'react-resizable/css/styles.css';
|
|
4
4
|
import type { PageGridLayoutProps } from './types.js';
|
|
5
|
-
export declare function PageGridLayout({ pageKey, defaultLayouts, defaultCols, layoutVersion, canEditPage, editorEventNames, preferenceAdapter, widgets, widgetMeta, className, isResizable, resizeHandles, addEntityWidgetEventNames, createEntityWidget, onRegisterEntityWidget, }: PageGridLayoutProps): React.JSX.Element;
|
|
5
|
+
export declare function PageGridLayout({ pageKey, defaultLayouts, defaultCols, layoutVersion, canEditPage, editorEventNames, preferenceAdapter, widgets, widgetMeta, className, isResizable, resizeHandles, tenantId, addEntityWidgetEventNames, createEntityWidget, onRegisterEntityWidget, relationshipCatalog, addRelationshipWidgetEventNames, createRelationshipWidget, onRegisterRelationshipWidget, onRelationshipWidgetRejected, }: PageGridLayoutProps): React.JSX.Element;
|
|
6
6
|
//# sourceMappingURL=PageGridLayout.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"PageGridLayout.d.ts","sourceRoot":"","sources":["../src/PageGridLayout.tsx"],"names":[],"mappings":"AACA,OAAO,KAA6E,MAAM,OAAO,CAAC;AAGlG,OAAO,kCAAkC,CAAC;AAC1C,OAAO,gCAAgC,CAAC;
|
|
1
|
+
{"version":3,"file":"PageGridLayout.d.ts","sourceRoot":"","sources":["../src/PageGridLayout.tsx"],"names":[],"mappings":"AACA,OAAO,KAA6E,MAAM,OAAO,CAAC;AAGlG,OAAO,kCAAkC,CAAC;AAC1C,OAAO,gCAAgC,CAAC;AAMxC,OAAO,KAAK,EAAe,mBAAmB,EAA4B,MAAM,YAAY,CAAC;AAmR7F,wBAAgB,cAAc,CAAC,EAC7B,OAAO,EACP,cAAc,EACd,WAAW,EACX,aAAa,EACb,WAAW,EACX,gBAAgB,EAChB,iBAAiB,EACjB,OAAO,EACP,UAAU,EACV,SAAS,EAMT,WAAkB,EAClB,aAAsC,EACtC,QAAQ,EACR,yBAAiE,EACjE,kBAAkB,EAClB,sBAAsB,EACtB,mBAAmB,EACnB,+BAA6E,EAC7E,wBAAwB,EACxB,4BAA4B,EAC5B,4BAA4B,GAC7B,EAAE,mBAAmB,qBAowBrB"}
|
package/dist/PageGridLayout.js
CHANGED
|
@@ -7,6 +7,7 @@ import 'react-grid-layout/css/styles.css';
|
|
|
7
7
|
import 'react-resizable/css/styles.css';
|
|
8
8
|
import { computeAutoHeightRows } from './autoHeight.js';
|
|
9
9
|
import { defaultPreferenceAdapter } from './preferences.js';
|
|
10
|
+
import { isRelationshipWritable } from './relationshipCatalog.js';
|
|
10
11
|
import { usePageGridLayout } from './usePageGridLayout.js';
|
|
11
12
|
import { cn } from './utils.js';
|
|
12
13
|
/**
|
|
@@ -48,6 +49,15 @@ const DEFAULT_CARD_CHROME_PX = 2;
|
|
|
48
49
|
const DEFAULT_RESIZE_BOUNDS = { minW: 2, minH: 1 };
|
|
49
50
|
const DEFAULT_RESIZE_HANDLES = ['se'];
|
|
50
51
|
const DEFAULT_ADD_ENTITY_WIDGET_EVENT_NAMES = ['bsu-add-entity-widget', 'crm7-add-entity-widget'];
|
|
52
|
+
/**
|
|
53
|
+
* Second widget kind (W4-1) — a relationship field, distinct from the
|
|
54
|
+
* entity-list events above. Named analogously so a consumer wiring both
|
|
55
|
+
* kinds recognises the pattern immediately.
|
|
56
|
+
*/
|
|
57
|
+
const DEFAULT_ADD_RELATIONSHIP_WIDGET_EVENT_NAMES = [
|
|
58
|
+
'bsu-add-relationship-widget',
|
|
59
|
+
'crm7-add-relationship-widget',
|
|
60
|
+
];
|
|
51
61
|
/**
|
|
52
62
|
* Custom resize handle for react-grid-layout v2.
|
|
53
63
|
*
|
|
@@ -153,7 +163,7 @@ export function PageGridLayout({ pageKey, defaultLayouts, defaultCols, layoutVer
|
|
|
153
163
|
// adapters then defaulted it back to true. With v2's handle gap fixed,
|
|
154
164
|
// the safe default is true so any consumer who forgets to set it still
|
|
155
165
|
// gets a usable canvas. Opt out per-page with isResizable={false}.
|
|
156
|
-
isResizable = true, resizeHandles = DEFAULT_RESIZE_HANDLES, addEntityWidgetEventNames = DEFAULT_ADD_ENTITY_WIDGET_EVENT_NAMES, createEntityWidget, onRegisterEntityWidget, }) {
|
|
166
|
+
isResizable = true, resizeHandles = DEFAULT_RESIZE_HANDLES, tenantId, addEntityWidgetEventNames = DEFAULT_ADD_ENTITY_WIDGET_EVENT_NAMES, createEntityWidget, onRegisterEntityWidget, relationshipCatalog, addRelationshipWidgetEventNames = DEFAULT_ADD_RELATIONSHIP_WIDGET_EVENT_NAMES, createRelationshipWidget, onRegisterRelationshipWidget, onRelationshipWidgetRejected, }) {
|
|
157
167
|
const { currentLayouts, layoutCols, isEditing, setIsEditing, activeCols, activeCompactor, onLayoutChange, handleColumnChange, handleCompact, handleReset, addWidget, moveWidget, setWidgetLocked, applyAutoHeightRows, autoHeightRows, resetConfirmOpen, setResetConfirmOpen, containerRef, containerWidth, } = usePageGridLayout({
|
|
158
168
|
pageKey,
|
|
159
169
|
defaultLayouts,
|
|
@@ -220,6 +230,7 @@ isResizable = true, resizeHandles = DEFAULT_RESIZE_HANDLES, addEntityWidgetEvent
|
|
|
220
230
|
minSize(DEFAULT_RESIZE_BOUNDS.minW, DEFAULT_RESIZE_BOUNDS.minH),
|
|
221
231
|
], []);
|
|
222
232
|
const [extraWidgetConfigs, setExtraWidgetConfigs] = useState({});
|
|
233
|
+
const [extraRelationshipWidgetConfigs, setExtraRelationshipWidgetConfigs] = useState({});
|
|
223
234
|
const { value: layerNames, setValue: setLayerNames } = (preferenceAdapter ?? defaultPreferenceAdapter)(`page:${pageKey}_grid_layer_names`, {});
|
|
224
235
|
const { value: hiddenLayerIds, setValue: setHiddenLayerIds } = (preferenceAdapter ?? defaultPreferenceAdapter)(`page:${pageKey}_grid_hidden_layers`, {});
|
|
225
236
|
const extraWidgets = useMemo(() => {
|
|
@@ -232,11 +243,35 @@ isResizable = true, resizeHandles = DEFAULT_RESIZE_HANDLES, addEntityWidgetEvent
|
|
|
232
243
|
entityType: config.entityType,
|
|
233
244
|
label: config.label,
|
|
234
245
|
isEditing,
|
|
246
|
+
tenantId,
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
return rendered;
|
|
250
|
+
}, [createEntityWidget, extraWidgetConfigs, isEditing, tenantId]);
|
|
251
|
+
// Relationship-field widgets (W4-1) — same shape as the entity-list path
|
|
252
|
+
// above (config registry -> render via a consumer-supplied factory), kept
|
|
253
|
+
// as a parallel structure rather than folded in because the two widget
|
|
254
|
+
// kinds are gated differently: an entity-list widget can always be added,
|
|
255
|
+
// a relationship widget only when `relationshipCatalog` proves the FK is
|
|
256
|
+
// real (see the add-relationship-widget listener below).
|
|
257
|
+
const extraRelationshipWidgets = useMemo(() => {
|
|
258
|
+
const rendered = {};
|
|
259
|
+
if (!createRelationshipWidget)
|
|
260
|
+
return rendered;
|
|
261
|
+
for (const [widgetId, detail] of Object.entries(extraRelationshipWidgetConfigs)) {
|
|
262
|
+
rendered[widgetId] = createRelationshipWidget({
|
|
263
|
+
widgetId,
|
|
264
|
+
hostEntityType: detail.hostEntityType,
|
|
265
|
+
fkColumn: detail.fkColumn,
|
|
266
|
+
targetEntityType: detail.targetEntityType,
|
|
267
|
+
label: detail.label,
|
|
268
|
+
isEditing,
|
|
269
|
+
tenantId,
|
|
235
270
|
});
|
|
236
271
|
}
|
|
237
272
|
return rendered;
|
|
238
|
-
}, [
|
|
239
|
-
const allWidgets = useMemo(() => ({ ...widgets, ...extraWidgets }), [widgets, extraWidgets]);
|
|
273
|
+
}, [createRelationshipWidget, extraRelationshipWidgetConfigs, isEditing, tenantId]);
|
|
274
|
+
const allWidgets = useMemo(() => ({ ...widgets, ...extraWidgets, ...extraRelationshipWidgets }), [widgets, extraWidgets, extraRelationshipWidgets]);
|
|
240
275
|
const renderableWidgetKeys = useMemo(() => new Set(Object.keys(allWidgets).filter((key) => allWidgets[key] !== null && allWidgets[key] !== undefined)), [allWidgets]);
|
|
241
276
|
const activeLayouts = useMemo(() => {
|
|
242
277
|
const filtered = { lg: [] };
|
|
@@ -363,6 +398,57 @@ isResizable = true, resizeHandles = DEFAULT_RESIZE_HANDLES, addEntityWidgetEvent
|
|
|
363
398
|
onRegisterEntityWidget,
|
|
364
399
|
setIsEditing,
|
|
365
400
|
]);
|
|
401
|
+
// Relationship-field widget listener (W4-1/W4-2). Mirrors the
|
|
402
|
+
// add-entity-widget listener above, with one structural difference: every
|
|
403
|
+
// request is checked against `relationshipCatalog` via
|
|
404
|
+
// `isRelationshipWritable` BEFORE a widget is ever created. No catalogue
|
|
405
|
+
// match means the FK is not proven to exist, so the request is refused —
|
|
406
|
+
// `onRelationshipWidgetRejected` fires (for the consumer to surface why)
|
|
407
|
+
// and nothing is added to the layout. This is the only place that gate is
|
|
408
|
+
// enforced; there is no other path that creates a relationship widget.
|
|
409
|
+
useEffect(() => {
|
|
410
|
+
if (!createRelationshipWidget || typeof window === 'undefined')
|
|
411
|
+
return;
|
|
412
|
+
const handleAddRelationshipWidget = (event) => {
|
|
413
|
+
const detail = event.detail;
|
|
414
|
+
if (!detail?.hostEntityType || !detail.fkColumn || !detail.targetEntityType)
|
|
415
|
+
return;
|
|
416
|
+
if (!isRelationshipWritable(relationshipCatalog, detail)) {
|
|
417
|
+
onRelationshipWidgetRejected?.(detail);
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
const widgetId = `relationship:${detail.hostEntityType}:${detail.fkColumn}`;
|
|
421
|
+
const alreadyInLayout = activeLayouts.lg?.some((item) => item.i === widgetId);
|
|
422
|
+
if (alreadyInLayout) {
|
|
423
|
+
startTransition(() => setIsEditing(true));
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
onRegisterRelationshipWidget?.({ widgetId, ...detail });
|
|
427
|
+
setExtraRelationshipWidgetConfigs((previous) => ({
|
|
428
|
+
...previous,
|
|
429
|
+
[widgetId]: detail,
|
|
430
|
+
}));
|
|
431
|
+
addWidget(widgetId, { w: 4, h: 3, minW: 3, minH: 2 });
|
|
432
|
+
startTransition(() => setIsEditing(true));
|
|
433
|
+
};
|
|
434
|
+
for (const eventName of addRelationshipWidgetEventNames) {
|
|
435
|
+
window.addEventListener(eventName, handleAddRelationshipWidget);
|
|
436
|
+
}
|
|
437
|
+
return () => {
|
|
438
|
+
for (const eventName of addRelationshipWidgetEventNames) {
|
|
439
|
+
window.removeEventListener(eventName, handleAddRelationshipWidget);
|
|
440
|
+
}
|
|
441
|
+
};
|
|
442
|
+
}, [
|
|
443
|
+
activeLayouts.lg,
|
|
444
|
+
addRelationshipWidgetEventNames,
|
|
445
|
+
addWidget,
|
|
446
|
+
createRelationshipWidget,
|
|
447
|
+
onRegisterRelationshipWidget,
|
|
448
|
+
onRelationshipWidgetRejected,
|
|
449
|
+
relationshipCatalog,
|
|
450
|
+
setIsEditing,
|
|
451
|
+
]);
|
|
366
452
|
const visibleKeys = useMemo(() => new Set((activeLayouts.lg ?? []).map((item) => item.i)), [activeLayouts.lg]);
|
|
367
453
|
const hiddenWidgetKeys = useMemo(() => [...renderableWidgetKeys].filter((key) => !visibleKeys.has(key)), [renderableWidgetKeys, visibleKeys]);
|
|
368
454
|
const layerItems = useMemo(() => (activeLayouts.lg ?? []).map((item, index, layers) => {
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import type { RelationshipFieldOption } from './types.js';
|
|
3
|
+
/**
|
|
4
|
+
* RelationshipField — the app-agnostic relationship-field widget (W4-1).
|
|
5
|
+
*
|
|
6
|
+
* A typeahead over a single entity that writes ONE foreign key on the
|
|
7
|
+
* record being edited, distinct from the entity-list widget (a table of
|
|
8
|
+
* every row, writing nothing). `@bsuite/page-builder` cannot import from
|
|
9
|
+
* crm7 or talk to Supabase, so the data source is entirely caller-supplied
|
|
10
|
+
* — `search`/`resolveOption` are the ClientSelector-equivalent behaviour
|
|
11
|
+
* crm7 (or any consumer) wires in; this component owns only the picker
|
|
12
|
+
* interaction and its validation contract.
|
|
13
|
+
*
|
|
14
|
+
* The validation contract is deliberately the same shape as crm7's
|
|
15
|
+
* `EntitySelector`/`ClientSelector` (`crm7/src/components/entity/
|
|
16
|
+
* EntitySelector.tsx`, `.../selectors/ClientSelector.tsx`): the ONLY way to
|
|
17
|
+
* commit a value is to pick one of the options `search` returned — there is
|
|
18
|
+
* no code path that turns raw typed text into a committed value. Typed text
|
|
19
|
+
* that does not match the currently-committed option's label is refused on
|
|
20
|
+
* blur/Escape: the field reverts to the last committed value and
|
|
21
|
+
* `onInvalidEntry` fires instead of `onChange`. That refusal is exactly
|
|
22
|
+
* what stopped 7 contacts becoming free-text orphans in crm7 — a
|
|
23
|
+
* relationship field that skipped it would recreate the same problem this
|
|
24
|
+
* widget exists to fix.
|
|
25
|
+
*
|
|
26
|
+
* Controlled component: `value` is the FK currently persisted on the host
|
|
27
|
+
* record. `onChange` REQUESTS a write; the caller updates its own backing
|
|
28
|
+
* store and re-passes `value` once the write lands (optimistically or
|
|
29
|
+
* not) — this component never assumes its own write succeeded.
|
|
30
|
+
*/
|
|
31
|
+
export interface RelationshipFieldProps {
|
|
32
|
+
/** Currently selected FK value (the id written on the host record), or
|
|
33
|
+
* null/undefined when unset. */
|
|
34
|
+
value?: string | null;
|
|
35
|
+
/** The caller's tenant — forwarded to `search`/`resolveOption` on every
|
|
36
|
+
* call so the picker's data source is scoped to it by construction,
|
|
37
|
+
* never by convention (W4-1's tenant-scoping requirement). */
|
|
38
|
+
tenantId?: string | null;
|
|
39
|
+
label?: string;
|
|
40
|
+
placeholder?: string;
|
|
41
|
+
disabled?: boolean;
|
|
42
|
+
/** Debounce, in ms, between the last keystroke and firing `search`. */
|
|
43
|
+
debounceMs?: number;
|
|
44
|
+
/**
|
|
45
|
+
* Search the target entity, scoped to `tenantId`. Consumers supply their
|
|
46
|
+
* own data source here (crm7's ClientSelector-equivalent query) — this
|
|
47
|
+
* package never talks to Supabase directly.
|
|
48
|
+
*/
|
|
49
|
+
search: (query: string, tenantId: string | null | undefined) => Promise<RelationshipFieldOption[]>;
|
|
50
|
+
/**
|
|
51
|
+
* Resolve the option behind an already-set `value` (e.g. re-fetch by id)
|
|
52
|
+
* so the field can show its label without the caller pre-resolving it.
|
|
53
|
+
* Optional — omit if `initialOption` is supplied instead.
|
|
54
|
+
*/
|
|
55
|
+
resolveOption?: (id: string, tenantId: string | null | undefined) => Promise<RelationshipFieldOption | null>;
|
|
56
|
+
/** Pre-resolved option for `value`, when the caller already has it. */
|
|
57
|
+
initialOption?: RelationshipFieldOption | null;
|
|
58
|
+
/**
|
|
59
|
+
* Called with the picked option — this is what writes the foreign key.
|
|
60
|
+
* Called with `null` only from the explicit clear action, never from an
|
|
61
|
+
* unresolved typed value.
|
|
62
|
+
*/
|
|
63
|
+
onChange: (option: RelationshipFieldOption | null) => void | Promise<void>;
|
|
64
|
+
/**
|
|
65
|
+
* Called when the field is blurred/committed with typed text that does
|
|
66
|
+
* not match any resolved/committed option — the value is refused
|
|
67
|
+
* (reverted) and `onChange` is NOT called.
|
|
68
|
+
*/
|
|
69
|
+
onInvalidEntry?: (query: string) => void;
|
|
70
|
+
emptyLabel?: string;
|
|
71
|
+
className?: string;
|
|
72
|
+
id?: string;
|
|
73
|
+
'aria-label'?: string;
|
|
74
|
+
}
|
|
75
|
+
export declare function RelationshipField({ value, tenantId, label, placeholder, disabled, debounceMs, search, resolveOption, initialOption, onChange, onInvalidEntry, emptyLabel, className, id, 'aria-label': ariaLabel, }: RelationshipFieldProps): React.JSX.Element;
|
|
76
|
+
export default RelationshipField;
|
|
77
|
+
//# sourceMappingURL=RelationshipField.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"RelationshipField.d.ts","sourceRoot":"","sources":["../src/RelationshipField.tsx"],"names":[],"mappings":"AACA,OAAO,KAA0D,MAAM,OAAO,CAAC;AAE/E,OAAO,KAAK,EAAE,uBAAuB,EAAE,MAAM,YAAY,CAAC;AAE1D;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AAEH,MAAM,WAAW,sBAAsB;IACrC;oCACgC;IAChC,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACtB;;kEAE8D;IAC9D,QAAQ,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,uEAAuE;IACvE,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB;;;;OAIG;IACH,MAAM,EAAE,CAAC,KAAK,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,KAAK,OAAO,CAAC,uBAAuB,EAAE,CAAC,CAAC;IACnG;;;;OAIG;IACH,aAAa,CAAC,EAAE,CAAC,EAAE,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,KAAK,OAAO,CAAC,uBAAuB,GAAG,IAAI,CAAC,CAAC;IAC7G,uEAAuE;IACvE,aAAa,CAAC,EAAE,uBAAuB,GAAG,IAAI,CAAC;IAC/C;;;;OAIG;IACH,QAAQ,EAAE,CAAC,MAAM,EAAE,uBAAuB,GAAG,IAAI,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC3E;;;;OAIG;IACH,cAAc,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IACzC,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB;AAED,wBAAgB,iBAAiB,CAAC,EAChC,KAAK,EACL,QAAQ,EACR,KAAK,EACL,WAAuB,EACvB,QAAgB,EAChB,UAAgB,EAChB,MAAM,EACN,aAAa,EACb,aAAoB,EACpB,QAAQ,EACR,cAAc,EACd,UAAyB,EACzB,SAAS,EACT,EAAE,EACF,YAAY,EAAE,SAAS,GACxB,EAAE,sBAAsB,qBAwOxB;AAED,eAAe,iBAAiB,CAAC"}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { X } from 'lucide-react';
|
|
3
|
+
import { useCallback, useEffect, useId, useRef, useState } from 'react';
|
|
4
|
+
import { cn } from './utils.js';
|
|
5
|
+
export function RelationshipField({ value, tenantId, label, placeholder = 'Search…', disabled = false, debounceMs = 250, search, resolveOption, initialOption = null, onChange, onInvalidEntry, emptyLabel = 'No matches', className, id, 'aria-label': ariaLabel, }) {
|
|
6
|
+
const generatedId = useId();
|
|
7
|
+
const inputId = id ?? `relationship-field-${generatedId}`;
|
|
8
|
+
const listboxId = `${inputId}-listbox`;
|
|
9
|
+
const [selected, setSelected] = useState(initialOption);
|
|
10
|
+
const [query, setQuery] = useState(initialOption?.label ?? '');
|
|
11
|
+
const [options, setOptions] = useState([]);
|
|
12
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
13
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
14
|
+
const [highlightedIndex, setHighlightedIndex] = useState(-1);
|
|
15
|
+
const debounceRef = useRef(null);
|
|
16
|
+
const requestIdRef = useRef(0);
|
|
17
|
+
// Hydrate the label for an externally-set/changed `value` this instance
|
|
18
|
+
// has not resolved yet. Deliberately keyed on `value` alone — `selected`
|
|
19
|
+
// is set BY this same effect (and by `commitOption`/`clearSelection`), so
|
|
20
|
+
// including it would either no-op (already in sync) or fight the user's
|
|
21
|
+
// own selection; `resolveOption`/`tenantId` changing mid-session should
|
|
22
|
+
// not retrigger a resolve for a value that hasn't changed.
|
|
23
|
+
useEffect(() => {
|
|
24
|
+
if ((value ?? null) === (selected?.id ?? null))
|
|
25
|
+
return;
|
|
26
|
+
if (!value) {
|
|
27
|
+
setSelected(null);
|
|
28
|
+
setQuery('');
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
if (!resolveOption)
|
|
32
|
+
return;
|
|
33
|
+
let cancelled = false;
|
|
34
|
+
resolveOption(value, tenantId)
|
|
35
|
+
.then((option) => {
|
|
36
|
+
if (cancelled || !option)
|
|
37
|
+
return;
|
|
38
|
+
setSelected(option);
|
|
39
|
+
setQuery(option.label);
|
|
40
|
+
})
|
|
41
|
+
.catch(() => {
|
|
42
|
+
/* leave the field as-is; caller's resolveOption owns error reporting */
|
|
43
|
+
});
|
|
44
|
+
return () => {
|
|
45
|
+
cancelled = true;
|
|
46
|
+
};
|
|
47
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
48
|
+
}, [value]);
|
|
49
|
+
useEffect(() => () => {
|
|
50
|
+
if (debounceRef.current)
|
|
51
|
+
clearTimeout(debounceRef.current);
|
|
52
|
+
}, []);
|
|
53
|
+
const runSearch = useCallback((text) => {
|
|
54
|
+
const requestId = ++requestIdRef.current;
|
|
55
|
+
setIsLoading(true);
|
|
56
|
+
search(text, tenantId)
|
|
57
|
+
.then((results) => {
|
|
58
|
+
if (requestIdRef.current !== requestId)
|
|
59
|
+
return;
|
|
60
|
+
setOptions(results);
|
|
61
|
+
})
|
|
62
|
+
.catch(() => {
|
|
63
|
+
if (requestIdRef.current !== requestId)
|
|
64
|
+
return;
|
|
65
|
+
setOptions([]);
|
|
66
|
+
})
|
|
67
|
+
.finally(() => {
|
|
68
|
+
if (requestIdRef.current !== requestId)
|
|
69
|
+
return;
|
|
70
|
+
setIsLoading(false);
|
|
71
|
+
});
|
|
72
|
+
}, [search, tenantId]);
|
|
73
|
+
const handleInputChange = (event) => {
|
|
74
|
+
const text = event.target.value;
|
|
75
|
+
setQuery(text);
|
|
76
|
+
setIsOpen(true);
|
|
77
|
+
setHighlightedIndex(-1);
|
|
78
|
+
if (debounceRef.current)
|
|
79
|
+
clearTimeout(debounceRef.current);
|
|
80
|
+
debounceRef.current = setTimeout(() => runSearch(text), debounceMs);
|
|
81
|
+
};
|
|
82
|
+
const commitOption = (option) => {
|
|
83
|
+
setSelected(option);
|
|
84
|
+
setQuery(option.label);
|
|
85
|
+
setIsOpen(false);
|
|
86
|
+
setOptions([]);
|
|
87
|
+
setHighlightedIndex(-1);
|
|
88
|
+
void onChange(option);
|
|
89
|
+
};
|
|
90
|
+
const clearSelection = () => {
|
|
91
|
+
setSelected(null);
|
|
92
|
+
setQuery('');
|
|
93
|
+
setOptions([]);
|
|
94
|
+
setIsOpen(false);
|
|
95
|
+
void onChange(null);
|
|
96
|
+
};
|
|
97
|
+
// The refusal behaviour this component exists to provide (see the module
|
|
98
|
+
// doc): committing is ONLY ever done via `commitOption`, called from a
|
|
99
|
+
// listbox option pick. Reaching blur/Escape with typed text that does not
|
|
100
|
+
// exactly match the currently-committed option's label means the user
|
|
101
|
+
// typed something that never resolved to a real row — revert, and tell
|
|
102
|
+
// the caller via `onInvalidEntry` instead of writing anything.
|
|
103
|
+
const refuseUnresolvedTypedText = () => {
|
|
104
|
+
const trimmed = query.trim();
|
|
105
|
+
const matchesCommitted = selected ? trimmed === selected.label : trimmed.length === 0;
|
|
106
|
+
if (matchesCommitted)
|
|
107
|
+
return;
|
|
108
|
+
onInvalidEntry?.(trimmed);
|
|
109
|
+
setQuery(selected?.label ?? '');
|
|
110
|
+
setOptions([]);
|
|
111
|
+
};
|
|
112
|
+
const handleBlur = () => {
|
|
113
|
+
// A mousedown on a listbox option (see onMouseDown below) prevents the
|
|
114
|
+
// input from ever losing focus for that interaction, so `handleBlur`
|
|
115
|
+
// only ever runs for a genuine focus-away — commitOption has already
|
|
116
|
+
// run synchronously before this fires when the user picks an option.
|
|
117
|
+
setIsOpen(false);
|
|
118
|
+
refuseUnresolvedTypedText();
|
|
119
|
+
};
|
|
120
|
+
const handleKeyDown = (event) => {
|
|
121
|
+
if (event.key === 'ArrowDown') {
|
|
122
|
+
event.preventDefault();
|
|
123
|
+
if (!isOpen) {
|
|
124
|
+
setIsOpen(true);
|
|
125
|
+
if (options.length === 0)
|
|
126
|
+
runSearch(query);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
setHighlightedIndex((index) => Math.min(index + 1, options.length - 1));
|
|
130
|
+
}
|
|
131
|
+
else if (event.key === 'ArrowUp') {
|
|
132
|
+
event.preventDefault();
|
|
133
|
+
setHighlightedIndex((index) => Math.max(index - 1, 0));
|
|
134
|
+
}
|
|
135
|
+
else if (event.key === 'Enter') {
|
|
136
|
+
if (isOpen && highlightedIndex >= 0 && options[highlightedIndex]) {
|
|
137
|
+
event.preventDefault();
|
|
138
|
+
commitOption(options[highlightedIndex]);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
else if (event.key === 'Escape') {
|
|
142
|
+
if (isOpen) {
|
|
143
|
+
event.preventDefault();
|
|
144
|
+
setIsOpen(false);
|
|
145
|
+
setQuery(selected?.label ?? '');
|
|
146
|
+
setOptions([]);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
return (_jsxs("div", { className: cn('relative w-full', className), children: [label && (_jsx("label", { htmlFor: inputId, className: "mb-1 block text-xs font-medium text-muted-foreground", children: label })), _jsxs("div", { className: "relative flex items-center gap-1", children: [_jsx("input", { id: inputId, role: "combobox", type: "text", "aria-expanded": isOpen, "aria-controls": listboxId, "aria-autocomplete": "list", "aria-activedescendant": isOpen && highlightedIndex >= 0 && options[highlightedIndex]
|
|
151
|
+
? `${listboxId}-option-${highlightedIndex}`
|
|
152
|
+
: undefined, "aria-label": ariaLabel ?? label, className: "w-full rounded-md border border-border bg-background px-2 py-1.5 text-sm text-foreground disabled:opacity-60", value: query, placeholder: placeholder, disabled: disabled, autoComplete: "off", onChange: handleInputChange, onFocus: () => {
|
|
153
|
+
setIsOpen(true);
|
|
154
|
+
if (options.length === 0)
|
|
155
|
+
runSearch(query);
|
|
156
|
+
}, onBlur: handleBlur, onKeyDown: handleKeyDown }), selected && !disabled && (_jsx("button", { type: "button", "aria-label": "Clear selection", className: "shrink-0 rounded p-1 text-muted-foreground hover:bg-muted hover:text-foreground", onMouseDown: (event) => event.preventDefault(), onClick: clearSelection, children: _jsx(X, { className: "h-3.5 w-3.5", "aria-hidden": "true" }) }))] }), isOpen && (_jsx("ul", { id: listboxId, role: "listbox", className: "absolute z-50 mt-1 max-h-60 w-full overflow-auto rounded-md border border-border bg-popover shadow-md", children: isLoading ? (_jsx("li", { className: "px-3 py-2 text-sm text-muted-foreground", children: "Searching\u2026" })) : options.length === 0 ? (_jsx("li", { className: "px-3 py-2 text-sm text-muted-foreground", children: emptyLabel })) : (options.map((option, index) => (_jsxs("li", { id: `${listboxId}-option-${index}`, role: "option", "aria-selected": selected?.id === option.id, className: cn('cursor-pointer px-3 py-2 text-sm', index === highlightedIndex ? 'bg-accent text-accent-foreground' : 'text-foreground'), onMouseDown: (event) => event.preventDefault(), onClick: () => commitOption(option), onMouseEnter: () => setHighlightedIndex(index), children: [_jsx("div", { children: option.label }), option.secondaryLabel && (_jsx("div", { className: "text-xs text-muted-foreground", children: option.secondaryLabel }))] }, option.id)))) }))] }));
|
|
157
|
+
}
|
|
158
|
+
export default RelationshipField;
|
package/dist/index.d.ts
CHANGED
|
@@ -11,5 +11,8 @@ export type { WysiwygPrimitive, WysiwygSurfaceContract, WysiwygSurfaceId, } from
|
|
|
11
11
|
export { buildResponsiveLayouts, RESPONSIVE_BREAKPOINTS, } from './buildResponsiveLayouts.js';
|
|
12
12
|
export type { BuildResponsiveLayoutsOptions, ResponsiveBreakpoint, } from './buildResponsiveLayouts.js';
|
|
13
13
|
export { useLocalPreference, defaultPreferenceAdapter } from './preferences.js';
|
|
14
|
-
export
|
|
14
|
+
export { RelationshipField } from './RelationshipField.js';
|
|
15
|
+
export type { RelationshipFieldProps } from './RelationshipField.js';
|
|
16
|
+
export { isRelationshipWritable } from './relationshipCatalog.js';
|
|
17
|
+
export type { EntityWidgetDetail, EntityWidgetFactoryOptions, GridLayoutItem, GridLayouts, PageGridLayoutProps, PageGridPreferenceAdapter, PageGridPreferenceFactory, RelationshipCatalog, RelationshipCatalogEntry, RelationshipFieldOption, RelationshipWidgetDetail, RelationshipWidgetFactoryOptions, UsePageGridLayoutOptions, UsePageGridLayoutResult, WidgetMeta, } from './types.js';
|
|
15
18
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,cAAc,EAAE,MAAM,qBAAqB,CAAC;AACrD,OAAO,EAAE,kBAAkB,EAAE,MAAM,yBAAyB,CAAC;AAC7D,YAAY,EAAE,YAAY,EAAE,uBAAuB,EAAE,MAAM,yBAAyB,CAAC;AACrF,OAAO,EACL,iBAAiB,EACjB,0BAA0B,EAC1B,uBAAuB,GACxB,MAAM,wBAAwB,CAAC;AAChC,YAAY,EAAE,0BAA0B,EAAE,MAAM,wBAAwB,CAAC;AACzE,OAAO,EAAE,aAAa,EAAE,MAAM,oBAAoB,CAAC;AACnD,OAAO,EAAE,qBAAqB,EAAE,MAAM,iBAAiB,CAAC;AACxD,YAAY,EAAE,4BAA4B,EAAE,MAAM,iBAAiB,CAAC;AACpE,OAAO,EACL,2BAA2B,EAC3B,yBAAyB,EACzB,8BAA8B,EAC9B,yBAAyB,GAC1B,MAAM,sBAAsB,CAAC;AAC9B,YAAY,EACV,gBAAgB,EAChB,sBAAsB,EACtB,gBAAgB,GACjB,MAAM,sBAAsB,CAAC;AAC9B,OAAO,EACL,sBAAsB,EACtB,sBAAsB,GACvB,MAAM,6BAA6B,CAAC;AACrC,YAAY,EACV,6BAA6B,EAC7B,oBAAoB,GACrB,MAAM,6BAA6B,CAAC;AACrC,OAAO,EAAE,kBAAkB,EAAE,wBAAwB,EAAE,MAAM,kBAAkB,CAAC;AAChF,YAAY,EACV,kBAAkB,EAClB,0BAA0B,EAC1B,cAAc,EACd,WAAW,EACX,mBAAmB,EACnB,yBAAyB,EACzB,yBAAyB,EACzB,wBAAwB,EACxB,uBAAuB,EACvB,UAAU,GACX,MAAM,YAAY,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,cAAc,EAAE,MAAM,qBAAqB,CAAC;AACrD,OAAO,EAAE,kBAAkB,EAAE,MAAM,yBAAyB,CAAC;AAC7D,YAAY,EAAE,YAAY,EAAE,uBAAuB,EAAE,MAAM,yBAAyB,CAAC;AACrF,OAAO,EACL,iBAAiB,EACjB,0BAA0B,EAC1B,uBAAuB,GACxB,MAAM,wBAAwB,CAAC;AAChC,YAAY,EAAE,0BAA0B,EAAE,MAAM,wBAAwB,CAAC;AACzE,OAAO,EAAE,aAAa,EAAE,MAAM,oBAAoB,CAAC;AACnD,OAAO,EAAE,qBAAqB,EAAE,MAAM,iBAAiB,CAAC;AACxD,YAAY,EAAE,4BAA4B,EAAE,MAAM,iBAAiB,CAAC;AACpE,OAAO,EACL,2BAA2B,EAC3B,yBAAyB,EACzB,8BAA8B,EAC9B,yBAAyB,GAC1B,MAAM,sBAAsB,CAAC;AAC9B,YAAY,EACV,gBAAgB,EAChB,sBAAsB,EACtB,gBAAgB,GACjB,MAAM,sBAAsB,CAAC;AAC9B,OAAO,EACL,sBAAsB,EACtB,sBAAsB,GACvB,MAAM,6BAA6B,CAAC;AACrC,YAAY,EACV,6BAA6B,EAC7B,oBAAoB,GACrB,MAAM,6BAA6B,CAAC;AACrC,OAAO,EAAE,kBAAkB,EAAE,wBAAwB,EAAE,MAAM,kBAAkB,CAAC;AAChF,OAAO,EAAE,iBAAiB,EAAE,MAAM,wBAAwB,CAAC;AAC3D,YAAY,EAAE,sBAAsB,EAAE,MAAM,wBAAwB,CAAC;AACrE,OAAO,EAAE,sBAAsB,EAAE,MAAM,0BAA0B,CAAC;AAClE,YAAY,EACV,kBAAkB,EAClB,0BAA0B,EAC1B,cAAc,EACd,WAAW,EACX,mBAAmB,EACnB,yBAAyB,EACzB,yBAAyB,EACzB,mBAAmB,EACnB,wBAAwB,EACxB,uBAAuB,EACvB,wBAAwB,EACxB,gCAAgC,EAChC,wBAAwB,EACxB,uBAAuB,EACvB,UAAU,GACX,MAAM,YAAY,CAAC"}
|
package/dist/index.js
CHANGED
|
@@ -6,3 +6,5 @@ export { computeAutoHeightRows } from './autoHeight.js';
|
|
|
6
6
|
export { WYSIWYG_REQUIRED_PRIMITIVES, WYSIWYG_SURFACE_CONTRACTS, assertWysiwygPrimitiveCoverage, getWysiwygSurfaceContract, } from './wysiwygContract.js';
|
|
7
7
|
export { buildResponsiveLayouts, RESPONSIVE_BREAKPOINTS, } from './buildResponsiveLayouts.js';
|
|
8
8
|
export { useLocalPreference, defaultPreferenceAdapter } from './preferences.js';
|
|
9
|
+
export { RelationshipField } from './RelationshipField.js';
|
|
10
|
+
export { isRelationshipWritable } from './relationshipCatalog.js';
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { RelationshipCatalog, RelationshipWidgetDetail } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* True only when `detail` names a relationship literally present in
|
|
4
|
+
* `catalog` — the W4-2 gate ("a relationship field may only be offered
|
|
5
|
+
* where the host record genuinely has a foreign key to write").
|
|
6
|
+
*
|
|
7
|
+
* `catalog` is undefined/empty-safe by design: no catalogue means nothing
|
|
8
|
+
* has been proven writable, so every relationship is refused (fail closed)
|
|
9
|
+
* rather than silently offering a picker that cannot save. This is the only
|
|
10
|
+
* place that decision is made — `PageGridLayout`'s add-relationship-widget
|
|
11
|
+
* handler calls this before ever creating a widget, and it is exported so
|
|
12
|
+
* consumers (e.g. a canvas "add element" palette deciding what to list) can
|
|
13
|
+
* apply the identical rule ahead of dispatching the event.
|
|
14
|
+
*
|
|
15
|
+
* `@bsuite/page-builder` never infers writability itself — it has no notion
|
|
16
|
+
* of a database. Consumers build `catalog` from a live schema source (in
|
|
17
|
+
* crm7, `report_catalog_joins` filtered to `cardinality = 'many_to_one'`
|
|
18
|
+
* and `is_active = true`, cross-checked against `pg_constraint` — see the
|
|
19
|
+
* Wave 4 implementation PR for the verification query and evidence).
|
|
20
|
+
*/
|
|
21
|
+
export declare function isRelationshipWritable(catalog: RelationshipCatalog | undefined, detail: Pick<RelationshipWidgetDetail, 'hostEntityType' | 'fkColumn' | 'targetEntityType'>): boolean;
|
|
22
|
+
//# sourceMappingURL=relationshipCatalog.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"relationshipCatalog.d.ts","sourceRoot":"","sources":["../src/relationshipCatalog.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,mBAAmB,EAAE,wBAAwB,EAAE,MAAM,YAAY,CAAC;AAEhF;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,sBAAsB,CACpC,OAAO,EAAE,mBAAmB,GAAG,SAAS,EACxC,MAAM,EAAE,IAAI,CAAC,wBAAwB,EAAE,gBAAgB,GAAG,UAAU,GAAG,kBAAkB,CAAC,GACzF,OAAO,CAQT"}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* True only when `detail` names a relationship literally present in
|
|
3
|
+
* `catalog` — the W4-2 gate ("a relationship field may only be offered
|
|
4
|
+
* where the host record genuinely has a foreign key to write").
|
|
5
|
+
*
|
|
6
|
+
* `catalog` is undefined/empty-safe by design: no catalogue means nothing
|
|
7
|
+
* has been proven writable, so every relationship is refused (fail closed)
|
|
8
|
+
* rather than silently offering a picker that cannot save. This is the only
|
|
9
|
+
* place that decision is made — `PageGridLayout`'s add-relationship-widget
|
|
10
|
+
* handler calls this before ever creating a widget, and it is exported so
|
|
11
|
+
* consumers (e.g. a canvas "add element" palette deciding what to list) can
|
|
12
|
+
* apply the identical rule ahead of dispatching the event.
|
|
13
|
+
*
|
|
14
|
+
* `@bsuite/page-builder` never infers writability itself — it has no notion
|
|
15
|
+
* of a database. Consumers build `catalog` from a live schema source (in
|
|
16
|
+
* crm7, `report_catalog_joins` filtered to `cardinality = 'many_to_one'`
|
|
17
|
+
* and `is_active = true`, cross-checked against `pg_constraint` — see the
|
|
18
|
+
* Wave 4 implementation PR for the verification query and evidence).
|
|
19
|
+
*/
|
|
20
|
+
export function isRelationshipWritable(catalog, detail) {
|
|
21
|
+
if (!catalog || catalog.length === 0)
|
|
22
|
+
return false;
|
|
23
|
+
return catalog.some((entry) => entry.hostEntityType === detail.hostEntityType &&
|
|
24
|
+
entry.fkColumn === detail.fkColumn &&
|
|
25
|
+
entry.targetEntityType === detail.targetEntityType);
|
|
26
|
+
}
|
package/dist/types.d.ts
CHANGED
|
@@ -54,18 +54,104 @@ export interface EntityWidgetDetail {
|
|
|
54
54
|
export interface EntityWidgetFactoryOptions extends EntityWidgetDetail {
|
|
55
55
|
widgetId: string;
|
|
56
56
|
isEditing: boolean;
|
|
57
|
+
/**
|
|
58
|
+
* The caller's tenant (Wave 4 / W4-3 — see {@link PageGridLayoutProps.tenantId}).
|
|
59
|
+
* Threaded through so the entity-list widget is scoped to the caller's
|
|
60
|
+
* tenant by default instead of each consumer inventing (or forgetting)
|
|
61
|
+
* its own plumbing — a platform developer and an ordinary tenant user
|
|
62
|
+
* previously saw identical `entityType`/`label` from this callback with
|
|
63
|
+
* no way to tell whose rows they were about to render.
|
|
64
|
+
*/
|
|
65
|
+
tenantId?: string | null;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* A single option a {@link RelationshipField} can offer — the row a user
|
|
69
|
+
* picks to write as the foreign key value. `id` is what gets written;
|
|
70
|
+
* `label` (and optional `secondaryLabel`) is what gets displayed.
|
|
71
|
+
*/
|
|
72
|
+
export interface RelationshipFieldOption {
|
|
73
|
+
id: string;
|
|
74
|
+
label: string;
|
|
75
|
+
secondaryLabel?: string;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Detail carried by the `crm7-add-relationship-widget` / `bsu-add-relationship-widget`
|
|
79
|
+
* events — mirrors {@link EntityWidgetDetail} for the entity-list kind.
|
|
80
|
+
* Unlike a list (which only needs an entity type to render a table), a
|
|
81
|
+
* relationship field writes a single foreign key, so it must name BOTH
|
|
82
|
+
* sides: the host record's entity type and FK column, and the entity type
|
|
83
|
+
* the FK targets.
|
|
84
|
+
*/
|
|
85
|
+
export interface RelationshipWidgetDetail {
|
|
86
|
+
/** Entity type of the record being edited — the host row the FK lives on. */
|
|
87
|
+
hostEntityType: string;
|
|
88
|
+
/** FK column on the host record this widget writes when a value is picked. */
|
|
89
|
+
fkColumn: string;
|
|
90
|
+
/** Entity type the FK points at — drives the typeahead's search. */
|
|
91
|
+
targetEntityType: string;
|
|
92
|
+
label?: string;
|
|
93
|
+
}
|
|
94
|
+
export interface RelationshipWidgetFactoryOptions extends RelationshipWidgetDetail {
|
|
95
|
+
widgetId: string;
|
|
96
|
+
isEditing: boolean;
|
|
97
|
+
/** The caller's tenant — see {@link PageGridLayoutProps.tenantId}. */
|
|
98
|
+
tenantId?: string | null;
|
|
57
99
|
}
|
|
100
|
+
/**
|
|
101
|
+
* One entry in the relationship catalogue: proof that a host entity's FK
|
|
102
|
+
* column genuinely points at a target entity, and is therefore safe to
|
|
103
|
+
* offer as a relationship field. Consumers build this array from a live
|
|
104
|
+
* schema source — in crm7, the `report_catalog_joins` table, filtered to
|
|
105
|
+
* `cardinality = 'many_to_one'` and `is_active = true` — never from
|
|
106
|
+
* inference. `@bsuite/page-builder` itself has no notion of a database and
|
|
107
|
+
* cannot verify writability on its own; see {@link isRelationshipWritable}.
|
|
108
|
+
*/
|
|
109
|
+
export interface RelationshipCatalogEntry {
|
|
110
|
+
hostEntityType: string;
|
|
111
|
+
fkColumn: string;
|
|
112
|
+
targetEntityType: string;
|
|
113
|
+
}
|
|
114
|
+
export type RelationshipCatalog = readonly RelationshipCatalogEntry[];
|
|
58
115
|
export interface PageGridLayoutProps extends UsePageGridLayoutOptions {
|
|
59
116
|
widgets: Record<string, React.ReactNode>;
|
|
60
117
|
widgetMeta?: Record<string, WidgetMeta>;
|
|
61
118
|
className?: string;
|
|
62
119
|
isResizable?: boolean;
|
|
63
120
|
resizeHandles?: readonly ResizeHandleAxis[];
|
|
121
|
+
/**
|
|
122
|
+
* The signed-in caller's tenant. Threaded into every widget-factory call
|
|
123
|
+
* (`createEntityWidget`, `createRelationshipWidget`) as `tenantId` so
|
|
124
|
+
* both widget kinds are scoped to the caller's tenant BY DEFAULT — the
|
|
125
|
+
* shared layer hands the scope down explicitly rather than leaving each
|
|
126
|
+
* consumer to invent (or omit) its own plumbing (W4-3).
|
|
127
|
+
*/
|
|
128
|
+
tenantId?: string | null;
|
|
64
129
|
addEntityWidgetEventNames?: readonly string[];
|
|
65
130
|
createEntityWidget?: (options: EntityWidgetFactoryOptions) => React.ReactNode;
|
|
66
131
|
onRegisterEntityWidget?: (options: EntityWidgetDetail & {
|
|
67
132
|
widgetId: string;
|
|
68
133
|
}) => void;
|
|
134
|
+
/**
|
|
135
|
+
* Schema facts describing which relationships are actually writable
|
|
136
|
+
* (W4-2). A relationship field is only ever added when its
|
|
137
|
+
* `{hostEntityType, fkColumn, targetEntityType}` triple appears here —
|
|
138
|
+
* consumers build this from a live catalogue, never from inference.
|
|
139
|
+
* Omitting this prop refuses every relationship field (fail closed): no
|
|
140
|
+
* catalogue means nothing has been proven writable.
|
|
141
|
+
*/
|
|
142
|
+
relationshipCatalog?: RelationshipCatalog;
|
|
143
|
+
addRelationshipWidgetEventNames?: readonly string[];
|
|
144
|
+
createRelationshipWidget?: (options: RelationshipWidgetFactoryOptions) => React.ReactNode;
|
|
145
|
+
onRegisterRelationshipWidget?: (options: RelationshipWidgetDetail & {
|
|
146
|
+
widgetId: string;
|
|
147
|
+
}) => void;
|
|
148
|
+
/**
|
|
149
|
+
* Called when an `add-relationship-widget` event names a relationship
|
|
150
|
+
* that is not present in `relationshipCatalog` — the widget is refused;
|
|
151
|
+
* this is the hook for surfacing why (e.g. a toast) without
|
|
152
|
+
* `@bsuite/page-builder` owning any UI copy.
|
|
153
|
+
*/
|
|
154
|
+
onRelationshipWidgetRejected?: (detail: RelationshipWidgetDetail) => void;
|
|
69
155
|
}
|
|
70
156
|
export interface UsePageGridLayoutResult {
|
|
71
157
|
currentLayouts: GridLayouts;
|
package/dist/types.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,KAAK,MAAM,OAAO,CAAC;AAC/B,OAAO,KAAK,EAAE,SAAS,EAAE,UAAU,EAAE,gBAAgB,EAAE,MAAM,mBAAmB,CAAC;AAEjF,MAAM,WAAW,cAAe,SAAQ,UAAU;IAChD;;;;;;;;;;;;OAYG;IACH,UAAU,CAAC,EAAE,OAAO,CAAC;CACtB;AAED,MAAM,WAAW,WAAW;IAC1B,EAAE,EAAE,cAAc,EAAE,CAAC;IACrB,CAAC,GAAG,EAAE,MAAM,GAAG,cAAc,EAAE,CAAC;CACjC;AAED,MAAM,WAAW,yBAAyB,CAAC,CAAC;IAC1C,KAAK,EAAE,CAAC,CAAC;IACT,QAAQ,EAAE,CAAC,KAAK,EAAE,CAAC,GAAG,CAAC,CAAC,QAAQ,EAAE,CAAC,KAAK,CAAC,CAAC,KAAK,IAAI,CAAC;IACpD,MAAM,EAAE,OAAO,CAAC;CACjB;AAED,MAAM,MAAM,yBAAyB,GAAG,CAAC,CAAC,EACxC,GAAG,EAAE,MAAM,EACX,QAAQ,EAAE,CAAC,KACR,yBAAyB,CAAC,CAAC,CAAC,CAAC;AAElC,MAAM,WAAW,wBAAwB;IACvC,OAAO,EAAE,MAAM,CAAC;IAChB,cAAc,EAAE,WAAW,CAAC;IAC5B,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB,gBAAgB,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IACrC,iBAAiB,CAAC,EAAE,yBAAyB,CAAC;CAC/C;AAED,MAAM,WAAW,UAAU;IACzB,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,KAAK,CAAC,aAAa,CAAC;QAAE,SAAS,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IACnD,WAAW,CAAC,EAAE;QAAE,CAAC,CAAC,EAAE,MAAM,CAAC;QAAC,CAAC,CAAC,EAAE,MAAM,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;CACxE;AAED,MAAM,WAAW,kBAAkB;IACjC,UAAU,EAAE,MAAM,CAAC;IACnB,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,WAAW,0BAA2B,SAAQ,kBAAkB;IACpE,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,EAAE,OAAO,CAAC;
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"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"}
|