From 1642c6b42ab25d5bdaff491b96c2211a47617004 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20Poizat?= Date: Thu, 19 Dec 2024 15:45:43 +0100 Subject: [PATCH 1/3] Revert "feat: Delete old search components" This reverts commit 158b7265b72a410850bb46edbd32afbc81652874. --- src/assets/icons/icon-search-empty.svg | 6 + .../search/components/AppBarSearch.spec.jsx | 17 ++ .../components/BarSearchAutosuggest.jsx | 148 ++++++++++++++++++ .../search/components/BarSearchInputGroup.jsx | 43 +++++ src/modules/search/components/SearchEmpty.jsx | 44 ++++++ .../search/components/SuggestionItem.jsx | 63 ++++++++ .../components/SuggestionItemSkeleton.jsx | 44 ++++++ .../SuggestionItemTextHighlighted.jsx | 104 ++++++++++++ .../SuggestionItemTextSecondary.jsx | 66 ++++++++ .../components/SuggestionListSkeleton.jsx | 17 ++ src/modules/search/components/helpers.js | 114 ++++++++++++++ .../search/components/helpers.spec.jsx | 118 ++++++++++++++ src/modules/search/components/styles.styl | 82 ++++++++++ src/modules/search/hooks/useSearch.jsx | 90 +++++++++++ src/modules/views/Search/SearchView.jsx | 145 +++++++++++++++++ 15 files changed, 1101 insertions(+) create mode 100644 src/assets/icons/icon-search-empty.svg create mode 100644 src/modules/search/components/AppBarSearch.spec.jsx create mode 100644 src/modules/search/components/BarSearchAutosuggest.jsx create mode 100644 src/modules/search/components/BarSearchInputGroup.jsx create mode 100644 src/modules/search/components/SearchEmpty.jsx create mode 100644 src/modules/search/components/SuggestionItem.jsx create mode 100644 src/modules/search/components/SuggestionItemSkeleton.jsx create mode 100644 src/modules/search/components/SuggestionItemTextHighlighted.jsx create mode 100644 src/modules/search/components/SuggestionItemTextSecondary.jsx create mode 100644 src/modules/search/components/SuggestionListSkeleton.jsx create mode 100644 src/modules/search/components/helpers.js create mode 100644 src/modules/search/components/helpers.spec.jsx create mode 100644 src/modules/search/components/styles.styl create mode 100644 src/modules/search/hooks/useSearch.jsx create mode 100644 src/modules/views/Search/SearchView.jsx diff --git a/src/assets/icons/icon-search-empty.svg b/src/assets/icons/icon-search-empty.svg new file mode 100644 index 0000000000..5dc84c4f71 --- /dev/null +++ b/src/assets/icons/icon-search-empty.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/modules/search/components/AppBarSearch.spec.jsx b/src/modules/search/components/AppBarSearch.spec.jsx new file mode 100644 index 0000000000..78401ede6e --- /dev/null +++ b/src/modules/search/components/AppBarSearch.spec.jsx @@ -0,0 +1,17 @@ +import { render, screen } from '@testing-library/react' +import React from 'react' + +import CozyClient from 'cozy-client' + +import AppBarSearch from 'modules/search/components/AppBarSearch' +import AppLike from 'test/components/AppLike' + +it('should display the Searchbar', () => { + const client = new CozyClient({}) + render( + + + + ) + expect(screen.getByPlaceholderText('Search anything')).toBeInTheDocument() +}) diff --git a/src/modules/search/components/BarSearchAutosuggest.jsx b/src/modules/search/components/BarSearchAutosuggest.jsx new file mode 100644 index 0000000000..40c7db23a7 --- /dev/null +++ b/src/modules/search/components/BarSearchAutosuggest.jsx @@ -0,0 +1,148 @@ +import cx from 'classnames' +import React, { useState } from 'react' +import Autosuggest from 'react-autosuggest' + +import { models, useClient } from 'cozy-client' +import { isFlagshipApp } from 'cozy-device-helper' +import { useWebviewIntent } from 'cozy-intent' +import List from 'cozy-ui/transpiled/react/List' + +import { SHARED_DRIVES_DIR_ID } from 'constants/config' +import BarSearchInputGroup from 'modules/search/components/BarSearchInputGroup' +import SuggestionItem from 'modules/search/components/SuggestionItem' +import SuggestionListSkeleton from 'modules/search/components/SuggestionListSkeleton' +import useSearch from 'modules/search/hooks/useSearch' + +import styles from 'modules/search/components/styles.styl' + +const BarSearchAutosuggest = ({ t }) => { + const webviewIntent = useWebviewIntent() + const client = useClient() + + const [input, setInput] = useState('') + const [searchTerm, setSearchTerm] = useState('') + const { suggestions, hasSuggestions, isBusy, query, makeIndexes } = + useSearch(searchTerm) + const [focused, setFocused] = useState(false) + + const theme = { + container: 'u-w-100', + suggestionsContainer: + styles['bar-search-autosuggest-suggestions-container'], + suggestionsContainerOpen: + styles['bar-search-autosuggest-suggestions-container--open'], + suggestionsList: styles['bar-search-autosuggest-suggestions-list'] + } + + const onSuggestionsFetchRequested = ({ value }) => { + setSearchTerm(value) + } + const onSuggestionsClearRequested = () => { + setSearchTerm('') + } + + const cleanSearch = () => { + setInput('') + setSearchTerm('') + } + + const onSuggestionSelected = async (event, { suggestion }) => { + // Open the shared drive in a new tab + if (suggestion.parentUrl?.includes(SHARED_DRIVES_DIR_ID)) { + window.open(`/#/external/${suggestion.id}`, '_blank') + return cleanSearch() + } + + let url = `${window.location.origin}/#${suggestion.url}` + if (suggestion.openOn === 'notes') { + url = await models.note.fetchURL(client, { + id: suggestion.url.substr(3) + }) + } + + if (url) { + if (isFlagshipApp()) { + webviewIntent.call('openApp', url, { slug: suggestion.openOn }) + } else { + window.location.assign(url) + } + } else { + // eslint-disable-next-line no-console + console.error(`openSuggestion (${suggestion.name}) could not be executed`) + } + cleanSearch() + } + + // We want the user to find folders in which he can then navigate into, so we return the path here + const getSuggestionValue = suggestion => suggestion.subtitle + + const renderSuggestion = suggestion => { + return ( + + ) + } + + const inputProps = { + placeholder: t('searchbar.placeholder'), + value: input, + onChange: (event, { newValue }) => { + setInput(newValue) + }, + onFocus: () => { + makeIndexes() + setFocused(true) + }, + onBlur: () => setFocused(false) + } + + const renderInputComponent = inputProps => ( + + + + ) + + const renderSuggestionsContainer = ({ containerProps, children }) => { + return {children} + } + + const hasNoSearchResult = searchTerm !== '' && focused && !hasSuggestions + + return ( +
+ + {hasNoSearchResult && !isBusy && ( +
+ {t('searchbar.empty', { query })} +
+ )} + {hasNoSearchResult && isBusy && ( +
+ +
+ )} +
+ ) +} + +export default BarSearchAutosuggest diff --git a/src/modules/search/components/BarSearchInputGroup.jsx b/src/modules/search/components/BarSearchInputGroup.jsx new file mode 100644 index 0000000000..92dc93ea0c --- /dev/null +++ b/src/modules/search/components/BarSearchInputGroup.jsx @@ -0,0 +1,43 @@ +import React from 'react' + +import Icon from 'cozy-ui/transpiled/react/Icon' +import IconButton from 'cozy-ui/transpiled/react/IconButton' +import CrossCircleOutlineIcon from 'cozy-ui/transpiled/react/Icons/CrossCircleOutline' +import Magnifier from 'cozy-ui/transpiled/react/Icons/Magnifier' +import InputGroup from 'cozy-ui/transpiled/react/InputGroup' + +import styles from 'modules/search/components/styles.styl' + +const BarSearchInputGroup = ({ + children, + isMobile, + onClean, + isInputNotEmpty +}) => { + return ( + + ) +} + +export default BarSearchInputGroup diff --git a/src/modules/search/components/SearchEmpty.jsx b/src/modules/search/components/SearchEmpty.jsx new file mode 100644 index 0000000000..f7ae8c674f --- /dev/null +++ b/src/modules/search/components/SearchEmpty.jsx @@ -0,0 +1,44 @@ +import React from 'react' + +import Grid from 'cozy-ui/transpiled/react/Grid' +import Icon from 'cozy-ui/transpiled/react/Icon' +import Typography from 'cozy-ui/transpiled/react/Typography' +import { useI18n } from 'cozy-ui/transpiled/react/providers/I18n' + +import searchEmptyIllustration from 'assets/icons/icon-search-empty.svg' + +const SearchEmpty = ({ query }) => { + const { t } = useI18n() + + return ( + + + + + + {t('search.empty.title', { query })} + + + + + {t('search.empty.subtitle', { query })} + + + + ) +} + +export default SearchEmpty diff --git a/src/modules/search/components/SuggestionItem.jsx b/src/modules/search/components/SuggestionItem.jsx new file mode 100644 index 0000000000..c37810da26 --- /dev/null +++ b/src/modules/search/components/SuggestionItem.jsx @@ -0,0 +1,63 @@ +import React, { useCallback } from 'react' + +import ListItem from 'cozy-ui/transpiled/react/ListItem' +import ListItemIcon from 'cozy-ui/transpiled/react/ListItemIcon' +import ListItemText from 'cozy-ui/transpiled/react/ListItemText' + +import { SHARED_DRIVES_DIR_ID } from 'constants/config' +import FileIconMime from 'modules/filelist/icons/FileIconMime' +import FileIconShortcut from 'modules/filelist/icons/FileIconShortcut' +import SuggestionItemTextHighlighted from 'modules/search/components/SuggestionItemTextHighlighted' +import SuggestionItemTextSecondary from 'modules/search/components/SuggestionItemTextSecondary' + +const SuggestionItem = ({ + suggestion, + query, + onClick, + onParentOpened, + isMobile = false +}) => { + const openSuggestion = useCallback(() => { + if (typeof onClick == 'function') { + onClick(suggestion) + } + }, [onClick, suggestion]) + + const file = { + class: suggestion.class, + type: suggestion.type, + mime: suggestion.mime, + name: suggestion.title.replace(/\.url$/, ''), // Not using `splitFileName()` because we don't have access to the full file here. + parentUrl: suggestion.parentUrl + } + + return ( + + + {file.class === 'shortcut' ? ( + + ) : ( + + )} + + + } + secondary={ + file.parentUrl?.includes(SHARED_DRIVES_DIR_ID) ? null : ( + + ) + } + /> + + ) +} + +export default SuggestionItem diff --git a/src/modules/search/components/SuggestionItemSkeleton.jsx b/src/modules/search/components/SuggestionItemSkeleton.jsx new file mode 100644 index 0000000000..a39fae978f --- /dev/null +++ b/src/modules/search/components/SuggestionItemSkeleton.jsx @@ -0,0 +1,44 @@ +import React from 'react' + +import ListItem from 'cozy-ui/transpiled/react/ListItem' +import ListItemIcon from 'cozy-ui/transpiled/react/ListItemIcon' +import ListItemText from 'cozy-ui/transpiled/react/ListItemText' +import Skeleton from 'cozy-ui/transpiled/react/Skeleton' + +const SuggestionItemSkeleton = () => { + return ( + + + + + + } + secondary={ + + } + /> + + ) +} + +export default SuggestionItemSkeleton diff --git a/src/modules/search/components/SuggestionItemTextHighlighted.jsx b/src/modules/search/components/SuggestionItemTextHighlighted.jsx new file mode 100644 index 0000000000..1cb6ff5049 --- /dev/null +++ b/src/modules/search/components/SuggestionItemTextHighlighted.jsx @@ -0,0 +1,104 @@ +import React from 'react' + +import { normalizeString } from 'modules/search/components/helpers' + +/** + * Add on part that equlas query into each result + * + * @param {Array} searchResult - list of results + * @param {string} query - search input + * @returns list of results with the query highlighted + */ +const highlightQueryTerms = (searchResult, query) => { + const normalizedQueryTerms = normalizeString(query) + const normalizedResultTerms = normalizeString(searchResult) + + const matchedIntervals = [] + const spacerLength = 1 + let currentIndex = 0 + + normalizedResultTerms.forEach(resultTerm => { + normalizedQueryTerms.forEach(queryTerm => { + const index = resultTerm.indexOf(queryTerm) + if (index >= 0) { + matchedIntervals.push({ + from: currentIndex + index, + to: currentIndex + index + queryTerm.length + }) + } + }) + + currentIndex += resultTerm.length + spacerLength + }) + + // matchedIntervals can overlap, so we merge them. + // - sort the intervals by starting index + // - add the first interval to the stack + // - for every interval, + // - - add it to the stack if it doesn't overlap with the stack top + // - - or extend the stack top if the start overlaps and the new interval's top is bigger + const mergedIntervals = matchedIntervals + .sort((intervalA, intervalB) => intervalA.from > intervalB.from) + .reduce((computedIntervals, newInterval) => { + if ( + computedIntervals.length === 0 || + computedIntervals[computedIntervals.length - 1].to < newInterval.from + ) { + computedIntervals.push(newInterval) + } else if ( + computedIntervals[computedIntervals.length - 1].to < newInterval.to + ) { + computedIntervals[computedIntervals.length - 1].to = newInterval.to + } + + return computedIntervals + }, []) + + // create an array containing the entire search result, with special characters, and the intervals surrounded y `` tags + const slicedOriginalResult = + mergedIntervals.length > 0 + ? [{searchResult.slice(0, mergedIntervals[0].from)}] + : searchResult + + for (let i = 0, l = mergedIntervals.length; i < l; ++i) { + slicedOriginalResult.push( + + {searchResult.slice(mergedIntervals[i].from, mergedIntervals[i].to)} + + ) + if (i + 1 < l) + slicedOriginalResult.push( + + {searchResult.slice( + mergedIntervals[i].to, + mergedIntervals[i + 1].from + )} + + ) + } + + if (mergedIntervals.length > 0) + slicedOriginalResult.push( + + {searchResult.slice( + mergedIntervals[mergedIntervals.length - 1].to, + searchResult.length + )} + + ) + + return slicedOriginalResult +} + +const SuggestionItemTextHighlighted = ({ text, query }) => { + const textHighlighted = highlightQueryTerms(text, query) + if (Array.isArray(textHighlighted)) { + return textHighlighted.map((item, idx) => ({ + ...item, + key: idx + })) + } + return textHighlighted +} + +export default SuggestionItemTextHighlighted diff --git a/src/modules/search/components/SuggestionItemTextSecondary.jsx b/src/modules/search/components/SuggestionItemTextSecondary.jsx new file mode 100644 index 0000000000..8680a0b08d --- /dev/null +++ b/src/modules/search/components/SuggestionItemTextSecondary.jsx @@ -0,0 +1,66 @@ +import React from 'react' + +import { generateWebLink, useClient } from 'cozy-client' +import { isFlagshipApp } from 'cozy-device-helper' +import AppLinker, { + generateUniversalLink +} from 'cozy-ui/transpiled/react/AppLinker' + +import SuggestionItemTextHighlighted from 'modules/search/components/SuggestionItemTextHighlighted' + +import styles from 'modules/search/components/styles.styl' + +const SuggestionItemTextSecondary = ({ + text, + query, + url, + onOpened, + isMobile +}) => { + const client = useClient() + + if (isMobile) { + return + } + + const app = { + slug: 'drive' + } + + const { subdomain: subDomainType } = client.getInstanceOptions() + const generateLink = isFlagshipApp() ? generateUniversalLink : generateWebLink + + const appWebRef = + app && + generateLink({ + slug: 'drive', + cozyUrl: client.getStackClient().uri, + subDomainType, + nativePath: url, + pathname: '/', + hash: url + }) + return ( + + {({ onClick, href }) => ( + { + e.stopPropagation() + if (typeof onOpened == 'function') { + onOpened(e) + } + if (typeof onClick == 'function') { + onClick(e) + } + }} + > + + + )} + + ) +} + +export default SuggestionItemTextSecondary diff --git a/src/modules/search/components/SuggestionListSkeleton.jsx b/src/modules/search/components/SuggestionListSkeleton.jsx new file mode 100644 index 0000000000..f6011386b9 --- /dev/null +++ b/src/modules/search/components/SuggestionListSkeleton.jsx @@ -0,0 +1,17 @@ +import React from 'react' + +import List from 'cozy-ui/transpiled/react/List' + +import SuggestionItemSkeleton from 'modules/search/components/SuggestionItemSkeleton' + +const SuggestionListSkeleton = ({ count }) => ( + + {Array(count || 4) + .fill(1) + .map((_, i) => ( + + ))} + +) + +export default SuggestionListSkeleton diff --git a/src/modules/search/components/helpers.js b/src/modules/search/components/helpers.js new file mode 100644 index 0000000000..2f4594ed46 --- /dev/null +++ b/src/modules/search/components/helpers.js @@ -0,0 +1,114 @@ +import { models } from 'cozy-client' + +import { ROOT_DIR_ID, SHARED_DRIVES_DIR_ID } from 'constants/config' +import FuzzyPathSearch from 'lib/FuzzyPathSearch.js' +import { isEncryptedFolder } from 'lib/encryption' +import { makeOnlyOfficeFileRoute } from 'modules/views/OnlyOffice/helpers' + +export const TYPE_DIRECTORY = 'directory' + +export const normalizeString = str => + str + .toString() + .toLowerCase() + .replace(/\//g, ' ') + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') + .split(' ') + +/** + * Normalize file for Front usage in component inside + * + * To reduce API call, the fetching of Note URL has been delayed + * inside an onSelect function called only if provided to + * see https://github.com/cozy/cozy-drive/pull/2663#discussion_r938671963 + * + * @param {CozyClient} client - cozy client instance + * @param {[IOCozyFile]} folders - all the folders returned by API + * @param {IOCozyFile} file - file to normalize + * @returns file with normalized field to be used in AutoSuggestion + */ +export const makeNormalizedFile = (client, folders, file) => { + const isDir = file.type === TYPE_DIRECTORY + const dirId = isDir ? file._id : file.dir_id + const urlToFolder = `/folder/${dirId}` + + let path, url, parentUrl + let openOn = 'drive' + if (isDir) { + path = file.path + url = urlToFolder + parentUrl = urlToFolder + } else { + const parentDir = folders.find(folder => folder._id === file.dir_id) + path = parentDir && parentDir.path ? parentDir.path : '' + parentUrl = parentDir && parentDir._id ? `/folder/${parentDir._id}` : '' + if (models.file.isNote(file)) { + url = `/n/${file.id}` + openOn = 'notes' + } else if (models.file.shouldBeOpenedByOnlyOffice(file)) { + url = makeOnlyOfficeFileRoute(file.id, { fromPathname: urlToFolder }) + } else { + url = `${urlToFolder}/file/${file._id}` + } + } + + return { + id: file._id, + type: file.type, + name: file.name, + mime: file.mime, + class: file.class, + path, + url, + parentUrl, + openOn, + isEncrypted: isEncryptedFolder(file) + } +} + +/** + * Fetches all files without trashed and preloads FuzzyPathSearch + * + * Using _all_docs route + * + * Also, this method: + * - removing trashed data directly + * - removes orphan file + * - normalize file to match expectation + * - preloads FuzzyPathSearch + * + * @returns {Promise} nothing + */ +export const indexFiles = async client => { + const resp = await client + .getStackClient() + .fetchJSON( + 'GET', + '/data/io.cozy.files/_all_docs?Fields=_id,trashed,dir_id,name,path,type,mime,class,metadata.title,metadata.version&DesignDocs=false&include_docs=true' + ) + const files = resp.rows.map(row => ({ id: row.id, ...row.doc })) + const folders = files.filter(file => file.type === TYPE_DIRECTORY) + + const notInTrash = file => !file.trashed && !/^\/\.cozy_trash/.test(file.path) + const notOrphans = file => + folders.find(folder => folder._id === file.dir_id) !== undefined + const notRoot = file => file._id !== ROOT_DIR_ID + // Shared drives folder to be hidden in search. + // The files inside it though must appear. Thus only the file with the folder ID is filtered out. + const notSharedDrivesDir = file => file._id !== SHARED_DRIVES_DIR_ID + + const normalizedFilesPrevious = files.filter( + file => + notInTrash(file) && + notOrphans(file) && + notRoot(file) && + notSharedDrivesDir(file) + ) + + const normalizedFiles = normalizedFilesPrevious.map(file => + makeNormalizedFile(client, folders, file) + ) + + return new FuzzyPathSearch(normalizedFiles) +} diff --git a/src/modules/search/components/helpers.spec.jsx b/src/modules/search/components/helpers.spec.jsx new file mode 100644 index 0000000000..e263019295 --- /dev/null +++ b/src/modules/search/components/helpers.spec.jsx @@ -0,0 +1,118 @@ +import { createMockClient, models } from 'cozy-client' + +import { makeNormalizedFile, TYPE_DIRECTORY } from './helpers' + +models.note.fetchURL = jest.fn(() => 'noteUrl') + +const client = createMockClient({}) + +const noteFileProps = { + name: 'note.cozy-note', + metadata: { + content: '', + schema: '', + title: '', + version: '' + } +} + +describe('makeNormalizedFile', () => { + it('should return correct values for a directory', () => { + const folders = [] + const file = { + _id: 'fileId', + type: TYPE_DIRECTORY, + path: 'filePath', + name: 'fileName' + } + + const normalizedFile = makeNormalizedFile(client, folders, file) + + expect(normalizedFile).toEqual({ + id: 'fileId', + name: 'fileName', + path: 'filePath', + url: '/folder/fileId', + parentUrl: '/folder/fileId', + openOn: 'drive', + isEncrypted: false, + mime: undefined, + type: 'directory' + }) + }) + + it('should return correct values for a file', () => { + const folders = [{ _id: 'folderId', path: 'folderPath' }] + const file = { + _id: 'fileId', + dir_id: 'folderId', + type: 'file', + name: 'fileName' + } + + const normalizedFile = makeNormalizedFile(client, folders, file) + + expect(normalizedFile).toEqual({ + id: 'fileId', + name: 'fileName', + path: 'folderPath', + url: '/folder/folderId/file/fileId', + parentUrl: '/folder/folderId', + openOn: 'drive', + isEncrypted: false, + mime: undefined, + type: 'file' + }) + }) + + it('should return correct values for a note with on Select function - better for performance', () => { + const folders = [{ _id: 'folderId', path: 'folderPath' }] + const file = { + _id: 'fileId', + id: 'noteId', + dir_id: 'folderId', + type: 'file', + name: 'fileName', + ...noteFileProps + } + + const normalizedFile = makeNormalizedFile(client, folders, file) + + expect(normalizedFile).toEqual({ + id: 'fileId', + name: 'note.cozy-note', + path: 'folderPath', + url: '/n/noteId', + parentUrl: '/folder/folderId', + openOn: 'notes', + isEncrypted: false, + mime: undefined, + type: 'file' + }) + }) + + it('should not return filled onSelect for a note without metadata', () => { + const folders = [{ _id: 'folderId', path: 'folderPath' }] + const file = { + _id: 'fileId', + id: 'noteId', + dir_id: 'folderId', + type: 'file', + name: 'note.cozy-note' + } + + const normalizedFile = makeNormalizedFile(client, folders, file) + + expect(normalizedFile).toEqual({ + id: 'fileId', + name: 'note.cozy-note', + path: 'folderPath', + url: '/folder/folderId/file/fileId', + parentUrl: '/folder/folderId', + openOn: 'drive', + isEncrypted: false, + mime: undefined, + type: 'file' + }) + }) +}) diff --git a/src/modules/search/components/styles.styl b/src/modules/search/components/styles.styl new file mode 100644 index 0000000000..db89498f03 --- /dev/null +++ b/src/modules/search/components/styles.styl @@ -0,0 +1,82 @@ +[role=banner] + .bar-search-autosuggest-suggestions-container + position absolute + top 100% + width 100% + max-height em(170px) + overflow auto + border-radius .5em + color var(--primaryTextColor) + background var(--paperBackgroundColor) + box-shadow var(--shadow7) + display none + box-sizing border-box + + .bar-search-autosuggest-suggestions-container--open + display block + + .bar-search-autosuggest-status-container + position absolute + display flex + align-items center + top 100% + left 0 + right 0 + min-height 48px + max-height em(170px) + overflow auto + border-radius .5em + background var(--paperBackgroundColor) + box-shadow var(--shadow7) + box-sizing border-box + + &.--empty + padding .75em 1em + + .bar-search-autosuggest-suggestions-list + margin 0 + padding 0 + list-style none + + .bar-search-container + position relative + display flex + align-items center + flex-grow 1 + margin-left 2em + margin-right 2em + padding-top .25em + padding-bottom .25em + + &.mobile + margin-left 0 + margin-right -.5em + + .bar-search-input-group + border 0 + max-height 40px + padding-left .5em + border-radius 1.25em + background-color var(--defaultBackgroundColor) + transition all .2s ease-out + overflow hidden + + &:hover + background linear-gradient(0deg, var(--actionColorHover), var(--actionColorHover)), var(--defaultBackgroundColor) + + .bar-search-input-group-append + padding-left .5em + color var(--secondaryTextColor) + + input + padding-left .5em + background-color transparent + max-width 100% + height 100% + +.suggestion-item-parent-link + color var(--secondaryTextColor) + text-decoration none + + &:hover + text-decoration underline diff --git a/src/modules/search/hooks/useSearch.jsx b/src/modules/search/hooks/useSearch.jsx new file mode 100644 index 0000000000..95134f835b --- /dev/null +++ b/src/modules/search/hooks/useSearch.jsx @@ -0,0 +1,90 @@ +import { useState, useEffect, useMemo } from 'react' + +import { useClient } from 'cozy-client' + +import useDebounce from 'hooks/useDebounce' +import { indexFiles } from 'modules/search/components/helpers' + +const useSearch = (searchTerm, { limit = 10 } = {}) => { + const client = useClient() + const [allSuggestions, setAllSuggestions] = useState([]) + const [suggestions, setSuggestions] = useState([]) + const [fuzzy, setFuzzy] = useState(null) + const [isBusy, setBusy] = useState(true) + const [query, setQuery] = useState('') + + const debouncedSearchTerm = useDebounce(searchTerm, { + delay: 500, + ignore: searchTerm === '' + }) + + const makeIndexes = async () => { + if (fuzzy == null) { + setFuzzy(await indexFiles(client)) + } + } + + useEffect(() => { + const fetchSuggestions = async value => { + setBusy(true) + let currentFuzzy = fuzzy + if (currentFuzzy == null) { + currentFuzzy = await indexFiles(client) + setFuzzy(currentFuzzy) + } + const suggestions = currentFuzzy.search(value).map(result => ({ + id: result.id, + title: result.name, + subtitle: result.path, + url: result.url, + parentUrl: result.parentUrl, + openOn: result.openOn, + type: result.type, + mime: result.mime, + isEncrypted: result.isEncrypted, + class: result.class + })) + + setBusy(value === '') // To prevent empty state to appear at the first search + setQuery(value) + setAllSuggestions(suggestions) + setSuggestions(suggestions.slice(0, limit)) + } + + if (debouncedSearchTerm !== '') { + fetchSuggestions(debouncedSearchTerm) + } else { + clearSuggestions() + } + }, [client, debouncedSearchTerm, fuzzy, limit]) + + const hasSuggestions = useMemo(() => suggestions.length > 0, [suggestions]) + + const hasMore = useMemo( + () => suggestions.length < allSuggestions.length, + [suggestions, allSuggestions] + ) + + const fetchMore = async () => { + setSuggestions(allSuggestions.slice(0, suggestions.length + limit)) + } + + const clearSuggestions = () => { + setBusy(true) + setQuery('') + setAllSuggestions([]) + setSuggestions([]) + } + + return { + suggestions, + hasSuggestions, + hasMore, + isBusy, + query, + makeIndexes, + fetchMore + } +} + +export default useSearch diff --git a/src/modules/views/Search/SearchView.jsx b/src/modules/views/Search/SearchView.jsx new file mode 100644 index 0000000000..3828f954fd --- /dev/null +++ b/src/modules/views/Search/SearchView.jsx @@ -0,0 +1,145 @@ +import cx from 'classnames' +import React, { useState, useCallback } from 'react' +import { useEffect } from 'react' +import { useLocation, useNavigate } from 'react-router-dom' + +import { BarLeft, BarSearch } from 'cozy-bar' +import { models, useClient } from 'cozy-client' +import { isFlagshipApp } from 'cozy-device-helper' +import { useWebviewIntent } from 'cozy-intent' +import Input from 'cozy-ui/transpiled/react/Input' +import { Main } from 'cozy-ui/transpiled/react/Layout' +import List from 'cozy-ui/transpiled/react/List' +import LoadMore from 'cozy-ui/transpiled/react/LoadMore' +import useBreakpoints from 'cozy-ui/transpiled/react/providers/Breakpoints' +import { useI18n } from 'cozy-ui/transpiled/react/providers/I18n' + +import BackButton from 'components/Button/BackButton' +import BarSearchInputGroup from 'modules/search/components/BarSearchInputGroup' +import SearchEmpty from 'modules/search/components/SearchEmpty' +import SuggestionItem from 'modules/search/components/SuggestionItem' +import SuggestionListSkeleton from 'modules/search/components/SuggestionListSkeleton' +import useSearch from 'modules/search/hooks/useSearch' + +import styles from 'modules/search/components/styles.styl' + +const SearchView = () => { + const webviewIntent = useWebviewIntent() + const { search } = useLocation() + const navigate = useNavigate() + const { isMobile } = useBreakpoints() + const client = useClient() + + const [searchTerm, setSearchTerm] = useState('') + const { t } = useI18n() + const { + isBusy, + suggestions, + hasSuggestions, + query, + makeIndexes, + hasMore, + fetchMore + } = useSearch(searchTerm, { limit: 25 }) + + useEffect(() => { + makeIndexes() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + const onInputChanged = event => { + setSearchTerm(event.target.value) + } + + const navigateBack = useCallback(() => { + const params = new URLSearchParams(search) + const returnPath = params.get('returnPath') + navigate(returnPath ? returnPath : '/') + }, [navigate, search]) + + const openSuggestion = useCallback( + async suggestion => { + if (suggestion.openOn === 'drive') { + navigate(suggestion.url) + } else if (suggestion.openOn === 'notes') { + const url = await models.note.fetchURL(client, { + id: suggestion.url.substr(3) + }) + if (isFlagshipApp()) { + webviewIntent.call('openApp', url, { + slug: 'notes' + }) + } else { + window.location.assign(url) + } + } else { + // eslint-disable-next-line no-console + console.error( + `openSuggestion (${suggestion.name}) could not be executed` + ) + } + }, + [navigate, webviewIntent, client] + ) + + const handleCleanInput = () => { + setSearchTerm('') + } + + const hasNoSearchResult = searchTerm !== '' && !hasSuggestions + + return ( +
+ {isMobile && ( + + + + )} + +
+ + + +
+
+ {hasSuggestions && ( + + {suggestions.map(suggestion => ( + + ))} + + )} + {hasMore && ( +
+ +
+ )} + {hasNoSearchResult && !isBusy && } + {hasNoSearchResult && isBusy && } +
+ ) +} + +export default SearchView From 12704b9de9a21b1ca536a88b66ac8f7868334d0d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20Poizat?= Date: Thu, 19 Dec 2024 16:54:28 +0100 Subject: [PATCH 2/3] feat: Use old search in mobile In flagship app, it is not possible to use the new search with the dataproxy. To simplify, let's use old search on mobile. --- src/modules/navigation/AppRoute.jsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/modules/navigation/AppRoute.jsx b/src/modules/navigation/AppRoute.jsx index 482e5bf2e1..8d1ffe35e7 100644 --- a/src/modules/navigation/AppRoute.jsx +++ b/src/modules/navigation/AppRoute.jsx @@ -2,7 +2,7 @@ import React from 'react' import { Route, useParams, Navigate } from 'react-router-dom' import { RealTimeQueries } from 'cozy-client' -import { SearchDialog, AssistantDialog } from 'cozy-dataproxy-lib' +import { AssistantDialog } from 'cozy-dataproxy-lib' import flag from 'cozy-flags' import ExternalRedirect from './ExternalRedirect' @@ -41,6 +41,7 @@ import { NextcloudFolderView } from 'modules/views/Nextcloud/NextcloudFolderView import { NextcloudMoveView } from 'modules/views/Nextcloud/NextcloudMoveView' import { NextcloudTrashEmptyView } from 'modules/views/Nextcloud/NextcloudTrashEmptyView' import { NextcloudTrashView } from 'modules/views/Nextcloud/NextcloudTrashView' +import SearchView from 'modules/views/Search/SearchView' import { TrashDestroyView } from 'modules/views/Trash/TrashDestroyView' import { TrashEmptyView } from 'modules/views/Trash/TrashEmptyView' @@ -153,7 +154,7 @@ const AppRoute = () => ( /> } /> - } /> + } /> Date: Thu, 19 Dec 2024 17:09:31 +0100 Subject: [PATCH 3/3] test: Remove unused test --- .../search/components/AppBarSearch.spec.jsx | 17 ----------------- 1 file changed, 17 deletions(-) delete mode 100644 src/modules/search/components/AppBarSearch.spec.jsx diff --git a/src/modules/search/components/AppBarSearch.spec.jsx b/src/modules/search/components/AppBarSearch.spec.jsx deleted file mode 100644 index 78401ede6e..0000000000 --- a/src/modules/search/components/AppBarSearch.spec.jsx +++ /dev/null @@ -1,17 +0,0 @@ -import { render, screen } from '@testing-library/react' -import React from 'react' - -import CozyClient from 'cozy-client' - -import AppBarSearch from 'modules/search/components/AppBarSearch' -import AppLike from 'test/components/AppLike' - -it('should display the Searchbar', () => { - const client = new CozyClient({}) - render( - - - - ) - expect(screen.getByPlaceholderText('Search anything')).toBeInTheDocument() -})