feat: add support for multiple integration kind options (#127)

* feat: add support for multiple integration kind options

* fix: deepsource issue JS-0417 missing use callback
This commit is contained in:
Meier Lukas
2024-02-18 14:24:07 +01:00
committed by GitHub
parent 1e414af57c
commit f1aa422614
4 changed files with 110 additions and 37 deletions

View File

@@ -5,7 +5,10 @@ import { useRouter } from "next/navigation";
import type { RouterOutputs } from "@homarr/api"; import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { getSecretKinds } from "@homarr/definitions"; import {
getAllSecretKindOptions,
getDefaultSecretKinds,
} from "@homarr/definitions";
import { useForm, zodResolver } from "@homarr/form"; import { useForm, zodResolver } from "@homarr/form";
import { import {
showErrorNotification, showErrorNotification,
@@ -32,7 +35,10 @@ interface EditIntegrationForm {
export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => { export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => {
const t = useI18n(); const t = useI18n();
const secretsKinds = getSecretKinds(integration.kind); const secretsKinds =
getAllSecretKindOptions(integration.kind).find((x) =>
integration.secrets.every((y) => x.includes(y.kind)),
) ?? getDefaultSecretKinds(integration.kind);
const initialFormValues = { const initialFormValues = {
name: integration.name, name: integration.name,
url: integration.url, url: integration.url,

View File

@@ -1,18 +1,30 @@
"use client"; "use client";
import { useCallback } from "react";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import type { IntegrationKind } from "@homarr/definitions"; import type {
import { getSecretKinds } from "@homarr/definitions"; IntegrationKind,
IntegrationSecretKind,
} from "@homarr/definitions";
import { getAllSecretKindOptions } from "@homarr/definitions";
import type { UseFormReturnType } from "@homarr/form";
import { useForm, zodResolver } from "@homarr/form"; import { useForm, zodResolver } from "@homarr/form";
import { import {
showErrorNotification, showErrorNotification,
showSuccessNotification, showSuccessNotification,
} from "@homarr/notifications"; } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client"; import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { Button, Fieldset, Group, Stack, TextInput } from "@homarr/ui"; import {
Button,
Fieldset,
Group,
SegmentedControl,
Stack,
TextInput,
} from "@homarr/ui";
import type { z } from "@homarr/validation"; import type { z } from "@homarr/validation";
import { validation } from "@homarr/validation"; import { validation } from "@homarr/validation";
@@ -34,11 +46,11 @@ export const NewIntegrationForm = ({
searchParams, searchParams,
}: NewIntegrationFormProps) => { }: NewIntegrationFormProps) => {
const t = useI18n(); const t = useI18n();
const secretKinds = getSecretKinds(searchParams.kind); const secretKinds = getAllSecretKindOptions(searchParams.kind);
const initialFormValues = { const initialFormValues = {
name: searchParams.name ?? "", name: searchParams.name ?? "",
url: searchParams.url ?? "", url: searchParams.url ?? "",
secrets: secretKinds.map((kind) => ({ secrets: secretKinds[0].map((kind) => ({
kind, kind,
value: "", value: "",
})), })),
@@ -99,7 +111,13 @@ export const NewIntegrationForm = ({
<Fieldset legend={t("integration.secrets.title")}> <Fieldset legend={t("integration.secrets.title")}>
<Stack gap="sm"> <Stack gap="sm">
{secretKinds.map((kind, index) => ( {secretKinds.length > 1 && (
<SecretKindsSegmentedControl
secretKinds={secretKinds}
form={form}
/>
)}
{form.values.secrets.map(({ kind }, index) => (
<IntegrationSecretInput <IntegrationSecretInput
key={kind} key={kind}
kind={kind} kind={kind}
@@ -134,4 +152,41 @@ export const NewIntegrationForm = ({
); );
}; };
interface SecretKindsSegmentedControlProps {
secretKinds: IntegrationSecretKind[][];
form: UseFormReturnType<FormType, (values: FormType) => FormType>;
}
const SecretKindsSegmentedControl = ({
secretKinds,
form,
}: SecretKindsSegmentedControlProps) => {
const t = useScopedI18n("integration.secrets");
const secretKindGroups = secretKinds.map((kinds) => ({
label: kinds.map((kind) => t(`kind.${kind}.label`)).join(" & "),
value: kinds.join("-"),
}));
const onChange = useCallback(
(value: string) => {
const kinds = value.split("-") as IntegrationSecretKind[];
const secrets = kinds.map((kind) => ({
kind,
value: "",
}));
form.setFieldValue("secrets", secrets);
},
[form],
);
return (
<SegmentedControl
fullWidth
data={secretKindGroups}
onChange={onChange}
></SegmentedControl>
);
};
type FormType = Omit<z.infer<typeof validation.integration.create>, "kind">; type FormType = Omit<z.infer<typeof validation.integration.create>, "kind">;

View File

@@ -6,7 +6,7 @@ import { and, createId, eq } from "@homarr/db";
import { integrations, integrationSecrets } from "@homarr/db/schema/sqlite"; import { integrations, integrationSecrets } from "@homarr/db/schema/sqlite";
import type { IntegrationSecretKind } from "@homarr/definitions"; import type { IntegrationSecretKind } from "@homarr/definitions";
import { import {
getSecretKinds, getAllSecretKindOptions,
integrationKinds, integrationKinds,
integrationSecretKindObject, integrationSecretKindObject,
} from "@homarr/definitions"; } from "@homarr/definitions";
@@ -165,22 +165,27 @@ export const integrationRouter = createTRPCRouter({
testConnection: publicProcedure testConnection: publicProcedure
.input(validation.integration.testConnection) .input(validation.integration.testConnection)
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const secretKinds = getSecretKinds(input.kind);
const secrets = input.secrets.filter( const secrets = input.secrets.filter(
(secret): secret is { kind: IntegrationSecretKind; value: string } => (secret): secret is { kind: IntegrationSecretKind; value: string } =>
Boolean(secret.value), Boolean(secret.value),
); );
const everyInputSecretDefined = secretKinds.every((secretKind) =>
secrets.some((secret) => secret.kind === secretKind), // Find any matching secret kinds
let secretKinds = getAllSecretKindOptions(input.kind).find(
(secretKinds) =>
secretKinds.every((secretKind) =>
secrets.some((secret) => secret.kind === secretKind),
),
); );
if (!everyInputSecretDefined && input.id === null) {
if (!secretKinds && input.id === null) {
throw new TRPCError({ throw new TRPCError({
code: "BAD_REQUEST", code: "BAD_REQUEST",
message: "SECRETS_NOT_DEFINED", message: "SECRETS_NOT_DEFINED",
}); });
} }
if (!everyInputSecretDefined && input.id !== null) { if (!secretKinds && input.id !== null) {
const integration = await ctx.db.query.integrations.findFirst({ const integration = await ctx.db.query.integrations.findFirst({
where: eq(integrations.id, input.id), where: eq(integrations.id, input.id),
with: { with: {
@@ -208,11 +213,13 @@ export const integrationRouter = createTRPCRouter({
} }
} }
const everySecretDefined = secretKinds.every((secretKind) => secretKinds = getAllSecretKindOptions(input.kind).find((secretKinds) =>
secrets.some((secret) => secret.kind === secretKind), secretKinds.every((secretKind) =>
secrets.some((secret) => secret.kind === secretKind),
),
); );
if (!everySecretDefined) { if (!secretKinds) {
throw new TRPCError({ throw new TRPCError({
code: "BAD_REQUEST", code: "BAD_REQUEST",
message: "SECRETS_NOT_DEFINED", message: "SECRETS_NOT_DEFINED",

View File

@@ -11,112 +11,112 @@ export const integrationSecretKinds = objectKeys(integrationSecretKindObject);
export const integrationDefs = { export const integrationDefs = {
sabNzbd: { sabNzbd: {
name: "SABnzbd", name: "SABnzbd",
secretKinds: ["apiKey"], secretKinds: [["apiKey"]],
iconUrl: iconUrl:
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/sabnzbd.png", "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/sabnzbd.png",
category: ["useNetClient"], category: ["useNetClient"],
}, },
nzbGet: { nzbGet: {
name: "NZBGet", name: "NZBGet",
secretKinds: ["username", "password"], secretKinds: [["username", "password"]],
iconUrl: iconUrl:
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/nzbget.png", "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/nzbget.png",
category: ["useNetClient"], category: ["useNetClient"],
}, },
deluge: { deluge: {
name: "Deluge", name: "Deluge",
secretKinds: ["password"], secretKinds: [["password"]],
iconUrl: iconUrl:
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/deluge.png", "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/deluge.png",
category: ["downloadClient"], category: ["downloadClient"],
}, },
transmission: { transmission: {
name: "Transmission", name: "Transmission",
secretKinds: ["username", "password"], secretKinds: [["username", "password"]],
iconUrl: iconUrl:
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/transmission.png", "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/transmission.png",
category: ["downloadClient"], category: ["downloadClient"],
}, },
qBittorrent: { qBittorrent: {
name: "qBittorrent", name: "qBittorrent",
secretKinds: ["username", "password"], secretKinds: [["username", "password"]],
iconUrl: iconUrl:
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/qbittorrent.png", "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/qbittorrent.png",
category: ["downloadClient"], category: ["downloadClient"],
}, },
sonarr: { sonarr: {
name: "Sonarr", name: "Sonarr",
secretKinds: ["apiKey"], secretKinds: [["apiKey"]],
iconUrl: iconUrl:
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/sonarr.png", "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/sonarr.png",
category: ["calendar"], category: ["calendar"],
}, },
radarr: { radarr: {
name: "Radarr", name: "Radarr",
secretKinds: ["apiKey"], secretKinds: [["apiKey"]],
iconUrl: iconUrl:
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/radarr.png", "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/radarr.png",
category: ["calendar"], category: ["calendar"],
}, },
lidarr: { lidarr: {
name: "Lidarr", name: "Lidarr",
secretKinds: ["apiKey"], secretKinds: [["apiKey"]],
iconUrl: iconUrl:
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/lidarr.png", "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/lidarr.png",
category: ["calendar"], category: ["calendar"],
}, },
readarr: { readarr: {
name: "Readarr", name: "Readarr",
secretKinds: ["apiKey"], secretKinds: [["apiKey"]],
iconUrl: iconUrl:
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/readarr.png", "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/readarr.png",
category: ["calendar"], category: ["calendar"],
}, },
jellyfin: { jellyfin: {
name: "Jellyfin", name: "Jellyfin",
secretKinds: ["username", "password"], secretKinds: [["username", "password"], ["apiKey"]],
iconUrl: iconUrl:
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/jellyfin.png", "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/jellyfin.png",
category: ["mediaService"], category: ["mediaService"],
}, },
plex: { plex: {
name: "Plex", name: "Plex",
secretKinds: ["apiKey"], secretKinds: [["apiKey"]],
iconUrl: iconUrl:
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/plex.png", "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/plex.png",
category: ["mediaService"], category: ["mediaService"],
}, },
jellyseerr: { jellyseerr: {
name: "Jellyseerr", name: "Jellyseerr",
secretKinds: ["apiKey"], secretKinds: [["apiKey"]],
iconUrl: iconUrl:
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/jellyseerr.png", "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/jellyseerr.png",
category: ["mediaSearch", "mediaRequest"], category: ["mediaSearch", "mediaRequest"],
}, },
overseerr: { overseerr: {
name: "Overseerr", name: "Overseerr",
secretKinds: ["apiKey"], secretKinds: [["apiKey"]],
iconUrl: iconUrl:
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/overseerr.png", "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/overseerr.png",
category: ["mediaSearch", "mediaRequest"], category: ["mediaSearch", "mediaRequest"],
}, },
piHole: { piHole: {
name: "Pi-hole", name: "Pi-hole",
secretKinds: ["apiKey"], secretKinds: [["apiKey"]],
iconUrl: iconUrl:
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/pi-hole.png", "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/pi-hole.png",
category: ["dnsHole"], category: ["dnsHole"],
}, },
adGuardHome: { adGuardHome: {
name: "AdGuard Home", name: "AdGuard Home",
secretKinds: ["username", "password"], secretKinds: [["username", "password"]],
iconUrl: iconUrl:
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/adguard-home.png", "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/adguard-home.png",
category: ["dnsHole"], category: ["dnsHole"],
}, },
homeAssistant: { homeAssistant: {
name: "Home Assistant", name: "Home Assistant",
secretKinds: ["apiKey"], secretKinds: [["apiKey"]],
iconUrl: iconUrl:
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/home-assistant.png", "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/home-assistant.png",
category: [], category: [],
@@ -126,7 +126,7 @@ export const integrationDefs = {
{ {
name: string; name: string;
iconUrl: string; iconUrl: string;
secretKinds: IntegrationSecretKind[]; secretKinds: [IntegrationSecretKind[], ...IntegrationSecretKind[][]]; // at least one secret kind set is required
category: IntegrationCategory[]; category: IntegrationCategory[];
} }
>; >;
@@ -137,9 +137,14 @@ export const getIconUrl = (integration: IntegrationKind) =>
export const getIntegrationName = (integration: IntegrationKind) => export const getIntegrationName = (integration: IntegrationKind) =>
integrationDefs[integration].name; integrationDefs[integration].name;
export const getSecretKinds = ( export const getDefaultSecretKinds = (
integration: IntegrationKind, integration: IntegrationKind,
): IntegrationSecretKind[] => integrationDefs[integration]?.secretKinds ?? null; ): IntegrationSecretKind[] => integrationDefs[integration]?.secretKinds[0];
export const getAllSecretKindOptions = (
integration: IntegrationKind,
): [IntegrationSecretKind[], ...IntegrationSecretKind[][]] =>
integrationDefs[integration]?.secretKinds;
export const integrationKinds = objectKeys(integrationDefs); export const integrationKinds = objectKeys(integrationDefs);