@ploykit/ui 0.5.0 → 0.6.2

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 (222) hide show
  1. package/dist/AppProviders.js +2 -2
  2. package/dist/components/ConfirmDialog.js +1 -1
  3. package/dist/components/CronInput.js +3 -3
  4. package/dist/components/DataTable.js +4 -4
  5. package/dist/components/FormField.js +1 -1
  6. package/dist/components/ImpersonationBanner.js +1 -1
  7. package/dist/components/NotificationBell.js +1 -1
  8. package/dist/components/OnboardingChecklist.js +3 -3
  9. package/dist/components/Page.js +2 -2
  10. package/dist/components/ReauthDialog.js +4 -4
  11. package/dist/components/SecretModal.js +2 -2
  12. package/dist/components/SettingsCard.js +2 -2
  13. package/dist/components/SiteBanner.js +1 -1
  14. package/dist/components/SiteBannerLayer.js +1 -1
  15. package/dist/components/StatusBadge.js +1 -1
  16. package/dist/components/UserMenu.js +3 -3
  17. package/dist/components/WorkspaceSwitcher.js +7 -7
  18. package/dist/components/ui/alert-dialog.js +2 -2
  19. package/dist/components/ui/avatar.js +1 -1
  20. package/dist/components/ui/badge.js +1 -1
  21. package/dist/components/ui/breadcrumb.js +1 -1
  22. package/dist/components/ui/button.js +1 -1
  23. package/dist/components/ui/card.js +1 -1
  24. package/dist/components/ui/checkbox.js +1 -1
  25. package/dist/components/ui/dialog.js +2 -2
  26. package/dist/components/ui/dropdown-menu.js +1 -1
  27. package/dist/components/ui/empty.js +1 -1
  28. package/dist/components/ui/field.js +3 -3
  29. package/dist/components/ui/input.js +1 -1
  30. package/dist/components/ui/label.js +1 -1
  31. package/dist/components/ui/popover.js +1 -1
  32. package/dist/components/ui/progress.js +1 -1
  33. package/dist/components/ui/select.js +1 -1
  34. package/dist/components/ui/separator.js +1 -1
  35. package/dist/components/ui/sheet.js +2 -2
  36. package/dist/components/ui/sidebar.js +8 -8
  37. package/dist/components/ui/skeleton.js +1 -1
  38. package/dist/components/ui/switch.js +1 -1
  39. package/dist/components/ui/tabs.js +1 -1
  40. package/dist/components/ui/textarea.js +1 -1
  41. package/dist/components/ui/tooltip.js +1 -1
  42. package/dist/index.js +78 -78
  43. package/dist/layouts/AppShell.js +4 -4
  44. package/dist/layouts/SettingsShell.js +4 -4
  45. package/dist/pages/LoginPage.js +5 -5
  46. package/dist/pages/account/NotificationPreferencesPage.js +3 -3
  47. package/dist/pages/account/ProfileSettingsPage.js +9 -9
  48. package/dist/pages/account/SecuritySettingsPage.js +13 -13
  49. package/dist/pages/account/TokensPage.js +12 -12
  50. package/dist/pages/admin/AdminSettingsPage.js +11 -11
  51. package/dist/pages/admin/AdminShell.js +3 -3
  52. package/dist/pages/admin/AuditPage.js +8 -8
  53. package/dist/pages/admin/OrdersPage.js +12 -12
  54. package/dist/pages/admin/OverviewPage.js +13 -13
  55. package/dist/pages/admin/SSOPage.js +6 -6
  56. package/dist/pages/admin/UserDetailPage.js +13 -13
  57. package/dist/pages/admin/UsersPage.js +13 -13
  58. package/dist/pages/admin/WebhookDeliveriesPage.js +9 -9
  59. package/dist/pages/admin/WorkspaceDetailPage.js +14 -14
  60. package/dist/pages/admin/WorkspacesPage.js +9 -9
  61. package/dist/pages/admin/shared.js +1 -1
  62. package/dist/pages/auth/ForgotPasswordPage.js +4 -4
  63. package/dist/pages/auth/InviteAcceptPage.js +6 -6
  64. package/dist/pages/auth/RegisterPage.js +7 -7
  65. package/dist/pages/auth/ResetPasswordPage.js +5 -5
  66. package/dist/pages/auth/VerifyEmailPage.js +3 -3
  67. package/dist/pages/workspace/BillingPage.js +11 -11
  68. package/dist/pages/workspace/BillingSuccessPage.js +2 -2
  69. package/dist/pages/workspace/MembersPage.js +15 -15
  70. package/dist/pages/workspace/RolePermissionsPage.js +9 -9
  71. package/dist/pages/workspace/SchedulesPage.js +12 -12
  72. package/dist/pages/workspace/UsagePage.js +6 -6
  73. package/dist/pages/workspace/WebhooksPage.js +16 -16
  74. package/dist/pages/workspace/WorkspaceAuditPage.js +7 -7
  75. package/dist/pages/workspace/WorkspaceGeneralPage.js +9 -9
  76. package/package.json +6 -5
  77. package/src/AppProviders.tsx +29 -0
  78. package/src/components/ConfirmDialog.tsx +77 -0
  79. package/src/components/CronInput.tsx +195 -0
  80. package/src/components/DataTable.tsx +152 -0
  81. package/src/components/ErrorBoundary.tsx +46 -0
  82. package/src/components/FormField.tsx +56 -0
  83. package/src/components/ImpersonationBanner.tsx +23 -0
  84. package/src/components/NotificationBell.tsx +135 -0
  85. package/src/components/OnboardingChecklist.tsx +114 -0
  86. package/src/components/Page.tsx +67 -0
  87. package/src/components/ReauthDialog.tsx +94 -0
  88. package/src/components/SecretModal.tsx +147 -0
  89. package/src/components/SettingsCard.tsx +39 -0
  90. package/src/components/SiteBanner.tsx +32 -0
  91. package/src/components/SiteBannerLayer.tsx +29 -0
  92. package/src/components/StatusBadge.tsx +12 -0
  93. package/src/components/UserMenu.tsx +103 -0
  94. package/src/components/WorkspaceSwitcher.tsx +159 -0
  95. package/src/components/__tests__/AppProviders.test.tsx +111 -0
  96. package/src/components/__tests__/CronInput.test.tsx +180 -0
  97. package/src/components/__tests__/DataTable.test.tsx +208 -0
  98. package/src/components/__tests__/ErrorBoundary.test.tsx +61 -0
  99. package/src/components/__tests__/ImpersonationBanner.test.tsx +59 -0
  100. package/src/components/__tests__/NotificationBell.test.tsx +207 -0
  101. package/src/components/__tests__/OnboardingChecklist.test.tsx +97 -0
  102. package/src/components/__tests__/SecretModal.test.tsx +112 -0
  103. package/src/components/__tests__/SiteBannerLayer.test.tsx +44 -0
  104. package/src/components/__tests__/UserMenu.test.tsx +98 -0
  105. package/src/components/__tests__/WorkspaceSwitcher.test.tsx +162 -0
  106. package/src/components/__tests__/form.test.tsx +133 -0
  107. package/src/components/__tests__/semantic-tokens.test.ts +41 -0
  108. package/src/components/__tests__/smoke.test.tsx +11 -0
  109. package/src/components/__tests__/toast.test.tsx +27 -0
  110. package/src/components/toast.tsx +23 -0
  111. package/src/components/ui/__tests__/alert-dialog.test.tsx +66 -0
  112. package/src/components/ui/__tests__/avatar.test.tsx +30 -0
  113. package/src/components/ui/__tests__/checkbox.test.tsx +18 -0
  114. package/src/components/ui/__tests__/dropdown-menu.test.tsx +33 -0
  115. package/src/components/ui/__tests__/field.test.tsx +43 -0
  116. package/src/components/ui/__tests__/overlay-size.test.tsx +84 -0
  117. package/src/components/ui/__tests__/popover.test.tsx +32 -0
  118. package/src/components/ui/__tests__/progress.test.tsx +24 -0
  119. package/src/components/ui/__tests__/select-callback.test.tsx +72 -0
  120. package/src/components/ui/__tests__/select.test.tsx +48 -0
  121. package/src/components/ui/__tests__/sidebar.test.tsx +121 -0
  122. package/src/components/ui/__tests__/stock-mirror.test.ts +39 -0
  123. package/src/components/ui/__tests__/subpath.test.tsx +38 -0
  124. package/src/components/ui/__tests__/switch.test.tsx +24 -0
  125. package/src/components/ui/__tests__/tabs.test.tsx +24 -0
  126. package/src/components/ui/__tests__/textarea.test.tsx +41 -0
  127. package/src/components/ui/alert-dialog.tsx +187 -0
  128. package/src/components/ui/avatar.tsx +108 -0
  129. package/src/components/ui/badge.tsx +51 -0
  130. package/src/components/ui/breadcrumb.tsx +124 -0
  131. package/src/components/ui/button.tsx +57 -0
  132. package/src/components/ui/card.tsx +102 -0
  133. package/src/components/ui/checkbox.tsx +28 -0
  134. package/src/components/ui/dialog.tsx +166 -0
  135. package/src/components/ui/dropdown-menu.tsx +265 -0
  136. package/src/components/ui/empty.tsx +103 -0
  137. package/src/components/ui/field.tsx +238 -0
  138. package/src/components/ui/input.tsx +19 -0
  139. package/src/components/ui/label.tsx +17 -0
  140. package/src/components/ui/popover.tsx +89 -0
  141. package/src/components/ui/progress.tsx +82 -0
  142. package/src/components/ui/select.tsx +235 -0
  143. package/src/components/ui/separator.tsx +24 -0
  144. package/src/components/ui/sheet.tsx +146 -0
  145. package/src/components/ui/sidebar.tsx +723 -0
  146. package/src/components/ui/skeleton.tsx +13 -0
  147. package/src/components/ui/switch.tsx +29 -0
  148. package/src/components/ui/tabs.tsx +79 -0
  149. package/src/components/ui/textarea.tsx +26 -0
  150. package/src/components/ui/tooltip.tsx +63 -0
  151. package/src/dev.css +1 -0
  152. package/src/hooks/use-mobile.ts +19 -0
  153. package/src/index.ts +150 -0
  154. package/src/layouts/AppShell.tsx +163 -0
  155. package/src/layouts/AuthShell.tsx +24 -0
  156. package/src/layouts/SettingsShell.tsx +129 -0
  157. package/src/layouts/__tests__/AppShell.test.tsx +163 -0
  158. package/src/layouts/__tests__/SettingsShell.test.tsx +146 -0
  159. package/src/lib/__tests__/api-error.test.ts +163 -0
  160. package/src/lib/__tests__/utils.test.ts +21 -0
  161. package/src/lib/api-error.ts +98 -0
  162. package/src/lib/utils.ts +109 -0
  163. package/src/pages/LandingPage.tsx +88 -0
  164. package/src/pages/LoginPage.tsx +291 -0
  165. package/src/pages/__tests__/LandingPage.test.tsx +58 -0
  166. package/src/pages/__tests__/LoginPage.test.tsx +229 -0
  167. package/src/pages/__tests__/RegisterPage.test.tsx +138 -0
  168. package/src/pages/__tests__/ssr-smoke.test.tsx +99 -0
  169. package/src/pages/account/NotificationPreferencesPage.tsx +138 -0
  170. package/src/pages/account/ProfileSettingsPage.tsx +189 -0
  171. package/src/pages/account/SecuritySettingsPage.tsx +214 -0
  172. package/src/pages/account/TokensPage.tsx +211 -0
  173. package/src/pages/account/__tests__/NotificationPreferencesPage.test.tsx +150 -0
  174. package/src/pages/account/__tests__/ProfileSettingsPage.test.tsx +173 -0
  175. package/src/pages/account/__tests__/SecuritySettingsPage.test.tsx +212 -0
  176. package/src/pages/account/__tests__/TokensPage.test.tsx +180 -0
  177. package/src/pages/admin/AdminSettingsPage.tsx +564 -0
  178. package/src/pages/admin/AdminShell.tsx +199 -0
  179. package/src/pages/admin/AuditPage.tsx +338 -0
  180. package/src/pages/admin/OrdersPage.tsx +642 -0
  181. package/src/pages/admin/OverviewPage.tsx +559 -0
  182. package/src/pages/admin/SSOPage.tsx +166 -0
  183. package/src/pages/admin/UserDetailPage.tsx +641 -0
  184. package/src/pages/admin/UsersPage.tsx +462 -0
  185. package/src/pages/admin/WebhookDeliveriesPage.tsx +318 -0
  186. package/src/pages/admin/WorkspaceDetailPage.tsx +551 -0
  187. package/src/pages/admin/WorkspacesPage.tsx +299 -0
  188. package/src/pages/admin/__tests__/AdminSettingsPage.test.tsx +260 -0
  189. package/src/pages/admin/__tests__/OrdersPageMarkPaid.test.tsx +207 -0
  190. package/src/pages/admin/__tests__/OverviewPageActivity.test.tsx +213 -0
  191. package/src/pages/admin/__tests__/SSOPage.test.tsx +128 -0
  192. package/src/pages/admin/__tests__/UsersPageActions.test.tsx +188 -0
  193. package/src/pages/admin/__tests__/WorkspaceDetailPageTransfer.test.tsx +170 -0
  194. package/src/pages/admin/__tests__/WorkspacesPagePlans.test.tsx +138 -0
  195. package/src/pages/admin/shared.ts +42 -0
  196. package/src/pages/auth/ForgotPasswordPage.tsx +83 -0
  197. package/src/pages/auth/InviteAcceptPage.tsx +105 -0
  198. package/src/pages/auth/RegisterPage.tsx +204 -0
  199. package/src/pages/auth/ResetPasswordPage.tsx +150 -0
  200. package/src/pages/auth/VerifyEmailPage.tsx +76 -0
  201. package/src/pages/auth/__tests__/ForgotPasswordPage.test.tsx +67 -0
  202. package/src/pages/auth/__tests__/InviteAcceptPage.test.tsx +86 -0
  203. package/src/pages/auth/__tests__/ResetPasswordPage.test.tsx +115 -0
  204. package/src/pages/auth/__tests__/VerifyEmailPage.test.tsx +74 -0
  205. package/src/pages/workspace/BillingPage.tsx +361 -0
  206. package/src/pages/workspace/BillingSuccessPage.tsx +115 -0
  207. package/src/pages/workspace/MembersPage.tsx +568 -0
  208. package/src/pages/workspace/RolePermissionsPage.tsx +202 -0
  209. package/src/pages/workspace/SchedulesPage.tsx +387 -0
  210. package/src/pages/workspace/UsagePage.tsx +207 -0
  211. package/src/pages/workspace/WebhooksPage.tsx +506 -0
  212. package/src/pages/workspace/WorkspaceAuditPage.tsx +229 -0
  213. package/src/pages/workspace/WorkspaceGeneralPage.tsx +184 -0
  214. package/src/pages/workspace/__tests__/BillingPage.test.tsx +302 -0
  215. package/src/pages/workspace/__tests__/BillingSuccessPage.test.tsx +134 -0
  216. package/src/pages/workspace/__tests__/MembersPage.test.tsx +347 -0
  217. package/src/pages/workspace/__tests__/RolePermissionsPage.test.tsx +138 -0
  218. package/src/pages/workspace/__tests__/SchedulesPage.test.tsx +239 -0
  219. package/src/pages/workspace/__tests__/UsagePage.test.tsx +223 -0
  220. package/src/pages/workspace/__tests__/WebhooksPage.test.tsx +309 -0
  221. package/src/pages/workspace/__tests__/WorkspaceAuditPage.test.tsx +112 -0
  222. package/src/pages/workspace/__tests__/WorkspaceGeneralPage.test.tsx +164 -0
