Back

100% Statements 39/39
100% Branches 18/18
100% Functions 12/12
100% Lines 34/34

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129          100x                 4x 102x                                                                                     128x       128x         128x   128x 52x 48x   4x   4x 4x 4x 1x           3x 3x 18x   2x     3x   4x     128x 45x     128x   5x 5x 5x         128x           128x 102x 102x 612x   50x     102x     128x    
import { useCallback, useEffect, useMemo, useRef, useState } from '@wordpress/element';
import type { LibraryItem, VideoDetailsPatch } from '../../types/library';
 
export type VideoDetailsFormValues = Required< VideoDetailsPatch >;
 
const baseline = ( video: LibraryItem ): VideoDetailsFormValues => ( {
	title: video.title,
	description: video.description,
	privacy: video.privacy,
	displayEmbed: video.displayEmbed,
	allowDownloads: video.allowDownloads,
	rating: video.rating,
} );
 
const shallowEqual = ( a: VideoDetailsFormValues, b: VideoDetailsFormValues ): boolean =>
	a.title === b.title &&
	a.description === b.description &&
	a.privacy === b.privacy &&
	a.displayEmbed === b.displayEmbed &&
	a.allowDownloads === b.allowDownloads &&
	a.rating === b.rating;
 
/**
 * Local form state for the Video details screen. One `update(partial)`
 * callback drives all fields. `isDirty` is true when current values diverge
 * from the most-recent baseline (initial mount or last `reset()`).
 *
 * If `video.id` changes (user navigates between details pages), state
 * re-baselines to the new video's values — unless `preserveDirtyOnRebind` is
 * set, in which case fields the user has edited survive the swap. The upload
 * flow's draft session needs that: it hands the form a synthetic record while
 * the file uploads and the real one once it settles, and the id change
 * between the two must not discard a half-typed title. The baseline still
 * moves to the new record, so the kept edits read as dirty (unsaved) against
 * it, which is exactly what they are.
 *
 * `initialDraft` is the same edits arriving from OUTSIDE the mount: the
 * upload's queue row carries them across the handoff to `/video/:id` and
 * across the remounts the tab order causes mid-flow. Read once, at mount, and
 * seeded over the baseline rather than into it — so the carried fields read as
 * unsaved against the server record, which is what they are.
 *
 * @param video                         - The video record to edit.
 * @param options                       - Hook options.
 * @param options.preserveDirtyOnRebind - Keep user-edited fields across an id change.
 * @param options.initialDraft          - Dirty-field diff to seed at mount.
 * @return Form-state controls.
 */
export function useVideoDetailsForm(
	video: LibraryItem,
	{
		preserveDirtyOnRebind = false,
		initialDraft,
	}: {
		preserveDirtyOnRebind?: boolean;
		initialDraft?: Partial< VideoDetailsFormValues >;
	} = {}
) {
	const [ values, setValues ] = useState< VideoDetailsFormValues >( () => ( {
		...baseline( video ),
		...initialDraft,
	} ) );
	const [ base, setBase ] = useState< VideoDetailsFormValues >( () => baseline( video ) );
 
	// The rebind effect must not fire on mount: the state above is already
	// baselined, and re-running it there would wipe an `initialDraft` before
	// the first paint.
	const boundIdRef = useRef( video.id );
 
	useEffect( () => {
		if ( boundIdRef.current === video.id ) {
			return;
		}
		boundIdRef.current = video.id;
 
		const next = baseline( video );
		setValues( prev => {
			if ( ! preserveDirtyOnRebind ) {
				return next;
			}
			// A field counts as the user's when it diverges from the OUTGOING
			// baseline (`base` here predates this effect's setBase below).
			// Untouched fields take the new record's values, so a title the
			// user never edited follows the server's, not the draft's.
			const merged: VideoDetailsFormValues = { ...next };
			for ( const key of Object.keys( next ) as ( keyof VideoDetailsFormValues )[] ) {
				if ( prev[ key ] !== base[ key ] ) {
					// Generic per-key writes need the loosened record view.
					( merged as Record< keyof VideoDetailsFormValues, unknown > )[ key ] = prev[ key ];
				}
			}
			return merged;
		} );
		setBase( next );
	}, [ video.id ] );
 
	const update = useCallback( ( partial: Partial< VideoDetailsFormValues > ) => {
		setValues( prev => ( { ...prev, ...partial } ) );
	}, [] );
 
	const reset = useCallback(
		( next?: VideoDetailsFormValues ) => {
			const target = next ?? base;
			setValues( target );
			setBase( target );
		},
		[ base ]
	);
 
	const isDirty = useMemo( () => ! shallowEqual( values, base ), [ values, base ] );
 
	// The same divergence `isDirty` reports, itemised: the fields the user owns
	// right now. Written through to the upload's queue row so the edit survives
	// the handoff and the mid-flow remounts; memoized on the two state values so
	// a consumer effect keyed on it doesn't fire on unrelated renders.
	const dirtyValues = useMemo( () => {
		const diff: Partial< VideoDetailsFormValues > = {};
		for ( const key of Object.keys( values ) as ( keyof VideoDetailsFormValues )[] ) {
			if ( values[ key ] !== base[ key ] ) {
				// Generic per-key writes need the loosened record view.
				( diff as Record< keyof VideoDetailsFormValues, unknown > )[ key ] = values[ key ];
			}
		}
		return diff;
	}, [ values, base ] );
 
	return { values, update, isDirty, dirtyValues, reset };
}