From 94e2c62a6fec4afc9c869ba5eb51075f430ff2cd Mon Sep 17 00:00:00 2001 From: Brylie Christopher Oxley Date: Sat, 12 Sep 2026 13:18:21 +0300 Subject: [PATCH] Board/Calendar: preserve groupBy on fresh shard connect (closes #217) When a CollectionView block connects to its shard, useCollectionView initially emits a synchronous snapshot before the WebSocket provider has synced with the server, meaning snapshot.collection is undefined and snapshot.schema/rows are empty. In BoardCollectionView and CalendarCollectionView, handleSnapshot was marking autoGroupByAttempted = true and executing autoPickGroupBy against that empty schema. Because the schema was empty, autoPickGroupBy failed to resolve the persisted groupBy property and reset it to undefined, marking the draft dirty and causing Board columns and Calendar dates to render completely empty. A subsequent real snapshot after sync was ignored because autoGroupByAttempted had already been set. In addition, announcer.notify was prematurely baselining against an empty list, erroneously announcing existing records as newly added. Guards handleSnapshot (and TableCollectionView's onSnapshot handler) to return early if snapshot.collection is not yet populated. Adds unit and Tier B Playwright regression coverage. --- src/lib/components/BoardCollectionView.svelte | 5 + .../BoardCollectionView.svelte.test.ts | 23 +++ .../components/CalendarCollectionView.svelte | 5 + .../CalendarCollectionView.svelte.test.ts | 27 +++- src/lib/components/TableCollectionView.svelte | 1 + tests/e2e/tier-b.spec.ts | 144 +++++++++++++++++- 6 files changed, 202 insertions(+), 3 deletions(-) diff --git a/src/lib/components/BoardCollectionView.svelte b/src/lib/components/BoardCollectionView.svelte index 55bd613..832d635 100644 --- a/src/lib/components/BoardCollectionView.svelte +++ b/src/lib/components/BoardCollectionView.svelte @@ -90,6 +90,11 @@ }); function handleSnapshot(snapshot: CollectionViewSnapshot): void { + // A fresh shard connection emits an initial empty snapshot before the + // WebSocket sync completes (snapshot.collection is undefined) — running + // autoPickGroupBy or announcer.notify against that empty doc would wipe + // an already-persisted groupBy and falsely baseline row diffs (issue #217). + if (!snapshot.collection) return; announcer.notify(snapshot.collectionId, snapshot.rows); if (autoGroupByAttempted) return; autoGroupByAttempted = true; diff --git a/src/lib/components/BoardCollectionView.svelte.test.ts b/src/lib/components/BoardCollectionView.svelte.test.ts index 7de6e83..ff1fe70 100644 --- a/src/lib/components/BoardCollectionView.svelte.test.ts +++ b/src/lib/components/BoardCollectionView.svelte.test.ts @@ -99,6 +99,29 @@ describe('BoardCollectionView', () => { expect(screen.getByText('No Status')).toBeInTheDocument(); }); + it('preserves persisted groupBy and renders columns when collection arrives after initial mount (issue #217)', async () => { + const onConfigChange = vi.fn(); + renderBoard('col-1', { sort: { mode: 'manual' }, groupBy: 'status' }, onConfigChange); + + createCollection(ydoc, { + id: 'col-1', + title: 'Board', + schema: [ + { + key: 'status', + label: 'Status', + type: 'select', + options: [{ id: 'todo', label: 'To do' }] + } + ] + }); + + expect(await screen.findByText('To do')).toBeInTheDocument(); + expect(onConfigChange).not.toHaveBeenCalledWith( + expect.objectContaining({ groupBy: undefined }) + ); + }); + it('makes the card title directly editable via its own field', async () => { createCollection(ydoc, { id: 'col-1', diff --git a/src/lib/components/CalendarCollectionView.svelte b/src/lib/components/CalendarCollectionView.svelte index 96dd8a1..e6746a2 100644 --- a/src/lib/components/CalendarCollectionView.svelte +++ b/src/lib/components/CalendarCollectionView.svelte @@ -85,6 +85,11 @@ }); function handleSnapshot(snapshot: CollectionViewSnapshot): void { + // A fresh shard connection emits an initial empty snapshot before the + // WebSocket sync completes (snapshot.collection is undefined) — running + // autoPickGroupBy or announcer.notify against that empty doc would wipe + // an already-persisted date groupBy and falsely baseline event diffs (issue #217). + if (!snapshot.collection) return; announcer.notify(snapshot.collectionId, snapshot.rows); if (autoGroupByAttempted) return; autoGroupByAttempted = true; diff --git a/src/lib/components/CalendarCollectionView.svelte.test.ts b/src/lib/components/CalendarCollectionView.svelte.test.ts index dd2d1c9..8feb66b 100644 --- a/src/lib/components/CalendarCollectionView.svelte.test.ts +++ b/src/lib/components/CalendarCollectionView.svelte.test.ts @@ -24,8 +24,12 @@ vi.mock('$lib/client/yjs-client', () => ({ const actor = { kind: 'human' as const, userId: 'local' }; -function renderCalendar(collectionId: string, initialConfig: ViewConfig = {}) { - return render(CalendarCollectionViewHarness, { collectionId, initialConfig }); +function renderCalendar( + collectionId: string, + initialConfig: ViewConfig = {}, + onConfigChange?: (config: ViewConfig) => void +) { + return render(CalendarCollectionViewHarness, { collectionId, initialConfig, onConfigChange }); } describe('CalendarCollectionView', () => { @@ -68,6 +72,25 @@ describe('CalendarCollectionView', () => { ]); }); + it('preserves persisted groupBy when collection arrives after initial mount (issue #217)', async () => { + const onConfigChange = vi.fn(); + renderCalendar('col-1', { groupBy: 'due' }, onConfigChange); + + createCollection(ydoc, { + id: 'col-1', + title: 'Cal', + schema: [ + { key: 'title', label: 'Title', type: 'text' }, + { key: 'due', label: 'Due', type: 'date' } + ] + }); + + expect(await screen.findByRole('option', { name: 'Due' })).toBeInTheDocument(); + expect(onConfigChange).not.toHaveBeenCalledWith( + expect.objectContaining({ groupBy: undefined }) + ); + }); + it('places a record on its matching day cell', async () => { createCollection(ydoc, { id: 'col-1', diff --git a/src/lib/components/TableCollectionView.svelte b/src/lib/components/TableCollectionView.svelte index 8b158e7..723699a 100644 --- a/src/lib/components/TableCollectionView.svelte +++ b/src/lib/components/TableCollectionView.svelte @@ -91,6 +91,7 @@ () => ydoc, () => connection.resolvedCollectionId ?? collectionId, (snapshot) => { + if (!snapshot.collection) return; announcer.notify(snapshot.collectionId, snapshot.rows); onSnapshot?.(snapshot); } diff --git a/tests/e2e/tier-b.spec.ts b/tests/e2e/tier-b.spec.ts index cb64edd..6beb673 100644 --- a/tests/e2e/tier-b.spec.ts +++ b/tests/e2e/tier-b.spec.ts @@ -1,6 +1,6 @@ import { test, expect } from '@playwright/test'; import { createTestHarness, type TestHarness } from './harness'; -import { createDocument, createRecord } from '$lib/services'; +import { createCollection, createDocument, createRecord } from '$lib/services'; import { flush, resolveWorkspaceContext } from '$lib/server/workspace-store'; import type { ActorId } from '$lib/data/types'; @@ -301,4 +301,146 @@ test.describe('Tier B: DOM-visible MCP/Browser parity', () => { const response = await page.request.get(new URL(faviconHref!, page.url()).toString()); expect(response.status()).toBe(200); }); + + test('Board view groupBy columns render on initial load after fresh connect (issue #217)', async ({ + page + }) => { + const collection = createCollection(human, { + title: 'Sprint Tasks', + schema: [ + { + key: 'status', + label: 'Status', + type: 'select', + options: [ + { id: 'todo', label: 'To Do' }, + { id: 'done', label: 'Done' } + ] + } + ] + }); + createRecord(human, { + parentId: collection.id, + properties: { + status: { type: 'select', value: 'todo' } + } + }); + + const docMeta = createDocument(human, { + title: 'Board Page', + createInitialBlock: false + }); + createRecord(human, { + parentId: docMeta.id, + blockType: 'collection_view', + referencedRecordId: collection.id, + viewConfig: { + viewType: 'board', + groupBy: 'status' + } + }); + flush(); + + // Open document fresh in browser + await page.goto(`${harness.httpUrl}/space/${defaultSpaceId()}/doc/${docMeta.id}`); + + // Columns should render! + await expect(page.getByRole('group', { name: 'To Do column' })).toBeVisible(); + await expect(page.getByRole('group', { name: 'Done column' })).toBeVisible(); + await expect(page.getByRole('group', { name: 'No Status column' })).toBeVisible(); + // No unsaved changes banner + await expect(page.getByText('Unsaved changes')).not.toBeVisible(); + }); + + test('Board view auto-picks groupBy on initial load when unset (issue #217)', async ({ + page + }) => { + const collection = createCollection(human, { + title: 'Sprint Tasks Auto', + schema: [ + { + key: 'status', + label: 'Status', + type: 'select', + options: [ + { id: 'todo', label: 'To Do' }, + { id: 'done', label: 'Done' } + ] + } + ] + }); + createRecord(human, { + parentId: collection.id, + properties: { + status: { type: 'select', value: 'todo' } + } + }); + + const docMeta = createDocument(human, { + title: 'Board Page Auto', + createInitialBlock: false + }); + createRecord(human, { + parentId: docMeta.id, + blockType: 'collection_view', + referencedRecordId: collection.id, + viewConfig: { + viewType: 'board' + } + }); + flush(); + + // Open document fresh in browser + await page.goto(`${harness.httpUrl}/space/${defaultSpaceId()}/doc/${docMeta.id}`); + + // Columns should render automatically via autoPickGroupBy! + await expect(page.getByRole('group', { name: 'To Do column' })).toBeVisible(); + await expect(page.getByRole('group', { name: 'Done column' })).toBeVisible(); + await expect(page.getByRole('group', { name: 'No Status column' })).toBeVisible(); + }); + + test('Calendar view preserves persisted date groupBy on fresh connect (issue #217)', async ({ + page + }) => { + const collection = createCollection(human, { + title: 'Sprint Milestones', + schema: [ + { + key: 'due', + label: 'Due Date', + type: 'date' + } + ] + }); + createRecord(human, { + parentId: collection.id, + properties: { + due: { type: 'date', value: '2026-09-15' } + } + }); + + const docMeta = createDocument(human, { + title: 'Calendar Page', + createInitialBlock: false + }); + createRecord(human, { + parentId: docMeta.id, + blockType: 'collection_view', + referencedRecordId: collection.id, + viewConfig: { + viewType: 'calendar', + groupBy: 'due' + } + }); + flush(); + + // Open document fresh in browser + await page.goto(`${harness.httpUrl}/space/${defaultSpaceId()}/doc/${docMeta.id}`); + + // "Dates from" dropdown should show "Due Date" and no unsaved changes + const select = page.locator(`select[id="calendar-date-property-${collection.id}"]`); + await expect(select).toBeVisible(); + await expect(select).toHaveValue('due'); + await expect(page.getByText('Unsaved changes')).not.toBeVisible(); + }); });