PluginProbe
Code Snippets / trunk
Code Snippets vtrunk
4.0.0-beta.2 3.10.2 3.10.1 3.10.0 3.10.0-beta.2 3.10.0-beta.1 4.0.0-beta.1 3.9.6 trunk 2.10.0 2.10.1 2.12.0 2.12.1 2.13.0 2.13.1 2.13.2 2.13.3 2.14.0 2.14.1 2.14.2 2.14.3 2.14.4 2.14.5 2.14.6 3.0.0 All 65 releases
← All changes | js/components/ManageMenu/CommunityCloud/CloudSearch.tsx +202 -52 4.0.0-beta.1 → trunk View file →
@@ -1,35 +1,48 @@
1 1 import { __ } from '@wordpress/i18n'
2 -import React from 'react'
2 +import React, { useEffect, useId, useState } from 'react'
3 +import classnames from 'classnames'
3 4 import { Spinner } from '@wordpress/components'
4 -import { TablePagination } from '../../common/ListTable/TablePagination'
5 -import { SearchResult } from './SearchResult'
5 +import { useRestAPI } from '../../../hooks/useRestAPI'
6 +import { REST_BASES } from '../../../utils/restAPI'
7 +import { isCloudSnippetDownloadable } from '../../../utils/snippets/snippets'
8 +import { TableNav } from '../../common/ListTable/TableNavigation'
9 +import { LoadingStatusNotices } from '../../common/LoadingStatusNotices'
10 +import { SnippetViewToggle } from '../../common/SnippetViewToggle'
11 +import { CloudSnippetAuthor, CloudSnippetCard } from '../../common/cloud/CloudSnippetCard'
12 +import { CloudSnippetsTable } from './CloudSnippetsTable'
6 13 import { useCloudSearch } from './WithCloudSearchContext'
7 14 import { SearchFilters } from './SearchFilters'
8 -import type { TablePaginationProps } from '../../common/ListTable/TablePagination'
9 -import type { FormEventHandler } from 'react'
15 +import type { CloudSearchResults } from './WithCloudSearchContext'
16 +import type { TableNavProps } from '../../common/ListTable/TableNavigation'
17 +import type { CloudSnippetSchema } from '../../../types/schema/CloudSnippetSchema'
18 +import type { SnippetView } from '../../../types/SnippetView'
19 +import type { ListTableAction } from '../../common/ListTable'
20 +import type { Dispatch, FormEventHandler, SetStateAction } from 'react'
10 21
11 22 const SearchBox = () => {
12 23 const { searchParams, updateSearchParams, isSearching, doSearch } = useCloudSearch()
24 + const searchMethodId = useId()
25 + const [query, setQuery] = useState(searchParams.query)
13 26
14 27 const handleSubmit: FormEventHandler<HTMLFormElement> = event => {
15 28 event.preventDefault()
16 - doSearch()
29 + doSearch({ query })
17 30 }
18 31
19 32 return (
20 33 <form className="cloud-search-form" onSubmit={handleSubmit}>
21 - <label className="screen-reader-text" htmlFor="cloud-search-method">
34 + <label htmlFor={searchMethodId} className="screen-reader-text">
22 35 {__('Search method', 'code-snippets')}
23 36 </label>
24 37 <select
25 - id="cloud-search-method"
38 + id={searchMethodId}
26 39 value={searchParams.method}
27 40 onChange={event =>
28 41 updateSearchParams({ method: 'codevault' === event.target.value ? 'codevault' : 'term' })}
29 42 >
30 43 <option value="term">{__('Search by keyword', 'code-snippets')}</option>
31 - <option value="codevault">{__('Name of codevault', 'code-snippets')}</option>
44 + <option value="codevault">{__('Name of library', 'code-snippets')}</option>
32 45 </select>
33 46
34 47 <div className="cloud-search-query">
35 48 <input
@@ -34,17 +47,17 @@
34 47 <div className="cloud-search-query">
35 48 <input
36 49 id="cloud-search-query"
37 50 type="search"
38 - value={searchParams.query}
51 + value={query}
39 52 aria-label={__('Search query', 'code-snippets')}
40 - onChange={event => updateSearchParams({ query: event.target.value })}
53 + onChange={event => setQuery(event.target.value)}
41 54 placeholder={__('e.g. Remove unused JavaScript…', 'code-snippets')}
42 55 />
43 - <span role="status" aria-live="polite">
44 - {isSearching &&
45 - <span className="screen-reader-text">{__('Searching…', 'code-snippets')}</span>}
46 - </span>
56 + {isSearching && (
57 + <span role="status" aria-live="polite">
58 + <span className="screen-reader-text">{__('Searching…', 'code-snippets')}</span>
59 + </span>)}
47 60 </div>
48 61
49 62 <button
50 63 type="submit"
@@ -56,56 +69,172 @@
56 69 </form>
57 70 )
58 71 }
59 72
60 -const SearchResultsTable = () => {
73 +interface SearchResultsGridProps {
74 + snippets: CloudSnippetSchema[]
75 + selected: Set<CloudSnippetSchema['id']>
76 + setSelected: Dispatch<SetStateAction<Set<CloudSnippetSchema['id']>>>
77 +}
78 +
79 +const SearchResultsGrid: React.FC<SearchResultsGridProps> = ({ snippets, selected, setSelected }) => {
80 + const { doSearch } = useCloudSearch()
81 +
82 + return (
83 + <ul
84 + className={classnames('cloud-search-results', 'code-snippets-cards', {
85 + 'has-selection': snippets.some(snippet => selected.has(snippet.id))
86 + })}
87 + >
88 + {snippets.map(result =>
89 + <CloudSnippetCard
90 + key={result.id}
91 + snippet={result}
92 + author={<CloudSnippetAuthor codevaultSlug={result.codevault} />}
93 + isSelected={selected.has(result.id)}
94 + onSnippetDownloaded={() => doSearch()}
95 + onSelectedChange={isCloudSnippetDownloadable(result)
96 + ? isSelected => {
97 + setSelected(previous => {
98 + const updated = new Set(previous)
99 +
100 + if (isSelected) {
101 + updated.add(result.id)
102 + } else {
103 + updated.delete(result.id)
104 + }
105 +
106 + return updated
107 + })
108 + }
109 + : undefined}
110 + />)}
111 + </ul>
112 + )
113 +}
114 +
115 +interface SearchResultsViewProps {
116 + snippetView: SnippetView
117 + setSnippetView: (view: SnippetView) => void
118 +}
119 +
120 +type CloudSearchAction = 'download'
121 +
122 +const CLOUD_BULK_ACTIONS: ListTableAction<CloudSearchAction>[] = [
123 + { key: 'download', label: __('Download', 'code-snippets') }
124 +]
125 +
126 +interface CloudSearchSnippetsProps {
127 + snippetView: SnippetView
128 + snippets: CloudSnippetSchema[]
129 + selected: Set<CloudSnippetSchema['id']>
130 + setSelected: Dispatch<SetStateAction<Set<CloudSnippetSchema['id']>>>
131 +}
132 +
133 +const CloudSearchSnippets: React.FC<CloudSearchSnippetsProps> = ({ snippetView, ...props }) =>
134 + 'card' === snippetView
135 + ? <SearchResultsGrid {...props} />
136 + : <CloudSnippetsTable {...props} />
137 +
138 +interface SearchResultsTableNavProps extends Omit<TableNavProps<CloudSnippetSchema['id'], CloudSearchAction>,
139 + 'totalItems' | 'totalPages' | 'currentPage' | 'setCurrentPage'> {
140 + which: 'top' | 'bottom'
141 + doSearch: ReturnType<typeof useCloudSearch>['doSearch']
142 + isSearching: boolean
143 + searchResults: CloudSearchResults
144 +}
145 +
146 +const SearchResultsTableNav: React.FC<SearchResultsTableNavProps> = ({
147 + which,
148 + doSearch,
149 + isSearching,
150 + searchResults,
151 + ...props
152 +}) =>
153 + <TableNav
154 + which={which}
155 + disabled={isSearching}
156 + totalItems={searchResults.totalItems}
157 + totalPages={searchResults.totalPages}
158 + currentPage={searchResults.page}
159 + setCurrentPage={page => doSearch({ page })}
160 + {...props}
161 + />
162 +
163 +/**
164 + * Bulk selection for the current page of results. A new set of results replaces
165 + * the rows the selection referred to, so nothing carries over: a stale
166 + * selection would let a bulk action act on snippets no longer on screen.
167 + */
168 +const useResultsSelection = (snippets: CloudSnippetSchema[] | undefined) => {
169 + const [selected, setSelected] = useState<Set<CloudSnippetSchema['id']>>(new Set())
170 +
171 + useEffect(() => {
172 + setSelected(new Set())
173 + }, [snippets])
174 +
175 + return { selected, setSelected }
176 +}
177 +
178 +const SearchResultsTable: React.FC<SearchResultsViewProps> = ({ snippetView, setSnippetView }) => {
179 + const { api } = useRestAPI()
61 180 const { searchResults, isSearching, doSearch } = useCloudSearch()
181 + const { selected, setSelected } = useResultsSelection(searchResults?.snippets)
62 182
63 183 if (!searchResults) {
64 184 return null
65 185 }
66 186
67 - const { totalItems, totalPages, page } = searchResults
187 + const doAction = async (
188 + action: CloudSearchAction | undefined,
189 + selectedIds: Set<CloudSnippetSchema['id']>
190 + ): Promise<void> => {
191 + if ('download' === action) {
192 + await Promise.all(searchResults.snippets
193 + .filter(snippet => selectedIds.has(snippet.id) && isCloudSnippetDownloadable(snippet))
194 + .map(({ id }) => api.post(`${REST_BASES.cloud.snippets}/${id}/download`)))
68 195
69 - const paginationProps: Omit<TablePaginationProps, 'which'> = {
70 - totalItems,
71 - totalPages,
72 - disabled: isSearching,
73 - currentPage: page,
74 - setCurrentPage: newPage => doSearch({ page: newPage })
196 + doSearch()
197 + }
75 198 }
76 199
77 200 return (
78 - <>
79 - <div className="tablenav top">
80 - <SearchFilters />
201 + <div className="snippets-list-view">
202 + <SearchResultsTableNav
203 + which="top"
204 + {...{ isSearching, searchResults, selected, setSelected, doSearch }}
205 + actions={CLOUD_BULK_ACTIONS}
206 + doAction={doAction}
207 + selectAllKeys={'card' === snippetView
208 + ? searchResults.snippets
209 + .filter(snippet => isCloudSnippetDownloadable(snippet))
210 + .map(snippet => snippet.id)
211 + : undefined}
212 + extraTableNav={() => <SearchFilters />}
213 + endTableNav={which =>
214 + 'top' === which
215 + ? <SnippetViewToggle snippetView={snippetView} setSnippetView={setSnippetView} />
216 + : null}
217 + />
81 218
82 - <TablePagination which="top" {...paginationProps} />
83 - </div>
219 + <CloudSearchSnippets
220 + snippets={searchResults.snippets}
221 + snippetView={snippetView}
222 + selected={selected}
223 + setSelected={setSelected}
224 + />
84 225
85 - <ul className="cloud-search-results">
86 - {searchResults.snippets.map(result =>
87 - <SearchResult key={result.id} snippet={result} />)}
88 - </ul>
89 -
90 - <div className="tablenav bottom">
91 - <TablePagination which="bottom" {...paginationProps} />
92 - </div>
93 - </>
226 + <SearchResultsTableNav
227 + which="bottom"
228 + {...{ isSearching, searchResults, selected, setSelected, doSearch }}
229 + />
230 + </div>
94 231 )
95 232 }
96 233
97 -const SearchResults = () => {
98 - const { searchResults, searchParams, isErrored } = useCloudSearch()
234 +const SearchResults: React.FC<SearchResultsViewProps> = ({ snippetView, setSnippetView }) => {
235 + const { searchResults, searchParams } = useCloudSearch()
99 236
100 - if (isErrored) {
101 - return (
102 - <div className="banner banner-error">
103 - <p>{__('An error occurred while fetching search results. Please try again.', 'code-snippets')}</p>
104 - </div>
105 - )
106 - }
107 -
108 237 if (!searchResults) {
109 238 return null
110 239 }
111 240
@@ -122,16 +251,37 @@
122 251
123 252 return searchResults.snippets.length
124 253 ? <>
125 254 {searchResults.isFeatured
126 - ? <h3 className="cloud-featured-heading">{__('Featured Snippets', 'code-snippets')}</h3>
127 - : <h3 className="cloud-snippets-heading">{__('Search Results', 'code-snippets')}</h3>}
128 - <SearchResultsTable />
255 + ? <h2 className="cloud-featured-heading">{__('Featured Snippets', 'code-snippets')}</h2>
256 + : <h2 className="cloud-snippets-heading">{__('Search Results', 'code-snippets')}</h2>}
257 + <SearchResultsTable snippetView={snippetView} setSnippetView={setSnippetView} />
129 258 </>
130 259 : null
131 260 }
132 261
133 -export const CloudSearch = () =>
262 +const SearchStatus = () => {
263 + const { isErrored, isLoading } = useCloudSearch()
264 +
265 + return isErrored || isLoading
266 + ? <LoadingStatusNotices
267 + isLoading={isLoading}
268 + errorMessage={isErrored
269 + ? __('An error occurred while fetching search results. Please try again.', 'code-snippets')
270 + : undefined}
271 + loadingNotice={__('Loading snippets from cloud…', 'code-snippets')}
272 + noticeLabel={__('Community snippets status', 'code-snippets')}
273 + />
274 + : null
275 +}
276 +
277 +export interface CloudSearchProps {
278 + snippetView: SnippetView
279 + setSnippetView: (view: SnippetView) => void
280 +}
281 +
282 +export const CloudSearch: React.FC<CloudSearchProps> = ({ snippetView, setSnippetView }) =>
134 283 <div className="cloud-search">
135 284 <SearchBox />
136 - <SearchResults />
285 + <SearchStatus />
286 + <SearchResults snippetView={snippetView} setSnippetView={setSnippetView} />
137 287 </div>