diff --git a/src/common/constants/api.constants.ts b/src/common/constants/api.constants.ts index aa6b3921..3ddba46b 100644 --- a/src/common/constants/api.constants.ts +++ b/src/common/constants/api.constants.ts @@ -5,10 +5,11 @@ export const EDITOR_API_BASE_PATH = 'EDITOR_API_BASE_PATH'; // API endpoints export const BIBFRAME_API_ENDPOINT = '/linked-data/resource'; -export const INVENTORY_API_ENDPOINT = '/linked-data/inventory-instance' +export const INVENTORY_API_ENDPOINT = '/linked-data/inventory-instance'; export const PROFILE_API_ENDPOINT = '/linked-data/profile'; export const SEARCH_API_ENDPOINT = '/search/linked-data'; export const SEARCH_RESOURCE_API_ENDPOINT = `${SEARCH_API_ENDPOINT}/works`; +export const AUTHORITY_ASSIGNMENT_CHECK_API_ENDPOINT = '/linked-data/authority-assignment-check'; export const DEFAULT_PAGES_METADATA = { totalElements: 0, diff --git a/src/common/constants/complexLookup.constants.ts b/src/common/constants/complexLookup.constants.ts index e977f220..c02a2f05 100644 --- a/src/common/constants/complexLookup.constants.ts +++ b/src/common/constants/complexLookup.constants.ts @@ -50,3 +50,11 @@ export const COMPLEX_LOOKUPS_LINKED_FIELDS_MAPPING = { export const EMPTY_LINKED_DROPDOWN_OPTION_SUFFIX = 'empty'; export const VALUE_DIVIDER = ' ,'; export const __MOCK_URI_CHANGE_WHEN_IMPLEMENTING = '__MOCK_URI_CHANGE_WHEN_IMPLEMENTING'; + +export enum Authority { + Creator = 'creator', +} + +export enum AuthorityValidationTarget { + CreatorOfWork = 'CREATOR_OF_WORK', +} diff --git a/src/common/helpers/complexLookup.helper.ts b/src/common/helpers/complexLookup.helper.ts index 859cccdc..ad4a90c9 100644 --- a/src/common/helpers/complexLookup.helper.ts +++ b/src/common/helpers/complexLookup.helper.ts @@ -1,4 +1,5 @@ import { + AuthorityValidationTarget, COMPLEX_LOOKUPS_LINKED_FIELDS_MAPPING, EMPTY_LINKED_DROPDOWN_OPTION_SUFFIX, } from '@common/constants/complexLookup.constants'; @@ -66,3 +67,18 @@ export const getUpdatedSelectedEntries = ({ return selectedEntriesService.get(); }; + +export const generateValidationRequestBody = ( + marcData: MarcDTO | null, + target = AuthorityValidationTarget.CreatorOfWork, +) => { + if (!marcData) return {}; + + const rawMarcEncoded = JSON.stringify(marcData?.parsedRecord?.content, null, 2); + const escapedString = rawMarcEncoded.replace(/\r/g, '\r').replace(/\n/g, '\n'); + + return { + rawMarc: escapedString, + target, + }; +}; diff --git a/src/common/hooks/useApi.ts b/src/common/hooks/useApi.ts new file mode 100644 index 00000000..7a5213ca --- /dev/null +++ b/src/common/hooks/useApi.ts @@ -0,0 +1,64 @@ +import { useState, useCallback } from 'react'; +import BaseApi from '@common/api/base.api'; +import { StatusType } from '@common/constants/status.constants'; +import { UserNotificationFactory } from '@common/services/userNotification'; +import { useLoadingState, useStatusState } from '@src/store'; + +interface RequestConfig { + url: string; + method?: APIRequestMethod; + urlParams?: Record; + urlParam?: { name: string; value: string | number }; + requestParams?: RequestInit; + body?: unknown; + errorMessageId?: string; +} + +interface ApiResponse { + data: T | null; +} + +export function useApi() { + const { setIsLoading } = useLoadingState(); + const { addStatusMessagesItem } = useStatusState(); + const [state, setState] = useState>({ + data: null, + }); + + const makeRequest = useCallback( + async ({ url, method = 'GET', urlParams, urlParam, requestParams, body, errorMessageId }: RequestConfig) => { + setIsLoading(true); + + try { + const finalUrl = BaseApi.generateUrl(url, urlParam); + + const response = await BaseApi.getJson({ + url: finalUrl, + urlParams, + requestParams: { + method, + headers: { 'content-type': 'application/json' }, + ...requestParams, + ...(typeof body === 'object' && body !== null ? { body: JSON.stringify(body, null, 2) } : {}), + }, + }); + + setState({ data: response }); + + return response; + } catch (error) { + addStatusMessagesItem?.( + UserNotificationFactory.createMessage(StatusType.error, errorMessageId ?? 'ld.errorMakingApiRequest'), + ); + } finally { + setIsLoading(false); + } + }, + [], + ); + + return { + ...state, + makeRequest, + }; +} diff --git a/src/common/hooks/useComplexLookup.ts b/src/common/hooks/useComplexLookup.ts index 2def26c4..bb2463fb 100644 --- a/src/common/hooks/useComplexLookup.ts +++ b/src/common/hooks/useComplexLookup.ts @@ -1,26 +1,34 @@ import { ChangeEvent, useCallback, useState } from 'react'; import { generateEmptyValueUuid, + generateValidationRequestBody, getLinkedField, getUpdatedSelectedEntries, updateLinkedFieldValue, } from '@common/helpers/complexLookup.helper'; -import { __MOCK_URI_CHANGE_WHEN_IMPLEMENTING } from '@common/constants/complexLookup.constants'; +import { __MOCK_URI_CHANGE_WHEN_IMPLEMENTING, Authority } from '@common/constants/complexLookup.constants'; import { AdvancedFieldType } from '@common/constants/uiControls.constants'; +import { useInputsState, useMarcPreviewState, useProfileState, useStatusState, useUIState } from '@src/store'; +import { UserNotificationFactory } from '@common/services/userNotification'; +import { StatusType } from '@common/constants/status.constants'; +import { AUTHORITY_ASSIGNMENT_CHECK_API_ENDPOINT } from '@common/constants/api.constants'; import { useModalControls } from './useModalControls'; import { useMarcData } from './useMarcData'; import { useServicesContext } from './useServicesContext'; -import { useInputsState, useMarcPreviewState, useProfileState, useUIState } from '@src/store'; +import { useApi } from './useApi'; +import { useComplexLookupValidation } from './useComplexLookupValidation'; export const useComplexLookup = ({ entry, value, lookupConfig, + authority = Authority.Creator, onChange, }: { entry: SchemaEntry; value?: UserValueContents[]; lookupConfig: ComplexLookupsConfigEntry; + authority?: string; onChange: (uuid: string, contents: Array) => void; }) => { const { selectedEntriesService } = useServicesContext() as Required; @@ -28,6 +36,7 @@ export const useComplexLookup = ({ const { schema } = useProfileState(); const { selectedEntries, setSelectedEntries } = useInputsState(); const { + complexValue, setComplexValue, resetComplexValue: resetMarcPreviewData, metadata: marcPreviewMetadata, @@ -38,6 +47,9 @@ export const useComplexLookup = ({ const { fetchMarcData } = useMarcData(setComplexValue); const { uuid, linkedEntry } = entry; const linkedField = getLinkedField({ schema, linkedEntry }); + const { makeRequest } = useApi(); + const { addStatusMessagesItem } = useStatusState(); + const { addFailedEntryId, clearFailedEntryIds } = useComplexLookupValidation(); const handleDelete = (id?: string) => { onChange(uuid, []); @@ -56,20 +68,37 @@ export const useComplexLookup = ({ }; const closeModal = useCallback(() => { + clearFailedEntryIds(); setIsModalOpen(false); }, []); - const handleAssign = async ({ id, title, linkedFieldValue }: ComplexLookupAssignRecordDTO) => { - let srsId; + const reset = () => { + resetMarcPreviewData(); + resetMarcPreviewMetadata(); + resetIsMarcPreviewOpen(); + }; - if (marcPreviewMetadata?.baseId === id) { - srsId = marcPreviewMetadata.srsId; - } else { - const marcData = await fetchMarcData(id, lookupConfig.api.endpoints.marcPreview); + const validateMarcRecord = (marcData: MarcDTO | null) => { + const { endpoints, validationTarget } = lookupConfig.api; - srsId = marcData?.matchedId; - } + return makeRequest({ + url: endpoints.validation ?? AUTHORITY_ASSIGNMENT_CHECK_API_ENDPOINT, + method: 'POST', + body: generateValidationRequestBody(marcData, validationTarget?.[authority]), + }); + }; + const assignMarcRecord = ({ + id, + title, + srsId, + linkedFieldValue, + }: { + id: string; + title: string; + srsId?: string; + linkedFieldValue?: string; + }) => { const newValue = { id, label: title, @@ -95,11 +124,37 @@ export const useComplexLookup = ({ setSelectedEntries(updatedSelectedEntries); } + }; - resetMarcPreviewData(); - resetMarcPreviewMetadata(); - resetIsMarcPreviewOpen(); - closeModal(); + const handleAssign = async ({ id, title, linkedFieldValue }: ComplexLookupAssignRecordDTO) => { + let srsId; + let marcData = complexValue; + + if (marcPreviewMetadata?.baseId === id) { + srsId = marcPreviewMetadata.srsId; + } else { + const response = await fetchMarcData(id, lookupConfig.api.endpoints.marcPreview); + + if (response) { + marcData = response; + srsId = marcData?.matchedId; + } + } + + const isValid = await validateMarcRecord(marcData); + + if (isValid) { + assignMarcRecord({ id, title, srsId, linkedFieldValue }); + clearFailedEntryIds(); + reset(); + closeModal(); + } else { + addFailedEntryId(id); + + addStatusMessagesItem?.( + UserNotificationFactory.createMessage(StatusType.error, 'ld.errorValidatingAuthorityRecord'), + ); + } }; const handleOnChangeBase = ({ target: { value } }: ChangeEvent) => { diff --git a/src/common/hooks/useComplexLookupSearchResults.ts b/src/common/hooks/useComplexLookupSearchResults.ts index fb068384..f7b13ee1 100644 --- a/src/common/hooks/useComplexLookupSearchResults.ts +++ b/src/common/hooks/useComplexLookupSearchResults.ts @@ -4,6 +4,7 @@ import { type Row } from '@components/Table'; import { useSearchContext } from '@common/hooks/useSearchContext'; import { ComplexLookupSearchResultsProps } from '@components/ComplexLookupField/ComplexLookupSearchResults'; import { useSearchState } from '@src/store'; +import { useComplexLookupValidation } from './useComplexLookupValidation'; export const useComplexLookupSearchResults = ({ onTitleClick, @@ -13,6 +14,7 @@ export const useComplexLookupSearchResults = ({ const { onAssignRecord } = useSearchContext(); const { data, sourceData } = useSearchState(); const { formatMessage } = useIntl(); + const { checkFailedId } = useComplexLookupValidation(); const applyActionItems = useCallback( (rows: Row[]): Row[] => @@ -23,7 +25,13 @@ export const useComplexLookupSearchResults = ({ formattedRow[key] = { ...row[key], children: column.formatter - ? column.formatter({ row, formatMessage, onAssign: onAssignRecord, onTitleClick }) + ? column.formatter({ + row, + formatMessage, + onAssign: onAssignRecord, + onTitleClick, + checkFailedId, + }) : row[key].label, }; }); diff --git a/src/common/hooks/useComplexLookupValidation.ts b/src/common/hooks/useComplexLookupValidation.ts new file mode 100644 index 00000000..4feb99aa --- /dev/null +++ b/src/common/hooks/useComplexLookupValidation.ts @@ -0,0 +1,25 @@ +import { useComplexLookupState } from '@src/store'; + +export const useComplexLookupValidation = () => { + const { + authorityAssignmentCheckFailedIds, + addAuthorityAssignmentCheckFailedIdsItem, + resetAuthorityAssignmentCheckFailedIds, + } = useComplexLookupState(); + + const addFailedEntryId = (id: string) => { + addAuthorityAssignmentCheckFailedIdsItem?.(id); + }; + + const clearFailedEntryIds = () => { + resetAuthorityAssignmentCheckFailedIds(); + }; + + const checkFailedId = (id?: string) => { + if (!id) return false; + + return authorityAssignmentCheckFailedIds.includes(id); + }; + + return { addFailedEntryId, clearFailedEntryIds, checkFailedId }; +}; diff --git a/src/components/ComplexLookupField/ComplexLookupField.tsx b/src/components/ComplexLookupField/ComplexLookupField.tsx index d1897d13..17288843 100644 --- a/src/components/ComplexLookupField/ComplexLookupField.tsx +++ b/src/components/ComplexLookupField/ComplexLookupField.tsx @@ -30,6 +30,7 @@ export const ComplexLookupField: FC = ({ value = undefined, id, entry, on entry, value, lookupConfig, + authority: layout?.baseLabelType, onChange, }); diff --git a/src/components/ComplexLookupField/MarcPreviewComplexLookup.tsx b/src/components/ComplexLookupField/MarcPreviewComplexLookup.tsx index 4ad9f387..d03ad1bb 100644 --- a/src/components/ComplexLookupField/MarcPreviewComplexLookup.tsx +++ b/src/components/ComplexLookupField/MarcPreviewComplexLookup.tsx @@ -1,6 +1,7 @@ import { FC } from 'react'; import { FormattedDate, FormattedMessage, useIntl } from 'react-intl'; import { useSearchContext } from '@common/hooks/useSearchContext'; +import { useComplexLookupValidation } from '@common/hooks/useComplexLookupValidation'; import { useMarcPreviewState, useUIState } from '@src/store'; import { SearchControlPane } from '@components/SearchControlPane'; import { MarcContent } from '@components/MarcContent'; @@ -17,6 +18,7 @@ export const MarcPreviewComplexLookup: FC = ({ on const { formatMessage } = useIntl(); const { isMarcPreviewOpen } = useUIState(); const { complexValue: marcPreviewData, metadata: marcPreviewMetadata } = useMarcPreviewState(); + const { checkFailedId } = useComplexLookupValidation(); const renderCloseButton = () => ( diff --git a/src/components/ComplexLookupField/ModalComplexLookup.tsx b/src/components/ComplexLookupField/ModalComplexLookup.tsx index bacd7f26..56b8991f 100644 --- a/src/components/ComplexLookupField/ModalComplexLookup.tsx +++ b/src/components/ComplexLookupField/ModalComplexLookup.tsx @@ -6,7 +6,7 @@ import { SEARCH_RESULTS_FORMATTER } from '@common/helpers/search/formatters'; import { SEARCH_QUERY_BUILDER } from '@common/helpers/search/queryBuilder'; import { IS_EMBEDDED_MODE } from '@common/constants/build.constants'; import { SearchSegment } from '@common/constants/search.constants'; -import { ComplexLookupType } from '@common/constants/complexLookup.constants'; +import { Authority, ComplexLookupType } from '@common/constants/complexLookup.constants'; import { useComplexLookupApi } from '@common/hooks/useComplexLookupApi'; import { useMarcData } from '@common/hooks/useMarcData'; import { COMPLEX_LOOKUPS_CONFIG } from '@src/configs'; @@ -35,7 +35,7 @@ export const ModalComplexLookup: FC = memo( onClose, value, assignEntityName = ComplexLookupType.Authorities, - baseLabelType = 'creator', + baseLabelType = Authority.Creator, }) => { const { api, diff --git a/src/components/ComplexLookupField/configs/Authorities.tsx b/src/components/ComplexLookupField/configs/Authorities.tsx index 3a2c416d..f4b80271 100644 --- a/src/components/ComplexLookupField/configs/Authorities.tsx +++ b/src/components/ComplexLookupField/configs/Authorities.tsx @@ -16,18 +16,18 @@ export const authoritiesTableConfig: SearchResultsTableConfig = { }, title: { label: 'ld.headingReference', - position: 1, + position: 2, className: 'cell-fixed cell-fixed-370', formatter: TitleFormatter, }, subclass: { label: 'ld.typeOfHeading', - position: 2, + position: 3, className: 'cell-fixed cell-fixed-140', }, authoritySource: { label: 'ld.authoritySource', - position: 3, + position: 4, className: 'cell-fixed cell-fixed-250', }, }, diff --git a/src/components/ComplexLookupField/formatters/Assign.tsx b/src/components/ComplexLookupField/formatters/Assign.tsx index b214c1a0..89ee0e99 100644 --- a/src/components/ComplexLookupField/formatters/Assign.tsx +++ b/src/components/ComplexLookupField/formatters/Assign.tsx @@ -5,11 +5,14 @@ import { Button, ButtonType } from '@components/Button'; export const AssignFormatter = ({ row, onAssign, + checkFailedId, }: { row: SearchResultsTableRow; onAssign: ({ id, title, linkedFieldValue }: ComplexLookupAssignRecordDTO) => void; + checkFailedId: (id: string) => boolean; }) => { const isAuthorized = row.authorized.label === AuthRefType.Authorized; + const isDisabled = checkFailedId(row.__meta.id); return isAuthorized ? ( diff --git a/src/configs/complexLookup/complexLookup.config.ts b/src/configs/complexLookup/complexLookup.config.ts index 310ac4b9..08483a9c 100644 --- a/src/configs/complexLookup/complexLookup.config.ts +++ b/src/configs/complexLookup/complexLookup.config.ts @@ -1,5 +1,6 @@ import { SearchSegment } from '@common/constants/search.constants'; -import { ComplexLookupType, SearchableIndex } from '@common/constants/complexLookup.constants'; +import { AuthorityValidationTarget, ComplexLookupType, SearchableIndex } from '@common/constants/complexLookup.constants'; +import { AUTHORITY_ASSIGNMENT_CHECK_API_ENDPOINT } from '@common/constants/api.constants'; import { COMPLEX_LOOKUP_FILTERS_CONFIG } from './complexLookupFilters.config'; import { COMPLEX_LOOKUP_SEARCH_BY_CONFIG } from './complexLookupSearchBy.config'; import { COMPLEX_LOOKUP_SEARCHABLE_INDICES_MAP } from './complexLookupSeachableIndicesMap'; @@ -16,6 +17,10 @@ export const COMPLEX_LOOKUPS_CONFIG: ComplexLookupsConfig = { [SearchSegment.Browse]: '/browse/authorities', }, marcPreview: '/source-storage/records/:recordId/formatted?idType=AUTHORITY', + validation: AUTHORITY_ASSIGNMENT_CHECK_API_ENDPOINT, + }, + validationTarget: { + creator: AuthorityValidationTarget.CreatorOfWork, }, sourceKey: 'authoritySourceFiles', searchQuery: { diff --git a/src/store/selectors.ts b/src/store/selectors.ts index 0282941f..fd8aed7e 100644 --- a/src/store/selectors.ts +++ b/src/store/selectors.ts @@ -6,6 +6,7 @@ import { useInputsStore } from './stores/inputs'; import { useConfigStore } from './stores/config'; import { useUIStore } from './stores/ui'; import { useSearchStore } from './stores/search'; +import { useComplexLookupStore } from './stores/complexLookup'; // The "createSelectors" function can be utilized here to generate memoized selectors // Note: "createSelectors" is currently unoptimized and may result in memory leaks if used as is @@ -17,3 +18,4 @@ export const useInputsState = useInputsStore; export const useConfigState = useConfigStore; export const useUIState = useUIStore; export const useSearchState = useSearchStore; +export const useComplexLookupState = useComplexLookupStore; diff --git a/src/store/stores/complexLookup.ts b/src/store/stores/complexLookup.ts new file mode 100644 index 00000000..74ac2d49 --- /dev/null +++ b/src/store/stores/complexLookup.ts @@ -0,0 +1,15 @@ +import { type SliceState } from '../utils/slice'; +import { createStoreFactory, SliceConfigs } from '../utils/createStoreFactory'; + +export type ComplexLookupState = SliceState<'authorityAssignmentCheckFailedIds', string[], string>; + +const STORE_NAME = 'ComplexLookup'; + +const sliceConfigs: SliceConfigs = { + authorityAssignmentCheckFailedIds: { + initialValue: [], + singleItem: { type: '' }, + }, +}; + +export const useComplexLookupStore = createStoreFactory(sliceConfigs, STORE_NAME); diff --git a/src/test/__tests__/common/helpers/complexLookup.helper.test.ts b/src/test/__tests__/common/helpers/complexLookup.helper.test.ts index f67a0261..70498d95 100644 --- a/src/test/__tests__/common/helpers/complexLookup.helper.test.ts +++ b/src/test/__tests__/common/helpers/complexLookup.helper.test.ts @@ -3,10 +3,12 @@ import { getLinkedField, updateLinkedFieldValue, getUpdatedSelectedEntries, + generateValidationRequestBody, } from '@common/helpers/complexLookup.helper'; import * as ComplexLookupConstants from '@common/constants/complexLookup.constants'; import { AdvancedFieldType } from '@common/constants/uiControls.constants'; import { getMockedImportedConstant } from '@src/test/__mocks__/common/constants/constants.mock'; +import { AuthorityValidationTarget } from '@common/constants/complexLookup.constants'; const mockImportedConstant = getMockedImportedConstant(ComplexLookupConstants, 'COMPLEX_LOOKUPS_LINKED_FIELDS_MAPPING'); mockImportedConstant({ @@ -125,4 +127,57 @@ describe('complexLookup.helper', () => { expect(result).toEqual(selectedEntries); }); }); + + describe('generateValidationRequestBody', () => { + const mockMarcContent = { + field_1: 'value 1', + field_2: 'value 2', + }; + + const mockMarcData = { + parsedRecord: { + content: mockMarcContent, + }, + } as unknown as MarcDTO; + + it('returns empty object when marcData is null', () => { + const result = generateValidationRequestBody(null); + + expect(result).toEqual({}); + }); + + it('returns correct request body with default target', () => { + const result = generateValidationRequestBody(mockMarcData); + + expect(result).toEqual({ + rawMarc: JSON.stringify(mockMarcContent, null, 2), + target: AuthorityValidationTarget.CreatorOfWork, + }); + }); + + it('returns correct request body with custom target', () => { + const customTarget = 'CUSTOM_TARGET'; + const result = generateValidationRequestBody(mockMarcData, customTarget as AuthorityValidationTargetType); + + expect(result).toEqual({ + rawMarc: JSON.stringify(mockMarcContent, null, 2), + target: customTarget, + }); + }); + + it('returns correct request body with escaped content', () => { + const marcDataWithSpecialChars = { + parsedRecord: { + content: { + field: 'value\r\nwith\rlinebreaks', + }, + }, + } as unknown as MarcDTO; + + const result = generateValidationRequestBody(marcDataWithSpecialChars); + + expect(result.rawMarc).toContain('\\r'); + expect(result.rawMarc).toContain('\\n'); + }); + }); }); diff --git a/src/test/__tests__/common/hooks/useApi.test.ts b/src/test/__tests__/common/hooks/useApi.test.ts new file mode 100644 index 00000000..74b30160 --- /dev/null +++ b/src/test/__tests__/common/hooks/useApi.test.ts @@ -0,0 +1,153 @@ +import { act, renderHook } from '@testing-library/react'; +import { setInitialGlobalState } from '@src/test/__mocks__/store'; +import BaseApi from '@common/api/base.api'; +import { UserNotificationFactory } from '@common/services/userNotification'; +import { StatusType } from '@common/constants/status.constants'; +import { useApi } from '@common/hooks/useApi'; +import { useLoadingState, useStatusState } from '@src/store'; + +jest.mock('@common/api/base.api'); +jest.mock('@common/services/userNotification', () => ({ + UserNotificationFactory: { + createMessage: jest.fn(), + }, +})); + +describe('useApi', () => { + const mockSetIsLoading = jest.fn(); + const mockAddStatusMessagesItem = jest.fn(); + const mockResponse = { data: 'test data' }; + + beforeEach(() => { + setInitialGlobalState([ + { + store: useLoadingState, + state: { setIsLoading: mockSetIsLoading }, + }, + { + store: useStatusState, + state: { addStatusMessagesItem: mockAddStatusMessagesItem }, + }, + ]); + + (BaseApi.getJson as jest.Mock).mockResolvedValue(mockResponse); + (BaseApi.generateUrl as jest.Mock).mockImplementation(url => url); + }); + + it('initializes with null data', () => { + const { result } = renderHook(() => useApi()); + + expect(result.current.data).toBeNull(); + }); + + describe('makeRequest', () => { + it('handles successful GET request', async () => { + const { result } = renderHook(() => useApi()); + + await act(async () => { + await result.current.makeRequest({ url: '/test-url' }); + }); + + expect(mockSetIsLoading).toHaveBeenCalledWith(true); + expect(BaseApi.getJson).toHaveBeenCalledWith({ + url: '/test-url', + urlParams: undefined, + requestParams: { + method: 'GET', + headers: { 'content-type': 'application/json' }, + }, + }); + expect(result.current.data).toEqual(mockResponse); + expect(mockSetIsLoading).toHaveBeenCalledWith(false); + }); + + it('handles POST request with body', async () => { + const { result } = renderHook(() => useApi()); + const testBody = { key: 'value' }; + + await act(async () => { + await result.current.makeRequest({ + url: '/test-url', + method: 'POST', + body: testBody, + }); + }); + + expect(BaseApi.getJson).toHaveBeenCalledWith({ + url: '/test-url', + urlParams: undefined, + requestParams: { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(testBody, null, 2), + }, + }); + }); + + it('handles URL parameters correctly', async () => { + const { result } = renderHook(() => useApi()); + const urlParam = { name: 'id', value: '123' }; + + await act(async () => { + await result.current.makeRequest({ + url: '/test-url', + urlParam, + }); + }); + + expect(BaseApi.generateUrl).toHaveBeenCalledWith('/test-url', urlParam); + }); + + it('handles errors and shows notification', async () => { + const error = new Error('API Error'); + (BaseApi.getJson as jest.Mock).mockRejectedValue(error); + + const { result } = renderHook(() => useApi()); + + await act(async () => { + await result.current.makeRequest({ url: '/test-url' }); + }); + + expect(UserNotificationFactory.createMessage).toHaveBeenCalledWith(StatusType.error, 'ld.errorMakingApiRequest'); + expect(mockSetIsLoading).toHaveBeenCalledWith(false); + }); + + it('uses custom error message when provided', async () => { + const error = new Error('API Error'); + (BaseApi.getJson as jest.Mock).mockRejectedValue(error); + + const { result } = renderHook(() => useApi()); + + await act(async () => { + await result.current.makeRequest({ + url: '/test-url', + errorMessageId: 'custom.error', + }); + }); + + expect(UserNotificationFactory.createMessage).toHaveBeenCalledWith(StatusType.error, 'custom.error'); + }); + + it('handles request params', async () => { + const { result } = renderHook(() => useApi()); + const customRequestParams = { credentials: 'include' as RequestCredentials }; + + await act(async () => { + await result.current.makeRequest({ + url: '/test-url', + requestParams: customRequestParams, + }); + }); + + expect(BaseApi.getJson).toHaveBeenCalledWith({ + url: '/test-url', + urlParams: undefined, + requestParams: { + method: 'GET', + headers: { 'content-type': 'application/json' }, + ...customRequestParams, + }, + }); + }); + }); +}); diff --git a/src/test/__tests__/common/hooks/useComplexLookup.test.ts b/src/test/__tests__/common/hooks/useComplexLookup.test.ts index 11ca87c6..25bba908 100644 --- a/src/test/__tests__/common/hooks/useComplexLookup.test.ts +++ b/src/test/__tests__/common/hooks/useComplexLookup.test.ts @@ -16,14 +16,17 @@ import { } from '@src/test/__mocks__/providers/ServicesProvider.mock'; import { setInitialGlobalState } from '@src/test/__mocks__/store'; import { useInputsStore } from '@src/store'; +import { useApi } from '@common/hooks/useApi'; jest.mock('@common/helpers/complexLookup.helper'); jest.mock('@common/hooks/useMarcData'); +jest.mock('@common/hooks/useApi'); describe('useComplexLookup', () => { const mockSelectedEntries = [] as string[]; const mockSetSelectedEntries = jest.fn(); const mockClearhMarcData = jest.fn(); + const mockMakeRequest = jest.fn(); const mockEntry = { uuid: 'testUuid', @@ -81,6 +84,10 @@ describe('useComplexLookup', () => { clearMarcData: mockClearhMarcData, }); + (useApi as jest.Mock).mockReturnValue({ + makeRequest: mockMakeRequest, + }); + result = getRenderedHook()?.result; }); @@ -155,6 +162,7 @@ describe('useComplexLookup', () => { (getLinkedField as jest.Mock).mockReturnValue(mockLinkedField); (updateLinkedFieldValue as jest.Mock).mockReturnValue({ uuid: 'newLinkedFieldId' }); (getUpdatedSelectedEntries as jest.Mock).mockReturnValue(['newId']); + mockMakeRequest.mockResolvedValue(true); result = getRenderedHook()?.result; @@ -176,6 +184,8 @@ describe('useComplexLookup', () => { }); test('updates state correctly and does not call "setSelectedEntries"', async () => { + mockMakeRequest.mockResolvedValue(true); + result = getRenderedHook({ ...mockEntry, linkedEntry: { diff --git a/src/test/__tests__/common/hooks/useComplexLookupValidation.test.ts b/src/test/__tests__/common/hooks/useComplexLookupValidation.test.ts new file mode 100644 index 00000000..70a1307a --- /dev/null +++ b/src/test/__tests__/common/hooks/useComplexLookupValidation.test.ts @@ -0,0 +1,65 @@ +import { renderHook } from '@testing-library/react'; +import { setInitialGlobalState } from '@src/test/__mocks__/store'; +import { useComplexLookupValidation } from '@src/common/hooks/useComplexLookupValidation'; +import { useComplexLookupStore } from '@src/store/stores/complexLookup'; + +describe('useComplexLookupValidation', () => { + const mockAddAuthorityAssignmentCheckFailedIdsItem = jest.fn(); + const mockResetAuthorityAssignmentCheckFailedIds = jest.fn(); + + beforeEach(() => { + setInitialGlobalState([ + { + store: useComplexLookupStore, + state: { + authorityAssignmentCheckFailedIds: ['existing-id-1', 'existing-id-2'], + addAuthorityAssignmentCheckFailedIdsItem: mockAddAuthorityAssignmentCheckFailedIdsItem, + resetAuthorityAssignmentCheckFailedIds: mockResetAuthorityAssignmentCheckFailedIds, + }, + }, + ]); + }); + + describe('addFailedEntryId', () => { + it('addAuthorityAssignmentCheckFailedIdsItem with provided id', () => { + const testId = 'test-id'; + + const { result } = renderHook(() => useComplexLookupValidation()); + result.current.addFailedEntryId(testId); + + expect(mockAddAuthorityAssignmentCheckFailedIdsItem).toHaveBeenCalledWith(testId); + }); + }); + + describe('clearFailedEntryIds', () => { + it('calls resetAuthorityAssignmentCheckFailedIds', () => { + const { result } = renderHook(() => useComplexLookupValidation()); + result.current.clearFailedEntryIds(); + + expect(mockResetAuthorityAssignmentCheckFailedIds).toHaveBeenCalled(); + }); + }); + + describe('checkFailedId', () => { + it('returns true for existing id', () => { + const { result } = renderHook(() => useComplexLookupValidation()); + const exists = result.current.checkFailedId('existing-id-1'); + + expect(exists).toBe(true); + }); + + it('returns false for non-existing id', () => { + const { result } = renderHook(() => useComplexLookupValidation()); + const exists = result.current.checkFailedId('non-existing-id'); + + expect(exists).toBe(false); + }); + + it('returns false for undefined id', () => { + const { result } = renderHook(() => useComplexLookupValidation()); + const exists = result.current.checkFailedId(); + + expect(exists).toBe(false); + }); + }); +}); diff --git a/src/test/__tests__/components/ComplexLookupField/configs/Authorities.test.tsx b/src/test/__tests__/components/ComplexLookupField/configs/Authorities.test.tsx index fbb107e7..ce5b8e62 100644 --- a/src/test/__tests__/components/ComplexLookupField/configs/Authorities.test.tsx +++ b/src/test/__tests__/components/ComplexLookupField/configs/Authorities.test.tsx @@ -29,18 +29,18 @@ describe('authoritiesTableConfig', () => { }, title: { label: 'ld.headingReference', - position: 1, + position: 2, className: 'cell-fixed cell-fixed-370', formatter: mockTitleFormatter, }, subclass: { label: 'ld.typeOfHeading', - position: 2, + position: 3, className: 'cell-fixed cell-fixed-140', }, authoritySource: { label: 'ld.authoritySource', - position: 3, + position: 4, className: 'cell-fixed cell-fixed-250', }, }); diff --git a/src/test/__tests__/components/ComplexLookupField/formatters/Assign.test.tsx b/src/test/__tests__/components/ComplexLookupField/formatters/Assign.test.tsx index 3a995a07..35fbc77a 100644 --- a/src/test/__tests__/components/ComplexLookupField/formatters/Assign.test.tsx +++ b/src/test/__tests__/components/ComplexLookupField/formatters/Assign.test.tsx @@ -4,8 +4,10 @@ import { AuthRefType } from '@common/constants/search.constants'; import { AssignFormatter } from '@components/ComplexLookupField/formatters'; describe('AssignFormatter', () => { - it('renders Button when authorized', () => { - const mockOnAssign = jest.fn(); + const mockOnAssign = jest.fn(); + const mockCheckFailedId = jest.fn(); + + describe('Button', () => { const row = { authorized: { label: AuthRefType.Authorized }, __meta: { id: '1' }, @@ -13,22 +15,39 @@ describe('AssignFormatter', () => { subclass: { label: 'Subclass 1' }, }; - const { getByTestId } = render(AssignFormatter({ row, onAssign: mockOnAssign })); - const button = getByTestId('assign-button-1'); + it('renders when authorized', () => { + mockCheckFailedId.mockReturnValue(false); + + const { getByTestId } = render( + AssignFormatter({ row, onAssign: mockOnAssign, checkFailedId: mockCheckFailedId }), + ); + const button = getByTestId('assign-button-1'); + + expect(button).toBeInTheDocument(); + + fireEvent.click(button); + + expect(mockOnAssign).toHaveBeenCalledWith({ + id: '1', + title: 'Title 1', + linkedFieldValue: 'Subclass 1', + }); + }); - expect(button).toBeInTheDocument(); + it('renders disabled Button', () => { + mockCheckFailedId.mockReturnValue(true); - fireEvent.click(button); + const { getByTestId } = render( + AssignFormatter({ row, onAssign: mockOnAssign, checkFailedId: mockCheckFailedId }), + ); + const button = getByTestId('assign-button-1'); - expect(mockOnAssign).toHaveBeenCalledWith({ - id: '1', - title: 'Title 1', - linkedFieldValue: 'Subclass 1', + expect(button).toBeInTheDocument(); + expect(button).toBeDisabled(); }); }); it('does not render Button when not authorized', () => { - const mockOnAssign = jest.fn(); const row = { authorized: { label: AuthRefType.AuthRef }, __meta: { id: '2' }, @@ -36,7 +55,9 @@ describe('AssignFormatter', () => { subclass: { label: 'Subclass 2' }, }; - const { queryByTestId } = render(AssignFormatter({ row, onAssign: mockOnAssign })); + const { queryByTestId } = render( + AssignFormatter({ row, onAssign: mockOnAssign, checkFailedId: mockCheckFailedId }), + ); expect(queryByTestId('assign-button-2')).toBeNull(); }); diff --git a/src/types/api.d.ts b/src/types/api.d.ts index 0882c629..b1c8420d 100644 --- a/src/types/api.d.ts +++ b/src/types/api.d.ts @@ -103,3 +103,5 @@ type MarcDTO = { }; metadata: Metadata; }; + +type APIRequestMethod = 'GET' | 'POST' | 'PUT' | 'DELETE'; diff --git a/src/types/complexLookup.d.ts b/src/types/complexLookup.d.ts index 3265ff28..e5560ebe 100644 --- a/src/types/complexLookup.d.ts +++ b/src/types/complexLookup.d.ts @@ -1,6 +1,7 @@ type SearchableIndexType = import('@common/constants/complexLookup.constants').SearchableIndex; type SearchableIndexQuerySelectorType = import('@common/constants/complexLookup.constants').SearchableIndexQuerySelector; +type AuthorityValidationTargetType = import('@common/constants/complexLookup.constants').AuthorityValidationTarget; type ComplexLookupLabels = { button: { @@ -40,7 +41,9 @@ type ComplexLookupApiEntryConfig = { [key in SearchSegment]: string; }; marcPreview?: string; + validation?: string; }; + validationTarget?: Record; sourceKey?: string; searchQuery: { filter?: string; diff --git a/translations/ui-linked-data/en.json b/translations/ui-linked-data/en.json index 0fe2ecff..2f331595 100644 --- a/translations/ui-linked-data/en.json +++ b/translations/ui-linked-data/en.json @@ -210,5 +210,7 @@ "ld.aria.filters.reset": "Reset filters button", "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?" + "ld.rdPropertiesMatchContinue": "Properties of this resource match an existing resource. Do you want to continue?", + "ld.errorMakingApiRequest": "Error making API request", + "ld.errorValidatingAuthorityRecord": "Error validating authority record" }