From 829f628fece2696a20426ef0dd6ee90676640082 Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Thu, 20 Aug 2026 23:42:24 +0200 Subject: [PATCH] feat(board): implement advanced add app modal with live preview and remove today widget --- apps/docs/docs/widgets/today/index.mdx | 34 - apps/docs/docs/widgets/today/index.ts | 43 - apps/docs/static/api/open-api-schema.json | 1 - .../board/items/item-select-modal.module.css | 20 - .../board/items/item-select-modal.tsx | 1186 +++++++++++++---- .../integration/integration-select-modal.tsx | 37 +- e2e/shared/actions/item-select-actions.ts | 211 +++ .../src/docs/homarr-docs-sitemap.ts | 1 - .../definitions/src/docs/widget-doc-slugs.ts | 1 - packages/definitions/src/widget.ts | 2 - .../src/apps/app-select-modal.tsx | 265 ++-- packages/modals/src/index.tsx | 10 +- packages/modals/src/reducer.tsx | 11 + packages/modals/src/type.ts | 10 +- packages/translation/src/lang/en.json | 44 - packages/ui/src/components/index.tsx | 7 + .../src/components/selectable-card/index.ts | 7 + .../selectable-card.module.css | 79 ++ .../selectable-card/selectable-card.spec.tsx | 308 +++++ .../selectable-card/selectable-card.tsx | 171 +++ .../integration-select-grid.spec.tsx | 265 ++++ .../integration-select-grid.tsx | 303 ++++- packages/widgets/package.json | 4 +- .../widgets/src/_inputs/widget-app-input.tsx | 14 +- packages/widgets/src/app/component.tsx | 2 +- packages/widgets/src/catalog.ts | 1 - packages/widgets/src/index.tsx | 2 - packages/widgets/src/manifest.ts | 2 - packages/widgets/src/today/component.tsx | 223 ---- packages/widgets/src/today/date-stats.spec.ts | 47 - packages/widgets/src/today/date-stats.ts | 172 --- packages/widgets/src/today/index.ts | 24 - 32 files changed, 2408 insertions(+), 1099 deletions(-) delete mode 100644 apps/docs/docs/widgets/today/index.mdx delete mode 100644 apps/docs/docs/widgets/today/index.ts delete mode 100644 apps/nextjs/src/components/board/items/item-select-modal.module.css create mode 100644 e2e/shared/actions/item-select-actions.ts create mode 100644 packages/ui/src/components/selectable-card/index.ts create mode 100644 packages/ui/src/components/selectable-card/selectable-card.module.css create mode 100644 packages/ui/src/components/selectable-card/selectable-card.spec.tsx create mode 100644 packages/ui/src/components/selectable-card/selectable-card.tsx create mode 100644 packages/ui/src/integration-select-grid/integration-select-grid.spec.tsx delete mode 100644 packages/widgets/src/today/component.tsx delete mode 100644 packages/widgets/src/today/date-stats.spec.ts delete mode 100644 packages/widgets/src/today/date-stats.ts delete mode 100644 packages/widgets/src/today/index.ts diff --git a/apps/docs/docs/widgets/today/index.mdx b/apps/docs/docs/widgets/today/index.mdx deleted file mode 100644 index 000861d20..000000000 --- a/apps/docs/docs/widgets/today/index.mdx +++ /dev/null @@ -1,34 +0,0 @@ ---- -title: "Today" -description: "Displays the current date and progress through the calendar." -hide_title: true ---- - -import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions"; - -import { AddingWidget } from "@site/src/components/widgets/adding"; -import { WidgetConfig } from "@site/src/components/widgets/configuration"; -import { WidgetHeader } from "@site/src/components/widgets/header"; -import { todayWidget } from "."; - - - -The Today widget presents the current local date without duplicating a live clock. It progressively adds the week -number, day of year, quarter, remaining days, and calendar progress as more space becomes available. - -The advanced view shows week, month, and year progress together with a twelve-month overview. Week numbers can follow -your locale or the ISO 8601 convention. “Days after today” excludes the current day to keep the remaining-day count -unambiguous. - -The widget runs entirely in your browser and does not send date or location data to an external service. It follows -the timezone and locale of the device viewing the board, so viewers in different regions can see their own local date. - - - -### Adding the widget - - - -### Configuration - - diff --git a/apps/docs/docs/widgets/today/index.ts b/apps/docs/docs/widgets/today/index.ts deleted file mode 100644 index 8922d4b6e..000000000 --- a/apps/docs/docs/widgets/today/index.ts +++ /dev/null @@ -1,43 +0,0 @@ -import type { WidgetDefinition } from "@site/src/types"; -import { IconCalendarWeek } from "@tabler/icons-react"; - -export const todayWidget: WidgetDefinition = { - icon: IconCalendarWeek, - name: "Today", - description: "Displays the current date and progress through the calendar.", - path: "../../widgets/today", - configuration: { - items: [ - { - name: "Week numbering", - description: "Use locale-specific week numbering or ISO 8601 week numbers", - values: { type: "select", options: ["Use locale rules", "ISO 8601"] }, - defaultValue: "Use locale rules", - }, - { - name: "Show week number", - description: "Display the current week number when the widget has room", - values: { type: "boolean" }, - defaultValue: "yes", - }, - { - name: "Show day of year", - description: "Display the current day number within the year", - values: { type: "boolean" }, - defaultValue: "yes", - }, - { - name: "Show quarter", - description: "Display the current calendar quarter", - values: { type: "boolean" }, - defaultValue: "yes", - }, - { - name: "Show year progress", - description: "Display progress through the current year", - values: { type: "boolean" }, - defaultValue: "yes", - }, - ], - }, -}; diff --git a/apps/docs/static/api/open-api-schema.json b/apps/docs/static/api/open-api-schema.json index 8e5da38c1..83b48a665 100644 --- a/apps/docs/static/api/open-api-schema.json +++ b/apps/docs/static/api/open-api-schema.json @@ -2106,7 +2106,6 @@ "enum": [ "clock", "weather", - "today", "airQuality", "countdown", "timer", diff --git a/apps/nextjs/src/components/board/items/item-select-modal.module.css b/apps/nextjs/src/components/board/items/item-select-modal.module.css deleted file mode 100644 index 1a53dd49b..000000000 --- a/apps/nextjs/src/components/board/items/item-select-modal.module.css +++ /dev/null @@ -1,20 +0,0 @@ -.action { - opacity: 0; - transition: opacity 150ms ease; -} -.card:hover .action, -.card:focus-within .action { - opacity: 1; -} - -@media (hover: none), (pointer: coarse) { - .action { - opacity: 1; - } -} - -@media (prefers-reduced-motion: reduce) { - .action { - transition: none; - } -} diff --git a/apps/nextjs/src/components/board/items/item-select-modal.tsx b/apps/nextjs/src/components/board/items/item-select-modal.tsx index 2d163c89d..5048d0f36 100644 --- a/apps/nextjs/src/components/board/items/item-select-modal.tsx +++ b/apps/nextjs/src/components/board/items/item-select-modal.tsx @@ -1,21 +1,35 @@ -import { useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import type { ComponentType } from "react"; import { Avatar, Badge, Box, Button, + Card, Center, + CloseButton, Divider, Flex, Group, Image, + Input, + Loader, + LoadingOverlay, + Paper, + ScrollArea, + SegmentedControl, Select, + SimpleGrid, Stack, Text, + ThemeIcon, Tooltip, } from "@mantine/core"; import { notifications } from "@mantine/notifications"; -import { IconApi, IconBuildingStore } from "@tabler/icons-react"; +import { useElementSize } from "@mantine/hooks"; +import { IconApi, IconBuildingStore, IconPlus, IconSearch, IconSparkles } from "@tabler/icons-react"; +import { QueryErrorResetBoundary } from "@tanstack/react-query"; +import { ErrorBoundary } from "react-error-boundary"; import type { RouterOutputs } from "@homarr/api"; import { clientApi } from "@homarr/api/client"; @@ -23,8 +37,8 @@ import { useSession } from "@homarr/auth/client"; import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context"; import { createId } from "@homarr/common"; import { - getIconUrl, getBoardLaneColumnCount, + getIconUrl, getIntegrationName, getRootSectionLane, widgetIntegrationSupport, @@ -32,18 +46,29 @@ import { widgetKindsWithOptionalIntegrations, } from "@homarr/definitions"; import type { IntegrationKind, WidgetKind } from "@homarr/definitions"; -import { createModal, modalSizeSelect, useModalAction } from "@homarr/modals"; +import { createModal, useModalAction } from "@homarr/modals"; import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; import { useSettings } from "@homarr/settings"; import { useI18n } from "@homarr/translation/client"; -import { CatalogItem, SelectGridLayout, selectGridCardHeight } from "@homarr/ui"; +import { IntegrationAvatar, SelectableCard } from "@homarr/ui"; import type { TablerIcon } from "@homarr/ui"; import { widgetCatalogIcons } from "@homarr/widgets/catalog"; -import { loadWidgetDefinition, reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest"; +import { WidgetError } from "@homarr/widgets/errors"; +import { NoIntegrationSelectedError } from "@homarr/widgets/errors/classes"; +import { getInputForType } from "@homarr/widgets/inputs"; +import { FormProvider, useForm } from "@homarr/widgets/inputs/form"; +import { loadWidgetDefinition, loadWidgetResources, reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest"; +import type { WidgetComponentProps, WidgetDefinition } from "@homarr/widgets/definition"; +import type { OptionsBuilderResult } from "@homarr/widgets/options"; +import type { IntegrationSelectOption } from "@homarr/widgets/widget-integration-select"; +import { WidgetIntegrationSelect } from "@homarr/widgets/widget-integration-select"; -import { IntegrationSelectModal } from "~/components/integration/integration-select-modal"; -import { useSetupAnalytics } from "~/components/create/setup-analytics"; +import { NewIntegrationForm } from "~/app/[locale]/manage/integrations/new/_integration-new-form"; +import type { CreatedIntegrationResult } from "~/app/[locale]/manage/integrations/new/_integration-new-form"; +import type { WidgetEditModalState } from "@homarr/widgets/modals"; import type { EmptySection } from "~/app/[locale]/boards/_types"; +import { useSetupAnalytics } from "~/components/create/setup-analytics"; +import { IntegrationSelectModal } from "~/components/integration/integration-select-modal"; import { useItemActions } from "./item-actions"; import type { WidgetConnectionStatus } from "./item-select-data"; import { @@ -62,6 +87,15 @@ interface ItemSelectModalContentProps { isAdmin: boolean; canCreateIntegration: boolean; initialSearch?: string; + initialWidgetKind?: WidgetKind; +} + +interface WidgetItemData { + kind: WidgetKind; + supportedIntegrations: IntegrationKind[]; + icon: TablerIcon; + name: string; + description: string; } const ItemSelectModalContent = ({ @@ -72,6 +106,7 @@ const ItemSelectModalContent = ({ isAdmin, canCreateIntegration, initialSearch = "", + initialWidgetKind, }: ItemSelectModalContentProps) => { const [search, setSearch] = useState(initialSearch); const t = useI18n(); @@ -108,15 +143,14 @@ const ItemSelectModalContent = ({ [board.sections, currentLayout, mainCanvasSection, t], ); const [targetSectionId, setTargetSectionId] = useState(placementOptions[0]?.value ?? null); + const effectiveSectionId = placementOptions.some(({ value }) => value === targetSectionId) + ? targetSectionId + : (placementOptions[0]?.value ?? null); + const [loadingSelection, setLoadingSelection] = useState(null); const selectionLock = useRef(false); const { createItem, removeItem } = useItemActions(); - useEffect(() => { - if (placementOptions.some(({ value }) => value === targetSectionId)) return; - setTargetSectionId(placementOptions[0]?.value ?? null); - }, [placementOptions, targetSectionId]); - const { openModal: openEditModal } = useModalAction(LazyWidgetEditModal); const { openModal: openIntegrationModal } = useModalAction(IntegrationSelectModal); const settings = useSettings(); @@ -128,7 +162,7 @@ const ItemSelectModalContent = ({ [integrationData], ); - const items = useMemo( + const items = useMemo( () => widgetKinds .filter((kind) => kind !== "customApi") @@ -167,6 +201,9 @@ const ItemSelectModalContent = ({ [customWidgetDefs, search], ); + // Selected item state for live preview pane - only shown when explicitly selected + const [selectedKind, setSelectedKind] = useState(initialWidgetKind ?? null); + const notifyDefinitionLoadError = (error: unknown) => { showErrorNotification({ title: t("common.error"), @@ -188,7 +225,7 @@ const ItemSelectModalContent = ({ createdItem.layouts.find(({ layoutId }) => layoutId === currentLayoutId)?.sectionId ?? createdItem.layouts[0]?.sectionId; const actualDestination = placementOptions.find(({ value }) => value === actualSectionId)?.label; - const wasRelocated = targetSectionId !== null && actualSectionId !== targetSectionId && actualDestination; + const wasRelocated = effectiveSectionId !== null && actualSectionId !== effectiveSectionId && actualDestination; const notificationId = `board-item-created:${itemId}`; showSuccessNotification({ id: notificationId, @@ -241,13 +278,13 @@ const ItemSelectModalContent = ({ options, integrationIds: [], }, - onSuccessfulEdit: ({ options: configuredOptions, advancedOptions }) => { + onSuccessfulEdit: ({ options: configuredOptions, advancedOptions }: WidgetEditModalState) => { const updatedBoard = createItem({ id: itemId, kind: "customApi", integrationIds: [], options: configuredOptions, - targetSectionId: targetSectionId ?? undefined, + targetSectionId: effectiveSectionId ?? undefined, advancedOptions, }); notifyCreated(updatedBoard, itemId, customWidgetDefinition.name); @@ -257,7 +294,9 @@ const ItemSelectModalContent = ({ settings, }, { - title: (titleT) => `${titleT("item.edit.title")} - ${customWidgetDefinition.name}`, + title(translate) { + return `${translate("item.edit.title")} - ${customWidgetDefinition.name}`; + }, }, ); } catch (error) { @@ -268,12 +307,15 @@ const ItemSelectModalContent = ({ } }; - const handleAdd = async (kind: WidgetKind) => { + const handleDirectAdd = async ( + kind: WidgetKind, + configuredOptions?: Record, + integrationIds?: string[], + ) => { if (!tryLockSelection(selectionLock)) return; flowStartedAt.current = performance.now(); trackSetup("widget-started", { entryPoint: "board", intent: kind }); setLoadingSelection(kind); - preloadWidgetEditModal(); try { const definition = await loadWidgetDefinition(kind); const hasIntegrationSupport = "supportedIntegrations" in definition; @@ -288,48 +330,10 @@ const ItemSelectModalContent = ({ const maxIntegrations = "maxIntegrations" in definition ? (definition.maxIntegrations ?? Infinity) : Infinity; const matchingIntegrationCount = Math.min(matchingIntegrations.length, maxIntegrations); const itemId = createId(); - const defaultOptions = reduceWidgetOptionsWithDefinition(definition, settings); - const openEditor = ( - availableIntegrations: { id: string; name: string; url: string; kind: IntegrationKind }[], - ) => { - const selectedIntegrationIds = availableIntegrations - .slice(0, maxIntegrations) - .map((integration) => integration.id); - openEditModal( - { - kind, - definition, - value: { - advancedOptions: { title: null, customCssClasses: [], borderColor: "" }, - options: defaultOptions, - integrationIds: selectedIntegrationIds, - }, - onSuccessfulEdit: ({ options, integrationIds: newIntegrationIds, advancedOptions }) => { - const updatedBoard = createItem({ - id: itemId, - kind, - options, - integrationIds: newIntegrationIds, - targetSectionId: targetSectionId ?? undefined, - advancedOptions, - }); - if (!notifyCreated(updatedBoard, itemId, t(`widget.${kind}.name`))) return; - trackSetup("widget-completed", { - entryPoint: "board", - intent: kind, - outcome: "completed", - elapsedMs: flowStartedAt.current ? Math.round(performance.now() - flowStartedAt.current) : undefined, - }); - }, - integrationData: availableIntegrations, - integrationSupport: hasIntegrationSupport, - settings, - }, - { - title: (titleT) => `${titleT("item.edit.title")} - ${titleT(`widget.${kind}.name`)}`, - }, - ); - }; + const defaultOptions = configuredOptions ?? reduceWidgetOptionsWithDefinition(definition, settings); + const selectedIntegrationIds = + integrationIds ?? matchingIntegrations.slice(0, maxIntegrations).map((integration) => integration.id); + const integrationsRequired = hasIntegrationSupport && (!("integrationsRequired" in definition) || definition.integrationsRequired !== false); @@ -348,10 +352,9 @@ const ItemSelectModalContent = ({ return; } - actions.closeModal(); openIntegrationModal({ allowedKinds: (definition.supportedIntegrations ?? []).filter((integration) => integration !== "mock"), - onSuccess: (result) => { + onSuccess: (result: CreatedIntegrationResult | undefined) => { if (result) { trackSetup("dependency-resolved-inline", { entryPoint: "board", @@ -359,7 +362,15 @@ const ItemSelectModalContent = ({ outcome: "continued", canResolveInline: true, }); - openEditor([result.integration]); + const updatedBoard = createItem({ + id: itemId, + kind, + options: defaultOptions, + integrationIds: [result.integration.id], + targetSectionId: effectiveSectionId ?? undefined, + advancedOptions: { title: null, customCssClasses: [], borderColor: "" }, + }); + notifyCreated(updatedBoard, itemId, t(`widget.${kind}.name`)); } }, }); @@ -367,7 +378,21 @@ const ItemSelectModalContent = ({ } actions.closeModal(); - openEditor(matchingIntegrations); + const updatedBoard = createItem({ + id: itemId, + kind, + options: defaultOptions, + integrationIds: selectedIntegrationIds, + targetSectionId: effectiveSectionId ?? undefined, + advancedOptions: { title: null, customCssClasses: [], borderColor: "" }, + }); + if (!notifyCreated(updatedBoard, itemId, t(`widget.${kind}.name`))) return; + trackSetup("widget-completed", { + entryPoint: "board", + intent: kind, + outcome: "completed", + elapsedMs: flowStartedAt.current ? Math.round(performance.now() - flowStartedAt.current) : undefined, + }); } catch (error) { notifyDefinitionLoadError(error); } finally { @@ -376,126 +401,797 @@ const ItemSelectModalContent = ({ } }; + const selectedItemData = selectedKind ? items.find((item) => item.kind === selectedKind) : null; + const isPreviewOpen = Boolean(selectedItemData); + return ( - - - { - if (event.key === "Enter" && loadingSelection === null && filteredItems.length === 1) { - const [item] = filteredItems; - if (item) void handleAdd(item.kind); - } + + {/* Top Search and Placement Controls */} + + + setSearch(event.currentTarget.value)} + leftSection={} + placeholder={`${t("item.create.search")}...`} + aria-label={t("item.create.search")} + data-autofocus + onKeyDown={(event) => { + if ( + event.key === "Enter" && + loadingSelection === null && + filteredItems.length === 1 && + filteredItems[0] + ) { + void handleDirectAdd(filteredItems[0].kind); + } + }} + /> + + + {placementOptions.length > 1 && ( + + - - )} - {filteredItems.map((item) => ( - void handleAdd(item.kind)} - onFocus={() => { - void loadWidgetDefinition(item.kind).catch(() => undefined); - preloadWidgetEditModal(); + + + + {filteredItems.map((item) => ( + setSelectedKind((current) => (current === item.kind ? null : item.kind))} + disabled={loadingSelection !== null} + loading={loadingSelection === item.kind} + connectionStatus={getWidgetConnectionStatus({ + supportedIntegrations: item.supportedIntegrations, + availableKinds, + connectionOptional: widgetKindsWithOptionalIntegrations.has(item.kind), + })} + /> + ))} + + + {/* Custom Widgets Section for Admins */} + {isAdmin && ( + <> + + + + {t("customWidget.page.list.title")} + + + } + labelPosition="center" + my="md" + /> + + + {filteredCustomWidgets.map((def) => ( + void handleAddCustomWidget(def)} + aria-label={def.name} + icon={ + def.iconUrl ? ( + + ) : ( + + + + ) + } + title={def.name} + description={def.description ?? ""} + footerLeft={ + + Custom Widget + + } + /> + ))} + + + + + + + )} + + {filteredItems.length === 0 && (!isAdmin || filteredCustomWidgets.length === 0) && ( +
+ {t("common.noResults")} +
+ )} +
+ +
+ + {/* Right Column: Live Preview & Interactive Settings Panel (Only shown when an item is selected) */} + {selectedItemData && ( + + setSelectedKind(null)} + onOpenConnectIntegration={() => { + const supported = widgetIntegrationSupport[selectedItemData.kind] ?? []; + openIntegrationModal({ + allowedKinds: supported.filter((i) => i !== "mock"), + onSuccess: (result) => { + if (result) { + void ensureIntegrationDataAsync(); + } + }, + }); }} - onPointerEnter={() => { - void loadWidgetDefinition(item.kind).catch(() => undefined); - preloadWidgetEditModal(); - }} - disabled={loadingSelection !== null} - loading={loadingSelection === item.kind} - connectionStatus={getWidgetConnectionStatus({ - supportedIntegrations: item.supportedIntegrations, - availableKinds, - connectionOptional: widgetKindsWithOptionalIntegrations.has(item.kind), - })} + onAddWidget={(configuredOptions, integrationIds) => + void handleDirectAdd(selectedItemData.kind, configuredOptions, integrationIds) + } + isAdding={loadingSelection === selectedItemData.kind} /> - ))} - - {isAdmin && ( - <> - - {filteredCustomWidgets.map((def) => ( - void handleAddCustomWidget(def)} - onFocus={() => { - void loadWidgetDefinition("customApi").catch(() => undefined); - }} - onPointerEnter={() => { - void loadWidgetDefinition("customApi").catch(() => undefined); - }} - > - - - {def.iconUrl ? ( - - ) : ( - - )} - - {def.name} - - - - {def.description ?? ""} - - - - - ))} - - - {/* Opens in a new tab so unsaved board changes survive the detour. */} - - - - )} - - {filteredItems.length === 0 && (!isAdmin || filteredCustomWidgets.length === 0) && ( -
- {t("common.noResults")} -
- )} - -
-
+ + )} + + ); }; + +// ========================================================================= +// WidgetLivePreviewAndConfigPane: Asynchronously Loads Resources & Renders Content +// ========================================================================= +interface WidgetLivePreviewAndConfigPaneProps { + item: WidgetItemData; + boardId: string; + integrationData: RouterOutputs["integration"]["all"]; + canCreateIntegration: boolean; + onClose: () => void; + onOpenConnectIntegration: () => void; + onAddWidget: (options: Record, integrationIds: string[]) => void; + isAdding: boolean; +} + +const WidgetLivePreviewAndConfigPane = ({ + item, + boardId, + integrationData, + canCreateIntegration, + onClose, + onOpenConnectIntegration, + onAddWidget, + isAdding, +}: WidgetLivePreviewAndConfigPaneProps) => { + const [resources, setResources] = useState<{ + definition: WidgetDefinition; + Component: ComponentType>; + } | null>(null); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + let active = true; + setIsLoading(true); + loadWidgetResources(item.kind) + .then((res) => { + if (active) { + setResources(res); + setIsLoading(false); + } + }) + .catch((_err) => { + if (active) { + setIsLoading(false); + } + }); + return () => { + active = false; + }; + }, [item.kind]); + + if (isLoading || !resources) { + return ( + +
+ +
+
+ ); + } + + return ( + + ); +}; + +// ========================================================================= +// WidgetLivePreviewAndConfigContent: Form State & Real-time Live Preview +// ========================================================================= +interface WidgetLivePreviewAndConfigContentProps { + item: WidgetItemData; + definition: WidgetDefinition; + Component: ComponentType>; + boardId: string; + integrationData: RouterOutputs["integration"]["all"]; + canCreateIntegration: boolean; + onClose: () => void; + onOpenConnectIntegration: () => void; + onAddWidget: (options: Record, integrationIds: string[]) => void; + isAdding: boolean; +} + +const WidgetLivePreviewAndConfigContent = ({ + item, + definition, + Component, + boardId, + integrationData, + canCreateIntegration, + onClose, + onOpenConnectIntegration, + onAddWidget, + isAdding, +}: WidgetLivePreviewAndConfigContentProps) => { + const t = useI18n(); + const settings = useSettings(); + + const { data: selectableApps } = clientApi.app.selectable.useQuery(undefined, { + enabled: item.kind === "app", + }); + + const utils = clientApi.useUtils(); + const supportedKinds = useMemo(() => { + if (!("supportedIntegrations" in definition)) return []; + return (definition.supportedIntegrations ?? []).filter((k) => k !== "mock"); + }, [definition]); + + // Filter integrations compatible with this widget + const compatibleIntegrations: IntegrationSelectOption[] = useMemo(() => { + if (!("supportedIntegrations" in definition)) return []; + const supported = definition.supportedIntegrations ?? []; + return (integrationData ?? []) + .filter((i) => supported.includes(i.kind)) + .map((i) => ({ + id: i.id, + name: i.name, + kind: i.kind, + url: i.url, + })); + }, [definition, integrationData]); + + const [showInlineConnect, setShowInlineConnect] = useState(compatibleIntegrations.length === 0); + const [inlineConnectKind, setInlineConnectKind] = useState(supportedKinds[0] ?? null); + + const supportsMock = + "supportedIntegrations" in definition && (definition.supportedIntegrations ?? []).includes("mock"); + const mockIntegration = supportsMock ? (integrationData ?? []).find((i) => i.kind === "mock") : null; + + // Initial options + const initialOptions = useMemo(() => { + const defaultOptions = reduceWidgetOptionsWithDefinition(definition, settings); + if (item.kind === "app" && !defaultOptions.appId && selectableApps?.[0]) { + return { + ...defaultOptions, + appId: selectableApps[0].id, + }; + } + return defaultOptions; + }, [definition, item.kind, selectableApps, settings]); + + // Initial integration IDs: auto-select single choice if available + const initialIntegrationIds = useMemo(() => { + if (!("supportedIntegrations" in definition)) return []; + const maxIntegrations = "maxIntegrations" in definition ? (definition.maxIntegrations ?? Infinity) : Infinity; + if (compatibleIntegrations.length === 1 && compatibleIntegrations[0]) { + return [compatibleIntegrations[0].id]; + } + return compatibleIntegrations.slice(0, maxIntegrations).map((i) => i.id); + }, [compatibleIntegrations, definition]); + + const form = useForm({ + initialValues: { + options: initialOptions, + integrationIds: initialIntegrationIds, + }, + }); + + const previewIntegrationIds = + form.values.integrationIds.length > 0 ? form.values.integrationIds : mockIntegration ? [mockIntegration.id] : []; + + const { mutateAsync: createMockIntegration, isPending: isCreatingMock } = clientApi.integration.create.useMutation({ + async onSuccess(data) { + await utils.integration.all.invalidate(); + if (data && "integration" in data && data.integration) { + form.setFieldValue("integrationIds", [ + ...(maxIntegrations > 1 ? form.values.integrationIds : []), + data.integration.id, + ]); + setShowInlineConnect(false); + showSuccessNotification({ + title: "Demo Service Connected", + message: "A mock integration was created and connected for testing.", + }); + } + }, + }); + + const handleCreateMockService = async () => { + try { + await createMockIntegration({ + name: "Demo Service", + url: "https://demo.homarr.dev", + kind: "mock", + secrets: [], + attemptSearchEngineCreation: false, + }); + } catch (error) { + showErrorNotification({ + title: t("common.error"), + message: error instanceof Error ? error.message : String(error), + }); + } + }; + + const handleInlineCreateSuccess = (result?: CreatedIntegrationResult) => { + void utils.integration.all.invalidate(); + if (result?.integration?.id) { + form.setFieldValue("integrationIds", [ + ...(maxIntegrations > 1 ? form.values.integrationIds : []), + result.integration.id, + ]); + } + setShowInlineConnect(false); + showSuccessNotification({ + message: t("integration.page.create.notification.success.message"), + }); + }; + + const optionsDefinition = useMemo(() => { + return definition.createOptions(settings) as Record; + }, [definition, settings]); + + const hasIntegrationSupport = "supportedIntegrations" in definition; + const integrationsRequired = + hasIntegrationSupport && (!("integrationsRequired" in definition) || definition.integrationsRequired !== false); + const maxIntegrations = "maxIntegrations" in definition ? (definition.maxIntegrations ?? Infinity) : Infinity; + + const { ref: previewContainerRef, width: previewWidth, height: previewHeight } = useElementSize(); + const board = useRequiredBoard(); + + const handleUpdatePartialOptions = useCallback( + ({ newOptions }: { newOptions: Record }) => { + form.setFieldValue("options", { + ...form.values.options, + ...newOptions, + }); + }, + [form], + ); + + return ( + + {/* Header Banner */} + + + + + + + + {item.name} + + + Live Preview & Options + + + + + + + + + + + {/* Live Widget Preview Frame (Simulating a 3x3 board widget tile) */} + + + + Preview (3×3) + + {form.values.integrationIds.length === 0 && Boolean(mockIntegration) && hasIntegrationSupport && ( + + Showing Demo / Mock Data + + )} + + +
+ + + + {({ reset }) => ( + ( +
+ +
+ )} + > + + +
+ )} +
+
+
+
+
+ + {/* Scrollable Configuration Form */} + + + Widget Settings + + + + + {/* Integration Configuration / In-Place Connection */} + {hasIntegrationSupport && ( + + + {/* Existing Integrations Dropdown (when not creating inline) */} + {compatibleIntegrations.length > 0 && !showInlineConnect && ( + + 1} + withAsterisk={integrationsRequired} + {...form.getInputProps("integrationIds")} + /> + {canCreateIntegration && supportedKinds.length > 0 && ( + + + + )} + + )} + + {/* Inline Connect Form */} + {showInlineConnect && canCreateIntegration && supportedKinds.length > 0 && ( + + + + + + Connect {getIntegrationName(inlineConnectKind ?? supportedKinds[0]!)} + + + + {supportsMock && ( + + )} + {compatibleIntegrations.length > 0 && ( + + )} + + + + {/* Multiple choice selector if widget supports >1 integration kind */} + {supportedKinds.length > 1 && ( + setInlineConnectKind(val as IntegrationKind)} + data={supportedKinds.map((kind) => ({ + value: kind, + label: getIntegrationName(kind), + }))} + /> + )} + + 0 ? () => setShowInlineConnect(false) : undefined} + /> + + )} + + {/* No Connected Services and cannot create */} + {compatibleIntegrations.length === 0 && !canCreateIntegration && ( + + + No Connected Services + + + This widget requires a connected service ( + {definition?.supportedIntegrations?.map(getIntegrationName).join(", ")}). + + + )} + + + )} + + {/* Dynamic Option Inputs */} + {Object.entries(optionsDefinition).map(([key, value]) => { + const InputComponent = getInputForType(value.type); + if ( + !InputComponent || + value.shouldHide?.( + form.values.options as never, + compatibleIntegrations + .filter(({ id }) => form.values.integrationIds.includes(id)) + .map(({ kind }) => kind), + ) + ) { + return null; + } + + return ( + + ); + })} + + {Object.keys(optionsDefinition).length === 0 && + (!hasIntegrationSupport || compatibleIntegrations.length === 0) && ( +
+ + This widget does not require additional configuration. + +
+ )} +
+
+
+
+
+ ); +}; + +const Throw = ({ when, error }: { when: boolean; error: Error }) => { + if (when) throw error; + return null; +}; + +// ========================================================================= +// WidgetItem: Variant 3 (Dashboard Inset) with Variant 4 (Fuller Icon & MD text) +// ========================================================================= +export const WidgetItem = ({ + item, + isSelected, + onSelect, + disabled, + loading, + connectionStatus, +}: { + item: WidgetItemData; + isSelected?: boolean; + onSelect: () => void; + disabled: boolean; + loading: boolean; + connectionStatus: WidgetConnectionStatus; +}) => { + return ( + + + + } + title={item.name} + topRight={!isSelected ? : null} + description={item.description} + footerLeft={} + /> + ); +}; + +const ConnectionStatusBadge = ({ status }: { status: WidgetConnectionStatus }) => { + if (status === "noConnectionRequired") return null; + const t = useI18n(); + const color = status === "ready" ? "green" : status === "needsSetup" ? "yellow" : "gray"; + + return ( + + {t(`item.create.connectionStatus.${status}`)} + + ); +}; + +const SupportedIntegrations = ({ integrations }: { integrations: IntegrationKind[] }) => { + if (integrations.length === 0) { + return ( + + Standalone + + ); + } + + const countToShow = integrations.length >= 6 ? 4 : 5; + const moreCount = integrations.length - countToShow; + + return ( + + + + {integrations.slice(0, countToShow).map((integration) => ( + + + + ))} + {moreCount > 0 && ( + + {integrations.slice(countToShow).map((integration) => ( + + {getIntegrationName(integration)} + + ))} + + } + > + + +{moreCount} + + + )} + + + + ); +}; + const ItemSelectModalFrame = ({ actions, innerProps, @@ -514,6 +1210,29 @@ const ItemSelectModalFrame = ({ { enabled: isAdmin }, ); + const { mutate: autoCreateMock } = clientApi.integration.create.useMutation({ + onSuccess: () => { + void utils.integration.all.invalidate(); + }, + }); + + const attemptedMockRef = useRef(false); + useEffect(() => { + if (integrationData && canCreateIntegration && !attemptedMockRef.current) { + const hasMock = integrationData.some((i) => i.kind === "mock"); + if (!hasMock) { + attemptedMockRef.current = true; + autoCreateMock({ + name: "Demo Service", + url: "https://demo.homarr.dev", + kind: "mock", + secrets: [], + attemptSearchEngineCreation: false, + }); + } + } + }, [integrationData, canCreateIntegration, autoCreateMock]); + return ( utils.integration.all.ensureData()} isAdmin={isAdmin} canCreateIntegration={canCreateIntegration} + initialWidgetKind={innerProps.initialWidgetKind} initialSearch={ innerProps.initialWidgetKind ? t(`widget.${innerProps.initialWidgetKind}.name`) @@ -539,111 +1259,5 @@ export const ItemSelectModal = createModal<{ initialWidgetKind?: WidgetKind; }>((props) => ).withOptions({ defaultTitle: (t) => t("item.create.title"), - size: modalSizeSelect, + size: "xxl", }); - -const WidgetItem = ({ - item, - onSelect, - onFocus, - onPointerEnter, - disabled, - loading, - connectionStatus, -}: { - item: { - kind: WidgetKind; - supportedIntegrations: IntegrationKind[]; - name: string; - description: string; - icon: TablerIcon; - }; - onSelect: () => void; - onFocus: () => void; - onPointerEnter: () => void; - disabled: boolean; - loading: boolean; - connectionStatus: WidgetConnectionStatus; -}) => { - const t = useI18n(); - const statusLabel = t(`item.create.connectionStatus.${connectionStatus}`); - - return ( - - - - - - {item.name} - - - - - {item.description} - - - - - - - ); -}; - -const ConnectionStatusBadge = ({ status }: { status: WidgetConnectionStatus }) => { - const t = useI18n(); - const color = status === "ready" ? "green" : status === "needsSetup" ? "yellow" : "gray"; - - return ( - - {t(`item.create.connectionStatus.${status}`)} - - ); -}; - -const SupportedIntegrations = ({ integrations }: { integrations: IntegrationKind[] }) => { - if (integrations.length === 0) { - return null; - } - - const countToShow = integrations.length >= 8 ? 6 : 7; - const moreCount = integrations.length - countToShow; - - return ( - - - - {integrations.slice(0, countToShow).map((integration) => ( - - - - ))} - {moreCount > 0 && ( - - {integrations.slice(countToShow).map((integration) => ( -
{getIntegrationName(integration)}
- ))} - - } - > - - +{moreCount} - -
- )} -
-
-
- ); -}; diff --git a/apps/nextjs/src/components/integration/integration-select-modal.tsx b/apps/nextjs/src/components/integration/integration-select-modal.tsx index 55105c538..76f8062a4 100644 --- a/apps/nextjs/src/components/integration/integration-select-modal.tsx +++ b/apps/nextjs/src/components/integration/integration-select-modal.tsx @@ -1,9 +1,10 @@ "use client"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { Group, ScrollArea, Title, UnstyledButton } from "@mantine/core"; import { IconArrowLeft, IconX } from "@tabler/icons-react"; +import { clientApi } from "@homarr/api/client"; import type { IntegrationKind } from "@homarr/definitions"; import { getIntegrationName } from "@homarr/definitions"; import { createModal, modalSizeSelect, useModalAction } from "@homarr/modals"; @@ -27,9 +28,28 @@ interface IntegrationSelectModalProps { export const IntegrationSelectModal = createModal(({ actions, innerProps }) => { const t = useI18n(); - const [step, setStep] = useState<"select" | "form">(innerProps.initialKind ? "form" : "select"); - const [selectedKind, setSelectedKind] = useState(innerProps.initialKind ?? null); + const directKind = + innerProps.initialKind ?? (innerProps.allowedKinds?.length === 1 ? innerProps.allowedKinds[0] : null); + const isDirectForm = Boolean(directKind); + const [step, setStep] = useState<"select" | "form">(isDirectForm ? "form" : "select"); + const [selectedKind, setSelectedKind] = useState(directKind ?? null); const { openModal: openCompletionModal } = useModalAction(IntegrationCompletionModal); + const { data: integrationData } = clientApi.integration.all.useQuery(); + + useEffect(() => { + if (step === "form" && !isDirectForm) { + actions.setCloseInterceptor?.(() => { + setStep("select"); + setSelectedKind(null); + return false; + }); + } else { + actions.setCloseInterceptor?.(null); + } + return () => { + actions.setCloseInterceptor?.(null); + }; + }, [step, isDirectForm, actions]); const handleSelect = (kind: IntegrationKind) => { setSelectedKind(kind); @@ -40,7 +60,7 @@ export const IntegrationSelectModal = createModal(( setStep("select"); setSelectedKind(null); }; - const handleFormBack = innerProps.initialKind ? actions.closeModal : handleBack; + const handleFormBack = isDirectForm ? actions.closeModal : handleBack; const handleSuccess = (result?: CreatedIntegrationResult) => { actions.closeModal(); @@ -52,12 +72,8 @@ export const IntegrationSelectModal = createModal(( }; if (step === "form" && selectedKind) { - let HeaderIcon = IconArrowLeft; - let headerLabel = t("common.action.previous"); - if (innerProps.initialKind) { - HeaderIcon = IconX; - headerLabel = t("common.action.close"); - } + const HeaderIcon = isDirectForm ? IconX : IconArrowLeft; + const headerLabel = isDirectForm ? t("common.action.close") : t("common.action.previous"); return ( @@ -84,6 +100,7 @@ export const IntegrationSelectModal = createModal(( onSelect={handleSelect} enableMockIntegration={innerProps.enableMockIntegration} allowedKinds={innerProps.allowedKinds} + integrationData={integrationData} /> ); }).withOptions({ diff --git a/e2e/shared/actions/item-select-actions.ts b/e2e/shared/actions/item-select-actions.ts new file mode 100644 index 000000000..d137c4d7a --- /dev/null +++ b/e2e/shared/actions/item-select-actions.ts @@ -0,0 +1,211 @@ +import type { Page } from "@playwright/test"; + +export class ItemSelectActions { + private readonly page: Page; + + constructor(page: Page) { + this.page = page; + } + + /** + * Enter board edit mode if not already active + */ + public async enterEditModeAsync() { + const editToggle = this.page.getByTestId("board-edit-mode-toggle"); + await editToggle.waitFor({ state: "visible", timeout: 15_000 }); + const isPressed = await editToggle.getAttribute("aria-pressed"); + if (isPressed !== "true") { + await editToggle.click(); + await this.page.waitForFunction( + () => document.querySelector('[data-testid="board-edit-mode-toggle"]')?.getAttribute("aria-pressed") === "true", + { timeout: 15_000 }, + ); + } + } + + /** + * Open the ItemSelectModal ("Choose item to add") from the board + */ + public async openItemSelectModalAsync() { + await this.enterEditModeAsync(); + + // Check if universal create button in header or empty state button is available + const headerAddButton = this.page.locator('button[aria-label="Add board content"]'); + const emptyStateAddButton = this.page.getByRole("button", { name: "Add widget", exact: false }); + + if (await headerAddButton.isVisible()) { + await headerAddButton.click(); + // In Universal Create modal, click "Widget" + const widgetOption = this.page + .getByRole("dialog") + .locator("button") + .filter({ hasText: /Widget/i }) + .first(); + await widgetOption.waitFor({ state: "visible", timeout: 10_000 }); + await widgetOption.click(); + } else if (await emptyStateAddButton.isVisible()) { + await emptyStateAddButton.click(); + } + + // Wait for ItemSelectModal to be visible + const modal = this.page.getByRole("dialog").filter({ hasText: "Choose item to add" }).first(); + await modal.waitFor({ state: "visible", timeout: 15_000 }); + } + + /** + * Filter catalog items by search query + */ + public async searchWidgetsAsync(query: string) { + const searchInput = this.page + .getByRole("dialog") + .locator('input[placeholder*="Filter items"], input[aria-label*="Filter items"]') + .first(); + await searchInput.waitFor({ state: "visible", timeout: 10_000 }); + await searchInput.fill(query); + // Allow debounce / filtering to update + await this.page.waitForTimeout(200); + } + + /** + * Clear the search input + */ + public async clearSearchAsync() { + const searchInput = this.page + .getByRole("dialog") + .locator('input[placeholder*="Filter items"], input[aria-label*="Filter items"]') + .first(); + await searchInput.waitFor({ state: "visible", timeout: 10_000 }); + await searchInput.clear(); + await this.page.waitForTimeout(200); + } + + /** + * Click a widget card in the catalog grid to select it and trigger live preview + */ + public async selectWidgetAsync(widgetName: string) { + const modal = this.page.getByRole("dialog").filter({ hasText: "Choose item to add" }).first(); + const widgetCard = modal + .locator("button") + .filter({ hasText: new RegExp(`^${widgetName}`, "i") }) + .first(); + await widgetCard.waitFor({ state: "visible", timeout: 10_000 }); + await widgetCard.click(); + // Wait for live preview container to appear + await modal.locator('text="Live Preview & Options"').waitFor({ state: "visible", timeout: 15_000 }); + } + + /** + * Close the live preview pane via the close button + */ + public async closePreviewAsync() { + const modal = this.page.getByRole("dialog").filter({ hasText: "Choose item to add" }).first(); + const closeBtn = modal.locator('button[aria-label="Close preview"]').first(); + await closeBtn.waitFor({ state: "visible", timeout: 10_000 }); + await closeBtn.click(); + } + + /** + * Change destination dropdown (e.g. Main canvas or a container section) + */ + public async selectDestinationAsync(destinationLabel: string) { + const modal = this.page.getByRole("dialog").filter({ hasText: "Choose item to add" }).first(); + const destinationSelect = modal.locator('input[aria-label="Add to"]').first(); + if (await destinationSelect.isVisible()) { + await destinationSelect.click(); + const option = this.page.getByRole("option", { name: destinationLabel }).first(); + await option.waitFor({ state: "visible", timeout: 5_000 }); + await option.click(); + } + } + + /** + * Toggle a switch in Widget Settings + */ + public async toggleSettingSwitchAsync(labelText: string | RegExp) { + const modal = this.page.getByRole("dialog").filter({ hasText: "Choose item to add" }).first(); + const switchInput = modal.getByLabel(labelText).first(); + await switchInput.waitFor({ state: "visible", timeout: 10_000 }); + await switchInput.click(); + } + + /** + * Select an option in a dropdown in Widget Settings + */ + public async selectSettingOptionAsync(selectLabel: string | RegExp, optionText: string | RegExp) { + const modal = this.page.getByRole("dialog").filter({ hasText: "Choose item to add" }).first(); + const selectField = modal.getByLabel(selectLabel).first(); + await selectField.waitFor({ state: "visible", timeout: 10_000 }); + await selectField.click(); + const option = this.page.getByRole("option", { name: optionText }).first(); + await option.waitFor({ state: "visible", timeout: 5_000 }); + await option.click(); + } + + /** + * Fill a text input in Widget Settings + */ + public async fillSettingInputAsync(labelText: string | RegExp, value: string) { + const modal = this.page.getByRole("dialog").filter({ hasText: "Choose item to add" }).first(); + const inputField = modal.getByLabel(labelText).first(); + await inputField.waitFor({ state: "visible", timeout: 10_000 }); + await inputField.fill(value); + } + + /** + * Click "Use Demo Service" button when available in Widget Settings + */ + public async clickUseDemoServiceAsync() { + const modal = this.page.getByRole("dialog").filter({ hasText: "Choose item to add" }).first(); + const demoBtn = modal.getByRole("button", { name: "Use Demo Service", exact: false }).first(); + await demoBtn.waitFor({ state: "visible", timeout: 10_000 }); + await demoBtn.click(); + } + + /** + * Fill inline integration form within the Widget Settings panel + */ + public async fillInlineIntegrationFormAsync(input: { name: string; url: string; apiKey?: string }) { + const modal = this.page.getByRole("dialog").filter({ hasText: "Choose item to add" }).first(); + // Name input + const nameInput = modal + .locator('input[placeholder*="name" i], input[name="name"], input[aria-label*="name" i]') + .first(); + await nameInput.waitFor({ state: "visible", timeout: 10_000 }); + await nameInput.fill(input.name); + + // URL input + const urlInput = modal + .locator('input[placeholder*="url" i], input[placeholder*="http" i], input[name="url"]') + .first(); + await urlInput.waitFor({ state: "visible", timeout: 10_000 }); + await urlInput.fill(input.url); + + // API Key input if provided + if (input.apiKey) { + const apiInput = modal + .locator('input[placeholder*="api" i], input[placeholder*="key" i], input[type="password"]') + .first(); + if (await apiInput.isVisible()) { + await apiInput.fill(input.apiKey); + } + } + + // Submit inline form + const submitBtn = modal + .locator('button[type="submit"]') + .filter({ hasText: /save|create|connect/i }) + .first(); + await submitBtn.waitFor({ state: "visible", timeout: 10_000 }); + await submitBtn.click(); + } + + /** + * Click the "Add" button in the Live Preview header to place the widget on the board + */ + public async submitAddWidgetAsync() { + const modal = this.page.getByRole("dialog").filter({ hasText: "Choose item to add" }).first(); + const addBtn = modal.locator('button[type="button"]').filter({ hasText: /^Add$/i }).first(); + await addBtn.waitFor({ state: "visible", timeout: 10_000 }); + await addBtn.click(); + } +} diff --git a/packages/definitions/src/docs/homarr-docs-sitemap.ts b/packages/definitions/src/docs/homarr-docs-sitemap.ts index 6af7bf14b..5790b89ac 100644 --- a/packages/definitions/src/docs/homarr-docs-sitemap.ts +++ b/packages/definitions/src/docs/homarr-docs-sitemap.ts @@ -336,7 +336,6 @@ export type HomarrDocumentationPath = | "/docs/integrations/whats-up-docker" | "/docs/widgets/clock" | "/docs/widgets/weather" - | "/docs/widgets/today" | "/docs/widgets/air-quality" | "/docs/widgets/countdown" | "/docs/widgets/timer" diff --git a/packages/definitions/src/docs/widget-doc-slugs.ts b/packages/definitions/src/docs/widget-doc-slugs.ts index 2504dda24..466d36369 100644 --- a/packages/definitions/src/docs/widget-doc-slugs.ts +++ b/packages/definitions/src/docs/widget-doc-slugs.ts @@ -3,7 +3,6 @@ import type { WidgetKind } from "../widget"; export const widgetDocSlugs: Record = { clock: "clock", weather: "weather", - today: "today", airQuality: "air-quality", countdown: "countdown", timer: "timer", diff --git a/packages/definitions/src/widget.ts b/packages/definitions/src/widget.ts index f3223a186..5279df2e0 100644 --- a/packages/definitions/src/widget.ts +++ b/packages/definitions/src/widget.ts @@ -1,7 +1,6 @@ export const widgetKinds = [ "clock", "weather", - "today", "airQuality", "countdown", "timer", @@ -63,7 +62,6 @@ export const widgetKinds = [ export type WidgetKind = (typeof widgetKinds)[number]; export const widgetDefaultSizes: Partial> = { - today: { width: 2, height: 1 }, airQuality: { width: 2, height: 1 }, countdown: { width: 2, height: 1 }, timer: { width: 2, height: 1 }, diff --git a/packages/modals-collection/src/apps/app-select-modal.tsx b/packages/modals-collection/src/apps/app-select-modal.tsx index e17558c84..1567825ae 100644 --- a/packages/modals-collection/src/apps/app-select-modal.tsx +++ b/packages/modals-collection/src/apps/app-select-modal.tsx @@ -1,12 +1,25 @@ import { useMemo, useState } from "react"; -import { Button, Center, Group, Stack, Text, Tooltip } from "@mantine/core"; -import { IconCheck, IconPlus, IconApps } from "@tabler/icons-react"; +import { + Badge, + Button, + Center, + Group, + Image, + Input, + Paper, + ScrollArea, + SimpleGrid, + Stack, + Text, + ThemeIcon, +} from "@mantine/core"; +import { IconPlus, IconSearch } from "@tabler/icons-react"; import type { RouterOutputs } from "@homarr/api"; import { clientApi } from "@homarr/api/client"; import { createModal, modalSizeSelect, useModalAction } from "@homarr/modals"; import { useI18n } from "@homarr/translation/client"; -import { CatalogItem, SelectGridLayout, selectGridCardHeight } from "@homarr/ui"; +import { SelectableCard } from "@homarr/ui"; import { QuickAddAppModal } from "./quick-add-app/quick-add-app-modal"; @@ -18,6 +31,8 @@ interface AppSelectModalProps { withCreate: boolean; } +const selectGridCols = { base: 1, xs: 2, sm: 2, md: 3, lg: 4 }; + export const AppSelectModal = createModal(({ actions, innerProps }) => { const [search, setSearch] = useState(""); const [selectedAppIds, setSelectedAppIds] = useState>(new Set()); @@ -50,19 +65,6 @@ export const AppSelectModal = createModal(({ actions, inner actions.closeModal(); }; - const handleSubmit = () => { - if (multiSelect) { - innerProps.onSelectMany?.(selectedApps); - actions.closeModal(); - return; - } - const [first] = filteredApps; - if (first) { - innerProps.onSelect?.(first); - actions.closeModal(); - } - }; - const handleAddNewApp = () => { openQuickAddAppModal({ onClose(app) { @@ -76,100 +78,165 @@ export const AppSelectModal = createModal(({ actions, inner }); }; + const handleMultiSubmit = () => { + innerProps.onSelectMany?.(selectedApps); + actions.closeModal(); + }; + return ( - <> - + {/* Embedded Dynamic Style for Card Hover & Focus */} + + + {/* Top Search Input */} + setSearch(event.currentTarget.value)} + leftSection={} placeholder={`${t("app.action.select.search")}...`} - ariaLabel={t("app.action.select.search")} - onSearchKeyDown={(event) => { + aria-label={t("app.action.select.search")} + data-autofocus + onKeyDown={(event) => { if (event.key === "Enter" && filteredApps.length === 1 && filteredApps[0]) { handleSelect(filteredApps[0]); } }} - > - {innerProps.withCreate && ( - - - - - - {t("app.action.create.title")} - - - - {t("app.action.create.description")} - - - {t("app.action.create.action")} - - - - )} + /> - {filteredApps.map((app) => ( - handleSelect(app)} - > - - - {app.name} - - {app.name} - - {multiSelect && selectedAppIds.has(app.id) && ( - - )} - - - - {app.description ?? ""} - - - - {multiSelect - ? selectedAppIds.has(app.id) - ? t("app.action.select.selected") - : t("app.action.select.toggle") - : t("app.action.select.action", { app: app.name })} - - - - ))} + {/* Scrollable Container with App Cards */} + + + + {innerProps.withCreate && ( + + + + } + title={t("app.action.create.title")} + topRight={ + + New + + } + description={t("app.action.create.description")} + footerLeft={ + + Custom Application + + } + /> + )} - {filteredApps.length === 0 && !isPending && ( -
- {t("app.action.select.noResults")} -
- )} -
- {multiSelect && ( - - - + {filteredApps.map((app) => ( + + ))} + + + {filteredApps.length === 0 && !isPending && ( +
+ {t("app.action.select.noResults")} +
+ )} + +
+ + {/* Multi-Select Action Footer */} + {multiSelect && selectedApps.length > 0 && ( + + + + {selectedApps.length} app{selectedApps.length > 1 ? "s" : ""} selected + + + + + + + )} - + ); }).withOptions({ defaultTitle: (t) => t("app.action.select.title"), size: modalSizeSelect, }); + +// ========================================================================= +// AppCard: Variant 3 (Dashboard Inset) with Medium Title & Large App Icon +// ========================================================================= +const AppCard = ({ + app, + isSelected, + multiSelect, + onSelect, +}: { + app: SelectableApp; + isSelected: boolean; + multiSelect: boolean; + onSelect: (app: SelectableApp) => void; +}) => { + const t = useI18n(); + + return ( + onSelect(app)} + aria-label={app.name} + selected={isSelected} + icon={{app.name}} + title={app.name} + topRight={ + isSelected ? ( + + {t("app.action.select.selected")} + + ) : null + } + description={app.description} + footerLeft={ + + {multiSelect ? (isSelected ? t("app.action.select.selected") : t("app.action.select.toggle")) : "Application"} + + } + /> + ); +}; diff --git a/packages/modals/src/index.tsx b/packages/modals/src/index.tsx index 884182462..fa25dd79b 100644 --- a/packages/modals/src/index.tsx +++ b/packages/modals/src/index.tsx @@ -102,6 +102,13 @@ const ActiveModal = ({ modal, state, handleCloseModal }: ActiveModalProps) => { content: presentationClasses.inspectorContent, }); + const handleClose = useCallback(() => { + const canClose = modal.reference.onCloseRequest ? modal.reference.onCloseRequest() : true; + if (canClose) { + handleCloseModal(); + } + }, [handleCloseModal, modal.reference]); + return ( { {...otherModalProps} title={translateIfNecessary(t, modal.props.defaultTitle)} opened={opened} - onClose={handleCloseModal} + onClose={handleClose} > {modal.reference.content} @@ -144,7 +151,6 @@ export const useModalAction = (modal: TModal) => return { openModal: (innerProps: inferInnerProps, options: OpenModalOptions | void) => { // void actually is undefined - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition context.openModalInner({ modal, innerProps, options: options ?? {} }); }, }; diff --git a/packages/modals/src/reducer.tsx b/packages/modals/src/reducer.tsx index ba22f7297..039f07e52 100644 --- a/packages/modals/src/reducer.tsx +++ b/packages/modals/src/reducer.tsx @@ -94,6 +94,8 @@ const getModal = (modal: ModalState) => const ModalContent = modal.modal.component; const { innerProps, ...rest } = modal.props; + let closeInterceptor: (() => boolean) | null = null; + const FullModal = () => { const context = useContext(ModalContext); @@ -106,6 +108,9 @@ const getModal = (modal: ModalState) => innerProps={innerProps} actions={{ closeModal: () => context.closeModal(modal.id), + setCloseInterceptor: (interceptor: (() => boolean) | null) => { + closeInterceptor = interceptor; + }, }} /> ); @@ -114,5 +119,11 @@ const getModal = (modal: ModalState) => return { modalProps: rest, content: , + onCloseRequest: () => { + if (closeInterceptor) { + return closeInterceptor(); + } + return true; + }, }; }; diff --git a/packages/modals/src/type.ts b/packages/modals/src/type.ts index cd553ac1a..5faa421d2 100644 --- a/packages/modals/src/type.ts +++ b/packages/modals/src/type.ts @@ -5,10 +5,12 @@ import type { stringOrTranslation } from "@homarr/translation"; import type { ModalPresentation } from "./modal-presentation"; -export type ModalComponent = (props: { - actions: { closeModal: () => void }; - innerProps: TInnerProps; -}) => ReactNode; +export interface ModalActions { + closeModal: () => void; + setCloseInterceptor?: (interceptor: (() => boolean) | null) => void; +} + +export type ModalComponent = (props: { actions: ModalActions; innerProps: TInnerProps }) => ReactNode; export type CreateModalOptions = Pick< ModalOptions, diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index 19e7c1ddd..fbfc0bb5c 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -3465,50 +3465,6 @@ "notificationsDenied": "Notifications are blocked in this browser." } }, - "today": { - "name": "Today", - "description": "Displays the current date and progress through the calendar.", - "option": { - "weekConvention": { - "label": "Week numbering", - "options": { - "locale": "Use locale rules", - "iso": "ISO 8601" - } - }, - "showWeekNumber": { - "label": "Show week number" - }, - "showDayOfYear": { - "label": "Show day of year" - }, - "showQuarter": { - "label": "Show quarter" - }, - "showYearProgress": { - "label": "Show year progress" - } - }, - "weekNumber": "Week {week}", - "dayOfYear": "Day {day} of {total}", - "quarter": "Quarter {quarter}", - "daysAfterToday": "{days, plural, =0 {Last day of the year} one {# day after today} other {# days after today}}", - "weekProgressLabel": "Week progress", - "monthProgressLabel": "Month progress", - "yearProgressLabel": "Year progress", - "yearProgress": "Year progress: {progress}%", - "weekConvention": { - "locale": "Week numbers follow your locale", - "iso": "ISO 8601 week numbers" - }, - "advanced": { - "week": "Week number", - "dayOfYear": "Day of year", - "quarter": "Quarter", - "quarterValue": "Q{quarter}", - "daysRemaining": "Days after today" - } - }, "minecraftServerStatus": { "name": "Minecraft Server Status", "description": "Displays the status of a Minecraft server", diff --git a/packages/ui/src/components/index.tsx b/packages/ui/src/components/index.tsx index a593e1975..e2f8d28dc 100644 --- a/packages/ui/src/components/index.tsx +++ b/packages/ui/src/components/index.tsx @@ -13,6 +13,13 @@ export { CustomPasswordInput } from "./password-input/password-input"; export { IntegrationAvatar } from "./integration-avatar"; export { BetaBadge } from "./beta-badge"; export { CatalogItem } from "./catalog-item"; +export { SelectableCard } from "./selectable-card"; +export type { + SelectableCardProps, + SelectableCardStylesNames, + SelectableCardCssVariables, + SelectableCardFactory, +} from "./selectable-card"; export { MaskedImage } from "./masked-image"; export { MaskedOrNormalImage } from "./masked-or-normal-image"; export { LanguageIcon } from "./language-icon"; diff --git a/packages/ui/src/components/selectable-card/index.ts b/packages/ui/src/components/selectable-card/index.ts new file mode 100644 index 000000000..c71cde19a --- /dev/null +++ b/packages/ui/src/components/selectable-card/index.ts @@ -0,0 +1,7 @@ +export { SelectableCard } from "./selectable-card"; +export type { + SelectableCardProps, + SelectableCardStylesNames, + SelectableCardCssVariables, + SelectableCardFactory, +} from "./selectable-card"; diff --git a/packages/ui/src/components/selectable-card/selectable-card.module.css b/packages/ui/src/components/selectable-card/selectable-card.module.css new file mode 100644 index 000000000..405460ef7 --- /dev/null +++ b/packages/ui/src/components/selectable-card/selectable-card.module.css @@ -0,0 +1,79 @@ +.root { + cursor: pointer; + text-align: start; + display: flex; + flex-direction: column; + position: relative; + width: 100%; + min-height: 175px; + height: 100%; + padding: var(--mantine-spacing-sm); + border-radius: var(--mantine-radius-md); + background-color: light-dark(var(--mantine-color-white), var(--mantine-color-dark-7)); + border: 1px solid var(--mantine-color-default-border); + box-shadow: var(--mantine-shadow-xs); + transition: + transform 150ms ease, + box-shadow 150ms ease, + border-color 150ms ease; + overflow: hidden; + font-family: inherit; + color: inherit; + text-decoration: none; +} + +.root:hover:not(:disabled) { + transform: translateY(-2px); + box-shadow: var(--mantine-shadow-sm); + border-color: var(--mantine-primary-color-filled); +} + +.root:focus-visible { + outline: 2px solid var(--mantine-primary-color-filled); + outline-offset: 2px; +} + +.root[data-selected] { + border-color: var(--mantine-primary-color-filled); + box-shadow: 0 0 0 1px var(--mantine-primary-color-filled); +} + +.root:disabled { + cursor: not-allowed; + opacity: 0.6; +} + +.header { + border-bottom: 1px solid var(--mantine-color-default-border); + background-color: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-8)); + padding-top: var(--mantine-spacing-xs); + padding-bottom: var(--mantine-spacing-xs); + margin: calc(var(--mantine-spacing-sm) * -1); + margin-bottom: 0; + padding-left: var(--mantine-spacing-sm); + padding-right: var(--mantine-spacing-sm); +} + +.body { + flex: 1 1 auto; + display: flex; + flex-direction: column; + gap: var(--mantine-spacing-xs); + margin-top: var(--mantine-spacing-xs); + margin-bottom: var(--mantine-spacing-xs); + min-height: 0; +} + +.footer { + border-top: 1px dashed var(--mantine-color-default-border); + background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-8)); + padding-top: var(--mantine-spacing-xs); + padding-bottom: var(--mantine-spacing-xs); + margin: calc(var(--mantine-spacing-sm) * -1); + margin-top: auto; + padding-left: var(--mantine-spacing-sm); + padding-right: var(--mantine-spacing-sm); + min-height: 40px; + display: flex; + align-items: center; +} diff --git a/packages/ui/src/components/selectable-card/selectable-card.spec.tsx b/packages/ui/src/components/selectable-card/selectable-card.spec.tsx new file mode 100644 index 000000000..0eb1c3440 --- /dev/null +++ b/packages/ui/src/components/selectable-card/selectable-card.spec.tsx @@ -0,0 +1,308 @@ +// @vitest-environment jsdom + +import { act, createRef } from "react"; +import { createRoot } from "react-dom/client"; +import type { Root } from "react-dom/client"; +import { Badge, MantineProvider, Text } from "@mantine/core"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { SelectableCard } from "./selectable-card"; +import classes from "./selectable-card.module.css"; + +let root: Root; +let host: HTMLDivElement; + +beforeEach(() => { + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + window.matchMedia = vi.fn().mockImplementation(() => ({ + matches: false, + media: "", + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })); + host = document.createElement("div"); + document.body.append(host); + root = createRoot(host); +}); + +afterEach(async () => { + await act(async () => root.unmount()); + host.remove(); +}); + +describe("SelectableCard", () => { + it("renders as a native button with default button type and accessible slots", async () => { + const onClick = vi.fn(); + await act(async () => + root.render( + + Icon} + title="Sonarr" + topRight={Media} + description="Manage TV series downloads" + footerLeft={Left Meta} + footerRight={Right Meta} + onClick={onClick} + /> + , + ), + ); + + const button = host.querySelector("button"); + expect(button).not.toBeNull(); + expect(button?.type).toBe("button"); + expect(button?.getAttribute("aria-label")).toBe("Sonarr Card"); + expect(host.querySelector("[data-testid='card-icon']")).not.toBeNull(); + expect(host.textContent).toContain("Sonarr"); + expect(host.querySelector("[data-testid='card-topright']")).not.toBeNull(); + expect(host.textContent).toContain("Manage TV series downloads"); + expect(host.querySelector("[data-testid='card-footer-left']")).not.toBeNull(); + expect(host.querySelector("[data-testid='card-footer-right']")).not.toBeNull(); + + await act(async () => button?.click()); + expect(onClick).toHaveBeenCalledOnce(); + }); + + it("applies Styles API classes for root, header, body, and footer", async () => { + await act(async () => + root.render( + + Footer} + classNames={{ + root: "custom-root", + header: "custom-header", + body: "custom-body", + footer: "custom-footer", + }} + /> + , + ), + ); + + const button = host.querySelector("button"); + expect(button?.classList.contains(classes.root)).toBe(true); + expect(button?.classList.contains("custom-root")).toBe(true); + + const header = button?.querySelector(`.${classes.header}`); + expect(header).not.toBeNull(); + expect(header?.classList.contains("custom-header")).toBe(true); + + const body = button?.querySelector(`.${classes.body}`); + expect(body).not.toBeNull(); + expect(body?.classList.contains("custom-body")).toBe(true); + + const footer = button?.querySelector(`.${classes.footer}`); + expect(footer).not.toBeNull(); + expect(footer?.classList.contains("custom-footer")).toBe(true); + }); + + it("prioritizes title without wrapping and constrains topRight badge with ellipsis", async () => { + await act(async () => + root.render( + + Category Badge} /> + , + ), + ); + + const button = host.querySelector("button"); + const header = button?.querySelector(`.${classes.header}`); + expect(header).not.toBeNull(); + + // The title text is rendered with whiteSpace: nowrap and fw: 700 + const titleText = header?.querySelector("p, span, div"); + expect(titleText?.textContent).toContain("Very Long Title Name That Should Not Wrap"); + + // Check that custom ReactNode title is supported + await act(async () => + root.render( + + Custom Title Element} + topRight={Top Right} + /> + , + ), + ); + + expect(host.querySelector("[data-testid='custom-title-node']")).not.toBeNull(); + expect(host.querySelector("[data-testid='custom-top-right']")).not.toBeNull(); + }); + + it("renders custom children in the body slot over description", async () => { + await act(async () => + root.render( + + +
Custom Child Content
+
+
, + ), + ); + + expect(host.querySelector("[data-testid='custom-body-children']")).not.toBeNull(); + expect(host.textContent).toContain("Custom Child Content"); + expect(host.textContent).not.toContain("This should not appear"); + }); + + it("renders description with fallback when description is empty string", async () => { + await act(async () => + root.render( + + + , + ), + ); + + expect(host.textContent).toContain("No description provided"); + }); + + it("omits header, body, or footer when corresponding props are omitted", async () => { + await act(async () => + root.render( + + + , + ), + ); + + const button = host.querySelector("button"); + expect(button?.querySelector(`.${classes.header}`)).toBeNull(); + expect(button?.querySelector(`.${classes.body}`)).toBeNull(); + expect(button?.querySelector(`.${classes.footer}`)).toBeNull(); + }); + + it("handles selected state via data-selected attribute", async () => { + await act(async () => + root.render( + + + , + ), + ); + + const button = host.querySelector("button"); + expect(button?.getAttribute("data-selected")).toBe("true"); + + await act(async () => + root.render( + + + , + ), + ); + + expect(button?.hasAttribute("data-selected")).toBe(false); + }); + + it("handles disabled state and prevents click handlers", async () => { + const onClick = vi.fn(); + await act(async () => + root.render( + + + , + ), + ); + + const button = host.querySelector("button"); + expect(button?.disabled).toBe(true); + + await act(async () => button?.click()); + expect(onClick).not.toHaveBeenCalled(); + }); + + it("handles loading state by disabling the button and showing LoadingOverlay", async () => { + const onClick = vi.fn(); + await act(async () => + root.render( + + + , + ), + ); + + const button = host.querySelector("button"); + expect(button?.disabled).toBe(true); + + await act(async () => button?.click()); + expect(onClick).not.toHaveBeenCalled(); + }); + + it("supports keyboard activation via Enter and Space", async () => { + const onClick = vi.fn(); + await act(async () => + root.render( + + + , + ), + ); + + const button = host.querySelector("button"); + button?.focus(); + expect(document.activeElement).toBe(button); + + // Native buttons fire click event on Enter / Space keydown/keyup + await act(async () => { + button?.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); + }); + expect(onClick).toHaveBeenCalledOnce(); + }); + + it("forwards ref to the underlying HTMLButtonElement", async () => { + const ref = createRef(); + await act(async () => + root.render( + + + , + ), + ); + + expect(ref.current).toBeInstanceOf(HTMLButtonElement); + expect(ref.current?.tagName).toBe("BUTTON"); + }); + + it("resolves radius CSS variables via varsResolver", async () => { + await act(async () => + root.render( + + + , + ), + ); + + const button = host.querySelector("button"); + expect(button?.style.getPropertyValue("--selectable-card-radius")).toBe("var(--mantine-radius-lg)"); + }); + + it("passes arbitrary HTML attributes down to the button element", async () => { + await act(async () => + root.render( + + + , + ), + ); + + const button = host.querySelector("button"); + expect(button?.getAttribute("data-testid")).toBe("my-selectable-card"); + expect(button?.getAttribute("data-custom")).toBe("custom-value"); + expect(button?.tabIndex).toBe(0); + }); +}); diff --git a/packages/ui/src/components/selectable-card/selectable-card.tsx b/packages/ui/src/components/selectable-card/selectable-card.tsx new file mode 100644 index 000000000..c5db014cf --- /dev/null +++ b/packages/ui/src/components/selectable-card/selectable-card.tsx @@ -0,0 +1,171 @@ +"use client"; + +import type { ReactNode } from "react"; +import { + Box, + createVarsResolver, + factory, + Group, + LoadingOverlay, + Stack, + Text, + Tooltip, + useProps, + useStyles, +} from "@mantine/core"; +import type { BoxProps, ElementProps, Factory, MantineRadius, StylesApiProps } from "@mantine/core"; + +import classes from "./selectable-card.module.css"; + +export type SelectableCardStylesNames = "root" | "header" | "body" | "footer"; +export type SelectableCardCssVariables = { + root: "--selectable-card-radius"; +}; + +export interface SelectableCardProps + extends BoxProps, StylesApiProps, Omit, "title"> { + icon?: ReactNode; + title?: ReactNode; + topRight?: ReactNode; + description?: string | null; + footerLeft?: ReactNode; + footerRight?: ReactNode; + selected?: boolean; + loading?: boolean; + disabled?: boolean; + radius?: MantineRadius; + children?: ReactNode; +} + +export type SelectableCardFactory = Factory<{ + props: SelectableCardProps; + ref: HTMLButtonElement; + stylesNames: SelectableCardStylesNames; + vars: SelectableCardCssVariables; +}>; + +const defaultProps: Partial = { + type: "button", +}; + +const varsResolver = createVarsResolver((_theme, { radius }) => ({ + root: { + "--selectable-card-radius": radius ? `var(--mantine-radius-${radius})` : undefined, + }, +})); + +export const SelectableCard = factory((_props, ref) => { + const props = useProps("SelectableCard", defaultProps, _props); + const { + classNames, + className, + style, + styles, + unstyled, + vars, + attributes, + icon, + title, + topRight, + description, + footerLeft, + footerRight, + selected, + loading = false, + disabled = false, + children, + type = "button", + ...others + } = props; + + const getStyles = useStyles({ + name: "SelectableCard", + classes, + props, + className, + style, + classNames, + styles, + unstyled, + vars, + attributes, + varsResolver, + }); + + return ( + + + + {/* Header Section: Inset background with Title that never elides before the category */} + {(icon || title || topRight) && ( + + + + {icon} + {typeof title === "string" ? ( + + {title} + + ) : ( + title + )} + + {topRight && ( + + {topRight} + + )} + + + )} + + {/* Body: Custom Children or Default Description */} + {children ? ( + {children} + ) : description !== undefined ? ( + + + + {description || "No description provided"} + + + + ) : null} + + {/* Footer Section: Inset Dashed Bottom Meta */} + {(footerLeft || footerRight) && ( + + + {footerLeft} + {footerRight} + + + )} + + ); +}); + +SelectableCard.displayName = "@homarr/ui/SelectableCard"; +SelectableCard.classes = classes; diff --git a/packages/ui/src/integration-select-grid/integration-select-grid.spec.tsx b/packages/ui/src/integration-select-grid/integration-select-grid.spec.tsx new file mode 100644 index 000000000..48bbaadf6 --- /dev/null +++ b/packages/ui/src/integration-select-grid/integration-select-grid.spec.tsx @@ -0,0 +1,265 @@ +// @vitest-environment jsdom + +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import type { Root } from "react-dom/client"; +import { MantineProvider } from "@mantine/core"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import type { IntegrationKind } from "@homarr/definitions"; + +import { IntegrationSelectGrid } from "./integration-select-grid"; + +vi.mock("@homarr/translation/client", () => ({ + useI18n: () => (key: string) => { + const translations: Record = { + "integration.page.list.search": "Search integrations", + "common.noResults": "No results found", + "integration.grid.noWidgets": "No widgets tied to this service", + "widget.mediaServer.name": "Media Server", + "widget.calendar.name": "Calendar", + "widget.downloads.name": "Downloads", + "integration.category.mediaService": "Media Service", + "integration.category.downloadClient": "Download Client", + "integration.category.calendar": "Calendar", + "integration.category.smartHomeServer": "Smart Home", + "integration.category.usenet": "Usenet", + "integration.category.torrent": "Torrent", + }; + return translations[key] ?? key; + }, +})); + +let root: Root; +let host: HTMLDivElement; + +beforeEach(() => { + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + window.matchMedia = vi.fn().mockImplementation(() => ({ + matches: false, + media: "", + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })); + vi.stubGlobal( + "ResizeObserver", + class { + observe() {} + unobserve() {} + disconnect() {} + }, + ); + host = document.createElement("div"); + document.body.append(host); + root = createRoot(host); +}); + +afterEach(async () => { + await act(async () => root.unmount()); + host.remove(); +}); + +describe("IntegrationSelectGrid", () => { + it("renders a 4-column responsive grid of selectable integration cards", async () => { + const onSelect = vi.fn(); + await act(async () => + root.render( + + + , + ), + ); + + const cards = host.querySelectorAll("button"); + expect(cards.length).toBe(4); + expect(host.textContent).toContain("Jellyfin"); + expect(host.textContent).toContain("Sonarr"); + expect(host.textContent).toContain("Radarr"); + expect(host.textContent).toContain("SABnzbd"); + }); + + it("renders tied widgets with icons and labels inside integration cards", async () => { + const onSelect = vi.fn(); + await act(async () => + root.render( + + + , + ), + ); + + expect(host.textContent).toContain("Tied Widgets"); + // Jellyfin is tied to Media Server widget + expect(host.textContent).toContain("Media Server"); + }); + + it("displays fallback message when integration has no tied widgets", async () => { + const onSelect = vi.fn(); + await act(async () => + root.render( + + + , + ), + ); + + const homeAssistantCard = host.querySelector("button"); + expect(homeAssistantCard).not.toBeNull(); + }); + + it("displays '{count} connected' in footerLeft when integration instances exist", async () => { + const onSelect = vi.fn(); + const integrationData = [ + { kind: "jellyfin" as IntegrationKind, name: "Home Jellyfin" }, + { kind: "jellyfin" as IntegrationKind, name: "Remote Jellyfin" }, + { kind: "sonarr" as IntegrationKind, name: "Sonarr Instance" }, + ]; + + await act(async () => + root.render( + + + , + ), + ); + + // Jellyfin has 2 connected + expect(host.textContent).toContain("2 connected"); + + // Radarr has 0 connected, so it doesn't show "0 connected" + expect(host.textContent).not.toContain("0 connected"); + }); + + it("filters integrations dynamically by search input", async () => { + const onSelect = vi.fn(); + await act(async () => + root.render( + + + , + ), + ); + + const searchInput = host.querySelector("input"); + expect(searchInput).not.toBeNull(); + + // Type "jelly" + await act(async () => { + if (searchInput) { + const nativeInputValueSetter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")?.set; + nativeInputValueSetter?.call(searchInput, "jelly"); + searchInput.dispatchEvent(new Event("input", { bubbles: true })); + searchInput.dispatchEvent(new Event("change", { bubbles: true })); + } + }); + + const cards = host.querySelectorAll("button"); + expect(cards.length).toBe(1); + expect(host.textContent).toContain("Jellyfin"); + expect(host.textContent).not.toContain("Sonarr"); + expect(host.textContent).not.toContain("Radarr"); + }); + + it("displays empty state when no integrations match search query", async () => { + const onSelect = vi.fn(); + await act(async () => + root.render( + + + , + ), + ); + + const searchInput = host.querySelector("input"); + await act(async () => { + if (searchInput) { + const nativeInputValueSetter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")?.set; + nativeInputValueSetter?.call(searchInput, "nonexistent-integration-query"); + searchInput.dispatchEvent(new Event("input", { bubbles: true })); + searchInput.dispatchEvent(new Event("change", { bubbles: true })); + } + }); + + expect(host.textContent).toContain("No results found"); + expect(host.querySelectorAll("button").length).toBe(0); + }); + + it("calls onSelect when Enter is pressed with exactly 1 search match", async () => { + const onSelect = vi.fn(); + await act(async () => + root.render( + + + , + ), + ); + + const searchInput = host.querySelector("input"); + await act(async () => { + if (searchInput) { + const nativeInputValueSetter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")?.set; + nativeInputValueSetter?.call(searchInput, "jelly"); + searchInput.dispatchEvent(new Event("input", { bubbles: true })); + searchInput.dispatchEvent(new Event("change", { bubbles: true })); + } + }); + + await act(async () => { + searchInput?.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); + }); + + expect(onSelect).toHaveBeenCalledWith("jellyfin"); + }); + + it("calls onSelect when an integration card is clicked", async () => { + const onSelect = vi.fn(); + await act(async () => + root.render( + + + , + ), + ); + + const cardButton = host.querySelector("button"); + await act(async () => cardButton?.click()); + expect(onSelect).toHaveBeenCalledWith("sonarr"); + }); + + it("includes mock integration when enableMockIntegration is true", async () => { + const onSelect = vi.fn(); + await act(async () => + root.render( + + + , + ), + ); + + expect(host.textContent).toContain("Mock"); + expect(host.textContent).toContain("Sonarr"); + }); + + it("excludes mock integration when enableMockIntegration is false", async () => { + const onSelect = vi.fn(); + await act(async () => + root.render( + + + , + ), + ); + + const buttons = Array.from(host.querySelectorAll("button")); + const mockButton = buttons.find((btn) => btn.getAttribute("aria-label")?.toLowerCase() === "mock"); + expect(mockButton).toBeUndefined(); + }); +}); diff --git a/packages/ui/src/integration-select-grid/integration-select-grid.tsx b/packages/ui/src/integration-select-grid/integration-select-grid.tsx index a0cfec6d8..a6fb1de33 100644 --- a/packages/ui/src/integration-select-grid/integration-select-grid.tsx +++ b/packages/ui/src/integration-select-grid/integration-select-grid.tsx @@ -1,27 +1,70 @@ "use client"; import { useMemo, useState } from "react"; -import { Badge, Card, Center, Group, Stack, Text, Tooltip, UnstyledButton } from "@mantine/core"; -import { IconPuzzle } from "@tabler/icons-react"; +import { Badge, Center, Group, Input, ScrollArea, SimpleGrid, Stack, Text } from "@mantine/core"; +import { + IconAd, + IconApps, + IconBinaryTree, + IconBookmark, + IconBrandDocker, + IconBrandMinecraft, + IconBrowser, + IconBuildingBank, + IconBusStop, + IconCalendar, + IconCalendarWeek, + IconChartBar, + IconClock, + IconClockPlay, + IconCloud, + IconDeviceCctv, + IconDeviceGamepad, + IconDownload, + IconGraphFilled, + IconHeartRateMonitor, + IconHourglass, + IconMessage, + IconMovie, + IconNotes, + IconPuzzle, + IconReportSearch, + IconRobot, + IconRocket, + IconRss, + IconSearch, + IconServer2, + IconTicket, + IconTopologyFull, + IconTransform, + IconVideo, + IconWall, + IconWind, + IconZoomQuestion, +} from "@tabler/icons-react"; -import type { IntegrationKind } from "@homarr/definitions"; +import type { IntegrationKind, WidgetKind } from "@homarr/definitions"; import { useI18n } from "@homarr/translation/client"; import { IntegrationAvatar } from "../components/integration-avatar"; -import { SelectGridLayout, selectGridCardHeight } from "../select-grid-layout"; +import { SelectableCard } from "../components/selectable-card"; import { buildSortedIntegrations, categoryTranslationKeys, filterIntegrations } from "./integration-grid-shared"; -import classes from "./integration-select-grid.module.css"; +import type { IntegrationGridItem } from "./integration-grid-shared"; export interface IntegrationSelectGridProps { onSelect: (kind: IntegrationKind) => void; enableMockIntegration?: boolean; allowedKinds?: readonly IntegrationKind[]; + integrationData?: { kind: IntegrationKind; name?: string }[]; } +const selectGridCols = { base: 1, xs: 2, sm: 2, md: 3, lg: 4 }; + export const IntegrationSelectGrid = ({ onSelect, enableMockIntegration = false, allowedKinds, + integrationData, }: IntegrationSelectGridProps) => { const [search, setSearch] = useState(""); const t = useI18n(); @@ -32,75 +75,189 @@ export const IntegrationSelectGrid = ({ const filtered = useMemo(() => filterIntegrations(integrations, search), [integrations, search]); return ( - - {filtered.map((integration) => ( - onSelect(integration.kind)} - aria-label={integration.name} - > - - - - - - {integration.name} - - - - - - - ))} - {filtered.length === 0 && ( -
- {t("common.noResults")} -
- )} -
- ); -}; - -const IntegrationGridMeta = ({ integration }: { integration: ReturnType[number] }) => { - const t = useI18n(); - return ( - - - {integration.categories.slice(0, 2).map((category) => ( - - {categoryTranslationKeys[category] ? t(categoryTranslationKeys[category] as never) : category} - - ))} - - {integration.widgets.length > 0 ? ( - - {integration.widgets.map((widgetKind) => ( - - {t(`widget.${widgetKind}.name`)} - - ))} - + + {/* Top Search Input */} + setSearch(event.currentTarget.value)} + leftSection={} + placeholder={`${t("integration.page.list.search")}...`} + aria-label={t("integration.page.list.search")} + data-autofocus + onKeyDown={(event) => { + if (event.key === "Enter" && filtered.length === 1 && filtered[0]) { + onSelect(filtered[0].kind); } - > - }> - {t("integration.grid.widgetCount", { count: String(integration.widgets.length) })} - - - ) : ( - - {t("integration.grid.noWidgets")} - - )} + }} + /> + + {/* Scrollable Container with Integration Cards */} + + + + {filtered.map((integration) => ( + i.kind === integration.kind).length} + /> + ))} + + + {filtered.length === 0 && ( +
+ {t("common.noResults")} +
+ )} +
+
); }; + +// ========================================================================= +// IntegrationCard: Shared Modular SelectableCard with Tied Widgets +// ========================================================================= +const IntegrationCard = ({ + integration, + onSelect, + connectedCount = 0, +}: { + integration: IntegrationGridItem; + onSelect: (kind: IntegrationKind) => void; + connectedCount?: number; +}) => { + return ( + onSelect(integration.kind)} + aria-label={integration.name} + icon={} + title={integration.name} + topRight={} + footerLeft={ + connectedCount > 0 ? ( + + {connectedCount} connected + + ) : ( + + ) + } + footerRight={ + connectedCount > 0 ? ( + + ) : ( + + ) + } + > + + Tied Widgets + + + + ); +}; + +const widgetIconsMap: Partial> = { + clock: IconClock, + weather: IconCloud, + airQuality: IconWind, + countdown: IconHourglass, + timer: IconClockPlay, + app: IconApps, + assistant: IconRobot, + iframe: IconBrowser, + video: IconDeviceCctv, + notebook: IconNotes, + anchorNote: IconNotes, + dnsHoleSummary: IconAd, + dnsHoleControls: IconDeviceGamepad, + "smartHome-entityState": IconBinaryTree, + "smartHome-executeAutomation": IconBinaryTree, + stockPrice: IconBuildingBank, + mediaServer: IconVideo, + calendar: IconCalendar, + downloads: IconDownload, + "mediaRequests-requestList": IconZoomQuestion, + "mediaRequests-requestStats": IconChartBar, + mediaTranscoding: IconTransform, + mediaMissing: IconMovie, + minecraftServerStatus: IconBrandMinecraft, + networkControllerSummary: IconTopologyFull, + networkControllerStatus: IconTopologyFull, + rssFeed: IconRss, + bookmarks: IconBookmark, + indexerManager: IconReportSearch, + healthMonitoring: IconHeartRateMonitor, + releases: IconRocket, + mediaReleases: IconTicket, + dockerContainers: IconBrandDocker, + firewall: IconWall, + notifications: IconMessage, + systemResources: IconGraphFilled, + coolify: IconCloud, + systemDisks: IconServer2, + timetable: IconBusStop, +}; + +const IntegrationTiedWidgets = ({ widgets, limit }: { widgets: WidgetKind[]; limit?: number }) => { + const t = useI18n(); + if (widgets.length === 0) { + return ( + + {t("integration.grid.noWidgets")} + + ); + } + + const items = limit ? widgets.slice(0, limit) : widgets; + const moreCount = limit ? widgets.length - limit : 0; + + return ( + + {items.map((widgetKind) => { + const WidgetIcon = widgetIconsMap[widgetKind] ?? IconPuzzle; + return ( + } + style={{ fontWeight: 500 }} + > + {t(`widget.${widgetKind}.name`)} + + ); + })} + {moreCount > 0 && ( + + +{moreCount} more + + )} + + ); +}; + +const IntegrationCategoryBadges = ({ + categories, + limit, +}: { + categories: IntegrationGridItem["categories"]; + limit?: number; +}) => { + const t = useI18n(); + const items = limit ? categories.slice(0, limit) : categories; + + return ( + + {items.map((category) => ( + + {categoryTranslationKeys[category] ? t(categoryTranslationKeys[category] as never) : category} + + ))} + + ); +}; diff --git a/packages/widgets/package.json b/packages/widgets/package.json index 034197e44..8580c9ed8 100644 --- a/packages/widgets/package.json +++ b/packages/widgets/package.json @@ -13,7 +13,9 @@ }, "exports": { ".": "./index.ts", - "./_inputs/json-path-tree-picker": "./src/_inputs/json-path-tree-picker.tsx", + "./inputs": "./src/_inputs/index.ts", + "./inputs/form": "./src/_inputs/form.ts", + "./inputs/json-path-tree-picker": "./src/_inputs/json-path-tree-picker.tsx", "./custom-api/component": "./src/custom-api/component.tsx", "./custom-api/custom-jsx-display": "./src/custom-api/custom-jsx-display.tsx", "./custom-api/extract-display-data": "./src/custom-api/extract-display-data.ts", diff --git a/packages/widgets/src/_inputs/widget-app-input.tsx b/packages/widgets/src/_inputs/widget-app-input.tsx index 933f7daf9..313d7243f 100644 --- a/packages/widgets/src/_inputs/widget-app-input.tsx +++ b/packages/widgets/src/_inputs/widget-app-input.tsx @@ -17,6 +17,12 @@ import type { CommonWidgetInputProps } from "./common"; import { useWidgetInputTranslation } from "./common"; import { useFormContext } from "./form"; +const renderHereLink = (children: React.ReactNode) => ( + + {children} + +); + 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={ - + {t.rich("widget.common.app.description", { - here: () => ( - - {t("common.here")} - - ), + here: renderHereLink, })} } diff --git a/packages/widgets/src/app/component.tsx b/packages/widgets/src/app/component.tsx index 8475de4e0..b676de965 100644 --- a/packages/widgets/src/app/component.tsx +++ b/packages/widgets/src/app/component.tsx @@ -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( diff --git a/packages/widgets/src/catalog.ts b/packages/widgets/src/catalog.ts index 6ca945d47..4452ea62e 100644 --- a/packages/widgets/src/catalog.ts +++ b/packages/widgets/src/catalog.ts @@ -60,7 +60,6 @@ import type { TablerIcon } from "@homarr/ui"; export const widgetCatalogIcons: Record = { clock: IconClock, weather: IconCloud, - today: IconCalendarWeek, airQuality: IconWind, countdown: IconHourglass, timer: IconClockPlay, diff --git a/packages/widgets/src/index.tsx b/packages/widgets/src/index.tsx index 060185a39..86ea2599d 100644 --- a/packages/widgets/src/index.tsx +++ b/packages/widgets/src/index.tsx @@ -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, diff --git a/packages/widgets/src/manifest.ts b/packages/widgets/src/manifest.ts index 9d1cb6e6d..09795db10 100644 --- a/packages/widgets/src/manifest.ts +++ b/packages/widgets/src/manifest.ts @@ -24,7 +24,6 @@ export type WidgetResources = { const moduleLoaders: Record Promise> = { clock: () => import("./clock") as Promise, weather: () => import("./weather") as Promise, - today: () => import("./today") as Promise, airQuality: () => import("./air-quality") as Promise, countdown: () => import("./countdown") as Promise, timer: () => import("./timer") as Promise, @@ -89,7 +88,6 @@ const moduleLoaders: Record Promise> = { const componentLoaders: Record Promise> = { clock: () => import("./clock/component") as Promise, weather: () => import("./weather/component") as Promise, - today: () => import("./today/component") as Promise, airQuality: () => import("./air-quality/component") as Promise, countdown: () => import("./countdown/component") as Promise, timer: () => import("./timer/component") as Promise, diff --git a/packages/widgets/src/today/component.tsx b/packages/widgets/src/today/component.tsx deleted file mode 100644 index 9b7493577..000000000 --- a/packages/widgets/src/today/component.tsx +++ /dev/null @@ -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 ( - - — - - ); - } - - const stats = getTodayStats(now, options.weekConvention, locale); - if (displayMode === "advanced") { - return ; - } - - 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 ( - - - {weekday} · {month} - - - {now.getDate()} - - - ); - } - - return ( - - - - - {weekday} - - - {layout.showFullDate ? fullDate : `${month} ${now.getDate()}`} - - - {options.showYearProgress && ( - - - {formatProgress(stats.yearProgress)}% - - } - /> - - - {t("yearProgress", { progress: formatProgress(stats.yearProgress) })} - - - - )} - - - {layout.showStats && ( - - {options.showWeekNumber && {t("weekNumber", { week: stats.weekNumber })}} - {options.showDayOfYear && ( - {t("dayOfYear", { day: stats.dayOfYear, total: stats.daysInYear })} - )} - {layout.showQuarter && options.showQuarter && ( - {t("quarter", { quarter: stats.quarter })} - )} - {layout.showQuarter && ( - - {t("daysAfterToday", { days: stats.daysAfterToday })} - - )} - - )} - - {layout.showMonthProgress && } - - ); -} - -interface AdvancedTodayProps { - now: Date; - locale: string; - options: WidgetComponentProps<"today">["options"]; - stats: ReturnType; -} - -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 ( - - - - - {formatLocalizedDate(now, locale, { dateStyle: "full" })} - - {options.showWeekNumber && ( - - {t(`weekConvention.${options.weekConvention}`)} - - )} - - - - {visibleMetrics.map((metric) => ( - - - {metric.label} - - - {metric.value} - - - ))} - - - - - - {options.showYearProgress && } - - - - {Array.from({ length: 12 }, (_, monthIndex) => { - const date = new Date(now.getFullYear(), monthIndex, 1); - const isCurrent = monthIndex === stats.monthIndex; - return ( - - - {formatLocalizedDate(date, locale, { month: "short" })} - - {isCurrent && ( - - {now.getDate()} - - )} - - ); - })} - - - - ); -}; - -const ProgressMetric = ({ label, value }: { label: string; value: number }) => { - const roundedValue = formatProgress(value); - return ( - - - - {label} - - - {roundedValue}% - - - - - ); -}; - -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}`; -}; diff --git a/packages/widgets/src/today/date-stats.spec.ts b/packages/widgets/src/today/date-stats.spec.ts deleted file mode 100644 index 414732dfa..000000000 --- a/packages/widgets/src/today/date-stats.spec.ts +++ /dev/null @@ -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, - }); - }); -}); diff --git a/packages/widgets/src/today/date-stats.ts b/packages/widgets/src/today/date-stats.ts deleted file mode 100644 index 790d41016..000000000 --- a/packages/widgets/src/today/date-stats.ts +++ /dev/null @@ -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, - }; -}; diff --git a/packages/widgets/src/today/index.ts b/packages/widgets/src/today/index.ts deleted file mode 100644 index e494df35b..000000000 --- a/packages/widgets/src/today/index.ts +++ /dev/null @@ -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"));