♻️ Add edit app action for new board

This commit is contained in:
Meier Lukas
2023-10-11 22:03:55 +02:00
parent f7fc4315d6
commit 98007f3776
18 changed files with 251 additions and 282 deletions

View File

@@ -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}

View File

@@ -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: {

View File

@@ -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('', '-');

View File

@@ -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>
);
};
};

View File

@@ -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>

View File

@@ -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>
);
);*/
};

View File

@@ -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>

View File

@@ -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>;

View 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,
};
};

View File

@@ -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>

View File

@@ -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',

View File

@@ -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')}
/>
);
};

View File

@@ -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&apos;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>
</>
);
};

View File

@@ -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;

View File

@@ -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,
},
})
}
>

View File

@@ -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];

View File

@@ -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()

View File

@@ -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,