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/SnippetsTable/SnippetsListTable.tsx +136 -306 4.0.0-beta.1 → trunk View file →
@@ -1,166 +1,24 @@
1 -import { __, _n, _x, sprintf } from '@wordpress/i18n'
2 -import React, { Fragment, useEffect, useMemo, useState } from 'react'
1 +import { __ } from '@wordpress/i18n'
2 +import React, { useEffect, useMemo, useState } from 'react'
3 3 import classnames from 'classnames'
4 -import { createInterpolateElement } from '@wordpress/element'
5 -import { useRestAPI } from '../../../hooks/useRestAPI'
6 -import { useSnippetsAPI } from '../../../hooks/useSnippetsAPI'
7 -import { useSnippetsList } from '../../../hooks/useSnippetsList'
8 -import { handleUnknownError } from '../../../utils/errors'
9 -import { downloadBulkSnippetExportFile } from '../../../utils/files'
10 -import { REST_BASES } from '../../../utils/restAPI'
11 -import { cloneSnippetObject, getSnippetType } from '../../../utils/snippets/snippets'
4 +import { getSnippetType, isSnippetActive } from '../../../utils/snippets/snippets'
12 5 import { buildUrl } from '../../../utils/urls'
13 6 import { ListTable } from '../../common/ListTable'
14 -import { SubmitButton } from '../../common/SubmitButton'
7 +import { SnippetViewToggle } from '../../common/SnippetViewToggle'
8 +import { SnippetsCardGrid } from './SnippetsCardGrid'
9 +import { SnippetsTableNavigation, SnippetsTableToolbar } from './SnippetsTableControls'
10 +import { SearchResultsIndicator } from './SnippetsTableSearch'
11 +import { getTableColumns } from './TableColumns'
12 +import { useFilteredSnippets } from './WithFilteredSnippetsContext'
15 13 import { INDEX_STATUS, useSnippetsFilters } from './WithSnippetsTableFilters'
16 -import { useFilteredSnippets } from './WithFilteredSnippetsContext'
17 -import { getTableColumns } from './TableColumns'
18 -import type { ListTableAction } from '../../common/ListTable'
19 -import type { SnippetStatus } from './WithSnippetsTableFilters'
14 +import { BULK_ACTIONS, TRASHED_BULK_ACTIONS, useApplyBulkAction } from './useApplyBulkAction'
15 +import type { ListTableAction, ListTableSortDirection } from '../../common/ListTable'
16 +import type { SnippetsTableAction } from './useApplyBulkAction'
20 17 import type { Snippet } from '../../../types/Snippet'
18 +import type { SnippetView } from '../../../types/SnippetView'
19 +import type { ReactNode } from 'react'
21 20
22 -const STATUS_LABELS: [SnippetStatus, string][] = [
23 - ['all', __('All', 'code-snippets')],
24 - ['active', __('Active', 'code-snippets')],
25 - ['inactive', __('Inactive', 'code-snippets')],
26 - ['recently_active', __('Recently Active', 'code-snippets')],
27 - ['locked', __('Locked', 'code-snippets')],
28 - ['unlocked', __('Unlocked', 'code-snippets')],
29 - ['trashed', __('Trashed', 'code-snippets')]
30 -]
31 -
32 -type SnippetsTableAction =
33 - 'activate' | 'deactivate' |
34 - 'clone' | 'export' | 'download' |
35 - 'trash' | 'restore' | 'delete'
36 -
37 -const BULK_ACTIONS: ListTableAction<SnippetsTableAction>[] = [
38 - { key: 'activate', label: __('Activate', 'code-snippets') },
39 - { key: 'deactivate', label: __('Deactivate', 'code-snippets') },
40 - { key: 'clone', label: __('Clone', 'code-snippets') },
41 - { key: 'export', label: __('Export', 'code-snippets') },
42 - { key: 'download', label: __('Download', 'code-snippets') },
43 - { key: 'trash', label: __('Trash', 'code-snippets') }
44 -]
45 -
46 -const TRASHED_BULK_ACTIONS: ListTableAction<SnippetsTableAction>[] = [
47 - { key: 'restore', label: __('Restore', 'code-snippets') },
48 - { key: 'delete', label: __('Delete Permanently', 'code-snippets') }
49 -]
50 -
51 -const BULK_DOWNLOAD_ACTION = 'bulk-download'
52 -const INDIVIDUAL_DOWNLOAD_DELAY_MS = 200
53 -
54 -const submitBulkSnippetDownload = (snippets: readonly Snippet[]): Promise<void> => {
55 - if (0 === snippets.length) {
56 - return Promise.resolve()
57 - }
58 -
59 - const form = document.createElement('form')
60 -
61 - const appendHiddenField = (name: string, value: string) => {
62 - const input = document.createElement('input')
63 - input.type = 'hidden'
64 - input.name = name
65 - input.value = value
66 - form.appendChild(input)
67 - }
68 -
69 - form.method = 'post'
70 - form.action = window.location.href
71 - form.hidden = true
72 -
73 - appendHiddenField('code_snippets_action', BULK_DOWNLOAD_ACTION)
74 - appendHiddenField('code_snippets_bulk_download_nonce', window.CODE_SNIPPETS_MANAGE?.bulkDownloadNonce ?? '')
75 - appendHiddenField(
76 - 'snippets',
77 - JSON.stringify(snippets.map(({ id, network }) => ({ id, network })))
78 - )
79 -
80 - document.body.appendChild(form)
81 - form.submit()
82 -
83 - window.setTimeout(() => {
84 - form.remove()
85 - }, 0)
86 -
87 - return Promise.resolve()
88 -}
89 -
90 -const submitBulkSnippetDownloadsIndividually = (snippets: readonly Snippet[]): Promise<void> =>
91 - snippets.reduce(
92 - (promise, snippet) =>
93 - promise.then(
94 - () =>
95 - new Promise<void>(resolve => {
96 - void submitBulkSnippetDownload([snippet])
97 - window.setTimeout(resolve, INDIVIDUAL_DOWNLOAD_DELAY_MS)
98 - })
99 - ),
100 - Promise.resolve()
101 - )
102 -
103 -const SnippetStatusCounts = () => {
104 - const { currentStatus, setCurrentStatus } = useSnippetsFilters()
105 - const { snippetsByStatus } = useFilteredSnippets()
106 -
107 - const visibleStatuses = STATUS_LABELS.filter(([status]) =>
108 - snippetsByStatus.has(status) && ('unlocked' !== status || snippetsByStatus.has('locked')))
109 -
110 - return (
111 - <ul className="subsubsub">
112 - {visibleStatuses.map(([status, label], index) =>
113 - <Fragment key={status}>
114 - <li className={status}>
115 - <a
116 - href={buildUrl(window.location.href, { status: INDEX_STATUS === status ? undefined : status })}
117 - className={currentStatus === status ? 'current' : undefined}
118 - aria-current={currentStatus === status ? 'page' : undefined}
119 - onClick={event => {
120 - event.preventDefault()
121 - setCurrentStatus(status)
122 - }}
123 - >
124 - {`${label} `}
125 - <span className="count">{
126 - // translators: %d: number of snippets in the current view.
127 - sprintf(_x('(%d)', 'table view count', 'code-snippets'), snippetsByStatus.get(status)?.length ?? 0)
128 - }</span>
129 - </a>
130 - {index < visibleStatuses.length - 1 && ' | '}
131 - </li>
132 - </Fragment>)}
133 - </ul>
134 - )
135 -}
136 -
137 -const ClearRecentlyActiveButton: React.FC = () => {
138 - const { api } = useRestAPI()
139 - const { refreshSnippetsList } = useSnippetsList()
140 - const { currentStatus } = useSnippetsFilters()
141 -
142 - return 'recently_active' === currentStatus
143 - ? <div className="alignleft actions">
144 - <SubmitButton
145 - secondary
146 - name="clear-recent-list"
147 - text={__('Clear List', 'code-snippets')}
148 - onClick={event => {
149 - event.preventDefault()
150 - api.del(REST_BASES.recentlyActive)
151 - .then(refreshSnippetsList)
152 - .catch(handleUnknownError)
153 - }}
154 - />
155 - </div>
156 - : null
157 -}
158 -
159 -interface ExtraTableNavProps {
160 - visibleSnippets: Snippet[]
161 -}
162 -
163 21 interface ManageTableSettings {
164 22 hiddenColumns: Set<string>
165 23 truncateRowValues: boolean
166 24 }
@@ -165,9 +23,11 @@
165 23 truncateRowValues: boolean
166 24 }
167 25
168 26 const useManageTableSettings = (): ManageTableSettings => {
169 - const [hiddenColumns, setHiddenColumns] = useState(() => new Set(window.CODE_SNIPPETS_MANAGE?.hiddenColumns ?? []))
27 + const [hiddenColumns, setHiddenColumns] = useState(
28 + () => new Set(window.CODE_SNIPPETS_MANAGE?.hiddenColumns ?? [])
29 + )
170 30 const [truncateRowValues, setTruncateRowValues] = useState(
171 31 () => 0 !== Number(window.CODE_SNIPPETS_MANAGE?.truncateRowValues ?? 1)
172 32 )
173 33
@@ -179,14 +39,18 @@
179 39 }
180 40
181 41 const updateHiddenColumns = () => {
182 42 setHiddenColumns(
183 - new Set(Array.from(screenOptions.querySelectorAll<HTMLInputElement>('.hide-column-tog:not(:checked)'))
43 + new Set(Array.from(screenOptions.querySelectorAll<HTMLInputElement>(
44 + '.hide-column-tog:not(:checked)'
45 + ))
184 46 .map(toggle => toggle.value))
185 47 )
186 48
187 49 setTruncateRowValues(
188 - screenOptions.querySelector<HTMLInputElement>('#snippets-table-truncate-row-values')?.checked ?? true
50 + screenOptions.querySelector<HTMLInputElement>(
51 + '#snippets-table-truncate-row-values'
52 + )?.checked ?? true
189 53 )
190 54 }
191 55
192 56 updateHiddenColumns()
@@ -199,60 +63,13 @@
199 63
200 64 return { hiddenColumns, truncateRowValues }
201 65 }
202 66
203 -const FilterByTagControl: React.FC<ExtraTableNavProps> = ({ visibleSnippets }) => {
204 - const { currentTag, setCurrentTag } = useSnippetsFilters()
205 -
206 - const tagsList: Set<string> = useMemo(
207 - () => visibleSnippets.reduce((tags, snippet) => {
208 - snippet.tags.forEach(tag => tags.add(tag))
209 - return tags
210 - }, new Set<string>()),
211 - [visibleSnippets])
212 -
213 - return 0 < tagsList.size
214 - ? <div className="alignleft actions">
215 - <label htmlFor="snippets-tag-filter" className="screen-reader-text">
216 - {__('Filter snippets by tag', 'code-snippets')}
217 - </label>
218 - <select
219 - id="snippets-tag-filter"
220 - name="tag"
221 - value={currentTag}
222 - aria-label={__('Filter snippets by tag', 'code-snippets')}
223 - onChange={event => setCurrentTag(event.target.value)}
224 - >
225 - <option value="">{__('Show all tags', 'code-snippets')}</option>
226 - {[...tagsList].map(tag =>
227 - <option key={tag} value={tag}>{tag}</option>)}
228 - </select>
229 - </div>
230 - : null
231 -}
232 -
233 -const SearchBox = () => {
234 - const { searchQuery, setSearchQuery } = useSnippetsFilters()
235 -
236 - return (
237 - <search aria-label={__('Search Snippets', 'code-snippets')}>
238 - <p className="search-box">
239 - <input
240 - type="search"
241 - id="snippets_search"
242 - name="s"
243 - value={searchQuery ?? ''}
244 - aria-label={__('Search Snippets:', 'code-snippets')}
245 - onChange={event => setSearchQuery(event.target.value)}
246 - placeholder={__('Search snippets', 'code-snippets')}
247 - />
248 - </p>
249 - </search>
250 - )
251 -}
252 -
253 67 const NoItemsMessage = () => {
254 68 const { currentType, currentTag, searchQuery } = useSnippetsFilters()
69 + const emptyMessage = currentType
70 + ? __("You don't have snippets of this type yet.", 'code-snippets')
71 + : __("You don't have any snippets yet.", 'code-snippets')
255 72
256 73 return searchQuery || currentTag
257 74 ? <>
258 75 {__('No snippets were found matching the current search query.', 'code-snippets')}
@@ -257,87 +74,115 @@
257 74 ? <>
258 75 {__('No snippets were found matching the current search query.', 'code-snippets')}
259 76 {__(' Please enter a new query or use the "Clear Filters" button above.', 'code-snippets')}
260 77 </>
261 - : <>{createInterpolateElement(
262 - currentType
263 - ? __("It looks like you don't have any snippets of this type. <a>Perhaps you would like to add a new one?</a>", 'code-snippets')
264 - : __("It looks like you don't have any snippets. <a>Perhaps you would like to add a new one?</a>", 'code-snippets'),
265 - {
266 - // eslint-disable-next-line jsx-a11y/anchor-has-content, jsx-a11y/control-has-associated-label
267 - a: <a href={buildUrl(window.CODE_SNIPPETS?.urls.addNew, { type: currentType })} />
268 - }
269 - )}
78 + : <>
79 + {emptyMessage}{' '}
80 + <a href={buildUrl(window.CODE_SNIPPETS?.urls.addNew, { type: currentType })}>
81 + {__('Add a new snippet.', 'code-snippets')}
82 + </a>
270 83 </>
271 84 }
272 85
273 -const applyAndRefresh = async (
274 - targets: Snippet[],
275 - action: (snippet: Snippet) => Promise<Snippet> | Promise<void>,
276 - refresh: () => Promise<void>
277 -): Promise<void> => {
278 - if (0 < targets.length) {
279 - for (const snippet of targets) {
280 - await action(snippet).catch(handleUnknownError)
86 +const getRowClassName = (
87 + snippet: Snippet,
88 + activeByCondition: Map<Snippet['id'], Snippet[]>
89 +): string =>
90 + classnames(
91 + 'snippet',
92 + `snippet ${isSnippetActive(snippet, activeByCondition) ? 'active' : 'inactive'}-snippet`,
93 + `${getSnippetType(snippet)}-snippet`,
94 + `${snippet.scope}-snippet`,
95 + {
96 + 'trashed-snippet': snippet.trashed
281 97 }
98 + )
282 99
283 - await refresh()
284 - }
100 +interface SnippetsViewProps {
101 + snippetView: SnippetView
102 + setSnippetView: (view: SnippetView) => void
103 + snippets: Snippet[]
104 + actions: ListTableAction<SnippetsTableAction>[]
105 + doAction: (action: SnippetsTableAction | undefined, selected: Set<Snippet['id']>) => Promise<void>
106 + extraTableNav: (which: 'top' | 'bottom') => ReactNode
107 + hiddenColumns: Set<string>
108 + truncateRowValues: boolean
285 109 }
286 110
287 -const useApplyBulkAction = (
288 - allSnippets: Snippet[]
289 -): (action: SnippetsTableAction, selected: Set<Snippet['id']>) => Promise<void> => {
290 - const api = useSnippetsAPI()
291 - const { refreshSnippetsList } = useSnippetsList()
111 +const SnippetsView: React.FC<SnippetsViewProps> = ({
112 + snippetView,
113 + setSnippetView,
114 + snippets,
115 + actions,
116 + doAction,
117 + extraTableNav,
118 + hiddenColumns,
119 + truncateRowValues
120 +}) => {
121 + const { activeByCondition } = useFilteredSnippets()
122 + const columns = useMemo(() => getTableColumns(hiddenColumns), [hiddenColumns])
123 + const itemsPerPage = window.CODE_SNIPPETS_MANAGE?.snippetsPerPage
124 + const pageCount = itemsPerPage && Math.ceil(snippets.length / itemsPerPage)
125 + const endTableNav = (which: 'top' | 'bottom') =>
126 + 'top' === which
127 + ? <SnippetViewToggle snippetView={snippetView} setSnippetView={setSnippetView} />
128 + : null
292 129
293 - return async (action, selected) => {
294 - switch (action) {
295 - case 'activate':
296 - await applyAndRefresh(
297 - allSnippets.filter(snippet => selected.has(snippet.id) && !snippet.active),
298 - snippet => api.activate({ id: snippet.id, network: snippet.network }),
299 - refreshSnippetsList)
300 - break
130 + return 'card' === snippetView
131 + ? <SnippetsCardGrid
132 + snippets={snippets}
133 + actions={actions}
134 + doAction={doAction}
135 + itemsPerPage={itemsPerPage}
136 + extraTableNav={extraTableNav}
137 + endTableNav={endTableNav}
138 + noItems={<NoItemsMessage />}
139 + beforeGrid={<SearchResultsIndicator />}
140 + />
141 + : <ListTable
142 + items={snippets}
143 + getKey={snippet => snippet.id}
144 + initialSort={getInitialSort()}
145 + className={classnames({ 'truncate-row-values': truncateRowValues })}
146 + columns={columns}
147 + actions={actions}
148 + doAction={doAction}
149 + totalPages={pageCount}
150 + extraTableNav={extraTableNav}
151 + selectAllControl
152 + endTableNav={endTableNav}
153 + rowClassName={snippet => getRowClassName(snippet, activeByCondition)}
154 + noItems={<NoItemsMessage />}
155 + beforeTable={<SearchResultsIndicator />}
156 + />
157 +}
301 158
302 - case 'deactivate':
303 - await applyAndRefresh(
304 - allSnippets.filter(snippet => selected.has(snippet.id) && snippet.active),
305 - snippet => api.deactivate({ id: snippet.id, network: snippet.network }),
306 - refreshSnippetsList)
307 - break
159 +/**
160 + * The "Snippets List Order" setting names a default ordering for the table,
161 + * which sorting is now expressed through columns. Map one to the other so the
162 + * setting still decides how the list opens; clicking a heading overrides it for
163 + * the rest of the visit, as it does for any other starting order.
164 + */
165 +const LIST_ORDER_SORTS: Record<string, { columnId: string, direction: ListTableSortDirection }> = {
166 + 'priority-asc': { columnId: 'priority', direction: 'asc' },
167 + 'name-asc': { columnId: 'name', direction: 'asc' },
168 + 'name-desc': { columnId: 'name', direction: 'desc' },
169 + 'modified-desc': { columnId: 'date', direction: 'desc' },
170 + 'modified-asc': { columnId: 'date', direction: 'asc' }
171 +}
308 172
309 - case 'clone':
310 - await applyAndRefresh(
311 - allSnippets.filter(snippet => selected.has(snippet.id) && !snippet.trashed),
312 - snippet => api.create(cloneSnippetObject(snippet)),
313 - refreshSnippetsList)
314 - break
173 +const getInitialSort = () =>
174 + LIST_ORDER_SORTS[window.CODE_SNIPPETS_MANAGE?.listOrder ?? ''] ?? undefined
315 175
316 - case 'export':
317 - downloadBulkSnippetExportFile(allSnippets.filter(snippet => selected.has(snippet.id)))
318 - break
319 -
320 - case 'download': {
321 - const selectedSnippets = allSnippets.filter(snippet => selected.has(snippet.id))
322 -
323 - return 1 < selectedSnippets.length && !window.CODE_SNIPPETS_MANAGE?.supportsZipDownloads
324 - ? submitBulkSnippetDownloadsIndividually(selectedSnippets)
325 - : submitBulkSnippetDownload(selectedSnippets)
326 - }
327 -
328 - case 'trash':
329 - case 'delete':
330 - await applyAndRefresh(
331 - allSnippets.filter(snippet => selected.has(snippet.id) && snippet.trashed),
332 - snippet => api.delete({ id: snippet.id, network: snippet.network }),
333 - refreshSnippetsList)
334 - break
335 - }
336 - }
176 +export interface SnippetsListTableProps {
177 + snippetView: SnippetView
178 + setSnippetView: (view: SnippetView) => void
337 179 }
338 180
339 -export const SnippetsListTable: React.FC = () => {
181 +export const SnippetsListTable: React.FC<SnippetsListTableProps> = ({
182 + snippetView,
183 + setSnippetView
184 +}) => {
340 185 const { snippetsByStatus } = useFilteredSnippets()
341 186 const { currentStatus, setCurrentStatus } = useSnippetsFilters()
342 187 const { hiddenColumns, truncateRowValues } = useManageTableSettings()
343 188
@@ -344,12 +189,8 @@
344 189 const currentSnippets = useMemo(
345 190 () => snippetsByStatus.get(currentStatus) ?? [],
346 191 [snippetsByStatus, currentStatus]
347 192 )
348 - const totalItems = currentSnippets.length
349 - const itemsPerPage = window.CODE_SNIPPETS_MANAGE?.snippetsPerPage
350 -
351 - const columns = useMemo(() => getTableColumns(hiddenColumns), [hiddenColumns])
352 193 const applyBulkAction = useApplyBulkAction(currentSnippets)
353 194
354 195 useEffect(() => {
355 196 if (INDEX_STATUS !== currentStatus && !snippetsByStatus.has(currentStatus)) {
@@ -356,37 +197,26 @@
356 197 setCurrentStatus(INDEX_STATUS)
357 198 }
358 199 }, [currentStatus, setCurrentStatus, snippetsByStatus])
359 200
201 + const extraTableNav = (which: 'top' | 'bottom') =>
202 + <SnippetsTableNavigation which={which} visibleSnippets={snippetsByStatus.get('all') ?? []} />
203 +
360 204 return (
361 205 <>
362 - <SnippetStatusCounts />
363 - <SearchBox />
206 + <SnippetsTableToolbar />
364 207
365 - <p className="screen-reader-text" role="status" aria-live="polite">
366 - {sprintf(
367 - // translators: %d: number of snippets matching current filters.
368 - _n('%d snippet found.', '%d snippets found.', totalItems),
369 - totalItems
370 - )}
371 - </p>
372 -
373 - <ListTable
374 - items={currentSnippets}
375 - getKey={snippet => snippet.id}
376 - className={classnames({ 'truncate-row-values': truncateRowValues })}
377 - columns={columns}
378 - actions={'trashed' === currentStatus ? TRASHED_BULK_ACTIONS : BULK_ACTIONS}
379 - doAction={applyBulkAction}
380 - totalPages={itemsPerPage && Math.ceil(totalItems / itemsPerPage)}
381 - extraTableNav={which =>
382 - <>
383 - {'top' === which && <FilterByTagControl visibleSnippets={snippetsByStatus.get('all') ?? []} />}
384 - <ClearRecentlyActiveButton />
385 - </>}
386 - rowClassName={snippet =>
387 - `snippet ${snippet.active ? 'active' : 'inactive'}-snippet ${getSnippetType(snippet)}-snippet ${snippet.scope}-snippet`}
388 - noItems={<NoItemsMessage />}
389 - />
208 + <div className="snippets-list-view">
209 + <SnippetsView
210 + snippetView={snippetView}
211 + setSnippetView={setSnippetView}
212 + snippets={currentSnippets}
213 + actions={'trashed' === currentStatus ? TRASHED_BULK_ACTIONS : BULK_ACTIONS}
214 + doAction={applyBulkAction}
215 + extraTableNav={extraTableNav}
216 + hiddenColumns={hiddenColumns}
217 + truncateRowValues={truncateRowValues}
218 + />
219 + </div>
390 220 </>
391 221 )
392 222 }