diff --git a/packages/dify-ui/src/button/__tests__/index.spec.tsx b/packages/dify-ui/src/button/__tests__/index.spec.tsx index ea99dc3800a..e58d031af6d 100644 --- a/packages/dify-ui/src/button/__tests__/index.spec.tsx +++ b/packages/dify-ui/src/button/__tests__/index.spec.tsx @@ -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( - , ) - 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') }) }) diff --git a/web/app/(shareLayout)/webapp-reset-password/set-password/page.tsx b/web/app/(shareLayout)/webapp-reset-password/set-password/page.tsx index 204954e9e19..96ce4c1335e 100644 --- a/web/app/(shareLayout)/webapp-reset-password/set-password/page.tsx +++ b/web/app/(shareLayout)/webapp-reset-password/set-password/page.tsx @@ -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 = () => {
- +
)} diff --git a/web/app/account/(commonLayout)/header.tsx b/web/app/account/(commonLayout)/header.tsx index 57cf2ab381d..6a3c9ca06b9 100644 --- a/web/app/account/(commonLayout)/header.tsx +++ b/web/app/account/(commonLayout)/header.tsx @@ -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 = () => {

- +
diff --git a/web/app/account/oauth/authorize/page.tsx b/web/app/account/oauth/authorize/page.tsx index 8b941e461d6..28d54aa82f0 100644 --- a/web/app/account/oauth/authorize/page.tsx +++ b/web/app/account/oauth/authorize/page.tsx @@ -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() {
{!isLoggedIn ? ( - + ) : ( <> - + )}
diff --git a/web/app/activate/activateForm.tsx b/web/app/activate/activateForm.tsx index 9d9e624594c..188bc2c5a8a 100644 --- a/web/app/activate/activateForm.tsx +++ b/web/app/activate/activateForm.tsx @@ -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 = () => {
- + + {t(($) => $.explore, { ns: 'login' })} +
)} diff --git a/web/app/components/app/access-point/__tests__/environment-access-point-cards.spec.tsx b/web/app/components/app/access-point/__tests__/environment-access-point-cards.spec.tsx index 95f752897a2..efa1ceb37d2 100644 --- a/web/app/components/app/access-point/__tests__/environment-access-point-cards.spec.tsx +++ b/web/app/components/app/access-point/__tests__/environment-access-point-cards.spec.tsx @@ -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() }) }) diff --git a/web/app/components/app/access-point/__tests__/service-api-card.spec.tsx b/web/app/components/app/access-point/__tests__/service-api-card.spec.tsx index b6aca69d3f4..1399ab48084 100644 --- a/web/app/components/app/access-point/__tests__/service-api-card.spec.tsx +++ b/web/app/components/app/access-point/__tests__/service-api-card.spec.tsx @@ -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() }) }) diff --git a/web/app/components/app/access-point/built-in-access-points/index.tsx b/web/app/components/app/access-point/built-in-access-points/index.tsx index d132a08cd31..95847013f29 100644 --- a/web/app/components/app/access-point/built-in-access-points/index.tsx +++ b/web/app/components/app/access-point/built-in-access-points/index.tsx @@ -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 })} - + {capabilities.canReleaseAndVersion ? ( + + {t(($) => $['studio.accessPoint.goToPublish'], { ns: 'deployments' })} + + + ) : ( + + )} )} diff --git a/web/app/components/app/access-point/shared/access-point-url.tsx b/web/app/components/app/access-point/shared/access-point-url.tsx index 7b267dcf915..cd90c57c111 100644 --- a/web/app/components/app/access-point/shared/access-point-url.tsx +++ b/web/app/components/app/access-point/shared/access-point-url.tsx @@ -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', + )} > {openLabel} diff --git a/web/app/components/app/access-point/shared/service-api-card-view.tsx b/web/app/components/app/access-point/shared/service-api-card-view.tsx index e1a39d457ef..aea557a21b3 100644 --- a/web/app/components/app/access-point/shared/service-api-card-view.tsx +++ b/web/app/components/app/access-point/shared/service-api-card-view.tsx @@ -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={ <> - + {available && apiReferenceUrl ? ( + + + {t(($) => $['overview.apiInfo.doc'], { ns: 'appOverview' })} + + + ) : ( + + )} } > diff --git a/web/app/components/app/deploy/deployment-dialog/version-selection/index.tsx b/web/app/components/app/deploy/deployment-dialog/version-selection/index.tsx index 2a67a35d336..0680e3b94d0 100644 --- a/web/app/components/app/deploy/deployment-dialog/version-selection/index.tsx +++ b/web/app/components/app/deploy/deployment-dialog/version-selection/index.tsx @@ -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'])}

