From 7d06686f62bff33c7e88579d77a2bec046c8dd0c Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Wed, 30 Sep 2026 22:47:08 +0800 Subject: [PATCH] feat(homepage): synchronize canvas layouts across devices (#1515) --- plugins/homepage/manifest.json | 21 ++++- plugins/homepage/src/backend/index.ts | 22 +++++ .../homepage/src/backend/layout-item-ids.ts | 22 +++++ .../tests/backend/layout-sync.test.ts | 84 +++++++++++++++++++ 4 files changed, 145 insertions(+), 4 deletions(-) create mode 100644 plugins/homepage/src/backend/layout-item-ids.ts create mode 100644 plugins/homepage/tests/backend/layout-sync.test.ts diff --git a/plugins/homepage/manifest.json b/plugins/homepage/manifest.json index 2a809cd5b..d1130f13a 100644 --- a/plugins/homepage/manifest.json +++ b/plugins/homepage/manifest.json @@ -34,8 +34,14 @@ } ], "dashboardCards": [ - { "id": "service_links", "titleKey": "dashboard.serviceLinks" }, - { "id": "homepage_preview", "titleKey": "dashboard.homepagePreview" } + { + "id": "service_links", + "titleKey": "dashboard.serviceLinks" + }, + { + "id": "homepage_preview", + "titleKey": "dashboard.homepagePreview" + } ], "permissions": [ { @@ -46,9 +52,16 @@ } ], "actionSlots": [ - { "id": "dashboard.secondaryView", "accepts": ["component"] } + { + "id": "dashboard.secondaryView", + "accepts": ["component"] + } + ], + "syncEntities": [ + "dashboardServiceLinks", + "homepageItems", + "homepageLayouts" ], - "syncEntities": ["dashboardServiceLinks", "homepageItems"], "settings": { "admin": [ { diff --git a/plugins/homepage/src/backend/index.ts b/plugins/homepage/src/backend/index.ts index 821247018..d8dd15725 100644 --- a/plugins/homepage/src/backend/index.ts +++ b/plugins/homepage/src/backend/index.ts @@ -1,3 +1,4 @@ +import { mapLayoutItemIds } from "./layout-item-ids.js"; import type { Router } from "express"; import type { PluginContext } from "@termix/plugin-sdk/backend"; import { @@ -45,6 +46,27 @@ export async function activate(ctx: PluginContext) { order: 70, }); + ctx.sync.registerEntity({ + type: "homepageLayouts", + table: layouts, + order: 81, + singleton: true, + serialize: async (row, resolveSyncId) => ({ + ...row, + layout: await mapLayoutItemIds(row.layout as string, async (id) => + typeof id === "number" && Number.isInteger(id) + ? resolveSyncId("homepageItems", id) + : null, + ), + }), + deserialize: async (row, resolveId) => ({ + ...row, + layout: await mapLayoutItemIds(row.layout as string, async (id) => + typeof id === "string" ? resolveId("homepageItems", id) : null, + ), + }), + }); + ctx.services.provide("homepage.items", createHomepageItemsService(ctx, repo)); ctx.log.info("Homepage routes mounted at /plugin-api/homepage"); diff --git a/plugins/homepage/src/backend/layout-item-ids.ts b/plugins/homepage/src/backend/layout-item-ids.ts new file mode 100644 index 000000000..f39d1002e --- /dev/null +++ b/plugins/homepage/src/backend/layout-item-ids.ts @@ -0,0 +1,22 @@ +/** Canvas positions refer to local widget ids; only sync ids can cross devices. */ +export async function mapLayoutItemIds( + layoutJson: string, + mapId: (id: string | number) => Promise, +): Promise { + const layout = JSON.parse(layoutJson); + if (!layout || typeof layout !== "object" || Array.isArray(layout)) { + throw new Error("Invalid homepage layout"); + } + if (layout.entries === undefined) return layoutJson; + if (!Array.isArray(layout.entries)) + throw new Error("Invalid homepage layout entries"); + const entries = []; + for (const entry of layout.entries) { + if (!entry || typeof entry !== "object") continue; + const id = entry.itemId; + if (typeof id !== "string" && typeof id !== "number") continue; + const itemId = await mapId(id); + if (itemId !== null) entries.push({ ...entry, itemId }); + } + return JSON.stringify({ ...layout, entries }); +} diff --git a/plugins/homepage/tests/backend/layout-sync.test.ts b/plugins/homepage/tests/backend/layout-sync.test.ts new file mode 100644 index 000000000..7660c8e94 --- /dev/null +++ b/plugins/homepage/tests/backend/layout-sync.test.ts @@ -0,0 +1,84 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { startServer, type TestServer } from "./helpers.js"; + +let server: TestServer | undefined; +afterEach(async () => { + await server?.close(); + server = undefined; +}); + +describe("homepage layout sync", () => { + it("maps widget ids across devices and keeps canvas geometry", async () => { + server = await startServer(); + const items = server.mock.syncEntities.find( + (entry) => entry.type === "homepageItems", + )!; + const entity = server.mock.syncEntities.find( + (entry) => entry.type === "homepageLayouts", + )!; + expect(entity.singleton).toBe(true); + expect(entity.order).toBeGreaterThan(items.order!); + const layout = { + entries: [{ itemId: 7, x: 10, y: 20, w: 300, h: 200, zOrder: 2 }], + pan: { x: 5, y: -3 }, + zoom: 0.75, + }; + const exported = await entity.serialize!( + { layout: JSON.stringify(layout) }, + async (type, id) => + type === "homepageItems" && id === 7 ? "widget-sync-id" : null, + ); + expect(JSON.parse(exported.layout as string).entries[0].itemId).toBe( + "widget-sync-id", + ); + const imported = await entity.deserialize!(exported, async (type, id) => + type === "homepageItems" && id === "widget-sync-id" ? 91 : null, + ); + expect(JSON.parse(imported.layout as string)).toEqual({ + ...layout, + entries: [{ ...layout.entries[0], itemId: 91 }], + }); + }); + + it("drops unavailable widgets and refuses numeric wire ids", async () => { + server = await startServer(); + const entity = server.mock.syncEntities.find( + (entry) => entry.type === "homepageLayouts", + )!; + const resolveId = vi.fn(async () => null); + const result = await entity.deserialize!( + { + layout: JSON.stringify({ + entries: [ + { itemId: "deleted", x: 1 }, + { itemId: 7, x: 2 }, + ], + zoom: 1, + }), + }, + resolveId, + ); + expect(JSON.parse(result.layout as string)).toEqual({ + entries: [], + zoom: 1, + }); + expect(resolveId).toHaveBeenCalledExactlyOnceWith( + "homepageItems", + "deleted", + ); + }); + + it("keeps an empty layout and rejects malformed JSON instead of copying local ids", async () => { + server = await startServer(); + const entity = server.mock.syncEntities.find( + (entry) => entry.type === "homepageLayouts", + )!; + const resolve = vi.fn(async () => null); + await expect(entity.serialize!({ layout: "{}" }, resolve)).resolves.toEqual( + { layout: "{}" }, + ); + await expect( + entity.serialize!({ layout: "broken" }, resolve), + ).rejects.toThrow(); + }); +});