439 lines
18 KiB
TypeScript
439 lines
18 KiB
TypeScript
import { useEffect, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import EntityIcon from "~/components/layouts/icons/EntityIcon";
|
|
import ButtonTertiary from "~/components/ui/buttons/ButtonTertiary";
|
|
import InputGroup from "~/components/ui/forms/InputGroup";
|
|
import QuestionMarkFilledIcon from "~/components/ui/icons/QuestionMarkFilledIcon";
|
|
import XIcon from "~/components/ui/icons/XIcon";
|
|
import InputCheckbox from "~/components/ui/input/InputCheckbox";
|
|
import InputRadioGroup from "~/components/ui/input/InputRadioGroup";
|
|
import InputSelect from "~/components/ui/input/InputSelect";
|
|
import InputSelector from "~/components/ui/input/InputSelector";
|
|
import InputText from "~/components/ui/input/InputText";
|
|
import CollapsibleRow from "~/components/ui/tables/CollapsibleRow";
|
|
import OnboardingStepUtils from "~/modules/onboarding/utils/OnboardingStepUtils";
|
|
import OnboardingGridForm from "./grid/OnboardingGridForm";
|
|
import StringUtils from "~/utils/shared/StringUtils";
|
|
import { defaultOnboardingStepBlocks } from "./defaultOnboarding/defaultOnboardingStepBlocks";
|
|
import {
|
|
defaultOnboardingBlock,
|
|
defaultOnboardingStepBlock,
|
|
OnboardingBlockDto,
|
|
OnboardingBlockStyle,
|
|
OnboardingBlockStyles,
|
|
OnboardingInputOptionDto,
|
|
OnboardingStepBlockDto,
|
|
} from "./OnboardingBlockUtils";
|
|
import ReactSortablePkg from "react-sortablejs";
|
|
const { ReactSortable } = ReactSortablePkg;
|
|
|
|
export default function OnboardingBlockForm({ item, onUpdate }: { item?: OnboardingBlockDto; onUpdate: (item: OnboardingBlockDto) => void }) {
|
|
const { t } = useTranslation();
|
|
const [state, setState] = useState<OnboardingBlockDto>(item || defaultOnboardingBlock);
|
|
useEffect(() => {
|
|
onUpdate(state);
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [state]);
|
|
return (
|
|
<div className="space-y-4">
|
|
<InputGroup title={t("onboarding.block.design")}>
|
|
<div className="grid grid-cols-2 gap-2">
|
|
<InputSelector
|
|
withSearch={false}
|
|
className="col-span-2"
|
|
title={t("onboarding.block.style")}
|
|
value={state.style}
|
|
setValue={(value) => setState({ ...state, style: value as OnboardingBlockStyle })}
|
|
options={OnboardingBlockStyles.map((f) => f)}
|
|
/>
|
|
|
|
<InputSelector
|
|
withSearch={false}
|
|
title={t("onboarding.block.height")}
|
|
value={state.height}
|
|
disabled={state.style !== "modal"}
|
|
setValue={(value) => setState({ ...state, height: value as any })}
|
|
options={[
|
|
{ value: "xs", name: "xs" },
|
|
{ value: "sm", name: "sm" },
|
|
{ value: "md", name: "md" },
|
|
{ value: "lg", name: "lg" },
|
|
{ value: "xl", name: "xl" },
|
|
{ value: "auto", name: "auto" },
|
|
]}
|
|
/>
|
|
|
|
<InputSelector
|
|
withSearch={false}
|
|
title={t("onboarding.block.canBeDismissed")}
|
|
value={state.canBeDismissed ? "true" : "false"}
|
|
disabled={state.style !== "modal"}
|
|
setValue={(value) => setState({ ...state, canBeDismissed: value === "true" })}
|
|
options={[
|
|
{ value: "true", name: t("shared.yes") },
|
|
{ value: "false", name: t("shared.no") },
|
|
]}
|
|
/>
|
|
</div>
|
|
</InputGroup>
|
|
|
|
<InputGroup title={t("onboarding.step.plural")} description={t("onboarding.block.stepsDescription")}>
|
|
<div className="space-y-2">
|
|
<ReactSortable className="flex flex-col space-y-2" animation={200} list={state.steps} setList={(steps) => setState({ ...state, steps })}>
|
|
{state.steps.map((item, index) => (
|
|
<CollapsibleRow
|
|
draggable
|
|
key={item.id}
|
|
title={item.title || t("onboarding.block.untitledStep")}
|
|
value={OnboardingStepUtils.getStepDescription(item)}
|
|
initial={!item.title}
|
|
onRemove={() => {
|
|
const steps = state.steps ?? [];
|
|
const index = steps.findIndex((e) => e.id === item.id);
|
|
if (index > -1) {
|
|
steps.splice(index, 1);
|
|
setState({ ...state, steps });
|
|
}
|
|
}}
|
|
>
|
|
<StepForm
|
|
item={item}
|
|
onUpdate={(item) => {
|
|
const steps = state.steps ?? [];
|
|
steps[index] = item;
|
|
setState({ ...state, steps });
|
|
}}
|
|
/>
|
|
</CollapsibleRow>
|
|
))}
|
|
</ReactSortable>
|
|
<div className="flex justify-between space-x-2">
|
|
<ButtonTertiary
|
|
onClick={() =>
|
|
setState({
|
|
...state,
|
|
steps: [
|
|
...(state.steps ?? []),
|
|
{
|
|
id: Math.floor(Math.random() * 10000).toString(),
|
|
// ...defaultOnboardingStepBlock,
|
|
title: "",
|
|
description: "",
|
|
links: [],
|
|
gallery: [],
|
|
input: [],
|
|
},
|
|
],
|
|
})
|
|
}
|
|
>
|
|
{t("onboarding.block.actions.addStep")}
|
|
</ButtonTertiary>
|
|
<div>
|
|
{state.steps.length > 0 ? (
|
|
<ButtonTertiary
|
|
destructive
|
|
onClick={() => {
|
|
if (prompt(t("onboarding.block.confirmClearPrompt")) === "yes") setState({ ...state, steps: [] });
|
|
}}
|
|
>
|
|
{t("shared.clear")}
|
|
</ButtonTertiary>
|
|
) : (
|
|
<ButtonTertiary onClick={() => setState({ ...state, steps: defaultOnboardingStepBlocks })}>{t("onboarding.block.actions.loadSampleSteps")}</ButtonTertiary>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</InputGroup>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function StepForm({ item, onUpdate }: { item: OnboardingStepBlockDto; onUpdate: (item: OnboardingStepBlockDto) => void }) {
|
|
const { t } = useTranslation();
|
|
const [state, setState] = useState<OnboardingStepBlockDto>(item || defaultOnboardingStepBlock);
|
|
useEffect(() => {
|
|
onUpdate(state);
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [state]);
|
|
return (
|
|
<div className="grid grid-cols-1 gap-4 p-2">
|
|
<InputText title={t("onboarding.step.title")} value={state.title} setValue={(e) => setState({ ...state, title: e?.toString() })} required />
|
|
<InputText
|
|
title={t("shared.description")}
|
|
value={state.description}
|
|
setValue={(e) => setState({ ...state, description: e?.toString() })}
|
|
rows={5}
|
|
hint={t("onboarding.block.markdownSupported")}
|
|
/>
|
|
<InputText
|
|
title={t("shared.icon")}
|
|
value={state.icon}
|
|
setValue={(e) => setState({ ...state, icon: e?.toString() })}
|
|
hint={<div className="text-muted-foreground">{t("onboarding.block.iconHint")}</div>}
|
|
button={
|
|
<div className="absolute inset-y-0 right-0 flex py-0.5 pr-0.5 ">
|
|
<kbd className="border-border text-muted-foreground bg-secondary inline-flex w-10 items-center justify-center rounded border px-1 text-center font-sans text-xs font-medium">
|
|
{state.icon ? (
|
|
<EntityIcon className="text-muted-foreground h-7 w-7" icon={state.icon} title={state.title} />
|
|
) : (
|
|
<QuestionMarkFilledIcon className="text-muted-foreground h-5 w-5" aria-hidden="true" />
|
|
)}
|
|
</kbd>
|
|
</div>
|
|
}
|
|
/>
|
|
|
|
<InputGroup title={t("onboarding.block.links")}>
|
|
<div className="flex flex-col space-y-2">
|
|
{state.links.map((link, index) => (
|
|
<div key={index} className="group relative grid grid-cols-4 gap-2">
|
|
<button
|
|
onClick={() => {
|
|
const links = state.links;
|
|
links.splice(index, 1);
|
|
setState({ ...state, links });
|
|
}}
|
|
type="button"
|
|
className="bg-background text-muted-foreground absolute right-0 top-0 -mt-3 hidden origin-top-right justify-center rounded-full hover:text-red-500 group-hover:flex"
|
|
>
|
|
<XIcon className="h-6 w-6" />
|
|
</button>
|
|
<InputText
|
|
title={t("shared.link")}
|
|
value={link.text}
|
|
setValue={(e) => setState({ ...state, links: state.links.map((step, i) => (i === index ? { ...step, text: e.toString() } : step)) })}
|
|
/>
|
|
<InputText
|
|
title={t("onboarding.block.href")}
|
|
value={link.href}
|
|
setValue={(e) => setState({ ...state, links: state.links.map((step, i) => (i === index ? { ...step, href: e.toString() } : step)) })}
|
|
/>
|
|
<InputCheckbox
|
|
title={t("onboarding.block.isPrimary")}
|
|
value={link.isPrimary}
|
|
setValue={(e) => setState({ ...state, links: state.links.map((link, i) => (i === index ? { ...link, isPrimary: Boolean(e) } : link)) })}
|
|
/>
|
|
<InputCheckbox
|
|
title={t("onboarding.block.targetBlank")}
|
|
value={link.target === "_blank"}
|
|
setValue={(e) => setState({ ...state, links: state.links.map((link, i) => (i === index ? { ...link, target: e ? "_blank" : "" } : link)) })}
|
|
/>
|
|
</div>
|
|
))}
|
|
<ButtonTertiary onClick={() => setState({ ...state, links: [...state.links, { text: t("shared.link"), href: "#", isPrimary: false }] })}>
|
|
{t("onboarding.block.actions.addLink")}
|
|
</ButtonTertiary>
|
|
</div>
|
|
</InputGroup>
|
|
|
|
<InputGroup title={t("onboarding.block.gallery")}>
|
|
<div className="flex flex-col space-y-2">
|
|
<ReactSortable className="flex flex-col space-y-2" animation={200} list={state.gallery} setList={(gallery) => setState({ ...state, gallery })}>
|
|
{state.gallery.map((item, index) => (
|
|
<CollapsibleRow
|
|
key={index}
|
|
title={item.title || t("onboarding.block.untitledGalleryItem")}
|
|
value={item.title || t("onboarding.block.untitledGalleryItem")}
|
|
initial={!item.title}
|
|
onRemove={() => {
|
|
const gallery = state.gallery ?? [];
|
|
gallery.splice(index, 1);
|
|
setState({ ...state, gallery });
|
|
}}
|
|
>
|
|
<div className="grid grid-cols-1 gap-2">
|
|
<InputRadioGroup
|
|
title={t("onboarding.step.type")}
|
|
value={item.type}
|
|
setValue={(e) =>
|
|
setState({
|
|
...state,
|
|
gallery: state.gallery.map((item, i) => (i === index ? { ...item, type: e?.toString() as "image" | "video" } : item)),
|
|
})
|
|
}
|
|
options={[
|
|
{ name: t("onboarding.block.mediaTypes.image"), value: "image" },
|
|
{ name: t("onboarding.block.mediaTypes.video"), value: "video" },
|
|
]}
|
|
/>
|
|
<InputText
|
|
title={t("onboarding.step.title")}
|
|
type="text"
|
|
value={item.title}
|
|
setValue={(e) => setState({ ...state, gallery: state.gallery.map((item, i) => (i === index ? { ...item, title: e.toString() } : item)) })}
|
|
/>
|
|
<InputText
|
|
title={t("onboarding.block.src")}
|
|
type="text"
|
|
value={item.src}
|
|
setValue={(e) => setState({ ...state, gallery: state.gallery.map((item, i) => (i === index ? { ...item, src: e.toString() } : item)) })}
|
|
/>
|
|
</div>
|
|
</CollapsibleRow>
|
|
))}
|
|
</ReactSortable>
|
|
<ButtonTertiary
|
|
onClick={() =>
|
|
setState({
|
|
...state,
|
|
gallery: [
|
|
...(state.gallery ?? []),
|
|
{
|
|
id: Math.floor(Math.random() * 10000).toString(),
|
|
type: "image",
|
|
title: t("onboarding.block.defaultGalleryTitle", { number: state.gallery.length + 1 }),
|
|
src: "",
|
|
},
|
|
],
|
|
})
|
|
}
|
|
>
|
|
{t("onboarding.block.actions.addGalleryItem")}
|
|
</ButtonTertiary>
|
|
</div>
|
|
</InputGroup>
|
|
|
|
<InputGroup title={t("onboarding.block.input")}>
|
|
<div className="flex flex-col space-y-2">
|
|
<OnboardingGridForm item={state.inputGrid} onUpdate={(item) => setState({ ...state, inputGrid: item })} />
|
|
<ReactSortable className="flex flex-col space-y-2" animation={200} list={state.input} setList={(input) => setState({ ...state, input })}>
|
|
{state.input.map((item, index) => (
|
|
<CollapsibleRow
|
|
key={index}
|
|
title={item.title || t("onboarding.block.untitledInput")}
|
|
value={item.title || t("onboarding.block.untitledInput")}
|
|
initial={!item.title}
|
|
onRemove={() => {
|
|
const input = state.input ?? [];
|
|
input.splice(index, 1);
|
|
setState({ ...state, input });
|
|
}}
|
|
>
|
|
<div className="grid grid-cols-3 gap-2">
|
|
<InputText
|
|
title={t("onboarding.step.title")}
|
|
type="text"
|
|
value={item.title}
|
|
setValue={(e) =>
|
|
setState({
|
|
...state,
|
|
input: state.input.map((item, i) =>
|
|
i === index ? { ...item, title: e.toString(), name: StringUtils.toCamelCase(e?.toString().toLowerCase()) } : item
|
|
),
|
|
})
|
|
}
|
|
/>
|
|
<InputText
|
|
title={t("shared.name")}
|
|
type="text"
|
|
value={item.name}
|
|
setValue={(e) => setState({ ...state, input: state.input.map((item, i) => (i === index ? { ...item, name: e.toString() } : item)) })}
|
|
/>
|
|
<InputCheckbox
|
|
title={t("onboarding.block.isRequired")}
|
|
value={item.isRequired}
|
|
setValue={(e) => setState({ ...state, input: state.input.map((item, i) => (i === index ? { ...item, isRequired: Boolean(e) } : item)) })}
|
|
/>
|
|
|
|
<InputSelect
|
|
className="col-span-3"
|
|
title={t("onboarding.step.type")}
|
|
value={item.type}
|
|
setValue={(e) =>
|
|
setState({
|
|
...state,
|
|
input: state.input.map((item, i) => (i === index ? { ...item, type: e?.toString() as "text" | "select" } : item)),
|
|
})
|
|
}
|
|
options={[
|
|
{ name: t("onboarding.block.inputTypes.text"), value: "text" },
|
|
{ name: t("onboarding.block.inputTypes.select"), value: "select" },
|
|
]}
|
|
/>
|
|
{item.type === "select" && (
|
|
<div className="col-span-3">
|
|
<SelectOptionsForm
|
|
options={item.options}
|
|
setOptions={(options) => setState({ ...state, input: state.input.map((item, i) => (i === index ? { ...item, options } : item)) })}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</CollapsibleRow>
|
|
))}
|
|
</ReactSortable>
|
|
<ButtonTertiary
|
|
onClick={() =>
|
|
setState({
|
|
...state,
|
|
input: [
|
|
...(state.input ?? []),
|
|
{
|
|
id: Math.floor(Math.random() * 10000).toString(),
|
|
type: "text",
|
|
name: "",
|
|
title: "",
|
|
isRequired: false,
|
|
options: [],
|
|
},
|
|
],
|
|
})
|
|
}
|
|
>
|
|
{t("onboarding.block.actions.addInput")}
|
|
</ButtonTertiary>
|
|
</div>
|
|
</InputGroup>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function SelectOptionsForm({ options, setOptions }: { options: OnboardingInputOptionDto[]; setOptions: (options: OnboardingInputOptionDto[]) => void }) {
|
|
const { t } = useTranslation();
|
|
const [state, setState] = useState<OnboardingInputOptionDto[]>(options ?? []);
|
|
|
|
useEffect(() => {
|
|
setOptions(state);
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [state]);
|
|
|
|
return (
|
|
<div className="flex flex-col space-y-2">
|
|
{options.map((option, index) => (
|
|
<div key={index} className="group relative grid grid-cols-2 gap-2">
|
|
<button
|
|
onClick={() => {
|
|
const options = state ?? [];
|
|
options.splice(index, 1);
|
|
setState(options);
|
|
}}
|
|
type="button"
|
|
className="bg-background text-muted-foreground absolute right-0 top-0 -mt-3 hidden origin-top-right justify-center rounded-full hover:text-red-500 group-hover:flex"
|
|
>
|
|
<XIcon className="h-6 w-6" />
|
|
</button>
|
|
<InputText
|
|
title={t("onboarding.step.title")}
|
|
value={option.name}
|
|
setValue={(e) =>
|
|
setState(
|
|
options.map((option, i) =>
|
|
i === index ? { ...option, name: e.toString(), value: StringUtils.toCamelCase(e?.toString().toLowerCase()) } : option
|
|
)
|
|
)
|
|
}
|
|
/>
|
|
<InputText
|
|
title={t("shared.value")}
|
|
value={option.value}
|
|
setValue={(e) => setState(options.map((option, i) => (i === index ? { ...option, value: e.toString() } : option)))}
|
|
/>
|
|
</div>
|
|
))}
|
|
<ButtonTertiary onClick={() => setState([...options, { value: "", name: "" }])}>{t("onboarding.block.actions.addOption")}</ButtonTertiary>
|
|
</div>
|
|
);
|
|
}
|