diff --git a/CHANGELOG.md b/CHANGELOG.md index 717fd90dc..bf97097f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,6 +41,7 @@ * Update Inventory: Set records for deletion permission's effect on ‘Set for deletion’ checkbox in Instance Edit view. Refs UIIN-3260. * *BREAKING* Migrate stripes dependencies to their Sunflower versions. Refs UIIN-3223. * *BREAKING* Migrate `react-intl` to v7. Refs UIIN-3224. +* Instance: Display all versions in View history fourth pane. Refs UIIN-3173. ## [12.0.12](https://github.com/folio-org/ui-inventory/tree/v12.0.12) (2025-01-27) [Full Changelog](https://github.com/folio-org/ui-inventory/compare/v12.0.11...v12.0.12) diff --git a/src/Instance/InstanceDetails/InstanceDetails.js b/src/Instance/InstanceDetails/InstanceDetails.js index 759e6f623..d5c358867 100644 --- a/src/Instance/InstanceDetails/InstanceDetails.js +++ b/src/Instance/InstanceDetails/InstanceDetails.js @@ -44,11 +44,12 @@ import { InstanceClassificationView } from './InstanceClassificationView'; import { InstanceRelationshipView } from './InstanceRelationshipView'; import { InstanceNewHolding } from './InstanceNewHolding'; import { InstanceAcquisition } from './InstanceAcquisition'; -import HelperApp from '../../components/HelperApp'; -import { VersionHistory } from '../../views/VersionHistory'; +import HelperApp from '../../components/HelperApp'; import { DataContext } from '../../contexts'; import { ConsortialHoldings } from '../HoldingsList/consortium/ConsortialHoldings'; +import { InstanceVersionHistory } from '../InstanceVersionHistory'; + import { getAccordionState, getPublishingInfo, @@ -352,7 +353,8 @@ const InstanceDetails = forwardRef(({ {isVersionHistoryOpen && ( - setIsVersionHistoryOpen(false)} /> )} diff --git a/src/Instance/InstanceVersionHistory/InstanceVersionHistory.js b/src/Instance/InstanceVersionHistory/InstanceVersionHistory.js new file mode 100644 index 000000000..e867d4c2d --- /dev/null +++ b/src/Instance/InstanceVersionHistory/InstanceVersionHistory.js @@ -0,0 +1,132 @@ +import { + useContext, + useState, +} from 'react'; +import { useIntl } from 'react-intl'; +import PropTypes from 'prop-types'; + +import { + AuditLogPane, + NoValue, +} from '@folio/stripes/components'; + +import { DataContext } from '../../contexts'; +import { + useInstanceAuditDataQuery, + useVersionHistory, +} from '../../hooks'; + +import { getDateWithTime } from '../../utils'; + +const InstanceVersionHistory = ({ + instanceId, + onClose, +}) => { + const intl = useIntl(); + const { formatMessage } = intl; + + const referenceData = useContext(DataContext); + + const [lastVersionEventTs, setLastVersionEventTs] = useState(null); + + const { + data, + totalRecords, + isLoading, + } = useInstanceAuditDataQuery(instanceId, lastVersionEventTs); + + const { + actionsMap, + isLoadedMoreVisible, + versionsToDisplay, + } = useVersionHistory(data, totalRecords); + + const fieldLabelsMap = { + administrativeNotes: formatMessage({ id: 'ui-inventory.administrativeNotes' }), + alternativeTitles: formatMessage({ id: 'ui-inventory.alternativeTitles' }), + catalogedDate: formatMessage({ id: 'ui-inventory.catalogedDate' }), + childInstances: formatMessage({ id: 'ui-inventory.childInstances' }), + classifications: formatMessage({ id: 'ui-inventory.classifications' }), + contributors: formatMessage({ id: 'ui-inventory.contributors' }), + date1: formatMessage({ id: 'ui-inventory.date1' }), + date2: formatMessage({ id: 'ui-inventory.date2' }), + dateTypeId: formatMessage({ id: 'ui-inventory.dateType' }), + deleted: formatMessage({ id: 'ui-inventory.setForDeletion' }), + discoverySuppress: formatMessage({ id: 'ui-inventory.discoverySuppressed' }), + editions: formatMessage({ id: 'ui-inventory.edition' }), + electronicAccess: formatMessage({ id: 'ui-inventory.electronicAccess' }), + hrid: formatMessage({ id: 'ui-inventory.instanceHrid' }), + identifiers: formatMessage({ id: 'ui-inventory.identifiers' }), + instanceFormatIds: formatMessage({ id: 'ui-inventory.instanceFormats' }), + instanceTypeId: formatMessage({ id: 'ui-inventory.resourceType' }), + indexTitle: formatMessage({ id: 'ui-inventory.indexTitle' }), + languages: formatMessage({ id: 'ui-inventory.languages' }), + modeOfIssuanceId: formatMessage({ id: 'ui-inventory.modeOfIssuance' }), + natureOfContentTermIds: formatMessage({ id: 'ui-inventory.natureOfContentTerms' }), + notes: formatMessage({ id: 'ui-inventory.instanceNotes' }), + parentInstances: formatMessage({ id: 'ui-inventory.parentInstances' }), + physicalDescriptions: formatMessage({ id: 'ui-inventory.physicalDescriptions' }), + precedingTitles: formatMessage({ id: 'ui-inventory.precedingTitles' }), + previouslyHeld: formatMessage({ id: 'ui-inventory.previouslyHeld' }), + publication: formatMessage({ id: 'ui-inventory.publications' }), + publicationFrequency: formatMessage({ id: 'ui-inventory.publicationFrequency' }), + publicationRange: formatMessage({ id: 'ui-inventory.publicationRange' }), + series: formatMessage({ id: 'ui-inventory.seriesStatements' }), + source: formatMessage({ id: 'ui-inventory.source' }), + staffSuppress: formatMessage({ id: 'ui-inventory.staffSuppressed' }), + statisticalCodeIds: formatMessage({ id: 'ui-inventory.statisticalCodes' }), + statusId: formatMessage({ id: 'ui-inventory.instanceStatus' }), + statusUpdatedDate: formatMessage({ id: 'ui-inventory.instanceStatusUpdatedDate' }), + subjects: formatMessage({ id: 'ui-inventory.subjects' }), + succeedingTitles: formatMessage({ id: 'ui-inventory.succeedingTitles' }), + tagList: formatMessage({ id: 'stripes-smart-components.tags' }), + title: formatMessage({ id: 'ui-inventory.instances.columns.title' }), + }; + const fieldFormatter = { + alternativeTitleTypeId: value => referenceData.alternativeTitleTypes?.find(type => type.id === value)?.name, + classificationTypeId: value => referenceData.classificationTypes?.find(type => type.id === value)?.name, + contributorNameTypeId: value => referenceData.contributorNameTypes?.find(contributor => contributor.id === value)?.name, + contributorTypeId: value => referenceData.contributorTypes?.find(contributor => contributor.id === value)?.name, + contributorTypeText: value => value || , + dateTypeId: value => referenceData.instanceDateTypes?.find(type => type.id === value)?.name, + identifierTypeId: value => referenceData.identifierTypes?.find(type => type.id === value)?.name, + instanceFormatIds: value => referenceData.instanceFormats?.find(format => format.id === value)?.name, + instanceNoteTypeId: value => referenceData.instanceNoteTypes?.find(note => note.id === value)?.name, + instanceTypeId: value => referenceData.instanceTypes?.find(type => type.id === value)?.name, + modeOfIssuanceId: value => referenceData.modesOfIssuance?.find(mode => mode.id === value)?.name, + natureOfContentTermIds: value => referenceData.natureOfContentTerms?.find(term => term.id === value)?.name, + primary: value => value.toString(), + relationshipId: value => referenceData.electronicAccessRelationships?.find(rel => rel.id === value)?.name, + sourceId: value => referenceData.subjectSources?.find(source => source.id === value)?.name, + staffOnly: value => value.toString(), + statisticalCodeIds: value => referenceData.statisticalCodes?.find(code => code.id === value)?.name, + statusId: value => referenceData.instanceStatuses?.find(status => status.id === value)?.name, + statusUpdatedDate: value => getDateWithTime(value), + typeId: value => referenceData.subjectTypes?.find(type => type.id === value)?.name, + uri: value => value || , + }; + + const handleLoadMore = lastEventTs => { + setLastVersionEventTs(lastEventTs); + }; + + return ( + + ); +}; + +InstanceVersionHistory.propTypes = { + instanceId: PropTypes.string.isRequired, + onClose: PropTypes.func, +}; + +export default InstanceVersionHistory; diff --git a/src/Instance/InstanceVersionHistory/InstanceVersionHistory.test.js b/src/Instance/InstanceVersionHistory/InstanceVersionHistory.test.js new file mode 100644 index 000000000..e73e51ea6 --- /dev/null +++ b/src/Instance/InstanceVersionHistory/InstanceVersionHistory.test.js @@ -0,0 +1,65 @@ +import { + QueryClient, + QueryClientProvider, +} from 'react-query'; +import { screen } from '@folio/jest-config-stripes/testing-library/react'; + +import { + renderWithIntl, + translationsProperties, +} from '../../../test/jest/helpers'; + +import InstanceVersionHistory from './InstanceVersionHistory'; +import { DataContext } from '../../contexts'; + +jest.mock('../../hooks', () => ({ + ...jest.requireActual('../../hooks'), + useInstanceAuditDataQuery: () => jest.fn(), +})); + +const queryClient = new QueryClient(); + +const onCloseMock = jest.fn(); +const instanceId = 'instanceId'; +const mockReferenceData = { + alternativeTitleTypes: [], + classificationTypes: [], + contributorNameTypes: [], + contributorTypes: [], + instanceDateTypes: [], + identifierTypes: [], + instanceFormats: [], + instanceNoteTypes: [], + instanceTypes: [], + modesOfIssuance: [], + natureOfContentTerms: [], + electronicAccessRelationships: [], + subjectSources: [], + statisticalCodes: [], + instanceStatuses: [], + subjectTypes:[], +}; + +const renderInstanceVersionHistory = () => { + const component = ( + + + + + + ); + + return renderWithIntl(component, translationsProperties); +}; + +describe('InstanceVersionHistory', () => { + it('should render View history pane', () => { + renderInstanceVersionHistory(); + + expect(screen.getByText('Version history')).toBeInTheDocument(); + }); +}); + diff --git a/src/Instance/InstanceVersionHistory/index.js b/src/Instance/InstanceVersionHistory/index.js new file mode 100644 index 000000000..40910fc65 --- /dev/null +++ b/src/Instance/InstanceVersionHistory/index.js @@ -0,0 +1 @@ +export { default as InstanceVersionHistory } from './InstanceVersionHistory'; diff --git a/src/hooks/index.js b/src/hooks/index.js index c0d413cbb..8dae0cf7f 100644 --- a/src/hooks/index.js +++ b/src/hooks/index.js @@ -5,6 +5,7 @@ export { default as useCallout } from './useCallout'; export { default as useHoldingItemsQuery } from './useHoldingItemsQuery'; export { default as useHoldingMutation } from './useHoldingMutation'; export { default as useHoldingsFromStorage } from './useHoldingsFromStorage'; +export { default as useInstanceAuditDataQuery } from './useInstanceAuditDataQuery'; export { default as useInstanceMutation } from './useInstanceMutation'; export { default as useHoldingsQueryByHrids } from './useHoldingsQueryByHrids'; export { default as useInventoryBrowse } from './useInventoryBrowse'; @@ -14,6 +15,7 @@ export { default as useClassificationIdentifierTypes } from './useClassification export { default as useClassificationBrowseConfig } from './useClassificationBrowseConfig'; export { default as useUpdateOwnership } from './useUpdateOwnership'; export { default as useLocalStorageItems } from './useLocalStorageItems'; +export { default as useVersionHistory } from './useVersionHistory'; export * from './useQuickExport'; export * from '@folio/stripes-inventory-components/lib/queries/useInstanceDateTypes'; export * from './useCallNumberTypesQuery'; diff --git a/src/hooks/useInstanceAuditDataQuery/index.js b/src/hooks/useInstanceAuditDataQuery/index.js new file mode 100644 index 000000000..ecf93d4ee --- /dev/null +++ b/src/hooks/useInstanceAuditDataQuery/index.js @@ -0,0 +1 @@ +export { default } from './useInstanceAuditDataQuery'; diff --git a/src/hooks/useInstanceAuditDataQuery/useInstanceAuditDataQuery.js b/src/hooks/useInstanceAuditDataQuery/useInstanceAuditDataQuery.js new file mode 100644 index 000000000..2c6b34382 --- /dev/null +++ b/src/hooks/useInstanceAuditDataQuery/useInstanceAuditDataQuery.js @@ -0,0 +1,30 @@ +import { useQuery } from 'react-query'; + +import { + useNamespace, + useOkapiKy, +} from '@folio/stripes/core'; + +const useInstanceAuditDataQuery = (instanceId, eventTs) => { + const ky = useOkapiKy(); + const [namespace] = useNamespace({ key: 'instance-audit-data' }); + + // eventTs param is used to load more data + const { isLoading, data = {} } = useQuery({ + queryKey: [namespace, instanceId, eventTs], + queryFn: () => ky.get(`audit-data/inventory/instance/${instanceId}`, { + searchParams: { + ...(eventTs && { eventTs }) + } + }).json(), + enabled: Boolean(instanceId), + }); + + return { + data: data?.inventoryAuditItems || [], + totalRecords: data?.totalRecords, + isLoading, + }; +}; + +export default useInstanceAuditDataQuery; diff --git a/src/hooks/useVersionHistory/getActionLabel.js b/src/hooks/useVersionHistory/getActionLabel.js new file mode 100644 index 000000000..c91228afb --- /dev/null +++ b/src/hooks/useVersionHistory/getActionLabel.js @@ -0,0 +1,12 @@ +/** + * Gets translated change type label + * @param {function} formatMessage + * @returns {{ADDED, MODIFIED, REMOVED}} + */ +export const getActionLabel = formatMessage => { + return { + ADDED: formatMessage({ id: 'stripes-acq-components.audit-log.action.added' }), + MODIFIED: formatMessage({ id: 'stripes-acq-components.audit-log.action.edited' }), + REMOVED: formatMessage({ id: 'stripes-acq-components.audit-log.action.removed' }), + }; +}; diff --git a/src/hooks/useVersionHistory/getActionLabel.test.js b/src/hooks/useVersionHistory/getActionLabel.test.js new file mode 100644 index 000000000..444a7c5ac --- /dev/null +++ b/src/hooks/useVersionHistory/getActionLabel.test.js @@ -0,0 +1,15 @@ +import { getActionLabel } from './getActionLabel'; + +const intl = { formatMessage: ({ id }) => id }; + +describe('getActionLabel', () => { + it('should return correct action labels', () => { + const labels = { + ADDED: 'stripes-acq-components.audit-log.action.added', + MODIFIED: 'stripes-acq-components.audit-log.action.edited', + REMOVED: 'stripes-acq-components.audit-log.action.removed', + }; + + expect(getActionLabel(intl.formatMessage)).toEqual(labels); + }); +}); diff --git a/src/hooks/useVersionHistory/getChangedFieldsList.js b/src/hooks/useVersionHistory/getChangedFieldsList.js new file mode 100644 index 000000000..d6c33b7f9 --- /dev/null +++ b/src/hooks/useVersionHistory/getChangedFieldsList.js @@ -0,0 +1,28 @@ +import { sortBy } from 'lodash'; + +/** + * Merge fieldChanges and collectionChanges into a list of changed fields and sort by changeType + * @param {Object} diff + * @param {Array} diff.fieldChanges + * @param {Array} diff.collectionChanges + * @returns {Array.<{fieldName: String, changeType: String, newValue: any, oldValue: any}>} + */ +export const getChangedFieldsList = diff => { + const fieldChanges = diff.fieldChanges ? diff.fieldChanges.map(field => ({ + fieldName: field.fieldName, + changeType: field.changeType, + newValue: field.newValue, + oldValue: field.oldValue, + })) : []; + + const collectionChanges = diff.collectionChanges ? diff.collectionChanges.flatMap(collection => { + return collection.itemChanges.map(field => ({ + fieldName: collection.collectionName, + changeType: field.changeType, + newValue: field.newValue, + oldValue: field.oldValue, + })); + }) : []; + + return sortBy([...fieldChanges, ...collectionChanges], data => data.changeType); +}; diff --git a/src/hooks/useVersionHistory/index.js b/src/hooks/useVersionHistory/index.js new file mode 100644 index 000000000..af5a0400f --- /dev/null +++ b/src/hooks/useVersionHistory/index.js @@ -0,0 +1 @@ +export { default } from './useVersionHistory'; diff --git a/src/hooks/useVersionHistory/useVersionHistory.js b/src/hooks/useVersionHistory/useVersionHistory.js new file mode 100644 index 000000000..4ba1eb1da --- /dev/null +++ b/src/hooks/useVersionHistory/useVersionHistory.js @@ -0,0 +1,104 @@ +import { + useEffect, + useMemo, + useState, +} from 'react'; +import { useIntl } from 'react-intl'; +import { Link } from 'react-router-dom'; +import { + keyBy, + uniq, +} from 'lodash'; + +import { + formatDateTime, + useUsersBatch, +} from '@folio/stripes-acq-components'; + +import { getChangedFieldsList } from './getChangedFieldsList'; +import { getActionLabel } from './getActionLabel'; + +const useVersionHistory = (data, totalRecords) => { + const intl = useIntl(); + const anonymousUserLabel = intl.formatMessage({ id: 'stripes-components.versionHistory.anonymousUser' }); + + const [versions, setVersions] = useState([]); + const [usersId, setUsersId] = useState([]); + const [usersMap, setUsersMap] = useState({}); + const [isLoadedMoreVisible, setIsLoadedMoreVisible] = useState(true); + + const { users } = useUsersBatch(usersId); + + // cleanup when component unmounts + useEffect(() => () => { + setVersions([]); + setUsersMap({}); + }, []); + + // update usersId when data changes + useEffect(() => { + if (!data?.length) return; + + const newUsersId = uniq(data.map(version => version.userId)); + + setUsersId(newUsersId); + }, [data]); + + // update usersMap when new users are fetched + useEffect(() => { + if (!users?.length) return; + + setUsersMap(prevState => ({ + ...prevState, + ...keyBy(users, 'id'), + })); + }, [users]); + + useEffect(() => { + if (!data?.length) return; + + setVersions(prevState => [...prevState, ...data]); + }, [data]); + + useEffect(() => { + setIsLoadedMoreVisible(versions.length < totalRecords); + }, [versions]); + + const versionsToDisplay = useMemo( + () => { + const getUserName = userId => { + const user = usersMap[userId]; + + return user ? `${user.personal.lastName}, ${user.personal.firstName}` : null; + }; + const getSourceLink = userId => { + return userId ? {getUserName(userId)} : anonymousUserLabel; + }; + + const transformDiffToVersions = diffArray => { + return diffArray + .filter(({ action }) => action !== 'CREATE') + .map(({ eventDate, eventTs, userId, eventId, diff }) => ({ + eventDate: formatDateTime(eventDate, intl), + source: getSourceLink(userId), + userName: getUserName(userId) || anonymousUserLabel, + fieldChanges: diff ? getChangedFieldsList(diff) : [], + eventId, + eventTs, + })); + }; + + return transformDiffToVersions(versions); + }, [versions, usersMap], + ); + + const actionsMap = { ...getActionLabel(intl.formatMessage) }; + + return { + actionsMap, + isLoadedMoreVisible, + versionsToDisplay, + }; +}; + +export default useVersionHistory;