diff --git a/apps/demo/config/blocks/Hero/index.tsx b/apps/demo/config/blocks/Hero/index.tsx index 321574da08..2a6a019070 100644 --- a/apps/demo/config/blocks/Hero/index.tsx +++ b/apps/demo/config/blocks/Hero/index.tsx @@ -33,7 +33,23 @@ export const Hero: ComponentConfig = { type: "external", placeholder: "Select a quote", showSearch: true, - fetchList: async ({ query }) => { + filterFields: { + author: { + type: "select", + options: [ + { value: "", label: "Select an author" }, + { value: "Mark Twain", label: "Mark Twain" }, + { value: "Henry Ford", label: "Henry Ford" }, + { value: "Kurt Vonnegut", label: "Kurt Vonnegut" }, + { value: "Andrew Carnegie", label: "Andrew Carnegie" }, + { value: "C. S. Lewis", label: "C. S. Lewis" }, + { value: "Confucius", label: "Confucius" }, + { value: "Eleanor Roosevelt", label: "Eleanor Roosevelt" }, + { value: "Samuel Ullman", label: "Samuel Ullman" }, + ], + }, + }, + fetchList: async ({ query, filters }) => { // Simulate delay await new Promise((res) => setTimeout(res, 500)); @@ -44,16 +60,20 @@ export const Hero: ComponentConfig = { description: quote.content, })) .filter((item) => { - if (!query) return item; + if (filters?.author && item.title !== filters?.author) { + return false; + } + + if (!query) return true; const queryLowercase = query.toLowerCase(); if (item.title.toLowerCase().indexOf(queryLowercase) > -1) { - return item; + return true; } if (item.description.toLowerCase().indexOf(queryLowercase) > -1) { - return item; + return true; } }); }, diff --git a/apps/docs/pages/docs/api-reference/configuration/fields/external.mdx b/apps/docs/pages/docs/api-reference/configuration/fields/external.mdx index 0d11aae21d..17855c7623 100644 --- a/apps/docs/pages/docs/api-reference/configuration/fields/external.mdx +++ b/apps/docs/pages/docs/api-reference/configuration/fields/external.mdx @@ -60,6 +60,8 @@ const config = { | [`placeholder`](#placeholder) | `placeholder: "Select content"` | String | - | | [`showSearch`](#showsearch) | `showSearch: true` | Boolean | - | | [`initialQuery`](#initialquery) | `initialQuery: "Hello, world"` | String | - | +| [`filterFields`](#filterfields) | `{ "rating": { type: "number" } }` | Object | - | +| [`initialFilters`](#initialfilters) | `"{ "rating": 1 }"` | Object | - | ## Required params @@ -88,7 +90,7 @@ const config = { }; ``` -### `fetchList()` +### `fetchList(queryParams)` Return a promise with a list of objects to be rendered in a tabular format via the external input modal. @@ -117,6 +119,23 @@ const config = { }; ``` +#### `queryParams` + +The parameters passed to the `fetchList` method based on your field configuration. + +| Param | Example | Type | +| --------------------- | ------------------- | ------ | +| [`query`](#query) | `"My Query"` | String | +| [`filters`](#filters) | `"{ "rating": 1 }"` | Object | + +##### `query` + +The search query when using [`showSearch`](#showsearch). + +##### `filters` + +An object describing the filters configured by [`filterFields`](#filterfields). + ## Optional params ### `getItemSummary(item)` @@ -169,7 +188,6 @@ const config = { render: ({ data }) => { return

{data?.title || "No data selected"}

; }, - // ... }} /> @@ -220,7 +238,6 @@ const config = { render: ({ data }) => { return

{data || "No data selected"}

; }, - // ... }} /> @@ -276,7 +293,6 @@ const config = { render: ({ data }) => { return

{data?.title || "No data selected"}

; }, - // ... }} /> @@ -291,7 +307,7 @@ const config = { fields: { data: { type: "external", - fetchList: async ({ params }) => { + fetchList: async ({ query }) => { return [ { title: "Apple", description: "Lorem ipsum 1" }, { title: "Orange", description: "Lorem ipsum 2" }, @@ -346,7 +362,6 @@ const config = { render: ({ data }) => { return

{data?.title || "No data selected"}

; }, - // ... }} /> @@ -362,7 +377,7 @@ const config = { fields: { data: { type: "external", - fetchList: async ({ params }) => { + fetchList: async ({ query }) => { return [ { title: "Apple", description: "Lorem ipsum 1" }, { title: "Orange", description: "Lorem ipsum 2" }, @@ -419,7 +434,128 @@ const config = { render: ({ data }) => { return

{data?.title || "No data selected"}

