Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,8 @@ export const FRONTPAGE_ADMIN_SCOPE = "frontpage:admin";

export const AUDIO_ADMIN_SCOPE = "audio:admin";

export const IMAGE_BATCH_SCOPE = "images:batch";

export const TAXONOMY_CUSTOM_FIELD_LANGUAGE = "language";
export const TAXONOMY_CUSTOM_FIELD_TOPIC_RESOURCES = "topic-resources";
export const TAXONOMY_CUSTOM_FIELD_GROUPED_RESOURCE = "grouped";
Expand Down
167 changes: 167 additions & 0 deletions src/containers/ImageUploader/BatchUploadImagePage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
/**
* Copyright (c) 2026-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 { Button, Heading, PageContainer, Text } from "@ndla/primitives";
import { styled } from "@ndla/styled-system/jsx";
import { NewImageMetaInformationV2DTO } from "@ndla/types-backend/image-api";
import { uniqBy } from "@ndla/util";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { FormActionsContainer } from "../../components/FormikForm";
import validateFormik from "../../components/formikValidationSchema";
import { IMAGE_BATCH_SCOPE } from "../../constants";
import { useLicenses } from "../../modules/draft/draftQueries";
import NotFound from "../NotFoundPage/NotFoundPage";
import PrivateRoute from "../PrivateRoute/PrivateRoute";
import { useSession } from "../Session/SessionProvider";
import { BatchImageUploader } from "./components/batch/BatchImageUploader";
import { CommonImageInfoForm, toImageFormValues } from "./components/batch/CommonInfoForm";
import { ImageListItem } from "./components/batch/ImageListItem";
import { ImageFormikType, imageFormTypeToApiType, imageRules } from "./imageTransformers";

const StyledList = styled("ul", {
base: {
display: "flex",
flexDirection: "column",
gap: "xsmall",
},
});

const StyledPageContainer = styled(PageContainer, {
base: {
gap: "medium",
},
});

export const Component = () => {
return <PrivateRoute component={<BatchUploadImagePage />} />;
};

export const BatchUploadImagePage = () => {
const [acceptedFiles, setAcceptedFiles] = useState<File[]>([]);
const [commonMetadata, setCommonMetadata] = useState<ImageFormikType | undefined>(undefined);
const [specifiedMetadata, setSpecifiedMetadata] = useState<Record<string, ImageFormikType>>({});
const [invalidFiles, setInvalidFiles] = useState<Record<string, string[]>>({});
const [hasImageWithErrors, setHasImageWithErrors] = useState(false);

const { userPermissions } = useSession();

const { t } = useTranslation();

const { data: licenses } = useLicenses({
placeholderData: [],
select: (data) => data.map((lic) => ({ ...lic, description: lic.description ?? "" })) ?? [],
});

const onAcceptFiles = (files: File[]) => {
setAcceptedFiles((prev) => uniqBy([...prev, ...files], (file) => file.name));
};

const onRemoveFile = (file: File) => {
setAcceptedFiles((prev) => prev.filter((f) => f.name !== file.name));
};

const onSave = async () => {
if (!commonMetadata) return;
setHasImageWithErrors(false);
const formValues = acceptedFiles.map((f) =>
toImageFormValues(commonMetadata, specifiedMetadata[f.name], f, commonMetadata.language),
);

const invalidValues = formValues.reduce<Record<string, string[]>>((acc, value) => {
const errors = Object.values(validateFormik(value, imageRules, t));
if (errors.length) {
acc[(value.imageFile as File).name] = errors;
}
return acc;
}, {});

if (Object.keys(invalidValues).length) {
setInvalidFiles(invalidValues);
return;
}

const metadatas = formValues.reduce<NewImageMetaInformationV2DTO[]>((acc, value) => {
const meta = imageFormTypeToApiType(value, licenses);
if (meta) {
acc.push(meta);
}
return acc;
}, []);

if (metadatas.length !== formValues.length) {
setHasImageWithErrors(true);
return;
}

const transformed = acceptedFiles.map((f) => {
const stitched = { ...commonMetadata, ...(specifiedMetadata[f.name] ?? {}), imageFile: f };
return imageFormTypeToApiType(stitched, licenses);
});

// TODO: Implement
return transformed;
};

if (!userPermissions?.includes(IMAGE_BATCH_SCOPE)) {
return <NotFound />;
}

return (
<StyledPageContainer>
<title>{t("htmlTitles.batchUploadImagePage")}</title>
<Heading textStyle="title.large">{t("batchUploadImagePage.heading")}</Heading>
<Text>{t("batchUploadImagePage.description")}</Text>
<Heading asChild consumeCss textStyle="title.medium">
<h2>{t("batchUploadImagePage.commonMetaHeading")}</h2>
</Heading>
<Text>{t("batchUploadImagePage.commonMetaHeadingDescription")}</Text>
<CommonImageInfoForm handleSubmit={setCommonMetadata} />
{!!commonMetadata && (
<>
<Heading asChild consumeCss textStyle="title.medium">
<h2>{t("batchUploadImagePage.uploadImages")}</h2>
</Heading>
<BatchImageUploader acceptedFiles={acceptedFiles} onFileAccept={onAcceptFiles} />
<Heading asChild consumeCss textStyle="title.medium">
<h2>{t("batchUploadImagePage.uploadedImages")}</h2>
</Heading>
<Text>{t("batchUploadImagePage.specificImageDescription")}</Text>
<StyledList>
{acceptedFiles.map((file) => (
<ImageListItem
key={file.name}
file={file}
commonData={commonMetadata}
initialValues={specifiedMetadata[file.name]}
onRemoveFile={onRemoveFile}
handleSubmit={(values) => {
setInvalidFiles((prev) => {
delete prev[file.name];
return prev;
});
setSpecifiedMetadata((prev) => ({ ...prev, [file.name]: values }));
}}
invalid={!!invalidFiles[file.name]}
/>
))}
</StyledList>
{hasImageWithErrors ||
(!!Object.keys(invalidFiles).length && (
<Text color="text.error">{t("batchUploadImagePage.hasImagesWithErrors")}</Text>
))}
<FormActionsContainer>
<Button onClick={onSave} disabled={!!Object.keys(invalidFiles).length}>
{t("batchUploadImagePage.createImages")}
</Button>
</FormActionsContainer>
</>
)}
</StyledPageContainer>
);
};
85 changes: 4 additions & 81 deletions src/containers/ImageUploader/components/ImageForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,17 +20,16 @@ import { useLocation, useNavigate } from "react-router";
import FormAccordion from "../../../components/Accordion/FormAccordion";
import FormAccordions from "../../../components/Accordion/FormAccordions";
import { FormActionsContainer } from "../../../components/FormikForm";
import validateFormik, { RulesType, getWarnings } from "../../../components/formikValidationSchema";
import validateFormik, { getWarnings } from "../../../components/formikValidationSchema";
import FormWrapper from "../../../components/FormWrapper";
import SaveButton from "../../../components/SaveButton";
import { SAVE_BUTTON_ID } from "../../../constants";
import { useLicenses } from "../../../modules/draft/draftQueries";
import { editorValueToPlainText } from "../../../util/articleContentConverter";
import { isFormikFormDirty } from "../../../util/formHelper";
import { NewlyCreatedLocationState } from "../../../util/routeHelpers";
import { AlertDialogWrapper } from "../../FormikForm/AlertDialogWrapper";
import SimpleVersionPanel from "../../FormikForm/SimpleVersionPanel";
import { imageApiTypeToFormType, ImageFormikType } from "../imageTransformers";
import { imageApiTypeToFormType, ImageFormikType, imageFormTypeToApiType, imageRules } from "../imageTransformers";
import ImageContent from "./ImageContent";
import ImageCopyright from "./ImageCopyright";
import { ImageFormHeader } from "./ImageFormHeader";
Expand All @@ -48,52 +47,6 @@ const StyledPageContent = styled(PageContent, {
},
});

const imageRules: RulesType<ImageFormikType, ImageMetaInformationV3DTO> = {
title: {
required: true,
warnings: {
languageMatch: true,
},
},
caption: {
warnings: {
languageMatch: true,
},
},
alttext: {
required: true,
warnings: {
languageMatch: true,
},
},
tags: {
minItems: 3,
warnings: {
languageMatch: true,
},
},
creators: {
allObjectFieldsRequired: true,
},
processors: {
allObjectFieldsRequired: true,
},
rightsholders: {
allObjectFieldsRequired: true,
},
imageFile: {
required: true,
},
license: {
required: true,
test: (values) => {
const authors = values.creators.concat(values.rightsholders).concat(values.processors);
if (!values.license || authors.length > 0) return undefined;
return { translationKey: "validation.noLicenseWithoutCopyrightHolder" };
},
},
};

interface Props<TImage extends ImageMetaInformationV3DTO | undefined = undefined> {
image?: TImage;
onSubmitFunc: (
Expand Down Expand Up @@ -140,45 +93,15 @@ const ImageForm = <TImage extends ImageMetaInformationV3DTO | undefined = undefi
});

const handleSubmit = async (values: ImageFormikType, actions: FormikHelpers<ImageFormikType>) => {
const license = licenses?.find((license) => license.license === values.license);
const imageMetaData = imageFormTypeToApiType(values, licenses);

if (
license === undefined ||
values.title === undefined ||
values.alttext === undefined ||
values.caption === undefined ||
values.language === undefined ||
values.tags === undefined ||
values.origin === undefined ||
values.creators === undefined ||
values.processors === undefined ||
values.rightsholders === undefined ||
values.imageFile === undefined ||
values.modelReleased === undefined
) {
if (!imageMetaData || !values.imageFile) {
actions.setSubmitting(false);
setSavedToServer(false);
return;
}

actions.setSubmitting(true);
const imageMetaData: NewImageMetaInformationV2DTO & UpdateImageMetaInformationDTO = {
title: editorValueToPlainText(values.title),
alttext: values.alttext,
caption: values.caption,
language: values.language,
tags: values.tags,
inactive: values.inactive,
copyright: {
license,
origin: values.origin,
creators: values.creators,
processors: values.processors,
rightsholders: values.rightsholders,
processed: values.processed,
},
modelReleased: values.modelReleased,
};
await onSubmitFunc(imageMetaData, values.imageFile);
setSavedToServer(true);
actions.resetForm();
Expand Down
23 changes: 4 additions & 19 deletions src/containers/ImageUploader/components/ImageUploadFormElement.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,13 @@ import {
import { SafeLink } from "@ndla/safelink";
import { styled } from "@ndla/styled-system/jsx";
import { ImageDimensionsDTO, ImageMetaInformationV3DTO } from "@ndla/types-backend/image-api";
import { uniq } from "@ndla/util";
import { useField } from "formik";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { MAX_IMAGE_UPLOAD_SIZE } from "../../../constants";
import { ImageFormikType } from "../imageTransformers";
import { translateFileError } from "./imageUtils";

const StyledImg = styled("img", {
base: {
Expand Down Expand Up @@ -114,26 +116,9 @@ export const ImageUploadFormElement = ({ language, image }: Props) => {
// as discussed here: https://github.com/jaredpalmer/formik/discussions/3870
const fileErrors = details.files?.[0]?.errors;
if (!fileErrors) return;
if (fileErrors.includes("FILE_TOO_LARGE")) {
const errorMessage = `${t("form.image.fileUpload.genericError")}: ${t(
"form.image.fileUpload.tooLargeError",
)}`;
setTimeout(() => {
helpers.setError(errorMessage);
}, 0);
return;
}
if (fileErrors.includes("FILE_INVALID_TYPE")) {
const errorMessage = `${t("form.image.fileUpload.genericError")}: ${t(
"form.image.fileUpload.fileTypeInvalidError",
)}`;
setTimeout(() => {
helpers.setError(errorMessage);
}, 0);
return;
}
const translatedErrors = fileErrors.map((err) => translateFileError(err, t));
setTimeout(() => {
helpers.setError(t("form.image.fileUpload.genericError"));
helpers.setError(uniq(translatedErrors).join(", "));
}, 0);
}}
>
Expand Down
Loading
Loading