/** * WordPress dependencies */ import { FormTokenField } from '@safe-wordpress/components'; import { useSelect } from '@safe-wordpress/data'; import { useCallback, useEffect, useRef, useState, } from '@safe-wordpress/element'; import { sprintf, _x } from '@safe-wordpress/i18n'; import type { TokenItem } from '@wordpress/components/build-types/form-token-field/types'; /** * External dependencies */ import clsx from 'clsx'; import { truncate, noop, isString, debounce, difference, map } from 'lodash'; import { store as NAB_DATA } from '@nab/data'; import { EMPTY_ARRAY, isDefined } from '@nab/utils'; import type { RefObject } from 'react'; import type { EntityId, EntityInstance, EntityKindName, Maybe, } from '@nab/types'; /** * Internal dependencies */ import './style.scss'; import { useOptionLoader } from '../utils'; import type { PostSearcherProps, OptionLoaderProps, OptionResults, } from '../types'; export const MultiplePostSearcher = ( { className, disabled, type, value, onChange, ...loaderProps }: PostSearcherProps< ReadonlyArray< EntityId > > ): JSX.Element => { const [ autoExpand, setAutoExpand ] = useState( false ); // HACK. Workaround. const { setQuery, items: foundItems, loadMoreItems, } = useSearchResult( type, loaderProps ); const ref = useRef< HTMLDivElement >( null ); useEffectOnScrollEnd( ref, loadMoreItems ); useEffectOnFocusAndBlur( ref, setAutoExpand ); const onSelectionChange = ( selection: ReadonlyArray< TokenItem | string > ): void => { const str = selection.find( isString ) ?? ''; const item = findByName( str, foundItems ); onChange( [ ...selection, { itemId: item?.id } ] .filter( hasItemId ) .map( ( s ) => s.itemId ) ); }; const currentRecords = useEntityRecords( type, value ); const selectedItems = [ ...currentRecords.items.map( simplify ), ...( currentRecords.finished ? currentRecords.missingItems.map( makeMissingItem ) : currentRecords.pendingItems.map( makeLoadingItem ) ), ]; return (
! selectedItems.some( ( si ) => si.id === fi.id ) ) .map( ( p ) => p.name ) } onInputChange={ setQuery } onChange={ onSelectionChange } { ...{ __experimentalExpandOnFocus: autoExpand, placeholder: _x( 'Search', 'command', 'nelio-popups' ), } } />
); }; // ===== // HOOKS // ===== const useEntityRecords = ( type: Maybe< EntityKindName >, ids: ReadonlyArray< EntityId > ): | { readonly finished: false; readonly items: ReadonlyArray< EntityInstance >; readonly pendingItems: ReadonlyArray< EntityId >; } | { readonly finished: true; readonly items: ReadonlyArray< EntityInstance >; readonly missingItems: ReadonlyArray< EntityId >; } => { const normalizedIds = normalizeIds( ids ); const items = useSelect( ( select ) => restoreOriginalIds( normalizedIds ) .map( ( id ) => type ? select( NAB_DATA ).getEntityRecord( type, id ) : undefined ) .filter( isDefined ), [ type, normalizedIds ] ); const missingIds = difference( ids, map( items, 'id' ) ); const normalizedMissingIds = normalizeIds( missingIds ); const finished = useSelect( ( select ) => { const { hasFinishedResolution, hasResolutionFailed } = select( NAB_DATA ); const done = ( id: EntityId ) => hasFinishedResolution( 'getEntityRecord', [ type, id ] ) || hasResolutionFailed( 'getEntityRecord', [ type, id ] ); return restoreOriginalIds( normalizedMissingIds ).every( done ); }, [ type, normalizedMissingIds ] ); return finished ? { finished, items, missingItems: missingIds } : { finished, items, pendingItems: missingIds }; }; type NormalizedIds = string & { _type: 'NormalizedIds' }; const normalizeIds = ( ids: ReadonlyArray< EntityId > = [] ): NormalizedIds => JSON.stringify( ids ) as NormalizedIds; function restoreOriginalIds( normalizedIds: NormalizedIds ): ReadonlyArray< EntityId > { try { return JSON.parse( normalizedIds ) as ReadonlyArray< EntityId >; } catch ( _ ) { return []; } } type QueryResults = { readonly options: OptionResults[ 'options' ]; readonly page: number; readonly hasMore: boolean; readonly lastRequest: Maybe< Promise< unknown > >; }; const DEFAULT_QUERY_RESULTS: QueryResults = { options: EMPTY_ARRAY, page: 1, hasMore: true, lastRequest: undefined, }; const useSearchResult = ( type: Maybe< EntityKindName >, loaderProps: Omit< OptionLoaderProps, 'type' > ): { setQuery: ( v: string ) => void; items: ReadonlyArray< Item >; loadMoreItems?: () => void; } => { const [ query, doSetQuery ] = useState( '' ); const [ queryResults, setQueryResults ] = useState< Record< string, QueryResults > >( {} ); const loadOptions = useOptionLoader( { type: type ?? 'post', ...loaderProps, } ); const currentQueryResults: QueryResults = queryResults[ query ] ?? DEFAULT_QUERY_RESULTS; const loadMoreItems = useCallback( () => ( { ...queryResults, [ query ]: { ...currentQueryResults, lastRequest: loadOptions( query, null, { page: currentQueryResults.page, } ).then( ( r ) => void setQueryResults( { ...queryResults, [ query ]: { options: [ ...currentQueryResults.options, ...r.options, ], page: r.additional.page, hasMore: r.hasMore, lastRequest: undefined, }, } ) ), }, } ), [ currentQueryResults, query, queryResults, loadOptions ] ); // Run first load. useEffect( () => { if ( !! queryResults[ query ] ) { return; } loadMoreItems(); }, [ query, queryResults, loadMoreItems ] ); if ( ! type ) { return { items: [], setQuery: debounce( doSetQuery, 1000 ) }; } return { items: currentQueryResults.options.map( ( { value, label } ) => ( { id: value, name: sprintf( '%1$s (#%2$d)', label, value ), } ) ), loadMoreItems: !! queryResults[ query ]?.hasMore ? loadMoreItems : undefined, setQuery: debounce( doSetQuery, 1000 ), }; }; const useEffectOnScrollEnd = ( ref: RefObject< HTMLDivElement | null >, callback = noop ) => { const current = ref.current; useEffect( () => { if ( ! current ) { return; } const onScroll = debounce( ( ev: Event ) => ev.target && isBottomScroll( ev.target as HTMLElement ) && callback(), 200 ); const opts = { capture: true }; current.addEventListener( 'scroll', onScroll, opts ); return () => current.removeEventListener( 'scroll', onScroll, opts ); }, [ callback, current ] ); }; const useEffectOnFocusAndBlur = ( ref: RefObject< HTMLDivElement | null >, callback: ( focus: boolean ) => void = noop ) => { const current = ref.current; useEffect( () => { if ( ! current ) { return; } const onFocus = () => callback( true ); const onBlur = () => callback( false ); const opts = { capture: true }; current.addEventListener( 'focus', onFocus, opts ); current.addEventListener( 'blur', onBlur, opts ); return () => { current.removeEventListener( 'focus', onFocus, opts ); current.removeEventListener( 'blur', onBlur, opts ); }; }, [ callback, current ] ); }; // ======= // HELPERS // ======= const makeMissingItem = ( itemId: EntityId ): Item => ( { id: itemId, /* translators: %d: Item id. */ name: sprintf( _x( 'Missing item %d', 'text', 'nelio-popups' ), itemId ), } ); const makeLoadingItem = ( itemId: EntityId ): Item => ( { id: itemId, name: sprintf( /* translators: %d: Id. */ _x( 'Loading item %d…', 'text', 'nelio-popups' ), itemId ), } ); const findByName = ( name: string, items: ReadonlyArray< Item > ): Item | undefined => items.find( ( item ) => item.name.toLowerCase() === name.toLowerCase() ); const itemToFormValue = ( item: Item ): TokenItem => ( { itemId: item.id, value: item.name, title: item.name, } ) as TokenItem; // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access const hasItemId = ( p: any ): p is { itemId: EntityId } => !! p.itemId; const getName = ( item: EntityInstance ): string => { // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access const hasName = ( i: any ): i is { name: string } => !! i.name; return hasName( item ) ? item.name : item.title; }; const simplify = ( item: EntityInstance ): Item => ( { id: item.id, name: sprintf( '%1$s (#%2$d)', truncate( getName( item ), { length: 20, omission: '…' } ).replace( /,/g, '' ), item.id ), } ); const isBottomScroll = ( el: HTMLElement ): boolean => el.scrollHeight - el.scrollTop === el.clientHeight; // ============ // HELPER TYPES // ============ type Item = { readonly id: EntityId; readonly name: string; };