diff --git a/packages/headless-components/cms/package.json b/packages/headless-components/cms/package.json index 14f38f3e9..ae00bc610 100644 --- a/packages/headless-components/cms/package.json +++ b/packages/headless-components/cms/package.json @@ -1,6 +1,6 @@ { "name": "@wix/headless-cms", - "version": "0.0.3", + "version": "0.0.4", "type": "module", "scripts": { "build": "npm run build:esm && npm run build:cjs", @@ -32,6 +32,7 @@ "@testing-library/jest-dom": "^6.6.3", "@types/node": "^20.9.0", "@vitest/ui": "^3.1.4", + "@wix/headless-components": "workspace:*", "jsdom": "^26.1.0", "prettier": "^3.4.2", "typescript": "^5.8.3", diff --git a/packages/headless-components/cms/src/react/CmsCollection.tsx b/packages/headless-components/cms/src/react/CmsCollection.tsx index 340b6776c..ba3b12164 100644 --- a/packages/headless-components/cms/src/react/CmsCollection.tsx +++ b/packages/headless-components/cms/src/react/CmsCollection.tsx @@ -9,6 +9,12 @@ import { import { AsChildChildren, AsChildSlot } from '@wix/headless-utils/react'; import type { DisplayType } from './core/CmsCollection.js'; import * as CmsItem from './CmsItem.js'; +import { + Sort as SortPrimitive, + type SortValue, + type SortOption, +} from '@wix/headless-components/react'; +import { CmsCollectionSort as CmsCollectionSortPrimitive } from './core/CmsCollectionSort.js'; enum TestIds { cmsCollectionRoot = 'cms-collection-root', @@ -19,6 +25,7 @@ enum TestIds { cmsCollectionCreateItem = 'cms-collection-create-item', cmsCollectionItems = 'cms-collection-items', cmsCollectionItem = 'cms-collection-item', + cmsCollectionSort = 'cms-collection-sort', } /** @@ -30,6 +37,7 @@ export interface RootProps { id: string; queryResult?: WixDataQueryResult; queryOptions?: CmsQueryOptions; + initialSort?: SortValue; }; } @@ -70,6 +78,7 @@ export const Root = React.forwardRef( collectionId: collection.id, queryResult: collection?.queryResult, queryOptions: collection?.queryOptions, + initialSort: collection?.initialSort, }; const attributes = { @@ -874,3 +883,127 @@ export const CreateItemAction = React.forwardRef< ); }); + +/** + * Props for CmsCollection.Sort component + */ +export interface SortProps { + /** Predefined sort options for declarative API */ + sortOptions?: Array; + /** Render mode - 'select' uses native select, 'list' provides list (default: 'select') */ + as?: 'select' | 'list'; + /** When true, the component will not render its own element but forward its props to its child */ + asChild?: boolean; + /** Children components or render function */ + children?: React.ReactNode; + /** CSS classes to apply */ + className?: string; +} + +/** + * Sort component that provides sorting controls for the collection items. + * Wraps the Sort primitive with CMS collection state management. + * + * @component + * @example + * ```tsx + * // Native select with predefined options + * + * + * // List with custom options + * + * + * + * + * + * // Custom implementation with asChild + * + * + * + * ``` + */ +export const Sort = React.forwardRef((props, ref) => { + const { + sortOptions, + as = 'select', + asChild, + children, + className, + ...otherProps + } = props; + + return ( + + {({ currentSort, setSort }) => { + const currentSortItem = currentSort?.[0]; + + return ( + + {children} + + ); + }} + + ); +}); + +/** + * SortOption component for individual sort options. + * Direct export of the Sort primitive's Option component - no CMS-specific customization needed + * since CMS collections have dynamic schemas. + * + * @component + * @example + * ```tsx + * // Set both field and order + * + * + * // Any custom field name from your collection + * + * + * // With asChild pattern + * + * + * + * ``` + */ +const SortOptionComponent = SortPrimitive.Option; + +// Set display names +Sort.displayName = 'CmsCollection.Sort'; +SortOptionComponent.displayName = 'CmsCollection.SortOption'; + +// Export as named export +export { SortOptionComponent as SortOption }; diff --git a/packages/headless-components/cms/src/react/core/CmsCollectionSort.tsx b/packages/headless-components/cms/src/react/core/CmsCollectionSort.tsx new file mode 100644 index 000000000..1c8f34552 --- /dev/null +++ b/packages/headless-components/cms/src/react/core/CmsCollectionSort.tsx @@ -0,0 +1,39 @@ +import type { ServiceAPI } from '@wix/services-definitions'; +import { useService } from '@wix/services-manager-react'; +import { CmsCollectionServiceDefinition } from '../../services/cms-collection-service.js'; +import type { SortValue } from '@wix/headless-components/react'; + +/** + * Props for CmsCollectionSort headless component + */ +export interface CmsCollectionSortProps { + /** Render prop function that receives sort controls */ + children: (props: CmsCollectionSortRenderProps) => React.ReactNode; +} + +/** + * Render props for CmsCollectionSort component + */ +export interface CmsCollectionSortRenderProps { + /** Current sort value */ + currentSort: SortValue; + /** Function to update the sort */ + setSort: (sort: SortValue) => void; +} + +/** + * Core headless component for CMS collection sorting + */ +export function CmsCollectionSort(props: CmsCollectionSortProps) { + const service = useService(CmsCollectionServiceDefinition) as ServiceAPI< + typeof CmsCollectionServiceDefinition + >; + + const currentSort = service.sortSignal.get(); + const setSort = service.setSort; + + return props.children({ + currentSort, + setSort, + }); +} diff --git a/packages/headless-components/cms/src/services/cms-collection-service.ts b/packages/headless-components/cms/src/services/cms-collection-service.ts index af934f5e4..621fdfb25 100644 --- a/packages/headless-components/cms/src/services/cms-collection-service.ts +++ b/packages/headless-components/cms/src/services/cms-collection-service.ts @@ -4,6 +4,7 @@ import { type Signal, } from '@wix/services-definitions/core-services/signals'; import { items } from '@wix/data'; +import type { SortValue } from '@wix/headless-components/react'; export type WixDataItem = items.WixDataItem; export type WixDataQueryResult = items.WixDataResult; @@ -25,6 +26,8 @@ export const CmsCollectionServiceDefinition = defineService<{ errorSignal: Signal; /** Reactive signal containing the current query result with pagination data */ queryResultSignal: Signal; + /** Reactive signal containing the current sort value */ + sortSignal: Signal; /** Function to load the next page of items */ loadNextPage: () => Promise; /** Function to load the previous page of items */ @@ -35,6 +38,8 @@ export const CmsCollectionServiceDefinition = defineService<{ loadItems: (options?: CmsQueryOptions) => Promise; /** Function to create a new item in the collection */ createItem: (itemData: Partial) => Promise; + /** Function to update the sort value */ + setSort: (sort: SortValue) => void; /** The collection ID */ collectionId: string; }>('cms-collection'); @@ -58,6 +63,7 @@ export interface CmsQueryOptions { const loadCollectionItems = async ( collectionId: string, options: CmsQueryOptions = {}, + sort?: SortValue, ) => { if (!collectionId) { throw new Error('No collection ID provided'); @@ -67,6 +73,18 @@ const loadCollectionItems = async ( let query = items.query(collectionId); + if (sort && sort.length > 0 && sort[0]) { + const { fieldName, order } = sort[0]; + if (fieldName) { + if (order === 'DESC') { + query = query.descending(fieldName); + } else { + // Default to ascending if order not specified or is 'ASC' + query = query.ascending(fieldName); + } + } + } + if (limit) { query = query.limit(limit); } @@ -87,6 +105,8 @@ export interface CmsCollectionServiceConfig { * If not provided, service will load initial data automatically. */ queryResult?: WixDataQueryResult; queryOptions?: CmsQueryOptions; + /** Optional initial sort value */ + initialSort?: SortValue; } /** @@ -107,6 +127,11 @@ export const CmsCollectionServiceImplementation = config.queryResult || null, ); + // Initialize sort signal + const sortSignal = signalsService.signal( + config.initialSort || [], + ); + // Track current query result for cursor-based pagination let currentQueryResult: WixDataQueryResult | null = queryResultSignal.get(); @@ -126,6 +151,7 @@ export const CmsCollectionServiceImplementation = const result = await loadCollectionItems( config.collectionId, mergedOptions, + sortSignal.get(), ); queryResultSignal.set(result); @@ -236,6 +262,12 @@ export const CmsCollectionServiceImplementation = } }; + const setSort = (sort: SortValue) => { + sortSignal.set(sort); + // Reload items with new sort, preserving pagination + loadItems(); + }; + // Auto-load items on service initialization only if not pre-loaded if (!config.queryResult) { loadItems(); @@ -245,11 +277,13 @@ export const CmsCollectionServiceImplementation = loadingSignal, errorSignal, queryResultSignal, + sortSignal, loadItems, invalidate, loadNextPage, loadPrevPage, createItem, + setSort, collectionId: config.collectionId, }; }, @@ -284,6 +318,7 @@ export type CmsCollectionServiceConfigResult = { export const loadCmsCollectionServiceInitialData = async ( collectionId: string, options: CmsQueryOptions = {}, + sort?: SortValue, ): Promise => { try { if (!collectionId) { @@ -291,13 +326,14 @@ export const loadCmsCollectionServiceInitialData = async ( } // Load collection items on the server using shared function - const result = await loadCollectionItems(collectionId, options); + const result = await loadCollectionItems(collectionId, options, sort); return { [CmsCollectionServiceDefinition]: { collectionId, queryResult: result, queryOptions: options, + initialSort: sort, }, }; } catch (error) { diff --git a/yarn.lock b/yarn.lock index c74585e6b..fa4475b03 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7600,6 +7600,7 @@ __metadata: "@types/node": "npm:^20.9.0" "@vitest/ui": "npm:^3.1.4" "@wix/data": "npm:^1.0.0" + "@wix/headless-components": "workspace:*" "@wix/headless-utils": "workspace:*" "@wix/services-definitions": "npm:^0.1.4" "@wix/services-manager-react": "npm:^0.1.26"