refactor(board): modernize item and application select modals with SelectableCard (#6642)

Modernize board item, application, and integration selection flows with shared Mantine components, batch board actions, improved accessibility, and hardened persistence.
This commit is contained in:
Thomas authored and GitHub committed 2026-08-21 03:19:18 +02:00
1 parent bd4f6a92c6
commit f3014fe271
60 files changed
+3791 -1839

No files matched your search

@@ -17,6 +17,12 @@ import type { CommonWidgetInputProps } from "./common";
import { useWidgetInputTranslation } from "./common";
import { useFormContext } from "./form";
const renderHereLink = (children: React.ReactNode) => (
<Anchor size="xs" component={Link} target="_blank" href="/manage/apps/new">
{children}
</Anchor>
);
export const WidgetAppInput = ({ property, kind }: CommonWidgetInputProps<"app">) => {
const t = useI18n();
const tInput = useWidgetInputTranslation(kind, property);
@@ -49,13 +55,9 @@ export const WidgetAppInput = ({ property, kind }: CommonWidgetInputProps<"app">
}
inputWrapperOrder={["label", "input", "description", "error"]}
description={
<Text size="xs">
<Text component="span" size="xs">
{t.rich("widget.common.app.description", {
here: () => (
<Anchor size="xs" component={Link} target="_blank" href="/manage/apps/new">
{t("common.here")}
</Anchor>
),
here: renderHereLink,
})}
</Text>
}
+2 -1
View File
@@ -26,7 +26,7 @@ export default function AppWidget({ options, isEditMode, height, width }: Widget
const t = useI18n();
const settings = useSettings();
const board = useRequiredBoard();
const appQuery = clientApi.app.byId.useQuery({ id: options.appId });
const appQuery = clientApi.app.byId.useQuery({ id: options.appId }, { enabled: Boolean(options.appId) });
const app = getUsableWidgetQueryData(appQuery);
const href = getSafeAppHref(app?.href);
useRegisterSpotlightContextResults(
@@ -50,6 +50,7 @@ export default function AppWidget({ options, isEditMode, height, width }: Widget
[app, href, options.openInNewTab],
);
if (!options.appId) return <WidgetEmptyState />;
if (isInitialWidgetQueryPending(appQuery)) return <WidgetQueryLoadingState />;
if (!app) return <WidgetEmptyState />;
-2
View File
@@ -15,7 +15,6 @@ import {
IconBuildingBank,
IconBusStop,
IconCalendar,
IconCalendarWeek,
IconChartAreaLine,
IconChartBar,
IconClock,
@@ -60,7 +59,6 @@ import type { TablerIcon } from "@homarr/ui";
export const widgetCatalogIcons: Record<WidgetKind, TablerIcon> = {
clock: IconClock,
weather: IconCloud,
today: IconCalendarWeek,
airQuality: IconWind,
countdown: IconHourglass,
timer: IconClockPlay,
-2
View File
@@ -63,7 +63,6 @@ import * as beszelAlerts from "./beszel-alerts";
import * as beszelSystemStats from "./beszel-system-stats";
import * as video from "./video";
import * as weather from "./weather";
import * as today from "./today";
import * as airQuality from "./air-quality";
import * as countdown from "./countdown";
import * as timer from "./timer";
@@ -98,7 +97,6 @@ export { AssistantWidgetRendererProvider } from "./assistant/context";
export const widgetImports = {
clock,
weather,
today,
airQuality,
countdown,
timer,
-2
View File
@@ -24,7 +24,6 @@ export type WidgetResources = {
const moduleLoaders: Record<WidgetKind, () => Promise<WidgetModule>> = {
clock: () => import("./clock") as Promise<WidgetModule>,
weather: () => import("./weather") as Promise<WidgetModule>,
today: () => import("./today") as Promise<WidgetModule>,
airQuality: () => import("./air-quality") as Promise<WidgetModule>,
countdown: () => import("./countdown") as Promise<WidgetModule>,
timer: () => import("./timer") as Promise<WidgetModule>,
@@ -89,7 +88,6 @@ const moduleLoaders: Record<WidgetKind, () => Promise<WidgetModule>> = {
const componentLoaders: Record<WidgetKind, () => Promise<WidgetComponentModule>> = {
clock: () => import("./clock/component") as Promise<WidgetComponentModule>,
weather: () => import("./weather/component") as Promise<WidgetComponentModule>,
today: () => import("./today/component") as Promise<WidgetComponentModule>,
airQuality: () => import("./air-quality/component") as Promise<WidgetComponentModule>,
countdown: () => import("./countdown/component") as Promise<WidgetComponentModule>,
timer: () => import("./timer/component") as Promise<WidgetComponentModule>,
@@ -58,6 +58,7 @@ export interface WidgetEditModalProps<TSort extends WidgetKind> {
appId?: string;
integrationEditForm?: ComponentType<EmbeddedIntegrationEditFormProps>;
onIntegrationSaved?: () => void;
onOpenNewIntegration?: (onCreated?: (id: string) => void) => void;
}
export const getSelectedWidgetIntegrations = (
@@ -217,6 +218,17 @@ export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({
handleSubmit(event);
};
const canCreateIntegration = maxIntegrations === 1 || form.values.integrationIds.length < maxIntegrations;
const handleOpenNewIntegration = innerProps.onOpenNewIntegration
? () => {
innerProps.onOpenNewIntegration?.((newId) => {
if (newId) {
form.setFieldValue("integrationIds", [...(maxIntegrations > 1 ? form.values.integrationIds : []), newId]);
}
});
}
: undefined;
const widgetFormContent = (
<Stack>
{canConfigureWidget && innerProps.integrationSupport && (
@@ -225,6 +237,7 @@ export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({
data={innerProps.integrationData}
canSelectMultiple={maxIntegrations > 1}
withAsterisk={integrationsRequired}
onOpenNewIntegration={canCreateIntegration ? handleOpenNewIntegration : undefined}
{...form.getInputProps("integrationIds")}
/>
)}
@@ -413,6 +426,5 @@ export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({
return t("item.edit.title");
},
size: modalSizeForm,
presentation: "inspector",
closeOnClickOutside: false,
});
-223
View File
@@ -1,223 +0,0 @@
"use client";
import {
Badge,
Box,
Group,
Paper,
Progress,
RingProgress,
ScrollArea,
SimpleGrid,
Stack,
Text,
VisuallyHidden,
} from "@mantine/core";
import { useCurrentIntlLocale, useScopedI18n } from "@homarr/translation/client";
import { formatLocalizedDate } from "../common/locale";
import { useWidgetNow } from "../common/use-widget-now";
import type { WidgetComponentProps } from "../definition";
import { getTodayLayout, getTodayStats } from "./date-stats";
const formatProgress = (value: number) => Math.round(value);
export default function TodayWidget({ options, width, height, displayMode }: WidgetComponentProps<"today">) {
const now = useWidgetNow("minute");
const locale = useCurrentIntlLocale();
const t = useScopedI18n("widget.today");
if (now === null) {
return (
<Stack h="100%" align="center" justify="center">
<Text c="dimmed">—</Text>
</Stack>
);
}
const stats = getTodayStats(now, options.weekConvention, locale);
if (displayMode === "advanced") {
return <AdvancedToday now={now} locale={locale} options={options} stats={stats} />;
}
const layout = getTodayLayout(width, height);
const weekday = formatLocalizedDate(now, locale, { weekday: "long" });
const month = formatLocalizedDate(now, locale, { month: "long" });
const fullDate = formatLocalizedDate(now, locale, { dateStyle: "long" });
if (layout.tiny) {
return (
<Stack h="100%" align="center" justify="center" gap={0} px="xs">
<Text size="xs" c="dimmed" tt="uppercase" fw={600} truncate>
{weekday} · {month}
</Text>
<Text component="time" dateTime={toDateTime(now)} fz={34} fw={700} lh={1}>
{now.getDate()}
</Text>
</Stack>
);
}
return (
<Stack h="100%" justify="center" gap="xs" px="md" py="sm" style={{ overflow: "hidden" }}>
<Group justify="space-between" align="center" wrap="nowrap">
<Stack gap={0} style={{ minWidth: 0 }}>
<Text size="xs" c="dimmed" tt="uppercase" fw={600} truncate>
{weekday}
</Text>
<Text component="time" dateTime={toDateTime(now)} fz={layout.showFullDate ? "lg" : 30} fw={700} truncate>
{layout.showFullDate ? fullDate : `${month} ${now.getDate()}`}
</Text>
</Stack>
{options.showYearProgress && (
<Box>
<RingProgress
aria-hidden
size={54}
thickness={5}
sections={[{ value: stats.yearProgress, color: "blue" }]}
label={
<Text size="xs" ta="center" fw={700}>
{formatProgress(stats.yearProgress)}%
</Text>
}
/>
<VisuallyHidden>
<progress value={stats.yearProgress} max={100}>
{t("yearProgress", { progress: formatProgress(stats.yearProgress) })}
</progress>
</VisuallyHidden>
</Box>
)}
</Group>
{layout.showStats && (
<Group gap={6} wrap="wrap">
{options.showWeekNumber && <Badge variant="light">{t("weekNumber", { week: stats.weekNumber })}</Badge>}
{options.showDayOfYear && (
<Badge variant="light">{t("dayOfYear", { day: stats.dayOfYear, total: stats.daysInYear })}</Badge>
)}
{layout.showQuarter && options.showQuarter && (
<Badge variant="light">{t("quarter", { quarter: stats.quarter })}</Badge>
)}
{layout.showQuarter && (
<Text size="xs" c="dimmed">
{t("daysAfterToday", { days: stats.daysAfterToday })}
</Text>
)}
</Group>
)}
{layout.showMonthProgress && <ProgressMetric label={t("monthProgressLabel")} value={stats.monthProgress} />}
</Stack>
);
}
interface AdvancedTodayProps {
now: Date;
locale: string;
options: WidgetComponentProps<"today">["options"];
stats: ReturnType<typeof getTodayStats>;
}
const AdvancedToday = ({ now, locale, options, stats }: AdvancedTodayProps) => {
const t = useScopedI18n("widget.today");
const numberFormatter = new Intl.NumberFormat(locale);
const visibleMetrics = [
options.showWeekNumber && { label: t("advanced.week"), value: numberFormatter.format(stats.weekNumber) },
options.showDayOfYear && {
label: t("advanced.dayOfYear"),
value: `${numberFormatter.format(stats.dayOfYear)} / ${numberFormatter.format(stats.daysInYear)}`,
},
options.showQuarter && {
label: t("advanced.quarter"),
value: t("advanced.quarterValue", { quarter: stats.quarter }),
},
{ label: t("advanced.daysRemaining"), value: numberFormatter.format(stats.daysAfterToday) },
].filter((metric): metric is { label: string; value: string } => Boolean(metric));
return (
<ScrollArea h="100%" w="100%" type="auto" offsetScrollbars scrollbarSize={6}>
<Stack gap="md" p="md">
<Stack gap={0}>
<Text component="time" dateTime={toDateTime(now)} fz="xl" fw={700}>
{formatLocalizedDate(now, locale, { dateStyle: "full" })}
</Text>
{options.showWeekNumber && (
<Text size="sm" c="dimmed">
{t(`weekConvention.${options.weekConvention}`)}
</Text>
)}
</Stack>
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="xs">
{visibleMetrics.map((metric) => (
<Paper key={metric.label} withBorder p="sm">
<Text size="xs" c="dimmed">
{metric.label}
</Text>
<Text fz="xl" fw={700}>
{metric.value}
</Text>
</Paper>
))}
</SimpleGrid>
<SimpleGrid cols={{ base: 1, sm: 3 }} spacing="sm">
<ProgressMetric label={t("weekProgressLabel")} value={stats.weekProgress} />
<ProgressMetric label={t("monthProgressLabel")} value={stats.monthProgress} />
{options.showYearProgress && <ProgressMetric label={t("yearProgressLabel")} value={stats.yearProgress} />}
</SimpleGrid>
<SimpleGrid cols={{ base: 3, sm: 4, md: 6 }} spacing="xs">
{Array.from({ length: 12 }, (_, monthIndex) => {
const date = new Date(now.getFullYear(), monthIndex, 1);
const isCurrent = monthIndex === stats.monthIndex;
return (
<Paper
key={monthIndex}
withBorder
p="xs"
bg={isCurrent ? "var(--mantine-primary-color-light)" : undefined}
>
<Text size="sm" fw={isCurrent ? 700 : 500} ta="center">
{formatLocalizedDate(date, locale, { month: "short" })}
</Text>
{isCurrent && (
<Text size="xs" c="dimmed" ta="center">
{now.getDate()}
</Text>
)}
</Paper>
);
})}
</SimpleGrid>
</Stack>
</ScrollArea>
);
};
const ProgressMetric = ({ label, value }: { label: string; value: number }) => {
const roundedValue = formatProgress(value);
return (
<Box>
<Group justify="space-between" gap="xs" mb={3}>
<Text size="xs" c="dimmed">
{label}
</Text>
<Text size="xs" fw={600}>
{roundedValue}%
</Text>
</Group>
<Progress value={value} aria-label={`${label}: ${roundedValue}%`} />
</Box>
);
};
const toDateTime = (date: Date) => {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
};
@@ -1,47 +0,0 @@
import { describe, expect, it } from "vitest";
import { getTodayLayout, getTodayStats } from "./date-stats";
describe("today date statistics", () => {
it("accounts for leap years", () => {
const stats = getTodayStats(new Date(2024, 11, 31, 12), "iso");
expect(stats.dayOfYear).toBe(366);
expect(stats.daysInYear).toBe(366);
expect(stats.daysAfterToday).toBe(0);
});
it("uses the requested week convention", () => {
const date = new Date(2023, 0, 1, 12);
expect(getTodayStats(date, "iso").weekNumber).toBe(52);
expect(getTodayStats(date, "locale", "en-US").weekNumber).toBe(1);
expect(getTodayStats(date, "locale", "de-DE").weekNumber).toBe(52);
});
it("returns bounded calendar progress", () => {
const stats = getTodayStats(new Date(2026, 7, 20, 12), "iso");
expect(stats.quarter).toBe(3);
expect(stats.weekProgress).toBeGreaterThanOrEqual(0);
expect(stats.weekProgress).toBeLessThanOrEqual(100);
expect(stats.monthProgress).toBeGreaterThanOrEqual(0);
expect(stats.monthProgress).toBeLessThanOrEqual(100);
expect(stats.yearProgress).toBeGreaterThanOrEqual(0);
expect(stats.yearProgress).toBeLessThanOrEqual(100);
});
});
describe("today compact layout", () => {
it("keeps tiny widgets focused on the date", () => {
expect(getTodayLayout(140, 120)).toMatchObject({ tiny: true, showStats: false });
});
it("reveals secondary information only when both axes have room", () => {
expect(getTodayLayout(240, 120)).toMatchObject({ showStats: true, showQuarter: false });
expect(getTodayLayout(300, 180)).toEqual({
tiny: false,
showFullDate: true,
showStats: true,
showQuarter: true,
showMonthProgress: true,
});
});
});
-172
View File
@@ -1,172 +0,0 @@
import dayjs from "dayjs";
import dayOfYear from "dayjs/plugin/dayOfYear";
import quarterOfYear from "dayjs/plugin/quarterOfYear";
dayjs.extend(dayOfYear);
dayjs.extend(quarterOfYear);
export type WeekConvention = "locale" | "iso";
export interface TodayStats {
dayOfYear: number;
daysInYear: number;
daysAfterToday: number;
monthIndex: number;
quarter: number;
weekNumber: number;
weekProgress: number;
monthProgress: number;
yearProgress: number;
}
const getProgress = (value: number, start: number, end: number) => {
if (end <= start) return 0;
return Math.min(100, Math.max(0, ((value - start) / (end - start)) * 100));
};
export const getTodayStats = (date: Date, weekConvention: WeekConvention, locale = "en-US"): TodayStats => {
const value = dayjs(date);
const yearStart = value.startOf("year");
const nextYearStart = yearStart.add(1, "year");
const monthStart = value.startOf("month");
const nextMonthStart = monthStart.add(1, "month");
const weekRules = getWeekRules(weekConvention, locale);
const weekDay = value.day() === 0 ? 7 : value.day();
const daysSinceWeekStart = (weekDay - weekRules.firstDay + 7) % 7;
const weekStart = new Date(date.getFullYear(), date.getMonth(), date.getDate() - daysSinceWeekStart);
const nextWeekStart = new Date(weekStart.getFullYear(), weekStart.getMonth(), weekStart.getDate() + 7);
const currentDayOfYear = value.dayOfYear();
const daysInYear = yearStart.endOf("year").dayOfYear();
return {
dayOfYear: currentDayOfYear,
daysInYear,
daysAfterToday: daysInYear - currentDayOfYear,
monthIndex: value.month(),
quarter: value.quarter(),
weekNumber: getWeekNumber(date, weekRules),
weekProgress: getProgress(value.valueOf(), weekStart.getTime(), nextWeekStart.getTime()),
monthProgress: getProgress(value.valueOf(), monthStart.valueOf(), nextMonthStart.valueOf()),
yearProgress: getProgress(value.valueOf(), yearStart.valueOf(), nextYearStart.valueOf()),
};
};
interface WeekRules {
firstDay: number;
minimalDays: number;
}
interface LocaleWeekInfo {
firstDay: number;
minimalDays?: number;
}
type LocaleWithWeekInfo = Intl.Locale & {
weekInfo?: LocaleWeekInfo;
getWeekInfo?: () => LocaleWeekInfo;
};
const fourDayMinimumRegions = new Set([
"AD",
"AN",
"AT",
"AX",
"BE",
"BG",
"CH",
"CZ",
"DE",
"DK",
"EE",
"ES",
"FI",
"FJ",
"FO",
"FR",
"GF",
"GB",
"GG",
"GI",
"GP",
"GR",
"IE",
"IM",
"IS",
"IT",
"LI",
"LT",
"LU",
"MC",
"MQ",
"NL",
"NO",
"PL",
"PT",
"RE",
"RU",
"SE",
"SJ",
"SK",
"VA",
]);
const getWeekRules = (convention: WeekConvention, locale: string): WeekRules => {
if (convention === "iso") return { firstDay: 1, minimalDays: 4 };
try {
const localeInfo = new Intl.Locale(locale) as LocaleWithWeekInfo;
const weekInfo = localeInfo.weekInfo ?? localeInfo.getWeekInfo?.();
if (weekInfo) {
const region = localeInfo.maximize().region;
const fallbackMinimalDays = region && fourDayMinimumRegions.has(region) ? 4 : 1;
return { firstDay: weekInfo.firstDay, minimalDays: weekInfo.minimalDays ?? fallbackMinimalDays };
}
} catch {
// Invalid locales fall back to the common US week convention.
}
return { firstDay: 7, minimalDays: 1 };
};
const getWeekNumber = (date: Date, rules: WeekRules): number => {
const dateDay = Date.UTC(date.getFullYear(), date.getMonth(), date.getDate());
let weekYear = date.getFullYear();
let firstWeekStart = getFirstWeekStart(weekYear, rules);
if (dateDay < firstWeekStart) {
weekYear -= 1;
firstWeekStart = getFirstWeekStart(weekYear, rules);
} else {
const nextFirstWeekStart = getFirstWeekStart(weekYear + 1, rules);
if (dateDay >= nextFirstWeekStart) return 1;
}
return Math.floor((dateDay - firstWeekStart) / (7 * 86_400_000)) + 1;
};
const getFirstWeekStart = (year: number, rules: WeekRules): number => {
const januaryFirst = Date.UTC(year, 0, 1);
const sundayBasedWeekDay = new Date(januaryFirst).getUTCDay();
const weekDay = sundayBasedWeekDay === 0 ? 7 : sundayBasedWeekDay;
const daysBeforeJanuary = (weekDay - rules.firstDay + 7) % 7;
const daysInFirstWeek = 7 - daysBeforeJanuary;
if (daysInFirstWeek >= rules.minimalDays) return januaryFirst - daysBeforeJanuary * 86_400_000;
return januaryFirst + daysInFirstWeek * 86_400_000;
};
export interface TodayLayout {
tiny: boolean;
showFullDate: boolean;
showStats: boolean;
showQuarter: boolean;
showMonthProgress: boolean;
}
export const getTodayLayout = (width: number, height: number): TodayLayout => {
const tiny = width < 150 || height < 96;
return {
tiny,
showFullDate: !tiny && width >= 180,
showStats: !tiny && width >= 210 && height >= 115,
showQuarter: width >= 240 && height >= 145,
showMonthProgress: width >= 300 && height >= 170,
};
};
-24
View File
@@ -1,24 +0,0 @@
import { IconCalendarWeek } from "@tabler/icons-react";
import { createWidgetDefinition } from "../definition";
import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("today", {
icon: IconCalendarWeek,
supportsAdvancedFocus: true,
createOptions() {
return optionsBuilder.from((factory) => ({
weekConvention: factory.select({
defaultValue: "locale",
options: [
{ value: "locale", label: (t) => t("widget.today.option.weekConvention.options.locale") },
{ value: "iso", label: (t) => t("widget.today.option.weekConvention.options.iso") },
],
}),
showWeekNumber: factory.switch({ defaultValue: true }),
showDayOfYear: factory.switch({ defaultValue: true }),
showQuarter: factory.switch({ defaultValue: true }),
showYearProgress: factory.switch({ defaultValue: true }),
}));
},
}).withDynamicImport(() => import("./component"));
@@ -32,6 +32,7 @@ interface WidgetIntegrationSelectProps {
canSelectMultiple?: boolean;
data: IntegrationSelectOption[];
withAsterisk?: boolean;
onOpenNewIntegration?: () => void;
}
export const WidgetIntegrationSelect = ({
data,
@@ -40,6 +41,7 @@ export const WidgetIntegrationSelect = ({
label,
canSelectMultiple = true,
withAsterisk = false,
onOpenNewIntegration,
...props
}: WidgetIntegrationSelectProps) => {
const t = useI18n();
@@ -109,11 +111,19 @@ export const WidgetIntegrationSelect = ({
<PillsInput
inputWrapperOrder={["label", "input", "description", "error"]}
description={
<Text size="xs" span>
<Anchor size="xs" component={Link} target="_blank" href="/manage/integrations">
{t("widget.common.integration.manage")}
</Anchor>
</Text>
onOpenNewIntegration ? (
<Text size="xs" span>
<Anchor size="xs" component="button" type="button" onClick={onOpenNewIntegration}>
{t("integration.action.create")}
</Anchor>
</Text>
) : (
<Text size="xs" span>
<Anchor size="xs" component={Link} target="_blank" href="/manage/integrations">
{t("widget.common.integration.manage")}
</Anchor>
</Text>
)
}
pointer
onClick={() => combobox.openDropdown()}