feat(homepage): synchronize canvas layouts across devices (#1515)

This commit is contained in:
ZacharyZcR authored and GitHub committed 2026-09-30 09:47:08 -05:00
1 parent fdb2f74259
commit 7d06686f62
4 files changed
+145 -4

No files matched your search

+17 -4
View File
@@ -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": [
{
+22
View File
@@ -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");
@@ -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<string | number | null>,
): Promise<string> {
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 });
}
@@ -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();
});
});