{publishHref && ( - + )} )} diff --git a/web/app/components/app/in-site-message/__tests__/index.spec.tsx b/web/app/components/app/in-site-message/__tests__/index.spec.tsx index b823c389fb5..cdb504cb9f2 100644 --- a/web/app/components/app/in-site-message/__tests__/index.spec.tsx +++ b/web/app/components/app/in-site-message/__tests__/index.spec.tsx @@ -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() }) }) }) diff --git a/web/app/components/app/in-site-message/__tests__/notification.spec.tsx b/web/app/components/app/in-site-message/__tests__/notification.spec.tsx index 31dcc680c79..254c40a6e0a 100644 --- a/web/app/components/app/in-site-message/__tests__/notification.spec.tsx +++ b/web/app/components/app/in-site-message/__tests__/notification.spec.tsx @@ -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' })) diff --git a/web/app/components/app/in-site-message/index.tsx b/web/app/components/app/in-site-message/index.tsx index b09e8cd7cd0..e747530d1d2 100644 --- a/web/app/components/app/in-site-message/index.tsx +++ b/web/app/components/app/in-site-message/index.tsx @@ -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({
- {actions.map((item) => ( - - ))} + {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 ( + handleAction(item)} + > + {item.text} + + ) + } + + return ( + + ) + })}
) diff --git a/web/app/components/app/overview/customize/index.tsx b/web/app/components/app/overview/customize/index.tsx index 72d92d937b9..b77bcdeb06f 100644 --- a/web/app/components/app/overview/customize/index.tsx +++ b/web/app/components/app/overview/customize/index.tsx @@ -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 = ({
{t(($) => $[`${prefixCustomize}.way1.step1Tip`], { ns: 'appOverview' })}
- +
@@ -129,24 +126,20 @@ const CustomizeModal: FC = ({
{t(($) => $[`${prefixCustomize}.way1.step2Tip`], { ns: 'appOverview' })}
- +
diff --git a/web/app/components/base/features/new-feature-panel/annotation-reply/__tests__/index.spec.tsx b/web/app/components/base/features/new-feature-panel/annotation-reply/__tests__/index.spec.tsx index dce430294ec..b380506507c 100644 --- a/web/app/components/base/features/new-feature-panel/annotation-reply/__tests__/index.spec.tsx +++ b/web/app/components/base/features/new-feature-panel/annotation-reply/__tests__/index.spec.tsx @@ -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 () => { diff --git a/web/app/components/base/features/new-feature-panel/annotation-reply/index.tsx b/web/app/components/base/features/new-feature-panel/annotation-reply/index.tsx index d180617cdd9..5dc697148ef 100644 --- a/web/app/components/base/features/new-feature-panel/annotation-reply/index.tsx +++ b/web/app/components/base/features/new-feature-panel/annotation-reply/index.tsx @@ -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) => { {t(($) => $['operation.params'], { ns: 'common' })} - + )} diff --git a/web/app/components/base/markdown-blocks/__tests__/button.spec.tsx b/web/app/components/base/markdown-blocks/__tests__/button.spec.tsx index c45d560380f..aa9ef2c15d6 100644 --- a/web/app/components/base/markdown-blocks/__tests__/button.spec.tsx +++ b/web/app/components/base/markdown-blocks/__tests__/button.spec.tsx @@ -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 () => { diff --git a/web/app/components/base/markdown-blocks/button.tsx b/web/app/components/base/markdown-blocks/button.tsx index 20a6c506235..c930eacfb65 100644 --- a/web/app/components/base/markdown-blocks/button.tsx +++ b/web/app/components/base/markdown-blocks/button.tsx @@ -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 ( + + {label} + + ) + } return ( + + {t(($) => $['apps.contactUs'], { ns: 'billing' })} + )}
diff --git a/web/app/components/datasets/create-from-pipeline/header.tsx b/web/app/components/datasets/create-from-pipeline/header.tsx index 4989133cf6c..532f9c0c4a4 100644 --- a/web/app/components/datasets/create-from-pipeline/header.tsx +++ b/web/app/components/datasets/create-from-pipeline/header.tsx @@ -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 (
{t(($) => $['creation.backToKnowledge'], { ns: 'datasetPipeline' })} - - + +
) diff --git a/web/app/components/datasets/create/embedding-process/__tests__/index.spec.tsx b/web/app/components/datasets/create/embedding-process/__tests__/index.spec.tsx index 09eb56eb06f..4aa3641eabb 100644 --- a/web/app/components/datasets/create/embedding-process/__tests__/index.spec.tsx +++ b/web/app/components/datasets/create/embedding-process/__tests__/index.spec.tsx @@ -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() - 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', () => { diff --git a/web/app/components/datasets/create/embedding-process/index.tsx b/web/app/components/datasets/create/embedding-process/index.tsx index e0456f650a9..54719c14628 100644 --- a/web/app/components/datasets/create/embedding-process/index.tsx +++ b/web/app/components/datasets/create/embedding-process/index.tsx @@ -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 (
- - + + + Access the API - +
) } @@ -78,7 +86,6 @@ const EmbeddingProcess: FC = ({ retrievalMethod, }) => { const { enableBilling, plan } = useProviderContext() - const router = useRouter() const invalidDocumentList = useInvalidDocumentList() const apiReferenceUrl = useDatasetApiAccessUrl() @@ -95,10 +102,7 @@ const EmbeddingProcess: FC = ({ // 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 = ({ />
- + ) } diff --git a/web/app/components/datasets/documents/create-from-pipeline/actions/index.tsx b/web/app/components/datasets/documents/create-from-pipeline/actions/index.tsx index 729489ee06a..32eb39979a3 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/actions/index.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/actions/index.tsx @@ -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 = ({ )}
- - + + {t(($) => $['operation.cancel'], { ns: 'common' })}
{steps[currentStep - 1]?.label}
{currentStep !== steps.length && ( - - + + )} diff --git a/web/app/components/datasets/documents/create-from-pipeline/processing/embedding-process/__tests__/index.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/processing/embedding-process/__tests__/index.spec.tsx index 4993fe7a045..3318b62f7e2 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/processing/embedding-process/__tests__/index.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/processing/embedding-process/__tests__/index.spec.tsx @@ -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() - 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() - const navButton = screen.getByText('datasetCreation.stepThree.navTo') - fireEvent.click(navButton) - - expect(callOrder).toEqual(['invalidate', 'push']) }) }) diff --git a/web/app/components/datasets/documents/create-from-pipeline/processing/embedding-process/index.tsx b/web/app/components/datasets/documents/create-from-pipeline/processing/embedding-process/index.tsx index f09dca164a9..7f0b893322c 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/processing/embedding-process/index.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/processing/embedding-process/index.tsx @@ -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( [], @@ -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 = ({ />
- - + + + Access the API - +
) diff --git a/web/app/components/datasets/external-knowledge-base/connector/__tests__/index.spec.tsx b/web/app/components/datasets/external-knowledge-base/connector/__tests__/index.spec.tsx index dde44bc173f..2fe6bfdb624 100644 --- a/web/app/components/datasets/external-knowledge-base/connector/__tests__/index.spec.tsx +++ b/web/app/components/datasets/external-knowledge-base/connector/__tests__/index.spec.tsx @@ -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() - 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 () => { diff --git a/web/app/components/datasets/external-knowledge-base/create/__tests__/index.spec.tsx b/web/app/components/datasets/external-knowledge-base/create/__tests__/index.spec.tsx index db476ec812c..d8a210d0796 100644 --- a/web/app/components/datasets/external-knowledge-base/create/__tests__/index.spec.tsx +++ b/web/app/components/datasets/external-knowledge-base/create/__tests__/index.spec.tsx @@ -15,6 +15,14 @@ vi.mock('@/next/navigation', () => ({ }), })) +vi.mock('@/next/link', () => ({ + default: ({ children, replace, ...props }: React.ComponentProps<'a'> & { replace?: boolean }) => ( + + {children} + + ), +})) + // 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 () => { diff --git a/web/app/components/datasets/external-knowledge-base/create/index.tsx b/web/app/components/datasets/external-knowledge-base/create/index.tsx index 2d8ca76bed5..59172741482 100644 --- a/web/app/components/datasets/external-knowledge-base/create/index.tsx +++ b/web/app/components/datasets/external-knowledge-base/create/index.tsx @@ -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 = } />
- + } /> - +
) diff --git a/web/app/components/plugins/install-plugin/base/installed.tsx b/web/app/components/plugins/install-plugin/base/installed.tsx index 13643aff7cf..1da5b0b1e0b 100644 --- a/web/app/components/plugins/install-plugin/base/installed.tsx +++ b/web/app/components/plugins/install-plugin/base/installed.tsx @@ -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 = ({ {/* Action Buttons */}
- + + ) : ( + + )}
) diff --git a/web/app/components/plugins/marketplace/list/__tests__/card-wrapper.spec.tsx b/web/app/components/plugins/marketplace/list/__tests__/card-wrapper.spec.tsx index b73763059fa..d61071aadc7 100644 --- a/web/app/components/plugins/marketplace/list/__tests__/card-wrapper.spec.tsx +++ b/web/app/components/plugins/marketplace/list/__tests__/card-wrapper.spec.tsx @@ -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', () => { diff --git a/web/app/components/plugins/marketplace/list/card-wrapper.tsx b/web/app/components/plugins/marketplace/list/card-wrapper.tsx index f680a7770d3..0e3fcd0c186 100644 --- a/web/app/components/plugins/marketplace/list/card-wrapper.tsx +++ b/web/app/components/plugins/marketplace/list/card-wrapper.tsx @@ -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' })} - + {isShowInstallFromMarketplace && ( = ({ className, payload }) => { {t(($) => $['detailPanel.operation.install'], { ns: 'plugin' })} )} - + + {t(($) => $['detailPanel.operation.detail'], { ns: 'plugin' })} + + {isShowInstallFromMarketplace && ( { }) } -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 }) => ( - - {children} - - ), + default: ({ children, ...props }: React.ComponentProps<'a'>) => {children}, })) const mockHandleSyncWorkflowDraft = vi.fn() @@ -433,17 +427,6 @@ describe('publisher', () => { expect(addDocumentsButton).toBeDisabled() }) - it('should enable action buttons when published', () => { - mockPublishedAt.mockReturnValue(1700000000) - - renderWithQueryClient() - - 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() - 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() - 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') }) }) diff --git a/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/__tests__/popup.spec.tsx b/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/__tests__/popup.spec.tsx index d87a38d4a6c..88d1aefb459 100644 --- a/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/__tests__/popup.spec.tsx +++ b/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/__tests__/popup.spec.tsx @@ -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 }) => ( - {children} - ), + default: ({ children, ...props }: React.ComponentProps<'a'>) => {children}, })) vi.mock('ahooks', () => ({ @@ -275,18 +271,6 @@ describe('Popup', () => { expect(container.querySelectorAll('kbd')).toHaveLength(3) }) - it('should render "Go to Add Documents" button', () => { - render() - - expect(screen.getByText('pipeline.common.goToAddDocuments')).toBeInTheDocument() - }) - - it('should render "API Reference" button', () => { - render() - - expect(screen.getByText('workflow.common.accessAPIReference')).toBeInTheDocument() - }) - it('should render "Publish As" button', () => { const { container } = render() @@ -312,12 +296,21 @@ describe('Popup', () => { }) describe('Navigation', () => { - it('should navigate to add documents page', () => { + it('should link to the add documents page', () => { render() - 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() + + 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') }) }) diff --git a/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/popup.tsx b/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/popup.tsx index aedcef6fb68..73158b9790a 100644 --- a/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/popup.tsx +++ b/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/popup.tsx @@ -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({
- - + {isAddDocumentsDisabled ? ( + + ) : ( + +
+ + {t(($) => $['common.goToAddDocuments'], { ns: 'pipeline' })} +
+ + + )} + {publishedAt ? ( + +
+ + {t(($) => $['common.accessAPIReference'], { ns: 'workflow' })} +
+ + + ) : ( - + )} + {genErrorHasUnknownTool && ( - + )}
diff --git a/web/app/device/components/__tests__/chooser.spec.tsx b/web/app/device/components/__tests__/chooser.spec.tsx index e970d5e4029..94c74e60f7c 100644 --- a/web/app/device/components/__tests__/chooser.spec.tsx +++ b/web/app/device/components/__tests__/chooser.spec.tsx @@ -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() - 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() 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() - 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() - 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() - 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() - 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', + ) }) }) diff --git a/web/app/device/components/chooser.tsx b/web/app/device/components/chooser.tsx index 407abba16d1..11de05b2e95 100644 --- a/web/app/device/components/chooser.tsx +++ b/web/app/device/components/chooser.tsx @@ -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 = ({ 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 (
- + {ssoAvailable && ( - + )}
) diff --git a/web/app/device/page.tsx b/web/app/device/page.tsx index 4c77d6b9232..114f28854a7 100644 --- a/web/app/device/page.tsx +++ b/web/app/device/page.tsx @@ -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() {

{t(($) => $['success.subtitle'])}

- + )} diff --git a/web/app/forgot-password/ChangePasswordForm.tsx b/web/app/forgot-password/ChangePasswordForm.tsx index a7d073d070f..dc35ce3c8be 100644 --- a/web/app/forgot-password/ChangePasswordForm.tsx +++ b/web/app/forgot-password/ChangePasswordForm.tsx @@ -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 = () => { )} @@ -171,9 +174,12 @@ const ChangePasswordForm = () => { )} diff --git a/web/app/forgot-password/ForgotPasswordForm.spec.tsx b/web/app/forgot-password/ForgotPasswordForm.spec.tsx index 614f8a507f0..97fdaae9835 100644 --- a/web/app/forgot-password/ForgotPasswordForm.spec.tsx +++ b/web/app/forgot-password/ForgotPasswordForm.spec.tsx @@ -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() @@ -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() }) diff --git a/web/app/forgot-password/ForgotPasswordForm.tsx b/web/app/forgot-password/ForgotPasswordForm.tsx index ba63146c224..2c6c592abeb 100644 --- a/web/app/forgot-password/ForgotPasswordForm.tsx +++ b/web/app/forgot-password/ForgotPasswordForm.tsx @@ -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 = () => { )}
- + {isEmailSent ? ( + + {t(($) => $.backToSignIn, { ns: 'login' })} + + ) : ( + + )}
diff --git a/web/app/reset-password/set-password/__tests__/page.spec.tsx b/web/app/reset-password/set-password/__tests__/page.spec.tsx index 2f9d96f4e6e..769d6b0c3ea 100644 --- a/web/app/reset-password/set-password/__tests__/page.spec.tsx +++ b/web/app/reset-password/set-password/__tests__/page.spec.tsx @@ -21,6 +21,14 @@ vi.mock('@/next/navigation', () => ({ useSearchParams: vi.fn(), })) +vi.mock('@/next/link', () => ({ + default: ({ children, replace, ...props }: React.ComponentProps<'a'> & { replace?: boolean }) => ( + + {children} + + ), +})) + 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', + ) }) }) }) diff --git a/web/app/reset-password/set-password/page.tsx b/web/app/reset-password/set-password/page.tsx index 7ea375cf4a8..69aa3fb3aa1 100644 --- a/web/app/reset-password/set-password/page.tsx +++ b/web/app/reset-password/set-password/page.tsx @@ -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 = () => {
- +
)} diff --git a/web/app/signin/components/social-auth.tsx b/web/app/signin/components/social-auth.tsx index f1dcfa7a704..6c152856513 100644 --- a/web/app/signin/components/social-auth.tsx +++ b/web/app/signin/components/social-auth.tsx @@ -27,17 +27,11 @@ export default function SocialAuth() { } return ( <> - + - + diff --git a/web/app/signin/invite-settings/page.tsx b/web/app/signin/invite-settings/page.tsx index a6ad52e9bc0..150d1dad000 100644 --- a/web/app/signin/invite-settings/page.tsx +++ b/web/app/signin/invite-settings/page.tsx @@ -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() { ) diff --git a/web/features/agent-v2/agent-detail/access/components/__tests__/access-surface-cards.spec.tsx b/web/features/agent-v2/agent-detail/access/components/__tests__/access-surface-cards.spec.tsx index 62621b9b95c..536d14b8e95 100644 --- a/web/features/agent-v2/agent-detail/access/components/__tests__/access-surface-cards.spec.tsx +++ b/web/features/agent-v2/agent-detail/access/components/__tests__/access-surface-cards.spec.tsx @@ -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')