Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions src/js/components/ManageMenu/SnippetsTable/ActionFeedback.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import React from 'react'
import { __ } from '@wordpress/i18n'
import { failureMessage, useActionFeedback } from '../../../hooks/useActionFeedback'
import { DismissibleNotice } from '../../common/Notice'

/**
* Show any snippet action that did not complete.
*
* Rendered above the table so a failure appears where the person is already
* looking, rather than only in the browser console.
*/
export const ActionFeedback: React.FC = () => {
const { failures, dismissFailure } = useActionFeedback()

if (0 === failures.length) {
return null
}

return (
<>
{failures.map(failure =>
<DismissibleNotice
key={failure.id}
type="error"
className="code-snippets-action-failure"
onDismiss={() => dismissFailure(failure.id)}
>
<p>
{failureMessage(failure)}{' '}
{__('Nothing has been changed.', 'code-snippets')}
</p>
</DismissibleNotice>)}
</>
)
}
18 changes: 13 additions & 5 deletions src/js/components/ManageMenu/SnippetsTable/ManageSnippetCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ import { RawHTML } from '@wordpress/element'
import { __, sprintf } from '@wordpress/i18n'
import React, { useState } from 'react'
import { ConfirmDeleteDialog, useDeleteSnippet } from '../../common/snippets/ConfirmDeleteDialog'
import { useActionFeedback } from '../../../hooks/useActionFeedback'
import { useSnippetsAPI } from '../../../hooks/useSnippetsAPI'
import { useSnippetsList } from '../../../hooks/useSnippetsList'
import { handleUnknownError } from '../../../utils/errors'
import { downloadSnippetExportFile } from '../../../utils/files'
import { canModifySnippet, cloneSnippetObject, getSnippetDisplayName, getSnippetEditUrl, getSnippetType, isNetworkOnlySnippet, isSnippetActive } from '../../../utils/snippets/snippets'
import { Button } from '../../common/Button'
Expand Down Expand Up @@ -39,14 +39,15 @@ const CardPreviewButton: React.FC<SnippetCardActionsProps> = ({ snippet }) => {
const CloneExportMenuItems: React.FC<SnippetCardActionsProps> = ({ snippet }) => {
const api = useSnippetsAPI()
const { refreshSnippetsList } = useSnippetsList()
const { reportFailure } = useActionFeedback()

return (
<>
<KebabMenuItem
onSelect={() => {
api.create(cloneSnippetObject(snippet))
.then(refreshSnippetsList)
.catch(handleUnknownError)
.catch((error: unknown) => reportFailure(__('clone this snippet', 'code-snippets'), error))
}}
>
{__('Clone', 'code-snippets')}
Expand All @@ -56,7 +57,7 @@ const CloneExportMenuItems: React.FC<SnippetCardActionsProps> = ({ snippet }) =>
onSelect={() => {
api.export(snippet)
.then(response => downloadSnippetExportFile(response, snippet))
.catch(handleUnknownError)
.catch((error: unknown) => reportFailure(__('export this snippet', 'code-snippets'), error))
}}
>
{__('Export', 'code-snippets')}
Expand All @@ -77,6 +78,7 @@ const RestoreDeleteMenuItems: React.FC<RestoreDeleteMenuItemsProps> = ({
}) => {
const api = useSnippetsAPI()
const { refreshSnippetsList } = useSnippetsList()
const { reportFailure } = useActionFeedback()

return (
<>
Expand All @@ -87,7 +89,7 @@ const RestoreDeleteMenuItems: React.FC<RestoreDeleteMenuItemsProps> = ({
onSelect={() => {
api.restore(snippet)
.then(refreshSnippetsList)
.catch(handleUnknownError)
.catch((error: unknown) => reportFailure(__('restore this snippet', 'code-snippets'), error))
}}
>
{__('Restore', 'code-snippets')}
Expand All @@ -103,11 +105,17 @@ const RestoreDeleteMenuItems: React.FC<RestoreDeleteMenuItemsProps> = ({

const CardActionsMenu: React.FC<SnippetCardActionsProps> = ({ snippet }) => {
const { refreshSnippetsList } = useSnippetsList()
const { reportFailure } = useActionFeedback()
const canModify = canModifySnippet(snippet)
const { requestDelete, deleteDialogProps } = useDeleteSnippet({
snippet,
onSuccess: refreshSnippetsList,
onError: handleUnknownError
onError: (error: unknown) => reportFailure(
snippet.trashed
? __('delete this snippet', 'code-snippets')
: __('move this snippet to the trash', 'code-snippets'),
error
)
})

return (
Expand Down
12 changes: 9 additions & 3 deletions src/js/components/ManageMenu/SnippetsTable/SnippetsTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,12 @@ import { isLicensed } from '../../../utils/screen'
import { SNIPPET_TYPE_LABELS, getSnippetAddNewUrl, getSnippetType, isProType } from '../../../utils/snippets/snippets'
import { buildUrl } from '../../../utils/urls'
import { Badge } from '../../common/Badge'
import { WithActionFeedbackContext } from '../../../hooks/useActionFeedback'
import { Notice } from '../../common/Notice'
import { ScreenMetaSlot } from '../../common/ScreenMetaSlot'
import { UpsellPage } from '../../common/UpsellDialog'
import { WithSnippetsTableFilters, useSnippetsFilters } from './WithSnippetsTableFilters'
import { ActionFeedback } from './ActionFeedback'
import { WithFilteredSnippetsContext } from './WithFilteredSnippetsContext'
import { SnippetsListTable } from './SnippetsListTable'
import type { SnippetType } from '../../../types/Snippet'
Expand Down Expand Up @@ -121,6 +123,8 @@ const SnippetsTableInner = () => {

return (
<>
<ActionFeedback />

<div
className={classnames('snippet-type-nav-wrapper', {
'has-scroll-start': !atStart,
Expand Down Expand Up @@ -169,9 +173,11 @@ export const SnippetsTable: React.FC = () =>
<WithRestAPIContext>
<WithSnippetsAPIContext>
<WithSnippetsListContext>
<WithSnippetsTableFilters>
<SnippetsTableInner />
</WithSnippetsTableFilters>
<WithActionFeedbackContext>
<WithSnippetsTableFilters>
<SnippetsTableInner />
</WithSnippetsTableFilters>
</WithActionFeedbackContext>
</WithSnippetsListContext>
</WithSnippetsAPIContext>
</WithRestAPIContext>
10 changes: 8 additions & 2 deletions src/js/components/ManageMenu/SnippetsTable/TableColumns.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ import { RawHTML } from '@wordpress/element'
import { __ } from '@wordpress/i18n'
import React, { Fragment } from 'react'
import { useSnippetsAPI } from '../../../hooks/useSnippetsAPI'
import { useActionFeedback } from '../../../hooks/useActionFeedback'
import { useSnippetsList } from '../../../hooks/useSnippetsList'
import { handleUnknownError } from '../../../utils/errors'
import { isNetworkAdmin } from '../../../utils/screen'
import { getSnippetDisplayName, getSnippetEditUrl, getSnippetType } from '../../../utils/snippets/snippets'
import { buildUrl } from '../../../utils/urls'
Expand Down Expand Up @@ -35,6 +35,7 @@ const RunOnceButton: React.FC<ColumnProps> = ({ snippet }) =>
const ActivationSwitch: React.FC<ColumnProps> = ({ snippet }) => {
const { activate, deactivate } = useSnippetsAPI()
const { refreshSnippetsList } = useSnippetsList()
const { reportFailure } = useActionFeedback()

const actionText = snippet.network && !snippet.shared_network
? snippet.active ? __('Network Deactivate', 'code-snippets') : __('Network Activate', 'code-snippets')
Expand All @@ -53,7 +54,12 @@ const ActivationSwitch: React.FC<ColumnProps> = ({ snippet }) => {
onChange={() => {
(snippet.active ? deactivate(snippet) : activate(snippet))
.then(refreshSnippetsList)
.catch(handleUnknownError)
.catch((error: unknown) => reportFailure(
snippet.active
? __('deactivate this snippet', 'code-snippets')
: __('activate this snippet', 'code-snippets'),
error
))
}}
/>
)
Expand Down
78 changes: 63 additions & 15 deletions src/js/components/ManageMenu/SnippetsTable/useApplyBulkAction.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { __ } from '@wordpress/i18n'
import { __, sprintf } from '@wordpress/i18n'
import { useSnippetsAPI } from '../../../hooks/useSnippetsAPI'
import { useActionFeedback } from '../../../hooks/useActionFeedback'
import { useSnippetsList } from '../../../hooks/useSnippetsList'
import { handleUnknownError } from '../../../utils/errors'
import { downloadBulkSnippetExportFile } from '../../../utils/files'
import { cloneSnippetObject } from '../../../utils/snippets/snippets'
import type { ListTableAction } from '../../common/ListTable'
Expand Down Expand Up @@ -81,64 +81,112 @@ const submitBulkSnippetDownloadsIndividually = (snippets: readonly Snippet[]): P
const applyAndRefresh = async (
targets: Snippet[],
action: (snippet: Snippet) => Promise<Snippet> | Promise<void>,
refresh: () => Promise<void>
refresh: () => Promise<void>,
onFailure: (failed: number, total: number, error: unknown) => void
): Promise<void> => {
if (0 < targets.length) {
let failed = 0
let firstError: unknown

// Every snippet is attempted even when one fails, so a single bad
// snippet does not silently halt the rest of the batch. Failures used
// to be discarded here, which is why a bulk action that did nothing
// looked exactly like one that had worked.
for (const snippet of targets) {
await action(snippet).catch(handleUnknownError)
try {
await action(snippet)
} catch (error: unknown) {
failed += 1
firstError ??= error
}
}

await refresh()

if (0 < failed) {
onFailure(failed, targets.length, firstError)
}
}
}

/**
* Build the failure reporter for one bulk action.
*
* The count is included because a batch can partly succeed, and "three of ten
* failed" is a very different situation to "nothing happened".
*/
const bulkFailureReporter = (
reportFailure: (action: string, error: unknown) => void,
label: string
) => (failed: number, total: number, error: unknown) =>
reportFailure(
sprintf(
/* translators: 1: what was being done, 2: number that failed, 3: number attempted. */
__('%1$s (%2$d of %3$d failed)', 'code-snippets'),
label,
failed,
total
),
error
)

/**
* Send the selected snippets to the browser as downloads.
*
* Falls back to one download per snippet where the server cannot build a zip.
*/
const submitBulkDownload = (selectedSnippets: Snippet[]): Promise<void> =>
1 < selectedSnippets.length && !window.CODE_SNIPPETS_MANAGE?.supportsZipDownloads
? submitBulkSnippetDownloadsIndividually(selectedSnippets)
: submitBulkSnippetDownload(selectedSnippets)

export const useApplyBulkAction = (
allSnippets: Snippet[]
): (action: SnippetsTableAction | undefined, selected: Set<Snippet['id']>) => Promise<void> => {
const api = useSnippetsAPI()
const { refreshSnippetsList } = useSnippetsList()
const { reportFailure } = useActionFeedback()

return async (action, selected) => {
switch (action) {
case 'activate':
await applyAndRefresh(
allSnippets.filter(snippet => selected.has(snippet.id) && !snippet.active),
snippet => api.activate({ id: snippet.id, network: snippet.network }),
refreshSnippetsList)
refreshSnippetsList,
bulkFailureReporter(reportFailure, __('activate the selected snippets', 'code-snippets')))
break

case 'deactivate':
await applyAndRefresh(
allSnippets.filter(snippet => selected.has(snippet.id) && snippet.active),
snippet => api.deactivate({ id: snippet.id, network: snippet.network }),
refreshSnippetsList)
refreshSnippetsList,
bulkFailureReporter(reportFailure, __('deactivate the selected snippets', 'code-snippets')))
break

case 'clone':
await applyAndRefresh(
allSnippets.filter(snippet => selected.has(snippet.id) && !snippet.trashed),
snippet => api.create(cloneSnippetObject(snippet)),
refreshSnippetsList)
refreshSnippetsList,
bulkFailureReporter(reportFailure, __('clone the selected snippets', 'code-snippets')))
break

case 'export':
downloadBulkSnippetExportFile(allSnippets.filter(snippet => selected.has(snippet.id)))
break

case 'download': {
const selectedSnippets = allSnippets.filter(snippet => selected.has(snippet.id))

return 1 < selectedSnippets.length && !window.CODE_SNIPPETS_MANAGE?.supportsZipDownloads
? submitBulkSnippetDownloadsIndividually(selectedSnippets)
: submitBulkSnippetDownload(selectedSnippets)
}
case 'download':
return submitBulkDownload(allSnippets.filter(snippet => selected.has(snippet.id)))

case 'trash':
case 'delete':
await applyAndRefresh(
allSnippets.filter(snippet => selected.has(snippet.id)),
snippet => api.delete({ id: snippet.id, network: snippet.network }),
refreshSnippetsList)
refreshSnippetsList,
bulkFailureReporter(reportFailure, __('remove the selected snippets', 'code-snippets')))
break

case undefined:
Expand Down
68 changes: 68 additions & 0 deletions src/js/hooks/useActionFeedback.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import React, { useCallback, useMemo, useState } from 'react'
import { __, sprintf } from '@wordpress/i18n'
import { createContextHook } from '../utils/bootstrap'
import { describeError, handleUnknownError } from '../utils/errors'
import type { PropsWithChildren } from 'react'

export interface ActionFailure {
id: number
/** What the person was trying to do, already translated. */
action: string
/** What went wrong, in terms they can act on. */
detail: string
}

export interface ActionFeedbackContext {
failures: readonly ActionFailure[]
/**
* Report that an action did not complete.
*
* Every snippet action used to send its error to the console and nothing
* else, so a failed request looked identical to a click that had never
* registered: the row did not change and nothing explained why. That left
* people unable to tell a permissions problem from a plugin conflict, and
* left us unable to ask them anything useful.
*/
reportFailure: (action: string, error: unknown) => void
dismissFailure: (id: number) => void
}

const [Context, useActionFeedback] = createContextHook<ActionFeedbackContext>('useActionFeedback')

export const WithActionFeedbackContext: React.FC<PropsWithChildren> = ({ children }) => {
const [failures, setFailures] = useState<ActionFailure[]>([])

const reportFailure = useCallback((action: string, error: unknown) => {
// Still logged, so the full object remains available in the console.
handleUnknownError(error)

setFailures(current => [
...current.filter(failure => failure.action !== action),
{ id: Date.now() + current.length, action, detail: describeError(error) }
])
}, [])

const dismissFailure = useCallback((id: number) => {
setFailures(current => current.filter(failure => failure.id !== id))
}, [])

const value = useMemo<ActionFeedbackContext>(
() => ({ failures, reportFailure, dismissFailure }),
[failures, reportFailure, dismissFailure]
)

return <Context.Provider value={value}>{children}</Context.Provider>
}

/**
* Build the sentence shown to the person, given what they were doing.
*/
export const failureMessage = (failure: ActionFailure): string =>
sprintf(
/* translators: 1: what the user was trying to do, 2: reason it did not work. */
__('Could not %1$s. %2$s', 'code-snippets'),
failure.action,
failure.detail
)

export { useActionFeedback }
Loading
Loading