mirror of
https://github.com/transloadit/uppy.git
synced 2026-01-23 02:25:07 +00:00
Server side search @uppy/Companion (#6003)
## High Level View <img width="3367" height="1576" alt="Global Search (1)" src="https://github.com/user-attachments/assets/134e8658-5cbd-4816-87a1-3bd42603089d" /> - Search View replicated , through minimal components `<GlobalSearchView />` and `<SearchResultItem />` - Both components take only the minimal state needed to render the search view no dependency on PartialTree. search response from companion server is directly passed to GlobalSearchView for file state. - `#buildPath` creates missing parent nodes in partialTree (if any) and opens the folder in the normal way using a minimal wrapper over openFolder function. - Both interactions : "checking a file/folder" and "opening a folder" use the same function `#buildPath` to build the path, then use the already existing `openFolder` and `toggleCheckBox`. - Max search results: 1000. Pagination removed for simplicity (can be added later). - From a UI/UX standpoint, all functionality works as expected. - The only limitation is occasional inconsistent partial checked states when the tree isn’t fully built — unavoidable since percolateUp and percolateDown require the complete partialTree to sync state correctly. This issue isn’t critical; even in other cases, we already mark folders "checked" whereas they may be empty if not yet fetched. - I figured out it's better to just derive the checkedState from PartialTree , and then pass it to `GlobalSearchView` rather than keep it separate and then worrying about checked state syncs across two views for UI to look right. - IMO this is the most simplest approach I could come up with. without sacrificing any user functionality and it carefully reuses all the util code. --------- Co-authored-by: Merlijn Vos <merlijn@soverin.net> Co-authored-by: Mikael Finstad <finstaden@gmail.com>
This commit is contained in:
parent
b7605df940
commit
5ba2c1c8d3
15 changed files with 507 additions and 66 deletions
10
.changeset/mean-times-try.md
Normal file
10
.changeset/mean-times-try.md
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
---
|
||||
"@uppy/companion-client": minor
|
||||
"@uppy/provider-views": minor
|
||||
"@uppy/companion": minor
|
||||
"@uppy/utils": minor
|
||||
"@uppy/core": minor
|
||||
---
|
||||
|
||||
|
||||
Introduce the concept of server-side search and add support for it for the Dropbox provider. Previously, only client-side filtering in the currently viewed folder was possible, which was limiting. Now users using Companion with Dropbox can perform a search across their entire account.
|
||||
|
|
@ -357,6 +357,22 @@ export default class Provider<M extends Meta, B extends Body>
|
|||
return this.get<ResBody>(`${this.id}/list/${directory || ''}`, options)
|
||||
}
|
||||
|
||||
search<ResBody>(
|
||||
text: string,
|
||||
options: RequestOptions & {
|
||||
path?: string | null | undefined
|
||||
cursor?: string | undefined
|
||||
} = {},
|
||||
): Promise<ResBody> {
|
||||
const qs = new URLSearchParams()
|
||||
qs.set('q', text)
|
||||
if (options.path) qs.set('path', options.path)
|
||||
if (options.cursor) qs.set('cursor', options.cursor)
|
||||
const base = `${this.id}/search`
|
||||
const path = `${base}?${qs.toString()}`
|
||||
return this.get<ResBody>(path, options)
|
||||
}
|
||||
|
||||
async logout<ResBody>(options?: RequestOptions): Promise<ResBody> {
|
||||
const response = await this.get<ResBody>(`${this.id}/logout`, options)
|
||||
await this.removeAuthToken()
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import grantConfigFn from './config/grant.js'
|
|||
import googlePicker from './server/controllers/googlePicker.js'
|
||||
import * as controllers from './server/controllers/index.js'
|
||||
import s3 from './server/controllers/s3.js'
|
||||
import searchController from './server/controllers/search.js'
|
||||
import url from './server/controllers/url.js'
|
||||
import createEmitter from './server/emitter/index.js'
|
||||
import { getURLBuilder } from './server/helpers/utils.js'
|
||||
|
|
@ -221,6 +222,12 @@ export function app(optionsArg = {}) {
|
|||
middlewares.verifyToken,
|
||||
controllers.list,
|
||||
)
|
||||
app.get(
|
||||
'/:providerName/search',
|
||||
middlewares.hasSessionAndProvider,
|
||||
middlewares.verifyToken,
|
||||
searchController,
|
||||
)
|
||||
// backwards compat:
|
||||
app.get(
|
||||
'/search/:providerName/list',
|
||||
|
|
|
|||
17
packages/@uppy/companion/src/server/controllers/search.js
Normal file
17
packages/@uppy/companion/src/server/controllers/search.js
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { respondWithError } from '../provider/error.js'
|
||||
|
||||
export default async function search({ query, companion }, res, next) {
|
||||
const { providerUserSession } = companion
|
||||
|
||||
try {
|
||||
const data = await companion.provider.search({
|
||||
companion,
|
||||
providerUserSession,
|
||||
query,
|
||||
})
|
||||
res.json(data)
|
||||
} catch (err) {
|
||||
if (respondWithError(err, res)) return
|
||||
next(err)
|
||||
}
|
||||
}
|
||||
|
|
@ -35,6 +35,16 @@ export default class Provider {
|
|||
throw new Error('method not implemented')
|
||||
}
|
||||
|
||||
/**
|
||||
* search for files/folders in the provider account
|
||||
*
|
||||
* @param {object} options
|
||||
* @returns {Promise}
|
||||
*/
|
||||
async search(options) {
|
||||
throw new Error('method not implemented')
|
||||
}
|
||||
|
||||
/**
|
||||
* download a certain file from the provider account
|
||||
*
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import adaptData from './adapter.js'
|
|||
const charsToEncode = /[\u007f-\uffff]/g
|
||||
function httpHeaderSafeJson(v) {
|
||||
return JSON.stringify(v).replace(charsToEncode, (c) => {
|
||||
return `\\u${(`000${c.charCodeAt(0).toString(16)}`).slice(-4)}`
|
||||
return `\\u${`000${c.charCodeAt(0).toString(16)}`.slice(-4)}`
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -96,6 +96,33 @@ async function list({ client, directory, query }) {
|
|||
.json()
|
||||
}
|
||||
|
||||
async function fetchSearchEntries({ client, query }) {
|
||||
const scopePath =
|
||||
typeof query.path === 'string' ? decodeURIComponent(query.path) : undefined
|
||||
|
||||
const searchRes = await client
|
||||
.post('files/search_v2', {
|
||||
json: {
|
||||
query: query.q.trim(),
|
||||
options: {
|
||||
path: scopePath || '',
|
||||
max_results: 1000,
|
||||
file_status: 'active',
|
||||
filename_only: false,
|
||||
},
|
||||
},
|
||||
responseType: 'json',
|
||||
})
|
||||
.json()
|
||||
|
||||
const entries = searchRes.matches.map((m) => m.metadata.metadata)
|
||||
return {
|
||||
entries,
|
||||
has_more: searchRes.has_more,
|
||||
cursor: searchRes.cursor,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adapter for API https://www.dropbox.com/developers/documentation/http/documentation
|
||||
*/
|
||||
|
|
@ -114,8 +141,28 @@ export default class Dropbox extends Provider {
|
|||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {object} options
|
||||
* Search entries
|
||||
*/
|
||||
async search(options) {
|
||||
return this.#withErrorHandling(
|
||||
'provider.dropbox.search.error',
|
||||
async () => {
|
||||
const { client, userInfo } = await getClient({
|
||||
token: options.providerUserSession.accessToken,
|
||||
namespaced: true,
|
||||
})
|
||||
|
||||
const stats = await fetchSearchEntries({ client, query: options.query })
|
||||
console.log(stats)
|
||||
const { email } = userInfo
|
||||
// we don't really need email, but let's mimic `list` response shape for consistency
|
||||
return adaptData(stats, email, options.companion.buildURL)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* List folder entries
|
||||
*/
|
||||
async list(options) {
|
||||
return this.#withErrorHandling('provider.dropbox.list.error', async () => {
|
||||
|
|
|
|||
|
|
@ -96,7 +96,10 @@ export type PartialTreeFolderNode = {
|
|||
|
||||
status: PartialTreeStatus
|
||||
parentId: PartialTreeId
|
||||
data: CompanionFile
|
||||
data: Pick<
|
||||
CompanionFile,
|
||||
'name' | 'icon' | 'thumbnail' | 'isFolder' | 'author' | 'custom'
|
||||
>
|
||||
}
|
||||
|
||||
export type PartialTreeFolderRoot = {
|
||||
|
|
@ -133,6 +136,7 @@ export type UnknownProviderPluginState = {
|
|||
partialTree: PartialTree
|
||||
currentFolderId: PartialTreeId
|
||||
username: string | null
|
||||
searchResults?: string[] | undefined
|
||||
}
|
||||
|
||||
export interface AsyncStore {
|
||||
|
|
|
|||
|
|
@ -93,6 +93,7 @@ export default class Dropbox<M extends Meta, B extends Body>
|
|||
provider: this.provider,
|
||||
loadAllFiles: true,
|
||||
virtualList: true,
|
||||
supportsSearch: true,
|
||||
})
|
||||
|
||||
const { target } = this.opts
|
||||
|
|
|
|||
|
|
@ -0,0 +1,68 @@
|
|||
import type { PartialTreeFile, PartialTreeFolderNode } from '@uppy/core'
|
||||
import type { I18n } from '@uppy/utils'
|
||||
import classNames from 'classnames'
|
||||
import type ProviderView from '../../ProviderView/ProviderView.js'
|
||||
import ItemIcon from './ItemIcon.js'
|
||||
|
||||
interface SearchResultItemProps {
|
||||
item: PartialTreeFile | PartialTreeFolderNode
|
||||
i18n: I18n
|
||||
openFolder: ProviderView<any, any>['openSearchResultFolder']
|
||||
toggleCheckbox: ProviderView<any, any>['toggleCheckbox']
|
||||
}
|
||||
|
||||
const SearchResultItem = ({
|
||||
i18n,
|
||||
item,
|
||||
toggleCheckbox,
|
||||
openFolder,
|
||||
}: SearchResultItemProps) => {
|
||||
const isDisabled =
|
||||
'restrictionError' in item &&
|
||||
item.restrictionError != null &&
|
||||
item.status !== 'checked'
|
||||
|
||||
return (
|
||||
<li
|
||||
className={classNames(
|
||||
'uppy-ProviderBrowserItem',
|
||||
{ 'uppy-ProviderBrowserItem--disabled': isDisabled },
|
||||
{ 'uppy-ProviderBrowserItem--noPreview': item.data.icon === 'video' },
|
||||
{ 'uppy-ProviderBrowserItem--is-checked': item.status === 'checked' },
|
||||
{ 'uppy-ProviderBrowserItem--is-partial': item.status === 'partial' },
|
||||
)}
|
||||
title={
|
||||
('restrictionError' in item ? item.restrictionError : undefined) ??
|
||||
undefined
|
||||
}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="uppy-u-reset uppy-ProviderBrowserItem-checkbox"
|
||||
onChange={() => toggleCheckbox(item, false)}
|
||||
checked={item.status === 'checked'}
|
||||
aria-label={item.data.name ?? i18n('unnamed')}
|
||||
disabled={isDisabled}
|
||||
data-uppy-super-focusable
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="uppy-u-reset uppy-c-btn uppy-ProviderBrowserItem-inner"
|
||||
disabled={isDisabled}
|
||||
aria-label={item.data.name}
|
||||
onClick={() => {
|
||||
if (item.data.isFolder) {
|
||||
openFolder(item.id)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="uppy-ProviderBrowserItem-iconWrap">
|
||||
<ItemIcon itemIconString={item.data.icon} />
|
||||
</div>
|
||||
{item.data.name ?? i18n('unnamed')}
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
export default SearchResultItem
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
import type { PartialTreeFile, PartialTreeFolderNode } from '@uppy/core'
|
||||
import type { I18n } from '@uppy/utils'
|
||||
import SearchResultItem from '../Item/components/SearchResultItem.js'
|
||||
import type ProviderView from './ProviderView.js'
|
||||
|
||||
interface GlobalSearchViewProps {
|
||||
searchResults: (PartialTreeFile | PartialTreeFolderNode)[]
|
||||
openFolder: ProviderView<any, any>['openSearchResultFolder']
|
||||
toggleCheckbox: ProviderView<any, any>['toggleCheckbox']
|
||||
i18n: I18n
|
||||
}
|
||||
|
||||
const GlobalSearchView = ({
|
||||
searchResults,
|
||||
toggleCheckbox,
|
||||
openFolder,
|
||||
i18n,
|
||||
}: GlobalSearchViewProps) => {
|
||||
if (searchResults.length === 0) {
|
||||
return <div className="uppy-Provider-empty">{i18n('noFilesFound')}</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="uppy-ProviderBrowser-body">
|
||||
<ul className="uppy-ProviderBrowser-list">
|
||||
{searchResults.map((item) => (
|
||||
<SearchResultItem
|
||||
i18n={i18n}
|
||||
key={item.id}
|
||||
item={item}
|
||||
toggleCheckbox={toggleCheckbox}
|
||||
openFolder={openFolder}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default GlobalSearchView
|
||||
|
|
@ -13,6 +13,7 @@ import type {
|
|||
import type { CompanionFile, I18n } from '@uppy/utils'
|
||||
import { remoteFileObjToLocal } from '@uppy/utils'
|
||||
import classNames from 'classnames'
|
||||
import debounce from 'lodash/debounce.js'
|
||||
import type { h } from 'preact'
|
||||
import packageJson from '../../package.json' with { type: 'json' }
|
||||
import Browser from '../Browser.js'
|
||||
|
|
@ -27,6 +28,7 @@ import getNumberOfSelectedFiles from '../utils/PartialTreeUtils/getNumberOfSelec
|
|||
import PartialTreeUtils from '../utils/PartialTreeUtils/index.js'
|
||||
import shouldHandleScroll from '../utils/shouldHandleScroll.js'
|
||||
import AuthView from './AuthView.js'
|
||||
import GlobalSearchView from './GlobalSearchView.js'
|
||||
import Header from './Header.js'
|
||||
|
||||
export function defaultPickerIcon(): h.JSX.Element {
|
||||
|
|
@ -78,6 +80,7 @@ export interface Opts<M extends Meta, B extends Body> {
|
|||
onAuth: (authFormData: unknown) => Promise<void>
|
||||
}) => h.JSX.Element
|
||||
virtualList: boolean
|
||||
supportsSearch?: boolean
|
||||
}
|
||||
type PassedOpts<M extends Meta, B extends Body> = Optional<
|
||||
Opts<M, B>,
|
||||
|
|
@ -93,9 +96,16 @@ type RenderOpts<M extends Meta, B extends Body> = Omit<
|
|||
PassedOpts<M, B>,
|
||||
'provider'
|
||||
>
|
||||
|
||||
/**
|
||||
* Class to easily generate generic views for Provider plugins
|
||||
*
|
||||
* We have a *search view* and a *normal view*.
|
||||
* Search view is only used when the Provider supports server side search i.e. provider.search method is implemented for the provider.
|
||||
* The state is stored in searchResults.
|
||||
* Search view is implemented in components GlobalSearchView and SearchResultItem.
|
||||
* We conditionally switch between search view and normal in the render method when a server side search is initiated.
|
||||
* When users type their search query in search input box (SearchInput component), we debounce the input and call provider.search method to fetch results from the server.
|
||||
* when the user enters a folder in search results or clears the search input query we switch back to Normal View.
|
||||
*/
|
||||
export default class ProviderView<M extends Meta, B extends Body> {
|
||||
static VERSION = packageJson.version
|
||||
|
|
@ -108,7 +118,7 @@ export default class ProviderView<M extends Meta, B extends Body> {
|
|||
|
||||
isHandlingScroll: boolean = false
|
||||
|
||||
lastCheckbox: string | null = null
|
||||
previousCheckbox: string | null = null
|
||||
|
||||
constructor(plugin: UnknownProviderPlugin<M, B>, opts: PassedOpts<M, B>) {
|
||||
this.plugin = plugin
|
||||
|
|
@ -133,6 +143,8 @@ export default class ProviderView<M extends Meta, B extends Body> {
|
|||
this.render = this.render.bind(this)
|
||||
this.cancelSelection = this.cancelSelection.bind(this)
|
||||
this.toggleCheckbox = this.toggleCheckbox.bind(this)
|
||||
this.openSearchResultFolder = this.openSearchResultFolder.bind(this)
|
||||
this.clearSearchState = this.clearSearchState.bind(this)
|
||||
|
||||
// Set default state for the plugin
|
||||
this.resetPluginState()
|
||||
|
|
@ -159,6 +171,10 @@ export default class ProviderView<M extends Meta, B extends Body> {
|
|||
this.plugin.setPluginState({ loading })
|
||||
}
|
||||
|
||||
get isLoading() {
|
||||
return this.plugin.getPluginState().loading
|
||||
}
|
||||
|
||||
cancelSelection(): void {
|
||||
const { partialTree } = this.plugin.getPluginState()
|
||||
const newPartialTree: PartialTree = partialTree.map((item) =>
|
||||
|
|
@ -167,6 +183,12 @@ export default class ProviderView<M extends Meta, B extends Body> {
|
|||
this.plugin.setPluginState({ partialTree: newPartialTree })
|
||||
}
|
||||
|
||||
clearSearchState(): void {
|
||||
this.plugin.setPluginState({
|
||||
searchResults: undefined,
|
||||
})
|
||||
}
|
||||
|
||||
#abortController: AbortController | undefined
|
||||
|
||||
async #withAbort(op: (signal: AbortSignal) => Promise<void>) {
|
||||
|
|
@ -195,13 +217,148 @@ export default class ProviderView<M extends Meta, B extends Body> {
|
|||
}
|
||||
}
|
||||
|
||||
async openFolder(folderId: string | null): Promise<void> {
|
||||
this.lastCheckbox = null
|
||||
async #search(): Promise<void> {
|
||||
const { partialTree, currentFolderId, searchString } =
|
||||
this.plugin.getPluginState()
|
||||
|
||||
const currentFolder = partialTree.find((i) => i.id === currentFolderId)!
|
||||
|
||||
if (searchString.trim() === '') {
|
||||
this.#abortController?.abort()
|
||||
this.clearSearchState()
|
||||
return
|
||||
}
|
||||
|
||||
this.setLoading(true)
|
||||
await this.#withAbort(async (signal) => {
|
||||
const scopePath =
|
||||
currentFolder.type === 'root' ? undefined : currentFolderId
|
||||
const { items } = await this.provider.search!(searchString, {
|
||||
signal,
|
||||
path: scopePath,
|
||||
})
|
||||
|
||||
// For each searched file, build the entire path (from the root all the way to the leaf node)
|
||||
// This is because we need to make sure all ancestor folders are present in the partialTree before we open the folder or check the file.
|
||||
// This is needed because when the user opens a folder we need to have all its parent folders in the partialTree to be able to render the breadcrumbs correctly.
|
||||
// Similarly when the user checks a file, we need to have all it's ancestor folders in the partialTree to be able to percolateUp the checked state correctly to its ancestors.
|
||||
|
||||
const { partialTree } = this.plugin.getPluginState()
|
||||
const newPartialTree: PartialTree = [...partialTree]
|
||||
|
||||
for (const file of items) {
|
||||
// Decode URI and split into path segments
|
||||
const decodedPath = decodeURIComponent(file.requestPath)
|
||||
const segments = decodedPath.split('/').filter((s) => s.length > 0)
|
||||
|
||||
// Start from root
|
||||
let parentId: PartialTreeId = this.plugin.rootFolderId
|
||||
let isParentFolderChecked: boolean
|
||||
|
||||
// Walk through each segment starting from the root and build child nodes if they don't exist
|
||||
segments.forEach((segment, index, arr) => {
|
||||
const pathSegments = segments.slice(0, index + 1)
|
||||
const encodedPath = encodeURIComponent(`/${pathSegments.join('/')}`)
|
||||
|
||||
// Skip if node already exists
|
||||
const existingNode = newPartialTree.find(
|
||||
(n) => n.id === encodedPath && n.type !== 'root',
|
||||
) as PartialTreeFolderNode | PartialTreeFile | undefined
|
||||
if (existingNode) {
|
||||
parentId = encodedPath
|
||||
isParentFolderChecked = existingNode.status === 'checked'
|
||||
return
|
||||
}
|
||||
|
||||
const isLeafNode = index === arr.length - 1
|
||||
let node: PartialTreeFolderNode | PartialTreeFile
|
||||
|
||||
// propagate checked state from parent to children, if the user has checked the parent folder before searching
|
||||
// and the parent folder is an ancestor of the searched file
|
||||
// see also afterOpenFolder which contains similar logic, we should probably refactor and reuse some
|
||||
const status = isParentFolderChecked ? 'checked' : 'unchecked'
|
||||
|
||||
// Build the Leaf Node, it can be a file (`PartialTreeFile`) or a folder (`PartialTreeFolderNode`).
|
||||
// Since we Already have the leaf node's data (`file`, `CompanionFile`) from the searchResults: CompanionFile[], we just use that.
|
||||
if (isLeafNode) {
|
||||
if (file.isFolder) {
|
||||
node = {
|
||||
type: 'folder',
|
||||
id: encodedPath,
|
||||
cached: false,
|
||||
nextPagePath: null,
|
||||
status,
|
||||
parentId,
|
||||
data: file,
|
||||
}
|
||||
} else {
|
||||
const restrictionError = this.validateSingleFile(file)
|
||||
node = {
|
||||
type: 'file',
|
||||
id: encodedPath,
|
||||
restrictionError,
|
||||
status: !restrictionError ? status : 'unchecked',
|
||||
parentId,
|
||||
data: file,
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// not leaf node, so by definition it is a folder leading up to the leaf node
|
||||
node = {
|
||||
type: 'folder',
|
||||
id: encodedPath,
|
||||
cached: false,
|
||||
nextPagePath: null,
|
||||
status,
|
||||
parentId,
|
||||
data: {
|
||||
// we don't have any data, so fill only the necessary fields
|
||||
name: decodeURIComponent(segment),
|
||||
icon: 'folder',
|
||||
isFolder: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
newPartialTree.push(node)
|
||||
parentId = encodedPath // This node becomes parent for the next iteration
|
||||
isParentFolderChecked = status === 'checked'
|
||||
})
|
||||
}
|
||||
|
||||
this.plugin.setPluginState({
|
||||
partialTree: newPartialTree,
|
||||
searchResults: items.map((item) => item.requestPath),
|
||||
})
|
||||
}).catch(handleError(this.plugin.uppy))
|
||||
this.setLoading(false)
|
||||
}
|
||||
|
||||
#searchDebounced = debounce(this.#search, 500)
|
||||
|
||||
onSearchInput = (s: string): void => {
|
||||
this.plugin.setPluginState({ searchString: s })
|
||||
if (this.opts.supportsSearch) this.#searchDebounced()
|
||||
}
|
||||
|
||||
async openSearchResultFolder(folderId: PartialTreeId): Promise<void> {
|
||||
// stop searching
|
||||
this.plugin.setPluginState({ searchString: '' })
|
||||
|
||||
// now open folder using the normal view
|
||||
await this.openFolder(folderId)
|
||||
}
|
||||
|
||||
async openFolder(folderId: PartialTreeId): Promise<void> {
|
||||
// always switch away from the search view when opening a folder, whether it happens from the search view or by clicking breadcrumbs
|
||||
this.clearSearchState()
|
||||
|
||||
this.previousCheckbox = null
|
||||
// Returning cached folder
|
||||
const { partialTree } = this.plugin.getPluginState()
|
||||
const clickedFolder = partialTree.find(
|
||||
(folder) => folder.id === folderId,
|
||||
)! as PartialTreeFolder
|
||||
|
||||
if (clickedFolder.cached) {
|
||||
this.plugin.setPluginState({
|
||||
currentFolderId: folderId,
|
||||
|
|
@ -331,6 +488,7 @@ export default class ProviderView<M extends Meta, B extends Body> {
|
|||
async donePicking(): Promise<void> {
|
||||
const { partialTree } = this.plugin.getPluginState()
|
||||
|
||||
if (this.isLoading) return
|
||||
this.setLoading(true)
|
||||
await this.#withAbort(async (signal) => {
|
||||
// 1. Enrich our partialTree by fetching all 'checked' but not-yet-fetched folders
|
||||
|
|
@ -373,15 +531,16 @@ export default class ProviderView<M extends Meta, B extends Body> {
|
|||
ourItem.id,
|
||||
this.getDisplayedPartialTree(),
|
||||
isShiftKeyPressed,
|
||||
this.lastCheckbox,
|
||||
this.previousCheckbox,
|
||||
)
|
||||
|
||||
const newPartialTree = PartialTreeUtils.afterToggleCheckbox(
|
||||
partialTree,
|
||||
clickedRange,
|
||||
)
|
||||
|
||||
this.plugin.setPluginState({ partialTree: newPartialTree })
|
||||
this.lastCheckbox = ourItem.id
|
||||
this.previousCheckbox = ourItem.id
|
||||
}
|
||||
|
||||
getDisplayedPartialTree = (): (PartialTreeFile | PartialTreeFolderNode)[] => {
|
||||
|
|
@ -390,14 +549,16 @@ export default class ProviderView<M extends Meta, B extends Body> {
|
|||
const inThisFolder = partialTree.filter(
|
||||
(item) => item.type !== 'root' && item.parentId === currentFolderId,
|
||||
) as (PartialTreeFile | PartialTreeFolderNode)[]
|
||||
|
||||
// If provider supports server side search, we don't filter the items client side
|
||||
const filtered =
|
||||
searchString === ''
|
||||
this.opts.supportsSearch || searchString.trim() === ''
|
||||
? inThisFolder
|
||||
: inThisFolder.filter(
|
||||
(item) =>
|
||||
(item.data.name ?? this.plugin.uppy.i18n('unnamed'))
|
||||
.toLowerCase()
|
||||
.indexOf(searchString.toLowerCase()) !== -1,
|
||||
.indexOf(searchString.trim().toLowerCase()) !== -1,
|
||||
)
|
||||
|
||||
return filtered
|
||||
|
|
@ -421,6 +582,36 @@ export default class ProviderView<M extends Meta, B extends Body> {
|
|||
return this.plugin.uppy.validateAggregateRestrictions(uppyFiles)
|
||||
}
|
||||
|
||||
#renderSearchResults() {
|
||||
const { i18n } = this.plugin.uppy
|
||||
|
||||
const { searchResults: ids, partialTree } = this.plugin.getPluginState()
|
||||
|
||||
// todo memoize this so we don't have to do it on every render
|
||||
const itemsById = new Map<string, PartialTreeFile | PartialTreeFolderNode>()
|
||||
partialTree.forEach((item) => {
|
||||
if (item.type !== 'root') {
|
||||
itemsById.set(item.id, item)
|
||||
}
|
||||
})
|
||||
|
||||
// the search results view needs data from the partial tree,
|
||||
const searchResults = ids!.map((id) => {
|
||||
const partialTreeItem = itemsById.get(id)
|
||||
if (partialTreeItem == null) throw new Error('Partial tree not complete')
|
||||
return partialTreeItem
|
||||
})
|
||||
|
||||
return (
|
||||
<GlobalSearchView
|
||||
searchResults={searchResults}
|
||||
openFolder={this.openSearchResultFolder}
|
||||
toggleCheckbox={this.toggleCheckbox}
|
||||
i18n={i18n}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
render(state: unknown, viewOptions: RenderOpts<M, B> = {}): h.JSX.Element {
|
||||
const { didFirstRender } = this.plugin.getPluginState()
|
||||
const { i18n } = this.plugin.uppy
|
||||
|
|
@ -448,7 +639,8 @@ export default class ProviderView<M extends Meta, B extends Body> {
|
|||
)
|
||||
}
|
||||
|
||||
const { partialTree, username, searchString } = this.plugin.getPluginState()
|
||||
const { partialTree, username, searchString, searchResults } =
|
||||
this.plugin.getPluginState()
|
||||
const breadcrumbs = this.getBreadcrumbs()
|
||||
|
||||
return (
|
||||
|
|
@ -468,13 +660,10 @@ export default class ProviderView<M extends Meta, B extends Body> {
|
|||
username={username}
|
||||
i18n={i18n}
|
||||
/>
|
||||
|
||||
{opts.showFilter && (
|
||||
<SearchInput
|
||||
searchString={searchString}
|
||||
setSearchString={(s: string) => {
|
||||
this.plugin.setPluginState({ searchString: s })
|
||||
}}
|
||||
setSearchString={(s: string) => this.onSearchInput(s)}
|
||||
submitSearchString={() => {}}
|
||||
inputLabel={i18n('filter')}
|
||||
clearSearchLabel={i18n('resetFilter')}
|
||||
|
|
@ -483,19 +672,23 @@ export default class ProviderView<M extends Meta, B extends Body> {
|
|||
/>
|
||||
)}
|
||||
|
||||
<Browser<M, B>
|
||||
toggleCheckbox={this.toggleCheckbox}
|
||||
displayedPartialTree={this.getDisplayedPartialTree()}
|
||||
openFolder={this.openFolder}
|
||||
virtualList={opts.virtualList}
|
||||
noResultsLabel={i18n('noFilesFound')}
|
||||
handleScroll={this.handleScroll}
|
||||
viewType={opts.viewType}
|
||||
showTitles={opts.showTitles}
|
||||
i18n={this.plugin.uppy.i18n}
|
||||
isLoading={loading}
|
||||
utmSource="Companion"
|
||||
/>
|
||||
{searchResults ? (
|
||||
this.#renderSearchResults()
|
||||
) : (
|
||||
<Browser<M, B>
|
||||
toggleCheckbox={this.toggleCheckbox}
|
||||
displayedPartialTree={this.getDisplayedPartialTree()}
|
||||
openFolder={this.openFolder}
|
||||
virtualList={opts.virtualList}
|
||||
noResultsLabel={i18n('noFilesFound')}
|
||||
handleScroll={this.handleScroll}
|
||||
viewType={opts.viewType}
|
||||
showTitles={opts.showTitles}
|
||||
i18n={this.plugin.uppy.i18n}
|
||||
isLoading={loading}
|
||||
utmSource="Companion"
|
||||
/>
|
||||
)}
|
||||
|
||||
<FooterActions
|
||||
partialTree={partialTree}
|
||||
|
|
|
|||
|
|
@ -13,8 +13,18 @@ const afterOpenFolder = (
|
|||
currentPagePath: string | null,
|
||||
validateSingleFile: (file: CompanionFile) => string | null,
|
||||
): PartialTree => {
|
||||
const discoveredFolders = discoveredItems.filter((i) => i.isFolder === true)
|
||||
const discoveredFiles = discoveredItems.filter((i) => i.isFolder === false)
|
||||
// Filter out existing items in the partial tree (we don't want duplicates)
|
||||
// If we don't, we would get a duplicate when the item is already added to the partial tree in the search view
|
||||
// and the user then enters its parent from the normal view e.g either through breadcrumbs or manually navigating to it.
|
||||
const discoveredUniqueItems = discoveredItems.filter(
|
||||
(i) => !oldPartialTree.find((f) => f.id === i.requestPath),
|
||||
)
|
||||
const discoveredFolders = discoveredUniqueItems.filter(
|
||||
(i) => i.isFolder === true,
|
||||
)
|
||||
const discoveredFiles = discoveredUniqueItems.filter(
|
||||
(i) => i.isFolder === false,
|
||||
)
|
||||
|
||||
const isParentFolderChecked =
|
||||
clickedFolder.type === 'folder' && clickedFolder.status === 'checked'
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ const percolateDown = (
|
|||
const children = tree.filter(
|
||||
(item) => item.type !== 'root' && item.parentId === id,
|
||||
) as (PartialTreeFolderNode | PartialTreeFile)[]
|
||||
|
||||
children.forEach((item) => {
|
||||
item.status =
|
||||
shouldMarkAsChecked && !(item.type === 'file' && item.restrictionError)
|
||||
|
|
@ -69,7 +70,22 @@ const percolateUp = (tree: PartialTree, id: PartialTreeId) => {
|
|||
(item) => item.status === 'unchecked',
|
||||
)
|
||||
|
||||
if (areAllChildrenChecked) {
|
||||
/**
|
||||
* We should *only* set parent folder to checked/unchecked if it has been fully read (`cached`).
|
||||
* Otherwise, checking a nested folder from the search view also marks its parent as checked,
|
||||
* which could be incorrect because there might be more unselected (unloaded) files.
|
||||
*
|
||||
* Example: /foo/bar/new/myfolder
|
||||
* If we search for "myfolder", we only build the minimal path (using ProviderView.#buildSearchResultPath)
|
||||
* up to that folder adding nodes for "bar", "new", and "myfolder" (assuming "foo" is already
|
||||
* present in the partialTree as part of the root folder).
|
||||
* Since "foo", "bar", and "new" aren’t fully fetched yet, we don’t know if they have other children.
|
||||
* If the user checks "myfolder" from the search results and we propagate the checked state
|
||||
* upward without verifying parent.cached, it would incorrectly mark all its parents as checked.
|
||||
* Later, when the user navigates to any of "foo", "bar", "new" through the Normal View (via breadcrumbs or manually),
|
||||
* PartialTreeUtils.afterOpenFolder would then incorrectly mark and display all its children as checked.
|
||||
*/
|
||||
if (areAllChildrenChecked && folder.cached) {
|
||||
folder.status = 'checked'
|
||||
} else if (areAllChildrenUnchecked) {
|
||||
folder.status = 'unchecked'
|
||||
|
|
@ -82,39 +98,29 @@ const percolateUp = (tree: PartialTree, id: PartialTreeId) => {
|
|||
|
||||
const afterToggleCheckbox = (
|
||||
oldTree: PartialTree,
|
||||
clickedRange: string[],
|
||||
checkedIds: string[],
|
||||
): PartialTree => {
|
||||
const tree: PartialTree = shallowClone(oldTree)
|
||||
|
||||
if (clickedRange.length >= 2) {
|
||||
// We checked two or more items
|
||||
const newlyCheckedItems = tree.filter(
|
||||
(item) => item.type !== 'root' && clickedRange.includes(item.id),
|
||||
) as (PartialTreeFile | PartialTreeFolderNode)[]
|
||||
const newlyCheckedItems = tree.filter(
|
||||
(item) => item.type !== 'root' && checkedIds.includes(item.id),
|
||||
) as (PartialTreeFile | PartialTreeFolderNode)[]
|
||||
|
||||
newlyCheckedItems.forEach((item) => {
|
||||
if (item.type === 'file') {
|
||||
item.status = item.restrictionError ? 'unchecked' : 'checked'
|
||||
} else {
|
||||
item.status = 'checked'
|
||||
}
|
||||
})
|
||||
newlyCheckedItems.forEach((item) => {
|
||||
// allow toggling:
|
||||
const newStatus = item.status === 'checked' ? 'unchecked' : 'checked'
|
||||
// and if it's a file, we need to respect restrictions
|
||||
if (item.type === 'file') {
|
||||
item.status = item.restrictionError ? 'unchecked' : newStatus
|
||||
} else {
|
||||
item.status = newStatus
|
||||
}
|
||||
|
||||
newlyCheckedItems.forEach((item) => {
|
||||
percolateDown(tree, item.id, true)
|
||||
})
|
||||
percolateUp(tree, newlyCheckedItems[0].parentId)
|
||||
} else {
|
||||
// We checked exactly one item
|
||||
const clickedItem = tree.find((item) => item.id === clickedRange[0]) as
|
||||
| PartialTreeFile
|
||||
| PartialTreeFolderNode
|
||||
clickedItem.status =
|
||||
clickedItem.status === 'checked' ? 'unchecked' : 'checked'
|
||||
percolateDown(tree, clickedItem.id, clickedItem.status === 'checked')
|
||||
percolateUp(tree, clickedItem.parentId)
|
||||
}
|
||||
percolateDown(tree, item.id, item.status === 'checked')
|
||||
})
|
||||
|
||||
// all checked items have the same parent so we only need to perlocate the first item
|
||||
percolateUp(tree, newlyCheckedItems[0].parentId)
|
||||
return tree
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,19 +6,19 @@ const getClickedRange = (
|
|||
clickedId: string,
|
||||
displayedPartialTree: (PartialTreeFolderNode | PartialTreeFile)[],
|
||||
isShiftKeyPressed: boolean,
|
||||
lastCheckbox: string | null,
|
||||
previousCheckbox: string | null,
|
||||
): string[] => {
|
||||
const lastCheckboxIndex = displayedPartialTree.findIndex(
|
||||
(item) => item.id === lastCheckbox,
|
||||
const previousCheckboxIndex = displayedPartialTree.findIndex(
|
||||
(item) => item.id === previousCheckbox,
|
||||
)
|
||||
|
||||
if (lastCheckboxIndex !== -1 && isShiftKeyPressed) {
|
||||
if (previousCheckboxIndex !== -1 && isShiftKeyPressed) {
|
||||
const newCheckboxIndex = displayedPartialTree.findIndex(
|
||||
(item) => item.id === clickedId,
|
||||
)
|
||||
const clickedRange = displayedPartialTree.slice(
|
||||
Math.min(lastCheckboxIndex, newCheckboxIndex),
|
||||
Math.max(lastCheckboxIndex, newCheckboxIndex) + 1,
|
||||
Math.min(previousCheckboxIndex, newCheckboxIndex),
|
||||
Math.max(previousCheckboxIndex, newCheckboxIndex) + 1,
|
||||
)
|
||||
|
||||
return clickedRange.map((item) => item.id)
|
||||
|
|
|
|||
|
|
@ -35,6 +35,18 @@ export interface CompanionClientProvider {
|
|||
nextPagePath: string | null
|
||||
items: CompanionFile[]
|
||||
}>
|
||||
// Optional: not every provider implements server-side search.
|
||||
search?: (
|
||||
text: string,
|
||||
options?: RequestOptions & {
|
||||
path?: string | null | undefined
|
||||
cursor?: string | undefined
|
||||
},
|
||||
) => Promise<{
|
||||
username: string
|
||||
nextPagePath: string | null
|
||||
items: CompanionFile[]
|
||||
}>
|
||||
}
|
||||
export interface CompanionClientSearchProvider {
|
||||
name: string
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue