diff --git a/web/src/lib/collections/boardColumns.test.ts b/web/src/lib/collections/boardColumns.test.ts index 249033d84..99806fedd 100644 --- a/web/src/lib/collections/boardColumns.test.ts +++ b/web/src/lib/collections/boardColumns.test.ts @@ -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 = {}; + 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); + }); + } +}); diff --git a/web/src/lib/collections/boardColumns.ts b/web/src/lib/collections/boardColumns.ts index 9db6ae4c4..d9dd0dcde 100644 --- a/web/src/lib/collections/boardColumns.ts +++ b/web/src/lib/collections/boardColumns.ts @@ -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 { - const known = new Set(columns); - const result: Record = { [UNCATEGORIZED]: [] }; +): Map { + const result = new Map([[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. * diff --git a/web/src/lib/collections/laneKeyCallers.test.ts b/web/src/lib/collections/laneKeyCallers.test.ts index 44d5bc31f..0d19adc6b 100644 --- a/web/src/lib/collections/laneKeyCallers.test.ts +++ b/web/src/lib/collections/laneKeyCallers.test.ts @@ -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);", diff --git a/web/src/lib/collections/relationGroups.test.ts b/web/src/lib/collections/relationGroups.test.ts index 8fb93699e..a41ee8f3f 100644 --- a/web/src/lib/collections/relationGroups.test.ts +++ b/web/src/lib/collections/relationGroups.test.ts @@ -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. diff --git a/web/src/lib/components/ChildItems.svelte b/web/src/lib/components/ChildItems.svelte index 807696599..922b9b8fb 100644 --- a/web/src/lib/components/ChildItems.svelte +++ b/web/src/lib/components/ChildItems.svelte @@ -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 = $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) { diff --git a/web/src/lib/components/collections/BoardView.prototypeLanes.svelte.test.ts b/web/src/lib/components/collections/BoardView.prototypeLanes.svelte.test.ts new file mode 100644 index 000000000..afe5096d7 --- /dev/null +++ b/web/src/lib/components/collections/BoardView.prototypeLanes.svelte.test.ts @@ -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']); + }); + } +}); diff --git a/web/src/lib/components/collections/BoardView.svelte b/web/src/lib/components/collections/BoardView.svelte index 20c86f8c8..4905d6c61 100644 --- a/web/src/lib/components/collections/BoardView.svelte +++ b/web/src/lib/components/collections/BoardView.svelte @@ -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>({}); 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 = $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 = {}; + 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>) { - 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>) { - 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}
{#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 diff --git a/web/src/lib/components/collections/ListView.svelte b/web/src/lib/components/collections/ListView.svelte index 21f1f762f..711c2b957 100644 --- a/web/src/lib/components/collections/ListView.svelte +++ b/web/src/lib/components/collections/ListView.svelte @@ -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; }