feat: add import for config files from oldmarr (#1019)

* wip: add oldmarr config import

* wip: add support for wrong amount of categories / sections with autofix, color mapping, position adjustments of wrappers

* fix: lockfile broken

* feat: add support for form data trpc requests

* wip: improve file upload

* refactor: restructure import, add import configuration

* wip: add configurations for import to modal

* refactor: move oldmarr import to old-import package

* fix: column count not respects screen size for board

* feat: add beta badge for oldmarr config import

* chore: address pull request feedback

* fix: format issues

* fix: inconsistent versions

* fix: deepsource issues

* fix: revert {} to Record<string, never> convertion to prevent typecheck issue

* fix: inconsistent zod version

* fix: format issue

* chore: address pull request feedback

* fix: wrong import

* fix: broken lock file

* fix: inconsistent versions

* fix: format issues
This commit is contained in:
Meier Lukas
2024-09-07 18:13:24 +02:00
committed by GitHub
parent fc1bff2110
commit 5404cebf5b
65 changed files with 2132 additions and 34 deletions

View File

@@ -5,7 +5,15 @@ import { useState } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { ReactQueryStreamedHydration } from "@tanstack/react-query-next-experimental";
import { createWSClient, loggerLink, unstable_httpBatchStreamLink, wsLink } from "@trpc/client";
import {
createWSClient,
httpLink,
isNonJsonSerializable,
loggerLink,
splitLink,
unstable_httpBatchStreamLink,
wsLink,
} from "@trpc/client";
import superjson from "superjson";
import type { AppRouter } from "@homarr/api";
@@ -34,18 +42,29 @@ export function TRPCReactProvider(props: PropsWithChildren) {
enabled: (opts) =>
process.env.NODE_ENV === "development" || (opts.direction === "down" && opts.result instanceof Error),
}),
(args) => {
return ({ op, next }) => {
console.log("op", op.type, op.input, op.path, op.id);
if (op.type === "subscription") {
const link = wsLink<AppRouter>({
client: wsClient,
transformer: superjson,
});
return link(args)({ op, next });
}
return unstable_httpBatchStreamLink({
splitLink({
condition: ({ type }) => type === "subscription",
true: wsLink<AppRouter>({
client: wsClient,
transformer: superjson,
}),
false: splitLink({
condition: ({ input }) => isNonJsonSerializable(input),
true: httpLink({
/**
* We don't want to transform the data here as we want to use form data
*/
transformer: {
serialize(object: unknown) {
return object;
},
deserialize(data: unknown) {
return data;
},
},
url: `${getBaseUrl()}/api/trpc`,
}),
false: unstable_httpBatchStreamLink({
transformer: superjson,
url: `${getBaseUrl()}/api/trpc`,
headers() {
@@ -53,9 +72,9 @@ export function TRPCReactProvider(props: PropsWithChildren) {
headers.set("x-trpc-source", "nextjs-react");
return headers;
},
})(args)({ op, next });
};
},
}),
}),
}),
],
});
});

View File

