@bsuite/page-builder 0.7.0 → 0.9.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.
@@ -1,6 +1,6 @@
1
1
  import { startTransition, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, } from 'react';
2
2
  import { verticalCompactor } from 'react-grid-layout';
3
- import { buildResponsiveLayouts } from './buildResponsiveLayouts.js';
3
+ import { buildResponsiveLayouts, isCanonicalisableBreakpoint, isDerivedBreakpoint, } from './buildResponsiveLayouts.js';
4
4
  import { defaultPreferenceAdapter } from './preferences.js';
5
5
  import { rescaleLayout } from './rescaleLayout.js';
6
6
  export const DEFAULT_EDITOR_EVENT_NAMES = [
@@ -20,7 +20,43 @@ export const DEFAULT_EDITOR_EVENT_NAMES = [
20
20
  * launchers scope by page if they handle multiple grids on one screen.
21
21
  */
22
22
  export const PAGE_GRID_EDITING_EVENT = 'bsuite-page-grid-editing';
23
- export function usePageGridLayout({ pageKey, defaultLayouts, defaultCols = 12, layoutVersion = 1, canEditPage = true, editorEventNames = DEFAULT_EDITOR_EVENT_NAMES, preferenceAdapter = defaultPreferenceAdapter, }) {
23
+ /**
24
+ * Package-wide layout epoch, added to EVERY consumer's `layoutVersion`.
25
+ *
26
+ * When a default that governs all grids changes, a saved layout produced under
27
+ * the old default has to be invalidated — and there is no way to reach ~330
28
+ * call sites across six apps to hand-bump each one. crm7's `DraggableCardPage`
29
+ * already solved this locally with its own `LAYOUT_EPOCH`; this is the same
30
+ * lever one level down, so it also covers the raw `PageGridLayout` consumers
31
+ * (Dashboard, Billing, GTO, Analytics, the throughput and braden pages) that
32
+ * no app-level epoch can see.
33
+ *
34
+ * 0 -> 1000 (2026-08-13, D-75/D-76): two all-grids defaults changed in the same
35
+ * release — `autoHeight` now defaults to true for raw consumers, and md/sm no
36
+ * longer collapse to a full-width stack. Every stored layout predates both and
37
+ * was additionally polluted with frozen derived breakpoints, so all of them
38
+ * reset once to the corrected defaults.
39
+ *
40
+ * Per-page `layoutVersion` bumps keep working on top; this only moves the
41
+ * floor.
42
+ */
43
+ export const PACKAGE_LAYOUT_EPOCH = 1000;
44
+ /**
45
+ * Default for `GridLayoutItem.autoHeight` when an item does not state one.
46
+ *
47
+ * D-76 ("cards render half cut off on page open"). `DraggableCardPage` has
48
+ * defaulted this to true since the E5a flip, but a RAW `PageGridLayout`
49
+ * consumer builds its layout array by hand and inherits nothing — the flag had
50
+ * to be written on every item. Sixteen of the eighteen raw consumers across the
51
+ * suite never did, so their cards were fixed at whatever seed `h` the author
52
+ * guessed and clipped everything past it behind `overflow-auto`. That is not a
53
+ * per-page authoring mistake repeated sixteen times; it is a default in the
54
+ * wrong place. An item that genuinely owns its own scroll (a virtualized list)
55
+ * opts out with an explicit `autoHeight: false`.
56
+ */
57
+ const DEFAULT_ITEM_AUTO_HEIGHT = true;
58
+ export function usePageGridLayout({ pageKey, defaultLayouts, defaultCols = 12, layoutVersion = 1, canEditPage = true, editorEventNames = DEFAULT_EDITOR_EVENT_NAMES, preferenceAdapter = defaultPreferenceAdapter, defaultAutoHeight, }) {
59
+ const effectiveLayoutVersion = layoutVersion + PACKAGE_LAYOUT_EPOCH;
24
60
  const containerRef = useRef(null);
25
61
  const [containerWidth, setContainerWidth] = useState(0);
26
62
  const [isEditing, setIsEditingState] = useState(false);
@@ -65,18 +101,18 @@ export function usePageGridLayout({ pageKey, defaultLayouts, defaultCols = 12, l
65
101
  useEffect(() => {
66
102
  if (!prefsLoaded)
67
103
  return;
68
- if ((savedLayoutVersion ?? 0) < layoutVersion) {
104
+ if ((savedLayoutVersion ?? 0) < effectiveLayoutVersion) {
69
105
  startTransition(() => {
70
106
  setSavedLayout(defaultLayouts);
71
107
  setLayoutCols(defaultCols);
72
108
  setBaseCols(defaultCols);
73
- setSavedLayoutVersion(layoutVersion);
109
+ setSavedLayoutVersion(effectiveLayoutVersion);
74
110
  });
75
111
  }
76
112
  }, [
77
113
  defaultCols,
78
114
  defaultLayouts,
79
- layoutVersion,
115
+ effectiveLayoutVersion,
80
116
  prefsLoaded,
81
117
  savedLayoutVersion,
82
118
  setBaseCols,
@@ -89,7 +125,26 @@ export function usePageGridLayout({ pageKey, defaultLayouts, defaultCols = 12, l
89
125
  const rawLayouts = useMemo(() => {
90
126
  if (Object.keys(savedLayout || {}).length === 0)
91
127
  return defaultLayouts;
92
- const saved = savedLayout;
128
+ // Drop DERIVED breakpoints out of a stored layout before anything reads it.
129
+ //
130
+ // Until 0.8.0 react-grid-layout's `onLayoutChange` echo persisted md/sm/xs/
131
+ // xxs alongside lg. `buildResponsiveLayouts` then treated them as
132
+ // consumer-supplied and preserved them verbatim, so a layout derived once —
133
+ // at whatever column count happened to be active that day — outlived every
134
+ // later change to `lg` and to the columns slider (D-75). Every user who has
135
+ // ever dragged a card on any page has that pollution stored, so the fix has
136
+ // to heal on read rather than wait for a layoutVersion bump; `lg` (the only
137
+ // breakpoint that was ever authoritative) is preserved untouched, so nobody
138
+ // loses the arrangement they made.
139
+ const stored = savedLayout;
140
+ const saved = {};
141
+ for (const bp of Object.keys(stored)) {
142
+ if (isDerivedBreakpoint(bp))
143
+ continue;
144
+ saved[bp] = stored[bp];
145
+ }
146
+ if (!saved.lg)
147
+ saved.lg = stored.lg ?? defaultLayouts.lg ?? [];
93
148
  let changed = false;
94
149
  const merged = {};
95
150
  for (const bp of Object.keys(saved)) {
@@ -107,12 +162,28 @@ export function usePageGridLayout({ pageKey, defaultLayouts, defaultCols = 12, l
107
162
  merged[bp] = savedItems;
108
163
  }
109
164
  }
110
- return changed ? merged : savedLayout;
165
+ // `merged` is always returned (never the raw `savedLayout`) because the
166
+ // derived-breakpoint filter above may have removed keys even when no
167
+ // default item was missing — handing back `savedLayout` would smuggle the
168
+ // stale derived breakpoints straight past the heal.
169
+ void changed;
170
+ return merged;
111
171
  }, [savedLayout, defaultLayouts]);
112
172
  const currentLayouts = useMemo(() => {
113
173
  const rescaled = baseCols !== layoutCols ? rescaleLayout(rawLayouts, baseCols, layoutCols) : rawLayouts;
114
- return buildResponsiveLayouts(rescaled, { cols: layoutCols });
115
- }, [rawLayouts, baseCols, layoutCols]);
174
+ const responsive = buildResponsiveLayouts(rescaled, { cols: layoutCols });
175
+ // Resolve the autoHeight default HERE rather than at the render layer, so
176
+ // `stripAutoHeightRows` (which reads this object through
177
+ // `currentLayoutsForStripRef`) and `PageGridLayout`'s `activeLayouts` agree
178
+ // on which items are auto-height. They disagreed once before and the
179
+ // measured height leaked into storage.
180
+ const resolvedAutoHeight = defaultAutoHeight ?? DEFAULT_ITEM_AUTO_HEIGHT;
181
+ const resolved = { lg: [] };
182
+ for (const bp of Object.keys(responsive)) {
183
+ resolved[bp] = (responsive[bp] ?? []).map((item) => item.autoHeight === undefined ? { ...item, autoHeight: resolvedAutoHeight } : item);
184
+ }
185
+ return resolved;
186
+ }, [rawLayouts, baseCols, layoutCols, defaultAutoHeight]);
116
187
  useEffect(() => {
117
188
  if (baseCols !== layoutCols) {
118
189
  const rescaled = rescaleLayout(rawLayouts, baseCols, layoutCols);
@@ -265,6 +336,67 @@ export function usePageGridLayout({ pageKey, defaultLayouts, defaultCols = 12, l
265
336
  useEffect(() => {
266
337
  currentLayoutsForStripRef.current = currentLayouts;
267
338
  }, [currentLayouts]);
339
+ /**
340
+ * The breakpoint react-grid-layout is currently rendering, tracked so a
341
+ * gesture can be written back to the breakpoint that OWNS it.
342
+ *
343
+ * Seeded to `lg` and corrected by `<Responsive onBreakpointChange>`, which
344
+ * fires on mount once the container width is known.
345
+ */
346
+ const activeBreakpointRef = useRef('lg');
347
+ const handleBreakpointChange = useCallback((breakpoint) => {
348
+ activeBreakpointRef.current = breakpoint;
349
+ }, []);
350
+ /**
351
+ * Reduce react-grid-layout's all-breakpoints echo down to the ONE breakpoint
352
+ * that is authoritative — `lg` — or refuse the write.
353
+ *
354
+ * Two defects this closes, both reported as D-75:
355
+ *
356
+ * 1. **The derived breakpoints froze.** RGL echoes every breakpoint on every
357
+ * gesture. Persisting them made them consumer-supplied, so
358
+ * `buildResponsiveLayouts` stopped deriving them and the columns slider
359
+ * (which rescales `lg`) could no longer reach the breakpoint actually on
360
+ * screen. Below a 1200px container — i.e. most real desktop sessions once
361
+ * the sidebar is subtracted — moving the slider changed nothing at all.
362
+ *
363
+ * 2. **An edit made at `md`/`sm` was invisible at `lg`.** The gesture landed
364
+ * in `layouts.md`; `layouts.lg` kept its old values; the same user on a
365
+ * wider monitor saw their arrangement revert. `md` and `sm` render the
366
+ * same array against the same column count as `lg`, so folding the
367
+ * gesture onto `lg` is lossless and makes one arrangement follow the user
368
+ * across every window size.
369
+ *
370
+ * `xs`/`xxs` return `null` (no write). Those breakpoints render a full-width
371
+ * stack, so a gesture there carries only a vertical order; applying it to
372
+ * `lg` would flatten a multi-column desktop arrangement that the user cannot
373
+ * even see on that device. Refusing the write is the conservative half of
374
+ * the trade: a phone visit can no longer destroy the desktop layout, at the
375
+ * cost of phone-only reordering not persisting. Stated in the PR, not hidden.
376
+ */
377
+ const canonicaliseLayoutForPersist = useCallback((layouts) => {
378
+ const breakpoint = activeBreakpointRef.current;
379
+ if (!isCanonicalisableBreakpoint(breakpoint))
380
+ return null;
381
+ const items = layouts[breakpoint] ?? layouts.lg;
382
+ if (!items)
383
+ return null;
384
+ const next = { lg: items };
385
+ // Preserve any breakpoint a CONSUMER supplied deliberately (never a
386
+ // derived one) so an opt-out layout is not silently discarded.
387
+ for (const bp of Object.keys(layouts)) {
388
+ if (bp === 'lg' || isDerivedBreakpoint(bp))
389
+ continue;
390
+ next[bp] = layouts[bp] ?? [];
391
+ }
392
+ return next;
393
+ }, []);
394
+ const commitLayout = useCallback((layouts) => {
395
+ const canonical = canonicaliseLayoutForPersist(layouts);
396
+ if (!canonical)
397
+ return;
398
+ setSavedLayout(stripAutoHeightRows(canonical));
399
+ }, [canonicaliseLayoutForPersist, setSavedLayout, stripAutoHeightRows]);
268
400
  const onLayoutChange = useCallback((_layout, layouts) => {
269
401
  if (!isEditing)
270
402
  return;
@@ -272,7 +404,7 @@ export function usePageGridLayout({ pageKey, defaultLayouts, defaultCols = 12, l
272
404
  if (layoutCommitFrameRef.current !== null)
273
405
  return;
274
406
  if (typeof window === 'undefined' || typeof window.requestAnimationFrame !== 'function') {
275
- setSavedLayout(stripAutoHeightRows(layouts));
407
+ commitLayout(layouts);
276
408
  return;
277
409
  }
278
410
  // Trailing rAF — lets a burst of resize ticks coalesce into one commit.
@@ -281,10 +413,10 @@ export function usePageGridLayout({ pageKey, defaultLayouts, defaultCols = 12, l
281
413
  const pending = pendingLayoutRef.current;
282
414
  if (pending) {
283
415
  pendingLayoutRef.current = null;
284
- setSavedLayout(stripAutoHeightRows(pending));
416
+ commitLayout(pending);
285
417
  }
286
418
  });
287
- }, [isEditing, setSavedLayout, stripAutoHeightRows]);
419
+ }, [commitLayout, isEditing]);
288
420
  useEffect(() => {
289
421
  return () => {
290
422
  if (layoutCommitFrameRef.current !== null &&
@@ -300,6 +432,23 @@ export function usePageGridLayout({ pageKey, defaultLayouts, defaultCols = 12, l
300
432
  setLayoutCols(newCols);
301
433
  setBaseCols(newCols);
302
434
  }, [currentLayouts, layoutCols, setBaseCols, setLayoutCols, setSavedLayout]);
435
+ /**
436
+ * Build the object handed to the preference adapter from a new `lg` array.
437
+ *
438
+ * Only `lg` and consumer-supplied breakpoints are stored; md/sm/xs/xxs are
439
+ * re-derived on every render. Before 0.8.0 every mutator below spread
440
+ * `currentLayouts` (which contains the derived breakpoints) into its write,
441
+ * which re-froze them one gesture after `rawLayouts` had healed them.
442
+ */
443
+ const layoutsWithLg = useCallback((items) => {
444
+ const next = { lg: items };
445
+ for (const bp of Object.keys(currentLayouts)) {
446
+ if (bp === 'lg' || isDerivedBreakpoint(bp))
447
+ continue;
448
+ next[bp] = currentLayouts[bp] ?? [];
449
+ }
450
+ return next;
451
+ }, [currentLayouts]);
303
452
  const handleCompact = useCallback(() => {
304
453
  if (!currentLayouts.lg)
305
454
  return;
@@ -316,8 +465,8 @@ export function usePageGridLayout({ pageKey, defaultLayouts, defaultCols = 12, l
316
465
  placed.push(compacted);
317
466
  return compacted;
318
467
  });
319
- startTransition(() => setSavedLayout({ ...currentLayouts, lg: result }));
320
- }, [currentLayouts, setSavedLayout]);
468
+ startTransition(() => setSavedLayout(layoutsWithLg(result)));
469
+ }, [currentLayouts, layoutsWithLg, setSavedLayout]);
321
470
  const handleReset = useCallback(() => {
322
471
  startTransition(() => {
323
472
  setSavedLayout(defaultLayouts);
@@ -326,7 +475,6 @@ export function usePageGridLayout({ pageKey, defaultLayouts, defaultCols = 12, l
326
475
  });
327
476
  }, [defaultCols, defaultLayouts, setBaseCols, setLayoutCols, setSavedLayout]);
328
477
  const addWidget = useCallback((widgetKey, initialSize) => {
329
- const bps = Object.keys(currentLayouts);
330
478
  const maxY = (currentLayouts.lg ?? []).reduce((max, item) => Math.max(max, item.y + item.h), 0);
331
479
  const newItem = {
332
480
  i: widgetKey,
@@ -337,60 +485,42 @@ export function usePageGridLayout({ pageKey, defaultLayouts, defaultCols = 12, l
337
485
  minW: initialSize?.minW,
338
486
  minH: initialSize?.minH,
339
487
  };
340
- const updated = { lg: [] };
341
- for (const bp of bps) {
342
- updated[bp] = [...(currentLayouts[bp] ?? []), newItem];
343
- }
344
- startTransition(() => setSavedLayout(updated));
345
- }, [currentLayouts, layoutCols, setSavedLayout]);
488
+ startTransition(() => setSavedLayout(layoutsWithLg([...(currentLayouts.lg ?? []), newItem])));
489
+ }, [currentLayouts, layoutCols, layoutsWithLg, setSavedLayout]);
346
490
  const moveWidget = useCallback((widgetKey, direction) => {
347
- const updated = { lg: [] };
348
- for (const bp of Object.keys(currentLayouts)) {
349
- const items = [...(currentLayouts[bp] ?? [])];
350
- const index = items.findIndex((item) => item.i === widgetKey);
351
- if (index === -1) {
352
- updated[bp] = items;
353
- continue;
354
- }
355
- const targetIndex = direction === 'up' ? index - 1 : index + 1;
356
- if (targetIndex < 0 || targetIndex >= items.length) {
357
- updated[bp] = items;
358
- continue;
359
- }
360
- const moved = items[index];
361
- const withoutMoved = [...items.slice(0, index), ...items.slice(index + 1)];
362
- const nextItems = [
363
- ...withoutMoved.slice(0, targetIndex),
364
- moved,
365
- ...withoutMoved.slice(targetIndex),
366
- ];
367
- updated[bp] = nextItems;
368
- }
369
- startTransition(() => setSavedLayout(updated));
370
- }, [currentLayouts, setSavedLayout]);
491
+ const items = [...(currentLayouts.lg ?? [])];
492
+ const index = items.findIndex((item) => item.i === widgetKey);
493
+ if (index === -1)
494
+ return;
495
+ const targetIndex = direction === 'up' ? index - 1 : index + 1;
496
+ if (targetIndex < 0 || targetIndex >= items.length)
497
+ return;
498
+ const moved = items[index];
499
+ const withoutMoved = [...items.slice(0, index), ...items.slice(index + 1)];
500
+ const nextItems = [
501
+ ...withoutMoved.slice(0, targetIndex),
502
+ moved,
503
+ ...withoutMoved.slice(targetIndex),
504
+ ];
505
+ startTransition(() => setSavedLayout(layoutsWithLg(nextItems)));
506
+ }, [currentLayouts, layoutsWithLg, setSavedLayout]);
371
507
  const setWidgetLocked = useCallback((widgetKey, locked) => {
372
- const updated = { lg: [] };
373
- for (const bp of Object.keys(currentLayouts)) {
374
- updated[bp] = (currentLayouts[bp] ?? []).map((item) => {
375
- if (item.i !== widgetKey)
376
- return item;
377
- return {
378
- ...item,
379
- static: locked,
380
- isDraggable: !locked,
381
- isResizable: !locked,
382
- };
383
- });
384
- }
385
- startTransition(() => setSavedLayout(updated));
386
- }, [currentLayouts, setSavedLayout]);
508
+ const nextItems = (currentLayouts.lg ?? []).map((item) => {
509
+ if (item.i !== widgetKey)
510
+ return item;
511
+ return {
512
+ ...item,
513
+ static: locked,
514
+ isDraggable: !locked,
515
+ isResizable: !locked,
516
+ };
517
+ });
518
+ startTransition(() => setSavedLayout(layoutsWithLg(nextItems)));
519
+ }, [currentLayouts, layoutsWithLg, setSavedLayout]);
387
520
  const removeWidget = useCallback((widgetKey) => {
388
- const updated = { lg: [] };
389
- for (const bp of Object.keys(currentLayouts)) {
390
- updated[bp] = (currentLayouts[bp] ?? []).filter((item) => item.i !== widgetKey);
391
- }
392
- startTransition(() => setSavedLayout(updated));
393
- }, [currentLayouts, setSavedLayout]);
521
+ const nextItems = (currentLayouts.lg ?? []).filter((item) => item.i !== widgetKey);
522
+ startTransition(() => setSavedLayout(layoutsWithLg(nextItems)));
523
+ }, [currentLayouts, layoutsWithLg, setSavedLayout]);
394
524
  /**
395
525
  * Measured auto-height overrides (widgetKey -> rows). DERIVED, in-memory-
396
526
  * only state (quality-review design ruling, 2026-07-14):
@@ -467,6 +597,7 @@ export function usePageGridLayout({ pageKey, defaultLayouts, defaultCols = 12, l
467
597
  activeCompactor,
468
598
  onLayoutChange,
469
599
  handleColumnChange,
600
+ handleBreakpointChange,
470
601
  handleCompact,
471
602
  handleReset,
472
603
  addWidget,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bsuite/page-builder",
3
- "version": "0.7.0",
3
+ "version": "0.9.0",
4
4
  "description": "Shared BSuite responsive page-builder grid and layout persistence primitives",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -23,7 +23,11 @@
23
23
  "import": "./dist/index.js",
24
24
  "types": "./dist/index.d.ts"
25
25
  },
26
- "./styles.css": "./src/styles/react-grid-layout-overrides.css"
26
+ "./styles.css": "./src/styles/react-grid-layout-overrides.css",
27
+ "./scanner": {
28
+ "import": "./dist/scanner/index.js",
29
+ "types": "./dist/scanner/index.d.ts"
30
+ }
27
31
  },
28
32
  "scripts": {
29
33
  "build": "tsc -p tsconfig.build.json",
@@ -70,6 +74,7 @@
70
74
  "react-resizable": "^4.0.1",
71
75
  "typescript": "~6.0.3",
72
76
  "vite": "^8.0.16",
73
- "vitest": "^4.1.8"
77
+ "vitest": "^4.1.8",
78
+ "@types/node": "^25.9.2"
74
79
  }
75
80
  }