feat(tables): implement sortable columns across various table components
- Added TableSortProvider to manage sorting state in AdminsTable, CmsTable, CompaniesTable, CustomersTable, TendersTable, and CompanyCategoriesTable. - Enhanced TableHead component to support sortable columns with visual indicators for sort direction. - Introduced columnSortKeys and columnDefaultOrder mappings in respective table presenters to define sortable fields and default sorting behavior. - Updated useCompanyListPresenter, useCmsTablePresenter, useCustomerListPresenter, useTenderListPresenter, and useCompanyCategoriesPresenter to handle sorting logic and state management. - Improved user experience by allowing dynamic sorting of table data based on user interactions.
This commit is contained in:
@@ -0,0 +1,241 @@
|
||||
# BP Panel — List sorting (frontend)
|
||||
|
||||
Guide for **Business Partner (BP)** list screens under `/api/v1`. Use these query parameters to control item order on paginated GET endpoints.
|
||||
|
||||
This does **not** apply to Kanban board card order (see [board-api.md](./board-api.md) — cards use `order` on move/reorder APIs).
|
||||
|
||||
---
|
||||
|
||||
## Quick reference
|
||||
|
||||
| Parameter | Type | Default | Allowed values |
|
||||
|-----------|------|---------|----------------|
|
||||
| `sort_by` | string | `created_at` | MongoDB field name (see per-endpoint tables) |
|
||||
| `sort_order` | string | `desc` | `asc` or `desc` |
|
||||
|
||||
**Example (newest tenders first — also the default if omitted):**
|
||||
|
||||
```http
|
||||
GET /api/v1/tenders?limit=20&sort_by=created_at&sort_order=desc
|
||||
Authorization: Bearer <customer_access_token>
|
||||
```
|
||||
|
||||
**Example (soonest deadline first):**
|
||||
|
||||
```http
|
||||
GET /api/v1/tenders?limit=20&sort_by=tender_deadline&sort_order=asc
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Authentication
|
||||
|
||||
All BP list endpoints below require a **customer** JWT from:
|
||||
|
||||
```http
|
||||
POST /api/v1/profile/login
|
||||
```
|
||||
|
||||
```http
|
||||
Authorization: Bearer <access_token>
|
||||
```
|
||||
|
||||
Base URL (local): `http://localhost:8081/api/v1`
|
||||
|
||||
---
|
||||
|
||||
## How sorting works
|
||||
|
||||
1. **`sort_by`** must match the **BSON field name** stored in MongoDB (snake_case), not the JSON display name in the UI.
|
||||
2. **`sort_order`**
|
||||
- `desc` — highest / latest values first (default).
|
||||
- `asc` — lowest / earliest values first.
|
||||
3. If you omit both parameters, the API uses **`sort_by=created_at`** and **`sort_order=desc`**.
|
||||
4. Lists use **keyset (cursor) pagination**. When requesting page 2+ with `cursor`, you **must keep the same** `sort_by`, `sort_order`, `limit`, and filters as the request that produced `meta.next_cursor`. Changing sort mid-pagination returns **400** (`cursor does not match requested sort`).
|
||||
5. Do not send **`cursor` and `offset` together** — the API returns **400**.
|
||||
|
||||
### Pagination parameters (used with sorting)
|
||||
|
||||
| Parameter | Default | Max | Notes |
|
||||
|-----------|---------|-----|--------|
|
||||
| `limit` | `10` | `100` | Page size |
|
||||
| `offset` | `0` | — | First page only; prefer `cursor` for deep pages |
|
||||
| `cursor` | — | — | Copy from `meta.next_cursor` on the previous response |
|
||||
|
||||
### Response metadata
|
||||
|
||||
Sorted list responses include `meta` on the envelope:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "Tenders retrieved successfully",
|
||||
"data": { "tenders": [] },
|
||||
"meta": {
|
||||
"total": 240,
|
||||
"limit": 20,
|
||||
"offset": 0,
|
||||
"page": 1,
|
||||
"pages": 12,
|
||||
"has_more": true,
|
||||
"next_cursor": "eyJ2IjoxLCJzb3J0X2ZpZWxkIjoiY3JlYXRlZF9hdCIs..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Endpoints that support `sort_by` / `sort_order`
|
||||
|
||||
### Tenders — main panel list
|
||||
|
||||
| | |
|
||||
|--|--|
|
||||
| **Method / path** | `GET /api/v1/tenders` |
|
||||
| **Also** | `GET /api/v1/tenders/recommend` (same sorting rules) |
|
||||
| **Default sort** | `created_at` `desc` |
|
||||
|
||||
**Recommended `sort_by` values** (field exists on tender documents):
|
||||
|
||||
| `sort_by` | Use case |
|
||||
|-----------|----------|
|
||||
| `created_at` | Newest ingested / default |
|
||||
| `updated_at` | Recently changed tenders |
|
||||
| `publication_date` | Publication date on TED notice |
|
||||
| `tender_deadline` | Submission / tender deadline |
|
||||
| `submission_deadline` | Alternative deadline field when set |
|
||||
| `estimated_value` | Value high → low or low → high |
|
||||
| `title` | Alphabetical (locale-dependent) |
|
||||
| `status` | Group by lifecycle status |
|
||||
| `country_code` | Group by country |
|
||||
|
||||
> The backend does not whitelist tender `sort_by` values. Use only documented fields; unknown fields may sort incorrectly or behave unpredictably.
|
||||
|
||||
**Example with filters + sort:**
|
||||
|
||||
```http
|
||||
GET /api/v1/tenders?limit=20&country_codes=DE,FR&sort_by=publication_date&sort_order=desc&deadline_from=1717200000
|
||||
```
|
||||
|
||||
**TypeScript helper:**
|
||||
|
||||
```typescript
|
||||
type SortOrder = "asc" | "desc";
|
||||
|
||||
interface ListSortParams {
|
||||
sort_by?: string;
|
||||
sort_order?: SortOrder;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
cursor?: string;
|
||||
}
|
||||
|
||||
function buildTenderListQuery(
|
||||
filters: Record<string, string | number | string[] | undefined>,
|
||||
sort: ListSortParams = { sort_by: "created_at", sort_order: "desc", limit: 20 }
|
||||
): string {
|
||||
const params = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(filters)) {
|
||||
if (value === undefined) continue;
|
||||
if (Array.isArray(value)) value.forEach((v) => params.append(key, v));
|
||||
else params.set(key, String(value));
|
||||
}
|
||||
params.set("sort_by", sort.sort_by ?? "created_at");
|
||||
params.set("sort_order", sort.sort_order ?? "desc");
|
||||
if (sort.limit) params.set("limit", String(sort.limit));
|
||||
if (sort.cursor) params.set("cursor", sort.cursor);
|
||||
else if (sort.offset) params.set("offset", String(sort.offset));
|
||||
return params.toString();
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Notifications
|
||||
|
||||
| | |
|
||||
|--|--|
|
||||
| **Method / path** | `GET /api/v1/notifications` |
|
||||
| **Default sort** | `created_at` `desc` |
|
||||
|
||||
**Recommended `sort_by` values:**
|
||||
|
||||
| `sort_by` | Use case |
|
||||
|-----------|----------|
|
||||
| `created_at` | Newest notifications (default) |
|
||||
| `updated_at` | Last updated |
|
||||
| `seen_at` | Seen / unread ordering |
|
||||
| `priority` | Important first (with `sort_order=desc`) |
|
||||
| `schedule_at` | Scheduled notifications |
|
||||
|
||||
---
|
||||
|
||||
### Feedback (company list)
|
||||
|
||||
| | |
|
||||
|--|--|
|
||||
| **Method / path** | `GET /api/v1/feedback` |
|
||||
| **Default sort** | `created_at` `desc` |
|
||||
| **Scope** | Company-scoped from the logged-in customer |
|
||||
|
||||
**Recommended `sort_by` values:**
|
||||
|
||||
| `sort_by` | Use case |
|
||||
|-----------|----------|
|
||||
| `created_at` | Newest feedback (default) |
|
||||
| `updated_at` | Recently changed |
|
||||
| `feedback_type` | Group likes / dislikes |
|
||||
|
||||
---
|
||||
|
||||
## Endpoints without `sort_by` / `sort_order` (BP)
|
||||
|
||||
| Endpoint | Notes |
|
||||
|----------|--------|
|
||||
| `GET /api/v1/tender-approvals` | Uses `limit` / `offset` only; **no** `sort_by` on the public handler today |
|
||||
| `GET /api/v1/companies` | Single “my company” resource, not a sortable list |
|
||||
| Kanban (`/admin/v1/kanban/...`) | Admin-only; card order is `order` + move/reorder APIs |
|
||||
|
||||
If you need sorted tender approvals in BP, coordinate with backend to add `sort_by` / `sort_order` to `PublicGetTenderApprovals` (admin list already supports them).
|
||||
|
||||
---
|
||||
|
||||
## UI patterns
|
||||
|
||||
### Column header → query params
|
||||
|
||||
| UI label | `sort_by` | Typical `sort_order` |
|
||||
|----------|-----------|----------------------|
|
||||
| Date added | `created_at` | `desc` |
|
||||
| Deadline | `tender_deadline` | `asc` |
|
||||
| Value | `estimated_value` | `desc` |
|
||||
| Published | `publication_date` | `desc` |
|
||||
|
||||
Toggle the same column: flip `asc` ↔ `desc`. Changing column: set new `sort_by` and reset to page 1 (`offset=0`, drop `cursor`).
|
||||
|
||||
### Infinite scroll / “Load more”
|
||||
|
||||
1. First request: `sort_by`, `sort_order`, `limit`, filters — no `cursor`.
|
||||
2. Next page: repeat the **same** sort and filters; pass `cursor=meta.next_cursor`.
|
||||
3. Stop when `meta.has_more === false`.
|
||||
|
||||
---
|
||||
|
||||
## Errors
|
||||
|
||||
| Situation | HTTP | `error.code` (typical) |
|
||||
|-----------|------|-------------------------|
|
||||
| Invalid `sort_order` (not `asc`/`desc`) | 400 | `BAD_REQUEST` |
|
||||
| `cursor` + `offset` together | 400 | `BAD_REQUEST` |
|
||||
| `cursor` with different `sort_by` / `sort_order` | 400 | `BAD_REQUEST` |
|
||||
| `limit` < 1 or > 100 | 400 | `BAD_REQUEST` |
|
||||
|
||||
---
|
||||
|
||||
## Admin panel (reference)
|
||||
|
||||
Admin list routes under `/admin/v1/*` use the same `sort_by` / `sort_order` conventions where `response.NewPagination` is used. Some admin forms restrict values (e.g. tender approvals: `created_at`, `updated_at`, `status`, `submission_mode`). BP tender lists are **less restricted** than admin tender-approval lists.
|
||||
|
||||
---
|
||||
|
||||
**Source:** `pkg/response/pagination.go`, `pkg/mongo/list_pagination.go`, `internal/tender`, `internal/notification`, `internal/feedback` · **Last updated:** 2026-05-31
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
TableSortProvider,
|
||||
} from "@/components/ui/table";
|
||||
import TableSkeleton from "@/components/ui/TableSkeleton";
|
||||
import { AdminStatus } from "@/constants/enums";
|
||||
@@ -41,6 +42,11 @@ const AdminsTable = () => {
|
||||
isChangeStatusModalOpen,
|
||||
setIsChangeStatusModalOpen,
|
||||
columns,
|
||||
columnSortKeys,
|
||||
columnDefaultOrder,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
handleSortChange,
|
||||
changeStatus,
|
||||
currentUser,
|
||||
setStatus,
|
||||
@@ -86,18 +92,29 @@ const AdminsTable = () => {
|
||||
onClearAll={clearAllFilters}
|
||||
/>
|
||||
<div ref={tableSectionRef}>
|
||||
<TableSortProvider
|
||||
sortKey={sortBy}
|
||||
sortOrder={sortOrder}
|
||||
onSortChange={handleSortChange}
|
||||
>
|
||||
<Table data-cy="admins-table">
|
||||
<TableHeader>
|
||||
<TableRow className="border-none uppercase [&>th]:text-start">
|
||||
{columns.map((column, index) => (
|
||||
{columns.map((column, index) => {
|
||||
const sortKey = columnSortKeys[column];
|
||||
return (
|
||||
<TableHead
|
||||
key={column}
|
||||
colSpan={100}
|
||||
sortable={Boolean(sortKey)}
|
||||
sortKey={sortKey}
|
||||
defaultSortOrder={columnDefaultOrder[column]}
|
||||
data-cy={`admins-table-header-${index}`}
|
||||
>
|
||||
{column}
|
||||
</TableHead>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
|
||||
@@ -202,6 +219,7 @@ const AdminsTable = () => {
|
||||
</TableBody>
|
||||
)}
|
||||
</Table>
|
||||
</TableSortProvider>
|
||||
</div>
|
||||
<Modal
|
||||
isOpen={isChangeStatusModalOpen}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { SortDirection } from "@/components/ui/tableSortContext";
|
||||
import { apiDefaultParams } from "@/constants/Api_Params";
|
||||
import { AdminStatus } from "@/constants/enums";
|
||||
import {
|
||||
@@ -10,7 +11,11 @@ import {
|
||||
} from "@/hooks/queries";
|
||||
import { IUser, TChangeAdminStatusCredentials } from "@/lib/api";
|
||||
import { useHybridPagination } from "@/hooks/useHybridPagination";
|
||||
import { deleteEmptyKeys, getPaginatedRowNumber } from "@/utils/shared";
|
||||
import {
|
||||
buildQueryString,
|
||||
deleteEmptyKeys,
|
||||
getPaginatedRowNumber,
|
||||
} from "@/utils/shared";
|
||||
import gsap from "gsap";
|
||||
import { useIsMutating } from "@tanstack/react-query";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
@@ -225,12 +230,49 @@ export const useAdminsPresenter = () => {
|
||||
"actions",
|
||||
];
|
||||
|
||||
/** Column label → BSON `sort_by` field. */
|
||||
const columnSortKeys: Record<string, string> = {
|
||||
"full name": "full_name",
|
||||
email: "email",
|
||||
username: "username",
|
||||
status: "status",
|
||||
};
|
||||
|
||||
/** First-click direction per column. */
|
||||
const columnDefaultOrder: Record<string, SortDirection> = {
|
||||
"full name": "asc",
|
||||
email: "asc",
|
||||
username: "asc",
|
||||
};
|
||||
|
||||
const sortBy = (params.sort_by as string) ?? "created_at";
|
||||
const sortOrder = (params.sort_order as SortDirection) ?? "desc";
|
||||
|
||||
const handleSortChange = useCallback(
|
||||
(key: string, order: SortDirection) => {
|
||||
const newParams = buildFirstPageParams({
|
||||
...params,
|
||||
sort_by: key,
|
||||
sort_order: order,
|
||||
});
|
||||
const cleanedParams = deleteEmptyKeys(newParams);
|
||||
const queryString = buildQueryString(cleanedParams);
|
||||
router.push(`${pathName}?${queryString}`);
|
||||
},
|
||||
[buildFirstPageParams, params, pathName, router],
|
||||
);
|
||||
|
||||
return {
|
||||
allUsers: usersList,
|
||||
usersList,
|
||||
setFilterValue,
|
||||
metadata,
|
||||
columns,
|
||||
columnSortKeys,
|
||||
columnDefaultOrder,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
handleSortChange,
|
||||
isPending,
|
||||
onConfirmDelete,
|
||||
isModalOpen,
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
TableSortProvider,
|
||||
} from "@/components/ui/table";
|
||||
import TableSkeleton from "@/components/ui/TableSkeleton";
|
||||
import { _TooltipDefaultParams } from "@/constants/tooltip";
|
||||
@@ -22,6 +23,11 @@ import useCmsTablePresenter from "./useCmsTablePresenter";
|
||||
const CmsTable = () => {
|
||||
const {
|
||||
columns,
|
||||
columnSortKeys,
|
||||
columnDefaultOrder,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
handleSortChange,
|
||||
data,
|
||||
isPending,
|
||||
isModalOpen,
|
||||
@@ -55,14 +61,28 @@ const CmsTable = () => {
|
||||
onClearAll={clearAllFilters}
|
||||
/>
|
||||
<div ref={tableSectionRef}>
|
||||
<TableSortProvider
|
||||
sortKey={sortBy}
|
||||
sortOrder={sortOrder}
|
||||
onSortChange={handleSortChange}
|
||||
>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="border-none uppercase [&>th]:text-start">
|
||||
{columns.map((column) => (
|
||||
<TableHead key={column} colSpan={100}>
|
||||
{columns.map((column) => {
|
||||
const sortKey = columnSortKeys[column];
|
||||
return (
|
||||
<TableHead
|
||||
key={column}
|
||||
colSpan={100}
|
||||
sortable={Boolean(sortKey)}
|
||||
sortKey={sortKey}
|
||||
defaultSortOrder={columnDefaultOrder[column]}
|
||||
>
|
||||
{column}
|
||||
</TableHead>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
{isPending ? (
|
||||
@@ -130,6 +150,7 @@ const CmsTable = () => {
|
||||
</TableBody>
|
||||
)}
|
||||
</Table>
|
||||
</TableSortProvider>
|
||||
</div>
|
||||
{isModalOpen && (
|
||||
<ConfirmationModal
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
"use client";
|
||||
import { SortDirection } from "@/components/ui/tableSortContext";
|
||||
import { useTableSectionRowAnimations } from "@/hooks/useTableSectionRowAnimations";
|
||||
import { useDeleteCms, useGetCmsList } from "@/hooks/queries/useCmsQueries";
|
||||
import { TCms } from "@/lib/api/types/TCms";
|
||||
import { deleteEmptyKeys } from "@/utils/shared";
|
||||
import { buildQueryString, deleteEmptyKeys } from "@/utils/shared";
|
||||
import { useIsMutating } from "@tanstack/react-query";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
@@ -67,8 +68,45 @@ const useCmsTablePresenter = () => {
|
||||
if (!currentCms?.id) return;
|
||||
deleteCms(currentCms.id);
|
||||
};
|
||||
|
||||
/** Column label → BSON `sort_by` field. */
|
||||
const columnSortKeys: Record<string, string> = {
|
||||
key: "key",
|
||||
"created at": "created_at",
|
||||
};
|
||||
|
||||
/** First-click direction per column. */
|
||||
const columnDefaultOrder: Record<string, SortDirection> = {
|
||||
key: "asc",
|
||||
"created at": "desc",
|
||||
};
|
||||
|
||||
const sortBy = (params.sort_by as string) ?? "created_at";
|
||||
const sortOrder = (params.sort_order as SortDirection) ?? "desc";
|
||||
|
||||
const handleSortChange = useCallback(
|
||||
(key: string, order: SortDirection) => {
|
||||
const next: Record<string, any> = {
|
||||
...params,
|
||||
sort_by: key,
|
||||
sort_order: order,
|
||||
offset: 0,
|
||||
};
|
||||
delete next.cursor;
|
||||
const cleanedParams = deleteEmptyKeys(next);
|
||||
const queryString = buildQueryString(cleanedParams);
|
||||
router.push(`${pathname}?${queryString}`);
|
||||
},
|
||||
[params, pathname, router],
|
||||
);
|
||||
|
||||
return {
|
||||
columns,
|
||||
columnSortKeys,
|
||||
columnDefaultOrder,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
handleSortChange,
|
||||
isPending,
|
||||
data,
|
||||
isModalOpen,
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
TableSortProvider,
|
||||
} from "@/components/ui/table";
|
||||
import TableSkeleton from "@/components/ui/TableSkeleton";
|
||||
import { _TooltipDefaultParams } from "@/constants/tooltip";
|
||||
@@ -42,6 +43,11 @@ const CompanyCategoriesTable = () => {
|
||||
watch,
|
||||
setFilterValue,
|
||||
columns,
|
||||
columnSortKeys,
|
||||
columnDefaultOrder,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
handleSortChange,
|
||||
isMutating,
|
||||
metadata,
|
||||
shouldShowPagination,
|
||||
@@ -70,15 +76,28 @@ const CompanyCategoriesTable = () => {
|
||||
onClearAll={clearAllFilters}
|
||||
/>
|
||||
<div ref={tableSectionRef}>
|
||||
<TableSortProvider
|
||||
sortKey={sortBy}
|
||||
sortOrder={sortOrder}
|
||||
onSortChange={handleSortChange}
|
||||
>
|
||||
<Table data-cy="company-categories-table">
|
||||
<TableHeader>
|
||||
<TableRow className="border-none uppercase [&>th]:text-start">
|
||||
<TableHead colSpan={100}>Row</TableHead>
|
||||
<TableHead colSpan={100}>Title</TableHead>
|
||||
<TableHead colSpan={100}>Description</TableHead>
|
||||
<TableHead colSpan={100}>Created at</TableHead>
|
||||
<TableHead colSpan={100}>Published</TableHead>
|
||||
<TableHead colSpan={100}>Actions</TableHead>
|
||||
{columns.map((column) => {
|
||||
const sortKey = columnSortKeys[column];
|
||||
return (
|
||||
<TableHead
|
||||
key={column}
|
||||
colSpan={100}
|
||||
sortable={Boolean(sortKey)}
|
||||
sortKey={sortKey}
|
||||
defaultSortOrder={columnDefaultOrder[column]}
|
||||
>
|
||||
{column}
|
||||
</TableHead>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
{isLoading ? (
|
||||
@@ -172,6 +191,7 @@ const CompanyCategoriesTable = () => {
|
||||
</TableBody>
|
||||
)}
|
||||
</Table>
|
||||
</TableSortProvider>
|
||||
</div>
|
||||
{isModalOpen && (
|
||||
<ConfirmationModal
|
||||
|
||||
+41
-1
@@ -1,4 +1,5 @@
|
||||
"use client";
|
||||
import { SortDirection } from "@/components/ui/tableSortContext";
|
||||
import { apiDefaultParams } from "@/constants/Api_Params";
|
||||
import {
|
||||
useCompanyCategoriesQuery,
|
||||
@@ -8,7 +9,7 @@ import {
|
||||
import { useHybridPagination } from "@/hooks/useHybridPagination";
|
||||
import { useTableSectionRowAnimations } from "@/hooks/useTableSectionRowAnimations";
|
||||
import { TCompanyCategory } from "@/lib/api";
|
||||
import { deleteEmptyKeys } from "@/utils/shared";
|
||||
import { buildQueryString, deleteEmptyKeys } from "@/utils/shared";
|
||||
import { useIsMutating } from "@tanstack/react-query";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useCallback, useEffect, useMemo, useOptimistic, useRef, useState } from "react";
|
||||
@@ -108,6 +109,40 @@ const useCompanyCategoriesPresenter = () => {
|
||||
"Published",
|
||||
"Actions",
|
||||
];
|
||||
|
||||
/** Column label → BSON `sort_by` field. */
|
||||
const columnSortKeys: Record<string, string> = {
|
||||
Title: "name",
|
||||
Description: "description",
|
||||
"Created at": "created_at",
|
||||
Published: "published",
|
||||
};
|
||||
|
||||
/** First-click direction per column. */
|
||||
const columnDefaultOrder: Record<string, SortDirection> = {
|
||||
Title: "asc",
|
||||
Description: "asc",
|
||||
"Created at": "desc",
|
||||
Published: "desc",
|
||||
};
|
||||
|
||||
const sortBy = (params.sort_by as string) ?? "created_at";
|
||||
const sortOrder = (params.sort_order as SortDirection) ?? "desc";
|
||||
|
||||
const handleSortChange = useCallback(
|
||||
(key: string, order: SortDirection) => {
|
||||
const newParams = buildFirstPageParams({
|
||||
...params,
|
||||
sort_by: key,
|
||||
sort_order: order,
|
||||
});
|
||||
const cleanedParams = deleteEmptyKeys(newParams);
|
||||
const queryString = buildQueryString(cleanedParams);
|
||||
router.push(`${pathName}?${queryString}`);
|
||||
},
|
||||
[buildFirstPageParams, params, pathName, router],
|
||||
);
|
||||
|
||||
return {
|
||||
categories,
|
||||
optimisticCategories,
|
||||
@@ -119,6 +154,11 @@ const useCompanyCategoriesPresenter = () => {
|
||||
pathName,
|
||||
router,
|
||||
columns,
|
||||
columnSortKeys,
|
||||
columnDefaultOrder,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
handleSortChange,
|
||||
params,
|
||||
setParams,
|
||||
currentCategory,
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
TableSortProvider,
|
||||
} from "@/components/ui/table";
|
||||
import TableSkeleton from "@/components/ui/TableSkeleton";
|
||||
import { _TooltipDefaultParams } from "@/constants/tooltip";
|
||||
@@ -55,6 +56,11 @@ const CompaniesTable = () => {
|
||||
isPending,
|
||||
pathName,
|
||||
columns,
|
||||
columnSortKeys,
|
||||
columnDefaultOrder,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
handleSortChange,
|
||||
allCompanies,
|
||||
setIsModalOpen,
|
||||
router,
|
||||
@@ -93,18 +99,29 @@ const CompaniesTable = () => {
|
||||
onClearAll={clearAllFilters}
|
||||
/>
|
||||
<div ref={tableSectionRef}>
|
||||
<TableSortProvider
|
||||
sortKey={sortBy}
|
||||
sortOrder={sortOrder}
|
||||
onSortChange={handleSortChange}
|
||||
>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
{columns.map((col) => (
|
||||
{columns.map((col) => {
|
||||
const sortKey = columnSortKeys[col];
|
||||
return (
|
||||
<TableHead
|
||||
colSpan={100}
|
||||
className="text-start uppercase"
|
||||
key={col}
|
||||
sortable={Boolean(sortKey)}
|
||||
sortKey={sortKey}
|
||||
defaultSortOrder={columnDefaultOrder[col]}
|
||||
>
|
||||
{col}
|
||||
</TableHead>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
{isPending ? (
|
||||
@@ -267,6 +284,7 @@ const CompaniesTable = () => {
|
||||
</TableBody>
|
||||
)}
|
||||
</Table>
|
||||
</TableSortProvider>
|
||||
</div>
|
||||
{isModalOpen && (
|
||||
<ConfirmationModal
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
import { ConfirmationModalProps } from "@/components/ui/ConfirmationModal";
|
||||
import { SortDirection } from "@/components/ui/tableSortContext";
|
||||
import { apiDefaultParams } from "@/constants/Api_Params";
|
||||
import {
|
||||
useCompaniesQuery,
|
||||
@@ -8,7 +9,10 @@ import {
|
||||
import { useHybridPagination } from "@/hooks/useHybridPagination";
|
||||
import { useTableSectionRowAnimations } from "@/hooks/useTableSectionRowAnimations";
|
||||
import { TCompany } from "@/lib/api";
|
||||
import { deleteEmptyKeys } from "@/utils/shared";
|
||||
import {
|
||||
buildQueryString,
|
||||
deleteEmptyKeys,
|
||||
} from "@/utils/shared";
|
||||
import { useIsMutating } from "@tanstack/react-query";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
@@ -104,6 +108,48 @@ export const useCompanyListPresenter = () => {
|
||||
deleteCompany(currentCompany.id);
|
||||
}
|
||||
};
|
||||
|
||||
/** Column label → BSON `sort_by` field. */
|
||||
const columnSortKeys: Record<string, string> = {
|
||||
name: "name",
|
||||
email: "email",
|
||||
phone: "phone",
|
||||
country: "address.country",
|
||||
state: "address.state",
|
||||
language: "language",
|
||||
currency: "currency",
|
||||
"employee count": "employee_count",
|
||||
};
|
||||
|
||||
/** First-click direction per column. */
|
||||
const columnDefaultOrder: Record<string, SortDirection> = {
|
||||
name: "asc",
|
||||
email: "asc",
|
||||
phone: "asc",
|
||||
country: "asc",
|
||||
state: "asc",
|
||||
language: "asc",
|
||||
currency: "asc",
|
||||
"employee count": "desc",
|
||||
};
|
||||
|
||||
const sortBy = (params.sort_by as string) ?? "created_at";
|
||||
const sortOrder = (params.sort_order as SortDirection) ?? "desc";
|
||||
|
||||
const handleSortChange = useCallback(
|
||||
(key: string, order: SortDirection) => {
|
||||
const newParams = buildFirstPageParams({
|
||||
...params,
|
||||
sort_by: key,
|
||||
sort_order: order,
|
||||
});
|
||||
const cleanedParams = deleteEmptyKeys(newParams);
|
||||
const queryString = buildQueryString(cleanedParams);
|
||||
router.push(`${pathName}?${queryString}`);
|
||||
},
|
||||
[buildFirstPageParams, params, pathName, router],
|
||||
);
|
||||
|
||||
const allCompanies = data?.data.companies ?? [];
|
||||
const metadata = data?.meta;
|
||||
return {
|
||||
@@ -111,6 +157,11 @@ export const useCompanyListPresenter = () => {
|
||||
onConfirmDelete,
|
||||
metadata,
|
||||
columns,
|
||||
columnSortKeys,
|
||||
columnDefaultOrder,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
handleSortChange,
|
||||
pathName,
|
||||
currentCompany,
|
||||
modalConfig,
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
TableSortProvider,
|
||||
} from "@/components/ui/table";
|
||||
import TableSkeleton from "@/components/ui/TableSkeleton";
|
||||
import { _TooltipDefaultParams } from "@/constants/tooltip";
|
||||
@@ -38,6 +39,11 @@ const CustomersTable = () => {
|
||||
pathName,
|
||||
router,
|
||||
columns,
|
||||
columnSortKeys,
|
||||
columnDefaultOrder,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
handleSortChange,
|
||||
allCustomers,
|
||||
metadata,
|
||||
isModalOpen,
|
||||
@@ -84,18 +90,29 @@ const CustomersTable = () => {
|
||||
onClearAll={clearAllFilters}
|
||||
/>
|
||||
<div ref={tableSectionRef}>
|
||||
<TableSortProvider
|
||||
sortKey={sortBy}
|
||||
sortOrder={sortOrder}
|
||||
onSortChange={handleSortChange}
|
||||
>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
{columns.map((col) => (
|
||||
{columns.map((col) => {
|
||||
const sortKey = columnSortKeys[col];
|
||||
return (
|
||||
<TableHead
|
||||
colSpan={100}
|
||||
className="text-start font-extrabold uppercase"
|
||||
key={col}
|
||||
sortable={Boolean(sortKey)}
|
||||
sortKey={sortKey}
|
||||
defaultSortOrder={columnDefaultOrder[col]}
|
||||
>
|
||||
{col}
|
||||
</TableHead>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
{isPending ? (
|
||||
@@ -266,6 +283,7 @@ const CustomersTable = () => {
|
||||
</TableBody>
|
||||
)}
|
||||
</Table>
|
||||
</TableSortProvider>
|
||||
</div>
|
||||
<IsVisible condition={shouldShowPagination}>
|
||||
<Pagination
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { SortDirection } from "@/components/ui/tableSortContext";
|
||||
import {
|
||||
useAssignCompany,
|
||||
useCompanyFullList,
|
||||
@@ -11,7 +12,7 @@ import { useHybridPagination } from "@/hooks/useHybridPagination";
|
||||
import { useTableSectionRowAnimations } from "@/hooks/useTableSectionRowAnimations";
|
||||
import { TAssignCustomerToCompanyCredentials, TCustomer } from "@/lib/api";
|
||||
import { apiDefaultParams } from "@/constants/Api_Params";
|
||||
import { deleteEmptyKeys } from "@/utils/shared";
|
||||
import { buildQueryString, deleteEmptyKeys } from "@/utils/shared";
|
||||
import { useIsMutating } from "@tanstack/react-query";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
@@ -144,6 +145,42 @@ const useCustomerListPresenter = () => {
|
||||
}
|
||||
};
|
||||
|
||||
/** Column label → BSON `sort_by` field. */
|
||||
const columnSortKeys: Record<string, string> = {
|
||||
"full name": "full_name",
|
||||
email: "email",
|
||||
"created at": "created_at",
|
||||
type: "type",
|
||||
status: "status",
|
||||
role: "role",
|
||||
};
|
||||
|
||||
/** First-click direction per column. */
|
||||
const columnDefaultOrder: Record<string, SortDirection> = {
|
||||
"full name": "asc",
|
||||
email: "asc",
|
||||
type: "asc",
|
||||
role: "asc",
|
||||
"created at": "desc",
|
||||
};
|
||||
|
||||
const sortBy = (params.sort_by as string) ?? "created_at";
|
||||
const sortOrder = (params.sort_order as SortDirection) ?? "desc";
|
||||
|
||||
const handleSortChange = useCallback(
|
||||
(key: string, order: SortDirection) => {
|
||||
const newParams = buildFirstPageParams({
|
||||
...params,
|
||||
sort_by: key,
|
||||
sort_order: order,
|
||||
});
|
||||
const cleanedParams = deleteEmptyKeys(newParams);
|
||||
const queryString = buildQueryString(cleanedParams);
|
||||
router.push(`${pathName}?${queryString}`);
|
||||
},
|
||||
[buildFirstPageParams, params, pathName, router],
|
||||
);
|
||||
|
||||
const customers = data?.data?.customers ?? [];
|
||||
const shouldShowPagination =
|
||||
!isPending && (data?.meta?.pages ?? 1) * (data?.meta?.limit ?? 10) > 10;
|
||||
@@ -153,6 +190,11 @@ const useCustomerListPresenter = () => {
|
||||
metadata: data?.meta,
|
||||
currentCustomer,
|
||||
columns,
|
||||
columnSortKeys,
|
||||
columnDefaultOrder,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
handleSortChange,
|
||||
isPending,
|
||||
setCurrentCustomer,
|
||||
selectedCompanies,
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
TableSortProvider,
|
||||
} from "@/components/ui/table";
|
||||
import IsVisible from "@/components/ui/IsVisible";
|
||||
import ListHeader from "@/components/ui/ListHeader";
|
||||
@@ -25,6 +26,11 @@ import useTenderListPresenter from "./useTenderListPresenter";
|
||||
const TendersTable = () => {
|
||||
const {
|
||||
columns,
|
||||
columnSortKeys,
|
||||
columnDefaultOrder,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
handleSortChange,
|
||||
filterRegister,
|
||||
handleFilterSubmit,
|
||||
search,
|
||||
@@ -67,14 +73,28 @@ const TendersTable = () => {
|
||||
onClearAll={clearAllFilters}
|
||||
/>
|
||||
<div ref={tableSectionRef}>
|
||||
<TableSortProvider
|
||||
sortKey={sortBy}
|
||||
sortOrder={sortOrder}
|
||||
onSortChange={handleSortChange}
|
||||
>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="border-none uppercase [&>th]:text-start">
|
||||
{columns.map((column) => (
|
||||
<TableHead key={column} colSpan={100}>
|
||||
{columns.map((column) => {
|
||||
const sortKey = columnSortKeys[column];
|
||||
return (
|
||||
<TableHead
|
||||
key={column}
|
||||
colSpan={100}
|
||||
sortable={Boolean(sortKey)}
|
||||
sortKey={sortKey}
|
||||
defaultSortOrder={columnDefaultOrder[column]}
|
||||
>
|
||||
{column}
|
||||
</TableHead>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
|
||||
@@ -153,6 +173,7 @@ const TendersTable = () => {
|
||||
</TableBody>
|
||||
)}
|
||||
</Table>
|
||||
</TableSortProvider>
|
||||
</div>
|
||||
<IsVisible condition={shouldShowPagination}>
|
||||
<Pagination
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
import { ConfirmationModalProps } from "@/components/ui/ConfirmationModal";
|
||||
import { SortDirection } from "@/components/ui/tableSortContext";
|
||||
import { apiDefaultParams } from "@/constants/Api_Params";
|
||||
import { useTendersQuery } from "@/hooks/queries";
|
||||
import { useHybridPagination } from "@/hooks/useHybridPagination";
|
||||
@@ -506,9 +507,50 @@ const useTenderListPresenter = () => {
|
||||
"status",
|
||||
"actions",
|
||||
];
|
||||
|
||||
/** Column label → BSON `sort_by` field (see bp-panel-list-sorting.md). */
|
||||
const columnSortKeys: Record<string, string> = {
|
||||
title: "title",
|
||||
"country code": "country_code",
|
||||
"publication date": "publication_date",
|
||||
"submission deadline": "submission_deadline",
|
||||
"tender deadline": "tender_deadline",
|
||||
"created at": "created_at",
|
||||
status: "status",
|
||||
};
|
||||
|
||||
/** First-click direction per column, following the BP sorting UX guide. */
|
||||
const columnDefaultOrder: Record<string, SortDirection> = {
|
||||
title: "asc",
|
||||
"submission deadline": "asc",
|
||||
"tender deadline": "asc",
|
||||
};
|
||||
|
||||
const sortBy = (params.sort_by as string) ?? "created_at";
|
||||
const sortOrder = (params.sort_order as SortDirection) ?? "desc";
|
||||
|
||||
const handleSortChange = useCallback(
|
||||
(key: string, order: SortDirection) => {
|
||||
const newParams = buildFirstPageParams({
|
||||
...params,
|
||||
sort_by: key,
|
||||
sort_order: order,
|
||||
});
|
||||
const cleanedParams = deleteEmptyKeys(newParams);
|
||||
const queryString = buildQueryString(cleanedParams);
|
||||
router.push(`${pathName}?${queryString}`);
|
||||
},
|
||||
[buildFirstPageParams, params, pathName, router],
|
||||
);
|
||||
|
||||
return {
|
||||
currentTender,
|
||||
columns,
|
||||
columnSortKeys,
|
||||
columnDefaultOrder,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
handleSortChange,
|
||||
setCurrentTender,
|
||||
router,
|
||||
allTenders: data,
|
||||
|
||||
+154
-6
@@ -1,5 +1,13 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import * as React from "react";
|
||||
import {
|
||||
SortDirection,
|
||||
TableSortContext,
|
||||
TableSortContextValue,
|
||||
} from "./tableSortContext";
|
||||
import { useSortableHeadPresenter } from "./useSortableHeadPresenter";
|
||||
|
||||
export type { SortDirection };
|
||||
|
||||
export function Table({
|
||||
className,
|
||||
@@ -15,6 +23,31 @@ export function Table({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides the active `sort_by` / `sort_order` state to any `TableHead`
|
||||
* rendered with `sortable`. Wrap a `<Table>` with this to enable server-driven
|
||||
* column sorting (see `bp-panel-list-sorting.md`).
|
||||
*/
|
||||
export function TableSortProvider({
|
||||
children,
|
||||
...value
|
||||
}: TableSortContextValue & { children: React.ReactNode }) {
|
||||
const memoizedValue = React.useMemo<TableSortContextValue>(
|
||||
() => ({
|
||||
sortKey: value.sortKey,
|
||||
sortOrder: value.sortOrder,
|
||||
onSortChange: value.onSortChange,
|
||||
}),
|
||||
[value.sortKey, value.sortOrder, value.onSortChange],
|
||||
);
|
||||
|
||||
return (
|
||||
<TableSortContext.Provider value={memoizedValue}>
|
||||
{children}
|
||||
</TableSortContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function TableHeader({
|
||||
className,
|
||||
...props
|
||||
@@ -74,19 +107,134 @@ export const TableRow = React.forwardRef<
|
||||
});
|
||||
TableRow.displayName = "TableRow";
|
||||
|
||||
const tableHeadBaseClasses =
|
||||
"h-13 px-4 text-left align-middle text-[11px] font-semibold uppercase tracking-[0.08em] text-dark-5 dark:text-dark-6 [&:has([role=checkbox])]:pr-0";
|
||||
|
||||
interface TableHeadProps
|
||||
extends React.ThHTMLAttributes<HTMLTableCellElement> {
|
||||
/** When set, renders an interactive, animated sort control. */
|
||||
sortable?: boolean;
|
||||
/** BSON field name sent as `sort_by` (e.g. `created_at`). */
|
||||
sortKey?: string;
|
||||
/** Direction requested on the first click of an inactive column. */
|
||||
defaultSortOrder?: SortDirection;
|
||||
}
|
||||
|
||||
export function TableHead({
|
||||
className,
|
||||
sortable,
|
||||
sortKey,
|
||||
defaultSortOrder,
|
||||
...props
|
||||
}: React.ThHTMLAttributes<HTMLTableCellElement>) {
|
||||
}: TableHeadProps) {
|
||||
if (sortable && sortKey) {
|
||||
return (
|
||||
<th
|
||||
className={cn(
|
||||
"h-13 px-4 text-left align-middle text-[11px] font-semibold uppercase tracking-[0.08em] text-dark-5 dark:text-dark-6 [&:has([role=checkbox])]:pr-0",
|
||||
className,
|
||||
)}
|
||||
<SortableTableHead
|
||||
className={className}
|
||||
sortKey={sortKey}
|
||||
defaultSortOrder={defaultSortOrder}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <th className={cn(tableHeadBaseClasses, className)} {...props} />;
|
||||
}
|
||||
|
||||
function Chevron({
|
||||
direction,
|
||||
className,
|
||||
}: {
|
||||
direction: "up" | "down";
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<svg
|
||||
width="11"
|
||||
height="7"
|
||||
viewBox="0 0 12 8"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
className={cn("block", className)}
|
||||
>
|
||||
<path
|
||||
d={direction === "up" ? "M1.5 6.5L6 2L10.5 6.5" : "M1.5 1.5L6 6L10.5 1.5"}
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function SortableTableHead({
|
||||
className,
|
||||
sortKey,
|
||||
defaultSortOrder = "desc",
|
||||
children,
|
||||
...props
|
||||
}: Omit<TableHeadProps, "sortable"> & { sortKey: string }) {
|
||||
const {
|
||||
rootRef,
|
||||
pillRef,
|
||||
upRef,
|
||||
downRef,
|
||||
sweepRef,
|
||||
isActive,
|
||||
currentOrder,
|
||||
handleClick,
|
||||
} = useSortableHeadPresenter(sortKey, defaultSortOrder);
|
||||
|
||||
return (
|
||||
<th
|
||||
className={cn(tableHeadBaseClasses, "select-none", className)}
|
||||
aria-sort={
|
||||
isActive
|
||||
? currentOrder === "asc"
|
||||
? "ascending"
|
||||
: "descending"
|
||||
: "none"
|
||||
}
|
||||
{...props}
|
||||
>
|
||||
<button
|
||||
ref={rootRef}
|
||||
type="button"
|
||||
onClick={handleClick}
|
||||
className={cn(
|
||||
"group/sort -mx-1 inline-flex items-center gap-1.5 rounded-md px-1 py-0.5 align-middle uppercase tracking-[0.08em] outline-none transition-colors duration-200 focus-visible:ring-2 focus-visible:ring-primary/40",
|
||||
isActive
|
||||
? "text-primary"
|
||||
: "text-dark-5 hover:text-dark dark:text-dark-6 dark:hover:text-white",
|
||||
)}
|
||||
>
|
||||
<span className="whitespace-nowrap">{children}</span>
|
||||
<span
|
||||
ref={pillRef}
|
||||
className={cn(
|
||||
"relative inline-flex h-5 w-5 shrink-0 items-center justify-center overflow-hidden rounded-md transition-colors duration-200",
|
||||
isActive
|
||||
? "bg-primary/10 dark:bg-primary/15"
|
||||
: "bg-transparent group-hover/sort:bg-gray-2 dark:group-hover/sort:bg-dark-3",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
ref={sweepRef}
|
||||
className="pointer-events-none absolute inset-y-0 -inset-x-2 bg-gradient-to-r from-transparent via-primary/50 to-transparent opacity-0"
|
||||
/>
|
||||
<span className="relative flex flex-col items-center justify-center gap-[1px] leading-none">
|
||||
<span ref={upRef} className="block">
|
||||
<Chevron direction="up" />
|
||||
</span>
|
||||
<span ref={downRef} className="block">
|
||||
<Chevron direction="down" />
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</th>
|
||||
);
|
||||
}
|
||||
|
||||
export function TableCell({
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { createContext } from "react";
|
||||
|
||||
export type SortDirection = "asc" | "desc";
|
||||
|
||||
export interface TableSortContextValue {
|
||||
/** BSON field name (snake_case) currently used for `sort_by`. */
|
||||
sortKey?: string;
|
||||
/** Active `sort_order` value. */
|
||||
sortOrder?: SortDirection;
|
||||
/** Fired when a sortable header is toggled. */
|
||||
onSortChange?: (key: string, order: SortDirection) => void;
|
||||
}
|
||||
|
||||
export const TableSortContext = createContext<TableSortContextValue>({});
|
||||
@@ -0,0 +1,143 @@
|
||||
"use client";
|
||||
|
||||
import gsap from "gsap";
|
||||
import { useCallback, useContext, useLayoutEffect, useRef } from "react";
|
||||
import { SortDirection, TableSortContext } from "./tableSortContext";
|
||||
|
||||
const prefersReducedMotion = () =>
|
||||
typeof window !== "undefined" &&
|
||||
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
|
||||
/**
|
||||
* Presenter for a single sortable column header.
|
||||
*
|
||||
* Owns the interaction logic (which direction to request next) and the GSAP
|
||||
* choreography for the animated sort indicator, keeping the `table.tsx` view
|
||||
* free of side effects per the architecture guidelines.
|
||||
*/
|
||||
export const useSortableHeadPresenter = (
|
||||
sortKey: string,
|
||||
defaultSortOrder: SortDirection = "desc",
|
||||
) => {
|
||||
const {
|
||||
sortKey: activeKey,
|
||||
sortOrder,
|
||||
onSortChange,
|
||||
} = useContext(TableSortContext);
|
||||
|
||||
const isActive = activeKey === sortKey;
|
||||
const currentOrder: SortDirection = isActive
|
||||
? sortOrder ?? defaultSortOrder
|
||||
: defaultSortOrder;
|
||||
|
||||
const rootRef = useRef<HTMLButtonElement>(null);
|
||||
const pillRef = useRef<HTMLSpanElement>(null);
|
||||
const upRef = useRef<HTMLSpanElement>(null);
|
||||
const downRef = useRef<HTMLSpanElement>(null);
|
||||
const sweepRef = useRef<HTMLSpanElement>(null);
|
||||
const didMount = useRef(false);
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
if (!onSortChange) return;
|
||||
const nextOrder: SortDirection = isActive
|
||||
? currentOrder === "asc"
|
||||
? "desc"
|
||||
: "asc"
|
||||
: defaultSortOrder;
|
||||
onSortChange(sortKey, nextOrder);
|
||||
}, [onSortChange, isActive, currentOrder, defaultSortOrder, sortKey]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const reduced = prefersReducedMotion();
|
||||
|
||||
const ctx = gsap.context(() => {
|
||||
const up = upRef.current;
|
||||
const down = downRef.current;
|
||||
const pill = pillRef.current;
|
||||
const sweep = sweepRef.current;
|
||||
if (!up || !down || !pill) return;
|
||||
|
||||
const activeArrow = currentOrder === "asc" ? up : down;
|
||||
const idleArrow = currentOrder === "asc" ? down : up;
|
||||
|
||||
const activeOpacity = isActive ? 1 : 0.45;
|
||||
const idleOpacity = isActive ? 0.2 : 0.45;
|
||||
|
||||
if (reduced) {
|
||||
gsap.set(idleArrow, { opacity: idleOpacity, y: 0, scale: 1 });
|
||||
gsap.set(activeArrow, { opacity: activeOpacity, y: 0, scale: 1 });
|
||||
return;
|
||||
}
|
||||
|
||||
// Resting state for whichever chevron is not the requested direction.
|
||||
gsap.to(idleArrow, {
|
||||
opacity: idleOpacity,
|
||||
scale: 0.85,
|
||||
y: 0,
|
||||
duration: 0.3,
|
||||
ease: "power2.out",
|
||||
});
|
||||
|
||||
// First paint: settle without the celebratory burst.
|
||||
if (!didMount.current) {
|
||||
didMount.current = true;
|
||||
gsap.set(activeArrow, {
|
||||
opacity: activeOpacity,
|
||||
scale: isActive ? 1 : 0.85,
|
||||
y: 0,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
gsap.to(activeArrow, {
|
||||
opacity: activeOpacity,
|
||||
scale: isActive ? 1 : 0.85,
|
||||
y: 0,
|
||||
duration: 0.35,
|
||||
ease: "power2.out",
|
||||
});
|
||||
|
||||
if (!isActive) return;
|
||||
|
||||
// Pop the pill and slide the chosen chevron in from its direction.
|
||||
gsap.fromTo(
|
||||
pill,
|
||||
{ scale: 0.78 },
|
||||
{ scale: 1, duration: 0.55, ease: "back.out(3.2)" },
|
||||
);
|
||||
gsap.fromTo(
|
||||
activeArrow,
|
||||
{ y: currentOrder === "asc" ? 7 : -7, scale: 0.4, opacity: 0 },
|
||||
{
|
||||
y: 0,
|
||||
scale: 1,
|
||||
opacity: 1,
|
||||
duration: 0.55,
|
||||
ease: "back.out(2.6)",
|
||||
},
|
||||
);
|
||||
|
||||
// A light sweep races across the indicator on every change.
|
||||
if (sweep) {
|
||||
gsap.fromTo(
|
||||
sweep,
|
||||
{ xPercent: -160, opacity: 0.9 },
|
||||
{ xPercent: 160, opacity: 0, duration: 0.7, ease: "power2.inOut" },
|
||||
);
|
||||
}
|
||||
}, rootRef);
|
||||
|
||||
return () => ctx.revert();
|
||||
}, [isActive, currentOrder]);
|
||||
|
||||
return {
|
||||
rootRef,
|
||||
pillRef,
|
||||
upRef,
|
||||
downRef,
|
||||
sweepRef,
|
||||
isActive,
|
||||
currentOrder,
|
||||
handleClick,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user