feat(dashboard): add Update button to My Skills with publish prefill

Add an "Update" button to each skill card on the My Skills dashboard
page. Clicking it navigates to the Publish page with the skill's
namespace and visibility pre-selected, reducing manual steps when
re-publishing a skill package.

- Add visibility field to SkillSummaryResponse so the list API exposes
  each skill's current visibility setting
- Add publish-prefill module to normalize and validate URL search params
- Wire TanStack Router validateSearch on the publish route
- Add E2E tests covering the prefill flow and invalid-param fallback
This commit is contained in:
dongmucat 2026-04-22 14:59:01 +08:00
parent 60a30190bf
commit 230b915194
18 changed files with 510 additions and 18 deletions

View file

@ -8,6 +8,7 @@ public record SkillSummaryResponse(
String slug,
String displayName,
String summary,
String visibility,
String status,
Long downloadCount,
Integer starCount,

View file

@ -67,6 +67,7 @@ public class JpaMySkillQueryRepository implements MySkillQueryRepository {
skill.getSlug(),
skill.getDisplayName(),
skill.getSummary(),
skill.getVisibility().name(),
skill.getStatus().name(),
skill.getDownloadCount(),
skill.getStarCount(),

View file

@ -198,6 +198,7 @@ public class SkillSearchAppService {
skill.getSlug(),
skill.getDisplayName(),
skill.getSummary(),
skill.getVisibility().name(),
skill.getStatus().name(),
skill.getDownloadCount(),
skill.getStarCount(),

View file

@ -102,6 +102,7 @@ class ClawHubCompatControllerTest {
"my-skill",
"My Skill",
"test summary",
"PUBLIC",
"ACTIVE",
10L,
5,

View file

@ -43,6 +43,7 @@ class ClawHubRegistryFacadeTest {
"time-skill",
"Time Skill",
"summary",
"PUBLIC",
"ACTIVE",
12L,
3,

View file

@ -63,6 +63,7 @@ class MeControllerTest {
"copilot",
"Copilot",
"Assist with code review",
"PUBLIC",
"ACTIVE",
12L,
3,

View file

@ -0,0 +1,57 @@
import { expect, test } from '@playwright/test'
import { setEnglishLocale } from './helpers/auth-fixtures'
import { registerSession } from './helpers/session'
import { E2eTestDataBuilder } from './helpers/test-data-builder'
test.describe('My Skills Update Prefill (Real API)', () => {
test.beforeEach(async ({ page }, testInfo) => {
await setEnglishLocale(page)
await registerSession(page, testInfo)
})
test('opens publish page with namespace and visibility prefilled from my skills card', async ({ page }, testInfo) => {
const builder = new E2eTestDataBuilder(page, testInfo)
await builder.init()
try {
const namespace = await builder.ensureWritableNamespace()
const skillName = `update-ui-${Date.now().toString(36)}`
await builder.publishSkill(namespace.slug, { name: skillName })
await page.goto('/dashboard/skills')
await expect(page.getByRole('heading', { name: 'My Skills' })).toBeVisible()
const skillCard = page.locator('.group').filter({
has: page.getByRole('heading', { name: skillName, exact: true }),
}).first()
await expect(skillCard).toBeVisible()
await skillCard.getByRole('button', { name: 'Update' }).click()
await expect(page).toHaveURL(/\/dashboard\/publish/)
const currentUrl = new URL(page.url())
expect(currentUrl.searchParams.get('namespace')).toBe(namespace.slug)
expect(currentUrl.searchParams.get('visibility')).toBe('PUBLIC')
await expect(page.locator('#namespace')).toContainText(`@${namespace.slug}`)
await expect(page.locator('#visibility')).toContainText('Public')
} finally {
await builder.cleanup()
}
})
test('falls back to public visibility when publish search params are invalid', async ({ page }, testInfo) => {
const builder = new E2eTestDataBuilder(page, testInfo)
await builder.init()
try {
const namespace = await builder.ensureWritableNamespace()
await page.goto(`/dashboard/publish?namespace=${encodeURIComponent(namespace.slug)}&visibility=internal`)
await expect(page.getByRole('heading', { name: 'Publish Skill' })).toBeVisible()
await expect(page.locator('#namespace')).toContainText(`@${namespace.slug}`)
await expect(page.locator('#visibility')).toContainText('Public')
} finally {
await builder.cleanup()
}
})
})

View file

@ -468,6 +468,38 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/v1/skills/{namespace}/{slug}/submit-review": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: operations["submitForReview"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/web/skills/{namespace}/{slug}/submit-review": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: operations["submitForReview_1"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/skills/{namespace}/{slug}/reports": {
parameters: {
query?: never;
@ -500,6 +532,38 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/web/skills/{namespace}/{slug}/confirm-publish": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: operations["confirmPublish"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/skills/{namespace}/{slug}/confirm-publish": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: operations["confirmPublish_1"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/skills/{namespace}/{slug}/archive": {
parameters: {
query?: never;
@ -3239,6 +3303,10 @@ export interface components {
targetVersion: string;
confirmWarnings?: boolean;
};
SubmitReviewRequest: {
version: string;
targetVisibility: string;
};
SkillReportSubmitRequest: {
reason?: string;
details?: string;
@ -3257,6 +3325,9 @@ export interface components {
reportId?: number;
status?: string;
};
ConfirmPublishRequest: {
version: string;
};
AdminSkillActionRequest: {
reason?: string;
};
@ -3648,6 +3719,7 @@ export interface components {
slug?: string;
displayName?: string;
summary?: string;
visibility?: string;
status?: string;
/** Format: int64 */
downloadCount?: number;
@ -5569,6 +5641,60 @@ export interface operations {
};
};
};
submitForReview: {
parameters: {
query?: never;
header?: never;
path: {
namespace: string;
slug: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["SubmitReviewRequest"];
};
};
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["ApiResponseSkillLifecycleMutationResponse"];
};
};
};
};
submitForReview_1: {
parameters: {
query?: never;
header?: never;
path: {
namespace: string;
slug: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["SubmitReviewRequest"];
};
};
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["ApiResponseSkillLifecycleMutationResponse"];
};
};
};
};
submitReport: {
parameters: {
query?: never;
@ -5623,6 +5749,60 @@ export interface operations {
};
};
};
confirmPublish: {
parameters: {
query?: never;
header?: never;
path: {
namespace: string;
slug: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["ConfirmPublishRequest"];
};
};
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["ApiResponseSkillLifecycleMutationResponse"];
};
};
};
};
confirmPublish_1: {
parameters: {
query?: never;
header?: never;
path: {
namespace: string;
slug: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["ConfirmPublishRequest"];
};
};
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["ApiResponseSkillLifecycleMutationResponse"];
};
};
};
};
archiveSkill: {
parameters: {
query?: never;

View file

@ -145,6 +145,7 @@ export interface SkillSummary {
slug: string
displayName: string
summary?: string
visibility?: string
status?: string
downloadCount: number
starCount: number

View file

@ -248,6 +248,10 @@ const dashboardPublishRoute = createRoute({
getParentRoute: () => rootRoute,
path: 'dashboard/publish',
beforeLoad: requireAuth,
validateSearch: (search: Record<string, unknown>): { namespace?: string; visibility?: string } => ({
namespace: typeof search.namespace === 'string' && search.namespace ? search.namespace : undefined,
visibility: typeof search.visibility === 'string' && search.visibility ? search.visibility : undefined,
}),
component: PublishPage,
})

View file

@ -0,0 +1,33 @@
import { describe, expect, it } from 'vitest'
import { normalizePublishPrefill } from './publish-prefill'
describe('normalizePublishPrefill', () => {
it('keeps namespace and normalizes visibility for valid route search params', () => {
expect(normalizePublishPrefill({
namespace: 'team-ai',
visibility: 'private',
})).toEqual({
namespace: 'team-ai',
visibility: 'PRIVATE',
})
})
it('falls back to PUBLIC when visibility is missing or invalid', () => {
expect(normalizePublishPrefill({
namespace: 'team-ai',
visibility: 'internal',
})).toEqual({
namespace: 'team-ai',
visibility: 'PUBLIC',
})
})
it('trims namespace input from search params', () => {
expect(normalizePublishPrefill({
namespace: ' team-ml ',
})).toEqual({
namespace: 'team-ml',
visibility: 'PUBLIC',
})
})
})

View file

@ -0,0 +1,23 @@
const VALID_VISIBILITIES = new Set(['PUBLIC', 'NAMESPACE_ONLY', 'PRIVATE'])
interface PublishPrefillSearch {
namespace?: string
visibility?: string
}
export interface PublishPrefillState {
namespace: string
visibility: string
}
export function normalizePublishPrefill(search: PublishPrefillSearch): PublishPrefillState {
const namespace = typeof search.namespace === 'string' ? search.namespace.trim() : ''
const normalizedVisibility = typeof search.visibility === 'string'
? search.visibility.trim().toUpperCase()
: ''
return {
namespace,
visibility: VALID_VISIBILITIES.has(normalizedVisibility) ? normalizedVisibility : 'PUBLIC',
}
}

View file

@ -340,6 +340,7 @@
"HIDDEN": "Hidden"
},
"publishNew": "Publish New Skill",
"update": "Update",
"archive": "Archive",
"unarchive": "Restore",
"statusArchived": "Archived",

View file

@ -340,6 +340,7 @@
"HIDDEN": "已隐藏"
},
"publishNew": "发布新技能",
"update": "更新",
"archive": "归档",
"unarchive": "恢复",
"statusArchived": "已归档",

View file

@ -1,7 +1,13 @@
import { describe, expect, it, vi } from 'vitest'
import { createElement, type ReactNode } from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const navigateMock = vi.fn()
const buttonRecords: Array<{ label: string; onClick?: ((event?: { stopPropagation: () => void }) => void) | undefined }> = []
const useMySkillsMock = vi.fn()
vi.mock('@tanstack/react-router', () => ({
useNavigate: () => vi.fn(),
useNavigate: () => navigateMock,
}))
vi.mock('react-i18next', async () => {
@ -19,15 +25,25 @@ vi.mock('@/features/auth/use-auth', () => ({
}))
vi.mock('@/shared/ui/button', () => ({
Button: ({ children }: { children: unknown }) => children,
Button: ({
children,
onClick,
}: {
children?: ReactNode
onClick?: (event?: { stopPropagation: () => void }) => void
}) => {
const label = Array.isArray(children) ? children.join('') : String(children ?? '')
buttonRecords.push({ label, onClick })
return createElement('button', null, children)
},
}))
vi.mock('@/shared/ui/card', () => ({
Card: ({ children }: { children: unknown }) => children,
Card: ({ children }: { children: ReactNode }) => createElement('div', null, children),
}))
vi.mock('@/shared/components/empty-state', () => ({
EmptyState: () => null,
EmptyState: () => createElement('div', null, 'empty-state'),
}))
vi.mock('@/shared/components/confirm-dialog', () => ({
@ -35,7 +51,7 @@ vi.mock('@/shared/components/confirm-dialog', () => ({
}))
vi.mock('@/shared/components/dashboard-page-header', () => ({
DashboardPageHeader: () => null,
DashboardPageHeader: ({ actions }: { actions?: ReactNode }) => createElement('div', null, actions),
}))
vi.mock('@/shared/components/pagination', () => ({
@ -49,16 +65,13 @@ vi.mock('@/shared/hooks/use-skill-queries', () => ({
}))
vi.mock('@/shared/hooks/use-user-queries', () => ({
useMySkills: () => ({
data: { items: [], total: 0, page: 0, size: 10 },
isLoading: false,
}),
useMySkills: () => useMySkillsMock(),
useSubmitPromotion: () => ({ mutateAsync: vi.fn(), isPending: false }),
}))
vi.mock('@/shared/lib/skill-lifecycle', () => ({
getHeadlineVersion: () => null,
getPublishedVersion: () => null,
getHeadlineVersion: () => ({ id: 11, version: '1.0.0', status: 'PUBLISHED' }),
getPublishedVersion: () => ({ id: 11, version: '1.0.0', status: 'PUBLISHED' }),
getOwnerPreviewVersion: () => null,
hasPendingOwnerPreview: () => false,
}))
@ -79,7 +92,120 @@ vi.mock('@/api/client', () => ({
import { MySkillsPage } from './my-skills'
function findButton(label: string) {
const record = buttonRecords.find((item) => item.label === label)
if (!record) {
throw new Error(`Missing button: ${label}`)
}
return record
}
describe('MySkillsPage', () => {
beforeEach(() => {
navigateMock.mockReset()
buttonRecords.length = 0
useMySkillsMock.mockReturnValue({
data: {
items: [
{
id: 1,
displayName: 'Team Agent',
summary: 'summary',
namespace: 'team-ai',
slug: 'team-agent',
downloadCount: 42,
status: 'PUBLISHED',
visibility: 'PRIVATE',
canSubmitPromotion: false,
},
],
total: 1,
page: 0,
size: 10,
},
isLoading: false,
})
})
it('navigates to publish page with namespace and visibility when update is clicked', () => {
renderToStaticMarkup(createElement(MySkillsPage))
const stopPropagation = vi.fn()
findButton('mySkills.update').onClick?.({ stopPropagation })
expect(stopPropagation).toHaveBeenCalledTimes(1)
expect(navigateMock).toHaveBeenCalledWith({
to: '/dashboard/publish',
search: {
namespace: 'team-ai',
visibility: 'PRIVATE',
},
})
})
it('does not render update action for archived skills', () => {
useMySkillsMock.mockReturnValue({
data: {
items: [
{
id: 2,
displayName: 'Archived Agent',
summary: 'summary',
namespace: 'team-ai',
slug: 'archived-agent',
downloadCount: 7,
status: 'ARCHIVED',
visibility: 'PUBLIC',
canSubmitPromotion: false,
},
],
total: 1,
page: 0,
size: 10,
},
isLoading: false,
})
renderToStaticMarkup(createElement(MySkillsPage))
expect(buttonRecords.some((button) => button.label === 'mySkills.update')).toBe(false)
})
it('falls back to public visibility when the skill card data has no visibility field', () => {
useMySkillsMock.mockReturnValue({
data: {
items: [
{
id: 3,
displayName: 'Default Visibility Agent',
summary: 'summary',
namespace: 'team-ai',
slug: 'default-visibility-agent',
downloadCount: 9,
status: 'PUBLISHED',
canSubmitPromotion: false,
},
],
total: 1,
page: 0,
size: 10,
},
isLoading: false,
})
renderToStaticMarkup(createElement(MySkillsPage))
findButton('mySkills.update').onClick?.({ stopPropagation: vi.fn() })
expect(navigateMock).toHaveBeenCalledWith({
to: '/dashboard/publish',
search: {
namespace: 'team-ai',
visibility: 'PUBLIC',
},
})
})
it('exports a named component function', () => {
expect(typeof MySkillsPage).toBe('function')
})

View file

@ -61,6 +61,13 @@ export function MySkillsPage() {
})
}
const handleUpdateSkill = (namespace: string, visibility?: string) => {
navigate({
to: '/dashboard/publish',
search: { namespace, visibility: visibility || 'PUBLIC' },
})
}
const resolveStatusLabel = (status?: string) => {
if (status === 'HIDDEN') {
return t('mySkills.statusHidden')
@ -306,6 +313,18 @@ export function MySkillsPage() {
</div>
</div>
<div className="flex items-center gap-2 pl-4">
{skill.status !== 'ARCHIVED' && (
<Button
size="sm"
variant="outline"
onClick={(event) => {
event.stopPropagation()
handleUpdateSkill(skill.namespace, skill.visibility ?? 'PUBLIC')
}}
>
{t('mySkills.update')}
</Button>
)}
{hasPendingPreview && ownerPreviewVersion ? (
<Button
size="sm"

View file

@ -1,7 +1,13 @@
import { describe, expect, it, vi } from 'vitest'
import { createElement } from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const useSearchMock = vi.fn()
const selectRecords: Array<{ value?: string }> = []
vi.mock('@tanstack/react-router', () => ({
useNavigate: () => vi.fn(),
useSearch: () => useSearchMock(),
}))
vi.mock('react-i18next', async () => {
@ -23,7 +29,10 @@ vi.mock('@/shared/ui/button', () => ({
}))
vi.mock('@/shared/ui/select', () => ({
Select: ({ children }: { children: unknown }) => children,
Select: ({ children, value }: { children: unknown; value?: string }) => {
selectRecords.push({ value })
return children
},
SelectContent: ({ children }: { children: unknown }) => children,
SelectItem: ({ children }: { children: unknown }) => children,
SelectTrigger: ({ children }: { children: unknown }) => children,
@ -64,6 +73,30 @@ vi.mock('@/api/client', () => ({
import { PublishPage } from './publish'
describe('PublishPage', () => {
beforeEach(() => {
selectRecords.length = 0
useSearchMock.mockReturnValue({
namespace: ' team-ai ',
visibility: 'private',
})
})
it('prefills namespace and visibility from route search params', () => {
renderToStaticMarkup(createElement(PublishPage))
expect(selectRecords[0]?.value).toBe('team-ai')
expect(selectRecords[1]?.value).toBe('PRIVATE')
})
it('falls back to public visibility when search params are missing', () => {
useSearchMock.mockReturnValue({})
renderToStaticMarkup(createElement(PublishPage))
expect(selectRecords[0]?.value).toBe('__select_namespace__')
expect(selectRecords[1]?.value).toBe('PUBLIC')
})
it('exports a named component function', () => {
expect(typeof PublishPage).toBe('function')
})

View file

@ -1,5 +1,5 @@
import { useState } from 'react'
import { useNavigate } from '@tanstack/react-router'
import { useEffect, useState } from 'react'
import { useNavigate, useSearch } from '@tanstack/react-router'
import { useTranslation } from 'react-i18next'
import { UploadZone } from '@/features/publish/upload-zone'
import {
@ -9,6 +9,7 @@ import {
isPrecheckFailureMessage,
isVersionExistsMessage,
} from '@/features/publish/publish-error-utils'
import { normalizePublishPrefill } from '@/features/publish/publish-prefill'
import { Button } from '@/shared/ui/button'
import {
Select,
@ -32,9 +33,11 @@ const EMPTY_NAMESPACE_VALUE = '__select_namespace__'
export function PublishPage() {
const { t } = useTranslation()
const navigate = useNavigate()
const search = useSearch({ from: '/dashboard/publish' })
const prefill = normalizePublishPrefill(search)
const [selectedFile, setSelectedFile] = useState<File | null>(null)
const [namespaceSlug, setNamespaceSlug] = useState<string>('')
const [visibility, setVisibility] = useState<string>('PUBLIC')
const [namespaceSlug, setNamespaceSlug] = useState<string>(prefill.namespace)
const [visibility, setVisibility] = useState<string>(prefill.visibility)
const [warningDialogOpen, setWarningDialogOpen] = useState(false)
const [precheckWarnings, setPrecheckWarnings] = useState<string[]>([])
@ -45,6 +48,11 @@ export function PublishPage() {
? t('publish.visibilityOptions.loggedInUsersOnly')
: t('publish.visibilityOptions.namespaceOnly')
useEffect(() => {
setNamespaceSlug(prefill.namespace)
setVisibility(prefill.visibility)
}, [prefill.namespace, prefill.visibility])
const handleRemoveSelectedFile = () => {
setSelectedFile(null)
setPrecheckWarnings([])