@carlonicora/nextjs-jsonapi 6.32.2 → 6.33.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (227) hide show
  1. package/dist/{BlockNoteEditor-KRNC6EAX.mjs → BlockNoteEditor-DCVT7YHE.mjs} +4 -4
  2. package/dist/{BlockNoteEditor-VXSVY6OY.js → BlockNoteEditor-ZVK5MD34.js} +24 -24
  3. package/dist/{BlockNoteEditor-VXSVY6OY.js.map → BlockNoteEditor-ZVK5MD34.js.map} +1 -1
  4. package/dist/{BlockNoteViewer-RQBYHBQL.js → BlockNoteViewer-2KPGZSTJ.js} +3 -3
  5. package/dist/{BlockNoteViewer-RQBYHBQL.js.map → BlockNoteViewer-2KPGZSTJ.js.map} +1 -1
  6. package/dist/{BlockNoteViewer-NVSR67X5.mjs → BlockNoteViewer-XDAC6AL7.mjs} +2 -2
  7. package/dist/billing/index.js +477 -469
  8. package/dist/billing/index.js.map +1 -1
  9. package/dist/billing/index.mjs +15 -7
  10. package/dist/billing/index.mjs.map +1 -1
  11. package/dist/{chunk-5H25LM2Z.js → chunk-576XHOYH.js} +5 -5
  12. package/dist/{chunk-5H25LM2Z.js.map → chunk-576XHOYH.js.map} +1 -1
  13. package/dist/{chunk-NPGX2GJD.js → chunk-CI64OTRX.js} +98 -75
  14. package/dist/chunk-CI64OTRX.js.map +1 -0
  15. package/dist/{chunk-AEBZ67JJ.mjs → chunk-ETU3AW2X.mjs} +2 -2
  16. package/dist/{chunk-4JVLLBRS.mjs → chunk-GUNH3THR.mjs} +24 -1
  17. package/dist/chunk-GUNH3THR.mjs.map +1 -0
  18. package/dist/{chunk-GPVLFNLB.js → chunk-KH4Y4FU4.js} +1411 -1187
  19. package/dist/chunk-KH4Y4FU4.js.map +1 -0
  20. package/dist/{chunk-PNCO3HMJ.js → chunk-TCAOU6NH.js} +3 -3
  21. package/dist/{chunk-PNCO3HMJ.js.map → chunk-TCAOU6NH.js.map} +1 -1
  22. package/dist/{chunk-4SXHRANW.mjs → chunk-V4NPMUZ2.mjs} +2627 -2403
  23. package/dist/chunk-V4NPMUZ2.mjs.map +1 -0
  24. package/dist/{chunk-AMQFKCAX.mjs → chunk-WPFEPIKP.mjs} +2 -2
  25. package/dist/client/index.js +6 -4
  26. package/dist/client/index.js.map +1 -1
  27. package/dist/client/index.mjs +5 -3
  28. package/dist/components/index.js +12 -4
  29. package/dist/components/index.js.map +1 -1
  30. package/dist/components/index.mjs +11 -3
  31. package/dist/components/tables/ContentListTable.d.ts +14 -0
  32. package/dist/components/tables/ContentListTable.d.ts.map +1 -1
  33. package/dist/components/tables/SortableHeader.d.ts +17 -0
  34. package/dist/components/tables/SortableHeader.d.ts.map +1 -0
  35. package/dist/components/tables/cells/cell.date.d.ts +4 -0
  36. package/dist/components/tables/cells/cell.date.d.ts.map +1 -1
  37. package/dist/components/tables/cells/cell.dateTime.d.ts +4 -0
  38. package/dist/components/tables/cells/cell.dateTime.d.ts.map +1 -1
  39. package/dist/components/tables/cells/cell.link.d.ts +4 -0
  40. package/dist/components/tables/cells/cell.link.d.ts.map +1 -1
  41. package/dist/components/tables/cells/cell.text.d.ts +4 -0
  42. package/dist/components/tables/cells/cell.text.d.ts.map +1 -1
  43. package/dist/components/tables/index.d.ts +2 -0
  44. package/dist/components/tables/index.d.ts.map +1 -1
  45. package/dist/components/tables/sorting.d.ts +24 -0
  46. package/dist/components/tables/sorting.d.ts.map +1 -0
  47. package/dist/contexts/index.js +4 -4
  48. package/dist/contexts/index.mjs +3 -3
  49. package/dist/core/index.js +2 -2
  50. package/dist/core/index.mjs +1 -1
  51. package/dist/email-automations/index.js +120 -115
  52. package/dist/email-automations/index.js.map +1 -1
  53. package/dist/email-automations/index.mjs +18 -13
  54. package/dist/email-automations/index.mjs.map +1 -1
  55. package/dist/features/analytics/index.js +114 -114
  56. package/dist/features/analytics/index.mjs +3 -3
  57. package/dist/features/analytics/server.js +4 -4
  58. package/dist/features/analytics/server.mjs +2 -2
  59. package/dist/features/billing/stripe-coupon/components/lists/CouponCodesList.d.ts.map +1 -1
  60. package/dist/features/billing/stripe-coupon/components/lists/CouponsList.d.ts.map +1 -1
  61. package/dist/features/billing/stripe-coupon/data/stripe-coupon-code.service.d.ts +2 -0
  62. package/dist/features/billing/stripe-coupon/data/stripe-coupon-code.service.d.ts.map +1 -1
  63. package/dist/features/billing/stripe-coupon/data/stripe-coupon.service.d.ts +1 -0
  64. package/dist/features/billing/stripe-coupon/data/stripe-coupon.service.d.ts.map +1 -1
  65. package/dist/features/billing/stripe-coupon/hooks/useStripeCouponCodeTableStructure.d.ts.map +1 -1
  66. package/dist/features/billing/stripe-coupon/hooks/useStripeCouponTableStructure.d.ts.map +1 -1
  67. package/dist/features/billing/stripe-price/components/lists/PricesList.d.ts.map +1 -1
  68. package/dist/features/billing/stripe-price/data/stripe-price.service.d.ts +2 -0
  69. package/dist/features/billing/stripe-price/data/stripe-price.service.d.ts.map +1 -1
  70. package/dist/features/billing/stripe-price/hooks/useStripePriceTableStructure.d.ts.map +1 -1
  71. package/dist/features/billing/stripe-product/components/lists/ProductsList.d.ts.map +1 -1
  72. package/dist/features/billing/stripe-product/data/stripe-product.service.d.ts +2 -0
  73. package/dist/features/billing/stripe-product/data/stripe-product.service.d.ts.map +1 -1
  74. package/dist/features/billing/stripe-product/hooks/useStripeProductTableStructure.d.ts.map +1 -1
  75. package/dist/features/blog/components/admin/BlogPostsList.d.ts.map +1 -1
  76. package/dist/features/blog/data/BlogPostService.d.ts +1 -0
  77. package/dist/features/blog/data/BlogPostService.d.ts.map +1 -1
  78. package/dist/features/blog/index.js +134 -132
  79. package/dist/features/blog/index.js.map +1 -1
  80. package/dist/features/blog/index.mjs +8 -6
  81. package/dist/features/blog/index.mjs.map +1 -1
  82. package/dist/features/blog/server.js +7 -7
  83. package/dist/features/blog/server.mjs +1 -1
  84. package/dist/features/company/components/lists/CompaniesList.d.ts.map +1 -1
  85. package/dist/features/company/data/company.service.d.ts +1 -0
  86. package/dist/features/company/data/company.service.d.ts.map +1 -1
  87. package/dist/features/content/data/content.service.d.ts +3 -0
  88. package/dist/features/content/data/content.service.d.ts.map +1 -1
  89. package/dist/features/email-automation/components/lists/EmailAutomationRecipientsList.d.ts.map +1 -1
  90. package/dist/features/email-automation/components/lists/EmailAutomationsList.d.ts.map +1 -1
  91. package/dist/features/email-automation/data/EmailAutomationService.d.ts +3 -0
  92. package/dist/features/email-automation/data/EmailAutomationService.d.ts.map +1 -1
  93. package/dist/features/email-automation/hooks/useEmailAutomationTableStructure.d.ts.map +1 -1
  94. package/dist/features/email-blast/components/lists/EmailBlastsList.d.ts.map +1 -1
  95. package/dist/features/email-blast/data/EmailBlastService.d.ts +1 -0
  96. package/dist/features/email-blast/data/EmailBlastService.d.ts.map +1 -1
  97. package/dist/features/email-blast/hooks/useEmailBlastTableStructure.d.ts.map +1 -1
  98. package/dist/features/help/index.js +37 -37
  99. package/dist/features/help/index.mjs +3 -3
  100. package/dist/features/how-to/components/lists/HowToList.d.ts.map +1 -1
  101. package/dist/features/how-to/data/HowToService.d.ts +2 -0
  102. package/dist/features/how-to/data/HowToService.d.ts.map +1 -1
  103. package/dist/features/notification/components/lists/NotificationsList.d.ts.map +1 -1
  104. package/dist/features/notification/data/notification.service.d.ts +2 -0
  105. package/dist/features/notification/data/notification.service.d.ts.map +1 -1
  106. package/dist/features/referral/components/containers/ReferralsAdminContainer.d.ts.map +1 -1
  107. package/dist/features/referral/data/ReferralAdminService.d.ts +1 -0
  108. package/dist/features/referral/data/ReferralAdminService.d.ts.map +1 -1
  109. package/dist/features/referral/hooks/useReferralAdminReferralTableStructure.d.ts.map +1 -1
  110. package/dist/features/role/components/lists/RolesList.d.ts.map +1 -1
  111. package/dist/features/role/components/lists/UserRolesList.d.ts.map +1 -1
  112. package/dist/features/role/data/role.service.d.ts +3 -0
  113. package/dist/features/role/data/role.service.d.ts.map +1 -1
  114. package/dist/features/tokenusage/index.js +114 -114
  115. package/dist/features/tokenusage/index.mjs +3 -3
  116. package/dist/features/user/components/lists/AdminUsersList.d.ts.map +1 -1
  117. package/dist/features/user/components/lists/CompanyUsersList.d.ts.map +1 -1
  118. package/dist/features/user/components/lists/PlatformUsersList.d.ts.map +1 -1
  119. package/dist/features/user/components/lists/RoleUsersList.d.ts.map +1 -1
  120. package/dist/features/user/components/lists/UsersListByContentIds.d.ts.map +1 -1
  121. package/dist/features/user/data/user.service.d.ts +11 -0
  122. package/dist/features/user/data/user.service.d.ts.map +1 -1
  123. package/dist/features/waitlist/components/lists/WaitlistList.d.ts.map +1 -1
  124. package/dist/features/waitlist/data/WaitlistService.d.ts +2 -0
  125. package/dist/features/waitlist/data/WaitlistService.d.ts.map +1 -1
  126. package/dist/hooks/index.d.ts +1 -0
  127. package/dist/hooks/index.d.ts.map +1 -1
  128. package/dist/hooks/useDataListRetriever.d.ts +13 -0
  129. package/dist/hooks/useDataListRetriever.d.ts.map +1 -1
  130. package/dist/hooks/useListSortParam.d.ts +23 -0
  131. package/dist/hooks/useListSortParam.d.ts.map +1 -0
  132. package/dist/index.js +3 -3
  133. package/dist/index.mjs +2 -2
  134. package/dist/server/index.js +3 -3
  135. package/dist/server/index.mjs +2 -2
  136. package/package.json +1 -1
  137. package/src/components/tables/ContentListTable.tsx +108 -10
  138. package/src/components/tables/SortableHeader.tsx +43 -0
  139. package/src/components/tables/__tests__/ContentListTable.rendering.spec.tsx +1 -0
  140. package/src/components/tables/__tests__/ContentListTable.sort.spec.tsx +273 -0
  141. package/src/components/tables/__tests__/sorting.spec.ts +61 -0
  142. package/src/components/tables/cells/cell.date.tsx +10 -2
  143. package/src/components/tables/cells/cell.dateTime.tsx +10 -2
  144. package/src/components/tables/cells/cell.link.tsx +6 -1
  145. package/src/components/tables/cells/cell.text.tsx +11 -2
  146. package/src/components/tables/index.ts +2 -0
  147. package/src/components/tables/sorting.ts +41 -0
  148. package/src/features/billing/stripe-coupon/components/lists/CouponCodesList.tsx +2 -0
  149. package/src/features/billing/stripe-coupon/components/lists/CouponsList.tsx +2 -0
  150. package/src/features/billing/stripe-coupon/data/stripe-coupon-code.service.ts +4 -0
  151. package/src/features/billing/stripe-coupon/data/stripe-coupon.service.ts +7 -1
  152. package/src/features/billing/stripe-coupon/hooks/__tests__/useStripeCouponCodeTableStructure.sort.spec.tsx +30 -0
  153. package/src/features/billing/stripe-coupon/hooks/__tests__/useStripeCouponTableStructure.sort.spec.tsx +33 -0
  154. package/src/features/billing/stripe-coupon/hooks/useStripeCouponCodeTableStructure.tsx +5 -4
  155. package/src/features/billing/stripe-coupon/hooks/useStripeCouponTableStructure.tsx +7 -4
  156. package/src/features/billing/stripe-price/components/lists/PricesList.tsx +2 -0
  157. package/src/features/billing/stripe-price/data/stripe-price.service.ts +4 -0
  158. package/src/features/billing/stripe-price/hooks/__tests__/useStripePriceTableStructure.sort.spec.tsx +29 -0
  159. package/src/features/billing/stripe-price/hooks/useStripePriceTableStructure.tsx +8 -5
  160. package/src/features/billing/stripe-product/components/lists/ProductsList.tsx +2 -0
  161. package/src/features/billing/stripe-product/data/stripe-product.service.ts +4 -0
  162. package/src/features/billing/stripe-product/hooks/__tests__/useStripeProductTableStructure.sort.spec.tsx +29 -0
  163. package/src/features/billing/stripe-product/hooks/useStripeProductTableStructure.tsx +3 -2
  164. package/src/features/blog/components/admin/BlogPostsList.tsx +2 -0
  165. package/src/features/blog/data/BlogPostService.ts +3 -1
  166. package/src/features/blog/hooks/__tests__/useBlogPostTableStructure.sort.spec.tsx +19 -0
  167. package/src/features/blog/hooks/useBlogPostTableStructure.tsx +2 -2
  168. package/src/features/company/components/lists/CompaniesList.tsx +2 -0
  169. package/src/features/company/data/company.service.ts +3 -1
  170. package/src/features/company/hooks/__tests__/useCompanyTableStructure.sort.spec.tsx +19 -0
  171. package/src/features/company/hooks/useCompanyTableStructure.tsx +1 -1
  172. package/src/features/content/data/content.service.ts +12 -1
  173. package/src/features/content/hooks/__tests__/useContentTableStructure.sort.spec.tsx +21 -0
  174. package/src/features/content/hooks/useContentTableStructure.tsx +1 -1
  175. package/src/features/email-automation/components/lists/EmailAutomationRecipientsList.tsx +2 -0
  176. package/src/features/email-automation/components/lists/EmailAutomationsList.tsx +2 -0
  177. package/src/features/email-automation/data/EmailAutomationService.ts +9 -1
  178. package/src/features/email-automation/hooks/__tests__/useEmailAutomationEnrollmentTableStructure.sort.spec.tsx +21 -0
  179. package/src/features/email-automation/hooks/__tests__/useEmailAutomationTableStructure.sort.spec.tsx +29 -0
  180. package/src/features/email-automation/hooks/useEmailAutomationEnrollmentTableStructure.tsx +4 -4
  181. package/src/features/email-automation/hooks/useEmailAutomationTableStructure.tsx +5 -4
  182. package/src/features/email-blast/components/lists/EmailBlastsList.tsx +2 -0
  183. package/src/features/email-blast/data/EmailBlastService.ts +5 -1
  184. package/src/features/email-blast/hooks/__tests__/useEmailBlastTableStructure.sort.spec.tsx +29 -0
  185. package/src/features/email-blast/hooks/useEmailBlastTableStructure.tsx +16 -8
  186. package/src/features/how-to/components/lists/HowToList.tsx +2 -0
  187. package/src/features/how-to/data/HowToService.ts +4 -0
  188. package/src/features/how-to/hooks/__tests__/useHowToTableStructure.sort.spec.tsx +18 -0
  189. package/src/features/how-to/hooks/useHowToTableStructure.tsx +1 -1
  190. package/src/features/notification/components/lists/NotificationsList.tsx +2 -0
  191. package/src/features/notification/data/notification.service.ts +4 -0
  192. package/src/features/notification/hooks/__tests__/useNotificationTableStructure.sort.spec.tsx +19 -0
  193. package/src/features/notification/hooks/useNotificationTableStructure.tsx +1 -1
  194. package/src/features/referral/components/containers/ReferralsAdminContainer.tsx +4 -2
  195. package/src/features/referral/data/ReferralAdminService.ts +6 -1
  196. package/src/features/referral/hooks/__tests__/useReferralAdminReferralTableStructure.sort.spec.tsx +35 -0
  197. package/src/features/referral/hooks/useReferralAdminReferralTableStructure.tsx +10 -5
  198. package/src/features/role/components/lists/RolesList.tsx +2 -0
  199. package/src/features/role/components/lists/UserRolesList.tsx +2 -0
  200. package/src/features/role/data/role.service.ts +7 -1
  201. package/src/features/role/hooks/__tests__/useRoleTableStructure.sort.spec.tsx +16 -0
  202. package/src/features/role/hooks/useRoleTableStructure.tsx +2 -2
  203. package/src/features/user/components/lists/AdminUsersList.tsx +2 -0
  204. package/src/features/user/components/lists/CompanyUsersList.tsx +4 -0
  205. package/src/features/user/components/lists/PlatformUsersList.tsx +2 -0
  206. package/src/features/user/components/lists/RoleUsersList.tsx +2 -0
  207. package/src/features/user/components/lists/UsersListByContentIds.tsx +2 -0
  208. package/src/features/user/data/user.service.ts +28 -1
  209. package/src/features/user/hooks/__tests__/useUserTableStructure.sort.spec.tsx +16 -0
  210. package/src/features/user/hooks/useUserTableStructure.tsx +2 -2
  211. package/src/features/waitlist/components/lists/WaitlistList.tsx +2 -0
  212. package/src/features/waitlist/data/WaitlistService.ts +4 -0
  213. package/src/features/waitlist/hooks/__tests__/useWaitlistTableStructure.sort.spec.tsx +17 -0
  214. package/src/features/waitlist/hooks/useWaitlistTableStructure.tsx +3 -3
  215. package/src/hooks/__tests__/useDataListRetriever.sort.spec.tsx +114 -0
  216. package/src/hooks/__tests__/useListSortParam.spec.tsx +91 -0
  217. package/src/hooks/index.ts +1 -0
  218. package/src/hooks/useDataListRetriever.ts +44 -4
  219. package/src/hooks/useListSortParam.ts +53 -0
  220. package/dist/chunk-4JVLLBRS.mjs.map +0 -1
  221. package/dist/chunk-4SXHRANW.mjs.map +0 -1
  222. package/dist/chunk-GPVLFNLB.js.map +0 -1
  223. package/dist/chunk-NPGX2GJD.js.map +0 -1
  224. /package/dist/{BlockNoteEditor-KRNC6EAX.mjs.map → BlockNoteEditor-DCVT7YHE.mjs.map} +0 -0
  225. /package/dist/{BlockNoteViewer-NVSR67X5.mjs.map → BlockNoteViewer-XDAC6AL7.mjs.map} +0 -0
  226. /package/dist/{chunk-AEBZ67JJ.mjs.map → chunk-ETU3AW2X.mjs.map} +0 -0
  227. /package/dist/{chunk-AMQFKCAX.mjs.map → chunk-WPFEPIKP.mjs.map} +0 -0
