fix(web): preserve link semantics in button-styled navigation (#41312)

This commit is contained in:
yyh 2026-08-26 10:50:06 +00:00 committed by GitHub
parent 638dfd77a4
commit c8e29d9a7d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
53 changed files with 587 additions and 611 deletions

View File

@ -17,15 +17,14 @@ describe('Button', () => {
await expect.element(screen.getByRole('button')).toHaveAttribute('type', 'submit')
})
it('renders custom element via render prop', async () => {
it('renders a non-native button via render prop', async () => {
const screen = await render(
<Button nativeButton={false} render={<a href="/test" />}>
Link
<Button nativeButton={false} render={<div />}>
Custom button
</Button>,
)
const button = screen.getByRole('button', { name: 'Link' }).element()
expect(button.tagName).toBe('A')
expect(button).toHaveAttribute('href', '/test')
const button = screen.getByRole('button', { name: 'Custom button' }).element()
expect(button.tagName).toBe('DIV')
})
})

View File

@ -1,5 +1,5 @@
'use client'
import { Button } from '@langgenius/dify-ui/button'
import { Button, buttonVariants } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { Field, FieldDescription, FieldLabel } from '@langgenius/dify-ui/field'
import { Form } from '@langgenius/dify-ui/form'
@ -12,6 +12,7 @@ import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { validPassword } from '@/config'
import useDocumentTitle from '@/hooks/use-document-title'
import Link from '@/next/link'
import { useRouter, useSearchParams } from '@/next/navigation'
import { changeWebAppPasswordWithToken } from '@/service/common'
@ -182,16 +183,13 @@ const ChangePasswordForm = () => {
</h1>
</div>
<div className="mx-auto mt-6 w-full">
<Button
variant="primary"
className="w-full"
onClick={() => {
setLeftTime(undefined)
router.replace(getSignInUrl())
}}
<Link
href={getSignInUrl()}
replace
className={cn(buttonVariants({ variant: 'primary' }), 'w-full')}
>
{t(($) => $.passwordChanged, { ns: 'login' })} ({Math.round(countdown / 1000)}){' '}
</Button>
</Link>
</div>
</div>
)}

View File

@ -1,22 +1,16 @@
'use client'
import { Button } from '@langgenius/dify-ui/button'
import { buttonVariants } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { useSuspenseQuery } from '@tanstack/react-query'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { DifyLogo } from '@/app/components/base/logo/dify-logo'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import Link from '@/next/link'
import { useRouter } from '@/next/navigation'
import Avatar from './avatar'
const Header = () => {
const { t } = useTranslation()
const router = useRouter()
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const goToHome = useCallback(() => {
router.push('/')
}, [router])
const logoLabel =
systemFeatures.branding.enabled && systemFeatures.branding.application_title
? systemFeatures.branding.application_title
@ -46,11 +40,11 @@ const Header = () => {
</p>
</div>
<div className="flex shrink-0 items-center gap-3">
<Button className="px-3 py-2 system-sm-medium" onClick={goToHome}>
<Link href="/" className={cn(buttonVariants(), 'px-3 py-2 system-sm-medium')}>
<span aria-hidden className="i-custom-vender-main-nav-home size-4" />
<p>{t(($) => $['mainNav.home'], { ns: 'common' })}</p>
<span aria-hidden className="i-ri-arrow-right-up-line size-4" />
</Button>
</Link>
<div className="h-4 w-px bg-divider-regular" />
<Avatar />
</div>

View File

@ -1,7 +1,8 @@
'use client'
import { Avatar } from '@langgenius/dify-ui/avatar'
import { Button } from '@langgenius/dify-ui/button'
import { Button, buttonVariants } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { toast } from '@langgenius/dify-ui/toast'
import {
RiAccountCircleLine,
@ -18,6 +19,7 @@ import Loading from '@/app/components/base/loading'
import { useLanguage } from '@/app/components/header/account-setting/model-provider-page/hooks'
import { isLegacyBase401, userProfileQueryOptions } from '@/features/account-profile/client'
import useDocumentTitle from '@/hooks/use-document-title'
import Link from '@/next/link'
import { useRouter, useSearchParams } from '@/next/navigation'
import { consoleQuery } from '@/service/client'
import { useLogout } from '@/service/use-common'
@ -221,9 +223,14 @@ export default function OAuthAuthorize() {
<div className="flex flex-col items-center gap-2 pt-4">
{!isLoggedIn ? (
<Button variant="primary" size="large" className="w-full" onClick={onLoginSwitchClick}>
<Link
href={`/signin?redirect_url=${encodeURIComponent(
buildReturnUrl('/account/oauth/authorize', `?${searchParams.toString()}`),
)}`}
className={cn(buttonVariants({ variant: 'primary', size: 'large' }), 'w-full')}
>
{t(($) => $.login, { ns: 'oauth' })}
</Button>
</Link>
) : (
<>
<Button
@ -236,9 +243,9 @@ export default function OAuthAuthorize() {
>
{t(($) => $.continue, { ns: 'oauth' })}
</Button>
<Button size="large" className="w-full" onClick={() => router.push('/apps')}>
<Link href="/apps" className={cn(buttonVariants({ size: 'large' }), 'w-full')}>
{t(($) => $['operation.cancel'], { ns: 'common' })}
</Button>
</Link>
</>
)}
</div>

View File

@ -1,5 +1,5 @@
'use client'
import { Button } from '@langgenius/dify-ui/button'
import { buttonVariants } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { useEffect } from 'react'
import { useTranslation } from 'react-i18next'
@ -64,9 +64,12 @@ const ActivateForm = () => {
</h2>
</div>
<div className="mx-auto mt-6 w-full">
<Button variant="primary" className="w-full text-sm!">
<a href="https://dify.ai">{t(($) => $.explore, { ns: 'login' })}</a>
</Button>
<a
href="https://dify.ai"
className={cn(buttonVariants({ variant: 'primary' }), 'w-full text-sm!')}
>
{t(($) => $.explore, { ns: 'login' })}
</a>
</div>
</div>
)}

View File

@ -339,7 +339,7 @@ describe('environment access point cards', () => {
}),
)
expect(screen.getByRole('button', { name: 'environment-api-keys' })).toBeInTheDocument()
const apiReferenceLink = screen.getByRole('button', { name: /apiInfo\.doc/ })
const apiReferenceLink = screen.getByRole('link', { name: /apiInfo\.doc/ })
expect(apiReferenceLink).toHaveAttribute(
'href',
'https://docs.example.test/en/api-reference/guides/workflow',
@ -369,8 +369,7 @@ describe('environment access point cards', () => {
await screen.findByText(api.base_url)
expect(await screen.findByRole('button', { name: 'environment-api-keys' })).toBeEnabled()
const apiReferenceLink = screen.getByRole('button', { name: /apiInfo\.doc/ })
expect(apiReferenceLink).not.toHaveAttribute('aria-disabled')
const apiReferenceLink = screen.getByRole('link', { name: /apiInfo\.doc/ })
expect(apiReferenceLink).toHaveAttribute(
'href',
'https://docs.example.test/en/api-reference/guides/workflow',
@ -395,9 +394,6 @@ describe('environment access point cards', () => {
expect(card).not.toHaveAttribute('aria-busy')
expect(screen.queryByText('common.loading')).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'environment-api-keys' })).toBeDisabled()
expect(screen.getByRole('button', { name: /apiInfo\.doc/ })).toHaveAttribute(
'aria-disabled',
'true',
)
expect(screen.getByRole('button', { name: /apiInfo\.doc/ })).toBeDisabled()
})
})

View File

@ -57,7 +57,7 @@ describe('ServiceApiAccessPointCard', () => {
/>,
)
const apiReferenceLink = screen.getByRole('button', { name: /apiInfo\.doc/ })
const apiReferenceLink = screen.getByRole('link', { name: /apiInfo\.doc/ })
expect(apiReferenceLink).toHaveAttribute('href', `https://docs.example.test/en${path}`)
expect(apiReferenceLink).toHaveAttribute('target', '_blank')
@ -93,8 +93,7 @@ describe('ServiceApiAccessPointCard', () => {
)
expect(screen.getByRole('button', { name: 'api-secret-keys' })).toBeEnabled()
const apiReferenceLink = screen.getByRole('button', { name: /apiInfo\.doc/ })
expect(apiReferenceLink).not.toHaveAttribute('aria-disabled')
const apiReferenceLink = screen.getByRole('link', { name: /apiInfo\.doc/ })
expect(apiReferenceLink).toHaveAttribute(
'href',
'https://docs.example.test/en/api-reference/guides/workflow',
@ -126,9 +125,6 @@ describe('ServiceApiAccessPointCard', () => {
)
expect(screen.getByRole('button', { name: 'api-secret-keys' })).toBeDisabled()
expect(screen.getByRole('button', { name: /apiInfo\.doc/ })).toHaveAttribute(
'aria-disabled',
'true',
)
expect(screen.getByRole('button', { name: /apiInfo\.doc/ })).toBeDisabled()
})
})

View File

@ -1,7 +1,8 @@
'use client'
import type { AccessPoint } from '@/app/components/app/deploy/access-point'
import { Button } from '@langgenius/dify-ui/button'
import { Button, buttonVariants } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { useSuspenseQuery } from '@tanstack/react-query'
import { useAtomValue } from 'jotai'
import { useMemo } from 'react'
@ -85,16 +86,23 @@ export function BuiltInAccessPoints({ appId, highlightedAccessPoint }: BuiltInAc
})}
</span>
</div>
<Button
variant="primary"
size="medium"
disabled={!capabilities.canReleaseAndVersion}
render={<Link href={`/app/${appId}/workflow`} />}
className="flex items-center gap-1"
>
{t(($) => $['studio.accessPoint.goToPublish'], { ns: 'deployments' })}
<span aria-hidden className="i-ri-arrow-right-line size-4" />
</Button>
{capabilities.canReleaseAndVersion ? (
<Link
href={`/app/${appId}/workflow`}
className={cn(
buttonVariants({ variant: 'primary', size: 'medium' }),
'flex items-center gap-1',
)}
>
{t(($) => $['studio.accessPoint.goToPublish'], { ns: 'deployments' })}
<span aria-hidden className="i-ri-arrow-right-line size-4" />
</Link>
) : (
<Button variant="primary" size="medium" disabled className="flex items-center gap-1">
{t(($) => $['studio.accessPoint.goToPublish'], { ns: 'deployments' })}
<span aria-hidden className="i-ri-arrow-right-line size-4" />
</Button>
)}
</div>
)}

View File

