/**
* 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;
};