From 2bc8cc102785dc2fa767902a46fb2d430b568359 Mon Sep 17 00:00:00 2001 From: Manuel <30572287+manuel-rw@users.noreply.github.com> Date: Sun, 7 Jan 2024 23:24:10 +0100 Subject: [PATCH] feature: board operations --- .../Modals/RenameBoard/RenameBoardModal.tsx | 63 +++++++++++ src/modals.ts | 2 + src/pages/manage/boards/index.tsx | 52 ++++++++- src/server/api/routers/board.ts | 104 +++++++++++++++++- 4 files changed, 210 insertions(+), 11 deletions(-) create mode 100644 src/components/Dashboard/Modals/RenameBoard/RenameBoardModal.tsx diff --git a/src/components/Dashboard/Modals/RenameBoard/RenameBoardModal.tsx b/src/components/Dashboard/Modals/RenameBoard/RenameBoardModal.tsx new file mode 100644 index 00000000000..9f39959690e --- /dev/null +++ b/src/components/Dashboard/Modals/RenameBoard/RenameBoardModal.tsx @@ -0,0 +1,63 @@ +import { ContextModalProps, 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'; + +type RenameBoardModalProps = { + configName: string; + configNames: string[]; +} + +export const RenameBoardModal = ({ context, id, innerProps }: ContextModalProps) => { + 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: z.string().min(1).refine(value => !innerProps.configNames.includes(value)), + })), + validateInputOnBlur: true, + validateInputOnChange: true, + }); + + const handleSubmit = () => { + mutateRenameBoardAsync({ + oldName: innerProps.configName, + newName: form.values.newName, + }).then(() => { + modals.close(id); + }); + }; + + return ( +
+ {isError && error && ( + } mb={"md"}> + {error.message} + + )} + + + + ); +}; diff --git a/src/modals.ts b/src/modals.ts index 4faea9bd3b6..0cee42c137e 100644 --- a/src/modals.ts +++ b/src/modals.ts @@ -14,6 +14,7 @@ import { CreateInviteModal } from './components/Manage/User/Invite/create-invite import { DeleteInviteModal } from './components/Manage/User/Invite/delete-invite.modal'; import { ChangeUserRoleModal } from './components/Manage/User/change-user-role.modal'; import { DeleteUserModal } from './components/Manage/User/delete-user.modal'; +import { RenameBoardModal } from '~/components/Dashboard/Modals/RenameBoard/RenameBoardModal'; export const modals = { editApp: EditAppModal, @@ -31,6 +32,7 @@ export const modals = { deleteBoardModal: DeleteBoardModal, changeUserRoleModal: ChangeUserRoleModal, dockerSelectBoardModal: DockerSelectBoardModal, + renameBoardModal: RenameBoardModal }; declare module '@mantine/modals' { diff --git a/src/pages/manage/boards/index.tsx b/src/pages/manage/boards/index.tsx index 822bfeef1b6..ab7a0be5e1e 100644 --- a/src/pages/manage/boards/index.tsx +++ b/src/pages/manage/boards/index.tsx @@ -8,13 +8,14 @@ import { Menu, SimpleGrid, Stack, - Text, + Text, TextInput, Title, } from '@mantine/core'; import { useListState } from '@mantine/hooks'; import { IconBox, IconCategory, + IconCopy, IconCursorText, IconDeviceFloppy, IconDotsVertical, IconFolderFilled, @@ -39,11 +40,13 @@ import { getServerSideTranslations } from '~/tools/server/getServerSideTranslati import { checkForSessionOrAskForLogin } from '~/tools/server/loginBuilder'; import { manageNamespaces } from '~/tools/server/translation-namespaces'; import { api } from '~/utils/api'; +import { modals } from '@mantine/modals'; +import { notifications } from '@mantine/notifications'; // Infer return type from the `getServerSideProps` function export default function BoardsPage({ - boards, - session, + boards, + session, }: InferGetServerSidePropsType) { const { data, refetch } = api.boards.all.useQuery(undefined, { initialData: boards, @@ -55,6 +58,20 @@ export default function BoardsPage({ }, }); + 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 + }) + } + }); + const [deletingDashboards, { append, filter }] = useListState([]); const { t } = useTranslation('manage/boards'); @@ -165,6 +182,30 @@ export default function BoardsPage({ + { + await mutateDuplicateBoardAsync({ + boardName: board.name + }); + }} + icon={}> + Duplicate + + { + modals.openContextModal({ + modal: 'renameBoardModal', + title: `Rename board ${board.name}`, + innerProps: { + configName: board.name, + configNames: data.map(board => board.name) + } + }) + }} + icon={} + disabled={board.name === 'default'}> + Rename + } onClick={async () => { @@ -177,7 +218,6 @@ export default function BoardsPage({ {session?.user.isAdmin && ( <> - { openDeleteBoardModal({ @@ -216,7 +256,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) => const result = checkForSessionOrAskForLogin( context, session, - () => session?.user.isAdmin == true + () => session?.user.isAdmin == true, ); if (result !== undefined) { return result; @@ -233,7 +273,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) => manageNamespaces, context.locale, context.req, - context.res + context.res, ); return { diff --git a/src/server/api/routers/board.ts b/src/server/api/routers/board.ts index 72bab7eaf89..f66efe232fd 100644 --- a/src/server/api/routers/board.ts +++ b/src/server/api/routers/board.ts @@ -1,6 +1,7 @@ import { TRPCError } from '@trpc/server'; import fs from 'fs'; import { z } from 'zod'; +import Consola from 'consola'; import { getDefaultBoardAsync } from '~/server/db/queries/userSettings'; import { configExists } from '~/tools/config/configExists'; import { getConfig } from '~/tools/config/getConfig'; @@ -9,6 +10,7 @@ import { generateDefaultApp } from '~/tools/shared/app'; import { adminProcedure, createTRPCRouter, protectedProcedure } from '../trpc'; import { configNameSchema } from './config'; +import { writeConfig } from '~/tools/config/writeConfig'; export const boardRouter = createTRPCRouter({ all: protectedProcedure.query(async ({ ctx }) => { @@ -31,7 +33,7 @@ export const boardRouter = createTRPCRouter({ countCategories: config.categories.length, isDefaultForUser: name === defaultBoard, }; - }) + }), ); }), addAppsForContainers: adminProcedure @@ -43,18 +45,18 @@ export const boardRouter = createTRPCRouter({ name: z.string(), icon: z.string().optional(), port: z.number().optional(), - }) + }), ), - }) + }), ) .mutation(async ({ input }) => { - if (!(await configExists(input.boardName))) { + if (!configExists(input.boardName)) { throw new TRPCError({ code: '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 newConfig = { @@ -86,4 +88,96 @@ export const boardRouter = createTRPCRouter({ const targetPath = `data/configs/${input.boardName}.json`; 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)`; +}