Skip to content

Commit

Permalink
feat(search): improve search by querying fewer data
Browse files Browse the repository at this point in the history
Selector is mandatory when using .select or .partialIndex
cozy/cozy-client#1216
  • Loading branch information
trollepierre committed Aug 10, 2022
1 parent f1e5acf commit 1133fb9
Show file tree
Hide file tree
Showing 8 changed files with 258 additions and 62 deletions.
11 changes: 9 additions & 2 deletions .eslintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
"no-console": 1,
"no-param-reassign": "error",
"react-hooks/exhaustive-deps": "error"

},
"globals": {
"fixture": false
Expand All @@ -13,5 +12,13 @@
"react": {
"version": "detect"
}
}
},
"overrides": [
{
"files": ["*.spec.js[x]"],
"rules": {
"react/display-name": "off"
}
}
]
}
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

* Upgrade [email protected] to be able to findAll in FileCollection
* Upgrade [email protected] to be able to call onSelect function
* 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)
Expand Down
31 changes: 31 additions & 0 deletions src/drive/web/modules/queries.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
86 changes: 51 additions & 35 deletions src/drive/web/modules/services/components/SuggestionProvider.jsx
Original file line number Diff line number Diff line change
@@ -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 => {
Expand All @@ -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<void>} nothing
*/
async provideSuggestions(query, id, intent) {
if (!this.hasIndexedFiles) {
if (!this.hasIndexFilesBeenLaunched) {
await this.indexFiles()
}

Expand All @@ -38,52 +56,50 @@ 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
}))
},
intent.attributes.client
)
}

// 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 <SearchBar> expectation
* - preloads FuzzyPathSearch
*
* @returns {Promise<void>} 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() {
Expand Down
109 changes: 109 additions & 0 deletions src/drive/web/modules/services/components/SuggestionProvider.spec.jsx
Original file line number Diff line number Diff line change
@@ -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 <Component client={mockClient} intent={intent}></Component>
}
}))
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(<SuggestionProvider />)

// 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(<SuggestionProvider />)

// 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'
)
})
})
})
20 changes: 17 additions & 3 deletions src/drive/web/modules/services/components/helpers.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,33 @@ import { models } from 'cozy-client'

export const TYPE_DIRECTORY = 'directory'

export const makeNormalizedFile = async (client, folders, file, getIconUrl) => {
/**
* Normalize file for Front usage in <AutoSuggestion> component inside <SearchBar>
*
* To reduce API call, the fetching of Note URL has been delayed
* inside an onSelect function called only if provided to <SearchBar>
* 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<string>)}} 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
} else {
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}`
}
Expand All @@ -26,6 +39,7 @@ export const makeNormalizedFile = async (client, folders, file, getIconUrl) => {
name: file.name,
path,
url,
onSelect,
icon: getIconUrl(file)
}
}
Loading

0 comments on commit 1133fb9

Please sign in to comment.