Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion components/content-picker/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,12 @@ import { ContentSearch } from '../content-search';
import SortableList from './SortableList';
import { StyledComponentContext } from '../styled-components-context';
import { defaultRenderItemType } from '../content-search/SearchItem';
import { ContentSearchMode, QueryFilter, RenderItemComponentProps } from '../content-search/types';
import {
ContentSearchMode,
QueryArgs,
QueryFilter,
RenderItemComponentProps,
} from '../content-search/types';
import { NormalizedSuggestion } from '../content-search/utils';
import { PickedItemType } from './PickedItem';

Expand Down Expand Up @@ -48,6 +53,7 @@ export interface ContentPickerProps {
placeholder?: string;
onPickChange?: (ids: any[]) => void;
queryFilter?: QueryFilter;
includeEmbeds?: QueryArgs['includeEmbeds'];
maxContentItems?: number;
isOrderable?: boolean;
singlePickedLabel?: string;
Expand All @@ -73,6 +79,7 @@ export const ContentPicker: React.FC<ContentPickerProps> = ({
console.log('Content picker list change', ids); // eslint-disable-line no-console
},
queryFilter = undefined,
includeEmbeds,
maxContentItems = 1,
isOrderable = false,
singlePickedLabel = __('You have selected the following item:', '10up-block-components'),
Expand Down Expand Up @@ -152,6 +159,7 @@ export const ContentPicker: React.FC<ContentPickerProps> = ({
contentTypes={contentTypes}
mode={mode}
queryFilter={queryFilter}
includeEmbeds={includeEmbeds}
perPage={perPage}
fetchInitialResults={fetchInitialResults}
renderItemType={renderItemType}
Expand Down
4 changes: 4 additions & 0 deletions components/content-search/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { StyledComponentContext } from '../styled-components-context';
import type {
ContentSearchMode,
IdentifiableObject,
QueryArgs,
QueryFilter,
RenderItemComponentProps,
} from './types';
Expand Down Expand Up @@ -79,6 +80,7 @@ export interface ContentSearchProps {
mode?: ContentSearchMode;
perPage?: number;
queryFilter?: QueryFilter;
includeEmbeds?: QueryArgs['includeEmbeds'];
excludeItems?: Array<IdentifiableObject>;
renderItemType?: (props: NormalizedSuggestion) => string;
renderItem?: (props: RenderItemComponentProps) => JSX.Element;
Expand All @@ -103,6 +105,7 @@ const ContentSearch: React.FC<ContentSearchProps> = ({
mode = 'post',
perPage = 20,
queryFilter = (query: string) => query,
includeEmbeds = false,
excludeItems = [],
renderItemType = undefined,
renderItem: SearchResultItem = SearchItem,
Expand Down Expand Up @@ -138,6 +141,7 @@ const ContentSearch: React.FC<ContentSearchProps> = ({
perPage,
contentTypes,
queryFilter,
includeEmbeds,
excludeItems,
signal,
}),
Expand Down
32 changes: 32 additions & 0 deletions components/content-search/readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ function MyComponent( props ) {
|-----------------------|------------|--------------------------------------|----------------------------------------------------------------------------------------------------------------------------------|
| `onSelectItem` | `function` | `undefined` | Function called when a searched item is clicked |
| `queryFilter` | `function` | `(query, parametersObject) => query` | Function called to allow you to customize the query before it's made. It's advisable to use `useCallback` to save this parameter |
| `includeEmbeds` | `bool, string, array` | `undefined` | Whether to include embedded items in the search results. A string or array of strings can be passed to specify the specific embeds. |
| `label` | `string` | `''` | Renders a label for the Search Field.
| `hideLabelFromVision` | `bool` | `true` | Whether to hide the label |
| `mode` | `string` | `'post'` | One of: `post`, `user`, `term` |
Expand All @@ -34,5 +35,36 @@ function MyComponent( props ) {
| `excludeItems` | `array` | `[ { id: 1, type: 'post' ]` | Items to exclude from search |
| `perPage` | `number` | `50` | Number of items to show during search |
| `renderItemType` | `function` | `undefined` | Function called to override the item type label in `SearchItem`. Must return the new label. |
| `renderItem` | `function` | `undefined` | Function called to override the entire item in `SearchItem`. Must return a React component. |
| `fetchInitialResults` | `bool` | `false` | Fetch initial results to present when focusing the search input |
| `options.inputDelay` | `number` | `undefined` | Debounce delay passed to the internal search input, defaults to 350ms |

## Search Result Item Customization

There are a number of vectors for customizing how search results are rendered. You can customize the item type label (e.g. "Post", "Page", "Category") by passing a function to the `renderItemType` prop. This function returns a string and receives a single `suggestion` argument, an object with the following shape:

```js
{
id: number;
subtype: string;
title: string;
type: string;
url: string;
embedded?: object;
}
```

You can also customize the entire item by passing a function to the `renderItem` prop. This function should be a React component that receives these props:

```js
{
item: object; // The suggestion object (see above).
onSelect: () => void;
searchTerm: string;
id: string;
contentTypes: string[];
renderType: ( suggestion: object ) => string;
}
```

You may need more than the default suggestion fields to render your custom item. The search endpoint is limited (by design) in what fields it returns, but you can use the linking & embedding functionality of the REST API to include the entire post object (or term, or user) in the response via the `embedded` prop. To do this, pass the `includeEmbeds` prop, which can be a boolean (to include all embeds), a string (to include a single embed type), or an array of strings (to include multiple embed types). This is useful if you want to display additional information about a post, such as its publication date. See the [content search example](example/src/blocks/content-search-example/edit.tsx) for a demonstration of this in action.
1 change: 1 addition & 0 deletions components/content-search/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export interface QueryArgs {
contentTypes: Array<string>;
mode: ContentSearchMode;
keyword: string;
includeEmbeds?: boolean | string | Array<string>;
}

export type QueryFilter = (query: string, args: QueryArgs) => string;
Expand Down
38 changes: 33 additions & 5 deletions components/content-search/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import type { WP_REST_API_User, WP_REST_API_Search_Result } from 'wp-types';
import apiFetch from '@wordpress/api-fetch';
import { addQueryArgs } from '@wordpress/url';
import type { ContentSearchMode, QueryFilter } from './types';
import type { ContentSearchMode, QueryArgs, QueryFilter } from './types';

interface IdentifiableObject extends Object {
id: number;
Expand Down Expand Up @@ -32,6 +32,7 @@ interface PrepareSearchQueryArgs {
perPage: number;
contentTypes: Array<string>;
queryFilter: QueryFilter;
includeEmbeds?: QueryArgs['includeEmbeds'];
}

/*
Expand All @@ -44,25 +45,43 @@ export const prepareSearchQuery = ({
perPage,
contentTypes,
queryFilter,
includeEmbeds = false,
}: PrepareSearchQueryArgs): string => {
let searchQuery;

switch (mode) {
case 'user':
searchQuery = addQueryArgs('wp/v2/users', {
search: keyword,
_fields: ['id', 'link', 'url', 'type', 'name', 'subtype'],
...(includeEmbeds ? { _embed: includeEmbeds } : {}),
_fields: [
'id',
'link',
'url',
'type',
'name',
'subtype',
...(includeEmbeds ? ['_links', '_embedded'] : []),
],
});
break;
default:
searchQuery = addQueryArgs('wp/v2/search', {
search: keyword,
subtype: contentTypes.join(','),
type: mode,
_embed: true,
per_page: perPage,
page,
_fields: ['id', 'link', 'url', 'type', 'title', 'subtype'],
...(includeEmbeds ? { _embed: includeEmbeds } : {}),
_fields: [
'id',
'link',
'url',
'type',
'title',
'subtype',
...(includeEmbeds ? ['_links', '_embedded'] : []),
],
});

break;
Expand All @@ -74,13 +93,15 @@ export const prepareSearchQuery = ({
contentTypes,
mode,
keyword,
includeEmbeds,
});
};

interface NormalizeResultsArgs {
mode: ContentSearchMode;
results: WP_REST_API_Search_Result[] | WP_REST_API_User[];
excludeItems: Array<IdentifiableObject>;
includeEmbeds?: QueryArgs['includeEmbeds'];
}

/*
Expand All @@ -91,12 +112,14 @@ export const normalizeResults = ({
mode,
results,
excludeItems,
includeEmbeds = false,
}: NormalizeResultsArgs): Array<{
id: number;
subtype: ContentSearchMode | string;
title: string;
type: ContentSearchMode | string;
url: string;
embedded?: WP_REST_API_Search_Result['_embedded'] | WP_REST_API_User['_embedded'];
}> => {
const filteredResults = filterOutExcludedItems({ results, excludeItems });
return filteredResults.map((item) => {
Expand All @@ -109,6 +132,7 @@ export const normalizeResults = ({
title: userItem.name,
type: mode,
url: userItem.link,
...(includeEmbeds ? { embedded: userItem._embedded } : {}),
};
default:
const searchItem = item as WP_REST_API_Search_Result;
Expand All @@ -118,6 +142,7 @@ export const normalizeResults = ({
title: searchItem.title,
type: searchItem.type,
url: searchItem.url,
...(includeEmbeds ? { embedded: searchItem._embedded } : {}),
};
}
});
Expand All @@ -133,6 +158,7 @@ interface FetchSearchResultsArgs {
perPage: number;
contentTypes: Array<string>;
queryFilter: QueryFilter;
includeEmbeds?: QueryArgs['includeEmbeds'];
excludeItems: Array<IdentifiableObject>;
signal?: AbortSignal;
}
Expand All @@ -144,6 +170,7 @@ export async function fetchSearchResults({
perPage,
contentTypes,
queryFilter,
includeEmbeds,
excludeItems,
signal,
}: FetchSearchResultsArgs) {
Expand All @@ -154,6 +181,7 @@ export async function fetchSearchResults({
perPage,
contentTypes,
queryFilter,
includeEmbeds,
});
const response = await apiFetch<Response>({
path: searchQueryString,
Expand All @@ -177,7 +205,7 @@ export async function fetchSearchResults({
break;
}

const normalizedResults = normalizeResults({ results, excludeItems, mode });
const normalizedResults = normalizeResults({ results, excludeItems, mode, includeEmbeds });

const hasNextPage = totalPages > page;
const hasPreviousPage = page > 1;
Expand Down
14 changes: 12 additions & 2 deletions cypress/integration/ContentPicker.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,18 @@ context('ContentPicker', () => {

it('allows the user to see results when on focus', () => {
cy.createPost({title: 'Post Picker'});
cy.insertBlock('Hello World');
cy.get('.wp-block-example-hello-world .components-text-control__input').focus();
cy.insertBlock('Content Picker');
cy.get('.wp-block-example-content-picker .components-input-control__input').focus();
cy.get('.tenup-content-search-list').should('exist');
})

it('displays the post date in the search results', () => {
cy.createPost({title: 'Post Picker with Post Date'});
cy.insertBlock('Content Picker');
cy.get('.wp-block-example-content-picker .components-input-control__input').focus();
cy.get('.tenup-content-search-list .tenup-content-search-list-item time').invoke('attr', 'datetime').then((datetime) => {
const date = new Date(datetime);
expect(date.toString()).to.not.equal('Invalid Date');
});
})
})
12 changes: 11 additions & 1 deletion cypress/integration/ContentSearch.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,17 @@ context('ContentSearch', () => {
it('allows the user to see initial results on focus', () => {
cy.createPost({title: 'Post Searcher with fetchOnFocus'});
cy.insertBlock('Post Searcher');
cy.get('.wp-block-example-content-search .components-text-control__input').focus();
cy.get('.wp-block-example-content-search .components-input-control__input').focus();
cy.get('.tenup-content-search-list').should('exist');
})

it('displays the post date in the search results', () => {
cy.createPost({title: 'Post Searcher with Post Date'});
cy.insertBlock('Post Searcher');
cy.get('.wp-block-example-content-search .components-input-control__input').focus();
cy.get('.tenup-content-search-list .tenup-content-search-list-item time').invoke('attr', 'datetime').then((datetime) => {
const date = new Date(datetime);
expect(date.toString()).to.not.equal('Invalid Date');
});
})
})
65 changes: 47 additions & 18 deletions example/src/blocks/content-picker-example/edit.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,28 @@
import React from 'react';
import { __ } from '@wordpress/i18n';
import { InspectorControls, useBlockProps } from '@wordpress/block-editor';
import { PanelBody, Placeholder } from '@wordpress/components';
import { addQueryArgs } from '@wordpress/url';

import { ContentPicker } from '@10up/block-components';

/**
* Example search result customization that uses embedded data to display the
* post date.
*/
const renderItemType = ( props ) => {
const { type, subtype, embedded } = props;
const { date } = embedded?.self?.[ 0 ] ?? {};
const postDate = new Date( Date.parse( date ) );

return (
<>
{ subtype ? subtype : type }
<br />
<time datetime={ postDate.toISOString() }>{ postDate.toLocaleDateString() }</time>
</>
);
};

export const BlockEdit = (props) => {
const {
attributes: {selectedPosts},
Expand All @@ -17,32 +35,43 @@ export const BlockEdit = (props) => {

const blockProps = useBlockProps();

/**
* Example query string filter that returns results in reverse chronological
* order.
*/
const queryFilter = ( query ) => {
return addQueryArgs( query, {
orderby: 'date',
order: 'desc',
} );
};

const ContentPickerControl = () => (
<ContentPicker
label={__('Select a Post or Page', 'example')}
contentTypes={['page', 'post']}
onPickChange={handlePostSelection}
content={selectedPosts}
maxContentItems={5}
queryFilter={queryFilter}
includeEmbeds="self"
renderItemType={renderItemType}
fetchInitialResults
/>
);

return (
<>
<InspectorControls>
<PanelBody title={__('Content Picker', 'example')}>
<ContentPicker
label={__('Select a Post or Page', 'example')}
contentTypes={['page', 'post']}
onPickChange={handlePostSelection}
fetchInitialResults
content={selectedPosts}
maxContentItems={5}
/>
<ContentPickerControl />
</PanelBody>
</InspectorControls>
<div {...blockProps}>
<Placeholder label={__('Content Picker', 'example')} instructions={__('Use the text field to search for a post', 'example')}>
<ContentPicker
label={__('Select a Post or Page', 'example')}
contentTypes={['page', 'post']}
onPickChange={handlePostSelection}
fetchInitialResults
content={selectedPosts}
maxContentItems={5}
/>
<ContentPickerControl />
</Placeholder>
</div>
</>
)
}
}
Loading