Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feature: board operations #1800

Merged
merged 1 commit into from
Jan 13, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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) => {
manuel-rw marked this conversation as resolved.
Show resolved Hide resolved
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
Loading