Commit 87c3d1f00733ca8d9cb1e44ad88d78d61a021bb5
1 parent
981d8e4a
feat(usr): 用户单据权限分类勾选列表 PermissionGroupList REQ-USR-001 REQ-USR-002
Showing
2 changed files
with
159 additions
and
0 deletions
frontend/src/pages/usr/UserDetail/PermissionGroupList.tsx
0 → 100644
| 1 | +// REQ-USR-001 / REQ-USR-002: 权限分类勾选列表(表头全选 indeterminate + 逐项 Checkbox,BR10/BR11/D3) | ||
| 2 | +import { Checkbox } from 'antd'; | ||
| 3 | +import type { PermissionItem } from '../../../api/types'; | ||
| 4 | +import { PERM_LIST_HEADER } from './constants'; | ||
| 5 | +import styles from './UserDetail.module.css'; | ||
| 6 | + | ||
| 7 | +export interface PermissionGroupListProps { | ||
| 8 | + permissions: PermissionItem[]; | ||
| 9 | + checkedIds: number[]; | ||
| 10 | + onToggle(id: number, checked: boolean): void; | ||
| 11 | + onToggleAll(checked: boolean): void; | ||
| 12 | + loading?: boolean; | ||
| 13 | +} | ||
| 14 | + | ||
| 15 | +export default function PermissionGroupList({ | ||
| 16 | + permissions, | ||
| 17 | + checkedIds, | ||
| 18 | + onToggle, | ||
| 19 | + onToggleAll, | ||
| 20 | +}: PermissionGroupListProps) { | ||
| 21 | + const total = permissions.length; | ||
| 22 | + const checkedCount = permissions.filter((p) => checkedIds.includes(p.id)).length; | ||
| 23 | + const allChecked = total > 0 && checkedCount === total; | ||
| 24 | + const indeterminate = checkedCount > 0 && checkedCount < total; | ||
| 25 | + | ||
| 26 | + return ( | ||
| 27 | + <div className={styles.permList} data-testid="perm-list"> | ||
| 28 | + <div className={styles.permHead}> | ||
| 29 | + <Checkbox | ||
| 30 | + checked={allChecked} | ||
| 31 | + indeterminate={indeterminate} | ||
| 32 | + onChange={(e) => onToggleAll(e.target.checked)} | ||
| 33 | + data-testid="perm-check-all" | ||
| 34 | + /> | ||
| 35 | + <span>{PERM_LIST_HEADER}</span> | ||
| 36 | + <span className={styles.permHeadSort} aria-hidden="true"> | ||
| 37 | + ⇅ | ||
| 38 | + </span> | ||
| 39 | + </div> | ||
| 40 | + | ||
| 41 | + {total === 0 ? ( | ||
| 42 | + <div className={styles.permEmpty} data-testid="perm-empty" /> | ||
| 43 | + ) : ( | ||
| 44 | + permissions.map((p) => ( | ||
| 45 | + <label key={p.id} className={styles.permRow}> | ||
| 46 | + <Checkbox | ||
| 47 | + checked={checkedIds.includes(p.id)} | ||
| 48 | + onChange={(e) => onToggle(p.id, e.target.checked)} | ||
| 49 | + data-testid={'perm-check-' + p.id} | ||
| 50 | + /> | ||
| 51 | + <span>{p.name}</span> | ||
| 52 | + </label> | ||
| 53 | + )) | ||
| 54 | + )} | ||
| 55 | + </div> | ||
| 56 | + ); | ||
| 57 | +} |
frontend/tests/unit/PermissionGroupList.test.tsx
0 → 100644
| 1 | +// REQ-USR-001 / REQ-USR-002: PermissionGroupList 权限分类勾选列表单测(渲染/勾选集合/全选 indeterminate/回勾,BR10/BR11/D3) | ||
| 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 | +import { renderShell } from './renderShell'; | ||
| 6 | +import PermissionGroupList from '../../src/pages/usr/UserDetail/PermissionGroupList'; | ||
| 7 | +import type { PermissionItem } from '../../src/api/types'; | ||
| 8 | + | ||
| 9 | +const PERMS: PermissionItem[] = [ | ||
| 10 | + { id: 1, name: '默认显示', category: '基础' }, | ||
| 11 | + { id: 2, name: '高级查看', category: '基础' }, | ||
| 12 | + { id: 3, name: '导出', category: '报表' }, | ||
| 13 | +]; | ||
| 14 | + | ||
| 15 | +function setup(over: { | ||
| 16 | + permissions?: PermissionItem[]; | ||
| 17 | + checkedIds?: number[]; | ||
| 18 | +} = {}) { | ||
| 19 | + const onToggle = vi.fn(); | ||
| 20 | + const onToggleAll = vi.fn(); | ||
| 21 | + renderShell( | ||
| 22 | + <PermissionGroupList | ||
| 23 | + permissions={over.permissions ?? PERMS} | ||
| 24 | + checkedIds={over.checkedIds ?? []} | ||
| 25 | + onToggle={onToggle} | ||
| 26 | + onToggleAll={onToggleAll} | ||
| 27 | + />, | ||
| 28 | + { preloadedAuth: { token: 't', user: { id: 1, sUserName: 'a', sUserType: 'x', sLanguage: '中文' } } }, | ||
| 29 | + ); | ||
| 30 | + return { onToggle, onToggleAll }; | ||
| 31 | +} | ||
| 32 | + | ||
| 33 | +function rowCheckbox(id: number): HTMLInputElement { | ||
| 34 | + return screen.getByTestId('perm-check-' + id) as HTMLInputElement; | ||
| 35 | +} | ||
| 36 | + | ||
| 37 | +function allCheckbox(): HTMLInputElement { | ||
| 38 | + return screen.getByTestId('perm-check-all') as HTMLInputElement; | ||
| 39 | +} | ||
| 40 | + | ||
| 41 | +describe('PermissionGroupList', () => { | ||
| 42 | + beforeEach(() => { | ||
| 43 | + vi.clearAllMocks(); | ||
| 44 | + }); | ||
| 45 | + | ||
| 46 | + it('renders header 权限分类 and one row per permission', () => { | ||
| 47 | + setup(); | ||
| 48 | + expect(screen.getByText('权限分类')).toBeInTheDocument(); | ||
| 49 | + expect(screen.getByText('默认显示')).toBeInTheDocument(); | ||
| 50 | + expect(screen.getByText('高级查看')).toBeInTheDocument(); | ||
| 51 | + expect(screen.getByText('导出')).toBeInTheDocument(); | ||
| 52 | + }); | ||
| 53 | + | ||
| 54 | + it('checked rows reflect checkedIds', () => { | ||
| 55 | + setup({ checkedIds: [1] }); | ||
| 56 | + expect(rowCheckbox(1).checked).toBe(true); | ||
| 57 | + expect(rowCheckbox(2).checked).toBe(false); | ||
| 58 | + expect(rowCheckbox(3).checked).toBe(false); | ||
| 59 | + }); | ||
| 60 | + | ||
| 61 | + it('toggling a row calls onToggle(id, checked)', async () => { | ||
| 62 | + const user = userEvent.setup(); | ||
| 63 | + const { onToggle } = setup({ checkedIds: [1] }); | ||
| 64 | + await user.click(rowCheckbox(2)); | ||
| 65 | + expect(onToggle).toHaveBeenCalledWith(2, true); | ||
| 66 | + await user.click(rowCheckbox(1)); | ||
| 67 | + expect(onToggle).toHaveBeenCalledWith(1, false); | ||
| 68 | + }); | ||
| 69 | + | ||
| 70 | + it('header select-all checked when all selected; indeterminate when partial', () => { | ||
| 71 | + const { } = setup({ checkedIds: [1, 2, 3] }); | ||
| 72 | + expect(allCheckbox().checked).toBe(true); | ||
| 73 | + }); | ||
| 74 | + | ||
| 75 | + it('header indeterminate when partial; unchecked when none', () => { | ||
| 76 | + setup({ checkedIds: [1] }); | ||
| 77 | + const all = allCheckbox(); | ||
| 78 | + expect(all.checked).toBe(false); | ||
| 79 | + // AntD 半选用 aria-checked='mixed' 表达于 wrapper;input 仍未 checked | ||
| 80 | + const wrapper = all.closest('.ant-checkbox'); | ||
| 81 | + expect(wrapper?.classList.contains('ant-checkbox-indeterminate')).toBe(true); | ||
| 82 | + }); | ||
| 83 | + | ||
| 84 | + it('header toggle calls onToggleAll', async () => { | ||
| 85 | + const user = userEvent.setup(); | ||
| 86 | + const { onToggleAll } = setup({ checkedIds: [] }); | ||
| 87 | + await user.click(allCheckbox()); | ||
| 88 | + expect(onToggleAll).toHaveBeenCalledWith(true); | ||
| 89 | + }); | ||
| 90 | + | ||
| 91 | + it('header toggle off when all selected', async () => { | ||
| 92 | + const user = userEvent.setup(); | ||
| 93 | + const { onToggleAll } = setup({ checkedIds: [1, 2, 3] }); | ||
| 94 | + await user.click(allCheckbox()); | ||
| 95 | + expect(onToggleAll).toHaveBeenCalledWith(false); | ||
| 96 | + }); | ||
| 97 | + | ||
| 98 | + it('empty permissions renders empty list (no rows)', () => { | ||
| 99 | + setup({ permissions: [] }); | ||
| 100 | + expect(screen.queryByTestId('perm-check-1')).toBeNull(); | ||
| 101 | + }); | ||
| 102 | +}); |