@godxjp/ui 5.0.2 → 6.0.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 (298) hide show
  1. package/README.md +101 -142
  2. package/package.json +124 -128
  3. package/scripts/ui-audit.mjs +179 -0
  4. package/src/app/__tests__/app-provider.test.tsx +232 -0
  5. package/src/app/__tests__/date-format-labels.test.ts +36 -0
  6. package/src/app/__tests__/date-formats.test.ts +44 -0
  7. package/src/app/__tests__/timezones.test.ts +65 -0
  8. package/src/app/app-provider.tsx +227 -0
  9. package/src/app/date-format-labels.ts +21 -0
  10. package/src/app/date-formats.ts +30 -0
  11. package/src/app/index.ts +40 -0
  12. package/src/app/locales.ts +32 -0
  13. package/src/app/request-headers.ts +31 -0
  14. package/src/app/storage.ts +44 -0
  15. package/src/app/time-format-labels.ts +19 -0
  16. package/src/app/time-formats.ts +15 -0
  17. package/src/app/timezones.ts +208 -0
  18. package/src/app/types.ts +39 -0
  19. package/src/app/use-formatting.ts +47 -0
  20. package/src/components/__tests__/accessibility-primitives.test.tsx +65 -0
  21. package/src/components/__tests__/docs-parity.test.ts +41 -0
  22. package/src/components/__tests__/shadcn-release-guardrails.test.ts +71 -0
  23. package/src/components/__tests__/theme-axes-integration.test.tsx +242 -0
  24. package/src/components/admin/index.ts +76 -0
  25. package/src/components/data-display/__tests__/card-table.test.tsx +328 -0
  26. package/src/components/data-display/__tests__/data-display.test.tsx +73 -0
  27. package/src/components/data-display/__tests__/data-table.test.tsx +84 -0
  28. package/src/components/data-display/__tests__/popover.test.tsx +92 -0
  29. package/src/components/data-display/__tests__/scroll-area-collapsible.test.tsx +66 -0
  30. package/src/components/data-display/badge.tsx +27 -0
  31. package/src/components/data-display/card.tsx +194 -0
  32. package/src/components/data-display/code-badge.tsx +28 -0
  33. package/src/components/data-display/collapsible.tsx +5 -0
  34. package/src/components/data-display/data-table.tsx +476 -0
  35. package/src/components/data-display/empty-state.tsx +22 -0
  36. package/src/components/data-display/index.ts +41 -0
  37. package/src/components/data-display/key-value-grid.tsx +46 -0
  38. package/src/components/data-display/popover.tsx +62 -0
  39. package/src/components/data-display/progress-meter.tsx +20 -0
  40. package/src/components/data-display/scan-panel.tsx +16 -0
  41. package/src/components/data-display/scroll-area.tsx +42 -0
  42. package/src/components/data-display/status-badge.tsx +83 -0
  43. package/src/components/data-display/table.tsx +59 -0
  44. package/src/components/data-display/timeline.tsx +42 -0
  45. package/src/components/data-display/tree-list.tsx +42 -0
  46. package/src/components/data-entry/__fixtures__/tree-options.ts +80 -0
  47. package/src/components/data-entry/__tests__/cascader-tree-transfer.test.tsx +417 -0
  48. package/src/components/data-entry/__tests__/checkbox-group.test.tsx +40 -0
  49. package/src/components/data-entry/__tests__/checkbox.test.tsx +20 -0
  50. package/src/components/data-entry/__tests__/date-autocomplete.test.tsx +94 -0
  51. package/src/components/data-entry/__tests__/form-field.test.tsx +49 -0
  52. package/src/components/data-entry/__tests__/input-textarea.test.tsx +38 -0
  53. package/src/components/data-entry/__tests__/label-select.test.tsx +62 -0
  54. package/src/components/data-entry/__tests__/pickers.test.tsx +74 -0
  55. package/src/components/data-entry/__tests__/radio.test.tsx +46 -0
  56. package/src/components/data-entry/__tests__/search-input.test.tsx +32 -0
  57. package/src/components/data-entry/__tests__/switch-field.test.tsx +52 -0
  58. package/src/components/data-entry/__tests__/upload.test.tsx +125 -0
  59. package/src/components/data-entry/autocomplete.tsx +91 -0
  60. package/src/components/data-entry/calendar.tsx +90 -0
  61. package/src/components/data-entry/cascader.tsx +305 -0
  62. package/src/components/data-entry/checkbox-group.tsx +90 -0
  63. package/src/components/data-entry/checkbox.tsx +30 -0
  64. package/src/components/data-entry/choice-field.tsx +27 -0
  65. package/src/components/data-entry/choice-option.ts +20 -0
  66. package/src/components/data-entry/color-picker.tsx +75 -0
  67. package/src/components/data-entry/command.tsx +56 -0
  68. package/src/components/data-entry/country-select.tsx +88 -0
  69. package/src/components/data-entry/date-picker.tsx +69 -0
  70. package/src/components/data-entry/date-range-picker.tsx +75 -0
  71. package/src/components/data-entry/form-field.tsx +59 -0
  72. package/src/components/data-entry/index.ts +62 -0
  73. package/src/components/data-entry/input.tsx +26 -0
  74. package/src/components/data-entry/label.tsx +25 -0
  75. package/src/components/data-entry/radio.tsx +109 -0
  76. package/src/components/data-entry/search-input.tsx +103 -0
  77. package/src/components/data-entry/select.tsx +149 -0
  78. package/src/components/data-entry/slider.tsx +38 -0
  79. package/src/components/data-entry/switch-field.tsx +91 -0
  80. package/src/components/data-entry/switch.tsx +24 -0
  81. package/src/components/data-entry/textarea.tsx +12 -0
  82. package/src/components/data-entry/time-picker.tsx +214 -0
  83. package/src/components/data-entry/transfer.tsx +231 -0
  84. package/src/components/data-entry/tree-select-strategy.ts +6 -0
  85. package/src/components/data-entry/tree-select.tsx +279 -0
  86. package/src/components/data-entry/tree-utils.ts +221 -0
  87. package/src/components/data-entry/upload-crop-dialog.tsx +109 -0
  88. package/src/components/data-entry/upload-types.ts +86 -0
  89. package/src/components/data-entry/upload.tsx +498 -0
  90. package/src/components/data-entry/use-upload-draft.ts +93 -0
  91. package/src/components/feedback/__tests__/alert.test.tsx +127 -0
  92. package/src/components/feedback/__tests__/dialog.test.tsx +290 -0
  93. package/src/components/feedback/__tests__/sheet.test.tsx +94 -0
  94. package/src/components/feedback/__tests__/skeleton.test.tsx +25 -0
  95. package/src/components/feedback/__tests__/toast.test.tsx +52 -0
  96. package/src/components/feedback/alert.tsx +167 -0
  97. package/src/components/feedback/dialog.tsx +325 -0
  98. package/src/components/feedback/index.ts +53 -0
  99. package/src/components/feedback/sheet.tsx +130 -0
  100. package/src/components/feedback/skeleton.tsx +95 -0
  101. package/src/components/feedback/sonner.tsx +54 -0
  102. package/src/components/feedback/toaster.tsx +1 -0
  103. package/src/components/feedback/use-toast.ts +62 -0
  104. package/src/components/general/__tests__/button.test.tsx +71 -0
  105. package/src/components/general/button.tsx +61 -0
  106. package/src/components/general/index.ts +2 -0
  107. package/src/components/layout/__tests__/page-container.test.tsx +69 -0
  108. package/src/components/layout/__tests__/page-inset.test.tsx +14 -0
  109. package/src/components/layout/__tests__/stack-inline.test.tsx +39 -0
  110. package/src/components/layout/app-shell.tsx +42 -0
  111. package/src/components/layout/breadcrumb.tsx +35 -0
  112. package/src/components/layout/index.ts +31 -0
  113. package/src/components/layout/inline.tsx +13 -0
  114. package/src/components/layout/menu.tsx +34 -0
  115. package/src/components/layout/mobile-frame.tsx +57 -0
  116. package/src/components/layout/page-container.tsx +81 -0
  117. package/src/components/layout/page-inset.tsx +16 -0
  118. package/src/components/layout/responsive-grid.tsx +14 -0
  119. package/src/components/layout/shell-app.tsx +30 -0
  120. package/src/components/layout/sidebar.tsx +98 -0
  121. package/src/components/layout/split-pane.tsx +16 -0
  122. package/src/components/layout/stack.tsx +13 -0
  123. package/src/components/layout/topbar.tsx +108 -0
  124. package/src/components/navigation/__tests__/app-pickers.test.tsx +118 -0
  125. package/src/components/navigation/__tests__/dropdown-menu.test.tsx +104 -0
  126. package/src/components/navigation/__tests__/navigation.test.tsx +61 -0
  127. package/src/components/navigation/__tests__/pagination-steps-tabs.test.tsx +76 -0
  128. package/src/components/navigation/date-format-picker.tsx +55 -0
  129. package/src/components/navigation/dropdown-menu.tsx +190 -0
  130. package/src/components/navigation/filter-bar.tsx +38 -0
  131. package/src/components/navigation/index.ts +28 -0
  132. package/src/components/navigation/locale-picker.tsx +49 -0
  133. package/src/components/navigation/page-header.tsx +50 -0
  134. package/src/components/navigation/pagination-utils.ts +35 -0
  135. package/src/components/navigation/pagination.tsx +168 -0
  136. package/src/components/navigation/steps.tsx +163 -0
  137. package/src/components/navigation/tabs-items.tsx +69 -0
  138. package/src/components/navigation/tabs.tsx +67 -0
  139. package/src/components/navigation/time-format-picker.tsx +55 -0
  140. package/src/components/navigation/timezone-picker.tsx +63 -0
  141. package/src/components/query/__tests__/data-state.test.tsx +214 -0
  142. package/src/components/query/__tests__/infinite-prefetch.test.tsx +105 -0
  143. package/src/components/query/__tests__/query-helpers.test.tsx +61 -0
  144. package/src/components/query/data-state.tsx +58 -0
  145. package/src/components/query/index.ts +10 -0
  146. package/src/components/query/infinite-query-state.tsx +99 -0
  147. package/src/components/query/mutation-feedback.tsx +31 -0
  148. package/src/components/query/prefetch-link.tsx +45 -0
  149. package/src/components/query/query-refetch-button.tsx +41 -0
  150. package/src/components/ui/alert-dialog.tsx +1 -0
  151. package/src/components/ui/alert.tsx +1 -0
  152. package/src/components/ui/autocomplete.tsx +1 -0
  153. package/src/components/ui/badge.tsx +1 -0
  154. package/src/components/ui/button.tsx +1 -0
  155. package/src/components/ui/calendar.tsx +1 -0
  156. package/src/components/ui/card.tsx +1 -0
  157. package/src/components/ui/checkbox.tsx +1 -0
  158. package/src/components/ui/color-picker.tsx +1 -0
  159. package/src/components/ui/command.tsx +1 -0
  160. package/src/components/ui/date-picker.tsx +1 -0
  161. package/src/components/ui/date-range-picker.tsx +1 -0
  162. package/src/components/ui/dialog.tsx +1 -0
  163. package/src/components/ui/dropdown-menu.tsx +1 -0
  164. package/src/components/ui/index.tsx +31 -0
  165. package/src/components/ui/input.tsx +1 -0
  166. package/src/components/ui/label.tsx +1 -0
  167. package/src/components/ui/pagination.tsx +1 -0
  168. package/src/components/ui/popover.tsx +1 -0
  169. package/src/components/ui/radio.tsx +1 -0
  170. package/src/components/ui/scroll-area.tsx +1 -0
  171. package/src/components/ui/select.tsx +1 -0
  172. package/src/components/ui/sheet.tsx +1 -0
  173. package/src/components/ui/slider.tsx +1 -0
  174. package/src/components/ui/sonner.tsx +1 -0
  175. package/src/components/ui/switch.tsx +1 -0
  176. package/src/components/ui/table.tsx +1 -0
  177. package/src/components/ui/tabs-items.tsx +1 -0
  178. package/src/components/ui/tabs.tsx +1 -0
  179. package/src/components/ui/textarea.tsx +1 -0
  180. package/src/components/ui/time-picker.tsx +1 -0
  181. package/src/components/ui/upload.tsx +1 -0
  182. package/src/form/__tests__/use-zod-form.test.tsx +97 -0
  183. package/src/form/form-field-control.tsx +44 -0
  184. package/src/form/form-root.tsx +29 -0
  185. package/src/form/index.ts +7 -0
  186. package/src/form/use-zod-form.ts +29 -0
  187. package/src/i18n/__tests__/translate.test.ts +23 -0
  188. package/src/i18n/index.ts +9 -0
  189. package/src/i18n/messages/en.json +171 -0
  190. package/src/i18n/messages/ja.json +171 -0
  191. package/src/i18n/messages/vi.json +171 -0
  192. package/src/i18n/translate.ts +74 -0
  193. package/src/i18n/use-translation.ts +53 -0
  194. package/src/index.ts +3 -0
  195. package/src/lib/__tests__/control-styles.test.ts +78 -0
  196. package/src/lib/__tests__/datetime.test.ts +77 -0
  197. package/src/lib/__tests__/format-date.test.ts +97 -0
  198. package/src/lib/__tests__/format.test.ts +62 -0
  199. package/src/lib/__tests__/theme-tokens-audit.test.ts +176 -0
  200. package/src/lib/__tests__/theme-tokens-css.test.ts +118 -0
  201. package/src/lib/__tests__/token-governance.test.ts +191 -0
  202. package/src/lib/__tests__/variants.test.ts +18 -0
  203. package/src/lib/control-styles.ts +33 -0
  204. package/src/lib/datetime/detect.ts +25 -0
  205. package/src/lib/datetime/format-date.ts +100 -0
  206. package/src/lib/datetime/format.ts +140 -0
  207. package/src/lib/datetime/index.ts +25 -0
  208. package/src/lib/datetime/parse.ts +51 -0
  209. package/src/lib/datetime/sync.ts +48 -0
  210. package/src/lib/format.ts +114 -0
  211. package/src/lib/hooks.ts +54 -0
  212. package/src/lib/utils.ts +6 -0
  213. package/src/lib/variants.ts +40 -0
  214. package/src/props/components/app.prop.ts +99 -0
  215. package/src/props/components/data-display.prop.ts +73 -0
  216. package/src/props/components/data-entry.prop.ts +334 -0
  217. package/src/props/components/feedback.prop.ts +80 -0
  218. package/src/props/components/form.prop.ts +46 -0
  219. package/src/props/components/general.prop.ts +18 -0
  220. package/src/props/components/index.ts +99 -0
  221. package/src/props/components/layout.prop.ts +130 -0
  222. package/src/props/components/navigation.prop.ts +88 -0
  223. package/src/props/components/query.prop.ts +94 -0
  224. package/src/props/index.ts +17 -0
  225. package/src/props/registry.ts +603 -0
  226. package/src/props/vocabulary/content.prop.ts +35 -0
  227. package/src/props/vocabulary/data.prop.ts +46 -0
  228. package/src/props/vocabulary/index.ts +73 -0
  229. package/src/props/vocabulary/interaction.prop.ts +42 -0
  230. package/src/props/vocabulary/layout.prop.ts +25 -0
  231. package/src/props/vocabulary/navigation.prop.ts +19 -0
  232. package/src/props/vocabulary/shared.prop.ts +59 -0
  233. package/src/styles/alert-layout.css +191 -0
  234. package/src/styles/badge-layout.css +22 -0
  235. package/src/styles/card-layout.css +373 -0
  236. package/src/styles/control.css +504 -0
  237. package/src/styles/data-display-layout.css +246 -0
  238. package/src/styles/density.css +43 -0
  239. package/src/styles/dialog-layout.css +84 -0
  240. package/src/styles/index.css +105 -0
  241. package/src/styles/layout.css +479 -0
  242. package/src/styles/shell-layout.css +604 -0
  243. package/src/styles/table-layout.css +109 -0
  244. package/src/test/__tests__/render-loop-guard.test.tsx +38 -0
  245. package/src/test/jest-dom.d.ts +4 -0
  246. package/src/test/render-loop-guard.tsx +50 -0
  247. package/src/test/render.tsx +29 -0
  248. package/src/test/theme-globals.test.ts +77 -0
  249. package/src/test/theme-globals.ts +134 -0
  250. package/src/test/theme-test-utils.tsx +67 -0
  251. package/src/theme/example.service.css +37 -0
  252. package/src/tokens/base.css +13 -0
  253. package/src/tokens/foundation.css +151 -0
  254. package/src/tokens/primitives/badge.css +13 -0
  255. package/src/tokens/primitives/card.css +29 -0
  256. package/src/tokens/primitives/control.css +55 -0
  257. package/src/tokens/primitives/feedback.css +17 -0
  258. package/src/tokens/primitives/layout.css +20 -0
  259. package/src/tokens/primitives/navigation.css +13 -0
  260. package/src/tokens/primitives/table.css +10 -0
  261. package/BRAND.md +0 -296
  262. package/CHANGELOG.md +0 -668
  263. package/config/eslint.js +0 -54
  264. package/config/prettier.cjs +0 -20
  265. package/config/tsconfig.base.json +0 -22
  266. package/config/vitest.base.ts +0 -26
  267. package/dist/MiniMonth-YAmPGEpC.d.ts +0 -143
  268. package/dist/Table.types-BbsxoIYE.d.ts +0 -352
  269. package/dist/color-DO0qqUAb.d.ts +0 -38
  270. package/dist/components/composites.d.ts +0 -963
  271. package/dist/components/composites.js +0 -7343
  272. package/dist/components/composites.js.map +0 -1
  273. package/dist/components/primitives.d.ts +0 -2744
  274. package/dist/components/primitives.js +0 -7356
  275. package/dist/components/primitives.js.map +0 -1
  276. package/dist/components/shell.d.ts +0 -182
  277. package/dist/components/shell.js +0 -774
  278. package/dist/components/shell.js.map +0 -1
  279. package/dist/hooks.d.ts +0 -100
  280. package/dist/hooks.js +0 -558
  281. package/dist/hooks.js.map +0 -1
  282. package/dist/i18n.d.ts +0 -61
  283. package/dist/i18n.js +0 -860
  284. package/dist/i18n.js.map +0 -1
  285. package/dist/index.d.ts +0 -33
  286. package/dist/index.js +0 -13062
  287. package/dist/index.js.map +0 -1
  288. package/dist/padding-DY0JV5Ja.d.ts +0 -16
  289. package/dist/preferences.d.ts +0 -132
  290. package/dist/preferences.js +0 -262
  291. package/dist/preferences.js.map +0 -1
  292. package/dist/props.d.ts +0 -86
  293. package/dist/props.js +0 -16
  294. package/dist/props.js.map +0 -1
  295. package/dist/size-CQwNvOWd.d.ts +0 -19
  296. package/dist/types-LTj-2bl-.d.ts +0 -30
  297. package/dist/useTableViews-D5NIAJ7h.d.ts +0 -154
  298. package/src/tokens/tailwind.css +0 -158
