🚧 WIP on integration menu

This commit is contained in:
ajnart
2023-07-02 23:15:45 +09:00
parent 496eb2cf67
commit 0affe0bca6
14 changed files with 346 additions and 100 deletions

View File

@@ -17,6 +17,7 @@
"createItem": "+ create {{item}}",
"sections": {
"settings": "Settings",
"integrations": "Integrations",
"dangerZone": "Danger zone"
},
"secrets": {

View File

@@ -0,0 +1,4 @@
{
"title": "Integrations settings",
"description": "Here you can configure all the integrations you want to use within Honmarr"
}

View File

@@ -0,0 +1,138 @@
import { Accordion, Modal, Stack, Text, Title, rem } from '@mantine/core';
import { Form, useForm } from '@mantine/form';
import Image from 'next/image';
import { useTranslation } from 'react-i18next';
import { integrationsList } from '~/components/Dashboard/Modals/EditAppModal/Tabs/IntegrationTab/Components/InputElements/IntegrationSelector';
import {
IntegrationOptionsRenderer,
IntegrationOptionsRendererNoForm,
} from '~/components/Dashboard/Modals/EditAppModal/Tabs/IntegrationTab/Components/IntegrationOptionsRenderer/IntegrationOptionsRenderer';
import { useConfigContext } from '~/config/provider';
import { AppIntegrationType, AppType, IntegrationType } from '~/types/app';
const ModalTitle = ({ title, description }: { title: string; description: string }) => (
<div>
<Title order={3} style={{ marginBottom: 0 }}>
{title}
</Title>
<Text color="dimmed">{description}</Text>
</div>
);
function IntegrationDisplay({ integration }: { integration: AppIntegrationType }) {
if (!integration.type) return null;
return (
<Accordion.Item value={integration.id}>
<Accordion.Control>{integration.name}</Accordion.Control>
<Accordion.Panel>
<IntegrationOptionsRendererNoForm
type={integration.type}
properties={integration.properties}
/>
</Accordion.Panel>
</Accordion.Item>
);
}
interface IntegrationGroupedType {
type: IntegrationType;
integration: AppIntegrationType[];
}
// export type IntegrationType =
// | 'readarr'
// | 'radarr'
// | 'sonarr'
// | 'lidarr'
// | 'sabnzbd'
// | 'jellyseerr'
// | 'overseerr'
// | 'deluge'
// | 'qBittorrent'
// | 'transmission'
// | 'plex'
// | 'jellyfin'
// | 'nzbGet'
// | 'pihole'
// | 'adGuardHome';
export function IntegrationsAccordion() {
const { config } = useConfigContext();
console.log(config.integrations);
if (!config.integrations) {
config.integrations = [];
}
// Fill configIntegrationList with config.integrations in the
const configIntegrationList: IntegrationGroupedType[] = [];
config.integrations.forEach((configIntegration) => {
const existingIntegration = configIntegrationList.find(
(integration) => integration.type === configIntegration.type
);
if (existingIntegration) {
existingIntegration.integration.push(configIntegration);
} else {
configIntegrationList.push({
type: configIntegration.type!,
integration: [configIntegration],
});
}
});
return (
<Accordion variant="separated" multiple>
{configIntegrationList.map((configIntegration) => {
// Match configIntegration with integrationsList
const integration = integrationsList.find(
(integration) => integration.value === configIntegration.type
);
if (!integration) {
return null;
}
return (
<Accordion.Item value={integration.label ?? integration.value} key={integration.value}>
<Accordion.Control
icon={
<Image
src={integration.image}
width={24}
height={24}
alt={integration.label ?? integration.value}
/>
}
>
{integration.label ?? integration.value}
</Accordion.Control>
<Accordion.Panel>
<Accordion variant="contained" radius="md" multiple>
{configIntegration.integration.map((item) => (
<IntegrationDisplay integration={item} />
))}
</Accordion>
</Accordion.Panel>
</Accordion.Item>
);
})}
</Accordion>
);
}
export function IntegrationModal({
opened,
closeModal,
}: {
opened: boolean;
closeModal: () => void;
}) {
const { t } = useTranslation('settings/integrations');
return (
<Modal
title={<ModalTitle title={t('title')} description={t('description')} />}
opened={opened}
onClose={() => closeModal()}
size={rem(1000)}
>
<IntegrationsAccordion />
</Modal>
);
}

View File

@@ -106,6 +106,7 @@ export const GenericSecretInput = ({
{displayUpdateField === true ? (
<PasswordInput
required
autoComplete={`homarr-${type}-password`}
defaultValue={value}
placeholder="new secret"
styles={{ root: { width: 200 } }}

View File

@@ -16,86 +16,88 @@ interface IntegrationSelectorProps {
form: UseFormReturnType<AppType, (item: AppType) => AppType>;
}
export const integrationsList: SelectItem[] = [
{
value: 'sabnzbd',
image: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/svg/sabnzbd.svg',
label: 'SABnzbd',
},
{
value: 'nzbGet',
image: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/nzbget.png',
label: 'NZBGet',
},
{
value: 'deluge',
image: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/svg/deluge.svg',
label: 'Deluge',
},
{
value: 'transmission',
image: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/svg/transmission.svg',
label: 'Transmission',
},
{
value: 'qBittorrent',
image: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/svg/qbittorrent.svg',
label: 'qBittorrent',
},
{
value: 'jellyseerr',
image: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/svg/jellyseerr.svg',
label: 'Jellyseerr',
},
{
value: 'overseerr',
image: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/svg/overseerr.svg',
label: 'Overseerr',
},
{
value: 'sonarr',
image: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/svg/sonarr.svg',
label: 'Sonarr',
},
{
value: 'radarr',
image: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/svg/radarr.svg',
label: 'Radarr',
},
{
value: 'lidarr',
image: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/svg/lidarr.svg',
label: 'Lidarr',
},
{
value: 'readarr',
image: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/svg/readarr.svg',
label: 'Readarr',
},
{
value: 'jellyfin',
image: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/svg/jellyfin.svg',
label: 'Jellyfin',
},
{
value: 'plex',
image: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/svg/plex.svg',
label: 'Plex',
},
{
value: 'pihole',
image: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/svg/pi-hole.svg',
label: 'PiHole',
},
{
value: 'adGuardHome',
image: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/svg/adguard-home.svg',
label: 'AdGuard Home',
},
];
export const IntegrationSelector = ({ form }: IntegrationSelectorProps) => {
const { t } = useTranslation('layout/modals/add-app');
const data: SelectItem[] = [
{
value: 'sabnzbd',
image: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/sabnzbd.png',
label: 'SABnzbd',
},
{
value: 'nzbGet',
image: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/nzbget.png',
label: 'NZBGet',
},
{
value: 'deluge',
image: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/deluge.png',
label: 'Deluge',
},
{
value: 'transmission',
image: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/transmission.png',
label: 'Transmission',
},
{
value: 'qBittorrent',
image: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/qbittorrent.png',
label: 'qBittorrent',
},
{
value: 'jellyseerr',
image: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/jellyseerr.png',
label: 'Jellyseerr',
},
{
value: 'overseerr',
image: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/overseerr.png',
label: 'Overseerr',
},
{
value: 'sonarr',
image: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/sonarr.png',
label: 'Sonarr',
},
{
value: 'radarr',
image: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/radarr.png',
label: 'Radarr',
},
{
value: 'lidarr',
image: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/lidarr.png',
label: 'Lidarr',
},
{
value: 'readarr',
image: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/readarr.png',
label: 'Readarr',
},
{
value: 'jellyfin',
image: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/jellyfin.png',
label: 'Jellyfin',
},
{
value: 'plex',
image: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/plex.png',
label: 'Plex',
},
{
value: 'pihole',
image: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/pi-hole.png',
label: 'PiHole',
},
{
value: 'adGuardHome',
image: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/adguard-home.png',
label: 'AdGuard Home',
},
].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 [];

View File

@@ -1,12 +1,14 @@
import { Stack } from '@mantine/core';
import { Accordion, Stack } from '@mantine/core';
import { UseFormReturnType } from '@mantine/form';
import { IconKey } from '@tabler/icons-react';
import {
IntegrationField,
integrationFieldDefinitions,
integrationFieldProperties,
AppIntegrationPropertyType,
AppType,
IntegrationField,
IntegrationType,
integrationFieldDefinitions,
integrationFieldProperties,
} from '../../../../../../../../types/app';
import { GenericSecretInput } from '../InputElements/GenericSecretInput';
@@ -14,6 +16,85 @@ interface IntegrationOptionsRendererProps {
form: UseFormReturnType<AppType, (values: AppType) => AppType>;
}
export const IntegrationOptionsRendererNoForm = ({
type,
properties,
}: {
type: IntegrationType;
properties: any;
}) => {
const selectedIntegration = type;
if (!selectedIntegration) return null;
const displayedProperties = integrationFieldProperties[type];
return (
<Stack spacing="xs" mb="md">
{displayedProperties.map((property, index) => {
const [_, definition] = Object.entries(integrationFieldDefinitions).find(
([key]) => property === key
)!;
let indexInFormValue = properties.findIndex((p: any) => p.field === property) ?? -1;
if (indexInFormValue === -1) {
const { type } = Object.entries(integrationFieldDefinitions).find(
([k, v]) => k === property
)![1];
const newProperty: AppIntegrationPropertyType = {
type,
field: property as IntegrationField,
isDefined: false,
};
indexInFormValue = properties.length;
}
const formValue = properties[indexInFormValue];
const isPresent = formValue?.isDefined;
const accessabilityType = formValue?.type;
if (!definition) {
return (
<GenericSecretInput
onClickUpdateButton={(value) => {
// form.setFieldValue(`integration.properties.${index}.value`, value);
// form.setFieldValue(
// `integration.properties.${index}.isDefined`,
// value !== undefined
// );
//TODO: Add a super form to handle saving of the properties
}}
key={`input-${property}`}
label={`${property} (potentionally unmapped)`}
secretIsPresent={isPresent}
setIcon={IconKey}
type={accessabilityType}
value={formValue}
// {...form.getInputProps(`integration.properties.${index}.value`)}
/>
);
}
return (
<GenericSecretInput
onClickUpdateButton={(value) => {
// form.setFieldValue(`integration.properties.${index}.value`, value);
// form.setFieldValue(`integration.properties.${index}.isDefined`, value !== undefined);
// TODO: THIS
}}
key={`input-${definition.label}`}
label={definition.label}
secretIsPresent={isPresent}
setIcon={definition.icon}
type={accessabilityType}
value={formValue}
// {...form.getInputProps(`integration.properties.${index}.value`)}
/>
);
})}
</Stack>
);
};
export const IntegrationOptionsRenderer = ({ form }: IntegrationOptionsRendererProps) => {
const selectedIntegration = form.values.integration?.type;

View File

@@ -6,6 +6,7 @@ import { motion } from 'framer-motion';
import { useTranslation } from 'next-i18next';
import { ReactNode } from 'react';
import { v4 as uuidv4 } from 'uuid';
import { useConfigContext } from '../../../../../../config/provider';
import { useConfigStore } from '../../../../../../config/store';
import { openContextModalGeneric } from '../../../../../../tools/mantineModalManagerExtensions';
@@ -113,6 +114,8 @@ export const AvailableElementTypes = ({
integration: {
type: null,
properties: [],
id: uuidv4(),
name: 'New integration',
},
},
allowAppNamePropagation: true,

View File

@@ -1,7 +0,0 @@
export function IntegrationPanel() {
return (
<div>
<h1>Integration Panel</h1>
</div>
);
}

View File

@@ -1,7 +1,15 @@
import { Badge, Button, Menu } from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import { IconInfoCircle, IconMenu2, IconSettings } from '@tabler/icons-react';
import { useDisclosure, useHotkeys } from '@mantine/hooks';
import {
IconInfoCircle,
IconMenu2,
IconPlug,
IconPlugConnected,
IconSettings,
} from '@tabler/icons-react';
import { useTranslation } from 'next-i18next';
import { IntegrationModal } from '~/components/Config/Integration/IntegrationModal';
import { useEditModeInformationStore } from '../../../hooks/useEditModeInformation';
import { AboutModal } from '../../Dashboard/Modals/AboutModal/AboutModal';
import { SettingsDrawer } from '../../Settings/SettingsDrawer';
@@ -12,9 +20,11 @@ import { EditModeToggle } from './SettingsMenu/EditModeToggle';
export function SettingsMenu({ newVersionAvailable }: { newVersionAvailable: string }) {
const [drawerOpened, drawer] = useDisclosure(false);
const { t } = useTranslation('common');
const [integrationsModalOpened, integrationsModal] = useDisclosure(false);
const [aboutModalOpened, aboutModal] = useDisclosure(false);
const { classes } = useCardStyles(true);
const { editModeEnabled } = useEditModeInformationStore();
useHotkeys([['mod+o', () => integrationsModal.toggle()]]);
return (
<>
@@ -33,6 +43,12 @@ export function SettingsMenu({ newVersionAvailable }: { newVersionAvailable: str
{t('sections.settings')}
</Menu.Item>
)}
<Menu.Item
icon={<IconPlugConnected strokeWidth={1.2} size={18} />}
onClick={integrationsModal.open}
>
{t('sections.integrations')}
</Menu.Item>
<Menu.Item
icon={<IconInfoCircle strokeWidth={1.2} size={18} />}
rightSection={
@@ -53,6 +69,7 @@ export function SettingsMenu({ newVersionAvailable }: { newVersionAvailable: str
closeDrawer={drawer.close}
newVersionAvailable={newVersionAvailable}
/>
<IntegrationModal opened={integrationsModalOpened} closeModal={integrationsModal.close} />
<AboutModal
opened={aboutModalOpened}
closeModal={aboutModal.close}

View File

@@ -5,7 +5,7 @@ import { ConfigType } from '../types/config';
import { useConfigStore } from './store';
export type ConfigContextType = {
config: ConfigType | undefined;
config: ConfigType;
name: string | undefined;
configVersion: number | undefined;
increaseVersion: () => void;
@@ -14,7 +14,7 @@ export type ConfigContextType = {
const ConfigContext = createContext<ConfigContextType>({
name: 'unknown',
config: undefined,
config: {} as ConfigType,
configVersion: undefined,
increaseVersion: () => {},
setConfigName: () => {},
@@ -38,7 +38,7 @@ export const ConfigProvider = ({ children }: { children: ReactNode }) => {
<ConfigContext.Provider
value={{
name: configName,
config: currentConfig,
config: currentConfig!,
configVersion,
increaseVersion: () => setConfigVersion((v) => v + 1),
setConfigName: (name: string) => setConfigName(name),

View File

@@ -8,6 +8,7 @@ export const dashboardNamespaces = [
'layout/header/actions/toggle-edit-mode',
'layout/mobile/drawer',
'settings/common',
'settings/integrations',
'settings/general/theme-selector',
'settings/general/config-changer',
'settings/general/internationalization',

View File

@@ -50,6 +50,8 @@ export type IntegrationType =
export type AppIntegrationType = {
type: IntegrationType | null;
id: string;
name: string;
properties: AppIntegrationPropertyType[];
};

View File

@@ -1,8 +1,8 @@
import { CategoryType } from './category';
import { WrapperType } from './wrapper';
import { ConfigAppType, AppType } from './app';
import { SettingsType } from './settings';
import { IWidget } from '../widgets/widgets';
import { AppIntegrationType, AppType, ConfigAppType } from './app';
import { CategoryType } from './category';
import { SettingsType } from './settings';
import { WrapperType } from './wrapper';
export interface ConfigType {
schemaVersion: number;
@@ -12,6 +12,7 @@ export interface ConfigType {
apps: AppType[];
widgets: IWidget<string, any>[];
settings: SettingsType;
integrations: AppIntegrationType[]
}
export type BackendConfigType = Omit<ConfigType, 'apps'> & {

View File

@@ -1,6 +1,6 @@
{
"compilerOptions": {
"target": "es5",
"target": "ESNext",
"lib": [
"dom",
"dom.iterable",
@@ -24,7 +24,9 @@
}
],
"paths": {
"~/*": ["./src/*"]
"~/*": [
"./src/*"
]
},
},
"include": [
@@ -37,4 +39,4 @@
"exclude": [
"node_modules"
]
}
}