feature: board operations (#1800)

This commit is contained in:
Manuel
2024-01-13 22:52:44 +01:00
committed by GitHub
parent 6717bcf8b4
commit c7992260f0
7 changed files with 241 additions and 22 deletions

View File

@@ -15,6 +15,19 @@
"delete": { "delete": {
"label": "Delete permanently", "label": "Delete permanently",
"disabled": "Deletion disabled, because older Homarr components do not allow the deletion of the default config. Deletion will be possible in the future." "disabled": "Deletion disabled, because older Homarr components do not allow the deletion of the default config. Deletion will be possible in the future."
},
"duplicate": "Duplicate",
"rename": {
"label": "Rename",
"modal": {
"title": "Rename board {{name}}",
"fields": {
"name": {
"label": "New name",
"placeholder": "New board name"
}
}
}
} }
}, },
"badges": { "badges": {

View File

@@ -0,0 +1,68 @@
import { modals } from '@mantine/modals';
import { Alert, Button, TextInput } from '@mantine/core';
import { api } from '~/utils/api';
import { useForm, zodResolver } from '@mantine/form';
import { z } from 'zod';
import { IconAlertCircle } from '@tabler/icons-react';
import { useTranslation } from 'next-i18next';
import { configNameSchema } from '~/validations/boards';
type RenameBoardModalProps = {
boardName: string;
configNames: string[];
onClose: () => void;
}
export const RenameBoardModal = ({ boardName, configNames, onClose }: RenameBoardModalProps) => {
const { t } = useTranslation(['manage/boards', 'common']);
const utils = api.useUtils();
const { mutateAsync: mutateRenameBoardAsync, isLoading, isError, error } = api.boards.renameBoard.useMutation({
onSettled: () => {
void utils.boards.all.invalidate();
}
});
const form = useForm({
initialValues: {
newName: '',
},
validate: zodResolver(z.object({
newName: configNameSchema.refine(value => !configNames.includes(value)),
})),
validateInputOnBlur: true,
validateInputOnChange: true,
});
const handleSubmit = () => {
mutateRenameBoardAsync({
oldName: boardName,
newName: form.values.newName,
}).then(() => {
onClose();
});
};
return (
<form onSubmit={form.onSubmit(handleSubmit)}>
{isError && error && (
<Alert icon={<IconAlertCircle size={"1rem"} />} mb={"md"}>
{error.message}
</Alert>
)}
<TextInput
label={t('cards.menu.rename.modal.fields.name.label')}
placeholder={t('cards.menu.rename.modal.fields.name.placeholder') as string}
data-autofocus
{...form.getInputProps('newName')} />
<Button
loading={isLoading}
fullWidth
mt="md"
type={'submit'}
variant={"light"}>
{t('common:confirm')}
</Button>
</form>
);
};

View File

@@ -30,7 +30,7 @@ export const modals = {
copyInviteModal: CopyInviteModal, copyInviteModal: CopyInviteModal,
deleteBoardModal: DeleteBoardModal, deleteBoardModal: DeleteBoardModal,
changeUserRoleModal: ChangeUserRoleModal, changeUserRoleModal: ChangeUserRoleModal,
dockerSelectBoardModal: DockerSelectBoardModal, dockerSelectBoardModal: DockerSelectBoardModal
}; };
declare module '@mantine/modals' { declare module '@mantine/modals' {

View File

@@ -5,16 +5,17 @@ import {
Card, Card,
Group, Group,
LoadingOverlay, LoadingOverlay,
Menu, Menu, Modal,
SimpleGrid, SimpleGrid,
Stack, Stack,
Text, Text,
Title, Title,
} from '@mantine/core'; } from '@mantine/core';
import { useListState } from '@mantine/hooks'; import { useDisclosure, useListState } from '@mantine/hooks';
import { import {
IconBox, IconBox,
IconCategory, IconCategory,
IconCopy, IconCursorText,
IconDeviceFloppy, IconDeviceFloppy,
IconDotsVertical, IconDotsVertical,
IconFolderFilled, IconFolderFilled,
@@ -39,19 +40,40 @@ import { getServerSideTranslations } from '~/tools/server/getServerSideTranslati
import { checkForSessionOrAskForLogin } from '~/tools/server/loginBuilder'; import { checkForSessionOrAskForLogin } from '~/tools/server/loginBuilder';
import { manageNamespaces } from '~/tools/server/translation-namespaces'; import { manageNamespaces } from '~/tools/server/translation-namespaces';
import { api } from '~/utils/api'; import { api } from '~/utils/api';
import { modals } from '@mantine/modals';
import { notifications } from '@mantine/notifications';
import { RenameBoardModal } from '~/components/Dashboard/Modals/RenameBoard/RenameBoardModal';
import { useState } from 'react';
// Infer return type from the `getServerSideProps` function // Infer return type from the `getServerSideProps` function
export default function BoardsPage({ export default function BoardsPage({
boards, boards,
session, session,
}: InferGetServerSidePropsType<typeof getServerSideProps>) { }: InferGetServerSidePropsType<typeof getServerSideProps>) {
const [openedRenameBoardModal, { open: openRenameBoardModal, close: closeRenameBoardModal }] = useDisclosure(false);
const [renameBoardName, setRenameBoardName] = useState<{ boardName: string }>();
const { data, refetch } = api.boards.all.useQuery(undefined, { const { data, refetch } = api.boards.all.useQuery(undefined, {
initialData: boards, initialData: boards,
cacheTime: 1000 * 60 * 5, // Cache for 5 minutes cacheTime: 1000 * 60 * 5, // Cache for 5 minutes
}); });
const { mutateAsync } = api.user.makeDefaultDashboard.useMutation({ const { mutateAsync } = api.user.makeDefaultDashboard.useMutation({
onSettled: () => { onSettled: () => {
refetch(); void refetch();
},
});
const utils = api.useUtils();
const { mutateAsync: mutateDuplicateBoardAsync } = api.boards.duplicateBoard.useMutation({
onSettled: () => {
void utils.boards.all.invalidate();
},
onError: (error) => {
notifications.show({
title: 'An error occurred while duplicating',
message: error.message,
});
}, },
}); });
@@ -67,6 +89,12 @@ export default function BoardsPage({
<title>{metaTitle}</title> <title>{metaTitle}</title>
</Head> </Head>
<Modal opened={openedRenameBoardModal} onClose={closeRenameBoardModal}
title={t('cards.menu.rename.modal.title', { name: renameBoardName?.boardName })}>
<RenameBoardModal boardName={renameBoardName?.boardName as string} configNames={data.map(board => board.name)}
onClose={closeRenameBoardModal} />
</Modal>
<Group position="apart"> <Group position="apart">
<Title mb="xl">{t('pageTitle')}</Title> <Title mb="xl">{t('pageTitle')}</Title>
{session?.user.isAdmin && ( {session?.user.isAdmin && (
@@ -165,6 +193,26 @@ export default function BoardsPage({
</ActionIcon> </ActionIcon>
</Menu.Target> </Menu.Target>
<Menu.Dropdown> <Menu.Dropdown>
<Menu.Item
onClick={async () => {
await mutateDuplicateBoardAsync({
boardName: board.name,
});
}}
icon={<IconCopy size={'1rem'} />}>
{t('cards.menu.duplicate')}
</Menu.Item>
<Menu.Item
onClick={() => {
setRenameBoardName({
boardName: board.name as string
});
openRenameBoardModal();
}}
icon={<IconCursorText size={'1rem'} />}
disabled={board.name === 'default'}>
{t('cards.menu.rename.label')}
</Menu.Item>
<Menu.Item <Menu.Item
icon={<IconDeviceFloppy size="1rem" />} icon={<IconDeviceFloppy size="1rem" />}
onClick={async () => { onClick={async () => {
@@ -177,7 +225,6 @@ export default function BoardsPage({
</Menu.Item> </Menu.Item>
{session?.user.isAdmin && ( {session?.user.isAdmin && (
<> <>
<Menu.Divider />
<Menu.Item <Menu.Item
onClick={async () => { onClick={async () => {
openDeleteBoardModal({ openDeleteBoardModal({
@@ -216,7 +263,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
const result = checkForSessionOrAskForLogin( const result = checkForSessionOrAskForLogin(
context, context,
session, session,
() => session?.user.isAdmin == true () => session?.user.isAdmin == true,
); );
if (result !== undefined) { if (result !== undefined) {
return result; return result;
@@ -233,7 +280,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
manageNamespaces, manageNamespaces,
context.locale, context.locale,
context.req, context.req,
context.res context.res,
); );
return { return {

View File

@@ -1,6 +1,7 @@
import { TRPCError } from '@trpc/server'; import { TRPCError } from '@trpc/server';
import fs from 'fs'; import fs from 'fs';
import { z } from 'zod'; import { z } from 'zod';
import Consola from 'consola';
import { getDefaultBoardAsync } from '~/server/db/queries/userSettings'; import { getDefaultBoardAsync } from '~/server/db/queries/userSettings';
import { configExists } from '~/tools/config/configExists'; import { configExists } from '~/tools/config/configExists';
import { getConfig } from '~/tools/config/getConfig'; import { getConfig } from '~/tools/config/getConfig';
@@ -8,7 +9,8 @@ import { getFrontendConfig } from '~/tools/config/getFrontendConfig';
import { generateDefaultApp } from '~/tools/shared/app'; import { generateDefaultApp } from '~/tools/shared/app';
import { adminProcedure, createTRPCRouter, protectedProcedure } from '../trpc'; import { adminProcedure, createTRPCRouter, protectedProcedure } from '../trpc';
import { configNameSchema } from './config'; import { writeConfig } from '~/tools/config/writeConfig';
import { configNameSchema } from '~/validations/boards';
export const boardRouter = createTRPCRouter({ export const boardRouter = createTRPCRouter({
all: protectedProcedure.query(async ({ ctx }) => { all: protectedProcedure.query(async ({ ctx }) => {
@@ -31,7 +33,7 @@ export const boardRouter = createTRPCRouter({
countCategories: config.categories.length, countCategories: config.categories.length,
isDefaultForUser: name === defaultBoard, isDefaultForUser: name === defaultBoard,
}; };
}) }),
); );
}), }),
addAppsForContainers: adminProcedure addAppsForContainers: adminProcedure
@@ -43,18 +45,18 @@ export const boardRouter = createTRPCRouter({
name: z.string(), name: z.string(),
icon: z.string().optional(), icon: z.string().optional(),
port: z.number().optional(), port: z.number().optional(),
}) }),
), ),
}) }),
) )
.mutation(async ({ input }) => { .mutation(async ({ input }) => {
if (!(await configExists(input.boardName))) { if (!configExists(input.boardName)) {
throw new TRPCError({ throw new TRPCError({
code: 'NOT_FOUND', code: 'NOT_FOUND',
message: 'Board not found', message: 'Board not found',
}); });
} }
const config = await getConfig(input.boardName); const config = getConfig(input.boardName);
const lowestWrapper = config?.wrappers.sort((a, b) => a.position - b.position)[0]; const lowestWrapper = config?.wrappers.sort((a, b) => a.position - b.position)[0];
const newConfig = { const newConfig = {
@@ -86,4 +88,96 @@ export const boardRouter = createTRPCRouter({
const targetPath = `data/configs/${input.boardName}.json`; const targetPath = `data/configs/${input.boardName}.json`;
fs.writeFileSync(targetPath, JSON.stringify(newConfig, null, 2), 'utf8'); fs.writeFileSync(targetPath, JSON.stringify(newConfig, null, 2), 'utf8');
}), }),
renameBoard: protectedProcedure
.input(z.object({
oldName: z.string(),
newName: z.string().min(1),
}))
.mutation(async ({ input }) => {
if (input.oldName === 'default') {
Consola.error(`Attempted to rename default configuration. Aborted deletion.`);
throw new TRPCError({
code: 'CONFLICT',
message: 'Cannot rename default board',
});
}
if (!configExists(input.oldName)) {
Consola.error(`Specified configuration ${input.oldName} does not exist on file system`);
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Board not found',
});
}
if (configExists(input.newName)) {
Consola.error(`Target name of rename conflicts with existing board`);
throw new TRPCError({
code: 'CONFLICT',
message: 'Board conflicts with existing board',
});
}
const config = getConfig(input.oldName);
config.configProperties.name = input.newName;
writeConfig(config);
Consola.info(`Deleting ${input.oldName} from the file system`);
const targetPath = `data/configs/${input.oldName}.json`;
fs.unlinkSync(targetPath);
Consola.info(`Deleted ${input.oldName} from file system`);
}),
duplicateBoard: protectedProcedure
.input(z.object({
boardName: z.string(),
}))
.mutation(async ({ input }) => {
if (!configExists(input.boardName)) {
Consola.error(`Tried to duplicate ${input.boardName} but this configuration does not exist.`);
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Board not found',
});
}
const targetName = attemptGenerateDuplicateName(input.boardName, 10);
Consola.info(`Target duplication name ${targetName} does not exist`);
const config = getConfig(input.boardName);
config.configProperties.name = targetName;
writeConfig(config);
Consola.info(`Wrote config to name '${targetName}'`)
}),
}); });
const duplicationName = /^(\w+)\s{1}\(([0-9]+)\)$/;
const attemptGenerateDuplicateName = (baseName: string, maxAttempts: number) => {
for (let i = 0; i < maxAttempts; i++) {
const newName = generateDuplicateName(baseName, i);
if (configExists(newName)) {
continue;
}
return newName;
}
Consola.error(`Duplication name ${baseName} conflicts with an existing configuration`);
throw new TRPCError({
code: 'CONFLICT',
message: 'Board conflicts with an existing board',
});
}
const generateDuplicateName = (baseName: string, increment: number) => {
const result = duplicationName.exec(baseName);
if (result && result.length === 3) {
const originalName = result.at(1);
const counter = Number(result.at(2));
return `${originalName} (${counter + 1 + increment})`;
}
return `${baseName} (2)`;
}

View File

@@ -8,15 +8,10 @@ import { configExists } from '~/tools/config/configExists';
import { getConfig } from '~/tools/config/getConfig'; import { getConfig } from '~/tools/config/getConfig';
import { getFrontendConfig } from '~/tools/config/getFrontendConfig'; import { getFrontendConfig } from '~/tools/config/getFrontendConfig';
import { BackendConfigType, ConfigType } from '~/types/config'; import { BackendConfigType, ConfigType } from '~/types/config';
import { boardCustomizationSchema } from '~/validations/boards'; import { boardCustomizationSchema, configNameSchema } from '~/validations/boards';
import { IRssWidget } from '~/widgets/rss/RssWidgetTile'; import { IRssWidget } from '~/widgets/rss/RssWidgetTile';
import { adminProcedure, createTRPCRouter, publicProcedure } from '../trpc'; import { adminProcedure, createTRPCRouter, publicProcedure } from '../trpc';
import { db } from '~/server/db';
import { users } from '~/server/db/schema';
import { sql } from 'drizzle-orm';
export const configNameSchema = z.string().regex(/^[a-zA-Z0-9-_]+$/);
export const configRouter = createTRPCRouter({ export const configRouter = createTRPCRouter({
delete: adminProcedure delete: adminProcedure

View File

@@ -2,8 +2,10 @@ import { DEFAULT_THEME, MANTINE_COLORS, MantineColor } from '@mantine/core';
import { z } from 'zod'; import { z } from 'zod';
import { BackgroundImageAttachment, BackgroundImageRepeat, BackgroundImageSize } from '~/types/settings'; import { BackgroundImageAttachment, BackgroundImageRepeat, BackgroundImageSize } from '~/types/settings';
export const configNameSchema = z.string().regex(/^[a-zA-Z0-9-_\s()]+$/);
export const createBoardSchemaValidation = z.object({ export const createBoardSchemaValidation = z.object({
name: z.string().min(2).max(25), name: configNameSchema,
}); });
export const boardCustomizationSchema = z.object({ export const boardCustomizationSchema = z.object({