; }, - // ... + +}} +/> + +### `filterFields` + +An object describing filters for your query using the [Fields API](/docs/api-reference/configuration/fields) + +```tsx {13-17} copy +const config = { + components: { + Example: { + fields: { + data: { + type: "external", + fetchList: async ({ filters }) => { + return [ + { title: "Apple", description: "Lorem ipsum 1", rating: 5 }, + { title: "Orange", description: "Lorem ipsum 2", rating: 3 }, + ].filter((item) => item.rating >= (filters.rating || 0)); + }, + filterFields: { + rating: { + type: "number", + }, + }, + }, + }, + // ... + }, + }, +}; +``` + + { + return [ + { title: "Apple", description: "Lorem ipsum 1", rating: 5 }, + { title: "Orange", description: "Lorem ipsum 2", rating: 3 }, + ].filter((item) => + item.rating >= (filters.rating || 0) + ) + }, + filterFields: { + rating: { + type: "number", + }, + }, + }, + }, + render: ({ data }) => { + return

{data?.title || "No data selected"}

; + }, + +}} +/> + +### `initialFilters` + +The initial filter values when using [`filterFields`](#filterfields). + +```tsx {18-20} copy +const config = { + components: { + Example: { + fields: { + data: { + type: "external", + fetchList: async ({ filters }) => { + return [ + { title: "Apple", description: "Lorem ipsum 1", rating: 5 }, + { title: "Orange", description: "Lorem ipsum 2", rating: 3 }, + ].filter((item) => item.rating >= (filters.rating || 0)); + }, + filterFields: { + rating: { + type: "number", + }, + }, + initialFilters: { + rating: 1, + }, + }, + }, + // ... + }, + }, +}; +``` + + { + return [ + { title: "Apple", description: "Lorem ipsum 1", rating: 5 }, + { title: "Orange", description: "Lorem ipsum 2", rating: 3 }, + ].filter((item) => + item.rating >= (filters.rating || 0) + ) + }, + filterFields: { + rating: { + type: "number", + }, + }, + initialFilters: { + rating: 1, + }, + }, + }, + render: ({ data }) => { + return

{data?.title || "No data selected"}

