Repository: folio-org/stripes-components
Pull request #2433: STCOM-1412: Introduce `<AuditLog>` component
Author: login: mariia-aloshyna
Created: 2025-02-28T14:05:30Z | Merged: 2025-03-05T11:49:39Z

## Purpose 
Implement `<AuditLog>` component to render the Version history pane with version cards.

## Approach
- Implemented `AuditLogPane` component to render the pane
- Implemented `AuditLogCard` component to render version cards
- Implemented `AuditLogChangedFieldsList` component to render a list of changed fields inside cards
- Implemented `AuditLogModal` component to render a modal window with changes details (action, field, changed from, changed to)
- Implemented `useVersionHistory` hook

## Refs
https://folio-org.atlassian.net/browse/UXPROD-4125
https://folio-org.atlassian.net/browse/STCOM-1412
https://folio-org.atlassian.net/browse/STCOM-1414
https://folio-org.atlassian.net/browse/STCOM-1413

## Screenshots

https://github.com/user-attachments/assets/7a40fe03-7c7c-434e-9222-87eaccdc41a1




Diff:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 048783cff..02b2243f6 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,8 @@
 
 ## 13.1.0 IN PROGRESS
 
+* Introduce `<AuditLog>` component. Refs STCOM-1412.
+
 ## [13.0.0](https://github.com/folio-org/stripes-components/tree/v13.0.0) (2025-02-24)
 [Full Changelog](https://github.com/folio-org/stripes-components/compare/v12.2.0...v13.0.0)
 
diff --git a/README.md b/README.md
index 8000f73c8..bbb3545b6 100644
--- a/README.md
+++ b/README.md
@@ -51,6 +51,7 @@ Component | doc | categories
 --- | --- | ---
 [`<Accordion>`](lib/Accordion) | [doc](lib/Accordion/readme.md) | structure
 [`<AdvancedSearch>`](lib/AdvancedSearch) | [doc](lib/AdvancedSearch/readme.md) | control
+[`<AuditLog>`](lib/AuditLog) | [doc](lib/AuditLog/readme.md) | data-display
 [`<AutoSuggest>`](lib/AutoSuggest) | [doc](lib/AutoSuggest/readme.md) | control
 [`<Avatar>`](lib/Avatar) | [doc](lib/Avatar/readme.md) | data-display
 [`<Badge>`](lib/Badge) | [doc](lib/Badge/readme.md) | data-display, design
diff --git a/hooks/tests/useVersionHistory-test.js b/hooks/tests/useVersionHistory-test.js
new file mode 100644
index 000000000..5ce48d1f0
--- /dev/null
+++ b/hooks/tests/useVersionHistory-test.js
@@ -0,0 +1,175 @@
+import { useState } from 'react';
+import {
+  describe,
+  beforeEach,
+  it,
+} from 'mocha';
+import {
+  Button as ButtonInteractor,
+  converge,
+} from '@folio/stripes-testing';
+import isEqual from 'lodash/isEqual';
+
+import Button from '../../lib/Button';
+import { getHookExecutionHarness } from '../../tests/helpers/getHookExecutionResult';
+import useVersionHistory from '../useVersionHistory';
+
+const mockData = [{
+  'eventId': 'b5028383-d4ca-4ae2-b321-8f5f43971485',
+  'entityId': '5aa67b42-6160-4f37-a662-3503159a240a',
+  'eventTs': 1741016911081,
+  'eventDate': '2025-03-03T15:48:31.081+00:00',
+  'action': 'UPDATE',
+  'userId': 'f8d8e8e3-3128-57c7-a137-80a237f62c2f',
+  'diff': {
+    'fieldChanges': [
+      {
+        'changeType': 'ADDED',
+        'fieldName': 'catalogedDate',
+        'fullPath': 'catalogedDate',
+        'newValue': '2025-03-17'
+      }
+    ],
+    'collectionChanges': [],
+  }
+}];
+
+const mockData2 = [{
+  'eventId': 'b5028383-d4ca-4ae2-b321-8f5f43971485',
+  'entityId': '5aa67b42-6160-4f37-a662-3503159a240a',
+  'eventTs': 103050304,
+  'eventDate': '2025-03-03T15:48:31.081+00:00',
+  'action': 'CHANGE',
+  'userId': 'f8d8e8e3-3128-57c7-a137-80a237f62c2f',
+  'diff': {
+    'fieldChanges': [
+      {
+        'changeType': 'REMOVED',
+        'fieldName': 'catalogedDate',
+        'fullPath': 'catalogedDate',
+        'newValue': ''
+      }
+    ],
+    'collectionChanges': [],
+  }
+}];
+
+const mockTotalRecords = 5;
+const mockVersionsFormatter = (diffArray) => {
+  return diffArray
+    .map(({ eventDate, eventTs, userId, eventId, diff }) => ({
+      eventDate,
+      source: userId,
+      userName: 'John, Doe',
+      fieldChanges: diff.fieldChanges || [],
+      eventId,
+      eventTs,
+    }));
+};
+
+const expectedRes = {
+  versions: [{
+    eventDate: '2025-03-03T15:48:31.081+00:00',
+    source: 'f8d8e8e3-3128-57c7-a137-80a237f62c2f',
+    userName: 'John, Doe',
+    fieldChanges: [
+      {
+        'changeType': 'ADDED',
+        'fieldName': 'catalogedDate',
+        'fullPath': 'catalogedDate',
+        'newValue': '2025-03-17'
+      }
+    ],
+    eventId: 'b5028383-d4ca-4ae2-b321-8f5f43971485',
+    eventTs: 1741016911081,
+  }],
+  isLoadedMoreVisible: true,
+};
+
+const expectedRes2 = {
+  versions: [{
+    eventDate: '2025-03-03T15:48:31.081+00:00',
+    source: 'f8d8e8e3-3128-57c7-a137-80a237f62c2f',
+    userName: 'John, Doe',
+    fieldChanges: [
+      {
+        'changeType': 'REMOVED',
+        'fieldName': 'catalogedDate',
+        'fullPath': 'catalogedDate',
+        'newValue': ''
+      }
+    ],
+    eventId: 'b5028383-d4ca-4ae2-b321-8f5f43971485',
+    eventTs: 103050304,
+  }],
+  isLoadedMoreVisible: true,
+};
+
+
+const Harness = ({ children }) => {
+  const [renderChild, setRenderChild] = useState(false);
+  const [data, setData] = useState(mockData);
+  return (
+    <>
+      <Button onClick={() => setRenderChild(visible => !visible)}>Toggle</Button>
+      <Button onClick={() => setData(mockData2)}>Change data</Button>
+      {renderChild && children({
+        data,
+        totalRecords: mockTotalRecords,
+        versionsFormatter: mockVersionsFormatter,
+      })}
+    </>
+  )
+};
+
+describe('useVersionHistory', () => {
+  describe('mounting / unmounting.', () => {
+    let result;
+    beforeEach(async () => {
+      await getHookExecutionHarness(
+        useVersionHistory,
+        {},
+        Harness,
+        (res) => { result = res; },
+        isEqual,
+        true
+      );
+    });
+
+    it('result contains versions', () => {
+      converge(() => {
+        if (!isEqual(result?.versions, expectedRes)) {
+          throw new Error('expected array of versions');
+        }
+      });
+    });
+
+    describe('changing data', () => {
+      beforeEach(async () => {
+        await ButtonInteractor('Change data').click();
+      });
+
+      it('updates result data', () => {
+        converge(() => {
+          if (!isEqual(result?.versions, expectedRes2)) {
+            throw new Error('expected array of versions to change');
+          }
+        });
+      });
+    });
+
+    describe('unmounting', () => {
+      beforeEach(async () => {
+        await ButtonInteractor('Toggle').click();
+      });
+
+      it('versions is emptied', () => {
+        converge(() => {
+          if (result?.versions?.length !== 0) {
+            throw new Error('expected array of versions to be empty');
+          }
+        });
+      });
+    });
+  });
+});
diff --git a/hooks/useVersionHistory/index.js b/hooks/useVersionHistory/index.js
new file mode 100644
index 000000000..af5a0400f
--- /dev/null
+++ b/hooks/useVersionHistory/index.js
@@ -0,0 +1 @@
+export { default } from './useVersionHistory';
diff --git a/hooks/useVersionHistory/useVersionHistory.js b/hooks/useVersionHistory/useVersionHistory.js
new file mode 100644
index 000000000..3d9f44758
--- /dev/null
+++ b/hooks/useVersionHistory/useVersionHistory.js
@@ -0,0 +1,53 @@
+import {
+  useEffect,
+  useMemo,
+  useState,
+} from 'react';
+
+/**
+ * React hook for managing version history data and the "Load more" button visibility.
+ *
+ * @param {Array} data - Array of version history records to be processed.
+ * @param {number} totalRecords - The total number of records available.
+ * @param {Function} versionsFormatter - Optional formatter function for versions list.
+ *
+ * @returns {{versions: (*|*[]), isLoadMoreVisible: boolean}} Returns formatted list of versions
+ * and a flag that indicates if the "Load More" button should be visible based on totalRecords value and a number of
+ * displayed versions.
+ */
+const useVersionHistory = ({
+  data,
+  totalRecords,
+  versionsFormatter,
+}) => {
+  const [versions, setVersions] = useState([]);
+
+  const [isLoadMoreVisible, setIsLoadMoreVisible] = useState(true);
+
+  // cleanup when component unmounts
+  useEffect(() => () => {
+    setVersions([]);
+  }, []);
+
+  useEffect(() => {
+    if (!data?.length) return;
+
+    setVersions(prevState => [...prevState, ...data]);
+  }, [data]);
+
+  useEffect(() => {
+    setIsLoadMoreVisible(versions.length < totalRecords);
+  }, [totalRecords, versions]);
+
+  const versionsToDisplay = useMemo(
+    () => (versionsFormatter ? versionsFormatter(versions) : versions),
+    [versionsFormatter, versions]
+  );
+
+  return {
+    versions: versionsToDisplay,
+    isLoadMoreVisible,
+  };
+};
+
+export default useVersionHistory;
diff --git a/index.js b/index.js
index 1bde5a251..d15cfaf6c 100644
--- a/index.js
+++ b/index.js
@@ -1,4 +1,5 @@
 /* form elements */
+export { default as AuditLogPane } from './lib/AuditLog';
 export { default as AutoSuggest } from './lib/AutoSuggest';
 export { default as Badge } from './lib/Badge';
 export { default as Button } from './lib/Button';
@@ -192,5 +193,6 @@ export { default as nativeChangeFieldValue } from './util/nativeChangeFieldValue
 export { default as useCurrencyOptions } from './hooks/useCurrencyOptions';
 export { default as useDateFormatter } from './hooks/useFormatDate';
 export { default as useTimeFormatter } from './hooks/useFormatTime';
+export { default as useVersionHistory } from './hooks/useVersionHistory';
 
 export { pagingTypes as MCLPagingTypes } from './lib/MultiColumnList';
diff --git a/lib/AuditLog/AuditLogCard.css b/lib/AuditLog/AuditLogCard.css
new file mode 100644
index 000000000..a75bed00b
--- /dev/null
+++ b/lib/AuditLog/AuditLogCard.css
@@ -0,0 +1,8 @@
+.card p {
+    margin: 0;
+}
+
+.card button {
+    margin-top: 0.25rem;
+    margin-bottom: 0.25rem;
+}
diff --git a/lib/AuditLog/AuditLogCard.js b/lib/AuditLog/AuditLogCard.js
new file mode 100644
index 000000000..6e1e3b94b
--- /dev/null
+++ b/lib/AuditLog/AuditLogCard.js
@@ -0,0 +1,89 @@
+import { useState } from 'react';
+import { FormattedMessage } from 'react-intl';
+import PropTypes from 'prop-types';
+
+import Card from '../Card';
+
+import AuditLogChangedFieldsList from './AuditLogChangedFieldsList';
+import AuditLogModal from './AuditLogModal';
+
+import css from './AuditLogCard.css';
+
+const AuditLogCard = ({
+  actionsMap,
+  columnWidths,
+  date,
+  fieldChanges,
+  fieldFormatter,
+  fieldLabelsMap,
+  source,
+  userName,
+}) => {
+  const [isModalOpen, setIsModalOpen] = useState(false);
+
+  const cardHeader = <b>{date}</b>;
+  const fieldChangesSource = (
+    <FormattedMessage
+      id="stripes-components.metaSection.source"
+      tagName="p"
+      values={{ source }}
+    />
+  );
+  const modalHeader = (
+    <>
+      {date}
+      <br />
+      <FormattedMessage
+        id="stripes-components.metaSection.source"
+        values={{ source: userName }}
+      />
+    </>
+  );
+
+  const onChangeButtonClick = () => {
+    setIsModalOpen(true);
+  };
+
+  return (
+    <>
+      <Card
+        cardClass={css.card}
+        headerStart={cardHeader}
+        cardStyle="positive"
+        headerProps={{ tabIndex: -1 }}
+        roundedBorder
+      >
+        {fieldChangesSource}
+        <AuditLogChangedFieldsList
+          actionsMap={actionsMap}
+          fieldChanges={fieldChanges}
+          fieldLabelsMap={fieldLabelsMap}
+          onChangeButtonClick={onChangeButtonClick}
+        />
+      </Card>
+      <AuditLogModal
+        contentData={fieldChanges}
+        open={isModalOpen}
+        label={modalHeader}
+        onClose={() => setIsModalOpen(false)}
+        fieldLabelsMap={fieldLabelsMap}
+        fieldFormatter={fieldFormatter}
+        actionsMap={actionsMap}
+        columnWidths={columnWidths}
+      />
+    </>
+  );
+};
+
+AuditLogCard.propTypes = {
+  actionsMap: PropTypes.object,
+  columnWidths: PropTypes.object,
+  date: PropTypes.string.isRequired,
+  fieldChanges: PropTypes.arrayOf(PropTypes.object).isRequired,
+  fieldFormatter: PropTypes.object,
+  fieldLabelsMap: PropTypes.object,
+  source: PropTypes.oneOf([PropTypes.string, PropTypes.node]).isRequired,
+  userName: PropTypes.oneOf([PropTypes.string, PropTypes.node]).isRequired,
+};
+
+export default AuditLogCard;
diff --git a/lib/AuditLog/AuditLogChangedFieldsList.js b/lib/AuditLog/AuditLogChangedFieldsList.js
new file mode 100644
index 000000000..0848bb31b
--- /dev/null
+++ b/lib/AuditLog/AuditLogChangedFieldsList.js
@@ -0,0 +1,59 @@
+import { FormattedMessage } from 'react-intl';
+import PropTypes from 'prop-types';
+
+import Button from '../Button';
+import List from '../List';
+
+const AuditLogChangedFieldsList = ({
+  actionsMap,
+  fieldChanges,
+  fieldLabelsMap,
+  onChangeButtonClick,
+}) => {
+  if (!fieldChanges) return '';
+
+  const itemFormatter = (item, i) => {
+    const {
+      fieldName,
+      changeType,
+    } = item;
+
+    return (
+      <li key={i}>
+        {`${fieldLabelsMap?.[fieldName] || fieldName} (${actionsMap?.[changeType] || changeType})`}
+      </li>
+    );
+  };
+
+  return (
+    <div>
+      <Button
+        buttonStyle="link"
+        onClick={onChangeButtonClick}
+      >
+        <FormattedMessage
+          id="stripes-components.versionHistory.card.changed"
+          tagName="strong"
+        />
+      </Button>
+      <List
+        items={fieldChanges}
+        itemFormatter={itemFormatter}
+        listStyle="bullets"
+        marginBottom0
+      />
+    </div>
+  );
+};
+
+AuditLogChangedFieldsList.propTypes = {
+  actionsMap: PropTypes.object,
+  fieldChanges: PropTypes.arrayOf(PropTypes.shape({
+    changeType: PropTypes.string,
+    fieldName: PropTypes.string,
+  })).isRequired,
+  fieldLabelsMap: PropTypes.object,
+  onChangeButtonClick: PropTypes.func,
+};
+
+export default AuditLogChangedFieldsList;
diff --git a/lib/AuditLog/AuditLogModal.js b/lib/AuditLog/AuditLogModal.js
new file mode 100644
index 000000000..7cbc1c6e1
--- /dev/null
+++ b/lib/AuditLog/AuditLogModal.js
@@ -0,0 +1,98 @@
+import { FormattedMessage } from 'react-intl';
+import PropTypes from 'prop-types';
+
+import Button from '../Button';
+import Modal from '../Modal';
+import MultiColumnList from '../MultiColumnList';
+
+import changedFieldsFormatter from './changedFieldsFormatter';
+
+const AuditLogModal = ({
+  actionsMap,
+  columnWidths,
+  contentData,
+  fieldFormatter,
+  fieldLabelsMap,
+  label,
+  onClose,
+  open,
+}) => {
+  const visibleColumns = ['action', 'field', 'changedFrom', 'changedTo'];
+  const columnMapping = {
+    action: <FormattedMessage id="stripes-components.versionHistory.modal.action" />,
+    field: <FormattedMessage id="stripes-components.versionHistory.modal.field" />,
+    changedFrom: <FormattedMessage id="stripes-components.versionHistory.modal.changedFrom" />,
+    changedTo: <FormattedMessage id="stripes-components.versionHistory.modal.changedTo" />,
+  };
+
+  const itemFormatter = (field, i) => {
+    return (
+      <li key={i}>
+        {fieldFormatter?.[field.name]?.(field.value) || field.value}
+      </li>
+    );
+  };
+  const formatter = {
+    action: item => actionsMap?.[item.changeType] || item.changeType,
+    field: item => fieldLabelsMap?.[item.fieldName] || item.fieldName,
+    changedFrom: item => changedFieldsFormatter({
+      fieldValue: item.oldValue,
+      fieldName: item.fieldName,
+      listItemFormatter: itemFormatter,
+      fieldFormatter,
+    }),
+    changedTo: item => changedFieldsFormatter({
+      fieldValue: item.newValue,
+      fieldName: item.fieldName,
+      listItemFormatter: itemFormatter,
+      fieldFormatter,
+    }),
+  };
+
+  const modalFooter = (
+    <Button
+      onClick={onClose}
+      buttonStyle="primary"
+      marginBottom0
+    >
+      <FormattedMessage id="stripes-components.close" />
+    </Button>
+  );
+
+  return (
+    <Modal
+      open={open}
+      label={label}
+      onClose={onClose}
+      footer={modalFooter}
+      dismissible
+    >
+      <MultiColumnList
+        contentData={contentData}
+        columnMapping={columnMapping}
+        visibleColumns={visibleColumns}
+        columnWidths={columnWidths}
+        formatter={formatter}
+        interactive={false}
+      />
+    </Modal>
+  );
+};
+
+AuditLogModal.propTypes = {
+  actionsMap: PropTypes.object,
+  columnWidths: PropTypes.object,
+  contentData: PropTypes.arrayOf(PropTypes.shape({
+    changeType: PropTypes.string,
+    fieldName: PropTypes.string,
+    newValue: PropTypes.oneOf([PropTypes.string, PropTypes.array, PropTypes.object]),
+    oldValue: PropTypes.oneOf([PropTypes.string, PropTypes.array, PropTypes.object]),
+  })).isRequired,
+  fieldFormatter: PropTypes.object,
+  fieldLabelsMap: PropTypes.object,
+  label: PropTypes.node.isRequired,
+  onClose: PropTypes.func,
+  open: PropTypes.bool.isRequired,
+};
+
+export default AuditLogModal;
diff --git a/lib/AuditLog/AuditLogPane.js b/lib/AuditLog/AuditLogPane.js
new file mode 100644
index 000000000..4ddff5bfb
--- /dev/null
+++ b/lib/AuditLog/AuditLogPane.js
@@ -0,0 +1,142 @@
+import {
+  useEffect,
+  useLayoutEffect,
+  useMemo,
+  useRef,
+} from 'react';
+import { FormattedMessage } from 'react-intl';
+import PropTypes from 'prop-types';
+
+import { Loading } from '../Loading';
+import Pane from '../Pane';
+import Button from '../Button';
+
+import AuditLogCard from './AuditLogCard';
+
+const paneTitle = <FormattedMessage id="stripes-components.versionHistory.pane.header" />;
+
+const AuditLogPane = ({
+  actionsMap,
+  columnWidths,
+  fieldFormatter,
+  fieldLabelsMap,
+  handleLoadMore,
+  isLoading,
+  isLoadMoreVisible = true,
+  onClose,
+  versions,
+}) => {
+  const paneTitleRef = useRef(null);
+  const cardsContainerRef = useRef(null);
+  const lastCardRef = useRef(null);
+
+  useLayoutEffect(() => {
+    if (paneTitleRef.current) {
+      paneTitleRef.current.focus();
+    }
+  }, []);
+
+  // set focus to the last card after new versions loaded
+  useEffect(() => {
+    if (lastCardRef?.current) {
+      lastCardRef.current.scrollIntoView({ behavior: 'smooth', block: 'start' });
+      lastCardRef.current.focus();
+    }
+  }, [versions]);
+
+  const onLoadMoreClick = () => {
+    // store the last card before loading more versions
+    if (cardsContainerRef.current) {
+      const cards = cardsContainerRef.current.querySelectorAll('[class^="card"]');
+
+      if (cards.length > 0) {
+        lastCardRef.current = cards[cards.length - 1];
+      }
+    }
+
+    handleLoadMore(versions.at(-1).eventTs);
+  };
+
+  const versionCards = useMemo(() => {
+    return versions.map(({
+      eventId,
+      eventDate,
+      source,
+      userName,
+      fieldChanges,
+    }, i) => {
+      return (
+        <AuditLogCard
+          key={eventId || i}
+          date={eventDate}
+          source={source}
+          userName={userName}
+          fieldChanges={fieldChanges}
+          fieldLabelsMap={fieldLabelsMap}
+          fieldFormatter={fieldFormatter}
+          actionsMap={actionsMap}
+          columnWidths={columnWidths}
+        />
+      );
+    });
+  }, [versions, fieldLabelsMap, fieldFormatter, actionsMap, columnWidths]);
+
+  return (
+    <Pane
+      defaultWidth="20%"
+      paneTitle={paneTitle}
+      paneTitleRef={paneTitleRef}
+      paneSub={(
+        <FormattedMessage
+          id="stripes-components.versionHistory.pane.sub"
+          values={{ count: versions.length }}
+        />
+      )}
+      dismissible
+      onClose={onClose}
+    >
+      <div
+        ref={cardsContainerRef}
+      >
+        {versionCards}
+      </div>
+      {isLoadMoreVisible && (
+        <Button
+          onClick={onLoadMoreClick}
+          fullWidth
+        >
+          {!isLoading && <FormattedMessage id="stripes-components.mcl.loadMore" />}
+          {isLoading && (
+            <>
+              <Loading size="medium" useCurrentColor />
+              <span className="sr-only">
+                <FormattedMessage id="stripes-components.versionHistory.pane.loadingLabel" />
+              </span>
+            </>
+          )}
+        </Button>
+      )}
+    </Pane>
+  );
+};
+
+AuditLogPane.propTypes = {
+  actionsMap: PropTypes.object,
+  columnWidths: PropTypes.object,
+  fieldFormatter: PropTypes.object,
+  fieldLabelsMap: PropTypes.object,
+  handleLoadMore: PropTypes.func,
+  isLoading: PropTypes.bool,
+  isLoadMoreVisible: PropTypes.bool,
+  onClose: PropTypes.func,
+  versions: PropTypes.arrayOf(
+    PropTypes.shape({
+      eventDate: PropTypes.string,
+      eventId: PropTypes.string,
+      fieldChanges: PropTypes.arrayOf(PropTypes.object),
+      source: PropTypes.object,
+    })
+  ).isRequired,
+};
+
+export default AuditLogPane;
diff --git a/lib/AuditLog/changedFieldsFormatter.js b/lib/AuditLog/changedFieldsFormatter.js
new file mode 100644
index 000000000..734fb0978
--- /dev/null
+++ b/lib/AuditLog/changedFieldsFormatter.js
@@ -0,0 +1,38 @@
+import { map } from 'lodash';
+
+import List from '../List';
+import NoValue from '../NoValue';
+
+const changedFieldsFormatter = ({
+  fieldFormatter,
+  fieldValue,
+  fieldName,
+  listItemFormatter,
+}) => {
+  if (!fieldValue) return <NoValue />;
+
+  if (typeof fieldValue === 'object' && !Array.isArray(fieldValue)) {
+    return (
+      <List
+        items={map(fieldValue, (value, name) => ({ name, value }))}
+        itemFormatter={listItemFormatter}
+        listStyle="bullets"
+        marginBottom0
+      />
+    );
+  }
+
+  if (Array.isArray(fieldValue)) {
+    return (
+      <List
+        items={fieldValue}
+        listStyle="bullets"
+        marginBottom0
+      />
+    );
+  }
+
+  return fieldFormatter?.[fieldName]?.(fieldValue) || fieldValue;
+};
+
+export default changedFieldsFormatter;
diff --git a/lib/AuditLog/index.js b/lib/AuditLog/index.js
new file mode 100644
index 000000000..267686f1b
--- /dev/null
+++ b/lib/AuditLog/index.js
@@ -0,0 +1 @@
+export { default } from './AuditLogPane';
diff --git a/lib/AuditLog/readme.md b/lib/AuditLog/readme.md
new file mode 100644
index 000000000..fa5391b62
--- /dev/null
+++ b/lib/AuditLog/readme.md
@@ -0,0 +1,244 @@
+# AuditLogPane
+AuditLogPane is a component that renders the Version history pane to view and track updates to different record types.
+It displays a list of AuditLogCard components, each representing a specific change event.
+Each AuditLogCard has a "Changed" button, clicking on the button within an AuditLogCard opens an AuditLogModal to display
+detailed fields changes information.
+
+## Basic Usage
+```js
+import { AuditLogPane, NoValue } from '@folio/stripes/components';
+
+const versions = [{
+  eventDate: '2025-03-01',
+  source: 'Doe, John',
+  userName: 'Doe, John',
+  fieldChanges: [{
+    fieldName: 'name',
+    changeType: 'ADDED',
+    newValue: 'newValue',
+    oldValue: 'oldValue',
+  }],
+  eventId: '12345',
+  eventTs: 1700000000,
+}];
+
+const fieldLabelsMap = {
+  administrativeNotes: formatMessage({ id: 'ui-inventory.administrativeNotes' }),
+  alternativeTitles: formatMessage({ id: 'ui-inventory.alternativeTitles' }),
+};
+
+const fieldFormatter = {
+  contributorTypeText: value => value || <NoValue />,
+  primary: value => value.toString(),
+};
+
+const handleClose = () => console.log('Pane closed');
+const handleLoadMore = () => console.log('Load more clicked');
+const isLoading = false;
+const isLoadMoreVisible = true;
+const actionsMap = { ADDED: formatMessage({ id: 'ui-inventory.versionHistory.action.added' }) };
+
+return (
+  <AuditLogPane
+    versions={versions}
+    onClose={handleClose}
+    isLoadMoreVisible={isLoadMoreVisible}
+    handleLoadMore={handleLoadMore}
+    isLoading={isLoading}
+    fieldLabelsMap={fieldLabelsMap}
+    fieldFormatter={fieldFormatter}
+    actionsMap={actionsMap}
+  />
+);
+```
+
+## Props
+Name | type | description                                                                       | default | required
+--- | --- |-----------------------------------------------------------------------------------| --- | ---
+actionsMap | object | Maps change type value to user friendly action label                              | | false
+columnWidths | object | Sets custom column widths to modal window columns                                 | | false
+fieldFormatter | object | Formats changed field value in modal content, used to format oldValue/newValue fields | | false
+fieldLabelsMap | object | Maps changed field name to user friendly label                                    | | false
+handleLoadMore | func | Callback fired when the "Load more" button is clicked                             | | false
+isLoading | bool | Flag that indicates whether data is being loaded                                  | | false
+isLoadMoreVisible | bool | Flag that indicates whether "Load more" button visible or not                     | true | false
+onClose | func | Callback fired when the pane is closed using its dismiss button                   | | false
+versions | array | An array of objects containing version change details                             | | true
+
+## AuditLogCard
+AuditLogCard represents a single change event within AuditLogPane.
+
+### Basic usage
+```js
+import { AuditLogCard } from '@folio/stripes/components';
+
+const versionCards = () => {
+  return versions.map(({
+    eventId,
+    eventDate,
+    source,
+    userName,
+    fieldChanges,
+  }, i) => {
+    return (
+      <AuditLogCard
+        key={eventId || i}
+        date={eventDate}
+        source={source}
+        userName={userName}
+        fieldChanges={fieldChanges}
+        fieldLabelsMap={fieldLabelsMap}
+        fieldFormatter={fieldFormatter}
+        actionsMap={actionsMap}
+        columnWidths={columnWidths}
+      />
+    );
+  });
+};
+```
+
+### Props
+Name | type   | description                                                                          | default | required
+--- |--------|--------------------------------------------------------------------------------------| --- | ---
+actionsMap | object | Maps change type value to user friendly action label                                 | | false
+columnWidths | object | Sets custom column widths to modal window columns                                    | | false
+date | string | The date of the change event                                                         | | true
+fieldChanges | array  | A list of changed fields                                                             | | true
+fieldFormatter | object | Formats changed field value in modal content, used to format oldValue/newValue fields | | false
+fieldLabelsMap | object | Maps changed field name to user friendly label                                       | | false
+source | string or node | The source of fields changes                                                         | | true
+userName | string | The name of the user who made the change | | true
+
+## AuditLogChangedFieldsList
+AuditLogChangedFieldsList renders a list of changed fields and "Changed" button within an AuditLogCard.
+
+### Basic usage
+```js
+import { AuditLogChangedFieldsList } from '@folio/stripes/components';
+
+const actionsMap = { ADDED: formatMessage({ id: 'ui-inventory.versionHistory.action.added' }) };
+const fieldChanges = [{
+  fieldName: 'name',
+  changeType: 'ADDED',
+  newValue: 'newValue',
+  oldValue: 'oldValue',
+}];
+const fieldLabelsMap = {
+  administrativeNotes: formatMessage({ id: 'ui-inventory.administrativeNotes' }),
+  alternativeTitles: formatMessage({ id: 'ui-inventory.alternativeTitles' }),
+};
+const onChangeButtonClick = () => console.log('Changed clicked');
+
+return (
+  <AuditLogChangedFieldsList
+    actionsMap={actionsMap}
+    fieldChanges={fieldChanges}
+    fieldLabelsMap={fieldLabelsMap}
+    onChangeButtonClick={onChangeButtonClick}
+  />
+);
+```
+
+### Props
+Name | type   | description                                                                         | default | required
+--- |--------|-------------------------------------------------------------------------------------| --- | ---
+actionsMap | object | Maps change type value to user friendly action label                                | | false
+fieldChanges | array  | A list of changed fields                                                            | | false
+fieldLabelsMap | object | Maps changed field name to user friendly label                                      | | false
+onChangeButtonClick | func | Callback triggered when clicking the "Changed" button                             | | false
+
+## AuditLogModal
+AuditLogModal is a modal window that opens when the "Changed" button is clicked, displaying detailed fields changes information.
+
+### Basic usage
+```js
+import { AuditLogModal, NoValue } from '@folio/stripes/components';
+
+const [isModalOpen, setIsModalOpen] = useState(true);
+
+const actionsMap = { ADDED: formatMessage({ id: 'ui-inventory.versionHistory.action.added' }) };
+const fieldChanges = [{
+  fieldName: 'name',
+  changeType: 'ADDED',
+  newValue: 'newValue',
+  oldValue: 'oldValue',
+}];
+const fieldLabelsMap = {
+  administrativeNotes: formatMessage({ id: 'ui-inventory.administrativeNotes' }),
+  alternativeTitles: formatMessage({ id: 'ui-inventory.alternativeTitles' }),
+};
+const fieldFormatter = {
+  contributorTypeText: value => value || <NoValue />,
+  primary: value => value.toString(),
+};
+const modalHeader = (
+  <>
+    {date}
+    <br />
+    <FormattedMessage
+      id="stripes-components.metaSection.source"
+      values={{ source: userName }}
+    />
+  </>
+);
+const columnWidths = { action: '30px' };
+
+return (
+  <AuditLogModal
+    contentData={fieldChanges}
+    open={isModalOpen}
+    label={modalHeader}
+    onClose={() => setIsModalOpen(false)}
+    fieldLabelsMap={fieldLabelsMap}
+    fieldFormatter={fieldFormatter}
+    actionsMap={actionsMap}
+    columnWidths={columnWidths}
+  />
+);
+```
+
+### Props
+Name | type           | description                                                                         | default | required
+--- |----------------|-------------------------------------------------------------------------------------| --- | ---
+actionsMap | object         | Maps change type value to user friendly action label                                | | false
+columnWidths | object         | Sets custom column widths to modal window columns                                   | | false
+contentData | array          | A list of changed fields                                                            | | true
+fieldLabelsMap | object         | Maps changed field name to user friendly label                                      | | false
+fieldFormatter | object         | Formats changed field value in modal content, used to format oldValue/newValue fields | | false
+label | string or node | The header label of the modal                                                       | | true
+onClose | func           | Callback triggered when closing the modal                                | | false
+open | bool           | Indicates whether the modal is open | | true
+
+## useVersionHistory hook
+React hook for managing version history data and the "Load more" button visibility.
+Can be used together with `<AuditLogPane>` components.
+
+### Basic usage
+```js
+import { useVersionHistory } from '@folio/stripes/components';
+
+const { data, totalRecords } = useInstanceAuditDataQuery();
+
+
+export const versionsFormatter = diffArray => {
+  return diffArray
+    .filter(({ action }) => action !== 'CREATE')
+    .map(({ eventDate, eventTs, userId, eventId, diff }) => ({
+      eventDate: formatDateTime(eventDate),
+      source: getSourceLink(userId),
+      userName: getUserName(userId) || anonymousUserLabel,
+      fieldChanges: diff.fieldChanges || [],
+      eventId,
+      eventTs,
+    }));
+};
+
+const {
+  versions,
+  isLoadMoreVisible,
+} = useVersionHistory({
+  data,
+  totalRecords,
+  versionsFormatter,
+});
+```
diff --git a/lib/AuditLog/stories/AuditLogPane.stories.js b/lib/AuditLog/stories/AuditLogPane.stories.js
new file mode 100644
index 000000000..a2c7da475
--- /dev/null
+++ b/lib/AuditLog/stories/AuditLogPane.stories.js
@@ -0,0 +1,8 @@
+import React from 'react';
+import BasicUsage from './BasicUsage';
+
+export default {
+  title: 'AuditLogPane',
+};
+
+export const _BasicUsage = () => <BasicUsage />;
diff --git a/lib/AuditLog/stories/BasicUsage.js b/lib/AuditLog/stories/BasicUsage.js
new file mode 100644
index 000000000..aabf71f0c
--- /dev/null
+++ b/lib/AuditLog/stories/BasicUsage.js
@@ -0,0 +1,49 @@
+import { useIntl } from 'react-intl';
+import NoValue from '../../NoValue';
+import AuditLogPane from '../AuditLogPane';
+
+export default () => {
+  const intl = useIntl();
+
+  const versions = [{
+    eventDate: '2025-02-28T12:00:42.183+00:00',
+    source: 'lastName, firstName',
+    userName: 'lastName, firstName',
+    fieldChanges: [{
+      fieldName: 'name',
+      changeType: 'ADDED',
+      newValue: 'newValue',
+      oldValue: 'oldValue',
+    }],
+    eventId: '4ff9b7db-cb62-42fa-b52d-708cda1ba666',
+    eventTs: 1740744042183,
+  }];
+  const fieldLabelsMap = {
+    administrativeNotes: intl.formatMessage({ id: 'ui-inventory.administrativeNotes' }),
+    alternativeTitles: intl.formatMessage({ id: 'ui-inventory.alternativeTitles' }),
+  };
+  const fieldFormatter = {
+    contributorTypeText: value => value || <NoValue />,
+    primary: value => value.toString(),
+  };
+  const actionsMap = {
+    ADDED: 'Added',
+    REMOVED: 'Removed',
+  }
+
+  const onClose = () => {};
+  const handleLoadMore = () => {};
+
+  return (
+    <AuditLogPane
+      versions={versions}
+      onClose={onClose}
+      isLoadMoreVisible={true}
+      handleLoadMore={handleLoadMore}
+      isLoading={false}
+      fieldLabelsMap={fieldLabelsMap}
+      fieldFormatter={fieldFormatter}
+      actionsMap={actionsMap}
+    />
+  );
+};
diff --git a/lib/AuditLog/tests/AuditLogChangedFieldsList-test.js b/lib/AuditLog/tests/AuditLogChangedFieldsList-test.js
new file mode 100644
index 000000000..326ae28c9
--- /dev/null
+++ b/lib/AuditLog/tests/AuditLogChangedFieldsList-test.js
@@ -0,0 +1,63 @@
+import React from 'react';
+import { describe, beforeEach, afterEach, it } from 'mocha';
+import sinon from 'sinon';
+import { expect } from 'chai';
+
+import {
+  runAxeTest,
+  List,
+  ListItem,
+  Button,
+  including,
+} from '@folio/stripes-testing';
+
+import { mount } from '../../../tests/helpers';
+import Harness from '../../../tests/Harness';
+
+import AuditLogChangedFieldsList from '../AuditLogChangedFieldsList';
+
+describe('AuditLogChangedFieldsList', () => {
+  const fieldChanges = [{
+    changeType: 'MODIFIED',
+    fieldName: 'name1',
+  }];
+
+  const fieldLabelsMap = {
+    name1: 'Name 1',
+  };
+
+  const onChangeSpy = sinon.spy();
+
+  describe('rendering AuditLogChangedFieldsList', () => {
+    beforeEach(async () => {
+      await mount(
+        <Harness>
+          <AuditLogChangedFieldsList
+            actionsMap={{}}
+            fieldLabelsMap={fieldLabelsMap}
+            fieldChanges={fieldChanges}
+            onChangeButtonClick={onChangeSpy}
+          />
+        </Harness>
+      );
+    });
+
+    afterEach(() => {
+      onChangeSpy.resetHistory();
+    });
+
+    it('has no axe errors', runAxeTest);
+
+    it('should render field changes', () => List().find(ListItem(including('Name 1 (MODIFIED)'))).exists());
+
+    describe('when clicking on changes button', () => {
+      beforeEach(async () => {
+        await Button().click();
+      });
+
+      it('should call the callback', () => {
+        expect(onChangeSpy.called).to.be.true;
+      });
+    });
+  });
+});
diff --git a/lib/AuditLog/tests/AuditLogModal-test.js b/lib/AuditLog/tests/AuditLogModal-test.js
new file mode 100644
index 000000000..12137ef51
--- /dev/null
+++ b/lib/AuditLog/tests/AuditLogModal-test.js
@@ -0,0 +1,114 @@
+import React from 'react';
+import sinon from 'sinon';
+import { describe, beforeEach, it } from 'mocha';
+import { expect } from 'chai';
+
+import {
+  runAxeTest,
+  Modal,
+  Button,
+  MultiColumnList,
+  MultiColumnListCell,
+  including,
+  converge,
+} from '@folio/stripes-testing';
+
+import { mountWithContext } from '../../../tests/helpers';
+import Harness from '../../../tests/Harness';
+import AuditLogModal from '../AuditLogModal';
+
+const AuditLogModalInteractor = Modal.extend('AuditLog modal')
+  .actions({
+    clickClose: ({ find }, label = 'Close') => find(Button(including(label))).click()
+  });
+
+describe('AuditLogModal', () => {
+  const auditLogModal = AuditLogModalInteractor();
+  const mcl = MultiColumnList();
+  const onClose = sinon.spy();
+
+  const buttonLabel = 'Close';
+  const fieldLabelsMap = {
+    name1: 'Name 1',
+    repeatable: 'Repeatable name',
+  };
+  const fieldFormatter = {
+    repeatable: (value) => `Repeatable: ${value}`,
+  };
+  const contentData = [{
+    changeType: 'ADDED',
+    fieldName: 'name1',
+    newValue: 'added a field',
+  }, {
+    changeType: 'MODIFIED',
+    fieldName: 'repeatable',
+    newValue: {
+      repeatable: 'new repeatable value',
+    },
+    oldValue: {
+      repeatable: 'old repeatable value',
+    },
+  }];
+  const actionsMap = {
+    ADDED: 'ADDED',
+    MODIFIED: 'MODIFIED',
+  };
+
+  beforeEach(async () => {
+    await mountWithContext(
+      <Harness>
+        <AuditLogModal
+          open
+          label="Audit log modal label"
+          onClose={onClose}
+          contentData={contentData}
+          fieldLabelsMap={fieldLabelsMap}
+          fieldFormatter={fieldFormatter}
+          actionsMap={actionsMap}
+        />
+      </Harness>
+    );
+  });
+
+  it('has no axe errors. - AuditLogModal', runAxeTest);
+
+  it('shows correct close button label', () => Button(buttonLabel).exists());
+
+  it('should render a string value as a string', async () => {
+    await mcl.find(MultiColumnListCell({ row: 0, columnIndex: 0 })).perform(el => {
+      expect(el.textContent).to.equal('ADDED');
+    });
+    await mcl.find(MultiColumnListCell({ row: 0, columnIndex: 1 })).perform(el => {
+      expect(el.textContent).to.equal('Name 1');
+    });
+    await mcl.find(MultiColumnListCell({ row: 0, columnIndex: 2 })).perform(el => {
+      expect(el.textContent).to.equal('No value set-');
+    });
+    await mcl.find(MultiColumnListCell({ row: 0, columnIndex: 3 })).perform(el => {
+      expect(el.textContent).to.equal('added a field');
+    });
+  });
+
+  it('should render an object value with mapped field names', async () => {
+    await mcl.find(MultiColumnListCell({ row: 1, columnIndex: 0 })).perform(el => {
+      expect(el.textContent).to.equal('MODIFIED');
+    });
+    await mcl.find(MultiColumnListCell({ row: 1, columnIndex: 1 })).perform(el => {
+      expect(el.textContent).to.equal('Repeatable name');
+    });
+    await mcl.find(MultiColumnListCell({ row: 1, columnIndex: 2 })).perform(el => {
+      expect(el.textContent).to.equal('Repeatable: old repeatable value');
+    });
+    await mcl.find(MultiColumnListCell({ row: 1, columnIndex: 3 })).perform(el => {
+      expect(el.textContent).to.equal('Repeatable: new repeatable value');
+    });
+  });
+
+  describe('when clicking the close button', () => {
+    beforeEach(async () => {
+      await auditLogModal.clickClose();
+    });
+
+    it('the onClose callback should be fired', () => converge(() => onClose.called));
+  });
+});
diff --git a/lib/AuditLog/tests/AuditLogPane-test.js b/lib/AuditLog/tests/AuditLogPane-test.js
new file mode 100644
index 000000000..3c993d5a3
--- /dev/null
+++ b/lib/AuditLog/tests/AuditLogPane-test.js
@@ -0,0 +1,110 @@
+import React from 'react';
+import { describe, beforeEach, it } from 'mocha';
+import sinon from 'sinon';
+import { expect } from 'chai';
+
+import {
+  runAxeTest,
+  Card,
+  Pane,
+  Button,
+} from '@folio/stripes-testing';
+
+import { mountWithContext } from '../../../tests/helpers';
+import Harness from '../../../tests/Harness';
+
+import AuditLogPane from '../AuditLogPane';
+
+const fieldLabelsMap = {
+  contributors: 'Contributors',
+  statusId: 'Status id',
+  statusUpdatedDate: 'Status updated date',
+};
+
+const defaultProps = {
+  onClose: sinon.spy(),
+  fieldLabelsMap,
+  isLoading: false,
+};
+
+const RenderAuditLogPane = ({
+  versions = [],
+  ...props
+} = {}) => (
+  <Harness>
+    <AuditLogPane
+      {...defaultProps}
+      {...props}
+      versions={versions}
+    />
+  </Harness>
+);
+
+const mockVersions = [{
+  action: 'CREATE',
+  diff: {
+    fieldChanges: [{
+      fieldName: 'fieldName1',
+      changeType: 'ADDED',
+      newValue: 'newValue1',
+      oldValue: 'oldValue1',
+    }],
+    collectionChanges: [],
+  },
+  eventDate: '2025-02-21T16:18:49.201+00:00',
+}, {
+  action: 'UPDATE',
+  diff: {
+    fieldChanges: [{
+      fieldName: 'statusId',
+      changeType: 'ADDED',
+      newValue: 'newValue2',
+      oldValue: 'oldValue2',
+    }],
+    collectionChanges: [],
+  },
+  eventDate: '2025-02-21T16:18:49.201+00:00',
+}];
+
+describe('AuditLogPane', () => {
+  beforeEach(() => {
+    defaultProps.onClose.resetHistory();
+  });
+
+  describe('should display loading pane if references are loading', () => {
+    beforeEach(async () => {
+      await mountWithContext(<RenderAuditLogPane isLoading />);
+    });
+
+    it('should display loading pane if versions are loading', () => Pane({ title: 'Version history' }).exists());
+    it('should display loading button', () => Button('Loading').exists());
+    it('should display without a11y errors - loading', () => runAxeTest());
+  });
+
+
+  describe('should display loading pane if references are loading', () => {
+    const loadMoreSpy = sinon.spy();
+    beforeEach(async () => {
+      loadMoreSpy.resetHistory();
+      await mountWithContext(
+        <RenderAuditLogPane
+          versions={mockVersions}
+          handleLoadMore={loadMoreSpy}
+        />
+      );
+    });
+
+    it('should display loading pane if versions are loading', () => Pane({ title: 'Version history' }).exists());
+    it('should display cards for entries', () => Card().exists());
+    it('should display without a11y errors - versions displayed', () => runAxeTest());
+    describe('clicking the loadMore button', () => {
+      beforeEach(async () => {
+        await Button('Load more').click();
+      });
+
+      it('calls the handleLoadMore function', () => {
+        expect(loadMoreSpy.called).to.equal(true);
+      });
+    });
+  });
+});
diff --git a/tests/helpers/getHookExecutionResult.js b/tests/helpers/getHookExecutionResult.js
index 076b1e21f..8114a5203 100644
--- a/tests/helpers/getHookExecutionResult.js
+++ b/tests/helpers/getHookExecutionResult.js
@@ -17,9 +17,9 @@ import { mountWithContext } from '../helpers';
 const getHookExecutionResult = (hook, hookArguments = []) => {
   let result = {};
   const TestComponent = () => {
-    const hookResult = Array.isArray(hookArguments)
-      ? hook(...hookArguments)
-      : hook(hookArguments);
+    const hookResult = Array.isArray(hookArguments) ?
+      hook(...hookArguments) :
+      hook(hookArguments);
     switch (typeof hookResult) {
       case 'function':
       case 'string':
@@ -56,6 +56,7 @@ const useHookExecutionResult = (hook, hookParams, comparator = isEqual) => {
  * @param {*} checkEffect Function that will be called each update to result.
  * @param {*} comparator Function to compare two values - a previous value and a potential candidate -
  * for potentially re-rendering the text component/updating state.
+ * @param {*} useFunctionalChildren Hook parameters will be passed via render props from Wrapper component.
  * @returns hook's result, wrapped in a Promise, because that's what mountWithContext
  *   returns and we need to wait for the component that calls the hook to mount and
  *   render.
@@ -65,15 +66,17 @@ export const getHookExecutionHarness = (
   hookArguments = [],
   Wrapper = Fragment,
   checkEffect = noop,
-  comparator = isEqual
+  comparator = isEqual,
+  useFunctionalChildren = false,
 ) => {
   let result = {};
-  const TestComponent = () => {
+  const TestComponent = (paramsFromProps) => {
     const innerResult = useHookExecutionResult(
       hook,
-      Array.isArray(hookArguments)
-        ? hookArguments
-        : [hookArguments],
+      useFunctionalChildren ? [paramsFromProps] :
+        Array.isArray(hookArguments)
+          ? hookArguments
+          : [hookArguments],
       comparator
     );
 
@@ -88,7 +91,10 @@ export const getHookExecutionHarness = (
 
   return mountWithContext(
     <Wrapper>
-      <TestComponent />
+      { useFunctionalChildren ?
+        (props) => <TestComponent {...props} /> :
+        <TestComponent />
+      }
     </Wrapper>
   ).then(() => result);
 };
diff --git a/translations/stripes-components/en.json b/translations/stripes-components/en.json
index 5e8cc5150..b045bc7e1 100644
--- a/translations/stripes-components/en.json
+++ b/translations/stripes-components/en.json
@@ -49,6 +49,14 @@
   "Timepicker.formStart": "Beginning of form",
   "clearThisField": "Clear this field",
   "validatingInProcess": "Validating...",
+  "versionHistory.pane.header": "Version history",
+  "versionHistory.pane.sub": "{count, plural, one {# version} other {# versions}}",
+  "versionHistory.pane.loadingLabel": "Loading",
+  "versionHistory.card.changed": "Changed",
+  "versionHistory.modal.action": "Action",
+  "versionHistory.modal.field": "Field",
+  "versionHistory.modal.changedFrom": "Changed from",
+  "versionHistory.modal.changedTo": "Changed to",
   "fieldIsDisabled": "Field is disabled",
   "fieldIsValid": "Field is valid",
   "fieldHasError": "Field has error",


Reviews:
user: login: JohnC-80
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-04T17:19:15Z
user: login: mariia-aloshyna
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-04T17:35:55Z
user: login: JohnC-80
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-04T23:02:42Z
user: login: OleksandrHladchenko1
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-05T10:42:21Z
user: login: BogdanDenis
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-05T11:37:19Z

Review comments:
diff_hunk: @@ -0,0 +1,42 @@
+import {
+  useEffect,
+  useMemo,
+  useState,
+} from 'react';
+
+const useVersionHistory = ({
+  data,
+  totalRecords,
+  versionsFormatter,
+}) => {
+  const [versions, setVersions] = useState([]);
+
+  const [isLoadedMoreVisible, setIsLoadedMoreVisible] = useState(true);
path: hooks/useVersionHistory/useVersionHistory.js
user: login: JohnC-80
body: pedantic... 
`isLoadMoreVisible`  instead of `isLoadedMoreVisible`
created_at: 2025-03-04T17:19:15Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,42 @@
+import {
+  useEffect,
+  useMemo,
+  useState,
+} from 'react';
+
+const useVersionHistory = ({
+  data,
+  totalRecords,
+  versionsFormatter,
+}) => {
+  const [versions, setVersions] = useState([]);
+
+  const [isLoadedMoreVisible, setIsLoadedMoreVisible] = useState(true);
path: hooks/useVersionHistory/useVersionHistory.js
user: login: mariia-aloshyna
body: right, that was planned to be exactly `isLoadMoreVisible`, thanks
created_at: 2025-03-04T17:35:55Z
author_association: CONTRIBUTOR

PR comments:
user: login: github-actions[bot]
created_at: 2025-02-28T14:11:09Z
body: ## Bigtest Unit Test Results
    1 files  ± 0      1 suites  ±0   15s ⏱️ ±0s
1 554 tests +17  1 546 ✅ +17  8 💤 ±0  0 ❌ ±0 
1 557 runs  +18  1 549 ✅ +18  8 💤 ±0  0 ❌ ±0 

Results for commit 760dd165. ± Comparison against base commit 74ea2c49.

[test-results]:data:application/gzip;base64,H4sIACIcyGcC/03MSwrDIBSF4a0Exx0oUe9NN1NEc0GaxOJjVLr3WrEmw/87cN6M/LYmdp/EbWKp+DzClWiyD8cvVe265LYpJf/5SMXaZlKf9vSvajiAjN8q8AFrjCF2ieXor9DrcroMOj9bXy5bXx9t2HefazDQ3DmhFYnZGQVSckCDmhAXVNoAAcyGULPPF1GLEA8IAQAA

:recycle: This comment has been updated with latest results.
author_association: NONE
user: login: sonarqubecloud[bot]
created_at: 2025-03-05T09:41:49Z
body: ## [![Quality Gate Passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-passed-20px.png 'Quality Gate Passed')](https://sonarcloud.io/dashboard?id=org.folio%3Astripes-components&pullRequest=2433) **Quality Gate passed**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 New issues](https://sonarcloud.io/project/issues?id=org.folio%3Astripes-components&pullRequest=2433&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted-16px.png '') [0 Accepted issues](https://sonarcloud.io/project/issues?id=org.folio%3Astripes-components&pullRequest=2433&issueStatuses=ACCEPTED)

Measures  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=org.folio%3Astripes-components&pullRequest=2433&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [87.1% Coverage on New Code](https://sonarcloud.io/component_measures?id=org.folio%3Astripes-components&pullRequest=2433&metric=new_coverage&view=list)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Duplication on New Code](https://sonarcloud.io/component_measures?id=org.folio%3Astripes-components&pullRequest=2433&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=org.folio%3Astripes-components&pullRequest=2433)


author_association: NONE