mirror of
https://github.com/ajnart/homarr.git
synced 2026-01-29 18:59:20 +01:00
♻️ Add edit app action for new board
This commit is contained in:
@@ -9,7 +9,7 @@ import { ItemWrapper } from '../ItemWrapper';
|
||||
import { AppMenu } from './AppMenu';
|
||||
import { AppPing } from './AppPing';
|
||||
|
||||
interface AppTileProps {
|
||||
interface AppItemProps {
|
||||
app: AppItem;
|
||||
className?: string;
|
||||
}
|
||||
@@ -21,7 +21,7 @@ const namePositions = {
|
||||
bottom: 'column-reverse',
|
||||
};
|
||||
|
||||
export const BoardAppItem = ({ className, app }: AppTileProps) => {
|
||||
export const BoardAppItem = ({ className, app }: AppItemProps) => {
|
||||
const isEditMode = useEditModeStore((x) => x.enabled);
|
||||
const { cx, classes } = useStyles();
|
||||
const { colorScheme } = useMantineTheme();
|
||||
@@ -49,7 +49,7 @@ export const BoardAppItem = ({ className, app }: AppTileProps) => {
|
||||
flexFlow: namePositions[app.namePosition] ?? 'column',
|
||||
}}
|
||||
>
|
||||
{app.nameStyle === 'show' && (
|
||||
{app.nameStyle === 'normal' && (
|
||||
<Text
|
||||
className={cx(classes.appName, 'dashboard-tile-app-title')}
|
||||
fw={700}
|
||||
|
||||
@@ -4,22 +4,24 @@ import { useResizeGridItem } from '~/components/Board/gridstack/useResizeGridIte
|
||||
import { openContextModalGeneric } from '~/tools/mantineModalManagerExtensions';
|
||||
|
||||
import { CommonItemMenu } from '../CommonItemMenu';
|
||||
import { EditAppModalInnerProps } from './EditAppModal';
|
||||
|
||||
interface TileMenuProps {
|
||||
interface AppMenuProps {
|
||||
app: AppItem;
|
||||
}
|
||||
|
||||
export const AppMenu = ({ app }: TileMenuProps) => {
|
||||
export const AppMenu = ({ app }: AppMenuProps) => {
|
||||
const board = useRequiredBoard();
|
||||
const { removeItem } = useItemActions({ boardName: board.name });
|
||||
const resizeGridItem = useResizeGridItem();
|
||||
|
||||
const handleClickEdit = () => {
|
||||
openContextModalGeneric<{ app: AppItem; allowAppNamePropagation: boolean }>({
|
||||
openContextModalGeneric<EditAppModalInnerProps>({
|
||||
modal: 'editApp',
|
||||
size: 'xl',
|
||||
innerProps: {
|
||||
app,
|
||||
board,
|
||||
allowAppNamePropagation: false,
|
||||
},
|
||||
styles: {
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { Flex, NumberInput, Select, Stack, Switch, Tabs } from '@mantine/core';
|
||||
import { UseFormReturnType } from '@mantine/form';
|
||||
import { Flex, NumberInput, Select, Stack, Tabs } from '@mantine/core';
|
||||
import { useDebouncedValue } from '@mantine/hooks';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
import { AppType } from '~/types/app';
|
||||
import { IconSelector } from '~/components/IconSelector/IconSelector';
|
||||
import { appNamePositions, appNameStyles } from '~/server/db/items';
|
||||
|
||||
import { AppForm } from '../EditAppModal';
|
||||
|
||||
interface AppearanceTabProps {
|
||||
form: UseFormReturnType<AppType, (values: AppType) => AppType>;
|
||||
form: AppForm;
|
||||
disallowAppNamePropagation: () => void;
|
||||
allowAppNamePropagation: boolean;
|
||||
}
|
||||
@@ -43,72 +43,47 @@ export const AppearanceTab = ({
|
||||
<Stack spacing="xs">
|
||||
<Flex gap={5} mb="xs">
|
||||
<IconSelector
|
||||
defaultValue={form.values.appearance.iconUrl}
|
||||
defaultValue={form.values.iconUrl}
|
||||
onChange={(value) => {
|
||||
form.setFieldValue('appearance.iconUrl', value);
|
||||
form.setFieldValue('iconUrl', value!);
|
||||
disallowAppNamePropagation();
|
||||
}}
|
||||
value={form.values.appearance.iconUrl}
|
||||
value={form.values.iconUrl}
|
||||
ref={iconSelectorRef}
|
||||
/>
|
||||
</Flex>
|
||||
<Select
|
||||
label={t('appearance.appNameStatus.label')}
|
||||
description={t('appearance.appNameStatus.description')}
|
||||
data={[
|
||||
{ value: 'normal', label: t('appearance.appNameStatus.dropdown.normal') as string },
|
||||
{ value: 'hover', label: t('appearance.appNameStatus.dropdown.hover') as string },
|
||||
{ value: 'hidden', label: t('appearance.appNameStatus.dropdown.hidden') as string },
|
||||
]}
|
||||
{...form.getInputProps('appearance.appNameStatus')}
|
||||
onChange={(value) => {
|
||||
form.setFieldValue('appearance.appNameStatus', value);
|
||||
}}
|
||||
data={appNameStyles.map((value) => ({
|
||||
value,
|
||||
label: t(`appearance.appNameStatus.dropdown.${value}`)!,
|
||||
}))}
|
||||
{...form.getInputProps('nameStyle')}
|
||||
/>
|
||||
{form.values.appearance.appNameStatus === 'normal' && (
|
||||
{form.values.nameStyle === 'normal' && (
|
||||
<>
|
||||
<NumberInput
|
||||
label={t('appearance.appNameFontSize.label')}
|
||||
description={t('appearance.appNameFontSize.description')}
|
||||
min={5}
|
||||
max={64}
|
||||
{...form.getInputProps('appearance.appNameFontSize')}
|
||||
onChange={(value) => {
|
||||
form.setFieldValue('appearance.appNameFontSize', value);
|
||||
}}
|
||||
{...form.getInputProps('fontSize')}
|
||||
/>
|
||||
<Select
|
||||
label={t('appearance.positionAppName.label')}
|
||||
description={t('appearance.positionAppName.description')}
|
||||
data={[
|
||||
{
|
||||
value: 'column',
|
||||
label: t('appearance.positionAppName.dropdown.top') as string },
|
||||
{
|
||||
value: 'row-reverse',
|
||||
label: t('appearance.positionAppName.dropdown.right') as string,
|
||||
},
|
||||
{
|
||||
value: 'column-reverse',
|
||||
label: t('appearance.positionAppName.dropdown.bottom') as string,
|
||||
},
|
||||
{
|
||||
value: 'row',
|
||||
label: t('appearance.positionAppName.dropdown.left') as string },
|
||||
]}
|
||||
{...form.getInputProps('appearance.positionAppName')}
|
||||
onChange={(value) => {
|
||||
form.setFieldValue('appearance.positionAppName', value);
|
||||
}}
|
||||
data={appNamePositions.map((value) => ({
|
||||
value,
|
||||
label: t(`appearance.positionAppName.dropdown.${value}`)!,
|
||||
}))}
|
||||
{...form.getInputProps('namePosition')}
|
||||
/>
|
||||
<NumberInput
|
||||
label={t('appearance.lineClampAppName.label')}
|
||||
description={t('appearance.lineClampAppName.description')}
|
||||
min={0}
|
||||
{...form.getInputProps('appearance.lineClampAppName')}
|
||||
onChange={(value) => {
|
||||
form.setFieldValue('appearance.lineClampAppName', value);
|
||||
}}
|
||||
{...form.getInputProps('nameLineClamp')}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
@@ -116,5 +91,3 @@ export const AppearanceTab = ({
|
||||
</Tabs.Panel>
|
||||
);
|
||||
};
|
||||
|
||||
const replaceCharacters = (value: string) => value.toLowerCase().replaceAll('', '-');
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
import { Text, TextInput, Tooltip, Stack, Switch, Tabs, Group, useMantineTheme, HoverCard } from '@mantine/core';
|
||||
import { UseFormReturnType } from '@mantine/form';
|
||||
import { Group, Stack, Switch, Tabs, Text, TextInput, useMantineTheme } from '@mantine/core';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { InfoCard } from '~/components/InfoCard/InfoCard';
|
||||
|
||||
import { AppType } from '~/types/app';
|
||||
import { InfoCard } from '~/components/InfoCard/InfoCard'
|
||||
import { AppForm } from '../EditAppModal';
|
||||
|
||||
interface BehaviourTabProps {
|
||||
form: UseFormReturnType<AppType, (values: AppType) => AppType>;
|
||||
form: AppForm;
|
||||
}
|
||||
|
||||
export const BehaviourTab = ({ form }: BehaviourTabProps) => {
|
||||
const { t } = useTranslation('layout/modals/add-app');
|
||||
const { primaryColor } = useMantineTheme();
|
||||
|
||||
return (
|
||||
<Tabs.Panel value="behaviour" pt="xs">
|
||||
@@ -19,21 +17,19 @@ export const BehaviourTab = ({ form }: BehaviourTabProps) => {
|
||||
<Switch
|
||||
label={t('behaviour.isOpeningNewTab.label')}
|
||||
description={t('behaviour.isOpeningNewTab.description')}
|
||||
styles={{ label: { fontWeight: 500, }, description: { marginTop: 0, }, }}
|
||||
{...form.getInputProps('behaviour.isOpeningNewTab', { type: 'checkbox' })}
|
||||
styles={{ label: { fontWeight: 500 }, description: { marginTop: 0 } }}
|
||||
{...form.getInputProps('openInNewTab', { type: 'checkbox' })}
|
||||
/>
|
||||
<Stack spacing="0.25rem">
|
||||
<Group>
|
||||
<Text size="0.875rem" weight={500}>
|
||||
{t('behaviour.tooltipDescription.label')}
|
||||
</Text>
|
||||
<InfoCard message={t('behaviour.tooltipDescription.description')}/>
|
||||
<InfoCard message={t('behaviour.tooltipDescription.description')} />
|
||||
</Group>
|
||||
<TextInput
|
||||
{...form.getInputProps('behaviour.tooltipDescription')}
|
||||
/>
|
||||
<TextInput {...form.getInputProps('description')} />
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Tabs.Panel>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
import { Stack, Tabs, Text, TextInput } from '@mantine/core';
|
||||
import { UseFormReturnType } from '@mantine/form';
|
||||
import { IconClick, IconCursorText, IconLink } from '@tabler/icons-react';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { AppType } from '~/types/app';
|
||||
|
||||
import { EditAppModalTab } from '../EditAppModal';
|
||||
import { AppForm } from '../EditAppModal';
|
||||
|
||||
interface GeneralTabProps {
|
||||
form: UseFormReturnType<AppType, (values: AppType) => AppType>;
|
||||
openTab: (tab: EditAppModalTab) => void;
|
||||
form: AppForm;
|
||||
}
|
||||
|
||||
export const GeneralTab = ({ form, openTab }: GeneralTabProps) => {
|
||||
export const GeneralTab = ({ form }: GeneralTabProps) => {
|
||||
const { t } = useTranslation('layout/modals/add-app');
|
||||
return (
|
||||
<Tabs.Panel value="general" pt="sm">
|
||||
@@ -32,10 +29,7 @@ export const GeneralTab = ({ form, openTab }: GeneralTabProps) => {
|
||||
placeholder="https://google.com"
|
||||
variant="default"
|
||||
withAsterisk
|
||||
{...form.getInputProps('url')}
|
||||
onChange={(e) => {
|
||||
form.setFieldValue('url', e.target.value);
|
||||
}}
|
||||
{...form.getInputProps('internalUrl')}
|
||||
/>
|
||||
<TextInput
|
||||
icon={<IconClick size={16} />}
|
||||
@@ -43,11 +37,12 @@ export const GeneralTab = ({ form, openTab }: GeneralTabProps) => {
|
||||
description={t('general.externalAddress.description')}
|
||||
placeholder="https://homarr.mywebsite.com/"
|
||||
variant="default"
|
||||
{...form.getInputProps('behaviour.externalUrl')}
|
||||
{...form.getInputProps('externalUrl')}
|
||||
/>
|
||||
|
||||
{!form.values.behaviour.externalUrl.startsWith('https://') &&
|
||||
!form.values.behaviour.externalUrl.startsWith('http://') && (
|
||||
{form.values.externalUrl &&
|
||||
!form.values.externalUrl.startsWith('https://') &&
|
||||
!form.values.externalUrl.startsWith('http://') && (
|
||||
<Text color="red" mt="sm" size="sm">
|
||||
{t('behaviour.customProtocolWarning')}
|
||||
</Text>
|
||||
|
||||
@@ -4,16 +4,18 @@ import { IconAlertTriangle } from '@tabler/icons-react';
|
||||
import { Trans, useTranslation } from 'next-i18next';
|
||||
import { AppType } from '~/types/app';
|
||||
|
||||
import { AppForm } from '../../EditAppModal';
|
||||
import { IntegrationSelector } from './InputElements/IntegrationSelector';
|
||||
import { IntegrationOptionsRenderer } from './IntegrationOptionsRenderer/IntegrationOptionsRenderer';
|
||||
|
||||
interface IntegrationTabProps {
|
||||
form: UseFormReturnType<AppType, (values: AppType) => AppType>;
|
||||
form: AppForm;
|
||||
}
|
||||
|
||||
export const IntegrationTab = ({ form }: IntegrationTabProps) => {
|
||||
return <></>; /*
|
||||
const { t } = useTranslation('layout/modals/add-app');
|
||||
const hasIntegrationSelected = form.values.integration?.type;
|
||||
const hasIntegrationSelected = form.values.integrationId?.type;
|
||||
|
||||
return (
|
||||
<Tabs.Panel value="integration" pt="lg">
|
||||
@@ -34,5 +36,5 @@ export const IntegrationTab = ({ form }: IntegrationTabProps) => {
|
||||
</>
|
||||
)}
|
||||
</Tabs.Panel>
|
||||
);
|
||||
);*/
|
||||
};
|
||||
|
||||
@@ -1,30 +1,27 @@
|
||||
import { MultiSelect, Stack, Switch, Tabs } from '@mantine/core';
|
||||
import { UseFormReturnType } from '@mantine/form';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
|
||||
import { StatusCodes } from '~/tools/acceptableStatusCodes';
|
||||
import { AppType } from '~/types/app';
|
||||
|
||||
import { AppForm } from '../EditAppModal';
|
||||
|
||||
interface NetworkTabProps {
|
||||
form: UseFormReturnType<AppType, (values: AppType) => AppType>;
|
||||
form: AppForm;
|
||||
}
|
||||
|
||||
export const NetworkTab = ({ form }: NetworkTabProps) => {
|
||||
const { t } = useTranslation('layout/modals/add-app');
|
||||
const acceptableStatusCodes = (form.values.network.statusCodes ?? ['200']).map((x) =>
|
||||
x.toString()
|
||||
);
|
||||
const acceptableStatusCodes = (form.values.statusCodes ?? [200]).map((x) => x.toString());
|
||||
return (
|
||||
<Tabs.Panel value="network" pt="lg">
|
||||
<Stack spacing="xs">
|
||||
<Switch
|
||||
label={t('network.statusChecker.label')}
|
||||
description={t('network.statusChecker.description')}
|
||||
styles={{ label: { fontWeight: 500, }, description: { marginTop: 0, }, }}
|
||||
defaultChecked={form.values.network.enabledStatusChecker}
|
||||
{...form.getInputProps('network.enabledStatusChecker')}
|
||||
styles={{ label: { fontWeight: 500 }, description: { marginTop: 0 } }}
|
||||
defaultChecked={form.values.isPingEnabled}
|
||||
{...form.getInputProps('isPingEnabled')}
|
||||
/>
|
||||
{form.values.network.enabledStatusChecker && (
|
||||
{form.values.isPingEnabled && (
|
||||
<MultiSelect
|
||||
required
|
||||
label={t('network.statusCodes.label')}
|
||||
@@ -34,7 +31,11 @@ export const NetworkTab = ({ form }: NetworkTabProps) => {
|
||||
searchable
|
||||
defaultValue={acceptableStatusCodes}
|
||||
variant="default"
|
||||
{...form.getInputProps('network.statusCodes')}
|
||||
{...form.getInputProps('statusCodes')}
|
||||
value={form.getInputProps('statusCodes').value.map((x: number) => x.toString())}
|
||||
onChange={(values) => {
|
||||
form.getInputProps('statusCodes').onChange(values.map((x) => parseInt(x, 10)));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Alert, Button, Group, Popover, Stack, Tabs, Text, ThemeIcon } from '@mantine/core';
|
||||
import { useForm } from '@mantine/form';
|
||||
import { Button, Group, Popover, Stack, Tabs, Text, ThemeIcon } from '@mantine/core';
|
||||
import { UseFormReturnType, useForm } from '@mantine/form';
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import { ContextModalProps } from '@mantine/modals';
|
||||
import {
|
||||
@@ -12,17 +12,20 @@ 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';
|
||||
import { z } from 'zod';
|
||||
import { appNamePositions, appNameStyles } from '~/server/db/items';
|
||||
import { objectKeys } from '~/tools/object';
|
||||
import { RouterOutputs } from '~/utils/api';
|
||||
import { useI18nZodResolver } from '~/utils/i18n-zod-resolver';
|
||||
|
||||
import { DebouncedImage } from '../../../IconSelector/DebouncedImage';
|
||||
import { useEditModeStore } from '../../useEditModeStore';
|
||||
import { AppItem } from '../../context';
|
||||
import { AppearanceTab } from './Edit/AppereanceTab';
|
||||
import { BehaviourTab } from './Edit/BehaviourTab';
|
||||
import { GeneralTab } from './Edit/GeneralTab';
|
||||
import { IntegrationTab } from './Edit/IntegrationTab/IntegrationTab';
|
||||
import { NetworkTab } from './Edit/NetworkTab';
|
||||
import { useAppActions } from './app-actions';
|
||||
|
||||
const appUrlRegex =
|
||||
'(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,})';
|
||||
@@ -30,93 +33,47 @@ const appUrlRegex =
|
||||
const appUrlWithAnyProtocolRegex =
|
||||
'([A-z]+://(?: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,}|[A-z]+://(?:www.|(?!www))\\[?[a-zA-Z0-9]+\\]?.[^\\s]{2,}|www.[a-zA-Z0-9]+.[^\\s]{2,})';
|
||||
|
||||
export type EditAppModalInnerProps = {
|
||||
app: AppItem;
|
||||
board: RouterOutputs['boards']['byName'];
|
||||
allowAppNamePropagation: boolean;
|
||||
};
|
||||
|
||||
export const EditAppModal = ({
|
||||
context,
|
||||
id,
|
||||
innerProps,
|
||||
}: ContextModalProps<{ app: AppType; allowAppNamePropagation: boolean }>) => {
|
||||
}: ContextModalProps<EditAppModalInnerProps>) => {
|
||||
const { t } = useTranslation(['layout/modals/add-app', 'common']);
|
||||
const { name: configName, config } = useConfigContext();
|
||||
const updateConfig = useConfigStore((store) => store.updateConfig);
|
||||
const { enabled: isEditMode } = useEditModeStore();
|
||||
const [activeTab, setActiveTab] = useState<EditAppModalTab>('general');
|
||||
const { createOrUpdateApp } = useAppActions({ boardName: innerProps.board.name });
|
||||
// TODO: change to ref
|
||||
const [allowAppNamePropagation, setAllowAppNamePropagation] = useState<boolean>(
|
||||
innerProps.allowAppNamePropagation
|
||||
);
|
||||
|
||||
const form = useForm<AppType>({
|
||||
const { i18nZodResolver } = useI18nZodResolver();
|
||||
|
||||
const form = useForm<FormType>({
|
||||
initialValues: innerProps.app,
|
||||
validate: {
|
||||
name: (name) => (!name ? t('validation.name') : null),
|
||||
url: (url) => {
|
||||
if (!url) {
|
||||
return t('validation.noUrl');
|
||||
}
|
||||
|
||||
if (!url.match(appUrlRegex)) {
|
||||
return t('validation.invalidUrl');
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
appearance: {
|
||||
iconUrl: (url: string) => {
|
||||
if (url.length < 1) {
|
||||
return t('validation.noIconUrl');
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
},
|
||||
behaviour: {
|
||||
externalUrl: (url: string) => {
|
||||
if (url === undefined || url.length < 1) {
|
||||
return t('validation.noExternalUri');
|
||||
}
|
||||
|
||||
if (!url.match(appUrlWithAnyProtocolRegex)) {
|
||||
return t('validation.invalidExternalUri');
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
},
|
||||
},
|
||||
validate: i18nZodResolver(appFormSchema),
|
||||
validateInputOnChange: true,
|
||||
validateInputOnBlur: true,
|
||||
});
|
||||
|
||||
const onSubmit = (values: AppType) => {
|
||||
if (!configName) {
|
||||
return;
|
||||
}
|
||||
|
||||
updateConfig(
|
||||
configName,
|
||||
(previousConfig) => ({
|
||||
...previousConfig,
|
||||
apps: [
|
||||
...previousConfig.apps.filter((x) => x.id !== values.id),
|
||||
{
|
||||
...values,
|
||||
},
|
||||
],
|
||||
}),
|
||||
true,
|
||||
!isEditMode
|
||||
);
|
||||
|
||||
const onSubmit = (values: FormType) => {
|
||||
createOrUpdateApp({ app: values });
|
||||
// also close the parent modal
|
||||
context.closeAll();
|
||||
};
|
||||
|
||||
const [activeTab, setActiveTab] = useState<EditAppModalTab>('general');
|
||||
|
||||
const closeModal = () => {
|
||||
context.closeModal(id);
|
||||
};
|
||||
|
||||
const validationErrors = Object.keys(form.errors);
|
||||
const validationErrors = objectKeys(form.errors) as (keyof AppItem)[];
|
||||
|
||||
const ValidationErrorIndicator = ({ keys }: { keys: string[] }) => {
|
||||
const ValidationErrorIndicator = ({ keys }: { keys: (keyof AppItem)[] }) => {
|
||||
const relevantErrors = validationErrors.filter((x) => keys.includes(x));
|
||||
|
||||
return (
|
||||
@@ -133,15 +90,8 @@ export const EditAppModal = ({
|
||||
|
||||
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">
|
||||
<DebouncedImage src={form.values.appearance.iconUrl} width={120} height={120} />
|
||||
<DebouncedImage src={form.values.iconUrl} width={120} height={120} />
|
||||
|
||||
<Text align="center" weight="bold" size="lg" mt="md">
|
||||
{form.values.name ?? 'New App'}
|
||||
@@ -163,28 +113,34 @@ export const EditAppModal = ({
|
||||
>
|
||||
<Tabs.List grow>
|
||||
<Tabs.Tab
|
||||
rightSection={<ValidationErrorIndicator keys={['name', 'url']} />}
|
||||
rightSection={
|
||||
<ValidationErrorIndicator keys={['name', 'internalUrl', 'externalUrl']} />
|
||||
}
|
||||
icon={<IconAdjustments size={14} />}
|
||||
value="general"
|
||||
>
|
||||
{t('tabs.general')}
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab
|
||||
rightSection={<ValidationErrorIndicator keys={['behaviour.externalUrl']} />}
|
||||
rightSection={<ValidationErrorIndicator keys={['openInNewTab', 'description']} />}
|
||||
icon={<IconClick size={14} />}
|
||||
value="behaviour"
|
||||
>
|
||||
{t('tabs.behaviour')}
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab
|
||||
rightSection={<ValidationErrorIndicator keys={[]} />}
|
||||
rightSection={<ValidationErrorIndicator keys={['isPingEnabled', 'statusCodes']} />}
|
||||
icon={<IconAccessPoint size={14} />}
|
||||
value="network"
|
||||
>
|
||||
{t('tabs.network')}
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab
|
||||
rightSection={<ValidationErrorIndicator keys={['appearance.iconUrl']} />}
|
||||
rightSection={
|
||||
<ValidationErrorIndicator
|
||||
keys={['iconUrl', 'nameStyle', 'fontSize', 'namePosition', 'nameLineClamp']}
|
||||
/>
|
||||
}
|
||||
icon={<IconBrush size={14} />}
|
||||
value="appearance"
|
||||
>
|
||||
@@ -199,7 +155,7 @@ export const EditAppModal = ({
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
<GeneralTab form={form} openTab={(targetTab) => setActiveTab(targetTab)} />
|
||||
<GeneralTab form={form} />
|
||||
<BehaviourTab form={form} />
|
||||
<NetworkTab form={form} />
|
||||
<AppearanceTab
|
||||
@@ -241,3 +197,24 @@ const SaveButton = ({ formIsValid }: { formIsValid: boolean }) => {
|
||||
};
|
||||
|
||||
export type EditAppModalTab = 'general' | 'behaviour' | 'network' | 'appereance' | 'integration';
|
||||
|
||||
export const appFormSchema = z.object({
|
||||
id: z.string().nonempty(),
|
||||
type: z.literal('app'),
|
||||
name: z.string().min(2).max(64),
|
||||
internalUrl: z.string().regex(new RegExp(appUrlRegex)),
|
||||
externalUrl: z.string().regex(new RegExp(appUrlWithAnyProtocolRegex)).nullable(),
|
||||
iconUrl: z.string().nonempty(),
|
||||
nameStyle: z.enum(appNameStyles),
|
||||
namePosition: z.enum(appNamePositions),
|
||||
nameLineClamp: z.number().min(0).max(10),
|
||||
fontSize: z.number().min(5).max(64),
|
||||
isPingEnabled: z.boolean(),
|
||||
statusCodes: z.array(z.number().min(100).max(999)),
|
||||
openInNewTab: z.boolean(),
|
||||
description: z.string().nonempty().max(512).nullable(),
|
||||
integrationId: z.string().nonempty().nullable(),
|
||||
});
|
||||
|
||||
type FormType = z.infer<typeof appFormSchema>;
|
||||
export type AppForm = UseFormReturnType<FormType, (values: FormType) => FormType>;
|
||||
|
||||
54
src/components/Board/Items/App/app-actions.tsx
Normal file
54
src/components/Board/Items/App/app-actions.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import { useCallback } from 'react';
|
||||
import { z } from 'zod';
|
||||
import { api } from '~/utils/api';
|
||||
|
||||
import { AppItem, EmptySection } from '../../context';
|
||||
import { appFormSchema } from './EditAppModal';
|
||||
|
||||
type CreateOrUpdateApp = {
|
||||
app: z.infer<typeof appFormSchema>;
|
||||
};
|
||||
|
||||
export const useAppActions = ({ boardName }: { boardName: string }) => {
|
||||
const utils = api.useContext();
|
||||
const createOrUpdateApp = useCallback(
|
||||
({ app }: CreateOrUpdateApp) => {
|
||||
utils.boards.byName.setData({ boardName }, (prev) => {
|
||||
if (!prev) return prev;
|
||||
|
||||
let sectionId = prev.sections.find((section) =>
|
||||
section.items.some((item) => item.id === app.id)
|
||||
)?.id;
|
||||
|
||||
if (!sectionId) {
|
||||
sectionId = prev.sections
|
||||
.filter((section): section is EmptySection => section.type === 'empty')
|
||||
.sort((a, b) => a.position - b.position)[0].id;
|
||||
}
|
||||
|
||||
return {
|
||||
...prev,
|
||||
sections: prev.sections.map((section) => {
|
||||
// Return same section if item is not in it
|
||||
if (section.id !== sectionId) return section;
|
||||
return {
|
||||
...section,
|
||||
items: section.items.map((item) => {
|
||||
// Return same item if item is not the one we're moving
|
||||
if (item.id !== app.id) return item;
|
||||
return {
|
||||
...(app as AppItem),
|
||||
};
|
||||
}),
|
||||
};
|
||||
}),
|
||||
};
|
||||
});
|
||||
},
|
||||
[boardName, utils]
|
||||
);
|
||||
|
||||
return {
|
||||
createOrUpdateApp,
|
||||
};
|
||||
};
|
||||
@@ -27,7 +27,6 @@ export const BoardEmptySection = ({ section }: EmptySectionWrapperProps) => {
|
||||
data-empty={section.id}
|
||||
ref={refs.wrapper}
|
||||
>
|
||||
{section.items.length === 0 && <span></span>}
|
||||
<SectionContent items={section.items} refs={refs} />
|
||||
</div>
|
||||
</GridstackProvider>
|
||||
|
||||
@@ -7,31 +7,35 @@ import { useTranslation } from 'next-i18next';
|
||||
import { ReactNode } from 'react';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { CategoryEditModalInnerProps } from '~/components/Board/Sections/Category/CategoryEditModal';
|
||||
import { useConfigContext } from '~/config/provider';
|
||||
import { useConfigStore } from '~/config/store';
|
||||
import { openContextModalGeneric } from '~/tools/mantineModalManagerExtensions';
|
||||
import { generateDefaultApp } from '~/tools/shared/app';
|
||||
import { AppType } from '~/types/app';
|
||||
import { generateDefaultApp2 } from '~/tools/shared/app';
|
||||
import { RouterOutputs } from '~/utils/api';
|
||||
|
||||
import { EditAppModalInnerProps } from '../../Items/App/EditAppModal';
|
||||
import { useCategoryActions } from '../../Sections/Category/Actions/category-actions';
|
||||
import { AppItem, CategorySection, EmptySection } from '../../context';
|
||||
import { useStyles } from '../Shared/styles';
|
||||
|
||||
interface AvailableElementTypesProps {
|
||||
modalId: string;
|
||||
board: RouterOutputs['boards']['byName'];
|
||||
onOpenIntegrations: () => void;
|
||||
onOpenStaticElements: () => void;
|
||||
}
|
||||
|
||||
export const AvailableElementTypes = ({
|
||||
modalId,
|
||||
board,
|
||||
onOpenIntegrations: onOpenWidgets,
|
||||
onOpenStaticElements,
|
||||
}: AvailableElementTypesProps) => {
|
||||
const { t } = useTranslation('layout/element-selector/selector');
|
||||
const { config, name: configName } = useConfigContext();
|
||||
const { updateConfig } = useConfigStore();
|
||||
const getLowestWrapper = () => config?.wrappers.sort((a, b) => a.position - b.position)[0];
|
||||
const { addCategory } = useCategoryActions({ boardName: board.name });
|
||||
const lastSection = board.sections
|
||||
.filter((x): x is CategorySection | EmptySection => x.type === 'empty' || x.type === 'category')
|
||||
.sort((a, b) => b.position - a.position)
|
||||
.at(0);
|
||||
|
||||
const onClickCreateCategory = async () => {
|
||||
if (!lastSection) return;
|
||||
openContextModalGeneric<CategoryEditModalInnerProps>({
|
||||
modal: 'categoryEditModal',
|
||||
title: t('category.newName'),
|
||||
@@ -43,33 +47,12 @@ export const AvailableElementTypes = ({
|
||||
position: 0, // doesn't matter, is being overwritten
|
||||
},
|
||||
onSuccess: async (category) => {
|
||||
if (!configName) return;
|
||||
|
||||
await updateConfig(configName, (previousConfig) => ({
|
||||
...previousConfig,
|
||||
wrappers: [
|
||||
...previousConfig.wrappers,
|
||||
{
|
||||
id: uuidv4(),
|
||||
// Thank you ChatGPT ;)
|
||||
position: previousConfig.categories.length + 1,
|
||||
},
|
||||
],
|
||||
categories: [
|
||||
...previousConfig.categories,
|
||||
{
|
||||
id: uuidv4(),
|
||||
name: category.name,
|
||||
position: previousConfig.categories.length + 1,
|
||||
},
|
||||
],
|
||||
})).then(() => {
|
||||
closeModal(modalId);
|
||||
showNotification({
|
||||
title: t('category.created.title'),
|
||||
message: t('category.created.message', { name: category.name }),
|
||||
color: 'teal',
|
||||
});
|
||||
addCategory({ name: category.name, position: lastSection.position + 1 });
|
||||
closeModal(modalId);
|
||||
showNotification({
|
||||
title: t('category.created.title'),
|
||||
message: t('category.created.message', { name: category.name }),
|
||||
color: 'teal',
|
||||
});
|
||||
},
|
||||
},
|
||||
@@ -85,11 +68,11 @@ export const AvailableElementTypes = ({
|
||||
name={t('apps')}
|
||||
icon={<IconBox size={40} strokeWidth={1.3} />}
|
||||
onClick={() => {
|
||||
openContextModalGeneric<{ app: AppType; allowAppNamePropagation: boolean }>({
|
||||
openContextModalGeneric<EditAppModalInnerProps>({
|
||||
modal: 'editApp',
|
||||
innerProps: {
|
||||
app: generateDefaultApp(getLowestWrapper()?.id ?? 'default'),
|
||||
// TODO: Add translation? t('app.defaultName')
|
||||
app: generateDefaultApp2() as AppItem,
|
||||
board: board,
|
||||
allowAppNamePropagation: true,
|
||||
},
|
||||
size: 'xl',
|
||||
|
||||
@@ -1,29 +1,26 @@
|
||||
import { ContextModalProps } from '@mantine/modals';
|
||||
import { useState } from 'react';
|
||||
import { RouterOutputs } from '~/utils/api';
|
||||
|
||||
import { AvailableElementTypes } from './Overview/AvailableElementsOverview';
|
||||
import { AvailableStaticTypes } from './StaticElementsTab/AvailableStaticElementsTab';
|
||||
import { AvailableIntegrationElements } from './WidgetsTab/AvailableWidgetsTab';
|
||||
|
||||
export const SelectElementModal = ({ context, id }: ContextModalProps) => {
|
||||
const [activeTab, setActiveTab] = useState<undefined | 'integrations' | 'static_elements'>();
|
||||
|
||||
switch (activeTab) {
|
||||
case undefined:
|
||||
return (
|
||||
<AvailableElementTypes
|
||||
modalId={id}
|
||||
onOpenIntegrations={() => setActiveTab('integrations')}
|
||||
onOpenStaticElements={() => setActiveTab('static_elements')}
|
||||
/>
|
||||
);
|
||||
case 'integrations':
|
||||
return <AvailableIntegrationElements onClickBack={() => setActiveTab(undefined)} />;
|
||||
case 'static_elements':
|
||||
return <AvailableStaticTypes onClickBack={() => setActiveTab(undefined)} />;
|
||||
default:
|
||||
/* default to the main selection tab */
|
||||
setActiveTab(undefined);
|
||||
return <></>;
|
||||
}
|
||||
type InnerProps = {
|
||||
board: RouterOutputs['boards']['byName'];
|
||||
};
|
||||
|
||||
export const SelectElementModal = ({ id, innerProps }: ContextModalProps<InnerProps>) => {
|
||||
const [activeTab, setActiveTab] = useState<undefined | 'integrations'>();
|
||||
|
||||
if (activeTab === 'integrations') {
|
||||
return <AvailableIntegrationElements onClickBack={() => setActiveTab(undefined)} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<AvailableElementTypes
|
||||
modalId={id}
|
||||
board={innerProps.board}
|
||||
onOpenIntegrations={() => setActiveTab('integrations')}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
import { Grid, Text } from '@mantine/core';
|
||||
import { IconCursorText } from '@tabler/icons-react';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
|
||||
import { GenericAvailableElementType } from '../Shared/GenericElementType';
|
||||
import { SelectorBackArrow } from '../Shared/SelectorBackArrow';
|
||||
|
||||
interface AvailableStaticTypesProps {
|
||||
onClickBack: () => void;
|
||||
}
|
||||
|
||||
export const AvailableStaticTypes = ({ onClickBack }: AvailableStaticTypesProps) => {
|
||||
const { t } = useTranslation('layout/element-selector/selector');
|
||||
return (
|
||||
<>
|
||||
<SelectorBackArrow onClickBack={onClickBack} />
|
||||
|
||||
<Text mb="md" color="dimmed">
|
||||
Static elements provide you additional control over your dashboard. They are static, because
|
||||
they don't integrate with any apps and their content never changes.
|
||||
</Text>
|
||||
|
||||
<Grid grow>
|
||||
<GenericAvailableElementType
|
||||
name="Static Text"
|
||||
description="Display a fixed string on your dashboard"
|
||||
image={IconCursorText}
|
||||
handleAddition={/* TODO: add something? */ async () => {}}
|
||||
/>
|
||||
</Grid>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -65,7 +65,7 @@ function setAttributesFromShape(ref: GridItemHTMLElement | null, item: Item) {
|
||||
ref.setAttribute('gs-h', item.height.toString());
|
||||
}
|
||||
|
||||
export type TileWithUnknownLocation = {
|
||||
export type ItemWithUnknownLocation = {
|
||||
x?: number;
|
||||
y?: number;
|
||||
w?: number;
|
||||
|
||||
@@ -189,6 +189,7 @@ const ToggleEditModeButton = () => {
|
||||
|
||||
const AddElementButton = () => {
|
||||
const { t } = useTranslation('layout/element-selector/selector');
|
||||
const board = useRequiredBoard();
|
||||
|
||||
return (
|
||||
<Tooltip label={t('actionIcon.tooltip')}>
|
||||
@@ -198,7 +199,9 @@ const AddElementButton = () => {
|
||||
modal: 'selectElement',
|
||||
title: t('modal.title'),
|
||||
size: 'xl',
|
||||
innerProps: {},
|
||||
innerProps: {
|
||||
board,
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
|
||||
@@ -48,8 +48,8 @@ export const widgetOptionTypes = [
|
||||
'array',
|
||||
'null',
|
||||
] as const;
|
||||
export const appNamePosition = ['right', 'left', 'top', 'bottom'] as const;
|
||||
export const appNameStyles = ['show', 'hide', 'hover'] as const;
|
||||
export const appNamePositions = ['right', 'left', 'top', 'bottom'] as const;
|
||||
export const appNameStyles = ['normal', 'hide', 'hover'] as const;
|
||||
export const statusCodeTypes = [
|
||||
'information',
|
||||
'success',
|
||||
@@ -158,7 +158,7 @@ export type IntegrationSecretVisibility = (typeof integrationSecretVisibility)[n
|
||||
export type IntegrationSecretKey = keyof typeof integrationSecrets;
|
||||
export type WidgetType = (typeof widgetTypes)[number];
|
||||
export type WidgetOptionType = (typeof widgetOptionTypes)[number];
|
||||
export type AppNamePosition = (typeof appNamePosition)[number];
|
||||
export type AppNamePosition = (typeof appNamePositions)[number];
|
||||
export type AppNameStyle = (typeof appNameStyles)[number];
|
||||
export type StatusCodeType = (typeof statusCodeTypes)[number];
|
||||
export type SectionType = (typeof sectionTypes)[number];
|
||||
|
||||
@@ -199,7 +199,7 @@ export const apps = sqliteTable('app', {
|
||||
description: text('description'),
|
||||
internalUrl: text('internal_url').notNull(),
|
||||
externalUrl: text('external_url'),
|
||||
iconUrl: text('icon_url'),
|
||||
iconUrl: text('icon_url').notNull(),
|
||||
integrationId: text('integration_id').references(() => integrations.id, { onDelete: 'cascade' }),
|
||||
});
|
||||
|
||||
@@ -208,7 +208,7 @@ export const appItems = sqliteTable('app_item', {
|
||||
isPingEnabled: int('is_ping_enabled', { mode: 'boolean' }).notNull().default(false),
|
||||
fontSize: int('font_size').notNull().default(16),
|
||||
namePosition: text('name_position').$type<AppNamePosition>().notNull().default('top'),
|
||||
nameStyle: text('name_style').$type<AppNameStyle>().notNull().default('show'),
|
||||
nameStyle: text('name_style').$type<AppNameStyle>().notNull().default('normal'),
|
||||
nameLineClamp: int('name_line_clamp').notNull().default(1),
|
||||
appId: text('app_id')
|
||||
.notNull()
|
||||
|
||||
@@ -1,6 +1,26 @@
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { AppItem } from '~/components/Board/context';
|
||||
import { AppType } from '~/types/app';
|
||||
|
||||
export const generateDefaultApp2 = (): Omit<AppItem, 'height' | 'width' | 'x' | 'y'> => ({
|
||||
id: uuidv4(),
|
||||
type: 'app',
|
||||
name: 'Your app',
|
||||
internalUrl: 'https://homarr.dev',
|
||||
externalUrl: 'https://homarr.dev',
|
||||
iconUrl: '/imgs/logo/logo.png',
|
||||
nameStyle: 'normal',
|
||||
namePosition: 'top',
|
||||
nameLineClamp: 1,
|
||||
fontSize: 16,
|
||||
isPingEnabled: true,
|
||||
statusCodes: [200, 301, 302, 304, 307, 308],
|
||||
openInNewTab: true,
|
||||
description: null,
|
||||
integration: null,
|
||||
integrationId: null,
|
||||
});
|
||||
|
||||
export const generateDefaultApp = (wrapperId: string): AppType =>
|
||||
({
|
||||
id: uuidv4(),
|
||||
@@ -11,7 +31,7 @@ export const generateDefaultApp = (wrapperId: string): AppType =>
|
||||
appNameStatus: 'normal',
|
||||
positionAppName: 'column',
|
||||
lineClampAppName: 1,
|
||||
appNameFontSize: 16
|
||||
appNameFontSize: 16,
|
||||
},
|
||||
network: {
|
||||
enabledStatusChecker: true,
|
||||
|
||||
Reference in New Issue
Block a user