diff --git a/.eslintrc.json b/.eslintrc.json index 938f4e68e3..9a1264d8b8 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -4,7 +4,6 @@ "no-console": 1, "no-param-reassign": "error", "react-hooks/exhaustive-deps": "error" - }, "globals": { "fixture": false @@ -13,5 +12,13 @@ "react": { "version": "detect" } - } + }, + "overrides": [ + { + "files": ["*.spec.js[x]"], + "rules": { + "react/display-name": "off" + } + } + ] } diff --git a/CHANGELOG.md b/CHANGELOG.md index c58896c76c..156d7ad454 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## ✨ Features +* Improve speed of search suggestion by querying fewer data * Update cozy-stack-client and cozy-pouch-link to sync with cozy-client version * Update cozy-ui - Modify Viewers to handle [68.0.0 BC](https://github.com/cozy/cozy-ui/releases/tag/v68.0.0) @@ -12,7 +13,7 @@ * Improve cozy-bar implementation to fix UI bugs in Amirale * Fix navigation through mobile Flagship on Note creation and opening -* Remove unused contacts permissions on Photos +* Remove unused contacts permissions on Photos ## 🔧 Tech diff --git a/src/drive/web/modules/queries.js b/src/drive/web/modules/queries.js index f1592dad6c..1efe3af6b7 100644 --- a/src/drive/web/modules/queries.js +++ b/src/drive/web/modules/queries.js @@ -325,6 +325,37 @@ export const buildEncryptionByIdQuery = id => ({ } }) +/** + * Provide selector and options to fetch files + * + * @returns {{options: {MangoQueryOptions}, selector: object}} A minimalist file list + */ +export const prepareSuggestionQuery = () => { + const selector = { + _id: { + $gt: null + } + } + const options = { + partialFilter: { + _id: { + $ne: TRASH_DIR_ID + }, + path: { + // this predicate is necessary until the trashed attribute is more reliable + $or: [{ $exists: false }, { $regex: '^(?!/.cozy_trash)' }] + }, + trashed: { + $or: [{ $exists: false }, { $eq: false }] + } + }, + fields: ['_id', 'trashed', 'dir_id', 'name', 'path', 'type', 'mime'], + indexedFields: ['_id'], + limit: 1000 + } + return { selector, options } +} + export { buildDriveQuery, buildRecentQuery, diff --git a/src/drive/web/modules/services/components/SuggestionProvider.jsx b/src/drive/web/modules/services/components/SuggestionProvider.jsx index abfccb3873..eaa507faa3 100644 --- a/src/drive/web/modules/services/components/SuggestionProvider.jsx +++ b/src/drive/web/modules/services/components/SuggestionProvider.jsx @@ -1,15 +1,16 @@ -/* global cozy */ import React from 'react' import FuzzyPathSearch from '../FuzzyPathSearch' import { withClient } from 'cozy-client' import { TYPE_DIRECTORY, makeNormalizedFile } from './helpers' import { getIconUrl } from './iconContext' +import { DOCTYPE_FILES } from 'drive/lib/doctypes' +import { prepareSuggestionQuery } from '../../queries' class SuggestionProvider extends React.Component { componentDidMount() { const { intent } = this.props - this.hasIndexedFiles = false + this.hasIndexFilesBeenLaunched = false // re-attach the message listener for the intent to receive the suggestion requests window.addEventListener('message', event => { @@ -22,8 +23,25 @@ class SuggestionProvider extends React.Component { }) } + /** + * Provide Suggestions to calling Intent + * + * This method called when intent provide query will indexFiles once + * to fill FuzzyPathSearch. Then will re-post message to the intent + * with updated search results containing `files` as `suggestions` + * for SearchBar need. + * + * ⚠️ For note file, we don't provide url to open, but onSelect method + * to be called on click. Less API calls expected. But a note will be opened + * slower. See helpers.js + * + * @param query - Query to find file + * @param id + * @param intent - Intent calling + * @returns {Promise} nothing + */ async provideSuggestions(query, id, intent) { - if (!this.hasIndexedFiles) { + if (!this.hasIndexFilesBeenLaunched) { await this.indexFiles() } @@ -38,7 +56,7 @@ class SuggestionProvider extends React.Component { title: result.name, subtitle: result.path, term: result.name, - onSelect: 'open:' + result.url, + onSelect: result.onSelect || 'open:' + result.url, icon: result.icon })) }, @@ -46,44 +64,42 @@ class SuggestionProvider extends React.Component { ) } - // fetches pretty much all the files and preloads FuzzyPathSearch + /** + * Fetches all files without trashed and preloads FuzzyPathSearch + * + * Using _find route (from findAll) improves performance: + * - using partial index to reduce amount of data fetched + * - removing trashed data directly + * + * Also, this method: + * - set first the `hasIndexFilesBeenLaunched` to prevent multiple calls + * - removes orphan file + * - normalize file to match expectation + * - preloads FuzzyPathSearch + * + * @returns {Promise} nothing + */ async indexFiles() { const { client } = this.props - // TODO: fix me - // eslint-disable-next-line no-async-promise-executor - return new Promise(async resolve => { - const resp = await cozy.client.fetchJSON( - 'GET', - `/data/io.cozy.files/_all_docs?include_docs=true` - ) - const files = resp.rows - // TODO: fix me - // eslint-disable-next-line no-prototype-builtins - .filter(row => !row.doc.hasOwnProperty('views')) - .map(row => ({ id: row.id, ...row.doc })) + this.hasIndexFilesBeenLaunched = true - const folders = files.filter(file => file.type === TYPE_DIRECTORY) + const { selector, options } = prepareSuggestionQuery() + const files = await client + .collection(DOCTYPE_FILES) + .findAll(selector, options) - const notInTrash = file => - !file.trashed && !/^\/\.cozy_trash/.test(file.path) - const notOrphans = file => - folders.find(folder => folder._id === file.dir_id) !== undefined + const folders = files.filter(file => file.type === TYPE_DIRECTORY) - const normalizedFilesPrevious = files - .filter(notInTrash) - .filter(notOrphans) + const notOrphans = file => + folders.find(folder => folder._id === file.dir_id) !== undefined - const normalizedFiles = await Promise.all( - normalizedFilesPrevious.map( - async file => - await makeNormalizedFile(client, folders, file, getIconUrl) - ) - ) + const normalizedFilesPrevious = files.filter(notOrphans) - this.fuzzyPathSearch = new FuzzyPathSearch(normalizedFiles) - this.hasIndexedFiles = true - resolve() - }) + const normalizedFiles = normalizedFilesPrevious.map(file => + makeNormalizedFile(client, folders, file, getIconUrl) + ) + + this.fuzzyPathSearch = new FuzzyPathSearch(normalizedFiles) } render() { diff --git a/src/drive/web/modules/services/components/SuggestionProvider.spec.jsx b/src/drive/web/modules/services/components/SuggestionProvider.spec.jsx new file mode 100644 index 0000000000..fe4f405890 --- /dev/null +++ b/src/drive/web/modules/services/components/SuggestionProvider.spec.jsx @@ -0,0 +1,109 @@ +import React from 'react' +import { render, waitFor } from '@testing-library/react' +import SuggestionProvider from './SuggestionProvider' +import { dummyFile, dummyNote } from 'test/dummies/dummyFile' + +const parentFolder = dummyFile({ _id: 'id-file' }) +const folder = dummyFile({ dir_id: 'id-file' }) +const note = dummyNote({ + dir_id: 'id-file', + name: 'name.cozy-note' +}) +const mockFindAll = jest.fn().mockReturnValue([parentFolder, folder, note]) +const mockClient = { + collection: jest.fn().mockReturnValue({ findAll: mockFindAll }) +} +const mockIntentAttributesClient = 'intent-attributes-client' + +jest.mock('cozy-client', () => ({ + ...jest.requireActual('cozy-client'), + withClient: Component => () => { + const intent = { + _id: 'id_intent', + attributes: { client: mockIntentAttributesClient } + } + return + } +})) +jest.mock('./iconContext', () => ({ getIconUrl: () => 'iconUrl' })) + +describe('SuggestionProvider', () => { + let events = {} + let event + + beforeEach(() => { + window.addEventListener = jest.fn((event, callback) => { + events[event] = callback + }) + window.parent.postMessage = jest.fn() + event = { + origin: mockIntentAttributesClient, + data: { query: 'name', id: 'id' } + } + }) + + it('should query all files to display fuzzy suggestion', () => { + // Given + render() + + // When + events.message(event) + + // Then + expect(mockClient.collection).toHaveBeenCalledWith('io.cozy.files') + expect(mockFindAll).toHaveBeenCalledWith( + { + _id: { + $gt: null + } + }, + { + fields: ['_id', 'trashed', 'dir_id', 'name', 'path', 'type', 'mime'], + indexedFields: ['_id'], + limit: 1000, + partialFilter: { + _id: { $ne: 'io.cozy.files.trash-dir' }, + path: { $or: [{ $exists: false }, { $regex: '^(?!/.cozy_trash)' }] }, + trashed: { $or: [{ $exists: false }, { $eq: false }] } + } + } + ) + }) + + it('should provide onSelect with open url when file is not a note + and function when it is a note', async () => { + // Given + render() + + // When + events.message(event) + + // Then + await waitFor(() => { + expect(window.parent.postMessage).toHaveBeenCalledWith( + { + id: 'id', + suggestions: [ + { + icon: 'iconUrl', + id: 'id-file', + onSelect: 'open:http://localhost/#/folder/id-file', + subtitle: '/path', + term: 'name', + title: 'name' + }, + { + icon: 'iconUrl', + id: 'id-file', + onSelect: expect.any(Function), + subtitle: '/path', + term: 'name.cozy-note', + title: 'name.cozy-note' + } + ], + type: 'intent-id_intent:data' + }, + 'intent-attributes-client' + ) + }) + }) +}) diff --git a/src/drive/web/modules/services/components/helpers.js b/src/drive/web/modules/services/components/helpers.js index a9e35a45a8..a15de3b70c 100644 --- a/src/drive/web/modules/services/components/helpers.js +++ b/src/drive/web/modules/services/components/helpers.js @@ -2,12 +2,25 @@ import { models } from 'cozy-client' export const TYPE_DIRECTORY = 'directory' -export const makeNormalizedFile = async (client, folders, file, getIconUrl) => { +/** + * 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 client - cozy client instance + * @param folders - all the folders returned by API + * @param file - file to normalize + * @param getIconUrl - method to get icon url + * @returns {{path: (*|string), name, icon, id, url: string, onSelect: (function(): Promise)}} file with + */ +export const makeNormalizedFile = (client, folders, file, getIconUrl) => { const isDir = file.type === TYPE_DIRECTORY const dirId = isDir ? file._id : file.dir_id const urlToFolder = `${window.location.origin}/#/folder/${dirId}` - let path, url + let path, url, onSelect if (isDir) { path = file.path url = urlToFolder @@ -15,7 +28,7 @@ export const makeNormalizedFile = async (client, folders, file, getIconUrl) => { const parentDir = folders.find(folder => folder._id === file.dir_id) path = parentDir && parentDir.path ? parentDir.path : '' if (models.file.isNote(file)) { - url = await models.note.fetchURL(client, file) + onSelect = () => models.note.fetchURL(client, file) } else { url = `${urlToFolder}/file/${file._id}` } @@ -26,6 +39,7 @@ export const makeNormalizedFile = async (client, folders, file, getIconUrl) => { name: file.name, path, url, + onSelect, icon: getIconUrl(file) } } diff --git a/src/drive/web/modules/services/components/helpers.spec.js b/src/drive/web/modules/services/components/helpers.spec.js index 57244d2ee2..84af90814d 100644 --- a/src/drive/web/modules/services/components/helpers.spec.js +++ b/src/drive/web/modules/services/components/helpers.spec.js @@ -19,7 +19,7 @@ const noteFileProps = { } describe('makeNormalizedFile', () => { - it('should return correct values for a directory', async () => { + it('should return correct values for a directory', () => { const folders = [] const file = { _id: 'fileId', @@ -28,12 +28,7 @@ describe('makeNormalizedFile', () => { name: 'fileName' } - const normalizedFile = await makeNormalizedFile( - client, - folders, - file, - getIconUrl - ) + const normalizedFile = makeNormalizedFile(client, folders, file, getIconUrl) expect(normalizedFile).toMatchObject({ id: 'fileId', @@ -43,7 +38,7 @@ describe('makeNormalizedFile', () => { }) }) - it('should return correct values for a file', async () => { + it('should return correct values for a file', () => { const folders = [{ _id: 'folderId', path: 'folderPath' }] const file = { _id: 'fileId', @@ -52,12 +47,7 @@ describe('makeNormalizedFile', () => { name: 'fileName' } - const normalizedFile = await makeNormalizedFile( - client, - folders, - file, - getIconUrl - ) + const normalizedFile = makeNormalizedFile(client, folders, file, getIconUrl) expect(normalizedFile).toMatchObject({ id: 'fileId', @@ -67,7 +57,7 @@ describe('makeNormalizedFile', () => { }) }) - it('should return correct values for a note', async () => { + 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', @@ -77,18 +67,13 @@ describe('makeNormalizedFile', () => { ...noteFileProps } - const normalizedFile = await makeNormalizedFile( - client, - folders, - file, - getIconUrl - ) + const normalizedFile = makeNormalizedFile(client, folders, file, getIconUrl) expect(normalizedFile).toMatchObject({ id: 'fileId', name: 'note.cozy-note', path: 'folderPath', - url: 'noteUrl' + onSelect: expect.any(Function) }) }) }) diff --git a/test/dummies/dummyFile.js b/test/dummies/dummyFile.js new file mode 100644 index 0000000000..0a1427f1e8 --- /dev/null +++ b/test/dummies/dummyFile.js @@ -0,0 +1,33 @@ +/** + * Create a dummy file, with overridden value of given param + * + * @param file + * @returns {*&{path: string, name: string, icon: string, id: string, _id: string, dir_id: string, type: string}} a dummy file + */ +export const dummyFile = file => ({ + name: 'name', + id: 'id-file', + _id: 'id-file', + icon: 'icon', + path: '/path', + type: 'directory', + ...file +}) + +/** + * Create a dummy note, with overridden value of given param + * + * @param note + * @returns {*&{path: string, name: string, icon: string, id: string, _id: string, dir_id: string, type: string}} a dummy note + */ +export const dummyNote = note => ({ + ...dummyFile(), + type: 'file', + metadata: { + content: '', + schema: '', + title: '', + version: '' + }, + ...note +})