diff --git a/static/src/js/components/CollectionAssociationForm/CollectionAssociationForm.jsx b/static/src/js/components/CollectionAssociationForm/CollectionAssociationForm.jsx index 516a541a0..2cbafbba0 100644 --- a/static/src/js/components/CollectionAssociationForm/CollectionAssociationForm.jsx +++ b/static/src/js/components/CollectionAssociationForm/CollectionAssociationForm.jsx @@ -1,12 +1,10 @@ import Form from '@rjsf/core' import pluralize from 'pluralize' - import React, { useCallback, useEffect, useState } from 'react' -import PropTypes from 'prop-types' import validator from '@rjsf/validator-ajv8' import camelcaseKeys from 'camelcase-keys' import { @@ -25,10 +23,10 @@ import moment from 'moment' import Alert from 'react-bootstrap/Alert' import Col from 'react-bootstrap/Col' -import Placeholder from 'react-bootstrap/Placeholder' import Row from 'react-bootstrap/Row' import Button from '@/js/components/Button/Button' +import ControlledPaginatedContent from '@/js/components/ControlledPaginatedContent/ControlledPaginatedContent' import CustomDateTimeWidget from '@/js/components/CustomDateTimeWidget/CustomDateTimeWidget' import CustomFieldTemplate from '@/js/components/CustomFieldTemplate/CustomFieldTemplate' import CustomSelectWidget from '@/js/components/CustomSelectWidget/CustomSelectWidget' @@ -37,9 +35,7 @@ import CustomTitleField from '@/js/components/CustomTitleField/CustomTitleField' import EllipsisLink from '@/js/components/EllipsisLink/EllipsisLink' import EllipsisText from '@/js/components/EllipsisText/EllipsisText' import GridLayout from '@/js/components/GridLayout/GridLayout' -import LoadingBanner from '@/js/components/LoadingBanner/LoadingBanner' import OneOfField from '@/js/components/OneOfField/OneOfField' -import Pagination from '@/js/components/Pagination/Pagination' import Table from '@/js/components/Table/Table' import collectionAssociation from '@/js/schemas/collectionAssociation' @@ -66,37 +62,33 @@ import conceptTypeQueries from '@/js/constants/conceptTypeQueries' * * ) */ -const CollectionAssociationForm = ({ metadata }) => { +const CollectionAssociationForm = () => { const { conceptId } = useParams() const navigate = useNavigate() const { addNotification } = useNotificationsContext() + const derivedConceptType = getConceptTypeByConceptId(conceptId) + + // Form state variables const [searchFormData, setSearchFormData] = useState({}) + const [isAssociatingCollections, setIsAssociatingCollections] = useState(false) const [focusField, setFocusField] = useState(null) - const [searchParams, setSearchParams] = useSearchParams() - const [collectionLoading, setCollectionLoading] = useState() - const [showSelectCollection, setShowSelectCollection] = useState(false) - const [collectionSearchResult, setCollectionSearchResult] = useState({}) - const [collectionConceptIds, setCollectionConceptIds] = useState([]) - const [loading, setLoading] = useState(true) - const [fetchedDraft, setFetchedDraft] = useState() - useEffect(() => { - setFetchedDraft(metadata) - setLoading(false) - setCollectionLoading(false) - }, [metadata]) - - const derivedConceptType = getConceptTypeByConceptId(conceptId) + // Get Collection variables + const [searchParams, setSearchParams] = useSearchParams() + const [collectionSearchResult, setCollectionSearchResult] = useState(null) + const sortKeyParam = searchParams.get('sortKey') + // Pagination variables + const [activePage, setActivePage] = useState(1) const limit = 20 - const activePage = parseInt(searchParams.get('page'), 10) || 1 - const sortKeyParam = searchParams.get('sortKey') - const page = searchParams.get('page') const offset = (activePage - 1) * limit + // Checkbox variables + const [collectionConceptIds, setCollectionConceptIds] = useState([]) + const fields = { OneOfField, TitleField: CustomTitleField, @@ -128,8 +120,9 @@ const CollectionAssociationForm = ({ metadata }) => { params: { limit: 2000 } - } - }, { skip: derivedConceptType !== conceptIdTypes.O }) + }, + skip: derivedConceptType !== conceptIdTypes.O + }) if (derivedConceptType === conceptIdTypes.O) { const { required } = schema @@ -157,23 +150,38 @@ const CollectionAssociationForm = ({ metadata }) => { // Validate ummMetadata const { errors: validationErrors } = validator.validateFormData(searchFormData, schema) - // Query to retrieve collections - const [getCollections] = useLazyQuery(GET_COLLECTIONS, { - onCompleted: (getCollectionsData) => { - setCollectionSearchResult(getCollectionsData.collections) - setCollectionLoading(false) + // Query to retrieve collections after 'Search for Collection' button click + const [ + getCollections, + { + loading: collectionLoading, + data: collectionData + }] = useLazyQuery(GET_COLLECTIONS, { + onCompleted: () => { + setCollectionSearchResult(collectionData.collections) }, onError: () => { - setCollectionLoading(false) errorLogger('Unable to get Collections', 'Collection Association: getCollections Query') setCollectionSearchResult(null) } }) - const collectionSearch = () => { - setCollectionLoading(true) - setShowSelectCollection(true) + // Query to retrieve concept after 'Search for Collection' button click + const [ + getConcept, + { + loading: conceptLoading, + data: conceptData + } + ] = useLazyQuery(conceptTypeQueries[derivedConceptType], { + fetchPolicy: 'network-only', + onError: () => { + errorLogger('Unable to get previous record', 'Collection Association: getConcept Query') + } + }) + + const collectionSearch = () => { const searchField = searchParams.get('searchField') const searchFieldValue = searchParams.get('searchFieldValue') const provider = searchParams.get('provider') @@ -193,6 +201,16 @@ const CollectionAssociationForm = ({ metadata }) => { }) } + const setPage = (nextPage) => { + setActivePage(nextPage) + } + + useEffect(() => { + if (collectionSearchResult) { + collectionSearch() + } + }, [activePage]) + const handleCollectionSearch = () => { const formattedFormData = camelcaseKeys(searchFormData, { deep: true }) const { searchField } = formattedFormData @@ -215,17 +233,17 @@ const CollectionAssociationForm = ({ metadata }) => { return Object.fromEntries(currentParams) }) + getConcept({ + variables: { + params: { + conceptId + } + } + }) + collectionSearch() } - // Calls handleCollectionSearch get the next set of collections for pagination - useEffect(() => { - if (page) { - collectionSearch() - } - }, [page]) - - // Handles checkbox selection. If selected, then adds the value to collectionConceptIds state variable const handleCheckbox = (event) => { const { target } = event @@ -242,6 +260,7 @@ const CollectionAssociationForm = ({ metadata }) => { // Handles selected collection association button by calling CREATE_ASSOCIATION mutation const handleAssociateSelectedCollection = () => { + setIsAssociatingCollections(true) let variables = { conceptId, associatedConceptIds: collectionConceptIds @@ -264,7 +283,7 @@ const CollectionAssociationForm = ({ metadata }) => { createAssociationMutation({ variables, onCompleted: () => { - setLoading(true) + setIsAssociatingCollections(false) if (derivedConceptType === conceptIdTypes.O) { navigate(`/order-options/${conceptId}`) } else { @@ -277,7 +296,7 @@ const CollectionAssociationForm = ({ metadata }) => { }) }, onError: () => { - setLoading(false) + setIsAssociatingCollections(false) errorLogger('Unable to create association', 'Collection Association Form: createAssociationForm') addNotification({ message: 'Error updating association', @@ -310,7 +329,8 @@ const CollectionAssociationForm = ({ metadata }) => { let checked = null const { conceptId: collectionConceptId } = rowData - const { collections = {} } = fetchedDraft + const fetchedConceptData = conceptData[camelCase(derivedConceptType)] + const { collections = {} } = fetchedConceptData const { items } = collections // Checks if collection is already associated to the record. @@ -339,30 +359,22 @@ const CollectionAssociationForm = ({ metadata }) => { ) }) - useEffect(() => { - if (sortKeyParam) { - collectionSearch() - } - }, [sortKeyParam]) - const sortFn = useCallback((key, order) => { let nextSortKey - searchParams.set('sortKey', nextSortKey) - setSearchParams((currentParams) => { if (order === 'ascending') nextSortKey = `-${key}` if (order === 'descending') nextSortKey = key - // Reset the page parameter - currentParams.delete('page') - // Set the sort key currentParams.set('sortKey', nextSortKey) return Object.fromEntries(currentParams) }) - }, []) + + setActivePage(1) // Reset to first page when sorting + collectionSearch() // Trigger a new search + }, [setSearchParams, collectionSearch]) const collectionColumns = [ { @@ -389,7 +401,7 @@ const CollectionAssociationForm = ({ metadata }) => { className: 'col-auto', dataAccessorFn: buildEllipsisTextCell, align: 'center', - sortFn + sortFn: (_, order) => sortFn('provider', order) }, { dataKey: 'version', @@ -400,32 +412,36 @@ const CollectionAssociationForm = ({ metadata }) => { ] - const setPage = (nextPage) => { - setSearchParams((currentParams) => { - currentParams.set('page', nextPage) - - return Object.fromEntries(currentParams) - }) - } - - if (loading) { - return ( - - ) - } - const { items = [], count } = collectionSearchResult || {} - const totalPages = Math.ceil(count / limit) - - const currentPageIndex = Math.floor(offset / limit) - const firstResultIndex = currentPageIndex * limit - const isLastPage = totalPages === activePage - const lastResultIndex = firstResultIndex + (isLastPage ? count % limit : limit) + const renderTableContent = () => { + if (collectionSearchResult) { + return ( +
+ + {' '} + + Disabled rows in the results below represent collections that are + already associated with this record. + + `column_${dataKey}_${conceptIdCell}`} + generateRowKey={({ conceptId: conceptIdRow }) => `row_${conceptIdRow}`} + limit={20} + noDataMessage="No Collections Found." + sortKey={sortKeyParam} + /> + + ) + } - const paginationMessage = count > 0 - ? `Showing Collections ${totalPages > 1 ? `${firstResultIndex + 1}-${lastResultIndex} of` : ''} ${count}` - : 'No matching Collections found' + return null + } return ( <> @@ -446,13 +462,10 @@ const CollectionAssociationForm = ({ metadata }) => { } } > -
- +
-
- { - showSelectCollection - && ( - <> - -
- { - !count && collectionLoading && ( -
- - - - - -
- ) - } - { - (!!count || (!collectionLoading && !count)) && ( - {paginationMessage} - ) - } - - { - totalPages > 1 && ( - - - - ) - } - - { - (!collectionLoading && items.length > 0) && ( - - {' '} - - Disabled rows in the results below represent collections that are - already associated with this record. - + + + + { + ({ + totalPages, + pagination, + firstResultPosition, + lastResultPosition + }) => { + const paginationMessage = `Showing ${totalPages > 1 ? `${firstResultPosition}-${lastResultPosition} of` : ''} ${count} ${pluralize('Collection', count)}` + + return ( + <> + + + { + (!!count) && ( + {paginationMessage} + ) + } + + + { + totalPages > 1 && ( + + {pagination} + + ) + } + + {renderTableContent()} + { + totalPages > 1 && ( + + +
+ {pagination} +
+ + + ) + } + ) } -
`column_${dataKey}_${conceptIdCell}`} - generateRowKey={({ conceptId: conceptIdRow }) => `row_${conceptIdRow}`} - noDataMessage="No Collections Found." - limit={limit} - offset={offset} - sortKey={sortKeyParam} - /> - - - ) - } - + } + + + + { + collectionSearchResult ? ( + + ) : null + } ) } -CollectionAssociationForm.defaultProps = { - metadata: {} -} - -CollectionAssociationForm.propTypes = { - metadata: PropTypes.shape({}) -} - export default CollectionAssociationForm diff --git a/static/src/js/components/CollectionAssociationForm/__tests__/CollectionAssociationForm.test.jsx b/static/src/js/components/CollectionAssociationForm/__tests__/CollectionAssociationForm.test.jsx index fc3f78739..88cb8bcfe 100644 --- a/static/src/js/components/CollectionAssociationForm/__tests__/CollectionAssociationForm.test.jsx +++ b/static/src/js/components/CollectionAssociationForm/__tests__/CollectionAssociationForm.test.jsx @@ -1,53 +1,65 @@ +import React, { Suspense } from 'react' import { render, screen, + waitFor, within } from '@testing-library/react' -import React from 'react' + import { MemoryRouter, - Route, - Routes -} from 'react-router' + Routes, + Route +} from 'react-router-dom' import * as router from 'react-router' import { MockedProvider } from '@apollo/client/testing' import userEvent from '@testing-library/user-event' import moment from 'moment' +import { GET_COLLECTIONS } from '@/js/operations/queries/getCollections' + +import { DATE_FORMAT } from '@/js/constants/dateFormat' + import AppContext from '@/js/context/AppContext' import NotificationsContext from '@/js/context/NotificationsContext' -import { GET_COLLECTIONS } from '@/js/operations/queries/getCollections' - import errorLogger from '@/js/utils/errorLogger' -import { DATE_FORMAT } from '@/js/constants/dateFormat' +import ErrorBoundary from '@/js/components/ErrorBoundary/ErrorBoundary' import CollectionAssociationForm from '../CollectionAssociationForm' import { - CollectionAssociationRequest, - CollectionResultsWithPages, createAssociationErrorRequest, createAssociationRequest, createAssociationWithServiceRequest, - mockTool, - mockVariable, - mockToolWithAssociation, - mockOrderOption, - CollectionSortRequest, - GetServicesRequest + getCollectionsMock, + getCollectionsMockPage1, + getCollectionsMockPage2, + getCollectionSortRequestByProvider, + getCollectionSortRequestByShortName, + getServices, + getToolMock, + getToolMockWithError, + mockNoCollections, + mockOrderOption } from './__mocks__/CollectionAssociationResults' -vi.mock('@/js/components/ErrorBanner/ErrorBanner') -vi.mock('@/js/utils/errorLogger') +vi.mock('../../../utils/errorLogger') const setup = ({ additionalMocks = [], overrideInitialEntries, overridePath, - overrideMock + overrideMocks }) => { + const mocks = [ + getToolMock, + getCollectionsMock, + getToolMock, + ...additionalMocks + ] + const notificationContext = { addNotification: vi.fn() } @@ -63,20 +75,33 @@ const setup = ({ } > - - + + } + element={ + ( + + + + + + ) + } /> } + element={ + ( + + + + + + ) + } /> - @@ -89,13 +114,10 @@ const setup = ({ } } -describe('CollectionAssociationForm', () => { - describe('when searching for collection', () => { - test('renders a list of collection', async () => { - const { user } = setup({ - additionalMocks: [CollectionAssociationRequest], - overrideMock: mockToolWithAssociation - }) +describe('CollectionAssociationForm component', () => { + describe('when the component mounts', () => { + test('it should render the search form', async () => { + const { user } = setup({}) const searchField = await screen.findByText('Select Search Field') @@ -107,37 +129,44 @@ describe('CollectionAssociationForm', () => { const field = screen.getByRole('textbox') await user.type(field, '*') - const searchForCollections = screen.getByText('Search for Collection') - await user.click(searchForCollections) + const searchButton = await screen.findByText('Search for Collection') + expect(searchButton).toBeEnabled() + await user.click(searchButton) - const firstCheckbox = screen.getAllByRole('checkbox')[0] - const secondCheckbox = screen.getAllByRole('checkbox')[1] + await waitFor(() => { + expect(screen.getByText('Collection Associations Entry Title 1')).toBeInTheDocument() + }) - await user.click(secondCheckbox) - await user.click(firstCheckbox) - await user.click(secondCheckbox) + const checkboxes = screen.getAllByRole('checkbox') + await user.click(checkboxes[1]) + await user.click(checkboxes[0]) + await user.click(checkboxes[1]) expect(errorLogger).toHaveBeenCalledTimes(0) }) test('when there is an error to get the list of collections', async () => { const { user } = setup({ - additionalMocks: [{ - request: { - query: GET_COLLECTIONS, - variables: { - params: { - limit: 20, - offset: 0, - provider: null, - sortKey: null, - options: { entryTitle: { pattern: true } }, - entryTitle: '*' + overrideMocks: [ + getToolMock, + { + request: { + query: GET_COLLECTIONS, + variables: { + params: { + limit: 20, + offset: 0, + provider: null, + sortKey: null, + options: { entryTitle: { pattern: true } }, + entryTitle: '*' + } } - } + }, + error: new Error('An error occurred') }, - error: new Error('An error occurred') - }] + getToolMock + ] }) const searchField = await screen.findByText('Select Search Field') @@ -156,92 +185,143 @@ describe('CollectionAssociationForm', () => { expect(errorLogger).toHaveBeenCalledWith('Unable to get Collections', 'Collection Association: getCollections Query') }) - describe('when searching for temporal extent', () => { - test('show fill out temporal extent form', async () => { - const { user } = setup({ - additionalMocks: [ - { - request: { - query: GET_COLLECTIONS, - variables: { - params: { - limit: 20, - offset: 0, - provider: null, - sortKey: null, - temporal: '1978-01-01T00:00:00.000,1978-01-01T00:00:00.000' - } + test('when there is an error to get the concept', async () => { + const { user } = setup({ + overrideMocks: [getToolMockWithError] + }) + + const searchField = await screen.findByText('Select Search Field') + await user.click(searchField) + + const selectField = screen.getByText('Entry Title') + await user.click(selectField) + + const field = screen.getByRole('textbox') + await user.type(field, '*') + + const searchForCollections = screen.getByText('Search for Collection') + await user.click(searchForCollections) + + expect(errorLogger).toHaveBeenCalledTimes(2) + expect(errorLogger).toHaveBeenCalledWith('Unable to get Collections', 'Collection Association: getCollections Query') + }) + + test('when there are no collections found', async () => { + const { user } = setup({ + overrideMocks: [ + getToolMock, + mockNoCollections, + getToolMock + ] + }) + + const searchField = await screen.findByText('Select Search Field') + await user.click(searchField) + + const selectField = screen.getByText('Entry Title') + await user.click(selectField) + + const field = screen.getByRole('textbox') + await user.type(field, '*') + + const searchForCollections = screen.getByText('Search for Collection') + await user.click(searchForCollections) + + await screen.findByText('No Collections Found.') + }) + }) + + describe('when searching for temporal extent', () => { + test('show fill out temporal extent form', async () => { + const { user } = setup({ + overrideMocks: [ + getToolMock, + { + request: { + query: GET_COLLECTIONS, + variables: { + params: { + limit: 20, + offset: 0, + provider: null, + sortKey: null, + temporal: '1978-01-01T00:00:00.000,1978-01-01T00:00:00.000' } - }, - result: { - data: { - collections: { - items: [ - { - conceptId: 'C12000001123-MMT_2', - provider: 'MMT_2', - version: '1', - revisionId: 1, - tags: 1, - granules: null, - entryTitle: 'Collection Association Entry Title 1', - shortName: 'Collection Associations Short Name 1', - title: 'Collection Associations Title 1', - revisionDate: null, - tagDefinitions: { - items: [{ - conceptId: 'C100000', - description: 'Mock tag description', - originatorId: 'test.user', - revisionId: '1', - tagKey: 'Mock tag key' - }] - }, - __typename: 'Collection' - } - ], - count: 25, - __typename: 'CollectionList' - } + } + }, + result: { + data: { + collections: { + items: [ + { + conceptId: 'C12000001123-MMT_2', + provider: 'MMT_2', + version: '1', + revisionId: 1, + tags: 1, + granules: null, + entryTitle: 'Collection Association Entry Title 1', + shortName: 'Collection Associations Short Name 1', + title: 'Collection Associations Title 1', + revisionDate: null, + tagDefinitions: { + items: [{ + conceptId: 'C100000', + description: 'Mock tag description', + originatorId: 'test.user', + revisionId: '1', + tagKey: 'Mock tag key' + }] + }, + __typename: 'Collection' + } + ], + count: 25, + __typename: 'CollectionList' } } } - ] - }) + }, + getToolMock + ] + }) - const searchField = await screen.findByText('Select Search Field') - await user.click(searchField) + const searchField = await screen.findByText('Select Search Field') + await user.click(searchField) - const selectField = screen.getByText('Temporal Extent') - await user.click(selectField) + const selectField = screen.getByText('Temporal Extent') + await user.click(selectField) - const startField = screen.getByText('Range Start') - await user.type(startField, moment.utc('1978-01-01T00:00:00Z').format(DATE_FORMAT)) + const startField = screen.getByText('Range Start') + await user.type(startField, moment.utc('1978-01-01T00:00:00Z').format(DATE_FORMAT)) - const endField = screen.getByText('Range End') - await user.type(endField, moment.utc('1978-01-01T00:00:00Z').format(DATE_FORMAT)) + const endField = screen.getByText('Range End') + await user.type(endField, moment.utc('1978-01-01T00:00:00Z').format(DATE_FORMAT)) - const searchForCollections = screen.getByText('Search for Collection') - await user.click(searchForCollections) + const searchForCollections = screen.getByText('Search for Collection') + await user.click(searchForCollections) - const table = await screen.findByRole('table') + const table = await screen.findByRole('table') - const tableRows = within(table).getAllByRole('row') + const tableRows = within(table).getAllByRole('row') - expect(tableRows.length).toEqual(2) + expect(tableRows.length).toEqual(2) - const row1 = tableRows[1] - const row1Cells = within(row1).queryAllByRole('cell') + const row1 = tableRows[1] + const row1Cells = within(row1).queryAllByRole('cell') - expect(row1Cells[1].textContent).toBe('Collection Association Entry Title 1') - }) + expect(row1Cells[1].textContent).toBe('Collection Association Entry Title 1') }) }) describe('when paging through the table', () => { test('navigate to the next page', async () => { const { user } = setup({ - additionalMocks: [CollectionAssociationRequest, CollectionResultsWithPages] + overrideMocks: [ + getToolMock, + getCollectionsMockPage1, + getToolMock, + getCollectionsMockPage2] }) const searchField = await screen.findByText('Select Search Field') @@ -256,10 +336,15 @@ describe('CollectionAssociationForm', () => { const searchForCollections = screen.getByText('Search for Collection') await user.click(searchForCollections) - const paginationButton = screen.getByRole('button', { name: 'Goto Page 3' }) - await user.click(paginationButton) + // Wait for the initial results to load + await screen.findByText('Showing 1-20 of 50 Collections') - expect(await screen.findByText('Collection Associations Short Name 1')).toBeInTheDocument() + // Find the "Next" button in the new pagination structure + const nextPageButtons = screen.getAllByRole('button', { name: 'Goto Next Page' }) + await user.click(nextPageButtons[0]) + + // Check if the page has changed by looking for a specific element on the new page + expect(await screen.findByText('Showing 21-40 of 50 Collections')).toBeInTheDocument() }) }) @@ -269,7 +354,12 @@ describe('CollectionAssociationForm', () => { vi.spyOn(router, 'useNavigate').mockImplementation(() => navigateSpy) const { user } = setup({ - additionalMocks: [CollectionAssociationRequest, createAssociationRequest] + overrideMocks: [ + getToolMock, + getCollectionsMock, + getToolMock, + createAssociationRequest + ] }) const searchField = await screen.findByText('Select Search Field') @@ -285,6 +375,8 @@ describe('CollectionAssociationForm', () => { const searchForCollections = screen.getByText('Search for Collection') await user.click(searchForCollections) + await screen.findByText('Unassociated Collection Entry Title 1') + const firstCheckbox = screen.getAllByRole('checkbox')[1] await user.click(firstCheckbox) @@ -294,7 +386,8 @@ describe('CollectionAssociationForm', () => { await user.click(createSelectedAssociationButton) expect(navigateSpy).toHaveBeenCalledTimes(2) - expect(navigateSpy).toHaveBeenCalledWith('/tools/T12000000-MMT_2/collection-association') + expect(navigateSpy).toHaveBeenNthCalledWith(1, '?searchField=entryTitle&searchFieldValue=*', undefined) + expect(navigateSpy).toHaveBeenNthCalledWith(2, '/tools/T1200000098-MMT_2/collection-association') }) }) @@ -305,7 +398,11 @@ describe('CollectionAssociationForm', () => { const { user } = setup({ overrideInitialEntries: ['/order-options/OO1257381321-EDF_OPS/collection-association-search'], - additionalMocks: [CollectionAssociationRequest, createAssociationWithServiceRequest] + overrideMocks: [ + getServices, + mockOrderOption, + getCollectionsMockPage1, + createAssociationWithServiceRequest] }) const serviceField = await screen.findByText('Select Service') @@ -341,7 +438,12 @@ describe('CollectionAssociationForm', () => { describe('when the create association request fails', () => { test('should call errorLogger and display error message', async () => { const { user } = setup({ - additionalMocks: [CollectionAssociationRequest, createAssociationErrorRequest] + overrideMocks: [ + getToolMock, + getCollectionsMock, + getToolMock, + createAssociationErrorRequest + ] }) const searchField = await screen.findByText('Select Search Field') @@ -357,7 +459,9 @@ describe('CollectionAssociationForm', () => { const searchForCollections = screen.getByText('Search for Collection') await user.click(searchForCollections) - const firstCheckbox = screen.getAllByRole('checkbox')[0] + await screen.findByText('Unassociated Collection Entry Title 1') + + const firstCheckbox = screen.getAllByRole('checkbox')[1] await user.click(firstCheckbox) await user.click(firstCheckbox) @@ -375,7 +479,7 @@ describe('CollectionAssociationForm', () => { describe('when clicking an ascending sort button', () => { test('should sorts and shows the button as active', async () => { const { user } = setup({ - additionalMocks: [CollectionAssociationRequest] + additionalMocks: [getCollectionSortRequestByShortName, getCollectionSortRequestByProvider] }) const searchField = await screen.findByText('Select Search Field') @@ -393,23 +497,10 @@ describe('CollectionAssociationForm', () => { const sortShortName = screen.getByRole('button', { name: /Sort Short Name in ascending order/ }) await user.click(sortShortName) expect(screen.queryByRole('button', { name: /Sort Short Name in ascending order/ })).toHaveClass('d-flex align-items-center text-nowrap button--naked table__sort-button text-secondary d-flex justify-content-center btn') - }) - }) - - describe('when the URL searchParam', () => { - test('should search for collection', async () => { - const navigateSpy = vi.fn() - vi.spyOn(router, 'useNavigate').mockImplementation(() => navigateSpy) - - setup({ - additionalMocks: [CollectionSortRequest], - overrideMock: { mockVariable }, - overridePath: 'variables/:conceptId/collection-association-search', - overrideInitialEntries: ['/variables/V12000000-MMT_2/collection-association-search?searchField=entryTitle&searchFieldValue=*&sortKey=-shortName'] - }) - const sortButton = await screen.findByRole('button', { name: /Sort Short Name in ascending order/ }) - expect(sortButton).toHaveClass('d-flex align-items-center text-nowrap button--naked table__sort-button text-secondary d-flex justify-content-center btn') + const sortProvider = screen.getByRole('button', { name: /Sort Provider in descending order/ }) + await user.click(sortProvider) + expect(screen.queryByRole('button', { name: /Sort Provider in descending order/ })).toHaveClass('d-flex align-items-center text-nowrap button--naked table__sort-button text-secondary d-flex justify-content-center btn') }) }) }) diff --git a/static/src/js/components/CollectionAssociationForm/__tests__/__mocks__/CollectionAssociationResults.js b/static/src/js/components/CollectionAssociationForm/__tests__/__mocks__/CollectionAssociationResults.js index aaf11c650..f516037f4 100644 --- a/static/src/js/components/CollectionAssociationForm/__tests__/__mocks__/CollectionAssociationResults.js +++ b/static/src/js/components/CollectionAssociationForm/__tests__/__mocks__/CollectionAssociationResults.js @@ -1,141 +1,31 @@ +import { CREATE_ASSOCIATION } from '@/js/operations/mutations/createAssociation' +import { GET_COLLECTIONS } from '@/js/operations/queries/getCollections' +import { GET_ORDER_OPTION } from '@/js/operations/queries/getOrderOption' import { GET_SERVICES } from '@/js/operations/queries/getServices' -import { CREATE_ASSOCIATION } from '../../../../operations/mutations/createAssociation' -import { GET_COLLECTIONS } from '../../../../operations/queries/getCollections' +import { GET_TOOL } from '@/js/operations/queries/getTool' -export const mockToolWithAssociation = { - accessConstraints: null, - ancillaryKeywords: null, - associationDetails: { - collections: [ - { - conceptId: 'C12000001123-MMT_2' - }, - { - conceptId: 'C1200000034-SEDAC' +export const mockOrderOption = { + request: { + query: GET_ORDER_OPTION, + variables: { + params: { + conceptId: 'OO1257381321-EDF_OPS' } - ] - }, - conceptId: 'T1200000098-MMT_2', - contactGroups: null, - contactPersons: null, - description: 'mock description', - doi: null, - nativeId: 'MMT_e090f57a-d611-48eb-a5d2-c6a94073f3f9', - lastUpdatedDate: null, - longName: 'mock long name', - metadataSpecification: { - url: 'https://cdn.earthdata.nasa.gov/umm/tool/v1.2.0', - name: 'UMM-T', - version: '1.2.0' - }, - name: 'Collection Association Mock Test', - organizations: [ - { - roles: [ - 'PUBLISHER' - ], - shortName: 'UCAR/NCAR/EOL/CEOPDM', - longName: 'CEOP Data Management, Earth Observing Laboratory, National Center for Atmospheric Research, University Corporation for Atmospheric Research', - urlValue: 'http://www.eol.ucar.edu/projects/ceop/dm/' - } - ], - providerId: 'MMT_2', - potentialAction: null, - quality: null, - revisionId: '1', - revisionDate: '2024-03-21T15:01:58.533Z', - relatedUrls: null, - searchAction: null, - supportedBrowsers: null, - supportedInputFormats: null, - supportedOperatingSystems: null, - supportedOutputFormats: null, - supportedSoftwareLanguages: null, - toolKeywords: [ - { - toolCategory: 'EARTH SCIENCE SERVICES', - toolTopic: 'DATA ANALYSIS AND VISUALIZATION', - toolTerm: 'CALIBRATION/VALIDATION' } - ], - type: 'Downloadable Tool', - ummMetadata: { - URL: { - URLContentType: 'DistributionURL', - Type: 'GOTO WEB TOOL', - URLValue: 'mock url' - }, - Type: 'Downloadable Tool', - Description: 'mock description', - Version: '1', - ToolKeywords: [ - { - ToolCategory: 'EARTH SCIENCE SERVICES', - ToolTopic: 'DATA ANALYSIS AND VISUALIZATION', - ToolTerm: 'CALIBRATION/VALIDATION' - } - ], - Name: 'Collection Association Mock Test', - Organizations: [ - { - Roles: [ - 'PUBLISHER' - ], - ShortName: 'UCAR/NCAR/EOL/CEOPDM', - LongName: 'CEOP Data Management, Earth Observing Laboratory, National Center for Atmospheric Research, University Corporation for Atmospheric Research', - URLValue: 'http://www.eol.ucar.edu/projects/ceop/dm/' - } - ], - MetadataSpecification: { - URL: 'https://cdn.earthdata.nasa.gov/umm/tool/v1.2.0', - Name: 'UMM-T', - Version: '1.2.0' - }, - LongName: 'mock long name' - }, - url: { - urlContentType: 'DistributionURL', - type: 'GOTO WEB TOOL', - urlValue: 'mock url' }, - useConstraints: null, - version: '1', - versionDescription: null, - collections: { - count: 2, - items: [ - { - title: '2000 Pilot Environmental Sustainability Index (ESI)', - conceptId: 'C12000001123-MMT_2', - entryTitle: '2000 Pilot Environmental Sustainability Index (ESI)', - shortName: 'CIESIN_SEDAC_ESI_2000', - version: '2000.00', - provider: 'SEDAC', - __typename: 'Collection' - }, - { - title: '2001 Environmental Sustainability Index (ESI)', - conceptId: 'C1200000035-SEDAC', - entryTitle: '2001 Environmental Sustainability Index (ESI)', - shortName: 'CIESIN_SEDAC_ESI_2001', - version: '2001.00', - provider: 'SEDAC', - __typename: 'Collection' + result: { + data: { + orderOption: { + deprecated: false, + name: 'Test Name', + description: 'Test Description', + form: 'Test Form', + scope: 'PROVIDER', + providerId: 'MMT_2', + __typename: 'OrderOption' } - ], - __typename: 'CollectionList' - }, - __typename: 'Tool' -} - -export const mockOrderOption = { - deprecated: false, - name: 'Test Name', - description: 'Test Description', - form: 'Test Form', - scope: 'PROVIDER', - providerId: 'MMT_2', - __typename: 'OrderOption' + } + } } export const mockTool = { @@ -274,88 +164,83 @@ export const mockTool = { __typename: 'Tool' } -export const mockVariable = { - additionalIdentifiers: [ - { - identifier: '123' - } - ], - associationDetails: { - collections: [ - { - conceptId: 'C1200000034-SEDAC' - }, - { - conceptId: 'C1200000035-SEDAC' +export const mockNoCollections = { + request: { + query: GET_COLLECTIONS, + variables: { + params: { + limit: 20, + offset: 0, + provider: null, + sortKey: null, + options: { entryTitle: { pattern: true } }, + entryTitle: '*' } - ] + } }, - conceptId: 'V1200000107-SEDAC', - dataType: null, - definition: '213', - dimensions: null, - fillValues: null, - indexRanges: null, - instanceInformation: null, - longName: '123', - measurementIdentifiers: null, - name: 'Testing association', - nativeId: 'MMT_fe95add0-9ae8-471e-938b-1e8110ec8207.', - offset: null, - providerId: 'SEDAC', - relatedUrls: null, - revisionDate: '2024-03-18T14:40:14.945Z', - revisionId: '24', - samplingIdentifiers: null, - scale: null, - scienceKeywords: null, - sets: null, - standardName: null, - ummMetadata: { - MetadataSpecification: { - URL: 'https://cdn.earthdata.nasa.gov/umm/variable/v1.9.0', - Name: 'UMM-Var', - Version: '1.9.0' - }, - AdditionalIdentifiers: [ - { - Identifier: '123' + result: { + data: { + collections: { + items: [], + count: 0, + __typename: 'CollectionList' } - ], - Name: 'Testing association', - LongName: '123', - Definition: '213' - }, - units: null, - validRanges: null, - variableSubType: null, - variableType: null, - collections: { - count: 1, - items: [ - { - conceptId: 'C1200000034-SEDAC', - entryTitle: '2000 Pilot Environmental Sustainability Index (ESI)', - shortName: 'CIESIN_SEDAC_ESI_2000', - version: '2000.00', - provider: 'SEDAC', - __typename: 'Collection' + } + } +} + +export const mockCollections = { + items: [ + { + conceptId: 'C12000001123-MMT_2', + provider: 'MMT_2', + version: '1', + revisionId: 1, + shortName: 'Collection Associations Short Name 1', + entryTitle: 'Collection Associations Entry Title 1', + tags: null, + granules: null, + revisionDate: null, + title: 'Collection Associations Title 1', + tagDefinitions: { + items: [{ + conceptId: 'C100000', + description: 'Mock tag description', + originatorId: 'test.user', + revisionId: '1', + tagKey: 'Mock tag key' + }] }, - { - conceptId: 'C1200000035-SEDAC', - entryTitle: '2001 Pilot Environmental Sustainability Index (ESI)', - shortName: 'CIESIN_SEDAC_ESI_2001', - version: '2000.00', - provider: 'SEDAC', - __typename: 'Collection' - } - ], - __typename: 'CollectionList' - }, - __typename: 'Variable' + __typename: 'Collection' + }, + { + conceptId: 'C12000001124-MMT_2', + provider: 'MMT_2', + version: '1', + revisionId: 1, + shortName: 'Unassociated Collection Short Name 1', + entryTitle: 'Unassociated Collection Entry Title 1', + tags: null, + granules: null, + revisionDate: null, + title: 'Unassociated Title 1', + tagDefinitions: { + items: [{ + conceptId: 'C100000', + description: 'Mock tag description', + originatorId: 'test.user', + revisionId: '1', + tagKey: 'Mock tag key' + }] + }, + __typename: 'Collection' + } + ], + count: 50, + __typename: 'CollectionList' } -export const CollectionAssociationRequest = { +export const getCollectionsMockPage1 = { request: { query: GET_COLLECTIONS, variables: { @@ -425,13 +310,13 @@ export const CollectionAssociationRequest = { } } -export const CollectionResultsWithPages = { +export const getCollectionsMockPage2 = { request: { query: GET_COLLECTIONS, variables: { params: { limit: 20, - offset: 40, + offset: 20, provider: null, sortKey: null, options: { entryTitle: { pattern: true } }, @@ -473,7 +358,7 @@ export const CollectionResultsWithPages = { } } -export const GetServicesRequest = { +export const getServices = { request: { query: GET_SERVICES, variables: { @@ -506,7 +391,7 @@ export const createAssociationRequest = { request: { query: CREATE_ASSOCIATION, variables: { - conceptId: 'T12000000-MMT_2', + conceptId: 'T1200000098-MMT_2', associatedConceptIds: ['C12000001124-MMT_2'] } }, @@ -547,44 +432,84 @@ export const createAssociationErrorRequest = { request: { query: CREATE_ASSOCIATION, variables: { - conceptId: 'T12000000-MMT_2', - associatedConceptIds: ['C12000001123-MMT_2'] + conceptId: 'T1200000098-MMT_2', + associatedConceptIds: ['C12000001124-MMT_2'] } }, error: new Error('An error occurred') } -export const ingestVariableRequest = { +export const getCollectionSortRequestByShortName = { request: { - query: CREATE_ASSOCIATION, + query: GET_COLLECTIONS, variables: { - conceptId: 'V12000000-MMT_2', - associatedConceptIds: ['C12000001123-MMT_2'] + params: { + limit: 20, + offset: 0, + provider: null, + sortKey: '-shortName', + options: { entryTitle: { pattern: true } }, + entryTitle: '*' + } } }, result: { data: { - createAssociation: { - associatedConceptId: 'C1200000035-SEDAC', - conceptId: 'VA1200000140-CMR', - revisionId: 2 + collections: { + items: [ + { + conceptId: 'C12000001123-MMT_2', + provider: 'MMT_2', + version: '1', + revisionId: 1, + tags: 1, + granules: null, + shortName: 'Collection Associations Short Name 1', + entryTitle: 'Collection Associations Entry Title 1 ', + title: 'Collection Associations Title 1', + revisionDate: null, + tagDefinitions: { + items: [{ + conceptId: 'C100000', + description: 'Mock tag description', + originatorId: 'test.user', + revisionId: '1', + tagKey: 'Mock tag key' + }] + }, + __typename: 'Collection' + }, + { + conceptId: 'C12000001124-MMT_2', + provider: 'MMT_2', + version: '1', + revisionId: 1, + tags: 1, + granules: null, + shortName: 'Collection Associations Short Name 2', + entryTitle: 'Collection Associations Entry Title 2', + title: 'Collection Associations Title 2', + revisionDate: null, + tagDefinitions: { + items: [{ + conceptId: 'C100000', + description: 'Mock tag description', + originatorId: 'test.user', + revisionId: '1', + tagKey: 'Mock tag key' + }] + }, + __typename: 'Collection' + } + ], + count: 50, + __typename: 'CollectionList' } } } } -export const ingestVariableErrorRequest = { - request: { - query: CREATE_ASSOCIATION, - variables: { - conceptId: 'V12000000-MMT_2', - associatedConceptIds: ['C12000001123-MMT_2'] - } - }, - error: new Error('An error occurred') -} - -export const CollectionSortRequest = { +export const getCollectionSortRequestByProvider = { request: { query: GET_COLLECTIONS, variables: { @@ -592,7 +517,7 @@ export const CollectionSortRequest = { limit: 20, offset: 0, provider: null, - sortKey: '-shortName', + sortKey: 'provider', options: { entryTitle: { pattern: true } }, entryTitle: '*' } @@ -653,3 +578,48 @@ export const CollectionSortRequest = { } } } + +export const getToolMock = { + request: { + query: GET_TOOL, + variables: { + params: { + conceptId: 'T1200000098-MMT_2' + } + } + }, + result: { + data: { tool: mockTool } + } +} + +export const getToolMockWithError = { + request: { + query: GET_TOOL, + variables: { + params: { + conceptId: 'T1200000098-MMT_2' + } + } + }, + error: new Error('An error occurred') +} + +export const getCollectionsMock = { + request: { + query: GET_COLLECTIONS, + variables: { + params: { + limit: 20, + offset: 0, + provider: null, + sortKey: null, + options: { entryTitle: { pattern: true } }, + entryTitle: '*' + } + } + }, + result: { + data: { collections: mockCollections } + } +} diff --git a/static/src/js/components/ManageCollectionAssociation/ManageCollectionAssociation.jsx b/static/src/js/components/ManageCollectionAssociation/ManageCollectionAssociation.jsx index dcab4dfdf..3e8844284 100644 --- a/static/src/js/components/ManageCollectionAssociation/ManageCollectionAssociation.jsx +++ b/static/src/js/components/ManageCollectionAssociation/ManageCollectionAssociation.jsx @@ -12,9 +12,9 @@ import React, { useCallback, useState } from 'react' import pluralize from 'pluralize' import Button from '@/js/components/Button/Button' +import ControlledPaginatedContent from '@/js/components/ControlledPaginatedContent/ControlledPaginatedContent' import CustomModal from '@/js/components/CustomModal/CustomModal' import EllipsisText from '@/js/components/EllipsisText/EllipsisText' -import Pagination from '@/js/components/Pagination/Pagination' import Table from '@/js/components/Table/Table' import conceptTypeQueries from '@/js/constants/conceptTypeQueries' @@ -41,14 +41,17 @@ const ManageCollectionAssociation = () => { const { addNotification } = useNotificationsContext() - const [searchParams, setSearchParams] = useSearchParams() + const derivedConceptType = getConceptTypeByConceptId(conceptId) + + // Variables for deletion mutation const [collectionConceptIds, setCollectionConceptIds] = useState([]) const [showDeleteModal, setShowDeleteModal] = useState(false) + const [isDeleting, setIsDeleting] = useState(false) - const derivedConceptType = getConceptTypeByConceptId(conceptId) - + // Variables for pagination + const [searchParams, setSearchParams] = useSearchParams() + const [activePage, setActivePage] = useState(1) const limit = 20 - const activePage = parseInt(searchParams.get('page'), 10) || 1 const offset = (activePage - 1) * limit let params = { @@ -79,10 +82,7 @@ const ManageCollectionAssociation = () => { }) const [deleteAssociationMutation] = useMutation(DELETE_ASSOCIATION, { - refetchQueries: [{ - query: conceptTypeQueries[derivedConceptType], - variables: params - }], + onCompleted: () => { setShowDeleteModal(false) @@ -93,8 +93,14 @@ const ManageCollectionAssociation = () => { }) setCollectionConceptIds([]) + + // Gives time for CMR to update data + setTimeout(() => { + refetch() + }, 250) }, onError: () => { + setShowDeleteModal(false) addNotification({ message: 'Error disassociating collection', variant: 'danger' @@ -105,13 +111,15 @@ const ManageCollectionAssociation = () => { }) // Handles deleting selected collection - // if no collections selected, returns an error notification const handleDeleteAssociation = () => { + setIsDeleting(true) deleteAssociationMutation({ variables: { conceptId, associatedConceptIds: collectionConceptIds } + }).finally(() => { + setIsDeleting(false) }) } @@ -124,14 +132,13 @@ const ManageCollectionAssociation = () => { if (order === 'ascending') nextSortKey = `-${key}` if (order === 'descending') nextSortKey = key - // Reset the page parameter - currentParams.delete('page') - // Set the sort key currentParams.set('sortKey', nextSortKey) return Object.fromEntries(currentParams) }) + + setActivePage(1) // Reset to first page when sorting }, []) const buildEllipsisTextCell = useCallback((cellData) => ( @@ -140,8 +147,8 @@ const ManageCollectionAssociation = () => { ), []) - // Handles checkbox selections, if checked add the conceptId to the state variable - // and pops the added conceptId from the array. + // Adds or removes checked collections from collectionConceptIds array + // which is provided to the deleteMutation const handleCheckbox = (event) => { const { target } = event const { value } = target @@ -200,11 +207,7 @@ const ManageCollectionAssociation = () => { ] const setPage = (nextPage) => { - setSearchParams((currentParams) => { - currentParams.set('page', nextPage) - - return Object.fromEntries(currentParams) - }) + setActivePage(nextPage) } const toggleShowDeleteModal = (nextState) => { @@ -216,91 +219,101 @@ const ManageCollectionAssociation = () => { toggleShowDeleteModal(true) }) - // Handle refresh, calls getMetadata to get the list of association - // TODO: MMT-4089 See if we can get rid of this refresh button. - const handleRefreshPage = () => { - refetch() - } - - const refreshAccessibleEventProps = useAccessibleEvent(() => { - handleRefreshPage() - }) - const { [camelCase(derivedConceptType)]: concept } = data const { collections: associatedCollections } = concept - const { items = [], count } = associatedCollections - - const totalPages = Math.ceil(count / limit) - - const currentPageIndex = Math.floor(offset / limit) - const firstResultIndex = currentPageIndex * limit - const isLastPage = totalPages === activePage - const lastResultIndex = firstResultIndex + (isLastPage ? count % limit : limit) - - const paginationMessage = count > 0 - ? `Showing ${totalPages > 1 ? `Collection Associations ${firstResultIndex + 1}-${lastResultIndex} of ${count}` : `${count} ${pluralize('Collection Association', count)}`}` - : 'No Collection Associations found' + const { items, count } = associatedCollections return ( -
+
{' '} Association operations may take some time. If you are not seeing what you expect below, please {' '} - refetch()} + aria-label="Refresh the page" + type="button" > - refresh the page - + refresh the page + - -
- { - (!!count) && ( - {paginationMessage} + + { + ({ + totalPages, + pagination, + firstResultPosition, + lastResultPosition + }) => { + const paginationMessage = count > 0 + ? `Showing ${totalPages > 1 ? `${firstResultPosition}-${lastResultPosition} of` : ''} ${count} Collection ${pluralize('Association', count)}` + : 'No collection associations found' + + return ( + <> + + + { + (!!count) && ( + {paginationMessage} + ) + } + + + { + totalPages > 1 && ( + + {pagination} + + ) + } + + `column_${dataKey}_${conceptIdCell}`} + generateRowKey={({ conceptId: conceptIdRow }) => `row_${conceptIdRow}`} + id="associated-collections" + limit={count} + noDataMessage="No collection associations found." + /> + { + totalPages > 1 && ( + + +
+ {pagination} +
+ + + ) + } + ) } - - { - totalPages > 1 && ( -
- - - ) } - -
`column_${dataKey}_${conceptIdCell}`} - generateRowKey={({ conceptId: conceptIdRow }) => `row_${conceptIdRow}`} - id="associated-collections" - limit={count} - noDataMessage="No collection associations found." - offset={offset} - /> +