From 5c710b61529dfa68ced38095cab2ab0c44eee3c1 Mon Sep 17 00:00:00 2001 From: Imants Date: Tue, 4 Aug 2026 23:29:31 +0300 Subject: [PATCH 01/12] feat: add a guarded extra-actions slot to the snippet preview modal --- .../components/common/SnippetPreviewModal.tsx | 72 +++++++++++++++---- 1 file changed, 60 insertions(+), 12 deletions(-) diff --git a/src/js/components/common/SnippetPreviewModal.tsx b/src/js/components/common/SnippetPreviewModal.tsx index d535b9fb5..210cdfd69 100644 --- a/src/js/components/common/SnippetPreviewModal.tsx +++ b/src/js/components/common/SnippetPreviewModal.tsx @@ -15,6 +15,14 @@ import type { Snippet, SnippetType } from '../../types/Snippet' const CODE_PREVIEW_LABEL = __('Snippet code preview', 'code-snippets') +export interface PreviewWorkingState { + isWorking: boolean + beginWorking: () => boolean + finishWorking: () => void +} + +export type PreviewExtraActions = (working: PreviewWorkingState) => ReactNode + export interface SnippetPreviewModalProps { title: string code: string @@ -23,6 +31,7 @@ export interface SnippetPreviewModalProps { setIsOpen: (isOpen: boolean) => void snippet?: Snippet footerActions?: ReactNode + extraActions?: PreviewExtraActions } const EDITOR_MODES: Record = { @@ -47,19 +56,49 @@ const getPreviewEditorSettings = (type: string): EditorConfiguration => ({ interface SnippetPreviewActionsProps { snippet: Snippet closeModal: () => void + extraActions?: PreviewExtraActions } interface SnippetPreviewButtonsProps extends SnippetPreviewActionsProps { requestDelete: () => void - isWorking: boolean - setIsWorking: (working: boolean) => void + working: PreviewWorkingState & { setIsWorking: (working: boolean) => void } +} + +/** + * Tracks whether a footer action is in flight. The ref mirrors the state so + * `beginWorking` can reject re-entry within the same tick, before React + * re-renders with the disabled buttons. + */ +const useWorkingState = () => { + const [isWorking, setIsWorking] = useState(false) + const isWorkingRef = useRef(false) + const updateWorking = (value: boolean) => { + isWorkingRef.current = value + setIsWorking(value) + } + + return { + isWorking, + beginWorking: () => { + if (isWorkingRef.current) { + return false + } + + updateWorking(true) + return true + }, + finishWorking: () => updateWorking(false), + setIsWorking: updateWorking + } } const usePreviewActionHandlers = ({ snippet, closeModal, setIsWorking -}: Omit) => { +}: Pick & { + setIsWorking: (working: boolean) => void +}) => { const api = useSnippetsAPI() const { refreshSnippetsList } = useSnippetsList() const handleClone = () => { @@ -87,10 +126,11 @@ const SnippetPreviewButtons: React.FC = ({ snippet, closeModal, requestDelete, - isWorking, - setIsWorking + working, + extraActions }) => { const canModify = canModifySnippet(snippet) + const { isWorking, setIsWorking } = working const actionOptions = { snippet, closeModal, setIsWorking } const { handleClone, handleExport } = usePreviewActionHandlers(actionOptions) @@ -118,6 +158,8 @@ const SnippetPreviewButtons: React.FC = ({ {__('Export', 'code-snippets')} + {extraActions?.(working)} + {snippet.locked || !canModify ? null : diff --git a/src/js/components/ImportMenu/UploadForm/SelectFiles/SelectedFilesList.tsx b/src/js/components/ImportMenu/UploadForm/SelectFiles/SelectedFilesList.tsx index ef545aae5..a5d63118a 100644 --- a/src/js/components/ImportMenu/UploadForm/SelectFiles/SelectedFilesList.tsx +++ b/src/js/components/ImportMenu/UploadForm/SelectFiles/SelectedFilesList.tsx @@ -48,11 +48,8 @@ export interface SelectedFilesListProps { export const SelectedFilesList: React.FC = ({ files, onRemoveFile }) =>

- {sprintf( - // translators: %d: number of selected files. - __('Selected files: (%d)', 'code-snippets'), - files.length - )} + {// translators: %d: number of selected files. + sprintf(__('Selected files: (%d)', 'code-snippets'), files.length)}

diff --git a/src/js/components/ImportMenu/UploadForm/SelectSnippets/SelectSnippets.tsx b/src/js/components/ImportMenu/UploadForm/SelectSnippets/SelectSnippets.tsx index d9becf264..e7a581086 100644 --- a/src/js/components/ImportMenu/UploadForm/SelectSnippets/SelectSnippets.tsx +++ b/src/js/components/ImportMenu/UploadForm/SelectSnippets/SelectSnippets.tsx @@ -8,7 +8,7 @@ import { Button } from '../../../common/Button' import { ImportCard } from '../../common/ImportCard' import { useSelection } from '../../../../hooks/useSelection' import { SnippetSelectionTable } from './SnippetSelectionTable' -import type { FormEventHandler, ReactNode} from 'react' +import type { FormEventHandler, ReactNode } from 'react' import type { UseSelection } from '../../../../hooks/useSelection' import type { ImportResult } from './ImportResultDisplay' import type { ImportableSnippetSchema } from '../../../../types/schema/ImportableSnippetSchema' @@ -67,11 +67,8 @@ const SelectSnippetsForm: React.FC = ({ availableSnippe <>
-

{sprintf( - // translators: %d: number of available snippets. - __('Available snippets (%d)', 'code-snippets'), - availableSnippets.length - )}

+

{// translators: %d: number of available snippets. + sprintf(__('Available snippets (%d)', 'code-snippets'), availableSnippets.length)}

{__('Select the snippets you would like to import.', 'code-snippets')}

diff --git a/src/js/components/ManageMenu/CommunityCloud/CloudSearch.tsx b/src/js/components/ManageMenu/CommunityCloud/CloudSearch.tsx index 3cafec7fd..93feba6b4 100644 --- a/src/js/components/ManageMenu/CommunityCloud/CloudSearch.tsx +++ b/src/js/components/ManageMenu/CommunityCloud/CloudSearch.tsx @@ -4,7 +4,7 @@ import classnames from 'classnames' import { Spinner } from '@wordpress/components' import { useRestAPI } from '../../../hooks/useRestAPI' import { REST_BASES } from '../../../utils/restAPI' -import { isCloudSnippetDownloadable } from '../../../utils/snippets/cloud' +import { isCloudSnippetDownloadable } from '../../../utils/snippets/snippets' import { TableNav } from '../../common/ListTable/TableNavigation' import { LoadingStatusNotices } from '../../common/LoadingStatusNotices' import { SnippetViewToggle } from '../../common/SnippetViewToggle' diff --git a/src/js/components/ManageMenu/CommunityCloud/CloudSnippetsTable.tsx b/src/js/components/ManageMenu/CommunityCloud/CloudSnippetsTable.tsx index a53bdaec3..ebc13e1aa 100644 --- a/src/js/components/ManageMenu/CommunityCloud/CloudSnippetsTable.tsx +++ b/src/js/components/ManageMenu/CommunityCloud/CloudSnippetsTable.tsx @@ -1,7 +1,6 @@ import { __, sprintf } from '@wordpress/i18n' import React, { useState } from 'react' -import { isCloudSnippetDownloadable } from '../../../utils/snippets/cloud' -import { getSnippetType } from '../../../utils/snippets/snippets' +import { getSnippetType , isCloudSnippetDownloadable } from '../../../utils/snippets/snippets' import { stripTags, truncateChars } from '../../../utils/text' import { Badge } from '../../common/Badge' import { Button } from '../../common/Button' @@ -134,8 +133,8 @@ export const CloudSnippetsTable: React.FC = ({ return - {selected && setSelected - ? - : null} + )} diff --git a/src/js/components/ManageMenu/CommunityCloud/SearchResult.tsx b/src/js/components/ManageMenu/CommunityCloud/SearchResult.tsx index 6cc839718..3c3ee0ca0 100644 --- a/src/js/components/ManageMenu/CommunityCloud/SearchResult.tsx +++ b/src/js/components/ManageMenu/CommunityCloud/SearchResult.tsx @@ -63,8 +63,9 @@ const CloudSnippetDetails: React.FC = ({ ?
{0 < snippet.tags.length ? - {__('Tags:', 'code-snippets')} - {' '}{snippet.tags.join(', ')} + + {__('Tags:', 'code-snippets')} + {snippet.tags.join(', ')} : null} diff --git a/src/js/components/ManageMenu/SnippetsTable/ManageSnippetCard.tsx b/src/js/components/ManageMenu/SnippetsTable/ManageSnippetCard.tsx index 9c36fd6a1..3ea7d961c 100644 --- a/src/js/components/ManageMenu/SnippetsTable/ManageSnippetCard.tsx +++ b/src/js/components/ManageMenu/SnippetsTable/ManageSnippetCard.tsx @@ -1,3 +1,4 @@ +import classnames from 'classnames' import { humanTimeDiff } from '@wordpress/date' import { RawHTML } from '@wordpress/element' import { __, sprintf } from '@wordpress/i18n' @@ -187,12 +188,12 @@ export const ManageSnippetCard: React.FC = ({ return ( = ({
- {0 < snippet.tags.length || snippet.modified - ?
- {0 < snippet.tags.length - ? - {__('Tags:', 'code-snippets')} - {' '} - - - : null} + {(0 < snippet.tags.length || snippet.modified) && ( +
+ {0 < snippet.tags.length && ( + + + {__('Tags:', 'code-snippets')} + + )} -
- : null} +
)} - {snippet.desc - ?
{snippet.desc}
- : null} + {snippet.desc && ( +
{snippet.desc}
)} ) diff --git a/src/js/components/ManageMenu/SnippetsTable/SnippetsTableControls.tsx b/src/js/components/ManageMenu/SnippetsTable/SnippetsTableControls.tsx index 07db89183..29aa7f8f3 100644 --- a/src/js/components/ManageMenu/SnippetsTable/SnippetsTableControls.tsx +++ b/src/js/components/ManageMenu/SnippetsTable/SnippetsTableControls.tsx @@ -45,13 +45,9 @@ const SnippetStatusCounts = () => { setCurrentStatus(status) }} > - {`${label} `} - { - // translators: %d: number of snippets in the current view. - sprintf( - _x('(%d)', 'table view count', 'code-snippets'), - snippetsByStatus.get(status)?.length ?? 0 - ) + {label} { + // translators: %d: number of snippets in the current view. + sprintf(_x('(%d)', 'table view count', 'code-snippets'), snippetsByStatus.get(status)?.length ?? 0) } diff --git a/src/js/components/common/KebabMenu.tsx b/src/js/components/common/KebabMenu.tsx index eb151c27c..b22c252d4 100644 --- a/src/js/components/common/KebabMenu.tsx +++ b/src/js/components/common/KebabMenu.tsx @@ -21,7 +21,7 @@ const FOCUSABLE_SELECTOR = [ ].join(', ') interface KebabMenuContextValue { - closeMenu: () => void + closeMenu: VoidFunction } const KebabMenuContext = createContext({ closeMenu: () => undefined }) @@ -29,7 +29,7 @@ const KebabMenuContext = createContext({ closeMenu: () => export const useKebabMenu = (): KebabMenuContextValue => useContext(KebabMenuContext) export interface KebabMenuItemProps { - onSelect?: () => void + onSelect?: VoidFunction destructive?: boolean disabled?: boolean className?: string @@ -152,22 +152,25 @@ const handlePopoverKeyDown = ( const focusable = Array.from(popoverRef.current.querySelectorAll(FOCUSABLE_SELECTOR)) const currentIndex = active instanceof HTMLElement ? focusable.indexOf(active) : -1 - if ( - 'ArrowDown' === event.key || - 'ArrowUp' === event.key || - 'Home' === event.key || - 'End' === event.key - ) { - if (0 < focusable.length) { - event.preventDefault() - const offset = 'ArrowDown' === event.key ? currentIndex + 1 : currentIndex - 1 - const target = 'Home' === event.key - ? 0 - : 'End' === event.key ? focusable.length - 1 : (offset + focusable.length) % focusable.length - focusable[target].focus() + if (0 < focusable.length && ['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) { + event.preventDefault() + + const getTarget = () => { + switch (event.key) { + case 'Home': + return 0 + + case 'End': + return focusable.length - 1 + + default: { + const offset = currentIndex + ('ArrowDown' === event.key ? 1 : -1) + return (offset + focusable.length) % focusable.length + } + } } - return + focusable[getTarget()].focus() } } diff --git a/src/js/utils/snippets/cloud.ts b/src/js/utils/snippets/cloud.ts deleted file mode 100644 index 74041d8e2..000000000 --- a/src/js/utils/snippets/cloud.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { isLicensed } from '../screen' -import { isProSnippet } from './snippets' -import type { CloudSnippetSchema } from '../../types/schema/CloudSnippetSchema' - -export const isCloudSnippetDownloadable = ( - snippet: Pick -): boolean => - !snippet.local_id && (!isProSnippet(snippet) || isLicensed()) diff --git a/src/js/utils/snippets/snippets.ts b/src/js/utils/snippets/snippets.ts index df5c52c3d..cf5584772 100644 --- a/src/js/utils/snippets/snippets.ts +++ b/src/js/utils/snippets/snippets.ts @@ -2,6 +2,7 @@ import { __, sprintf } from '@wordpress/i18n' import { isLicensed, isNetworkAdmin } from '../screen' import { buildUrl } from '../urls' import { parseSnippetObject } from './objects' +import type { CloudSnippetSchema } from '../../types/schema/CloudSnippetSchema' import type { SnippetSchema } from '../../types/schema/SnippetSchema' import type { Snippet, SnippetScope, SnippetType } from '../../types/Snippet' @@ -112,3 +113,8 @@ export const isNetworkOnlySnippet = (snippet: Snippet): boolean => export const canModifySnippet = (snippet: Snippet): boolean => !isNetworkOnlySnippet(snippet) && !(snippet.shared_network && !window.CODE_SNIPPETS_MANAGE?.hasNetworkCap) + +export const isCloudSnippetDownloadable = ( + snippet: Pick +): boolean => + !snippet.local_id && (!isProSnippet(snippet) || isLicensed())
+ {selected && setSelected && ( + = ({ onChange={event => updateSelection(setSelected, downloadable.map(snippet => snippet.id), event.target.checked)} /> - {__('Name', 'code-snippets')} {__('Type', 'code-snippets')} {__('Status', 'code-snippets')}