diff --git a/src/App.tsx b/src/App.tsx index 375a0a39..94c9107e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -57,10 +57,12 @@ const createRouter = (basename: string) => createBrowserRouter(routes, { basenam const Container: FC = ({ routePrefix = '', config }) => { const setCustomEvents = useSetRecoilState(state.config.customEvents); + const setHasNavigationOrigin = useSetRecoilState(state.config.hasNavigationOrigin); const cachedMessages = useRef({}); useEffect(() => { setCustomEvents(config?.customEvents as Record); + config?.navigationOrigin && setHasNavigationOrigin(true); }, [config]); return ( diff --git a/src/common/api/base.api.ts b/src/common/api/base.api.ts index c41b29a5..26896f5e 100644 --- a/src/common/api/base.api.ts +++ b/src/common/api/base.api.ts @@ -33,13 +33,15 @@ async function doRequest({ url, requestParams, headers }: DoRequest) { }); if (!response.ok) { - const errorBody = await response.text(); + const errorBody = await response.json(); throw errorBody; } return response; - } catch (err) { - console.error(err); + } catch (err: any) { + const selectedError = err?.errors?.[0]; + + selectedError && console.error(`${selectedError?.type}: ${selectedError?.message}`); throw err; } diff --git a/src/common/api/records.api.ts b/src/common/api/records.api.ts index 7ff63769..ddc26d26 100644 --- a/src/common/api/records.api.ts +++ b/src/common/api/records.api.ts @@ -32,6 +32,21 @@ export const getRecord = async ({ recordId, idType }: IGetRecord) => { }); }; +const graphIdByInventoryIdUrl = '/resource/import/:recordId'; + +export const getGraphIdByExternalId = async ({ recordId }: IGetRecord) => { + const url = baseApi.generateUrl(graphIdByInventoryIdUrl, { name: ':recordId', value: recordId }); + + const response = await baseApi.request({ + url, + requestParams: { + method: 'POST', + }, + }); + + return await response?.json(); +}; + const singleRecordMarcUrl = `${BIBFRAME_API_ENDPOINT}/:recordId/marc`; export const getMarcRecord = async ({ recordId, endpointUrl }: SingleRecord & { endpointUrl?: string }) => { diff --git a/src/common/constants/api.constants.ts b/src/common/constants/api.constants.ts index 3183c596..ae9a0b88 100644 --- a/src/common/constants/api.constants.ts +++ b/src/common/constants/api.constants.ts @@ -14,6 +14,10 @@ export const DEFAULT_PAGES_METADATA = { totalPages: 0, }; +export enum ApiErrorCodes { + AlreadyExists = 'already_exists_error', +} + export enum ExternalResourceIdType { Inventory = 'inventory', } diff --git a/src/common/helpers/api.helper.ts b/src/common/helpers/api.helper.ts index 3813fa84..14cefdc4 100644 --- a/src/common/helpers/api.helper.ts +++ b/src/common/helpers/api.helper.ts @@ -1,4 +1,5 @@ import { getLookupDict } from '@common/api/lookup.api'; +import { ApiErrorCodes } from '@common/constants/api.constants'; export const loadSimpleLookup = async ( uris: string | string[], @@ -30,3 +31,6 @@ const fetchSimpleLookup = async (url: string): Promise => { return response; }; + +export const checkHasErrorOfCodeType = (err: ApiError, codeType: ApiErrorCodes) => + err?.errors.find(e => e.code === codeType); diff --git a/src/common/hooks/useContainerEvents.ts b/src/common/hooks/useContainerEvents.ts index d607f340..e056310c 100644 --- a/src/common/hooks/useContainerEvents.ts +++ b/src/common/hooks/useContainerEvents.ts @@ -3,6 +3,8 @@ import { useRecoilValue } from 'recoil'; import { IS_EMBEDDED_MODE } from '@common/constants/build.constants'; import { dispatchEventWrapper, getWrapperAsWebComponent } from '@common/helpers/dom.helper'; import { useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { ROUTES } from '@common/constants/routes.constants'; type IUseContainerEvents = | { @@ -12,9 +14,17 @@ type IUseContainerEvents = | undefined; export const useContainerEvents = ({ onTriggerModal, watchEditedState = false }: IUseContainerEvents = {}) => { + const hasNavigationOrigin = useRecoilValue(state.config.hasNavigationOrigin); const isEdited = useRecoilValue(state.status.recordIsEdited); - const { BLOCK_NAVIGATION, UNBLOCK_NAVIGATION, TRIGGER_MODAL, PROCEED_NAVIGATION } = - useRecoilValue(state.config.customEvents) ?? {}; + const { + BLOCK_NAVIGATION, + UNBLOCK_NAVIGATION, + TRIGGER_MODAL, + PROCEED_NAVIGATION, + NAVIGATE_TO_ORIGIN, + DROP_NAVIGATE_TO_ORIGIN, + } = useRecoilValue(state.config.customEvents) ?? {}; + const navigate = useNavigate(); useEffect(() => { if (IS_EMBEDDED_MODE && TRIGGER_MODAL && onTriggerModal) { @@ -36,9 +46,16 @@ export const useContainerEvents = ({ onTriggerModal, watchEditedState = false }: const dispatchProceedNavigationEvent = () => dispatchEventWrapper(PROCEED_NAVIGATION); + const dispatchNavigateToOriginEventWithFallback = (fallbackUri?: string) => + hasNavigationOrigin ? dispatchEventWrapper(NAVIGATE_TO_ORIGIN) : navigate(fallbackUri ?? ROUTES.SEARCH.uri); + + const dispatchDropNavigateToOriginEvent = () => dispatchEventWrapper(DROP_NAVIGATE_TO_ORIGIN); + return { dispatchUnblockEvent, dispatchProceedNavigationEvent, dispatchBlockEvent, + dispatchNavigateToOriginEventWithFallback, + dispatchDropNavigateToOriginEvent, }; }; diff --git a/src/common/hooks/useRecordControls.ts b/src/common/hooks/useRecordControls.ts index 9235531f..a75d16c9 100644 --- a/src/common/hooks/useRecordControls.ts +++ b/src/common/hooks/useRecordControls.ts @@ -2,7 +2,13 @@ import { flushSync } from 'react-dom'; import { useLocation, useNavigate, useSearchParams } from 'react-router-dom'; import { useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil'; import { applyUserValues } from '@common/helpers/profile.helper'; -import { postRecord, putRecord, deleteRecord as deleteRecordRequest } from '@common/api/records.api'; +import { + postRecord, + putRecord, + deleteRecord as deleteRecordRequest, + getGraphIdByExternalId, + getRecord, +} from '@common/api/records.api'; import { BibframeEntities, PROFILE_BFIDS } from '@common/constants/bibframe.constants'; import { StatusType } from '@common/constants/status.constants'; import { DEFAULT_RECORD_ID } from '@common/constants/storage.constants'; @@ -17,7 +23,6 @@ import { UserNotificationFactory } from '@common/services/userNotification'; import { PreviewParams, useConfig } from '@common/hooks/useConfig.hook'; import { getSavedRecord } from '@common/helpers/record.helper'; import { formatRecord } from '@common/helpers/recordFormatting.helper'; -import { getRecord } from '@common/api/records.api'; import { QueryParams, ROUTES } from '@common/constants/routes.constants'; import { BLOCKS_BFLITE } from '@common/constants/bibframeMapping.constants'; import { RecordStatus, ResourceType } from '@common/constants/record.constants'; @@ -25,7 +30,8 @@ import { generateEditResourceUrl } from '@common/helpers/navigation.helper'; import { useBackToSearchUri } from './useBackToSearchUri'; import state from '@state'; import { useContainerEvents } from './useContainerEvents'; -import { ExternalResourceIdType } from '@common/constants/api.constants'; +import { ApiErrorCodes, ExternalResourceIdType } from '@common/constants/api.constants'; +import { checkHasErrorOfCodeType } from '@common/helpers/api.helper'; type SaveRecordProps = { asRefToNewRecord?: boolean; @@ -56,13 +62,14 @@ export const useRecordControls = () => { const setCurrentlyEditedEntityBfid = useSetRecoilState(state.ui.currentlyEditedEntityBfid); const setCurrentlyPreviewedEntityBfid = useSetRecoilState(state.ui.currentlyPreviewedEntityBfid); const [selectedRecordBlocks, setSelectedRecordBlocks] = useRecoilState(state.inputs.selectedRecordBlocks); + const setIsDuplicateImportedResourceModalOpen = useSetRecoilState(state.ui.isDuplicateImportedResourceModalOpen); const profile = PROFILE_BFIDS.MONOGRAPH; const currentRecordId = getRecordId(record); const { getProfiles } = useConfig(); const navigate = useNavigate(); const location = useLocation(); const searchResultsUri = useBackToSearchUri(); - const { dispatchUnblockEvent } = useContainerEvents(); + const { dispatchUnblockEvent, dispatchNavigateToOriginEventWithFallback } = useContainerEvents(); const [queryParams] = useSearchParams(); const isClone = queryParams.get(QueryParams.CloneOf); @@ -203,7 +210,7 @@ export const useRecordControls = () => { const discardRecord = (clearState = true) => { if (clearState) clearRecordState(); - navigate(searchResultsUri); + dispatchNavigateToOriginEventWithFallback(searchResultsUri); }; const deleteRecord = async () => { @@ -296,6 +303,29 @@ export const useRecordControls = () => { }); }; + const tryFetchExternalRecordForEdit = async (recordId?: string) => { + try { + if (!recordId) return; + + setIsLoading(true); + + const { id } = await getGraphIdByExternalId({ recordId }); + + id && navigate(generateEditResourceUrl(id), { replace: true }); + } catch (err: unknown) { + if (checkHasErrorOfCodeType(err as ApiError, ApiErrorCodes.AlreadyExists)) { + setIsDuplicateImportedResourceModalOpen(true); + } else { + setStatusMessages(currentStatus => [ + ...currentStatus, + UserNotificationFactory.createMessage(StatusType.error, 'ld.errorFetchingExternalResourceForEditing'), + ]); + } + } finally { + setIsLoading(false); + } + }; + return { fetchRecord, saveRecord, @@ -305,5 +335,6 @@ export const useRecordControls = () => { clearRecordState, fetchRecordAndSelectEntityValues, fetchExternalRecordForPreview, + tryFetchExternalRecordForEdit, }; }; diff --git a/src/components/Modal/Modal.tsx b/src/components/Modal/Modal.tsx index b89a4c6c..f125bf32 100644 --- a/src/components/Modal/Modal.tsx +++ b/src/components/Modal/Modal.tsx @@ -25,6 +25,7 @@ interface Props { showCloseIconButton?: boolean; showModalControls?: boolean; titleClassName?: string; + alignTitleCenter?: boolean; } const Modal: FC = ({ @@ -44,6 +45,7 @@ const Modal: FC = ({ showCloseIconButton = true, showModalControls = true, titleClassName, + alignTitleCenter = false, }) => { const portalElement = document.getElementById(MODAL_CONTAINER_ID) as Element; // TODO: uncomment for using with Shadow DOM @@ -73,6 +75,7 @@ const Modal: FC = ({ )}

{title}

+ {alignTitleCenter && } {!!children && children} {showModalControls && ( diff --git a/src/components/ModalDuplicateImportedResource/ModalDuplicateImportedResource.scss b/src/components/ModalDuplicateImportedResource/ModalDuplicateImportedResource.scss new file mode 100644 index 00000000..c536cb01 --- /dev/null +++ b/src/components/ModalDuplicateImportedResource/ModalDuplicateImportedResource.scss @@ -0,0 +1,32 @@ +.duplicate-imported-resource { + padding-left: 0; + padding-right: 0; + + > * { + padding: 0 1.5rem 0 1.5rem; + } + + &-contents { + padding: 0.75rem 1.5rem; + margin: 0.75rem 0; + + border-top: 1px solid rgba(0, 0, 0, 0.1); + border-bottom: 1px solid rgba(0, 0, 0, 0.1); + } + + .modal-controls { + justify-content: space-between; + } + + .title { + font-size: 1rem; + } + + .modal-header { + justify-content: space-between; + + .empty-block { + min-width: 1.5rem; + } + } +} diff --git a/src/components/ModalDuplicateImportedResource/ModalDuplicateImportedResource.tsx b/src/components/ModalDuplicateImportedResource/ModalDuplicateImportedResource.tsx new file mode 100644 index 00000000..593a40d3 --- /dev/null +++ b/src/components/ModalDuplicateImportedResource/ModalDuplicateImportedResource.tsx @@ -0,0 +1,33 @@ +import { memo } from 'react'; +import { FormattedMessage, useIntl } from 'react-intl'; +import { Modal } from '@components/Modal'; +import { useRecoilState } from 'recoil'; +import state from '@state'; +import { useContainerEvents } from '@common/hooks/useContainerEvents'; +import './ModalDuplicateImportedResource.scss'; + +export const ModalDuplicateImportedResource = memo(() => { + const [isDuplicateImportedResourceModalOpen, setIsDuplicateImportedResourceModalOpen] = useRecoilState( + state.ui.isDuplicateImportedResourceModalOpen, + ); + const { formatMessage } = useIntl(); + const { dispatchNavigateToOriginEventWithFallback } = useContainerEvents(); + + return ( + setIsDuplicateImportedResourceModalOpen(false)} + onCancel={dispatchNavigateToOriginEventWithFallback} + > +
+ +
+
+ ); +}); diff --git a/src/components/ModalDuplicateImportedResource/index.ts b/src/components/ModalDuplicateImportedResource/index.ts new file mode 100644 index 00000000..ae54a522 --- /dev/null +++ b/src/components/ModalDuplicateImportedResource/index.ts @@ -0,0 +1 @@ +export { ModalDuplicateImportedResource } from './ModalDuplicateImportedResource'; diff --git a/src/components/PreviewExternalResourceControls/PreviewExternalResourceControls.tsx b/src/components/PreviewExternalResourceControls/PreviewExternalResourceControls.tsx index 94aad0b9..d2c62b84 100644 --- a/src/components/PreviewExternalResourceControls/PreviewExternalResourceControls.tsx +++ b/src/components/PreviewExternalResourceControls/PreviewExternalResourceControls.tsx @@ -1,17 +1,29 @@ import { Button, ButtonType } from '@components/Button'; import { FormattedMessage } from 'react-intl'; -import { useNavigate } from 'react-router-dom'; +import { useParams } from 'react-router-dom'; +import { useRecordControls } from '@common/hooks/useRecordControls'; +import { useContainerEvents } from '@common/hooks/useContainerEvents'; import './PreviewExternalResourceControls.scss'; export const PreviewExternalResourceControls = () => { - const navigate = useNavigate(); + const { tryFetchExternalRecordForEdit } = useRecordControls(); + const { dispatchNavigateToOriginEventWithFallback } = useContainerEvents(); + const { externalId } = useParams(); return (
- -
diff --git a/src/components/PreviewExternalResourcePane/PreviewExternalResourcePane.tsx b/src/components/PreviewExternalResourcePane/PreviewExternalResourcePane.tsx index c48ab3fd..3ac81960 100644 --- a/src/components/PreviewExternalResourcePane/PreviewExternalResourcePane.tsx +++ b/src/components/PreviewExternalResourcePane/PreviewExternalResourcePane.tsx @@ -1,13 +1,13 @@ -import { useNavigate } from 'react-router-dom'; import { Button, ButtonType } from '@components/Button'; import Times16 from '@src/assets/times-16.svg?react'; import { useRecoilValue } from 'recoil'; import state from '@state'; import { getRecordTitle } from '@common/helpers/record.helper'; +import { useContainerEvents } from '@common/hooks/useContainerEvents'; export const PreviewExternalResourcePane = () => { - const navigate = useNavigate(); const record = useRecoilValue(state.inputs.record); + const { dispatchNavigateToOriginEventWithFallback } = useContainerEvents(); return (
@@ -15,7 +15,7 @@ export const PreviewExternalResourcePane = () => {
); }, + useIntl: () => ({ + formatMessage: ({ id }: { id: string }) => id, + }), })); describe('ExternalResourcePreview', () => { const renderComponent = (withRecord = false) => render( snapshot.set(state.inputs.record, withRecord ? {} : null)}> - + + + , ); diff --git a/src/types/api.d.ts b/src/types/api.d.ts index 1e8f4596..0882c629 100644 --- a/src/types/api.d.ts +++ b/src/types/api.d.ts @@ -16,6 +16,12 @@ type ResourceListItem = { type?: string; }; +type ApiError = { + errors: { + code?: string; + }[]; +}; + type GenericStructDTO = { value?: string; type?: T; diff --git a/src/views/ExternalResource/ExternalResourcePreview.tsx b/src/views/ExternalResource/ExternalResourcePreview.tsx index 7c4e6a24..f93e3241 100644 --- a/src/views/ExternalResource/ExternalResourcePreview.tsx +++ b/src/views/ExternalResource/ExternalResourcePreview.tsx @@ -7,6 +7,7 @@ import { useRecordControls } from '@common/hooks/useRecordControls'; import { ExternalResourceIdType } from '@common/constants/api.constants'; import { Preview } from '@components/Preview'; import { EDIT_ALT_DISPLAY_LABELS } from '@common/constants/uiElements.constants'; +import { ModalDuplicateImportedResource } from '@components/ModalDuplicateImportedResource'; import './ExternalResourcePreview.scss'; export const ExternalResourcePreview = () => { @@ -32,6 +33,7 @@ export const ExternalResourcePreview = () => { ) : ( )} + ); }; diff --git a/src/views/Search/Search.tsx b/src/views/Search/Search.tsx index e4fa2791..7ae92f6e 100644 --- a/src/views/Search/Search.tsx +++ b/src/views/Search/Search.tsx @@ -15,9 +15,13 @@ import Plus16 from '@src/assets/plus-16.svg?react'; import Compare from '@src/assets/compare.svg?react'; import { filters } from './data/filters'; import './Search.scss'; +import { useContainerEvents } from '@common/hooks/useContainerEvents'; export const SearchView = () => { const { navigateToEditPage } = useNavigateToEditPage(); + const { dispatchDropNavigateToOriginEvent } = useContainerEvents(); + + dispatchDropNavigateToOriginEvent(); const items = useMemo( () => [ diff --git a/translations/ui-linked-data/en.json b/translations/ui-linked-data/en.json index 7c8ee91b..fe55a80a 100644 --- a/translations/ui-linked-data/en.json +++ b/translations/ui-linked-data/en.json @@ -44,6 +44,7 @@ "ld.startFromScratch": "Start from scratch", "ld.selectOrStartFromScratch": "{select} or {startFromScratch}", "ld.backToTop": "Back to top", + "ld.backToInventory": "Back to inventory", "ld.properties": "Properties", "ld.preview": "Preview", "ld.confirmCloseRd": "Do you really want to close the resource description? All unsaved changes will be lost.", @@ -178,6 +179,7 @@ "ld.notSpecified": "Not specified", "ld.externalResourcePreview": "External resource preview", "ld.errorFetchingExternalResourceForPreview": "Error fetching external resource for preview", + "ld.errorFetchingExternalResourceForEditing": "Error fetching external resource for editing", "ld.fetchingExternalResourceById": "Fetching external resource id {resourceId}...", "ld.lastUpdated": "Last updated", "ld.marcAuthorityRecord": "MARC authority record", @@ -187,5 +189,7 @@ "ld.aria.filters.select": "Select search identifiers", "ld.aria.filters.textbox": "Search query textbox", "ld.aria.filters.reset": "Reset filters button", - "ld.aria.filters.reset.announce": "Search field and filters are reset" + "ld.aria.filters.reset.announce": "Search field and filters are reset", + "ld.duplicateImportWarn": "Duplicate import warning", + "ld.rdPropertiesMatchContinue": "Properties of this resource match an existing resource. Do you want to continue?" }