diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 829ee56d7e..01720b8e9f 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -784,11 +784,6 @@ "count": 10 } }, - "web/app/components/base/chip/index.tsx": { - "ts/no-explicit-any": { - "count": 3 - } - }, "web/app/components/base/date-and-time-picker/hooks.ts": { "react/no-unnecessary-use-prefix": { "count": 2 @@ -1607,11 +1602,6 @@ "count": 1 } }, - "web/app/components/base/sort/index.tsx": { - "ts/no-explicit-any": { - "count": 2 - } - }, "web/app/components/base/svg-gallery/index.tsx": { "node/prefer-global/buffer": { "count": 1 @@ -2120,11 +2110,6 @@ "count": 2 } }, - "web/app/components/explore/item-operation/index.tsx": { - "react/set-state-in-effect": { - "count": 1 - } - }, "web/app/components/explore/try-app/tab.tsx": { "erasable-syntax-only/enums": { "count": 1 @@ -2596,11 +2581,6 @@ "count": 2 } }, - "web/app/components/plugins/plugin-page/install-plugin-dropdown.tsx": { - "react/set-state-in-effect": { - "count": 2 - } - }, "web/app/components/plugins/reference-setting-modal/auto-update-setting/types.ts": { "erasable-syntax-only/enums": { "count": 2 @@ -2778,11 +2758,6 @@ "count": 1 } }, - "web/app/components/share/text-generation/menu-dropdown.tsx": { - "react/set-state-in-effect": { - "count": 1 - } - }, "web/app/components/share/text-generation/no-data/index.tsx": { "ts/no-empty-object-type": { "count": 1 @@ -2950,11 +2925,6 @@ "count": 1 } }, - "web/app/components/workflow/block-selector/market-place-plugin/action.tsx": { - "react/set-state-in-effect": { - "count": 1 - } - }, "web/app/components/workflow/block-selector/market-place-plugin/item.tsx": { "erasable-syntax-only/enums": { "count": 1 @@ -4051,11 +4021,6 @@ "count": 1 } }, - "web/app/components/workflow/operator/zoom-in-out.tsx": { - "erasable-syntax-only/enums": { - "count": 1 - } - }, "web/app/components/workflow/panel/__tests__/index.spec.tsx": { "react/static-components": { "count": 2 @@ -4378,11 +4343,6 @@ "count": 1 } }, - "web/app/components/workflow/workflow-preview/components/zoom-in-out.tsx": { - "erasable-syntax-only/enums": { - "count": 1 - } - }, "web/app/education-apply/hooks.ts": { "react/set-state-in-effect": { "count": 5 diff --git a/web/app/components/app-sidebar/dataset-info/dropdown.tsx b/web/app/components/app-sidebar/dataset-info/dropdown.tsx index fa5a40f8a4..b06d92e8d9 100644 --- a/web/app/components/app-sidebar/dataset-info/dropdown.tsx +++ b/web/app/components/app-sidebar/dataset-info/dropdown.tsx @@ -134,7 +134,7 @@ const DropDown = ({ render={( )} > diff --git a/web/app/components/app/configuration/config/automatic/version-selector.tsx b/web/app/components/app/configuration/config/automatic/version-selector.tsx index 60584f1a72..cb4f2cb9f5 100644 --- a/web/app/components/app/configuration/config/automatic/version-selector.tsx +++ b/web/app/components/app/configuration/config/automatic/version-selector.tsx @@ -7,9 +7,7 @@ import { DropdownMenuTrigger, } from '@langgenius/dify-ui/dropdown-menu' import { RiArrowDownSLine, RiCheckLine } from '@remixicon/react' -import { useBoolean } from 'ahooks' import * as React from 'react' -import { useCallback } from 'react' import { useTranslation } from 'react-i18next' type VersionSelectorProps = { @@ -20,17 +18,7 @@ type VersionSelectorProps = { const VersionSelector: React.FC = ({ versionLen, value, onChange }) => { const { t } = useTranslation() - const [isOpen, { - setFalse: handleOpenFalse, - set: handleOpenSet, - }] = useBoolean(false) - const moreThanOneVersion = versionLen > 1 - const handleOpen = useCallback((nextOpen: boolean) => { - if (moreThanOneVersion) - handleOpenSet(nextOpen) - }, [moreThanOneVersion, handleOpenSet]) - const versions = Array.from({ length: versionLen }, (_, index) => ({ label: `${t('generate.version', { ns: 'appDebug' })} ${index + 1}${index === versionLen - 1 ? ` ยท ${t('generate.latest', { ns: 'appDebug' })}` : ''}`, value: index, @@ -39,14 +27,12 @@ const VersionSelector: React.FC = ({ versionLen, value, on const isLatest = value === versionLen - 1 return ( - + + disabled={!moreThanOneVersion} + className={cn( + 'flex items-center border-none bg-transparent p-0 system-xs-medium text-text-tertiary', + moreThanOneVersion ? 'cursor-pointer data-popup-open:text-text-secondary' : 'cursor-default', )} >
@@ -63,14 +49,13 @@ const VersionSelector: React.FC = ({ versionLen, value, on alignOffset={-12} popupClassName="w-[208px] rounded-xl border-[0.5px] bg-components-panel-bg-blur p-1" > -
+
{t('generate.versions', { ns: 'appDebug' })}
{ onChange(nextValue) - handleOpenFalse() }} > {versions.map(option => ( diff --git a/web/app/components/app/configuration/debug/debug-with-multiple-model/debug-item.tsx b/web/app/components/app/configuration/debug/debug-with-multiple-model/debug-item.tsx index 064a1fb97f..db79804755 100644 --- a/web/app/components/app/configuration/debug/debug-with-multiple-model/debug-item.tsx +++ b/web/app/components/app/configuration/debug/debug-with-multiple-model/debug-item.tsx @@ -1,6 +1,5 @@ import type { CSSProperties, FC } from 'react' import type { ModelAndParameter } from '../types' -import { cn } from '@langgenius/dify-ui/cn' import { DropdownMenu, DropdownMenuContent, @@ -8,7 +7,7 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from '@langgenius/dify-ui/dropdown-menu' -import { memo, useState } from 'react' +import { memo } from 'react' import { useTranslation } from 'react-i18next' import ActionButton from '@/app/components/base/action-button' import { ModelStatusEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' @@ -42,10 +41,8 @@ const DebugItem: FC = ({ const index = multipleModelConfigs.findIndex(v => v.id === modelAndParameter.id) const currentProvider = textGenerationModelList.find(item => item.provider === modelAndParameter.provider) const currentModel = currentProvider?.models.find(item => item.model === modelAndParameter.model) - const [open, setOpen] = useState(false) const handleDuplicate = () => { - setOpen(false) if (multipleModelConfigs.length >= 4) return @@ -63,12 +60,10 @@ const DebugItem: FC = ({ } const handleDebugAsSingleModel = () => { - setOpen(false) onDebugWithMultipleModelChange(modelAndParameter) } const handleRemove = () => { - setOpen(false) onMultipleModelConfigsChange( true, multipleModelConfigs.filter(item => item.id !== modelAndParameter.id), @@ -92,11 +87,11 @@ const DebugItem: FC = ({ - + diff --git a/web/app/components/app/workflow-log/__tests__/filter.spec.tsx b/web/app/components/app/workflow-log/__tests__/filter.spec.tsx index 59063dfb98..99443ae1e0 100644 --- a/web/app/components/app/workflow-log/__tests__/filter.spec.tsx +++ b/web/app/components/app/workflow-log/__tests__/filter.spec.tsx @@ -8,7 +8,7 @@ */ import type { QueryParam } from '../index' -import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { fireEvent, render, screen, waitFor, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { useState } from 'react' import Filter, { TIME_PERIOD_MAPPING } from '../filter' @@ -297,13 +297,13 @@ describe('Filter', () => { it('should call setQueryParams when typing in search', async () => { const user = userEvent.setup() - const setQueryParams = vi.fn() + const onSetQueryParams = vi.fn() const Wrapper = () => { - const [queryParams, updateQueryParams] = useState(createDefaultQueryParams()) + const [queryParams, setQueryParams] = useState(() => createDefaultQueryParams()) const handleSetQueryParams = (next: QueryParam) => { - updateQueryParams(next) setQueryParams(next) + onSetQueryParams(next) } return ( { await user.type(input, 'workflow') // Should call setQueryParams for each character typed - expect(setQueryParams).toHaveBeenLastCalledWith( + expect(onSetQueryParams).toHaveBeenLastCalledWith( expect.objectContaining({ keyword: 'workflow' }), ) }) @@ -335,7 +335,9 @@ describe('Filter', () => { />, ) - await user.click(screen.getByRole('button', { name: 'common.operation.clear' })) + const searchInput = screen.getByPlaceholderText('common.operation.search') + const searchField = searchInput.closest('div')! + await user.click(within(searchField).getByRole('button', { name: 'common.operation.clear' })) expect(setQueryParams).toHaveBeenCalledWith({ status: 'all', diff --git a/web/app/components/apps/app-card.tsx b/web/app/components/apps/app-card.tsx index 262f0fee56..c4755e3835 100644 --- a/web/app/components/apps/app-card.tsx +++ b/web/app/components/apps/app-card.tsx @@ -532,8 +532,7 @@ const AppCard = ({ app, onlineUsers = [], onRefresh, onOpenTagManagement = () => { e.stopPropagation() diff --git a/web/app/components/base/chat/chat-with-history/header/__tests__/operation.spec.tsx b/web/app/components/base/chat/chat-with-history/header/__tests__/operation.spec.tsx index 294d5eebc5..60fc1a2b03 100644 --- a/web/app/components/base/chat/chat-with-history/header/__tests__/operation.spec.tsx +++ b/web/app/components/base/chat/chat-with-history/header/__tests__/operation.spec.tsx @@ -95,10 +95,11 @@ describe('Operation Component', () => { const trigger = screen.getByText('Chat Title').closest('.cursor-pointer') // closed state - expect(trigger).not.toHaveClass('bg-state-base-hover') + expect(trigger).toHaveClass('data-popup-open:bg-state-base-hover') + expect(trigger).not.toHaveAttribute('data-popup-open') // open state await user.click(screen.getByText('Chat Title')) - expect(trigger).toHaveClass('bg-state-base-hover') + expect(trigger).toHaveAttribute('data-popup-open') }) }) diff --git a/web/app/components/base/chat/chat-with-history/header/operation.tsx b/web/app/components/base/chat/chat-with-history/header/operation.tsx index 066cec5183..57ac96e366 100644 --- a/web/app/components/base/chat/chat-with-history/header/operation.tsx +++ b/web/app/components/base/chat/chat-with-history/header/operation.tsx @@ -1,7 +1,6 @@ 'use client' import type { Placement } from '@langgenius/dify-ui/dropdown-menu' import type { FC } from 'react' -import { cn } from '@langgenius/dify-ui/cn' import { DropdownMenu, DropdownMenuContent, @@ -9,7 +8,6 @@ import { DropdownMenuTrigger, } from '@langgenius/dify-ui/dropdown-menu' import * as React from 'react' -import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' type Props = { @@ -23,6 +21,10 @@ type Props = { placement?: Placement } +const deferAction = (action: () => void) => { + queueMicrotask(action) +} + const Operation: FC = ({ title, isPinned, @@ -34,22 +36,11 @@ const Operation: FC = ({ placement = 'bottom-start', }) => { const { t } = useTranslation() - const [open, setOpen] = useState(false) - const handleDeferredAction = useCallback((action: () => void) => { - setOpen(false) - queueMicrotask(action) - }, []) return ( - + {title} @@ -65,7 +56,7 @@ const Operation: FC = ({ {isShowRenameConversation && ( onRenameConversation && handleDeferredAction(onRenameConversation)} + onClick={() => onRenameConversation && deferAction(onRenameConversation)} > {t('sidebar.action.rename', { ns: 'explore' })} @@ -74,7 +65,7 @@ const Operation: FC = ({ handleDeferredAction(onDelete)} + onClick={() => deferAction(onDelete)} > {t('sidebar.action.delete', { ns: 'explore' })} diff --git a/web/app/components/base/chat/chat-with-history/sidebar/index.tsx b/web/app/components/base/chat/chat-with-history/sidebar/index.tsx index 38b91e439b..d44d3f86fa 100644 --- a/web/app/components/base/chat/chat-with-history/sidebar/index.tsx +++ b/web/app/components/base/chat/chat-with-history/sidebar/index.tsx @@ -10,11 +10,6 @@ import { } from '@langgenius/dify-ui/alert-dialog' import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' -import { - RiEditBoxLine, - RiExpandRightLine, - RiLayoutLeft2Line, -} from '@remixicon/react' import { useSuspenseQuery } from '@tanstack/react-query' import { useCallback, @@ -35,7 +30,7 @@ type Props = { panelVisible?: boolean } -const Sidebar = ({ isPanel, panelVisible }: Props) => { +const Sidebar = ({ isPanel }: Props) => { const { t } = useTranslation() const { isInstalledApp, @@ -112,18 +107,18 @@ const Sidebar = ({ isPanel, panelVisible }: Props) => {
{appData?.site.title}
{!isMobile && isSidebarCollapsed && ( handleSidebarCollapse(false)}> - + )} {!isMobile && !isSidebarCollapsed && ( handleSidebarCollapse(true)}> - + )}
@@ -156,7 +151,6 @@ const Sidebar = ({ isPanel, panelVisible }: Props) => { hideLogout={isInstalledApp} placement="top-start" data={appData?.site} - forceClose={isPanel && !panelVisible} /> {/* powered by */}
diff --git a/web/app/components/base/chip/__tests__/index.spec.tsx b/web/app/components/base/chip/__tests__/index.spec.tsx index 40ecbb7a33..826441779d 100644 --- a/web/app/components/base/chip/__tests__/index.spec.tsx +++ b/web/app/components/base/chip/__tests__/index.spec.tsx @@ -40,7 +40,7 @@ describe('Chip', () => { // Helper function to get the trigger element const getTrigger = (container: HTMLElement) => { - return container.querySelector('[role="button"][aria-haspopup="menu"]') as HTMLElement | null + return container.querySelector('button[aria-haspopup="menu"], [role="button"][aria-haspopup="menu"]') as HTMLElement | null } // Helper function to open dropdown panel @@ -98,12 +98,11 @@ describe('Chip', () => { }) it('should hide left icon when showLeftIcon is false', () => { - renderChip({ showLeftIcon: false }) + renderChip({ showLeftIcon: false, value: '' }) // When showLeftIcon is false, there should be no filter icon before the text - const textElement = screen.getByText('All Items') - const parent = textElement.closest('[role="button"]') - const icons = parent?.querySelectorAll('svg') + const trigger = getTrigger(document.body) + const icons = trigger?.querySelectorAll('svg') // Should only have the arrow icon, not the filter icon expect(icons?.length).toBe(1) @@ -190,12 +189,7 @@ describe('Chip', () => { it('should call onClear when clear button is clicked', () => { const { container } = renderChip({ value: 'active' }) - // Find the close icon (last SVG in the trigger) and click its parent - const trigger = getTrigger(container) - const svgs = trigger?.querySelectorAll('svg') - // The close icon should be the last SVG element - const closeIcon = svgs?.[svgs.length - 1] - const clearButton = closeIcon?.parentElement + const clearButton = container.querySelector('button[aria-label="common.operation.clear"]') expect(clearButton)!.toBeInTheDocument() if (clearButton) @@ -210,10 +204,7 @@ describe('Chip', () => { const trigger = getTrigger(container) expect(trigger)!.toHaveAttribute('aria-expanded', 'false') - // Find the close icon (last SVG) and click its parent - const svgs = trigger?.querySelectorAll('svg') - const closeIcon = svgs?.[svgs.length - 1] - const clearButton = closeIcon?.parentElement + const clearButton = container.querySelector('button[aria-label="common.operation.clear"]') if (clearButton) fireEvent.click(clearButton) diff --git a/web/app/components/base/chip/index.tsx b/web/app/components/base/chip/index.tsx index 6ba5d9cb44..3190fb0ccb 100644 --- a/web/app/components/base/chip/index.tsx +++ b/web/app/components/base/chip/index.tsx @@ -1,4 +1,4 @@ -import type { FC } from 'react' +import type { ReactNode } from 'react' import { cn } from '@langgenius/dify-ui/cn' import { DropdownMenu, @@ -8,24 +8,28 @@ import { DropdownMenuTrigger, } from '@langgenius/dify-ui/dropdown-menu' import { RiArrowDownSLine, RiCheckLine, RiCloseCircleFill, RiFilter3Line } from '@remixicon/react' -import { useMemo, useState } from 'react' +import { useMemo } from 'react' +import { useTranslation } from 'react-i18next' -export type Item = { - value: number | string +type ItemValue = number | string + +export type Item = { + value: T name: string -} & Record +} & Record -type Props = { +type Props = { className?: string panelClassName?: string showLeftIcon?: boolean - leftIcon?: any - value: number | string - items: Item[] - onSelect: (item: any) => void + leftIcon?: ReactNode + value: T + items: Item[] + onSelect: (item: Item) => void onClear: () => void } -const Chip: FC = ({ + +function Chip({ className, panelClassName, showLeftIcon = true, @@ -34,31 +38,24 @@ const Chip: FC = ({ items, onSelect, onClear, -}) => { - const [open, setOpen] = useState(false) - +}: Props) { + const { t } = useTranslation() const triggerContent = useMemo(() => { return items.find(item => item.value === value)?.name || '' }, [items, value]) return ( - +
- } - > -
+ > + {showLeftIcon && (
{leftIcon || ( @@ -72,19 +69,21 @@ const Chip: FC = ({
{!value && } - {!!value && ( -
{ - e.stopPropagation() - onClear() - }} - > - -
- )} -
- + + {!!value && ( + + )} +
+} & Record type Props = { order?: string value: number | string items: Item[] - onSelect: (item: any) => void + onSelect: (value: string) => void } -const Sort: FC = ({ + +function Sort({ order, value, items, onSelect, -}) => { +}: Props) { const { t } = useTranslation() - const [open, setOpen] = useState(false) const triggerContent = useMemo(() => { return items.find(item => item.value === value)?.name || '' @@ -37,28 +36,18 @@ const Sort: FC = ({ return (
- +
} + className="flex min-h-8 cursor-pointer items-center rounded-l-lg border-none bg-components-input-bg-normal px-2 py-1 hover:bg-state-base-hover-alt data-popup-open:bg-state-base-hover-alt! data-popup-open:hover:bg-state-base-hover-alt" > -
-
-
{t('filter.sortBy', { ns: 'appLog' })}
-
- {triggerContent} -
+
+
{t('filter.sortBy', { ns: 'appLog' })}
+
+ {triggerContent}
-
+ = ({ +
diff --git a/web/app/components/datasets/create-from-pipeline/list/template-card/actions.tsx b/web/app/components/datasets/create-from-pipeline/list/template-card/actions.tsx index 34b0673c59..b6e3fc63a4 100644 --- a/web/app/components/datasets/create-from-pipeline/list/template-card/actions.tsx +++ b/web/app/components/datasets/create-from-pipeline/list/template-card/actions.tsx @@ -59,7 +59,7 @@ const Actions = ({ aria-label={t('operation.more', { ns: 'common' })} className={cn( 'flex size-8 cursor-pointer items-center justify-center rounded-lg p-0 shadow-xs shadow-shadow-shadow-3', - isMoreOperationsOpen && 'bg-state-base-hover', + 'data-popup-open:bg-state-base-hover', )} onClick={e => e.stopPropagation()} > diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/dropdown/__tests__/index.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/dropdown/__tests__/index.spec.tsx index d57e8340e9..d93de876e7 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/dropdown/__tests__/index.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/dropdown/__tests__/index.spec.tsx @@ -368,9 +368,9 @@ describe('Dropdown', () => { // Act - Open dropdown fireEvent.click(button) - // Assert - Open state: should have bg-state-base-hover + // Assert - Open state is exposed declaratively via data-popup-open await waitFor(() => { - expect(button)!.toHaveClass('bg-state-base-hover') + expect(button).toHaveAttribute('data-popup-open') }) }) }) diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/dropdown/__tests__/item.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/dropdown/__tests__/item.spec.tsx index 4437305ad4..fd582a3bae 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/dropdown/__tests__/item.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/dropdown/__tests__/item.spec.tsx @@ -1,7 +1,19 @@ +import type { ReactElement } from 'react' +import { DropdownMenu, DropdownMenuContent } from '@langgenius/dify-ui/dropdown-menu' import { fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import Item from '../item' +const renderItem = (ui: ReactElement) => { + return render( + + + {ui} + + , + ) +} + describe('Item', () => { const defaultProps = { name: 'Documents', @@ -16,7 +28,7 @@ describe('Item', () => { // Rendering: verify the breadcrumb name is displayed describe('Rendering', () => { it('should render breadcrumb name', () => { - render() + renderItem() expect(screen.getByText('Documents')).toBeInTheDocument() }) @@ -25,7 +37,7 @@ describe('Item', () => { // User interactions: clicking triggers callback with correct index describe('User Interactions', () => { it('should call onBreadcrumbClick with correct index on click', () => { - render() + renderItem() fireEvent.click(screen.getByText('Documents')) @@ -34,7 +46,7 @@ describe('Item', () => { }) it('should pass different index values correctly', () => { - render() + renderItem() fireEvent.click(screen.getByText('Documents')) diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/dropdown/__tests__/menu.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/dropdown/__tests__/menu.spec.tsx index c8c6b8fec3..80c6f7bfb0 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/dropdown/__tests__/menu.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/dropdown/__tests__/menu.spec.tsx @@ -1,7 +1,19 @@ +import type { ReactElement } from 'react' +import { DropdownMenu, DropdownMenuContent } from '@langgenius/dify-ui/dropdown-menu' import { fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import Menu from '../menu' +const renderMenu = (ui: ReactElement) => { + return render( + + + {ui} + + , + ) +} + describe('Menu', () => { const defaultProps = { breadcrumbs: ['Folder A', 'Folder B', 'Folder C'], @@ -16,7 +28,7 @@ describe('Menu', () => { // Rendering: verify all breadcrumb items are displayed describe('Rendering', () => { it('should render all breadcrumb items', () => { - render() + renderMenu() expect(screen.getByText('Folder A')).toBeInTheDocument() expect(screen.getByText('Folder B')).toBeInTheDocument() @@ -36,7 +48,7 @@ describe('Menu', () => { // Index mapping: startIndex offsets are applied correctly describe('Index Mapping', () => { it('should pass correct index (startIndex + offset) to each item', () => { - render() + renderMenu() fireEvent.click(screen.getByText('Folder A')) expect(defaultProps.onBreadcrumbClick).toHaveBeenCalledWith(1) @@ -49,7 +61,7 @@ describe('Menu', () => { }) it('should offset from startIndex of zero', () => { - render( + renderMenu( { // User interactions: clicking items triggers the callback describe('User Interactions', () => { it('should call onBreadcrumbClick with correct index when item clicked', () => { - render() + renderMenu() fireEvent.click(screen.getByText('Folder B')) diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/dropdown/index.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/dropdown/index.tsx index a77ba87ac6..17e824ff97 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/dropdown/index.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/dropdown/index.tsx @@ -5,7 +5,6 @@ import { DropdownMenuTrigger, } from '@langgenius/dify-ui/dropdown-menu' import * as React from 'react' -import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' import Menu from './menu' @@ -21,18 +20,9 @@ const Dropdown = ({ onBreadcrumbClick, }: DropdownProps) => { const { t } = useTranslation() - const [open, setOpen] = useState(false) - - const handleBreadCrumbClick = useCallback((index: number) => { - onBreadcrumbClick(index) - setOpen(false) - }, [onBreadcrumbClick]) return ( - + @@ -56,7 +45,7 @@ const Dropdown = ({ / diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/dropdown/item.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/dropdown/item.tsx index 6f04ede88a..beb217f6b7 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/dropdown/item.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/dropdown/item.tsx @@ -1,5 +1,5 @@ +import { DropdownMenuItem } from '@langgenius/dify-ui/dropdown-menu' import * as React from 'react' -import { useCallback } from 'react' type ItemProps = { name: string @@ -12,17 +12,13 @@ const Item = ({ index, onBreadcrumbClick, }: ItemProps) => { - const handleClick = useCallback(() => { - onBreadcrumbClick(index) - }, [index, onBreadcrumbClick]) - return ( -
onBreadcrumbClick(index)} > {name} -
+ ) } diff --git a/web/app/components/datasets/documents/detail/completed/__tests__/status-item.spec.tsx b/web/app/components/datasets/documents/detail/completed/__tests__/status-item.spec.tsx deleted file mode 100644 index da7e301e4d..0000000000 --- a/web/app/components/datasets/documents/detail/completed/__tests__/status-item.spec.tsx +++ /dev/null @@ -1,93 +0,0 @@ -import { render, screen } from '@testing-library/react' -import { describe, expect, it } from 'vitest' -import StatusItem from '../status-item' - -describe('StatusItem', () => { - const defaultItem = { - value: '1', - name: 'Test Status', - } - - describe('Rendering', () => { - it('should render without crashing', () => { - const { container } = render() - - expect(container.firstChild).toBeInTheDocument() - }) - - it('should render item name', () => { - render() - - expect(screen.getByText('Test Status')).toBeInTheDocument() - }) - - it('should render with correct styling classes', () => { - const { container } = render() - - const wrapper = container.firstChild as HTMLElement - expect(wrapper).toHaveClass('flex') - expect(wrapper).toHaveClass('items-center') - expect(wrapper).toHaveClass('justify-between') - }) - }) - - describe('Props', () => { - it('should show check icon when selected is true', () => { - const { container } = render() - - // Assert - RiCheckLine icon should be present - const checkIcon = container.querySelector('.text-text-accent') - expect(checkIcon).toBeInTheDocument() - }) - - it('should not show check icon when selected is false', () => { - const { container } = render() - - // Assert - RiCheckLine icon should not be present - const checkIcon = container.querySelector('.text-text-accent') - expect(checkIcon).not.toBeInTheDocument() - }) - - it('should render different item names', () => { - const item = { value: '2', name: 'Different Status' } - render() - - expect(screen.getByText('Different Status')).toBeInTheDocument() - }) - }) - - describe('Memoization', () => { - it('should render consistently with same props', () => { - const { container: container1 } = render() - const { container: container2 } = render() - - expect(container1.textContent).toBe(container2.textContent) - }) - }) - - describe('Edge Cases', () => { - it('should handle empty item name', () => { - const emptyItem = { value: '1', name: '' } - - const { container } = render() - - expect(container.firstChild).toBeInTheDocument() - }) - - it('should handle special characters in item name', () => { - const specialItem = { value: '1', name: 'Status <>&"' } - - render() - - expect(screen.getByText('Status <>&"')).toBeInTheDocument() - }) - - it('should maintain structure when rerendered', () => { - const { rerender } = render() - - rerender() - - expect(screen.getByText('Test Status')).toBeInTheDocument() - }) - }) -}) diff --git a/web/app/components/datasets/documents/detail/completed/components/__tests__/menu-bar.spec.tsx b/web/app/components/datasets/documents/detail/completed/components/__tests__/menu-bar.spec.tsx index 9ca5601e6a..25ecded349 100644 --- a/web/app/components/datasets/documents/detail/completed/components/__tests__/menu-bar.spec.tsx +++ b/web/app/components/datasets/documents/detail/completed/components/__tests__/menu-bar.spec.tsx @@ -11,10 +11,6 @@ vi.mock('../../display-toggle', () => ({ ), })) -vi.mock('../../status-item', () => ({ - default: ({ item }: { item: { name: string } }) =>
{item.name}
, -})) - describe('MenuBar', () => { const defaultProps = { hasSelectableSegments: true, @@ -87,22 +83,19 @@ describe('MenuBar', () => { expect(defaultProps.onInputChange).toHaveBeenCalledWith('') }) - it('should render select with status items via renderOption', () => { + it('should render the selected status in the trigger', () => { renderMenuBar() expect(screen.getByText('All')).toBeInTheDocument() }) - it('should call renderOption for each item when dropdown is opened', async () => { + it('should render status options when dropdown is opened', async () => { renderMenuBar() const selectButton = screen.getByRole('combobox') fireEvent.click(selectButton) - // After opening, renderOption is called for each item, rendering the mocked StatusItem - const statusItems = await screen.findAllByTestId('status-item') - expect(statusItems.length).toBe(3) - expect(statusItems[0]).toHaveTextContent('All') - expect(statusItems[1]).toHaveTextContent('Enabled') - expect(statusItems[2]).toHaveTextContent('Disabled') + expect(await screen.findByRole('option', { name: 'All' })).toBeInTheDocument() + expect(screen.getByRole('option', { name: 'Enabled' })).toBeInTheDocument() + expect(screen.getByRole('option', { name: 'Disabled' })).toBeInTheDocument() }) }) diff --git a/web/app/components/datasets/documents/detail/completed/components/menu-bar.tsx b/web/app/components/datasets/documents/detail/completed/components/menu-bar.tsx index 09a3db925c..8937246406 100644 --- a/web/app/components/datasets/documents/detail/completed/components/menu-bar.tsx +++ b/web/app/components/datasets/documents/detail/completed/components/menu-bar.tsx @@ -1,12 +1,10 @@ 'use client' import { Checkbox } from '@langgenius/dify-ui/checkbox' -import { cn } from '@langgenius/dify-ui/cn' import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select' import { useTranslation } from 'react-i18next' import Divider from '@/app/components/base/divider' import Input from '@/app/components/base/input' import DisplayToggle from '../display-toggle' -import StatusItem from '../status-item' import s from '../style.module.css' type Item = { @@ -67,15 +65,14 @@ function MenuBar({ onChangeStatus(nextItem) }} > - + {selectedStatus?.name ?? ''} {statusList.map(item => ( - - {item.name} - - {item.value === selectDefaultValue && } + + {item.name} + ))} diff --git a/web/app/components/datasets/documents/detail/completed/status-item.tsx b/web/app/components/datasets/documents/detail/completed/status-item.tsx deleted file mode 100644 index ebb4b661e6..0000000000 --- a/web/app/components/datasets/documents/detail/completed/status-item.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import type { FC } from 'react' -import { RiCheckLine } from '@remixicon/react' -import * as React from 'react' - -type StatusOption = { - value: string | number - name: string -} - -type IStatusItemProps = { - item: StatusOption - selected: boolean -} - -const StatusItem: FC = ({ - item, - selected, -}) => { - return ( -
- {item.name} - {selected && } -
- ) -} - -export default React.memo(StatusItem) diff --git a/web/app/components/datasets/documents/detail/completed/style.module.css b/web/app/components/datasets/documents/detail/completed/style.module.css index 3009c9e41a..8cfbc178c0 100644 --- a/web/app/components/datasets/documents/detail/completed/style.module.css +++ b/web/app/components/datasets/documents/detail/completed/style.module.css @@ -33,9 +33,6 @@ background: linear-gradient(to left, white, 90%, transparent); @apply text-primary-600 font-semibold text-xs absolute right-0 hidden h-12 pl-12 items-center; } -.select { - @apply !h-8 !w-[100px] !py-0 !pr-5 !shadow-none; -} .segModalContent { @apply h-96 text-gray-800 text-base break-all overflow-y-scroll; white-space: pre-line; diff --git a/web/app/components/datasets/documents/detail/segment-add/__tests__/index.spec.tsx b/web/app/components/datasets/documents/detail/segment-add/__tests__/index.spec.tsx index b31641a076..800e9d79d6 100644 --- a/web/app/components/datasets/documents/detail/segment-add/__tests__/index.spec.tsx +++ b/web/app/components/datasets/documents/detail/segment-add/__tests__/index.spec.tsx @@ -143,6 +143,18 @@ describe('SegmentAdd', () => { expect(mockShowBatchModal).toHaveBeenCalledTimes(1) }) + + it('should show plan upgrade modal instead of batch modal for sandbox users', async () => { + mockPlan = { type: Plan.sandbox } + const mockShowBatchModal = vi.fn() + render() + + fireEvent.click(screen.getByRole('button', { name: /list\.action\.batchAdd/i })) + fireEvent.click(await screen.findByRole('menuitem', { name: /list\.action\.batchAdd/i })) + + expect(screen.getByRole('dialog')).toBeInTheDocument() + expect(mockShowBatchModal).not.toHaveBeenCalled() + }) }) // Disabled state (embedding) diff --git a/web/app/components/datasets/documents/detail/segment-add/index.tsx b/web/app/components/datasets/documents/detail/segment-add/index.tsx index 566a14720e..7b171be45b 100644 --- a/web/app/components/datasets/documents/detail/segment-add/index.tsx +++ b/web/app/components/datasets/documents/detail/segment-add/index.tsx @@ -7,7 +7,7 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from '@langgenius/dify-ui/dropdown-menu' -import { useRef, useState } from 'react' +import { useState } from 'react' import { useTranslation } from 'react-i18next' import { PlanUpgradeModal } from '@/app/components/billing/plan-upgrade-modal' import { Plan } from '@/app/components/billing/type' @@ -30,9 +30,7 @@ export function SegmentAdd({ embedding, }: SegmentAddProps) { const { t } = useTranslation() - const [isBatchMenuOpen, setIsBatchMenuOpen] = useState(false) const [isPlanUpgradeModalOpen, setIsPlanUpgradeModalOpen] = useState(false) - const batchMenuAnchorRef = useRef(null) const { plan, enableBilling } = useProviderContext() const canAddChunks = !enableBilling || plan.type !== Plan.sandbox @@ -40,24 +38,13 @@ export function SegmentAdd({ ? 'text-components-button-secondary-accent-text-disabled' : 'text-components-button-secondary-accent-text' - const handleAddClick = () => { + const openSegmentDialog = (openDialog: () => void) => { if (!canAddChunks) { setIsPlanUpgradeModalOpen(true) return } - showNewSegmentModal() - } - - const handleBatchAddClick = () => { - setIsBatchMenuOpen(false) - - if (!canAddChunks) { - setIsPlanUpgradeModalOpen(true) - return - } - - showBatchModal() + openDialog() } if (importStatus) { @@ -115,7 +102,6 @@ export function SegmentAdd({ return (
openSegmentDialog(showNewSegmentModal)} disabled={embedding} > @@ -133,29 +119,25 @@ export function SegmentAdd({ {t('list.action.addButton', { ns: 'datasetDocuments' })} - + -
- -
+
openSegmentDialog(showBatchModal)} > {t('list.action.batchAdd', { ns: 'datasetDocuments' })} diff --git a/web/app/components/datasets/documents/style.module.css b/web/app/components/datasets/documents/style.module.css index dccc964942..ef11b7bb45 100644 --- a/web/app/components/datasets/documents/style.module.css +++ b/web/app/components/datasets/documents/style.module.css @@ -17,7 +17,7 @@ @apply !p-2 !border-[0.5px] !border-components-button-secondary-border !bg-components-button-secondary-bg shadow-xs shadow-shadow-shadow-3 hover:!border-components-button-secondary-border-hover hover:!bg-components-button-secondary-bg-hover; } .actionItem { - @apply h-9 py-2 px-3 mx-1 flex items-center gap-2 rounded-lg border-none bg-transparent text-left hover:bg-state-base-hover cursor-pointer; + @apply h-9 w-[calc(100%-8px)] py-2 px-3 mx-1 flex items-center gap-2 rounded-lg border-none bg-transparent text-left hover:bg-state-base-hover cursor-pointer; } .deleteActionItem { @apply hover:!bg-state-destructive-hover; 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 index fc32d0b8af..481819599e 100644 --- a/web/app/components/datasets/list/dataset-card/components/operations-dropdown.tsx +++ b/web/app/components/datasets/list/dataset-card/components/operations-dropdown.tsx @@ -42,7 +42,7 @@ const OperationsDropdown = ({ 'border-components-actionbar-border bg-components-button-secondary-bg p-0 shadow-lg ring-2 shadow-shadow-shadow-5 ring-components-button-secondary-bg ring-inset', 'transition-colors hover:border-components-actionbar-border hover:bg-state-base-hover', 'focus-visible:bg-state-base-hover focus-visible:ring-1 focus-visible:ring-components-input-border-hover focus-visible:outline-hidden focus-visible:ring-inset', - open && 'bg-state-base-hover', + 'data-popup-open:bg-state-base-hover', )} aria-label="Dataset operations" > diff --git a/web/app/components/datasets/settings/permission-selector/index.tsx b/web/app/components/datasets/settings/permission-selector/index.tsx index d4611a8eda..067bf68da4 100644 --- a/web/app/components/datasets/settings/permission-selector/index.tsx +++ b/web/app/components/datasets/settings/permission-selector/index.tsx @@ -101,7 +101,7 @@ const PermissionSelector = ({
+
{ isOnlyMe && ( <> @@ -169,8 +169,7 @@ const PermissionSelector = ({ } diff --git a/web/app/components/explore/item-operation/__tests__/index.spec.tsx b/web/app/components/explore/item-operation/__tests__/index.spec.tsx index 268fe8ebea..f0c35e93f0 100644 --- a/web/app/components/explore/item-operation/__tests__/index.spec.tsx +++ b/web/app/components/explore/item-operation/__tests__/index.spec.tsx @@ -1,4 +1,4 @@ -import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { fireEvent, render, screen } from '@testing-library/react' import * as React from 'react' import ItemOperation from '../index' @@ -13,11 +13,21 @@ vi.mock('@langgenius/dify-ui/dropdown-menu', () => { } return { - DropdownMenu: ({ children, open, onOpenChange }: { children: React.ReactNode, open: boolean, onOpenChange?: (open: boolean) => void }) => ( - -
{children}
-
- ), + DropdownMenu: ({ + children, + modal, + }: { + children: React.ReactNode + modal?: boolean + }) => { + const [isOpen, setIsOpen] = React.useState(false) + + return ( + +
{children}
+
+ ) + }, DropdownMenuTrigger: ({ children, onClick, @@ -158,37 +168,41 @@ describe('ItemOperation', () => { }) describe('Edge Cases', () => { - it('should close the menu when mouse leaves the panel and item is not hovering', async () => { - renderComponent() + it('should keep the menu open when item hover leaves', async () => { + const { props, rerender } = renderComponent({ isItemHovering: true }) fireEvent.click(screen.getByTestId('item-operation-trigger')) await screen.findByText('explore.sidebar.action.pin') - const menu = screen.getByTestId('dropdown-content') - fireEvent.mouseEnter(menu) - fireEvent.mouseLeave(menu) + rerender() - await waitFor(() => { - expect(screen.queryByText('explore.sidebar.action.pin')).not.toBeInTheDocument() - }) + expect(screen.getByText('explore.sidebar.action.pin')).toBeInTheDocument() }) - it('should stop propagation when clicking inside the dropdown content', async () => { + it('should render a non-modal menu', () => { + renderComponent() + + expect(screen.getByTestId('dropdown-menu')).toHaveAttribute('data-modal', 'false') + }) + + it('should stop propagation when clicking menu actions', async () => { const onParentClick = vi.fn() + const togglePin = vi.fn() render(
, ) fireEvent.click(screen.getByTestId('item-operation-trigger')) - fireEvent.click(await screen.findByTestId('dropdown-content')) + fireEvent.click(await screen.findByText('explore.sidebar.action.pin')) + expect(togglePin).toHaveBeenCalledTimes(1) expect(onParentClick).not.toHaveBeenCalled() }) }) diff --git a/web/app/components/explore/item-operation/index.tsx b/web/app/components/explore/item-operation/index.tsx index d7942170e2..11a740a131 100644 --- a/web/app/components/explore/item-operation/index.tsx +++ b/web/app/components/explore/item-operation/index.tsx @@ -7,13 +7,7 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from '@langgenius/dify-ui/dropdown-menu' -import { - RiDeleteBinLine, - RiEditLine, -} from '@remixicon/react' -import { useBoolean } from 'ahooks' import * as React from 'react' -import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import { Pin02 } from '../../base/icons/src/vender/line/general' import s from './style.module.css' @@ -41,20 +35,17 @@ const ItemOperation: FC = ({ }) => { const { t } = useTranslation('explore') const { t: tCommon } = useTranslation('common') - const [open, setOpen] = useState(false) - const [isHovering, { setTrue: setIsHovering, setFalse: setNotHovering }] = useBoolean(false) - useEffect(() => { - if (!isItemHovering && !isHovering) - setOpen(false) - }, [isItemHovering, isHovering]) + return ( - + { e.stopPropagation() }} @@ -65,11 +56,6 @@ const ItemOperation: FC = ({ placement="bottom-end" sideOffset={4} popupClassName="min-w-[120px]" - popupProps={{ - onMouseEnter: setIsHovering, - onMouseLeave: setNotHovering, - onClick: e => e.stopPropagation(), - }} > = ({ onRenameConversation?.() }} > - + {t('sidebar.action.rename')} )} @@ -101,7 +87,7 @@ const ItemOperation: FC = ({ onDelete() }} > - + {t('sidebar.action.delete')} )} diff --git a/web/app/components/explore/item-operation/style.module.css b/web/app/components/explore/item-operation/style.module.css index 62b4e28088..c84ab8e4d6 100644 --- a/web/app/components/explore/item-operation/style.module.css +++ b/web/app/components/explore/item-operation/style.module.css @@ -20,6 +20,7 @@ mask-image: url(~@/assets/action.svg); } +body .btn[data-popup-open], body .btn.open, body .btn:hover { background: url(~@/assets/action.svg) center center no-repeat transparent; diff --git a/web/app/components/header/account-dropdown/__tests__/support.spec.tsx b/web/app/components/header/account-dropdown/__tests__/support.spec.tsx index 38be78abf5..9b78e66683 100644 --- a/web/app/components/header/account-dropdown/__tests__/support.spec.tsx +++ b/web/app/components/header/account-dropdown/__tests__/support.spec.tsx @@ -42,8 +42,6 @@ vi.mock('@/config', async (importOriginal) => { }) describe('Support', () => { - const mockCloseAccountDropdown = vi.fn() - const baseAppContextValue: AppContextValue = { userProfile: { id: '1', @@ -105,7 +103,7 @@ describe('Support', () => { { }}> open - + , ) @@ -189,7 +187,7 @@ describe('Support', () => { }) describe('Interactions and Links', () => { - it('should call toggleZendeskWindow and closeAccountDropdown when "Contact Us" is clicked', () => { + it('should call toggleZendeskWindow when "Contact Us" is clicked', () => { // Act renderSupport() fireEvent.click(screen.getByText('common.userProfile.support')) @@ -197,7 +195,6 @@ describe('Support', () => { // Assert expect(window.zE).toHaveBeenCalledWith('messenger', 'open') - expect(mockCloseAccountDropdown).toHaveBeenCalled() }) it('should have correct forum and community links', () => { diff --git a/web/app/components/header/account-dropdown/index.tsx b/web/app/components/header/account-dropdown/index.tsx index cc1bd06641..3744db6e81 100644 --- a/web/app/components/header/account-dropdown/index.tsx +++ b/web/app/components/header/account-dropdown/index.tsx @@ -2,7 +2,6 @@ import type { MouseEventHandler, ReactNode } from 'react' import { Avatar } from '@langgenius/dify-ui/avatar' -import { cn } from '@langgenius/dify-ui/cn' import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuLinkItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@langgenius/dify-ui/dropdown-menu' import { useSuspenseQuery } from '@tanstack/react-query' import { useState } from 'react' @@ -110,7 +109,6 @@ function AccountMenuSection({ children }: AccountMenuSectionProps) { export default function AppSelector() { const router = useRouter() const [aboutVisible, setAboutVisible] = useState(false) - const [isAccountMenuOpen, setIsAccountMenuOpen] = useState(false) const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) const { t } = useTranslation() @@ -136,10 +134,10 @@ export default function AppSelector() { return (
- + @@ -185,7 +183,7 @@ export default function AppSelector() { label={t('userProfile.helpCenter', { ns: 'common' })} trailing={} /> - setIsAccountMenuOpen(false)} /> + {IS_CLOUD_EDITION && isCurrentWorkspaceOwner && } @@ -214,7 +212,6 @@ export default function AppSelector() { label={t('userProfile.about', { ns: 'common' })} onClick={() => { setAboutVisible(true) - setIsAccountMenuOpen(false) }} trailing={(
diff --git a/web/app/components/header/account-dropdown/support.tsx b/web/app/components/header/account-dropdown/support.tsx index 9a7023c415..8f7dba2985 100644 --- a/web/app/components/header/account-dropdown/support.tsx +++ b/web/app/components/header/account-dropdown/support.tsx @@ -8,12 +8,8 @@ import { useProviderContext } from '@/context/provider-context' import { mailToSupport } from '../utils/util' import { ExternalLinkIndicator, MenuItemContent } from './menu-item-content' -type SupportProps = { - closeAccountDropdown: () => void -} - // Submenu-only: this component must be rendered within an existing DropdownMenu root. -export default function Support({ closeAccountDropdown }: SupportProps) { +export default function Support() { const { t } = useTranslation() const { plan } = useProviderContext() const { userProfile, langGeniusVersionInfo } = useAppContext() @@ -37,7 +33,6 @@ export default function Support({ closeAccountDropdown }: SupportProps) { className="justify-between" onClick={() => { toggleZendeskWindow(true) - closeAccountDropdown() }} > { const { t } = useTranslation() - const [open, setOpen] = useState(false) const { type, } = credentialItem const handleAction = useCallback((action: string) => { - setOpen(false) queueMicrotask(() => { if (action === 'rename') { onRename?.() @@ -45,12 +41,12 @@ const Operator = ({ }, [credentialItem, onAction, onRename]) return ( - + diff --git a/web/app/components/header/account-setting/members-page/operation/index.tsx b/web/app/components/header/account-setting/members-page/operation/index.tsx index 19d6dfbaa6..fe72f972d1 100644 --- a/web/app/components/header/account-setting/members-page/operation/index.tsx +++ b/web/app/components/header/account-setting/members-page/operation/index.tsx @@ -1,6 +1,5 @@ 'use client' import type { Member } from '@/models/common' -import { cn } from '@langgenius/dify-ui/cn' import { DropdownMenu, DropdownMenuContent, @@ -9,7 +8,7 @@ import { DropdownMenuTrigger, } from '@langgenius/dify-ui/dropdown-menu' import { toast } from '@langgenius/dify-ui/toast' -import { memo, useMemo, useState } from 'react' +import { memo, useMemo } from 'react' import { useTranslation } from 'react-i18next' import { useProviderContext } from '@/context/provider-context' import { deleteMemberOrCancelInvitation, updateMemberRole } from '@/service/common' @@ -30,7 +29,6 @@ const nonOwnerRoles = ['admin', 'editor', 'normal'] as const const isNonOwnerRole = (role: Member['role']) => role !== 'owner' const Operation = ({ member, operatorRole, onOperate }: IOperationProps) => { - const [open, setOpen] = useState(false) const { t } = useTranslation() const { datasetOperatorEnabled } = useProviderContext() const RoleMap = { @@ -59,7 +57,6 @@ const Operation = ({ member, operatorRole, onOperate }: IOperationProps) => { }, [operatorRole, datasetOperatorEnabled]) const canRemoveMember = operatorRole === 'owner' || (operatorRole === 'admin' && isNonOwnerRole(member.role)) const handleDeleteMemberOrCancelInvitation = async () => { - setOpen(false) try { await deleteMemberOrCancelInvitation({ url: `/workspaces/current/members/${member.id}` }) onOperate() @@ -69,7 +66,6 @@ const Operation = ({ member, operatorRole, onOperate }: IOperationProps) => { } } const handleUpdateMemberRole = async (role: string) => { - setOpen(false) try { await updateMemberRole({ url: `/workspaces/current/members/${member.id}/update-role`, @@ -82,12 +78,12 @@ const Operation = ({ member, operatorRole, onOperate }: IOperationProps) => { } } return ( - + } + className="group flex size-full cursor-pointer items-center justify-between border-none bg-transparent px-3 text-left system-sm-regular text-text-secondary hover:bg-state-base-hover data-popup-open:bg-state-base-hover" > {RoleMap[member.role] || RoleMap.normal} - + { const trigger = screen.getByTestId('member-selector-trigger') await user.click(trigger) - expect(trigger).toHaveClass('bg-state-base-hover-alt') + expect(trigger).toHaveAttribute('data-popup-open') + expect(trigger).toHaveClass('data-popup-open:bg-state-base-hover-alt') }) it('should not match account when neither name nor email contains search value', async () => { diff --git a/web/app/components/header/account-setting/members-page/transfer-ownership-modal/member-selector.tsx b/web/app/components/header/account-setting/members-page/transfer-ownership-modal/member-selector.tsx index 2e6ab10e26..8c60f8244b 100644 --- a/web/app/components/header/account-setting/members-page/transfer-ownership-modal/member-selector.tsx +++ b/web/app/components/header/account-setting/members-page/transfer-ownership-modal/member-selector.tsx @@ -1,7 +1,6 @@ 'use client' import type { FC } from 'react' import { Avatar } from '@langgenius/dify-ui/avatar' -import { cn } from '@langgenius/dify-ui/cn' import { Popover, PopoverContent, @@ -56,7 +55,7 @@ const MemberSelector: FC = ({ render={(
{!currentValue && (
{t('members.transferModal.transferPlaceholder', { ns: 'common' })}
@@ -68,7 +67,7 @@ const MemberSelector: FC = ({
{currentValue.email}
)} -
+
)} /> diff --git a/web/app/components/plugins/plugin-detail-panel/operation-dropdown.tsx b/web/app/components/plugins/plugin-detail-panel/operation-dropdown.tsx index df6902a5a4..20c2ac3a5b 100644 --- a/web/app/components/plugins/plugin-detail-panel/operation-dropdown.tsx +++ b/web/app/components/plugins/plugin-detail-panel/operation-dropdown.tsx @@ -39,16 +39,15 @@ const OperationDropdown: FC = ({ popupClassName, }) => { const { t } = useTranslation() - const [open, setOpen] = React.useState(false) const { data: enable_marketplace } = useSuspenseQuery({ ...systemFeaturesQueryOptions(), select: s => s.enable_marketplace, }) return ( - + diff --git a/web/app/components/plugins/plugin-page/__tests__/install-plugin-dropdown.spec.tsx b/web/app/components/plugins/plugin-page/__tests__/install-plugin-dropdown.spec.tsx index bc39cfdbd4..49ac901af1 100644 --- a/web/app/components/plugins/plugin-page/__tests__/install-plugin-dropdown.spec.tsx +++ b/web/app/components/plugins/plugin-page/__tests__/install-plugin-dropdown.spec.tsx @@ -4,7 +4,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features' import InstallPluginDropdown from '../install-plugin-dropdown' -let portalOpen = false const { mockSystemFeatures, } = vi.hoisted(() => ({ @@ -63,15 +62,22 @@ vi.mock('@langgenius/dify-ui/dropdown-menu', async () => { modal, children, }: { - open: boolean + open?: boolean onOpenChange?: (open: boolean) => void modal?: boolean children: React.ReactNode }) => { - portalOpen = open + const [internalOpen, setInternalOpen] = React.useState(open ?? false) + const isOpen = open ?? internalOpen + const setOpen = (nextOpen: boolean) => { + if (open === undefined) + setInternalOpen(nextOpen) + onOpenChange?.(nextOpen) + } + return ( - -
{children}
+ +
{children}
) }, @@ -99,7 +105,10 @@ vi.mock('@langgenius/dify-ui/dropdown-menu', async () => { children, }: { children: React.ReactNode - }) => portalOpen ?
{children}
: null, + }) => { + const { isOpen } = useDropdownMenuContext() + return isOpen ?
{children}
: null + }, DropdownMenuItem: ({ children, onClick, @@ -150,7 +159,6 @@ vi.mock('@/app/components/plugins/install-plugin/install-from-local-package', () describe('InstallPluginDropdown', () => { beforeEach(() => { vi.clearAllMocks() - portalOpen = false mockSystemFeatures.enable_marketplace = true mockSystemFeatures.plugin_installation_permission.restrict_to_marketplace_only = false }) diff --git a/web/app/components/plugins/plugin-page/install-plugin-dropdown.tsx b/web/app/components/plugins/plugin-page/install-plugin-dropdown.tsx index abcd1bd2b8..3582d127f2 100644 --- a/web/app/components/plugins/plugin-page/install-plugin-dropdown.tsx +++ b/web/app/components/plugins/plugin-page/install-plugin-dropdown.tsx @@ -1,7 +1,6 @@ 'use client' import { Button } from '@langgenius/dify-ui/button' -import { cn } from '@langgenius/dify-ui/cn' import { DropdownMenu, DropdownMenuContent, @@ -11,7 +10,7 @@ import { import { RiAddLine, RiArrowDownSLine } from '@remixicon/react' import { useSuspenseQuery } from '@tanstack/react-query' import { noop } from 'es-toolkit/function' -import { useEffect, useRef, useState } from 'react' +import { useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { FileZip } from '@/app/components/base/icons/src/vender/solid/files' import { Github } from '@/app/components/base/icons/src/vender/solid/general' @@ -36,7 +35,6 @@ const InstallPluginDropdown = ({ }: Props) => { const { t } = useTranslation() const fileInputRef = useRef(null) - const [isMenuOpen, setIsMenuOpen] = useState(false) const [selectedAction, setSelectedAction] = useState(null) const [selectedFile, setSelectedFile] = useState(null) const { data: enable_marketplace } = useSuspenseQuery({ @@ -54,7 +52,6 @@ const InstallPluginDropdown = ({ if (file) { setSelectedFile(file) setSelectedAction('local') - setIsMenuOpen(false) } } @@ -78,20 +75,17 @@ const InstallPluginDropdown = ({ // console.log(res) // } - const [installMethods, setInstallMethods] = useState([]) - useEffect(() => { - const methods = [] + const installMethods = useMemo(() => { + const methods: InstallMethod[] = [] if (enable_marketplace) methods.push({ icon: MagicBox, text: t('source.marketplace', { ns: 'plugin' }), action: 'marketplace' }) - if (plugin_installation_permission.restrict_to_marketplace_only) { - setInstallMethods(methods) - } - else { - methods.push({ icon: Github, text: t('source.github', { ns: 'plugin' }), action: 'github' }) - methods.push({ icon: FileZip, text: t('source.local', { ns: 'plugin' }), action: 'local' }) - setInstallMethods(methods) - } + if (plugin_installation_permission.restrict_to_marketplace_only) + return methods + + methods.push({ icon: Github, text: t('source.github', { ns: 'plugin' }), action: 'github' }) + methods.push({ icon: FileZip, text: t('source.local', { ns: 'plugin' }), action: 'local' }) + return methods }, [plugin_installation_permission, enable_marketplace, t]) const handleInstallMethodSelect = (action: string) => { @@ -111,7 +105,7 @@ const InstallPluginDropdown = ({ } return ( - +
)} > diff --git a/web/app/components/share/text-generation/__tests__/menu-dropdown.spec.tsx b/web/app/components/share/text-generation/__tests__/menu-dropdown.spec.tsx index edad871855..7c8a2c8c95 100644 --- a/web/app/components/share/text-generation/__tests__/menu-dropdown.spec.tsx +++ b/web/app/components/share/text-generation/__tests__/menu-dropdown.spec.tsx @@ -233,25 +233,6 @@ describe('MenuDropdown', () => { }) }) - describe('forceClose prop', () => { - it('should close dropdown when forceClose changes to true', async () => { - const { rerender } = render() - - const triggerButton = screen.getByRole('button') - fireEvent.click(triggerButton) - - await waitFor(() => { - expect(screen.getByText('common.theme.theme')).toBeInTheDocument() - }) - - rerender() - - await waitFor(() => { - expect(screen.queryByText('common.theme.theme')).not.toBeInTheDocument() - }) - }) - }) - describe('placement prop', () => { it('should accept custom placement', () => { render() diff --git a/web/app/components/share/text-generation/menu-dropdown.tsx b/web/app/components/share/text-generation/menu-dropdown.tsx index 004648909f..89983552f7 100644 --- a/web/app/components/share/text-generation/menu-dropdown.tsx +++ b/web/app/components/share/text-generation/menu-dropdown.tsx @@ -2,7 +2,6 @@ import type { Placement } from '@langgenius/dify-ui/dropdown-menu' import type { FC } from 'react' import type { SiteInfo } from '@/models/share' -import { cn } from '@langgenius/dify-ui/cn' import { DropdownMenu, DropdownMenuContent, @@ -12,7 +11,7 @@ import { DropdownMenuTrigger, } from '@langgenius/dify-ui/dropdown-menu' import * as React from 'react' -import { useCallback, useEffect, useState } from 'react' +import { useState } from 'react' import { useTranslation } from 'react-i18next' import ActionButton from '@/app/components/base/action-button' import ThemeSwitcher from '@/app/components/base/theme-switcher' @@ -26,50 +25,37 @@ type Props = { data?: SiteInfo placement?: Placement hideLogout?: boolean - forceClose?: boolean } const MenuDropdown: FC = ({ data, placement, hideLogout, - forceClose, }) => { const webAppAccessMode = useWebAppStore(s => s.webAppAccessMode) const router = useRouter() const pathname = usePathname() const { t } = useTranslation() - const [open, setOpen] = useState(false) const shareCode = useWebAppStore(s => s.shareCode) - const handleLogout = useCallback(async () => { - setOpen(false) + const handleLogout = async () => { await webAppLogout(shareCode!) router.replace(`/webapp-signin?redirect_url=${pathname}`) - }, [pathname, router, setOpen, shareCode]) + } const [show, setShow] = useState(false) - const handleOpenInfoModal = useCallback(() => { - setOpen(false) + const handleOpenInfoModal = () => { queueMicrotask(() => { setShow(true) }) - }, []) - - useEffect(() => { - if (forceClose) - setOpen(false) - }, [forceClose, setOpen]) + } return ( <> - + + )} diff --git a/web/app/components/tools/mcp/detail/__tests__/operation-dropdown.spec.tsx b/web/app/components/tools/mcp/detail/__tests__/operation-dropdown.spec.tsx index bb41d4f25a..e558ffbdac 100644 --- a/web/app/components/tools/mcp/detail/__tests__/operation-dropdown.spec.tsx +++ b/web/app/components/tools/mcp/detail/__tests__/operation-dropdown.spec.tsx @@ -14,11 +14,21 @@ vi.mock('@langgenius/dify-ui/dropdown-menu', async () => { } return { - DropdownMenu: ({ children, open, onOpenChange }: { children: React.ReactNode, open: boolean, onOpenChange?: (open: boolean) => void }) => ( - -
{children}
-
- ), + DropdownMenu: ({ children, open, onOpenChange }: { children: React.ReactNode, open?: boolean, onOpenChange?: (open: boolean) => void }) => { + const [internalOpen, setInternalOpen] = React.useState(open ?? false) + const isOpen = open ?? internalOpen + const setOpen = (nextOpen: boolean) => { + if (open === undefined) + setInternalOpen(nextOpen) + onOpenChange?.(nextOpen) + } + + return ( + +
{children}
+
+ ) + }, DropdownMenuTrigger: ({ children, render, diff --git a/web/app/components/tools/mcp/detail/operation-dropdown.tsx b/web/app/components/tools/mcp/detail/operation-dropdown.tsx index f17391a2ab..0c21e280a1 100644 --- a/web/app/components/tools/mcp/detail/operation-dropdown.tsx +++ b/web/app/components/tools/mcp/detail/operation-dropdown.tsx @@ -13,7 +13,6 @@ import { RiMoreFill, } from '@remixicon/react' import * as React from 'react' -import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' import ActionButton from '@/app/components/base/action-button' @@ -31,16 +30,11 @@ const OperationDropdown: FC = ({ onRemove, }) => { const { t } = useTranslation() - const [open, setOpen] = useState(false) - const handleOpenChange = useCallback((nextOpen: boolean) => { - setOpen(nextOpen) - onOpenChange?.(nextOpen) - }, [onOpenChange]) return ( - + } + render={} > diff --git a/web/app/components/workflow/block-selector/market-place-plugin/__tests__/action.spec.tsx b/web/app/components/workflow/block-selector/market-place-plugin/__tests__/action.spec.tsx index 1d845dd5fc..1b0d890e53 100644 --- a/web/app/components/workflow/block-selector/market-place-plugin/__tests__/action.spec.tsx +++ b/web/app/components/workflow/block-selector/market-place-plugin/__tests__/action.spec.tsx @@ -3,11 +3,10 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { useDownloadPlugin } from '@/service/use-plugins' import OperationDropdown from '../action' const mockDownloadBlob = vi.fn() -const mockRemoveQueries = vi.fn() +const mockDownloadPlugin = vi.fn() vi.mock('next-themes', () => ({ useTheme: () => ({ @@ -15,8 +14,15 @@ vi.mock('next-themes', () => ({ }), })) -vi.mock('@/service/use-plugins', () => ({ - useDownloadPlugin: vi.fn(), +vi.mock('@/service/client', () => ({ + marketplaceQuery: { + downloadPlugin: { + mutationOptions: (options = {}) => ({ + mutationFn: (input: unknown) => mockDownloadPlugin(input), + ...options, + }), + }, + }, })) vi.mock('@/utils/download', () => ({ @@ -37,9 +43,6 @@ const createQueryClient = () => new QueryClient({ const renderComponent = (props?: Partial>) => { const queryClient = createQueryClient() - vi.spyOn(queryClient, 'removeQueries').mockImplementation(((...args) => { - return mockRemoveQueries(...args) - }) as typeof queryClient.removeQueries) return render( @@ -58,10 +61,7 @@ const renderComponent = (props?: Partial { beforeEach(() => { vi.clearAllMocks() - vi.mocked(useDownloadPlugin).mockImplementation((_, enabled) => ({ - data: enabled ? null : null, - isLoading: false, - }) as unknown as ReturnType) + mockDownloadPlugin.mockResolvedValue(new Blob(['plugin zip'], { type: 'application/zip' })) }) it('should render download and view details actions when opened', async () => { @@ -78,28 +78,35 @@ describe('OperationDropdown', () => { await userEvent.setup().click(screen.getByText('common.operation.download')) expect(onOpenChange).toHaveBeenCalledWith(false) - expect(mockRemoveQueries).toHaveBeenCalled() + await waitFor(() => { + expect(mockDownloadPlugin).toHaveBeenCalledWith({ + params: { + organization: 'langgenius', + pluginName: 'test-plugin', + version: '1.0.0', + }, + }) + }) }) - it('should skip download when already loading', async () => { - vi.mocked(useDownloadPlugin).mockReturnValue({ - data: null, - isLoading: true, - } as unknown as ReturnType) + it('should skip duplicate downloads while pending', async () => { + mockDownloadPlugin.mockReturnValue(new Promise(() => {})) renderComponent({ open: true }) - await userEvent.setup().click(screen.getByText('common.operation.download')) + const user = userEvent.setup() + await user.click(screen.getByText('common.operation.download')) - expect(mockRemoveQueries).not.toHaveBeenCalled() + await waitFor(() => { + expect(mockDownloadPlugin).toHaveBeenCalledTimes(1) + }) + + await user.click(screen.getByText('common.operation.download')) + + expect(mockDownloadPlugin).toHaveBeenCalledTimes(1) }) - it('should download the blob when the hook returns data', async () => { - vi.mocked(useDownloadPlugin).mockImplementation((_, enabled) => ({ - data: enabled ? new Blob(['plugin zip'], { type: 'application/zip' }) : null, - isLoading: false, - }) as unknown as ReturnType) - + it('should download the blob when the request returns data', async () => { renderComponent({ open: true }) await userEvent.setup().click(screen.getByText('common.operation.download')) @@ -110,7 +117,6 @@ describe('OperationDropdown', () => { fileName: 'langgenius-test-plugin_1.0.0.zip', }) }) - expect(mockRemoveQueries).toHaveBeenCalled() }) it('should link to the marketplace detail page', () => { diff --git a/web/app/components/workflow/block-selector/market-place-plugin/action.tsx b/web/app/components/workflow/block-selector/market-place-plugin/action.tsx index eacad3de4a..fd0e87c7fc 100644 --- a/web/app/components/workflow/block-selector/market-place-plugin/action.tsx +++ b/web/app/components/workflow/block-selector/market-place-plugin/action.tsx @@ -1,6 +1,5 @@ 'use client' import type { FC } from 'react' -import { cn } from '@langgenius/dify-ui/cn' import { DropdownMenu, DropdownMenuContent, @@ -8,13 +7,12 @@ import { DropdownMenuLinkItem, DropdownMenuTrigger, } from '@langgenius/dify-ui/dropdown-menu' -import { useQueryClient } from '@tanstack/react-query' +import { useMutation } from '@tanstack/react-query' import { useTheme } from 'next-themes' import * as React from 'react' -import { useCallback, useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import ActionButton from '@/app/components/base/action-button' -import { useDownloadPlugin } from '@/service/use-plugins' +import { marketplaceQuery } from '@/service/client' import { downloadBlob } from '@/utils/download' import { getMarketplaceUrl } from '@/utils/var' @@ -35,49 +33,36 @@ const OperationDropdown: FC = ({ }) => { const { t } = useTranslation() const { theme } = useTheme() - const queryClient = useQueryClient() - const setOpen = useCallback((value: boolean) => { - onOpenChange(value) - }, [onOpenChange]) - const [needDownload, setNeedDownload] = useState(false) - const downloadInfo = useMemo(() => ({ - organization: author, - pluginName: name, - version, - }), [author, name, version]) - const { data: blob, isLoading } = useDownloadPlugin(downloadInfo, needDownload) - const handleDownload = useCallback(() => { - if (isLoading) - return - setOpen(false) - queryClient.removeQueries({ - queryKey: ['plugins', 'downloadPlugin', downloadInfo], - exact: true, - }) - setNeedDownload(true) - }, [downloadInfo, isLoading, queryClient, setOpen]) + const downloadMutation = useMutation(marketplaceQuery.downloadPlugin.mutationOptions({ + onSuccess: (blob) => { + downloadBlob({ data: blob, fileName: `${author}-${name}_${version}.zip` }) + }, + })) - useEffect(() => { - if (!needDownload || !blob) + const handleDownload = () => { + if (downloadMutation.isPending) return - const fileName = `${author}-${name}_${version}.zip` - downloadBlob({ data: blob, fileName }) - setNeedDownload(false) - queryClient.removeQueries({ - queryKey: ['plugins', 'downloadPlugin', downloadInfo], - exact: true, + + onOpenChange(false) + downloadMutation.mutate({ + params: { + organization: author, + pluginName: name, + version, + }, }) - }, [author, blob, downloadInfo, name, needDownload, queryClient, version]) + } + return ( diff --git a/web/app/components/workflow/nodes/assigner/components/operation-selector.tsx b/web/app/components/workflow/nodes/assigner/components/operation-selector.tsx index 780e01d949..2cdc804d8c 100644 --- a/web/app/components/workflow/nodes/assigner/components/operation-selector.tsx +++ b/web/app/components/workflow/nodes/assigner/components/operation-selector.tsx @@ -54,7 +54,7 @@ const OperationSelector: FC = ({ >
= ({ {selectedItem && isOperationItem(selectedItem) ? t(`nodes.assigner.operations.${selectedItem.name}`, { ns: 'workflow' }) : t('nodes.assigner.operations.title', { ns: 'workflow' })}
- +
= ({ render={( diff --git a/web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/__tests__/member-selector.spec.tsx b/web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/__tests__/member-selector.spec.tsx index e1d959bd86..d2dfef2795 100644 --- a/web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/__tests__/member-selector.spec.tsx +++ b/web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/__tests__/member-selector.spec.tsx @@ -1,60 +1,8 @@ import type { Member } from '@/models/common' -import { fireEvent, render, screen } from '@testing-library/react' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' import MemberSelector from '../member-selector' -vi.mock('@langgenius/dify-ui/popover', async () => { - const React = await import('react') - const PopoverContext = React.createContext({ - open: false, - setOpen: (_open: boolean) => {}, - }) - - const Popover = ({ - children, - open: controlledOpen, - onOpenChange, - }: { - children: import('react').ReactNode - open?: boolean - onOpenChange?: (open: boolean) => void - }) => { - const [uncontrolledOpen, setUncontrolledOpen] = React.useState(false) - const isControlled = controlledOpen !== undefined - const open = isControlled ? !!controlledOpen : uncontrolledOpen - const setOpen = (nextOpen: boolean) => { - if (!isControlled) - setUncontrolledOpen(nextOpen) - onOpenChange?.(nextOpen) - } - - return ( - - {children} - - ) - } - - const PopoverTrigger = ({ render }: { render: import('react').ReactNode }) => { - const { open, setOpen } = React.useContext(PopoverContext) - return ( -
setOpen(!open)}> - {render} -
- ) - } - - const PopoverContent = ({ children }: { children: import('react').ReactNode }) => { - const { open } = React.useContext(PopoverContext) - return open ?
{children}
: null - } - - return { - Popover, - PopoverTrigger, - PopoverContent, - } -}) - const mockMemberList = vi.hoisted(() => vi.fn()) vi.mock('../member-list', () => ({ @@ -87,7 +35,9 @@ describe('human-input/delivery-method/recipient/member-selector', () => { vi.clearAllMocks() }) - it('should toggle the member list and forward selection props', () => { + it('should toggle the member list and forward selection props', async () => { + const user = userEvent.setup() + render( { expect(screen.queryByTestId('member-list')).not.toBeInTheDocument() - fireEvent.click(trigger) + await user.click(trigger) expect(screen.getByTestId('member-list')).toBeInTheDocument() - expect(trigger).toHaveClass('bg-state-accent-hover') + expect(trigger).toHaveAttribute('data-popup-open') + expect(trigger).toHaveClass('data-popup-open:bg-state-accent-hover') expect(mockMemberList).toHaveBeenCalledWith(expect.objectContaining({ searchValue: '', list: members, email: 'owner@example.com', })) - fireEvent.click(trigger) + await user.click(trigger) expect(screen.queryByTestId('member-list')).not.toBeInTheDocument() }) }) diff --git a/web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/member-selector.tsx b/web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/member-selector.tsx index ad831bc0ed..1e2de58247 100644 --- a/web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/member-selector.tsx +++ b/web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/member-selector.tsx @@ -3,7 +3,6 @@ import type { FC } from 'react' import type { Recipient } from '@/app/components/workflow/nodes/human-input/types' import type { Member } from '@/models/common' import { Button } from '@langgenius/dify-ui/button' -import { cn } from '@langgenius/dify-ui/cn' import { Popover, PopoverContent, @@ -45,7 +44,7 @@ const MemberSelector: FC = ({ diff --git a/web/app/components/workflow/note-node/note-editor/toolbar/__tests__/operator.spec.tsx b/web/app/components/workflow/note-node/note-editor/toolbar/__tests__/operator.spec.tsx index a7959d1252..52e6c6cdf1 100644 --- a/web/app/components/workflow/note-node/note-editor/toolbar/__tests__/operator.spec.tsx +++ b/web/app/components/workflow/note-node/note-editor/toolbar/__tests__/operator.spec.tsx @@ -82,8 +82,18 @@ vi.mock('@langgenius/dify-ui/dropdown-menu', async () => { return ( diff --git a/web/app/components/workflow/note-node/note-editor/toolbar/operator.tsx b/web/app/components/workflow/note-node/note-editor/toolbar/operator.tsx index e5768af574..bb822cea99 100644 --- a/web/app/components/workflow/note-node/note-editor/toolbar/operator.tsx +++ b/web/app/components/workflow/note-node/note-editor/toolbar/operator.tsx @@ -37,12 +37,10 @@ const Operator = ({ onOpenChange={setOpen} > } aria-label={t('operation.more', { ns: 'common' })} className={cn( 'flex size-8 cursor-pointer items-center justify-center rounded-lg text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary', - open && 'bg-state-base-hover text-text-secondary', + 'data-popup-open:bg-state-base-hover data-popup-open:text-text-secondary', )} onMouseDown={(event) => { event.preventDefault() diff --git a/web/app/components/workflow/operator/zoom-in-out.tsx b/web/app/components/workflow/operator/zoom-in-out.tsx index b1467508f8..48d790fd18 100644 --- a/web/app/components/workflow/operator/zoom-in-out.tsx +++ b/web/app/components/workflow/operator/zoom-in-out.tsx @@ -1,5 +1,4 @@ import type { FC } from 'react' -import { cn } from '@langgenius/dify-ui/cn' import { DropdownMenu, DropdownMenuContent, @@ -11,7 +10,6 @@ import { useSuspenseQuery } from '@tanstack/react-query' import { Fragment, memo, - useState, } from 'react' import { useTranslation } from 'react-i18next' import { @@ -26,17 +24,19 @@ import { import { ShortcutKbd } from '../shortcuts/shortcut-kbd' import TipPopup from './tip-popup' -enum ZoomType { - zoomToFit = 'zoomToFit', - zoomTo25 = 'zoomTo25', - zoomTo50 = 'zoomTo50', - zoomTo75 = 'zoomTo75', - zoomTo100 = 'zoomTo100', - zoomTo200 = 'zoomTo200', - toggleUserComments = 'toggleUserComments', - toggleUserCursors = 'toggleUserCursors', - toggleMiniMap = 'toggleMiniMap', -} +const ZoomType = { + zoomToFit: 'zoomToFit', + zoomTo25: 'zoomTo25', + zoomTo50: 'zoomTo50', + zoomTo75: 'zoomTo75', + zoomTo100: 'zoomTo100', + zoomTo200: 'zoomTo200', + toggleUserComments: 'toggleUserComments', + toggleUserCursors: 'toggleUserCursors', + toggleMiniMap: 'toggleMiniMap', +} as const + +type ZoomType = typeof ZoomType[keyof typeof ZoomType] type ZoomInOutProps = { showMiniMap?: boolean @@ -66,7 +66,6 @@ const ZoomInOut: FC = ({ } = useReactFlow() const { zoom } = useViewport() const { handleSyncWorkflowDraft } = useNodesSyncDraft() - const [open, setOpen] = useState(false) const { workflowReadOnly, getWorkflowReadOnly, @@ -126,12 +125,10 @@ const ZoomInOut: FC = ({ ], ] - const handleZoom = (type: string) => { + const handleZoom = (type: ZoomType) => { if (workflowReadOnly) return - setOpen(false) - if (type === ZoomType.zoomToFit) fitView() @@ -199,16 +196,10 @@ const ZoomInOut: FC = ({ - + {Number.parseFloat(`${zoom * 100}`).toFixed(0)} % diff --git a/web/app/components/workflow/workflow-preview/components/zoom-in-out.tsx b/web/app/components/workflow/workflow-preview/components/zoom-in-out.tsx index 6c3938b377..c46357d738 100644 --- a/web/app/components/workflow/workflow-preview/components/zoom-in-out.tsx +++ b/web/app/components/workflow/workflow-preview/components/zoom-in-out.tsx @@ -10,7 +10,6 @@ import { import { Fragment, memo, - useState, } from 'react' import { useTranslation } from 'react-i18next' import { @@ -20,14 +19,16 @@ import { import TipPopup from '@/app/components/workflow/operator/tip-popup' import { ShortcutKbd } from '@/app/components/workflow/shortcuts/shortcut-kbd' -enum ZoomType { - zoomToFit = 'zoomToFit', - zoomTo25 = 'zoomTo25', - zoomTo50 = 'zoomTo50', - zoomTo75 = 'zoomTo75', - zoomTo100 = 'zoomTo100', - zoomTo200 = 'zoomTo200', -} +const ZoomType = { + zoomToFit: 'zoomToFit', + zoomTo25: 'zoomTo25', + zoomTo50: 'zoomTo50', + zoomTo75: 'zoomTo75', + zoomTo100: 'zoomTo100', + zoomTo200: 'zoomTo200', +} as const + +type ZoomType = typeof ZoomType[keyof typeof ZoomType] const ZoomInOut: FC = () => { const { t } = useTranslation() @@ -38,7 +39,6 @@ const ZoomInOut: FC = () => { fitView, } = useReactFlow() const { zoom } = useViewport() - const [open, setOpen] = useState(false) const zoomOptions = [ [ @@ -71,9 +71,7 @@ const ZoomInOut: FC = () => { ], ] - const handleZoom = (type: string) => { - setOpen(false) - + const handleZoom = (type: ZoomType) => { if (type === ZoomType.zoomToFit) fitView() @@ -122,11 +120,8 @@ const ZoomInOut: FC = () => { - - + + {Number.parseFloat(`${zoom * 100}`).toFixed(0)} % diff --git a/web/contract/marketplace.ts b/web/contract/marketplace.ts index 9f2475041e..3fdb344161 100644 --- a/web/contract/marketplace.ts +++ b/web/contract/marketplace.ts @@ -67,3 +67,17 @@ export const templateDetailContract = base } }>()) .output(type<{ data: MarketplaceTemplate }>()) + +export const downloadPluginContract = base + .route({ + path: '/plugins/{organization}/{pluginName}/{version}/download', + method: 'GET', + }) + .input(type<{ + params: { + organization: string + pluginName: string + version: string + } + }>()) + .output(type()) diff --git a/web/contract/router.ts b/web/contract/router.ts index 8e198e981d..1987022551 100644 --- a/web/contract/router.ts +++ b/web/contract/router.ts @@ -52,13 +52,14 @@ import { workflowDraftUpdateFeaturesContract, } from './console/workflow' import { workflowCommentContracts } from './console/workflow-comment' -import { collectionPluginsContract, collectionsContract, searchAdvancedContract, templateDetailContract } from './marketplace' +import { collectionPluginsContract, collectionsContract, downloadPluginContract, searchAdvancedContract, templateDetailContract } from './marketplace' export const marketplaceRouterContract = { collections: collectionsContract, collectionPlugins: collectionPluginsContract, searchAdvanced: searchAdvancedContract, templateDetail: templateDetailContract, + downloadPlugin: downloadPluginContract, } export type MarketPlaceInputs = InferContractRouterInputs diff --git a/web/service/use-plugins.ts b/web/service/use-plugins.ts index 36397c7f64..1222b4513d 100644 --- a/web/service/use-plugins.ts +++ b/web/service/use-plugins.ts @@ -569,15 +569,6 @@ export const usePluginManifestInfo = (pluginUID: string) => { }) } -export const useDownloadPlugin = (info: { organization: string, pluginName: string, version: string }, needDownload: boolean) => { - return useQuery({ - queryKey: [NAME_SPACE, 'downloadPlugin', info], - queryFn: () => getMarketplace(`/plugins/${info.organization}/${info.pluginName}/${info.version}/download`), - enabled: needDownload, - retry: 0, - }) -} - export const useMutationCheckDependencies = () => { return useMutation({ mutationFn: (appId: string) => {