diff --git a/e2e/cypress/e2e/multiProject.spec.js b/e2e/cypress/e2e/multiProject.spec.js index 769521dcf9..d81d640857 100644 --- a/e2e/cypress/e2e/multiProject.spec.js +++ b/e2e/cypress/e2e/multiProject.spec.js @@ -84,8 +84,7 @@ describe('Render project cards', () => { cy.visit('/') // Search for the project - cy.get('nav [data-cy=search-field] > input').type(user.username) - cy.get('nav [data-cy=search-form]').submit() + cy.get('[data-cy=search-field] > input').type(user.username) // Click on a subproject project card cy.get('[data-cy=project-card]') @@ -188,8 +187,7 @@ describe('Multi project page', () => { const multiProjectName = multiProjectsNames[0] cy.visit('/') // Search for the project - cy.get('nav [data-cy=search-field] > input').type(user.username) - cy.get('nav [data-cy=search-form]').submit() + cy.get('[data-cy=search-field] > input').type(user.username) // Click on a multiproject project card cy.get('[data-cy=project-card]') diff --git a/e2e/cypress/e2e/search.spec.js b/e2e/cypress/e2e/search.spec.js index afc1693411..feef923b14 100644 --- a/e2e/cypress/e2e/search.spec.js +++ b/e2e/cypress/e2e/search.spec.js @@ -1,19 +1,15 @@ import { getFakeUser } from '../support/getFakeUser' -describe('Navbar search', () => { - it('should redirect to /search when search is submitted', () => { +describe('Search', () => { + it('should rewrite url to /search', () => { const queryTerm = 'awesome project' // Visit homepage cy.visit('/') // Write query term in the search field - cy.get('nav [data-cy=search-field] > input').type(queryTerm) - // The URL shouldn't change before clicking on `Search` - cy.url().should('equal', `${Cypress.config().baseUrl}/`) - // Press enter - cy.get('nav [data-cy=search-form]').submit() - // Should redirect to the search page - cy.url().should('include', `/search?q=${encodeURI(queryTerm)}`) + cy.get('[data-cy=search-field] > input').type(queryTerm) + // Should rewrite url to the search page + cy.url().should('include', `/search?q=${encodeURIComponent(queryTerm)}`) }) it('should display project card on submitting search form', () => { @@ -34,9 +30,7 @@ describe('Navbar search', () => { cy.visit('/') // Write query term in the search field - cy.get('nav [data-cy=search-field] > input').type(repoName) - // Press enter - cy.get('nav [data-cy=search-form]').submit() + cy.get('[data-cy=search-field] > input').type(repoName) // Should redirect to the search page cy.url().should('include', `/search?q=${encodeURI(repoName)}`) cy.get('[data-cy=project-card]').should('have.length.gte', 1) @@ -65,10 +59,8 @@ describe('Homepage search on mobile', () => { cy.viewport('iphone-6') // Write query term in the search field cy.get('main [data-cy=search-field] > input').type(repoName) - // Press enter - cy.get('main [data-cy=search-form]').submit() // Should redirect to the search page - cy.url().should('include', `/search?q=${encodeURI(repoName)}`) + cy.url().should('include', `/search?q=${encodeURIComponent(repoName)}`) cy.get('[data-cy=project-card]').should('have.length.gte', 1) }) }) @@ -102,11 +94,10 @@ describe('/search route', () => { cy.get('[data-cy=project-card]').should('have.length.gte', 1) }) - it('should redirect to /search when search box is cleared', () => { + it('should redirect to / when search box is cleared', () => { cy.visit('search?q=query') // Clear the search form and press enter - cy.get('nav [data-cy=search-field] > input').clear() - cy.get('nav [data-cy=search-form]').submit() - cy.url().should('equal', `${Cypress.config().baseUrl}/search?q=`) + cy.get('[data-cy=search-field] > input').clear() + cy.url().should('equal', `${Cypress.config().baseUrl}/`) }) }) diff --git a/e2e/cypress/support/commands.js b/e2e/cypress/support/commands.js index 3a0c19b85a..f3e398ca4a 100644 --- a/e2e/cypress/support/commands.js +++ b/e2e/cypress/support/commands.js @@ -75,7 +75,7 @@ Cypress.Commands.add('importRepo', (remoteUrl, repoName, user) => { method: 'GET', failOnStatusCode: false, }) - .then(response => response.body.empty === false), + .then(response => !response.body.empty), { timeout: 60_000, interval: 1000 }, ) }) diff --git a/frontend/next.config.js b/frontend/next.config.js index d36443ef6a..5b0cffd37d 100644 --- a/frontend/next.config.js +++ b/frontend/next.config.js @@ -82,5 +82,13 @@ module.exports = async phase => { }, ] }, + async rewrites() { + return [ + { + source: '/', + destination: '/search?q=', + }, + ] + }, } } diff --git a/frontend/package.json b/frontend/package.json index 5ed61ac03e..cba8920cd2 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -14,8 +14,8 @@ "express": "^4.19.2", "file-loader": "^6.0.0", "highlight.js": "^11.6.0", - "joi": "^17.2.1", "lodash": "^4.17.21", + "lodash.debounce": "^4.0.8", "meilisearch": "^0.25.1", "morgan": "^1.10.0", "next": "^12.1.6", @@ -26,7 +26,8 @@ "react-intersection-observer": "^9.4.1", "semantic-ui-css": "^2.4.1", "semantic-ui-react": "^2.0.3", - "swr": "^1.3.0", + "swr": "^2.2.5", + "usehooks-ts": "^3.1.0", "webpack": "^5.76.0" }, "devDependencies": { diff --git a/frontend/src/components/NavBar/NavSearchInput.tsx b/frontend/src/components/NavBar/NavSearchInput.tsx new file mode 100644 index 0000000000..44243ffc73 --- /dev/null +++ b/frontend/src/components/NavBar/NavSearchInput.tsx @@ -0,0 +1,44 @@ +import React, { useState } from 'react' +import { useRouter } from 'next/router' +import { Form, Icon, Input } from 'semantic-ui-react' + +import { useSearchQuery } from '@contexts/SearchContext' + +const NavSearchInput = () => { + const { push } = useRouter() + const { query: contextQuery, updateQuery: updateContextQuery } = useSearchQuery() + const [query, setQuery] = useState(contextQuery) + + const handleSubmit = () => { + updateContextQuery(query) + const path = `/search?q=${encodeURIComponent(query)}` + push(path) + } + + return ( +
+ + } + id="search-field" + name="query" + placeholder="Search for projects" + value={query ?? ''} + onChange={e => setQuery(e.target.value)} + /> + + ) +} + +export default NavSearchInput diff --git a/frontend/src/components/NavBar/SearchBar.module.scss b/frontend/src/components/NavBar/SearchBar.module.scss deleted file mode 100644 index c8c57c05ae..0000000000 --- a/frontend/src/components/NavBar/SearchBar.module.scss +++ /dev/null @@ -1,18 +0,0 @@ -@import '../colors.scss'; - -.searchFieldIcon { - color: $silver-chalice; -} - -main #search-field::placeholder { - color: $silver-chalice !important; - font-size: 18px; -} - -main #search-field { - height: 60px; -} - -main #search-field:focus { - border: 1px solid $lightlightgray !important; -} diff --git a/frontend/src/components/NavBar/SearchBar.tsx b/frontend/src/components/NavBar/SearchBar.tsx deleted file mode 100644 index 17a7ca288a..0000000000 --- a/frontend/src/components/NavBar/SearchBar.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import React, { useEffect, useState } from 'react' -import { useRouter } from 'next/router' -import { Form, Icon, Input } from 'semantic-ui-react' - -import { useSearchQuery } from '@contexts/SearchContext' -import UseForm from '@hooks/useForm' -import SearchFormModel from '@models/SearchFrom' - -import styles from './SearchBar.module.scss' - -const SearchBar = ({ className }: SearchBarProps) => { - const [isLoading, setIsLoading] = useState(false) - const { form, onChange, formatErrorPrompt, populate } = UseForm(SearchFormModel) - const { push } = useRouter() - const { updateQuery, query } = useSearchQuery() - - useEffect(() => { - populate({ query: query }) - }, [populate, query]) - - /** - * i. Update the search query in the {@link SearchProvider}. - * ii. Change the search query parameter `q`. - * iii. Make a `shallow` redirect to the new url `/search?q=${submitted query term}`. - ** note: the `shallow option only work if the path name doesn't change, - ** e.g., submitting a search from the `/search` path will be shallow, - ** from other paths it will actually redirect to `/search` which is the desired behavior. - ** see https://nextjs.org/docs/routing/shallow-routing#caveats. - */ - const onSubmit = () => { - setIsLoading(true) - updateQuery(form.query) - - push(`/search?q=${form.query}`, undefined, { shallow: true }).then(() => - // When redirection finishes, replace the loading icon with the search one. - setIsLoading(false), - ) - } - - return ( -
- : } - id="search-field" - name="query" - placeholder="Search for projects" - value={form.query ?? ''} - onChange={onChange} - /> - - ) -} - -const LoadingIcon = () => ( - -) -const SearchIcon = () => - -export default SearchBar - -interface SearchBarProps { - className?: string -} diff --git a/frontend/src/components/NavBar/index.jsx b/frontend/src/components/NavBar/index.tsx similarity index 86% rename from frontend/src/components/NavBar/index.jsx rename to frontend/src/components/NavBar/index.tsx index 321ccca11b..fdd643cec8 100644 --- a/frontend/src/components/NavBar/index.jsx +++ b/frontend/src/components/NavBar/index.tsx @@ -3,9 +3,10 @@ import Link from 'next/link' import { useRouter } from 'next/router' import { Button, Icon, Menu, Popup } from 'semantic-ui-react' -import SearchBar from './SearchBar' +import NavSearchInput from './NavSearchInput' import styles from './index.module.scss' import logoSvg from './logo.svg' +import { useSearchQuery } from '@contexts/SearchContext' const NavBar = () => { return ( @@ -88,21 +89,14 @@ const AddProjectButton = () => { const SiteMenuItems = () => { const { pathname } = useRouter() - const isProjectRoute = - pathname === '/' || - pathname === '/search' || - RegExp('^/projects/').test(pathname) + const isSearchRoute = pathname === '/' || pathname === '/search' + const isProjectRoute = isSearchRoute + const { query } = useSearchQuery() return ( <> - - + + Projects @@ -111,9 +105,11 @@ const SiteMenuItems = () => { 1-click BOM - - - + {isSearchRoute ? null : ( + + + + )} ) } diff --git a/frontend/src/components/Page/index.jsx b/frontend/src/components/Page/index.jsx index a512c7bb63..4f50b87d55 100644 --- a/frontend/src/components/Page/index.jsx +++ b/frontend/src/components/Page/index.jsx @@ -3,7 +3,6 @@ import { string, bool, node } from 'prop-types' import Head from '@components/Head' import NavBar from '@components/NavBar' -import SearchProvider from '@contexts/SearchContext' import styles from './index.module.scss' const Content = ({ contentFullSize, children }) => { @@ -19,13 +18,13 @@ const Container = ({ contentFullSize, children }) => ( ) -const Page = ({ title, initialQuery, contentFullSize, children }) => { +const Page = ({ title, contentFullSize, children }) => { return ( - + <> {children} - + ) } diff --git a/frontend/src/components/SearchInput.module.scss b/frontend/src/components/SearchInput.module.scss new file mode 100644 index 0000000000..246bebb458 --- /dev/null +++ b/frontend/src/components/SearchInput.module.scss @@ -0,0 +1,29 @@ +@import './colors.scss'; + +.searchInput { + display: block; + max-width: min(600px, 90%); + height: 60px; + border: 1px solid $lightlightgray; + margin: 0 auto !important; + margin-bottom: 48px !important; + box-sizing: border-box; + border-radius: 4px; +} + +.searchIcon { + margin-right: 7px !important; +} + +main #search-field::placeholder { + color: $silver-chalice !important; + font-size: 18px; +} + +main #search-field { + height: 60px; +} + +main #search-field:focus { + border: 1px solid $lightlightgray !important; +} diff --git a/frontend/src/components/SearchInput.tsx b/frontend/src/components/SearchInput.tsx new file mode 100644 index 0000000000..0c5f9fae94 --- /dev/null +++ b/frontend/src/components/SearchInput.tsx @@ -0,0 +1,73 @@ +import { useEffect, useRef, useState } from 'react' +import { Icon, Input } from 'semantic-ui-react' + +import { useSearchQuery } from '@contexts/SearchContext' +import debounce from 'lodash.debounce' + +import styles from './SearchInput.module.scss' +import { useRouter } from 'next/router' + +const SearchInput = () => { + const { + replace, + query: { q: routerQuery }, + } = useRouter() + const { updateQuery: updateContextQuery, query: contextQuery } = useSearchQuery() + const [inputQuery, setInputQuery] = useState(contextQuery) + + useEffect(() => { + if (routerQuery !== inputQuery) { + setInputQuery(routerQuery as string) + updateContextQuery(routerQuery as string) + } + // We don't want to depend on `inputQuery` because it would be locked to the + // `routerQuery`. We just want to update `inputQuery` when `routerQuery` + // changes because of navigation. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [routerQuery]) + + const debouncedSubmit = useRef(debounce(value => updateContextQuery(value), 100)) + + const handleChange = (value: string) => { + debouncedSubmit.current.cancel() + setInputQuery(value) + debouncedSubmit.current(value) + const path = value ? `/search?q=${encodeURIComponent(value)}` : '/' + replace(path, undefined, { shallow: true }) + } + + return ( + { + handleChange('') + }} + /> + ) : ( + + ) + } + id="search-field" + name="query" + placeholder="Search for projects" + value={inputQuery} + onChange={e => handleChange(e.target.value)} + onKeyDown={e => { + if (e.key === 'Enter') { + e.target.blur() + } + }} + /> + ) +} + +export default SearchInput diff --git a/frontend/src/contexts/SearchContext.tsx b/frontend/src/contexts/SearchContext.tsx index 8bca7cb332..6c889bf36f 100644 --- a/frontend/src/contexts/SearchContext.tsx +++ b/frontend/src/contexts/SearchContext.tsx @@ -1,5 +1,5 @@ -import { useRouter } from 'next/router' -import { createContext, useContext, useEffect, useState } from 'react' +import { createContext, useContext, useEffect } from 'react' +import { useSessionStorage } from 'usehooks-ts' const SearchContext = createContext({ query: '', @@ -7,30 +7,13 @@ const SearchContext = createContext({ }) const SearchProvider = ({ children, initialQuery }: SearchProviderProps) => { - const [query, setQuery] = useState(initialQuery) - - const { - query: { q }, - events, - } = useRouter() + const [query, setQuery] = useSessionStorage('searchQuery', initialQuery) useEffect(() => { - if (q == null) { - setQuery('') - } - }, [q]) - - events?.on('routeChangeComplete', (url: string) => { - /* - * Handle history navigation by update the search query in the context when the url changes. - * We read the query parameter from the url instead of using the router query - * because the router query gets updated asynchronously. - */ - const latestQuery = new URLSearchParams(url.split('?')[1]).get('q') - if (latestQuery != null) { - setQuery(latestQuery) + if (initialQuery != null) { + setQuery(initialQuery) } - }) + }, [initialQuery]) return ( diff --git a/frontend/src/hooks/useForm.ts b/frontend/src/hooks/useForm.ts deleted file mode 100644 index 3dc7dad07d..0000000000 --- a/frontend/src/hooks/useForm.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { useState, useCallback, useEffect } from 'react' -import { Schema } from 'joi' - -export default function UseForm(schema: Schema, validateOnBlur?: boolean) { - const [form, setForm] = useState({}) - const [formValidationErrors, setFormValidationErrors] = useState([]) - /* - For forms supporting lazy validation (onBlur), - a dirty field is a field the user has interacted with on blurred it (moved focus to other element). - */ - const [dirtyFields, setDirtyFields] = useState([]) - - useEffect(() => setFormValidationErrors(validate(form, schema)), [form, schema]) - - const onChange = (event, data) => { - const isCheckBox = data?.type === 'checkbox' - - if (isCheckBox) { - setForm(prevForm => ({ - ...prevForm, - [data.name]: data.checked, - })) - } else { - setForm(prevForm => ({ - ...prevForm, - [event.target.name]: data.value, - })) - } - } - - /** - * Mark a field `dirty`. - * @param {Event} event - */ - const onBlur = event => { - if (validateOnBlur) { - setDirtyFields(prevFields => [...prevFields, event.target.name]) - } - } - - const populate = useCallback( - /** - * populate form data externally. - * @param {object} data form data - * @param {boolean} predicate condition to use for populating - */ - (data, predicate = true) => { - if (predicate) { - setForm(data) - } - }, - [], - ) - - const isValid = Object.keys(formValidationErrors).length === 0 - - const isErrorField = field => { - const fieldHasInvalidValue = - formValidationErrors?.[field] && form[field] != null - - if (validateOnBlur) { - return fieldHasInvalidValue && dirtyFields.includes(field) - } - - return fieldHasInvalidValue - } - - const formatErrorPrompt = field => { - if (isErrorField(field)) { - // The structure for react-semantic-ui error object, - // see https://react.semantic-ui.com/collections/form/#shorthand-field-control-id - return { content: formValidationErrors[field], pointing: 'below' } - } - } - - return { - form, - onChange, - populate, - isValid, - errors: formValidationErrors, - formatErrorPrompt, - onBlur, - clear: () => setForm({}), - } -} - -/** - * - * @param {*} form - * @param {*} schema - * @returns all the errors in a form eagerly. - */ -const validate = (form, schema) => { - const { error } = schema.validate({ ...form }, { abortEarly: false }) - const details = error?.details ?? [] - - const allErrors = {} - for (const errorField of details) { - allErrors[errorField.context.key] = - errorField.context.message ?? errorField.message - } - - return allErrors -} diff --git a/frontend/src/hooks/useLazySearch.ts b/frontend/src/hooks/useLazySearch.ts index 3a20ba2f8a..1ed4b1ce29 100644 --- a/frontend/src/hooks/useLazySearch.ts +++ b/frontend/src/hooks/useLazySearch.ts @@ -1,4 +1,4 @@ -import { useEffect } from 'react' +import { useEffect, useState } from 'react' import useSWRInfinite from 'swr/infinite' import { useInView } from 'react-intersection-observer' import { Filter } from 'meilisearch' @@ -49,8 +49,12 @@ export const searchFetcher = async ({ * Get projects from MeiliSearch lazily. */ export const useLazySearch = (params: SearchParams) => { + const { data, setSize, isLoading } = useSWRInfinite( + makeSWRKeyGetter(params), + searchFetcher, + ) + const [projects, setProjects] = useState(data?.flat() ?? []) const [ref, isReachingLimit] = useInView({ triggerOnce: true }) - const { data, setSize } = useSWRInfinite(makeSWRKeyGetter(params), searchFetcher) useEffect(() => { if (isReachingLimit) { @@ -58,5 +62,11 @@ export const useLazySearch = (params: SearchParams) => { } }, [isReachingLimit, setSize]) - return { projects: data?.flat() ?? [], intersectionObserverRef: ref } + useEffect(() => { + if (!isLoading) { + setProjects(data?.flat() ?? []) + } + }, [data, isLoading]) + + return { projects, intersectionObserverRef: ref, isLoading } } diff --git a/frontend/src/models/SearchFrom.js b/frontend/src/models/SearchFrom.js deleted file mode 100644 index cdaa4c3b95..0000000000 --- a/frontend/src/models/SearchFrom.js +++ /dev/null @@ -1,8 +0,0 @@ -import Joi from 'joi' - -const SearchFormModel = Joi.object({ - _csrf: Joi.string(), - query: Joi.string().required().max(60), -}) - -export default SearchFormModel diff --git a/frontend/src/pages/_app.jsx b/frontend/src/pages/_app.jsx index a17f305649..bb81cad79e 100644 --- a/frontend/src/pages/_app.jsx +++ b/frontend/src/pages/_app.jsx @@ -31,6 +31,7 @@ import 'semantic-ui-css/components/table.min.css' import 'semantic-ui-css/components/progress.min.css' import './_app.scss' +import SearchProvider from '@contexts/SearchContext' if (typeof window !== 'undefined') { window.plausible = @@ -41,7 +42,7 @@ if (typeof window !== 'undefined') { } } -function KitspaceApp({ Component, pageProps, isStaticFallback }) { +function KitspaceApp({ Component, pageProps, isStaticFallback, initialQuery }) { const setStaticFallback = isStaticFallback ? (