diff --git a/cypress/e2e/with_api/items/functions.ts b/cypress/e2e/with_api/items/functions.ts
index c9f152094..53144fb23 100644
--- a/cypress/e2e/with_api/items/functions.ts
+++ b/cypress/e2e/with_api/items/functions.ts
@@ -284,3 +284,27 @@ export const editItem = () => {
system: 'optics 1',
});
};
+
+export const spareDefinition = () => {
+ cy.visit('/admin-ims');
+
+ cy.findByText('Spares definition').click();
+
+ cy.findByRole('combobox').click();
+ cy.findByRole('option', { name: 'Used' }).click();
+ cy.findByRole('combobox').click();
+ cy.findByRole('option', { name: 'New' }).click();
+
+ cy.findByLabelText('Confirm understanding and proceed checkbox').click();
+
+ cy.findByRole('button', { name: 'Save' }).click();
+
+ cy.findByRole('dialog').should('not.exist');
+
+ cy.findByText('Spares definition').click();
+
+ cy.findByText('New').should('exist');
+ cy.findByText('Used').should('exist');
+
+ cy.go('back');
+};
diff --git a/cypress/e2e/with_api/items/items.cy.ts b/cypress/e2e/with_api/items/items.cy.ts
index ea08b3501..5e3bc72af 100644
--- a/cypress/e2e/with_api/items/items.cy.ts
+++ b/cypress/e2e/with_api/items/items.cy.ts
@@ -8,9 +8,10 @@ import {
addItem,
addProperty,
deleteItem,
+ duplicateItem,
editItem,
editProperty,
- duplicateItem,
+ spareDefinition,
} from './functions';
describe('items', () => {
@@ -47,6 +48,7 @@ describe('items', () => {
'systems',
'units',
'usage_statuses',
+ 'settings',
]);
});
@@ -56,6 +58,7 @@ describe('items', () => {
duplicateItem('MX4332424', 0);
addProperty();
editProperty();
+ spareDefinition();
deleteItem('MX4332424', 0);
deleteItem('MX4332424', 0);
});
diff --git a/cypress/e2e/with_mock_data/admin.cy.ts b/cypress/e2e/with_mock_data/admin.cy.ts
index 59f4872d9..20bc174fd 100644
--- a/cypress/e2e/with_mock_data/admin.cy.ts
+++ b/cypress/e2e/with_mock_data/admin.cy.ts
@@ -1,10 +1,266 @@
describe('Admin Page', () => {
- beforeEach(() => {
- cy.visit('/admin-ims');
- });
-
it('should render admin page correctly', () => {
+ cy.visit('/admin-ims');
cy.findByText('Units').should('be.visible');
cy.findByText('Usage Statuses').should('be.visible');
});
+
+ describe('UsageStatus', () => {
+ beforeEach(() => {
+ cy.visit('/admin-ims/usage-statuses');
+ });
+ afterEach(() => {
+ cy.clearMocks();
+ });
+
+ it('should render table correctly', () => {
+ cy.findByText('Value').should('be.visible');
+ cy.findByText('Last modified').should('be.visible');
+ cy.findByText('Created').should('be.visible');
+
+ cy.findByText('New').should('be.visible');
+ });
+
+ it('adds a usage status and deals with errors correctly', () => {
+ cy.findByRole('button', { name: 'Add Usage Status' }).click();
+
+ cy.findByRole('button', { name: 'Save' }).click();
+ cy.findByText('Please enter a value.').should('be.visible');
+
+ cy.findByLabelText('Value *').type('test_dup');
+ cy.findByRole('button', { name: 'Save' }).click();
+ cy.findByText(
+ 'A usage status with the same value already exists. Please enter a different value.'
+ ).should('be.visible');
+
+ cy.findByLabelText('Value *').clear();
+ cy.findByLabelText('Value *').type('test');
+
+ cy.startSnoopingBrowserMockedRequest();
+
+ cy.findByRole('button', { name: 'Save' }).click();
+ cy.findByRole('dialog').should('not.exist');
+
+ cy.findBrowserMockedRequests({
+ method: 'POST',
+ url: '/v1/usage-statuses',
+ }).should(async (postRequests) => {
+ expect(postRequests.length).equal(1);
+ const request = postRequests[0];
+ expect(JSON.stringify(await request.json())).equal('{"value":"test"}');
+ });
+ });
+
+ it('deletes a usage status', () => {
+ cy.findAllByLabelText('Row Actions').first().click();
+ cy.findByText('Delete').click();
+
+ cy.startSnoopingBrowserMockedRequest();
+
+ cy.findByRole('button', { name: 'Continue' }).click();
+
+ cy.findBrowserMockedRequests({
+ method: 'DELETE',
+ url: '/v1/usage-statuses/:id',
+ }).should((patchRequests) => {
+ expect(patchRequests.length).equal(1);
+ const request = patchRequests[0];
+ expect(request.url.toString()).to.contain('1');
+ });
+ });
+
+ it('shows error if trying to delete a unit that is in a catalogue category', () => {
+ cy.findAllByLabelText('Row Actions').eq(2).click();
+ cy.findByText('Delete').click();
+
+ cy.findByRole('button', { name: 'Continue' }).click();
+
+ cy.findByText(
+ 'This usage status is currently used by one or more items. Remove all uses before deleting it here.'
+ );
+ });
+ });
+ describe('Units', () => {
+ beforeEach(() => {
+ cy.visit('/admin-ims/units');
+ });
+ afterEach(() => {
+ cy.clearMocks();
+ });
+
+ it('should render table correctly', () => {
+ cy.findByText('Value').should('be.visible');
+ cy.findByText('Last modified').should('be.visible');
+ cy.findByText('Created').should('be.visible');
+
+ cy.findByText('megapixels').should('be.visible');
+ });
+
+ it('adds a unit and deals with errors correctly', () => {
+ cy.findByRole('button', { name: 'Add Unit' }).click();
+
+ cy.findByRole('button', { name: 'Save' }).click();
+ cy.findByText('Please enter a value.').should('be.visible');
+
+ cy.findByLabelText('Value *').type('test_dup');
+ cy.findByRole('button', { name: 'Save' }).click();
+ cy.findByText(
+ 'A unit with the same value already exists. Please enter a different value.'
+ ).should('be.visible');
+
+ cy.findByLabelText('Value *').clear();
+ cy.findByLabelText('Value *').type('test');
+
+ cy.startSnoopingBrowserMockedRequest();
+
+ cy.findByRole('button', { name: 'Save' }).click();
+ cy.findByRole('dialog').should('not.exist');
+
+ cy.findBrowserMockedRequests({
+ method: 'POST',
+ url: '/v1/units',
+ }).should(async (postRequests) => {
+ expect(postRequests.length).equal(1);
+ const request = postRequests[0];
+ expect(JSON.stringify(await request.json())).equal('{"value":"test"}');
+ });
+ });
+
+ it('deletes a unit', () => {
+ cy.findAllByLabelText('Row Actions').first().click();
+ cy.findByText('Delete').click();
+
+ cy.startSnoopingBrowserMockedRequest();
+
+ cy.findByRole('button', { name: 'Continue' }).click();
+
+ cy.findBrowserMockedRequests({
+ method: 'DELETE',
+ url: '/v1/units/:id',
+ }).should((patchRequests) => {
+ expect(patchRequests.length).equal(1);
+ const request = patchRequests[0];
+ expect(request.url.toString()).to.contain('1');
+ });
+ });
+
+ it('shows error if trying to delete a unit that is in a catalogue category', () => {
+ cy.findAllByLabelText('Row Actions').eq(1).click();
+ cy.findByText('Delete').click();
+
+ cy.findByRole('button', { name: 'Continue' }).click();
+
+ cy.findByText(
+ 'This unit is currently used by one or more catalogue categories. Remove all uses before deleting it here.'
+ );
+ });
+ });
+
+ describe('Spares Definition', () => {
+ beforeEach(() => {
+ cy.visit('/admin-ims');
+ });
+ afterEach(() => {
+ cy.clearMocks();
+ });
+
+ it('disables save button when checkbox is not checked', () => {
+ cy.findByText('Spares definition').click();
+
+ cy.findByRole('button', { name: 'Save' }).should('be.disabled');
+
+ cy.findByLabelText('Confirm understanding and proceed checkbox').click();
+
+ cy.findByRole('button', { name: 'Save' }).should('be.enabled');
+ });
+
+ it('closes the dialog when Cancel button is clicked', () => {
+ cy.findByText('Spares definition').click();
+
+ cy.findByRole('dialog').should('exist');
+
+ cy.findByRole('button', { name: 'Cancel' }).click();
+
+ cy.findByRole('dialog').should('not.exist');
+ });
+
+ it('should modify spares definition', () => {
+ cy.findByText('Spares definition').click();
+
+ cy.findByRole('combobox').click();
+ cy.findByRole('option', { name: 'Scrapped' }).click();
+
+ cy.findByLabelText('Confirm understanding and proceed checkbox').click();
+ cy.startSnoopingBrowserMockedRequest();
+
+ cy.findByRole('button', { name: 'Save' }).click();
+ cy.findByRole('dialog').should('not.exist');
+
+ cy.findBrowserMockedRequests({
+ method: 'PUT',
+ url: '/v1/settings/spares_definition',
+ }).should(async (postRequests) => {
+ expect(postRequests.length).equal(1);
+ const request = postRequests[0];
+ expect(JSON.stringify(await request.json())).equal(
+ JSON.stringify({
+ usage_statuses: [
+ {
+ id: '0',
+ },
+ {
+ id: '2',
+ },
+ {
+ id: '3',
+ },
+ ],
+ })
+ );
+ });
+ });
+
+ it('displays error message if spares definition has not changed and clears when value is changed', () => {
+ cy.findByText('Spares definition').click();
+
+ cy.findByLabelText('Confirm understanding and proceed checkbox').click();
+
+ cy.findByRole('button', { name: 'Save' }).click();
+
+ cy.findByText(
+ 'No changes detected in the spares definition. Please update the spares definition or select Cancel to exit.'
+ ).should('exist');
+
+ cy.findByRole('combobox').click();
+ cy.findByRole('option', { name: 'Scrapped' }).click();
+
+ cy.findByText(
+ 'No changes detected in the spares definition. Please update the spares definition or select Cancel to exit.'
+ ).should('not.exist');
+ });
+
+ it('displays error message if spares definition has less then 1 usage status and clears when value is changed', () => {
+ cy.findByText('Spares definition').click();
+
+ cy.findByRole('combobox').click();
+ cy.findByRole('option', { name: 'Used' }).click();
+ cy.findByRole('combobox').click();
+ cy.findByRole('option', { name: 'New' }).click();
+
+ cy.findByLabelText('Confirm understanding and proceed checkbox').click();
+
+ cy.findByRole('button', { name: 'Save' }).click();
+
+ cy.findByText(
+ 'The list must have at least one item. Please add a usage status.'
+ ).should('exist');
+
+ cy.findByRole('combobox').click();
+ cy.findByRole('option', { name: 'Scrapped' }).click();
+
+ cy.findByText(
+ 'The list must have at least one item. Please add a usage status.'
+ ).should('not.exist');
+ });
+ });
});
diff --git a/cypress/e2e/with_mock_data/units.cy.ts b/cypress/e2e/with_mock_data/units.cy.ts
deleted file mode 100644
index cf766bf2d..000000000
--- a/cypress/e2e/with_mock_data/units.cy.ts
+++ /dev/null
@@ -1,75 +0,0 @@
-describe('Units', () => {
- beforeEach(() => {
- cy.visit('/admin-ims/units');
- });
- afterEach(() => {
- cy.clearMocks();
- });
-
- it('should render table correctly', () => {
- cy.findByText('Value').should('be.visible');
- cy.findByText('Last modified').should('be.visible');
- cy.findByText('Created').should('be.visible');
-
- cy.findByText('megapixels').should('be.visible');
- });
-
- it('adds a unit and deals with errors correctly', () => {
- cy.findByRole('button', { name: 'Add Unit' }).click();
-
- cy.findByRole('button', { name: 'Save' }).click();
- cy.findByText('Please enter a value.').should('be.visible');
-
- cy.findByLabelText('Value *').type('test_dup');
- cy.findByRole('button', { name: 'Save' }).click();
- cy.findByText(
- 'A unit with the same value already exists. Please enter a different value.'
- ).should('be.visible');
-
- cy.findByLabelText('Value *').clear();
- cy.findByLabelText('Value *').type('test');
-
- cy.startSnoopingBrowserMockedRequest();
-
- cy.findByRole('button', { name: 'Save' }).click();
- cy.findByRole('dialog').should('not.exist');
-
- cy.findBrowserMockedRequests({
- method: 'POST',
- url: '/v1/units',
- }).should(async (postRequests) => {
- expect(postRequests.length).equal(1);
- const request = postRequests[0];
- expect(JSON.stringify(await request.json())).equal('{"value":"test"}');
- });
- });
-
- it('deletes a unit', () => {
- cy.findAllByLabelText('Row Actions').first().click();
- cy.findByText('Delete').click();
-
- cy.startSnoopingBrowserMockedRequest();
-
- cy.findByRole('button', { name: 'Continue' }).click();
-
- cy.findBrowserMockedRequests({
- method: 'DELETE',
- url: '/v1/units/:id',
- }).should((patchRequests) => {
- expect(patchRequests.length).equal(1);
- const request = patchRequests[0];
- expect(request.url.toString()).to.contain('1');
- });
- });
-
- it('shows error if trying to delete a unit that is in a catalogue category', () => {
- cy.findAllByLabelText('Row Actions').eq(1).click();
- cy.findByText('Delete').click();
-
- cy.findByRole('button', { name: 'Continue' }).click();
-
- cy.findByText(
- 'This unit is currently used by one or more catalogue categories. Remove all uses before deleting it here.'
- );
- });
-});
diff --git a/cypress/e2e/with_mock_data/usageStatus.cy.ts b/cypress/e2e/with_mock_data/usageStatus.cy.ts
deleted file mode 100644
index c762cace8..000000000
--- a/cypress/e2e/with_mock_data/usageStatus.cy.ts
+++ /dev/null
@@ -1,75 +0,0 @@
-describe('UsageStatus', () => {
- beforeEach(() => {
- cy.visit('/admin-ims/usage-statuses');
- });
- afterEach(() => {
- cy.clearMocks();
- });
-
- it('should render table correctly', () => {
- cy.findByText('Value').should('be.visible');
- cy.findByText('Last modified').should('be.visible');
- cy.findByText('Created').should('be.visible');
-
- cy.findByText('New').should('be.visible');
- });
-
- it('adds a usage status and deals with errors correctly', () => {
- cy.findByRole('button', { name: 'Add Usage Status' }).click();
-
- cy.findByRole('button', { name: 'Save' }).click();
- cy.findByText('Please enter a value.').should('be.visible');
-
- cy.findByLabelText('Value *').type('test_dup');
- cy.findByRole('button', { name: 'Save' }).click();
- cy.findByText(
- 'A usage status with the same value already exists. Please enter a different value.'
- ).should('be.visible');
-
- cy.findByLabelText('Value *').clear();
- cy.findByLabelText('Value *').type('test');
-
- cy.startSnoopingBrowserMockedRequest();
-
- cy.findByRole('button', { name: 'Save' }).click();
- cy.findByRole('dialog').should('not.exist');
-
- cy.findBrowserMockedRequests({
- method: 'POST',
- url: '/v1/usage-statuses',
- }).should(async (postRequests) => {
- expect(postRequests.length).equal(1);
- const request = postRequests[0];
- expect(JSON.stringify(await request.json())).equal('{"value":"test"}');
- });
- });
-
- it('deletes a usage status', () => {
- cy.findAllByLabelText('Row Actions').first().click();
- cy.findByText('Delete').click();
-
- cy.startSnoopingBrowserMockedRequest();
-
- cy.findByRole('button', { name: 'Continue' }).click();
-
- cy.findBrowserMockedRequests({
- method: 'DELETE',
- url: '/v1/usage-statuses/:id',
- }).should((patchRequests) => {
- expect(patchRequests.length).equal(1);
- const request = patchRequests[0];
- expect(request.url.toString()).to.contain('1');
- });
- });
-
- it('shows error if trying to delete a unit that is in a catalogue category', () => {
- cy.findAllByLabelText('Row Actions').eq(2).click();
- cy.findByText('Delete').click();
-
- cy.findByRole('button', { name: 'Continue' }).click();
-
- cy.findByText(
- 'This usage status is currently used by one or more items. Remove all uses before deleting it here.'
- );
- });
-});
diff --git a/src/admin/__snapshots__/adminCard.component.test.tsx.snap b/src/admin/__snapshots__/adminCard.component.test.tsx.snap
new file mode 100644
index 000000000..fc9d9d6cd
--- /dev/null
+++ b/src/admin/__snapshots__/adminCard.component.test.tsx.snap
@@ -0,0 +1,553 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`AdminCard Component > dialog > renders correctly with "dialog" type 1`] = `
+
+
+
+
+`;
+
+exports[`AdminCard Component > link > link > renders correctly with "page" type and link 1`] = `
+
+
+
+
+`;
+
+exports[`AdminCard Component > link > renders correctly with "page" type and link 1`] = `
+
+
+
+
+`;
+
+exports[`AdminCard Component > page > dialog > renders correctly with "dialog" type 1`] = `
+
+
+
+
+`;
+
+exports[`AdminCard Component > page > dialog > renders correctly with "dialog" type and link 1`] = `
+
+
+
+
+`;
+
+exports[`AdminCard Component > page > dialog > renders correctly with "page" type and link 1`] = `
+
+
+
+
+`;
+
+exports[`AdminCard Component > page > renders correctly with "page" type 1`] = `
+
+
+
+
+`;
+
+exports[`AdminCard Component > page > renders correctly with "page" type and link 1`] = `
+
+
+
+
+`;
diff --git a/src/admin/__snapshots__/sparesDefinitionDialog.component.test.tsx.snap b/src/admin/__snapshots__/sparesDefinitionDialog.component.test.tsx.snap
new file mode 100644
index 000000000..d2a5ec95d
--- /dev/null
+++ b/src/admin/__snapshots__/sparesDefinitionDialog.component.test.tsx.snap
@@ -0,0 +1,421 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`SparesDefinitionDialog Component > renders correctly when opened 1`] = `
+
+
+
+
+
+
+`;
diff --git a/src/admin/admin.component.test.tsx b/src/admin/admin.component.test.tsx
index 88e6250a1..5b62f58fb 100644
--- a/src/admin/admin.component.test.tsx
+++ b/src/admin/admin.component.test.tsx
@@ -1,4 +1,5 @@
import { screen, waitFor } from '@testing-library/react';
+import userEvent, { UserEvent } from '@testing-library/user-event';
import { renderComponentWithRouterProvider } from '../testUtils';
import AdminPage from './admin.component';
@@ -10,10 +11,14 @@ vi.mock('react-router-dom', async () => ({
}));
describe('AdminPage', () => {
+ let user: UserEvent;
const createView = (path: string) => {
return renderComponentWithRouterProvider( , 'admin', path);
};
+ beforeEach(() => {
+ user = userEvent.setup();
+ });
it('renders admin page correctly', async () => {
createView('/admin-ims');
@@ -23,6 +28,27 @@ describe('AdminPage', () => {
expect(screen.getByText('Usage Statuses')).toBeInTheDocument();
});
+ it('should open and close spares definition dialog', async () => {
+ createView('/admin-ims');
+
+ await waitFor(() => {
+ expect(screen.getByText('Spares definition')).toBeInTheDocument();
+ });
+
+ await user.click(screen.getByText('Spares definition'));
+
+ await waitFor(() => {
+ expect(screen.getByRole('dialog')).toBeInTheDocument();
+ });
+
+ const cancelButton = screen.getByRole('button', { name: 'Cancel' });
+ await user.click(cancelButton);
+
+ await waitFor(() => {
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
+ });
+ });
+
it('renders no results page for invalid url', async () => {
createView('/admin-ims/testFake');
diff --git a/src/admin/admin.component.tsx b/src/admin/admin.component.tsx
index 87bae5d64..fc052ceef 100644
--- a/src/admin/admin.component.tsx
+++ b/src/admin/admin.component.tsx
@@ -1,15 +1,10 @@
-import {
- Box,
- Button,
- Card,
- CardContent,
- Grid,
- Typography,
-} from '@mui/material';
+import { Box, Grid, Typography } from '@mui/material';
import React from 'react';
-import { Link, useLocation, useNavigate } from 'react-router-dom';
+import { useLocation, useNavigate } from 'react-router-dom';
import { BreadcrumbsInfo } from '../api/api.types';
import Breadcrumbs from '../view/breadcrumbs.component';
+import AdminCard from './adminCard.component';
+import SparesDefinitionDialog from './sparesDefinitionDialog.component';
import Units from './units/units.component';
import UsageStatuses from './usageStatuses/usageStatuses.component';
@@ -55,6 +50,10 @@ function AdminPage() {
}
: undefined;
+ const [openAdminDialog, setOpenAdminDialog] = React.useState<
+ false | 'sparesDefinition'
+ >(false);
+
return (
@@ -79,82 +78,21 @@ function AdminPage() {
-
-
-
-
-
- Units
-
-
-
-
-
+
-
-
-
-
-
- Usage Statuses
-
-
-
-
-
+
+
+
+ setOpenAdminDialog('sparesDefinition')}
+ />
@@ -179,6 +117,11 @@ function AdminPage() {
)}
+
+ setOpenAdminDialog(false)}
+ />
);
}
diff --git a/src/admin/adminCard.component.test.tsx b/src/admin/adminCard.component.test.tsx
new file mode 100644
index 000000000..708e2da09
--- /dev/null
+++ b/src/admin/adminCard.component.test.tsx
@@ -0,0 +1,41 @@
+import { act } from 'react';
+import { renderComponentWithRouterProvider } from '../testUtils';
+import AdminCard, { AdminCardProps } from './adminCard.component';
+
+describe('AdminCard Component', () => {
+ let props: AdminCardProps;
+
+ const createView = () => {
+ return renderComponentWithRouterProvider( );
+ };
+
+ describe('page', () => {
+ beforeEach(() => {
+ props = { type: 'page', label: 'Go to Dashboard', link: '/dashboard' };
+ });
+ it('renders correctly with "page" type ', async () => {
+ createView();
+
+ let baseElement;
+ await act(async () => {
+ baseElement = createView().baseElement;
+ });
+ expect(baseElement).toMatchSnapshot();
+ });
+ });
+ describe('dialog', () => {
+ const onClick = vi.fn();
+ beforeEach(() => {
+ props = { type: 'dialog', label: 'Go to Dashboard', onClick: onClick };
+ });
+ it('renders correctly with "dialog" type', async () => {
+ createView();
+
+ let baseElement;
+ await act(async () => {
+ baseElement = createView().baseElement;
+ });
+ expect(baseElement).toMatchSnapshot();
+ });
+ });
+});
diff --git a/src/admin/adminCard.component.tsx b/src/admin/adminCard.component.tsx
new file mode 100644
index 000000000..8b05adecb
--- /dev/null
+++ b/src/admin/adminCard.component.tsx
@@ -0,0 +1,83 @@
+import {
+ Button,
+ Card,
+ CardContent,
+ Grid,
+ SxProps,
+ Theme,
+ Typography,
+} from '@mui/material';
+import { Link } from 'react-router-dom';
+
+interface BaseAdminCardProps {
+ label: string;
+ type: 'page' | 'dialog';
+}
+
+interface PageAdminCardProps extends BaseAdminCardProps {
+ type: 'page';
+ link: string;
+ onClick?: never;
+}
+
+interface DialogAdminCardProps extends BaseAdminCardProps {
+ type: 'dialog';
+ link?: never;
+ onClick: () => void;
+}
+
+export type AdminCardProps = PageAdminCardProps | DialogAdminCardProps;
+
+const buttonStyles: SxProps = {
+ display: 'flex',
+ width: '100%',
+ textDecoration: 'none',
+ color: 'inherit',
+ position: 'relative',
+};
+const AdminCard = (props: AdminCardProps) => {
+ const { link, label, type, onClick } = props;
+
+ const cardContent = (
+
+
+
+
+ {label}
+
+
+
+
+ );
+
+ if (type === 'page' && link) {
+ return (
+
+ {cardContent}
+
+ );
+ }
+
+ return (
+
+ {cardContent}
+
+ );
+};
+
+export default AdminCard;
diff --git a/src/admin/sparesDefinitionDialog.component.test.tsx b/src/admin/sparesDefinitionDialog.component.test.tsx
new file mode 100644
index 000000000..97b9525d4
--- /dev/null
+++ b/src/admin/sparesDefinitionDialog.component.test.tsx
@@ -0,0 +1,159 @@
+import { screen } from '@testing-library/react';
+import userEvent, { UserEvent } from '@testing-library/user-event';
+import { act } from 'react';
+import { MockInstance, vi } from 'vitest';
+import { imsApi } from '../api/api';
+import { renderComponentWithRouterProvider } from '../testUtils';
+import SparesDefinitionDialog, {
+ SparesDefinitionDialogProps,
+} from './sparesDefinitionDialog.component';
+
+describe('SparesDefinitionDialog Component', () => {
+ let props: SparesDefinitionDialogProps;
+ let user: UserEvent;
+ let axiosPutSpy: MockInstance;
+
+ const onClose = vi.fn();
+
+ const createView = () => {
+ return renderComponentWithRouterProvider(
+
+ );
+ };
+
+ beforeEach(() => {
+ user = userEvent.setup();
+ props = {
+ open: true,
+ onClose: onClose,
+ };
+
+ axiosPutSpy = vi.spyOn(imsApi, 'put');
+ });
+
+ afterEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('renders correctly when opened', async () => {
+ createView();
+
+ let baseElement;
+ await act(async () => {
+ baseElement = createView().baseElement;
+ });
+ expect(baseElement).toMatchSnapshot();
+ });
+
+ it('disables save button when checkbox is not checked', async () => {
+ createView();
+
+ const saveButton = screen.getByRole('button', { name: 'Save' });
+ const checkbox = screen.getByLabelText(
+ 'Confirm understanding and proceed checkbox'
+ );
+
+ expect(saveButton).toBeDisabled();
+
+ await user.click(checkbox);
+ expect(saveButton).toBeEnabled();
+ });
+
+ it('calls onClose when Cancel button is clicked', async () => {
+ createView();
+
+ const cancelButton = screen.getByRole('button', { name: 'Cancel' });
+ await user.click(cancelButton);
+
+ expect(onClose).toHaveBeenCalled();
+ });
+
+ it('should modify spares definition', async () => {
+ createView();
+
+ const saveButton = screen.getByRole('button', { name: 'Save' });
+ const checkbox = screen.getByLabelText(
+ 'Confirm understanding and proceed checkbox'
+ );
+
+ await user.click(screen.getByRole('combobox'));
+ await user.click(screen.getByRole('option', { name: 'Scrapped' }));
+ await user.click(checkbox);
+
+ await user.click(saveButton);
+
+ expect(axiosPutSpy).toHaveBeenCalledWith('/v1/settings/spares_definition', {
+ usage_statuses: [
+ {
+ id: '0',
+ },
+ {
+ id: '2',
+ },
+ {
+ id: '3',
+ },
+ ],
+ });
+ });
+
+ it('displays error message if spares definition has not changed and clears when value is changed', async () => {
+ createView();
+
+ const saveButton = screen.getByRole('button', { name: 'Save' });
+ const checkbox = screen.getByLabelText(
+ 'Confirm understanding and proceed checkbox'
+ );
+
+ await user.click(checkbox);
+
+ await user.click(saveButton);
+
+ expect(
+ screen.getByText(
+ 'No changes detected in the spares definition. Please update the spares definition or select Cancel to exit.'
+ )
+ ).toBeInTheDocument();
+
+ await user.click(screen.getByRole('combobox'));
+ await user.click(screen.getByRole('option', { name: 'Scrapped' }));
+
+ expect(
+ screen.queryByText(
+ 'No changes detected in the spares definition. Please update the spares definition or select Cancel to exit.'
+ )
+ ).not.toBeInTheDocument();
+ });
+
+ it('displays error message if spares definition has less then 1 usage status and clears when value is changed', async () => {
+ createView();
+
+ const saveButton = screen.getByRole('button', { name: 'Save' });
+ const checkbox = screen.getByLabelText(
+ 'Confirm understanding and proceed checkbox'
+ );
+
+ await user.click(screen.getByRole('combobox'));
+ await user.click(screen.getByRole('option', { name: 'Used' }));
+ await user.click(screen.getByRole('combobox'));
+ await user.click(screen.getByRole('option', { name: 'New' }));
+
+ await user.click(checkbox);
+ await user.click(saveButton);
+
+ expect(
+ screen.getByText(
+ 'The list must have at least one item. Please add a usage status.'
+ )
+ ).toBeInTheDocument();
+
+ await user.click(screen.getByRole('combobox'));
+ await user.click(screen.getByRole('option', { name: 'Scrapped' }));
+
+ expect(
+ screen.queryByText(
+ 'The list must have at least one item. Please add a usage status.'
+ )
+ ).not.toBeInTheDocument();
+ });
+});
diff --git a/src/admin/sparesDefinitionDialog.component.tsx b/src/admin/sparesDefinitionDialog.component.tsx
new file mode 100644
index 000000000..00742d211
--- /dev/null
+++ b/src/admin/sparesDefinitionDialog.component.tsx
@@ -0,0 +1,205 @@
+import { zodResolver } from '@hookform/resolvers/zod';
+import {
+ Autocomplete,
+ Box,
+ Button,
+ CircularProgress,
+ Dialog,
+ DialogActions,
+ DialogContent,
+ DialogTitle,
+ FormHelperText,
+ TextField,
+} from '@mui/material';
+import { AxiosError } from 'axios';
+import React from 'react';
+import { Controller, useForm } from 'react-hook-form';
+import { SparesDefinitionPut, UsageStatus } from '../api/api.types';
+import {
+ useGetSparesDefinition,
+ usePutSparesDefinition,
+} from '../api/settings';
+import { useGetUsageStatuses } from '../api/usageStatuses';
+import WarningMessage from '../common/warningMessage.component';
+import { SparesDefinitionSchema } from '../form.schemas';
+import handleIMS_APIError from '../handleIMS_APIError';
+import { areListsEqual } from '../utils';
+
+export interface SparesDefinitionDialogProps {
+ open: boolean;
+ onClose: () => void;
+}
+
+const SparesDefinitionDialog = (props: SparesDefinitionDialogProps) => {
+ const { open, onClose } = props;
+
+ const { data: usageStatuses } = useGetUsageStatuses();
+ const { data: sparesDefinition } = useGetSparesDefinition();
+
+ const initialSparesDefinition: SparesDefinitionPut = React.useMemo(
+ () => ({
+ usage_statuses:
+ sparesDefinition?.usage_statuses.map((status) => ({ id: status.id })) ||
+ [],
+ }),
+ [sparesDefinition]
+ );
+
+ const {
+ handleSubmit,
+ control,
+ formState: { errors },
+ clearErrors,
+ setError,
+ reset,
+ watch,
+ } = useForm({
+ resolver: zodResolver(SparesDefinitionSchema),
+ defaultValues: initialSparesDefinition,
+ });
+ const { mutateAsync: putSparesDefinition, isPending: isPutPending } =
+ usePutSparesDefinition();
+
+ const [isWarningMessageChecked, setIsWarningMessageChecked] =
+ React.useState(false);
+
+ const handleClose = React.useCallback(() => {
+ clearErrors();
+ onClose();
+ reset();
+ setIsWarningMessageChecked(false);
+ }, [clearErrors, onClose, reset]);
+
+ const handlePutSparesDefinition = React.useCallback(
+ (sparesDefinitionData: SparesDefinitionPut) => {
+ if (initialSparesDefinition) {
+ const isSparesDefinitionUpdated = !areListsEqual(
+ initialSparesDefinition.usage_statuses.map((status) => status.id),
+ sparesDefinitionData.usage_statuses.map((status) => status.id)
+ );
+
+ if (isSparesDefinitionUpdated) {
+ putSparesDefinition(sparesDefinitionData)
+ .then(() => handleClose())
+ .catch((error: AxiosError) => {
+ handleIMS_APIError(error);
+ });
+ } else {
+ setError('root.formError', {
+ message:
+ 'No changes detected in the spares definition. Please update the spares definition or select Cancel to exit.',
+ });
+ }
+ }
+ },
+ [initialSparesDefinition, putSparesDefinition, handleClose, setError]
+ );
+
+ const onSubmit = (data: SparesDefinitionPut) => {
+ handlePutSparesDefinition(data);
+ };
+
+ // Load the values for editing
+ React.useEffect(() => {
+ reset(initialSparesDefinition);
+ }, [initialSparesDefinition, reset]);
+
+ // Clears form errors when a value has been changed
+ React.useEffect(() => {
+ if (errors.root?.formError) {
+ const subscription = watch(() => clearErrors('root.formError'));
+ return () => subscription.unsubscribe();
+ }
+ }, [clearErrors, errors, watch]);
+ return (
+
+ Spares Definition
+
+ {
+ const currentSparesDef: UsageStatus[] = usage_status_ids
+ .map(
+ ({ id }) =>
+ usageStatuses?.find((status) => status.id === id) || null
+ )
+ .filter((status): status is UsageStatus => status !== null);
+ return (
+ {
+ onChange(
+ usageStatus.map((status) => ({
+ id: status.id,
+ }))
+ );
+ }}
+ multiple
+ sx={{ alignItems: 'center', mt: 1 }}
+ fullWidth
+ options={usageStatuses ?? []}
+ isOptionEqualToValue={(option, value) => option.id == value.id}
+ getOptionLabel={(option) => option.value}
+ renderInput={(params) => (
+
+ )}
+ />
+ );
+ }}
+ />
+
+
+
+
+
+
+
+ Cancel
+
+ : null}
+ >
+ Save
+
+
+ {errors.root?.formError && (
+
+ {errors.root?.formError.message}
+
+ )}
+
+
+ );
+};
+
+export default SparesDefinitionDialog;
diff --git a/src/api/api.types.tsx b/src/api/api.types.tsx
index 1cbd1dd83..e7d312262 100644
--- a/src/api/api.types.tsx
+++ b/src/api/api.types.tsx
@@ -261,3 +261,13 @@ export interface APIImage
export interface APIImageWithURL extends APIImage {
url: string;
}
+
+// ------------------------------------ SPARES ------------------------------------------------
+
+export interface SparesDefinition {
+ usage_statuses: UsageStatus[];
+}
+
+export interface SparesDefinitionPut {
+ usage_statuses: { id: string }[];
+}
diff --git a/src/api/settings.test.tsx b/src/api/settings.test.tsx
new file mode 100644
index 000000000..c50348645
--- /dev/null
+++ b/src/api/settings.test.tsx
@@ -0,0 +1,53 @@
+import { renderHook, waitFor } from '@testing-library/react';
+import UsageStatusJSON from '../mocks/UsageStatuses.json';
+import { hooksWrapperWithProviders } from '../testUtils';
+import { SparesDefinitionPut } from './api.types';
+import { useGetSparesDefinition, usePutSparesDefinition } from './settings';
+
+describe('units api functions', () => {
+ afterEach(() => {
+ vi.clearAllMocks();
+ });
+
+ describe('useGetUnits', () => {
+ it('sends request to fetch the units and returns successful response', async () => {
+ const { result } = renderHook(() => useGetSparesDefinition(), {
+ wrapper: hooksWrapperWithProviders(),
+ });
+
+ await waitFor(() => {
+ expect(result.current.isSuccess).toBeTruthy();
+ });
+
+ expect(result.current.data).toEqual({
+ usage_statuses: [UsageStatusJSON[0], UsageStatusJSON[2]],
+ });
+ });
+ });
+
+ describe('usePostUnits', () => {
+ let mockDataPost: SparesDefinitionPut;
+ beforeEach(() => {
+ mockDataPost = {
+ usage_statuses: [
+ { id: UsageStatusJSON[0].id },
+ { id: UsageStatusJSON[2].id },
+ ],
+ };
+ });
+
+ it('posts a request to add a unit and returns successful response', async () => {
+ const { result } = renderHook(() => usePutSparesDefinition(), {
+ wrapper: hooksWrapperWithProviders(),
+ });
+ expect(result.current.isIdle).toBe(true);
+ result.current.mutate(mockDataPost);
+ await waitFor(() => {
+ expect(result.current.isSuccess).toBeTruthy();
+ });
+ expect(result.current.data).toEqual({
+ usage_statuses: [UsageStatusJSON[0], UsageStatusJSON[2]],
+ });
+ });
+ });
+});
diff --git a/src/api/settings.tsx b/src/api/settings.tsx
new file mode 100644
index 000000000..760c9d8d0
--- /dev/null
+++ b/src/api/settings.tsx
@@ -0,0 +1,53 @@
+import {
+ useMutation,
+ UseMutationResult,
+ useQuery,
+ useQueryClient,
+ UseQueryResult,
+} from '@tanstack/react-query';
+import { AxiosError } from 'axios';
+import { imsApi } from './api';
+import { SparesDefinition, SparesDefinitionPut } from './api.types';
+
+const getSparesDefinition = async (): Promise => {
+ return imsApi.get('/v1/settings/spares_definition').then((response) => {
+ return response.data;
+ });
+};
+
+export const useGetSparesDefinition = (): UseQueryResult<
+ SparesDefinition,
+ AxiosError
+> => {
+ return useQuery({
+ queryKey: ['SparesDefinition'],
+ queryFn: () => {
+ return getSparesDefinition();
+ },
+ });
+};
+
+const putSparesDefinition = async (
+ sparesDefinition: SparesDefinitionPut
+): Promise => {
+ return imsApi
+ .put(`/v1/settings/spares_definition`, sparesDefinition)
+ .then((response) => response.data);
+};
+
+export const usePutSparesDefinition = (): UseMutationResult<
+ SparesDefinition,
+ AxiosError,
+ SparesDefinitionPut
+> => {
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: (sparesDefinition: SparesDefinitionPut) =>
+ putSparesDefinition(sparesDefinition),
+ onSuccess: () => {
+ queryClient.invalidateQueries({
+ queryKey: ['SparesDefinition'],
+ });
+ },
+ });
+};
diff --git a/src/catalogue/category/property/addPropertyMigrationDialog.component.tsx b/src/catalogue/category/property/addPropertyMigrationDialog.component.tsx
index 4504c00ff..569bcc08a 100644
--- a/src/catalogue/category/property/addPropertyMigrationDialog.component.tsx
+++ b/src/catalogue/category/property/addPropertyMigrationDialog.component.tsx
@@ -1,25 +1,20 @@
import { zodResolver } from '@hookform/resolvers/zod';
import AddIcon from '@mui/icons-material/Add';
import DeleteIcon from '@mui/icons-material/Delete';
-import WarningIcon from '@mui/icons-material/Warning';
import {
Autocomplete,
Box,
Button,
- Checkbox,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
- FormControlLabel,
FormHelperText,
Grid,
IconButton,
- Paper,
Stack,
TextField,
Tooltip,
- Typography,
} from '@mui/material';
import React from 'react';
import {
@@ -38,6 +33,7 @@ import {
import { usePostCatalogueCategoryProperty } from '../../../api/catalogueCategories';
import { useGetUnits } from '../../../api/units';
import { AddPropertyMigration } from '../../../app.types';
+import WarningMessage from '../../../common/warningMessage.component';
import { CatalogueCategoryPropertyPostSchema } from '../../../form.schemas';
import { transformAllowedValues } from '../catalogueCategoryDialog.component';
@@ -172,53 +168,8 @@ const AllowedValuesListTextFields = () => {
);
};
-interface MigrationWarningMessageProps {
- isChecked: boolean;
- setIsChecked: (isChecked: boolean) => void;
-}
-export const MigrationWarningMessage = (
- props: MigrationWarningMessageProps
-) => {
- const { isChecked, setIsChecked } = props;
- return (
-
- {
- setIsChecked(event.target.checked);
- }}
- color="primary"
- />
- }
- label=""
- aria-label="Confirm understanding and proceed checkbox"
- />
-
-
- This action will permanently alter all existing items and catalogue
- items in this catalogue category. Please confirm that you understand the
- consequences by checking the box to proceed.
-
-
- );
-};
-
+export const migrationWarningMessageText =
+ 'This action will permanently alter all existing items and catalogue items in this catalogue category. Please confirm that you understand the consequences by checking the box to proceed.';
function transformAddPropertyMigrationToCatalogueCategoryPropertyPost(
property: AddPropertyMigration
): CatalogueCategoryPropertyPost {
@@ -648,9 +599,10 @@ const AddPropertyMigrationDialog = (props: AddPropertyMigrationDialogProps) => {
-
-
when checked > renders correctly with checked state 1`] = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Warning: Saving these changes will trigger updates to all catalog items, which may cause requests on the items to be denied and slow down the system.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Warning: Saving these changes will trigger updates to all catalog items, which may cause requests on the items to be denied and slow down the system.
+
+
+
+
+`;
+
+exports[`WarningMessage Component > when not checked > renders correctly with unchecked state 1`] = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Warning: Saving these changes will trigger updates to all catalog items, which may cause requests on the items to be denied and slow down the system.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Warning: Saving these changes will trigger updates to all catalog items, which may cause requests on the items to be denied and slow down the system.
+
+
+
+
+`;
diff --git a/src/common/warningMessage.component.test.tsx b/src/common/warningMessage.component.test.tsx
new file mode 100644
index 000000000..25606b04d
--- /dev/null
+++ b/src/common/warningMessage.component.test.tsx
@@ -0,0 +1,75 @@
+import { screen } from '@testing-library/react';
+import userEvent, { UserEvent } from '@testing-library/user-event';
+import { act } from 'react';
+import { renderComponentWithRouterProvider } from '../testUtils';
+import WarningMessage, {
+ WarningMessageProps,
+} from './warningMessage.component';
+
+describe('WarningMessage Component', () => {
+ let props: WarningMessageProps;
+ let user: UserEvent;
+ const setIsChecked = vi.fn();
+
+ const createView = () => {
+ return renderComponentWithRouterProvider( );
+ };
+
+ beforeEach(() => {
+ user = userEvent.setup();
+ props = {
+ isChecked: false,
+ setIsChecked: setIsChecked,
+ message:
+ 'Warning: Saving these changes will trigger updates to all catalog items, which may cause requests on the items to be denied and slow down the system.',
+ };
+ });
+
+ describe('when not checked', () => {
+ it('renders correctly with unchecked state', async () => {
+ createView();
+
+ let baseElement;
+ await act(async () => {
+ baseElement = createView().baseElement;
+ });
+ expect(baseElement).toMatchSnapshot();
+ });
+
+ it('checkbox state can be toggled', async () => {
+ createView();
+ const checkbox = screen.getByLabelText(
+ 'Confirm understanding and proceed checkbox'
+ );
+
+ await user.click(checkbox);
+ expect(setIsChecked).toHaveBeenCalledWith(true);
+ });
+ });
+
+ describe('when checked', () => {
+ beforeEach(() => {
+ props.isChecked = true;
+ });
+
+ it('renders correctly with checked state', async () => {
+ createView();
+
+ let baseElement;
+ await act(async () => {
+ baseElement = createView().baseElement;
+ });
+ expect(baseElement).toMatchSnapshot();
+ });
+
+ it('checkbox state can be toggled', async () => {
+ createView();
+ const checkbox = screen.getByLabelText(
+ 'Confirm understanding and proceed checkbox'
+ );
+
+ await user.click(checkbox);
+ expect(setIsChecked).toHaveBeenCalledWith(false);
+ });
+ });
+});
diff --git a/src/common/warningMessage.component.tsx b/src/common/warningMessage.component.tsx
new file mode 100644
index 000000000..39ebec3c1
--- /dev/null
+++ b/src/common/warningMessage.component.tsx
@@ -0,0 +1,46 @@
+import WarningIcon from '@mui/icons-material/Warning';
+import { Checkbox, FormControlLabel, Paper, Typography } from '@mui/material';
+
+export interface WarningMessageProps {
+ isChecked: boolean;
+ setIsChecked: (isChecked: boolean) => void;
+ message: string;
+}
+const WarningMessage = (props: WarningMessageProps) => {
+ const { isChecked, setIsChecked, message } = props;
+ return (
+
+ {
+ setIsChecked(event.target.checked);
+ }}
+ color="primary"
+ />
+ }
+ label=""
+ aria-label="Confirm understanding and proceed checkbox"
+ />
+
+ {message}
+
+ );
+};
+
+export default WarningMessage;
diff --git a/src/form.schemas.tsx b/src/form.schemas.tsx
index 9efc6c3a5..76d595634 100644
--- a/src/form.schemas.tsx
+++ b/src/form.schemas.tsx
@@ -614,3 +614,11 @@ export const ItemDetailsStepSchema = (requestType: RequestType) => {
notes: OptionalOrNullableStringSchema({ requestType }),
});
};
+
+// ------------------------------------ SPARES ------------------------------------
+
+export const SparesDefinitionSchema = z.object({
+ usage_statuses: z.array(z.object({ id: z.string() })).min(1, {
+ message: 'The list must have at least one item. Please add a usage status.',
+ }),
+});
diff --git a/src/mocks/Units.json b/src/mocks/Units.json
index 98f68e7e2..9a8100012 100644
--- a/src/mocks/Units.json
+++ b/src/mocks/Units.json
@@ -2,54 +2,63 @@
{
"id": "1",
"value": "megapixels",
+ "code": "megapixels",
"created_time": "2024-01-01T12:00:00.000+00:00",
"modified_time": "2024-01-02T13:10:10.000+00:00"
},
{
"id": "2",
"value": "fps",
+ "code": "fps",
"created_time": "2024-01-01T12:00:00.000+00:00",
"modified_time": "2024-01-02T13:10:10.000+00:00"
},
{
"id": "3",
"value": "Joules",
+ "code": "joules",
"created_time": "2024-01-01T12:00:00.000+00:00",
"modified_time": "2024-01-02T13:10:10.000+00:00"
},
{
"id": "4",
"value": "micrometers",
+ "code": "micrometers",
"created_time": "2024-01-01T12:00:00.000+00:00",
"modified_time": "2024-01-02T13:10:10.000+00:00"
},
{
"id": "5",
"value": "millimeters",
+ "code": "millimeters",
"created_time": "2024-01-01T12:00:00.000+00:00",
"modified_time": "2024-01-02T13:10:10.000+00:00"
},
{
"id": "6",
"value": "kilograms",
+ "code": "kilograms",
"created_time": "2024-01-01T12:00:00.000+00:00",
"modified_time": "2024-01-02T13:10:10.000+00:00"
},
{
"id": "7",
"value": "liters per second",
+ "code": "liters_per_second",
"created_time": "2024-01-01T12:00:00.000+00:00",
"modified_time": "2024-01-02T13:10:10.000+00:00"
},
{
"id": "8",
"value": "millibar",
+ "code": "millibar",
"created_time": "2024-01-01T12:00:00.000+00:00",
"modified_time": "2024-01-02T13:10:10.000+00:00"
},
{
"id": "9",
"value": "volts",
+ "code": "volts",
"created_time": "2024-01-01T12:00:00.000+00:00",
"modified_time": "2024-01-02T13:10:10.000+00:00"
}
diff --git a/src/mocks/UsageStatuses.json b/src/mocks/UsageStatuses.json
index 118c46c2b..116a3b2f1 100644
--- a/src/mocks/UsageStatuses.json
+++ b/src/mocks/UsageStatuses.json
@@ -2,24 +2,28 @@
{
"id": "0",
"value": "New",
+ "code": "new",
"created_time": "2024-01-01T12:00:00.000+00:00",
"modified_time": "2024-01-02T13:10:10.000+00:00"
},
{
"id": "1",
"value": "In Use",
+ "code": "in_use",
"created_time": "2024-01-01T12:00:00.000+00:00",
"modified_time": "2024-01-02T13:10:10.000+00:00"
},
{
"id": "2",
"value": "Used",
+ "code": "used",
"created_time": "2024-01-01T12:00:00.000+00:00",
"modified_time": "2024-01-02T13:10:10.000+00:00"
},
{
"id": "3",
"value": "Scrapped",
+ "code": "scrapped",
"created_time": "2024-01-01T12:00:00.000+00:00",
"modified_time": "2024-01-02T13:10:10.000+00:00"
}
diff --git a/src/mocks/handlers.ts b/src/mocks/handlers.ts
index 5c2b9e798..3ca48839b 100644
--- a/src/mocks/handlers.ts
+++ b/src/mocks/handlers.ts
@@ -1,5 +1,6 @@
import { DefaultBodyType, delay, http, HttpResponse, PathParams } from 'msw';
import {
+ APIImage,
AttachmentPostMetadata,
AttachmentPostMetadataResponse,
AttachmentUploadInfo,
@@ -13,12 +14,15 @@ import {
CatalogueItem,
CatalogueItemPatch,
CatalogueItemPost,
+ ImagePost,
Item,
ItemPatch,
ItemPost,
Manufacturer,
ManufacturerPatch,
ManufacturerPost,
+ SparesDefinition,
+ SparesDefinitionPut,
System,
SystemPatch,
SystemPost,
@@ -814,7 +818,7 @@ export const handlers = [
// ------------------------------------ UNITS ------------------------------------------------
- http.get('/v1/units', () => {
+ http.get('/v1/units', () => {
return HttpResponse.json(UnitsJSON, { status: 200 });
}),
@@ -876,9 +880,12 @@ export const handlers = [
// ------------------------------------ USAGE STATUSES ------------------------------------------------
- http.get('/v1/usage-statuses', () => {
- return HttpResponse.json(UsageStatusJSON, { status: 200 });
- }),
+ http.get(
+ '/v1/usage-statuses',
+ () => {
+ return HttpResponse.json(UsageStatusJSON, { status: 200 });
+ }
+ ),
http.post(
'/v1/usage-statuses',
@@ -979,60 +986,66 @@ export const handlers = [
// ------------------------------------ IMAGES ------------------------------------------------
- http.post('/images', async () => {
- return HttpResponse.json(ImagesJSON[0], { status: 200 });
- }),
- http.get('/images', ({ request }) => {
- const url = new URL(request.url);
- const imageParams = url.searchParams;
- const primary = imageParams.get('primary');
- const entityId = imageParams.get('entity_id');
-
- if (primary === 'true') {
- if (entityId === '90') {
- return HttpResponse.json([], { status: 200 });
- } else {
- return HttpResponse.json(
- [
- {
- ...ImagesJSON[0],
- primary: true,
- entity_id: entityId,
- ...(entityId === '3' && { thumbnail_base64: 'test' }),
- },
- ],
- { status: 200 }
- );
- }
+ http.post(
+ '/images',
+ async () => {
+ return HttpResponse.json(ImagesJSON[0], { status: 200 });
}
+ ),
+ http.get(
+ '/images',
+ ({ request }) => {
+ const url = new URL(request.url);
+ const imageParams = url.searchParams;
+ const primary = imageParams.get('primary');
+ const entityId = imageParams.get('entity_id');
- const generateImages = () => {
- return Array.from({ length: 20 }, (_, index) => {
- const id = index + 1;
- let image;
-
- if (Number(id) % 2 === 0) {
- image = ImagesJSON[0];
+ if (primary === 'true') {
+ if (entityId === '90') {
+ return HttpResponse.json([], { status: 200 });
} else {
- image = {
- ...ImagesJSON[1],
- ...(id === 3 && {
- thumbnail_base64: 'test',
- description: undefined,
- }),
- };
+ return HttpResponse.json(
+ [
+ {
+ ...ImagesJSON[0],
+ primary: true,
+ entity_id: entityId ?? '',
+ ...(entityId === '3' && { thumbnail_base64: 'test' }),
+ },
+ ],
+ { status: 200 }
+ );
}
- return {
- ...image,
- id: String(id),
- };
- });
- };
+ }
- return HttpResponse.json(generateImages(), { status: 200 });
- }),
+ const generateImages = () => {
+ return Array.from({ length: 20 }, (_, index) => {
+ const id = index + 1;
+ let image;
+
+ if (Number(id) % 2 === 0) {
+ image = ImagesJSON[0];
+ } else {
+ image = {
+ ...ImagesJSON[1],
+ ...(id === 3 && {
+ thumbnail_base64: 'test',
+ description: undefined,
+ }),
+ };
+ }
+ return {
+ ...image,
+ id: String(id),
+ };
+ });
+ };
+
+ return HttpResponse.json(generateImages(), { status: 200 });
+ }
+ ),
- http.get('/images/:id', ({ params }) => {
+ http.get<{ id: string }, DefaultBodyType>('/images/:id', ({ params }) => {
const { id } = params;
// This is needed otherwise the msw would intercept the
// mocked image get request for the object store
@@ -1048,7 +1061,7 @@ export const handlers = [
image = {
...ImagesJSON[1],
url: 'invalid url',
- description: undefined,
+ description: null,
};
} else {
image = {
@@ -1071,4 +1084,33 @@ export const handlers = [
);
}
}),
+
+ // ------------------------------------ SPARES ------------------------------------------------
+ http.put(
+ '/v1/settings/spares_definition',
+ async ({ request }) => {
+ const sparesDef = await request.json();
+
+ return HttpResponse.json(
+ {
+ usage_statuses: sparesDef.usage_statuses
+ .map(
+ ({ id }) =>
+ UsageStatusJSON?.find((status) => status.id === id) || null
+ )
+ .filter((status): status is UsageStatus => status !== null),
+ },
+ { status: 200 }
+ );
+ }
+ ),
+ http.get(
+ '/v1/settings/spares_definition',
+ () => {
+ return HttpResponse.json(
+ { usage_statuses: [UsageStatusJSON[0], UsageStatusJSON[2]] },
+ { status: 200 }
+ );
+ }
+ ),
];
diff --git a/src/utils.test.tsx b/src/utils.test.tsx
index 45aaff9aa..6b2700080 100644
--- a/src/utils.test.tsx
+++ b/src/utils.test.tsx
@@ -6,6 +6,7 @@ import { UsageStatus } from './api/api.types';
import { renderComponentWithRouterProvider } from './testUtils';
import {
OverflowTip,
+ areListsEqual,
checkForDuplicates,
customFilterFunctions,
generateUniqueId,
@@ -421,3 +422,47 @@ describe('getNonEmptyTrimmedString', () => {
expect(getNonEmptyTrimmedString([])).toBeUndefined();
});
});
+
+describe('areListsEqual', () => {
+ it('returns true for identical lists', () => {
+ const list1 = ['a', 'b', 'c'];
+ const list2 = ['a', 'b', 'c'];
+ expect(areListsEqual(list1, list2)).toBe(true);
+ });
+
+ it('returns true for identical lists with different order', () => {
+ const list1 = ['c', 'b', 'a'];
+ const list2 = ['a', 'b', 'c'];
+ expect(areListsEqual(list1, list2)).toBe(true);
+ });
+
+ it('returns false for lists with different lengths', () => {
+ const list1 = ['a', 'b'];
+ const list2 = ['a', 'b', 'c'];
+ expect(areListsEqual(list1, list2)).toBe(false);
+ });
+
+ it('returns false for lists with different values', () => {
+ const list1 = ['a', 'b', 'c'];
+ const list2 = ['a', 'b', 'd'];
+ expect(areListsEqual(list1, list2)).toBe(false);
+ });
+
+ it('returns true for empty lists', () => {
+ const list1: string[] = [];
+ const list2: string[] = [];
+ expect(areListsEqual(list1, list2)).toBe(true);
+ });
+
+ it('returns false for lists with duplicate elements in one list', () => {
+ const list1 = ['a', 'b', 'b'];
+ const list2 = ['a', 'b'];
+ expect(areListsEqual(list1, list2)).toBe(false);
+ });
+
+ it('returns true for lists with duplicate elements in the same quantity', () => {
+ const list1 = ['a', 'b', 'b'];
+ const list2 = ['b', 'a', 'b'];
+ expect(areListsEqual(list1, list2)).toBe(true);
+ });
+});
diff --git a/src/utils.tsx b/src/utils.tsx
index d85385912..428adbbb4 100644
--- a/src/utils.tsx
+++ b/src/utils.tsx
@@ -513,3 +513,14 @@ export function getNonEmptyTrimmedString(value: unknown): string | undefined {
? value.trim()
: undefined;
}
+
+export function areListsEqual(list1: string[], list2: string[]): boolean {
+ if (list1.length !== list2.length) {
+ return false;
+ }
+
+ const sortedList1 = [...list1].sort();
+ const sortedList2 = [...list2].sort();
+
+ return sortedList1.every((value, index) => value === sortedList2[index]);
+}