fix: improve workspace edit modal UX

This commit is contained in:
yyh
2026-03-12 11:53:12 +08:00
parent cb8e20786a
commit fee6d13f44
2 changed files with 115 additions and 53 deletions

View File

@ -51,30 +51,13 @@ describe('EditWorkspaceModal', () => {
renderModal()
const input = screen.getByPlaceholderText(/account\.workspaceNamePlaceholder/i)
const input = screen.getByLabelText(/account\.workspaceName/i)
await user.clear(input)
await user.type(input, 'New Workspace Name')
expect(input).toHaveValue('New Workspace Name')
})
it('should reset name to current workspace name when cleared', async () => {
const user = userEvent.setup()
renderModal()
const input = screen.getByPlaceholderText(/account\.workspaceNamePlaceholder/i)
await user.clear(input)
await user.type(input, 'New Workspace Name')
expect(input).toHaveValue('New Workspace Name')
// Click the clear button (Input component clear button)
const clearBtn = screen.getByTestId('input-clear')
await user.click(clearBtn)
expect(input).toHaveValue('Test Workspace')
})
it('should submit update when confirming as owner', async () => {
const user = userEvent.setup()
const mockAssign = vi.fn()
@ -83,10 +66,10 @@ describe('EditWorkspaceModal', () => {
renderModal()
const input = screen.getByPlaceholderText(/account\.workspaceNamePlaceholder/i)
const input = screen.getByLabelText(/account\.workspaceName/i)
await user.clear(input)
await user.type(input, 'Renamed Workspace')
await user.click(screen.getByTestId('edit-workspace-confirm'))
await user.click(screen.getByTestId('edit-workspace-save'))
await waitFor(() => {
expect(updateWorkspaceInfo).toHaveBeenCalledWith({
@ -95,6 +78,8 @@ describe('EditWorkspaceModal', () => {
})
expect(mockAssign).toHaveBeenCalledWith('http://localhost')
})
expect(mockOnCancel).not.toHaveBeenCalled()
})
it('should show error toast when update fails', async () => {
@ -104,7 +89,10 @@ describe('EditWorkspaceModal', () => {
renderModal()
await user.click(screen.getByTestId('edit-workspace-confirm'))
const input = screen.getByLabelText(/account\.workspaceName/i)
await user.clear(input)
await user.type(input, 'Broken Workspace')
await user.click(screen.getByTestId('edit-workspace-save'))
await waitFor(() => {
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({
@ -113,6 +101,25 @@ describe('EditWorkspaceModal', () => {
})
})
it('should disable save button when there are no changes', async () => {
renderModal()
expect(screen.getByTestId('edit-workspace-save')).toBeDisabled()
})
it('should disable save button and show error when the name is empty', async () => {
const user = userEvent.setup()
renderModal()
const input = screen.getByLabelText(/account\.workspaceName/i)
await user.clear(input)
expect(screen.getByTestId('edit-workspace-save')).toBeDisabled()
expect(input).toHaveAttribute('aria-invalid', 'true')
expect(screen.getByTestId('edit-workspace-error')).toBeInTheDocument()
})
it('should disable confirm button for non-owners', async () => {
vi.mocked(useAppContext).mockReturnValue({
currentWorkspace: { name: 'Test Workspace' } as ICurrentWorkspace,
@ -121,7 +128,7 @@ describe('EditWorkspaceModal', () => {
renderModal()
expect(screen.getByTestId('edit-workspace-confirm')).toBeDisabled()
expect(screen.getByTestId('edit-workspace-save')).toBeDisabled()
})
it('should call onCancel when close icon is clicked', async () => {

View File

@ -1,17 +1,19 @@
'use client'
import { useState } from 'react'
import { useId, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import Button from '@/app/components/base/button'
import Input from '@/app/components/base/input'
import { ToastContext } from '@/app/components/base/toast/context'
import { Dialog, DialogContent } from '@/app/components/base/ui/dialog'
import { Dialog, DialogCloseButton, DialogContent, DialogTitle } from '@/app/components/base/ui/dialog'
import { useAppContext } from '@/context/app-context'
import { updateWorkspaceInfo } from '@/service/common'
import { cn } from '@/utils/classnames'
type IEditWorkspaceModalProps = {
onCancel: () => void
}
const EditWorkspaceModal = ({
onCancel,
}: IEditWorkspaceModalProps) => {
@ -19,13 +21,33 @@ const EditWorkspaceModal = ({
const { notify } = useContext(ToastContext)
const { currentWorkspace, isCurrentWorkspaceOwner } = useAppContext()
const [name, setName] = useState<string>(currentWorkspace.name)
const [isSubmitting, setIsSubmitting] = useState(false)
const inputId = useId()
const errorId = useId()
const normalizedName = name.trim()
const hasChanges = normalizedName !== currentWorkspace.name
const hasError = normalizedName.length === 0
const isSaveDisabled = !isCurrentWorkspaceOwner || !hasChanges || hasError || isSubmitting
const nameErrorMessage = useMemo(() => {
if (!hasError)
return ''
const changeWorkspaceInfo = async (name: string) => {
return t('errorMsg.fieldRequired', {
ns: 'common',
field: t('account.workspaceName', { ns: 'common' }),
})
}, [hasError, t])
const changeWorkspaceInfo = async () => {
if (isSaveDisabled)
return
setIsSubmitting(true)
try {
await updateWorkspaceInfo({
url: '/workspaces/info',
body: {
name,
name: normalizedName,
},
})
notify({ type: 'success', message: t('actionMsg.modifiedSuccessfully', { ns: 'common' }) })
@ -34,6 +56,9 @@ const EditWorkspaceModal = ({
catch {
notify({ type: 'error', message: t('actionMsg.modifiedUnsuccessfully', { ns: 'common' }) })
}
finally {
setIsSubmitting(false)
}
}
return (
@ -48,28 +73,57 @@ const EditWorkspaceModal = ({
backdropProps={{ forceRender: true }}
className="overflow-visible"
>
<div className="mb-2 flex justify-between">
<div className="text-xl font-semibold text-text-primary" data-testid="edit-workspace-title">{t('account.editWorkspaceInfo', { ns: 'common' })}</div>
<div className="i-ri-close-line h-4 w-4 cursor-pointer text-text-tertiary" data-testid="edit-workspace-close" onClick={onCancel} />
</div>
<div>
<div className="mb-2 text-sm font-medium text-text-primary">{t('account.workspaceName', { ns: 'common' })}</div>
<Input
className="mb-2"
value={name}
placeholder={t('account.workspaceNamePlaceholder', { ns: 'common' })}
onChange={(e) => {
setName(e.target.value)
}}
onClear={() => {
setName(currentWorkspace.name)
}}
showClearIcon
/>
<DialogCloseButton data-testid="edit-workspace-close" />
<form
className="flex flex-col"
onSubmit={(e) => {
e.preventDefault()
void changeWorkspaceInfo()
}}
>
<div className="mb-4 pr-8">
<DialogTitle className="text-xl font-semibold text-text-primary" data-testid="edit-workspace-title">
{t('account.editWorkspaceInfo', { ns: 'common' })}
</DialogTitle>
</div>
<div className="space-y-2">
<label htmlFor={inputId} className="block text-sm font-medium text-text-primary">
{t('account.workspaceName', { ns: 'common' })}
</label>
<Input
id={inputId}
autoFocus
value={name}
placeholder={t('account.workspaceNamePlaceholder', { ns: 'common' })}
onChange={(e) => {
setName(e.target.value)
}}
aria-invalid={hasError}
aria-describedby={hasError ? errorId : undefined}
className={cn(
hasError && 'border-components-input-border-destructive bg-components-input-bg-destructive hover:border-components-input-border-destructive hover:bg-components-input-bg-destructive focus:border-components-input-border-destructive focus:bg-components-input-bg-destructive',
)}
/>
<div className="min-h-6">
{hasError && (
<p
id={errorId}
data-testid="edit-workspace-error"
className="text-text-destructive system-xs-regular"
role="alert"
>
{nameErrorMessage}
</p>
)}
</div>
</div>
<div className="sticky bottom-0 -mx-2 mt-2 flex flex-wrap items-center justify-end gap-x-2 bg-components-panel-bg px-2 pt-4">
<Button
size="large"
type="button"
data-testid="edit-workspace-cancel"
onClick={onCancel}
>
@ -77,21 +131,22 @@ const EditWorkspaceModal = ({
</Button>
<Button
size="large"
type="submit"
variant="primary"
data-testid="edit-workspace-confirm"
onClick={() => {
changeWorkspaceInfo(name)
onCancel()
}}
disabled={!isCurrentWorkspaceOwner}
data-testid="edit-workspace-save"
disabled={isSaveDisabled}
loading={isSubmitting}
>
{t('operation.confirm', { ns: 'common' })}
{t(
isSubmitting ? 'operation.saving' : 'operation.save',
{ ns: 'common' },
)}
</Button>
</div>
</div>
</form>
</DialogContent>
</Dialog>
)
}
export default EditWorkspaceModal