feat: add crud for integrations (#11)
* wip: add crud for services and integrations * feat: remove services * feat: move integration definitions to homarr/definitions, add temporary test connection solution without actual request * feat: add integration count badge * feat: add translation for integrations * feat: add notifications and translate them * feat: add notice to integration forms about test connection * chore: fix ci check issues * feat: add confirm modals for integration deletion and secret card cancellation, change ordering for list page, add name property to integrations * refactor: move revalidate path action * chore: fix ci check issues * chore: install missing dependencies * chore: fix ci check issues * chore: address pull request feedback
This commit is contained in:
@@ -2,8 +2,28 @@
|
||||
|
||||
import type { PropsWithChildren } from "react";
|
||||
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { ModalsManager } from "../modals";
|
||||
|
||||
export const ModalsProvider = ({ children }: PropsWithChildren) => {
|
||||
return <ModalsManager>{children}</ModalsManager>;
|
||||
const t = useScopedI18n("common.action");
|
||||
return (
|
||||
<ModalsManager
|
||||
labels={{
|
||||
cancel: t("cancel"),
|
||||
confirm: t("confirm"),
|
||||
}}
|
||||
modalProps={{
|
||||
styles: {
|
||||
title: {
|
||||
fontSize: "1.25rem",
|
||||
fontWeight: 500,
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</ModalsManager>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -8,7 +8,7 @@ import { loggerLink, unstable_httpBatchStreamLink } from "@trpc/client";
|
||||
import superjson from "superjson";
|
||||
|
||||
import { env } from "~/env.mjs";
|
||||
import { api } from "~/utils/api";
|
||||
import { api } from "~/trpc/react";
|
||||
|
||||
const getBaseUrl = () => {
|
||||
if (typeof window !== "undefined") return ""; // browser should use relative url
|
||||
|
||||
@@ -12,7 +12,7 @@ import { Button, PasswordInput, Stack, TextInput } from "@homarr/ui";
|
||||
import type { z } from "@homarr/validation";
|
||||
import { validation } from "@homarr/validation";
|
||||
|
||||
import { api } from "~/utils/api";
|
||||
import { api } from "~/trpc/react";
|
||||
|
||||
export const InitUserForm = () => {
|
||||
const router = useRouter();
|
||||
|
||||
32
apps/nextjs/src/app/[locale]/integrations/_accordion.tsx
Normal file
32
apps/nextjs/src/app/[locale]/integrations/_accordion.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
"use client";
|
||||
|
||||
import type { PropsWithChildren } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import type { IntegrationKind } from "@homarr/definitions";
|
||||
import { Accordion } from "@homarr/ui";
|
||||
|
||||
type IntegrationGroupAccordionControlProps = PropsWithChildren<{
|
||||
activeTab: IntegrationKind | undefined;
|
||||
}>;
|
||||
|
||||
export const IntegrationGroupAccordion = ({
|
||||
children,
|
||||
activeTab,
|
||||
}: IntegrationGroupAccordionControlProps) => {
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<Accordion
|
||||
variant="separated"
|
||||
defaultValue={activeTab}
|
||||
onChange={(tab) =>
|
||||
tab
|
||||
? router.replace(`?tab=${tab}`, {})
|
||||
: router.replace("/integrations")
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</Accordion>
|
||||
);
|
||||
};
|
||||
18
apps/nextjs/src/app/[locale]/integrations/_avatar.tsx
Normal file
18
apps/nextjs/src/app/[locale]/integrations/_avatar.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { getIconUrl } from "@homarr/definitions";
|
||||
import type { IntegrationKind } from "@homarr/definitions";
|
||||
import { Avatar } from "@homarr/ui";
|
||||
import type { MantineSize } from "@homarr/ui";
|
||||
|
||||
interface IntegrationAvatarProps {
|
||||
size: MantineSize;
|
||||
kind: IntegrationKind | null;
|
||||
}
|
||||
|
||||
export const IntegrationAvatar = ({ kind, size }: IntegrationAvatarProps) => {
|
||||
const url = kind ? getIconUrl(kind) : null;
|
||||
if (!url) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <Avatar size={size} src={url} />;
|
||||
};
|
||||
68
apps/nextjs/src/app/[locale]/integrations/_buttons.tsx
Normal file
68
apps/nextjs/src/app/[locale]/integrations/_buttons.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import {
|
||||
showErrorNotification,
|
||||
showSuccessNotification,
|
||||
} from "@homarr/notifications";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { ActionIcon, IconTrash } from "@homarr/ui";
|
||||
|
||||
import { api } from "~/trpc/react";
|
||||
import { revalidatePathAction } from "../../revalidatePathAction";
|
||||
import { modalEvents } from "../modals";
|
||||
|
||||
interface DeleteIntegrationActionButtonProps {
|
||||
count: number;
|
||||
integration: { id: string; name: string };
|
||||
}
|
||||
|
||||
export const DeleteIntegrationActionButton = ({
|
||||
count,
|
||||
integration,
|
||||
}: DeleteIntegrationActionButtonProps) => {
|
||||
const t = useScopedI18n("integration.page.delete");
|
||||
const router = useRouter();
|
||||
const { mutateAsync, isPending } = api.integration.delete.useMutation();
|
||||
|
||||
return (
|
||||
<ActionIcon
|
||||
loading={isPending}
|
||||
variant="subtle"
|
||||
color="red"
|
||||
onClick={() => {
|
||||
modalEvents.openConfirmModal({
|
||||
title: t("title"),
|
||||
children: t("message", integration),
|
||||
onConfirm: () => {
|
||||
void mutateAsync(
|
||||
{ id: integration.id },
|
||||
{
|
||||
onSuccess: () => {
|
||||
showSuccessNotification({
|
||||
title: t("notification.success.title"),
|
||||
message: t("notification.success.message"),
|
||||
});
|
||||
if (count === 1) {
|
||||
router.replace("/integrations");
|
||||
}
|
||||
void revalidatePathAction("/integrations");
|
||||
},
|
||||
onError: () => {
|
||||
showErrorNotification({
|
||||
title: t("notification.error.title"),
|
||||
message: t("notification.error.message"),
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
});
|
||||
}}
|
||||
aria-label="Delete integration"
|
||||
>
|
||||
<IconTrash color="red" size={16} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
);
|
||||
};
|
||||
95
apps/nextjs/src/app/[locale]/integrations/_secret-card.tsx
Normal file
95
apps/nextjs/src/app/[locale]/integrations/_secret-card.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import dayjs from "dayjs";
|
||||
import relativeTime from "dayjs/plugin/relativeTime";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { integrationSecretKindObject } from "@homarr/definitions";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import {
|
||||
ActionIcon,
|
||||
Avatar,
|
||||
Button,
|
||||
Card,
|
||||
Collapse,
|
||||
Group,
|
||||
IconEye,
|
||||
IconEyeOff,
|
||||
Kbd,
|
||||
Stack,
|
||||
Text,
|
||||
} from "@homarr/ui";
|
||||
|
||||
import { integrationSecretIcons } from "./_secret-icons";
|
||||
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
interface SecretCardProps {
|
||||
secret: RouterOutputs["integration"]["byId"]["secrets"][number];
|
||||
children: React.ReactNode;
|
||||
onCancel: () => Promise<boolean>;
|
||||
}
|
||||
|
||||
export const SecretCard = ({ secret, children, onCancel }: SecretCardProps) => {
|
||||
const params = useParams<{ locale: string }>();
|
||||
const t = useI18n();
|
||||
const { isPublic } = integrationSecretKindObject[secret.kind];
|
||||
const [publicSecretDisplayOpened, { toggle: togglePublicSecretDisplay }] =
|
||||
useDisclosure(false);
|
||||
const [editMode, setEditMode] = useState(false);
|
||||
const DisplayIcon = publicSecretDisplayOpened ? IconEye : IconEyeOff;
|
||||
const KindIcon = integrationSecretIcons[secret.kind];
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<Stack>
|
||||
<Group justify="space-between">
|
||||
<Group>
|
||||
<Avatar>
|
||||
<KindIcon size={16} />
|
||||
</Avatar>
|
||||
<Text fw={500}>
|
||||
{t(`integration.secrets.kind.${secret.kind}.label`)}
|
||||
</Text>
|
||||
{publicSecretDisplayOpened ? <Kbd>{secret.value}</Kbd> : null}
|
||||
</Group>
|
||||
<Group>
|
||||
<Text c="gray.6" size="sm">
|
||||
{t("integration.secrets.lastUpdated", {
|
||||
date: dayjs().locale(params.locale).to(dayjs(secret.updatedAt)),
|
||||
})}
|
||||
</Text>
|
||||
{isPublic ? (
|
||||
<ActionIcon
|
||||
color="gray"
|
||||
variant="subtle"
|
||||
onClick={togglePublicSecretDisplay}
|
||||
>
|
||||
<DisplayIcon size={16} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
) : null}
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={async () => {
|
||||
if (!editMode) {
|
||||
setEditMode(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const shouldCancel = await onCancel();
|
||||
if (!shouldCancel) return;
|
||||
setEditMode(false);
|
||||
}}
|
||||
>
|
||||
{editMode ? t("common.action.cancel") : t("common.action.edit")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
<Collapse in={editMode}>{children}</Collapse>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
12
apps/nextjs/src/app/[locale]/integrations/_secret-icons.ts
Normal file
12
apps/nextjs/src/app/[locale]/integrations/_secret-icons.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import type { IntegrationSecretKind } from "@homarr/definitions";
|
||||
import type { TablerIconsProps } from "@homarr/ui";
|
||||
import { IconKey, IconPassword, IconUser } from "@homarr/ui";
|
||||
|
||||
export const integrationSecretIcons = {
|
||||
username: IconUser,
|
||||
apiKey: IconKey,
|
||||
password: IconPassword,
|
||||
} satisfies Record<
|
||||
IntegrationSecretKind,
|
||||
(props: TablerIconsProps) => JSX.Element
|
||||
>;
|
||||
60
apps/nextjs/src/app/[locale]/integrations/_secret-inputs.tsx
Normal file
60
apps/nextjs/src/app/[locale]/integrations/_secret-inputs.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
"use client";
|
||||
|
||||
import type { ChangeEventHandler, FocusEventHandler } from "react";
|
||||
|
||||
import { integrationSecretKindObject } from "@homarr/definitions";
|
||||
import type { IntegrationSecretKind } from "@homarr/definitions";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { PasswordInput, TextInput } from "@homarr/ui";
|
||||
|
||||
import { integrationSecretIcons } from "./_secret-icons";
|
||||
|
||||
interface IntegrationSecretInputProps {
|
||||
label?: string;
|
||||
kind: IntegrationSecretKind;
|
||||
value: string;
|
||||
onChange: ChangeEventHandler<HTMLInputElement>;
|
||||
onFocus?: FocusEventHandler<HTMLInputElement>;
|
||||
onBlur?: FocusEventHandler<HTMLInputElement>;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export const IntegrationSecretInput = (props: IntegrationSecretInputProps) => {
|
||||
const { isPublic } = integrationSecretKindObject[props.kind];
|
||||
|
||||
if (isPublic) return <PublicSecretInput {...props} />;
|
||||
|
||||
return <PrivateSecretInput {...props} />;
|
||||
};
|
||||
|
||||
const PublicSecretInput = ({ kind, ...props }: IntegrationSecretInputProps) => {
|
||||
const t = useI18n();
|
||||
const Icon = integrationSecretIcons[kind];
|
||||
|
||||
return (
|
||||
<TextInput
|
||||
{...props}
|
||||
label={props.label ?? t(`integration.secrets.kind.${kind}.label`)}
|
||||
w="100%"
|
||||
leftSection={<Icon size={20} stroke={1.5} />}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const PrivateSecretInput = ({
|
||||
kind,
|
||||
...props
|
||||
}: IntegrationSecretInputProps) => {
|
||||
const t = useI18n();
|
||||
const Icon = integrationSecretIcons[kind];
|
||||
|
||||
return (
|
||||
<PasswordInput
|
||||
{...props}
|
||||
label={props.label ?? t(`integration.secrets.kind.${kind}.label`)}
|
||||
description={t(`integration.secrets.secureNotice`)}
|
||||
w="100%"
|
||||
leftSection={<Icon size={20} stroke={1.5} />}
|
||||
/>
|
||||
);
|
||||
};
|
||||
162
apps/nextjs/src/app/[locale]/integrations/_test-connection.tsx
Normal file
162
apps/nextjs/src/app/[locale]/integrations/_test-connection.tsx
Normal file
@@ -0,0 +1,162 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
|
||||
import type { RouterInputs } from "@homarr/api";
|
||||
import {
|
||||
showErrorNotification,
|
||||
showSuccessNotification,
|
||||
} from "@homarr/notifications";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
import {
|
||||
Alert,
|
||||
Anchor,
|
||||
Group,
|
||||
IconCheck,
|
||||
IconInfoCircle,
|
||||
IconX,
|
||||
Loader,
|
||||
} from "@homarr/ui";
|
||||
|
||||
import { api } from "~/trpc/react";
|
||||
|
||||
interface UseTestConnectionDirtyProps {
|
||||
defaultDirty: boolean;
|
||||
initialFormValue: {
|
||||
url: string;
|
||||
secrets: { kind: string; value: string | null }[];
|
||||
};
|
||||
}
|
||||
|
||||
export const useTestConnectionDirty = ({
|
||||
defaultDirty,
|
||||
initialFormValue,
|
||||
}: UseTestConnectionDirtyProps) => {
|
||||
const [isDirty, setIsDirty] = useState(defaultDirty);
|
||||
const prevFormValueRef = useRef(initialFormValue);
|
||||
|
||||
return {
|
||||
onValuesChange: (values: typeof initialFormValue) => {
|
||||
if (isDirty) return;
|
||||
|
||||
// If relevant values changed, set dirty
|
||||
if (
|
||||
prevFormValueRef.current.url !== values.url ||
|
||||
!prevFormValueRef.current.secrets
|
||||
.map((secret) => secret.value)
|
||||
.every(
|
||||
(secretValue, index) =>
|
||||
values.secrets[index]?.value === secretValue,
|
||||
)
|
||||
) {
|
||||
setIsDirty(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// If relevant values changed back to last tested, set not dirty
|
||||
setIsDirty(false);
|
||||
},
|
||||
isDirty,
|
||||
removeDirty: () => {
|
||||
prevFormValueRef.current = initialFormValue;
|
||||
setIsDirty(false);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
interface TestConnectionProps {
|
||||
isDirty: boolean;
|
||||
removeDirty: () => void;
|
||||
integration: RouterInputs["integration"]["testConnection"] & { name: string };
|
||||
}
|
||||
|
||||
export const TestConnection = ({
|
||||
integration,
|
||||
removeDirty,
|
||||
isDirty,
|
||||
}: TestConnectionProps) => {
|
||||
const t = useScopedI18n("integration.testConnection");
|
||||
const { mutateAsync, ...mutation } =
|
||||
api.integration.testConnection.useMutation();
|
||||
|
||||
return (
|
||||
<Group>
|
||||
<Anchor
|
||||
type="button"
|
||||
component="button"
|
||||
onClick={async () => {
|
||||
await mutateAsync(integration, {
|
||||
onSuccess: () => {
|
||||
removeDirty();
|
||||
showSuccessNotification({
|
||||
title: t("notification.success.title"),
|
||||
message: t("notification.success.message"),
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
if (error.data?.zodError?.fieldErrors.url) {
|
||||
showErrorNotification({
|
||||
title: t("notification.invalidUrl.title"),
|
||||
message: t("notification.invalidUrl.message"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (error.message === "SECRETS_NOT_DEFINED") {
|
||||
showErrorNotification({
|
||||
title: t("notification.notAllSecretsProvided.title"),
|
||||
message: t("notification.notAllSecretsProvided.message"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
showErrorNotification({
|
||||
title: t("notification.commonError.title"),
|
||||
message: t("notification.commonError.message"),
|
||||
});
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
{t("action")}
|
||||
</Anchor>
|
||||
<TestConnectionIcon isDirty={isDirty} {...mutation} size={20} />
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
|
||||
interface TestConnectionIconProps {
|
||||
isDirty: boolean;
|
||||
isPending: boolean;
|
||||
isSuccess: boolean;
|
||||
isError: boolean;
|
||||
size: number;
|
||||
}
|
||||
|
||||
const TestConnectionIcon = ({
|
||||
isDirty,
|
||||
isPending,
|
||||
isSuccess,
|
||||
isError,
|
||||
size,
|
||||
}: TestConnectionIconProps) => {
|
||||
if (isPending) return <Loader color="blue" size={size} />;
|
||||
if (isDirty) return null;
|
||||
if (isSuccess) return <IconCheck size={size} stroke={1.5} color="green" />;
|
||||
if (isError) return <IconX size={size} stroke={1.5} color="red" />;
|
||||
return null;
|
||||
};
|
||||
|
||||
export const TestConnectionNoticeAlert = () => {
|
||||
const t = useI18n();
|
||||
return (
|
||||
<Alert
|
||||
variant="light"
|
||||
color="yellow"
|
||||
title="Test Connection"
|
||||
icon={<IconInfoCircle />}
|
||||
>
|
||||
{t("integration.testConnection.alertNotice")}
|
||||
</Alert>
|
||||
);
|
||||
};
|
||||
175
apps/nextjs/src/app/[locale]/integrations/edit/[id]/_form.tsx
Normal file
175
apps/nextjs/src/app/[locale]/integrations/edit/[id]/_form.tsx
Normal file
@@ -0,0 +1,175 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { getSecretKinds } from "@homarr/definitions";
|
||||
import { useForm, zodResolver } from "@homarr/form";
|
||||
import {
|
||||
showErrorNotification,
|
||||
showSuccessNotification,
|
||||
} from "@homarr/notifications";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { Button, Fieldset, Group, Stack, TextInput } from "@homarr/ui";
|
||||
import type { z } from "@homarr/validation";
|
||||
import { validation } from "@homarr/validation";
|
||||
|
||||
import { modalEvents } from "~/app/[locale]/modals";
|
||||
import { api } from "~/trpc/react";
|
||||
import { SecretCard } from "../../_secret-card";
|
||||
import { IntegrationSecretInput } from "../../_secret-inputs";
|
||||
import {
|
||||
TestConnection,
|
||||
TestConnectionNoticeAlert,
|
||||
useTestConnectionDirty,
|
||||
} from "../../_test-connection";
|
||||
import { revalidatePathAction } from "../../../../revalidatePathAction";
|
||||
|
||||
interface EditIntegrationForm {
|
||||
integration: RouterOutputs["integration"]["byId"];
|
||||
}
|
||||
|
||||
export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => {
|
||||
const t = useI18n();
|
||||
const secretsKinds = getSecretKinds(integration.kind);
|
||||
const initialFormValues = {
|
||||
name: integration.name,
|
||||
url: integration.url,
|
||||
secrets: secretsKinds.map((kind) => ({
|
||||
kind,
|
||||
value:
|
||||
integration.secrets.find((secret) => secret.kind === kind)?.value ?? "",
|
||||
})),
|
||||
};
|
||||
const { isDirty, onValuesChange, removeDirty } = useTestConnectionDirty({
|
||||
defaultDirty: true,
|
||||
initialFormValue: initialFormValues,
|
||||
});
|
||||
|
||||
const router = useRouter();
|
||||
const form = useForm<FormType>({
|
||||
initialValues: initialFormValues,
|
||||
validate: zodResolver(
|
||||
validation.integration.update.omit({ id: true, kind: true }),
|
||||
),
|
||||
onValuesChange,
|
||||
});
|
||||
const { mutateAsync, isPending } = api.integration.update.useMutation();
|
||||
|
||||
const secretsMap = new Map(
|
||||
integration.secrets.map((secret) => [secret.kind, secret]),
|
||||
);
|
||||
|
||||
const handleSubmit = async (values: FormType) => {
|
||||
if (isDirty) return;
|
||||
await mutateAsync(
|
||||
{
|
||||
id: integration.id,
|
||||
...values,
|
||||
secrets: values.secrets.map((secret) => ({
|
||||
kind: secret.kind,
|
||||
value: secret.value === "" ? null : secret.value,
|
||||
})),
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
showSuccessNotification({
|
||||
title: t("integration.page.edit.notification.success.title"),
|
||||
message: t("integration.page.edit.notification.success.message"),
|
||||
});
|
||||
void revalidatePathAction("/integrations").then(() =>
|
||||
router.push("/integrations"),
|
||||
);
|
||||
},
|
||||
onError: () => {
|
||||
showErrorNotification({
|
||||
title: t("integration.page.edit.notification.error.title"),
|
||||
message: t("integration.page.edit.notification.error.message"),
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={form.onSubmit((v) => void handleSubmit(v))}>
|
||||
<Stack>
|
||||
<TestConnectionNoticeAlert />
|
||||
|
||||
<TextInput
|
||||
label={t("integration.field.name.label")}
|
||||
{...form.getInputProps("name")}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label={t("integration.field.url.label")}
|
||||
{...form.getInputProps("url")}
|
||||
/>
|
||||
|
||||
<Fieldset legend={t("integration.secrets.title")}>
|
||||
<Stack gap="sm">
|
||||
{secretsKinds.map((kind, index) => (
|
||||
<SecretCard
|
||||
key={kind}
|
||||
secret={secretsMap.get(kind)!}
|
||||
onCancel={() =>
|
||||
new Promise((res) => {
|
||||
// When nothing changed, just close the secret card
|
||||
if (
|
||||
(form.values.secrets[index]?.value ?? "") ===
|
||||
(secretsMap.get(kind)?.value ?? "")
|
||||
) {
|
||||
return res(true);
|
||||
}
|
||||
modalEvents.openConfirmModal({
|
||||
title: t("integration.secrets.reset.title"),
|
||||
children: t("integration.secrets.reset.message"),
|
||||
onCancel: () => res(false),
|
||||
onConfirm: () => {
|
||||
form.setFieldValue(
|
||||
`secrets.${index}.value`,
|
||||
secretsMap.get(kind)!.value ?? "",
|
||||
);
|
||||
res(true);
|
||||
},
|
||||
});
|
||||
})
|
||||
}
|
||||
>
|
||||
<IntegrationSecretInput
|
||||
label={t(`integration.secrets.kind.${kind}.newLabel`)}
|
||||
key={kind}
|
||||
kind={kind}
|
||||
{...form.getInputProps(`secrets.${index}.value`)}
|
||||
/>
|
||||
</SecretCard>
|
||||
))}
|
||||
</Stack>
|
||||
</Fieldset>
|
||||
|
||||
<Group justify="space-between" align="center">
|
||||
<TestConnection
|
||||
isDirty={isDirty}
|
||||
removeDirty={removeDirty}
|
||||
integration={{
|
||||
id: integration.id,
|
||||
kind: integration.kind,
|
||||
...form.values,
|
||||
}}
|
||||
/>
|
||||
<Group>
|
||||
<Button variant="default" component={Link} href="/integrations">
|
||||
{t("common.action.backToOverview")}
|
||||
</Button>
|
||||
<Button type="submit" loading={isPending} disabled={isDirty}>
|
||||
{t("common.action.save")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
type FormType = Omit<z.infer<typeof validation.integration.update>, "id">;
|
||||
32
apps/nextjs/src/app/[locale]/integrations/edit/[id]/page.tsx
Normal file
32
apps/nextjs/src/app/[locale]/integrations/edit/[id]/page.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { getIntegrationName } from "@homarr/definitions";
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
import { Container, Group, Stack, Title } from "@homarr/ui";
|
||||
|
||||
import { api } from "~/trpc/server";
|
||||
import { IntegrationAvatar } from "../../_avatar";
|
||||
import { EditIntegrationForm } from "./_form";
|
||||
|
||||
interface EditIntegrationPageProps {
|
||||
params: { id: string };
|
||||
}
|
||||
|
||||
export default async function EditIntegrationPage({
|
||||
params,
|
||||
}: EditIntegrationPageProps) {
|
||||
const t = await getScopedI18n("integration.page.edit");
|
||||
const integration = await api.integration.byId.query({ id: params.id });
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<Stack>
|
||||
<Group align="center">
|
||||
<IntegrationAvatar kind={integration.kind} size="md" />
|
||||
<Title>
|
||||
{t("title", { name: getIntegrationName(integration.kind) })}
|
||||
</Title>
|
||||
</Group>
|
||||
<EditIntegrationForm integration={integration} />
|
||||
</Stack>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
59
apps/nextjs/src/app/[locale]/integrations/new/_dropdown.tsx
Normal file
59
apps/nextjs/src/app/[locale]/integrations/new/_dropdown.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
|
||||
import { getIntegrationName, integrationKinds } from "@homarr/definitions";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import {
|
||||
Group,
|
||||
IconSearch,
|
||||
Menu,
|
||||
ScrollArea,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
} from "@homarr/ui";
|
||||
|
||||
import { IntegrationAvatar } from "../_avatar";
|
||||
|
||||
export const IntegrationCreateDropdownContent = () => {
|
||||
const t = useI18n();
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
const filteredKinds = useMemo(() => {
|
||||
return integrationKinds.filter((kind) =>
|
||||
kind.includes(search.toLowerCase()),
|
||||
);
|
||||
}, [search]);
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<TextInput
|
||||
leftSection={<IconSearch stroke={1.5} size={20} />}
|
||||
placeholder={t("integration.page.list.search")}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
|
||||
{filteredKinds.length > 0 ? (
|
||||
<ScrollArea.Autosize mah={384}>
|
||||
{filteredKinds.map((kind) => (
|
||||
<Menu.Item
|
||||
component={Link}
|
||||
href={`/integrations/new?kind=${kind}`}
|
||||
key={kind}
|
||||
>
|
||||
<Group>
|
||||
<IntegrationAvatar kind={kind} size="sm" />
|
||||
<Text size="sm">{getIntegrationName(kind)}</Text>
|
||||
</Group>
|
||||
</Menu.Item>
|
||||
))}
|
||||
</ScrollArea.Autosize>
|
||||
) : (
|
||||
<Menu.Item disabled>{t("common.noResults")}</Menu.Item>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
137
apps/nextjs/src/app/[locale]/integrations/new/_form.tsx
Normal file
137
apps/nextjs/src/app/[locale]/integrations/new/_form.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import type { IntegrationKind } from "@homarr/definitions";
|
||||
import { getSecretKinds } from "@homarr/definitions";
|
||||
import { useForm, zodResolver } from "@homarr/form";
|
||||
import {
|
||||
showErrorNotification,
|
||||
showSuccessNotification,
|
||||
} from "@homarr/notifications";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { Button, Fieldset, Group, Stack, TextInput } from "@homarr/ui";
|
||||
import type { z } from "@homarr/validation";
|
||||
import { validation } from "@homarr/validation";
|
||||
|
||||
import { api } from "~/trpc/react";
|
||||
import { IntegrationSecretInput } from "../_secret-inputs";
|
||||
import {
|
||||
TestConnection,
|
||||
TestConnectionNoticeAlert,
|
||||
useTestConnectionDirty,
|
||||
} from "../_test-connection";
|
||||
import { revalidatePathAction } from "../../../revalidatePathAction";
|
||||
|
||||
interface NewIntegrationFormProps {
|
||||
searchParams: Partial<z.infer<typeof validation.integration.create>> & {
|
||||
kind: IntegrationKind;
|
||||
};
|
||||
}
|
||||
|
||||
export const NewIntegrationForm = ({
|
||||
searchParams,
|
||||
}: NewIntegrationFormProps) => {
|
||||
const t = useI18n();
|
||||
const secretKinds = getSecretKinds(searchParams.kind);
|
||||
const initialFormValues = {
|
||||
name: searchParams.name ?? "",
|
||||
url: searchParams.url ?? "",
|
||||
secrets: secretKinds.map((kind) => ({
|
||||
kind,
|
||||
value: "",
|
||||
})),
|
||||
};
|
||||
const { isDirty, onValuesChange, removeDirty } = useTestConnectionDirty({
|
||||
defaultDirty: true,
|
||||
initialFormValue: initialFormValues,
|
||||
});
|
||||
const router = useRouter();
|
||||
const form = useForm<FormType>({
|
||||
initialValues: initialFormValues,
|
||||
validate: zodResolver(validation.integration.create.omit({ kind: true })),
|
||||
onValuesChange,
|
||||
});
|
||||
const { mutateAsync, isPending } = api.integration.create.useMutation();
|
||||
|
||||
const handleSubmit = async (values: FormType) => {
|
||||
if (isDirty) return;
|
||||
await mutateAsync(
|
||||
{
|
||||
kind: searchParams.kind,
|
||||
...values,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
showSuccessNotification({
|
||||
title: t("integration.page.create.notification.success.title"),
|
||||
message: t("integration.page.create.notification.success.message"),
|
||||
});
|
||||
void revalidatePathAction("/integrations").then(() =>
|
||||
router.push("/integrations"),
|
||||
);
|
||||
},
|
||||
onError: () => {
|
||||
showErrorNotification({
|
||||
title: t("integration.page.create.notification.error.title"),
|
||||
message: t("integration.page.create.notification.error.message"),
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={form.onSubmit((value) => void handleSubmit(value))}>
|
||||
<Stack>
|
||||
<TestConnectionNoticeAlert />
|
||||
|
||||
<TextInput
|
||||
label={t("integration.field.name.label")}
|
||||
{...form.getInputProps("name")}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label={t("integration.field.url.label")}
|
||||
{...form.getInputProps("url")}
|
||||
/>
|
||||
|
||||
<Fieldset legend={t("integration.secrets.title")}>
|
||||
<Stack gap="sm">
|
||||
{secretKinds.map((kind, index) => (
|
||||
<IntegrationSecretInput
|
||||
key={kind}
|
||||
kind={kind}
|
||||
{...form.getInputProps(`secrets.${index}.value`)}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</Fieldset>
|
||||
|
||||
<Group justify="space-between" align="center">
|
||||
<TestConnection
|
||||
isDirty={isDirty}
|
||||
removeDirty={removeDirty}
|
||||
integration={{
|
||||
id: null,
|
||||
kind: searchParams.kind,
|
||||
...form.values,
|
||||
}}
|
||||
/>
|
||||
|
||||
<Group>
|
||||
<Button variant="default" component={Link} href="/integrations">
|
||||
{t("common.action.backToOverview")}
|
||||
</Button>
|
||||
<Button type="submit" loading={isPending} disabled={isDirty}>
|
||||
{t("common.action.create")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
type FormType = Omit<z.infer<typeof validation.integration.create>, "kind">;
|
||||
44
apps/nextjs/src/app/[locale]/integrations/new/page.tsx
Normal file
44
apps/nextjs/src/app/[locale]/integrations/new/page.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
import type { IntegrationKind } from "@homarr/definitions";
|
||||
import { getIntegrationName, integrationKinds } from "@homarr/definitions";
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
import { Container, Group, Stack, Title } from "@homarr/ui";
|
||||
import type { validation } from "@homarr/validation";
|
||||
import { z } from "@homarr/validation";
|
||||
|
||||
import { IntegrationAvatar } from "../_avatar";
|
||||
import { NewIntegrationForm } from "./_form";
|
||||
|
||||
interface NewIntegrationPageProps {
|
||||
searchParams: Partial<z.infer<typeof validation.integration.create>> & {
|
||||
kind: IntegrationKind;
|
||||
};
|
||||
}
|
||||
|
||||
export default async function IntegrationsNewPage({
|
||||
searchParams,
|
||||
}: NewIntegrationPageProps) {
|
||||
const result = z
|
||||
.enum([integrationKinds[0]!, ...integrationKinds.slice(1)])
|
||||
.safeParse(searchParams.kind);
|
||||
if (!result.success) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const t = await getScopedI18n("integration.page.create");
|
||||
|
||||
const currentKind = result.data;
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<Stack>
|
||||
<Group align="center">
|
||||
<IntegrationAvatar kind={currentKind} size="md" />
|
||||
<Title>{t("title", { name: getIntegrationName(currentKind) })}</Title>
|
||||
</Group>
|
||||
<NewIntegrationForm searchParams={searchParams} />
|
||||
</Stack>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
175
apps/nextjs/src/app/[locale]/integrations/page.tsx
Normal file
175
apps/nextjs/src/app/[locale]/integrations/page.tsx
Normal file
@@ -0,0 +1,175 @@
|
||||
import Link from "next/link";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { objectEntries } from "@homarr/common";
|
||||
import { getIntegrationName } from "@homarr/definitions";
|
||||
import type { IntegrationKind } from "@homarr/definitions";
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
import {
|
||||
AccordionControl,
|
||||
AccordionItem,
|
||||
AccordionPanel,
|
||||
ActionIcon,
|
||||
ActionIconGroup,
|
||||
Anchor,
|
||||
Button,
|
||||
Container,
|
||||
CountBadge,
|
||||
Group,
|
||||
IconChevronDown,
|
||||
IconPencil,
|
||||
Menu,
|
||||
MenuDropdown,
|
||||
MenuTarget,
|
||||
Stack,
|
||||
Table,
|
||||
TableTbody,
|
||||
TableTd,
|
||||
TableTh,
|
||||
TableThead,
|
||||
TableTr,
|
||||
Text,
|
||||
Title,
|
||||
} from "@homarr/ui";
|
||||
|
||||
import { api } from "~/trpc/server";
|
||||
import { IntegrationGroupAccordion } from "./_accordion";
|
||||
import { IntegrationAvatar } from "./_avatar";
|
||||
import { DeleteIntegrationActionButton } from "./_buttons";
|
||||
import { IntegrationCreateDropdownContent } from "./new/_dropdown";
|
||||
|
||||
interface IntegrationsPageProps {
|
||||
searchParams: {
|
||||
tab?: IntegrationKind;
|
||||
};
|
||||
}
|
||||
|
||||
export default async function IntegrationsPage({
|
||||
searchParams,
|
||||
}: IntegrationsPageProps) {
|
||||
const integrations = await api.integration.all.query();
|
||||
const t = await getScopedI18n("integration");
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<Stack>
|
||||
<Group justify="space-between" align="center">
|
||||
<Title>{t("page.list.title")}</Title>
|
||||
<Menu
|
||||
width={256}
|
||||
trapFocus
|
||||
position="bottom-start"
|
||||
withinPortal
|
||||
shadow="md"
|
||||
keepMounted={false}
|
||||
>
|
||||
<MenuTarget>
|
||||
<Button rightSection={<IconChevronDown size={16} stroke={1.5} />}>
|
||||
{t("action.create")}
|
||||
</Button>
|
||||
</MenuTarget>
|
||||
<MenuDropdown>
|
||||
<IntegrationCreateDropdownContent />
|
||||
</MenuDropdown>
|
||||
</Menu>
|
||||
</Group>
|
||||
|
||||
<IntegrationList
|
||||
integrations={integrations}
|
||||
activeTab={searchParams.tab}
|
||||
/>
|
||||
</Stack>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
interface IntegrationListProps {
|
||||
integrations: RouterOutputs["integration"]["all"];
|
||||
activeTab?: IntegrationKind;
|
||||
}
|
||||
|
||||
const IntegrationList = async ({
|
||||
integrations,
|
||||
activeTab,
|
||||
}: IntegrationListProps) => {
|
||||
const t = await getScopedI18n("integration");
|
||||
|
||||
if (integrations.length === 0) {
|
||||
return <div>{t("page.list.empty")}</div>;
|
||||
}
|
||||
|
||||
const grouppedIntegrations = integrations.reduce(
|
||||
(acc, integration) => {
|
||||
if (!acc[integration.kind]) {
|
||||
acc[integration.kind] = [];
|
||||
}
|
||||
|
||||
acc[integration.kind].push(integration);
|
||||
|
||||
return acc;
|
||||
},
|
||||
{} as Record<IntegrationKind, RouterOutputs["integration"]["all"]>,
|
||||
);
|
||||
|
||||
return (
|
||||
<IntegrationGroupAccordion activeTab={activeTab}>
|
||||
{objectEntries(grouppedIntegrations).map(([kind, integrations]) => (
|
||||
<AccordionItem key={kind} value={kind}>
|
||||
<AccordionControl icon={<IntegrationAvatar size="sm" kind={kind} />}>
|
||||
<Group>
|
||||
<Text>{getIntegrationName(kind)}</Text>
|
||||
<CountBadge count={integrations.length} />
|
||||
</Group>
|
||||
</AccordionControl>
|
||||
<AccordionPanel>
|
||||
<Table>
|
||||
<TableThead>
|
||||
<TableTr>
|
||||
<TableTh>{t("field.name.label")}</TableTh>
|
||||
<TableTh>{t("field.url.label")}</TableTh>
|
||||
<TableTh />
|
||||
</TableTr>
|
||||
</TableThead>
|
||||
<TableTbody>
|
||||
{integrations.map((integration) => (
|
||||
<TableTr key={integration.id}>
|
||||
<TableTd>{integration.name}</TableTd>
|
||||
<TableTd>
|
||||
<Anchor
|
||||
href={integration.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
size="sm"
|
||||
>
|
||||
{integration.url}
|
||||
</Anchor>
|
||||
</TableTd>
|
||||
<TableTd>
|
||||
<Group justify="end">
|
||||
<ActionIconGroup>
|
||||
<ActionIcon
|
||||
component={Link}
|
||||
href={`/integrations/edit/${integration.id}`}
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
aria-label="Edit integration"
|
||||
>
|
||||
<IconPencil size={16} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
<DeleteIntegrationActionButton
|
||||
integration={integration}
|
||||
count={integrations.length}
|
||||
/>
|
||||
</ActionIconGroup>
|
||||
</Group>
|
||||
</TableTd>
|
||||
</TableTr>
|
||||
))}
|
||||
</TableTbody>
|
||||
</Table>
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
))}
|
||||
</IntegrationGroupAccordion>
|
||||
);
|
||||
};
|
||||
@@ -27,7 +27,8 @@ const handler = auth(async (req) => {
|
||||
endpoint: "/api/trpc",
|
||||
router: appRouter,
|
||||
req,
|
||||
createContext: () => createTRPCContext({ auth: req.auth, req }),
|
||||
createContext: () =>
|
||||
createTRPCContext({ auth: req.auth, headers: req.headers }),
|
||||
onError({ error, path }) {
|
||||
console.error(`>>> tRPC Error on '${path}'`, error);
|
||||
},
|
||||
|
||||
7
apps/nextjs/src/app/revalidatePathAction.ts
Normal file
7
apps/nextjs/src/app/revalidatePathAction.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
|
||||
export async function revalidatePathAction(path: string) {
|
||||
return new Promise((resolve) => resolve(revalidatePath(path, "page")));
|
||||
}
|
||||
61
apps/nextjs/src/trpc/server.ts
Normal file
61
apps/nextjs/src/trpc/server.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { cache } from "react";
|
||||
import { headers } from "next/headers";
|
||||
import { createTRPCClient, loggerLink, TRPCClientError } from "@trpc/client";
|
||||
import { callProcedure } from "@trpc/server";
|
||||
import { observable } from "@trpc/server/observable";
|
||||
import type { TRPCErrorResponse } from "@trpc/server/rpc";
|
||||
import SuperJSON from "superjson";
|
||||
|
||||
import { appRouter, createTRPCContext } from "@homarr/api";
|
||||
import { auth } from "@homarr/auth";
|
||||
|
||||
/**
|
||||
* This wraps the `createTRPCContext` helper and provides the required context for the tRPC API when
|
||||
* handling a tRPC call from a React Server Component.
|
||||
*/
|
||||
const createContext = cache(async () => {
|
||||
const heads = new Headers(headers());
|
||||
heads.set("x-trpc-source", "rsc");
|
||||
|
||||
return createTRPCContext({
|
||||
auth: await auth(),
|
||||
headers: heads,
|
||||
});
|
||||
});
|
||||
|
||||
export const api = createTRPCClient<typeof appRouter>({
|
||||
transformer: SuperJSON,
|
||||
links: [
|
||||
loggerLink({
|
||||
enabled: (op) =>
|
||||
process.env.NODE_ENV === "development" ||
|
||||
(op.direction === "down" && op.result instanceof Error),
|
||||
}),
|
||||
/**
|
||||
* Custom RSC link that invokes procedures directly in the server component Don't be too afraid
|
||||
* about the complexity here, it's just wrapping `callProcedure` with an observable to make it a
|
||||
* valid ending link for tRPC.
|
||||
*/
|
||||
() =>
|
||||
({ op }) =>
|
||||
observable((observer) => {
|
||||
createContext()
|
||||
.then((ctx) => {
|
||||
return callProcedure({
|
||||
procedures: appRouter._def.procedures,
|
||||
path: op.path,
|
||||
getRawInput: () => Promise.resolve(op.input),
|
||||
ctx,
|
||||
type: op.type,
|
||||
});
|
||||
})
|
||||
.then((data) => {
|
||||
observer.next({ result: { data } });
|
||||
observer.complete();
|
||||
})
|
||||
.catch((cause: TRPCErrorResponse) => {
|
||||
observer.error(TRPCClientError.from(cause));
|
||||
});
|
||||
}),
|
||||
],
|
||||
});
|
||||
Reference in New Issue
Block a user