From c49113f0cf6a8a7f11dc6e2c9d64537998a6d5a5 Mon Sep 17 00:00:00 2001 From: Demian Katz Date: Wed, 24 Jul 2024 15:11:14 -0400 Subject: [PATCH] Add support for cloning process metadata. (#326) --- CHANGELOG.md | 1 + .../DatastreamProcessMetadataContent.test.tsx | 119 +++- .../DatastreamProcessMetadataContent.tsx | 155 ++-- ...streamProcessMetadataContent.test.tsx.snap | 666 +++++++++--------- client/hooks/useDatastreamOperation.ts | 6 +- 5 files changed, 579 insertions(+), 368 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 64bfcbc5..2c87c023 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ All notable changes to this project will be documented in this file, in reverse - "Copy PID" button. - Countdown in status message when updating multiple object states. - Models are visible in the object editor. +- Process metadata can be cloned. - Recent PIDs are now available in the PID selector. ### Changed diff --git a/client/components/edit/datastream/DatastreamProcessMetadataContent.test.tsx b/client/components/edit/datastream/DatastreamProcessMetadataContent.test.tsx index 3bbc0771..0c85100d 100644 --- a/client/components/edit/datastream/DatastreamProcessMetadataContent.test.tsx +++ b/client/components/edit/datastream/DatastreamProcessMetadataContent.test.tsx @@ -5,6 +5,7 @@ import userEvent from "@testing-library/user-event"; import renderer from "react-test-renderer"; import DatastreamProcessMetadataContent from "./DatastreamProcessMetadataContent"; import { waitFor } from "@testing-library/react"; +import { act } from "react-dom/test-utils"; const mockUseGlobalContext = jest.fn(); jest.mock("../../../context/GlobalContext", () => ({ @@ -13,6 +14,13 @@ jest.mock("../../../context/GlobalContext", () => ({ }, })); +const mockUseEditorContext = jest.fn(); +jest.mock("../../../context/EditorContext", () => ({ + useEditorContext: () => { + return mockUseEditorContext(); + }, +})); + const mockUseProcessMetadataContext = jest.fn(); jest.mock("../../../context/ProcessMetadataContext", () => ({ useProcessMetadataContext: () => { @@ -37,8 +45,25 @@ jest.mock("@mui/x-date-pickers", () => ({ LocalizationProvider: () => "LocalizationProvider", })); +jest.mock("@mui/material/Box", () => (props) => props.children); +jest.mock("@mui/material/FormControl", () => (props) => props.children); +jest.mock("@mui/material/FormLabel", () => (props) => props.children); +let tabChangeFunction: ((tab: number) => void) | null = null; +jest.mock("@mui/material/Tabs", () => (props) => { + tabChangeFunction = props.onChange; + return props.children; +}); +jest.mock("@mui/material/Tab", () => (props) => `Tab: ${props.label}`); +jest.mock("@mui/material/Grid", () => (props) => props.children); +let pidPickerFunction: ((pid: string) => void) | null = null; +jest.mock("../PidPicker", () => (props) => { + pidPickerFunction = props.setSelected; + return "PidPicker: " + JSON.stringify(props); +}); + describe("DatastreamProcessMetadataContent", () => { let datastreamOperationValues; + let editorContext; let globalValues; let processMetadataValues; @@ -51,7 +76,7 @@ describe("DatastreamProcessMetadataContent", () => { await waitFor(() => expect(processMetadataValues.action.setMetadata).toHaveBeenCalledWith(fakeData)); }; - const getRenderedTree = async (fakeData = {}) => { + const getRenderedTree = async (fakeData = {}, extraStep: (() => void) | null = null) => { datastreamOperationValues.getProcessMetadata.mockResolvedValue(fakeData); processMetadataValues.state = fakeData; @@ -61,10 +86,27 @@ describe("DatastreamProcessMetadataContent", () => { await waitFor(() => expect(processMetadataValues.action.setMetadata).toHaveBeenCalledWith(fakeData)); }); + if (extraStep) { + await renderer.act(async () => { + extraStep(); + }); + } return tree.toJSON(); }; beforeEach(() => { + editorContext = { + state: { + childListStorage: {}, + objectDetailsStorage: {}, + }, + action: { + getChildListStorageKey: jest.fn(), + loadChildrenIntoStorage: jest.fn(), + loadObjectDetailsIntoStorage: jest.fn(), + }, + }; + mockUseEditorContext.mockReturnValue(editorContext); datastreamOperationValues = { uploadProcessMetadata: jest.fn(), getProcessMetadata: jest.fn(), @@ -105,6 +147,16 @@ describe("DatastreamProcessMetadataContent", () => { expect(tree).toMatchSnapshot(); }); + it("supports tab switching", async () => { + const tree = await getRenderedTree({}, () => { + if (tabChangeFunction) { + tabChangeFunction(null, 1); + } + }); + expect(processMetadataValues.action.addTask).toHaveBeenCalledWith(0); + expect(tree).toMatchSnapshot(); + }); + it("renders a form when non-empty data is loaded", async () => { const tree = await getRenderedTree({ processLabel: "label", @@ -123,6 +175,71 @@ describe("DatastreamProcessMetadataContent", () => { expect(datastreamOperationValues.uploadProcessMetadata).toHaveBeenCalledWith(processMetadataValues.state); }); + it("ignores empty PIDs in clone input", async () => { + await renderComponent(); + expect(pidPickerFunction).not.toBeNull(); + await act(async () => { + pidPickerFunction && pidPickerFunction(""); + }); + expect(editorContext.action.loadObjectDetailsIntoStorage).not.toHaveBeenCalled(); + }); + + it("handles errors that occur during PID cloning", async () => { + await renderComponent(); + const alertSpy = jest.spyOn(window, "alert").mockImplementation(jest.fn()); + editorContext.action.loadObjectDetailsIntoStorage.mockImplementation( + (pid: string, errorCallback: () => void) => { + expect(pid).toEqual("foo:123"); + errorCallback(); + }, + ); + expect(pidPickerFunction).not.toBeNull(); + await act(async () => { + pidPickerFunction && pidPickerFunction("foo:123"); + }); + expect(alertSpy).toHaveBeenCalledWith("Cannot load PID: foo:123"); + }); + + it("supports loading PID data into storage after selection for cloning", async () => { + await renderComponent(); + expect(pidPickerFunction).not.toBeNull(); + await act(async () => { + pidPickerFunction && pidPickerFunction("foo:123"); + }); + expect(editorContext.action.loadObjectDetailsIntoStorage).toHaveBeenCalledWith("foo:123", expect.anything()); + }); + + it("validates datastreams before cloning metadata from another PID", async () => { + editorContext.state.objectDetailsStorage = { + "foo:123": { + datastreams: [], + }, + }; + const alertSpy = jest.spyOn(window, "alert").mockImplementation(jest.fn()); + await renderComponent(); + expect(pidPickerFunction).not.toBeNull(); + await act(async () => { + pidPickerFunction && pidPickerFunction("foo:123"); + }); + await userEvent.setup().click(screen.getByText("Clone")); + expect(alertSpy).toHaveBeenCalledWith("foo:123 does not contain a PROCESS-MD datastream."); + }); + + it("can clone metadata from another PID", async () => { + editorContext.state.objectDetailsStorage = { + "foo:123": { + datastreams: ["PROCESS-MD"], + }, + }; + await renderComponent(); + expect(pidPickerFunction).not.toBeNull(); + await act(async () => { + pidPickerFunction && pidPickerFunction("foo:123"); + }); + await userEvent.setup().click(screen.getByText("Clone")); + expect(datastreamOperationValues.getProcessMetadata).toHaveBeenCalledWith("foo:123", true); + }); + it("supports task updates", async () => { await renderComponent({ tasks: [{ id: 1 }] }); const attr = { foo: "bar" }; diff --git a/client/components/edit/datastream/DatastreamProcessMetadataContent.tsx b/client/components/edit/datastream/DatastreamProcessMetadataContent.tsx index 4b8f2c5c..7b8e26eb 100644 --- a/client/components/edit/datastream/DatastreamProcessMetadataContent.tsx +++ b/client/components/edit/datastream/DatastreamProcessMetadataContent.tsx @@ -1,9 +1,12 @@ import React, { useEffect, useState } from "react"; +import Box from "@mui/material/Box"; import Button from "@mui/material/Button"; import DialogActions from "@mui/material/DialogActions"; import DialogContent from "@mui/material/DialogContent"; import FormControl from "@mui/material/FormControl"; import FormLabel from "@mui/material/FormLabel"; +import Tab from "@mui/material/Tab"; +import Tabs from "@mui/material/Tabs"; import TextField from "@mui/material/TextField"; import useDatastreamOperation from "../../../hooks/useDatastreamOperation"; import { useGlobalContext } from "../../../context/GlobalContext"; @@ -14,6 +17,8 @@ import DatastreamProcessMetadataTask from "./DatastreamProcessMetadataTask"; import { DateTimePicker, LocalizationProvider } from "@mui/x-date-pickers"; import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; import type {} from "@mui/x-date-pickers/themeAugmentation"; +import { useEditorContext } from "../../../context/EditorContext"; +import PidPicker from "../PidPicker"; // Whenever a task is added or removed, we need to revise the keys on the task // components so that React renders correctly. This counter is incremented on each @@ -37,18 +42,58 @@ const DatastreamProcessMetadataContent = (): React.ReactElement => { updateTaskAttributes, }, } = useProcessMetadataContext(); + const { + state: { objectDetailsStorage }, + action: { loadObjectDetailsIntoStorage }, + } = useEditorContext(); const [loading, setLoading] = useState(true); - + const [clonePid, setClonePid] = useState(""); + const EDIT_TAB = 0; + const CLONE_TAB = 1; + const [tab, setTab] = useState(EDIT_TAB); + const handleTabChange = (event: React.SyntheticEvent, newValue: number) => { + setTab(newValue); + }; + const clonePidLoaded = clonePid.length > 0 && Object.prototype.hasOwnProperty.call(objectDetailsStorage, clonePid); + const loadClonePid = async (newClonePid: string) => { + if (newClonePid.length == 0) { + setClonePid(""); + return; + } + if (!Object.prototype.hasOwnProperty.call(objectDetailsStorage, newClonePid)) { + let error = false; + const errorCallback = () => { + error = true; + }; + await loadObjectDetailsIntoStorage(newClonePid, errorCallback); + if (error) { + alert(`Cannot load PID: ${newClonePid}`); + return; + } + } + setClonePid(newClonePid); + }; const { uploadProcessMetadata, getProcessMetadata } = useDatastreamOperation(); + const loadProcessMetadata = async (overridePid: string | null = null, force = false) => { + const metadata = await getProcessMetadata(overridePid, force); + setMetadata(metadata); + if ((metadata.tasks ?? []).length == 0) { + addTask(0); + } + setLoading(false); + }; + const doClone = async () => { + const details = objectDetailsStorage[clonePid] ?? {}; + if ((details?.datastreams ?? []).includes("PROCESS-MD")) { + setLoading(true); + await loadProcessMetadata(clonePid, true); + } else { + alert(`${clonePid} does not contain a PROCESS-MD datastream.`); + } + setTab(EDIT_TAB); + setClonePid(""); + }; useEffect(() => { - const loadProcessMetadata = async () => { - const metadata = await getProcessMetadata(); - setMetadata(metadata); - if ((metadata.tasks ?? []).length == 0) { - addTask(0); - } - setLoading(false); - }; loadProcessMetadata(); }, []); const tasks = (processMetadata.tasks ?? []).map((task, i) => { @@ -82,46 +127,60 @@ const DatastreamProcessMetadataContent = (): React.ReactElement => { Digital Provenance - - - - - - - - - - - - - - } - label="Process Date/Time" - value={processMetadata.processDateTime ?? ""} - onChange={setProcessDateTime} - /> - - - - - - + + + + + + +
+ + + + + + + + + + + + + + } + label="Process Date/Time" + value={processMetadata.processDateTime ?? ""} + onChange={setProcessDateTime} + /> + + + + + + + - - {tasks.length > 0 ? tasks : } + {tasks.length > 0 ? tasks : } +
+
+
+ +
+ {clonePidLoaded ? : null} +
diff --git a/client/components/edit/datastream/__snapshots__/DatastreamProcessMetadataContent.test.tsx.snap b/client/components/edit/datastream/__snapshots__/DatastreamProcessMetadataContent.test.tsx.snap index a7686730..62891a69 100644 --- a/client/components/edit/datastream/__snapshots__/DatastreamProcessMetadataContent.test.tsx.snap +++ b/client/components/edit/datastream/__snapshots__/DatastreamProcessMetadataContent.test.tsx.snap @@ -5,192 +5,121 @@ exports[`DatastreamProcessMetadataContent renders a form when empty data is load
+ Digital Provenance + Tab: Editor + Tab: Clone
- -
-
+ Process Label
-
+
-
- -
- -
- - - Process Label - - -
-
-
-
+ + +
+ Process Creator
-
+
-
- -
- -
- - - Process Creator - - -
-
-
-
+ + +
+ LocalizationProvider + Process Organization
- LocalizationProvider -
-
-
+
-
- -
- -
- - - Process Organization - - -
-
-
-
+ + +
+
- +
+ PidPicker: {"selected":""} +
+
,
+ Digital Provenance + Tab: Editor + Tab: Clone
- -
-
+ Process Label
-
+
-
- -
- -
- - - Process Label - - -
-
-
-
+ + +
+ Process Creator
-
+
-
- -
- -
- - - Process Creator - - -
-
-
-
+ + +
+ LocalizationProvider + Process Organization
- LocalizationProvider -
-
-
+
-
- -
- -
- - - Process Organization - - -
-
-
-
+ + + +
+
+
+
+ PidPicker: {"selected":""}
, @@ -496,3 +354,179 @@ exports[`DatastreamProcessMetadataContent renders a loading message if content i Loading... `; + +exports[`DatastreamProcessMetadataContent supports tab switching 1`] = ` +[ +
+ Digital Provenance + Tab: Editor + Tab: Clone +
+ Process Label +
+ +
+ + + Process Label + + +
+
+ Process Creator +
+ +
+ + + Process Creator + + +
+
+ LocalizationProvider + Process Organization +
+ +
+ + + Process Organization + + +
+
+ +
+
+
+ PidPicker: {"selected":""} +
+
+
, +
+ + +
, +] +`; diff --git a/client/hooks/useDatastreamOperation.ts b/client/hooks/useDatastreamOperation.ts index c8db809c..a4fe42bd 100644 --- a/client/hooks/useDatastreamOperation.ts +++ b/client/hooks/useDatastreamOperation.ts @@ -273,10 +273,10 @@ const useDatastreamOperation = () => { } return ""; }; - const getProcessMetadata = async (): Promise => { - if(currentDatastreams.includes(activeDatastream)) { + const getProcessMetadata = async (overridePid: string | null = null, force = false): Promise => { + if(force || currentDatastreams.includes(activeDatastream)) { try { - return await fetchJSON(objectDatastreamProcessMetadataUrl(currentPid, activeDatastream)); + return await fetchJSON(objectDatastreamProcessMetadataUrl(overridePid ?? currentPid, activeDatastream)); } catch(err) { setSnackbarState({ open: true,