mirror of
https://github.com/ajnart/homarr.git
synced 2025-11-15 09:46:19 +01:00
feature: board operations (#1800)
This commit is contained in:
@@ -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": {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -30,7 +30,7 @@ export const modals = {
|
||||
copyInviteModal: CopyInviteModal,
|
||||
deleteBoardModal: DeleteBoardModal,
|
||||
changeUserRoleModal: ChangeUserRoleModal,
|
||||
dockerSelectBoardModal: DockerSelectBoardModal,
|
||||
dockerSelectBoardModal: DockerSelectBoardModal
|
||||
};
|
||||
|
||||
declare module '@mantine/modals' {
|
||||
|
||||
@@ -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,
|
||||
@@ -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,
|
||||
}: 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,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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 && (
|
||||
@@ -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 () => {
|
||||
@@ -177,7 +225,6 @@ export default function BoardsPage({
|
||||
</Menu.Item>
|
||||
{session?.user.isAdmin && (
|
||||
<>
|
||||
<Menu.Divider />
|
||||
<Menu.Item
|
||||
onClick={async () => {
|
||||
openDeleteBoardModal({
|
||||
@@ -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;
|
||||
@@ -233,7 +280,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
manageNamespaces,
|
||||
context.locale,
|
||||
context.req,
|
||||
context.res
|
||||
context.res,
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
@@ -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';
|
||||
@@ -8,7 +9,8 @@ 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 }) => {
|
||||
@@ -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)`;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -2,8 +2,10 @@ import { DEFAULT_THEME, MANTINE_COLORS, MantineColor } from '@mantine/core';
|
||||
import { z } from 'zod';
|
||||
import { BackgroundImageAttachment, BackgroundImageRepeat, BackgroundImageSize } from '~/types/settings';
|
||||
|
||||
export const configNameSchema = z.string().regex(/^[a-zA-Z0-9-_\s()]+$/);
|
||||
|
||||
export const createBoardSchemaValidation = z.object({
|
||||
name: z.string().min(2).max(25),
|
||||
name: configNameSchema,
|
||||
});
|
||||
|
||||
export const boardCustomizationSchema = z.object({
|
||||
|
||||
Reference in New Issue
Block a user