Skip to content

Commit 172db0e

Browse files
feat(integrations): add app linking (#4338)
1 parent 6f0b5d7 commit 172db0e

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

47 files changed

+6791
-158
lines changed

apps/nextjs/src/app/[locale]/boards/(content)/_header-actions.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
} from "@tabler/icons-react";
2020

2121
import { clientApi } from "@homarr/api/client";
22+
import { useSession } from "@homarr/auth/client";
2223
import { useRequiredBoard } from "@homarr/boards/context";
2324
import { useEditMode } from "@homarr/boards/edit-mode";
2425
import { revalidatePathActionAsync } from "@homarr/common/client";
@@ -62,6 +63,7 @@ export const BoardContentHeaderActions = () => {
6263
};
6364

6465
const AddMenu = () => {
66+
const { data: session } = useSession();
6567
const { openModal: openCategoryEditModal } = useModalAction(CategoryEditModal);
6668
const { openModal: openItemSelectModal } = useModalAction(ItemSelectModal);
6769
const { openModal: openAppSelectModal } = useModalAction(AppSelectModal);
@@ -96,12 +98,13 @@ const AddMenu = () => {
9698

9799
const handleSelectApp = useCallback(() => {
98100
openAppSelectModal({
99-
onSelect: (appId) => {
101+
onSelect: (app) => {
100102
createItem({
101103
kind: "app",
102-
options: { appId },
104+
options: { appId: app.id },
103105
});
104106
},
107+
withCreate: session?.user.permissions.includes("app-create") ?? false,
105108
});
106109
}, [openAppSelectModal, createItem]);
107110

apps/nextjs/src/app/[locale]/manage/integrations/edit/[id]/_integration-edit-form.tsx

Lines changed: 103 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,18 @@
33
import { useState } from "react";
44
import Link from "next/link";
55
import { useRouter } from "next/navigation";
6-
import { Alert, Button, Fieldset, Group, Stack, Text, TextInput } from "@mantine/core";
7-
import { IconInfoCircle } from "@tabler/icons-react";
8-
import type { z } from "zod/v4";
6+
import { Alert, Anchor, Button, ButtonGroup, Fieldset, Group, Stack, Text, TextInput } from "@mantine/core";
7+
import { IconInfoCircle, IconPencil, IconPlus, IconUnlink } from "@tabler/icons-react";
8+
import { z } from "zod/v4";
99

1010
import type { RouterOutputs } from "@homarr/api";
1111
import { clientApi } from "@homarr/api/client";
12+
import { useSession } from "@homarr/auth/client";
1213
import { revalidatePathActionAsync } from "@homarr/common/client";
1314
import { getAllSecretKindOptions, getDefaultSecretKinds } from "@homarr/definitions";
1415
import { useZodForm } from "@homarr/form";
15-
import { useConfirmModal } from "@homarr/modals";
16+
import { useConfirmModal, useModalAction } from "@homarr/modals";
17+
import { AppSelectModal } from "@homarr/modals-collection";
1618
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
1719
import { useI18n } from "@homarr/translation/client";
1820
import { integrationUpdateSchema } from "@homarr/validation/integration";
@@ -27,6 +29,19 @@ interface EditIntegrationForm {
2729
integration: RouterOutputs["integration"]["byId"];
2830
}
2931

32+
const formSchema = integrationUpdateSchema.omit({ id: true, appId: true }).and(
33+
z.object({
34+
app: z
35+
.object({
36+
id: z.string(),
37+
name: z.string(),
38+
iconUrl: z.string(),
39+
href: z.string().nullable(),
40+
})
41+
.nullable(),
42+
}),
43+
);
44+
3045
export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => {
3146
const t = useI18n();
3247
const { openConfirmModal } = useConfirmModal();
@@ -40,22 +55,23 @@ export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => {
4055
const hasUrlSecret = initialSecretsKinds.includes("url");
4156

4257
const router = useRouter();
43-
const form = useZodForm(integrationUpdateSchema.omit({ id: true }), {
58+
const form = useZodForm(formSchema, {
4459
initialValues: {
4560
name: integration.name,
4661
url: integration.url,
4762
secrets: initialSecretsKinds.map((kind) => ({
4863
kind,
4964
value: integration.secrets.find((secret) => secret.kind === kind)?.value ?? "",
5065
})),
66+
app: integration.app ?? null,
5167
},
5268
});
5369
const { mutateAsync, isPending } = clientApi.integration.update.useMutation();
5470
const [error, setError] = useState<null | AnyMappedTestConnectionError>(null);
5571

5672
const secretsMap = new Map(integration.secrets.map((secret) => [secret.kind, secret]));
5773

58-
const handleSubmitAsync = async (values: FormType) => {
74+
const handleSubmitAsync = async ({ app, ...values }: FormType) => {
5975
const url = hasUrlSecret
6076
? new URL(values.secrets.find((secret) => secret.kind === "url")?.value ?? values.url).origin
6177
: values.url;
@@ -68,6 +84,7 @@ export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => {
6884
kind: secret.kind,
6985
value: secret.value === "" ? null : secret.value,
7086
})),
87+
appId: app?.id ?? null,
7188
},
7289
{
7390
onSuccess: (data) => {
@@ -102,7 +119,7 @@ export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => {
102119
form.values.secrets.length === initialSecretsKinds.length;
103120

104121
return (
105-
<form onSubmit={form.onSubmit((values) => void handleSubmitAsync(values))}>
122+
<form onSubmit={form.onSubmit(async (values) => await handleSubmitAsync(values))}>
106123
<Stack>
107124
<TextInput withAsterisk label={t("integration.field.name.label")} {...form.getInputProps("name")} />
108125

@@ -169,6 +186,8 @@ export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => {
169186
</Stack>
170187
</Fieldset>
171188

189+
<IntegrationLinkApp value={form.values.app} onChange={(app) => form.setFieldValue("app", app)} />
190+
172191
{error !== null && <IntegrationTestConnectionError error={error} url={form.values.url} />}
173192

174193
<Group justify="end" align="center">
@@ -184,4 +203,80 @@ export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => {
184203
);
185204
};
186205

187-
type FormType = Omit<z.infer<typeof integrationUpdateSchema>, "id">;
206+
type FormType = z.infer<typeof formSchema>;
207+
208+
interface IntegrationAppSelectProps {
209+
value: FormType["app"];
210+
onChange: (app: FormType["app"]) => void;
211+
}
212+
213+
const IntegrationLinkApp = ({ value, onChange }: IntegrationAppSelectProps) => {
214+
const { openModal } = useModalAction(AppSelectModal);
215+
const t = useI18n();
216+
const { data: session } = useSession();
217+
const canCreateApps = session?.user.permissions.includes("app-create") ?? false;
218+
219+
const handleChange = () =>
220+
openModal(
221+
{
222+
onSelect: onChange,
223+
withCreate: canCreateApps,
224+
},
225+
{
226+
title: t("integration.page.edit.app.action.select"),
227+
},
228+
);
229+
230+
if (!value) {
231+
return (
232+
<Button
233+
variant="subtle"
234+
color="gray"
235+
leftSection={<IconPlus size={16} stroke={1.5} />}
236+
fullWidth
237+
onClick={handleChange}
238+
>
239+
{t("integration.page.edit.app.action.add")}
240+
</Button>
241+
);
242+
}
243+
244+
return (
245+
<Fieldset legend={t("integration.field.app.sectionTitle")}>
246+
<Group justify="space-between">
247+
<Group gap="sm">
248+
{/* eslint-disable-next-line @next/next/no-img-element */}
249+
<img src={value.iconUrl} alt={value.name} width={32} height={32} />
250+
<Stack gap={0}>
251+
<Text size="sm" fw="bold">
252+
{value.name}
253+
</Text>
254+
{value.href !== null && (
255+
<Anchor href={value.href} target="_blank" rel="noopener noreferrer" size="sm">
256+
{value.href}
257+
</Anchor>
258+
)}
259+
</Stack>
260+
</Group>
261+
<ButtonGroup>
262+
<Button
263+
variant="subtle"
264+
color="gray"
265+
leftSection={<IconUnlink size={16} stroke={1.5} />}
266+
onClick={() => onChange(null)}
267+
>
268+
{t("integration.page.edit.app.action.remove")}
269+
</Button>
270+
<Button
271+
variant="subtle"
272+
color="gray"
273+
leftSection={<IconPencil size={16} stroke={1.5} />}
274+
onClick={handleChange}
275+
>
276+
{t("common.action.change")}
277+
</Button>
278+
</ButtonGroup>
279+
</Group>
280+
</Fieldset>
281+
);
282+
};

0 commit comments

Comments
 (0)