Skip to content
Open
Show file tree
Hide file tree
Changes from 13 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: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
"devDependencies": {
"@babel/core": "^7.29.0",
"@ndla/preset-panda": "^0.0.76",
"@ndla/types-backend": "^1.0.125",
"@ndla/types-backend": "^1.0.130",
"@ndla/types-embed": "^5.0.22-alpha.0",
"@pandacss/dev": "^1.10.0",
"@playwright/test": "^1.57.0",
Expand Down
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_BULK_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
225 changes: 225 additions & 0 deletions src/containers/ImageUploader/BulkUploadImagePage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,225 @@
/**
* 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 { Heading, PageContainer, Text } from "@ndla/primitives";
import { styled } from "@ndla/styled-system/jsx";
import { BulkUploadStartedDTO, BulkUploadStateDTO, 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 SaveButton from "../../components/SaveButton";
import { IMAGE_BULK_SCOPE } from "../../constants";
import { useLicenses } from "../../modules/draft/draftQueries";
import { bulkUploadImages } from "../../modules/image/imageApi";
import NotFound from "../NotFoundPage/NotFoundPage";
import PrivateRoute from "../PrivateRoute/PrivateRoute";
import { useSession } from "../Session/SessionProvider";
import { BulkImageUploader } from "./components/bulk/BulkImageUploader";
import { CommonImageInfoForm, toImageFormValues } from "./components/bulk/CommonInfoForm";
import { ImageListItem } from "./components/bulk/ImageListItem";
import { ImageFormikType, imageFormTypeToApiType, imageRules } from "./imageTransformers";
import { useImageUploadStatus } from "./useImageUploadStatus";

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

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

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

export const BulkUploadImagePage = () => {
const [bulkUploadId, setBulkUploadId] = useState<BulkUploadStartedDTO | undefined>(undefined);
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 uploadState = useImageUploadStatus(bulkUploadId?.uploadId);

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 || uploadState?.status === "Complete") return;
setBulkUploadId(undefined);
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), f];
});

const res = await bulkUploadImages(metadatas, acceptedFiles);
setBulkUploadId(res);

return transformed;
};

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

return (
<StyledPageContainer>
<title>{t("htmlTitles.bulkUploadImagePage")}</title>
<Heading textStyle="title.large">{t("bulkUploadImagePage.heading")}</Heading>
<Text>{t("bulkUploadImagePage.description")}</Text>
<Heading asChild consumeCss textStyle="title.medium">
<h2>{t("bulkUploadImagePage.commonMetaHeading")}</h2>
</Heading>
<Text>{t("bulkUploadImagePage.commonMetaHeadingDescription")}</Text>
<CommonImageInfoForm handleSubmit={setCommonMetadata} />
{!!commonMetadata && (
<>
<Heading asChild consumeCss textStyle="title.medium">
<h2>{t("bulkUploadImagePage.uploadImages")}</h2>
</Heading>
<BulkImageUploader acceptedFiles={acceptedFiles} onFileAccept={onAcceptFiles} />
<Heading asChild consumeCss textStyle="title.medium">
<h2>{t("bulkUploadImagePage.uploadedImages")}</h2>
</Heading>
<Text>{t("bulkUploadImagePage.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("bulkUploadImagePage.hasImagesWithErrors")}</Text>
))}
<FormActionsContainer>
<SaveButton
onClick={onSave}
showSaved={uploadState?.status === "Complete"}
loading={!!uploadState && uploadState.status !== "Complete"}
disabled={!!Object.keys(invalidFiles).length}
>
{t("bulkUploadImagePage.createImages")}
</SaveButton>
</FormActionsContainer>
{!!uploadState && !!bulkUploadId && <BulkUploadState state={uploadState} />}
</>
)}
</StyledPageContainer>
);
};

interface BulkUploadStateProps {
state: BulkUploadStateDTO;
}

const TextContainer = styled("div", {
base: {
display: "flex",
flexDirection: "column",
gap: "4xsmall",
},
});

const BulkUploadState = ({ state }: BulkUploadStateProps) => {
const { t } = useTranslation();

if (state.status === "Pending" || state.status === "Running") {
return (
<TextContainer>
<Text>{t("bulkUploadImagePage.uploadInProgress")}</Text>
<Text>{t("bulkUploadImagePage.progressText", { completed: state.completed, total: state.total })}</Text>
{!!state.failed && <Text>{t("bulkUploadImagePage.progressFailed", { failed: state.failed })}</Text>}
</TextContainer>
);
}

if (state.status === "Failed") {
return (
<TextContainer>
<Text color="text.error">
{t("bulkUploadImagePage.uploadFailed", {
completed: state.completed,
total: state.total,
failed: state.failed,
})}
</Text>
</TextContainer>
);
} else if (state.status === "Complete" && state.failed) {
return <Text>{t("bulkUploadImagePage.uploadCompletedWithFailed", { failed: state.failed })}</Text>;
} else if (state.status === "Complete") {
return <Text>{t("bulkUploadImagePage.uploadCompleted", { total: state.completed })}</Text>;
}
};
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
Loading
Loading