diff --git a/projects/packages/videopress/changelog/fix-videopress-upload-error-handler b/projects/packages/videopress/changelog/fix-videopress-upload-error-handler new file mode 100644 index 0000000000000..401323095c692 --- /dev/null +++ b/projects/packages/videopress/changelog/fix-videopress-upload-error-handler @@ -0,0 +1,4 @@ +Significance: minor +Type: changed + +VideoPress: fix upload error handler to be able to hint the user something's gone wrong diff --git a/projects/packages/videopress/src/client/admin/components/admin-page/index.tsx b/projects/packages/videopress/src/client/admin/components/admin-page/index.tsx index 914c33ed28a97..7178449b89919 100644 --- a/projects/packages/videopress/src/client/admin/components/admin-page/index.tsx +++ b/projects/packages/videopress/src/client/admin/components/admin-page/index.tsx @@ -45,8 +45,17 @@ import styles from './styles.module.scss'; const useDashboardVideos = () => { const { uploadVideo, uploadVideoFromLibrary, setVideosQuery } = useDispatch( STORE_ID ); - const { items, uploading, uploadedVideoCount, isFetching, search, page, itemsPerPage, total } = - useVideos(); + const { + items, + uploadErrors, + uploading, + uploadedVideoCount, + isFetching, + search, + page, + itemsPerPage, + total, + } = useVideos(); const { items: localVideos, uploadedLocalVideoCount } = useLocalVideos(); const { hasVideoPressPurchase } = usePlan(); @@ -105,9 +114,10 @@ const useDashboardVideos = () => { }, [ totalOfPages, page, pageFromSearchParam, search, searchFromSearchParam, tempPage.current ] ); // Do not show uploading videos if not in the first page or searching - let videos = page > 1 || Boolean( search ) ? items : [ ...uploading, ...items ]; + let videos = page > 1 || Boolean( search ) ? items : [ ...uploadErrors, ...uploading, ...items ]; - const hasVideos = uploadedVideoCount > 0 || isFetching || uploading?.length > 0; + const hasVideos = + uploadedVideoCount > 0 || isFetching || uploading?.length > 0 || uploadErrors?.length > 0; const hasLocalVideos = uploadedLocalVideoCount > 0; const handleFilesUpload = ( files: File[] ) => { @@ -144,7 +154,7 @@ const useDashboardVideos = () => { handleFilesUpload, handleLocalVideoUpload, loading: isFetching, - uploading: uploading?.length > 0, + uploading: uploading?.length > 0 || uploadErrors?.length > 0, hasVideoPressPurchase, }; }; diff --git a/projects/packages/videopress/src/client/admin/components/admin-page/libraries.tsx b/projects/packages/videopress/src/client/admin/components/admin-page/libraries.tsx index 1acfb8fd020a9..adfb95ad11e0a 100644 --- a/projects/packages/videopress/src/client/admin/components/admin-page/libraries.tsx +++ b/projects/packages/videopress/src/client/admin/components/admin-page/libraries.tsx @@ -125,10 +125,12 @@ export const VideoPressLibrary = ( { videos, totalVideos, loading }: VideoLibrar const history = useHistory(); const { search } = useVideos(); const videosToDisplay = [ - // First comes the videos that are uploading + // First comes video upload errors + ...videos.filter( video => video.error ), + // Then comes the videos that are uploading ...videos.filter( video => video.uploading ), // Then the videos that are not uploading, at most 6 - ...videos.filter( video => ! video.uploading ).slice( 0, 6 ), + ...videos.filter( video => ! video.uploading && ! video.error ).slice( 0, 6 ), ]; const libraryTypeFromLocalStorage = localStorage.getItem( diff --git a/projects/packages/videopress/src/client/admin/components/video-card/error.tsx b/projects/packages/videopress/src/client/admin/components/video-card/error.tsx new file mode 100644 index 0000000000000..b0e592455c0b1 --- /dev/null +++ b/projects/packages/videopress/src/client/admin/components/video-card/error.tsx @@ -0,0 +1,143 @@ +/** + * External dependencies + */ +import { + Button, + Title, + useBreakpointMatch, + ActionPopover, + getRedirectUrl, + Text, +} from '@automattic/jetpack-components'; +import { useDispatch } from '@wordpress/data'; +import { __ } from '@wordpress/i18n'; +import { Icon, chevronDown, chevronUp, trash } from '@wordpress/icons'; +import clsx from 'clsx'; +import { useState } from 'react'; +/** + * Internal dependencies + */ +import { STORE_ID } from '../../../state'; +import VideoThumbnail from '../video-thumbnail'; +import styles from './style.module.scss'; +import { VideoCardProps } from './types'; +/** + * Types + */ +import type React from 'react'; + +/** + * Video Card Error component + * + * @param {VideoCardProps} props - Component props. + * @returns {React.ReactNode} - VideoCardError react component. + */ +export const VideoCardError = ( { title, id }: VideoCardProps ) => { + const { dismissErroredVideo } = useDispatch( STORE_ID ); + const isBlank = ! title; + + const [ anchor, setAnchor ] = useState( null ); + const [ isSm ] = useBreakpointMatch( 'sm' ); + const [ isOpen, setIsOpen ] = useState( false ); + const [ showError, setShowError ] = useState( false ); + const disabled = false; + + const handleDismiss = () => dismissErroredVideo( id ); + + const handleErrorHint = () => setShowError( true ); + + const troubleshootUrl = getRedirectUrl( 'jetpack-videopress-dashboard-troubleshoot' ); + + const closeErrorHint = () => setShowError( false ); + + return ( + <> +
setIsOpen( wasOpen => ! wasOpen ) } ) } + > + { ! isSm &&
} + + + +
+ { isSm && ( +
+ { isOpen && } + { ! isOpen && } +
+ ) } + + + { title } + +
+ + { showError && ( + + + { __( + "There's been an error uploading your video. Try uploading the video again, if the error persists, visit our documentation to troubleshoot the issue or contact support.", + 'jetpack-videopress-pkg' + ) } + + + ) } + + { ! isSm && ( +
+ + +
+ ) } +
+ + { isSm && isOpen && ( +
+ + +
+ ) } + + ); +}; + +export default VideoCardError; diff --git a/projects/packages/videopress/src/client/admin/components/video-card/style.module.scss b/projects/packages/videopress/src/client/admin/components/video-card/style.module.scss index d32bc65904a44..f7b0f3c182766 100644 --- a/projects/packages/videopress/src/client/admin/components/video-card/style.module.scss +++ b/projects/packages/videopress/src/client/admin/components/video-card/style.module.scss @@ -101,3 +101,7 @@ .chevron { display: flex; } + +.video-card__quick-actions__error-button { + background-color: red; +} diff --git a/projects/packages/videopress/src/client/admin/components/video-grid/index.tsx b/projects/packages/videopress/src/client/admin/components/video-grid/index.tsx index 042d8545e702c..4a8be1755641c 100644 --- a/projects/packages/videopress/src/client/admin/components/video-grid/index.tsx +++ b/projects/packages/videopress/src/client/admin/components/video-grid/index.tsx @@ -6,6 +6,7 @@ import { Container, Col } from '@automattic/jetpack-components'; * Internal dependencies */ import VideoCard from '../video-card'; +import VideoCardError from '../video-card/error'; import styles from './style.module.scss'; import { VideoGridProps } from './types'; import type React from 'react'; @@ -29,15 +30,29 @@ const VideoGrid = ( { videos, count = 6, onVideoDetailsClick, loading }: VideoGr { gridVideos.map( ( video, index ) => { return ( - + { video.error ? ( + + alert( + `${ video.error.error || 'Error' }: ${ + video.error.message || 'Unknown error' + }` + ) + } + /> + ) : ( + + ) } ); } ) } diff --git a/projects/packages/videopress/src/client/admin/components/video-list/index.tsx b/projects/packages/videopress/src/client/admin/components/video-list/index.tsx index a4caea8ecdbae..75cfabba669a9 100644 --- a/projects/packages/videopress/src/client/admin/components/video-list/index.tsx +++ b/projects/packages/videopress/src/client/admin/components/video-list/index.tsx @@ -19,6 +19,7 @@ import { usePlan } from '../../hooks/use-plan'; import useVideos from '../../hooks/use-videos'; import Checkbox from '../checkbox'; import ConnectVideoRow, { LocalVideoRow, Stats } from '../video-row'; +import VideoRowError from '../video-row/error'; import styles from './style.module.scss'; /** * Types @@ -74,7 +75,9 @@ const VideoList = ( { const isPrivate = VIDEO_PRIVACY_LEVELS[ video.privacySetting ] === VIDEO_PRIVACY_LEVEL_PRIVATE; - return ( + return video.error ? ( + + ) : ( { + const textRef = useRef( null ); + const checkboxRef = useRef( null ); + + const [ isSmall ] = useBreakpointMatch( 'sm' ); + const [ keyPressed, setKeyDown ] = useState( false ); + const [ expanded, setExpanded ] = useState( false ); + const [ anchor, setAnchor ] = useState( null ); + const [ showError, setShowError ] = useState( false ); + + const { dismissErroredVideo } = useDispatch( STORE_ID ); + + const uploadDateFormatted = dateI18n( 'M j, Y', new Date(), null ); + const isEllipsisActive = textRef?.current?.offsetWidth < textRef?.current?.scrollWidth; + + const showTitleLabel = ! isSmall && isEllipsisActive; + const showBottom = ! isSmall || ( isSmall && expanded ); + + const wrapperAriaLabel = sprintf( + /* translators: 1 Video title, 2 Video duration, 3 Video upload date */ + __( + 'Video Upload Error: Video upload, titled %1$s, failed. Please try again or visit the troubleshooting docs at %2$s.', + 'jetpack-videopress-pkg' + ), + title, + getRedirectUrl( 'jetpack-videopress-dashboard-troubleshoot' ) + ); + + const troubleshootUrl = getRedirectUrl( 'jetpack-videopress-dashboard-troubleshoot' ); + + const isSpaceOrEnter = code => code === 'Space' || code === 'Enter'; + + const onActionClick = () => { + setShowError( true ); + }; + + const closeErrorHint = () => { + setShowError( false ); + }; + + const handleClickWithStopPropagation = callback => event => { + event.stopPropagation(); + callback?.( event ); + }; + + const handleInfoWrapperClick = e => { + if ( isSmall ) { + setExpanded( current => ! current ); + } else { + handleClick( e ); + } + }; + + const handleClick = e => { + if ( e.target !== checkboxRef.current ) { + checkboxRef?.current?.click(); + } + }; + + const handleKeyDown = e => { + if ( isSpaceOrEnter( e?.code ) ) { + setKeyDown( true ); + } + }; + + const handleKeyUp = e => { + if ( isSpaceOrEnter( e?.code ) ) { + setKeyDown( false ); + handleClick( e ); + } + }; + + const handleDismiss = () => dismissErroredVideo( id ); + + return ( +
+
+
+
+ +
+ +
+ { showTitleLabel && ( + + { title } + + ) } + + + { title } + + + { isSmall && { uploadDateFormatted } } +
+ + { isSmall && } +
+ + { showBottom && ( +
+ { ! isSmall && ( +
+ +
+ ) } + + { isSmall && ( +
+ +
+ ) } +
+ ) } +
+ + { showError && ( + + + { __( + "There's been an error uploading your video. Try uploading the video again, if the error persists, visit our documentation to troubleshoot the issue or contact support.", + 'jetpack-videopress-pkg' + ) } + + + ) } +
+ ); +}; + +export default VideoRowError; diff --git a/projects/packages/videopress/src/client/admin/components/video-thumbnail/index.tsx b/projects/packages/videopress/src/client/admin/components/video-thumbnail/index.tsx index a5f6b171aaf3e..c0f728d216c0b 100644 --- a/projects/packages/videopress/src/client/admin/components/video-thumbnail/index.tsx +++ b/projects/packages/videopress/src/client/admin/components/video-thumbnail/index.tsx @@ -12,7 +12,7 @@ import { import { Dropdown } from '@wordpress/components'; import { gmdateI18n } from '@wordpress/date'; import { __, sprintf } from '@wordpress/i18n'; -import { Icon, edit, cloud, image, media, video } from '@wordpress/icons'; +import { Icon, edit, cloud, image, media, video, warning } from '@wordpress/icons'; import clsx from 'clsx'; import { forwardRef } from 'react'; /** @@ -153,6 +153,12 @@ const ProcessingThumbnail = ( { isRow = false }: { isRow?: boolean } ) => (
); +const ErrorThumbnail = ( { isRow } ) => ( +
+ +
+); + /** * React component to display video thumbnail. * @@ -177,6 +183,7 @@ const VideoThumbnail = forwardRef< HTMLDivElement, VideoThumbnailProps >( onUploadImage, uploadProgress, isRow = false, + hasError = false, }, ref ) => { @@ -185,6 +192,7 @@ const VideoThumbnail = forwardRef< HTMLDivElement, VideoThumbnailProps >( // Mapping thumbnail (Ordered by priority) let thumbnail = defaultThumbnail; + thumbnail = loading ? : thumbnail; thumbnail = uploading ? ( @@ -193,6 +201,8 @@ const VideoThumbnail = forwardRef< HTMLDivElement, VideoThumbnailProps >( ); thumbnail = processing ? : thumbnail; + thumbnail = hasError ? : thumbnail; + thumbnail = typeof thumbnail === 'string' && thumbnail !== '' ? ( { diff --git a/projects/packages/videopress/src/client/admin/components/video-thumbnail/style.module.scss b/projects/packages/videopress/src/client/admin/components/video-thumbnail/style.module.scss index a6973c23d8abb..459ec96043ede 100644 --- a/projects/packages/videopress/src/client/admin/components/video-thumbnail/style.module.scss +++ b/projects/packages/videopress/src/client/admin/components/video-thumbnail/style.module.scss @@ -43,6 +43,10 @@ align-items: center; justify-content: center; fill: var( --jp-gray-50 ); + + &.thumbnail-error { + fill: var( --jp-yellow-20 ); + } } } diff --git a/projects/packages/videopress/src/client/admin/components/video-thumbnail/types.ts b/projects/packages/videopress/src/client/admin/components/video-thumbnail/types.ts index 016d1add98715..dcc92168971c0 100644 --- a/projects/packages/videopress/src/client/admin/components/video-thumbnail/types.ts +++ b/projects/packages/videopress/src/client/admin/components/video-thumbnail/types.ts @@ -82,4 +82,9 @@ export type VideoThumbnailProps = VideoThumbnailDropdownProps & { * True if the thumbnail is used on a video row. */ isRow?: boolean; + + /** + * True if the video has an error. + */ + hasError?: boolean; }; diff --git a/projects/packages/videopress/src/client/admin/hooks/use-videos/index.js b/projects/packages/videopress/src/client/admin/hooks/use-videos/index.js index 9c352b9f16c10..7f9e876bff5b4 100644 --- a/projects/packages/videopress/src/client/admin/hooks/use-videos/index.js +++ b/projects/packages/videopress/src/client/admin/hooks/use-videos/index.js @@ -32,6 +32,7 @@ export default function useVideos() { const pagination = useSelect( select => select( STORE_ID ).getPagination() ); const storageUsed = useSelect( select => select( STORE_ID ).getStorageUsed(), [] ); const filter = useSelect( select => select( STORE_ID ).getVideosFilter() ); + const uploadErrors = useSelect( select => select( STORE_ID ).getUploadErrorVideos() ); return { items, @@ -48,6 +49,7 @@ export default function useVideos() { ...query, ...pagination, ...storageUsed, + uploadErrors, // Handlers setPage: page => dispatch( STORE_ID ).setVideosQuery( { page } ), diff --git a/projects/packages/videopress/src/client/admin/types/index.ts b/projects/packages/videopress/src/client/admin/types/index.ts index b93b716dfaa01..9547c179db7a4 100644 --- a/projects/packages/videopress/src/client/admin/types/index.ts +++ b/projects/packages/videopress/src/client/admin/types/index.ts @@ -143,6 +143,11 @@ export type OriginalVideoPressVideo = { jetpack_videopress_guid: string; }; +export type VideoPressVideoError = { + error?: string; + message?: string; +}; + export type VideoPressVideo = { width?: OriginalVideoPressVideo[ 'media_details' ][ 'width' ]; height?: OriginalVideoPressVideo[ 'media_details' ][ 'height' ]; @@ -169,6 +174,7 @@ export type VideoPressVideo = { thumbnail?: string; uploading?: boolean; plays?: number; // Not provided yet + error?: VideoPressVideoError; }; export type LocalVideo = { diff --git a/projects/packages/videopress/src/client/lib/resumable-file-uploader/index.ts b/projects/packages/videopress/src/client/lib/resumable-file-uploader/index.ts index e1f8a6ceb7300..5a771b5c7ccc0 100644 --- a/projects/packages/videopress/src/client/lib/resumable-file-uploader/index.ts +++ b/projects/packages/videopress/src/client/lib/resumable-file-uploader/index.ts @@ -70,7 +70,19 @@ const resumableFileUploader = ( { filetype: file.type, }, retryDelays: [ 0, 1000, 3000, 5000, 10000 ], - onBeforeRequest: function ( req ) { + onShouldRetry: function ( err: tus.DetailedError ) { + const status = err.originalResponse ? err.originalResponse.getStatus() : 0; + // Do not retry if the status is a 400. + if ( status === 400 ) { + debug( '400 error, cleanup', upload._urlStorageKey ); + localStorage.removeItem( upload._urlStorageKey ); + return false; + } + + // For any other status code, we retry. + return true; + }, + onBeforeRequest: async function ( req: VPUploadHttpRequest ) { // make ALL requests be either POST or GET to honor the public-api.wordpress.com "contract". const method = req._method; diff --git a/projects/packages/videopress/src/client/state/actions.js b/projects/packages/videopress/src/client/state/actions.js index 650c5fb8bde1a..d5759bc43a63f 100644 --- a/projects/packages/videopress/src/client/state/actions.js +++ b/projects/packages/videopress/src/client/state/actions.js @@ -3,6 +3,7 @@ */ import apiFetch from '@wordpress/api-fetch'; import { addQueryArgs } from '@wordpress/url'; +import debugFactory from 'debug'; /** * Internal dependencies */ @@ -33,6 +34,7 @@ import { VIDEO_PRIVACY_LEVELS, WP_REST_API_MEDIA_ENDPOINT, SET_VIDEO_UPLOADING, + SET_VIDEO_UPLOADING_ERROR, SET_VIDEO_PROCESSING, SET_VIDEO_UPLOADED, SET_IS_FETCHING_PURCHASES, @@ -53,26 +55,33 @@ import { UPDATE_PAGINATION_AFTER_DELETE, FLUSH_DELETED_VIDEOS, UPDATE_VIDEO_IS_PRIVATE, + DISMISS_ERRORED_VIDEO, } from './constants'; import { mapVideoFromWPV2MediaEndpoint } from './utils/map-videos'; import { videoIsPrivate } from './utils/video-is-private'; +const debug = debugFactory( 'videopress:actions' ); + /** * Utility function to pool the video data until poster is ready. */ const pollingUploadedVideoData = async data => { + debug( 'Polling video data', data ); const response = await apiFetch( { path: addQueryArgs( `${ WP_REST_API_MEDIA_ENDPOINT }/${ data?.id }` ), } ); + debug( 'Polling response', response ); const video = mapVideoFromWPV2MediaEndpoint( response ); if ( video?.posterImage !== null && video?.posterImage !== '' ) { + debug( 'Video data ready', video ); return Promise.resolve( video ); } return new Promise( ( resolve, reject ) => { + debug( 'Promising to poll video data' ); setTimeout( () => { pollingUploadedVideoData( data ).then( resolve ).catch( reject ); }, 2000 ); @@ -265,17 +274,17 @@ const uploadVideo = async ( { dispatch } ) => { const tempId = uid(); - // @todo: implement progress and error handler - const noop = () => {}; - + debug( 'Uploading video' ); dispatch( { type: SET_VIDEO_UPLOADING, id: tempId, title: file?.name } ); // @todo: this should be stored in the state const tokenData = await getMediaToken( 'upload-jwt' ); const onSuccess = async data => { + debug( 'Video uploaded', data ); dispatch( { type: SET_VIDEO_PROCESSING, id: tempId, data } ); const video = await pollingUploadedVideoData( data ); + debug( 'Video processed', video ); dispatch( { type: SET_VIDEO_UPLOADED, video } ); }; @@ -283,10 +292,16 @@ const uploadVideo = dispatch( { type: SET_VIDEO_UPLOAD_PROGRESS, id: tempId, bytesSent, bytesTotal } ); }; + const onError = err => { + debug( 'upload err handler', err ); + window.lastErr = err; + dispatch( { type: SET_VIDEO_UPLOADING_ERROR, id: tempId, error: err } ); + }; + fileUploader( { tokenData, file, - onError: noop, + onError, onProgress, onSuccess, } ); @@ -476,6 +491,10 @@ const dismissFirstVideoPopover = () => { return { type: DISMISS_FIRST_VIDEO_POPOVER }; }; +const dismissErroredVideo = id => { + return { type: DISMISS_ERRORED_VIDEO, id }; +}; + const actions = { setIsFetchingVideos, setFetchVideosError, @@ -521,6 +540,7 @@ const actions = { updateVideoPressSettings, updateVideoIsPrivate, + dismissErroredVideo, }; export { actions as default }; diff --git a/projects/packages/videopress/src/client/state/constants.js b/projects/packages/videopress/src/client/state/constants.js index 43b7ee37a6840..931dfa1417ee2 100644 --- a/projects/packages/videopress/src/client/state/constants.js +++ b/projects/packages/videopress/src/client/state/constants.js @@ -44,6 +44,7 @@ export const FLUSH_DELETED_VIDEOS = 'FLUSH_DELETED_VIDEOS'; export const UPDATE_PAGINATION_AFTER_DELETE = 'UPDATE_PAGINATION_AFTER_DELETE'; export const SET_VIDEO_UPLOADING = 'SET_VIDEO_UPLOADING'; +export const SET_VIDEO_UPLOADING_ERROR = 'SET_VIDEO_UPLOADING_ERROR'; export const SET_VIDEO_PROCESSING = 'SET_VIDEO_PROCESSING'; export const SET_VIDEO_UPLOADED = 'SET_VIDEO_UPLOADED'; export const SET_VIDEO_UPLOAD_PROGRESS = 'SET_VIDEO_UPLOAD_PROGRESS'; @@ -65,6 +66,8 @@ export const SET_VIDEOPRESS_SETTINGS = 'SET_VIDEOPRESS_SETTINGS'; export const UPDATE_VIDEO_IS_PRIVATE = 'UPDATE_VIDEO_IS_PRIVATE'; +export const DISMISS_ERRORED_VIDEO = 'DISMISS_ERRORED_VIDEO'; + /* * Accepted file extensions */ diff --git a/projects/packages/videopress/src/client/state/reducers.js b/projects/packages/videopress/src/client/state/reducers.js index 9cc36efbc436b..fadfc4891c551 100644 --- a/projects/packages/videopress/src/client/state/reducers.js +++ b/projects/packages/videopress/src/client/state/reducers.js @@ -20,6 +20,7 @@ import { REMOVE_VIDEO, DELETE_VIDEO, SET_VIDEO_UPLOADING, + SET_VIDEO_UPLOADING_ERROR, SET_VIDEO_PROCESSING, SET_VIDEO_UPLOADED, SET_IS_FETCHING_PURCHASES, @@ -44,6 +45,7 @@ import { FLUSH_DELETED_VIDEOS, UPDATE_PAGINATION_AFTER_DELETE, UPDATE_VIDEO_IS_PRIVATE, + DISMISS_ERRORED_VIDEO, } from './constants'; /** @@ -424,6 +426,42 @@ const videos = ( state, action ) => { }; } + case SET_VIDEO_UPLOADING_ERROR: { + const { id, error } = action; + const currentMeta = state?._meta || {}; + const currentMetaItems = currentMeta?.items || {}; + + return { + ...state, + _meta: { + ...currentMeta, + items: { + ...currentMetaItems, + [ id ]: { + ...currentMetaItems[ id ], + uploading: false, + error: JSON.parse( error?.originalResponse?.getBody?.() || '{}' ), + }, + }, + }, + }; + } + + case DISMISS_ERRORED_VIDEO: { + const { id } = action; + const currentMeta = state?._meta || {}; + const currentMetaItems = currentMeta?.items || {}; + delete currentMetaItems[ id ]; + + return { + ...state, + _meta: { + ...currentMeta, + items: { ...currentMetaItems }, + }, + }; + } + case SET_VIDEO_PROCESSING: { const { id, data } = action; const query = state?.query ?? getDefaultQuery(); diff --git a/projects/packages/videopress/src/client/state/selectors.js b/projects/packages/videopress/src/client/state/selectors.js index 51b2e7dde4cbc..81aebcc16c316 100644 --- a/projects/packages/videopress/src/client/state/selectors.js +++ b/projects/packages/videopress/src/client/state/selectors.js @@ -9,6 +9,13 @@ export const getUploadingVideos = state => { .filter( item => item.uploading ); }; +export const getUploadErrorVideos = state => { + const items = state?.videos?._meta?.items || {}; + return Object.keys( items || {} ) + .map( id => ( { ...items[ id ], id } ) ) + .filter( item => !! item.error ); +}; + export const getVideosQuery = state => { return state?.videos?.query; }; @@ -154,6 +161,7 @@ const selectors = { isFetchingPlaybackToken, getVideoPressSettings, + getUploadErrorVideos, }; export default selectors;