Skip to main content

Instalation

npx shadcn add "https://tarcisioandrade.github.io/rich-domain/packages/react-rich-domain/public/r/sorting.json"

Basic Usage

import { useCriteria } from "@/hooks/use-criteria";
import { Sorting } from "@/components/sorting";
import type { SortingField } from "@/components/sorting";

interface User {
  id: string;
  name: string;
  email: string;
  age: number;
  createdAt: Date;
}

// Define sortable fields
const sortingFields: SortingField[] = [
  { field: "name", fieldLabel: "Name" },
  { field: "email", fieldLabel: "Email" },
  { field: "age", fieldLabel: "Age" },
  { field: "createdAt", fieldLabel: "Created At" },
];

function UserSorting() {
  const criteria = useCriteria<User>({
    initialSort: [{ field: "name", direction: "asc" }],
  });

  return <Sorting fields={sortingFields} criteria={criteria} />;
}

SortingField Type

interface SortingField {
  // Field path in the entity (e.g., "name", "profile.bio")
  field: string;

  // Display label for the field
  fieldLabel: string;
}

Features

  • One-click add: Click a field to add it with default “asc” direction
  • Drag-and-drop reordering: Change sort priority by dragging items
  • Toggle direction: Click the direction button to switch between ascending/descending
  • Remove sorts: Individual remove buttons for each sort
  • Clear all: One button to clear all sorting
  • Duplicate prevention: Automatically prevents duplicate fields

Drag-and-Drop Sorting

The Sorting component uses @dnd-kit for smooth drag-and-drop interactions:
import { Sorting } from "@/components/sorting";

function ProductTable() {
  const criteria = useCriteria<Product>({
    initialSort: [
      { field: "price", direction: "desc" },
      { field: "name", direction: "asc" },
    ],
  });

  return (
    <div>
      <Sorting
        fields={[
          { field: "name", fieldLabel: "Name" },
          { field: "price", fieldLabel: "Price" },
          { field: "stock", fieldLabel: "Stock" },
          { field: "createdAt", fieldLabel: "Created" },
        ]}
        criteria={criteria}
      />

      {/* Your table or list */}
    </div>
  );
}

Multi-level Sorting

The order of sorts matters - the first sort is applied first, then the second, and so on:
// Sort by status first, then by name within each status
const criteria = useCriteria<User>({
  initialSort: [
    { field: "status", direction: "asc" },
    { field: "name", direction: "asc" },
  ],
});

// Users will be grouped by status (active, inactive, pending)
// Within each status group, they'll be sorted alphabetically by name

With Data Table

Combine Sorting with data tables for a complete solution:
import { useCriteria } from "@/hooks/use-criteria";
import { Sorting } from "@/components/sorting";
import { Filter } from "@/components/filter/filter";

function UserManagement() {
  const criteria = useCriteria<User>({
    pageSize: 10,
    syncWithUrl: true,
  });

  return (
    <div className="space-y-4">
      {/* Toolbar with Filter and Sorting */}
      <div className="flex gap-2">
        <Filter
          fields={filterFields}
          filters={criteria.filters}
          addOrReplaceByIndex={criteria.addOrReplaceByIndex}
          removeFilter={criteria.removeFilter}
          clearFilters={criteria.clearFilters}
        />

        <Sorting fields={sortingFields} criteria={criteria} />
      </div>

      {/* Your table */}
      <DataTable data={data} sorting={criteria.sorting} />
    </div>
  );
}

Complete Example

A full example combining useCriteria, Filter, and a data table:
import { useQuery } from "@tanstack/react-query";
import { useCriteria } from "@/hooks/use-criteria";
import { Filter } from "@/components/filter/filter";
import type { QueryFilter } from "@/lib/filter-utils";
import { PaginatedResult } from "@woltz/rich-domain";

interface Product {
  id: string;
  name: string;
  category: string;
  price: number;
  stock: number;
  status: "active" | "draft" | "archived";
  createdAt: Date;
}

