Skip to content

Commit

Permalink
feature: board operations
Browse files Browse the repository at this point in the history
  • Loading branch information
manuel-rw committed Jan 9, 2024
1 parent 209119f commit 4a6083d
Show file tree
Hide file tree
Showing 5 changed files with 226 additions and 11 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
66 changes: 66 additions & 0 deletions src/components/Dashboard/Modals/RenameBoard/RenameBoardModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
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';
import { useTranslation } from 'next-i18next';

type RenameBoardModalProps = {
configName: string;
configNames: string[];
}

export const RenameBoardModal = ({ context, id, innerProps }: ContextModalProps<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: 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 (
<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')}
data-autofocus
{...form.getInputProps('newName')} />
<Button
loading={isLoading}
fullWidth
mt="md"
type={'submit'}
variant={"light"}>
{t('common:confirm')}
</Button>
</form>
);
};
2 changes: 2 additions & 0 deletions src/modals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -31,6 +32,7 @@ export const modals = {
deleteBoardModal: DeleteBoardModal,
changeUserRoleModal: ChangeUserRoleModal,
dockerSelectBoardModal: DockerSelectBoardModal,
renameBoardModal: RenameBoardModal
};

declare module '@mantine/modals' {
Expand Down
52 changes: 46 additions & 6 deletions src/pages/manage/boards/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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<typeof getServerSideProps>) {
const { data, refetch } = api.boards.all.useQuery(undefined, {
initialData: boards,
Expand All @@ -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<string>([]);

const { t } = useTranslation('manage/boards');
Expand Down Expand Up @@ -165,6 +182,30 @@ 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={() => {
modals.openContextModal({
modal: 'renameBoardModal',
title: t('cards.menu.rename.modal.title', { name: board.name }),
innerProps: {
configName: board.name,
configNames: data.map(board => board.name)
}
})
}}
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 +218,6 @@ export default function BoardsPage({
</Menu.Item>
{session?.user.isAdmin && (
<>
<Menu.Divider />
<Menu.Item
onClick={async () => {
openDeleteBoardModal({
Expand Down Expand Up @@ -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;
Expand All @@ -233,7 +273,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
manageNamespaces,
context.locale,
context.req,
context.res
context.res,
);

return {
Expand Down
104 changes: 99 additions & 5 deletions src/server/api/routers/board.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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 }) => {
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)`;
}

0 comments on commit 4a6083d

Please sign in to comment.