/** * WordPress dependencies */ import { useInstanceId } from '@safe-wordpress/compose'; import { BaseControl, FormTokenField } from '@safe-wordpress/components'; import { store as CORE } from '@safe-wordpress/core-data'; import { useSelect } from '@safe-wordpress/data'; import { useEffect, useMemo, useRef, useState } from '@safe-wordpress/element'; import { sprintf, _x } from '@safe-wordpress/i18n'; import type { GetRecordsHttpQuery } from '@safe-wordpress/core-data'; /** * External dependencies */ import clsx from 'clsx'; import { castArray, isString, debounce, isEqual, uniqBy } from 'lodash'; import type { Dict, Maybe, PostId, TermId, UserId } from '@nelio-content/types'; /** * Internal dependencies */ import './style.scss'; import { useIsKindReady as useIsProperKindReady, useEntityRecords as useProperEntityRecords, useEntityRecordSearch as useProperEntityRecordSearch, } from './hooks'; import type { Post, Term, User } from './hooks'; import { EMPTY_ARRAY } from '@nelio-content/constants'; type Item = { readonly id: number; readonly name: string; }; type FormValue = { readonly itemId: number; readonly value: string; readonly title: string; }; export type ItemSelectControlProps = | ValueProps< 'postType', string, PostId > | ValueProps< 'taxonomy', string, TermId > | ValueProps< 'author', '', UserId >; type ValueProps< TKind, TName, TValue > = | ( { readonly kind: TKind; readonly isSingle: true; readonly value: Maybe< TValue >; readonly onChange: ( value: Maybe< TValue > ) => void; } & ExtraProps & ( TName extends '' ? { readonly name?: never } : { readonly name: TName } ) ) | ( { readonly kind: TKind; readonly isSingle?: false; readonly value: ReadonlyArray< TValue >; readonly onChange: ( value: ReadonlyArray< TValue > ) => void; } & ExtraProps & ( TName extends '' ? { readonly name?: never } : { readonly name: TName } ) ); type ExtraProps = { readonly id?: string; readonly help?: string | false; readonly error?: string | false; readonly disabled?: boolean; readonly label?: string; readonly placeholder?: string; }; export const ItemSelectControl = ( { id, help, error, kind, name = '', value: originalValue, disabled, label, placeholder, isSingle, onChange, }: ItemSelectControlProps ): JSX.Element | null => { const instanceId = useInstanceId( ItemSelectControl ); const value: ReadonlyArray< number > = originalValue ? castArray( originalValue ) : []; const [ autoExpand, setAutoExpand ] = useState( false ); // NOTE. Workaround. const isKindReady = useIsKindReady( kind, name ); const hasSingleValue = isSingle && !! value.length; const { setQuery, items: foundItems, loadMoreItems, } = useSearchResult( kind, name, { exclude: value } ); const ref = useRef( null ); useEffectOnScrollEnd( ref.current, loadMoreItems ); useEffectOnFocusAndBlur( ref.current, setAutoExpand ); const onSelectionChange = ( selection: ReadonlyArray< unknown > ) => { const str = selection.find( isString ) ?? ''; const item = findByName( str, foundItems ); const newValue = [ ...selection, { itemId: item?.id } ] .filter( isFormValue ) .map( ( s ) => s.itemId ); if ( isSingle ) { // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument onChange( newValue[ 0 ] as any ); } else { // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument onChange( newValue as any ); } }; const currentRecords = useEntityRecords( kind, name, value ); const selectedItems = [ ...currentRecords.items.map( simplify ), ...( currentRecords.finished ? currentRecords.missingItems.map( makeMissingItem( kind ) ) : currentRecords.pendingItems.map( makeLoadingItem ) ), ]; const hasResults = useHasResults( kind, name ); if ( ! hasResults && ! selectedItems.length ) { return null; } placeholder = placeholder ?? _x( 'Search…', 'user', 'nelio-content' ); return ( { error } ) : ( help ) } >
p.name ) } onInputChange={ setQuery } onChange={ onSelectionChange } maxLength={ isSingle ? 1 : undefined } { ...{ label, __experimentalExpandOnFocus: autoExpand && ! hasSingleValue, placeholder: isKindReady ? placeholder : _x( 'Loading…', 'text', 'nelio-popups' ), } } />
); }; // ===== // HOOKS // ===== const useIsKindReady = ( kind: 'postType' | 'taxonomy' | 'author', name: string ) => useIsProperKindReady( 'author' === kind ? 'root' : kind, 'author' === kind ? 'user' : name ); const useEntityRecords = ( kind: 'postType' | 'taxonomy' | 'author', name: string, value: ReadonlyArray< number > ) => useProperEntityRecords( 'author' === kind ? 'root' : kind, 'author' === kind ? 'user' : name, value ); const useEntityRecordSearch = ( kind: 'postType' | 'taxonomy' | 'author', name: string, searchQuery: GetRecordsHttpQuery ) => useProperEntityRecordSearch( 'author' === kind ? 'root' : kind, 'author' === kind ? 'user' : name, 'author' === kind ? { ...searchQuery, who: 'authors' } : searchQuery ); const useSearchResult = ( kind: 'postType' | 'taxonomy' | 'author', name: string, searchQuery: GetRecordsHttpQuery ) => { const [ items, setItems ] = useState< ReadonlyArray< Item > >( [] ); const [ query, doSetQuery ] = useState( '' ); const [ page, setPage ] = useState( 1 ); const searchResult = useEntityRecordSearch( kind, name, { ...searchQuery, search: query, page, } ); const foundItems = searchResult.finished ? searchResult.items : EMPTY_ARRAY; useEffect( () => { if ( ! foundItems.length ) { return; } const cleanItems = foundItems.map( simplify ); const newItems = 1 === page ? cleanItems : uniqBy( [ ...items, ...cleanItems ], 'id' ); if ( isEqual( items, newItems ) ) { return; } setItems( newItems ); }, [ query, page, foundItems, items ] ); return { items: filterByQuery( { ...searchQuery, search: query }, items ), loadMoreItems: searchResult.finished && searchResult.more ? () => setPage( page + 1 ) : () => void null, setQuery: debounce( doSetQuery, 1000 ), }; }; const AUTHOR_QUERY = Object.freeze( { per_page: 10, context: 'view', search: '', page: 1, exclude: [], who: 'authors', } as const ); const DEFAULT_QUERY = Object.freeze( { per_page: 10, context: 'edit', search: '', page: 1, exclude: [], } as const ); const useHasResults = ( kind: 'postType' | 'taxonomy' | 'author', name: string ) => { const actualKind = kind === 'author' ? 'root' : kind; const actualName = kind === 'author' ? 'user' : name; const query = useMemo( () => ( kind === 'author' ? AUTHOR_QUERY : DEFAULT_QUERY ), [ kind ] ); return useSelect( ( select ) => { const { hasFinishedResolution, hasResolutionFailed, getEntityRecords, } = select( CORE ); const done = hasFinishedResolution( 'getEntityRecords', [ actualKind, actualName, query, ] ) || hasResolutionFailed( 'getEntityRecords', [ actualKind, actualName, query, ] ); const records = getEntityRecords( actualKind, actualName, query ); return done && !! records?.length; }, [ actualKind, actualName, query ] ); }; const useEffectOnScrollEnd = ( el: HTMLElement | null, callback: () => void ) => useEffect( () => { if ( ! el ) { return; } const onScroll = debounce( ( ev: Event ) => ev.target && isBottomScroll( ev.target as HTMLElement ) && callback(), 200 ); const opts = { capture: true }; el.addEventListener( 'scroll', onScroll, opts ); return () => el.removeEventListener( 'scroll', onScroll, opts ); }, [ callback, el ] ); const useEffectOnFocusAndBlur = ( el: HTMLElement | null, callback: ( expand: boolean ) => void ) => useEffect( () => { if ( ! el ) { return; } const onFocus = () => callback( true ); const onBlur = () => callback( false ); const opts = { capture: true }; el.addEventListener( 'focus', onFocus, opts ); el.addEventListener( 'blur', onBlur, opts ); return () => { el.removeEventListener( 'focus', onFocus, opts ); el.removeEventListener( 'blur', onBlur, opts ); }; }, [ callback, el ] ); // ======= // HELPERS // ======= const makeMissingItem = ( kind: 'postType' | 'taxonomy' | 'author' ): ( ( itemId: number ) => Item ) => { switch ( kind ) { case 'postType': return ( itemId: number ): Item => ( { id: itemId, name: sprintf( /* translators: %d: Item id. */ _x( 'Missing content %d', 'text', 'nelio-content' ), itemId ), } ); case 'taxonomy': return ( itemId: number ): Item => ( { id: itemId, name: sprintf( /* translators: %d: Item id. */ _x( 'Missing term %d', 'text', 'nelio-content' ), itemId ), } ); case 'author': return ( itemId: number ): Item => ( { id: itemId, name: sprintf( /* translators: %d: Item id. */ _x( 'Missing author %d', 'text', 'nelio-content' ), itemId ), } ); } }; const makeLoadingItem = ( itemId: number ): Item => ( { id: itemId, name: sprintf( /* translators: %d: Id. */ _x( 'Loading %d…', 'text', 'nelio-content' ), itemId ), } ); function filterByQuery< T extends Item >( searchQuery: GetRecordsHttpQuery, items: ReadonlyArray< T > ): ReadonlyArray< T > { if ( searchQuery.search ) { const { search } = searchQuery; items = items.filter( ( item ) => item.name.toLowerCase().includes( search.toLowerCase() ) ); } if ( searchQuery.exclude ) { const { exclude } = searchQuery; items = items.filter( ( { id } ) => ! exclude.includes( id ) ); } return items; } const findByName = ( name: string, items: ReadonlyArray< Item > ): Maybe< Item > => items.find( ( item ) => item.name.toLowerCase() === name.toLowerCase() ); const itemToFormValue = ( item: Item ): FormValue => ( { itemId: item.id, value: item.name, title: item.name, } ); const isFormValue = ( p: unknown ): p is FormValue => !! p && !! ( p as Dict ).itemId; const getName = ( item: Post | Term | User ) => { const isPost = ( i: Post | Term | User ): i is Post => ! ( i as Dict ).name; return isPost( item ) ? item.title.raw : item.name; }; const simplify = ( item: Post | Term | User ): Item => ( { id: item.id, name: sprintf( '%1$s (%2$d)', getName( item ).replace( /,/g, '' ), item.id ), } ); const isBottomScroll = ( el: HTMLElement ) => el.scrollHeight - el.scrollTop === el.clientHeight;