From 8f97c854ff42e43096507618b9c665982eed32b8 Mon Sep 17 00:00:00 2001 From: ajnart Date: Mon, 3 Jul 2023 14:13:05 +0900 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20Add=20the=20ability=20to=20add=20in?= =?UTF-8?q?tegrations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Integration/AddIntegrationPanel.tsx | 121 ++++++++++ .../Config/Integration/IntegrationModal.tsx | 215 ++++++++++++++---- .../Modals/EditAppModal/EditAppModal.tsx | 5 +- .../InputElements/IntegrationSelector.tsx | 12 +- .../Tabs/IntegrationTab/IntegrationTab.tsx | 5 +- src/server/api/routers/config.ts | 12 +- 6 files changed, 307 insertions(+), 63 deletions(-) create mode 100644 src/components/Config/Integration/AddIntegrationPanel.tsx diff --git a/src/components/Config/Integration/AddIntegrationPanel.tsx b/src/components/Config/Integration/AddIntegrationPanel.tsx new file mode 100644 index 000000000..1cafa5ae5 --- /dev/null +++ b/src/components/Config/Integration/AddIntegrationPanel.tsx @@ -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; + queryKey: QueryKey; + integrations: IntegrationTypeMap | undefined; + setIntegrations: React.Dispatch>; +}) { + const { t } = useTranslation(['settings/integrations', 'common']); + const queryClient = useQueryClient(); + const { primaryColor } = useMantineTheme(); + + const form = useForm({ + initialValues: defaultAppValues, + }); + + if (!integrations) { + return null; + } + + return ( +
{ + 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', + }); + })} + > + + + + + + + + +
+ ); +} diff --git a/src/components/Config/Integration/IntegrationModal.tsx b/src/components/Config/Integration/IntegrationModal.tsx index 8a7c1d010..eeeee4a94 100644 --- a/src/components/Config/Integration/IntegrationModal.tsx +++ b/src/components/Config/Integration/IntegrationModal.tsx @@ -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 }) => (
@@ -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> ); } diff --git a/src/components/Dashboard/Modals/EditAppModal/EditAppModal.tsx b/src/components/Dashboard/Modals/EditAppModal/EditAppModal.tsx index 7628a842a..cf8d3da1a 100644 --- a/src/components/Dashboard/Modals/EditAppModal/EditAppModal.tsx +++ b/src/components/Dashboard/Modals/EditAppModal/EditAppModal.tsx @@ -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"> diff --git a/src/components/Dashboard/Modals/EditAppModal/Tabs/IntegrationTab/Components/InputElements/IntegrationSelector.tsx b/src/components/Dashboard/Modals/EditAppModal/Tabs/IntegrationTab/Components/InputElements/IntegrationSelector.tsx index 4640b7e60..e37613ed8 100644 --- a/src/components/Dashboard/Modals/EditAppModal/Tabs/IntegrationTab/Components/InputElements/IntegrationSelector.tsx +++ b/src/components/Dashboard/Modals/EditAppModal/Tabs/IntegrationTab/Components/InputElements/IntegrationSelector.tsx @@ -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} diff --git a/src/components/Dashboard/Modals/EditAppModal/Tabs/IntegrationTab/IntegrationTab.tsx b/src/components/Dashboard/Modals/EditAppModal/Tabs/IntegrationTab/IntegrationTab.tsx index 52cd7660c..89df187e1 100644 --- a/src/components/Dashboard/Modals/EditAppModal/Tabs/IntegrationTab/IntegrationTab.tsx +++ b/src/components/Dashboard/Modals/EditAppModal/Tabs/IntegrationTab/IntegrationTab.tsx @@ -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> ); }; diff --git a/src/server/api/routers/config.ts b/src/server/api/routers/config.ts index eda6361f9..e0565ab75 100644 --- a/src/server/api/routers/config.ts +++ b/src/server/api/routers/config.ts @@ -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