mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-26 06:19:49 +00:00
fix(web): a board lane named __proto__ or constructor keeps its own bucket and sort, so one option can no longer un-proxy the whole board (BUG-3208) (#1549)
bucketByColumn built a plain object keyed by schema option values. An option
named __proto__ did not create a lane: the assignment re-parented the object
onto that lane's array, and because BoardView assigns the result to $state,
and Svelte 5 proxies only Object.prototype objects, the whole board lost its
drag reactivity. The per-lane sort overrides had the same shape, wider: a
bare overrides[value] ?? sortMode read the inherited member for every
Object.prototype name, so a lane named constructor or toString ignored the
page sort and lost its reorder menu.
- bucketByColumn returns a Map.
- boardColumns exports laneKey ('lane:' prefix, the BUG-3054 answer), and
BoardView keys columnData and laneSortOverrides through it.
Object.create(null) is out because these records are $state.
- ListView and ChildItems import the shared laneKey. Their private copies are
gone, and laneKeyCallers.test.ts forbids a private copy on all three
surfaces.
Census of keyed-by-lane-value objects on the board path (full table on the
BUG-3208 trail): two live members fixed (the bucket record and the sort
overrides). Draft maps (draftKey is field-prefixed), relationLaneByValue (a
Map), column colours (hasOwn, BUG-3041), the public board (Map buckets) and
the roles board (keyed by role UUID) are safe.
This commit is contained in:
@@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest';
|
||||
import type { Item } from '$lib/types';
|
||||
import {
|
||||
bucketByColumn,
|
||||
laneKey,
|
||||
formatLaneLabel,
|
||||
isUngrouped,
|
||||
laneValue,
|
||||
@@ -23,24 +24,24 @@ describe('bucketByColumn', () => {
|
||||
item('c', { status: 'open' })
|
||||
];
|
||||
const result = bucketByColumn(items, 'status', columns);
|
||||
expect(result['open'].map((i) => i.id)).toEqual(['a', 'c']);
|
||||
expect(result['done'].map((i) => i.id)).toEqual(['b']);
|
||||
expect(result['in_progress']).toEqual([]);
|
||||
expect(result.get('open')!.map((i) => i.id)).toEqual(['a', 'c']);
|
||||
expect(result.get('done')!.map((i) => i.id)).toEqual(['b']);
|
||||
expect(result.get('in_progress')!).toEqual([]);
|
||||
});
|
||||
|
||||
it('always seeds every known column and the uncategorized bucket', () => {
|
||||
const result = bucketByColumn([], 'status', columns);
|
||||
expect(Object.keys(result).sort()).toEqual(
|
||||
expect([...result.keys()].sort()).toEqual(
|
||||
[UNCATEGORIZED, 'done', 'in_progress', 'open'].sort()
|
||||
);
|
||||
expect(result[UNCATEGORIZED]).toEqual([]);
|
||||
expect(result.get(UNCATEGORIZED)!).toEqual([]);
|
||||
});
|
||||
|
||||
it('collects items with an empty group value into UNCATEGORIZED', () => {
|
||||
const items = [item('a', { status: '' }), item('b', { status: 'open' })];
|
||||
const result = bucketByColumn(items, 'status', columns);
|
||||
expect(result[UNCATEGORIZED].map((i) => i.id)).toEqual(['a']);
|
||||
expect(result['open'].map((i) => i.id)).toEqual(['b']);
|
||||
expect(result.get(UNCATEGORIZED)!.map((i) => i.id)).toEqual(['a']);
|
||||
expect(result.get('open')!.map((i) => i.id)).toEqual(['b']);
|
||||
});
|
||||
|
||||
it('treats a missing/null group value as uncategorized', () => {
|
||||
@@ -50,20 +51,20 @@ describe('bucketByColumn', () => {
|
||||
item('c', { priority: 'high' }) // unrelated field only
|
||||
];
|
||||
const result = bucketByColumn(items, 'status', columns);
|
||||
expect(result[UNCATEGORIZED].map((i) => i.id)).toEqual(['a', 'b', 'c']);
|
||||
expect(result.get(UNCATEGORIZED)!.map((i) => i.id)).toEqual(['a', 'b', 'c']);
|
||||
});
|
||||
|
||||
it('collects items whose value is not a known option (stale/removed) into UNCATEGORIZED', () => {
|
||||
const items = [item('a', { status: 'archived' }), item('b', { status: 'open' })];
|
||||
const result = bucketByColumn(items, 'status', columns);
|
||||
expect(result[UNCATEGORIZED].map((i) => i.id)).toEqual(['a']);
|
||||
expect(result['open'].map((i) => i.id)).toEqual(['b']);
|
||||
expect(result.get(UNCATEGORIZED)!.map((i) => i.id)).toEqual(['a']);
|
||||
expect(result.get('open')!.map((i) => i.id)).toEqual(['b']);
|
||||
});
|
||||
|
||||
it('leaves the uncategorized bucket empty when every item is categorized', () => {
|
||||
const items = [item('a', { status: 'open' }), item('b', { status: 'done' })];
|
||||
const result = bucketByColumn(items, 'status', columns);
|
||||
expect(result[UNCATEGORIZED]).toEqual([]);
|
||||
expect(result.get(UNCATEGORIZED)!).toEqual([]);
|
||||
});
|
||||
|
||||
it('honours the chosen group field, not always status', () => {
|
||||
@@ -72,8 +73,8 @@ describe('bucketByColumn', () => {
|
||||
item('b', { status: 'open' }) // no impact → uncategorized under impact grouping
|
||||
];
|
||||
const result = bucketByColumn(items, 'impact', ['low', 'medium', 'high']);
|
||||
expect(result['high'].map((i) => i.id)).toEqual(['a']);
|
||||
expect(result[UNCATEGORIZED].map((i) => i.id)).toEqual(['b']);
|
||||
expect(result.get('high')!.map((i) => i.id)).toEqual(['a']);
|
||||
expect(result.get(UNCATEGORIZED)!.map((i) => i.id)).toEqual(['b']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -162,12 +163,33 @@ describe('bucketByColumn with falsy-but-present values', () => {
|
||||
// truthiness test, so '0' is truthy — but pin it, because this is the
|
||||
// behaviour ListView now shares and a change here would move both.
|
||||
const result = bucketByColumn([item('a', { score: 0 })], 'score', ['0']);
|
||||
expect(result['0'].map((i) => i.id)).toEqual(['a']);
|
||||
expect(result[UNCATEGORIZED]).toEqual([]);
|
||||
expect(result.get('0')!.map((i) => i.id)).toEqual(['a']);
|
||||
expect(result.get(UNCATEGORIZED)!).toEqual([]);
|
||||
});
|
||||
|
||||
it('files a 0 with no matching column under Uncategorized, still visible', () => {
|
||||
const result = bucketByColumn([item('a', { score: 0 })], 'score', columns);
|
||||
expect(result[UNCATEGORIZED].map((i) => i.id)).toEqual(['a']);
|
||||
expect(result.get(UNCATEGORIZED)!.map((i) => i.id)).toEqual(['a']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('lanes named after an Object.prototype member (BUG-3208)', () => {
|
||||
const NAMES = ['__proto__', 'constructor', 'toString', 'hasOwnProperty', 'valueOf'];
|
||||
|
||||
for (const name of NAMES) {
|
||||
it(`bucketByColumn gives "${name}" its own lane`, () => {
|
||||
const result = bucketByColumn([item('a', { stage: name })], 'stage', [name, 'open']);
|
||||
expect([...result.keys()].sort()).toEqual([UNCATEGORIZED, name, 'open'].sort());
|
||||
expect(result.get(name)!.map((i) => i.id)).toEqual(['a']);
|
||||
expect(result.get(UNCATEGORIZED)).toEqual([]);
|
||||
});
|
||||
|
||||
it(`laneKey makes "${name}" an own key of a plain record`, () => {
|
||||
const record: Record<string, string[]> = {};
|
||||
expect(record[laneKey(name)], 'nothing inherited under the key').toBeUndefined();
|
||||
record[laneKey(name)] = ['x'];
|
||||
expect(Object.getPrototypeOf(record), 'the record is not re-parented').toBe(Object.prototype);
|
||||
expect(Object.hasOwn(record, laneKey(name))).toBe(true);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -70,6 +70,14 @@ export function isUngrouped(value: unknown): boolean {
|
||||
* rather than being dropped. The returned map always contains the '' key
|
||||
* (possibly empty) — callers decide whether to render the lane based on its
|
||||
* length, so it only appears "if needed".
|
||||
*
|
||||
* A `Map`, not a plain object (BUG-3208). The keys are schema option VALUES,
|
||||
* and a plain object keyed by them does not create a lane for `__proto__`:
|
||||
* `result['__proto__'] = []` RE-PARENTS the object onto that array, the lane
|
||||
* is missing from `Object.keys`, and a caller that puts the result in `$state`
|
||||
* loses proxying for the WHOLE board, because Svelte proxies only objects whose
|
||||
* prototype is Object.prototype. A caller that needs a `$state` record keys it
|
||||
* by `laneKey(value)` instead.
|
||||
*/
|
||||
export function bucketByColumn(
|
||||
items: Item[],
|
||||
@@ -83,23 +91,34 @@ export function bucketByColumn(
|
||||
* the field's own value, so every existing caller is unchanged.
|
||||
*/
|
||||
valueFor?: (item: Item) => string
|
||||
): Record<string, Item[]> {
|
||||
const known = new Set(columns);
|
||||
const result: Record<string, Item[]> = { [UNCATEGORIZED]: [] };
|
||||
): Map<string, Item[]> {
|
||||
const result = new Map<string, Item[]>([[UNCATEGORIZED, []]]);
|
||||
for (const col of columns) {
|
||||
result[col] = [];
|
||||
result.set(col, []);
|
||||
}
|
||||
for (const item of items) {
|
||||
const value = valueFor ? valueFor(item) : laneValue(parseFields(item)[groupField]);
|
||||
if (value && known.has(value)) {
|
||||
result[value].push(item);
|
||||
} else {
|
||||
result[UNCATEGORIZED].push(item);
|
||||
}
|
||||
const lane = value ? result.get(value) : undefined;
|
||||
(lane ?? result.get(UNCATEGORIZED)!).push(item);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* The key a lane's value is stored under in a plain-object lane record.
|
||||
*
|
||||
* A prefix no Object.prototype member starts with, so every value is an OWN
|
||||
* key: `__proto__` creates a lane instead of re-parenting the record, and
|
||||
* `constructor` or `toString` read as absent instead of finding the inherited
|
||||
* function (BUG-3208, the board's copy of BUG-3054). Not `Object.create(null)`:
|
||||
* these records are `$state`, and Svelte proxies only objects whose prototype
|
||||
* is Object.prototype, so a null-prototype record would silently lose its
|
||||
* reactivity.
|
||||
*/
|
||||
export function laneKey(value: string): string {
|
||||
return `lane:${value}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The heading a lane shows for its key.
|
||||
*
|
||||
|
||||
@@ -34,16 +34,25 @@ const SURFACES: { name: string; source: string; uses: string[]; forbidden: strin
|
||||
{
|
||||
name: 'ListView',
|
||||
source: read('../components/collections/ListView.svelte'),
|
||||
uses: ['laneValue', 'isUngrouped', 'formatLaneLabel'],
|
||||
forbidden: ['formatLabel', 'laneValue', 'isUngrouped', 'inlinedLaneValue'],
|
||||
uses: ['laneValue', 'isUngrouped', 'formatLaneLabel', 'laneKey'],
|
||||
forbidden: ['formatLabel', 'laneValue', 'isUngrouped', 'inlinedLaneValue', 'laneKey'],
|
||||
},
|
||||
{
|
||||
name: 'BoardView',
|
||||
source: read('../components/collections/BoardView.svelte'),
|
||||
// Buckets through `bucketByColumn`, which normalises internally, so it
|
||||
// needs the label but not the pair.
|
||||
uses: ['formatLaneLabel'],
|
||||
forbidden: ['formatLabel', 'laneValue', 'isUngrouped', 'inlinedLaneValue'],
|
||||
// needs the label but not the pair. `laneKey` keys its `$state` lane
|
||||
// records (BUG-3208).
|
||||
uses: ['formatLaneLabel', 'laneKey'],
|
||||
forbidden: ['formatLabel', 'laneValue', 'isUngrouped', 'inlinedLaneValue', 'laneKey'],
|
||||
},
|
||||
{
|
||||
// Groups children by status into a `$state` record (BUG-3054), with a
|
||||
// private `laneKey` until BUG-3208 moved the one copy into boardColumns.
|
||||
name: 'ChildItems',
|
||||
source: read('../components/ChildItems.svelte'),
|
||||
uses: ['laneKey'],
|
||||
forbidden: ['laneKey'],
|
||||
},
|
||||
{
|
||||
name: 'shareView (public board + list)',
|
||||
@@ -100,6 +109,7 @@ describe('no surface keeps a private copy of a lane-key helper', () => {
|
||||
formatLabel: { pattern: /function\s+formatLabel\s*\(/, what: 'a private formatLabel' },
|
||||
laneValue: { pattern: /function\s+laneValue\s*\(/, what: 'a private laneValue' },
|
||||
isUngrouped: { pattern: /function\s+isUngrouped\s*\(/, what: 'a private isUngrouped' },
|
||||
laneKey: { pattern: /function\s+laneKey\s*\(/, what: 'a private laneKey' },
|
||||
inlinedLaneValue: {
|
||||
// The inlined normalisation, in the exact spelling all four copies used.
|
||||
pattern: /typeof\s+\w+\s*===\s*'string'\s*\?\s*\w+\s*:\s*\w+\s*==\s*null\s*\?\s*''/,
|
||||
@@ -130,6 +140,7 @@ describe('no surface keeps a private copy of a lane-key helper', () => {
|
||||
expect(/function\s+formatLabel\s*\(/.test('\tfunction formatLabel(value: string): string {')).toBe(
|
||||
true,
|
||||
);
|
||||
expect(/function\s+laneKey\s*\(/.test('\tfunction laneKey(status: string): string {')).toBe(true);
|
||||
expect(
|
||||
/typeof\s+\w+\s*===\s*'string'\s*\?\s*\w+\s*:\s*\w+\s*==\s*null\s*\?\s*''/.test(
|
||||
"const value = typeof raw === 'string' ? raw : raw == null ? '' : String(raw);",
|
||||
|
||||
@@ -119,7 +119,7 @@ describe('relationLaneValueFor + bucketByColumn', () => {
|
||||
|
||||
const buckets = bucketByColumn(rewritten, 'car', lanes.map((l) => l.value));
|
||||
|
||||
const placements = Object.entries(buckets).flatMap(([lane, list]) =>
|
||||
const placements = [...buckets].flatMap(([lane, list]) =>
|
||||
list.map((i) => ({ id: i.id, lane })),
|
||||
);
|
||||
// Exactly one placement per item, and no item lost.
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
import type { Item, Collection, PaneTarget } from '$lib/types';
|
||||
import { parseFields, parseSchema, formatItemRef } from '$lib/types';
|
||||
import { collectionsNotStaleFor, categoricalValueFor } from '$lib/collections/categoricalFieldValue';
|
||||
import { laneKey } from '$lib/collections/boardColumns';
|
||||
import { dndzone, TRIGGERS, SHADOW_ITEM_MARKER_PROPERTY_NAME } from 'svelte-dnd-action';
|
||||
import type { DndEvent } from 'svelte-dnd-action';
|
||||
import {
|
||||
@@ -160,11 +161,6 @@
|
||||
*/
|
||||
let groupData: Record<string, Item[]> = $state({});
|
||||
|
||||
/** The `groupData` key for a status (BUG-3054; see `groupData`). */
|
||||
function laneKey(status: string): string {
|
||||
return `lane:${status}`;
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
const g = groups;
|
||||
if (!isDragging) {
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
// BUG-3208 — board lanes named after an Object.prototype member.
|
||||
//
|
||||
// Two records on the board were keyed by a raw option VALUE:
|
||||
//
|
||||
// 1. The lane buckets (`bucketByColumn` → `columnData`). An option named
|
||||
// `__proto__` did not create a lane: the assignment RE-PARENTED the record
|
||||
// onto that lane's array. The card still rendered, because the read went
|
||||
// through the prototype and found the array — which is why a leg asserting
|
||||
// "the card is in its lane" passes on the defect. What broke is that
|
||||
// `$state` proxies only objects whose prototype is Object.prototype, so the
|
||||
// whole record was left unproxied and no lane on the board re-rendered on a
|
||||
// drag. The drag leg below is the discriminating one.
|
||||
// 2. The per-lane sort overrides. A bare `overrides[value] ?? sortMode` finds
|
||||
// the INHERITED member for every Object.prototype name, so a lane named
|
||||
// `constructor` read a function as its override and ignored the page sort.
|
||||
//
|
||||
// svelte-dnd-action dispatches `consider` / `finalize` CustomEvents and the
|
||||
// component binds them as plain DOM handlers, so a drag is driven directly.
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { render, cleanup } from '@testing-library/svelte';
|
||||
import { tick } from 'svelte';
|
||||
import type { Collection, Item } from '$lib/types';
|
||||
|
||||
vi.mock('$lib/stores/localIndex.svelte', () => ({
|
||||
localIndex: { findByIdOrSlug: () => null, getByCollection: () => [] },
|
||||
}));
|
||||
vi.mock('$lib/stores/collections.svelte', () => ({
|
||||
collectionStore: { collections: [{ slug: 'cars' }] },
|
||||
}));
|
||||
|
||||
import BoardView from './BoardView.svelte';
|
||||
|
||||
function collection(options: string[]): Collection {
|
||||
return {
|
||||
id: 'c1',
|
||||
workspace_id: 'ws1',
|
||||
name: 'Cars',
|
||||
slug: 'cars',
|
||||
icon: '',
|
||||
description: '',
|
||||
schema: JSON.stringify({ fields: [{ key: 'stage', label: 'Stage', type: 'select', options }] }),
|
||||
settings: JSON.stringify({}),
|
||||
sort_order: 0,
|
||||
is_default: true,
|
||||
is_system: false,
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
updated_at: '2026-01-01T00:00:00Z',
|
||||
prefix: 'CAR',
|
||||
} as unknown as Collection;
|
||||
}
|
||||
|
||||
function item(id: string, stage: string, sortOrder = 0): Item {
|
||||
return {
|
||||
id,
|
||||
workspace_id: 'ws1',
|
||||
collection_id: 'c1',
|
||||
slug: id,
|
||||
title: id,
|
||||
content: '',
|
||||
fields: JSON.stringify({ stage }),
|
||||
tags: '[]',
|
||||
sort_order: sortOrder,
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
updated_at: '2026-01-01T00:00:00Z',
|
||||
} as unknown as Item;
|
||||
}
|
||||
|
||||
function renderBoard(options: string[], items: Item[], sortMode = 'manual') {
|
||||
return render(BoardView, {
|
||||
props: {
|
||||
items,
|
||||
collection: collection(options),
|
||||
wsSlug: 'ws',
|
||||
groupField: 'stage',
|
||||
canEdit: true,
|
||||
sortMode,
|
||||
onLaneChange: vi.fn(),
|
||||
} as never,
|
||||
});
|
||||
}
|
||||
|
||||
function laneTitles(container: HTMLElement): string[] {
|
||||
return [...container.querySelectorAll('.column-name')].map((el) => el.textContent?.trim() ?? '');
|
||||
}
|
||||
|
||||
/** The card ids rendered in a lane's drop zone, in order. */
|
||||
function cardsIn(container: HTMLElement, laneIndex: number): string[] {
|
||||
const zone = container.querySelectorAll('.column-cards')[laneIndex];
|
||||
return [...zone.querySelectorAll('.card-wrapper')].map(
|
||||
(el) => el.querySelector('[data-item-id]')?.getAttribute('data-item-id') ?? el.textContent?.trim() ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
function laneIndex(container: HTMLElement, title: string): number {
|
||||
const i = laneTitles(container).indexOf(title);
|
||||
expect(i, `lane "${title}" should render; lanes are ${JSON.stringify(laneTitles(container))}`).toBeGreaterThanOrEqual(0);
|
||||
return i;
|
||||
}
|
||||
|
||||
afterEach(() => cleanup());
|
||||
|
||||
describe('an option named __proto__ does not un-proxy the board (BUG-3208)', () => {
|
||||
it('re-renders a drag in another lane when one lane is named __proto__', async () => {
|
||||
const a = item('car-a', 'open');
|
||||
const p = item('car-p', '__proto__');
|
||||
const { container } = renderBoard(['__proto__', 'open'], [a, p]);
|
||||
|
||||
const open = laneIndex(container, 'Open');
|
||||
expect(cardsIn(container, open)).toHaveLength(1);
|
||||
|
||||
// Drag the only card out of "open": the library's consider event hands
|
||||
// the lane its new (empty) list. A proxied record re-renders; the
|
||||
// re-parented one did not, and the card stayed on screen.
|
||||
container.querySelectorAll('.column-cards')[open].dispatchEvent(
|
||||
new CustomEvent('consider', {
|
||||
detail: { items: [], info: { trigger: 'dragStarted', id: 'car-a' } },
|
||||
}),
|
||||
);
|
||||
await tick();
|
||||
|
||||
expect(cardsIn(container, open), 'the drag should re-render the lane').toHaveLength(0);
|
||||
});
|
||||
|
||||
it('control: the same drag re-renders when no lane is named __proto__', async () => {
|
||||
// Without this the leg above could pass on a harness where consider never
|
||||
// re-renders anything, and fail on the fix for the same reason.
|
||||
const a = item('car-a', 'open');
|
||||
const { container } = renderBoard(['closed', 'open'], [a]);
|
||||
|
||||
const open = laneIndex(container, 'Open');
|
||||
container.querySelectorAll('.column-cards')[open].dispatchEvent(
|
||||
new CustomEvent('consider', {
|
||||
detail: { items: [], info: { trigger: 'dragStarted', id: 'car-a' } },
|
||||
}),
|
||||
);
|
||||
await tick();
|
||||
|
||||
expect(cardsIn(container, open)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('a lane named after an Object.prototype member follows the page sort (BUG-3208)', () => {
|
||||
// Manual order (sort_order) is z-car, then a-car; title order is the reverse.
|
||||
for (const name of ['constructor', 'toString', '__proto__']) {
|
||||
it(`sorts the "${name}" lane by the page-wide title sort`, () => {
|
||||
const z = item('z-car', name, 0);
|
||||
const a = item('a-car', name, 1);
|
||||
const { container } = renderBoard([name, 'open'], [z, a], 'title');
|
||||
|
||||
const lane = [...container.querySelectorAll('.column-cards')].findIndex(
|
||||
(zone) => zone.querySelectorAll('.card-wrapper').length === 2,
|
||||
);
|
||||
expect(lane, 'the lane holding both cards should render').toBeGreaterThanOrEqual(0);
|
||||
const order = [...container.querySelectorAll('.column-cards')[lane].querySelectorAll('.card-wrapper')].map(
|
||||
(card) => (card.textContent?.includes('a-car') ? 'a-car' : card.textContent?.includes('z-car') ? 'z-car' : '?'),
|
||||
);
|
||||
expect(order, 'title sort puts a-car first').toEqual(['a-car', 'z-car']);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -7,6 +7,7 @@
|
||||
import {
|
||||
bucketByColumn,
|
||||
formatLaneLabel,
|
||||
laneKey,
|
||||
laneValue,
|
||||
UNCATEGORIZED,
|
||||
} from '$lib/collections/boardColumns';
|
||||
@@ -173,15 +174,20 @@
|
||||
// Ephemeral per-lane sort overrides (TASK-1673): a lane sorts by its
|
||||
// override when set, else the page-wide `sortMode`. Not persisted —
|
||||
// cleared on reload. Available to everyone (sort is a view preference).
|
||||
// Keyed by `laneKey(value)` (BUG-3208): a bare option value finds the
|
||||
// INHERITED member for every Object.prototype name, so a lane named
|
||||
// `constructor` read a function as its override, ignored the page sort and
|
||||
// lost its reorder menu.
|
||||
let laneSortOverrides = $state<Record<string, SortMode>>({});
|
||||
function setLaneSort(colValue: string, mode: SortMode | null) {
|
||||
if (mode === null) {
|
||||
delete laneSortOverrides[colValue];
|
||||
delete laneSortOverrides[laneKey(colValue)];
|
||||
} else {
|
||||
laneSortOverrides[colValue] = mode;
|
||||
laneSortOverrides[laneKey(colValue)] = mode;
|
||||
}
|
||||
}
|
||||
const laneSortFor = (colValue: string): SortMode => laneSortOverrides[colValue] ?? sortMode;
|
||||
const laneSortFor = (colValue: string): SortMode =>
|
||||
laneSortOverrides[laneKey(colValue)] ?? sortMode;
|
||||
|
||||
// ── Inline draft cards (TASK-1676) ──────────────────────────────────
|
||||
// Trello/GitHub-style: the `+` opens an editable draft card in the
|
||||
@@ -440,12 +446,19 @@
|
||||
}
|
||||
|
||||
let isDragging = $state(false);
|
||||
/**
|
||||
* Lanes keyed by `laneKey(value)`, never by the raw option value (BUG-3208).
|
||||
* An option named `__proto__` re-parented a raw-keyed record instead of
|
||||
* creating a lane, and a re-parented record is not proxied by `$state`, so
|
||||
* the whole board lost its drag reactivity. Read and write it only through
|
||||
* `laneKey`.
|
||||
*/
|
||||
let columnData: Record<string, Item[]> = $state({});
|
||||
|
||||
let propColumnData = $derived.by(() => {
|
||||
// Bucket items into their lanes, routing empty/unknown-value items
|
||||
// into the UNCATEGORIZED ('') lane instead of dropping them (IDEA-2275).
|
||||
const result = bucketByColumn(
|
||||
const buckets = bucketByColumn(
|
||||
items,
|
||||
groupField,
|
||||
columns,
|
||||
@@ -457,10 +470,14 @@
|
||||
// else the page-wide sort (TASK-1670 / TASK-1673); 'manual'
|
||||
// resolves to the stored sort_order, preserving prior behavior.
|
||||
if (!preserveOrder) {
|
||||
for (const key of Object.keys(result)) {
|
||||
result[key].sort(itemComparator(laneSortFor(key), collection));
|
||||
for (const [value, list] of buckets) {
|
||||
list.sort(itemComparator(laneSortFor(value), collection));
|
||||
}
|
||||
}
|
||||
const result: Record<string, Item[]> = {};
|
||||
for (const [value, list] of buckets) {
|
||||
result[laneKey(value)] = list;
|
||||
}
|
||||
return result;
|
||||
});
|
||||
|
||||
@@ -479,7 +496,7 @@
|
||||
const data = propColumnData;
|
||||
if (!isDragging && !dropCooldown) {
|
||||
columnData = data;
|
||||
showUncategorized = (data[UNCATEGORIZED]?.length ?? 0) > 0;
|
||||
showUncategorized = (data[laneKey(UNCATEGORIZED)]?.length ?? 0) > 0;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -515,7 +532,7 @@
|
||||
let navColumns = $derived(
|
||||
renderColumns.map((col) => ({
|
||||
value: col,
|
||||
items: (columnData[col] ?? []).filter((i: any) => !i[SHADOW_ITEM_MARKER_PROPERTY_NAME]),
|
||||
items: (columnData[laneKey(col)] ?? []).filter((i: any) => !i[SHADOW_ITEM_MARKER_PROPERTY_NAME]),
|
||||
}))
|
||||
);
|
||||
$effect(() => {
|
||||
@@ -523,7 +540,7 @@
|
||||
});
|
||||
|
||||
function handleConsider(columnValue: string, e: CustomEvent<DndEvent<Item>>) {
|
||||
columnData[columnValue] = e.detail.items;
|
||||
columnData[laneKey(columnValue)] = e.detail.items;
|
||||
if (!isDragging && e.detail.info.trigger === TRIGGERS.DRAG_STARTED) {
|
||||
if (typeof navigator !== 'undefined' && navigator.vibrate) {
|
||||
navigator.vibrate(50);
|
||||
@@ -533,7 +550,7 @@
|
||||
}
|
||||
|
||||
async function handleFinalize(columnValue: string, e: CustomEvent<DndEvent<Item>>) {
|
||||
columnData[columnValue] = e.detail.items;
|
||||
columnData[laneKey(columnValue)] = e.detail.items;
|
||||
|
||||
const { id: itemId, trigger } = e.detail.info;
|
||||
isDragging = false;
|
||||
@@ -654,7 +671,7 @@
|
||||
if (moveSucceeded) {
|
||||
// Only persist reorder after a successful move.
|
||||
if (onReorder) {
|
||||
const order = placement === 'top' ? (columnData[targetColumn] ?? []) : placement;
|
||||
const order = placement === 'top' ? (columnData[laneKey(targetColumn)] ?? []) : placement;
|
||||
const reorderUpdates = order
|
||||
.filter((i: any) => !i[SHADOW_ITEM_MARKER_PROPERTY_NAME])
|
||||
.map((it, index) => ({ slug: it.id, sort_order: index }));
|
||||
@@ -674,7 +691,7 @@
|
||||
// lanes. Scope is the item's own lane, matching the drag handler.
|
||||
function reorderItem(columnValue: string, item: Item, dir: ReorderDirection) {
|
||||
if (!onReorder) return;
|
||||
const grp = (columnData[columnValue] ?? []).filter(
|
||||
const grp = (columnData[laneKey(columnValue)] ?? []).filter(
|
||||
(i: any) => !i[SHADOW_ITEM_MARKER_PROPERTY_NAME]
|
||||
);
|
||||
const updates = reorderGroup(grp, item.id, dir);
|
||||
@@ -698,10 +715,10 @@
|
||||
const target = adjacentColumn(renderColumns, columnValue, dir);
|
||||
if (target === null) return;
|
||||
|
||||
const source = (columnData[columnValue] ?? []).filter((i) => i.id !== item.id);
|
||||
const dest = [item, ...(columnData[target] ?? []).filter((i) => i.id !== item.id)];
|
||||
columnData[columnValue] = source;
|
||||
columnData[target] = dest;
|
||||
const source = (columnData[laneKey(columnValue)] ?? []).filter((i) => i.id !== item.id);
|
||||
const dest = [item, ...(columnData[laneKey(target)] ?? []).filter((i) => i.id !== item.id)];
|
||||
columnData[laneKey(columnValue)] = source;
|
||||
columnData[laneKey(target)] = dest;
|
||||
|
||||
commitColumnMove(item, target, 'top');
|
||||
}
|
||||
@@ -806,7 +823,7 @@
|
||||
{/each}
|
||||
<div class="board-view">
|
||||
{#each renderColumns as colValue (colValue)}
|
||||
{@const colItems = columnData[colValue] ?? []}
|
||||
{@const colItems = columnData[laneKey(colValue)] ?? []}
|
||||
{@const isUncategorized = colValue === UNCATEGORIZED}
|
||||
{@const relLane = relationLaneByValue.get(colValue)}
|
||||
{@const laneName = relLane ? (relLane.title ?? relLane.label) : formatLaneLabel(colValue)}
|
||||
@@ -897,7 +914,7 @@
|
||||
{members}
|
||||
{tagSuggestions}
|
||||
{sortMode}
|
||||
laneSort={laneSortOverrides[colValue]}
|
||||
laneSort={laneSortOverrides[laneKey(colValue)]}
|
||||
onSetLaneSort={(m) => setLaneSort(colValue, m)}
|
||||
onClose={closeMenu}
|
||||
onAddItem={onCreateInColumn && !isUncategorized && !isRelationGroup && !groupingRefusal
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
import type { Item, Collection } from '$lib/types';
|
||||
import { parseSchema, parseFields } from '$lib/types';
|
||||
import { itemComparator, type SortMode } from '$lib/collections/itemSort';
|
||||
import { formatLaneLabel, isUngrouped, laneValue } from '$lib/collections/boardColumns';
|
||||
import { formatLaneLabel, isUngrouped, laneKey, laneValue } from '$lib/collections/boardColumns';
|
||||
import { reorderGroup, disabledDirections, type ReorderDirection } from '$lib/collections/reorder';
|
||||
import {
|
||||
narrowRelationRow,
|
||||
@@ -419,11 +419,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
/** The `groupData` key for a lane value (BUG-3054; see `groupData`). */
|
||||
function laneKey(value: string): string {
|
||||
return `lane:${value}`;
|
||||
}
|
||||
|
||||
function itemCount(groupItems: Item[]): number {
|
||||
return groupItems.filter((i: any) => !i[SHADOW_ITEM_MARKER_PROPERTY_NAME]).length;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user