mirror of
https://github.com/iflytek/skillhub.git
synced 2026-10-14 04:08:16 +00:00
fix(avatar): unify update flow and crop in a compact dialog
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
This commit is contained in:
parent
4a2a1f78f6
commit
66571f709b
18 changed files with 402 additions and 166 deletions
|
|
@ -42,3 +42,12 @@
|
|||
## 裁剪组件选型调整(2026-10-13)
|
||||
|
||||
头像文件选择复用已有 `react-dropzone`,裁剪交互采用 `react-image-crop`(ISC,兼容项目 React 版本)。方形选区的拖动、缩放选区、触屏和键盘行为交由开源组件处理,移除自写居中裁剪与缩放交互。仅保留选区图像导出及私有上传/提交状态衔接,不改变审核、引用消费、保留/替换/移除规则。组件适配须验证非居中选区的实际导出结果、取消与迟到响应隔离,以及新增/更新/独立维护入口。
|
||||
|
||||
## 头像入口与弹窗简化(2026-10-13,用户人工反馈)
|
||||
|
||||
- 详情页仅保留标题旁头像,删除专门头像状态卡片、重复候选图、供应商开关说明及已批准审核理由。审核治理能力不因展示简化而移除。
|
||||
- 我的技能自绘列表补齐名称旁头像,删除独立“管理头像”按钮与入口;统一“更新”页同时支持仅修改头像或上传新技能包。无技能包时只提交既有头像维护接口,不新增版本;有包时保留原子发布/头像关联流程。新建及驳回重提仍必须提供技能包。
|
||||
- 使用已有 Tailwind CSS 视觉样式及 Radix Dialog 封装承载 React Image Crop,不引入新的整体 UI 框架。父表单为64像素头像和简短操作;裁剪弹窗画布有界、自适应,错误和重试在弹窗内。
|
||||
- 弹窗取消/关闭/ESC恢复打开前草稿,上传确认成功后关闭弹窗并展示小预览;成功上传仍需父页面最终提交。更新页仅展示所有者需要处理的待审/失败/驳回信息及原有重试、申诉动作,保持治理闭环。
|
||||
|
||||
组件接入方式:仓库已有 `web/components.json`,本地 `shared/ui/Button`、`Dialog` 沿用 shadcn 的源码分发/组件组合方式,底层为 Radix 与 Tailwind。当前为 Tailwind 3 和旧 `default` 风格;本轮复用现有封装与主题,不重新执行 init,也不覆盖已有 Portal/焦点/主题定制。头像选择由本地预览、Button、Dialog 和 React Image Crop 组合,shadcn Avatar 的展示职责与图片裁剪/上传业务分开。
|
||||
|
|
|
|||
|
|
@ -110,13 +110,19 @@ PRIVATE 且仅所有者可访问的头像完成文件安全处理后可直接使
|
|||
|
||||
前后端 SHALL 提供可理解的头像提交与审核闭环。
|
||||
|
||||
新增和更新 Skill 页面提供可选自定义头像、方形裁剪、上传预览及上传状态,不提供预设图标选择;未上传时使用首字母兜底。更新页面展示当前头像并提供保留、替换、移除三种动作,默认保留。“我的技能”提供单独修改头像的入口,复用同一套上传与裁剪组件,不要求发布新的 SkillVersion。详情页展示头像、审核状态和驳回原因,不再承载完整上传表单。技能内容与头像分别显示提交和审核状态,技能内容审核通过不代表头像审核通过。审核员可以读取其管辖范围候选并处理,机器单开时可以处理例外申诉。所有展示 Skill 身份的位置使用同一头像与兜底组件。
|
||||
新增和更新 Skill 页面提供可选自定义头像、方形裁剪、上传预览及上传状态,不提供预设图标选择;未上传时使用首字母兜底。更新页面展示当前头像并提供保留、替换、移除三种动作,默认保留。“我的技能”仅保留统一的“更新”入口,列表在技能名称旁显示头像。更新页复用同一套自定义头像组件,可只提交头像修改而不要求上传技能包或发布新的 SkillVersion;上传技能包时仍按原流程发布版本。选择图片后在项目已有弹窗内裁剪,父表单仅保留小头像预览与必要操作,不展开大图。关闭、取消或按 ESC 退出裁剪时恢复打开前的头像草稿。详情页仅在标题旁显示当前头像,不再显示单独的头像管理面板、重复图片、机器开关说明或已批准理由;所有者需要处理的头像待审状态和驳回原因集中在更新页。技能内容与头像分别显示提交和审核状态,技能内容审核通过不代表头像审核通过。审核员可以读取其管辖范围候选并处理,机器单开时可以处理例外申诉。所有展示 Skill 身份的位置使用同一头像与兜底组件。
|
||||
|
||||
#### Scenario: 申请等待审核
|
||||
- **WHEN** 所有者上传候选后需人工决定
|
||||
- **THEN** 页面展示待审状态且已发布头像维持不变
|
||||
|
||||
|
||||
|
||||
#### Scenario: 裁剪取消
|
||||
- **WHEN** 所有者已选择头像草稿,再选择另一张图并取消裁剪弹窗
|
||||
- **THEN** 恢复弹窗打开前的草稿,包括原临时上传引用或移除选择
|
||||
- **AND** 迟到的上传结果不得替换恢复后的草稿
|
||||
|
||||
### Requirement: REQ-AV-10 头像 SHALL 先私有上传再随提交关联
|
||||
|
||||
新增、更新或单独修改头像时,客户端 SHALL 先裁剪并上传图片到私有存储,上传成功后取得可用于提交的临时引用。选择了新图片但仍在上传或上传失败时,客户端不得提交该次操作;服务端亦须校验引用对应的文件上传成功、未过期、属于当前提交者且不可越权关联。未选择头像的新技能可正常提交。
|
||||
|
|
@ -144,7 +150,7 @@ PRIVATE 且仅所有者可访问的头像完成文件安全处理后可直接使
|
|||
- **THEN** 服务端拒绝提交,不更改技能或头像状态
|
||||
|
||||
#### Scenario: 单独修改头像
|
||||
- **WHEN** 所有者从“我的技能”上传并提交新头像
|
||||
- **WHEN** 所有者从“我的技能”的“更新”入口上传并提交新头像
|
||||
- **THEN** 复用相同上传、关联及审核流程,不创建新的 SkillVersion
|
||||
|
||||
### Requirement: REQ-AV-11 更新页移除 SHALL 在提交成功后生效
|
||||
|
|
|
|||
|
|
@ -49,3 +49,9 @@
|
|||
- [x] 6.3 构建当前提交的唯一预览,实际验证新增/更新/独立维护并更新 PR #947 验收记录。
|
||||
|
||||
开源组件替换的验收:真实组件专项14项、全量前端965项及类型/Lint/构建通过;独立Review无阻塞。Chromium实际拖动红蓝测试图到左半,上传512 PNG三个采样点均为红色;中俄键盘操作断言选区位置改变;头像生命周期和无头像发布/下载两个轨道通过。机器供应商真实调用未执行,唯一预览继续保留供人工验收。
|
||||
|
||||
## 7. 人工验收后的入口与展示优化(2026-10-13,实施中)
|
||||
|
||||
- [x] 7.1 简化详情展示,补齐我的技能列表头像,合并为唯一更新入口,保留仅改头像不新增版本能力。
|
||||
- [x] 7.2 复用 Radix 弹窗承载开源裁剪组件,父表单紧凑展示,取消恢复草稿并隔离迟到结果。
|
||||
- [ ] 7.3 验证更新页头像单独提交/带技能包提交、审核恢复、列表/详情、桌面/移动弹窗和键盘焦点,独立Review后更新同一预览供人工验收。
|
||||
|
|
|
|||
|
|
@ -2,13 +2,9 @@
|
|||
import { QueryClient, QueryClientProvider, useQuery } from '@tanstack/react-query'
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
const api = vi.hoisted(() => ({ status: vi.fn(), submit: vi.fn(), queue: vi.fn(), decide: vi.fn() }))
|
||||
const api = vi.hoisted(() => ({ status: vi.fn(), appeal: vi.fn(), queue: vi.fn(), decide: vi.fn() }))
|
||||
vi.mock('@/api/client', () => ({ skillAvatarApi: api, buildApiUrl: (url: string) => url }))
|
||||
vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key }) }))
|
||||
vi.mock('./skill-avatar-editor', () => ({
|
||||
KEEP_AVATAR: { action: 'KEEP', ready: true },
|
||||
SkillAvatarEditor: ({ onChange }: { onChange: (value: { action: 'REPLACE'; ready: boolean; uploadId: string }) => void }) => <button onClick={() => onChange({ action: 'REPLACE', ready: true, uploadId: 'staged-image' })}>Use staged image</button>,
|
||||
}))
|
||||
import { SkillAvatarManager } from './skill-avatar-manager'
|
||||
import { SkillAvatarReviewQueue } from './skill-avatar-review'
|
||||
|
||||
|
|
@ -20,19 +16,18 @@ const active = { ...pending, avatarUrl: '/active', candidateStatus: 'ACTIVE', re
|
|||
describe('avatar cache across owner and reviewer flows', () => {
|
||||
afterEach(() => { cleanup(); Object.values(api).forEach(fn => fn.mockReset()) })
|
||||
|
||||
it('refreshes a previously empty fresh review queue when the owner submits a staged image', async () => {
|
||||
it('refreshes a previously empty fresh review queue when the owner appeals a blocked candidate', async () => {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity } } })
|
||||
client.setQueryData(ownerKey, { ...active, candidateId: 20 })
|
||||
client.setQueryData(ownerKey, { ...pending, candidateId: 20, machineStatus: 'BLOCKED', reviewStatus: 'NOT_REQUIRED' })
|
||||
client.setQueryData(queueKey, [])
|
||||
api.status.mockResolvedValue(pending)
|
||||
api.submit.mockResolvedValue(pending)
|
||||
api.appeal.mockResolvedValue(pending)
|
||||
api.queue.mockResolvedValue([pending])
|
||||
render(<QueryClientProvider client={client}><SkillAvatarManager namespace="team" slug="foo" /><SkillAvatarReviewQueue namespace="team" /></QueryClientProvider>)
|
||||
expect(screen.getByText('skillAvatar.emptyQueue')).toBeTruthy()
|
||||
expect(api.queue).not.toHaveBeenCalled()
|
||||
|
||||
fireEvent.click(screen.getByText('Use staged image'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'skillAvatar.saveChanges' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'skillAvatar.appeal' }))
|
||||
|
||||
expect(await screen.findByText('@team/foo')).toBeTruthy()
|
||||
expect(client.getQueryData(ownerKey)).toEqual(pending)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
/** @vitest-environment jsdom */
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
const api = vi.hoisted(() => ({ stage: vi.fn() }))
|
||||
vi.mock('@/api/client', () => ({ skillAvatarApi: api, buildApiUrl: (url: string) => url }))
|
||||
|
|
@ -8,7 +8,8 @@ import { SkillAvatarEditor } from './skill-avatar-editor'
|
|||
|
||||
async function selectImage() {
|
||||
fireEvent.change(screen.getByLabelText('skillAvatar.choose'), { target: { files: [new File(['image'], 'avatar.png', { type: 'image/png' })] } })
|
||||
const image = await screen.findByAltText('skillAvatar.cropPreview')
|
||||
const dialog = await screen.findByRole('dialog')
|
||||
const image = within(dialog).getByAltText('skillAvatar.cropPreview')
|
||||
fireEvent.load(image)
|
||||
}
|
||||
function deferred<T>() {
|
||||
|
|
@ -41,7 +42,7 @@ describe('SkillAvatarEditor staged uploads', () => {
|
|||
const initialLeft = area.style.left
|
||||
fireEvent.keyDown(area, { key: 'ArrowRight', shiftKey: true })
|
||||
expect(area.style.left).not.toBe(initialLeft)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'skillAvatar.cropUpload' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'skillAvatar.cropConfirm' }))
|
||||
await waitFor(() => expect(api.stage).toHaveBeenCalledOnce())
|
||||
const context = vi.mocked(HTMLCanvasElement.prototype.getContext).mock.results[0].value as unknown as { translate: ReturnType<typeof vi.fn>; drawImage: ReturnType<typeof vi.fn> }
|
||||
// Initial square is 576px at x=224. Library Shift+Right moves 10 display px = 40 source px.
|
||||
|
|
@ -58,7 +59,7 @@ describe('SkillAvatarEditor staged uploads', () => {
|
|||
fireEvent.pointerDown(area, { clientX: 128, clientY: 96 })
|
||||
fireEvent.pointerMove(document, { clientX: 148, clientY: 106 })
|
||||
fireEvent.pointerUp(document)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'skillAvatar.cropUpload' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'skillAvatar.cropConfirm' }))
|
||||
await waitFor(() => expect(api.stage).toHaveBeenCalledOnce())
|
||||
const context = vi.mocked(HTMLCanvasElement.prototype.getContext).mock.results[0].value as unknown as { translate: ReturnType<typeof vi.fn> }
|
||||
expect(context.translate.mock.calls[0][0]).toBeCloseTo(-304)
|
||||
|
|
@ -71,9 +72,9 @@ describe('SkillAvatarEditor staged uploads', () => {
|
|||
vi.mocked(HTMLCanvasElement.prototype.toBlob).mockImplementation(callback => { finish = callback })
|
||||
render(<SkillAvatarEditor name="Foo" onChange={change} />)
|
||||
await selectImage()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'skillAvatar.cropUpload' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'skillAvatar.cropConfirm' }))
|
||||
await waitFor(() => expect(finish).toBeTypeOf('function'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'skillAvatar.discardChange' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.cancel' }))
|
||||
finish(new Blob(['cropped'], { type: 'image/png' }))
|
||||
await waitFor(() => expect(change).toHaveBeenLastCalledWith({ action: 'KEEP', ready: true }))
|
||||
expect(api.stage).not.toHaveBeenCalled()
|
||||
|
|
@ -83,10 +84,13 @@ describe('SkillAvatarEditor staged uploads', () => {
|
|||
api.stage.mockReturnValue(new Promise(() => {}))
|
||||
render(<SkillAvatarEditor name="Foo" onChange={vi.fn()} />)
|
||||
const input = screen.getByLabelText('skillAvatar.choose') as HTMLInputElement
|
||||
expect(input.tabIndex).toBe(0)
|
||||
expect(input.tabIndex).toBe(-1)
|
||||
const choose = screen.getByRole('button', { name: 'skillAvatar.choose' }) as HTMLButtonElement
|
||||
expect(choose.disabled).toBe(false)
|
||||
expect(input.className).toContain('sr-only')
|
||||
expect(input.disabled).toBe(false)
|
||||
await selectImage()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'skillAvatar.cropUpload' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'skillAvatar.cropConfirm' }))
|
||||
expect(input.disabled).toBe(true)
|
||||
})
|
||||
|
||||
|
|
@ -103,7 +107,7 @@ describe('SkillAvatarEditor staged uploads', () => {
|
|||
render(<SkillAvatarEditor name="Foo" onChange={vi.fn()} />)
|
||||
fireEvent.change(screen.getByLabelText('skillAvatar.choose'), { target: { files: [new File(['image'], 'avatar.png', { type: 'image/png' })] } })
|
||||
const image = await screen.findByAltText('skillAvatar.cropPreview')
|
||||
expect((screen.getByRole('button', { name: 'skillAvatar.cropUpload' }) as HTMLButtonElement).disabled).toBe(true)
|
||||
expect((screen.getByRole('button', { name: 'skillAvatar.cropConfirm' }) as HTMLButtonElement).disabled).toBe(true)
|
||||
fireEvent.error(image)
|
||||
expect(screen.getByRole('alert').textContent).toBe('skillAvatar.invalid')
|
||||
expect(api.stage).not.toHaveBeenCalled()
|
||||
|
|
@ -116,7 +120,7 @@ describe('SkillAvatarEditor staged uploads', () => {
|
|||
render(<SkillAvatarEditor name="Foo" currentUrl="/active" onChange={change} />)
|
||||
await selectImage()
|
||||
expect(change).toHaveBeenLastCalledWith({ action: 'REPLACE', ready: false })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'skillAvatar.cropUpload' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'skillAvatar.cropConfirm' }))
|
||||
await waitFor(() => expect(api.stage).toHaveBeenCalledOnce())
|
||||
expect(api.stage.mock.calls[0][0]).toBeInstanceOf(Blob)
|
||||
expect(screen.getByRole('status').textContent).toBe('skillAvatar.uploadStates.UPLOADING')
|
||||
|
|
@ -134,7 +138,8 @@ describe('SkillAvatarEditor staged uploads', () => {
|
|||
expect(api.stage).not.toHaveBeenCalled()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'skillAvatar.discardChange' }))
|
||||
expect(change).toHaveBeenLastCalledWith({ action: 'KEEP', ready: true })
|
||||
expect(screen.getByRole('status').textContent).toBe('skillAvatar.uploadStates.KEEP')
|
||||
expect(screen.queryByRole('status')).toBeNull()
|
||||
expect(document.querySelector('img')?.getAttribute('src')).toBe('/active')
|
||||
})
|
||||
|
||||
it('keeps submission blocked after upload failure and allows a successful retry', async () => {
|
||||
|
|
@ -142,26 +147,28 @@ describe('SkillAvatarEditor staged uploads', () => {
|
|||
api.stage.mockRejectedValueOnce(new Error('storage unavailable')).mockResolvedValueOnce(uploaded)
|
||||
render(<SkillAvatarEditor name="Foo" onChange={change} />)
|
||||
await selectImage()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'skillAvatar.cropUpload' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'skillAvatar.cropConfirm' }))
|
||||
expect((await screen.findByRole('alert')).textContent).toBe('storage unavailable')
|
||||
expect(change).toHaveBeenLastCalledWith({ action: 'REPLACE', ready: false })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'skillAvatar.uploadRetry' }))
|
||||
await waitFor(() => expect(change).toHaveBeenLastCalledWith({ action: 'REPLACE', uploadId: uploaded.uploadId, ready: true }))
|
||||
})
|
||||
|
||||
it.each(['discardChange', 'remove'] as const)('ignores an old upload completion after %s', async action => {
|
||||
it.each(['cancel', 'escape'] as const)('ignores an old upload completion after %s', async action => {
|
||||
const change = vi.fn()
|
||||
const task = deferred<typeof uploaded>()
|
||||
api.stage.mockReturnValue(task.promise)
|
||||
render(<SkillAvatarEditor name="Foo" currentUrl="/active" canRemove onChange={change} />)
|
||||
await selectImage()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'skillAvatar.cropUpload' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'skillAvatar.cropConfirm' }))
|
||||
await waitFor(() => expect(api.stage).toHaveBeenCalledOnce())
|
||||
fireEvent.click(screen.getByRole('button', { name: `skillAvatar.${action}` }))
|
||||
if (action === 'cancel') fireEvent.click(screen.getByRole('button', { name: 'common.cancel' }))
|
||||
else fireEvent.keyDown(screen.getByRole('dialog'), { key: 'Escape' })
|
||||
task.resolve(uploaded)
|
||||
await waitFor(() => expect(change).toHaveBeenLastCalledWith({ action: action === 'remove' ? 'REMOVE' : 'KEEP', ready: true }))
|
||||
await waitFor(() => expect(change).toHaveBeenLastCalledWith({ action: 'KEEP', ready: true }))
|
||||
expect(change.mock.calls.some(([value]) => value.uploadId)).toBe(false)
|
||||
expect(screen.queryByAltText('skillAvatar.cropPreview')).toBeNull()
|
||||
expect(screen.queryByRole('dialog')).toBeNull()
|
||||
})
|
||||
|
||||
it('invalidates a staged reference when a different image is selected', async () => {
|
||||
|
|
@ -169,11 +176,73 @@ describe('SkillAvatarEditor staged uploads', () => {
|
|||
api.stage.mockResolvedValue(uploaded)
|
||||
render(<SkillAvatarEditor name="Foo" onChange={change} />)
|
||||
await selectImage()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'skillAvatar.cropUpload' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'skillAvatar.cropConfirm' }))
|
||||
await waitFor(() => expect(change).toHaveBeenLastCalledWith({ action: 'REPLACE', uploadId: uploaded.uploadId, ready: true }))
|
||||
await selectImage()
|
||||
expect(change).toHaveBeenLastCalledWith({ action: 'REPLACE', ready: false })
|
||||
expect(screen.getByRole('status').textContent).toBe('skillAvatar.uploadStates.SELECTED')
|
||||
expect(screen.getByRole('dialog')).toBeTruthy()
|
||||
expect(screen.queryByRole('status')).toBeNull()
|
||||
})
|
||||
|
||||
|
||||
it('uses the shared portal dialog and restores chooser focus when Escape cancels', async () => {
|
||||
const { container } = render(<SkillAvatarEditor name="Foo" onChange={vi.fn()} />)
|
||||
const chooser = screen.getByRole('button', { name: 'skillAvatar.choose' })
|
||||
chooser.focus()
|
||||
await selectImage()
|
||||
const dialog = screen.getByRole('dialog', { name: 'skillAvatar.cropTitle' })
|
||||
expect(container.contains(dialog)).toBe(false)
|
||||
expect(within(dialog).getByText('skillAvatar.cropHelp')).toBeTruthy()
|
||||
expect(container.querySelector('.ReactCrop')).toBeNull()
|
||||
fireEvent.keyDown(dialog, { key: 'Escape' })
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull())
|
||||
await waitFor(() => expect(document.activeElement).toBe(chooser))
|
||||
expect(api.stage).not.toHaveBeenCalled()
|
||||
expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:chosen')
|
||||
})
|
||||
|
||||
it('restores an already uploaded replacement when a second crop is canceled', async () => {
|
||||
const change = vi.fn()
|
||||
api.stage.mockResolvedValue(uploaded)
|
||||
render(<SkillAvatarEditor name="Foo" onChange={change} />)
|
||||
await selectImage()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'skillAvatar.cropConfirm' }))
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull())
|
||||
await selectImage()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.cancel' }))
|
||||
expect(change).toHaveBeenLastCalledWith({ action: 'REPLACE', uploadId: uploaded.uploadId, ready: true })
|
||||
expect(screen.getByAltText('skillAvatar.cropPreview').getAttribute('src')).toBe(uploaded.previewUrl)
|
||||
expect(api.stage).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('does not overwrite a prior staged draft with a late second upload', async () => {
|
||||
const change = vi.fn()
|
||||
const late = deferred<typeof uploaded>()
|
||||
api.stage.mockResolvedValueOnce(uploaded).mockReturnValueOnce(late.promise)
|
||||
render(<SkillAvatarEditor name="Foo" onChange={change} />)
|
||||
await selectImage()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'skillAvatar.cropConfirm' }))
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull())
|
||||
await selectImage()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'skillAvatar.cropConfirm' }))
|
||||
await waitFor(() => expect(api.stage).toHaveBeenCalledTimes(2))
|
||||
fireEvent.keyDown(screen.getByRole('dialog'), { key: 'Escape' })
|
||||
late.resolve({ ...uploaded, uploadId: 'stale-second-upload', previewUrl: '/private/second' })
|
||||
await waitFor(() => expect(change).toHaveBeenLastCalledWith({ action: 'REPLACE', uploadId: uploaded.uploadId, ready: true }))
|
||||
expect(change.mock.calls.some(([value]) => value.uploadId === 'stale-second-upload')).toBe(false)
|
||||
expect(screen.getByAltText('skillAvatar.cropPreview').getAttribute('src')).toBe(uploaded.previewUrl)
|
||||
})
|
||||
|
||||
it('restores a removal draft when the crop dialog close button is used', async () => {
|
||||
const change = vi.fn()
|
||||
render(<SkillAvatarEditor name="Foo" currentUrl="/active" canRemove onChange={change} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'skillAvatar.remove' }))
|
||||
await selectImage()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Close' }))
|
||||
expect(change).toHaveBeenLastCalledWith({ action: 'REMOVE', ready: true })
|
||||
expect(screen.queryByRole('dialog')).toBeNull()
|
||||
expect(screen.getByText('F')).toBeTruthy()
|
||||
expect(api.stage).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('previews removal as a draft and cancel restores the original image', () => {
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import 'react-image-crop/dist/ReactCrop.css'
|
|||
import { useTranslation } from 'react-i18next'
|
||||
import { buildApiUrl, skillAvatarApi } from '@/api/client'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { Input } from '@/shared/ui/input'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/shared/ui/dialog'
|
||||
import { SkillAvatar } from './skill-avatar'
|
||||
|
||||
export type AvatarEdit = { action: 'KEEP' | 'REPLACE' | 'REMOVE'; uploadId?: string; ready: boolean }
|
||||
|
|
@ -32,6 +32,10 @@ export function SkillAvatarEditor({ name, currentUrl, canRemove = false, disable
|
|||
const [previewUrl, setPreviewUrl] = useState<string | null>(null)
|
||||
const [state, setState] = useState<'KEEP' | 'SELECTED' | 'UPLOADING' | 'READY' | 'ERROR' | 'REMOVE'>('KEEP')
|
||||
const [error, setError] = useState('')
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const edit = useRef<AvatarEdit>(KEEP_AVATAR)
|
||||
const beforeSelection = useRef({ edit: KEEP_AVATAR, previewUrl: null as string | null, state: 'KEEP' as typeof state })
|
||||
const chooseButton = useRef<HTMLButtonElement>(null)
|
||||
const generation = useRef(0)
|
||||
const image = useRef<HTMLImageElement>(null)
|
||||
useEffect(() => () => { generation.current += 1 }, [])
|
||||
|
|
@ -45,7 +49,22 @@ export function SkillAvatarEditor({ name, currentUrl, canRemove = false, disable
|
|||
setState(remove ? 'REMOVE' : 'KEEP')
|
||||
setCrop(undefined)
|
||||
if (inputRef.current) inputRef.current.value = ''
|
||||
onChange(remove ? { action: 'REMOVE', ready: true } : KEEP_AVATAR)
|
||||
setDialogOpen(false)
|
||||
edit.current = remove ? { action: 'REMOVE', ready: true } : KEEP_AVATAR
|
||||
onChange(edit.current)
|
||||
}
|
||||
|
||||
function cancelSelection() {
|
||||
generation.current += 1
|
||||
setDialogOpen(false)
|
||||
setSourceUrl(null)
|
||||
setCrop(undefined)
|
||||
setError('')
|
||||
setPreviewUrl(beforeSelection.current.previewUrl)
|
||||
setState(beforeSelection.current.state)
|
||||
edit.current = beforeSelection.current.edit
|
||||
onChange(edit.current)
|
||||
if (inputRef.current) inputRef.current.value = ''
|
||||
}
|
||||
|
||||
async function upload() {
|
||||
|
|
@ -61,7 +80,12 @@ export function SkillAvatarEditor({ name, currentUrl, canRemove = false, disable
|
|||
if (attempt !== generation.current) return
|
||||
setPreviewUrl(staged.previewUrl)
|
||||
setState('READY')
|
||||
onChange({ action: 'REPLACE', uploadId: staged.uploadId, ready: true })
|
||||
edit.current = { action: 'REPLACE', uploadId: staged.uploadId, ready: true }
|
||||
onChange(edit.current)
|
||||
setDialogOpen(false)
|
||||
setSourceUrl(null)
|
||||
setCrop(undefined)
|
||||
if (inputRef.current) inputRef.current.value = ''
|
||||
} catch (cause) {
|
||||
if (attempt !== generation.current) return
|
||||
setState('ERROR')
|
||||
|
|
@ -69,7 +93,7 @@ export function SkillAvatarEditor({ name, currentUrl, canRemove = false, disable
|
|||
}
|
||||
}
|
||||
|
||||
const { getRootProps, getInputProps, inputRef } = useDropzone({
|
||||
const { getRootProps, getInputProps, inputRef, open } = useDropzone({
|
||||
accept: { 'image/png': ['.png'], 'image/jpeg': ['.jpg', '.jpeg'] },
|
||||
maxSize: 2 * 1024 * 1024,
|
||||
maxFiles: 1,
|
||||
|
|
@ -78,6 +102,9 @@ export function SkillAvatarEditor({ name, currentUrl, canRemove = false, disable
|
|||
noClick: true,
|
||||
noKeyboard: true,
|
||||
onDrop: (accepted, rejected) => {
|
||||
if (state === 'KEEP' || state === 'READY' || state === 'REMOVE') {
|
||||
beforeSelection.current = { edit: edit.current, previewUrl, state }
|
||||
}
|
||||
generation.current += 1
|
||||
setPreviewUrl(null)
|
||||
setSourceUrl(null)
|
||||
|
|
@ -88,45 +115,60 @@ export function SkillAvatarEditor({ name, currentUrl, canRemove = false, disable
|
|||
setState('ERROR'); setError(t('skillAvatar.invalid')); return
|
||||
}
|
||||
setSourceUrl(URL.createObjectURL(file))
|
||||
setError(''); setState('SELECTED')
|
||||
setError(''); setState('SELECTED'); setDialogOpen(true)
|
||||
},
|
||||
})
|
||||
|
||||
return <section className="space-y-3" aria-label={t('skillAvatar.title')}>
|
||||
<h2 className="font-semibold">{t('skillAvatar.optionalTitle')}</h2>
|
||||
<p className="text-sm text-muted-foreground">{t('skillAvatar.stagedHelp')}</p>
|
||||
<div className="flex items-center gap-3">
|
||||
{state === 'READY' && previewUrl ? <img src={buildApiUrl(previewUrl)} alt={t('skillAvatar.cropPreview')} className="h-20 w-20 rounded-lg object-cover" />
|
||||
: <SkillAvatar name={name} url={state === 'REMOVE' ? null : currentUrl} size="h-20 w-20 text-2xl" />}
|
||||
<p role="status" className="text-sm text-muted-foreground">{t(`skillAvatar.uploadStates.${state}`)}</p>
|
||||
return <section className="space-y-2" aria-label={t('skillAvatar.title')}>
|
||||
<h2 className="text-sm font-medium">{t('skillAvatar.optionalTitle')}</h2>
|
||||
<div {...getRootProps()} className="flex items-center gap-3">
|
||||
{state === 'READY' && previewUrl ? <img src={buildApiUrl(previewUrl)} alt={t('skillAvatar.cropPreview')} className="h-16 w-16 shrink-0 rounded-lg object-cover" />
|
||||
: <SkillAvatar name={name} url={state === 'REMOVE' ? null : currentUrl} size="h-16 w-16 shrink-0 text-xl" />}
|
||||
<input {...getInputProps({ 'aria-label': t('skillAvatar.choose'), className: 'sr-only', style: undefined, disabled: disabled || state === 'UPLOADING' })} />
|
||||
<div className="min-w-0 space-y-1">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<Button ref={chooseButton} type="button" size="sm" variant="outline" disabled={disabled || state === 'UPLOADING'} onClick={open}>{t(currentUrl || state === 'READY' ? 'skillAvatar.change' : 'skillAvatar.choose')}</Button>
|
||||
{(canRemove || state === 'READY') && state !== 'REMOVE' && <Button type="button" size="sm" variant="ghost" disabled={disabled || dialogOpen} onClick={() => reset(canRemove)}>{t('skillAvatar.remove')}</Button>}
|
||||
{state !== 'KEEP' && !dialogOpen && <Button type="button" size="sm" variant="ghost" disabled={disabled} onClick={() => reset()}>{t('skillAvatar.discardChange')}</Button>}
|
||||
</div>
|
||||
{state !== 'KEEP' && !dialogOpen && <p role="status" className="text-xs text-muted-foreground">{t(state === 'READY' ? 'skillAvatar.stagedReady' : state === 'REMOVE' ? 'skillAvatar.removedDraft' : `skillAvatar.uploadStates.${state}`)}</p>}
|
||||
</div>
|
||||
</div>
|
||||
<div {...getRootProps()}>
|
||||
<Input {...getInputProps({ 'aria-label': t('skillAvatar.choose'), style: undefined, tabIndex: 0, disabled: disabled || state === 'UPLOADING' })} />
|
||||
</div>
|
||||
{sourceUrl && state !== 'READY' && <div className="space-y-2">
|
||||
<ReactCrop crop={crop} aspect={1} keepSelection minWidth={16} minHeight={16}
|
||||
disabled={disabled || state === 'UPLOADING'}
|
||||
onChange={(_, percentCrop) => setCrop(percentCrop)}
|
||||
ariaLabels={{
|
||||
cropArea: t('skillAvatar.cropArea'),
|
||||
nwDragHandle: t('skillAvatar.cropHandles.nw'), nDragHandle: t('skillAvatar.cropHandles.n'),
|
||||
neDragHandle: t('skillAvatar.cropHandles.ne'), eDragHandle: t('skillAvatar.cropHandles.e'),
|
||||
seDragHandle: t('skillAvatar.cropHandles.se'), sDragHandle: t('skillAvatar.cropHandles.s'),
|
||||
swDragHandle: t('skillAvatar.cropHandles.sw'), wDragHandle: t('skillAvatar.cropHandles.w'),
|
||||
}}>
|
||||
<img ref={image} src={sourceUrl} alt={t('skillAvatar.cropPreview')} className="max-h-80 max-w-full"
|
||||
onLoad={event => {
|
||||
const { naturalWidth, naturalHeight } = event.currentTarget
|
||||
setCrop(centerCrop(makeAspectCrop({ unit: '%', width: Math.min(75, 75 * naturalHeight / naturalWidth) }, 1, naturalWidth, naturalHeight), naturalWidth, naturalHeight))
|
||||
}} onError={() => { setState('ERROR'); setError(t('skillAvatar.invalid')); setCrop(undefined) }} />
|
||||
</ReactCrop>
|
||||
<p className="text-sm text-muted-foreground">{t('skillAvatar.cropHelp')}</p>
|
||||
<Button type="button" disabled={disabled || state === 'UPLOADING' || !crop?.width || !crop.height} onClick={() => void upload()}>{t(state === 'ERROR' ? 'skillAvatar.uploadRetry' : 'skillAvatar.cropUpload')}</Button>
|
||||
</div>}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{state !== 'KEEP' && <Button type="button" variant="outline" disabled={disabled} onClick={() => reset()}>{t('skillAvatar.discardChange')}</Button>}
|
||||
{canRemove && state !== 'REMOVE' && <Button type="button" variant="outline" disabled={disabled} onClick={() => reset(true)}>{t('skillAvatar.remove')}</Button>}
|
||||
</div>
|
||||
{error && <p role="alert" className="text-sm text-destructive">{error}</p>}
|
||||
{!dialogOpen && error && <p role="alert" className="text-sm text-destructive">{error}</p>}
|
||||
<Dialog open={dialogOpen} onOpenChange={value => { if (!value) cancelSelection() }}>
|
||||
<DialogContent className="w-[min(calc(100vw-2rem),26rem)] gap-4 p-4 sm:p-6"
|
||||
onCloseAutoFocus={event => { event.preventDefault(); chooseButton.current?.focus() }}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('skillAvatar.cropTitle')}</DialogTitle>
|
||||
<DialogDescription>{t('skillAvatar.cropHelp')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
{sourceUrl && <div className="flex h-80 max-h-[min(320px,45vh)] min-w-0 items-center justify-center overflow-hidden rounded-lg bg-muted/40">
|
||||
<ReactCrop crop={crop} aspect={1} keepSelection minWidth={16} minHeight={16}
|
||||
className="max-h-full max-w-full"
|
||||
disabled={disabled || state === 'UPLOADING'}
|
||||
onChange={(_, percentCrop) => setCrop(percentCrop)}
|
||||
ariaLabels={{
|
||||
cropArea: t('skillAvatar.cropArea'),
|
||||
nwDragHandle: t('skillAvatar.cropHandles.nw'), nDragHandle: t('skillAvatar.cropHandles.n'),
|
||||
neDragHandle: t('skillAvatar.cropHandles.ne'), eDragHandle: t('skillAvatar.cropHandles.e'),
|
||||
seDragHandle: t('skillAvatar.cropHandles.se'), sDragHandle: t('skillAvatar.cropHandles.s'),
|
||||
swDragHandle: t('skillAvatar.cropHandles.sw'), wDragHandle: t('skillAvatar.cropHandles.w'),
|
||||
}}>
|
||||
<img ref={image} src={sourceUrl} alt={t('skillAvatar.cropPreview')}
|
||||
className="block max-h-[min(320px,45vh)] max-w-full object-contain"
|
||||
onLoad={event => {
|
||||
const { naturalWidth, naturalHeight } = event.currentTarget
|
||||
setCrop(centerCrop(makeAspectCrop({ unit: '%', width: Math.min(75, 75 * naturalHeight / naturalWidth) }, 1, naturalWidth, naturalHeight), naturalWidth, naturalHeight))
|
||||
}} onError={() => { setState('ERROR'); setError(t('skillAvatar.invalid')); setCrop(undefined) }} />
|
||||
</ReactCrop>
|
||||
</div>}
|
||||
{state === 'UPLOADING' && <p role="status" className="text-sm text-muted-foreground">{t('skillAvatar.uploadStates.UPLOADING')}</p>}
|
||||
{error && <p role="alert" className="text-sm text-destructive">{error}</p>}
|
||||
<DialogFooter className="flex-row justify-end">
|
||||
<Button type="button" variant="outline" onClick={cancelSelection}>{t('common.cancel')}</Button>
|
||||
<Button type="button" disabled={disabled || state === 'UPLOADING' || !crop?.width || !crop.height} onClick={() => void upload()}>{t(state === 'ERROR' ? 'skillAvatar.uploadRetry' : 'skillAvatar.cropConfirm')}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</section>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,13 +3,13 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
|||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const api = vi.hoisted(() => ({ status: vi.fn(), delete: vi.fn(), submit: vi.fn() }))
|
||||
const api = vi.hoisted(() => ({ status: vi.fn(), delete: vi.fn(), submit: vi.fn(), retry: vi.fn(), appeal: vi.fn() }))
|
||||
vi.mock('@/api/client', () => ({ skillAvatarApi: api, buildApiUrl: (url: string) => url }))
|
||||
vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key }) }))
|
||||
import { SkillAvatarManager } from './skill-avatar-manager'
|
||||
|
||||
describe('SkillAvatarManager appeal', () => {
|
||||
afterEach(() => { cleanup(); api.status.mockReset(); api.delete.mockReset(); api.submit.mockReset() })
|
||||
afterEach(() => { cleanup(); Object.values(api).forEach(fn => fn.mockReset()) })
|
||||
|
||||
it.each([
|
||||
['PENDING', 'BLOCKED', 'NOT_REQUIRED', true],
|
||||
|
|
@ -36,26 +36,24 @@ describe('SkillAvatarManager appeal', () => {
|
|||
expect(screen.getByText('skillAvatar.automaticRetryStopped')).toBeTruthy()
|
||||
expect(screen.getByRole('button', { name: 'skillAvatar.retry' }).hasAttribute('disabled')).toBe(true)
|
||||
})
|
||||
it('renders detail status without upload, removal or retry controls', async () => {
|
||||
api.status.mockResolvedValue({ avatarUrl: '/active', candidateId: 7, candidateStatus: 'REJECTED', machineStatus: 'BLOCKED', reviewStatus: 'REJECTED', reviewReason: 'not suitable' })
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
render(<QueryClientProvider client={client}><SkillAvatarManager namespace="team" slug="foo" readOnly /></QueryClientProvider>)
|
||||
expect(await screen.findByText('not suitable')).toBeTruthy()
|
||||
expect(screen.queryByLabelText('skillAvatar.choose')).toBeNull()
|
||||
expect(screen.queryByRole('button')).toBeNull()
|
||||
})
|
||||
|
||||
it('does not remove an avatar until save and preserves the draft when the server rejects it', async () => {
|
||||
api.status.mockResolvedValue({ avatarUrl: '/active', candidateId: 7, candidateStatus: 'ACTIVE', machineStatus: 'SKIPPED', reviewStatus: 'APPROVED' })
|
||||
api.delete.mockRejectedValue(new Error('save failed'))
|
||||
it('calls the existing retry API and preserves the recoverable state on failure', async () => {
|
||||
api.status.mockResolvedValue({ candidateId: 7, candidateStatus: 'PENDING', machineStatus: 'ERROR', reviewStatus: 'NOT_REQUIRED', machineNextRetryAt: null, automaticRetryExhausted: true })
|
||||
api.retry.mockRejectedValue(new Error('service unavailable'))
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
render(<QueryClientProvider client={client}><SkillAvatarManager namespace="team" slug="foo" /></QueryClientProvider>)
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'skillAvatar.remove' }))
|
||||
expect(api.delete).not.toHaveBeenCalled()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'skillAvatar.saveChanges' }))
|
||||
expect((await screen.findByRole('alert')).textContent).toBe('save failed')
|
||||
await waitFor(() => expect(api.delete).toHaveBeenCalledWith('team', 'foo'))
|
||||
expect(screen.getByText('skillAvatar.uploadStates.REMOVE')).toBeTruthy()
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'skillAvatar.retry' }))
|
||||
expect((await screen.findByRole('alert')).textContent).toBe('service unavailable')
|
||||
expect(api.retry).toHaveBeenCalledWith('team', 'foo', 7)
|
||||
expect(screen.getByRole('button', { name: 'skillAvatar.retry' }).hasAttribute('disabled')).toBe(false)
|
||||
})
|
||||
|
||||
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 } } })
|
||||
render(<QueryClientProvider client={client}><SkillAvatarManager namespace="team" slug="foo" /></QueryClientProvider>)
|
||||
await waitFor(() => expect(api.status).toHaveBeenCalled())
|
||||
expect(screen.queryByText('approved reason')).toBeNull()
|
||||
expect(screen.queryByRole('img')).toBeNull()
|
||||
expect(screen.queryByRole('button')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,59 +1,41 @@
|
|||
import { useState } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { buildApiUrl, skillAvatarApi } from '@/api/client'
|
||||
import { skillAvatarApi } from '@/api/client'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { Card } from '@/shared/ui/card'
|
||||
import { SkillAvatarEditor, KEEP_AVATAR, type AvatarEdit } from './skill-avatar-editor'
|
||||
|
||||
export function SkillAvatarManager({ namespace, slug, name = slug, readOnly = false }: { namespace: string; slug: string; name?: string; readOnly?: boolean }) {
|
||||
export function SkillAvatarManager({ namespace, slug, disabled = false }: { namespace: string; slug: string; disabled?: boolean }) {
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
const key = ['skill-avatar', namespace, slug]
|
||||
const status = useQuery({ queryKey: key, queryFn: () => skillAvatarApi.status(namespace, slug) })
|
||||
const [edit, setEdit] = useState<AvatarEdit>(KEEP_AVATAR)
|
||||
const status = useQuery({ queryKey: key, queryFn: () => skillAvatarApi.status(namespace, slug), enabled: !!namespace && !!slug })
|
||||
const [error, setError] = useState('')
|
||||
const [editorVersion, setEditorVersion] = useState(0)
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (action: { kind: 'upload'; uploadId: string } | { kind: 'remove' | 'appeal' | 'retry'; id?: number }) => {
|
||||
if (action.kind === 'upload') return skillAvatarApi.submit(namespace, slug, action.uploadId)
|
||||
if (action.kind === 'remove') return skillAvatarApi.delete(namespace, slug)
|
||||
if (action.kind === 'appeal') return skillAvatarApi.appeal(namespace, slug, action.id!)
|
||||
return skillAvatarApi.retry(namespace, slug, action.id!)
|
||||
},
|
||||
onSuccess: async (_data, action) => {
|
||||
mutationFn: async (action: { kind: 'appeal' | 'retry'; id: number }) => action.kind === 'appeal'
|
||||
? skillAvatarApi.appeal(namespace, slug, action.id)
|
||||
: skillAvatarApi.retry(namespace, slug, action.id),
|
||||
onSuccess: async () => {
|
||||
setError('')
|
||||
if (action.kind === 'upload' || action.kind === 'remove') {
|
||||
setEdit(KEEP_AVATAR)
|
||||
setEditorVersion(value => value + 1)
|
||||
}
|
||||
await Promise.all([queryClient.invalidateQueries({ queryKey: key }), queryClient.invalidateQueries({ queryKey: ['skill'] }), queryClient.invalidateQueries({ queryKey: ['skills'] }), queryClient.invalidateQueries({ queryKey: ['skill-avatar-review'] })])
|
||||
},
|
||||
onError: (cause) => setError(cause instanceof Error ? cause.message : t('skillAvatar.failed')),
|
||||
})
|
||||
const candidate = status.data
|
||||
const retryCoolingDown = !!candidate?.machineNextRetryAt && Date.parse(candidate.machineNextRetryAt) > Date.now()
|
||||
if (!candidate || !['PENDING', 'REJECTED'].includes(candidate.candidateStatus ?? '')) return null
|
||||
return (
|
||||
<Card className="space-y-3 p-4">
|
||||
<h2 className="font-semibold">{t('skillAvatar.title')}</h2>
|
||||
<p className="text-sm text-muted-foreground">{t(readOnly ? 'skillAvatar.detailHelp' : 'skillAvatar.ownerHelp')}</p>
|
||||
{candidate?.candidateStatus && <p role="status" className="text-sm">{t('skillAvatar.status')}: {t(`skillAvatar.states.${candidate.candidateStatus}`)} · {t(`skillAvatar.machine.${candidate.machineStatus ?? 'SKIPPED'}`)}</p>}
|
||||
<div className="space-y-2 text-sm">
|
||||
{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>}
|
||||
{candidate?.candidateUrl && <img src={buildApiUrl(candidate.candidateUrl)} alt={t('skillAvatar.candidatePreview')} className="h-24 w-24 rounded-lg object-cover" />}
|
||||
{!readOnly && <>
|
||||
<SkillAvatarEditor key={editorVersion} name={name} currentUrl={candidate?.avatarUrl} canRemove={!!candidate?.avatarUrl || candidate?.candidateStatus === 'PENDING'} disabled={mutation.isPending || status.isLoading || status.isError} onChange={setEdit} />
|
||||
<Button disabled={edit.action === 'KEEP' || !edit.ready || mutation.isPending || status.isLoading || status.isError} onClick={() => mutation.mutate(edit.action === 'REMOVE' ? { kind: 'remove' } : { kind: 'upload', uploadId: edit.uploadId! })}>{t('skillAvatar.saveChanges')}</Button>
|
||||
</>}
|
||||
{status.isError && <p role="alert" className="text-sm text-destructive">{t('skillAvatar.failed')}</p>}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{!readOnly && candidate?.candidateStatus === 'PENDING' && candidate.machineStatus === 'BLOCKED'
|
||||
{candidate?.candidateStatus === 'PENDING' && candidate.machineStatus === 'BLOCKED'
|
||||
&& candidate.reviewStatus === 'NOT_REQUIRED' && candidate.candidateId
|
||||
&& <Button variant="outline" disabled={mutation.isPending} onClick={() => mutation.mutate({ kind: 'appeal', id: candidate.candidateId! })}>{t('skillAvatar.appeal')}</Button>}
|
||||
{!readOnly && candidate?.machineStatus === 'ERROR' && candidate.candidateId && <Button variant="outline" disabled={mutation.isPending || retryCoolingDown} onClick={() => mutation.mutate({ kind: 'retry', id: candidate.candidateId! })}>{t('skillAvatar.retry')}</Button>}
|
||||
&& <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>
|
||||
{error && <p role="alert" className="text-sm text-destructive">{error}</p>}
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1833,7 +1833,12 @@
|
|||
"frontmatterFailedTitle": "SKILL.md format is invalid",
|
||||
"frontmatterFailedDescription": "Please check the YAML frontmatter at the top of SKILL.md. If a field value contains a colon, wrap it in quotes.",
|
||||
"selectRequired": "Please select namespace and file",
|
||||
"folderPackagingFailed": "Could not package the selected folder. Make sure it contains files."
|
||||
"folderPackagingFailed": "Could not package the selected folder. Make sure it contains files.",
|
||||
"updateTitle": "Update skill",
|
||||
"updateSubtitle": "Save avatar changes independently, or upload a package to publish a new version.",
|
||||
"optionalPackage": "A package is optional when changing only the avatar.",
|
||||
"saveAvatar": "Save avatar",
|
||||
"avatarSaved": "Avatar changes submitted"
|
||||
},
|
||||
"toast": {
|
||||
"success": "Success",
|
||||
|
|
@ -2667,6 +2672,11 @@
|
|||
"PASSED": "Machine review passed",
|
||||
"BLOCKED": "Machine review blocked",
|
||||
"ERROR": "Machine review failed"
|
||||
}
|
||||
},
|
||||
"cropTitle": "Crop avatar",
|
||||
"cropConfirm": "Confirm and upload",
|
||||
"change": "Change image",
|
||||
"stagedReady": "Uploaded; applied after submission",
|
||||
"removedDraft": "Removed after submission"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1824,7 +1824,12 @@
|
|||
"frontmatterFailedTitle": "Неверный формат SKILL.md",
|
||||
"frontmatterFailedDescription": "Проверьте YAML frontmatter в начале SKILL.md. Если значение поля содержит двоеточие, заключите его в кавычки.",
|
||||
"selectRequired": "Выберите пространство имён и файл",
|
||||
"folderPackagingFailed": "Не удалось упаковать выбранную папку. Убедитесь, что она содержит файлы."
|
||||
"folderPackagingFailed": "Не удалось упаковать выбранную папку. Убедитесь, что она содержит файлы.",
|
||||
"updateTitle": "Обновить навык",
|
||||
"updateSubtitle": "Сохраните аватар отдельно или загрузите пакет для публикации новой версии.",
|
||||
"optionalPackage": "Для изменения только аватара пакет не требуется.",
|
||||
"saveAvatar": "Сохранить аватар",
|
||||
"avatarSaved": "Изменения аватара отправлены"
|
||||
},
|
||||
"ratingInput": {
|
||||
"yourRating": "Ваша оценка: {{score}} зв."
|
||||
|
|
@ -2669,6 +2674,11 @@
|
|||
"PASSED": "Проверка пройдена",
|
||||
"BLOCKED": "Заблокирован",
|
||||
"ERROR": "Ошибка проверки"
|
||||
}
|
||||
},
|
||||
"cropTitle": "Обрезка аватара",
|
||||
"cropConfirm": "Подтвердить и загрузить",
|
||||
"change": "Заменить изображение",
|
||||
"stagedReady": "Загружено; применится после отправки",
|
||||
"removedDraft": "Удалится после отправки"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1832,7 +1832,12 @@
|
|||
"frontmatterFailedTitle": "SKILL.md 格式有误",
|
||||
"frontmatterFailedDescription": "请检查 SKILL.md 顶部 frontmatter 的 YAML 格式。若字段值中包含冒号,请用引号包裹。",
|
||||
"selectRequired": "请选择命名空间和文件",
|
||||
"folderPackagingFailed": "无法打包所选文件夹,请确认其中包含文件。"
|
||||
"folderPackagingFailed": "无法打包所选文件夹,请确认其中包含文件。",
|
||||
"updateTitle": "更新技能",
|
||||
"updateSubtitle": "头像可单独保存;上传技能包将发布新版本。",
|
||||
"optionalPackage": "仅更新头像时无需上传技能包。",
|
||||
"saveAvatar": "保存头像",
|
||||
"avatarSaved": "头像修改已提交"
|
||||
},
|
||||
"toast": {
|
||||
"success": "成功",
|
||||
|
|
@ -2666,6 +2671,11 @@
|
|||
"PASSED": "机器审核通过",
|
||||
"BLOCKED": "机器审核阻止",
|
||||
"ERROR": "机器审核失败"
|
||||
}
|
||||
},
|
||||
"cropTitle": "裁剪头像",
|
||||
"cropConfirm": "确认并上传",
|
||||
"change": "更换图片",
|
||||
"stagedReady": "已上传,提交后生效",
|
||||
"removedDraft": "提交后移除头像"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -101,6 +101,7 @@ vi.mock('@/shared/lib/toast', () => ({
|
|||
}))
|
||||
|
||||
vi.mock('@/api/client', () => ({
|
||||
buildApiUrl: (url: string) => url,
|
||||
ApiError: class ApiError extends Error {
|
||||
serverMessageKey?: string
|
||||
},
|
||||
|
|
@ -163,6 +164,14 @@ describe('MySkillsPage', () => {
|
|||
})
|
||||
})
|
||||
|
||||
it('displays the active avatar beside the skill name and exposes only the existing update entry', () => {
|
||||
useMySkillsMock.mockReturnValue({ data: { items: [{ id: 1, displayName: 'Team Agent', namespace: 'team-ai', slug: 'team-agent', avatarUrl: '/skill-avatar.png', status: 'PUBLISHED', downloadCount: 0 }], total: 1 }, isLoading: false })
|
||||
const html = renderToStaticMarkup(createElement(MySkillsPage))
|
||||
expect(html).toContain('src="/skill-avatar.png"')
|
||||
expect(buttonRecords.some(button => button.label === 'skillAvatar.manage')).toBe(false)
|
||||
expect(buttonRecords.some(button => button.label === 'mySkills.update')).toBe(true)
|
||||
})
|
||||
|
||||
it('does not render update action for archived skills', () => {
|
||||
useMySkillsMock.mockReturnValue({
|
||||
data: {
|
||||
|
|
|
|||
|
|
@ -18,8 +18,7 @@ import { getHeadlineVersion, getPublishedVersion, getOwnerPreviewVersion, hasPen
|
|||
import { formatCompactCount } from '@/shared/lib/number-format'
|
||||
import { toast } from '@/shared/lib/toast'
|
||||
import { buildReturnTo } from '@/shared/lib/auth-route'
|
||||
import { SkillAvatarManager } from '@/features/skill/skill-avatar-manager'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/shared/ui/dialog'
|
||||
import { SkillAvatar } from '@/features/skill/skill-avatar'
|
||||
import { ApiError } from '@/api/client'
|
||||
import { getMySkillEmptyStateKey, getMySkillFilters, type MySkillFilter } from './my-skill-filters'
|
||||
|
||||
|
|
@ -59,7 +58,6 @@ export function MySkillsPage() {
|
|||
// Input remains local until the user explicitly submits the search.
|
||||
const [keywordInput, setKeywordInput] = useState(keyword)
|
||||
|
||||
const [avatarTarget, setAvatarTarget] = useState<{ namespace: string; slug: string; name: string } | null>(null)
|
||||
const [archiveTarget, setArchiveTarget] = useState<{ namespace: string; slug: string; name: string } | null>(null)
|
||||
const [unarchiveTarget, setUnarchiveTarget] = useState<{ namespace: string; slug: string; name: string } | null>(null)
|
||||
const [withdrawTarget, setWithdrawTarget] = useState<{ namespace: string; slug: string; name: string; version: string } | null>(null)
|
||||
|
|
@ -382,11 +380,14 @@ export function MySkillsPage() {
|
|||
className={`p-5 group animate-fade-up delay-${Math.min(idx + 1, 6)} ${filter === 'HIDDEN' ? '' : 'cursor-pointer'}`}
|
||||
onClick={filter === 'HIDDEN' ? undefined : () => handleSkillClick(skill.namespace, skill.slug)}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold font-heading text-lg mb-1 group-hover:text-primary transition-colors">
|
||||
{skill.displayName}
|
||||
</h3>
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="mb-3 flex items-center gap-3">
|
||||
<SkillAvatar name={skill.displayName} url={skill.avatarUrl} />
|
||||
<h3 className="min-w-0 break-words font-heading text-lg font-semibold group-hover:text-primary transition-colors">
|
||||
{skill.displayName}
|
||||
</h3>
|
||||
</div>
|
||||
{skill.summary && (
|
||||
<p className="text-sm text-muted-foreground mb-3 leading-relaxed">{skill.summary}</p>
|
||||
)}
|
||||
|
|
@ -423,11 +424,7 @@ export function MySkillsPage() {
|
|||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2 pl-4">
|
||||
{filter !== 'HIDDEN' && <Button size="sm" variant="outline" onClick={event => {
|
||||
event.stopPropagation()
|
||||
setAvatarTarget({ namespace: skill.namespace, slug: skill.slug, name: skill.displayName })
|
||||
}}>{t('skillAvatar.manage')}</Button>}
|
||||
<div className="flex shrink-0 flex-wrap items-center gap-2 sm:max-w-[45%] sm:justify-end">
|
||||
{canRestoreHidden ? (
|
||||
<Button
|
||||
size="sm"
|
||||
|
|
@ -545,13 +542,6 @@ export function MySkillsPage() {
|
|||
/>
|
||||
)}
|
||||
|
||||
<Dialog open={!!avatarTarget} onOpenChange={open => { if (!open) setAvatarTarget(null) }}>
|
||||
<DialogContent>
|
||||
<DialogHeader><DialogTitle>{t('skillAvatar.manage')}</DialogTitle><DialogDescription>{avatarTarget?.name}</DialogDescription></DialogHeader>
|
||||
{avatarTarget && <SkillAvatarManager namespace={avatarTarget.namespace} slug={avatarTarget.slug} name={avatarTarget.name} />}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!restoreHiddenTarget}
|
||||
onOpenChange={(open) => {
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
/** @vitest-environment jsdom */
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
const mocks = vi.hoisted(() => ({ publish: vi.fn(), navigate: vi.fn(), search: { namespace: 'team', visibility: 'PUBLIC', updateSkill: 'foo' } }))
|
||||
vi.mock('@tanstack/react-query', () => ({ useQuery: () => ({ data: { avatarUrl: '/owner-active' }, isLoading: false, isError: false }) }))
|
||||
const mocks = vi.hoisted(() => ({ publish: vi.fn(), navigate: vi.fn(), submit: vi.fn(), remove: vi.fn(), refresh: vi.fn(), search: { namespace: 'team', visibility: 'PUBLIC', updateSkill: 'foo', resubmitSkill: '' } }))
|
||||
vi.mock('@tanstack/react-query', () => ({ useQueryClient: () => ({ invalidateQueries: mocks.refresh }), useQuery: () => ({ data: { avatarUrl: '/owner-active' }, isLoading: false, isError: false }) }))
|
||||
vi.mock('@tanstack/react-router', () => ({ useNavigate: () => mocks.navigate, useSearch: () => mocks.search }))
|
||||
vi.mock('react-i18next', async () => ({ ...await vi.importActual<typeof import('react-i18next')>('react-i18next'), useTranslation: () => ({ t: (key: string) => key }) }))
|
||||
vi.mock('@/shared/hooks/use-skill-queries', () => ({
|
||||
|
|
@ -21,10 +21,12 @@ vi.mock('@/features/skill/skill-avatar-editor', () => ({
|
|||
}))
|
||||
vi.mock('@/shared/lib/navigate-after-overlays', () => ({ navigateAfterOverlays: (fn: () => void) => fn() }))
|
||||
vi.mock('@/shared/lib/toast', () => ({ toast: { success: vi.fn(), error: vi.fn() } }))
|
||||
vi.mock('@/features/skill/skill-avatar-manager', () => ({ SkillAvatarManager: () => null }))
|
||||
vi.mock('@/api/client', async () => ({ ...await vi.importActual<typeof import('@/api/client')>('@/api/client'), skillAvatarApi: { submit: mocks.submit, delete: mocks.remove } }))
|
||||
import { PublishPage } from './publish'
|
||||
|
||||
describe('Publish avatar submission', () => {
|
||||
beforeEach(() => { mocks.publish.mockReset(); mocks.navigate.mockReset(); mocks.search.updateSkill = 'foo' })
|
||||
beforeEach(() => { mocks.publish.mockReset(); mocks.navigate.mockReset(); mocks.search.updateSkill = 'foo'; mocks.search.resubmitSkill = ''; mocks.submit.mockReset(); mocks.remove.mockReset(); mocks.refresh.mockReset(); mocks.refresh.mockResolvedValue(undefined) })
|
||||
afterEach(cleanup)
|
||||
|
||||
it('blocks publishing a selected image until staged upload completes', async () => {
|
||||
|
|
@ -80,4 +82,66 @@ describe('Publish avatar submission', () => {
|
|||
await waitFor(() => expect(mocks.publish).toHaveBeenCalledWith(expect.objectContaining({ avatarAction: 'KEEP', avatarUploadId: undefined, expectedSlug: 'bar' })))
|
||||
})
|
||||
|
||||
it('saves only an avatar through the existing API, without publishing a version, then refreshes caches', async () => {
|
||||
mocks.submit.mockResolvedValue({ candidateStatus: 'PENDING' })
|
||||
render(<PublishPage />)
|
||||
expect(screen.getByRole('button', { name: 'publish.saveAvatar' }).hasAttribute('disabled')).toBe(true)
|
||||
fireEvent.click(screen.getByText('image uploaded'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'publish.saveAvatar' }))
|
||||
await waitFor(() => expect(mocks.navigate).toHaveBeenCalled())
|
||||
expect(mocks.submit).toHaveBeenCalledWith('team', 'foo', 'private-reference')
|
||||
expect(mocks.publish).not.toHaveBeenCalled()
|
||||
expect(mocks.refresh.mock.calls.map(([arg]) => arg.queryKey)).toEqual([
|
||||
['skills'], ['skill-avatar', 'team', 'foo'], ['skill-avatar-review'], ['reviews'],
|
||||
])
|
||||
})
|
||||
|
||||
it('waits for cache refresh and ignores a second save while the request is pending', async () => {
|
||||
let finish!: () => void
|
||||
mocks.submit.mockReturnValue(new Promise<void>(resolve => { finish = resolve }))
|
||||
let finishRefresh!: () => void
|
||||
mocks.refresh.mockReturnValue(new Promise<void>(resolve => { finishRefresh = resolve }))
|
||||
render(<PublishPage />)
|
||||
fireEvent.click(screen.getByText('image uploaded'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'publish.saveAvatar' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'publish.publishing' }))
|
||||
expect(mocks.submit).toHaveBeenCalledTimes(1)
|
||||
finish()
|
||||
await waitFor(() => expect(mocks.refresh).toHaveBeenCalled())
|
||||
expect(mocks.navigate).not.toHaveBeenCalled()
|
||||
finishRefresh()
|
||||
await waitFor(() => expect(mocks.navigate).toHaveBeenCalled())
|
||||
})
|
||||
|
||||
it('keeps an avatar-only upload reference after failure so it can be retried', async () => {
|
||||
mocks.submit.mockRejectedValueOnce(new Error('503')).mockResolvedValueOnce({ candidateStatus: 'PENDING' })
|
||||
render(<PublishPage />)
|
||||
fireEvent.click(screen.getByText('image uploaded'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'publish.saveAvatar' }))
|
||||
await waitFor(() => expect(screen.queryByRole('button', { name: 'publish.publishing' })).toBeNull())
|
||||
expect(mocks.navigate).not.toHaveBeenCalled()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'publish.saveAvatar' }))
|
||||
await waitFor(() => expect(mocks.submit).toHaveBeenCalledTimes(2))
|
||||
expect(mocks.submit.mock.calls[1]).toEqual(mocks.submit.mock.calls[0])
|
||||
})
|
||||
|
||||
it('removes only after save and does not create a skill version', async () => {
|
||||
mocks.remove.mockResolvedValue(undefined)
|
||||
render(<PublishPage />)
|
||||
fireEvent.click(screen.getByText('remove draft'))
|
||||
expect(mocks.remove).not.toHaveBeenCalled()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'publish.saveAvatar' }))
|
||||
await waitFor(() => expect(mocks.remove).toHaveBeenCalledWith('team', 'foo'))
|
||||
expect(mocks.publish).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each(['create', 'resubmit'])('still requires a package for %s', mode => {
|
||||
mocks.search.updateSkill = ''
|
||||
if (mode === 'resubmit') mocks.search.resubmitSkill = 'foo'
|
||||
render(<PublishPage />)
|
||||
fireEvent.click(screen.getByText('image uploaded'))
|
||||
expect(screen.getByRole('button', { name: 'publish.confirm' }).hasAttribute('disabled')).toBe(true)
|
||||
expect(mocks.submit).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
})
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|||
const useSearchMock = vi.fn()
|
||||
const selectRecords: Array<{ value?: string }> = []
|
||||
|
||||
vi.mock('@tanstack/react-query', () => ({ useQuery: () => ({ data: null, isLoading: false, isError: false }) }))
|
||||
vi.mock('@tanstack/react-query', () => ({ useQueryClient: () => ({ invalidateQueries: vi.fn() }), useQuery: () => ({ data: null, isLoading: false, isError: false }) }))
|
||||
|
||||
vi.mock('@tanstack/react-router', () => ({
|
||||
useNavigate: () => vi.fn(),
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useNavigate, useSearch } from '@tanstack/react-router'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { UploadZone } from '@/features/publish/upload-zone'
|
||||
|
|
@ -24,6 +24,7 @@ import {
|
|||
import { Label } from '@/shared/ui/label'
|
||||
import { Card } from '@/shared/ui/card'
|
||||
import { SkillAvatarEditor, KEEP_AVATAR, type AvatarEdit } from '@/features/skill/skill-avatar-editor'
|
||||
import { SkillAvatarManager } from '@/features/skill/skill-avatar-manager'
|
||||
import { usePublishSkill } from '@/shared/hooks/use-skill-queries'
|
||||
import { useMyNamespaces } from '@/shared/hooks/use-namespace-queries'
|
||||
import { ConfirmDialog } from '@/shared/components/confirm-dialog'
|
||||
|
|
@ -40,6 +41,10 @@ export function PublishPage() {
|
|||
const search = useSearch({ from: '/dashboard/publish' })
|
||||
const prefill = normalizePublishPrefill(search)
|
||||
const updateSlug = typeof search.updateSkill === 'string' ? search.updateSkill : prefill.resubmitSkill
|
||||
const isUpdate = !!search.updateSkill && !prefill.resubmitSkill
|
||||
const queryClient = useQueryClient()
|
||||
const submitting = useRef(false)
|
||||
const [isSavingAvatar, setIsSavingAvatar] = useState(false)
|
||||
const [avatarEdit, setAvatarEdit] = useState<AvatarEdit>(KEEP_AVATAR)
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null)
|
||||
const [namespaceSlug, setNamespaceSlug] = useState<string>(prefill.namespace)
|
||||
|
|
@ -52,6 +57,9 @@ export function PublishPage() {
|
|||
const publishMutation = usePublishSkill()
|
||||
const avatarTarget = useQuery({ queryKey: ['skill-avatar', namespaceSlug, updateSlug], queryFn: () => skillAvatarApi.status(namespaceSlug, updateSlug), enabled: !!namespaceSlug && !!updateSlug })
|
||||
const avatarUnavailable = !!updateSlug && (avatarTarget.isLoading || avatarTarget.isError)
|
||||
const isBusy = publishMutation.isPending || isSavingAvatar
|
||||
const avatarOnly = isUpdate && !selectedFile
|
||||
const canSubmit = !!namespaceSlug && avatarEdit.ready && !avatarUnavailable && !isBusy && (selectedFile !== null || (isUpdate && avatarEdit.action !== 'KEEP'))
|
||||
const selectedNamespace = namespaces?.find((ns) => ns.slug === namespaceSlug)
|
||||
const namespaceOnlyLabel = selectedNamespace?.type === 'GLOBAL'
|
||||
? t('publish.visibilityOptions.loggedInUsersOnly')
|
||||
|
|
@ -88,15 +96,31 @@ export function PublishPage() {
|
|||
}
|
||||
|
||||
const publishSkill = async (confirmWarnings = false) => {
|
||||
if (!selectedFile || !namespaceSlug || !avatarEdit.ready || avatarUnavailable) {
|
||||
if (submitting.current) return
|
||||
if (!canSubmit) {
|
||||
toast.error(t('publish.selectRequired'))
|
||||
return
|
||||
}
|
||||
|
||||
submitting.current = true
|
||||
try {
|
||||
if (avatarOnly) {
|
||||
setIsSavingAvatar(true)
|
||||
if (avatarEdit.action === 'REMOVE') await skillAvatarApi.delete(namespaceSlug, updateSlug)
|
||||
else await skillAvatarApi.submit(namespaceSlug, updateSlug, avatarEdit.uploadId!)
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ['skills'] }),
|
||||
queryClient.invalidateQueries({ queryKey: ['skill-avatar', namespaceSlug, updateSlug] }),
|
||||
queryClient.invalidateQueries({ queryKey: ['skill-avatar-review'] }),
|
||||
queryClient.invalidateQueries({ queryKey: ['reviews'] }),
|
||||
])
|
||||
toast.success(t('publish.avatarSaved'), t(avatarEdit.action === 'REMOVE' ? 'skillAvatar.removedNotice' : 'skillAvatar.submittedNotice'))
|
||||
navigateAfterOverlays(() => { navigate({ to: '/dashboard/skills' }) })
|
||||
return
|
||||
}
|
||||
const result = await publishMutation.mutateAsync({
|
||||
namespace: namespaceSlug,
|
||||
file: selectedFile,
|
||||
file: selectedFile!,
|
||||
visibility,
|
||||
confirmWarnings,
|
||||
avatarAction: avatarEdit.action,
|
||||
|
|
@ -158,6 +182,9 @@ export function PublishPage() {
|
|||
}
|
||||
|
||||
toast.error(t('publish.error'), error instanceof Error ? error.message : '')
|
||||
} finally {
|
||||
submitting.current = false
|
||||
setIsSavingAvatar(false)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -167,7 +194,7 @@ export function PublishPage() {
|
|||
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl space-y-8 animate-fade-up">
|
||||
<DashboardPageHeader title={t('publish.title')} subtitle={t('publish.subtitle')} />
|
||||
<DashboardPageHeader title={t(isUpdate ? 'publish.updateTitle' : 'publish.title')} subtitle={t(isUpdate ? 'publish.updateSubtitle' : 'publish.subtitle')} />
|
||||
|
||||
<Card className="space-y-8 p-6 md:p-8">
|
||||
{prefill.resubmitSkill && prefill.resubmitVersion ? (
|
||||
|
|
@ -198,7 +225,7 @@ export function PublishPage() {
|
|||
<div className="h-11 animate-shimmer rounded-lg" />
|
||||
) : (
|
||||
<Select
|
||||
disabled={!!updateSlug || publishMutation.isPending}
|
||||
disabled={!!updateSlug || isBusy}
|
||||
value={normalizeSelectValue(namespaceSlug) ?? EMPTY_NAMESPACE_VALUE}
|
||||
onValueChange={(value) => {
|
||||
setNamespaceSlug(value === EMPTY_NAMESPACE_VALUE ? '' : value)
|
||||
|
|
@ -222,7 +249,7 @@ export function PublishPage() {
|
|||
|
||||
<div className="space-y-3">
|
||||
<Label htmlFor="visibility" className="text-sm font-semibold font-heading">{t('publish.visibility')}</Label>
|
||||
<Select value={visibility} onValueChange={setVisibility}>
|
||||
<Select value={visibility} onValueChange={setVisibility} disabled={isBusy || avatarOnly}>
|
||||
<SelectTrigger id="visibility">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
|
|
@ -236,10 +263,11 @@ export function PublishPage() {
|
|||
|
||||
<div className="space-y-3">
|
||||
<Label className="text-sm font-semibold font-heading">{t('publish.file')}</Label>
|
||||
{isUpdate && <p className="text-sm text-muted-foreground">{t('publish.optionalPackage')}</p>}
|
||||
<UploadZone
|
||||
onFileSelect={handleFileSelect}
|
||||
onFolderSelect={handleFolderSelect}
|
||||
disabled={publishMutation.isPending || isPackaging}
|
||||
disabled={isBusy || isPackaging}
|
||||
/>
|
||||
{selectedFile && (
|
||||
<div className="flex items-center justify-between gap-3 rounded-lg border border-border/60 bg-secondary/30 px-4 py-3">
|
||||
|
|
@ -256,7 +284,7 @@ export function PublishPage() {
|
|||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleRemoveSelectedFile}
|
||||
disabled={publishMutation.isPending}
|
||||
disabled={isBusy}
|
||||
>
|
||||
{t('publish.removeSelectedFile')}
|
||||
</Button>
|
||||
|
|
@ -264,16 +292,17 @@ export function PublishPage() {
|
|||
)}
|
||||
</div>
|
||||
|
||||
<SkillAvatarEditor key={`${namespaceSlug}/${updateSlug}`} name={search.updateName ?? updateSlug} currentUrl={avatarTarget.data?.avatarUrl} canRemove={!!updateSlug} disabled={publishMutation.isPending || avatarUnavailable} onChange={setAvatarEdit} />
|
||||
<SkillAvatarEditor key={`${namespaceSlug}/${updateSlug}`} name={search.updateName ?? updateSlug} currentUrl={avatarTarget.data?.avatarUrl} canRemove={!!updateSlug} disabled={isBusy || avatarUnavailable} onChange={setAvatarEdit} />
|
||||
{updateSlug && <SkillAvatarManager namespace={namespaceSlug} slug={updateSlug} disabled={isBusy} />}
|
||||
{avatarTarget.isError && <p role="alert" className="text-sm text-destructive">{t('skillAvatar.failed')}</p>}
|
||||
|
||||
<Button
|
||||
className="w-full text-primary-foreground disabled:text-primary-foreground"
|
||||
size="lg"
|
||||
onClick={handlePublish}
|
||||
disabled={!selectedFile || !namespaceSlug || !avatarEdit.ready || avatarUnavailable || publishMutation.isPending}
|
||||
disabled={!canSubmit}
|
||||
>
|
||||
{publishMutation.isPending ? t('publish.publishing') : t('publish.confirm')}
|
||||
{isBusy ? t('publish.publishing') : t(avatarOnly ? 'publish.saveAvatar' : 'publish.confirm')}
|
||||
</Button>
|
||||
</Card>
|
||||
|
||||
|
|
|
|||
|
|
@ -334,6 +334,15 @@ describe('SkillDetailPage', () => {
|
|||
useSkillFileMock.mockReturnValue({ data: null, isLoading: false, error: null })
|
||||
})
|
||||
|
||||
it('shows the avatar once beside the title, without an owner management section', () => {
|
||||
useSkillDetailMock.mockReturnValue({ data: createSkill({ avatarUrl: '/approved-avatar.png' }), isLoading: false, isFetching: false, error: null })
|
||||
const view = render(<SkillDetailPage />)
|
||||
expect(view.container.querySelectorAll('img[src="/approved-avatar.png"]')).toHaveLength(1)
|
||||
expect(screen.queryByText('skillAvatar.detailHelp')).toBeNull()
|
||||
expect(screen.queryByText('skillAvatar.title')).toBeNull()
|
||||
expect(screen.queryByRole('button', { name: 'skillAvatar.manage' })).toBeNull()
|
||||
})
|
||||
|
||||
it('offers a published team version for review against the current global version', async () => {
|
||||
routeParams.namespace = 'team-ai'
|
||||
useSkillDetailMock.mockReturnValue({
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'
|
|||
import { ArrowLeft, ArrowUpCircle, Boxes, ChevronDown, ChevronUp, Clock, Folder, Globe, Lock, RefreshCw, ShieldCheck, Terminal, User, Users } from 'lucide-react'
|
||||
import { MarkdownRenderer } from '@/features/skill/markdown-renderer'
|
||||
import { SkillAvatar } from '@/features/skill/skill-avatar'
|
||||
import { SkillAvatarManager } from '@/features/skill/skill-avatar-manager'
|
||||
import { SkillAvatarTakedown } from '@/features/skill/skill-avatar-takedown'
|
||||
import { resolvePackageRelativeLink } from '@/features/skill/package-relative-link'
|
||||
import { FileTree } from '@/features/skill/file-tree'
|
||||
|
|
@ -903,7 +902,6 @@ export function SkillDetailPage() {
|
|||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-4"><SkillAvatar name={skill.displayName} url={skill.avatarUrl} size="h-16 w-16 text-2xl" /><h1 className="text-balance break-words text-4xl font-bold font-heading text-foreground [overflow-wrap:anywhere]">{skill.displayName}</h1></div>
|
||||
{user?.userId === skill.ownerId && <SkillAvatarManager namespace={namespace} slug={slug} readOnly />}
|
||||
{skill.avatarUrl && user?.userId !== skill.ownerId && (namespace === 'global'
|
||||
? isPromotionAdmin
|
||||
: myNamespaces?.some((item) => item.slug === namespace && (item.currentUserRole === 'OWNER' || item.currentUserRole === 'ADMIN')))
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue