/** * WordPress dependencies */ import { useCallback, useEffect, useRef, useState, } from '@safe-wordpress/element'; import { BaseControl, FormTokenField } from '@safe-wordpress/components'; import { useInstanceId } from '@safe-wordpress/compose'; import { sprintf, _x } from '@safe-wordpress/i18n'; import type { Post } from '@safe-wordpress/core-data'; import type { TokenItem } from '@wordpress/components/build-types/form-token-field/types'; import type { RefObject } from 'react'; /** * External dependencies */ import clsx from 'clsx'; import { noop, isString, debounce, isEqual, uniqBy } from 'lodash'; import type { EntityId, TermId } from '@nab/types'; /** * Internal dependencies */ import './style.scss'; import { useEntityKind, useEntityRecords, useEntityRecordSearch, } from './hooks'; import type { SearchQuery, Term } from './types'; import { EMPTY_ARRAY } from '@nab/utils'; type Kind = 'postType' | 'taxonomy'; type ItemId< K extends Kind > = K extends 'postType' ? EntityId : TermId; type Item< K extends Kind > = { readonly id: ItemId< K >; readonly name: string; }; export type ItemSelectControlProps< K extends Kind > = { readonly className?: string; readonly kind: K; readonly name: string; readonly label?: string; readonly placeholder?: string; readonly value: ReadonlyArray< ItemId< K > >; readonly onChange: ( value: ReadonlyArray< ItemId< K > > ) => void; readonly disabled?: boolean; readonly isSingle?: boolean; }; export const ItemSelectControl = < K extends Kind >( { className, kind, name, label, placeholder, value, disabled, isSingle, onChange, }: ItemSelectControlProps< K > ): JSX.Element => { const instanceId = useInstanceId( ItemSelectControl ); const [ autoExpand, setAutoExpand ] = useState( false ); // HACK. Workaround. const actualKind = useEntityKind( kind, name ); const hasSingleValue = isSingle && !! value.length; const { setQuery, items: foundItems, loadMoreItems, } = useSearchResult( kind, name, { exclude: value } ); const ref = useRef< HTMLDivElement >( null ); useEffectOnScrollEnd( ref, loadMoreItems ); useEffectOnFocusAndBlur( ref, setAutoExpand ); const hasItemId = ( p: string | TokenItem | { itemId?: ItemId< K > } | undefined ): p is { itemId: ItemId< K > } => 'object' === typeof p && 'itemId' in p && !! p.itemId; const onSelectionChange = ( selection: ReadonlyArray< TokenItem | string > ): void => { const str = selection.find( isString ) ?? ''; const item = findByName( str, foundItems ); setQuery( '' ); onChange( [ ...selection, { itemId: item?.id } ] .filter( hasItemId ) .map( ( s ) => s.itemId ) ); }; const currentRecords = useEntityRecords( kind, name, value ); const selectedItems = [ ...currentRecords.items.map( simplify( kind ) ), ...( currentRecords.finished ? currentRecords.missingItems.map( makeMissingItem( kind ) ) : currentRecords.pendingItems.map( makeLoadingItem( kind ) ) ), ]; const Wrapper = !! label ? BaseControl : EmptyBaseControl; return (
p.name ) } onInputChange={ setQuery } onChange={ onSelectionChange } maxLength={ isSingle ? 1 : undefined } { ...{ label: !! label ? label : '', __experimentalShowHowTo: ! isSingle, __experimentalExpandOnFocus: autoExpand && ! hasSingleValue, placeholder: actualKind ? placeholder ?? _x( 'Search', 'command', 'nelio-ab-testing' ) : _x( 'Loading…', 'text', 'nelio-ab-testing' ), } } />
); }; const EmptyBaseControl = ( { children }: { children: JSX.Element } ) => children; // ===== // HOOKS // ===== const useSearchResult = < K extends Kind >( kind: K, name: string, searchQuery: SearchQuery ): { setQuery: ( v: string ) => void; items: ReadonlyArray< Item< K > >; loadMoreItems?: () => void; } => { const [ serverQuery, doSetServerQuery ] = useState( '' ); const [ liveQuery, setLiveQuery ] = useState( '' ); const setServerQuery = useCallback( ( ...args: Parameters< typeof doSetServerQuery > ) => debounce( doSetServerQuery, 1000 )( ...args ), [ doSetServerQuery ] ); const [ items, setItems ] = useState< ReadonlyArray< Item< K > > >( [] ); const [ page, setPage ] = useState( 1 ); const searchResult = useEntityRecordSearch( kind, name, { ...searchQuery, search: serverQuery, page, nelio_popups_search_by_title: true, } ); const stringifiedQuery = searchQuery.exclude?.join( ',' ) ?? ''; const foundItems = searchResult.finished ? searchResult.items : EMPTY_ARRAY; useEffect( () => { if ( ! foundItems.length ) { return; } const cleanItems = foundItems.map( simplify( kind ) ); const newItems = 1 === page ? cleanItems : uniqBy( [ ...items, ...cleanItems ], 'id' ); if ( isEqual( items, newItems ) ) { return; } setItems( newItems ); }, [ kind, items, foundItems, page, serverQuery, stringifiedQuery ] ); return { items: filterByQuery( kind, { ...searchQuery, search: liveQuery }, items ), loadMoreItems: searchResult.finished && searchResult.more ? () => setPage( page + 1 ) : undefined, setQuery: ( query: string ) => { setLiveQuery( query ); setServerQuery( query ); }, }; }; const useEffectOnScrollEnd = ( ref: RefObject< HTMLDivElement | null >, callback = noop ) => useEffect( () => { const el = ref.current; 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, ref ] ); const useEffectOnFocusAndBlur = ( ref: RefObject< HTMLDivElement | null >, callback: ( focus: boolean ) => void = noop ) => useEffect( () => { const el = ref.current; 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, ref ] ); // ======= // HELPERS // ======= const makeMissingItem = < K extends Kind >( _: K ) => ( itemId: ItemId< K > ): Item< K > => ( { id: itemId, name: sprintf( /* translators: %d: Item id. */ _x( 'Unknown (ID: %d)', 'text', 'nelio-ab-testing' ), itemId ), } ); const makeLoadingItem = < K extends Kind >( _: K ) => ( itemId: ItemId< K > ): Item< K > => ( { id: itemId, name: sprintf( /* translators: %d: Item id. */ _x( 'Loading… (ID: %d)', 'text', 'nelio-ab-testing' ), itemId ), } ); const filterByQuery = < K extends Kind >( _: K, searchQuery: SearchQuery, items: ReadonlyArray< Item< K > > ): ReadonlyArray< Item< K > > => { if ( searchQuery.search ) { const { search } = searchQuery; items = items.filter( ( item ) => item.name .replace( / \(ID: [0-9]+\)$/, '' ) .toLowerCase() .includes( search.toLowerCase() ) ); } if ( searchQuery.exclude ) { const { exclude } = searchQuery; items = items.filter( ( { id } ) => ! exclude.includes( id ) ); } return items; }; const findByName = < K extends Kind >( name: string, items: ReadonlyArray< Item< K > > ): Item< K > | undefined => items.find( ( item ) => item.name.toLowerCase() === name.toLowerCase() ); const itemToFormValue = ( item: Item< Kind > ): TokenItem => ( { itemId: item.id, value: item.name, title: item.name, } ) as TokenItem; const getName = ( item: Post | Term ): 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.raw; }; const simplify = < K extends Kind >( _: K ) => ( item: K extends 'postType' ? Post : Term ): Item< K > => ( { // @ts-expect-error Valid ID id: item.id, name: sprintf( '%1$s (ID: %2$d)', getName( item ).replace( /,/g, '' ), item.id ), } ); const isBottomScroll = ( el: HTMLElement ): boolean => el.scrollHeight - el.scrollTop === el.clientHeight;