feat(integrations): add grid picker page and dashboard integration action

Refactor integration creation page to show a grid picker before the
form. Add "Add integration" action to dashboard header menu. Convert
app select modal to a two-step stepper. Standardize modal widths to
1200 for item, integration, and app select modals.
This commit is contained in:
Thomas Camlong committed 2026-05-01 09:59:01 +02:00
1 parent 7985ff953a
commit 1c6782a102
5 files changed
+112 -47

No files matched your search

@@ -11,6 +11,7 @@ import {
IconLayoutBoard,
IconPencil,
IconPencilOff,
IconPlug,
IconPlus,
IconReplace,
IconResize,
@@ -36,6 +37,7 @@ import { useBoardPermissions } from "~/components/board/permissions/client";
import { useCategoryActions } from "~/components/board/sections/category/category-actions";
import { CategoryEditModal } from "~/components/board/sections/category/category-edit-modal";
import { useDynamicSectionActions } from "~/components/board/sections/dynamic/dynamic-actions";
import { IntegrationSelectModal } from "~/components/integration/integration-select-modal";
import { HeaderButton } from "~/components/layout/header/button";
export const BoardContentHeaderActions = () => {
@@ -67,6 +69,7 @@ const AddMenu = () => {
const { openModal: openCategoryEditModal } = useModalAction(CategoryEditModal);
const { openModal: openItemSelectModal } = useModalAction(ItemSelectModal);
const { openModal: openAppSelectModal } = useModalAction(AppSelectModal);
const { openModal: openIntegrationSelectModal } = useModalAction(IntegrationSelectModal);
const { addCategoryToEnd } = useCategoryActions();
const { addDynamicSection } = useDynamicSectionActions();
const { createItem } = useItemActions();
@@ -108,6 +111,10 @@ const AddMenu = () => {
});
}, [openAppSelectModal, createItem]);
const handleAddIntegration = useCallback(() => {
openIntegrationSelectModal({});
}, [openIntegrationSelectModal]);
return (
<Menu position="bottom-end" withArrow>
<Menu.Target>
@@ -127,6 +134,10 @@ const AddMenu = () => {
{t("app.action.add")}
</Menu.Item>
<Menu.Item leftSection={<IconPlug size={20} />} onClick={handleAddIntegration}>
{t("integration.action.create")}
</Menu.Item>
<Menu.Divider />
<Menu.Item leftSection={<IconBoxAlignTop size={20} />} onClick={handleAddCategory}>
@@ -45,9 +45,11 @@ import { IntegrationTestConnectionError } from "../_components/test-connection/i
import type { AnyMappedTestConnectionError } from "../_components/test-connection/types";
interface NewIntegrationFormProps {
searchParams: Partial<z.infer<typeof integrationCreateSchema>> & {
kind: IntegrationKind;
};
kind: IntegrationKind;
initialUrl?: string;
initialName?: string;
onSuccess?: () => void;
onCancel?: () => void;
}
const formSchema = integrationCreateSchema.omit({ kind: true, app: true }).and(
@@ -58,29 +60,28 @@ const formSchema = integrationCreateSchema.omit({ kind: true, app: true }).and(
}),
);
export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) => {
export const NewIntegrationForm = ({ kind, initialUrl, initialName, onSuccess, onCancel }: NewIntegrationFormProps) => {
const t = useI18n();
const secretKinds = getAllSecretKindOptions(searchParams.kind);
const secretKinds = getAllSecretKindOptions(kind);
const hasUrlSecret = secretKinds.some((kinds) => kinds.includes("url"));
const router = useRouter();
const { data: session } = useSession();
const canCreateApps = session?.user.permissions.includes("app-create") ?? false;
let url = searchParams.url ?? getIntegrationDefaultUrl(searchParams.kind) ?? "";
let url = initialUrl ?? getIntegrationDefaultUrl(kind) ?? "";
if (hasUrlSecret) {
// Placeholder Url, replaced with origin of the secret Url on submit
url = "http://localhost";
}
const form = useZodForm(formSchema, {
initialValues: {
name: searchParams.name ?? getIntegrationName(searchParams.kind),
name: initialName ?? getIntegrationName(kind),
url,
secrets: secretKinds[0].map((kind) => ({
kind,
value: "",
})),
attemptSearchEngineCreation: true,
hasApp: false,
hasApp: true,
appHref: url,
appId: null,
},
@@ -106,7 +107,7 @@ export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) =>
: {
name: values.name,
href: hasCustomHref ? appHref : url,
iconUrl: getIconUrl(searchParams.kind),
iconUrl: getIconUrl(kind),
description: null,
pingUrl: url,
}
@@ -114,7 +115,7 @@ export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) =>
await createIntegrationAsync(
{
kind: searchParams.kind,
kind,
...values,
url,
app,
@@ -136,7 +137,11 @@ export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) =>
message: t("integration.page.create.notification.success.message"),
});
router.push("/manage/integrations");
if (onSuccess) {
onSuccess();
} else {
router.push("/manage/integrations");
}
},
onError: () => {
showErrorNotification({
@@ -148,7 +153,7 @@ export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) =>
);
};
const supportsSearchEngine = integrationDefs[searchParams.kind].category.flat().includes("search");
const supportsSearchEngine = integrationDefs[kind].category.flat().includes("search");
return (
<form onSubmit={form.onSubmit((value) => void handleSubmitAsync(value))}>
@@ -184,7 +189,7 @@ export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) =>
<Checkbox
label={t("integration.field.attemptSearchEngineCreation.label")}
description={t("integration.field.attemptSearchEngineCreation.description", {
kind: getIntegrationName(searchParams.kind),
kind: getIntegrationName(kind),
})}
{...form.getInputProps("attemptSearchEngineCreation", { type: "checkbox" })}
/>
@@ -193,9 +198,15 @@ export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) =>
<AppForm form={form} canCreateApps={canCreateApps} />
<Group justify="end" align="center">
<Button variant="default" component={Link} href="/manage/integrations">
{t("common.action.backToOverview")}
</Button>
{onCancel ? (
<Button variant="default" onClick={onCancel}>
{t("common.action.backToOverview")}
</Button>
) : (
<Button variant="default" component={Link} href="/manage/integrations">
{t("common.action.backToOverview")}
</Button>
)}
<Button type="submit" loading={isPending}>
{t("integration.testConnection.action.create")}
</Button>
@@ -3,21 +3,21 @@ import { Container, Group, Stack, Title } from "@mantine/core";
import { z } from "zod/v4";
import { auth } from "@homarr/auth/next";
import type { IntegrationKind } from "@homarr/definitions";
import { getIntegrationName, integrationKinds } from "@homarr/definitions";
import { getScopedI18n } from "@homarr/translation/server";
import { IntegrationAvatar } from "@homarr/ui";
import type { integrationCreateSchema } from "@homarr/validation/integration";
import { IntegrationPickerPage } from "~/components/integration/integration-picker-page";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { env } from "~/env";
import { NewIntegrationForm } from "./_integration-new-form";
interface NewIntegrationPageProps {
searchParams: Promise<
Partial<z.infer<typeof integrationCreateSchema>> & {
kind: IntegrationKind;
}
>;
searchParams: Promise<{
kind?: string;
url?: string;
name?: string;
}>;
}
export default async function IntegrationsNewPage(props: NewIntegrationPageProps) {
@@ -28,8 +28,20 @@ export default async function IntegrationsNewPage(props: NewIntegrationPageProps
}
const result = z.enum(integrationKinds).safeParse(searchParams.kind);
if (!result.success) {
notFound();
const t = await getScopedI18n("integration");
return (
<>
<DynamicBreadcrumb />
<Container size="xl">
<Stack>
<Title>{t("action.create")}</Title>
<IntegrationPickerPage enableMockIntegration={env.UNSAFE_ENABLE_MOCK_INTEGRATION} />
</Stack>
</Container>
</>
);
}
const tCreate = await getScopedI18n("integration.page.create");
@@ -45,7 +57,7 @@ export default async function IntegrationsNewPage(props: NewIntegrationPageProps
<IntegrationAvatar kind={currentKind} size="md" />
<Title>{tCreate("title", { name: getIntegrationName(currentKind) })}</Title>
</Group>
<NewIntegrationForm searchParams={searchParams} />
<NewIntegrationForm kind={currentKind} initialUrl={searchParams.url} initialName={searchParams.name} />
</Stack>
</Container>
</>
@@ -70,7 +70,7 @@ export const ItemSelectModal = createModal<void>(({ actions }) => {
);
}).withOptions({
defaultTitle: (t) => t("item.create.title"),
size: "xl",
size: 1200,
});
const WidgetItem = ({
@@ -1,13 +1,15 @@
import { useMemo, useState } from "react";
import { Button, Card, Center, Grid, Input, Stack, Text } from "@mantine/core";
import { IconPlus, IconSearch } from "@tabler/icons-react";
import { Button, Card, Center, Grid, Group, Input, ScrollArea, Stack, Text, Title } from "@mantine/core";
import { IconArrowLeft, IconPlus, IconSearch } from "@tabler/icons-react";
import type { z } from "zod/v4";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { createModal, useModalAction } from "@homarr/modals";
import { useI18n } from "@homarr/translation/client";
import { QuickAddAppModal } from "./quick-add-app/quick-add-app-modal";
import { AppForm } from "@homarr/forms-collection";
import { createModal } from "@homarr/modals";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import type { appManageSchema } from "@homarr/validation/app";
interface AppSelectModalProps {
onSelect?: (app: RouterOutputs["app"]["selectable"][number]) => void;
@@ -15,10 +17,20 @@ interface AppSelectModalProps {
}
export const AppSelectModal = createModal<AppSelectModalProps>(({ actions, innerProps }) => {
const [step, setStep] = useState<"select" | "form">("select");
const [search, setSearch] = useState("");
const t = useI18n();
const { data: apps = [], isPending } = clientApi.app.selectable.useQuery();
const { openModal: openQuickAddAppModal } = useModalAction(QuickAddAppModal);
const tScoped = useScopedI18n("app.page.create.notification");
const { data: apps = [], isPending: isLoadingApps } = clientApi.app.selectable.useQuery();
const { mutate, isPending: isCreating } = clientApi.app.create.useMutation({
onError: () => {
showErrorNotification({
title: tScoped("error.title"),
message: tScoped("error.message"),
});
},
});
const filteredApps = useMemo(
() =>
@@ -29,23 +41,42 @@ export const AppSelectModal = createModal<AppSelectModalProps>(({ actions, inner
);
const handleSelect = (app: RouterOutputs["app"]["selectable"][number]) => {
if (innerProps.onSelect) {
innerProps.onSelect(app);
}
innerProps.onSelect?.(app);
actions.closeModal();
};
const handleAddNewApp = () => {
openQuickAddAppModal({
onClose(app) {
if (innerProps.onSelect) {
innerProps.onSelect(app);
}
const handleCreateSubmit = (values: z.infer<typeof appManageSchema>) => {
mutate(values, {
onSuccess(app) {
showSuccessNotification({
title: tScoped("success.title"),
message: tScoped("success.message"),
});
innerProps.onSelect?.(app);
actions.closeModal();
},
});
};
if (step === "form") {
return (
<ScrollArea.Autosize mah="80vh">
<Group gap="xs" mb="md" style={{ cursor: "pointer" }} onClick={() => setStep("select")}>
<IconArrowLeft size={18} />
<Title order={4}>{t("app.action.create.title")}</Title>
</Group>
<AppForm
buttonLabels={{
submit: t("board.action.quickCreateApp.modal.createAndUse"),
}}
showBackToOverview={false}
handleSubmit={handleCreateSubmit}
isPending={isCreating}
/>
</ScrollArea.Autosize>
);
}
return (
<Stack>
<Input
@@ -77,7 +108,7 @@ export const AppSelectModal = createModal<AppSelectModalProps>(({ actions, inner
{t("app.action.create.description")}
</Text>
</Stack>
<Button onClick={handleAddNewApp} variant="light" size="xs" mt="auto" radius="md" fullWidth>
<Button onClick={() => setStep("form")} variant="light" size="xs" mt="auto" radius="md" fullWidth>
{t("app.action.create.action")}
</Button>
</Stack>
@@ -108,7 +139,7 @@ export const AppSelectModal = createModal<AppSelectModalProps>(({ actions, inner
</Grid.Col>
))}
{filteredApps.length === 0 && !isPending && (
{filteredApps.length === 0 && !isLoadingApps && (
<Grid.Col span={12}>
<Center p="xl">
<Text c="dimmed">{t("app.action.select.noResults")}</Text>
@@ -120,5 +151,5 @@ export const AppSelectModal = createModal<AppSelectModalProps>(({ actions, inner
);
}).withOptions({
defaultTitle: (t) => t("app.action.select.title"),
size: "xl",
size: 1200,
});