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:
Meier Lukas
2024-01-02 17:12:26 +01:00
committed by GitHub
parent 2809e01b03
commit 367beb6759
52 changed files with 2164 additions and 23 deletions

View File

@@ -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>
);
};

View File

@@ -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

View File

@@ -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();

View 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>
);
};

View 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} />;
};

View 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>
);
};

View 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>
);
};

View 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
>;

View 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} />}
/>
);
};

View 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>
);
};

View 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">;

View 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>
);
}

View 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>
);
};

View 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">;

View 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>
);
}

View 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>
);
};

View File

@@ -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);
},

View 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")));
}

View 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));
});
}),
],
});