SearchProviderView.tsx 6.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232
  1. import { h } from 'preact'
  2. import type { Body, Meta } from '@uppy/utils/lib/UppyFile'
  3. import type { UnknownSearchProviderPlugin } from '@uppy/core/lib/Uppy'
  4. import type { DefinePluginOpts } from '@uppy/core/lib/BasePlugin'
  5. import type Uppy from '@uppy/core'
  6. import type { CompanionFile } from '@uppy/utils/lib/CompanionFile'
  7. import SearchFilterInput from '../SearchFilterInput.tsx'
  8. import Browser from '../Browser.tsx'
  9. import CloseWrapper from '../CloseWrapper.ts'
  10. import View, { type ViewOptions } from '../View.ts'
  11. // eslint-disable-next-line @typescript-eslint/ban-ts-comment
  12. // @ts-ignore We don't want TS to generate types for the package.json
  13. import packageJson from '../../package.json'
  14. const defaultState = {
  15. isInputMode: true,
  16. files: [],
  17. folders: [],
  18. breadcrumbs: [],
  19. filterInput: '',
  20. currentSelection: [],
  21. searchTerm: null,
  22. }
  23. type PluginType = 'SearchProvider'
  24. const defaultOptions = {
  25. viewType: 'grid',
  26. showTitles: true,
  27. showFilter: true,
  28. showBreadcrumbs: true,
  29. }
  30. type Opts<
  31. M extends Meta,
  32. B extends Body,
  33. T extends PluginType,
  34. > = DefinePluginOpts<ViewOptions<M, B, T>, keyof typeof defaultOptions>
  35. type Res = {
  36. items: CompanionFile[]
  37. nextPageQuery: string | null
  38. searchedFor: string
  39. }
  40. /**
  41. * SearchProviderView, used for Unsplash and future image search providers.
  42. * Extends generic View, shared with regular providers like Google Drive and Instagram.
  43. */
  44. export default class SearchProviderView<
  45. M extends Meta,
  46. B extends Body,
  47. > extends View<M, B, PluginType, Opts<M, B, PluginType>> {
  48. static VERSION = packageJson.version
  49. nextPageQuery: string | null = null
  50. constructor(
  51. plugin: UnknownSearchProviderPlugin<M, B>,
  52. opts: ViewOptions<M, B, PluginType>,
  53. ) {
  54. super(plugin, { ...defaultOptions, ...opts })
  55. this.search = this.search.bind(this)
  56. this.clearSearch = this.clearSearch.bind(this)
  57. this.resetPluginState = this.resetPluginState.bind(this)
  58. this.handleScroll = this.handleScroll.bind(this)
  59. this.donePicking = this.donePicking.bind(this)
  60. this.render = this.render.bind(this)
  61. this.plugin.setPluginState(defaultState)
  62. this.registerRequestClient()
  63. }
  64. // eslint-disable-next-line class-methods-use-this
  65. tearDown(): void {
  66. // Nothing.
  67. }
  68. resetPluginState(): void {
  69. this.plugin.setPluginState(defaultState)
  70. }
  71. #updateFilesAndInputMode(res: Res, files: CompanionFile[]): void {
  72. this.nextPageQuery = res.nextPageQuery
  73. res.items.forEach((item) => {
  74. files.push(item)
  75. })
  76. this.plugin.setPluginState({
  77. currentSelection: [],
  78. isInputMode: false,
  79. files,
  80. searchTerm: res.searchedFor,
  81. })
  82. }
  83. async search(query: string): Promise<void> {
  84. const { searchTerm } = this.plugin.getPluginState()
  85. if (query && query === searchTerm) {
  86. // no need to search again as this is the same as the previous search
  87. return
  88. }
  89. this.setLoading(true)
  90. try {
  91. const res = await this.provider.search<Res>(query)
  92. this.#updateFilesAndInputMode(res, [])
  93. } catch (err) {
  94. this.handleError(err)
  95. } finally {
  96. this.setLoading(false)
  97. }
  98. }
  99. clearSearch(): void {
  100. this.plugin.setPluginState({
  101. currentSelection: [],
  102. files: [],
  103. searchTerm: null,
  104. })
  105. }
  106. async handleScroll(event: Event): Promise<void> {
  107. const query = this.nextPageQuery || null
  108. if (this.shouldHandleScroll(event) && query) {
  109. this.isHandlingScroll = true
  110. try {
  111. const { files, searchTerm } = this.plugin.getPluginState()
  112. const response = await this.provider.search<Res>(searchTerm!, query)
  113. this.#updateFilesAndInputMode(response, files)
  114. } catch (error) {
  115. this.handleError(error)
  116. } finally {
  117. this.isHandlingScroll = false
  118. }
  119. }
  120. }
  121. donePicking(): void {
  122. const { currentSelection } = this.plugin.getPluginState()
  123. this.plugin.uppy.log('Adding remote search provider files')
  124. this.plugin.uppy.addFiles(
  125. currentSelection.map((file) => this.getTagFile(file)),
  126. )
  127. this.resetPluginState()
  128. }
  129. render(
  130. state: unknown,
  131. viewOptions: Omit<ViewOptions<M, B, PluginType>, 'provider'> = {},
  132. ): JSX.Element {
  133. const { didFirstRender, isInputMode, searchTerm } =
  134. this.plugin.getPluginState()
  135. const { i18n } = this.plugin.uppy
  136. if (!didFirstRender) {
  137. this.preFirstRender()
  138. }
  139. const targetViewOptions = { ...this.opts, ...viewOptions }
  140. const { files, folders, filterInput, loading, currentSelection } =
  141. this.plugin.getPluginState()
  142. const { isChecked, filterItems, recordShiftKeyPress } = this
  143. const hasInput = filterInput !== ''
  144. const browserProps = {
  145. isChecked,
  146. toggleCheckbox: this.toggleCheckbox.bind(this),
  147. recordShiftKeyPress,
  148. currentSelection,
  149. files: hasInput ? filterItems(files) : files,
  150. folders: hasInput ? filterItems(folders) : folders,
  151. handleScroll: this.handleScroll,
  152. done: this.donePicking,
  153. cancel: this.cancelPicking,
  154. // For SearchFilterInput component
  155. showSearchFilter: targetViewOptions.showFilter,
  156. search: this.search,
  157. clearSearch: this.clearSearch,
  158. searchTerm,
  159. searchOnInput: false,
  160. searchInputLabel: i18n('search'),
  161. clearSearchLabel: i18n('resetSearch'),
  162. noResultsLabel: i18n('noSearchResults'),
  163. title: this.plugin.title,
  164. viewType: targetViewOptions.viewType,
  165. showTitles: targetViewOptions.showTitles,
  166. showFilter: targetViewOptions.showFilter,
  167. isLoading: loading,
  168. showBreadcrumbs: targetViewOptions.showBreadcrumbs,
  169. pluginIcon: this.plugin.icon,
  170. i18n,
  171. uppyFiles: this.plugin.uppy.getFiles(),
  172. validateRestrictions: (
  173. ...args: Parameters<Uppy<M, B>['validateRestrictions']>
  174. ) => this.plugin.uppy.validateRestrictions(...args),
  175. }
  176. if (isInputMode) {
  177. return (
  178. <CloseWrapper onUnmount={this.resetPluginState}>
  179. <div className="uppy-SearchProvider">
  180. <SearchFilterInput
  181. search={this.search}
  182. inputLabel={i18n('enterTextToSearch')}
  183. buttonLabel={i18n('searchImages')}
  184. inputClassName="uppy-c-textInput uppy-SearchProvider-input"
  185. buttonCSSClassName="uppy-SearchProvider-searchButton"
  186. showButton
  187. />
  188. </div>
  189. </CloseWrapper>
  190. )
  191. }
  192. return (
  193. <CloseWrapper onUnmount={this.resetPluginState}>
  194. {/* eslint-disable-next-line react/jsx-props-no-spreading */}
  195. <Browser {...browserProps} />
  196. </CloseWrapper>
  197. )
  198. }
  199. }