@ -1,6 +1,7 @@
'use client'
import { Button, buttonVariants } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { IconButton } from '@langgenius/dify-ui/icon-button'
import { useTranslation } from 'react-i18next'
import { CopyFeedback } from '@/app/components/base/copy-feedback'
@ -111,11 +112,10 @@ export function AccessPointUrl({
href={openUrl}
target="_blank"
rel="noopener noreferrer"
className={buttonVariants({
variant: 'secondary',
size: 'small',
className: 'h-6 gap-1 px-1.5',
})}
className={cn(
buttonVariants({ variant: 'secondary', size: 'small' }),
'h-6 gap-1 px-1.5',
)}
>
<span aria-hidden className="i-ri-external-link-line size-3.5" />
{openLabel}

View File

@ -3,7 +3,8 @@
import type { ComponentProps } from 'react'
import type { AccessPointStatus } from './access-point-status'
import type { AppModeEnum } from '@/types/app'
import { Button } from '@langgenius/dify-ui/button'
import { Button, buttonVariants } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { useTranslation } from 'react-i18next'
import { useDocLink } from '@/context/i18n'
import Link from '@/next/link'
@ -56,23 +57,24 @@ export function ServiceApiCardView({
actions={
<>
<ApiSecretKeyButton {...apiKeyButtonProps} />
<Button
variant="secondary"
disabled={!available || !apiReferenceUrl}
nativeButton={false}
render={
apiReferenceUrl ? (
<Link href={apiReferenceUrl} target="_blank" rel="noopener noreferrer" />
) : (
<span />
)
}
className="flex items-center gap-1"
>
<span aria-hidden className="i-ri-book-open-line size-4" />
{t(($) => $['overview.apiInfo.doc'], { ns: 'appOverview' })}
<span aria-hidden className="i-ri-arrow-right-up-line size-3.5" />
</Button>
{available && apiReferenceUrl ? (
<Link
href={apiReferenceUrl}
target="_blank"
rel="noopener noreferrer"
className={cn(buttonVariants({ variant: 'secondary' }), 'flex items-center gap-1')}
>
<span aria-hidden className="i-ri-book-open-line size-4" />
{t(($) => $['overview.apiInfo.doc'], { ns: 'appOverview' })}
<span aria-hidden className="i-ri-arrow-right-up-line size-3.5" />
</Link>
) : (
<Button variant="secondary" disabled className="flex items-center gap-1">
<span aria-hidden className="i-ri-book-open-line size-4" />
{t(($) => $['overview.apiInfo.doc'], { ns: 'appOverview' })}
<span aria-hidden className="i-ri-arrow-right-up-line size-3.5" />
</Button>
)}
</>
}
>

View File

@ -2,7 +2,7 @@
import type { ReactNode } from 'react'
import type { DeploymentVersion } from '../../version'
import type { DeploymentDialogRequest } from '../types'
import { Button } from '@langgenius/dify-ui/button'
import { Button, buttonVariants } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { DialogClose, DialogDescription, DialogTitle } from '@langgenius/dify-ui/dialog'
import { IconButton } from '@langgenius/dify-ui/icon-button'
@ -159,14 +159,13 @@ function VersionList({
{t(($) => $['studio.accessPoint.noPublishedTitle'])}
</p>
{publishHref && (
<Button
size="medium"
render={<Link href={publishHref} />}
className="flex items-center gap-1"
<Link
href={publishHref}
className={cn(buttonVariants({ size: 'medium' }), 'flex items-center gap-1')}
>
{t(($) => $['studio.accessPoint.goToPublish'])}
<span aria-hidden className="i-ri-arrow-right-line size-4" />
</Button>
</Link>
)}
</div>
)}

View File

@ -1,7 +1,8 @@
import type { ComponentProps } from 'react'
import type { InSiteMessageActionItem } from '../index'
import { fireEvent, render, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test'
import { beforeEach, describe, expect, it, vi } from 'vite-plus/test'
import { trackEvent } from '@/app/components/base/amplitude'
import InSiteMessage from '../index'
vi.mock('@/app/components/base/amplitude', () => ({
@ -9,19 +10,8 @@ vi.mock('@/app/components/base/amplitude', () => ({
}))
describe('InSiteMessage', () => {
const originalLocation = window.location
beforeEach(() => {
vi.clearAllMocks()
vi.stubGlobal('open', vi.fn())
})
afterEach(() => {
Object.defineProperty(window, 'location', {
value: originalLocation,
configurable: true,
})
vi.unstubAllGlobals()
})
const renderComponent = (
@ -59,7 +49,7 @@ describe('InSiteMessage', () => {
const closeButton = screen.getByRole('button', { name: 'Close' })
const outlineButton = screen.getByRole('button', { name: 'Outline' })
const learnMoreButton = screen.getByRole('button', { name: 'Learn more' })
const learnMoreLink = screen.getByRole('link', { name: 'Learn more' })
const panel = closeButton.closest('div.fixed')
const titleElement = panel?.querySelector('.title-3xl-bold')
const subtitleElement = panel?.querySelector('.body-md-regular')
@ -71,7 +61,7 @@ describe('InSiteMessage', () => {
expect(screen.getByText('Main content')).toBeInTheDocument()
expect(closeButton).toBeInTheDocument()
expect(outlineButton).toHaveClass('bg-components-button-secondary-bg')
expect(learnMoreButton).toBeInTheDocument()
expect(learnMoreLink).toHaveAttribute('href', 'https://example.com')
})
it('should fallback to default header background when headerBgUrl is empty string', () => {
@ -103,7 +93,8 @@ describe('InSiteMessage', () => {
expect(screen.queryByRole('button', { name: 'Close' })).not.toBeInTheDocument()
})
it('should open a new tab when link action data is a string', () => {
it('should render a new-tab link and report its activation', () => {
const onAction = vi.fn()
const linkAction: InSiteMessageActionItem = {
action: 'link',
action_name: 'confirm',
@ -112,26 +103,22 @@ describe('InSiteMessage', () => {
data: 'https://example.com',
}
renderComponent([linkAction])
fireEvent.click(screen.getByRole('button', { name: 'Open link' }))
renderComponent([linkAction], { onAction })
const link = screen.getByRole('link', { name: 'Open link' })
expect(window.open).toHaveBeenCalledWith(
'https://example.com',
'_blank',
'noopener,noreferrer',
)
expect(link).toHaveAttribute('href', 'https://example.com')
expect(link).toHaveAttribute('target', '_blank')
expect(link).toHaveAttribute('rel', 'noopener noreferrer')
fireEvent.click(link)
expect(onAction).toHaveBeenCalledWith(linkAction)
expect(vi.mocked(trackEvent)).toHaveBeenCalledWith('in_site_message_action', {
notification_id: 'test-notification-id',
action: 'confirm',
})
})
it('should navigate with location.assign when link action target is _self', () => {
const assignSpy = vi.fn()
Object.defineProperty(window, 'location', {
value: {
...originalLocation,
assign: assignSpy,
},
configurable: true,
})
it('should render a same-tab link when target is _self', () => {
const linkAction: InSiteMessageActionItem = {
action: 'link',
action_name: 'confirm',
@ -141,10 +128,10 @@ describe('InSiteMessage', () => {
}
renderComponent([linkAction])
fireEvent.click(screen.getByRole('button', { name: 'Open self' }))
const link = screen.getByRole('link', { name: 'Open self' })
expect(assignSpy).toHaveBeenCalledWith('https://example.com/self')
expect(window.open).not.toHaveBeenCalled()
expect(link).toHaveAttribute('href', 'https://example.com/self')
expect(link).toHaveAttribute('target', '_self')
})
it('should not trigger navigation when link data is invalid', () => {
@ -159,7 +146,7 @@ describe('InSiteMessage', () => {
renderComponent([linkAction])
fireEvent.click(screen.getByRole('button', { name: 'Broken link' }))
expect(window.open).not.toHaveBeenCalled()
expect(screen.queryByRole('link', { name: 'Broken link' })).not.toBeInTheDocument()
})
})
})

View File

@ -137,12 +137,15 @@ describe('InSiteMessageNotification', () => {
await waitFor(() => {
expect(screen.getByText('Parsed body main')).toBeInTheDocument()
})
expect(screen.getByRole('button', { name: 'Visit docs' })).toBeInTheDocument()
const docsLink = screen.getByRole('link', { name: 'Visit docs' })
expect(docsLink).toHaveAttribute('href', 'https://example.com/docs')
expect(docsLink).toHaveAttribute('target', '_blank')
expect(docsLink).toHaveAttribute('rel', 'noopener noreferrer')
expect(screen.getByRole('button', { name: 'Outline close' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Dismiss now' })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Invalid' })).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Visit docs' }))
fireEvent.click(docsLink)
expect(mockNotificationDismiss).not.toHaveBeenCalled()
fireEvent.click(screen.getByRole('button', { name: 'Dismiss now' }))

View File

@ -1,6 +1,6 @@
'use client'
import { Button } from '@langgenius/dify-ui/button'
import { Button, buttonVariants } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { useEffect, useMemo, useState } from 'react'
import { trackEvent } from '@/app/components/base/amplitude'
@ -90,21 +90,7 @@ function InSiteMessage({
})
onAction?.(item)
if (item.action === 'close') {
setVisible(false)
return
}
const linkData = normalizeLinkData(item.data)
if (!linkData) return
const target = linkData.target ?? '_blank'
if (target === '_self') {
window.location.assign(linkData.href)
return
}
window.open(linkData.href, target, linkData.rel || 'noopener,noreferrer')
if (item.action === 'close') setVisible(false)
}
if (!visible) return null
@ -129,17 +115,42 @@ function InSiteMessage({
</div>
<div className="flex items-center justify-end gap-2 p-4">
{actions.map((item) => (
<Button
key={`${item.type}-${item.action}-${item.text}`}
variant={resolveButtonVariant(item.type)}
size="medium"
className={cn(item.type === 'default' && 'text-text-secondary')}
onClick={() => handleAction(item)}
>
{item.text}
</Button>
))}
{actions.map((item) => {
const variant = resolveButtonVariant(item.type)
const className = cn(
buttonVariants({ variant, size: 'medium' }),
item.type === 'default' && 'text-text-secondary',
)
const linkData = item.action === 'link' ? normalizeLinkData(item.data) : null
if (linkData) {
const target = linkData.target ?? '_blank'
return (
<a
key={`${item.type}-${item.action}-${item.text}`}
href={linkData.href}
target={target}
rel={linkData.rel || (target === '_blank' ? 'noopener noreferrer' : undefined)}
className={className}
onClick={() => handleAction(item)}
>
{item.text}
</a>
)
}
return (
<Button
key={`${item.type}-${item.action}-${item.text}`}
variant={variant}
size="medium"
className={cn(item.type === 'default' && 'text-text-secondary')}
onClick={() => handleAction(item)}
>
{item.text}
</Button>
)
})}
</div>
</div>
)

View File

@ -1,6 +1,7 @@
'use client'
import type { FC } from 'react'
import { Button } from '@langgenius/dify-ui/button'
import { buttonVariants } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import {
Dialog,
DialogClose,
@ -102,22 +103,18 @@ const CustomizeModal: FC<IShareLinkProps> = ({
<div className="mt-1 mb-2 text-xs text-text-tertiary">
{t(($) => $[`${prefixCustomize}.way1.step1Tip`], { ns: 'appOverview' })}
</div>
<Button
nativeButton={false}
render={
<a
href={`https://github.com/langgenius/${repository}`}
target="_blank"
rel="noopener noreferrer"
aria-label={t(($) => $[`${prefixCustomize}.way1.step1Operation`], {
ns: 'appOverview',
})}
/>
}
<a
href={`https://github.com/langgenius/${repository}`}
target="_blank"
rel="noopener noreferrer"
aria-label={t(($) => $[`${prefixCustomize}.way1.step1Operation`], {
ns: 'appOverview',
})}
className={buttonVariants()}
>
<GithubIcon className="text-text-secondary" />
{t(($) => $[`${prefixCustomize}.way1.step1Operation`], { ns: 'appOverview' })}
</Button>
</a>
</div>
</div>
<div className="flex pt-4">
@ -129,24 +126,20 @@ const CustomizeModal: FC<IShareLinkProps> = ({
<div className="mt-1 mb-2 text-xs text-text-tertiary">
{t(($) => $[`${prefixCustomize}.way1.step2Tip`], { ns: 'appOverview' })}
</div>
<Button
nativeButton={false}
render={
<a
href="https://vercel.com/docs/concepts/deployments/git/vercel-for-github"
target="_blank"
rel="noopener noreferrer"
aria-label={t(($) => $[`${prefixCustomize}.way1.step2Operation`], {
ns: 'appOverview',
})}
/>
}
<a
href="https://vercel.com/docs/concepts/deployments/git/vercel-for-github"
target="_blank"
rel="noopener noreferrer"
aria-label={t(($) => $[`${prefixCustomize}.way1.step2Operation`], {
ns: 'appOverview',
})}
className={buttonVariants()}
>
<div className="border-t-0 border-r-[7px] border-b-12 border-l-[7px] border-solid border-text-primary border-t-transparent border-r-transparent border-l-transparent"></div>
<span>
{t(($) => $[`${prefixCustomize}.way1.step2Operation`], { ns: 'appOverview' })}
</span>
</Button>
</a>
</div>
</div>
<div className="flex py-4">
@ -176,19 +169,14 @@ const CustomizeModal: FC<IShareLinkProps> = ({
<p className="my-2 system-sm-medium text-text-secondary">
{t(($) => $[`${prefixCustomize}.way2.name`], { ns: 'appOverview' })}
</p>
<Button
nativeButton={false}
render={
<a
href={apiDocLink}
target="_blank"
rel="noopener noreferrer"
aria-label={t(($) => $[`${prefixCustomize}.way2.operation`], {
ns: 'appOverview',
})}
/>
}
className="mt-2"
<a
href={apiDocLink}
target="_blank"
rel="noopener noreferrer"
aria-label={t(($) => $[`${prefixCustomize}.way2.operation`], {
ns: 'appOverview',
})}
className={cn(buttonVariants(), 'mt-2')}
>
<span className="text-sm text-text-secondary">
{t(($) => $[`${prefixCustomize}.way2.operation`], { ns: 'appOverview' })}
@ -197,7 +185,7 @@ const CustomizeModal: FC<IShareLinkProps> = ({
aria-hidden="true"
className="i-heroicons-arrow-top-right-on-square size-4 shrink-0 text-text-secondary"
/>
</Button>
</a>
</div>
</div>
</DialogContent>

View File

@ -5,10 +5,8 @@ import { FeaturesProvider } from '../../../context'
import AnnotationReply from '../index'
const originalConsoleError = console.error
const mockPush = vi.fn()
let mockPathname = '/app/test-app-id/configuration'
vi.mock('@/next/navigation', () => ({
useRouter: () => ({ push: mockPush }),
usePathname: () => mockPathname,
}))
@ -273,7 +271,7 @@ describe('AnnotationReply', () => {
expect(mockSetIsShowAnnotationConfigInit).toHaveBeenCalledWith(true)
})
it('should navigate to annotations page when cache management is clicked', () => {
it('should link to the app annotations page from cache management', () => {
renderWithProvider(
{},
{
@ -290,9 +288,10 @@ describe('AnnotationReply', () => {
const card = screen.getByText(/feature\.annotation\.title/).closest('[class]')!
fireEvent.mouseEnter(card)
fireEvent.click(screen.getByText(/feature\.annotation\.cacheManagement/))
expect(mockPush).toHaveBeenCalledWith('/app/test-app-id/annotations')
expect(
screen.getByRole('link', { name: /feature\.annotation\.cacheManagement/ }),
).toHaveAttribute('href', '/app/test-app-id/annotations')
})
it('should fallback appId to empty string when pathname does not match', () => {
@ -313,9 +312,10 @@ describe('AnnotationReply', () => {
const card = screen.getByText(/feature\.annotation\.title/).closest('[class]')!
fireEvent.mouseEnter(card)
fireEvent.click(screen.getByText(/feature\.annotation\.cacheManagement/))
expect(mockPush).toHaveBeenCalledWith('/app//annotations')
expect(
screen.getByRole('link', { name: /feature\.annotation\.cacheManagement/ }),
).toHaveAttribute('href', '/app//annotations')
})
it('should show config param modal when isShowAnnotationConfigInit is true', async () => {

View File

@ -1,6 +1,7 @@
import type { OnFeaturesChange } from '@/app/components/base/features/types'
import type { AnnotationReplyConfig } from '@/models/debug'
import { Button } from '@langgenius/dify-ui/button'
import { Button, buttonVariants } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { RiEqualizer2Line, RiExternalLinkLine } from '@remixicon/react'
import { produce } from 'immer'
import * as React from 'react'
@ -13,7 +14,8 @@ import FeatureCard from '@/app/components/base/features/new-feature-panel/featur
import { MessageFast } from '@/app/components/base/icons/src/vender/features'
import AnnotationFullModal from '@/app/components/billing/annotation-full/modal'
import { ANNOTATION_DEFAULT } from '@/config'
import { usePathname, useRouter } from '@/next/navigation'
import Link from '@/next/link'
import { usePathname } from '@/next/navigation'
type Props = Readonly<{
disabled?: boolean
@ -22,7 +24,6 @@ type Props = Readonly<{
const AnnotationReply = ({ disabled, onChange }: Props) => {
const { t } = useTranslation()
const router = useRouter()
const pathname = usePathname()
const matched = /\/app\/([^/]+)/.exec(pathname)
const appId = matched?.length && matched[1] ? matched[1] : ''
@ -126,15 +127,13 @@ const AnnotationReply = ({ disabled, onChange }: Props) => {
<RiEqualizer2Line className="size-4" />
{t(($) => $['operation.params'], { ns: 'common' })}
</Button>
<Button
className="w-44.5"
onClick={() => {
router.push(`/app/${appId}/annotations`)
}}
<Link
href={`/app/${appId}/annotations`}
className={cn(buttonVariants(), 'w-44.5')}
>
<RiExternalLinkLine className="size-4" />
{t(($) => $['feature.annotation.cacheManagement'], { ns: 'appDebug' })}
</Button>
</Link>
</div>
)}
</>

View File

@ -52,21 +52,19 @@ describe('MarkdownButton (integration)', () => {
expect(screen.getByRole('button')).toHaveTextContent('Click me')
})
it('opens new tab when link is valid and does not call onSend', async () => {
it('renders a native link when the URL is valid', () => {
isValidUrlSpy.mockReturnValue(true)
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null)
const user = userEvent.setup()
const node = createButtonNode({ dataLink: 'https://example.com' }, 'Go')
renderWithCtx(node)
await user.click(screen.getByRole('button'))
const link = screen.getByRole('link', { name: 'Go' })
expect(isValidUrlSpy).toHaveBeenCalledWith('https://example.com')
expect(openSpy).toHaveBeenCalledWith('https://example.com', '_blank')
expect(link).toHaveAttribute('href', 'https://example.com')
expect(link).toHaveAttribute('target', '_blank')
expect(link).toHaveAttribute('rel', 'noopener noreferrer')
expect(onSendSpy).not.toHaveBeenCalled()
openSpy.mockRestore()
})
it('calls onSend when link is invalid but message exists', async () => {

View File

@ -1,6 +1,6 @@
import type { ComponentProps } from 'react'
import type { ExtraProps } from 'streamdown'
import { Button } from '@langgenius/dify-ui/button'
import { Button, buttonVariants } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { useChatContext } from '@/app/components/base/chat/chat/context'
import { getMarkdownButtonAppearance } from './button-appearance'
@ -22,16 +22,28 @@ const MarkdownButton = ({ node }: MarkdownButtonProps) => {
const link = getStringProperty(node?.properties.dataLink)
const firstChild = node?.children[0]
const label = firstChild?.type === 'text' ? firstChild.value : ''
const validLink = link && isValidUrl(link) ? link : undefined
const className = 'h-auto! min-h-8 px-3! whitespace-normal select-none'
if (validLink) {
return (
<a
href={validLink}
target="_blank"
rel="noopener noreferrer"
className={cn(buttonVariants(appearance), className)}
>
<span className="text-[13px]">{label}</span>
</a>
)
}
return (
<Button
{...appearance}
className={cn('h-auto! min-h-8 px-3! whitespace-normal select-none')}
className={className}
onClick={() => {
if (link && isValidUrl(link)) {
window.open(link, '_blank')
return
}
if (!message) return
onSend?.(message)
}}

View File

@ -1,7 +1,7 @@
'use client'
import type { MeterTone } from '@langgenius/dify-ui/meter'
import type { FC } from 'react'
import { Button } from '@langgenius/dify-ui/button'
import { buttonVariants } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { Meter, MeterIndicator, MeterTrack } from '@langgenius/dify-ui/meter'
import { useSuspenseQuery } from '@tanstack/react-query'
@ -61,19 +61,18 @@ const AppsFull: FC<{ loc: string; className?: string }> = ({ loc, className }) =
<UpgradeBtn isShort loc={loc} />
)}
{plan.type !== 'sandbox' && plan.type !== 'professional' && (
<Button variant="secondary-accent">
<a
target="_blank"
rel="noopener noreferrer"
href={mailToSupport(
accountProfile.email,
plan.type,
accountProfile.currentVersion ?? '',
)}
>
{t(($) => $['apps.contactUs'], { ns: 'billing' })}
</a>
</Button>
<a
target="_blank"
rel="noopener noreferrer"
href={mailToSupport(
accountProfile.email,
plan.type,
accountProfile.currentVersion ?? '',
)}
className={buttonVariants({ variant: 'secondary-accent' })}
>
{t(($) => $['apps.contactUs'], { ns: 'billing' })}
</a>
)}
</div>
<div className="flex flex-col gap-2">

View File

@ -1,4 +1,5 @@
import { Button } from '@langgenius/dify-ui/button'
import { buttonVariants } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { RiArrowLeftLine } from '@remixicon/react'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
@ -10,10 +11,15 @@ const Header = () => {
return (
<div className="relative flex px-16 pt-5 pb-2 system-md-semibold text-text-primary">
<span>{t(($) => $['creation.backToKnowledge'], { ns: 'datasetPipeline' })}</span>
<Link className="absolute bottom-0 left-5" href="/datasets" replace>
<Button variant="secondary-accent" className="size-9 rounded-full p-0">
<RiArrowLeftLine className="size-5" />
</Button>
<Link
className={cn(
buttonVariants({ variant: 'secondary-accent' }),
'absolute bottom-0 left-5 size-9 rounded-full p-0',
)}
href="/datasets"
replace
>
<RiArrowLeftLine className="size-5" />
</Link>
</div>
)

View File

@ -3,7 +3,6 @@ import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import EmbeddingProcess from '../index'
const mockPush = vi.fn()
const mockInvalidDocumentList = vi.fn()
let mockEnableBilling = false
let mockPlanType = 'sandbox'
@ -17,10 +16,6 @@ let mockPollingState: {
isEmbeddingCompleted: false,
}
vi.mock('@/next/navigation', () => ({
useRouter: () => ({ push: mockPush }),
}))
vi.mock('@/next/link', () => ({
default: ({
children,
@ -184,14 +179,15 @@ describe('EmbeddingProcess', () => {
).toBeInTheDocument()
})
it('invalidates the document list before navigating to it', async () => {
it('links to the document list and invalidates its cache on activation', async () => {
const user = userEvent.setup()
render(<EmbeddingProcess datasetId="dataset-1" batchId="batch-1" />)
await user.click(screen.getByRole('button', { name: 'datasetCreation.stepThree.navTo' }))
const link = screen.getByRole('link', { name: 'datasetCreation.stepThree.navTo' })
expect(link).toHaveAttribute('href', '/datasets/dataset-1/documents')
await user.click(link)
expect(mockInvalidDocumentList).toHaveBeenCalledOnce()
expect(mockPush).toHaveBeenCalledWith('/datasets/dataset-1/documents')
})
it('links to the dataset API reference', () => {

View File

@ -1,7 +1,8 @@
import type { FC } from 'react'
import type { FullDocumentDetail } from '@/models/datasets'
import type { RETRIEVE_METHOD } from '@/types/app'
import { Button } from '@langgenius/dify-ui/button'
import { buttonVariants } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { RiArrowRightLine, RiLoader2Fill, RiTerminalBoxLine } from '@remixicon/react'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
@ -10,7 +11,6 @@ import VectorSpaceAdmissionAlert from '@/app/components/datasets/common/vector-s
import { useProviderContext } from '@/context/provider-context'
import { useDatasetApiAccessUrl } from '@/hooks/use-api-access-url'
import Link from '@/next/link'
import { useRouter } from '@/next/navigation'
import { useProcessRule } from '@/service/knowledge/use-dataset'
import { useInvalidDocumentList } from '@/service/knowledge/use-document'
import IndexingProgressItem from './indexing-progress-item'
@ -50,22 +50,30 @@ const StatusHeader: FC<{ isEmbedding: boolean; isCompleted: boolean }> = ({
// Action buttons component
const ActionButtons: FC<{
apiReferenceUrl: string
onNavToDocuments: () => void
}> = ({ apiReferenceUrl, onNavToDocuments }) => {
documentsHref: string
onNavigateToDocuments: () => void
}> = ({ apiReferenceUrl, documentsHref, onNavigateToDocuments }) => {
const { t } = useTranslation()
return (
<div className="mt-6 flex items-center gap-x-2 py-2">
<Link href={apiReferenceUrl} target="_blank" rel="noopener noreferrer">
<Button className="w-fit">
<RiTerminalBoxLine className="size-4" />
<span>Access the API</span>
</Button>
<Link
href={apiReferenceUrl}
target="_blank"
rel="noopener noreferrer"
className={cn(buttonVariants(), 'w-fit')}
>
<RiTerminalBoxLine className="size-4" />
<span>Access the API</span>
</Link>
<Button className="w-fit" variant="primary" onClick={onNavToDocuments}>
<Link
href={documentsHref}
className={cn(buttonVariants({ variant: 'primary' }), 'w-fit')}
onClick={onNavigateToDocuments}
>
<span>{t(($) => $['stepThree.navTo'], { ns: 'datasetCreation' })}</span>
<RiArrowRightLine className="size-4 stroke-current stroke-1" />
</Button>
</Link>
</div>
)
}
@ -78,7 +86,6 @@ const EmbeddingProcess: FC<EmbeddingProcessProps> = ({
retrievalMethod,
}) => {
const { enableBilling, plan } = useProviderContext()
const router = useRouter()
const invalidDocumentList = useInvalidDocumentList()
const apiReferenceUrl = useDatasetApiAccessUrl()
@ -95,10 +102,7 @@ const EmbeddingProcess: FC<EmbeddingProcessProps> = ({
// Document lookup utilities - memoized for performance
const documentLookup = useMemo(() => createDocumentLookup(documents), [documents])
const handleNavToDocuments = () => {
invalidDocumentList()
router.push(`/datasets/${datasetId}/documents`)
}
const documentsHref = `/datasets/${datasetId}/documents`
const showUpgradeBanner = enableBilling && plan.type !== 'team'
const showVectorSpaceUpgrade =
@ -145,7 +149,11 @@ const EmbeddingProcess: FC<EmbeddingProcessProps> = ({
/>
</div>
<ActionButtons apiReferenceUrl={apiReferenceUrl} onNavToDocuments={handleNavToDocuments} />
<ActionButtons
apiReferenceUrl={apiReferenceUrl}
documentsHref={documentsHref}
onNavigateToDocuments={invalidDocumentList}
/>
</>
)
}

View File

@ -1,5 +1,6 @@
import { Button } from '@langgenius/dify-ui/button'
import { Button, buttonVariants } from '@langgenius/dify-ui/button'
import { Checkbox } from '@langgenius/dify-ui/checkbox'
import { cn } from '@langgenius/dify-ui/cn'
import { RiArrowRightLine } from '@remixicon/react'
import * as React from 'react'
import { useMemo } from 'react'
@ -63,10 +64,12 @@ const Actions = ({
</>
)}
<div className="flex grow items-center justify-end gap-x-2">
<Link href={`/datasets/${datasetId}/documents`} replace>
<Button variant="ghost" className="px-3 py-2">
{t(($) => $['operation.cancel'], { ns: 'common' })}
</Button>
<Link
href={`/datasets/${datasetId}/documents`}
replace
className={cn(buttonVariants({ variant: 'ghost' }), 'px-3 py-2')}
>
{t(($) => $['operation.cancel'], { ns: 'common' })}
</Link>
<Button disabled={disabled} variant="primary" onClick={handleNextStep}>
<span>{t(($) => $['stepOne.button'], { ns: 'datasetCreation' })}</span>

View File

@ -1,5 +1,6 @@
import type { Step } from './step-indicator'
import { Button } from '@langgenius/dify-ui/button'
import { buttonVariants } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { RiArrowLeftLine } from '@remixicon/react'
import * as React from 'react'
import Effect from '@/app/components/base/effect'
@ -27,13 +28,15 @@ const LeftHeader = ({ steps, title, currentStep }: LeftHeaderProps) => {
</div>
<div className="system-md-semibold text-text-primary">{steps[currentStep - 1]?.label}</div>
{currentStep !== steps.length && (
<Link href={`/datasets/${datasetId}/documents`} replace>
<Button
variant="secondary-accent"
className="absolute top-3.5 -left-11 size-9 rounded-full p-0"
>
<RiArrowLeftLine className="size-5" />
</Button>
<Link
href={`/datasets/${datasetId}/documents`}
replace
className={cn(
buttonVariants({ variant: 'secondary-accent' }),
'absolute top-3.5 -left-11 size-9 rounded-full p-0',
)}
>
<RiArrowLeftLine className="size-5" />
</Link>
)}
<Effect className="-top-8.5 left-8 opacity-20" />

View File

@ -10,13 +10,6 @@ import { renderWithConsoleQuery as render } from '@/test/console/query-data'
import { RETRIEVE_METHOD } from '@/types/app'
import EmbeddingProcess from '../index'
const mockPush = vi.fn()
vi.mock('@/next/navigation', () => ({
useRouter: () => ({
push: mockPush,
}),
}))
// Mock next/link
vi.mock('@/next/link', () => ({
default: function MockLink({
@ -650,29 +643,15 @@ describe('EmbeddingProcess', () => {
})
describe('User Interactions', () => {
// Tests for button clicks and navigation
it('should navigate to document list when nav button is clicked', async () => {
it('should link to the document list and invalidate its cache on activation', () => {
const props = createDefaultProps({ datasetId: 'my-dataset-123' })
render(<EmbeddingProcess {...props} />)
const navButton = screen.getByText('datasetCreation.stepThree.navTo')
fireEvent.click(navButton)
const link = screen.getByRole('link', { name: 'datasetCreation.stepThree.navTo' })
expect(link).toHaveAttribute('href', '/datasets/my-dataset-123/documents')
fireEvent.click(link)
expect(mockInvalidDocumentList).toHaveBeenCalled()
expect(mockPush).toHaveBeenCalledWith('/datasets/my-dataset-123/documents')
})
it('should call invalidDocumentList before navigation', () => {
const props = createDefaultProps()
const callOrder: string[] = []
mockInvalidDocumentList.mockImplementation(() => callOrder.push('invalidate'))
mockPush.mockImplementation(() => callOrder.push('push'))
render(<EmbeddingProcess {...props} />)
const navButton = screen.getByText('datasetCreation.stepThree.navTo')
fireEvent.click(navButton)
expect(callOrder).toEqual(['invalidate', 'push'])
})
})

View File

@ -2,7 +2,7 @@ import type { IndexingType } from '@/app/components/datasets/create/step-two'
import type { IndexingStatusResponse } from '@/models/datasets'
import type { InitialDocumentDetail } from '@/models/pipeline'
import type { RETRIEVE_METHOD } from '@/types/app'
import { Button } from '@langgenius/dify-ui/button'
import { buttonVariants } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
import {
@ -26,7 +26,6 @@ import { useProviderContext } from '@/context/provider-context'
import { useDatasetApiAccessUrl } from '@/hooks/use-api-access-url'
import { DatasourceType } from '@/models/pipeline'
import Link from '@/next/link'
import { useRouter } from '@/next/navigation'
import { useIndexingStatusBatch, useProcessRule } from '@/service/knowledge/use-dataset'
import { useInvalidDocumentList } from '@/service/knowledge/use-document'
import RuleDetail from './rule-detail'
@ -47,7 +46,6 @@ const EmbeddingProcess = ({
retrievalMethod,
}: EmbeddingProcessProps) => {
const { t } = useTranslation()
const router = useRouter()
const { enableBilling, plan } = useProviderContext()
const [indexingStatusBatchDetail, setIndexingStatusDetail] = useState<IndexingStatusResponse[]>(
[],
@ -86,10 +84,7 @@ const EmbeddingProcess = ({
const { data: ruleDetail } = useProcessRule(firstDocument!.id)
const invalidDocumentList = useInvalidDocumentList()
const navToDocumentList = () => {
invalidDocumentList()
router.push(`/datasets/${datasetId}/documents`)
}
const documentsHref = `/datasets/${datasetId}/documents`
const apiReferenceUrl = useDatasetApiAccessUrl()
const isEmbeddingWaiting = useMemo(() => {
@ -255,16 +250,23 @@ const EmbeddingProcess = ({
/>
</div>
<div className="mt-6 flex items-center gap-x-2 py-2">
<Link href={apiReferenceUrl} target="_blank" rel="noopener noreferrer">
<Button className="w-fit">
<RiTerminalBoxLine className="size-4" />
<span>Access the API</span>
</Button>
<Link
href={apiReferenceUrl}
target="_blank"
rel="noopener noreferrer"
className={cn(buttonVariants(), 'w-fit')}
>
<RiTerminalBoxLine className="size-4" />
<span>Access the API</span>
</Link>
<Button className="w-fit" variant="primary" onClick={navToDocumentList}>
<Link
href={documentsHref}
className={cn(buttonVariants({ variant: 'primary' }), 'w-fit')}
onClick={invalidDocumentList}
>
<span>{t(($) => $['stepThree.navTo'], { ns: 'datasetCreation' })}</span>
<RiArrowRightLine className="size-4 stroke-current stroke-1" />
</Button>
</Link>
</div>
</>
)

View File

@ -368,16 +368,12 @@ describe('ExternalKnowledgeBaseConnector', () => {
expect((descriptionInput as HTMLTextAreaElement).value).toBe('My Description')
})
it('should handle cancel button click', async () => {
const user = userEvent.setup()
it('should link back to the dataset list from cancel', () => {
render(<ExternalKnowledgeBaseConnector />)
const cancelButton = screen
.getByText('dataset.externalKnowledgeForm.cancel')
.closest('button')
await user.click(cancelButton!)
expect(mockReplace).toHaveBeenCalledWith('/datasets')
expect(
screen.getByRole('link', { name: 'dataset.externalKnowledgeForm.cancel' }),
).toHaveAttribute('href', '/datasets')
})
it('should handle back button click', async () => {

View File

@ -15,6 +15,14 @@ vi.mock('@/next/navigation', () => ({
}),
}))
vi.mock('@/next/link', () => ({
default: ({ children, replace, ...props }: React.ComponentProps<'a'> & { replace?: boolean }) => (
<a {...props} data-replace={replace || undefined}>
{children}
</a>
),
}))
// Mock useDocLink hook
vi.mock('@/context/i18n', () => ({
useDocLink: () => (path?: string) =>
@ -363,16 +371,12 @@ describe('ExternalKnowledgeBaseCreate', () => {
expect(mockReplace).toHaveBeenCalledWith('/datasets')
})
it('should navigate back when cancel button is clicked', async () => {
const user = userEvent.setup()
it('should link back to the dataset list from cancel', () => {
renderComponent()
const cancelButton = screen
.getByText('dataset.externalKnowledgeForm.cancel')
.closest('button')
await user.click(cancelButton!)
expect(mockReplace).toHaveBeenCalledWith('/datasets')
const link = screen.getByRole('link', { name: 'dataset.externalKnowledgeForm.cancel' })
expect(link).toHaveAttribute('href', '/datasets')
expect(link).toHaveAttribute('data-replace', 'true')
})
it('should call onConnect with complete form data when connect is clicked', async () => {

View File

@ -1,12 +1,13 @@
'use client'
import type { CreateKnowledgeBaseReq } from './declarations'
import { Button } from '@langgenius/dify-ui/button'
import { Button, buttonVariants } from '@langgenius/dify-ui/button'
import { IconButton } from '@langgenius/dify-ui/icon-button'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Divider from '@/app/components/base/divider'
import { useDocLink } from '@/context/i18n'
import Link from '@/next/link'
import { useRouter } from '@/next/navigation'
import ExternalApiSelection from './ExternalApiSelection'
import InfoPanel from './InfoPanel'
@ -124,11 +125,11 @@ const ExternalKnowledgeBaseCreate: React.FC<ExternalKnowledgeBaseCreateProps> =
}
/>
<div className="flex items-center justify-end gap-2 self-stretch py-2">
<Button variant="secondary" onClick={navBackHandle}>
<Link href="/datasets" replace className={buttonVariants({ variant: 'secondary' })}>
<div className="system-sm-medium text-components-button-secondary-text">
{t(($) => $['externalKnowledgeForm.cancel'], { ns: 'dataset' })}
</div>
</Button>
</Link>
<Button
variant="primary"
onClick={() => {

View File

@ -1,4 +1,5 @@
import { Button } from '@langgenius/dify-ui/button'
import { Button, buttonVariants } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { PopoverClose } from '@langgenius/dify-ui/popover'
import { StatusDot } from '@langgenius/dify-ui/status-dot'
import { useTranslation } from 'react-i18next'
@ -68,17 +69,17 @@ export function ServiceApiCard({ apiBaseUrl, canManageApiKey, onOpenApiKeyModal
</Button>
}
/>
<Button
variant="ghost"
size="small"
className="text-text-tertiary"
render={<Link href={apiReferenceUrl} target="_blank" rel="noopener noreferrer" />}
<Link
href={apiReferenceUrl}
target="_blank"
rel="noopener noreferrer"
className={cn(buttonVariants({ variant: 'ghost', size: 'small' }), 'text-text-tertiary')}
>
<span aria-hidden className="i-ri-book-open-line size-3.5 shrink-0" />
<span className="system-xs-medium">
{t(($) => $['serviceApi.card.apiReference'], { ns: 'dataset' })}
</span>
</Button>
</Link>
</div>
</div>
)

View File

@ -1,7 +1,8 @@
'use client'
import type { FC } from 'react'
import type { Plugin, PluginDeclaration, PluginManifestInMarket } from '../../types'
import { Button } from '@langgenius/dify-ui/button'
import { Button, buttonVariants } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import * as React from 'react'
import { Trans, useTranslation } from 'react-i18next'
import Badge, { BadgeState } from '@/app/components/base/badge/index'
@ -125,21 +126,22 @@ const Installed: FC<Props> = ({
</div>
{/* Action Buttons */}
<div className="flex items-center justify-end gap-2 self-stretch p-6 pt-5">
<Button
variant="primary"
className="min-w-18"
render={categoryTarget ? <Link href={categoryTarget.path} /> : undefined}
onClick={handleClose}
>
{categoryTarget ? (
{categoryTarget ? (
<Link
href={categoryTarget.path}
className={cn(buttonVariants({ variant: 'primary' }), 'min-w-18')}
onClick={handleClose}
>
<>
<span>{t(($) => $['installModal.viewDetails'], { ns: 'plugin' })}</span>
<span className="i-ri-arrow-right-up-line size-4 shrink-0" aria-hidden="true" />
</>
) : (
t(($) => $['operation.close'], { ns: 'common' })
)}
</Button>
</Link>
) : (
<Button variant="primary" className="min-w-18" onClick={handleClose}>
{t(($) => $['operation.close'], { ns: 'common' })}
</Button>
)}
</div>
</>
)

View File

@ -107,7 +107,7 @@ describe('CardWrapper', () => {
screen.getByRole('button', { name: 'plugin.detailPanel.operation.install' }),
).toBeInTheDocument()
expect(
screen.getByRole('button', { name: 'plugin.detailPanel.operation.detail' }),
screen.getByRole('link', { name: 'plugin.detailPanel.operation.detail' }),
).toBeInTheDocument()
})
@ -122,18 +122,13 @@ describe('CardWrapper', () => {
expect(screen.queryByTestId('install-modal')).not.toBeInTheDocument()
})
it('opens marketplace detail from the detail action', () => {
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null)
it('links the detail action to the marketplace', () => {
renderCardWrapper({ showInstallButton: true })
fireEvent.click(screen.getByRole('button', { name: 'plugin.detailPanel.operation.detail' }))
expect(openSpy).toHaveBeenCalledWith(
'/marketplace/dify/plugin-a?language=en-US&theme=system',
'_blank',
'noopener,noreferrer',
)
const link = screen.getByRole('link', { name: 'plugin.detailPanel.operation.detail' })
expect(link).toHaveAttribute('href', '/marketplace/dify/plugin-a?language=en-US&theme=system')
expect(link).toHaveAttribute('target', '_blank')
expect(link).toHaveAttribute('rel', 'noopener noreferrer')
})
it('opens and closes install modal from install action', () => {

View File

@ -1,6 +1,7 @@
'use client'
import type { Plugin } from '@/app/components/plugins/types'
import { Button } from '@langgenius/dify-ui/button'
import { Button, buttonVariants } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { useBoolean } from 'ahooks'
import { useTheme } from 'next-themes'
import * as React from 'react'
@ -50,13 +51,6 @@ const CardWrapperComponent = ({
() => plugin.tags.map((tag) => getTagLabel(tag.name)),
[plugin.tags, getTagLabel],
)
const handleOpenMarketplaceDetail = () => {
window.open(
getPluginLinkInMarketplace(plugin, marketplaceLinkParams),
'_blank',
'noopener,noreferrer',
)
}
const showInstallAction = !!showInstallButton && canInstallPlugin
if (showInstallAction) {
@ -85,13 +79,15 @@ const CardWrapperComponent = ({
? t(($) => $['task.installed'], { ns: 'plugin' })
: t(($) => $['detailPanel.operation.install'], { ns: 'plugin' })}
</Button>
<Button
className="min-w-0 flex-1 shadow-xs backdrop-blur-[5px]"
onClick={handleOpenMarketplaceDetail}
<a
href={getPluginLinkInMarketplace(plugin, marketplaceLinkParams)}
target="_blank"
rel="noopener noreferrer"
className={cn(buttonVariants(), 'min-w-0 flex-1 shadow-xs backdrop-blur-[5px]')}
>
{t(($) => $['detailPanel.operation.detail'], { ns: 'plugin' })}
<span aria-hidden className="i-ri-arrow-right-up-line size-4" />
</Button>
</a>
</div>
{isShowInstallFromMarketplace && (
<InstallFromMarketplace

View File

@ -1,7 +1,7 @@
'use client'
import type { FC } from 'react'
import type { Plugin } from './types'
import { Button } from '@langgenius/dify-ui/button'
import { Button, buttonVariants } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { useBoolean } from 'ahooks'
import { useTheme } from 'next-themes'
@ -79,16 +79,15 @@ const ProviderCardComponent: FC<Props> = ({ className, payload }) => {
{t(($) => $['detailPanel.operation.install'], { ns: 'plugin' })}
</Button>
)}
<Button className="grow" variant="secondary">
<a
href={getPluginLinkInMarketplace(payload, marketplaceLinkParams)}
target="_blank"
className="flex items-center gap-0.5"
>
{t(($) => $['detailPanel.operation.detail'], { ns: 'plugin' })}
<span className="i-ri-arrow-right-up-line size-4" />
</a>
</Button>
<a
href={getPluginLinkInMarketplace(payload, marketplaceLinkParams)}
target="_blank"
rel="noopener noreferrer"
className={cn(buttonVariants({ variant: 'secondary' }), 'grow gap-0.5')}
>
{t(($) => $['detailPanel.operation.detail'], { ns: 'plugin' })}
<span className="i-ri-arrow-right-up-line size-4" />
</a>
</div>
{isShowInstallFromMarketplace && (
<InstallFromMarketplace

View File

@ -37,18 +37,12 @@ const triggerHotkey = (hotkey: string) => {
})
}
const mockPush = vi.fn()
vi.mock('@/next/navigation', () => ({
useParams: () => ({ datasetId: 'test-dataset-id' }),
useRouter: () => ({ push: mockPush }),
}))
vi.mock('@/next/link', () => ({
default: ({ children, href, ...props }: { children: React.ReactNode; href: string }) => (
<a href={href} {...props}>
{children}
</a>
),
default: ({ children, ...props }: React.ComponentProps<'a'>) => <a {...props}>{children}</a>,
}))
const mockHandleSyncWorkflowDraft = vi.fn()
@ -433,17 +427,6 @@ describe('publisher', () => {
expect(addDocumentsButton).toBeDisabled()
})
it('should enable action buttons when published', () => {
mockPublishedAt.mockReturnValue(1700000000)
renderWithQueryClient(<Popup />)
const addDocumentsButton = screen
.getAllByRole('button')
.find((btn) => btn.textContent?.includes('pipeline.common.goToAddDocuments'))
expect(addDocumentsButton).not.toBeDisabled()
})
it('should show premium badge when publish as template is not allowed', () => {
mockPublishedAt.mockReturnValue(1700000000)
mockIsAllowPublishAsCustomKnowledgePipelineTemplate.mockReturnValue(false)
@ -506,18 +489,13 @@ describe('publisher', () => {
})
describe('User Interactions', () => {
it('should navigate to add documents when go to add documents is clicked', async () => {
it('should link to add documents when the pipeline is published', () => {
mockPublishedAt.mockReturnValue(1700000000)
renderWithQueryClient(<Popup />)
const addDocumentsButton = screen
.getAllByRole('button')
.find((btn) => btn.textContent?.includes('pipeline.common.goToAddDocuments'))
fireEvent.click(addDocumentsButton!)
expect(mockPush).toHaveBeenCalledWith(
'/datasets/test-dataset-id/documents/create-from-pipeline',
)
expect(
screen.getByRole('link', { name: 'pipeline.common.goToAddDocuments' }),
).toHaveAttribute('href', '/datasets/test-dataset-id/documents/create-from-pipeline')
})
it('should show pricing modal when publish as template is clicked without permission', async () => {
@ -913,9 +891,12 @@ describe('publisher', () => {
renderWithQueryClient(<Popup />)
const apiLink = screen.getByRole('link')
const apiLink = screen.getByRole('link', {
name: 'workflow.common.accessAPIReference',
})
expect(apiLink).toHaveAttribute('href', 'https://api.dify.ai/v1/datasets/test-dataset-id')
expect(apiLink).toHaveAttribute('target', '_blank')
expect(apiLink).toHaveAttribute('rel', 'noopener noreferrer')
})
})

View File

@ -39,7 +39,6 @@ vi.mock('@langgenius/dify-ui/toast', () => ({
promise: toastMocks.promise,
}),
}))
const mockPush = vi.fn()
const mockHandleCheckBeforePublish = vi.fn().mockResolvedValue(true)
const mockSetPublishedAt = vi.fn()
const mockMutateDatasetRes = vi.fn()
@ -60,13 +59,10 @@ let mockWorkspacePermissionKeys: string[] = []
const mockUseBoolean = vi.hoisted(() => vi.fn())
vi.mock('@/next/navigation', () => ({
useParams: () => ({ datasetId: 'ds-123' }),
useRouter: () => ({ push: mockPush }),
}))
vi.mock('@/next/link', () => ({
default: ({ children, href }: { children: React.ReactNode; href: string }) => (
<a href={href}>{children}</a>
),
default: ({ children, ...props }: React.ComponentProps<'a'>) => <a {...props}>{children}</a>,
}))
vi.mock('ahooks', () => ({
@ -275,18 +271,6 @@ describe('Popup', () => {
expect(container.querySelectorAll('kbd')).toHaveLength(3)
})
it('should render "Go to Add Documents" button', () => {
render(<Popup />)
expect(screen.getByText('pipeline.common.goToAddDocuments')).toBeInTheDocument()
})
it('should render "API Reference" button', () => {
render(<Popup />)
expect(screen.getByText('workflow.common.accessAPIReference')).toBeInTheDocument()
})
it('should render "Publish As" button', () => {
const { container } = render(<Popup />)
@ -312,12 +296,21 @@ describe('Popup', () => {
})
describe('Navigation', () => {
it('should navigate to add documents page', () => {
it('should link to the add documents page', () => {
render(<Popup />)
fireEvent.click(screen.getByText('pipeline.common.goToAddDocuments'))
expect(
screen.getByRole('link', { name: 'pipeline.common.goToAddDocuments' }),
).toHaveAttribute('href', '/datasets/ds-123/documents/create-from-pipeline')
})
expect(mockPush).toHaveBeenCalledWith('/datasets/ds-123/documents/create-from-pipeline')
it('should open the API reference safely in a new tab', () => {
render(<Popup />)
const link = screen.getByRole('link', { name: 'workflow.common.accessAPIReference' })
expect(link).toHaveAttribute('href', '/api/datasets/ds-123')
expect(link).toHaveAttribute('target', '_blank')
expect(link).toHaveAttribute('rel', 'noopener noreferrer')
})
})

View File

@ -8,7 +8,7 @@ import {
AlertDialogDescription,
AlertDialogTitle,
} from '@langgenius/dify-ui/alert-dialog'
import { Button } from '@langgenius/dify-ui/button'
import { Button, buttonVariants } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { Kbd, KbdGroup } from '@langgenius/dify-ui/kbd'
import { toast } from '@langgenius/dify-ui/toast'
@ -37,7 +37,7 @@ import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import { useDatasetApiAccessUrl } from '@/hooks/use-api-access-url'
import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now'
import Link from '@/next/link'
import { useParams, useRouter } from '@/next/navigation'
import { useParams } from '@/next/navigation'
import { useInvalidDatasetList } from '@/service/knowledge/use-dataset'
import { useInvalid } from '@/service/use-base'
import { publishedPipelineInfoQueryKeyPrefix } from '@/service/use-pipeline'
@ -68,7 +68,6 @@ export function Popup({
select: ({ deployment_edition }) => deployment_edition,
})
const { datasetId } = useParams()
const { push } = useRouter()
const publishedAt = useStore((s) => s.publishedAt)
const draftUpdatedAt = useStore((s) => s.draftUpdatedAt)
const pipelineId = useStore((s) => s.pipelineId)
@ -195,11 +194,6 @@ export function Popup({
ignoreInputs: true,
preventDefault: true,
})
const goToAddDocuments = useCallback(() => {
if (isAddDocumentsDisabled) return
push(`/datasets/${datasetId}/documents/create-from-pipeline`)
}, [datasetId, isAddDocumentsDisabled, push])
const handleClickPublishAsKnowledgePipeline = useCallback(() => {
onRequestClose?.()
if (!isAllowPublishAsCustomKnowledgePipelineTemplate) {
@ -263,23 +257,54 @@ export function Popup({
</Button>
</div>
<div className="border-t-[0.5px] border-t-divider-regular p-4 pt-3">
<Button
className="mb-1 w-full hover:bg-state-accent-hover hover:text-text-accent"
variant="tertiary"
onClick={goToAddDocuments}
disabled={isAddDocumentsDisabled}
>
<div className="flex grow items-center">
<RiPlayCircleLine className="mr-2 size-4" />
{t(($) => $['common.goToAddDocuments'], { ns: 'pipeline' })}
</div>
<RiArrowRightUpLine className="size-4 shrink-0" />
</Button>
<Link href={apiReferenceUrl} target="_blank" rel="noopener noreferrer">
{isAddDocumentsDisabled ? (
<Button
className="mb-1 w-full hover:bg-state-accent-hover hover:text-text-accent"
variant="tertiary"
disabled
>
<div className="flex grow items-center">
<RiPlayCircleLine className="mr-2 size-4" />
{t(($) => $['common.goToAddDocuments'], { ns: 'pipeline' })}
</div>
<RiArrowRightUpLine className="size-4 shrink-0" />
</Button>
) : (
<Link
href={`/datasets/${datasetId}/documents/create-from-pipeline`}
className={cn(
buttonVariants({ variant: 'tertiary' }),
'mb-1 w-full hover:bg-state-accent-hover hover:text-text-accent',
)}
>
<div className="flex grow items-center">
<RiPlayCircleLine className="mr-2 size-4" />
{t(($) => $['common.goToAddDocuments'], { ns: 'pipeline' })}
</div>
<RiArrowRightUpLine className="size-4 shrink-0" />
</Link>
)}
{publishedAt ? (
<Link
href={apiReferenceUrl}
target="_blank"
rel="noopener noreferrer"
className={cn(
buttonVariants({ variant: 'tertiary' }),
'w-full hover:bg-state-accent-hover hover:text-text-accent',
)}
>
<div className="flex grow items-center">
<RiTerminalBoxLine className="mr-2 size-4" />
{t(($) => $['common.accessAPIReference'], { ns: 'workflow' })}
</div>
<RiArrowRightUpLine className="size-4 shrink-0" />
</Link>
) : (
<Button
className="w-full hover:bg-state-accent-hover hover:text-text-accent"
variant="tertiary"
disabled={!publishedAt}
disabled
>
<div className="flex grow items-center">
<RiTerminalBoxLine className="mr-2 size-4" />
@ -287,7 +312,7 @@ export function Popup({
</div>
<RiArrowRightUpLine className="size-4 shrink-0" />
</Button>
</Link>
)}
<Divider className="my-2" />
<Button
className="w-full hover:bg-state-accent-hover hover:text-text-accent"

View File

@ -16,7 +16,7 @@ import {
AlertDialogDescription,
AlertDialogTitle,
} from '@langgenius/dify-ui/alert-dialog'
import { Button } from '@langgenius/dify-ui/button'
import { Button, buttonVariants } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import {
Drawer,
@ -336,24 +336,21 @@ const ProviderDetail = ({ collection, onHide, onRefreshData }: Props) => {
!isDetailLoading &&
customCollection && (
<>
<Button
nativeButton={false}
variant="primary"
className={cn('my-3 h-8 min-w-0 flex-1 rounded-lg py-2')}
render={
<a
href={`${basePath}/app/${(customCollection as WorkflowToolProviderResponse).workflow_app_id}/workflow`}
rel="noreferrer"
target="_blank"
aria-label={t(($) => $.openInStudio, { ns: 'tools' })}
/>
}
<a
href={`${basePath}/app/${(customCollection as WorkflowToolProviderResponse).workflow_app_id}/workflow`}
rel="noreferrer"
target="_blank"
aria-label={t(($) => $.openInStudio, { ns: 'tools' })}
className={cn(
buttonVariants({ variant: 'primary' }),
'my-3 h-8 min-w-0 flex-1 rounded-lg py-2',
)}
>
<span className="min-w-0 truncate system-sm-medium">
{t(($) => $.openInStudio, { ns: 'tools' })}
</span>
<span aria-hidden className="i-ri-arrow-right-up-line size-4 shrink-0" />
</Button>
</a>
<Button
variant="secondary"
className={cn('my-3 h-8 min-w-0 flex-1 rounded-lg py-2')}

View File

@ -41,7 +41,7 @@ export function EditInConsoleLink({
if (canManageAgents) {
return (
<Link
className={cn(buttonVariants({ className: layoutClassName }))}
className={cn(buttonVariants(), layoutClassName)}
href={getAgentDetailPath(agentId, 'configure')}
target="_blank"
rel="noopener noreferrer"

View File

@ -233,12 +233,10 @@ const Right = ({ nodeId, currentNodeVar, handleOpenMenu, isValueFetching }: Prop
href={fullContent?.download_url}
target="_blank"
rel="noopener noreferrer"
className={buttonVariants({
variant: 'ghost',
size: 'small',
className:
'size-6 rounded-lg p-0 text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary',
})}
className={cn(
buttonVariants({ variant: 'ghost', size: 'small' }),
'size-6 rounded-lg p-0 text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary',
)}
>
<span aria-hidden className="i-ri-file-download-fill size-4" />
</a>

View File

@ -19,7 +19,7 @@ import {
AlertDialogDescription,
AlertDialogTitle,
} from '@langgenius/dify-ui/alert-dialog'
import { Button } from '@langgenius/dify-ui/button'
import { Button, buttonVariants } from '@langgenius/dify-ui/button'
import { Dialog, DialogContent, DialogDescription, DialogTitle } from '@langgenius/dify-ui/dialog'
import { Field, FieldLabel } from '@langgenius/dify-ui/field'
import { Textarea } from '@langgenius/dify-ui/textarea'
@ -36,6 +36,7 @@ import ModelParameterModal from '@/app/components/header/account-setting/model-p
import WorkflowPreview from '@/app/components/workflow/workflow-preview'
import { WORKFLOW_GENERATION_TIMEOUT_MS } from '@/config'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import Link from '@/next/link'
import { useRouter } from '@/next/navigation'
import { fetchWorkflowDraft } from '@/service/workflow'
import { generateWorkflow, generateWorkflowStream } from '@/service/workflow-generator'
@ -684,16 +685,13 @@ function WorkflowGeneratorModal() {
{t(($) => $['workflowGenerator.regenerate'])}
</Button>
{genErrorHasUnknownTool && (
<Button
size="small"
variant="secondary"
onClick={() => {
closeGenerator()
router.push('/tools')
}}
<Link
href="/tools"
className={buttonVariants({ size: 'small', variant: 'secondary' })}
onClick={closeGenerator}
>
{t(($) => $['workflowGenerator.errors.installTools'])}
</Button>
</Link>
)}
</div>
</div>

View File

@ -3,59 +3,44 @@ import { describe, expect, it, vi } from 'vite-plus/test'
import { setPostLoginRedirect } from '@/app/signin/utils/post-login-redirect'
import Chooser from '../chooser'
const mockPush = vi.fn()
vi.mock('@/next/navigation', () => ({
useRouter: () => ({ push: mockPush }),
}))
vi.mock('@/app/signin/utils/post-login-redirect', () => ({
setPostLoginRedirect: vi.fn(),
}))
describe('Chooser', () => {
it('renders account button', () => {
it('renders an account sign-in link', () => {
render(<Chooser userCode="ABCD-3456" ssoAvailable={false} />)
expect(
screen.getByRole('button', { name: /deviceFlow.chooser.signInAccount/i }),
).toBeInTheDocument()
expect(screen.getByRole('link', { name: /deviceFlow.chooser.signInAccount/i })).toHaveAttribute(
'href',
'/signin',
)
})
it('hides SSO button when ssoAvailable is false', () => {
render(<Chooser userCode="ABCD-3456" ssoAvailable={false} />)
expect(
screen.queryByRole('button', { name: /deviceFlow.chooser.signInSSO/i }),
screen.queryByRole('link', { name: /deviceFlow.chooser.signInSSO/i }),
).not.toBeInTheDocument()
})
it('shows SSO button when ssoAvailable is true', () => {
render(<Chooser userCode="ABCD-3456" ssoAvailable={true} />)
expect(
screen.getByRole('button', { name: /deviceFlow.chooser.signInSSO/i }),
).toBeInTheDocument()
})
it('sets post-login redirect and navigates to /signin on account button click', () => {
it('sets the post-login redirect when the account link is activated', () => {
render(<Chooser userCode="ABCD-3456" ssoAvailable={false} />)
fireEvent.click(screen.getByRole('button', { name: /deviceFlow.chooser.signInAccount/i }))
fireEvent.click(screen.getByRole('link', { name: /deviceFlow.chooser.signInAccount/i }))
expect(vi.mocked(setPostLoginRedirect)).toHaveBeenCalledWith('/device?user_code=ABCD-3456')
expect(mockPush).toHaveBeenCalledWith('/signin')
})
it('encodes userCode in post-login redirect', () => {
// Uses a code with a space to exercise encodeURIComponent
render(<Chooser userCode="AB CD" ssoAvailable={false} />)
fireEvent.click(screen.getByRole('button', { name: /deviceFlow.chooser.signInAccount/i }))
fireEvent.click(screen.getByRole('link', { name: /deviceFlow.chooser.signInAccount/i }))
expect(vi.mocked(setPostLoginRedirect)).toHaveBeenCalledWith('/device?user_code=AB%20CD')
})
it('navigates to SSO initiate URL on SSO button click', () => {
Object.defineProperty(window, 'location', {
writable: true,
value: { href: '' },
})
it('links to the SSO initiate URL', () => {
render(<Chooser userCode="ABCD-3456" ssoAvailable={true} />)
fireEvent.click(screen.getByRole('button', { name: /deviceFlow.chooser.signInSSO/i }))
expect(window.location.href).toBe('/openapi/v1/oauth/device/sso-initiate?user_code=ABCD-3456')
expect(screen.getByRole('link', { name: /deviceFlow.chooser.signInSSO/i })).toHaveAttribute(
'href',
'/openapi/v1/oauth/device/sso-initiate?user_code=ABCD-3456',
)
})
})

View File

@ -1,10 +1,11 @@
'use client'
import type { FC } from 'react'
import { Button } from '@langgenius/dify-ui/button'
import { buttonVariants } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { useTranslation } from 'react-i18next'
import { setPostLoginRedirect } from '@/app/signin/utils/post-login-redirect'
import { useRouter } from '@/next/navigation'
import Link from '@/next/link'
type Props = {
userCode: string
@ -26,28 +27,26 @@ type Props = {
*/
const Chooser: FC<Props> = ({ userCode, ssoAvailable }) => {
const { t } = useTranslation('deviceFlow')
const router = useRouter()
const onAccount = () => {
setPostLoginRedirect(`/device?user_code=${encodeURIComponent(userCode)}`)
router.push('/signin')
}
const onSSO = () => {
window.location.href = `/openapi/v1/oauth/device/sso-initiate?user_code=${encodeURIComponent(userCode)}`
}
const deviceReturnPath = `/device?user_code=${encodeURIComponent(userCode)}`
return (
<div className="flex flex-col gap-3">
<Button variant="primary" size="large" className="w-full" onClick={onAccount}>
<Link
href="/signin"
className={cn(buttonVariants({ variant: 'primary', size: 'large' }), 'w-full')}
onClick={() => setPostLoginRedirect(deviceReturnPath)}
>
<span className="i-ri-user-3-line h-4 w-4" />
{t(($) => $['chooser.signInAccount'])}
</Button>
</Link>
{ssoAvailable && (
<Button variant="secondary" size="large" className="w-full" onClick={onSSO}>
<a
href={`/openapi/v1/oauth/device/sso-initiate?user_code=${encodeURIComponent(userCode)}`}
className={cn(buttonVariants({ variant: 'secondary', size: 'large' }), 'w-full')}
>
<span className="i-ri-shield-line h-4 w-4" />
{t(($) => $['chooser.signInSSO'])}
</Button>
</a>
)}
</div>
)

View File

@ -1,6 +1,7 @@
'use client'
import { Button } from '@langgenius/dify-ui/button'
import { Button, buttonVariants } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { useQuery, useSuspenseQuery } from '@tanstack/react-query'
import { useEffect, useState } from 'react'
import { Trans, useTranslation } from 'react-i18next'
@ -8,6 +9,7 @@ import Divider from '@/app/components/base/divider'
import { userProfileQueryOptions } from '@/features/account-profile/client'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import useDocumentTitle from '@/hooks/use-document-title'
import Link from '@/next/link'
import { usePathname, useRouter, useSearchParams } from '@/next/navigation'
import { consoleQuery } from '@/service/client'
import { deviceLookup } from '@/service/device-flow'
@ -214,9 +216,9 @@ export default function DevicePage() {
</h1>
<p className="text-sm text-text-secondary">{t(($) => $['success.subtitle'])}</p>
<Divider className="my-3" />
<Button variant="ghost" className="w-full" onClick={() => router.push('/')}>
<Link href="/" className={cn(buttonVariants({ variant: 'ghost' }), 'w-full')}>
{t(($) => $['success.goToConsole'])}
</Button>
</Link>
</div>
)}

View File

@ -1,6 +1,6 @@
'use client'
import { CheckCircleIcon } from '@heroicons/react/24/solid'
import { Button } from '@langgenius/dify-ui/button'
import { Button, buttonVariants } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { toast } from '@langgenius/dify-ui/toast'
import { useCallback, useState } from 'react'
@ -91,9 +91,12 @@ const ChangePasswordForm = () => {
</h1>
</div>
<div className="mx-auto mt-6 w-full">
<Button variant="primary" className="w-full text-sm!">
<a href="https://dify.ai">{t(($) => $.explore, { ns: 'login' })}</a>
</Button>
<a
href="https://dify.ai"
className={cn(buttonVariants({ variant: 'primary' }), 'w-full text-sm!')}
>
{t(($) => $.explore, { ns: 'login' })}
</a>
</div>
</div>
)}
@ -171,9 +174,12 @@ const ChangePasswordForm = () => {
</h1>
</div>
<div className="mx-auto mt-6 w-full">
<Button variant="primary" className="w-full">
<a href={`${basePath}/signin`}>{t(($) => $.passwordChanged, { ns: 'login' })}</a>
</Button>
<a
href={`${basePath}/signin`}
className={cn(buttonVariants({ variant: 'primary' }), 'w-full')}
>
{t(($) => $.passwordChanged, { ns: 'login' })}
</a>
</div>
</div>
)}

View File

@ -8,12 +8,6 @@ import {
} from '@/service/common'
import ForgotPasswordForm from './ForgotPasswordForm'
const mockPush = vi.fn()
vi.mock('@/next/navigation', () => ({
useRouter: () => ({ push: mockPush }),
}))
vi.mock('@/service/common', () => ({
fetchSetupStatus: vi.fn(),
fetchInitValidateStatus: vi.fn(),
@ -65,7 +59,7 @@ describe('ForgotPasswordForm', () => {
expect(mockSendForgotPasswordEmail).not.toHaveBeenCalled()
})
it('should send reset email and navigate after confirmation', async () => {
it('should send the reset email and show a sign-in link after confirmation', async () => {
mockSendForgotPasswordEmail.mockResolvedValue({ result: 'success', data: 'ok' } as any)
render(<ForgotPasswordForm />)
@ -83,12 +77,12 @@ describe('ForgotPasswordForm', () => {
})
await waitFor(() => {
expect(screen.getByRole('button', { name: /login\.backToSignIn/ })).toBeInTheDocument()
expect(screen.getByRole('link', { name: /login\.backToSignIn/ })).toHaveAttribute(
'href',
'/signin',
)
})
expect(mockUseDocumentTitle).toHaveBeenLastCalledWith('login.resetLinkSent')
fireEvent.click(screen.getByRole('button', { name: /login\.backToSignIn/ }))
expect(mockPush).toHaveBeenCalledWith('/signin')
})
it('should submit when form is submitted', async () => {
@ -139,7 +133,7 @@ describe('ForgotPasswordForm', () => {
resolveRequest?.({ result: 'success', data: 'ok' })
await waitFor(() => {
expect(screen.getByRole('button', { name: /login\.backToSignIn/ })).toBeInTheDocument()
expect(screen.getByRole('link', { name: /login\.backToSignIn/ })).toBeInTheDocument()
})
})
@ -159,7 +153,6 @@ describe('ForgotPasswordForm', () => {
})
expect(screen.getByRole('button', { name: /login\.sendResetLink/ })).toBeInTheDocument()
expect(mockPush).not.toHaveBeenCalled()
consoleSpy.mockRestore()
})

View File

@ -1,6 +1,7 @@
'use client'
import type { InitValidateStatusResponse } from '@/models/common'
import { Button } from '@langgenius/dify-ui/button'
import { Button, buttonVariants } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { useStore } from '@tanstack/react-form'
import * as React from 'react'
import { useEffect, useState } from 'react'
@ -9,7 +10,7 @@ import * as z from 'zod'
import { formContext, useAppForm } from '@/app/components/base/form'
import { zodSubmitValidator } from '@/app/components/base/form/utils/zod-submit-validator'
import useDocumentTitle from '@/hooks/use-document-title'
import { useRouter } from '@/next/navigation'
import Link from '@/next/link'
import {
fetchInitValidateStatus,
fetchSetupStatus,
@ -27,7 +28,6 @@ const accountFormSchema = z.object({
const ForgotPasswordForm = () => {
const { t } = useTranslation()
const router = useRouter()
const [loading, setLoading] = useState(true)
const [isEmailSent, setIsEmailSent] = useState(false)
const documentTitle = loading
@ -59,14 +59,10 @@ const ForgotPasswordForm = () => {
const isSubmitting = useStore(form.store, (state) => state.isSubmitting)
const emailErrors = useStore(form.store, (state) => state.fieldMeta.email?.errors)
const handleSendResetPasswordClick = async () => {
const handleSendResetPasswordClick = () => {
if (isSubmitting) return
if (isEmailSent) {
router.push('/signin')
} else {
form.handleSubmit()
}
form.handleSubmit()
}
useEffect(() => {
@ -134,16 +130,23 @@ const ForgotPasswordForm = () => {
</div>
)}
<div>
<Button
variant="primary"
className="w-full"
disabled={isSubmitting}
onClick={handleSendResetPasswordClick}
>
{isEmailSent
? t(($) => $.backToSignIn, { ns: 'login' })
: t(($) => $.sendResetLink, { ns: 'login' })}
</Button>
{isEmailSent ? (
<Link
href="/signin"
className={cn(buttonVariants({ variant: 'primary' }), 'w-full')}
>
{t(($) => $.backToSignIn, { ns: 'login' })}
</Link>
) : (
<Button
variant="primary"
className="w-full"
disabled={isSubmitting}
onClick={handleSendResetPasswordClick}
>
{t(($) => $.sendResetLink, { ns: 'login' })}
</Button>
)}
</div>
</form>
</formContext.Provider>

View File

@ -21,6 +21,14 @@ vi.mock('@/next/navigation', () => ({
useSearchParams: vi.fn(),
}))
vi.mock('@/next/link', () => ({
default: ({ children, replace, ...props }: React.ComponentProps<'a'> & { replace?: boolean }) => (
<a {...props} data-replace={replace || undefined}>
{children}
</a>
),
}))
vi.mock('@/service/common', () => ({
changePasswordWithToken: vi.fn(),
}))
@ -59,7 +67,7 @@ const completePasswordChange = async () => {
fireEvent.click(screen.getByRole('button', { name: 'login.changePasswordBtn' }))
await waitFor(() => {
expect(screen.getByRole('button', { name: /login\.passwordChanged/ })).toBeInTheDocument()
expect(screen.getByRole('link', { name: /login\.passwordChanged/ })).toBeInTheDocument()
})
expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('login.passwordChangedTip')
expect(mockUseDocumentTitle).toHaveBeenLastCalledWith('login.passwordChangedTip')
@ -117,9 +125,9 @@ describe('Reset Password Set Password Page', () => {
setSearchParams({ token: 'reset-token', redirect_url: redirectUrl })
await completePasswordChange()
fireEvent.click(screen.getByRole('button', { name: /login\.passwordChanged/ }))
expect(mockReplace).toHaveBeenCalledWith(encodedSigninUrl)
const link = screen.getByRole('link', { name: /login\.passwordChanged/ })
expect(link).toHaveAttribute('href', encodedSigninUrl)
expect(link).toHaveAttribute('data-replace', 'true')
})
it('should preserve redirect_url when the countdown returns to sign in automatically', async () => {
@ -140,17 +148,19 @@ describe('Reset Password Set Password Page', () => {
})
await completePasswordChange()
fireEvent.click(screen.getByRole('button', { name: /login\.passwordChanged/ }))
expect(mockReplace).toHaveBeenCalledWith('/activate?token=invite-token')
expect(screen.getByRole('link', { name: /login\.passwordChanged/ })).toHaveAttribute(
'href',
'/activate?token=invite-token',
)
})
it('should return to plain sign in when no redirect target is present', async () => {
await completePasswordChange()
fireEvent.click(screen.getByRole('button', { name: /login\.passwordChanged/ }))
expect(mockReplace).toHaveBeenCalledWith('/signin')
expect(screen.getByRole('link', { name: /login\.passwordChanged/ })).toHaveAttribute(
'href',
'/signin',
)
})
})
})

View File

@ -1,5 +1,5 @@
'use client'
import { Button } from '@langgenius/dify-ui/button'
import { Button, buttonVariants } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { Field, FieldDescription, FieldLabel } from '@langgenius/dify-ui/field'
import { Form } from '@langgenius/dify-ui/form'
@ -12,6 +12,7 @@ import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { validPassword } from '@/config'
import useDocumentTitle from '@/hooks/use-document-title'
import Link from '@/next/link'
import { useRouter, useSearchParams } from '@/next/navigation'
import { changePasswordWithToken } from '@/service/common'
@ -195,16 +196,13 @@ const ChangePasswordForm = () => {
</h1>
</div>
<div className="mx-auto mt-6 w-full">
<Button
variant="primary"
className="w-full"
onClick={() => {
setLeftTime(undefined)
router.replace(getSignInUrl())
}}
<Link
href={getSignInUrl()}
replace
className={cn(buttonVariants({ variant: 'primary' }), 'w-full')}
>
{t(($) => $.passwordChanged, { ns: 'login' })} ({Math.round(countdown / 1000)}){' '}
</Button>
</Link>
</div>
</div>
)}

View File

@ -27,17 +27,11 @@ export default function SocialAuth() {
}
return (
<>
<a
className={buttonVariants({ className: 'w-full' })}
href={getOAuthLink('/oauth/login/github')}
>
<a className={cn(buttonVariants(), 'w-full')} href={getOAuthLink('/oauth/login/github')}>
<span aria-hidden="true" className={cn(style.githubIcon, 'size-5')} />
<span className="truncate leading-normal">{t(($) => $.withGitHub, { ns: 'login' })}</span>
</a>
<a
className={buttonVariants({ className: 'w-full' })}
href={getOAuthLink('/oauth/login/google')}
>
<a className={cn(buttonVariants(), 'w-full')} href={getOAuthLink('/oauth/login/google')}>
<span aria-hidden="true" className={cn(style.googleIcon, 'size-5')} />
<span className="truncate leading-normal">{t(($) => $.withGoogle, { ns: 'login' })}</span>
</a>

View File

@ -1,6 +1,7 @@
'use client'
import type { Locale } from '@/i18n-config'
import { Button } from '@langgenius/dify-ui/button'
import { Button, buttonVariants } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { Input } from '@langgenius/dify-ui/input'
import {
Select,
@ -197,9 +198,12 @@ export default function InviteSettingsPage() {
</h1>
</div>
<div className="mx-auto mt-6 w-full">
<Button variant="primary" className="w-full text-sm!">
<a href="https://dify.ai">{t(($) => $.explore, { ns: 'login' })}</a>
</Button>
<a
href="https://dify.ai"
className={cn(buttonVariants({ variant: 'primary' }), 'w-full text-sm!')}
>
{t(($) => $.explore, { ns: 'login' })}
</a>
</div>
</div>
)

View File

@ -443,7 +443,7 @@ describe('Agent access surface cards', () => {
expect(dialog).toHaveTextContent(/NEXT_PUBLIC_APP_ID=\s*'app-1'/)
expect(dialog).toHaveTextContent(/NEXT_PUBLIC_API_URL=\s*'https:\/\/api\.example\.test\/v1'/)
expect(
within(dialog).getByRole('button', {
within(dialog).getByRole('link', {
name: /appOverview\.overview\.appInfo\.customize\.way1\.step1Operation/,
}),
).toHaveAttribute('href', 'https://github.com/langgenius/webapp-conversation')