mirror of
https://github.com/iflytek/skillhub.git
synced 2026-10-15 04:18:20 +00:00
fix(web): clarify pending avatars and space management
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
This commit is contained in:
parent
be1529aeda
commit
5916771237
21 changed files with 573 additions and 168 deletions
|
|
@ -1,6 +1,6 @@
|
|||
# 技能头像与审核
|
||||
|
||||
技能所有者可在新增/更新技能页面选择自定义 PNG/JPEG 头像,网页提供方形裁剪和缩放;不提供预设图标。更新默认保留头像,也可替换或移除。“我的技能”统一从“更新”入口维护,也可只改头像而不发布新版本;详情页仅在标题旁展示当前头像。服务器仍会独立校验:输入不超过 2 MB、边长不超过 1024 像素,并重新编码为 PNG,避免依赖客户端裁剪或保留原图元数据。头像候选存在私有对象存储;审核完成前,公众继续看到旧头像或技能名首字母。
|
||||
技能所有者可在新增/更新技能页面选择自定义 PNG/JPEG 头像,网页提供方形裁剪和缩放;不提供预设图标。更新默认保留头像,也可替换或移除。“我的技能”统一从“更新”入口维护,也可只改头像而不发布新版本;详情页仅在标题旁展示当前头像。服务器仍会独立校验:输入不超过 2 MB、边长不超过 1024 像素,并重新编码为 PNG,避免依赖客户端裁剪或保留原图元数据。头像候选存在私有对象存储;审核完成前,公众继续看到旧头像或技能名首字母。所有者在“我的技能”和更新页可查看已提交头像的缩略图及审核状态,待审头像不会替代公开头像;加载失败会提示重试。
|
||||
|
||||
## 上传与提交
|
||||
|
||||
|
|
|
|||
|
|
@ -24,3 +24,9 @@
|
|||
新分享地址为 `/join#invite=<token>`,兼容原 `/join#<token>`。进入页仍在登录跳转前同步捕获并清片段,Token不进入HTTP路径、查询、returnTo或日志。新片段无效时清旧暂存Token,无片段登录回跳才恢复暂存值。
|
||||
|
||||
命名空间卡片不平铺成员、邀请、作废、冻结、归档按钮;管理详情按技能/成员/审核/设置集中导航。成员页弹窗管理邀请,治理操作进入设置并保留旧权限与危险动作确认。公共空间技能浏览及旧成员/审核链接保持可用。平台审核权不等于成员管理权;GLOBAL不可冻结归档,但合法技能审核仍可用。
|
||||
|
||||
### 2026-10-14 人工核验交互修正
|
||||
|
||||
返回我的空间放在标题上方。普通成员不显示变更操作;角色编辑仍在角色列,保存/取消/移除使用操作列图标,提供可访问名称和悬停说明,保存成功或取消后恢复角色选择框焦点。审核状态用下拉筛选区分空间主导航。
|
||||
|
||||
邀请生成原文仍不存服务器;创建者以 userId+slug 隔离保存在当前标签页 sessionStorage。重开及刷新时先检查 active 再预览当前 Token,成功才显示;失效/替换时清除,网络失败不展示旧缓存但可重试。跨浏览器或标签页无法恢复时明确说明,替换须确认。存储不可用时仅保留当前弹窗的内存副本并提示立即复制。
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@
|
|||
|
||||
Token SHALL 避免出现在日志、持久化明文和登录 URL 中。
|
||||
|
||||
Token 使用安全随机数,数据库仅保存哈希;原文只在生成响应中出现一次。分享 URL 使用 `/join#invite=<token>`,页面立即移入当前标签页 sessionStorage 并清除 fragment;登录回跳只包含应用内 `/join`,不带 Token。创建、预览、兑换、作废响应禁止缓存;日志和审计不得包含原文或哈希。四类操作仅允许浏览器 Session,变更请求遵守 CSRF。
|
||||
Token 使用安全随机数,数据库仅保存哈希;原文只在生成响应中出现一次。创建者页面可将原文保存在按账号和空间隔离的当前标签页 sessionStorage,供重开弹窗或刷新后恢复;展示前须重新确认服务端 active 状态并预览该 Token,失效或替换时清除。不得存入 localStorage 或数据库明文;其他标签页或设备无法恢复时明确说明,并保留确认替换入口。分享 URL 使用 `/join#invite=<token>`,页面立即移入当前标签页 sessionStorage 并清除 fragment;登录回跳只包含应用内 `/join`,不带 Token。创建、预览、兑换、作废响应禁止缓存;日志和审计不得包含原文或哈希。四类操作仅允许浏览器 Session,变更请求遵守 CSRF。
|
||||
Token 原始随机字节不得少于 32 字节,并采用 Base64URL 编码;数据库仅保存 SHA-256 哈希。
|
||||
|
||||
#### Scenario: 经过登录回跳
|
||||
|
|
@ -121,3 +121,10 @@ API SHALL 使用 Issue #911 的路径与响应合同。
|
|||
- **WHEN** 他人已消费邀请,而管理员页面还缓存旧的可兑换状态
|
||||
- **THEN** 点击生成先刷新服务端状态,已消费或过期的邀请不误弹替换确认,仍可兑换的邀请必须确认
|
||||
- **AND** 状态刷新失败时不沿用旧缓存生成或替换,显示可重试错误
|
||||
|
||||
#### Scenario: 重开邀请弹窗与成员页布局
|
||||
- **WHEN** 同一账号在同一标签页重开弹窗或刷新成员页
|
||||
- **THEN** 重新校验有效的已保存链接后显示,并提供复制;失效或替换的链接不展示
|
||||
- **AND** 无保存链接时说明限制,不自动替换邀请
|
||||
- **AND** 返回我的空间位于空间标题上方;无管理权限不显示成员变更按钮;有权限的角色保存、取消和移除放在操作列,图标有可访问名称及悬停提示
|
||||
- **AND** 审核状态采用带标签的下拉筛选,与空间主导航区分
|
||||
|
|
|
|||
|
|
@ -202,3 +202,11 @@ PRIVATE 且仅所有者可访问的头像完成文件安全处理后可直接使
|
|||
- **WHEN** 审核员曾看到空列表,其他登录会话随后提交新申请,审核员重新进入列表
|
||||
- **THEN** 重新获取当前申请,不能仅以此前缓存的空列表显示“暂无审核记录”
|
||||
- **AND** 命名空间、状态、分页和排序仍各自隔离,不新增轮询或扩大权限
|
||||
|
||||
### Requirement: 提交者 SHALL 可查看已提交头像
|
||||
|
||||
技能所有者在我的技能及更新页 SHALL 可查看已提交头像的紧凑缩略图及当前审核状态。待审候选与当前公开头像须明确区分;通过前公开展示保持旧头像。候选图片只使用现有所有者受保护接口,不公开未审核内容;无法加载候选时显示明确提示,不冒充现用头像。
|
||||
|
||||
#### Scenario: 头像申请等待审核
|
||||
- **WHEN** 所有者提交新头像并返回我的技能或重开更新页
|
||||
- **THEN** 可看到所提交的新头像及审核状态,而公共列表和详情继续展示旧头像
|
||||
|
|
|
|||
|
|
@ -7,16 +7,25 @@ const api = vi.hoisted(() => ({
|
|||
invitationStatus: vi.fn(),
|
||||
createInvitation: vi.fn(),
|
||||
revokeInvitation: vi.fn(),
|
||||
previewInvitation: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/client', () => ({ namespaceApi: api }))
|
||||
vi.mock('@/api/client', () => ({ namespaceApi: api, getAppBaseUrl: () => undefined }))
|
||||
const auth = vi.hoisted(() => ({ user: { userId: 'owner' } }))
|
||||
vi.mock('@/features/auth/use-auth', () => ({ useAuth: () => auth }))
|
||||
vi.mock('@/shared/lib/toast', () => ({ toast: { success: vi.fn(), info: vi.fn() } }))
|
||||
vi.mock('@/i18n/config', () => ({ default: { exists: () => false } }))
|
||||
vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key }) }))
|
||||
|
||||
import { ApiError } from '@/shared/lib/api-error'
|
||||
import { InvitationControls } from './invitation-controls'
|
||||
|
||||
describe('InvitationControls', () => {
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.restoreAllMocks()
|
||||
window.sessionStorage.clear()
|
||||
auth.user = { userId: 'owner' }
|
||||
Object.values(api).forEach((mock) => mock.mockReset())
|
||||
})
|
||||
|
||||
|
|
@ -78,4 +87,135 @@ describe('InvitationControls', () => {
|
|||
expect(screen.queryByRole('dialog')).toBeNull()
|
||||
})
|
||||
|
||||
const token = 'a'.repeat(43)
|
||||
const storageKey = 'skillhub.namespace.issued-invitation:owner:team'
|
||||
const validPreview = { namespaceSlug: 'team', expiresAt: '2999-01-01T00:00:00Z', outcome: 'ALREADY_MEMBER' }
|
||||
|
||||
function mount(client = new QueryClient({ defaultOptions: { queries: { retry: false } } })) {
|
||||
return render(<QueryClientProvider client={client}><InvitationControls slug="team" /></QueryClientProvider>)
|
||||
}
|
||||
|
||||
it('keeps the generated link after dialog reopen and a new QueryClient (tab reload)', async () => {
|
||||
api.invitationStatus.mockResolvedValueOnce({ active: false }).mockResolvedValueOnce({ active: false }).mockResolvedValue({ active: true })
|
||||
api.createInvitation.mockResolvedValue({ token, replaced: false })
|
||||
api.previewInvitation.mockResolvedValue(validPreview)
|
||||
const view = mount()
|
||||
const button = screen.getByRole('button', { name: 'invitation.create' })
|
||||
await waitFor(() => expect((button as HTMLButtonElement).disabled).toBe(false))
|
||||
fireEvent.click(button)
|
||||
await screen.findByRole('textbox')
|
||||
expect(window.sessionStorage.getItem(storageKey)).toBe(token)
|
||||
view.unmount()
|
||||
mount()
|
||||
expect((await screen.findByRole('textbox') as HTMLInputElement).value).toContain(`/join#invite=${token}`)
|
||||
expect(api.createInvitation).toHaveBeenCalledTimes(1)
|
||||
expect(api.previewInvitation).toHaveBeenCalledWith(token)
|
||||
expect(screen.getByRole('button', { name: 'invitation.copy' })).toBeTruthy()
|
||||
})
|
||||
|
||||
it.each(['consumed', 'expired', 'revoked'])('clears the remembered link when server reports %s/inactive', async () => {
|
||||
window.sessionStorage.setItem(storageKey, token)
|
||||
api.invitationStatus.mockResolvedValue({ active: false })
|
||||
mount()
|
||||
await waitFor(() => expect(window.sessionStorage.getItem(storageKey)).toBeNull())
|
||||
expect(screen.queryByRole('textbox')).toBeNull()
|
||||
expect(api.previewInvitation).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('clears a replaced token and explains why the active link cannot be recovered', async () => {
|
||||
window.sessionStorage.setItem(storageKey, token)
|
||||
api.invitationStatus.mockResolvedValue({ active: true })
|
||||
api.previewInvitation.mockRejectedValue(new ApiError('invalid', 404))
|
||||
mount()
|
||||
await screen.findByText('invitation.activeUnavailable')
|
||||
expect(window.sessionStorage.getItem(storageKey)).toBeNull()
|
||||
expect(screen.queryByRole('textbox')).toBeNull()
|
||||
expect(api.createInvitation).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('hides remembered links on validation network failure but retains them for retry', async () => {
|
||||
window.sessionStorage.setItem(storageKey, token)
|
||||
api.invitationStatus.mockResolvedValue({ active: true })
|
||||
api.previewInvitation.mockRejectedValue(new ApiError('network', 0))
|
||||
mount()
|
||||
await screen.findByRole('alert')
|
||||
expect(window.sessionStorage.getItem(storageKey)).toBe(token)
|
||||
expect(screen.queryByRole('textbox')).toBeNull()
|
||||
})
|
||||
|
||||
it('does not display a token saved by a different authenticated user', async () => {
|
||||
window.sessionStorage.setItem(storageKey, token)
|
||||
auth.user = { userId: 'another-admin' }
|
||||
api.invitationStatus.mockResolvedValue({ active: true })
|
||||
mount()
|
||||
await screen.findByText('invitation.activeUnavailable')
|
||||
expect(screen.queryByRole('textbox')).toBeNull()
|
||||
expect(api.previewInvitation).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('clears the token after confirmed revoke', async () => {
|
||||
window.sessionStorage.setItem(storageKey, token)
|
||||
api.invitationStatus.mockResolvedValue({ active: true })
|
||||
api.previewInvitation.mockResolvedValue(validPreview)
|
||||
api.revokeInvitation.mockImplementation(async () => { api.invitationStatus.mockResolvedValue({ active: false }) })
|
||||
mount()
|
||||
await screen.findByRole('textbox')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'invitation.revoke' }))
|
||||
fireEvent.click(within(await screen.findByRole('dialog')).getByRole('button', { name: 'dialog.confirm' }))
|
||||
await waitFor(() => expect(window.sessionStorage.getItem(storageKey)).toBeNull())
|
||||
await waitFor(() => expect(screen.queryByRole('textbox')).toBeNull())
|
||||
})
|
||||
|
||||
it('keeps only an in-memory copy and explains the limitation when storage is unavailable', async () => {
|
||||
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { throw new Error('storage unavailable') })
|
||||
api.invitationStatus.mockResolvedValueOnce({ active: false }).mockResolvedValueOnce({ active: false }).mockResolvedValue({ active: true })
|
||||
api.createInvitation.mockResolvedValue({ token, replaced: false })
|
||||
api.previewInvitation.mockResolvedValue(validPreview)
|
||||
mount()
|
||||
const button = screen.getByRole('button', { name: 'invitation.create' })
|
||||
await waitFor(() => expect((button as HTMLButtonElement).disabled).toBe(false))
|
||||
fireEvent.click(button)
|
||||
await screen.findByRole('textbox')
|
||||
expect(screen.getByText('invitation.storageUnavailable')).toBeTruthy()
|
||||
expect(window.sessionStorage.getItem(storageKey)).toBeNull()
|
||||
})
|
||||
|
||||
it('does not show a previously validated cached link while reopening validation fails', async () => {
|
||||
window.sessionStorage.setItem(storageKey, token)
|
||||
api.invitationStatus.mockResolvedValue({ active: true })
|
||||
api.previewInvitation.mockResolvedValue(validPreview)
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
const view = mount(client)
|
||||
await screen.findByRole('textbox')
|
||||
view.unmount()
|
||||
api.previewInvitation.mockRejectedValue(new ApiError('network', 0))
|
||||
mount(client)
|
||||
expect(screen.queryByRole('textbox')).toBeNull()
|
||||
await screen.findByRole('alert')
|
||||
expect(screen.queryByRole('textbox')).toBeNull()
|
||||
expect(window.sessionStorage.getItem(storageKey)).toBe(token)
|
||||
})
|
||||
|
||||
it('replaces the remembered link only after confirmation and restores the new link on reopen', async () => {
|
||||
window.sessionStorage.setItem(storageKey, token)
|
||||
const nextToken = 'b'.repeat(43)
|
||||
api.invitationStatus.mockResolvedValue({ active: true })
|
||||
api.previewInvitation.mockResolvedValue(validPreview)
|
||||
api.createInvitation.mockResolvedValue({ token: nextToken, replaced: true })
|
||||
const view = mount()
|
||||
await screen.findByRole('textbox')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'invitation.create' }))
|
||||
const dialog = await screen.findByRole('dialog')
|
||||
expect(api.createInvitation).not.toHaveBeenCalled()
|
||||
expect(window.sessionStorage.getItem(storageKey)).toBe(token)
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: 'dialog.confirm' }))
|
||||
await waitFor(() => expect(window.sessionStorage.getItem(storageKey)).toBe(nextToken))
|
||||
await waitFor(() => expect((screen.getByRole('textbox') as HTMLInputElement).value).toContain(nextToken))
|
||||
expect(api.createInvitation).toHaveBeenCalledWith('team', true)
|
||||
view.unmount()
|
||||
mount()
|
||||
expect((await screen.findByRole('textbox') as HTMLInputElement).value).toContain(nextToken)
|
||||
expect(api.createInvitation).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
import { useId, useState } from 'react'
|
||||
import { useId, useRef, useState } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { getAppBaseUrl, namespaceApi } from '@/api/client'
|
||||
import { useAuth } from '@/features/auth/use-auth'
|
||||
import { ApiError } from '@/shared/lib/api-error'
|
||||
import { ConfirmDialog } from '@/shared/components/confirm-dialog'
|
||||
import { BASE_PATH } from '@/shared/lib/base-path'
|
||||
import { invitationUrl } from '@/shared/lib/namespace-invitation'
|
||||
|
|
@ -14,28 +16,65 @@ export function InvitationControls({ slug, compact = false }: { slug: string; co
|
|||
const { t } = useTranslation()
|
||||
const linkId = useId()
|
||||
const queryClient = useQueryClient()
|
||||
const { user } = useAuth()
|
||||
const storageKey = `skillhub.namespace.issued-invitation:${user?.userId ?? ''}:${slug}`
|
||||
const inMemoryToken = useRef<{ key: string; token: string } | null>(null)
|
||||
const [storageUnavailable, setStorageUnavailable] = useState(false)
|
||||
const [confirm, setConfirm] = useState<'replace' | 'revoke' | null>(null)
|
||||
const [link, setLink] = useState<string | null>(null)
|
||||
const status = useQuery({ queryKey: ['namespace-invitation-status', slug], queryFn: () => namespaceApi.invitationStatus(slug), enabled: !compact })
|
||||
const clearStoredToken = () => {
|
||||
inMemoryToken.current = null
|
||||
try { window.sessionStorage.removeItem(storageKey) } catch { /* Storage may be unavailable. */ }
|
||||
}
|
||||
const status = useQuery({
|
||||
queryKey: ['namespace-invitation-status', slug, user?.userId],
|
||||
enabled: !compact && !!user,
|
||||
staleTime: 0,
|
||||
queryFn: async () => {
|
||||
const current = await namespaceApi.invitationStatus(slug)
|
||||
if (!current.active) {
|
||||
clearStoredToken()
|
||||
return { active: false, token: null }
|
||||
}
|
||||
let token = inMemoryToken.current?.key === storageKey ? inMemoryToken.current.token : null
|
||||
try { token = window.sessionStorage.getItem(storageKey) ?? token } catch { /* Use the current dialog's copy. */ }
|
||||
if (!token) return { active: true, token: null }
|
||||
try {
|
||||
const preview = await namespaceApi.previewInvitation(token)
|
||||
if (preview.namespaceSlug !== slug || Date.parse(preview.expiresAt) <= Date.now()) {
|
||||
clearStoredToken()
|
||||
return { active: true, token: null }
|
||||
}
|
||||
} catch (error) {
|
||||
if (!(error instanceof ApiError) || error.status !== 404) throw error
|
||||
clearStoredToken()
|
||||
return { active: true, token: null }
|
||||
}
|
||||
return { active: true, token }
|
||||
},
|
||||
})
|
||||
const link = !status.isFetching && !status.isError && status.data?.token
|
||||
? invitationUrl(resolvePublicRegistryUrl(getAppBaseUrl(), window.location.origin, BASE_PATH), status.data.token)
|
||||
: null
|
||||
const copyLink = async (url: string, replaced = false) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(url)
|
||||
toast.success(t(replaced ? 'invitation.replacedCopied' : 'invitation.copied'))
|
||||
} catch { toast.info(t('invitation.manualCopy')) }
|
||||
}
|
||||
const create = useMutation({
|
||||
mutationFn: (replace: boolean) => namespaceApi.createInvitation(slug, replace),
|
||||
onSuccess: ({ token, replaced }) => {
|
||||
onSuccess: async ({ token, replaced }) => {
|
||||
inMemoryToken.current = { key: storageKey, token }
|
||||
try { window.sessionStorage.setItem(storageKey, token) } catch { setStorageUnavailable(true) }
|
||||
const url = invitationUrl(resolvePublicRegistryUrl(getAppBaseUrl(), window.location.origin, BASE_PATH), token)
|
||||
setLink(url)
|
||||
try {
|
||||
void navigator.clipboard.writeText(url)
|
||||
.then(() => toast.success(t(replaced ? 'invitation.replacedCopied' : 'invitation.copied')))
|
||||
.catch(() => toast.info(t('invitation.manualCopy')))
|
||||
} catch {
|
||||
toast.info(t('invitation.manualCopy'))
|
||||
}
|
||||
void queryClient.invalidateQueries({ queryKey: ['namespace-invitation-status', slug] })
|
||||
await copyLink(url, replaced)
|
||||
await queryClient.invalidateQueries({ queryKey: ['namespace-invitation-status', slug] })
|
||||
},
|
||||
})
|
||||
const revoke = useMutation({
|
||||
mutationFn: () => namespaceApi.revokeInvitation(slug),
|
||||
onSuccess: async () => {
|
||||
setLink(null)
|
||||
clearStoredToken()
|
||||
await queryClient.invalidateQueries({ queryKey: ['namespace-invitation-status', slug] })
|
||||
toast.success(t('invitation.revoked'))
|
||||
},
|
||||
|
|
@ -50,16 +89,20 @@ export function InvitationControls({ slug, compact = false }: { slug: string; co
|
|||
return (
|
||||
<div id={compact ? undefined : 'invite'} className="space-y-2 scroll-mt-24" onClick={compact ? (event) => event.stopPropagation() : undefined}>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size={compact ? 'sm' : 'default'} disabled={(!compact && status.isPending) || status.isFetching || create.isPending} onClick={handleCreate}>{t('invitation.create')}</Button>
|
||||
<Button variant="outline" size={compact ? 'sm' : 'default'} disabled={!user || (!compact && status.isPending) || status.isFetching || create.isPending} onClick={handleCreate}>{t('invitation.create')}</Button>
|
||||
{status.data?.active ? <Button variant="outline" disabled={revoke.isPending} onClick={() => setConfirm('revoke')}>{t('invitation.revoke')}</Button> : null}
|
||||
</div>
|
||||
{link ? (
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm" htmlFor={linkId}>{t('invitation.link')}</label>
|
||||
<Input id={linkId} readOnly value={link} onFocus={(event) => event.currentTarget.select()} />
|
||||
<p className="text-xs text-muted-foreground">{t('invitation.linkHint')}</p>
|
||||
<div className="flex gap-2">
|
||||
<Input id={linkId} readOnly value={link} onFocus={(event) => event.currentTarget.select()} />
|
||||
<Button variant="outline" onClick={() => void copyLink(link)}>{t('invitation.copy')}</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{t(storageUnavailable ? 'invitation.storageUnavailable' : 'invitation.localLinkHint')}</p>
|
||||
</div>
|
||||
) : null}
|
||||
{!link && !status.isFetching && !status.isError && status.data?.active ? <p className="text-sm text-muted-foreground">{t('invitation.activeUnavailable')}</p> : null}
|
||||
{(create.error || revoke.error || status.error) ? <p role="alert" className="text-sm text-destructive">{t('invitation.error')}</p> : null}
|
||||
<ConfirmDialog open={confirm !== null} onOpenChange={(open) => { if (!open) setConfirm(null) }}
|
||||
title={t(confirm === 'revoke' ? 'invitation.revokeTitle' : 'invitation.replaceTitle')}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,20 @@
|
|||
import { ArrowLeft } from 'lucide-react'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useAuth } from '@/features/auth/use-auth'
|
||||
import { useMyNamespaces } from '@/shared/hooks/use-namespace-queries'
|
||||
import { cn } from '@/shared/lib/utils'
|
||||
|
||||
export function NamespaceManagementBack() {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<Link to="/dashboard/namespaces" className="inline-flex items-center gap-2 rounded-md px-2 py-1.5 text-sm font-medium text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
|
||||
<ArrowLeft className="h-4 w-4" aria-hidden="true" />
|
||||
{t('namespaceManagement.back')}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
export function NamespaceManagementNav({ slug, active }: { slug: string; active: 'skills' | 'members' | 'reviews' | 'settings' }) {
|
||||
const { t } = useTranslation()
|
||||
const { hasRole } = useAuth()
|
||||
|
|
@ -19,9 +30,6 @@ export function NamespaceManagementNav({ slug, active }: { slug: string; active:
|
|||
] as const
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<Link to="/dashboard/namespaces" className="inline-flex text-sm text-muted-foreground hover:text-foreground">
|
||||
{t('namespaceManagement.back')}
|
||||
</Link>
|
||||
<nav aria-label={t('namespaceManagement.navigation')} className="flex gap-1 overflow-x-auto border-b border-border">
|
||||
{tabs.filter((tab) => tab.visible).map((tab) => (
|
||||
<Link key={tab.key} to={tab.path} params={{ slug }}
|
||||
|
|
|
|||
|
|
@ -47,6 +47,75 @@ describe('SkillAvatarManager appeal', () => {
|
|||
expect(screen.getByRole('button', { name: 'skillAvatar.retry' }).hasAttribute('disabled')).toBe(false)
|
||||
})
|
||||
|
||||
|
||||
it('shows the submitted image separately from the unchanged public avatar while waiting', async () => {
|
||||
api.status.mockResolvedValue({ avatarUrl: '/old-public', candidateId: 8, candidateStatus: 'PENDING',
|
||||
machineStatus: 'SKIPPED', reviewStatus: 'PENDING', candidateUrl: '/private-candidate/8' })
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
render(<QueryClientProvider client={client}><SkillAvatarManager namespace="team" slug="foo" /></QueryClientProvider>)
|
||||
expect((await screen.findByRole('img', { name: 'skillAvatar.submittedImage' })).getAttribute('src')).toBe('/private-candidate/8')
|
||||
expect(screen.getByText('skillAvatar.publicUnchanged')).toBeTruthy()
|
||||
expect(screen.queryByRole('img')?.getAttribute('src')).not.toBe('/old-public')
|
||||
expect(api.status).toHaveBeenCalledWith('team', 'foo')
|
||||
})
|
||||
|
||||
it('keeps the My Skills preview read-only without adding another avatar action', async () => {
|
||||
api.status.mockResolvedValue({ candidateId: 8, candidateStatus: 'PENDING', machineStatus: 'BLOCKED',
|
||||
reviewStatus: 'NOT_REQUIRED', candidateUrl: '/private-candidate/8' })
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
render(<QueryClientProvider client={client}><SkillAvatarManager namespace="team" slug="foo" previewOnly /></QueryClientProvider>)
|
||||
expect(await screen.findByRole('img', { name: 'skillAvatar.submittedImage' })).toBeTruthy()
|
||||
expect(screen.queryByRole('button')).toBeNull()
|
||||
})
|
||||
|
||||
it('explains an unavailable candidate preview rather than presenting the public avatar as the submission', async () => {
|
||||
api.status.mockResolvedValue({ avatarUrl: '/old-public', candidateId: 8, candidateStatus: 'REJECTED',
|
||||
machineStatus: 'SKIPPED', reviewStatus: 'REJECTED', candidateUrl: '/private-candidate/8', reviewReason: 'Please choose a different image' })
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
render(<QueryClientProvider client={client}><SkillAvatarManager namespace="team" slug="foo" /></QueryClientProvider>)
|
||||
fireEvent.error(await screen.findByRole('img', { name: 'skillAvatar.submittedImage' }))
|
||||
expect(screen.getByText('skillAvatar.candidateUnavailable')).toBeTruthy()
|
||||
expect(screen.getByText('Please choose a different image')).toBeTruthy()
|
||||
expect(screen.queryByRole('img')).toBeNull()
|
||||
})
|
||||
|
||||
it('removes the pending preview after the standard approval invalidates owner state', async () => {
|
||||
api.status.mockResolvedValueOnce({ candidateId: 8, candidateStatus: 'PENDING', machineStatus: 'SKIPPED',
|
||||
reviewStatus: 'PENDING', candidateUrl: '/private-candidate/8' })
|
||||
.mockResolvedValue({ avatarUrl: '/new-public', candidateId: 8, candidateStatus: 'ACTIVE',
|
||||
machineStatus: 'SKIPPED', reviewStatus: 'APPROVED', candidateUrl: '/private-candidate/8' })
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
render(<QueryClientProvider client={client}><SkillAvatarManager namespace="team" slug="foo" previewOnly /></QueryClientProvider>)
|
||||
expect(await screen.findByRole('img', { name: 'skillAvatar.submittedImage' })).toBeTruthy()
|
||||
await client.invalidateQueries({ queryKey: ['skill-avatar', 'team', 'foo'] })
|
||||
await waitFor(() => expect(screen.queryByRole('img')).toBeNull())
|
||||
expect(screen.queryByText('skillAvatar.publicUnchanged')).toBeNull()
|
||||
})
|
||||
|
||||
|
||||
it('reports an initial owner-status load failure and allows a read-only reload', async () => {
|
||||
api.status.mockRejectedValueOnce(new Error('unavailable')).mockResolvedValue({ candidateId: 8,
|
||||
candidateStatus: 'PENDING', machineStatus: 'SKIPPED', reviewStatus: 'PENDING', candidateUrl: '/private-candidate/8' })
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
render(<QueryClientProvider client={client}><SkillAvatarManager namespace="team" slug="foo" previewOnly /></QueryClientProvider>)
|
||||
expect(await screen.findByText('skillAvatar.statusLoadFailed')).toBeTruthy()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'skillAvatar.reloadStatus' }))
|
||||
expect(await screen.findByRole('img', { name: 'skillAvatar.submittedImage' })).toBeTruthy()
|
||||
expect(screen.queryByRole('alert')).toBeNull()
|
||||
expect(api.status).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('keeps a cached submitted image visible while clearly reporting a failed status refresh', async () => {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
client.setQueryData(['skill-avatar', 'team', 'foo'], { candidateId: 8, candidateStatus: 'PENDING',
|
||||
machineStatus: 'SKIPPED', reviewStatus: 'PENDING', candidateUrl: '/private-candidate/8' })
|
||||
api.status.mockRejectedValue(new Error('unavailable'))
|
||||
render(<QueryClientProvider client={client}><SkillAvatarManager namespace="team" slug="foo" previewOnly /></QueryClientProvider>)
|
||||
expect(await screen.findByText('skillAvatar.statusRefreshFailed')).toBeTruthy()
|
||||
expect(screen.getByRole('img', { name: 'skillAvatar.submittedImage' }).getAttribute('src')).toBe('/private-candidate/8')
|
||||
expect(screen.getByRole('button', { name: 'skillAvatar.reloadStatus' })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('does not render approved reasons, duplicate image or editing controls', async () => {
|
||||
api.status.mockResolvedValue({ avatarUrl: '/active', candidateId: 7, candidateStatus: 'ACTIVE', machineStatus: 'SKIPPED', reviewStatus: 'APPROVED', reviewReason: 'approved reason' })
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
|
|
|
|||
|
|
@ -1,14 +1,14 @@
|
|||
import { useState } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { skillAvatarApi } from '@/api/client'
|
||||
import { buildApiUrl, skillAvatarApi } from '@/api/client'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
|
||||
export function SkillAvatarManager({ namespace, slug, disabled = false }: { namespace: string; slug: string; disabled?: boolean }) {
|
||||
export function SkillAvatarManager({ namespace, slug, disabled = false, previewOnly = false }: { namespace: string; slug: string; disabled?: boolean; previewOnly?: boolean }) {
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
const key = ['skill-avatar', namespace, slug]
|
||||
const status = useQuery({ queryKey: key, queryFn: () => skillAvatarApi.status(namespace, slug), enabled: !!namespace && !!slug })
|
||||
const status = useQuery({ queryKey: key, queryFn: () => skillAvatarApi.status(namespace, slug), enabled: !!namespace && !!slug, staleTime: 0 })
|
||||
const [error, setError] = useState('')
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (action: { kind: 'appeal' | 'retry'; id: number }) => action.kind === 'appeal'
|
||||
|
|
@ -20,21 +20,36 @@ export function SkillAvatarManager({ namespace, slug, disabled = false }: { name
|
|||
},
|
||||
onError: (cause) => setError(cause instanceof Error ? cause.message : t('skillAvatar.failed')),
|
||||
})
|
||||
const [failedPreview, setFailedPreview] = useState<string | null>(null)
|
||||
const candidate = status.data
|
||||
const retryCoolingDown = !!candidate?.machineNextRetryAt && Date.parse(candidate.machineNextRetryAt) > Date.now()
|
||||
if (!candidate || !['PENDING', 'REJECTED'].includes(candidate.candidateStatus ?? '')) return null
|
||||
const loadError = status.isError ? <div role="alert" className="flex flex-wrap items-center gap-2 text-xs text-destructive">
|
||||
<span>{t(candidate ? 'skillAvatar.statusRefreshFailed' : 'skillAvatar.statusLoadFailed')}</span>
|
||||
<Button type="button" size="sm" variant="ghost" disabled={status.isFetching} onClick={() => void status.refetch()}>{t('skillAvatar.reloadStatus')}</Button>
|
||||
</div> : null
|
||||
if (!candidate || !['PENDING', 'REJECTED'].includes(candidate.candidateStatus ?? '')) return loadError
|
||||
return (
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className={`space-y-2 rounded-lg border bg-muted/20 p-3 text-sm ${previewOnly ? 'mb-3' : ''}`} aria-label={t('skillAvatar.submittedImage')}>
|
||||
<div className="flex items-center gap-3">
|
||||
{candidate.candidateUrl && failedPreview !== candidate.candidateUrl
|
||||
? <img src={buildApiUrl(candidate.candidateUrl)} alt={t('skillAvatar.submittedImage')} className="h-12 w-12 shrink-0 rounded-lg object-cover" onError={() => setFailedPreview(candidate.candidateUrl)} />
|
||||
: <span className="text-xs text-muted-foreground">{t('skillAvatar.candidateUnavailable')}</span>}
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium">{t('skillAvatar.submittedImage')}</p>
|
||||
<p className="text-xs text-muted-foreground">{t('skillAvatar.publicUnchanged')}</p>
|
||||
</div>
|
||||
</div>
|
||||
{candidate?.candidateStatus && <p role="status" className="text-sm">{t('skillAvatar.status')}: {t(`skillAvatar.states.${candidate.candidateStatus}`)}</p>}
|
||||
{candidate?.reviewReason && <p className="text-sm">{candidate.reviewReason}</p>}
|
||||
{candidate?.machineStatus === 'ERROR' && retryCoolingDown && <p className="text-sm text-muted-foreground">{t('skillAvatar.retryAfter', { time: new Date(candidate.machineNextRetryAt!).toLocaleString() })}</p>}
|
||||
{candidate?.machineStatus === 'ERROR' && candidate.automaticRetryExhausted && <p className="text-sm text-muted-foreground">{t('skillAvatar.automaticRetryStopped')}</p>}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{!previewOnly && <div className="flex flex-wrap gap-2">
|
||||
{candidate?.candidateStatus === 'PENDING' && candidate.machineStatus === 'BLOCKED'
|
||||
&& candidate.reviewStatus === 'NOT_REQUIRED' && candidate.candidateId
|
||||
&& <Button variant="outline" disabled={disabled || mutation.isPending} onClick={() => mutation.mutate({ kind: 'appeal', id: candidate.candidateId! })}>{t('skillAvatar.appeal')}</Button>}
|
||||
{candidate?.machineStatus === 'ERROR' && candidate.candidateId && <Button variant="outline" disabled={disabled || mutation.isPending || retryCoolingDown} onClick={() => mutation.mutate({ kind: 'retry', id: candidate.candidateId! })}>{t('skillAvatar.retry')}</Button>}
|
||||
</div>
|
||||
</div>}
|
||||
{loadError}
|
||||
{error && <p role="alert" className="text-sm text-destructive">{error}</p>}
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1412,7 +1412,11 @@
|
|||
"permissions": "Confirm to join as MEMBER. You can access space resources and publish skills.",
|
||||
"alreadyMember": "You are already a member of this space.",
|
||||
"enter": "Enter space",
|
||||
"confirm": "Confirm and join"
|
||||
"confirm": "Confirm and join",
|
||||
"activeUnavailable": "The invitation is active, but this tab has no saved link. Copy it from the tab that created it, or confirm creating a replacement to invalidate the old link.",
|
||||
"localLinkHint": "The link is saved only for this account in this browser tab; one new member can use it within 24 hours.",
|
||||
"copy": "Copy link",
|
||||
"storageUnavailable": "Your browser could not save the link. Copy it now; it cannot be restored after closing this dialog or refreshing."
|
||||
},
|
||||
"members": {
|
||||
"title": "Member Management",
|
||||
|
|
@ -1508,7 +1512,8 @@
|
|||
"batchResultInvalidRole": "Invalid role",
|
||||
"batchResultUnknownError": "Unknown error",
|
||||
"batchDone": "Done",
|
||||
"batchBack": "Back"
|
||||
"batchBack": "Back",
|
||||
"cancelRole": "Cancel role change"
|
||||
},
|
||||
"namespaceEdit": {
|
||||
"editButton": "Edit",
|
||||
|
|
@ -1550,7 +1555,8 @@
|
|||
"nextPage": "Next",
|
||||
"globalReadOnly": "This is a built-in system namespace. Review history is visible, but no new review actions are allowed.",
|
||||
"frozenReadOnly": "This namespace is frozen. You can still view review history, but cannot continue processing review tasks.",
|
||||
"archivedReadOnly": "This namespace is archived. You can still view review history, but cannot continue processing review tasks until it is restored."
|
||||
"archivedReadOnly": "This namespace is archived. You can still view review history, but cannot continue processing review tasks until it is restored.",
|
||||
"statusLabel": "Review status"
|
||||
},
|
||||
"namespaceStatus": {
|
||||
"active": "Active",
|
||||
|
|
@ -2693,7 +2699,13 @@
|
|||
"cropConfirm": "Confirm and upload",
|
||||
"change": "Change image",
|
||||
"stagedReady": "Image uploaded; ready to submit",
|
||||
"removedDraft": "Pending change: remove avatar"
|
||||
"removedDraft": "Pending change: remove avatar",
|
||||
"submittedImage": "Submitted avatar",
|
||||
"publicUnchanged": "The public avatar stays unchanged until approval.",
|
||||
"candidateUnavailable": "Submitted avatar preview unavailable",
|
||||
"statusLoadFailed": "Couldn't load the submitted avatar. Please retry.",
|
||||
"statusRefreshFailed": "Couldn't refresh review status. The last loaded avatar is shown.",
|
||||
"reloadStatus": "Reload"
|
||||
},
|
||||
"namespaceManagement": {
|
||||
"back": "Back to my spaces",
|
||||
|
|
|
|||
|
|
@ -1477,7 +1477,11 @@
|
|||
"permissions": "Подтвердите вступление с ролью MEMBER. Вы сможете просматривать ресурсы и публиковать навыки.",
|
||||
"alreadyMember": "Вы уже участник этого пространства.",
|
||||
"enter": "Открыть пространство",
|
||||
"confirm": "Подтвердить вступление"
|
||||
"confirm": "Подтвердить вступление",
|
||||
"activeUnavailable": "Приглашение активно, но ссылка не сохранена в этой вкладке. Скопируйте её из вкладки, в которой создали приглашение, или подтвердите замену ссылки.",
|
||||
"localLinkHint": "Ссылка сохранена только для текущего аккаунта в этой вкладке; один новый участник может использовать её в течение 24 часов.",
|
||||
"copy": "Копировать ссылку",
|
||||
"storageUnavailable": "Браузер не смог сохранить ссылку. Скопируйте её сейчас; после закрытия окна или обновления восстановить её не получится."
|
||||
},
|
||||
"members": {
|
||||
"title": "Управление участниками",
|
||||
|
|
@ -1573,7 +1577,8 @@
|
|||
"batchResultInvalidRole": "Недопустимая роль",
|
||||
"batchResultUnknownError": "Неизвестная ошибка",
|
||||
"batchDone": "Готово",
|
||||
"batchBack": "Назад"
|
||||
"batchBack": "Назад",
|
||||
"cancelRole": "Отменить изменение роли"
|
||||
},
|
||||
"namespaceEdit": {
|
||||
"editButton": "Редактировать",
|
||||
|
|
@ -1770,7 +1775,8 @@
|
|||
"nextPage": "Вперёд",
|
||||
"globalReadOnly": "Это встроенное системное пространство имён. История ревью видна, но новые действия ревью недоступны.",
|
||||
"frozenReadOnly": "Это пространство имён заморожено. Историю ревью можно смотреть, но обрабатывать задачи нельзя.",
|
||||
"archivedReadOnly": "Это пространство имён в архиве. Историю ревью можно смотреть, но обрабатывать задачи нельзя, пока оно не восстановлено."
|
||||
"archivedReadOnly": "Это пространство имён в архиве. Историю ревью можно смотреть, но обрабатывать задачи нельзя, пока оно не восстановлено.",
|
||||
"statusLabel": "Статус проверки"
|
||||
},
|
||||
"pagination": {
|
||||
"label": "Навигация по страницам",
|
||||
|
|
@ -2695,7 +2701,13 @@
|
|||
"cropConfirm": "Подтвердить и загрузить",
|
||||
"change": "Заменить изображение",
|
||||
"stagedReady": "Изображение загружено; готово к отправке",
|
||||
"removedDraft": "Изменение ожидает отправки: удалить аватар"
|
||||
"removedDraft": "Изменение ожидает отправки: удалить аватар",
|
||||
"submittedImage": "Отправленный аватар",
|
||||
"publicUnchanged": "Публичный аватар не изменится до одобрения.",
|
||||
"candidateUnavailable": "Предпросмотр отправленного аватара недоступен",
|
||||
"statusLoadFailed": "Не удалось загрузить отправленный аватар. Повторите попытку.",
|
||||
"statusRefreshFailed": "Не удалось обновить статус проверки. Показан последний загруженный аватар.",
|
||||
"reloadStatus": "Загрузить повторно"
|
||||
},
|
||||
"namespaceManagement": {
|
||||
"back": "Вернуться к моим пространствам",
|
||||
|
|
|
|||
|
|
@ -1412,7 +1412,11 @@
|
|||
"permissions": "确认后,你将以 MEMBER 身份加入,可访问空间资源并发布技能。",
|
||||
"alreadyMember": "你已是该空间成员。",
|
||||
"enter": "进入空间",
|
||||
"confirm": "确认加入"
|
||||
"confirm": "确认加入",
|
||||
"activeUnavailable": "当前邀请仍有效,但此标签页没有保存完整链接。可在创建链接的标签页复制,或确认生成新链接使旧链接失效。",
|
||||
"localLinkHint": "链接仅保存在当前账号的这个浏览器标签页;24 小时内可供一名新成员使用。",
|
||||
"copy": "复制链接",
|
||||
"storageUnavailable": "浏览器未能保存链接,请现在复制;关闭此窗口或刷新后将无法恢复。"
|
||||
},
|
||||
"members": {
|
||||
"title": "成员管理",
|
||||
|
|
@ -1508,7 +1512,8 @@
|
|||
"batchResultInvalidRole": "角色无效",
|
||||
"batchResultUnknownError": "未知错误",
|
||||
"batchDone": "完成",
|
||||
"batchBack": "返回"
|
||||
"batchBack": "返回",
|
||||
"cancelRole": "取消角色修改"
|
||||
},
|
||||
"namespaceEdit": {
|
||||
"editButton": "编辑",
|
||||
|
|
@ -1550,7 +1555,8 @@
|
|||
"nextPage": "下一页",
|
||||
"globalReadOnly": "这是系统内置命名空间,仅可查看审核记录,不允许执行新的审核动作。",
|
||||
"frozenReadOnly": "命名空间已冻结。你仍可查看审核记录,但当前不能继续处理审核任务。",
|
||||
"archivedReadOnly": "命名空间已归档。你仍可查看审核记录,但在恢复前不能继续处理审核任务。"
|
||||
"archivedReadOnly": "命名空间已归档。你仍可查看审核记录,但在恢复前不能继续处理审核任务。",
|
||||
"statusLabel": "审核状态"
|
||||
},
|
||||
"namespaceStatus": {
|
||||
"active": "正常",
|
||||
|
|
@ -2692,7 +2698,13 @@
|
|||
"cropConfirm": "确认并上传",
|
||||
"change": "更换图片",
|
||||
"stagedReady": "图片已上传,待提交",
|
||||
"removedDraft": "待提交:移除头像"
|
||||
"removedDraft": "待提交:移除头像",
|
||||
"submittedImage": "已提交头像",
|
||||
"publicUnchanged": "审核通过前,公开展示的头像保持不变。",
|
||||
"candidateUnavailable": "已提交头像暂时无法预览",
|
||||
"statusLoadFailed": "无法加载已提交头像,请重试。",
|
||||
"statusRefreshFailed": "无法刷新审核状态,以下为上次加载的头像。",
|
||||
"reloadStatus": "重新加载"
|
||||
},
|
||||
"namespaceManagement": {
|
||||
"back": "返回我的空间",
|
||||
|
|
|
|||
|
|
@ -107,6 +107,11 @@ vi.mock('@/api/client', () => ({
|
|||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/features/skill/skill-avatar-manager', () => ({
|
||||
SkillAvatarManager: ({ namespace, slug, previewOnly }: { namespace: string; slug: string; previewOnly?: boolean }) =>
|
||||
createElement('div', { 'data-submitted-avatar': `${namespace}/${slug}`, 'data-read-only': previewOnly }),
|
||||
}))
|
||||
|
||||
import { MySkillsPage } from './my-skills'
|
||||
|
||||
function findButton(label: string) {
|
||||
|
|
@ -172,6 +177,16 @@ describe('MySkillsPage', () => {
|
|||
expect(buttonRecords.some(button => button.label === 'mySkills.update')).toBe(true)
|
||||
})
|
||||
|
||||
it('offers an owner-only submitted avatar preview for active skills without a second editing action', () => {
|
||||
useMySkillsMock.mockReturnValue({ data: { items: [{ id: 1, displayName: 'Team Agent', namespace: 'team-ai', slug: 'team-agent', avatarUrl: '/skill-avatar.png', status: 'ACTIVE', downloadCount: 0 }], total: 1 }, isLoading: false })
|
||||
const html = renderToStaticMarkup(createElement(MySkillsPage))
|
||||
expect(html).toContain('data-submitted-avatar="team-ai/team-agent"')
|
||||
expect(html).toContain('data-read-only="true"')
|
||||
expect(html).toContain('src="/skill-avatar.png"')
|
||||
expect(buttonRecords.some(button => button.label === 'mySkills.update')).toBe(true)
|
||||
expect(buttonRecords.some(button => button.label === 'skillAvatar.manage')).toBe(false)
|
||||
})
|
||||
|
||||
it('does not render update action for archived skills', () => {
|
||||
useMySkillsMock.mockReturnValue({
|
||||
data: {
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import { formatCompactCount } from '@/shared/lib/number-format'
|
|||
import { toast } from '@/shared/lib/toast'
|
||||
import { buildReturnTo } from '@/shared/lib/auth-route'
|
||||
import { SkillAvatar } from '@/features/skill/skill-avatar'
|
||||
import { SkillAvatarManager } from '@/features/skill/skill-avatar-manager'
|
||||
import { ApiError } from '@/api/client'
|
||||
import { getMySkillEmptyStateKey, getMySkillFilters, type MySkillFilter } from './my-skill-filters'
|
||||
|
||||
|
|
@ -388,6 +389,7 @@ export function MySkillsPage() {
|
|||
{skill.displayName}
|
||||
</h3>
|
||||
</div>
|
||||
{skill.status === 'ACTIVE' && filter !== 'HIDDEN' && <SkillAvatarManager namespace={skill.namespace} slug={skill.slug} previewOnly />}
|
||||
{skill.summary && (
|
||||
<p className="text-sm text-muted-foreground mb-3 leading-relaxed">{skill.summary}</p>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useParams } from '@tanstack/react-router'
|
||||
import { NamespaceHeader } from '@/features/namespace/namespace-header'
|
||||
import { NamespaceManagementNav } from '@/features/namespace/namespace-management-nav'
|
||||
import { NamespaceManagementBack, NamespaceManagementNav } from '@/features/namespace/namespace-management-nav'
|
||||
import { useNamespaceDetail } from '@/shared/hooks/use-namespace-queries'
|
||||
import { NamespaceSkills } from '@/features/namespace/namespace-skills'
|
||||
|
||||
|
|
@ -9,6 +9,7 @@ export function NamespaceManagementPage() {
|
|||
const { data: namespace } = useNamespaceDetail(slug)
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<NamespaceManagementBack />
|
||||
{namespace ? <NamespaceHeader namespace={namespace} /> : null}
|
||||
<NamespaceManagementNav slug={slug} active="skills" />
|
||||
<NamespaceSkills key={slug} namespace={slug} showHeader={false} />
|
||||
|
|
|
|||
|
|
@ -1,72 +0,0 @@
|
|||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@tanstack/react-router', () => ({
|
||||
useParams: () => ({ slug: 'test-ns' }),
|
||||
}))
|
||||
|
||||
vi.mock('react-i18next', async () => {
|
||||
const actual = await vi.importActual<typeof import('react-i18next')>('react-i18next')
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
i18n: { language: 'en' },
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/shared/lib/date-time', () => ({
|
||||
formatLocalDateTime: (v: string) => v,
|
||||
}))
|
||||
|
||||
vi.mock('@/features/namespace/add-namespace-member-dialog', () => ({
|
||||
AddNamespaceMemberDialog: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('@/features/namespace/namespace-header', () => ({
|
||||
NamespaceHeader: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/components/confirm-dialog', () => ({
|
||||
ConfirmDialog: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/components/dashboard-page-header', () => ({
|
||||
DashboardPageHeader: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/ui/button', () => ({
|
||||
Button: ({ children }: { children: unknown }) => children,
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/ui/card', () => ({
|
||||
Card: ({ children }: { children: unknown }) => children,
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/ui/select', () => ({
|
||||
Select: ({ children }: { children: unknown }) => children,
|
||||
SelectContent: ({ children }: { children: unknown }) => children,
|
||||
SelectItem: ({ children }: { children: unknown }) => children,
|
||||
SelectTrigger: ({ children }: { children: unknown }) => children,
|
||||
SelectValue: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/hooks/use-namespace-queries', () => ({
|
||||
useMyNamespaces: () => ({ data: [] }),
|
||||
useNamespaceDetail: () => ({ data: null, isLoading: false }),
|
||||
useNamespaceMembers: () => ({ data: [], isLoading: false, error: null }),
|
||||
useRemoveNamespaceMember: () => ({ mutateAsync: vi.fn() }),
|
||||
useUpdateNamespaceMemberRole: () => ({ mutateAsync: vi.fn() }),
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/lib/toast', () => ({
|
||||
toast: { success: vi.fn(), error: vi.fn() },
|
||||
}))
|
||||
|
||||
import { NamespaceMembersPage } from './namespace-members'
|
||||
|
||||
describe('NamespaceMembersPage', () => {
|
||||
it('exports a named component function', () => {
|
||||
expect(typeof NamespaceMembersPage).toBe('function')
|
||||
})
|
||||
})
|
||||
93
web/src/pages/dashboard/namespace-members.test.tsx
Normal file
93
web/src/pages/dashboard/namespace-members.test.tsx
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
/** @vitest-environment jsdom */
|
||||
import { Children, type ReactElement, type ReactNode, type Ref } from 'react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
|
||||
const state = vi.hoisted(() => ({ role: 'OWNER', status: 'ACTIVE', update: vi.fn(), remove: vi.fn() }))
|
||||
vi.mock('@tanstack/react-router', () => ({ useParams: () => ({ slug: 'team' }) }))
|
||||
vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key, i18n: { language: 'en' } }) }))
|
||||
vi.mock('@/features/namespace/namespace-header', () => ({ NamespaceHeader: () => <h1>Team</h1> }))
|
||||
vi.mock('@/features/namespace/namespace-management-nav', () => ({ NamespaceManagementBack: () => <a href="/dashboard/namespaces">Back</a>, NamespaceManagementNav: () => <nav>Navigation</nav> }))
|
||||
vi.mock('@/features/namespace/invitation-controls', () => ({ InvitationControls: () => null }))
|
||||
vi.mock('@/features/namespace/add-namespace-member-dialog', () => ({ AddNamespaceMemberDialog: ({ children }: { children: ReactNode }) => children }))
|
||||
vi.mock('@/features/namespace/batch-import-members-dialog', () => ({ BatchImportMembersDialog: ({ children }: { children: ReactNode }) => children }))
|
||||
vi.mock('@/features/namespace/transfer-ownership-dialog', () => ({ TransferOwnershipDialog: ({ children }: { children: ReactNode }) => children }))
|
||||
vi.mock('@/shared/components/confirm-dialog', () => ({ ConfirmDialog: () => null }))
|
||||
vi.mock('@/shared/lib/toast', () => ({ toast: { success: vi.fn(), error: vi.fn() } }))
|
||||
vi.mock('@/shared/ui/select', () => ({
|
||||
Select: ({ children, value, onValueChange, disabled }: { children: ReactNode; value: string; onValueChange: (value: string) => void; disabled?: boolean }) => {
|
||||
const trigger = Children.toArray(children)[0] as ReactElement<{ ref?: Ref<HTMLSelectElement> }>
|
||||
return <select ref={trigger.props.ref} aria-label="Role" value={value} disabled={disabled} onChange={(event) => onValueChange(event.target.value)}>{children}</select>
|
||||
},
|
||||
SelectTrigger: () => null,
|
||||
SelectValue: () => null,
|
||||
SelectContent: ({ children }: { children: ReactNode }) => children,
|
||||
SelectItem: ({ children, value }: { children: ReactNode; value: string }) => <option value={value}>{children}</option>,
|
||||
}))
|
||||
vi.mock('@/shared/hooks/use-namespace-queries', () => ({
|
||||
useMyNamespaces: () => ({ data: [{ slug: 'team', currentUserRole: state.role }] }),
|
||||
useNamespaceDetail: () => ({ data: { slug: 'team', type: 'TEAM', status: state.status }, isLoading: false }),
|
||||
useNamespaceMembers: () => ({ data: { items: [
|
||||
{ id: 1, userId: 'owner', displayName: 'Owner', role: 'OWNER', createdAt: '2026-10-13T00:00:00Z' },
|
||||
{ id: 2, userId: 'member', displayName: 'Member', role: 'MEMBER', createdAt: '2026-10-13T00:00:00Z' },
|
||||
], total: 2 }, isLoading: false }),
|
||||
useUpdateNamespaceMemberRole: () => ({ mutateAsync: state.update }),
|
||||
useRemoveNamespaceMember: () => ({ mutateAsync: state.remove }),
|
||||
}))
|
||||
import { NamespaceMembersPage } from './namespace-members'
|
||||
|
||||
afterEach(cleanup)
|
||||
beforeEach(() => { state.role = 'OWNER'; state.status = 'ACTIVE'; state.update.mockReset().mockResolvedValue({}); state.remove.mockReset() })
|
||||
|
||||
describe('NamespaceMembersPage permissions and role actions', () => {
|
||||
it('places back navigation before the namespace header', () => {
|
||||
const { container } = render(<NamespaceMembersPage />)
|
||||
expect(container.firstElementChild?.firstElementChild?.tagName).toBe('A')
|
||||
})
|
||||
it.each(['MEMBER', 'FROZEN', 'ARCHIVED'])('hides management buttons for %s', (mode) => {
|
||||
if (mode === 'MEMBER') state.role = mode
|
||||
else state.status = mode
|
||||
render(<NamespaceMembersPage />)
|
||||
expect(screen.queryByRole('button', { name: 'members.addMember' })).toBeNull()
|
||||
expect(screen.queryByRole('button', { name: 'members.remove' })).toBeNull()
|
||||
expect(screen.queryByRole('columnheader', { name: 'members.colActions' })).toBeNull()
|
||||
})
|
||||
it('never shows a removal button for the owner and places icon actions in the action column', async () => {
|
||||
render(<NamespaceMembersPage />)
|
||||
const ownerRow = screen.getByText('Owner').closest('tr')!
|
||||
expect(within(ownerRow).queryByRole('button')).toBeNull()
|
||||
const row = screen.getByText('Member').closest('tr')!
|
||||
fireEvent.change(within(row).getByRole('combobox'), { target: { value: 'ADMIN' } })
|
||||
const save = within(row).getByRole('button', { name: 'members.saveRole' })
|
||||
expect(save.closest('td')).toBe(row.lastElementChild)
|
||||
expect(save.getAttribute('title')).toBe('members.saveRole')
|
||||
expect(save.querySelector('svg')).toBeTruthy()
|
||||
save.focus()
|
||||
fireEvent.click(save)
|
||||
await waitFor(() => expect(state.update).toHaveBeenCalledWith({ slug: 'team', userId: 'member', role: 'ADMIN' }))
|
||||
await waitFor(() => expect(document.activeElement).toBe(within(row).getByRole('combobox')))
|
||||
})
|
||||
it('preserves the selected role and save action when an async update fails', async () => {
|
||||
state.update.mockRejectedValueOnce(new Error('unavailable'))
|
||||
render(<NamespaceMembersPage />)
|
||||
const role = screen.getByRole('combobox')
|
||||
fireEvent.change(role, { target: { value: 'ADMIN' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'members.saveRole' }))
|
||||
await waitFor(() => expect((role as HTMLSelectElement).disabled).toBe(false))
|
||||
expect((role as HTMLSelectElement).value).toBe('ADMIN')
|
||||
expect(screen.getByRole('button', { name: 'members.saveRole' })).toBeTruthy()
|
||||
expect(state.update).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
it('cancel clears the role draft without sending an update', () => {
|
||||
render(<NamespaceMembersPage />)
|
||||
const role = screen.getByRole('combobox')
|
||||
fireEvent.change(role, { target: { value: 'ADMIN' } })
|
||||
const cancel = screen.getByRole('button', { name: 'members.cancelRole' })
|
||||
cancel.focus()
|
||||
fireEvent.click(cancel)
|
||||
expect(document.activeElement).toBe(role)
|
||||
expect((role as HTMLSelectElement).value).toBe('MEMBER')
|
||||
expect(state.update).not.toHaveBeenCalled()
|
||||
expect(screen.queryByRole('button', { name: 'members.saveRole' })).toBeNull()
|
||||
})
|
||||
})
|
||||
|
|
@ -1,11 +1,12 @@
|
|||
import { useState } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Check, Loader2, Trash2, X } from 'lucide-react'
|
||||
import { useParams } from '@tanstack/react-router'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { formatLocalDateTime } from '@/shared/lib/date-time'
|
||||
import { AddNamespaceMemberDialog } from '@/features/namespace/add-namespace-member-dialog'
|
||||
import { BatchImportMembersDialog } from '@/features/namespace/batch-import-members-dialog'
|
||||
import { InvitationControls } from '@/features/namespace/invitation-controls'
|
||||
import { NamespaceManagementNav } from '@/features/namespace/namespace-management-nav'
|
||||
import { NamespaceManagementBack, NamespaceManagementNav } from '@/features/namespace/namespace-management-nav'
|
||||
import { Dialog, DialogContent, DialogDescription, DialogTitle, DialogTrigger } from '@/shared/ui/dialog'
|
||||
import { NamespaceHeader } from '@/features/namespace/namespace-header'
|
||||
import { TransferOwnershipDialog } from '@/features/namespace/transfer-ownership-dialog'
|
||||
|
|
@ -49,6 +50,16 @@ export function NamespaceMembersPage() {
|
|||
const [pendingRemoval, setPendingRemoval] = useState<PendingRemoval | null>(null)
|
||||
const [savingRoleUserId, setSavingRoleUserId] = useState<string | null>(null)
|
||||
const [removingUserId, setRemovingUserId] = useState<string | null>(null)
|
||||
const roleSelectRefs = useRef<Record<string, HTMLButtonElement | null>>({})
|
||||
const roleToFocus = useRef<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (savingRoleUserId === null && roleToFocus.current) {
|
||||
roleSelectRefs.current[roleToFocus.current]?.focus()
|
||||
roleToFocus.current = null
|
||||
}
|
||||
}, [savingRoleUserId])
|
||||
|
||||
|
||||
const { data: namespace, isLoading: isLoadingNamespace } = useNamespaceDetail(slug)
|
||||
const { data: membersPage, isLoading: isLoadingMembers, error: membersError } = useNamespaceMembers(slug, page, MEMBER_PAGE_SIZE)
|
||||
|
|
@ -98,6 +109,7 @@ export function NamespaceMembersPage() {
|
|||
t('members.updateRoleSuccessTitle'),
|
||||
t('members.updateRoleSuccessDescription', { userId, role: nextRole }),
|
||||
)
|
||||
roleToFocus.current = userId
|
||||
setDraftRoles((current) => {
|
||||
const next = { ...current }
|
||||
delete next[userId]
|
||||
|
|
@ -153,6 +165,7 @@ export function NamespaceMembersPage() {
|
|||
|
||||
return (
|
||||
<div className="space-y-8 animate-fade-up">
|
||||
<NamespaceManagementBack />
|
||||
<NamespaceHeader namespace={namespace} canEdit={canEditNamespace} />
|
||||
<NamespaceManagementNav slug={slug} active="members" />
|
||||
|
||||
|
|
@ -163,7 +176,7 @@ export function NamespaceMembersPage() {
|
|||
</Card>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
{canManageMembers ? <div className="flex flex-wrap items-center justify-end gap-2">
|
||||
{canManageMembers ? (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild><Button variant="outline">{t('namespaceManagement.invite')}</Button></DialogTrigger>
|
||||
|
|
@ -188,10 +201,8 @@ export function NamespaceMembersPage() {
|
|||
<Button>{t('members.addMember')}</Button>
|
||||
</AddNamespaceMemberDialog>
|
||||
</>
|
||||
) : (
|
||||
<Button disabled>{t('members.addMember')}</Button>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div> : null}
|
||||
|
||||
{membersError ? (
|
||||
<Card className="p-6 text-center text-red-600">
|
||||
|
|
@ -213,7 +224,7 @@ export function NamespaceMembersPage() {
|
|||
<th className="text-left p-4 font-medium font-heading text-sm text-muted-foreground">{t('members.colEmail')}</th>
|
||||
<th className="text-left p-4 font-medium font-heading text-sm text-muted-foreground">{t('members.colRole')}</th>
|
||||
<th className="text-left p-4 font-medium font-heading text-sm text-muted-foreground">{t('members.colJoinedAt')}</th>
|
||||
<th className="text-right p-4 font-medium font-heading text-sm text-muted-foreground">{t('members.colActions')}</th>
|
||||
{canManageMembers ? <th className="text-right p-4 font-medium font-heading text-sm text-muted-foreground">{t('members.colActions')}</th> : null}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
|
@ -237,6 +248,7 @@ export function NamespaceMembersPage() {
|
|||
<div className="flex items-center gap-2">
|
||||
<Select
|
||||
value={roleValue}
|
||||
disabled={isSavingRole || isRemoving}
|
||||
onValueChange={(value) => {
|
||||
setDraftRoles((current) => ({
|
||||
...current,
|
||||
|
|
@ -244,7 +256,7 @@ export function NamespaceMembersPage() {
|
|||
}))
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-36">
|
||||
<SelectTrigger ref={(element) => { roleSelectRefs.current[member.userId] = element }} className="w-36" aria-label={t('members.colRole')}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
|
@ -252,18 +264,9 @@ export function NamespaceMembersPage() {
|
|||
<SelectItem value="ADMIN">{t('members.roleAdmin')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={roleValue === member.role || isSavingRole}
|
||||
onClick={() => handleRoleSave(member.userId, member.role)}
|
||||
>
|
||||
{isSavingRole ? t('members.savingRole') : t('members.saveRole')}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<span className="inline-flex items-center rounded-full px-2.5 py-1 text-xs font-medium bg-accent/10 text-accent border border-accent/20">
|
||||
<span className="inline-flex items-center rounded-full px-2.5 py-1 text-xs font-medium bg-secondary text-foreground border border-border/60">
|
||||
{member.role === 'OWNER'
|
||||
? t('members.roleOwner')
|
||||
: member.role === 'ADMIN'
|
||||
|
|
@ -272,20 +275,43 @@ export function NamespaceMembersPage() {
|
|||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="p-4 text-sm text-muted-foreground">
|
||||
<td className="p-4 text-sm whitespace-nowrap text-muted-foreground">
|
||||
{formatLocalDateTime(member.createdAt, i18n.language, { dateStyle: 'medium' })}
|
||||
</td>
|
||||
<td className="p-4 text-right">
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
disabled={!canManageMembers || isOwner || isRemoving}
|
||||
onClick={() => setPendingRemoval({ userId: member.userId })}
|
||||
>
|
||||
{t('members.remove')}
|
||||
</Button>
|
||||
</td>
|
||||
{canManageMembers ? (
|
||||
<td className="p-4 text-right">
|
||||
{!isOwner ? (
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
{roleValue !== member.role ? (
|
||||
<>
|
||||
<Button type="button" variant="ghost" size="icon"
|
||||
aria-label={t('members.saveRole')} title={t('members.saveRole')}
|
||||
disabled={isSavingRole || isRemoving}
|
||||
onClick={() => handleRoleSave(member.userId, member.role)}>
|
||||
{isSavingRole ? <Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" /> : <Check className="h-4 w-4" aria-hidden="true" />}
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" size="icon"
|
||||
aria-label={t('members.cancelRole')} title={t('members.cancelRole')}
|
||||
disabled={isSavingRole || isRemoving}
|
||||
onClick={() => {
|
||||
setDraftRoles((current) => { const next = { ...current }; delete next[member.userId]; return next })
|
||||
roleSelectRefs.current[member.userId]?.focus()
|
||||
}}>
|
||||
<X className="h-4 w-4" aria-hidden="true" />
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
<Button type="button" variant="ghost" size="icon"
|
||||
className="text-destructive hover:bg-destructive/10 hover:text-destructive"
|
||||
aria-label={t('members.remove')} title={t('members.remove')}
|
||||
disabled={isRemoving || isSavingRole}
|
||||
onClick={() => setPendingRemoval({ userId: member.userId })}>
|
||||
<Trash2 className="h-4 w-4" aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</td>
|
||||
) : null}
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -67,7 +67,7 @@ vi.mock('@/features/namespace/namespace-header', () => ({
|
|||
NamespaceHeader: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('@/features/namespace/namespace-management-nav', () => ({ NamespaceManagementNav: ({ slug, active }: { slug: string; active: string }) => createElement('nav', { 'data-slug': slug, 'data-active': active }) }))
|
||||
vi.mock('@/features/namespace/namespace-management-nav', () => ({ NamespaceManagementBack: () => createElement('a', { href: '/dashboard/namespaces' }, 'back'), NamespaceManagementNav: ({ slug, active }: { slug: string; active: string }) => createElement('nav', { 'data-slug': slug, 'data-active': active }) }))
|
||||
|
||||
import { NamespaceReviewsPage } from './namespace-reviews'
|
||||
|
||||
|
|
@ -126,6 +126,14 @@ describe('NamespaceReviewsPage', () => {
|
|||
expect(typeof NamespaceReviewsPage).toBe('function')
|
||||
})
|
||||
|
||||
it('uses a status filter instead of nested review tabs', () => {
|
||||
const html = renderToStaticMarkup(createElement(NamespaceReviewsPage))
|
||||
expect(html).toContain('nsReviews.statusLabel')
|
||||
expect(html).toContain('nsReviews.tabPending')
|
||||
expect(html).toContain('nsReviews.sortLabel')
|
||||
expect(html).not.toContain('role="tab"')
|
||||
})
|
||||
|
||||
it('renders pagination for namespace review list when totalPages > 1', () => {
|
||||
const html = renderToStaticMarkup(createElement(NamespaceReviewsPage))
|
||||
|
||||
|
|
|
|||
|
|
@ -5,11 +5,9 @@ import { buildNamespaceReviewDetailPath } from '@/features/review/review-paths'
|
|||
import { formatLocalDateTime } from '@/shared/lib/date-time'
|
||||
import { Card } from '@/shared/ui/card'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/shared/ui/tabs'
|
||||
import { useNamespaceDetail } from '@/shared/hooks/use-namespace-queries'
|
||||
import { useReviewList } from '@/features/review/use-review-list'
|
||||
import { NamespaceManagementNav } from '@/features/namespace/namespace-management-nav'
|
||||
import { DashboardPageHeader } from '@/shared/components/dashboard-page-header'
|
||||
import { NamespaceManagementBack, NamespaceManagementNav } from '@/features/namespace/namespace-management-nav'
|
||||
import { Pagination } from '@/shared/components/pagination'
|
||||
import { NamespaceHeader } from '@/features/namespace/namespace-header'
|
||||
|
||||
|
|
@ -109,19 +107,25 @@ function ReviewListSection({ namespaceId, slug }: { namespaceId?: number; slug:
|
|||
}
|
||||
|
||||
return (
|
||||
<Tabs value={activeStatus} onValueChange={(value) => setActiveStatus(value as ReviewStatus)}>
|
||||
<section aria-label={t('nsReviews.title')}>
|
||||
<div className="mb-6 flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
|
||||
<TabsList>
|
||||
<TabsTrigger value="PENDING">{t('nsReviews.tabPending')}</TabsTrigger>
|
||||
<TabsTrigger value="APPROVED">{t('nsReviews.tabApproved')}</TabsTrigger>
|
||||
<TabsTrigger value="REJECTED">{t('nsReviews.tabRejected')}</TabsTrigger>
|
||||
</TabsList>
|
||||
<div className="w-full max-w-48">
|
||||
<p className="mb-2 text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">
|
||||
<label htmlFor="namespace-review-status" className="mb-2 block text-sm font-medium text-muted-foreground">{t('nsReviews.statusLabel')}</label>
|
||||
<Select value={activeStatus} onValueChange={(value) => setActiveStatus(value as ReviewStatus)}>
|
||||
<SelectTrigger id="namespace-review-status"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="PENDING">{t('nsReviews.tabPending')}</SelectItem>
|
||||
<SelectItem value="APPROVED">{t('nsReviews.tabApproved')}</SelectItem>
|
||||
<SelectItem value="REJECTED">{t('nsReviews.tabRejected')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="w-full max-w-48">
|
||||
<label htmlFor="namespace-review-sort" className="mb-2 block text-sm font-medium text-muted-foreground">
|
||||
{t('nsReviews.sortLabel')}
|
||||
</p>
|
||||
</label>
|
||||
<Select value={sortDirection} onValueChange={handleSortChange}>
|
||||
<SelectTrigger>
|
||||
<SelectTrigger id="namespace-review-sort">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
|
@ -131,10 +135,8 @@ function ReviewListSection({ namespaceId, slug }: { namespaceId?: number; slug:
|
|||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<TabsContent value="PENDING" className="mt-0">{renderItems(pending, 'PENDING')}</TabsContent>
|
||||
<TabsContent value="APPROVED" className="mt-0">{renderItems(approved, 'APPROVED')}</TabsContent>
|
||||
<TabsContent value="REJECTED" className="mt-0">{renderItems(rejected, 'REJECTED')}</TabsContent>
|
||||
</Tabs>
|
||||
{renderItems(activeStatus === 'PENDING' ? pending : activeStatus === 'APPROVED' ? approved : rejected, activeStatus)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -152,10 +154,7 @@ export function NamespaceReviewsPage() {
|
|||
|
||||
return (
|
||||
<div className="space-y-8 animate-fade-up">
|
||||
<DashboardPageHeader
|
||||
title={t('nsReviews.title')}
|
||||
subtitle={namespace ? t('nsReviews.reviewsFor', { name: namespace.displayName }) : t('nsReviews.loadingNamespace')}
|
||||
/>
|
||||
<NamespaceManagementBack />
|
||||
{namespace ? <NamespaceHeader namespace={namespace} /> : null}
|
||||
{readOnlyMessage ? (
|
||||
<Card className="border-border/50 bg-secondary/40 p-4 text-sm text-muted-foreground">
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { useState } from 'react'
|
|||
import { useNavigate, useParams } from '@tanstack/react-router'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { NamespaceHeader } from '@/features/namespace/namespace-header'
|
||||
import { NamespaceManagementNav } from '@/features/namespace/namespace-management-nav'
|
||||
import { NamespaceManagementBack, NamespaceManagementNav } from '@/features/namespace/namespace-management-nav'
|
||||
import { executeNamespaceAction, resolveNamespaceActionCopy, type PendingNamespaceAction } from '@/features/namespace/namespace-actions'
|
||||
import { ConfirmDialog } from '@/shared/components/confirm-dialog'
|
||||
import { EmptyState } from '@/shared/components/empty-state'
|
||||
|
|
@ -33,6 +33,7 @@ export function NamespaceSettingsPage() {
|
|||
const canEdit = namespace.type === 'TEAM' && namespace.status === 'ACTIVE' && (managed?.currentUserRole === 'OWNER' || managed?.currentUserRole === 'ADMIN')
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<NamespaceManagementBack />
|
||||
<NamespaceHeader namespace={namespace} canEdit={canEdit} />
|
||||
<NamespaceManagementNav slug={slug} active="settings" />
|
||||
<Card className="space-y-4 p-5">
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue