Skip to content

Commit

Permalink
feature: board operations (#1800)
Browse files Browse the repository at this point in the history
  • Loading branch information
manuel-rw committed Jan 13, 2024
1 parent 6717bcf commit c799226
Show file tree
Hide file tree
Showing 7 changed files with 241 additions and 22 deletions.
13 changes: 13 additions & 0 deletions public/locales/en/manage/boards.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,19 @@
"delete": {
"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."
},
"duplicate": "Duplicate",
"rename": {
"label": "Rename",
"modal": {
"title": "Rename board {{name}}",
"fields": {
"name": {
"label": "New name",
"placeholder": "New board name"
}
}
}
}
},
"badges": {
Expand Down
68 changes: 68 additions & 0 deletions src/components/Dashboard/Modals/RenameBoard/RenameBoardModal.tsx
Original file line number Diff line number Diff line change
@@ -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>
);
};
2 changes: 1 addition & 1 deletion src/modals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ export const modals = {
copyInviteModal: CopyInviteModal,
deleteBoardModal: DeleteBoardModal,
changeUserRoleModal: ChangeUserRoleModal,
dockerSelectBoardModal: DockerSelectBoardModal,
dockerSelectBoardModal: DockerSelectBoardModal
};

declare module '@mantine/modals' {
Expand Down
63 changes: 55 additions & 8 deletions src/pages/manage/boards/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,17 @@ import {
Card,
Group,
LoadingOverlay,
Menu,
Menu, Modal,
SimpleGrid,
Stack,
Text,
Title,
} from '@mantine/core';
import { useListState } from '@mantine/hooks';
import { useDisclosure, useListState } from '@mantine/hooks';
import {
IconBox,
IconCategory,
IconCopy, IconCursorText,
IconDeviceFloppy,
IconDotsVertical,
IconFolderFilled,
Expand All @@ -39,19 +40,40 @@ 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';
import { RenameBoardModal } from '~/components/Dashboard/Modals/RenameBoard/RenameBoardModal';
import { useState } from 'react';

// Infer return type from the `getServerSideProps` function
export default function BoardsPage({
boards,
session,
boards,
session,
}: 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, {
initialData: boards,
cacheTime: 1000 * 60 * 5, // Cache for 5 minutes
});
const { mutateAsync } = api.user.makeDefaultDashboard.useMutation({
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,
});
},
});

Expand All @@ -67,6 +89,12 @@ export default function BoardsPage({
<title>{metaTitle}</title>
</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">
<Title mb="xl">{t('pageTitle')}</Title>
{session?.user.isAdmin && (
Expand Down Expand Up @@ -165,6 +193,26 @@ export default function BoardsPage({
</ActionIcon>
</Menu.Target>
<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
icon={<IconDeviceFloppy size="1rem" />}
onClick={async () => {
Expand All @@ -177,7 +225,6 @@ export default function BoardsPage({
</Menu.Item>
{session?.user.isAdmin && (
<>
<Menu.Divider />
<Menu.Item
onClick={async () => {
openDeleteBoardModal({
Expand Down Expand Up @@ -216,7 +263,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;
Expand All @@ -233,7 +280,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
manageNamespaces,
context.locale,
context.req,
context.res
context.res,
);

return {
Expand Down
106 changes: 100 additions & 6 deletions src/server/api/routers/board.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
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';
import { getFrontendConfig } from '~/tools/config/getFrontendConfig';
import { generateDefaultApp } from '~/tools/shared/app';

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({
all: protectedProcedure.query(async ({ ctx }) => {
Expand All @@ -31,7 +33,7 @@ export const boardRouter = createTRPCRouter({
countCategories: config.categories.length,
isDefaultForUser: name === defaultBoard,
};
})
}),
);
}),
addAppsForContainers: adminProcedure
Expand All @@ -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 = {
Expand Down Expand Up @@ -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)`;
}
7 changes: 1 addition & 6 deletions src/server/api/routers/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,10 @@ import { configExists } from '~/tools/config/configExists';
import { getConfig } from '~/tools/config/getConfig';
import { getFrontendConfig } from '~/tools/config/getFrontendConfig';
import { BackendConfigType, ConfigType } from '~/types/config';
import { boardCustomizationSchema } from '~/validations/boards';
import { boardCustomizationSchema, configNameSchema } from '~/validations/boards';
import { IRssWidget } from '~/widgets/rss/RssWidgetTile';

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({
delete: adminProcedure
Expand Down
Loading

0 comments on commit c799226

Please sign in to comment.