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:
@@ -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,
|
||||||
|
|||||||
@@ -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">;
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user