test
1 month ago
useCompleteOnboarding.js
1 month ago
useDateRangePicker.js
2 months ago
useEmail.ts
2 months ago
useEngagementChartData.js
2 months ago
useLicenseSettings.js
2 months ago
useLink.js
2 months ago
useMediaDetail.js
2 months ago
useMediaLibrary.js
2 months ago
useMediaList.ts
1 month ago
usePerformanceSettings.js
2 months ago
useRegisterActivePage.js
2 months ago
useSettingOption.js
2 months ago
useSimpleSettingsPage.js
2 months ago
useTopPerforming.js
2 months ago
useTopVideosPaginated.js
2 months ago
useUpgradeCTA.js
2 months ago
useUserDetail.js
2 months ago
useMediaList.ts
320 lines
| 1 | import { useState, useEffect, useRef, useCallback } from '@wordpress/element'; |
| 2 | import apiFetch from '@wordpress/api-fetch'; |
| 3 | import { addQueryArgs } from '@wordpress/url'; |
| 4 | import { __, sprintf } from '@wordpress/i18n'; |
| 5 | import { decodeHTMLEntities } from '../utils/formatters'; |
| 6 | |
| 7 | /** |
| 8 | * Server-driven Media Hub list hook. |
| 9 | * |
| 10 | * One request per page view: search, status, tag, orderby, order, page and |
| 11 | * per_page are pushed to the server, which returns just the rows for the |
| 12 | * current page along with the total counts and tag list. There is no |
| 13 | * eager full-library fetch and no client-side filter / sort / slice. |
| 14 | * |
| 15 | * Mirrors the existing paginated-hook pattern in useTopVideosPaginated.js: |
| 16 | * `parse: false` + AbortController on every param change so an in-flight |
| 17 | * request is cancelled the moment the user types another keystroke or |
| 18 | * switches tabs. |
| 19 | */ |
| 20 | |
| 21 | const ENDPOINT = '/presto-player/v1/media-list'; |
| 22 | |
| 23 | export interface MediaTag { |
| 24 | id: number; |
| 25 | name: string; |
| 26 | slug: string; |
| 27 | } |
| 28 | |
| 29 | export interface MediaItem { |
| 30 | id: number; |
| 31 | title: string; |
| 32 | status: string; |
| 33 | date: string; |
| 34 | modified: string; |
| 35 | post_name: string; |
| 36 | // Only present when caller can edit_post (WP REST context=edit). |
| 37 | post_password?: string; |
| 38 | shortcode: string; |
| 39 | poster: string; |
| 40 | author: { id: number; name: string }; |
| 41 | tags: MediaTag[]; |
| 42 | link: string; |
| 43 | } |
| 44 | |
| 45 | export interface MediaListCounts { |
| 46 | publish: number; |
| 47 | draft: number; |
| 48 | pending: number; |
| 49 | private: number; |
| 50 | future: number; |
| 51 | trash: number; |
| 52 | } |
| 53 | |
| 54 | export interface UseMediaListParams { |
| 55 | search?: string; |
| 56 | status?: string; |
| 57 | tag?: number; |
| 58 | orderby?: 'date' | 'modified' | 'title'; |
| 59 | order?: 'asc' | 'desc'; |
| 60 | page?: number; |
| 61 | perPage?: number; |
| 62 | } |
| 63 | |
| 64 | export interface UseMediaListReturn { |
| 65 | items: MediaItem[]; |
| 66 | totalItems: number; |
| 67 | totalPages: number; |
| 68 | counts: MediaListCounts; |
| 69 | allTags: MediaTag[]; |
| 70 | loading: boolean; |
| 71 | error: string | null; |
| 72 | /** |
| 73 | * Flips to true once the first response (success OR failure) has |
| 74 | * settled. Consumers use it to distinguish "first-paint skeleton" from |
| 75 | * "in-place refetch" without depending on counts. |
| 76 | */ |
| 77 | hasLoadedOnce: boolean; |
| 78 | refetch: () => void; |
| 79 | } |
| 80 | |
| 81 | const EMPTY_COUNTS: MediaListCounts = { |
| 82 | publish: 0, |
| 83 | draft: 0, |
| 84 | pending: 0, |
| 85 | private: 0, |
| 86 | future: 0, |
| 87 | trash: 0, |
| 88 | }; |
| 89 | |
| 90 | const DEFAULT_STATUSES = 'publish,draft,pending,private,future'; |
| 91 | |
| 92 | interface RawTag { |
| 93 | id?: unknown; |
| 94 | name?: unknown; |
| 95 | slug?: unknown; |
| 96 | } |
| 97 | |
| 98 | const normalizeTags = ( raw: unknown ): MediaTag[] => { |
| 99 | if ( ! Array.isArray( raw ) ) { |
| 100 | return []; |
| 101 | } |
| 102 | const out: MediaTag[] = []; |
| 103 | for ( const candidate of raw ) { |
| 104 | const t = candidate as RawTag; |
| 105 | const id = Number( t?.id ); |
| 106 | if ( ! Number.isFinite( id ) || id <= 0 || ! t?.name ) { |
| 107 | continue; |
| 108 | } |
| 109 | out.push( { |
| 110 | id, |
| 111 | name: String( t.name ), |
| 112 | slug: typeof t.slug === 'string' ? t.slug : '', |
| 113 | } ); |
| 114 | } |
| 115 | return out; |
| 116 | }; |
| 117 | |
| 118 | // Sanitize a row on the boundary. The server already strips WP's |
| 119 | // "Protected: " / "Private: " prefix and decodes entities, but we mirror |
| 120 | // the consumer-side decode here so titles still render correctly if the |
| 121 | // server is ever bypassed by a fixture. Empty titles fall back to |
| 122 | // `Media #<id>` so an untitled draft renders as a recognisable row rather |
| 123 | // than a blank cell. |
| 124 | const normalizeItem = ( raw: unknown ): MediaItem => { |
| 125 | const r = ( raw ?? {} ) as Record< string, unknown >; |
| 126 | const authorRaw = ( r.author ?? {} ) as Record< string, unknown >; |
| 127 | |
| 128 | const id = Number( r.id ) || 0; |
| 129 | const decoded = decodeHTMLEntities( String( r.title ?? '' ) ).trim(); |
| 130 | const title = |
| 131 | decoded || |
| 132 | ( id > 0 |
| 133 | ? sprintf( |
| 134 | /* translators: %d: id of an untitled media item */ |
| 135 | __( 'Media #%d', 'presto-player' ), |
| 136 | id |
| 137 | ) |
| 138 | : '' ); |
| 139 | |
| 140 | return { |
| 141 | id, |
| 142 | title, |
| 143 | status: String( r.status ?? 'publish' ), |
| 144 | date: String( r.date ?? '' ), |
| 145 | modified: String( r.modified ?? '' ), |
| 146 | post_name: String( r.post_name ?? '' ), |
| 147 | post_password: String( r.post_password ?? '' ), |
| 148 | shortcode: String( r.shortcode ?? '' ), |
| 149 | poster: String( r.poster ?? '' ), |
| 150 | author: { |
| 151 | id: Number( authorRaw.id ) || 0, |
| 152 | name: String( authorRaw.name ?? '' ), |
| 153 | }, |
| 154 | tags: normalizeTags( r.tags ), |
| 155 | link: String( r.link ?? '' ), |
| 156 | }; |
| 157 | }; |
| 158 | |
| 159 | /** |
| 160 | * Read the JSON body of a Response-like object, swallowing parse errors. |
| 161 | * Used both on the success path (apiFetch resolves the Response) and inside |
| 162 | * the catch (apiFetch with `parse: false` THROWS the raw Response on non-2xx |
| 163 | * — the thrown value is a Response, not an Error, and its body holds the |
| 164 | * WP REST error envelope `{ code, message, data }`). |
| 165 | */ |
| 166 | const readJsonBody = async ( response: { |
| 167 | json: () => Promise< any >; |
| 168 | } ): Promise< any > => { |
| 169 | try { |
| 170 | return await response.json(); |
| 171 | } catch { |
| 172 | return {}; |
| 173 | } |
| 174 | }; |
| 175 | |
| 176 | /** |
| 177 | * Duck-type check for a Response-like rejection from apiFetch. Avoids |
| 178 | * `instanceof Response` because jsdom (Jest's test runtime) doesn't define |
| 179 | * the global `Response` constructor. |
| 180 | */ |
| 181 | const isResponseLike = ( |
| 182 | err: unknown |
| 183 | ): err is { status: number; json: () => Promise< any > } => { |
| 184 | if ( ! err || typeof err !== 'object' ) { |
| 185 | return false; |
| 186 | } |
| 187 | const e = err as { status?: unknown; json?: unknown }; |
| 188 | return typeof e.status === 'number' && typeof e.json === 'function'; |
| 189 | }; |
| 190 | |
| 191 | /** |
| 192 | * Extract a human-readable message from whatever `apiFetch` rejected with. |
| 193 | * Three shapes are possible: |
| 194 | * |
| 195 | * 1. AbortError — caller cancelled; handled separately by `isAbort()`. |
| 196 | * 2. Response-like — apiFetch's behaviour when `parse: false`; the body |
| 197 | * holds the WP REST error envelope. |
| 198 | * 3. Plain Error / object — middleware-generated (offline, nonce refresh |
| 199 | * failure); already has `.message`. |
| 200 | */ |
| 201 | const messageFromRejection = async ( err: unknown ): Promise< string > => { |
| 202 | if ( isResponseLike( err ) ) { |
| 203 | const body = await readJsonBody( err ); |
| 204 | if ( typeof body?.message === 'string' && body.message ) { |
| 205 | return body.message; |
| 206 | } |
| 207 | return `HTTP ${ err.status }`; |
| 208 | } |
| 209 | const maybeMessage = ( err as { message?: unknown } )?.message; |
| 210 | if ( typeof maybeMessage === 'string' && maybeMessage ) { |
| 211 | return maybeMessage; |
| 212 | } |
| 213 | return __( 'Failed to load media.', 'presto-player' ); |
| 214 | }; |
| 215 | |
| 216 | const isAbort = ( err: unknown ): boolean => |
| 217 | ( err as { name?: string } )?.name === 'AbortError'; |
| 218 | |
| 219 | const useMediaList = ( { |
| 220 | search = '', |
| 221 | status = DEFAULT_STATUSES, |
| 222 | tag = 0, |
| 223 | orderby = 'date', |
| 224 | order = 'desc', |
| 225 | page = 1, |
| 226 | perPage = 25, |
| 227 | }: UseMediaListParams = {} ): UseMediaListReturn => { |
| 228 | const [ items, setItems ] = useState< MediaItem[] >( [] ); |
| 229 | const [ totalItems, setTotalItems ] = useState( 0 ); |
| 230 | const [ totalPages, setTotalPages ] = useState( 0 ); |
| 231 | const [ counts, setCounts ] = useState< MediaListCounts >( EMPTY_COUNTS ); |
| 232 | const [ allTags, setAllTags ] = useState< MediaTag[] >( [] ); |
| 233 | const [ loading, setLoading ] = useState( true ); |
| 234 | const [ error, setError ] = useState< string | null >( null ); |
| 235 | const [ hasLoadedOnce, setHasLoadedOnce ] = useState( false ); |
| 236 | // Bump to force a refetch with identical params (e.g. after a mutation). |
| 237 | const [ refetchKey, setRefetchKey ] = useState( 0 ); |
| 238 | const abortRef = useRef< AbortController | null >( null ); |
| 239 | |
| 240 | const refetch = useCallback( () => { |
| 241 | setRefetchKey( ( k ) => k + 1 ); |
| 242 | }, [] ); |
| 243 | |
| 244 | useEffect( () => { |
| 245 | if ( abortRef.current ) { |
| 246 | abortRef.current.abort(); |
| 247 | } |
| 248 | const controller = new AbortController(); |
| 249 | abortRef.current = controller; |
| 250 | |
| 251 | ( async () => { |
| 252 | try { |
| 253 | setLoading( true ); |
| 254 | // `parse: false` lets us own pagination headers; the trade-off |
| 255 | // is that apiFetch THROWS the raw Response on non-2xx instead |
| 256 | // of resolving it, so the error envelope is recovered in the |
| 257 | // catch block via `messageFromRejection`. |
| 258 | const response = ( await apiFetch( { |
| 259 | path: addQueryArgs( ENDPOINT, { |
| 260 | search, |
| 261 | status, |
| 262 | tag, |
| 263 | orderby, |
| 264 | order, |
| 265 | page, |
| 266 | per_page: perPage, |
| 267 | } ), |
| 268 | parse: false, |
| 269 | signal: controller.signal, |
| 270 | } ) ) as Response; |
| 271 | |
| 272 | const json = await readJsonBody( response ); |
| 273 | |
| 274 | if ( controller.signal.aborted ) { |
| 275 | return; |
| 276 | } |
| 277 | |
| 278 | setItems( |
| 279 | Array.isArray( json?.items ) ? json.items.map( normalizeItem ) : [] |
| 280 | ); |
| 281 | setTotalItems( Number( json?.total ) || 0 ); |
| 282 | setTotalPages( Number( json?.total_pages ) || 0 ); |
| 283 | setCounts( { ...EMPTY_COUNTS, ...( json?.counts || {} ) } ); |
| 284 | setAllTags( normalizeTags( json?.all_tags ) ); |
| 285 | setError( null ); |
| 286 | } catch ( err ) { |
| 287 | if ( isAbort( err ) || controller.signal.aborted ) { |
| 288 | return; |
| 289 | } |
| 290 | const message = await messageFromRejection( err ); |
| 291 | setError( message ); |
| 292 | setItems( [] ); |
| 293 | setTotalItems( 0 ); |
| 294 | setTotalPages( 0 ); |
| 295 | } finally { |
| 296 | if ( ! controller.signal.aborted ) { |
| 297 | setLoading( false ); |
| 298 | setHasLoadedOnce( true ); |
| 299 | } |
| 300 | } |
| 301 | } )(); |
| 302 | |
| 303 | return () => controller.abort(); |
| 304 | }, [ search, status, tag, orderby, order, page, perPage, refetchKey ] ); |
| 305 | |
| 306 | return { |
| 307 | items, |
| 308 | totalItems, |
| 309 | totalPages, |
| 310 | counts, |
| 311 | allTags, |
| 312 | loading, |
| 313 | error, |
| 314 | hasLoadedOnce, |
| 315 | refetch, |
| 316 | }; |
| 317 | }; |
| 318 | |
| 319 | export default useMediaList; |
| 320 |