feat: add onboarding with oldmarr import (#1606)
This commit is contained in:
3
packages/old-import/src/components/index.ts
Normal file
3
packages/old-import/src/components/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { InitialOldmarrImport } from "./initial-oldmarr-import";
|
||||
export { SidebarBehaviourSelect } from "./shared/sidebar-behaviour-select";
|
||||
export { OldmarrImportAppsSettings } from "./shared/apps-section";
|
||||
112
packages/old-import/src/components/initial-oldmarr-import.tsx
Normal file
112
packages/old-import/src/components/initial-oldmarr-import.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { Stack } from "@mantine/core";
|
||||
import SuperJSON from "superjson";
|
||||
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { useModalAction } from "@homarr/modals";
|
||||
import { boardSizes } from "@homarr/old-schema";
|
||||
|
||||
// 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";
|
||||
import type { AnalyseResult } from "../analyse/analyse-oldmarr-import";
|
||||
import { prepareMultipleImports } from "../prepare/prepare-multiple";
|
||||
import type { InitialOldmarrImportSettings } from "../settings";
|
||||
import { defaultSidebarBehaviour } from "../settings";
|
||||
import type { BoardSelectionMap, BoardSizeRecord } from "./initial/board-selection-card";
|
||||
import { BoardSelectionCard } from "./initial/board-selection-card";
|
||||
import { ImportSettingsCard } from "./initial/import-settings-card";
|
||||
import { ImportSummaryCard } from "./initial/import-summary-card";
|
||||
import { ImportTokenModal } from "./initial/token-modal";
|
||||
|
||||
interface InitialOldmarrImportProps {
|
||||
file: File;
|
||||
analyseResult: AnalyseResult;
|
||||
}
|
||||
|
||||
export const InitialOldmarrImport = ({ file, analyseResult }: InitialOldmarrImportProps) => {
|
||||
const [boardSelections, setBoardSelections] = useState<BoardSelectionMap>(
|
||||
new Map(createDefaultSelections(analyseResult.configs)),
|
||||
);
|
||||
const [settings, setSettings] = useState<InitialOldmarrImportSettings>({
|
||||
onlyImportApps: false,
|
||||
sidebarBehaviour: defaultSidebarBehaviour,
|
||||
});
|
||||
|
||||
const { preparedApps, preparedBoards, preparedIntegrations } = useMemo(
|
||||
() => prepareMultipleImports(analyseResult.configs, settings, boardSelections),
|
||||
[analyseResult, boardSelections, settings],
|
||||
);
|
||||
|
||||
const { mutateAsync, isPending } = clientApi.import.importInitialOldmarrImport.useMutation({
|
||||
async onSuccess() {
|
||||
await revalidatePathActionAsync("/init");
|
||||
},
|
||||
});
|
||||
const { openModal } = useModalAction(ImportTokenModal);
|
||||
|
||||
const createFormData = (token: string | null) => {
|
||||
const formData = new FormData();
|
||||
formData.set("file", file);
|
||||
formData.set("settings", JSON.stringify(settings));
|
||||
// Map can not be send over the wire without superjson
|
||||
formData.set("boardSelections", SuperJSON.stringify(boardSelections));
|
||||
if (token) {
|
||||
formData.set("token", token);
|
||||
}
|
||||
return formData;
|
||||
};
|
||||
|
||||
const handleSubmitAsync = async () => {
|
||||
if (analyseResult.checksum) {
|
||||
openModal({
|
||||
checksum: analyseResult.checksum,
|
||||
onSuccessAsync: async (token) => {
|
||||
await mutateAsync(createFormData(token));
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
await mutateAsync(createFormData(null));
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack mb="sm">
|
||||
<ImportSettingsCard
|
||||
settings={settings}
|
||||
updateSetting={(setting, value) => {
|
||||
setSettings((settings) => ({ ...settings, [setting]: value }));
|
||||
}}
|
||||
/>
|
||||
{settings.onlyImportApps ? null : (
|
||||
<BoardSelectionCard selections={boardSelections} updateSelections={setBoardSelections} />
|
||||
)}
|
||||
<ImportSummaryCard
|
||||
counts={{
|
||||
apps: preparedApps.length,
|
||||
boards: preparedBoards.length,
|
||||
integrations: preparedIntegrations.length,
|
||||
credentialUsers: analyseResult.userCount,
|
||||
}}
|
||||
onSubmit={handleSubmitAsync}
|
||||
loading={isPending}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
const createDefaultSelections = (configs: AnalyseResult["configs"]) => {
|
||||
return configs
|
||||
.map(({ name, config }) => {
|
||||
if (!config) return null;
|
||||
|
||||
const shapes = config.apps.flatMap((app) => app.shape).concat(config.widgets.flatMap((widget) => widget.shape));
|
||||
const boardSizeRecord = boardSizes.reduce<BoardSizeRecord>((acc, size) => {
|
||||
const allInclude = shapes.every((shape) => Boolean(shape[size]));
|
||||
acc[size] = allInclude ? true : null;
|
||||
return acc;
|
||||
}, {} as BoardSizeRecord);
|
||||
return [name, boardSizeRecord];
|
||||
})
|
||||
.filter((selection): selection is [string, BoardSizeRecord] => Boolean(selection));
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
67
packages/old-import/src/components/initial/token-modal.tsx
Normal file
67
packages/old-import/src/components/initial/token-modal.tsx
Normal 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") });
|
||||
23
packages/old-import/src/components/shared/apps-section.tsx
Normal file
23
packages/old-import/src/components/shared/apps-section.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { Fieldset, Switch } from "@mantine/core";
|
||||
|
||||
import type { CheckboxProps } from "@homarr/form/types";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
interface OldmarrImportAppsSettingsProps {
|
||||
onlyImportApps: CheckboxProps;
|
||||
background?: string;
|
||||
}
|
||||
|
||||
export const OldmarrImportAppsSettings = ({ background, onlyImportApps }: OldmarrImportAppsSettingsProps) => {
|
||||
const tApps = useScopedI18n("board.action.oldImport.form.apps");
|
||||
|
||||
return (
|
||||
<Fieldset legend={tApps("label")} bg={background}>
|
||||
<Switch
|
||||
{...onlyImportApps}
|
||||
label={tApps("onlyImportApps.label")}
|
||||
description={tApps("onlyImportApps.description")}
|
||||
/>
|
||||
</Fieldset>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { InputPropsFor } from "@homarr/form/types";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { SelectWithDescription } from "@homarr/ui";
|
||||
|
||||
import type { SidebarBehaviour } from "../../settings";
|
||||
|
||||
export const SidebarBehaviourSelect = (props: InputPropsFor<SidebarBehaviour, SidebarBehaviour, HTMLButtonElement>) => {
|
||||
const tSidebarBehaviour = useScopedI18n("board.action.oldImport.form.sidebarBehavior");
|
||||
|
||||
return (
|
||||
<SelectWithDescription
|
||||
withAsterisk
|
||||
label={tSidebarBehaviour("label")}
|
||||
description={tSidebarBehaviour("description")}
|
||||
data={[
|
||||
{
|
||||
value: "last-section",
|
||||
label: tSidebarBehaviour("option.lastSection.label"),
|
||||
description: tSidebarBehaviour("option.lastSection.description"),
|
||||
},
|
||||
{
|
||||
value: "remove-items",
|
||||
label: tSidebarBehaviour("option.removeItems.label"),
|
||||
description: tSidebarBehaviour("option.removeItems.description"),
|
||||
},
|
||||
]}
|
||||
{...props}
|
||||
onChange={(value) => (value ? props.onChange(value as SidebarBehaviour) : null)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user