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 ( +
+

No Results Found

+
+ ); + } + + 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:*