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..a55a87e1f --- /dev/null +++ b/static/src/js/components/GranulesList/GranulesList.jsx @@ -0,0 +1,141 @@ +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' + +const GranulesList = () => { + const { conceptId } = useParams() + const [activePage, setActivePage] = useState(1) + + const limit = 10 + const offset = (activePage - 1) * limit + + const derivedConceptType = getConceptTypeByConceptId(conceptId) + + const { data } = useSuspenseQuery(GET_GRANULES, { + variables: { + params: { + conceptId + }, + granulesParams: { + limit, + offset + } + } + }) + + const { [derivedConceptType.toLowerCase()]: concept } = data + const { granules } = concept + const { count, items } = granules + + const setPage = (nextPage) => { + setActivePage(nextPage) + } + + 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) => { + if (cellData === null || cellData === undefined) { + return 'N/A' + } + + return moment.utc(cellData).format(DATE_FORMAT) + } + } + ] + + return ( + + + + { + ({ + 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} +
+ + + ) + } + + ) + } + } + + + + ) +} + +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..7b904f660 --- /dev/null +++ b/static/src/js/components/GranulesList/__tests__/GranuleList.test.jsx @@ -0,0 +1,221 @@ +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 { GET_GRANULES } from '@/js/operations/queries/getGranules' +import GranulesList from '../GranulesList' +import ErrorBoundary from '../../ErrorBoundary/ErrorBoundary' + +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( + + + + + + + + + ) + } + /> + + + + ) + + return { user } +} + +describe('GranulesList component', () => { + 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' + } + } + } + }]) + + await screen.findByText('No granules found') + }) + }) + + 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/) + }) + }) +}) diff --git a/static/src/js/components/PageHeader/PageHeader.jsx b/static/src/js/components/PageHeader/PageHeader.jsx index b84d070a3..166d80069 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: actionDisabled, 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={actionDisabled} 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..9a4eb6170 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) => { @@ -231,10 +235,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..949db8f59 --- /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 GetCollection($params: CollectionInput, $granulesParams: GranulesInput) { + collection (params: $params) { + granules (params: $granulesParams) { + 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
Render a GranulesListPageHeaderRender a GranulesListPage