; + }, }} /> diff --git a/packages/core/components/ExternalInput/index.tsx b/packages/core/components/ExternalInput/index.tsx index dd5dd406e6..5e8181a1f6 100644 --- a/packages/core/components/ExternalInput/index.tsx +++ b/packages/core/components/ExternalInput/index.tsx @@ -2,11 +2,13 @@ import { useMemo, useEffect, useState, useCallback } from "react"; import styles from "./styles.module.css"; import getClassNameFactory from "../../lib/get-class-name-factory"; import { ExternalField } from "../../types/Config"; -import { Link, Search, Unlock } from "lucide-react"; +import { Link, Search, SlidersHorizontal, Unlock } from "lucide-react"; import { Modal } from "../Modal"; import { Heading } from "../Heading"; import { ClipLoader } from "react-spinners"; import { Button } from "../Button"; +import { InputOrGroup } from "../InputOrGroup"; +import { IconButton } from "../IconButton"; const getClassName = getClassNameFactory("ExternalInput", styles); const getClassNameModal = getClassNameFactory("ExternalInputModal", styles); @@ -26,12 +28,21 @@ export const ExternalInput = ({ name: string; id: string; }) => { - const { mapProp = (val) => val, mapRow = (val) => val } = field || {}; + const { + mapProp = (val) => val, + mapRow = (val) => val, + filterFields, + } = field || {}; const [data, setData] = useState[]>([]); const [isOpen, setOpen] = useState(false); const [isLoading, setIsLoading] = useState(true); + const hasFilterFields = !!filterFields; + + const [filters, setFilters] = useState(field.initialFilters || {}); + const [filtersToggled, setFiltersToggled] = useState(hasFilterFields); + const mappedData = useMemo(() => { return data.map(mapRow); }, [data]); @@ -53,13 +64,13 @@ export const ExternalInput = ({ const [searchQuery, setSearchQuery] = useState(field.initialQuery || ""); const search = useCallback( - async (query) => { + async (query, filters) => { setIsLoading(true); - const cacheKey = `${id}-${name}-${query}`; + const cacheKey = `${id}-${name}-${query}-${JSON.stringify(filters)}`; const listData = - dataCache[cacheKey] || (await field.fetchList({ query })); + dataCache[cacheKey] || (await field.fetchList({ query, filters })); if (listData) { setData(listData); @@ -72,7 +83,7 @@ export const ExternalInput = ({ ); useEffect(() => { - search(searchQuery); + search(searchQuery, filters); }, []); return ( @@ -114,23 +125,22 @@ export const ExternalInput = ({ )} setOpen(false)} isOpen={isOpen}> -
0, + filtersToggled, })} + onSubmit={(e) => { + e.preventDefault(); + + search(searchQuery, filters); + }} >
{field.showSearch ? ( -
{ - e.preventDefault(); - - search(searchQuery); - }} - > +
- - +
+ + {hasFilterFields && ( +
+ { + e.preventDefault(); + e.stopPropagation(); + setFiltersToggled(!filtersToggled); + }} + > + + +
+ )} +
+
) : ( {field.placeholder || "Select data"} @@ -161,54 +187,82 @@ export const ExternalInput = ({ )}
-
- - - - {keys.map((key) => ( - - ))} - - - - {mappedData.map((item, i) => { - return ( - { - onChange(mapProp(data[i])); - - setOpen(false); - }} - > - {keys.map((key) => ( - - ))} - - ); - })} - -
- {key} -
- {item[key]} -
- -
- +
+ {hasFilterFields && ( +
+ {hasFilterFields && + Object.keys(filterFields).map((fieldName) => { + const filterField = filterFields[fieldName]; + return ( + { + const newFilters = { ...filters, [fieldName]: value }; + + setFilters(newFilters); + + search(searchQuery, newFilters); + }} + /> + ); + })} +
+ )} + +
+ + + + {keys.map((key) => ( + + ))} + + + + {mappedData.map((item, i) => { + return ( + { + onChange(mapProp(data[i])); + + setOpen(false); + }} + > + {keys.map((key) => ( + + ))} + + ); + })} + +
+ {key} +
+ {item[key]} +
+ +
+ +
{mappedData.length} result{mappedData.length === 1 ? "" : "s"}
-
+
); diff --git a/packages/core/components/ExternalInput/styles.module.css b/packages/core/components/ExternalInput/styles.module.css index 6f0727e841..6073b07011 100644 --- a/packages/core/components/ExternalInput/styles.module.css +++ b/packages/core/components/ExternalInput/styles.module.css @@ -73,13 +73,49 @@ .ExternalInputModal { color: var(--puck-color-black); - display: flex; - flex-direction: column; + display: grid; + grid-template-rows: min-content minmax(128px, 100%) min-content; + grid-template-columns: 100%; position: relative; min-height: 50vh; max-height: 90vh; } +.ExternalInputModal-grid { + display: flex; + flex-direction: column; +} + +@media (min-width: 458px) { + .ExternalInputModal-grid { + display: grid; + grid-template-columns: 100%; + } + + .ExternalInputModal--filtersToggled .ExternalInputModal-grid { + grid-template-columns: 25% 75%; + } +} + +.ExternalInputModal-filters { + border-bottom: 1px solid var(--puck-color-grey-09); +} + +.ExternalInputModal--filtersToggled .ExternalInputModal-filters { + display: none; /* Hide filters by default on smaller viewports */ +} + +@media (min-width: 458px) { + .ExternalInputModal-filters { + border-right: 1px solid var(--puck-color-grey-09); + display: none; + } + + .ExternalInputModal--filtersToggled .ExternalInputModal-filters { + display: block; /* Show filters by default on larger viewports */ + } +} + .ExternalInputModal-masthead { background-color: var(--puck-color-grey-12); border-bottom: 1px solid var(--puck-color-grey-09); @@ -106,6 +142,7 @@ } .ExternalInputModal-thead { + background-color: var(--puck-color-white); position: sticky; top: 0; z-index: 1; @@ -184,11 +221,17 @@ .ExternalInputModal-searchForm { display: flex; - height: 43px; + flex-wrap: wrap; gap: 12px; flex-grow: 1; } +@media (min-width: 458px) { + .ExternalInputModal-searchForm { + flex-wrap: nowrap; + } +} + .ExternalInputModal-search { display: flex; background: var(--puck-color-white); @@ -196,8 +239,8 @@ border-style: solid; border-color: var(--puck-color-grey-09); border-radius: 4px; + flex-grow: 1; transition: border-color 50ms ease-in; - width: 100%; } .ExternalInputModal-search:focus-within { @@ -262,6 +305,23 @@ outline: 0; } +.ExternalInputModal-searchActions { + display: flex; + gap: 8px; + height: 44px; + width: 100%; +} + +@media (min-width: 458px) { + .ExternalInputModal-searchActions { + width: auto; + } +} + +.ExternalInputModal-searchActionIcon { + align-self: center; +} + .ExternalInputModal-footer { background-color: var(--puck-color-grey-12); border-top: 1px solid var(--puck-color-grey-09); diff --git a/packages/core/components/Modal/styles.module.css b/packages/core/components/Modal/styles.module.css index abb6453eb1..7b3ecec832 100644 --- a/packages/core/components/Modal/styles.module.css +++ b/packages/core/components/Modal/styles.module.css @@ -9,7 +9,7 @@ bottom: 0; right: 0; z-index: 1; - padding: 64px; + padding: 32px; } .Modal--isOpen { diff --git a/packages/core/types/Config.tsx b/packages/core/types/Config.tsx index bed43f6f6b..e8dfffd0e4 100644 --- a/packages/core/types/Config.tsx +++ b/packages/core/types/Config.tsx @@ -88,12 +88,17 @@ export type ExternalField< > = BaseField & { type: "external"; placeholder?: string; - fetchList: (params: { query: string }) => Promise; + fetchList: (params: { + query: string; + filters: Record; + }) => Promise; mapProp?: (value: any) => Props; mapRow?: (value: any) => Record; getItemSummary: (item: Props, index?: number) => string; showSearch?: boolean; initialQuery?: string; + filterFields?: Record; + initialFilters?: Record; }; export type CustomField<