feat(integrations): add app linking (#4338)

This commit is contained in:
Meier Lukas
2025-10-24 20:21:27 +02:00
committed by GitHub
parent 6f0b5d7e04
commit 172db0e3f9
47 changed files with 6791 additions and 158 deletions

View File

@@ -19,6 +19,7 @@ import {
} from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
import { useRequiredBoard } from "@homarr/boards/context";
import { useEditMode } from "@homarr/boards/edit-mode";
import { revalidatePathActionAsync } from "@homarr/common/client";
@@ -62,6 +63,7 @@ export const BoardContentHeaderActions = () => {
};
const AddMenu = () => {
const { data: session } = useSession();
const { openModal: openCategoryEditModal } = useModalAction(CategoryEditModal);
const { openModal: openItemSelectModal } = useModalAction(ItemSelectModal);
const { openModal: openAppSelectModal } = useModalAction(AppSelectModal);
@@ -96,12 +98,13 @@ const AddMenu = () => {
const handleSelectApp = useCallback(() => {
openAppSelectModal({
onSelect: (appId) => {
onSelect: (app) => {
createItem({
kind: "app",
options: { appId },
options: { appId: app.id },
});
},
withCreate: session?.user.permissions.includes("app-create") ?? false,
});
}, [openAppSelectModal, createItem]);

View File

@@ -3,16 +3,18 @@
import { useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { Alert, Button, Fieldset, Group, Stack, Text, TextInput } from "@mantine/core";
import { IconInfoCircle } from "@tabler/icons-react";
import type { z } from "zod/v4";
import { Alert, Anchor, Button, ButtonGroup, Fieldset, Group, Stack, Text, TextInput } from "@mantine/core";
import { IconInfoCircle, IconPencil, IconPlus, IconUnlink } from "@tabler/icons-react";
import { z } from "zod/v4";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { getAllSecretKindOptions, getDefaultSecretKinds } from "@homarr/definitions";
import { useZodForm } from "@homarr/form";
import { useConfirmModal } from "@homarr/modals";
import { useConfirmModal, useModalAction } from "@homarr/modals";
import { AppSelectModal } from "@homarr/modals-collection";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import { integrationUpdateSchema } from "@homarr/validation/integration";
@@ -27,6 +29,19 @@ interface EditIntegrationForm {
integration: RouterOutputs["integration"]["byId"];
}
const formSchema = integrationUpdateSchema.omit({ id: true, appId: true }).and(
z.object({
app: z
.object({
id: z.string(),
name: z.string(),
iconUrl: z.string(),
href: z.string().nullable(),
})
.nullable(),
}),
);
export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => {
const t = useI18n();
const { openConfirmModal } = useConfirmModal();
@@ -40,7 +55,7 @@ export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => {
const hasUrlSecret = initialSecretsKinds.includes("url");
const router = useRouter();
const form = useZodForm(integrationUpdateSchema.omit({ id: true }), {
const form = useZodForm(formSchema, {
initialValues: {
name: integration.name,
url: integration.url,
@@ -48,6 +63,7 @@ export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => {
kind,
value: integration.secrets.find((secret) => secret.kind === kind)?.value ?? "",
})),
app: integration.app ?? null,
},
});
const { mutateAsync, isPending } = clientApi.integration.update.useMutation();
@@ -55,7 +71,7 @@ export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => {
const secretsMap = new Map(integration.secrets.map((secret) => [secret.kind, secret]));
const handleSubmitAsync = async (values: FormType) => {
const handleSubmitAsync = async ({ app, ...values }: FormType) => {
const url = hasUrlSecret
? new URL(values.secrets.find((secret) => secret.kind === "url")?.value ?? values.url).origin
: values.url;
@@ -68,6 +84,7 @@ export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => {
kind: secret.kind,
value: secret.value === "" ? null : secret.value,
})),
appId: app?.id ?? null,
},
{
onSuccess: (data) => {
@@ -102,7 +119,7 @@ export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => {
form.values.secrets.length === initialSecretsKinds.length;
return (
<form onSubmit={form.onSubmit((values) => void handleSubmitAsync(values))}>
<form onSubmit={form.onSubmit(async (values) => await handleSubmitAsync(values))}>
<Stack>
<TextInput withAsterisk label={t("integration.field.name.label")} {...form.getInputProps("name")} />
@@ -169,6 +186,8 @@ export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => {
</Stack>
</Fieldset>
<IntegrationLinkApp value={form.values.app} onChange={(app) => form.setFieldValue("app", app)} />
{error !== null && <IntegrationTestConnectionError error={error} url={form.values.url} />}
<Group justify="end" align="center">
@@ -184,4 +203,80 @@ export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => {
);
};
type FormType = Omit<z.infer<typeof integrationUpdateSchema>, "id">;
type FormType = z.infer<typeof formSchema>;
interface IntegrationAppSelectProps {
value: FormType["app"];
onChange: (app: FormType["app"]) => void;
}
const IntegrationLinkApp = ({ value, onChange }: IntegrationAppSelectProps) => {
const { openModal } = useModalAction(AppSelectModal);
const t = useI18n();
const { data: session } = useSession();
const canCreateApps = session?.user.permissions.includes("app-create") ?? false;
const handleChange = () =>
openModal(
{
onSelect: onChange,
withCreate: canCreateApps,
},
{
title: t("integration.page.edit.app.action.select"),
},
);
if (!value) {
return (
<Button
variant="subtle"
color="gray"
leftSection={<IconPlus size={16} stroke={1.5} />}
fullWidth
onClick={handleChange}
>
{t("integration.page.edit.app.action.add")}
</Button>
);
}
return (
<Fieldset legend={t("integration.field.app.sectionTitle")}>
<Group justify="space-between">
<Group gap="sm">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={value.iconUrl} alt={value.name} width={32} height={32} />
<Stack gap={0}>
<Text size="sm" fw="bold">
{value.name}
</Text>
{value.href !== null && (
<Anchor href={value.href} target="_blank" rel="noopener noreferrer" size="sm">
{value.href}
</Anchor>
)}
</Stack>
</Group>
<ButtonGroup>
<Button
variant="subtle"
color="gray"
leftSection={<IconUnlink size={16} stroke={1.5} />}
onClick={() => onChange(null)}
>
{t("integration.page.edit.app.action.remove")}
</Button>
<Button
variant="subtle"
color="gray"
leftSection={<IconPencil size={16} stroke={1.5} />}
onClick={handleChange}
>
{t("common.action.change")}
</Button>
</ButtonGroup>
</Group>
</Fieldset>
);
};

View File

@@ -1,14 +1,29 @@
"use client";
import { useState } from "react";
import { startTransition, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { Alert, Button, Checkbox, Collapse, Fieldset, Group, Stack, Text, TextInput } from "@mantine/core";
import { IconInfoCircle } from "@tabler/icons-react";
import {
Alert,
Button,
Checkbox,
Collapse,
Fieldset,
Group,
Loader,
SegmentedControl,
Select,
Stack,
Text,
TextInput,
} from "@mantine/core";
import { IconCheck, IconInfoCircle } from "@tabler/icons-react";
import { z } from "zod/v4";
import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import type { Modify } from "@homarr/common/types";
import type { IntegrationKind } from "@homarr/definitions";
import {
getAllSecretKindOptions,
@@ -17,6 +32,7 @@ import {
getIntegrationName,
integrationDefs,
} from "@homarr/definitions";
import type { GetInputPropsReturnType, UseFormReturnType } from "@homarr/form";
import { useZodForm } from "@homarr/form";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
@@ -34,10 +50,11 @@ interface NewIntegrationFormProps {
};
}
const formSchema = integrationCreateSchema.omit({ kind: true }).and(
const formSchema = integrationCreateSchema.omit({ kind: true, app: true }).and(
z.object({
createApp: z.boolean(),
hasApp: z.boolean(),
appHref: appHrefSchema,
appId: z.string().nullable(),
}),
);
@@ -46,7 +63,8 @@ export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) =>
const secretKinds = getAllSecretKindOptions(searchParams.kind);
const hasUrlSecret = secretKinds.some((kinds) => kinds.includes("url"));
const router = useRouter();
const [opened, setOpened] = useState(false);
const { data: session } = useSession();
const canCreateApps = session?.user.permissions.includes("app-create") ?? false;
let url = searchParams.url ?? getIntegrationDefaultUrl(searchParams.kind) ?? "";
if (hasUrlSecret) {
@@ -62,31 +80,40 @@ export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) =>
value: "",
})),
attemptSearchEngineCreation: true,
createApp: false,
appHref: "",
},
onValuesChange(values, previous) {
if (values.createApp !== previous.createApp) {
setOpened(values.createApp);
}
hasApp: false,
appHref: url,
appId: null,
},
});
const { mutateAsync: createIntegrationAsync, isPending: isPendingIntegration } =
clientApi.integration.create.useMutation();
const { mutateAsync: createAppAsync, isPending: isPendingApp } = clientApi.app.create.useMutation();
const isPending = isPendingIntegration || isPendingApp;
const { mutateAsync: createIntegrationAsync, isPending } = clientApi.integration.create.useMutation();
const [error, setError] = useState<null | AnyMappedTestConnectionError>(null);
const handleSubmitAsync = async (values: FormType) => {
const handleSubmitAsync = async ({ appId, appHref, hasApp, ...values }: FormType) => {
const url = hasUrlSecret
? new URL(values.secrets.find((secret) => secret.kind === "url")?.value ?? values.url).origin
: values.url;
const hasCustomHref = appHref !== null && appHref.trim().length >= 1;
const app = hasApp
? appId !== null
? { id: appId }
: {
name: values.name,
href: hasCustomHref ? appHref : url,
iconUrl: getIconUrl(searchParams.kind),
description: null,
pingUrl: url,
}
: undefined;
await createIntegrationAsync(
{
kind: searchParams.kind,
...values,
url,
app,
},
{
async onSuccess(data) {
@@ -105,32 +132,7 @@ export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) =>
message: t("integration.page.create.notification.success.message"),
});
if (!values.createApp) {
await revalidatePathActionAsync("/manage/integrations").then(() => router.push("/manage/integrations"));
return;
}
const hasCustomHref = values.appHref !== null && values.appHref.trim().length >= 1;
await createAppAsync(
{
name: values.name,
href: hasCustomHref ? values.appHref : url,
iconUrl: getIconUrl(searchParams.kind),
description: null,
pingUrl: url,
},
{
async onSettled() {
await revalidatePathActionAsync("/manage/integrations").then(() => router.push("/manage/integrations"));
},
onError() {
showErrorNotification({
title: t("app.page.create.notification.error.title"),
message: t("app.page.create.notification.error.message"),
});
},
},
);
await revalidatePathActionAsync("/manage/integrations").then(() => router.push("/manage/integrations"));
},
onError: () => {
showErrorNotification({
@@ -184,15 +186,7 @@ export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) =>
/>
)}
<Checkbox
{...form.getInputProps("createApp", { type: "checkbox" })}
label={t("integration.field.createApp.label")}
description={t("integration.field.createApp.description")}
/>
<Collapse in={opened}>
<TextInput placeholder={t("integration.field.appHref.placeholder")} {...form.getInputProps("appHref")} />
</Collapse>
<AppForm form={form} canCreateApps={canCreateApps} />
<Group justify="end" align="center">
<Button variant="default" component={Link} href="/manage/integrations">
@@ -208,3 +202,114 @@ export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) =>
};
type FormType = z.infer<typeof formSchema>;
const AppForm = ({ form, canCreateApps }: { form: UseFormReturnType<FormType>; canCreateApps: boolean }) => {
const t = useI18n();
const checkboxInputProps = form.getInputProps("hasApp", { type: "checkbox" });
return (
<>
<Checkbox
{...checkboxInputProps}
onChange={(event) => {
startTransition(() => {
form.setFieldValue("appHref", event.currentTarget.checked ? form.values.url : null);
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
checkboxInputProps.onChange(event);
});
}}
label={t("integration.field.createApp.label")}
description={t("integration.field.createApp.description")}
/>
<Collapse in={form.values.hasApp}>
<Fieldset legend={t("integration.field.app.sectionTitle")}>
<Stack gap="sm">
{canCreateApps && (
<SegmentedControl
data={(["new", "existing"] as const).map((value) => ({
value,
label: t(`integration.page.create.app.option.${value}.title`),
}))}
value={form.values.appHref === null ? "existing" : "new"}
onChange={(value) => {
if (value === "existing") {
form.setFieldValue("appId", null);
form.setFieldValue("appHref", null);
} else {
form.setFieldValue("appId", null);
form.setFieldValue("appHref", form.values.url);
}
}}
/>
)}
{typeof form.values.appHref === "string" && canCreateApps ? (
<TextInput
placeholder={t("integration.field.appHref.placeholder")}
withAsterisk
label={t("integration.page.create.app.option.new.url.label")}
description={t("integration.page.create.app.option.new.url.description")}
{...form.getInputProps("appHref")}
/>
) : (
<IntegrationAppSelect {...form.getInputProps("appId")} />
)}
</Stack>
</Fieldset>
</Collapse>
</>
);
};
type IntegrationAppSelectProps = Modify<
GetInputPropsReturnType,
{
value?: string | null;
onChange: (value: string | null) => void;
}
>;
const IntegrationAppSelect = ({ value, ...props }: IntegrationAppSelectProps) => {
const { data, isPending } = clientApi.app.selectable.useQuery();
const t = useI18n();
const appMap = new Map(data?.map((app) => [app.id, app] as const));
return (
<Select
withAsterisk
label={t("integration.page.create.app.option.existing.label")}
searchable
clearable
leftSection={
// eslint-disable-next-line @next/next/no-img-element
value ? <img width={20} height={20} src={appMap.get(value)?.iconUrl} alt={appMap.get(value)?.name} /> : null
}
renderOption={({ option, checked }) => (
<Group flex="1" gap="xs">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img width={20} height={20} src={appMap.get(option.value)?.iconUrl} alt={option.label} />
<Stack gap={0}>
<Text>{option.label}</Text>
<Text size="xs" c="dimmed">
{appMap.get(option.value)?.href}
</Text>
</Stack>
{checked && (
<IconCheck
style={{ marginInlineStart: "auto" }}
stroke={1.5}
color="currentColor"
opacity={0.6}
size={18}
/>
)}
</Group>
)}
{...props}
data={data?.map((app) => ({ value: app.id, label: app.name }))}
rightSection={isPending ? <Loader size="sm" /> : null}
/>
);
};