diff --git a/static/src/js/App.jsx b/static/src/js/App.jsx index 52e5ff434..7ebe4df99 100644 --- a/static/src/js/App.jsx +++ b/static/src/js/App.jsx @@ -21,6 +21,7 @@ import PublishPreview from './components/PublishPreview/PublishPreview' import SearchPage from './pages/SearchPage/SearchPage' import HomePage from './pages/HomePage/HomePage' import AuthRequiredContainer from './components/AuthRequiredContainer/AuthRequiredContainer' +import AuthCallbackContainer from './components/AuthCallbackContainer/AuthCallbackContainer' import REDIRECTS from './constants/redirectsMap/redirectsMap' @@ -151,6 +152,11 @@ const App = () => { ) } /> + } + /> Not Found :(} /> } /> } /> diff --git a/static/src/js/components/AuthCallbackContainer/AuthCallbackContainer.jsx b/static/src/js/components/AuthCallbackContainer/AuthCallbackContainer.jsx index 1c28be24e..d29199850 100644 --- a/static/src/js/components/AuthCallbackContainer/AuthCallbackContainer.jsx +++ b/static/src/js/components/AuthCallbackContainer/AuthCallbackContainer.jsx @@ -17,7 +17,7 @@ export const AuthCallbackContainer = () => { if (path) { navigate(path) } - }) + }, []) return
} diff --git a/static/src/js/components/AuthRequiredContainer/AuthRequiredContainer.jsx b/static/src/js/components/AuthRequiredContainer/AuthRequiredContainer.jsx index 1a254314d..5c0756ade 100644 --- a/static/src/js/components/AuthRequiredContainer/AuthRequiredContainer.jsx +++ b/static/src/js/components/AuthRequiredContainer/AuthRequiredContainer.jsx @@ -17,7 +17,10 @@ export const AuthRequiredContainer = ({ const { apiHost } = getApplicationConfig() if (token === null || token === '' || token === undefined) { - window.location.href = `${apiHost}/saml-login?target=${encodeURIComponent(location.pathname)}` + console.log('🚀 ~ useEffect ~ location:', location) + const nextPath = location.pathname + location.search + // debugger + window.location.href = `${apiHost}/saml-login?target=${encodeURIComponent(nextPath)}` } }, []) diff --git a/static/src/js/components/CustomArrayFieldTemplate/CustomArrayFieldTemplate.jsx b/static/src/js/components/CustomArrayFieldTemplate/CustomArrayFieldTemplate.jsx index 7457b1a1e..38c2ed4c1 100644 --- a/static/src/js/components/CustomArrayFieldTemplate/CustomArrayFieldTemplate.jsx +++ b/static/src/js/components/CustomArrayFieldTemplate/CustomArrayFieldTemplate.jsx @@ -129,6 +129,7 @@ const CustomArrayFieldTemplate = ({
`; @@ -19,9 +23,13 @@ exports[`Error Banner Component Provided a test id it renders a loading banner 1 class="justify-content-center mt-5 row" >
+ class="d-flex justify-content-center col-12" + > +
+
`; diff --git a/static/src/js/constants/conceptTypeQueries.js b/static/src/js/constants/conceptTypeQueries.js index 93c4cc430..3a5fb592d 100644 --- a/static/src/js/constants/conceptTypeQueries.js +++ b/static/src/js/constants/conceptTypeQueries.js @@ -1,15 +1,21 @@ import { GET_COLLECTION } from '../operations/queries/getCollection' import { GET_COLLECTIONS } from '../operations/queries/getCollections' import { GET_SERVICE } from '../operations/queries/getService' +import { GET_SERVICES } from '../operations/queries/getServices' import { GET_TOOL } from '../operations/queries/getTool' +import { GET_TOOLS } from '../operations/queries/getTools' import { GET_VARIABLE } from '../operations/queries/getVariable' +import { GET_VARIABLES } from '../operations/queries/getVariables' const conceptTypeQueries = { Collection: GET_COLLECTION, Collections: GET_COLLECTIONS, Service: GET_SERVICE, + Services: GET_SERVICES, Tool: GET_TOOL, - Variable: GET_VARIABLE + Tools: GET_TOOLS, + Variable: GET_VARIABLE, + Variables: GET_VARIABLES } export default conceptTypeQueries diff --git a/static/src/js/constants/conceptTypes.js b/static/src/js/constants/conceptTypes.js new file mode 100644 index 000000000..ef8347766 --- /dev/null +++ b/static/src/js/constants/conceptTypes.js @@ -0,0 +1,15 @@ +/** + * Mapping of concept id types + */ +const conceptTypes = { + Collection: 'Collection', + Collections: 'Collections', + Service: 'Service', + Services: 'Services', + Tool: 'Tool', + Tools: 'Tools', + Variable: 'Variable', + Variables: 'Variables' +} + +export default conceptTypes diff --git a/static/src/js/hooks/useSearchQuery.js b/static/src/js/hooks/useSearchQuery.js index e6a854879..7e16af490 100644 --- a/static/src/js/hooks/useSearchQuery.js +++ b/static/src/js/hooks/useSearchQuery.js @@ -3,6 +3,7 @@ import { isEmpty } from 'lodash-es' import { useLazyQuery } from '@apollo/client' import conceptTypeQueries from '../constants/conceptTypeQueries' +import conceptTypes from '../constants/conceptTypes' /** * Creates a query that can be used to search across the published types. @@ -23,6 +24,15 @@ const useSearchQuery = ({ const [error, setError] = useState() const [loading, setLoading] = useState() + let conditionalParams = {} + + if (type === conceptTypes.Collections) { + conditionalParams = { + ...conditionalParams, + includeTags: '*' + } + } + const [getResults, { loading: queryLoading }] = useLazyQuery(conceptTypeQueries[type], { // If the search results has already been loaded, skip this query skip: !isEmpty(results), @@ -32,8 +42,8 @@ const useSearchQuery = ({ limit, offset, keyword, - includeTags: '*', - sortKey + sortKey, + ...conditionalParams } }, onCompleted: (getResultsData) => { diff --git a/static/src/js/operations/queries/getServices.js b/static/src/js/operations/queries/getServices.js new file mode 100644 index 000000000..0f2d5d539 --- /dev/null +++ b/static/src/js/operations/queries/getServices.js @@ -0,0 +1,16 @@ +import { gql } from '@apollo/client' + +export const GET_SERVICES = gql` + query GetServices($params: ServicesInput) { + services(params: $params) { + count + items { + conceptId + name + longName + providerId + revisionDate + } + } + } +` diff --git a/static/src/js/operations/queries/getTools.js b/static/src/js/operations/queries/getTools.js new file mode 100644 index 000000000..9a4d22c6c --- /dev/null +++ b/static/src/js/operations/queries/getTools.js @@ -0,0 +1,16 @@ +import { gql } from '@apollo/client' + +export const GET_TOOLS = gql` + query GetTools($params: ToolsInput) { + tools(params: $params) { + count + items { + conceptId + name + longName + providerId + revisionDate + } + } + } +` diff --git a/static/src/js/operations/queries/getVariables.js b/static/src/js/operations/queries/getVariables.js new file mode 100644 index 000000000..e1135b026 --- /dev/null +++ b/static/src/js/operations/queries/getVariables.js @@ -0,0 +1,16 @@ +import { gql } from '@apollo/client' + +export const GET_VARIABLES = gql` + query GetVariables($params: VariablesInput) { + variables(params: $params) { + count + items { + conceptId + name + longName + providerId + revisionDate + } + } + } +` diff --git a/static/src/js/pages/SearchPage/SearchPage.jsx b/static/src/js/pages/SearchPage/SearchPage.jsx index b815f4284..071791802 100644 --- a/static/src/js/pages/SearchPage/SearchPage.jsx +++ b/static/src/js/pages/SearchPage/SearchPage.jsx @@ -1,6 +1,10 @@ -import React, { useCallback, useState } from 'react' +import React, { + useEffect, + useCallback, + useState +} from 'react' import PropTypes from 'prop-types' -import { useSearchParams } from 'react-router-dom' +import { useSearchParams, Navigate } from 'react-router-dom' import Col from 'react-bootstrap/Col' import Placeholder from 'react-bootstrap/Placeholder' import Row from 'react-bootstrap/Row' @@ -8,6 +12,9 @@ import { capitalize, startCase } from 'lodash-es' import pluralize from 'pluralize' import ListGroup from 'react-bootstrap/ListGroup' import ListGroupItem from 'react-bootstrap/ListGroupItem' +import commafy from 'commafy' + +import conceptTypes from '../../constants/conceptTypes' import parseError from '../../utils/parseError' @@ -23,6 +30,28 @@ import For from '../../components/For/For' import EllipsisText from '../../components/EllipsisText/EllipsisText' import EllipsisLink from '../../components/EllipsisLink/EllipsisLink' +const typeParamToHumanizedNameMap = { + collections: 'collection', + services: 'service', + tools: 'tool', + variables: 'variable' +} + +/** + * Takes a type from the url and returns a humanized singular or plural version + * @param {String} type The type from the url. + * @param {Boolean} [plural] A boolean that determines whether or not the string should be plural + */ +const getHumanizedNameFromTypeParam = (type, plural) => { + const humanizedName = typeParamToHumanizedNameMap[type] + + if (humanizedName) { + return plural ? `${humanizedName}s` : humanizedName + } + + return null +} + /** * Renders a `SearchPage` component * @@ -37,12 +66,28 @@ const SearchPage = ({ limit }) => { const [showTagModal, setShowTagModal] = useState(false) const [tagModalActiveCollection, setTagModalActiveCollection] = useState(null) - const formattedType = capitalize(searchParams.get('type')) - const keyword = searchParams.get('keyword') - const sortKey = searchParams.get('sortKey') + const typeParam = searchParams.get('type') + const keywordParam = searchParams.get('keyword') + const sortKeyParam = searchParams.get('sortKey') + + const formattedType = capitalize(typeParam) const activePage = parseInt(searchParams.get('page'), 10) || 1 const offset = (activePage - 1) * limit + if (!typeParamToHumanizedNameMap[typeParam]) { + return ( + + ) + } + + const { results, loading, error } = useSearchQuery({ + type: formattedType, + keyword: keywordParam, + limit, + offset, + sortKey: sortKeyParam + }) + const setPage = (nextPage) => { setSearchParams((currentParams) => { currentParams.set('page', nextPage) @@ -65,14 +110,6 @@ const SearchPage = ({ limit }) => { setTagModalActiveCollection(null) } - const { results, loading, error } = useSearchQuery({ - type: formattedType, - keyword, - limit, - offset, - sortKey - }) - const { count } = results const { items = [] } = results const currentPageIndex = Math.floor(offset / limit) @@ -81,14 +118,23 @@ const SearchPage = ({ limit }) => { const firstResultIndex = currentPageIndex * limit const lastResultIndex = firstResultIndex + (isLastPage ? count % limit : limit) + // Checks to see if any filters are provided so that they display in the pagination message + const hasFilter = !!keywordParam || !!sortKeyParam + const paginationMessage = count > 0 - ? `Showing ${totalPages > 1 ? `${firstResultIndex + 1}-${lastResultIndex} of` : ''} ${count} matching ${pluralize(searchParams.get('type'), results.count)}` - : `No matching ${searchParams.get('type')} found` + ? `Showing ${totalPages > 1 ? `${firstResultIndex + 1}-${lastResultIndex} of` : ''} ${count} ` + + `${hasFilter ? 'matching ' : ''}${pluralize(typeParam, results.count)}` + : `No matching ${typeParam} found` + + let queryMessage = '' - let queryMessage = `for: Keyword: "${keyword}"` - if (sortKey) { - const isAscending = sortKey.includes('-') - queryMessage += `, sorted by "${startCase(sortKey.replace('-', ''))}" ${isAscending ? '(ascending)' : ''}` + if (keywordParam) { + queryMessage += `for: Keyword: "${keywordParam}"` + } + + if (sortKeyParam) { + const isAscending = sortKeyParam.includes('-') + queryMessage += `${queryMessage.length ? ',' : ''} sorted by "${startCase(sortKeyParam.replace('-', ''))}" ${isAscending ? '(ascending)' : ''}` } const buildEllipsisLinkCell = useCallback((cellData, rowData) => { @@ -161,57 +207,100 @@ const SearchPage = ({ limit }) => { } }) }, []) - const [columns] = useState([ - { - dataKey: 'shortName', - title: 'Short Name', - className: 'col-auto', - dataAccessorFn: buildEllipsisLinkCell, - sortFn - }, - { - dataKey: 'version', - title: 'Version', - className: 'col-auto text-nowrap', - align: 'end' - }, - { - dataKey: 'title', - sortKey: 'entryTitle', - title: 'Entry Title', - className: 'col-auto', - dataAccessorFn: buildEllipsisTextCell, - sortFn: (_, order) => sortFn('entryTitle', order) - }, - { - dataKey: 'provider', - title: 'Provider', - className: 'col-auto text-nowrap', - align: 'center', - sortFn - }, - { - dataKey: 'granules.count', - title: 'Granule Count', - className: 'col-auto text-nowrap', - align: 'end' - }, - { - dataKey: 'tags', - title: 'Tags', - className: 'col-auto text-nowrap', - dataAccessorFn: buildTagCell, - align: 'end' - }, - { - dataKey: 'revisionDate', - title: 'Last Modified', - className: 'col-auto text-nowrap', - dataAccessorFn: (cellData) => cellData.split('T')[0], - align: 'end', - sortFn + + const getColumnState = () => { + if (formattedType === conceptTypes.Collections) { + return [ + { + dataKey: 'shortName', + title: 'Short Name', + className: 'col-auto', + dataAccessorFn: buildEllipsisLinkCell, + sortFn + }, + { + dataKey: 'version', + title: 'Version', + className: 'col-auto text-nowrap', + align: 'end' + }, + { + dataKey: 'title', + sortKey: 'entryTitle', + title: 'Entry Title', + className: 'col-auto', + dataAccessorFn: buildEllipsisTextCell, + sortFn: (_, order) => sortFn('entryTitle', order) + }, + { + dataKey: 'provider', + title: 'Provider', + className: 'col-auto text-nowrap', + align: 'center', + sortFn + }, + { + dataKey: 'granules.count', + title: 'Granule Count', + className: 'col-auto text-nowrap', + align: 'end' + }, + { + dataKey: 'tags', + title: 'Tags', + className: 'col-auto text-nowrap', + dataAccessorFn: buildTagCell, + align: 'end' + }, + { + dataKey: 'revisionDate', + title: 'Last Modified', + className: 'col-auto text-nowrap', + dataAccessorFn: (cellData) => cellData.split('T')[0], + align: 'end', + sortFn + } + ] } - ]) + + return [ + { + dataKey: 'name', + title: 'Name', + className: 'col-auto', + dataAccessorFn: buildEllipsisLinkCell, + sortFn + }, + { + dataKey: 'longName', + title: 'Long Name', + className: 'col-auto', + dataAccessorFn: buildEllipsisTextCell, + sortFn: (_, order) => sortFn('longName', order) + }, + { + dataKey: 'providerId', + title: 'Provider', + className: 'col-auto text-nowrap', + align: 'center', + sortFn + }, + { + dataKey: 'revisionDate', + title: 'Last Modified', + className: 'col-auto text-nowrap', + dataAccessorFn: (cellData) => cellData.split('T')[0], + align: 'end', + sortFn + } + ] + } + + const [columns, setColumns] = useState(getColumnState()) + + useEffect(() => { + setColumns(getColumnState()) + }, [typeParam]) const activeTagModalCollection = items?.find((item) => ( item.conceptId === tagModalActiveCollection @@ -219,8 +308,8 @@ const SearchPage = ({ limit }) => { return ( { setPage={setPage} limit={limit} offset={offset} - sortKey={sortKey} + sortKey={sortKeyParam} /> ) diff --git a/static/src/js/pages/SearchPage/__tests__/SearchPage.test.js b/static/src/js/pages/SearchPage/__tests__/SearchPage.test.js index 46f02d85f..856980814 100644 --- a/static/src/js/pages/SearchPage/__tests__/SearchPage.test.js +++ b/static/src/js/pages/SearchPage/__tests__/SearchPage.test.js @@ -20,7 +20,10 @@ import { multiPageCollectionSearchPage1TitleAsc, multiPageCollectionSearchPage2, singlePageCollectionSearch, - singlePageCollectionSearchError + singlePageCollectionSearchError, + singlePageServicesSearch, + singlePageToolsSearch, + singlePageVariablesSearch } from './__mocks__/searchResults' import SearchPage from '../SearchPage' @@ -349,4 +352,127 @@ describe('SearchPage component', () => { }) }) }) + + describe('when searching for services', () => { + beforeEach(() => { + setup([singlePageServicesSearch], {}, ['/search?type=services&keyword=']) + }) + + describe('while the request is loading', () => { + test('renders the headers', async () => { + await waitFor(() => { + expect(screen.queryAllByRole('row').length).toEqual(2) + }) + + const rows = screen.queryAllByRole('row') + + const headerRow = rows[0] + + expect(headerRow.children[0].textContent).toContain('Name') + expect(headerRow.children[1].textContent).toContain('Long Name') + expect(headerRow.children[2].textContent).toContain('Provider') + expect(headerRow.children[3].textContent).toContain('Last Modified') + }) + }) + + describe('when the request has loaded', () => { + test('renders the data', async () => { + await waitFor(() => { + expect(screen.queryAllByRole('row').length).toEqual(2) + }) + + const rows = screen.queryAllByRole('row') + const row1 = rows[1] + const row1Cells = within(row1).queryAllByRole('cell') + + expect(row1Cells).toHaveLength(4) + expect(row1Cells[0].textContent).toBe('Service Name 1') + expect(row1Cells[1].textContent).toBe('Service Long Name 1') + expect(row1Cells[2].textContent).toBe('TESTPROV') + expect(row1Cells[3].textContent).toBe('2023-11-30 00:00:00') + }) + }) + }) + + describe('when searching for tools', () => { + beforeEach(() => { + setup([singlePageToolsSearch], {}, ['/search?type=tools&keyword=']) + }) + + describe('while the request is loading', () => { + test('renders the headers', async () => { + await waitFor(() => { + expect(screen.queryAllByRole('row').length).toEqual(2) + }) + + const rows = screen.queryAllByRole('row') + + const headerRow = rows[0] + + expect(headerRow.children[0].textContent).toContain('Name') + expect(headerRow.children[1].textContent).toContain('Long Name') + expect(headerRow.children[2].textContent).toContain('Provider') + expect(headerRow.children[3].textContent).toContain('Last Modified') + }) + }) + + describe('when the request has loaded', () => { + test('renders the data', async () => { + await waitFor(() => { + expect(screen.queryAllByRole('row').length).toEqual(2) + }) + + const rows = screen.queryAllByRole('row') + const row1 = rows[1] + const row1Cells = within(row1).queryAllByRole('cell') + + expect(row1Cells).toHaveLength(4) + expect(row1Cells[0].textContent).toBe('Tool Name 1') + expect(row1Cells[1].textContent).toBe('Tool Long Name 1') + expect(row1Cells[2].textContent).toBe('TESTPROV') + expect(row1Cells[3].textContent).toBe('2023-11-30 00:00:00') + }) + }) + }) + + describe('when searching for variables', () => { + beforeEach(() => { + setup([singlePageVariablesSearch], {}, ['/search?type=variables&keyword=']) + }) + + describe('while the request is loading', () => { + test('renders the headers', async () => { + await waitFor(() => { + expect(screen.queryAllByRole('row').length).toEqual(2) + }) + + const rows = screen.queryAllByRole('row') + + const headerRow = rows[0] + + expect(headerRow.children[0].textContent).toContain('Name') + expect(headerRow.children[1].textContent).toContain('Long Name') + expect(headerRow.children[2].textContent).toContain('Provider') + expect(headerRow.children[3].textContent).toContain('Last Modified') + }) + }) + + describe('when the request has loaded', () => { + test('renders the data', async () => { + await waitFor(() => { + expect(screen.queryAllByRole('row').length).toEqual(2) + }) + + const rows = screen.queryAllByRole('row') + const row1 = rows[1] + const row1Cells = within(row1).queryAllByRole('cell') + + expect(row1Cells).toHaveLength(4) + expect(row1Cells[0].textContent).toBe('Variable Name 1') + expect(row1Cells[1].textContent).toBe('Variable Long Name 1') + expect(row1Cells[2].textContent).toBe('TESTPROV') + expect(row1Cells[3].textContent).toBe('2023-11-30 00:00:00') + }) + }) + }) }) diff --git a/static/src/js/pages/SearchPage/__tests__/__mocks__/searchResults.js b/static/src/js/pages/SearchPage/__tests__/__mocks__/searchResults.js index a89471f50..077768219 100644 --- a/static/src/js/pages/SearchPage/__tests__/__mocks__/searchResults.js +++ b/static/src/js/pages/SearchPage/__tests__/__mocks__/searchResults.js @@ -1,5 +1,8 @@ import { GraphQLError } from 'graphql' import { GET_COLLECTIONS } from '../../../../operations/queries/getCollections' +import { GET_SERVICES } from '../../../../operations/queries/getServices' +import { GET_VARIABLES } from '../../../../operations/queries/getVariables' +import { GET_TOOLS } from '../../../../operations/queries/getTools' export const singlePageCollectionSearch = { request: { @@ -454,3 +457,93 @@ export const singlePageCollectionSearchError = { errors: [new GraphQLError('An error occurred')] } } + +export const singlePageServicesSearch = { + request: { + query: GET_SERVICES, + variables: { + params: { + keyword: '', + limit: 20, + offset: 0, + sortKey: null + } + } + }, + result: { + data: { + services: { + count: 1, + items: [ + { + conceptId: 'S1000000000-TESTPROV', + name: 'Service Name 1', + longName: 'Service Long Name 1', + providerId: 'TESTPROV', + revisionDate: '2023-11-30 00:00:00' + } + ] + } + } + } +} + +export const singlePageVariablesSearch = { + request: { + query: GET_VARIABLES, + variables: { + params: { + keyword: '', + limit: 20, + offset: 0, + sortKey: null + } + } + }, + result: { + data: { + variables: { + count: 1, + items: [ + { + conceptId: 'V1000000000-TESTPROV', + name: 'Variable Name 1', + longName: 'Variable Long Name 1', + providerId: 'TESTPROV', + revisionDate: '2023-11-30 00:00:00' + } + ] + } + } + } +} + +export const singlePageToolsSearch = { + request: { + query: GET_TOOLS, + variables: { + params: { + keyword: '', + limit: 20, + offset: 0, + sortKey: null + } + } + }, + result: { + data: { + tools: { + count: 1, + items: [ + { + conceptId: 'T1000000000-TESTPROV', + name: 'Tool Name 1', + longName: 'Tool Long Name 1', + providerId: 'TESTPROV', + revisionDate: '2023-11-30 00:00:00' + } + ] + } + } + } +}