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:
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>
|
||||
}
|
||||
|
||||
@@ -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 />;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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()}
|
||||
|
||||
Reference in new issue
Block a user