mirror of
https://github.com/ajnart/homarr.git
synced 2025-11-10 15:35:55 +01:00
feat: improve design in edit service modal
This commit is contained in:
@@ -1,24 +1,23 @@
|
|||||||
import { Button, createStyles, Group, Stack, Tabs, Text } from '@mantine/core';
|
import { Alert, Button, createStyles, Group, Stack, Tabs, Text } from '@mantine/core';
|
||||||
import { useForm } from '@mantine/form';
|
import { useForm } from '@mantine/form';
|
||||||
import { ContextModalProps } from '@mantine/modals';
|
import { ContextModalProps } from '@mantine/modals';
|
||||||
import { hideNotification, showNotification } from '@mantine/notifications';
|
import { hideNotification, showNotification } from '@mantine/notifications';
|
||||||
import {
|
import { IconAccessPoint, IconAdjustments, IconBrush, IconClick, IconPlug } from '@tabler/icons';
|
||||||
IconAccessPoint,
|
|
||||||
IconAdjustments,
|
|
||||||
IconBrush,
|
|
||||||
IconClick,
|
|
||||||
IconDeviceFloppy,
|
|
||||||
IconDoorExit,
|
|
||||||
IconPlug,
|
|
||||||
} from '@tabler/icons';
|
|
||||||
import { useTranslation } from 'next-i18next';
|
import { useTranslation } from 'next-i18next';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useConfigContext } from '../../../../config/provider';
|
||||||
|
import { useConfigStore } from '../../../../config/store';
|
||||||
import { ServiceType } from '../../../../types/service';
|
import { ServiceType } from '../../../../types/service';
|
||||||
import { AppearanceTab } from './Tabs/AppereanceTab/AppereanceTab';
|
import { AppearanceTab } from './Tabs/AppereanceTab/AppereanceTab';
|
||||||
import { BehaviourTab } from './Tabs/BehaviourTab/BehaviourTab';
|
import { BehaviourTab } from './Tabs/BehaviourTab/BehaviourTab';
|
||||||
import { GeneralTab } from './Tabs/GeneralTab/GeneralTab';
|
import { GeneralTab } from './Tabs/GeneralTab/GeneralTab';
|
||||||
import { IntegrationTab } from './Tabs/IntegrationTab/IntegrationTab';
|
import { IntegrationTab } from './Tabs/IntegrationTab/IntegrationTab';
|
||||||
import { NetworkTab } from './Tabs/NetworkTab/NetworkTab';
|
import { NetworkTab } from './Tabs/NetworkTab/NetworkTab';
|
||||||
|
import { EditServiceModalTab } from './Tabs/type';
|
||||||
|
|
||||||
|
const serviceUrlRegex =
|
||||||
|
'(https?://(?:www.|(?!www))[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9].[^\\s]{2,}|www.[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9].[^\\s]{2,}|https?://(?:www.|(?!www))[a-zA-Z0-9]+.[^\\s]{2,}|www.[a-zA-Z0-9]+.[^\\s]{2,})';
|
||||||
|
|
||||||
export const EditServiceModal = ({
|
export const EditServiceModal = ({
|
||||||
context,
|
context,
|
||||||
@@ -27,16 +26,55 @@ export const EditServiceModal = ({
|
|||||||
}: ContextModalProps<{ service: ServiceType }>) => {
|
}: ContextModalProps<{ service: ServiceType }>) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { classes } = useStyles();
|
const { classes } = useStyles();
|
||||||
|
const { name: configName, config } = useConfigContext();
|
||||||
|
const updateConfig = useConfigStore((store) => store.updateConfig);
|
||||||
|
|
||||||
const form = useForm<ServiceType>({
|
const form = useForm<ServiceType>({
|
||||||
initialValues: innerProps.service,
|
initialValues: innerProps.service,
|
||||||
|
validate: {
|
||||||
|
name: (name) => (!name ? 'Name is required' : null),
|
||||||
|
url: (url) => {
|
||||||
|
if (!url) {
|
||||||
|
return 'Url is required';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!url.match(serviceUrlRegex)) {
|
||||||
|
return 'Value is not a valid url';
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
appearance: (appearance) => (!appearance.iconUrl ? 'Icon is required' : null),
|
||||||
|
behaviour: (behaviour) => {
|
||||||
|
if (behaviour.onClickUrl === undefined || behaviour.onClickUrl.length < 1) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!behaviour.onClickUrl?.match(serviceUrlRegex)) {
|
||||||
|
return 'Uri override is not a valid uri';
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
validateInputOnChange: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
const onSubmit = (values: ServiceType) => {
|
const onSubmit = (values: ServiceType) => {
|
||||||
console.log('form submitted');
|
|
||||||
console.log(values);
|
console.log(values);
|
||||||
|
|
||||||
|
if (!configName) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateConfig(configName, (previousConfig) => ({
|
||||||
|
...previousConfig,
|
||||||
|
services: [...previousConfig.services.filter((x) => x.id !== form.values.id), form.values],
|
||||||
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const [activeTab, setActiveTab] = useState<EditServiceModalTab>('general');
|
||||||
|
|
||||||
const tryCloseModal = () => {
|
const tryCloseModal = () => {
|
||||||
if (form.isDirty()) {
|
if (form.isDirty()) {
|
||||||
showNotification({
|
showNotification({
|
||||||
@@ -65,6 +103,13 @@ export const EditServiceModal = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
{configName === undefined ||
|
||||||
|
(config === undefined && (
|
||||||
|
<Alert color="red">
|
||||||
|
There was an unexpected problem loading the configuration. Functionality might be
|
||||||
|
restricted. Please report this incident.
|
||||||
|
</Alert>
|
||||||
|
))}
|
||||||
<Stack spacing={0} align="center" my="lg">
|
<Stack spacing={0} align="center" my="lg">
|
||||||
{form.values.appearance.iconUrl ? (
|
{form.values.appearance.iconUrl ? (
|
||||||
// disabled because image target is too dynamic for next image cache
|
// disabled because image target is too dynamic for next image cache
|
||||||
@@ -85,7 +130,11 @@ export const EditServiceModal = ({
|
|||||||
</Text>
|
</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
<form onSubmit={form.onSubmit(onSubmit)}>
|
<form onSubmit={form.onSubmit(onSubmit)}>
|
||||||
<Tabs defaultValue="general">
|
<Tabs
|
||||||
|
value={activeTab}
|
||||||
|
onTabChange={(tab) => setActiveTab(tab as EditServiceModalTab)}
|
||||||
|
defaultValue="general"
|
||||||
|
>
|
||||||
<Tabs.List grow>
|
<Tabs.List grow>
|
||||||
<Tabs.Tab value="general" icon={<IconAdjustments size={14} />}>
|
<Tabs.Tab value="general" icon={<IconAdjustments size={14} />}>
|
||||||
General
|
General
|
||||||
@@ -104,7 +153,7 @@ export const EditServiceModal = ({
|
|||||||
</Tabs.Tab>
|
</Tabs.Tab>
|
||||||
</Tabs.List>
|
</Tabs.List>
|
||||||
|
|
||||||
<GeneralTab form={form} />
|
<GeneralTab form={form} openTab={(targetTab) => setActiveTab(targetTab)} />
|
||||||
<BehaviourTab form={form} />
|
<BehaviourTab form={form} />
|
||||||
<NetworkTab form={form} />
|
<NetworkTab form={form} />
|
||||||
<AppearanceTab form={form} />
|
<AppearanceTab form={form} />
|
||||||
@@ -112,16 +161,10 @@ export const EditServiceModal = ({
|
|||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
<Group position="right" mt={100}>
|
<Group position="right" mt={100}>
|
||||||
<Button
|
<Button px={50} variant="light" color="gray" onClick={tryCloseModal}>
|
||||||
leftIcon={<IconDoorExit size={20} />}
|
|
||||||
px={50}
|
|
||||||
variant="light"
|
|
||||||
color="gray"
|
|
||||||
onClick={tryCloseModal}
|
|
||||||
>
|
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" leftIcon={<IconDeviceFloppy size={20} />} px={50}>
|
<Button type="submit" px={50}>
|
||||||
Save
|
Save
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ export const AppearanceTab = ({ form }: AppearanceTabProps) => {
|
|||||||
className={classes.textInput}
|
className={classes.textInput}
|
||||||
icon={<Image />}
|
icon={<Image />}
|
||||||
label="Service Icon"
|
label="Service Icon"
|
||||||
|
description="Logo of your service displayed in your dashboard. Must return a body content containg an image"
|
||||||
variant="default"
|
variant="default"
|
||||||
withAsterisk
|
withAsterisk
|
||||||
required
|
required
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
/* eslint-disable @next/next/no-img-element */
|
/* eslint-disable @next/next/no-img-element */
|
||||||
import {
|
import {
|
||||||
ActionIcon,
|
ActionIcon,
|
||||||
|
Button,
|
||||||
createStyles,
|
createStyles,
|
||||||
Divider,
|
Divider,
|
||||||
Flex,
|
Flex,
|
||||||
@@ -12,7 +13,7 @@ import {
|
|||||||
TextInput,
|
TextInput,
|
||||||
Title,
|
Title,
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import { IconFlame, IconSearch, IconX } from '@tabler/icons';
|
import { IconSearch, IconX } from '@tabler/icons';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { ICON_PICKER_SLICE_LIMIT } from '../../../../../../../../data/constants';
|
import { ICON_PICKER_SLICE_LIMIT } from '../../../../../../../../data/constants';
|
||||||
import { useRepositoryIconsQuery } from '../../../../../../../tools/hooks/useRepositoryIconsQuery';
|
import { useRepositoryIconsQuery } from '../../../../../../../tools/hooks/useRepositoryIconsQuery';
|
||||||
@@ -38,7 +39,12 @@ export const IconSelector = ({ onChange }: IconSelectorProps) => {
|
|||||||
return <Loader />;
|
return <Loader />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const filteredItems = searchTerm ? data.filter((x) => x.url.includes(searchTerm)) : data;
|
const replaceCharacters = (value: string) =>
|
||||||
|
value.toLowerCase().replaceAll(' ', '').replaceAll('-', '');
|
||||||
|
|
||||||
|
const filteredItems = searchTerm
|
||||||
|
? data.filter((x) => replaceCharacters(x.url).includes(replaceCharacters(searchTerm)))
|
||||||
|
: data;
|
||||||
const slicedFilteredItems = filteredItems.slice(0, ICON_PICKER_SLICE_LIMIT);
|
const slicedFilteredItems = filteredItems.slice(0, ICON_PICKER_SLICE_LIMIT);
|
||||||
const isTruncated =
|
const isTruncated =
|
||||||
slicedFilteredItems.length > 0 && slicedFilteredItems.length !== filteredItems.length;
|
slicedFilteredItems.length > 0 && slicedFilteredItems.length !== filteredItems.length;
|
||||||
@@ -46,9 +52,13 @@ export const IconSelector = ({ onChange }: IconSelectorProps) => {
|
|||||||
return (
|
return (
|
||||||
<Popover width={310}>
|
<Popover width={310}>
|
||||||
<Popover.Target>
|
<Popover.Target>
|
||||||
<ActionIcon className={classes.actionIcon} size={36} variant="default">
|
<Button
|
||||||
<IconSearch size={20} />
|
className={classes.actionIcon}
|
||||||
</ActionIcon>
|
variant="default"
|
||||||
|
leftIcon={<IconSearch size={20} />}
|
||||||
|
>
|
||||||
|
Icon Picker
|
||||||
|
</Button>
|
||||||
</Popover.Target>
|
</Popover.Target>
|
||||||
<Popover.Dropdown>
|
<Popover.Dropdown>
|
||||||
<Stack pt={4}>
|
<Stack pt={4}>
|
||||||
@@ -76,7 +86,6 @@ export const IconSelector = ({ onChange }: IconSelectorProps) => {
|
|||||||
{isTruncated && (
|
{isTruncated && (
|
||||||
<Stack spacing="xs" pr={15}>
|
<Stack spacing="xs" pr={15}>
|
||||||
<Divider mt={35} mx="xl" />
|
<Divider mt={35} mx="xl" />
|
||||||
<IconFlame className={classes.flameIcon} size={40} opacity={0.6} strokeWidth={1} />
|
|
||||||
<Title order={6} color="dimmed" align="center">
|
<Title order={6} color="dimmed" align="center">
|
||||||
Search is limited to {ICON_PICKER_SLICE_LIMIT} icons
|
Search is limited to {ICON_PICKER_SLICE_LIMIT} icons
|
||||||
</Title>
|
</Title>
|
||||||
|
|||||||
@@ -15,13 +15,17 @@ export const BehaviourTab = ({ form }: BehaviourTabProps) => {
|
|||||||
<TextInput
|
<TextInput
|
||||||
icon={<IconClick size={16} />}
|
icon={<IconClick size={16} />}
|
||||||
label="On click url"
|
label="On click url"
|
||||||
placeholder="Override the default service url when clicking on the service"
|
description="Overrides the service URL when clicking on the service"
|
||||||
|
placeholder="URL that should be opened instead when clicking on the service"
|
||||||
variant="default"
|
variant="default"
|
||||||
mb="md"
|
mb="md"
|
||||||
{...form.getInputProps('behaviour.onClickUrl')}
|
{...form.getInputProps('behaviour.onClickUrl')}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Switch label="Open in new tab" {...form.getInputProps('behaviour.isOpeningNewTab')} />
|
<Switch
|
||||||
|
label="Open in new tab"
|
||||||
|
{...form.getInputProps('behaviour.isOpeningNewTab', { type: 'checkbox' })}
|
||||||
|
/>
|
||||||
</Tabs.Panel>
|
</Tabs.Panel>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,32 +1,46 @@
|
|||||||
import { Tabs, TextInput } from '@mantine/core';
|
import { Group, Tabs, Text, TextInput } from '@mantine/core';
|
||||||
import { UseFormReturnType } from '@mantine/form';
|
import { UseFormReturnType } from '@mantine/form';
|
||||||
import { IconCursorText, IconLink } from '@tabler/icons';
|
import { IconCursorText, IconLink } from '@tabler/icons';
|
||||||
import { useTranslation } from 'next-i18next';
|
import { useTranslation } from 'next-i18next';
|
||||||
import { ServiceType } from '../../../../../../types/service';
|
import { ServiceType } from '../../../../../../types/service';
|
||||||
|
import { EditServiceModalTab } from '../type';
|
||||||
|
|
||||||
interface GeneralTabProps {
|
interface GeneralTabProps {
|
||||||
form: UseFormReturnType<ServiceType, (values: ServiceType) => ServiceType>;
|
form: UseFormReturnType<ServiceType, (values: ServiceType) => ServiceType>;
|
||||||
|
openTab: (tab: EditServiceModalTab) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const GeneralTab = ({ form }: GeneralTabProps) => {
|
export const GeneralTab = ({ form, openTab }: GeneralTabProps) => {
|
||||||
const { t } = useTranslation('');
|
const { t } = useTranslation('');
|
||||||
return (
|
return (
|
||||||
<Tabs.Panel value="general" pt="lg">
|
<Tabs.Panel value="general" pt="lg">
|
||||||
<TextInput
|
<TextInput
|
||||||
icon={<IconCursorText size={16} />}
|
icon={<IconCursorText size={16} />}
|
||||||
label="Service name"
|
label="Service name"
|
||||||
|
description="Used for displaying the service on the dashboard"
|
||||||
placeholder="My example service"
|
placeholder="My example service"
|
||||||
variant="default"
|
variant="default"
|
||||||
mb="md"
|
mb="md"
|
||||||
required
|
withAsterisk
|
||||||
{...form.getInputProps('name')}
|
{...form.getInputProps('name')}
|
||||||
/>
|
/>
|
||||||
<TextInput
|
<TextInput
|
||||||
icon={<IconLink size={16} />}
|
icon={<IconLink size={16} />}
|
||||||
label="Service url"
|
label="Service url"
|
||||||
|
description={
|
||||||
|
<Group spacing={3}>
|
||||||
|
<Text>
|
||||||
|
URL that will be opened when clicking on the service. Can be overwritten using
|
||||||
|
</Text>
|
||||||
|
<Text onClick={() => openTab('behaviour')} variant="link">
|
||||||
|
on click URL
|
||||||
|
</Text>
|
||||||
|
<Text>when using external URLs to enhance security.</Text>
|
||||||
|
</Group>
|
||||||
|
}
|
||||||
placeholder="https://google.com"
|
placeholder="https://google.com"
|
||||||
variant="default"
|
variant="default"
|
||||||
required
|
withAsterisk
|
||||||
{...form.getInputProps('url')}
|
{...form.getInputProps('url')}
|
||||||
/>
|
/>
|
||||||
</Tabs.Panel>
|
</Tabs.Panel>
|
||||||
|
|||||||
@@ -62,11 +62,9 @@ export const IntegrationSelector = ({ form }: IntegrationSelectorProps) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<TextExplanation />
|
|
||||||
<Space h="sm" />
|
|
||||||
|
|
||||||
<Select
|
<Select
|
||||||
label="Configure this service for the following integration"
|
label="Integration configuration"
|
||||||
|
description="Treats this service as the selected integration and provides you with per-service configuration"
|
||||||
placeholder="Select your desired configuration"
|
placeholder="Select your desired configuration"
|
||||||
itemComponent={SelectItemComponent}
|
itemComponent={SelectItemComponent}
|
||||||
data={data}
|
data={data}
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
import { Text } from '@mantine/core';
|
|
||||||
|
|
||||||
export const TextExplanation = () => {
|
|
||||||
return (
|
|
||||||
<Text color="dimmed">
|
|
||||||
You can optionally connect your services using integrations. Integration elements on your
|
|
||||||
dashboard will communicate to your services using the configuration below.
|
|
||||||
</Text>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -14,6 +14,7 @@ export const NetworkTab = ({ form }: NetworkTabProps) => {
|
|||||||
<Tabs.Panel value="network" pt="lg">
|
<Tabs.Panel value="network" pt="lg">
|
||||||
<Switch
|
<Switch
|
||||||
label="Enable status checker"
|
label="Enable status checker"
|
||||||
|
description="Sends a simple HTTP / HTTPS request to check if your service is online"
|
||||||
mb="md"
|
mb="md"
|
||||||
defaultChecked={form.values.network.enabledStatusChecker}
|
defaultChecked={form.values.network.enabledStatusChecker}
|
||||||
{...form.getInputProps('network.enabledStatusChecker')}
|
{...form.getInputProps('network.enabledStatusChecker')}
|
||||||
@@ -22,6 +23,7 @@ export const NetworkTab = ({ form }: NetworkTabProps) => {
|
|||||||
<MultiSelect
|
<MultiSelect
|
||||||
required
|
required
|
||||||
label="HTTP status codes"
|
label="HTTP status codes"
|
||||||
|
description="Determines what response codes are allowed for this service to be 'Online'"
|
||||||
data={StatusCodes}
|
data={StatusCodes}
|
||||||
clearable
|
clearable
|
||||||
searchable
|
searchable
|
||||||
|
|||||||
1
src/components/Dashboard/Modals/EditService/Tabs/type.ts
Normal file
1
src/components/Dashboard/Modals/EditService/Tabs/type.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export type EditServiceModalTab = 'general' | 'behaviour' | 'network' | 'appereance' | 'integration';
|
||||||
@@ -42,7 +42,8 @@ export const AvailableElementTypes = ({
|
|||||||
okStatus: [],
|
okStatus: [],
|
||||||
},
|
},
|
||||||
behaviour: {
|
behaviour: {
|
||||||
isOpeningNewTab: false,
|
isOpeningNewTab: true,
|
||||||
|
onClickUrl: '',
|
||||||
},
|
},
|
||||||
area: {
|
area: {
|
||||||
type: 'wrapper',
|
type: 'wrapper',
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export interface ServiceType extends TileBaseType {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface ServiceBehaviourType {
|
interface ServiceBehaviourType {
|
||||||
onClickUrl?: string;
|
onClickUrl: string;
|
||||||
isOpeningNewTab: boolean;
|
isOpeningNewTab: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user