@@ -0,0 +1,214 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import * as React from "react";
3
+ import { QueryClient, QueryClientProvider, useQuery } from "@tanstack/react-query";
4
+ import type { UseQueryResult } from "@tanstack/react-query";
5
+ import { renderWithUi, screen, userEvent, waitFor } from "@/test/render";
6
+ import { DataState } from "../data-state";
7
+
8
+ type ShipmentList = { items: number[] };
9
+
10
+ function mockQuery<T>(partial: Partial<UseQueryResult<T>>): UseQueryResult<T> {
11
+ return partial as UseQueryResult<T>;
12
+ }
13
+
14
+ function withQueryClient(ui: React.ReactElement, client: QueryClient) {
15
+ return <QueryClientProvider client={client}>{ui}</QueryClientProvider>;
16
+ }
17
+
18
+ function EmptyWarehouseHarness() {
19
+ const query = useQuery<ShipmentList>({
20
+ queryKey: ["data-state-test", "empty"],
21
+ queryFn: () => Promise.resolve({ items: [] }),
22
+ });
23
+ return (
24
+ <DataState
25
+ query={query}
26
+ skeleton={<div data-testid="hawb-skeleton">loading</div>}
27
+ empty={<div>Không có kiện chờ gom</div>}
28
+ isEmpty={(data) => data.items.length === 0}
29
+ >
30
+ {(data) => <div data-testid="hawb-list">{data.items.length} kiện</div>}
31
+ </DataState>
32
+ );
33
+ }
34
+
35
+ function LoadedWarehouseHarness() {
36
+ const query = useQuery<ShipmentList>({
37
+ queryKey: ["data-state-test", "loaded"],
38
+ queryFn: () => Promise.resolve({ items: [1, 2] }),
39
+ });
40
+ return (
41
+ <DataState
42
+ query={query}
43
+ skeleton={<div data-testid="hawb-skeleton">loading</div>}
44
+ empty={<div>Không có kiện chờ gom</div>}
45
+ isEmpty={(data) => data.items.length === 0}
46
+ >
47
+ {(data) => <div data-testid="hawb-list">{data.items.length} kiện</div>}
48
+ </DataState>
49
+ );
50
+ }
51
+
52
+ describe("DataState", () => {
53
+ it("shows skeleton while pending", () => {
54
+ renderWithUi(
55
+ <DataState
56
+ query={mockQuery({ isPending: true, isError: false })}
57
+ skeleton={<div data-testid="skel">loading</div>}
58
+ >
59
+ {() => <div>data</div>}
60
+ </DataState>,
61
+ );
62
+ expect(screen.getByTestId("skel")).toBeInTheDocument();
63
+ });
64
+
65
+ it("renders children when data loaded", () => {
66
+ renderWithUi(
67
+ <DataState
68
+ query={mockQuery({ isPending: false, isError: false, data: { items: [1] } })}
69
+ skeleton={<div>loading</div>}
70
+ empty={<div>empty</div>}
71
+ >
72
+ {(data) => <div>count:{data.items.length}</div>}
73
+ </DataState>,
74
+ );
75
+ expect(screen.getByText("count:1")).toBeInTheDocument();
76
+ });
77
+
78
+ it("shows empty state when isEmpty", () => {
79
+ renderWithUi(
80
+ <DataState
81
+ query={mockQuery({ isPending: false, isError: false, data: { items: [] } })}
82
+ skeleton={<div>loading</div>}
83
+ empty={<div>No items</div>}
84
+ >
85
+ {() => <div>data</div>}
86
+ </DataState>,
87
+ );
88
+ expect(screen.getByText("No items")).toBeInTheDocument();
89
+ });
90
+
91
+ it("renders Alert.QueryError and refetches on retry when query errors", async () => {
92
+ const user = userEvent.setup();
93
+ const refetch = vi.fn();
94
+ renderWithUi(
95
+ <DataState
96
+ query={mockQuery({
97
+ isPending: false,
98
+ isError: true,
99
+ isFetching: false,
100
+ error: new Error("GET /v1/customers failed: 503"),
101
+ refetch,
102
+ })}
103
+ skeleton={<div data-testid="skel">loading</div>}
104
+ >
105
+ {() => <div>data</div>}
106
+ </DataState>,
107
+ );
108
+ expect(screen.getByRole("alert")).toBeInTheDocument();
109
+ expect(screen.getByText(/503/)).toBeInTheDocument();
110
+ await user.click(screen.getByRole("button", { name: /thử lại/i }));
111
+ expect(refetch).toHaveBeenCalledOnce();
112
+ });
113
+
114
+ it("shows skeleton while retrying after error", () => {
115
+ renderWithUi(
116
+ <DataState
117
+ query={mockQuery({
118
+ isPending: false,
119
+ isError: true,
120
+ isFetching: true,
121
+ error: new Error("503"),
122
+ })}
123
+ skeleton={<div data-testid="retry-skel">loading</div>}
124
+ >
125
+ {() => <div>data</div>}
126
+ </DataState>,
127
+ );
128
+ expect(screen.getByTestId("retry-skel")).toBeInTheDocument();
129
+ expect(screen.queryByRole("alert")).not.toBeInTheDocument();
130
+ });
131
+
132
+ it("omits retry when showRetry is false", () => {
133
+ renderWithUi(
134
+ <DataState
135
+ query={mockQuery({
136
+ isPending: false,
137
+ isError: true,
138
+ isFetching: false,
139
+ error: new Error("403"),
140
+ })}
141
+ skeleton={<div>loading</div>}
142
+ showRetry={false}
143
+ >
144
+ {() => <div>data</div>}
145
+ </DataState>,
146
+ );
147
+ expect(screen.getByRole("alert")).toBeInTheDocument();
148
+ expect(screen.queryByRole("button", { name: /thử lại/i })).not.toBeInTheDocument();
149
+ });
150
+
151
+ it("calls onRetry override instead of refetch", async () => {
152
+ const user = userEvent.setup();
153
+ const onRetry = vi.fn();
154
+ const refetch = vi.fn();
155
+ renderWithUi(
156
+ <DataState
157
+ query={mockQuery({
158
+ isPending: false,
159
+ isError: true,
160
+ isFetching: false,
161
+ error: new Error("fail"),
162
+ refetch,
163
+ })}
164
+ skeleton={<div>loading</div>}
165
+ onRetry={onRetry}
166
+ >
167
+ {() => <div>data</div>}
168
+ </DataState>,
169
+ );
170
+ await user.click(screen.getByRole("button", { name: /thử lại/i }));
171
+ expect(onRetry).toHaveBeenCalledOnce();
172
+ expect(refetch).not.toHaveBeenCalled();
173
+ });
174
+
175
+ it("uses custom errorRenderer when provided", () => {
176
+ renderWithUi(
177
+ <DataState
178
+ query={mockQuery({
179
+ isPending: false,
180
+ isError: true,
181
+ isFetching: false,
182
+ error: new Error("fail"),
183
+ })}
184
+ skeleton={<div>loading</div>}
185
+ errorRenderer={(err) => <div data-testid="custom-err">{String(err)}</div>}
186
+ >
187
+ {() => <div>data</div>}
188
+ </DataState>,
189
+ );
190
+ expect(screen.getByTestId("custom-err")).toHaveTextContent("fail");
191
+ });
192
+ });
193
+
194
+ describe("DataState integration (useQuery)", () => {
195
+ it("shows empty warehouse after query resolves", async () => {
196
+ const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
197
+ renderWithUi(withQueryClient(<EmptyWarehouseHarness />, client));
198
+
199
+ expect(screen.getByTestId("hawb-skeleton")).toBeInTheDocument();
200
+ await waitFor(() => {
201
+ expect(screen.getByText("Không có kiện chờ gom")).toBeInTheDocument();
202
+ });
203
+ expect(screen.queryByTestId("hawb-list")).not.toBeInTheDocument();
204
+ });
205
+
206
+ it("shows list when warehouse has shipments", async () => {
207
+ const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
208
+ renderWithUi(withQueryClient(<LoadedWarehouseHarness />, client));
209
+
210
+ await waitFor(() => {
211
+ expect(screen.getByTestId("hawb-list")).toHaveTextContent("2 kiện");
212
+ });
213
+ });
214
+ });
@@ -0,0 +1,105 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import type { InfiniteData, UseInfiniteQueryResult } from "@tanstack/react-query";
3
+ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
4
+ import { renderWithUi, screen, userEvent, waitFor } from "@/test/render";
5
+ import { InfiniteQueryState, flattenItemPages } from "../infinite-query-state";
6
+ import { PrefetchLink } from "../prefetch-link";
7
+
8
+ type Page = { items: string[]; next_cursor?: string };
9
+
10
+ function mockInfiniteQuery(partial: Record<string, unknown>) {
11
+ return partial as unknown as UseInfiniteQueryResult<InfiniteData<Page>, Error>;
12
+ }
13
+
14
+ describe("flattenItemPages", () => {
15
+ it("concatenates items across pages", () => {
16
+ expect(
17
+ flattenItemPages({
18
+ pages: [{ items: ["a"] }, { items: ["b", "c"] }],
19
+ }),
20
+ ).toEqual(["a", "b", "c"]);
21
+ });
22
+ });
23
+
24
+ describe("InfiniteQueryState", () => {
25
+ it("shows skeleton while pending", () => {
26
+ renderWithUi(
27
+ <InfiniteQueryState<Page, string[]>
28
+ query={mockInfiniteQuery({ isPending: true, isError: false })}
29
+ skeleton={<div data-testid="skel">loading</div>}
30
+ flatten={(d) => flattenItemPages(d)}
31
+ >
32
+ {() => null}
33
+ </InfiniteQueryState>,
34
+ );
35
+ expect(screen.getByTestId("skel")).toBeInTheDocument();
36
+ });
37
+
38
+ it("renders flattened items and load more", async () => {
39
+ const user = userEvent.setup();
40
+ const fetchNextPage = vi.fn();
41
+ renderWithUi(
42
+ <InfiniteQueryState<Page, string[]>
43
+ query={mockInfiniteQuery({
44
+ isPending: false,
45
+ isError: false,
46
+ isFetchingNextPage: false,
47
+ data: { pages: [{ items: ["note-1"] }], pageParams: [undefined] },
48
+ hasNextPage: true,
49
+ fetchNextPage,
50
+ })}
51
+ skeleton={<div>loading</div>}
52
+ flatten={(d) => flattenItemPages(d)}
53
+ >
54
+ {(items) => (
55
+ <ul>
56
+ {items.map((id) => (
57
+ <li key={id}>{id}</li>
58
+ ))}
59
+ </ul>
60
+ )}
61
+ </InfiniteQueryState>,
62
+ );
63
+ expect(screen.getByText("note-1")).toBeInTheDocument();
64
+ await user.click(screen.getByRole("button", { name: /tải thêm/i }));
65
+ expect(fetchNextPage).toHaveBeenCalledOnce();
66
+ });
67
+
68
+ it("shows empty state", () => {
69
+ renderWithUi(
70
+ <InfiniteQueryState<Page, string[]>
71
+ query={mockInfiniteQuery({
72
+ isPending: false,
73
+ isError: false,
74
+ data: { pages: [{ items: [] }], pageParams: [undefined] },
75
+ hasNextPage: false,
76
+ })}
77
+ skeleton={<div>loading</div>}
78
+ empty={<div>No notes</div>}
79
+ flatten={(d) => flattenItemPages(d)}
80
+ >
81
+ {() => null}
82
+ </InfiniteQueryState>,
83
+ );
84
+ expect(screen.getByText("No notes")).toBeInTheDocument();
85
+ });
86
+ });
87
+
88
+ describe("PrefetchLink", () => {
89
+ it("prefetches on hover", async () => {
90
+ const user = userEvent.setup();
91
+ const queryFn = vi.fn(() => Promise.resolve({ id: "cust_1" }));
92
+ const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
93
+
94
+ renderWithUi(
95
+ <QueryClientProvider client={client}>
96
+ <PrefetchLink to="/customers/cust_1" queryKey={["customer", "cust_1"]} queryFn={queryFn}>
97
+ Mai Nguyen
98
+ </PrefetchLink>
99
+ </QueryClientProvider>,
100
+ );
101
+
102
+ await user.hover(screen.getByRole("link", { name: "Mai Nguyen" }));
103
+ await waitFor(() => expect(queryFn).toHaveBeenCalledOnce());
104
+ });
105
+ });
@@ -0,0 +1,61 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { renderWithUi, screen, userEvent } from "@/test/render";
3
+ import { MutationFeedback } from "../mutation-feedback";
4
+ import { QueryRefetchButton } from "../query-refetch-button";
5
+
6
+ describe("MutationFeedback", () => {
7
+ it("renders nothing when idle", () => {
8
+ const { container } = renderWithUi(
9
+ <MutationFeedback mutation={{ isError: false, error: null, isPending: false }} />,
10
+ );
11
+ expect(container).toBeEmptyDOMElement();
12
+ });
13
+
14
+ it("shows Alert.QueryError when mutation failed", () => {
15
+ renderWithUi(
16
+ <MutationFeedback
17
+ mutation={{ isError: true, error: new Error("save failed"), isPending: false }}
18
+ onRetry={() => undefined}
19
+ />,
20
+ );
21
+ expect(screen.getByRole("alert")).toBeInTheDocument();
22
+ expect(screen.getByText(/save failed/)).toBeInTheDocument();
23
+ });
24
+
25
+ it("shows pending slot while running", () => {
26
+ renderWithUi(
27
+ <MutationFeedback
28
+ mutation={{ isError: false, error: null, isPending: true }}
29
+ pending={<div data-testid="pending">Running…</div>}
30
+ />,
31
+ );
32
+ expect(screen.getByTestId("pending")).toBeInTheDocument();
33
+ });
34
+
35
+ it("hides retry when showRetry is false", () => {
36
+ renderWithUi(
37
+ <MutationFeedback
38
+ mutation={{ isError: true, error: new Error("403"), isPending: false }}
39
+ showRetry={false}
40
+ />,
41
+ );
42
+ expect(screen.queryByRole("button", { name: /thử lại/i })).not.toBeInTheDocument();
43
+ });
44
+ });
45
+
46
+ describe("QueryRefetchButton", () => {
47
+ it("calls refetch on click", async () => {
48
+ const user = userEvent.setup();
49
+ const refetch = vi.fn();
50
+ renderWithUi(
51
+ <QueryRefetchButton query={{ isFetching: false, refetch }} label="Refresh list" />,
52
+ );
53
+ await user.click(screen.getByRole("button", { name: /refresh list/i }));
54
+ expect(refetch).toHaveBeenCalledOnce();
55
+ });
56
+
57
+ it("disables while fetching", () => {
58
+ renderWithUi(<QueryRefetchButton query={{ isFetching: true, refetch: vi.fn() }} />);
59
+ expect(screen.getByRole("button")).toBeDisabled();
60
+ });
61
+ });
@@ -0,0 +1,58 @@
1
+ import * as React from "react";
2
+
3
+ import { AlertQueryError } from "../feedback/alert";
4
+ import type { DataStateProp } from "../../props/components/query.prop";
5
+
6
+ export type {
7
+ DataStateProp,
8
+ DataStateProp as DataStateProps,
9
+ } from "../../props/components/query.prop";
10
+
11
+ function defaultIsEmpty(data: unknown): boolean {
12
+ if (!data) return true;
13
+ if (Array.isArray(data)) return data.length === 0;
14
+ if (typeof data === "object" && data !== null) {
15
+ const obj = data as { items?: unknown[]; length?: number };
16
+ if (Array.isArray(obj.items)) return obj.items.length === 0;
17
+ if (typeof obj.length === "number") return obj.length === 0;
18
+ }
19
+ return false;
20
+ }
21
+
22
+ /**
23
+ * Query lifecycle widget — orchestrates skeleton / error / empty / success for one `useQuery` block.
24
+ * Not a visual component; prefer `@godxjp/ui/query`. Apps may also import via `@godxjp/ui/admin`.
25
+ */
26
+ export function DataState<T>({
27
+ query,
28
+ skeleton,
29
+ empty,
30
+ isEmpty = defaultIsEmpty,
31
+ errorRenderer,
32
+ showRetry = true,
33
+ onRetry,
34
+ children,
35
+ }: DataStateProp<T>) {
36
+ const retry = React.useCallback(() => {
37
+ if (onRetry) {
38
+ void onRetry();
39
+ return;
40
+ }
41
+ void query.refetch();
42
+ }, [onRetry, query]);
43
+
44
+ if (query.isPending) return <>{skeleton}</>;
45
+
46
+ if (query.isError) {
47
+ if (query.isFetching) return <>{skeleton}</>;
48
+ if (errorRenderer) return <>{errorRenderer(query.error, retry)}</>;
49
+ return <AlertQueryError error={query.error} onRetry={showRetry ? retry : undefined} />;
50
+ }
51
+
52
+ const data = query.data;
53
+ if (data === undefined) return <>{skeleton}</>;
54
+
55
+ if (empty && (data === null || isEmpty(data))) return <>{empty}</>;
56
+
57
+ return <>{children(data as NonNullable<T>)}</>;
58
+ }
@@ -0,0 +1,10 @@
1
+ export { DataState } from "./data-state";
2
+ export type { DataStateProp, DataStateProps } from "./data-state";
3
+ export { MutationFeedback } from "./mutation-feedback";
4
+ export type { MutationFeedbackProp, MutationFeedbackProps } from "./mutation-feedback";
5
+ export { QueryRefetchButton } from "./query-refetch-button";
6
+ export type { QueryRefetchButtonProp, QueryRefetchButtonProps } from "./query-refetch-button";
7
+ export { InfiniteQueryState, flattenItemPages } from "./infinite-query-state";
8
+ export type { InfiniteQueryStateProp, InfiniteQueryStateProps } from "./infinite-query-state";
9
+ export { PrefetchLink } from "./prefetch-link";
10
+ export type { PrefetchLinkProp, PrefetchLinkProps } from "./prefetch-link";
@@ -0,0 +1,99 @@
1
+ import * as React from "react";
2
+
3
+ import { AlertQueryError } from "../feedback/alert";
4
+ import { Button } from "../general/button";
5
+ import { useTranslation } from "../../i18n/use-translation";
6
+ import type { InfiniteQueryStateProp } from "../../props/components/query.prop";
7
+
8
+ export type {
9
+ InfiniteQueryStateProp,
10
+ InfiniteQueryStateProp as InfiniteQueryStateProps,
11
+ } from "../../props/components/query.prop";
12
+
13
+ /** Flatten `{ pages: [{ items }] }` — default GODX paginated API shape. */
14
+ export function flattenItemPages<TItem, TPage extends { items: TItem[] }>(
15
+ data: { pages: TPage[] } | undefined,
16
+ ): TItem[] {
17
+ if (!data) return [];
18
+ return data.pages.flatMap((page) => page.items);
19
+ }
20
+
21
+ function defaultIsEmptyFlat(flat: unknown): boolean {
22
+ if (Array.isArray(flat)) return flat.length === 0;
23
+ return !flat;
24
+ }
25
+
26
+ /**
27
+ * `useInfiniteQuery` lifecycle widget — flatten pages, load-more footer.
28
+ * Cursor / activity feeds where user accumulates pages (vs DataTable cursor buttons).
29
+ */
30
+ export function InfiniteQueryState<TPage, TFlat>({
31
+ query,
32
+ skeleton,
33
+ empty,
34
+ flatten,
35
+ isEmpty = defaultIsEmptyFlat,
36
+ errorRenderer,
37
+ showRetry = true,
38
+ onRetry,
39
+ loadingMore,
40
+ loadMore,
41
+ showLoadMore = true,
42
+ children,
43
+ }: InfiniteQueryStateProp<TPage, TFlat>) {
44
+ const { t } = useTranslation();
45
+
46
+ const retry = React.useCallback(() => {
47
+ if (onRetry) {
48
+ void onRetry();
49
+ return;
50
+ }
51
+ void query.refetch();
52
+ }, [onRetry, query]);
53
+
54
+ if (query.isPending) return <>{skeleton}</>;
55
+
56
+ if (query.isError) {
57
+ if (query.isFetching && !query.isFetchingNextPage) return <>{skeleton}</>;
58
+ if (errorRenderer) return <>{errorRenderer(query.error, retry)}</>;
59
+ return <AlertQueryError error={query.error} onRetry={showRetry ? retry : undefined} />;
60
+ }
61
+
62
+ const data = query.data;
63
+ if (!data) return <>{skeleton}</>;
64
+
65
+ const flat = flatten(data);
66
+ if (empty && isEmpty(flat)) return <>{empty}</>;
67
+
68
+ const footer =
69
+ showLoadMore && query.hasNextPage
70
+ ? (loadMore ?? (
71
+ <div className="flex justify-center pt-4">
72
+ <Button
73
+ type="button"
74
+ variant="outline"
75
+ size="sm"
76
+ disabled={query.isFetchingNextPage}
77
+ onClick={() => void query.fetchNextPage()}
78
+ >
79
+ {query.isFetchingNextPage ? t("common.working") : t("query.loadMore")}
80
+ </Button>
81
+ </div>
82
+ ))
83
+ : null;
84
+
85
+ return (
86
+ <>
87
+ {children(flat, {
88
+ fetchNextPage: () => void query.fetchNextPage(),
89
+ hasNextPage: !!query.hasNextPage,
90
+ isFetchingNextPage: query.isFetchingNextPage,
91
+ })}
92
+ {query.isFetchingNextPage &&
93
+ (loadingMore ?? (
94
+ <p className="text-muted-foreground pt-2 text-center text-xs">{t("common.working")}</p>
95
+ ))}
96
+ {footer}
97
+ </>
98
+ );
99
+ }
@@ -0,0 +1,31 @@
1
+ import { AlertQueryError } from "../feedback/alert";
2
+ import type { MutationFeedbackProp } from "../../props/components/query.prop";
3
+
4
+ export type {
5
+ MutationFeedbackProp,
6
+ MutationFeedbackProp as MutationFeedbackProps,
7
+ } from "../../props/components/query.prop";
8
+
9
+ /**
10
+ * Inline mutation error — renders nothing when idle/success.
11
+ * Prefer toast for transient saves; use this for blocking form sections (SimulatorPage).
12
+ */
13
+ export function MutationFeedback({
14
+ mutation,
15
+ onRetry,
16
+ showRetry = true,
17
+ pending,
18
+ className,
19
+ }: MutationFeedbackProp) {
20
+ if (mutation.isPending && pending) return <>{pending}</>;
21
+
22
+ if (!mutation.isError || mutation.error == null) return null;
23
+
24
+ return (
25
+ <AlertQueryError
26
+ className={className}
27
+ error={mutation.error}
28
+ onRetry={showRetry ? onRetry : undefined}
29
+ />
30
+ );
31
+ }
@@ -0,0 +1,45 @@
1
+ import * as React from "react";
2
+ import { Link } from "react-router-dom";
3
+ import { useQueryClient } from "@tanstack/react-query";
4
+
5
+ import type { PrefetchLinkProp } from "../../props/components/query.prop";
6
+
7
+ export type {
8
+ PrefetchLinkProp,
9
+ PrefetchLinkProp as PrefetchLinkProps,
10
+ } from "../../props/components/query.prop";
11
+
12
+ /**
13
+ * React Router `Link` + `queryClient.prefetchQuery` on hover/focus.
14
+ * Detail routes feel instant when list rows are hovered.
15
+ */
16
+ export function PrefetchLink({
17
+ queryKey,
18
+ queryFn,
19
+ prefetchOn = "both",
20
+ staleTime = 30_000,
21
+ onMouseEnter,
22
+ onFocus,
23
+ ...linkProps
24
+ }: PrefetchLinkProp) {
25
+ const queryClient = useQueryClient();
26
+
27
+ const prefetch = React.useCallback(() => {
28
+ if (prefetchOn === "none") return;
29
+ void queryClient.prefetchQuery({ queryKey, queryFn, staleTime });
30
+ }, [prefetchOn, queryClient, queryFn, queryKey, staleTime]);
31
+
32
+ return (
33
+ <Link
34
+ {...linkProps}
35
+ onMouseEnter={(event) => {
36
+ if (prefetchOn === "hover" || prefetchOn === "both") prefetch();
37
+ onMouseEnter?.(event);
38
+ }}
39
+ onFocus={(event) => {
40
+ if (prefetchOn === "focus" || prefetchOn === "both") prefetch();
41
+ onFocus?.(event);
42
+ }}
43
+ />
44
+ );
45
+ }
@@ -0,0 +1,41 @@
1
+ import { RefreshCw } from "lucide-react";
2
+
3
+ import { Button } from "../general/button";
4
+ import type { QueryRefetchButtonProp } from "../../props/components/query.prop";
5
+
6
+ export type {
7
+ QueryRefetchButtonProp,
8
+ QueryRefetchButtonProp as QueryRefetchButtonProps,
9
+ } from "../../props/components/query.prop";
10
+
11
+ /** Page-header Refresh — spins while `query.isFetching`, calls `query.refetch()`. */
12
+ export function QueryRefetchButton({
13
+ query,
14
+ label = "Refresh",
15
+ children,
16
+ variant = "outline",
17
+ size = "sm",
18
+ className,
19
+ ...props
20
+ }: QueryRefetchButtonProp) {
21
+ const text = children ?? label;
22
+
23
+ return (
24
+ <Button
25
+ type="button"
26
+ variant={variant}
27
+ size={size}
28
+ className={className}
29
+ onClick={() => void query.refetch()}
30
+ disabled={query.isFetching}
31
+ {...props}
32
+ >
33
+ <RefreshCw
34
+ className="ui-query-refetch-icon"
35
+ data-fetching={query.isFetching}
36
+ aria-hidden="true"
37
+ />
38
+ {text}
39
+ </Button>
40
+ );
41
+ }
@@ -0,0 +1 @@
1
+ export * from "../feedback/dialog";
@@ -0,0 +1 @@
1
+ export * from "../feedback/alert";
@@ -0,0 +1 @@
1
+ export * from "../data-entry/autocomplete";
@@ -0,0 +1 @@
1
+ export * from "../data-display/badge";
@@ -0,0 +1 @@
1
+ export * from "../general/button";
@@ -0,0 +1 @@
1
+ export * from "../data-entry/calendar";