Files
homarr/packages/validation/src/header-preferences.ts
T
+12 fcd7c979df feat!: release v2 (#6545)
Co-authored-by: Artur <145776561+arturict@users.noreply.github.com>
Co-authored-by: arturict <arturict@users.noreply.github.com>
Co-authored-by: Thomas <thomas@ajnart.fr>
Co-authored-by: Afonso Jorge Ramos <afonsojorgeramos@gmail.com>
Co-authored-by: Danura30082 <danura30082+github@gmail.com>
Co-authored-by: Kushie <122986913+kushiemoon-dev@users.noreply.github.com>
Co-authored-by: ajnart <thomas@ajnart.fr>
Co-authored-by: ajnart <thomas@ajnart.dev>
Co-authored-by: chenjun <chenjun@foxmail.com>
Co-authored-by: Wikibear <wikibear@users.noreply.github.com>
Co-authored-by: Bergasha <bergasha@gmail.com>
Co-authored-by: leafyelin <43306196+leafyelin@users.noreply.github.com>
Co-authored-by: Lucas Araujo <lucas.lra@gmail.com>
Co-authored-by: GreenMonito <86085570+GreenMonito@users.noreply.github.com>
Co-authored-by: Nerdy-Technician <roffo@nerdy-technician.social>
Co-authored-by: Jun <34545896+jcstrong@users.noreply.github.com>
Co-authored-by: Edmond Abraham <54119952+gitedmond@users.noreply.github.com>
Co-authored-by: thrawnx <tight.bulb9384@fastmail.com>
Co-authored-by: Chris <58918656+nordada@users.noreply.github.com>
Co-authored-by: yi111 <153097222+Yi-111-a@users.noreply.github.com>
Co-authored-by: Yanis <naamaneyanis@gmail.com>
Co-authored-by: Wikibear <me@sej.de>
Co-authored-by: alessiottavo <93441886+alessiottavo@users.noreply.github.com>
2026-10-02 21:00:14 +02:00

238 lines
8.1 KiB
TypeScript

import { z } from "zod/v4";
export const headerPreferencesVersion = 3 as const;
export const headerBuiltinItemIds = [
"logo",
"search",
"home",
"boardSwitcher",
"assistant",
"docker",
"boardEdit",
"boardSettings",
"settings",
"themeToggle",
"user",
] as const;
export const requiredHeaderBuiltinItemIds = ["user"] as const;
export const headerPreferenceItemIds = headerBuiltinItemIds;
export const headerItemIds = headerBuiltinItemIds;
export type HeaderBuiltinItemId = (typeof headerBuiltinItemIds)[number];
export type HeaderPreferenceItemId = HeaderBuiltinItemId;
export const headerZoneIds = ["left", "center", "right"] as const;
export const headerSearchDisplayValues = ["input", "icon"] as const;
export const headerLogoDisplayValues = ["logo", "logoAndText"] as const;
export type HeaderZoneId = (typeof headerZoneIds)[number];
export type HeaderSearchDisplay = (typeof headerSearchDisplayValues)[number];
export type HeaderLogoDisplay = (typeof headerLogoDisplayValues)[number];
const headerBuiltinItemSchema = z
.object({
type: z.literal("builtin"),
id: z.enum(headerBuiltinItemIds),
})
.strict();
const headerBoardItemSchema = z
.object({
type: z.literal("board"),
boardId: z.string().min(1).max(255),
})
.strict();
export const headerItemSchema = z.discriminatedUnion("type", [headerBuiltinItemSchema, headerBoardItemSchema]);
export type HeaderBuiltinItem = z.infer<typeof headerBuiltinItemSchema>;
export type HeaderBoardItem = z.infer<typeof headerBoardItemSchema>;
export type HeaderItem = z.infer<typeof headerItemSchema>;
export type HeaderItemId = HeaderItem;
export type HeaderZones = Record<HeaderZoneId, HeaderItem[]>;
export const createBuiltinHeaderItem = (id: HeaderBuiltinItemId): HeaderBuiltinItem => ({ type: "builtin", id });
export const createBoardHeaderItem = (boardId: string): HeaderBoardItem => ({ type: "board", boardId });
export const isRequiredHeaderItem = (item: HeaderItem) =>
item.type === "builtin" && requiredHeaderBuiltinItemIds.some((itemId) => itemId === item.id);
export const getHeaderItemKey = (item: HeaderItem): string => {
if (item.type === "board") return `board:${item.boardId}`;
return `builtin:${item.id}`;
};
export const getHeaderItems = (zones: HeaderZones): HeaderItem[] => headerZoneIds.flatMap((zone) => zones[zone]);
export const getHeaderItemZone = (zones: HeaderZones, item: HeaderItem): HeaderZoneId | undefined => {
const itemKey = getHeaderItemKey(item);
return headerZoneIds.find((zone) => zones[zone].some((candidate) => getHeaderItemKey(candidate) === itemKey));
};
export const getDefaultHeaderZone = (item: HeaderItem): HeaderZoneId => {
if (item.type === "board") return "right";
if (item.id === "logo") return "left";
if (item.id === "search") return "center";
return "right";
};
const headerItemListSchema = z.array(headerItemSchema).max(100);
const headerZonesSchema = z
.object({
left: headerItemListSchema,
center: headerItemListSchema,
right: headerItemListSchema,
})
.strict()
.refine((zones) => getHeaderItems(zones).length > 0, "At least one header item is required")
.refine((zones) => {
const itemKeys = getHeaderItems(zones).map(getHeaderItemKey);
return new Set(itemKeys).size === itemKeys.length;
}, "Header items must be unique")
.refine(
(zones) => getHeaderItems(zones).some((item) => item.type === "builtin" && item.id === "user"),
"Account access must remain in the header",
);
export const headerPreferencesSchema = z
.object({
version: z.literal(headerPreferencesVersion),
visible: z.boolean(),
searchDisplay: z.enum(headerSearchDisplayValues),
logoDisplay: z.enum(headerLogoDisplayValues).default("logoAndText"),
zones: headerZonesSchema,
})
.strict();
export type HeaderPreferences = z.infer<typeof headerPreferencesSchema>;
const legacyHeaderItemIds = ["logo", "search", "boardSwitcher", "assistant", "docker", "user"] as const;
type LegacyHeaderItemId = (typeof legacyHeaderItemIds)[number];
const legacyHeaderItemListSchema = z
.array(z.enum(legacyHeaderItemIds))
.min(1)
.max(legacyHeaderItemIds.length)
.refine((items) => new Set(items).size === items.length, "Header items must be unique")
.refine((items) => items.includes("user"), "Account access must remain in the header");
const legacyHeaderPreferencesV1Schema = z
.object({
version: z.literal(1),
visible: z.boolean(),
items: legacyHeaderItemListSchema,
})
.strict();
const legacyHeaderPreferencesV2Schema = z
.object({
version: z.literal(2),
visible: z.boolean(),
zones: z
.object({
left: z.array(z.enum(legacyHeaderItemIds)).max(legacyHeaderItemIds.length),
center: z.array(z.enum(legacyHeaderItemIds)).max(legacyHeaderItemIds.length),
right: z.array(z.enum(legacyHeaderItemIds)).max(legacyHeaderItemIds.length),
})
.strict()
.refine((zones) => {
const items = headerZoneIds.flatMap((zone) => zones[zone]);
return items.length > 0 && new Set(items).size === items.length && items.includes("user");
}, "Header items must be unique and include account access"),
})
.strict();
const migrateLegacyItems = (
visible: boolean,
zones: Record<HeaderZoneId, LegacyHeaderItemId[]>,
): HeaderPreferences => ({
version: headerPreferencesVersion,
visible,
searchDisplay: "input",
logoDisplay: "logoAndText",
zones: {
left: zones.left.map(createBuiltinHeaderItem),
center: zones.center.map(createBuiltinHeaderItem),
right: zones.right.map(createBuiltinHeaderItem),
},
});
const migrateLegacyHeaderPreferencesV1 = (
legacy: z.infer<typeof legacyHeaderPreferencesV1Schema>,
): HeaderPreferences => {
const zones: Record<HeaderZoneId, LegacyHeaderItemId[]> = { left: [], center: [], right: [] };
for (const itemId of legacy.items) {
const item = createBuiltinHeaderItem(itemId);
zones[getDefaultHeaderZone(item)].push(itemId);
}
return migrateLegacyItems(legacy.visible, zones);
};
const migrateLegacyHeaderPreferencesV2 = (legacy: z.infer<typeof legacyHeaderPreferencesV2Schema>): HeaderPreferences =>
migrateLegacyItems(legacy.visible, legacy.zones);
const includeRequiredHeaderItems = (preferences: HeaderPreferences): HeaderPreferences => {
const itemKeys = new Set(getHeaderItems(preferences.zones).map(getHeaderItemKey));
const missingItems = requiredHeaderBuiltinItemIds
.map(createBuiltinHeaderItem)
.filter((item) => !itemKeys.has(getHeaderItemKey(item)));
if (missingItems.length === 0) return preferences;
return {
...preferences,
zones: {
...preferences.zones,
right: [...preferences.zones.right, ...missingItems],
},
};
};
export const headerPreferencesMutationSchema = z
.discriminatedUnion("version", [
headerPreferencesSchema,
legacyHeaderPreferencesV1Schema,
legacyHeaderPreferencesV2Schema,
])
.transform((preferences): HeaderPreferences => {
if (preferences.version === 1) return includeRequiredHeaderItems(migrateLegacyHeaderPreferencesV1(preferences));
if (preferences.version === 2) return includeRequiredHeaderItems(migrateLegacyHeaderPreferencesV2(preferences));
return includeRequiredHeaderItems(preferences);
});
export const defaultHeaderPreferences = {
version: headerPreferencesVersion,
visible: true,
searchDisplay: "input",
logoDisplay: "logoAndText",
zones: {
left: [createBuiltinHeaderItem("logo")],
center: [createBuiltinHeaderItem("search")],
right: [
createBuiltinHeaderItem("boardEdit"),
createBuiltinHeaderItem("boardSettings"),
createBuiltinHeaderItem("user"),
],
},
} satisfies HeaderPreferences;
export const defaultHeaderPreferencesSerialized = JSON.stringify(defaultHeaderPreferences);
export const parseHeaderPreferences = (value: unknown): HeaderPreferences => {
try {
const decoded = typeof value === "string" ? JSON.parse(value) : value;
const parsed = headerPreferencesMutationSchema.safeParse(decoded);
if (parsed.success) return parsed.data;
return defaultHeaderPreferences;
} catch {
return defaultHeaderPreferences;
}
};
export const serializeHeaderPreferences = (value: HeaderPreferences) =>
JSON.stringify(includeRequiredHeaderItems(value));