fix: keep mobile board widgets unchanged

This commit is contained in:
ajnart committed 2026-07-29 20:21:59 +02:00
1 parent e2732dce2c
commit b5ffefe707
43 files changed
+1669 -181

No files matched your search

+1 -1
View File
@@ -4,7 +4,7 @@ import { objectEntries } from "@homarr/common";
import { dbEnv } from "@homarr/core/infrastructure/db/env";
import type { HomarrDatabase, HomarrDatabaseMysql, HomarrDatabasePostgresql } from "./driver";
import * as schema from "./schema";
import { schema } from "./schema";
type TableKey = {
[K in keyof typeof schema]: (typeof schema)[K] extends { _: { brand: "Table" } } ? K : never;
+115
View File
@@ -0,0 +1,115 @@
import { stringify } from "superjson";
import { describe, expect, test } from "vitest";
import { createId } from "@homarr/common";
import { eq } from "@homarr/db";
import {
getServerSettingByKeyAsync,
getServerSettingsAsync,
shouldEnableAutomaticMobileLayoutForUpgrade,
} from "@homarr/db/queries";
import { boards, layouts, serverSettings } from "@homarr/db/schema";
import { createDb } from "@homarr/db/test";
import { defaultServerSettings } from "@homarr/server-settings";
import { seedServerSettingsAsync } from "./seed";
describe("automatic mobile layout upgrade default", () => {
test("enables automatic mode for a new instance", () => {
expect(shouldEnableAutomaticMobileLayoutForUpgrade([])).toBe(true);
});
test("enables automatic mode when every existing board has only its canonical layout", () => {
expect(
shouldEnableAutomaticMobileLayoutForUpgrade([
{ boardId: "first", name: "Base", breakpoint: 0 },
{ boardId: "second", name: "Base", breakpoint: 0 },
]),
).toBe(true);
});
test("preserves legacy mode when a board has multiple responsive layouts", () => {
expect(
shouldEnableAutomaticMobileLayoutForUpgrade([
{ boardId: "board", name: "Base", breakpoint: 0 },
{ boardId: "board", name: "Mobile", breakpoint: 480 },
]),
).toBe(false);
});
test("preserves legacy mode when a board has duplicate canonical layouts", () => {
expect(
shouldEnableAutomaticMobileLayoutForUpgrade([
{ boardId: "board", name: "Base", breakpoint: 0 },
{ boardId: "board", name: "Base", breakpoint: 0 },
]),
).toBe(false);
});
test.each([
{ boardId: "board", name: "Phone", breakpoint: 0 },
{ boardId: "board", name: "Base", breakpoint: 480 },
])("preserves legacy mode for a noncanonical saved layout", (existingLayout) => {
expect(shouldEnableAutomaticMobileLayoutForUpgrade([existingLayout])).toBe(false);
});
test("uses the upgrade heuristic when migrations and seeding are skipped", async () => {
const db = createDb();
const boardId = createId();
await db.insert(boards).values({ id: boardId, name: "dashboard", isPublic: false });
await db.insert(layouts).values([
{ id: createId(), boardId, name: "Base", columnCount: 12, breakpoint: 0 },
{ id: createId(), boardId, name: "Phone", columnCount: 4, breakpoint: 480 },
]);
await expect(getServerSettingByKeyAsync(db, "board")).resolves.toMatchObject({
enableAutomaticMobileLayout: false,
});
await expect(getServerSettingsAsync(db)).resolves.toMatchObject({
board: {
enableAutomaticMobileLayout: false,
},
});
});
test("backfills legacy responsive instances once without overriding an explicit choice", async () => {
const db = createDb();
const boardId = createId();
await db.insert(boards).values({ id: boardId, name: "dashboard", isPublic: false });
await db.insert(layouts).values([
{ id: createId(), boardId, name: "Base", columnCount: 12, breakpoint: 0 },
{ id: createId(), boardId, name: "Phone", columnCount: 4, breakpoint: 480 },
]);
await db.insert(serverSettings).values({
settingKey: "board",
value: stringify({
homeBoardId: null,
mobileHomeBoardId: null,
enableStatusByDefault: true,
forceDisableStatus: false,
}),
});
await seedServerSettingsAsync(db);
await seedServerSettingsAsync(db);
await expect(getServerSettingByKeyAsync(db, "board")).resolves.toStrictEqual({
...defaultServerSettings.board,
enableAutomaticMobileLayout: false,
});
await db
.update(serverSettings)
.set({
value: stringify({
...defaultServerSettings.board,
enableAutomaticMobileLayout: true,
}),
})
.where(eq(serverSettings.settingKey, "board"));
await seedServerSettingsAsync(db);
await expect(getServerSettingByKeyAsync(db, "board")).resolves.toStrictEqual(defaultServerSettings.board);
});
});
+19 -4
View File
@@ -21,6 +21,7 @@ import { getMaxGroupPositionAsync, placeAllWidgetsAsync } from "../queries";
import {
getServerSettingByKeyAsync,
insertServerSettingByKeyAsync,
shouldEnableAutomaticMobileLayoutForUpgrade,
updateServerSettingByKeyAsync,
} from "../queries/server-setting";
@@ -245,19 +246,33 @@ const seedDefaultSearchEnginesAsync = async (db: Database) => {
console.log("Set Homarr docs as the default search engine");
};
const seedServerSettingsAsync = async (db: Database) => {
export const seedServerSettingsAsync = async (db: Database) => {
const serverSettingsData = await db.query.serverSettings.findMany();
const existingLayouts = await db.query.layouts.findMany({
columns: {
boardId: true,
name: true,
breakpoint: true,
},
});
const defaultsForThisInstance = {
...defaultServerSettings,
board: {
...defaultServerSettings.board,
enableAutomaticMobileLayout: shouldEnableAutomaticMobileLayoutForUpgrade(existingLayouts),
},
};
for (const settingsKey of defaultServerSettingsKeys) {
const currentDbEntry = serverSettingsData.find((setting) => setting.settingKey === settingsKey);
if (!currentDbEntry) {
await insertServerSettingByKeyAsync(db, settingsKey, defaultServerSettings[settingsKey]);
await insertServerSettingByKeyAsync(db, settingsKey, defaultsForThisInstance[settingsKey]);
console.log(`Created serverSetting through seed key=${settingsKey}`);
continue;
}
const currentSettings = await getServerSettingByKeyAsync(db, settingsKey);
const defaultSettings = defaultServerSettings[settingsKey];
const currentSettings = SuperJSON.parse<(typeof defaultServerSettings)[typeof settingsKey]>(currentDbEntry.value);
const defaultSettings = defaultsForThisInstance[settingsKey];
const missingKeys = objectKeys(defaultSettings).filter((key) => !(key in currentSettings));
if (missingKeys.length === 0) {
+132 -10
View File
@@ -1,29 +1,80 @@
import SuperJSON from "superjson";
import { parse, stringify } from "superjson";
import type { ServerSettings } from "@homarr/server-settings";
import { defaultServerSettings, defaultServerSettingsKeys } from "@homarr/server-settings";
import type { Database } from "..";
import { eq } from "..";
import { eq, sql } from "..";
import { isMysql, isPostgresql } from "../collection";
import type { HomarrDatabaseMysql, HomarrDatabasePostgresql } from "../driver";
import { serverSettings } from "../schema";
import { serverSettings as mysqlServerSettings } from "../schema/mysql";
import { serverSettings as postgresqlServerSettings } from "../schema/postgresql";
interface ExistingBoardLayout {
boardId: string;
name: string;
breakpoint: number;
}
export const shouldEnableAutomaticMobileLayoutForUpgrade = (existingLayouts: ExistingBoardLayout[]) => {
const layoutCounts = new Map<string, number>();
for (const layout of existingLayouts) {
if (layout.name !== "Base" || layout.breakpoint !== 0) {
return false;
}
const nextCount = (layoutCounts.get(layout.boardId) ?? 0) + 1;
if (nextCount > 1) {
return false;
}
layoutCounts.set(layout.boardId, nextCount);
}
return true;
};
const getAutomaticMobileLayoutUpgradeDefaultAsync = async (db: Database) => {
const existingLayouts = await db.query.layouts.findMany({
columns: {
boardId: true,
name: true,
breakpoint: true,
},
});
return shouldEnableAutomaticMobileLayoutForUpgrade(existingLayouts);
};
export const getServerSettingsAsync = async (db: Database) => {
const settings = await db.query.serverSettings.findMany();
let parsedBoardSettings: Partial<ServerSettings["board"]> = {};
return defaultServerSettingsKeys.reduce((acc, settingKey) => {
const setting = settings.find((setting) => setting.settingKey === settingKey);
const mergedSettings = defaultServerSettingsKeys.reduce((acc, settingKey) => {
const setting = settings.find((candidate) => candidate.settingKey === settingKey);
if (!setting) {
// Typescript is not happy because the key is a union and it does not know that they are the same
acc[settingKey] = defaultServerSettings[settingKey] as never;
acc[settingKey] = { ...defaultServerSettings[settingKey] } as never;
return acc;
}
const persistedSettings = parse<Record<string, unknown>>(setting.value);
if (settingKey === "board") {
parsedBoardSettings = persistedSettings as Partial<ServerSettings["board"]>;
}
acc[settingKey] = {
...defaultServerSettings[settingKey],
...SuperJSON.parse(setting.value),
};
...persistedSettings,
} as never;
return acc;
}, {} as ServerSettings);
if (typeof parsedBoardSettings.enableAutomaticMobileLayout !== "boolean") {
mergedSettings.board.enableAutomaticMobileLayout = await getAutomaticMobileLayoutUpgradeDefaultAsync(db);
}
return mergedSettings;
};
export const getServerSettingByKeyAsync = async <TKey extends keyof ServerSettings>(db: Database, key: TKey) => {
@@ -32,10 +83,31 @@ export const getServerSettingByKeyAsync = async <TKey extends keyof ServerSettin
});
if (!dbSettings) {
if (key === "board") {
return {
...defaultServerSettings.board,
enableAutomaticMobileLayout: await getAutomaticMobileLayoutUpgradeDefaultAsync(db),
} as ServerSettings[TKey];
}
return defaultServerSettings[key];
}
return SuperJSON.parse<ServerSettings[TKey]>(dbSettings.value);
const persistedSettings = parse<Partial<ServerSettings[TKey]>>(dbSettings.value);
const mergedSettings = {
...defaultServerSettings[key],
...persistedSettings,
} as ServerSettings[TKey];
if (
key === "board" &&
typeof (persistedSettings as Partial<ServerSettings["board"]>).enableAutomaticMobileLayout !== "boolean"
) {
(mergedSettings as ServerSettings["board"]).enableAutomaticMobileLayout =
await getAutomaticMobileLayoutUpgradeDefaultAsync(db);
}
return mergedSettings;
};
export const updateServerSettingByKeyAsync = async <TKey extends keyof ServerSettings>(
@@ -46,7 +118,7 @@ export const updateServerSettingByKeyAsync = async <TKey extends keyof ServerSet
await db
.update(serverSettings)
.set({
value: SuperJSON.stringify(value),
value: stringify(value),
})
.where(eq(serverSettings.settingKey, key));
};
@@ -58,6 +130,56 @@ export const insertServerSettingByKeyAsync = async <TKey extends keyof ServerSet
) => {
await db.insert(serverSettings).values({
settingKey: key,
value: SuperJSON.stringify(value),
value: stringify(value),
});
};
export const mergeServerSettingByKeyAsync = async <TKey extends keyof ServerSettings>(
db: Database,
key: TKey,
value: ServerSettings[TKey],
patch: Partial<ServerSettings[TKey]>,
) => {
const serializedValue = stringify(value);
const serializedPatch = stringify(patch);
if (isMysql()) {
await (db as unknown as HomarrDatabaseMysql)
.insert(mysqlServerSettings)
.values({ settingKey: key, value: serializedValue })
.onDuplicateKeyUpdate({
set: {
value: sql`JSON_MERGE_PATCH(${mysqlServerSettings.value}, ${serializedPatch})`,
},
});
return;
}
if (isPostgresql()) {
await (db as unknown as HomarrDatabasePostgresql)
.insert(postgresqlServerSettings)
.values({ settingKey: key, value: serializedValue })
.onConflictDoUpdate({
target: postgresqlServerSettings.settingKey,
set: {
value: sql`jsonb_set(
${postgresqlServerSettings.value}::jsonb,
'{json}',
(${postgresqlServerSettings.value}::jsonb -> 'json') || (${serializedPatch}::jsonb -> 'json'),
true
)::text`,
},
});
return;
}
await db
.insert(serverSettings)
.values({ settingKey: key, value: serializedValue })
.onConflictDoUpdate({
target: serverSettings.settingKey,
set: {
value: sql`json_patch(${serverSettings.value}, ${serializedPatch})`,
},
});
};
+6 -3
View File
@@ -11,6 +11,9 @@ import * as mysqlSchema from "../schema/mysql";
import * as postgresqlSchema from "../schema/postgresql";
import * as sqliteSchema from "../schema/sqlite";
const mysqlTables = { ...mysqlSchema };
const postgresqlTables = { ...postgresqlSchema };
// We need the following three types as there is currently no support for Buffer in mysql & pg and
// so we use a custom type which results in the config beeing different
type FixedMysqlConfig = {
@@ -63,7 +66,7 @@ test("schemas should match", () => {
if (!("uniqueName" in sqliteColumn)) return;
if (!("primary" in sqliteColumn)) return;
const mysqlTable = mysqlSchema[tableName];
const mysqlTable = mysqlTables[tableName];
const mysqlColumn = mysqlTable[columnName as keyof typeof mysqlTable] as object;
if (!("isUnique" in mysqlColumn)) return;
@@ -84,7 +87,7 @@ test("schemas should match", () => {
).toEqual(mysqlColumn.primary);
});
const mysqlTable = mysqlSchema[tableName];
const mysqlTable = mysqlTables[tableName];
const sqliteForeignKeys = sqliteTable[Symbol.for("drizzle:SQLiteInlineForeignKeys") as keyof typeof sqliteTable] as
| SqliteForeignKey[]
| undefined;
@@ -144,7 +147,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
const postgresqlTable = postgresqlSchema[tableName];
const postgresqlTable = postgresqlTables[tableName];
Object.entries(sqliteTable).forEach(([columnName, sqliteColumn]: [string, object]) => {
if (!("isUnique" in sqliteColumn)) return;
if (!("uniqueName" in sqliteColumn)) return;