Skip to content
Merged
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
7 changes: 7 additions & 0 deletions src/css/common/_theme.scss
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,13 @@
--cs-direction-multiplier: 1; // LTR = 1, RTL = -1
}

// WordPress halves the content area's start padding on narrow screens.
@media (width <= 782px) {
:root {
--cs-wpcontent-inline-start-indent: 10px;
}
}

html[dir="rtl"],
body.rtl {
--cs-direction-multiplier: -1;
Expand Down
16 changes: 16 additions & 0 deletions src/css/common/_toolbar.scss
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,10 @@
}

@media (width <= 782px) {
nav {
overflow-x: auto;
}

li a {
inline-size: 100%;
padding-inline: 8px;
Expand Down Expand Up @@ -438,3 +442,15 @@
margin: 0;
}
}

// On the narrowest screens WordPress makes room for its 46px admin bar by
// padding the body rather than the page, so the toolbar has to clear it itself.
@media (width <= 600px) {
#wpbody {
padding-block-start: calc(var(--cs-toolbar-block-size) + 46px);
}

.code-snippets-toolbar {
inset-block-start: 46px;
}
}
1 change: 1 addition & 0 deletions src/css/settings.scss
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ input[type='number'] {
}

.CodeMirror {
box-sizing: border-box;
max-inline-size: 800px;
inline-size: 100%;
padding-inline-end: 1em;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ export const PriorityInput = () => {

<input
type="number"
min={0}
max={32767}
id={priorityId}
name="snippet_priority"
value={snippet.priority}
Expand Down
97 changes: 95 additions & 2 deletions src/js/components/ManageMenu/SnippetsTable/SnippetsListTable.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { __ } from '@wordpress/i18n'
import { __, _n, sprintf } from '@wordpress/i18n'
import React, { useEffect, useMemo, useState } from 'react'
import classnames from 'classnames'
import { getSnippetType, isSnippetActive } from '../../../utils/snippets/snippets'
import { buildUrl } from '../../../utils/urls'
import { ConfirmDialog } from '../../common/ConfirmDialog'
import { ListTable } from '../../common/ListTable'
import { DismissibleNotice } from '../../common/Notice'
import { SnippetViewToggle } from '../../common/SnippetViewToggle'
import { SnippetsCardGrid } from './SnippetsCardGrid'
import { SnippetsTableNavigation, SnippetsTableToolbar } from './SnippetsTableControls'
Expand Down Expand Up @@ -178,6 +180,88 @@ export interface SnippetsListTableProps {
setSnippetView: (view: SnippetView) => void
}

type ApplyBulkAction = ReturnType<typeof useApplyBulkAction>

/**
* Hold a permanent bulk delete until it is confirmed, and count what it deleted.
*/
const useConfirmedBulkDelete = (applyBulkAction: ApplyBulkAction) => {
const [pendingDelete, setPendingDelete] = useState<Set<Snippet['id']>>()
const [deletedCount, setDeletedCount] = useState<number>()

const doAction = async (action: SnippetsTableAction | undefined, selected: Set<Snippet['id']>): Promise<void> => {
if ('delete' === action) {
setPendingDelete(selected)
} else {
await applyBulkAction(action, selected)
}
}

const confirmDelete = () => {
const selected = pendingDelete
setPendingDelete(undefined)

if (selected) {
void applyBulkAction('delete', selected).then(count => setDeletedCount(count ?? 0))
}
}

return {
doAction,
pendingDelete,
cancelDelete: () => setPendingDelete(undefined),
confirmDelete,
deletedCount,
dismissDeleted: () => setDeletedCount(undefined)
}
}

interface BulkDeleteFeedbackProps {
pendingDelete: Set<Snippet['id']> | undefined
cancelDelete: VoidFunction
confirmDelete: VoidFunction
deletedCount: number | undefined
dismissDeleted: VoidFunction
}

const BulkDeleteFeedback: React.FC<BulkDeleteFeedbackProps> = ({
pendingDelete,
cancelDelete,
confirmDelete,
deletedCount,
dismissDeleted
}) =>
<>
{undefined !== deletedCount &&
<DismissibleNotice type="success" onDismiss={dismissDeleted}>
<p>
{sprintf(
// translators: %d: number of snippets.
_n('%d snippet permanently deleted.', '%d snippets permanently deleted.', deletedCount, 'code-snippets'),
deletedCount
)}
</p>
</DismissibleNotice>}

<ConfirmDialog
open={undefined !== pendingDelete}
title={__('Are you sure?', 'code-snippets')}
confirmLabel={__('Delete', 'code-snippets')}
confirmButtonClassName="is-destructive"
onCancel={cancelDelete}
onConfirm={confirmDelete}
>
<p>
{sprintf(
// translators: %d: number of snippets.
_n('%d snippet will be permanently deleted.', '%d snippets will be permanently deleted.', pendingDelete?.size ?? 0, 'code-snippets'),
pendingDelete?.size ?? 0
)}
</p>
<p>{__('This action cannot be undone.', 'code-snippets')}</p>
</ConfirmDialog>
</>

export const SnippetsListTable: React.FC<SnippetsListTableProps> = ({
snippetView,
setSnippetView
Expand All @@ -191,6 +275,7 @@ export const SnippetsListTable: React.FC<SnippetsListTableProps> = ({
[snippetsByStatus, currentStatus]
)
const applyBulkAction = useApplyBulkAction(currentSnippets)
const { doAction, pendingDelete, cancelDelete, confirmDelete, deletedCount, dismissDeleted } = useConfirmedBulkDelete(applyBulkAction)

useEffect(() => {
if (INDEX_STATUS !== currentStatus && !snippetsByStatus.has(currentStatus)) {
Expand All @@ -205,13 +290,21 @@ export const SnippetsListTable: React.FC<SnippetsListTableProps> = ({
<>
<SnippetsTableToolbar />

<BulkDeleteFeedback
pendingDelete={pendingDelete}
cancelDelete={cancelDelete}
confirmDelete={confirmDelete}
deletedCount={deletedCount}
dismissDeleted={dismissDeleted}
/>

<div className="snippets-list-view">
<SnippetsView
snippetView={snippetView}
setSnippetView={setSnippetView}
snippets={currentSnippets}
actions={'trashed' === currentStatus ? TRASHED_BULK_ACTIONS : BULK_ACTIONS}
doAction={applyBulkAction}
doAction={doAction}
extraTableNav={extraTableNav}
hiddenColumns={hiddenColumns}
truncateRowValues={truncateRowValues}
Expand Down
27 changes: 21 additions & 6 deletions src/js/components/ManageMenu/SnippetsTable/SnippetsTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,19 +63,34 @@ const SnippetTypeTab: React.FC<SnippetTypeTabProps> = ({ type, count }) => {
}


const getUrlWithoutSafeMode = (): string => {
const url = new URL(window.location.href)
url.searchParams.delete('snippets-safe-mode')
return url.toString()
}

const SafeModeNotice = () =>
window.CODE_SNIPPETS_MANAGE?.isSafeModeActive
? <Notice type="error">
<p>
<strong>{__('Warning:', 'code-snippets')}</strong>{'\n'}
{__('Safe mode is active and snippets will not execute!', 'code-snippets')}{'\n'}

{createInterpolateElement(
__('Remove the <code>CODE_SNIPPETS_SAFE_MODE</code> constant from <code>wp-config.php</code> file to turn off safe mode.', 'code-snippets'),
{
code: <code />
}
)}{'\n'}
{window.CODE_SNIPPETS?.safeModeRequested
? createInterpolateElement(
__('It was turned on for this visit with <code>snippets-safe-mode</code> in the address. <a>Leave safe mode</a>.', 'code-snippets'),
{
code: <code />,
// eslint-disable-next-line jsx-a11y/anchor-has-content, jsx-a11y/control-has-associated-label
a: <a href={getUrlWithoutSafeMode()} />
}
)
: createInterpolateElement(
__('Remove the <code>CODE_SNIPPETS_SAFE_MODE</code> constant from <code>wp-config.php</code> file to turn off safe mode.', 'code-snippets'),
{
code: <code />
}
)}{'\n'}

<a href="https://codesnippets.pro/doc/safe-mode" target="_blank" rel="noreferrer">
{__('Read more', 'code-snippets')}
Expand Down
29 changes: 22 additions & 7 deletions src/js/components/ManageMenu/SnippetsTable/useApplyBulkAction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,23 +78,36 @@ const submitBulkSnippetDownloadsIndividually = (snippets: readonly Snippet[]): P
Promise.resolve()
)

/**
* Apply an action to each target in turn, then refresh the list.
*
* @returns The number of targets the action succeeded on.
*/
const applyAndRefresh = async (
targets: Snippet[],
action: (snippet: Snippet) => Promise<Snippet> | Promise<void>,
refresh: () => Promise<void>
): Promise<void> => {
): Promise<number> => {
let succeeded = 0

if (0 < targets.length) {
for (const snippet of targets) {
await action(snippet).catch(handleUnknownError)
await action(snippet)
.then(() => {
succeeded += 1
})
.catch(handleUnknownError)
}

await refresh()
}

return succeeded
}

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

Expand Down Expand Up @@ -128,21 +141,23 @@ export const useApplyBulkAction = (
case 'download': {
const selectedSnippets = allSnippets.filter(snippet => selected.has(snippet.id))

return 1 < selectedSnippets.length && !window.CODE_SNIPPETS_MANAGE?.supportsZipDownloads
await (1 < selectedSnippets.length && !window.CODE_SNIPPETS_MANAGE?.supportsZipDownloads
? submitBulkSnippetDownloadsIndividually(selectedSnippets)
: submitBulkSnippetDownload(selectedSnippets)
: submitBulkSnippetDownload(selectedSnippets))
break
}

case 'trash':
case 'delete':
await applyAndRefresh(
return applyAndRefresh(
allSnippets.filter(snippet => selected.has(snippet.id)),
snippet => api.delete({ id: snippet.id, network: snippet.network }),
refreshSnippetsList)
break

case undefined:
break
}

return undefined
}
}
2 changes: 2 additions & 0 deletions src/js/components/common/snippets/SnippetPriorityInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ export const SnippetPriorityInput: React.FC<SnippetPriorityInputProps> = ({ snip
type="number"
className="snippet-priority"
value={value}
min={0}
max={32767}
step="1"
onBlur={handleUpdate}
aria-label={__('Snippet priority', 'code-snippets')}
Expand Down
2 changes: 2 additions & 0 deletions src/js/hooks/useRestAPI.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
applyCacheBuster,
applyMethodOverride,
applyRestNonce,
applySafeMode,
listenForNonceRefresh
} from '../utils/restAPI'
import type { PropsWithChildren } from 'react'
Expand Down Expand Up @@ -69,6 +70,7 @@ export const WithRestAPIContext: React.FC<PropsWithChildren> = ({ children }) =>
instance.interceptors.request.use(applyRestNonce)
instance.interceptors.request.use(applyMethodOverride)
instance.interceptors.request.use(applyCacheBuster)
instance.interceptors.request.use(applySafeMode)
listenForNonceRefresh()
return instance
}, [])
Expand Down
1 change: 1 addition & 0 deletions src/js/types/Window.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ declare global {
readonly CODE_SNIPPETS?: {
debug: boolean
isLicensed: boolean
safeModeRequested: boolean
hideUpsell: boolean
isCloudConnected: boolean
snippetView: SnippetView
Expand Down
6 changes: 5 additions & 1 deletion src/js/utils/Linter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,11 @@ export class Linter {

this.visit(program)
} catch (error) {
console.error(error)
// The parser can fail outright on code that is still being typed, even with
// errors suppressed. The next change lints again, so this is not an error.
if (window.CODE_SNIPPETS?.debug) {
console.debug(error)
}
}
}

Expand Down
6 changes: 5 additions & 1 deletion src/js/utils/files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,11 @@ export const downloadSnippetExportFile = (
{ id, name, scope }: Snippet,
type?: keyof typeof MIME_INFO
) => {
const sanitizedName = name.toLowerCase().replace(/[^\w-]+/g, '-').trim()
const sanitizedName = name
.normalize('NFC')
.toLowerCase()
.replace(/[^\p{L}\p{M}\p{N}_-]+/gu, '-')
.replace(/^-+|-+$/g, '')
const title = '' === sanitizedName ? `snippet-${id}` : sanitizedName

if ('string' === typeof content) {
Expand Down
15 changes: 15 additions & 0 deletions src/js/utils/restAPI.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,21 @@ export const applyCacheBuster = (config: InternalAxiosRequestConfig): InternalAx
return config
}

/**
* Carry safe mode through to the REST API when the page was opened in it.
*
* Safe mode is requested per request. Without the flag, a call from a screen
* opened in safe mode runs every snippet, including the one that made safe
* mode necessary, and fails the same way the rest of the site does.
*/
export const applySafeMode = (config: InternalAxiosRequestConfig): InternalAxiosRequestConfig => {
if (window.CODE_SNIPPETS?.safeModeRequested) {
config.params = { ...<Record<string, unknown> | undefined>config.params, 'snippets-safe-mode': 1 }
}

return config
}

export const REST_API_AXIOS_CONFIG: AxiosRequestConfig = {
headers: {
'Access-Control': window.CODE_SNIPPETS?.restAPI.cloud.token
Expand Down
Loading
Loading