feat(integrations): add app linking (#4338)
This commit is contained in:
@@ -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]);
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user