const productFields: QueryFilter[] = [
  { field: "name", fieldLabel: "Name", type: "string" },
  {
    field: "category",
    fieldLabel: "Category",
    type: "string",
    options: [
      { value: "electronics", label: "Electronics" },
      { value: "clothing", label: "Clothing" },
      { value: "books", label: "Books" },
    ],
  },
  { field: "price", fieldLabel: "Price", type: "number" },
  { field: "stock", fieldLabel: "Stock", type: "number" },
  {
    field: "status",
    fieldLabel: "Status",
    type: "string",
    options: [
      { value: "active", label: "Active" },
      { value: "draft", label: "Draft" },
      { value: "archived", label: "Archived" },
    ],
  },
  { field: "createdAt", fieldLabel: "Created At", type: "date" },
];

async function fetchProducts(criteria: Criteria<Product>) {
  const params = new URLSearchParams();

  // Add filters
  criteria.getFilters().forEach((f) => {
    params.set(`${f.field}:${f.operator}`, String(f.value));
  });

  // Add pagination
  const pagination = criteria.getPagination();
  params.set("page", String(pagination.page));
  params.set("limit", String(pagination.limit));

  // Add sorting
  const orders = criteria.getOrders();
  if (orders.length > 0) {
    params.set(
      "orderBy",
      orders.map((o) => `${o.field}:${o.direction}`).join(",")
    );
  }

  const response = await fetch(`/api/products?${params}`);
  return response.json() as Promise<PaginatedResult<Product>>;
}

export function ProductList() {
  const {
    criteria,
    filters,
    sorting,
    pagination,
    addOrReplaceByIndex,
    removeFilter,
    clearFilters,
    setSort,
    setPage,
  } = useCriteria<Product>({
    pageSize: 10,
    syncWithUrl: true,
  });

  const { data, isLoading, error } = useQuery({
    queryKey: ["products", criteria.toJSON()],
    queryFn: () => fetchProducts(criteria),
  });

  if (error) {
    return <div>Error loading products</div>;
  }

  return (
    <div className="space-y-4">
      {/* Filters */}
      <Filter
        fields={productFields}
        filters={filters}
        addOrReplaceByIndex={addOrReplaceByIndex}
        removeFilter={removeFilter}
        clearFilters={clearFilters}
      />

      {/* Table */}
      {isLoading ? (
        <div>Loading...</div>
      ) : (
        <table className="w-full">
          <thead>
            <tr>
              <th onClick={() => setSort("name", "asc")}>Name</th>
              <th onClick={() => setSort("category", "asc")}>Category</th>
              <th onClick={() => setSort("price", "asc")}>Price</th>
              <th onClick={() => setSort("stock", "asc")}>Stock</th>
              <th onClick={() => setSort("status", "asc")}>Status</th>
            </tr>
          </thead>
          <tbody>
            {data?.data.map((product) => (
              <tr key={product.id}>
                <td>{product.name}</td>
                <td>{product.category}</td>
                <td>${product.price}</td>
                <td>{product.stock}</td>
                <td>{product.status}</td>
              </tr>
            ))}
          </tbody>
        </table>
      )}

      {/* Pagination */}
      {data && (
        <div className="flex items-center justify-between">
          <span>
            Showing {pagination.offset + 1} to{" "}
            {Math.min(pagination.offset + pagination.limit, data.meta.total)} of{" "}
            {data.meta.total} results
          </span>
          <div className="flex gap-2">
            <button
              onClick={() => setPage(pagination.page - 1)}
              disabled={!data.meta.hasPrevious}
            >
              Previous
            </button>
            <span>
              Page {pagination.page} of {data.meta.totalPages}
            </span>
            <button
              onClick={() => setPage(pagination.page + 1)}
              disabled={!data.meta.hasNext}
            >
              Next
            </button>
          </div>
        </div>
      )}
    </div>
  );
}

API Reference

useCriteria Options

OptionTypeDefaultDescription
initialPagenumber1Starting page number
pageSizenumber20Items per page
initialFiltersFilter[][]Pre-applied filters
initialSortOrder[][]Pre-applied sorting
initialSearchobjectundefinedPre-applied search
onChangefunctionundefinedCallback when criteria changes
persistKeystringundefinedlocalStorage key for persistence
syncWithUrlbooleanfalseSync state with URL params

Filter Props

PropTypeRequiredDescription
fieldsQueryFilter[]YesAvailable fields for filtering
filtersFilter[]YesCurrent active filters
addOrReplaceByIndexfunctionYesAdd or update a filter
removeFilterfunctionYesRemove a filter by index
clearFiltersfunctionYesClear all filters