- {state === 'READY' && previewUrl ?
})
- :
}
-
{t(`skillAvatar.uploadStates.${state}`)}
+ return
+ {t('skillAvatar.optionalTitle')}
+
+ {state === 'READY' && previewUrl ?
})
+ :
}
+
+
+
+
+ {(canRemove || state === 'READY') && state !== 'REMOVE' && }
+ {state !== 'KEEP' && !dialogOpen && }
+
+ {state !== 'KEEP' && !dialogOpen &&
{t(state === 'READY' ? 'skillAvatar.stagedReady' : state === 'REMOVE' ? 'skillAvatar.removedDraft' : `skillAvatar.uploadStates.${state}`)}
}
+
-
-
-
- {sourceUrl && state !== 'READY' &&
-
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'),
- }}>
-
{
- 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) }} />
-
-
{t('skillAvatar.cropHelp')}
-
-
}
-
- {state !== 'KEEP' && }
- {canRemove && state !== 'REMOVE' && }
-
- {error && {error}
}
+ {!dialogOpen && error && {error}
}
+
}
diff --git a/web/src/features/skill/skill-avatar-manager.test.tsx b/web/src/features/skill/skill-avatar-manager.test.tsx
index 6c8aa0c1..5dd1b298 100644
--- a/web/src/features/skill/skill-avatar-manager.test.tsx
+++ b/web/src/features/skill/skill-avatar-manager.test.tsx
@@ -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(
)
- 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(
)
- 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(
)
+ await waitFor(() => expect(api.status).toHaveBeenCalled())
+ expect(screen.queryByText('approved reason')).toBeNull()
+ expect(screen.queryByRole('img')).toBeNull()
+ expect(screen.queryByRole('button')).toBeNull()
+ })
})
diff --git a/web/src/features/skill/skill-avatar-manager.tsx b/web/src/features/skill/skill-avatar-manager.tsx
index 2cfa428f..18e62114 100644
--- a/web/src/features/skill/skill-avatar-manager.tsx
+++ b/web/src/features/skill/skill-avatar-manager.tsx
@@ -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
(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 (
-
- {t('skillAvatar.title')}
- {t(readOnly ? 'skillAvatar.detailHelp' : 'skillAvatar.ownerHelp')}
- {candidate?.candidateStatus && {t('skillAvatar.status')}: {t(`skillAvatar.states.${candidate.candidateStatus}`)} · {t(`skillAvatar.machine.${candidate.machineStatus ?? 'SKIPPED'}`)}
}
+
+ {candidate?.candidateStatus &&
{t('skillAvatar.status')}: {t(`skillAvatar.states.${candidate.candidateStatus}`)}
}
{candidate?.reviewReason &&
{candidate.reviewReason}
}
{candidate?.machineStatus === 'ERROR' && retryCoolingDown &&
{t('skillAvatar.retryAfter', { time: new Date(candidate.machineNextRetryAt!).toLocaleString() })}
}
{candidate?.machineStatus === 'ERROR' && candidate.automaticRetryExhausted &&
{t('skillAvatar.automaticRetryStopped')}
}
- {candidate?.candidateUrl &&
})
}
- {!readOnly && <>
-
-
- >}
- {status.isError &&
{t('skillAvatar.failed')}
}
- {!readOnly && candidate?.candidateStatus === 'PENDING' && candidate.machineStatus === 'BLOCKED'
+ {candidate?.candidateStatus === 'PENDING' && candidate.machineStatus === 'BLOCKED'
&& candidate.reviewStatus === 'NOT_REQUIRED' && candidate.candidateId
- && }
- {!readOnly && candidate?.machineStatus === 'ERROR' && candidate.candidateId && }
+ && }
+ {candidate?.machineStatus === 'ERROR' && candidate.candidateId && }
{error &&
{error}
}
-
+
)
}
diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json
index 3843f05c..62949c2e 100644
--- a/web/src/i18n/locales/en.json
+++ b/web/src/i18n/locales/en.json
@@ -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"
}
}
diff --git a/web/src/i18n/locales/ru.json b/web/src/i18n/locales/ru.json
index 37ced423..65b95619 100644
--- a/web/src/i18n/locales/ru.json
+++ b/web/src/i18n/locales/ru.json
@@ -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": "Удалится после отправки"
}
}
diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json
index 6ee65bf7..3ee4790c 100644
--- a/web/src/i18n/locales/zh.json
+++ b/web/src/i18n/locales/zh.json
@@ -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": "提交后移除头像"
}
}
diff --git a/web/src/pages/dashboard/my-skills.test.ts b/web/src/pages/dashboard/my-skills.test.ts
index 81c2bb43..28501665 100644
--- a/web/src/pages/dashboard/my-skills.test.ts
+++ b/web/src/pages/dashboard/my-skills.test.ts
@@ -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: {
diff --git a/web/src/pages/dashboard/my-skills.tsx b/web/src/pages/dashboard/my-skills.tsx
index 6b05d2a8..14fdcc17 100644
--- a/web/src/pages/dashboard/my-skills.tsx
+++ b/web/src/pages/dashboard/my-skills.tsx
@@ -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)}
>
-
-
-
- {skill.displayName}
-
+
+
+
+
+
+ {skill.displayName}
+
+
{skill.summary && (
{skill.summary}
)}
@@ -423,11 +424,7 @@ export function MySkillsPage() {
) : null}
-
- {filter !== 'HIDDEN' &&
}
+
{canRestoreHidden ? (
)}
-
-
{
diff --git a/web/src/pages/dashboard/publish-avatar.test.tsx b/web/src/pages/dashboard/publish-avatar.test.tsx
index 84aed94e..9526a269 100644
--- a/web/src/pages/dashboard/publish-avatar.test.tsx
+++ b/web/src/pages/dashboard/publish-avatar.test.tsx
@@ -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('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('@/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()
+ 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(resolve => { finish = resolve }))
+ let finishRefresh!: () => void
+ mocks.refresh.mockReturnValue(new Promise(resolve => { finishRefresh = resolve }))
+ render()
+ 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()
+ 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()
+ 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()
+ fireEvent.click(screen.getByText('image uploaded'))
+ expect(screen.getByRole('button', { name: 'publish.confirm' }).hasAttribute('disabled')).toBe(true)
+ expect(mocks.submit).not.toHaveBeenCalled()
+ })
+
})
diff --git a/web/src/pages/dashboard/publish.test.ts b/web/src/pages/dashboard/publish.test.ts
index 08d4d761..6714e790 100644
--- a/web/src/pages/dashboard/publish.test.ts
+++ b/web/src/pages/dashboard/publish.test.ts
@@ -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(),
diff --git a/web/src/pages/dashboard/publish.tsx b/web/src/pages/dashboard/publish.tsx
index 962a76c1..4bf9ee6e 100644
--- a/web/src/pages/dashboard/publish.tsx
+++ b/web/src/pages/dashboard/publish.tsx
@@ -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(KEEP_AVATAR)
const [selectedFile, setSelectedFile] = useState(null)
const [namespaceSlug, setNamespaceSlug] = useState(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 (
-
+
{prefill.resubmitSkill && prefill.resubmitVersion ? (
@@ -198,7 +225,7 @@ export function PublishPage() {
) : (