From f1aa42261446bcc4bd16b47ed0c1d5cd279a2e48 Mon Sep 17 00:00:00 2001 From: Meier Lukas Date: Sun, 18 Feb 2024 14:24:07 +0100 Subject: [PATCH] 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 --- .../edit/[id]/_integration-edit-form.tsx | 10 ++- .../new/_integration-new-form.tsx | 69 +++++++++++++++++-- packages/api/src/router/integration.ts | 25 ++++--- packages/definitions/src/integration.ts | 43 +++++++----- 4 files changed, 110 insertions(+), 37 deletions(-) diff --git a/apps/nextjs/src/app/[locale]/(main)/integrations/edit/[id]/_integration-edit-form.tsx b/apps/nextjs/src/app/[locale]/(main)/integrations/edit/[id]/_integration-edit-form.tsx index 1f55d6635..50670d878 100644 --- a/apps/nextjs/src/app/[locale]/(main)/integrations/edit/[id]/_integration-edit-form.tsx +++ b/apps/nextjs/src/app/[locale]/(main)/integrations/edit/[id]/_integration-edit-form.tsx @@ -5,7 +5,10 @@ import { useRouter } from "next/navigation"; import type { RouterOutputs } from "@homarr/api"; import { clientApi } from "@homarr/api/client"; -import { getSecretKinds } from "@homarr/definitions"; +import { + getAllSecretKindOptions, + getDefaultSecretKinds, +} from "@homarr/definitions"; import { useForm, zodResolver } from "@homarr/form"; import { showErrorNotification, @@ -32,7 +35,10 @@ interface EditIntegrationForm { export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => { 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 = { name: integration.name, url: integration.url, diff --git a/apps/nextjs/src/app/[locale]/(main)/integrations/new/_integration-new-form.tsx b/apps/nextjs/src/app/[locale]/(main)/integrations/new/_integration-new-form.tsx index c5fad467c..701a0b741 100644 --- a/apps/nextjs/src/app/[locale]/(main)/integrations/new/_integration-new-form.tsx +++ b/apps/nextjs/src/app/[locale]/(main)/integrations/new/_integration-new-form.tsx @@ -1,18 +1,30 @@ "use client"; +import { useCallback } from "react"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { clientApi } from "@homarr/api/client"; -import type { IntegrationKind } from "@homarr/definitions"; -import { getSecretKinds } from "@homarr/definitions"; +import type { + IntegrationKind, + IntegrationSecretKind, +} from "@homarr/definitions"; +import { getAllSecretKindOptions } from "@homarr/definitions"; +import type { UseFormReturnType } from "@homarr/form"; 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 { useI18n, useScopedI18n } from "@homarr/translation/client"; +import { + Button, + Fieldset, + Group, + SegmentedControl, + Stack, + TextInput, +} from "@homarr/ui"; import type { z } from "@homarr/validation"; import { validation } from "@homarr/validation"; @@ -34,11 +46,11 @@ export const NewIntegrationForm = ({ searchParams, }: NewIntegrationFormProps) => { const t = useI18n(); - const secretKinds = getSecretKinds(searchParams.kind); + const secretKinds = getAllSecretKindOptions(searchParams.kind); const initialFormValues = { name: searchParams.name ?? "", url: searchParams.url ?? "", - secrets: secretKinds.map((kind) => ({ + secrets: secretKinds[0].map((kind) => ({ kind, value: "", })), @@ -99,7 +111,13 @@ export const NewIntegrationForm = ({
- {secretKinds.map((kind, index) => ( + {secretKinds.length > 1 && ( + + )} + {form.values.secrets.map(({ kind }, index) => ( 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 ( + + ); +}; + type FormType = Omit, "kind">; diff --git a/packages/api/src/router/integration.ts b/packages/api/src/router/integration.ts index 1be00335a..b1e523fb5 100644 --- a/packages/api/src/router/integration.ts +++ b/packages/api/src/router/integration.ts @@ -6,7 +6,7 @@ import { and, createId, eq } from "@homarr/db"; import { integrations, integrationSecrets } from "@homarr/db/schema/sqlite"; import type { IntegrationSecretKind } from "@homarr/definitions"; import { - getSecretKinds, + getAllSecretKindOptions, integrationKinds, integrationSecretKindObject, } from "@homarr/definitions"; @@ -165,22 +165,27 @@ export const integrationRouter = createTRPCRouter({ testConnection: publicProcedure .input(validation.integration.testConnection) .mutation(async ({ ctx, input }) => { - const secretKinds = getSecretKinds(input.kind); const secrets = input.secrets.filter( (secret): secret is { kind: IntegrationSecretKind; value: string } => 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({ code: "BAD_REQUEST", message: "SECRETS_NOT_DEFINED", }); } - if (!everyInputSecretDefined && input.id !== null) { + if (!secretKinds && input.id !== null) { const integration = await ctx.db.query.integrations.findFirst({ where: eq(integrations.id, input.id), with: { @@ -208,11 +213,13 @@ export const integrationRouter = createTRPCRouter({ } } - const everySecretDefined = secretKinds.every((secretKind) => - secrets.some((secret) => secret.kind === secretKind), + secretKinds = getAllSecretKindOptions(input.kind).find((secretKinds) => + secretKinds.every((secretKind) => + secrets.some((secret) => secret.kind === secretKind), + ), ); - if (!everySecretDefined) { + if (!secretKinds) { throw new TRPCError({ code: "BAD_REQUEST", message: "SECRETS_NOT_DEFINED", diff --git a/packages/definitions/src/integration.ts b/packages/definitions/src/integration.ts index 4c33ceb85..6f2b5c58f 100644 --- a/packages/definitions/src/integration.ts +++ b/packages/definitions/src/integration.ts @@ -11,112 +11,112 @@ export const integrationSecretKinds = objectKeys(integrationSecretKindObject); export const integrationDefs = { sabNzbd: { name: "SABnzbd", - secretKinds: ["apiKey"], + secretKinds: [["apiKey"]], iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/sabnzbd.png", category: ["useNetClient"], }, nzbGet: { name: "NZBGet", - secretKinds: ["username", "password"], + secretKinds: [["username", "password"]], iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/nzbget.png", category: ["useNetClient"], }, deluge: { name: "Deluge", - secretKinds: ["password"], + secretKinds: [["password"]], iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/deluge.png", category: ["downloadClient"], }, transmission: { name: "Transmission", - secretKinds: ["username", "password"], + secretKinds: [["username", "password"]], iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/transmission.png", category: ["downloadClient"], }, qBittorrent: { name: "qBittorrent", - secretKinds: ["username", "password"], + secretKinds: [["username", "password"]], iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/qbittorrent.png", category: ["downloadClient"], }, sonarr: { name: "Sonarr", - secretKinds: ["apiKey"], + secretKinds: [["apiKey"]], iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/sonarr.png", category: ["calendar"], }, radarr: { name: "Radarr", - secretKinds: ["apiKey"], + secretKinds: [["apiKey"]], iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/radarr.png", category: ["calendar"], }, lidarr: { name: "Lidarr", - secretKinds: ["apiKey"], + secretKinds: [["apiKey"]], iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/lidarr.png", category: ["calendar"], }, readarr: { name: "Readarr", - secretKinds: ["apiKey"], + secretKinds: [["apiKey"]], iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/readarr.png", category: ["calendar"], }, jellyfin: { name: "Jellyfin", - secretKinds: ["username", "password"], + secretKinds: [["username", "password"], ["apiKey"]], iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/jellyfin.png", category: ["mediaService"], }, plex: { name: "Plex", - secretKinds: ["apiKey"], + secretKinds: [["apiKey"]], iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/plex.png", category: ["mediaService"], }, jellyseerr: { name: "Jellyseerr", - secretKinds: ["apiKey"], + secretKinds: [["apiKey"]], iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/jellyseerr.png", category: ["mediaSearch", "mediaRequest"], }, overseerr: { name: "Overseerr", - secretKinds: ["apiKey"], + secretKinds: [["apiKey"]], iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/overseerr.png", category: ["mediaSearch", "mediaRequest"], }, piHole: { name: "Pi-hole", - secretKinds: ["apiKey"], + secretKinds: [["apiKey"]], iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/pi-hole.png", category: ["dnsHole"], }, adGuardHome: { name: "AdGuard Home", - secretKinds: ["username", "password"], + secretKinds: [["username", "password"]], iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/adguard-home.png", category: ["dnsHole"], }, homeAssistant: { name: "Home Assistant", - secretKinds: ["apiKey"], + secretKinds: [["apiKey"]], iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/home-assistant.png", category: [], @@ -126,7 +126,7 @@ export const integrationDefs = { { name: string; iconUrl: string; - secretKinds: IntegrationSecretKind[]; + secretKinds: [IntegrationSecretKind[], ...IntegrationSecretKind[][]]; // at least one secret kind set is required category: IntegrationCategory[]; } >; @@ -137,9 +137,14 @@ export const getIconUrl = (integration: IntegrationKind) => export const getIntegrationName = (integration: IntegrationKind) => integrationDefs[integration].name; -export const getSecretKinds = ( +export const getDefaultSecretKinds = ( 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);