Skip to content

Commit 53cf145

Browse files
feat(spa): column lock / freeze — sticky columns + contiguous-prefix invariant
A new column-lock feature on the list page. The pk column was already the "permanently first" column; this lets the operator extend that sticky region to any contiguous prefix of the column list — locked columns stay visible when the table scrolls horizontally, work as frozen columns, and disable drag-and-drop until unlocked. UX summary ---------- - Layout modal's pk row already showed a lock icon; that stays. - Every non-pk row gets a lock button next to its drag handle. Click locks; the contiguous-prefix invariant kicks in automatically. - Locked rows render above the sortable block with their own "unlock" button. Drag handle is removed entirely — to drag a locked column, unlock it first. - The Reset button (#590) now also clears the locked set, so a one-click reset goes back to the ModelAdmin's registered default (which today is "only pk locked"). Contiguous-prefix invariant (the load-bearing rule) --------------------------------------------------- Locked columns MUST form a contiguous prefix from the pk. If they weren't contiguous, the Table primitive's sticky-cluster on the left would have visible gaps where unlocked non-sticky columns scrolled underneath the next locked one — a broken-looking frozen header. Two pure-function helpers in ColumnLayoutModal.tsx enforce that on each click: - `lockThrough(ordered, name)` — locks every non-pk column from the start to `name` inclusive. Used on a Lock click. - `unlockFrom(ordered, locked, name)` — unlocks `name` and every column to its right (the ones above stay whatever they were). Used on an Unlock click. 9 new unit tests pin the semantics: locking through, the first-and-last-column edges, the round-trip lock-then-unlock, the keep-earlier-locked-entries-unchanged case, and the "target not in the order" defensive path. Table primitive — frozen-columns rendering (@dar/ui) --------------------------------------------------- New `sticky?: boolean` on `TableColumn`. When set: - The header `<th>` and every body `<td>` get `position: sticky; left: <px>; z-index: 10` + a background colour so scrolling content doesn't bleed through. - The pixel offset is measured via `useLayoutEffect` over the header refs and accumulated left-to-right, so widths can be whatever the column actually rendered at (no required fixed width). Selection checkbox column counts as the first sticky column when `selectable` is set. - The LAST sticky column gets a subtle right-edge shadow to visually separate the frozen block from the scrolling content. - Sticky cells use `bg-white` (which has a `.dark` remap so the dark mode is automatic) for the header `bg-gray-50` (also remapped). Hooks were carefully placed BEFORE the empty-state early return so the rules-of-hooks linter stays happy across loading / empty / loaded paths. Persistence ----------- New `lockedColsKey(app, model)` localStorage key (`dar:colslocked:<app>:<model>`) — a `Set<string>` of LOCKED non-pk column names (pk is implicitly locked, never stored). Persisted via the existing `usePersistedSet` from @dar/customization, alongside the saved order (`dar:colorder:v1:*`) and the hidden-columns set (`dar:cols:*`). All three preferences evolve independently. Mobile no-op ------------ The mobile `RecordCardList` rendering uses a stacked card layout with no horizontal scroll — the `sticky` flag is meaningless there and is naturally ignored (only the desktop `Table` primitive reads it). Tablet falls in the desktop bucket since it still scrolls horizontally when there are enough columns. Bundle impact ------------- - Main JS: 266.14 → 267.31 kB (+1.2 kB raw / +0.4 kB gz) for the sticky-offset measurement + lockedCols wiring on the page side. - ColumnLayoutModal lazy chunk: 48.79 → 50.69 kB (+1.9 kB raw / +0.5 kB gz) for the lock buttons + the LockedRow variants + the contiguous-prefix invariant helpers. - CSS: 30.86 → 31.37 kB (+0.5 kB) for the sticky-shadow utility. - No new runtime dep — `Lock` and `Unlock` already exist in lucide via existing imports. Tests ----- - Frontend: 154 → 163 tests (+9 for the lock helpers). - Backend: unchanged at 56 passed (no backend code change). No API contract / schema / settings change. Minor bump (1.3.4 → 1.4.0) because of the new public lock UI surface and storage key. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 2cb02d5 commit 53cf145

7 files changed

Lines changed: 421 additions & 80 deletions

File tree

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
// Unit tests for the contiguous-prefix lock invariant (#586). The full
2+
// modal interactions are exercised via the page tests; this file pins
3+
// the pure-function semantics that drive every lock / unlock click.
4+
5+
import { describe, expect, it } from 'vitest';
6+
7+
import { lockThrough, unlockFrom } from './ColumnLayoutModal';
8+
9+
const COLS = ['a', 'b', 'c', 'd', 'e'] as const;
10+
11+
describe('lockThrough', () => {
12+
it('locks every column from the first up to and including the target', () => {
13+
expect([...lockThrough(COLS, 'c')]).toEqual(['a', 'b', 'c']);
14+
});
15+
16+
it('locking the first non-pk column just locks that one column', () => {
17+
expect([...lockThrough(COLS, 'a')]).toEqual(['a']);
18+
});
19+
20+
it('locking the last column locks every non-pk column', () => {
21+
expect([...lockThrough(COLS, 'e')]).toEqual(['a', 'b', 'c', 'd', 'e']);
22+
});
23+
24+
it('returns an empty set when the target name is not in the order', () => {
25+
expect([...lockThrough(COLS, 'missing')]).toEqual([]);
26+
});
27+
});
28+
29+
describe('unlockFrom', () => {
30+
it('unlocks the target and every column to its right (contiguous-prefix rule)', () => {
31+
const locked = new Set(['a', 'b', 'c', 'd']);
32+
expect([...unlockFrom(COLS, locked, 'c')]).toEqual(['a', 'b']);
33+
});
34+
35+
it('unlocking the first non-pk column unlocks everything', () => {
36+
const locked = new Set(['a', 'b', 'c', 'd', 'e']);
37+
expect([...unlockFrom(COLS, locked, 'a')]).toEqual([]);
38+
});
39+
40+
it('keeps earlier locked entries unchanged when they were already locked', () => {
41+
const locked = new Set(['a', 'c']); // odd state, but exercises the keep-earlier rule
42+
expect([...unlockFrom(COLS, locked, 'c')]).toEqual(['a']);
43+
});
44+
45+
it('returns a copy of the input when the target is not in the order', () => {
46+
const locked = new Set(['a', 'b']);
47+
const out = unlockFrom(COLS, locked, 'missing');
48+
expect([...out]).toEqual(['a', 'b']);
49+
expect(out).not.toBe(locked); // a fresh Set, not the input
50+
});
51+
52+
it('round-trip: locking through then unlocking from yields an empty set', () => {
53+
const locked = lockThrough(COLS, 'd');
54+
expect([...unlockFrom(COLS, locked, 'a')]).toEqual([]);
55+
});
56+
});

0 commit comments

Comments
 (0)