Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add alertdialog and refactor form provider to wrap entirity of contentpage #2421

Open
wants to merge 6 commits into
base: master
Choose a base branch
from
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
*/

import { useEffect, useMemo, useRef, useState } from "react";
import { FormProvider, useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import {
closestCenter,
Expand All @@ -28,9 +29,10 @@ import { AddLine } from "@ndla/icons";
import { Button, Heading } from "@ndla/primitives";
import { SafeLinkButton } from "@ndla/safelink";
import { Stack, styled } from "@ndla/styled-system/jsx";
import { AlertDialog } from "./components/AlertDialog";
import { DraggableLearningpathStepListItem } from "./components/DraggableLearningpathStepListItem";
import LearningpathStepForm from "./components/LearningpathStepForm";
import { formValuesToGQLInput } from "./learningpathFormUtils";
import { formValuesToGQLInput, toFormValues } from "./learningpathFormUtils";
import { FormValues } from "./types";
import { useToast } from "../../../components/ToastContext";
import { GQLMyNdlaLearningpathFragment } from "../../../graphqlTypes";
Expand All @@ -56,10 +58,15 @@ interface Props {
learningpath: GQLMyNdlaLearningpathFragment;
}

const NO_FORM_ID = -2;
const ADD_NEW_FORM_ID = -1;

export const EditLearningpathStepsPageContent = ({ learningpath }: Props) => {
const [sortedLearningpathSteps, setSortedLearningpathSteps] = useState(learningpath.learningsteps ?? []);
const { t, i18n } = useTranslation();
const [selectedLearningpathStepId, setSelectedLearningpathStepId] = useState<undefined | number>(undefined);
const [selectedLearningpathStepId, setSelectedLearningpathStepId] = useState<number>(NO_FORM_ID);
const [nextId, setNextId] = useState<number | undefined>(undefined);

const [createStep] = useCreateLearningpathStep();
const [updateLearningpathStepSeqNo] = useUpdateLearningpathStepSeqNo();
const toast = useToast();
Expand All @@ -70,6 +77,10 @@ export const EditLearningpathStepsPageContent = ({ learningpath }: Props) => {
setSortedLearningpathSteps(learningpath.learningsteps);
}, [learningpath.learningsteps]);

const formMethods = useForm<FormValues>({
defaultValues: toFormValues("text"),
});

const onSaveStep = async (values: FormValues) => {
if (learningpath?.id) {
const transformedData = formValuesToGQLInput(values);
Expand All @@ -80,7 +91,7 @@ export const EditLearningpathStepsPageContent = ({ learningpath }: Props) => {
},
});
if (!res.errors?.length) {
setSelectedLearningpathStepId(undefined);
handleStateChanges(NO_FORM_ID);
toast.create({ title: t("myNdla.learningpath.toast.createdStep", { name: values.title }) });
headingRef.current?.scrollIntoView({ behavior: "smooth" });
} else {
Expand All @@ -89,6 +100,21 @@ export const EditLearningpathStepsPageContent = ({ learningpath }: Props) => {
}
};

const onFormChange = (val: number) => {
if (formMethods.formState.isDirty && !formMethods.formState.isSubmitting) {
setNextId(val);
} else {
handleStateChanges(val);
}
};

const handleStateChanges = (val: number | undefined) => {
//Reset the form to remove traces of changes
formMethods.reset();
setSelectedLearningpathStepId(val ?? NO_FORM_ID);
setNextId(undefined);
};

const announcements = useMemo(
() => makeDndTranslations("learningpathstep", t, sortedLearningpathSteps.length),
[sortedLearningpathSteps, t],
Expand Down Expand Up @@ -137,11 +163,17 @@ export const EditLearningpathStepsPageContent = ({ learningpath }: Props) => {
);

return (
<>
<FormProvider {...formMethods}>
<AlertDialog
onAbort={() => setNextId(undefined)}
onContinue={() => handleStateChanges(nextId)}
isBlocking={!!nextId}
/>
<Stack gap="medium" justify="left">
<Heading textStyle="heading.small" asChild consumeCss ref={headingRef}>
<h2>{t("myNdla.learningpath.form.content.title")}</h2>
</Heading>

{!!sortedLearningpathSteps.length && (
<DndContext
sensors={sensors}
Expand All @@ -159,30 +191,26 @@ export const EditLearningpathStepsPageContent = ({ learningpath }: Props) => {
{sortedLearningpathSteps.map((step, index) => (
<DraggableLearningpathStepListItem
key={`${step.id.toString()}`}
learningpathId={learningpath.id ?? -1}
step={step}
learningpathId={learningpath.id}
selectedLearningpathStepId={selectedLearningpathStepId}
setSelectedLearningpathStepId={setSelectedLearningpathStepId}
onClose={() => onFormChange(NO_FORM_ID)}
onSelect={() => onFormChange(step.id)}
index={index}
/>
))}
</StyledOl>
</SortableContext>
</DndContext>
)}
{!selectedLearningpathStepId || selectedLearningpathStepId !== -1 ? (
<AddButton variant="secondary" onClick={() => setSelectedLearningpathStepId(-1)}>
{selectedLearningpathStepId !== ADD_NEW_FORM_ID ? (
<AddButton variant="secondary" onClick={() => onFormChange(ADD_NEW_FORM_ID)}>
<AddLine />
{t("myNdla.learningpath.form.steps.add")}
</AddButton>
) : null}
{selectedLearningpathStepId === -1 ? (
<LearningpathStepForm
stepType="text"
onClose={() => setSelectedLearningpathStepId(undefined)}
onSave={onSaveStep}
/>
) : null}
) : (
<LearningpathStepForm stepType="text" onClose={() => onFormChange(NO_FORM_ID)} onSave={onSaveStep} />
)}
</Stack>
<Stack justify="space-between" direction="row">
<SafeLinkButton variant="secondary" to={routes.myNdla.learningpathEditTitle(learningpath.id)}>
Expand All @@ -192,7 +220,7 @@ export const EditLearningpathStepsPageContent = ({ learningpath }: Props) => {
{t("myNdla.learningpath.form.next")}
</SafeLinkButton>
</Stack>
</>
</FormProvider>
);
};

Expand Down
125 changes: 125 additions & 0 deletions src/containers/MyNdla/Learningpath/components/AlertDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
/**
* Copyright (c) 2025-present, NDLA.
*
* This source code is licensed under the GPLv3 license found in the
* LICENSE file in the root directory of this source tree.
*
*/

import { History, Blocker, Transition } from "history";
import { useContext, useEffect, useState } from "react";
import { useFormContext } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { UNSAFE_NavigationContext, useNavigate, Location } from "react-router-dom";
import {
Button,
DialogBody,
DialogContent,
DialogFooter,
DialogHeader,
DialogRoot,
DialogTitle,
Text,
} from "@ndla/primitives";
import { DialogCloseButton } from "../../../../components/DialogCloseButton";
import { supportedLanguages } from "../../../../i18n";

// TODO: Remove when upgrading react-router
// V6 has not added useBlocker hook yet. Source taken from react-router. Same logic used in editorial frontend
const useBlocker = (blocker: Blocker, when = true): void => {
const navigator = useContext(UNSAFE_NavigationContext).navigator as History;

useEffect(() => {
if (!when) return;
const unblock = navigator.block((tx: Transition) => {
const autoUnblockingTx = {
...tx,
retry() {
// Automatically unblock the transition so it can play all the way
// through before retrying it. TODO: Figure out how to re-enable
// this block if the transition is cancelled for some reason.
unblock();
tx.retry();
},
};

blocker(autoUnblockingTx);
});
return unblock;
}, [navigator, blocker, when]);
};

interface Props {
onContinue?: () => void;
onAbort?: () => void;
isBlocking?: boolean;
}

export const AlertDialog = ({ onContinue, onAbort, isBlocking }: Props) => {
const [open, setOpen] = useState<boolean>(!!isBlocking);
const [nextLocation, setNextLocation] = useState<Location | undefined>(undefined);
const { formState } = useFormContext();
const { t } = useTranslation();

const navigate = useNavigate();

const shouldBlock = !(!formState.isDirty || formState.isSubmitting);
const onCancel = () => {
setNextLocation(undefined);
setOpen(false);
onAbort?.();
};

const onWillContinue = () => {
setOpen(false);
onContinue?.();
};

useBlocker((transition) => {
if (shouldBlock) {
// transition does not respect basename. Filter out basename until it is fixed.
const pathRegex = new RegExp(supportedLanguages.map((l) => `/${l}/`).join("|"));
const pathname = transition.location.pathname.replace(pathRegex, "/");
setOpen(true);
setNextLocation({ ...transition.location, pathname });
}
}, shouldBlock);

useEffect(() => {
setOpen(!!isBlocking);
}, [isBlocking]);

useEffect(() => {
if (!shouldBlock && nextLocation) {
navigate(nextLocation);
}
}, [shouldBlock, nextLocation, navigate]);

return (
<DialogRoot
open={open}
onOpenChange={(details) => setOpen(details.open)}
closeOnEscape
closeOnInteractOutside
onExitComplete={onCancel}
>
<DialogContent>
<DialogHeader>
<DialogTitle>{t(`myNdla.learningpath.alert.title`)}</DialogTitle>
<DialogCloseButton />
</DialogHeader>
<DialogBody>
<Text textStyle="body.large">{t(`myNdla.learningpath.alert.content`)}</Text>
</DialogBody>
<DialogFooter>
<Button variant="secondary" onClick={onCancel}>
{t(`myNdla.learningpath.alert.cancel`)}
</Button>
<Button variant="danger" onClick={onWillContinue}>
{t(`myNdla.learningpath.alert.continue`)}
</Button>
</DialogFooter>
</DialogContent>
</DialogRoot>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
*
*/

import { Dispatch, SetStateAction } from "react";
import { useTranslation } from "react-i18next";
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
Expand All @@ -16,12 +15,12 @@ import { Stack, styled } from "@ndla/styled-system/jsx";
import { useToast } from "../../../../components/ToastContext";
import { GQLMyNdlaLearningpathStepFragment } from "../../../../graphqlTypes";
import { useUpdateLearningpathStep, useDeleteLearningpathStep } from "../../../../mutations/learningpathMutations";
import DragHandle from "../../components/DragHandle";
import { FormValues } from "../types";
import { getFormTypeFromStep } from "../utils";
import LearningpathStepForm from "./LearningpathStepForm";
import { formValuesToGQLInput } from "../learningpathFormUtils";
import { DraggableListItem } from "./DraggableListItem";
import DragHandle from "../../components/DragHandle";

export const DragWrapper = styled("div", {
base: {
Expand Down Expand Up @@ -70,15 +69,17 @@ interface LearningpathStepListItemProps {
learningpathId: number;
step: GQLMyNdlaLearningpathStepFragment;
selectedLearningpathStepId: number | undefined;
setSelectedLearningpathStepId: Dispatch<SetStateAction<number | undefined>>;
onClose: VoidFunction;
onSelect: VoidFunction;
index: number;
}

export const DraggableLearningpathStepListItem = ({
step,
learningpathId,
selectedLearningpathStepId,
setSelectedLearningpathStepId,
onClose,
onSelect,
index,
}: LearningpathStepListItemProps) => {
const { t, i18n } = useTranslation();
Expand Down Expand Up @@ -110,8 +111,9 @@ export const DraggableLearningpathStepListItem = ({
params: { ...transformedData, language: i18n.language, revision: step.revision },
},
});

if (!res.errors?.length) {
setSelectedLearningpathStepId(undefined);
onClose();
} else {
toast.create({ title: t("myNdla.learningpath.toast.updateStepFailed", { name: step.title }) });
}
Expand All @@ -125,7 +127,7 @@ export const DraggableLearningpathStepListItem = ({
},
});
if (!res.errors?.length) {
setSelectedLearningpathStepId(undefined);
onClose();
toast.create({ title: t("myNdla.learningpath.toast.deletedStep", { name: step.title }) });
close();
} else {
Expand Down Expand Up @@ -155,11 +157,12 @@ export const DraggableLearningpathStepListItem = ({
<Text textStyle="label.small">{t(`myNdla.learningpath.form.options.${stepType}`)}</Text>
</Stack>
{step.id !== selectedLearningpathStepId ? (
<Button variant="tertiary" onClick={() => setSelectedLearningpathStepId(step.id)}>
{t("myNdla.learningpath.form.steps.edit")} <PencilLine />
<Button variant="tertiary" onClick={onSelect}>
{t("myNdla.learningpath.form.steps.edit")}
<PencilLine />
</Button>
) : (
<Button variant="tertiary" onClick={() => setSelectedLearningpathStepId(undefined)}>
<Button variant="tertiary" onClick={onClose}>
<CloseLine />
{t("close")}
</Button>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@
*
*/

import { Controller, FormProvider, useForm, useFormContext } from "react-hook-form";
import { useEffect } from "react";
import { Controller, FormProvider, useFormContext } from "react-hook-form";
import { useTranslation } from "react-i18next";
import {
Button,
Expand Down Expand Up @@ -55,13 +56,13 @@ interface Props {
export const LearningpathStepForm = ({ step, stepType, onClose, onSave, onDelete }: Props) => {
const { t } = useTranslation();

const methods = useForm<FormValues>({
mode: "onSubmit",
defaultValues: toFormValues(stepType, step),
});

const methods = useFormContext<FormValues>();
const { handleSubmit, control, reset, formState } = methods;

useEffect(() => {
reset(toFormValues(stepType, step));
}, [reset, step, stepType]);
Comment on lines +62 to +64
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hvorfor trengs denne?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Formcontexten er nå global og ikke unik for hvert steg så må vi sette formValues til dataen for steget, hver gang vi åpner et steg. Dette vet vi ikke før steget er valgt.


return (
<FormProvider {...methods}>
<ContentForm onSubmit={handleSubmit(onSave)} noValidate>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ export const ResourceStepForm = ({ resource }: ResourceFormProps) => {
const { t } = useTranslation();
const [selectedResource, setSelectedResource] = useState<ResourceData | undefined>(resource);
const [focusId, setFocusId] = useState<string | undefined>(undefined);
const { setValue } = useFormContext<ResourceFormValues>();
const { setValue, reset } = useFormContext<ResourceFormValues>();

const onSelectResource = (resource: ResourceData) => {
setSelectedResource(resource);
Expand All @@ -42,8 +42,7 @@ export const ResourceStepForm = ({ resource }: ResourceFormProps) => {

const onRemove = () => {
setSelectedResource(undefined);
setValue("embedUrl", "", { shouldDirty: true });
setValue("title", "", { shouldDirty: true });
reset({ type: "resource", title: "", embedUrl: "" });
setFocusId("resource-input");
};

Expand Down
Loading