From fea434299976355e13604a204d1c3e18ada1df09 Mon Sep 17 00:00:00 2001 From: nileshgulia1 Date: Tue, 17 Mar 2026 19:55:07 +0530 Subject: [PATCH 01/32] 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 000000000..9357eed53 --- /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 82b8fb2ed..c75023b71 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 000000000..c6edfb93b --- /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 000000000..b31540824 --- /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 07150b5fe..d6e81b872 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/32] 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 000000000..4ffae0601 --- /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 b31540824..1d01e3b89 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 000000000..3f514a00c --- /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/32] 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 9357eed53..000000000 --- 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 c75023b71..82b8fb2ed 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 c6edfb93b..000000000 --- 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/32] 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 1d01e3b89..670dbe1ef 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/32] 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 4ffae0601..14633e400 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 670dbe1ef..28084fe25 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 3f514a00c..de1e72438 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 ec46f8725..a2eb973b2 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 000000000..5ee9e01f8 --- /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/32] 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 28084fe25..edec4b49a 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/32] 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 000000000..cadb1ac11 --- /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 5ee9e01f8..1a1a94a55 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/32] 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 000000000..9357eed53 --- /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 6d4af2dbc..b1b7b1aba 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 000000000..c6edfb93b --- /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/32] 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 c6edfb93b..d75556753 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 71f39554e..ea21f11a6 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 484acbc51..57b4f20f9 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 edec4b49a..b32c7ee98 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 000000000..a5733b462 --- /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 e842c71ac..bc7f0e97e 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/32] 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 14633e400..31f829f34 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/32] 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 14633e400..31f829f34 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 edec4b49a..e561f9683 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 de1e72438..e1acdebac 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 1a1a94a55..989388b0f 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 000000000..a5733b462 --- /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/32] 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 e842c71ac..98bf5fd5b 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/32] 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 000000000..403edbb41 --- /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/32] 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 000000000..60a6e3a02 --- /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/32] 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 cb3f87f27..e09f87182 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 000000000..b3b31f8e5 --- /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 ea21f11a6..69c38fbc3 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 57b4f20f9..484acbc51 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 31f829f34..b582ccbe0 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 e561f9683..526ea1fcc 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/32] 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 e561f9683..0f56bc8e1 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/32] 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 b3b31f8e5..3678de048 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 6de07c4f9..b00dda923 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 4da1f8a6b..2de672a12 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 555f07d69c097b1a4d65cf9a218b45fcd3188fed Mon Sep 17 00:00:00 2001 From: nileshgulia1 Date: Mon, 8 Jun 2026 21:29:53 +0530 Subject: [PATCH 23/32] 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 70a98bd9a..eec2fadeb 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 000000000..dd6bd2abb --- /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 000000000..e357553ce --- /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 69c38fbc3..71f39554e 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 e1acdebac..cbf21f773 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 000000000..112074e0f --- /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 From e1101126112f437991de7901dbdc1c7c27f0d075 Mon Sep 17 00:00:00 2001 From: nileshgulia1 Date: Tue, 9 Jun 2026 12:32:52 +0530 Subject: [PATCH 24/32] fix: remove duplicate export --- packages/components/src/quanta/index.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/components/src/quanta/index.ts b/packages/components/src/quanta/index.ts index 8ff80b9bc..7b8752505 100644 --- a/packages/components/src/quanta/index.ts +++ b/packages/components/src/quanta/index.ts @@ -3,7 +3,6 @@ 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/ComboBox/ComboBox.quanta'; export * from '../components/Dialog/Dialog.quanta'; From 8e3532547387201d5ee7b728eb5795dd40addcdd Mon Sep 17 00:00:00 2001 From: Victor Fernandez de Alba Date: Sun, 14 Jun 2026 17:26:46 +0200 Subject: [PATCH 25/32] Fix defaultBlockWidth for ploneBlock listing. --- packages/blocks/Listing/index.tsx | 1 + packages/blocks/index.ts | 6 ------ 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/packages/blocks/Listing/index.tsx b/packages/blocks/Listing/index.tsx index b1b7b1aba..63ed886b3 100644 --- a/packages/blocks/Listing/index.tsx +++ b/packages/blocks/Listing/index.tsx @@ -15,6 +15,7 @@ const ListingBlockInfo = { blockSchema: ListingSchema, icon: ListIcon, category: 'common', + defaultBlockWidth: 'default', } satisfies Partial; export default ListingBlockInfo; diff --git a/packages/blocks/index.ts b/packages/blocks/index.ts index 56a6c4d1e..72aceed56 100644 --- a/packages/blocks/index.ts +++ b/packages/blocks/index.ts @@ -106,12 +106,6 @@ export default function install(config: ConfigType) { widths: ['default'], }, }, - listing: { - blockWidth: { - defaultWidth: 'default', - widths: ['layout', 'default', 'narrow'], - }, - }, toc: { category: 'navigation', blockWidth: { From 17a7a22c871051c7096d6080d34f7fcdbab32662 Mon Sep 17 00:00:00 2001 From: nileshgulia1 Date: Tue, 16 Jun 2026 19:43:15 +0530 Subject: [PATCH 26/32] fix: add missing Select widget for valueType select --- packages/blocks/Listing/ListingBlockEdit.tsx | 10 ++++------ .../QuerystringWidget/QuerystringWidget.tsx | 15 +++++++++++++++ 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/packages/blocks/Listing/ListingBlockEdit.tsx b/packages/blocks/Listing/ListingBlockEdit.tsx index e09f87182..dbdf37351 100644 --- a/packages/blocks/Listing/ListingBlockEdit.tsx +++ b/packages/blocks/Listing/ListingBlockEdit.tsx @@ -21,12 +21,10 @@ const ListingEdit = (props: BlockEditProps) => { const { items } = useQuerystringResults(data.querystring as any); useEffect(() => { - if (hasListingQuery && items.length > 0) { - setBlock({ - ...data, - items, - }); - } + setBlock({ + ...data, + items, + }); }, [items, hasListingQuery, data, setBlock]); if (!hasListingQuery) { diff --git a/packages/cmsui/components/QuerystringWidget/QuerystringWidget.tsx b/packages/cmsui/components/QuerystringWidget/QuerystringWidget.tsx index 49c0120a9..94e670fe6 100644 --- a/packages/cmsui/components/QuerystringWidget/QuerystringWidget.tsx +++ b/packages/cmsui/components/QuerystringWidget/QuerystringWidget.tsx @@ -145,6 +145,21 @@ function QueryCriterionRow({ onChange={(value: string) => handleValueChange(value)} isDisabled={disabled} /> + ) : field?.valueType === 'select' && field?.valueOptions?.length ? ( + ) : ( Date: Tue, 16 Jun 2026 21:43:25 +0530 Subject: [PATCH 27/32] refactor: make listing blockData more generic --- packages/blocks/Listing/ListingBlockEdit.tsx | 2 +- packages/blocks/Listing/useQuerystringResults.ts | 10 ++++++---- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/blocks/Listing/ListingBlockEdit.tsx b/packages/blocks/Listing/ListingBlockEdit.tsx index dbdf37351..49393b793 100644 --- a/packages/blocks/Listing/ListingBlockEdit.tsx +++ b/packages/blocks/Listing/ListingBlockEdit.tsx @@ -23,7 +23,7 @@ const ListingEdit = (props: BlockEditProps) => { useEffect(() => { setBlock({ ...data, - items, + items: hasListingQuery ? items : [], }); }, [items, hasListingQuery, data, setBlock]); diff --git a/packages/blocks/Listing/useQuerystringResults.ts b/packages/blocks/Listing/useQuerystringResults.ts index 3678de048..b52427895 100644 --- a/packages/blocks/Listing/useQuerystringResults.ts +++ b/packages/blocks/Listing/useQuerystringResults.ts @@ -9,7 +9,9 @@ export function useQuerystringResults( ) { const fetcher = useFetcher(); - const querySignature = JSON.stringify(querystring?.query ?? []); + const criteria = querystring?.query ?? []; + const hasCriteria = criteria.length > 0; + const querySignature = JSON.stringify(criteria); const [debouncedQuerySignature] = useDebounceValue(querySignature, 400); useEffect(() => { @@ -22,9 +24,9 @@ export function useQuerystringResults( // 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'; + const items = hasCriteria ? (fetcher.data?.items ?? []) : []; + const total = hasCriteria ? (fetcher.data?.items_total ?? 0) : 0; + const loading = hasCriteria && fetcher.state !== 'idle'; return { items, total, loading }; } From 0221d0283dfe56d3592ac3af959a79cef51693ff Mon Sep 17 00:00:00 2001 From: nileshgulia1 Date: Wed, 17 Jun 2026 14:01:29 +0530 Subject: [PATCH 28/32] fix: simplify query criteria and move search logic to ListingView --- packages/blocks/Listing/ListingBlockEdit.tsx | 12 +-- packages/blocks/Listing/ListingBlockView.tsx | 12 ++- .../blocks/Listing/useQuerystringResults.ts | 102 +++++++++++++++--- .../src/restapi/querystring-search/get.ts | 30 ++++-- packages/cmsui/routes/querystringSearch.tsx | 45 ++++---- 5 files changed, 146 insertions(+), 55 deletions(-) diff --git a/packages/blocks/Listing/ListingBlockEdit.tsx b/packages/blocks/Listing/ListingBlockEdit.tsx index 49393b793..895061eb6 100644 --- a/packages/blocks/Listing/ListingBlockEdit.tsx +++ b/packages/blocks/Listing/ListingBlockEdit.tsx @@ -1,8 +1,6 @@ -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; @@ -16,16 +14,8 @@ const hasQuery = (value: any): boolean => { }; const ListingEdit = (props: BlockEditProps) => { - const { data, setBlock } = props; + const { data } = props; const hasListingQuery = hasQuery(data.querystring as any); - const { items } = useQuerystringResults(data.querystring as any); - - useEffect(() => { - setBlock({ - ...data, - items: hasListingQuery ? items : [], - }); - }, [items, hasListingQuery, data, setBlock]); if (!hasListingQuery) { return ( diff --git a/packages/blocks/Listing/ListingBlockView.tsx b/packages/blocks/Listing/ListingBlockView.tsx index 95493a845..9b1c20067 100644 --- a/packages/blocks/Listing/ListingBlockView.tsx +++ b/packages/blocks/Listing/ListingBlockView.tsx @@ -1,5 +1,9 @@ import type { BlockViewProps, Brain, ListingBlockFormData } from '@plone/types'; import { useTranslation } from 'react-i18next'; +import { useQuerystringResults } from './useQuerystringResults'; + +const hasQuery = (value: ListingBlockFormData['querystring']): boolean => + Array.isArray(value?.query) && value.query.length > 0; /** * View listing block component. @@ -11,6 +15,10 @@ const ListingBlockView = (props: BlockViewProps) => { const HeadlineTag = data.headlineTag || 'h2'; const ItemTitleTag = data.headlineTag === 'h2' ? 'h3' : 'h4'; const { t } = useTranslation(); + const hasListingQuery = hasQuery(data.querystring); + const { items, loaded } = useQuerystringResults(data.querystring as any); + const initialItems = props.isEditMode ? [] : (data.items ?? []); + const listingItems = hasListingQuery ? (loaded ? items : initialItems) : []; const getPreviewImageUrl = (item: Brain) => { const imageField = item.image_field; @@ -51,10 +59,10 @@ const ListingBlockView = (props: BlockViewProps) => { return ( <> {data.headline ? {data.headline} : ''} - {!data.items || data.items?.length === 0 ? ( + {listingItems.length === 0 ? (
{t('blocks.listing.no-results')}
) : ( - data.items.map((item) => + listingItems.map((item) => data.variation === 'summary' ? renderSummary(item) : renderDefault(item), diff --git a/packages/blocks/Listing/useQuerystringResults.ts b/packages/blocks/Listing/useQuerystringResults.ts index b52427895..f920a6437 100644 --- a/packages/blocks/Listing/useQuerystringResults.ts +++ b/packages/blocks/Listing/useQuerystringResults.ts @@ -1,9 +1,64 @@ -import { useEffect } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { useFetcher } from 'react-router'; import { useDebounceValue } from 'usehooks-ts'; import type { QuerystringValue } from '../../cmsui/components/QuerystringWidget/QuerystringWidgetContext'; import type { QuerystringSearchResult } from '../../cmsui/routes/querystringSearch'; +/** + * Convert sort_order to string format. + * Handles both boolean (true = descending, false = ascending) and string values. + */ +function normalizeSortOrder( + sortOrder: string | boolean | undefined, +): 'ascending' | 'descending' | undefined { + if (sortOrder === undefined || sortOrder === null) return undefined; + if (typeof sortOrder === 'boolean') { + return sortOrder ? 'descending' : 'ascending'; + } + if (sortOrder === 'ascending' || sortOrder === 'descending') { + return sortOrder; + } +} + +/** + * Build query parameters object with all supported fields. + * Only includes fields that have values (following Volto's pattern). + */ +function buildQueryParams(querystring: QuerystringValue | undefined) { + if (!querystring) return null; + + const params: { + query?: QuerystringValue['query']; + sort_on?: string; + sort_order?: string; + b_size?: number; + limit?: number; + } = {}; + + if (querystring.query && querystring.query.length > 0) { + params.query = querystring.query; + } + + if (querystring.sort_on) { + params.sort_on = querystring.sort_on; + } + + const normalizedSortOrder = normalizeSortOrder(querystring.sort_order); + if (normalizedSortOrder) { + params.sort_order = normalizedSortOrder; + } + + if (querystring.b_size !== undefined && querystring.b_size !== null) { + params.b_size = querystring.b_size; + } + + if (querystring.limit !== undefined && querystring.limit !== null) { + params.limit = querystring.limit; + } + + return Object.keys(params).length > 0 ? params : null; +} + export function useQuerystringResults( querystring: QuerystringValue | undefined, ) { @@ -11,22 +66,45 @@ export function useQuerystringResults( const criteria = querystring?.query ?? []; const hasCriteria = criteria.length > 0; - const querySignature = JSON.stringify(criteria); - const [debouncedQuerySignature] = useDebounceValue(querySignature, 400); + + // Build full params signature including all supported fields + const params = buildQueryParams(querystring); + const paramsSignature = JSON.stringify(params); + const [debouncedParamsSignature] = useDebounceValue(paramsSignature, 400); + const pendingParamsSignature = useRef(undefined); + const [loadedParamsSignature, setLoadedParamsSignature] = useState< + string | undefined + >(undefined); + + const queryUrl = useMemo(() => { + const currentParams = JSON.parse(debouncedParamsSignature); + if (!currentParams?.query?.length) return null; + + // Encode the entire params object as a single query parameter + const queryString = JSON.stringify(currentParams); + const encodedQuery = encodeURIComponent(queryString); + + return `/@querystringSearch?query=${encodedQuery}`; + }, [debouncedParamsSignature]); useEffect(() => { - const criteria = JSON.parse(debouncedQuerySignature); - if (!criteria || criteria.length === 0) return; + if (!queryUrl) return; - fetcher.load( - `/@querystringSearch?query=${encodeURIComponent(debouncedQuerySignature)}`, - ); + pendingParamsSignature.current = debouncedParamsSignature; + fetcher.load(queryUrl); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [debouncedQuerySignature]); + }, [queryUrl]); + + useEffect(() => { + if (!fetcher.data || !pendingParamsSignature.current) return; + setLoadedParamsSignature(pendingParamsSignature.current); + }, [fetcher.data]); - const items = hasCriteria ? (fetcher.data?.items ?? []) : []; - const total = hasCriteria ? (fetcher.data?.items_total ?? 0) : 0; + const loaded = + !hasCriteria || loadedParamsSignature === debouncedParamsSignature; + const items = hasCriteria && loaded ? (fetcher.data?.items ?? []) : []; + const total = hasCriteria && loaded ? (fetcher.data?.items_total ?? 0) : 0; const loading = hasCriteria && fetcher.state !== 'idle'; - return { items, total, loading }; + return { items, total, loading, loaded }; } diff --git a/packages/client/src/restapi/querystring-search/get.ts b/packages/client/src/restapi/querystring-search/get.ts index 93b36c588..79b5b43c5 100644 --- a/packages/client/src/restapi/querystring-search/get.ts +++ b/packages/client/src/restapi/querystring-search/get.ts @@ -9,27 +9,45 @@ export type QuerystringSearchArgs = z.infer; export async function querystringSearch( this: PloneClient, - { query, post }: QuerystringSearchArgs, + args: QuerystringSearchArgs, ): Promise> { - const validatedArgs = querystringSearchDataSchema.parse({ + const { query, - }); + post, + sort_on, + sort_order, + b_size, + limit, + b_start, + fullobjects, + } = querystringSearchDataSchema.parse(args); + + // Build the complete query object with all parameters + const queryObject = { + query, + ...(sort_on && { sort_on }), + ...(sort_order && { sort_order }), + ...(b_size && { b_size }), + ...(limit && { limit }), + ...(b_start && { b_start }), + ...(fullobjects !== undefined && { fullobjects }), + }; + if (post) { const options: ApiRequestParams = { - data: { query: validatedArgs.query }, + data: queryObject, config: this.config, }; return apiRequest('post', '/@querystring-search', options); } else { - const queryObject = { query: validatedArgs.query }; const querystring = JSON.stringify(queryObject); const encodedQuery = encodeURIComponent(querystring); const options: ApiRequestParams = { config: this.config, params: { - ...(encodedQuery && { query: encodedQuery }), + query: encodedQuery, }, }; diff --git a/packages/cmsui/routes/querystringSearch.tsx b/packages/cmsui/routes/querystringSearch.tsx index c2eb765db..12a82884b 100644 --- a/packages/cmsui/routes/querystringSearch.tsx +++ b/packages/cmsui/routes/querystringSearch.tsx @@ -4,30 +4,13 @@ import { type LoaderFunctionArgs, } from 'react-router'; import { ploneClientContext } from '@plone/aurora/app/middleware.server'; -import type { Brain, Query } from '@plone/types'; +import type { Brain } 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, @@ -35,21 +18,35 @@ export async function loader({ const cli = context.get(ploneClientContext); const url = new URL(request.url); - const query = parseQuery(url.searchParams.get('query')); + const queryParam = url.searchParams.get('query'); const empty: QuerystringSearchResult = { items: [], items_total: 0 }; - if (query.length === 0) { + if (!queryParam) { return data(empty, { headers: { 'Content-Type': 'application/json' }, }); } try { - const { data: results } = await cli.querystringSearch({ - query, - post: true, - }); + // Parse the query parameter as JSON (contains all params: query, sort_on, etc.) + let queryObject; + try { + queryObject = JSON.parse(decodeURIComponent(queryParam)); + } catch { + return data(empty, { + headers: { 'Content-Type': 'application/json' }, + }); + } + + if (!queryObject.query?.length) { + return data(empty, { + headers: { 'Content-Type': 'application/json' }, + }); + } + + // Pass the entire query object to cli.querystringSearch() + const { data: results } = await cli.querystringSearch(queryObject); return data( { From 61d1aead5315a5752cfb5d3a9cfd9e77ca6eeacd Mon Sep 17 00:00:00 2001 From: nileshgulia1 Date: Wed, 17 Jun 2026 14:34:13 +0530 Subject: [PATCH 29/32] fix sort_on and limit type --- packages/blocks/Listing/useQuerystringResults.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/blocks/Listing/useQuerystringResults.ts b/packages/blocks/Listing/useQuerystringResults.ts index f920a6437..c7d3952e6 100644 --- a/packages/blocks/Listing/useQuerystringResults.ts +++ b/packages/blocks/Listing/useQuerystringResults.ts @@ -31,8 +31,8 @@ function buildQueryParams(querystring: QuerystringValue | undefined) { query?: QuerystringValue['query']; sort_on?: string; sort_order?: string; - b_size?: number; - limit?: number; + b_size?: string; + limit?: string; } = {}; if (querystring.query && querystring.query.length > 0) { @@ -49,11 +49,11 @@ function buildQueryParams(querystring: QuerystringValue | undefined) { } if (querystring.b_size !== undefined && querystring.b_size !== null) { - params.b_size = querystring.b_size; + params.b_size = String(querystring.b_size); } if (querystring.limit !== undefined && querystring.limit !== null) { - params.limit = querystring.limit; + params.limit = String(querystring.limit); } return Object.keys(params).length > 0 ? params : null; From 6cb547d5b6c3e517111b265c7addfdadc3056c3c Mon Sep 17 00:00:00 2001 From: nileshgulia1 Date: Wed, 17 Jun 2026 15:38:26 +0530 Subject: [PATCH 30/32] fix playright tests --- apps/aurora/app/config/server.server.ts | 6 +++++- packages/client/news/+get-querystring.feature | 1 + packages/cmsui/acceptance/tests/listing-block.test.ts | 5 +++-- packages/cmsui/routes/querystringSearch.tsx | 8 ++++++-- 4 files changed, 15 insertions(+), 5 deletions(-) create mode 100644 packages/client/news/+get-querystring.feature diff --git a/apps/aurora/app/config/server.server.ts b/apps/aurora/app/config/server.server.ts index b2b836631..aa8f17dca 100644 --- a/apps/aurora/app/config/server.server.ts +++ b/apps/aurora/app/config/server.server.ts @@ -3,6 +3,7 @@ */ import config from '@plone/registry'; import PloneClient from '@plone/client'; +import { flattenToAppURL } from '@plone/helpers'; // eslint-disable-next-line import/no-unresolved import applyAddonConfiguration from '../../.plone/registry.loader'; // eslint-disable-next-line import/no-unresolved @@ -34,7 +35,10 @@ export default function install() { const { id, block } = listingBlocks[i]; if (block.querystring) { const results = await args.cli.querystringSearch(block.querystring); - args.content.blocks[id].items = results.data.items; + const flattened = flattenToAppURL( + results?.data ?? { items: [], items_total: 0 }, + ); + args.content.blocks[id].items = flattened.items; } } }, diff --git a/packages/client/news/+get-querystring.feature b/packages/client/news/+get-querystring.feature new file mode 100644 index 000000000..f79dd7e26 --- /dev/null +++ b/packages/client/news/+get-querystring.feature @@ -0,0 +1 @@ +add queryParams to request body @nileshgulia1 \ 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 index e357553ce..590e18c74 100644 --- a/packages/cmsui/acceptance/tests/listing-block.test.ts +++ b/packages/cmsui/acceptance/tests/listing-block.test.ts @@ -1,4 +1,5 @@ import { expect, test } from '../../../tooling/playwright/test'; +import { PLONE_BLOCK_TYPE } from '@plone/helpers'; import { login } from '../../../tooling/playwright/login'; import { createContent } from '../../../tooling/playwright/content'; import { waitForPlateEditorReady } from '../../../tooling/playwright/plate'; @@ -45,7 +46,7 @@ async function setupListingBlockPage(page: Page) { children: [{ text: 'Listing Block Page' }], }, { - type: 'unknown', + type: PLONE_BLOCK_TYPE, '@type': 'listing', headline: 'Latest News', querystring: { @@ -115,7 +116,7 @@ test.describe('Listing block', () => { children: [{ text: 'Empty Listing Page' }], }, { - type: 'unknown', + type: PLONE_BLOCK_TYPE, '@type': 'listing', headline: 'No Query', children: [{ text: '' }], diff --git a/packages/cmsui/routes/querystringSearch.tsx b/packages/cmsui/routes/querystringSearch.tsx index 12a82884b..8eedb8d20 100644 --- a/packages/cmsui/routes/querystringSearch.tsx +++ b/packages/cmsui/routes/querystringSearch.tsx @@ -3,6 +3,7 @@ import { RouterContextProvider, type LoaderFunctionArgs, } from 'react-router'; +import { flattenToAppURL } from '@plone/helpers'; import { ploneClientContext } from '@plone/aurora/app/middleware.server'; import type { Brain } from '@plone/types'; @@ -47,11 +48,14 @@ export async function loader({ // Pass the entire query object to cli.querystringSearch() const { data: results } = await cli.querystringSearch(queryObject); + const flattened = results + ? flattenToAppURL(results) + : { items: [], items_total: 0 }; return data( { - items: results?.items ?? [], - items_total: results?.items_total ?? 0, + items: flattened.items ?? [], + items_total: flattened.items_total ?? 0, } satisfies QuerystringSearchResult, { headers: { 'Content-Type': 'application/json' }, From 64b103b7a64aed512cdbfe53cf9435e1cd9b9d2c Mon Sep 17 00:00:00 2001 From: nileshgulia1 Date: Wed, 17 Jun 2026 16:48:53 +0530 Subject: [PATCH 31/32] chore: update changelog --- apps/aurora/news/+listingblockData-flattenToAppUrl.feature | 1 + 1 file changed, 1 insertion(+) create mode 100644 apps/aurora/news/+listingblockData-flattenToAppUrl.feature diff --git a/apps/aurora/news/+listingblockData-flattenToAppUrl.feature b/apps/aurora/news/+listingblockData-flattenToAppUrl.feature new file mode 100644 index 000000000..85ac2dbbb --- /dev/null +++ b/apps/aurora/news/+listingblockData-flattenToAppUrl.feature @@ -0,0 +1 @@ +Add flattenToAppUrl on listing data @nileshgulia1 \ No newline at end of file From 9e4b95305eea2ea1249a1624247a0e1ac53d99b3 Mon Sep 17 00:00:00 2001 From: nileshgulia1 Date: Thu, 18 Jun 2026 13:57:48 +0530 Subject: [PATCH 32/32] fix storybook for QuerystringWidget --- .../QuerystringWidget.stories.tsx | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/packages/cmsui/components/QuerystringWidget/QuerystringWidget.stories.tsx b/packages/cmsui/components/QuerystringWidget/QuerystringWidget.stories.tsx index b582ccbe0..265139a7c 100644 --- a/packages/cmsui/components/QuerystringWidget/QuerystringWidget.stories.tsx +++ b/packages/cmsui/components/QuerystringWidget/QuerystringWidget.stories.tsx @@ -128,7 +128,11 @@ const createQuerystringRouter = (props: QuerystringWidgetStoryProps) => sortable: true, group: 'Metadata', operators: { - is: { title: 'Is', description: null, widget: null }, + is: { + title: 'Is', + description: null, + widget: 'SelectionWidget', + }, }, values: { published: { title: 'Published' }, @@ -143,7 +147,11 @@ const createQuerystringRouter = (props: QuerystringWidgetStoryProps) => sortable: false, group: 'Metadata', operators: { - is: { title: 'Is', description: null, widget: null }, + is: { + title: 'Is', + description: null, + widget: 'SelectionWidget', + }, }, values: { Document: { title: 'Page' }, @@ -220,9 +228,9 @@ export const WithSingleCriterion: Story = { value: { query: [ { - i: 'Creator', + i: 'portal_type', o: 'is', - v: 'admin', + v: 'Document', }, ], sort_on: 'Title',