From 2345a2d9d41707a2ccdeca4b36f99985cb1cf8a3 Mon Sep 17 00:00:00 2001
From: Vadym Shchekotilin <86330150+vashjs@users.noreply.github.com>
Date: Mon, 16 Dec 2024 14:09:00 +0100
Subject: [PATCH] UIBULKED-562 Include statistical code option on Instances
 bulk edit forms (#663)

---
 CHANGELOG.md                                  |  1 +
 package.json                                  |  4 +-
 .../ContentUpdatesForm/ValuesColumn.js        |  9 ++
 .../ContentUpdatesForm/ValuesColumn.test.js   | 48 ++++++---
 .../InstanceStatisticalCodesControl.js        | 51 ++++++++++
 .../ContentUpdatesForm/helpers.js             | 25 ++++-
 .../ContentUpdatesForm/helpers.test.js        | 44 +++++++++
 src/constants/core.js                         |  1 +
 src/constants/inAppActions.js                 | 15 +++
 src/constants/selectOptions.js                | 12 +++
 src/hooks/api/index.js                        |  1 +
 src/hooks/api/useStatisticalCode.test.js      | 98 +++++++++++++++++++
 src/hooks/api/useStatisticalCodes.js          | 41 ++++++++
 src/utils/helpers.js                          | 15 +++
 src/utils/helpers.test.js                     | 74 ++++++++++++++
 translations/ui-bulk-edit/en.json             |  4 +
 16 files changed, 429 insertions(+), 14 deletions(-)
 create mode 100644 src/components/BulkEditPane/BulkEditListResult/BulkEditInApp/ContentUpdatesForm/controls/InstanceStatisticalCodesControl.js
 create mode 100644 src/hooks/api/useStatisticalCode.test.js
 create mode 100644 src/hooks/api/useStatisticalCodes.js

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9b94cd76..c0ab2c8f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,7 @@
 * [UIBULKED-560](https://folio-org.atlassian.net/browse/UIBULKED-560) Update actions menu.
 * [UIBULKED-585](https://folio-org.atlassian.net/browse/UUIBULKED-585) Adding missing translation and filters.
 * [UIBULKED-561](https://folio-org.atlassian.net/browse/UUIBULKED-561) Add administrative data accordion to MARC bulk edit form.
+* [UIBULKED-562](https://folio-org.atlassian.net/browse/UIBULKED-562) Include statistical code option on Instances bulk edit forms.
 
 ## [4.2.2](https://github.com/folio-org/ui-bulk-edit/tree/v4.2.2) (2024-11-15)
 
diff --git a/package.json b/package.json
index f7c37e7f..bddf384f 100644
--- a/package.json
+++ b/package.json
@@ -78,7 +78,9 @@
           "consortia.publications-results.item.get",
           "consortium-search.institutions.collection.get",
           "consortium-search.campuses.collection.get",
-          "consortium-search.libraries.collection.get"
+          "consortium-search.libraries.collection.get",
+          "inventory-storage.statistical-codes.collection.get",
+          "inventory-storage.statistical-code-types.collection.get"
         ]
       },
       {
diff --git a/src/components/BulkEditPane/BulkEditListResult/BulkEditInApp/ContentUpdatesForm/ValuesColumn.js b/src/components/BulkEditPane/BulkEditListResult/BulkEditInApp/ContentUpdatesForm/ValuesColumn.js
index b81c7b95..e7399dbd 100644
--- a/src/components/BulkEditPane/BulkEditListResult/BulkEditInApp/ContentUpdatesForm/ValuesColumn.js
+++ b/src/components/BulkEditPane/BulkEditListResult/BulkEditInApp/ContentUpdatesForm/ValuesColumn.js
@@ -27,6 +27,7 @@ import { InstanceNotesControl } from './controls/InstanceNotesControl';
 import { ElectronicAccessRelationshipControl } from './controls/ElectronicAccessRelationshipControl';
 import { DuplicateNoteControl } from './controls/DuplicateNotesControl';
 import { StatusControl } from './controls/StatusControl';
+import { InstanceStatisticalCodesControl } from './controls/InstanceStatisticalCodesControl';
 
 
 export const ValuesColumn = ({ action, allActions, actionIndex, onChange, option }) => {
@@ -150,6 +151,13 @@ export const ValuesColumn = ({ action, allActions, actionIndex, onChange, option
     />
   );
 
+  const renderStatisticalCodesSelect = () => controlType === CONTROL_TYPES.STATISTICAL_CODES_SELECT && (
+    <InstanceStatisticalCodesControl
+      actionName={action.name}
+      {...sharedProps}
+    />
+  );
+
   return (
     <>
       {renderTextField()}
@@ -162,6 +170,7 @@ export const ValuesColumn = ({ action, allActions, actionIndex, onChange, option
       {renderNoteTypeSelect()}
       {renderNoteDuplicateTypeSelect()}
       {renderElectronicAccessRelationshipSelect()}
+      {renderStatisticalCodesSelect()}
     </>
   );
 };
diff --git a/src/components/BulkEditPane/BulkEditListResult/BulkEditInApp/ContentUpdatesForm/ValuesColumn.test.js b/src/components/BulkEditPane/BulkEditListResult/BulkEditInApp/ContentUpdatesForm/ValuesColumn.test.js
index c0847241..a5dc00cc 100644
--- a/src/components/BulkEditPane/BulkEditListResult/BulkEditInApp/ContentUpdatesForm/ValuesColumn.test.js
+++ b/src/components/BulkEditPane/BulkEditListResult/BulkEditInApp/ContentUpdatesForm/ValuesColumn.test.js
@@ -9,25 +9,33 @@ import { createMemoryHistory } from 'history';
 import { runAxeTest } from '@folio/stripes-testing';
 import { queryClient } from '../../../../../../test/jest/utils/queryClient';
 import { ValuesColumn } from './ValuesColumn';
-import { useElectronicAccessRelationships, useLoanTypes, usePatronGroup } from '../../../../../hooks/api';
+import {
+  useElectronicAccessRelationships,
+  useLoanTypes,
+  usePatronGroup,
+  useStatisticalCodes
+} from '../../../../../hooks/api';
 import { CAPABILITIES, CONTROL_TYPES } from '../../../../../constants';
 
+
 jest.mock('../../../../../hooks/api/useLoanTypes');
 jest.mock('../../../../../hooks/api/usePatronGroup');
 jest.mock('../../../../../hooks/api/useElectronicAccess');
+jest.mock('../../../../../hooks/api/useStatisticalCodes');
 
 const onChange = jest.fn();
 
 const history = createMemoryHistory();
 
-const mockAction = {
-  type: '',
-  name: 'testName',
-  value: 'testValue',
-};
+const renderComponent = (actionType, override = {}) => {
+  const action = {
+    type: '',
+    name: 'testName',
+    value: 'testValue',
+    controlType: actionType,
+    ...override,
+  };
 
-const renderComponent = (actionType) => {
-  const action = { ...mockAction, controlType: actionType };
   return render(
     <Router history={history}>
       <QueryClientProvider client={queryClient}>
@@ -54,11 +62,11 @@ describe('ValuesColumn Component', () => {
       isElectronicAccessLoading: false,
       electronicAccessRelationships: [],
     });
-  });
 
-  afterEach(() => {
-    usePatronGroup.mockReset();
-    useLoanTypes.mockReset();
+    useStatisticalCodes.mockReturnValue({
+      statisticalCodes: [],
+      isStatisticalCodesLoading: false,
+    });
   });
 
   it('should render TextField when action type is INPUT', async () => {
@@ -191,6 +199,22 @@ describe('ValuesColumn Component', () => {
     await waitFor(() => expect(onChange).toHaveBeenCalled());
   });
 
+  it('should render select with statistical codes when action type is ADD, or REMOVE_SOME', async () => {
+    const { getByRole } = renderComponent(
+      () => CONTROL_TYPES.STATISTICAL_CODES_SELECT,
+      {
+        value: [{ label: 'test', value: 'test' }],
+      }
+    );
+    const element = getByRole('combobox');
+
+    expect(element).toBeInTheDocument();
+
+    fireEvent.change(element, [{ label: 'test', value: 'test' }]);
+
+    await waitFor(() => expect(onChange).toHaveBeenCalled());
+  });
+
   it('should render with no axe errors', async () => {
     renderComponent(() => CONTROL_TYPES.ELECTRONIC_ACCESS_RELATIONSHIP_SELECT);
 
diff --git a/src/components/BulkEditPane/BulkEditListResult/BulkEditInApp/ContentUpdatesForm/controls/InstanceStatisticalCodesControl.js b/src/components/BulkEditPane/BulkEditListResult/BulkEditInApp/ContentUpdatesForm/controls/InstanceStatisticalCodesControl.js
new file mode 100644
index 00000000..954e20f5
--- /dev/null
+++ b/src/components/BulkEditPane/BulkEditListResult/BulkEditInApp/ContentUpdatesForm/controls/InstanceStatisticalCodesControl.js
@@ -0,0 +1,51 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { useIntl } from 'react-intl';
+
+import { Loading, MultiSelection } from '@folio/stripes/components';
+
+import { FIELD_VALUE_KEY, getLabelByValue, sortWithoutPlaceholder } from '../helpers';
+import { useStatisticalCodes } from '../../../../../../hooks/api/useStatisticalCodes';
+import { customMultiSelectionFilter } from '../../../../../../utils/helpers';
+
+
+export const InstanceStatisticalCodesControl = ({ actionName, actionValue, actionIndex, onChange }) => {
+  const { formatMessage } = useIntl();
+
+  const { statisticalCodes, isStatisticalCodesLoading } = useStatisticalCodes();
+  const sortedStatisticalCodes = sortWithoutPlaceholder(statisticalCodes);
+  const title = getLabelByValue(sortedStatisticalCodes, actionValue);
+
+  const handleChange = value => {
+    onChange({
+      actionIndex,
+      value,
+      fieldName: FIELD_VALUE_KEY,
+    });
+  };
+
+  if (isStatisticalCodesLoading) return <Loading size="large" />;
+
+  return (
+    <div title={title}>
+      <MultiSelection
+        key={actionName}
+        id="statisticalCodes"
+        value={actionValue}
+        onChange={handleChange}
+        placeholder={formatMessage({ id: 'ui-bulk-edit.layer.statisticalCode' })}
+        aria-label={formatMessage({ id: 'ui-bulk-edit.ariaLabel.statisticalCode' })}
+        dataOptions={statisticalCodes}
+        dirty={!!actionValue}
+        filter={customMultiSelectionFilter}
+      />
+    </div>
+  );
+};
+
+InstanceStatisticalCodesControl.propTypes = {
+  actionValue: PropTypes.arrayOf(PropTypes.object),
+  actionName: PropTypes.string,
+  actionIndex: PropTypes.number,
+  onChange: PropTypes.func,
+};
diff --git a/src/components/BulkEditPane/BulkEditListResult/BulkEditInApp/ContentUpdatesForm/helpers.js b/src/components/BulkEditPane/BulkEditListResult/BulkEditInApp/ContentUpdatesForm/helpers.js
index be700f78..9dd8a644 100644
--- a/src/components/BulkEditPane/BulkEditListResult/BulkEditInApp/ContentUpdatesForm/helpers.js
+++ b/src/components/BulkEditPane/BulkEditListResult/BulkEditInApp/ContentUpdatesForm/helpers.js
@@ -21,6 +21,7 @@ import {
   noteActionsWithMarc,
   noteActionsWithDuplicate,
   electronicAccess,
+  statisticalCodeActions,
 } from '../../../../../constants';
 import { getActionParameters } from '../../../../../constants/actionParameters';
 
@@ -89,6 +90,7 @@ export const getDefaultActions = ({
   const expirationDefaultActions = expirationActions();
   const holdingsLocationDefaultActions = permanentHoldingsLocation();
   const suppressDefaultActions = suppressFromDiscActions();
+  const statisticalCodeDefaultActions = statisticalCodeActions();
   const statusDefaultActions = statusActions();
   const loanDefaultActions = permanentLoanTypeActions();
   const noteDefaultActions = noteActions();
@@ -196,6 +198,19 @@ export const getDefaultActions = ({
           },
         ],
       };
+    case OPTIONS.STATISTICAL_CODE:
+      return {
+        type: '',
+        actions: [
+          null,
+          {
+            actionsList: statisticalCodeDefaultActions,
+            controlType: () => CONTROL_TYPES.STATISTICAL_CODES_SELECT,
+            [ACTION_VALUE_KEY]: statisticalCodeDefaultActions[0].value,
+            [FIELD_VALUE_KEY]: '',
+          },
+        ],
+      };
     case OPTIONS.STAFF_SUPPRESS:
       return {
         type: '',
@@ -425,6 +440,8 @@ export const getLabelByValue = (items, targetValue) => {
 };
 
 export const sortWithoutPlaceholder = (array) => {
+  if (!array.length) return [];
+
   const [placeholder, ...rest] = array;
 
   return [placeholder, ...rest.sort((a, b) => a.label.localeCompare(b.label))];
@@ -433,7 +450,13 @@ export const sortWithoutPlaceholder = (array) => {
 export const getMappedContentUpdates = (fields, options) => fields.map(({
   parameters, tenants, option, actionsDetails: { actions }
 }) => {
-  const [initial, updated] = actions.map(action => action?.value ?? null);
+  const [initial, updated] = actions.map(action => {
+    if (Array.isArray(action?.value)) {
+      return action.value.map(item => item?.value).join(',');
+    }
+
+    return action?.value || null;
+  });
   const actionTenants = actions.map(action => action?.tenants);
   const sourceOption = options.find(o => o.value === option);
   const optionType = sourceOption?.type;
diff --git a/src/components/BulkEditPane/BulkEditListResult/BulkEditInApp/ContentUpdatesForm/helpers.test.js b/src/components/BulkEditPane/BulkEditListResult/BulkEditInApp/ContentUpdatesForm/helpers.test.js
index 277e8fd2..ce8fbf5b 100644
--- a/src/components/BulkEditPane/BulkEditListResult/BulkEditInApp/ContentUpdatesForm/helpers.test.js
+++ b/src/components/BulkEditPane/BulkEditListResult/BulkEditInApp/ContentUpdatesForm/helpers.test.js
@@ -879,6 +879,50 @@ describe('ContentUpdatesForm helpers', () => {
           );
       });
 
+      it('returns the correct object for the ELECTRONIC_ACCESS_MATERIALS_SPECIFIED option', () => {
+        expect(JSON.stringify(getDefaultActions({
+          option: OPTIONS.STATISTICAL_CODE,
+          options: [],
+          formatMessage,
+          capability: CAPABILITIES.INSTANCE
+        })))
+          .toEqual(
+            JSON.stringify({
+              type: '',
+              actions: [
+                null,
+                {
+                  actionsList: [
+                    {
+                      value: '',
+                      label: <FormattedMessage id="ui-bulk-edit.actions.placeholder" />,
+                      disabled: true,
+                    },
+                    {
+                      value: ACTIONS.ADD_TO_EXISTING,
+                      label: <FormattedMessage id="ui-bulk-edit.layer.options.add" />,
+                      disabled: false,
+                    },
+                    {
+                      value: ACTIONS.REMOVE_SOME,
+                      label: <FormattedMessage id="ui-bulk-edit.layer.options.items.removeNote" />,
+                      disabled: false,
+                    },
+                    {
+                      value: ACTIONS.REMOVE_ALL,
+                      label: <FormattedMessage id="ui-bulk-edit.layer.options.items.removeAll" />,
+                      disabled: false,
+                    },
+                  ],
+                  controlType: () => CONTROL_TYPES.STATISTICAL_CODES_SELECT,
+                  [ACTION_VALUE_KEY]: '',
+                  [FIELD_VALUE_KEY]: '',
+                },
+              ],
+            }),
+          );
+      });
+
       it('returns the correct object for the default case', () => {
         expect(getDefaultActions({
           option: 'unknown',
diff --git a/src/constants/core.js b/src/constants/core.js
index a9c05874..e57e0ffd 100644
--- a/src/constants/core.js
+++ b/src/constants/core.js
@@ -94,6 +94,7 @@ export const CONTROL_TYPES = {
   NOTE_SELECT: 'NOTE_SELECT',
   NOTE_DUPLICATE_SELECT: 'NOTE_DUPLICATE_SELECT',
   ELECTRONIC_ACCESS_RELATIONSHIP_SELECT: 'ELECTRONIC_ACCESS_RELATIONSHIP_SELECT',
+  STATISTICAL_CODES_SELECT: 'STATISTICAL_CODES_SELECT',
 };
 
 export const TRANSLATION_SUFFIX = {
diff --git a/src/constants/inAppActions.js b/src/constants/inAppActions.js
index cdb953c3..18d7c32b 100644
--- a/src/constants/inAppActions.js
+++ b/src/constants/inAppActions.js
@@ -12,6 +12,7 @@ export const ACTIONS = {
   MARK_AS_STAFF_ONLY: 'MARK_AS_STAFF_ONLY',
   REMOVE_MARK_AS_STAFF_ONLY: 'REMOVE_MARK_AS_STAFF_ONLY',
   REMOVE_ALL: 'REMOVE_ALL',
+  REMOVE_SOME: 'REMOVE_SOME',
   CHANGE_TYPE: 'CHANGE_TYPE',
   DUPLICATE: 'DUPLICATE',
 
@@ -119,6 +120,12 @@ export const getRemoveTheseAction = () => ({
   disabled: false,
 });
 
+export const getRemoveSomeAction = () => ({
+  value: ACTIONS.REMOVE_SOME,
+  label: <FormattedMessage id="ui-bulk-edit.layer.options.items.removeNote" />,
+  disabled: false,
+});
+
 export const getDuplicateToNoteAction = () => ({
   value: ACTIONS.DUPLICATE,
   label: <FormattedMessage id="ui-bulk-edit.layer.options.items.duplicateTo" />,
@@ -142,6 +149,14 @@ export const suppressFromDiscActions = () => [
   getSetToTrueAction(),
   getSetToFalseAction(),
 ];
+
+export const statisticalCodeActions = () => [
+  getPlaceholder(),
+  getAddAction(),
+  getRemoveSomeAction(),
+  getRemoveAllAction(),
+];
+
 export const noteActions = () => [
   getPlaceholder(),
   getAddToExistingAction(),
diff --git a/src/constants/selectOptions.js b/src/constants/selectOptions.js
index 772ea434..9af1c767 100644
--- a/src/constants/selectOptions.js
+++ b/src/constants/selectOptions.js
@@ -10,6 +10,7 @@ export const OPTIONS = {
   TEMPORARY_LOCATION: 'TEMPORARY_LOCATION',
   PERMANENT_LOCATION: 'PERMANENT_LOCATION',
   SUPPRESS_FROM_DISCOVERY: 'SUPPRESS_FROM_DISCOVERY',
+  STATISTICAL_CODE: 'STATISTICAL_CODE',
   STAFF_SUPPRESS: 'STAFF_SUPPRESS',
   STATUS: 'STATUS',
   EXPIRATION_DATE: 'EXPIRATION_DATE',
@@ -287,6 +288,12 @@ export const getInstanceOptions = (formatMessage, instanceNotes) => [
     disabled: false,
     categoryName: formatMessage({ id: 'ui-bulk-edit.category.administrativeData' }),
   },
+  {
+    value: OPTIONS.STATISTICAL_CODE,
+    label: formatMessage({ id: 'ui-bulk-edit.layer.options.instances.statisticalCode' }),
+    disabled: false,
+    categoryName: formatMessage({ id: 'ui-bulk-edit.category.administrativeData' }),
+  },
   ...instanceNotes
 ];
 
@@ -306,6 +313,11 @@ export const getAdministrativeDataOptions = (formatMessage) => [
     label: formatMessage({ id: 'ui-bulk-edit.layer.options.instances.suppress' }),
     disabled: false,
   },
+  {
+    value: OPTIONS.STATISTICAL_CODE,
+    label: formatMessage({ id: 'ui-bulk-edit.layer.options.instances.statisticalCode' }),
+    disabled: false,
+  },
 ];
 
 export const getHoldingsNotes = (formatMessage, holdingsNotes) => [
diff --git a/src/hooks/api/index.js b/src/hooks/api/index.js
index 81dda79f..cc770080 100644
--- a/src/hooks/api/index.js
+++ b/src/hooks/api/index.js
@@ -20,3 +20,4 @@ export * from './useHoldingsNotesEcs';
 export * from './useLocationEcs';
 export * from './useLoanTypesEcs';
 export * from './useElectronicAccessEcs';
+export * from './useStatisticalCodes';
diff --git a/src/hooks/api/useStatisticalCode.test.js b/src/hooks/api/useStatisticalCode.test.js
new file mode 100644
index 00000000..a8539705
--- /dev/null
+++ b/src/hooks/api/useStatisticalCode.test.js
@@ -0,0 +1,98 @@
+import { QueryClient, QueryClientProvider } from 'react-query';
+import { renderHook } from '@testing-library/react-hooks';
+
+import { useNamespace, useOkapiKy } from '@folio/stripes/core';
+
+import { useErrorMessages } from '../useErrorMessages';
+import { useStatisticalCodes, STATISTICAL_CODES_KEY } from './useStatisticalCodes';
+import { getMappedStatisticalCodes } from '../../utils/helpers';
+
+
+jest.mock('@folio/stripes/core', () => ({
+  useNamespace: jest.fn(),
+  useOkapiKy: jest.fn(),
+}));
+
+jest.mock('../useErrorMessages', () => ({
+  useErrorMessages: jest.fn(),
+}));
+
+jest.mock('../../utils/helpers', () => ({
+  getMappedStatisticalCodes: jest.fn(),
+}));
+
+describe('useStatisticalCodes', () => {
+  const queryClient = new QueryClient();
+  const wrapper = ({ children }) => (
+    <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
+  );
+
+  const mockNamespaceKey = `${STATISTICAL_CODES_KEY}_namespace`;
+  const mockKy = {
+    get: jest.fn(),
+  };
+  const mockShowErrorMessage = jest.fn();
+  const mockMappedStatisticalCodes = [{ id: '1', name: 'Code 1' }];
+
+  beforeEach(() => {
+    jest.clearAllMocks();
+
+    useNamespace.mockReturnValue([mockNamespaceKey]);
+    useOkapiKy.mockReturnValue(mockKy);
+    useErrorMessages.mockReturnValue({ showErrorMessage: mockShowErrorMessage });
+    getMappedStatisticalCodes.mockReturnValue(mockMappedStatisticalCodes);
+  });
+
+  it('should fetch and return statistical codes data', async () => {
+    const statisticalCodeTypes = [{ id: 'type1', name: 'Type 1' }];
+    const statisticalCodes = [{ id: 'code1', name: 'Code 1' }];
+
+    mockKy.get.mockImplementation((url) => {
+      if (url === 'statistical-code-types') {
+        return {
+          json: async () => ({ statisticalCodeTypes }),
+        };
+      }
+
+      if (url === 'statistical-codes') {
+        return {
+          json: async () => ({ statisticalCodes }),
+        };
+      }
+
+      throw new Error('Unexpected URL');
+    });
+
+    const { result, waitFor } = renderHook(() => useStatisticalCodes(), { wrapper });
+
+    await waitFor(() => !result.current.isStatisticalCodesLoading);
+
+    expect(result.current.statisticalCodes).toEqual(mockMappedStatisticalCodes);
+    expect(result.current.isStatisticalCodesLoading).toBe(false);
+    expect(mockKy.get).toHaveBeenCalledWith('statistical-code-types', {
+      searchParams: { query: 'cql.allRecords=1', limit: 1000 },
+    });
+    expect(mockKy.get).toHaveBeenCalledWith('statistical-codes', {
+      searchParams: { query: 'cql.allRecords=1', limit: 1000 },
+    });
+    expect(getMappedStatisticalCodes).toHaveBeenCalledWith([
+      statisticalCodeTypes,
+      statisticalCodes,
+    ]);
+  });
+
+  it('should handle errors and call showErrorMessage', async () => {
+    const error = new Error('Failed to fetch');
+    mockKy.get.mockRejectedValue(error);
+
+    const { result, waitFor } = renderHook(() => useStatisticalCodes(), { wrapper });
+
+    await waitFor(() => !result.current.isStatisticalCodesLoading);
+
+    expect(result.current.statisticalCodes).toEqual([{
+      id: '1',
+      name: 'Code 1',
+    }]);
+    expect(result.current.isStatisticalCodesLoading).toBe(false);
+  });
+});
diff --git a/src/hooks/api/useStatisticalCodes.js b/src/hooks/api/useStatisticalCodes.js
new file mode 100644
index 00000000..19e8cce5
--- /dev/null
+++ b/src/hooks/api/useStatisticalCodes.js
@@ -0,0 +1,41 @@
+import { useQuery } from 'react-query';
+
+import { useNamespace, useOkapiKy } from '@folio/stripes/core';
+
+import { useErrorMessages } from '../useErrorMessages';
+import { getMappedStatisticalCodes } from '../../utils/helpers';
+
+
+export const STATISTICAL_CODES_KEY = 'STATISTICAL_CODES_KEY';
+
+export const useStatisticalCodes = (options = {}) => {
+  const ky = useOkapiKy();
+  const [namespaceKey] = useNamespace({ key: STATISTICAL_CODES_KEY });
+  const { showErrorMessage } = useErrorMessages();
+
+  const sharedParams = { searchParams: { query: 'cql.allRecords=1', limit: 1000 } };
+
+  const { data, isLoading: isStatisticalCodesLoading } = useQuery(
+    {
+      queryKey: [namespaceKey],
+      cacheTime: Infinity,
+      staleTime: Infinity,
+      queryFn: () => Promise.all([
+        ky.get('statistical-code-types', sharedParams).json()
+          .then(response => response.statisticalCodeTypes),
+        ky.get('statistical-codes', sharedParams).json()
+          .then(response => response.statisticalCodes),
+      ]),
+      select: getMappedStatisticalCodes,
+      onError: showErrorMessage,
+      ...options,
+    },
+  );
+
+  const statisticalCodes = data || [];
+
+  return {
+    statisticalCodes,
+    isStatisticalCodesLoading,
+  };
+};
diff --git a/src/utils/helpers.js b/src/utils/helpers.js
index 6f988857..fc50bf25 100644
--- a/src/utils/helpers.js
+++ b/src/utils/helpers.js
@@ -110,6 +110,10 @@ export const customFilter = (value, dataOptions) => {
   }, []);
 };
 
+export const customMultiSelectionFilter = (value, dataOptions) => {
+  return { renderedItems: customFilter(value, dataOptions) };
+};
+
 export const setIn = (obj, path, value) => {
   return setWith(clone(obj), path, value, clone);
 };
@@ -176,3 +180,14 @@ export const getTransformedLogsFilterValue = (values) => {
 
   return Array.from(result);
 };
+
+export const getMappedStatisticalCodes = ([statisticalCodeTypes, statisticalCodesArr]) => {
+  return statisticalCodesArr.map((statisticalCode) => {
+    const type = statisticalCodeTypes.find((codeType) => codeType.id === statisticalCode.statisticalCodeTypeId);
+
+    return {
+      label: `${type.name}: ${statisticalCode.code} - ${statisticalCode.name}`,
+      value: statisticalCode.id,
+    };
+  });
+};
diff --git a/src/utils/helpers.test.js b/src/utils/helpers.test.js
index ec94aa94..fbd91dd8 100644
--- a/src/utils/helpers.test.js
+++ b/src/utils/helpers.test.js
@@ -1,5 +1,6 @@
 import {
   customFilter,
+  getMappedStatisticalCodes,
   getTenantsById,
   getTransformedLogsFilterValue,
   removeDuplicatesByValue
@@ -280,3 +281,76 @@ describe('getTransformedLogsFilterValue', () => {
     expect(result).toContain('other_value');
   });
 });
+
+describe('getMappedStatisticalCodes', () => {
+  test('should correctly map statistical codes with corresponding types', () => {
+    const statisticalCodeTypes = [
+      { id: '1', name: 'Type A' },
+      { id: '2', name: 'Type B' },
+    ];
+
+    const statisticalCodesArr = [
+      { id: 'code1', statisticalCodeTypeId: '1', code: '001', name: 'Code One' },
+      { id: 'code2', statisticalCodeTypeId: '2', code: '002', name: 'Code Two' },
+    ];
+
+    const expected = [
+      { label: 'Type A: 001 - Code One', value: 'code1' },
+      { label: 'Type B: 002 - Code Two', value: 'code2' },
+    ];
+
+    const result = getMappedStatisticalCodes([statisticalCodeTypes, statisticalCodesArr]);
+    expect(result).toEqual(expected);
+  });
+
+  test('should return an empty array when statisticalCodesArr is empty', () => {
+    const statisticalCodeTypes = [
+      { id: '1', name: 'Type A' },
+      { id: '2', name: 'Type B' },
+    ];
+
+    const statisticalCodesArr = [];
+
+    const expected = [];
+
+    const result = getMappedStatisticalCodes([statisticalCodeTypes, statisticalCodesArr]);
+    expect(result).toEqual(expected);
+  });
+
+  test('should throw an error when a statistical code has no matching type', () => {
+    const statisticalCodeTypes = [
+      { id: '1', name: 'Type A' },
+    ];
+
+    const statisticalCodesArr = [
+      { id: 'code1', statisticalCodeTypeId: '2', code: '002', name: 'Code Two' },
+    ];
+
+    expect(() => {
+      getMappedStatisticalCodes([statisticalCodeTypes, statisticalCodesArr]);
+    }).toThrow(TypeError);
+  });
+
+  test('should correctly map multiple statistical codes', () => {
+    const statisticalCodeTypes = [
+      { id: '1', name: 'Type A' },
+      { id: '2', name: 'Type B' },
+      { id: '3', name: 'Type C' },
+    ];
+
+    const statisticalCodesArr = [
+      { id: 'code1', statisticalCodeTypeId: '1', code: '001', name: 'Code One' },
+      { id: 'code2', statisticalCodeTypeId: '2', code: '002', name: 'Code Two' },
+      { id: 'code3', statisticalCodeTypeId: '3', code: '003', name: 'Code Three' },
+    ];
+
+    const expected = [
+      { label: 'Type A: 001 - Code One', value: 'code1' },
+      { label: 'Type B: 002 - Code Two', value: 'code2' },
+      { label: 'Type C: 003 - Code Three', value: 'code3' },
+    ];
+
+    const result = getMappedStatisticalCodes([statisticalCodeTypes, statisticalCodesArr]);
+    expect(result).toEqual(expected);
+  });
+});
diff --git a/translations/ui-bulk-edit/en.json b/translations/ui-bulk-edit/en.json
index a6fbfcd7..1d8c16bd 100644
--- a/translations/ui-bulk-edit/en.json
+++ b/translations/ui-bulk-edit/en.json
@@ -326,6 +326,7 @@
   "columns.INSTANCE.Publication frequency": "Publication frequency",
   "columns.INSTANCE.Publication range": "Publication range",
   "columns.INSTANCE.Notes" : "Notes",
+  "columns.INSTANCE.Statistical code" : "Statistical code",
 
   "columns.logs.hrId": "ID",
   "columns.logs.operationType": "Bulk operation type",
@@ -384,6 +385,7 @@
   "layer.options.holdings.urlPublic": "URL public note",
   "layer.options.instances.staffSuppress": "Staff suppress",
   "layer.options.instances.suppress": "Suppress from discovery",
+  "layer.options.instances.statisticalCode": "Statistical code",
   "layer.options.expirationDate": "Expiration date",
   "layer.options.email": "Email",
   "layer.options.patronGroup": "Patron group",
@@ -409,6 +411,7 @@
   "layer.selectLocation": "Select location",
   "layer.selectPatronGroup": "Select patron group",
   "layer.selectLoanType": "Select loan type",
+  "layer.statisticalCode": "Select statistical code",
   "layer.selectNoteType": "Select note type",
   "layer.selectType": "Select note type",
   "layer.options.items.true": "Set true",
@@ -517,6 +520,7 @@
   "ariaLabel.location": "Location",
   "ariaLabel.statusSelect": "Status select",
   "ariaLabel.loanTypeSelect": "Loan type select",
+  "ariaLabel.statisticalCode": "Statistical code select",
   "ariaLabel.urlRelationshipSelect": "Url relationship select",
   "ariaLabel.columnFilter": "Column filter input",