diff --git a/apps/docs/docs/management/boards/index.mdx b/apps/docs/docs/management/boards/index.mdx index c5731c5cd..8b00a36fc 100644 --- a/apps/docs/docs/management/boards/index.mdx +++ b/apps/docs/docs/management/boards/index.mdx @@ -160,7 +160,7 @@ Resize handles stop at the tile's minimum allowed size while the pointer is stil Exit edit mode to save the resulting positions. While edit mode is active, **Add board content** opens the same Universal Create surface as the global header action and -Spotlight. It can add a widget, app, integration, Container, board, import, Workshop item, or Custom Widget when the +Spotlight. It can add a widget, app, integration, Container, board, Workshop item, or Custom Widget when the current user and context allow it. Widget setup offers the main canvas, enabled rails, and existing Containers as destinations before the widget is created, so the common flow does not require a second move step. diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_header-actions.tsx b/apps/nextjs/src/app/[locale]/boards/(content)/_header-actions.tsx index cf50c0c69..990ffc58b 100644 --- a/apps/nextjs/src/app/[locale]/boards/(content)/_header-actions.tsx +++ b/apps/nextjs/src/app/[locale]/boards/(content)/_header-actions.tsx @@ -5,6 +5,7 @@ import dynamic from "next/dynamic"; import { useRouter, useSearchParams } from "next/navigation"; import { Box, Center, Loader, Menu, ScrollArea } from "@mantine/core"; import { useHotkeys } from "@mantine/hooks"; +import { notifications } from "@mantine/notifications"; import { IconDeviceFloppy, IconLayoutBoard, @@ -98,6 +99,9 @@ const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => { latestBoardRef.current = board; const { mutate: saveBoard, isPending } = clientApi.board.saveBoard.useMutation({ + onMutate(boardToSave) { + boardToSave.items.forEach(({ id }) => notifications.hide(`board-item-created:${id}`)); + }, onSuccess() { showSuccessNotification({ title: t("notification.success.title"), diff --git a/apps/nextjs/src/app/[locale]/manage/tools/docker/docker-reconciliation.tsx b/apps/nextjs/src/app/[locale]/manage/tools/docker/docker-reconciliation.tsx index eed63cf29..a4ca1199d 100644 --- a/apps/nextjs/src/app/[locale]/manage/tools/docker/docker-reconciliation.tsx +++ b/apps/nextjs/src/app/[locale]/manage/tools/docker/docker-reconciliation.tsx @@ -20,6 +20,7 @@ import { IconAlertTriangle, IconArrowRight, IconEyeOff, IconPlugConnected, IconR import type { RouterOutputs } from "@homarr/api"; import { clientApi } from "@homarr/api/client"; +import { getIntegrationName } from "@homarr/definitions"; import { useModalAction } from "@homarr/modals"; import { AddDockerAppToHomarr } from "@homarr/modals-collection"; import { useScopedI18n } from "@homarr/translation/client"; @@ -32,8 +33,18 @@ type ServiceHealth = RouterOutputs["docker"]["getServiceHealth"]["services"][num export const DockerReconciliation = () => { const t = useScopedI18n("docker.reconciliation"); + const utils = clientApi.useUtils(); const reconciliation = clientApi.docker.reconcileServices.useQuery(); const health = clientApi.docker.getServiceHealth.useQuery(); + const refreshInventory = clientApi.docker.refreshInventory.useMutation({ + async onSuccess() { + await Promise.all([ + utils.docker.getContainers.invalidate(), + utils.docker.reconcileServices.invalidate(), + utils.docker.getServiceHealth.invalidate(), + ]); + }, + }); const [filter, setFilter] = useState("attention"); const [dismissedCandidateKeys, setDismissedCandidateKeys] = useLocalStorage({ key: "homarr-docker-reconciliation-dismissed", @@ -50,7 +61,7 @@ export const DockerReconciliation = () => { ["newRecognized", "newApp", "moved"].includes(status) && !dismissedCandidateKeys.includes(key), ).length : 0; - const isRefreshing = reconciliation.isFetching || health.isFetching; + const isRefreshing = refreshInventory.isPending || reconciliation.isFetching || health.isFetching; return ( @@ -89,7 +100,7 @@ export const DockerReconciliation = () => { size="xs" loading={isRefreshing} leftSection={} - onClick={() => void Promise.all([reconciliation.refetch(), health.refetch()])} + onClick={() => refreshInventory.mutate()} > {t("action.refresh")} @@ -244,6 +255,13 @@ const DockerReconciliationCandidate = ({ }; const getCandidateTarget = (candidate: ReconciliationCandidate, url: string) => { + if (candidate.representation.signals.ambiguous) { + if (candidate.nextAction === "reviewIntegration" && candidate.match) { + const params = new URLSearchParams({ search: getIntegrationName(candidate.match.kind) }); + return { kind: "reviewIntegration" as const, href: `/manage/integrations?${params.toString()}` }; + } + return { kind: "viewRepresentation" as const, href: "/manage/apps" }; + } if (candidate.state === "newRecognized" && candidate.match) { const params = new URLSearchParams({ kind: candidate.match.kind, name: candidate.container.name }); if (url) params.set("url", url); diff --git a/apps/nextjs/src/app/[locale]/manage/tools/docker/docker-table.tsx b/apps/nextjs/src/app/[locale]/manage/tools/docker/docker-table.tsx index 0eca785fb..ce277fe07 100644 --- a/apps/nextjs/src/app/[locale]/manage/tools/docker/docker-table.tsx +++ b/apps/nextjs/src/app/[locale]/manage/tools/docker/docker-table.tsx @@ -1,6 +1,5 @@ "use client"; -import { useCallback } from "react"; import { useRouter } from "next/navigation"; import type { MantineColor } from "@mantine/core"; import { ActionIcon, Alert, Avatar, Badge, Box, Button, Group, Menu, Stack, Text, Tooltip } from "@mantine/core"; @@ -190,26 +189,31 @@ interface DockerTableProps { export function DockerTable({ initialData }: DockerTableProps) { const t = useI18n(); const tDocker = useScopedI18n("docker"); - const { data, isFetching, refetch } = clientApi.docker.getContainers.useQuery(undefined, { + const utils = clientApi.useUtils(); + const { data, isFetching } = clientApi.docker.getContainers.useQuery(undefined, { initialData, refetchOnMount: false, }); + const refreshInventory = clientApi.docker.refreshInventory.useMutation({ + async onSuccess() { + await Promise.all([ + utils.docker.getContainers.invalidate(), + utils.docker.reconcileServices.invalidate(), + utils.docker.getServiceHealth.invalidate(), + ]); + showSuccessNotification({ + title: tDocker("action.refresh.notification.success.title"), + message: tDocker("action.refresh.notification.success.message"), + }); + }, + onError() { + showErrorNotification({ + title: tDocker("action.refresh.notification.error.title"), + message: tDocker("action.refresh.notification.error.message"), + }); + }, + }); const relativeTime = useTimeAgo(data?.timestamp ?? new Date()); - const mutate = useCallback(() => { - void refetch().then((result) => { - if (result.isError) { - showErrorNotification({ - title: tDocker("action.refresh.notification.error.title"), - message: tDocker("action.refresh.notification.error.message"), - }); - } else { - showSuccessNotification({ - title: tDocker("action.refresh.notification.success.title"), - message: tDocker("action.refresh.notification.success.message"), - }); - } - }); - }, [refetch, tDocker]); const containers = data?.containers ?? []; const unavailableEndpoints = data?.endpoints.filter(({ status }) => status === "unavailable") ?? []; @@ -217,7 +221,7 @@ export function DockerTable({ initialData }: DockerTableProps) { const table = useTranslatedMantineReactTable({ data: containers, getRowId: (row) => row.resourceId, - state: { isLoading: isFetching }, + state: { isLoading: isFetching || refreshInventory.isPending }, enableDensityToggle: false, enableColumnActions: false, enableColumnFilters: false, @@ -252,8 +256,8 @@ export function DockerTable({ initialData }: DockerTableProps) { diff --git a/apps/nextjs/src/app/api/mcp/_handler.ts b/apps/nextjs/src/app/api/mcp/_handler.ts index c94317299..d1fe6f5ac 100644 --- a/apps/nextjs/src/app/api/mcp/_handler.ts +++ b/apps/nextjs/src/app/api/mcp/_handler.ts @@ -246,7 +246,7 @@ If \`hasUseAccess\` is false, the API key owner lacks permission for that integr **"Check Beszel systems"** → Call \`integration_all\` to get Beszel integrationIds, then use \`beszel_getSystems\` to list all monitored systems with CPU/memory/disk/network status. Use \`beszel_getAlerts\` for active alerts and history. Use \`beszel_getSystemStats\` for historical metrics of a specific system (requires systemId from \`beszel_getSystems\`). -**"Manage Docker containers"** → Use \`docker_getContainers\` to list containers with status/CPU/memory, then \`docker_startAll\`/\`docker_stopAll\`/\`docker_restartAll\` with container IDs. +**"Manage Docker containers"** → Use \`docker_getContainers\` to list containers with status/CPU/memory, then \`docker_startAll\`/\`docker_stopAll\`/\`docker_restartAll\` with \`targets\` containing the endpointId and id returned for each container. **"Control smart home"** → Use \`smartHome_entityState\` to check a Home Assistant entity, \`smartHome_switchEntity\` to toggle it, or \`smartHome_executeAutomation\` to trigger an automation. diff --git a/apps/nextjs/src/components/board/items/actions/create-item.ts b/apps/nextjs/src/components/board/items/actions/create-item.ts index c12ff03e3..233b2d4fa 100644 --- a/apps/nextjs/src/components/board/items/actions/create-item.ts +++ b/apps/nextjs/src/components/board/items/actions/create-item.ts @@ -36,11 +36,16 @@ export const createItemCallback = section.id === targetSectionId && (section.kind === "empty" || section.kind === "container"), ) : undefined; - const targetSection = requestedSection ?? firstCanvasSection; + let targetSection = requestedSection ?? firstCanvasSection; if (!targetSection) return previous; - const itemLayouts = createItemLayouts(previous, targetSection, kind); + let itemLayouts = createItemLayouts(previous, targetSection, kind); + if (itemLayouts.length === 0 && firstCanvasSection && targetSection.id !== firstCanvasSection.id) { + targetSection = firstCanvasSection; + itemLayouts = createItemLayouts(previous, targetSection, kind); + } + if (itemLayouts.length === 0) return previous; const widget = { id: id ?? createId(), kind, @@ -73,15 +78,16 @@ const createItemLayouts = ( const layouts = getBoardLayouts(board); const itemSize = getWidgetItemSize(kind); - return layouts.map((layoutId) => { + return layouts.flatMap((layoutId) => { const boardLayout = board.layouts.find((layout) => layout.id === layoutId); const elements = getSectionElements(board, { sectionId: currentSection.id, layoutId }); const containerLayout = currentSection.kind === "container" ? currentSection.layouts.find((layout) => layout.layoutId === layoutId) : undefined; - const columnCount = - containerLayout?.width ?? (boardLayout ? getBoardLaneColumnCount(boardLayout, "main") : itemSize.width); + const lane = currentSection.kind === "empty" ? getRootSectionLane(currentSection.xOffset) : "main"; + const columnCount = containerLayout?.width ?? (boardLayout ? getBoardLaneColumnCount(boardLayout, lane) : 0); + if (columnCount === 0) return []; const size = { width: Math.min(itemSize.width, columnCount), height: itemSize.height, @@ -95,12 +101,14 @@ const createItemLayouts = ( throw new Error("Your board is full"); } - return { - width: size.width, - height: size.height, - ...emptyPosition, - sectionId: currentSection.id, - layoutId, - }; + return [ + { + width: size.width, + height: size.height, + ...emptyPosition, + sectionId: currentSection.id, + layoutId, + }, + ]; }); }; diff --git a/apps/nextjs/src/components/board/items/actions/test/create-item.spec.ts b/apps/nextjs/src/components/board/items/actions/test/create-item.spec.ts index 86cf7dd6f..a98b96884 100644 --- a/apps/nextjs/src/components/board/items/actions/test/create-item.spec.ts +++ b/apps/nextjs/src/components/board/items/actions/test/create-item.spec.ts @@ -169,6 +169,36 @@ describe("item actions create-item", () => { ]); }); + test("sizes a new item for its requested rail and omits layouts where that rail is hidden", () => { + const board = new BoardMockBuilder() + .addLayout({ id: "mobile", name: "Mobile", role: "mobile", columnCount: 3, breakpoint: 0 }) + .addEmptySection({ id: "canvas" }) + .addEmptySection({ id: "left-rail", xOffset: -1 }) + .build(); + const baseLayout = board.layouts.find((layout) => layout.role === "base"); + if (!baseLayout) throw new Error("Expected a base layout"); + baseLayout.leftGutterColumnCount = 2; + + const result = createItemCallback({ id: "missing", kind: "mediaMissing", targetSectionId: "left-rail" })(board); + + expect(result.items.find((item) => item.id === "missing")?.layouts).toEqual([ + expect.objectContaining({ layoutId: baseLayout.id, sectionId: "left-rail", width: 2 }), + ]); + }); + + test("falls back to the main canvas instead of creating a ghost item for a disabled rail", () => { + const board = new BoardMockBuilder() + .addEmptySection({ id: "canvas" }) + .addEmptySection({ id: "left-rail", xOffset: -1 }) + .build(); + + const result = createItemCallback({ id: "clock", kind: "clock", targetSectionId: "left-rail" })(board); + + expect(result.items.find((item) => item.id === "clock")?.layouts).toEqual([ + expect.objectContaining({ sectionId: "canvas" }), + ]); + }); + test("falls back to the main canvas when a requested destination no longer exists", () => { const board = new BoardMockBuilder().addEmptySection({ id: "canvas" }).build(); const layout = board.layouts.at(0); 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 09f068476..a2d501d50 100644 --- a/apps/nextjs/src/components/board/items/item-select-modal.tsx +++ b/apps/nextjs/src/components/board/items/item-select-modal.tsx @@ -1,14 +1,16 @@ import { useMemo, useRef, useState } from "react"; import { Avatar, Badge, Box, Button, Center, Divider, Group, Image, Select, Stack, Text, Tooltip } from "@mantine/core"; +import { notifications } from "@mantine/notifications"; import { IconApi } from "@tabler/icons-react"; import type { RouterOutputs } from "@homarr/api"; import { clientApi } from "@homarr/api/client"; import { useSession } from "@homarr/auth/client"; -import { useRequiredBoard } from "@homarr/boards/context"; +import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context"; import { createId } from "@homarr/common"; import { getIconUrl, + getBoardLaneColumnCount, getIntegrationName, getRootSectionLane, widgetIntegrationSupport, @@ -61,6 +63,8 @@ const ItemSelectModalContent = ({ const [search, setSearch] = useState(initialSearch); const t = useI18n(); const board = useRequiredBoard(); + const currentLayoutId = useCurrentLayout(); + const currentLayout = board.layouts.find((layout) => layout.id === currentLayoutId); const mainCanvasSection = board.sections .filter( (section): section is EmptySection => section.kind === "empty" && getRootSectionLane(section.xOffset) === "main", @@ -73,7 +77,7 @@ const ItemSelectModalContent = ({ ...board.sections.flatMap((section) => { if (section.kind !== "empty") return []; const lane = getRootSectionLane(section.xOffset); - if (lane === "main") return []; + if (lane === "main" || !currentLayout || getBoardLaneColumnCount(currentLayout, lane) === 0) return []; return [ { value: section.id, @@ -88,7 +92,7 @@ const ItemSelectModalContent = ({ label: section.options.title.trim() || t("section.container.untitled"), })), ], - [board.sections, mainCanvasSection, t], + [board.sections, currentLayout, mainCanvasSection, t], ); const [targetSectionId, setTargetSectionId] = useState(mainCanvasSection?.id ?? null); const [loadingSelection, setLoadingSelection] = useState(null); @@ -149,12 +153,21 @@ const ItemSelectModalContent = ({ }; const notifyCreated = (itemId: string, name: string) => { + const notificationId = `board-item-created:${itemId}`; showSuccessNotification({ + id: notificationId, title: t("item.create.notification.success.title"), message: ( {t("item.create.notification.success.message", { name })} - diff --git a/apps/nextjs/src/components/create/universal-create-actions.spec.ts b/apps/nextjs/src/components/create/universal-create-actions.spec.ts index 4afcf3d32..60e323a4b 100644 --- a/apps/nextjs/src/components/create/universal-create-actions.spec.ts +++ b/apps/nextjs/src/components/create/universal-create-actions.spec.ts @@ -10,7 +10,7 @@ describe("getUniversalCreateActionDefinitions", () => { permissions: ["board-create", "integration-create"], }); - expect(actions.map(({ key }) => key)).toEqual(["integration", "board", "importBoard"]); + expect(actions.map(({ key }) => key)).toEqual(["integration", "board"]); }); test("places globally-created apps in the library group", () => { @@ -37,7 +37,6 @@ describe("getUniversalCreateActionDefinitions", () => { "integration", "container", "board", - "importBoard", "workshop", "customWidget", ]); diff --git a/apps/nextjs/src/components/create/universal-create-actions.ts b/apps/nextjs/src/components/create/universal-create-actions.ts index 433046a70..7f9d37643 100644 --- a/apps/nextjs/src/components/create/universal-create-actions.ts +++ b/apps/nextjs/src/components/create/universal-create-actions.ts @@ -6,7 +6,6 @@ export const universalCreateActionKeys = [ "integration", "container", "board", - "importBoard", "workshop", "customWidget", ] as const; @@ -60,12 +59,6 @@ const definitions: readonly UniversalCreateActionDefinition[] = [ priority: 60, isVisible: (context) => hasPermission(context, "board-create"), }, - { - key: "importBoard", - group: "boards", - priority: 50, - isVisible: (context) => hasPermission(context, "board-create"), - }, { key: "workshop", group: "library", diff --git a/apps/nextjs/src/components/create/universal-create-modal.tsx b/apps/nextjs/src/components/create/universal-create-modal.tsx index 59e664773..72d24ff1a 100644 --- a/apps/nextjs/src/components/create/universal-create-modal.tsx +++ b/apps/nextjs/src/components/create/universal-create-modal.tsx @@ -7,7 +7,6 @@ import { IconApps, IconBox, IconCode, - IconFileImport, IconLayoutDashboard, IconLayoutGridAdd, IconPlugConnected, @@ -17,7 +16,7 @@ import { import { useSession } from "@homarr/auth/client"; import type { GroupPermissionKey } from "@homarr/definitions"; import { createModal, useModalAction } from "@homarr/modals"; -import { AddBoardModal, ImportBoardModal } from "@homarr/modals-collection"; +import { AddBoardModal } from "@homarr/modals-collection"; import { useScopedI18n } from "@homarr/translation/client"; import type { TablerIcon } from "@homarr/ui"; @@ -44,7 +43,6 @@ const actionIcons = { integration: IconPlugConnected, container: IconLayoutGridAdd, board: IconLayoutDashboard, - importBoard: IconFileImport, workshop: IconSearch, customWidget: IconCode, } satisfies Record; @@ -54,7 +52,6 @@ export const UniversalCreateModal = createModal(({ ac const router = useRouter(); const t = useScopedI18n("universalCreate"); const { openModal: openAddBoardModal } = useModalAction(AddBoardModal); - const { openModal: openImportBoardModal } = useModalAction(ImportBoardModal); const [query, setQuery] = useState(""); const trackedOpen = useRef(false); const boardActions = innerProps.boardActions; @@ -100,9 +97,6 @@ export const UniversalCreateModal = createModal(({ ac case "board": openAddBoardModal(undefined); break; - case "importBoard": - openImportBoardModal(undefined); - break; case "workshop": router.push("/manage/workshop"); break; diff --git a/docs/release-v2-experience-implementation.md b/docs/release-v2-experience-implementation.md index 3e98fe244..1a2c65935 100644 --- a/docs/release-v2-experience-implementation.md +++ b/docs/release-v2-experience-implementation.md @@ -107,7 +107,7 @@ Universal Create ranks permission-aware actions for the current context: - Add Widget, App, or Container to the current Board. - Connect a service. -- Create or import a Board. +- Create a Board. - Open Workshop. - Create a Custom Widget. diff --git a/packages/api/package.json b/packages/api/package.json index ea47a10a7..c667aada4 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -20,7 +20,6 @@ "./onboarding-claim": "./src/onboarding-claim.ts", "./assistant": "./src/assistant.ts", "./onboarding-step": "./src/onboarding-step.ts", - "./custom-widget/display-data": "./src/router/custom-widget/display-data.ts", "./mcp": "./src/mcp.ts", "./custom-widget-configuration": "./src/custom-widget-configuration.ts" }, diff --git a/packages/api/src/router/docker/docker-reconciliation.ts b/packages/api/src/router/docker/docker-reconciliation.ts index cfe91f7fa..6e915dc33 100644 --- a/packages/api/src/router/docker/docker-reconciliation.ts +++ b/packages/api/src/router/docker/docker-reconciliation.ts @@ -53,7 +53,10 @@ export const getDockerReconciliationAsync = async (db: Database) => { ? sameKindIntegrations.filter(({ name }) => normalizeName(name) === normalizeName(container.name)) : []; const exactIntegration = exactIntegrationMatches.length === 1 ? exactIntegrationMatches[0] : undefined; - const nameIntegration = nameIntegrationMatches.length === 1 ? nameIntegrationMatches[0] : undefined; + const nameIntegration = + exactIntegrationMatches.length === 0 && nameIntegrationMatches.length === 1 + ? nameIntegrationMatches[0] + : undefined; const integration = exactIntegration ?? nameIntegration; const exactAppMatches = apps.filter(({ href }) => { const normalizedUrl = normalizeDockerServiceUrl(href); @@ -63,11 +66,16 @@ export const getDockerReconciliationAsync = async (db: Database) => { ? apps.filter(({ name }) => normalizeName(name) === normalizeName(container.name)) : []; const exactApp = exactAppMatches.length === 1 ? exactAppMatches[0] : undefined; - const nameApp = nameAppMatches.length === 1 ? nameAppMatches[0] : undefined; + const nameApp = exactAppMatches.length === 0 && nameAppMatches.length === 1 ? nameAppMatches[0] : undefined; const linkedApp = integration?.appId ? apps.find(({ id }) => id === integration.appId) : undefined; const app = linkedApp ?? exactApp ?? nameApp; const exactUrl = Boolean(exactIntegration || exactApp); const linked = Boolean(integration?.appId && linkedApp); + const integrationAmbiguous = + exactIntegrationMatches.length > 1 || (exactIntegrationMatches.length === 0 && nameIntegrationMatches.length > 1); + const appAmbiguous = + !linkedApp && (exactAppMatches.length > 1 || (exactAppMatches.length === 0 && nameAppMatches.length > 1)); + const ambiguous = integrationAmbiguous || appAmbiguous; const state: DockerReconciliationState = linked ? "linked" : integration && !exactUrl @@ -87,7 +95,11 @@ export const getDockerReconciliationAsync = async (db: Database) => { match, urlCandidates, state, - nextAction: nextActionForState(state), + nextAction: integrationAmbiguous + ? ("reviewIntegration" as const) + : appAmbiguous + ? ("viewRepresentation" as const) + : nextActionForState(state), representation: { integration: integration ? { @@ -105,11 +117,7 @@ export const getDockerReconciliationAsync = async (db: Database) => { exactUrl, nameMatch: Boolean(nameIntegration || nameApp), linked, - ambiguous: - exactIntegrationMatches.length > 1 || - nameIntegrationMatches.length > 1 || - exactAppMatches.length > 1 || - nameAppMatches.length > 1, + ambiguous, }, }, }; @@ -148,7 +156,7 @@ export const getDockerServiceHealthAsync = async (db: Database) => { Boolean(candidate.representation.integration), ), nextAction: - candidate.state === "moved" + candidate.nextAction === "reviewIntegration" ? ("reviewIntegration" as const) : candidate.state === "newRecognized" ? ("setupIntegration" as const) @@ -159,9 +167,11 @@ export const getDockerServiceHealthAsync = async (db: Database) => { status: "notObserved" as const, nextAction: candidate.representation.integration ? ("testConnection" as const) - : candidate.match - ? ("setupIntegration" as const) - : ("none" as const), + : candidate.nextAction === "reviewIntegration" + ? ("reviewIntegration" as const) + : candidate.match + ? ("setupIntegration" as const) + : ("none" as const), }, { layer: "apiRequest" as const, @@ -173,7 +183,11 @@ export const getDockerServiceHealthAsync = async (db: Database) => { { layer: "appRepresentation" as const, status: candidate.representation.app ? ("linked" as const) : ("missing" as const), - nextAction: candidate.representation.app ? ("none" as const) : ("createApp" as const), + nextAction: candidate.representation.app + ? ("none" as const) + : candidate.representation.signals.ambiguous && candidate.nextAction === "viewRepresentation" + ? ("viewRepresentation" as const) + : ("createApp" as const), }, { layer: "widgetConfiguration" as const, diff --git a/packages/api/src/router/docker/docker-router.ts b/packages/api/src/router/docker/docker-router.ts index 44365be30..8bd27f7ce 100644 --- a/packages/api/src/router/docker/docker-router.ts +++ b/packages/api/src/router/docker/docker-router.ts @@ -29,6 +29,12 @@ export const dockerRouter = createTRPCRouter({ .requiresPermission("admin") .concat(dockerMiddleware()) .query(async ({ ctx }) => await getDockerServiceHealthAsync(ctx.db)), + refreshInventory: permissionRequiredProcedure + .requiresPermission("admin") + .concat(dockerMiddleware()) + .mutation(() => { + dockerContainersRequestHandler.invalidateCache(); + }), getContainers: permissionRequiredProcedure .requiresPermission("admin") .meta({ diff --git a/packages/api/src/router/kubernetes/kubernetes-client.ts b/packages/api/src/router/kubernetes/kubernetes-client.ts index 404552e24..9d60d0b6d 100644 --- a/packages/api/src/router/kubernetes/kubernetes-client.ts +++ b/packages/api/src/router/kubernetes/kubernetes-client.ts @@ -31,6 +31,10 @@ export class KubernetesClient { return KubernetesClient.getRegistry().getClient(contextId); } + public static getDefaultInstance(): KubernetesClient { + return KubernetesClient.getRegistry().getDefaultClient(); + } + public static async getContextsAsync() { return await KubernetesClient.getRegistry().getContextsAsync(); } @@ -68,6 +72,10 @@ export class KubernetesClientRegistry { return client; } + public getDefaultClient() { + return this.getClient(this.defaultContextId); + } + public async getContextsAsync() { const contexts = await Promise.all( [...this.clients.entries()].map(async ([contextId, client]): Promise => { diff --git a/packages/api/src/router/onboard/capability-probes.ts b/packages/api/src/router/onboard/capability-probes.ts index e0d6fdcc2..f3de67edc 100644 --- a/packages/api/src/router/onboard/capability-probes.ts +++ b/packages/api/src/router/onboard/capability-probes.ts @@ -41,7 +41,7 @@ export const probeRuntimeCapabilitiesAsync = async ({ workshopApiUrl, fetchImpl = fetch, getKubernetesVersionAsync = async (signal) => - await KubernetesClient.getInstance().versionApi.getCode({ + await KubernetesClient.getDefaultInstance().versionApi.getCode({ middleware: [ { pre: async (request: RequestContext) => { diff --git a/packages/api/src/router/test/docker/docker-router.spec.ts b/packages/api/src/router/test/docker/docker-router.spec.ts index 09452e8a6..500eab8ee 100644 --- a/packages/api/src/router/test/docker/docker-router.spec.ts +++ b/packages/api/src/router/test/docker/docker-router.spec.ts @@ -13,6 +13,7 @@ import { dockerRouter } from "../../docker/docker-router"; const findDockerContainerAsyncMock = vi.hoisted(() => vi.fn()); const hasDockerEndpointCapabilityMock = vi.hoisted(() => vi.fn(() => true)); +const invalidateDockerCacheMock = vi.hoisted(() => vi.fn()); const getDockerDataAsyncMock = vi.hoisted(() => vi.fn< () => Promise<{ @@ -75,6 +76,7 @@ vi.mock("@homarr/request-handler/docker", () => ({ return () => undefined; }, dockerContainersRequestHandler: { + invalidateCache: invalidateDockerCacheMock, handler: () => ({ getDataAsync: getDockerDataAsyncMock, }), @@ -103,6 +105,7 @@ const validInputs: { } = { reconcileServices: undefined, getServiceHealth: undefined, + refreshInventory: undefined, getContainers: undefined, startAll: { targets: [{ endpointId: "one", id: "1" }] }, stopAll: { targets: [{ endpointId: "one", id: "1" }] }, @@ -317,6 +320,136 @@ test("does not adopt an ambiguous existing integration", async () => { expect(result.candidates[0]?.representation.integration).toBeNull(); expect(result.candidates[0]?.representation.signals.ambiguous).toBe(true); expect(result.candidates[0]?.state).toBe("newRecognized"); + expect(result.candidates[0]?.nextAction).toBe("reviewIntegration"); +}); + +test("prefers a unique exact integration match over duplicate name matches", async () => { + getDockerDataAsyncMock.mockResolvedValueOnce({ + data: { + containers: [createInventoryContainer({ endpointId: "home", id: "one", name: "sonarr", publicPort: 8989 })], + endpoints: [{ id: "home", name: "Home", status: "available" }], + }, + timestamp: new Date(), + }); + findIntegrationsAsyncMock.mockResolvedValueOnce([ + { + id: "exact", + name: "Primary", + kind: "sonarr", + url: "http://home.example:8989", + appId: null, + items: [], + secrets: [], + }, + { + id: "name-one", + name: "sonarr", + kind: "sonarr", + url: "http://other.example:8989", + appId: null, + items: [], + secrets: [], + }, + { + id: "name-two", + name: "sonarr", + kind: "sonarr", + url: "http://third.example:8989", + appId: null, + items: [], + secrets: [], + }, + ]); + + const result = await createAdminCaller().reconcileServices(); + + expect(result.candidates[0]?.representation.integration?.id).toBe("exact"); + expect(result.candidates[0]?.representation.signals.ambiguous).toBe(false); +}); + +test("does not fall back to a unique name when exact integration matches are ambiguous", async () => { + getDockerDataAsyncMock.mockResolvedValueOnce({ + data: { + containers: [createInventoryContainer({ endpointId: "home", id: "one", name: "sonarr", publicPort: 8989 })], + endpoints: [{ id: "home", name: "Home", status: "available" }], + }, + timestamp: new Date(), + }); + findIntegrationsAsyncMock.mockResolvedValueOnce([ + { id: "named", name: "sonarr", kind: "sonarr", url: "http://home.example:8989", appId: null }, + { id: "other", name: "Other", kind: "sonarr", url: "http://home.example:8989", appId: null }, + ]); + + const result = await createAdminCaller().reconcileServices(); + + expect(result.candidates[0]?.representation.integration).toBeNull(); + expect(result.candidates[0]?.representation.signals.ambiguous).toBe(true); + expect(result.candidates[0]?.nextAction).toBe("reviewIntegration"); +}); + +test("prefers an explicitly linked app over duplicate app matches", async () => { + getDockerDataAsyncMock.mockResolvedValueOnce({ + data: { + containers: [createInventoryContainer({ endpointId: "home", id: "one", name: "sonarr", publicPort: 8989 })], + endpoints: [{ id: "home", name: "Home", status: "available" }], + }, + timestamp: new Date(), + }); + findIntegrationsAsyncMock.mockResolvedValueOnce([ + { + id: "integration", + name: "Sonarr", + kind: "sonarr", + url: "http://home.example:8989", + appId: "linked", + items: [], + secrets: [], + }, + ]); + findAppsAsyncMock.mockResolvedValueOnce([ + { id: "linked", name: "Sonarr", href: "http://home.example:8989", iconUrl: "" }, + { id: "duplicate", name: "Sonarr duplicate", href: "http://home.example:8989", iconUrl: "" }, + ]); + + const result = await createAdminCaller().reconcileServices(); + + expect(result.candidates[0]?.representation.app?.id).toBe("linked"); + expect(result.candidates[0]?.representation.signals.ambiguous).toBe(false); + expect(result.candidates[0]?.state).toBe("linked"); +}); + +test("does not offer to create an app when existing app matches are ambiguous", async () => { + const dockerData = { + data: { + containers: [createInventoryContainer({ endpointId: "home", id: "one", name: "sonarr", publicPort: 8989 })], + endpoints: [{ id: "home", name: "Home", status: "available" }], + }, + timestamp: new Date(), + } satisfies Awaited>; + getDockerDataAsyncMock.mockResolvedValueOnce(dockerData).mockResolvedValueOnce(dockerData); + const apps = [ + { id: "first", name: "Sonarr one", href: "http://home.example:8989", iconUrl: "" }, + { id: "second", name: "Sonarr two", href: "http://home.example:8989", iconUrl: "" }, + ] satisfies Awaited>; + findAppsAsyncMock.mockResolvedValueOnce(apps).mockResolvedValueOnce(apps); + + const reconciliation = await createAdminCaller().reconcileServices(); + const health = await createAdminCaller().getServiceHealth(); + + expect(reconciliation.candidates[0]?.representation.app).toBeNull(); + expect(reconciliation.candidates[0]?.representation.signals.ambiguous).toBe(true); + expect(reconciliation.candidates[0]?.nextAction).toBe("viewRepresentation"); + expect(health.services[0]?.layers).toContainEqual({ + layer: "appRepresentation", + status: "missing", + nextAction: "viewRepresentation", + }); +}); + +test("invalidates the shared Docker inventory on explicit refresh", async () => { + await createAdminCaller().refreshInventory(); + + expect(invalidateDockerCacheMock).toHaveBeenCalledOnce(); }); test("projects persisted service layers without inventing runtime health", async () => { diff --git a/packages/definitions/src/docs/integration-doc-slugs.ts b/packages/definitions/src/docs/integration-doc-slugs.ts index e03c20034..3a306b14e 100644 --- a/packages/definitions/src/docs/integration-doc-slugs.ts +++ b/packages/definitions/src/docs/integration-doc-slugs.ts @@ -53,7 +53,7 @@ export const integrationDocSlugs: Record = { peaNut: "peanut", beszel: "beszel", traefik: "traefik", - archiveTeamWarrior: null, + archiveTeamWarrior: "archiveteam-warrior", wud: "whats-up-docker", mock: null, }; diff --git a/packages/modals/src/confirm-modal.spec.tsx b/packages/modals/src/confirm-modal.spec.tsx new file mode 100644 index 000000000..f333cda07 --- /dev/null +++ b/packages/modals/src/confirm-modal.spec.tsx @@ -0,0 +1,71 @@ +// @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, test, vi } from "vitest"; + +import { ConfirmModal } from "./confirm-modal"; + +const ConfirmModalComponent = ConfirmModal.component; + +vi.mock("@homarr/translation/client", () => ({ + useI18n: () => (key: string) => 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(), + })); + host = document.createElement("div"); + document.body.append(host); + root = createRoot(host); +}); + +afterEach(async () => { + await act(() => root.unmount()); + host.remove(); +}); + +describe("ConfirmModal", () => { + test("clears loading and stays open when confirmation rejects", async () => { + const closeModal = vi.fn(); + let rejectConfirmation!: (error: Error) => void; + const pendingConfirmation = new Promise((_resolve, reject) => { + rejectConfirmation = reject; + }); + const onConfirm = vi.fn(() => pendingConfirmation); + + await act(async () => + root.render( + + + , + ), + ); + + const confirmButton = host.querySelectorAll("button").item(1); + await act(async () => confirmButton.click()); + expect(confirmButton.disabled).toBe(true); + + await act(async () => { + rejectConfirmation(new Error("Removal failed")); + await Promise.resolve(); + }); + + expect(confirmButton.disabled).toBe(false); + expect(closeModal).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/modals/src/confirm-modal.tsx b/packages/modals/src/confirm-modal.tsx index efea408e7..95502df49 100644 --- a/packages/modals/src/confirm-modal.tsx +++ b/packages/modals/src/confirm-modal.tsx @@ -36,8 +36,8 @@ export const ConfirmModal = createModal>(({ act const closeOnConfirm = innerProps.closeOnConfirm ?? true; const closeOnCancel = innerProps.closeOnCancel ?? true; - const cancelLabel = labels?.cancel ?? ((t: TranslationFunction) => t("common.action.cancel")); - const confirmLabel = labels?.confirm ?? ((t: TranslationFunction) => t("common.action.confirm")); + const cancelLabel = labels?.cancel ?? ((translate: TranslationFunction) => translate("common.action.cancel")); + const confirmLabel = labels?.confirm ?? ((translate: TranslationFunction) => translate("common.action.confirm")); const handleCancel = useCallback( async (event: React.MouseEvent) => { @@ -59,19 +59,23 @@ export const ConfirmModal = createModal>(({ act const handleConfirm = useCallback( async (event: React.MouseEvent) => { setLoading(true); + try { + if (typeof confirmProps?.onClick === "function") { + confirmProps.onClick(event); + } - if (typeof confirmProps?.onClick === "function") { - confirmProps.onClick(event); - } + if (typeof onConfirm === "function") { + await onConfirm(); + } - if (typeof onConfirm === "function") { - await onConfirm(); + if (closeOnConfirm) { + actions.closeModal(); + } + } catch { + // Keep the modal open. Callers own action-specific error feedback. + } finally { + setLoading(false); } - - if (closeOnConfirm) { - actions.closeModal(); - } - setLoading(false); }, [confirmProps, onConfirm, closeOnConfirm, actions], ); diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index f86414e6e..7af4576db 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -281,11 +281,6 @@ "description": "Start a new dashboard and add its first widget.", "keywords": "dashboard page" }, - "importBoard": { - "label": "Import board", - "description": "Bring an existing Homarr board configuration into this instance.", - "keywords": "upload oldmarr migrate" - }, "workshop": { "label": "Browse Workshop", "description": "Discover community-built widgets and install them.", @@ -2072,8 +2067,8 @@ "tryAgain": "Try again", "loading": "Loading", "show": "Show", - "hide": "Hide" - ,"undo": "Undo" + "hide": "Hide", + "undo": "Undo" }, "here": "here", "iconPicker": { diff --git a/packages/ui/src/integration-select-grid/integration-grid-shared.spec.ts b/packages/ui/src/integration-select-grid/integration-grid-shared.spec.ts index bbea5e8b5..829ecafbc 100644 --- a/packages/ui/src/integration-select-grid/integration-grid-shared.spec.ts +++ b/packages/ui/src/integration-select-grid/integration-grid-shared.spec.ts @@ -11,4 +11,13 @@ describe("buildSortedIntegrations", () => { expect(kinds).toHaveLength(integrationKinds.length - 1); expect(kinds).not.toContain("mock"); }); + + it("limits the grid to the allowed integration kinds", () => { + const kinds = buildSortedIntegrations({ allowedKinds: ["jellyfin", "sonarr"] }).map( + (integration) => integration.kind, + ); + + expect(kinds).toEqual(expect.arrayContaining(["jellyfin", "sonarr"])); + expect(kinds).toHaveLength(2); + }); }); diff --git a/packages/ui/src/integration-select-grid/integration-grid-shared.ts b/packages/ui/src/integration-select-grid/integration-grid-shared.ts index ae6f96c5c..ffbc4483d 100644 --- a/packages/ui/src/integration-select-grid/integration-grid-shared.ts +++ b/packages/ui/src/integration-select-grid/integration-grid-shared.ts @@ -47,10 +47,15 @@ export interface IntegrationGridItem { } export const buildSortedIntegrations = ( - options: { enableMockIntegration?: boolean; onboarding?: boolean } = {}, + options: { + enableMockIntegration?: boolean; + onboarding?: boolean; + allowedKinds?: readonly IntegrationKind[]; + } = {}, ): IntegrationGridItem[] => integrationKinds .filter((kind) => { + if (options.allowedKinds && !options.allowedKinds.includes(kind)) return false; if (options.onboarding && hiddenFromOnboarding.has(kind)) return false; if (!options.enableMockIntegration && kind === "mock") return false; return true; 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 06e4743db..a0cfec6d8 100644 --- a/packages/ui/src/integration-select-grid/integration-select-grid.tsx +++ b/packages/ui/src/integration-select-grid/integration-select-grid.tsx @@ -15,12 +15,20 @@ import classes from "./integration-select-grid.module.css"; export interface IntegrationSelectGridProps { onSelect: (kind: IntegrationKind) => void; enableMockIntegration?: boolean; + allowedKinds?: readonly IntegrationKind[]; } -export const IntegrationSelectGrid = ({ onSelect, enableMockIntegration = false }: IntegrationSelectGridProps) => { +export const IntegrationSelectGrid = ({ + onSelect, + enableMockIntegration = false, + allowedKinds, +}: IntegrationSelectGridProps) => { const [search, setSearch] = useState(""); const t = useI18n(); - const integrations = useMemo(() => buildSortedIntegrations({ enableMockIntegration }), [enableMockIntegration]); + const integrations = useMemo( + () => buildSortedIntegrations({ enableMockIntegration, allowedKinds }), + [allowedKinds, enableMockIntegration], + ); const filtered = useMemo(() => filterIntegrations(integrations, search), [integrations, search]); return ( diff --git a/packages/widgets/src/docker/component.tsx b/packages/widgets/src/docker/component.tsx index 9a697c72d..1352ba43c 100644 --- a/packages/widgets/src/docker/component.tsx +++ b/packages/widgets/src/docker/component.tsx @@ -247,9 +247,19 @@ export default function DockerWidget({ const hasChangeAccess = board ? constructBoardPermissions(board, session).hasChangeAccess : false; const isAdvanced = displayMode === "advanced"; + const utils = clientApi.useUtils(); const containersQuery = clientApi.docker.getContainers.useQuery(); const data = getUsableWidgetQueryData(containersQuery); - const { refetch, isFetching } = containersQuery; + const { isFetching } = containersQuery; + const refreshInventory = clientApi.docker.refreshInventory.useMutation({ + async onSuccess() { + await Promise.all([ + utils.docker.getContainers.invalidate(), + utils.docker.reconcileServices.invalidate(), + utils.docker.getServiceHealth.invalidate(), + ]); + }, + }); const containers = useMemo(() => data?.containers ?? [], [data?.containers]); const timestamp = useMemo(() => data?.timestamp ?? new Date(), [data?.timestamp]); const relativeTime = useTimeAgo(timestamp); @@ -437,8 +447,8 @@ export default function DockerWidget({ size="sm" variant="transparent" c="var(--mantine-color-text)" - loading={isFetching} - onClick={() => void refetch()} + loading={isFetching || refreshInventory.isPending} + onClick={() => refreshInventory.mutate()} aria-label={t("table.refresh.lastUpdated", { when: relativeTime })} > diff --git a/scripts/feature-platform/feature-platform.contract.mts b/scripts/feature-platform/feature-platform.contract.mts index d45d6774e..96703c519 100644 --- a/scripts/feature-platform/feature-platform.contract.mts +++ b/scripts/feature-platform/feature-platform.contract.mts @@ -3,7 +3,8 @@ import { test } from "node:test"; import { resolve } from "node:path"; import { checkFeatureContracts } from "./contracts.mts"; -import { planFeatureGeneration } from "./generate-feature.mts"; +import { formatFeatureChanges, planFeatureGeneration } from "./generate-feature.mts"; +// oxlint-disable-next-line import/no-unassigned-import -- Registers shared contract tests in this suite. import "./check-feature.contract.mts"; const root = resolve(import.meta.dirname, "../.."); @@ -12,26 +13,30 @@ test("the checked-in feature contracts are consistent", () => { assert.deepEqual(checkFeatureContracts(root), []); }); -test("paired generation plans every contract without writing", () => { - const changes = planFeatureGeneration(root, { - integration: { - kind: "exampleService", - name: "Example Service", - slug: "example-service", - description: "Example native integration.", - category: "miscellaneous", - secretKinds: ["apiKey"], - iconUrl: "https://example.com/icon.svg", - }, - widget: { - kind: "exampleStatus", - name: "Example Status", - slug: "example-status", - description: "Example native widget.", - icon: "IconBox", - supportedIntegrations: ["exampleService"], - }, - }); +test("paired generation plans every formatter-clean contract without writing", async () => { + const description = + "A deliberately long feature description that verifies generated source stays formatter-clean when contributor input exceeds the configured print width."; + const changes = await formatFeatureChanges( + planFeatureGeneration(root, { + integration: { + kind: "exampleService", + name: 'Example "Home" Service', + slug: "example-service", + description, + category: "miscellaneous", + secretKinds: ["apiKey"], + iconUrl: "https://example.com/icon.svg", + }, + widget: { + kind: "exampleStatus", + name: 'Example "Home" Status', + slug: "example-status", + description, + icon: "IconBox", + supportedIntegrations: ["exampleService"], + }, + }), + ); assert.equal(changes.length, 20); assert.equal(new Set(changes.map((change) => change.path)).size, changes.length); @@ -41,6 +46,10 @@ test("paired generation plans every contract without writing", () => { assert.ok(changes.some((change) => change.path === "apps/docs/docs/widgets/example-status/index.mdx")); const integrationTest = changes.find((change) => change.path.endsWith("example-service-integration.spec.ts")); assert.match(integrationTest?.content ?? "", /simulateResponseContractAsync/); + assert.match( + integrationTest?.content ?? "", + /expect\(\n results\.every\(\(result\) => result\.passed\),\n results,\n \)\.toBe\(true\)/, + ); const widgetDefinition = changes.find((change) => change.path.endsWith("example-status/index.ts")); assert.match(widgetDefinition?.content ?? "", /nativeFeatureCapabilities\.exampleStatus\.integrations/); const capabilityDescriptor = changes.find( @@ -52,6 +61,7 @@ test("paired generation plans every contract without writing", () => { const parsed = JSON.parse(translations.content) as { integration: object; widget: object }; assert.ok("exampleService" in parsed.integration); assert.ok("exampleStatus" in parsed.widget); + assert.deepEqual(await formatFeatureChanges(changes), changes); }); test("generation stops before overwriting an existing feature", () => { @@ -71,3 +81,22 @@ test("generation stops before overwriting an existing feature", () => { /already exists/, ); }); + +test("generation rejects slugs that cannot form TypeScript identifiers", () => { + for (const slug of ["2fa-status", "example-2fa"]) { + assert.throws( + () => + planFeatureGeneration(root, { + widget: { + kind: "twoFactorStatus", + name: "2FA Status", + slug, + description: "Must not generate an invalid component name.", + icon: "IconBox", + supportedIntegrations: [], + }, + }), + /must start with a lowercase letter and use kebab-case/, + ); + } +}); diff --git a/scripts/feature-platform/generate-feature.mts b/scripts/feature-platform/generate-feature.mts index d0a4b2f0e..8a8119c80 100644 --- a/scripts/feature-platform/generate-feature.mts +++ b/scripts/feature-platform/generate-feature.mts @@ -1,6 +1,7 @@ import { existsSync, readFileSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { mkdirSync } from "node:fs"; +import { format } from "oxfmt"; export interface IntegrationFeature { kind: string; @@ -112,8 +113,8 @@ const validate = (request: FeatureRequest) => { if (!feature) continue; if (!/^[a-z][A-Za-z0-9]*$/.test(feature.kind)) throw new Error(`Generator stopped: ${feature.kind} must be lower camel case.`); - if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(feature.slug)) - throw new Error(`Generator stopped: ${feature.slug} must be kebab-case.`); + if (!/^[a-z][a-z0-9]*(?:-[a-z][a-z0-9]*)*$/.test(feature.slug)) + throw new Error(`Generator stopped: ${feature.slug} must start with a lowercase letter and use kebab-case.`); } if (request.widget && !/^Icon[A-Z][A-Za-z0-9]+$/.test(request.widget.icon)) { throw new Error(`Generator stopped: ${request.widget.icon} must be a Tabler icon export such as IconBox.`); @@ -173,12 +174,12 @@ describe("${className}", () => { test("exposes its public integration data", () => { const integration = new ${className}({ id: "test", - name: "${feature.name}", + name: ${JSON.stringify(feature.name)}, url: "http://localhost", externalUrl: null, decryptedSecrets: [], }); - expect(integration.publicIntegration.name).toBe("${feature.name}"); + expect(integration.publicIntegration.name).toBe(${JSON.stringify(feature.name)}); }); test("parses documented response fixtures", async () => { @@ -187,7 +188,10 @@ describe("${className}", () => { { name: "invalid response", payload: null, rejects: true }, ]); - expect(results.every((result) => result.passed), results).toBe(true); + expect( + results.every((result) => result.passed), + results, + ).toBe(true); }); }); `; @@ -294,14 +298,14 @@ const planWidget = (root: string, feature: WidgetFeature, changes: PlannedChange changes, root, `${widgetPath}/component.tsx`, - `"use client";\n\nimport { Center, Text } from "@mantine/core";\n\nimport type { WidgetComponentProps } from "../definition";\n\nexport default function ${pascalCase(feature.slug)}Widget(_props: WidgetComponentProps<"${feature.kind}">) {\n return (\n
\n ${feature.name}\n
\n );\n}\n`, + `"use client";\n\nimport { Center, Text } from "@mantine/core";\n\nimport type { WidgetComponentProps } from "../definition";\n\nexport default function ${pascalCase(feature.slug)}Widget(_props: WidgetComponentProps<"${feature.kind}">) {\n return (\n
\n {${JSON.stringify(feature.name)}}\n
\n );\n}\n`, "create", ); addChange( changes, root, `${widgetPath}/definition.spec.ts`, - `import { describe, expect, test } from "vitest";\n\nimport { componentLoader, definition } from ".";\n\ndescribe("${feature.name} widget definition", () => {\n test("registers its kind and loader", () => {\n expect(definition.kind).toBe("${feature.kind}");\n expect(componentLoader).toBeTypeOf("function");\n });\n});\n`, + `import { describe, expect, test } from "vitest";\n\nimport { componentLoader, definition } from ".";\n\ndescribe(${JSON.stringify(`${feature.name} widget definition`)}, () => {\n test("registers its kind and loader", () => {\n expect(definition.kind).toBe("${feature.kind}");\n expect(componentLoader).toBeTypeOf("function");\n });\n});\n`, "create", ); @@ -444,8 +448,25 @@ export const planFeatureGeneration = (root: string, request: FeatureRequest): Pl return changes; }; -export const generateFeature = (root: string, request: FeatureRequest) => { - const changes = planFeatureGeneration(root, request); +const isFormatterManaged = (path: string) => + /\.(?:json|mdx|ts|tsx)$/.test(path) && path !== "packages/translation/src/lang/en.json"; + +export const formatFeatureChanges = async (changes: PlannedChange[]) => + await Promise.all( + changes.map(async (change) => { + if (!isFormatterManaged(change.path)) return change; + const result = await format(change.path, change.content, { endOfLine: "lf", printWidth: 120 }); + if (result.errors.length > 0) { + throw new Error( + `Generator stopped: oxfmt could not format ${change.path}: ${result.errors.map(({ message }) => message).join("; ")}`, + ); + } + return { ...change, content: result.code }; + }), + ); + +export const generateFeature = async (root: string, request: FeatureRequest) => { + const changes = await formatFeatureChanges(planFeatureGeneration(root, request)); for (const change of changes) { mkdirSync(dirname(join(root, change.path)), { recursive: true }); writeFileSync(join(root, change.path), change.content); diff --git a/turbo/generators/config.ts b/turbo/generators/config.ts index 6baed23b6..1634f68f2 100644 --- a/turbo/generators/config.ts +++ b/turbo/generators/config.ts @@ -1,6 +1,7 @@ import type { PlopTypes } from "@turbo/gen"; -import { generateFeature, type FeatureRequest } from "../../scripts/feature-platform/generate-feature.mts"; +import type { FeatureRequest } from "../../scripts/feature-platform/generate-feature.mts"; +import { generateFeature } from "../../scripts/feature-platform/generate-feature.mts"; type Answers = Record & { turbo: { paths: { root: string } } }; @@ -13,7 +14,8 @@ const list = (value: unknown) => const lowerCamel = (value: string) => /^[a-z][A-Za-z0-9]*$/.test(value) || "Use lower camel case (for example, mediaServer)."; const kebab = (value: string) => - /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(value) || "Use kebab-case (for example, media-server)."; + /^[a-z][a-z0-9]*(?:-[a-z][a-z0-9]*)*$/.test(value) || + "Start with a lowercase letter and use kebab-case (for example, media-server)."; const required = (value: string) => value.trim().length > 0 || "This value is required."; const integrationCategories = [ "dnsHole", @@ -142,7 +144,7 @@ const action = (request: (answers: Answers) => FeatureRequest): PlopTypes.CustomActionFunction => async (rawAnswers) => { const answers = rawAnswers as Answers; - const changes = generateFeature(answers.turbo.paths.root, request(answers)); + const changes = await generateFeature(answers.turbo.paths.root, request(answers)); return `Created or updated ${changes.length} feature files. Run pnpm check:feature-contracts next.`; };