diff --git a/packages/components/CHANGELOG.md b/packages/components/CHANGELOG.md
index 5190af098b75e4..3b4b42367466c6 100644
--- a/packages/components/CHANGELOG.md
+++ b/packages/components/CHANGELOG.md
@@ -15,6 +15,7 @@
- Removed deprecated `@storybook/addon-knobs` dependency from the package ([47152](https://github.com/WordPress/gutenberg/pull/47152)).
- `ColorListPicker`: Convert to TypeScript ([#46358](https://github.com/WordPress/gutenberg/pull/46358)).
- `Button`: Convert to TypeScript ([#46997](https://github.com/WordPress/gutenberg/pull/46997)).
+- `QueryControls`: Convert to TypeScript ([#46721](https://github.com/WordPress/gutenberg/pull/46721)).
### Bug Fix
diff --git a/packages/components/src/query-controls/README.md b/packages/components/src/query-controls/README.md
index 9a0f5b08e0cef5..c34aae6f4217cd 100644
--- a/packages/components/src/query-controls/README.md
+++ b/packages/components/src/query-controls/README.md
@@ -28,10 +28,10 @@ const QUERY_DEFAULTS = {
},
],
maxItems: 20,
- minItems: 1,
+ minItems: 1,
numberOfItems: 10,
order: 'asc',
- orderBy: 'title',
+ orderBy: 'title',
};
const MyQueryControls = () => {
@@ -66,24 +66,35 @@ The `QueryControls` component now supports multiple category selection, to repla
const QUERY_DEFAULTS = {
orderBy: 'title',
order: 'asc',
- selectedCategories: [ 1 ],
- categories: [
+ selectedCategories: [
{
id: 1,
- name: 'Category 1',
+ value: 'Category 1',
parent: 0,
},
{
+ id: 2,
+ value: 'Category 1b',
+ parent: 1,
+ },
+ ],
+ categories: {
+ 'Category 1': {
+ id: 1,
+ name: 'Category 1',
+ parent: 0,
+ },
+ 'Category 1b': {
id: 2,
name: 'Category 1b',
parent: 1,
},
- {
+ 'Category 2': {
id: 3,
name: 'Category 2',
parent: 0,
},
- ],
+ },
numberOfItems: 10,
};
@@ -111,132 +122,120 @@ const MyQueryControls = () => {
};
```
-The format of the categories list also needs to be updated to match what `FormTokenField` expects for making suggestions.
+The format of the categories list also needs to be updated to match the expected type for the category suggestions.
### Props
-#### authorList
+#### `authorList`: `Author[]`
-An array of author IDs that is passed into an `AuthorSelect` sub-component.
+An array of the authors to select from.
-- Type: `Array`
- Required: No
- Platform: Web
-#### selectedAuthorId
+#### `categoriesList`: `Category[]`
-The selected author ID.
+An array of categories. When passed in conjunction with the `onCategoryChange` prop, it causes the component to render UI that allows selecting one category at a time.
-- Type: `Number`
- Required: No
- Platform: Web
-#### categoriesList
+#### `categorySuggestions`: `Record< Category[ 'name' ], Category >`
-An array of category IDs; renders a `CategorySelect` sub-component when passed in conjunction with `onCategoryChange`.
+An object of categories with the category name as the key. When passed in conjunction with the `onCategoryChange` prop, it causes the component to render UI that enables multiple selection.
-- Type: `Array`
- Required: No
- Platform: Web
-#### categorySuggestions
+#### `maxItems`: `number`
-An array of category names; renders a `FormTokenField` component when passed in conjunction with `onCategoryChange`.
+The maximum number of items.
-- Type: `Array`
- Required: No
+- Default: 100
- Platform: Web
-#### maxItems
+#### `minItems`: `number`
+
+The minimum number of items.
-- Type: `Number`
- Required: No
-- Default: 100
+- Default: 1
- Platform: Web
-#### minItems
+#### `numberOfItems`: `number`
+
+The selected number of items to retrieve via the query.
-- Type: `Number`
- Required: No
-- Default: 1
- Platform: Web
-#### numberOfItems
+#### `onAuthorChange`: `( newAuthor: string ) => void`
-The selected number of items to retrieve via the query.
+A function that receives the new author value. If not specified, the author controls are not rendered.
-- Type: `Number`
- Required: No
- Platform: Web
-#### onAuthorChange
+#### `onCategoryChange`: `CategorySelectProps[ 'onChange' ] | FormTokenFieldProps[ 'onChange' ]`
-A function that receives the new author value. If this is not specified, the author controls are not included.
+A function that receives the new category value. If not specified, the category controls are not rendered.
-- Type: `Function`
- Required: No
- Platform: Web
-#### onCategoryChange
+#### `onNumberOfItemsChange`: `( newNumber?: number ) => void`
-A function that receives the new category value. If this is not specified, the category controls are not included.
+A function that receives the new number of items. If not specified, then the number of items range control is not rendered.
-- Type: `Function`
- Required: No
- Platform: Web
-#### onNumberOfItemsChange
+#### `onOrderChange`: `( newOrder: 'asc' | 'desc' ) => void`
-A function that receives the new number of items value. If this is not specified, then the number of items range control is not included.
+A function that receives the new order value. If this prop or the `onOrderByChange` prop are not specified, then the order controls are not rendered.
-- Type: `Function`
- Required: No
- Platform: Web
-#### onOrderChange
+#### `onOrderByChange`: `( newOrderBy: 'date' | 'title' ) => void`
-A function that receives the new order value. If this or onOrderByChange are not specified, then the order controls are not included.
+A function that receives the new orderby value. If this prop or the `onOrderChange` prop are not specified, then the order controls are not rendered.
-- Type: `Function`
- Required: No
- Platform: Web
-#### onOrderByChange
+#### `order`: `'asc' | 'desc'`
-A function that receives the new orderby value. If this or onOrderChange are not specified, then the order controls are not included.
+The order in which to retrieve posts.
-- Type: `Function`
- Required: No
- Platform: Web
-#### order
+#### `orderBy`: `'date' | 'title'`
-The order in which to retrieve posts. Can be 'asc' or 'desc'.
+The meta key by which to order posts.
-- Type: `String`
- Required: No
- Platform: Web
-#### orderBy
+#### `selectedAuthorId`: `number`
-The meta key by which to order posts. Can be 'date' or 'title'.
+The selected author ID.
-- Type: `String`
- Required: No
- Platform: Web
-#### selectedCategories
+#### `selectedCategories`: `Category[]`
-The selected categories for the `categorySuggestions`.
+The selected categories for the `categorySuggestions` prop.
-- Type: `Array`
- Required: No
- Platform: Web
-#### selectedCategoryId
+#### `selectedCategoryId`: `number`
-The selected category for the `categoriesList`.
+The selected category for the `categoriesList` prop.
-- Type: `Number`
- Required: No
- Platform: Web
diff --git a/packages/components/src/query-controls/author-select.js b/packages/components/src/query-controls/author-select.js
deleted file mode 100644
index 77901ce8703d19..00000000000000
--- a/packages/components/src/query-controls/author-select.js
+++ /dev/null
@@ -1,23 +0,0 @@
-/**
- * Internal dependencies
- */
-import { buildTermsTree } from './terms';
-import TreeSelect from '../tree-select';
-
-export default function AuthorSelect( {
- label,
- noOptionLabel,
- authorList,
- selectedAuthorId,
- onChange,
-} ) {
- if ( ! authorList ) return null;
- const termsTree = buildTermsTree( authorList );
- return (
-
- );
-}
diff --git a/packages/components/src/query-controls/author-select.tsx b/packages/components/src/query-controls/author-select.tsx
new file mode 100644
index 00000000000000..ebda05ffb9a365
--- /dev/null
+++ b/packages/components/src/query-controls/author-select.tsx
@@ -0,0 +1,37 @@
+/**
+ * Internal dependencies
+ */
+import { buildTermsTree } from './terms';
+import TreeSelect from '../tree-select';
+import type { TreeSelectProps } from '../tree-select/types';
+import type { AuthorSelectProps } from './types';
+
+export default function AuthorSelect( {
+ label,
+ noOptionLabel,
+ authorList,
+ selectedAuthorId,
+ onChange: onChangeProp,
+}: AuthorSelectProps ) {
+ if ( ! authorList ) return null;
+ const termsTree = buildTermsTree( authorList );
+ return (
+
+ );
+}
diff --git a/packages/components/src/query-controls/category-select.js b/packages/components/src/query-controls/category-select.js
deleted file mode 100644
index 5a401014676d3d..00000000000000
--- a/packages/components/src/query-controls/category-select.js
+++ /dev/null
@@ -1,31 +0,0 @@
-/**
- * Internal dependencies
- */
-import { buildTermsTree } from './terms';
-import TreeSelect from '../tree-select';
-/**
- * WordPress dependencies
- */
-import { useMemo } from '@wordpress/element';
-
-export default function CategorySelect( {
- label,
- noOptionLabel,
- categoriesList,
- selectedCategoryId,
- onChange,
- ...props
-} ) {
- const termsTree = useMemo( () => {
- return buildTermsTree( categoriesList );
- }, [ categoriesList ] );
-
- return (
-
- );
-}
diff --git a/packages/components/src/query-controls/category-select.tsx b/packages/components/src/query-controls/category-select.tsx
new file mode 100644
index 00000000000000..938dfe792de08f
--- /dev/null
+++ b/packages/components/src/query-controls/category-select.tsx
@@ -0,0 +1,46 @@
+/**
+ * Internal dependencies
+ */
+import { buildTermsTree } from './terms';
+import TreeSelect from '../tree-select';
+import type { TreeSelectProps } from '../tree-select/types';
+
+/**
+ * WordPress dependencies
+ */
+import { useMemo } from '@wordpress/element';
+import type { CategorySelectProps } from './types';
+
+export default function CategorySelect( {
+ label,
+ noOptionLabel,
+ categoriesList,
+ selectedCategoryId,
+ onChange: onChangeProp,
+ ...props
+}: CategorySelectProps ) {
+ const termsTree = useMemo( () => {
+ return buildTermsTree( categoriesList );
+ }, [ categoriesList ] );
+
+ return (
+
+ );
+}
diff --git a/packages/components/src/query-controls/index.js b/packages/components/src/query-controls/index.js
deleted file mode 100644
index 79343c5954c94f..00000000000000
--- a/packages/components/src/query-controls/index.js
+++ /dev/null
@@ -1,122 +0,0 @@
-/**
- * WordPress dependencies
- */
-import { __ } from '@wordpress/i18n';
-
-/**
- * Internal dependencies
- */
-import CategorySelect from './category-select';
-import { RangeControl, SelectControl, FormTokenField } from '../';
-import AuthorSelect from './author-select';
-
-const DEFAULT_MIN_ITEMS = 1;
-const DEFAULT_MAX_ITEMS = 100;
-const MAX_CATEGORIES_SUGGESTIONS = 20;
-
-export default function QueryControls( {
- authorList,
- selectedAuthorId,
- categoriesList,
- selectedCategoryId,
- categorySuggestions,
- selectedCategories,
- numberOfItems,
- order,
- orderBy,
- maxItems = DEFAULT_MAX_ITEMS,
- minItems = DEFAULT_MIN_ITEMS,
- onCategoryChange,
- onAuthorChange,
- onNumberOfItemsChange,
- onOrderChange,
- onOrderByChange,
-} ) {
- return [
- onOrderChange && onOrderByChange && (
- {
- const [ newOrderBy, newOrder ] = value.split( '/' );
- if ( newOrder !== order ) {
- onOrderChange( newOrder );
- }
- if ( newOrderBy !== orderBy ) {
- onOrderByChange( newOrderBy );
- }
- } }
- />
- ),
- categoriesList && onCategoryChange && (
-
- ),
- categorySuggestions && onCategoryChange && (
- ( {
- id: item.id,
- value: item.name || item.value,
- } ) )
- }
- suggestions={ Object.keys( categorySuggestions ) }
- onChange={ onCategoryChange }
- maxSuggestions={ MAX_CATEGORIES_SUGGESTIONS }
- />
- ),
- onAuthorChange && (
-
- ),
- onNumberOfItemsChange && (
-
- ),
- ];
-}
diff --git a/packages/components/src/query-controls/index.tsx b/packages/components/src/query-controls/index.tsx
new file mode 100644
index 00000000000000..91769410b4fa92
--- /dev/null
+++ b/packages/components/src/query-controls/index.tsx
@@ -0,0 +1,192 @@
+/**
+ * WordPress dependencies
+ */
+import { __ } from '@wordpress/i18n';
+
+/**
+ * Internal dependencies
+ */
+import AuthorSelect from './author-select';
+import CategorySelect from './category-select';
+import FormTokenField from '../form-token-field';
+import RangeControl from '../range-control';
+import SelectControl from '../select-control';
+import type {
+ QueryControlsProps,
+ QueryControlsWithMultipleCategorySelectionProps,
+ QueryControlsWithSingleCategorySelectionProps,
+} from './types';
+
+const DEFAULT_MIN_ITEMS = 1;
+const DEFAULT_MAX_ITEMS = 100;
+const MAX_CATEGORIES_SUGGESTIONS = 20;
+
+function isSingleCategorySelection(
+ props: QueryControlsProps
+): props is QueryControlsWithSingleCategorySelectionProps {
+ return 'categoriesList' in props;
+}
+
+function isMultipleCategorySelection(
+ props: QueryControlsProps
+): props is QueryControlsWithMultipleCategorySelectionProps {
+ return 'categorySuggestions' in props;
+}
+
+/**
+ * Controls to query for posts.
+ *
+ * ```jsx
+ * const MyQueryControls = () => (
+ * {
+ * updateQuery( { orderBy: newOrderBy } )
+ * }
+ * onOrderChange={ ( newOrder ) => {
+ * updateQuery( { order: newOrder } )
+ * }
+ * categoriesList={ categories }
+ * selectedCategoryId={ category }
+ * onCategoryChange={ ( newCategory ) => {
+ * updateQuery( { category: newCategory } )
+ * }
+ * onNumberOfItemsChange={ ( newNumberOfItems ) => {
+ * updateQuery( { numberOfItems: newNumberOfItems } )
+ * } }
+ * />
+ * );
+ * ```
+ */
+export function QueryControls( {
+ authorList,
+ selectedAuthorId,
+ numberOfItems,
+ order,
+ orderBy,
+ maxItems = DEFAULT_MAX_ITEMS,
+ minItems = DEFAULT_MIN_ITEMS,
+ onAuthorChange,
+ onNumberOfItemsChange,
+ onOrderChange,
+ onOrderByChange,
+ // Props for single OR multiple category selection are not destructured here,
+ // but instead are destructured inline where necessary.
+ ...props
+}: QueryControlsProps ) {
+ return (
+ <>
+ { [
+ onOrderChange && onOrderByChange && (
+ {
+ if ( typeof value !== 'string' ) {
+ return;
+ }
+
+ const [ newOrderBy, newOrder ] = value.split( '/' );
+ if ( newOrder !== order ) {
+ onOrderChange(
+ newOrder as NonNullable<
+ QueryControlsProps[ 'order' ]
+ >
+ );
+ }
+ if ( newOrderBy !== orderBy ) {
+ onOrderByChange(
+ newOrderBy as NonNullable<
+ QueryControlsProps[ 'orderBy' ]
+ >
+ );
+ }
+ } }
+ />
+ ),
+ isSingleCategorySelection( props ) &&
+ props.categoriesList &&
+ props.onCategoryChange && (
+
+ ),
+ isMultipleCategorySelection( props ) &&
+ props.categorySuggestions &&
+ props.onCategoryChange && (
+ ( {
+ id: item.id,
+ // Keeping the fallback to `item.value` for legacy reasons,
+ // even if items of `selectedCategories` should not have a
+ // `value` property.
+ // @ts-expect-error
+ value: item.name || item.value,
+ } ) )
+ }
+ suggestions={ Object.keys(
+ props.categorySuggestions
+ ) }
+ onChange={ props.onCategoryChange }
+ maxSuggestions={ MAX_CATEGORIES_SUGGESTIONS }
+ />
+ ),
+ onAuthorChange && (
+
+ ),
+ onNumberOfItemsChange && (
+
+ ),
+ ] }
+ >
+ );
+}
+
+export default QueryControls;
diff --git a/packages/components/src/query-controls/stories/index.tsx b/packages/components/src/query-controls/stories/index.tsx
new file mode 100644
index 00000000000000..d7fa5e50a4531a
--- /dev/null
+++ b/packages/components/src/query-controls/stories/index.tsx
@@ -0,0 +1,205 @@
+/**
+ * External dependencies
+ */
+import type { ComponentMeta, ComponentStory } from '@storybook/react';
+
+/**
+ * WordPress dependencies
+ */
+import { useState } from '@wordpress/element';
+
+/**
+ * Internal dependencies
+ */
+import QueryControls from '..';
+import type {
+ Category,
+ QueryControlsWithSingleCategorySelectionProps,
+ QueryControlsWithMultipleCategorySelectionProps,
+} from '../types';
+
+const meta: ComponentMeta< typeof QueryControls > = {
+ title: 'Components/QueryControls',
+ component: QueryControls,
+ argTypes: {
+ numberOfItems: { control: { type: null } },
+ order: { control: { type: null } },
+ orderBy: { control: { type: null } },
+ selectedAuthorId: { control: { type: null } },
+ selectedCategories: { control: { type: null } },
+ selectedCategoryId: { control: { type: null } },
+ },
+ parameters: {
+ actions: { argTypesRegex: '^on.*' },
+ controls: { expanded: true },
+ docs: { source: { state: 'open' } },
+ },
+};
+export default meta;
+
+export const Default: ComponentStory< typeof QueryControls > = ( args ) => {
+ const {
+ onAuthorChange,
+ onCategoryChange,
+ onNumberOfItemsChange,
+ onOrderByChange,
+ onOrderChange,
+ ...props
+ } = args as QueryControlsWithMultipleCategorySelectionProps;
+ const [ ownNumberOfItems, setOwnNumberOfItems ] = useState(
+ props.numberOfItems
+ );
+ const [ ownOrder, setOwnOrder ] = useState( props.order );
+ const [ ownOrderBy, setOwnOrderBy ] = useState( props.orderBy );
+ const [ ownSelectedAuthorId, setOwnSelectedAuthorId ] = useState(
+ props.selectedAuthorId
+ );
+ const [ ownSelectedCategories, setOwnSelectedCategories ] = useState(
+ props.selectedCategories
+ );
+
+ const handleCategoryChange: QueryControlsWithMultipleCategorySelectionProps[ 'onCategoryChange' ] =
+ ( tokens ) => {
+ onCategoryChange?.( tokens );
+
+ const hasNoSuggestion = tokens.some(
+ ( token ) =>
+ typeof token === 'string' &&
+ ! props.categorySuggestions?.[ token ]
+ );
+ if ( hasNoSuggestion ) {
+ return;
+ }
+ const allCategories = tokens
+ .map( ( token ) => {
+ return typeof token === 'string'
+ ? props.categorySuggestions?.[ token ]
+ : token;
+ } )
+ .filter( Boolean ) as Array< Required< Category > >;
+
+ setOwnSelectedCategories( allCategories );
+ };
+
+ return (
+ {
+ onOrderByChange?.( newOrderBy );
+ setOwnOrderBy( newOrderBy );
+ } }
+ onOrderChange={ ( newOrder ) => {
+ onOrderChange?.( newOrder );
+ setOwnOrder( newOrder );
+ } }
+ order={ ownOrder }
+ orderBy={ ownOrderBy }
+ onNumberOfItemsChange={ ( newNumber ) => {
+ onNumberOfItemsChange?.( newNumber );
+ setOwnNumberOfItems( newNumber );
+ } }
+ onAuthorChange={ ( newAuthor ) => {
+ onAuthorChange?.( newAuthor );
+ setOwnSelectedAuthorId( Number( newAuthor ) );
+ } }
+ selectedAuthorId={ ownSelectedAuthorId }
+ selectedCategories={ ownSelectedCategories }
+ />
+ );
+};
+
+Default.args = {
+ authorList: [
+ {
+ id: 1,
+ name: 'admin',
+ },
+ {
+ id: 2,
+ name: 'editor',
+ },
+ ],
+ categorySuggestions: {
+ TypeScript: {
+ id: 11,
+ name: 'TypeScript',
+ parent: 0,
+ },
+ JavaScript: {
+ id: 12,
+ name: 'JavaScript',
+ parent: 0,
+ },
+ },
+ selectedCategories: [
+ {
+ id: 11,
+ name: 'JavaScript',
+ parent: 0,
+ },
+ ],
+ numberOfItems: 5,
+ order: 'desc',
+ orderBy: 'date',
+ selectedAuthorId: 1,
+};
+
+const SingleCategoryTemplate: ComponentStory< typeof QueryControls > = (
+ args
+) => {
+ const {
+ onAuthorChange,
+ onCategoryChange,
+ onNumberOfItemsChange,
+ onOrderByChange,
+ onOrderChange,
+ ...props
+ } = args as QueryControlsWithSingleCategorySelectionProps;
+ const [ ownOrder, setOwnOrder ] = useState( props.order );
+ const [ ownOrderBy, setOwnOrderBy ] = useState( props.orderBy );
+ const [ ownSelectedCategoryId, setSelectedCategoryId ] = useState(
+ props.selectedCategoryId
+ );
+
+ const handleCategoryChange: QueryControlsWithSingleCategorySelectionProps[ 'onCategoryChange' ] =
+ ( newCategory ) => {
+ onCategoryChange?.( newCategory );
+ setSelectedCategoryId( Number( newCategory ) );
+ };
+
+ return (
+ {
+ setOwnOrderBy( newOrderBy );
+ } }
+ onOrderChange={ ( newOrder ) => {
+ onOrderChange?.( newOrder );
+ setOwnOrder( newOrder );
+ } }
+ order={ ownOrder }
+ orderBy={ ownOrderBy }
+ selectedCategoryId={ ownSelectedCategoryId }
+ />
+ );
+};
+export const SelectSingleCategory: ComponentStory< typeof QueryControls > =
+ SingleCategoryTemplate.bind( {} );
+SelectSingleCategory.args = {
+ categoriesList: [
+ {
+ id: 11,
+ name: 'TypeScript',
+ parent: 0,
+ },
+ {
+ id: 12,
+ name: 'JavaScript',
+ parent: 0,
+ },
+ ],
+ selectedCategoryId: 11,
+};
diff --git a/packages/components/src/query-controls/terms.js b/packages/components/src/query-controls/terms.js
deleted file mode 100644
index 6405cf5d1ba5b0..00000000000000
--- a/packages/components/src/query-controls/terms.js
+++ /dev/null
@@ -1,40 +0,0 @@
-/**
- * External dependencies
- */
-import { groupBy } from 'lodash';
-
-/**
- * Returns terms in a tree form.
- *
- * @param {Array} flatTerms Array of terms in flat format.
- *
- * @return {Array} Array of terms in tree format.
- */
-export function buildTermsTree( flatTerms ) {
- const flatTermsWithParentAndChildren = flatTerms.map( ( term ) => {
- return {
- children: [],
- parent: null,
- ...term,
- };
- } );
-
- const termsByParent = groupBy( flatTermsWithParentAndChildren, 'parent' );
- if ( termsByParent.null && termsByParent.null.length ) {
- return flatTermsWithParentAndChildren;
- }
- const fillWithChildren = ( terms ) => {
- return terms.map( ( term ) => {
- const children = termsByParent[ term.id ];
- return {
- ...term,
- children:
- children && children.length
- ? fillWithChildren( children )
- : [],
- };
- } );
- };
-
- return fillWithChildren( termsByParent[ '0' ] || [] );
-}
diff --git a/packages/components/src/query-controls/terms.ts b/packages/components/src/query-controls/terms.ts
new file mode 100644
index 00000000000000..3802e05d84c7d6
--- /dev/null
+++ b/packages/components/src/query-controls/terms.ts
@@ -0,0 +1,57 @@
+/**
+ * External dependencies
+ */
+import { groupBy } from 'lodash';
+
+/**
+ * Internal dependencies
+ */
+import type {
+ Author,
+ Category,
+ TermWithParentAndChildren,
+ TermsByParent,
+} from './types';
+
+/**
+ * Returns terms in a tree form.
+ *
+ * @param flatTerms Array of terms in flat format.
+ *
+ * @return Terms in tree format.
+ */
+export function buildTermsTree( flatTerms: readonly ( Author | Category )[] ) {
+ const flatTermsWithParentAndChildren: TermWithParentAndChildren[] =
+ flatTerms.map( ( term ) => {
+ return {
+ children: [],
+ parent: null,
+ ...term,
+ id: String( term.id ),
+ };
+ } );
+
+ const termsByParent: TermsByParent = groupBy(
+ flatTermsWithParentAndChildren,
+ 'parent'
+ );
+ if ( termsByParent.null && termsByParent.null.length ) {
+ return flatTermsWithParentAndChildren;
+ }
+ const fillWithChildren = (
+ terms: TermWithParentAndChildren[]
+ ): TermWithParentAndChildren[] => {
+ return terms.map( ( term ) => {
+ const children = termsByParent[ term.id ];
+ return {
+ ...term,
+ children:
+ children && children.length
+ ? fillWithChildren( children )
+ : [],
+ };
+ } );
+ };
+
+ return fillWithChildren( termsByParent[ '0' ] || [] );
+}
diff --git a/packages/components/src/query-controls/test/terms.js b/packages/components/src/query-controls/test/terms.ts
similarity index 52%
rename from packages/components/src/query-controls/test/terms.js
rename to packages/components/src/query-controls/test/terms.ts
index 68a1a7f10fb190..0dcaa62f1440a8 100644
--- a/packages/components/src/query-controls/test/terms.js
+++ b/packages/components/src/query-controls/test/terms.ts
@@ -6,38 +6,53 @@ import { buildTermsTree } from '../terms';
describe( 'buildTermsTree()', () => {
it( 'Should return same array as input with null parent and empty children added if parent is never specified.', () => {
const input = Object.freeze( [
- { id: 2232, dummy: true },
- { id: 2245, dummy: true },
+ { id: 2232, name: 'foo', dummy: true },
+ { id: 2245, name: 'baz', dummy: true },
] );
const output = Object.freeze( [
- { id: 2232, parent: null, children: [], dummy: true },
- { id: 2245, parent: null, children: [], dummy: true },
+ {
+ id: '2232',
+ name: 'foo',
+ parent: null,
+ children: [],
+ dummy: true,
+ },
+ {
+ id: '2245',
+ name: 'baz',
+ parent: null,
+ children: [],
+ dummy: true,
+ },
] );
const termsTreem = buildTermsTree( input );
expect( termsTreem ).toEqual( output );
} );
it( 'Should return same array as input with empty children added if all the elements are top level', () => {
const input = Object.freeze( [
- { id: 2232, parent: 0, dummy: true },
- { id: 2245, parent: 0, dummy: false },
+ { id: 2232, name: 'foo', parent: 0, dummy: true },
+ { id: 2245, name: 'baz', parent: 0, dummy: false },
] );
const output = [
- { id: 2232, parent: 0, children: [], dummy: true },
- { id: 2245, parent: 0, children: [], dummy: false },
+ { id: '2232', name: 'foo', parent: 0, children: [], dummy: true },
+ { id: '2245', name: 'baz', parent: 0, children: [], dummy: false },
];
const termsTreem = buildTermsTree( input );
expect( termsTreem ).toEqual( output );
} );
it( 'Should return element with its child if a child exists', () => {
const input = Object.freeze( [
- { id: 2232, parent: 0 },
- { id: 2245, parent: 2232 },
+ { id: 2232, name: 'foo', parent: 0 },
+ { id: 2245, name: 'baz', parent: 2232 },
] );
const output = [
{
- id: 2232,
+ id: '2232',
+ name: 'foo',
parent: 0,
- children: [ { id: 2245, parent: 2232, children: [] } ],
+ children: [
+ { id: '2245', name: 'baz', parent: 2232, children: [] },
+ ],
},
];
const termsTreem = buildTermsTree( input );
@@ -45,21 +60,22 @@ describe( 'buildTermsTree()', () => {
} );
it( 'Should return elements with multiple children and elements with no children', () => {
const input = Object.freeze( [
- { id: 2232, parent: 0 },
- { id: 2245, parent: 2232 },
- { id: 2249, parent: 0 },
- { id: 2246, parent: 2232 },
+ { id: 2232, name: 'a', parent: 0 },
+ { id: 2245, name: 'b', parent: 2232 },
+ { id: 2249, name: 'c', parent: 0 },
+ { id: 2246, name: 'd', parent: 2232 },
] );
const output = [
{
- id: 2232,
+ id: '2232',
+ name: 'a',
parent: 0,
children: [
- { id: 2245, parent: 2232, children: [] },
- { id: 2246, parent: 2232, children: [] },
+ { id: '2245', name: 'b', parent: 2232, children: [] },
+ { id: '2246', name: 'd', parent: 2232, children: [] },
],
},
- { id: 2249, parent: 0, children: [] },
+ { id: '2249', name: 'c', parent: 0, children: [] },
];
const termsTreem = buildTermsTree( input );
expect( termsTreem ).toEqual( output );
diff --git a/packages/components/src/query-controls/types.ts b/packages/components/src/query-controls/types.ts
new file mode 100644
index 00000000000000..63bbfeaa5a0aac
--- /dev/null
+++ b/packages/components/src/query-controls/types.ts
@@ -0,0 +1,146 @@
+/**
+ * Internal dependencies
+ */
+import type { FormTokenFieldProps } from '../form-token-field/types';
+import type { TreeSelectProps } from '../tree-select/types';
+
+export type Author = {
+ id: number;
+ name: string;
+};
+
+export type Category = {
+ id: number;
+ name: string;
+ parent: number;
+};
+
+export type TermWithParentAndChildren = {
+ id: string;
+ name: string;
+ parent: number | null;
+ children: TermWithParentAndChildren[];
+};
+
+export type TermsByParent = Record< string, TermWithParentAndChildren[] >;
+
+export type CategorySelectProps = Pick<
+ TreeSelectProps,
+ 'label' | 'noOptionLabel'
+> & {
+ categoriesList: Category[];
+ onChange: ( newCategory: string ) => void;
+ selectedCategoryId?: Category[ 'id' ];
+};
+
+export type AuthorSelectProps = Pick<
+ TreeSelectProps,
+ 'label' | 'noOptionLabel'
+> & {
+ authorList?: Author[];
+ onChange: ( newAuthor: string ) => void;
+ selectedAuthorId?: Author[ 'id' ];
+};
+
+type Order = 'asc' | 'desc';
+type OrderBy = 'date' | 'title';
+
+type BaseQueryControlsProps = {
+ /**
+ * An array of the authors to select from.
+ */
+ authorList?: AuthorSelectProps[ 'authorList' ];
+ /**
+ * The maximum number of items.
+ *
+ * @default 100
+ */
+ maxItems?: number;
+ /**
+ * The minimum number of items.
+ *
+ * @default 1
+ */
+ minItems?: number;
+ /**
+ * The selected number of items to retrieve via the query.
+ */
+ numberOfItems?: number;
+ /**
+ * A function that receives the new author value.
+ * If not specified, the author controls are not rendered.
+ */
+ onAuthorChange?: AuthorSelectProps[ 'onChange' ];
+ /**
+ * A function that receives the new number of items.
+ * If not specified, then the number of items
+ * range control is not rendered.
+ */
+ onNumberOfItemsChange?: ( newNumber?: number ) => void;
+ /**
+ * A function that receives the new order value.
+ * If this prop or the `onOrderByChange` prop are not specified,
+ * then the order controls are not rendered.
+ */
+ onOrderChange?: ( newOrder: Order ) => void;
+ /**
+ * A function that receives the new orderby value.
+ * If this prop or the `onOrderChange` prop are not specified,
+ * then the order controls are not rendered.
+ */
+ onOrderByChange?: ( newOrderBy: OrderBy ) => void;
+ /**
+ * The order in which to retrieve posts.
+ */
+ order?: Order;
+ /**
+ * The meta key by which to order posts.
+ */
+ orderBy?: OrderBy;
+ /**
+ * The selected author ID.
+ */
+ selectedAuthorId?: AuthorSelectProps[ 'selectedAuthorId' ];
+};
+
+export type QueryControlsWithSingleCategorySelectionProps =
+ BaseQueryControlsProps & {
+ /**
+ * An array of categories. When passed in conjunction with the
+ * `onCategoryChange` prop, it causes the component to render UI that allows
+ * selecting one category at a time.
+ */
+ categoriesList?: CategorySelectProps[ 'categoriesList' ];
+ /**
+ * The selected category for the `categoriesList` prop.
+ */
+ selectedCategoryId?: CategorySelectProps[ 'selectedCategoryId' ];
+ /**
+ * A function that receives the new category value.
+ * If not specified, the category controls are not rendered.
+ */
+ onCategoryChange?: CategorySelectProps[ 'onChange' ];
+ };
+
+export type QueryControlsWithMultipleCategorySelectionProps =
+ BaseQueryControlsProps & {
+ /**
+ * An object of categories with the category name as the key. When passed in
+ * conjunction with the `onCategoryChange` prop, it causes the component to
+ * render UI that enables multiple selection.
+ */
+ categorySuggestions?: Record< Category[ 'name' ], Category >;
+ /**
+ * The selected categories for the `categorySuggestions` prop.
+ */
+ selectedCategories?: Category[];
+ /**
+ * A function that receives the new category value.
+ * If not specified, the category controls are not rendered.
+ */
+ onCategoryChange?: FormTokenFieldProps[ 'onChange' ];
+ };
+
+export type QueryControlsProps =
+ | QueryControlsWithSingleCategorySelectionProps
+ | QueryControlsWithMultipleCategorySelectionProps;
diff --git a/packages/components/tsconfig.json b/packages/components/tsconfig.json
index 7570fa13a60ada..83d8d6251b94d6 100644
--- a/packages/components/tsconfig.json
+++ b/packages/components/tsconfig.json
@@ -60,7 +60,6 @@
"src/notice",
"src/palette-edit",
"src/panel",
- "src/query-controls",
"src/toolbar",
"src/tree-grid"
]