diff --git a/web/app/components/base/ui/dropdown-menu/index.tsx b/web/app/components/base/ui/dropdown-menu/index.tsx index 0c3effadc4..85a81968d4 100644 --- a/web/app/components/base/ui/dropdown-menu/index.tsx +++ b/web/app/components/base/ui/dropdown-menu/index.tsx @@ -25,24 +25,36 @@ type DropdownMenuContentProps = { alignOffset?: number className?: string popupClassName?: string + positionerProps?: Omit< + React.ComponentPropsWithoutRef, + 'children' | 'className' | 'side' | 'align' | 'sideOffset' | 'alignOffset' + > + popupProps?: Omit< + React.ComponentPropsWithoutRef, + 'children' | 'className' + > } -type DropdownMenuPopupProps = Required> & { +type DropdownMenuPopupRenderProps = Required> & { placement: Placement sideOffset: number alignOffset: number className?: string popupClassName?: string + positionerProps?: DropdownMenuContentProps['positionerProps'] + popupProps?: DropdownMenuContentProps['popupProps'] } -function DropdownMenuPopup({ +function renderDropdownMenuPopup({ children, placement, sideOffset, alignOffset, className, popupClassName, -}: DropdownMenuPopupProps) { + positionerProps, + popupProps, +}: DropdownMenuPopupRenderProps) { const { side, align } = parsePlacement(placement) return ( @@ -53,6 +65,7 @@ function DropdownMenuPopup({ sideOffset={sideOffset} alignOffset={alignOffset} className={cn('outline-none', className)} + {...positionerProps} > {children} @@ -75,18 +89,19 @@ export function DropdownMenuContent({ alignOffset = 0, className, popupClassName, + positionerProps, + popupProps, }: DropdownMenuContentProps) { - return ( - - {children} - - ) + return renderDropdownMenuPopup({ + children, + placement, + sideOffset, + alignOffset, + className, + popupClassName, + positionerProps, + popupProps, + }) } type DropdownMenuSubTriggerProps = React.ComponentPropsWithoutRef & { @@ -118,6 +133,8 @@ type DropdownMenuSubContentProps = { alignOffset?: number className?: string popupClassName?: string + positionerProps?: DropdownMenuContentProps['positionerProps'] + popupProps?: DropdownMenuContentProps['popupProps'] } export function DropdownMenuSubContent({ @@ -127,18 +144,19 @@ export function DropdownMenuSubContent({ alignOffset = 0, className, popupClassName, + positionerProps, + popupProps, }: DropdownMenuSubContentProps) { - return ( - - {children} - - ) + return renderDropdownMenuPopup({ + children, + placement, + sideOffset, + alignOffset, + className, + popupClassName, + positionerProps, + popupProps, + }) } type DropdownMenuItemProps = React.ComponentPropsWithoutRef & { diff --git a/web/app/components/base/ui/popover/index.tsx b/web/app/components/base/ui/popover/index.tsx index cda7748ed2..fe9808ec52 100644 --- a/web/app/components/base/ui/popover/index.tsx +++ b/web/app/components/base/ui/popover/index.tsx @@ -19,6 +19,14 @@ type PopoverContentProps = { alignOffset?: number className?: string popupClassName?: string + positionerProps?: Omit< + React.ComponentPropsWithoutRef, + 'children' | 'className' | 'side' | 'align' | 'sideOffset' | 'alignOffset' + > + popupProps?: Omit< + React.ComponentPropsWithoutRef, + 'children' | 'className' + > } export function PopoverContent({ @@ -28,6 +36,8 @@ export function PopoverContent({ alignOffset = 0, className, popupClassName, + positionerProps, + popupProps, }: PopoverContentProps) { const { side, align } = parsePlacement(placement) @@ -39,6 +49,7 @@ export function PopoverContent({ sideOffset={sideOffset} alignOffset={alignOffset} className={cn('outline-none', className)} + {...positionerProps} > {children} diff --git a/web/app/components/base/ui/select/index.tsx b/web/app/components/base/ui/select/index.tsx index aa386c5037..68786815db 100644 --- a/web/app/components/base/ui/select/index.tsx +++ b/web/app/components/base/ui/select/index.tsx @@ -42,6 +42,18 @@ type SelectContentProps = { className?: string popupClassName?: string listClassName?: string + positionerProps?: Omit< + React.ComponentPropsWithoutRef, + 'children' | 'className' | 'side' | 'align' | 'sideOffset' | 'alignOffset' + > + popupProps?: Omit< + React.ComponentPropsWithoutRef, + 'children' | 'className' + > + listProps?: Omit< + React.ComponentPropsWithoutRef, + 'children' | 'className' + > } export function SelectContent({ @@ -52,6 +64,9 @@ export function SelectContent({ className, popupClassName, listClassName, + positionerProps, + popupProps, + listProps, }: SelectContentProps) { const { side, align } = parsePlacement(placement) @@ -63,6 +78,7 @@ export function SelectContent({ sideOffset={sideOffset} alignOffset={alignOffset} className={cn('outline-none', className)} + {...positionerProps} > - + {children} diff --git a/web/docs/lint.md b/web/docs/lint.md index a0ec9d58ad..1105d4af08 100644 --- a/web/docs/lint.md +++ b/web/docs/lint.md @@ -43,6 +43,8 @@ This command lints the entire project and is intended for final verification bef If a new rule causes many existing code errors or automatic fixes generate too many diffs, do not use the `--fix` option for automatic fixes. You can introduce the rule first, then use the `--suppress-all` option to temporarily suppress these errors, and gradually fix them in subsequent changes. +For overlay migration policy and cleanup phases, see [Overlay Migration Guide](./overlay-migration.md). + ## Type Check You should be able to see suggestions from TypeScript in your editor for all open files. diff --git a/web/docs/overlay-migration.md b/web/docs/overlay-migration.md new file mode 100644 index 0000000000..b91583fd79 --- /dev/null +++ b/web/docs/overlay-migration.md @@ -0,0 +1,50 @@ +# Overlay Migration Guide + +This document tracks the migration away from legacy `portal-to-follow-elem` APIs. + +## Scope + +- Deprecated API: `@/app/components/base/portal-to-follow-elem` +- Replacement primitives: + - `@/app/components/base/ui/tooltip` + - `@/app/components/base/ui/dropdown-menu` + - `@/app/components/base/ui/popover` + - `@/app/components/base/ui/dialog` + - `@/app/components/base/ui/select` +- Tracking issue: https://github.com/langgenius/dify/issues/32767 + +## ESLint policy + +- `no-restricted-imports` blocks new usage of `portal-to-follow-elem`. +- The rule is enabled for normal source files and test files are excluded. +- Legacy `app/components/base/*` callers are temporarily allowlisted in ESLint config. +- New files must not be added to the allowlist without migration owner approval. + +## Migration phases + +1. Business/UI features outside `app/components/base/**` + - Migrate old calls to semantic primitives. + - Keep `eslint-suppressions.json` stable or shrinking. +1. Legacy base components in allowlist + - Migrate allowlisted base callers gradually. + - Remove migrated files from allowlist immediately. +1. Cleanup + - Remove remaining suppressions for `no-restricted-imports`. + - Remove legacy `portal-to-follow-elem` implementation. + +## Suppression maintenance + +- After each migration batch, run: + +```sh +pnpm eslint --prune-suppressions --pass-on-unpruned-suppressions +``` + +- Never increase suppressions to bypass new code. +- Prefer direct migration over adding suppression entries. + +## React Refresh policy for base UI primitives + +- We keep primitive aliases (for example `DropdownMenu = Menu.Root`) in the same module. +- To avoid IDE noise, `react-refresh/only-export-components` is configured with explicit `allowExportNames` for the base UI primitive surface. +- Do not use file-level `eslint-disable` comments for this policy. diff --git a/web/eslint.config.mjs b/web/eslint.config.mjs index 2293cdc595..0c3f740f5a 100644 --- a/web/eslint.config.mjs +++ b/web/eslint.config.mjs @@ -6,6 +6,7 @@ import hyoban from 'eslint-plugin-hyoban' import sonar from 'eslint-plugin-sonarjs' import storybook from 'eslint-plugin-storybook' import dify from './eslint-rules/index.js' +import { BASE_UI_PRIMITIVE_EXPORT_NAMES, OVERLAY_MIGRATION_LEGACY_BASE_FILES } from './eslint.constants.mjs' // Enable Tailwind CSS IntelliSense mode for ESLint runs // See: tailwind-css-plugin.ts @@ -147,17 +148,19 @@ export default antfu( }, { name: 'dify/base-ui-primitives', - files: ['app/components/base/ui/**/*.ts', 'app/components/base/ui/**/*.tsx'], + files: ['app/components/base/ui/**/*.tsx'], rules: { - 'react-refresh/only-export-components': 'off', + 'react-refresh/only-export-components': ['error', { + allowExportNames: BASE_UI_PRIMITIVE_EXPORT_NAMES, + }], }, }, { name: 'dify/overlay-migration', files: [GLOB_TS, GLOB_TSX], ignores: [ - 'app/components/base/**', ...GLOB_TESTS, + ...OVERLAY_MIGRATION_LEGACY_BASE_FILES, ], rules: { 'no-restricted-imports': ['error', { diff --git a/web/eslint.constants.mjs b/web/eslint.constants.mjs new file mode 100644 index 0000000000..c880d3b97b --- /dev/null +++ b/web/eslint.constants.mjs @@ -0,0 +1,72 @@ +export const BASE_UI_PRIMITIVE_EXPORT_NAMES = [ + 'Dialog', + 'DialogClose', + 'DialogContent', + 'DialogDescription', + 'DialogTitle', + 'DialogTrigger', + 'DropdownMenu', + 'DropdownMenuCheckboxItem', + 'DropdownMenuCheckboxItemIndicator', + 'DropdownMenuContent', + 'DropdownMenuGroup', + 'DropdownMenuGroupLabel', + 'DropdownMenuItem', + 'DropdownMenuPortal', + 'DropdownMenuRadioGroup', + 'DropdownMenuRadioItem', + 'DropdownMenuRadioItemIndicator', + 'DropdownMenuSeparator', + 'DropdownMenuSub', + 'DropdownMenuSubContent', + 'DropdownMenuSubTrigger', + 'DropdownMenuTrigger', + 'Popover', + 'PopoverClose', + 'PopoverContent', + 'PopoverDescription', + 'PopoverTitle', + 'PopoverTrigger', + 'Select', + 'SelectContent', + 'SelectGroup', + 'SelectGroupLabel', + 'SelectItem', + 'SelectSeparator', + 'SelectTrigger', + 'SelectValue', + 'Tooltip', + 'TooltipContent', + 'TooltipProvider', + 'TooltipTrigger', +] + +export const OVERLAY_MIGRATION_LEGACY_BASE_FILES = [ + 'app/components/base/chat/chat-with-history/header/mobile-operation-dropdown.tsx', + 'app/components/base/chat/chat-with-history/header/operation.tsx', + 'app/components/base/chat/chat-with-history/inputs-form/view-form-dropdown.tsx', + 'app/components/base/chat/chat-with-history/sidebar/operation.tsx', + 'app/components/base/chat/chat/citation/popup.tsx', + 'app/components/base/chat/chat/citation/progress-tooltip.tsx', + 'app/components/base/chat/chat/citation/tooltip.tsx', + 'app/components/base/chat/embedded-chatbot/inputs-form/view-form-dropdown.tsx', + 'app/components/base/chip/index.tsx', + 'app/components/base/date-and-time-picker/date-picker/index.tsx', + 'app/components/base/date-and-time-picker/time-picker/index.tsx', + 'app/components/base/dropdown/index.tsx', + 'app/components/base/features/new-feature-panel/file-upload/setting-modal.tsx', + 'app/components/base/features/new-feature-panel/text-to-speech/voice-settings.tsx', + 'app/components/base/file-uploader/file-from-link-or-local/index.tsx', + 'app/components/base/image-uploader/chat-image-uploader.tsx', + 'app/components/base/image-uploader/text-generation-image-uploader.tsx', + 'app/components/base/modal/modal.tsx', + 'app/components/base/prompt-editor/plugins/context-block/component.tsx', + 'app/components/base/prompt-editor/plugins/history-block/component.tsx', + 'app/components/base/select/custom.tsx', + 'app/components/base/select/index.tsx', + 'app/components/base/select/pure.tsx', + 'app/components/base/sort/index.tsx', + 'app/components/base/tag-management/filter.tsx', + 'app/components/base/theme-selector.tsx', + 'app/components/base/tooltip/index.tsx', +]