mirror of
https://github.com/langgenius/dify.git
synced 2026-04-26 13:45:57 +08:00
fix: improve workspace edit modal UX
This commit is contained in:
@ -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 () => {
|
||||
|
||||
@ -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
|
||||
|
||||
Reference in New Issue
Block a user