@@ -1,15 +1,17 @@
"use client";
import { useCallback } from "react";
import { IconCategoryPlus } from "@tabler/icons-react";
import { Affix, Button, Group, Menu } from "@mantine/core";
import { IconCategoryPlus, IconChevronDown, IconFileImport } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useModalAction } from "@homarr/modals";
import { useI18n } from "@homarr/translation/client";
import { BetaBadge } from "@homarr/ui";
import { revalidatePathActionAsync } from "~/app/revalidatePathAction";
import { AddBoardModal } from "~/components/manage/boards/add-board-modal";
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
import { ImportBoardModal } from "~/components/manage/boards/import-board-modal";
interface CreateBoardButtonProps {
boardNames: string[];
@@ -17,7 +19,8 @@ interface CreateBoardButtonProps {
export const CreateBoardButton = ({ boardNames }: CreateBoardButtonProps) => {
const t = useI18n();
const { openModal } = useModalAction(AddBoardModal);
const { openModal: openAddModal } = useModalAction(AddBoardModal);
const { openModal: openImportModal } = useModalAction(ImportBoardModal);
const { mutateAsync, isPending } = clientApi.board.createBoard.useMutation({
onSettled: async () => {
@@ -25,8 +28,8 @@ export const CreateBoardButton = ({ boardNames }: CreateBoardButtonProps) => {
},
});
const onClick = useCallback(() => {
openModal({
const onCreateClick = useCallback(() => {
openAddModal({
onSuccess: async (values) => {
await mutateAsync({
name: values.name,
@@ -36,11 +39,41 @@ export const CreateBoardButton = ({ boardNames }: CreateBoardButtonProps) => {
},
boardNames,
});
}, [mutateAsync, boardNames, openModal]);
}, [mutateAsync, boardNames, openAddModal]);
const onImportClick = useCallback(() => {
openImportModal({ boardNames });
}, [openImportModal, boardNames]);
const buttonGroupContent = (
<>
<Button leftSection={<IconCategoryPlus size="1rem" />} onClick={onCreateClick} loading={isPending}>
{t("management.page.board.action.new.label")}
</Button>
<Menu position="bottom-end">
<Menu.Target>
<Button px="xs" ms={1}>
<IconChevronDown size="1rem" />
</Button>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item onClick={onImportClick} leftSection={<IconFileImport size="1rem" />}>
<Group>
{t("board.action.oldImport.label")}
<BetaBadge size="xs" />
</Group>
</Menu.Item>
</Menu.Dropdown>
</Menu>
</>
);
return (
<MobileAffixButton leftSection={<IconCategoryPlus size="1rem" />} onClick={onClick} loading={isPending}>
{t("management.page.board.action.new.label")}
</MobileAffixButton>
<>
<Button.Group visibleFrom="md">{buttonGroupContent}</Button.Group>
<Affix hiddenFrom="md" position={{ bottom: 20, right: 20 }}>
<Button.Group>{buttonGroupContent}</Button.Group>
</Affix>
</>
);
};

View File

@@ -0,0 +1,189 @@
import { useState } from "react";
import { Button, Fieldset, FileInput, Grid, Group, Radio, Stack, Switch, TextInput } from "@mantine/core";
import { IconFileUpload } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useZodForm } from "@homarr/form";
import { createModal } from "@homarr/modals";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { oldmarrConfigSchema } from "@homarr/old-schema";
import { useScopedI18n } from "@homarr/translation/client";
import { SelectWithDescription } from "@homarr/ui";
import type { OldmarrImportConfiguration } from "@homarr/validation";
import { createOldmarrImportConfigurationSchema, superRefineJsonImportFile, z } from "@homarr/validation";
import { revalidatePathActionAsync } from "~/app/revalidatePathAction";
interface InnerProps {
boardNames: string[];
}
export const ImportBoardModal = createModal<InnerProps>(({ actions, innerProps }) => {
const tOldImport = useScopedI18n("board.action.oldImport");
const tCommon = useScopedI18n("common");
const [fileValid, setFileValid] = useState(true);
const form = useZodForm(
z.object({
file: z.instanceof(File).nullable().superRefine(superRefineJsonImportFile),
configuration: createOldmarrImportConfigurationSchema(innerProps.boardNames),
}),
{
initialValues: {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
file: null!,
configuration: {
distinctAppsByHref: true,
onlyImportApps: false,
screenSize: "lg",
sidebarBehaviour: "last-section",
name: "",
},
},
onValuesChange(values, previous) {
// This is a workarround until async validation is supported by mantine
void (async () => {
if (values.file === previous.file) {
return;
}
if (!values.file) {
return;
}
const content = await values.file.text();
const result = oldmarrConfigSchema.safeParse(JSON.parse(content));
if (!result.success) {
console.error(result.error.errors);
setFileValid(false);
return;
}
setFileValid(true);
form.setFieldValue("configuration.name", result.data.configProperties.name);
})();
},
},
);
const { mutateAsync, isPending } = clientApi.board.importOldmarrConfig.useMutation();
const handleSubmitAsync = async (values: { file: File; configuration: OldmarrImportConfiguration }) => {
const formData = new FormData();
formData.set("file", values.file);
formData.set("configuration", JSON.stringify(values.configuration));
await mutateAsync(formData, {
async onSuccess() {
actions.closeModal();
await revalidatePathActionAsync("/manage/boards");
showSuccessNotification({
title: tOldImport("notification.success.title"),
message: tOldImport("notification.success.message"),
});
},
onError() {
showErrorNotification({
title: tOldImport("notification.error.title"),
message: tOldImport("notification.error.message"),
});
},
});
};
return (
<form
onSubmit={form.onSubmit((values) => {
if (!fileValid) {
return;
}
void handleSubmitAsync({
// It's checked for null in the superrefine
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
file: values.file!,
configuration: values.configuration,
});
})}
>
<Stack>
<FileInput
rightSection={<IconFileUpload />}
withAsterisk
accept="application/json"
{...form.getInputProps("file")}
error={
(form.getInputProps("file").error as string | undefined) ??
(!fileValid && form.isDirty("file") ? tOldImport("form.file.invalidError") : undefined)
}
type="button"
label={tOldImport("form.file.label")}
/>
<Fieldset legend={tOldImport("form.apps.label")}>
<Grid>
<Grid.Col span={{ base: 12, sm: 6 }}>
<Switch
label={tOldImport("form.apps.avoidDuplicates.label")}
description={tOldImport("form.apps.avoidDuplicates.description")}
{...form.getInputProps("configuration.distinctAppsByHref", { type: "checkbox" })}
/>
</Grid.Col>
<Grid.Col span={{ base: 12, sm: 6 }}>
<Switch
label={tOldImport("form.apps.onlyImportApps.label")}
description={tOldImport("form.apps.onlyImportApps.description")}
{...form.getInputProps("configuration.onlyImportApps", { type: "checkbox" })}
/>
</Grid.Col>
</Grid>
</Fieldset>
<TextInput withAsterisk label={tOldImport("form.name.label")} {...form.getInputProps("configuration.name")} />
<Radio.Group
withAsterisk
label={tOldImport("form.screenSize.label")}
{...form.getInputProps("configuration.screenSize")}
>
<Group mt="xs">
<Radio value="sm" label={tOldImport("form.screenSize.option.sm")} />
<Radio value="md" label={tOldImport("form.screenSize.option.md")} />
<Radio value="lg" label={tOldImport("form.screenSize.option.lg")} />
</Group>
</Radio.Group>
<SelectWithDescription
withAsterisk
label={tOldImport("form.sidebarBehavior.label")}
description={tOldImport("form.sidebarBehavior.description")}
data={[
{
value: "last-section",
label: tOldImport("form.sidebarBehavior.option.lastSection.label"),
description: tOldImport("form.sidebarBehavior.option.lastSection.description"),
},
{
value: "remove-items",
label: tOldImport("form.sidebarBehavior.option.removeItems.label"),
description: tOldImport("form.sidebarBehavior.option.removeItems.description"),
},
]}
{...form.getInputProps("configuration.sidebarBehaviour")}
/>
<Group justify="end">
<Button variant="subtle" color="gray" onClick={actions.closeModal}>
{tCommon("action.cancel")}
</Button>
<Button type="submit" loading={isPending}>
{tCommon("action.import")}
</Button>
</Group>
</Stack>
</form>
);
}).withOptions({
defaultTitle: (t) => t("board.action.oldImport.label"),
size: "lg",
});