From 50a55513d48309b156b945e0cdb54e51efb63988 Mon Sep 17 00:00:00 2001 From: yyh <92089059+lyzno1@users.noreply.github.com> Date: Wed, 15 Apr 2026 15:03:40 +0800 Subject: [PATCH 01/61] refactor(ui): decouple CSS dependencies and improve test quality (#35242) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- .../__tests__/automatic-btn.spec.tsx | 16 -- .../__tests__/cloud.spec.tsx | 6 - .../__tests__/index.spec.tsx | 6 - .../apikey-info-panel.test-utils.tsx | 12 +- .../__tests__/index.spec.tsx | 43 ----- .../notion-connector/__tests__/index.spec.tsx | 1 - .../ui/alert-dialog/__tests__/index.spec.tsx | 10 +- .../components/base/ui/alert-dialog/index.tsx | 1 - web/app/components/base/ui/avatar/index.tsx | 2 +- .../base/ui/button/__tests__/index.spec.tsx | 66 +++++--- web/app/components/base/ui/button/index.css | 148 ------------------ web/app/components/base/ui/button/index.tsx | 86 ++++++++-- .../base/ui/dialog/__tests__/index.spec.tsx | 15 -- web/app/components/base/ui/dialog/index.tsx | 2 +- .../ui/dropdown-menu/__tests__/index.spec.tsx | 21 +-- .../ui/number-field/__tests__/index.spec.tsx | 12 -- .../base/ui/popover/__tests__/index.spec.tsx | 16 -- web/app/components/base/ui/popover/index.tsx | 2 + .../ui/scroll-area/__tests__/index.spec.tsx | 5 +- .../components/base/ui/scroll-area/index.tsx | 4 +- .../{index.module.css => scroll-area.css} | 30 ++-- .../base/ui/tooltip/__tests__/index.spec.tsx | 11 +- .../template-card/__tests__/actions.spec.tsx | 15 -- .../__tests__/csv-uploader.spec.tsx | 18 --- .../__tests__/modal.spec.tsx | 31 +--- .../dataset-metadata-drawer.spec.tsx | 7 +- .../__tests__/secret-key-button.spec.tsx | 12 -- .../__tests__/index.spec.tsx | 6 - .../__tests__/authorize-components.spec.tsx | 42 ----- .../__tests__/version-mismatch-modal.spec.tsx | 17 -- .../confirm-modal/__tests__/index.spec.tsx | 10 -- web/app/styles/globals.css | 1 - web/eslint-suppressions.json | 5 - 33 files changed, 166 insertions(+), 513 deletions(-) delete mode 100644 web/app/components/base/ui/button/index.css rename web/app/components/base/ui/scroll-area/{index.module.css => scroll-area.css} (57%) diff --git a/web/app/components/app/configuration/config/automatic/__tests__/automatic-btn.spec.tsx b/web/app/components/app/configuration/config/automatic/__tests__/automatic-btn.spec.tsx index b32c77ba21..218aa2732b 100644 --- a/web/app/components/app/configuration/config/automatic/__tests__/automatic-btn.spec.tsx +++ b/web/app/components/app/configuration/config/automatic/__tests__/automatic-btn.spec.tsx @@ -58,20 +58,4 @@ describe('AutomaticBtn', () => { expect(mockOnClick).toHaveBeenCalledTimes(3) }) }) - - describe('Styling', () => { - it('should have secondary-accent variant', () => { - render() - - const button = screen.getByRole('button') - expect(button.className).toContain('secondary-accent') - }) - - it('should have small size', () => { - render() - - const button = screen.getByRole('button') - expect(button.className).toContain('small') - }) - }) }) diff --git a/web/app/components/app/overview/apikey-info-panel/__tests__/cloud.spec.tsx b/web/app/components/app/overview/apikey-info-panel/__tests__/cloud.spec.tsx index 037803d355..e6abf54ac5 100644 --- a/web/app/components/app/overview/apikey-info-panel/__tests__/cloud.spec.tsx +++ b/web/app/components/app/overview/apikey-info-panel/__tests__/cloud.spec.tsx @@ -95,12 +95,6 @@ describe('APIKeyInfoPanel - Cloud Edition', () => { }) describe('Props and Styling', () => { - it('should render button with primary variant', () => { - scenarios.withAPIKeyNotSet() - const button = screen.getByRole('button') - expect(button).toHaveClass('btn-primary') - }) - it('should render panel container with correct classes', () => { const { container } = scenarios.withAPIKeyNotSet() const panel = container.firstChild as HTMLElement diff --git a/web/app/components/app/overview/apikey-info-panel/__tests__/index.spec.tsx b/web/app/components/app/overview/apikey-info-panel/__tests__/index.spec.tsx index d9c10b6ab9..7b3a12898a 100644 --- a/web/app/components/app/overview/apikey-info-panel/__tests__/index.spec.tsx +++ b/web/app/components/app/overview/apikey-info-panel/__tests__/index.spec.tsx @@ -108,12 +108,6 @@ describe('APIKeyInfoPanel - Community Edition', () => { }) describe('Props and Styling', () => { - it('should render button with primary variant', () => { - scenarios.withAPIKeyNotSet() - const button = screen.getByRole('button') - expect(button).toHaveClass('btn-primary') - }) - it('should render panel container with correct classes', () => { const { container } = scenarios.withAPIKeyNotSet() const panel = container.firstChild as HTMLElement diff --git a/web/app/components/app/overview/apikey-info-panel/apikey-info-panel.test-utils.tsx b/web/app/components/app/overview/apikey-info-panel/apikey-info-panel.test-utils.tsx index 4bab54b711..5d3c008989 100644 --- a/web/app/components/app/overview/apikey-info-panel/apikey-info-panel.test-utils.tsx +++ b/web/app/components/app/overview/apikey-info-panel/apikey-info-panel.test-utils.tsx @@ -1,7 +1,7 @@ import type { RenderOptions } from '@testing-library/react' import type { Mock, MockedFunction } from 'vitest' import type { ModalContextState } from '@/context/modal-context' -import { fireEvent, render } from '@testing-library/react' +import { fireEvent, render, screen } from '@testing-library/react' import { noop } from 'es-toolkit/function' import { defaultPlan } from '@/app/components/billing/config' import { useModalContext as actualUseModalContext } from '@/context/modal-context' @@ -81,6 +81,8 @@ type APIKeyInfoPanelRenderOptions = { mockOverrides?: MockOverrides } & Omit +const mainButtonName = /appOverview\.apiKeyInfo\.setAPIBtn/ + // Setup function to configure mocks function setupMocks(overrides: MockOverrides = {}) { mockUseProviderContext.mockReturnValue({ @@ -137,7 +139,7 @@ export const scenarios = { export const assertions = { // Should render main button shouldRenderMainButton: () => { - const button = document.querySelector('button.btn-primary') + const button = screen.getByRole('button', { name: mainButtonName }) expect(button).toBeInTheDocument() return button }, @@ -174,9 +176,8 @@ export const assertions = { export const interactions = { // Click the main button clickMainButton: () => { - const button = document.querySelector('button.btn-primary') - if (button) - fireEvent.click(button) + const button = screen.getByRole('button', { name: mainButtonName }) + fireEvent.click(button) return button }, @@ -191,6 +192,7 @@ export const interactions = { // Text content keys for assertions export const textKeys = { + button: mainButtonName, selfHost: { titleRow1: /appOverview\.apiKeyInfo\.selfHost\.title\.row1/, titleRow2: /appOverview\.apiKeyInfo\.selfHost\.title\.row2/, diff --git a/web/app/components/base/inline-delete-confirm/__tests__/index.spec.tsx b/web/app/components/base/inline-delete-confirm/__tests__/index.spec.tsx index 521988d3e6..176aa87786 100644 --- a/web/app/components/base/inline-delete-confirm/__tests__/index.spec.tsx +++ b/web/app/components/base/inline-delete-confirm/__tests__/index.spec.tsx @@ -84,49 +84,6 @@ describe('InlineDeleteConfirm', () => { }) }) - describe('Variant prop', () => { - it('should render with delete variant by default', () => { - const onConfirm = vi.fn() - const onCancel = vi.fn() - const { getByText } = render( - , - ) - - const confirmButton = getByText('Yes').closest('button') - expect(confirmButton?.className).toContain('btn-destructive-primary') - }) - - it('should render without destructive class for warning variant', () => { - const onConfirm = vi.fn() - const onCancel = vi.fn() - const { getByText } = render( - , - ) - - const confirmButton = getByText('Yes').closest('button') - expect(confirmButton?.className).not.toContain('btn-destructive-primary') - }) - - it('should render without destructive class for info variant', () => { - const onConfirm = vi.fn() - const onCancel = vi.fn() - const { getByText } = render( - , - ) - - const confirmButton = getByText('Yes').closest('button') - expect(confirmButton?.className).not.toContain('btn-destructive-primary') - }) - }) - describe('Custom className', () => { it('should apply custom className to wrapper', () => { const onConfirm = vi.fn() diff --git a/web/app/components/base/notion-connector/__tests__/index.spec.tsx b/web/app/components/base/notion-connector/__tests__/index.spec.tsx index 578ffffdca..2fb4976b29 100644 --- a/web/app/components/base/notion-connector/__tests__/index.spec.tsx +++ b/web/app/components/base/notion-connector/__tests__/index.spec.tsx @@ -22,7 +22,6 @@ describe('NotionConnector', () => { }) expect(button).toBeInTheDocument() - expect(button).toHaveClass('btn', 'btn-primary') }) it('should trigger the onSetting callback when the real button is clicked', async () => { diff --git a/web/app/components/base/ui/alert-dialog/__tests__/index.spec.tsx b/web/app/components/base/ui/alert-dialog/__tests__/index.spec.tsx index 3eec5f9f05..23fbcb19d6 100644 --- a/web/app/components/base/ui/alert-dialog/__tests__/index.spec.tsx +++ b/web/app/components/base/ui/alert-dialog/__tests__/index.spec.tsx @@ -4,7 +4,6 @@ import { AlertDialog, AlertDialogActions, AlertDialogCancelButton, - AlertDialogClose, AlertDialogConfirmButton, AlertDialogContent, AlertDialogDescription, @@ -70,14 +69,16 @@ describe('AlertDialog wrapper', () => { }) describe('User Interactions', () => { - it('should open and close dialog when trigger and close are clicked', async () => { + it('should open and close dialog when trigger and cancel button are clicked', async () => { render( Open Dialog Action Required Please confirm the action. - Cancel + + Cancel + , ) @@ -109,8 +110,7 @@ describe('AlertDialog wrapper', () => { expect(screen.getByTestId('actions')).toHaveClass('flex', 'items-start', 'justify-end', 'gap-2', 'self-stretch', 'p-6', 'custom-actions') const confirmButton = screen.getByRole('button', { name: 'Confirm' }) - expect(confirmButton).toHaveClass('btn-primary') - expect(confirmButton).toHaveClass('btn-destructive-primary') + expect(confirmButton).toHaveClass('bg-components-button-destructive-primary-bg') }) it('should keep dialog open after confirm click and close via cancel helper', async () => { diff --git a/web/app/components/base/ui/alert-dialog/index.tsx b/web/app/components/base/ui/alert-dialog/index.tsx index c8e68260c0..f922a19847 100644 --- a/web/app/components/base/ui/alert-dialog/index.tsx +++ b/web/app/components/base/ui/alert-dialog/index.tsx @@ -10,7 +10,6 @@ export const AlertDialog = BaseAlertDialog.Root export const AlertDialogTrigger = BaseAlertDialog.Trigger export const AlertDialogTitle = BaseAlertDialog.Title export const AlertDialogDescription = BaseAlertDialog.Description -export const AlertDialogClose = BaseAlertDialog.Close type AlertDialogContentProps = { children: React.ReactNode diff --git a/web/app/components/base/ui/avatar/index.tsx b/web/app/components/base/ui/avatar/index.tsx index 0842a1734d..f3bb5298b1 100644 --- a/web/app/components/base/ui/avatar/index.tsx +++ b/web/app/components/base/ui/avatar/index.tsx @@ -53,7 +53,7 @@ function AvatarImage({ }: AvatarImageProps) { return ( ) diff --git a/web/app/components/base/ui/button/__tests__/index.spec.tsx b/web/app/components/base/ui/button/__tests__/index.spec.tsx index 656c344b1a..e7b9c92c91 100644 --- a/web/app/components/base/ui/button/__tests__/index.spec.tsx +++ b/web/app/components/base/ui/button/__tests__/index.spec.tsx @@ -1,8 +1,6 @@ -import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { fireEvent, render, screen } from '@testing-library/react' import { Button } from '../index' -afterEach(cleanup) - describe('Button', () => { describe('rendering', () => { it('renders children text', () => { @@ -31,58 +29,79 @@ describe('Button', () => { expect(link).toHaveTextContent('Link') expect(link).toHaveAttribute('href', '/test') }) + + it('applies base layout classes', () => { + render() + const btn = screen.getByRole('button') + expect(btn).toHaveClass('inline-flex', 'justify-center', 'items-center', 'cursor-pointer') + }) }) describe('variants', () => { it('applies default secondary variant', () => { render() - expect(screen.getByRole('button').className).toContain('btn-secondary') + const btn = screen.getByRole('button') + expect(btn).toHaveClass('bg-components-button-secondary-bg', 'text-components-button-secondary-text') }) it.each([ - 'primary', - 'secondary', - 'secondary-accent', - 'ghost', - 'ghost-accent', - 'tertiary', - ] as const)('applies %s variant', (variant) => { + { variant: 'primary' as const, expectedClass: 'bg-components-button-primary-bg' }, + { variant: 'secondary' as const, expectedClass: 'bg-components-button-secondary-bg' }, + { variant: 'secondary-accent' as const, expectedClass: 'text-components-button-secondary-accent-text' }, + { variant: 'ghost' as const, expectedClass: 'text-components-button-ghost-text' }, + { variant: 'ghost-accent' as const, expectedClass: 'hover:bg-state-accent-hover' }, + { variant: 'tertiary' as const, expectedClass: 'bg-components-button-tertiary-bg' }, + ])('applies $variant variant', ({ variant, expectedClass }) => { render() - expect(screen.getByRole('button').className).toContain(`btn-${variant}`) + expect(screen.getByRole('button')).toHaveClass(expectedClass) }) it('applies destructive tone with default variant', () => { render() - expect(screen.getByRole('button').className).toContain('btn-destructive-secondary') + expect(screen.getByRole('button')).toHaveClass('bg-components-button-destructive-secondary-bg') }) it('applies destructive tone with primary variant', () => { render() - expect(screen.getByRole('button').className).toContain('btn-destructive-primary') + expect(screen.getByRole('button')).toHaveClass('bg-components-button-destructive-primary-bg') + }) + + it('applies destructive tone with tertiary variant', () => { + render() + expect(screen.getByRole('button')).toHaveClass('bg-components-button-destructive-tertiary-bg') + }) + + it('applies destructive tone with ghost variant', () => { + render() + expect(screen.getByRole('button')).toHaveClass('text-components-button-destructive-ghost-text') }) }) describe('sizes', () => { it('applies default medium size', () => { render() - expect(screen.getByRole('button').className).toContain('btn-medium') + expect(screen.getByRole('button')).toHaveClass('h-8', 'rounded-lg') }) - it.each(['small', 'medium', 'large'] as const)('applies %s size', (size) => { + it.each([ + { size: 'small' as const, expectedClass: 'h-6' }, + { size: 'medium' as const, expectedClass: 'h-8' }, + { size: 'large' as const, expectedClass: 'h-9' }, + ])('applies $size size', ({ size, expectedClass }) => { render() - expect(screen.getByRole('button').className).toContain(`btn-${size}`) + expect(screen.getByRole('button')).toHaveClass(expectedClass) }) }) describe('loading', () => { it('shows spinner when loading', () => { render() - expect(screen.getByRole('button').querySelector('.animate-spin')).toBeInTheDocument() + expect(screen.getByRole('button').querySelector('[aria-hidden="true"]')).toBeInTheDocument() }) it('hides spinner when not loading', () => { render() - expect(screen.getByRole('button').querySelector('.animate-spin')).not.toBeInTheDocument() + expect(screen.getByRole('button').querySelector('[aria-hidden="true"]')).not.toBeInTheDocument() }) it('auto-disables when loading', () => { @@ -137,6 +156,15 @@ describe('Button', () => { }) }) + describe('className merging', () => { + it('merges custom className with variant classes', () => { + render() + const btn = screen.getByRole('button') + expect(btn).toHaveClass('custom-class') + expect(btn).toHaveClass('inline-flex') + }) + }) + describe('ref forwarding', () => { it('forwards ref to the button element', () => { let buttonRef: HTMLButtonElement | null = null diff --git a/web/app/components/base/ui/button/index.css b/web/app/components/base/ui/button/index.css deleted file mode 100644 index eee8b7944b..0000000000 --- a/web/app/components/base/ui/button/index.css +++ /dev/null @@ -1,148 +0,0 @@ -@utility btn { - @apply inline-flex justify-center items-center cursor-pointer whitespace-nowrap - outline-hidden focus-visible:ring-2 focus-visible:ring-state-accent-solid; - - &:is(:disabled, [data-disabled]) { - @apply cursor-not-allowed; - } -} - -@utility btn-small { - @apply px-2 h-6 rounded-md text-xs font-medium; -} - -@utility btn-medium { - @apply px-3.5 h-8 rounded-lg text-[13px] leading-4 font-medium; -} - -@utility btn-large { - @apply px-4 h-9 rounded-[10px] text-sm font-semibold; -} - -@utility btn-primary { - @apply shadow - bg-components-button-primary-bg - border-components-button-primary-border - hover:bg-components-button-primary-bg-hover - hover:border-components-button-primary-border-hover - text-components-button-primary-text; - - &:is(:disabled, [data-disabled]) { - @apply shadow-none - bg-components-button-primary-bg-disabled - border-components-button-primary-border-disabled - text-components-button-primary-text-disabled; - } -} - -@utility btn-secondary { - @apply border-[0.5px] - shadow-xs - backdrop-blur-[5px] - bg-components-button-secondary-bg - border-components-button-secondary-border - hover:bg-components-button-secondary-bg-hover - hover:border-components-button-secondary-border-hover - text-components-button-secondary-text; - - &:is(:disabled, [data-disabled]) { - @apply backdrop-blur-xs - bg-components-button-secondary-bg-disabled - border-components-button-secondary-border-disabled - text-components-button-secondary-text-disabled; - } -} - -@utility btn-secondary-accent { - @apply border-[0.5px] - shadow-xs - bg-components-button-secondary-bg - border-components-button-secondary-border - hover:bg-components-button-secondary-bg-hover - hover:border-components-button-secondary-border-hover - text-components-button-secondary-accent-text; - - &:is(:disabled, [data-disabled]) { - @apply bg-components-button-secondary-bg-disabled - border-components-button-secondary-border-disabled - text-components-button-secondary-accent-text-disabled; - } -} - -@utility btn-tertiary { - @apply bg-components-button-tertiary-bg - hover:bg-components-button-tertiary-bg-hover - text-components-button-tertiary-text; - - &:is(:disabled, [data-disabled]) { - @apply bg-components-button-tertiary-bg-disabled - text-components-button-tertiary-text-disabled; - } -} - -@utility btn-ghost { - @apply hover:bg-components-button-ghost-bg-hover - text-components-button-ghost-text; - - &:is(:disabled, [data-disabled]) { - @apply text-components-button-ghost-text-disabled; - } -} - -@utility btn-ghost-accent { - @apply hover:bg-state-accent-hover - text-components-button-secondary-accent-text; - - &:is(:disabled, [data-disabled]) { - @apply text-components-button-secondary-accent-text-disabled; - } -} - -@utility btn-destructive-primary { - @apply bg-components-button-destructive-primary-bg - border-components-button-destructive-primary-border - hover:bg-components-button-destructive-primary-bg-hover - hover:border-components-button-destructive-primary-border-hover - text-components-button-destructive-primary-text; - - &:is(:disabled, [data-disabled]) { - @apply shadow-none - bg-components-button-destructive-primary-bg-disabled - border-components-button-destructive-primary-border-disabled - text-components-button-destructive-primary-text-disabled; - } -} - -@utility btn-destructive-secondary { - @apply bg-components-button-destructive-secondary-bg - border-components-button-destructive-secondary-border - hover:bg-components-button-destructive-secondary-bg-hover - hover:border-components-button-destructive-secondary-border-hover - text-components-button-destructive-secondary-text; - - &:is(:disabled, [data-disabled]) { - @apply bg-components-button-destructive-secondary-bg-disabled - border-components-button-destructive-secondary-border-disabled - text-components-button-destructive-secondary-text-disabled; - } -} - -@utility btn-destructive-tertiary { - @apply bg-components-button-destructive-tertiary-bg - hover:bg-components-button-destructive-tertiary-bg-hover - text-components-button-destructive-tertiary-text; - - &:is(:disabled, [data-disabled]) { - @apply bg-components-button-destructive-tertiary-bg-disabled - text-components-button-destructive-tertiary-text-disabled; - } -} - -@utility btn-destructive-ghost { - @apply hover:bg-components-button-destructive-ghost-bg-hover - text-components-button-destructive-ghost-text; - - &:is(:disabled, [data-disabled]) { - @apply text-components-button-destructive-ghost-text-disabled; - } -} diff --git a/web/app/components/base/ui/button/index.tsx b/web/app/components/base/ui/button/index.tsx index 00bbe3c8b7..002213486b 100644 --- a/web/app/components/base/ui/button/index.tsx +++ b/web/app/components/base/ui/button/index.tsx @@ -5,21 +5,47 @@ import { cva } from 'class-variance-authority' import { cn } from '@/utils/classnames' const buttonVariants = cva( - 'btn', + 'inline-flex cursor-pointer items-center justify-center whitespace-nowrap outline-hidden focus-visible:ring-2 focus-visible:ring-state-accent-solid data-[disabled]:cursor-not-allowed', { variants: { variant: { - 'primary': 'btn-primary', - 'secondary': 'btn-secondary', - 'secondary-accent': 'btn-secondary-accent', - 'ghost': 'btn-ghost', - 'ghost-accent': 'btn-ghost-accent', - 'tertiary': 'btn-tertiary', + 'primary': [ + 'border-components-button-primary-border bg-components-button-primary-bg text-components-button-primary-text shadow', + 'hover:border-components-button-primary-border-hover hover:bg-components-button-primary-bg-hover', + 'data-[disabled]:border-components-button-primary-border-disabled data-[disabled]:bg-components-button-primary-bg-disabled data-[disabled]:text-components-button-primary-text-disabled data-[disabled]:shadow-none', + ], + 'secondary': [ + 'border-[0.5px] shadow-xs backdrop-blur-[5px]', + 'border-components-button-secondary-border bg-components-button-secondary-bg text-components-button-secondary-text', + 'hover:border-components-button-secondary-border-hover hover:bg-components-button-secondary-bg-hover', + 'data-[disabled]:border-components-button-secondary-border-disabled data-[disabled]:bg-components-button-secondary-bg-disabled data-[disabled]:text-components-button-secondary-text-disabled data-[disabled]:backdrop-blur-xs', + ], + 'secondary-accent': [ + 'border-[0.5px] shadow-xs', + 'border-components-button-secondary-border bg-components-button-secondary-bg text-components-button-secondary-accent-text', + 'hover:border-components-button-secondary-border-hover hover:bg-components-button-secondary-bg-hover', + 'data-[disabled]:border-components-button-secondary-border-disabled data-[disabled]:bg-components-button-secondary-bg-disabled data-[disabled]:text-components-button-secondary-accent-text-disabled', + ], + 'tertiary': [ + 'bg-components-button-tertiary-bg text-components-button-tertiary-text', + 'hover:bg-components-button-tertiary-bg-hover', + 'data-[disabled]:bg-components-button-tertiary-bg-disabled data-[disabled]:text-components-button-tertiary-text-disabled', + ], + 'ghost': [ + 'text-components-button-ghost-text', + 'hover:bg-components-button-ghost-bg-hover', + 'data-[disabled]:text-components-button-ghost-text-disabled', + ], + 'ghost-accent': [ + 'text-components-button-secondary-accent-text', + 'hover:bg-state-accent-hover', + 'data-[disabled]:text-components-button-secondary-accent-text-disabled', + ], }, size: { - small: 'btn-small', - medium: 'btn-medium', - large: 'btn-large', + small: 'h-6 rounded-md px-2 text-xs font-medium', + medium: 'h-8 rounded-lg px-3.5 text-[13px] leading-4 font-medium', + large: 'h-9 rounded-[10px] px-4 text-sm font-semibold', }, tone: { default: '', @@ -27,10 +53,42 @@ const buttonVariants = cva( }, }, compoundVariants: [ - { variant: 'primary', tone: 'destructive', class: 'btn-destructive-primary' }, - { variant: 'secondary', tone: 'destructive', class: 'btn-destructive-secondary' }, - { variant: 'tertiary', tone: 'destructive', class: 'btn-destructive-tertiary' }, - { variant: 'ghost', tone: 'destructive', class: 'btn-destructive-ghost' }, + { + variant: 'primary', + tone: 'destructive', + class: [ + 'border-components-button-destructive-primary-border bg-components-button-destructive-primary-bg text-components-button-destructive-primary-text', + 'hover:border-components-button-destructive-primary-border-hover hover:bg-components-button-destructive-primary-bg-hover', + 'data-[disabled]:border-components-button-destructive-primary-border-disabled data-[disabled]:bg-components-button-destructive-primary-bg-disabled data-[disabled]:text-components-button-destructive-primary-text-disabled data-[disabled]:shadow-none', + ], + }, + { + variant: 'secondary', + tone: 'destructive', + class: [ + 'border-components-button-destructive-secondary-border bg-components-button-destructive-secondary-bg text-components-button-destructive-secondary-text', + 'hover:border-components-button-destructive-secondary-border-hover hover:bg-components-button-destructive-secondary-bg-hover', + 'data-[disabled]:border-components-button-destructive-secondary-border-disabled data-[disabled]:bg-components-button-destructive-secondary-bg-disabled data-[disabled]:text-components-button-destructive-secondary-text-disabled', + ], + }, + { + variant: 'tertiary', + tone: 'destructive', + class: [ + 'bg-components-button-destructive-tertiary-bg text-components-button-destructive-tertiary-text', + 'hover:bg-components-button-destructive-tertiary-bg-hover', + 'data-[disabled]:bg-components-button-destructive-tertiary-bg-disabled data-[disabled]:text-components-button-destructive-tertiary-text-disabled', + ], + }, + { + variant: 'ghost', + tone: 'destructive', + class: [ + 'text-components-button-destructive-ghost-text', + 'hover:bg-components-button-destructive-ghost-bg-hover', + 'data-[disabled]:text-components-button-destructive-ghost-text-disabled', + ], + }, ], defaultVariants: { variant: 'secondary', diff --git a/web/app/components/base/ui/dialog/__tests__/index.spec.tsx b/web/app/components/base/ui/dialog/__tests__/index.spec.tsx index 2e52bd547a..55f4e53288 100644 --- a/web/app/components/base/ui/dialog/__tests__/index.spec.tsx +++ b/web/app/components/base/ui/dialog/__tests__/index.spec.tsx @@ -1,15 +1,11 @@ -import { Dialog as BaseDialog } from '@base-ui/react/dialog' import { fireEvent, render, screen } from '@testing-library/react' import { describe, expect, it, vi } from 'vitest' import { Dialog, - DialogClose, DialogCloseButton, DialogContent, DialogDescription, - DialogPortal, DialogTitle, - DialogTrigger, } from '../index' describe('Dialog wrapper', () => { @@ -86,15 +82,4 @@ describe('Dialog wrapper', () => { expect(onClick).not.toHaveBeenCalled() }) }) - - describe('Exports', () => { - it('should map dialog aliases to the matching base dialog primitives', () => { - expect(Dialog).toBe(BaseDialog.Root) - expect(DialogTrigger).toBe(BaseDialog.Trigger) - expect(DialogTitle).toBe(BaseDialog.Title) - expect(DialogDescription).toBe(BaseDialog.Description) - expect(DialogClose).toBe(BaseDialog.Close) - expect(DialogPortal).toBe(BaseDialog.Portal) - }) - }) }) diff --git a/web/app/components/base/ui/dialog/index.tsx b/web/app/components/base/ui/dialog/index.tsx index 5dcebfcac2..c1802806c9 100644 --- a/web/app/components/base/ui/dialog/index.tsx +++ b/web/app/components/base/ui/dialog/index.tsx @@ -12,10 +12,10 @@ import * as React from 'react' import { cn } from '@/utils/classnames' export const Dialog = BaseDialog.Root +/** @public */ export const DialogTrigger = BaseDialog.Trigger export const DialogTitle = BaseDialog.Title export const DialogDescription = BaseDialog.Description -export const DialogClose = BaseDialog.Close export const DialogPortal = BaseDialog.Portal type DialogCloseButtonProps = Omit, 'children'> diff --git a/web/app/components/base/ui/dropdown-menu/__tests__/index.spec.tsx b/web/app/components/base/ui/dropdown-menu/__tests__/index.spec.tsx index b6772e5ad0..93ceb911e7 100644 --- a/web/app/components/base/ui/dropdown-menu/__tests__/index.spec.tsx +++ b/web/app/components/base/ui/dropdown-menu/__tests__/index.spec.tsx @@ -1,7 +1,5 @@ -import type { ComponentPropsWithoutRef, ReactNode } from 'react' import { fireEvent, render, screen, within } from '@testing-library/react' import { describe, expect, it, vi } from 'vitest' -import Link from '@/next/link' import { DropdownMenu, DropdownMenuContent, @@ -14,21 +12,6 @@ import { DropdownMenuTrigger, } from '../index' -vi.mock('@/next/link', () => ({ - default: ({ - href, - children, - ...props - }: { - href: string - children?: ReactNode - } & Omit, 'href'>) => ( - - {children} - - ), -})) - describe('dropdown-menu wrapper', () => { describe('DropdownMenuContent', () => { it('should position content at bottom-end with default placement when props are omitted', () => { @@ -295,13 +278,13 @@ describe('dropdown-menu wrapper', () => { expect(link).not.toHaveAttribute('closeOnClick') }) - it('should preserve link semantics when render prop uses a custom link component', () => { + it('should preserve link semantics when render prop uses a custom anchor element', () => { render( Open } + render={} aria-label="account link" > Account settings diff --git a/web/app/components/base/ui/number-field/__tests__/index.spec.tsx b/web/app/components/base/ui/number-field/__tests__/index.spec.tsx index f988e2b312..abfee58302 100644 --- a/web/app/components/base/ui/number-field/__tests__/index.spec.tsx +++ b/web/app/components/base/ui/number-field/__tests__/index.spec.tsx @@ -6,7 +6,6 @@ import type { NumberFieldInputProps, NumberFieldUnitProps, } from '../index' -import { NumberField as BaseNumberField } from '@base-ui/react/number-field' import { render, screen } from '@testing-library/react' import { NumberField, @@ -67,17 +66,6 @@ const renderNumberField = ({ } describe('NumberField wrapper', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - // Export mapping should stay aligned with the Base UI primitive. - describe('Exports', () => { - it('should map NumberField to the matching base primitive root', () => { - expect(NumberField).toBe(BaseNumberField.Root) - }) - }) - // Group and input wrappers should preserve the design-system variants and DOM defaults. describe('Group and input', () => { it('should apply regular group classes by default and merge custom className', () => { diff --git a/web/app/components/base/ui/popover/__tests__/index.spec.tsx b/web/app/components/base/ui/popover/__tests__/index.spec.tsx index 9d65f8c934..39fb3f3fac 100644 --- a/web/app/components/base/ui/popover/__tests__/index.spec.tsx +++ b/web/app/components/base/ui/popover/__tests__/index.spec.tsx @@ -1,12 +1,8 @@ -import { Popover as BasePopover } from '@base-ui/react/popover' import { fireEvent, render, screen } from '@testing-library/react' import { describe, expect, it, vi } from 'vitest' import { Popover, - PopoverClose, PopoverContent, - PopoverDescription, - PopoverTitle, PopoverTrigger, } from '..' @@ -93,15 +89,3 @@ describe('PopoverContent', () => { }) }) }) - -describe('Popover aliases', () => { - describe('Export mapping', () => { - it('should map aliases to the matching base popover primitives when wrapper exports are imported', () => { - expect(Popover).toBe(BasePopover.Root) - expect(PopoverTrigger).toBe(BasePopover.Trigger) - expect(PopoverClose).toBe(BasePopover.Close) - expect(PopoverTitle).toBe(BasePopover.Title) - expect(PopoverDescription).toBe(BasePopover.Description) - }) - }) -}) diff --git a/web/app/components/base/ui/popover/index.tsx b/web/app/components/base/ui/popover/index.tsx index 045d9066d3..08e3cf51e2 100644 --- a/web/app/components/base/ui/popover/index.tsx +++ b/web/app/components/base/ui/popover/index.tsx @@ -9,7 +9,9 @@ import { cn } from '@/utils/classnames' export const Popover = BasePopover.Root export const PopoverTrigger = BasePopover.Trigger export const PopoverClose = BasePopover.Close +/** @public */ export const PopoverTitle = BasePopover.Title +/** @public */ export const PopoverDescription = BasePopover.Description type PopoverContentProps = { diff --git a/web/app/components/base/ui/scroll-area/__tests__/index.spec.tsx b/web/app/components/base/ui/scroll-area/__tests__/index.spec.tsx index 4945b7f805..0d37c30712 100644 --- a/web/app/components/base/ui/scroll-area/__tests__/index.spec.tsx +++ b/web/app/components/base/ui/scroll-area/__tests__/index.spec.tsx @@ -9,7 +9,6 @@ import { ScrollAreaThumb, ScrollAreaViewport, } from '../index' -import styles from '../index.module.css' const renderScrollArea = (options: { rootClassName?: string @@ -106,7 +105,7 @@ describe('scroll-area wrapper', () => { const thumb = screen.getByTestId('scroll-area-vertical-thumb') expect(scrollbar).toHaveAttribute('data-orientation', 'vertical') - expect(scrollbar).toHaveClass(styles.scrollbar) + expect(scrollbar).toHaveAttribute('data-dify-scrollbar') expect(scrollbar).toHaveClass( 'flex', 'overflow-clip', @@ -144,7 +143,7 @@ describe('scroll-area wrapper', () => { const thumb = screen.getByTestId('scroll-area-horizontal-thumb') expect(scrollbar).toHaveAttribute('data-orientation', 'horizontal') - expect(scrollbar).toHaveClass(styles.scrollbar) + expect(scrollbar).toHaveAttribute('data-dify-scrollbar') expect(scrollbar).toHaveClass( 'flex', 'overflow-clip', diff --git a/web/app/components/base/ui/scroll-area/index.tsx b/web/app/components/base/ui/scroll-area/index.tsx index 34c5e69188..1533092b8a 100644 --- a/web/app/components/base/ui/scroll-area/index.tsx +++ b/web/app/components/base/ui/scroll-area/index.tsx @@ -3,7 +3,7 @@ import { ScrollArea as BaseScrollArea } from '@base-ui/react/scroll-area' import * as React from 'react' import { cn } from '@/utils/classnames' -import styles from './index.module.css' +import './scroll-area.css' export const ScrollAreaRoot = BaseScrollArea.Root type ScrollAreaRootProps = React.ComponentPropsWithRef @@ -25,7 +25,6 @@ type ScrollAreaProps = Omit & { } const scrollAreaScrollbarClassName = cn( - styles.scrollbar, 'flex touch-none overflow-clip p-1 opacity-100 transition-opacity select-none motion-reduce:transition-none', 'pointer-events-none data-hovering:pointer-events-auto', 'data-scrolling:pointer-events-auto', @@ -68,6 +67,7 @@ export function ScrollAreaScrollbar({ }: ScrollAreaScrollbarProps) { return ( diff --git a/web/app/components/base/ui/scroll-area/index.module.css b/web/app/components/base/ui/scroll-area/scroll-area.css similarity index 57% rename from web/app/components/base/ui/scroll-area/index.module.css rename to web/app/components/base/ui/scroll-area/scroll-area.css index a81fd3d3c2..7a33076acf 100644 --- a/web/app/components/base/ui/scroll-area/index.module.css +++ b/web/app/components/base/ui/scroll-area/scroll-area.css @@ -1,5 +1,5 @@ -.scrollbar::before, -.scrollbar::after { +[data-dify-scrollbar]::before, +[data-dify-scrollbar]::after { content: ''; position: absolute; z-index: 1; @@ -9,7 +9,7 @@ transition: opacity 150ms ease; } -.scrollbar[data-orientation='vertical']::before { +[data-dify-scrollbar][data-orientation='vertical']::before { left: 50%; top: 4px; width: 4px; @@ -18,7 +18,7 @@ background: linear-gradient(to bottom, var(--scroll-area-edge-hint-bg, var(--color-components-panel-bg)), transparent); } -.scrollbar[data-orientation='vertical']::after { +[data-dify-scrollbar][data-orientation='vertical']::after { left: 50%; bottom: 4px; width: 4px; @@ -27,7 +27,7 @@ background: linear-gradient(to top, var(--scroll-area-edge-hint-bg, var(--color-components-panel-bg)), transparent); } -.scrollbar[data-orientation='horizontal']::before { +[data-dify-scrollbar][data-orientation='horizontal']::before { top: 50%; left: 4px; width: 12px; @@ -36,7 +36,7 @@ background: linear-gradient(to right, var(--scroll-area-edge-hint-bg, var(--color-components-panel-bg)), transparent); } -.scrollbar[data-orientation='horizontal']::after { +[data-dify-scrollbar][data-orientation='horizontal']::after { top: 50%; right: 4px; width: 12px; @@ -45,31 +45,31 @@ background: linear-gradient(to left, var(--scroll-area-edge-hint-bg, var(--color-components-panel-bg)), transparent); } -.scrollbar[data-orientation='vertical']:not([data-overflow-y-start])::before { +[data-dify-scrollbar][data-orientation='vertical']:not([data-overflow-y-start])::before { opacity: 1; } -.scrollbar[data-orientation='vertical']:not([data-overflow-y-end])::after { +[data-dify-scrollbar][data-orientation='vertical']:not([data-overflow-y-end])::after { opacity: 1; } -.scrollbar[data-orientation='horizontal']:not([data-overflow-x-start])::before { +[data-dify-scrollbar][data-orientation='horizontal']:not([data-overflow-x-start])::before { opacity: 1; } -.scrollbar[data-orientation='horizontal']:not([data-overflow-x-end])::after { +[data-dify-scrollbar][data-orientation='horizontal']:not([data-overflow-x-end])::after { opacity: 1; } -.scrollbar[data-hovering] > [data-orientation], -.scrollbar[data-scrolling] > [data-orientation], -.scrollbar > [data-orientation]:active { +[data-dify-scrollbar][data-hovering] > [data-orientation], +[data-dify-scrollbar][data-scrolling] > [data-orientation], +[data-dify-scrollbar] > [data-orientation]:active { background-color: var(--scroll-area-thumb-bg-active, var(--color-state-base-handle-hover)); } @media (prefers-reduced-motion: reduce) { - .scrollbar::before, - .scrollbar::after { + [data-dify-scrollbar]::before, + [data-dify-scrollbar]::after { transition: none; } } diff --git a/web/app/components/base/ui/tooltip/__tests__/index.spec.tsx b/web/app/components/base/ui/tooltip/__tests__/index.spec.tsx index f1a96060d2..7feee4f8c3 100644 --- a/web/app/components/base/ui/tooltip/__tests__/index.spec.tsx +++ b/web/app/components/base/ui/tooltip/__tests__/index.spec.tsx @@ -1,7 +1,6 @@ -import { Tooltip as BaseTooltip } from '@base-ui/react/tooltip' import { fireEvent, render, screen } from '@testing-library/react' import { describe, expect, it, vi } from 'vitest' -import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../index' +import { Tooltip, TooltipContent, TooltipTrigger } from '../index' describe('TooltipContent', () => { describe('Placement and offsets', () => { @@ -105,11 +104,3 @@ describe('TooltipContent', () => { }) }) }) - -describe('Tooltip aliases', () => { - it('should map alias exports to BaseTooltip components when wrapper exports are imported', () => { - expect(TooltipProvider).toBe(BaseTooltip.Provider) - expect(Tooltip).toBe(BaseTooltip.Root) - expect(TooltipTrigger).toBe(BaseTooltip.Trigger) - }) -}) diff --git a/web/app/components/datasets/create-from-pipeline/list/template-card/__tests__/actions.spec.tsx b/web/app/components/datasets/create-from-pipeline/list/template-card/__tests__/actions.spec.tsx index 6dbfb42ec8..0ac767667e 100644 --- a/web/app/components/datasets/create-from-pipeline/list/template-card/__tests__/actions.spec.tsx +++ b/web/app/components/datasets/create-from-pipeline/list/template-card/__tests__/actions.spec.tsx @@ -85,21 +85,6 @@ describe('Actions', () => { }) }) - // Button Variants Tests - describe('Button Variants', () => { - it('should have primary variant for choose button', () => { - render() - const chooseButton = screen.getByText(/operations\.choose/i).closest('button') - expect(chooseButton).toHaveClass('btn-primary') - }) - - it('should have secondary variant for details button', () => { - render() - const detailsButton = screen.getByText(/operations\.details/i).closest('button') - expect(detailsButton).toHaveClass('btn-secondary') - }) - }) - describe('Layout', () => { it('should have absolute positioning', () => { const { container } = render() diff --git a/web/app/components/datasets/documents/detail/batch-modal/__tests__/csv-uploader.spec.tsx b/web/app/components/datasets/documents/detail/batch-modal/__tests__/csv-uploader.spec.tsx index 38169c1537..6e3ba6ab90 100644 --- a/web/app/components/datasets/documents/detail/batch-modal/__tests__/csv-uploader.spec.tsx +++ b/web/app/components/datasets/documents/detail/batch-modal/__tests__/csv-uploader.spec.tsx @@ -160,24 +160,6 @@ describe('CSVUploader', () => { expect(mockUpdateFile).toHaveBeenCalled() }) }) - - it('should call updateFile with undefined when remove is clicked', () => { - const mockUpdateFile = vi.fn() - const mockFile: FileItem = { - fileID: 'file-1', - file: new File(['content'], 'test.csv', { type: 'text/csv' }) as CustomFile, - progress: 100, - } - const { container } = render( - , - ) - - const deleteButton = container.querySelector('.cursor-pointer') - if (deleteButton) - fireEvent.click(deleteButton) - - expect(mockUpdateFile).toHaveBeenCalledWith() - }) }) describe('Validation', () => { diff --git a/web/app/components/datasets/metadata/edit-metadata-batch/__tests__/modal.spec.tsx b/web/app/components/datasets/metadata/edit-metadata-batch/__tests__/modal.spec.tsx index c3b44ebd61..d9b88e20bb 100644 --- a/web/app/components/datasets/metadata/edit-metadata-batch/__tests__/modal.spec.tsx +++ b/web/app/components/datasets/metadata/edit-metadata-batch/__tests__/modal.spec.tsx @@ -187,10 +187,7 @@ describe('EditMetadataBatchModal', () => { }) // Find the primary save button (not the one in SelectMetadataModal) - const saveButtons = screen.getAllByText(/save/i) - const modalSaveButton = saveButtons.find(btn => btn.closest('button')?.classList.contains('btn-primary')) - if (modalSaveButton) - fireEvent.click(modalSaveButton) + fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' })) expect(onSave).toHaveBeenCalled() }) @@ -443,13 +440,10 @@ describe('EditMetadataBatchModal', () => { }) // Find the primary save button - const saveButtons = screen.getAllByText(/save/i) - const saveBtn = saveButtons.find(btn => btn.closest('button')?.classList.contains('btn-primary')) - if (saveBtn) { - fireEvent.click(saveBtn) - fireEvent.click(saveBtn) - fireEvent.click(saveBtn) - } + const saveBtn = screen.getByRole('button', { name: 'common.operation.save' }) + fireEvent.click(saveBtn) + fireEvent.click(saveBtn) + fireEvent.click(saveBtn) expect(onSave).toHaveBeenCalledTimes(3) }) @@ -462,10 +456,7 @@ describe('EditMetadataBatchModal', () => { expect(screen.getByRole('dialog')).toBeInTheDocument() }) - const saveButtons = screen.getAllByText(/save/i) - const saveBtn = saveButtons.find(btn => btn.closest('button')?.classList.contains('btn-primary')) - if (saveBtn) - fireEvent.click(saveBtn) + fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' })) expect(onSave).toHaveBeenCalledWith( expect.any(Array), @@ -486,10 +477,7 @@ describe('EditMetadataBatchModal', () => { if (checkboxContainer) fireEvent.click(checkboxContainer) - const saveButtons = screen.getAllByText(/save/i) - const saveBtn = saveButtons.find(btn => btn.closest('button')?.classList.contains('btn-primary')) - if (saveBtn) - fireEvent.click(saveBtn) + fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' })) await waitFor(() => { expect(onSave).toHaveBeenCalledWith( @@ -511,10 +499,7 @@ describe('EditMetadataBatchModal', () => { // Remove an item fireEvent.click(screen.getByTestId('remove-1')) - const saveButtons = screen.getAllByText(/save/i) - const saveBtn = saveButtons.find(btn => btn.closest('button')?.classList.contains('btn-primary')) - if (saveBtn) - fireEvent.click(saveBtn) + fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' })) expect(onSave).toHaveBeenCalled() // The first argument should not contain the deleted item (id '1') diff --git a/web/app/components/datasets/metadata/metadata-dataset/__tests__/dataset-metadata-drawer.spec.tsx b/web/app/components/datasets/metadata/metadata-dataset/__tests__/dataset-metadata-drawer.spec.tsx index 907174ab19..353a39450e 100644 --- a/web/app/components/datasets/metadata/metadata-dataset/__tests__/dataset-metadata-drawer.spec.tsx +++ b/web/app/components/datasets/metadata/metadata-dataset/__tests__/dataset-metadata-drawer.spec.tsx @@ -284,12 +284,7 @@ describe('DatasetMetadataDrawer', () => { fireEvent.change(inputs[0], { target: { value: 'renamed_field' } }) // Find and click save button - const saveBtns = screen.getAllByText(/save/i) - const primaryBtn = saveBtns.find(btn => - btn.closest('button')?.classList.contains('btn-primary'), - ) - if (primaryBtn) - fireEvent.click(primaryBtn) + fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' })) await waitFor(() => { expect(onRename).toHaveBeenCalled() diff --git a/web/app/components/develop/secret-key/__tests__/secret-key-button.spec.tsx b/web/app/components/develop/secret-key/__tests__/secret-key-button.spec.tsx index 798d0dd16f..e5134b995d 100644 --- a/web/app/components/develop/secret-key/__tests__/secret-key-button.spec.tsx +++ b/web/app/components/develop/secret-key/__tests__/secret-key-button.spec.tsx @@ -139,18 +139,6 @@ describe('SecretKeyButton', () => { const button = screen.getByRole('button') expect(button.className).toContain('px-3') }) - - it('should have small size', () => { - render() - const button = screen.getByRole('button') - expect(button.className).toContain('btn-small') - }) - - it('should have ghost variant', () => { - render() - const button = screen.getByRole('button') - expect(button.className).toContain('btn-ghost') - }) }) describe('icon styling', () => { diff --git a/web/app/components/header/account-setting/model-provider-page/system-model-selector/__tests__/index.spec.tsx b/web/app/components/header/account-setting/model-provider-page/system-model-selector/__tests__/index.spec.tsx index 2cf83597ca..c1b27a2c04 100644 --- a/web/app/components/header/account-setting/model-provider-page/system-model-selector/__tests__/index.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/system-model-selector/__tests__/index.spec.tsx @@ -120,12 +120,6 @@ describe('SystemModel', () => { expect(screen.getByRole('button', { name: /system model settings/i })).toBeDisabled() }) - it('should render the primary button variant when configuration is required', () => { - render() - - expect(screen.getByRole('button', { name: /system model settings/i })).toHaveClass('btn-primary') - }) - it('should close dialog when cancel is clicked', async () => { render() fireEvent.click(screen.getByRole('button', { name: /system model settings/i })) diff --git a/web/app/components/plugins/plugin-auth/authorize/__tests__/authorize-components.spec.tsx b/web/app/components/plugins/plugin-auth/authorize/__tests__/authorize-components.spec.tsx index acb31cee5f..c495d5501a 100644 --- a/web/app/components/plugins/plugin-auth/authorize/__tests__/authorize-components.spec.tsx +++ b/web/app/components/plugins/plugin-auth/authorize/__tests__/authorize-components.spec.tsx @@ -156,29 +156,6 @@ describe('AddApiKeyButton', () => { expect(screen.getByRole('button')).toHaveTextContent('Custom API Key') }) - - it('should apply button variant', () => { - const pluginPayload = createPluginPayload() - - render( - , - { wrapper: createWrapper() }, - ) - - expect(screen.getByRole('button').className).toContain('btn-primary') - }) - - it('should use secondary-accent variant by default', () => { - const pluginPayload = createPluginPayload() - - render(, { wrapper: createWrapper() }) - - // Verify the default button has secondary-accent variant class - expect(screen.getByRole('button').className).toContain('btn-secondary-accent') - }) }) describe('Props Testing', () => { @@ -372,25 +349,6 @@ describe('AddOAuthButton', () => { expect(screen.getByText('plugin.auth.setupOAuth')).toBeInTheDocument() }) - - it('should apply button variant to setup button', () => { - const pluginPayload = createPluginPayload() - mockGetPluginOAuthClientSchema.mockReturnValue({ - schema: [], - is_oauth_custom_client_enabled: false, - is_system_oauth_params_exists: false, - }) - - render( - , - { wrapper: createWrapper() }, - ) - - expect(screen.getByRole('button').className).toContain('btn-secondary') - }) }) describe('Rendering - Configured State', () => { diff --git a/web/app/components/rag-pipeline/components/__tests__/version-mismatch-modal.spec.tsx b/web/app/components/rag-pipeline/components/__tests__/version-mismatch-modal.spec.tsx index 038362c7ce..56ed88e2ae 100644 --- a/web/app/components/rag-pipeline/components/__tests__/version-mismatch-modal.spec.tsx +++ b/web/app/components/rag-pipeline/components/__tests__/version-mismatch-modal.spec.tsx @@ -88,23 +88,6 @@ describe('VersionMismatchModal', () => { }) }) - describe('button variants', () => { - it('should render cancel button with secondary variant', () => { - render() - - const cancelBtn = screen.getByRole('button', { name: /app\.newApp\.Cancel/ }) - expect(cancelBtn).toHaveClass('btn-secondary') - }) - - it('should render confirm button with primary destructive variant', () => { - render() - - const confirmBtn = screen.getByRole('button', { name: /app\.newApp\.Confirm/ }) - expect(confirmBtn).toHaveClass('btn-primary') - expect(confirmBtn).toHaveClass('btn-destructive-primary') - }) - }) - describe('edge cases', () => { it('should handle undefined versions gracefully', () => { render() diff --git a/web/app/components/tools/workflow-tool/confirm-modal/__tests__/index.spec.tsx b/web/app/components/tools/workflow-tool/confirm-modal/__tests__/index.spec.tsx index 2cbe98c7ab..c5bce8b663 100644 --- a/web/app/components/tools/workflow-tool/confirm-modal/__tests__/index.spec.tsx +++ b/web/app/components/tools/workflow-tool/confirm-modal/__tests__/index.spec.tsx @@ -157,16 +157,6 @@ describe('ConfirmModal', () => { // Act & Assert - This will fail the test if user.click throws an unhandled error await user.click(confirmButton) }) - - it('should have correct button variants', () => { - // Arrange & Act - renderComponent() - - // Assert - const confirmButton = screen.getByText('common.operation.confirm') - expect(confirmButton).toHaveClass('btn-primary') - expect(confirmButton).toHaveClass('btn-destructive-primary') - }) }) // Edge Cases (REQUIRED) diff --git a/web/app/styles/globals.css b/web/app/styles/globals.css index ac6475a565..ec5dc86a89 100644 --- a/web/app/styles/globals.css +++ b/web/app/styles/globals.css @@ -8,7 +8,6 @@ @import '../components/base/action-button/index.css'; @import '../components/base/badge/index.css'; -@import '../components/base/ui/button/index.css'; @import '../components/base/modal/index.css' layer(base); @import '../components/base/premium-badge/index.css'; @import '../components/base/segmented-control/index.css'; diff --git a/web/eslint-suppressions.json b/web/eslint-suppressions.json index 6968859c1e..2160c4bcf2 100644 --- a/web/eslint-suppressions.json +++ b/web/eslint-suppressions.json @@ -3309,11 +3309,6 @@ "count": 1 } }, - "app/components/base/ui/avatar/index.tsx": { - "tailwindcss/enforce-consistent-class-order": { - "count": 1 - } - }, "app/components/base/video-gallery/VideoPlayer.tsx": { "react/set-state-in-effect": { "count": 1 From 79332c0e5e8141d6120e17bf04ea5a59989b011e Mon Sep 17 00:00:00 2001 From: zyssyz123 <916125788@qq.com> Date: Wed, 15 Apr 2026 15:50:52 +0800 Subject: [PATCH 02/61] =?UTF-8?q?fix:=20Change=20'commit'=20to=20'flush'?= =?UTF-8?q?=20to=20prevent=20subsequent=20transaction=20fai=E2=80=A6=20(#3?= =?UTF-8?q?5243)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/services/app_dsl_service.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/services/app_dsl_service.py b/api/services/app_dsl_service.py index 87c28980ab..74b800606d 100644 --- a/api/services/app_dsl_service.py +++ b/api/services/app_dsl_service.py @@ -455,7 +455,7 @@ class AppDslService: app.updated_by = account.id self._session.add(app) - self._session.commit() + self._session.flush() app_was_created.send(app, account=account) # save dependencies From 554232955468c996c3807601a4b016fa9dc3ad00 Mon Sep 17 00:00:00 2001 From: Wu Tianwei <30284043+WTW0313@users.noreply.github.com> Date: Wed, 15 Apr 2026 16:03:02 +0800 Subject: [PATCH 03/61] fix(dataset): fix dataset list overlay issue (#35244) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: yyh --- .../dataset-card/__tests__/index.spec.tsx | 152 +----------------- .../__tests__/operations.spec.tsx | 70 +++----- .../__tests__/corner-labels.spec.tsx | 4 +- ....spec.tsx => operations-dropdown.spec.tsx} | 111 +++++++++---- .../dataset-card/components/corner-labels.tsx | 4 +- .../components/operations-dropdown.tsx | 68 ++++++++ .../components/operations-popover.tsx | 52 ------ .../datasets/list/dataset-card/index.tsx | 4 +- .../datasets/list/dataset-card/operations.tsx | 48 +++--- web/app/components/datasets/list/index.tsx | 7 +- web/eslint-suppressions.json | 13 -- 11 files changed, 198 insertions(+), 335 deletions(-) rename web/app/components/datasets/list/dataset-card/components/__tests__/{operations-popover.spec.tsx => operations-dropdown.spec.tsx} (51%) create mode 100644 web/app/components/datasets/list/dataset-card/components/operations-dropdown.tsx delete mode 100644 web/app/components/datasets/list/dataset-card/components/operations-popover.tsx diff --git a/web/app/components/datasets/list/dataset-card/__tests__/index.spec.tsx b/web/app/components/datasets/list/dataset-card/__tests__/index.spec.tsx index 21ddda5ce6..29139da114 100644 --- a/web/app/components/datasets/list/dataset-card/__tests__/index.spec.tsx +++ b/web/app/components/datasets/list/dataset-card/__tests__/index.spec.tsx @@ -7,8 +7,6 @@ import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datase import DatasetCardFooter from '../components/dataset-card-footer' import Description from '../components/description' import DatasetCard from '../index' -import OperationItem from '../operation-item' -import Operations from '../operations' // Mock external hooks only vi.mock('@/hooks/use-format-time-from-now', () => ({ @@ -62,8 +60,8 @@ vi.mock('../components/tag-area', () => ({
)), })) -vi.mock('../components/operations-popover', () => ({ - default: () =>
, +vi.mock('../components/operations-dropdown', () => ({ + default: () =>
, })) // Factory function for DataSet mock data @@ -233,152 +231,6 @@ describe('DatasetCard Integration', () => { }) }) }) - - // Integration tests for OperationItem component - describe('OperationItem', () => { - const MockIcon = ({ className }: { className?: string }) => ( - - ) - - describe('Rendering', () => { - it('should render icon and name', () => { - render() - expect(screen.getByText('Edit')).toBeInTheDocument() - expect(screen.getByTestId('mock-icon')).toBeInTheDocument() - }) - }) - - describe('User Interactions', () => { - it('should call handleClick when clicked', () => { - const handleClick = vi.fn() - render() - - const item = screen.getByText('Delete').closest('div') - fireEvent.click(item!) - - expect(handleClick).toHaveBeenCalledTimes(1) - }) - - it('should prevent default and stop propagation on click', () => { - const handleClick = vi.fn() - render() - - const item = screen.getByText('Action').closest('div') - const event = new MouseEvent('click', { bubbles: true, cancelable: true }) - const preventDefaultSpy = vi.spyOn(event, 'preventDefault') - const stopPropagationSpy = vi.spyOn(event, 'stopPropagation') - - item!.dispatchEvent(event) - - expect(preventDefaultSpy).toHaveBeenCalled() - expect(stopPropagationSpy).toHaveBeenCalled() - }) - }) - - describe('Edge Cases', () => { - it('should not throw when handleClick is undefined', () => { - render() - const item = screen.getByText('No handler').closest('div') - expect(() => { - fireEvent.click(item!) - }).not.toThrow() - }) - - it('should handle empty name', () => { - render() - expect(screen.getByTestId('mock-icon')).toBeInTheDocument() - }) - }) - }) - - // Integration tests for Operations component - describe('Operations', () => { - const defaultProps = { - showDelete: true, - showExportPipeline: true, - openRenameModal: vi.fn(), - handleExportPipeline: vi.fn(), - detectIsUsedByApp: vi.fn(), - } - - describe('Rendering', () => { - it('should always render edit operation', () => { - render() - expect(screen.getByText(/operation\.edit/)).toBeInTheDocument() - }) - - it('should render export pipeline when showExportPipeline is true', () => { - render() - expect(screen.getByText(/exportPipeline/)).toBeInTheDocument() - }) - - it('should not render export pipeline when showExportPipeline is false', () => { - render() - expect(screen.queryByText(/exportPipeline/)).not.toBeInTheDocument() - }) - - it('should render delete when showDelete is true', () => { - render() - expect(screen.getByText(/operation\.delete/)).toBeInTheDocument() - }) - - it('should not render delete when showDelete is false', () => { - render() - expect(screen.queryByText(/operation\.delete/)).not.toBeInTheDocument() - }) - }) - - describe('User Interactions', () => { - it('should call openRenameModal when edit is clicked', () => { - const openRenameModal = vi.fn() - render() - - const editItem = screen.getByText(/operation\.edit/).closest('div') - fireEvent.click(editItem!) - - expect(openRenameModal).toHaveBeenCalledTimes(1) - }) - - it('should call handleExportPipeline when export is clicked', () => { - const handleExportPipeline = vi.fn() - render() - - const exportItem = screen.getByText(/exportPipeline/).closest('div') - fireEvent.click(exportItem!) - - expect(handleExportPipeline).toHaveBeenCalledTimes(1) - }) - - it('should call detectIsUsedByApp when delete is clicked', () => { - const detectIsUsedByApp = vi.fn() - render() - - const deleteItem = screen.getByText(/operation\.delete/).closest('div') - fireEvent.click(deleteItem!) - - expect(detectIsUsedByApp).toHaveBeenCalledTimes(1) - }) - }) - - describe('Edge Cases', () => { - it('should render only edit when both showDelete and showExportPipeline are false', () => { - render() - expect(screen.getByText(/operation\.edit/)).toBeInTheDocument() - expect(screen.queryByText(/exportPipeline/)).not.toBeInTheDocument() - expect(screen.queryByText(/operation\.delete/)).not.toBeInTheDocument() - }) - - it('should render divider before delete section when showDelete is true', () => { - const { container } = render() - expect(container.querySelector('.bg-divider-subtle')).toBeInTheDocument() - }) - - it('should not render divider when showDelete is false', () => { - const { container } = render() - expect(container.querySelector('.bg-divider-subtle')).toBeNull() - }) - }) - }) }) describe('DatasetCard Component', () => { diff --git a/web/app/components/datasets/list/dataset-card/__tests__/operations.spec.tsx b/web/app/components/datasets/list/dataset-card/__tests__/operations.spec.tsx index edb54cba0c..a6765c0ebe 100644 --- a/web/app/components/datasets/list/dataset-card/__tests__/operations.spec.tsx +++ b/web/app/components/datasets/list/dataset-card/__tests__/operations.spec.tsx @@ -1,7 +1,16 @@ import { fireEvent, render, screen } from '@testing-library/react' import { describe, expect, it, vi } from 'vitest' +import { DropdownMenu } from '@/app/components/base/ui/dropdown-menu' import Operations from '../operations' +function renderInMenu(ui: React.ReactElement) { + return render( + + {ui} + , + ) +} + describe('Operations', () => { const defaultProps = { showDelete: true, @@ -17,100 +26,65 @@ describe('Operations', () => { describe('Rendering', () => { it('should render without crashing', () => { - render() - // Edit operation should always be visible + renderInMenu() expect(screen.getByText(/operation\.edit/)).toBeInTheDocument() }) it('should render edit operation', () => { - render() + renderInMenu() expect(screen.getByText(/operation\.edit/)).toBeInTheDocument() }) it('should render export pipeline operation when showExportPipeline is true', () => { - render() + renderInMenu() expect(screen.getByText(/exportPipeline/)).toBeInTheDocument() }) it('should not render export pipeline operation when showExportPipeline is false', () => { - render() + renderInMenu() expect(screen.queryByText(/exportPipeline/)).not.toBeInTheDocument() }) it('should render delete operation when showDelete is true', () => { - render() + renderInMenu() expect(screen.getByText(/operation\.delete/)).toBeInTheDocument() }) it('should not render delete operation when showDelete is false', () => { - render() + renderInMenu() expect(screen.queryByText(/operation\.delete/)).not.toBeInTheDocument() }) }) - describe('Props', () => { - it('should render divider when showDelete is true', () => { - const { container } = render() - const divider = container.querySelector('.bg-divider-subtle') - expect(divider).toBeInTheDocument() - }) - - it('should not render divider when showDelete is false', () => { - const { container } = render() - // Should not have the divider-subtle one (the separator before delete) - expect(container.querySelector('.bg-divider-subtle')).toBeNull() - }) - }) - describe('User Interactions', () => { it('should call openRenameModal when edit is clicked', () => { const openRenameModal = vi.fn() - render() - - const editItem = screen.getByText(/operation\.edit/).closest('div') - fireEvent.click(editItem!) + renderInMenu() + fireEvent.click(screen.getByText(/operation\.edit/)) expect(openRenameModal).toHaveBeenCalledTimes(1) }) it('should call handleExportPipeline when export is clicked', () => { const handleExportPipeline = vi.fn() - render() - - const exportItem = screen.getByText(/exportPipeline/).closest('div') - fireEvent.click(exportItem!) + renderInMenu() + fireEvent.click(screen.getByText(/exportPipeline/)) expect(handleExportPipeline).toHaveBeenCalledTimes(1) }) it('should call detectIsUsedByApp when delete is clicked', () => { const detectIsUsedByApp = vi.fn() - render() - - const deleteItem = screen.getByText(/operation\.delete/).closest('div') - fireEvent.click(deleteItem!) + renderInMenu() + fireEvent.click(screen.getByText(/operation\.delete/)) expect(detectIsUsedByApp).toHaveBeenCalledTimes(1) }) }) - describe('Styles', () => { - it('should have correct container styling', () => { - const { container } = render() - const operationsContainer = container.firstChild - expect(operationsContainer).toHaveClass( - 'relative', - 'flex', - 'w-full', - 'flex-col', - 'rounded-xl', - ) - }) - }) - describe('Edge Cases', () => { it('should render only edit when both showDelete and showExportPipeline are false', () => { - render() + renderInMenu() expect(screen.getByText(/operation\.edit/)).toBeInTheDocument() expect(screen.queryByText(/exportPipeline/)).not.toBeInTheDocument() expect(screen.queryByText(/operation\.delete/)).not.toBeInTheDocument() diff --git a/web/app/components/datasets/list/dataset-card/components/__tests__/corner-labels.spec.tsx b/web/app/components/datasets/list/dataset-card/components/__tests__/corner-labels.spec.tsx index 7fff6f4dc1..b68f018e72 100644 --- a/web/app/components/datasets/list/dataset-card/components/__tests__/corner-labels.spec.tsx +++ b/web/app/components/datasets/list/dataset-card/components/__tests__/corner-labels.spec.tsx @@ -80,7 +80,7 @@ describe('CornerLabels', () => { const dataset = createMockDataset({ embedding_available: false }) const { container } = render() const labelContainer = container.firstChild as HTMLElement - expect(labelContainer).toHaveClass('absolute', 'right-0', 'top-0', 'z-10') + expect(labelContainer).toHaveClass('absolute', 'right-0', 'top-0', 'z-5') }) it('should have correct positioning for pipeline label', () => { @@ -90,7 +90,7 @@ describe('CornerLabels', () => { }) const { container } = render() const labelContainer = container.firstChild as HTMLElement - expect(labelContainer).toHaveClass('absolute', 'right-0', 'top-0', 'z-10') + expect(labelContainer).toHaveClass('absolute', 'right-0', 'top-0', 'z-5') }) }) diff --git a/web/app/components/datasets/list/dataset-card/components/__tests__/operations-popover.spec.tsx b/web/app/components/datasets/list/dataset-card/components/__tests__/operations-dropdown.spec.tsx similarity index 51% rename from web/app/components/datasets/list/dataset-card/components/__tests__/operations-popover.spec.tsx rename to web/app/components/datasets/list/dataset-card/components/__tests__/operations-dropdown.spec.tsx index 0d53bb1315..690e01113f 100644 --- a/web/app/components/datasets/list/dataset-card/components/__tests__/operations-popover.spec.tsx +++ b/web/app/components/datasets/list/dataset-card/components/__tests__/operations-dropdown.spec.tsx @@ -1,11 +1,11 @@ import type { DataSet } from '@/models/datasets' -import { fireEvent, render } from '@testing-library/react' +import { fireEvent, render, screen } from '@testing-library/react' import { describe, expect, it, vi } from 'vitest' import { IndexingType } from '@/app/components/datasets/create/step-two' import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets' -import OperationsPopover from '../operations-popover' +import OperationsDropdown from '../operations-dropdown' -describe('OperationsPopover', () => { +describe('OperationsDropdown', () => { const createMockDataset = (overrides: Partial = {}): DataSet => ({ id: 'dataset-1', name: 'Test Dataset', @@ -42,102 +42,143 @@ describe('OperationsPopover', () => { describe('Rendering', () => { it('should render without crashing', () => { - const { container } = render() + const { container } = render() expect(container.firstChild).toBeInTheDocument() }) it('should render the more icon button', () => { - const { container } = render() - const moreIcon = container.querySelector('svg') + const { container } = render() + const moreIcon = container.querySelector('.i-ri-more-fill') expect(moreIcon).toBeInTheDocument() }) it('should render in hidden state initially (group-hover)', () => { - const { container } = render() + const { container } = render() const wrapper = container.firstChild as HTMLElement - expect(wrapper).toHaveClass('hidden', 'group-hover:block') + expect(wrapper).toHaveClass( + 'invisible', + 'pointer-events-none', + 'group-hover:visible', + 'group-hover:pointer-events-auto', + ) }) }) describe('Props', () => { it('should show delete option when not workspace dataset operator', () => { - render() + render() const triggerButton = document.querySelector('[class*="cursor-pointer"]') if (triggerButton) fireEvent.click(triggerButton) - - // showDelete should be true (inverse of isCurrentWorkspaceDatasetOperator) - // This means delete operation will be visible }) it('should hide delete option when is workspace dataset operator', () => { - render() + render() const triggerButton = document.querySelector('[class*="cursor-pointer"]') if (triggerButton) fireEvent.click(triggerButton) - - // showDelete should be false }) it('should show export pipeline when runtime_mode is rag_pipeline', () => { const dataset = createMockDataset({ runtime_mode: 'rag_pipeline' }) - render() + render() const triggerButton = document.querySelector('[class*="cursor-pointer"]') if (triggerButton) fireEvent.click(triggerButton) - - // showExportPipeline should be true }) it('should hide export pipeline when runtime_mode is not rag_pipeline', () => { const dataset = createMockDataset({ runtime_mode: 'general' }) - render() + render() const triggerButton = document.querySelector('[class*="cursor-pointer"]') if (triggerButton) fireEvent.click(triggerButton) - - // showExportPipeline should be false }) }) describe('Styles', () => { it('should have correct positioning styles', () => { - const { container } = render() + const { container } = render() const wrapper = container.firstChild as HTMLElement - expect(wrapper).toHaveClass('absolute', 'right-2', 'top-2', 'z-15') + expect(wrapper).toHaveClass('absolute', 'right-2', 'top-2', 'z-5') + }) + + it('should keep the trigger mounted when closed so menu exit animations retain an anchor', () => { + const { container } = render() + const wrapper = container.firstChild as HTMLElement + const trigger = container.querySelector('[aria-label="Dataset operations"]') + + expect(wrapper).not.toHaveClass('hidden') + expect(trigger).toBeInTheDocument() }) it('should have icon with correct size classes', () => { - const { container } = render() - const icon = container.querySelector('svg') + const { container } = render() + const icon = container.querySelector('.i-ri-more-fill') expect(icon).toHaveClass('h-5', 'w-5', 'text-text-tertiary') }) + + it('should have aria-label on trigger for accessibility', () => { + const { container } = render() + const trigger = container.querySelector('[aria-label="Dataset operations"]') + expect(trigger).toBeInTheDocument() + }) + + it('should expose visible keyboard focus styles on the trigger', () => { + const { container } = render() + const trigger = container.querySelector('[aria-label="Dataset operations"]') + expect(trigger).toHaveClass( + 'focus-visible:outline-hidden', + 'focus-visible:ring-1', + 'focus-visible:ring-inset', + 'focus-visible:ring-components-input-border-hover', + ) + }) + + it('should use a solid trigger background without backdrop blur on hover states', () => { + const { container } = render() + const trigger = container.querySelector('[aria-label="Dataset operations"]') + expect(trigger).toHaveClass('bg-components-button-secondary-bg') + expect(trigger).not.toHaveClass('hover:backdrop-blur-[5px]', 'backdrop-blur-[5px]') + }) }) describe('User Interactions', () => { + it('should keep outside interactions available when the menu is open', () => { + const onOutsideClick = vi.fn() + + render( +
+ + +
, + ) + + fireEvent.click(screen.getByLabelText('Dataset operations')) + fireEvent.click(screen.getByRole('button', { name: 'Outside action' })) + + expect(onOutsideClick).toHaveBeenCalledTimes(1) + }) + it('should pass openRenameModal to Operations', () => { const openRenameModal = vi.fn() - render() - - // The openRenameModal should be passed to Operations component - expect(openRenameModal).not.toHaveBeenCalled() // Initially not called + render() + expect(openRenameModal).not.toHaveBeenCalled() }) it('should pass handleExportPipeline to Operations', () => { const handleExportPipeline = vi.fn() - render() - + render() expect(handleExportPipeline).not.toHaveBeenCalled() }) it('should pass detectIsUsedByApp to Operations', () => { const detectIsUsedByApp = vi.fn() - render() - + render() expect(detectIsUsedByApp).not.toHaveBeenCalled() }) }) @@ -145,13 +186,13 @@ describe('OperationsPopover', () => { describe('Edge Cases', () => { it('should handle dataset with external provider', () => { const dataset = createMockDataset({ provider: 'external' }) - const { container } = render() + const { container } = render() expect(container.firstChild).toBeInTheDocument() }) it('should handle dataset with undefined runtime_mode', () => { const dataset = createMockDataset({ runtime_mode: undefined }) - const { container } = render() + const { container } = render() expect(container.firstChild).toBeInTheDocument() }) }) diff --git a/web/app/components/datasets/list/dataset-card/components/corner-labels.tsx b/web/app/components/datasets/list/dataset-card/components/corner-labels.tsx index 03ca543ee7..2fbb840526 100644 --- a/web/app/components/datasets/list/dataset-card/components/corner-labels.tsx +++ b/web/app/components/datasets/list/dataset-card/components/corner-labels.tsx @@ -14,7 +14,7 @@ const CornerLabels = ({ dataset }: CornerLabelsProps) => { return ( ) @@ -24,7 +24,7 @@ const CornerLabels = ({ dataset }: CornerLabelsProps) => { return ( ) diff --git a/web/app/components/datasets/list/dataset-card/components/operations-dropdown.tsx b/web/app/components/datasets/list/dataset-card/components/operations-dropdown.tsx new file mode 100644 index 0000000000..37a73d5d8f --- /dev/null +++ b/web/app/components/datasets/list/dataset-card/components/operations-dropdown.tsx @@ -0,0 +1,68 @@ +import type { DataSet } from '@/models/datasets' +import * as React from 'react' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuTrigger, +} from '@/app/components/base/ui/dropdown-menu' +import { cn } from '@/utils/classnames' +import Operations from '../operations' + +type OperationsDropdownProps = { + dataset: DataSet + isCurrentWorkspaceDatasetOperator: boolean + openRenameModal: () => void + handleExportPipeline: (include?: boolean) => void + detectIsUsedByApp: () => void +} + +const OperationsDropdown = ({ + dataset, + isCurrentWorkspaceDatasetOperator, + openRenameModal, + handleExportPipeline, + detectIsUsedByApp, +}: OperationsDropdownProps) => { + const [open, setOpen] = React.useState(false) + + return ( +
e.stopPropagation()} + > + + + + + + + + +
+ ) +} + +export default React.memo(OperationsDropdown) diff --git a/web/app/components/datasets/list/dataset-card/components/operations-popover.tsx b/web/app/components/datasets/list/dataset-card/components/operations-popover.tsx deleted file mode 100644 index 643242a24f..0000000000 --- a/web/app/components/datasets/list/dataset-card/components/operations-popover.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import type { DataSet } from '@/models/datasets' -import { RiMoreFill } from '@remixicon/react' -import * as React from 'react' -import CustomPopover from '@/app/components/base/popover' -import { cn } from '@/utils/classnames' -import Operations from '../operations' - -type OperationsPopoverProps = { - dataset: DataSet - isCurrentWorkspaceDatasetOperator: boolean - openRenameModal: () => void - handleExportPipeline: (include?: boolean) => void - detectIsUsedByApp: () => void -} - -const OperationsPopover = ({ - dataset, - isCurrentWorkspaceDatasetOperator, - openRenameModal, - handleExportPipeline, - detectIsUsedByApp, -}: OperationsPopoverProps) => ( -
- - )} - className="z-20 min-w-[186px]" - popupClassName="rounded-xl bg-none shadow-none ring-0 min-w-[186px]" - position="br" - trigger="click" - btnElement={( -
- -
- )} - btnClassName={open => - cn( - 'size-9 cursor-pointer justify-center radius-lg border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-0 shadow-lg shadow-shadow-shadow-5 ring-2 ring-inset ring-components-actionbar-bg hover:border-components-actionbar-border', - open ? 'border-components-actionbar-border bg-state-base-hover' : '', - )} - /> -
-) - -export default React.memo(OperationsPopover) diff --git a/web/app/components/datasets/list/dataset-card/index.tsx b/web/app/components/datasets/list/dataset-card/index.tsx index 2a22255eda..5bd032d151 100644 --- a/web/app/components/datasets/list/dataset-card/index.tsx +++ b/web/app/components/datasets/list/dataset-card/index.tsx @@ -9,7 +9,7 @@ import DatasetCardFooter from './components/dataset-card-footer' import DatasetCardHeader from './components/dataset-card-header' import DatasetCardModals from './components/dataset-card-modals' import Description from './components/description' -import OperationsPopover from './components/operations-popover' +import OperationsDropdown from './components/operations-dropdown' import TagArea from './components/tag-area' import { useDatasetCardState } from './hooks/use-dataset-card-state' @@ -82,7 +82,7 @@ const DatasetCard = ({ onClick={handleTagAreaClick} /> - -
- - {showExportPipeline && ( - - )} -
+ <> + + + {t('operation.edit', { ns: 'common' })} + + {showExportPipeline && ( + + + {t('operations.exportPipeline', { ns: 'datasetPipeline' })} + + )} {showDelete && ( <> - -
- -
+ + + + {t('operation.delete', { ns: 'common' })} + )} -
+ ) } diff --git a/web/app/components/datasets/list/index.tsx b/web/app/components/datasets/list/index.tsx index fae469dc9e..ffc9950f46 100644 --- a/web/app/components/datasets/list/index.tsx +++ b/web/app/components/datasets/list/index.tsx @@ -5,7 +5,6 @@ import { useBoolean, useDebounceFn } from 'ahooks' import { useState } from 'react' import { useTranslation } from 'react-i18next' -import { ApiConnectionMod } from '@/app/components/base/icons/src/vender/solid/development' import Input from '@/app/components/base/input' import TagManagementModal from '@/app/components/base/tag-management' import TagFilter from '@/app/components/base/tag-management/filter' @@ -85,11 +84,11 @@ const List = () => { }
diff --git a/web/eslint-suppressions.json b/web/eslint-suppressions.json index 2160c4bcf2..3b32e4e7e0 100644 --- a/web/eslint-suppressions.json +++ b/web/eslint-suppressions.json @@ -4503,11 +4503,6 @@ "count": 3 } }, - "app/components/datasets/list/dataset-card/components/corner-labels.tsx": { - "tailwindcss/enforce-consistent-class-order": { - "count": 2 - } - }, "app/components/datasets/list/dataset-card/components/dataset-card-footer.tsx": { "no-restricted-imports": { "count": 1 @@ -4526,14 +4521,6 @@ "count": 1 } }, - "app/components/datasets/list/dataset-card/components/operations-popover.tsx": { - "no-restricted-imports": { - "count": 1 - }, - "tailwindcss/enforce-consistent-class-order": { - "count": 2 - } - }, "app/components/datasets/list/dataset-card/components/tag-area.tsx": { "tailwindcss/enforce-consistent-class-order": { "count": 1 From 98897a537987a2c5516cade2d70d298c718dec03 Mon Sep 17 00:00:00 2001 From: James <63717587+jamesrayammons@users.noreply.github.com> Date: Wed, 15 Apr 2026 10:14:36 +0200 Subject: [PATCH 04/61] test: migrate webhook service additional mock tests to testcontainers (#35199) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- .../test_webhook_service_relationships.py | 507 ++++++++++++++++++ .../test_webhook_service_additional.py | 385 +------------ 2 files changed, 510 insertions(+), 382 deletions(-) create mode 100644 api/tests/test_containers_integration_tests/services/test_webhook_service_relationships.py diff --git a/api/tests/test_containers_integration_tests/services/test_webhook_service_relationships.py b/api/tests/test_containers_integration_tests/services/test_webhook_service_relationships.py new file mode 100644 index 0000000000..ec10c51e04 --- /dev/null +++ b/api/tests/test_containers_integration_tests/services/test_webhook_service_relationships.py @@ -0,0 +1,507 @@ +from __future__ import annotations + +import json +from types import SimpleNamespace +from unittest.mock import MagicMock, patch +from uuid import uuid4 + +import pytest +from sqlalchemy import select +from sqlalchemy.orm import Session + +from core.trigger.constants import TRIGGER_WEBHOOK_NODE_TYPE +from models.account import Account, Tenant, TenantAccountJoin, TenantAccountRole +from models.enums import AppTriggerStatus, AppTriggerType +from models.model import App +from models.trigger import AppTrigger, WorkflowWebhookTrigger +from models.workflow import Workflow +from services.errors.app import QuotaExceededError +from services.trigger.webhook_service import WebhookService + + +class WebhookServiceRelationshipFactory: + @staticmethod + def create_account_and_tenant(db_session_with_containers: Session) -> tuple[Account, Tenant]: + account = Account( + name=f"Account {uuid4()}", + email=f"webhook-{uuid4()}@example.com", + password="hashed-password", + password_salt="salt", + interface_language="en-US", + timezone="UTC", + ) + db_session_with_containers.add(account) + db_session_with_containers.commit() + + tenant = Tenant(name=f"Tenant {uuid4()}", plan="basic", status="normal") + db_session_with_containers.add(tenant) + db_session_with_containers.commit() + + join = TenantAccountJoin( + tenant_id=tenant.id, + account_id=account.id, + role=TenantAccountRole.OWNER, + current=True, + ) + db_session_with_containers.add(join) + db_session_with_containers.commit() + + account.current_tenant = tenant + return account, tenant + + @staticmethod + def create_app(db_session_with_containers: Session, tenant: Tenant, account: Account) -> App: + app = App( + tenant_id=tenant.id, + name=f"Webhook App {uuid4()}", + description="", + mode="workflow", + icon_type="emoji", + icon="bot", + icon_background="#FFFFFF", + enable_site=False, + enable_api=True, + api_rpm=100, + api_rph=100, + is_demo=False, + is_public=False, + is_universal=False, + created_by=account.id, + updated_by=account.id, + ) + db_session_with_containers.add(app) + db_session_with_containers.commit() + return app + + @staticmethod + def create_workflow( + db_session_with_containers: Session, + *, + app: App, + account: Account, + node_ids: list[str], + version: str, + ) -> Workflow: + graph = { + "nodes": [ + { + "id": node_id, + "data": { + "type": TRIGGER_WEBHOOK_NODE_TYPE, + "title": f"Webhook {node_id}", + "method": "post", + "content_type": "application/json", + "headers": [], + "params": [], + "body": [], + "status_code": 200, + "response_body": '{"status": "ok"}', + "timeout": 30, + }, + } + for node_id in node_ids + ], + "edges": [], + } + + workflow = Workflow( + tenant_id=app.tenant_id, + app_id=app.id, + type="workflow", + graph=json.dumps(graph), + features=json.dumps({}), + created_by=account.id, + updated_by=account.id, + environment_variables=[], + conversation_variables=[], + version=version, + ) + db_session_with_containers.add(workflow) + db_session_with_containers.commit() + return workflow + + @staticmethod + def create_webhook_trigger( + db_session_with_containers: Session, + *, + app: App, + account: Account, + node_id: str, + webhook_id: str | None = None, + ) -> WorkflowWebhookTrigger: + webhook_trigger = WorkflowWebhookTrigger( + app_id=app.id, + node_id=node_id, + tenant_id=app.tenant_id, + webhook_id=webhook_id or uuid4().hex[:24], + created_by=account.id, + ) + db_session_with_containers.add(webhook_trigger) + db_session_with_containers.commit() + return webhook_trigger + + @staticmethod + def create_app_trigger( + db_session_with_containers: Session, + *, + app: App, + node_id: str, + status: AppTriggerStatus, + ) -> AppTrigger: + app_trigger = AppTrigger( + tenant_id=app.tenant_id, + app_id=app.id, + node_id=node_id, + trigger_type=AppTriggerType.TRIGGER_WEBHOOK, + provider_name="webhook", + title=f"Webhook {node_id}", + status=status, + ) + db_session_with_containers.add(app_trigger) + db_session_with_containers.commit() + return app_trigger + + +class TestWebhookServiceLookupWithContainers: + def test_get_webhook_trigger_and_workflow_raises_when_app_trigger_missing( + self, db_session_with_containers: Session, flask_app_with_containers + ): + del flask_app_with_containers + factory = WebhookServiceRelationshipFactory + account, tenant = factory.create_account_and_tenant(db_session_with_containers) + app = factory.create_app(db_session_with_containers, tenant, account) + factory.create_workflow( + db_session_with_containers, app=app, account=account, node_ids=["node-1"], version="2026-04-14.001" + ) + webhook_trigger = factory.create_webhook_trigger( + db_session_with_containers, app=app, account=account, node_id="node-1" + ) + + with pytest.raises(ValueError, match="App trigger not found"): + WebhookService.get_webhook_trigger_and_workflow(webhook_trigger.webhook_id) + + def test_get_webhook_trigger_and_workflow_raises_when_app_trigger_rate_limited( + self, db_session_with_containers: Session, flask_app_with_containers + ): + del flask_app_with_containers + factory = WebhookServiceRelationshipFactory + account, tenant = factory.create_account_and_tenant(db_session_with_containers) + app = factory.create_app(db_session_with_containers, tenant, account) + factory.create_workflow( + db_session_with_containers, app=app, account=account, node_ids=["node-1"], version="2026-04-14.001" + ) + webhook_trigger = factory.create_webhook_trigger( + db_session_with_containers, app=app, account=account, node_id="node-1" + ) + factory.create_app_trigger( + db_session_with_containers, app=app, node_id="node-1", status=AppTriggerStatus.RATE_LIMITED + ) + + with pytest.raises(ValueError, match="rate limited"): + WebhookService.get_webhook_trigger_and_workflow(webhook_trigger.webhook_id) + + def test_get_webhook_trigger_and_workflow_raises_when_app_trigger_disabled( + self, db_session_with_containers: Session, flask_app_with_containers + ): + del flask_app_with_containers + factory = WebhookServiceRelationshipFactory + account, tenant = factory.create_account_and_tenant(db_session_with_containers) + app = factory.create_app(db_session_with_containers, tenant, account) + factory.create_workflow( + db_session_with_containers, app=app, account=account, node_ids=["node-1"], version="2026-04-14.001" + ) + webhook_trigger = factory.create_webhook_trigger( + db_session_with_containers, app=app, account=account, node_id="node-1" + ) + factory.create_app_trigger( + db_session_with_containers, app=app, node_id="node-1", status=AppTriggerStatus.DISABLED + ) + + with pytest.raises(ValueError, match="disabled"): + WebhookService.get_webhook_trigger_and_workflow(webhook_trigger.webhook_id) + + def test_get_webhook_trigger_and_workflow_raises_when_workflow_missing( + self, db_session_with_containers: Session, flask_app_with_containers + ): + del flask_app_with_containers + factory = WebhookServiceRelationshipFactory + account, tenant = factory.create_account_and_tenant(db_session_with_containers) + app = factory.create_app(db_session_with_containers, tenant, account) + webhook_trigger = factory.create_webhook_trigger( + db_session_with_containers, app=app, account=account, node_id="node-1" + ) + factory.create_app_trigger( + db_session_with_containers, app=app, node_id="node-1", status=AppTriggerStatus.ENABLED + ) + + with pytest.raises(ValueError, match="Workflow not found"): + WebhookService.get_webhook_trigger_and_workflow(webhook_trigger.webhook_id) + + def test_get_webhook_trigger_and_workflow_returns_debug_draft_workflow( + self, db_session_with_containers: Session, flask_app_with_containers + ): + del flask_app_with_containers + factory = WebhookServiceRelationshipFactory + account, tenant = factory.create_account_and_tenant(db_session_with_containers) + app = factory.create_app(db_session_with_containers, tenant, account) + factory.create_workflow( + db_session_with_containers, + app=app, + account=account, + node_ids=["published-node"], + version="2026-04-14.001", + ) + draft_workflow = factory.create_workflow( + db_session_with_containers, + app=app, + account=account, + node_ids=["debug-node"], + version=Workflow.VERSION_DRAFT, + ) + webhook_trigger = factory.create_webhook_trigger( + db_session_with_containers, app=app, account=account, node_id="debug-node" + ) + + got_trigger, got_workflow, got_node_config = WebhookService.get_webhook_trigger_and_workflow( + webhook_trigger.webhook_id, + is_debug=True, + ) + + assert got_trigger.id == webhook_trigger.id + assert got_workflow.id == draft_workflow.id + assert got_node_config["id"] == "debug-node" + + +class TestWebhookServiceTriggerExecutionWithContainers: + def test_trigger_workflow_execution_triggers_async_workflow_successfully( + self, db_session_with_containers: Session, flask_app_with_containers + ): + del flask_app_with_containers + factory = WebhookServiceRelationshipFactory + account, tenant = factory.create_account_and_tenant(db_session_with_containers) + app = factory.create_app(db_session_with_containers, tenant, account) + workflow = factory.create_workflow( + db_session_with_containers, app=app, account=account, node_ids=["node-1"], version="2026-04-14.001" + ) + webhook_trigger = factory.create_webhook_trigger( + db_session_with_containers, app=app, account=account, node_id="node-1" + ) + + end_user = SimpleNamespace(id=str(uuid4())) + webhook_data = {"body": {"value": 1}, "headers": {}, "query_params": {}, "files": {}, "method": "POST"} + + with ( + patch( + "services.trigger.webhook_service.EndUserService.get_or_create_end_user_by_type", + return_value=end_user, + ), + patch("services.trigger.webhook_service.QuotaType.TRIGGER.consume") as mock_consume, + patch("services.trigger.webhook_service.AsyncWorkflowService.trigger_workflow_async") as mock_trigger, + ): + WebhookService.trigger_workflow_execution(webhook_trigger, webhook_data, workflow) + + mock_consume.assert_called_once_with(webhook_trigger.tenant_id) + mock_trigger.assert_called_once() + trigger_args = mock_trigger.call_args.args + assert trigger_args[1] is end_user + assert trigger_args[2].workflow_id == workflow.id + assert trigger_args[2].root_node_id == webhook_trigger.node_id + + def test_trigger_workflow_execution_marks_tenant_rate_limited_when_quota_exceeded( + self, db_session_with_containers: Session, flask_app_with_containers + ): + del flask_app_with_containers + factory = WebhookServiceRelationshipFactory + account, tenant = factory.create_account_and_tenant(db_session_with_containers) + app = factory.create_app(db_session_with_containers, tenant, account) + workflow = factory.create_workflow( + db_session_with_containers, app=app, account=account, node_ids=["node-1"], version="2026-04-14.001" + ) + webhook_trigger = factory.create_webhook_trigger( + db_session_with_containers, app=app, account=account, node_id="node-1" + ) + + with ( + patch( + "services.trigger.webhook_service.EndUserService.get_or_create_end_user_by_type", + return_value=SimpleNamespace(id=str(uuid4())), + ), + patch( + "services.trigger.webhook_service.QuotaType.TRIGGER.consume", + side_effect=QuotaExceededError(feature="trigger", tenant_id=tenant.id, required=1), + ), + patch( + "services.trigger.webhook_service.AppTriggerService.mark_tenant_triggers_rate_limited" + ) as mock_mark_rate_limited, + ): + with pytest.raises(QuotaExceededError): + WebhookService.trigger_workflow_execution( + webhook_trigger, + {"body": {}, "headers": {}, "query_params": {}, "files": {}, "method": "POST"}, + workflow, + ) + + mock_mark_rate_limited.assert_called_once_with(tenant.id) + + def test_trigger_workflow_execution_logs_and_reraises_unexpected_errors( + self, db_session_with_containers: Session, flask_app_with_containers + ): + del flask_app_with_containers + factory = WebhookServiceRelationshipFactory + account, tenant = factory.create_account_and_tenant(db_session_with_containers) + app = factory.create_app(db_session_with_containers, tenant, account) + workflow = factory.create_workflow( + db_session_with_containers, app=app, account=account, node_ids=["node-1"], version="2026-04-14.001" + ) + webhook_trigger = factory.create_webhook_trigger( + db_session_with_containers, app=app, account=account, node_id="node-1" + ) + + with ( + patch( + "services.trigger.webhook_service.EndUserService.get_or_create_end_user_by_type", + side_effect=RuntimeError("boom"), + ), + patch("services.trigger.webhook_service.logger.exception") as mock_logger_exception, + ): + with pytest.raises(RuntimeError, match="boom"): + WebhookService.trigger_workflow_execution( + webhook_trigger, + {"body": {}, "headers": {}, "query_params": {}, "files": {}, "method": "POST"}, + workflow, + ) + + mock_logger_exception.assert_called_once() + + +class TestWebhookServiceRelationshipSyncWithContainers: + def test_sync_webhook_relationships_raises_when_workflow_exceeds_node_limit( + self, db_session_with_containers: Session, flask_app_with_containers + ): + del flask_app_with_containers + factory = WebhookServiceRelationshipFactory + account, tenant = factory.create_account_and_tenant(db_session_with_containers) + app = factory.create_app(db_session_with_containers, tenant, account) + node_ids = [f"node-{index}" for index in range(WebhookService.MAX_WEBHOOK_NODES_PER_WORKFLOW + 1)] + workflow = factory.create_workflow( + db_session_with_containers, app=app, account=account, node_ids=node_ids, version=Workflow.VERSION_DRAFT + ) + + with pytest.raises(ValueError, match="maximum webhook node limit"): + WebhookService.sync_webhook_relationships(app, workflow) + + def test_sync_webhook_relationships_raises_when_lock_not_acquired( + self, db_session_with_containers: Session, flask_app_with_containers + ): + del flask_app_with_containers + factory = WebhookServiceRelationshipFactory + account, tenant = factory.create_account_and_tenant(db_session_with_containers) + app = factory.create_app(db_session_with_containers, tenant, account) + workflow = factory.create_workflow( + db_session_with_containers, app=app, account=account, node_ids=["node-1"], version=Workflow.VERSION_DRAFT + ) + lock = MagicMock() + lock.acquire.return_value = False + + with patch("services.trigger.webhook_service.redis_client.lock", return_value=lock): + with pytest.raises(RuntimeError, match="Failed to acquire lock"): + WebhookService.sync_webhook_relationships(app, workflow) + + def test_sync_webhook_relationships_creates_missing_records_and_deletes_stale_records( + self, db_session_with_containers: Session, flask_app_with_containers + ): + del flask_app_with_containers + factory = WebhookServiceRelationshipFactory + account, tenant = factory.create_account_and_tenant(db_session_with_containers) + app = factory.create_app(db_session_with_containers, tenant, account) + stale_trigger = factory.create_webhook_trigger( + db_session_with_containers, + app=app, + account=account, + node_id="node-stale", + webhook_id="stale-webhook-id-000001", + ) + stale_trigger_id = stale_trigger.id + workflow = factory.create_workflow( + db_session_with_containers, + app=app, + account=account, + node_ids=["node-new"], + version=Workflow.VERSION_DRAFT, + ) + + with patch( + "services.trigger.webhook_service.WebhookService.generate_webhook_id", return_value="new-webhook-id-000001" + ): + WebhookService.sync_webhook_relationships(app, workflow) + + db_session_with_containers.expire_all() + records = db_session_with_containers.scalars( + select(WorkflowWebhookTrigger).where(WorkflowWebhookTrigger.app_id == app.id) + ).all() + + assert [record.node_id for record in records] == ["node-new"] + assert records[0].webhook_id == "new-webhook-id-000001" + assert db_session_with_containers.get(WorkflowWebhookTrigger, stale_trigger_id) is None + + def test_sync_webhook_relationships_sets_redis_cache_for_new_record( + self, db_session_with_containers: Session, flask_app_with_containers + ): + del flask_app_with_containers + factory = WebhookServiceRelationshipFactory + account, tenant = factory.create_account_and_tenant(db_session_with_containers) + app = factory.create_app(db_session_with_containers, tenant, account) + workflow = factory.create_workflow( + db_session_with_containers, + app=app, + account=account, + node_ids=["node-cache"], + version=Workflow.VERSION_DRAFT, + ) + cache_key = f"{WebhookService.__WEBHOOK_NODE_CACHE_KEY__}:{app.id}:node-cache" + + with patch( + "services.trigger.webhook_service.WebhookService.generate_webhook_id", return_value="cache-webhook-id-00001" + ): + WebhookService.sync_webhook_relationships(app, workflow) + + cached_payload = WebhookServiceRelationshipFactory._read_cache(cache_key) + assert cached_payload is not None + assert cached_payload["node_id"] == "node-cache" + assert cached_payload["webhook_id"] == "cache-webhook-id-00001" + + def test_sync_webhook_relationships_logs_when_lock_release_fails( + self, db_session_with_containers: Session, flask_app_with_containers + ): + del flask_app_with_containers + factory = WebhookServiceRelationshipFactory + account, tenant = factory.create_account_and_tenant(db_session_with_containers) + app = factory.create_app(db_session_with_containers, tenant, account) + workflow = factory.create_workflow( + db_session_with_containers, app=app, account=account, node_ids=[], version=Workflow.VERSION_DRAFT + ) + lock = MagicMock() + lock.acquire.return_value = True + lock.release.side_effect = RuntimeError("release failed") + + with ( + patch("services.trigger.webhook_service.redis_client.lock", return_value=lock), + patch("services.trigger.webhook_service.logger.exception") as mock_logger_exception, + ): + WebhookService.sync_webhook_relationships(app, workflow) + + mock_logger_exception.assert_called_once() + + +def _read_cache(cache_key: str) -> dict[str, str] | None: + from extensions.ext_redis import redis_client + + cached = redis_client.get(cache_key) + if not cached: + return None + if isinstance(cached, bytes): + cached = cached.decode("utf-8") + return json.loads(cached) + + +WebhookServiceRelationshipFactory._read_cache = staticmethod(_read_cache) diff --git a/api/tests/unit_tests/services/test_webhook_service_additional.py b/api/tests/unit_tests/services/test_webhook_service_additional.py index 92f8a3fcc0..8a7a463d33 100644 --- a/api/tests/unit_tests/services/test_webhook_service_additional.py +++ b/api/tests/unit_tests/services/test_webhook_service_additional.py @@ -1,5 +1,5 @@ from types import SimpleNamespace -from typing import Any, cast +from typing import Any from unittest.mock import MagicMock import pytest @@ -13,11 +13,6 @@ from core.workflow.nodes.trigger_webhook.entities import ( WebhookData, WebhookParameter, ) -from models.enums import AppTriggerStatus -from models.model import App -from models.trigger import WorkflowWebhookTrigger -from models.workflow import Workflow -from services.errors.app import QuotaExceededError from services.trigger import webhook_service as service_module from services.trigger.webhook_service import WebhookService @@ -39,156 +34,13 @@ class _FakeQuery: return self._result -class _SessionContext: - def __init__(self, session: Any) -> None: - self._session = session - - def __enter__(self) -> Any: - return self._session - - def __exit__(self, exc_type: Any, exc: Any, tb: Any) -> bool: - return False - - -class _SessionmakerContext: - def __init__(self, session: Any) -> None: - self._session = session - - def begin(self) -> "_SessionmakerContext": - return self - - def __enter__(self) -> Any: - return self._session - - def __exit__(self, exc_type: Any, exc: Any, tb: Any) -> bool: - return False - - @pytest.fixture def flask_app() -> Flask: return Flask(__name__) -def _patch_session(monkeypatch: pytest.MonkeyPatch, session: Any) -> None: - monkeypatch.setattr(service_module, "db", SimpleNamespace(engine=MagicMock(), session=MagicMock())) - monkeypatch.setattr(service_module, "Session", lambda *args, **kwargs: _SessionContext(session)) - monkeypatch.setattr(service_module, "sessionmaker", lambda *args, **kwargs: _SessionmakerContext(session)) - - -def _workflow_trigger(**kwargs: Any) -> WorkflowWebhookTrigger: - return cast(WorkflowWebhookTrigger, SimpleNamespace(**kwargs)) - - -def _workflow(**kwargs: Any) -> Workflow: - return cast(Workflow, SimpleNamespace(**kwargs)) - - -def _app(**kwargs: Any) -> App: - return cast(App, SimpleNamespace(**kwargs)) - - -class TestWebhookServiceLookup: - def test_get_webhook_trigger_and_workflow_should_raise_when_webhook_not_found( - self, - monkeypatch: pytest.MonkeyPatch, - ) -> None: - fake_session = MagicMock() - fake_session.scalar.return_value = None - _patch_session(monkeypatch, fake_session) - - with pytest.raises(ValueError, match="Webhook not found"): - WebhookService.get_webhook_trigger_and_workflow("webhook-1") - - def test_get_webhook_trigger_and_workflow_should_raise_when_app_trigger_not_found( - self, - monkeypatch: pytest.MonkeyPatch, - ) -> None: - webhook_trigger = SimpleNamespace(app_id="app-1", node_id="node-1") - fake_session = MagicMock() - fake_session.scalar.side_effect = [webhook_trigger, None] - _patch_session(monkeypatch, fake_session) - - with pytest.raises(ValueError, match="App trigger not found"): - WebhookService.get_webhook_trigger_and_workflow("webhook-1") - - def test_get_webhook_trigger_and_workflow_should_raise_when_app_trigger_rate_limited( - self, - monkeypatch: pytest.MonkeyPatch, - ) -> None: - webhook_trigger = SimpleNamespace(app_id="app-1", node_id="node-1") - app_trigger = SimpleNamespace(status=AppTriggerStatus.RATE_LIMITED) - fake_session = MagicMock() - fake_session.scalar.side_effect = [webhook_trigger, app_trigger] - _patch_session(monkeypatch, fake_session) - - with pytest.raises(ValueError, match="rate limited"): - WebhookService.get_webhook_trigger_and_workflow("webhook-1") - - def test_get_webhook_trigger_and_workflow_should_raise_when_app_trigger_disabled( - self, - monkeypatch: pytest.MonkeyPatch, - ) -> None: - webhook_trigger = SimpleNamespace(app_id="app-1", node_id="node-1") - app_trigger = SimpleNamespace(status=AppTriggerStatus.DISABLED) - fake_session = MagicMock() - fake_session.scalar.side_effect = [webhook_trigger, app_trigger] - _patch_session(monkeypatch, fake_session) - - with pytest.raises(ValueError, match="disabled"): - WebhookService.get_webhook_trigger_and_workflow("webhook-1") - - def test_get_webhook_trigger_and_workflow_should_raise_when_workflow_not_found( - self, - monkeypatch: pytest.MonkeyPatch, - ) -> None: - webhook_trigger = SimpleNamespace(app_id="app-1", node_id="node-1") - app_trigger = SimpleNamespace(status=AppTriggerStatus.ENABLED) - fake_session = MagicMock() - fake_session.scalar.side_effect = [webhook_trigger, app_trigger, None] - _patch_session(monkeypatch, fake_session) - - with pytest.raises(ValueError, match="Workflow not found"): - WebhookService.get_webhook_trigger_and_workflow("webhook-1") - - def test_get_webhook_trigger_and_workflow_should_return_values_for_non_debug_mode( - self, - monkeypatch: pytest.MonkeyPatch, - ) -> None: - webhook_trigger = SimpleNamespace(app_id="app-1", node_id="node-1") - app_trigger = SimpleNamespace(status=AppTriggerStatus.ENABLED) - workflow = MagicMock() - workflow.get_node_config_by_id.return_value = {"data": {"key": "value"}} - - fake_session = MagicMock() - fake_session.scalar.side_effect = [webhook_trigger, app_trigger, workflow] - _patch_session(monkeypatch, fake_session) - - got_trigger, got_workflow, got_node_config = WebhookService.get_webhook_trigger_and_workflow("webhook-1") - - assert got_trigger is webhook_trigger - assert got_workflow is workflow - assert got_node_config == {"data": {"key": "value"}} - - def test_get_webhook_trigger_and_workflow_should_return_values_for_debug_mode( - self, - monkeypatch: pytest.MonkeyPatch, - ) -> None: - webhook_trigger = SimpleNamespace(app_id="app-1", node_id="node-1") - workflow = MagicMock() - workflow.get_node_config_by_id.return_value = {"data": {"mode": "debug"}} - - fake_session = MagicMock() - fake_session.scalar.side_effect = [webhook_trigger, workflow] - _patch_session(monkeypatch, fake_session) - - got_trigger, got_workflow, got_node_config = WebhookService.get_webhook_trigger_and_workflow( - "webhook-1", - is_debug=True, - ) - - assert got_trigger is webhook_trigger - assert got_workflow is workflow - assert got_node_config == {"data": {"mode": "debug"}} +def _workflow_trigger(**kwargs: Any) -> Any: + return SimpleNamespace(**kwargs) class TestWebhookServiceExtractionFallbacks: @@ -420,237 +272,6 @@ class TestWebhookServiceValidationAndConversion: assert result["webhook_body"] == {"b": 2} -class TestWebhookServiceExecutionAndSync: - def test_trigger_workflow_execution_should_trigger_async_workflow_successfully( - self, - monkeypatch: pytest.MonkeyPatch, - ) -> None: - webhook_trigger = _workflow_trigger( - app_id="app-1", - node_id="node-1", - tenant_id="tenant-1", - webhook_id="webhook-1", - ) - workflow = _workflow(id="wf-1") - webhook_data = {"body": {"x": 1}} - - session = MagicMock() - _patch_session(monkeypatch, session) - - end_user = SimpleNamespace(id="end-user-1") - monkeypatch.setattr( - service_module.EndUserService, - "get_or_create_end_user_by_type", - MagicMock(return_value=end_user), - ) - quota_type = SimpleNamespace(TRIGGER=SimpleNamespace(consume=MagicMock())) - monkeypatch.setattr(service_module, "QuotaType", quota_type) - trigger_async_mock = MagicMock() - monkeypatch.setattr(service_module.AsyncWorkflowService, "trigger_workflow_async", trigger_async_mock) - - WebhookService.trigger_workflow_execution(webhook_trigger, webhook_data, workflow) - - trigger_async_mock.assert_called_once() - - def test_trigger_workflow_execution_should_mark_tenant_rate_limited_when_quota_exceeded( - self, - monkeypatch: pytest.MonkeyPatch, - ) -> None: - webhook_trigger = _workflow_trigger( - app_id="app-1", - node_id="node-1", - tenant_id="tenant-1", - webhook_id="webhook-1", - ) - workflow = _workflow(id="wf-1") - - session = MagicMock() - _patch_session(monkeypatch, session) - - monkeypatch.setattr( - service_module.EndUserService, - "get_or_create_end_user_by_type", - MagicMock(return_value=SimpleNamespace(id="end-user-1")), - ) - quota_type = SimpleNamespace( - TRIGGER=SimpleNamespace( - consume=MagicMock(side_effect=QuotaExceededError(feature="trigger", tenant_id="tenant-1", required=1)) - ) - ) - monkeypatch.setattr(service_module, "QuotaType", quota_type) - mark_rate_limited_mock = MagicMock() - monkeypatch.setattr( - service_module.AppTriggerService, "mark_tenant_triggers_rate_limited", mark_rate_limited_mock - ) - - with pytest.raises(QuotaExceededError): - WebhookService.trigger_workflow_execution(webhook_trigger, {"body": {}}, workflow) - - mark_rate_limited_mock.assert_called_once_with("tenant-1") - - def test_trigger_workflow_execution_should_log_and_reraise_unexpected_errors( - self, - monkeypatch: pytest.MonkeyPatch, - ) -> None: - webhook_trigger = _workflow_trigger( - app_id="app-1", - node_id="node-1", - tenant_id="tenant-1", - webhook_id="webhook-1", - ) - workflow = _workflow(id="wf-1") - - session = MagicMock() - _patch_session(monkeypatch, session) - - monkeypatch.setattr( - service_module.EndUserService, - "get_or_create_end_user_by_type", - MagicMock(side_effect=RuntimeError("boom")), - ) - logger_exception_mock = MagicMock() - monkeypatch.setattr(service_module.logger, "exception", logger_exception_mock) - - with pytest.raises(RuntimeError, match="boom"): - WebhookService.trigger_workflow_execution(webhook_trigger, {"body": {}}, workflow) - - logger_exception_mock.assert_called_once() - - def test_sync_webhook_relationships_should_raise_when_workflow_exceeds_node_limit(self) -> None: - app = _app(id="app-1", tenant_id="tenant-1", created_by="user-1") - workflow = _workflow( - walk_nodes=lambda _node_type: [ - (f"node-{i}", {}) for i in range(WebhookService.MAX_WEBHOOK_NODES_PER_WORKFLOW + 1) - ] - ) - - with pytest.raises(ValueError, match="maximum webhook node limit"): - WebhookService.sync_webhook_relationships(app, workflow) - - def test_sync_webhook_relationships_should_raise_when_lock_not_acquired( - self, - monkeypatch: pytest.MonkeyPatch, - ) -> None: - app = _app(id="app-1", tenant_id="tenant-1", created_by="user-1") - workflow = _workflow(walk_nodes=lambda _node_type: [("node-1", {})]) - - lock = MagicMock() - lock.acquire.return_value = False - monkeypatch.setattr(service_module.redis_client, "get", MagicMock(return_value=None)) - monkeypatch.setattr(service_module.redis_client, "lock", MagicMock(return_value=lock)) - - with pytest.raises(RuntimeError, match="Failed to acquire lock"): - WebhookService.sync_webhook_relationships(app, workflow) - - def test_sync_webhook_relationships_should_create_missing_records_and_delete_stale_records( - self, - monkeypatch: pytest.MonkeyPatch, - ) -> None: - app = _app(id="app-1", tenant_id="tenant-1", created_by="user-1") - workflow = _workflow(walk_nodes=lambda _node_type: [("node-new", {})]) - - class _WorkflowWebhookTrigger: - app_id = "app_id" - tenant_id = "tenant_id" - webhook_id = "webhook_id" - node_id = "node_id" - - def __init__(self, app_id: str, tenant_id: str, node_id: str, webhook_id: str, created_by: str) -> None: - self.id = None - self.app_id = app_id - self.tenant_id = tenant_id - self.node_id = node_id - self.webhook_id = webhook_id - self.created_by = created_by - - class _Select: - def where(self, *args: Any, **kwargs: Any) -> "_Select": - return self - - class _Session: - def __init__(self) -> None: - self.added: list[Any] = [] - self.deleted: list[Any] = [] - self.commit_count = 0 - self.existing_records = [SimpleNamespace(node_id="node-stale")] - - def scalars(self, _stmt: Any) -> Any: - return SimpleNamespace(all=lambda: self.existing_records) - - def add(self, obj: Any) -> None: - self.added.append(obj) - - def flush(self) -> None: - for idx, obj in enumerate(self.added, start=1): - if obj.id is None: - obj.id = f"rec-{idx}" - - def commit(self) -> None: - self.commit_count += 1 - - def delete(self, obj: Any) -> None: - self.deleted.append(obj) - - lock = MagicMock() - lock.acquire.return_value = True - lock.release.return_value = None - - fake_session = _Session() - - monkeypatch.setattr(service_module, "WorkflowWebhookTrigger", _WorkflowWebhookTrigger) - monkeypatch.setattr(service_module, "select", MagicMock(return_value=_Select())) - monkeypatch.setattr(service_module.redis_client, "get", MagicMock(return_value=None)) - monkeypatch.setattr(service_module.redis_client, "lock", MagicMock(return_value=lock)) - redis_set_mock = MagicMock() - redis_delete_mock = MagicMock() - monkeypatch.setattr(service_module.redis_client, "set", redis_set_mock) - monkeypatch.setattr(service_module.redis_client, "delete", redis_delete_mock) - monkeypatch.setattr(WebhookService, "generate_webhook_id", MagicMock(return_value="generated-webhook-id")) - _patch_session(monkeypatch, fake_session) - - WebhookService.sync_webhook_relationships(app, workflow) - - assert len(fake_session.added) == 1 - assert len(fake_session.deleted) == 1 - redis_set_mock.assert_called_once() - redis_delete_mock.assert_called_once() - lock.release.assert_called_once() - - def test_sync_webhook_relationships_should_log_when_lock_release_fails( - self, - monkeypatch: pytest.MonkeyPatch, - ) -> None: - app = _app(id="app-1", tenant_id="tenant-1", created_by="user-1") - workflow = _workflow(walk_nodes=lambda _node_type: []) - - class _Select: - def where(self, *args: Any, **kwargs: Any) -> "_Select": - return self - - class _Session: - def scalars(self, _stmt: Any) -> Any: - return SimpleNamespace(all=lambda: []) - - def commit(self) -> None: - return None - - lock = MagicMock() - lock.acquire.return_value = True - lock.release.side_effect = RuntimeError("release failed") - - logger_exception_mock = MagicMock() - - monkeypatch.setattr(service_module, "select", MagicMock(return_value=_Select())) - monkeypatch.setattr(service_module.redis_client, "get", MagicMock(return_value=None)) - monkeypatch.setattr(service_module.redis_client, "lock", MagicMock(return_value=lock)) - monkeypatch.setattr(service_module.logger, "exception", logger_exception_mock) - _patch_session(monkeypatch, _Session()) - - WebhookService.sync_webhook_relationships(app, workflow) - - assert logger_exception_mock.call_count == 1 - - class TestWebhookServiceUtilities: def test_generate_webhook_response_should_fallback_when_response_body_is_not_json(self) -> None: node_config = {"data": {"status_code": 200, "response_body": "{bad-json"}} From 9fd196642dea93038a598d58fc89ec1a5bd322db Mon Sep 17 00:00:00 2001 From: zyssyz123 <916125788@qq.com> Date: Wed, 15 Apr 2026 16:46:25 +0800 Subject: [PATCH 05/61] feat: tidb endpoint (#35158) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- ...d_qdrant_endpoint_to_tidb_auth_bindings.py | 26 +++ api/models/dataset.py | 1 + .../tidb_on_qdrant_vector.py | 33 ++- .../dify_vdb_tidb_on_qdrant/tidb_service.py | 74 +++++- .../unit_tests/test_tidb_on_qdrant_vector.py | 79 ++++++- .../tests/unit_tests/test_tidb_service.py | 218 ++++++++++++++++++ api/schedule/create_tidb_serverless_task.py | 1 + 7 files changed, 425 insertions(+), 7 deletions(-) create mode 100644 api/migrations/versions/2026_04_14_1500-8574b23a38fd_add_qdrant_endpoint_to_tidb_auth_bindings.py create mode 100644 api/providers/vdb/vdb-tidb-on-qdrant/tests/unit_tests/test_tidb_service.py diff --git a/api/migrations/versions/2026_04_14_1500-8574b23a38fd_add_qdrant_endpoint_to_tidb_auth_bindings.py b/api/migrations/versions/2026_04_14_1500-8574b23a38fd_add_qdrant_endpoint_to_tidb_auth_bindings.py new file mode 100644 index 0000000000..0e188ec080 --- /dev/null +++ b/api/migrations/versions/2026_04_14_1500-8574b23a38fd_add_qdrant_endpoint_to_tidb_auth_bindings.py @@ -0,0 +1,26 @@ +"""add qdrant_endpoint to tidb_auth_bindings + +Revision ID: 8574b23a38fd +Revises: 6b5f9f8b1a2c +Create Date: 2026-04-14 15:00:00.000000 + +""" + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "8574b23a38fd" +down_revision = "6b5f9f8b1a2c" +branch_labels = None +depends_on = None + + +def upgrade(): + with op.batch_alter_table("tidb_auth_bindings", schema=None) as batch_op: + batch_op.add_column(sa.Column("qdrant_endpoint", sa.String(length=512), nullable=True)) + + +def downgrade(): + with op.batch_alter_table("tidb_auth_bindings", schema=None) as batch_op: + batch_op.drop_column("qdrant_endpoint") diff --git a/api/models/dataset.py b/api/models/dataset.py index 4540c29206..50301dd2d7 100644 --- a/api/models/dataset.py +++ b/api/models/dataset.py @@ -1305,6 +1305,7 @@ class TidbAuthBinding(TypeBase): ) account: Mapped[str] = mapped_column(String(255), nullable=False) password: Mapped[str] = mapped_column(String(255), nullable=False) + qdrant_endpoint: Mapped[str | None] = mapped_column(String(512), nullable=True, default=None) created_at: Mapped[datetime] = mapped_column( DateTime, nullable=False, server_default=func.current_timestamp(), init=False ) diff --git a/api/providers/vdb/vdb-tidb-on-qdrant/src/dify_vdb_tidb_on_qdrant/tidb_on_qdrant_vector.py b/api/providers/vdb/vdb-tidb-on-qdrant/src/dify_vdb_tidb_on_qdrant/tidb_on_qdrant_vector.py index bb8a580ebf..abca55f540 100644 --- a/api/providers/vdb/vdb-tidb-on-qdrant/src/dify_vdb_tidb_on_qdrant/tidb_on_qdrant_vector.py +++ b/api/providers/vdb/vdb-tidb-on-qdrant/src/dify_vdb_tidb_on_qdrant/tidb_on_qdrant_vector.py @@ -1,4 +1,5 @@ import json +import logging import os import uuid from collections.abc import Generator, Iterable, Sequence @@ -7,6 +8,8 @@ from typing import TYPE_CHECKING, Any import httpx import qdrant_client + +logger = logging.getLogger(__name__) from flask import current_app from httpx import DigestAuth from pydantic import BaseModel @@ -421,13 +424,16 @@ class TidbOnQdrantVector(BaseVector): class TidbOnQdrantVectorFactory(AbstractVectorFactory): def init_vector(self, dataset: Dataset, attributes: list, embeddings: Embeddings) -> TidbOnQdrantVector: + logger.info("init_vector: tenant_id=%s, dataset_id=%s", dataset.tenant_id, dataset.id) stmt = select(TidbAuthBinding).where(TidbAuthBinding.tenant_id == dataset.tenant_id) tidb_auth_binding = db.session.scalars(stmt).one_or_none() if not tidb_auth_binding: + logger.info("No existing TidbAuthBinding for tenant %s, acquiring lock", dataset.tenant_id) with redis_client.lock("create_tidb_serverless_cluster_lock", timeout=900): stmt = select(TidbAuthBinding).where(TidbAuthBinding.tenant_id == dataset.tenant_id) tidb_auth_binding = db.session.scalars(stmt).one_or_none() if tidb_auth_binding: + logger.info("Found binding after lock: cluster_id=%s", tidb_auth_binding.cluster_id) TIDB_ON_QDRANT_API_KEY = f"{tidb_auth_binding.account}:{tidb_auth_binding.password}" else: @@ -437,11 +443,18 @@ class TidbOnQdrantVectorFactory(AbstractVectorFactory): .limit(1) ) if idle_tidb_auth_binding: + logger.info( + "Assigning idle cluster %s to tenant %s", + idle_tidb_auth_binding.cluster_id, + dataset.tenant_id, + ) idle_tidb_auth_binding.active = True idle_tidb_auth_binding.tenant_id = dataset.tenant_id db.session.commit() + tidb_auth_binding = idle_tidb_auth_binding TIDB_ON_QDRANT_API_KEY = f"{idle_tidb_auth_binding.account}:{idle_tidb_auth_binding.password}" else: + logger.info("No idle clusters available, creating new cluster for tenant %s", dataset.tenant_id) new_cluster = TidbService.create_tidb_serverless_cluster( dify_config.TIDB_PROJECT_ID or "", dify_config.TIDB_API_URL or "", @@ -450,21 +463,39 @@ class TidbOnQdrantVectorFactory(AbstractVectorFactory): dify_config.TIDB_PRIVATE_KEY or "", dify_config.TIDB_REGION or "", ) + logger.info( + "New cluster created: cluster_id=%s, qdrant_endpoint=%s", + new_cluster["cluster_id"], + new_cluster.get("qdrant_endpoint"), + ) new_tidb_auth_binding = TidbAuthBinding( cluster_id=new_cluster["cluster_id"], cluster_name=new_cluster["cluster_name"], account=new_cluster["account"], password=new_cluster["password"], + qdrant_endpoint=new_cluster.get("qdrant_endpoint"), tenant_id=dataset.tenant_id, active=True, status=TidbAuthBindingStatus.ACTIVE, ) db.session.add(new_tidb_auth_binding) db.session.commit() + tidb_auth_binding = new_tidb_auth_binding TIDB_ON_QDRANT_API_KEY = f"{new_tidb_auth_binding.account}:{new_tidb_auth_binding.password}" else: + logger.info("Existing binding found: cluster_id=%s", tidb_auth_binding.cluster_id) TIDB_ON_QDRANT_API_KEY = f"{tidb_auth_binding.account}:{tidb_auth_binding.password}" + qdrant_url = ( + (tidb_auth_binding.qdrant_endpoint if tidb_auth_binding else None) or dify_config.TIDB_ON_QDRANT_URL or "" + ) + logger.info( + "Using qdrant endpoint: %s (from_binding=%s, fallback_global=%s)", + qdrant_url, + tidb_auth_binding.qdrant_endpoint if tidb_auth_binding else None, + dify_config.TIDB_ON_QDRANT_URL, + ) + if dataset.index_struct_dict: class_prefix: str = dataset.index_struct_dict["vector_store"]["class_prefix"] collection_name = class_prefix @@ -479,7 +510,7 @@ class TidbOnQdrantVectorFactory(AbstractVectorFactory): collection_name=collection_name, group_id=dataset.id, config=TidbOnQdrantConfig( - endpoint=dify_config.TIDB_ON_QDRANT_URL or "", + endpoint=qdrant_url, api_key=TIDB_ON_QDRANT_API_KEY, root_path=str(config.root_path), timeout=dify_config.TIDB_ON_QDRANT_CLIENT_TIMEOUT, diff --git a/api/providers/vdb/vdb-tidb-on-qdrant/src/dify_vdb_tidb_on_qdrant/tidb_service.py b/api/providers/vdb/vdb-tidb-on-qdrant/src/dify_vdb_tidb_on_qdrant/tidb_service.py index 37114be6e7..ece061db67 100644 --- a/api/providers/vdb/vdb-tidb-on-qdrant/src/dify_vdb_tidb_on_qdrant/tidb_service.py +++ b/api/providers/vdb/vdb-tidb-on-qdrant/src/dify_vdb_tidb_on_qdrant/tidb_service.py @@ -1,3 +1,4 @@ +import logging import time import uuid from collections.abc import Sequence @@ -12,6 +13,8 @@ from extensions.ext_redis import redis_client from models.dataset import TidbAuthBinding from models.enums import TidbAuthBindingStatus +logger = logging.getLogger(__name__) + # Reuse a pooled HTTP client for all TiDB Cloud requests to minimize connection churn _tidb_http_client: httpx.Client = get_pooled_http_client( "tidb:cloud", @@ -20,6 +23,46 @@ _tidb_http_client: httpx.Client = get_pooled_http_client( class TidbService: + @staticmethod + def extract_qdrant_endpoint(cluster_response: dict) -> str | None: + """Extract the qdrant endpoint URL from a Get Cluster API response. + + Reads ``endpoints.public.host`` (e.g. ``gateway01.xx.tidbcloud.com``), + prepends ``qdrant-`` and wraps it as an ``https://`` URL. + """ + endpoints = cluster_response.get("endpoints") or {} + public = endpoints.get("public") or {} + host = public.get("host") + if host: + return f"https://qdrant-{host}" + return None + + @staticmethod + def fetch_qdrant_endpoint(api_url: str, public_key: str, private_key: str, cluster_id: str) -> str | None: + """Call Get Cluster API and extract the qdrant endpoint. + + Use ``extract_qdrant_endpoint`` instead when you already have + the cluster response to avoid a redundant API call. + """ + try: + logger.info("Fetching qdrant endpoint for cluster %s", cluster_id) + cluster_response = TidbService.get_tidb_serverless_cluster(api_url, public_key, private_key, cluster_id) + if not cluster_response: + logger.warning("Empty response from Get Cluster API for cluster %s", cluster_id) + return None + qdrant_url = TidbService.extract_qdrant_endpoint(cluster_response) + if qdrant_url: + logger.info("Resolved qdrant endpoint for cluster %s: %s", cluster_id, qdrant_url) + return qdrant_url + logger.warning( + "No endpoints.public.host found for cluster %s, response keys: %s", + cluster_id, + list(cluster_response.keys()), + ) + except Exception: + logger.exception("Failed to fetch qdrant endpoint for cluster %s", cluster_id) + return None + @staticmethod def create_tidb_serverless_cluster( project_id: str, api_url: str, iam_url: str, public_key: str, private_key: str, region: str @@ -57,6 +100,7 @@ class TidbService: "rootPassword": password, } + logger.info("Creating TiDB serverless cluster: display_name=%s, region=%s", display_name, region) response = _tidb_http_client.post( f"{api_url}/clusters", json=cluster_data, auth=DigestAuth(public_key, private_key) ) @@ -64,21 +108,39 @@ class TidbService: if response.status_code == 200: response_data = response.json() cluster_id = response_data["clusterId"] + logger.info("Cluster created, cluster_id=%s, waiting for ACTIVE state", cluster_id) retry_count = 0 max_retries = 30 while retry_count < max_retries: cluster_response = TidbService.get_tidb_serverless_cluster(api_url, public_key, private_key, cluster_id) if cluster_response["state"] == "ACTIVE": user_prefix = cluster_response["userPrefix"] + qdrant_endpoint = TidbService.extract_qdrant_endpoint(cluster_response) + logger.info( + "Cluster %s is ACTIVE, user_prefix=%s, qdrant_endpoint=%s", + cluster_id, + user_prefix, + qdrant_endpoint, + ) return { "cluster_id": cluster_id, "cluster_name": display_name, "account": f"{user_prefix}.root", "password": password, + "qdrant_endpoint": qdrant_endpoint, } - time.sleep(30) # wait 30 seconds before retrying + logger.info( + "Cluster %s state=%s, retry %d/%d", + cluster_id, + cluster_response["state"], + retry_count + 1, + max_retries, + ) + time.sleep(30) retry_count += 1 + logger.error("Cluster %s did not become ACTIVE after %d retries", cluster_id, max_retries) else: + logger.error("Failed to create cluster: status=%d, body=%s", response.status_code, response.text) response.raise_for_status() @staticmethod @@ -243,19 +305,29 @@ class TidbService: if response.status_code == 200: response_data = response.json() cluster_infos = [] + logger.info("Batch created %d clusters", len(response_data.get("clusters", []))) for item in response_data["clusters"]: cache_key = f"tidb_serverless_cluster_password:{item['displayName']}" cached_password = redis_client.get(cache_key) if not cached_password: + logger.warning("No cached password for cluster %s, skipping", item["displayName"]) continue + qdrant_endpoint = TidbService.fetch_qdrant_endpoint(api_url, public_key, private_key, item["clusterId"]) + logger.info( + "Batch cluster %s: qdrant_endpoint=%s", + item["clusterId"], + qdrant_endpoint, + ) cluster_info = { "cluster_id": item["clusterId"], "cluster_name": item["displayName"], "account": "root", "password": cached_password.decode("utf-8"), + "qdrant_endpoint": qdrant_endpoint, } cluster_infos.append(cluster_info) return cluster_infos else: + logger.error("Batch create failed: status=%d, body=%s", response.status_code, response.text) response.raise_for_status() return [] diff --git a/api/providers/vdb/vdb-tidb-on-qdrant/tests/unit_tests/test_tidb_on_qdrant_vector.py b/api/providers/vdb/vdb-tidb-on-qdrant/tests/unit_tests/test_tidb_on_qdrant_vector.py index 3e9229fea5..76802de62e 100644 --- a/api/providers/vdb/vdb-tidb-on-qdrant/tests/unit_tests/test_tidb_on_qdrant_vector.py +++ b/api/providers/vdb/vdb-tidb-on-qdrant/tests/unit_tests/test_tidb_on_qdrant_vector.py @@ -114,14 +114,12 @@ class TestTidbOnQdrantVectorDeleteByIds: assert exc_info.value.status_code == 500 - def test_delete_by_ids_with_large_batch(self, vector_instance): - """Test deletion with a large batch of IDs.""" - # Create 1000 IDs + def test_delete_by_ids_with_exactly_1000(self, vector_instance): + """Test deletion with exactly 1000 IDs triggers a single batch.""" ids = [f"doc_{i}" for i in range(1000)] vector_instance.delete_by_ids(ids) - # Verify single delete call with all IDs vector_instance._client.delete.assert_called_once() call_args = vector_instance._client.delete.call_args @@ -129,11 +127,28 @@ class TestTidbOnQdrantVectorDeleteByIds: filter_obj = filter_selector.filter field_condition = filter_obj.must[0] - # Verify all 1000 IDs are in the batch assert len(field_condition.match.any) == 1000 assert "doc_0" in field_condition.match.any assert "doc_999" in field_condition.match.any + def test_delete_by_ids_splits_into_batches(self, vector_instance): + """Test deletion with >1000 IDs triggers multiple batched calls.""" + ids = [f"doc_{i}" for i in range(2500)] + + vector_instance.delete_by_ids(ids) + + assert vector_instance._client.delete.call_count == 3 + + batches = [] + for call in vector_instance._client.delete.call_args_list: + filter_selector = call[1]["points_selector"] + field_condition = filter_selector.filter.must[0] + batches.append(field_condition.match.any) + + assert len(batches[0]) == 1000 + assert len(batches[1]) == 1000 + assert len(batches[2]) == 500 + def test_delete_by_ids_filter_structure(self, vector_instance): """Test that the filter structure is correctly constructed.""" ids = ["doc1", "doc2"] @@ -157,3 +172,57 @@ class TestTidbOnQdrantVectorDeleteByIds: # Verify MatchAny structure assert isinstance(field_condition.match, rest.MatchAny) assert field_condition.match.any == ids + + +class TestInitVectorEndpointSelection: + """Test that init_vector selects the correct qdrant endpoint. + + We avoid importing the full module (which triggers Flask app context) + by testing the endpoint selection logic directly on TidbOnQdrantConfig. + """ + + def test_uses_binding_endpoint_when_present(self): + binding_endpoint = "https://qdrant-custom.tidb.com" + global_url = "https://qdrant-global.tidb.com" + + qdrant_url = binding_endpoint or global_url or "" + + assert qdrant_url == "https://qdrant-custom.tidb.com" + config = TidbOnQdrantConfig(endpoint=qdrant_url) + assert config.endpoint == "https://qdrant-custom.tidb.com" + + def test_falls_back_to_global_when_binding_endpoint_is_none(self): + binding_endpoint = None + global_url = "https://qdrant-global.tidb.com" + + qdrant_url = binding_endpoint or global_url or "" + + assert qdrant_url == "https://qdrant-global.tidb.com" + config = TidbOnQdrantConfig(endpoint=qdrant_url) + assert config.endpoint == "https://qdrant-global.tidb.com" + + def test_falls_back_to_empty_when_both_none(self): + binding_endpoint = None + global_url = None + + qdrant_url = binding_endpoint or global_url or "" + + assert qdrant_url == "" + config = TidbOnQdrantConfig(endpoint=qdrant_url) + assert config.endpoint == "" + + def test_binding_endpoint_takes_precedence_over_global(self): + binding_endpoint = "https://qdrant-ap-southeast.tidb.com" + global_url = "https://qdrant-us-east.tidb.com" + + qdrant_url = binding_endpoint or global_url or "" + + assert qdrant_url == "https://qdrant-ap-southeast.tidb.com" + + def test_empty_string_binding_endpoint_falls_back_to_global(self): + binding_endpoint = "" + global_url = "https://qdrant-global.tidb.com" + + qdrant_url = binding_endpoint or global_url or "" + + assert qdrant_url == "https://qdrant-global.tidb.com" diff --git a/api/providers/vdb/vdb-tidb-on-qdrant/tests/unit_tests/test_tidb_service.py b/api/providers/vdb/vdb-tidb-on-qdrant/tests/unit_tests/test_tidb_service.py new file mode 100644 index 0000000000..c1ffbacbbc --- /dev/null +++ b/api/providers/vdb/vdb-tidb-on-qdrant/tests/unit_tests/test_tidb_service.py @@ -0,0 +1,218 @@ +from unittest.mock import MagicMock, patch + +import pytest +from dify_vdb_tidb_on_qdrant.tidb_service import TidbService + + +class TestExtractQdrantEndpoint: + """Unit tests for TidbService.extract_qdrant_endpoint.""" + + def test_returns_endpoint_when_host_present(self): + response = {"endpoints": {"public": {"host": "gateway01.us-east-1.tidbcloud.com", "port": 4000}}} + result = TidbService.extract_qdrant_endpoint(response) + assert result == "https://qdrant-gateway01.us-east-1.tidbcloud.com" + + def test_returns_none_when_host_missing(self): + response = {"endpoints": {"public": {}}} + assert TidbService.extract_qdrant_endpoint(response) is None + + def test_returns_none_when_public_missing(self): + response = {"endpoints": {}} + assert TidbService.extract_qdrant_endpoint(response) is None + + def test_returns_none_when_endpoints_missing(self): + assert TidbService.extract_qdrant_endpoint({}) is None + + +class TestFetchQdrantEndpoint: + """Unit tests for TidbService.fetch_qdrant_endpoint.""" + + @patch.object(TidbService, "get_tidb_serverless_cluster") + def test_returns_endpoint_when_host_present(self, mock_get_cluster): + mock_get_cluster.return_value = { + "endpoints": {"public": {"host": "gateway01.us-east-1.tidbcloud.com", "port": 4000}} + } + result = TidbService.fetch_qdrant_endpoint("url", "pub", "priv", "c-123") + assert result == "https://qdrant-gateway01.us-east-1.tidbcloud.com" + + @patch.object(TidbService, "get_tidb_serverless_cluster") + def test_returns_none_when_cluster_response_is_none(self, mock_get_cluster): + mock_get_cluster.return_value = None + assert TidbService.fetch_qdrant_endpoint("url", "pub", "priv", "c-123") is None + + @patch.object(TidbService, "get_tidb_serverless_cluster") + def test_returns_none_when_host_missing(self, mock_get_cluster): + mock_get_cluster.return_value = {"endpoints": {"public": {}}} + assert TidbService.fetch_qdrant_endpoint("url", "pub", "priv", "c-123") is None + + @patch.object(TidbService, "get_tidb_serverless_cluster") + def test_returns_none_when_endpoints_missing(self, mock_get_cluster): + mock_get_cluster.return_value = {} + assert TidbService.fetch_qdrant_endpoint("url", "pub", "priv", "c-123") is None + + @patch.object(TidbService, "get_tidb_serverless_cluster") + def test_returns_none_on_exception(self, mock_get_cluster): + mock_get_cluster.side_effect = RuntimeError("network error") + assert TidbService.fetch_qdrant_endpoint("url", "pub", "priv", "c-123") is None + + +class TestCreateTidbServerlessClusterQdrantEndpoint: + """Verify that create_tidb_serverless_cluster includes qdrant_endpoint in its result.""" + + @patch.object(TidbService, "get_tidb_serverless_cluster") + @patch("dify_vdb_tidb_on_qdrant.tidb_service._tidb_http_client") + @patch("dify_vdb_tidb_on_qdrant.tidb_service.dify_config") + def test_result_contains_qdrant_endpoint(self, mock_config, mock_http, mock_get_cluster): + mock_config.TIDB_SPEND_LIMIT = 10 + mock_http.post.return_value = MagicMock(status_code=200, json=lambda: {"clusterId": "c-1"}) + mock_get_cluster.return_value = { + "state": "ACTIVE", + "userPrefix": "pfx", + "endpoints": {"public": {"host": "gw.tidbcloud.com", "port": 4000}}, + } + + result = TidbService.create_tidb_serverless_cluster("proj", "url", "iam", "pub", "priv", "us-east-1") + + assert result is not None + assert result["qdrant_endpoint"] == "https://qdrant-gw.tidbcloud.com" + + @patch.object(TidbService, "get_tidb_serverless_cluster") + @patch("dify_vdb_tidb_on_qdrant.tidb_service._tidb_http_client") + @patch("dify_vdb_tidb_on_qdrant.tidb_service.dify_config") + def test_result_qdrant_endpoint_none_when_no_endpoints(self, mock_config, mock_http, mock_get_cluster): + mock_config.TIDB_SPEND_LIMIT = 10 + mock_http.post.return_value = MagicMock(status_code=200, json=lambda: {"clusterId": "c-1"}) + mock_get_cluster.return_value = {"state": "ACTIVE", "userPrefix": "pfx"} + + result = TidbService.create_tidb_serverless_cluster("proj", "url", "iam", "pub", "priv", "us-east-1") + + assert result is not None + assert result["qdrant_endpoint"] is None + + +class TestBatchCreateTidbServerlessClusterQdrantEndpoint: + """Verify that batch_create includes qdrant_endpoint per cluster.""" + + @patch.object(TidbService, "fetch_qdrant_endpoint", return_value="https://qdrant-gw.tidbcloud.com") + @patch("dify_vdb_tidb_on_qdrant.tidb_service.redis_client") + @patch("dify_vdb_tidb_on_qdrant.tidb_service._tidb_http_client") + @patch("dify_vdb_tidb_on_qdrant.tidb_service.dify_config") + def test_batch_result_contains_qdrant_endpoint(self, mock_config, mock_http, mock_redis, mock_fetch_ep): + mock_config.TIDB_SPEND_LIMIT = 10 + cluster_name = "abc123" + mock_http.post.return_value = MagicMock( + status_code=200, + json=lambda: {"clusters": [{"clusterId": "c-1", "displayName": cluster_name}]}, + ) + mock_redis.setex = MagicMock() + mock_redis.get.return_value = b"password123" + + result = TidbService.batch_create_tidb_serverless_cluster( + batch_size=1, + project_id="proj", + api_url="url", + iam_url="iam", + public_key="pub", + private_key="priv", + region="us-east-1", + ) + + assert len(result) == 1 + assert result[0]["qdrant_endpoint"] == "https://qdrant-gw.tidbcloud.com" + + +class TestCreateTidbServerlessClusterRetry: + """Cover retry/logging paths in create_tidb_serverless_cluster.""" + + @patch.object(TidbService, "get_tidb_serverless_cluster") + @patch("dify_vdb_tidb_on_qdrant.tidb_service._tidb_http_client") + @patch("dify_vdb_tidb_on_qdrant.tidb_service.dify_config") + def test_polls_until_active(self, mock_config, mock_http, mock_get_cluster): + mock_config.TIDB_SPEND_LIMIT = 10 + mock_http.post.return_value = MagicMock(status_code=200, json=lambda: {"clusterId": "c-1"}) + mock_get_cluster.side_effect = [ + {"state": "CREATING", "userPrefix": ""}, + {"state": "ACTIVE", "userPrefix": "pfx", "endpoints": {"public": {"host": "gw.tidb.com"}}}, + ] + + with patch("dify_vdb_tidb_on_qdrant.tidb_service.time.sleep"): + result = TidbService.create_tidb_serverless_cluster("proj", "url", "iam", "pub", "priv", "us-east-1") + + assert result is not None + assert result["qdrant_endpoint"] == "https://qdrant-gw.tidb.com" + assert mock_get_cluster.call_count == 2 + + @patch.object(TidbService, "get_tidb_serverless_cluster") + @patch("dify_vdb_tidb_on_qdrant.tidb_service._tidb_http_client") + @patch("dify_vdb_tidb_on_qdrant.tidb_service.dify_config") + def test_returns_none_after_max_retries(self, mock_config, mock_http, mock_get_cluster): + mock_config.TIDB_SPEND_LIMIT = 10 + mock_http.post.return_value = MagicMock(status_code=200, json=lambda: {"clusterId": "c-1"}) + mock_get_cluster.return_value = {"state": "CREATING", "userPrefix": ""} + + with patch("dify_vdb_tidb_on_qdrant.tidb_service.time.sleep"): + result = TidbService.create_tidb_serverless_cluster("proj", "url", "iam", "pub", "priv", "us-east-1") + + assert result is None + + @patch("dify_vdb_tidb_on_qdrant.tidb_service._tidb_http_client") + @patch("dify_vdb_tidb_on_qdrant.tidb_service.dify_config") + def test_raises_on_post_failure(self, mock_config, mock_http): + mock_config.TIDB_SPEND_LIMIT = 10 + mock_response = MagicMock(status_code=400, text="Bad Request") + mock_response.raise_for_status.side_effect = Exception("HTTP 400") + mock_http.post.return_value = mock_response + + with pytest.raises(Exception, match="HTTP 400"): + TidbService.create_tidb_serverless_cluster("proj", "url", "iam", "pub", "priv", "us-east-1") + + +class TestBatchCreateEdgeCases: + """Cover logging/edge-case branches in batch_create.""" + + @patch.object(TidbService, "fetch_qdrant_endpoint", return_value=None) + @patch("dify_vdb_tidb_on_qdrant.tidb_service.redis_client") + @patch("dify_vdb_tidb_on_qdrant.tidb_service._tidb_http_client") + @patch("dify_vdb_tidb_on_qdrant.tidb_service.dify_config") + def test_skips_cluster_when_no_cached_password(self, mock_config, mock_http, mock_redis, mock_fetch_ep): + mock_config.TIDB_SPEND_LIMIT = 10 + mock_http.post.return_value = MagicMock( + status_code=200, + json=lambda: {"clusters": [{"clusterId": "c-1", "displayName": "name1"}]}, + ) + mock_redis.setex = MagicMock() + mock_redis.get.return_value = None + + result = TidbService.batch_create_tidb_serverless_cluster( + batch_size=1, + project_id="proj", + api_url="url", + iam_url="iam", + public_key="pub", + private_key="priv", + region="us-east-1", + ) + + assert len(result) == 0 + mock_fetch_ep.assert_not_called() + + @patch("dify_vdb_tidb_on_qdrant.tidb_service.redis_client") + @patch("dify_vdb_tidb_on_qdrant.tidb_service._tidb_http_client") + @patch("dify_vdb_tidb_on_qdrant.tidb_service.dify_config") + def test_raises_on_post_failure(self, mock_config, mock_http, mock_redis): + mock_config.TIDB_SPEND_LIMIT = 10 + mock_response = MagicMock(status_code=500, text="Server Error") + mock_response.raise_for_status.side_effect = Exception("HTTP 500") + mock_http.post.return_value = mock_response + mock_redis.setex = MagicMock() + + with pytest.raises(Exception, match="HTTP 500"): + TidbService.batch_create_tidb_serverless_cluster( + batch_size=1, + project_id="proj", + api_url="url", + iam_url="iam", + public_key="pub", + private_key="priv", + region="us-east-1", + ) diff --git a/api/schedule/create_tidb_serverless_task.py b/api/schedule/create_tidb_serverless_task.py index c4c203c150..e242b0c667 100644 --- a/api/schedule/create_tidb_serverless_task.py +++ b/api/schedule/create_tidb_serverless_task.py @@ -57,6 +57,7 @@ def create_clusters(batch_size): cluster_name=new_cluster["cluster_name"], account=new_cluster["account"], password=new_cluster["password"], + qdrant_endpoint=new_cluster.get("qdrant_endpoint"), active=False, status=TidbAuthBindingStatus.CREATING, ) From fb17339d8989cc12fa3adbdac5f1c67565b74914 Mon Sep 17 00:00:00 2001 From: Coding On Star <447357187@qq.com> Date: Wed, 15 Apr 2026 16:59:31 +0800 Subject: [PATCH 06/61] feat(web): unify create_app tracking and persist external attribution (#35241) Co-authored-by: CodingOnStar --- .../__tests__/app-initializer.spec.tsx | 197 ++++++++++++++ .../create-app-attribution-bootstrap.spec.tsx | 97 +++++++ .../app-list/__tests__/index.spec.tsx | 13 +- .../app/create-app-dialog/app-list/index.tsx | 11 +- .../create-app-modal/__tests__/index.spec.tsx | 13 +- .../components/app/create-app-modal/index.tsx | 8 +- .../__tests__/index.spec.tsx | 21 +- .../app/create-from-dsl-modal/index.tsx | 10 +- .../components/apps/__tests__/index.spec.tsx | 200 ++++++++++++++- web/app/components/apps/index.tsx | 25 +- .../create-app-attribution-bootstrap.tsx | 23 ++ .../explore/app-list/__tests__/index.spec.tsx | 40 ++- web/app/components/explore/app-list/index.tsx | 25 +- web/app/layout.tsx | 2 + web/app/signup/set-password/page.tsx | 2 + .../__tests__/create-app-tracking.spec.ts | 189 ++++++++++++++ web/utils/create-app-tracking.ts | 240 ++++++++++++++++++ 17 files changed, 1045 insertions(+), 71 deletions(-) create mode 100644 web/app/components/__tests__/app-initializer.spec.tsx create mode 100644 web/app/components/__tests__/create-app-attribution-bootstrap.spec.tsx create mode 100644 web/app/components/create-app-attribution-bootstrap.tsx create mode 100644 web/utils/__tests__/create-app-tracking.spec.ts create mode 100644 web/utils/create-app-tracking.ts diff --git a/web/app/components/__tests__/app-initializer.spec.tsx b/web/app/components/__tests__/app-initializer.spec.tsx new file mode 100644 index 0000000000..b4c2d08f2e --- /dev/null +++ b/web/app/components/__tests__/app-initializer.spec.tsx @@ -0,0 +1,197 @@ +import { screen, waitFor } from '@testing-library/react' +import Cookies from 'js-cookie' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + EDUCATION_VERIFY_URL_SEARCHPARAMS_ACTION, + EDUCATION_VERIFYING_LOCALSTORAGE_ITEM, +} from '@/app/education-apply/constants' +import { resolvePostLoginRedirect } from '@/app/signin/utils/post-login-redirect' +import { usePathname, useRouter, useSearchParams } from '@/next/navigation' +import { renderWithNuqs } from '@/test/nuqs-testing' +import { fetchSetupStatusWithCache } from '@/utils/setup-status' +import { AppInitializer } from '../app-initializer' + +const { mockSendGAEvent, mockTrackEvent } = vi.hoisted(() => ({ + mockSendGAEvent: vi.fn(), + mockTrackEvent: vi.fn(), +})) + +vi.mock('@/next/navigation', () => ({ + usePathname: vi.fn(), + useRouter: vi.fn(), + useSearchParams: vi.fn(), +})) + +vi.mock('@/utils/setup-status', () => ({ + fetchSetupStatusWithCache: vi.fn(), +})) + +vi.mock('@/app/signin/utils/post-login-redirect', () => ({ + resolvePostLoginRedirect: vi.fn(), +})) + +vi.mock('@/utils/gtag', () => ({ + sendGAEvent: (...args: unknown[]) => mockSendGAEvent(...args), +})) + +vi.mock('../base/amplitude', () => ({ + trackEvent: (...args: unknown[]) => mockTrackEvent(...args), +})) + +const mockUsePathname = vi.mocked(usePathname) +const mockUseRouter = vi.mocked(useRouter) +const mockUseSearchParams = vi.mocked(useSearchParams) +const mockFetchSetupStatusWithCache = vi.mocked(fetchSetupStatusWithCache) +const mockResolvePostLoginRedirect = vi.mocked(resolvePostLoginRedirect) +const mockReplace = vi.fn() + +describe('AppInitializer', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.unstubAllGlobals() + window.localStorage.clear() + window.sessionStorage.clear() + Cookies.remove('utm_info') + vi.spyOn(console, 'error').mockImplementation(() => {}) + mockUsePathname.mockReturnValue('/apps') + mockUseRouter.mockReturnValue({ replace: mockReplace } as unknown as ReturnType) + mockUseSearchParams.mockReturnValue(new URLSearchParams() as unknown as ReturnType) + mockFetchSetupStatusWithCache.mockResolvedValue({ step: 'finished' }) + mockResolvePostLoginRedirect.mockReturnValue(null) + }) + + it('renders children after setup checks finish', async () => { + renderWithNuqs( + +
ready
+
, + ) + + await waitFor(() => expect(screen.getByText('ready')).toBeInTheDocument()) + + expect(mockFetchSetupStatusWithCache).toHaveBeenCalledTimes(1) + expect(mockReplace).not.toHaveBeenCalledWith('/signin') + }) + + it('redirects to install when setup status loading fails', async () => { + mockFetchSetupStatusWithCache.mockRejectedValue(new Error('unauthorized')) + + renderWithNuqs( + +
ready
+
, + ) + + await waitFor(() => expect(mockReplace).toHaveBeenCalledWith('/install')) + expect(screen.queryByText('ready')).not.toBeInTheDocument() + }) + + it('does not persist create app attribution from the url anymore', async () => { + renderWithNuqs( + +
ready
+
, + ) + + await waitFor(() => expect(screen.getByText('ready')).toBeInTheDocument()) + + expect(window.sessionStorage.getItem('create_app_external_attribution')).toBeNull() + }) + + it('tracks oauth registration with utm info and clears the cookie', async () => { + Cookies.set('utm_info', JSON.stringify({ + utm_source: 'linkedin', + slug: 'agent-launch', + })) + + renderWithNuqs( + +
ready
+
, + { searchParams: 'oauth_new_user=true' }, + ) + + await waitFor(() => expect(screen.getByText('ready')).toBeInTheDocument()) + + expect(mockTrackEvent).toHaveBeenCalledWith('user_registration_success_with_utm', { + method: 'oauth', + utm_source: 'linkedin', + slug: 'agent-launch', + }) + expect(mockSendGAEvent).toHaveBeenCalledWith('user_registration_success_with_utm', { + method: 'oauth', + utm_source: 'linkedin', + slug: 'agent-launch', + }) + expect(mockReplace).toHaveBeenCalledWith('/apps') + expect(Cookies.get('utm_info')).toBeUndefined() + }) + + it('falls back to the base registration event when the oauth utm cookie is invalid', async () => { + Cookies.set('utm_info', '{invalid-json') + + renderWithNuqs( + +
ready
+
, + { searchParams: 'oauth_new_user=true' }, + ) + + await waitFor(() => expect(screen.getByText('ready')).toBeInTheDocument()) + + expect(mockTrackEvent).toHaveBeenCalledWith('user_registration_success', { + method: 'oauth', + }) + expect(mockSendGAEvent).toHaveBeenCalledWith('user_registration_success', { + method: 'oauth', + }) + expect(console.error).toHaveBeenCalled() + expect(Cookies.get('utm_info')).toBeUndefined() + }) + + it('stores the education verification flag in localStorage', async () => { + mockUseSearchParams.mockReturnValue( + new URLSearchParams(`action=${EDUCATION_VERIFY_URL_SEARCHPARAMS_ACTION}`) as unknown as ReturnType, + ) + + renderWithNuqs( + +
ready
+
, + ) + + await waitFor(() => expect(screen.getByText('ready')).toBeInTheDocument()) + + expect(window.localStorage.getItem(EDUCATION_VERIFYING_LOCALSTORAGE_ITEM)).toBe('yes') + }) + + it('redirects to the resolved post-login url when one exists', async () => { + const mockLocationReplace = vi.fn() + vi.stubGlobal('location', { ...window.location, replace: mockLocationReplace }) + mockResolvePostLoginRedirect.mockReturnValue('/explore') + + renderWithNuqs( + +
ready
+
, + ) + + await waitFor(() => expect(mockLocationReplace).toHaveBeenCalledWith('/explore')) + expect(screen.queryByText('ready')).not.toBeInTheDocument() + }) + + it('redirects to signin when redirect resolution throws', async () => { + mockResolvePostLoginRedirect.mockImplementation(() => { + throw new Error('redirect resolution failed') + }) + + renderWithNuqs( + +
ready
+
, + ) + + await waitFor(() => expect(mockReplace).toHaveBeenCalledWith('/signin')) + expect(screen.queryByText('ready')).not.toBeInTheDocument() + }) +}) diff --git a/web/app/components/__tests__/create-app-attribution-bootstrap.spec.tsx b/web/app/components/__tests__/create-app-attribution-bootstrap.spec.tsx new file mode 100644 index 0000000000..a6cdf2cbf6 --- /dev/null +++ b/web/app/components/__tests__/create-app-attribution-bootstrap.spec.tsx @@ -0,0 +1,97 @@ +import type { ReactElement, ReactNode } from 'react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { runCreateAppAttributionBootstrap } from '@/utils/create-app-tracking' + +let mockIsProd = false +let mockNonce: string | null = 'test-nonce' + +type BootstrapScriptProps = { + id?: string + strategy?: string + nonce?: string + children?: string +} + +vi.mock('@/config', () => ({ + get IS_PROD() { return mockIsProd }, +})) + +vi.mock('@/next/headers', () => ({ + headers: vi.fn(() => ({ + get: vi.fn((name: string) => { + if (name === 'x-nonce') + return mockNonce + return null + }), + })), +})) + +const loadComponent = async () => { + const mod = await import('../create-app-attribution-bootstrap') + const rawExport = mod.default as unknown + const renderer: (() => Promise) | undefined + = typeof rawExport === 'function' ? rawExport as () => Promise : (rawExport as { type?: () => Promise }).type + + if (!renderer) + throw new Error('CreateAppAttributionBootstrap component is not callable in tests') + + return renderer +} + +const runBootstrapScript = () => { + runCreateAppAttributionBootstrap() +} + +describe('CreateAppAttributionBootstrap', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.resetModules() + mockIsProd = false + mockNonce = 'test-nonce' + window.sessionStorage.clear() + window.history.replaceState({}, '', '/apps') + }) + + it('renders a beforeInteractive script element', async () => { + const renderComponent = await loadComponent() + const element = await renderComponent() as ReactElement + + expect(element).toBeTruthy() + expect(element.props.id).toBe('create-app-attribution-bootstrap') + expect(element.props.strategy).toBe('beforeInteractive') + expect(element.props.children).toContain('window.sessionStorage.setItem') + }) + + it('uses the nonce header in production', async () => { + mockIsProd = true + mockNonce = 'prod-nonce' + + const renderComponent = await loadComponent() + const element = await renderComponent() as ReactElement + + expect(element.props.nonce).toBe('prod-nonce') + }) + + it('stores external attribution and clears only attribution params from the url', () => { + window.history.replaceState({}, '', '/apps?action=keep&utm_source=dify_blog&slug=get-started-with-dif#preview') + + runBootstrapScript() + + expect(window.sessionStorage.getItem('create_app_external_attribution')).toBe(JSON.stringify({ + utmSource: 'blog', + utmCampaign: 'get-started-with-dif', + })) + expect(window.location.pathname).toBe('/apps') + expect(window.location.search).toBe('?action=keep') + expect(window.location.hash).toBe('#preview') + }) + + it('does nothing for invalid external sources', () => { + window.history.replaceState({}, '', '/apps?action=keep&utm_source=internal&slug=ignored') + + runBootstrapScript() + + expect(window.sessionStorage.getItem('create_app_external_attribution')).toBeNull() + expect(window.location.search).toBe('?action=keep&utm_source=internal&slug=ignored') + }) +}) diff --git a/web/app/components/app/create-app-dialog/app-list/__tests__/index.spec.tsx b/web/app/components/app/create-app-dialog/app-list/__tests__/index.spec.tsx index 3ebc5f7157..a319bb58f7 100644 --- a/web/app/components/app/create-app-dialog/app-list/__tests__/index.spec.tsx +++ b/web/app/components/app/create-app-dialog/app-list/__tests__/index.spec.tsx @@ -4,7 +4,6 @@ import { AppModeEnum } from '@/types/app' import Apps from '../index' const mockUseExploreAppList = vi.fn() -const mockTrackEvent = vi.fn() const mockImportDSL = vi.fn() const mockFetchAppDetail = vi.fn() const mockHandleCheckPluginDependencies = vi.fn() @@ -12,6 +11,7 @@ const mockGetRedirection = vi.fn() const mockPush = vi.fn() const mockToastSuccess = vi.fn() const mockToastError = vi.fn() +const mockTrackCreateApp = vi.fn() let latestDebounceFn = () => {} vi.mock('ahooks', () => ({ @@ -92,8 +92,8 @@ vi.mock('@/app/components/base/ui/toast', () => ({ error: (...args: unknown[]) => mockToastError(...args), }, })) -vi.mock('@/app/components/base/amplitude', () => ({ - trackEvent: (...args: unknown[]) => mockTrackEvent(...args), +vi.mock('@/utils/create-app-tracking', () => ({ + trackCreateApp: (...args: unknown[]) => mockTrackCreateApp(...args), })) vi.mock('@/service/apps', () => ({ importDSL: (...args: unknown[]) => mockImportDSL(...args), @@ -246,10 +246,9 @@ describe('Apps', () => { })) }) - expect(mockTrackEvent).toHaveBeenCalledWith('create_app_with_template', expect.objectContaining({ - template_id: 'Alpha', - template_name: 'Alpha', - })) + expect(mockTrackCreateApp).toHaveBeenCalledWith({ + appMode: AppModeEnum.CHAT, + }) expect(mockToastSuccess).toHaveBeenCalledWith('app.newApp.appCreated') expect(onSuccess).toHaveBeenCalled() expect(mockHandleCheckPluginDependencies).toHaveBeenCalledWith('created-app-id') diff --git a/web/app/components/app/create-app-dialog/app-list/index.tsx b/web/app/components/app/create-app-dialog/app-list/index.tsx index 1aa40d2014..daf49115c8 100644 --- a/web/app/components/app/create-app-dialog/app-list/index.tsx +++ b/web/app/components/app/create-app-dialog/app-list/index.tsx @@ -8,7 +8,6 @@ import * as React from 'react' import { useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import AppTypeSelector from '@/app/components/app/type-selector' -import { trackEvent } from '@/app/components/base/amplitude' import Divider from '@/app/components/base/divider' import Input from '@/app/components/base/input' import Loading from '@/app/components/base/loading' @@ -25,6 +24,7 @@ import { useExploreAppList } from '@/service/use-explore' import { AppModeEnum } from '@/types/app' import { getRedirection } from '@/utils/app-redirection' import { cn } from '@/utils/classnames' +import { trackCreateApp } from '@/utils/create-app-tracking' import AppCard from '../app-card' import Sidebar, { AppCategories, AppCategoryLabel } from './sidebar' @@ -127,14 +127,7 @@ const Apps = ({ icon_background, description, }) - - // Track app creation from template - trackEvent('create_app_with_template', { - app_mode: mode, - template_id: currApp?.app.id, - template_name: currApp?.app.name, - description, - }) + trackCreateApp({ appMode: mode }) setIsShowCreateModal(false) toast.success(t('newApp.appCreated', { ns: 'app' })) diff --git a/web/app/components/app/create-app-modal/__tests__/index.spec.tsx b/web/app/components/app/create-app-modal/__tests__/index.spec.tsx index ee24ab4006..3e06b89f0e 100644 --- a/web/app/components/app/create-app-modal/__tests__/index.spec.tsx +++ b/web/app/components/app/create-app-modal/__tests__/index.spec.tsx @@ -1,7 +1,6 @@ import type { App } from '@/types/app' import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' -import { trackEvent } from '@/app/components/base/amplitude' import { NEED_REFRESH_APP_LIST_KEY } from '@/config' import { useAppContext } from '@/context/app-context' @@ -10,6 +9,7 @@ import { useRouter } from '@/next/navigation' import { createApp } from '@/service/apps' import { AppModeEnum } from '@/types/app' import { getRedirection } from '@/utils/app-redirection' +import { trackCreateApp } from '@/utils/create-app-tracking' import CreateAppModal from '../index' const ahooksMocks = vi.hoisted(() => ({ @@ -31,8 +31,8 @@ vi.mock('ahooks', () => ({ vi.mock('@/next/navigation', () => ({ useRouter: vi.fn(), })) -vi.mock('@/app/components/base/amplitude', () => ({ - trackEvent: vi.fn(), +vi.mock('@/utils/create-app-tracking', () => ({ + trackCreateApp: vi.fn(), })) vi.mock('@/service/apps', () => ({ createApp: vi.fn(), @@ -87,7 +87,7 @@ vi.mock('@/hooks/use-theme', () => ({ const mockUseRouter = vi.mocked(useRouter) const mockPush = vi.fn() const mockCreateApp = vi.mocked(createApp) -const mockTrackEvent = vi.mocked(trackEvent) +const mockTrackCreateApp = vi.mocked(trackCreateApp) const mockGetRedirection = vi.mocked(getRedirection) const mockUseProviderContext = vi.mocked(useProviderContext) const mockUseAppContext = vi.mocked(useAppContext) @@ -178,10 +178,7 @@ describe('CreateAppModal', () => { mode: AppModeEnum.ADVANCED_CHAT, })) - expect(mockTrackEvent).toHaveBeenCalledWith('create_app', { - app_mode: AppModeEnum.ADVANCED_CHAT, - description: '', - }) + expect(mockTrackCreateApp).toHaveBeenCalledWith({ appMode: AppModeEnum.ADVANCED_CHAT }) expect(mockToastSuccess).toHaveBeenCalledWith('app.newApp.appCreated') expect(onSuccess).toHaveBeenCalled() expect(onClose).toHaveBeenCalled() diff --git a/web/app/components/app/create-app-modal/index.tsx b/web/app/components/app/create-app-modal/index.tsx index c0c70660bc..61681892d2 100644 --- a/web/app/components/app/create-app-modal/index.tsx +++ b/web/app/components/app/create-app-modal/index.tsx @@ -6,7 +6,6 @@ import { RiArrowRightLine, RiArrowRightSLine, RiExchange2Fill } from '@remixicon import { useDebounceFn, useKeyPress } from 'ahooks' import { useCallback, useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' -import { trackEvent } from '@/app/components/base/amplitude' import AppIcon from '@/app/components/base/app-icon' import Divider from '@/app/components/base/divider' import FullScreenModal from '@/app/components/base/fullscreen-modal' @@ -25,6 +24,7 @@ import { createApp } from '@/service/apps' import { AppModeEnum } from '@/types/app' import { getRedirection } from '@/utils/app-redirection' import { cn } from '@/utils/classnames' +import { trackCreateApp } from '@/utils/create-app-tracking' import { basePath } from '@/utils/var' import AppIconPicker from '../../base/app-icon-picker' import ShortcutsName from '../../workflow/shortcuts-name' @@ -80,11 +80,7 @@ function CreateApp({ onClose, onSuccess, onCreateFromTemplate, defaultAppMode }: mode: appMode, }) - // Track app creation success - trackEvent('create_app', { - app_mode: appMode, - description, - }) + trackCreateApp({ appMode: app.mode }) toast.success(t('newApp.appCreated', { ns: 'app' })) onSuccess() diff --git a/web/app/components/app/create-from-dsl-modal/__tests__/index.spec.tsx b/web/app/components/app/create-from-dsl-modal/__tests__/index.spec.tsx index c1ffbc22e8..e106cc7eb3 100644 --- a/web/app/components/app/create-from-dsl-modal/__tests__/index.spec.tsx +++ b/web/app/components/app/create-from-dsl-modal/__tests__/index.spec.tsx @@ -2,12 +2,13 @@ import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' import { NEED_REFRESH_APP_LIST_KEY } from '@/config' import { DSLImportMode, DSLImportStatus } from '@/models/app' +import { AppModeEnum } from '@/types/app' import CreateFromDSLModal, { CreateFromDSLModalTab } from '../index' const mockPush = vi.fn() const mockImportDSL = vi.fn() const mockImportDSLConfirm = vi.fn() -const mockTrackEvent = vi.fn() +const mockTrackCreateApp = vi.fn() const mockHandleCheckPluginDependencies = vi.fn() const mockGetRedirection = vi.fn() const toastMocks = vi.hoisted(() => ({ @@ -43,8 +44,8 @@ vi.mock('@/next/navigation', () => ({ }), })) -vi.mock('@/app/components/base/amplitude', () => ({ - trackEvent: (...args: unknown[]) => mockTrackEvent(...args), +vi.mock('@/utils/create-app-tracking', () => ({ + trackCreateApp: (...args: unknown[]) => mockTrackCreateApp(...args), })) vi.mock('@/service/apps', () => ({ @@ -172,7 +173,7 @@ describe('CreateFromDSLModal', () => { id: 'import-1', status: DSLImportStatus.COMPLETED, app_id: 'app-1', - app_mode: 'chat', + app_mode: AppModeEnum.CHAT, }) render( @@ -196,10 +197,7 @@ describe('CreateFromDSLModal', () => { mode: DSLImportMode.YAML_URL, yaml_url: 'https://example.com/app.yml', }) - expect(mockTrackEvent).toHaveBeenCalledWith('create_app_with_dsl', expect.objectContaining({ - creation_method: 'dsl_url', - has_warnings: false, - })) + expect(mockTrackCreateApp).toHaveBeenCalledWith({ appMode: AppModeEnum.CHAT }) expect(handleSuccess).toHaveBeenCalledTimes(1) expect(handleClose).toHaveBeenCalledTimes(1) expect(localStorage.getItem(NEED_REFRESH_APP_LIST_KEY)).toBe('1') @@ -212,7 +210,7 @@ describe('CreateFromDSLModal', () => { id: 'import-2', status: DSLImportStatus.COMPLETED_WITH_WARNINGS, app_id: 'app-2', - app_mode: 'chat', + app_mode: AppModeEnum.CHAT, }) render( @@ -275,7 +273,7 @@ describe('CreateFromDSLModal', () => { mockImportDSLConfirm.mockResolvedValue({ status: DSLImportStatus.COMPLETED, app_id: 'app-3', - app_mode: 'workflow', + app_mode: AppModeEnum.WORKFLOW, }) render( @@ -305,6 +303,7 @@ describe('CreateFromDSLModal', () => { expect(mockImportDSLConfirm).toHaveBeenCalledWith({ import_id: 'import-3', }) + expect(mockTrackCreateApp).toHaveBeenCalledWith({ appMode: AppModeEnum.WORKFLOW }) }) it('should ignore empty import responses and prevent duplicate submissions while a request is in flight', async () => { @@ -332,7 +331,7 @@ describe('CreateFromDSLModal', () => { id: 'import-in-flight', status: DSLImportStatus.COMPLETED, app_id: 'app-1', - app_mode: 'chat', + app_mode: AppModeEnum.CHAT, }) }) diff --git a/web/app/components/app/create-from-dsl-modal/index.tsx b/web/app/components/app/create-from-dsl-modal/index.tsx index 1fd985c7f8..d588610f71 100644 --- a/web/app/components/app/create-from-dsl-modal/index.tsx +++ b/web/app/components/app/create-from-dsl-modal/index.tsx @@ -6,7 +6,6 @@ import { useDebounceFn, useKeyPress } from 'ahooks' import { noop } from 'es-toolkit/function' import { useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' -import { trackEvent } from '@/app/components/base/amplitude' import Input from '@/app/components/base/input' import Modal from '@/app/components/base/modal' import { Button } from '@/app/components/base/ui/button' @@ -27,6 +26,7 @@ import { } from '@/service/apps' import { getRedirection } from '@/utils/app-redirection' import { cn } from '@/utils/classnames' +import { trackCreateApp } from '@/utils/create-app-tracking' import ShortcutsName from '../../workflow/shortcuts-name' import Uploader from './uploader' @@ -112,12 +112,7 @@ const CreateFromDSLModal = ({ show, onSuccess, onClose, activeTab = CreateFromDS return const { id, status, app_id, app_mode, imported_dsl_version, current_dsl_version } = response if (status === DSLImportStatus.COMPLETED || status === DSLImportStatus.COMPLETED_WITH_WARNINGS) { - // Track app creation from DSL import - trackEvent('create_app_with_dsl', { - app_mode, - creation_method: currentTab === CreateFromDSLModalTab.FROM_FILE ? 'dsl_file' : 'dsl_url', - has_warnings: status === DSLImportStatus.COMPLETED_WITH_WARNINGS, - }) + trackCreateApp({ appMode: app_mode }) if (onSuccess) onSuccess() @@ -179,6 +174,7 @@ const CreateFromDSLModal = ({ show, onSuccess, onClose, activeTab = CreateFromDS const { status, app_id, app_mode } = response if (status === DSLImportStatus.COMPLETED) { + trackCreateApp({ appMode: app_mode }) if (onSuccess) onSuccess() if (onClose) diff --git a/web/app/components/apps/__tests__/index.spec.tsx b/web/app/components/apps/__tests__/index.spec.tsx index da4fbc2d44..2e0d1bcc84 100644 --- a/web/app/components/apps/__tests__/index.spec.tsx +++ b/web/app/components/apps/__tests__/index.spec.tsx @@ -1,12 +1,48 @@ import type { ReactNode } from 'react' +import type { App } from '@/models/explore' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -import { render, screen } from '@testing-library/react' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' import * as React from 'react' +import { useContextSelector } from 'use-context-selector' +import AppListContext from '@/context/app-list-context' +import { fetchAppDetail } from '@/service/explore' +import { AppModeEnum } from '@/types/app' import Apps from '../index' let documentTitleCalls: string[] = [] let educationInitCalls: number = 0 +const mockHandleImportDSL = vi.fn() +const mockHandleImportDSLConfirm = vi.fn() +const mockTrackCreateApp = vi.fn() +const mockFetchAppDetail = vi.mocked(fetchAppDetail) + +const mockTemplateApp: App = { + app_id: 'template-1', + category: 'Assistant', + app: { + id: 'template-1', + mode: AppModeEnum.CHAT, + icon_type: 'emoji', + icon: '🤖', + icon_background: '#fff', + icon_url: '', + name: 'Sample App', + description: 'Sample App', + use_icon_as_answer_icon: false, + }, + description: 'Sample App', + can_trial: true, + copyright: '', + privacy_policy: null, + custom_disclaimer: null, + position: 1, + is_listed: true, + install_count: 0, + installed: false, + editable: false, + is_agent: false, +} vi.mock('@/hooks/use-document-title', () => ({ default: (title: string) => { @@ -22,17 +58,83 @@ vi.mock('@/app/education-apply/hooks', () => ({ vi.mock('@/hooks/use-import-dsl', () => ({ useImportDSL: () => ({ - handleImportDSL: vi.fn(), - handleImportDSLConfirm: vi.fn(), + handleImportDSL: mockHandleImportDSL, + handleImportDSLConfirm: mockHandleImportDSLConfirm, versions: [], isFetching: false, }), })) -vi.mock('../list', () => ({ - default: () => { - return React.createElement('div', { 'data-testid': 'apps-list' }, 'Apps List') - }, +vi.mock('../list', () => { + const MockList = () => { + const setShowTryAppPanel = useContextSelector(AppListContext, ctx => ctx.setShowTryAppPanel) + return React.createElement( + 'div', + { 'data-testid': 'apps-list' }, + React.createElement('span', null, 'Apps List'), + React.createElement( + 'button', + { + 'data-testid': 'open-preview', + 'onClick': () => setShowTryAppPanel(true, { + appId: mockTemplateApp.app_id, + app: mockTemplateApp, + }), + }, + 'Open Preview', + ), + ) + } + + return { default: MockList } +}) + +vi.mock('../../explore/try-app', () => ({ + default: ({ onCreate, onClose }: { onCreate: () => void, onClose: () => void }) => ( +
+ + +
+ ), +})) + +vi.mock('../../explore/create-app-modal', () => ({ + default: ({ show, onConfirm, onHide }: { show: boolean, onConfirm: (payload: Record) => Promise, onHide: () => void }) => show + ? ( +
+ + +
+ ) + : null, +})) + +vi.mock('../../app/create-from-dsl-modal/dsl-confirm-modal', () => ({ + default: ({ onConfirm, onCancel }: { onConfirm: () => void, onCancel: () => void }) => ( +
+ + +
+ ), +})) + +vi.mock('@/service/explore', () => ({ + fetchAppDetail: vi.fn(), +})) + +vi.mock('@/utils/create-app-tracking', () => ({ + trackCreateApp: (...args: unknown[]) => mockTrackCreateApp(...args), })) describe('Apps', () => { @@ -59,6 +161,14 @@ describe('Apps', () => { vi.clearAllMocks() documentTitleCalls = [] educationInitCalls = 0 + mockFetchAppDetail.mockResolvedValue({ + id: 'template-1', + name: 'Sample App', + icon: '🤖', + icon_background: '#fff', + mode: AppModeEnum.CHAT, + export_data: 'yaml-content', + }) }) describe('Rendering', () => { @@ -116,6 +226,82 @@ describe('Apps', () => { ) expect(screen.getByTestId('apps-list')).toBeInTheDocument() }) + + it('should track template preview creation after a successful import', async () => { + mockHandleImportDSL.mockImplementation(async (_payload: unknown, options: { onSuccess?: () => void }) => { + options.onSuccess?.() + }) + + renderWithClient() + + fireEvent.click(screen.getByTestId('open-preview')) + fireEvent.click(await screen.findByTestId('try-app-create')) + fireEvent.click(await screen.findByTestId('confirm-create')) + + await waitFor(() => { + expect(mockFetchAppDetail).toHaveBeenCalledWith('template-1') + expect(mockTrackCreateApp).toHaveBeenCalledWith({ + appMode: AppModeEnum.CHAT, + }) + }) + }) + + it('should track template preview creation after confirming a pending import', async () => { + mockHandleImportDSL.mockImplementation(async (_payload: unknown, options: { onPending?: () => void }) => { + options.onPending?.() + }) + mockHandleImportDSLConfirm.mockImplementation(async (options: { onSuccess?: () => void }) => { + options.onSuccess?.() + }) + + renderWithClient() + + fireEvent.click(screen.getByTestId('open-preview')) + fireEvent.click(await screen.findByTestId('try-app-create')) + fireEvent.click(await screen.findByTestId('confirm-create')) + + fireEvent.click(await screen.findByTestId('confirm-dsl')) + + await waitFor(() => { + expect(mockHandleImportDSLConfirm).toHaveBeenCalledTimes(1) + expect(mockTrackCreateApp).toHaveBeenCalledWith({ + appMode: AppModeEnum.CHAT, + }) + }) + }) + + it('should close the dsl confirm modal when the pending import is canceled', async () => { + mockHandleImportDSL.mockImplementation(async (_payload: unknown, options: { onPending?: () => void }) => { + options.onPending?.() + }) + + renderWithClient() + + fireEvent.click(screen.getByTestId('open-preview')) + fireEvent.click(await screen.findByTestId('try-app-create')) + fireEvent.click(await screen.findByTestId('confirm-create')) + + fireEvent.click(await screen.findByTestId('cancel-dsl')) + + await waitFor(() => { + expect(screen.queryByTestId('dsl-confirm-modal')).not.toBeInTheDocument() + }) + expect(mockTrackCreateApp).not.toHaveBeenCalled() + }) + + it('should hide the create modal without tracking when the modal closes', async () => { + renderWithClient() + + fireEvent.click(screen.getByTestId('open-preview')) + fireEvent.click(await screen.findByTestId('try-app-create')) + + fireEvent.click(await screen.findByTestId('hide-create')) + + await waitFor(() => { + expect(screen.queryByTestId('create-app-modal')).not.toBeInTheDocument() + }) + expect(mockTrackCreateApp).not.toHaveBeenCalled() + }) }) describe('Styling', () => { diff --git a/web/app/components/apps/index.tsx b/web/app/components/apps/index.tsx index b6ca60bd7b..9bf07e81e6 100644 --- a/web/app/components/apps/index.tsx +++ b/web/app/components/apps/index.tsx @@ -1,7 +1,7 @@ 'use client' import type { CreateAppModalProps } from '../explore/create-app-modal' import type { TryAppSelection } from '@/types/try-app' -import { useCallback, useState } from 'react' +import { useCallback, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { useEducationInit } from '@/app/education-apply/hooks' import AppListContext from '@/context/app-list-context' @@ -10,6 +10,7 @@ import { useImportDSL } from '@/hooks/use-import-dsl' import { DSLImportMode } from '@/models/app' import dynamic from '@/next/dynamic' import { fetchAppDetail } from '@/service/explore' +import { trackCreateApp } from '@/utils/create-app-tracking' import List from './list' const DSLConfirmModal = dynamic(() => import('../app/create-from-dsl-modal/dsl-confirm-modal'), { ssr: false }) @@ -23,6 +24,7 @@ const Apps = () => { useEducationInit() const [currentTryAppParams, setCurrentTryAppParams] = useState(undefined) + const currentCreateAppModeRef = useRef(null) const currApp = currentTryAppParams?.app const [isShowTryAppPanel, setIsShowTryAppPanel] = useState(false) const hideTryAppPanel = useCallback(() => { @@ -40,6 +42,12 @@ const Apps = () => { const handleShowFromTryApp = useCallback(() => { setIsShowCreateModal(true) }, []) + const trackCurrentCreateApp = useCallback(() => { + if (!currentCreateAppModeRef.current) + return + + trackCreateApp({ appMode: currentCreateAppModeRef.current }) + }, []) const [controlRefreshList, setControlRefreshList] = useState(0) const [controlHideCreateFromTemplatePanel, setControlHideCreateFromTemplatePanel] = useState(0) @@ -59,11 +67,14 @@ const Apps = () => { const onConfirmDSL = useCallback(async () => { await handleImportDSLConfirm({ - onSuccess, + onSuccess: () => { + trackCurrentCreateApp() + onSuccess() + }, }) - }, [handleImportDSLConfirm, onSuccess]) + }, [handleImportDSLConfirm, onSuccess, trackCurrentCreateApp]) - const onCreate: CreateAppModalProps['onConfirm'] = async ({ + const onCreate: CreateAppModalProps['onConfirm'] = useCallback(async ({ name, icon_type, icon, @@ -72,9 +83,10 @@ const Apps = () => { }) => { hideTryAppPanel() - const { export_data } = await fetchAppDetail( + const { export_data, mode } = await fetchAppDetail( currApp?.app.id as string, ) + currentCreateAppModeRef.current = mode const payload = { mode: DSLImportMode.YAML_CONTENT, yaml_content: export_data, @@ -86,13 +98,14 @@ const Apps = () => { } await handleImportDSL(payload, { onSuccess: () => { + trackCurrentCreateApp() setIsShowCreateModal(false) }, onPending: () => { setShowDSLConfirmModal(true) }, }) - } + }, [currApp?.app.id, handleImportDSL, hideTryAppPanel, trackCurrentCreateApp]) return ( { + const nonce = IS_PROD ? (await headers()).get('x-nonce') ?? '' : '' + /* v8 ignore next -- `nonce` is always a string (`''` or header value), so nullish fallback is unreachable in runtime. @preserve */ + const scriptNonce = nonce ?? undefined + + return ( + + ) +} + +export default memo(CreateAppAttributionBootstrap) diff --git a/web/app/components/explore/app-list/__tests__/index.spec.tsx b/web/app/components/explore/app-list/__tests__/index.spec.tsx index 5d7dffd40a..67312f5ec3 100644 --- a/web/app/components/explore/app-list/__tests__/index.spec.tsx +++ b/web/app/components/explore/app-list/__tests__/index.spec.tsx @@ -15,6 +15,7 @@ let mockIsLoading = false let mockIsError = false const mockHandleImportDSL = vi.fn() const mockHandleImportDSLConfirm = vi.fn() +const mockTrackCreateApp = vi.fn() vi.mock('@/service/use-explore', () => ({ useExploreAppList: () => ({ @@ -45,6 +46,9 @@ vi.mock('@/hooks/use-import-dsl', () => ({ isFetching: false, }), })) +vi.mock('@/utils/create-app-tracking', () => ({ + trackCreateApp: (...args: unknown[]) => mockTrackCreateApp(...args), +})) vi.mock('@/app/components/explore/create-app-modal', () => ({ default: (props: CreateAppModalProps) => { @@ -214,7 +218,7 @@ describe('AppList', () => { categories: ['Writing'], allList: [createApp()], }; - (fetchAppDetail as unknown as Mock).mockResolvedValue({ export_data: 'yaml-content' }) + (fetchAppDetail as unknown as Mock).mockResolvedValue({ export_data: 'yaml-content', mode: AppModeEnum.CHAT }) mockHandleImportDSL.mockImplementation(async (_payload: unknown, options: { onSuccess?: () => void, onPending?: () => void }) => { options.onPending?.() }) @@ -235,6 +239,9 @@ describe('AppList', () => { fireEvent.click(screen.getByTestId('dsl-confirm')) await waitFor(() => { expect(mockHandleImportDSLConfirm).toHaveBeenCalledTimes(1) + expect(mockTrackCreateApp).toHaveBeenCalledWith({ + appMode: AppModeEnum.CHAT, + }) expect(onSuccess).toHaveBeenCalledTimes(1) }) }) @@ -307,7 +314,7 @@ describe('AppList', () => { categories: ['Writing'], allList: [createApp()], }; - (fetchAppDetail as unknown as Mock).mockResolvedValue({ export_data: 'yaml' }) + (fetchAppDetail as unknown as Mock).mockResolvedValue({ export_data: 'yaml', mode: AppModeEnum.CHAT }) renderAppList(true) fireEvent.click(screen.getByText('explore.appCard.addToWorkspace')) @@ -317,6 +324,7 @@ describe('AppList', () => { await waitFor(() => { expect(screen.queryByTestId('create-app-modal')).not.toBeInTheDocument() }) + expect(mockTrackCreateApp).not.toHaveBeenCalled() }) it('should close create modal on successful DSL import', async () => { @@ -325,7 +333,7 @@ describe('AppList', () => { categories: ['Writing'], allList: [createApp()], }; - (fetchAppDetail as unknown as Mock).mockResolvedValue({ export_data: 'yaml' }) + (fetchAppDetail as unknown as Mock).mockResolvedValue({ export_data: 'yaml', mode: AppModeEnum.CHAT }) mockHandleImportDSL.mockImplementation(async (_payload: unknown, options: { onSuccess?: () => void }) => { options.onSuccess?.() }) @@ -345,7 +353,7 @@ describe('AppList', () => { categories: ['Writing'], allList: [createApp()], }; - (fetchAppDetail as unknown as Mock).mockResolvedValue({ export_data: 'yaml' }) + (fetchAppDetail as unknown as Mock).mockResolvedValue({ export_data: 'yaml', mode: AppModeEnum.CHAT }) mockHandleImportDSL.mockImplementation(async (_payload: unknown, options: { onPending?: () => void }) => { options.onPending?.() }) @@ -385,6 +393,30 @@ describe('AppList', () => { }) }) + it('should track preview source when creation starts from try app details', async () => { + vi.useRealTimers() + mockExploreData = { + categories: ['Writing'], + allList: [createApp()], + }; + (fetchAppDetail as unknown as Mock).mockResolvedValue({ export_data: 'yaml', mode: AppModeEnum.CHAT }) + mockHandleImportDSL.mockImplementation(async (_payload: unknown, options: { onSuccess?: () => void }) => { + options.onSuccess?.() + }) + + renderAppList(true) + + fireEvent.click(screen.getByText('explore.appCard.try')) + fireEvent.click(screen.getByTestId('try-app-create')) + fireEvent.click(await screen.findByTestId('confirm-create')) + + await waitFor(() => { + expect(mockTrackCreateApp).toHaveBeenCalledWith({ + appMode: AppModeEnum.CHAT, + }) + }) + }) + it('should close try app panel when close is clicked', () => { mockExploreData = { categories: ['Writing'], diff --git a/web/app/components/explore/app-list/index.tsx b/web/app/components/explore/app-list/index.tsx index 1261c0949c..f52fa44c4f 100644 --- a/web/app/components/explore/app-list/index.tsx +++ b/web/app/components/explore/app-list/index.tsx @@ -6,7 +6,7 @@ import type { TryAppSelection } from '@/types/try-app' import { useDebounceFn } from 'ahooks' import { useQueryState } from 'nuqs' import * as React from 'react' -import { useCallback, useMemo, useState } from 'react' +import { useCallback, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import DSLConfirmModal from '@/app/components/app/create-from-dsl-modal/dsl-confirm-modal' import Input from '@/app/components/base/input' @@ -26,6 +26,7 @@ import { fetchAppDetail } from '@/service/explore' import { useMembers } from '@/service/use-common' import { useExploreAppList } from '@/service/use-explore' import { cn } from '@/utils/classnames' +import { trackCreateApp } from '@/utils/create-app-tracking' import TryApp from '../try-app' import s from './style.module.css' @@ -101,6 +102,7 @@ const Apps = ({ const [showDSLConfirmModal, setShowDSLConfirmModal] = useState(false) const [currentTryApp, setCurrentTryApp] = useState(undefined) + const currentCreateAppModeRef = useRef(null) const isShowTryAppPanel = !!currentTryApp const hideTryAppPanel = useCallback(() => { setCurrentTryApp(undefined) @@ -112,8 +114,14 @@ const Apps = ({ setCurrApp(currentTryApp?.app || null) setIsShowCreateModal(true) }, [currentTryApp?.app]) + const trackCurrentCreateApp = useCallback(() => { + if (!currentCreateAppModeRef.current) + return - const onCreate: CreateAppModalProps['onConfirm'] = async ({ + trackCreateApp({ appMode: currentCreateAppModeRef.current }) + }, []) + + const onCreate: CreateAppModalProps['onConfirm'] = useCallback(async ({ name, icon_type, icon, @@ -122,9 +130,10 @@ const Apps = ({ }) => { hideTryAppPanel() - const { export_data } = await fetchAppDetail( + const { export_data, mode } = await fetchAppDetail( currApp?.app.id as string, ) + currentCreateAppModeRef.current = mode const payload = { mode: DSLImportMode.YAML_CONTENT, yaml_content: export_data, @@ -136,19 +145,23 @@ const Apps = ({ } await handleImportDSL(payload, { onSuccess: () => { + trackCurrentCreateApp() setIsShowCreateModal(false) }, onPending: () => { setShowDSLConfirmModal(true) }, }) - } + }, [currApp?.app.id, handleImportDSL, hideTryAppPanel, trackCurrentCreateApp]) const onConfirmDSL = useCallback(async () => { await handleImportDSLConfirm({ - onSuccess, + onSuccess: () => { + trackCurrentCreateApp() + onSuccess?.() + }, }) - }, [handleImportDSLConfirm, onSuccess]) + }, [handleImportDSLConfirm, onSuccess, trackCurrentCreateApp]) if (isLoading) { return ( diff --git a/web/app/layout.tsx b/web/app/layout.tsx index 03107f5d15..63e506bd45 100644 --- a/web/app/layout.tsx +++ b/web/app/layout.tsx @@ -9,6 +9,7 @@ import { getLocaleOnServer } from '@/i18n-config/server' import { ToastHost } from './components/base/ui/toast' import { TooltipProvider } from './components/base/ui/tooltip' import PartnerStackCookieRecorder from './components/billing/partner-stack/cookie-recorder' +import CreateAppAttributionBootstrap from './components/create-app-attribution-bootstrap' import { AgentationLoader } from './components/devtools/agentation-loader' import { ReactScanLoader } from './components/devtools/react-scan/loader' import { I18nServerProvider } from './components/provider/i18n-server' @@ -47,6 +48,7 @@ const LocaleLayout = async ({ + {/* */} diff --git a/web/app/signup/set-password/page.tsx b/web/app/signup/set-password/page.tsx index 72a25d6ac2..4a662e0623 100644 --- a/web/app/signup/set-password/page.tsx +++ b/web/app/signup/set-password/page.tsx @@ -11,6 +11,7 @@ import { validPassword } from '@/config' import { useRouter, useSearchParams } from '@/next/navigation' import { useMailRegister } from '@/service/use-common' import { cn } from '@/utils/classnames' +import { rememberCreateAppExternalAttribution } from '@/utils/create-app-tracking' import { sendGAEvent } from '@/utils/gtag' const parseUtmInfo = () => { @@ -68,6 +69,7 @@ const ChangePasswordForm = () => { const { result } = res as MailRegisterResponse if (result === 'success') { const utmInfo = parseUtmInfo() + rememberCreateAppExternalAttribution({ utmInfo }) trackEvent(utmInfo ? 'user_registration_success_with_utm' : 'user_registration_success', { method: 'email', ...utmInfo, diff --git a/web/utils/__tests__/create-app-tracking.spec.ts b/web/utils/__tests__/create-app-tracking.spec.ts new file mode 100644 index 0000000000..b732d8d3b8 --- /dev/null +++ b/web/utils/__tests__/create-app-tracking.spec.ts @@ -0,0 +1,189 @@ +import Cookies from 'js-cookie' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import * as amplitude from '@/app/components/base/amplitude' +import { AppModeEnum } from '@/types/app' +import { + buildCreateAppEventPayload, + extractExternalCreateAppAttribution, + rememberCreateAppExternalAttribution, + trackCreateApp, +} from '../create-app-tracking' + +describe('create-app-tracking', () => { + beforeEach(() => { + vi.restoreAllMocks() + vi.spyOn(amplitude, 'trackEvent').mockImplementation(() => {}) + window.sessionStorage.clear() + window.history.replaceState({}, '', '/apps') + }) + + describe('extractExternalCreateAppAttribution', () => { + it('should map campaign links to external attribution', () => { + const attribution = extractExternalCreateAppAttribution({ + searchParams: new URLSearchParams('utm_source=x&slug=how-to-build-rag-agent'), + }) + + expect(attribution).toEqual({ + utmSource: 'twitter/x', + utmCampaign: 'how-to-build-rag-agent', + }) + }) + + it('should map newsletter and blog sources to blog', () => { + expect(extractExternalCreateAppAttribution({ + searchParams: new URLSearchParams('utm_source=newsletter'), + })).toEqual({ utmSource: 'blog' }) + + expect(extractExternalCreateAppAttribution({ + utmInfo: { utm_source: 'dify_blog', slug: 'launch-week' }, + })).toEqual({ + utmSource: 'blog', + utmCampaign: 'launch-week', + }) + }) + }) + + describe('rememberCreateAppExternalAttribution', () => { + it('should ignore malformed utm cookies', () => { + vi.spyOn(Cookies, 'get').mockImplementation(((key?: string) => { + return key ? 'not-json' : {} + }) as typeof Cookies.get) + + expect(rememberCreateAppExternalAttribution()).toBeNull() + expect(window.sessionStorage.getItem('create_app_external_attribution')).toBeNull() + }) + }) + + describe('buildCreateAppEventPayload', () => { + it('should build original payloads with normalized app mode and timestamp', () => { + expect(buildCreateAppEventPayload({ + appMode: AppModeEnum.ADVANCED_CHAT, + }, null, new Date(2026, 3, 13, 14, 5, 9))).toEqual({ + source: 'original', + app_mode: 'chatflow', + time: '04-13-14:05:09', + }) + }) + + it('should map agent mode into the canonical app mode bucket', () => { + expect(buildCreateAppEventPayload({ + appMode: AppModeEnum.AGENT_CHAT, + }, null, new Date(2026, 3, 13, 9, 8, 7))).toEqual({ + source: 'original', + app_mode: 'agent', + time: '04-13-09:08:07', + }) + }) + + it('should fold legacy non-agent modes into chatflow', () => { + expect(buildCreateAppEventPayload({ + appMode: AppModeEnum.CHAT, + }, null, new Date(2026, 3, 13, 8, 0, 1))).toEqual({ + source: 'original', + app_mode: 'chatflow', + time: '04-13-08:00:01', + }) + + expect(buildCreateAppEventPayload({ + appMode: AppModeEnum.COMPLETION, + }, null, new Date(2026, 3, 13, 8, 0, 2))).toEqual({ + source: 'original', + app_mode: 'chatflow', + time: '04-13-08:00:02', + }) + }) + + it('should map workflow mode into the workflow bucket', () => { + expect(buildCreateAppEventPayload({ + appMode: AppModeEnum.WORKFLOW, + }, null, new Date(2026, 3, 13, 7, 6, 5))).toEqual({ + source: 'original', + app_mode: 'workflow', + time: '04-13-07:06:05', + }) + }) + + it('should prefer external attribution when present', () => { + expect(buildCreateAppEventPayload( + { + appMode: AppModeEnum.WORKFLOW, + }, + { + utmSource: 'linkedin', + utmCampaign: 'agent-launch', + }, + )).toEqual({ + source: 'external', + utm_source: 'linkedin', + utm_campaign: 'agent-launch', + }) + }) + }) + + describe('trackCreateApp', () => { + it('should track remembered external attribution once before falling back to internal source', () => { + rememberCreateAppExternalAttribution({ + searchParams: new URLSearchParams('utm_source=newsletter&slug=how-to-build-rag-agent'), + }) + + trackCreateApp({ appMode: AppModeEnum.WORKFLOW }) + + expect(amplitude.trackEvent).toHaveBeenNthCalledWith(1, 'create_app', { + source: 'external', + utm_source: 'blog', + utm_campaign: 'how-to-build-rag-agent', + }) + + trackCreateApp({ appMode: AppModeEnum.WORKFLOW }) + + expect(amplitude.trackEvent).toHaveBeenNthCalledWith(2, 'create_app', { + source: 'original', + app_mode: 'workflow', + time: expect.stringMatching(/^\d{2}-\d{2}-\d{2}:\d{2}:\d{2}$/), + }) + }) + + it('should keep using remembered external attribution after navigating away from the original url', () => { + window.history.replaceState({}, '', '/apps?utm_source=linkedin&slug=agent-launch') + + rememberCreateAppExternalAttribution({ + searchParams: new URLSearchParams(window.location.search), + }) + + window.history.replaceState({}, '', '/explore') + + trackCreateApp({ appMode: AppModeEnum.CHAT }) + + expect(amplitude.trackEvent).toHaveBeenCalledWith('create_app', { + source: 'external', + utm_source: 'linkedin', + utm_campaign: 'agent-launch', + }) + }) + + it('should fall back to the original payload when window is unavailable', () => { + const originalWindow = globalThis.window + + try { + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: undefined, + }) + + trackCreateApp({ appMode: AppModeEnum.AGENT_CHAT }) + + expect(amplitude.trackEvent).toHaveBeenCalledWith('create_app', { + source: 'original', + app_mode: 'agent', + time: expect.stringMatching(/^\d{2}-\d{2}-\d{2}:\d{2}:\d{2}$/), + }) + } + finally { + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: originalWindow, + }) + } + }) + }) +}) diff --git a/web/utils/create-app-tracking.ts b/web/utils/create-app-tracking.ts new file mode 100644 index 0000000000..f56e2c13fa --- /dev/null +++ b/web/utils/create-app-tracking.ts @@ -0,0 +1,240 @@ +import Cookies from 'js-cookie' +import { trackEvent } from '@/app/components/base/amplitude' +import { AppModeEnum } from '@/types/app' + +const CREATE_APP_EXTERNAL_ATTRIBUTION_STORAGE_KEY = 'create_app_external_attribution' +const CREATE_APP_EXTERNAL_ATTRIBUTION_QUERY_KEYS = ['utm_source', 'utm_campaign', 'slug'] as const + +const EXTERNAL_UTM_SOURCE_MAP = { + blog: 'blog', + dify_blog: 'blog', + linkedin: 'linkedin', + newsletter: 'blog', + twitter: 'twitter/x', + x: 'twitter/x', +} as const + +type SearchParamReader = { + get: (name: string) => string | null +} + +type OriginalCreateAppMode = 'workflow' | 'chatflow' | 'agent' + +type TrackCreateAppParams = { + appMode: AppModeEnum +} + +type ExternalCreateAppAttribution = { + utmSource: typeof EXTERNAL_UTM_SOURCE_MAP[keyof typeof EXTERNAL_UTM_SOURCE_MAP] + utmCampaign?: string +} + +const serializeBootstrapValue = (value: unknown) => { + return JSON.stringify(value).replace(/ { + const trimmed = value?.trim() + return trimmed || undefined +} + +const getObjectStringValue = (value: unknown) => { + return typeof value === 'string' ? normalizeString(value) : undefined +} + +const getSearchParamValue = (searchParams?: SearchParamReader | null, key?: string) => { + if (!searchParams || !key) + return undefined + return normalizeString(searchParams.get(key)) +} + +const parseJSONRecord = (value?: string | null): Record | null => { + if (!value) + return null + + try { + const parsed = JSON.parse(value) + return parsed && typeof parsed === 'object' ? parsed as Record : null + } + catch { + return null + } +} + +const getCookieUtmInfo = () => { + return parseJSONRecord(Cookies.get('utm_info')) +} + +const mapExternalUtmSource = (value?: string) => { + if (!value) + return undefined + + const normalized = value.toLowerCase() + return EXTERNAL_UTM_SOURCE_MAP[normalized as keyof typeof EXTERNAL_UTM_SOURCE_MAP] +} + +const padTimeValue = (value: number) => String(value).padStart(2, '0') + +const formatCreateAppTime = (date: Date) => { + return `${padTimeValue(date.getMonth() + 1)}-${padTimeValue(date.getDate())}-${padTimeValue(date.getHours())}:${padTimeValue(date.getMinutes())}:${padTimeValue(date.getSeconds())}` +} + +const mapOriginalCreateAppMode = (appMode: AppModeEnum): OriginalCreateAppMode => { + if (appMode === AppModeEnum.WORKFLOW) + return 'workflow' + + if (appMode === AppModeEnum.AGENT_CHAT) + return 'agent' + + return 'chatflow' +} + +export const runCreateAppAttributionBootstrap = ( + sourceMap = EXTERNAL_UTM_SOURCE_MAP, + storageKey = CREATE_APP_EXTERNAL_ATTRIBUTION_STORAGE_KEY, + queryKeys = CREATE_APP_EXTERNAL_ATTRIBUTION_QUERY_KEYS, +) => { + try { + if (typeof window === 'undefined' || !window.sessionStorage) + return + + const searchParams = new URLSearchParams(window.location.search) + const rawSource = searchParams.get('utm_source') + + if (!rawSource) + return + + const normalizedSource = rawSource.trim().toLowerCase() + const mappedSource = sourceMap[normalizedSource as keyof typeof sourceMap] + + if (!mappedSource) + return + + const normalizedSlug = searchParams.get('slug')?.trim() + const normalizedCampaign = searchParams.get('utm_campaign')?.trim() + const utmCampaign = normalizedSlug || normalizedCampaign + const attribution = utmCampaign + ? { utmSource: mappedSource, utmCampaign } + : { utmSource: mappedSource } + + window.sessionStorage.setItem(storageKey, JSON.stringify(attribution)) + + const nextSearchParams = new URLSearchParams(window.location.search) + let hasChanges = false + + queryKeys.forEach((key) => { + if (!nextSearchParams.has(key)) + return + + nextSearchParams.delete(key) + hasChanges = true + }) + + if (!hasChanges) + return + + const nextSearch = nextSearchParams.toString() + const nextUrl = `${window.location.pathname}${nextSearch ? `?${nextSearch}` : ''}${window.location.hash}` + + try { + window.history.replaceState(window.history.state, '', nextUrl) + } + catch {} + } + catch {} +} + +export const buildCreateAppAttributionBootstrapScript = () => { + return `(${runCreateAppAttributionBootstrap.toString()})(${serializeBootstrapValue(EXTERNAL_UTM_SOURCE_MAP)}, ${serializeBootstrapValue(CREATE_APP_EXTERNAL_ATTRIBUTION_STORAGE_KEY)}, ${serializeBootstrapValue(CREATE_APP_EXTERNAL_ATTRIBUTION_QUERY_KEYS)});` +} + +export const extractExternalCreateAppAttribution = ({ + searchParams, + utmInfo, +}: { + searchParams?: SearchParamReader | null + utmInfo?: Record | null +}) => { + const rawSource = getSearchParamValue(searchParams, 'utm_source') ?? getObjectStringValue(utmInfo?.utm_source) + const mappedSource = mapExternalUtmSource(rawSource) + + if (!mappedSource) + return null + + const utmCampaign = getSearchParamValue(searchParams, 'slug') + ?? getSearchParamValue(searchParams, 'utm_campaign') + ?? getObjectStringValue(utmInfo?.slug) + ?? getObjectStringValue(utmInfo?.utm_campaign) + + return { + utmSource: mappedSource, + ...(utmCampaign ? { utmCampaign } : {}), + } satisfies ExternalCreateAppAttribution +} + +const readRememberedExternalCreateAppAttribution = (): ExternalCreateAppAttribution | null => { + return parseJSONRecord(window.sessionStorage.getItem(CREATE_APP_EXTERNAL_ATTRIBUTION_STORAGE_KEY)) as ExternalCreateAppAttribution | null +} + +const writeRememberedExternalCreateAppAttribution = (attribution: ExternalCreateAppAttribution) => { + window.sessionStorage.setItem(CREATE_APP_EXTERNAL_ATTRIBUTION_STORAGE_KEY, JSON.stringify(attribution)) +} + +const clearRememberedExternalCreateAppAttribution = () => { + window.sessionStorage.removeItem(CREATE_APP_EXTERNAL_ATTRIBUTION_STORAGE_KEY) +} + +export const rememberCreateAppExternalAttribution = ({ + searchParams, + utmInfo, +}: { + searchParams?: SearchParamReader | null + utmInfo?: Record | null +} = {}) => { + const attribution = extractExternalCreateAppAttribution({ + searchParams, + utmInfo: utmInfo ?? getCookieUtmInfo(), + }) + + if (attribution && typeof window !== 'undefined') + writeRememberedExternalCreateAppAttribution(attribution) + + return attribution +} + +const resolveCurrentExternalCreateAppAttribution = () => { + if (typeof window === 'undefined') + return null + + return readRememberedExternalCreateAppAttribution() +} + +export const buildCreateAppEventPayload = ( + params: TrackCreateAppParams, + externalAttribution?: ExternalCreateAppAttribution | null, + currentTime = new Date(), +) => { + if (externalAttribution) { + return { + source: 'external', + utm_source: externalAttribution.utmSource, + ...(externalAttribution.utmCampaign ? { utm_campaign: externalAttribution.utmCampaign } : {}), + } satisfies Record + } + + return { + source: 'original', + app_mode: mapOriginalCreateAppMode(params.appMode), + time: formatCreateAppTime(currentTime), + } satisfies Record +} + +export const trackCreateApp = (params: TrackCreateAppParams) => { + const externalAttribution = resolveCurrentExternalCreateAppAttribution() + const payload = buildCreateAppEventPayload(params, externalAttribution) + + if (externalAttribution) + clearRememberedExternalCreateAppAttribution() + + trackEvent('create_app', payload) +} From e3c2116501028ff0ce5c83217fdb59831744163d Mon Sep 17 00:00:00 2001 From: zyssyz123 <916125788@qq.com> Date: Wed, 15 Apr 2026 17:18:29 +0800 Subject: [PATCH 07/61] fix: remove enable for get (#35245) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: Joel --- api/controllers/console/explore/trial.py | 8 ++---- .../controllers/console/explore/test_trial.py | 28 +++++++++---------- web/service/try-app.spec.ts | 26 +++++++++++++++++ web/service/try-app.ts | 10 ++++--- 4 files changed, 48 insertions(+), 24 deletions(-) create mode 100644 web/service/try-app.spec.ts diff --git a/api/controllers/console/explore/trial.py b/api/controllers/console/explore/trial.py index e432574434..0a3595454a 100644 --- a/api/controllers/console/explore/trial.py +++ b/api/controllers/console/explore/trial.py @@ -169,6 +169,7 @@ console_ns.schema_model( class TrialAppWorkflowRunApi(TrialAppResource): + @trial_feature_enable @console_ns.expect(console_ns.models[WorkflowRunRequest.__name__]) def post(self, trial_app): """ @@ -210,6 +211,7 @@ class TrialAppWorkflowRunApi(TrialAppResource): class TrialAppWorkflowTaskStopApi(TrialAppResource): + @trial_feature_enable def post(self, trial_app, task_id: str): """ Stop workflow task @@ -290,7 +292,6 @@ class TrialChatApi(TrialAppResource): class TrialMessageSuggestedQuestionApi(TrialAppResource): - @trial_feature_enable def get(self, trial_app, message_id): app_model = trial_app app_mode = AppMode.value_of(app_model.mode) @@ -470,7 +471,6 @@ class TrialCompletionApi(TrialAppResource): class TrialSitApi(Resource): """Resource for trial app sites.""" - @trial_feature_enable @get_app_model_with_trial(None) def get(self, app_model): """Retrieve app site info. @@ -492,7 +492,6 @@ class TrialSitApi(Resource): class TrialAppParameterApi(Resource): """Resource for app variables.""" - @trial_feature_enable @get_app_model_with_trial(None) def get(self, app_model): """Retrieve app parameters.""" @@ -521,7 +520,6 @@ class TrialAppParameterApi(Resource): class AppApi(Resource): - @trial_feature_enable @get_app_model_with_trial(None) @marshal_with(app_detail_with_site_model) def get(self, app_model): @@ -534,7 +532,6 @@ class AppApi(Resource): class AppWorkflowApi(Resource): - @trial_feature_enable @get_app_model_with_trial(None) @marshal_with(workflow_model) def get(self, app_model): @@ -547,7 +544,6 @@ class AppWorkflowApi(Resource): class DatasetListApi(Resource): - @trial_feature_enable @get_app_model_with_trial(None) def get(self, app_model): page = request.args.get("page", default=1, type=int) diff --git a/api/tests/unit_tests/controllers/console/explore/test_trial.py b/api/tests/unit_tests/controllers/console/explore/test_trial.py index 04beb31389..a43c3ca47e 100644 --- a/api/tests/unit_tests/controllers/console/explore/test_trial.py +++ b/api/tests/unit_tests/controllers/console/explore/test_trial.py @@ -94,7 +94,7 @@ class TestTrialAppWorkflowRunApi: with app.test_request_context("/"): with pytest.raises(NotWorkflowAppError): - method(MagicMock(mode=AppMode.CHAT)) + method(api, MagicMock(mode=AppMode.CHAT)) def test_success(self, app, trial_app_workflow, account): api = module.TrialAppWorkflowRunApi() @@ -106,7 +106,7 @@ class TestTrialAppWorkflowRunApi: patch.object(module.AppGenerateService, "generate", return_value=MagicMock()), patch.object(module.RecommendedAppService, "add_trial_app_record"), ): - result = method(trial_app_workflow) + result = method(api, trial_app_workflow) assert result is not None @@ -124,7 +124,7 @@ class TestTrialAppWorkflowRunApi: ), ): with pytest.raises(ProviderNotInitializeError): - method(trial_app_workflow) + method(api, trial_app_workflow) def test_workflow_quota_exceeded(self, app, trial_app_workflow, account): api = module.TrialAppWorkflowRunApi() @@ -140,7 +140,7 @@ class TestTrialAppWorkflowRunApi: ), ): with pytest.raises(ProviderQuotaExceededError): - method(trial_app_workflow) + method(api, trial_app_workflow) def test_workflow_model_not_support(self, app, trial_app_workflow, account): api = module.TrialAppWorkflowRunApi() @@ -156,7 +156,7 @@ class TestTrialAppWorkflowRunApi: ), ): with pytest.raises(ProviderModelCurrentlyNotSupportError): - method(trial_app_workflow) + method(api, trial_app_workflow) def test_workflow_invoke_error(self, app, trial_app_workflow, account): api = module.TrialAppWorkflowRunApi() @@ -172,7 +172,7 @@ class TestTrialAppWorkflowRunApi: ), ): with pytest.raises(CompletionRequestError): - method(trial_app_workflow) + method(api, trial_app_workflow) def test_workflow_rate_limit_error(self, app, trial_app_workflow, account): api = module.TrialAppWorkflowRunApi() @@ -188,7 +188,7 @@ class TestTrialAppWorkflowRunApi: ), ): with pytest.raises(InvokeRateLimitHttpError): - method(trial_app_workflow) + method(api, trial_app_workflow) def test_workflow_value_error(self, app, trial_app_workflow, account): api = module.TrialAppWorkflowRunApi() @@ -204,7 +204,7 @@ class TestTrialAppWorkflowRunApi: ), ): with pytest.raises(ValueError): - method(trial_app_workflow) + method(api, trial_app_workflow) def test_workflow_generic_exception(self, app, trial_app_workflow, account): api = module.TrialAppWorkflowRunApi() @@ -220,7 +220,7 @@ class TestTrialAppWorkflowRunApi: ), ): with pytest.raises(InternalServerError): - method(trial_app_workflow) + method(api, trial_app_workflow) class TestTrialChatApi: @@ -566,7 +566,7 @@ class TestTrialMessageSuggestedQuestionApi: with app.test_request_context("/"): with pytest.raises(NotChatAppError): - method(api, MagicMock(mode="completion"), str(uuid4())) + method(MagicMock(mode="completion"), str(uuid4())) def test_success(self, app, trial_app_chat, account): api = module.TrialMessageSuggestedQuestionApi() @@ -581,7 +581,7 @@ class TestTrialMessageSuggestedQuestionApi: return_value=["q1", "q2"], ), ): - result = method(api, trial_app_chat, str(uuid4())) + result = method(trial_app_chat, str(uuid4())) assert result == {"data": ["q1", "q2"]} @@ -599,7 +599,7 @@ class TestTrialMessageSuggestedQuestionApi: ), ): with pytest.raises(NotFound): - method(api, trial_app_chat, str(uuid4())) + method(trial_app_chat, str(uuid4())) class TestTrialAppParameterApi: @@ -931,7 +931,7 @@ class TestTrialAppWorkflowTaskStopApi: with app.test_request_context("/"): with pytest.raises(NotWorkflowAppError): - method(trial_app_chat, str(uuid4())) + method(api, trial_app_chat, str(uuid4())) def test_success(self, app, trial_app_workflow, account): api = module.TrialAppWorkflowTaskStopApi() @@ -944,7 +944,7 @@ class TestTrialAppWorkflowTaskStopApi: patch.object(module.AppQueueManager, "set_stop_flag_no_user_check") as mock_set_flag, patch.object(module.GraphEngineManager, "send_stop_command") as mock_send_cmd, ): - result = method(trial_app_workflow, task_id) + result = method(api, trial_app_workflow, task_id) assert result == {"result": "success"} mock_set_flag.assert_called_once_with(task_id) diff --git a/web/service/try-app.spec.ts b/web/service/try-app.spec.ts new file mode 100644 index 0000000000..9e9cd8d04a --- /dev/null +++ b/web/service/try-app.spec.ts @@ -0,0 +1,26 @@ +import { get } from './base' +import { fetchTryAppDatasets } from './try-app' + +vi.mock('./base', () => ({ + get: vi.fn(), +})) + +vi.mock('@/service/client', () => ({ + consoleClient: { + trialApps: { + info: vi.fn(), + workflows: vi.fn(), + parameters: vi.fn(), + }, + }, +})) + +describe('fetchTryAppDatasets', () => { + it('serializes ids as repeated query params', async () => { + vi.mocked(get).mockResolvedValue({ data: [] }) + + await fetchTryAppDatasets('app-1', ['id-1', 'id-2']) + + expect(get).toHaveBeenCalledWith('/trial-apps/app-1/datasets?ids=id-1&ids=id-2') + }) +}) diff --git a/web/service/try-app.ts b/web/service/try-app.ts index 11aa638537..7b81e7c4fa 100644 --- a/web/service/try-app.ts +++ b/web/service/try-app.ts @@ -1,17 +1,19 @@ import type { ChatConfig } from '@/app/components/base/chat/types' import type { DataSetListResponse } from '@/models/datasets' import type { TryAppFlowPreview, TryAppInfo } from '@/models/try-app' +import qs from 'qs' import { consoleClient } from '@/service/client' +import { get } from './base' export const fetchTryAppInfo = (appId: string): Promise => { return consoleClient.trialApps.info({ params: { appId } }) } export const fetchTryAppDatasets = (appId: string, ids: string[]): Promise => { - return consoleClient.trialApps.datasets({ - params: { appId }, - query: { ids }, - }) + const queryString = qs.stringify({ ids }, { indices: false }) + const url = `/trial-apps/${encodeURIComponent(appId)}/datasets${queryString ? `?${queryString}` : ''}` + + return get(url) } export const fetchTryAppFlowPreview = (appId: string): Promise => { From fd71c56f16e12f6b903c0a8d0b25032d23104545 Mon Sep 17 00:00:00 2001 From: James <63717587+jamesrayammons@users.noreply.github.com> Date: Wed, 15 Apr 2026 11:52:31 +0200 Subject: [PATCH 08/61] test: remove legacy storage key loader integration test (#35225) --- .../factories/test_storage_key_loader.py | 375 ------------------ 1 file changed, 375 deletions(-) delete mode 100644 api/tests/integration_tests/factories/test_storage_key_loader.py diff --git a/api/tests/integration_tests/factories/test_storage_key_loader.py b/api/tests/integration_tests/factories/test_storage_key_loader.py deleted file mode 100644 index c1bb8e1245..0000000000 --- a/api/tests/integration_tests/factories/test_storage_key_loader.py +++ /dev/null @@ -1,375 +0,0 @@ -import unittest -from datetime import UTC, datetime -from unittest.mock import patch -from uuid import uuid4 - -import pytest -from graphon.file import File, FileTransferMethod, FileType -from sqlalchemy.orm import Session - -from core.app.file_access import DatabaseFileAccessController -from extensions.ext_database import db -from extensions.storage.storage_type import StorageType -from factories.file_factory import StorageKeyLoader -from models import ToolFile, UploadFile -from models.enums import CreatorUserRole - - -@pytest.mark.usefixtures("flask_req_ctx") -class TestStorageKeyLoader(unittest.TestCase): - """ - Integration tests for StorageKeyLoader class. - - Tests the batched loading of storage keys from the database for files - with different transfer methods: LOCAL_FILE, REMOTE_URL, and TOOL_FILE. - """ - - def setUp(self): - """Set up test data before each test method.""" - self.session = db.session() - self.tenant_id = str(uuid4()) - self.user_id = str(uuid4()) - self.conversation_id = str(uuid4()) - - # Create test data that will be cleaned up after each test - self.test_upload_files = [] - self.test_tool_files = [] - - # Create StorageKeyLoader instance - self.loader = StorageKeyLoader( - self.session, - self.tenant_id, - access_controller=DatabaseFileAccessController(), - ) - - def tearDown(self): - """Clean up test data after each test method.""" - self.session.rollback() - - def _create_upload_file( - self, file_id: str | None = None, storage_key: str | None = None, tenant_id: str | None = None - ) -> UploadFile: - """Helper method to create an UploadFile record for testing.""" - if file_id is None: - file_id = str(uuid4()) - if storage_key is None: - storage_key = f"test_storage_key_{uuid4()}" - if tenant_id is None: - tenant_id = self.tenant_id - - upload_file = UploadFile( - tenant_id=tenant_id, - storage_type=StorageType.LOCAL, - key=storage_key, - name="test_file.txt", - size=1024, - extension=".txt", - mime_type="text/plain", - created_by_role=CreatorUserRole.ACCOUNT, - created_by=self.user_id, - created_at=datetime.now(UTC), - used=False, - ) - upload_file.id = file_id - - self.session.add(upload_file) - self.session.flush() - self.test_upload_files.append(upload_file) - - return upload_file - - def _create_tool_file( - self, file_id: str | None = None, file_key: str | None = None, tenant_id: str | None = None - ) -> ToolFile: - """Helper method to create a ToolFile record for testing.""" - if file_id is None: - file_id = str(uuid4()) - if file_key is None: - file_key = f"test_file_key_{uuid4()}" - if tenant_id is None: - tenant_id = self.tenant_id - - tool_file = ToolFile( - user_id=self.user_id, - tenant_id=tenant_id, - conversation_id=self.conversation_id, - file_key=file_key, - mimetype="text/plain", - original_url="http://example.com/file.txt", - name="test_tool_file.txt", - size=2048, - ) - tool_file.id = file_id - self.session.add(tool_file) - self.session.flush() - self.test_tool_files.append(tool_file) - - return tool_file - - def _create_file(self, related_id: str, transfer_method: FileTransferMethod, tenant_id: str | None = None) -> File: - """Helper method to create a File object for testing.""" - if tenant_id is None: - tenant_id = self.tenant_id - - # Set related_id for LOCAL_FILE and TOOL_FILE transfer methods - file_related_id = None - remote_url = None - - if transfer_method in (FileTransferMethod.LOCAL_FILE, FileTransferMethod.TOOL_FILE): - file_related_id = related_id - elif transfer_method == FileTransferMethod.REMOTE_URL: - remote_url = "https://example.com/test_file.txt" - file_related_id = related_id - - return File( - id=str(uuid4()), # Generate new UUID for File.id - tenant_id=tenant_id, - type=FileType.DOCUMENT, - transfer_method=transfer_method, - related_id=file_related_id, - remote_url=remote_url, - filename="test_file.txt", - extension=".txt", - mime_type="text/plain", - size=1024, - storage_key="initial_key", - ) - - def test_load_storage_keys_local_file(self): - """Test loading storage keys for LOCAL_FILE transfer method.""" - # Create test data - upload_file = self._create_upload_file() - file = self._create_file(related_id=upload_file.id, transfer_method=FileTransferMethod.LOCAL_FILE) - - # Load storage keys - self.loader.load_storage_keys([file]) - - # Verify storage key was loaded correctly - assert file._storage_key == upload_file.key - - def test_load_storage_keys_remote_url(self): - """Test loading storage keys for REMOTE_URL transfer method.""" - # Create test data - upload_file = self._create_upload_file() - file = self._create_file(related_id=upload_file.id, transfer_method=FileTransferMethod.REMOTE_URL) - - # Load storage keys - self.loader.load_storage_keys([file]) - - # Verify storage key was loaded correctly - assert file._storage_key == upload_file.key - - def test_load_storage_keys_tool_file(self): - """Test loading storage keys for TOOL_FILE transfer method.""" - # Create test data - tool_file = self._create_tool_file() - file = self._create_file(related_id=tool_file.id, transfer_method=FileTransferMethod.TOOL_FILE) - - # Load storage keys - self.loader.load_storage_keys([file]) - - # Verify storage key was loaded correctly - assert file._storage_key == tool_file.file_key - - def test_load_storage_keys_mixed_methods(self): - """Test batch loading with mixed transfer methods.""" - # Create test data for different transfer methods - upload_file1 = self._create_upload_file() - upload_file2 = self._create_upload_file() - tool_file = self._create_tool_file() - - file1 = self._create_file(related_id=upload_file1.id, transfer_method=FileTransferMethod.LOCAL_FILE) - file2 = self._create_file(related_id=upload_file2.id, transfer_method=FileTransferMethod.REMOTE_URL) - file3 = self._create_file(related_id=tool_file.id, transfer_method=FileTransferMethod.TOOL_FILE) - - files = [file1, file2, file3] - - # Load storage keys - self.loader.load_storage_keys(files) - - # Verify all storage keys were loaded correctly - assert file1._storage_key == upload_file1.key - assert file2._storage_key == upload_file2.key - assert file3._storage_key == tool_file.file_key - - def test_load_storage_keys_empty_list(self): - """Test with empty file list.""" - # Should not raise any exceptions - self.loader.load_storage_keys([]) - - def test_load_storage_keys_ignores_legacy_file_tenant_id(self): - """Legacy file tenant_id should not override the loader tenant scope.""" - upload_file = self._create_upload_file() - file = self._create_file( - related_id=upload_file.id, transfer_method=FileTransferMethod.LOCAL_FILE, tenant_id=str(uuid4()) - ) - - self.loader.load_storage_keys([file]) - - assert file._storage_key == upload_file.key - - def test_load_storage_keys_missing_file_id(self): - """Test with None file.related_id.""" - # Create a file with valid parameters first, then manually set related_id to None - file = self._create_file(related_id=str(uuid4()), transfer_method=FileTransferMethod.LOCAL_FILE) - file.related_id = None - - # Should raise ValueError for None file related_id - with pytest.raises(ValueError) as context: - self.loader.load_storage_keys([file]) - - assert str(context.value) == "file id should not be None." - - def test_load_storage_keys_nonexistent_upload_file_records(self): - """Test with missing UploadFile database records.""" - # Create file with non-existent upload file id - non_existent_id = str(uuid4()) - file = self._create_file(related_id=non_existent_id, transfer_method=FileTransferMethod.LOCAL_FILE) - - # Should raise ValueError for missing record - with pytest.raises(ValueError): - self.loader.load_storage_keys([file]) - - def test_load_storage_keys_nonexistent_tool_file_records(self): - """Test with missing ToolFile database records.""" - # Create file with non-existent tool file id - non_existent_id = str(uuid4()) - file = self._create_file(related_id=non_existent_id, transfer_method=FileTransferMethod.TOOL_FILE) - - # Should raise ValueError for missing record - with pytest.raises(ValueError): - self.loader.load_storage_keys([file]) - - def test_load_storage_keys_invalid_uuid(self): - """Test with invalid UUID format.""" - # Create a file with valid parameters first, then manually set invalid related_id - file = self._create_file(related_id=str(uuid4()), transfer_method=FileTransferMethod.LOCAL_FILE) - file.related_id = "invalid-uuid-format" - - # Should raise ValueError for invalid UUID - with pytest.raises(ValueError): - self.loader.load_storage_keys([file]) - - def test_load_storage_keys_batch_efficiency(self): - """Test batched operations use efficient queries.""" - # Create multiple files of different types - upload_files = [self._create_upload_file() for _ in range(3)] - tool_files = [self._create_tool_file() for _ in range(2)] - - files = [] - files.extend( - [self._create_file(related_id=uf.id, transfer_method=FileTransferMethod.LOCAL_FILE) for uf in upload_files] - ) - files.extend( - [self._create_file(related_id=tf.id, transfer_method=FileTransferMethod.TOOL_FILE) for tf in tool_files] - ) - - # Mock the session to count queries - with patch.object(self.session, "scalars", wraps=self.session.scalars) as mock_scalars: - self.loader.load_storage_keys(files) - - # Should make exactly 2 queries (one for upload_files, one for tool_files) - assert mock_scalars.call_count == 2 - - # Verify all storage keys were loaded correctly - for i, file in enumerate(files[:3]): - assert file._storage_key == upload_files[i].key - for i, file in enumerate(files[3:]): - assert file._storage_key == tool_files[i].file_key - - def test_load_storage_keys_tenant_isolation(self): - """Test that tenant isolation works correctly.""" - # Create files for different tenants - other_tenant_id = str(uuid4()) - - # Create upload file for current tenant - upload_file_current = self._create_upload_file() - file_current = self._create_file( - related_id=upload_file_current.id, transfer_method=FileTransferMethod.LOCAL_FILE - ) - - # Create upload file for other tenant (but don't add to cleanup list) - upload_file_other = UploadFile( - tenant_id=other_tenant_id, - storage_type=StorageType.LOCAL, - key="other_tenant_key", - name="other_file.txt", - size=1024, - extension=".txt", - mime_type="text/plain", - created_by_role=CreatorUserRole.ACCOUNT, - created_by=self.user_id, - created_at=datetime.now(UTC), - used=False, - ) - upload_file_other.id = str(uuid4()) - self.session.add(upload_file_other) - self.session.flush() - - # Create file for other tenant but try to load with current tenant's loader - file_other = self._create_file( - related_id=upload_file_other.id, transfer_method=FileTransferMethod.LOCAL_FILE, tenant_id=other_tenant_id - ) - - # Should raise ValueError due to tenant mismatch - with pytest.raises(ValueError) as context: - self.loader.load_storage_keys([file_other]) - - assert "Upload file not found for id:" in str(context.value) - - # Current tenant's file should still work - self.loader.load_storage_keys([file_current]) - assert file_current._storage_key == upload_file_current.key - - def test_load_storage_keys_mixed_tenant_batch(self): - """Test batch with mixed tenant files (should fail on first mismatch).""" - # Create files for current tenant - upload_file_current = self._create_upload_file() - file_current = self._create_file( - related_id=upload_file_current.id, transfer_method=FileTransferMethod.LOCAL_FILE - ) - - # Create file for different tenant - other_tenant_id = str(uuid4()) - file_other = self._create_file( - related_id=str(uuid4()), transfer_method=FileTransferMethod.LOCAL_FILE, tenant_id=other_tenant_id - ) - - # Should raise ValueError on tenant mismatch - with pytest.raises(ValueError) as context: - self.loader.load_storage_keys([file_current, file_other]) - - assert "Upload file not found for id:" in str(context.value) - - def test_load_storage_keys_duplicate_file_ids(self): - """Test handling of duplicate file IDs in the batch.""" - # Create upload file - upload_file = self._create_upload_file() - - # Create two File objects with same related_id - file1 = self._create_file(related_id=upload_file.id, transfer_method=FileTransferMethod.LOCAL_FILE) - file2 = self._create_file(related_id=upload_file.id, transfer_method=FileTransferMethod.LOCAL_FILE) - - # Should handle duplicates gracefully - self.loader.load_storage_keys([file1, file2]) - - # Both files should have the same storage key - assert file1._storage_key == upload_file.key - assert file2._storage_key == upload_file.key - - def test_load_storage_keys_session_isolation(self): - """Test that the loader uses the provided session correctly.""" - # Create test data - upload_file = self._create_upload_file() - file = self._create_file(related_id=upload_file.id, transfer_method=FileTransferMethod.LOCAL_FILE) - - # Create loader with different session (same underlying connection) - - with Session(bind=db.engine) as other_session: - other_loader = StorageKeyLoader( - other_session, - self.tenant_id, - access_controller=DatabaseFileAccessController(), - ) - with pytest.raises(ValueError): - other_loader.load_storage_keys([file]) From 2f33867d072a0561118a2a52cd2b84476a58d60b Mon Sep 17 00:00:00 2001 From: James <63717587+jamesrayammons@users.noreply.github.com> Date: Wed, 15 Apr 2026 11:53:42 +0200 Subject: [PATCH 09/61] test: remove legacy trigger provider permissions test (#35227) --- .../test_trigger_provider_permissions.py | 244 ------------------ 1 file changed, 244 deletions(-) delete mode 100644 api/tests/integration_tests/controllers/console/workspace/test_trigger_provider_permissions.py diff --git a/api/tests/integration_tests/controllers/console/workspace/test_trigger_provider_permissions.py b/api/tests/integration_tests/controllers/console/workspace/test_trigger_provider_permissions.py deleted file mode 100644 index e55c12e678..0000000000 --- a/api/tests/integration_tests/controllers/console/workspace/test_trigger_provider_permissions.py +++ /dev/null @@ -1,244 +0,0 @@ -"""Integration tests for Trigger Provider subscription permission verification.""" - -import uuid -from unittest import mock - -import pytest -from flask.testing import FlaskClient - -from controllers.console.workspace import trigger_providers as trigger_providers_api -from libs.datetime_utils import naive_utc_now -from models import Tenant -from models.account import Account, TenantAccountJoin, TenantAccountRole - - -class TestTriggerProviderSubscriptionPermissions: - """Test permission verification for Trigger Provider subscription endpoints.""" - - @pytest.fixture - def mock_account(self, monkeypatch: pytest.MonkeyPatch): - """Create a mock Account for testing.""" - - account = Account(name="Test User", email="test@example.com") - account.id = str(uuid.uuid4()) - account.last_active_at = naive_utc_now() - account.created_at = naive_utc_now() - account.updated_at = naive_utc_now() - - # Create mock tenant - tenant = Tenant(name="Test Tenant") - tenant.id = str(uuid.uuid4()) - - mock_session_instance = mock.Mock() - - mock_tenant_join = TenantAccountJoin(role=TenantAccountRole.OWNER) - monkeypatch.setattr(mock_session_instance, "scalar", mock.Mock(return_value=mock_tenant_join)) - - mock_scalars_result = mock.Mock() - mock_scalars_result.one.return_value = tenant - monkeypatch.setattr(mock_session_instance, "scalars", mock.Mock(return_value=mock_scalars_result)) - - mock_session_context = mock.Mock() - mock_session_context.__enter__.return_value = mock_session_instance - monkeypatch.setattr("models.account.Session", lambda _, expire_on_commit: mock_session_context) - - account.current_tenant = tenant - account.current_tenant_id = tenant.id - return account - - @pytest.mark.parametrize( - ("role", "list_status", "get_status", "update_status", "create_status", "build_status", "delete_status"), - [ - # Admin/Owner can do everything - (TenantAccountRole.OWNER, 200, 200, 200, 200, 200, 200), - (TenantAccountRole.ADMIN, 200, 200, 200, 200, 200, 200), - # Editor can list, get, update (parameters), but not create, build, or delete - (TenantAccountRole.EDITOR, 200, 200, 200, 403, 403, 403), - # Normal user cannot do anything - (TenantAccountRole.NORMAL, 403, 403, 403, 403, 403, 403), - # Dataset operator cannot do anything - (TenantAccountRole.DATASET_OPERATOR, 403, 403, 403, 403, 403, 403), - ], - ) - def test_trigger_subscription_permissions( - self, - test_client: FlaskClient, - auth_header, - monkeypatch, - mock_account, - role: TenantAccountRole, - list_status: int, - get_status: int, - update_status: int, - create_status: int, - build_status: int, - delete_status: int, - ): - """Test that different roles have appropriate permissions for trigger subscription operations.""" - # Set user role - mock_account.role = role - - # Mock current user - monkeypatch.setattr(trigger_providers_api, "current_user", mock_account) - - # Mock AccountService.load_user to prevent authentication issues - from services.account_service import AccountService - - mock_load_user = mock.Mock(return_value=mock_account) - monkeypatch.setattr(AccountService, "load_user", mock_load_user) - - # Test data - provider = "some_provider/some_trigger" - subscription_builder_id = str(uuid.uuid4()) - subscription_id = str(uuid.uuid4()) - - # Mock service methods - mock_list_subscriptions = mock.Mock(return_value=[]) - monkeypatch.setattr( - "services.trigger.trigger_provider_service.TriggerProviderService.list_trigger_provider_subscriptions", - mock_list_subscriptions, - ) - - mock_get_subscription_builder = mock.Mock(return_value={"id": subscription_builder_id}) - monkeypatch.setattr( - "services.trigger.trigger_subscription_builder_service.TriggerSubscriptionBuilderService.get_subscription_builder_by_id", - mock_get_subscription_builder, - ) - - mock_update_subscription_builder = mock.Mock(return_value={"id": subscription_builder_id}) - monkeypatch.setattr( - "services.trigger.trigger_subscription_builder_service.TriggerSubscriptionBuilderService.update_trigger_subscription_builder", - mock_update_subscription_builder, - ) - - mock_create_subscription_builder = mock.Mock(return_value={"id": subscription_builder_id}) - monkeypatch.setattr( - "services.trigger.trigger_subscription_builder_service.TriggerSubscriptionBuilderService.create_trigger_subscription_builder", - mock_create_subscription_builder, - ) - - mock_update_and_build_builder = mock.Mock() - monkeypatch.setattr( - "services.trigger.trigger_subscription_builder_service.TriggerSubscriptionBuilderService.update_and_build_builder", - mock_update_and_build_builder, - ) - - mock_delete_provider = mock.Mock() - mock_delete_plugin_trigger = mock.Mock() - mock_db_session = mock.Mock() - mock_db_session.commit = mock.Mock() - - def mock_session_func(engine=None): - return mock_session_context - - mock_session_context = mock.Mock() - mock_session_context.__enter__.return_value = mock_db_session - mock_session_context.__exit__.return_value = None - - monkeypatch.setattr("services.trigger.trigger_provider_service.Session", mock_session_func) - monkeypatch.setattr("services.trigger.trigger_subscription_operator_service.Session", mock_session_func) - - monkeypatch.setattr( - "services.trigger.trigger_provider_service.TriggerProviderService.delete_trigger_provider", - mock_delete_provider, - ) - monkeypatch.setattr( - "services.trigger.trigger_subscription_operator_service.TriggerSubscriptionOperatorService.delete_plugin_trigger_by_subscription", - mock_delete_plugin_trigger, - ) - - # Test 1: List subscriptions (should work for Editor, Admin, Owner) - response = test_client.get( - f"/console/api/workspaces/current/trigger-provider/{provider}/subscriptions/list", - headers=auth_header, - ) - assert response.status_code == list_status - - # Test 2: Get subscription builder (should work for Editor, Admin, Owner) - response = test_client.get( - f"/console/api/workspaces/current/trigger-provider/{provider}/subscriptions/builder/{subscription_builder_id}", - headers=auth_header, - ) - assert response.status_code == get_status - - # Test 3: Update subscription builder parameters (should work for Editor, Admin, Owner) - response = test_client.post( - f"/console/api/workspaces/current/trigger-provider/{provider}/subscriptions/builder/update/{subscription_builder_id}", - headers=auth_header, - json={"parameters": {"webhook_url": "https://example.com/webhook"}}, - ) - assert response.status_code == update_status - - # Test 4: Create subscription builder (should only work for Admin, Owner) - response = test_client.post( - f"/console/api/workspaces/current/trigger-provider/{provider}/subscriptions/builder/create", - headers=auth_header, - json={"credential_type": "api_key"}, - ) - assert response.status_code == create_status - - # Test 5: Build/activate subscription (should only work for Admin, Owner) - response = test_client.post( - f"/console/api/workspaces/current/trigger-provider/{provider}/subscriptions/builder/build/{subscription_builder_id}", - headers=auth_header, - json={"name": "Test Subscription"}, - ) - assert response.status_code == build_status - - # Test 6: Delete subscription (should only work for Admin, Owner) - response = test_client.post( - f"/console/api/workspaces/current/trigger-provider/{subscription_id}/subscriptions/delete", - headers=auth_header, - ) - assert response.status_code == delete_status - - @pytest.mark.parametrize( - ("role", "status"), - [ - (TenantAccountRole.OWNER, 200), - (TenantAccountRole.ADMIN, 200), - # Editor should be able to access logs for debugging - (TenantAccountRole.EDITOR, 200), - (TenantAccountRole.NORMAL, 403), - (TenantAccountRole.DATASET_OPERATOR, 403), - ], - ) - def test_trigger_subscription_logs_permissions( - self, - test_client: FlaskClient, - auth_header, - monkeypatch, - mock_account, - role: TenantAccountRole, - status: int, - ): - """Test that different roles have appropriate permissions for accessing subscription logs.""" - # Set user role - mock_account.role = role - - # Mock current user - monkeypatch.setattr(trigger_providers_api, "current_user", mock_account) - - # Mock AccountService.load_user to prevent authentication issues - from services.account_service import AccountService - - mock_load_user = mock.Mock(return_value=mock_account) - monkeypatch.setattr(AccountService, "load_user", mock_load_user) - - # Test data - provider = "some_provider/some_trigger" - subscription_builder_id = str(uuid.uuid4()) - - # Mock service method - mock_list_logs = mock.Mock(return_value=[]) - monkeypatch.setattr( - "services.trigger.trigger_subscription_builder_service.TriggerSubscriptionBuilderService.list_logs", - mock_list_logs, - ) - - # Test access to logs - response = test_client.get( - f"/console/api/workspaces/current/trigger-provider/{provider}/subscriptions/builder/logs/{subscription_builder_id}", - headers=auth_header, - ) - assert response.status_code == status From e5bd18132c2f285cd8a77e8d134e55654c6682ce Mon Sep 17 00:00:00 2001 From: James <63717587+jamesrayammons@users.noreply.github.com> Date: Wed, 15 Apr 2026 11:53:46 +0200 Subject: [PATCH 10/61] test: remove legacy jinja2 code executor integration test (#35222) --- .../nodes/code_executor/test_code_jinja2.py | 95 ------------------- 1 file changed, 95 deletions(-) delete mode 100644 api/tests/integration_tests/workflow/nodes/code_executor/test_code_jinja2.py diff --git a/api/tests/integration_tests/workflow/nodes/code_executor/test_code_jinja2.py b/api/tests/integration_tests/workflow/nodes/code_executor/test_code_jinja2.py deleted file mode 100644 index c8eb9ec3e4..0000000000 --- a/api/tests/integration_tests/workflow/nodes/code_executor/test_code_jinja2.py +++ /dev/null @@ -1,95 +0,0 @@ -import base64 - -from core.helper.code_executor.code_executor import CodeExecutor, CodeLanguage -from core.helper.code_executor.jinja2.jinja2_transformer import Jinja2TemplateTransformer - -CODE_LANGUAGE = CodeLanguage.JINJA2 - - -def test_jinja2(): - """Test basic Jinja2 template rendering.""" - template = "Hello {{template}}" - # Template must be base64 encoded to match the new safe embedding approach - template_b64 = base64.b64encode(template.encode("utf-8")).decode("utf-8") - inputs = base64.b64encode(b'{"template": "World"}').decode("utf-8") - code = ( - Jinja2TemplateTransformer.get_runner_script() - .replace(Jinja2TemplateTransformer._template_b64_placeholder, template_b64) - .replace(Jinja2TemplateTransformer._inputs_placeholder, inputs) - ) - result = CodeExecutor.execute_code( - language=CODE_LANGUAGE, preload=Jinja2TemplateTransformer.get_preload_script(), code=code - ) - assert result == "<>Hello World<>\n" - - -def test_jinja2_with_code_template(): - """Test template rendering via the high-level workflow API.""" - result = CodeExecutor.execute_workflow_code_template( - language=CODE_LANGUAGE, code="Hello {{template}}", inputs={"template": "World"} - ) - assert result == {"result": "Hello World"} - - -def test_jinja2_get_runner_script(): - """Test that runner script contains required placeholders.""" - runner_script = Jinja2TemplateTransformer.get_runner_script() - assert runner_script.count(Jinja2TemplateTransformer._template_b64_placeholder) == 1 - assert runner_script.count(Jinja2TemplateTransformer._inputs_placeholder) == 1 - assert runner_script.count(Jinja2TemplateTransformer._result_tag) == 2 - - -def test_jinja2_template_with_special_characters(): - """ - Test that templates with special characters (quotes, newlines) render correctly. - This is a regression test for issue #26818 where textarea pre-fill values - containing special characters would break template rendering. - """ - # Template with triple quotes, single quotes, double quotes, and newlines - template = """ - - - -

Status: "{{ status }}"

-
'''code block'''
- -""" - inputs = {"task": {"Task ID": "TASK-123", "Issues": "Line 1\nLine 2\nLine 3"}, "status": "completed"} - - result = CodeExecutor.execute_workflow_code_template(language=CODE_LANGUAGE, code=template, inputs=inputs) - - # Verify the template rendered correctly with all special characters - output = result["result"] - assert 'value="TASK-123"' in output - assert "" in output - assert 'Status: "completed"' in output - assert "'''code block'''" in output - - -def test_jinja2_template_with_html_textarea_prefill(): - """ - Specific test for HTML textarea with Jinja2 variable pre-fill. - Verifies fix for issue #26818. - """ - template = "" - notes_content = "This is a multi-line note.\nWith special chars: 'single' and \"double\" quotes." - inputs = {"notes": notes_content} - - result = CodeExecutor.execute_workflow_code_template(language=CODE_LANGUAGE, code=template, inputs=inputs) - - expected_output = f"" - assert result["result"] == expected_output - - -def test_jinja2_assemble_runner_script_encodes_template(): - """Test that assemble_runner_script properly base64 encodes the template.""" - template = "Hello {{ name }}!" - inputs = {"name": "World"} - - script = Jinja2TemplateTransformer.assemble_runner_script(template, inputs) - - # The template should be base64 encoded in the script - template_b64 = base64.b64encode(template.encode("utf-8")).decode("utf-8") - assert template_b64 in script - # The raw template should NOT appear in the script (it's encoded) - assert "Hello {{ name }}!" not in script From 425457cb16845819d028775d45e3172cf6fa6003 Mon Sep 17 00:00:00 2001 From: James <63717587+jamesrayammons@users.noreply.github.com> Date: Wed, 15 Apr 2026 11:53:53 +0200 Subject: [PATCH 11/61] test: remove legacy workflow draft variable api test (#35226) --- .../app/test_workflow_draft_variable.py | 47 ------------------- 1 file changed, 47 deletions(-) delete mode 100644 api/tests/integration_tests/controllers/console/app/test_workflow_draft_variable.py diff --git a/api/tests/integration_tests/controllers/console/app/test_workflow_draft_variable.py b/api/tests/integration_tests/controllers/console/app/test_workflow_draft_variable.py deleted file mode 100644 index 038f37af5f..0000000000 --- a/api/tests/integration_tests/controllers/console/app/test_workflow_draft_variable.py +++ /dev/null @@ -1,47 +0,0 @@ -import uuid -from unittest import mock - -from controllers.console.app import workflow_draft_variable as draft_variable_api -from controllers.console.app import wraps -from factories.variable_factory import build_segment -from models import App, AppMode -from models.workflow import WorkflowDraftVariable -from services.workflow_draft_variable_service import WorkflowDraftVariableList, WorkflowDraftVariableService - - -def _get_mock_srv_class() -> type[WorkflowDraftVariableService]: - return mock.create_autospec(WorkflowDraftVariableService) - - -class TestWorkflowDraftNodeVariableListApi: - def test_get(self, test_client, auth_header, monkeypatch): - srv_class = _get_mock_srv_class() - mock_app_model: App = App() - mock_app_model.id = str(uuid.uuid4()) - test_node_id = "test_node_id" - mock_app_model.mode = AppMode.ADVANCED_CHAT - mock_load_app_model = mock.Mock(return_value=mock_app_model) - - monkeypatch.setattr(draft_variable_api, "WorkflowDraftVariableService", srv_class) - monkeypatch.setattr(wraps, "_load_app_model", mock_load_app_model) - - var1 = WorkflowDraftVariable.new_node_variable( - app_id="test_app_1", - node_id="test_node_1", - name="str_var", - value=build_segment("str_value"), - node_execution_id=str(uuid.uuid4()), - ) - srv_instance = mock.create_autospec(WorkflowDraftVariableService, instance=True) - srv_class.return_value = srv_instance - srv_instance.list_node_variables.return_value = WorkflowDraftVariableList(variables=[var1]) - - response = test_client.get( - f"/console/api/apps/{mock_app_model.id}/workflows/draft/nodes/{test_node_id}/variables", - headers=auth_header, - ) - assert response.status_code == 200 - response_dict = response.json - assert isinstance(response_dict, dict) - assert "items" in response_dict - assert len(response_dict["items"]) == 1 From dbceb3067e5c1ce177bf60ec8617b53e2d831ac3 Mon Sep 17 00:00:00 2001 From: NVIDIAN Date: Wed, 15 Apr 2026 02:57:27 -0700 Subject: [PATCH 12/61] refactor(api): migrate console tag responses from marshal_with to BaseModel (#35208) Co-authored-by: ai-hpc --- api/controllers/console/tag/tags.py | 56 +++++++++++++------ .../controllers/console/tag/test_tags.py | 26 ++++++++- 2 files changed, 62 insertions(+), 20 deletions(-) diff --git a/api/controllers/console/tag/tags.py b/api/controllers/console/tag/tags.py index 39b84d3869..614bf03ea5 100644 --- a/api/controllers/console/tag/tags.py +++ b/api/controllers/console/tag/tags.py @@ -1,13 +1,14 @@ from typing import Literal from flask import request -from flask_restx import Namespace, Resource, fields, marshal_with -from pydantic import BaseModel, Field +from flask_restx import Resource +from pydantic import BaseModel, Field, field_validator from werkzeug.exceptions import Forbidden from controllers.common.schema import register_schema_models from controllers.console import console_ns from controllers.console.wraps import account_initialization_required, edit_permission_required, setup_required +from fields.base import ResponseModel from libs.login import current_account_with_tenant, login_required from models.enums import TagType from services.tag_service import ( @@ -18,17 +19,6 @@ from services.tag_service import ( UpdateTagPayload, ) -dataset_tag_fields = { - "id": fields.String, - "name": fields.String, - "type": fields.String, - "binding_count": fields.String, -} - - -def build_dataset_tag_fields(api_or_ns: Namespace): - return api_or_ns.model("DataSetTag", dataset_tag_fields) - class TagBasePayload(BaseModel): name: str = Field(description="Tag name", min_length=1, max_length=50) @@ -52,12 +42,36 @@ class TagListQueryParam(BaseModel): keyword: str | None = Field(None, description="Search keyword") +class TagResponse(ResponseModel): + id: str + name: str + type: str | None = None + binding_count: str | None = None + + @field_validator("type", mode="before") + @classmethod + def normalize_type(cls, value: TagType | str | None) -> str | None: + if value is None: + return None + if isinstance(value, TagType): + return value.value + return value + + @field_validator("binding_count", mode="before") + @classmethod + def normalize_binding_count(cls, value: int | str | None) -> str | None: + if value is None: + return None + return str(value) + + register_schema_models( console_ns, TagBasePayload, TagBindingPayload, TagBindingRemovePayload, TagListQueryParam, + TagResponse, ) @@ -69,14 +83,18 @@ class TagListApi(Resource): @console_ns.doc( params={"type": 'Tag type filter. Can be "knowledge" or "app".', "keyword": "Search keyword for tag name."} ) - @marshal_with(dataset_tag_fields) + @console_ns.doc(responses={200: ("Success", [console_ns.models[TagResponse.__name__]])}) def get(self): _, current_tenant_id = current_account_with_tenant() raw_args = request.args.to_dict() param = TagListQueryParam.model_validate(raw_args) tags = TagService.get_tags(param.type, current_tenant_id, param.keyword) - return tags, 200 + serialized_tags = [ + TagResponse.model_validate(tag, from_attributes=True).model_dump(mode="json") for tag in tags + ] + + return serialized_tags, 200 @console_ns.expect(console_ns.models[TagBasePayload.__name__]) @setup_required @@ -91,7 +109,9 @@ class TagListApi(Resource): payload = TagBasePayload.model_validate(console_ns.payload or {}) tag = TagService.save_tags(SaveTagPayload(name=payload.name, type=payload.type)) - response = {"id": tag.id, "name": tag.name, "type": tag.type, "binding_count": 0} + response = TagResponse.model_validate( + {"id": tag.id, "name": tag.name, "type": tag.type, "binding_count": 0} + ).model_dump(mode="json") return response, 200 @@ -114,7 +134,9 @@ class TagUpdateDeleteApi(Resource): binding_count = TagService.get_tag_binding_count(tag_id) - response = {"id": tag.id, "name": tag.name, "type": tag.type, "binding_count": binding_count} + response = TagResponse.model_validate( + {"id": tag.id, "name": tag.name, "type": tag.type, "binding_count": binding_count} + ).model_dump(mode="json") return response, 200 diff --git a/api/tests/unit_tests/controllers/console/tag/test_tags.py b/api/tests/unit_tests/controllers/console/tag/test_tags.py index e89b89c8b1..2be5a21f28 100644 --- a/api/tests/unit_tests/controllers/console/tag/test_tags.py +++ b/api/tests/unit_tests/controllers/console/tag/test_tags.py @@ -1,9 +1,11 @@ +from types import SimpleNamespace from unittest.mock import MagicMock, PropertyMock, patch import pytest from flask import Flask from werkzeug.exceptions import Forbidden +import controllers.console.tag.tags as module from controllers.console import console_ns from controllers.console.tag.tags import ( TagBindingCreateApi, @@ -83,13 +85,20 @@ class TestTagListApi: ), patch( "controllers.console.tag.tags.TagService.get_tags", - return_value=[{"id": "1", "name": "tag"}], + return_value=[ + SimpleNamespace( + id="1", + name="tag", + type=TagType.KNOWLEDGE, + binding_count=1, + ) + ], ), ): result, status = method(api) assert status == 200 - assert isinstance(result, list) + assert result == [{"id": "1", "name": "tag", "type": "knowledge", "binding_count": "1"}] def test_post_success(self, app, admin_user, tag, payload_patch): api = TagListApi() @@ -113,6 +122,7 @@ class TestTagListApi: assert status == 200 assert result["name"] == "test-tag" + assert result["binding_count"] == "0" def test_post_forbidden(self, app, readonly_user, payload_patch): api = TagListApi() @@ -158,7 +168,7 @@ class TestTagUpdateDeleteApi: result, status = method(api, "tag-1") assert status == 200 - assert result["binding_count"] == 3 + assert result["binding_count"] == "3" def test_patch_forbidden(self, app, readonly_user, payload_patch): api = TagUpdateDeleteApi() @@ -277,3 +287,13 @@ class TestTagBindingDeleteApi: ): with pytest.raises(Forbidden): method(api) + + +class TestTagResponseModel: + def test_tag_response_normalizes_enum_type(self): + payload = module.TagResponse.model_validate( + {"id": "tag-1", "name": "tag", "type": TagType.KNOWLEDGE, "binding_count": 1} + ).model_dump(mode="json") + + assert payload["type"] == "knowledge" + assert payload["binding_count"] == "1" From af7d5e60b47503a0c5c3751fa783d63654644a21 Mon Sep 17 00:00:00 2001 From: yyh <92089059+lyzno1@users.noreply.github.com> Date: Wed, 15 Apr 2026 21:11:20 +0800 Subject: [PATCH 13/61] feat(ui): scaffold @langgenius/dify-ui and migrate design tokens (#35256) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- packages/dify-ui/AGENTS.md | 27 + packages/dify-ui/package.json | 24 + .../dify-ui/src/cn.ts | 0 packages/dify-ui/src/styles/styles.css | 3 + packages/dify-ui/src/styles/utilities.css | 272 ++ packages/dify-ui/src/tailwind-preset.ts | 87 + {web => packages/dify-ui/src}/themes/dark.css | 0 .../dify-ui/src}/themes/light.css | 0 .../src}/themes/tailwind-theme-var-define.ts | 0 packages/dify-ui/tsconfig.json | 18 + pnpm-lock.yaml | 85 +- pnpm-workspace.yaml | 85 +- web/AGENTS.md | 4 + web/README.md | 2 - .../plugins/plugin-auth-flow.test.tsx | 2 +- .../plugins/plugin-card-rendering.test.tsx | 2 +- .../tool-browsing-and-filtering.test.tsx | 2 +- .../tools/tool-provider-detail-flow.test.tsx | 2 +- .../(appDetailLayout)/[appId]/layout-main.tsx | 2 +- .../time-range-picker/date-picker.tsx | 4 +- .../time-range-picker/range-selector.tsx | 8 +- .../overview/tracing/config-button.tsx | 2 +- .../[appId]/overview/tracing/config-popup.tsx | 8 +- .../[appId]/overview/tracing/field.tsx | 2 +- .../[appId]/overview/tracing/panel.tsx | 12 +- .../overview/tracing/provider-panel.tsx | 6 +- .../[appId]/overview/tracing/tracing-icon.tsx | 4 +- .../[datasetId]/layout-main.tsx | 2 +- .../(humanInputLayout)/form/[token]/form.tsx | 14 +- .../webapp-reset-password/layout.tsx | 6 +- .../set-password/page.tsx | 2 +- .../(shareLayout)/webapp-signin/layout.tsx | 4 +- .../webapp-signin/normalForm.tsx | 24 +- web/app/account/(commonLayout)/avatar.tsx | 4 +- web/app/account/oauth/authorize/layout.tsx | 6 +- web/app/activate/activateForm.tsx | 6 +- web/app/activate/page.tsx | 2 +- .../text-squeeze-fix-verification.spec.tsx | 2 +- .../app-sidebar/app-info/app-info-trigger.tsx | 6 +- .../app-sidebar/app-info/app-operations.tsx | 2 +- .../app-sidebar/app-sidebar-dropdown.tsx | 12 +- .../app-sidebar/dataset-info/dropdown.tsx | 2 +- .../app-sidebar/dataset-info/index.tsx | 12 +- .../app-sidebar/dataset-sidebar-dropdown.tsx | 14 +- web/app/components/app-sidebar/index.tsx | 4 +- .../components/app-sidebar/nav-link/index.tsx | 8 +- .../components/app-sidebar/toggle-button.tsx | 2 +- .../app/annotation/batch-action.tsx | 4 +- .../csv-uploader.tsx | 2 +- .../edit-annotation-modal/edit-item/index.tsx | 2 +- .../app/annotation/header-opts/index.tsx | 4 +- web/app/components/app/annotation/index.tsx | 6 +- web/app/components/app/annotation/list.tsx | 18 +- .../view-annotation-modal/index.tsx | 22 +- .../access-control-dialog.tsx | 4 +- .../access-control-item.tsx | 4 +- .../add-member-or-group-pop.tsx | 2 +- .../app/app-publisher/suggested-action.tsx | 4 +- .../base/feature-panel/index.tsx | 4 +- .../base/operation-btn/index.tsx | 4 +- .../base/warning-mask/has-not-set-api.tsx | 10 +- .../config-prompt/advanced-prompt-input.tsx | 2 +- .../config-prompt/message-type-selector.tsx | 8 +- .../prompt-editor-height-resize-wrap.tsx | 2 +- .../config-prompt/simple-prompt-input.tsx | 8 +- .../config-var/config-modal/field.tsx | 6 +- .../config-var/config-modal/form-fields.tsx | 2 +- .../config-var/config-modal/type-select.tsx | 2 +- .../config-var/config-select/index.tsx | 8 +- .../app/configuration/config-var/index.tsx | 2 +- .../config-var/select-type-item/index.tsx | 4 +- .../app/configuration/config-var/var-item.tsx | 12 +- .../app/configuration/config-vision/index.tsx | 12 +- .../config-vision/param-config.tsx | 2 +- .../config/agent/agent-setting/item-panel.tsx | 4 +- .../config/agent/agent-tools/index.tsx | 2 +- .../agent-tools/setting-built-in-tool.tsx | 2 +- .../config/assistant-type-picker/index.tsx | 20 +- .../config/automatic/idea-output.tsx | 6 +- .../config/automatic/instruction-editor.tsx | 10 +- .../config/automatic/prompt-toast.tsx | 4 +- .../config/automatic/version-selector.tsx | 10 +- .../dataset-config/card-item/index.tsx | 4 +- .../dataset-config/context-var/index.tsx | 4 +- .../dataset-config/context-var/var-picker.tsx | 10 +- .../configuration/dataset-config/index.tsx | 4 +- .../params-config/config-content.tsx | 22 +- .../dataset-config/params-config/index.tsx | 2 +- .../dataset-config/select-dataset/index.tsx | 2 +- .../dataset-config/settings-modal/index.tsx | 2 +- .../settings-modal/retrieval-section.tsx | 10 +- .../configuration/debug/chat-user-input.tsx | 6 +- .../app/configuration/debug/index.tsx | 4 +- .../prompt-value-panel/index.tsx | 2 +- .../app/create-app-dialog/app-card/index.tsx | 4 +- .../app/create-app-dialog/app-list/index.tsx | 6 +- .../create-app-dialog/app-list/sidebar.tsx | 4 +- .../components/app/create-app-modal/index.tsx | 4 +- .../app/create-from-dsl-modal/index.tsx | 2 +- .../app/create-from-dsl-modal/uploader.tsx | 10 +- .../components/app/duplicate-modal/index.tsx | 2 +- .../components/app/in-site-message/index.tsx | 2 +- .../components/app/log-annotation/index.tsx | 2 +- web/app/components/app/log/list.tsx | 38 +- web/app/components/app/log/model-info.tsx | 8 +- web/app/components/app/log/var-panel.tsx | 14 +- .../app/overview/apikey-info-panel/index.tsx | 2 +- .../app/overview/embedded/index.tsx | 10 +- .../app/overview/settings/index.tsx | 2 +- .../components/app/switch-app-modal/index.tsx | 4 +- .../app/text-generate/item/action-groups.tsx | 6 +- .../app/text-generate/item/index.tsx | 10 +- .../app/text-generate/item/workflow-body.tsx | 2 +- .../app/text-generate/saved-items/index.tsx | 8 +- .../saved-items/no-data/index.tsx | 2 +- .../components/app/type-selector/index.tsx | 10 +- web/app/components/app/workflow-log/list.tsx | 28 +- web/app/components/apps/app-card.tsx | 22 +- web/app/components/apps/list.tsx | 18 +- web/app/components/apps/new-app-card.tsx | 10 +- .../components/base/action-button/index.tsx | 2 +- .../base/agent-log-modal/detail.tsx | 6 +- .../components/base/agent-log-modal/index.tsx | 4 +- .../base/agent-log-modal/iteration.tsx | 6 +- .../base/agent-log-modal/tool-call.tsx | 8 +- web/app/components/base/alert.tsx | 4 +- web/app/components/base/answer-icon/index.tsx | 2 +- .../base/app-icon-picker/ImageInput.tsx | 6 +- .../components/base/app-icon-picker/index.tsx | 2 +- .../base/app-icon/__tests__/index.spec.tsx | 6 +- web/app/components/base/app-icon/index.tsx | 34 +- web/app/components/base/app-unavailable.tsx | 4 +- .../base/audio-gallery/AudioPlayer.tsx | 10 +- .../base/auto-height-textarea/index.tsx | 4 +- web/app/components/base/badge.tsx | 6 +- web/app/components/base/badge/index.tsx | 2 +- web/app/components/base/block-input/index.tsx | 4 +- web/app/components/base/carousel/index.tsx | 2 +- .../chat/chat-with-history/chat-wrapper.tsx | 4 +- .../chat/chat-with-history/header/index.tsx | 2 +- .../chat-with-history/header/operation.tsx | 10 +- .../base/chat/chat-with-history/index.tsx | 2 +- .../chat-with-history/inputs-form/content.tsx | 2 +- .../chat-with-history/inputs-form/index.tsx | 2 +- .../chat/chat-with-history/sidebar/index.tsx | 4 +- .../chat/chat-with-history/sidebar/item.tsx | 4 +- .../chat-with-history/sidebar/operation.tsx | 8 +- .../base/chat/chat/answer/basic-content.tsx | 2 +- .../human-input-content/content-wrapper.tsx | 4 +- .../human-input-content/expiration-time.tsx | 4 +- .../base/chat/chat/answer/index.tsx | 10 +- .../base/chat/chat/answer/operation.tsx | 14 +- .../chat/chat/answer/suggested-questions.tsx | 4 +- .../base/chat/chat/answer/tool-detail.tsx | 16 +- .../chat/chat/answer/workflow-process.tsx | 16 +- .../base/chat/chat/chat-input-area/index.tsx | 8 +- .../chat/chat/chat-input-area/operation.tsx | 2 +- web/app/components/base/chat/chat/index.tsx | 2 +- .../base/chat/chat/loading-anim/index.tsx | 2 +- .../components/base/chat/chat/log/index.tsx | 2 +- .../components/base/chat/chat/question.tsx | 4 +- .../chat/embedded-chatbot/chat-wrapper.tsx | 6 +- .../chat/embedded-chatbot/header/index.tsx | 4 +- .../base/chat/embedded-chatbot/index.tsx | 2 +- .../embedded-chatbot/inputs-form/content.tsx | 8 +- .../embedded-chatbot/inputs-form/index.tsx | 2 +- .../inputs-form/view-form-dropdown.tsx | 4 +- .../components/base/checkbox-list/index.tsx | 2 +- web/app/components/base/checkbox/index.tsx | 4 +- web/app/components/base/chip/index.tsx | 4 +- .../components/base/content-dialog/index.tsx | 4 +- .../components/base/corner-label/index.tsx | 2 +- .../date-and-time-picker/calendar/item.tsx | 6 +- .../common/option-list-item.tsx | 4 +- .../date-picker/footer.tsx | 2 +- .../date-picker/index.tsx | 8 +- .../time-picker/index.tsx | 10 +- .../time-picker/options.tsx | 6 +- .../year-and-month-picker/options.tsx | 4 +- web/app/components/base/dialog/index.tsx | 6 +- web/app/components/base/divider/index.tsx | 2 +- web/app/components/base/drawer-plus/index.tsx | 8 +- web/app/components/base/drawer/index.tsx | 2 +- web/app/components/base/dropdown/index.tsx | 2 +- web/app/components/base/effect/index.tsx | 2 +- .../components/base/emoji-picker/Inner.tsx | 10 +- .../components/base/emoji-picker/index.tsx | 2 +- .../base/encrypted-bottom/index.tsx | 4 +- .../components/base/error-boundary/index.tsx | 2 +- .../conversation-opener/modal.tsx | 2 +- .../new-feature-panel/dialog-wrapper.tsx | 6 +- .../new-feature-panel/feature-bar.tsx | 2 +- .../moderation/moderation-setting-modal.tsx | 2 +- .../text-to-speech/param-config-content.tsx | 18 +- web/app/components/base/file-thumb/index.tsx | 4 +- .../file-from-link-or-local/index.tsx | 2 +- .../base/file-uploader/file-image-render.tsx | 2 +- .../base/file-uploader/file-list-in-log.tsx | 8 +- .../base/file-uploader/file-type-icon.tsx | 2 +- .../file-uploader-in-attachment/file-item.tsx | 8 +- .../file-uploader-in-attachment/index.tsx | 2 +- .../file-uploader-in-chat-input/file-item.tsx | 2 +- .../file-uploader-in-chat-input/file-list.tsx | 2 +- .../file-uploader-in-chat-input/index.tsx | 2 +- .../base/form/components/base/base-field.tsx | 10 +- .../base/form/components/base/base-form.tsx | 2 +- .../base/form/components/field/checkbox.tsx | 4 +- .../form/components/field/custom-select.tsx | 2 +- .../base/form/components/field/file-types.tsx | 2 +- .../form/components/field/file-uploader.tsx | 2 +- .../field/input-type-select/index.tsx | 2 +- .../field/input-type-select/trigger.tsx | 2 +- .../field/mixed-variable-text-input/index.tsx | 2 +- .../form/components/field/number-input.tsx | 2 +- .../form/components/field/number-slider.tsx | 4 +- .../base/form/components/field/options.tsx | 2 +- .../base/form/components/field/select.tsx | 2 +- .../base/form/components/field/text-area.tsx | 2 +- .../base/form/components/field/text.tsx | 2 +- .../form/components/field/upload-method.tsx | 2 +- .../field/variable-or-constant-input.tsx | 2 +- .../components/field/variable-selector.tsx | 2 +- .../components/base/form/components/label.tsx | 6 +- .../fullscreen-modal/__tests__/index.spec.tsx | 4 +- .../base/fullscreen-modal/index.tsx | 8 +- web/app/components/base/grid-mask/index.tsx | 2 +- .../icons/src/image/llm/BaichuanTextCn.tsx | 2 +- .../base/icons/src/image/llm/Minimax.tsx | 2 +- .../base/icons/src/image/llm/MinimaxText.tsx | 2 +- .../base/icons/src/image/llm/Tongyi.tsx | 2 +- .../base/icons/src/image/llm/TongyiText.tsx | 2 +- .../base/icons/src/image/llm/TongyiTextCn.tsx | 2 +- .../base/icons/src/image/llm/Wxyy.tsx | 2 +- .../base/icons/src/image/llm/WxyyText.tsx | 2 +- .../base/icons/src/image/llm/WxyyTextCn.tsx | 2 +- .../components/base/image-gallery/index.tsx | 2 +- .../image-uploader/chat-image-uploader.tsx | 4 +- .../base/image-uploader/image-list.tsx | 7 +- .../base/inline-delete-confirm/index.tsx | 4 +- .../components/base/input-with-copy/index.tsx | 6 +- .../base/input/__tests__/index.spec.tsx | 6 +- web/app/components/base/input/index.tsx | 16 +- .../base/linked-apps-panel/index.tsx | 6 +- web/app/components/base/list-empty/index.tsx | 14 +- web/app/components/base/loading/index.tsx | 2 +- web/app/components/base/logo/dify-logo.tsx | 2 +- .../base/logo/logo-embedded-chat-header.tsx | 2 +- web/app/components/base/logo/logo-site.tsx | 2 +- .../base/markdown-blocks/button.tsx | 2 +- .../base/markdown-blocks/think-block.tsx | 4 +- .../components/with-icon-card-item.tsx | 4 +- .../components/with-icon-card-list.tsx | 2 +- web/app/components/base/markdown/index.tsx | 2 +- web/app/components/base/mermaid/index.tsx | 4 +- .../base/message-log-modal/index.tsx | 6 +- .../components/base/modal-like-wrap/index.tsx | 2 +- web/app/components/base/modal/index.css | 7 - web/app/components/base/modal/index.tsx | 2 +- web/app/components/base/modal/modal.tsx | 2 +- web/app/components/base/node-status/index.tsx | 2 +- .../base/notion-connector/index.tsx | 2 +- web/app/components/base/notion-icon/index.tsx | 2 +- .../page-selector/page-row.tsx | 2 +- .../search-input/index.tsx | 4 +- web/app/components/base/pagination/index.tsx | 6 +- .../components/base/pagination/pagination.tsx | 2 +- web/app/components/base/popover/index.tsx | 2 +- .../base/portal-to-follow-elem/index.tsx | 2 +- .../premium-badge/__tests__/index.spec.tsx | 2 +- .../components/base/premium-badge/index.css | 10 +- .../components/base/premium-badge/index.tsx | 6 +- .../base/progress-bar/progress-circle.tsx | 2 +- .../components/base/prompt-editor/index.tsx | 2 +- .../plugins/current-block/component.tsx | 6 +- .../plugins/draggable-plugin/index.tsx | 8 +- .../plugins/error-message-block/component.tsx | 6 +- .../plugins/hitl-input-block/component-ui.tsx | 6 +- .../plugins/hitl-input-block/input-field.tsx | 4 +- .../plugins/hitl-input-block/pre-populate.tsx | 4 +- .../plugins/hitl-input-block/tag-label.tsx | 4 +- .../plugins/hitl-input-block/type-switch.tsx | 4 +- .../plugins/last-run-block/component.tsx | 6 +- .../prompt-editor/plugins/placeholder.tsx | 4 +- .../plugins/request-url-block/component.tsx | 6 +- .../plugins/shortcuts-popup-plugin/index.tsx | 2 +- .../prompt-editor/prompt-editor-content.tsx | 2 +- web/app/components/base/radio-card/index.tsx | 6 +- .../base/radio-card/simple/index.tsx | 6 +- .../base/radio/component/group/index.tsx | 2 +- .../base/radio/component/radio/index.tsx | 2 +- web/app/components/base/radio/ui.tsx | 2 +- .../components/base/search-input/index.tsx | 6 +- .../__tests__/index.spec.tsx | 4 +- .../base/segmented-control/index.css | 36 +- .../base/segmented-control/index.tsx | 26 +- web/app/components/base/select/custom.tsx | 6 +- web/app/components/base/select/index.tsx | 16 +- web/app/components/base/select/pure.tsx | 8 +- .../base/simple-pie-chart/index.tsx | 2 +- web/app/components/base/skeleton/index.tsx | 2 +- web/app/components/base/sort/index.tsx | 4 +- web/app/components/base/svg/index.tsx | 2 +- .../base/switch/__tests__/index.spec.tsx | 6 +- web/app/components/base/switch/index.tsx | 10 +- web/app/components/base/switch/skeleton.tsx | 6 +- web/app/components/base/tab-header/index.tsx | 4 +- .../components/base/tab-slider-new/index.tsx | 4 +- .../base/tab-slider-plain/index.tsx | 4 +- web/app/components/base/tab-slider/index.tsx | 8 +- web/app/components/base/tag-input/index.tsx | 6 +- .../components/base/tag-management/filter.tsx | 12 +- .../base/tag-management/selector.tsx | 2 +- .../base/tag-management/tag-item-editor.tsx | 6 +- .../base/tag-management/tag-remove-modal.tsx | 2 +- web/app/components/base/tag/index.tsx | 2 +- web/app/components/base/textarea/index.tsx | 2 +- web/app/components/base/theme-switcher.tsx | 4 +- .../components/base/timezone-label/index.tsx | 4 +- web/app/components/base/tooltip/index.tsx | 4 +- .../components/base/ui/alert-dialog/index.tsx | 2 +- web/app/components/base/ui/avatar/index.tsx | 2 +- web/app/components/base/ui/button/index.tsx | 2 +- .../components/base/ui/context-menu/index.tsx | 2 +- web/app/components/base/ui/dialog/index.tsx | 2 +- .../base/ui/dropdown-menu/index.tsx | 2 +- .../ui/number-field/__tests__/index.spec.tsx | 4 +- .../base/ui/number-field/index.stories.tsx | 2 +- .../components/base/ui/number-field/index.tsx | 6 +- web/app/components/base/ui/popover/index.tsx | 2 +- .../ui/scroll-area/__tests__/index.spec.tsx | 4 +- .../base/ui/scroll-area/index.stories.tsx | 20 +- .../components/base/ui/scroll-area/index.tsx | 4 +- web/app/components/base/ui/select/index.tsx | 4 +- web/app/components/base/ui/slider/index.tsx | 2 +- web/app/components/base/ui/toast/index.tsx | 2 +- web/app/components/base/ui/tooltip/index.tsx | 2 +- web/app/components/base/voice-input/index.tsx | 6 +- .../billing/annotation-full/index.tsx | 4 +- .../billing/annotation-full/modal.tsx | 4 +- .../billing/apps-full-in-dialog/index.tsx | 2 +- .../billing/header-billing-btn/index.tsx | 2 +- web/app/components/billing/pricing/footer.tsx | 2 +- web/app/components/billing/pricing/header.tsx | 2 +- .../billing/pricing/plan-switcher/tab.tsx | 2 +- .../pricing/plans/cloud-plan-item/button.tsx | 4 +- .../cloud-plan-item/list/item/tooltip.tsx | 4 +- .../plans/self-hosted-plan-item/button.tsx | 6 +- .../plans/self-hosted-plan-item/index.tsx | 18 +- .../billing/priority-label/index.tsx | 4 +- .../components/billing/progress-bar/index.tsx | 10 +- .../trigger-events-limit-modal/index.tsx | 2 +- .../components/billing/usage-info/index.tsx | 8 +- .../billing/vector-space-full/index.tsx | 4 +- .../components/chat-preview-card.tsx | 2 +- .../components/workflow-preview-card.tsx | 2 +- .../custom/custom-web-app-brand/index.tsx | 2 +- .../datasets/common/credential-icon.tsx | 4 +- .../common/document-picker/document-list.tsx | 2 +- .../datasets/common/document-picker/index.tsx | 6 +- .../preview-document-picker.tsx | 8 +- .../status-with-action.tsx | 4 +- .../datasets/common/image-list/index.tsx | 2 +- .../datasets/common/image-previewer/index.tsx | 2 +- .../image-uploader-in-chunk/image-input.tsx | 6 +- .../image-uploader-in-chunk/index.tsx | 2 +- .../index.tsx | 2 +- .../common/retrieval-param-config/index.tsx | 14 +- .../__tests__/index.spec.tsx | 4 +- .../create-from-dsl-modal/tab/item.tsx | 4 +- .../create-from-dsl-modal/uploader.tsx | 8 +- .../create-from-pipeline/list/create-card.tsx | 6 +- .../details/chunk-structure-card.tsx | 4 +- .../indexing-progress-item.tsx | 8 +- .../empty-dataset-creation-modal/index.tsx | 2 +- .../datasets/create/file-preview/index.tsx | 2 +- .../components/upload-dropzone.tsx | 4 +- .../datasets/create/file-uploader/index.tsx | 4 +- .../create/notion-page-preview/index.tsx | 2 +- .../components/data-source-type-selector.tsx | 2 +- .../datasets/create/step-one/index.tsx | 2 +- .../datasets/create/step-three/index.tsx | 10 +- .../components/indexing-mode-section.tsx | 2 +- .../step-two/components/option-card.tsx | 8 +- .../step-two/components/preview-panel.tsx | 2 +- .../datasets/create/step-two/index.tsx | 4 +- .../create/step-two/language-select/index.tsx | 4 +- .../datasets/create/stepper/step.tsx | 4 +- .../create/stop-embedding-modal/index.tsx | 2 +- .../datasets/create/top-bar/index.tsx | 6 +- .../website/base/checkbox-with-label.tsx | 2 +- .../website/base/crawled-result-item.tsx | 2 +- .../create/website/base/crawled-result.tsx | 2 +- .../create/website/base/error-message.tsx | 4 +- .../datasets/create/website/base/field.tsx | 2 +- .../datasets/create/website/base/header.tsx | 2 +- .../create/website/base/options-wrap.tsx | 6 +- .../create/website/firecrawl/options.tsx | 2 +- .../datasets/create/website/index.tsx | 16 +- .../create/website/jina-reader/options.tsx | 4 +- .../datasets/create/website/no-data.tsx | 2 +- .../datasets/create/website/preview.tsx | 4 +- .../create/website/watercrawl/options.tsx | 4 +- .../document-list/components/sort-header.tsx | 4 +- .../document-list/components/utils.tsx | 2 +- .../documents/components/operations.tsx | 2 +- .../data-source-options/datasource-icon.tsx | 2 +- .../data-source-options/option-card.tsx | 6 +- .../base/credential-selector/trigger.tsx | 4 +- .../local-file/components/file-list-item.tsx | 2 +- .../local-file/components/upload-dropzone.tsx | 4 +- .../online-drive/connect/index.tsx | 2 +- .../file-list/header/breadcrumbs/bucket.tsx | 4 +- .../file-list/header/breadcrumbs/drive.tsx | 2 +- .../header/breadcrumbs/dropdown/index.tsx | 2 +- .../file-list/header/breadcrumbs/item.tsx | 4 +- .../file-list/list/empty-folder.tsx | 2 +- .../file-list/list/empty-search-result.tsx | 2 +- .../online-drive/file-list/list/file-icon.tsx | 2 +- .../online-drive/file-list/list/index.tsx | 2 +- .../online-drive/file-list/list/item.tsx | 6 +- .../base/checkbox-with-label.tsx | 2 +- .../base/crawled-result-item.tsx | 2 +- .../website-crawl/base/crawled-result.tsx | 4 +- .../website-crawl/base/crawling.tsx | 6 +- .../website-crawl/base/error-message.tsx | 2 +- .../website-crawl/base/options/index.tsx | 2 +- .../processing/embedding-process/index.tsx | 2 +- .../create-from-pipeline/processing/index.tsx | 6 +- .../create-from-pipeline/step-indicator.tsx | 2 +- .../detail/batch-modal/csv-uploader.tsx | 2 +- .../detail/completed/child-segment-detail.tsx | 8 +- .../detail/completed/child-segment-list.tsx | 10 +- .../detail/completed/common/add-another.tsx | 2 +- .../detail/completed/common/batch-action.tsx | 6 +- .../detail/completed/common/chunk-content.tsx | 6 +- .../detail/completed/common/drawer.tsx | 2 +- .../completed/common/full-screen-drawer.tsx | 4 +- .../detail/completed/common/keywords.tsx | 2 +- .../completed/common/segment-index-tag.tsx | 2 +- .../detail/completed/common/summary-label.tsx | 4 +- .../detail/completed/common/summary-text.tsx | 4 +- .../documents/detail/completed/common/tag.tsx | 2 +- .../components/segment-list-content.tsx | 2 +- .../detail/completed/new-child-segment.tsx | 12 +- .../completed/segment-card/chunk-content.tsx | 6 +- .../detail/completed/segment-card/index.tsx | 4 +- .../detail/completed/segment-detail.tsx | 8 +- .../documents/detail/document-title.tsx | 2 +- .../embedding/components/progress-bar.tsx | 2 +- .../datasets/documents/detail/index.tsx | 8 +- .../metadata/components/doc-type-selector.tsx | 2 +- .../detail/metadata/components/field-info.tsx | 2 +- .../datasets/documents/detail/new-segment.tsx | 12 +- .../documents/detail/segment-add/index.tsx | 22 +- .../datasets/documents/status-item/index.tsx | 2 +- .../external-api/external-api-modal/Form.tsx | 4 +- .../external-api/external-api-panel/index.tsx | 2 +- .../create/RetrievalSettings.tsx | 2 +- .../datasets/extra-info/api-access/card.tsx | 4 +- .../datasets/extra-info/api-access/index.tsx | 6 +- .../datasets/extra-info/service-api/index.tsx | 4 +- .../formatted-text/flavours/edit-slice.tsx | 2 +- .../formatted-text/flavours/shared.tsx | 8 +- .../datasets/formatted-text/formatted.tsx | 2 +- .../components/chunk-detail-modal.tsx | 12 +- .../hit-testing/components/empty-records.tsx | 4 +- .../datasets/hit-testing/components/mask.tsx | 2 +- .../components/query-input/index.tsx | 4 +- .../components/query-input/textarea.tsx | 12 +- .../hit-testing/components/records.tsx | 8 +- .../components/result-item-external.tsx | 6 +- .../components/result-item-meta.tsx | 2 +- .../hit-testing/components/result-item.tsx | 4 +- .../datasets/hit-testing/components/score.tsx | 4 +- .../components/datasets/hit-testing/index.tsx | 8 +- .../components/dataset-card-footer.tsx | 4 +- .../components/dataset-card-header.tsx | 20 +- .../dataset-card/components/description.tsx | 4 +- .../components/operations-dropdown.tsx | 4 +- .../list/dataset-card/components/tag-area.tsx | 4 +- .../datasets/metadata/add-metadata-button.tsx | 2 +- .../datasets/metadata/base/date-picker.tsx | 4 +- .../metadata/edit-metadata-batch/add-row.tsx | 2 +- .../metadata/edit-metadata-batch/edit-row.tsx | 4 +- .../edit-metadata-batch/input-combined.tsx | 6 +- .../input-has-set-multiple-value.tsx | 8 +- .../metadata/edit-metadata-batch/label.tsx | 4 +- .../dataset-metadata-drawer.tsx | 4 +- .../metadata/metadata-document/index.tsx | 2 +- .../metadata/metadata-document/info-group.tsx | 8 +- .../components/datasets/preview/container.tsx | 6 +- .../components/datasets/preview/header.tsx | 4 +- .../datasets/rename-modal/index.tsx | 2 +- .../datasets/settings/index-method/index.tsx | 2 +- .../datasets/settings/option-card.tsx | 6 +- .../settings/permission-selector/index.tsx | 18 +- .../permission-selector/member-item.tsx | 8 +- web/app/components/develop/code.tsx | 12 +- web/app/components/develop/doc.tsx | 4 +- web/app/components/develop/md.tsx | 4 +- web/app/components/develop/tag.tsx | 4 +- web/app/components/develop/toc-panel.tsx | 10 +- web/app/components/explore/app-card/index.tsx | 2 +- web/app/components/explore/app-list/index.tsx | 2 +- .../components/explore/banner/banner-item.tsx | 18 +- .../explore/banner/indicator-button.tsx | 4 +- web/app/components/explore/category.tsx | 4 +- .../explore/item-operation/index.tsx | 4 +- .../explore/sidebar/app-nav-item/index.tsx | 6 +- web/app/components/explore/sidebar/index.tsx | 18 +- .../explore/sidebar/no-apps/index.tsx | 6 +- .../explore/try-app/__tests__/index.spec.tsx | 2 +- .../explore/try-app/app-info/index.tsx | 4 +- .../components/explore/try-app/app/chat.tsx | 4 +- .../explore/try-app/app/text-generation.tsx | 8 +- web/app/components/explore/try-app/index.tsx | 2 +- .../try-app/preview/flow-app-preview.tsx | 2 +- .../actions/__tests__/knowledge.spec.ts | 2 +- .../components/goto-anything/actions/app.tsx | 2 +- .../goto-anything/actions/knowledge.tsx | 2 +- .../header/account-dropdown/index.tsx | 16 +- .../account-dropdown/menu-item-content.tsx | 2 +- .../workplace-selector/index.tsx | 16 +- .../Integrations-page/index.tsx | 6 +- .../api-based-extension-page/empty.tsx | 6 +- .../header/account-setting/collapse/index.tsx | 4 +- .../data-source-page-new/card.tsx | 2 +- .../install-from-marketplace.tsx | 8 +- .../data-source-page-new/item.tsx | 2 +- .../header/account-setting/index.tsx | 2 +- .../edit-workspace-modal/index.tsx | 2 +- .../members-page/invite-modal/index.tsx | 2 +- .../invite-modal/role-selector.tsx | 10 +- .../members-page/operation/index.tsx | 18 +- .../operation/transfer-ownership.tsx | 8 +- .../member-selector.tsx | 14 +- .../header/account-setting/menu-dialog.tsx | 6 +- .../model-provider-page/index.tsx | 18 +- .../install-from-marketplace.tsx | 8 +- .../add-credential-in-load-balancing.tsx | 4 +- .../model-auth/add-custom-model.tsx | 2 +- .../model-auth/authorized/credential-item.tsx | 6 +- .../model-auth/authorized/index.tsx | 2 +- .../model-auth/config-model.tsx | 2 +- .../manage-custom-model-credentials.tsx | 2 +- .../switch-credential-in-load-balancing.tsx | 2 +- .../model-provider-page/model-badge/index.tsx | 4 +- .../model-provider-page/model-icon/index.tsx | 2 +- .../model-provider-page/model-modal/Form.tsx | 22 +- .../model-provider-page/model-name/index.tsx | 4 +- .../agent-model-trigger.tsx | 4 +- .../model-parameter-modal/index.tsx | 16 +- .../model-parameter-modal/parameter-item.tsx | 14 +- .../model-parameter-modal/trigger.tsx | 10 +- .../model-selector/feature-icon.tsx | 2 +- .../model-selector/model-selector-trigger.tsx | 8 +- .../model-selector/popup-item.tsx | 14 +- .../model-selector/popup.tsx | 6 +- .../provider-added-card/index.tsx | 18 +- .../model-auth-dropdown/api-key-section.tsx | 2 +- .../credits-exhausted-alert.tsx | 14 +- .../usage-priority-section.tsx | 10 +- .../provider-added-card/model-list-item.tsx | 6 +- .../model-load-balancing-configs.tsx | 6 +- .../model-load-balancing-modal.tsx | 2 +- .../provider-added-card/priority-selector.tsx | 2 +- .../provider-card-actions.tsx | 2 +- .../provider-added-card/quota-panel.tsx | 10 +- .../provider-added-card/system-quota-card.tsx | 2 +- .../provider-icon/index.tsx | 4 +- web/app/components/header/app-back/index.tsx | 6 +- .../components/header/app-selector/index.tsx | 12 +- .../components/header/explore-nav/index.tsx | 2 +- web/app/components/header/header-wrapper.tsx | 4 +- web/app/components/header/indicator/index.tsx | 2 +- web/app/components/header/nav/index.tsx | 4 +- .../header/nav/nav-selector/index.tsx | 14 +- .../components/header/plugins-nav/index.tsx | 6 +- web/app/components/header/tools-nav/index.tsx | 2 +- .../plugins/base/badges/icon-with-tooltip.tsx | 2 +- .../plugins/base/deprecation-notice.tsx | 6 +- .../plugins/base/key-value-item.tsx | 6 +- .../card/base/__tests__/placeholder.spec.tsx | 2 +- .../plugins/card/base/card-icon.tsx | 2 +- .../plugins/card/base/description.tsx | 2 +- .../components/plugins/card/base/org-info.tsx | 8 +- .../plugins/card/base/placeholder.tsx | 4 +- web/app/components/plugins/card/index.tsx | 6 +- .../install-plugin/base/loading-error.tsx | 8 +- .../install-plugin/install-bundle/index.tsx | 6 +- .../install-from-github/index.tsx | 8 +- .../install-from-local-package/index.tsx | 6 +- .../install-from-marketplace/index.tsx | 6 +- .../plugins/marketplace/empty/index.tsx | 16 +- .../plugins/marketplace/list/index.tsx | 2 +- .../marketplace/list/list-with-collection.tsx | 4 +- .../marketplace/plugin-type-switch.tsx | 4 +- .../search-box/__tests__/index.spec.tsx | 2 +- .../plugins/marketplace/search-box/index.tsx | 10 +- .../search-box/trigger/marketplace.tsx | 6 +- .../search-box/trigger/tool-selector.tsx | 10 +- .../sticky-search-and-switch-wrapper.tsx | 2 +- .../__tests__/add-oauth-button.spec.tsx | 2 +- .../authorize/add-oauth-button.tsx | 2 +- .../plugins/plugin-auth/authorize/index.tsx | 4 +- .../authorized-in-data-source-node.tsx | 2 +- .../plugin-auth/authorized-in-node.tsx | 2 +- .../plugins/plugin-auth/authorized/index.tsx | 2 +- .../plugins/plugin-auth/authorized/item.tsx | 4 +- .../plugin-auth/plugin-auth-in-agent.tsx | 2 +- .../plugins/plugin-auth/plugin-auth.tsx | 2 +- .../__tests__/detail-header.spec.tsx | 2 +- .../__tests__/endpoint-list.spec.tsx | 2 +- .../__tests__/index.spec.tsx | 2 +- .../__tests__/operation-dropdown.spec.tsx | 2 +- .../__tests__/strategy-detail.spec.tsx | 2 +- .../__tests__/strategy-item.spec.tsx | 2 +- .../__tests__/app-trigger.spec.tsx | 2 +- .../app-selector/app-inputs-panel.tsx | 4 +- .../app-selector/app-trigger.tsx | 6 +- .../detail-header/index.tsx | 2 +- .../plugin-detail-panel/endpoint-card.tsx | 2 +- .../plugin-detail-panel/endpoint-list.tsx | 10 +- .../plugin-detail-panel/endpoint-modal.tsx | 2 +- .../plugins/plugin-detail-panel/index.tsx | 4 +- .../model-selector/index.tsx | 4 +- .../model-selector/llm-params-panel.tsx | 4 +- .../multiple-tool-selector/index.tsx | 10 +- .../operation-dropdown.tsx | 2 +- .../plugin-detail-panel/strategy-detail.tsx | 18 +- .../plugin-detail-panel/strategy-item.tsx | 6 +- .../subscription-list/create/index.tsx | 2 +- .../subscription-list/list-view.tsx | 2 +- .../subscription-list/log-viewer.tsx | 10 +- .../subscription-list/selector-entry.tsx | 4 +- .../subscription-list/selector-view.tsx | 6 +- .../subscription-list/subscription-card.tsx | 6 +- .../__tests__/tool-credentials-form.spec.tsx | 2 +- .../components/reasoning-config-form.tsx | 12 +- .../components/tool-credentials-form.tsx | 2 +- .../tool-selector/components/tool-item.tsx | 2 +- .../tool-selector/components/tool-trigger.tsx | 6 +- .../tool-selector/index.tsx | 4 +- .../__tests__/event-detail-drawer.spec.tsx | 2 +- .../trigger/__tests__/event-list.spec.tsx | 2 +- .../trigger/event-detail-drawer.tsx | 18 +- .../trigger/event-list.tsx | 10 +- .../components/plugins/plugin-item/index.tsx | 18 +- .../__tests__/category-filter.spec.tsx | 2 +- .../filter-management/category-filter.tsx | 8 +- .../filter-management/tag-filter.tsx | 12 +- .../components/plugins/plugin-page/index.tsx | 2 +- .../plugin-page/install-plugin-dropdown.tsx | 2 +- .../components/task-status-indicator.tsx | 4 +- web/app/components/plugins/provider-card.tsx | 2 +- .../readme-panel/__tests__/entrance.spec.tsx | 2 +- .../plugins/readme-panel/entrance.tsx | 6 +- .../components/plugins/readme-panel/index.tsx | 6 +- .../auto-update-setting/index.tsx | 6 +- .../no-data-placeholder.tsx | 4 +- .../no-plugin-selected.tsx | 2 +- .../auto-update-setting/plugins-picker.tsx | 2 +- .../auto-update-setting/plugins-selected.tsx | 2 +- .../auto-update-setting/tool-picker.tsx | 2 +- .../plugins/reference-setting-modal/label.tsx | 4 +- .../update-plugin/from-market-place.tsx | 2 +- .../update-plugin/plugin-version-picker.tsx | 8 +- .../components/chunk-card-list/index.tsx | 2 +- .../panel/input-field/editor/index.tsx | 6 +- .../input-field/field-list/field-item.tsx | 8 +- .../field-list/field-list-container.tsx | 2 +- .../panel/input-field/field-list/index.tsx | 2 +- .../components/panel/input-field/index.tsx | 2 +- .../panel/input-field/preview/index.tsx | 2 +- .../data-source-options/option-card.tsx | 6 +- .../test-run/preparation/step-indicator.tsx | 2 +- .../panel/test-run/result/tabs/tab.tsx | 4 +- .../__tests__/run-mode.spec.tsx | 2 +- .../publisher/__tests__/popup.spec.tsx | 2 +- .../rag-pipeline-header/publisher/popup.tsx | 2 +- .../rag-pipeline-header/run-mode.tsx | 4 +- .../share/text-generation/index.tsx | 2 +- .../share/text-generation/info-modal.tsx | 6 +- .../share/text-generation/menu-dropdown.tsx | 10 +- .../run-batch/csv-reader/index.tsx | 4 +- .../share/text-generation/run-batch/index.tsx | 2 +- .../run-batch/res-download/index.tsx | 2 +- .../share/text-generation/run-once/index.tsx | 4 +- .../text-generation-result-panel.tsx | 14 +- .../text-generation-sidebar.tsx | 8 +- .../config-credentials.tsx | 2 +- .../edit-custom-collection-modal/index.tsx | 2 +- web/app/components/tools/labels/filter.tsx | 10 +- web/app/components/tools/labels/selector.tsx | 6 +- .../components/tools/mcp/detail/content.tsx | 2 +- .../tools/mcp/detail/list-loading.tsx | 2 +- .../tools/mcp/detail/operation-dropdown.tsx | 6 +- .../tools/mcp/detail/provider-detail.tsx | 4 +- .../components/tools/mcp/detail/tool-item.tsx | 12 +- .../components/tools/mcp/headers-input.tsx | 2 +- web/app/components/tools/mcp/index.tsx | 4 +- .../components/tools/mcp/mcp-server-modal.tsx | 2 +- .../components/tools/mcp/mcp-service-card.tsx | 2 +- web/app/components/tools/mcp/modal.tsx | 2 +- .../components/tools/mcp/provider-card.tsx | 2 +- .../mcp/sections/authentication-section.tsx | 4 +- web/app/components/tools/provider-list.tsx | 8 +- web/app/components/tools/provider/detail.tsx | 4 +- web/app/components/tools/provider/empty.tsx | 4 +- .../components/tools/provider/tool-item.tsx | 6 +- .../setting/build-in/config-credentials.tsx | 2 +- .../tools/workflow-tool/configure-button.tsx | 2 +- .../workflow-tool/confirm-modal/index.tsx | 2 +- .../components/tools/workflow-tool/index.tsx | 2 +- .../tools/workflow-tool/method-selector.tsx | 12 +- .../workflow-header/features-trigger.tsx | 2 +- .../start-node-selection-panel.tsx | 4 +- web/app/components/workflow/block-icon.tsx | 2 +- .../block-selector/all-start-blocks.tsx | 2 +- .../workflow/block-selector/all-tools.tsx | 2 +- .../workflow/block-selector/data-sources.tsx | 6 +- .../workflow/block-selector/index-bar.tsx | 4 +- .../market-place-plugin/action.tsx | 2 +- .../market-place-plugin/item.tsx | 12 +- .../market-place-plugin/list.tsx | 10 +- .../rag-tool-recommendations/list.tsx | 2 +- .../workflow/block-selector/tabs.tsx | 4 +- .../workflow/block-selector/tool-picker.tsx | 2 +- .../block-selector/tool/action-item.tsx | 8 +- .../workflow/block-selector/tool/tool.tsx | 6 +- .../workflow/block-selector/tools.tsx | 2 +- .../trigger-plugin/action-item.tsx | 8 +- .../block-selector/trigger-plugin/item.tsx | 6 +- .../block-selector/view-type-select.tsx | 4 +- web/app/components/workflow/custom-edge.tsx | 2 +- .../workflow/dsl-export-confirm-modal.tsx | 4 +- .../workflow/header/chat-variable-button.tsx | 2 +- .../workflow/header/checklist/index.tsx | 14 +- .../header/checklist/item-indicator.tsx | 2 +- .../workflow/header/checklist/node-group.tsx | 8 +- .../header/checklist/plugin-group.tsx | 2 +- .../components/workflow/header/env-button.tsx | 2 +- .../header/global-variable-button.tsx | 2 +- .../workflow/header/header-in-restoring.tsx | 2 +- .../workflow/header/run-and-history.tsx | 2 +- .../components/workflow/header/run-mode.tsx | 6 +- .../header/scroll-to-selected-node-button.tsx | 4 +- .../components/workflow/header/undo-redo.tsx | 6 +- .../header/version-history-button.tsx | 2 +- .../workflow/header/view-history.tsx | 14 +- .../workflow/header/view-workflow-history.tsx | 10 +- web/app/components/workflow/index.tsx | 2 +- .../nodes/_base/components/add-button.tsx | 2 +- .../components/agent-strategy-selector.tsx | 4 +- .../components/before-run-form/form-item.tsx | 10 +- .../_base/components/before-run-form/form.tsx | 2 +- .../components/before-run-form/index.tsx | 2 +- .../components/code-generator-button.tsx | 2 +- .../nodes/_base/components/collapse/index.tsx | 2 +- .../nodes/_base/components/editor/base.tsx | 4 +- .../code-editor/editor-support-vars.tsx | 2 +- .../components/editor/code-editor/index.tsx | 4 +- .../error-handle/error-handle-on-node.tsx | 4 +- .../error-handle/fail-branch-card.tsx | 6 +- .../workflow/nodes/_base/components/field.tsx | 6 +- .../nodes/_base/components/file-type-item.tsx | 8 +- .../_base/components/form-input-boolean.tsx | 10 +- .../components/form-input-item.sections.tsx | 6 +- .../_base/components/form-input-item.tsx | 2 +- .../components/form-input-type-switch.tsx | 4 +- .../workflow/nodes/_base/components/group.tsx | 4 +- .../components/input-support-select-var.tsx | 4 +- .../components/install-plugin-button.tsx | 2 +- .../nodes/_base/components/layout/box.tsx | 2 +- .../_base/components/layout/field-title.tsx | 6 +- .../nodes/_base/components/layout/group.tsx | 2 +- .../components/list-no-data-placeholder.tsx | 2 +- .../nodes/_base/components/memory-config.tsx | 6 +- .../mixed-variable-text-input/index.tsx | 2 +- .../_base/components/next-step/container.tsx | 6 +- .../nodes/_base/components/next-step/item.tsx | 2 +- .../nodes/_base/components/node-handle.tsx | 8 +- .../nodes/_base/components/node-resizer.tsx | 4 +- .../_base/components/node-status-icon.tsx | 2 +- .../nodes/_base/components/option-card.tsx | 6 +- .../nodes/_base/components/output-vars.tsx | 6 +- .../nodes/_base/components/prompt/editor.tsx | 12 +- .../readonly-input-with-select-var.tsx | 4 +- .../_base/components/retry/retry-on-node.tsx | 4 +- .../nodes/_base/components/selector.tsx | 8 +- .../nodes/_base/components/setting-item.tsx | 8 +- .../workflow/nodes/_base/components/split.tsx | 2 +- .../components/support-var-input/index.tsx | 2 +- .../components/switch-plugin-version.tsx | 2 +- .../object-child-tree-panel/picker/field.tsx | 6 +- .../object-child-tree-panel/picker/index.tsx | 6 +- .../object-child-tree-panel/show/field.tsx | 14 +- .../tree-indent-line.tsx | 4 +- .../_base/components/variable/var-list.tsx | 4 +- .../variable/var-reference-picker.trigger.tsx | 4 +- .../variable/var-reference-picker.tsx | 2 +- .../variable/var-reference-vars.tsx | 24 +- .../components/variable/var-type-picker.tsx | 4 +- .../variable-label/base/variable-icon.tsx | 2 +- .../variable-label/base/variable-label.tsx | 6 +- .../variable-label/base/variable-name.tsx | 4 +- .../variable-icon-with-color.tsx | 2 +- .../variable-label-in-editor.tsx | 2 +- .../variable-label/variable-label-in-node.tsx | 2 +- .../variable-label/variable-label-in-text.tsx | 2 +- .../_base/components/workflow-panel/index.tsx | 20 +- .../workflow-panel/trigger-subscription.tsx | 2 +- .../components/workflow/nodes/_base/node.tsx | 10 +- .../nodes/agent/components/tool-icon.tsx | 6 +- .../components/operation-selector.tsx | 10 +- .../nodes/data-source-empty/index.tsx | 2 +- .../nodes/http/components/api-input.tsx | 6 +- .../http/components/authorization/index.tsx | 2 +- .../components/authorization/radio-group.tsx | 6 +- .../nodes/http/components/edit-body/index.tsx | 4 +- .../key-value/key-value-edit/index.tsx | 6 +- .../key-value/key-value-edit/input-item.tsx | 4 +- .../key-value/key-value-edit/item.tsx | 4 +- .../components/workflow/nodes/http/panel.tsx | 6 +- .../components/button-style-dropdown.tsx | 2 +- .../delivery-method/email-configure-modal.tsx | 4 +- .../components/delivery-method/index.tsx | 2 +- .../delivery-method/mail-body-input.tsx | 2 +- .../delivery-method/method-item.tsx | 6 +- .../delivery-method/method-selector.tsx | 46 +- .../delivery-method/recipient/email-input.tsx | 4 +- .../delivery-method/recipient/email-item.tsx | 4 +- .../delivery-method/recipient/index.tsx | 12 +- .../delivery-method/recipient/member-list.tsx | 8 +- .../recipient/member-selector.tsx | 2 +- .../delivery-method/test-email-sender.tsx | 2 +- .../delivery-method/upgrade-modal.tsx | 2 +- .../human-input/components/form-content.tsx | 10 +- .../nodes/human-input/components/timeout.tsx | 8 +- .../components/variable-in-markdown.tsx | 2 +- .../workflow/nodes/human-input/node.tsx | 14 +- .../workflow/nodes/human-input/panel.tsx | 4 +- .../condition-list/condition-item.tsx | 10 +- .../condition-list/condition-operator.tsx | 2 +- .../components/condition-list/index.tsx | 10 +- .../components/condition-number-input.tsx | 2 +- .../if-else/components/condition-wrap.tsx | 4 +- .../workflow/nodes/iteration/add-block.tsx | 8 +- .../workflow/nodes/iteration/node.tsx | 2 +- .../components/chunk-structure/hooks.tsx | 2 +- .../chunk-structure/instruction/index.tsx | 10 +- .../components/index-method.tsx | 4 +- .../knowledge-base/components/option-card.tsx | 10 +- .../search-method-option.tsx | 8 +- .../workflow/nodes/knowledge-base/node.tsx | 8 +- .../condition-list/condition-date.tsx | 6 +- .../condition-list/condition-item.tsx | 8 +- .../condition-list/condition-operator.tsx | 2 +- .../condition-list/condition-value-method.tsx | 2 +- .../metadata/condition-list/index.tsx | 10 +- .../components/metadata/metadata-icon.tsx | 2 +- .../components/retrieval-config.tsx | 2 +- .../components/extract-input.tsx | 4 +- .../components/filter-condition.tsx | 2 +- .../list-operator/components/limit-config.tsx | 2 +- .../components/sub-variable-picker.tsx | 6 +- .../nodes/llm/components/config-prompt.tsx | 4 +- .../json-schema-config-modal/code-editor.tsx | 6 +- .../error-message.tsx | 4 +- .../json-importer.tsx | 2 +- .../json-schema-config.tsx | 2 +- .../json-schema-generator/index.tsx | 2 +- .../schema-editor.tsx | 4 +- .../edit-card/auto-width-input.tsx | 6 +- .../visual-editor/edit-card/index.tsx | 8 +- .../visual-editor/edit-card/type-selector.tsx | 4 +- .../visual-editor/index.tsx | 2 +- .../visual-editor/schema-node.tsx | 2 +- .../llm/components/prompt-generator-btn.tsx | 2 +- .../nodes/llm/components/structure-output.tsx | 4 +- .../workflow/nodes/loop/add-block.tsx | 10 +- .../condition-list/condition-item.tsx | 8 +- .../condition-list/condition-operator.tsx | 2 +- .../loop/components/condition-list/index.tsx | 10 +- .../components/condition-number-input.tsx | 2 +- .../nodes/loop/components/condition-wrap.tsx | 4 +- .../loop/components/loop-variables/empty.tsx | 2 +- .../components/loop-variables/form-item.tsx | 2 +- .../workflow/nodes/loop/insert-block.tsx | 4 +- .../components/workflow/nodes/loop/node.tsx | 2 +- .../extract-parameter/import-from-tool.tsx | 2 +- .../components/class-list.tsx | 8 +- .../nodes/start/components/var-item.tsx | 2 +- .../nodes/start/components/var-list.tsx | 6 +- .../nodes/tool/components/input-var-list.tsx | 2 +- .../mixed-variable-text-input/index.tsx | 2 +- .../mixed-variable-text-input/placeholder.tsx | 6 +- .../components/workflow/nodes/tool/node.tsx | 8 +- .../components/generic-table.tsx | 16 +- .../components/paragraph-input.tsx | 8 +- .../components/add-variable/index.tsx | 2 +- .../components/node-group-item.tsx | 8 +- .../components/node-variable-item.tsx | 4 +- .../nodes/variable-assigner/panel.tsx | 2 +- .../components/workflow/note-node/index.tsx | 4 +- .../plugins/link-editor-plugin/component.tsx | 2 +- .../note-editor/toolbar/color-picker.tsx | 6 +- .../note-node/note-editor/toolbar/command.tsx | 2 +- .../toolbar/font-size-selector.tsx | 6 +- .../note-editor/toolbar/operator.tsx | 2 +- .../workflow/operator/add-block.tsx | 2 +- .../components/workflow/operator/control.tsx | 2 +- .../workflow/operator/more-actions.tsx | 14 +- .../workflow/operator/zoom-in-out.tsx | 8 +- .../components/workflow/panel-contextmenu.tsx | 2 +- .../components/array-bool-list.tsx | 2 +- .../components/variable-item.tsx | 10 +- .../components/variable-modal.sections.tsx | 2 +- .../components/variable-modal.tsx | 2 +- .../components/variable-type-select.tsx | 12 +- .../panel/chat-variable-panel/index.tsx | 26 +- .../conversation-variable-modal.tsx | 18 +- .../panel/debug-and-preview/index.tsx | 8 +- .../panel/debug-and-preview/user-input.tsx | 4 +- .../workflow/panel/env-panel/env-item.tsx | 14 +- .../workflow/panel/env-panel/index.tsx | 8 +- .../panel/env-panel/variable-modal.tsx | 8 +- .../panel/global-variable-panel/index.tsx | 8 +- .../panel/global-variable-panel/item.tsx | 8 +- web/app/components/workflow/panel/index.tsx | 4 +- .../context-menu/menu-item.tsx | 6 +- .../version-history-panel/filter/index.tsx | 4 +- .../version-history-panel/loading/item.tsx | 6 +- .../version-history-item.tsx | 16 +- .../workflow/panel/workflow-preview.tsx | 2 +- .../workflow/run/agent-log/agent-log-item.tsx | 4 +- .../run/agent-log/agent-log-trigger.tsx | 10 +- web/app/components/workflow/run/index.tsx | 8 +- .../iteration-log/iteration-result-panel.tsx | 6 +- .../run/loop-log/loop-result-panel.tsx | 4 +- .../workflow/run/loop-result-panel.tsx | 10 +- web/app/components/workflow/run/node.tsx | 16 +- .../workflow/run/status-container.tsx | 6 +- web/app/components/workflow/run/status.tsx | 18 +- .../components/workflow/run/tracing-panel.tsx | 6 +- .../components/workflow/shortcuts-name.tsx | 4 +- .../components/workflow/simple-node/index.tsx | 6 +- .../variable-inspect/display-content.tsx | 10 +- .../workflow/variable-inspect/empty.tsx | 4 +- .../workflow/variable-inspect/group.tsx | 2 +- .../workflow/variable-inspect/index.tsx | 2 +- .../variable-inspect/large-data-alert.tsx | 6 +- .../workflow/variable-inspect/left.tsx | 2 +- .../workflow/variable-inspect/listening.tsx | 2 +- .../workflow/variable-inspect/panel.tsx | 10 +- .../workflow/variable-inspect/right.tsx | 12 +- .../workflow/variable-inspect/trigger.tsx | 10 +- .../value-content-sections.tsx | 4 +- .../variable-inspect/value-content.tsx | 2 +- .../components/error-handle-on-node.tsx | 4 +- .../components/node-handle.tsx | 6 +- .../components/nodes/base.tsx | 12 +- .../components/nodes/iteration/node.tsx | 2 +- .../components/nodes/loop/node.tsx | 2 +- .../components/note-node/index.tsx | 2 +- .../components/zoom-in-out.tsx | 6 +- .../workflow/workflow-preview/index.tsx | 2 +- web/app/education-apply/role-selector.tsx | 6 +- .../forgot-password/ChangePasswordForm.tsx | 6 +- web/app/forgot-password/page.tsx | 2 +- web/app/init/page.tsx | 2 +- web/app/install/installForm.tsx | 6 +- web/app/install/page.tsx | 2 +- web/app/reset-password/layout.tsx | 6 +- web/app/reset-password/set-password/page.tsx | 2 +- web/app/signin/components/social-auth.tsx | 2 +- web/app/signin/layout.tsx | 6 +- web/app/signin/normal-form.tsx | 2 +- web/app/signin/split.tsx | 2 +- web/app/signup/layout.tsx | 6 +- web/app/signup/set-password/page.tsx | 2 +- web/app/styles/globals.css | 849 +------ web/app/styles/markdown.css | 3 +- web/docs/test.md | 2 - web/eslint-suppressions.json | 2200 ----------------- web/package.json | 3 +- web/scripts/gen-icons.mjs | 2 +- web/tailwind-common-config.ts | 86 +- web/themes/markdown-light.css | 44 - .../{markdown-dark.css => markdown.css} | 1 + 990 files changed, 3065 insertions(+), 5786 deletions(-) create mode 100644 packages/dify-ui/AGENTS.md create mode 100644 packages/dify-ui/package.json rename web/utils/classnames.ts => packages/dify-ui/src/cn.ts (100%) create mode 100644 packages/dify-ui/src/styles/styles.css create mode 100644 packages/dify-ui/src/styles/utilities.css create mode 100644 packages/dify-ui/src/tailwind-preset.ts rename {web => packages/dify-ui/src}/themes/dark.css (100%) rename {web => packages/dify-ui/src}/themes/light.css (100%) rename {web => packages/dify-ui/src}/themes/tailwind-theme-var-define.ts (100%) create mode 100644 packages/dify-ui/tsconfig.json delete mode 100644 web/app/components/base/modal/index.css delete mode 100644 web/themes/markdown-light.css rename web/themes/{markdown-dark.css => markdown.css} (98%) diff --git a/packages/dify-ui/AGENTS.md b/packages/dify-ui/AGENTS.md new file mode 100644 index 0000000000..6875f8b4e9 --- /dev/null +++ b/packages/dify-ui/AGENTS.md @@ -0,0 +1,27 @@ +# @langgenius/dify-ui + +This package provides shared design tokens (colors, shadows, typography), the `cn()` utility, and a Tailwind CSS preset consumed by `web/`. + +## Border Radius: Figma Token → Tailwind Class Mapping + +The Figma design system uses `--radius/*` tokens whose scale is **offset by one step** from Tailwind CSS v4 defaults. When translating Figma specs to code, always use this mapping — never use `radius-*` as a CSS class, and never extend `borderRadius` in the preset. + +| Figma Token | Value | Tailwind Class | +|---|---|---| +| `--radius/2xs` | 2px | `rounded-xs` | +| `--radius/xs` | 4px | `rounded-sm` | +| `--radius/sm` | 6px | `rounded-md` | +| `--radius/md` | 8px | `rounded-lg` | +| `--radius/lg` | 10px | `rounded-[10px]` | +| `--radius/xl` | 12px | `rounded-xl` | +| `--radius/2xl` | 16px | `rounded-2xl` | +| `--radius/3xl` | 20px | `rounded-[20px]` | +| `--radius/6xl` | 28px | `rounded-[28px]` | +| `--radius/full` | 999px | `rounded-full` | + +### Rules + +- **Do not** add custom `borderRadius` values to `tailwind-preset.ts`. We use Tailwind v4 defaults and arbitrary values (`rounded-[Npx]`) for sizes without a standard equivalent. +- **Do not** use `radius-*` as CSS class names. The old `@utility radius-*` definitions have been removed. +- When the Figma MCP returns `rounded-[var(--radius/sm, 6px)]`, convert it to the standard Tailwind class from the table above (e.g. `rounded-md`). +- For values without a standard Tailwind equivalent (10px, 20px, 28px), use arbitrary values like `rounded-[10px]`. diff --git a/packages/dify-ui/package.json b/packages/dify-ui/package.json new file mode 100644 index 0000000000..d8314a6be3 --- /dev/null +++ b/packages/dify-ui/package.json @@ -0,0 +1,24 @@ +{ + "name": "@langgenius/dify-ui", + "version": "0.0.1", + "private": true, + "type": "module", + "exports": { + "./styles.css": "./src/styles/styles.css", + "./tailwind-preset": { + "import": "./src/tailwind-preset.ts", + "types": "./src/tailwind-preset.ts" + }, + "./cn": { + "import": "./src/cn.ts", + "types": "./src/cn.ts" + } + }, + "dependencies": { + "clsx": "catalog:", + "tailwind-merge": "catalog:" + }, + "devDependencies": { + "tailwindcss": "catalog:" + } +} diff --git a/web/utils/classnames.ts b/packages/dify-ui/src/cn.ts similarity index 100% rename from web/utils/classnames.ts rename to packages/dify-ui/src/cn.ts diff --git a/packages/dify-ui/src/styles/styles.css b/packages/dify-ui/src/styles/styles.css new file mode 100644 index 0000000000..a712e9c5db --- /dev/null +++ b/packages/dify-ui/src/styles/styles.css @@ -0,0 +1,3 @@ +@import '../themes/light.css' layer(base); +@import '../themes/dark.css' layer(base); +@import './utilities.css'; diff --git a/packages/dify-ui/src/styles/utilities.css b/packages/dify-ui/src/styles/utilities.css new file mode 100644 index 0000000000..69b15d4c10 --- /dev/null +++ b/packages/dify-ui/src/styles/utilities.css @@ -0,0 +1,272 @@ +@utility system-kbd { + font-size: 12px; + font-weight: 500; + line-height: 16px; +} + +@utility system-2xs-regular-uppercase { + font-size: 10px; + font-weight: 400; + text-transform: uppercase; + line-height: 12px; +} + +@utility system-2xs-regular { + font-size: 10px; + font-weight: 400; + line-height: 12px; +} + +@utility system-2xs-medium { + font-size: 10px; + font-weight: 500; + line-height: 12px; +} + +@utility system-2xs-medium-uppercase { + font-size: 10px; + font-weight: 500; + text-transform: uppercase; + line-height: 12px; +} + +@utility system-2xs-semibold-uppercase { + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + line-height: 12px; +} + +@utility system-xs-regular { + font-size: 12px; + font-weight: 400; + line-height: 16px; +} + +@utility system-xs-regular-uppercase { + font-size: 12px; + font-weight: 400; + text-transform: uppercase; + line-height: 16px; +} + +@utility system-xs-medium { + font-size: 12px; + font-weight: 500; + line-height: 16px; +} + +@utility system-xs-medium-uppercase { + font-size: 12px; + font-weight: 500; + text-transform: uppercase; + line-height: 16px; +} + +@utility system-xs-semibold { + font-size: 12px; + font-weight: 600; + line-height: 16px; +} + +@utility system-xs-semibold-uppercase { + font-size: 12px; + font-weight: 600; + text-transform: uppercase; + line-height: 16px; +} + +@utility system-sm-regular { + font-size: 13px; + font-weight: 400; + line-height: 16px; +} + +@utility system-sm-medium { + font-size: 13px; + font-weight: 500; + line-height: 16px; +} + +@utility system-sm-medium-uppercase { + font-size: 13px; + font-weight: 500; + text-transform: uppercase; + line-height: 16px; +} + +@utility system-sm-semibold { + font-size: 13px; + font-weight: 600; + line-height: 16px; +} + +@utility system-sm-semibold-uppercase { + font-size: 13px; + font-weight: 600; + text-transform: uppercase; + line-height: 16px; +} + +@utility system-md-regular { + font-size: 14px; + font-weight: 400; + line-height: 20px; +} + +@utility system-md-medium { + font-size: 14px; + font-weight: 500; + line-height: 20px; +} + +@utility system-md-semibold { + font-size: 14px; + font-weight: 600; + line-height: 20px; +} + +@utility system-md-semibold-uppercase { + font-size: 14px; + font-weight: 600; + text-transform: uppercase; + line-height: 20px; +} + +@utility system-xl-medium { + font-size: 16px; + font-weight: 500; + line-height: 24px; +} + +@utility system-xl-semibold { + font-size: 16px; + font-weight: 600; + line-height: 24px; +} + +@utility code-xs-regular { + font-size: 12px; + font-weight: 400; + line-height: 1.5; +} + +@utility code-sm-regular { + font-size: 13px; + font-weight: 400; + line-height: 1.5; +} + +@utility code-sm-semibold { + font-size: 13px; + font-weight: 600; + line-height: 1.5; +} + +@utility body-xs-regular { + font-size: 12px; + font-weight: 400; + line-height: 16px; +} + +@utility body-xs-medium { + font-size: 12px; + font-weight: 500; + line-height: 16px; +} + +@utility body-sm-regular { + font-size: 13px; + font-weight: 400; + line-height: 16px; +} + +@utility body-sm-medium { + font-size: 13px; + font-weight: 500; + line-height: 16px; +} + +@utility body-md-regular { + font-size: 14px; + font-weight: 400; + line-height: 20px; +} + +@utility body-md-medium { + font-size: 14px; + font-weight: 500; + line-height: 20px; +} + +@utility body-lg-regular { + font-size: 15px; + font-weight: 400; + line-height: 20px; +} + +@utility body-2xl-regular { + font-size: 18px; + font-weight: 400; + line-height: 1.5; +} + +@utility title-xs-semi-bold { + font-size: 12px; + font-weight: 600; + line-height: 16px; +} + +@utility title-sm-semi-bold { + font-size: 13px; + font-weight: 600; + line-height: 16px; +} + +@utility title-md-semi-bold { + font-size: 14px; + font-weight: 600; + line-height: 20px; +} + +@utility title-lg-bold { + font-size: 15px; + font-weight: 700; + line-height: 1.2; +} + +@utility title-xl-semi-bold { + font-size: 16px; + font-weight: 600; + line-height: 1.2; +} + +@utility title-2xl-semi-bold { + font-size: 18px; + font-weight: 600; + line-height: 1.2; +} + +@utility title-3xl-semi-bold { + font-size: 20px; + font-weight: 600; + line-height: 1.2; +} + +@utility title-3xl-bold { + font-size: 20px; + font-weight: 700; + line-height: 1.2; +} + +@utility title-4xl-semi-bold { + font-size: 24px; + font-weight: 600; + line-height: 1.2; +} + +@utility title-5xl-bold { + font-size: 30px; + font-weight: 700; + line-height: 1.2; +} diff --git a/packages/dify-ui/src/tailwind-preset.ts b/packages/dify-ui/src/tailwind-preset.ts new file mode 100644 index 0000000000..2dbf4781b0 --- /dev/null +++ b/packages/dify-ui/src/tailwind-preset.ts @@ -0,0 +1,87 @@ +import tailwindThemeVarDefine from './themes/tailwind-theme-var-define' + +const difyUIPreset = { + theme: { + extend: { + colors: { + gray: { + 25: '#fcfcfd', + 50: '#f9fafb', + 100: '#f2f4f7', + 200: '#eaecf0', + 300: '#d0d5dd', + 400: '#98a2b3', + 500: '#667085', + 600: '#344054', + 700: '#475467', + 800: '#1d2939', + 900: '#101828', + }, + primary: { + 25: '#f5f8ff', + 50: '#eff4ff', + 100: '#d1e0ff', + 200: '#b2ccff', + 300: '#84adff', + 400: '#528bff', + 500: '#2970ff', + 600: '#155eef', + 700: '#004eeb', + 800: '#0040c1', + 900: '#00359e', + }, + blue: { + 500: '#E1EFFE', + }, + green: { + 50: '#F3FAF7', + 100: '#DEF7EC', + 800: '#03543F', + }, + yellow: { + 100: '#FDF6B2', + 800: '#723B13', + }, + purple: { + 50: '#F6F5FF', + 200: '#DCD7FE', + }, + indigo: { + 25: '#F5F8FF', + 50: '#EEF4FF', + 100: '#E0EAFF', + 300: '#A4BCFD', + 400: '#8098F9', + 600: '#444CE7', + 800: '#2D31A6', + }, + ...tailwindThemeVarDefine, + }, + boxShadow: { + 'xs': '0px 1px 2px 0px rgba(16, 24, 40, 0.05)', + 'sm': '0px 1px 2px 0px rgba(16, 24, 40, 0.06), 0px 1px 3px 0px rgba(16, 24, 40, 0.10)', + 'sm-no-bottom': '0px -1px 2px 0px rgba(16, 24, 40, 0.06), 0px -1px 3px 0px rgba(16, 24, 40, 0.10)', + 'md': '0px 2px 4px -2px rgba(16, 24, 40, 0.06), 0px 4px 8px -2px rgba(16, 24, 40, 0.10)', + 'lg': '0px 4px 6px -2px rgba(16, 24, 40, 0.03), 0px 12px 16px -4px rgba(16, 24, 40, 0.08)', + 'xl': '0px 8px 8px -4px rgba(16, 24, 40, 0.03), 0px 20px 24px -4px rgba(16, 24, 40, 0.08)', + '2xl': '0px 24px 48px -12px rgba(16, 24, 40, 0.18)', + '3xl': '0px 32px 64px -12px rgba(16, 24, 40, 0.14)', + 'status-indicator-green-shadow': '0px 2px 6px 0px var(--color-components-badge-status-light-success-halo), 0px 0px 0px 1px var(--color-components-badge-status-light-border-outer)', + 'status-indicator-warning-shadow': '0px 2px 6px 0px var(--color-components-badge-status-light-warning-halo), 0px 0px 0px 1px var(--color-components-badge-status-light-border-outer)', + 'status-indicator-red-shadow': '0px 2px 6px 0px var(--color-components-badge-status-light-error-halo), 0px 0px 0px 1px var(--color-components-badge-status-light-border-outer)', + 'status-indicator-blue-shadow': '0px 2px 6px 0px var(--color-components-badge-status-light-normal-halo), 0px 0px 0px 1px var(--color-components-badge-status-light-border-outer)', + 'status-indicator-gray-shadow': '0px 1px 2px 0px var(--color-components-badge-status-light-disabled-halo), 0px 0px 0px 1px var(--color-components-badge-status-light-border-outer)', + }, + opacity: { + 2: '0.02', + 8: '0.08', + }, + fontSize: { + '2xs': '0.625rem', + }, + }, + }, + plugins: [], +} + +export default difyUIPreset diff --git a/web/themes/dark.css b/packages/dify-ui/src/themes/dark.css similarity index 100% rename from web/themes/dark.css rename to packages/dify-ui/src/themes/dark.css diff --git a/web/themes/light.css b/packages/dify-ui/src/themes/light.css similarity index 100% rename from web/themes/light.css rename to packages/dify-ui/src/themes/light.css diff --git a/web/themes/tailwind-theme-var-define.ts b/packages/dify-ui/src/themes/tailwind-theme-var-define.ts similarity index 100% rename from web/themes/tailwind-theme-var-define.ts rename to packages/dify-ui/src/themes/tailwind-theme-var-define.ts diff --git a/packages/dify-ui/tsconfig.json b/packages/dify-ui/tsconfig.json new file mode 100644 index 0000000000..3e912baba0 --- /dev/null +++ b/packages/dify-ui/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "dist", + "rootDir": "src", + "isolatedModules": true, + "verbatimModuleSyntax": true + }, + "include": ["src"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4444981601..094faf78cb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -599,6 +599,19 @@ importers: specifier: 'catalog:' version: 0.1.16(@types/node@25.6.0)(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.6.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(happy-dom@20.9.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3) + packages/dify-ui: + dependencies: + clsx: + specifier: 'catalog:' + version: 2.1.1 + tailwind-merge: + specifier: 'catalog:' + version: 3.5.0 + devDependencies: + tailwindcss: + specifier: 'catalog:' + version: 4.2.2 + packages/iconify-collections: devDependencies: iconify-import-svg: @@ -739,9 +752,6 @@ importers: client-only: specifier: 'catalog:' version: 0.0.1 - clsx: - specifier: 'catalog:' - version: 2.1.1 cmdk: specifier: 'catalog:' version: 1.1.1(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) @@ -922,9 +932,6 @@ importers: string-ts: specifier: 'catalog:' version: 2.3.1 - tailwind-merge: - specifier: 'catalog:' - version: 3.5.0 tldts: specifier: 'catalog:' version: 7.0.28 @@ -971,6 +978,9 @@ importers: '@iconify-json/ri': specifier: 'catalog:' version: 1.2.10 + '@langgenius/dify-ui': + specifier: workspace:* + version: link:../packages/dify-ui '@mdx-js/loader': specifier: 'catalog:' version: 3.1.1(webpack@5.105.4(uglify-js@3.19.3)) @@ -1126,7 +1136,7 @@ importers: version: 4.12.12 knip: specifier: 'catalog:' - version: 6.4.1(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1) + version: 6.4.1(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2) postcss: specifier: 'catalog:' version: 8.5.9 @@ -1553,14 +1563,17 @@ packages: peerDependencies: tailwindcss: '*' - '@emnapi/core@1.9.1': - resolution: {integrity: sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==} + '@emnapi/core@1.9.2': + resolution: {integrity: sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==} '@emnapi/runtime@1.9.1': resolution: {integrity: sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==} - '@emnapi/wasi-threads@1.2.0': - resolution: {integrity: sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==} + '@emnapi/runtime@1.9.2': + resolution: {integrity: sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==} + + '@emnapi/wasi-threads@1.2.1': + resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} '@emoji-mart/data@1.2.1': resolution: {integrity: sha512-no2pQMWiBy6gpBEiqGeU77/bFejDqUTRY7KX+0+iur13op3bqUsXdnwoZs6Xb1zbv0gAj5VvS1PWoUUckSr5Dw==} @@ -8007,6 +8020,10 @@ packages: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} + tinyglobby@0.2.16: + resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} + engines: {node: '>=12.0.0'} + tinypool@2.1.0: resolution: {integrity: sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw==} engines: {node: ^20.0.0 || >=22.0.0} @@ -9153,9 +9170,9 @@ snapshots: '@iconify/utils': 3.1.0 tailwindcss: 4.2.2 - '@emnapi/core@1.9.1': + '@emnapi/core@1.9.2': dependencies: - '@emnapi/wasi-threads': 1.2.0 + '@emnapi/wasi-threads': 1.2.1 tslib: 2.8.1 optional: true @@ -9164,7 +9181,12 @@ snapshots: tslib: 2.8.1 optional: true - '@emnapi/wasi-threads@1.2.0': + '@emnapi/runtime@1.9.2': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.2.1': dependencies: tslib: 2.8.1 optional: true @@ -9944,10 +9966,10 @@ snapshots: react: 19.2.5 react-dom: 19.2.5(react@19.2.5) - '@napi-rs/wasm-runtime@1.1.2(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)': + '@napi-rs/wasm-runtime@1.1.2(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)': dependencies: - '@emnapi/core': 1.9.1 - '@emnapi/runtime': 1.9.1 + '@emnapi/core': 1.9.2 + '@emnapi/runtime': 1.9.2 '@tybys/wasm-util': 0.10.1 optional: true @@ -10120,9 +10142,9 @@ snapshots: '@oxc-parser/binding-openharmony-arm64@0.121.0': optional: true - '@oxc-parser/binding-wasm32-wasi@0.121.0(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)': + '@oxc-parser/binding-wasm32-wasi@0.121.0(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)': dependencies: - '@napi-rs/wasm-runtime': 1.1.2(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1) + '@napi-rs/wasm-runtime': 1.1.2(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2) transitivePeerDependencies: - '@emnapi/core' - '@emnapi/runtime' @@ -10191,9 +10213,9 @@ snapshots: '@oxc-resolver/binding-openharmony-arm64@11.19.1': optional: true - '@oxc-resolver/binding-wasm32-wasi@11.19.1(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)': + '@oxc-resolver/binding-wasm32-wasi@11.19.1(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)': dependencies: - '@napi-rs/wasm-runtime': 1.1.2(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1) + '@napi-rs/wasm-runtime': 1.1.2(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2) transitivePeerDependencies: - '@emnapi/core' - '@emnapi/runtime' @@ -11758,7 +11780,7 @@ snapshots: debug: 4.4.3(supports-color@8.1.1) minimatch: 10.2.4 semver: 7.7.4 - tinyglobby: 0.2.15 + tinyglobby: 0.2.16 ts-api-utils: 2.5.0(typescript@6.0.2) typescript: 6.0.2 transitivePeerDependencies: @@ -14140,7 +14162,7 @@ snapshots: khroma@2.1.0: {} - knip@6.4.1(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1): + knip@6.4.1(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2): dependencies: '@nodelib/fs.walk': 1.2.8 fast-glob: 3.3.3 @@ -14148,8 +14170,8 @@ snapshots: get-tsconfig: 4.13.7 jiti: 2.6.1 minimist: 1.2.8 - oxc-parser: 0.121.0(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1) - oxc-resolver: 11.19.1(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1) + oxc-parser: 0.121.0(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2) + oxc-resolver: 11.19.1(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2) picocolors: 1.1.1 picomatch: 4.0.4 smol-toml: 1.6.1 @@ -15056,7 +15078,7 @@ snapshots: type-check: 0.4.0 word-wrap: 1.2.5 - oxc-parser@0.121.0(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1): + oxc-parser@0.121.0(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2): dependencies: '@oxc-project/types': 0.121.0 optionalDependencies: @@ -15076,7 +15098,7 @@ snapshots: '@oxc-parser/binding-linux-x64-gnu': 0.121.0 '@oxc-parser/binding-linux-x64-musl': 0.121.0 '@oxc-parser/binding-openharmony-arm64': 0.121.0 - '@oxc-parser/binding-wasm32-wasi': 0.121.0(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1) + '@oxc-parser/binding-wasm32-wasi': 0.121.0(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2) '@oxc-parser/binding-win32-arm64-msvc': 0.121.0 '@oxc-parser/binding-win32-ia32-msvc': 0.121.0 '@oxc-parser/binding-win32-x64-msvc': 0.121.0 @@ -15084,7 +15106,7 @@ snapshots: - '@emnapi/core' - '@emnapi/runtime' - oxc-resolver@11.19.1(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1): + oxc-resolver@11.19.1(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2): optionalDependencies: '@oxc-resolver/binding-android-arm-eabi': 11.19.1 '@oxc-resolver/binding-android-arm64': 11.19.1 @@ -15102,7 +15124,7 @@ snapshots: '@oxc-resolver/binding-linux-x64-gnu': 11.19.1 '@oxc-resolver/binding-linux-x64-musl': 11.19.1 '@oxc-resolver/binding-openharmony-arm64': 11.19.1 - '@oxc-resolver/binding-wasm32-wasi': 11.19.1(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1) + '@oxc-resolver/binding-wasm32-wasi': 11.19.1(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2) '@oxc-resolver/binding-win32-arm64-msvc': 11.19.1 '@oxc-resolver/binding-win32-ia32-msvc': 11.19.1 '@oxc-resolver/binding-win32-x64-msvc': 11.19.1 @@ -16241,6 +16263,11 @@ snapshots: fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 + tinyglobby@0.2.16: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + tinypool@2.1.0: {} tinyrainbow@2.0.0: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 98f1fcfa86..cd72c6bc0e 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,57 +1,22 @@ -catalogMode: prefer -trustPolicy: no-downgrade -trustPolicyExclude: - - chokidar@4.0.3 - - reselect@5.1.1 - - semver@6.3.1 -blockExoticSubdeps: true -strictDepBuilds: true -allowBuilds: - "@parcel/watcher": false - canvas: false - esbuild: false - sharp: false packages: - web - e2e - sdks/nodejs-client - packages/* -overrides: - "@lexical/code": npm:lexical-code-no-prism@0.41.0 - "@monaco-editor/loader": 1.7.0 - brace-expansion@>=2.0.0 <2.0.3: 2.0.3 - canvas: ^3.2.2 - dompurify@>=3.1.3 <=3.3.1: 3.3.2 - esbuild@<0.27.2: 0.27.2 - flatted@<=3.4.1: 3.4.2 - glob@>=10.2.0 <10.5.0: 11.1.0 - is-core-module: npm:@nolyfill/is-core-module@^1.0.39 - lodash@>=4.0.0 <= 4.17.23: 4.18.0 - lodash-es@>=4.0.0 <= 4.17.23: 4.18.0 - picomatch@<2.3.2: 2.3.2 - picomatch@>=4.0.0 <4.0.4: 4.0.4 - rollup@>=4.0.0 <4.59.0: 4.59.0 - safe-buffer: ^5.2.1 - safer-buffer: npm:@nolyfill/safer-buffer@^1.0.44 - side-channel: npm:@nolyfill/side-channel@^1.0.44 - smol-toml@<1.6.1: 1.6.1 - solid-js: 1.9.11 - string-width: ~8.2.0 - svgo@>=3.0.0 <3.3.3: 3.3.3 - tar@<=7.5.10: 7.5.11 - undici@>=7.0.0 <7.24.0: 7.24.0 - vite: npm:@voidzero-dev/vite-plus-core@0.1.16 - vitest: npm:@voidzero-dev/vite-plus-test@0.1.16 - yaml@>=2.0.0 <2.8.3: 2.8.3 - yauzl@<3.2.1: 3.2.1 +allowBuilds: + "@parcel/watcher": false + canvas: false + esbuild: false + sharp: false +blockExoticSubdeps: true catalog: "@amplitude/analytics-browser": 2.39.0 "@amplitude/plugin-session-replay-browser": 1.27.7 "@antfu/eslint-config": 8.2.0 "@base-ui/react": 1.4.0 - "@date-fns/tz": 1.4.1 "@chromatic-com/storybook": 5.1.2 "@cucumber/cucumber": 12.8.0 + "@date-fns/tz": 1.4.1 "@egoist/tailwindcss-icons": 1.9.2 "@emoji-mart/data": 1.2.1 "@eslint-react/eslint-plugin": 3.0.0 @@ -107,6 +72,7 @@ catalog: "@testing-library/jest-dom": 6.9.1 "@testing-library/react": 16.3.2 "@testing-library/user-event": 14.6.1 + '@tsdown/css': 0.21.8 "@tsslint/cli": 3.0.3 "@tsslint/compat-eslint": 3.0.3 "@tsslint/config": 3.0.3 @@ -230,3 +196,38 @@ catalog: zod: 4.3.6 zundo: 2.3.0 zustand: 5.0.12 +catalogMode: prefer +overrides: + "@lexical/code": npm:lexical-code-no-prism@0.41.0 + "@monaco-editor/loader": 1.7.0 + brace-expansion@>=2.0.0 <2.0.3: 2.0.3 + canvas: ^3.2.2 + dompurify@>=3.1.3 <=3.3.1: 3.3.2 + esbuild@<0.27.2: 0.27.2 + flatted@<=3.4.1: 3.4.2 + glob@>=10.2.0 <10.5.0: 11.1.0 + is-core-module: npm:@nolyfill/is-core-module@^1.0.39 + lodash-es@>=4.0.0 <= 4.17.23: 4.18.0 + lodash@>=4.0.0 <= 4.17.23: 4.18.0 + picomatch@<2.3.2: 2.3.2 + picomatch@>=4.0.0 <4.0.4: 4.0.4 + rollup@>=4.0.0 <4.59.0: 4.59.0 + safe-buffer: ^5.2.1 + safer-buffer: npm:@nolyfill/safer-buffer@^1.0.44 + side-channel: npm:@nolyfill/side-channel@^1.0.44 + smol-toml@<1.6.1: 1.6.1 + solid-js: 1.9.11 + string-width: ~8.2.0 + svgo@>=3.0.0 <3.3.3: 3.3.3 + tar@<=7.5.10: 7.5.11 + undici@>=7.0.0 <7.24.0: 7.24.0 + vite: npm:@voidzero-dev/vite-plus-core@0.1.16 + vitest: npm:@voidzero-dev/vite-plus-test@0.1.16 + yaml@>=2.0.0 <2.8.3: 2.8.3 + yauzl@<3.2.1: 3.2.1 +strictDepBuilds: true +trustPolicy: no-downgrade +trustPolicyExclude: + - chokidar@4.0.3 + - reselect@5.1.1 + - semver@6.3.1 diff --git a/web/AGENTS.md b/web/AGENTS.md index 97f74441a7..4a705bf4b8 100644 --- a/web/AGENTS.md +++ b/web/AGENTS.md @@ -12,6 +12,10 @@ - `frontend-query-mutation` is the source of truth for Dify frontend contracts, query and mutation call-site patterns, conditional queries, invalidation, and mutation error handling. +## Design Token Mapping + +- When translating Figma designs to code, read `../packages/dify-ui/AGENTS.md` for the Figma `--radius/*` token to Tailwind `rounded-*` class mapping. The two scales are offset by one step. + ## Automated Test Generation - Use `./docs/test.md` as the canonical instruction set for generating frontend automated tests. diff --git a/web/README.md b/web/README.md index 2d69a94dbd..382c804264 100644 --- a/web/README.md +++ b/web/README.md @@ -136,7 +136,6 @@ pnpm -C web test If you are not familiar with writing tests, refer to: -- [classnames.spec.ts] - Utility function test example - [index.spec.tsx] - Component test example ### Analyze Component Complexity @@ -166,7 +165,6 @@ The Dify community can be found on [Discord community], where you can ask questi [Storybook]: https://storybook.js.org [Vite+]: https://viteplus.dev [Vitest]: https://vitest.dev -[classnames.spec.ts]: ./utils/classnames.spec.ts [index.spec.tsx]: ./app/components/base/button/index.spec.tsx [pnpm]: https://pnpm.io [vinext]: https://github.com/cloudflare/vinext diff --git a/web/__tests__/plugins/plugin-auth-flow.test.tsx b/web/__tests__/plugins/plugin-auth-flow.test.tsx index a2ec8703ca..c4d28e3f34 100644 --- a/web/__tests__/plugins/plugin-auth-flow.test.tsx +++ b/web/__tests__/plugins/plugin-auth-flow.test.tsx @@ -31,7 +31,7 @@ vi.mock('@/context/app-context', () => ({ }), })) -vi.mock('@/utils/classnames', () => ({ +vi.mock('@langgenius/dify-ui/cn', () => ({ cn: (...args: unknown[]) => args.filter(Boolean).join(' '), })) diff --git a/web/__tests__/plugins/plugin-card-rendering.test.tsx b/web/__tests__/plugins/plugin-card-rendering.test.tsx index 5bd7f0c8bf..fb0163f344 100644 --- a/web/__tests__/plugins/plugin-card-rendering.test.tsx +++ b/web/__tests__/plugins/plugin-card-rendering.test.tsx @@ -32,7 +32,7 @@ vi.mock('@/types/app', async () => { return vi.importActual('@/types/app') }) -vi.mock('@/utils/classnames', () => ({ +vi.mock('@langgenius/dify-ui/cn', () => ({ cn: (...args: unknown[]) => args.filter(a => typeof a === 'string' && a).join(' '), })) diff --git a/web/__tests__/tools/tool-browsing-and-filtering.test.tsx b/web/__tests__/tools/tool-browsing-and-filtering.test.tsx index dbefb1fdc3..aa8f59ca31 100644 --- a/web/__tests__/tools/tool-browsing-and-filtering.test.tsx +++ b/web/__tests__/tools/tool-browsing-and-filtering.test.tsx @@ -226,7 +226,7 @@ vi.mock('@/app/components/tools/mcp', () => ({ default: () =>
MCP List
, })) -vi.mock('@/utils/classnames', () => ({ +vi.mock('@langgenius/dify-ui/cn', () => ({ cn: (...args: unknown[]) => args.filter(Boolean).join(' '), })) diff --git a/web/__tests__/tools/tool-provider-detail-flow.test.tsx b/web/__tests__/tools/tool-provider-detail-flow.test.tsx index ce5ffe531e..c52871c946 100644 --- a/web/__tests__/tools/tool-provider-detail-flow.test.tsx +++ b/web/__tests__/tools/tool-provider-detail-flow.test.tsx @@ -112,7 +112,7 @@ vi.mock('@/service/use-tools', () => ({ useInvalidateAllWorkflowTools: () => vi.fn(), })) -vi.mock('@/utils/classnames', () => ({ +vi.mock('@langgenius/dify-ui/cn', () => ({ cn: (...args: unknown[]) => args.filter(Boolean).join(' '), })) diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/layout-main.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/layout-main.tsx index d3f15bdf46..8a1a6fd131 100644 --- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/layout-main.tsx +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/layout-main.tsx @@ -2,6 +2,7 @@ import type { FC } from 'react' import type { NavIcon } from '@/app/components/app-sidebar/nav-link' import type { App } from '@/types/app' +import { cn } from '@langgenius/dify-ui/cn' import { RiDashboard2Fill, RiDashboard2Line, @@ -28,7 +29,6 @@ import dynamic from '@/next/dynamic' import { usePathname, useRouter } from '@/next/navigation' import { fetchAppDetailDirect } from '@/service/apps' import { AppModeEnum } from '@/types/app' -import { cn } from '@/utils/classnames' import s from './style.module.css' const TagManagementModal = dynamic(() => import('@/app/components/base/tag-management'), { diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/time-range-picker/date-picker.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/time-range-picker/date-picker.tsx index 368c3dcfc3..dbc429cbdc 100644 --- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/time-range-picker/date-picker.tsx +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/time-range-picker/date-picker.tsx @@ -2,6 +2,7 @@ import type { Dayjs } from 'dayjs' import type { FC } from 'react' import type { TriggerProps } from '@/app/components/base/date-and-time-picker/types' +import { cn } from '@langgenius/dify-ui/cn' import { RiCalendarLine } from '@remixicon/react' import dayjs from 'dayjs' import { noop } from 'es-toolkit/function' @@ -9,7 +10,6 @@ import * as React from 'react' import { useCallback } from 'react' import Picker from '@/app/components/base/date-and-time-picker/date-picker' import { useLocale } from '@/context/i18n' -import { cn } from '@/utils/classnames' import { formatToLocalTime } from '@/utils/format' type Props = { @@ -30,7 +30,7 @@ const DatePicker: FC = ({ const renderDate = useCallback(({ value, handleClickTrigger, isOpen }: TriggerProps) => { return ( -
+
{value ? formatToLocalTime(value, locale, 'MMM D') : ''}
) diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/time-range-picker/range-selector.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/time-range-picker/range-selector.tsx index a4bf025139..a89b77e9e3 100644 --- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/time-range-picker/range-selector.tsx +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/time-range-picker/range-selector.tsx @@ -3,13 +3,13 @@ import type { FC } from 'react' import type { PeriodParamsWithTimeRange, TimeRange } from '@/app/components/app/overview/app-chart' import type { Item } from '@/app/components/base/select' import type { I18nKeysByPrefix } from '@/types/i18n' +import { cn } from '@langgenius/dify-ui/cn' import { RiArrowDownSLine, RiCheckLine } from '@remixicon/react' import dayjs from 'dayjs' import * as React from 'react' import { useCallback } from 'react' import { useTranslation } from 'react-i18next' import { SimpleSelect } from '@/app/components/base/select' -import { cn } from '@/utils/classnames' const today = dayjs() @@ -44,7 +44,7 @@ const RangeSelector: FC = ({ const renderTrigger = useCallback((item: Item | null, isOpen: boolean) => { return ( -
+
{isCustomRange ? t('filter.period.custom', { ns: 'appLog' }) : item?.name}
@@ -57,13 +57,13 @@ const RangeSelector: FC = ({ {selected && ( )} - {item.name} + {item.name} ) }, []) diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/config-button.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/config-button.tsx index 17ca5d78cf..29de1a1eae 100644 --- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/config-button.tsx +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/config-button.tsx @@ -2,6 +2,7 @@ import type { FC } from 'react' import type { PopupProps } from './config-popup' +import { cn } from '@langgenius/dify-ui/cn' import * as React from 'react' import { useCallback, useRef, useState } from 'react' import { @@ -9,7 +10,6 @@ import { PortalToFollowElemContent, PortalToFollowElemTrigger, } from '@/app/components/base/portal-to-follow-elem' -import { cn } from '@/utils/classnames' import ConfigPopup from './config-popup' type Props = { diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/config-popup.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/config-popup.tsx index 138d238b47..15b81e1ad6 100644 --- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/config-popup.tsx +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/config-popup.tsx @@ -1,6 +1,7 @@ 'use client' import type { FC, JSX } from 'react' import type { AliyunConfig, ArizeConfig, DatabricksConfig, LangFuseConfig, LangSmithConfig, MLflowConfig, OpikConfig, PhoenixConfig, TencentConfig, WeaveConfig } from './type' +import { cn } from '@langgenius/dify-ui/cn' import { useBoolean } from 'ahooks' import * as React from 'react' import { useCallback, useState } from 'react' @@ -9,7 +10,6 @@ import Divider from '@/app/components/base/divider' import Switch from '@/app/components/base/switch' import Tooltip from '@/app/components/base/tooltip' import Indicator from '@/app/components/header/indicator' -import { cn } from '@/utils/classnames' import ProviderConfigModal from './provider-config-modal' import ProviderPanel from './provider-panel' import TracingIcon from './tracing-icon' @@ -331,7 +331,7 @@ const ConfigPopup: FC = ({
-
+
{t(`${I18N_PREFIX}.${enabled ? 'enabled' : 'disabled'}`, { ns: 'app' })}
{!readOnly && ( @@ -350,7 +350,7 @@ const ConfigPopup: FC = ({
-
+
{t(`${I18N_PREFIX}.tracingDescription`, { ns: 'app' })}
@@ -379,7 +379,7 @@ const ConfigPopup: FC = ({
{configuredProviderPanel()}
-
{t(`${I18N_PREFIX}.configProviderTitle.moreProvider`, { ns: 'app' })}
+
{t(`${I18N_PREFIX}.configProviderTitle.moreProvider`, { ns: 'app' })}
{moreProviderPanel()}
diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/field.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/field.tsx index 7c47249830..2b56ecfeea 100644 --- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/field.tsx +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/field.tsx @@ -1,8 +1,8 @@ 'use client' import type { FC } from 'react' +import { cn } from '@langgenius/dify-ui/cn' import * as React from 'react' import Input from '@/app/components/base/input' -import { cn } from '@/utils/classnames' type Props = { className?: string diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/panel.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/panel.tsx index 239427159c..9c42f85825 100644 --- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/panel.tsx +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/panel.tsx @@ -2,6 +2,7 @@ import type { FC } from 'react' import type { AliyunConfig, ArizeConfig, DatabricksConfig, LangFuseConfig, LangSmithConfig, MLflowConfig, OpikConfig, PhoenixConfig, TencentConfig, WeaveConfig } from './type' import type { TracingStatus } from '@/models/app' +import { cn } from '@langgenius/dify-ui/cn' import { RiArrowDownDoubleLine, RiEqualizer2Line, @@ -18,7 +19,6 @@ import Indicator from '@/app/components/header/indicator' import { useAppContext } from '@/context/app-context' import { usePathname } from '@/next/navigation' import { fetchTracingConfig as doFetchTracingConfig, fetchTracingStatus, updateTracingStatus } from '@/service/apps' -import { cn } from '@/utils/classnames' import ConfigButton from './config-button' import TracingIcon from './tracing-icon' import { TracingProvider } from './type' @@ -247,11 +247,11 @@ const Panel: FC = () => { >
-
{t(`${I18N_PREFIX}.title`, { ns: 'app' })}
+
{t(`${I18N_PREFIX}.title`, { ns: 'app' })}
@@ -286,12 +286,12 @@ const Panel: FC = () => { >
-
+
-
+
{t(`${I18N_PREFIX}.${enabled ? 'enabled' : 'disabled'}`, { ns: 'app' })}
diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/provider-panel.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/provider-panel.tsx index 076a5cd7d8..ed7f4ab962 100644 --- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/provider-panel.tsx +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/provider-panel.tsx @@ -1,5 +1,6 @@ 'use client' import type { FC } from 'react' +import { cn } from '@langgenius/dify-ui/cn' import { RiEqualizer2Line, } from '@remixicon/react' @@ -8,7 +9,6 @@ import { useCallback } from 'react' import { useTranslation } from 'react-i18next' import { AliyunIconBig, ArizeIconBig, DatabricksIconBig, LangfuseIconBig, LangsmithIconBig, MlflowIconBig, OpikIconBig, PhoenixIconBig, TencentIconBig, WeaveIconBig } from '@/app/components/base/icons/src/public/tracing' import { Eye as View } from '@/app/components/base/icons/src/vender/solid/general' -import { cn } from '@/utils/classnames' import { TracingProvider } from './type' const I18N_PREFIX = 'tracing' @@ -82,7 +82,7 @@ const ProviderPanel: FC = ({
- {isChosen &&
{t(`${I18N_PREFIX}.inUse`, { ns: 'app' })}
} + {isChosen &&
{t(`${I18N_PREFIX}.inUse`, { ns: 'app' })}
}
{!readOnly && (
@@ -102,7 +102,7 @@ const ProviderPanel: FC = ({
)}
-
+
{t(`${I18N_PREFIX}.${type}.description`, { ns: 'app' })}
diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/tracing-icon.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/tracing-icon.tsx index 9bf1ddc50d..cdc0f753a6 100644 --- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/tracing-icon.tsx +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/tracing-icon.tsx @@ -1,8 +1,8 @@ 'use client' import type { FC } from 'react' +import { cn } from '@langgenius/dify-ui/cn' import * as React from 'react' import { TracingIcon as Icon } from '@/app/components/base/icons/src/public/tracing' -import { cn } from '@/utils/classnames' type Props = { className?: string @@ -10,7 +10,7 @@ type Props = { } const sizeClassMap = { - lg: 'w-9 h-9 p-2 radius-lg', + lg: 'w-9 h-9 p-2 rounded-[10px]', md: 'w-6 h-6 p-1 rounded-lg', } diff --git a/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/layout-main.tsx b/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/layout-main.tsx index 092e47278f..ba3272c1a7 100644 --- a/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/layout-main.tsx +++ b/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/layout-main.tsx @@ -1,6 +1,7 @@ 'use client' import type { RemixiconComponentType } from '@remixicon/react' import type { FC } from 'react' +import { cn } from '@langgenius/dify-ui/cn' import { RiEqualizer2Fill, RiEqualizer2Line, @@ -24,7 +25,6 @@ import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints' import useDocumentTitle from '@/hooks/use-document-title' import { usePathname } from '@/next/navigation' import { useDatasetDetail, useDatasetRelatedApps } from '@/service/knowledge/use-dataset' -import { cn } from '@/utils/classnames' type IAppDetailLayoutProps = { children: React.ReactNode diff --git a/web/app/(humanInputLayout)/form/[token]/form.tsx b/web/app/(humanInputLayout)/form/[token]/form.tsx index 898dab8f4a..be30585101 100644 --- a/web/app/(humanInputLayout)/form/[token]/form.tsx +++ b/web/app/(humanInputLayout)/form/[token]/form.tsx @@ -3,6 +3,7 @@ import type { ButtonProps } from '@/app/components/base/ui/button' import type { FormInputItem, UserAction } from '@/app/components/workflow/nodes/human-input/types' import type { SiteInfo } from '@/models/share' import type { HumanInputFormError } from '@/service/use-share' +import { cn } from '@langgenius/dify-ui/cn' import { RiCheckboxCircleFill, RiErrorWarningFill, @@ -22,7 +23,6 @@ import { Button } from '@/app/components/base/ui/button' import useDocumentTitle from '@/hooks/use-document-title' import { useParams } from '@/next/navigation' import { useGetHumanInputForm, useSubmitHumanInputForm } from '@/service/use-share' -import { cn } from '@/utils/classnames' export type FormData = { site: { site: SiteInfo } @@ -101,7 +101,7 @@ const FormContent = () => { return (
-
+
@@ -129,7 +129,7 @@ const FormContent = () => { return (
-
+
@@ -157,7 +157,7 @@ const FormContent = () => { return (
-
+
@@ -185,7 +185,7 @@ const FormContent = () => { return (
-
+
@@ -211,7 +211,7 @@ const FormContent = () => { return (
-
+
@@ -248,7 +248,7 @@ const FormContent = () => {
{site.title}
-
+
{contentList.map((content, index) => (
{!systemFeatures.branding.enabled && ( -
+
© {' '} {new Date().getFullYear()} 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 5b89084ea1..df102e609a 100644 --- a/web/app/(shareLayout)/webapp-reset-password/set-password/page.tsx +++ b/web/app/(shareLayout)/webapp-reset-password/set-password/page.tsx @@ -1,4 +1,5 @@ 'use client' +import { cn } from '@langgenius/dify-ui/cn' import { RiCheckboxCircleFill } from '@remixicon/react' import { useCountDown } from 'ahooks' import { useCallback, useState } from 'react' @@ -9,7 +10,6 @@ import { toast } from '@/app/components/base/ui/toast' import { validPassword } from '@/config' import { useRouter, useSearchParams } from '@/next/navigation' import { changeWebAppPasswordWithToken } from '@/service/common' -import { cn } from '@/utils/classnames' const ChangePasswordForm = () => { const { t } = useTranslation() diff --git a/web/app/(shareLayout)/webapp-signin/layout.tsx b/web/app/(shareLayout)/webapp-signin/layout.tsx index 21cb0e1f57..5451b45194 100644 --- a/web/app/(shareLayout)/webapp-signin/layout.tsx +++ b/web/app/(shareLayout)/webapp-signin/layout.tsx @@ -1,10 +1,10 @@ 'use client' import type { PropsWithChildren } from 'react' +import { cn } from '@langgenius/dify-ui/cn' import { useTranslation } from 'react-i18next' import { useGlobalPublicStore } from '@/context/global-public-context' import useDocumentTitle from '@/hooks/use-document-title' -import { cn } from '@/utils/classnames' export default function SignInLayout({ children }: PropsWithChildren) { const { t } = useTranslation() @@ -21,7 +21,7 @@ export default function SignInLayout({ children }: PropsWithChildren) {
{systemFeatures.branding.enabled === false && ( -
+
© {' '} {new Date().getFullYear()} diff --git a/web/app/(shareLayout)/webapp-signin/normalForm.tsx b/web/app/(shareLayout)/webapp-signin/normalForm.tsx index ed97e64806..436c7e64bb 100644 --- a/web/app/(shareLayout)/webapp-signin/normalForm.tsx +++ b/web/app/(shareLayout)/webapp-signin/normalForm.tsx @@ -1,4 +1,5 @@ 'use client' +import { cn } from '@langgenius/dify-ui/cn' import { RiContractLine, RiDoorLockLine, RiErrorWarningFill } from '@remixicon/react' import * as React from 'react' import { useCallback, useEffect, useState } from 'react' @@ -8,7 +9,6 @@ import { IS_CE_EDITION } from '@/config' import { useGlobalPublicStore } from '@/context/global-public-context' import Link from '@/next/link' import { LicenseStatus } from '@/types/feature' -import { cn } from '@/utils/classnames' import MailAndCodeAuth from './components/mail-and-code-auth' import MailAndPasswordAuth from './components/mail-and-password-auth' import SSOAuth from './components/sso-auth' @@ -58,10 +58,10 @@ const NormalForm = () => {
- +

{t('licenseLost', { ns: 'login' })}

-

{t('licenseLostTip', { ns: 'login' })}

+

{t('licenseLostTip', { ns: 'login' })}

@@ -74,10 +74,10 @@ const NormalForm = () => {
- +

{t('licenseExpired', { ns: 'login' })}

-

{t('licenseExpiredTip', { ns: 'login' })}

+

{t('licenseExpiredTip', { ns: 'login' })}

@@ -90,10 +90,10 @@ const NormalForm = () => {
- +

{t('licenseInactive', { ns: 'login' })}

-

{t('licenseInactiveTip', { ns: 'login' })}

+

{t('licenseInactiveTip', { ns: 'login' })}

@@ -105,7 +105,7 @@ const NormalForm = () => {

{systemFeatures.branding.enabled ? t('pageTitleForE', { ns: 'login' }) : t('pageTitle', { ns: 'login' })}

-

{t('welcome', { ns: 'login' })}

+

{t('welcome', { ns: 'login' })}

@@ -122,7 +122,7 @@ const NormalForm = () => {
- {t('or', { ns: 'login' })} + {t('or', { ns: 'login' })}
)} @@ -159,7 +159,7 @@ const NormalForm = () => {

{t('noLoginMethod', { ns: 'login' })}

-

{t('noLoginMethodTip', { ns: 'login' })}

+

{t('noLoginMethodTip', { ns: 'login' })}