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(); + }); });