From d1b80059acccc9e4c2c6f8629afb9e8e2ddf6348 Mon Sep 17 00:00:00 2001 From: Guido Scialfa Date: Thu, 11 Apr 2024 18:33:45 +0200 Subject: [PATCH] First step for Preset Post Types --- @types/index.d.ts | 10 +- docs/api.md | 14 ++ docs/components.md | 49 ++++++ .../src/api/create-search-entities-options.ts | 19 +++ .../client/src/api/search-entities-options.ts | 36 ++++ sources/client/src/api/search-entities.ts | 2 +- .../components/preset-entities-by-kind.tsx | 158 ++++++++++++++++++ sources/client/src/index.ts | 3 + .../convert-entities-to-control-options.ts | 2 +- .../js/post-types-example-block/index.js | 87 ++++------ .../js/taxonomies-example-block/index.js | 93 ++++------- .../preset-posts-types.markup.test.tsx | 151 +++++++++++++++++ .../create-search-entities-options.test.ts | 33 ++++ tests/client/unit/api/search-posts.test.ts | 131 +++++++++++++++ .../preset-posts-types.markup.test.tsx.snap | 35 ++++ .../components/preset-posts-types.test.tsx | 89 ++++++++++ 16 files changed, 793 insertions(+), 119 deletions(-) create mode 100644 sources/client/src/api/create-search-entities-options.ts create mode 100644 sources/client/src/api/search-entities-options.ts create mode 100644 sources/client/src/components/preset-entities-by-kind.tsx create mode 100644 tests/client/integration/components/preset-posts-types.markup.test.tsx create mode 100644 tests/client/unit/api/create-search-entities-options.test.ts create mode 100644 tests/client/unit/api/search-posts.test.ts create mode 100644 tests/client/unit/components/__snapshots__/preset-posts-types.markup.test.tsx.snap create mode 100644 tests/client/unit/components/preset-posts-types.test.tsx diff --git a/@types/index.d.ts b/@types/index.d.ts index 1f6b7fe..dddb2da 100644 --- a/@types/index.d.ts +++ b/@types/index.d.ts @@ -17,11 +17,12 @@ declare namespace EntitiesSearch { type Options = Set>; type Value = string | number; - interface QueryArguments + // TODO Can we convert QueryArguments to an Immutable Map? + interface QueryArguments extends Partial< Readonly<{ - exclude: Set; - include: Set; + exclude: Set; + include: Set; fields: EntitiesSearch.SearchQueryFields; [p: string]: unknown; }> @@ -34,6 +35,8 @@ declare namespace EntitiesSearch { url: string; type: string; subtype: string; + post_content: string; + post_excerpt: string; }> {} type SearchEntitiesFunction = ( @@ -99,6 +102,7 @@ declare namespace EntitiesSearch { /* * Api */ + // TODO Better to convert the SearchQueryFields to a Set. type SearchQueryFields = ReadonlyArray< keyof EntitiesSearch.SearchEntityFields >; diff --git a/docs/api.md b/docs/api.md index 32e1c07..653d861 100644 --- a/docs/api.md +++ b/docs/api.md @@ -72,3 +72,17 @@ NOTE: The function does not handle possible request exceptions. The `searchEntities` will automatically abort the requests with the same parameters if a new request is made before the previous one is completed. + + +## `searchEntitiesOptions` + +This function is a wrapper for the `searchEntites` which perform a small additional task you might want to do quite ofter after retrieving the entities that is +to convert the entities to `EntitiesSearch.ControlOptions`. The argument taken are the same of the `searchEntities`. + +### `createSearchEntitiesOptions` + +This is a factory function, it returns a `searchEntitiesOptions` function preconfigured to search for a specific *root* kind. + +For instance, if we call this function with `post` like `createSearchEntitiesOptions('post')` the returned function will be of type `searchEntitiesOptions` where you don't always have to pass the *root* type. + +Most probably this will be the function you'll deal with most as it make easy to build a function you can consume with other components. diff --git a/docs/components.md b/docs/components.md index a042558..3e2b22d 100644 --- a/docs/components.md +++ b/docs/components.md @@ -35,6 +35,10 @@ data down to the _Base Components_. - `CompositeEntitiesByKind` - A composite component that displays a list of entities by kind. In this context _kind_ is `post` or `term` and _entity_ is the entity type of the content e.g. a `page` or a `category` term. +## Preset Components + +- `PresetEntitiesByKind` - A top level component simplifying the DX with a preconfigured path of how to manage the data based on the context given. + ## Composite Entities by Kind The `CompositeEntitiesByKind` is a _generic_ component that can be used to display a list of entities by kind. It acts @@ -188,6 +192,51 @@ export function MyComponent(props) { Obviously depending on what you want to achieve you can use different _Base Components_ or create new ones, as mentioned above the package comes with a set of _Base Components_ that can be used out of the box. +## Preset Entities By Kind + +This component can be used for different scenarios since it allows you to pass all the necessary information to render the Selectors. + +It gets passed the `entitiesFinder` which is the function performing the request to retrieve the Entities, but also it gives you the freedom to decide which components to render as Controls UI. + +Below you can see how easy is to create a new control set compared to use the low level api `CompositeEntitiesByKind`. + +```typescript jsx +const entitiesFinder = createSearchEntitiesOptions( 'post' ); +const postTypesEntities = convertEntitiesToControlOptions(useQueryViewablePostTypes().records(), 'name', 'slug'); + +const props = { + entitiesFinder, + entities: new Set( [ 1, 2, 3 ] ), + onChangeEntities: (entities) => { + // Do something with the new set of entities + }, + entitiesComponent: ToggleControl, + kind: new Set(['post']), + kindOptions: stubControlOptionsSet(), + onChangeKind: (kinds) => { + // Do something with the new set of kinds + }, + kindComponent: ToggleControl +}; + +return (); +``` + +Therefore, the preset simplify and take care of some parts that you would have to configure otherwise. In the chapter below you can read the available properties. + +### Properties + +- `entitiesFinder` - The function which perform the search of the contextual entities. You can use the `createSearchEntitiesOptions` function by passing the `root` value such as `term` or `post`. +- `className` - For better customization you can pass your own custom classes. +- `entities` - A `EntitiesSearch.Entities` set of selected entities. For when you want some entities already selected. +- `onChangeEntities` - A task to perform when the selection change due to a user interaction. +- `entitiesComponent` - The component to use to render the control ui for the entities. +- `kind` - The predefined set of kind (e.g. post-types or taxonomies) you want to have already selected. +- `kindOptions` - A collection of `EntitiesSearch.ControlOption` among which the user can choose to retrieve the entities from. +- `onChangeKind` - A task to perform when the selection change due to a user interaction. +- `kindComponent` - The component to use to render the control ui for the kinds. +- `entitiesFields` - Additional fields you want to retrieve and have available within your `entitiesComponent` and `kindComponent`. For more info read the [Control Option](./control-option.md) documentation. + ## About Singular Base Components The _Composite Component_ always give a collection of Entities and Kind even though you are consuming a Single* _Base diff --git a/sources/client/src/api/create-search-entities-options.ts b/sources/client/src/api/create-search-entities-options.ts new file mode 100644 index 0000000..2105e52 --- /dev/null +++ b/sources/client/src/api/create-search-entities-options.ts @@ -0,0 +1,19 @@ +/** + * External dependencies + */ +import EntitiesSearch from '@types'; + +/** + * Internal dependencies + */ +import { Set } from '../models/set'; +import { searchEntitiesOptions } from './search-entities-options'; + +export function createSearchEntitiesOptions< E >( type: string ) { + return async ( + phrase: string, + postTypes: EntitiesSearch.Kind< string >, + queryArguments?: EntitiesSearch.QueryArguments + ): Promise< Set< EntitiesSearch.ControlOption< E > > > => + searchEntitiesOptions( type, phrase, postTypes, queryArguments ); +} diff --git a/sources/client/src/api/search-entities-options.ts b/sources/client/src/api/search-entities-options.ts new file mode 100644 index 0000000..d3caecc --- /dev/null +++ b/sources/client/src/api/search-entities-options.ts @@ -0,0 +1,36 @@ +/** + * External dependencies + */ +import EntitiesSearch from '@types'; + +/** + * Internal dependencies + */ +import { Set } from '../models/set'; +import { searchEntities } from './search-entities'; +import { convertEntitiesToControlOptions } from '../utils/convert-entities-to-control-options'; + +export async function searchEntitiesOptions< E >( + type: string, + phrase: string, + postTypes: EntitiesSearch.Kind< string >, + queryArguments?: EntitiesSearch.QueryArguments +): Promise< Set< EntitiesSearch.ControlOption< E > > > { + const postsEntities = + await searchEntities< EntitiesSearch.SearchEntityFields >( + type, + postTypes, + phrase, + queryArguments + ); + + const { fields = [] } = queryArguments ?? {}; + const [ label = 'title', value = 'id', ...extraFields ] = fields; + + return convertEntitiesToControlOptions( + postsEntities, + label, + value, + ...extraFields + ); +} diff --git a/sources/client/src/api/search-entities.ts b/sources/client/src/api/search-entities.ts index 58ec4c9..12e4ead 100644 --- a/sources/client/src/api/search-entities.ts +++ b/sources/client/src/api/search-entities.ts @@ -20,7 +20,7 @@ export async function searchEntities< E >( type: string, subtype: Set< string >, phrase: string, - queryArguments?: EntitiesSearch.QueryArguments< string > + queryArguments?: EntitiesSearch.QueryArguments ): Promise< Set< E > > { const { exclude, diff --git a/sources/client/src/components/preset-entities-by-kind.tsx b/sources/client/src/components/preset-entities-by-kind.tsx new file mode 100644 index 0000000..edf10af --- /dev/null +++ b/sources/client/src/components/preset-entities-by-kind.tsx @@ -0,0 +1,158 @@ +/** + * External dependencies + */ +import EntitiesSearch from '@types'; +import React, { JSX } from 'react'; +import classnames from 'classnames'; + +/** + * WordPress dependencies + */ +import { createHigherOrderComponent } from '@wordpress/compose'; + +/** + * Internal dependencies + */ +import { CompositeEntitiesByKind } from './composite-entities-by-kind'; +import { SearchControl } from './search-control'; +import { Set } from '../models/set'; + +type EntitiesValue = EntitiesSearch.Value; +type Entities = EntitiesSearch.Entities< EntitiesValue >; +type KindValue = EntitiesSearch.Kind< string > | string; + +type EntitiesFinder = ( + phrase: string, + kind: EntitiesSearch.Kind< string >, + queryArguments?: EntitiesSearch.QueryArguments +) => Promise< Set< EntitiesSearch.ControlOption< EntitiesValue > > >; + +type EntitiesComponent = React.ComponentType< + EntitiesSearch.BaseControl< EntitiesValue > +>; +type KindComponent = React.ComponentType< + EntitiesSearch.BaseControl< KindValue > +>; + +type PublicComponentProps = { + entitiesFinder: EntitiesFinder; + className?: string; + entities: Entities; + onChangeEntities: ( values: Entities ) => void; + entitiesComponent: EntitiesComponent; + kind: KindValue; + kindOptions: EntitiesSearch.Options< string >; + onChangeKind: ( values: KindValue ) => void; + kindComponent: KindComponent; + entitiesFields?: EntitiesSearch.QueryArguments[ 'fields' ]; +}; + +interface PrivateComponentProps + extends Pick< + EntitiesSearch.CompositeEntitiesKinds< EntitiesValue, string >, + 'kind' | 'entities' + > { + className?: string; + entitiesFinder: EntitiesFinder; + kindComponent: KindComponent; + entitiesComponent: EntitiesComponent; +} + +function PrivateComponent( props: PrivateComponentProps ): JSX.Element { + const className = classnames( 'wes-preset-entities-by-kind', { + // @ts-ignore + [ props.className ]: !! props.className, + } ); + + return ( +
+ + { ( _entities, _kind, search ) => ( + <> + + + + + ) } + +
+ ); +} + +const withDataBound = createHigherOrderComponent< + React.ComponentType< PrivateComponentProps >, + React.ComponentType< PublicComponentProps > +>( + ( Component ) => ( props ) => { + const { + kind, + entities, + onChangeEntities, + kindOptions, + onChangeKind, + entitiesFields, + kindComponent, + entitiesComponent, + entitiesFinder, + ..._props + } = props; + + const kindValue = narrowKindValue( props.kind ); + + const _entities = { + value: entities, + onChange: onChangeEntities, + }; + + const _kind = { + value: kindValue, + options: kindOptions, + onChange: onChangeKind, + }; + + const _entitiesFinder = entitiesFinderWithExtraFields( + entitiesFinder, + entitiesFields + ); + + return ( + + ); + }, + 'withDataBound' +); + +function entitiesFinderWithExtraFields( + entitiesFinder: EntitiesFinder, + entitiesFields?: EntitiesSearch.QueryArguments[ 'fields' ] +): EntitiesFinder { + return ( + phrase: string, + kind: EntitiesSearch.Kind< string >, + queryArguments?: EntitiesSearch.QueryArguments + ) => + entitiesFinder( phrase, kind, { + ...queryArguments, + fields: [ + ...( queryArguments?.fields ?? [ 'title', 'id' ] ), + ...( entitiesFields ?? [] ), + ], + } ); +} + +function narrowKindValue( value: KindValue ): EntitiesSearch.Kind< string > { + return typeof value === 'string' ? new Set( [ value ] ) : value; +} + +export const PresetEntitiesByKind = withDataBound( PrivateComponent ); diff --git a/sources/client/src/index.ts b/sources/client/src/index.ts index 222cb22..9a40e9d 100644 --- a/sources/client/src/index.ts +++ b/sources/client/src/index.ts @@ -1,7 +1,10 @@ export * from './api/search-entities'; +export * from './api/search-entities-options'; +export * from './api/create-search-entities-options'; export * from './components/composite-entities-by-kind'; export * from './components/plural-select-control'; +export * from './components/preset-entities-by-kind'; export * from './components/radio-control'; export * from './components/search-control'; export * from './components/singular-select-control'; diff --git a/sources/client/src/utils/convert-entities-to-control-options.ts b/sources/client/src/utils/convert-entities-to-control-options.ts index 798c3e8..58154dc 100644 --- a/sources/client/src/utils/convert-entities-to-control-options.ts +++ b/sources/client/src/utils/convert-entities-to-control-options.ts @@ -18,7 +18,7 @@ export function convertEntitiesToControlOptions< entities: Set< EntitiesFields >, labelKey: string, valueKey: string, - ...extraKeys: Array< string > + ...extraKeys: ReadonlyArray< string > ): Set< EntitiesSearch.ControlOption< V > > { return entities.map( ( entity ) => { const label = entity[ labelKey ]; diff --git a/sources/server/src/Modules/E2e/resources/js/post-types-example-block/index.js b/sources/server/src/Modules/E2e/resources/js/post-types-example-block/index.js index f92bbcb..ef5ce21 100644 --- a/sources/server/src/Modules/E2e/resources/js/post-types-example-block/index.js +++ b/sources/server/src/Modules/E2e/resources/js/post-types-example-block/index.js @@ -14,11 +14,13 @@ document.addEventListener('DOMContentLoaded', () => { SingularSelectControl, PluralSelectControl, RadioControl, + PresetEntitiesByKind, ToggleControl, SearchControl, CompositeEntitiesByKind, useQueryViewablePostTypes, convertEntitiesToControlOptions, + createSearchEntitiesOptions, } = wpEntitiesSearch; // TODO Check why the object form does not work. @@ -39,65 +41,38 @@ document.addEventListener('DOMContentLoaded', () => { return createElement(Spinner); } + const searchPosts = createSearchEntitiesOptions( 'post' ); + const PostPostTypesControllerElement = createElement( - CompositeEntitiesByKind, + PresetEntitiesByKind, { - // TODO Wrap around a throttle or debounce function - searchEntities: async ( - phrase, - postType, - queryArguments - ) => { - const postsEntities = await searchEntities( - 'post', - postType, - phrase, - queryArguments - ); - return convertEntitiesToControlOptions( - postsEntities, - 'title', - 'id' - ); - }, - entities: { - value: new Set(props.attributes.posts), - onChange: (posts) => - props.setAttributes({ posts: posts.toArray() }), - }, - kind: { - value: new Set(props.attributes.postType), - options: convertEntitiesToControlOptions( - postTypesEntities - .records() - .filter( - (record) => - !UNSUPPORTED_CPTS.includes(record.slug) - ), - 'name', - 'slug' + entitiesFinder: searchPosts, + entities: new Set(props.attributes.posts), + onChangeEntities: (posts) => + props.setAttributes({ posts: posts.toArray() }), + entitiesComponent: ToggleControl, + + kind: new Set(props.attributes.postType), + kindOptions: convertEntitiesToControlOptions( + postTypesEntities + .records() + .filter( + (record) => + !UNSUPPORTED_CPTS.includes(record.slug) ), - onChange: (postType) => - props.setAttributes({ - postType: postType.toArray(), - }), - }, - }, - (posts, type, search) => { - return [ - createElement(ToggleControl, { - ...type, - key: 'post-type-radio', - }), - createElement(SearchControl, { - onChange: search, - key: 'search', - }), - createElement(ToggleControl, { - ...posts, - key: 'posts-toggle', - }), - ]; + 'name', + 'slug' + ), + onChangeKind: (postType) => + props.setAttributes({ + postType: postType.toArray(), + }), + kindComponent: ToggleControl, + + entitiesFields: [ + 'title', + 'id', + ] } ); diff --git a/sources/server/src/Modules/E2e/resources/js/taxonomies-example-block/index.js b/sources/server/src/Modules/E2e/resources/js/taxonomies-example-block/index.js index a2e0a2a..9d4503a 100644 --- a/sources/server/src/Modules/E2e/resources/js/taxonomies-example-block/index.js +++ b/sources/server/src/Modules/E2e/resources/js/taxonomies-example-block/index.js @@ -10,11 +10,16 @@ document.addEventListener('DOMContentLoaded', () => { Set, searchEntities, SingularSelectControl, + PluralSelectControl, + RadioControl, + PresetEntitiesByKind, ToggleControl, SearchControl, CompositeEntitiesByKind, + useQueryViewablePostTypes, useQueryViewableTaxonomies, convertEntitiesToControlOptions, + createSearchEntitiesOptions, } = wpEntitiesSearch; // TODO Check why the object form does not work. @@ -35,68 +40,40 @@ document.addEventListener('DOMContentLoaded', () => { return createElement(Spinner); } + const entitiesFinder = createSearchEntitiesOptions( 'term' ); + const TermTaxonomiesControllerElement = createElement( - CompositeEntitiesByKind, - { - // TODO Wrap around a throttle or debounce function - searchEntities: async ( - phrase, - taxonomyName, - queryArguments - ) => { - const terms = await searchEntities( - 'term', - taxonomyName, - phrase, - queryArguments - ); - return convertEntitiesToControlOptions( - terms, - 'title', - 'id' - ); - }, - entities: { - value: new Set(props.attributes.terms), - onChange: (terms) => - props.setAttributes({ terms: terms?.toArray() }), - }, - kind: { - value: new Set(props.attributes.taxonomy), - options: convertEntitiesToControlOptions( - taxonomiesEntities.records(), - 'name', - 'slug' - ), - onChange: (taxonomy) => - props.setAttributes({ - taxonomy: taxonomy.toArray(), - }), - }, - }, - (terms, taxonomy, search) => { - return [ - createElement(SingularSelectControl, { - ...taxonomy, - value: taxonomy.value.first(), - key: 'taxonomy-radio', - }), - createElement(SearchControl, { - onChange: search, - key: 'search', - }), - createElement(ToggleControl, { - ...terms, - key: 'terms-toggle', - }), - ]; - } + PresetEntitiesByKind, + { + entitiesFinder: entitiesFinder, + entities: new Set(props.attributes.terms), + onChangeEntities: (entities) => + props.setAttributes({ terms: entities.toArray() }), + entitiesComponent: ToggleControl, + + kind: new Set(props.attributes.taxonomy), + kindOptions: convertEntitiesToControlOptions( + taxonomiesEntities.records(), + 'name', + 'slug' + ), + onChangeKind: (kind) => + props.setAttributes({ + taxonomy: kind.toArray(), + }), + kindComponent: ToggleControl, + + entitiesFields: [ + 'title', + 'id', + ] + } ); return createElement( - 'div', - blockProps, - TermTaxonomiesControllerElement + 'div', + blockProps, + TermTaxonomiesControllerElement ); }, save: () => null, diff --git a/tests/client/integration/components/preset-posts-types.markup.test.tsx b/tests/client/integration/components/preset-posts-types.markup.test.tsx new file mode 100644 index 0000000..4bb36cd --- /dev/null +++ b/tests/client/integration/components/preset-posts-types.markup.test.tsx @@ -0,0 +1,151 @@ +/** + * External dependencies + */ +import React from 'react'; +import { describe, expect, it, jest } from '@jest/globals'; +import { render, screen, act } from '@testing-library/react'; + +/** + * Internal dependencies + */ +import { Set } from '../../../../sources/client/src/models/set'; +import { createSearchEntitiesOptions } from '../../../../sources/client/src'; +import { ControlOption } from '../../../../sources/client/src/value-objects/control-option'; +import { PresetEntitiesByKind } from '../../../../sources/client/src/components/preset-entities-by-kind'; + +describe( 'Preset Entities By Kind', () => { + it( 'Should render the CompositeEntitiesByKind component with the appropriate configuration', async () => { + const entitiesFinder = + jest.fn< + ReturnType< typeof createSearchEntitiesOptions< string > > + >(); + + const props = { + entitiesFinder, + entities: new Set( [ 1, 2, 3 ] ), + onChangeEntities: jest.fn(), + entitiesComponent: () =>
EntitiesComponent
, + kind: 'post', + kindOptions: stubControlOptionsSet(), + onChangeKind: jest.fn(), + kindComponent: () =>
KindComponent
, + className: 'extra-class-name', + }; + + const rendered = await act( () => + render( ) + ); + + expect( rendered.asFragment() ).toMatchSnapshot(); + } ); + + it( 'Expect Kind Component to be rendered as first component', async () => { + const entitiesFinder = + jest.fn< + ReturnType< typeof createSearchEntitiesOptions< string > > + >(); + + const props = { + entitiesFinder, + entities: new Set( [ 1, 2, 3 ] ), + onChangeEntities: jest.fn(), + entitiesComponent: () => ( +
EntitiesComponent
+ ), + kind: 'post', + kindOptions: stubControlOptionsSet(), + onChangeKind: jest.fn(), + kindComponent: () => ( +
KindComponent
+ ), + }; + + const rendered = await act( () => + render( ) + ); + + const kindComponent = screen.getByTestId( 'kind-component' ); + const firstComponent = rendered.container.querySelector( + '.wes-preset-entities-by-kind' + ); + + expect( kindComponent === firstComponent?.firstElementChild ).toEqual( + true + ); + } ); + + it( 'Expect Entities Component to be rendered as last component', async () => { + const entitiesFinder = + jest.fn< + ReturnType< typeof createSearchEntitiesOptions< string > > + >(); + + const props = { + entitiesFinder, + entities: new Set( [ 1, 2, 3 ] ), + onChangeEntities: jest.fn(), + entitiesComponent: () => ( +
EntitiesComponent
+ ), + kind: 'post', + kindOptions: stubControlOptionsSet(), + onChangeKind: jest.fn(), + kindComponent: () => ( +
KindComponent
+ ), + }; + + const rendered = await act( () => + render( ) + ); + + const kindComponent = screen.getByTestId( 'entities-component' ); + const firstComponent = rendered.container.querySelector( + '.wes-preset-entities-by-kind' + ); + + expect( kindComponent === firstComponent?.lastElementChild ).toEqual( + true + ); + } ); + + it( 'Allows extra className property', async () => { + const entitiesFinder = + jest.fn< + ReturnType< typeof createSearchEntitiesOptions< string > > + >(); + + const props = { + entitiesFinder, + entities: new Set( [ 1, 2, 3 ] ), + onChangeEntities: jest.fn(), + entitiesComponent: () => ( +
EntitiesComponent
+ ), + kind: 'post', + kindOptions: stubControlOptionsSet(), + onChangeKind: jest.fn(), + kindComponent: () => ( +
KindComponent
+ ), + className: 'extra-class', + }; + + const rendered = await act( () => + render( ) + ); + + expect( + rendered.container + .querySelector( '.wes-preset-entities-by-kind' ) + ?.classList.contains( 'extra-class' ) + ).toEqual( true ); + } ); +} ); + +function stubControlOptionsSet() { + return new Set( [ + new ControlOption( 'Post', 'post' ), + new ControlOption( 'Page', 'page' ), + ] ); +} diff --git a/tests/client/unit/api/create-search-entities-options.test.ts b/tests/client/unit/api/create-search-entities-options.test.ts new file mode 100644 index 0000000..da1cce2 --- /dev/null +++ b/tests/client/unit/api/create-search-entities-options.test.ts @@ -0,0 +1,33 @@ +/** + * External dependencies + */ +import { describe, it, expect, jest } from '@jest/globals'; + +/** + * Internal dependencies + */ +import { Set } from '../../../../sources/client/src/models/set'; +import { searchEntitiesOptions } from '../../../../sources/client/src/api/search-entities-options'; +import { createSearchEntitiesOptions } from '../../../../sources/client/src/api/create-search-entities-options'; + +jest.mock( + '../../../../sources/client/src/api/search-entities-options', + () => ( { + searchEntitiesOptions: jest.fn(), + } ) +); + +describe( 'Create Search Entities Options', () => { + it( 'Should create search entities options and receive the correct parameters when called.', () => { + const _searchEntitiesOptions = createSearchEntitiesOptions( 'post' ); + + _searchEntitiesOptions( 'phrase', new Set( [ 'post' ] ) ); + + expect( searchEntitiesOptions ).toHaveBeenCalledWith( + 'post', + 'phrase', + new Set( [ 'post' ] ), + undefined + ); + } ); +} ); diff --git a/tests/client/unit/api/search-posts.test.ts b/tests/client/unit/api/search-posts.test.ts new file mode 100644 index 0000000..37465cc --- /dev/null +++ b/tests/client/unit/api/search-posts.test.ts @@ -0,0 +1,131 @@ +/** + * External dependencies + */ +import EntitiesSearch from '@types'; +import { faker } from '@faker-js/faker'; +import { describe, it, expect, jest } from '@jest/globals'; + +/** + * Internal dependencies + */ +import { Set } from '../../../../sources/client/src/models/set'; +import { searchEntities } from '../../../../sources/client/src/api/search-entities'; +import { searchEntitiesOptions } from '../../../../sources/client/src/api/search-entities-options'; +import { convertEntitiesToControlOptions } from '../../../../sources/client/src/utils/convert-entities-to-control-options'; + +jest.mock( '../../../../sources/client/src/api/search-entities', () => ( { + searchEntities: jest.fn(), +} ) ); + +describe( 'Search Entities Options', () => { + it( 'Should return a Set of Control Option with the title and id.', async () => { + const stubs = stubEntities(); + jest.mocked( searchEntities ).mockResolvedValue( stubs ); + const posts = await searchEntitiesOptions( + 'post', + 'Phrase', + new Set( [ 'post' ] ) + ); + + expect( posts.toArray() ).toEqual( + convertEntitiesToControlOptions( stubs, 'title', 'id' ).toArray() + ); + } ); + + it( 'Should return a Set of Control Option with the title and id and extra fields.', async () => { + const stubs = stubEntities(); + jest.mocked( searchEntities ).mockResolvedValue( stubs ); + const posts = await searchEntitiesOptions( + 'post', + 'Phrase', + new Set( [ 'post' ] ), + { + fields: [ + 'title', + 'id', + // @ts-ignore + 'slug', + 'post_content', + 'post_excerpt', + ], + } + ); + + expect( posts.toArray() ).toEqual( + convertEntitiesToControlOptions( + stubs, + 'title', + 'id', + 'slug', + 'post_content', + 'post_excerpt' + ).toArray() + ); + } ); + + it( 'Use the given label and value for the Control Options', async () => { + const stubs = new Set( [ + { + post_excerpt: faker.lorem.word(), + slug: faker.lorem.slug(), + }, + ] ); + + jest.mocked( searchEntities ).mockResolvedValue( stubs ); + + const posts = await searchEntitiesOptions( + 'post', + 'Phrase', + new Set( [ 'post' ] ), + { + // @ts-ignore + fields: [ 'post_excerpt', 'slug' ], + } + ); + + expect( posts.toArray() ).toEqual( + convertEntitiesToControlOptions( + stubs, + 'post_excerpt', + 'slug' + ).toArray() + ); + } ); + + it( 'Expect to call searchEntities with the given parameters', async () => { + const postTypes = new Set( [ 'post' ] ); + const phrase = 'Phrase'; + const fields: EntitiesSearch.SearchQueryFields = [ 'title', 'id' ]; + const queryArguments = { + fields, + }; + + jest.mocked( searchEntities ).mockResolvedValue( stubEntities() ); + + await searchEntitiesOptions( + 'post', + phrase, + postTypes, + queryArguments + ); + + expect( searchEntities ).toHaveBeenCalledWith( + 'post', + postTypes, + phrase, + queryArguments + ); + } ); +} ); + +function stubEntities(): Set< { id: number; title: string } > { + return new Set( + faker.helpers.multiple( + () => ( { + title: faker.lorem.word(), + id: faker.number.int(), + } ), + { count: 3 } + ) + ); +} diff --git a/tests/client/unit/components/__snapshots__/preset-posts-types.markup.test.tsx.snap b/tests/client/unit/components/__snapshots__/preset-posts-types.markup.test.tsx.snap new file mode 100644 index 0000000..d46da2f --- /dev/null +++ b/tests/client/unit/components/__snapshots__/preset-posts-types.markup.test.tsx.snap @@ -0,0 +1,35 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Preset Entities By Kind Should render the CompositeEntitiesByKind component with the appropriate configuration 1`] = ` + +
+
+ KindComponent +
+
+ +
+
+ EntitiesComponent +
+
+
+`; diff --git a/tests/client/unit/components/preset-posts-types.test.tsx b/tests/client/unit/components/preset-posts-types.test.tsx new file mode 100644 index 0000000..63a94a1 --- /dev/null +++ b/tests/client/unit/components/preset-posts-types.test.tsx @@ -0,0 +1,89 @@ +/** + * External dependencies + */ +import React from 'react'; +import EntitiesSearch from '@types'; +import { describe, expect, it, jest } from '@jest/globals'; +import { render } from '@testing-library/react'; + +/** + * Internal dependencies + */ +import { Set } from '../../../../sources/client/src/models/set'; +import { createSearchEntitiesOptions } from '../../../../sources/client/src'; +import { ControlOption } from '../../../../sources/client/src/value-objects/control-option'; +import { PresetEntitiesByKind } from '../../../../sources/client/src/components/preset-entities-by-kind'; +import { CompositeEntitiesByKind } from '../../../../sources/client/src/components/composite-entities-by-kind'; + +jest.mock( + '../../../../sources/client/src/components/composite-entities-by-kind', + () => ( { + CompositeEntitiesByKind: jest.fn( () => ( +
CompositeEntitiesByKind
+ ) ), + } ) +); + +describe( 'Preset Entities by Kind', () => { + it( 'Pass the entities fields to the given entitiesFinder function', ( done ) => { + const entitiesFinder = + jest.fn< + ReturnType< typeof createSearchEntitiesOptions< string > > + >(); + + const entitiesFields: EntitiesSearch.SearchQueryFields = [ + 'post_content', + 'post_excerpt', + ]; + + const props = { + entitiesFinder, + entities: new Set( [ 1, 2, 3 ] ), + onChangeEntities: jest.fn(), + entitiesComponent: () => ( +
EntitiesComponent
+ ), + kind: 'post', + kindOptions: stubControlOptionsSet(), + onChangeKind: jest.fn(), + kindComponent: () => ( +
KindComponent
+ ), + entitiesFields, + }; + + jest.mocked( entitiesFinder ).mockImplementation( + // @ts-ignore + ( + _phrase: string, + _kind: EntitiesSearch.Kind< string >, + queryArguments?: EntitiesSearch.QueryArguments + ) => { + expect( queryArguments?.fields ).toEqual( [ + 'title', + 'id', + 'post_content', + 'post_excerpt', + ] ); + done(); + } + ); + + jest.mocked( CompositeEntitiesByKind ).mockImplementation( + ( { searchEntities } ) => { + // @ts-ignore + searchEntities( 'phrase', new Set( [ 'post' ] ) ); + return
CompositeEntitiesByKind
; + } + ); + + render( ); + } ); +} ); + +function stubControlOptionsSet() { + return new Set( [ + new ControlOption( 'Post', 'post' ), + new ControlOption( 'Page', 'page' ), + ] ); +}