@@ -32,6 +32,8 @@ export function WaitlistList({ fullWidth }: WaitlistListProps = {}) {
32
32
  retriever: (params) => WaitlistService.findMany(params),
33
33
  retrieverParams: {},
34
34
  module: Modules.Waitlist,
35
+ // Display only (call C8): the server's own order when no sort is chosen.
36
+ defaultSort: "-createdAt",
35
37
  });
36
38
 
37
39
  const loadStats = useCallback(async () => {
@@ -43,6 +43,8 @@ export class WaitlistService extends AbstractService {
43
43
  fetchAll?: boolean;
44
44
  next?: NextRef;
45
45
  previous?: PreviousRef;
46
+ /** JSON:API `sort` (`name`, `-name`), forwarded to the API. */
47
+ sort?: string;
46
48
  }): Promise<WaitlistInterface[]> {
47
49
  const endpoint = new EndpointCreator({ endpoint: Modules.Waitlist });
48
50
 
@@ -50,6 +52,8 @@ export class WaitlistService extends AbstractService {
50
52
  if (params?.search) endpoint.addAdditionalParam("search", params.search);
51
53
  if (params?.fetchAll) endpoint.addAdditionalParam("fetchAll", "true");
52
54
 
55
+ if (params?.sort) endpoint.addAdditionalParam("sort", params?.sort);
56
+
53
57
  return this.callApi<WaitlistInterface[]>({
54
58
  type: Modules.Waitlist,
55
59
  method: HttpMethod.GET,
@@ -0,0 +1,17 @@
1
+ import { renderHook } from "@testing-library/react";
2
+ import { describe, expect, it, vi } from "vitest";
3
+ import { WaitlistFields } from "../../data/waitlist.fields";
4
+ import { useWaitlistTableStructure } from "../useWaitlistTableStructure";
5
+
6
+ vi.mock("next-intl", () => ({ useTranslations: () => (key: string) => key, useLocale: () => "en" }));
7
+
8
+ /** Spec row A1: exactly email, status and createdAt are sortable (call C3: an explicit `true`). */
9
+ describe("useWaitlistTableStructure sorting", () => {
10
+ it("opts in exactly the row's sortable columns", () => {
11
+ const { result } = renderHook(() =>
12
+ useWaitlistTableStructure({ data: [], fields: Object.values(WaitlistFields) as WaitlistFields[] }),
13
+ );
14
+ const sortable = result.current.columns.filter((c) => c.enableSorting === true).map((c) => c.id);
15
+ expect(sortable.sort()).toEqual(["createdAt", "email", "status"]);
16
+ });
17
+ });
@@ -53,7 +53,7 @@ export const useWaitlistTableStructure: UseTableStructureHook<WaitlistInterface,
53
53
  cell: ({ row }: { row: TableContent<WaitlistInterface> }) => (
54
54
  <span className="font-medium">{row.original.jsonApiData.email}</span>
55
55
  ),
56
- enableSorting: false,
56
+ enableSorting: true,
57
57
  enableHiding: false,
58
58
  }),
59
59
  [WaitlistFields.status]: () => ({
@@ -61,7 +61,7 @@ export const useWaitlistTableStructure: UseTableStructureHook<WaitlistInterface,
61
61
  accessorKey: "status",
62
62
  header: t("waitlist.admin.columns.status"),
63
63
  cell: ({ row }: { row: TableContent<WaitlistInterface> }) => getStatusBadge(row.original.jsonApiData.status),
64
- enableSorting: false,
64
+ enableSorting: true,
65
65
  enableHiding: false,
66
66
  }),
67
67
  [WaitlistFields.createdAt]: () => ({
@@ -73,7 +73,7 @@ export const useWaitlistTableStructure: UseTableStructureHook<WaitlistInterface,
73
73
  if (!entry.createdAt) return "-";
74
74
  return dates.format(entry.createdAt, "date");
75
75
  },
76
- enableSorting: false,
76
+ enableSorting: true,
77
77
  enableHiding: false,
78
78
  }),
79
79
  [WaitlistFields.source]: () => ({
@@ -0,0 +1,114 @@
1
+ import { act, renderHook, waitFor } from "@testing-library/react";
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3
+ import { createMockModule } from "../../testing";
4
+ import { useDataListRetriever } from "../useDataListRetriever";
5
+
6
+ describe("useDataListRetriever sort", () => {
7
+ const mockModule = createMockModule({ name: "accounts" });
8
+
9
+ beforeEach(() => {
10
+ window.history.replaceState(null, "", "/accounts?sort%5Baccounts%5D=-name");
11
+ });
12
+
13
+ afterEach(() => {
14
+ window.history.replaceState(null, "", "/");
15
+ vi.restoreAllMocks();
16
+ });
17
+
18
+ it("reads the sort from the address and fetches once with it", async () => {
19
+ const retriever = vi.fn().mockResolvedValue([]);
20
+ const { result } = renderHook(() => useDataListRetriever({ retriever, module: mockModule }));
21
+
22
+ await waitFor(() => expect(result.current.isLoaded).toBe(true));
23
+
24
+ expect(retriever).toHaveBeenCalledTimes(1);
25
+ expect(retriever.mock.calls[0][0].sort).toBe("-name");
26
+ expect(result.current.sort).toBe("-name");
27
+ });
28
+
29
+ it("refetches once with the new sort and the current search term", async () => {
30
+ const retriever = vi.fn().mockResolvedValue([]);
31
+ const { result } = renderHook(() => useDataListRetriever({ retriever, module: mockModule }));
32
+ await waitFor(() => expect(result.current.isLoaded).toBe(true));
33
+
34
+ await act(async () => {
35
+ await result.current.search("rossi");
36
+ });
37
+ await waitFor(() => expect(result.current.isLoaded).toBe(true));
38
+ retriever.mockClear();
39
+
40
+ await act(async () => {
41
+ result.current.setSort("name");
42
+ });
43
+ await waitFor(() => expect(result.current.isLoaded).toBe(true));
44
+
45
+ expect(retriever).toHaveBeenCalledTimes(1);
46
+ expect(retriever.mock.calls[0][0].sort).toBe("name");
47
+ expect(retriever.mock.calls[0][0].search).toBe("rossi");
48
+ expect(result.current.sort).toBe("name");
49
+ expect(new URLSearchParams(window.location.search).get("sort[accounts]")).toBe("name");
50
+ });
51
+
52
+ it("returns to page 1 when the sort changes", async () => {
53
+ const retriever = vi.fn().mockImplementation(async (params: { next?: { next?: string } }) => {
54
+ if (params.next) params.next.next = "accounts?page[offset]=25";
55
+ return [];
56
+ });
57
+ const { result } = renderHook(() => useDataListRetriever({ retriever, module: mockModule }));
58
+ await waitFor(() => expect(result.current.isLoaded).toBe(true));
59
+ await waitFor(() => expect(result.current.next).toBeDefined());
60
+
61
+ retriever.mockClear();
62
+ retriever.mockResolvedValue([]);
63
+ await act(async () => {
64
+ result.current.setSort("name");
65
+ });
66
+ await waitFor(() => expect(result.current.isLoaded).toBe(true));
67
+
68
+ // The refetch goes through the list's own retriever (page 1), never the stored next-page link.
69
+ expect(retriever).toHaveBeenCalledTimes(1);
70
+ expect(retriever.mock.calls[0][0].sort).toBe("name");
71
+ expect(result.current.next).toBeUndefined();
72
+ expect(result.current.previous).toBeUndefined();
73
+ });
74
+
75
+ it("sends no sort after it is cleared", async () => {
76
+ const retriever = vi.fn().mockResolvedValue([]);
77
+ const { result } = renderHook(() => useDataListRetriever({ retriever, module: mockModule }));
78
+ await waitFor(() => expect(result.current.isLoaded).toBe(true));
79
+ retriever.mockClear();
80
+
81
+ await act(async () => {
82
+ result.current.setSort(null);
83
+ });
84
+ await waitFor(() => expect(result.current.isLoaded).toBe(true));
85
+
86
+ expect(retriever).toHaveBeenCalledTimes(1);
87
+ expect(retriever.mock.calls[0][0].sort).toBeUndefined();
88
+ expect(result.current.sort).toBeUndefined();
89
+ });
90
+
91
+ it("uses sortKey over the module name", async () => {
92
+ window.history.replaceState(null, "", "/accounts?sort%5Bclients%5D=name&sort%5Baccounts%5D=-name");
93
+ const retriever = vi.fn().mockResolvedValue([]);
94
+ const { result } = renderHook(() => useDataListRetriever({ retriever, module: mockModule, sortKey: "clients" }));
95
+ await waitFor(() => expect(result.current.isLoaded).toBe(true));
96
+
97
+ expect(retriever).toHaveBeenCalledTimes(1);
98
+ expect(retriever.mock.calls[0][0].sort).toBe("name");
99
+ });
100
+
101
+ it("exposes defaultSort and never sends it", async () => {
102
+ window.history.replaceState(null, "", "/accounts");
103
+ const retriever = vi.fn().mockResolvedValue([]);
104
+ const { result } = renderHook(() =>
105
+ useDataListRetriever({ retriever, module: mockModule, defaultSort: "-updatedAt" }),
106
+ );
107
+ await waitFor(() => expect(result.current.isLoaded).toBe(true));
108
+
109
+ expect(result.current.defaultSort).toBe("-updatedAt");
110
+ expect(result.current.sort).toBeUndefined();
111
+ expect(retriever).toHaveBeenCalledTimes(1);
112
+ expect(retriever.mock.calls[0][0].sort).toBeUndefined();
113
+ });
114
+ });
@@ -0,0 +1,91 @@
1
+ import { act, renderHook } from "@testing-library/react";
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3
+ import { useListSortParam } from "../useListSortParam";
4
+
5
+ describe("useListSortParam", () => {
6
+ beforeEach(() => {
7
+ window.history.replaceState(null, "", "/accounts?search=x");
8
+ });
9
+
10
+ afterEach(() => {
11
+ window.history.replaceState(null, "", "/");
12
+ vi.restoreAllMocks();
13
+ });
14
+
15
+ it("is not ready on the first render and ready after mount", () => {
16
+ const renders: boolean[] = [];
17
+ const { result } = renderHook(() => {
18
+ const state = useListSortParam({ sortKey: "accounts" });
19
+ renders.push(state.ready);
20
+ return state;
21
+ });
22
+
23
+ expect(renders[0]).toBe(false);
24
+ expect(result.current.ready).toBe(true);
25
+ });
26
+
27
+ it("reads its key from the address", () => {
28
+ window.history.replaceState(null, "", "/accounts?sort%5Baccounts%5D=-name");
29
+ const { result } = renderHook(() => useListSortParam({ sortKey: "accounts" }));
30
+
31
+ expect(result.current.sort).toBe("-name");
32
+ });
33
+
34
+ it("is undefined when the address has no value for its key", () => {
35
+ const { result } = renderHook(() => useListSortParam({ sortKey: "accounts" }));
36
+
37
+ expect(result.current.ready).toBe(true);
38
+ expect(result.current.sort).toBeUndefined();
39
+ });
40
+
41
+ it("writes its key with replaceState, keeping the path and the other params", () => {
42
+ const replaceState = vi.spyOn(window.history, "replaceState");
43
+ const { result } = renderHook(() => useListSortParam({ sortKey: "accounts" }));
44
+
45
+ act(() => result.current.setSort("name"));
46
+
47
+ expect(replaceState).toHaveBeenCalledTimes(1);
48
+ expect(replaceState).toHaveBeenCalledWith(null, "", "/accounts?search=x&sort%5Baccounts%5D=name");
49
+ expect(result.current.sort).toBe("name");
50
+ });
51
+
52
+ it("keeps two keys on one address independent", () => {
53
+ window.history.replaceState(null, "", "/accounts?sort%5Bcontacts%5D=-lastName");
54
+ const accounts = renderHook(() => useListSortParam({ sortKey: "accounts" }));
55
+ const contacts = renderHook(() => useListSortParam({ sortKey: "contacts" }));
56
+
57
+ expect(accounts.result.current.sort).toBeUndefined();
58
+ expect(contacts.result.current.sort).toBe("-lastName");
59
+
60
+ act(() => accounts.result.current.setSort("name"));
61
+
62
+ const params = new URLSearchParams(window.location.search);
63
+ expect(params.get("sort[accounts]")).toBe("name");
64
+ expect(params.get("sort[contacts]")).toBe("-lastName");
65
+ expect(contacts.result.current.sort).toBe("-lastName");
66
+ });
67
+
68
+ it("removes only its own key on null", () => {
69
+ window.history.replaceState(null, "", "/accounts?search=x&sort%5Baccounts%5D=name&sort%5Bcontacts%5D=-lastName");
70
+ const { result } = renderHook(() => useListSortParam({ sortKey: "accounts" }));
71
+
72
+ act(() => result.current.setSort(null));
73
+
74
+ const params = new URLSearchParams(window.location.search);
75
+ expect(params.has("sort[accounts]")).toBe(false);
76
+ expect(params.get("sort[contacts]")).toBe("-lastName");
77
+ expect(params.get("search")).toBe("x");
78
+ expect(window.location.pathname).toBe("/accounts");
79
+ expect(result.current.sort).toBeUndefined();
80
+ });
81
+
82
+ it("keeps the hash", () => {
83
+ window.history.replaceState(null, "", "/accounts#top");
84
+ const replaceState = vi.spyOn(window.history, "replaceState");
85
+ const { result } = renderHook(() => useListSortParam({ sortKey: "accounts" }));
86
+
87
+ act(() => result.current.setSort("-name"));
88
+
89
+ expect(replaceState).toHaveBeenCalledWith(null, "", "/accounts?sort%5Baccounts%5D=-name#top");
90
+ });
91
+ });
@@ -9,6 +9,7 @@ export type {
9
9
  export { useUrlRewriter } from "./url.rewriter";
10
10
  export { useDataListRetriever, type DataListRetriever } from "./useDataListRetriever";
11
11
  export { useDebounce } from "./useDebounce";
12
+ export { useListSortParam } from "./useListSortParam";
12
13
  export { usePageUrlGenerator } from "./usePageUrlGenerator";
13
14
  export { registerTableGenerator, useTableGenerator } from "./useTableGenerator";
14
15
 
@@ -3,6 +3,7 @@
3
3
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
4
4
  import { ClientAbstractService } from "../core/abstracts/ClientAbstractService";
5
5
  import { getLastApiMeta, getLastApiTotal } from "../core/abstracts/AbstractService";
6
+ import { useListSortParam } from "./useListSortParam";
6
7
 
7
8
  export type PageInfo = {
8
9
  startItem: number;
@@ -27,6 +28,15 @@ export type DataListRetriever<T> = {
27
28
  removeElement: (element: T) => void;
28
29
  isSearch: boolean;
29
30
  pageInfo?: PageInfo;
31
+ /** The chosen sort in JSON:API wire format (`name`, `-name`), kept in the page address. */
32
+ sort?: string;
33
+ /** The list's display-only default order (e.g. `-updatedAt`). Never sent to the API. */
34
+ defaultSort?: string;
35
+ /**
36
+ * Sets (or, with `null`, clears) the sort: writes the address and refetches from page 1, keeping the search term.
37
+ * Optional so hand-built retrievers keep compiling; without it a server-mode table renders plain headers.
38
+ */
39
+ setSort?: (value: string | null) => void;
30
40
  };
31
41
 
32
42
  export function useDataListRetriever<T>(params: {
@@ -37,6 +47,10 @@ export function useDataListRetriever<T>(params: {
37
47
  addAdditionalParameter?: (key: string, value: any | null) => void;
38
48
  requiresSearch?: boolean;
39
49
  module: any;
50
+ /** Address key of this list's sort (`sort[<sortKey>]`). Defaults to `module.name`. */
51
+ sortKey?: string;
52
+ /** Display-only default order, exposed for the table header and never passed to the retriever. */
53
+ defaultSort?: string;
40
54
  }): DataListRetriever<T> {
41
55
  const [data, setData] = useState<T[] | undefined>(undefined);
42
56
  const [total, setTotal] = useState<number | undefined>(undefined);
@@ -52,6 +66,15 @@ export function useDataListRetriever<T>(params: {
52
66
  const isFetchingRef = useRef(false);
53
67
  const isPaginatingRef = useRef(false);
54
68
 
69
+ // The sort lives in the page address. The first fetch waits until the address has been
70
+ // read, so a restored sort is fetched once instead of unsorted first and sorted after.
71
+ const sortParam = useListSortParam({ sortKey: params.sortKey ?? params.module?.name ?? "" });
72
+ const sortReady = sortParam.ready;
73
+ const sortRef = useRef<string | undefined>(undefined);
74
+ useEffect(() => {
75
+ sortRef.current = sortParam.sort;
76
+ }, [sortParam.sort]);
77
+
55
78
  const resolvedType = params.module;
56
79
  const resolvedService = ClientAbstractService; // We'll just use ClientAbstractService directly for pagination
57
80
 
@@ -129,7 +152,7 @@ export function useDataListRetriever<T>(params: {
129
152
  /** Re-fetch even though a single-page list is already loaded. */
130
153
  force?: boolean;
131
154
  }) => {
132
- if (ready === false) return;
155
+ if (ready === false || !sortReady) return;
133
156
 
134
157
  // Prevent concurrent pagination calls
135
158
  if ((fetchParams?.callNext || fetchParams?.callPrevious) && isPaginatingRef.current) {
@@ -213,6 +236,7 @@ export function useDataListRetriever<T>(params: {
213
236
  // An explicit `search` passed through addAdditionalParameter wins: overwriting it
214
237
  // with the internal term (only ever set by `search()`) silently dropped it.
215
238
  if (additionalParamsRef.current.search === undefined) retrieverParams.search = currentSearchTerm;
239
+ if (sortRef.current !== undefined) retrieverParams.sort = sortRef.current;
216
240
  retrieverParams.next = nextRef;
217
241
  retrieverParams.previous = previousRef;
218
242
  retrieverParams.self = selfRef;
@@ -254,7 +278,7 @@ export function useDataListRetriever<T>(params: {
254
278
  }
255
279
  }
256
280
  },
257
- [stableParams, ready, params.search],
281
+ [stableParams, ready, sortReady, params.search],
258
282
  );
259
283
 
260
284
  const setRefreshedElement = useCallback(
@@ -297,10 +321,10 @@ export function useDataListRetriever<T>(params: {
297
321
  // and ready effect fired simultaneously on initial render
298
322
  useEffect(() => {
299
323
  // Only fetch if ready and haven't loaded yet
300
- if (ready && !isLoaded) {
324
+ if (ready && sortReady && !isLoaded) {
301
325
  fetchData({ isRefresh: true });
302
326
  }
303
- }, [ready, fetchData]);
327
+ }, [ready, sortReady, fetchData]);
304
328
 
305
329
  // Cache Components keeps a visited route alive in a hidden <Activity>: leaving it cleans up
306
330
  // its effects, coming back re-runs them with the old state intact. A loaded list would then
@@ -377,6 +401,19 @@ export function useDataListRetriever<T>(params: {
377
401
  [fetchData],
378
402
  );
379
403
 
404
+ const writeSortParam = sortParam.setSort;
405
+ const setSort = useCallback(
406
+ (value: string | null) => {
407
+ writeSortParam(value);
408
+ sortRef.current = value ? value : undefined;
409
+ setNextPage(undefined);
410
+ setPreviousPage(undefined);
411
+ // `force`: a new order must be fetched even when the loaded list fits on one page.
412
+ fetchData({ isRefine: true, isRefresh: true, force: true });
413
+ },
414
+ [fetchData, writeSortParam],
415
+ );
416
+
380
417
  const isSearch = !!searchTermRef.current;
381
418
 
382
419
  const refresh = useCallback(async () => {
@@ -412,5 +449,8 @@ export function useDataListRetriever<T>(params: {
412
449
  isSearch: isSearch,
413
450
  removeElement: removeElement,
414
451
  pageInfo: pageInfo,
452
+ sort: sortParam.sort,
453
+ defaultSort: params.defaultSort,
454
+ setSort: setSort,
415
455
  };
416
456
  }
@@ -0,0 +1,53 @@
1
+ "use client";
2
+
3
+ import { useCallback, useEffect, useState } from "react";
4
+
5
+ /**
6
+ * The sort of one list, kept in the page address as `sort[<sortKey>]=<value>`
7
+ * (JSON:API wire format: `name`, `-name`, `lastName,firstName`).
8
+ *
9
+ * The address is read from `window.location` once on mount, so this hook is not a
10
+ * Next search-params hook consumer and needs no `<Suspense>` boundary. `ready` is false
11
+ * until that read has happened; a loader holds its first fetch until then, so a
12
+ * restored sort costs one request, not two.
13
+ *
14
+ * `setSort` replaces only the query string of the current path with
15
+ * `history.replaceState`, keeping every other search param and the hash: no
16
+ * navigation, no scroll, no locale handling. `null` removes this list's key only.
17
+ *
18
+ * @param params.sortKey the list's address key; two lists on one page need two keys.
19
+ */
20
+ export function useListSortParam(params: { sortKey: string }): {
21
+ sort: string | undefined;
22
+ ready: boolean;
23
+ setSort: (value: string | null) => void;
24
+ } {
25
+ const { sortKey } = params;
26
+ const [sort, setSortState] = useState<string | undefined>(undefined);
27
+ const [ready, setReady] = useState(false);
28
+
29
+ useEffect(() => {
30
+ const value = new URLSearchParams(window.location.search).get(`sort[${sortKey}]`);
31
+ setSortState(value ? value : undefined);
32
+ setReady(true);
33
+ }, [sortKey]);
34
+
35
+ const setSort = useCallback(
36
+ (value: string | null) => {
37
+ const search = new URLSearchParams(window.location.search);
38
+ if (value) search.set(`sort[${sortKey}]`, value);
39
+ else search.delete(`sort[${sortKey}]`);
40
+
41
+ const next = search.toString();
42
+ window.history.replaceState(
43
+ null,
44
+ "",
45
+ `${window.location.pathname}${next ? `?${next}` : ""}${window.location.hash}`,
46
+ );
47
+ setSortState(value ? value : undefined);
48
+ },
49
+ [sortKey],
50
+ );
51
+
52
+ return { sort, ready, setSort };
53
+ }