diff --git a/cypress/e2e/with_api/app.cy.ts b/cypress/e2e/with_api/app.cy.ts index 025b01c63..4b308276d 100644 --- a/cypress/e2e/with_api/app.cy.ts +++ b/cypress/e2e/with_api/app.cy.ts @@ -17,7 +17,7 @@ describe('App', () => { cy.visit('/catalogue'); cy.wait('@getCatalogueCategoryDataRoot', { timeout: 10000 }); cy.findByText( - 'There are no catalogue categories. Please add a category using the plus icon in the top left of your screen' + 'There are no catalogue categories. Please add a category using the button in the top left of your screen.' ).should('exist'); }); }); diff --git a/cypress/e2e/with_api/catalogueCategories/functions.ts b/cypress/e2e/with_api/catalogueCategories/functions.ts index 89b401923..63cb864fa 100644 --- a/cypress/e2e/with_api/catalogueCategories/functions.ts +++ b/cypress/e2e/with_api/catalogueCategories/functions.ts @@ -27,7 +27,7 @@ const modifyCatalogueCategory = ( cy.findByLabelText('Catalogue Items').click(); } } else { - cy.findByRole('button', { name: 'add catalogue category' }).click(); + cy.findByRole('button', { name: 'Add Catalogue Category' }).click(); } if (values.name !== undefined) { diff --git a/cypress/e2e/with_mock_data/catalogueCategories.cy.ts b/cypress/e2e/with_mock_data/catalogueCategories.cy.ts index 21ccb781f..e50f154c9 100644 --- a/cypress/e2e/with_mock_data/catalogueCategories.cy.ts +++ b/cypress/e2e/with_mock_data/catalogueCategories.cy.ts @@ -14,7 +14,7 @@ describe('Catalogue Category', () => { name: 'Test ' + index.toString(), parent_id: null, code: index.toString(), - is_leaf: true, + is_leaf: false, created_time: '2024-01-01T12:00:00.000+00:00', modified_time: '2024-01-02T13:10:10.000+00:00', properties: [], @@ -129,7 +129,7 @@ describe('Catalogue Category', () => { }); it('display error message when there is no name when adding a catalogue category', () => { - cy.findByRole('button', { name: 'add catalogue category' }).click(); + cy.findByRole('button', { name: 'Add Catalogue Category' }).click(); cy.findByRole('button', { name: 'Save' }).click(); cy.findByRole('dialog') .should('be.visible') @@ -149,7 +149,7 @@ describe('Catalogue Category', () => { }); it('adds a catalogue category where isLeaf is false', () => { - cy.findByRole('button', { name: 'add catalogue category' }).click(); + cy.findByRole('button', { name: 'Add Catalogue Category' }).click(); cy.findByLabelText('Name *').type('test'); cy.startSnoopingBrowserMockedRequest(); @@ -240,7 +240,7 @@ describe('Catalogue Category', () => { }); it('adds a catalogue category where isLeaf is true', () => { - cy.findByRole('button', { name: 'add catalogue category' }).click(); + cy.findByRole('button', { name: 'Add Catalogue Category' }).click(); cy.findByLabelText('Name *').type('test'); cy.findByLabelText('Catalogue Items').click(); @@ -296,7 +296,7 @@ describe('Catalogue Category', () => { }); it('adds a catalogue category where isLeaf is true with a list of allowed values', () => { - cy.findByRole('button', { name: 'add catalogue category' }).click(); + cy.findByRole('button', { name: 'Add Catalogue Category' }).click(); cy.findByLabelText('Name *').type('test'); cy.findByLabelText('Catalogue Items').click(); @@ -353,7 +353,7 @@ describe('Catalogue Category', () => { }); it('displays the allowed values list error states missing values (Text)', () => { - cy.findByRole('button', { name: 'add catalogue category' }).click(); + cy.findByRole('button', { name: 'Add Catalogue Category' }).click(); cy.findByLabelText('Name *').type('test'); cy.findByLabelText('Catalogue Items').click(); @@ -383,7 +383,7 @@ describe('Catalogue Category', () => { }); it('displays the allowed values list error states duplicate values (Text)', () => { - cy.findByRole('button', { name: 'add catalogue category' }).click(); + cy.findByRole('button', { name: 'Add Catalogue Category' }).click(); cy.findByLabelText('Name *').type('test'); cy.findByLabelText('Catalogue Items').click(); @@ -415,7 +415,7 @@ describe('Catalogue Category', () => { }); it('displays the allowed values list error states and check if the error states are in the correct location (number)', () => { - cy.findByRole('button', { name: 'add catalogue category' }).click(); + cy.findByRole('button', { name: 'Add Catalogue Category' }).click(); cy.findByLabelText('Name *').type('test'); cy.findByLabelText('Catalogue Items').click(); @@ -445,7 +445,7 @@ describe('Catalogue Category', () => { }); it('displays error message when duplicate names for properties are entered', () => { - cy.findByRole('button', { name: 'add catalogue category' }).click(); + cy.findByRole('button', { name: 'Add Catalogue Category' }).click(); cy.findByLabelText('Name *').type('test'); cy.findByLabelText('Catalogue Items').click(); @@ -816,7 +816,7 @@ describe('Catalogue Category', () => { it('category with no data displays no results found', () => { cy.visit('/catalogue/16'); cy.findByText( - 'There are no catalogue categories. Please add a category using the plus icon in the top left of your screen' + 'There are no catalogue categories. Please add a category using the button in the top left of your screen.' ).should('exist'); }); @@ -828,20 +828,13 @@ describe('Catalogue Category', () => { }); it('expired url displays search not found message', () => { - cy.visit('/catalogue/not-exist'); + cy.visit('/catalogue/not_exist'); cy.findByText( - 'The category you searched for does not exist. Please navigate home by pressing the home button at the top left of your screen.' + `The catalogue route you are trying to access doesn't exist. Please click the Home button to navigate back to the Catalogue Home page.`, + { timeout: 10000 } ).should('exist'); }); - it('add button disabled when expired url is used', () => { - cy.visit('/catalogue/not-exist'); - - cy.findByRole('button', { name: 'add catalogue category' }).should( - 'be.disabled' - ); - }); - it('when root has no data it displays no categories error message', () => { cy.editEndpointResponse({ url: '/v1/catalogue-categories', @@ -849,7 +842,7 @@ describe('Catalogue Category', () => { statusCode: 200, }); cy.findByText( - 'There are no catalogue categories. Please add a category using the plus icon in the top left of your screen' + 'There are no catalogue categories. Please add a category using the button in the top left of your screen.' ).should('exist'); }); diff --git a/cypress/e2e/with_mock_data/catalogueItems.cy.ts b/cypress/e2e/with_mock_data/catalogueItems.cy.ts index 1c31bd91c..72075412b 100644 --- a/cypress/e2e/with_mock_data/catalogueItems.cy.ts +++ b/cypress/e2e/with_mock_data/catalogueItems.cy.ts @@ -5,6 +5,14 @@ describe('Catalogue Items', () => { afterEach(() => { cy.clearMocks(); }); + + it('should navigate back to the catalogue items table from the landing page using the breadcrumbs', () => { + cy.visit('/catalogue/5/items/89'); + + cy.findByRole('link', { name: 'Energy Meters' }).click(); + + cy.findByRole('button', { name: 'Add Catalogue Item' }).should('exist'); + }); it('adds a catalogue item', () => { cy.findByRole('button', { name: 'Add Catalogue Item' }).click(); @@ -510,10 +518,10 @@ describe('Catalogue Items', () => { }); it('displays the expired landing page message and navigates back to the catalogue home', () => { - cy.visit('/catalogue/item/1fds'); + cy.visit('/catalogue/4/items/1fds'); cy.findByText( - `This catalogue item doesn't exist. Please click the Home button on the top left of your screen to navigate to the catalogue home.` + `The catalogue route you are trying to access doesn't exist. Please click the Home button to navigate back to the Catalogue Home page.` ).should('exist'); cy.findByRole('button', { name: 'navigate to catalogue home' }).click(); @@ -521,6 +529,14 @@ describe('Catalogue Items', () => { cy.findByText('Motion').should('exist'); }); + it('displays the expired landing page message if the catalogue_category_id does not match the catalogue_item_id ', () => { + cy.visit('/catalogue/4/items/89'); + + cy.findByText( + `The catalogue route you are trying to access doesn't exist. Please click the Home button to navigate back to the Catalogue Home page.` + ).should('exist'); + }); + it('displays error message when user tries to delete a catalogue item that has children elements', () => { cy.visit('/catalogue/5'); cy.findAllByLabelText('Row Actions').eq(1).click(); @@ -739,7 +755,7 @@ describe('Catalogue Items', () => { it('can load and clear date filters', () => { cy.visit( - '/catalogue/4?state=N4IgxgYiBcDaoEsAmNwEMAuaA2B7A5gK4CmAkhsQLYB0luSCAZgsUgPoYKXEgA0IANxwkY8EBgCeABx7QQSTD35DsIuQCYADOoAsAWk0BGAwGYAKps3RL1zdUuaAWiAC%2BvUJJmoAzhgBOCAB2%2BHyCwrIgrgC6LjFAA' + '/catalogue/4/items?state=N4IgxgYiBcDaoEsAmNwEMAuaA2B7A5gK4CmAkhsQLYB0luSCAZgsUgPoYKXEgA0IANxwkY8EBgCeABx7QQSTD35DsIuQCYADOoAsAWk0BGAwGYAKps3RL1zdUuaAWiAC%2BvUJJmoAzhgBOCAB2%2BHyCwrIgrgC6LjFAA' ); cy.findByText('Cameras 25').should('exist'); @@ -890,7 +906,7 @@ describe('Catalogue Items', () => { cy.findAllByRole('link', { name: 'Click here' }).eq(1).click(); - cy.url().should('contain', 'catalogue/item/6'); + cy.url().should('contain', 'catalogue/5/items/6'); }); it('can navigate to an items page from the table view', () => { @@ -898,7 +914,7 @@ describe('Catalogue Items', () => { cy.findAllByRole('link', { name: 'Click here' }).eq(0).click(); - cy.url().should('contain', 'catalogue/item/89/items'); + cy.url().should('contain', 'catalogue/5/items/89/items'); }); it('can navigate to an items page from the landing page', () => { @@ -907,7 +923,7 @@ describe('Catalogue Items', () => { cy.findAllByRole('link', { name: 'Items' }).eq(0).click(); - cy.url().should('contain', 'catalogue/item/89/items'); + cy.url().should('contain', 'catalogue/5/items/89/items'); }); it('opens add dialog for categories in directory and has functionality of duplicate', () => { diff --git a/cypress/e2e/with_mock_data/items.cy.ts b/cypress/e2e/with_mock_data/items.cy.ts index f6f01cb37..f5c376ed6 100644 --- a/cypress/e2e/with_mock_data/items.cy.ts +++ b/cypress/e2e/with_mock_data/items.cy.ts @@ -2,11 +2,39 @@ import { delay, HttpResponse } from 'msw'; describe('Items', () => { beforeEach(() => { - cy.visit('/catalogue/item/1/items'); + cy.visit('/catalogue/4/items/1/items'); }); afterEach(() => { cy.clearMocks(); }); + + it('displays the expired landing page message and navigates back to the catalogue home', () => { + cy.visit('/catalogue/4/items/1/items/1fds'); + + cy.findByText( + `The catalogue route you are trying to access doesn't exist. Please click the Home button to navigate back to the Catalogue Home page.` + ).should('exist'); + + cy.findByRole('button', { name: 'navigate to catalogue home' }).click(); + + cy.findByText('Motion').should('exist'); + }); + + it('displays the expired landing page message if the catalogue_category_id does not match the catalogue_item_id ', () => { + cy.visit('/catalogue/5/items/1/items/KvT2Ox7n'); + + cy.findByText( + `The catalogue route you are trying to access doesn't exist. Please click the Home button to navigate back to the Catalogue Home page.` + ).should('exist'); + }); + + it('displays the expired landing page message if the catalogue_item_id does not match the item_id ', () => { + cy.visit('/catalogue/4/items/89/items/G463gOIA'); + + cy.findByText( + `The catalogue route you are trying to access doesn't exist. Please click the Home button to navigate back to the Catalogue Home page.` + ).should('exist'); + }); it('should be able to navigate back to the catalogue catalogue item table view', () => { cy.findByRole('link', { name: 'Cameras' }).click(); cy.findByText('Cameras 1').should('be.visible'); @@ -21,7 +49,7 @@ describe('Items', () => { }); it('should be able to navigate back to the catalogue home step by step', () => { - cy.visit('/catalogue/item/1/items/KvT2Ox7n'); + cy.visit('/catalogue/4/items/1/items/KvT2Ox7n'); cy.findByRole('link', { name: 'Items' }).click(); @@ -224,7 +252,7 @@ describe('Items', () => { }); it('adds an item with only mandatory fields (allowed list of values)', () => { - cy.visit('/catalogue/item/17/items'); + cy.visit('/catalogue/12/items/17/items'); cy.findByRole('button', { name: 'Add Item' }).click(); cy.findByLabelText('Usage status *').click(); diff --git a/cypress/e2e/with_mock_data/systems.cy.ts b/cypress/e2e/with_mock_data/systems.cy.ts index dbe5a9a34..973de0e87 100644 --- a/cypress/e2e/with_mock_data/systems.cy.ts +++ b/cypress/e2e/with_mock_data/systems.cy.ts @@ -218,7 +218,7 @@ describe('Systems', () => { cy.findAllByRole('link', { name: 'Cameras 8' }).first().click(); // Check now on landing page for the catalogue item - cy.url().should('include', '/catalogue/item/27'); + cy.url().should('include', '/catalogue/4/items/27'); cy.findByText('Properties').should('be.visible'); }); @@ -228,7 +228,7 @@ describe('Systems', () => { cy.findByRole('link', { name: 'QnfSKahnQuze' }).click(); // Check now on landing page for the item - cy.url().should('include', '/catalogue/item/28/items/z1hJvV8Z'); + cy.url().should('include', '/catalogue/4/items/28/items/z1hJvV8Z'); cy.findByText('Properties').should('be.visible'); }); diff --git a/src/App.tsx b/src/App.tsx index 0cfdf2587..e41581515 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -11,6 +11,7 @@ import { AxiosError } from 'axios'; import { enGB } from 'date-fns/locale/en-GB'; import React from 'react'; import { + Outlet, RouterProvider, createBrowserRouter, type RouteObject, @@ -26,8 +27,14 @@ import { retryFailedAuthRequests, } from './api/api'; import { MicroFrontendId } from './app.types'; -import Catalogue from './catalogue/catalogue.component'; +import CatalogueLayout, { + CatalogueErrorComponent, + CatalogueLayoutErrorComponent, + catalogueLayoutLoader, +} from './catalogue/catalogueLayout.component'; +import CatalogueCardView from './catalogue/category/catalogueCardView.component'; import CatalogueItemsLandingPage from './catalogue/items/catalogueItemsLandingPage.component'; +import CatalogueItemsPage from './catalogue/items/catalogueItemsPage.component'; import ConfigProvider from './configProvider.component'; import handleIMS_APIError from './handleIMS_APIError'; import { HomePage } from './homePage/homePage.component'; @@ -63,17 +70,19 @@ export const paths = { adminUnits: '/admin-ims/units', adminUsageStatuses: '/admin-ims/usage-statuses', homepage: '/ims', - catalogue: '/catalogue/*', + catalogue: '/catalogue', + catalogueCategories: '/catalogue/:catalogue_category_id', + catalogueItems: '/catalogue/:catalogue_category_id/items', + catalogueItem: '/catalogue/:catalogue_category_id/items/:catalogue_item_id', + items: '/catalogue/:catalogue_category_id/items/:catalogue_item_id/items', + item: '/catalogue/:catalogue_category_id/items/:catalogue_item_id/items/:item_id', systems: '/systems', system: '/systems/:system_id', manufacturers: '/manufacturers', manufacturer: '/manufacturers/:manufacturer_id', - catalogueItem: '/catalogue/item/:catalogue_item_id', - items: '/catalogue/item/:catalogue_item_id/items', - item: '/catalogue/item/:catalogue_item_id/items/:item_id', }; -const queryClient = new QueryClient({ +export const queryClient = new QueryClient({ queryCache: new QueryCache({ onError: (error) => { handleIMS_APIError(error as AxiosError); @@ -116,15 +125,75 @@ const routeObject: RouteObject[] = [ }, ], }, - { path: paths.catalogue, Component: Catalogue }, { - path: paths.catalogueItem, - Component: CatalogueItemsLandingPage, - }, - { path: paths.items, Component: Items }, - { - path: paths.item, - Component: ItemsLandingPage, + path: paths.catalogue, + Component: CatalogueLayout, + ErrorBoundary: CatalogueLayoutErrorComponent, + children: [ + { + index: true, + Component: CatalogueCardView, + }, + { + path: paths.catalogueCategories, + Component: Outlet, + children: [ + { + index: true, + Component: CatalogueCardView, + loader: catalogueLayoutLoader(queryClient), + }, + { + path: paths.catalogueItems, + Component: Outlet, + children: [ + { + index: true, + Component: CatalogueItemsPage, + loader: catalogueLayoutLoader(queryClient), + }, + { + path: paths.catalogueItem, + Component: Outlet, + children: [ + { + index: true, + Component: CatalogueItemsLandingPage, + loader: catalogueLayoutLoader(queryClient), + }, + { + path: paths.items, + Component: Outlet, + children: [ + { + index: true, + Component: Items, + loader: catalogueLayoutLoader(queryClient), + }, + { + path: paths.item, + Component: Outlet, + children: [ + { + index: true, + Component: ItemsLandingPage, + loader: catalogueLayoutLoader(queryClient), + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, + { + path: '*', + Component: CatalogueErrorComponent, + }, + ], }, { path: paths.systems, diff --git a/src/api/catalogueCategories.test.tsx b/src/api/catalogueCategories.test.tsx index 917042d64..c3c0da07e 100644 --- a/src/api/catalogueCategories.test.tsx +++ b/src/api/catalogueCategories.test.tsx @@ -218,7 +218,29 @@ describe('catalogue categories api functions', () => { trail: [['2', 'Motion']], }); }); - }); + + it('sends request to fetch catalogue breadcrumbs data and returns 404 error', async () => { + const { result } = renderHook( + () => useGetCatalogueBreadcrumbs('invalid'), + { + wrapper: hooksWrapperWithProviders(), + } + ); + + await waitFor( + () => { + expect(result.current.isError).toBeTruthy(); + }, + { timeout: 10000 } + ); + + expect(result.current.error?.response?.status).toBe(404); + + expect(result.current.error?.response?.data).toEqual({ + detail: 'Catalogue category not found', + }); + }); + }, 20000); describe('useGetCatalogueCategory', () => { it('sends request to fetch a single catalogue category data and returns successful response', async () => { diff --git a/src/api/catalogueCategories.tsx b/src/api/catalogueCategories.tsx index bf2402d48..be099495e 100644 --- a/src/api/catalogueCategories.tsx +++ b/src/api/catalogueCategories.tsx @@ -1,4 +1,5 @@ import { + queryOptions, useMutation, UseMutationResult, useQuery, @@ -472,14 +473,21 @@ const getCatalogueCategory = async ( }); }; -export const useGetCatalogueCategory = ( - id?: string | null -): UseQueryResult => { - return useQuery({ +export const getCatalogueCategoryQuery = ( + id?: string | null, + loader?: boolean +) => + queryOptions({ queryKey: ['CatalogueCategory', id], queryFn: () => { return getCatalogueCategory(id ?? ''); }, enabled: !!id, + retry: loader ? false : undefined, }); + +export const useGetCatalogueCategory = ( + id?: string | null +): UseQueryResult => { + return useQuery(getCatalogueCategoryQuery(id)); }; diff --git a/src/api/catalogueItems.tsx b/src/api/catalogueItems.tsx index 2d248cafd..4912acbe3 100644 --- a/src/api/catalogueItems.tsx +++ b/src/api/catalogueItems.tsx @@ -1,6 +1,7 @@ import { UseMutationResult, UseQueryResult, + queryOptions, useMutation, useQueries, useQuery, @@ -81,16 +82,23 @@ const getCatalogueItem = async ( }); }; -export const useGetCatalogueItem = ( - catalogueCategoryId: string | undefined -): UseQueryResult => { - return useQuery({ +export const getCatalogueItemQuery = ( + catalogueCategoryId: string | undefined, + loader?: boolean +) => + queryOptions({ queryKey: ['CatalogueItem', catalogueCategoryId], queryFn: () => { return getCatalogueItem(catalogueCategoryId); }, enabled: catalogueCategoryId !== undefined, + retry: loader ? false : undefined, }); + +export const useGetCatalogueItem = ( + catalogueCategoryId: string | undefined +): UseQueryResult => { + return useQuery(getCatalogueItemQuery(catalogueCategoryId)); }; export const useGetCatalogueItemIds = ( diff --git a/src/api/items.tsx b/src/api/items.tsx index 007809ed2..53d23e57a 100644 --- a/src/api/items.tsx +++ b/src/api/items.tsx @@ -1,4 +1,5 @@ import { + queryOptions, useMutation, UseMutationResult, useQuery, @@ -131,16 +132,20 @@ const getItem = async (id: string): Promise => { }); }; -export const useGetItem = ( - id?: string | null -): UseQueryResult => { - return useQuery({ +export const getItemQuery = (id?: string | null, loader?: boolean) => + queryOptions({ queryKey: ['Item', id], queryFn: () => { return getItem(id ?? ''); }, enabled: !!id, + retry: loader ? false : undefined, }); + +export const useGetItem = ( + id?: string | null +): UseQueryResult => { + return useQuery(getItemQuery(id)); }; const deleteItem = async (item: Item): Promise => { diff --git a/src/catalogue/__snapshots__/catalogueLayout.component.test.tsx.snap b/src/catalogue/__snapshots__/catalogueLayout.component.test.tsx.snap new file mode 100644 index 000000000..d74f7be1a --- /dev/null +++ b/src/catalogue/__snapshots__/catalogueLayout.component.test.tsx.snap @@ -0,0 +1,908 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Catalogue Layout > renders a catalogue categories page correctly 1`] = ` + +
+
+
+ + + + +
+
+
+
+`; + +exports[`Catalogue Layout > renders a catalogue items landing page correctly 1`] = ` + +
+
+
+ + + + +
+
+
+
+`; + +exports[`Catalogue Layout > renders a catalogue items page correctly 1`] = ` + +
+
+
+ + + + +
+
+
+
+`; + +exports[`Catalogue Layout > renders catalogue home page correctly 1`] = ` + +
+
+
+ + + + +
+
+
+
+`; + +exports[`Catalogue Layout > renders the item landing page page correctly 1`] = ` + +
+
+
+ + + + +
+
+
+
+`; + +exports[`Catalogue Layout > renders the items page correctly 1`] = ` + +
+
+
+ + + + +
+
+
+
+`; + +exports[`Catalogue Layout Error Component > renders catalogue error page correctly 1`] = ` + +
+
+
+ + + + +
+
+
+

+ Invalid Catalogue Route +

+

+ The catalogue route you are trying to access doesn't exist. Please click the Home button to navigate back to the Catalogue Home page. +

+
+
+
+`; diff --git a/src/catalogue/catalogue.component.test.tsx b/src/catalogue/catalogue.component.test.tsx deleted file mode 100644 index 56f04e97a..000000000 --- a/src/catalogue/catalogue.component.test.tsx +++ /dev/null @@ -1,531 +0,0 @@ -import { screen, waitFor } from '@testing-library/react'; -import userEvent, { UserEvent } from '@testing-library/user-event'; -import { HttpResponse, http } from 'msw'; -import { - CatalogueCategoryProperty, - CatalogueCategoryPropertyType, - Property, -} from '../api/api.types'; -import { server } from '../mocks/server'; -import { renderComponentWithRouterProvider } from '../testUtils'; -import Catalogue, { matchCatalogueItemProperties } from './catalogue.component'; - -describe('matchCatalogueItemProperties', () => { - it('should match catalogue item properties correctly', () => { - const formData: CatalogueCategoryProperty[] = [ - { - id: '1', - name: 'Name1', - type: CatalogueCategoryPropertyType.Text, - mandatory: true, - unit_id: null, - unit: null, - allowed_values: null, - }, - { - id: '2', - name: 'Name2', - type: CatalogueCategoryPropertyType.Number, - mandatory: false, - unit_id: null, - unit: null, - allowed_values: null, - }, - { - id: '3', - name: 'Name3', - type: CatalogueCategoryPropertyType.Boolean, - mandatory: true, - unit_id: null, - unit: null, - allowed_values: null, - }, - ]; - - const itemProperties: Property[] = [ - { - id: '1', - name: 'Name1', - value: 'Value1', - unit_id: null, - unit: null, - }, - { - id: '2', - value: '42', - name: 'Name2', - unit_id: null, - unit: null, - }, - { - id: '3', - value: true, - name: 'Name3', - unit_id: null, - unit: null, - }, - ]; - - const result = matchCatalogueItemProperties(formData, itemProperties); - - // Your assertions - expect(result).toEqual(['Value1', '42', 'true']); - }); - - it('should handle missing properties', () => { - const formData: CatalogueCategoryProperty[] = [ - { - id: '1', - name: 'Name1', - type: CatalogueCategoryPropertyType.Text, - mandatory: true, - unit_id: null, - unit: null, - allowed_values: null, - }, - { - id: '2', - name: 'Name2', - type: CatalogueCategoryPropertyType.Number, - mandatory: false, - unit_id: null, - unit: null, - allowed_values: null, - }, - ]; - - const itemProperties: Property[] = [ - { - id: '1', - name: 'Name1', - value: 'Value1', - unit_id: null, - unit: null, - }, - ]; - - const result = matchCatalogueItemProperties(formData, itemProperties); - - // Your assertions for missing properties (null values) - expect(result).toEqual(['Value1', null]); - }); -}); - -describe('Catalogue', () => { - let user: UserEvent; - const createView = (path: string) => { - return renderComponentWithRouterProvider(, 'catalogue', path); - }; - - beforeEach(() => { - user = userEvent.setup(); - - window.Element.prototype.getBoundingClientRect = vi - .fn() - .mockReturnValue({ height: 100, width: 200 }); - }); - - afterEach(() => { - vi.clearAllMocks(); - }); - - it('progress bar renders correctly', async () => { - createView('/catalogue'); - - await waitFor(() => { - expect(screen.getByRole('progressbar')).toBeInTheDocument(); - }); - }); - - it('renders catalogue category card view correctly', async () => { - createView('/catalogue'); - - await waitFor(() => { - expect(screen.getByText('Beam Characterization')).toBeInTheDocument(); - }); - expect(screen.getByText('Motion')).toBeInTheDocument(); - expect(screen.getByText('Vacuum Technology')).toBeInTheDocument(); - expect(screen.getByText('High Power Lasers')).toBeInTheDocument(); - expect(screen.getByText('X-RAY Beams')).toBeInTheDocument(); - }); - - it('renders catalogue items table correctly', async () => { - createView('/catalogue/4'); - - await waitFor( - () => { - expect(screen.queryAllByRole('progressbar').length).toBe(0); - }, - { timeout: 5000 } - ); - - await waitFor(() => { - expect(screen.getByText('Cameras 1')).toBeInTheDocument(); - }); - }); - - it('navigates back to the root directory', async () => { - createView('/catalogue/2'); - - await waitFor(() => { - expect(screen.getByText('Actuators')).toBeInTheDocument(); - }); - - const homeButton = screen.getByRole('button', { - name: 'navigate to catalogue home', - }); - await user.click(homeButton); - await waitFor(() => { - expect(screen.getByText('Beam Characterization')).toBeInTheDocument(); - }); - expect(screen.getByText('Motion')).toBeInTheDocument(); - expect(screen.getByText('Vacuum Technology')).toBeInTheDocument(); - expect(screen.getByText('High Power Lasers')).toBeInTheDocument(); - expect(screen.getByText('X-RAY Beams')).toBeInTheDocument(); - }); - - it('opens the add catalogue category dialog', async () => { - createView('/catalogue'); - - const addButton = screen.getByRole('button', { - name: 'add catalogue category', - }); - await user.click(addButton); - - await waitFor(() => { - expect(screen.getByRole('dialog')).toBeInTheDocument(); - }); - - const closeButton = screen.getByRole('button', { name: 'Cancel' }); - await user.click(closeButton); - await waitFor(() => { - expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); - }); - }); - - it('no results found page after X-rays opened', async () => { - createView('/catalogue/16'); - - await waitFor(() => { - expect( - screen.getByText( - 'There are no catalogue categories. Please add a category using the plus icon in the top left of your screen' - ) - ).toBeInTheDocument(); - }); - }); - - it('no items found after empty category opened', async () => { - createView('/catalogue/17'); - - await waitFor(() => { - expect( - screen.getByText( - 'No results found: Try adding an item by using the Add Catalogue Item button on the top left of your screen' - ) - ).toBeInTheDocument(); - }); - }); - - it('expired url opens no results page', async () => { - createView('/catalogue/not-category'); - - await waitFor(() => { - expect( - screen.getByText( - 'The category you searched for does not exist. Please navigate home by pressing the home button at the top left of your screen.' - ) - ).toBeInTheDocument(); - }); - }); - - it('add button disabled when expired url is used', async () => { - createView('/catalogue/not-category'); - - const addButton = screen.getByRole('button', { - name: 'add catalogue category', - }); - await waitFor(() => { - expect(addButton).toBeDisabled(); - }); - }); - - it('root has no categories so there is no results page', async () => { - server.use( - http.get('/v1/catalogue-categories', () => { - return HttpResponse.json([], { status: 200 }); - }) - ); - - createView('/catalogue'); - - await waitFor(() => { - expect( - screen.getByText( - 'There are no catalogue categories. Please add a category using the plus icon in the top left of your screen' - ) - ).toBeInTheDocument(); - }); - }); - - it('opens the delete catalogue category dialog', async () => { - createView('/catalogue'); - - await waitFor(() => { - expect(screen.getByText('Beam Characterization')).toBeInTheDocument(); - }); - - const actionsButton = screen.getByRole('button', { - name: 'actions Beam Characterization catalogue category button', - }); - await user.click(actionsButton); - - const deleteButton = screen.getByRole('menuitem', { - name: 'delete Beam Characterization catalogue category button', - }); - await user.click(deleteButton); - - await waitFor(() => { - expect(screen.getByRole('dialog')).toBeInTheDocument(); - }); - - const continueButton = screen.getByRole('button', { name: 'Continue' }); - await user.click(continueButton); - await waitFor(() => { - expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); - }); - }); - - it('can open the edit catalogue category dialog and close it again', async () => { - createView('/catalogue/1'); - - await waitFor(() => { - expect(screen.getByText('Amp Meters')).toBeInTheDocument(); - }); - - const actionsButton = screen.getByRole('button', { - name: 'actions Amp Meters catalogue category button', - }); - await user.click(actionsButton); - - const editButton = screen.getByRole('menuitem', { - name: 'edit Amp Meters catalogue category button', - }); - await user.click(editButton); - - await waitFor(() => { - expect(screen.getByRole('dialog')).toBeInTheDocument(); - }); - - const saveButton = screen.getByRole('button', { name: 'Save' }); - - await user.type(screen.getByLabelText('Name *'), '1'); - await user.click(saveButton); - - const closeButton = screen.getByRole('button', { name: 'Close' }); - await user.click(closeButton); - await waitFor(() => { - expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); - }); - }); - - it('can open the duplicate catalogue category dialog and close it again', async () => { - createView('/catalogue/1'); - - await waitFor(() => { - expect(screen.getByText('Amp Meters')).toBeInTheDocument(); - }); - - const actionsButton = screen.getByRole('button', { - name: 'actions Amp Meters catalogue category button', - }); - await user.click(actionsButton); - - const editButton = screen.getByRole('menuitem', { - name: 'duplicate Amp Meters catalogue category button', - }); - await user.click(editButton); - - await waitFor(() => { - expect(screen.getByRole('dialog')).toBeInTheDocument(); - }); - - const saveButton = screen.getByRole('button', { name: 'Save' }); - - await user.type(screen.getByLabelText('Name *'), '1'); - await user.click(saveButton); - await waitFor(() => { - expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); - }); - }); - - it('renders the breadcrumbs and navigate to another directory', async () => { - createView('/catalogue/8'); - - await waitFor(() => { - expect(screen.getByRole('link', { name: 'Motion' })).toBeInTheDocument(); - }); - await user.click(screen.getByRole('link', { name: 'Motion' })); - - await waitFor(() => { - expect( - screen.queryByRole('link', { name: 'Motion' }) - ).not.toBeInTheDocument(); - }); - }); - - it('updates the cards when a card button is clicked', async () => { - createView('/catalogue'); - await waitFor(() => { - expect(screen.getByText('Beam Characterization')).toBeInTheDocument(); - }); - expect(screen.getByText('Motion')).toBeInTheDocument(); - expect(screen.getByText('Vacuum Technology')).toBeInTheDocument(); - - const beamButton = screen.getByText('Beam Characterization'); - await user.click(beamButton); - await waitFor(() => { - expect(screen.getByText('Cameras')).toBeInTheDocument(); - }); - expect(screen.getByText('Energy Meters')).toBeInTheDocument(); - expect(screen.getByText('Wavefront Sensors')).toBeInTheDocument(); - }); - - it('opens add catalogue item dialog and can close the dialog', async () => { - createView('/catalogue/4'); - - await waitFor(() => { - expect( - screen.getByRole('button', { - name: 'Add Catalogue Item', - }) - ).toBeInTheDocument(); - }); - - const addCatalogueItemButton = screen.getByRole('button', { - name: 'Add Catalogue Item', - }); - - await user.click(addCatalogueItemButton); - 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(); - }); - }, 10000); - - it('opens move catalogue category dialog and can closes the dialog', async () => { - createView('/catalogue/1'); - - await waitFor(() => { - expect(screen.getByText('Cameras')).toBeInTheDocument(); - }); - - const camerasCheckbox = screen.getByLabelText('Cameras checkbox'); - - await user.click(camerasCheckbox); - - await waitFor(() => { - expect( - screen.getByRole('button', { name: 'Move to' }) - ).toBeInTheDocument(); - }); - - await user.click(screen.getByRole('button', { name: 'Move to' })); - - const cancelButton = screen.getByRole('button', { name: 'Cancel' }); - await user.click(cancelButton); - - await waitFor(() => { - expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); - }); - }); - - it('opens copy catalogue category dialog and can close the dialog', async () => { - createView('/catalogue/1'); - - await waitFor(() => { - expect(screen.getByText('Cameras')).toBeInTheDocument(); - }); - - const camerasCheckbox = screen.getByLabelText('Cameras checkbox'); - - await user.click(camerasCheckbox); - - await waitFor(() => { - expect( - screen.getByRole('button', { name: 'Copy to' }) - ).toBeInTheDocument(); - }); - - await user.click(screen.getByRole('button', { name: 'Copy to' })); - - const cancelButton = screen.getByRole('button', { name: 'Cancel' }); - await user.click(cancelButton); - - await waitFor(() => { - expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); - }); - }); - it('selects and deselects catalogue categories', async () => { - createView('/catalogue/1'); - - await waitFor(() => { - expect(screen.getByText('Energy Meters')).toBeInTheDocument(); - }); - - const energyMetersCheckbox = screen.getByLabelText( - 'Energy Meters checkbox' - ); - - await user.click(energyMetersCheckbox); - - const camerasCheckbox = screen.getByLabelText('Cameras checkbox'); - - await user.click(camerasCheckbox); - - await user.click(energyMetersCheckbox); - await user.click(camerasCheckbox); - - await waitFor(() => { - expect( - screen.queryByRole('button', { name: 'Move to' }) - ).not.toBeInTheDocument(); - }); - }); - - it('selects and deselects all catalogue categories', async () => { - createView('/catalogue/1'); - - await waitFor(() => { - expect(screen.getByText('Energy Meters')).toBeInTheDocument(); - }); - - const energyMetersCheckbox = screen.getByLabelText( - 'Energy Meters checkbox' - ); - - await user.click(energyMetersCheckbox); - - const camerasCheckbox = screen.getByLabelText('Cameras checkbox'); - - await user.click(camerasCheckbox); - - const clearSelected = await screen.findByRole('button', { - name: '2 selected', - }); - - await user.click(clearSelected); - - await waitFor(() => { - expect( - screen.queryByRole('button', { name: 'Move to' }) - ).not.toBeInTheDocument(); - }); - }); -}); diff --git a/src/catalogue/catalogue.component.tsx b/src/catalogue/catalogue.component.tsx deleted file mode 100644 index 31df09b38..000000000 --- a/src/catalogue/catalogue.component.tsx +++ /dev/null @@ -1,459 +0,0 @@ -import { NavigateNext } from '@mui/icons-material'; -import AddIcon from '@mui/icons-material/Add'; -import ClearIcon from '@mui/icons-material/Clear'; -import DriveFileMoveOutlinedIcon from '@mui/icons-material/DriveFileMoveOutlined'; -import FolderCopyOutlinedIcon from '@mui/icons-material/FolderCopyOutlined'; -import { - Box, - Button, - Grid, - IconButton, - LinearProgress, - Tooltip, - Typography, -} from '@mui/material'; -import React from 'react'; -import { useLocation, useNavigate } from 'react-router-dom'; -import { - CatalogueCategory, - CatalogueCategoryProperty, - Property, -} from '../api/api.types'; -import { - useGetCatalogueBreadcrumbs, - useGetCatalogueCategories, - useGetCatalogueCategory, -} from '../api/catalogueCategories'; -import { generateUniqueName } from '../utils'; -import Breadcrumbs from '../view/breadcrumbs.component'; -import CatalogueCardView from './category/catalogueCardView.component'; -import CatalogueCategoryDialog from './category/catalogueCategoryDialog.component'; -import CatalogueCategoryDirectoryDialog from './category/catalogueCategoryDirectoryDialog.component'; -import DeleteCatalogueCategoryDialog from './category/deleteCatalogueCategoryDialog.component'; -import CatalogueItemsTable from './items/catalogueItemsTable.component'; - -/* Returns function that navigates to a specific catalogue category id or catalogue path (or to the root of - all categories if given null) */ -export const useNavigateToCatalogue = () => { - const navigate = useNavigate(); - - return React.useCallback( - (newIdPath: string | null) => { - navigate(`/catalogue${newIdPath ? `/${newIdPath}` : ''}`); - }, - [navigate] - ); -}; - -/* Returns the catalogue category id from the location pathname (null when not found) */ -export const useCatalogueCategoryId = (): string | null => { - // Navigation setup - const location = useLocation(); - - return React.useMemo(() => { - let catalogueCategoryId: string | null = location.pathname.replace( - '/catalogue', - '' - ); - catalogueCategoryId = - catalogueCategoryId === '' ? null : catalogueCategoryId.replace('/', ''); - return catalogueCategoryId; - }, [location.pathname]); -}; - -export interface AddCatalogueButtonProps { - disabled: boolean; - parentId: string | null; - type: 'add' | 'edit'; - selectedCatalogueCategory?: CatalogueCategory; - resetSelectedCatalogueCategory: () => void; -} - -const AddCategoryButton = (props: AddCatalogueButtonProps) => { - const [addCategoryDialogOpen, setAddCategoryDialogOpen] = - React.useState(false); - - return ( - <> - - - setAddCategoryDialogOpen(true)} - disabled={props.disabled} - aria-label="add catalogue category" - > - - - - - setAddCategoryDialogOpen(false)} - parentId={props.parentId} - requestType="post" - resetSelectedCatalogueCategory={props.resetSelectedCatalogueCategory} - /> - - ); -}; - -const MoveCategoriesButton = (props: { - selectedCategories: CatalogueCategory[]; - onChangeSelectedCategories: (selectedCategories: CatalogueCategory[]) => void; - parentCategoryId: string | null; -}) => { - const [moveToCategoryDialogOpen, setMoveToCategoryDialogOpen] = - React.useState(false); - - return ( - <> - - setMoveToCategoryDialogOpen(false)} - selectedCategories={props.selectedCategories} - onChangeSelectedCategories={props.onChangeSelectedCategories} - parentCategoryId={props.parentCategoryId} - requestType="moveTo" - /> - - ); -}; - -const CopyCategoriesButton = (props: { - selectedCategories: CatalogueCategory[]; - onChangeSelectedCategories: (selectedCategories: CatalogueCategory[]) => void; - parentCategoryId: string | null; -}) => { - const [copyToCategoryDialogOpen, setCopyToCategoryDialogOpen] = - React.useState(false); - - return ( - <> - - setCopyToCategoryDialogOpen(false)} - selectedCategories={props.selectedCategories} - onChangeSelectedCategories={props.onChangeSelectedCategories} - parentCategoryId={props.parentCategoryId} - requestType="copyTo" - /> - - ); -}; - -export function matchCatalogueItemProperties( - form: CatalogueCategoryProperty[], - items: Property[] -): (string | null)[] { - const result: (string | null)[] = []; - - for (const property of form) { - const matchingItem = items.find((item) => item.id === property.id); - if (matchingItem) { - // Type check and assign the value - if (property.type === 'boolean') { - result.push( - typeof matchingItem.value === 'boolean' - ? String(matchingItem.value) - : '' - ); - } else { - result.push( - matchingItem.value !== null ? String(matchingItem.value) : null - ); - } - } else { - // If there is no matching item, push null - result.push(null); - } - } - - return result; -} - -function Catalogue() { - // Navigation - const catalogueCategoryId = useCatalogueCategoryId(); - const navigateToCatalogue = useNavigateToCatalogue(); - - const { - data: catalogueCategoryDetail, - isLoading: catalogueCategoryDetailLoading, - } = useGetCatalogueCategory(catalogueCategoryId); - - const { data: catalogueBreadcrumbs } = - useGetCatalogueBreadcrumbs(catalogueCategoryId); - - const parentInfo = React.useMemo( - () => catalogueCategoryDetail, - [catalogueCategoryDetail] - ); - const parentId = (parentInfo && parentInfo.id) || null; - - const isLeafNode = parentInfo ? parentInfo.is_leaf : false; - const { - data: catalogueCategoryData, - isLoading: catalogueCategoryDataLoading, - } = useGetCatalogueCategories( - catalogueCategoryDetailLoading ? true : !!parentInfo && parentInfo.is_leaf, - // String value of null for filtering root catalogue category - !catalogueCategoryId ? 'null' : catalogueCategoryId - ); - - const catalogueCategoryNames: string[] = catalogueCategoryData - ? catalogueCategoryData.map((item) => item.name) - : []; - - const [deleteCategoryDialogOpen, setDeleteCategoryDialogOpen] = - React.useState(false); - - const [editCategoryDialogOpen, setEditCategoryDialogOpen] = - React.useState(false); - - const [duplicateCategoryDialogOpen, setDuplicateCategoryDialogOpen] = - React.useState(false); - - const [selectedCatalogueCategory, setSelectedCatalogueCategory] = - React.useState(undefined); - - // useEffect hook to update selectedCatalogueCategory when catalogueCategoryData changes - // Ensures that the edit dialog has the latest property data after a migration (add or edit) is completed - React.useEffect(() => { - // Extract the IDs of all categories from the catalogueCategoryData array - const catalogueCategoryIds = catalogueCategoryData?.map( - (category) => category.id - ); - - // Check if the selectedCatalogueCategory's ID is part of the catalogueCategoryIds array - // This ensures that the selected category is still valid after an "add" or "edit" migration - if (catalogueCategoryIds?.includes(selectedCatalogueCategory?.id ?? '')) { - // Find the updated category from the catalogueCategoryData array - const updatedCategory = catalogueCategoryData?.find( - (category) => category.id === selectedCatalogueCategory?.id - ); - - // Update the state with the updated category, triggering a re-render of the dialog - setSelectedCatalogueCategory(updatedCategory); - } - // Dependencies for this effect: it will re-run when either catalogueCategoryData or selectedCatalogueCategory changes - }, [catalogueCategoryData, selectedCatalogueCategory]); - - const onChangeOpenDeleteCategoryDialog = ( - catalogueCategory: CatalogueCategory - ) => { - setDeleteCategoryDialogOpen(true); - setSelectedCatalogueCategory(catalogueCategory); - }; - - const onChangeOpenEditCategoryDialog = ( - catalogueCategory: CatalogueCategory - ) => { - setEditCategoryDialogOpen(true); - setSelectedCatalogueCategory(catalogueCategory); - }; - - const onChangeOpenDuplicateDialog = ( - catalogueCategory: CatalogueCategory - ) => { - setDuplicateCategoryDialogOpen(true); - setSelectedCatalogueCategory(catalogueCategory); - }; - - const [selectedCategories, setSelectedCategories] = React.useState< - CatalogueCategory[] - >([]); - - const handleToggleSelect = (catalogueCategory: CatalogueCategory) => { - if ( - selectedCategories.some( - (category: CatalogueCategory) => category.id === catalogueCategory.id - ) - ) { - // If the category is already selected, remove it - setSelectedCategories( - selectedCategories.filter( - (category: CatalogueCategory) => category.id !== catalogueCategory.id - ) - ); - } else { - // If the category is not selected, add it - setSelectedCategories([...selectedCategories, catalogueCategory]); - } - }; - - // Clears the selected categories when the user navigates toa different page - React.useEffect(() => { - setSelectedCategories([]); - }, [parentId]); - - return ( - - - -
- navigateToCatalogue(null)} - homeLocation="Catalogue" - /> - - - setSelectedCatalogueCategory(undefined) - } - /> -
- - {!isLeafNode && selectedCategories.length >= 1 && ( - - - - - - - )} -
-
- - {(catalogueCategoryDataLoading || !catalogueCategoryData) && - (!catalogueCategoryDetailLoading || !catalogueCategoryDetail) && - !parentInfo?.is_leaf && ( - - - - )} - - {!catalogueCategoryData?.length && //logic for no results page - !parentInfo?.is_leaf && - !catalogueCategoryDetailLoading && - !catalogueCategoryDataLoading && ( - - - No results found - - - {!parentInfo && catalogueCategoryId !== null - ? 'The category you searched for does not exist. Please navigate home by pressing the home button at the top left of your screen.' - : 'There are no catalogue categories. Please add a category using the plus icon in the top left of your screen'} - - - )} - - {catalogueCategoryData && - catalogueCategoryData.length > 0 && - !parentInfo?.is_leaf && - !catalogueCategoryDetailLoading && ( - - )} - - {parentInfo && parentInfo.is_leaf && ( - - )} - - setEditCategoryDialogOpen(false)} - parentId={parentId} - requestType="patch" - selectedCatalogueCategory={selectedCatalogueCategory} - resetSelectedCatalogueCategory={() => - setSelectedCatalogueCategory(undefined) - } - /> - - setDuplicateCategoryDialogOpen(false)} - parentId={parentId} - requestType="post" - duplicate - selectedCatalogueCategory={ - selectedCatalogueCategory - ? { - ...selectedCatalogueCategory, - name: generateUniqueName( - selectedCatalogueCategory.name, - catalogueCategoryNames - ), - } - : undefined - } - resetSelectedCatalogueCategory={() => - setSelectedCatalogueCategory(undefined) - } - /> - setDeleteCategoryDialogOpen(false)} - catalogueCategory={selectedCatalogueCategory} - onChangeCatalogueCategory={setSelectedCatalogueCategory} - /> -
- ); -} - -export default Catalogue; diff --git a/src/catalogue/catalogueLayout.component.test.tsx b/src/catalogue/catalogueLayout.component.test.tsx new file mode 100644 index 000000000..d816e5773 --- /dev/null +++ b/src/catalogue/catalogueLayout.component.test.tsx @@ -0,0 +1,285 @@ +import { QueryClient } from '@tanstack/react-query'; +import { screen, waitFor } from '@testing-library/react'; +import userEvent, { UserEvent } from '@testing-library/user-event'; +import type { LoaderFunctionArgs } from 'react-router-dom'; +import { paths } from '../App'; +import { renderComponentWithRouterProvider } from '../testUtils'; +import CatalogueLayout, { + CatalogueLayoutErrorComponent, + catalogueLayoutLoader, +} from './catalogueLayout.component'; + +const mockedUseNavigate = vi.fn(); + +vi.mock('react-router-dom', async () => ({ + ...(await vi.importActual('react-router-dom')), + useNavigate: () => mockedUseNavigate, +})); + +describe('Catalogue Layout', () => { + let user: UserEvent; + + beforeEach(() => { + user = userEvent.setup(); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + const createView = (path: string, urlPathKey: keyof typeof paths) => { + return renderComponentWithRouterProvider( + , + urlPathKey, + path + ); + }; + + it('renders catalogue home page correctly', async () => { + const view = createView('/catalogue', 'catalogue'); + + await waitFor(() => { + expect( + screen.getByRole('button', { + name: 'navigate to catalogue home', + }) + ).toBeInTheDocument(); + }); + + expect(view.asFragment()).toMatchSnapshot(); + }); + + it('renders a catalogue categories page correctly', async () => { + const view = createView('/catalogue/1', 'catalogueCategories'); + + await waitFor(() => { + expect(screen.getByText('Beam Characterization')).toBeInTheDocument(); + }); + + expect(view.asFragment()).toMatchSnapshot(); + }); + + it('navigates to catalogue category table view', async () => { + createView('/catalogue/5/items/89', 'catalogueItem'); + await waitFor(() => { + expect( + screen.getByRole('link', { name: 'Energy Meters' }) + ).toBeInTheDocument(); + }); + + const breadcrumb = screen.getByRole('link', { + name: 'Energy Meters', + }); + + await user.click(breadcrumb); + + expect(mockedUseNavigate).toHaveBeenCalledTimes(1); + expect(mockedUseNavigate).toHaveBeenCalledWith('/catalogue/5/items'); + }); + + it('renders a catalogue items page correctly', async () => { + const view = createView('/catalogue/4/items', 'catalogueItems'); + + await waitFor(() => { + expect(screen.getByText('Cameras')).toBeInTheDocument(); + }); + + expect(view.asFragment()).toMatchSnapshot(); + }); + + it('renders a catalogue items landing page correctly', async () => { + const view = createView('/catalogue/4/items/1', 'catalogueItem'); + + await waitFor(() => { + expect(screen.getByText('Cameras 1')).toBeInTheDocument(); + }); + + expect(view.asFragment()).toMatchSnapshot(); + }); + + it('renders the items page correctly', async () => { + const view = createView('/catalogue/4/items/1/items', 'items'); + + await waitFor(() => { + expect(screen.getByText('Items')).toBeInTheDocument(); + }); + + expect(view.asFragment()).toMatchSnapshot(); + }); + + it('renders the item landing page page correctly', async () => { + const view = createView('/catalogue/4/items/1/items/KvT2Ox7n', 'item'); + + await waitFor(() => { + expect(screen.getByText('5YUQDDjKpz2z')).toBeInTheDocument(); + }); + + expect(view.asFragment()).toMatchSnapshot(); + }); + + it('calls useNavigate when the home button is clicked', async () => { + createView('/catalogue/4/items/1/items', 'items'); + + await waitFor(() => { + expect(screen.getByText('Items')).toBeInTheDocument(); + }); + + const homeButton = screen.getByRole('button', { + name: 'navigate to catalogue home', + }); + + await user.click(homeButton); + + expect(mockedUseNavigate).toHaveBeenCalledTimes(1); + expect(mockedUseNavigate).toHaveBeenCalledWith('/catalogue'); + }); +}); + +describe('Catalogue Layout Error Component', () => { + let user: UserEvent; + + beforeEach(() => { + user = userEvent.setup(); + }); + const createView = () => { + return renderComponentWithRouterProvider(); + }; + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('renders catalogue error page correctly', async () => { + const view = createView(); + + await waitFor(() => { + expect( + screen.getByRole('button', { + name: 'navigate to catalogue home', + }) + ).toBeInTheDocument(); + }); + + const homeButton = screen.getByRole('button', { + name: 'navigate to catalogue home', + }); + + await user.click(homeButton); + + expect(mockedUseNavigate).toHaveBeenCalledTimes(1); + expect(mockedUseNavigate).toHaveBeenCalledWith('/catalogue'); + + expect(view.asFragment()).toMatchSnapshot(); + }); +}); + +describe('catalogueLayoutLoader', () => { + let queryClient: QueryClient; + + beforeEach(() => { + queryClient = new QueryClient(); + vi.clearAllMocks(); + }); + it('should fetch catalogue category data if catalogue_category_id is provided', async () => { + const params = { catalogue_category_id: '1' }; + const output = await catalogueLayoutLoader(queryClient)({ + params, + } as unknown as LoaderFunctionArgs); + + expect(output).toEqual({ + catalogue_category_id: '1', + }); + }); + + it('should throw an error if an invalid catalogue_category_id is provided', async () => { + const params = { catalogue_category_id: '120' }; + + await expect( + catalogueLayoutLoader(queryClient)({ + params, + } as unknown as LoaderFunctionArgs) + ).rejects.toThrow('Request failed with status code 404'); + }); + + it('should throw an error if catalogue_item_id does not belong to catalogue_category_id', async () => { + const params = { catalogue_category_id: '1', catalogue_item_id: '2' }; + + await expect( + catalogueLayoutLoader(queryClient)({ + params, + } as unknown as LoaderFunctionArgs) + ).rejects.toThrow( + 'Catalogue item 2 does not belong to catalogue category 1' + ); + }); + + it('should throw an error if an invalid catalogue_item_id is provided', async () => { + const params = { catalogue_category_id: '1', catalogue_item_id: 'invalid' }; + + await expect( + catalogueLayoutLoader(queryClient)({ + params, + } as unknown as LoaderFunctionArgs) + ).rejects.toThrow('Request failed with status code 404'); + }); + + it('should fetch item data if item_id is provided', async () => { + const params = { item_id: 'KvT2Ox7n' }; + const output = await catalogueLayoutLoader(queryClient)({ + params, + } as unknown as LoaderFunctionArgs); + + expect(output).toEqual({ + item_id: 'KvT2Ox7n', + }); + }); + + it('should throw an error if an invalid item_id is provided', async () => { + const params = { + catalogue_category_id: '4', + catalogue_item_id: '1', + item_id: 'invalid', + }; + + await expect( + catalogueLayoutLoader(queryClient)({ + params, + } as unknown as LoaderFunctionArgs) + ).rejects.toThrow('Request failed with status code 404'); + }); + + it('should fetch catalogue category data if catalogue_category_id and catalogue_item_id is provided', async () => { + const params = { catalogue_category_id: '4', catalogue_item_id: '1' }; + const output = await catalogueLayoutLoader(queryClient)({ + params, + } as unknown as LoaderFunctionArgs); + + expect(output).toEqual(params); + }); + + it('should fetch catalogue category data if catalogue_category_id, item_id and catalogue_item_id is provided', async () => { + const params = { + catalogue_category_id: '4', + catalogue_item_id: '1', + item_id: 'KvT2Ox7n', + }; + const output = await catalogueLayoutLoader(queryClient)({ + params, + } as unknown as LoaderFunctionArgs); + + expect(output).toEqual(params); + }); + + it('should throw an error if item_id does not belong to catalogue_item_id', async () => { + const params = { + catalogue_category_id: '4', + catalogue_item_id: '2', + item_id: 'KvT2Ox7n', + }; + + await expect( + catalogueLayoutLoader(queryClient)({ + params, + } as unknown as LoaderFunctionArgs) + ).rejects.toThrow('Item KvT2Ox7n does not belong to catalogue item 2'); + }); +}); diff --git a/src/catalogue/catalogueLayout.component.tsx b/src/catalogue/catalogueLayout.component.tsx new file mode 100644 index 000000000..cfd32a2d1 --- /dev/null +++ b/src/catalogue/catalogueLayout.component.tsx @@ -0,0 +1,195 @@ +import type { QueryClient } from '@tanstack/react-query'; +import React from 'react'; +import { + Outlet, + useLocation, + useParams, + type LoaderFunctionArgs, +} from 'react-router-dom'; +import { BreadcrumbsInfo } from '../api/api.types'; +import { + getCatalogueCategoryQuery, + useGetCatalogueBreadcrumbs, + useGetCatalogueCategory, +} from '../api/catalogueCategories'; +import { + getCatalogueItemQuery, + useGetCatalogueItem, +} from '../api/catalogueItems'; +import { getItemQuery, useGetItem } from '../api/items'; +import BaseLayoutHeader from '../common/baseLayoutHeader.component'; +import ErrorPage from '../common/errorPage.component'; + +export const CatalogueErrorComponent = () => ( + +); + +export const CatalogueLayoutErrorComponent = () => { + return ( + + + + ); +}; + +export const catalogueLayoutLoader = + (queryClient: QueryClient) => + async ({ params }: LoaderFunctionArgs) => { + const { + catalogue_category_id: catalogueCategoryId, + catalogue_item_id: catalogueItemId, + item_id: itemId, + } = params; + + if (catalogueCategoryId) { + await queryClient.ensureQueryData( + getCatalogueCategoryQuery(catalogueCategoryId, true) + ); + } + if (catalogueItemId && catalogueCategoryId) { + const catalogueItem = await queryClient.ensureQueryData( + getCatalogueItemQuery(catalogueItemId, true) + ); + + if (catalogueItem.catalogue_category_id !== catalogueCategoryId) { + throw new Error( + `Catalogue item ${catalogueItemId} does not belong to catalogue category ${catalogueCategoryId}` + ); + } + } + if (catalogueItemId && catalogueCategoryId && itemId) { + const item = await queryClient.ensureQueryData( + getItemQuery(itemId, true) + ); + if (item.catalogue_item_id !== catalogueItemId) { + throw new Error( + `Item ${itemId} does not belong to catalogue item ${catalogueItemId}` + ); + } + } + + return { ...params }; + }; + +function CatalogueLayout() { + const { + catalogue_category_id: catalogueCategoryId, + catalogue_item_id: catalogueItemId, + item_id: itemId, + } = useParams(); + + const location = useLocation(); + + // Remove the trailing slash (if it exists) before splitting + const cleanPath = location.pathname.replace(/\/$/, ''); + + // Now split the cleaned path + const cataloguePath = cleanPath.split('/'); + + const lastSegmentOfCataloguePath = cataloguePath[cataloguePath.length - 1]; + + const { data: breadcrumbs } = useGetCatalogueBreadcrumbs(catalogueCategoryId); + + const { data: catalogueCategory } = + useGetCatalogueCategory(catalogueCategoryId); + + const { data: catalogueItem } = useGetCatalogueItem(catalogueItemId); + + const { data: item } = useGetItem(itemId); + + const [catalogueBreadcrumbs, setCatalogueBreadcrumbs] = React.useState< + BreadcrumbsInfo | undefined + >(breadcrumbs); + React.useEffect(() => { + if (breadcrumbs) { + const catalogueItemBreadcrumbTrail: BreadcrumbsInfo['trail'] = + breadcrumbs.trail.map((breadcrumb) => { + if (breadcrumb[0] === catalogueCategory?.id) { + return [`${breadcrumb[0]}/items`, breadcrumb[1]]; + } + return breadcrumb; + }); + setCatalogueBreadcrumbs({ + ...breadcrumbs, + trail: [ + // Catalogue categories + ...(lastSegmentOfCataloguePath === catalogueCategory?.id && + !catalogueCategory?.is_leaf + ? [...breadcrumbs.trail] + : []), + // Catalogue items + ...(lastSegmentOfCataloguePath === 'items' && + cataloguePath.length === 4 && + catalogueCategory?.is_leaf + ? [...catalogueItemBreadcrumbTrail] + : []), + // Catalogue item landing page + ...((catalogueItem && lastSegmentOfCataloguePath === catalogueItem.id + ? [ + ...catalogueItemBreadcrumbTrail, + [ + `${catalogueItem.catalogue_category_id}/items/${catalogueItem.id}`, + catalogueItem.name, + ], + ] + : []) satisfies BreadcrumbsInfo['trail']), + // Items table + ...((catalogueItem && lastSegmentOfCataloguePath === 'items' + ? [ + ...catalogueItemBreadcrumbTrail, + [ + `${catalogueItem.catalogue_category_id}/items/${catalogueItem.id}`, + `${catalogueItem.name}`, + ], + [ + `${catalogueItem.catalogue_category_id}/items/${catalogueItem.id}/items`, + 'Items', + ], + ] + : []) satisfies BreadcrumbsInfo['trail']), + // Item landing page + ...((catalogueItem && item && lastSegmentOfCataloguePath === item.id + ? [ + ...catalogueItemBreadcrumbTrail, + [ + `${catalogueItem.catalogue_category_id}/items/${catalogueItem.id}`, + `${catalogueItem.name}`, + ], + [ + `${catalogueItem.catalogue_category_id}/items/${catalogueItem.id}/items`, + 'Items', + ], + [ + `${catalogueItem.catalogue_category_id}/items/${catalogueItem.id}/items/${item.id}`, + item?.serial_number ?? 'No serial number', + ], + ] + : []) satisfies BreadcrumbsInfo['trail']), + ], + }); + } else { + setCatalogueBreadcrumbs(undefined); + } + }, [ + breadcrumbs, + catalogueCategory, + catalogueItem, + cataloguePath.length, + item, + lastSegmentOfCataloguePath, + ]); + + return ( + + + + ); +} + +export default CatalogueLayout; diff --git a/src/catalogue/category/catalogueCard.component.tsx b/src/catalogue/category/catalogueCard.component.tsx index 496bee1a4..530c8f422 100644 --- a/src/catalogue/category/catalogueCard.component.tsx +++ b/src/catalogue/category/catalogueCard.component.tsx @@ -12,8 +12,8 @@ import { ListItemIcon, Menu, MenuItem, - Typography, Tooltip, + Typography, } from '@mui/material'; import React from 'react'; import { Link } from 'react-router-dom'; @@ -51,7 +51,7 @@ function CatalogueCard(props: CatalogueCardProps) { return ( + + setAddCategoryDialogOpen(false)} + parentId={props.parentId} + requestType="post" + resetSelectedCatalogueCategory={props.resetSelectedCatalogueCategory} + /> + + ); +}; + +const MoveCategoriesButton = (props: { + selectedCategories: CatalogueCategory[]; + onChangeSelectedCategories: (selectedCategories: CatalogueCategory[]) => void; + parentCategoryId: string | null; +}) => { + const [moveToCategoryDialogOpen, setMoveToCategoryDialogOpen] = + React.useState(false); + + return ( + <> + + setMoveToCategoryDialogOpen(false)} + selectedCategories={props.selectedCategories} + onChangeSelectedCategories={props.onChangeSelectedCategories} + parentCategoryId={props.parentCategoryId} + requestType="moveTo" + /> + + ); +}; + +const CopyCategoriesButton = (props: { + selectedCategories: CatalogueCategory[]; + onChangeSelectedCategories: (selectedCategories: CatalogueCategory[]) => void; + parentCategoryId: string | null; +}) => { + const [copyToCategoryDialogOpen, setCopyToCategoryDialogOpen] = + React.useState(false); + + return ( + <> + + setCopyToCategoryDialogOpen(false)} + selectedCategories={props.selectedCategories} + onChangeSelectedCategories={props.onChangeSelectedCategories} + parentCategoryId={props.parentCategoryId} + requestType="copyTo" + /> + + ); +}; + +function CatalogueCardView() { + const { catalogue_category_id: catalogueCategoryId = null } = useParams(); const { - catalogueCategoryData, - onChangeOpenDeleteCategoryDialog, - onChangeOpenEditCategoryDialog, - onChangeOpenDuplicateDialog, - handleToggleSelect, - selectedCategories, - } = props; + data: catalogueCategoryDetail, + isLoading: catalogueCategoryDetailLoading, + } = useGetCatalogueCategory(catalogueCategoryId); + + const parentInfo = React.useMemo( + () => catalogueCategoryDetail, + [catalogueCategoryDetail] + ); + const parentId = (parentInfo && parentInfo.id) || null; + + const isLeafNode = parentInfo ? parentInfo.is_leaf : false; + + const navigate = useNavigate(); + React.useEffect(() => { + // If it's a leaf node, redirect to catalogue items page + if (isLeafNode) { + navigate('items'); + } + }, [isLeafNode, navigate]); + + const { + data: catalogueCategoryData, + isLoading: catalogueCategoryDataLoading, + } = useGetCatalogueCategories( + catalogueCategoryDetailLoading ? true : !!parentInfo && parentInfo.is_leaf, + // String value of null for filtering root catalogue category + !catalogueCategoryId ? 'null' : catalogueCategoryId + ); + + const catalogueCategoryNames: string[] = catalogueCategoryData + ? catalogueCategoryData.map((item) => item.name) + : []; + + const [menuDialogOpen, setMenuDialogOpen] = React.useState< + false | 'delete' | 'edit' | 'duplicate' + >(); + const [selectedCatalogueCategory, setSelectedCatalogueCategory] = + React.useState(undefined); + + // useEffect hook to update selectedCatalogueCategory when catalogueCategoryData changes + // Ensures that the edit dialog has the latest property data after a migration (add or edit) is completed + React.useEffect(() => { + // Extract the IDs of all categories from the catalogueCategoryData array + const catalogueCategoryIds = catalogueCategoryData?.map( + (category) => category.id + ); + + // Check if the selectedCatalogueCategory's ID is part of the catalogueCategoryIds array + // This ensures that the selected category is still valid after an "add" or "edit" migration + if (catalogueCategoryIds?.includes(selectedCatalogueCategory?.id ?? '')) { + // Find the updated category from the catalogueCategoryData array + const updatedCategory = catalogueCategoryData?.find( + (category) => category.id === selectedCatalogueCategory?.id + ); + + // Update the state with the updated category, triggering a re-render of the dialog + setSelectedCatalogueCategory(updatedCategory); + } + // Dependencies for this effect: it will re-run when either catalogueCategoryData or selectedCatalogueCategory changes + }, [catalogueCategoryData, selectedCatalogueCategory]); + + const onChangeOpenDeleteCategoryDialog = ( + catalogueCategory: CatalogueCategory + ) => { + setMenuDialogOpen('delete'); + setSelectedCatalogueCategory(catalogueCategory); + }; + + const onChangeOpenEditCategoryDialog = ( + catalogueCategory: CatalogueCategory + ) => { + setMenuDialogOpen('edit'); + setSelectedCatalogueCategory(catalogueCategory); + }; + + const onChangeOpenDuplicateDialog = ( + catalogueCategory: CatalogueCategory + ) => { + setMenuDialogOpen('duplicate'); + setSelectedCatalogueCategory(catalogueCategory); + }; + + const [selectedCategories, setSelectedCategories] = React.useState< + CatalogueCategory[] + >([]); + + const handleToggleSelect = (catalogueCategory: CatalogueCategory) => { + if ( + selectedCategories.some( + (category: CatalogueCategory) => category.id === catalogueCategory.id + ) + ) { + // If the category is already selected, remove it + setSelectedCategories( + selectedCategories.filter( + (category: CatalogueCategory) => category.id !== catalogueCategory.id + ) + ); + } else { + // If the category is not selected, add it + setSelectedCategories([...selectedCategories, catalogueCategory]); + } + }; + + // Clears the selected categories when the user navigates to a different page + React.useEffect(() => { + setSelectedCategories([]); + }, [parentId]); // Display total and pagination on separate lines if on a small screen const theme = useTheme(); @@ -236,19 +439,55 @@ function CatalogueCardView(props: CatalogueCardViewProps) { }; return ( - - - - + <> + {!catalogueCategoryDataLoading && catalogueCategoryData ? ( + + + + setSelectedCatalogueCategory(undefined) + } + /> + {selectedCategories.length >= 1 && ( + + + + + + + )} - - - - - {isCollapsed ? 'Show Filters' : 'Hide Filters'} - - - - {data?.map((item, index) => ( - - - selectedCategory.id === item.id - )} - /> + + + + + + + {isCollapsed ? 'Show Filters' : 'Hide Filters'} + + + + {data.length !== 0 ? ( + data.map((item, index) => ( + + + selectedCategory.id === item.id + )} + /> + + )) + ) : ( + + )} - ))} + + + + + + setMenuDialogOpen(false)} + parentId={parentId} + requestType={menuDialogOpen === 'duplicate' ? 'post' : 'patch'} + selectedCatalogueCategory={ + menuDialogOpen === 'duplicate' + ? ({ + ...selectedCatalogueCategory, + name: generateUniqueName( + selectedCatalogueCategory?.name ?? '', + catalogueCategoryNames + ), + } as CatalogueCategory) + : selectedCatalogueCategory + } + resetSelectedCatalogueCategory={() => + setSelectedCatalogueCategory(undefined) + } + /> + setMenuDialogOpen(false)} + catalogueCategory={selectedCatalogueCategory} + onChangeCatalogueCategory={setSelectedCatalogueCategory} + /> - - - - - + ) : ( + + + + )} + ); } diff --git a/src/catalogue/items/__snapshots__/catalogueItemsDetailsPanel.component.test.tsx.snap b/src/catalogue/items/__snapshots__/catalogueItemsDetailsPanel.component.test.tsx.snap index d3a66a7df..a4a05f1e2 100644 --- a/src/catalogue/items/__snapshots__/catalogueItemsDetailsPanel.component.test.tsx.snap +++ b/src/catalogue/items/__snapshots__/catalogueItemsDetailsPanel.component.test.tsx.snap @@ -1289,8 +1289,7 @@ exports[`Catalogue Items details panel > renders details panel correctly (with o > Click here @@ -1813,8 +1812,7 @@ exports[`Catalogue Items details panel > renders details panel correctly 1`] = ` > Click here @@ -2347,8 +2345,7 @@ exports[`Catalogue Items details panel > renders manufacturer panel correctly 1` > Click here @@ -2880,8 +2877,7 @@ exports[`Catalogue Items details panel > renders notes panel correctly 1`] = ` > Click here @@ -3413,8 +3409,7 @@ exports[`Catalogue Items details panel > renders properties panel correctly 1`] > Click here diff --git a/src/catalogue/items/__snapshots__/catalogueItemsPage.component.test.tsx.snap b/src/catalogue/items/__snapshots__/catalogueItemsPage.component.test.tsx.snap new file mode 100644 index 000000000..ff2f4181a --- /dev/null +++ b/src/catalogue/items/__snapshots__/catalogueItemsPage.component.test.tsx.snap @@ -0,0 +1,511 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`CatalogueItemsPage > renders a catalogue items page correctly 1`] = ` + +
+
+
+ +
+
+ + +
+
+
+
+
+
+
+ + +
+ + + +
+ +
+
+
+
+
+
+ + +
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+
+
+ +
+
+
+
+

+ Total Catalogue Items: 25 +

+
+
+
+ +
+ + + +
+
+ +
+
+
+
+
+
+
+`; diff --git a/src/catalogue/items/__snapshots__/catalogueLink.component.test.tsx.snap b/src/catalogue/items/__snapshots__/catalogueLink.component.test.tsx.snap new file mode 100644 index 000000000..c05dd8211 --- /dev/null +++ b/src/catalogue/items/__snapshots__/catalogueLink.component.test.tsx.snap @@ -0,0 +1,43 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`ObsoleteReplacementLink > renders a link correctly (catalogue item) 1`] = ` + + + +`; + +exports[`ObsoleteReplacementLink > renders a link correctly (item) 1`] = ` + + + +`; + +exports[`ObsoleteReplacementLink > renders nothing when data is undefined correctly (catalogue item) 1`] = ` + +
+ test +
+ +`; + +exports[`ObsoleteReplacementLink > renders nothing when data is undefined correctly (item) 1`] = ` + +
+ test +
+ +`; diff --git a/src/catalogue/items/__snapshots__/obsoleteReplacementLink.component.test.tsx.snap b/src/catalogue/items/__snapshots__/obsoleteReplacementLink.component.test.tsx.snap new file mode 100644 index 000000000..fc149f695 --- /dev/null +++ b/src/catalogue/items/__snapshots__/obsoleteReplacementLink.component.test.tsx.snap @@ -0,0 +1,14 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`ObsoleteReplacementLink > renders a link correctly 1`] = ` + + + Click here + + +`; + +exports[`ObsoleteReplacementLink > renders nothing when data is undefined correctly 1`] = ``; diff --git a/src/catalogue/items/catalogueItemsDetailsPanel.component.test.tsx b/src/catalogue/items/catalogueItemsDetailsPanel.component.test.tsx index bec6a4943..359be5db7 100644 --- a/src/catalogue/items/catalogueItemsDetailsPanel.component.test.tsx +++ b/src/catalogue/items/catalogueItemsDetailsPanel.component.test.tsx @@ -1,4 +1,4 @@ -import { screen } from '@testing-library/react'; +import { screen, waitFor } from '@testing-library/react'; import { getCatalogueCategoryById, getCatalogueItemById, @@ -33,6 +33,11 @@ describe('Catalogue Items details panel', () => { it('renders details panel correctly', async () => { const view = createView(); + await waitFor(() => { + expect( + screen.getByRole('link', { name: 'Click here' }) + ).toBeInTheDocument(); + }); expect(view.asFragment()).toMatchSnapshot(); }); @@ -46,6 +51,12 @@ describe('Catalogue Items details panel', () => { props.manufacturerData = getManufacturerById('3'); const view = createView(); + await waitFor(() => { + expect( + screen.getByRole('link', { name: 'Click here' }) + ).toBeInTheDocument(); + }); + expect(view.asFragment()).toMatchSnapshot(); }); diff --git a/src/catalogue/items/catalogueItemsDetailsPanel.component.tsx b/src/catalogue/items/catalogueItemsDetailsPanel.component.tsx index 519a3d531..f9d332198 100644 --- a/src/catalogue/items/catalogueItemsDetailsPanel.component.tsx +++ b/src/catalogue/items/catalogueItemsDetailsPanel.component.tsx @@ -15,6 +15,7 @@ import { } from '../../api/api.types'; import PlaceholderImage from '../../common/images/placeholderImage.component'; import { formatDateTimeStrings } from '../../utils'; +import CatalogueLink from './catalogueLink.component'; // eslint-disable-next-line @typescript-eslint/no-explicit-any function TabPanel(props: any) { @@ -103,14 +104,13 @@ function CatalogueItemsDetailsPanel(props: CatalogueItemsDetailsPanelProps) { {catalogueItemIdData.obsolete_replacement_catalogue_item_id ? ( - Click here - + ) : ( 'None' )} diff --git a/src/catalogue/items/catalogueItemsLandingPage.component.test.tsx b/src/catalogue/items/catalogueItemsLandingPage.component.test.tsx index aea237280..14186fa51 100644 --- a/src/catalogue/items/catalogueItemsLandingPage.component.test.tsx +++ b/src/catalogue/items/catalogueItemsLandingPage.component.test.tsx @@ -27,20 +27,12 @@ describe('Catalogue Items Landing Page', () => { }); it('renders text correctly (only basic details given)', async () => { - createView('/catalogue/item/1'); + createView('/catalogue/4/items/1'); await waitFor(() => { expect(screen.getByText('Cameras 1')).toBeInTheDocument(); }); - await waitFor(() => { - expect( - screen.getByRole('link', { - name: 'Cameras', - }) - ).toBeInTheDocument(); - }); - expect(screen.getByText('Description:')).toBeInTheDocument(); expect( screen.getByText('High-resolution cameras for beam characterization. 1') @@ -50,20 +42,12 @@ describe('Catalogue Items Landing Page', () => { }); it('renders text correctly (notes tab)', async () => { - createView('/catalogue/item/1'); + createView('/catalogue/4/items/1'); await waitFor(() => { expect(screen.getByText('Cameras 1')).toBeInTheDocument(); }); - await waitFor(() => { - expect( - screen.getByRole('link', { - name: 'Cameras', - }) - ).toBeInTheDocument(); - }); - expect(screen.getByText('Description:')).toBeInTheDocument(); expect( screen.getByText('High-resolution cameras for beam characterization. 1') @@ -75,20 +59,12 @@ describe('Catalogue Items Landing Page', () => { }); it('renders text correctly (extra details given)', async () => { - createView('/catalogue/item/2'); + createView('/catalogue/4/items/2'); await waitFor(() => { expect(screen.getByText('Cameras 2')).toBeInTheDocument(); }); - await waitFor(() => { - expect( - screen.getByRole('link', { - name: 'Cameras', - }) - ).toBeInTheDocument(); - }); - expect(screen.getByText('Description:')).toBeInTheDocument(); expect( screen.getByText('High-resolution cameras for beam characterization. 2') @@ -102,19 +78,8 @@ describe('Catalogue Items Landing Page', () => { expect(screen.getByText('Resolution (megapixels)')).toBeInTheDocument(); }); - it('renders no item page correctly', async () => { - createView('/catalogue/item/1fds'); - await waitFor(() => { - expect( - screen.getByText( - `This catalogue item doesn't exist. Please click the Home button on the top left of your screen to navigate to the catalogue home.` - ) - ).toBeInTheDocument(); - }); - }); - it('shows the loading indicator', async () => { - createView('/catalogue/item/1'); + createView('/catalogue/4/items/1'); await waitFor(() => { expect(screen.getByRole('progressbar')).toBeInTheDocument(); @@ -122,7 +87,7 @@ describe('Catalogue Items Landing Page', () => { }); it('opens and closes the edit catalogue item dialog', async () => { - createView('/catalogue/item/1'); + createView('/catalogue/4/items/1'); await waitFor(() => { expect(screen.getByText('Cameras 1')).toBeInTheDocument(); @@ -149,7 +114,7 @@ describe('Catalogue Items Landing Page', () => { }); it('opens and closes the edit catalogue item dialog (more catalogue item details filled in)', async () => { - createView('/catalogue/item/6'); + createView('/catalogue/5/items/6'); await waitFor(() => { expect(screen.getByText('Energy Meters 27')).toBeInTheDocument(); @@ -176,20 +141,22 @@ describe('Catalogue Items Landing Page', () => { }); it('renders obsolete replace id link', async () => { - createView('/catalogue/item/89'); + createView('/catalogue/5/items/89'); await waitFor(() => { expect(screen.getByText('Energy Meters 26')).toBeInTheDocument(); }); - expect( - screen.getByRole('link', { name: 'Click here' }) - ).toBeInTheDocument(); + await waitFor(() => { + expect( + screen.getByRole('link', { name: 'Click here' }) + ).toBeInTheDocument(); + }); }); it('prints when the button is clicked', async () => { const spy = vi.spyOn(window, 'print').mockImplementation(() => {}); - createView('/catalogue/item/89'); + createView('/catalogue/5/items/89'); await waitFor(() => { expect(screen.getByText('Energy Meters 26')).toBeInTheDocument(); @@ -211,45 +178,8 @@ describe('Catalogue Items Landing Page', () => { spy.mockRestore(); }); - it('navigates to catalogue category table view', async () => { - createView('/catalogue/item/89'); - await waitFor(() => { - expect( - screen.getByRole('link', { name: 'Energy Meters' }) - ).toBeInTheDocument(); - }); - - const breadcrumb = screen.getByRole('link', { - name: 'Energy Meters', - }); - - await user.click(breadcrumb); - - expect(mockedUseNavigate).toHaveBeenCalledTimes(1); - expect(mockedUseNavigate).toHaveBeenCalledWith('/catalogue/5'); - }); - - it('navigates back to the root directory', async () => { - createView('/catalogue/item/89'); - - await waitFor(() => { - expect( - screen.getByRole('link', { name: 'Energy Meters' }) - ).toBeInTheDocument(); - }); - - const homeButton = screen.getByRole('button', { - name: 'navigate to catalogue home', - }); - - await user.click(homeButton); - - expect(mockedUseNavigate).toHaveBeenCalledTimes(1); - expect(mockedUseNavigate).toHaveBeenCalledWith('/catalogue'); - }); - it('navigates to items table view', async () => { - createView('/catalogue/item/89'); + createView('/catalogue/5/items/89'); await waitFor(() => { expect(screen.getByRole('link', { name: 'Items' })).toBeInTheDocument(); }); @@ -257,11 +187,11 @@ describe('Catalogue Items Landing Page', () => { const url = screen.getByRole('link', { name: 'Items', }); - expect(url).toHaveAttribute('href', '/catalogue/item/89/items'); + expect(url).toHaveAttribute('href', '/catalogue/5/items/89/items'); }); it('landing page renders data correctly when optional values are null', async () => { - createView('/catalogue/item/33'); + createView('/catalogue/4/items/33'); await waitFor(() => { expect(screen.getByText('Cameras 14')).toBeInTheDocument(); @@ -277,7 +207,7 @@ describe('Catalogue Items Landing Page', () => { }); it('navigates to manufacturer landing page', async () => { - createView('/catalogue/item/1'); + createView('/catalogue/4/items/1'); await waitFor(() => { expect(screen.getByText('Cameras 1')).toBeInTheDocument(); }); diff --git a/src/catalogue/items/catalogueItemsLandingPage.component.tsx b/src/catalogue/items/catalogueItemsLandingPage.component.tsx index b86f05198..74d140833 100644 --- a/src/catalogue/items/catalogueItemsLandingPage.component.tsx +++ b/src/catalogue/items/catalogueItemsLandingPage.component.tsx @@ -12,24 +12,16 @@ import Grid from '@mui/material/Grid'; import Typography from '@mui/material/Typography'; import React from 'react'; import { Link, useParams } from 'react-router-dom'; -import { - BreadcrumbsInfo, - CatalogueCategory, - CatalogueItem, -} from '../../api/api.types'; -import { - useGetCatalogueBreadcrumbs, - useGetCatalogueCategory, -} from '../../api/catalogueCategories'; +import { CatalogueCategory, CatalogueItem } from '../../api/api.types'; +import { useGetCatalogueCategory } from '../../api/catalogueCategories'; import { useGetCatalogueItem } from '../../api/catalogueItems'; import { useGetManufacturer } from '../../api/manufacturers'; import ActionMenu from '../../common/actionMenu.component'; import PlaceholderImage from '../../common/images/placeholderImage.component'; import TabView from '../../common/tab/tabView.component'; import { formatDateTimeStrings } from '../../utils'; -import Breadcrumbs from '../../view/breadcrumbs.component'; -import { useNavigateToCatalogue } from '../catalogue.component'; import CatalogueItemsDialog from './catalogueItemsDialog.component'; +import CatalogueLink from './catalogueLink.component'; const CatalogueItemsActionMenu = (props: { catalogueItem: CatalogueItem; @@ -61,32 +53,21 @@ const CatalogueItemsActionMenu = (props: { }; function CatalogueItemsLandingPage() { - const { catalogue_item_id: catalogueItemId } = useParams(); - const navigateToCatalogue = useNavigateToCatalogue(); + const { + catalogue_category_id: catalogueCategoryId, + catalogue_item_id: catalogueItemId, + } = useParams(); const { data: catalogueItemIdData, isLoading: catalogueItemIdDataLoading } = useGetCatalogueItem(catalogueItemId); - const { data: catalogueBreadcrumbs } = useGetCatalogueBreadcrumbs( - catalogueItemIdData?.catalogue_category_id - ); - const { data: catalogueCategoryData } = useGetCatalogueCategory( - catalogueItemIdData?.catalogue_category_id - ); + const { + data: catalogueCategoryData, + isLoading: catalogueCategoryDataLoading, + } = useGetCatalogueCategory(catalogueCategoryId); - const [catalogueLandingBreadcrumbs, setCatalogueLandingBreadcrumbs] = - React.useState(catalogueBreadcrumbs); - - React.useEffect(() => { - if (catalogueBreadcrumbs && catalogueItemIdData) - setCatalogueLandingBreadcrumbs({ - ...catalogueBreadcrumbs, - trail: [ - ...catalogueBreadcrumbs.trail, - [`item/${catalogueItemIdData.id}`, catalogueItemIdData.name], - ], - }); - }, [catalogueBreadcrumbs, catalogueItemIdData]); + const isParentCorrect = + catalogueItemIdData?.catalogue_category_id === catalogueCategoryId; const { data: manufacturer } = useGetManufacturer( catalogueItemIdData?.manufacturer_id @@ -94,32 +75,7 @@ function CatalogueItemsLandingPage() { return ( - - - navigateToCatalogue(null)} - homeLocation="Catalogue" - /> - - - {catalogueItemIdData && catalogueCategoryData && ( + {catalogueItemIdData && catalogueCategoryData && isParentCorrect && ( {catalogueItemIdData.obsolete_replacement_catalogue_item_id ? ( - Click here - + ) : ( 'None' )} @@ -554,26 +509,7 @@ function CatalogueItemsLandingPage() { )} - {!catalogueItemIdDataLoading ? ( - !catalogueItemIdData && ( - - - No result found - - - This catalogue item doesn't exist. Please click the Home - button on the top left of your screen to navigate to the catalogue - home. - - - ) - ) : ( + {(catalogueItemIdDataLoading || catalogueCategoryDataLoading) && ( diff --git a/src/catalogue/items/catalogueItemsPage.component.test.tsx b/src/catalogue/items/catalogueItemsPage.component.test.tsx new file mode 100644 index 000000000..213c51b44 --- /dev/null +++ b/src/catalogue/items/catalogueItemsPage.component.test.tsx @@ -0,0 +1,31 @@ +import { screen, waitFor } from '@testing-library/react'; +import { paths } from '../../App'; +import { renderComponentWithRouterProvider } from '../../testUtils'; +import CatalogueItemsPage from './catalogueItemsPage.component'; + +describe('CatalogueItemsPage', () => { + const createView = (path: string, urlPathKey: keyof typeof paths) => { + return renderComponentWithRouterProvider( + , + urlPathKey, + path + ); + }; + + it('renders a catalogue items page correctly', async () => { + const view = createView('/catalogue/4/items', 'catalogueItems'); + + await waitFor( + () => expect(screen.queryByRole('progressbar')).not.toBeInTheDocument(), + { timeout: 10000 } + ); + + await waitFor(() => { + expect( + screen.getByRole('button', { name: 'Add Catalogue Item' }) + ).toBeInTheDocument(); + }); + + expect(view.asFragment()).toMatchSnapshot(); + }, 15000); +}); diff --git a/src/catalogue/items/catalogueItemsPage.component.tsx b/src/catalogue/items/catalogueItemsPage.component.tsx new file mode 100644 index 000000000..2fdfece0b --- /dev/null +++ b/src/catalogue/items/catalogueItemsPage.component.tsx @@ -0,0 +1,25 @@ +import { Box, LinearProgress } from '@mui/material'; +import { useParams } from 'react-router-dom'; +import { useGetCatalogueCategory } from '../../api/catalogueCategories'; +import CatalogueItemsTable from './catalogueItemsTable.component'; + +const CatalogueItemsPage = () => { + const { catalogue_category_id: catalogueCategoryId } = useParams(); + + const { data: catalogueCategory, isLoading } = + useGetCatalogueCategory(catalogueCategoryId); + + if (isLoading) { + return ( + + + + ); + } + + if (catalogueCategory) { + return ; + } +}; + +export default CatalogueItemsPage; diff --git a/src/catalogue/items/catalogueItemsTable.component.test.tsx b/src/catalogue/items/catalogueItemsTable.component.test.tsx index cf7627078..ea94e29e6 100644 --- a/src/catalogue/items/catalogueItemsTable.component.test.tsx +++ b/src/catalogue/items/catalogueItemsTable.component.test.tsx @@ -321,7 +321,7 @@ describe('Catalogue Items Table', () => { await ensureColumnsVisible(['Obsolete replacement link']); const url = screen.queryAllByText('Click here'); - expect(url[0]).toHaveAttribute('href', '/item/6'); + expect(url[0]).toHaveAttribute('href', '/catalogue/5/items/6'); }); it('navigates to catalogue item landing page', async () => { @@ -332,7 +332,7 @@ describe('Catalogue Items Table', () => { await ensureColumnsVisible(['Name']); const url = screen.getByText('Energy Meters 26'); - expect(url).toHaveAttribute('href', '/item/89'); + expect(url).toHaveAttribute('href', '/89'); }); it('navigates to items table', async () => { @@ -343,7 +343,7 @@ describe('Catalogue Items Table', () => { await ensureColumnsVisible(['View Items']); const url = screen.queryAllByText('Click here'); - expect(url[0]).toHaveAttribute('href', '/item/89/items'); + expect(url[0]).toHaveAttribute('href', '/89/items'); }); it('navigates to drawing link', async () => { diff --git a/src/catalogue/items/catalogueItemsTable.component.tsx b/src/catalogue/items/catalogueItemsTable.component.tsx index 3a27367f3..39f5d9d03 100644 --- a/src/catalogue/items/catalogueItemsTable.component.tsx +++ b/src/catalogue/items/catalogueItemsTable.component.tsx @@ -54,6 +54,7 @@ import { import CatalogueItemDirectoryDialog from './catalogueItemDirectoryDialog.component'; import CatalogueItemsDetailsPanel from './catalogueItemsDetailsPanel.component'; import CatalogueItemsDialog from './catalogueItemsDialog.component'; +import CatalogueLink from './catalogueLink.component'; import DeleteCatalogueItemsDialog from './deleteCatalogueItemDialog.component'; import ObsoleteCatalogueItemDialog from './obsoleteCatalogueItemDialog.component'; @@ -243,7 +244,7 @@ const CatalogueItemsTable = (props: CatalogueItemsTableProps) => { {renderedCellValue} @@ -286,7 +287,7 @@ const CatalogueItemsTable = (props: CatalogueItemsTableProps) => { Click here @@ -329,13 +330,14 @@ const CatalogueItemsTable = (props: CatalogueItemsTableProps) => { enableGrouping: false, Cell: ({ row }) => row.original.catalogueItem.obsolete_replacement_catalogue_item_id && ( - Click here - + ), }, { diff --git a/src/catalogue/items/catalogueLink.component.test.tsx b/src/catalogue/items/catalogueLink.component.test.tsx new file mode 100644 index 000000000..8c8766350 --- /dev/null +++ b/src/catalogue/items/catalogueLink.component.test.tsx @@ -0,0 +1,79 @@ +import { screen, waitFor } from '@testing-library/react'; +import { act } from 'react'; +import { renderComponentWithRouterProvider } from '../../testUtils'; +import CatalogueLink, { + type CatalogueLinkProps, +} from './catalogueLink.component'; + +describe('ObsoleteReplacementLink', () => { + let props: CatalogueLinkProps; + + const createView = () => { + return renderComponentWithRouterProvider(); + }; + + it('renders a link correctly (catalogue item)', async () => { + props = { catalogueItemId: '1', children: 'Click here' }; + + let baseElement; + await act(async () => { + baseElement = createView().baseElement; + }); + + await waitFor(() => { + expect( + screen.getByRole('link', { name: 'Click here' }) + ).toBeInTheDocument(); + }); + + expect(baseElement).toMatchSnapshot(); + }); + + it('renders a link correctly (item)', async () => { + props = { itemId: 'KvT2Ox7n', children: 'Click here' }; + let baseElement; + await act(async () => { + baseElement = createView().baseElement; + }); + + await waitFor(() => { + expect( + screen.getByRole('link', { name: 'Click here' }) + ).toBeInTheDocument(); + }); + + expect(baseElement).toMatchSnapshot(); + }); + + it('renders nothing when data is undefined correctly (catalogue item)', async () => { + props = { catalogueItemId: 'invalid', children: 'test' }; + let baseElement; + await act(async () => { + baseElement = createView().baseElement; + }); + + await waitFor(() => { + expect( + screen.queryByRole('link', { name: 'test' }) + ).not.toBeInTheDocument(); + }); + + expect(baseElement).toMatchSnapshot(); + }); + + it('renders nothing when data is undefined correctly (item)', async () => { + props = { itemId: 'invalid', children: 'test' }; + let baseElement; + await act(async () => { + baseElement = createView().baseElement; + }); + + await waitFor(() => { + expect( + screen.queryByRole('link', { name: 'test' }) + ).not.toBeInTheDocument(); + }); + + expect(baseElement).toMatchSnapshot(); + }); +}, 15000); diff --git a/src/catalogue/items/catalogueLink.component.tsx b/src/catalogue/items/catalogueLink.component.tsx new file mode 100644 index 000000000..6439d7211 --- /dev/null +++ b/src/catalogue/items/catalogueLink.component.tsx @@ -0,0 +1,54 @@ +import { Link as MuiLink, type SxProps, type Theme } from '@mui/material'; +import type React from 'react'; +import { Link } from 'react-router-dom'; +import { useGetCatalogueItem } from '../../api/catalogueItems'; +import { useGetItem } from '../../api/items'; + +export interface CatalogueLinkBaseProps { + children: React.ReactNode; + sx?: SxProps; +} + +export interface CatalogueLinkCatalogueItemProps + extends CatalogueLinkBaseProps { + catalogueItemId: string; + itemId?: never; +} + +export interface CatalogueLinkItemProps extends CatalogueLinkBaseProps { + catalogueItemId?: never; + itemId: string; +} + +export type CatalogueLinkProps = + | CatalogueLinkCatalogueItemProps + | CatalogueLinkItemProps; + +const CatalogueLink = (props: CatalogueLinkProps) => { + const { catalogueItemId, itemId, children, sx } = props; + + const { data: item } = useGetItem(itemId); + + const { data: catalogueItem } = useGetCatalogueItem( + catalogueItemId || item?.catalogue_item_id + ); + + if ((!catalogueItem && !item) || !catalogueItem) { + return children; + } + let link: string = ''; + + if (catalogueItem) + link = `/catalogue/${catalogueItem.catalogue_category_id}/items/${catalogueItem.id}`; + + if (item) + link = `/catalogue/${catalogueItem.catalogue_category_id}/items/${catalogueItem.id}/items/${item.id}`; + + return ( + + {children} + + ); +}; + +export default CatalogueLink; diff --git a/src/common/__snapshots__/errorPage.component.test.tsx.snap b/src/common/__snapshots__/errorPage.component.test.tsx.snap index 08670e33c..dca5e1315 100644 --- a/src/common/__snapshots__/errorPage.component.test.tsx.snap +++ b/src/common/__snapshots__/errorPage.component.test.tsx.snap @@ -20,7 +20,7 @@ exports[`ErrorPage Component > renders both boldErrorText and errorText when bot

{ - let props: ErrorPageProps; + let props: ErrorPageProps & { sx?: SxProps }; const createView = () => { return renderComponentWithRouterProvider(); @@ -34,6 +35,7 @@ describe('ErrorPage Component', () => { props = { boldErrorText: 'Critical Error!', errorText: 'Please contact support.', + sx: { margin: 1 }, }; let baseElement; await act(async () => { diff --git a/src/common/errorPage.component.tsx b/src/common/errorPage.component.tsx index 08e06e73d..41887113a 100644 --- a/src/common/errorPage.component.tsx +++ b/src/common/errorPage.component.tsx @@ -1,17 +1,18 @@ -import { Box, Typography } from '@mui/material'; +import { Box, SxProps, Theme, Typography } from '@mui/material'; export type ErrorPageProps = | { boldErrorText: string; errorText?: string } | { boldErrorText?: string; errorText: string }; -const ErrorPage = (props: ErrorPageProps) => { - const { boldErrorText, errorText } = props; +const ErrorPage = (props: ErrorPageProps & { sx?: SxProps }) => { + const { boldErrorText, errorText, sx } = props; return ( {boldErrorText && ( diff --git a/src/common/tab/tabView.component.test.tsx b/src/common/tab/tabView.component.test.tsx index c6cb0fef9..1ba33b697 100644 --- a/src/common/tab/tabView.component.test.tsx +++ b/src/common/tab/tabView.component.test.tsx @@ -49,18 +49,18 @@ describe('TabView Component', () => { it('renders correctly', async () => { let baseElement; await act(async () => { - baseElement = createView('/catalogue/item/1').baseElement; + baseElement = createView('/catalogue/4/items/1').baseElement; }); expect(baseElement).toMatchSnapshot(); }); it('renders correctly with default tab', () => { - createView('/catalogue/item/1'); + createView('/catalogue/4/items/1'); expect(screen.getByText('Content for Tab 1')).toBeInTheDocument(); }); it('changes tabs', async () => { - const { router } = createView('/catalogue/item/1'); + const { router } = createView('/catalogue/4/items/1'); // Click on the second tab await userEvent.click(screen.getByText('tab2')); @@ -80,12 +80,12 @@ describe('TabView Component', () => { }); it('loads tab from URL', () => { - createView('/catalogue/item/1?tab=tab2'); + createView('/catalogue/4/items/1?tab=tab2'); expect(screen.getByText('Content for Tab 2')).toBeInTheDocument(); }); it('renders tabs in correct order', () => { - createView('/catalogue/item/1'); + createView('/catalogue/4/items/1'); const tabs = screen.getAllByRole('tab'); const tabLabels = tabs.map((tab) => tab.textContent); diff --git a/src/items/items.component.test.tsx b/src/items/items.component.test.tsx index 5877ce2e7..a2283e7cb 100644 --- a/src/items/items.component.test.tsx +++ b/src/items/items.component.test.tsx @@ -1,5 +1,5 @@ import { screen, waitFor } from '@testing-library/react'; -import userEvent, { UserEvent } from '@testing-library/user-event'; +import { paths } from '../App'; import { renderComponentWithRouterProvider } from '../testUtils'; import Items from './items.component'; @@ -11,82 +11,23 @@ vi.mock('react-router-dom', async () => ({ })); describe('Items', () => { - let user: UserEvent; - const createView = (path: string) => { - return renderComponentWithRouterProvider(, 'items', path); + const createView = (path: string, urlPathKey?: keyof typeof paths) => { + return renderComponentWithRouterProvider( + , + urlPathKey || 'items', + path + ); }; - beforeEach(() => { - user = userEvent.setup(); - }); - - afterEach(() => { - vi.clearAllMocks(); - }); - - it('navigates to catalogue category table view', async () => { - createView('/catalogue/item/1/items'); - - await waitFor(() => { - expect( - screen.getByRole('link', { - name: 'Cameras', - }) - ).toBeInTheDocument(); - }); - - const breadcrumb = screen.getByRole('link', { - name: 'Cameras', - }); - await user.click(breadcrumb); - - expect(mockedUseNavigate).toHaveBeenCalledTimes(1); - expect(mockedUseNavigate).toHaveBeenCalledWith('/catalogue/4'); - }); - - it('navigates to catalogue item landing page', async () => { - createView('/catalogue/item/1/items'); - await waitFor(() => { - expect( - screen.getByRole('link', { name: 'Cameras 1' }) - ).toBeInTheDocument(); - }); - - const breadcrumb = screen.getByRole('link', { - name: 'Cameras 1', - }); - await user.click(breadcrumb); - - expect(mockedUseNavigate).toHaveBeenCalledTimes(1); - expect(mockedUseNavigate).toHaveBeenCalledWith('/catalogue/item/1'); - }); - - it('navigates back to the root directory', async () => { - createView('/catalogue/item/1/items'); - - await waitFor(() => { - expect( - screen.getByRole('link', { name: 'Cameras 1' }) - ).toBeInTheDocument(); - }); - - const homeButton = screen.getByRole('button', { - name: 'navigate to catalogue home', - }); - - await user.click(homeButton); - - expect(mockedUseNavigate).toHaveBeenCalledTimes(1); - expect(mockedUseNavigate).toHaveBeenCalledWith('/catalogue'); - }); + it('renders item page correctly', async () => { + createView('/catalogue/4/items/1/items'); + await waitFor(() => + expect(screen.queryByRole('progressbar')).not.toBeInTheDocument() + ); - it('renders no item page correctly', async () => { - createView('/catalogue/item/1fghj/items'); await waitFor(() => { expect( - screen.getByText( - `These items don't exist. Please click the Home button on the top left of your screen to navigate to the catalogue home.` - ) + screen.getByRole('button', { name: 'Add Item' }) ).toBeInTheDocument(); }); }); diff --git a/src/items/items.component.tsx b/src/items/items.component.tsx index d0e7bb3ed..67a6b48d1 100644 --- a/src/items/items.component.tsx +++ b/src/items/items.component.tsx @@ -1,20 +1,12 @@ -import { Box, Grid, LinearProgress, Typography } from '@mui/material'; -import React from 'react'; +import { Box, Grid, LinearProgress } from '@mui/material'; import { useParams } from 'react-router-dom'; -import { BreadcrumbsInfo } from '../api/api.types'; -import { - useGetCatalogueBreadcrumbs, - useGetCatalogueCategory, -} from '../api/catalogueCategories'; +import { useGetCatalogueCategory } from '../api/catalogueCategories'; import { useGetCatalogueItem } from '../api/catalogueItems'; -import { useNavigateToCatalogue } from '../catalogue/catalogue.component'; -import Breadcrumbs from '../view/breadcrumbs.component'; import ItemsTable from './itemsTable.component'; export function Items() { // Navigation const { catalogue_item_id: catalogueItemId } = useParams(); - const navigateToCatalogue = useNavigateToCatalogue(); const { data: catalogueItem, isLoading: catalogueItemLoading } = useGetCatalogueItem(catalogueItemId); @@ -22,50 +14,8 @@ export function Items() { catalogueItem?.catalogue_category_id ); - const { data: catalogueBreadcrumbs } = useGetCatalogueBreadcrumbs( - catalogueItem?.catalogue_category_id - ); - - const [itemsBreadcrumbs, setItemsBreadcrumbs] = React.useState< - BreadcrumbsInfo | undefined - >(catalogueBreadcrumbs); - - React.useEffect(() => { - if (catalogueBreadcrumbs && catalogueItem) - setItemsBreadcrumbs({ - ...catalogueBreadcrumbs, - trail: [ - ...catalogueBreadcrumbs.trail, - [`item/${catalogueItem.id}`, `${catalogueItem.name}`], - [`item/${catalogueItem.id}/items`, 'Items'], - ], - }); - }, [catalogueBreadcrumbs, catalogueItem]); return ( - - navigateToCatalogue(null)} - homeLocation="Catalogue" - /> - - {catalogueCategory && catalogueItem && ( )} - {!catalogueItemLoading ? ( - !catalogueItem && ( - - - No result found - - - These items don't exist. Please click the Home button on the - top left of your screen to navigate to the catalogue home. - - - ) - ) : ( + {catalogueItemLoading && ( diff --git a/src/items/itemsLandingPage.component.test.tsx b/src/items/itemsLandingPage.component.test.tsx index bea84777c..56b582f31 100644 --- a/src/items/itemsLandingPage.component.test.tsx +++ b/src/items/itemsLandingPage.component.test.tsx @@ -29,20 +29,12 @@ describe('Items Landing Page', () => { }); it('renders text correctly (only basic details given)', async () => { - createView('/catalogue/item/1/items/KvT2Ox7n'); + createView('/catalogue/4/items/1/items/KvT2Ox7n'); await waitFor(() => { expect(screen.getByText('Cameras 1')).toBeInTheDocument(); }); - await waitFor(() => { - expect( - screen.getByRole('link', { - name: 'Beam Characterization', - }) - ).toBeInTheDocument(); - }); - expect(screen.getByText('Description:')).toBeInTheDocument(); expect( screen.getByText('High-resolution cameras for beam characterization. 1') @@ -56,56 +48,32 @@ describe('Items Landing Page', () => { }); it('renders text correctly (Notes)', async () => { - createView('/catalogue/item/1/items/KvT2Ox7n'); + createView('/catalogue/4/items/1/items/KvT2Ox7n'); await waitFor(() => { expect(screen.getByText('Cameras 1')).toBeInTheDocument(); }); - await waitFor(() => { - expect( - screen.getByRole('link', { - name: 'Beam Characterization', - }) - ).toBeInTheDocument(); - }); - await user.click(screen.getByText('Notes')); expect(screen.getByText('6Y5XTJfBrNNx8oltI9HE')).toBeInTheDocument(); }); it('navigates to the system when the system id is clicked', async () => { - createView('/catalogue/item/1/items/KvT2Ox7n'); + createView('/catalogue/4/items/1/items/KvT2Ox7n'); await waitFor(() => { expect(screen.getByText('Cameras 1')).toBeInTheDocument(); }); - const systemName = screen.getByText('Giant laser'); + const systemName = await screen.findByText('Giant laser'); expect(systemName).toHaveAttribute( 'href', '/systems/65328f34a40ff5301575a4e3' ); }); - it('renders no item page correctly', async () => { - createView('/catalogue/item/1/items/KvT2'); - await waitFor(() => { - expect( - screen.getByText( - `This item doesn't exist. Please click the Home button to navigate to the catalogue home` - ) - ).toBeInTheDocument(); - }); - - const homeButton = screen.getByRole('button', { - name: 'navigate to catalogue home', - }); - expect(homeButton).toBeInTheDocument(); - }); - it('shows the loading indicator', async () => { - createView('/catalogue/item/1/items/KvT2Ox7n'); + createView('/catalogue/4/items/1/items/KvT2Ox7n'); await waitFor(() => { expect(screen.getByRole('progressbar')).toBeInTheDocument(); @@ -114,7 +82,7 @@ describe('Items Landing Page', () => { it('prints when the button is clicked', async () => { const spy = vi.spyOn(window, 'print').mockImplementation(() => {}); - createView('/catalogue/item/1/items/KvT2Ox7n'); + createView('/catalogue/4/items/1/items/KvT2Ox7n'); await waitFor(() => { expect(screen.getByText('Cameras 1')).toBeInTheDocument(); @@ -134,42 +102,8 @@ describe('Items Landing Page', () => { spy.mockRestore(); }); - it('navigates to items table view', async () => { - createView('/catalogue/item/1/items/KvT2Ox7n'); - await waitFor(() => { - expect( - screen.getByRole('link', { name: 'Cameras 1' }) - ).toBeInTheDocument(); - }); - - const breadcrumb = screen.getByRole('link', { - name: 'Cameras 1', - }); - await user.click(breadcrumb); - expect(mockedUseNavigate).toHaveBeenCalledTimes(1); - expect(mockedUseNavigate).toHaveBeenCalledWith('/catalogue/item/1'); - }); - - it('navigates back to the root directory', async () => { - createView('/catalogue/item/1/items/KvT2Ox7n'); - - await waitFor(() => { - expect( - screen.getByRole('link', { name: 'Cameras 1' }) - ).toBeInTheDocument(); - }); - - const homeButton = screen.getByRole('button', { - name: 'navigate to catalogue home', - }); - - await user.click(homeButton); - - expect(mockedUseNavigate).toHaveBeenCalledTimes(1); - expect(mockedUseNavigate).toHaveBeenCalledWith('/catalogue'); - }); it('landing page renders data correctly when optional values are null', async () => { - createView('/catalogue/item/33/items/I26EJNJ0'); + createView('/catalogue/4/items/33/items/I26EJNJ0'); await waitFor(() => { expect(screen.getByText('Cameras 14')).toBeInTheDocument(); @@ -185,7 +119,7 @@ describe('Items Landing Page', () => { }); it('opens the edit item dialog', async () => { - createView('/catalogue/item/1/items/KvT2Ox7n'); + createView('/catalogue/4/items/1/items/KvT2Ox7n'); const serialNumber = '5YUQDDjKpz2z'; await waitFor(() => { @@ -211,7 +145,7 @@ describe('Items Landing Page', () => { }); it('navigates to manufacturer landing page', async () => { - createView('/catalogue/item/1/items/KvT2Ox7n'); + createView('/catalogue/4/items/1/items/KvT2Ox7n'); await waitFor(() => { expect(screen.getByText('Cameras 1')).toBeInTheDocument(); }); diff --git a/src/items/itemsLandingPage.component.tsx b/src/items/itemsLandingPage.component.tsx index ea0df6832..244e7ddf2 100644 --- a/src/items/itemsLandingPage.component.tsx +++ b/src/items/itemsLandingPage.component.tsx @@ -5,26 +5,16 @@ import Grid from '@mui/material/Grid'; import Typography from '@mui/material/Typography'; import React from 'react'; import { Link, useParams } from 'react-router-dom'; -import { - BreadcrumbsInfo, - CatalogueCategory, - CatalogueItem, - Item, -} from '../api/api.types'; -import { - useGetCatalogueBreadcrumbs, - useGetCatalogueCategory, -} from '../api/catalogueCategories'; +import { CatalogueCategory, CatalogueItem, Item } from '../api/api.types'; +import { useGetCatalogueCategory } from '../api/catalogueCategories'; import { useGetCatalogueItem } from '../api/catalogueItems'; import { useGetItem } from '../api/items'; import { useGetManufacturer } from '../api/manufacturers'; import { useGetSystem } from '../api/systems'; -import { useNavigateToCatalogue } from '../catalogue/catalogue.component'; import ActionMenu from '../common/actionMenu.component'; import PlaceholderImage from '../common/images/placeholderImage.component'; import TabView from '../common/tab/tabView.component'; import { formatDateTimeStrings } from '../utils'; -import Breadcrumbs from '../view/breadcrumbs.component'; import ItemDialog from './itemDialog.component'; const ItemsActionMenu = (props: { @@ -65,493 +55,435 @@ const ItemsActionMenu = (props: { }; function ItemsLandingPage() { - // Navigation + const { + catalogue_category_id: catalogueCategoryId, + catalogue_item_id: catalogueItemId, + item_id: itemId, + } = useParams(); - const { item_id: id } = useParams(); - const navigateToCatalogue = useNavigateToCatalogue(); + const { data: itemData, isLoading: itemDataIsLoading } = useGetItem(itemId); - const { data: itemData, isLoading: itemDataIsLoading } = useGetItem(id); + const { data: catalogueItemData, isLoading: catalogueItemDataIsLoading } = + useGetCatalogueItem(catalogueItemId); - const { data: catalogueItemData } = useGetCatalogueItem( - itemData?.catalogue_item_id - ); - - const { data: catalogueCategoryData } = useGetCatalogueCategory( - catalogueItemData?.catalogue_category_id - ); - - const { data: catalogueBreadcrumbs } = useGetCatalogueBreadcrumbs( - catalogueItemData?.catalogue_category_id - ); + const { + data: catalogueCategoryData, + isLoading: catalogueCategoryDataIsLoading, + } = useGetCatalogueCategory(catalogueCategoryId); const { data: systemData } = useGetSystem(itemData?.system_id); - const [itemLandingBreadcrumbs, setItemLandingBreadcrumbs] = React.useState< - BreadcrumbsInfo | undefined - >(catalogueBreadcrumbs); - - React.useEffect(() => { - if (catalogueBreadcrumbs && catalogueItemData) - setItemLandingBreadcrumbs({ - ...catalogueBreadcrumbs, - trail: [ - ...catalogueBreadcrumbs.trail, - [`item/${catalogueItemData.id}`, `${catalogueItemData.name}`], - [`item/${catalogueItemData.id}/items`, 'Items'], - [ - `item/${catalogueItemData.id}/items/${id}`, - itemData?.serial_number ?? 'No serial number', - ], - ], - }); - }, [catalogueBreadcrumbs, catalogueItemData, id, itemData?.serial_number]); - const { data: manufacturer } = useGetManufacturer( catalogueItemData?.manufacturer_id ); return ( - - - navigateToCatalogue(null)} - homeLocation="Catalogue" - /> - - - {catalogueItemData && itemData && catalogueCategoryData && ( - - - {/* Image Section */} - - - - - {/* Title and Description Section */} - - - - {catalogueItemData.name} - - - - Serial Number: {itemData.serial_number ?? 'None'} - - - - Description: - - - {catalogueItemData.description ?? 'None'} - + {(!itemDataIsLoading || + !catalogueItemDataIsLoading || + !catalogueCategoryDataIsLoading) && + catalogueItemData && + itemData && + catalogueCategoryData && ( + + + {/* Image Section */} + + + - - - {/* Actions Section */} - - - + {/* Title and Description Section */} + + + + {catalogueItemData.name} + - , - component: ( - - - Details - - + + Serial Number: {itemData.serial_number ?? 'None'} + - - - - - Serial Number - - - {itemData.serial_number ?? 'None'} - - - - - Asset Number - - - {itemData.asset_number ?? 'None'} - - + + Description: + + + {catalogueItemData.description ?? 'None'} + + + - - - Purchase Order Number - - - {itemData.purchase_order_number ?? 'None'} - - - - - Warranty End Date - - - {itemData.warranty_end_date - ? formatDateTimeStrings( - itemData.warranty_end_date, - false - ) - : 'None'} - - + {/* Actions Section */} - - - Delivered Date - - - {itemData.delivered_date - ? formatDateTimeStrings( - itemData.delivered_date, - false - ) - : 'None'} - - - - - Is Defective - - - {itemData.is_defective ? 'Yes' : 'No'} - - + + - - - Usage Status - - - {itemData.usage_status} - - - - - System - - - - {systemData?.name} - - - - - - Last modified - - - {formatDateTimeStrings( - itemData.modified_time, - true - )} - - - - - Created - - - {formatDateTimeStrings( - itemData.created_time, - true - )} - - + , + component: ( + + + Details + - - - Properties - - - - - {itemData.properties && - itemData.properties.map((property, index) => ( - - {`${property.name} ${ - property.unit ? `(${property.unit})` : '' - }`} - - {property.value !== null - ? String(property.value) - : 'None'} - - - ))} - - - - - Manufacturer - - - - {manufacturer && ( - + - Name + Serial Number - - {manufacturer.name} - + {itemData.serial_number ?? 'None'} - URL + Asset Number - {manufacturer.url ? ( - - {manufacturer.url} - - ) : ( - 'None' - )} + {itemData.asset_number ?? 'None'} + - Telephone number + Purchase Order Number - {manufacturer?.telephone ?? 'None'} + {itemData.purchase_order_number ?? 'None'} - Address + Warranty End Date - - {manufacturer?.address.address_line} + + {itemData.warranty_end_date + ? formatDateTimeStrings( + itemData.warranty_end_date, + false + ) + : 'None'} - - {manufacturer?.address.town} + + + + + Delivered Date - - {manufacturer?.address.county} + + {itemData.delivered_date + ? formatDateTimeStrings( + itemData.delivered_date, + false + ) + : 'None'} + + + + + Is Defective + + + {itemData.is_defective ? 'Yes' : 'No'} + + + + + + Usage Status - {manufacturer?.address.country} + {itemData.usage_status} + + + + + System - {manufacturer?.address.postcode} + + {systemData?.name} + + + + + + Last modified + + + {formatDateTimeStrings( + itemData.modified_time, + true + )} + + + + + Created + + + {formatDateTimeStrings( + itemData.created_time, + true + )} - )} - - ), - order: 0, - }, - { - value: 'Notes', - icon: , - component: ( - - {itemData.notes ?? 'None'} - - ), - order: 3, - }, - ]} - /> + + + Properties + + + + + {itemData.properties && + itemData.properties.map((property, index) => ( + + {`${property.name} ${ + property.unit ? `(${property.unit})` : '' + }`} + + {property.value !== null + ? String(property.value) + : 'None'} + + + ))} + + + + + Manufacturer + + + + {manufacturer && ( + + + + + Name + + + + {manufacturer.name} + + + + + + URL + + + {manufacturer.url ? ( + + {manufacturer.url} + + ) : ( + 'None' + )} + + + + + Telephone number + + + {manufacturer?.telephone ?? 'None'} + + + + + Address + + + {manufacturer?.address.address_line} + + + {manufacturer?.address.town} + + + {manufacturer?.address.county} + + + {manufacturer?.address.country} + + + {manufacturer?.address.postcode} + + + + + )} + + ), + order: 0, + }, + { + value: 'Notes', + icon: , + component: ( + + {itemData.notes ?? 'None'} + + ), + order: 3, + }, + ]} + /> + - - )} - {!itemDataIsLoading ? ( - !itemData && ( - - - No result found - - - This item doesn't exist. Please click the Home button to - navigate to the catalogue home - - - ) - ) : ( + )} + + {(itemDataIsLoading || + catalogueItemDataIsLoading || + catalogueCategoryDataIsLoading) && ( diff --git a/src/mocks/handlers.ts b/src/mocks/handlers.ts index f925af307..219ceb9f8 100644 --- a/src/mocks/handlers.ts +++ b/src/mocks/handlers.ts @@ -97,7 +97,7 @@ export const handlers = [ ); }), - http.get<{ id: string }, DefaultBodyType, CatalogueCategory>( + http.get<{ id: string }, DefaultBodyType, CatalogueCategory | ErrorResponse>( '/v1/catalogue-categories/:id', ({ params }) => { const { id } = params; @@ -105,6 +105,12 @@ export const handlers = [ const data = CatalogueCategoriesJSON.find( (catalogueCategory) => catalogueCategory.id === id ); + if (!data) { + return HttpResponse.json( + { detail: 'Catalogue category not found' }, + { status: 404 } + ); + } return HttpResponse.json(data as CatalogueCategory, { status: 200 }); } @@ -134,13 +140,21 @@ export const handlers = [ } ), - http.get<{ id: string }, DefaultBodyType, BreadcrumbsInfo>( + http.get<{ id: string }, DefaultBodyType, BreadcrumbsInfo | ErrorResponse>( '/v1/catalogue-categories/:id/breadcrumbs', ({ params }) => { const { id } = params; const data = CatalogueCategoryBreadcrumbsJSON.find( (catalogueBreadcrumbs) => catalogueBreadcrumbs.id === id ) as unknown as BreadcrumbsInfo; + + if (!data) { + return HttpResponse.json( + { detail: 'Catalogue category not found' }, + { status: 404 } + ); + } + return HttpResponse.json(data, { status: 200, }); @@ -320,9 +334,16 @@ export const handlers = [ const CatalogueItemData = CatalogueItemsJSON.find( (catalogueItem) => catalogueItem.id === id ); + + if (!CatalogueItemData) { + return HttpResponse.json( + { detail: 'Catalogue not found' }, + { status: 404 } + ); + } + return HttpResponse.json(CatalogueItemData, { status: 200 }); } - return HttpResponse.json({ detail: '' }, { status: 422 }); } ), @@ -742,6 +763,10 @@ export const handlers = [ const data = ItemsJSON.find((items) => items.id === id); + if (!data) { + return HttpResponse.json({ detail: 'Item not found' }, { status: 404 }); + } + return HttpResponse.json(data, { status: 200 }); } ), diff --git a/src/systems/__snapshots__/systemItemsTable.component.test.tsx.snap b/src/systems/__snapshots__/systemItemsTable.component.test.tsx.snap index 4557ad828..7d1212642 100644 --- a/src/systems/__snapshots__/systemItemsTable.component.test.tsx.snap +++ b/src/systems/__snapshots__/systemItemsTable.component.test.tsx.snap @@ -1770,7 +1770,7 @@ exports[`SystemItemsTable > SystemItemsTable (normal) > renders correctly 1`] = > Turbomolecular Pumps 42 @@ -1976,7 +1976,7 @@ exports[`SystemItemsTable > SystemItemsTable (normal) > renders correctly 1`] = > 5xE1KSraISvu @@ -2142,7 +2142,7 @@ exports[`SystemItemsTable > SystemItemsTable (normal) > renders correctly 1`] = > 5xE1KSraISvu @@ -2235,7 +2235,7 @@ exports[`SystemItemsTable > SystemItemsTable (normal) > renders correctly 1`] = > Turbomolecular Pumps 43 diff --git a/src/systems/systemItemsTable.component.test.tsx b/src/systems/systemItemsTable.component.test.tsx index 475309f05..8c4491f0d 100644 --- a/src/systems/systemItemsTable.component.test.tsx +++ b/src/systems/systemItemsTable.component.test.tsx @@ -104,7 +104,7 @@ describe('SystemItemsTable', () => { screen.getByRole('link', { name: `Turbomolecular Pumps 42`, }) - ).toHaveAttribute('href', '/catalogue/item/21'); + ).toHaveAttribute('href', '/catalogue/13/items/21'); }); it('can set a table filter and clear them again', async () => { diff --git a/src/systems/systemItemsTable.component.tsx b/src/systems/systemItemsTable.component.tsx index 08100529f..52fcba4f6 100644 --- a/src/systems/systemItemsTable.component.tsx +++ b/src/systems/systemItemsTable.component.tsx @@ -6,7 +6,6 @@ import { Box, Button, FormControl, - Link as MuiLink, TableCellBaseProps, TextField, Typography, @@ -19,11 +18,11 @@ import { } from 'material-react-table'; import { MRT_Localization_EN } from 'material-react-table/locales/en'; import React from 'react'; -import { Link } from 'react-router-dom'; import { CatalogueItem, Item, System, UsageStatus } from '../api/api.types'; import { useGetCatalogueItemIds } from '../api/catalogueItems'; import { useGetItems } from '../api/items'; import { useGetUsageStatuses } from '../api/usageStatuses'; +import CatalogueLink from '../catalogue/items/catalogueLink.component'; import { usePreservedTableState } from '../common/preservedTableState.component'; import ItemsDetailsPanel from '../items/itemsDetailsPanel.component'; import { @@ -215,16 +214,13 @@ export function SystemItemsTable(props: SystemItemsTableProps) { id: 'catalogueItem.name', Cell: type === 'normal' - ? ({ renderedCellValue, row }) => ( - ( + - {renderedCellValue} - + {row.original.catalogueItem?.name} + ) : undefined, size: 250, @@ -258,15 +254,12 @@ export function SystemItemsTable(props: SystemItemsTableProps) { }} > {type === 'normal' ? ( - - {row.original?.catalogueItem?.name} - + {row.original.catalogueItem?.name} + ) : ( row.original?.catalogueItem?.name )} @@ -286,13 +279,9 @@ export function SystemItemsTable(props: SystemItemsTableProps) { Cell: type === 'normal' ? ({ row }) => ( - + {row.original.item.serial_number ?? 'No serial number'} - + ) : undefined, enableGrouping: false,