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 Render a GranulesListPageHeader
+ * 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 Render a GranulesListPage
+ * 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