merge: stack widget modernization on dnd kit

This commit is contained in:
Thomas Camlong committed 2026-08-08 20:40:26 +02:00
commit 9edeba5cd2
940 files changed
+104475 -33649

No files matched your search

@@ -0,0 +1,65 @@
import { parse } from "superjson";
import { expect } from "vitest";
import { BUNDLED_CUSTOM_WIDGETS, customWidgetDefinitionSchema } from "@homarr/custom-widgets/core";
import type { Database } from "..";
import { eq } from "..";
import { seedDataAsync } from "../migrations/seed";
import { customWidgetDefinitions, customWidgetSecrets } from "../schema";
const expectedSeedIds = BUNDLED_CUSTOM_WIDGETS.map(({ id }) => id).toSorted();
const reconstructWidget = (definition: typeof customWidgetDefinitions.$inferSelect) =>
customWidgetDefinitionSchema.parse({
$schema: "homarr-custom-widget-v2",
name: definition.name,
...(definition.description === null ? {} : { description: definition.description }),
...(definition.iconUrl === null ? {} : { iconUrl: definition.iconUrl }),
sources: parse(definition.sources),
requests: parse(definition.requests),
options: parse(definition.options),
template: definition.template,
});
export const expectBundledCustomWidgetsSeeded = async (db: Database) => {
const definitions = await db.query.customWidgetDefinitions.findMany({
where: (table, { like }) => like(table.id, "seed-%"),
});
expect(definitions.map(({ id }) => id).toSorted()).toEqual(expectedSeedIds);
expect(definitions.every(({ enabled }) => !enabled)).toBe(true);
expect(definitions.every(({ creatorId }) => creatorId === null)).toBe(true);
expect(await db.$count(customWidgetSecrets)).toBe(0);
for (const definition of definitions) {
const expected = BUNDLED_CUSTOM_WIDGETS.find(({ id }) => id === definition.id);
expect(expected).toBeDefined();
expect(reconstructWidget(definition)).toEqual(customWidgetDefinitionSchema.parse(expected?.widget));
}
await db
.update(customWidgetDefinitions)
.set({ name: "User-edited dog facts", enabled: true })
.where(eq(customWidgetDefinitions.id, "seed-dog-facts"));
await db.delete(customWidgetDefinitions).where(eq(customWidgetDefinitions.id, "seed-currency-exchange"));
await seedDataAsync(db);
const reseededDefinitions = await db.query.customWidgetDefinitions.findMany({
where: (table, { like }) => like(table.id, "seed-%"),
});
const editedDefinition = reseededDefinitions.find(({ id }) => id === "seed-dog-facts");
const reseededCurrency = reseededDefinitions.find(({ id }) => id === "seed-currency-exchange");
expect(reseededDefinitions.map(({ id }) => id).toSorted()).toEqual(expectedSeedIds);
expect(editedDefinition).toMatchObject({ name: "User-edited dog facts", enabled: true, creatorId: null });
expect(reseededDefinitions.filter(({ id }) => id !== "seed-dog-facts").every(({ enabled }) => !enabled)).toBe(true);
if (!reseededCurrency) throw new Error("Currency Exchange seed was not restored");
expect(reconstructWidget(reseededCurrency)).toEqual(
customWidgetDefinitionSchema.parse(
BUNDLED_CUSTOM_WIDGETS.find(({ id }) => id === "seed-currency-exchange")?.widget,
),
);
expect(await db.$count(customWidgetSecrets)).toBe(0);
};
+162 -1
View File
@@ -1,13 +1,28 @@
import path from "path";
import { readFileSync } from "node:fs";
import { MySqlContainer } from "@testcontainers/mysql";
import { drizzle } from "drizzle-orm/mysql2";
import { migrate } from "drizzle-orm/mysql2/migrator";
import mysql from "mysql2";
import { describe, test } from "vitest";
import SuperJSON from "superjson";
import { describe, expect, test } from "vitest";
import { DB_CASING } from "@homarr/core/infrastructure/db/constants";
import * as mysqlSchema from "../schema/mysql";
import type { Database } from "..";
import { seedDataAsync } from "../migrations/seed";
import { expectBundledCustomWidgetsSeeded } from "./custom-widget-seed-assertions";
const applyMigration = async (connection: mysql.Connection, fileName: string) => {
const migration = readFileSync(path.join(__dirname, "..", "migrations", "mysql", fileName), "utf8");
for (const statement of migration
.split("--> statement-breakpoint")
.map((value) => value.trim())
.filter(Boolean)) {
await connection.promise().query(statement);
}
};
describe("Mysql Migration", () => {
test("should add all tables and keys specified in migration files", async () => {
@@ -31,11 +46,157 @@ describe("Mysql Migration", () => {
await migrate(database, {
migrationsFolder: path.join(__dirname, "..", "migrations", "mysql"),
});
await seedDataAsync(database as unknown as Database);
// Check if users table exists
await database.query.users.findMany();
await expectBundledCustomWidgetsSeeded(database as unknown as Database);
const [customWidgetTables] = await connection
.promise()
.query<mysql.RowDataPacket[]>("SHOW TABLES LIKE 'custom_widget%'");
expect(customWidgetTables).toHaveLength(4);
connection.end();
await mysqlContainer.stop();
}, 120_000);
test("Custom Widget v2 migration preserves v1 data and board references", async () => {
const mysqlContainer = await new MySqlContainer("mysql:latest").start();
const connection = mysql.createConnection({
host: mysqlContainer.getHost(),
database: mysqlContainer.getDatabase(),
port: mysqlContainer.getPort(),
user: mysqlContainer.getUsername(),
password: mysqlContainer.getUserPassword(),
multipleStatements: true,
});
const sql = connection.promise();
try {
const legacyPlacementOptions = SuperJSON.stringify({ definitionId: "legacy-weather", refreshInterval: 30 });
await sql.query(`
CREATE TABLE \`user\` (\`id\` varchar(64) NOT NULL PRIMARY KEY);
CREATE TABLE \`groupPermission\` (
\`group_id\` varchar(64) NOT NULL,
\`permission\` text NOT NULL
);
CREATE TABLE \`item\` (
\`id\` varchar(64) NOT NULL PRIMARY KEY,
\`kind\` varchar(64) NOT NULL,
\`options\` text NOT NULL
);
CREATE TABLE \`custom_widget_definition\` (
\`id\` varchar(64) NOT NULL PRIMARY KEY,
\`name\` varchar(256) NOT NULL,
\`description\` text,
\`icon_url\` text,
\`url\` text NOT NULL,
\`auth_type\` varchar(32) NOT NULL DEFAULT 'none',
\`header_name\` varchar(256),
\`method\` varchar(16) NOT NULL DEFAULT 'GET',
\`request_body\` text,
\`display_type\` varchar(32) NOT NULL DEFAULT 'singleValue',
\`display_config\` text NOT NULL,
\`enabled\` boolean NOT NULL DEFAULT true,
\`created_at\` timestamp NOT NULL,
\`updated_at\` timestamp NOT NULL,
\`creator_id\` varchar(64),
CONSTRAINT \`custom_widget_definition_creator_id_user_id_fk\`
FOREIGN KEY (\`creator_id\`) REFERENCES \`user\`(\`id\`) ON DELETE SET NULL
);
CREATE TABLE \`custom_widget_secret\` (
\`kind\` varchar(64) NOT NULL,
\`value\` text NOT NULL,
\`updated_at\` timestamp NOT NULL,
\`definition_id\` varchar(64) NOT NULL,
PRIMARY KEY (\`definition_id\`, \`kind\`),
CONSTRAINT \`cw_secret_definition_id_cw_definition_id_fk\`
FOREIGN KEY (\`definition_id\`) REFERENCES \`custom_widget_definition\`(\`id\`) ON DELETE CASCADE
);
INSERT INTO \`user\` (\`id\`) VALUES ('owner');
INSERT INTO \`custom_widget_definition\` (
\`id\`, \`name\`, \`url\`, \`auth_type\`, \`method\`, \`display_type\`, \`display_config\`,
\`enabled\`, \`created_at\`, \`updated_at\`, \`creator_id\`
) VALUES (
'legacy-weather', 'Weather', 'https://example.test/weather', 'bearer', 'GET', 'singleValue',
'{"type":"singleValue","jsonPath":"$.temperature"}', true,
'2023-11-14 00:00:00', '2023-11-14 00:00:01', 'owner'
);
INSERT INTO \`custom_widget_secret\` (\`kind\`, \`value\`, \`updated_at\`, \`definition_id\`)
VALUES ('apiKey', 'encrypted.value', '2023-11-14 00:00:01', 'legacy-weather');
INSERT INTO \`groupPermission\` (\`group_id\`, \`permission\`) VALUES
('editors', 'custom-widget-manage'),
('editors', 'custom-widget-secret-write'),
('editors', 'board-create');
`);
await sql.execute("INSERT INTO `item` (`id`, `kind`, `options`) VALUES ('weather-item', 'customApi', ?)", [
legacyPlacementOptions,
]);
await applyMigration(connection, "0044_custom_widget_v2_tables.sql");
await sql.query(`
INSERT INTO \`custom_widget_v2_definition\` (
\`id\`, \`name\`, \`sources\`, \`requests\`, \`options\`, \`template\`, \`enabled\`,
\`created_at\`, \`updated_at\`, \`creator_id\`
) VALUES (
'legacy-weather', 'Weather v2', '[]', '[]', '[]', '<Text>Weather</Text>', true,
'2023-11-14 00:00:02', '2023-11-14 00:00:03', 'owner'
);
INSERT INTO \`custom_widget_v2_secret\` (
\`source_id\`, \`kind\`, \`encrypted_value\`, \`updated_at\`, \`definition_id\`
) VALUES ('weather', 'apiKey', 'encrypted.v2-value', '2023-11-14 00:00:03', 'legacy-weather');
`);
const [definitions] = await sql.query<mysql.RowDataPacket[]>(
"SELECT id, name, url, enabled, creator_id FROM custom_widget_definition",
);
const [secrets] = await sql.query<mysql.RowDataPacket[]>(
"SELECT definition_id, kind, value FROM custom_widget_secret",
);
expect(definitions).toEqual([
expect.objectContaining({
id: "legacy-weather",
name: "Weather",
url: "https://example.test/weather",
enabled: 1,
creator_id: "owner",
}),
]);
expect(secrets).toEqual([
expect.objectContaining({ definition_id: "legacy-weather", kind: "apiKey", value: "encrypted.value" }),
]);
const [v2Definitions] = await sql.query<mysql.RowDataPacket[]>(
"SELECT id, name, template FROM custom_widget_v2_definition",
);
const [v2Secrets] = await sql.query<mysql.RowDataPacket[]>(
"SELECT definition_id, source_id, kind, encrypted_value FROM custom_widget_v2_secret",
);
expect(v2Definitions).toEqual([
expect.objectContaining({ id: "legacy-weather", name: "Weather v2", template: "<Text>Weather</Text>" }),
]);
expect(v2Secrets).toEqual([
expect.objectContaining({
definition_id: "legacy-weather",
source_id: "weather",
kind: "apiKey",
encrypted_value: "encrypted.v2-value",
}),
]);
const [items] = await sql.query<mysql.RowDataPacket[]>("SELECT options FROM item WHERE id = 'weather-item'");
expect(items).toEqual([{ options: legacyPlacementOptions }]);
const [permissions] = await sql.query<mysql.RowDataPacket[]>(
"SELECT permission FROM groupPermission ORDER BY permission",
);
expect(permissions).toEqual([{ permission: "board-create" }]);
const [v1Columns] = await sql.query<mysql.RowDataPacket[]>("SHOW COLUMNS FROM custom_widget_definition");
expect(v1Columns.map((column) => column.Field)).toEqual(
expect.arrayContaining(["id", "url", "auth_type", "display_type", "display_config"]),
);
expect(v1Columns.map((column) => column.Field)).not.toContain("sources");
const [legacyTables] = await sql.query<mysql.RowDataPacket[]>("SHOW TABLES LIKE 'legacy_custom_widget%'");
expect(legacyTables).toEqual([]);
} finally {
await sql.end();
await mysqlContainer.stop();
}
}, 120_000);
});
+162 -1
View File
@@ -1,13 +1,28 @@
import path from "path";
import { readFileSync } from "node:fs";
import { PostgreSqlContainer } from "@testcontainers/postgresql";
import { drizzle } from "drizzle-orm/node-postgres";
import { migrate } from "drizzle-orm/node-postgres/migrator";
import { Pool } from "pg";
import { describe, test } from "vitest";
import SuperJSON from "superjson";
import { describe, expect, test } from "vitest";
import { DB_CASING } from "@homarr/core/infrastructure/db/constants";
import * as pgSchema from "../schema/postgresql";
import type { Database } from "..";
import { seedDataAsync } from "../migrations/seed";
import { expectBundledCustomWidgetsSeeded } from "./custom-widget-seed-assertions";
const applyMigration = async (pool: Pool, fileName: string) => {
const migration = readFileSync(path.join(__dirname, "..", "migrations", "postgresql", fileName), "utf8");
for (const statement of migration
.split("--> statement-breakpoint")
.map((value) => value.trim())
.filter(Boolean)) {
await pool.query(statement);
}
};
describe("PostgreSql Migration", () => {
test("should add all tables and keys specified in migration files", async () => {
@@ -36,13 +51,159 @@ describe("PostgreSql Migration", () => {
await migrate(database, {
migrationsFolder: path.join(__dirname, "..", "migrations", "postgresql"),
});
await seedDataAsync(database as unknown as Database);
// Check if users table exists
await database.query.users.findMany();
await expectBundledCustomWidgetsSeeded(database as unknown as Database);
expect(
(
await pool.query(
"SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' AND table_name LIKE 'custom_widget%'",
)
).rows,
).toHaveLength(4);
// Close the pool to release resources
await pool.end();
// Stop the container
await postgreSqlContainer.stop();
}, 40_000);
test("Custom Widget v2 migration preserves v1 data and board references", async () => {
const postgreSqlContainer = await new PostgreSqlContainer("postgres:latest").start();
const pool = new Pool({
user: postgreSqlContainer.getUsername(),
database: postgreSqlContainer.getDatabase(),
password: postgreSqlContainer.getPassword(),
port: postgreSqlContainer.getPort(),
host: postgreSqlContainer.getHost(),
});
try {
const legacyPlacementOptions = SuperJSON.stringify({ definitionId: "legacy-weather", refreshInterval: 30 });
await pool.query(`
CREATE TABLE "user" ("id" varchar(64) PRIMARY KEY NOT NULL);
CREATE TABLE "groupPermission" (
"group_id" varchar(64) NOT NULL,
"permission" text NOT NULL
);
CREATE TABLE "item" (
"id" varchar(64) PRIMARY KEY NOT NULL,
"kind" varchar(64) NOT NULL,
"options" text NOT NULL
);
CREATE TABLE "custom_widget_definition" (
"id" varchar(64) PRIMARY KEY NOT NULL,
"name" varchar(256) NOT NULL,
"description" text,
"icon_url" text,
"url" text NOT NULL,
"auth_type" varchar(32) DEFAULT 'none' NOT NULL,
"header_name" varchar(256),
"method" varchar(16) DEFAULT 'GET' NOT NULL,
"request_body" text,
"display_type" varchar(32) DEFAULT 'singleValue' NOT NULL,
"display_config" text NOT NULL,
"enabled" boolean DEFAULT true NOT NULL,
"created_at" timestamp NOT NULL,
"updated_at" timestamp NOT NULL,
"creator_id" varchar(64),
CONSTRAINT "custom_widget_definition_creator_id_user_id_fk"
FOREIGN KEY ("creator_id") REFERENCES "user"("id") ON DELETE SET NULL
);
CREATE TABLE "custom_widget_secret" (
"kind" varchar(64) NOT NULL,
"value" text NOT NULL,
"updated_at" timestamp NOT NULL,
"definition_id" varchar(64) NOT NULL,
CONSTRAINT "custom_widget_secret_definition_id_kind_pk" PRIMARY KEY("definition_id", "kind"),
CONSTRAINT "custom_widget_secret_definition_id_custom_widget_definition_id_fk"
FOREIGN KEY ("definition_id") REFERENCES "custom_widget_definition"("id") ON DELETE CASCADE
);
INSERT INTO "user" ("id") VALUES ('owner');
INSERT INTO "custom_widget_definition" (
"id", "name", "url", "auth_type", "method", "display_type", "display_config", "enabled",
"created_at", "updated_at", "creator_id"
) VALUES (
'legacy-weather', 'Weather', 'https://example.test/weather', 'bearer', 'GET', 'singleValue',
'{"type":"singleValue","jsonPath":"$.temperature"}', true,
'2023-11-14 00:00:00', '2023-11-14 00:00:01', 'owner'
);
INSERT INTO "custom_widget_secret" ("kind", "value", "updated_at", "definition_id")
VALUES ('apiKey', 'encrypted.value', '2023-11-14 00:00:01', 'legacy-weather');
INSERT INTO "groupPermission" ("group_id", "permission") VALUES
('editors', 'custom-widget-manage'),
('editors', 'custom-widget-secret-write'),
('editors', 'board-create');
`);
await pool.query(`INSERT INTO "item" ("id", "kind", "options") VALUES ('weather-item', 'customApi', $1)`, [
legacyPlacementOptions,
]);
await applyMigration(pool, "0010_custom_widget_v2_tables.sql");
await pool.query(`
INSERT INTO "custom_widget_v2_definition" (
"id", "name", "sources", "requests", "options", "template", "enabled",
"created_at", "updated_at", "creator_id"
) VALUES (
'legacy-weather', 'Weather v2', '[]', '[]', '[]', '<Text>Weather</Text>', true,
'2023-11-14 00:00:02', '2023-11-14 00:00:03', 'owner'
);
INSERT INTO "custom_widget_v2_secret" (
"source_id", "kind", "encrypted_value", "updated_at", "definition_id"
) VALUES ('weather', 'apiKey', 'encrypted.v2-value', '2023-11-14 00:00:03', 'legacy-weather');
`);
expect(
(await pool.query("SELECT id, name, url, enabled, creator_id FROM custom_widget_definition")).rows,
).toEqual([
expect.objectContaining({
id: "legacy-weather",
name: "Weather",
url: "https://example.test/weather",
enabled: true,
creator_id: "owner",
}),
]);
expect((await pool.query("SELECT definition_id, kind, value FROM custom_widget_secret")).rows).toEqual([
expect.objectContaining({ definition_id: "legacy-weather", kind: "apiKey", value: "encrypted.value" }),
]);
expect((await pool.query("SELECT id, name, template FROM custom_widget_v2_definition")).rows).toEqual([
expect.objectContaining({ id: "legacy-weather", name: "Weather v2", template: "<Text>Weather</Text>" }),
]);
expect(
(await pool.query("SELECT definition_id, source_id, kind, encrypted_value FROM custom_widget_v2_secret")).rows,
).toEqual([
expect.objectContaining({
definition_id: "legacy-weather",
source_id: "weather",
kind: "apiKey",
encrypted_value: "encrypted.v2-value",
}),
]);
expect((await pool.query("SELECT options FROM item WHERE id = 'weather-item'")).rows).toEqual([
{ options: legacyPlacementOptions },
]);
expect((await pool.query('SELECT "permission" FROM "groupPermission" ORDER BY "permission"')).rows).toEqual([
{ permission: "board-create" },
]);
const v1Columns = (
await pool.query(
"SELECT column_name FROM information_schema.columns WHERE table_schema = 'public' AND table_name = 'custom_widget_definition'",
)
).rows.map((column: { column_name: string }) => column.column_name);
expect(v1Columns).toEqual(expect.arrayContaining(["id", "url", "auth_type", "display_type", "display_config"]));
expect(v1Columns).not.toContain("sources");
expect(
(
await pool.query(
"SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' AND table_name LIKE 'legacy_custom_widget%'",
)
).rows,
).toEqual([]);
} finally {
await pool.end();
await postgreSqlContainer.stop();
}
}, 40_000);
});
+4 -2
View File
@@ -1,5 +1,5 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
/* eslint-disable import/namespace -- The test compares matching schema tables by their shared keys. */
/* eslint-disable import/namespace -- This test compares equivalent schema exports by shared dynamic keys. */
import type { Column, InferSelectModel } from "drizzle-orm";
import type { ForeignKey as MysqlForeignKey, MySqlTableWithColumns } from "drizzle-orm/mysql-core";
import type { PgTableWithColumns, ForeignKey as PostgresqlForeignKey } from "drizzle-orm/pg-core";
@@ -24,7 +24,6 @@ type FixedMysqlConfig = {
};
};
};
type FixedPostgresqlConfig = {
[key in keyof PostgreisqlConfig]: {
[column in keyof PostgreisqlConfig[key]]: {
@@ -64,6 +63,7 @@ test("schemas should match", () => {
if (!("uniqueName" in sqliteColumn)) return;
if (!("primary" in sqliteColumn)) return;
// oxlint-disable-next-line import/namespace -- this test intentionally compares matching dynamic schema keys.
const mysqlTable = mysqlSchema[tableName];
const mysqlColumn = mysqlTable[columnName as keyof typeof mysqlTable] as object;
@@ -85,6 +85,7 @@ test("schemas should match", () => {
).toEqual(mysqlColumn.primary);
});
// oxlint-disable-next-line import/namespace -- this test intentionally compares matching dynamic schema keys.
const mysqlTable = mysqlSchema[tableName];
const sqliteForeignKeys = sqliteTable[Symbol.for("drizzle:SQLiteInlineForeignKeys") as keyof typeof sqliteTable] as
| SqliteForeignKey[]
@@ -145,6 +146,7 @@ test("schemas should match for postgresql", () => {
objectEntries(sqliteSchema).forEach(([tableName, sqliteTable]) => {
// keys of sqliteSchema and postgresqlSchema are the same, so we can safely use tableName as key
// skipcq: JS-E1007
// oxlint-disable-next-line import/namespace -- this test intentionally compares matching dynamic schema keys.
const postgresqlTable = postgresqlSchema[tableName];
Object.entries(sqliteTable).forEach(([columnName, sqliteColumn]: [string, object]) => {
if (!("isUnique" in sqliteColumn)) return;
+164
View File
@@ -0,0 +1,164 @@
import path from "path";
import { readFileSync } from "node:fs";
import BetterSqlite3 from "better-sqlite3";
import { drizzle } from "drizzle-orm/better-sqlite3";
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
import SuperJSON from "superjson";
import { expect, test } from "vitest";
import { DB_CASING } from "@homarr/core/infrastructure/db/constants";
import type { Database } from "..";
import { seedDataAsync } from "../migrations/seed";
import * as sqliteSchema from "../schema/sqlite";
import { expectBundledCustomWidgetsSeeded } from "./custom-widget-seed-assertions";
const applyMigration = (connection: BetterSqlite3.Database, fileName: string) => {
const migration = readFileSync(path.join(__dirname, "..", "migrations", "sqlite", fileName), "utf8");
for (const statement of migration
.split("--> statement-breakpoint")
.map((value) => value.trim())
.filter(Boolean)) {
connection.exec(statement);
}
};
test("SQLite migrations seed the five disabled bundled custom widgets", async () => {
const connection = new BetterSqlite3(":memory:");
const database = drizzle(connection, { schema: sqliteSchema, casing: DB_CASING });
migrate(database, { migrationsFolder: path.join(__dirname, "..", "migrations", "sqlite") });
await seedDataAsync(database as unknown as Database);
await expectBundledCustomWidgetsSeeded(database as unknown as Database);
expect(
connection
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name LIKE 'custom_widget%' ORDER BY name")
.all(),
).toEqual([
{ name: "custom_widget_definition" },
{ name: "custom_widget_secret" },
{ name: "custom_widget_v2_definition" },
{ name: "custom_widget_v2_secret" },
]);
connection.close();
});
test("Custom Widget v2 migration preserves v1 data and board references", () => {
const connection = new BetterSqlite3(":memory:");
const legacyPlacementOptions = SuperJSON.stringify({ definitionId: "legacy-weather", refreshInterval: 30 });
connection.exec(`
CREATE TABLE user (id text PRIMARY KEY NOT NULL);
CREATE TABLE groupPermission (
group_id text NOT NULL,
permission text NOT NULL
);
CREATE TABLE item (
id text PRIMARY KEY NOT NULL,
kind text NOT NULL,
options text NOT NULL
);
CREATE TABLE custom_widget_definition (
id text PRIMARY KEY NOT NULL,
name text NOT NULL,
description text,
icon_url text,
url text NOT NULL,
auth_type text DEFAULT 'none' NOT NULL,
header_name text,
method text DEFAULT 'GET' NOT NULL,
request_body text,
display_type text DEFAULT 'singleValue' NOT NULL,
display_config text DEFAULT '{}' NOT NULL,
enabled integer DEFAULT true NOT NULL,
created_at integer NOT NULL,
updated_at integer NOT NULL,
creator_id text REFERENCES user(id) ON DELETE SET NULL
);
CREATE TABLE custom_widget_secret (
kind text NOT NULL,
value text NOT NULL,
updated_at integer NOT NULL,
definition_id text NOT NULL REFERENCES custom_widget_definition(id) ON DELETE CASCADE,
PRIMARY KEY(definition_id, kind)
);
INSERT INTO user (id) VALUES ('owner');
INSERT INTO custom_widget_definition (
id, name, url, auth_type, method, display_type, display_config, enabled, created_at, updated_at, creator_id
) VALUES ('legacy-weather', 'Weather', 'https://example.test/weather', 'bearer', 'GET', 'singleValue',
'{"type":"singleValue","jsonPath":"$.temperature"}', true, 1700000000, 1700000001, 'owner');
INSERT INTO custom_widget_secret (kind, value, updated_at, definition_id)
VALUES ('apiKey', 'encrypted.value', 1700000001, 'legacy-weather');
INSERT INTO groupPermission (group_id, permission) VALUES
('editors', 'custom-widget-manage'),
('editors', 'custom-widget-secret-write'),
('editors', 'board-create');
`);
connection
.prepare("INSERT INTO item (id, kind, options) VALUES ('weather-item', 'customApi', ?)")
.run(legacyPlacementOptions);
applyMigration(connection, "0042_custom_widget_v2_tables.sql");
connection.exec(`
INSERT INTO custom_widget_v2_definition (
id, name, sources, requests, options, template, enabled, created_at, updated_at, creator_id
) VALUES (
'legacy-weather', 'Weather v2', '[]', '[]', '[]', '<Text>Weather</Text>', true,
1700000002, 1700000003, 'owner'
);
INSERT INTO custom_widget_v2_secret (source_id, kind, encrypted_value, updated_at, definition_id)
VALUES ('weather', 'apiKey', 'encrypted.v2-value', 1700000003, 'legacy-weather');
`);
expect(connection.prepare("SELECT * FROM custom_widget_definition").get()).toMatchObject({
id: "legacy-weather",
name: "Weather",
url: "https://example.test/weather",
enabled: 1,
creator_id: "owner",
});
expect(connection.prepare("SELECT * FROM custom_widget_secret").get()).toMatchObject({
definition_id: "legacy-weather",
kind: "apiKey",
value: "encrypted.value",
});
expect(connection.prepare("SELECT * FROM custom_widget_v2_definition").get()).toMatchObject({
id: "legacy-weather",
name: "Weather v2",
template: "<Text>Weather</Text>",
});
expect(connection.prepare("SELECT * FROM custom_widget_v2_secret").get()).toMatchObject({
definition_id: "legacy-weather",
source_id: "weather",
encrypted_value: "encrypted.v2-value",
});
expect(connection.prepare("SELECT options FROM item WHERE id = 'weather-item'").get()).toEqual({
options: legacyPlacementOptions,
});
expect(connection.prepare("SELECT permission FROM groupPermission ORDER BY permission").all()).toEqual([
{ permission: "board-create" },
]);
expect(
connection
.prepare(
`SELECT id, name, url, auth_type, header_name, method, request_body, display_type, display_config,
enabled, created_at, updated_at, creator_id
FROM custom_widget_definition`,
)
.get(),
).toMatchObject({
id: "legacy-weather",
auth_type: "bearer",
display_type: "singleValue",
});
expect(
connection
.prepare(
"SELECT name FROM sqlite_master WHERE type = 'table' AND name LIKE 'legacy_custom_widget%' ORDER BY name",
)
.all(),
).toEqual([]);
connection.close();
});