diff --git a/packages/cmsui/components/QuerystringWidget/QuerystringWidget.stories.tsx b/packages/cmsui/components/QuerystringWidget/QuerystringWidget.stories.tsx new file mode 100644 index 00000000000..31f829f3482 --- /dev/null +++ b/packages/cmsui/components/QuerystringWidget/QuerystringWidget.stories.tsx @@ -0,0 +1,386 @@ +import { useMemo } from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { + RouterProvider, + createMemoryRouter, + type LoaderFunctionArgs, +} from 'react-router'; +import { fn } from 'storybook/test'; +import type { QuerystringValue } from './QuerystringWidgetContext'; +import { QuerystringWidget } from './QuerystringWidget'; + +interface QuerystringWidgetStoryProps { + label?: string; + description?: string; + errorMessage?: string; + value?: QuerystringValue; + defaultValue?: QuerystringValue; + onChange?: (value: QuerystringValue) => void; + onPatchFormData?: (partial: Record) => void; +} + +const createQuerystringLoader = () => { + return ({ request }: LoaderFunctionArgs) => { + return { + content: { + '@id': '/Test/Document', + }, + }; + }; +}; + +const createQuerystringRouter = (props: QuerystringWidgetStoryProps) => + createMemoryRouter( + [ + { + id: 'root', + path: '/', + loader: createQuerystringLoader(), + element: ( +
+
+ +
+
+ ), + }, + { + 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' }, + }, + }, + }, + }), + }, + { + 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: ['/'], + }, + ); + +const StoryRouter = (props: QuerystringWidgetStoryProps) => { + const router = useMemo(() => createQuerystringRouter(props), [props]); + return ; +}; + +const meta = { + component: QuerystringWidget, + parameters: { + layout: 'fullscreen', + backgrounds: { disable: true }, + }, + tags: ['autodocs'], + args: { + label: 'Search Criteria', + description: 'Define search criteria to filter content', + onChange: fn(), + onPatchFormData: fn(), + }, +} 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 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: [ + { + 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 new file mode 100644 index 00000000000..0f56bc8e1f5 --- /dev/null +++ b/packages/cmsui/components/QuerystringWidget/QuerystringWidget.tsx @@ -0,0 +1,454 @@ +import { useId, useCallback, useMemo, useEffect, useRef } from 'react'; +import { tv } from 'tailwind-variants'; +import type { TextFieldProps as QuantaTextFieldProps } from '@plone/components/quanta'; +import { + Description, + fieldBorderStyles, + FieldError, + Label, +} from '../Field/Field'; +import { useLoaderData } from 'react-router'; +import type { loader as editLoader } from '../../routes/edit'; + +import { focusRing } from '../utils'; +import { Switch } from '@plone/components'; +import { + TextField, + Select, + SelectItem, + ComboBox, + ComboBoxItem, + Button, +} from '@plone/components/quanta'; +import { + QuerystringProvider, + useQuerystringContext, + type QuerystringValue, + type QueryCriterion, + type FieldMetadata, +} 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 overflow-visible rounded-md p-4', + variants: { + isFocused: fieldBorderStyles.variants.isFocusWithin, + isInvalid: fieldBorderStyles.variants.isInvalid, + isDisabled: fieldBorderStyles.variants.isDisabled, + }, +}); + +interface QuerystringWidgetProps extends BaseFormFieldProps { + value?: QuerystringValue; + defaultValue?: QuerystringValue; + onChange?: (value: QuerystringValue) => void; + onPatchFormData?: (partial: Record) => 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 ( +
+
+ key && handleFieldChange(key as string)} + isDisabled={disabled} + placeholder="Search field…" + > + {availableFields.map((f) => ( + + {f.title} + + ))} + +
+ +
+ +
+ +
+ {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, + 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 }), + [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 ? parseInt(value, 10) : undefined, + }) + } + /> +
+ )} + + {/* Sort By Field */} +
+ +
+ + {/* Sort Order Toggle */} +
+ + handleValueChange({ + ...synced, + sort_order: selected ? 'descending' : 'ascending', + }) + } + > + Reverse order + +
+ + {/* Results Options Row */} +
+
+ + handleValueChange({ + ...synced, + limit: value ? parseInt(value, 10) : undefined, + }) + } + /> +
+
+ + handleValueChange({ + ...synced, + b_size: value ? parseInt(value, 10) : undefined, + }) + } + /> +
+
+
+ )} +
+ + {description && {description}} + {errorMessage} +
+ ); +} + +export function QuerystringWidget(props: QuerystringWidgetProps) { + useLoaderData(); + + const { label, description, errorMessage, value, defaultValue, ...rest } = + props; + const initialValue = value ?? defaultValue; + + return ( + + + + ); +} + +QuerystringWidget.displayName = 'QuerystringWidget'; diff --git a/packages/cmsui/components/QuerystringWidget/QuerystringWidgetContext.tsx b/packages/cmsui/components/QuerystringWidget/QuerystringWidgetContext.tsx new file mode 100644 index 00000000000..e1acdebac58 --- /dev/null +++ b/packages/cmsui/components/QuerystringWidget/QuerystringWidgetContext.tsx @@ -0,0 +1,267 @@ +import { + createContext, + useContext, + useState, + useCallback, + useMemo, + 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 + * 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 }>; +} + +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[] { + 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 }>; + value: QuerystringValue; + setValue: (value: QuerystringValue) => void; + addCriterion: () => void; + removeCriterion: (index: number) => void; + updateCriterion: (index: number, criterion: QueryCriterion) => void; + searchItems: Brain[]; + searchTotal: number; + searchLoading: boolean; +} + +const QuerystringContext = createContext( + undefined, +); + +export interface QuerystringProviderProps { + initialValue?: QuerystringValue; + availableFields?: FieldMetadata[]; + availableSortFields?: Array<{ value: string; label: string }>; + backendIndexes?: Record; + children: React.ReactNode; +} + +const EMPTY_ITEMS: Brain[] = []; + +export function QuerystringProvider({ + initialValue = {}, + availableFields, + availableSortFields: availableSortFieldsProp, + 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) : []), + [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(() => { + setValue((prev) => ({ + ...prev, + query: [ + ...(prev.query ?? []), + { + i: fields[0]?.name ?? '', + o: fields[0]?.operators?.[0]?.value ?? '', + v: '', + }, + ], + })); + }, [fields]); + + 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 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, + availableSortFields, + value, + setValue, + addCriterion, + removeCriterion, + updateCriterion, + searchItems, + searchTotal, + searchLoading, + }), + [ + value, + fields, + availableSortFields, + addCriterion, + removeCriterion, + updateCriterion, + searchItems, + searchTotal, + searchLoading, + ], + ); + + return ( + + {children} + + ); +} + +export function useQuerystringContext(): QuerystringContextType { + const context = useContext(QuerystringContext); + if (!context) { + throw new Error( + 'useQuerystringContext must be used within QuerystringProvider', + ); + } + return context; +} diff --git a/packages/cmsui/config/routes.ts b/packages/cmsui/config/routes.ts index 9032a55a241..cccac5bec7a 100644 --- a/packages/cmsui/config/routes.ts +++ b/packages/cmsui/config/routes.ts @@ -114,6 +114,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/config/widgets.ts b/packages/cmsui/config/widgets.ts index e88f79472d7..e1e160a7ea0 100644 --- a/packages/cmsui/config/widgets.ts +++ b/packages/cmsui/config/widgets.ts @@ -11,6 +11,7 @@ import { DateField } from '@plone/components'; import { RecurrenceWidget } from '../components/RecurrenceWidget/RecurrenceWidget'; 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); @@ -53,6 +54,12 @@ export default function install(config: ConfigType) { object_browser: ObjectBrowserWidget, }, }); + config.registerWidget({ + key: 'widget', + definition: { + querystring: QuerystringWidget, + }, + }); config.registerWidget({ key: 'vocabulary', definition: { diff --git a/packages/cmsui/news/8007.feature b/packages/cmsui/news/8007.feature new file mode 100644 index 00000000000..cadb1ac11e9 --- /dev/null +++ b/packages/cmsui/news/8007.feature @@ -0,0 +1 @@ +Added querystringWidget for seven @nileshgulia1 \ No newline at end of file diff --git a/packages/cmsui/routes/queryStringOptions.tsx b/packages/cmsui/routes/queryStringOptions.tsx new file mode 100644 index 00000000000..989388b0f98 --- /dev/null +++ b/packages/cmsui/routes/queryStringOptions.tsx @@ -0,0 +1,63 @@ +import { + data, + RouterContextProvider, + type LoaderFunctionArgs, +} from 'react-router'; +import { ploneClientContext } from 'seven/app/middleware.server'; + +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({ + context, +}: LoaderFunctionArgs) { + const cli = context.get(ploneClientContext); + + try { + const { data: 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', + }, + }, + ); + } +} diff --git a/packages/cmsui/routes/querystringSearch.tsx b/packages/cmsui/routes/querystringSearch.tsx new file mode 100644 index 00000000000..403edbb4160 --- /dev/null +++ b/packages/cmsui/routes/querystringSearch.tsx @@ -0,0 +1,70 @@ +import { + data, + RouterContextProvider, + type LoaderFunctionArgs, +} from 'react-router'; +import { ploneClientContext } from 'seven/app/middleware.server'; +import type { Brain, Query } from '@plone/types'; + +export interface QuerystringSearchResult { + items: Brain[]; + items_total: number; +} + +function parseQuery(raw: string | null): Query[] { + if (!raw) return []; + try { + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) return []; + return parsed + .filter((c) => c && typeof c.i === 'string' && typeof c.o === 'string') + .map((c) => ({ + i: c.i, + o: c.o, + v: Array.isArray(c.v) ? c.v.map(String) : String(c.v ?? ''), + })); + } catch { + return []; + } +} + +export async function loader({ + request, + context, +}: LoaderFunctionArgs) { + const cli = context.get(ploneClientContext); + + const url = new URL(request.url); + const query = parseQuery(url.searchParams.get('query')); + + const empty: QuerystringSearchResult = { items: [], items_total: 0 }; + + if (query.length === 0) { + return data(empty, { + headers: { 'Content-Type': 'application/json' }, + }); + } + + try { + const { data: results } = await cli.querystringSearch({ + query, + post: true, + }); + + return data( + { + items: results?.items ?? [], + items_total: results?.items_total ?? 0, + } satisfies QuerystringSearchResult, + { + headers: { 'Content-Type': 'application/json' }, + }, + ); + } catch (error) { + // eslint-disable-next-line no-console + console.error('Failed to fetch querystring-search results:', error); + return data(empty, { + headers: { 'Content-Type': 'application/json' }, + }); + } +} diff --git a/packages/components/news/8007.feature b/packages/components/news/8007.feature new file mode 100644 index 00000000000..60a6e3a023a --- /dev/null +++ b/packages/components/news/8007.feature @@ -0,0 +1 @@ +add quanta variant for comboBox @nileshgulia1 \ No newline at end of file diff --git a/packages/components/src/components/ComboBox/ComboBox.quanta.tsx b/packages/components/src/components/ComboBox/ComboBox.quanta.tsx new file mode 100644 index 00000000000..a5733b462fd --- /dev/null +++ b/packages/components/src/components/ComboBox/ComboBox.quanta.tsx @@ -0,0 +1,148 @@ +import React from 'react'; +import { + Button, + ComboBox as RACComboBox, + type ComboBoxProps as RACComboBoxProps, + Group, + Input, + type ListBoxItemProps, + type ListBoxProps, + type ValidationResult, +} from 'react-aria-components'; +import { tv } from 'tailwind-variants'; + +import { Description, FieldError, Label } from '../Field/Field.quanta'; +import { DropdownItem, ListBox } from '../ListBox/ListBox.quanta'; +import { Popover } from '../Popover/Popover.quanta'; +import { composeTailwindRenderProps, focusRing } from '../utils'; +import { ChevrondownIcon } from '../icons'; + +const triggerStyles = tv({ + extend: focusRing, + base: ` + flex min-h-11 min-w-45 items-center gap-2 rounded-lg bg-quanta-snow py-1 pr-2 pl-3 text-sm + text-quanta-space transition + focus-within:bg-quanta-air + hover:bg-quanta-smoke + forced-colors:bg-[Field] + `, + variants: { + isFocusVisible: { + // Mirror the focus ring on the group when the inner input is focused. + true: 'outline-3', + false: 'outline-0', + }, + isDisabled: { + true: ` + cursor-not-allowed bg-quanta-air text-quanta-silver + hover:bg-quanta-air + forced-colors:text-[GrayText] + `, + }, + isInvalid: { + true: ` + bg-quanta-ballet + hover:bg-quanta-flamingo + `, + }, + }, +}); + +export interface ComboBoxProps + extends Omit, 'children'> { + label?: string; + description?: string | null; + errorMessage?: string | ((validation: ValidationResult) => string); + placeholder?: string; + items?: Iterable; + children: React.ReactNode | ((item: T) => React.ReactNode); +} + +/** + * Quanta-styled, searchable single-select. Built on react-aria's `ComboBox`, + * so typing in the input filters the options (default "contains" filter when + * options are passed as a static collection). Visually matches `Select`. + */ +export function ComboBox({ + label, + description, + errorMessage, + placeholder, + items, + children, + ...props +}: ComboBoxProps) { + return ( + + {({ isOpen, isDisabled, isInvalid }) => ( + <> + {label && } + + triggerStyles({ ...renderProps, isDisabled, isInvalid }) + } + > + + + + {description && {description}} + {errorMessage} + + {children} + + + )} + + ); +} + +export function ComboBoxListBox(props: ListBoxProps) { + return ( + ( +
+ No results found +
+ )) + } + className={composeTailwindRenderProps( + props.className, + 'max-h-72 min-w-(--trigger-width) p-1', + )} + /> + ); +} + +export function ComboBoxItem(props: ListBoxItemProps) { + return ; +} diff --git a/packages/components/src/quanta/index.ts b/packages/components/src/quanta/index.ts index e4f855c6b31..7b875250581 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/Dialog/Dialog.quanta'; export * from '../components/DropZone/DropZone.quanta'; export * from '../components/Field/Field.quanta';