From 784bda9c868cdcf574218ce6053d9eec54212c36 Mon Sep 17 00:00:00 2001 From: yyh Date: Wed, 4 Mar 2026 21:55:23 +0800 Subject: [PATCH] refactor(web): migrate operation-dropdown to base UI and align provider card styles with Figma MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Migrate OperationDropdown from legacy portal-to-follow-elem to base UI DropdownMenu primitives - Add placement, sideOffset, alignOffset, popupClassName props for flexible positioning - Fix version badge font size: system-2xs-medium-uppercase (10px) → system-xs-medium-uppercase (12px) - Set provider card dropdown to bottom-start placement with 192px width per Figma spec - Fix PluginVersionPicker toggle: clicking badge now opens and closes the picker - Add max-h-[224px] overflow scroll to version list - Replace Remix icon imports with Tailwind CSS icon classes - Prune stale eslint suppressions for migrated files --- .../provider-card-actions.tsx | 15 +- .../__tests__/operation-dropdown.spec.tsx | 47 +++---- .../operation-dropdown.tsx | 132 +++++++----------- .../update-plugin/plugin-version-picker.tsx | 4 +- web/eslint-suppressions.json | 21 --- 5 files changed, 82 insertions(+), 137 deletions(-) diff --git a/web/app/components/header/account-setting/model-provider-page/provider-added-card/provider-card-actions.tsx b/web/app/components/header/account-setting/model-provider-page/provider-added-card/provider-card-actions.tsx index a732a76a1c..c46c179e6e 100644 --- a/web/app/components/header/account-setting/model-provider-page/provider-added-card/provider-card-actions.tsx +++ b/web/app/components/header/account-setting/model-provider-page/provider-added-card/provider-card-actions.tsx @@ -1,7 +1,6 @@ import type { FC } from 'react' import type { PluginDetail } from '@/app/components/plugins/types' import { useMemo } from 'react' -import Badge from '@/app/components/base/badge' import { HeaderModals } from '@/app/components/plugins/plugin-detail-panel/detail-header/components' import { useDetailHeaderState, usePluginOperations } from '@/app/components/plugins/plugin-detail-panel/detail-header/hooks' import OperationDropdown from '@/app/components/plugins/plugin-detail-panel/operation-dropdown' @@ -68,21 +67,21 @@ const ProviderCardActions: FC = ({ detail, onUpdate }) => { pluginID={detail.plugin_id} currentVersion={version} onSelect={handleVersionSelect} + offset={{ mainAxis: 4, crossAxis: 0 }} trigger={( - {version} - {isFromMarketplace && } + {isFromMarketplace && } {hasNewVersion && ( )} - + )} /> )} @@ -93,6 +92,8 @@ const ProviderCardActions: FC = ({ detail, onUpdate }) => { onCheckVersion={() => handleUpdate()} onRemove={modalStates.showDeleteConfirm} detailUrl={detailUrl} + placement="bottom-start" + popupClassName="w-[192px]" /> ({ cn: (...args: (string | undefined | false | null)[]) => args.filter(Boolean).join(' '), })) -vi.mock('@/app/components/base/action-button', () => ({ - default: ({ children, className, onClick }: { children: React.ReactNode, className?: string, onClick?: () => void }) => ( - +vi.mock('@/app/components/base/ui/dropdown-menu', () => ({ + DropdownMenu: ({ children, open }: { children: ReactNode, open: boolean }) => ( +
{children}
), -})) - -vi.mock('@/app/components/base/portal-to-follow-elem', () => ({ - PortalToFollowElem: ({ children, open }: { children: React.ReactNode, open: boolean }) => ( -
{children}
+ DropdownMenuTrigger: ({ children, className }: { children: ReactNode, className?: string }) => ( + ), - PortalToFollowElemTrigger: ({ children, onClick }: { children: React.ReactNode, onClick: () => void }) => ( -
{children}
- ), - PortalToFollowElemContent: ({ children, className }: { children: React.ReactNode, className?: string }) => ( -
{children}
+ DropdownMenuContent: ({ children }: { children: ReactNode }) => ( +
{children}
), + DropdownMenuItem: ({ children, onClick, render, destructive }: { children: ReactNode, onClick?: () => void, render?: ReactElement, destructive?: boolean }) => { + if (render) + return cloneElement(render, { onClick, 'data-destructive': destructive } as Record, children) + return
{children}
+ }, + DropdownMenuSeparator: () =>
, })) describe('OperationDropdown', () => { @@ -52,14 +52,13 @@ describe('OperationDropdown', () => { it('should render trigger button', () => { render() - expect(screen.getByTestId('portal-trigger')).toBeInTheDocument() - expect(screen.getByTestId('action-button')).toBeInTheDocument() + expect(screen.getByTestId('dropdown-trigger')).toBeInTheDocument() }) it('should render dropdown content', () => { render() - expect(screen.getByTestId('portal-content')).toBeInTheDocument() + expect(screen.getByTestId('dropdown-content')).toBeInTheDocument() }) it('should render info option for github source', () => { @@ -118,14 +117,10 @@ describe('OperationDropdown', () => { }) describe('User Interactions', () => { - it('should toggle dropdown when trigger is clicked', () => { + it('should render dropdown menu root', () => { render() - const trigger = screen.getByTestId('portal-trigger') - fireEvent.click(trigger) - - // The portal-elem should reflect the open state - expect(screen.getByTestId('portal-elem')).toBeInTheDocument() + expect(screen.getByTestId('dropdown-menu')).toBeInTheDocument() }) it('should call onInfo when info option is clicked', () => { @@ -174,7 +169,7 @@ describe('OperationDropdown', () => { const { unmount } = render( , ) - expect(screen.getByTestId('portal-elem')).toBeInTheDocument() + expect(screen.getByTestId('dropdown-menu')).toBeInTheDocument() expect(screen.getByText('plugin.detailPanel.operation.remove')).toBeInTheDocument() unmount() }) @@ -199,9 +194,7 @@ describe('OperationDropdown', () => { describe('Memoization', () => { it('should be wrapped with React.memo', () => { - // Verify the component is exported as a memo component expect(OperationDropdown).toBeDefined() - // React.memo wraps the component, so it should have $$typeof expect((OperationDropdown as { $$typeof?: symbol }).$$typeof).toBeDefined() }) }) 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 c32dc9ac58..9e89e1038b 100644 --- a/web/app/components/plugins/plugin-detail-panel/operation-dropdown.tsx +++ b/web/app/components/plugins/plugin-detail-panel/operation-dropdown.tsx @@ -1,16 +1,15 @@ 'use client' import type { FC } from 'react' -import { RiArrowRightUpLine, RiMoreFill } from '@remixicon/react' +import type { Placement } from '@/app/components/base/ui/placement' import * as React from 'react' -import { useCallback, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' -import ActionButton from '@/app/components/base/action-button' -// import Button from '@/app/components/base/button' import { - PortalToFollowElem, - PortalToFollowElemContent, - PortalToFollowElemTrigger, -} from '@/app/components/base/portal-to-follow-elem' + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/app/components/base/ui/dropdown-menu' import { useGlobalPublicStore } from '@/context/global-public-context' import { cn } from '@/utils/classnames' import { PluginSource } from '../types' @@ -21,6 +20,10 @@ type Props = { onCheckVersion: () => void onRemove: () => void detailUrl: string + placement?: Placement + sideOffset?: number + alignOffset?: number + popupClassName?: string } const OperationDropdown: FC = ({ @@ -29,83 +32,52 @@ const OperationDropdown: FC = ({ onInfo, onCheckVersion, onRemove, + placement = 'bottom-end', + sideOffset = 4, + alignOffset = 0, + popupClassName, }) => { const { t } = useTranslation() - const [open, doSetOpen] = useState(false) - const openRef = useRef(open) - const setOpen = useCallback((v: boolean) => { - doSetOpen(v) - openRef.current = v - }, [doSetOpen]) - - const handleTrigger = useCallback(() => { - setOpen(!openRef.current) - }, [setOpen]) - + const [open, setOpen] = React.useState(false) const { enable_marketplace } = useGlobalPublicStore(s => s.systemFeatures) return ( - - -
- - - -
-
- -
- {source === PluginSource.github && ( -
{ - onInfo() - handleTrigger() - }} - className="system-md-regular cursor-pointer rounded-lg px-3 py-1.5 text-text-secondary hover:bg-state-base-hover" - > - {t('detailPanel.operation.info', { ns: 'plugin' })} -
- )} - {source === PluginSource.github && ( -
{ - onCheckVersion() - handleTrigger() - }} - className="system-md-regular cursor-pointer rounded-lg px-3 py-1.5 text-text-secondary hover:bg-state-base-hover" - > - {t('detailPanel.operation.checkUpdate', { ns: 'plugin' })} -
- )} - {(source === PluginSource.marketplace || source === PluginSource.github) && enable_marketplace && ( - - {t('detailPanel.operation.viewDetail', { ns: 'plugin' })} - - - )} - {(source === PluginSource.marketplace || source === PluginSource.github) && enable_marketplace && ( -
- )} -
{ - onRemove() - handleTrigger() - }} - className="system-md-regular cursor-pointer rounded-lg px-3 py-1.5 text-text-secondary hover:bg-state-destructive-hover hover:text-text-destructive" - > - {t('detailPanel.operation.remove', { ns: 'plugin' })} -
-
-
-
+ + + + + + {source === PluginSource.github && ( + + {t('detailPanel.operation.info', { ns: 'plugin' })} + + )} + {source === PluginSource.github && ( + + {t('detailPanel.operation.checkUpdate', { ns: 'plugin' })} + + )} + {(source === PluginSource.marketplace || source === PluginSource.github) && enable_marketplace && ( + }> + {t('detailPanel.operation.viewDetail', { ns: 'plugin' })} + + + )} + {(source === PluginSource.marketplace || source === PluginSource.github) && enable_marketplace && ( + + )} + + {t('detailPanel.operation.remove', { ns: 'plugin' })} + + + ) } export default React.memo(OperationDropdown) diff --git a/web/app/components/plugins/update-plugin/plugin-version-picker.tsx b/web/app/components/plugins/update-plugin/plugin-version-picker.tsx index 8327e4eabd..88fff483d2 100644 --- a/web/app/components/plugins/update-plugin/plugin-version-picker.tsx +++ b/web/app/components/plugins/update-plugin/plugin-version-picker.tsx @@ -59,7 +59,7 @@ const PluginVersionPicker: FC = ({ const handleTriggerClick = () => { if (disabled) return - onShowChange(true) + onShowChange(!isShow) } const { data: res } = useVersionListOfPlugin(pluginID) @@ -94,7 +94,7 @@ const PluginVersionPicker: FC = ({
{t('detailPanel.switchVersion', { ns: 'plugin' })}
-
+
{res?.data.versions.map(version => (