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 ? (