Wire RDM member settings to server-side member search #108
@@ -147,8 +147,14 @@ export function RdmRolesPanel() {
|
||||
|
||||
// ---- 项目设置:成员(P7:可搜索人员选择器 + 专业角色/项目身份两列 + 显式身份调整)----
|
||||
|
||||
import {
|
||||
type MemberMatchMode, type PersonPickItem, type RdmAccount, type RdmMemberPage, type RdmProfRole,
|
||||
MATCH_MODE_LABELS, accountListParams, accountsToPickItems, appendMemberPage, memberListParams,
|
||||
personLabel, roleOptionsPayload,
|
||||
} from '../rdmManagement'
|
||||
|
||||
interface MemberRow {
|
||||
owner: string; role: string; addedBy: string; addedAt: string
|
||||
owner: string; name?: string; role: string; addedBy: string; addedAt: string
|
||||
profRole?: { roleId: string; name: string } | null
|
||||
}
|
||||
|
||||
@@ -157,42 +163,78 @@ export function RdmMembersSection({ project }: { project: Project }) {
|
||||
const writeError = projectWorkError(project)
|
||||
const cmd = <T,>(id: string, params: Record<string, unknown> = {}) => api.rdmProjectCommand<T>(project, id, params)
|
||||
const [rows, setRows] = useState<MemberRow[] | null>(null)
|
||||
const [memberNext, setMemberNext] = useState('')
|
||||
const [memberTotal, setMemberTotal] = useState<number | null>(null)
|
||||
const [directoryOk, setDirectoryOk] = useState(true)
|
||||
const [loadingMore, setLoadingMore] = useState(false)
|
||||
const [memberQuery, setMemberQuery] = useState('')
|
||||
const [memberRoleId, setMemberRoleId] = useState('')
|
||||
// null=未加载或 rdm.role.options 失败:两处专业角色筛选均不渲染(不给误导选项)。
|
||||
const [roleOptions, setRoleOptions] = useState<RdmProfRole[] | null>(null)
|
||||
const [err, setErr] = useState('')
|
||||
const [notice, setNotice] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [picked, setPicked] = useState<string[]>([])
|
||||
const [pickedItems, setPickedItems] = useState<PersonPickItem[]>([])
|
||||
const [candRoleId, setCandRoleId] = useState('')
|
||||
const [candMatch, setCandMatch] = useState<MemberMatchMode>('fuzzy')
|
||||
const [role, setRole] = useState<'member' | 'owner'>('member')
|
||||
// 成员表经 ref 参与取数(已加入标记),保持 fetchPage 身份稳定(Picker 依赖)。
|
||||
const rowsRef = useRef<MemberRow[] | null>(null)
|
||||
rowsRef.current = rows
|
||||
const knownPicks = useRef(new Map<string, PersonPickItem>())
|
||||
// 成员表请求代次:切项目/切筛选推进代次,迟到响应丢弃。
|
||||
const memberGen = useRef(0)
|
||||
|
||||
/** 人员选择器取数适配(P7-A):rdm.account.list 目录(member.manage 生效者)。
|
||||
* query 透传后端(基座升级 AccountSearcher 后全量生效);旧基座忽略 query 时
|
||||
* 对首页做本地包含匹配降级(翻页为浏览模式,不过滤)。 */
|
||||
const fetchPage = useCallback(async (query: string, cursor: string, limit: number) => {
|
||||
const out = await cmd<{ accounts?: { owner: string; name?: string; valid: boolean }[]; next?: string }>(
|
||||
'rdm.account.list', { projectId, cursor, limit, query })
|
||||
const joined = new Set((rowsRef.current ?? []).map(m => m.owner))
|
||||
let items = (out.accounts ?? []).filter(a => a.valid).map(a => ({
|
||||
id: a.owner,
|
||||
title: a.name || a.owner,
|
||||
subtitle: a.owner,
|
||||
badge: joined.has(a.owner) ? '已加入' : undefined,
|
||||
}))
|
||||
if (query !== '' && cursor === '') {
|
||||
const q = query.toLowerCase()
|
||||
items = items.filter(it => it.id.toLowerCase().includes(q) || it.title.toLowerCase().includes(q))
|
||||
}
|
||||
return { items, nextCursor: out.next || undefined }
|
||||
useEffect(() => {
|
||||
setPicked([]); setPickedItems([]); knownPicks.current.clear()
|
||||
setCandRoleId(''); setCandMatch('fuzzy'); setMemberQuery(''); setMemberRoleId('')
|
||||
setRoleOptions(null)
|
||||
let live = true
|
||||
cmd<{ roles?: RdmProfRole[] }>('rdm.role.options', roleOptionsPayload(projectId))
|
||||
.then(out => { if (live) setRoleOptions(out.roles ?? []) })
|
||||
.catch(() => { if (live) setRoleOptions(null) })
|
||||
return () => { live = false }
|
||||
}, [project])
|
||||
|
||||
const refresh = async () => {
|
||||
/** 人员选择器取数适配:rdm.account.list 服务端按 query/roleId/match 先过滤后分页。 */
|
||||
const fetchPage = useCallback(async (query: string, cursor: string, limit: number) => {
|
||||
const out = await cmd<{ accounts?: RdmAccount[]; next?: string }>('rdm.account.list',
|
||||
accountListParams(projectId, { query, cursor, limit, roleId: candRoleId, match: candMatch }))
|
||||
const items = accountsToPickItems(out.accounts, (rowsRef.current ?? []).map(m => m.owner))
|
||||
for (const it of items) knownPicks.current.set(it.id, it)
|
||||
return { items, nextCursor: out.next || undefined }
|
||||
}, [project, candRoleId, candMatch])
|
||||
|
||||
/** cursor 空=重载首页(clear 时先清空表格);非空=追加下一页。 */
|
||||
const loadMembers = useCallback(async (cursor: string, clear: boolean) => {
|
||||
const gen = cursor ? memberGen.current : ++memberGen.current
|
||||
if (cursor) setLoadingMore(true)
|
||||
else {
|
||||
setLoadingMore(false)
|
||||
if (clear) { setErr(''); setRows(null); setMemberNext(''); setMemberTotal(null) }
|
||||
}
|
||||
try {
|
||||
const out = await cmd<{ members: MemberRow[] }>('rdm.project.member.list', { projectId })
|
||||
setRows(out.members ?? [])
|
||||
} catch (e) { setErr(String(e)) }
|
||||
}
|
||||
useEffect(() => { setPicked([]); refresh() }, [project])
|
||||
const out = await cmd<RdmMemberPage>('rdm.project.member.list',
|
||||
memberListParams(projectId, { query: memberQuery, roleId: memberRoleId, cursor }))
|
||||
if (gen !== memberGen.current) return
|
||||
setRows(prev => appendMemberPage(cursor ? prev : null, out.members))
|
||||
setMemberNext(out.next || '')
|
||||
setMemberTotal(typeof out.total === 'number' ? out.total : null)
|
||||
setDirectoryOk(out.accountDirectoryAvailable !== false)
|
||||
} catch (e) {
|
||||
if (gen === memberGen.current) setErr(String(e))
|
||||
} finally {
|
||||
if (cursor && gen === memberGen.current) setLoadingMore(false)
|
||||
}
|
||||
}, [project, memberQuery, memberRoleId])
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => { void loadMembers('', true) }, memberQuery.trim() ? 250 : 0)
|
||||
return () => { clearTimeout(timer); ++memberGen.current }
|
||||
}, [loadMembers])
|
||||
|
||||
const refresh = () => loadMembers('', false)
|
||||
|
||||
const run = async (label: string, fn: () => Promise<unknown>) => {
|
||||
setErr(''); setNotice(''); setBusy(true)
|
||||
@@ -200,38 +242,76 @@ export function RdmMembersSection({ project }: { project: Project }) {
|
||||
catch (e) { setErr(String(e)) } finally { setBusy(false) }
|
||||
}
|
||||
|
||||
const onPick = (ids: string[]) => {
|
||||
setPicked(ids)
|
||||
setPickedItems(ids.flatMap(id => {
|
||||
const it = knownPicks.current.get(id)
|
||||
return it ? [it] : []
|
||||
}))
|
||||
}
|
||||
|
||||
const pickedId = picked[0] ?? ''
|
||||
const pickedItem = pickedItems.find(it => it.id === pickedId)
|
||||
const pickedValid = !!pickedId && !pickedItem?.disabled && !(rows ?? []).some(m => m.owner === pickedId)
|
||||
const pickerDisabled = busy || !!writeError
|
||||
const filtering = memberQuery.trim() !== '' || memberRoleId !== ''
|
||||
|
||||
const roleFilter = (value: string, onChange: (v: string) => void, label: string) =>
|
||||
roleOptions && roleOptions.length > 0 ? (
|
||||
<select className="lg-input" aria-label={label} value={value} onChange={e => onChange(e.target.value)}>
|
||||
<option value="">全部专业角色</option>
|
||||
{roleOptions.map(r => <option key={r.roleId} value={r.roleId}>{r.name}</option>)}
|
||||
</select>
|
||||
) : null
|
||||
|
||||
return (
|
||||
<div>
|
||||
{writeError && <p className="ad-hint">{writeError}</p>}
|
||||
<div className="ad-create">
|
||||
<div className="ad-row">
|
||||
{roleFilter(candRoleId, setCandRoleId, '候选专业角色筛选')}
|
||||
<select className="lg-input" aria-label="匹配方式" value={candMatch}
|
||||
onChange={e => setCandMatch(e.target.value as MemberMatchMode)}>
|
||||
{(Object.keys(MATCH_MODE_LABELS) as MemberMatchMode[]).map(m =>
|
||||
<option key={m} value={m}>{MATCH_MODE_LABELS[m]}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="ad-row">
|
||||
<Picker
|
||||
aria-label="选择人员"
|
||||
selected={picked}
|
||||
onSelect={setPicked}
|
||||
onSelect={onPick}
|
||||
selectedItems={pickedItems}
|
||||
fetchPage={fetchPage}
|
||||
reloadKey={`${projectId}|${candRoleId}|${candMatch}`}
|
||||
disabled={pickerDisabled}
|
||||
placeholder="输入账号或姓名,或浏览候选…"
|
||||
/>
|
||||
<select className="lg-input" value={role} onChange={e => setRole(e.target.value as 'member' | 'owner')}>
|
||||
<option value="member">项目成员</option>
|
||||
<option value="owner">项目负责人</option>
|
||||
</select>
|
||||
<button className="lg-submit ad-create-btn" disabled={busy || !!writeError || !pickedId}
|
||||
onClick={() => run(`已添加 ${pickedId}`, async () => {
|
||||
<button className="lg-submit ad-create-btn" disabled={pickerDisabled || !pickedValid}
|
||||
onClick={() => run(`已添加 ${pickedItem ? personLabel({ owner: pickedId, name: pickedItem.title }) : pickedId}`, async () => {
|
||||
await cmd('rdm.project.member.add', memberAddPayload(projectId, pickedId, role))
|
||||
setPicked([])
|
||||
setPicked([]); setPickedItems([])
|
||||
})}>添加成员</button>
|
||||
</div>
|
||||
<p className="ad-hint">候选来自系统账号目录(姓名为空时显示账号);「已加入」标记避免重复添加造成隐式变更——调整身份请在成员表中操作。最后一位负责人不可移除/降级。</p>
|
||||
<p className="ad-hint">候选来自系统账号目录(姓名为空时显示账号),无效账号不提供;已加入项目的账号显示为不可选——调整身份请在成员表中操作。最后一位负责人不可移除/降级。</p>
|
||||
</div>
|
||||
<div className="ad-row">
|
||||
<input className="lg-input" aria-label="搜索成员" placeholder="搜索成员账号或姓名" value={memberQuery}
|
||||
onChange={e => setMemberQuery(e.target.value)} />
|
||||
{roleFilter(memberRoleId, setMemberRoleId, '成员专业角色筛选')}
|
||||
{rows !== null && <span className="ad-dim">共 {memberTotal ?? rows.length} 名成员匹配</span>}
|
||||
</div>
|
||||
{!directoryOk && <p className="ad-hint">账号目录暂不可用:成员姓名可能缺失,仅按账号显示。</p>}
|
||||
<table className="ad-table">
|
||||
<thead><tr><th>成员</th><th>专业角色</th><th>项目身份</th><th>添加人</th><th>添加时间</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{(rows ?? []).map(m => (
|
||||
<tr key={m.owner}>
|
||||
<td><span className="ad-mono">{m.owner}</span>{m.profRole?.name ? <span className="ad-dim"> · {m.profRole.name}</span> : null}</td>
|
||||
<td className="ad-mono">{personLabel(m)}</td>
|
||||
<td className="ad-dim">{m.profRole?.name || '—'}</td>
|
||||
<td>{m.role === 'owner' ? <span className="ad-chip ad-chip-admin">项目负责人</span> : <span className="ad-chip">项目成员</span>}</td>
|
||||
<td className="ad-dim">{m.addedBy || '—'}</td>
|
||||
@@ -249,10 +329,16 @@ export function RdmMembersSection({ project }: { project: Project }) {
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{rows !== null && rows.length === 0 && <tr><td colSpan={6} className="ad-dim">暂无成员</td></tr>}
|
||||
{rows === null && <tr><td colSpan={6} className="ad-dim">加载中…</td></tr>}
|
||||
{rows !== null && rows.length === 0 && <tr><td colSpan={6} className="ad-dim">{filtering ? '没有匹配的成员' : '暂无成员'}</td></tr>}
|
||||
{rows === null && <tr><td colSpan={6} className="ad-dim">{err ? '成员清单未加载(见下方提示)' : '加载中…'}</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
{memberNext && rows !== null && (
|
||||
<div className="ad-row">
|
||||
<button className="ad-mini" disabled={loadingMore}
|
||||
onClick={() => loadMembers(memberNext, false)}>{loadingMore ? '加载中…' : '加载更多成员'}</button>
|
||||
</div>
|
||||
)}
|
||||
{notice && <div className="ad-notice"><Icon name="check" size={14} />{notice}</div>}
|
||||
{err && <div className="lg-err"><Icon name="alert" size={14} />{err}</div>}
|
||||
</div>
|
||||
|
||||
@@ -27,6 +27,7 @@ const PROJECT_READ_COMMANDS = new Set([
|
||||
'rdm.project.member.list', 'rdm.perm.check', 'rdm.perm.matrix',
|
||||
'rdm.review.doc.status', 'rdm.duty.catalog', 'rdm.duty.get',
|
||||
'rdm.duty.matrix', 'rdm.duty.preview', 'rdm.duty.legacy.scan',
|
||||
'rdm.role.options',
|
||||
])
|
||||
|
||||
export function rdmProjectCommandError(project: Project, command: string): string | null {
|
||||
@@ -152,6 +153,100 @@ export const docStatusPayload = (projectId: string, docId: string) => ({ project
|
||||
|
||||
export const permCheckPayload = (projectId: string, member: string) => ({ projectId, member })
|
||||
|
||||
// ---- 项目成员页:候选检索 + 成员表检索(R2 契约:服务端先过滤后分页) ----
|
||||
|
||||
export type MemberMatchMode = 'fuzzy' | 'exact'
|
||||
export interface RdmProfRole { roleId: string; name: string }
|
||||
/** rdm.account.list 单条候选。 */
|
||||
export interface RdmAccount {
|
||||
owner: string; name?: string; valid: boolean; joined?: boolean
|
||||
projectRole?: string; profRole?: RdmProfRole | null
|
||||
}
|
||||
/** rdm.project.member.list 单条成员。 */
|
||||
export interface RdmMemberRow {
|
||||
owner: string; name?: string; role: string; addedBy: string; addedAt: string
|
||||
profRole?: RdmProfRole | null
|
||||
}
|
||||
export interface RdmMemberPage {
|
||||
members: RdmMemberRow[]; next?: string; total?: number; accountDirectoryAvailable?: boolean
|
||||
}
|
||||
/** 与 @ui PickItem 结构兼容(纯逻辑层不依赖组件库)。 */
|
||||
export interface PersonPickItem {
|
||||
id: string; title: string; subtitle?: string; disabled?: boolean; disabledReason?: string
|
||||
}
|
||||
|
||||
export const MEMBER_PAGE_LIMIT = 50
|
||||
export const JOINED_REASON = '已加入项目'
|
||||
export const MATCH_MODE_LABELS: Record<MemberMatchMode, string> = {
|
||||
fuzzy: '模糊匹配',
|
||||
exact: '精确账号或姓名',
|
||||
}
|
||||
|
||||
export const roleOptionsPayload = (projectId: string) => ({ projectId, purpose: 'members' as const })
|
||||
|
||||
/** 空串筛选不携带(缺省=不过滤);match 恒携带,服务端据此选择匹配语义。 */
|
||||
export function accountListParams(projectId: string, opts: {
|
||||
query?: string; cursor?: string; limit: number; roleId?: string; match?: MemberMatchMode
|
||||
}) {
|
||||
const query = opts.query?.trim() ?? ''
|
||||
return {
|
||||
projectId,
|
||||
limit: opts.limit,
|
||||
match: opts.match ?? 'fuzzy',
|
||||
...(query ? { query } : {}),
|
||||
...(opts.cursor ? { cursor: opts.cursor } : {}),
|
||||
...(opts.roleId ? { roleId: opts.roleId } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
export function memberListParams(projectId: string, opts: {
|
||||
query?: string; roleId?: string; cursor?: string; limit?: number
|
||||
match?: MemberMatchMode; projectRole?: 'owner' | 'member'
|
||||
} = {}) {
|
||||
const query = opts.query?.trim() ?? ''
|
||||
return {
|
||||
projectId,
|
||||
limit: opts.limit ?? MEMBER_PAGE_LIMIT,
|
||||
...(query ? { query } : {}),
|
||||
...(opts.roleId ? { roleId: opts.roleId } : {}),
|
||||
...(opts.cursor ? { cursor: opts.cursor } : {}),
|
||||
...(opts.match ? { match: opts.match } : {}),
|
||||
...(opts.projectRole ? { projectRole: opts.projectRole } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
/** 人员标签:「姓名 · 账号」,姓名为空时只显示账号。 */
|
||||
export function personLabel(p: { owner: string; name?: string }): string {
|
||||
const name = p.name?.trim()
|
||||
return name && name !== p.owner ? `${name} · ${p.owner}` : p.owner
|
||||
}
|
||||
|
||||
/** 候选映射:无效账号不提供;已加入(服务端 joined 或已在成员表)可见但不可选。 */
|
||||
export function accountsToPickItems(accounts: RdmAccount[] | undefined, joinedOwners: Iterable<string> = []): PersonPickItem[] {
|
||||
const joined = new Set(joinedOwners)
|
||||
return (accounts ?? []).filter(a => a.valid).map(a => {
|
||||
const isJoined = a.joined === true || joined.has(a.owner)
|
||||
return {
|
||||
id: a.owner,
|
||||
title: a.name?.trim() || a.owner,
|
||||
subtitle: a.owner,
|
||||
...(isJoined ? { disabled: true, disabledReason: JOINED_REASON } : {}),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** 成员表追加下一页:保序,已存在的账号不重复。 */
|
||||
export function appendMemberPage(prev: RdmMemberRow[] | null, page: RdmMemberRow[] | undefined): RdmMemberRow[] {
|
||||
const out = [...(prev ?? [])]
|
||||
const seen = new Set(out.map(m => m.owner))
|
||||
for (const m of page ?? []) {
|
||||
if (seen.has(m.owner)) continue
|
||||
seen.add(m.owner)
|
||||
out.push(m)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** 矩阵来源标签(人话映射;未知来源回退原文——服务端新增来源时不至于空白)。 */
|
||||
export function describeSource(source: string): string {
|
||||
switch (source) {
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
// 项目成员页检索(R2 契约):服务端先过滤后分页,前端只拼参数、映射候选与追加分页。
|
||||
// node 环境无 DOM——决策逻辑全部落在 src/rdmManagement.ts 的纯函数上逐项断言。
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { Project } from '../src/contracts'
|
||||
import {
|
||||
JOINED_REASON, MATCH_MODE_LABELS, MEMBER_PAGE_LIMIT, RDM_MEMBER_COMMANDS,
|
||||
accountListParams, accountsToPickItems, appendMemberPage, memberListParams,
|
||||
personLabel, rdmMembersReady, rdmProjectCommandError, roleOptionsPayload,
|
||||
type RdmMemberRow,
|
||||
} from '../src/rdmManagement'
|
||||
|
||||
const row = (owner: string, name?: string): RdmMemberRow =>
|
||||
({ owner, name, role: 'member', addedBy: 'admin', addedAt: '' })
|
||||
|
||||
describe('accountListParams', () => {
|
||||
it('passes roleId, match and cursor to rdm.account.list', () => {
|
||||
expect(accountListParams('p1', { query: ' zhang ', cursor: 'c2', limit: 20, roleId: 'r-eng', match: 'exact' }))
|
||||
.toEqual({ projectId: 'p1', query: 'zhang', cursor: 'c2', limit: 20, roleId: 'r-eng', match: 'exact' })
|
||||
})
|
||||
|
||||
it('omits empty filters and defaults to fuzzy matching', () => {
|
||||
expect(accountListParams('p1', { query: ' ', cursor: '', limit: 20, roleId: '' }))
|
||||
.toEqual({ projectId: 'p1', limit: 20, match: 'fuzzy' })
|
||||
})
|
||||
|
||||
it('labels both match modes in Chinese', () => {
|
||||
expect(MATCH_MODE_LABELS).toEqual({ fuzzy: '模糊匹配', exact: '精确账号或姓名' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('memberListParams', () => {
|
||||
it('sends query, roleId and cursor with a page size of 50', () => {
|
||||
expect(memberListParams('p1', { query: 'li', roleId: 'r-qa', cursor: 'n1' }))
|
||||
.toEqual({ projectId: 'p1', query: 'li', roleId: 'r-qa', cursor: 'n1', limit: MEMBER_PAGE_LIMIT })
|
||||
expect(MEMBER_PAGE_LIMIT).toBe(50)
|
||||
})
|
||||
|
||||
it('loads the unfiltered first page when no filter is set', () => {
|
||||
expect(memberListParams('p1', { query: '', roleId: '', cursor: '' })).toEqual({ projectId: 'p1', limit: 50 })
|
||||
})
|
||||
|
||||
it('forwards the optional match and projectRole filters', () => {
|
||||
expect(memberListParams('p1', { match: 'exact', projectRole: 'owner', limit: 10 }))
|
||||
.toEqual({ projectId: 'p1', limit: 10, match: 'exact', projectRole: 'owner' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('roleOptionsPayload', () => {
|
||||
it('asks for member-purpose role options', () => {
|
||||
expect(roleOptionsPayload('p1')).toEqual({ projectId: 'p1', purpose: 'members' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('accountsToPickItems', () => {
|
||||
it('drops invalid accounts', () => {
|
||||
const items = accountsToPickItems([
|
||||
{ owner: 'a', name: '甲', valid: true, joined: false },
|
||||
{ owner: 'gone', name: '离职', valid: false, joined: false },
|
||||
])
|
||||
expect(items.map(it => it.id)).toEqual(['a'])
|
||||
})
|
||||
|
||||
it('disables joined candidates with the 已加入项目 reason instead of a badge', () => {
|
||||
const items = accountsToPickItems([
|
||||
{ owner: 'a', name: '甲', valid: true, joined: true },
|
||||
{ owner: 'b', valid: true, joined: false },
|
||||
{ owner: 'c', name: '丙', valid: true },
|
||||
], ['b'])
|
||||
expect(items).toEqual([
|
||||
{ id: 'a', title: '甲', subtitle: 'a', disabled: true, disabledReason: JOINED_REASON },
|
||||
{ id: 'b', title: 'b', subtitle: 'b', disabled: true, disabledReason: '已加入项目' },
|
||||
{ id: 'c', title: '丙', subtitle: 'c' },
|
||||
])
|
||||
expect(items.some(it => 'badge' in it)).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps name as title and account as subtitle so the selection shows both', () => {
|
||||
const [it] = accountsToPickItems([{ owner: 'zhangsan', name: '张三', valid: true, joined: false }])
|
||||
expect(it.title).toBe('张三')
|
||||
expect(it.subtitle).toBe('zhangsan')
|
||||
})
|
||||
|
||||
it('tolerates a missing account list', () => {
|
||||
expect(accountsToPickItems(undefined)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('personLabel', () => {
|
||||
it('renders name · account', () => {
|
||||
expect(personLabel({ owner: 'zhangsan', name: '张三' })).toBe('张三 · zhangsan')
|
||||
})
|
||||
|
||||
it('falls back to the account when there is no name', () => {
|
||||
expect(personLabel({ owner: 'zhangsan' })).toBe('zhangsan')
|
||||
expect(personLabel({ owner: 'zhangsan', name: ' ' })).toBe('zhangsan')
|
||||
expect(personLabel({ owner: 'zhangsan', name: 'zhangsan' })).toBe('zhangsan')
|
||||
})
|
||||
})
|
||||
|
||||
describe('appendMemberPage', () => {
|
||||
it('appends the next page in order without duplicating accounts', () => {
|
||||
const first = [row('a', '甲'), row('b')]
|
||||
const got = appendMemberPage(first, [row('b', '乙-重复'), row('c', '丙')])
|
||||
expect(got.map(m => m.owner)).toEqual(['a', 'b', 'c'])
|
||||
expect(got[1].name).toBeUndefined()
|
||||
expect(first.map(m => m.owner)).toEqual(['a', 'b'])
|
||||
})
|
||||
|
||||
it('starts a fresh list from null and dedupes within a page', () => {
|
||||
expect(appendMemberPage(null, [row('a'), row('a')]).map(m => m.owner)).toEqual(['a'])
|
||||
expect(appendMemberPage(null, undefined)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('rdm.role.options command gating', () => {
|
||||
it('is a project read command, so archived projects can still load role filters', () => {
|
||||
const archived = { id: 'p1', settings: { 'rdm.state': 'archived', 'rdm.projectId': 'rdm-1' } } as unknown as Project
|
||||
expect(rdmProjectCommandError(archived, 'rdm.role.options')).toBeNull()
|
||||
})
|
||||
|
||||
it('does not gate member page readiness', () => {
|
||||
expect(RDM_MEMBER_COMMANDS).not.toContain('rdm.role.options')
|
||||
expect(rdmMembersReady(RDM_MEMBER_COMMANDS.map(id => ({ id, enabled: true })))).toBe(true)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user