mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-31 15:45:23 +00:00
feat(homepage): synchronize canvas layouts across devices (#1515)
This commit is contained in:
1 parent
fdb2f74259
commit
7d06686f62
4 files changed
+145
-4
No files matched your search
@@ -34,8 +34,14 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"dashboardCards": [
|
"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": [
|
"permissions": [
|
||||||
{
|
{
|
||||||
@@ -46,9 +52,16 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"actionSlots": [
|
"actionSlots": [
|
||||||
{ "id": "dashboard.secondaryView", "accepts": ["component"] }
|
{
|
||||||
|
"id": "dashboard.secondaryView",
|
||||||
|
"accepts": ["component"]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"syncEntities": [
|
||||||
|
"dashboardServiceLinks",
|
||||||
|
"homepageItems",
|
||||||
|
"homepageLayouts"
|
||||||
],
|
],
|
||||||
"syncEntities": ["dashboardServiceLinks", "homepageItems"],
|
|
||||||
"settings": {
|
"settings": {
|
||||||
"admin": [
|
"admin": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { mapLayoutItemIds } from "./layout-item-ids.js";
|
||||||
import type { Router } from "express";
|
import type { Router } from "express";
|
||||||
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
||||||
import {
|
import {
|
||||||
@@ -45,6 +46,27 @@ export async function activate(ctx: PluginContext) {
|
|||||||
order: 70,
|
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.services.provide("homepage.items", createHomepageItemsService(ctx, repo));
|
||||||
|
|
||||||
ctx.log.info("Homepage routes mounted at /plugin-api/homepage");
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user