diff --git a/__tests__/actionCreators/transfer.test.js b/__tests__/actionCreators/transfer.test.js index 63a026df8..b5f0448d4 100644 --- a/__tests__/actionCreators/transfer.test.js +++ b/__tests__/actionCreators/transfer.test.js @@ -16,14 +16,15 @@ describe("transfer", () => { sinopiaApi.postTransfer = jest.fn().mockResolvedValue() const store = mockStore(createState()) await store.dispatch( - transfer(resourceUri, "stanford", "ils", "testerrorkey") + transfer(resourceUri, "stanford", "ils", "abc123", "testerrorkey") ) expect(store.getActions()).toHaveLength(0) expect(sinopiaApi.postTransfer).toHaveBeenCalledWith( resourceUri, "stanford", - "ils" + "ils", + "abc123" ) }) }) @@ -32,7 +33,7 @@ describe("transfer", () => { sinopiaApi.postTransfer = jest.fn().mockRejectedValue("Ooops!") const store = mockStore(createState()) await store.dispatch( - transfer(resourceUri, "stanford", "ils", "testerrorkey") + transfer(resourceUri, "stanford", "ils", "abc123", "testerrorkey") ) expect(store.getActions()).toHaveAction("ADD_ERROR", { diff --git a/__tests__/feature/editing/transfer.test.js b/__tests__/feature/editing/transfer.test.js index fb5dee059..bd4b009ea 100644 --- a/__tests__/feature/editing/transfer.test.js +++ b/__tests__/feature/editing/transfer.test.js @@ -20,7 +20,7 @@ jest.spyOn(Config, "transferConfig", "get").mockReturnValue({ }, }) -describe("transfer saved bf:Instance when user belongs to a transfer group", () => { +describe("transfer saved bf:Instance when user belongs to a transfer group and no local ID", () => { it("allows transfer", async () => { const state = createState() const store = createStore(state) @@ -42,6 +42,37 @@ describe("transfer saved bf:Instance when user belongs to a transfer group", () const transferBtn = screen.getByText("Export to Catalog") fireEvent.click(transferBtn) + fireEvent.click(await screen.findByText("Create a new record in catalog.")) + await screen.findByText("Requesting") + }, 15000) +}) + +describe("transfer saved bf:Instance when user belongs to a transfer group and provided local ID", () => { + it("allows transfer", async () => { + const state = createState() + const store = createStore(state) + renderApp(store) + + fireEvent.click(screen.getByText("Linked Data Editor", { selector: "a" })) + + fireEvent.change(screen.getByLabelText("Search"), { + target: { value: bfUri }, + }) + fireEvent.click(screen.getByTestId("Submit search")) + + await screen.findByText(bfUri) + fireEvent.click(screen.getByRole("button", { name: `Edit ${bfUri}` })) + + await screen.findByText("The Practitioner's Guide to Graph Data", { + selector: resourceHeaderSelector, + }) + + const transferBtn = screen.getByText("Export to Catalog") + fireEvent.click(transferBtn) + fireEvent.change(await screen.findByLabelText("Enter local system id"), { + target: { value: "abc123" }, + }) + fireEvent.click(await screen.findByText("Go")) await screen.findByText("Requesting") }, 15000) }) diff --git a/__tests__/sinopiaApi.test.js b/__tests__/sinopiaApi.test.js index 548f19d81..eb3ba9171 100644 --- a/__tests__/sinopiaApi.test.js +++ b/__tests__/sinopiaApi.test.js @@ -431,13 +431,13 @@ describe("putUserHistory", () => { }) describe("postTransfer", () => { - describe("success", () => { + describe("success without localId", () => { it("returns", async () => { global.fetch = jest.fn().mockResolvedValue({ ok: true, }) - await postTransfer(resourceUri, "stanford", "ils") + await postTransfer(resourceUri, "stanford", "ils", null) expect(global.fetch).toHaveBeenCalledWith( "https://api.development.sinopia.io/transfer/7b4c275d-b0c7-40a4-80b3-e95a0d9d987c/stanford/ils", @@ -450,6 +450,25 @@ describe("postTransfer", () => { ) }) }) + describe("success with localId", () => { + it("returns", async () => { + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + }) + + await postTransfer(resourceUri, "stanford", "ils", "abc123") + + expect(global.fetch).toHaveBeenCalledWith( + "https://api.development.sinopia.io/transfer/7b4c275d-b0c7-40a4-80b3-e95a0d9d987c/stanford/ils/abc123", + { + method: "POST", + headers: { + Authorization: "Bearer Secret-Token", + }, + } + ) + }) + }) }) describe("fetchResourceRelationships", () => { diff --git a/__tests__/testUtilities/fixtureLoaderHelper.js b/__tests__/testUtilities/fixtureLoaderHelper.js index 0aa9da865..f26f56ed5 100644 --- a/__tests__/testUtilities/fixtureLoaderHelper.js +++ b/__tests__/testUtilities/fixtureLoaderHelper.js @@ -15,6 +15,7 @@ const resourceFilenames = { "a5c5f4c0-e7cd-4ca5-a20f-2a37fe1080d5": "instance_with_refs.json", "b6c5f4c0-e7cd-4ca5-a20f-2a37fe1080d6": "test-inputs.json", "c7c5f4c0-e7cd-4ca5-a20f-2a37fe1080d7": "test-multiple_property_uris.json", + "ae93cff4-d272-43b2-a4ee-fb8651907e51": "local_admin_metadata.json", } const templateFilenames = { @@ -201,5 +202,8 @@ export const getFixtureResourceRelationships = () => { bfItemInferredRefs: [], bfInstanceInferredRefs: [], bfWorkInferredRefs: [], + sinopiaHasLocalAdminMetadataInferredRefs: [ + "http://localhost:3000/resource/ae93cff4-d272-43b2-a4ee-fb8651907e51", + ], } } diff --git a/src/Config.js b/src/Config.js index 510c080aa..5b9a298c3 100644 --- a/src/Config.js +++ b/src/Config.js @@ -140,13 +140,19 @@ class Config { static get transferConfig() { return { - ils: { + SIRSI: { // group: label - stanford: "Catalog", + stanford: "Symphony", cornell: "Catalog", + }, + FOLIO: { + stanford: "Folio", + cornell: "Catalog", + }, + ALMA: { penn: "Catalog", }, - // Can add additional transfer targets, e.g., discovery + // Can add additional transfer targets. } } diff --git a/src/actionCreators/relationships.js b/src/actionCreators/relationships.js index 6b652aa98..227c344bd 100644 --- a/src/actionCreators/relationships.js +++ b/src/actionCreators/relationships.js @@ -6,7 +6,7 @@ import { fetchResourceRelationships } from "sinopiaApi" /** * A thunk that loads inferred relationships from the Sinopia API and adds to state. - * @return true if successful + * @return relationships if successful or false if not */ export const loadRelationships = (resourceKey, uri, errorKey) => (dispatch) => { dispatch(clearErrors(errorKey)) @@ -15,12 +15,14 @@ export const loadRelationships = (resourceKey, uri, errorKey) => (dispatch) => { dispatch( setRelationships(resourceKey, { bfAdminMetadataRefs: relationships.bfAdminMetadataInferredRefs, + sinopiaLocalAdminMetadataRefs: + relationships.sinopiaHasLocalAdminMetadataInferredRefs, bfItemRefs: relationships.bfItemInferredRefs, bfInstanceRefs: relationships.bfInstanceInferredRefs, bfWorkRefs: relationships.bfWorkInferredRefs, }) ) - return true + return relationships }) .catch((err) => { console.error(err) diff --git a/src/actionCreators/resources.js b/src/actionCreators/resources.js index c7b1a4f62..052b7ffc5 100644 --- a/src/actionCreators/resources.js +++ b/src/actionCreators/resources.js @@ -41,6 +41,7 @@ import { addResourceHistory } from "actionCreators/history" import _ from "lodash" import { setCurrentComponent } from "actions/index" import { loadRelationships } from "./relationships" +import { loadLocalIds } from "./transfer" /** * A thunk that loads an existing resource from Sinopia API and adds to state. @@ -72,7 +73,19 @@ export const loadResource = unusedDataset.size > 0 ? unusedDataset.toCanonical() : null ) ) - dispatch(loadRelationships(resource.key, uri, errorKey)) + dispatch(loadRelationships(resource.key, uri, errorKey)).then( + (relationships) => { + if (relationships) { + dispatch( + loadLocalIds( + resource.key, + relationships.sinopiaHasLocalAdminMetadataInferredRefs, + errorKey + ) + ) + } + } + ) return [response, resource, unusedDataset] }) .catch((err) => { diff --git a/src/actionCreators/transfer.js b/src/actionCreators/transfer.js index 333e683f8..181112a49 100644 --- a/src/actionCreators/transfer.js +++ b/src/actionCreators/transfer.js @@ -1,13 +1,75 @@ -import { postTransfer } from "../sinopiaApi" +import { postTransfer, fetchResource } from "../sinopiaApi" import { addError } from "actions/errors" +import { clearLocalIds, setLocalId } from "actions/transfer" +import rdf from "rdf-ext" export const transfer = - (resourceUri, group, target, errorKey) => (dispatch) => { - postTransfer(resourceUri, group, target).catch((err) => { + (resourceUri, group, target, localId, errorKey) => (dispatch) => + postTransfer(resourceUri, group, target, localId).catch((err) => { dispatch( addError(errorKey, `Error requesting transfer: ${err.message || err}`) ) }) + +export const loadLocalIds = + (resourceKey, sinopiaLocalAdminMetadataRefs, errorKey) => (dispatch) => { + dispatch(clearLocalIds(resourceKey)) + sinopiaLocalAdminMetadataRefs.forEach((resourceUri) => { + dispatch(fetchLocalId(resourceUri, errorKey)).then( + ([target, group, localId]) => { + if (!target) { + return + } + dispatch(setLocalId(resourceKey, target, group, localId)) + } + ) + }) } -export const noop = () => {} +const fetchLocalId = (uri, errorKey) => (dispatch) => + fetchResource(uri) + .then(([dataset, response]) => { + if (!dataset) return [false, false, false] + const identifierNode = identifierNodeFromDataset(uri, dataset) + if (!identifierNode) return [false, false, false] + const localId = localIdFromIdentifierNode(identifierNode, dataset) + const target = targetFromIdentifierNode(identifierNode, dataset) + return [target, response.group, localId] + }) + .catch((err) => { + dispatch( + addError(errorKey, `Error retrieving ${uri}: ${err.message || err}`) + ) + return [false, false, false] + }) + +const localIdFromIdentifierNode = (identifierNode, dataset) => + dataset + .match( + identifierNode, + rdf.namedNode("http://www.w3.org/1999/02/22-rdf-syntax-ns#value") + ) + .toArray()[0].object.value + +const targetFromIdentifierNode = (identifierNode, dataset) => { + const sourceNode = dataset + .match( + identifierNode, + rdf.namedNode("http://id.loc.gov/ontologies/bibframe/source") + ) + .toArray()[0].object + return dataset + .match( + sourceNode, + rdf.namedNode("http://www.w3.org/2000/01/rdf-schema#label") + ) + .toArray()[0].object.value +} + +const identifierNodeFromDataset = (uri, dataset) => + dataset + .match( + rdf.namedNode(uri), + rdf.namedNode("http://id.loc.gov/ontologies/bibframe/identifier") + ) + .toArray()[0].object diff --git a/src/components/editor/actions/TransferButton.jsx b/src/components/editor/actions/TransferButton.jsx index 1d5a8936f..0e5cd1bc2 100644 --- a/src/components/editor/actions/TransferButton.jsx +++ b/src/components/editor/actions/TransferButton.jsx @@ -1,8 +1,21 @@ import React, { useState, useEffect, useRef } from "react" +import { useSelector } from "react-redux" +import { selectLocalId } from "selectors/transfer" import PropTypes from "prop-types" +import _ from "lodash" -const TransferButton = ({ label, handleClick }) => { - const [btnText, setBtnText] = useState(label) +const TransferButton = ({ + label, + group, + target, + resourceKey, + handleTransfer, +}) => { + const [requesting, setRequesting] = useState(false) + const localId = useSelector((state) => + selectLocalId(state, resourceKey, target, group) + ) + const [providedLocalId, setProvidedLocalId] = useState(localId) const timerRef = useRef(null) useEffect( @@ -12,27 +25,110 @@ const TransferButton = ({ label, handleClick }) => { [] ) - const handleBtnClick = (event) => { - setBtnText(Requesting) - timerRef.current = setTimeout(() => setBtnText(label), 3000) - handleClick(event) + const handleExistingLocalIdClick = (event) => { + handleTransfer(localId) + notify() + event.preventDefault() + } + + const handleProvidedLocalIdClick = (event) => { + handleTransfer(providedLocalId) + notify() event.preventDefault() } + const handleNoLocalIdClick = (event) => { + handleTransfer(null) + notify() + event.preventDefault() + } + + const notify = () => { + setRequesting(true) + timerRef.current = setTimeout(() => setRequesting(false), 3000) + } + + const handleChangeProvidedLocalId = (event) => { + setProvidedLocalId(event.target.value) + event.preventDefault() + } + + if (requesting) { + return ( + + ) + } + + const btnId = `transferBtn-${group}-${target}` + const btnClasses = ["btn", "dropdown-toggle", "btn-no-outline"] + const dropDownItemBtnClasses = ["btn", "btn-secondary", "dropdown-item"] + return ( - +
+ +
+ {localId ? ( + + + + ) : ( + +
+ Overlay record with local system ID: +
+ + +
+
+ +
+ )} +
+
) } TransferButton.propTypes = { label: PropTypes.string.isRequired, - handleClick: PropTypes.func.isRequired, + group: PropTypes.string.isRequired, + target: PropTypes.string.isRequired, + resourceKey: PropTypes.string.isRequired, + handleTransfer: PropTypes.func.isRequired, } export default TransferButton diff --git a/src/components/editor/actions/TransferButtons.jsx b/src/components/editor/actions/TransferButtons.jsx index 9d74f8144..c51badb4f 100644 --- a/src/components/editor/actions/TransferButtons.jsx +++ b/src/components/editor/actions/TransferButtons.jsx @@ -35,16 +35,18 @@ const TransferButtons = ({ resourceKey }) => { // Must be targets if (_.isEmpty(transferTargets)) return null - const handleClick = (event, group, target) => { - dispatch(transfer(resource.uri, group, target, errorKey)) - event.preventDefault() + const handleTransfer = (group, target, localId) => { + dispatch(transfer(resource.uri, group, target, localId, errorKey)) } const buttons = transferTargets.map(([target, group, label]) => ( handleClick(event, group, target)} + handleTransfer={(localId) => handleTransfer(group, target, localId)} /> )) diff --git a/src/reducers/index.js b/src/reducers/index.js index 7c417746d..3ad5eacd9 100644 --- a/src/reducers/index.js +++ b/src/reducers/index.js @@ -60,6 +60,7 @@ import { } from "./history" import { clearSearchResults, setSearchResults, setHeaderSearch } from "./search" import { lookupOptionsRetrieved } from "./lookups" +import { clearLocalIds, setLocalId } from "./transfer" import _ from "lodash" export const setCurrentComponent = (state, action) => { @@ -119,6 +120,7 @@ const entityHandlers = { ADD_SUBJECT: addSubject, ADD_TEMPLATES: addTemplates, ADD_VALUE: addValue, + CLEAR_LOCAL_IDS: clearLocalIds, CLEAR_RELATIONSHIPS: clearRelationships, CLEAR_RESOURCE: clearResource, CLEAR_VERSIONS: clearVersions, @@ -137,6 +139,7 @@ const entityHandlers = { SET_BASE_URL: setBaseURL, SET_CLASSES: setClasses, SET_DEFAULT_LANG: setDefaultLang, + SET_LOCAL_ID: setLocalId, SET_VALUE_PROPERTY_URI: setValuePropertyURI, SET_PROPERTY_PROPERTY_URI: setPropertyPropertyURI, SET_RELATIONSHIPS: setRelationships, diff --git a/src/selectors/relationships.js b/src/selectors/relationships.js index 4532c4b2c..6200ab323 100644 --- a/src/selectors/relationships.js +++ b/src/selectors/relationships.js @@ -18,6 +18,9 @@ export const selectRelationships = (state, resourceKey) => { bfItemRefs: mergeRelationship("bfItemRefs"), bfInstanceRefs: mergeRelationship("bfInstanceRefs"), bfWorkRefs: mergeRelationship("bfWorkRefs"), + sinopiaLocalAdminMetadataRefs: mergeRelationship( + "sinopiaLocalAdminMetadataRefs" + ), } } diff --git a/src/sinopiaApi.js b/src/sinopiaApi.js index 2c03e12b9..adfa0a845 100644 --- a/src/sinopiaApi.js +++ b/src/sinopiaApi.js @@ -212,11 +212,11 @@ export const putUserHistory = ( ) } -export const postTransfer = (resourceUri, group, target) => { - const url = `${resourceUri.replace( - "resource", - "transfer" - )}/${group}/${target}` +export const postTransfer = (resourceUri, group, target, localId) => { + let url = `${resourceUri.replace("resource", "transfer")}/${group}/${target}` + if (localId) { + url += `/${localId}` + } return getJwt() .then((jwt) => fetch(url, { diff --git a/src/store.js b/src/store.js index 24649198e..6475f37ba 100644 --- a/src/store.js +++ b/src/store.js @@ -43,6 +43,7 @@ export const initialState = { transliterations: {}, transliterationLookup: [], groupMap: {}, + localIds: {}, // {: {target: {group: localId}}} lookups: {}, exports: [], properties: {},