PluginProbe
Code Snippets / 4.0.0-beta.1
Code Snippets v4.0.0-beta.1
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
code-snippets / js / components / ManageMenu / SnippetsTable / SnippetsListTable.tsx

SnippetsListTable.tsx in Code Snippets 4.0.0-beta.1, at js/components/ManageMenu/SnippetsTable/SnippetsListTable.tsx

393 lines 12.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 import { __, _n, _x, sprintf } from '@wordpress/i18n'
2 import React, { Fragment, useEffect, useMemo, useState } from 'react'
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'
12 import { buildUrl } from '../../../utils/urls'
13 import { ListTable } from '../../common/ListTable'
14 import { SubmitButton } from '../../common/SubmitButton'
15 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'
20 import type { Snippet } from '../../../types/Snippet'
21
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 interface ManageTableSettings {
164 hiddenColumns: Set<string>
165 truncateRowValues: boolean
166 }
167
168 const useManageTableSettings = (): ManageTableSettings => {
169 const [hiddenColumns, setHiddenColumns] = useState(() => new Set(window.CODE_SNIPPETS_MANAGE?.hiddenColumns ?? []))
170 const [truncateRowValues, setTruncateRowValues] = useState(
171 () => 0 !== Number(window.CODE_SNIPPETS_MANAGE?.truncateRowValues ?? 1)
172 )
173
174 useEffect(() => {
175 const screenOptions = document.getElementById('adv-settings')
176
177 if (!screenOptions) {
178 return
179 }
180
181 const updateHiddenColumns = () => {
182 setHiddenColumns(
183 new Set(Array.from(screenOptions.querySelectorAll<HTMLInputElement>('.hide-column-tog:not(:checked)'))
184 .map(toggle => toggle.value))
185 )
186
187 setTruncateRowValues(
188 screenOptions.querySelector<HTMLInputElement>('#snippets-table-truncate-row-values')?.checked ?? true
189 )
190 }
191
192 updateHiddenColumns()
193 screenOptions.addEventListener('change', updateHiddenColumns)
194
195 return () => {
196 screenOptions.removeEventListener('change', updateHiddenColumns)
197 }
198 }, [])
199
200 return { hiddenColumns, truncateRowValues }
201 }
202
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 const NoItemsMessage = () => {
254 const { currentType, currentTag, searchQuery } = useSnippetsFilters()
255
256 return searchQuery || currentTag
257 ? <>
258 {__('No snippets were found matching the current search query.', 'code-snippets')}
259 {__(' Please enter a new query or use the "Clear Filters" button above.', 'code-snippets')}
260 </>
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 )}
270 </>
271 }
272
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)
281 }
282
283 await refresh()
284 }
285 }
286
287 const useApplyBulkAction = (
288 allSnippets: Snippet[]
289 ): (action: SnippetsTableAction, selected: Set<Snippet['id']>) => Promise<void> => {
290 const api = useSnippetsAPI()
291 const { refreshSnippetsList } = useSnippetsList()
292
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
301
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
308
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
315
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 }
337 }
338
339 export const SnippetsListTable: React.FC = () => {
340 const { snippetsByStatus } = useFilteredSnippets()
341 const { currentStatus, setCurrentStatus } = useSnippetsFilters()
342 const { hiddenColumns, truncateRowValues } = useManageTableSettings()
343
344 const currentSnippets = useMemo(
345 () => snippetsByStatus.get(currentStatus) ?? [],
346 [snippetsByStatus, currentStatus]
347 )
348 const totalItems = currentSnippets.length
349 const itemsPerPage = window.CODE_SNIPPETS_MANAGE?.snippetsPerPage
350
351 const columns = useMemo(() => getTableColumns(hiddenColumns), [hiddenColumns])
352 const applyBulkAction = useApplyBulkAction(currentSnippets)
353
354 useEffect(() => {
355 if (INDEX_STATUS !== currentStatus && !snippetsByStatus.has(currentStatus)) {
356 setCurrentStatus(INDEX_STATUS)
357 }
358 }, [currentStatus, setCurrentStatus, snippetsByStatus])
359
360 return (
361 <>
362 <SnippetStatusCounts />
363 <SearchBox />
364
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 />
390 </>
391 )
392 }
393