Commit db8f9d9f96c07f340be5fa640c3725aba5a2f338
1 parent
bdfcfae2
feat(usr): 用户单据页面集成与路由接线 UserDetailPage REQ-USR-001 REQ-USR-002
Showing
5 changed files
with
397 additions
and
10 deletions
frontend/src/pages/usr/UserDetail/constants.ts
| ... | ... | @@ -102,6 +102,7 @@ export const MSG_ERR_NETWORK = '保存失败,请稍后重试'; |
| 102 | 102 | export const MSG_ERR_LOAD_EMPLOYEES = '员工列表加载失败'; |
| 103 | 103 | export const MSG_ERR_LOAD_PERMISSIONS = '权限列表加载失败'; |
| 104 | 104 | export const MSG_LOAD_DETAIL_FAIL = '加载失败,点击重试'; |
| 105 | +export const TEXT_RETRY = '点击重试'; | |
| 105 | 106 | export const MSG_CANCEL_CONFIRM = '放弃未保存的修改?'; |
| 106 | 107 | export const MSG_FUNC_PLACEHOLDER = '功能开发中'; |
| 107 | 108 | export const TEXT_BACK_TO_LIST = '返回列表'; | ... | ... |
frontend/src/pages/usr/UserDetail/index.tsx
0 → 100644
| 1 | +// REQ-USR-001 / REQ-USR-002: 用户单据页面容器(判 mode、装配 4 子组件、提交反馈与导航回流) | |
| 2 | +import { useEffect } from 'react'; | |
| 3 | +import { Form, Spin, Button, Modal, App as AntdApp } from 'antd'; | |
| 4 | +import { useNavigate, useParams, useLocation } from 'react-router-dom'; | |
| 5 | +import type { UserVO } from '../../../api/types'; | |
| 6 | +import UserDetailToolbar from './UserDetailToolbar'; | |
| 7 | +import UserBasicForm from './UserBasicForm'; | |
| 8 | +import PermissionTabs from './PermissionTabs'; | |
| 9 | +import PermissionGroupList from './PermissionGroupList'; | |
| 10 | +import { useUserDetail } from './useUserDetail'; | |
| 11 | +import { | |
| 12 | + MODE_CREATE, | |
| 13 | + MODE_EDIT, | |
| 14 | + MSG_CREATE_SUCCESS, | |
| 15 | + MSG_EDIT_SUCCESS, | |
| 16 | + MSG_ERR_USER_NOT_FOUND, | |
| 17 | + MSG_LOAD_DETAIL_FAIL, | |
| 18 | + MSG_CANCEL_CONFIRM, | |
| 19 | + PATH_USER_LIST, | |
| 20 | + PATH_USER_NEW, | |
| 21 | + TEXT_BACK_TO_LIST, | |
| 22 | + TEXT_RETRY, | |
| 23 | + type UserFormValues, | |
| 24 | +} from './constants'; | |
| 25 | +import styles from './UserDetail.module.css'; | |
| 26 | + | |
| 27 | +/** Checkbox 受控值为 boolean,提交映射需 0/1(BR8) */ | |
| 28 | +function normalizeFormValues(raw: UserFormValues): UserFormValues { | |
| 29 | + return { | |
| 30 | + ...raw, | |
| 31 | + iCanModifyBill: (raw.iCanModifyBill ? 1 : 0) as 0 | 1, | |
| 32 | + }; | |
| 33 | +} | |
| 34 | + | |
| 35 | +export default function UserDetailPage() { | |
| 36 | + const navigate = useNavigate(); | |
| 37 | + const params = useParams<{ id?: string }>(); | |
| 38 | + const location = useLocation(); | |
| 39 | + const { message } = AntdApp.useApp(); | |
| 40 | + const [form] = Form.useForm<UserFormValues>(); | |
| 41 | + | |
| 42 | + const mode = params.id ? MODE_EDIT : MODE_CREATE; | |
| 43 | + const userId = params.id ? Number(params.id) : undefined; | |
| 44 | + const presetUser = (location.state as { user?: UserVO } | null)?.user ?? null; | |
| 45 | + | |
| 46 | + const detail = useUserDetail({ mode, userId, presetUser }); | |
| 47 | + const { | |
| 48 | + formValues, | |
| 49 | + employees, | |
| 50 | + permissions, | |
| 51 | + checkedPermissionIds, | |
| 52 | + readonlyCreator, | |
| 53 | + readonlyCreateTime, | |
| 54 | + loading, | |
| 55 | + submitting, | |
| 56 | + loadFailed, | |
| 57 | + notFound, | |
| 58 | + selectEmployee, | |
| 59 | + togglePermission, | |
| 60 | + toggleAll, | |
| 61 | + submit, | |
| 62 | + reload, | |
| 63 | + } = detail; | |
| 64 | + | |
| 65 | + // hook 持有的受控值回写到 AntD Form(create 默认 / edit 回填,BR1/BR2/BR6/BR17) | |
| 66 | + useEffect(() => { | |
| 67 | + form.setFieldsValue({ | |
| 68 | + ...formValues, | |
| 69 | + iCanModifyBill: (formValues.iCanModifyBill ? 1 : 0) as 0 | 1, | |
| 70 | + }); | |
| 71 | + // eslint-disable-next-line react-hooks/exhaustive-deps | |
| 72 | + }, [formValues]); | |
| 73 | + | |
| 74 | + const handleSave = async () => { | |
| 75 | + try { | |
| 76 | + const values = await form.validateFields(); | |
| 77 | + const ret = await submit(normalizeFormValues({ ...formValues, ...values })); | |
| 78 | + if (ret.ok) { | |
| 79 | + message.success(mode === MODE_CREATE ? MSG_CREATE_SUCCESS : MSG_EDIT_SUCCESS); | |
| 80 | + navigate(PATH_USER_LIST); | |
| 81 | + } else if (ret.fieldError) { | |
| 82 | + form.setFields([ | |
| 83 | + { name: ret.fieldError.field, errors: [ret.fieldError.message] }, | |
| 84 | + ]); | |
| 85 | + } | |
| 86 | + } catch { | |
| 87 | + // validateFields 失败:就近字段已展示错误,不发请求(BR12) | |
| 88 | + } | |
| 89 | + }; | |
| 90 | + | |
| 91 | + const handleCancel = () => { | |
| 92 | + if (form.isFieldsTouched()) { | |
| 93 | + Modal.confirm({ | |
| 94 | + title: MSG_CANCEL_CONFIRM, | |
| 95 | + onOk: () => navigate(PATH_USER_LIST), | |
| 96 | + }); | |
| 97 | + } else { | |
| 98 | + navigate(PATH_USER_LIST); | |
| 99 | + } | |
| 100 | + }; | |
| 101 | + | |
| 102 | + const handleNew = () => { | |
| 103 | + navigate(PATH_USER_NEW); | |
| 104 | + }; | |
| 105 | + | |
| 106 | + const handleSelectEmployee = (value: number | null) => { | |
| 107 | + selectEmployee(value); | |
| 108 | + }; | |
| 109 | + | |
| 110 | + // edit 详情不存在(40401):返回列表入口(spec § 4) | |
| 111 | + if (notFound) { | |
| 112 | + return ( | |
| 113 | + <div className={styles.page}> | |
| 114 | + <div className={styles.loadError} data-testid="userdetail-notfound"> | |
| 115 | + <span className={styles.loadErrorText}>{MSG_ERR_USER_NOT_FOUND}</span> | |
| 116 | + <Button type="primary" onClick={() => navigate(PATH_USER_LIST)}> | |
| 117 | + {TEXT_BACK_TO_LIST} | |
| 118 | + </Button> | |
| 119 | + </div> | |
| 120 | + </div> | |
| 121 | + ); | |
| 122 | + } | |
| 123 | + | |
| 124 | + // 预取/详情取数失败:重试入口(spec § 4 loadError) | |
| 125 | + if (loadFailed) { | |
| 126 | + return ( | |
| 127 | + <div className={styles.page}> | |
| 128 | + <div className={styles.loadError} data-testid="userdetail-loaderror"> | |
| 129 | + <span className={styles.loadErrorText}>{MSG_LOAD_DETAIL_FAIL}</span> | |
| 130 | + <Button type="primary" onClick={() => reload()}> | |
| 131 | + {TEXT_RETRY} | |
| 132 | + </Button> | |
| 133 | + </div> | |
| 134 | + </div> | |
| 135 | + ); | |
| 136 | + } | |
| 137 | + | |
| 138 | + return ( | |
| 139 | + <div className={styles.page} data-testid="userdetail-page"> | |
| 140 | + <UserDetailToolbar | |
| 141 | + mode={mode} | |
| 142 | + submitting={submitting} | |
| 143 | + canSave={!loading} | |
| 144 | + onSave={() => void handleSave()} | |
| 145 | + onCancel={handleCancel} | |
| 146 | + onNew={handleNew} | |
| 147 | + /> | |
| 148 | + <Spin spinning={loading}> | |
| 149 | + <Form form={form} layout="vertical" component={false}> | |
| 150 | + <UserBasicForm | |
| 151 | + form={form} | |
| 152 | + mode={mode} | |
| 153 | + employees={employees} | |
| 154 | + readonlyCreator={readonlyCreator} | |
| 155 | + readonlyCreateTime={readonlyCreateTime} | |
| 156 | + onSelectEmployee={handleSelectEmployee} | |
| 157 | + /> | |
| 158 | + </Form> | |
| 159 | + <PermissionTabs> | |
| 160 | + <PermissionGroupList | |
| 161 | + permissions={permissions} | |
| 162 | + checkedIds={checkedPermissionIds} | |
| 163 | + onToggle={togglePermission} | |
| 164 | + onToggleAll={toggleAll} | |
| 165 | + /> | |
| 166 | + </PermissionTabs> | |
| 167 | + </Spin> | |
| 168 | + </div> | |
| 169 | + ); | |
| 170 | +} | ... | ... |
frontend/src/pages/usr/UserDetail/useUserDetail.ts
| ... | ... | @@ -64,6 +64,7 @@ export interface UseUserDetailReturn { |
| 64 | 64 | submitting: boolean; |
| 65 | 65 | error: ApiError | null; |
| 66 | 66 | loadFailed: boolean; |
| 67 | + notFound: boolean; | |
| 67 | 68 | setField(name: keyof UserFormValues, value: unknown): void; |
| 68 | 69 | selectEmployee(value: number | null): void; |
| 69 | 70 | togglePermission(id: number, checked: boolean): void; |
| ... | ... | @@ -86,6 +87,7 @@ export function useUserDetail(args: UseUserDetailArgs): UseUserDetailReturn { |
| 86 | 87 | const [submitting, setSubmitting] = useState(false); |
| 87 | 88 | const [error, setError] = useState<ApiError | null>(null); |
| 88 | 89 | const [loadFailed, setLoadFailed] = useState(false); |
| 90 | + const [notFound, setNotFound] = useState(false); | |
| 89 | 91 | |
| 90 | 92 | const employeesRef = useRef<EmployeeOption[]>(employees); |
| 91 | 93 | employeesRef.current = employees; |
| ... | ... | @@ -108,6 +110,7 @@ export function useUserDetail(args: UseUserDetailArgs): UseUserDetailReturn { |
| 108 | 110 | const runLoad = useCallback(async () => { |
| 109 | 111 | setLoading(true); |
| 110 | 112 | setLoadFailed(false); |
| 113 | + setNotFound(false); | |
| 111 | 114 | try { |
| 112 | 115 | const [emps, perms] = await Promise.all([listEmployees(), listPermissions()]); |
| 113 | 116 | if (!mountedRef.current) return; |
| ... | ... | @@ -126,8 +129,8 @@ export function useUserDetail(args: UseUserDetailArgs): UseUserDetailReturn { |
| 126 | 129 | if (vo) { |
| 127 | 130 | initFromVo(vo); |
| 128 | 131 | } else { |
| 129 | - // 详情不存在:交由页面按 40401 路径处理,标记 loadFailed | |
| 130 | - setLoadFailed(true); | |
| 132 | + // 详情不存在:交由页面按 40401 路径处理(返回列表入口) | |
| 133 | + setNotFound(true); | |
| 131 | 134 | messageRef.current.error(MSG_ERR_USER_NOT_FOUND); |
| 132 | 135 | setLoading(false); |
| 133 | 136 | return; |
| ... | ... | @@ -256,6 +259,7 @@ export function useUserDetail(args: UseUserDetailArgs): UseUserDetailReturn { |
| 256 | 259 | submitting, |
| 257 | 260 | error, |
| 258 | 261 | loadFailed, |
| 262 | + notFound, | |
| 259 | 263 | setField, |
| 260 | 264 | selectEmployee, |
| 261 | 265 | togglePermission, | ... | ... |
frontend/src/router/index.tsx
| 1 | 1 | // REQ-USR-003 / REQ-USR-004: 路由表(FE 共享骨架)。 |
| 2 | 2 | // FE-02 将 '/' 占位替换为应用外壳 + 受保护嵌套路由 + 错误边界。 |
| 3 | -// 子路由目标内容(FE-03 用户列表 / FE-04 用户单据)由后续 FE 落地,本处仅留可挂载占位。 | |
| 3 | +// FE-04 将 /usr/users/new 与 /usr/users/:id 占位替换为真实「用户信息单据」页 UserDetailPage。 | |
| 4 | 4 | import { Routes, Route, Navigate } from 'react-router-dom'; |
| 5 | 5 | import LoginPage from '../pages/usr/Login/LoginPage'; |
| 6 | 6 | import RequireAuth from './RequireAuth'; |
| ... | ... | @@ -9,11 +9,7 @@ import AppErrorBoundary from './AppErrorBoundary'; |
| 9 | 9 | import AppLayout from '../layouts/AppLayout/AppLayout'; |
| 10 | 10 | import HomePage from '../pages/home/HomePage/HomePage'; |
| 11 | 11 | import UserListPage from '../pages/usr/UserList'; |
| 12 | - | |
| 13 | -// FE-04 用户单据容器占位(新增 / 修改) | |
| 14 | -function UserDetailPlaceholder() { | |
| 15 | - return <div data-testid="fe04-userdetail-placeholder" />; | |
| 16 | -} | |
| 12 | +import UserDetailPage from '../pages/usr/UserDetail'; | |
| 17 | 13 | |
| 18 | 14 | export default function AppRouter() { |
| 19 | 15 | return ( |
| ... | ... | @@ -39,8 +35,8 @@ export default function AppRouter() { |
| 39 | 35 | > |
| 40 | 36 | <Route index element={<HomePage />} /> |
| 41 | 37 | <Route path="/usr/users" element={<UserListPage />} /> |
| 42 | - <Route path="/usr/users/new" element={<UserDetailPlaceholder />} /> | |
| 43 | - <Route path="/usr/users/:id" element={<UserDetailPlaceholder />} /> | |
| 38 | + <Route path="/usr/users/new" element={<UserDetailPage />} /> | |
| 39 | + <Route path="/usr/users/:id" element={<UserDetailPage />} /> | |
| 44 | 40 | {/* 受保护区内未匹配 → 回主页(D7) */} |
| 45 | 41 | <Route path="*" element={<Navigate to="/" replace />} /> |
| 46 | 42 | </Route> | ... | ... |
frontend/tests/unit/UserDetailPage.test.tsx
0 → 100644
| 1 | +// REQ-USR-001 / REQ-USR-002: UserDetailPage 页面集成 + 路由接线 | |
| 2 | +// create/edit 贯通 + 提交回流 + 错误就近 + 取数失败(BR3/BR12/BR16/BR17/D4/D5) | |
| 3 | +import { describe, it, expect, vi, beforeEach } from 'vitest'; | |
| 4 | +import { screen, waitFor, within } from '@testing-library/react'; | |
| 5 | +import userEvent from '@testing-library/user-event'; | |
| 6 | +import { Routes, Route, useLocation } from 'react-router-dom'; | |
| 7 | +import { renderShell } from './renderShell'; | |
| 8 | + | |
| 9 | +const messageSpy = { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() }; | |
| 10 | +vi.mock('antd', async () => { | |
| 11 | + const actual = await vi.importActual<typeof import('antd')>('antd'); | |
| 12 | + return { | |
| 13 | + ...actual, | |
| 14 | + App: Object.assign(actual.App, { useApp: () => ({ message: messageSpy }) }), | |
| 15 | + }; | |
| 16 | +}); | |
| 17 | + | |
| 18 | +vi.mock('../../src/api/usrApi', () => ({ | |
| 19 | + createUser: vi.fn(), | |
| 20 | + updateUser: vi.fn(), | |
| 21 | + getUserDetail: vi.fn(), | |
| 22 | + listEmployees: vi.fn(), | |
| 23 | + listPermissions: vi.fn(), | |
| 24 | +})); | |
| 25 | + | |
| 26 | +import { | |
| 27 | + createUser, | |
| 28 | + updateUser, | |
| 29 | + getUserDetail, | |
| 30 | + listEmployees, | |
| 31 | + listPermissions, | |
| 32 | +} from '../../src/api/usrApi'; | |
| 33 | +import UserDetailPage from '../../src/pages/usr/UserDetail'; | |
| 34 | +import { ApiError } from '../../src/api/request'; | |
| 35 | +import { ERR_USERNAME_EXISTS } from '../../src/pages/usr/UserDetail/constants'; | |
| 36 | +import type { UserVO, EmployeeOption, PermissionItem } from '../../src/api/types'; | |
| 37 | + | |
| 38 | +const mockedCreate = createUser as unknown as ReturnType<typeof vi.fn>; | |
| 39 | +const mockedUpdate = updateUser as unknown as ReturnType<typeof vi.fn>; | |
| 40 | +const mockedDetail = getUserDetail as unknown as ReturnType<typeof vi.fn>; | |
| 41 | +const mockedEmployees = listEmployees as unknown as ReturnType<typeof vi.fn>; | |
| 42 | +const mockedPermissions = listPermissions as unknown as ReturnType<typeof vi.fn>; | |
| 43 | + | |
| 44 | +const EMPLOYEES: EmployeeOption[] = [{ value: 3, label: '张三', sEmployeeNo: 'zs' }]; | |
| 45 | +const PERMISSIONS: PermissionItem[] = [ | |
| 46 | + { id: 1, name: '默认显示', category: '基础' }, | |
| 47 | + { id: 2, name: '高级查看', category: '基础' }, | |
| 48 | +]; | |
| 49 | + | |
| 50 | +function makeVo(over: Partial<UserVO> = {}): UserVO { | |
| 51 | + return { | |
| 52 | + id: 7, | |
| 53 | + sUserName: 'zhangsan', | |
| 54 | + employeeName: '张三', | |
| 55 | + sUserNo: 'zs', | |
| 56 | + departmentName: null, | |
| 57 | + sUserType: '超级管理员', | |
| 58 | + sLanguage: '英文', | |
| 59 | + iIsVoid: 0, | |
| 60 | + tLastLoginDate: null, | |
| 61 | + sCreator: 'admin', | |
| 62 | + tCreateDate: '2026-01-01T00:00:00', | |
| 63 | + ...over, | |
| 64 | + }; | |
| 65 | +} | |
| 66 | + | |
| 67 | +function LocationProbe() { | |
| 68 | + const loc = useLocation(); | |
| 69 | + return <div data-testid="loc">{loc.pathname}</div>; | |
| 70 | +} | |
| 71 | + | |
| 72 | +function renderPage(entry: string) { | |
| 73 | + return renderShell( | |
| 74 | + <> | |
| 75 | + <LocationProbe /> | |
| 76 | + <Routes> | |
| 77 | + <Route path="/usr/users" element={<div data-testid="list-sentinel">list</div>} /> | |
| 78 | + <Route path="/usr/users/new" element={<UserDetailPage />} /> | |
| 79 | + <Route path="/usr/users/:id" element={<UserDetailPage />} /> | |
| 80 | + </Routes> | |
| 81 | + </>, | |
| 82 | + { | |
| 83 | + initialEntries: [entry], | |
| 84 | + preloadedAuth: { | |
| 85 | + token: 't', | |
| 86 | + user: { id: 1, sUserName: '朱子纯', sUserType: '超级管理员', sLanguage: '中文' }, | |
| 87 | + }, | |
| 88 | + }, | |
| 89 | + ); | |
| 90 | +} | |
| 91 | + | |
| 92 | +async function fillValidCreateForm(user: ReturnType<typeof userEvent.setup>) { | |
| 93 | + await user.type(screen.getByTestId('field-username'), 'zhangsan'); | |
| 94 | + await user.type(screen.getByTestId('field-userno'), 'zs'); | |
| 95 | + // 语言必填 | |
| 96 | + await user.click(screen.getByTestId('select-language').querySelector('.ant-select-selector')!); | |
| 97 | + await user.click(await screen.findByText('中文')); | |
| 98 | +} | |
| 99 | + | |
| 100 | +describe('UserDetailPage 集成', () => { | |
| 101 | + beforeEach(() => { | |
| 102 | + vi.clearAllMocks(); | |
| 103 | + mockedEmployees.mockResolvedValue(EMPLOYEES); | |
| 104 | + mockedPermissions.mockResolvedValue(PERMISSIONS); | |
| 105 | + }); | |
| 106 | + | |
| 107 | + it('create mode renders empty form with defaults', async () => { | |
| 108 | + renderPage('/usr/users/new'); | |
| 109 | + await waitFor(() => expect(mockedEmployees).toHaveBeenCalled()); | |
| 110 | + expect(await screen.findByText('保存后自动生成')).toBeInTheDocument(); | |
| 111 | + expect(within(screen.getByTestId('select-usertype')).getByText('普通用户')).toBeInTheDocument(); | |
| 112 | + }); | |
| 113 | + | |
| 114 | + it('create submit success navigates to /usr/users with success', async () => { | |
| 115 | + const user = userEvent.setup(); | |
| 116 | + mockedCreate.mockResolvedValue({ id: 9 }); | |
| 117 | + renderPage('/usr/users/new'); | |
| 118 | + await waitFor(() => expect(mockedEmployees).toHaveBeenCalled()); | |
| 119 | + await fillValidCreateForm(user); | |
| 120 | + await user.click(screen.getByTestId('perm-check-1')); | |
| 121 | + await user.click(screen.getByTestId('btn-save')); | |
| 122 | + await waitFor(() => expect(mockedCreate).toHaveBeenCalled()); | |
| 123 | + const body = mockedCreate.mock.calls[0][0]; | |
| 124 | + expect(body.sUserName).toBe('zhangsan'); | |
| 125 | + expect(body.permissionIds).toContain(1); | |
| 126 | + expect(messageSpy.success).toHaveBeenCalledWith('用户创建成功'); | |
| 127 | + await waitFor(() => expect(screen.getByTestId('loc').textContent).toBe('/usr/users')); | |
| 128 | + }); | |
| 129 | + | |
| 130 | + it('create username format invalid blocks submit', async () => { | |
| 131 | + const user = userEvent.setup(); | |
| 132 | + renderPage('/usr/users/new'); | |
| 133 | + await waitFor(() => expect(mockedEmployees).toHaveBeenCalled()); | |
| 134 | + await user.type(screen.getByTestId('field-username'), 'ab'); | |
| 135 | + await user.click(screen.getByTestId('btn-save')); | |
| 136 | + expect(await screen.findByText('用户名须为 3-20 位字母数字下划线')).toBeInTheDocument(); | |
| 137 | + expect(mockedCreate).not.toHaveBeenCalled(); | |
| 138 | + }); | |
| 139 | + | |
| 140 | + it('create 40901 highlights username field', async () => { | |
| 141 | + const user = userEvent.setup(); | |
| 142 | + mockedCreate.mockRejectedValue(new ApiError(ERR_USERNAME_EXISTS, 'dup')); | |
| 143 | + renderPage('/usr/users/new'); | |
| 144 | + await waitFor(() => expect(mockedEmployees).toHaveBeenCalled()); | |
| 145 | + await fillValidCreateForm(user); | |
| 146 | + await user.click(screen.getByTestId('btn-save')); | |
| 147 | + await waitFor(() => expect(mockedCreate).toHaveBeenCalled()); | |
| 148 | + expect(await screen.findByText('用户名已存在,请更换')).toBeInTheDocument(); | |
| 149 | + }); | |
| 150 | + | |
| 151 | + it('edit mode prefills from getUserDetail and username disabled', async () => { | |
| 152 | + mockedDetail.mockResolvedValue(makeVo()); | |
| 153 | + renderPage('/usr/users/7'); | |
| 154 | + await waitFor(() => expect(mockedDetail).toHaveBeenCalled()); | |
| 155 | + await waitFor(() => | |
| 156 | + expect((screen.getByTestId('field-username') as HTMLInputElement).value).toBe('zhangsan'), | |
| 157 | + ); | |
| 158 | + expect(screen.getByTestId('field-username')).toBeDisabled(); | |
| 159 | + }); | |
| 160 | + | |
| 161 | + it('edit submit success navigates to /usr/users with 保存成功', async () => { | |
| 162 | + const user = userEvent.setup(); | |
| 163 | + mockedDetail.mockResolvedValue(makeVo()); | |
| 164 | + mockedUpdate.mockResolvedValue({ id: 7 }); | |
| 165 | + renderPage('/usr/users/7'); | |
| 166 | + await waitFor(() => expect(mockedDetail).toHaveBeenCalled()); | |
| 167 | + await waitFor(() => | |
| 168 | + expect((screen.getByTestId('field-username') as HTMLInputElement).value).toBe('zhangsan'), | |
| 169 | + ); | |
| 170 | + await user.click(screen.getByTestId('btn-save')); | |
| 171 | + await waitFor(() => expect(mockedUpdate).toHaveBeenCalled()); | |
| 172 | + expect(mockedUpdate.mock.calls[0][0]).toBe(7); | |
| 173 | + expect(messageSpy.success).toHaveBeenCalledWith('保存成功'); | |
| 174 | + await waitFor(() => expect(screen.getByTestId('loc').textContent).toBe('/usr/users')); | |
| 175 | + }); | |
| 176 | + | |
| 177 | + it('cancel with dirty form confirms then navigates', async () => { | |
| 178 | + const user = userEvent.setup(); | |
| 179 | + renderPage('/usr/users/new'); | |
| 180 | + await waitFor(() => expect(mockedEmployees).toHaveBeenCalled()); | |
| 181 | + await user.type(screen.getByTestId('field-username'), 'dirtyuser'); | |
| 182 | + await user.click(screen.getByTestId('btn-cancel')); | |
| 183 | + // AntD Modal.confirm 弹确认 | |
| 184 | + expect((await screen.findAllByText('放弃未保存的修改?')).length).toBeGreaterThan(0); | |
| 185 | + await user.click(screen.getByRole('button', { name: /确\s*定|OK/ })); | |
| 186 | + await waitFor(() => expect(screen.getByTestId('loc').textContent).toBe('/usr/users')); | |
| 187 | + }); | |
| 188 | + | |
| 189 | + it('新增 navigates to /usr/users/new', async () => { | |
| 190 | + const user = userEvent.setup(); | |
| 191 | + mockedDetail.mockResolvedValue(makeVo()); | |
| 192 | + renderPage('/usr/users/7'); | |
| 193 | + await waitFor(() => expect(mockedDetail).toHaveBeenCalled()); | |
| 194 | + await user.click(screen.getByTestId('btn-new')); | |
| 195 | + await waitFor(() => expect(screen.getByTestId('loc').textContent).toBe('/usr/users/new')); | |
| 196 | + }); | |
| 197 | + | |
| 198 | + it('loadError shows retry; retry calls reload', async () => { | |
| 199 | + mockedPermissions.mockRejectedValueOnce(new ApiError(-1, 'net')); | |
| 200 | + renderPage('/usr/users/new'); | |
| 201 | + expect(await screen.findByTestId('userdetail-loaderror')).toBeInTheDocument(); | |
| 202 | + mockedPermissions.mockResolvedValue(PERMISSIONS); | |
| 203 | + const user = userEvent.setup(); | |
| 204 | + await user.click(within(screen.getByTestId('userdetail-loaderror')).getByText('点击重试')); | |
| 205 | + await waitFor(() => expect(screen.queryByTestId('userdetail-loaderror')).toBeNull()); | |
| 206 | + }); | |
| 207 | + | |
| 208 | + it('edit 40401 offers 返回列表', async () => { | |
| 209 | + mockedDetail.mockResolvedValue(null); | |
| 210 | + renderPage('/usr/users/7'); | |
| 211 | + expect(await screen.findByText('该用户不存在或已被删除')).toBeInTheDocument(); | |
| 212 | + const user = userEvent.setup(); | |
| 213 | + await user.click(screen.getByText('返回列表')); | |
| 214 | + await waitFor(() => expect(screen.getByTestId('loc').textContent).toBe('/usr/users')); | |
| 215 | + }); | |
| 216 | +}); | ... | ... |