@bsuite/page-builder 0.6.2 → 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.
@@ -112,5 +112,5 @@ export function PageEditorLauncher({ pageId, tenantId, widgets, onAddWidget, onR
112
112
  // reference it here to avoid an unused-variable lint at the call site.
113
113
  void tenantId;
114
114
  const effectiveDefaultLayouts = defaultLayouts ?? { lg: [] };
115
- return (_jsxs("div", { className: "relative isolate", children: [_jsx("div", { className: "sticky top-0 z-40 isolate flex flex-col gap-3 rounded-xl shadow-lg border-2 mb-4 p-4 bg-card/95 supports-[backdrop-filter]:bg-card/80 backdrop-blur border-primary", role: "region", "aria-label": "Page editor toolbar", children: _jsxs("div", { className: "flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3", children: [_jsxs("div", { className: "flex items-center gap-3", children: [_jsx("div", { className: "rounded-full flex items-center justify-center bg-primary/10 h-10 w-10 animate-pulse", children: _jsx(LayoutGrid, { className: "h-5 w-5 text-primary" }) }), _jsxs("div", { children: [_jsx("h3", { className: "font-semibold text-foreground text-lg", children: "Page Editor" }), _jsx("span", { className: "text-sm text-muted-foreground", children: "Drag cards to rearrange. Resize with the bottom-right handle." })] })] }), _jsxs("div", { className: "flex items-center gap-2 shrink-0", children: [_jsxs("button", { type: "button", className: "inline-flex items-center gap-1 rounded-md border border-border px-3 py-2 text-sm font-medium transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1", onClick: () => setPaletteOpen(true), children: [_jsx(Plus, { className: "h-4 w-4" }), "Add Widget"] }), _jsxs("button", { type: "button", className: "inline-flex items-center gap-1 rounded-md border border-border px-3 py-2 text-sm font-medium transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1", onClick: handleCancel, children: [_jsx(X, { className: "h-4 w-4" }), "Cancel"] }), _jsxs("button", { type: "button", className: "inline-flex items-center rounded-md px-3 py-2 text-sm font-medium bg-primary text-primary-foreground shadow transition-colors hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1", onClick: handleSave, children: [_jsx(Save, { className: "h-4 w-4 mr-2" }), "Save"] })] })] }) }), _jsx(PageGridLayout, { pageKey: pageId, defaultLayouts: effectiveDefaultLayouts, canEditPage: true, preferenceAdapter: capturedAdapter, widgets: gridWidgets, widgetMeta: gridWidgetMeta }), paletteOpen && (_jsxs("div", { className: "fixed inset-0 z-50 flex", children: [_jsx("div", { className: "absolute inset-0 bg-black/40", onClick: () => setPaletteOpen(false), "aria-hidden": "true" }), _jsxs("div", { className: "relative ml-auto h-full w-80 max-w-[85vw] flex flex-col bg-card border-l border-border shadow-xl", children: [_jsxs("div", { className: "flex items-center justify-between px-4 pt-4 pb-3 border-b", children: [_jsx("h2", { className: "text-base font-semibold", children: "Add Widget" }), _jsx("button", { type: "button", className: "rounded p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground", onClick: () => setPaletteOpen(false), "aria-label": "Close widget palette", children: _jsx(X, { className: "h-4 w-4" }) })] }), _jsxs("div", { className: "flex-1 overflow-y-auto px-4 py-4 space-y-4", children: [widgets.length === 0 ? (_jsx("p", { className: "text-sm text-muted-foreground text-center py-8", children: "No widgets available. Add widgets via your app's widget catalogue." })) : (_jsxs("div", { className: "space-y-2", children: [_jsx("p", { className: "text-xs font-semibold uppercase tracking-wider text-muted-foreground px-1", children: "Available Widgets" }), widgets.map((w) => (_jsxs("div", { className: "flex items-center justify-between rounded-md border bg-card p-3 hover:bg-accent/50 transition-colors", children: [_jsx("span", { className: "text-sm font-medium truncate", children: w.meta?.label ?? w.key }), _jsx("button", { type: "button", className: "ml-2 shrink-0 inline-flex items-center rounded-md px-2 py-1 text-xs font-medium bg-secondary text-secondary-foreground transition-colors hover:bg-secondary/80", onClick: () => handlePaletteAdd(w.key), children: "Add" })] }, w.key)))] })), _jsxs("div", { className: "border-t border-border pt-4 space-y-2", children: [_jsx("p", { className: "text-xs font-semibold uppercase tracking-wider text-muted-foreground px-1", children: "Remove Widget" }), widgets.length === 0 ? (_jsx("p", { className: "text-xs text-muted-foreground px-1", children: "No widgets on the canvas to remove." })) : (widgets.map((w) => (_jsxs("div", { className: "flex items-center justify-between rounded-md border bg-card p-3", children: [_jsx("span", { className: "text-sm font-medium truncate", children: w.meta?.label ?? w.key }), _jsx("button", { type: "button", className: "ml-2 shrink-0 inline-flex items-center rounded-md px-2 py-1 text-xs font-medium bg-destructive/10 text-destructive transition-colors hover:bg-destructive/20", onClick: () => onRemoveWidget(w.key), children: "Remove" })] }, `remove-${w.key}`))))] })] })] })] }))] }));
115
+ return (_jsxs("div", { className: "relative isolate", children: [_jsx("div", { className: "sticky top-0 z-40 isolate flex flex-col gap-3 rounded-xl shadow-lg border-2 mb-4 p-4 bg-card/95 supports-[backdrop-filter]:bg-card/80 backdrop-blur border-primary", role: "region", "aria-label": "Page editor toolbar", children: _jsxs("div", { className: "flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3", children: [_jsxs("div", { className: "flex items-center gap-3", children: [_jsx("div", { className: "rounded-full flex items-center justify-center bg-primary/10 h-10 w-10 animate-pulse", children: _jsx(LayoutGrid, { className: "h-5 w-5 text-primary" }) }), _jsxs("div", { children: [_jsx("h3", { className: "font-semibold text-foreground text-lg", children: "Page Editor" }), _jsx("span", { className: "text-sm text-muted-foreground", children: "Drag cards to rearrange. Resize with the bottom-right handle." })] })] }), _jsxs("div", { className: "flex items-center gap-2 shrink-0", children: [_jsxs("button", { type: "button", className: "inline-flex items-center gap-1 rounded-md border border-border px-3 py-2 text-sm font-medium transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1", onClick: () => setPaletteOpen(true), children: [_jsx(Plus, { className: "h-4 w-4" }), "Add Widget"] }), _jsxs("button", { type: "button", className: "inline-flex items-center gap-1 rounded-md border border-border px-3 py-2 text-sm font-medium transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1", onClick: handleCancel, children: [_jsx(X, { className: "h-4 w-4" }), "Cancel"] }), _jsxs("button", { type: "button", className: "inline-flex items-center rounded-md px-3 py-2 text-sm font-medium bg-primary text-primary-foreground shadow transition-colors hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1", onClick: handleSave, children: [_jsx(Save, { className: "h-4 w-4 mr-2" }), "Save"] })] })] }) }), _jsx(PageGridLayout, { pageKey: pageId, defaultLayouts: effectiveDefaultLayouts, canEditPage: true, preferenceAdapter: capturedAdapter, widgets: gridWidgets, widgetMeta: gridWidgetMeta }), paletteOpen && (_jsxs("div", { className: "fixed inset-0 z-50 flex", children: [_jsx("div", { className: "absolute inset-0 bg-overlay/50", onClick: () => setPaletteOpen(false), "aria-hidden": "true" }), _jsxs("div", { className: "relative ml-auto h-full w-80 max-w-[85vw] flex flex-col bg-card border-l border-border shadow-xl", children: [_jsxs("div", { className: "flex items-center justify-between px-4 pt-4 pb-3 border-b", children: [_jsx("h2", { className: "text-base font-semibold", children: "Add Widget" }), _jsx("button", { type: "button", className: "rounded p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground", onClick: () => setPaletteOpen(false), "aria-label": "Close widget palette", children: _jsx(X, { className: "h-4 w-4" }) })] }), _jsxs("div", { className: "flex-1 overflow-y-auto px-4 py-4 space-y-4", children: [widgets.length === 0 ? (_jsx("p", { className: "text-sm text-muted-foreground text-center py-8", children: "No widgets available. Add widgets via your app's widget catalogue." })) : (_jsxs("div", { className: "space-y-2", children: [_jsx("p", { className: "text-xs font-semibold uppercase tracking-wider text-muted-foreground px-1", children: "Available Widgets" }), widgets.map((w) => (_jsxs("div", { className: "flex items-center justify-between rounded-md border bg-card p-3 hover:bg-accent/50 transition-colors", children: [_jsx("span", { className: "text-sm font-medium truncate", children: w.meta?.label ?? w.key }), _jsx("button", { type: "button", className: "ml-2 shrink-0 inline-flex items-center rounded-md px-2 py-1 text-xs font-medium bg-secondary text-secondary-foreground transition-colors hover:bg-secondary/80", onClick: () => handlePaletteAdd(w.key), children: "Add" })] }, w.key)))] })), _jsxs("div", { className: "border-t border-border pt-4 space-y-2", children: [_jsx("p", { className: "text-xs font-semibold uppercase tracking-wider text-muted-foreground px-1", children: "Remove Widget" }), widgets.length === 0 ? (_jsx("p", { className: "text-xs text-muted-foreground px-1", children: "No widgets on the canvas to remove." })) : (widgets.map((w) => (_jsxs("div", { className: "flex items-center justify-between rounded-md border bg-card p-3", children: [_jsx("span", { className: "text-sm font-medium truncate", children: w.meta?.label ?? w.key }), _jsx("button", { type: "button", className: "ml-2 shrink-0 inline-flex items-center rounded-md px-2 py-1 text-xs font-medium bg-destructive/10 text-destructive transition-colors hover:bg-destructive/20", onClick: () => onRemoveWidget(w.key), children: "Remove" })] }, `remove-${w.key}`))))] })] })] })] }))] }));
116
116
  }
@@ -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;AAKxC,OAAO,KAAK,EAAe,mBAAmB,EAAE,MAAM,YAAY,CAAC;AA0QnE,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,yBAAiE,EACjE,kBAAkB,EAClB,sBAAsB,GACvB,EAAE,mBAAmB,qBAirBrB"}
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"}
@@ -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
  *
@@ -142,7 +152,7 @@ const GridItem = React.memo(React.forwardRef(function GridItem({ id, content, is
142
152
  // (buttons, inputs, textarea, select, links, [data-no-drag]) are still
143
153
  // protected via the `cancel` selector below in the <Responsive> render.
144
154
  const outerClass = cn('relative group overflow-visible', isEditing && 'drag-handle cursor-move', injectedClassName);
145
- return (_jsxs("div", { ref: ref, className: outerClass, style: injectedStyle, ...rest, children: [_jsxs("div", { className: "h-full w-full relative", children: [isEditing && (_jsx("div", { className: "absolute inset-0 z-10 pointer-events-none rounded-3xl border-2 border-transparent group-hover:border-primary/50 transition-colors bg-black/5" })), isEditing && (_jsx("div", { className: "absolute top-2 left-2 z-30 flex items-center gap-1 pointer-events-none", children: _jsx("span", { className: "text-[10px] px-1.5 py-0.5 rounded-md opacity-80 font-medium bg-muted text-muted-foreground", children: label }) })), isEditing && (_jsx("button", { className: "absolute top-2 right-2 z-30 h-6 w-6 rounded-full flex items-center justify-center bg-destructive/80 hover:bg-destructive text-white shadow transition-colors", "data-no-drag": true, onPointerDown: (event) => event.stopPropagation(), onClick: (event) => {
155
+ return (_jsxs("div", { ref: ref, className: outerClass, style: injectedStyle, ...rest, children: [_jsxs("div", { className: "h-full w-full relative", children: [isEditing && (_jsx("div", { className: "absolute inset-0 z-10 pointer-events-none rounded-3xl border-2 border-transparent group-hover:border-primary/50 transition-colors bg-primary/5" })), isEditing && (_jsx("div", { className: "absolute top-2 left-2 z-30 flex items-center gap-1 pointer-events-none", children: _jsx("span", { className: "text-[10px] px-1.5 py-0.5 rounded-md opacity-80 font-medium bg-muted text-muted-foreground", children: label }) })), isEditing && (_jsx("button", { className: "absolute top-2 right-2 z-30 h-6 w-6 rounded-full flex items-center justify-center bg-destructive/80 hover:bg-destructive text-destructive-foreground shadow transition-colors", "data-no-drag": true, onPointerDown: (event) => event.stopPropagation(), onClick: (event) => {
146
156
  event.stopPropagation();
147
157
  onHide(id);
148
158
  }, title: `Hide ${label}`, "aria-label": `Hide ${label}`, children: _jsx(EyeOff, { className: "h-3 w-3", "aria-hidden": "true" }) })), _jsx("div", { className: "h-full w-full rounded-3xl transition-all flex flex-col bg-card border border-border shadow-sm dark:shadow-[var(--glow-card,none)]", style: { contain: 'layout style' }, children: _jsx("div", { className: cn('flex-1 min-h-0', autoHeight ? 'overflow-hidden' : 'overflow-auto'), children: autoHeight ? _jsx("div", { ref: measureRef, children: content }) : content }) })] }), injectedChildren] }));
@@ -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
- }, [createEntityWidget, extraWidgetConfigs, isEditing]);
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) => {
@@ -476,10 +562,10 @@ isResizable = true, resizeHandles = DEFAULT_RESIZE_HANDLES, addEntityWidgetEvent
476
562
  const meta = widgetMeta?.[key];
477
563
  const Icon = meta?.icon;
478
564
  return (_jsxs("button", { type: "button", onClick: () => showLayer(key, meta?.defaultSize), className: cn('flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium border transition-colors', 'bg-muted text-muted-foreground border-border', 'hover:bg-muted/80 hover:text-foreground hover:border-primary/60', 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1'), children: [_jsx(Plus, { className: "h-3 w-3" }), Icon && _jsx(Icon, { className: "h-3 w-3" }), layerNames[key] || meta?.label || key] }, key));
479
- }) })] })), layerItems.length > 0 && (_jsxs("div", { className: "basis-full rounded-md border border-border bg-background/80 p-2", children: [_jsxs("div", { className: "mb-2 flex items-center gap-2 text-xs font-medium uppercase tracking-wide text-muted-foreground", children: [_jsx(Layers, { className: "h-3.5 w-3.5" }), "Layers"] }), _jsx("div", { className: "flex flex-col gap-1", children: layerItems.map((layer) => (_jsxs("div", { className: "flex items-center gap-1 rounded border border-border bg-card px-2 py-1", children: [_jsx("input", { type: "text", value: layer.label, onChange: (event) => handleLayerRename(layer.id, event.target.value), className: "min-w-0 flex-1 bg-transparent text-xs text-foreground outline-none", "aria-label": `Rename ${layer.label}` }), _jsx("button", { type: "button", onClick: () => moveWidget(layer.id, 'up'), disabled: !layer.canMoveUp, "aria-label": `Move ${layer.label} up`, className: "rounded p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:opacity-40", children: _jsx(ArrowUp, { className: "h-3.5 w-3.5" }) }), _jsx("button", { type: "button", onClick: () => moveWidget(layer.id, 'down'), disabled: !layer.canMoveDown, "aria-label": `Move ${layer.label} down`, className: "rounded p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:opacity-40", children: _jsx(ArrowDown, { className: "h-3.5 w-3.5" }) }), _jsx("button", { type: "button", onClick: () => setWidgetLocked(layer.id, !layer.locked), "aria-label": `${layer.locked ? 'Unlock' : 'Lock'} ${layer.label}`, className: "rounded p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground", children: layer.locked ? _jsx(Lock, { className: "h-3.5 w-3.5" }) : _jsx(Unlock, { className: "h-3.5 w-3.5" }) }), _jsx("button", { type: "button", onClick: () => hideLayer(layer.id), "aria-label": `Hide ${layer.label}`, className: "rounded p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground", children: _jsx(EyeOff, { className: "h-3.5 w-3.5" }) })] }, layer.id))) })] })), _jsxs("button", { type: "button", onClick: handleCompact, className: cn('ml-auto inline-flex items-center rounded-md border border-border px-2 py-1 text-sm transition-colors', 'text-muted-foreground hover:bg-muted hover:text-foreground', 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1'), children: [_jsx(Layers, { className: "h-4 w-4 mr-1" }), "Compact Layout"] }), _jsxs("button", { type: "button", onClick: () => setResetConfirmOpen(true), className: cn('inline-flex items-center rounded-md border border-border px-2 py-1 text-sm transition-colors', 'text-muted-foreground hover:bg-muted hover:text-foreground', 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1'), children: [_jsx(RotateCcw, { className: "h-4 w-4 mr-1" }), "Reset to Default"] })] })] })), resetConfirmOpen && (_jsx("div", { className: "fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4", role: "dialog", "aria-modal": "true", "aria-labelledby": "page-grid-reset-title", "aria-describedby": "page-grid-reset-description", children: _jsxs("div", { className: "max-w-md rounded-lg bg-card p-5 shadow-lg border border-border", children: [_jsx("h2", { id: "page-grid-reset-title", className: "text-lg font-semibold", children: "Reset page layout?" }), _jsx("p", { id: "page-grid-reset-description", className: "mt-2 text-sm text-muted-foreground", children: "This will discard your current layout and restore all widgets to their default positions." }), _jsxs("div", { className: "mt-4 flex justify-end gap-2", children: [_jsx("button", { type: "button", ref: resetCancelButtonRef, onClick: () => setResetConfirmOpen(false), className: cn('inline-flex items-center rounded-md border border-border px-3 py-2 text-sm transition-colors', 'bg-card text-foreground hover:bg-muted hover:text-foreground', 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1'), children: "Cancel" }), _jsx("button", { type: "button", ref: resetConfirmButtonRef, onClick: () => {
565
+ }) })] })), layerItems.length > 0 && (_jsxs("div", { className: "basis-full rounded-md border border-border bg-background/80 p-2", children: [_jsxs("div", { className: "mb-2 flex items-center gap-2 text-xs font-medium uppercase tracking-wide text-muted-foreground", children: [_jsx(Layers, { className: "h-3.5 w-3.5" }), "Layers"] }), _jsx("div", { className: "flex flex-col gap-1", children: layerItems.map((layer) => (_jsxs("div", { className: "flex items-center gap-1 rounded border border-border bg-card px-2 py-1", children: [_jsx("input", { type: "text", value: layer.label, onChange: (event) => handleLayerRename(layer.id, event.target.value), className: "min-w-0 flex-1 bg-transparent text-xs text-foreground outline-none", "aria-label": `Rename ${layer.label}` }), _jsx("button", { type: "button", onClick: () => moveWidget(layer.id, 'up'), disabled: !layer.canMoveUp, "aria-label": `Move ${layer.label} up`, className: "rounded p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:opacity-40", children: _jsx(ArrowUp, { className: "h-3.5 w-3.5" }) }), _jsx("button", { type: "button", onClick: () => moveWidget(layer.id, 'down'), disabled: !layer.canMoveDown, "aria-label": `Move ${layer.label} down`, className: "rounded p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:opacity-40", children: _jsx(ArrowDown, { className: "h-3.5 w-3.5" }) }), _jsx("button", { type: "button", onClick: () => setWidgetLocked(layer.id, !layer.locked), "aria-label": `${layer.locked ? 'Unlock' : 'Lock'} ${layer.label}`, className: "rounded p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground", children: layer.locked ? _jsx(Lock, { className: "h-3.5 w-3.5" }) : _jsx(Unlock, { className: "h-3.5 w-3.5" }) }), _jsx("button", { type: "button", onClick: () => hideLayer(layer.id), "aria-label": `Hide ${layer.label}`, className: "rounded p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground", children: _jsx(EyeOff, { className: "h-3.5 w-3.5" }) })] }, layer.id))) })] })), _jsxs("button", { type: "button", onClick: handleCompact, className: cn('ml-auto inline-flex items-center rounded-md border border-border px-2 py-1 text-sm transition-colors', 'text-muted-foreground hover:bg-muted hover:text-foreground', 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1'), children: [_jsx(Layers, { className: "h-4 w-4 mr-1" }), "Compact Layout"] }), _jsxs("button", { type: "button", onClick: () => setResetConfirmOpen(true), className: cn('inline-flex items-center rounded-md border border-border px-2 py-1 text-sm transition-colors', 'text-muted-foreground hover:bg-muted hover:text-foreground', 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1'), children: [_jsx(RotateCcw, { className: "h-4 w-4 mr-1" }), "Reset to Default"] })] })] })), resetConfirmOpen && (_jsx("div", { className: "fixed inset-0 z-50 flex items-center justify-center bg-overlay/50 p-4", role: "dialog", "aria-modal": "true", "aria-labelledby": "page-grid-reset-title", "aria-describedby": "page-grid-reset-description", children: _jsxs("div", { className: "max-w-md rounded-lg bg-card p-5 shadow-lg border border-border", children: [_jsx("h2", { id: "page-grid-reset-title", className: "text-lg font-semibold", children: "Reset page layout?" }), _jsx("p", { id: "page-grid-reset-description", className: "mt-2 text-sm text-muted-foreground", children: "This will discard your current layout and restore all widgets to their default positions." }), _jsxs("div", { className: "mt-4 flex justify-end gap-2", children: [_jsx("button", { type: "button", ref: resetCancelButtonRef, onClick: () => setResetConfirmOpen(false), className: cn('inline-flex items-center rounded-md border border-border px-3 py-2 text-sm transition-colors', 'bg-card text-foreground hover:bg-muted hover:text-foreground', 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1'), children: "Cancel" }), _jsx("button", { type: "button", ref: resetConfirmButtonRef, onClick: () => {
480
566
  handleReset();
481
567
  setResetConfirmOpen(false);
482
- }, className: cn('inline-flex items-center rounded-md px-3 py-2 text-sm font-medium transition-colors', 'bg-destructive text-white shadow hover:bg-destructive/90', 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-destructive/40 focus-visible:ring-offset-1'), children: "Reset to Default" })] })] }) })), _jsx("div", { ref: containerRef, className: cn('relative z-0 overflow-visible', isEditing && 'min-h-[200px] bg-muted/30'), "data-page-grid-editing": isEditing || undefined, children: _jsx("div", { className: "relative z-0", style: { opacity: containerWidth > 0 ? 1 : 0 }, "aria-busy": containerWidth <= 0, children: _jsx(Responsive, { width: Math.max(containerWidth, 1), className: "layout page-grid-canvas", layouts: activeLayouts, breakpoints: { lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0 }, rowHeight: DEFAULT_ROW_HEIGHT, onLayoutChange: onLayoutChange, dragConfig: {
568
+ }, className: cn('inline-flex items-center rounded-md px-3 py-2 text-sm font-medium transition-colors', 'bg-destructive text-destructive-foreground shadow hover:bg-destructive/90', 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-destructive/40 focus-visible:ring-offset-1'), children: "Reset to Default" })] })] }) })), _jsx("div", { ref: containerRef, className: cn('relative z-0 overflow-visible', isEditing && 'min-h-[200px] bg-muted/30'), "data-page-grid-editing": isEditing || undefined, children: _jsx("div", { className: "relative z-0", style: { opacity: containerWidth > 0 ? 1 : 0 }, "aria-busy": containerWidth <= 0, children: _jsx(Responsive, { width: Math.max(containerWidth, 1), className: "layout page-grid-canvas", layouts: activeLayouts, breakpoints: { lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0 }, rowHeight: DEFAULT_ROW_HEIGHT, onLayoutChange: onLayoutChange, dragConfig: {
483
569
  enabled: isEditing,
484
570
  handle: '.drag-handle',
485
571
  bounded: false,
@@ -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 type { EntityWidgetDetail, EntityWidgetFactoryOptions, GridLayoutItem, GridLayouts, PageGridLayoutProps, PageGridPreferenceAdapter, PageGridPreferenceFactory, UsePageGridLayoutOptions, UsePageGridLayoutResult, WidgetMeta, } from './types.js';
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
@@ -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;
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bsuite/page-builder",
3
- "version": "0.6.2",
3
+ "version": "0.7.0",
4
4
  "description": "Shared BSuite responsive page-builder grid and layout persistence primitives",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -32,7 +32,7 @@
32
32
  "prepublishOnly": "pnpm build"
33
33
  },
34
34
  "peerDependencies": {
35
- "@bsuite/theme": ">=0.5.0",
35
+ "@bsuite/theme": ">=0.7.1",
36
36
  "@dnd-kit/core": "^6",
37
37
  "@dnd-kit/sortable": "^10",
38
38
  "@dnd-kit/utilities": "^3",
@@ -72,8 +72,14 @@
72
72
  bottom: 3px;
73
73
  width: 6px;
74
74
  height: 6px;
75
- border-right: 2px solid var(--text-secondary, oklch(0 0 0 / 0.45));
76
- border-bottom: 2px solid var(--text-secondary, oklch(0 0 0 / 0.45));
75
+ /* --text-secondary is NOT a token this design system defines — the name is
76
+ --role-text-secondary. So the fallback was not a fallback, it was the
77
+ value: every resize handle rendered at pure black 45%, which on a dark
78
+ surface is very nearly invisible. Named correctly, and the fallback
79
+ dropped rather than corrected — a fallback here can only ever mask the
80
+ same mistake again. */
81
+ border-right: 2px solid var(--role-text-secondary);
82
+ border-bottom: 2px solid var(--role-text-secondary);
77
83
  }
78
84
 
79
85
  /* Highlight the marker when hovering the handle itself. */