Commit 96e88d38256f5ccbf6a77dc15c37c8736830a2fb
1 parent
b9c7da30
fix(usr): 修复 review must-fix FE: FE-04 编辑预填走 navigate state 并补 loadError 返回列表入口
Showing
6 changed files
with
65 additions
and
60 deletions
frontend/src/pages/usr/UserDetail/index.tsx
| ... | ... | @@ -13,7 +13,6 @@ import { |
| 13 | 13 | MODE_EDIT, |
| 14 | 14 | MSG_CREATE_SUCCESS, |
| 15 | 15 | MSG_EDIT_SUCCESS, |
| 16 | - MSG_ERR_USER_NOT_FOUND, | |
| 17 | 16 | MSG_LOAD_DETAIL_FAIL, |
| 18 | 17 | MSG_CANCEL_CONFIRM, |
| 19 | 18 | PATH_USER_LIST, |
| ... | ... | @@ -54,7 +53,6 @@ export default function UserDetailPage() { |
| 54 | 53 | loading, |
| 55 | 54 | submitting, |
| 56 | 55 | loadFailed, |
| 57 | - notFound, | |
| 58 | 56 | selectEmployee, |
| 59 | 57 | togglePermission, |
| 60 | 58 | toggleAll, |
| ... | ... | @@ -107,21 +105,10 @@ export default function UserDetailPage() { |
| 107 | 105 | selectEmployee(value); |
| 108 | 106 | }; |
| 109 | 107 | |
| 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) | |
| 108 | + // 预取/详情取数失败:整页重试入口(spec § 4 loadError)。 | |
| 109 | + // edit 态额外给「返回列表」——edit 预填只能来自列表行经 navigate state 透传的 | |
| 110 | + // presetUser(无 by-id 读端点,详见 useUserDetail),缺 state 时重试仍会回到 loadError, | |
| 111 | + // 需经列表双击重新携带 state 进入,故提供返回列表入口(spec § 4「edit 详情失败给整页重试或返回列表」)。 | |
| 125 | 112 | if (loadFailed) { |
| 126 | 113 | return ( |
| 127 | 114 | <div className={styles.page}> |
| ... | ... | @@ -130,6 +117,11 @@ export default function UserDetailPage() { |
| 130 | 117 | <Button type="primary" onClick={() => reload()}> |
| 131 | 118 | {TEXT_RETRY} |
| 132 | 119 | </Button> |
| 120 | + {mode === MODE_EDIT && ( | |
| 121 | + <Button onClick={() => navigate(PATH_USER_LIST)}> | |
| 122 | + {TEXT_BACK_TO_LIST} | |
| 123 | + </Button> | |
| 124 | + )} | |
| 133 | 125 | </div> |
| 134 | 126 | </div> |
| 135 | 127 | ); | ... | ... |
frontend/src/pages/usr/UserDetail/useUserDetail.ts
| ... | ... | @@ -4,7 +4,6 @@ import { App as AntdApp } from 'antd'; |
| 4 | 4 | import { |
| 5 | 5 | createUser, |
| 6 | 6 | updateUser, |
| 7 | - getUserDetail, | |
| 8 | 7 | listEmployees, |
| 9 | 8 | listPermissions, |
| 10 | 9 | } from '../../../api/usrApi'; |
| ... | ... | @@ -64,7 +63,6 @@ export interface UseUserDetailReturn { |
| 64 | 63 | submitting: boolean; |
| 65 | 64 | error: ApiError | null; |
| 66 | 65 | loadFailed: boolean; |
| 67 | - notFound: boolean; | |
| 68 | 66 | setField(name: keyof UserFormValues, value: unknown): void; |
| 69 | 67 | selectEmployee(value: number | null): void; |
| 70 | 68 | togglePermission(id: number, checked: boolean): void; |
| ... | ... | @@ -87,7 +85,6 @@ export function useUserDetail(args: UseUserDetailArgs): UseUserDetailReturn { |
| 87 | 85 | const [submitting, setSubmitting] = useState(false); |
| 88 | 86 | const [error, setError] = useState<ApiError | null>(null); |
| 89 | 87 | const [loadFailed, setLoadFailed] = useState(false); |
| 90 | - const [notFound, setNotFound] = useState(false); | |
| 91 | 88 | |
| 92 | 89 | const employeesRef = useRef<EmployeeOption[]>(employees); |
| 93 | 90 | employeesRef.current = employees; |
| ... | ... | @@ -110,7 +107,6 @@ export function useUserDetail(args: UseUserDetailArgs): UseUserDetailReturn { |
| 110 | 107 | const runLoad = useCallback(async () => { |
| 111 | 108 | setLoading(true); |
| 112 | 109 | setLoadFailed(false); |
| 113 | - setNotFound(false); | |
| 114 | 110 | try { |
| 115 | 111 | const [emps, perms] = await Promise.all([listEmployees(), listPermissions()]); |
| 116 | 112 | if (!mountedRef.current) return; |
| ... | ... | @@ -120,21 +116,18 @@ export function useUserDetail(args: UseUserDetailArgs): UseUserDetailReturn { |
| 120 | 116 | if (mode === 'edit') { |
| 121 | 117 | if (presetUser) { |
| 122 | 118 | initFromVo(presetUser); |
| 123 | - } else if (userId != null) { | |
| 124 | - const vo = await getUserDetail({ | |
| 125 | - queryField: '用户号', | |
| 126 | - queryValue: String(userId), | |
| 127 | - }); | |
| 119 | + } else { | |
| 120 | + // FE-04: edit 预填只能来自 FE-03 经 navigate state 透传的列表行(presetUser)。 | |
| 121 | + // 路由 :id 是用户主键,而唯一读端点 GET /api/usr/users 的 queryField 无主键选项 | |
| 122 | + // (docs/05 REQ-USR-003),故不能按主键去查「用户号」列——在真实后端必然返回空、 | |
| 123 | + // 误报 40401,无法满足编辑预填(BR17 / REQ-USR-002 验收)。 | |
| 124 | + // 缺 presetUser(如直接访问 URL / 刷新丢失 state)时按 loadError 处理, | |
| 125 | + // 由页面给出「返回列表」重试入口,重新经列表双击携带 state 进入。 | |
| 128 | 126 | if (!mountedRef.current) return; |
| 129 | - if (vo) { | |
| 130 | - initFromVo(vo); | |
| 131 | - } else { | |
| 132 | - // 详情不存在:交由页面按 40401 路径处理(返回列表入口) | |
| 133 | - setNotFound(true); | |
| 134 | - messageRef.current.error(MSG_ERR_USER_NOT_FOUND); | |
| 135 | - setLoading(false); | |
| 136 | - return; | |
| 137 | - } | |
| 127 | + setLoading(false); | |
| 128 | + setLoadFailed(true); | |
| 129 | + messageRef.current.error(MSG_LOAD_DETAIL_FAIL); | |
| 130 | + return; | |
| 138 | 131 | } |
| 139 | 132 | } else { |
| 140 | 133 | setFormValues({ ...CREATE_DEFAULTS }); |
| ... | ... | @@ -259,7 +252,6 @@ export function useUserDetail(args: UseUserDetailArgs): UseUserDetailReturn { |
| 259 | 252 | submitting, |
| 260 | 253 | error, |
| 261 | 254 | loadFailed, |
| 262 | - notFound, | |
| 263 | 255 | setField, |
| 264 | 256 | selectEmployee, |
| 265 | 257 | togglePermission, | ... | ... |
frontend/src/pages/usr/UserList/index.tsx
| ... | ... | @@ -33,7 +33,12 @@ export default function UserListPage() { |
| 33 | 33 | const [selectedRowKey, setSelectedRowKey] = useState<number | null>(null); |
| 34 | 34 | |
| 35 | 35 | const handleRowDoubleClick = (row: UserVO) => { |
| 36 | - navigate('/usr/users/' + row.id); // BR12 | |
| 36 | + // BR12: 进入编辑单据。列表行 UserVO 已含单据预填所需全部字段, | |
| 37 | + // 经 navigate state 透传给详情页走 presetUser 分支; | |
| 38 | + // 不能仅靠 :id(路由 :id 为用户主键,而读端点 GET /api/usr/users 的 queryField | |
| 39 | + // 无主键选项,按主键去查「用户号」列在真实后端必然返回空 → 误报 40401), | |
| 40 | + // 详见 FE-04 / docs/05 REQ-USR-002。 | |
| 41 | + navigate('/usr/users/' + row.id, { state: { user: row } }); | |
| 37 | 42 | }; |
| 38 | 43 | |
| 39 | 44 | return ( | ... | ... |
frontend/tests/unit/UserDetailPage.test.tsx
| ... | ... | @@ -69,7 +69,13 @@ function LocationProbe() { |
| 69 | 69 | return <div data-testid="loc">{loc.pathname}</div>; |
| 70 | 70 | } |
| 71 | 71 | |
| 72 | -function renderPage(entry: string) { | |
| 72 | +// FE-04: edit 单据预填来自 FE-03 经 navigate state 透传的列表行(presetUser)。 | |
| 73 | +// 测试入口可携带 state.user 复刻该数据流(路由 :id 仅为主键,无 by-id 读端点, | |
| 74 | +// 不能按主键查「用户号」列——详见 useUserDetail / docs/05 REQ-USR-002/003)。 | |
| 75 | +function renderPage(entry: string, presetUser?: UserVO) { | |
| 76 | + const initialEntry = presetUser | |
| 77 | + ? { pathname: entry, state: { user: presetUser } } | |
| 78 | + : entry; | |
| 73 | 79 | return renderShell( |
| 74 | 80 | <> |
| 75 | 81 | <LocationProbe /> |
| ... | ... | @@ -80,7 +86,7 @@ function renderPage(entry: string) { |
| 80 | 86 | </Routes> |
| 81 | 87 | </>, |
| 82 | 88 | { |
| 83 | - initialEntries: [entry], | |
| 89 | + initialEntries: [initialEntry], | |
| 84 | 90 | preloadedAuth: { |
| 85 | 91 | token: 't', |
| 86 | 92 | user: { id: 1, sUserName: '朱子纯', sUserType: '超级管理员', sLanguage: '中文' }, |
| ... | ... | @@ -148,22 +154,20 @@ describe('UserDetailPage 集成', () => { |
| 148 | 154 | expect(await screen.findByText('用户名已存在,请更换')).toBeInTheDocument(); |
| 149 | 155 | }); |
| 150 | 156 | |
| 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()); | |
| 157 | + it('edit mode prefills from navigate state (presetUser) and username disabled', async () => { | |
| 158 | + // FE-04: edit 预填走 FE-03 经 navigate state 透传的列表行,不再按主键查列表端点 | |
| 159 | + renderPage('/usr/users/7', makeVo()); | |
| 155 | 160 | await waitFor(() => |
| 156 | 161 | expect((screen.getByTestId('field-username') as HTMLInputElement).value).toBe('zhangsan'), |
| 157 | 162 | ); |
| 163 | + expect(mockedDetail).not.toHaveBeenCalled(); | |
| 158 | 164 | expect(screen.getByTestId('field-username')).toBeDisabled(); |
| 159 | 165 | }); |
| 160 | 166 | |
| 161 | 167 | it('edit submit success navigates to /usr/users with 保存成功', async () => { |
| 162 | 168 | const user = userEvent.setup(); |
| 163 | - mockedDetail.mockResolvedValue(makeVo()); | |
| 164 | 169 | mockedUpdate.mockResolvedValue({ id: 7 }); |
| 165 | - renderPage('/usr/users/7'); | |
| 166 | - await waitFor(() => expect(mockedDetail).toHaveBeenCalled()); | |
| 170 | + renderPage('/usr/users/7', makeVo()); | |
| 167 | 171 | await waitFor(() => |
| 168 | 172 | expect((screen.getByTestId('field-username') as HTMLInputElement).value).toBe('zhangsan'), |
| 169 | 173 | ); |
| ... | ... | @@ -188,9 +192,11 @@ describe('UserDetailPage 集成', () => { |
| 188 | 192 | |
| 189 | 193 | it('新增 navigates to /usr/users/new', async () => { |
| 190 | 194 | const user = userEvent.setup(); |
| 191 | - mockedDetail.mockResolvedValue(makeVo()); | |
| 192 | - renderPage('/usr/users/7'); | |
| 193 | - await waitFor(() => expect(mockedDetail).toHaveBeenCalled()); | |
| 195 | + // edit 经 navigate state 预填后,工具栏「新增」跳 /usr/users/new(BR14) | |
| 196 | + renderPage('/usr/users/7', makeVo()); | |
| 197 | + await waitFor(() => | |
| 198 | + expect((screen.getByTestId('field-username') as HTMLInputElement).value).toBe('zhangsan'), | |
| 199 | + ); | |
| 194 | 200 | await user.click(screen.getByTestId('btn-new')); |
| 195 | 201 | await waitFor(() => expect(screen.getByTestId('loc').textContent).toBe('/usr/users/new')); |
| 196 | 202 | }); |
| ... | ... | @@ -205,12 +211,16 @@ describe('UserDetailPage 集成', () => { |
| 205 | 211 | await waitFor(() => expect(screen.queryByTestId('userdetail-loaderror')).toBeNull()); |
| 206 | 212 | }); |
| 207 | 213 | |
| 208 | - it('edit 40401 offers 返回列表', async () => { | |
| 209 | - mockedDetail.mockResolvedValue(null); | |
| 214 | + it('edit without navigate state shows loadError offering 点击重试 + 返回列表', async () => { | |
| 215 | + // FE-04 B1 fix: edit 缺 presetUser(直接访问 URL / 刷新丢 state)→ loadError, | |
| 216 | + // 整页给「点击重试」与「返回列表」两个入口(spec § 4 loadError)。 | |
| 210 | 217 | renderPage('/usr/users/7'); |
| 211 | - expect(await screen.findByText('该用户不存在或已被删除')).toBeInTheDocument(); | |
| 218 | + const loadError = await screen.findByTestId('userdetail-loaderror'); | |
| 219 | + expect(within(loadError).getByText('点击重试')).toBeInTheDocument(); | |
| 220 | + expect(within(loadError).getByText('返回列表')).toBeInTheDocument(); | |
| 221 | + expect(mockedDetail).not.toHaveBeenCalled(); | |
| 212 | 222 | const user = userEvent.setup(); |
| 213 | - await user.click(screen.getByText('返回列表')); | |
| 223 | + await user.click(within(loadError).getByText('返回列表')); | |
| 214 | 224 | await waitFor(() => expect(screen.getByTestId('loc').textContent).toBe('/usr/users')); |
| 215 | 225 | }); |
| 216 | 226 | }); | ... | ... |
frontend/tests/unit/renderShell.tsx
| ... | ... | @@ -4,6 +4,10 @@ import type { ReactElement } from 'react'; |
| 4 | 4 | import { render } from '@testing-library/react'; |
| 5 | 5 | import { Provider } from 'react-redux'; |
| 6 | 6 | import { MemoryRouter } from 'react-router-dom'; |
| 7 | + | |
| 8 | +// react-router-dom 未公开 re-export InitialEntry,这里按其 MemoryRouter 接受的形态本地声明: | |
| 9 | +// 纯路径字符串,或携带 navigate state 的 Partial<Location>(FE-04 edit 预填数据流需要)。 | |
| 10 | +type ShellInitialEntry = string | { pathname: string; search?: string; hash?: string; state?: unknown }; | |
| 7 | 11 | import { App as AntdApp, ConfigProvider } from 'antd'; |
| 8 | 12 | import zhCN from 'antd/locale/zh_CN'; |
| 9 | 13 | import { configureStore } from '@reduxjs/toolkit'; |
| ... | ... | @@ -21,7 +25,9 @@ export function makeShellStore(preloadedAuth?: Partial<AuthState>) { |
| 21 | 25 | } |
| 22 | 26 | |
| 23 | 27 | export interface RenderShellOptions { |
| 24 | - initialEntries?: string[]; | |
| 28 | + // FE-04: 允许传 Partial<Location>(携带 navigate state),用于复刻 edit 单据经 | |
| 29 | + // navigate state 透传 presetUser 的数据流,不仅限纯路径字符串。 | |
| 30 | + initialEntries?: ShellInitialEntry[]; | |
| 25 | 31 | preloadedAuth?: Partial<AuthState>; |
| 26 | 32 | store?: ReturnType<typeof makeShellStore>; |
| 27 | 33 | } | ... | ... |
frontend/tests/unit/useUserDetail.test.tsx
| ... | ... | @@ -118,17 +118,17 @@ describe('useUserDetail 状态机', () => { |
| 118 | 118 | expect(mockedDetail).not.toHaveBeenCalled(); |
| 119 | 119 | }); |
| 120 | 120 | |
| 121 | - it('edit mode prefills from getUserDetail and pre-checks permissions', async () => { | |
| 122 | - mockedDetail.mockResolvedValue(makeVo()); | |
| 121 | + it('edit mode without presetUser sets loadFailed without calling getUserDetail', async () => { | |
| 122 | + // FE-04 B1 fix: 路由 :id 为用户主键,无 by-id 读端点(docs/05 REQ-USR-003), | |
| 123 | + // 不能按主键查列表端点;缺 presetUser(直接访问 URL / 刷新丢 state)时按 loadError 处理, | |
| 124 | + // 由页面给出「返回列表」恢复入口。 | |
| 123 | 125 | const { result } = renderHook( |
| 124 | 126 | () => useUserDetail({ mode: 'edit', userId: 7 }), |
| 125 | 127 | { wrapper }, |
| 126 | 128 | ); |
| 127 | 129 | await waitFor(() => expect(result.current.loading).toBe(false)); |
| 128 | - expect(mockedDetail).toHaveBeenCalled(); | |
| 129 | - expect(result.current.formValues.sUserName).toBe('zhangsan'); | |
| 130 | - expect(result.current.formValues.sUserType).toBe('超级管理员'); | |
| 131 | - expect(result.current.formValues.sLanguage).toBe('英文'); | |
| 130 | + expect(result.current.loadFailed).toBe(true); | |
| 131 | + expect(mockedDetail).not.toHaveBeenCalled(); | |
| 132 | 132 | }); |
| 133 | 133 | |
| 134 | 134 | it('edit mode with presetUser skips getUserDetail', async () => { | ... | ... |