feat: complete automatic mobile board UX

This commit is contained in:
ajnart committed 2026-08-14 11:32:09 +02:00
1 parent b217d8e026
commit e6bd1adfc2
50 files changed
+1279 -343

No files matched your search

+2 -4
View File
@@ -1913,9 +1913,6 @@ const getHomeIdBoardAsync = async (
user: InferSelectModel<typeof users> | null,
deviceType: DeviceType,
) => {
// Mobile home-board selection remains device-based because the server does
// not know the resolved CSS viewport. Tablet rendering is resolved against
// 48em on the client and must not be guessed here.
const settingKey = deviceType === "mobile" ? "mobileHomeBoardId" : "homeBoardId";
if (!user) {
@@ -1937,7 +1934,8 @@ const getHomeIdBoardAsync = async (
and(
eq(groupMembers.userId, user.id),
not(eq(groups.name, everyoneGroup)),
or(not(isNull(groups[settingKey])), not(isNull(groups.homeBoardId))),
not(isNull(groups[settingKey])),
not(isNull(groups.homeBoardId)),
),
)
.orderBy(asc(groups.position))
+14 -4
View File
@@ -19,6 +19,7 @@ import { nextOnboardingStepAsync } from "./onboard/onboard-queries";
const boardServerSettingsSchema = z.object({
homeBoardId: z.string().nullable(),
mobileHomeBoardId: z.string().nullable(),
enableAutomaticMobileLayout: z.boolean(),
enableStatusByDefault: z.boolean(),
forceDisableStatus: z.boolean(),
}) satisfies z.ZodType<ServerSettings["board"]>;
@@ -39,7 +40,7 @@ export const serverSettingsRouter = createTRPCRouter({
mcp: {
enabled: true,
description:
"Get global board defaults, including desktop/mobile home board IDs and status behavior. Requires admin permission",
"Get global board defaults, including desktop/mobile home board IDs, automatic mobile layout mode, and status behavior. Requires admin permission",
},
})
.input(z.void())
@@ -54,7 +55,7 @@ export const serverSettingsRouter = createTRPCRouter({
mcp: {
enabled: true,
description:
"Update global board defaults. Requires admin permission. Optional fields: homeBoardId, mobileHomeBoardId, enableStatusByDefault, forceDisableStatus. Home board IDs must reference public boards or be null",
"Update global board defaults. Requires admin permission. Optional fields: homeBoardId, mobileHomeBoardId, enableAutomaticMobileLayout, enableStatusByDefault, forceDisableStatus. Home board IDs must reference public boards or be null",
},
})
.input(boardServerSettingsUpdateSchema)
@@ -103,10 +104,19 @@ export const serverSettingsRouter = createTRPCRouter({
)
.mutation(async ({ ctx, input }) => {
const current = await getServerSettingByKeyAsync(ctx.db, input.settingsKey);
await updateServerSettingByKeyAsync(ctx.db, input.settingsKey, {
const next = {
...current,
...input.value,
} as ServerSettings[keyof ServerSettings]);
} as ServerSettings[keyof ServerSettings];
const existing = await ctx.db.query.serverSettings.findFirst({
where: eq(serverSettings.settingKey, input.settingsKey),
});
if (existing) {
await updateServerSettingByKeyAsync(ctx.db, input.settingsKey, next);
} else {
await insertServerSettingByKeyAsync(ctx.db, input.settingsKey, next);
}
}),
initSettings: onboardingProcedure
.requiresStep("settings")
+3 -64
View File
@@ -549,64 +549,6 @@ describe("getHomeBoard should return home board", () => {
});
expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "view");
});
test.each([
{ deviceType: "mobile", expectedBoard: "mobile-home" },
{ deviceType: "tablet", expectedBoard: "desktop-home" },
] as const)(
"should use the $expectedBoard preference for a $deviceType device",
async ({ deviceType, expectedBoard }) => {
// Arrange
const db = createDb();
const caller = boardRouter.createCaller({ db, deviceType, session: defaultSession });
const mobileBoardProps = await createFullBoardAsync(db, "mobile-home");
const desktopBoardProps = await createFullBoardAsync(db, "desktop-home");
await db
.update(users)
.set({
homeBoardId: desktopBoardProps.boardId,
mobileHomeBoardId: mobileBoardProps.boardId,
})
.where(eq(users.id, defaultCreatorId));
// Act
const result = await caller.getHomeBoard();
// Assert
expectInputToBeFullBoardWithName(result, {
...(expectedBoard === "mobile-home" ? mobileBoardProps : desktopBoardProps),
name: expectedBoard,
});
},
);
test("should return a mobile-only group home board", async () => {
// Arrange
const db = createDb();
const caller = boardRouter.createCaller({ db, deviceType: "mobile", session: defaultSession });
const mobileBoardProps = await createFullBoardAsync(db, "group-mobile-home");
const groupId = createId();
await db.insert(groups).values({
id: groupId,
name: "mobile group",
position: 1,
homeBoardId: null,
mobileHomeBoardId: mobileBoardProps.boardId,
});
await db.insert(groupMembers).values({
userId: defaultCreatorId,
groupId,
});
// Act
const result = await caller.getHomeBoard();
// Assert
expectInputToBeFullBoardWithName(result, {
name: "group-mobile-home",
...mobileBoardProps,
});
});
test("should return global home board when user doesn't have one", async () => {
// Arrange
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
@@ -2077,12 +2019,9 @@ const expectInputToBeFullBoardWithName = (
};
const createFullBoardAsync = async (db: Database, name: string) => {
await db
.insert(users)
.values({
id: defaultCreatorId,
})
.onConflictDoNothing();
await db.insert(users).values({
id: defaultCreatorId,
});
const boardId = createId();
await db.insert(boards).values({
@@ -56,6 +56,33 @@ describe("getAll server settings", () => {
});
describe("saveSettings", () => {
test("saveSettings should persist settings when defaults are not seeded", async () => {
const db = createDb();
const caller = serverSettingsRouter.createCaller({
db,
deviceType: undefined,
session: defaultSession,
});
await caller.saveSettings({
settingsKey: "board",
value: {
enableAutomaticMobileLayout: false,
},
});
const dbSettings = await db.select().from(serverSettings);
expect(dbSettings).toStrictEqual([
{
settingKey: "board",
value: stringify({
...defaultServerSettings.board,
enableAutomaticMobileLayout: false,
}),
},
]);
});
test("saveSettings should update settings and return true when it updated only one", async () => {
const db = createDb();
const caller = serverSettingsRouter.createCaller({
@@ -103,6 +130,27 @@ describe("board settings API", () => {
await expect(caller.getBoardSettings()).resolves.toStrictEqual(defaultServerSettings.board);
});
test("getBoardSettings should backfill the automatic layout default for legacy values", async () => {
const db = createDb();
const caller = serverSettingsRouter.createCaller({
db,
deviceType: undefined,
session: defaultSession,
});
const legacyBoardSettings = {
homeBoardId: defaultServerSettings.board.homeBoardId,
mobileHomeBoardId: defaultServerSettings.board.mobileHomeBoardId,
enableStatusByDefault: defaultServerSettings.board.enableStatusByDefault,
forceDisableStatus: defaultServerSettings.board.forceDisableStatus,
};
await db.insert(serverSettings).values({
settingKey: "board",
value: stringify(legacyBoardSettings),
});
await expect(caller.getBoardSettings()).resolves.toStrictEqual(defaultServerSettings.board);
});
test("updateBoardSettings should insert settings when defaults are not persisted", async () => {
const db = createDb();
const caller = serverSettingsRouter.createCaller({
@@ -145,6 +193,7 @@ describe("board settings API", () => {
await caller.updateBoardSettings({ homeBoardId: boardId });
const result = await caller.updateBoardSettings({
mobileHomeBoardId: boardId,
enableAutomaticMobileLayout: false,
enableStatusByDefault: false,
});
@@ -152,6 +201,7 @@ describe("board settings API", () => {
...defaultServerSettings.board,
homeBoardId: boardId,
mobileHomeBoardId: boardId,
enableAutomaticMobileLayout: false,
enableStatusByDefault: false,
});
const dbSettings = await db.select().from(serverSettings);
+17 -1
View File
@@ -2,7 +2,7 @@
import { describe, expect, it, vi } from "vitest";
import { settleIntegrationQueries } from "./settle-integrations";
import { getIntegrationQueryProvenance, settleIntegrationQueries } from "./settle-integrations";
const integrations = [
{ id: "first", name: "First", kind: "mock" },
@@ -76,3 +76,19 @@ describe("settleIntegrationQueries", () => {
).resolves.toEqual([]);
});
});
describe("getIntegrationQueryProvenance", () => {
it("counts dropped and stale integration results independently", () => {
expect(getIntegrationQueryProvenance(4, [{ isStale: false }, { isStale: true }, { isStale: true }])).toEqual({
failedIntegrationCount: 1,
staleIntegrationCount: 2,
});
});
it("does not report failures for complete current results", () => {
expect(getIntegrationQueryProvenance(2, [{ isStale: false }, {}])).toEqual({
failedIntegrationCount: 0,
staleIntegrationCount: 0,
});
});
});
+13
View File
@@ -20,6 +20,19 @@ interface Options<TIntegration extends IntegrationLike, TResult> {
throwOnAllFailures?: boolean;
}
export interface IntegrationQueryProvenance {
failedIntegrationCount: number;
staleIntegrationCount: number;
}
export const getIntegrationQueryProvenance = (
selectedIntegrationCount: number,
results: readonly { isStale?: boolean }[],
): IntegrationQueryProvenance => ({
failedIntegrationCount: Math.max(0, selectedIntegrationCount - results.length),
staleIntegrationCount: results.filter((result) => result.isStale === true).length,
});
export async function settleIntegrationQueries<TIntegration extends IntegrationLike, TResult>(
integrations: TIntegration[],
fn: (integration: TIntegration) => Promise<TResult>,
+1
View File
@@ -67,6 +67,7 @@ const MCP_TOOL_ALLOWLIST = {
"board_deleteBoard",
"board_duplicateBoard",
"board_renameBoard",
"board_resetLayout",
"board_savePartialBoardSettings",
"board_setHomeBoard",
"board_setMobileHomeBoard",
+1
View File
@@ -25,6 +25,7 @@
},
"dependencies": {
"@homarr/api": "workspace:^0.1.0",
"@homarr/settings": "workspace:^0.1.0",
"react": "catalog:",
"react-dom": "catalog:"
},
+36
View File
@@ -0,0 +1,36 @@
import { describe, expect, test } from "vitest";
import type { RouterOutputs } from "@homarr/api";
import { getBoardLayouts, getCurrentLayout, getDesktopLayout } from "./context";
const createBoard = () =>
({
layouts: [
{ id: "small", name: "Small", columnCount: 4, breakpoint: 0 },
{ id: "medium", name: "Medium", columnCount: 8, breakpoint: 768 },
{ id: "large", name: "Large", columnCount: 12, breakpoint: 1200 },
],
}) as RouterOutputs["board"]["getBoardByName"];
describe("board layout selection", () => {
test("automatic mode always uses the desktop layout", () => {
const board = createBoard();
expect(getDesktopLayout(board).id).toBe("large");
expect(getCurrentLayout(board, true, 390)).toBe("large");
});
test.each([
[390, "small"],
[768, "medium"],
[1024, "medium"],
[1440, "large"],
])("legacy mode selects the matching responsive layout at %spx", (viewportWidth, expectedLayoutId) => {
expect(getCurrentLayout(createBoard(), false, viewportWidth)).toBe(expectedLayoutId);
});
test("keeps every saved layout available for reversible mutations", () => {
expect(getBoardLayouts(createBoard())).toEqual(["small", "medium", "large"]);
});
});
+30
View File
@@ -0,0 +1,30 @@
import { describe, expect, test, vi } from "vitest";
import type { BoardEditAction, BoardEditActionEvent } from "./edit-mode";
import { boardEditActionEventName, requestBoardEditAction } from "./edit-mode";
describe("requestBoardEditAction", () => {
test("runs the action immediately when no edit guard is mounted", () => {
const action = vi.fn();
requestBoardEditAction(action);
expect(action).toHaveBeenCalledOnce();
});
test("lets an edit guard defer the action until the user confirms", () => {
const action = vi.fn();
let deferredAction: BoardEditAction | undefined;
const listener = (event: Event) => {
event.preventDefault();
deferredAction = (event as BoardEditActionEvent).detail.action;
};
document.addEventListener(boardEditActionEventName, listener, { once: true });
requestBoardEditAction(action);
expect(action).not.toHaveBeenCalled();
void deferredAction?.();
expect(action).toHaveBeenCalledOnce();
});
});
+22
View File
@@ -6,6 +6,28 @@ import { useDisclosure } from "@mantine/hooks";
const EditModeContext = createContext<ReturnType<typeof useDisclosure> | null>(null);
export const boardEditActionEventName = "homarr:board-edit-action";
export type BoardEditActionResult = boolean | void;
export type BoardEditAction = () => BoardEditActionResult | Promise<BoardEditActionResult>;
export type BoardEditActionEvent = CustomEvent<{ action: BoardEditAction }>;
export const requestBoardEditAction = (action: BoardEditAction) => {
if (typeof document === "undefined") {
void action();
return;
}
const event: BoardEditActionEvent = new CustomEvent(boardEditActionEventName, {
cancelable: true,
detail: { action },
});
if (document.dispatchEvent(event)) {
void action();
}
};
const noop = () => undefined;
const readOnlyEditMode = [false, { open: noop, close: noop, toggle: noop, set: noop }] satisfies ReturnType<
typeof useDisclosure
+106
View File
@@ -0,0 +1,106 @@
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.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";
@@ -155,19 +156,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) {
+78 -9
View File
@@ -1,4 +1,4 @@
import SuperJSON from "superjson";
import { parse, stringify } from "superjson";
import type { ServerSettings } from "@homarr/server-settings";
import { defaultServerSettings, defaultServerSettingsKeys } from "@homarr/server-settings";
@@ -7,23 +7,71 @@ import type { Database } from "..";
import { eq } from "..";
import { serverSettings } from "../schema";
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();
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);
acc[settingKey] = {
...defaultServerSettings[settingKey],
...SuperJSON.parse(setting.value),
};
...persistedSettings,
} as never;
return acc;
}, {} as ServerSettings);
const persistedBoardSettings = settings.find((setting) => setting.settingKey === "board");
const parsedBoardSettings = persistedBoardSettings
? parse<Partial<ServerSettings["board"]>>(persistedBoardSettings.value)
: {};
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 +80,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 +115,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 +127,6 @@ export const insertServerSettingByKeyAsync = async <TKey extends keyof ServerSet
) => {
await db.insert(serverSettings).values({
settingKey: key,
value: SuperJSON.stringify(value),
value: stringify(value),
});
};
+1
View File
@@ -16,6 +16,7 @@ export interface ConfirmModalProps {
children: ReactNode;
onConfirm?: () => MaybePromise<void>;
onCancel?: () => MaybePromise<void>;
onClose?: () => void;
closeOnConfirm?: boolean;
closeOnCancel?: boolean;
cancelProps?: ButtonProps & ComponentPropsWithoutRef<"button">;
+6 -1
View File
@@ -128,6 +128,7 @@ interface OpenModalOptions {
keepMounted?: boolean;
onClose?: () => void;
title?: stringOrTranslation;
onClose?: () => void;
}
export const useModalAction = <TModal extends ModalDefinition>(modal: TModal) => {
@@ -148,6 +149,10 @@ export const useConfirmModal = () => {
const { openModal } = useModalAction(ConfirmModal);
return {
openConfirmModal: (props: ConfirmModalProps) => openModal(props, { title: props.title }),
openConfirmModal: (props: ConfirmModalProps) =>
openModal(props, {
title: props.title,
onClose: props.onClose,
}),
};
};
+1
View File
@@ -27,6 +27,7 @@ export const defaultServerSettings = {
board: {
homeBoardId: null as string | null,
mobileHomeBoardId: null as string | null,
enableAutomaticMobileLayout: true,
enableStatusByDefault: true,
forceDisableStatus: false,
},
+3 -2
View File
@@ -12,14 +12,14 @@ export type SettingsContextProps = Pick<
| "pingIconsEnabled"
| "enableRightClickOnWidgets"
> &
Pick<ServerSettings["board"], "enableStatusByDefault" | "forceDisableStatus"> &
Pick<ServerSettings["board"], "enableAutomaticMobileLayout" | "enableStatusByDefault" | "forceDisableStatus"> &
Pick<ServerSettings["user"], "enableGravatar">;
export interface PublicServerSettings {
search: Pick<ServerSettings["search"], "defaultSearchEngineId">;
board: Pick<
ServerSettings["board"],
"homeBoardId" | "mobileHomeBoardId" | "enableStatusByDefault" | "forceDisableStatus"
"homeBoardId" | "mobileHomeBoardId" | "enableAutomaticMobileLayout" | "enableStatusByDefault" | "forceDisableStatus"
>;
user: Pick<ServerSettings["user"], "enableGravatar">;
}
@@ -51,6 +51,7 @@ export const createSettings = ({
mobileHomeBoardId: user?.mobileHomeBoardId ?? serverSettings.board.mobileHomeBoardId,
pingIconsEnabled: user?.pingIconsEnabled ?? false,
enableRightClickOnWidgets: user?.enableRightClickOnWidgets ?? true,
enableAutomaticMobileLayout: serverSettings.board.enableAutomaticMobileLayout,
enableStatusByDefault: serverSettings.board.enableStatusByDefault,
forceDisableStatus: serverSettings.board.forceDisableStatus,
enableGravatar: serverSettings.user.enableGravatar,
+1
View File
@@ -26,6 +26,7 @@
"dependencies": {
"@homarr/api": "workspace:^0.1.0",
"@homarr/auth": "workspace:^0.1.0",
"@homarr/boards": "workspace:^0.1.0",
"@homarr/common": "workspace:^0.1.0",
"@homarr/definitions": "workspace:^0.1.0",
"@homarr/integrations": "workspace:^0.1.0",
@@ -7,6 +7,7 @@ import type { DayOfWeek } from "@mantine/dates";
import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
import { requestBoardEditAction } from "@homarr/boards/edit-mode";
import type { ColorScheme } from "@homarr/definitions";
import { showErrorNotification } from "@homarr/notifications";
import type { SettingsContextProps } from "@homarr/settings/creator";
@@ -160,7 +161,7 @@ export const useUserPreferences = () => {
const clientSetters: Partial<Record<UserPreferenceKey, (value: unknown) => void>> = {
colorScheme: (value) => setColorScheme(value as ColorScheme),
locale: (value) => changeLocale(value as typeof currentLocale),
locale: (value) => requestBoardEditAction(() => changeLocale(value as typeof currentLocale)),
fullPreferencesPage: () => undefined,
};
+3 -3
View File
@@ -111,7 +111,7 @@ export default function BookmarksWidget({
return (
<Stack h="100%" mih={0} gap="sm" p={height < 120 ? "xs" : "sm"}>
{options.title.length > 0 && (
<Title order={4} px="0.25rem" lineClamp={displayMode === "advanced" ? undefined : 1}>
<Title order={4} px="0.25rem" lineClamp={1}>
{options.title}
</Title>
)}
@@ -283,14 +283,14 @@ const AdvancedBookmarksLayout = ({
style={{ width: 40, height: 40, flex: "0 0 auto" }}
/>
<Stack gap={3} miw={0}>
<Text fw={700} size="sm">
<Text fw={700} size="sm" truncate="end">
{app.name}
</Text>
<Anchor component="span" size="xs" truncate="end">
{getBookmarkHostname(app.href)}
</Anchor>
{app.description && (
<Text size="xs" c="dimmed">
<Text size="xs" c="dimmed" lineClamp={3}>
{app.description}
</Text>
)}
@@ -0,0 +1,73 @@
"use client";
import type { ReactNode } from "react";
import { Box, Center, Tooltip } from "@mantine/core";
import { IconAlertTriangle } from "@tabler/icons-react";
import { useI18n } from "@homarr/translation/client";
interface WidgetQueryProvenance {
error?: unknown;
failedIntegrationCount?: number;
staleIntegrationCount?: number;
expectedIntegrationCount?: number;
receivedIntegrationCount?: number;
}
export const hasWidgetDataWarning = ({
error,
failedIntegrationCount = 0,
staleIntegrationCount = 0,
expectedIntegrationCount,
receivedIntegrationCount,
}: WidgetQueryProvenance) =>
Boolean(error) ||
failedIntegrationCount > 0 ||
staleIntegrationCount > 0 ||
(expectedIntegrationCount !== undefined &&
receivedIntegrationCount !== undefined &&
receivedIntegrationCount < expectedIntegrationCount);
export const throwOnInitialQueryError = (error: unknown, hasData: boolean) => {
if (error && !hasData) throw error;
};
interface WidgetDataStateProps {
children: ReactNode;
hasWarning: boolean;
}
export function WidgetDataState({ children, hasWarning }: WidgetDataStateProps) {
const t = useI18n();
const label = t("board.mobile.dataWarning");
return (
<Box pos="relative" h="100%" w="100%">
{hasWarning && (
<Tooltip label={label} position="left" withArrow>
<Center
component="output"
aria-label={label}
pos="absolute"
bottom={4}
w={28}
h={28}
style={{
insetInlineStart: 4,
zIndex: 4,
borderRadius: "var(--mantine-radius-sm)",
background: "light-dark(var(--mantine-color-yellow-1), var(--mantine-color-dark-6))",
}}
>
<IconAlertTriangle
size={16}
color="light-dark(var(--mantine-color-yellow-9), var(--mantine-color-yellow-3))"
aria-hidden
/>
</Center>
</Tooltip>
)}
{children}
</Box>
);
}
+1 -1
View File
@@ -6,7 +6,7 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("customApi", {
icon: IconApi,
mobile: { supportsDetailView: true },
mobile: { supportsDetailView: true, unmountWhenOffscreen: true },
queryKey: [["widget", "customApi"]],
createOptions() {
return optionsBuilder.from((factory) => ({
@@ -10,7 +10,7 @@ export const widgetKind = "dnsHoleControls";
export const { definition, componentLoader } = createWidgetDefinition(widgetKind, {
supportsAdvancedFocus: false,
icon: IconDeviceGamepad,
mobile: { supportsDetailView: true },
mobile: { supportsDetailView: true, unmountWhenOffscreen: true },
queryKey: [["widget", "dnsHole"]],
refetchInterval: 5,
createOptions() {
@@ -10,6 +10,7 @@ export const widgetKind = "dnsHoleSummary";
export const { definition, componentLoader } = createWidgetDefinition(widgetKind, {
supportsAdvancedFocus: false,
icon: IconAd,
mobile: { unmountWhenOffscreen: true },
queryKey: [["widget", "dnsHole"]],
refetchInterval: 5,
createOptions() {