From 230b9151943efa5519a02e505350ea3541a5951e Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Wed, 22 Apr 2026 14:59:01 +0800 Subject: [PATCH] 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 --- .../skillhub/dto/SkillSummaryResponse.java | 1 + .../repository/JpaMySkillQueryRepository.java | 1 + .../service/SkillSearchAppService.java | 1 + .../compat/ClawHubCompatControllerTest.java | 1 + .../compat/ClawHubRegistryFacadeTest.java | 1 + .../skillhub/controller/MeControllerTest.java | 1 + web/e2e/my-skills-update-prefill.spec.ts | 57 ++++++ web/src/api/generated/schema.d.ts | 180 ++++++++++++++++++ web/src/api/types.ts | 1 + web/src/app/router.tsx | 4 + .../features/publish/publish-prefill.test.ts | 33 ++++ web/src/features/publish/publish-prefill.ts | 23 +++ web/src/i18n/locales/en.json | 1 + web/src/i18n/locales/zh.json | 1 + web/src/pages/dashboard/my-skills.test.ts | 150 +++++++++++++-- web/src/pages/dashboard/my-skills.tsx | 19 ++ web/src/pages/dashboard/publish.test.ts | 37 +++- web/src/pages/dashboard/publish.tsx | 16 +- 18 files changed, 510 insertions(+), 18 deletions(-) create mode 100644 web/e2e/my-skills-update-prefill.spec.ts create mode 100644 web/src/features/publish/publish-prefill.test.ts create mode 100644 web/src/features/publish/publish-prefill.ts diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillSummaryResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillSummaryResponse.java index 5d699a45..0948756e 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillSummaryResponse.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillSummaryResponse.java @@ -8,6 +8,7 @@ public record SkillSummaryResponse( String slug, String displayName, String summary, + String visibility, String status, Long downloadCount, Integer starCount, diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/JpaMySkillQueryRepository.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/JpaMySkillQueryRepository.java index a8609fea..d9c97041 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/JpaMySkillQueryRepository.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/JpaMySkillQueryRepository.java @@ -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(), diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillSearchAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillSearchAppService.java index 820980bb..bffa778a 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillSearchAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillSearchAppService.java @@ -198,6 +198,7 @@ public class SkillSearchAppService { skill.getSlug(), skill.getDisplayName(), skill.getSummary(), + skill.getVisibility().name(), skill.getStatus().name(), skill.getDownloadCount(), skill.getStarCount(), diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubCompatControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubCompatControllerTest.java index 607e3d65..cb4c0fc8 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubCompatControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubCompatControllerTest.java @@ -102,6 +102,7 @@ class ClawHubCompatControllerTest { "my-skill", "My Skill", "test summary", + "PUBLIC", "ACTIVE", 10L, 5, diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubRegistryFacadeTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubRegistryFacadeTest.java index a381a39d..27d825dc 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubRegistryFacadeTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubRegistryFacadeTest.java @@ -43,6 +43,7 @@ class ClawHubRegistryFacadeTest { "time-skill", "Time Skill", "summary", + "PUBLIC", "ACTIVE", 12L, 3, diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/MeControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/MeControllerTest.java index de29b4f7..af16f4dd 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/MeControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/MeControllerTest.java @@ -63,6 +63,7 @@ class MeControllerTest { "copilot", "Copilot", "Assist with code review", + "PUBLIC", "ACTIVE", 12L, 3, diff --git a/web/e2e/my-skills-update-prefill.spec.ts b/web/e2e/my-skills-update-prefill.spec.ts new file mode 100644 index 00000000..8aaa9091 --- /dev/null +++ b/web/e2e/my-skills-update-prefill.spec.ts @@ -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() + } + }) +}) diff --git a/web/src/api/generated/schema.d.ts b/web/src/api/generated/schema.d.ts index d8714025..27fb3c1b 100644 --- a/web/src/api/generated/schema.d.ts +++ b/web/src/api/generated/schema.d.ts @@ -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; diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 2b2f9382..be4a9938 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -145,6 +145,7 @@ export interface SkillSummary { slug: string displayName: string summary?: string + visibility?: string status?: string downloadCount: number starCount: number diff --git a/web/src/app/router.tsx b/web/src/app/router.tsx index 4095904d..73cb0ff3 100644 --- a/web/src/app/router.tsx +++ b/web/src/app/router.tsx @@ -248,6 +248,10 @@ const dashboardPublishRoute = createRoute({ getParentRoute: () => rootRoute, path: 'dashboard/publish', beforeLoad: requireAuth, + validateSearch: (search: Record): { 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, }) diff --git a/web/src/features/publish/publish-prefill.test.ts b/web/src/features/publish/publish-prefill.test.ts new file mode 100644 index 00000000..8152c711 --- /dev/null +++ b/web/src/features/publish/publish-prefill.test.ts @@ -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', + }) + }) +}) diff --git a/web/src/features/publish/publish-prefill.ts b/web/src/features/publish/publish-prefill.ts new file mode 100644 index 00000000..629b1339 --- /dev/null +++ b/web/src/features/publish/publish-prefill.ts @@ -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', + } +} diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index c96495f9..5d51b7e3 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -340,6 +340,7 @@ "HIDDEN": "Hidden" }, "publishNew": "Publish New Skill", + "update": "Update", "archive": "Archive", "unarchive": "Restore", "statusArchived": "Archived", diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index 12219285..42eed50a 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -340,6 +340,7 @@ "HIDDEN": "已隐藏" }, "publishNew": "发布新技能", + "update": "更新", "archive": "归档", "unarchive": "恢复", "statusArchived": "已归档", diff --git a/web/src/pages/dashboard/my-skills.test.ts b/web/src/pages/dashboard/my-skills.test.ts index 17ffa2f4..f2c58112 100644 --- a/web/src/pages/dashboard/my-skills.test.ts +++ b/web/src/pages/dashboard/my-skills.test.ts @@ -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') }) diff --git a/web/src/pages/dashboard/my-skills.tsx b/web/src/pages/dashboard/my-skills.tsx index 5f437cd7..78192c32 100644 --- a/web/src/pages/dashboard/my-skills.tsx +++ b/web/src/pages/dashboard/my-skills.tsx @@ -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() {
+ {skill.status !== 'ARCHIVED' && ( + + )} {hasPendingPreview && ownerPreviewVersion ? (