Phase 6: Workspaces — Widget CRUD, Manager-Check, Cross-Tenant, Zustand Store, Settings Route
Backend:
- Widget CRUD: get_widgets, create_widget, update_widget, delete_widget
- Manager role check: is_workspace_manager
- Cross-tenant validation: verify_user_same_tenant (UserTenant)
- Default workspace seeding: seed_default_workspace with 12 standard modules
- Set user default workspace: set_user_default_workspace
- Fix create_workspace default uniqueness (unset others before insert)
- Widget CRUD routes: GET/POST/PUT/DELETE /{workspace_id}/widgets
- Set-default route: POST /{workspace_id}/set-default
- Cross-tenant validation in assign_user route
Frontend:
- workspaceStore (Zustand): central state with sessionStorage persistence
- API client interceptor: X-Workspace-ID header on all requests
- useWorkspace hook refactored to use workspaceStore
- Widget API hooks: useWorkspaceWidgets, useCreateWorkspaceWidget, etc.
- useSetDefaultWorkspace hook
- Settings route: /settings/workspaces with WorkspaceManagerPage
- Settings nav item for Workspaces
Tests:
- 25 backend tests (CRUD, modules, widgets, users, manager, seeding, context, isolation)
- 12 frontend tests (workspaceStore state, visibility, persistence, reset)
- 48/48 backend tests passing
- 12/12 frontend tests passing
This commit is contained in:
@@ -0,0 +1,135 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { useWorkspaceStore } from '@/store/workspaceStore';
|
||||
|
||||
// Mock sessionStorage
|
||||
const mockSessionStorage = {
|
||||
store: {} as Record<string, string>,
|
||||
getItem: vi.fn((key: string) => mockSessionStorage.store[key] ?? null),
|
||||
setItem: vi.fn((key: string, value: string) => { mockSessionStorage.store[key] = value; }),
|
||||
removeItem: vi.fn((key: string) => { delete mockSessionStorage.store[key]; }),
|
||||
clear: vi.fn(() => { mockSessionStorage.store = {}; }),
|
||||
};
|
||||
|
||||
Object.defineProperty(window, 'sessionStorage', {
|
||||
value: mockSessionStorage,
|
||||
writable: true,
|
||||
});
|
||||
|
||||
// Mock the API client import
|
||||
vi.mock('@/api/client', () => ({
|
||||
setActiveWorkspaceId: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('workspaceStore', () => {
|
||||
beforeEach(() => {
|
||||
mockSessionStorage.clear();
|
||||
useWorkspaceStore.getState().reset();
|
||||
});
|
||||
|
||||
it('initializes with no active workspace', () => {
|
||||
expect(useWorkspaceStore.getState().activeWorkspaceId).toBeNull();
|
||||
expect(useWorkspaceStore.getState().context).toBeNull();
|
||||
expect(useWorkspaceStore.getState().myWorkspaces).toEqual([]);
|
||||
});
|
||||
|
||||
it('setActiveWorkspace stores ID in sessionStorage', () => {
|
||||
const wsId = 'test-workspace-id';
|
||||
useWorkspaceStore.getState().setActiveWorkspace(wsId);
|
||||
expect(useWorkspaceStore.getState().activeWorkspaceId).toBe(wsId);
|
||||
expect(mockSessionStorage.setItem).toHaveBeenCalledWith('leocrm-active-workspace', wsId);
|
||||
});
|
||||
|
||||
it('setActiveWorkspace(null) removes from sessionStorage', () => {
|
||||
useWorkspaceStore.getState().setActiveWorkspace('test-id');
|
||||
useWorkspaceStore.getState().setActiveWorkspace(null);
|
||||
expect(useWorkspaceStore.getState().activeWorkspaceId).toBeNull();
|
||||
expect(mockSessionStorage.removeItem).toHaveBeenCalledWith('leocrm-active-workspace');
|
||||
});
|
||||
|
||||
it('setActiveWorkspace clears context on switch', () => {
|
||||
useWorkspaceStore.getState().setContext({
|
||||
workspace_id: 'old-id',
|
||||
modules: [{ module_key: 'contacts', menu_order: 0, config: {} }],
|
||||
widgets: [],
|
||||
});
|
||||
useWorkspaceStore.getState().setActiveWorkspace('new-id');
|
||||
expect(useWorkspaceStore.getState().context).toBeNull();
|
||||
});
|
||||
|
||||
it('isModuleVisible returns true when no workspace context (backward compatible)', () => {
|
||||
expect(useWorkspaceStore.getState().isModuleVisible('contacts')).toBe(true);
|
||||
});
|
||||
|
||||
it('isModuleVisible returns true for system admin', () => {
|
||||
useWorkspaceStore.getState().setContext({
|
||||
workspace_id: 'ws-1',
|
||||
modules: [{ module_key: 'contacts', menu_order: 0, config: {} }],
|
||||
widgets: [],
|
||||
});
|
||||
expect(useWorkspaceStore.getState().isModuleVisible('mail', true)).toBe(true);
|
||||
});
|
||||
|
||||
it('isModuleVisible returns false for module not in workspace', () => {
|
||||
useWorkspaceStore.getState().setContext({
|
||||
workspace_id: 'ws-1',
|
||||
modules: [{ module_key: 'contacts', menu_order: 0, config: {} }],
|
||||
widgets: [],
|
||||
});
|
||||
expect(useWorkspaceStore.getState().isModuleVisible('mail')).toBe(false);
|
||||
});
|
||||
|
||||
it('isModuleVisible returns true for module in workspace', () => {
|
||||
useWorkspaceStore.getState().setContext({
|
||||
workspace_id: 'ws-1',
|
||||
modules: [
|
||||
{ module_key: 'contacts', menu_order: 0, config: {} },
|
||||
{ module_key: 'calendar', menu_order: 1, config: {} },
|
||||
],
|
||||
widgets: [],
|
||||
});
|
||||
expect(useWorkspaceStore.getState().isModuleVisible('contacts')).toBe(true);
|
||||
expect(useWorkspaceStore.getState().isModuleVisible('calendar')).toBe(true);
|
||||
});
|
||||
|
||||
it('visibleModuleKeys returns set of visible module keys', () => {
|
||||
useWorkspaceStore.getState().setContext({
|
||||
workspace_id: 'ws-1',
|
||||
modules: [
|
||||
{ module_key: 'contacts', menu_order: 0, config: {} },
|
||||
{ module_key: 'calendar', menu_order: 1, config: {} },
|
||||
],
|
||||
widgets: [],
|
||||
});
|
||||
const keys = useWorkspaceStore.getState().visibleModuleKeys();
|
||||
expect(keys.has('contacts')).toBe(true);
|
||||
expect(keys.has('calendar')).toBe(true);
|
||||
expect(keys.has('mail')).toBe(false);
|
||||
});
|
||||
|
||||
it('hasWorkspaces returns false when empty', () => {
|
||||
expect(useWorkspaceStore.getState().hasWorkspaces()).toBe(false);
|
||||
});
|
||||
|
||||
it('hasWorkspaces returns true when workspaces exist', () => {
|
||||
useWorkspaceStore.getState().setMyWorkspaces([
|
||||
{ id: 'ws-1', name: 'WS1', icon: 'LayoutGrid', description: null, is_default: false, is_active: true },
|
||||
]);
|
||||
expect(useWorkspaceStore.getState().hasWorkspaces()).toBe(true);
|
||||
});
|
||||
|
||||
it('reset clears all state', () => {
|
||||
useWorkspaceStore.getState().setActiveWorkspace('test-id');
|
||||
useWorkspaceStore.getState().setMyWorkspaces([
|
||||
{ id: 'ws-1', name: 'WS1', icon: 'LayoutGrid', description: null, is_default: false, is_active: true },
|
||||
]);
|
||||
useWorkspaceStore.getState().setContext({
|
||||
workspace_id: 'ws-1',
|
||||
modules: [],
|
||||
widgets: [],
|
||||
});
|
||||
useWorkspaceStore.getState().reset();
|
||||
expect(useWorkspaceStore.getState().activeWorkspaceId).toBeNull();
|
||||
expect(useWorkspaceStore.getState().context).toBeNull();
|
||||
expect(useWorkspaceStore.getState().myWorkspaces).toEqual([]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user