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/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
diff --git a/packages/blocks/Listing/ListingBlockEdit.tsx b/packages/blocks/Listing/ListingBlockEdit.tsx
new file mode 100644
index 000000000..895061eb6
--- /dev/null
+++ b/packages/blocks/Listing/ListingBlockEdit.tsx
@@ -0,0 +1,36 @@
+import type { BlockEditProps } from '@plone/types';
+
+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);
+
+ if (!hasListingQuery) {
+ return (
+
+ );
+ }
+
+ return ;
+};
+
+export default ListingEdit;
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/index.tsx b/packages/blocks/Listing/index.tsx
index 6d4af2dbc..63ed886b3 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,7 +9,13 @@ const ListingBlockInfo = {
view: React.lazy(
() => import(/* webpackChunkName: "plone-blocks" */ './ListingBlockView'),
),
+ edit: React.lazy(
+ () => import(/* webpackChunkName: "plone-blocks" */ './ListingBlockEdit'),
+ ),
+ blockSchema: ListingSchema,
+ icon: ListIcon,
category: 'common',
+ defaultBlockWidth: 'default',
} satisfies Partial;
export default ListingBlockInfo;
diff --git a/packages/blocks/Listing/schema.tsx b/packages/blocks/Listing/schema.tsx
new file mode 100644
index 000000000..d75556753
--- /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/blocks/Listing/useQuerystringResults.ts b/packages/blocks/Listing/useQuerystringResults.ts
new file mode 100644
index 000000000..c7d3952e6
--- /dev/null
+++ b/packages/blocks/Listing/useQuerystringResults.ts
@@ -0,0 +1,110 @@
+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?: string;
+ limit?: string;
+ } = {};
+
+ 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 = String(querystring.b_size);
+ }
+
+ if (querystring.limit !== undefined && querystring.limit !== null) {
+ params.limit = String(querystring.limit);
+ }
+
+ return Object.keys(params).length > 0 ? params : null;
+}
+
+export function useQuerystringResults(
+ querystring: QuerystringValue | undefined,
+) {
+ const fetcher = useFetcher();
+
+ const criteria = querystring?.query ?? [];
+ const hasCriteria = criteria.length > 0;
+
+ // 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(() => {
+ if (!queryUrl) return;
+
+ pendingParamsSignature.current = debouncedParamsSignature;
+ fetcher.load(queryUrl);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [queryUrl]);
+
+ useEffect(() => {
+ if (!fetcher.data || !pendingParamsSignature.current) return;
+ setLoadedParamsSignature(pendingParamsSignature.current);
+ }, [fetcher.data]);
+
+ 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, loaded };
+}
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/blocks/package.json b/packages/blocks/package.json
index 0d93d1048..2d38ce936 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/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/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/acceptance/tests/listing-block.test.ts b/packages/cmsui/acceptance/tests/listing-block.test.ts
new file mode 100644
index 000000000..590e18c74
--- /dev/null
+++ b/packages/cmsui/acceptance/tests/listing-block.test.ts
@@ -0,0 +1,209 @@
+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';
+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: PLONE_BLOCK_TYPE,
+ '@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: PLONE_BLOCK_TYPE,
+ '@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/QuerystringWidget/QuerystringWidget.stories.tsx b/packages/cmsui/components/QuerystringWidget/QuerystringWidget.stories.tsx
index 31f829f34..265139a7c 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 = () => {
@@ -129,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' },
@@ -144,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' },
@@ -199,7 +206,6 @@ const meta = {
label: 'Search Criteria',
description: 'Define search criteria to filter content',
onChange: fn(),
- onPatchFormData: fn(),
},
} satisfies Meta;
@@ -222,9 +228,9 @@ export const WithSingleCriterion: Story = {
value: {
query: [
{
- i: 'Creator',
+ i: 'portal_type',
o: 'is',
- v: 'admin',
+ v: 'Document',
},
],
sort_on: 'Title',
diff --git a/packages/cmsui/components/QuerystringWidget/QuerystringWidget.tsx b/packages/cmsui/components/QuerystringWidget/QuerystringWidget.tsx
index 0f56bc8e1..94e670fe6 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;
}
/**
@@ -146,6 +145,21 @@ function QueryCriterionRow({
onChange={(value: string) => handleValueChange(value)}
isDisabled={disabled}
/>
+ ) : field?.valueType === 'select' && field?.valueOptions?.length ? (
+
) : (
('[]');
-
- 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 }),
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/config/routes.ts b/packages/cmsui/config/routes.ts
index cccac5bec..1efd49909 100644
--- a/packages/cmsui/config/routes.ts
+++ b/packages/cmsui/config/routes.ts
@@ -125,6 +125,17 @@ export default function install(config: ConfigType) {
},
],
});
+ config.registerRoute({
+ type: 'prefix',
+ path: '@querystringSearch',
+ children: [
+ {
+ type: 'route',
+ path: '*',
+ file: '@plone/cmsui/routes/querystringSearch.tsx',
+ },
+ ],
+ });
config.registerRoute({
type: 'prefix',
path: '@createContent',
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
diff --git a/packages/cmsui/routes/querystringSearch.tsx b/packages/cmsui/routes/querystringSearch.tsx
index c2eb765db..8eedb8d20 100644
--- a/packages/cmsui/routes/querystringSearch.tsx
+++ b/packages/cmsui/routes/querystringSearch.tsx
@@ -3,31 +3,15 @@ import {
RouterContextProvider,
type LoaderFunctionArgs,
} from 'react-router';
+import { flattenToAppURL } from '@plone/helpers';
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,26 +19,43 @@ 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);
+ 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' },
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index b8db8d53e..4827615df 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:*