Add the ability to add integrations

This commit is contained in:
ajnart
2023-07-03 14:13:05 +09:00
parent 0ae5bd480f
commit 8f97c854ff
6 changed files with 307 additions and 63 deletions

View File

@@ -0,0 +1,121 @@
import { Button, Group, Stack, TextInput, useMantineTheme } from '@mantine/core';
import { UseFormReturnType, useForm } from '@mantine/form';
import { notifications } from '@mantine/notifications';
import { QueryKey, useQueryClient } from '@tanstack/react-query';
import { getQueryKey } from '@trpc/react-query';
import { getCookie } from 'cookies-next';
import { produce } from 'immer';
import { useTranslation } from 'react-i18next';
import { v4 as uuidv4 } from 'uuid';
import { IntegrationTab } from '~/components/Dashboard/Modals/EditAppModal/Tabs/IntegrationTab/IntegrationTab';
import { AppType } from '~/types/app';
import { IntegrationTypeMap } from '~/types/config';
import { api } from '~/utils/api';
const defaultAppValues: AppType = {
id: uuidv4(),
name: 'Your app',
url: 'https://homarr.dev',
appearance: {
iconUrl: '/imgs/logo/logo.png',
},
network: {
enabledStatusChecker: true,
statusCodes: ['200', '301', '302', '304', '307', '308'],
okStatus: [200, 301, 302, 304, 307, 308],
},
behaviour: {
isOpeningNewTab: true,
externalUrl: '',
},
area: {
type: 'wrapper',
properties: {
id: 'default',
},
},
shape: {},
integration: {
id: uuidv4(),
url: '',
type: null,
properties: [],
name: 'New integration',
},
};
export function AddIntegrationPanel({
globalForm,
queryKey,
integrations,
setIntegrations,
}: {
globalForm: UseFormReturnType<any>;
queryKey: QueryKey;
integrations: IntegrationTypeMap | undefined;
setIntegrations: React.Dispatch<React.SetStateAction<IntegrationTypeMap | undefined>>;
}) {
const { t } = useTranslation(['settings/integrations', 'common']);
const queryClient = useQueryClient();
const { primaryColor } = useMantineTheme();
const form = useForm<AppType>({
initialValues: defaultAppValues,
});
if (!integrations) {
return null;
}
return (
<form
onSubmit={form.onSubmit(({ integration }) => {
if (!integration.type || !integrations) return null;
const newIntegrations = produce(integrations, (draft) => {
integration.id = uuidv4();
// console.log(integration.type);
if (!integration.type) return;
// If integration type is not in integrations, add it
if (!draft[integration.type]) {
draft[integration.type] = [];
}
draft[integration.type].push(integration);
});
// queryClient.setQueryData(queryKey, newIntegrations);
form.reset();
setIntegrations(newIntegrations);
notifications.show({
title: t('integration.Added'),
message: t('integration.AddedDescription', { name: integration.name }),
color: 'green',
});
})}
>
<Stack>
<Group grow>
<TextInput
withAsterisk
required
label={'URL'}
description={t('integration.urlDescription')}
placeholder="http://localhost:3039"
{...form.getInputProps('integration.url')}
/>
<TextInput
withAsterisk
required
label={t('integrationName')}
description={t('integrationNameDescription')}
placeholder="My integration"
{...form.getInputProps('integration.name')}
/>
</Group>
<IntegrationTab form={form} />
<Button type="submit" color={primaryColor} variant="light">
{t('common:add')}
</Button>
</Stack>
</form>
);
}

View File

