Commit bdfcfae299dbdec30d80ffeaac57e3e77c1c6d3d
1 parent
06252b2c
feat(usr): 用户单据工具栏 UserDetailToolbar REQ-USR-001 REQ-USR-002
Showing
2 changed files
with
181 additions
and
0 deletions
frontend/src/pages/usr/UserDetail/UserDetailToolbar.tsx
0 → 100644
| 1 | +// REQ-USR-001 / REQ-USR-002: 用户单据工具栏(保存/取消/新增 + 占位按钮 + 齿轮,BR12/BR13/BR14/BR15/D8/D10) | |
| 2 | +import { Button, App as AntdApp } from 'antd'; | |
| 3 | +import { | |
| 4 | + SaveOutlined, | |
| 5 | + CloseCircleOutlined, | |
| 6 | + PlusCircleOutlined, | |
| 7 | + SettingOutlined, | |
| 8 | +} from '@ant-design/icons'; | |
| 9 | +import { | |
| 10 | + TEXT_SAVE, | |
| 11 | + TEXT_CANCEL, | |
| 12 | + TEXT_NEW, | |
| 13 | + TEXT_DELETE, | |
| 14 | + TEXT_VOID, | |
| 15 | + TEXT_RESET_PWD, | |
| 16 | + TEXT_UNVOID, | |
| 17 | + TEXT_FUNC, | |
| 18 | + MSG_FUNC_PLACEHOLDER, | |
| 19 | +} from './constants'; | |
| 20 | +import styles from './UserDetail.module.css'; | |
| 21 | + | |
| 22 | +export interface UserDetailToolbarProps { | |
| 23 | + mode: 'create' | 'edit'; | |
| 24 | + submitting: boolean; | |
| 25 | + canSave: boolean; | |
| 26 | + onSave(): void; | |
| 27 | + onCancel(): void; | |
| 28 | + onNew(): void; | |
| 29 | +} | |
| 30 | + | |
| 31 | +const PLACEHOLDER_BUTTONS = [TEXT_DELETE, TEXT_VOID, TEXT_RESET_PWD, TEXT_UNVOID, TEXT_FUNC]; | |
| 32 | + | |
| 33 | +export default function UserDetailToolbar({ | |
| 34 | + submitting, | |
| 35 | + canSave, | |
| 36 | + onSave, | |
| 37 | + onCancel, | |
| 38 | + onNew, | |
| 39 | +}: UserDetailToolbarProps) { | |
| 40 | + const { message } = AntdApp.useApp(); | |
| 41 | + const placeholder = () => message.info(MSG_FUNC_PLACEHOLDER); | |
| 42 | + | |
| 43 | + return ( | |
| 44 | + <div className={styles.toolbar}> | |
| 45 | + <Button | |
| 46 | + type="primary" | |
| 47 | + icon={<SaveOutlined />} | |
| 48 | + loading={submitting} | |
| 49 | + disabled={submitting || !canSave} | |
| 50 | + onClick={() => onSave()} | |
| 51 | + data-testid="btn-save" | |
| 52 | + > | |
| 53 | + {TEXT_SAVE} | |
| 54 | + </Button> | |
| 55 | + <Button | |
| 56 | + type="text" | |
| 57 | + icon={<CloseCircleOutlined />} | |
| 58 | + onClick={() => onCancel()} | |
| 59 | + data-testid="btn-cancel" | |
| 60 | + > | |
| 61 | + {TEXT_CANCEL} | |
| 62 | + </Button> | |
| 63 | + <Button | |
| 64 | + type="text" | |
| 65 | + icon={<PlusCircleOutlined />} | |
| 66 | + onClick={() => onNew()} | |
| 67 | + data-testid="btn-new" | |
| 68 | + > | |
| 69 | + {TEXT_NEW} | |
| 70 | + </Button> | |
| 71 | + | |
| 72 | + {PLACEHOLDER_BUTTONS.map((label) => ( | |
| 73 | + <Button key={label} type="text" onClick={placeholder} data-testid={'btn-ph-' + label}> | |
| 74 | + {label} | |
| 75 | + </Button> | |
| 76 | + ))} | |
| 77 | + | |
| 78 | + <span className={styles.toolbarSpacer} /> | |
| 79 | + | |
| 80 | + <span | |
| 81 | + className={styles.gear} | |
| 82 | + data-testid="btn-gear" | |
| 83 | + role="presentation" | |
| 84 | + aria-label="设置" | |
| 85 | + onClick={placeholder} | |
| 86 | + > | |
| 87 | + <SettingOutlined /> | |
| 88 | + </span> | |
| 89 | + </div> | |
| 90 | + ); | |
| 91 | +} | ... | ... |
frontend/tests/unit/UserDetailToolbar.test.tsx
0 → 100644
| 1 | +// REQ-USR-001 / REQ-USR-002: UserDetailToolbar 工具栏单测(保存/取消/新增 + 提交中禁用 + 占位按钮,BR12/BR13/BR14/BR15/D8) | |
| 2 | +import { describe, it, expect, vi, beforeEach } from 'vitest'; | |
| 3 | +import { screen } from '@testing-library/react'; | |
| 4 | +import userEvent from '@testing-library/user-event'; | |
| 5 | + | |
| 6 | +const messageSpy = { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() }; | |
| 7 | +vi.mock('antd', async () => { | |
| 8 | + const actual = await vi.importActual<typeof import('antd')>('antd'); | |
| 9 | + return { | |
| 10 | + ...actual, | |
| 11 | + App: Object.assign(actual.App, { useApp: () => ({ message: messageSpy }) }), | |
| 12 | + }; | |
| 13 | +}); | |
| 14 | + | |
| 15 | +import { renderShell } from './renderShell'; | |
| 16 | +import UserDetailToolbar from '../../src/pages/usr/UserDetail/UserDetailToolbar'; | |
| 17 | + | |
| 18 | +function setup(over: { mode?: 'create' | 'edit'; submitting?: boolean; canSave?: boolean } = {}) { | |
| 19 | + const onSave = vi.fn(); | |
| 20 | + const onCancel = vi.fn(); | |
| 21 | + const onNew = vi.fn(); | |
| 22 | + renderShell( | |
| 23 | + <UserDetailToolbar | |
| 24 | + mode={over.mode ?? 'create'} | |
| 25 | + submitting={over.submitting ?? false} | |
| 26 | + canSave={over.canSave ?? true} | |
| 27 | + onSave={onSave} | |
| 28 | + onCancel={onCancel} | |
| 29 | + onNew={onNew} | |
| 30 | + />, | |
| 31 | + { preloadedAuth: { token: 't', user: { id: 1, sUserName: 'a', sUserType: 'x', sLanguage: '中文' } } }, | |
| 32 | + ); | |
| 33 | + return { onSave, onCancel, onNew }; | |
| 34 | +} | |
| 35 | + | |
| 36 | +describe('UserDetailToolbar', () => { | |
| 37 | + beforeEach(() => { | |
| 38 | + vi.clearAllMocks(); | |
| 39 | + }); | |
| 40 | + | |
| 41 | + it('renders 保存/取消/新增 + placeholder buttons + gear', () => { | |
| 42 | + setup(); | |
| 43 | + expect(screen.getByTestId('btn-save')).toBeInTheDocument(); | |
| 44 | + expect(screen.getByTestId('btn-cancel')).toBeInTheDocument(); | |
| 45 | + expect(screen.getByTestId('btn-new')).toBeInTheDocument(); | |
| 46 | + expect(screen.getByText('删除')).toBeInTheDocument(); | |
| 47 | + expect(screen.getByText('作废')).toBeInTheDocument(); | |
| 48 | + expect(screen.getByText('重置密码')).toBeInTheDocument(); | |
| 49 | + expect(screen.getByText('取消作废')).toBeInTheDocument(); | |
| 50 | + expect(screen.getByText('功能')).toBeInTheDocument(); | |
| 51 | + expect(screen.getByTestId('btn-gear')).toBeInTheDocument(); | |
| 52 | + }); | |
| 53 | + | |
| 54 | + it('click 保存 calls onSave / 取消 calls onCancel / 新增 calls onNew', async () => { | |
| 55 | + const user = userEvent.setup(); | |
| 56 | + const { onSave, onCancel, onNew } = setup(); | |
| 57 | + await user.click(screen.getByTestId('btn-save')); | |
| 58 | + expect(onSave).toHaveBeenCalledTimes(1); | |
| 59 | + await user.click(screen.getByTestId('btn-cancel')); | |
| 60 | + expect(onCancel).toHaveBeenCalledTimes(1); | |
| 61 | + await user.click(screen.getByTestId('btn-new')); | |
| 62 | + expect(onNew).toHaveBeenCalledTimes(1); | |
| 63 | + }); | |
| 64 | + | |
| 65 | + it('submitting disables 保存 and shows loading', async () => { | |
| 66 | + const user = userEvent.setup(); | |
| 67 | + const { onSave } = setup({ submitting: true }); | |
| 68 | + const btn = screen.getByTestId('btn-save'); | |
| 69 | + expect(btn).toBeDisabled(); | |
| 70 | + await user.click(btn); | |
| 71 | + expect(onSave).not.toHaveBeenCalled(); | |
| 72 | + }); | |
| 73 | + | |
| 74 | + it('canSave=false disables 保存', () => { | |
| 75 | + setup({ canSave: false }); | |
| 76 | + expect(screen.getByTestId('btn-save')).toBeDisabled(); | |
| 77 | + }); | |
| 78 | + | |
| 79 | + it('placeholder buttons show 功能开发中 (no business callback)', async () => { | |
| 80 | + const user = userEvent.setup(); | |
| 81 | + const { onSave, onCancel, onNew } = setup(); | |
| 82 | + await user.click(screen.getByText('删除')); | |
| 83 | + expect(messageSpy.info).toHaveBeenCalledWith('功能开发中'); | |
| 84 | + await user.click(screen.getByTestId('btn-gear')); | |
| 85 | + expect(messageSpy.info).toHaveBeenCalledWith('功能开发中'); | |
| 86 | + expect(onSave).not.toHaveBeenCalled(); | |
| 87 | + expect(onCancel).not.toHaveBeenCalled(); | |
| 88 | + expect(onNew).not.toHaveBeenCalled(); | |
| 89 | + }); | |
| 90 | +}); | ... | ... |