From fea434299976355e13604a204d1c3e18ada1df09 Mon Sep 17 00:00:00 2001 From: nileshgulia1 Date: Tue, 17 Mar 2026 19:55:07 +0530 Subject: [PATCH 01/23] add base listing block component --- packages/blocks/Listing/ListingEdit.tsx | 36 +++++++++++++++++++ packages/blocks/Listing/index.tsx | 7 ++++ packages/blocks/Listing/schema.tsx | 27 ++++++++++++++ .../QuerystringWidget/QuerystringWidget.tsx | 23 ++++++++++++ packages/cmsui/config/widgets.ts | 7 ++++ 5 files changed, 100 insertions(+) create mode 100644 packages/blocks/Listing/ListingEdit.tsx create mode 100644 packages/blocks/Listing/schema.tsx create mode 100644 packages/cmsui/components/QuerystringWidget/QuerystringWidget.tsx diff --git a/packages/blocks/Listing/ListingEdit.tsx b/packages/blocks/Listing/ListingEdit.tsx new file mode 100644 index 00000000000..9357eed53e5 --- /dev/null +++ b/packages/blocks/Listing/ListingEdit.tsx @@ -0,0 +1,36 @@ +import { useCallback, type ComponentType } from 'react'; +import type { BlockEditProps } from '@plone/types'; + +import config from '@plone/registry'; +import ListingBlockView from './ListingBlockView'; + +const hasQuery = (value: any): boolean => { + if (!value) return false; + + if (typeof value === 'object' && 'query' in value) { + const query = value.query; + return Array.isArray(query) && query.length > 0; + } + + return false; +}; +const ListingEdit = (props: BlockEditProps) => { + const { data } = props; + const hasListingQuery = hasQuery(data.querystring as any); + + const QuerystringWidget = config.getWidget('querystring') as + | ComponentType + | undefined; + + if (!hasListingQuery) { + return ( +
+

No Results Found

+
+ ); + } + + return ; +}; + +export default ListingEdit; diff --git a/packages/blocks/Listing/index.tsx b/packages/blocks/Listing/index.tsx index 82b8fb2ed8a..c75023b710e 100644 --- a/packages/blocks/Listing/index.tsx +++ b/packages/blocks/Listing/index.tsx @@ -1,4 +1,6 @@ import React from 'react'; +import { ListIcon } from '@plone/components/Icons'; +import { ListingSchema } from './schema'; const ListingBlockInfo = { id: 'listing', @@ -6,7 +8,12 @@ const ListingBlockInfo = { view: React.lazy( () => import(/* webpackChunkName: "plone-blocks" */ './ListingBlockView'), ), + edit: React.lazy( + () => import(/* webpackChunkName: "plone-blocks" */ './ListingEdit'), + ), category: 'common', + blockSchema: ListingSchema, + icon: ListIcon, }; export default ListingBlockInfo; diff --git a/packages/blocks/Listing/schema.tsx b/packages/blocks/Listing/schema.tsx new file mode 100644 index 00000000000..c6edfb93be9 --- /dev/null +++ b/packages/blocks/Listing/schema.tsx @@ -0,0 +1,27 @@ +import type { JSONSchema } from '@plone/types'; + +export function ListingSchema(): JSONSchema { + return { + title: 'Listing', + fieldsets: [ + { + id: 'default', + title: 'Default', + fields: ['headline', 'querystring'], + }, + ], + properties: { + headline: { + title: 'Headline', + }, + + querystring: { + title: 'Query', + description: + 'Enter a querystring to filter the content items to be listed. For example: "Type: News Item" or "path: /news".', + }, + widget: 'querystring', + }, + required: ['querystring'], + }; +} diff --git a/packages/cmsui/components/QuerystringWidget/QuerystringWidget.tsx b/packages/cmsui/components/QuerystringWidget/QuerystringWidget.tsx new file mode 100644 index 00000000000..b31540824b7 --- /dev/null +++ b/packages/cmsui/components/QuerystringWidget/QuerystringWidget.tsx @@ -0,0 +1,23 @@ +import type { BaseFormFieldProps } from '../TextField/TextField'; +import { useLoaderData } from 'react-router'; +import type { loader as editLoader } from '../../routes/edit'; + +interface QuerystringWidgetProps extends BaseFormFieldProps { + // Add your custom props here if needed +} + +export function QuerystringWidget(props: QuerystringWidgetProps) { + const { content } = useLoaderData(); + const { label, description, errorMessage, ...rest } = props; + + return ( +
+ {label && } + {description &&

{description}

} + {/* Add your querystring input here */} + {errorMessage &&

{errorMessage}

} +
+ ); +} + +QuerystringWidget.displayName = 'QuerystringWidget'; diff --git a/packages/cmsui/config/widgets.ts b/packages/cmsui/config/widgets.ts index 07150b5fec7..d6e81b872d8 100644 --- a/packages/cmsui/config/widgets.ts +++ b/packages/cmsui/config/widgets.ts @@ -10,6 +10,7 @@ import { import { DateField } from '@plone/components'; import { ObjectBrowserWidget } from '../components/ObjectBrowserWidget/ObjectBrowserWidget'; import ImageWidget from '../components/ImageWidget/ImageWidget'; +import { QuerystringWidget } from '../components/QuerystringWidget/QuerystringWidget'; export default function install(config: ConfigType) { config.registerDefaultWidget(TextField); @@ -47,6 +48,12 @@ export default function install(config: ConfigType) { object_browser: ObjectBrowserWidget, }, }); + config.registerWidget({ + key: 'widget', + definition: { + querystring: QuerystringWidget, + }, + }); config.registerWidget({ key: 'vocabulary', definition: { From 2927d92acaa7679938f898fa9405f12c0fc05331 Mon Sep 17 00:00:00 2001 From: nileshgulia1 Date: Thu, 19 Mar 2026 12:29:56 +0530 Subject: [PATCH 02/23] feat(QuerystringWidget): add Querystring Widget for seven --- .../QuerystringWidget.stories.tsx | 272 ++++++++++++ .../QuerystringWidget/QuerystringWidget.tsx | 420 +++++++++++++++++- .../QuerystringWidgetContext.tsx | 229 ++++++++++ 3 files changed, 913 insertions(+), 8 deletions(-) create mode 100644 packages/cmsui/components/QuerystringWidget/QuerystringWidget.stories.tsx create mode 100644 packages/cmsui/components/QuerystringWidget/QuerystringWidgetContext.tsx diff --git a/packages/cmsui/components/QuerystringWidget/QuerystringWidget.stories.tsx b/packages/cmsui/components/QuerystringWidget/QuerystringWidget.stories.tsx new file mode 100644 index 00000000000..4ffae0601f9 --- /dev/null +++ b/packages/cmsui/components/QuerystringWidget/QuerystringWidget.stories.tsx @@ -0,0 +1,272 @@ +import { useMemo } from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { + RouterProvider, + createMemoryRouter, + type LoaderFunctionArgs, +} from 'react-router'; +import type { QuerystringValue } from './QuerystringWidgetContext'; +import { QuerystringWidget } from './QuerystringWidget'; + +interface QuerystringWidgetStoryProps { + label?: string; + description?: string; + errorMessage?: string; + value?: QuerystringValue; + onChange?: (value: QuerystringValue) => void; +} + +const createQuerystringLoader = () => { + return ({ request }: LoaderFunctionArgs) => { + return { + content: { + '@id': '/Test/Document', + }, + }; + }; +}; + +const createQuerystringRouter = (props: QuerystringWidgetStoryProps) => + createMemoryRouter( + [ + { + id: 'root', + path: '/', + loader: createQuerystringLoader(), + element: ( +
+
+ +
+
+ ), + }, + ], + { + initialEntries: ['/'], + }, + ); + +const StoryRouter = (props: QuerystringWidgetStoryProps) => { + const router = useMemo(() => createQuerystringRouter(props), [props]); + return ; +}; + +const meta = { + component: QuerystringWidget, + parameters: { + layout: 'fullscreen', + backgrounds: { disable: true }, + }, + argTypes: { + onChange: { action: 'onChange' }, + }, + tags: ['autodocs'], + args: { + label: 'Search Criteria', + description: 'Define search criteria to filter content', + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +/** + * Default empty state of the QuerystringWidget with no criteria + */ +export const Default: Story = { + render: (args) => , +}; + +/** + * QuerystringWidget with a single criterion + */ +export const WithSingleCriterion: Story = { + render: (args) => , + args: { + value: { + query: [ + { + i: 'Creator', + o: 'is', + v: 'admin', + }, + ], + sort_on: 'Title', + sort_order: 'ascending', + limit: 100, + b_size: 50, + } as QuerystringValue, + }, +}; + +/** + * QuerystringWidget with multiple criteria + */ +export const WithMultipleCriteria: Story = { + render: (args) => , + args: { + value: { + query: [ + { + i: 'Creator', + o: 'is', + v: 'admin', + }, + { + i: 'Title', + o: 'has', + v: 'Document', + }, + { + i: 'modified', + o: 'before', + v: '2024-12-31', + }, + ], + sort_on: 'modified', + sort_order: 'descending', + limit: 50, + b_size: 25, + } as QuerystringValue, + }, +}; + +/** + * QuerystringWidget with path criterion (shows depth field) + */ +export const WithPathCriterionAndDepth: Story = { + render: (args) => , + args: { + value: { + query: [ + { + i: 'path', + o: 'is', + v: '/Test/Folder', + }, + { + i: 'review_state', + o: 'is', + v: 'published', + }, + ], + depth: 2, + sort_on: 'Title', + sort_order: 'ascending', + limit: 100, + b_size: 50, + } as QuerystringValue, + }, +}; + +/** + * QuerystringWidget in disabled state + */ +export const Disabled: Story = { + render: (args) => , + args: { + isDisabled: true, + value: { + query: [ + { + i: 'Creator', + o: 'is', + v: 'admin', + }, + ], + sort_on: 'Title', + sort_order: 'ascending', + limit: 100, + b_size: 50, + } as QuerystringValue, + }, +}; + +/** + * QuerystringWidget with error state + */ +export const WithError: Story = { + render: (args) => , + args: { + errorMessage: 'Please check your search criteria', + value: { + query: [ + { + i: 'Creator', + o: 'is', + v: '', + }, + ], + } as QuerystringValue, + }, +}; + +/** + * QuerystringWidget with all options configured + */ +export const FullyConfigured: Story = { + render: (args) => , + args: { + label: 'Advanced Search', + description: + 'Build complex search queries by adding multiple criteria. Criteria are combined with AND logic.', + value: { + query: [ + { + i: 'Title', + o: 'has', + v: 'news', + }, + { + i: 'Creator', + o: 'is', + v: 'site_owner', + }, + { + i: 'modified', + o: 'after', + v: '2024-01-01', + }, + { + i: 'review_state', + o: 'is', + v: 'published', + }, + ], + sort_on: 'modified', + sort_order: 'descending', + limit: 200, + b_size: 25, + } as QuerystringValue, + }, +}; + +/** + * QuerystringWidget with complex date-based criteria + */ +export const WithDateCriteria: Story = { + render: (args) => , + args: { + label: 'Date-based Search', + description: 'Filter content by creation and modification dates', + value: { + query: [ + { + i: 'created', + o: 'after', + v: '2024-01-01', + }, + { + i: 'modified', + o: 'before', + v: '2024-12-31', + }, + ], + sort_on: 'created', + sort_order: 'descending', + limit: 50, + b_size: 10, + } as QuerystringValue, + }, +}; diff --git a/packages/cmsui/components/QuerystringWidget/QuerystringWidget.tsx b/packages/cmsui/components/QuerystringWidget/QuerystringWidget.tsx index b31540824b7..1d01e3b8974 100644 --- a/packages/cmsui/components/QuerystringWidget/QuerystringWidget.tsx +++ b/packages/cmsui/components/QuerystringWidget/QuerystringWidget.tsx @@ -1,22 +1,426 @@ import type { BaseFormFieldProps } from '../TextField/TextField'; +import { + Description, + fieldBorderStyles, + FieldError, + Label, +} from '../Field/Field'; import { useLoaderData } from 'react-router'; import type { loader as editLoader } from '../../routes/edit'; +import { useId, useCallback, useMemo } from 'react'; +import { tv } from 'tailwind-variants'; +import { focusRing } from '../utils'; +import { + Select, + SelectItem, + TextField, + NumberField, + Switch, + Button, +} from '@plone/components'; +import { + QuerystringProvider, + useQuerystringContext, + type QuerystringValue, + type QueryCriterion, + type FieldMetadata, +} from './QuerystringWidgetContext'; +import { BinIcon, AddIcon } from '@plone/components/Icons'; + +const widgetStyles = tv({ + extend: focusRing, + base: 'mx-1 flex flex-col gap-4 rounded-md p-4', + variants: { + isFocused: fieldBorderStyles.variants.isFocusWithin, + isInvalid: fieldBorderStyles.variants.isInvalid, + isDisabled: fieldBorderStyles.variants.isDisabled, + }, +}); interface QuerystringWidgetProps extends BaseFormFieldProps { - // Add your custom props here if needed + value?: QuerystringValue; + onChange?: (value: QuerystringValue) => void; } +/** + * Query builder row component for individual criteria + */ +function QueryCriterionRow({ + criterion, + index, + availableFields, + disabled, + onChange, + onRemove, +}: { + criterion: QueryCriterion; + index: number; + availableFields: FieldMetadata[]; + disabled: boolean; + onChange: (criterion: QueryCriterion) => void; + onRemove: () => void; +}) { + const field = useMemo( + () => availableFields.find((f) => f.name === criterion.i), + [criterion.i, availableFields], + ); + + const handleFieldChange = (fieldName: string) => { + const newField = availableFields.find((f) => f.name === fieldName); + onChange({ + i: fieldName, + o: newField?.operators?.[0]?.value ?? '', + v: '', + }); + }; + + const handleOperatorChange = (operator: string) => { + onChange({ + ...criterion, + o: operator, + }); + }; + + const handleValueChange = (newValue: string | number) => { + onChange({ + ...criterion, + v: newValue, + }); + }; + + return ( +
+
+ +
+ +
+ +
+ +
+ {field?.valueType === 'date' ? ( + handleValueChange(value)} + isDisabled={disabled} + /> + ) : ( + handleValueChange(value)} + isDisabled={disabled} + placeholder="Enter value..." + /> + )} +
+ + +
+ ); +} + +/** + * Inner component with the widget UI + */ +function QuerystringWidgetComponent(props: QuerystringWidgetProps) { + const { label, description, errorMessage, value = {}, onChange } = props; + const id = useId(); + const { + availableFields, + availableSortFields, + value: contextValue, + setValue, + addCriterion, + removeCriterion, + updateCriterion, + } = useQuerystringContext(); + + // Sync context value with prop value + const synced = useMemo( + () => ({ ...value, ...contextValue }), + [value, contextValue], + ); + + // Handle changes by transforming and calling onChange + const handleValueChange = useCallback( + (newValue: QuerystringValue) => { + // Transform sort_order_boolean to sort_order if needed + const transformedValue = newValue; + if ('sort_order_boolean' in newValue) { + const { sort_order_boolean } = newValue as any; + transformedValue.sort_order = sort_order_boolean + ? 'descending' + : 'ascending'; + delete (transformedValue as any).sort_order_boolean; + } + + setValue(transformedValue); + onChange?.(transformedValue); + }, + [onChange, setValue], + ); + + const handleCriterionChange = useCallback( + (index: number, criterion: QueryCriterion) => { + updateCriterion(index, criterion); + const updated = { + ...synced, + query: synced.query ? [...synced.query] : [], + }; + if (!updated.query) updated.query = []; + updated.query[index] = criterion; + handleValueChange(updated); + }, + [synced, updateCriterion, handleValueChange], + ); + + const handleRemove = useCallback( + (index: number) => { + removeCriterion(index); + const updated = { + ...synced, + query: synced.query?.filter((_, i) => i !== index), + }; + handleValueChange(updated); + }, + [synced, removeCriterion, handleValueChange], + ); + + const handleAddCriterion = useCallback(() => { + addCriterion(); + const updated = { + ...synced, + query: [ + ...(synced.query ?? []), + { + i: availableFields[0]?.name ?? '', + o: availableFields[0]?.operators?.[0]?.value ?? '', + v: '', + }, + ], + }; + handleValueChange(updated); + }, [synced, addCriterion, availableFields, handleValueChange]); + + const hasNoQueryCriteria = !synced.query || synced.query.length === 0; + const hasPathCriterion = synced.query?.some((q) => q.i === 'path'); + const sortOrderBoolean = synced.sort_order === 'descending'; + + return ( +
+ {label && ( + + )} + +
+ {/* Query Criteria Section */} +
+

+ Criteria +

+ + {synced.query && synced.query.length > 0 ? ( +
+ {synced.query.map((criterion, index) => ( + handleCriterionChange(index, updated)} + onRemove={() => handleRemove(index)} + /> + ))} +
+ ) : ( +

+ No criteria added yet +

+ )} + + +
+ + {/* Divider */} + {!hasNoQueryCriteria &&
} + + {/* Display Options Section */} + {!hasNoQueryCriteria && ( +
+ {/* Depth Field - Conditional */} + {hasPathCriterion && ( +
+ + handleValueChange({ + ...synced, + depth: value, + }) + } + minValue={0} + maxValue={10} + /> +
+ )} + + {/* Sort By Field */} +
+ +
+ + {/* Sort Order Toggle */} +
+ + handleValueChange({ + ...synced, + sort_order: selected ? 'descending' : 'ascending', + }) + } + > + Reverse order + +
+ + {/* Results Options Row */} +
+
+ + handleValueChange({ + ...synced, + limit: value, + }) + } + minValue={0} + /> +
+
+ + handleValueChange({ + ...synced, + b_size: value, + }) + } + minValue={1} + /> +
+
+
+ )} +
+ + {description && {description}} + {errorMessage} +
+ ); +} + +/** + * Outer component that connects to form data loading + */ export function QuerystringWidget(props: QuerystringWidgetProps) { - const { content } = useLoaderData(); + useLoaderData(); const { label, description, errorMessage, ...rest } = props; return ( -
- {label && } - {description &&

{description}

} - {/* Add your querystring input here */} - {errorMessage &&

{errorMessage}

} -
+ + } + /> ); } diff --git a/packages/cmsui/components/QuerystringWidget/QuerystringWidgetContext.tsx b/packages/cmsui/components/QuerystringWidget/QuerystringWidgetContext.tsx new file mode 100644 index 00000000000..3f514a00c29 --- /dev/null +++ b/packages/cmsui/components/QuerystringWidget/QuerystringWidgetContext.tsx @@ -0,0 +1,229 @@ +import { + createContext, + useContext, + useState, + useCallback, + useMemo, +} from 'react'; + +/** + * Represents a single query criterion + * i = field index name (e.g., "Creator", "Title", "path") + * o = operator (e.g., "is", "has", "before") + * v = value + */ +export interface QueryCriterion { + i: string; + o: string; + v: any; +} + +/** + * The complete querystring widget value structure + */ +export interface QuerystringValue { + query?: QueryCriterion[]; + depth?: number; + sort_on?: string; + sort_order?: 'ascending' | 'descending'; + limit?: number; + b_size?: number; +} + +/** + * Metadata about available fields and their operators + */ +export interface FieldMetadata { + name: string; + title: string; + operators: Array<{ value: string; label: string }>; + valueType?: 'text' | 'date' | 'number' | 'select'; + valueOptions?: Array<{ value: string; label: string }>; +} + +interface QuerystringContextType { + availableFields: FieldMetadata[]; + availableSortFields: Array<{ value: string; label: string }>; + value: QuerystringValue; + setValue: (value: QuerystringValue) => void; + addCriterion: () => void; + removeCriterion: (index: number) => void; + updateCriterion: (index: number, criterion: QueryCriterion) => void; +} + +const QuerystringContext = createContext( + undefined, +); + +export interface QuerystringProviderProps { + initialValue?: QuerystringValue; + availableFields?: FieldMetadata[]; + availableSortFields?: Array<{ value: string; label: string }>; + children: React.ReactNode; +} + +/** + * Default available fields based on typical Plone catalog indexes + */ +const DEFAULT_FIELDS: FieldMetadata[] = [ + { + name: 'Creator', + title: 'Creator', + operators: [ + { value: 'is', label: 'is' }, + { value: 'is_not', label: 'is not' }, + ], + }, + { + name: 'Title', + title: 'Title', + operators: [ + { value: 'is', label: 'is' }, + { value: 'has', label: 'has' }, + { value: 'is_not', label: 'is not' }, + ], + valueType: 'text', + }, + { + name: 'Description', + title: 'Description', + operators: [ + { value: 'is', label: 'is' }, + { value: 'has', label: 'has' }, + { value: 'is_not', label: 'is not' }, + ], + valueType: 'text', + }, + { + name: 'Subject', + title: 'Keywords', + operators: [ + { value: 'is', label: 'is' }, + { value: 'is_not', label: 'is not' }, + ], + }, + { + name: 'path', + title: 'Location', + operators: [ + { value: 'is', label: 'is' }, + { value: 'is_not', label: 'is not' }, + ], + }, + { + name: 'modified', + title: 'Last modified', + operators: [ + { value: 'before', label: 'before' }, + { value: 'on', label: 'on' }, + { value: 'after', label: 'after' }, + ], + valueType: 'date', + }, + { + name: 'created', + title: 'Created', + operators: [ + { value: 'before', label: 'before' }, + { value: 'on', label: 'on' }, + { value: 'after', label: 'after' }, + ], + valueType: 'date', + }, + { + name: 'review_state', + title: 'Review state', + operators: [ + { value: 'is', label: 'is' }, + { value: 'is_not', label: 'is not' }, + ], + }, +]; + +const DEFAULT_SORT_FIELDS = [ + { value: 'Title', label: 'Title' }, + { value: 'Creator', label: 'Creator' }, + { value: 'modified', label: 'Last modified' }, + { value: 'created', label: 'Created' }, + { value: 'Subject', label: 'Keywords' }, +]; + +export function QuerystringProvider({ + initialValue = {}, + availableFields = DEFAULT_FIELDS, + availableSortFields = DEFAULT_SORT_FIELDS, + children, +}: QuerystringProviderProps) { + const [value, setValue] = useState(initialValue); + + const addCriterion = useCallback(() => { + setValue((prev) => ({ + ...prev, + query: [ + ...(prev.query ?? []), + { + i: availableFields[0]?.name ?? '', + o: availableFields[0]?.operators?.[0]?.value ?? '', + v: '', + }, + ], + })); + }, [availableFields]); + + const removeCriterion = useCallback((index: number) => { + setValue((prev) => ({ + ...prev, + query: prev.query?.filter((_, i) => i !== index), + })); + }, []); + + const updateCriterion = useCallback( + (index: number, criterion: QueryCriterion) => { + setValue((prev) => { + const newQuery = [...(prev.query ?? [])]; + newQuery[index] = criterion; + return { + ...prev, + query: newQuery, + }; + }); + }, + [], + ); + + const contextValue = useMemo( + () => ({ + availableFields, + availableSortFields, + value, + setValue, + addCriterion, + removeCriterion, + updateCriterion, + }), + [ + value, + availableFields, + availableSortFields, + addCriterion, + removeCriterion, + updateCriterion, + ], + ); + + return ( + + {children} + + ); +} + +export function useQuerystringContext(): QuerystringContextType { + const context = useContext(QuerystringContext); + if (!context) { + throw new Error( + 'useQuerystringContext must be used within QuerystringProvider', + ); + } + return context; +} From 20f5feb6555fab2ec42e972993e47f3686ccd86b Mon Sep 17 00:00:00 2001 From: nileshgulia1 Date: Thu, 19 Mar 2026 12:38:40 +0530 Subject: [PATCH 03/23] refactor: remove listing block edits --- packages/blocks/Listing/ListingEdit.tsx | 36 ------------------------- packages/blocks/Listing/index.tsx | 7 ----- packages/blocks/Listing/schema.tsx | 27 ------------------- 3 files changed, 70 deletions(-) delete mode 100644 packages/blocks/Listing/ListingEdit.tsx delete mode 100644 packages/blocks/Listing/schema.tsx diff --git a/packages/blocks/Listing/ListingEdit.tsx b/packages/blocks/Listing/ListingEdit.tsx deleted file mode 100644 index 9357eed53e5..00000000000 --- a/packages/blocks/Listing/ListingEdit.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import { useCallback, type ComponentType } from 'react'; -import type { BlockEditProps } from '@plone/types'; - -import config from '@plone/registry'; -import ListingBlockView from './ListingBlockView'; - -const hasQuery = (value: any): boolean => { - if (!value) return false; - - if (typeof value === 'object' && 'query' in value) { - const query = value.query; - return Array.isArray(query) && query.length > 0; - } - - return false; -}; -const ListingEdit = (props: BlockEditProps) => { - const { data } = props; - const hasListingQuery = hasQuery(data.querystring as any); - - const QuerystringWidget = config.getWidget('querystring') as - | ComponentType - | undefined; - - if (!hasListingQuery) { - return ( -
-

No Results Found

-
- ); - } - - return ; -}; - -export default ListingEdit; diff --git a/packages/blocks/Listing/index.tsx b/packages/blocks/Listing/index.tsx index c75023b710e..82b8fb2ed8a 100644 --- a/packages/blocks/Listing/index.tsx +++ b/packages/blocks/Listing/index.tsx @@ -1,6 +1,4 @@ import React from 'react'; -import { ListIcon } from '@plone/components/Icons'; -import { ListingSchema } from './schema'; const ListingBlockInfo = { id: 'listing', @@ -8,12 +6,7 @@ const ListingBlockInfo = { view: React.lazy( () => import(/* webpackChunkName: "plone-blocks" */ './ListingBlockView'), ), - edit: React.lazy( - () => import(/* webpackChunkName: "plone-blocks" */ './ListingEdit'), - ), category: 'common', - blockSchema: ListingSchema, - icon: ListIcon, }; export default ListingBlockInfo; diff --git a/packages/blocks/Listing/schema.tsx b/packages/blocks/Listing/schema.tsx deleted file mode 100644 index c6edfb93be9..00000000000 --- a/packages/blocks/Listing/schema.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import type { JSONSchema } from '@plone/types'; - -export function ListingSchema(): JSONSchema { - return { - title: 'Listing', - fieldsets: [ - { - id: 'default', - title: 'Default', - fields: ['headline', 'querystring'], - }, - ], - properties: { - headline: { - title: 'Headline', - }, - - querystring: { - title: 'Query', - description: - 'Enter a querystring to filter the content items to be listed. For example: "Type: News Item" or "path: /news".', - }, - widget: 'querystring', - }, - required: ['querystring'], - }; -} From 3cd04b5d9203c0fa7b7d7e5ccdd73d71e5aa0721 Mon Sep 17 00:00:00 2001 From: nileshgulia1 Date: Thu, 19 Mar 2026 12:56:19 +0530 Subject: [PATCH 04/23] fix: eslint --- .../QuerystringWidget/QuerystringWidget.tsx | 24 +++++++++---------- 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/packages/cmsui/components/QuerystringWidget/QuerystringWidget.tsx b/packages/cmsui/components/QuerystringWidget/QuerystringWidget.tsx index 1d01e3b8974..670dbe1efb0 100644 --- a/packages/cmsui/components/QuerystringWidget/QuerystringWidget.tsx +++ b/packages/cmsui/components/QuerystringWidget/QuerystringWidget.tsx @@ -1,3 +1,5 @@ +import { useId, useCallback, useMemo } from 'react'; +import { tv } from 'tailwind-variants'; import type { BaseFormFieldProps } from '../TextField/TextField'; import { Description, @@ -7,8 +9,7 @@ import { } from '../Field/Field'; import { useLoaderData } from 'react-router'; import type { loader as editLoader } from '../../routes/edit'; -import { useId, useCallback, useMemo } from 'react'; -import { tv } from 'tailwind-variants'; + import { focusRing } from '../utils'; import { Select, @@ -410,17 +411,14 @@ export function QuerystringWidget(props: QuerystringWidgetProps) { const { label, description, errorMessage, ...rest } = props; return ( - - } - /> + + + ); } From a7b584b61478a30bbb5c2e95707823daed28b698 Mon Sep 17 00:00:00 2001 From: nileshgulia1 Date: Thu, 19 Mar 2026 18:06:08 +0530 Subject: [PATCH 05/23] refactor: get Querystring options for criteria from backend --- .../QuerystringWidget.stories.tsx | 140 +++++++++++++++--- .../QuerystringWidget/QuerystringWidget.tsx | 21 +-- .../QuerystringWidgetContext.tsx | 86 +++++++++-- packages/cmsui/index.ts | 11 ++ packages/cmsui/routes/queryStringOptions.tsx | 68 +++++++++ 5 files changed, 284 insertions(+), 42 deletions(-) create mode 100644 packages/cmsui/routes/queryStringOptions.tsx diff --git a/packages/cmsui/components/QuerystringWidget/QuerystringWidget.stories.tsx b/packages/cmsui/components/QuerystringWidget/QuerystringWidget.stories.tsx index 4ffae0601f9..14633e400cf 100644 --- a/packages/cmsui/components/QuerystringWidget/QuerystringWidget.stories.tsx +++ b/packages/cmsui/components/QuerystringWidget/QuerystringWidget.stories.tsx @@ -41,6 +41,119 @@ const createQuerystringRouter = (props: QuerystringWidgetStoryProps) =>
), }, + { + path: '/@queryStringOptions', + loader: () => ({ + indexes: { + Creator: { + title: 'Creator', + description: 'The person that created an item', + enabled: true, + sortable: true, + group: 'Metadata', + operators: { + is: { title: 'Is', description: null, widget: null }, + }, + }, + Title: { + title: 'Title', + description: "Text search of an item's title", + enabled: true, + sortable: false, + group: 'Text', + operators: { + has: { title: 'Contains', description: null, widget: null }, + }, + }, + Subject: { + title: 'Tag', + description: 'Tags are used for organization of content', + enabled: true, + sortable: false, + group: 'Text', + operators: { + is: { title: 'Is', description: null, widget: null }, + }, + }, + path: { + title: 'Location', + description: 'The location of an item', + enabled: true, + sortable: false, + group: 'Metadata', + operators: { + is: { + title: 'Navigation path', + description: null, + widget: null, + }, + }, + }, + modified: { + title: 'Modification date', + description: 'The time and date an item was last modified', + enabled: true, + sortable: true, + group: 'Dates', + operators: { + before: { + title: 'Before date', + description: null, + widget: null, + }, + after: { title: 'After date', description: null, widget: null }, + }, + }, + created: { + title: 'Creation date', + description: 'The date an item was created', + enabled: true, + sortable: true, + group: 'Dates', + operators: { + before: { + title: 'Before date', + description: null, + widget: null, + }, + after: { title: 'After date', description: null, widget: null }, + }, + }, + review_state: { + title: 'Review state', + description: "An item's workflow state (e.g.published)", + enabled: true, + sortable: true, + group: 'Metadata', + operators: { + is: { title: 'Is', description: null, widget: null }, + }, + values: { + published: { title: 'Published' }, + pending: { title: 'Pending review' }, + private: { title: 'Private' }, + }, + }, + portal_type: { + title: 'Type', + description: "An item's type (e.g. Event)", + enabled: true, + sortable: false, + group: 'Metadata', + operators: { + is: { title: 'Is', description: null, widget: null }, + }, + values: { + Document: { title: 'Page' }, + Folder: { title: 'Folder' }, + Image: { title: 'Image' }, + File: { title: 'File' }, + Event: { title: 'Event' }, + }, + }, + }, + }), + }, ], { initialEntries: ['/'], @@ -161,34 +274,13 @@ export const WithPathCriterionAndDepth: Story = { }; /** - * QuerystringWidget in disabled state - */ -export const Disabled: Story = { - render: (args) => , - args: { - isDisabled: true, - value: { - query: [ - { - i: 'Creator', - o: 'is', - v: 'admin', - }, - ], - sort_on: 'Title', - sort_order: 'ascending', - limit: 100, - b_size: 50, - } as QuerystringValue, - }, -}; - -/** - * QuerystringWidget with error state + * QuerystringWidget in error state */ export const WithError: Story = { render: (args) => , args: { + label: 'Search Criteria', + description: 'Define search criteria to filter content', errorMessage: 'Please check your search criteria', value: { query: [ diff --git a/packages/cmsui/components/QuerystringWidget/QuerystringWidget.tsx b/packages/cmsui/components/QuerystringWidget/QuerystringWidget.tsx index 670dbe1efb0..28084fe25b5 100644 --- a/packages/cmsui/components/QuerystringWidget/QuerystringWidget.tsx +++ b/packages/cmsui/components/QuerystringWidget/QuerystringWidget.tsx @@ -1,6 +1,6 @@ import { useId, useCallback, useMemo } from 'react'; import { tv } from 'tailwind-variants'; -import type { BaseFormFieldProps } from '../TextField/TextField'; +import type { TextFieldProps as QuantaTextFieldProps } from '@plone/components/quanta'; import { Description, fieldBorderStyles, @@ -28,6 +28,11 @@ import { } from './QuerystringWidgetContext'; import { BinIcon, AddIcon } from '@plone/components/Icons'; +type BaseFormFieldProps = Pick< + QuantaTextFieldProps, + 'label' | 'description' | 'errorMessage' | 'placeholder' +>; + const widgetStyles = tv({ extend: focusRing, base: 'mx-1 flex flex-col gap-4 rounded-md p-4', @@ -90,10 +95,10 @@ function QueryCriterionRow({ }; return ( -
+
key && handleOperatorChange(key as string) @@ -126,7 +131,7 @@ function QueryCriterionRow({
{field?.valueType === 'date' ? ( handleValueChange(value)} @@ -134,7 +139,7 @@ function QueryCriterionRow({ /> ) : ( handleValueChange(value)} isDisabled={disabled} @@ -403,11 +408,9 @@ function QuerystringWidgetComponent(props: QuerystringWidgetProps) { ); } -/** - * Outer component that connects to form data loading - */ export function QuerystringWidget(props: QuerystringWidgetProps) { useLoaderData(); + const { label, description, errorMessage, ...rest } = props; return ( diff --git a/packages/cmsui/components/QuerystringWidget/QuerystringWidgetContext.tsx b/packages/cmsui/components/QuerystringWidget/QuerystringWidgetContext.tsx index 3f514a00c29..de1e7243851 100644 --- a/packages/cmsui/components/QuerystringWidget/QuerystringWidgetContext.tsx +++ b/packages/cmsui/components/QuerystringWidget/QuerystringWidgetContext.tsx @@ -4,7 +4,10 @@ import { useState, useCallback, useMemo, + useEffect, } from 'react'; +import { useFetcher } from 'react-router'; +import type { loader, BackendIndex } from '../../routes/queryStringOptions'; /** * Represents a single query criterion @@ -41,6 +44,52 @@ export interface FieldMetadata { valueOptions?: Array<{ value: string; label: string }>; } +export function transformBackendIndexes( + backendIndexes: Record, +): FieldMetadata[] { + return Object.entries(backendIndexes) + .filter(([_, index]) => index.enabled) + .map(([name, index]) => { + // Detect value type from operator widget and field title + const firstOperatorWidget = Object.values(index.operators)[0]?.widget; + let valueType: 'text' | 'date' | 'number' | 'select' = 'text'; + + if ( + firstOperatorWidget?.includes('Date') || + index.title.toLowerCase().includes('date') + ) { + valueType = 'date'; + } else if ( + firstOperatorWidget?.includes('Number') || + firstOperatorWidget?.includes('Int') + ) { + valueType = 'number'; + } else if (firstOperatorWidget?.includes('Selection')) { + valueType = 'select'; + } + + // Extract select options from values or vocabulary + let valueOptions: Array<{ value: string; label: string }> | undefined; + if (index.values && Object.keys(index.values).length > 0) { + valueOptions = Object.entries(index.values).map(([key, val]) => ({ + value: key, + label: val.title, + })); + } + + return { + name, + title: index.title, + operators: Object.entries(index.operators).map(([key, op]) => ({ + value: key, + label: op.title, + })), + valueType, + valueOptions, + }; + }); +} + interface QuerystringContextType { availableFields: FieldMetadata[]; availableSortFields: Array<{ value: string; label: string }>; @@ -59,12 +108,10 @@ export interface QuerystringProviderProps { initialValue?: QuerystringValue; availableFields?: FieldMetadata[]; availableSortFields?: Array<{ value: string; label: string }>; + backendIndexes?: Record; children: React.ReactNode; } -/** - * Default available fields based on typical Plone catalog indexes - */ const DEFAULT_FIELDS: FieldMetadata[] = [ { name: 'Creator', @@ -150,10 +197,31 @@ const DEFAULT_SORT_FIELDS = [ export function QuerystringProvider({ initialValue = {}, - availableFields = DEFAULT_FIELDS, + availableFields, availableSortFields = DEFAULT_SORT_FIELDS, + backendIndexes, children, }: QuerystringProviderProps) { + const fetcher = useFetcher(); + + // Fetch querystring options on mount + useEffect(() => { + if (fetcher.state === 'idle' && !fetcher.data) { + fetcher.load('/@queryStringOptions'); + } + }, [fetcher]); + + // Use transformed backend indexes from fetcher, prop, or defaults + const fetchedIndexes = (fetcher.data as any)?.indexes; + const indexes = backendIndexes || fetchedIndexes; + + const fields = useMemo( + () => + availableFields || + (indexes ? transformBackendIndexes(indexes) : DEFAULT_FIELDS), + [availableFields, indexes], + ); + const [value, setValue] = useState(initialValue); const addCriterion = useCallback(() => { @@ -162,13 +230,13 @@ export function QuerystringProvider({ query: [ ...(prev.query ?? []), { - i: availableFields[0]?.name ?? '', - o: availableFields[0]?.operators?.[0]?.value ?? '', + i: fields[0]?.name ?? '', + o: fields[0]?.operators?.[0]?.value ?? '', v: '', }, ], })); - }, [availableFields]); + }, [fields]); const removeCriterion = useCallback((index: number) => { setValue((prev) => ({ @@ -193,7 +261,7 @@ export function QuerystringProvider({ const contextValue = useMemo( () => ({ - availableFields, + availableFields: fields, availableSortFields, value, setValue, @@ -203,7 +271,7 @@ export function QuerystringProvider({ }), [ value, - availableFields, + fields, availableSortFields, addCriterion, removeCriterion, diff --git a/packages/cmsui/index.ts b/packages/cmsui/index.ts index ec46f872559..a2eb973b242 100644 --- a/packages/cmsui/index.ts +++ b/packages/cmsui/index.ts @@ -127,6 +127,17 @@ export default function install(config: ConfigType) { }, ], }); + config.registerRoute({ + type: 'prefix', + path: '@queryStringOptions', + children: [ + { + type: 'route', + path: '*', + file: '@plone/cmsui/routes/queryStringOptions.tsx', + }, + ], + }); config.registerRoute({ type: 'prefix', path: '@createContent', diff --git a/packages/cmsui/routes/queryStringOptions.tsx b/packages/cmsui/routes/queryStringOptions.tsx new file mode 100644 index 00000000000..5ee9e01f872 --- /dev/null +++ b/packages/cmsui/routes/queryStringOptions.tsx @@ -0,0 +1,68 @@ +import { data, type LoaderFunctionArgs } from 'react-router'; +import type PloneClient from '@plone/client'; +import { getAuthFromRequest } from '@plone/react-router'; +import config from '@plone/registry'; + +export interface BackendOperator { + title: string; + description?: string; + widget?: string | null; + operation?: string; +} + +export interface BackendIndex { + title: string; + description?: string; + enabled: boolean; + sortable: boolean; + operators: Record; + operations?: string[]; + group?: string; + values?: Record; + vocabulary?: string | null; + fetch_vocabulary?: boolean; +} + +export interface QuerystringOptionsResponse { + '@id': string; + indexes: Record; + sortable_indexes?: Record; +} + +export async function loader({ request }: LoaderFunctionArgs) { + const token = await getAuthFromRequest(request); + + const cli = config + .getUtility({ + name: 'ploneClient', + type: 'client', + }) + .method() as PloneClient; + + cli.config.token = token; + + try { + const qs = await cli.getQuerystring(); + const response = qs as unknown as QuerystringOptionsResponse; + + return data( + { indexes: response?.indexes || {} }, + { + headers: { + 'Content-Type': 'application/json', + }, + }, + ); + } catch (error) { + // eslint-disable-next-line no-console + console.error('Failed to fetch querystring options:', error); + return data( + { indexes: {} }, + { + headers: { + 'Content-Type': 'application/json', + }, + }, + ); + } +} From 93f1036300eb6496df63b01e9a3e1cdd05929af2 Mon Sep 17 00:00:00 2001 From: nileshgulia1 Date: Mon, 18 May 2026 13:48:01 +0200 Subject: [PATCH 06/23] refactor: use Selects from quanta --- .../QuerystringWidget/QuerystringWidget.tsx | 22 +++++++------------ 1 file changed, 8 insertions(+), 14 deletions(-) diff --git a/packages/cmsui/components/QuerystringWidget/QuerystringWidget.tsx b/packages/cmsui/components/QuerystringWidget/QuerystringWidget.tsx index 28084fe25b5..edec4b49a18 100644 --- a/packages/cmsui/components/QuerystringWidget/QuerystringWidget.tsx +++ b/packages/cmsui/components/QuerystringWidget/QuerystringWidget.tsx @@ -11,14 +11,8 @@ import { useLoaderData } from 'react-router'; import type { loader as editLoader } from '../../routes/edit'; import { focusRing } from '../utils'; -import { - Select, - SelectItem, - TextField, - NumberField, - Switch, - Button, -} from '@plone/components'; +import { TextField, NumberField, Switch, Button } from '@plone/components'; +import { Select, SelectItem } from '@plone/components/quanta'; import { QuerystringProvider, useQuerystringContext, @@ -35,7 +29,7 @@ type BaseFormFieldProps = Pick< const widgetStyles = tv({ extend: focusRing, - base: 'mx-1 flex flex-col gap-4 rounded-md p-4', + base: 'mx-1 flex flex-col gap-4 overflow-visible rounded-md p-4', variants: { isFocused: fieldBorderStyles.variants.isFocusWithin, isInvalid: fieldBorderStyles.variants.isInvalid, @@ -95,8 +89,8 @@ function QueryCriterionRow({ }; return ( -
-
+
+
{/* Query Criteria Section */} -
+

Criteria

{synced.query && synced.query.length > 0 ? ( -
+
{synced.query.map((criterion, index) => ( Date: Mon, 18 May 2026 13:54:30 +0200 Subject: [PATCH 07/23] chore: update changelog --- packages/cmsui/news/8007.feature | 1 + packages/cmsui/routes/queryStringOptions.tsx | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) create mode 100644 packages/cmsui/news/8007.feature diff --git a/packages/cmsui/news/8007.feature b/packages/cmsui/news/8007.feature new file mode 100644 index 00000000000..cadb1ac11e9 --- /dev/null +++ b/packages/cmsui/news/8007.feature @@ -0,0 +1 @@ +Added querystringWidget for seven @nileshgulia1 \ No newline at end of file diff --git a/packages/cmsui/routes/queryStringOptions.tsx b/packages/cmsui/routes/queryStringOptions.tsx index 5ee9e01f872..1a1a94a5561 100644 --- a/packages/cmsui/routes/queryStringOptions.tsx +++ b/packages/cmsui/routes/queryStringOptions.tsx @@ -37,7 +37,7 @@ export async function loader({ request }: LoaderFunctionArgs) { name: 'ploneClient', type: 'client', }) - .method() as PloneClient; + .method() as unknown as PloneClient; cli.config.token = token; From 971783df4f6059fc1c09e8df79bc4bf04a5c3fa3 Mon Sep 17 00:00:00 2001 From: nileshgulia1 Date: Mon, 18 May 2026 15:51:55 +0200 Subject: [PATCH 08/23] feat: add Seven listing block --- packages/blocks/Listing/ListingBlockEdit.tsx | 36 ++++++++++++++++++++ packages/blocks/Listing/index.tsx | 7 ++++ packages/blocks/Listing/schema.tsx | 27 +++++++++++++++ 3 files changed, 70 insertions(+) create mode 100644 packages/blocks/Listing/ListingBlockEdit.tsx create mode 100644 packages/blocks/Listing/schema.tsx diff --git a/packages/blocks/Listing/ListingBlockEdit.tsx b/packages/blocks/Listing/ListingBlockEdit.tsx new file mode 100644 index 00000000000..9357eed53e5 --- /dev/null +++ b/packages/blocks/Listing/ListingBlockEdit.tsx @@ -0,0 +1,36 @@ +import { useCallback, type ComponentType } from 'react'; +import type { BlockEditProps } from '@plone/types'; + +import config from '@plone/registry'; +import ListingBlockView from './ListingBlockView'; + +const hasQuery = (value: any): boolean => { + if (!value) return false; + + if (typeof value === 'object' && 'query' in value) { + const query = value.query; + return Array.isArray(query) && query.length > 0; + } + + return false; +}; +const ListingEdit = (props: BlockEditProps) => { + const { data } = props; + const hasListingQuery = hasQuery(data.querystring as any); + + const QuerystringWidget = config.getWidget('querystring') as + | ComponentType + | undefined; + + if (!hasListingQuery) { + return ( +
+

No Results Found

+
+ ); + } + + return ; +}; + +export default ListingEdit; diff --git a/packages/blocks/Listing/index.tsx b/packages/blocks/Listing/index.tsx index 6d4af2dbc30..b1b7b1abad8 100644 --- a/packages/blocks/Listing/index.tsx +++ b/packages/blocks/Listing/index.tsx @@ -1,5 +1,7 @@ import React from 'react'; import type { BlockConfigBase } from '@plone/types'; +import { ListIcon } from '@plone/components/Icons'; +import { ListingSchema } from './schema'; const ListingBlockInfo = { id: 'listing', @@ -7,6 +9,11 @@ const ListingBlockInfo = { view: React.lazy( () => import(/* webpackChunkName: "plone-blocks" */ './ListingBlockView'), ), + edit: React.lazy( + () => import(/* webpackChunkName: "plone-blocks" */ './ListingBlockEdit'), + ), + blockSchema: ListingSchema, + icon: ListIcon, category: 'common', } satisfies Partial; diff --git a/packages/blocks/Listing/schema.tsx b/packages/blocks/Listing/schema.tsx new file mode 100644 index 00000000000..c6edfb93be9 --- /dev/null +++ b/packages/blocks/Listing/schema.tsx @@ -0,0 +1,27 @@ +import type { JSONSchema } from '@plone/types'; + +export function ListingSchema(): JSONSchema { + return { + title: 'Listing', + fieldsets: [ + { + id: 'default', + title: 'Default', + fields: ['headline', 'querystring'], + }, + ], + properties: { + headline: { + title: 'Headline', + }, + + querystring: { + title: 'Query', + description: + 'Enter a querystring to filter the content items to be listed. For example: "Type: News Item" or "path: /news".', + }, + widget: 'querystring', + }, + required: ['querystring'], + }; +} From c1bd20cacdf9642d48b011ed58379e7228bd4773 Mon Sep 17 00:00:00 2001 From: nileshgulia1 Date: Tue, 19 May 2026 10:20:46 +0200 Subject: [PATCH 09/23] WIP: listing block --- packages/blocks/Listing/schema.tsx | 2 +- .../BlockEditor/BlockSettingsForm.tsx | 8 +++ .../BlockEditor/BlockSettingsFormRenderer.tsx | 1 + .../QuerystringWidget/QuerystringWidget.tsx | 27 ++++++- .../QuerystringWidgetContext.tsx | 39 +++++++++++ packages/cmsui/config/routes.ts | 11 +++ packages/cmsui/routes/queryStringOptions.tsx | 27 +++---- packages/cmsui/routes/querystringSearch.tsx | 70 +++++++++++++++++++ 8 files changed, 165 insertions(+), 20 deletions(-) create mode 100644 packages/cmsui/routes/querystringSearch.tsx diff --git a/packages/blocks/Listing/schema.tsx b/packages/blocks/Listing/schema.tsx index c6edfb93be9..d7555675341 100644 --- a/packages/blocks/Listing/schema.tsx +++ b/packages/blocks/Listing/schema.tsx @@ -19,8 +19,8 @@ export function ListingSchema(): JSONSchema { title: 'Query', description: 'Enter a querystring to filter the content items to be listed. For example: "Type: News Item" or "path: /news".', + widget: 'querystring', }, - widget: 'querystring', }, required: ['querystring'], }; diff --git a/packages/cmsui/components/BlockEditor/BlockSettingsForm.tsx b/packages/cmsui/components/BlockEditor/BlockSettingsForm.tsx index 71f39554e05..ea21f11a6d9 100644 --- a/packages/cmsui/components/BlockEditor/BlockSettingsForm.tsx +++ b/packages/cmsui/components/BlockEditor/BlockSettingsForm.tsx @@ -77,6 +77,14 @@ const BlockSettingsForm = (props: BlockSettingsFormProps) => { value, ); + props.onFormDataChange?.(nextData); + }, + onPatchFormData: (partial: Record) => { + const nextData = { + ...((form.state.values as Record) ?? {}), + ...partial, + }; + props.onFormDataChange?.(nextData); }, })} diff --git a/packages/cmsui/components/BlockEditor/BlockSettingsFormRenderer.tsx b/packages/cmsui/components/BlockEditor/BlockSettingsFormRenderer.tsx index 484acbc519c..57b4f20f9d4 100644 --- a/packages/cmsui/components/BlockEditor/BlockSettingsFormRenderer.tsx +++ b/packages/cmsui/components/BlockEditor/BlockSettingsFormRenderer.tsx @@ -15,6 +15,7 @@ type RendererSchema = { type BaseFieldExtraProps = { formAtom?: PrimitiveAtom; onChange?: (value: unknown) => void; + onPatchFormData?: (partial: Record) => void; }; type BlockSettingsFormRendererProps = { diff --git a/packages/cmsui/components/QuerystringWidget/QuerystringWidget.tsx b/packages/cmsui/components/QuerystringWidget/QuerystringWidget.tsx index edec4b49a18..b32c7ee98cc 100644 --- a/packages/cmsui/components/QuerystringWidget/QuerystringWidget.tsx +++ b/packages/cmsui/components/QuerystringWidget/QuerystringWidget.tsx @@ -1,4 +1,4 @@ -import { useId, useCallback, useMemo } from 'react'; +import { useId, useCallback, useMemo, useEffect, useRef } from 'react'; import { tv } from 'tailwind-variants'; import type { TextFieldProps as QuantaTextFieldProps } from '@plone/components/quanta'; import { @@ -40,6 +40,7 @@ const widgetStyles = tv({ interface QuerystringWidgetProps extends BaseFormFieldProps { value?: QuerystringValue; onChange?: (value: QuerystringValue) => void; + onPatchFormData?: (partial: Record) => void; } /** @@ -89,7 +90,12 @@ function QueryCriterionRow({ }; return ( -
+
key && handleFieldChange(key as string)} isDisabled={disabled} + placeholder="Search field…" > {availableFields.map((f) => ( - + {f.title} - + ))} - +
diff --git a/packages/components/src/components/ComboBox/ComboBox.quanta.tsx b/packages/components/src/components/ComboBox/ComboBox.quanta.tsx new file mode 100644 index 00000000000..a5733b462fd --- /dev/null +++ b/packages/components/src/components/ComboBox/ComboBox.quanta.tsx @@ -0,0 +1,148 @@ +import React from 'react'; +import { + Button, + ComboBox as RACComboBox, + type ComboBoxProps as RACComboBoxProps, + Group, + Input, + type ListBoxItemProps, + type ListBoxProps, + type ValidationResult, +} from 'react-aria-components'; +import { tv } from 'tailwind-variants'; + +import { Description, FieldError, Label } from '../Field/Field.quanta'; +import { DropdownItem, ListBox } from '../ListBox/ListBox.quanta'; +import { Popover } from '../Popover/Popover.quanta'; +import { composeTailwindRenderProps, focusRing } from '../utils'; +import { ChevrondownIcon } from '../icons'; + +const triggerStyles = tv({ + extend: focusRing, + base: ` + flex min-h-11 min-w-45 items-center gap-2 rounded-lg bg-quanta-snow py-1 pr-2 pl-3 text-sm + text-quanta-space transition + focus-within:bg-quanta-air + hover:bg-quanta-smoke + forced-colors:bg-[Field] + `, + variants: { + isFocusVisible: { + // Mirror the focus ring on the group when the inner input is focused. + true: 'outline-3', + false: 'outline-0', + }, + isDisabled: { + true: ` + cursor-not-allowed bg-quanta-air text-quanta-silver + hover:bg-quanta-air + forced-colors:text-[GrayText] + `, + }, + isInvalid: { + true: ` + bg-quanta-ballet + hover:bg-quanta-flamingo + `, + }, + }, +}); + +export interface ComboBoxProps + extends Omit, 'children'> { + label?: string; + description?: string | null; + errorMessage?: string | ((validation: ValidationResult) => string); + placeholder?: string; + items?: Iterable; + children: React.ReactNode | ((item: T) => React.ReactNode); +} + +/** + * Quanta-styled, searchable single-select. Built on react-aria's `ComboBox`, + * so typing in the input filters the options (default "contains" filter when + * options are passed as a static collection). Visually matches `Select`. + */ +export function ComboBox({ + label, + description, + errorMessage, + placeholder, + items, + children, + ...props +}: ComboBoxProps) { + return ( + + {({ isOpen, isDisabled, isInvalid }) => ( + <> + {label && } + + triggerStyles({ ...renderProps, isDisabled, isInvalid }) + } + > + + + + {description && {description}} + {errorMessage} + + {children} + + + )} + + ); +} + +export function ComboBoxListBox(props: ListBoxProps) { + return ( + ( +
+ No results found +
+ )) + } + className={composeTailwindRenderProps( + props.className, + 'max-h-72 min-w-(--trigger-width) p-1', + )} + /> + ); +} + +export function ComboBoxItem(props: ListBoxItemProps) { + return ; +} diff --git a/packages/components/src/quanta/index.ts b/packages/components/src/quanta/index.ts index e842c71ace4..bc7f0e97ece 100644 --- a/packages/components/src/quanta/index.ts +++ b/packages/components/src/quanta/index.ts @@ -3,6 +3,7 @@ export * from '../components/Breadcrumbs/Breadcrumbs.quanta'; export * from '../components/Accordion/Accordion.quanta'; export * from '../components/Calendar/Calendar.quanta'; export * from '../components/Checkbox/Checkbox.quanta'; +export * from '../components/ComboBox/ComboBox.quanta'; export * from '../components/Container/Container.quanta'; export * from '../components/Field/Field.quanta'; export * from '../components/Link/Link.quanta'; From 4c0bdfb0d3b935d19a8f69c2f8dea9ba0820466e Mon Sep 17 00:00:00 2001 From: nileshgulia1 Date: Tue, 19 May 2026 15:31:12 +0200 Subject: [PATCH 15/23] fix storybook for QuerystringWidget --- .../QuerystringWidget.stories.tsx | 28 +++++++++++++++++-- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/packages/cmsui/components/QuerystringWidget/QuerystringWidget.stories.tsx b/packages/cmsui/components/QuerystringWidget/QuerystringWidget.stories.tsx index 14633e400cf..31f829f3482 100644 --- a/packages/cmsui/components/QuerystringWidget/QuerystringWidget.stories.tsx +++ b/packages/cmsui/components/QuerystringWidget/QuerystringWidget.stories.tsx @@ -5,6 +5,7 @@ import { createMemoryRouter, type LoaderFunctionArgs, } from 'react-router'; +import { fn } from 'storybook/test'; import type { QuerystringValue } from './QuerystringWidgetContext'; import { QuerystringWidget } from './QuerystringWidget'; @@ -13,7 +14,9 @@ interface QuerystringWidgetStoryProps { description?: string; errorMessage?: string; value?: QuerystringValue; + defaultValue?: QuerystringValue; onChange?: (value: QuerystringValue) => void; + onPatchFormData?: (partial: Record) => void; } const createQuerystringLoader = () => { @@ -154,6 +157,26 @@ const createQuerystringRouter = (props: QuerystringWidgetStoryProps) => }, }), }, + { + path: '/@querystringSearch', + loader: () => ({ + items: [ + { + '@id': '/Test/example-news-item', + '@type': 'News Item', + title: 'Example news item', + description: 'A sample result for the query-string search.', + }, + { + '@id': '/Test/example-page', + '@type': 'Document', + title: 'Example page', + description: 'Another sample result.', + }, + ], + items_total: 2, + }), + }, ], { initialEntries: ['/'], @@ -171,13 +194,12 @@ const meta = { layout: 'fullscreen', backgrounds: { disable: true }, }, - argTypes: { - onChange: { action: 'onChange' }, - }, tags: ['autodocs'], args: { label: 'Search Criteria', description: 'Define search criteria to filter content', + onChange: fn(), + onPatchFormData: fn(), }, } satisfies Meta; From 45d307078917b92a0d9c9093f18b989255e0f7b0 Mon Sep 17 00:00:00 2001 From: Nilesh Date: Tue, 19 May 2026 19:08:50 +0530 Subject: [PATCH 16/23] feat: add ComboBox.quanta and use it in Select for queryString --- .../QuerystringWidget.stories.tsx | 28 +++- .../QuerystringWidget/QuerystringWidget.tsx | 55 +++++-- .../QuerystringWidgetContext.tsx | 142 +++++++---------- packages/cmsui/routes/queryStringOptions.tsx | 27 ++-- .../components/ComboBox/ComboBox.quanta.tsx | 148 ++++++++++++++++++ 5 files changed, 284 insertions(+), 116 deletions(-) create mode 100644 packages/components/src/components/ComboBox/ComboBox.quanta.tsx diff --git a/packages/cmsui/components/QuerystringWidget/QuerystringWidget.stories.tsx b/packages/cmsui/components/QuerystringWidget/QuerystringWidget.stories.tsx index 14633e400cf..31f829f3482 100644 --- a/packages/cmsui/components/QuerystringWidget/QuerystringWidget.stories.tsx +++ b/packages/cmsui/components/QuerystringWidget/QuerystringWidget.stories.tsx @@ -5,6 +5,7 @@ import { createMemoryRouter, type LoaderFunctionArgs, } from 'react-router'; +import { fn } from 'storybook/test'; import type { QuerystringValue } from './QuerystringWidgetContext'; import { QuerystringWidget } from './QuerystringWidget'; @@ -13,7 +14,9 @@ interface QuerystringWidgetStoryProps { description?: string; errorMessage?: string; value?: QuerystringValue; + defaultValue?: QuerystringValue; onChange?: (value: QuerystringValue) => void; + onPatchFormData?: (partial: Record) => void; } const createQuerystringLoader = () => { @@ -154,6 +157,26 @@ const createQuerystringRouter = (props: QuerystringWidgetStoryProps) => }, }), }, + { + path: '/@querystringSearch', + loader: () => ({ + items: [ + { + '@id': '/Test/example-news-item', + '@type': 'News Item', + title: 'Example news item', + description: 'A sample result for the query-string search.', + }, + { + '@id': '/Test/example-page', + '@type': 'Document', + title: 'Example page', + description: 'Another sample result.', + }, + ], + items_total: 2, + }), + }, ], { initialEntries: ['/'], @@ -171,13 +194,12 @@ const meta = { layout: 'fullscreen', backgrounds: { disable: true }, }, - argTypes: { - onChange: { action: 'onChange' }, - }, tags: ['autodocs'], args: { label: 'Search Criteria', description: 'Define search criteria to filter content', + onChange: fn(), + onPatchFormData: fn(), }, } satisfies Meta; diff --git a/packages/cmsui/components/QuerystringWidget/QuerystringWidget.tsx b/packages/cmsui/components/QuerystringWidget/QuerystringWidget.tsx index edec4b49a18..e561f9683c0 100644 --- a/packages/cmsui/components/QuerystringWidget/QuerystringWidget.tsx +++ b/packages/cmsui/components/QuerystringWidget/QuerystringWidget.tsx @@ -1,4 +1,4 @@ -import { useId, useCallback, useMemo } from 'react'; +import { useId, useCallback, useMemo, useEffect, useRef } from 'react'; import { tv } from 'tailwind-variants'; import type { TextFieldProps as QuantaTextFieldProps } from '@plone/components/quanta'; import { @@ -11,8 +11,15 @@ import { useLoaderData } from 'react-router'; import type { loader as editLoader } from '../../routes/edit'; import { focusRing } from '../utils'; -import { TextField, NumberField, Switch, Button } from '@plone/components'; -import { Select, SelectItem } from '@plone/components/quanta'; +import { NumberField, Switch } from '@plone/components'; +import { + TextField, + Select, + SelectItem, + ComboBox, + ComboBoxItem, + Button, +} from '@plone/components/quanta'; import { QuerystringProvider, useQuerystringContext, @@ -39,7 +46,9 @@ const widgetStyles = tv({ interface QuerystringWidgetProps extends BaseFormFieldProps { value?: QuerystringValue; + defaultValue?: QuerystringValue; onChange?: (value: QuerystringValue) => void; + onPatchFormData?: (partial: Record) => void; } /** @@ -89,20 +98,26 @@ function QueryCriterionRow({ }; return ( -
+
- +
@@ -148,6 +163,7 @@ function QueryCriterionRow({ className={` h-fit rounded p-2 hover:bg-red-100 + lg:h-fit `} aria-label="Remove criterion" > @@ -171,8 +187,22 @@ function QuerystringWidgetComponent(props: QuerystringWidgetProps) { addCriterion, removeCriterion, updateCriterion, + searchItems, } = useQuerystringContext(); + // Feed query-string search results into the block's `items` so the + // Listing block preview renders them, mirroring Volto's withQuerystringResults. + const patchRef = useRef(props.onPatchFormData); + patchRef.current = props.onPatchFormData; + const lastItemsRef = useRef('[]'); + + useEffect(() => { + const signature = JSON.stringify(searchItems.map((item) => item['@id'])); + if (signature === lastItemsRef.current) return; + lastItemsRef.current = signature; + patchRef.current?.({ items: searchItems }); + }, [searchItems]); + // Sync context value with prop value const synced = useMemo( () => ({ ...value, ...contextValue }), @@ -272,7 +302,7 @@ function QuerystringWidgetComponent(props: QuerystringWidgetProps) { {synced.query && synced.query.length > 0 ? ( -
+
{synced.query.map((criterion, index) => ( (); - const { label, description, errorMessage, ...rest } = props; + const { label, description, errorMessage, value, defaultValue, ...rest } = + props; + const initialValue = value ?? defaultValue; return ( - + diff --git a/packages/cmsui/components/QuerystringWidget/QuerystringWidgetContext.tsx b/packages/cmsui/components/QuerystringWidget/QuerystringWidgetContext.tsx index de1e7243851..e1acdebac58 100644 --- a/packages/cmsui/components/QuerystringWidget/QuerystringWidgetContext.tsx +++ b/packages/cmsui/components/QuerystringWidget/QuerystringWidgetContext.tsx @@ -7,7 +7,13 @@ import { useEffect, } from 'react'; import { useFetcher } from 'react-router'; +import { useDebounceValue } from 'usehooks-ts'; +import type { Brain } from '@plone/types'; import type { loader, BackendIndex } from '../../routes/queryStringOptions'; +import type { + loader as querystringSearchLoader, + QuerystringSearchResult, +} from '../../routes/querystringSearch'; /** * Represents a single query criterion @@ -44,6 +50,15 @@ export interface FieldMetadata { valueOptions?: Array<{ value: string; label: string }>; } +export function transformSortableIndexes( + backendIndexes: Record, +): Array<{ value: string; label: string }> { + return Object.entries(backendIndexes) + .filter(([, index]) => index.sortable) + .map(([name, index]) => ({ value: name, label: index.title })) + .sort((a, b) => a.label.localeCompare(b.label)); +} + export function transformBackendIndexes( backendIndexes: Record, ): FieldMetadata[] { @@ -98,6 +113,9 @@ interface QuerystringContextType { addCriterion: () => void; removeCriterion: (index: number) => void; updateCriterion: (index: number, criterion: QueryCriterion) => void; + searchItems: Brain[]; + searchTotal: number; + searchLoading: boolean; } const QuerystringContext = createContext( @@ -112,93 +130,12 @@ export interface QuerystringProviderProps { children: React.ReactNode; } -const DEFAULT_FIELDS: FieldMetadata[] = [ - { - name: 'Creator', - title: 'Creator', - operators: [ - { value: 'is', label: 'is' }, - { value: 'is_not', label: 'is not' }, - ], - }, - { - name: 'Title', - title: 'Title', - operators: [ - { value: 'is', label: 'is' }, - { value: 'has', label: 'has' }, - { value: 'is_not', label: 'is not' }, - ], - valueType: 'text', - }, - { - name: 'Description', - title: 'Description', - operators: [ - { value: 'is', label: 'is' }, - { value: 'has', label: 'has' }, - { value: 'is_not', label: 'is not' }, - ], - valueType: 'text', - }, - { - name: 'Subject', - title: 'Keywords', - operators: [ - { value: 'is', label: 'is' }, - { value: 'is_not', label: 'is not' }, - ], - }, - { - name: 'path', - title: 'Location', - operators: [ - { value: 'is', label: 'is' }, - { value: 'is_not', label: 'is not' }, - ], - }, - { - name: 'modified', - title: 'Last modified', - operators: [ - { value: 'before', label: 'before' }, - { value: 'on', label: 'on' }, - { value: 'after', label: 'after' }, - ], - valueType: 'date', - }, - { - name: 'created', - title: 'Created', - operators: [ - { value: 'before', label: 'before' }, - { value: 'on', label: 'on' }, - { value: 'after', label: 'after' }, - ], - valueType: 'date', - }, - { - name: 'review_state', - title: 'Review state', - operators: [ - { value: 'is', label: 'is' }, - { value: 'is_not', label: 'is not' }, - ], - }, -]; - -const DEFAULT_SORT_FIELDS = [ - { value: 'Title', label: 'Title' }, - { value: 'Creator', label: 'Creator' }, - { value: 'modified', label: 'Last modified' }, - { value: 'created', label: 'Created' }, - { value: 'Subject', label: 'Keywords' }, -]; +const EMPTY_ITEMS: Brain[] = []; export function QuerystringProvider({ initialValue = {}, availableFields, - availableSortFields = DEFAULT_SORT_FIELDS, + availableSortFields: availableSortFieldsProp, backendIndexes, children, }: QuerystringProviderProps) { @@ -216,12 +153,17 @@ export function QuerystringProvider({ const indexes = backendIndexes || fetchedIndexes; const fields = useMemo( - () => - availableFields || - (indexes ? transformBackendIndexes(indexes) : DEFAULT_FIELDS), + () => availableFields || (indexes ? transformBackendIndexes(indexes) : []), [availableFields, indexes], ); + const availableSortFields = useMemo(() => { + if (availableSortFieldsProp && availableSortFieldsProp.length > 0) { + return availableSortFieldsProp; + } + return indexes ? transformSortableIndexes(indexes) : []; + }, [availableSortFieldsProp, indexes]); + const [value, setValue] = useState(initialValue); const addCriterion = useCallback(() => { @@ -259,6 +201,28 @@ export function QuerystringProvider({ [], ); + const searchFetcher = useFetcher(); + + const querySignature = useMemo( + () => JSON.stringify(value.query ?? []), + [value.query], + ); + const [debouncedQuerySignature] = useDebounceValue(querySignature, 400); + + useEffect(() => { + const criteria = JSON.parse(debouncedQuerySignature) as QueryCriterion[]; + if (!criteria || criteria.length === 0) return; + searchFetcher.load( + `/@querystringSearch?query=${encodeURIComponent(debouncedQuerySignature)}`, + ); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [debouncedQuerySignature]); + + const searchData = searchFetcher.data as QuerystringSearchResult | undefined; + const searchItems = searchData?.items ?? EMPTY_ITEMS; + const searchTotal = searchData?.items_total ?? 0; + const searchLoading = searchFetcher.state !== 'idle'; + const contextValue = useMemo( () => ({ availableFields: fields, @@ -268,6 +232,9 @@ export function QuerystringProvider({ addCriterion, removeCriterion, updateCriterion, + searchItems, + searchTotal, + searchLoading, }), [ value, @@ -276,6 +243,9 @@ export function QuerystringProvider({ addCriterion, removeCriterion, updateCriterion, + searchItems, + searchTotal, + searchLoading, ], ); diff --git a/packages/cmsui/routes/queryStringOptions.tsx b/packages/cmsui/routes/queryStringOptions.tsx index 1a1a94a5561..989388b0f98 100644 --- a/packages/cmsui/routes/queryStringOptions.tsx +++ b/packages/cmsui/routes/queryStringOptions.tsx @@ -1,7 +1,9 @@ -import { data, type LoaderFunctionArgs } from 'react-router'; -import type PloneClient from '@plone/client'; -import { getAuthFromRequest } from '@plone/react-router'; -import config from '@plone/registry'; +import { + data, + RouterContextProvider, + type LoaderFunctionArgs, +} from 'react-router'; +import { ploneClientContext } from 'seven/app/middleware.server'; export interface BackendOperator { title: string; @@ -29,20 +31,13 @@ export interface QuerystringOptionsResponse { sortable_indexes?: Record; } -export async function loader({ request }: LoaderFunctionArgs) { - const token = await getAuthFromRequest(request); - - const cli = config - .getUtility({ - name: 'ploneClient', - type: 'client', - }) - .method() as unknown as PloneClient; - - cli.config.token = token; +export async function loader({ + context, +}: LoaderFunctionArgs) { + const cli = context.get(ploneClientContext); try { - const qs = await cli.getQuerystring(); + const { data: qs } = await cli.getQuerystring(); const response = qs as unknown as QuerystringOptionsResponse; return data( diff --git a/packages/components/src/components/ComboBox/ComboBox.quanta.tsx b/packages/components/src/components/ComboBox/ComboBox.quanta.tsx new file mode 100644 index 00000000000..a5733b462fd --- /dev/null +++ b/packages/components/src/components/ComboBox/ComboBox.quanta.tsx @@ -0,0 +1,148 @@ +import React from 'react'; +import { + Button, + ComboBox as RACComboBox, + type ComboBoxProps as RACComboBoxProps, + Group, + Input, + type ListBoxItemProps, + type ListBoxProps, + type ValidationResult, +} from 'react-aria-components'; +import { tv } from 'tailwind-variants'; + +import { Description, FieldError, Label } from '../Field/Field.quanta'; +import { DropdownItem, ListBox } from '../ListBox/ListBox.quanta'; +import { Popover } from '../Popover/Popover.quanta'; +import { composeTailwindRenderProps, focusRing } from '../utils'; +import { ChevrondownIcon } from '../icons'; + +const triggerStyles = tv({ + extend: focusRing, + base: ` + flex min-h-11 min-w-45 items-center gap-2 rounded-lg bg-quanta-snow py-1 pr-2 pl-3 text-sm + text-quanta-space transition + focus-within:bg-quanta-air + hover:bg-quanta-smoke + forced-colors:bg-[Field] + `, + variants: { + isFocusVisible: { + // Mirror the focus ring on the group when the inner input is focused. + true: 'outline-3', + false: 'outline-0', + }, + isDisabled: { + true: ` + cursor-not-allowed bg-quanta-air text-quanta-silver + hover:bg-quanta-air + forced-colors:text-[GrayText] + `, + }, + isInvalid: { + true: ` + bg-quanta-ballet + hover:bg-quanta-flamingo + `, + }, + }, +}); + +export interface ComboBoxProps + extends Omit, 'children'> { + label?: string; + description?: string | null; + errorMessage?: string | ((validation: ValidationResult) => string); + placeholder?: string; + items?: Iterable; + children: React.ReactNode | ((item: T) => React.ReactNode); +} + +/** + * Quanta-styled, searchable single-select. Built on react-aria's `ComboBox`, + * so typing in the input filters the options (default "contains" filter when + * options are passed as a static collection). Visually matches `Select`. + */ +export function ComboBox({ + label, + description, + errorMessage, + placeholder, + items, + children, + ...props +}: ComboBoxProps) { + return ( + + {({ isOpen, isDisabled, isInvalid }) => ( + <> + {label && } + + triggerStyles({ ...renderProps, isDisabled, isInvalid }) + } + > + + + + {description && {description}} + {errorMessage} + + {children} + + + )} + + ); +} + +export function ComboBoxListBox(props: ListBoxProps) { + return ( + ( +
+ No results found +
+ )) + } + className={composeTailwindRenderProps( + props.className, + 'max-h-72 min-w-(--trigger-width) p-1', + )} + /> + ); +} + +export function ComboBoxItem(props: ListBoxItemProps) { + return ; +} From ef68b6d8c0e707dee094e7cb7fe214648871fd32 Mon Sep 17 00:00:00 2001 From: nileshgulia1 Date: Tue, 19 May 2026 15:52:25 +0200 Subject: [PATCH 17/23] fix Combobox.quanta import --- packages/components/src/quanta/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/components/src/quanta/index.ts b/packages/components/src/quanta/index.ts index e842c71ace4..98bf5fd5b18 100644 --- a/packages/components/src/quanta/index.ts +++ b/packages/components/src/quanta/index.ts @@ -4,6 +4,7 @@ export * from '../components/Accordion/Accordion.quanta'; export * from '../components/Calendar/Calendar.quanta'; export * from '../components/Checkbox/Checkbox.quanta'; export * from '../components/Container/Container.quanta'; +export * from '../components/ComboBox/ComboBox.quanta'; export * from '../components/Field/Field.quanta'; export * from '../components/Link/Link.quanta'; export * from '../components/Menu/Menu.quanta'; From eeb54e6d5b4ba23cad7687dc68812415d352418c Mon Sep 17 00:00:00 2001 From: Nilesh Date: Tue, 19 May 2026 19:36:58 +0530 Subject: [PATCH 18/23] refactor: add missing file querystringSearch --- packages/cmsui/routes/querystringSearch.tsx | 70 +++++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 packages/cmsui/routes/querystringSearch.tsx diff --git a/packages/cmsui/routes/querystringSearch.tsx b/packages/cmsui/routes/querystringSearch.tsx new file mode 100644 index 00000000000..403edbb4160 --- /dev/null +++ b/packages/cmsui/routes/querystringSearch.tsx @@ -0,0 +1,70 @@ +import { + data, + RouterContextProvider, + type LoaderFunctionArgs, +} from 'react-router'; +import { ploneClientContext } from 'seven/app/middleware.server'; +import type { Brain, Query } from '@plone/types'; + +export interface QuerystringSearchResult { + items: Brain[]; + items_total: number; +} + +function parseQuery(raw: string | null): Query[] { + if (!raw) return []; + try { + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) return []; + return parsed + .filter((c) => c && typeof c.i === 'string' && typeof c.o === 'string') + .map((c) => ({ + i: c.i, + o: c.o, + v: Array.isArray(c.v) ? c.v.map(String) : String(c.v ?? ''), + })); + } catch { + return []; + } +} + +export async function loader({ + request, + context, +}: LoaderFunctionArgs) { + const cli = context.get(ploneClientContext); + + const url = new URL(request.url); + const query = parseQuery(url.searchParams.get('query')); + + const empty: QuerystringSearchResult = { items: [], items_total: 0 }; + + if (query.length === 0) { + return data(empty, { + headers: { 'Content-Type': 'application/json' }, + }); + } + + try { + const { data: results } = await cli.querystringSearch({ + query, + post: true, + }); + + return data( + { + items: results?.items ?? [], + items_total: results?.items_total ?? 0, + } satisfies QuerystringSearchResult, + { + headers: { 'Content-Type': 'application/json' }, + }, + ); + } catch (error) { + // eslint-disable-next-line no-console + console.error('Failed to fetch querystring-search results:', error); + return data(empty, { + headers: { 'Content-Type': 'application/json' }, + }); + } +} From acc5311f7588347c1cb4dce0c12d88764d808b3e Mon Sep 17 00:00:00 2001 From: nileshgulia1 Date: Tue, 19 May 2026 16:29:56 +0200 Subject: [PATCH 19/23] chore: update changelog --- packages/components/news/8007.feature | 1 + 1 file changed, 1 insertion(+) create mode 100644 packages/components/news/8007.feature diff --git a/packages/components/news/8007.feature b/packages/components/news/8007.feature new file mode 100644 index 00000000000..60a6e3a023a --- /dev/null +++ b/packages/components/news/8007.feature @@ -0,0 +1 @@ +add quanta variant for comboBox @nileshgulia1 \ No newline at end of file From b0807870cbc7fba9b0e9eb2f66e3ed1eefbee611 Mon Sep 17 00:00:00 2001 From: nileshgulia1 Date: Thu, 21 May 2026 18:50:31 +0200 Subject: [PATCH 20/23] refactor: create useQuerystringResults hook, move search logic to it --- packages/blocks/Listing/ListingBlockEdit.tsx | 15 +++++++++- .../blocks/Listing/useQuerystringResults.ts | 30 +++++++++++++++++++ .../BlockEditor/BlockSettingsForm.tsx | 9 ------ .../BlockEditor/BlockSettingsFormRenderer.tsx | 1 - .../QuerystringWidget.stories.tsx | 2 -- .../QuerystringWidget/QuerystringWidget.tsx | 18 +---------- 6 files changed, 45 insertions(+), 30 deletions(-) create mode 100644 packages/blocks/Listing/useQuerystringResults.ts diff --git a/packages/blocks/Listing/ListingBlockEdit.tsx b/packages/blocks/Listing/ListingBlockEdit.tsx index cb3f87f27ea..e09f8718254 100644 --- a/packages/blocks/Listing/ListingBlockEdit.tsx +++ b/packages/blocks/Listing/ListingBlockEdit.tsx @@ -1,6 +1,8 @@ +import { useEffect } from 'react'; import type { BlockEditProps } from '@plone/types'; import ListingBlockView from './ListingBlockView'; +import { useQuerystringResults } from './useQuerystringResults'; const hasQuery = (value: any): boolean => { if (!value) return false; @@ -12,9 +14,20 @@ const hasQuery = (value: any): boolean => { return false; }; + const ListingEdit = (props: BlockEditProps) => { - const { data } = props; + const { data, setBlock } = props; const hasListingQuery = hasQuery(data.querystring as any); + const { items } = useQuerystringResults(data.querystring as any); + + useEffect(() => { + if (hasListingQuery && items.length > 0) { + setBlock({ + ...data, + items, + }); + } + }, [items, hasListingQuery, data, setBlock]); if (!hasListingQuery) { return ( diff --git a/packages/blocks/Listing/useQuerystringResults.ts b/packages/blocks/Listing/useQuerystringResults.ts new file mode 100644 index 00000000000..b3b31f8e591 --- /dev/null +++ b/packages/blocks/Listing/useQuerystringResults.ts @@ -0,0 +1,30 @@ +import { useEffect } from 'react'; +import { useFetcher } from 'react-router'; +import { useDebounceValue } from 'usehooks-ts'; +import type { QuerystringValue } from '@plone/cmsui/components/QuerystringWidget'; +import type { QuerystringSearchResult } from '../../cmsui/routes/querystringSearch'; + +export function useQuerystringResults( + querystring: QuerystringValue | undefined, +) { + const fetcher = useFetcher(); + + const querySignature = JSON.stringify(querystring?.query ?? []); + const [debouncedQuerySignature] = useDebounceValue(querySignature, 400); + + useEffect(() => { + const criteria = JSON.parse(debouncedQuerySignature); + if (!criteria || criteria.length === 0) return; + + fetcher.load( + `/@querystringSearch?query=${encodeURIComponent(debouncedQuerySignature)}`, + ); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [debouncedQuerySignature]); + + const items = fetcher.data?.items ?? []; + const total = fetcher.data?.items_total ?? 0; + const loading = fetcher.state !== 'idle'; + + return { items, total, loading }; +} diff --git a/packages/cmsui/components/BlockEditor/BlockSettingsForm.tsx b/packages/cmsui/components/BlockEditor/BlockSettingsForm.tsx index ea21f11a6d9..69c38fbc353 100644 --- a/packages/cmsui/components/BlockEditor/BlockSettingsForm.tsx +++ b/packages/cmsui/components/BlockEditor/BlockSettingsForm.tsx @@ -76,15 +76,6 @@ const BlockSettingsForm = (props: BlockSettingsFormProps) => { fieldName, value, ); - - props.onFormDataChange?.(nextData); - }, - onPatchFormData: (partial: Record) => { - const nextData = { - ...((form.state.values as Record) ?? {}), - ...partial, - }; - props.onFormDataChange?.(nextData); }, })} diff --git a/packages/cmsui/components/BlockEditor/BlockSettingsFormRenderer.tsx b/packages/cmsui/components/BlockEditor/BlockSettingsFormRenderer.tsx index 57b4f20f9d4..484acbc519c 100644 --- a/packages/cmsui/components/BlockEditor/BlockSettingsFormRenderer.tsx +++ b/packages/cmsui/components/BlockEditor/BlockSettingsFormRenderer.tsx @@ -15,7 +15,6 @@ type RendererSchema = { type BaseFieldExtraProps = { formAtom?: PrimitiveAtom; onChange?: (value: unknown) => void; - onPatchFormData?: (partial: Record) => void; }; type BlockSettingsFormRendererProps = { diff --git a/packages/cmsui/components/QuerystringWidget/QuerystringWidget.stories.tsx b/packages/cmsui/components/QuerystringWidget/QuerystringWidget.stories.tsx index 31f829f3482..b582ccbe039 100644 --- a/packages/cmsui/components/QuerystringWidget/QuerystringWidget.stories.tsx +++ b/packages/cmsui/components/QuerystringWidget/QuerystringWidget.stories.tsx @@ -16,7 +16,6 @@ interface QuerystringWidgetStoryProps { value?: QuerystringValue; defaultValue?: QuerystringValue; onChange?: (value: QuerystringValue) => void; - onPatchFormData?: (partial: Record) => void; } const createQuerystringLoader = () => { @@ -199,7 +198,6 @@ const meta = { label: 'Search Criteria', description: 'Define search criteria to filter content', onChange: fn(), - onPatchFormData: fn(), }, } satisfies Meta; diff --git a/packages/cmsui/components/QuerystringWidget/QuerystringWidget.tsx b/packages/cmsui/components/QuerystringWidget/QuerystringWidget.tsx index e561f9683c0..526ea1fcc4a 100644 --- a/packages/cmsui/components/QuerystringWidget/QuerystringWidget.tsx +++ b/packages/cmsui/components/QuerystringWidget/QuerystringWidget.tsx @@ -1,4 +1,4 @@ -import { useId, useCallback, useMemo, useEffect, useRef } from 'react'; +import { useId, useCallback, useMemo } from 'react'; import { tv } from 'tailwind-variants'; import type { TextFieldProps as QuantaTextFieldProps } from '@plone/components/quanta'; import { @@ -48,7 +48,6 @@ interface QuerystringWidgetProps extends BaseFormFieldProps { value?: QuerystringValue; defaultValue?: QuerystringValue; onChange?: (value: QuerystringValue) => void; - onPatchFormData?: (partial: Record) => void; } /** @@ -187,22 +186,7 @@ function QuerystringWidgetComponent(props: QuerystringWidgetProps) { addCriterion, removeCriterion, updateCriterion, - searchItems, } = useQuerystringContext(); - - // Feed query-string search results into the block's `items` so the - // Listing block preview renders them, mirroring Volto's withQuerystringResults. - const patchRef = useRef(props.onPatchFormData); - patchRef.current = props.onPatchFormData; - const lastItemsRef = useRef('[]'); - - useEffect(() => { - const signature = JSON.stringify(searchItems.map((item) => item['@id'])); - if (signature === lastItemsRef.current) return; - lastItemsRef.current = signature; - patchRef.current?.({ items: searchItems }); - }, [searchItems]); - // Sync context value with prop value const synced = useMemo( () => ({ ...value, ...contextValue }), From 202d8fe0c8b7ab5cffa470152e6301e4b61b92f0 Mon Sep 17 00:00:00 2001 From: nileshgulia1 Date: Mon, 1 Jun 2026 23:00:30 +0200 Subject: [PATCH 21/23] fix: use textWidget with type 'number' instead of NumberField --- .../QuerystringWidget/QuerystringWidget.tsx | 33 +++++++++---------- 1 file changed, 16 insertions(+), 17 deletions(-) diff --git a/packages/cmsui/components/QuerystringWidget/QuerystringWidget.tsx b/packages/cmsui/components/QuerystringWidget/QuerystringWidget.tsx index e561f9683c0..0f56bc8e1f5 100644 --- a/packages/cmsui/components/QuerystringWidget/QuerystringWidget.tsx +++ b/packages/cmsui/components/QuerystringWidget/QuerystringWidget.tsx @@ -11,7 +11,7 @@ import { useLoaderData } from 'react-router'; import type { loader as editLoader } from '../../routes/edit'; import { focusRing } from '../utils'; -import { NumberField, Switch } from '@plone/components'; +import { Switch } from '@plone/components'; import { TextField, Select, @@ -342,17 +342,16 @@ function QuerystringWidgetComponent(props: QuerystringWidgetProps) { {/* Depth Field - Conditional */} {hasPathCriterion && (
- + type="number" + value={String(synced.depth || '')} + onChange={(value: string) => handleValueChange({ ...synced, - depth: value, + depth: value ? parseInt(value, 10) : undefined, }) } - minValue={0} - maxValue={10} />
)} @@ -396,29 +395,29 @@ function QuerystringWidgetComponent(props: QuerystringWidgetProps) { {/* Results Options Row */}
- + type="number" + value={String(synced.limit || '')} + onChange={(value: string) => handleValueChange({ ...synced, - limit: value, + limit: value ? parseInt(value, 10) : undefined, }) } - minValue={0} />
- + type="number" + value={String(synced.b_size || '')} + onChange={(value: string) => handleValueChange({ ...synced, - b_size: value, + b_size: value ? parseInt(value, 10) : undefined, }) } - minValue={1} />
From 079486867f41108ad776b4a98b4be26952df1314 Mon Sep 17 00:00:00 2001 From: nileshgulia1 Date: Thu, 4 Jun 2026 16:25:10 +0200 Subject: [PATCH 22/23] fix: use react-router from catalog, fix imports --- packages/blocks/Listing/useQuerystringResults.ts | 2 +- packages/blocks/package.json | 4 +++- pnpm-lock.yaml | 6 ++++++ 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/blocks/Listing/useQuerystringResults.ts b/packages/blocks/Listing/useQuerystringResults.ts index b3b31f8e591..3678de04803 100644 --- a/packages/blocks/Listing/useQuerystringResults.ts +++ b/packages/blocks/Listing/useQuerystringResults.ts @@ -1,7 +1,7 @@ import { useEffect } from 'react'; import { useFetcher } from 'react-router'; import { useDebounceValue } from 'usehooks-ts'; -import type { QuerystringValue } from '@plone/cmsui/components/QuerystringWidget'; +import type { QuerystringValue } from '../../cmsui/components/QuerystringWidget/QuerystringWidgetContext'; import type { QuerystringSearchResult } from '../../cmsui/routes/querystringSearch'; export function useQuerystringResults( diff --git a/packages/blocks/package.json b/packages/blocks/package.json index 6de07c4f93a..b00dda92349 100644 --- a/packages/blocks/package.json +++ b/packages/blocks/package.json @@ -55,7 +55,9 @@ "@plone/components": "workspace:*", "@plone/registry": "workspace:*", "clsx": "^2.1.1", - "react-i18next": "catalog:" + "react-i18next": "catalog:", + "react-router": "catalog:", + "usehooks-ts": "^3.1.1" }, "devDependencies": { "@plone/helpers": "workspace:*", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4da1f8a6b1f..2de672a12ac 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -338,6 +338,12 @@ importers: react-i18next: specifier: 'catalog:' version: 15.7.3(i18next@24.2.3(typescript@5.9.2))(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@5.9.2) + react-router: + specifier: 'catalog:' + version: 7.14.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + usehooks-ts: + specifier: ^3.1.1 + version: 3.1.1(react@19.2.0) devDependencies: '@plone/helpers': specifier: workspace:* From 541a78896c02d3d18bbcb80583e06a55520b35d9 Mon Sep 17 00:00:00 2001 From: nileshgulia1 Date: Mon, 8 Jun 2026 21:29:53 +0530 Subject: [PATCH 23/23] refactor: add tests and clean up querystringWidget --- packages/blocks/index.ts | 6 + packages/blocks/news/40.feature | 1 + .../acceptance/tests/listing-block.test.ts | 208 ++++++++++++++++++ .../BlockEditor/BlockSettingsForm.tsx | 1 + .../QuerystringWidgetContext.tsx | 39 ---- .../news/+fix-querystringWidgetContext.bugfix | 1 + 6 files changed, 217 insertions(+), 39 deletions(-) create mode 100644 packages/blocks/news/40.feature create mode 100644 packages/cmsui/acceptance/tests/listing-block.test.ts create mode 100644 packages/cmsui/news/+fix-querystringWidgetContext.bugfix diff --git a/packages/blocks/index.ts b/packages/blocks/index.ts index 70a98bd9aae..eec2fadeb75 100644 --- a/packages/blocks/index.ts +++ b/packages/blocks/index.ts @@ -83,6 +83,12 @@ export default function install(config: ConfigType) { widths: ['default'], }, }, + listing: { + blockWidth: { + defaultWidth: 'default', + widths: ['layout', 'default', 'narrow'], + }, + }, toc: { blockWidth: { defaultWidth: 'default', diff --git a/packages/blocks/news/40.feature b/packages/blocks/news/40.feature new file mode 100644 index 00000000000..dd6bd2abb38 --- /dev/null +++ b/packages/blocks/news/40.feature @@ -0,0 +1 @@ +Aurora: Listing block @nileshgulia \ No newline at end of file diff --git a/packages/cmsui/acceptance/tests/listing-block.test.ts b/packages/cmsui/acceptance/tests/listing-block.test.ts new file mode 100644 index 00000000000..e357553ce66 --- /dev/null +++ b/packages/cmsui/acceptance/tests/listing-block.test.ts @@ -0,0 +1,208 @@ +import { expect, test } from '../../../tooling/playwright/test'; +import { login } from '../../../tooling/playwright/login'; +import { createContent } from '../../../tooling/playwright/content'; +import { waitForPlateEditorReady } from '../../../tooling/playwright/plate'; +import type { Page } from '@playwright/test'; + +const PAGE_ID = 'listing-block-page'; + +async function setupListingBlockPage(page: Page) { + await createContent(page, { + contentType: 'Document', + contentId: 'news-folder', + contentTitle: 'News Folder', + }); + + await createContent(page, { + contentType: 'Document', + contentId: 'news-item-1', + contentTitle: 'First News Article', + contentDescription: 'Description of the first article', + path: '/news-folder', + }); + + await createContent(page, { + contentType: 'Document', + contentId: 'news-item-2', + contentTitle: 'Second News Article', + contentDescription: 'Description of the second article', + path: '/news-folder', + }); + + await createContent(page, { + contentType: 'Document', + contentId: PAGE_ID, + contentTitle: 'Listing Block Page', + transition: 'publish', + bodyModifier: (body) => ({ + ...body, + blocks: { + __somersault__: { + '@type': '__somersault__', + value: [ + { + type: 'title', + children: [{ text: 'Listing Block Page' }], + }, + { + type: 'unknown', + '@type': 'listing', + headline: 'Latest News', + querystring: { + query: [ + { + i: 'path', + o: 'plone.app.querystring.operation.string.path', + v: '/news-folder', + }, + ], + }, + children: [{ text: '' }], + }, + ], + }, + }, + blocks_layout: { + items: ['__somersault__'], + }, + }), + }); + + await page.goto(`/@@edit/${PAGE_ID}`); + await waitForPlateEditorReady(page); +} + +test.describe('Listing block', () => { + test('displays listing block with headline and items in edit mode', async ({ + page, + }) => { + await login(page); + await setupListingBlockPage(page); + + // Check headline is visible + await expect( + page.getByRole('heading', { name: 'Latest News' }), + ).toBeVisible(); + + // Check that items are displayed + await expect(page.getByText('First News Article')).toBeVisible(); + await expect(page.getByText('Second News Article')).toBeVisible(); + + // Check descriptions are visible + await expect( + page.getByText('Description of the first article'), + ).toBeVisible(); + await expect( + page.getByText('Description of the second article'), + ).toBeVisible(); + }); + + test('shows placeholder when no query is configured', async ({ page }) => { + await login(page); + await createContent(page, { + contentType: 'Document', + contentId: 'empty-listing-page', + contentTitle: 'Empty Listing Page', + transition: 'publish', + bodyModifier: (body) => ({ + ...body, + blocks: { + __somersault__: { + '@type': '__somersault__', + value: [ + { + type: 'title', + children: [{ text: 'Empty Listing Page' }], + }, + { + type: 'unknown', + '@type': 'listing', + headline: 'No Query', + children: [{ text: '' }], + }, + ], + }, + }, + blocks_layout: { + items: ['__somersault__'], + }, + }), + }); + + await page.goto('/@@edit/empty-listing-page'); + await waitForPlateEditorReady(page); + + await expect(page.getByText('No Results Found')).toBeVisible(); + }); + + test('displays listing block items in view', async ({ page }) => { + await login(page); + await setupListingBlockPage(page); + + // Wait for items to be fetched by the listing block edit component + await expect(page.getByText('First News Article')).toBeVisible({ + timeout: 10000, + }); + await expect(page.getByText('Second News Article')).toBeVisible({ + timeout: 10000, + }); + + // Save using the toolbar button + const saveButton = page + .locator('#toolbar') + .getByRole('button', { name: /save/i }) + .first(); + await saveButton.click(); + await page.waitForLoadState('networkidle'); + + // Navigate to the published view + await page.goto(`/${PAGE_ID}`, { waitUntil: 'networkidle' }); + + // Check headline is visible + await expect( + page.getByRole('heading', { name: 'Latest News' }), + ).toBeVisible(); + + // Check that items are displayed + await expect(page.getByText('First News Article')).toBeVisible(); + await expect(page.getByText('Second News Article')).toBeVisible(); + + // Check descriptions are visible + await expect( + page.getByText('Description of the first article'), + ).toBeVisible(); + await expect( + page.getByText('Description of the second article'), + ).toBeVisible(); + }); + + test('items are clickable in view', async ({ page }) => { + await login(page); + await setupListingBlockPage(page); + + // Wait for items to be fetched + await expect(page.getByText('First News Article')).toBeVisible({ + timeout: 10000, + }); + + // Save using the toolbar button + const saveButton = page + .locator('#toolbar') + .getByRole('button', { name: /save/i }) + .first(); + await saveButton.click(); + await page.waitForLoadState('networkidle'); + + // Navigate to the published view + await page.goto(`/${PAGE_ID}`, { waitUntil: 'networkidle' }); + + // Click on the first article link + await page.getByRole('link', { name: 'First News Article' }).click(); + + // Should navigate to the article + await expect(page).toHaveURL(/\/news-folder\/news-item-1$/); + await expect( + page.getByRole('heading', { name: 'First News Article' }), + ).toBeVisible(); + }); +}); diff --git a/packages/cmsui/components/BlockEditor/BlockSettingsForm.tsx b/packages/cmsui/components/BlockEditor/BlockSettingsForm.tsx index 69c38fbc353..71f39554e05 100644 --- a/packages/cmsui/components/BlockEditor/BlockSettingsForm.tsx +++ b/packages/cmsui/components/BlockEditor/BlockSettingsForm.tsx @@ -76,6 +76,7 @@ const BlockSettingsForm = (props: BlockSettingsFormProps) => { fieldName, value, ); + props.onFormDataChange?.(nextData); }, })} diff --git a/packages/cmsui/components/QuerystringWidget/QuerystringWidgetContext.tsx b/packages/cmsui/components/QuerystringWidget/QuerystringWidgetContext.tsx index e1acdebac58..cbf21f77312 100644 --- a/packages/cmsui/components/QuerystringWidget/QuerystringWidgetContext.tsx +++ b/packages/cmsui/components/QuerystringWidget/QuerystringWidgetContext.tsx @@ -7,13 +7,7 @@ import { useEffect, } from 'react'; import { useFetcher } from 'react-router'; -import { useDebounceValue } from 'usehooks-ts'; -import type { Brain } from '@plone/types'; import type { loader, BackendIndex } from '../../routes/queryStringOptions'; -import type { - loader as querystringSearchLoader, - QuerystringSearchResult, -} from '../../routes/querystringSearch'; /** * Represents a single query criterion @@ -113,9 +107,6 @@ interface QuerystringContextType { addCriterion: () => void; removeCriterion: (index: number) => void; updateCriterion: (index: number, criterion: QueryCriterion) => void; - searchItems: Brain[]; - searchTotal: number; - searchLoading: boolean; } const QuerystringContext = createContext( @@ -130,8 +121,6 @@ export interface QuerystringProviderProps { children: React.ReactNode; } -const EMPTY_ITEMS: Brain[] = []; - export function QuerystringProvider({ initialValue = {}, availableFields, @@ -201,28 +190,6 @@ export function QuerystringProvider({ [], ); - const searchFetcher = useFetcher(); - - const querySignature = useMemo( - () => JSON.stringify(value.query ?? []), - [value.query], - ); - const [debouncedQuerySignature] = useDebounceValue(querySignature, 400); - - useEffect(() => { - const criteria = JSON.parse(debouncedQuerySignature) as QueryCriterion[]; - if (!criteria || criteria.length === 0) return; - searchFetcher.load( - `/@querystringSearch?query=${encodeURIComponent(debouncedQuerySignature)}`, - ); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [debouncedQuerySignature]); - - const searchData = searchFetcher.data as QuerystringSearchResult | undefined; - const searchItems = searchData?.items ?? EMPTY_ITEMS; - const searchTotal = searchData?.items_total ?? 0; - const searchLoading = searchFetcher.state !== 'idle'; - const contextValue = useMemo( () => ({ availableFields: fields, @@ -232,9 +199,6 @@ export function QuerystringProvider({ addCriterion, removeCriterion, updateCriterion, - searchItems, - searchTotal, - searchLoading, }), [ value, @@ -243,9 +207,6 @@ export function QuerystringProvider({ addCriterion, removeCriterion, updateCriterion, - searchItems, - searchTotal, - searchLoading, ], ); diff --git a/packages/cmsui/news/+fix-querystringWidgetContext.bugfix b/packages/cmsui/news/+fix-querystringWidgetContext.bugfix new file mode 100644 index 00000000000..112074e0fe9 --- /dev/null +++ b/packages/cmsui/news/+fix-querystringWidgetContext.bugfix @@ -0,0 +1 @@ +fix(querystringwidgetContext): move the @querystringSearch logic to useQuerystringResults and add tests @nileshgulia1 \ No newline at end of file