From 8cd7f319722e3741c18e8cab4925222c571b10b7 Mon Sep 17 00:00:00 2001 From: "Christopher D. Gokey" Date: Tue, 28 Oct 2025 10:10:32 -0400 Subject: [PATCH 1/5] MMT-4098: Added View Granules Option --- static/src/js/App.jsx | 5 + static/src/js/__tests__/App.test.jsx | 19 ++++ .../components/GranulesList/GranulesList.jsx | 82 ++++++++++++++++ .../__tests__/GranuleList.test.jsx | 96 +++++++++++++++++++ .../__tests__/__mocks__/granuleResults.js | 39 ++++++++ .../js/components/PageHeader/PageHeader.jsx | 2 + .../PublishPreview/PublishPreview.jsx | 9 +- .../__tests__/PublishPreview.test.jsx | 42 ++++++++ .../src/js/operations/queries/getGranules.js | 17 ++++ .../GranulesListPage/GranulesListPage.jsx | 84 ++++++++++++++++ .../__tests__/GranulesListPage.test.jsx | 53 ++++++++++ .../__tests__/__mocks__/granuleResults.js | 39 ++++++++ 12 files changed, 485 insertions(+), 2 deletions(-) create mode 100644 static/src/js/components/GranulesList/GranulesList.jsx create mode 100644 static/src/js/components/GranulesList/__tests__/GranuleList.test.jsx create mode 100644 static/src/js/components/GranulesList/__tests__/__mocks__/granuleResults.js create mode 100644 static/src/js/operations/queries/getGranules.js create mode 100644 static/src/js/pages/GranulesListPage/GranulesListPage.jsx create mode 100644 static/src/js/pages/GranulesListPage/__tests__/GranulesListPage.test.jsx create mode 100644 static/src/js/pages/GranulesListPage/__tests__/__mocks__/granuleResults.js diff --git a/static/src/js/App.jsx b/static/src/js/App.jsx index f0ff0c9f4..4fe1b4e71 100644 --- a/static/src/js/App.jsx +++ b/static/src/js/App.jsx @@ -5,6 +5,7 @@ import { Navigate, createBrowserRouter } from 'react-router-dom' import CollectionAssociationFormPage from '@/js/pages/CollectionAssociationFormPage/CollectionAssociationFormPage' import DraftListPage from '@/js/pages/DraftListPage/DraftListPage' import DraftPage from '@/js/pages/DraftPage/DraftPage' +import GranulesListPage from '@/js/pages/GranulesListPage/GranulesListPage' import GroupFormPage from '@/js/pages/GroupFormPage/GroupFormPage' import GroupListPage from '@/js/pages/GroupListPage/GroupListPage' import GroupPage from '@/js/pages/GroupPage/GroupPage' @@ -111,6 +112,10 @@ export const App = () => { path: '/:type/:conceptId/collection-association', element: }, + { + path: '/collections/:conceptId/granules', + element: + }, { path: '/collections/:conceptId/service-associations', element: diff --git a/static/src/js/__tests__/App.test.jsx b/static/src/js/__tests__/App.test.jsx index 47362d436..b304ac298 100644 --- a/static/src/js/__tests__/App.test.jsx +++ b/static/src/js/__tests__/App.test.jsx @@ -81,6 +81,12 @@ vi.mock('@/js/pages/KeywordManagerPage/KeywordManagerPage', () => ({ )) })) +vi.mock('@/js/pages/GranulesListPage/GranulesListPage', () => ({ + default: vi.fn(() => ( +
Granules List Page
+ )) +})) + const setup = () => { render( @@ -229,4 +235,17 @@ describe('App component', () => { window.history.pushState({}, '', '/') }) }) + + describe('when rendering the "/collections/:conceptId/granules" route', () => { + test('renders the granules list page', async () => { + const mockConceptId = 'C1234567-TEST' + window.history.pushState({}, '', `/collections/${mockConceptId}/granules`) + + setup() + + expect(await screen.findByTestId('mock-granules-list-page')).toBeInTheDocument() + + window.history.pushState({}, '', '/') + }) + }) }) diff --git a/static/src/js/components/GranulesList/GranulesList.jsx b/static/src/js/components/GranulesList/GranulesList.jsx new file mode 100644 index 000000000..4f34419cb --- /dev/null +++ b/static/src/js/components/GranulesList/GranulesList.jsx @@ -0,0 +1,82 @@ +import React, { useMemo } from 'react' + +import { useSuspenseQuery } from '@apollo/client' +import { useParams } from 'react-router' + +import moment from 'moment' + +import { GET_GRANULES } from '@/js/operations/queries/getGranules' +import getConceptTypeByConceptId from '../../utils/getConceptTypeByConceptId' + +import { DATE_FORMAT } from '../../constants/dateFormat' + +import Table from '../Table/Table' + +/** + * Renders a RevisionList component + * + * @component + * @example Render a RevisionList + * return ( + * + * ) + */ +const GranulesList = () => { + const { conceptId } = useParams() + + const derivedConceptType = getConceptTypeByConceptId(conceptId) + + const { data } = useSuspenseQuery(GET_GRANULES, { + variables: { + params: { + conceptId + } + } + }) + + const { [derivedConceptType.toLowerCase()]: concept } = data + const { granules } = concept + const { count, items } = granules + + const sortedItems = useMemo(() => { + const sorted = [...items].sort((a, b) => new Date(b.revisionDate) - new Date(a.revisionDate)) + + return sorted + }, [items]) + + const columns = [ + { + dataKey: 'conceptId', + title: 'Concept Id', + className: 'col-auto' + }, + { + dataKey: 'title', + title: 'Title', + className: 'col-auto' + }, + + { + dataKey: 'revisionDate', + title: 'Revision Date (UTC)', + className: 'col-auto', + dataAccessorFn: (cellData) => moment.utc(cellData).format(DATE_FORMAT) + } + ] + + return ( + `column_${dataKey}_${conceptId}_${revisionId}`} + generateRowKey={({ revisionId }) => `row_${conceptId}_${revisionId}`} + noDataMessage="No results" + count={count} + limit={10} + /> + + ) +} + +export default GranulesList diff --git a/static/src/js/components/GranulesList/__tests__/GranuleList.test.jsx b/static/src/js/components/GranulesList/__tests__/GranuleList.test.jsx new file mode 100644 index 000000000..2c7d26dca --- /dev/null +++ b/static/src/js/components/GranulesList/__tests__/GranuleList.test.jsx @@ -0,0 +1,96 @@ +import React, { Suspense } from 'react' +import { + render, + screen, + within +} from '@testing-library/react' +import { MockedProvider } from '@apollo/client/testing' +import { + MemoryRouter, + Routes, + Route +} from 'react-router-dom' + +import granuleResults from '@/js/components/GranulesList/__tests__/__mocks__/granuleResults' +import GranulesList from '../GranulesList' +import ErrorBoundary from '../../ErrorBoundary/ErrorBoundary' + +const setup = ({ + overrideInitialEntries, + overrideMocks +}) => { + const mocks = [granuleResults] + + render( + + + + + + + + + ) + } + /> + 404 page} + /> + + + + ) +} + +describe('GranulesList component', () => { + describe('when all metadata is provided', () => { + beforeEach(() => { + setup({}) + }) + + test('should render the granules', async () => { + expect(screen.queryByText('Loading...')) + + const tableRows = await screen.findAllByRole('row') + expect(tableRows.length).toEqual(3) // 2 granules + 1 header row + + const date = new Date(2000, 1, 1, 13) + vi.setSystemTime(date) + const rows = screen.queryAllByRole('row') + const row1 = rows[1] + const row2 = rows[2] + + const row1Cells = within(row1).queryAllByRole('cell') + const row2Cells = within(row2).queryAllByRole('cell') + expect(row1Cells).toHaveLength(3) + expect(row1Cells[0].textContent).toBe('G1200484638-CMR_ONLY') + expect(row1Cells[1].textContent).toBe('jteague_granule2321') + expect(row1Cells[2].textContent).toBe('Tuesday, April 29, 2025 6:20 PM') + + expect(row2Cells).toHaveLength(3) + expect(row2Cells[0].textContent).toBe('G1200484635-CMR_ONLY') + expect(row2Cells[1].textContent).toBe('jteague_granule2') + expect(row2Cells[2].textContent).toBe('Tuesday, April 29, 2025 6:18 PM') + }) + }) + + describe('when there is an error', () => { + test('should display an error message', async () => { + setup({ + overrideMocks: [{ + request: granuleResults.request, + error: new Error('An error occurred') + }] + }) + + await screen.findByText('An error occurred') + }) + }) +}) diff --git a/static/src/js/components/GranulesList/__tests__/__mocks__/granuleResults.js b/static/src/js/components/GranulesList/__tests__/__mocks__/granuleResults.js new file mode 100644 index 000000000..269efe90f --- /dev/null +++ b/static/src/js/components/GranulesList/__tests__/__mocks__/granuleResults.js @@ -0,0 +1,39 @@ +import { GET_GRANULES } from '@/js/operations/queries/getGranules' + +const granuleResults = { + request: { + query: GET_GRANULES, + variables: { + params: { + conceptId: 'C1200000104-MMT_2' + } + } + }, + result: { + data: { + collection: { + shortName: '10099-2', + granules: { + count: 2, + items: [ + { + conceptId: 'G1200484635-CMR_ONLY', + title: 'jteague_granule2', + revisionDate: '2025-04-29T18:18:54.983Z', + __typename: 'Granule' + }, + { + conceptId: 'G1200484638-CMR_ONLY', + title: 'jteague_granule2321', + revisionDate: '2025-04-29T18:20:50.608Z', + __typename: 'Granule' + } + ], + __typename: 'GranuleList' + }, + __typename: 'Collection' + } + } + } +} +export default granuleResults diff --git a/static/src/js/components/PageHeader/PageHeader.jsx b/static/src/js/components/PageHeader/PageHeader.jsx index b84d070a3..59f5dcf36 100644 --- a/static/src/js/components/PageHeader/PageHeader.jsx +++ b/static/src/js/components/PageHeader/PageHeader.jsx @@ -214,6 +214,7 @@ const PageHeader = ({ ( { count: actionCount, + disabled, loading: actionLoading, loadingText: actionLoadingText, onClick: actionOnClick, @@ -257,6 +258,7 @@ const PageHeader = ({ className="d-flex flex-row align-items-center" key={actionTitle} eventKey={snakeCase(actionTitle)} + disabled={disabled} onClick={actionOnClick} aria-busy={actionLoading} > diff --git a/static/src/js/components/PublishPreview/PublishPreview.jsx b/static/src/js/components/PublishPreview/PublishPreview.jsx index ec40a2627..e69574ff9 100644 --- a/static/src/js/components/PublishPreview/PublishPreview.jsx +++ b/static/src/js/components/PublishPreview/PublishPreview.jsx @@ -76,6 +76,10 @@ const PublishPreviewHeader = () => { navigate(`/collections/${conceptId}/service-associations`) } + const navigateToGranules = () => { + navigate(`/collections/${conceptId}/granules`) + } + const { addNotification } = useNotificationsContext() const [deleteMutation] = useMutation(deleteMutationTypes[derivedConceptType], { update: (cache) => { @@ -117,6 +121,7 @@ const PublishPreviewHeader = () => { const { count: revisionCount } = revisions let granuleCount = 0 + console.log('granules=', granules) if (granules) { ({ count: granuleCount } = granules) } @@ -231,10 +236,10 @@ const PublishPreviewHeader = () => { ? [ { icon: FaEye, - onClick: () => { }, + onClick: () => navigateToGranules(), title: 'View Granules', count: granuleCount, - disabled: true + disabled: granuleCount === 0 }, { icon: FaEye, diff --git a/static/src/js/components/PublishPreview/__tests__/PublishPreview.test.jsx b/static/src/js/components/PublishPreview/__tests__/PublishPreview.test.jsx index 8ecb39e3d..7ea04078e 100644 --- a/static/src/js/components/PublishPreview/__tests__/PublishPreview.test.jsx +++ b/static/src/js/components/PublishPreview/__tests__/PublishPreview.test.jsx @@ -757,6 +757,44 @@ describe('PublishPreview', () => { }) }) + describe('when clicking on View Granules', () => { + test('should navigate to granules page', async () => { + const navigateSpy = vi.fn() + vi.spyOn(router, 'useNavigate').mockImplementation(() => navigateSpy) + + const { user } = setup({ + overrideInitialEntries: ['/collections/C1000000-MMT/1'], + overridePath: '/collections', + overrideMocks: [ + { + request: { + query: conceptTypeQueries.Collection, + variables: { + params: { + conceptId: 'C1000000-MMT' + } + } + }, + result: { + data: { + collection: publishCollectionRecord + } + } + } + ] + }) + + const moreActionsButton = await screen.findByText(/More Actions/) + await user.click(moreActionsButton) + + const viewGranulesButton = screen.getByRole('button', { name: 'View Granules 1' }) + await user.click(viewGranulesButton) + + expect(navigateSpy).toHaveBeenCalledTimes(1) + expect(navigateSpy).toHaveBeenCalledWith('/collections/C1000000-MMT/granules') + }) + }) + describe('when the collection has no granules', () => { test('should display the granule count with 0', async () => { const { user } = setup({ @@ -803,6 +841,10 @@ describe('PublishPreview', () => { await user.click(moreActionsButton) expect(screen.getByRole('button', { name: 'View Granules 0' })) + + const viewGranulesButton = screen.getByRole('button', { name: 'View Granules 0' }) + expect(viewGranulesButton).toBeInTheDocument() + expect(viewGranulesButton).toHaveClass('disabled') }) }) }) diff --git a/static/src/js/operations/queries/getGranules.js b/static/src/js/operations/queries/getGranules.js new file mode 100644 index 000000000..f70edc947 --- /dev/null +++ b/static/src/js/operations/queries/getGranules.js @@ -0,0 +1,17 @@ +import { gql } from '@apollo/client' + +export const GET_GRANULES = gql` + query GetGranules ($params: CollectionInput) { + collection (params: $params) { + granules { + count + items { + conceptId + title + revisionDate + } + } + shortName + } + } +` diff --git a/static/src/js/pages/GranulesListPage/GranulesListPage.jsx b/static/src/js/pages/GranulesListPage/GranulesListPage.jsx new file mode 100644 index 000000000..0d085a3de --- /dev/null +++ b/static/src/js/pages/GranulesListPage/GranulesListPage.jsx @@ -0,0 +1,84 @@ +import React, { Suspense } from 'react' + +import { useParams } from 'react-router' + +import ErrorBoundary from '@/js/components/ErrorBoundary/ErrorBoundary' +import LoadingTable from '@/js/components/LoadingTable/LoadingTable' +import Page from '@/js/components/Page/Page' +import PageHeader from '@/js/components/PageHeader/PageHeader' + +import GranulesList from '@/js/components/GranulesList/GranulesList' +import { useSuspenseQuery } from '@apollo/client' +import { GET_GRANULES } from '@/js/operations/queries/getGranules' + +/** + * Renders a GranulesListPageHeader component + * + * @component + * @example + * return ( + * + * ) + */ +const GranulesListPageHeader = () => { + const { conceptId } = useParams() + + const { data } = useSuspenseQuery(GET_GRANULES, { + variables: { + params: { + conceptId + } + } + }) + + const { collection } = data + + const { shortName: name } = collection + + return ( + + ) +} + +/** + * Renders a GranulesListPage component + * + * @component + * @example + * return ( + * + * ) + */ +const GranulesListPage = () => ( + } + > + + }> + + + + +) + +export default GranulesListPage diff --git a/static/src/js/pages/GranulesListPage/__tests__/GranulesListPage.test.jsx b/static/src/js/pages/GranulesListPage/__tests__/GranulesListPage.test.jsx new file mode 100644 index 000000000..6789d6b61 --- /dev/null +++ b/static/src/js/pages/GranulesListPage/__tests__/GranulesListPage.test.jsx @@ -0,0 +1,53 @@ +import React from 'react' +import { + MemoryRouter, + Route, + Routes +} from 'react-router-dom' +import { render, screen } from '@testing-library/react' +import { MockedProvider } from '@apollo/client/testing' + +import GranulesListPage from '../GranulesListPage' +import granuleResults from './__mocks__/granuleResults' + +vi.mock('../../../components/GranulesList/GranulesList') + +vi.mock('react-router-dom', async () => ({ + ...await vi.importActual('react-router-dom'), + useParams: vi.fn().mockImplementation(() => ({ conceptId: 'C1200000104-MMT_2' })) +})) + +const setup = () => { + const mocks = [ + granuleResults + ] + + render( + + + + } /> + + + + ) +} + +describe('GranulesListPage', () => { + describe('when showing the header', () => { + test('render the header', async () => { + setup() + + expect(await screen.findByText('Collections')).toBeInTheDocument() + expect(screen.getByText('C1200000104-MMT_2')).toBeInTheDocument() + expect(screen.getByText('10099-2 Granules')).toBeInTheDocument() + }) + }) +}) diff --git a/static/src/js/pages/GranulesListPage/__tests__/__mocks__/granuleResults.js b/static/src/js/pages/GranulesListPage/__tests__/__mocks__/granuleResults.js new file mode 100644 index 000000000..269efe90f --- /dev/null +++ b/static/src/js/pages/GranulesListPage/__tests__/__mocks__/granuleResults.js @@ -0,0 +1,39 @@ +import { GET_GRANULES } from '@/js/operations/queries/getGranules' + +const granuleResults = { + request: { + query: GET_GRANULES, + variables: { + params: { + conceptId: 'C1200000104-MMT_2' + } + } + }, + result: { + data: { + collection: { + shortName: '10099-2', + granules: { + count: 2, + items: [ + { + conceptId: 'G1200484635-CMR_ONLY', + title: 'jteague_granule2', + revisionDate: '2025-04-29T18:18:54.983Z', + __typename: 'Granule' + }, + { + conceptId: 'G1200484638-CMR_ONLY', + title: 'jteague_granule2321', + revisionDate: '2025-04-29T18:20:50.608Z', + __typename: 'Granule' + } + ], + __typename: 'GranuleList' + }, + __typename: 'Collection' + } + } + } +} +export default granuleResults From d01905cca8bee7ce1d83e730d80f994bf68a6502 Mon Sep 17 00:00:00 2001 From: "Christopher D. Gokey" Date: Tue, 28 Oct 2025 14:45:31 -0400 Subject: [PATCH 2/5] MMT-4098: Updated comments --- static/src/js/components/GranulesList/GranulesList.jsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/static/src/js/components/GranulesList/GranulesList.jsx b/static/src/js/components/GranulesList/GranulesList.jsx index 4f34419cb..528abd737 100644 --- a/static/src/js/components/GranulesList/GranulesList.jsx +++ b/static/src/js/components/GranulesList/GranulesList.jsx @@ -13,12 +13,12 @@ import { DATE_FORMAT } from '../../constants/dateFormat' import Table from '../Table/Table' /** - * Renders a RevisionList component + * Renders a GranulesList component * * @component - * @example + * @example * return ( - * + * * ) */ const GranulesList = () => { From 09ab16a98f8e9286ab157b5bf7c8289439e02ec7 Mon Sep 17 00:00:00 2001 From: "Christopher D. Gokey" Date: Tue, 28 Oct 2025 14:46:24 -0400 Subject: [PATCH 3/5] MMT-4098: Removed console log. --- static/src/js/components/PublishPreview/PublishPreview.jsx | 1 - 1 file changed, 1 deletion(-) diff --git a/static/src/js/components/PublishPreview/PublishPreview.jsx b/static/src/js/components/PublishPreview/PublishPreview.jsx index e69574ff9..9a4eb6170 100644 --- a/static/src/js/components/PublishPreview/PublishPreview.jsx +++ b/static/src/js/components/PublishPreview/PublishPreview.jsx @@ -121,7 +121,6 @@ const PublishPreviewHeader = () => { const { count: revisionCount } = revisions let granuleCount = 0 - console.log('granules=', granules) if (granules) { ({ count: granuleCount } = granules) } From a5c2c4e68139e42c0f402e503db5292808ba2dd1 Mon Sep 17 00:00:00 2001 From: "Christopher D. Gokey" Date: Fri, 31 Oct 2025 12:55:34 -0400 Subject: [PATCH 4/5] MMT-4098: Updated to use Paginated table. --- .../components/GranulesList/GranulesList.jsx | 123 +++++++--- .../__tests__/GranuleList.test.jsx | 223 ++++++++++++++---- .../__tests__/__mocks__/granuleResults.js | 39 --- .../src/js/operations/queries/getGranules.js | 4 +- 4 files changed, 267 insertions(+), 122 deletions(-) delete mode 100644 static/src/js/components/GranulesList/__tests__/__mocks__/granuleResults.js diff --git a/static/src/js/components/GranulesList/GranulesList.jsx b/static/src/js/components/GranulesList/GranulesList.jsx index 528abd737..a55a87e1f 100644 --- a/static/src/js/components/GranulesList/GranulesList.jsx +++ b/static/src/js/components/GranulesList/GranulesList.jsx @@ -1,28 +1,23 @@ -import React, { useMemo } from 'react' - +import React, { useState } from 'react' import { useSuspenseQuery } from '@apollo/client' import { useParams } from 'react-router' - import moment from 'moment' +import pluralize from 'pluralize' +import Col from 'react-bootstrap/Col' +import Row from 'react-bootstrap/Row' import { GET_GRANULES } from '@/js/operations/queries/getGranules' +import ControlledPaginatedContent from '@/js/components/ControlledPaginatedContent/ControlledPaginatedContent' import getConceptTypeByConceptId from '../../utils/getConceptTypeByConceptId' - import { DATE_FORMAT } from '../../constants/dateFormat' - import Table from '../Table/Table' -/** - * Renders a GranulesList component - * - * @component - * @example - * return ( - * - * ) - */ const GranulesList = () => { const { conceptId } = useParams() + const [activePage, setActivePage] = useState(1) + + const limit = 10 + const offset = (activePage - 1) * limit const derivedConceptType = getConceptTypeByConceptId(conceptId) @@ -30,6 +25,10 @@ const GranulesList = () => { variables: { params: { conceptId + }, + granulesParams: { + limit, + offset } } }) @@ -38,11 +37,9 @@ const GranulesList = () => { const { granules } = concept const { count, items } = granules - const sortedItems = useMemo(() => { - const sorted = [...items].sort((a, b) => new Date(b.revisionDate) - new Date(a.revisionDate)) - - return sorted - }, [items]) + const setPage = (nextPage) => { + setActivePage(nextPage) + } const columns = [ { @@ -55,27 +52,89 @@ const GranulesList = () => { title: 'Title', className: 'col-auto' }, - { dataKey: 'revisionDate', title: 'Revision Date (UTC)', className: 'col-auto', - dataAccessorFn: (cellData) => moment.utc(cellData).format(DATE_FORMAT) + dataAccessorFn: (cellData) => { + if (cellData === null || cellData === undefined) { + return 'N/A' + } + + return moment.utc(cellData).format(DATE_FORMAT) + } } ] return ( -
Render a GranulesListPageHeaderRender a GranulesListPageRender a RevisionListRender a GranulesListRender a GranulesList
`column_${dataKey}_${conceptId}_${revisionId}`} - generateRowKey={({ revisionId }) => `row_${conceptId}_${revisionId}`} - noDataMessage="No results" - count={count} - limit={10} - /> - + + + + { + ({ + totalPages, + pagination, + firstResultPosition, + lastResultPosition + }) => { + const paginationMessage = count > 0 + ? `Showing ${totalPages > 1 ? `${firstResultPosition}-${lastResultPosition} of` : ''} ${count} ${pluralize('granule', count)}` + : 'No granules found' + + return ( + <> + + + { + (!!count) && ( + {paginationMessage} + ) + } + + + { + totalPages > 1 && ( + + {pagination} + + ) + } + +
`column_${dataKey}_${granuleConceptId}_${revisionId}`} + generateRowKey={({ conceptId: granuleConceptId, revisionId }) => `row_${granuleConceptId}_${revisionId}`} + noDataMessage="No granules found" + count={count} + setPage={setPage} + limit={limit} + offset={offset} + /> + { + totalPages > 1 && ( + + +
+ {pagination} +
+ + + ) + } + + ) + } + } + + + ) } diff --git a/static/src/js/components/GranulesList/__tests__/GranuleList.test.jsx b/static/src/js/components/GranulesList/__tests__/GranuleList.test.jsx index 2c7d26dca..7b904f660 100644 --- a/static/src/js/components/GranulesList/__tests__/GranuleList.test.jsx +++ b/static/src/js/components/GranulesList/__tests__/GranuleList.test.jsx @@ -2,30 +2,71 @@ import React, { Suspense } from 'react' import { render, screen, + waitFor, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' import { MockedProvider } from '@apollo/client/testing' import { MemoryRouter, Routes, Route } from 'react-router-dom' +import { GraphQLError } from 'graphql' -import granuleResults from '@/js/components/GranulesList/__tests__/__mocks__/granuleResults' +import { GET_GRANULES } from '@/js/operations/queries/getGranules' import GranulesList from '../GranulesList' import ErrorBoundary from '../../ErrorBoundary/ErrorBoundary' -const setup = ({ - overrideInitialEntries, - overrideMocks -}) => { - const mocks = [granuleResults] +const createGranulesMock = (offset = 0) => ({ + request: { + query: GET_GRANULES, + variables: { + params: { + conceptId: 'C1200000104-MMT_2' + }, + granulesParams: { + limit: 10, + offset + } + } + }, + result: { + data: { + collection: { + shortName: '10099-2', + granules: { + count: 15, + items: Array(Math.min(10, 15 - offset)).fill(null).map((_, index) => ({ + conceptId: `G120048463${5 + index + offset}-CMR_ONLY`, + title: `test_granule${1 + index + offset}`, + revisionDate: `2025-04-29T18:${18 + index + offset}:54.983Z` + })) + } + } + } + } +}) + +/** + * Sets up the test environment for GranulesList component. + * + * @param {Array} mocks - Apollo Client mocks for GraphQL queries. + * Default mocks: + * - First mock: Initial query for the first page (offset 0), triggered by useSuspenseQuery + * - Second mock: Subsequent query with the same parameters (offset 0), also triggered by useSuspenseQuery + * This second query is part of React Suspense's design to ensure fresh data after the initial load + * - Third mock: Query for the second page when pagination is clicked (offset 10) + * + * This setup reflects the expected behavior of useSuspenseQuery with React Suspense, + * where two queries are made for the initial data load to ensure data freshness. + */ +const setup = (mocks = [createGranulesMock(0), createGranulesMock(0), createGranulesMock(10)]) => { + const user = userEvent.setup() render( - - + + - 404 page} - /> ) + + return { user } } describe('GranulesList component', () => { - describe('when all metadata is provided', () => { - beforeEach(() => { - setup({}) + describe('when granules are returned', () => { + test('renders initial granule list', async () => { + setup([createGranulesMock(0)]) + + // Check initial render + await screen.findByText('Showing 1-10 of 15 granules') + + const rows = screen.getAllByRole('row') + expect(rows).toHaveLength(11) + + expect(within(rows[1]).getByRole('cell', { name: 'G1200484635-CMR_ONLY' })).toBeInTheDocument() + expect(within(rows[1]).getByRole('cell', { name: 'test_granule1' })).toBeInTheDocument() + expect(within(rows[1]).getByRole('cell', { name: 'Tuesday, April 29, 2025 6:18 PM' })).toBeInTheDocument() + + expect(within(rows[10]).getByRole('cell', { name: 'G12004846314-CMR_ONLY' })).toBeInTheDocument() + expect(within(rows[10]).getByRole('cell', { name: 'test_granule10' })).toBeInTheDocument() + expect(within(rows[10]).getByRole('cell', { name: 'Tuesday, April 29, 2025 6:27 PM' })).toBeInTheDocument() + }) + + test('updates granule list when pagination is clicked', async () => { + const { user } = setup() + + // Check for the presence of the first granule + await waitFor(() => { + expect(screen.getByText('G1200484635-CMR_ONLY')).toBeInTheDocument() + }) + + // Find all "Next" buttons and click the first one + const nextButtons = screen.getAllByRole('button', { name: /next/i }) + expect(nextButtons.length).toBeGreaterThan(0) + await user.click(nextButtons[0]) + + // Wait for the second page to load + await waitFor(() => { + expect(screen.getByText('Showing 11-15 of 15 granules')).toBeInTheDocument() + }) + + // Check that a granule from the second page is present + expect(screen.getByText('G12004846315-CMR_ONLY')).toBeInTheDocument() + + // Check that a granule from the first page is no longer present + expect(screen.queryByText('G1200484635-CMR_ONLY')).not.toBeInTheDocument() }) + }) + + describe('when no granules are found', () => { + test('renders no granules message', async () => { + setup([{ + request: createGranulesMock().request, + result: { + data: { + collection: { + shortName: '10099-2', + granules: { + count: 0, + items: [], + __typename: 'GranuleList' + }, + __typename: 'Collection' + } + } + } + }]) - test('should render the granules', async () => { - expect(screen.queryByText('Loading...')) - - const tableRows = await screen.findAllByRole('row') - expect(tableRows.length).toEqual(3) // 2 granules + 1 header row - - const date = new Date(2000, 1, 1, 13) - vi.setSystemTime(date) - const rows = screen.queryAllByRole('row') - const row1 = rows[1] - const row2 = rows[2] - - const row1Cells = within(row1).queryAllByRole('cell') - const row2Cells = within(row2).queryAllByRole('cell') - expect(row1Cells).toHaveLength(3) - expect(row1Cells[0].textContent).toBe('G1200484638-CMR_ONLY') - expect(row1Cells[1].textContent).toBe('jteague_granule2321') - expect(row1Cells[2].textContent).toBe('Tuesday, April 29, 2025 6:20 PM') - - expect(row2Cells).toHaveLength(3) - expect(row2Cells[0].textContent).toBe('G1200484635-CMR_ONLY') - expect(row2Cells[1].textContent).toBe('jteague_granule2') - expect(row2Cells[2].textContent).toBe('Tuesday, April 29, 2025 6:18 PM') + await screen.findByText('No granules found') }) }) - describe('when there is an error', () => { - test('should display an error message', async () => { - setup({ - overrideMocks: [{ - request: granuleResults.request, - error: new Error('An error occurred') - }] - }) + describe('when a granule has no revision date', () => { + test('renders N/A for missing revision date', async () => { + const mockWithNoRevisionDate = { + request: { + query: GET_GRANULES, + variables: { + params: { + conceptId: 'C1200000104-MMT_2' + }, + granulesParams: { + limit: 10, + offset: 0 + } + } + }, + result: { + data: { + collection: { + shortName: '10099-2', + granules: { + count: 1, + items: [ + { + conceptId: 'G1200484635-CMR_ONLY', + title: 'test_granule1', + revisionDate: null + } + ] + } + } + } + } + } + + setup([mockWithNoRevisionDate]) + + // Wait for the granule list to load + await screen.findByText('Showing 1 granule') + + // Check that the granule is displayed with N/A for the revision date + expect(screen.getByText('G1200484635-CMR_ONLY')).toBeInTheDocument() + expect(screen.getByText('test_granule1')).toBeInTheDocument() + expect(screen.getByText('N/A')).toBeInTheDocument() + + // Verify that the date column header is still present + expect(screen.getByText('Revision Date (UTC)')).toBeInTheDocument() + }) + }) + + describe('when an error occurs', () => { + test('renders error message', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + + setup([{ + request: createGranulesMock().request, + error: new GraphQLError('An error occurred') + }]) - await screen.findByText('An error occurred') + await screen.findByText(/An error occurred/) }) }) }) diff --git a/static/src/js/components/GranulesList/__tests__/__mocks__/granuleResults.js b/static/src/js/components/GranulesList/__tests__/__mocks__/granuleResults.js deleted file mode 100644 index 269efe90f..000000000 --- a/static/src/js/components/GranulesList/__tests__/__mocks__/granuleResults.js +++ /dev/null @@ -1,39 +0,0 @@ -import { GET_GRANULES } from '@/js/operations/queries/getGranules' - -const granuleResults = { - request: { - query: GET_GRANULES, - variables: { - params: { - conceptId: 'C1200000104-MMT_2' - } - } - }, - result: { - data: { - collection: { - shortName: '10099-2', - granules: { - count: 2, - items: [ - { - conceptId: 'G1200484635-CMR_ONLY', - title: 'jteague_granule2', - revisionDate: '2025-04-29T18:18:54.983Z', - __typename: 'Granule' - }, - { - conceptId: 'G1200484638-CMR_ONLY', - title: 'jteague_granule2321', - revisionDate: '2025-04-29T18:20:50.608Z', - __typename: 'Granule' - } - ], - __typename: 'GranuleList' - }, - __typename: 'Collection' - } - } - } -} -export default granuleResults diff --git a/static/src/js/operations/queries/getGranules.js b/static/src/js/operations/queries/getGranules.js index f70edc947..949db8f59 100644 --- a/static/src/js/operations/queries/getGranules.js +++ b/static/src/js/operations/queries/getGranules.js @@ -1,9 +1,9 @@ import { gql } from '@apollo/client' export const GET_GRANULES = gql` - query GetGranules ($params: CollectionInput) { + query GetCollection($params: CollectionInput, $granulesParams: GranulesInput) { collection (params: $params) { - granules { + granules (params: $granulesParams) { count items { conceptId From df7ac7ae5dc64427ffbd7aaa3895e9728929cde6 Mon Sep 17 00:00:00 2001 From: "Christopher D. Gokey" Date: Fri, 31 Oct 2025 12:58:58 -0400 Subject: [PATCH 5/5] MMT-4098: Updated name of disabled option. --- static/src/js/components/PageHeader/PageHeader.jsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/static/src/js/components/PageHeader/PageHeader.jsx b/static/src/js/components/PageHeader/PageHeader.jsx index 59f5dcf36..166d80069 100644 --- a/static/src/js/components/PageHeader/PageHeader.jsx +++ b/static/src/js/components/PageHeader/PageHeader.jsx @@ -214,7 +214,7 @@ const PageHeader = ({ ( { count: actionCount, - disabled, + disabled: actionDisabled, loading: actionLoading, loadingText: actionLoadingText, onClick: actionOnClick, @@ -258,7 +258,7 @@ const PageHeader = ({ className="d-flex flex-row align-items-center" key={actionTitle} eventKey={snakeCase(actionTitle)} - disabled={disabled} + disabled={actionDisabled} onClick={actionOnClick} aria-busy={actionLoading} >