diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d4c89357d..0170f86bf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -98,6 +98,9 @@ jobs: - name: Validate feature contracts run: pnpm check:feature-contracts + - name: Test feature platform + run: pnpm test:feature-platform + - name: Validate Custom Widget bundle budget run: | pnpm --filter nextjs build diff --git a/apps/docs/docs/management/api/index.mdx b/apps/docs/docs/management/api/index.mdx index 35e9f49e4..7d1f996f9 100644 --- a/apps/docs/docs/management/api/index.mdx +++ b/apps/docs/docs/management/api/index.mdx @@ -86,6 +86,30 @@ Board layout responses identify each layout with a `mobile`, `base`, or `custom` Use the interactive API reference to inspect the exact request body for each endpoint under the `boards` and `settings` tags. +## Docker action payload migration + +Docker lifecycle and removal actions now identify each container with both its Docker endpoint and container ID. This is a +breaking change for tRPC and MCP clients that previously sent an `ids` array to the start, stop, restart, or remove action. + +Replace the old payload: + +```json +{ "ids": ["container-id"] } +``` + +with endpoint-qualified targets: + +```json +{ + "targets": [{ "endpointId": "local", "id": "container-id" }] +} +``` + +Read `endpointId` and `id` from `docker.getContainers` (MCP tool `docker_getContainers`) immediately before the action. +Pass those pairs unchanged to `docker.startAll`, `docker.stopAll`, `docker.restartAll`, or `docker.removeAll`; their MCP tool +names use underscores, such as `docker_restartAll`. Endpoint qualification prevents identical container IDs on different +Docker or Podman endpoints from targeting the wrong service. + ## Partial integration failures Multi-integration widget queries keep data from integrations that remain available. Calendar and media-server results include diff --git a/apps/nextjs/src/app/[locale]/manage/tools/docker/docker-reconciliation-inbox.spec.ts b/apps/nextjs/src/app/[locale]/manage/tools/docker/docker-reconciliation-inbox.spec.ts index 4a5c14a4e..d82948438 100644 --- a/apps/nextjs/src/app/[locale]/manage/tools/docker/docker-reconciliation-inbox.spec.ts +++ b/apps/nextjs/src/app/[locale]/manage/tools/docker/docker-reconciliation-inbox.spec.ts @@ -1,9 +1,14 @@ import { describe, expect, test } from "vitest"; -import { dismissDockerReconciliationCandidate, filterDockerReconciliationInbox } from "./docker-reconciliation-inbox"; +import { + dismissDockerReconciliationCandidate, + filterDockerReconciliationInbox, + getValidDockerServiceUrl, +} from "./docker-reconciliation-inbox"; const candidates = [ { candidateKey: "home:new", state: "newRecognized" as const }, + { candidateKey: "home:new-app", state: "newApp" as const }, { candidateKey: "remote:moved", state: "moved" as const }, { candidateKey: "home:linked", state: "linked" as const }, { candidateKey: "home:represented", state: "represented" as const }, @@ -13,7 +18,7 @@ describe("Docker reconciliation inbox", () => { test("separates attention items from represented services", () => { expect( filterDockerReconciliationInbox(candidates, "attention", []).map(({ candidateKey }) => candidateKey), - ).toEqual(["home:new", "remote:moved"]); + ).toEqual(["home:new", "home:new-app", "remote:moved"]); expect( filterDockerReconciliationInbox(candidates, "represented", []).map(({ candidateKey }) => candidateKey), ).toEqual(["home:linked", "home:represented"]); @@ -31,4 +36,10 @@ describe("Docker reconciliation inbox", () => { }); expect(dismissDockerReconciliationCandidate(dismissed, "home:new")).toBe(dismissed); }); + + test("accepts only HTTP service URLs", () => { + expect(getValidDockerServiceUrl("https://service.example.com")).toBe("https://service.example.com/"); + expect(getValidDockerServiceUrl("ftp://service.example.com")).toBeNull(); + expect(getValidDockerServiceUrl("service.example.com")).toBeNull(); + }); }); diff --git a/apps/nextjs/src/app/[locale]/manage/tools/docker/docker-reconciliation-inbox.ts b/apps/nextjs/src/app/[locale]/manage/tools/docker/docker-reconciliation-inbox.ts index f9d8f1bfc..c6101abdc 100644 --- a/apps/nextjs/src/app/[locale]/manage/tools/docker/docker-reconciliation-inbox.ts +++ b/apps/nextjs/src/app/[locale]/manage/tools/docker/docker-reconciliation-inbox.ts @@ -23,3 +23,12 @@ export const filterDockerReconciliationInbox = dismissedCandidateKeys.includes(candidateKey) ? dismissedCandidateKeys : [...dismissedCandidateKeys, candidateKey]; + +export const getValidDockerServiceUrl = (value: string) => { + try { + const url = new URL(value); + return url.protocol === "http:" || url.protocol === "https:" ? url.toString() : null; + } catch { + return null; + } +}; 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 a4ca1199d..55c85523a 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 @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { Alert, Avatar, @@ -23,10 +23,15 @@ import { clientApi } from "@homarr/api/client"; import { getIntegrationName } from "@homarr/definitions"; import { useModalAction } from "@homarr/modals"; import { AddDockerAppToHomarr } from "@homarr/modals-collection"; +import { showErrorNotification } from "@homarr/notifications"; import { useScopedI18n } from "@homarr/translation/client"; import { Link } from "@homarr/ui"; import type { DockerReconciliationInboxFilter } from "./docker-reconciliation-inbox"; -import { dismissDockerReconciliationCandidate, filterDockerReconciliationInbox } from "./docker-reconciliation-inbox"; +import { + dismissDockerReconciliationCandidate, + filterDockerReconciliationInbox, + getValidDockerServiceUrl, +} from "./docker-reconciliation-inbox"; type ReconciliationCandidate = RouterOutputs["docker"]["reconcileServices"]["candidates"][number]; type ServiceHealth = RouterOutputs["docker"]["getServiceHealth"]["services"][number]; @@ -44,6 +49,12 @@ export const DockerReconciliation = () => { utils.docker.getServiceHealth.invalidate(), ]); }, + onError() { + showErrorNotification({ + title: t("refreshError.title"), + message: t("refreshError.message"), + }); + }, }); const [filter, setFilter] = useState("attention"); const [dismissedCandidateKeys, setDismissedCandidateKeys] = useLocalStorage({ @@ -51,6 +62,19 @@ export const DockerReconciliation = () => { defaultValue: [], }); + if (reconciliation.isError) { + return ( + } title={t("loadError.title")}> + + {t("loadError.message")} + + + + ); + } + if (!reconciliation.data) return null; const unavailableEndpoints = reconciliation.data.endpoints.filter(({ status }) => status === "unavailable"); @@ -156,7 +180,16 @@ const DockerReconciliationCandidate = ({ const [selectedCandidateId, setSelectedCandidateId] = useState(initialUrlCandidate?.id ?? null); const [url, setUrl] = useState(initialUrlCandidate?.url ?? ""); const selectedCandidate = candidate.urlCandidates.find(({ id }) => id === selectedCandidateId); - const target = getCandidateTarget(candidate, url); + const validatedUrl = getValidDockerServiceUrl(url); + const isInvalidUrl = url.length > 0 && validatedUrl === null; + const target = getCandidateTarget(candidate, validatedUrl ?? ""); + const actionNeedsUrl = target.kind === "createApp" || target.kind === "setupIntegration"; + const isActionDisabled = actionNeedsUrl && validatedUrl === null; + + useEffect(() => { + setSelectedCandidateId(initialUrlCandidate?.id ?? null); + setUrl(initialUrlCandidate?.url ?? ""); + }, [initialUrlCandidate?.id, initialUrlCandidate?.url]); return ( @@ -224,8 +257,11 @@ const DockerReconciliationCandidate = ({ }} /> setUrl(event.currentTarget.value)} /> @@ -237,14 +273,27 @@ const DockerReconciliationCandidate = ({ ) : ( - )} diff --git a/apps/nextjs/src/app/[locale]/manage/tools/docker/logs/[containerId]/page.tsx b/apps/nextjs/src/app/[locale]/manage/tools/docker/logs/[containerId]/page.tsx index 7afa1d951..0e0ec2a9b 100644 --- a/apps/nextjs/src/app/[locale]/manage/tools/docker/logs/[containerId]/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/tools/docker/logs/[containerId]/page.tsx @@ -24,10 +24,11 @@ export default async function DockerContainerLogsPage({ params, searchParams }: const { containerId } = await params; const { endpointId, name } = await searchParams; - if (!endpointId) notFound(); + const resolvedEndpointId = endpointId ?? (await getOnlyDockerEndpointIdAsync()); + if (!resolvedEndpointId) notFound(); try { - await api.docker.logs({ endpointId, id: containerId, tail: 1 }); + await api.docker.logs({ endpointId: resolvedEndpointId, id: containerId, tail: 1 }); } catch { notFound(); } @@ -41,8 +42,17 @@ export default async function DockerContainerLogsPage({ params, searchParams }: /> - + ); } + +const getOnlyDockerEndpointIdAsync = async () => { + try { + const { endpoints } = await api.docker.getContainers(); + return endpoints.length === 1 ? endpoints[0]?.id : undefined; + } catch { + return undefined; + } +}; diff --git a/apps/nextjs/src/app/[locale]/manage/tools/feature-workbench/_feature-state-fixtures.ts b/apps/nextjs/src/app/[locale]/manage/tools/feature-workbench/_feature-state-fixtures.ts index 933ad7f89..f8f4c725a 100644 --- a/apps/nextjs/src/app/[locale]/manage/tools/feature-workbench/_feature-state-fixtures.ts +++ b/apps/nextjs/src/app/[locale]/manage/tools/feature-workbench/_feature-state-fixtures.ts @@ -4,69 +4,31 @@ export type ResponseFixtureId = "loading" | "success" | "failure"; export interface CatalogStateFixture { id: CatalogFixtureId; - label: string; - status: string; - description: string; color: "green" | "yellow" | "gray" | "red"; } export const catalogStateFixtures: readonly CatalogStateFixture[] = [ - { - id: "ready", - label: "Ready service", - status: "Ready", - description: "Configured and available to three widgets.", - color: "green", - }, - { - id: "needsSetup", - label: "Service needing setup", - status: "Needs setup", - description: "Credentials or a service URL are still required.", - color: "yellow", - }, - { - id: "noConnection", - label: "Standalone feature", - status: "No connection required", - description: "This feature works without a configured integration.", - color: "gray", - }, - { - id: "error", - label: "Unreachable service", - status: "Connection failed", - description: "The saved connection could not be reached.", - color: "red", - }, + { id: "ready", color: "green" }, + { id: "needsSetup", color: "yellow" }, + { id: "noConnection", color: "gray" }, + { id: "error", color: "red" }, ]; -export const catalogInteractionFixtures: readonly { id: CatalogInteractionId; label: string }[] = [ - { id: "default", label: "Default" }, - { id: "selected", label: "Selected" }, - { id: "loading", label: "Loading" }, - { id: "disabled", label: "Disabled" }, +export const catalogInteractionFixtures: readonly CatalogInteractionId[] = [ + "default", + "selected", + "loading", + "disabled", ]; export const responseStateFixtures = [ - { - id: "loading", - label: "Loading", - title: "Checking response", - description: "The parser is waiting for the integration response.", - }, + { id: "loading" }, { id: "success", - label: "Success", - title: "Response accepted", - description: "The upstream payload matched the integration contract.", payload: { status: "ok", service: "fixture-service", version: "2.0" }, }, { id: "failure", - label: "Failure", - title: "Response rejected", - description: "The upstream payload failed schema validation and stays isolated from the widget.", payload: { status: 503, message: "Service unavailable" }, }, ] as const; diff --git a/apps/nextjs/src/app/[locale]/manage/tools/feature-workbench/_feature-state-workbench.spec.tsx b/apps/nextjs/src/app/[locale]/manage/tools/feature-workbench/_feature-state-workbench.spec.tsx index bfea6467b..af6fea9eb 100644 --- a/apps/nextjs/src/app/[locale]/manage/tools/feature-workbench/_feature-state-workbench.spec.tsx +++ b/apps/nextjs/src/app/[locale]/manage/tools/feature-workbench/_feature-state-workbench.spec.tsx @@ -10,6 +10,21 @@ import { FeatureStateWorkbench } from "./_feature-state-workbench"; import { catalogStateFixtures, responseStateFixtures } from "./_feature-state-fixtures"; import { getResponseContractFixtureResultsAsync } from "./_response-contract-fixtures"; +vi.mock("@homarr/translation/client", () => ({ + useScopedI18n: () => (key: string) => + ({ + "catalog.state.needsSetup.label": "Service needing setup", + "catalog.state.needsSetup.status": "Needs setup", + "catalog.interaction.loading": "Loading", + "catalog.interaction.disabled": "Disabled", + "response.state.failure.label": "Failure", + "response.state.failure.title": "Response rejected", + "response.contractPassed": "Fixture contract passed", + "catalog.loadingAriaLabel": "Loading catalog item", + "catalog.loading": "Loading", + })[key] ?? key, +})); + let root: Root; let host: HTMLDivElement; diff --git a/apps/nextjs/src/app/[locale]/manage/tools/feature-workbench/_feature-state-workbench.tsx b/apps/nextjs/src/app/[locale]/manage/tools/feature-workbench/_feature-state-workbench.tsx index c81328e48..e68f707b3 100644 --- a/apps/nextjs/src/app/[locale]/manage/tools/feature-workbench/_feature-state-workbench.tsx +++ b/apps/nextjs/src/app/[locale]/manage/tools/feature-workbench/_feature-state-workbench.tsx @@ -17,6 +17,7 @@ import { } from "@mantine/core"; import { IconAlertCircle, IconCircleCheck, IconPlugConnected, IconPlugConnectedX } from "@tabler/icons-react"; +import { useScopedI18n } from "@homarr/translation/client"; import { CatalogItem } from "@homarr/ui"; import { catalogInteractionFixtures, catalogStateFixtures, responseStateFixtures } from "./_feature-state-fixtures"; @@ -28,6 +29,7 @@ interface FeatureStateWorkbenchProps { } export function FeatureStateWorkbench({ responseResults }: FeatureStateWorkbenchProps) { + const t = useScopedI18n("featureWorkbench"); const [catalogFixtureId, setCatalogFixtureId] = useState("ready"); const [interactionId, setInteractionId] = useState("default"); const [responseFixtureId, setResponseFixtureId] = useState("loading"); @@ -38,6 +40,8 @@ export function FeatureStateWorkbench({ responseResults }: FeatureStateWorkbench const busy = interactionId === "loading"; const disabled = interactionId === "disabled"; const selected = interactionId === "selected"; + const catalogLabel = t(`catalog.state.${catalogFixture.id}.label`); + const catalogStatus = busy ? t("catalog.loading") : t(`catalog.state.${catalogFixture.id}.status`); return ( @@ -45,34 +49,40 @@ export function FeatureStateWorkbench({ responseResults }: FeatureStateWorkbench
- Catalog item states + {t("catalog.title")} - Compare the shared selection surface with stable service and interaction fixtures. + {t("catalog.description")}
- Service state + {t("catalog.serviceState")} setCatalogFixtureId(value as CatalogFixtureId)} - data={catalogStateFixtures.map((fixture) => ({ value: fixture.id, label: fixture.status }))} + data={catalogStateFixtures.map((fixture) => ({ + value: fixture.id, + label: t(`catalog.state.${fixture.id}.status`), + }))} /> - Interaction state + {t("catalog.interactionState")} setInteractionId(value as CatalogInteractionId)} - data={catalogInteractionFixtures.map((fixture) => ({ value: fixture.id, label: fixture.label }))} + data={catalogInteractionFixtures.map((id) => ({ + value: id, + label: t(`catalog.interaction.${id}`), + }))} /> @@ -80,15 +90,15 @@ export function FeatureStateWorkbench({ responseResults }: FeatureStateWorkbench setInteractionId(selected ? "default" : "selected")} > {busy ? ( - + @@ -105,15 +115,15 @@ export function FeatureStateWorkbench({ responseResults }: FeatureStateWorkbench - {catalogFixture.label} + {catalogLabel} - {catalogFixture.description} + {t(`catalog.state.${catalogFixture.id}.description`)} - {catalogFixture.status} + {t(`catalog.state.${catalogFixture.id}.status`)} )} @@ -121,13 +131,13 @@ export function FeatureStateWorkbench({ responseResults }: FeatureStateWorkbench - Semantic output + {t("catalog.semanticOutput")} {`aria-label="${catalogFixture.label}, ${busy ? "Loading" : catalogFixture.status}"\naria-pressed=${selected}\ndisabled=${busy || disabled}\naria-busy=${busy}`} + >{`aria-label="${catalogLabel}, ${catalogStatus}"\naria-pressed=${selected}\ndisabled=${busy || disabled}\naria-busy=${busy}`} - Tab to the item to inspect its visible focus ring. Select it with Enter or Space. + {t("catalog.instructions")} @@ -138,23 +148,25 @@ export function FeatureStateWorkbench({ responseResults }: FeatureStateWorkbench
- Integration response contract + {t("response.title")} - Inspect waiting, accepted, and rejected response fixtures using the same parser contract as generated - integrations. + {t("response.description")}
setResponseFixtureId(value as ResponseFixtureId)} - data={responseStateFixtures.map((fixture) => ({ value: fixture.id, label: fixture.label }))} + data={responseStateFixtures.map((fixture) => ({ + value: fixture.id, + label: t(`response.state.${fixture.id}.label`), + }))} /> {responseFixture.id === "loading" ? ( - + @@ -165,16 +177,14 @@ export function FeatureStateWorkbench({ responseResults }: FeatureStateWorkbench : } - title={responseFixture.title} + title={t(`response.state.${responseFixture.id}.title`)} role={responseFixture.id === "failure" ? "alert" : "status"} > - {responseFixture.description} + {t(`response.state.${responseFixture.id}.description`)} {JSON.stringify(responseFixture.payload, null, 2)} - {responseResult?.passed - ? "Fixture contract passed" - : (responseResult?.message ?? "Fixture contract failed")} + {responseResult?.passed ? t("response.contractPassed") : t("response.contractFailed")} diff --git a/apps/nextjs/src/app/[locale]/manage/tools/feature-workbench/page.tsx b/apps/nextjs/src/app/[locale]/manage/tools/feature-workbench/page.tsx index 09c1963f0..9697f9ca2 100644 --- a/apps/nextjs/src/app/[locale]/manage/tools/feature-workbench/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/tools/feature-workbench/page.tsx @@ -2,6 +2,7 @@ import { notFound } from "next/navigation"; import { Stack, Text, Title } from "@mantine/core"; import { auth } from "@homarr/auth/next"; +import { getScopedI18n } from "@homarr/translation/server"; import { FeatureStateWorkbench } from "./_feature-state-workbench"; import { getResponseContractFixtureResultsAsync } from "./_response-contract-fixtures"; @@ -10,15 +11,15 @@ export default async function FeatureWorkbenchPage() { if (process.env.NODE_ENV === "production") notFound(); const session = await auth(); if (!session?.user.permissions.includes("admin")) notFound(); + const t = await getScopedI18n("featureWorkbench.page"); const responseResults = await getResponseContractFixtureResultsAsync(); return (
- Native feature state workbench + {t("title")} - A development-only fixture surface for reviewing shared catalog and integration response states without live - services. + {t("description")}
diff --git a/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/kubernetes-context-selector.tsx b/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/kubernetes-context-selector.tsx index ad1165725..16b49af77 100644 --- a/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/kubernetes-context-selector.tsx +++ b/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/kubernetes-context-selector.tsx @@ -2,7 +2,7 @@ import { useEffect } from "react"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; -import { Alert, Badge, Group, Select } from "@mantine/core"; +import { Alert, Badge, Button, Group, Select, Stack, Text } from "@mantine/core"; import { useLocalStorage } from "@mantine/hooks"; import { IconAlertTriangle } from "@tabler/icons-react"; @@ -14,7 +14,7 @@ export const KubernetesContextSelector = () => { const pathname = usePathname(); const router = useRouter(); const searchParams = useSearchParams(); - const { data } = clientApi.kubernetes.contexts.getContexts.useQuery(undefined, { + const { data, isError, refetch } = clientApi.kubernetes.contexts.getContexts.useQuery(undefined, { refetchInterval: 30_000, refetchOnMount: "always", refetchOnReconnect: true, @@ -40,6 +40,19 @@ export const KubernetesContextSelector = () => { router.replace(`${pathname}?${next.toString()}`); }, [contextId, pathname, requestedContextId, router, searchParams]); + if (isError) { + return ( + } title={t("loadError.title")}> + + {t("loadError.message")} + + + + ); + } + if (!data || !contextId) return null; const selectContext = (value: string | null) => { diff --git a/apps/nextjs/src/components/assistant/assistant-widget-tool.spec.ts b/apps/nextjs/src/components/assistant/assistant-widget-tool.spec.ts index 71d9153e0..704829201 100644 --- a/apps/nextjs/src/components/assistant/assistant-widget-tool.spec.ts +++ b/apps/nextjs/src/components/assistant/assistant-widget-tool.spec.ts @@ -46,7 +46,7 @@ describe("getAssistantWidgetConfiguration", () => { boardName: "Home", kind: "audioStats", summary: "Show audio statistics", - integrationIds: ["navidrome-1", "audiobookshelf-1", "plex-1"], + integrationIds: ["navidrome-private", "navidrome-1", "audiobookshelf-1", "plex-1"], }, settings, [ diff --git a/apps/nextjs/src/components/board/board-setup-checklist.tsx b/apps/nextjs/src/components/board/board-setup-checklist.tsx index be8895fb7..b702e5186 100644 --- a/apps/nextjs/src/components/board/board-setup-checklist.tsx +++ b/apps/nextjs/src/components/board/board-setup-checklist.tsx @@ -66,7 +66,10 @@ export const BoardSetupChecklist = () => {
- + layout.layoutId === layoutId) : undefined; const lane = currentSection.kind === "empty" ? getRootSectionLane(currentSection.xOffset) : "main"; + if (currentSection.kind === "container" && !containerLayout) return []; const columnCount = containerLayout?.width ?? (boardLayout ? getBoardLaneColumnCount(boardLayout, lane) : 0); if (columnCount === 0) return []; const size = { 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 a98b96884..3b052d35c 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,27 @@ describe("item actions create-item", () => { ]); }); + test("omits layouts where the requested container has no layout entry", () => { + const board = new BoardMockBuilder() + .addLayout({ id: "mobile", name: "Mobile", role: "mobile", columnCount: 3, breakpoint: 0 }) + .addEmptySection({ id: "canvas" }) + .addSection( + new ContainerSectionMockBuilder({ id: "media" }) + .addLayout({ layoutId: "base", parentSectionId: "canvas", width: 4, height: 3 }) + .build(), + ) + .build(); + const baseLayout = board.layouts.find((layout) => layout.role === "base"); + if (!baseLayout) throw new Error("Expected a base layout"); + baseLayout.id = "base"; + + const result = createItemCallback({ id: "calendar", kind: "clock", targetSectionId: "media" })(board); + + expect(result.items.find((item) => item.id === "calendar")?.layouts).toEqual([ + expect.objectContaining({ layoutId: "base", sectionId: "media" }), + ]); + }); + 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 }) diff --git a/apps/nextjs/src/components/board/items/board-recipe-recommendations.spec.ts b/apps/nextjs/src/components/board/items/board-recipe-recommendations.spec.ts index 7377606d5..7ff9a989b 100644 --- a/apps/nextjs/src/components/board/items/board-recipe-recommendations.spec.ts +++ b/apps/nextjs/src/components/board/items/board-recipe-recommendations.spec.ts @@ -15,11 +15,12 @@ describe("getBoardRecipeRecommendations", () => { preferredIntegrationKind: "jellyfin", }); - expect(recommendations).toHaveLength(3); - expect(recommendations.slice(0, 2).every(({ isNewlyAvailable }) => isNewlyAvailable)).toBe(true); - expect(recommendations.at(2)).toEqual( - expect.objectContaining({ integrationKind: "piHole", isNewlyAvailable: false }), - ); + const firstFallbackIndex = recommendations.findIndex(({ isNewlyAvailable }) => !isNewlyAvailable); + expect(recommendations.some(({ isNewlyAvailable }) => isNewlyAvailable)).toBe(true); + if (firstFallbackIndex !== -1) { + expect(recommendations.slice(0, firstFallbackIndex).every(({ isNewlyAvailable }) => isNewlyAvailable)).toBe(true); + expect(recommendations.slice(firstFallbackIndex).every(({ isNewlyAvailable }) => !isNewlyAvailable)).toBe(true); + } }); test("does not recommend widget kinds already present on the board", () => { @@ -29,7 +30,8 @@ describe("getBoardRecipeRecommendations", () => { preferredIntegrationKind: "piHole", }); - expect(recommendations.map(({ widgetKind }) => widgetKind)).toEqual(["dnsHoleControls"]); + expect(recommendations.every(({ widgetKind }) => widgetKind !== "dnsHoleSummary")).toBe(true); + expect(recommendations.every(({ integrationKind }) => integrationKind === "piHole")).toBe(true); }); test("deduplicates configured kinds and emitted widget capabilities", () => { @@ -39,7 +41,9 @@ describe("getBoardRecipeRecommendations", () => { limit: 10, }); - expect(recommendations.map(({ widgetKind }) => widgetKind)).toEqual(["dnsHoleControls", "dnsHoleSummary"]); + const widgetKinds = recommendations.map(({ widgetKind }) => widgetKind); + expect(new Set(widgetKinds).size).toBe(widgetKinds.length); + expect(recommendations.every(({ integrationKind }) => integrationKind === "piHole")).toBe(true); }); test("falls back to other configured integrations when the new service has no missing widgets", () => { 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 0cd8ba8dc..40b8ed6c2 100644 --- a/apps/nextjs/src/components/board/items/item-select-modal.tsx +++ b/apps/nextjs/src/components/board/items/item-select-modal.tsx @@ -1,4 +1,4 @@ -import { useMemo, useRef, useState } from "react"; +import { useEffect, 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"; @@ -94,10 +94,16 @@ const ItemSelectModalContent = ({ ], [board.sections, currentLayout, mainCanvasSection, t], ); - const [targetSectionId, setTargetSectionId] = useState(mainCanvasSection?.id ?? null); + const [targetSectionId, setTargetSectionId] = useState(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(); @@ -155,14 +161,32 @@ const ItemSelectModalContent = ({ }); }; - const notifyCreated = (itemId: string, name: string) => { + const notifyCreated = (updatedBoard: ReturnType, itemId: string, name: string) => { + const createdItem = updatedBoard?.items.find((item) => item.id === itemId); + if (!createdItem) { + showErrorNotification({ + title: t("item.create.notification.error.title"), + message: t("item.create.notification.error.message"), + }); + return false; + } + + const actualSectionId = + 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 notificationId = `board-item-created:${itemId}`; showSuccessNotification({ id: notificationId, title: t("item.create.notification.success.title"), message: ( - {t("item.create.notification.success.message", { name })} + + {wasRelocated + ? t("item.create.notification.success.relocatedMessage", { name, destination: actualDestination }) + : t("item.create.notification.success.message", { name })} +