canvas-slice.test.ts8.6 KBView on GitHub /**
* Canvas Slice — upsertCanvas dirty check & owner filter clearing
*
* Tests the two canvas-slice behaviors that were fixed:
* 1. upsertCanvas must not overwrite a canvas that has unsaved local changes (dirty).
* 2. Removing the primaryUser filter must atomically clear viewConfig.ownerUserIds.
*/
import { create } from 'zustand';
import type { Canvas } from '@/modules/canvas/types/canvas-types';
import type { ConversationViewConfig } from '@/modules/canvas/types/canvas-types';
import type { CanvasFilterSortConfiguration } from '@/modules/cedar-os/src/store/messages/MessageTypes';
// ─── Minimal test canvas factory ─────────────────────────────────────────────
function makeCanvas(id: string, overrides: Partial<Canvas> = {}): Canvas {
return {
id,
type: 'conversationCanvas',
title: 'Test Canvas',
data: {},
primaryOwner: 'user1',
visibility: 'private',
homeViewOrder: 1,
viewConfig: { type: 'conversationCanvas' },
createdAt: '2024-01-01T00:00:00Z',
updatedAt: '2024-01-01T00:00:00Z',
...overrides,
} as Canvas;
}
// ─── Minimal store that mirrors the canvasSlice logic under test ───────────────
interface TestStore {
canvasesById: Record<string, Canvas>;
dirtyCanvasIds: Set<string>;
/** Mirrors canvasSlice.upsertCanvas — skips dirty canvases */
upsertCanvas: (canvas: Canvas) => void;
/** Mirrors canvasSlice.updateCanvasViewConfig — marks canvas dirty */
updateCanvasViewConfig: (canvasId: string, viewConfig: ConversationViewConfig) => void;
/** Mirrors what saveCanvasViewConfig does after the API call succeeds */
clearDirty: (canvasId: string) => void;
}
const useStore = create<TestStore>((set, get) => ({
canvasesById: {},
dirtyCanvasIds: new Set<string>(),
upsertCanvas: (canvas) => {
const state = get();
// Don't overwrite a canvas that has pending local changes (dirty).
if (state.dirtyCanvasIds.has(canvas.id)) return;
set({ canvasesById: { ...state.canvasesById, [canvas.id]: canvas } });
},
updateCanvasViewConfig: (canvasId, viewConfig) => {
const state = get();
const canvas = state.canvasesById[canvasId];
if (!canvas) return;
const dirtyCanvasIds = new Set(state.dirtyCanvasIds);
dirtyCanvasIds.add(canvasId);
set({
canvasesById: { ...state.canvasesById, [canvasId]: { ...canvas, viewConfig } },
dirtyCanvasIds,
});
},
clearDirty: (canvasId) => {
const state = get();
const dirtyCanvasIds = new Set(state.dirtyCanvasIds);
dirtyCanvasIds.delete(canvasId);
set({ dirtyCanvasIds });
},
}));
const resetStore = () => {
useStore.setState({ canvasesById: {}, dirtyCanvasIds: new Set() });
};
// ─── Tests ───────────────────────────────────────────────────────────────────
describe('upsertCanvas — dirty check', () => {
beforeEach(resetStore);
it('updates a clean canvas', () => {
const canvas = makeCanvas('c1', { title: 'Original' });
useStore.getState().upsertCanvas(canvas);
expect(useStore.getState().canvasesById['c1'].title).toBe('Original');
useStore.getState().upsertCanvas({ ...canvas, title: 'Updated' });
expect(useStore.getState().canvasesById['c1'].title).toBe('Updated');
});
it('does NOT overwrite a dirty canvas (race condition guard)', () => {
// Seed canvas, then mark it dirty via a local edit
useStore.getState().upsertCanvas(makeCanvas('c1', { title: 'Original' }));
useStore.getState().updateCanvasViewConfig('c1', {
type: 'conversationCanvas',
filterSortConfiguration: { status: { filter: { selected: ['active'] } } },
});
// Simulate background refetch arriving with stale server data
useStore.getState().upsertCanvas(makeCanvas('c1', { title: 'Server version' }));
// Dirty canvas should be untouched
expect(useStore.getState().canvasesById['c1'].title).toBe('Original');
expect(
(useStore.getState().canvasesById['c1'].viewConfig as ConversationViewConfig)
?.filterSortConfiguration?.status?.filter?.selected,
).toEqual(['active']);
});
it('resumes accepting updates after dirty flag is cleared (save completes)', () => {
useStore.getState().upsertCanvas(makeCanvas('c1', { title: 'Original' }));
useStore.getState().updateCanvasViewConfig('c1', { type: 'conversationCanvas' });
// Save completes → clear dirty flag
useStore.getState().clearDirty('c1');
// Now a server update should land normally
useStore.getState().upsertCanvas(makeCanvas('c1', { title: 'Post-save server version' }));
expect(useStore.getState().canvasesById['c1'].title).toBe('Post-save server version');
});
it('skips upsert for canvas not yet in store when another canvas is dirty', () => {
// c1 is dirty; c2 is unrelated and should be insertable
useStore.getState().upsertCanvas(makeCanvas('c1'));
useStore.getState().updateCanvasViewConfig('c1', { type: 'conversationCanvas' });
useStore.getState().upsertCanvas(makeCanvas('c2', { title: 'New canvas' }));
expect(useStore.getState().canvasesById['c2'].title).toBe('New canvas');
});
});
// ─── Owner filter clearing ────────────────────────────────────────────────────
/**
* Mirrors the handleRemoveColumn logic for 'primaryUser' in FilterSortConfigurationRow.
* The fix ensures ownerUserIds is atomically cleared alongside the filter.
*/
function removeColumnFromViewConfig(
currentViewConfig: ConversationViewConfig,
columnId: string,
): ConversationViewConfig {
const currentFilterSort: CanvasFilterSortConfiguration =
currentViewConfig.filterSortConfiguration ?? {};
const newFilterSort: CanvasFilterSortConfiguration = {
...currentFilterSort,
[columnId]: {
...currentFilterSort[columnId],
sort: currentFilterSort[columnId]?.sort
? { ...currentFilterSort[columnId].sort!, active: false }
: undefined,
filter: undefined,
},
};
return {
...currentViewConfig,
filterSortConfiguration: newFilterSort,
...(columnId === 'primaryUser' ? { ownerUserIds: [] } : {}),
};
}
describe('handleRemoveColumn — owner filter clearing', () => {
it('clears ownerUserIds when removing primaryUser filter', () => {
const viewConfig: ConversationViewConfig = {
type: 'conversationCanvas',
ownerUserIds: ['user-abc', 'user-xyz'],
filterSortConfiguration: {
primaryUser: { filter: { selected: ['user-abc', 'user-xyz'] } },
},
};
const result = removeColumnFromViewConfig(viewConfig, 'primaryUser');
expect(result.ownerUserIds).toEqual([]);
expect(result.filterSortConfiguration?.primaryUser?.filter).toBeUndefined();
});
it('does NOT touch ownerUserIds when removing a non-owner column', () => {
const viewConfig: ConversationViewConfig = {
type: 'conversationCanvas',
ownerUserIds: ['user-abc'],
filterSortConfiguration: {
status: { filter: { selected: ['active'] } },
},
};
const result = removeColumnFromViewConfig(viewConfig, 'status');
expect(result.ownerUserIds).toEqual(['user-abc']);
expect(result.filterSortConfiguration?.status?.filter).toBeUndefined();
});
it('deactivates an active sort on the removed column without clearing other sorts', () => {
const viewConfig: ConversationViewConfig = {
type: 'conversationCanvas',
filterSortConfiguration: {
status: { sort: { active: true, direction: 'asc', priority: 0 } },
priority: { sort: { active: true, direction: 'desc', priority: 1 } },
},
};
const result = removeColumnFromViewConfig(viewConfig, 'status');
expect(result.filterSortConfiguration?.status?.sort?.active).toBe(false);
expect(result.filterSortConfiguration?.priority?.sort?.active).toBe(true);
});
it('resets ownerUserIds to [] (current user default) after primaryUser removal', () => {
const viewConfig: ConversationViewConfig = {
type: 'conversationCanvas',
ownerUserIds: ['user-abc'],
filterSortConfiguration: { primaryUser: { filter: { selected: ['user-abc'] } } },
};
const result = removeColumnFromViewConfig(viewConfig, 'primaryUser');
// [] means "current user only" (same as undefined) — resets to default scope
expect(result.ownerUserIds).toStrictEqual([]);
expect(Array.isArray(result.ownerUserIds)).toBe(true);
});
});