@@ -0,0 +1,159 @@
1
+
2
+ import { useState } from 'react'
3
+ import { Link } from 'react-router'
4
+ import { Check, ChevronsUpDown, Plus } from 'lucide-react'
5
+ import { isApiError } from '@ploykit/client'
6
+ import { cn } from '../lib/utils'
7
+ import { apiErrorMessage } from '../lib/api-error'
8
+ import { useWorkspaceSwitch } from '@ploykit/hooks'
9
+ import { toast } from './toast'
10
+ import { Button } from './ui/button'
11
+ import { Input } from './ui/input'
12
+ import {
13
+ SidebarMenu, SidebarMenuButton, SidebarMenuItem,
14
+ } from './ui/sidebar'
15
+ import {
16
+ DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem,
17
+ DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger,
18
+ } from './ui/dropdown-menu'
19
+
20
+
21
+ const slugSuffix = (n = 4): string => {
22
+ const alphabet = 'abcdefghijklmnopqrstuvwxyz0123456789'
23
+ let out = ''
24
+ for (let i = 0; i < n; i++) out += alphabet[Math.floor(Math.random() * alphabet.length)]
25
+ return out
26
+ }
27
+
28
+
29
+ const slugify = (s: string): string => {
30
+ let slug = s
31
+ .toLowerCase()
32
+ .replace(/[^a-z0-9-]/g, '-')
33
+ .replace(/-+/g, '-')
34
+ .replace(/^-+|-+$/g, '')
35
+ if (slug.length > 40) slug = slug.slice(0, 40).replace(/-+$/g, '')
36
+ if (slug.length < 4) slug = `${slug || 'ws'}-${slugSuffix()}`
37
+ return slug
38
+ }
39
+
40
+ export function WorkspaceSwitcher() {
41
+ const { current, list, switchTo, create, creating, error } = useWorkspaceSwitch()
42
+ const [showCreate, setShowCreate] = useState(false)
43
+ const [name, setName] = useState('')
44
+
45
+
46
+
47
+ const createError = error === null ? null : apiErrorMessage(error, '创建工作区失败,请稍后重试')
48
+ const quotaExceeded = isApiError(error, 'E_QUOTA_EXCEEDED') || (isApiError(error) && error.status === 402)
49
+
50
+ const handleCreate = async () => {
51
+ if (!name.trim()) return
52
+ const ws = await create(name.trim(), slugify(name.trim()))
53
+ if (ws) {
54
+ toast.success(`工作区「${ws.name}」已创建`)
55
+ setName('')
56
+ setShowCreate(false)
57
+ }
58
+ }
59
+
60
+ return (
61
+ <div className="relative">
62
+ <SidebarMenu>
63
+ <SidebarMenuItem>
64
+ <DropdownMenu>
65
+ <DropdownMenuTrigger
66
+ render={
67
+ <SidebarMenuButton
68
+ size="lg"
69
+ className="data-open:bg-sidebar-accent data-open:text-sidebar-accent-foreground"
70
+ />
71
+ }
72
+ aria-label="切换工作区"
73
+ >
74
+ <span className="flex aspect-square size-8 items-center justify-center rounded-lg bg-sidebar-primary text-sm font-bold text-sidebar-primary-foreground">
75
+ {current?.name?.[0]?.toUpperCase() ?? '?'}
76
+ </span>
77
+ <span className="grid flex-1 text-left text-sm leading-tight">
78
+ <span className="truncate font-medium">{current?.name ?? '选择工作区'}</span>
79
+ <span className="truncate text-xs text-muted-foreground">
80
+ {current?.plan_code ?? '—'}
81
+ </span>
82
+ </span>
83
+ <ChevronsUpDown className="ml-auto size-4 text-muted-foreground" />
84
+ </DropdownMenuTrigger>
85
+ <DropdownMenuContent className="w-(--anchor-width) min-w-56" align="start" side="bottom" sideOffset={4}>
86
+ <DropdownMenuGroup>
87
+ <DropdownMenuLabel className="text-xs text-muted-foreground">工作区</DropdownMenuLabel>
88
+ {list.map((ws) => (
89
+ <DropdownMenuItem
90
+ key={ws.id}
91
+ className="gap-2 p-2"
92
+ onClick={() => switchTo(ws.id)}
93
+ >
94
+ <span className="flex size-6 items-center justify-center rounded-md border bg-transparent text-[10px] font-bold">
95
+ {ws.name[0]?.toUpperCase()}
96
+ </span>
97
+ <span className="flex-1 truncate">{ws.name}</span>
98
+ {current?.id === ws.id && <Check className="size-4" />}
99
+ </DropdownMenuItem>
100
+ ))}
101
+ </DropdownMenuGroup>
102
+ <DropdownMenuSeparator />
103
+ <DropdownMenuItem className="gap-2 p-2" onClick={() => setShowCreate(true)}>
104
+ <span className="flex size-6 items-center justify-center rounded-md border bg-transparent">
105
+ <Plus className="size-4" />
106
+ </span>
107
+ <span className="whitespace-nowrap font-medium text-muted-foreground">新建工作区</span>
108
+ </DropdownMenuItem>
109
+ </DropdownMenuContent>
110
+ </DropdownMenu>
111
+ </SidebarMenuItem>
112
+ </SidebarMenu>
113
+
114
+ {showCreate && (
115
+ <div
116
+ className={cn(
117
+ 'absolute left-0 top-full z-50 mt-2 w-64 rounded-lg border bg-popover p-2 shadow-lg',
118
+ )}
119
+ >
120
+ <Input
121
+ autoFocus
122
+ value={name}
123
+ onChange={(e) => setName(e.target.value)}
124
+
125
+ onKeyDown={(e) => {
126
+ if (e.key === 'Enter' && !creating) handleCreate()
127
+ }}
128
+ placeholder="工作区名称"
129
+ aria-label="新工作区名称"
130
+ maxLength={64}
131
+ />
132
+ <div className="mt-2 flex gap-1">
133
+ <Button size="sm" className="flex-1" onClick={handleCreate} disabled={!name.trim() || creating}>
134
+ {creating ? '创建中…' : '创建'}
135
+ </Button>
136
+ <Button size="sm" variant="outline" className="flex-1" onClick={() => setShowCreate(false)}>
137
+ 取消
138
+ </Button>
139
+ </div>
140
+ {createError && (
141
+ <div className="mt-2 space-y-1">
142
+ <p role="alert" className="text-xs text-destructive">{createError}</p>
143
+ {quotaExceeded && (
144
+
145
+ <p className="text-xs text-muted-foreground">
146
+ 已达当前工作区数上限,可在
147
+ <Link to="/settings/workspace/billing" className="mx-1 text-primary underline-offset-2 hover:underline">
148
+ 设置→计费
149
+ </Link>
150
+ 查看套餐与配额
151
+ </p>
152
+ )}
153
+ </div>
154
+ )}
155
+ </div>
156
+ )}
157
+ </div>
158
+ )
159
+ }
@@ -0,0 +1,111 @@
1
+ import '@testing-library/jest-dom/vitest'
2
+ import { fireEvent, render, screen, waitFor } from '@testing-library/react'
3
+ import { describe, expect, it, vi } from 'vitest'
4
+ import { AppProviders } from '../../AppProviders'
5
+ import { ConfirmProvider, useConfirm } from '../ConfirmDialog'
6
+
7
+ describe('useConfirm fail-fast(无 Provider 时必须抛错,不许静默返回 false)', () => {
8
+ it('组件树里没有 ConfirmProvider 时,useConfirm 抛出对齐 hooks 包风格的错误', () => {
9
+ function Probe() {
10
+ useConfirm()
11
+ return null
12
+ }
13
+ // 屏蔽 React 未捕获错误的噪音输出,只关心抛错本身
14
+ const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
15
+ expect(() => render(<Probe />)).toThrow('useConfirm must be used within ConfirmProvider')
16
+ errSpy.mockRestore()
17
+ })
18
+ })
19
+
20
+ describe('ConfirmProvider 内 useConfirm 的确认/取消契约', () => {
21
+ it('探针组件调用 confirm,点击确认后 resolve(true)', async () => {
22
+ const resolved = vi.fn()
23
+ function Probe() {
24
+ const confirm = useConfirm()
25
+ return (
26
+ <button
27
+ onClick={() => {
28
+ confirm({ title: '重置 API Key', description: '旧 Key 立即失效' }).then(resolved)
29
+ }}
30
+ >
31
+ 发起确认
32
+ </button>
33
+ )
34
+ }
35
+ render(
36
+ <ConfirmProvider>
37
+ <Probe />
38
+ </ConfirmProvider>,
39
+ )
40
+
41
+ fireEvent.click(screen.getByRole('button', { name: '发起确认' }))
42
+ expect(await screen.findByText('重置 API Key')).toBeInTheDocument()
43
+
44
+ fireEvent.click(screen.getByRole('button', { name: '确认' }))
45
+ await waitFor(() => expect(resolved).toHaveBeenCalledWith(true))
46
+ })
47
+
48
+ it('点击取消时 resolve(false)', async () => {
49
+ const resolved = vi.fn()
50
+ function Probe() {
51
+ const confirm = useConfirm()
52
+ return (
53
+ <button
54
+ onClick={() => {
55
+ confirm({ title: '危险操作' }).then(resolved)
56
+ }}
57
+ >
58
+ 发起确认
59
+ </button>
60
+ )
61
+ }
62
+ render(
63
+ <ConfirmProvider>
64
+ <Probe />
65
+ </ConfirmProvider>,
66
+ )
67
+
68
+ fireEvent.click(screen.getByRole('button', { name: '发起确认' }))
69
+ expect(await screen.findByText('危险操作')).toBeInTheDocument()
70
+
71
+ fireEvent.click(screen.getByRole('button', { name: '取消' }))
72
+ await waitFor(() => expect(resolved).toHaveBeenCalledWith(false))
73
+ })
74
+ })
75
+
76
+ describe('AppProviders(PloykitProvider > ConfirmProvider > Toaster 一步到位)', () => {
77
+ it('组合件内 useConfirm 可用:探针 confirm 后点击确认 resolve(true)', async () => {
78
+ const resolved = vi.fn()
79
+ function Probe() {
80
+ const confirm = useConfirm()
81
+ return (
82
+ <button
83
+ onClick={() => {
84
+ confirm({ title: '删除工作区', danger: true }).then(resolved)
85
+ }}
86
+ >
87
+ 发起确认
88
+ </button>
89
+ )
90
+ }
91
+ // AppProviders 内部的 PloykitProvider 会在挂载时请求 /auth/me 与 /api/workspaces,
92
+ // jsdom 环境无后端:stub fetch 返回 401,让 auth 层按未登录降级(这正是产品首屏路径)。
93
+ const fetchSpy = vi
94
+ .spyOn(globalThis, 'fetch')
95
+ .mockImplementation(() => Promise.resolve(new Response(JSON.stringify({ error: 'unauthorized' }), { status: 401 })))
96
+
97
+ render(
98
+ <AppProviders>
99
+ <Probe />
100
+ </AppProviders>,
101
+ )
102
+
103
+ fireEvent.click(screen.getByRole('button', { name: '发起确认' }))
104
+ expect(await screen.findByText('删除工作区')).toBeInTheDocument()
105
+
106
+ fireEvent.click(screen.getByRole('button', { name: '确认' }))
107
+ await waitFor(() => expect(resolved).toHaveBeenCalledWith(true))
108
+
109
+ fetchSpy.mockRestore()
110
+ })
111
+ })
@@ -0,0 +1,180 @@
1
+ import '@testing-library/jest-dom/vitest'
2
+ import { fireEvent, render, screen, waitFor } from '@testing-library/react'
3
+ import { describe, it, expect, vi, beforeEach } from 'vitest'
4
+ import { useState } from 'react'
5
+ import { CronInput, describeCron, timezoneOptions, type CronValue } from '../CronInput'
6
+
7
+
8
+
9
+
10
+
11
+ const mockedPost = vi.hoisted(() => vi.fn())
12
+ vi.mock('@ploykit/client', () => {
13
+ class ApiError extends Error {
14
+ status: number
15
+ code?: string
16
+ constructor(status: number, code: string | undefined, message: string) {
17
+ super(message)
18
+ this.status = status
19
+ this.code = code
20
+ }
21
+ }
22
+ return {
23
+ api: { post: mockedPost },
24
+ ApiError,
25
+ setWorkspaceIdProvider: vi.fn(),
26
+ }
27
+ })
28
+
29
+
30
+ function Harness({ initial }: { initial: CronValue }) {
31
+ const [v, setV] = useState(initial)
32
+ return <CronInput value={v} onChange={setV} />
33
+ }
34
+
35
+ function renderInput(initial: CronValue = { cron: '', timezone: 'UTC' }) {
36
+ return render(<Harness initial={initial} />)
37
+ }
38
+
39
+ beforeEach(() => {
40
+ mockedPost.mockReset()
41
+ })
42
+
43
+ describe('CronInput(批次 11 前端件:预览走后端 cronx 单一事实源)', () => {
44
+ it('空表达式:提示五字段格式,预览按钮禁用', () => {
45
+ renderInput()
46
+ expect(screen.getByTestId('cron-hint')).toHaveTextContent('五字段:分 时 日 月 周')
47
+ expect(screen.getByRole('button', { name: /预览下次 3 次/ })).toBeDisabled()
48
+ })
49
+
50
+ it('输入校验(人类可读描述):常见档位映射中文短语', () => {
51
+ renderInput()
52
+ const input = screen.getByLabelText('Cron 表达式')
53
+ fireEvent.change(input, { target: { value: '0 9 * * *' } })
54
+ expect(screen.getByTestId('cron-hint')).toHaveTextContent('每天 09:00')
55
+
56
+ fireEvent.change(input, { target: { value: '15 * * * *' } })
57
+ expect(screen.getByTestId('cron-hint')).toHaveTextContent('每小时(第 15 分)')
58
+ })
59
+
60
+ it('describeCron 纯函数:每分钟/每周/复杂表达式原样显示', () => {
61
+ expect(describeCron('* * * * *')).toBe('每分钟')
62
+ expect(describeCron('30 8 * * 1')).toBe('每周一 08:30')
63
+ expect(describeCron('0 9 1 * *')).toBe('0 9 1 * *')
64
+ expect(describeCron('not cron')).toBe('not cron')
65
+ })
66
+
67
+ it('时区下拉:内置常用列表兜底可用,切换更新所选时区', () => {
68
+ const zones = timezoneOptions()
69
+ expect(zones.length).toBeGreaterThan(0)
70
+ expect(zones).toContain('UTC')
71
+ expect(zones).toContain('Asia/Shanghai')
72
+
73
+ renderInput()
74
+ const select = screen.getByLabelText('时区')
75
+ expect((select as HTMLSelectElement).value).toBe('UTC')
76
+ fireEvent.change(select, { target: { value: 'Asia/Shanghai' } })
77
+ expect((select as HTMLSelectElement).value).toBe('Asia/Shanghai')
78
+ })
79
+
80
+ it('预览:POST /api/schedules/preview {cron_expr, timezone, count} 并渲染 3 项', async () => {
81
+ mockedPost.mockResolvedValue({
82
+ items: ['2026-10-07T01:00:00Z', '2026-10-08T01:00:00Z', '2026-10-09T01:00:00Z'],
83
+ })
84
+ renderInput({ cron: '0 9 * * *', timezone: 'Asia/Shanghai' })
85
+
86
+ fireEvent.click(screen.getByRole('button', { name: /预览下次 3 次/ }))
87
+ await waitFor(() =>
88
+ expect(mockedPost).toHaveBeenCalledWith('/api/schedules/preview', {
89
+ cron_expr: '0 9 * * *',
90
+ timezone: 'Asia/Shanghai',
91
+ count: 3,
92
+ }),
93
+ )
94
+
95
+ const items = await screen.findAllByRole('listitem', {}, { container: screen.getByTestId('cron-preview') })
96
+ expect(items).toHaveLength(3)
97
+
98
+ expect(items[0].textContent).toContain('09:00')
99
+ expect(items[0].textContent).toContain('2026')
100
+ })
101
+
102
+ it('预览失败:展示可读错误(服务端 E_INVALID_CRON 等透传)', async () => {
103
+ mockedPost.mockRejectedValue(new Error('invalid cron expression'))
104
+ renderInput({ cron: 'bad', timezone: 'UTC' })
105
+
106
+ fireEvent.click(screen.getByRole('button', { name: /预览下次 3 次/ }))
107
+ const alert = await screen.findByRole('alert')
108
+ expect(alert).toHaveTextContent('invalid cron expression')
109
+ expect(screen.queryByTestId('cron-preview')).not.toBeInTheDocument()
110
+ })
111
+
112
+ it('disabled:表达式输入、时区下拉与预览按钮全部禁用', () => {
113
+ render(
114
+ <CronInput
115
+ value={{ cron: '0 9 * * *', timezone: 'UTC' }}
116
+ onChange={() => {}}
117
+ disabled
118
+ />,
119
+ )
120
+ expect(screen.getByLabelText('Cron 表达式')).toBeDisabled()
121
+ expect(screen.getByLabelText('时区')).toBeDisabled()
122
+ expect(screen.getByRole('button', { name: /预览/ })).toBeDisabled()
123
+ })
124
+ })
125
+
126
+ describe('B5:describeCron 周几中文与越界回退', () => {
127
+ it('每周档位输出中文周几(0/7 均为周日)', () => {
128
+ expect(describeCron('30 8 * * 1')).toBe('每周一 08:30')
129
+ expect(describeCron('0 9 * * 5')).toBe('每周五 09:00')
130
+ expect(describeCron('0 9 * * 0')).toBe('每周日 09:00')
131
+ expect(describeCron('0 9 * * 7')).toBe('每周日 09:00')
132
+ })
133
+
134
+ it('数值域越界/非整数回退原表达式,不再伪翻译"每天 99:99"', () => {
135
+ expect(describeCron('99 99 * * *')).toBe('99 99 * * *')
136
+ expect(describeCron('0 25 * * *')).toBe('0 25 * * *')
137
+ expect(describeCron('61 * * * *')).toBe('61 * * * *')
138
+ expect(describeCron('0 9 * * 8')).toBe('0 9 * * 8')
139
+ expect(describeCron('abc 9 * * *')).toBe('abc 9 * * *')
140
+ })
141
+
142
+ it('合法档位翻译不受影响(每分钟/每小时/每天)', () => {
143
+ expect(describeCron('* * * * *')).toBe('每分钟')
144
+ expect(describeCron('15 * * * *')).toBe('每小时(第 15 分)')
145
+ expect(describeCron('0 9 * * *')).toBe('每天 09:00')
146
+ })
147
+ })
148
+
149
+ describe('P3-8:过期响应守卫与输入变化失效', () => {
150
+ it('在途响应晚归被丢弃:预览中改时区后,旧请求结果不再上屏', async () => {
151
+ let resolveFirst!: (v: { items: string[] }) => void
152
+ mockedPost.mockImplementationOnce(() =>
153
+ new Promise((res) => {
154
+ resolveFirst = res as typeof resolveFirst
155
+ }),
156
+ )
157
+ renderInput({ cron: '0 9 * * *', timezone: 'UTC' })
158
+ fireEvent.click(screen.getByRole('button', { name: /预览下次/ }))
159
+
160
+
161
+
162
+ fireEvent.change(screen.getByLabelText('时区'), { target: { value: 'Asia/Shanghai' } })
163
+ resolveFirst({ items: ['2026-10-09T09:00:00Z'] })
164
+ await Promise.resolve()
165
+ expect(screen.queryByTestId('cron-preview')).not.toBeInTheDocument()
166
+
167
+ await waitFor(() =>
168
+ expect(screen.getByRole('button', { name: /预览下次/ })).toBeEnabled())
169
+ })
170
+
171
+ it('预览在途/完成后修改时区:旧列表立即失效(不被误读为当前时区的语义)', async () => {
172
+ mockedPost.mockResolvedValue({ items: ['2026-10-09T09:00:00Z'] })
173
+ renderInput({ cron: '0 9 * * *', timezone: 'UTC' })
174
+ fireEvent.click(screen.getByRole('button', { name: /预览下次/ }))
175
+ expect(await screen.findByTestId('cron-preview')).toBeInTheDocument()
176
+
177
+ fireEvent.change(screen.getByLabelText('时区'), { target: { value: 'Asia/Shanghai' } })
178
+ expect(screen.queryByTestId('cron-preview')).not.toBeInTheDocument()
179
+ })
180
+ })
@@ -0,0 +1,208 @@
1
+ import '@testing-library/jest-dom/vitest'
2
+ import { fireEvent, render, screen } from '@testing-library/react'
3
+ import { describe, expect, it, vi } from 'vitest'
4
+ import { DataTable, type Column } from '../DataTable'
5
+
6
+ interface Row { id: string; name: string; score: number }
7
+
8
+ const columns: Column<Row>[] = [
9
+ { key: 'name', header: '名称' },
10
+ { key: 'score', header: '分数', render: (r) => <span>{r.score} 分</span> },
11
+ ]
12
+ const rows: Row[] = [
13
+ { id: 'a', name: '甲', score: 1 },
14
+ { id: 'b', name: '乙', score: 2 },
15
+ { id: 'c', name: '丙', score: 3 },
16
+ ]
17
+
18
+ describe('DataTable', () => {
19
+ it('3 行 2 列渲染表头与单元格文案', () => {
20
+ render(<DataTable columns={columns} rows={rows} rowKey={(r) => r.id} />)
21
+ expect(screen.getByText('名称')).toBeInTheDocument()
22
+ expect(screen.getByText('分数')).toBeInTheDocument()
23
+ for (const name of ['甲', '乙', '丙']) {
24
+ expect(screen.getByText(name)).toBeInTheDocument()
25
+ }
26
+ expect(screen.getByText('2 分')).toBeInTheDocument()
27
+ expect(document.querySelectorAll('tbody tr')).toHaveLength(3)
28
+ })
29
+
30
+ it('loading 时渲染列数个 Skeleton 且表头保留', () => {
31
+ render(<DataTable columns={columns} rows={[]} rowKey={(r) => r.id} loading />)
32
+ expect(document.querySelectorAll('[data-slot="skeleton"]')).toHaveLength(2)
33
+ expect(screen.getByText('名称')).toBeInTheDocument()
34
+ })
35
+
36
+ it('空 rows 显示传入的 empty 文案', () => {
37
+ render(<DataTable columns={columns} rows={[]} rowKey={(r) => r.id} empty={<div>这里空空如也</div>} />)
38
+ expect(screen.getByText('这里空空如也')).toBeInTheDocument()
39
+ })
40
+
41
+ it('空 rows 且无 empty 时回退 PageEmpty 默认占位', () => {
42
+ render(<DataTable columns={columns} rows={[]} rowKey={(r) => r.id} />)
43
+ expect(screen.getByText('暂无数据')).toBeInTheDocument()
44
+ })
45
+
46
+ it('传入 pagination 渲染底栏文案,且边界页禁用对应按钮', () => {
47
+ const onPageChange = vi.fn()
48
+ render(
49
+ <DataTable
50
+ columns={columns}
51
+ rows={rows}
52
+ rowKey={(r) => r.id}
53
+ pagination={{ page: 2, pageSize: 20, total: 42, onPageChange }}
54
+ />,
55
+ )
56
+
57
+ expect(screen.getByText('42')).toBeInTheDocument()
58
+ expect(screen.getByText('2/3')).toBeInTheDocument()
59
+ const prev = screen.getByRole('button', { name: '上一页' })
60
+ const next = screen.getByRole('button', { name: '下一页' })
61
+ expect(prev).toBeEnabled()
62
+ expect(next).toBeEnabled()
63
+ fireEvent.click(next)
64
+ expect(onPageChange).toHaveBeenCalledWith(3)
65
+ fireEvent.click(prev)
66
+ expect(onPageChange).toHaveBeenCalledWith(1)
67
+ })
68
+
69
+ it('pagination 首页禁用上一页、末页禁用下一页', () => {
70
+ const first = render(
71
+ <DataTable
72
+ columns={columns}
73
+ rows={rows}
74
+ rowKey={(r) => r.id}
75
+ pagination={{ page: 1, pageSize: 20, total: 42, onPageChange: () => {} }}
76
+ />,
77
+ )
78
+ expect(screen.getByRole('button', { name: '上一页' })).toBeDisabled()
79
+ expect(screen.getByRole('button', { name: '下一页' })).toBeEnabled()
80
+ expect(screen.getByText('1/3')).toBeInTheDocument()
81
+ first.unmount()
82
+
83
+ render(
84
+ <DataTable
85
+ columns={columns}
86
+ rows={rows}
87
+ rowKey={(r) => r.id}
88
+ pagination={{ page: 3, pageSize: 20, total: 42, onPageChange: () => {} }}
89
+ />,
90
+ )
91
+ expect(screen.getByRole('button', { name: '上一页' })).toBeEnabled()
92
+ expect(screen.getByRole('button', { name: '下一页' })).toBeDisabled()
93
+ expect(screen.getByText('3/3')).toBeInTheDocument()
94
+ })
95
+
96
+ it('空 rows 时不渲染分页底栏(空态行为不受 pagination 影响)', () => {
97
+ render(
98
+ <DataTable
99
+ columns={columns}
100
+ rows={[]}
101
+ rowKey={(r) => r.id}
102
+ pagination={{ page: 1, pageSize: 20, total: 0, onPageChange: () => {} }}
103
+ />,
104
+ )
105
+ expect(screen.getByText('暂无数据')).toBeInTheDocument()
106
+ expect(screen.queryByRole('button', { name: '下一页' })).not.toBeInTheDocument()
107
+ })
108
+
109
+ it('P3-10:stale page(有 total 的空页)不整表换空态——空表体+分页栏,保住"上一页"出口', () => {
110
+
111
+
112
+ render(
113
+ <DataTable
114
+ columns={columns}
115
+ rows={[]}
116
+ rowKey={(r) => r.id}
117
+ pagination={{ page: 5, pageSize: 20, total: 42, onPageChange: () => {} }}
118
+ />,
119
+ )
120
+ expect(screen.queryByText('暂无数据')).not.toBeInTheDocument()
121
+ expect(screen.getByText('本页没有数据,可返回上一页')).toBeInTheDocument()
122
+
123
+ expect(screen.getByRole('button', { name: '上一页' })).toBeEnabled()
124
+ expect(screen.getByRole('button', { name: '下一页' })).toBeDisabled()
125
+ })
126
+
127
+ it('rowKey 回调收到行下标(第二参数),可用于同 code 多行的唯一 key', () => {
128
+ const seen: Array<[string, number]> = []
129
+ render(
130
+ <DataTable
131
+ columns={columns}
132
+ rows={rows}
133
+ rowKey={(r, index) => {
134
+ seen.push([r.id, index])
135
+ return `${r.id}-${index}`
136
+ }}
137
+ />,
138
+ )
139
+ expect(seen).toEqual([
140
+ ['a', 0],
141
+ ['b', 1],
142
+ ['c', 2],
143
+ ])
144
+ expect(document.querySelectorAll('tbody tr')).toHaveLength(3)
145
+ })
146
+
147
+ it('onRowClick:点击行触发回调并携带行数据,行带 cursor-pointer', () => {
148
+ const onRowClick = vi.fn()
149
+ render(<DataTable columns={columns} rows={rows} rowKey={(r) => r.id} onRowClick={onRowClick} />)
150
+ const firstRow = document.querySelector('tbody tr') as HTMLTableRowElement
151
+ expect(firstRow.className).toContain('cursor-pointer')
152
+ fireEvent.click(firstRow)
153
+ expect(onRowClick).toHaveBeenCalledTimes(1)
154
+ expect(onRowClick).toHaveBeenCalledWith(rows[0])
155
+ })
156
+
157
+ it('未传 onRowClick 的行无 cursor-pointer 也不响应点击', () => {
158
+ const onRowClick = vi.fn()
159
+ render(<DataTable columns={columns} rows={rows} rowKey={(r) => r.id} />)
160
+ const firstRow = document.querySelector('tbody tr') as HTMLTableRowElement
161
+ expect(firstRow.className).not.toContain('cursor-pointer')
162
+ fireEvent.click(firstRow)
163
+ expect(onRowClick).not.toHaveBeenCalled()
164
+ })
165
+
166
+ it('renderExpanded:返回非 null 时在行后插入 colSpan 展开行;返回 null 时不插入', () => {
167
+ const { rerender } = render(
168
+ <DataTable
169
+ columns={columns}
170
+ rows={rows}
171
+ rowKey={(r) => r.id}
172
+ renderExpanded={(r) => (r.id === 'b' ? <div>详情:{r.name}</div> : null)}
173
+ />,
174
+ )
175
+ // 3 行数据 + 仅乙行有展开行 = 4 个 tr
176
+ const allRows = document.querySelectorAll('tbody tr')
177
+ expect(allRows).toHaveLength(4)
178
+ const expandedRow = document.querySelector('[data-slot="data-table-expanded-row"]') as HTMLTableRowElement
179
+ expect(expandedRow).not.toBeNull()
180
+ const expandedTd = expandedRow.querySelector('td') as HTMLTableCellElement
181
+ expect(expandedTd).toHaveAttribute('colspan', '2')
182
+ expect(screen.getByText('详情:乙')).toBeInTheDocument()
183
+ // 展开行不应出现在普通数据行里重复渲染
184
+ expect(allRows[1].querySelector('td')).not.toHaveAttribute('colspan')
185
+
186
+ rerender(
187
+ <DataTable columns={columns} rows={rows} rowKey={(r) => r.id} renderExpanded={() => null} />,
188
+ )
189
+ expect(document.querySelectorAll('tbody tr')).toHaveLength(3)
190
+ expect(document.querySelector('[data-slot="data-table-expanded-row"]')).toBeNull()
191
+ })
192
+
193
+ it('renderExpanded 收到行下标(第二参数)', () => {
194
+ const seenIndexes: number[] = []
195
+ render(
196
+ <DataTable
197
+ columns={columns}
198
+ rows={rows}
199
+ rowKey={(r) => r.id}
200
+ renderExpanded={(_r, index) => {
201
+ seenIndexes.push(index)
202
+ return null
203
+ }}
204
+ />,
205
+ )
206
+ expect(seenIndexes).toEqual([0, 1, 2])
207
+ })
208
+ })