fix release v2 review issues
This commit is contained in:
1 parent
ef2ed6c602
commit
ca856f9f54
29 files changed
+508
-131
No files matched your search
@@ -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.
|
||||
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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<DockerReconciliationInboxFilter>("attention");
|
||||
const [dismissedCandidateKeys, setDismissedCandidateKeys] = useLocalStorage<string[]>({
|
||||
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 (
|
||||
<Card withBorder>
|
||||
@@ -89,7 +100,7 @@ export const DockerReconciliation = () => {
|
||||
size="xs"
|
||||
loading={isRefreshing}
|
||||
leftSection={<IconRefresh size={14} />}
|
||||
onClick={() => void Promise.all([reconciliation.refetch(), health.refetch()])}
|
||||
onClick={() => refreshInventory.mutate()}
|
||||
>
|
||||
{t("action.refresh")}
|
||||
</Button>
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
<Button
|
||||
variant="default"
|
||||
rightSection={<IconRefresh size="1rem" />}
|
||||
onClick={() => mutate()}
|
||||
loading={isFetching}
|
||||
onClick={() => refreshInventory.mutate()}
|
||||
loading={isFetching || refreshInventory.isPending}
|
||||
>
|
||||
{tDocker("action.refresh.label")}
|
||||
</Button>
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
];
|
||||
});
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
@@ -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<string | null>(mainCanvasSection?.id ?? null);
|
||||
const [loadingSelection, setLoadingSelection] = useState<string | null>(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: (
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Text size="sm">{t("item.create.notification.success.message", { name })}</Text>
|
||||
<Button variant="subtle" size="compact-xs" onClick={() => removeItem({ itemId })}>
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="compact-xs"
|
||||
onClick={() => {
|
||||
removeItem({ itemId });
|
||||
notifications.hide(notificationId);
|
||||
}}
|
||||
>
|
||||
{t("common.action.undo")}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
@@ -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",
|
||||
]);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<UniversalCreateActionKey, TablerIcon>;
|
||||
@@ -54,7 +52,6 @@ export const UniversalCreateModal = createModal<UniversalCreateModalProps>(({ 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<UniversalCreateModalProps>(({ ac
|
||||
case "board":
|
||||
openAddBoardModal(undefined);
|
||||
break;
|
||||
case "importBoard":
|
||||
openImportBoardModal(undefined);
|
||||
break;
|
||||
case "workshop":
|
||||
router.push("/manage/workshop");
|
||||
break;
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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<KubernetesContextStatus> => {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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<ReturnType<typeof getDockerDataAsyncMock>>;
|
||||
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<ReturnType<typeof findAppsAsyncMock>>;
|
||||
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 () => {
|
||||
|
||||
@@ -53,7 +53,7 @@ export const integrationDocSlugs: Record<IntegrationKind, string | null> = {
|
||||
peaNut: "peanut",
|
||||
beszel: "beszel",
|
||||
traefik: "traefik",
|
||||
archiveTeamWarrior: null,
|
||||
archiveTeamWarrior: "archiveteam-warrior",
|
||||
wud: "whats-up-docker",
|
||||
mock: null,
|
||||
};
|
||||
@@ -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<void>((_resolve, reject) => {
|
||||
rejectConfirmation = reject;
|
||||
});
|
||||
const onConfirm = vi.fn(() => pendingConfirmation);
|
||||
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<ConfirmModalComponent actions={{ closeModal }} innerProps={{ children: "Remove containers?", onConfirm }} />
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -36,8 +36,8 @@ export const ConfirmModal = createModal<Omit<ConfirmModalProps, "title">>(({ 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<HTMLButtonElement>) => {
|
||||
@@ -59,19 +59,23 @@ export const ConfirmModal = createModal<Omit<ConfirmModalProps, "title">>(({ act
|
||||
const handleConfirm = useCallback(
|
||||
async (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
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],
|
||||
);
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 })}
|
||||
>
|
||||
<IconRefresh size={16} />
|
||||
|
||||
@@ -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/,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -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 <Center h="100%">\n <Text>${feature.name}</Text>\n </Center>\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 <Center h="100%">\n <Text>{${JSON.stringify(feature.name)}}</Text>\n </Center>\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);
|
||||
|
||||
@@ -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<string, unknown> & { 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.`;
|
||||
};
|
||||
|
||||
|
||||
Reference in new issue
Block a user