@ploykit/ui 0.6.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.
- package/dist/AppProviders.js +2 -2
- package/dist/components/ConfirmDialog.js +1 -1
- package/dist/components/CronInput.js +3 -3
- package/dist/components/DataTable.js +4 -4
- package/dist/components/FormField.js +1 -1
- package/dist/components/ImpersonationBanner.js +1 -1
- package/dist/components/NotificationBell.js +1 -1
- package/dist/components/OnboardingChecklist.js +3 -3
- package/dist/components/Page.js +2 -2
- package/dist/components/ReauthDialog.js +4 -4
- package/dist/components/SecretModal.js +2 -2
- package/dist/components/SettingsCard.js +2 -2
- package/dist/components/SiteBanner.js +1 -1
- package/dist/components/SiteBannerLayer.js +1 -1
- package/dist/components/StatusBadge.js +1 -1
- package/dist/components/UserMenu.js +3 -3
- package/dist/components/WorkspaceSwitcher.js +7 -7
- package/dist/components/ui/alert-dialog.js +2 -2
- package/dist/components/ui/avatar.js +1 -1
- package/dist/components/ui/badge.js +1 -1
- package/dist/components/ui/breadcrumb.js +1 -1
- package/dist/components/ui/button.js +1 -1
- package/dist/components/ui/card.js +1 -1
- package/dist/components/ui/checkbox.js +1 -1
- package/dist/components/ui/dialog.js +2 -2
- package/dist/components/ui/dropdown-menu.js +1 -1
- package/dist/components/ui/empty.js +1 -1
- package/dist/components/ui/field.js +3 -3
- package/dist/components/ui/input.js +1 -1
- package/dist/components/ui/label.js +1 -1
- package/dist/components/ui/popover.js +1 -1
- package/dist/components/ui/progress.js +1 -1
- package/dist/components/ui/select.js +1 -1
- package/dist/components/ui/separator.js +1 -1
- package/dist/components/ui/sheet.js +2 -2
- package/dist/components/ui/sidebar.js +8 -8
- package/dist/components/ui/skeleton.js +1 -1
- package/dist/components/ui/switch.js +1 -1
- package/dist/components/ui/tabs.js +1 -1
- package/dist/components/ui/textarea.js +1 -1
- package/dist/components/ui/tooltip.js +1 -1
- package/dist/index.js +78 -78
- package/dist/layouts/AppShell.js +4 -4
- package/dist/layouts/SettingsShell.js +4 -4
- package/dist/pages/LoginPage.js +5 -5
- package/dist/pages/account/NotificationPreferencesPage.js +3 -3
- package/dist/pages/account/ProfileSettingsPage.js +9 -9
- package/dist/pages/account/SecuritySettingsPage.js +13 -13
- package/dist/pages/account/TokensPage.js +12 -12
- package/dist/pages/admin/AdminSettingsPage.js +11 -11
- package/dist/pages/admin/AdminShell.js +3 -3
- package/dist/pages/admin/AuditPage.js +8 -8
- package/dist/pages/admin/OrdersPage.js +12 -12
- package/dist/pages/admin/OverviewPage.js +13 -13
- package/dist/pages/admin/SSOPage.js +6 -6
- package/dist/pages/admin/UserDetailPage.js +13 -13
- package/dist/pages/admin/UsersPage.js +13 -13
- package/dist/pages/admin/WebhookDeliveriesPage.js +9 -9
- package/dist/pages/admin/WorkspaceDetailPage.js +14 -14
- package/dist/pages/admin/WorkspacesPage.js +9 -9
- package/dist/pages/admin/shared.js +1 -1
- package/dist/pages/auth/ForgotPasswordPage.js +4 -4
- package/dist/pages/auth/InviteAcceptPage.js +6 -6
- package/dist/pages/auth/RegisterPage.js +7 -7
- package/dist/pages/auth/ResetPasswordPage.js +5 -5
- package/dist/pages/auth/VerifyEmailPage.js +3 -3
- package/dist/pages/workspace/BillingPage.js +11 -11
- package/dist/pages/workspace/BillingSuccessPage.js +2 -2
- package/dist/pages/workspace/MembersPage.js +15 -15
- package/dist/pages/workspace/RolePermissionsPage.js +9 -9
- package/dist/pages/workspace/SchedulesPage.js +12 -12
- package/dist/pages/workspace/UsagePage.js +6 -6
- package/dist/pages/workspace/WebhooksPage.js +16 -16
- package/dist/pages/workspace/WorkspaceAuditPage.js +7 -7
- package/dist/pages/workspace/WorkspaceGeneralPage.js +9 -9
- package/package.json +6 -5
- package/src/AppProviders.tsx +29 -0
- package/src/components/ConfirmDialog.tsx +77 -0
- package/src/components/CronInput.tsx +195 -0
- package/src/components/DataTable.tsx +152 -0
- package/src/components/ErrorBoundary.tsx +46 -0
- package/src/components/FormField.tsx +56 -0
- package/src/components/ImpersonationBanner.tsx +23 -0
- package/src/components/NotificationBell.tsx +135 -0
- package/src/components/OnboardingChecklist.tsx +114 -0
- package/src/components/Page.tsx +67 -0
- package/src/components/ReauthDialog.tsx +94 -0
- package/src/components/SecretModal.tsx +147 -0
- package/src/components/SettingsCard.tsx +39 -0
- package/src/components/SiteBanner.tsx +32 -0
- package/src/components/SiteBannerLayer.tsx +29 -0
- package/src/components/StatusBadge.tsx +12 -0
- package/src/components/UserMenu.tsx +103 -0
- package/src/components/WorkspaceSwitcher.tsx +159 -0
- package/src/components/__tests__/AppProviders.test.tsx +111 -0
- package/src/components/__tests__/CronInput.test.tsx +180 -0
- package/src/components/__tests__/DataTable.test.tsx +208 -0
- package/src/components/__tests__/ErrorBoundary.test.tsx +61 -0
- package/src/components/__tests__/ImpersonationBanner.test.tsx +59 -0
- package/src/components/__tests__/NotificationBell.test.tsx +207 -0
- package/src/components/__tests__/OnboardingChecklist.test.tsx +97 -0
- package/src/components/__tests__/SecretModal.test.tsx +112 -0
- package/src/components/__tests__/SiteBannerLayer.test.tsx +44 -0
- package/src/components/__tests__/UserMenu.test.tsx +98 -0
- package/src/components/__tests__/WorkspaceSwitcher.test.tsx +162 -0
- package/src/components/__tests__/form.test.tsx +133 -0
- package/src/components/__tests__/semantic-tokens.test.ts +41 -0
- package/src/components/__tests__/smoke.test.tsx +11 -0
- package/src/components/__tests__/toast.test.tsx +27 -0
- package/src/components/toast.tsx +23 -0
- package/src/components/ui/__tests__/alert-dialog.test.tsx +66 -0
- package/src/components/ui/__tests__/avatar.test.tsx +30 -0
- package/src/components/ui/__tests__/checkbox.test.tsx +18 -0
- package/src/components/ui/__tests__/dropdown-menu.test.tsx +33 -0
- package/src/components/ui/__tests__/field.test.tsx +43 -0
- package/src/components/ui/__tests__/overlay-size.test.tsx +84 -0
- package/src/components/ui/__tests__/popover.test.tsx +32 -0
- package/src/components/ui/__tests__/progress.test.tsx +24 -0
- package/src/components/ui/__tests__/select-callback.test.tsx +72 -0
- package/src/components/ui/__tests__/select.test.tsx +48 -0
- package/src/components/ui/__tests__/sidebar.test.tsx +121 -0
- package/src/components/ui/__tests__/stock-mirror.test.ts +39 -0
- package/src/components/ui/__tests__/subpath.test.tsx +38 -0
- package/src/components/ui/__tests__/switch.test.tsx +24 -0
- package/src/components/ui/__tests__/tabs.test.tsx +24 -0
- package/src/components/ui/__tests__/textarea.test.tsx +41 -0
- package/src/components/ui/alert-dialog.tsx +187 -0
- package/src/components/ui/avatar.tsx +108 -0
- package/src/components/ui/badge.tsx +51 -0
- package/src/components/ui/breadcrumb.tsx +124 -0
- package/src/components/ui/button.tsx +57 -0
- package/src/components/ui/card.tsx +102 -0
- package/src/components/ui/checkbox.tsx +28 -0
- package/src/components/ui/dialog.tsx +166 -0
- package/src/components/ui/dropdown-menu.tsx +265 -0
- package/src/components/ui/empty.tsx +103 -0
- package/src/components/ui/field.tsx +238 -0
- package/src/components/ui/input.tsx +19 -0
- package/src/components/ui/label.tsx +17 -0
- package/src/components/ui/popover.tsx +89 -0
- package/src/components/ui/progress.tsx +82 -0
- package/src/components/ui/select.tsx +235 -0
- package/src/components/ui/separator.tsx +24 -0
- package/src/components/ui/sheet.tsx +146 -0
- package/src/components/ui/sidebar.tsx +723 -0
- package/src/components/ui/skeleton.tsx +13 -0
- package/src/components/ui/switch.tsx +29 -0
- package/src/components/ui/tabs.tsx +79 -0
- package/src/components/ui/textarea.tsx +26 -0
- package/src/components/ui/tooltip.tsx +63 -0
- package/src/dev.css +1 -0
- package/src/hooks/use-mobile.ts +19 -0
- package/src/index.ts +150 -0
- package/src/layouts/AppShell.tsx +163 -0
- package/src/layouts/AuthShell.tsx +24 -0
- package/src/layouts/SettingsShell.tsx +129 -0
- package/src/layouts/__tests__/AppShell.test.tsx +163 -0
- package/src/layouts/__tests__/SettingsShell.test.tsx +146 -0
- package/src/lib/__tests__/api-error.test.ts +163 -0
- package/src/lib/__tests__/utils.test.ts +21 -0
- package/src/lib/api-error.ts +98 -0
- package/src/lib/utils.ts +109 -0
- package/src/pages/LandingPage.tsx +88 -0
- package/src/pages/LoginPage.tsx +291 -0
- package/src/pages/__tests__/LandingPage.test.tsx +58 -0
- package/src/pages/__tests__/LoginPage.test.tsx +229 -0
- package/src/pages/__tests__/RegisterPage.test.tsx +138 -0
- package/src/pages/__tests__/ssr-smoke.test.tsx +99 -0
- package/src/pages/account/NotificationPreferencesPage.tsx +138 -0
- package/src/pages/account/ProfileSettingsPage.tsx +189 -0
- package/src/pages/account/SecuritySettingsPage.tsx +214 -0
- package/src/pages/account/TokensPage.tsx +211 -0
- package/src/pages/account/__tests__/NotificationPreferencesPage.test.tsx +150 -0
- package/src/pages/account/__tests__/ProfileSettingsPage.test.tsx +173 -0
- package/src/pages/account/__tests__/SecuritySettingsPage.test.tsx +212 -0
- package/src/pages/account/__tests__/TokensPage.test.tsx +180 -0
- package/src/pages/admin/AdminSettingsPage.tsx +564 -0
- package/src/pages/admin/AdminShell.tsx +199 -0
- package/src/pages/admin/AuditPage.tsx +338 -0
- package/src/pages/admin/OrdersPage.tsx +642 -0
- package/src/pages/admin/OverviewPage.tsx +559 -0
- package/src/pages/admin/SSOPage.tsx +166 -0
- package/src/pages/admin/UserDetailPage.tsx +641 -0
- package/src/pages/admin/UsersPage.tsx +462 -0
- package/src/pages/admin/WebhookDeliveriesPage.tsx +318 -0
- package/src/pages/admin/WorkspaceDetailPage.tsx +551 -0
- package/src/pages/admin/WorkspacesPage.tsx +299 -0
- package/src/pages/admin/__tests__/AdminSettingsPage.test.tsx +260 -0
- package/src/pages/admin/__tests__/OrdersPageMarkPaid.test.tsx +207 -0
- package/src/pages/admin/__tests__/OverviewPageActivity.test.tsx +213 -0
- package/src/pages/admin/__tests__/SSOPage.test.tsx +128 -0
- package/src/pages/admin/__tests__/UsersPageActions.test.tsx +188 -0
- package/src/pages/admin/__tests__/WorkspaceDetailPageTransfer.test.tsx +170 -0
- package/src/pages/admin/__tests__/WorkspacesPagePlans.test.tsx +138 -0
- package/src/pages/admin/shared.ts +42 -0
- package/src/pages/auth/ForgotPasswordPage.tsx +83 -0
- package/src/pages/auth/InviteAcceptPage.tsx +105 -0
- package/src/pages/auth/RegisterPage.tsx +204 -0
- package/src/pages/auth/ResetPasswordPage.tsx +150 -0
- package/src/pages/auth/VerifyEmailPage.tsx +76 -0
- package/src/pages/auth/__tests__/ForgotPasswordPage.test.tsx +67 -0
- package/src/pages/auth/__tests__/InviteAcceptPage.test.tsx +86 -0
- package/src/pages/auth/__tests__/ResetPasswordPage.test.tsx +115 -0
- package/src/pages/auth/__tests__/VerifyEmailPage.test.tsx +74 -0
- package/src/pages/workspace/BillingPage.tsx +361 -0
- package/src/pages/workspace/BillingSuccessPage.tsx +115 -0
- package/src/pages/workspace/MembersPage.tsx +568 -0
- package/src/pages/workspace/RolePermissionsPage.tsx +202 -0
- package/src/pages/workspace/SchedulesPage.tsx +387 -0
- package/src/pages/workspace/UsagePage.tsx +207 -0
- package/src/pages/workspace/WebhooksPage.tsx +506 -0
- package/src/pages/workspace/WorkspaceAuditPage.tsx +229 -0
- package/src/pages/workspace/WorkspaceGeneralPage.tsx +184 -0
- package/src/pages/workspace/__tests__/BillingPage.test.tsx +302 -0
- package/src/pages/workspace/__tests__/BillingSuccessPage.test.tsx +134 -0
- package/src/pages/workspace/__tests__/MembersPage.test.tsx +347 -0
- package/src/pages/workspace/__tests__/RolePermissionsPage.test.tsx +138 -0
- package/src/pages/workspace/__tests__/SchedulesPage.test.tsx +239 -0
- package/src/pages/workspace/__tests__/UsagePage.test.tsx +223 -0
- package/src/pages/workspace/__tests__/WebhooksPage.test.tsx +309 -0
- package/src/pages/workspace/__tests__/WorkspaceAuditPage.test.tsx +112 -0
- 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
|
+
})
|