Skip to content

Commit

Permalink
Add support for cloning process metadata. (#326)
Browse files Browse the repository at this point in the history
  • Loading branch information
demiankatz authored Jul 24, 2024
1 parent b67a418 commit c49113f
Show file tree
Hide file tree
Showing 5 changed files with 579 additions and 368 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => ({
Expand All @@ -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: () => {
Expand All @@ -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;

Expand All @@ -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;

Expand All @@ -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(),
Expand Down Expand Up @@ -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",
Expand All @@ -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" };
Expand Down
155 changes: 107 additions & 48 deletions client/components/edit/datastream/DatastreamProcessMetadataContent.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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
Expand All @@ -37,18 +42,58 @@ const DatastreamProcessMetadataContent = (): React.ReactElement => {
updateTaskAttributes,
},
} = useProcessMetadataContext();
const {
state: { objectDetailsStorage },
action: { loadObjectDetailsIntoStorage },
} = useEditorContext();
const [loading, setLoading] = useState<boolean>(true);

const [clonePid, setClonePid] = useState("");
const EDIT_TAB = 0;
const CLONE_TAB = 1;
const [tab, setTab] = useState<number>(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) => {
Expand Down Expand Up @@ -82,46 +127,60 @@ const DatastreamProcessMetadataContent = (): React.ReactElement => {
<FormControl style={{ marginBottom: "10px" }}>
<FormLabel>Digital Provenance</FormLabel>
</FormControl>
<Grid container spacing={1} style={{ marginBottom: "10px" }}>
<Grid item xs={3}>
<FormControl fullWidth={true}>
<BlurSavingTextField
options={{ label: "Process Label" }}
value={processMetadata.processLabel ?? ""}
setValue={setProcessLabel}
/>
</FormControl>
</Grid>
<Grid item xs={3}>
<FormControl fullWidth={true}>
<BlurSavingTextField
options={{ label: "Process Creator" }}
value={processMetadata.processCreator ?? ""}
setValue={setProcessCreator}
/>
</FormControl>
</Grid>
<Grid item xs={3}>
<LocalizationProvider dateAdapter={AdapterDayjs}>
<DateTimePicker
renderInput={(props) => <TextField {...props} />}
label="Process Date/Time"
value={processMetadata.processDateTime ?? ""}
onChange={setProcessDateTime}
/>
</LocalizationProvider>
</Grid>
<Grid item xs={3}>
<FormControl fullWidth={true}>
<BlurSavingTextField
options={{ label: "Process Organization" }}
value={processMetadata.processOrganization ?? ""}
setValue={setProcessOrganization}
/>
</FormControl>
<Box sx={{ borderBottom: 1, borderColor: "divider", marginBottom: "1em" }}>
<Tabs value={tab} onChange={handleTabChange}>
<Tab label="Editor" />
<Tab label="Clone" />
</Tabs>
</Box>
<div style={{ display: tab == EDIT_TAB ? "block" : "none" }}>
<Grid container spacing={1} style={{ marginBottom: "10px" }}>
<Grid item xs={3}>
<FormControl fullWidth={true}>
<BlurSavingTextField
options={{ label: "Process Label" }}
value={processMetadata.processLabel ?? ""}
setValue={setProcessLabel}
/>
</FormControl>
</Grid>
<Grid item xs={3}>
<FormControl fullWidth={true}>
<BlurSavingTextField
options={{ label: "Process Creator" }}
value={processMetadata.processCreator ?? ""}
setValue={setProcessCreator}
/>
</FormControl>
</Grid>
<Grid item xs={3}>
<LocalizationProvider dateAdapter={AdapterDayjs}>
<DateTimePicker
renderInput={(props) => <TextField {...props} />}
label="Process Date/Time"
value={processMetadata.processDateTime ?? ""}
onChange={setProcessDateTime}
/>
</LocalizationProvider>
</Grid>
<Grid item xs={3}>
<FormControl fullWidth={true}>
<BlurSavingTextField
options={{ label: "Process Organization" }}
value={processMetadata.processOrganization ?? ""}
setValue={setProcessOrganization}
/>
</FormControl>
</Grid>
</Grid>
</Grid>
{tasks.length > 0 ? tasks : <button onClick={() => addTask(0)}>Add Task</button>}
{tasks.length > 0 ? tasks : <button onClick={() => addTask(0)}>Add Task</button>}
</div>
<div style={{ display: tab == CLONE_TAB ? "block" : "none" }}>
<div>
<PidPicker selected={clonePid} setSelected={loadClonePid} />
</div>
{clonePidLoaded ? <button onClick={doClone}>Clone</button> : null}
</div>
</DialogContent>

<DialogActions>
Expand Down
Loading

0 comments on commit c49113f

Please sign in to comment.