Compare commits

..

2 Commits

Author SHA1 Message Date
yyh
68f4dff852 refactor(web): migrate webapp email code fields 2026-08-09 22:02:45 +08:00
yyh
a85e583720 fix(web): restore webapp email code form submission
Move the email code request behind the native form submit boundary, add stable email field metadata, and verify Enter submits exactly once.
2026-08-09 22:02:09 +08:00
6 changed files with 120 additions and 36 deletions

View File

@@ -141,11 +141,6 @@
"count": 1
}
},
"web/app/(shareLayout)/webapp-signin/components/mail-and-code-auth.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/(shareLayout)/webapp-signin/components/mail-and-password-auth.tsx": {
"jsx_a11y/tabindex-no-positive": {
"count": 3
@@ -5269,6 +5264,14 @@
"count": 4
}
},
"web/app/components/workflow/panel/version-history-panel/filter/filter-item.tsx": {
"jsx_a11y/click-events-have-key-events": {
"count": 1
},
"jsx_a11y/no-static-element-interactions": {
"count": 1
}
},
"web/app/components/workflow/panel/version-history-panel/index.tsx": {
"jsx_a11y/click-events-have-key-events": {
"count": 2

View File

@@ -0,0 +1,58 @@
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import MailAndCodeAuth from '../mail-and-code-auth'
const pushMock = vi.fn()
const replaceMock = vi.fn()
const setCountdownLeftTimeMock = vi.fn()
const sendWebAppEMailLoginCodeMock = vi.fn()
const searchParams = new URLSearchParams({
redirect_url: encodeURIComponent('/chatbot/test-app'),
})
vi.mock('@/next/navigation', () => ({
useRouter: () => ({ push: pushMock, replace: replaceMock }),
useSearchParams: () => searchParams,
}))
vi.mock('@/context/i18n', () => ({
useLocale: () => 'en-US',
}))
vi.mock('@/app/components/signin/storage', () => ({
COUNT_DOWN_TIME_MS: 60_000,
useSetCountdownLeftTime: () => setCountdownLeftTimeMock,
}))
vi.mock('@/service/common', () => ({
sendWebAppEMailLoginCode: (...args: unknown[]) => sendWebAppEMailLoginCodeMock(...args),
}))
describe('MailAndCodeAuth', () => {
beforeEach(() => {
vi.clearAllMocks()
sendWebAppEMailLoginCodeMock.mockResolvedValue({ result: 'success', data: 'token-abc' })
})
it('submits from the email field through the native form', async () => {
const user = userEvent.setup()
render(<MailAndCodeAuth />)
const emailInput = screen.getByRole('textbox', { name: 'login.email' })
const submitButton = screen.getByRole('button', { name: 'login.signup.verifyMail' })
expect(emailInput).toHaveAttribute('name', 'email')
expect(emailInput).toHaveAttribute('autocomplete', 'email')
expect(emailInput).toHaveAttribute('spellcheck', 'false')
expect(submitButton).toHaveAttribute('type', 'submit')
await user.tab()
expect(emailInput).toHaveFocus()
await user.type(emailInput, 'user@example.com{Enter}')
await waitFor(() => {
expect(sendWebAppEMailLoginCodeMock).toHaveBeenCalledTimes(1)
})
expect(pushMock).toHaveBeenCalledWith(expect.stringContaining('/webapp-signin/check-code?'))
})
})

View File

@@ -1,10 +1,10 @@
import { Button } from '@langgenius/dify-ui/button'
import { Field, FieldControl, FieldLabel } from '@langgenius/dify-ui/field'
import { Form } from '@langgenius/dify-ui/form'
import { toast } from '@langgenius/dify-ui/toast'
import { noop } from 'es-toolkit/function'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { resolveWebAppLoginRedirect } from '@/app/(shareLayout)/webapp-signin/login-redirect'
import Input from '@/app/components/base/input'
import { COUNT_DOWN_TIME_MS, useSetCountdownLeftTime } from '@/app/components/signin/storage'
import { emailRegex } from '@/config'
import { useLocale } from '@/context/i18n'
@@ -64,33 +64,37 @@ export default function MailAndCodeAuth() {
}
return (
<form onSubmit={noop}>
<input type="text" className="hidden" />
<div className="mb-2">
<label htmlFor="email" className="my-2 system-md-semibold text-text-secondary">
<Form
onFormSubmit={() => {
void handleGetEMailVerificationCode()
}}
>
<Field name="email" className="mb-2 block">
<FieldLabel className="my-2 py-0 system-md-semibold! text-text-secondary">
{t(($) => $.email, { ns: 'login' })}
</label>
</FieldLabel>
<div className="mt-1">
<Input
id="email"
<FieldControl
type="email"
autoComplete="email"
spellCheck={false}
value={email}
placeholder={t(($) => $.emailPlaceholder, { ns: 'login' }) as string}
onChange={(e) => setEmail(e.target.value)}
onValueChange={setEmail}
/>
</div>
<div className="mt-3">
<Button
type="submit"
loading={loading}
disabled={loading || !email}
variant="primary"
className="w-full"
onClick={handleGetEMailVerificationCode}
>
{t(($) => $['signup.verifyMail'], { ns: 'login' })}
</Button>
</div>
</div>
</form>
</Field>
</Form>
)
}

View File

@@ -7,7 +7,7 @@ describe('FilterItem', () => {
it('renders the label, fires selection, and shows the check mark when selected', async () => {
const user = userEvent.setup()
const onClick = vi.fn()
render(
const { container } = render(
<FilterItem
item={{
key: WorkflowVersionFilterOptions.onlyYours,
@@ -18,12 +18,9 @@ describe('FilterItem', () => {
/>,
)
const option = screen.getByRole('button', { name: 'Only yours' })
expect(option).toHaveAttribute('aria-pressed', 'true')
option.focus()
await user.keyboard(' ')
await user.click(screen.getByText('Only yours'))
expect(onClick).toHaveBeenCalledWith(WorkflowVersionFilterOptions.onlyYours)
expect(container.querySelector('svg')).not.toBeNull()
})
})

View File

@@ -1,6 +1,7 @@
import { fireEvent, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { WorkflowVersionFilterOptions } from '../../../../types'
import FilterItem from '../filter-item'
import FilterSwitch from '../filter-switch'
import Filter from '../index'
@@ -28,6 +29,32 @@ describe('VersionHistory Filter Components', () => {
})
})
// Filter items should show the current selection and forward the option key.
describe('FilterItem', () => {
it('should call onClick with the selected filter key', async () => {
const user = userEvent.setup()
const onClick = vi.fn()
const { container } = render(
<FilterItem
item={{
key: WorkflowVersionFilterOptions.onlyYours,
name: 'Only Yours',
}}
isSelected
onClick={onClick}
/>,
)
expect(screen.getByText('Only Yours')).toBeInTheDocument()
expect(container.querySelector('svg')).toBeInTheDocument()
await user.click(screen.getByText('Only Yours'))
expect(onClick).toHaveBeenCalledWith(WorkflowVersionFilterOptions.onlyYours)
})
})
// The composed filter popover should open, list options, and delegate actions.
describe('Filter', () => {
it('should open the menu and forward option and switch actions', async () => {
@@ -52,9 +79,7 @@ describe('VersionHistory Filter Components', () => {
expect(screen.getByText('workflow.versionHistory.filter.all')).toBeInTheDocument()
expect(screen.getByText('workflow.versionHistory.filter.onlyYours')).toBeInTheDocument()
await user.click(
screen.getByRole('button', { name: 'workflow.versionHistory.filter.onlyYours' }),
)
await user.click(screen.getByText('workflow.versionHistory.filter.onlyYours'))
expect(onClickFilterItem).toHaveBeenCalledWith(WorkflowVersionFilterOptions.onlyYours)
fireEvent.click(screen.getByRole('switch'))

View File

@@ -1,5 +1,6 @@
import type { FC } from 'react'
import type { WorkflowVersionFilterOptions } from '../../../types'
import { RiCheckLine } from '@remixicon/react'
import * as React from 'react'
type FilterItemProps = {
@@ -13,19 +14,15 @@ type FilterItemProps = {
const FilterItem: FC<FilterItemProps> = ({ item, isSelected = false, onClick }) => {
return (
<button
type="button"
aria-pressed={isSelected}
className="flex w-full cursor-pointer appearance-none items-center justify-between gap-x-1 rounded-lg px-2 py-1.5 text-left outline-hidden hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid"
<div
className="flex cursor-pointer items-center justify-between gap-x-1 rounded-lg px-2 py-1.5 hover:bg-state-base-hover"
onClick={() => {
onClick(item.key)
}}
>
<span className="flex-1 system-md-regular text-text-primary">{item.name}</span>
{isSelected && (
<span aria-hidden className="i-ri-check-line size-4 shrink-0 text-text-accent" />
)}
</button>
<div className="flex-1 system-md-regular text-text-primary">{item.name}</div>
{isSelected && <RiCheckLine className="size-4 shrink-0 text-text-accent" />}
</div>
)
}