feat: add onboarding with oldmarr import (#1606)

This commit is contained in:
Meier Lukas
2024-12-15 15:40:26 +01:00
committed by GitHub
parent 82ec77d2da
commit 6de74d9525
108 changed files with 6045 additions and 312 deletions

View File

@@ -0,0 +1,156 @@
import type { ChangeEvent } from "react";
import { Anchor, Card, Checkbox, Group, Stack, Text } from "@mantine/core";
import { objectEntries, objectKeys } from "@homarr/common";
import { boardSizes } from "@homarr/old-schema";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
type BoardSize = (typeof boardSizes)[number];
export type BoardSizeRecord = Record<BoardSize, boolean | null>;
export type BoardSelectionMap = Map<string, BoardSizeRecord>;
interface BoardSelectionCardProps {
selections: BoardSelectionMap;
updateSelections: (callback: (selections: BoardSelectionMap) => BoardSelectionMap) => void;
}
const allChecked = (map: BoardSelectionMap) => {
return [...map.values()].every((selection) => groupChecked(selection));
};
const groupChecked = (selection: BoardSizeRecord) =>
objectEntries(selection).every(([_, value]) => value === true || value === null);
export const BoardSelectionCard = ({ selections, updateSelections }: BoardSelectionCardProps) => {
const tBoardSelection = useScopedI18n("init.step.import.boardSelection");
const t = useI18n();
const areAllChecked = allChecked(selections);
const handleToggleAll = () => {
updateSelections((selections) => {
const updated = new Map(selections);
[...selections.entries()].forEach(([name, selection]) => {
objectKeys(selection).forEach((size) => {
if (selection[size] === null) return;
selection[size] = !areAllChecked;
});
updated.set(name, selection);
});
return updated;
});
};
const registerToggleGroup = (name: string) => (event: ChangeEvent<HTMLInputElement>) => {
updateSelections((selections) => {
const updated = new Map(selections);
const selection = selections.get(name);
if (!selection) return updated;
objectKeys(selection).forEach((size) => {
if (selection[size] === null) return;
selection[size] = event.target.checked;
});
updated.set(name, selection);
return updated;
});
};
const registerToggle = (name: string, size: BoardSize) => (event: ChangeEvent<HTMLInputElement>) => {
updateSelections((selections) => {
const updated = new Map(selections);
const selection = selections.get(name);
if (!selection) return updated;
selection[size] = event.target.checked;
updated.set(name, selection);
return updated;
});
};
if (selections.size === 0) {
return null;
}
return (
<Card w={64 * 12 + 8} maw="90vw">
<Stack gap="sm">
<Stack gap={0}>
<Group justify="space-between" align="center">
<Text fw={500}>{tBoardSelection("title", { count: selections.size })}</Text>
<Anchor component="button" onClick={handleToggleAll}>
{areAllChecked ? tBoardSelection("action.unselectAll") : tBoardSelection("action.selectAll")}
</Anchor>
</Group>
<Text size="sm" c="gray.6">
{tBoardSelection("description")}
</Text>
<Text size="xs" c="gray.6">
{t("board.action.oldImport.form.screenSize.description")}
</Text>
</Stack>
<Stack gap="sm">
{[...selections.entries()].map(([name, selection]) => (
<Card key={name} withBorder>
<Group justify="space-between" align="center" visibleFrom="md">
<Checkbox
checked={groupChecked(selection)}
onChange={registerToggleGroup(name)}
label={
<Text fw={500} size="sm">
{name}
</Text>
}
/>
<Group>
{boardSizes.map((size) => (
<Checkbox
key={size}
disabled={selection[size] === null}
checked={selection[size] ?? undefined}
onChange={registerToggle(name, size)}
label={t(`board.action.oldImport.form.screenSize.option.${size}`)}
/>
))}
</Group>
</Group>
<Stack hiddenFrom="md">
<Checkbox
checked={groupChecked(selection)}
onChange={registerToggleGroup(name)}
label={
<Text fw={500} size="sm">
{name}
</Text>
}
/>
<Stack gap="sm" ps="sm">
{objectEntries(selection)
.filter(([_, value]) => value !== null)
.map(([size, value]) => (
<Checkbox
key={size}
checked={value ?? undefined}
onChange={registerToggle(name, size)}
label={`screenSize.${size}`}
/>
))}
</Stack>
</Stack>
</Card>
))}
</Stack>
</Stack>
</Card>
);
};

View File

@@ -0,0 +1,44 @@
import { Card, Stack, Text } from "@mantine/core";
import { useScopedI18n } from "@homarr/translation/client";
import type { InitialOldmarrImportSettings } from "../../settings";
import { OldmarrImportAppsSettings } from "../shared/apps-section";
import { SidebarBehaviourSelect } from "../shared/sidebar-behaviour-select";
interface ImportSettingsCardProps {
settings: InitialOldmarrImportSettings;
updateSetting: <TKey extends keyof InitialOldmarrImportSettings>(
setting: TKey,
value: InitialOldmarrImportSettings[TKey],
) => void;
}
export const ImportSettingsCard = ({ settings, updateSetting }: ImportSettingsCardProps) => {
const tImportSettings = useScopedI18n("init.step.import.importSettings");
return (
<Card w={64 * 12 + 8} maw="90vw">
<Stack gap="sm">
<Stack gap={0}>
<Text fw={500}>{tImportSettings("title")}</Text>
<Text size="sm" c="gray.6">
{tImportSettings("description")}
</Text>
</Stack>
<OldmarrImportAppsSettings
background="transparent"
onlyImportApps={{
checked: settings.onlyImportApps,
onChange: (event) => updateSetting("onlyImportApps", event.target.checked),
}}
/>
<SidebarBehaviourSelect
value={settings.sidebarBehaviour}
onChange={(value) => updateSetting("sidebarBehaviour", value)}
/>
</Stack>
</Card>
);
};

View File

@@ -0,0 +1,44 @@
import { Button, Card, Group, Stack, Text } from "@mantine/core";
import { objectEntries } from "@homarr/common";
import type { MaybePromise } from "@homarr/common/types";
import { useScopedI18n } from "@homarr/translation/client";
interface ImportSummaryCardProps {
counts: { apps: number; boards: number; integrations: number; credentialUsers: number };
loading: boolean;
onSubmit: () => MaybePromise<void>;
}
export const ImportSummaryCard = ({ counts, onSubmit, loading }: ImportSummaryCardProps) => {
const tSummary = useScopedI18n("init.step.import.summary");
return (
<Card w={64 * 12 + 8} maw="90vw">
<Stack gap="sm">
<Stack gap={0}>
<Text fw={500}>{tSummary("title")}</Text>
<Text size="sm" c="gray.6">
{tSummary("description")}
</Text>
</Stack>
<Stack gap="xs">
{objectEntries(counts).map(([key, count]) => (
<Card key={key} withBorder p="sm">
<Group justify="space-between" align="center">
<Text fw={500} size="sm">
{tSummary(`entities.${key}`)}
</Text>
<Text size="sm">{count}</Text>
</Group>
</Card>
))}
</Stack>
<Button onClick={onSubmit} loading={loading}>
{tSummary("action.import")}
</Button>
</Stack>
</Card>
);
};

View File

@@ -0,0 +1,67 @@
import { Button, Group, PasswordInput, Stack } from "@mantine/core";
import { z } from "zod";
import { useZodForm } from "@homarr/form";
import { createModal } from "@homarr/modals";
import { showErrorNotification } from "@homarr/notifications";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
// We don't have access to the API client here, so we need to import it from the API package
// In the future we should consider having the used router also in this package
import { clientApi } from "../../../../api/src/client";
interface InnerProps {
checksum: string;
onSuccessAsync: (token: string) => Promise<void>;
}
const formSchema = z.object({
token: z.string().min(1).max(256),
});
export const ImportTokenModal = createModal<InnerProps>(({ actions, innerProps }) => {
const t = useI18n();
const tTokenModal = useScopedI18n("init.step.import.tokenModal");
const { mutate, isPending } = clientApi.import.validateToken.useMutation();
const form = useZodForm(formSchema, { initialValues: { token: "" } });
const handleSubmit = (values: z.infer<typeof formSchema>) => {
mutate(
{ checksum: innerProps.checksum, token: values.token },
{
async onSuccess(isValid) {
if (isValid) {
actions.closeModal();
await innerProps.onSuccessAsync(values.token);
} else {
showErrorNotification({
title: tTokenModal("notification.error.title"),
message: tTokenModal("notification.error.message"),
});
}
},
},
);
};
return (
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack>
<PasswordInput
{...form.getInputProps("token")}
label={tTokenModal("field.token.label")}
description={tTokenModal("field.token.description")}
withAsterisk
/>
<Group justify="end">
<Button variant="subtle" color="gray" onClick={actions.closeModal}>
{t("common.action.cancel")}
</Button>
<Button type="submit" loading={isPending}>
{t("common.action.confirm")}
</Button>
</Group>
</Stack>
</form>
);
}).withOptions({ defaultTitle: (t) => t("init.step.import.tokenModal.title") });