From 0efc36e70c12045e6c73bc15e6111531b0cdea06 Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Sat, 14 Mar 2026 13:36:47 +0100 Subject: [PATCH] feat: add configurable overview layout builder --- apps/main/src/hooks/tablos.ts | 17 + .../src/pages/tablo-details.layout.test.tsx | 143 +++++ apps/main/src/pages/tablo-details.tsx | 536 +++++++++++++----- .../tablo-details/overviewLayout.test.ts | 36 ++ .../src/pages/tablo-details/overviewLayout.ts | 66 +++ .../tablo-details/overviewReorder.test.ts | 32 ++ .../pages/tablo-details/overviewReorder.ts | 46 ++ packages/shared-types/src/database.types.ts | 3 + ...314103000_add_tablo_overview_layout_v1.sql | 5 + .../database/01_schema_structure.test.sql | 5 +- 10 files changed, 739 insertions(+), 150 deletions(-) create mode 100644 apps/main/src/pages/tablo-details.layout.test.tsx create mode 100644 apps/main/src/pages/tablo-details/overviewLayout.test.ts create mode 100644 apps/main/src/pages/tablo-details/overviewLayout.ts create mode 100644 apps/main/src/pages/tablo-details/overviewReorder.test.ts create mode 100644 apps/main/src/pages/tablo-details/overviewReorder.ts create mode 100644 supabase/migrations/20260314103000_add_tablo_overview_layout_v1.sql diff --git a/apps/main/src/hooks/tablos.ts b/apps/main/src/hooks/tablos.ts index d2c1eaf..a450a4c 100644 --- a/apps/main/src/hooks/tablos.ts +++ b/apps/main/src/hooks/tablos.ts @@ -45,6 +45,22 @@ export const useTablo = (id: string) => { }); }; +export const useTabloOverviewLayout = (tabloId: string | undefined) => { + return useQuery({ + queryKey: ["tablo-overview-layout", tabloId], + queryFn: async () => { + const { data, error } = await supabase + .from("tablos") + .select("layout_overview_v1") + .eq("id", tabloId) + .single(); + if (error) throw error; + return data.layout_overview_v1; + }, + enabled: !!tabloId, + }); +}; + // Fetch tablo members export const useTabloMembers = (tabloId: string) => { const api = useAuthedApi(); @@ -112,6 +128,7 @@ export const useUpdateTablo = () => { onSuccess: (_, { id }) => { queryClient.invalidateQueries({ queryKey: ["tablos"] }); queryClient.invalidateQueries({ queryKey: ["tablos", id] }); + queryClient.invalidateQueries({ queryKey: ["tablo-overview-layout", id] }); queryClient.invalidateQueries({ queryKey: ["events", "all"] }); queryClient.invalidateQueries({ queryKey: ["events", id] }); }, diff --git a/apps/main/src/pages/tablo-details.layout.test.tsx b/apps/main/src/pages/tablo-details.layout.test.tsx new file mode 100644 index 0000000..0576c99 --- /dev/null +++ b/apps/main/src/pages/tablo-details.layout.test.tsx @@ -0,0 +1,143 @@ +import { screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { renderWithProviders } from "../utils/testHelpers"; +import { TabloDetailsPage } from "./tablo-details"; + +const mutateUpdateTablo = vi.fn(); +const mutateUpdateTask = vi.fn(); + +const tablosData = [ + { + id: "tablo-1", + name: "Test Tablo", + color: "bg-blue-500", + image: null, + created_at: "2026-01-01T00:00:00.000Z", + deleted_at: null, + position: 0, + status: "todo", + user_id: "user-1", + is_admin: true, + access_level: "admin", + }, +]; + +const layoutData: unknown = { + version: 1, + leftZone: ["myTasks", "description"], + rightZone: ["files", "info"], +}; + +vi.mock("../hooks/channel", () => ({ + useTabloDiscussionUnread: () => ({ + hasUnread: false, + }), +})); + +vi.mock("../hooks/tablos", () => ({ + useTablosList: () => ({ + data: tablosData, + isLoading: false, + }), + useTabloMembers: () => ({ + data: [], + }), + useTabloOverviewLayout: () => ({ + data: layoutData, + }), + useUpdateTablo: () => ({ + mutate: mutateUpdateTablo, + }), +})); + +vi.mock("../hooks/tablo_invites", () => ({ + usePendingTabloInvitesByTablo: () => ({ + data: [], + }), + useCancelTabloInvite: () => ({ + mutate: vi.fn(), + isPending: false, + }), +})); + +vi.mock("../hooks/invite", () => ({ + useInviteUser: () => ({ + mutate: vi.fn(), + isPending: false, + }), +})); + +vi.mock("../hooks/tasks", () => ({ + useAllTasks: () => ({ + data: [ + { + id: "task-1", + tablo_id: "tablo-1", + assignee_id: "user-1", + title: "Task A", + status: "todo", + }, + ], + }), + useTabloEtapes: () => ({ + data: [], + }), + useUpdateTask: () => ({ + mutate: mutateUpdateTask, + }), + useTask: () => ({ + data: null, + }), + useCreateEtape: () => ({ + mutateAsync: vi.fn(), + isPending: false, + }), + useCreateTask: () => ({ + mutate: vi.fn(), + }), +})); + +vi.mock("../hooks/tablo_data", () => ({ + useTabloFileNames: () => ({ + data: { + fileNames: [], + }, + }), +})); + +vi.mock("../providers/UserStoreProvider", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useUser: () => ({ + id: "user-1", + name: "Test User", + avatar_url: null, + }), + }; +}); + +describe("TabloDetailsPage overview layout", () => { + it("renders overview cards in persisted left-zone order", () => { + renderWithProviders(, { + route: "/tablos/tablo-1", + path: "/tablos/:tabloId", + }); + + const tasksHeading = screen.getByText("Mes tâches"); + const descriptionHeading = screen.getByText("Description du projet"); + + expect(tasksHeading.compareDocumentPosition(descriptionHeading)).toBe( + Node.DOCUMENT_POSITION_FOLLOWING + ); + }); + + it("shows layout edit toggle for admin users", () => { + renderWithProviders(, { + route: "/tablos/tablo-1", + path: "/tablos/:tabloId", + }); + + expect(screen.getByRole("button", { name: "Modifier la mise en page" })).toBeInTheDocument(); + }); +}); diff --git a/apps/main/src/pages/tablo-details.tsx b/apps/main/src/pages/tablo-details.tsx index 6ea0cac..524baab 100644 --- a/apps/main/src/pages/tablo-details.tsx +++ b/apps/main/src/pages/tablo-details.tsx @@ -50,7 +50,12 @@ import { useTabloDiscussionUnread } from "../hooks/channel"; import { useInviteUser } from "../hooks/invite"; import { useTabloFileNames } from "../hooks/tablo_data"; import { useCancelTabloInvite, usePendingTabloInvitesByTablo } from "../hooks/tablo_invites"; -import { useTabloMembers, useTablosList } from "../hooks/tablos"; +import { + useTabloMembers, + useTabloOverviewLayout, + useTablosList, + useUpdateTablo, +} from "../hooks/tablos"; import { useAllTasks, useCreateEtape, @@ -60,6 +65,13 @@ import { } from "../hooks/tasks"; import { useUser } from "../providers/UserStoreProvider"; import { getEtapeProgressStats } from "../utils/etapeProgress"; +import { + DEFAULT_OVERVIEW_LAYOUT, + type OverviewBlockId, + type OverviewLayoutV1, + sanitizeOverviewLayout, +} from "./tablo-details/overviewLayout"; +import { moveBetweenZones, moveWithinZone } from "./tablo-details/overviewReorder"; // ─── Icon helpers ───────────────────────────────────────────────────────────── @@ -170,13 +182,21 @@ export const TabloDetailsPage = () => { const [showAllOverviewTasks, setShowAllOverviewTasks] = useState(false); const [isShareDialogOpen, setIsShareDialogOpen] = useState(false); const [inviteEmail, setInviteEmail] = useState(""); + const [isLayoutEditMode, setIsLayoutEditMode] = useState(false); + const [draggedOverviewBlock, setDraggedOverviewBlock] = useState<{ + zone: "left" | "right"; + index: number; + } | null>(null); + const [overviewLayout, setOverviewLayout] = useState(DEFAULT_OVERVIEW_LAYOUT); const currentUser = useUser(); const { data: members } = useTabloMembers(tabloId ?? ""); + const { data: rawOverviewLayout } = useTabloOverviewLayout(tabloId); const { data: pendingInvites } = usePendingTabloInvitesByTablo(tabloId ?? ""); const { mutate: cancelInvite, isPending: isCancellingInvite } = useCancelTabloInvite(); const { mutate: inviteUser, isPending: isInvitingUser } = useInviteUser(); const { mutate: updateTask } = useUpdateTask(); + const { mutate: updateTablo } = useUpdateTablo(); const isEmailValid = (email: string): boolean => { const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; @@ -231,6 +251,10 @@ export const TabloDetailsPage = () => { } }, [tablos, tabloId, navigate]); + useEffect(() => { + setOverviewLayout(sanitizeOverviewLayout(rawOverviewLayout)); + }, [rawOverviewLayout]); + // Tasks for this tablo (used in overview) const { data: allTasks = [] } = useAllTasks(); const tabloTasks = (allTasks as KanbanTask[]).filter((t) => t.tablo_id === tabloId); @@ -260,6 +284,100 @@ export const TabloDetailsPage = () => { const TabloIcon = getTabloIcon(tablo.color); const iconColor = getTabloIconColor(tablo.color); + const persistOverviewLayout = ( + nextLayout: OverviewLayoutV1, + previousLayout: OverviewLayoutV1 + ) => { + setOverviewLayout(nextLayout); + + if (!tabloId) return; + + updateTablo( + { + id: tabloId, + layout_overview_v1: nextLayout, + }, + { + onError: () => { + setOverviewLayout(previousLayout); + }, + } + ); + }; + + const buildLayoutWithMetadata = (next: OverviewLayoutV1): OverviewLayoutV1 => ({ + ...next, + updatedAt: new Date().toISOString(), + updatedBy: currentUser.id, + }); + + const handleOverviewBlockDragStart = ( + event: React.DragEvent, + zone: "left" | "right", + index: number + ) => { + if (!isLayoutEditMode) return; + event.dataTransfer.effectAllowed = "move"; + setDraggedOverviewBlock({ zone, index }); + }; + + const handleOverviewBlockDragOver = (event: React.DragEvent) => { + if (!isLayoutEditMode) return; + event.preventDefault(); + event.dataTransfer.dropEffect = "move"; + }; + + const handleOverviewBlockDrop = (zone: "left" | "right", targetIndex: number) => { + if (!isLayoutEditMode || !draggedOverviewBlock) { + setDraggedOverviewBlock(null); + return; + } + + const sourceKey = draggedOverviewBlock.zone === "left" ? "leftZone" : "rightZone"; + const targetKey = zone === "left" ? "leftZone" : "rightZone"; + + const sourceZone = overviewLayout[sourceKey]; + const targetZone = overviewLayout[targetKey]; + + const nextLayout = + sourceKey === targetKey + ? { + ...overviewLayout, + [targetKey]: moveWithinZone(targetZone, draggedOverviewBlock.index, targetIndex), + } + : (() => { + const moved = moveBetweenZones( + sourceZone, + targetZone, + draggedOverviewBlock.index, + targetIndex + ); + return { + ...overviewLayout, + [sourceKey]: moved.sourceItems, + [targetKey]: moved.targetItems, + }; + })(); + + setDraggedOverviewBlock(null); + + if ( + nextLayout.leftZone === overviewLayout.leftZone && + nextLayout.rightZone === overviewLayout.rightZone + ) { + return; + } + + const previousLayout = overviewLayout; + persistOverviewLayout(buildLayoutWithMetadata(nextLayout), previousLayout); + }; + + const handleResetOverviewLayout = () => { + const previousLayout = overviewLayout; + const resetLayout = buildLayoutWithMetadata(DEFAULT_OVERVIEW_LAYOUT); + persistOverviewLayout(resetLayout, previousLayout); + }; + return (
{/* ── Header ──────────────────────────────────────────────────────── */} @@ -382,165 +500,287 @@ export const TabloDetailsPage = () => { {/* ── Tab content ─────────────────────────────────────────────────── */}
- {activeSection === "overview" && ( -
- {/* Left column */} -
- {/* Description */} -
-

- Description du projet -

-

- Ce projet regroupe les tâches, fichiers et événements associés. Utilisez les - onglets ci-dessus pour naviguer entre les différentes sections. -

-
- - {/* Tasks */} -
-
-

- Mes tâches + {activeSection === "overview" && + (() => { + const overviewBlocks: Record = { + description: ( +
+

+ Description du projet

- +

+ Ce projet regroupe les tâches, fichiers et événements associés. Utilisez les + onglets ci-dessus pour naviguer entre les différentes sections. +

-
- {myTabloTasks.length === 0 ? ( -
- Aucune tâche -
- ) : ( - visibleOverviewTasks.map((task) => ( -
setSearchParams({ section: "tasks" })} - > - -

- {task.title} -

-
- )) - )} - {myTabloTasks.length > 5 && ( + ), + myTasks: ( +
+
+

+ Mes tâches +

- )} -
-
-
- - {/* Right column */} -
- {/* Files */} -
-
-

Fichiers

- -
-
- {fileNames.length === 0 ? ( -

Aucun fichier

- ) : ( - fileNames.slice(0, 5).map((fileName) => ( -
-
- -
-
-

{fileName}

-
- +
+
+ {myTabloTasks.length === 0 ? ( +
+ Aucune tâche
- )) - )} + ) : ( + visibleOverviewTasks.map((task) => ( +
setSearchParams({ section: "tasks" })} + > + +

+ {task.title} +

+
+ )) + )} + {myTabloTasks.length > 5 && ( + + )} +
-
+ ), + files: ( +
+
+

Fichiers

+ +
+
+ {fileNames.length === 0 ? ( +

Aucun fichier

+ ) : ( + fileNames.slice(0, 5).map((fileName) => ( +
+
+ +
+
+

+ {fileName} +

+
+ +
+ )) + )} +
+
+ ), + info: ( +
+

Informations

+
+
+
Tâches
+
{tabloTasks.length}
+
+
+
Fichiers
+
{fileNames.length}
+
+
+
Statut
+
+ {statusLabel} +
+
+
+
Rôle
+
+ {isAdmin ? "Admin" : "Invité"} +
+
+
+
+ ), + }; - {/* Info */} -
-

Informations

-
-
-
Tâches
-
{tabloTasks.length}
+ return ( + <> + {isAdmin && ( +
+ {isLayoutEditMode && ( + + )} +
-
-
Fichiers
-
{fileNames.length}
+ )} + +
+
{ + event.preventDefault(); + handleOverviewBlockDrop("left", overviewLayout.leftZone.length); + }} + > + {overviewLayout.leftZone.map((blockId, index) => ( +
handleOverviewBlockDragStart(event, "left", index)} + onDragOver={handleOverviewBlockDragOver} + onDrop={(event) => { + event.preventDefault(); + event.stopPropagation(); + handleOverviewBlockDrop("left", index); + }} + onDragEnd={() => setDraggedOverviewBlock(null)} + className={cn( + isLayoutEditMode && "cursor-move", + isLayoutEditMode && + draggedOverviewBlock?.zone === "left" && + draggedOverviewBlock.index === index && + "opacity-60" + )} + > + {isLayoutEditMode && ( +
+ + Glisser pour réorganiser +
+ )} + {overviewBlocks[blockId]} +
+ ))} + {isLayoutEditMode && overviewLayout.leftZone.length === 0 && ( +
+ Déposez un bloc ici +
+ )}
-
-
Statut
-
- {statusLabel} -
+ +
{ + event.preventDefault(); + handleOverviewBlockDrop("right", overviewLayout.rightZone.length); + }} + > + {overviewLayout.rightZone.map((blockId, index) => ( +
handleOverviewBlockDragStart(event, "right", index)} + onDragOver={handleOverviewBlockDragOver} + onDrop={(event) => { + event.preventDefault(); + event.stopPropagation(); + handleOverviewBlockDrop("right", index); + }} + onDragEnd={() => setDraggedOverviewBlock(null)} + className={cn( + isLayoutEditMode && "cursor-move", + isLayoutEditMode && + draggedOverviewBlock?.zone === "right" && + draggedOverviewBlock.index === index && + "opacity-60" + )} + > + {isLayoutEditMode && ( +
+ + Glisser pour réorganiser +
+ )} + {overviewBlocks[blockId]} +
+ ))} + {isLayoutEditMode && overviewLayout.rightZone.length === 0 && ( +
+ Déposez un bloc ici +
+ )}
-
-
Rôle
-
{isAdmin ? "Admin" : "Invité"}
-
-
-
-
-

- )} +
+ + ); + })()} {activeSection === "tasks" && } {activeSection === "files" && } diff --git a/apps/main/src/pages/tablo-details/overviewLayout.test.ts b/apps/main/src/pages/tablo-details/overviewLayout.test.ts new file mode 100644 index 0000000..fe7d265 --- /dev/null +++ b/apps/main/src/pages/tablo-details/overviewLayout.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import { DEFAULT_OVERVIEW_LAYOUT, sanitizeOverviewLayout } from "./overviewLayout"; + +describe("sanitizeOverviewLayout", () => { + it("returns the default layout when input is null", () => { + expect(sanitizeOverviewLayout(null)).toEqual(DEFAULT_OVERVIEW_LAYOUT); + }); + + it("drops unknown IDs and restores missing required IDs", () => { + const result = sanitizeOverviewLayout({ + version: 1, + leftZone: ["description", "unknown"], + rightZone: [], + }); + + expect(result).toEqual({ + version: 1, + leftZone: ["description"], + rightZone: ["myTasks", "files", "info"], + }); + }); + + it("deduplicates IDs across zones and preserves zone split", () => { + const result = sanitizeOverviewLayout({ + version: 1, + leftZone: ["description", "files"], + rightZone: ["files", "info", "description"], + }); + + expect(result).toEqual({ + version: 1, + leftZone: ["description", "files"], + rightZone: ["info", "myTasks"], + }); + }); +}); diff --git a/apps/main/src/pages/tablo-details/overviewLayout.ts b/apps/main/src/pages/tablo-details/overviewLayout.ts new file mode 100644 index 0000000..6424602 --- /dev/null +++ b/apps/main/src/pages/tablo-details/overviewLayout.ts @@ -0,0 +1,66 @@ +type OverviewLayoutVersion = 1; + +export type OverviewBlockId = "description" | "myTasks" | "files" | "info"; + +export type OverviewLayoutV1 = { + version: OverviewLayoutVersion; + leftZone: OverviewBlockId[]; + rightZone: OverviewBlockId[]; + updatedAt?: string; + updatedBy?: string; +}; + +const DEFAULT_LEFT_ZONE: OverviewBlockId[] = ["description", "myTasks"]; +const DEFAULT_RIGHT_ZONE: OverviewBlockId[] = ["files", "info"]; +const ALL_BLOCK_IDS: OverviewBlockId[] = ["description", "myTasks", "files", "info"]; + +export const DEFAULT_OVERVIEW_LAYOUT: OverviewLayoutV1 = { + version: 1, + leftZone: DEFAULT_LEFT_ZONE, + rightZone: DEFAULT_RIGHT_ZONE, +}; + +function unique(items: T[]): T[] { + return [...new Set(items)]; +} + +function isObject(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function parseZone(value: unknown): OverviewBlockId[] { + if (!Array.isArray(value)) return []; + const valid = value.filter( + (item): item is OverviewBlockId => + item === "description" || item === "myTasks" || item === "files" || item === "info" + ); + return unique(valid); +} + +export function sanitizeOverviewLayout(input: unknown): OverviewLayoutV1 { + if (!isObject(input)) { + return DEFAULT_OVERVIEW_LAYOUT; + } + + const leftZoneInput = parseZone(input.leftZone); + const rightZoneInput = parseZone(input.rightZone); + + const leftZoneSize = leftZoneInput.length; + const allBlocks = unique([...leftZoneInput, ...rightZoneInput]); + + for (const blockId of ALL_BLOCK_IDS) { + if (!allBlocks.includes(blockId)) { + allBlocks.push(blockId); + } + } + + const safeLeftZoneSize = Math.min(leftZoneSize, allBlocks.length); + const leftZone = allBlocks.slice(0, safeLeftZoneSize); + const rightZone = allBlocks.slice(safeLeftZoneSize); + + return { + version: 1, + leftZone, + rightZone, + }; +} diff --git a/apps/main/src/pages/tablo-details/overviewReorder.test.ts b/apps/main/src/pages/tablo-details/overviewReorder.test.ts new file mode 100644 index 0000000..131aec2 --- /dev/null +++ b/apps/main/src/pages/tablo-details/overviewReorder.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; +import { moveBetweenZones, moveWithinZone } from "./overviewReorder"; + +describe("moveWithinZone", () => { + it("reorders items within bounds", () => { + expect(moveWithinZone(["a", "b", "c"], 0, 2)).toEqual(["b", "c", "a"]); + }); + + it("supports moving to end using zone drop target index", () => { + expect(moveWithinZone(["a", "b", "c"], 1, 3)).toEqual(["a", "c", "b"]); + }); + + it("returns original list when indices are invalid", () => { + expect(moveWithinZone(["a"], 0, 2)).toEqual(["a"]); + }); +}); + +describe("moveBetweenZones", () => { + it("moves one item from source to target at requested index", () => { + expect(moveBetweenZones(["a", "b"], ["c"], 0, 1)).toEqual({ + sourceItems: ["b"], + targetItems: ["c", "a"], + }); + }); + + it("returns original zones when source index is invalid", () => { + expect(moveBetweenZones(["a"], ["b"], 2, 0)).toEqual({ + sourceItems: ["a"], + targetItems: ["b"], + }); + }); +}); diff --git a/apps/main/src/pages/tablo-details/overviewReorder.ts b/apps/main/src/pages/tablo-details/overviewReorder.ts new file mode 100644 index 0000000..c62c48d --- /dev/null +++ b/apps/main/src/pages/tablo-details/overviewReorder.ts @@ -0,0 +1,46 @@ +export function moveWithinZone(items: T[], fromIndex: number, toIndex: number): T[] { + if (fromIndex < 0 || fromIndex >= items.length || toIndex < 0 || toIndex > items.length) { + return items; + } + + const next = [...items]; + const [moved] = next.splice(fromIndex, 1); + const insertionIndex = Math.min(toIndex, next.length); + next.splice(insertionIndex, 0, moved); + + if (next.every((item, index) => item === items[index])) { + return items; + } + + return next; +} + +export function moveBetweenZones( + sourceItems: T[], + targetItems: T[], + sourceIndex: number, + targetIndex: number +): { + sourceItems: T[]; + targetItems: T[]; +} { + if ( + sourceIndex < 0 || + sourceIndex >= sourceItems.length || + targetIndex < 0 || + targetIndex > targetItems.length + ) { + return { sourceItems, targetItems }; + } + + const nextSourceItems = [...sourceItems]; + const [moved] = nextSourceItems.splice(sourceIndex, 1); + const nextTargetItems = [...targetItems]; + const insertionIndex = Math.min(targetIndex, nextTargetItems.length); + nextTargetItems.splice(insertionIndex, 0, moved); + + return { + sourceItems: nextSourceItems, + targetItems: nextTargetItems, + }; +} diff --git a/packages/shared-types/src/database.types.ts b/packages/shared-types/src/database.types.ts index 84482f5..c48c314 100644 --- a/packages/shared-types/src/database.types.ts +++ b/packages/shared-types/src/database.types.ts @@ -570,6 +570,7 @@ export type Database = { deleted_at: string | null; id: string; image: string | null; + layout_overview_v1: Json | null; name: string; owner_id: string; position: number; @@ -582,6 +583,7 @@ export type Database = { deleted_at?: string | null; id?: string; image?: string | null; + layout_overview_v1?: Json | null; name: string; owner_id: string; position?: number; @@ -594,6 +596,7 @@ export type Database = { deleted_at?: string | null; id?: string; image?: string | null; + layout_overview_v1?: Json | null; name?: string; owner_id?: string; position?: number; diff --git a/supabase/migrations/20260314103000_add_tablo_overview_layout_v1.sql b/supabase/migrations/20260314103000_add_tablo_overview_layout_v1.sql new file mode 100644 index 0000000..06cdaa9 --- /dev/null +++ b/supabase/migrations/20260314103000_add_tablo_overview_layout_v1.sql @@ -0,0 +1,5 @@ +alter table public.tablos + add column if not exists layout_overview_v1 jsonb; + +comment on column public.tablos.layout_overview_v1 is + 'Per-tablo overview layout configuration (v1) for tablo-details.'; diff --git a/supabase/tests/database/01_schema_structure.test.sql b/supabase/tests/database/01_schema_structure.test.sql index a5cf916..fdc231c 100644 --- a/supabase/tests/database/01_schema_structure.test.sql +++ b/supabase/tests/database/01_schema_structure.test.sql @@ -1,5 +1,5 @@ begin; -select plan(97); -- Total number of tests +select plan(99); -- Total number of tests -- ============================================================================ -- Table Existence Tests @@ -42,11 +42,13 @@ SELECT has_column('public', 'tablos', 'status', 'tablos should have status colum SELECT has_column('public', 'tablos', 'position', 'tablos should have position column'); SELECT has_column('public', 'tablos', 'created_at', 'tablos should have created_at column'); SELECT has_column('public', 'tablos', 'deleted_at', 'tablos should have deleted_at column'); +SELECT has_column('public', 'tablos', 'layout_overview_v1', 'tablos should have layout_overview_v1 column'); SELECT col_type_is('public', 'tablos', 'owner_id', 'uuid', 'tablos.owner_id should be uuid'); SELECT col_type_is('public', 'tablos', 'name', 'character varying(255)', 'tablos.name should be varchar(255)'); SELECT col_type_is('public', 'tablos', 'status', 'character varying(20)', 'tablos.status should be varchar(20)'); SELECT col_type_is('public', 'tablos', 'position', 'integer', 'tablos.position should be integer'); +SELECT col_type_is('public', 'tablos', 'layout_overview_v1', 'jsonb', 'tablos.layout_overview_v1 should be jsonb'); SELECT col_not_null('public', 'tablos', 'owner_id', 'tablos.owner_id should be NOT NULL'); SELECT col_not_null('public', 'tablos', 'name', 'tablos.name should be NOT NULL'); @@ -156,4 +158,3 @@ SELECT col_type_is('public', 'note_access', 'is_active', 'boolean', 'note_access select * from finish(); rollback; -