@@ -1,5 +1,8 @@
import {
Accordion,
ActionIcon,
Button,
Group,
Image,
Loader,
Menu,
@@ -11,19 +14,36 @@ import {
TextInput,
Title,
rem,
useMantineTheme,
} from '@mantine/core';
import { AccordionItem } from '@mantine/core/lib/Accordion/AccordionItem/AccordionItem';
import { UseFormReturnType, useForm } from '@mantine/form';
import { notifications } from '@mantine/notifications';
import { IconLock, IconPlugConnected } from '@tabler/icons-react';
import {
IconCheck,
IconCircle0Filled,
IconCircleX,
IconCircleXFilled,
IconDeviceFloppy,
IconLock,
IconPlug,
IconPlugConnected,
IconPlus,
IconTrash,
} from '@tabler/icons-react';
import { useQueryClient } from '@tanstack/react-query';
import { getQueryKey } from '@trpc/react-query';
import { getCookie, setCookie } from 'cookies-next';
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { integrationsList } from '~/components/Dashboard/Modals/EditAppModal/Tabs/IntegrationTab/Components/InputElements/IntegrationSelector';
import { useConfigContext } from '~/config/provider';
import { AppIntegrationType, IntegrationType } from '~/types/app';
import { IntegrationTypeMap } from '~/types/config';
import { api } from '~/utils/api';
import { AddIntegrationPanel } from './AddIntegrationPanel';
const ModalTitle = ({ title, description }: { title: string; description: string }) => (
<div>
<Title order={3} style={{ marginBottom: 0 }}>
@@ -97,16 +117,31 @@ function IntegrationDisplay({
form: UseFormReturnType<any>;
}) {
if (!integration.type) return null;
const { t } = useTranslation('settings/integrations');
return (
<Accordion.Item value={integration.id}>
<Accordion.Item key={integration.id} value={integration.id}>
<Accordion.Control>{integration.name}</Accordion.Control>
<Accordion.Panel>
<Stack>
<TextInput
label="url"
{...form.getInputProps(`${integration.type}.${integrationIdx}.url`)}
/>
<Group grow>
<TextInput
withAsterisk
required
label={'URL'}
description={t('integration.urlDescription')}
placeholder="http://localhost:3039"
{...form.getInputProps(`${integration.type}.${integrationIdx}.url`)}
/>
<TextInput
withAsterisk
required
label={t('integrationName')}
description={t('integrationNameDescription')}
placeholder="My integration"
{...form.getInputProps(`${integration.type}.${integrationIdx}.name`)}
/>
</Group>
{integration.properties.map((property, idx) => {
if (property.type === 'private')
return (
@@ -156,61 +191,138 @@ export interface IntegrationObject {
[key: string]: AppIntegrationType;
}
export function IntegrationsAccordion() {
export function IntegrationsAccordion({ closeModal }: { closeModal: () => void }) {
const { t } = useTranslation('settings/integrations, common');
const cookie = getCookie('INTEGRATIONS_PASSWORD');
const queryClient = useQueryClient();
const { primaryColor } = useMantineTheme();
const queryKey = getQueryKey(api.system.checkLogin, { password: cookie?.toString() }, 'query');
let integrations: IntegrationTypeMap | undefined = queryClient.getQueryData(queryKey);
let integrationsQuery: IntegrationTypeMap | undefined = queryClient.getQueryData(queryKey);
const mutation = api.config.save.useMutation();
const { config, name } = useConfigContext();
const [isLoading, setIsLoading] = useState(false);
const [integrations, setIntegrations] = useState<IntegrationTypeMap | undefined>(
integrationsQuery
);
if (!integrations) {
return null;
}
const form = useForm({
initialValues: integrations,
initialValues: integrationsQuery,
});
// Loop over integrations item
useEffect(() => {
if (!integrations) return;
form.setValues(integrations);
}, [integrations]);
return (
<Accordion variant="separated" multiple>
{Object.keys(integrations).map((item) => {
if (!integrations) return null;
const configIntegrations = integrations[item as keyof IntegrationTypeMap];
const integrationListItem = integrationsList.find(
(integration) => integration.value === item
);
if (!configIntegrations || !integrationListItem) return null;
return (
<Accordion.Item value={integrationListItem.value} key={integrationListItem.value}>
<Accordion.Control
icon={
<Image
src={integrationListItem.image}
withPlaceholder
width={24}
height={24}
alt={integrationListItem.value}
/>
}
>
{integrationListItem.label}
</Accordion.Control>
<Accordion.Panel>
<Accordion variant="separated" radius="md" multiple>
{configIntegrations.map((integration, integrationIdx) => {
return (
<IntegrationDisplay
integrationIdx={integrationIdx}
form={form}
integration={integration}
/>
);
})}
</Accordion>
</Accordion.Panel>
</Accordion.Item>
);
})}
</Accordion>
<Stack>
<Accordion variant="separated" multiple>
{Object.keys(integrations).map((item) => {
if (!integrations) return null;
const configIntegrations = integrations[item as keyof IntegrationTypeMap];
const integrationListItem = integrationsList.find(
(integration) => integration.value === item
);
if (!configIntegrations || !integrationListItem) return null;
return (
<Accordion.Item value={integrationListItem.value} key={integrationListItem.value}>
<Accordion.Control
icon={
<Image
src={integrationListItem.image}
withPlaceholder
width={24}
height={24}
alt={integrationListItem.value}
/>
}
>
{integrationListItem.label}
</Accordion.Control>
<Accordion.Panel>
<Accordion variant="separated" radius="md" multiple>
{configIntegrations.map((integration, integrationIdx) => {
return (
<IntegrationDisplay
integrationIdx={integrationIdx}
form={form}
integration={integration}
/>
);
})}
</Accordion>
</Accordion.Panel>
</Accordion.Item>
);
})}
<Accordion.Item value="add-new">
<Accordion.Control
chevron={
<ActionIcon color={primaryColor} radius={'lg'} size={'md'} variant="light">
<IconPlus />
</ActionIcon>
}
icon={<IconPlug stroke={2} />}
>
{t('addNewIntegration')}
</Accordion.Control>
<Accordion.Panel>
<AddIntegrationPanel
globalForm={form}
queryKey={queryKey}
integrations={integrations}
setIntegrations={setIntegrations}
/>
</Accordion.Panel>
</Accordion.Item>
</Accordion>
<Group position="right">
<Button
type="submit"
variant="light"
color="red"
leftIcon={<IconCircleX />}
onClick={() => {
queryClient.invalidateQueries(queryKey);
closeModal();
}}
>
{t('common:close')}
</Button>
<Button
variant="light"
loading={isLoading}
leftIcon={<IconDeviceFloppy />}
onClick={() => {
setIsLoading(true);
mutation
.mutateAsync({
config: {
...config,
integrations: form.values,
},
name: name!,
})
.then(() => {
notifications.show({
icon: <IconCheck />,
title: t('common:success'),
message: t('savedSuccessfully'),
color: 'green',
});
setIsLoading(false);
setIntegrations(form.values);
queryClient.invalidateQueries(queryKey);
});
}}
>
{t('common:save')}
</Button>
</Group>
</Stack>
);
}
@@ -226,10 +338,13 @@ export function IntegrationModal({
<Modal
title={<ModalTitle title={t('title')} description={t('description')} />}
opened={opened}
closeOnClickOutside={false}
closeOnEscape={false}
withCloseButton={false}
onClose={() => closeModal()}
size={rem(1000)}
>
<IntegrationsAccordion />
<IntegrationsAccordion closeModal={closeModal} />
</Modal>
);
}

View File

@@ -12,6 +12,7 @@ import {
} from '@tabler/icons-react';
import { useTranslation } from 'next-i18next';
import { useState } from 'react';
import { useConfigContext } from '../../../../config/provider';
import { useConfigStore } from '../../../../config/store';
import { AppType } from '../../../../types/app';
@@ -204,7 +205,9 @@ export const EditAppModal = ({
disallowAppNameProgagation={() => setAllowAppNamePropagation(false)}
allowAppNamePropagation={allowAppNamePropagation}
/>
<IntegrationTab form={form} />
<Tabs.Panel value="integration" pt="lg">
<IntegrationTab form={form} />
</Tabs.Panel>
</Tabs>
<Group position="right" mt="md">

View File

@@ -3,13 +3,14 @@ import { Group, Image, Select, SelectItem, Text } from '@mantine/core';
import { UseFormReturnType } from '@mantine/form';
import { useTranslation } from 'next-i18next';
import { forwardRef } from 'react';
import {
IntegrationField,
integrationFieldDefinitions,
integrationFieldProperties,
AppIntegrationPropertyType,
AppIntegrationType,
AppType,
IntegrationField,
integrationFieldDefinitions,
integrationFieldProperties,
} from '../../../../../../../../types/app';
interface IntegrationSelectorProps {
@@ -97,7 +98,9 @@ export const integrationsList = [
export const IntegrationSelector = ({ form }: IntegrationSelectorProps) => {
const { t } = useTranslation('layout/modals/add-app');
const data = integrationsList.filter((x) => Object.keys(integrationFieldProperties).includes(x.value));
const data = integrationsList.filter((x) =>
Object.keys(integrationFieldProperties).includes(x.value)
);
const getNewProperties = (value: string | null): AppIntegrationPropertyType[] => {
if (!value) return [];
@@ -129,7 +132,6 @@ export const IntegrationSelector = ({ form }: IntegrationSelectorProps) => {
data={data}
maxDropdownHeight={250}
dropdownPosition="bottom"
clearable
variant="default"
searchable
zIndex={203}

View File

@@ -2,6 +2,7 @@ import { Alert, Divider, Tabs, Text } from '@mantine/core';
import { UseFormReturnType } from '@mantine/form';
import { IconAlertTriangle } from '@tabler/icons-react';
import { Trans, useTranslation } from 'next-i18next';
import { AppType } from '../../../../../../types/app';
import { IntegrationSelector } from './Components/InputElements/IntegrationSelector';
import { IntegrationOptionsRenderer } from './Components/IntegrationOptionsRenderer/IntegrationOptionsRenderer';
@@ -15,7 +16,7 @@ export const IntegrationTab = ({ form }: IntegrationTabProps) => {
const hasIntegrationSelected = form.values.integration?.type;
return (
<Tabs.Panel value="integration" pt="lg">
<div>
<IntegrationSelector form={form} />
{hasIntegrationSelected && (
@@ -32,6 +33,6 @@ export const IntegrationTab = ({ form }: IntegrationTabProps) => {
</Alert>
</>
)}
</Tabs.Panel>
</div>
);
};

View File

@@ -1,13 +1,15 @@
import { TRPCError } from '@trpc/server';
import Consola from 'consola';
import { getCookie } from 'cookies-next';
import fs from 'fs';
import path from 'path';
import Consola from 'consola';
import { z } from 'zod';
import { TRPCError } from '@trpc/server';
import { createTRPCRouter, publicProcedure } from '../trpc';
import { BackendConfigType, ConfigType } from '~/types/config';
import { getConfig } from '../../../tools/config/getConfig';
import { BackendConfigType, ConfigType, IntegrationTypeMap } from '~/types/config';
import { IRssWidget } from '~/widgets/rss/RssWidgetTile';
import { getConfig } from '../../../tools/config/getConfig';
import { createTRPCRouter, publicProcedure } from '../trpc';
export const configRouter = createTRPCRouter({
all: publicProcedure.query(async () => {
// Get all the configs in the /data/configs folder