Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion packages/headless-components/cms/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -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",
Expand Down
133 changes: 133 additions & 0 deletions packages/headless-components/cms/src/react/CmsCollection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -19,6 +25,7 @@ enum TestIds {
cmsCollectionCreateItem = 'cms-collection-create-item',
cmsCollectionItems = 'cms-collection-items',
cmsCollectionItem = 'cms-collection-item',
cmsCollectionSort = 'cms-collection-sort',
}

/**
Expand All @@ -30,6 +37,7 @@ export interface RootProps {
id: string;
queryResult?: WixDataQueryResult;
queryOptions?: CmsQueryOptions;
initialSort?: SortValue;
};
}

Expand Down Expand Up @@ -70,6 +78,7 @@ export const Root = React.forwardRef<HTMLDivElement, RootProps>(
collectionId: collection.id,
queryResult: collection?.queryResult,
queryOptions: collection?.queryOptions,
initialSort: collection?.initialSort,
};

const attributes = {
Expand Down Expand Up @@ -874,3 +883,127 @@ export const CreateItemAction = React.forwardRef<
</CoreCmsCollection.CreateItemAction>
);
});

/**
* Props for CmsCollection.Sort component
*/
export interface SortProps {
/** Predefined sort options for declarative API */
sortOptions?: Array<SortOption>;
/** 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
* <CmsCollection.Sort
* as="select"
* sortOptions={[
* { fieldName: 'title', order: 'ASC', label: 'Title (A-Z)' },
* { fieldName: 'created', order: 'DESC', label: 'Newest First' },
* ]}
* className="w-full"
* />
*
* // List with custom options
* <CmsCollection.Sort as="list" className="flex gap-2">
* <CmsCollection.SortOption fieldName="title" order="ASC" label="Title (A-Z)" />
* <CmsCollection.SortOption fieldName="created" order="DESC" label="Newest" />
* </CmsCollection.Sort>
*
* // Custom implementation with asChild
* <CmsCollection.Sort asChild sortOptions={sortOptions}>
* <MyCustomSortComponent />
* </CmsCollection.Sort>
* ```
*/
export const Sort = React.forwardRef<HTMLElement, SortProps>((props, ref) => {
const {
sortOptions,
as = 'select',
asChild,
children,
className,
...otherProps
} = props;

return (
<CmsCollectionSortPrimitive>
{({ currentSort, setSort }) => {
const currentSortItem = currentSort?.[0];

return (
<SortPrimitive.Root
ref={ref}
value={currentSort}
onChange={setSort}
sortOptions={sortOptions}
as={as}
asChild={asChild}
className={className}
data-testid={TestIds.cmsCollectionSort}
data-sorted-by={currentSortItem?.fieldName}
data-sort-direction={currentSortItem?.order}
{...otherProps}
>
{children}
</SortPrimitive.Root>
);
}}
</CmsCollectionSortPrimitive>
);
});

/**
* 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
* <CmsCollection.SortOption
* fieldName="title"
* order="ASC"
* label="Title (A-Z)"
* />
*
* // Any custom field name from your collection
* <CmsCollection.SortOption
* fieldName="customField"
* order="DESC"
* label="Custom Field"
* />
*
* // With asChild pattern
* <CmsCollection.SortOption
* fieldName="created"
* order="DESC"
* label="Newest"
* asChild
* >
* <button className="sort-btn">Newest First</button>
* </CmsCollection.SortOption>
* ```
*/
const SortOptionComponent = SortPrimitive.Option;

// Set display names
Sort.displayName = 'CmsCollection.Sort';
SortOptionComponent.displayName = 'CmsCollection.SortOption';

// Export as named export
export { SortOptionComponent as SortOption };
Original file line number Diff line number Diff line change
@@ -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,
});
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -25,6 +26,8 @@ export const CmsCollectionServiceDefinition = defineService<{
errorSignal: Signal<string | null>;
/** Reactive signal containing the current query result with pagination data */
queryResultSignal: Signal<WixDataQueryResult | null>;
/** Reactive signal containing the current sort value */
sortSignal: Signal<SortValue>;
/** Function to load the next page of items */
loadNextPage: () => Promise<void>;
/** Function to load the previous page of items */
Expand All @@ -35,6 +38,8 @@ export const CmsCollectionServiceDefinition = defineService<{
loadItems: (options?: CmsQueryOptions) => Promise<void>;
/** Function to create a new item in the collection */
createItem: (itemData: Partial<WixDataItem>) => Promise<void>;
/** Function to update the sort value */
setSort: (sort: SortValue) => void;
/** The collection ID */
collectionId: string;
}>('cms-collection');
Expand All @@ -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');
Expand All @@ -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);
}
Expand All @@ -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;
}

/**
Expand All @@ -107,6 +127,11 @@ export const CmsCollectionServiceImplementation =
config.queryResult || null,
);

// Initialize sort signal
const sortSignal = signalsService.signal<SortValue>(
config.initialSort || [],
);

// Track current query result for cursor-based pagination
let currentQueryResult: WixDataQueryResult | null =
queryResultSignal.get();
Expand All @@ -126,6 +151,7 @@ export const CmsCollectionServiceImplementation =
const result = await loadCollectionItems(
config.collectionId,
mergedOptions,
sortSignal.get(),
);

queryResultSignal.set(result);
Expand Down Expand Up @@ -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();
Expand All @@ -245,11 +277,13 @@ export const CmsCollectionServiceImplementation =
loadingSignal,
errorSignal,
queryResultSignal,
sortSignal,
loadItems,
invalidate,
loadNextPage,
loadPrevPage,
createItem,
setSort,
collectionId: config.collectionId,
};
},
Expand Down Expand Up @@ -284,20 +318,22 @@ export type CmsCollectionServiceConfigResult = {
export const loadCmsCollectionServiceInitialData = async (
collectionId: string,
options: CmsQueryOptions = {},
sort?: SortValue,
): Promise<CmsCollectionServiceConfigResult> => {
try {
if (!collectionId) {
throw new Error('No collection ID provided');
}

// 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) {
Expand Down
1 change: 1 addition & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down