Skip to content

Commit 409bb29

Browse files
authored
refactor(web): model app settings with form primitives (#44073)
1 parent 65d9761 commit 409bb29

2 files changed

Lines changed: 320 additions & 453 deletions

File tree

‎web/app/components/app/overview/settings/__tests__/index.spec.tsx‎

Lines changed: 81 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { AppSiteUpdatePayload } from '@dify/contracts/api/console/apps/types.gen'
22
import type { ReactElement, ReactNode } from 'react'
33
import type { SettingsAppInfo } from '../index'
4-
import { act, fireEvent, screen, waitFor } from '@testing-library/react'
4+
import { act, fireEvent, screen, waitFor, within } from '@testing-library/react'
55
import userEvent from '@testing-library/user-event'
66
import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
77
import { consoleQuery } from '@/service/console'
@@ -39,32 +39,6 @@ vi.mock('react-i18next', async () => {
3939
}
4040
})
4141

42-
const toastMocks = vi.hoisted(() => ({
43-
call: vi.fn(),
44-
dismiss: vi.fn(),
45-
update: vi.fn(),
46-
promise: vi.fn(),
47-
}))
48-
49-
vi.mock('@/app/notifications', () => ({
50-
toast: Object.assign(toastMocks.call, {
51-
success: vi.fn((message: string, options?: Record<string, unknown>) =>
52-
toastMocks.call({ type: 'success', message, ...options }),
53-
),
54-
error: vi.fn((message: string, options?: Record<string, unknown>) =>
55-
toastMocks.call({ type: 'error', message, ...options }),
56-
),
57-
warning: vi.fn((message: string, options?: Record<string, unknown>) =>
58-
toastMocks.call({ type: 'warning', message, ...options }),
59-
),
60-
info: vi.fn((message: string, options?: Record<string, unknown>) =>
61-
toastMocks.call({ type: 'info', message, ...options }),
62-
),
63-
dismiss: toastMocks.dismiss,
64-
update: toastMocks.update,
65-
promise: toastMocks.promise,
66-
}),
67-
}))
6842
const mockOnSave = vi.fn<(params: AppSiteUpdatePayload) => Promise<boolean>>()
6943

