From fe5c96a473d9a1cc17fc7d7e72da5af8253de474 Mon Sep 17 00:00:00 2001 From: Konstantin Obenland Date: Fri, 10 Jul 2026 18:27:21 -0500 Subject: [PATCH 1/2] VideoPress: Move caption manager outcome messages to snackbars MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The caption manager funneled every message — successes, async/API failures, and form validation — through a single inline notice. Split that into two channels: transient outcomes and async errors now surface as snackbars rendered by SnackbarList at the bottom of the modal, while sticky form-validation errors stay inline where the user is acting. Adds a use-caption-snackbars hook to own the snackbar list, routes the mutation hook and modal flows to notify()/setValidationNotice() accordingly, removes the now-unused NoticeState type, and updates the tests to assert moved messages on the snackbar list. Claude-Session: https://claude.ai/code/session_017px3yqTVgtB3RJXZwB5yFk --- .../update-caption-manager-snackbar-notices | 4 + .../caption-manager-modal/index.tsx | 111 ++++++++---------- .../caption-manager-modal/style.scss | 17 +++ .../caption-manager-modal/test/index.test.tsx | 70 ++++++----- .../caption-manager-modal/track-helpers.ts | 1 - .../use-caption-snackbars.ts | 35 ++++++ .../use-track-mutations.ts | 100 ++++++---------- 7 files changed, 185 insertions(+), 153 deletions(-) create mode 100644 projects/packages/videopress/changelog/update-caption-manager-snackbar-notices create mode 100644 projects/packages/videopress/src/client/components/caption-manager-modal/use-caption-snackbars.ts diff --git a/projects/packages/videopress/changelog/update-caption-manager-snackbar-notices b/projects/packages/videopress/changelog/update-caption-manager-snackbar-notices new file mode 100644 index 000000000000..fbd4ec3ad9a2 --- /dev/null +++ b/projects/packages/videopress/changelog/update-caption-manager-snackbar-notices @@ -0,0 +1,4 @@ +Significance: patch +Type: changed + +Captions: Surface outcome and async error messages as snackbars, keeping only form-validation errors as an inline notice. diff --git a/projects/packages/videopress/src/client/components/caption-manager-modal/index.tsx b/projects/packages/videopress/src/client/components/caption-manager-modal/index.tsx index 449b435c2cb9..7d3a91cf1bc5 100644 --- a/projects/packages/videopress/src/client/components/caption-manager-modal/index.tsx +++ b/projects/packages/videopress/src/client/components/caption-manager-modal/index.tsx @@ -2,7 +2,7 @@ * External dependencies */ import { QueryClientProvider } from '@tanstack/react-query'; -import { Button, DropZone, Modal, Notice } from '@wordpress/components'; +import { Button, DropZone, Modal, Notice, SnackbarList } from '@wordpress/components'; import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from '@wordpress/element'; import { __, _x, isRTL, sprintf } from '@wordpress/i18n'; import { chevronLeft, chevronRight, close, plus, upload } from '@wordpress/icons'; @@ -56,6 +56,7 @@ import { } from './track-helpers'; import TrackList from './track-list'; import UploadWorkspace from './upload-workspace'; +import { useCaptionSnackbars } from './use-caption-snackbars'; import { useCaptionTracks } from './use-caption-tracks'; import { useTrackMutations } from './use-track-mutations'; import { useVideoTracks } from './use-video-tracks'; @@ -69,7 +70,7 @@ import './style.scss'; * Types */ import type { CaptionPreviewPlayerHandle, CaptionPreviewProps } from './caption-preview-player'; -import type { CaptionCueBlock, ManualTrack, NoticeState } from './track-helpers'; +import type { CaptionCueBlock, ManualTrack } from './track-helpers'; import type { TrackListBusy } from './track-list'; import type { CaptionManagerModalProps } from './types'; import type { WorkspaceAction } from './workspace-reducer'; @@ -151,7 +152,10 @@ function CaptionManagerModalInner( { }, [] ); const [ workspace, dispatch ] = useReducer( workspaceReducer, initialWorkspaceState ); - const [ notice, setNotice ] = useState< NoticeState >( null ); + // Sticky, inline error for form-validation the current action can't proceed past. + const [ validationNotice, setValidationNotice ] = useState< string | null >( null ); + // Transient snackbars for outcomes and async failures; see use-caption-snackbars.ts. + const { snackbars, notify, removeSnackbar } = useCaptionSnackbars(); const [ confirmation, setConfirmation ] = useState< ConfirmationState | null >( null ); // Whether the manual editor holds unsaved track edits; reported by the // editor on transitions and reset with each new workspace instance. @@ -166,10 +170,10 @@ function CaptionManagerModalInner( { [] ); - // Workspace transitions and form edits clear any lingering notice. + // Workspace transitions and form edits clear any lingering validation notice. const dispatchAndClearNotice = useCallback( ( action: WorkspaceAction ) => { dispatch( action ); - setNotice( null ); + setValidationNotice( null ); }, [] ); // Editor-wide bits the cue blocks can't receive as props; see caption-editor-context.ts. @@ -181,25 +185,23 @@ function CaptionManagerModalInner( { isPrivate, tracks, onError: () => - setNotice( { - status: 'error', - message: __( + notify( + __( 'Couldn’t load the latest track list for this video. It may be incomplete.', 'jetpack-videopress-pkg' - ), - } ), + ) + ), } ); const { captionTracks, setCaptionTracks, isLoadingCaptionTracks } = useCaptionTracks( { guid, isOpen, onError: () => - setNotice( { - status: 'error', - message: __( + notify( + __( 'Couldn’t load saved subtitle drafts. Any existing drafts may not appear.', 'jetpack-videopress-pkg' - ), - } ), + ) + ), } ); const { @@ -221,7 +223,7 @@ function CaptionManagerModalInner( { setManagedTracks, setCaptionTracks, onTracksChange, - setNotice, + notify, } ); const resetToTrackList = useCallback( () => { @@ -357,13 +359,9 @@ function CaptionManagerModalInner( { const concludeTrackFlow = useCallback( ( cleanupFailed: boolean, successMessage: string, cleanupFailedMessage: string ) => { resetToTrackList(); - setNotice( - cleanupFailed - ? { status: 'error', message: cleanupFailedMessage } - : { status: 'success', message: successMessage } - ); + notify( cleanupFailed ? cleanupFailedMessage : successMessage ); }, - [ resetToTrackList ] + [ notify, resetToTrackList ] ); /* @@ -464,13 +462,12 @@ function CaptionManagerModalInner( { dispatch( { type: 'CONTENT_LOAD_FAILED', requestId } ); // Only surface the failure if this workspace is still the current one. if ( isCurrentWorkspace( requestId ) ) { - setNotice( { - status: 'error', - message: __( + notify( + __( 'Unable to load subtitle content. You can try again from the track list or start from an empty subtitle track.', 'jetpack-videopress-pkg' - ), - } ); + ) + ); } } }, @@ -481,6 +478,7 @@ function CaptionManagerModalInner( { isCurrentWorkspace, loadTrackText, nextRequestId, + notify, ] ); @@ -540,14 +538,13 @@ function CaptionManagerModalInner( { } if ( ! isAcceptedTrackFile( file ) ) { - setNotice( { - status: 'error', - message: sprintf( + setValidationNotice( + sprintf( /* translators: %s: comma-separated list of accepted subtitle file extensions. */ __( 'Accepted formats: %s.', 'jetpack-videopress-pkg' ), SUPPORTED_CAPTION_FORMATS_LABEL - ), - } ); + ) + ); return; } @@ -575,7 +572,7 @@ function CaptionManagerModalInner( { managedTracks, } ); if ( 'error' in result ) { - setNotice( { status: 'error', message: result.error } ); + setValidationNotice( result.error ); return; } @@ -620,10 +617,7 @@ function CaptionManagerModalInner( { captionTracks, } ); if ( ! payload ) { - setNotice( { - status: 'error', - message: __( 'Choose a subtitle language.', 'jetpack-videopress-pkg' ), - } ); + setValidationNotice( __( 'Choose a subtitle language.', 'jetpack-videopress-pkg' ) ); return; } @@ -655,28 +649,21 @@ function CaptionManagerModalInner( { captionTracks, } ); if ( ! payload ) { - setNotice( { - status: 'error', - message: __( 'Choose a subtitle language.', 'jetpack-videopress-pkg' ), - } ); + setValidationNotice( __( 'Choose a subtitle language.', 'jetpack-videopress-pkg' ) ); return; } const cueValidationErrors = getCaptionCueValidationErrors( cueBlocks ); if ( cueValidationErrors.length ) { - setNotice( { - status: 'error', - message: getCueValidationNoticeMessage( cueValidationErrors[ 0 ] ), - } ); + setValidationNotice( getCueValidationNoticeMessage( cueValidationErrors[ 0 ] ) ); return; } const cues = captionBlocksToCues( cueBlocks ); if ( ! cues.length ) { - setNotice( { - status: 'error', - message: __( 'Add at least one subtitle cue before publishing.', 'jetpack-videopress-pkg' ), - } ); + setValidationNotice( + __( 'Add at least one subtitle cue before publishing.', 'jetpack-videopress-pkg' ) + ); return; } @@ -739,10 +726,9 @@ function CaptionManagerModalInner( { const cues = parseCaptionTextInput( workspace.textImportValue ); if ( ! cues.length ) { - setNotice( { - status: 'error', - message: __( 'Paste subtitle text before importing.', 'jetpack-videopress-pkg' ), - } ); + setValidationNotice( + __( 'Paste subtitle text before importing.', 'jetpack-videopress-pkg' ) + ); return false; } @@ -752,13 +738,10 @@ function CaptionManagerModalInner( { cueBlocks: cues.map( createCueBlock ), currentCueBlocks: cueBlocksRef.current, } ); - setNotice( { - status: 'success', - message: __( 'Subtitle text imported.', 'jetpack-videopress-pkg' ), - } ); + notify( __( 'Subtitle text imported.', 'jetpack-videopress-pkg' ) ); return true; }, - [ workspace ] + [ notify, workspace ] ); const requestDeleteTrack = useCallback( @@ -1009,9 +992,9 @@ function CaptionManagerModalInner( { - { notice && ( - - { notice.message } + { validationNotice && ( + + { validationNotice } ) } @@ -1104,6 +1087,12 @@ function CaptionManagerModalInner( { onCancel={ () => setConfirmation( null ) } /> ) } + + diff --git a/projects/packages/videopress/src/client/components/caption-manager-modal/style.scss b/projects/packages/videopress/src/client/components/caption-manager-modal/style.scss index 294da31ed755..034a428e3346 100644 --- a/projects/packages/videopress/src/client/components/caption-manager-modal/style.scss +++ b/projects/packages/videopress/src/client/components/caption-manager-modal/style.scss @@ -606,3 +606,20 @@ justify-content: flex-end; gap: 8px; } + +/* + * Transient outcome and error snackbars float over the bottom of the modal. + * The list spans the modal so it can't push layout, but only the bubbles + * themselves capture clicks, leaving the content behind interactive. + */ +.videopress-caption-manager__snackbars { + position: absolute; + inset-block-end: 16px; + inset-inline: 16px; + z-index: 11; + pointer-events: none; + + .components-snackbar { + pointer-events: auto; + } +} diff --git a/projects/packages/videopress/src/client/components/caption-manager-modal/test/index.test.tsx b/projects/packages/videopress/src/client/components/caption-manager-modal/test/index.test.tsx index 959570358959..c92e84d630d4 100644 --- a/projects/packages/videopress/src/client/components/caption-manager-modal/test/index.test.tsx +++ b/projects/packages/videopress/src/client/components/caption-manager-modal/test/index.test.tsx @@ -182,6 +182,18 @@ jest.mock( '@wordpress/components', () => ( { ) : null, Notice: ( { children } ) =>
{ children }
, + SnackbarList: ( { notices, onRemove } ) => ( +
+ { notices.map( ( notice: { id: string; content: string } ) => ( +
+ { notice.content } + +
+ ) ) } +
+ ), TextareaControl: ( { label, onChange, value } ) => (