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:
xarmian
2026-09-25 18:23:16 -04:00
committed by GitHub
parent 228abe9702
commit 4e2a7cda9f
8 changed files with 280 additions and 60 deletions
+38 -16
View File
@@ -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);
});
}
});
+28 -9
View File
@@ -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.
*
+16 -5
View File
@@ -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.
+1 -5
View File
@@ -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;
}