7044
vi.mock('@/context/i18n', async () => {
@@ -121,7 +95,6 @@ function render(...args: Parameters<typeof renderWithData>) {
12195

12296
describe('SettingsDialog', () => {
12397
beforeEach(() => {
124-
toastMocks.call.mockClear()
12598
mockOnSave.mockReset().mockResolvedValue(true)
12699
onPricingUrlUpdate.mockClear()
127100
deploymentEdition = 'CLOUD'
@@ -190,7 +163,7 @@ describe('SettingsDialog', () => {
190163
)
191164
})
192165

193-
it('names the Inverted switch from its visible label and exposes its state', async () => {
166+
it('toggles inversion through its label without changing the theme color', async () => {
194167
const user = userEvent.setup()
195168
await renderSettingsDialog()
196169

@@ -199,58 +172,84 @@ describe('SettingsDialog', () => {
199172
})
200173
expect(invertedSwitch).toBeChecked()
201174

202-
await user.click(invertedSwitch)
175+
await user.click(
176+
screen.getByText('appOverview.overview.appInfo.settings.chatColorThemeInverted'),
177+
)
203178

204179
expect(invertedSwitch).not.toBeChecked()
180+
expect(
181+
screen.getByRole('textbox', { name: 'appOverview.overview.appInfo.settings.chatColorTheme' }),
182+
).toHaveValue('#123456')
205183
})
206184

207-
it('should notify the user when the name is empty', async () => {
208-
await renderSettingsDialog()
209-
const nameInput = screen.getByPlaceholderText('app.appNamePlaceholder')
210-
fireEvent.change(nameInput, { target: { value: '' } })
211-
fireEvent.click(screen.getByText('common.operation.save'))
212-
213-
await waitFor(() => {
214-
expect(toastMocks.call).toHaveBeenCalledWith(
215-
expect.objectContaining({ message: 'app.newApp.nameNotEmpty' }),
216-
)
217-
})
218-
expect(mockOnSave).not.toHaveBeenCalled()
219-
})
185+
it.each([
186+
{
187+
label: 'webName',
188+
invalid: '',
189+
valid: 'Updated app',
190+
error: 'app.newApp.nameNotEmpty',
191+
},
192+
{
193+
label: 'chatColorTheme',
194+
invalid: '#123456extra',
195+
valid: '#A020F0',
196+
error: 'appOverview.overview.appInfo.settings.invalidHexMessage',
197+
},
198+
{
199+
label: 'more.privacyPolicy',
200+
invalid: 'ftp://invalid-url',
201+
valid: 'https://example.com/privacy',
202+
error: 'appOverview.overview.appInfo.settings.invalidPrivacyPolicy',
203+
},
204+
])(
205+
'associates the $label validation error with its control and permits correction',
206+
async ({ label, invalid, valid, error }) => {
207+
const user = userEvent.setup()
208+
await renderSettingsDialog()
209+
const input = screen.getByRole('textbox', {
210+
name: `appOverview.overview.appInfo.settings.${label}`,
211+
})
212+
await user.clear(input)
213+
if (invalid) await user.type(input, invalid)
214+
await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
220215

221-
it('should validate the theme color and show an error when the hex is invalid', async () => {
222-
await renderSettingsDialog()
223-
const colorInput = screen.getByPlaceholderText('E.g #A020F0')
224-
fireEvent.change(colorInput, { target: { value: 'not-a-hex' } })
216+
expect(await screen.findByText(error)).toBeVisible()
217+
expect(input).toHaveAttribute('aria-invalid', 'true')
218+
expect(input).toHaveAccessibleDescription(expect.stringContaining(error))
219+
expect(mockOnSave).not.toHaveBeenCalled()
225220

226-
fireEvent.click(screen.getByText('common.operation.save'))
227-
await waitFor(() => {
228-
expect(toastMocks.call).toHaveBeenCalledWith(
229-
expect.objectContaining({
230-
message: 'appOverview.overview.appInfo.settings.invalidHexMessage',
231-
}),
232-
)
233-
})
234-
expect(mockOnSave).not.toHaveBeenCalled()
235-
})
221+
await user.clear(input)
222+
await user.type(input, valid)
223+
await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
224+
await waitFor(() => expect(mockOnSave).toHaveBeenCalledOnce())
225+
},
226+
)
236227

237-
it('should validate the privacy policy URL', async () => {
228+
it('preserves the copyright draft when toggled and saves it with the selected language', async () => {
229+
const user = userEvent.setup()
238230
await renderSettingsDialog()
239-
const privacyInput = screen.getByPlaceholderText(
240-
'appOverview.overview.appInfo.settings.more.privacyPolicyPlaceholder',
241-
)
242-
243-
fireEvent.change(privacyInput, { target: { value: 'ftp://invalid-url' } })
231+
const prefix = 'appOverview.overview.appInfo.settings'
232+
const copyrightGroup = within(screen.getByRole('group', { name: `${prefix}.more.copyright` }))
233+
const copyrightSwitch = copyrightGroup.getByRole('switch', { name: `${prefix}.more.copyright` })
234+
await user.click(copyrightSwitch)
235+
expect(copyrightGroup.queryByRole('textbox')).not.toBeInTheDocument()
236+
await user.click(copyrightSwitch)
237+
expect(copyrightGroup.getByRole('textbox')).toHaveValue('© Dify')
238+
await user.clear(copyrightGroup.getByRole('textbox'))
239+
await user.type(copyrightGroup.getByRole('textbox'), 'Updated copyright')
240+
const language = screen.getByRole('combobox', { name: `${prefix}.language` })
241+
await user.click(language)
242+
await user.click(screen.getByRole('option', { name: '简体中文' }))
243+
await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
244244

245-
fireEvent.click(screen.getByText('common.operation.save'))
246-
await waitFor(() => {
247-
expect(toastMocks.call).toHaveBeenCalledWith(
245+
await waitFor(() =>
246+
expect(mockOnSave).toHaveBeenCalledWith(
248247
expect.objectContaining({
249-
message: 'appOverview.overview.appInfo.settings.invalidPrivacyPolicy',
248+
default_language: 'zh-Hans',
249+
copyright: 'Updated copyright',
250250
}),
251-
)
252-
})
253-
expect(mockOnSave).not.toHaveBeenCalled()
251+
),
252+
)
254253
})
255254

256255
it('should save valid settings and close the modal', async () => {
@@ -343,7 +342,6 @@ describe('SettingsDialog', () => {
343342
await waitFor(() => expect(name).not.toHaveAttribute('readonly'))
344343
expect(screen.getByRole('dialog', { name: dialogName })).toBeInTheDocument()
345344
expect(name).toHaveValue('Unsaved title')
346-
expect(toastMocks.call).not.toHaveBeenCalled()
347345
await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
348346
await waitFor(() =>
349347
expect(screen.queryByRole('dialog', { name: dialogName })).not.toBeInTheDocument(),
@@ -530,7 +528,18 @@ describe('SettingsDialog', () => {
530528
copyrightEnabled = false
531529

532530
await renderSettingsDialog()
533-
fireEvent.click((await screen.findAllByText('billing.upgradeBtn.encourageShort'))[0]!)
531+
const copyrightGroup = within(
532+
screen.getByRole('group', {
533+
name: 'appOverview.overview.appInfo.settings.more.copyright',
534+
}),
535+
)
536+
expect(copyrightGroup.getByRole('switch')).toHaveAttribute('aria-disabled', 'true')
537+
expect(copyrightGroup.getByRole('textbox')).toBeDisabled()
538+
await userEvent.setup().click(
539+
copyrightGroup.getByRole('button', {
540+
name: 'billing.upgradeBtn.encourageShort',
541+
}),
542+
)
534543

535544
await waitFor(() =>
536545
expect(onPricingUrlUpdate.mock.lastCall?.[0].searchParams.get('pricing')).toBe('open'),

0 commit comments

Comments
 (0)