feat: add namespace creation dialog

This commit is contained in:
yun-zhi-ztl 2026-03-16 12:23:19 +08:00 • committed by Xudong Sun
parent 93323766db
commit 889a6d3b9d
7 changed files with 335 additions and 3 deletions

View file

@ -21,6 +21,7 @@ import type {
User,
ManagedNamespace,
Namespace,
CreateNamespaceRequest,
} from './types'
import { ApiError } from '@/shared/lib/api-error'
import i18n from '@/i18n/config'
@ -467,6 +468,20 @@ function normalizeNamespaceSlug(namespace: string): string {
}
export const namespaceApi = {
async create(request: CreateNamespaceRequest): Promise<Namespace> {
const namespace = await unwrap<Namespace>(client.POST('/api/v1/namespaces', {
headers: await ensureCsrfHeaders({
'Content-Type': 'application/json',
}),
body: {
slug: normalizeNamespaceSlug(request.slug),
displayName: request.displayName.trim(),
description: request.description?.trim() || undefined,
},
} as never) as never)
return namespace
},
async listMine(): Promise<ManagedNamespace[]> {
return fetchJson<ManagedNamespace[]>(`${WEB_API_PREFIX}/me/namespaces`)
},

View file

@ -61,6 +61,12 @@ export interface ChangePasswordRequest {
newPassword: string
}
export type CreateNamespaceRequest = Omit<components['schemas']['NamespaceRequest'], 'slug' | 'displayName'> & {
slug: string
displayName: string
description?: string
}
export interface MergeInitiateRequest {
secondaryIdentifier: string
}

View file

@ -0,0 +1,239 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import type { CreateNamespaceRequest } from '@/api/types'
import { useCreateNamespace } from '@/shared/hooks/use-skill-queries'
import { toast } from '@/shared/lib/toast'
import { Button } from '@/shared/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/shared/ui/dialog'
import { Input } from '@/shared/ui/input'
import { Label } from '@/shared/ui/label'
import { Textarea } from '@/shared/ui/textarea'
interface CreateNamespaceDialogProps {
children: React.ReactNode
}
type FieldErrors = {
slug?: string
displayName?: string
description?: string
}
const SLUG_PATTERN = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/
const RESERVED_SLUGS = new Set([
'admin',
'api',
'dashboard',
'search',
'auth',
'me',
'global',
'system',
'static',
'assets',
'health',
])
const MAX_SLUG_LENGTH = 64
const MIN_SLUG_LENGTH = 2
const MAX_DISPLAY_NAME_LENGTH = 128
const MAX_DESCRIPTION_LENGTH = 512
function buildFieldErrors(request: CreateNamespaceRequest, t: (key: string, options?: Record<string, unknown>) => string): FieldErrors {
const errors: FieldErrors = {}
const slug = request.slug.trim()
const displayName = request.displayName.trim()
const description = request.description?.trim() ?? ''
if (!slug) {
errors.slug = t('myNamespaces.createSlugRequired')
} else if (slug.length < MIN_SLUG_LENGTH || slug.length > MAX_SLUG_LENGTH) {
errors.slug = t('myNamespaces.createSlugLength', { min: MIN_SLUG_LENGTH, max: MAX_SLUG_LENGTH })
} else if (!SLUG_PATTERN.test(slug)) {
errors.slug = t('myNamespaces.createSlugPattern')
} else if (slug.includes('--')) {
errors.slug = t('myNamespaces.createSlugDoubleHyphen')
} else if (RESERVED_SLUGS.has(slug)) {
errors.slug = t('myNamespaces.createSlugReserved', { slug })
}
if (!displayName) {
errors.displayName = t('myNamespaces.createDisplayNameRequired')
} else if (displayName.length > MAX_DISPLAY_NAME_LENGTH) {
errors.displayName = t('myNamespaces.createDisplayNameLength', { max: MAX_DISPLAY_NAME_LENGTH })
}
if (description.length > MAX_DESCRIPTION_LENGTH) {
errors.description = t('myNamespaces.createDescriptionLength', { max: MAX_DESCRIPTION_LENGTH })
}
return errors
}
export function CreateNamespaceDialog({ children }: CreateNamespaceDialogProps) {
const { t } = useTranslation()
const createMutation = useCreateNamespace()
const [open, setOpen] = useState(false)
const [slug, setSlug] = useState('')
const [displayName, setDisplayName] = useState('')
const [description, setDescription] = useState('')
const [errors, setErrors] = useState<FieldErrors>({})
const resetDialog = () => {
setSlug('')
setDisplayName('')
setDescription('')
setErrors({})
createMutation.reset()
}
const handleOpenChange = (nextOpen: boolean) => {
setOpen(nextOpen)
if (!nextOpen) {
resetDialog()
}
}
const normalizedRequest: CreateNamespaceRequest = {
slug: slug.trim().toLowerCase(),
displayName: displayName.trim(),
description: description.trim() || undefined,
}
const handleSubmit = async () => {
const nextErrors = buildFieldErrors(normalizedRequest, t)
if (Object.keys(nextErrors).length > 0) {
setErrors(nextErrors)
return
}
try {
const namespace = await createMutation.mutateAsync(normalizedRequest)
toast.success(
t('myNamespaces.createSuccessTitle'),
t('myNamespaces.createSuccessDescription', { name: namespace.displayName }),
)
handleOpenChange(false)
} catch (error) {
toast.error(t('myNamespaces.createErrorTitle'), error instanceof Error ? error.message : '')
}
}
const slugLength = slug.trim().length
const displayNameLength = displayName.trim().length
const descriptionLength = description.trim().length
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent>
<DialogHeader className="text-center sm:text-center">
<DialogTitle className="text-center">{t('myNamespaces.createDialogTitle')}</DialogTitle>
<DialogDescription className="text-center">
{t('myNamespaces.createDialogDescription')}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="namespace-slug">{t('myNamespaces.createSlugLabel')}</Label>
<Input
id="namespace-slug"
placeholder={t('myNamespaces.createSlugPlaceholder')}
value={slug}
maxLength={MAX_SLUG_LENGTH}
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
onChange={(event) => {
setSlug(event.target.value.toLowerCase())
if (errors.slug) {
setErrors((current) => ({ ...current, slug: undefined }))
}
}}
onKeyDown={(event) => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault()
handleSubmit()
}
}}
aria-invalid={errors.slug ? 'true' : 'false'}
/>
<div className="flex items-center justify-between gap-3 text-xs">
<span className="text-red-600">{errors.slug ?? t('myNamespaces.createSlugHint')}</span>
<span className="text-muted-foreground">{slugLength}/{MAX_SLUG_LENGTH}</span>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="namespace-display-name">{t('myNamespaces.createDisplayNameLabel')}</Label>
<Input
id="namespace-display-name"
placeholder={t('myNamespaces.createDisplayNamePlaceholder')}
value={displayName}
maxLength={MAX_DISPLAY_NAME_LENGTH}
onChange={(event) => {
setDisplayName(event.target.value)
if (errors.displayName) {
setErrors((current) => ({ ...current, displayName: undefined }))
}
}}
onKeyDown={(event) => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault()
handleSubmit()
}
}}
aria-invalid={errors.displayName ? 'true' : 'false'}
/>
<div className="flex items-center justify-between gap-3 text-xs">
<span className="text-red-600">{errors.displayName ?? ''}</span>
<span className="text-muted-foreground">{displayNameLength}/{MAX_DISPLAY_NAME_LENGTH}</span>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="namespace-description">{t('myNamespaces.createDescriptionLabel')}</Label>
<Textarea
id="namespace-description"
placeholder={t('myNamespaces.createDescriptionPlaceholder')}
value={description}
maxLength={MAX_DESCRIPTION_LENGTH}
onChange={(event) => {
setDescription(event.target.value)
if (errors.description) {
setErrors((current) => ({ ...current, description: undefined }))
}
}}
aria-invalid={errors.description ? 'true' : 'false'}
/>
<div className="flex items-center justify-between gap-3 text-xs">
<span className="text-red-600">{errors.description ?? t('myNamespaces.createDescriptionHint')}</span>
<span className="text-muted-foreground">{descriptionLength}/{MAX_DESCRIPTION_LENGTH}</span>
</div>
</div>
</div>
{createMutation.error ? (
<p className="text-sm text-red-600">{createMutation.error.message}</p>
) : null}
<DialogFooter className="sm:justify-center sm:space-x-3">
<Button variant="outline" onClick={() => handleOpenChange(false)}>
{t('dialog.cancel')}
</Button>
<Button onClick={handleSubmit} disabled={createMutation.isPending}>
{createMutation.isPending ? t('myNamespaces.creating') : t('myNamespaces.createSubmit')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View file

@ -263,6 +263,29 @@
"title": "My Namespaces",
"subtitle": "Manage your namespaces and teams",
"create": "Create Namespace",
"creating": "Creating...",
"createSubmit": "Create Now",
"createDialogTitle": "Create Team Namespace",
"createDialogDescription": "You will become the OWNER after creation and can continue managing members, reviews, and skill publishing.",
"createSlugLabel": "Namespace slug",
"createSlugPlaceholder": "e.g. team-ml",
"createSlugHint": "Use 2-64 lowercase letters, numbers, or hyphens, without leading or trailing hyphens.",
"createSlugRequired": "Namespace slug is required",
"createSlugLength": "Namespace slug must be between {{min}} and {{max}} characters",
"createSlugPattern": "Namespace slug can only contain lowercase letters, numbers, or hyphens",
"createSlugDoubleHyphen": "Namespace slug cannot contain consecutive hyphens",
"createSlugReserved": "\"{{slug}}\" is reserved. Please choose another slug.",
"createDisplayNameLabel": "Display name",
"createDisplayNamePlaceholder": "e.g. Machine Learning Team",
"createDisplayNameRequired": "Display name is required",
"createDisplayNameLength": "Display name must be at most {{max}} characters",
"createDescriptionLabel": "Description",
"createDescriptionPlaceholder": "Briefly describe what this namespace is for, who uses it, or how it is governed",
"createDescriptionHint": "Optional, up to 512 characters.",
"createDescriptionLength": "Description must be at most {{max}} characters",
"createSuccessTitle": "Namespace created",
"createSuccessDescription": "\"{{name}}\" is ready. You can now manage members or publish skills.",
"createErrorTitle": "Failed to create namespace",
"typeGlobal": "Global",
"typeTeam": "Team",
"roleLabel": "Current role",

View file

@ -263,6 +263,29 @@
"title": "我的命名空间",
"subtitle": "管理你的命名空间和团队",
"create": "创建命名空间",
"creating": "创建中...",
"createSubmit": "立即创建",
"createDialogTitle": "创建团队命名空间",
"createDialogDescription": "创建后你会自动成为 OWNER,可以继续管理成员、审核和技能发布。",
"createSlugLabel": "命名空间标识",
"createSlugPlaceholder": "例如 team-ml",
"createSlugHint": "使用 2-64 位小写字母、数字或连字符,且不能以连字符开头或结尾。",
"createSlugRequired": "请输入命名空间标识",
"createSlugLength": "命名空间标识长度需在 {{min}} 到 {{max}} 位之间",
"createSlugPattern": "命名空间标识只能包含小写字母、数字或连字符",
"createSlugDoubleHyphen": "命名空间标识不能包含连续连字符",
"createSlugReserved": "“{{slug}}”是保留标识,请换一个名称",
"createDisplayNameLabel": "显示名称",
"createDisplayNamePlaceholder": "例如 Machine Learning Team",
"createDisplayNameRequired": "请输入显示名称",
"createDisplayNameLength": "显示名称最多 {{max}} 个字符",
"createDescriptionLabel": "描述",
"createDescriptionPlaceholder": "简要说明这个命名空间的用途、成员范围或治理规则",
"createDescriptionHint": "可选,最多 512 个字符。",
"createDescriptionLength": "描述最多 {{max}} 个字符",
"createSuccessTitle": "命名空间已创建",
"createSuccessDescription": "“{{name}}”已经准备好,可以继续管理成员或发布技能。",
"createErrorTitle": "创建命名空间失败",
"typeGlobal": "全局",
"typeTeam": "团队",
"roleLabel": "当前角色",

View file

@ -7,6 +7,7 @@ import { NamespaceBadge } from '@/shared/components/namespace-badge'
import { EmptyState } from '@/shared/components/empty-state'
import { ConfirmDialog } from '@/shared/components/confirm-dialog'
import { DashboardPageHeader } from '@/shared/components/dashboard-page-header'
import { CreateNamespaceDialog } from '@/features/namespace/create-namespace-dialog'
import { useArchiveNamespace, useFreezeNamespace, useMyNamespaces, useRestoreNamespace, useUnfreezeNamespace } from '@/shared/hooks/use-skill-queries'
import { toast } from '@/shared/lib/toast'
@ -159,7 +160,11 @@ export function MyNamespacesPage() {
<DashboardPageHeader
title={t('myNamespaces.title')}
subtitle={t('myNamespaces.subtitle')}
actions={<Button disabled>{t('myNamespaces.create')}</Button>}
actions={(
<CreateNamespaceDialog>
<Button>{t('myNamespaces.create')}</Button>
</CreateNamespaceDialog>
)}
/>
{namespaces && namespaces.length > 0 ? (
@ -273,7 +278,11 @@ export function MyNamespacesPage() {
<EmptyState
title={t('myNamespaces.emptyTitle')}
description={t('myNamespaces.emptyDescription')}
action={<Button disabled>{t('myNamespaces.create')}</Button>}
action={(
<CreateNamespaceDialog>
<Button>{t('myNamespaces.create')}</Button>
</CreateNamespaceDialog>
)}
/>
)}

View file

@ -1,5 +1,5 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import type { SkillSummary, SkillDetail, SkillVersion, SkillVersionDetail, SkillFile, SearchParams, PagedResponse, PublishResult, Namespace, NamespaceMember, ManagedNamespace } from '@/api/types'
import type { SkillSummary, SkillDetail, SkillVersion, SkillVersionDetail, SkillFile, SearchParams, PagedResponse, PublishResult, Namespace, NamespaceMember, ManagedNamespace, CreateNamespaceRequest } from '@/api/types'
import { fetchJson, fetchText, getCsrfHeaders, meApi, namespaceApi, skillLifecycleApi, WEB_API_PREFIX } from '@/api/client'
const PUBLISH_REQUEST_TIMEOUT_MS = 60_000
@ -60,6 +60,10 @@ async function getMyNamespaces(): Promise<ManagedNamespace[]> {
return namespaceApi.listMine()
}
async function createNamespace(request: CreateNamespaceRequest): Promise<Namespace> {
return namespaceApi.create(request)
}
async function getNamespaceDetail(slug: string): Promise<Namespace> {
return namespaceApi.getDetail(slug)
}
@ -155,6 +159,19 @@ export function useMyNamespaces() {
})
}
export function useCreateNamespace() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: createNamespace,
onSuccess: (namespace) => {
queryClient.invalidateQueries({ queryKey: ['namespaces', 'my'] })
queryClient.invalidateQueries({ queryKey: ['namespaces', namespace.slug] })
queryClient.invalidateQueries({ queryKey: ['namespaces'] })
},
})
}
export function useNamespaceDetail(slug: string) {
return useQuery({
queryKey: ['namespaces', slug],