310a9f0542
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
219 lines
6.8 KiB
TypeScript
219 lines
6.8 KiB
TypeScript
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|
import { apiGet, apiPost, apiPut, apiDelete } from '@/api/client';
|
|
|
|
export interface WorkspaceModule {
|
|
id?: string;
|
|
module_key: string;
|
|
is_visible: boolean;
|
|
menu_order: number;
|
|
config: Record<string, any>;
|
|
}
|
|
|
|
export interface Workspace {
|
|
id: string;
|
|
name: string;
|
|
icon: string;
|
|
description: string | null;
|
|
is_default: boolean;
|
|
is_active: boolean;
|
|
created_by: string | null;
|
|
created_at: string | null;
|
|
updated_at: string | null;
|
|
modules?: WorkspaceModule[];
|
|
user_count?: number;
|
|
}
|
|
|
|
export interface MyWorkspace extends Workspace {
|
|
role: 'member' | 'manager';
|
|
is_user_default: boolean;
|
|
}
|
|
|
|
export interface WorkspaceContext {
|
|
workspace_id: string | null;
|
|
name?: string;
|
|
icon?: string;
|
|
role?: string;
|
|
modules: { module_key: string; menu_order: number; config: Record<string, any> }[];
|
|
widgets: { id: string; widget_key: string; position_x: number; position_y: number; width: number; height: number; config: Record<string, any> }[];
|
|
error?: string;
|
|
}
|
|
|
|
export function useWorkspaces() {
|
|
return useQuery<{ items: Workspace[]; total: number }>({
|
|
queryKey: ['workspaces'],
|
|
queryFn: () => apiGet('/api/v1/workspaces'),
|
|
});
|
|
}
|
|
|
|
export function useMyWorkspaces() {
|
|
return useQuery<{ items: MyWorkspace[]; total: number }>({
|
|
queryKey: ['my-workspaces'],
|
|
queryFn: () => apiGet('/api/v1/workspaces/my'),
|
|
});
|
|
}
|
|
|
|
export function useWorkspaceContext(workspaceId: string | null) {
|
|
return useQuery<WorkspaceContext>({
|
|
queryKey: ['workspace-context', workspaceId],
|
|
queryFn: () => apiGet('/api/v1/workspaces/context', {
|
|
headers: workspaceId ? { 'X-Workspace-ID': workspaceId } : {},
|
|
}),
|
|
enabled: !!workspaceId,
|
|
});
|
|
}
|
|
|
|
export function useCreateWorkspace() {
|
|
const qc = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: (data: { name: string; icon?: string; description?: string; is_default?: boolean }) =>
|
|
apiPost('/api/v1/workspaces', data),
|
|
onSuccess: () => {
|
|
qc.invalidateQueries({ queryKey: ['workspaces'] });
|
|
qc.invalidateQueries({ queryKey: ['my-workspaces'] });
|
|
},
|
|
});
|
|
}
|
|
|
|
export function useUpdateWorkspace() {
|
|
const qc = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: ({ id, ...data }: { id: string; name?: string; icon?: string; description?: string; is_default?: boolean; is_active?: boolean }) =>
|
|
apiPut(`/api/v1/workspaces/${id}`, data),
|
|
onSuccess: () => {
|
|
qc.invalidateQueries({ queryKey: ['workspaces'] });
|
|
qc.invalidateQueries({ queryKey: ['my-workspaces'] });
|
|
},
|
|
});
|
|
}
|
|
|
|
export function useDeleteWorkspace() {
|
|
const qc = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: (id: string) => apiDelete(`/api/v1/workspaces/${id}`),
|
|
onSuccess: () => {
|
|
qc.invalidateQueries({ queryKey: ['workspaces'] });
|
|
qc.invalidateQueries({ queryKey: ['my-workspaces'] });
|
|
},
|
|
});
|
|
}
|
|
|
|
export function useSetWorkspaceModules() {
|
|
const qc = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: ({ workspaceId, modules }: { workspaceId: string; modules: WorkspaceModule[] }) =>
|
|
apiPost(`/api/v1/workspaces/${workspaceId}/modules`, { modules }),
|
|
onSuccess: () => {
|
|
qc.invalidateQueries({ queryKey: ['workspaces'] });
|
|
qc.invalidateQueries({ queryKey: ['my-workspaces'] });
|
|
},
|
|
});
|
|
}
|
|
|
|
export function useAssignWorkspaceUser() {
|
|
const qc = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: ({ workspaceId, userId, role }: { workspaceId: string; userId: string; role?: string }) =>
|
|
apiPost(`/api/v1/workspaces/${workspaceId}/users`, { user_id: userId, role: role || 'member' }),
|
|
onSuccess: () => {
|
|
qc.invalidateQueries({ queryKey: ['workspaces'] });
|
|
qc.invalidateQueries({ queryKey: ['my-workspaces'] });
|
|
},
|
|
});
|
|
}
|
|
|
|
export function useRemoveWorkspaceUser() {
|
|
const qc = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: ({ workspaceId, userId }: { workspaceId: string; userId: string }) =>
|
|
apiDelete(`/api/v1/workspaces/${workspaceId}/users/${userId}`),
|
|
onSuccess: () => {
|
|
qc.invalidateQueries({ queryKey: ['workspaces'] });
|
|
qc.invalidateQueries({ queryKey: ['my-workspaces'] });
|
|
},
|
|
});
|
|
}
|
|
|
|
// ─── Widget Hooks ─────────────────────────────────────────────
|
|
|
|
export interface WorkspaceWidget {
|
|
id: string;
|
|
workspace_id: string;
|
|
widget_key: string;
|
|
position_x: number;
|
|
position_y: number;
|
|
width: number;
|
|
height: number;
|
|
config: Record<string, any>;
|
|
}
|
|
|
|
export function useWorkspaceWidgets(workspaceId: string | null) {
|
|
return useQuery<{ items: WorkspaceWidget[]; total: number }>({
|
|
queryKey: ['workspace-widgets', workspaceId],
|
|
queryFn: () => apiGet(`/api/v1/workspaces/${workspaceId}/widgets`),
|
|
enabled: !!workspaceId,
|
|
});
|
|
}
|
|
|
|
export function useCreateWorkspaceWidget() {
|
|
const qc = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: ({ workspaceId, ...data }: {
|
|
workspaceId: string;
|
|
widget_key: string;
|
|
position_x?: number;
|
|
position_y?: number;
|
|
width?: number;
|
|
height?: number;
|
|
config?: Record<string, any>;
|
|
}) => apiPost(`/api/v1/workspaces/${workspaceId}/widgets`, data),
|
|
onSuccess: () => {
|
|
qc.invalidateQueries({ queryKey: ['workspace-widgets'] });
|
|
qc.invalidateQueries({ queryKey: ['workspace-context'] });
|
|
},
|
|
});
|
|
}
|
|
|
|
export function useUpdateWorkspaceWidget() {
|
|
const qc = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: ({ workspaceId, widgetId, ...data }: {
|
|
workspaceId: string;
|
|
widgetId: string;
|
|
position_x?: number;
|
|
position_y?: number;
|
|
width?: number;
|
|
height?: number;
|
|
config?: Record<string, any>;
|
|
}) => apiPut(`/api/v1/workspaces/${workspaceId}/widgets/${widgetId}`, data),
|
|
onSuccess: () => {
|
|
qc.invalidateQueries({ queryKey: ['workspace-widgets'] });
|
|
qc.invalidateQueries({ queryKey: ['workspace-context'] });
|
|
},
|
|
});
|
|
}
|
|
|
|
export function useDeleteWorkspaceWidget() {
|
|
const qc = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: ({ workspaceId, widgetId }: { workspaceId: string; widgetId: string }) =>
|
|
apiDelete(`/api/v1/workspaces/${workspaceId}/widgets/${widgetId}`),
|
|
onSuccess: () => {
|
|
qc.invalidateQueries({ queryKey: ['workspace-widgets'] });
|
|
qc.invalidateQueries({ queryKey: ['workspace-context'] });
|
|
},
|
|
});
|
|
}
|
|
|
|
// ─── Set Default Workspace ────────────────────────────────────
|
|
|
|
export function useSetDefaultWorkspace() {
|
|
const qc = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: (workspaceId: string) =>
|
|
apiPost(`/api/v1/workspaces/${workspaceId}/set-default`),
|
|
onSuccess: () => {
|
|
qc.invalidateQueries({ queryKey: ['my-workspaces'] });
|
|
},
|
|
});
|
|
}
|