feat: test integration connection (#669)

* feat: add test integration for pi-hole

* refactor: test integration for pi-hole

* fix: multiple secrets of same type could be used for integration creation

* fix: remove integration test connection test and add mock for test-connection function

* fix: add missing onUpdateFn to mysql integration secrets

* fix: format issues

* feat: add home assistant test connection

* fix: deepsource issues

* test: add system integration tests for test connection

* fix: add before all for pulling home assistant image

* test: add unit tests for handleTestConnectionResponseAsync

* test: add unit test for testConnectionAsync

* test: add mroe tests to integration-test-connection

* fix: deepsource issues

* fix: deepsource issue

* chore: address pull request feedback
This commit is contained in:
Meier Lukas
2024-06-22 21:02:04 +02:00
committed by GitHub
parent 92afd82d22
commit f92aeba403
30 changed files with 1138 additions and 550 deletions

View File

@@ -12,7 +12,7 @@ import type { RouterOutputs } from "@homarr/api";
import { integrationSecretKindObject } from "@homarr/definitions";
import { useI18n } from "@homarr/translation/client";
import { integrationSecretIcons } from "./_integration-secret-icons";
import { integrationSecretIcons } from "./integration-secret-icons";
dayjs.extend(relativeTime);

View File

@@ -7,7 +7,7 @@ import { integrationSecretKindObject } from "@homarr/definitions";
import type { IntegrationSecretKind } from "@homarr/definitions";
import { useI18n } from "@homarr/translation/client";
import { integrationSecretIcons } from "./_integration-secret-icons";
import { integrationSecretIcons } from "./integration-secret-icons";
interface IntegrationSecretInputProps {
withAsterisk?: boolean;
@@ -50,7 +50,7 @@ const PrivateSecretInput = ({ kind, ...props }: IntegrationSecretInputProps) =>
<PasswordInput
{...props}
label={props.label ?? t(`integration.secrets.kind.${kind}.label`)}
description={t(`integration.secrets.secureNotice`)}
description={t("integration.secrets.secureNotice")}
w="100%"
leftSection={<Icon size={20} stroke={1.5} />}
/>

View File

@@ -1,129 +0,0 @@
"use client";
import { useRef, useState } from "react";
import { Alert, Anchor, Group, Loader } from "@mantine/core";
import { IconCheck, IconInfoCircle, IconX } from "@tabler/icons-react";
import type { RouterInputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
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 } = clientApi.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

@@ -8,6 +8,7 @@ import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { getAllSecretKindOptions, getDefaultSecretKinds } from "@homarr/definitions";
import { useZodForm } from "@homarr/form";
import { convertIntegrationTestConnectionError } from "@homarr/integrations/client";
import { useConfirmModal } from "@homarr/modals";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
@@ -15,9 +16,8 @@ import type { z } from "@homarr/validation";
import { validation } from "@homarr/validation";
import { revalidatePathActionAsync } from "~/app/revalidatePathAction";
import { SecretCard } from "../../_integration-secret-card";
import { IntegrationSecretInput } from "../../_integration-secret-inputs";
import { TestConnection, TestConnectionNoticeAlert, useTestConnectionDirty } from "../../_integration-test-connection";
import { SecretCard } from "../../_components/secrets/integration-secret-card";
import { IntegrationSecretInput } from "../../_components/secrets/integration-secret-inputs";
interface EditIntegrationForm {
integration: RouterOutputs["integration"]["byId"];
@@ -30,30 +30,23 @@ export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => {
getAllSecretKindOptions(integration.kind).find((secretKinds) =>
integration.secrets.every((secret) => secretKinds.includes(secret.kind)),
) ?? getDefaultSecretKinds(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 = useZodForm(validation.integration.update.omit({ id: true }), {
initialValues: initialFormValues,
onValuesChange,
initialValues: {
name: integration.name,
url: integration.url,
secrets: secretsKinds.map((kind) => ({
kind,
value: integration.secrets.find((secret) => secret.kind === kind)?.value ?? "",
})),
},
});
const { mutateAsync, isPending } = clientApi.integration.update.useMutation();
const secretsMap = new Map(integration.secrets.map((secret) => [secret.kind, secret]));
const handleSubmitAsync = async (values: FormType) => {
if (isDirty) return;
await mutateAsync(
{
id: integration.id,
@@ -71,7 +64,19 @@ export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => {
});
void revalidatePathActionAsync("/manage/integrations").then(() => router.push("/manage/integrations"));
},
onError: () => {
onError: (error) => {
const testConnectionError = convertIntegrationTestConnectionError(error.data?.error);
if (testConnectionError) {
showErrorNotification({
title: t(`integration.testConnection.notification.${testConnectionError.key}.title`),
message: testConnectionError.message
? testConnectionError.message
: t(`integration.testConnection.notification.${testConnectionError.key}.message`),
});
return;
}
showErrorNotification({
title: t("integration.page.edit.notification.error.title"),
message: t("integration.page.edit.notification.error.message"),
@@ -84,8 +89,6 @@ export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => {
return (
<form onSubmit={form.onSubmit((values) => void handleSubmitAsync(values))}>
<Stack>
<TestConnectionNoticeAlert />
<TextInput withAsterisk label={t("integration.field.name.label")} {...form.getInputProps("name")} />
<TextInput withAsterisk label={t("integration.field.url.label")} {...form.getInputProps("url")} />
@@ -98,18 +101,18 @@ export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
secret={secretsMap.get(kind)!}
onCancel={() =>
new Promise((res) => {
new Promise((resolve) => {
// When nothing changed, just close the secret card
if ((form.values.secrets[index]?.value ?? "") === (secretsMap.get(kind)?.value ?? "")) {
return res(true);
return resolve(true);
}
openConfirmModal({
title: t("integration.secrets.reset.title"),
children: t("integration.secrets.reset.message"),
onCancel: () => res(false),
onCancel: () => resolve(false),
onConfirm: () => {
form.setFieldValue(`secrets.${index}.value`, secretsMap.get(kind)?.value ?? "");
res(true);
resolve(true);
},
});
})
@@ -126,24 +129,13 @@ export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => {
</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="/manage/integrations">
{t("common.action.backToOverview")}
</Button>
<Button type="submit" loading={isPending} disabled={isDirty}>
{t("common.action.save")}
</Button>
</Group>
<Group justify="end" align="center">
<Button variant="default" component={Link} href="/manage/integrations">
{t("common.action.backToOverview")}
</Button>
<Button type="submit" loading={isPending}>
{t("integration.testConnection.action.edit")}
</Button>
</Group>
</Stack>
</form>

View File

@@ -10,13 +10,13 @@ import type { IntegrationKind, IntegrationSecretKind } from "@homarr/definitions
import { getAllSecretKindOptions } from "@homarr/definitions";
import type { UseFormReturnType } from "@homarr/form";
import { useZodForm } from "@homarr/form";
import { convertIntegrationTestConnectionError } from "@homarr/integrations/client";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import type { z } from "@homarr/validation";
import { validation } from "@homarr/validation";
import { IntegrationSecretInput } from "../_integration-secret-inputs";
import { TestConnection, TestConnectionNoticeAlert, useTestConnectionDirty } from "../_integration-test-connection";
import { IntegrationSecretInput } from "../_components/secrets/integration-secret-inputs";
import { revalidatePathActionAsync } from "../../../../revalidatePathAction";
interface NewIntegrationFormProps {
@@ -28,27 +28,20 @@ interface NewIntegrationFormProps {
export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) => {
const t = useI18n();
const secretKinds = getAllSecretKindOptions(searchParams.kind);
const initialFormValues = {
name: searchParams.name ?? "",
url: searchParams.url ?? "",
secrets: secretKinds[0].map((kind) => ({
kind,
value: "",
})),
};
const { isDirty, onValuesChange, removeDirty } = useTestConnectionDirty({
defaultDirty: true,
initialFormValue: initialFormValues,
});
const router = useRouter();
const form = useZodForm(validation.integration.create.omit({ kind: true }), {
initialValues: initialFormValues,
onValuesChange,
initialValues: {
name: searchParams.name ?? "",
url: searchParams.url ?? "",
secrets: secretKinds[0].map((kind) => ({
kind,
value: "",
})),
},
});
const { mutateAsync, isPending } = clientApi.integration.create.useMutation();
const handleSubmitAsync = async (values: FormType) => {
if (isDirty) return;
await mutateAsync(
{
kind: searchParams.kind,
@@ -62,7 +55,19 @@ export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) =>
});
void revalidatePathActionAsync("/manage/integrations").then(() => router.push("/manage/integrations"));
},
onError: () => {
onError: (error) => {
const testConnectionError = convertIntegrationTestConnectionError(error.data?.error);
if (testConnectionError) {
showErrorNotification({
title: t(`integration.testConnection.notification.${testConnectionError.key}.title`),
message: testConnectionError.message
? testConnectionError.message
: t(`integration.testConnection.notification.${testConnectionError.key}.message`),
});
return;
}
showErrorNotification({
title: t("integration.page.create.notification.error.title"),
message: t("integration.page.create.notification.error.message"),
@@ -75,8 +80,6 @@ export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) =>
return (
<form onSubmit={form.onSubmit((value) => void handleSubmitAsync(value))}>
<Stack>
<TestConnectionNoticeAlert />
<TextInput withAsterisk label={t("integration.field.name.label")} {...form.getInputProps("name")} />
<TextInput withAsterisk label={t("integration.field.url.label")} {...form.getInputProps("url")} />
@@ -95,25 +98,13 @@ export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) =>
</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="/manage/integrations">
{t("common.action.backToOverview")}
</Button>
<Button type="submit" loading={isPending} disabled={isDirty}>
{t("common.action.create")}
</Button>
</Group>
<Group justify="end" align="center">
<Button variant="default" component={Link} href="/manage/integrations">
{t("common.action.backToOverview")}
</Button>
<Button type="submit" loading={isPending}>
{t("integration.testConnection.action.create")}
</Button>
</Group>
</Stack>
</form>