refactor(web): converge overlay layering on dify-ui z-50 (#35976)

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
yyh 2026-05-09 20:18:39 +08:00 committed by GitHub
parent 8581a68174
commit 24ea21db25
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
44 changed files with 1052 additions and 1602 deletions

View File

@ -192,11 +192,6 @@
"count": 1
}
},
"web/app/components/app/annotation/add-annotation-modal/index.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/app/annotation/batch-add-annotation-modal/index.tsx": {
"erasable-syntax-only/enums": {
"count": 1
@ -222,11 +217,6 @@
"count": 1
}
},
"web/app/components/app/annotation/edit-annotation-modal/index.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/app/annotation/header-opts/index.tsx": {
"ts/no-explicit-any": {
"count": 1
@ -249,9 +239,6 @@
"erasable-syntax-only/enums": {
"count": 1
},
"no-restricted-imports": {
"count": 1
},
"react/set-state-in-effect": {
"count": 5
},
@ -844,16 +831,6 @@
"count": 1
}
},
"web/app/components/base/drawer-plus/index.stories.tsx": {
"react/component-hook-factories": {
"count": 1
}
},
"web/app/components/base/drawer-plus/index.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/base/error-boundary/index.tsx": {
"react-refresh/only-export-components": {
"count": 3
@ -2879,20 +2856,7 @@
"count": 2
}
},
"web/app/components/tools/edit-custom-collection-modal/config-credentials.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/tools/edit-custom-collection-modal/get-schema.tsx": {
"ts/no-explicit-any": {
"count": 1
}
},
"web/app/components/tools/edit-custom-collection-modal/index.tsx": {
"no-restricted-imports": {
"count": 1
},
"react/set-state-in-effect": {
"count": 4
},
@ -2901,9 +2865,6 @@
}
},
"web/app/components/tools/edit-custom-collection-modal/test-api.tsx": {
"no-restricted-imports": {
"count": 1
},
"ts/no-explicit-any": {
"count": 1
}
@ -2944,9 +2905,6 @@
}
},
"web/app/components/tools/setting/build-in/config-credentials.tsx": {
"no-restricted-imports": {
"count": 1
},
"ts/no-explicit-any": {
"count": 3
}

View File

@ -75,7 +75,7 @@ Composition rules:
- Keep Base UI primitive semantics visible in the public API. Export compound parts such as `ComboboxInputGroup`, `ComboboxInput`, `ComboboxContent`, `ComboboxList`, `ComboboxItem`, and `ComboboxItemIndicator` instead of wrapping them into one business component.
- For `Combobox` multiple selection, follow the official chips pattern: `ComboboxInputGroup` contains `ComboboxChips`, `ComboboxValue` renders `ComboboxChip` items, and `ComboboxInput` remains inside the chips row. Chips should wrap and let the input group grow vertically instead of forcing horizontal overflow.
- Content primitives must own their Base UI `Portal` and use `z-1002` on `Positioner`, matching the overlay contract in `README.md`.
- Content primitives must own their Base UI `Portal` and use `z-50` on `Positioner`, matching the overlay contract in `README.md`. Toast owns `z-60`.
- Use `w-(--anchor-width)` with viewport-aware max-width for `Autocomplete` and `Combobox` popups. Do not add `min-w-(--anchor-width)` when it would defeat available-width clamping.
[Autocomplete docs]: https://base-ui.com/react/components/autocomplete.md#usage-guidelines

View File

@ -84,18 +84,18 @@ Equivalent: any root element with `isolation: isolate` in CSS. Without it, overl
Every overlay primitive uses a single, shared z-index. Do **not** override it at call sites.
| Layer | z-index | Where |
| ------------------------------------------------------------------------------------------------------------------- | -------- | -------------------------------------------------------------------------- |
| Overlays (Dialog, AlertDialog, Autocomplete, Combobox, Drawer, Popover, DropdownMenu, ContextMenu, Select, Tooltip) | `z-1002` | Positioner / Backdrop |
| Toast viewport | `z-1003` | One layer above overlays so notifications are never hidden under a dialog. |
| Layer | z-index | Where |
| ------------------------------------------------------------------------------------------------------------------- | ------- | -------------------------------------------------------------------------- |
| Overlays (Dialog, AlertDialog, Autocomplete, Combobox, Drawer, Popover, DropdownMenu, ContextMenu, Select, Tooltip) | `z-50` | Positioner / Backdrop |
| Toast viewport | `z-60` | One layer above overlays so notifications are never hidden under a dialog. |
Rationale: during Dify's migration from legacy `base/modal` / `base/dialog` / `base/drawer` / `base/drawer-plus` overlays to this package, new and old overlays coexist in the DOM. `z-1002` sits above any common legacy layer, eliminating per-call-site z-index hacks. Among themselves, new primitives share the same z-index and **rely on DOM order** for stacking — the portal mounted later wins.
Rationale: Dify UI owns the normal application overlay layer. Overlay primitives share `z-50` and **rely on DOM order** for stacking — the portal mounted later wins. Toast owns `z-60` so notifications remain visible above dialogs, popovers, and other portalled surfaces without falling back to `z-9999`.
See `[web/docs/overlay-migration.md](../../web/docs/overlay-migration.md)` for the Dify-web migration history. Once the legacy overlays are gone, the values in this table can drop back to `z-50` / `z-51`.
See `[web/docs/overlay.md](../../web/docs/overlay.md)` for the web app overlay best practices.
### Rules
- Never add `z-1003` / `z-9999` / etc. overrides on primitives from this package. If something is getting clipped, the **parent** overlay (typically a legacy one) is the problem and should be migrated.
- Never add ad hoc `z-*` overrides on primitives from this package. If something is getting clipped, fix the parent overlay structure instead of raising the child primitive.
- Never create an extra manual portal on top of our primitives — use the exported content / portal parts such as `DialogContent`, `PopoverContent`, and `DrawerPortal`. Base UI handles focus management, scroll-locking, and dismissal.
- When a primitive needs additional presentation chrome (e.g. a custom backdrop), add it **inside** the exported component, not at call sites.

View File

@ -29,14 +29,14 @@ export function AlertDialogContent({
<BaseAlertDialog.Backdrop
{...backdropProps}
className={cn(
'fixed inset-0 z-1002 bg-background-overlay',
'fixed inset-0 z-50 bg-background-overlay',
'transition-opacity duration-150 data-ending-style:opacity-0 data-starting-style:opacity-0 motion-reduce:transition-none',
backdropClassName,
)}
/>
<BaseAlertDialog.Popup
className={cn(
'fixed top-1/2 left-1/2 z-1002 max-h-[calc(100vh-2rem)] w-[480px] max-w-[calc(100vw-2rem)] -translate-x-1/2 -translate-y-1/2 overflow-y-auto overscroll-contain rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg',
'fixed top-1/2 left-1/2 z-50 max-h-[calc(100vh-2rem)] w-120 max-w-[calc(100vw-2rem)] -translate-x-1/2 -translate-y-1/2 overflow-y-auto overscroll-contain rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg',
'transition-[transform,scale,opacity] duration-150 data-ending-style:scale-95 data-ending-style:opacity-0 data-starting-style:scale-95 data-starting-style:opacity-0 motion-reduce:transition-none',
className,
)}

View File

@ -173,7 +173,7 @@ describe('Autocomplete wrappers', () => {
await expect.element(screen.getByRole('group', { name: 'autocomplete positioner' })).toHaveAttribute('data-side', 'bottom')
await expect.element(screen.getByRole('group', { name: 'autocomplete positioner' })).toHaveAttribute('data-align', 'start')
await expect.element(screen.getByRole('group', { name: 'autocomplete positioner' })).toHaveClass('z-1002')
await expect.element(screen.getByRole('group', { name: 'autocomplete positioner' })).toHaveClass('z-50')
await expect.element(screen.getByRole('dialog', { name: 'autocomplete popup' })).toHaveClass('rounded-xl')
await expect.element(screen.getByRole('dialog', { name: 'autocomplete popup' })).toHaveClass('w-(--anchor-width)')
await expect.element(screen.getByRole('listbox', { name: 'autocomplete list' })).toHaveClass('scroll-py-1')

View File

@ -261,7 +261,7 @@ export function AutocompleteContent({
align={align}
sideOffset={sideOffset}
alignOffset={alignOffset}
className={cn('z-1002 outline-hidden', className)}
className={cn('z-50 outline-hidden', className)}
{...positionerProps}
>
<BaseAutocomplete.Popup

View File

@ -231,7 +231,7 @@ describe('Combobox wrappers', () => {
await expect.element(screen.getByRole('group', { name: 'combobox positioner' })).toHaveAttribute('data-side', 'bottom')
await expect.element(screen.getByRole('group', { name: 'combobox positioner' })).toHaveAttribute('data-align', 'start')
await expect.element(screen.getByRole('group', { name: 'combobox positioner' })).toHaveClass('z-1002')
await expect.element(screen.getByRole('group', { name: 'combobox positioner' })).toHaveClass('z-50')
await expect.element(screen.getByRole('dialog', { name: 'combobox popup' })).toHaveClass('rounded-xl')
await expect.element(screen.getByRole('dialog', { name: 'combobox popup' })).toHaveClass('w-(--anchor-width)')
await expect.element(screen.getByRole('listbox', { name: 'combobox list' })).toHaveClass('scroll-py-1')

View File

@ -323,7 +323,7 @@ export function ComboboxContent({
align={align}
sideOffset={sideOffset}
alignOffset={alignOffset}
className={cn('z-1002 outline-hidden', className)}
className={cn('z-50 outline-hidden', className)}
{...positionerProps}
>
<BaseCombobox.Popup

View File

@ -76,7 +76,7 @@ function renderContextMenuPopup({
align={align}
sideOffset={sideOffset}
alignOffset={alignOffset}
className={cn('z-1002 outline-hidden', className)}
className={cn('z-50 outline-hidden', className)}
{...positionerProps}
>
<BaseContextMenu.Popup

View File

@ -1,8 +1,8 @@
'use client'
// z-index strategy (relies on root `isolation: isolate` in layout.tsx):
// All @langgenius/dify-ui/* overlay primitives — z-1002
// Toast stays one layer above overlays at z-1003.
// All @langgenius/dify-ui/* overlay primitives — z-50
// Toast stays one layer above overlays at z-60.
// Overlays share the same z-index; DOM order handles stacking when multiple are open.
// This ensures overlays inside a Dialog (e.g. a Tooltip on a dialog button) render
// above the dialog backdrop instead of being clipped by it.
@ -56,14 +56,14 @@ export function DialogContent({
<BaseDialog.Backdrop
{...backdropProps}
className={cn(
'fixed inset-0 z-1002 bg-background-overlay',
'fixed inset-0 z-50 bg-background-overlay',
'transition-opacity duration-150 data-ending-style:opacity-0 data-starting-style:opacity-0 motion-reduce:transition-none',
backdropClassName,
)}
/>
<BaseDialog.Popup
className={cn(
'fixed top-1/2 left-1/2 z-1002 max-h-[80dvh] w-120 max-w-[calc(100vw-2rem)] -translate-x-1/2 -translate-y-1/2 overflow-y-auto overscroll-contain rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg p-6 shadow-xl',
'fixed top-1/2 left-1/2 z-50 max-h-[80dvh] w-120 max-w-[calc(100vw-2rem)] -translate-x-1/2 -translate-y-1/2 overflow-y-auto overscroll-contain rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg p-6 shadow-xl',
'transition-[transform,scale,opacity] duration-150 data-ending-style:scale-95 data-ending-style:opacity-0 data-starting-style:scale-95 data-starting-style:opacity-0 motion-reduce:transition-none',
className,
)}

View File

@ -49,7 +49,7 @@ describe('Drawer wrapper', () => {
expect(screen.container).not.toContainElement(dialog)
await expect.element(dialog).toHaveTextContent('Workspace controls')
await expect.element(screen.getByText('Configure the current workspace.')).toBeInTheDocument()
await expect.element(screen.getByTestId('drawer-backdrop')).toHaveClass('z-1002')
await expect.element(screen.getByTestId('drawer-backdrop')).toHaveClass('z-50')
asHTMLElement(screen.getByRole('button', { name: 'Close drawer' }).element()).click()

View File

@ -32,7 +32,7 @@ export function DrawerBackdrop({
return (
<BaseDrawer.Backdrop
className={cn(
'fixed inset-0 z-1002 bg-background-overlay opacity-[calc(1-var(--drawer-swipe-progress,0))]',
'fixed inset-0 z-50 bg-background-overlay opacity-[calc(1-var(--drawer-swipe-progress,0))]',
'transition-opacity duration-200 data-ending-style:opacity-0 data-starting-style:opacity-0 data-swiping:duration-0 motion-reduce:transition-none',
className,
)}
@ -47,7 +47,7 @@ export function DrawerViewport({
}: BaseDrawer.Viewport.Props) {
return (
<BaseDrawer.Viewport
className={cn('fixed inset-0 z-1002 touch-none overflow-hidden overscroll-contain outline-hidden', className)}
className={cn('fixed inset-0 z-50 touch-none overflow-hidden overscroll-contain outline-hidden', className)}
{...props}
/>
)
@ -60,7 +60,7 @@ export function DrawerPopup({
return (
<BaseDrawer.Popup
className={cn(
'fixed z-1002 flex min-h-0 flex-col overflow-hidden border-[0.5px] border-components-panel-border bg-components-panel-bg text-text-primary shadow-xl outline-hidden touch-none',
'fixed z-50 flex min-h-0 flex-col overflow-hidden border-[0.5px] border-components-panel-border bg-components-panel-bg text-text-primary shadow-xl outline-hidden touch-none',
'transition-[transform,opacity,box-shadow] duration-200 data-swiping:select-none data-swiping:duration-0 motion-reduce:transition-none',
'data-[swipe-direction=right]:inset-y-0 data-[swipe-direction=right]:right-0 data-[swipe-direction=right]:h-dvh data-[swipe-direction=right]:w-120 data-[swipe-direction=right]:max-w-[calc(100vw-2rem)] data-[swipe-direction=right]:rounded-l-2xl data-[swipe-direction=right]:border-r-0 data-[swipe-direction=right]:transform-[translateX(var(--drawer-swipe-movement-x,0px))]',
'data-starting-style:data-[swipe-direction=right]:transform-[translateX(calc(100%+2px))] data-ending-style:data-[swipe-direction=right]:transform-[translateX(calc(100%+2px))]',

View File

@ -134,7 +134,7 @@ function renderDropdownMenuPopup({
align={align}
sideOffset={sideOffset}
alignOffset={alignOffset}
className={cn('z-1002 outline-hidden', className)}
className={cn('z-50 outline-hidden', className)}
{...positionerProps}
>
<Menu.Popup

View File

@ -7,4 +7,4 @@ export const overlayLabelClassName = 'px-3 pb-0.5 pt-1 text-text-tertiary system
export const overlaySeparatorClassName = 'my-1 h-px bg-divider-subtle'
export const overlayPopupBaseClassName = 'max-h-(--available-height) overflow-y-auto overflow-x-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur py-1 text-sm text-text-secondary shadow-lg outline-hidden focus:outline-hidden focus-visible:outline-hidden backdrop-blur-[5px]'
export const overlayPopupAnimationClassName = 'origin-(--transform-origin) transition-[transform,scale,opacity] data-ending-style:scale-95 data-starting-style:scale-95 data-ending-style:opacity-0 data-starting-style:opacity-0 motion-reduce:transition-none'
export const overlayBackdropClassName = 'fixed inset-0 z-1002 bg-transparent transition-opacity duration-150 data-ending-style:opacity-0 data-starting-style:opacity-0 motion-reduce:transition-none'
export const overlayBackdropClassName = 'fixed inset-0 z-50 bg-transparent transition-opacity duration-150 data-ending-style:opacity-0 data-starting-style:opacity-0 motion-reduce:transition-none'

View File

@ -51,7 +51,7 @@ export function PopoverContent({
align={align}
sideOffset={sideOffset}
alignOffset={alignOffset}
className={cn('z-1002 outline-hidden', className)}
className={cn('z-50 outline-hidden', className)}
{...positionerProps}
>
<BasePopover.Popup

View File

@ -62,7 +62,7 @@ export function PreviewCardContent({
align={align}
sideOffset={sideOffset}
alignOffset={alignOffset}
className={cn('z-1002 outline-hidden', className)}
className={cn('z-50 outline-hidden', className)}
{...positionerProps}
>
<BasePreviewCard.Popup

View File

@ -135,7 +135,7 @@ export function SelectContent({
sideOffset={sideOffset}
alignOffset={alignOffset}
alignItemWithTrigger={false}
className={cn('z-1002 outline-hidden', className)}
className={cn('z-50 outline-hidden', className)}
{...positionerProps}
>
<BaseSelect.Popup

View File

@ -39,7 +39,7 @@ describe('@langgenius/dify-ui/toast', () => {
await expect.element(screen.getByText('Saved')).toBeInTheDocument()
await expect.element(screen.getByText('Your changes are available now.')).toBeInTheDocument()
await expect.element(screen.getByRole('region', { name: 'Notifications' })).toHaveAttribute('aria-live', 'polite')
await expect.element(screen.getByRole('region', { name: 'Notifications' })).toHaveClass('z-1003')
await expect.element(screen.getByRole('region', { name: 'Notifications' })).toHaveClass('z-60')
expect(screen.getByRole('region', { name: 'Notifications' }).element().firstElementChild).toHaveClass('top-4')
expect(screen.getByRole('dialog').element()).not.toHaveClass('outline-hidden')
expect(document.body.querySelector('[aria-hidden="true"].i-ri-checkbox-circle-fill')).toBeInTheDocument()

View File

@ -222,7 +222,7 @@ function ToastViewport() {
<BaseToast.Viewport
aria-label={toastViewportLabel}
className={cn(
'group/toast-viewport pointer-events-none fixed inset-0 z-1003 overflow-visible',
'group/toast-viewport pointer-events-none fixed inset-0 z-60 overflow-visible',
)}
>
<div

View File

@ -58,7 +58,7 @@ export function TooltipContent({
align={align}
sideOffset={sideOffset}
alignOffset={alignOffset}
className={cn('z-1002 outline-hidden', positionerClassName)}
className={cn('z-50 outline-hidden', positionerClassName)}
>
<BaseTooltip.Popup
className={cn(

View File

@ -4,10 +4,10 @@
## Overlay Components (Mandatory)
- `../packages/dify-ui/README.md` is the permanent contract for overlay primitives, portals, root `isolation: isolate`, and the `z-1002` / `z-1003` layering.
- `./docs/overlay-migration.md` is the source of truth for the ongoing migration (deprecated import paths and coexistence rules).
- `../packages/dify-ui/README.md` is the permanent contract for overlay primitives, portals, root `isolation: isolate`, and the `z-50` / `z-60` layering.
- `./docs/overlay.md` records the current web overlay best practices.
- In new or modified code, use only overlay primitives from `@langgenius/dify-ui/*`.
- Do not introduce deprecated overlay imports from `@/app/components/base/*`; when touching legacy callers, prefer migrating them.
- Do not introduce overlay imports from `@/app/components/base/*`; when touching existing callers, migrate them.
## Query & Mutation (Mandatory)

View File

@ -2,12 +2,21 @@
import type { FC } from 'react'
import type { AnnotationItemBasic } from '../type'
import { Button } from '@langgenius/dify-ui/button'
import {
Drawer,
DrawerBackdrop,
DrawerCloseButton,
DrawerContent,
DrawerPopup,
DrawerPortal,
DrawerTitle,
DrawerViewport,
} from '@langgenius/dify-ui/drawer'
import { toast } from '@langgenius/dify-ui/toast'
import * as React from 'react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import Checkbox from '@/app/components/base/checkbox'
import Drawer from '@/app/components/base/drawer-plus'
import AnnotationFull from '@/app/components/billing/annotation-full'
import { useProviderContext } from '@/context/provider-context'
import EditItem, { EditItemType } from './edit-item'
@ -67,52 +76,72 @@ const AddAnnotationModal: FC<Props> = ({
onHide()
}
}
if (!isShow)
return null
return (
<div>
<Drawer
isShow={isShow}
onHide={onHide}
maxWidthClassName="max-w-[480px]!"
title={t('addModal.title', { ns: 'appAnnotation' }) as string}
body={(
<div className="space-y-6 p-6 pb-4">
<EditItem
type={EditItemType.Query}
content={question}
onChange={setQuestion}
/>
<EditItem
type={EditItemType.Answer}
content={answer}
onChange={setAnswer}
/>
</div>
)}
foot={
(
<div>
{isAnnotationFull && (
<div className="mt-6 mb-4 px-6">
<AnnotationFull />
</div>
)}
<div className="flex h-16 items-center justify-between rounded-br-xl rounded-bl-xl border-t border-divider-subtle bg-background-section-burn px-4 system-sm-medium text-text-tertiary">
<div
className="flex items-center space-x-2"
>
<Checkbox id="create-next-checkbox" checked={isCreateNext} onCheck={() => setIsCreateNext(!isCreateNext)} />
<div>{t('addModal.createNext', { ns: 'appAnnotation' })}</div>
</div>
<div className="mt-2 flex space-x-2">
<Button className="h-7 text-xs" onClick={onHide}>{t('operation.cancel', { ns: 'common' })}</Button>
<Button className="h-7 text-xs" variant="primary" onClick={handleSave} loading={isSaving} disabled={isAnnotationFull}>{t('operation.add', { ns: 'common' })}</Button>
</div>
</div>
</div>
)
}
open
modal
disablePointerDismissal
swipeDirection="right"
onOpenChange={(open) => {
if (!open)
onHide()
}}
>
<DrawerPortal>
<DrawerBackdrop />
<DrawerViewport>
<DrawerPopup className="data-[swipe-direction=right]:top-16 data-[swipe-direction=right]:right-2 data-[swipe-direction=right]:bottom-3 data-[swipe-direction=right]:h-auto data-[swipe-direction=right]:w-120 data-[swipe-direction=right]:max-w-[calc(100vw-1rem)] data-[swipe-direction=right]:rounded-xl data-[swipe-direction=right]:border-r-[0.5px] data-[swipe-direction=right]:border-divider-subtle">
<DrawerContent className="flex min-h-0 flex-1 flex-col p-0 pb-0">
<div className="shrink-0 border-b border-divider-subtle py-4">
<div className="flex h-6 items-center justify-between pr-5 pl-6">
<DrawerTitle className="min-w-0 truncate system-xl-semibold text-text-primary">
{t('addModal.title', { ns: 'appAnnotation' })}
</DrawerTitle>
<DrawerCloseButton
aria-label={t('operation.close', { ns: 'common' })}
className="h-6 w-6 rounded-md"
/>
</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto">
<div className="space-y-6 p-6 pb-4">
<EditItem
type={EditItemType.Query}
content={question}
onChange={setQuestion}
/>
<EditItem
type={EditItemType.Answer}
content={answer}
onChange={setAnswer}
/>
</div>
</div>
<div className="shrink-0">
{isAnnotationFull && (
<div className="mt-6 mb-4 px-6">
<AnnotationFull />
</div>
)}
<div className="flex h-16 items-center justify-between rounded-br-xl rounded-bl-xl border-t border-divider-subtle bg-background-section-burn px-4 system-sm-medium text-text-tertiary">
<div className="flex items-center space-x-2">
<Checkbox id="create-next-checkbox" checked={isCreateNext} onCheck={() => setIsCreateNext(!isCreateNext)} />
<div>{t('addModal.createNext', { ns: 'appAnnotation' })}</div>
</div>
<div className="mt-2 flex space-x-2">
<Button className="h-7 text-xs" onClick={onHide}>{t('operation.cancel', { ns: 'common' })}</Button>
<Button className="h-7 text-xs" variant="primary" onClick={handleSave} loading={isSaving} disabled={isAnnotationFull}>{t('operation.add', { ns: 'common' })}</Button>
</div>
</div>
</div>
</DrawerContent>
</DrawerPopup>
</DrawerViewport>
</DrawerPortal>
</Drawer>
</div>
)

View File

@ -8,11 +8,20 @@ import {
AlertDialogContent,
AlertDialogTitle,
} from '@langgenius/dify-ui/alert-dialog'
import {
Drawer,
DrawerBackdrop,
DrawerCloseButton,
DrawerContent,
DrawerPopup,
DrawerPortal,
DrawerTitle,
DrawerViewport,
} from '@langgenius/dify-ui/drawer'
import { toast } from '@langgenius/dify-ui/toast'
import * as React from 'react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import Drawer from '@/app/components/base/drawer-plus'
import { MessageCheckRemove } from '@/app/components/base/icons/src/vender/line/communication'
import AnnotationFull from '@/app/components/billing/annotation-full'
import { useProviderContext } from '@/context/provider-context'
@ -90,92 +99,115 @@ const EditAnnotationModal: FC<Props> = ({
}
}
const [showModal, setShowModal] = useState(false)
if (!isShow)
return null
return (
<div>
<Drawer
isShow={isShow}
onHide={onHide}
maxWidthClassName="max-w-[480px]!"
title={t('editModal.title', { ns: 'appAnnotation' }) as string}
body={(
<div>
<div className="space-y-6 p-6 pb-4">
<EditItem
type={EditItemType.Query}
content={query}
readonly={(isAdd && isAnnotationFull) || onlyEditResponse}
onSave={editedContent => handleSave(EditItemType.Query, editedContent)}
/>
<EditItem
type={EditItemType.Answer}
content={answer}
readonly={isAdd && isAnnotationFull}
onSave={editedContent => handleSave(EditItemType.Answer, editedContent)}
/>
<AlertDialog open={showModal} onOpenChange={open => !open && setShowModal(false)}>
<AlertDialogContent>
<div className="flex flex-col gap-2 px-6 pt-6 pb-4">
<AlertDialogTitle
title={t('feature.annotation.removeConfirm', { ns: 'appDebug' })}
className="w-full truncate title-2xl-semi-bold text-text-primary"
>
{t('feature.annotation.removeConfirm', { ns: 'appDebug' })}
</AlertDialogTitle>
open
modal
disablePointerDismissal
swipeDirection="right"
onOpenChange={(open) => {
if (!open)
onHide()
}}
>
<DrawerPortal>
<DrawerBackdrop />
<DrawerViewport>
<DrawerPopup className="data-[swipe-direction=right]:top-16 data-[swipe-direction=right]:right-2 data-[swipe-direction=right]:bottom-3 data-[swipe-direction=right]:h-auto data-[swipe-direction=right]:w-120 data-[swipe-direction=right]:max-w-[calc(100vw-1rem)] data-[swipe-direction=right]:rounded-xl data-[swipe-direction=right]:border-r-[0.5px] data-[swipe-direction=right]:border-divider-subtle">
<DrawerContent className="flex min-h-0 flex-1 flex-col p-0 pb-0">
<div className="shrink-0 border-b border-divider-subtle py-4">
<div className="flex h-6 items-center justify-between pr-5 pl-6">
<DrawerTitle className="min-w-0 truncate system-xl-semibold text-text-primary">
{t('editModal.title', { ns: 'appAnnotation' })}
</DrawerTitle>
<DrawerCloseButton
aria-label={t('operation.close', { ns: 'common' })}
className="h-6 w-6 rounded-md"
/>
</div>
<AlertDialogActions>
<AlertDialogCancelButton>
{t('operation.cancel', { ns: 'common' })}
</AlertDialogCancelButton>
<AlertDialogConfirmButton
tone="destructive"
onClick={() => {
onRemove()
setShowModal(false)
onHide()
}}
>
{t('operation.confirm', { ns: 'common' })}
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogContent>
</AlertDialog>
</div>
</div>
)}
foot={(
<div>
{isAnnotationFull && (
<div className="mt-6 mb-4 px-6">
<AnnotationFull />
</div>
)}
{
annotationId
? (
<div className="flex h-16 items-center justify-between rounded-br-xl rounded-bl-xl border-t border-divider-subtle bg-background-section-burn px-4 system-sm-medium text-text-tertiary">
<div
className="flex cursor-pointer items-center space-x-2 pl-3"
onClick={() => setShowModal(true)}
>
<MessageCheckRemove />
<div>{t('editModal.removeThisCache', { ns: 'appAnnotation' })}</div>
</div>
{!!createdAt && (
<div>
{t('editModal.createdAt', { ns: 'appAnnotation' })}
&nbsp;
{formatTime(createdAt, t('dateTimeFormat', { ns: 'appLog' }) as string)}
</div>
<div className="min-h-0 flex-1 overflow-y-auto">
<div className="space-y-6 p-6 pb-4">
<EditItem
type={EditItemType.Query}
content={query}
readonly={(isAdd && isAnnotationFull) || onlyEditResponse}
onSave={editedContent => handleSave(EditItemType.Query, editedContent)}
/>
<EditItem
type={EditItemType.Answer}
content={answer}
readonly={isAdd && isAnnotationFull}
onSave={editedContent => handleSave(EditItemType.Answer, editedContent)}
/>
<AlertDialog open={showModal} onOpenChange={open => !open && setShowModal(false)}>
<AlertDialogContent>
<div className="flex flex-col gap-2 px-6 pt-6 pb-4">
<AlertDialogTitle
title={t('feature.annotation.removeConfirm', { ns: 'appDebug' })}
className="w-full truncate title-2xl-semi-bold text-text-primary"
>
{t('feature.annotation.removeConfirm', { ns: 'appDebug' })}
</AlertDialogTitle>
</div>
)}
<AlertDialogActions>
<AlertDialogCancelButton>
{t('operation.cancel', { ns: 'common' })}
</AlertDialogCancelButton>
<AlertDialogConfirmButton
tone="destructive"
onClick={() => {
onRemove()
setShowModal(false)
onHide()
}}
>
{t('operation.confirm', { ns: 'common' })}
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogContent>
</AlertDialog>
</div>
</div>
<div className="shrink-0">
{isAnnotationFull && (
<div className="mt-6 mb-4 px-6">
<AnnotationFull />
</div>
)
: undefined
}
</div>
)}
/>
)}
{
annotationId
? (
<div className="flex h-16 items-center justify-between rounded-br-xl rounded-bl-xl border-t border-divider-subtle bg-background-section-burn px-4 system-sm-medium text-text-tertiary">
<div
className="flex cursor-pointer items-center space-x-2 pl-3"
onClick={() => setShowModal(true)}
>
<MessageCheckRemove />
<div>{t('editModal.removeThisCache', { ns: 'appAnnotation' })}</div>
</div>
{!!createdAt && (
<div>
{t('editModal.createdAt', { ns: 'appAnnotation' })}
&nbsp;
{formatTime(createdAt, t('dateTimeFormat', { ns: 'appLog' }) as string)}
</div>
)}
</div>
)
: undefined
}
</div>
</DrawerContent>
</DrawerPopup>
</DrawerViewport>
</DrawerPortal>
</Drawer>
</div>
)

View File

@ -10,11 +10,20 @@ import {
AlertDialogTitle,
} from '@langgenius/dify-ui/alert-dialog'
import { cn } from '@langgenius/dify-ui/cn'
import {
Drawer,
DrawerBackdrop,
DrawerCloseButton,
DrawerContent,
DrawerPopup,
DrawerPortal,
DrawerTitle,
DrawerViewport,
} from '@langgenius/dify-ui/drawer'
import * as React from 'react'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Badge from '@/app/components/base/badge'
import Drawer from '@/app/components/base/drawer-plus'
import { MessageCheckRemove } from '@/app/components/base/icons/src/vender/line/communication'
import Pagination from '@/app/components/base/pagination'
import TabSlider from '@/app/components/base/tab-slider-plain'
@ -198,75 +207,97 @@ const ViewAnnotationModal: FC<Props> = ({
</div>
)
if (!isShow)
return null
return (
<div>
<Drawer
isShow={isShow}
onHide={onHide}
maxWidthClassName="max-w-[800px]!"
title={(
<TabSlider
className="relative top-[9px] shrink-0"
value={activeTab}
onChange={v => setActiveTab(v as TabType)}
options={tabs}
noBorderBottom
itemClassName="pb-3.5!"
/>
)}
body={(
<div>
<div className="space-y-6 p-6 pb-4">
{activeTab === TabType.annotation ? annotationTab : hitHistoryTab}
</div>
<AlertDialog open={showModal} onOpenChange={open => !open && setShowModal(false)}>
<AlertDialogContent>
<div className="flex flex-col gap-2 px-6 pt-6 pb-4">
<AlertDialogTitle
title={t('feature.annotation.removeConfirm', { ns: 'appDebug' })}
className="w-full truncate title-2xl-semi-bold text-text-primary"
>
{t('feature.annotation.removeConfirm', { ns: 'appDebug' })}
</AlertDialogTitle>
open
modal
disablePointerDismissal
swipeDirection="right"
onOpenChange={(open) => {
if (!open)
onHide()
}}
>
<DrawerPortal>
<DrawerBackdrop />
<DrawerViewport>
<DrawerPopup className="data-[swipe-direction=right]:top-16 data-[swipe-direction=right]:right-2 data-[swipe-direction=right]:bottom-3 data-[swipe-direction=right]:h-auto data-[swipe-direction=right]:w-200 data-[swipe-direction=right]:max-w-[calc(100vw-1rem)] data-[swipe-direction=right]:rounded-xl data-[swipe-direction=right]:border-r-[0.5px] data-[swipe-direction=right]:border-divider-subtle">
<DrawerContent className="flex min-h-0 flex-1 flex-col p-0 pb-0">
<div className="shrink-0 border-b border-divider-subtle py-4">
<div className="flex h-6 items-center justify-between pr-5 pl-6">
<DrawerTitle render={<div />} className="min-w-0">
<TabSlider
className="relative top-[9px] shrink-0"
value={activeTab}
onChange={v => setActiveTab(v as TabType)}
options={tabs}
noBorderBottom
itemClassName="pb-3.5!"
/>
</DrawerTitle>
<DrawerCloseButton
aria-label={t('operation.close', { ns: 'common' })}
className="h-6 w-6 rounded-md"
/>
</div>
</div>
<AlertDialogActions>
<AlertDialogCancelButton>
{t('operation.cancel', { ns: 'common' })}
</AlertDialogCancelButton>
<AlertDialogConfirmButton
tone="destructive"
onClick={async () => {
await onRemove()
setShowModal(false)
onHide()
}}
>
{t('operation.confirm', { ns: 'common' })}
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogContent>
</AlertDialog>
</div>
)}
foot={id
? (
<div className="flex h-16 items-center justify-between rounded-br-xl rounded-bl-xl border-t border-divider-subtle bg-background-section-burn px-4 system-sm-medium text-text-tertiary">
<div
className="flex cursor-pointer items-center space-x-2 pl-3"
onClick={() => setShowModal(true)}
>
<MessageCheckRemove />
<div>{t('editModal.removeThisCache', { ns: 'appAnnotation' })}</div>
<div className="min-h-0 flex-1 overflow-y-auto">
<div className="space-y-6 p-6 pb-4">
{activeTab === TabType.annotation ? annotationTab : hitHistoryTab}
</div>
<AlertDialog open={showModal} onOpenChange={open => !open && setShowModal(false)}>
<AlertDialogContent>
<div className="flex flex-col gap-2 px-6 pt-6 pb-4">
<AlertDialogTitle
title={t('feature.annotation.removeConfirm', { ns: 'appDebug' })}
className="w-full truncate title-2xl-semi-bold text-text-primary"
>
{t('feature.annotation.removeConfirm', { ns: 'appDebug' })}
</AlertDialogTitle>
</div>
<AlertDialogActions>
<AlertDialogCancelButton>
{t('operation.cancel', { ns: 'common' })}
</AlertDialogCancelButton>
<AlertDialogConfirmButton
tone="destructive"
onClick={async () => {
await onRemove()
setShowModal(false)
onHide()
}}
>
{t('operation.confirm', { ns: 'common' })}
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogContent>
</AlertDialog>
</div>
<div>
{t('editModal.createdAt', { ns: 'appAnnotation' })}
{id && (
<div className="flex h-16 shrink-0 items-center justify-between rounded-br-xl rounded-bl-xl border-t border-divider-subtle bg-background-section-burn px-4 system-sm-medium text-text-tertiary">
<div
className="flex cursor-pointer items-center space-x-2 pl-3"
onClick={() => setShowModal(true)}
>
<MessageCheckRemove />
<div>{t('editModal.removeThisCache', { ns: 'appAnnotation' })}</div>
</div>
<div>
{t('editModal.createdAt', { ns: 'appAnnotation' })}
&nbsp;
{formatTime(createdAt, t('dateTimeFormat', { ns: 'appLog' }) as string)}
</div>
</div>
)
: undefined}
/>
{formatTime(createdAt, t('dateTimeFormat', { ns: 'appLog' }) as string)}
</div>
</div>
)}
</DrawerContent>
</DrawerPopup>
</DrawerViewport>
</DrawerPortal>
</Drawer>
</div>
)

View File

@ -258,7 +258,7 @@ describe('InputsFormContent', () => {
renderWithContext(<InputsFormContent />, context)
await user.click(screen.getByText('B'))
expect(screen.getByText('A').closest('.z-1002')).not.toBeNull()
expect(screen.getByText('A').closest('.z-50')).not.toBeNull()
})
it('handles select input with existing value (value not in options -> shows placeholder)', () => {

View File

@ -208,7 +208,7 @@ describe('InputsFormContent', () => {
await user.click(selectTrigger)
expect(screen.getByText('Option 1').closest('.z-1002')).not.toBeNull()
expect(screen.getByText('Option 1').closest('.z-50')).not.toBeNull()
})
it('should handle single file upload change', async () => {

View File

@ -1,446 +0,0 @@
import { fireEvent, render, screen } from '@testing-library/react'
import * as React from 'react'
import DrawerPlus from '..'
vi.mock('@/hooks/use-breakpoints', () => ({
default: () => 'desktop',
MediaType: { mobile: 'mobile', desktop: 'desktop', tablet: 'tablet' },
}))
describe('DrawerPlus', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should not render when isShow is false', () => {
render(
<DrawerPlus
isShow={false}
onHide={() => {}}
title="Test Drawer"
body={<div>Content</div>}
/>,
)
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
})
it('should render when isShow is true', () => {
const bodyContent = <div>Body Content</div>
render(
<DrawerPlus
isShow={true}
onHide={() => {}}
title="Test Drawer"
body={bodyContent}
/>,
)
expect(screen.getByRole('dialog')).toBeInTheDocument()
expect(screen.getByText('Test Drawer')).toBeInTheDocument()
expect(screen.getByText('Body Content')).toBeInTheDocument()
})
it('should render footer when provided', () => {
const footerContent = <div>Footer Content</div>
render(
<DrawerPlus
isShow={true}
onHide={() => {}}
title="Test Drawer"
body={<div>Body</div>}
foot={footerContent}
/>,
)
expect(screen.getByText('Footer Content')).toBeInTheDocument()
})
it('should render JSX element as title', () => {
const titleElement = <h1 data-testid="custom-title">Custom Title</h1>
render(
<DrawerPlus
isShow={true}
onHide={() => {}}
title={titleElement}
body={<div>Body</div>}
/>,
)
expect(screen.getByTestId('custom-title')).toBeInTheDocument()
})
it('should render titleDescription when provided', () => {
render(
<DrawerPlus
isShow={true}
onHide={() => {}}
title="Test Drawer"
titleDescription="Description text"
body={<div>Body</div>}
/>,
)
expect(screen.getByText('Description text')).toBeInTheDocument()
})
it('should not render titleDescription when not provided', () => {
render(
<DrawerPlus
isShow={true}
onHide={() => {}}
title="Test Drawer"
body={<div>Body</div>}
/>,
)
expect(screen.queryByText(/Description/)).not.toBeInTheDocument()
})
it('should render JSX element as titleDescription', () => {
const descElement = <span data-testid="custom-desc">Custom Description</span>
render(
<DrawerPlus
isShow={true}
onHide={() => {}}
title="Test"
titleDescription={descElement}
body={<div>Body</div>}
/>,
)
expect(screen.getByTestId('custom-desc')).toBeInTheDocument()
})
})
describe('Props - Display Options', () => {
it('should apply default maxWidthClassName', () => {
render(
<DrawerPlus
isShow={true}
onHide={() => {}}
title="Test"
body={<div>Body</div>}
/>,
)
const innerPanel = screen.getByText('Test').closest('.bg-components-panel-bg')
const outerPanel = innerPanel?.parentElement
expect(outerPanel?.className).toContain('max-w-[640px]!')
})
it('should apply custom maxWidthClassName', () => {
render(
<DrawerPlus
isShow={true}
onHide={() => {}}
title="Test"
body={<div>Body</div>}
maxWidthClassName="max-w-[800px]!"
/>,
)
const innerPanel = screen.getByText('Test').closest('.bg-components-panel-bg')
const outerPanel = innerPanel?.parentElement
expect(outerPanel?.className).toContain('max-w-[800px]!')
})
it('should apply custom panelClassName', () => {
render(
<DrawerPlus
isShow={true}
onHide={() => {}}
title="Test"
body={<div>Body</div>}
panelClassName="custom-panel"
/>,
)
const innerPanel = screen.getByText('Test').closest('.bg-components-panel-bg')
const outerPanel = innerPanel?.parentElement
expect(outerPanel?.className).toContain('custom-panel')
})
it('should apply custom dialogClassName', () => {
render(
<DrawerPlus
isShow={true}
onHide={() => {}}
title="Test"
body={<div>Body</div>}
dialogClassName="custom-dialog"
/>,
)
expect(document.querySelector('.custom-dialog')).toBeInTheDocument()
})
it('should apply custom contentClassName', () => {
render(
<DrawerPlus
isShow={true}
onHide={() => {}}
title="Test"
body={<div>Body</div>}
contentClassName="custom-content"
/>,
)
const title = screen.getByText('Test')
const header = title.closest('.shrink-0.border-b.border-divider-subtle')
const content = header?.parentElement
expect(content?.className).toContain('custom-content')
})
it('should apply custom headerClassName', () => {
render(
<DrawerPlus
isShow={true}
onHide={() => {}}
title="Test"
body={<div>Body</div>}
headerClassName="custom-header"
/>,
)
const title = screen.getByText('Test')
const header = title.closest('.shrink-0.border-b.border-divider-subtle')
expect(header?.className).toContain('custom-header')
})
it('should apply custom height', () => {
render(
<DrawerPlus
isShow={true}
onHide={() => {}}
title="Test"
body={<div>Body</div>}
height="500px"
/>,
)
const title = screen.getByText('Test')
const header = title.closest('.shrink-0.border-b.border-divider-subtle')
const content = header?.parentElement
expect(content?.getAttribute('style')).toContain('height: 500px')
})
it('should use default height', () => {
render(
<DrawerPlus
isShow={true}
onHide={() => {}}
title="Test"
body={<div>Body</div>}
/>,
)
const title = screen.getByText('Test')
const header = title.closest('.shrink-0.border-b.border-divider-subtle')
const content = header?.parentElement
expect(content?.getAttribute('style')).toContain('calc(100vh - 72px)')
})
})
describe('Event Handlers', () => {
it('should call onHide when close button is clicked', () => {
const handleHide = vi.fn()
render(
<DrawerPlus
isShow={true}
onHide={handleHide}
title="Test"
body={<div>Body</div>}
/>,
)
const title = screen.getByText('Test')
const headerRight = title.nextElementSibling // .flex items-center
const closeDiv = headerRight?.querySelector('.cursor-pointer') as HTMLElement
fireEvent.click(closeDiv)
expect(handleHide).toHaveBeenCalledTimes(1)
})
})
describe('Complex Content', () => {
it('should render complex JSX elements in body', () => {
const complexBody = (
<div>
<h2>Header</h2>
<p>Paragraph</p>
<button>Action Button</button>
</div>
)
render(
<DrawerPlus
isShow={true}
onHide={() => {}}
title="Test"
body={complexBody}
/>,
)
expect(screen.getByText('Header')).toBeInTheDocument()
expect(screen.getByText('Paragraph')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Action Button' })).toBeInTheDocument()
})
it('should render complex footer', () => {
const complexFooter = (
<div className="footer-actions">
<button>Cancel</button>
<button>Save</button>
</div>
)
render(
<DrawerPlus
isShow={true}
onHide={() => {}}
title="Test"
body={<div>Body</div>}
foot={complexFooter}
/>,
)
expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Save' })).toBeInTheDocument()
})
})
describe('Edge Cases', () => {
it('should handle empty title', () => {
render(
<DrawerPlus
isShow={true}
onHide={() => {}}
title=""
body={<div>Body</div>}
/>,
)
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
it('should handle undefined titleDescription', () => {
render(
<DrawerPlus
isShow={true}
onHide={() => {}}
title="Test"
titleDescription={undefined}
body={<div>Body</div>}
/>,
)
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
it('should handle rapid isShow toggle', () => {
const { rerender } = render(
<DrawerPlus
isShow={true}
onHide={() => {}}
title="Test"
body={<div>Body</div>}
/>,
)
expect(screen.getByRole('dialog')).toBeInTheDocument()
rerender(
<DrawerPlus
isShow={false}
onHide={() => {}}
title="Test"
body={<div>Body</div>}
/>,
)
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
rerender(
<DrawerPlus
isShow={true}
onHide={() => {}}
title="Test"
body={<div>Body</div>}
/>,
)
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
it('should handle special characters in title', () => {
const specialTitle = 'Test <> & " \' | Drawer'
render(
<DrawerPlus
isShow={true}
onHide={() => {}}
title={specialTitle}
body={<div>Body</div>}
/>,
)
expect(screen.getByText(specialTitle)).toBeInTheDocument()
})
it('should handle empty body content', () => {
render(
<DrawerPlus
isShow={true}
onHide={() => {}}
title="Test"
body={<div></div>}
/>,
)
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
it('should apply both custom maxWidth and panel classNames', () => {
render(
<DrawerPlus
isShow={true}
onHide={() => {}}
title="Test"
body={<div>Body</div>}
maxWidthClassName="max-w-[500px]!"
panelClassName="custom-style"
/>,
)
const innerPanel = screen.getByText('Test').closest('.bg-components-panel-bg')
const outerPanel = innerPanel?.parentElement
expect(outerPanel?.className).toContain('max-w-[500px]!')
expect(outerPanel?.className).toContain('custom-style')
})
})
describe('Memoization', () => {
it('should be memoized and not re-render on parent changes', () => {
const { rerender } = render(
<DrawerPlus
isShow={true}
onHide={() => {}}
title="Test"
body={<div>Body</div>}
/>,
)
const dialog = screen.getByRole('dialog')
rerender(
<DrawerPlus
isShow={true}
onHide={() => {}}
title="Test"
body={<div>Body</div>}
/>,
)
expect(dialog).toBeInTheDocument()
})
})
})

View File

@ -1,124 +0,0 @@
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
import { useState } from 'react'
import { fn } from 'storybook/test'
import DrawerPlus from '.'
const meta = {
title: 'Base/Feedback/DrawerPlus',
component: DrawerPlus,
parameters: {
layout: 'fullscreen',
docs: {
description: {
component: 'Enhanced drawer built atop the base drawer component. Provides header/foot slots, mask control, and mobile breakpoints.',
},
},
},
tags: ['autodocs'],
} satisfies Meta<typeof DrawerPlus>
export default meta
type Story = StoryObj<typeof meta>
type DrawerPlusProps = React.ComponentProps<typeof DrawerPlus>
const storyBodyElement: React.JSX.Element = (
<div className="space-y-3 p-6 text-sm text-text-secondary">
<p>
DrawerPlus allows rich content with sticky header/footer and responsive masking on mobile. Great for editing flows or showing execution logs.
</p>
<div className="rounded-lg border border-divider-subtle bg-components-panel-bg p-3 text-xs">
Body content scrolls if it exceeds the allotted height.
</div>
</div>
)
const DrawerPlusDemo = (props: Partial<DrawerPlusProps>) => {
const [open, setOpen] = useState(false)
const {
body,
title,
foot,
isShow: _isShow,
onHide: _onHide,
...rest
} = props
const resolvedBody: React.JSX.Element = body ?? storyBodyElement
return (
<div className="flex h-[400px] items-center justify-center bg-background-default-subtle">
<button
type="button"
className="rounded-md bg-primary-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-primary-700"
onClick={() => setOpen(true)}
>
Open drawer plus
</button>
<DrawerPlus
{...rest as Omit<DrawerPlusProps, 'isShow' | 'onHide' | 'title' | 'body' | 'foot'>}
isShow={open}
onHide={() => setOpen(false)}
title={title ?? 'Workflow execution details'}
body={resolvedBody}
foot={foot}
/>
</div>
)
}
export const Playground: Story = {
render: args => <DrawerPlusDemo {...args} />,
args: {
isShow: false,
onHide: fn(),
title: 'Edit configuration',
body: storyBodyElement,
},
}
export const WithFooter: Story = {
render: (args) => {
const FooterDemo = () => {
const [open, setOpen] = useState(false)
return (
<div className="flex h-[400px] items-center justify-center bg-background-default-subtle">
<button
type="button"
className="rounded-md bg-primary-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-primary-700"
onClick={() => setOpen(true)}
>
Open drawer plus
</button>
<DrawerPlus
{...args}
isShow={open}
onHide={() => setOpen(false)}
title={args.title ?? 'Workflow execution details'}
body={args.body ?? (
<div className="space-y-3 p-6 text-sm text-text-secondary">
<p>Populate the body with scrollable content. Footer stays pinned.</p>
</div>
)}
foot={(
<div className="flex justify-end gap-2 border-t border-divider-subtle bg-components-panel-bg p-4">
<button className="rounded-md border border-divider-subtle px-3 py-1.5 text-sm text-text-secondary" onClick={() => setOpen(false)}>Cancel</button>
<button className="rounded-md bg-primary-600 px-3 py-1.5 text-sm text-white">Save</button>
</div>
)}
/>
</div>
)
}
return <FooterDemo />
},
args: {
isShow: false,
onHide: fn(),
title: 'Edit configuration!',
body: storyBodyElement,
},
}

View File

@ -1,106 +0,0 @@
'use client'
import type { FC } from 'react'
import { cn } from '@langgenius/dify-ui/cn'
import { RiCloseLine } from '@remixicon/react'
import * as React from 'react'
import { useRef } from 'react'
import Drawer from '@/app/components/base/drawer'
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
type Props = {
isShow: boolean
onHide: () => void
dialogClassName?: string
dialogBackdropClassName?: string
panelClassName?: string
maxWidthClassName?: string
contentClassName?: string
headerClassName?: string
height?: number | string
title: string | React.JSX.Element
titleDescription?: string | React.JSX.Element
body: React.JSX.Element
foot?: React.JSX.Element
isShowMask?: boolean
clickOutsideNotOpen?: boolean
positionCenter?: boolean
}
const DrawerPlus: FC<Props> = ({
isShow,
onHide,
dialogClassName = '',
dialogBackdropClassName = '',
panelClassName = '',
maxWidthClassName = 'max-w-[640px]!',
height = 'calc(100vh - 72px)',
contentClassName,
headerClassName,
title,
titleDescription,
body,
foot,
isShowMask,
clickOutsideNotOpen = true,
positionCenter,
}) => {
const ref = useRef(null)
const media = useBreakpoints()
const isMobile = media === MediaType.mobile
if (!isShow)
return null
return (
// clickOutsideNotOpen to fix confirm modal click cause drawer close
<Drawer
isOpen={isShow}
clickOutsideNotOpen={clickOutsideNotOpen}
onClose={onHide}
footer={null}
mask={isMobile || isShowMask}
positionCenter={positionCenter}
dialogClassName={dialogClassName}
dialogBackdropClassName={dialogBackdropClassName}
panelClassName={cn('mx-2 mt-16 mb-3 rounded-xl p-0! sm:mr-2', panelClassName, maxWidthClassName)}
>
<div
className={cn(contentClassName, 'flex w-full flex-col rounded-xl border-[0.5px] border-divider-subtle bg-components-panel-bg shadow-xl')}
style={{
height,
}}
ref={ref}
>
<div className={cn(headerClassName, 'shrink-0 border-b border-divider-subtle py-4')}>
<div className="flex h-6 items-center justify-between pr-5 pl-6">
<div className="system-xl-semibold text-text-primary">
{title}
</div>
<div className="flex items-center">
<div
onClick={onHide}
className="flex h-6 w-6 cursor-pointer items-center justify-center"
>
<RiCloseLine className="h-4 w-4 text-text-tertiary" />
</div>
</div>
</div>
{titleDescription && (
<div className="pr-10 pl-6 system-xs-regular text-text-tertiary">
{titleDescription}
</div>
)}
</div>
<div className="grow overflow-y-auto">
{body}
</div>
{foot && (
<div className="shrink-0">
{foot}
</div>
)}
</div>
</Drawer>
)
}
export default React.memo(DrawerPlus)

View File

@ -201,7 +201,6 @@ const CloudPlanItem: FC<CloudPlanItemProps> = ({
open={showEducationPricingConfirm}
onOpenChange={setShowEducationPricingConfirm}
>
{showEducationPricingConfirm && <div className="fixed inset-0 z-1002 bg-background-overlay"></div>}
<AlertDialogContent>
<div className="flex flex-col gap-2 px-6 pt-6 pb-4">
<AlertDialogTitle className="w-full truncate title-2xl-semi-bold text-text-primary">

View File

@ -57,7 +57,7 @@ describe('EditWorkspaceModal', () => {
it('should render on the dify-ui overlay layer', async () => {
renderModal()
expect(await screen.findByRole('dialog')).toHaveClass('z-1002')
expect(await screen.findByRole('dialog')).toHaveClass('z-50')
})
it('should let user edit workspace name', async () => {

View File

@ -1,5 +1,5 @@
import type { Credential } from '@/app/components/tools/types'
import { act, fireEvent, render, screen } from '@testing-library/react'
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { AuthHeaderPrefix, AuthType } from '@/app/components/tools/types'
import ConfigCredential from '../config-credentials'
@ -82,6 +82,25 @@ describe('ConfigCredential', () => {
expect(mockOnChange).not.toHaveBeenCalled()
})
it('should call onHide when Escape is pressed', async () => {
await act(async () => {
render(
<ConfigCredential
credential={baseCredential}
onChange={mockOnChange}
onHide={mockOnHide}
/>,
)
})
fireEvent.keyDown(document, { key: 'Escape' })
await waitFor(() => {
expect(mockOnHide).toHaveBeenCalledTimes(1)
})
expect(mockOnChange).not.toHaveBeenCalled()
})
it('should call both onChange and onHide when save is pressed', async () => {
await act(async () => {
render(

View File

@ -1,11 +1,19 @@
'use client'
import type { FC } from 'react'
import type { Credential } from '@/app/components/tools/types'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import * as React from 'react'
import {
Drawer,
DrawerBackdrop,
DrawerCloseButton,
DrawerContent,
DrawerPopup,
DrawerPortal,
DrawerTitle,
DrawerViewport,
} from '@langgenius/dify-ui/drawer'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import Drawer from '@/app/components/base/drawer-plus'
import { Infotip } from '@/app/components/base/infotip'
import Input from '@/app/components/base/input'
import Radio from '@/app/components/base/radio/ui'
@ -25,175 +33,200 @@ type ItemProps = {
onClick: (value: AuthType | AuthHeaderPrefix) => void
}
const SelectItem: FC<ItemProps> = ({ text, value, isChecked, onClick }) => {
function SelectItem({ text, value, isChecked, onClick }: ItemProps) {
return (
<div
className={cn(isChecked ? 'border-2 border-util-colors-indigo-indigo-600 bg-components-panel-on-panel-item-bg shadow-sm' : 'border border-components-card-border', 'mb-2 flex h-9 w-[150px] cursor-pointer items-center space-x-2 rounded-xl bg-components-panel-on-panel-item-bg pl-3 hover:bg-components-panel-on-panel-item-bg-hover')}
<button
type="button"
className={cn(
isChecked ? 'border-2 border-util-colors-indigo-indigo-600 bg-components-panel-on-panel-item-bg shadow-sm' : 'border border-components-card-border',
'mb-2 flex h-9 w-37.5 cursor-pointer items-center space-x-2 rounded-xl bg-components-panel-on-panel-item-bg pl-3 text-left outline-hidden hover:bg-components-panel-on-panel-item-bg-hover focus-visible:ring-1 focus-visible:ring-components-input-border-hover',
)}
onClick={() => onClick(value)}
>
<Radio isChecked={isChecked} />
<div className="system-sm-regular text-text-primary">{text}</div>
</div>
</button>
)
}
const ConfigCredential: FC<Props> = ({
export default function ConfigCredential({
positionCenter,
credential,
onChange,
onHide,
}) => {
}: Props) {
const { t } = useTranslation()
const [tempCredential, setTempCredential] = React.useState<Credential>(credential)
const [tempCredential, setTempCredential] = useState<Credential>(credential)
return (
<Drawer
isShow
positionCenter={positionCenter}
onHide={onHide}
title={t('createTool.authMethod.title', { ns: 'tools' })!}
dialogClassName="z-60"
dialogBackdropClassName="z-70"
panelClassName="mt-2 w-[520px]! h-fit z-80"
maxWidthClassName="max-w-[520px]!"
height="fit-content"
headerClassName="border-b-divider-regular!"
body={(
<div className="px-6 pt-2">
<div className="space-y-4">
<div>
<div className="py-2 system-sm-medium text-text-primary">{t('createTool.authMethod.type', { ns: 'tools' })}</div>
<div className="flex space-x-3">
<SelectItem
text={t('createTool.authMethod.types.none', { ns: 'tools' })}
value={AuthType.none}
isChecked={tempCredential.auth_type === AuthType.none}
onClick={value => setTempCredential({
auth_type: value as AuthType,
})}
/>
<SelectItem
text={t('createTool.authMethod.types.api_key_header', { ns: 'tools' })}
value={AuthType.apiKeyHeader}
isChecked={tempCredential.auth_type === AuthType.apiKeyHeader}
onClick={value => setTempCredential({
auth_type: value as AuthType,
api_key_header: tempCredential.api_key_header || 'Authorization',
api_key_value: tempCredential.api_key_value || '',
api_key_header_prefix: tempCredential.api_key_header_prefix || AuthHeaderPrefix.custom,
})}
/>
<SelectItem
text={t('createTool.authMethod.types.api_key_query', { ns: 'tools' })}
value={AuthType.apiKeyQuery}
isChecked={tempCredential.auth_type === AuthType.apiKeyQuery}
onClick={value => setTempCredential({
auth_type: value as AuthType,
api_key_query_param: tempCredential.api_key_query_param || 'key',
api_key_value: tempCredential.api_key_value || '',
})}
/>
open
modal
disablePointerDismissal
swipeDirection="right"
onOpenChange={(open) => {
if (!open)
onHide()
}}
>
<DrawerPortal>
<DrawerBackdrop forceRender />
<DrawerViewport>
<DrawerPopup
className={cn(
'data-[swipe-direction=right]:top-2 data-[swipe-direction=right]:bottom-auto data-[swipe-direction=right]:h-fit data-[swipe-direction=right]:max-h-[calc(100dvh-1rem)] data-[swipe-direction=right]:w-130 data-[swipe-direction=right]:max-w-[calc(100vw-1rem)] data-[swipe-direction=right]:rounded-xl data-[swipe-direction=right]:border-r-[0.5px] data-[swipe-direction=right]:border-divider-subtle',
positionCenter
? 'data-[swipe-direction=right]:right-[max(0.5rem,calc(50%_-_260px))]'
: 'data-[swipe-direction=right]:right-2',
)}
>
<DrawerContent className="flex min-h-0 flex-1 flex-col p-0 pb-0">
<div className="shrink-0 border-b border-divider-regular py-4">
<div className="flex h-6 items-center justify-between pr-5 pl-6">
<DrawerTitle className="min-w-0 truncate system-xl-semibold text-text-primary">
{t('createTool.authMethod.title', { ns: 'tools' })}
</DrawerTitle>
<DrawerCloseButton
aria-label={t('operation.close', { ns: 'common' })}
className="h-6 w-6 rounded-md"
/>
</div>
</div>
</div>
{tempCredential.auth_type === AuthType.apiKeyHeader && (
<>
<div>
<div className="py-2 system-sm-medium text-text-primary">{t('createTool.authHeaderPrefix.title', { ns: 'tools' })}</div>
<div className="flex space-x-3">
<SelectItem
text={t('createTool.authHeaderPrefix.types.basic', { ns: 'tools' })}
value={AuthHeaderPrefix.basic}
isChecked={tempCredential.api_key_header_prefix === AuthHeaderPrefix.basic}
onClick={value => setTempCredential({ ...tempCredential, api_key_header_prefix: value as AuthHeaderPrefix })}
/>
<SelectItem
text={t('createTool.authHeaderPrefix.types.bearer', { ns: 'tools' })}
value={AuthHeaderPrefix.bearer}
isChecked={tempCredential.api_key_header_prefix === AuthHeaderPrefix.bearer}
onClick={value => setTempCredential({ ...tempCredential, api_key_header_prefix: value as AuthHeaderPrefix })}
/>
<SelectItem
text={t('createTool.authHeaderPrefix.types.custom', { ns: 'tools' })}
value={AuthHeaderPrefix.custom}
isChecked={tempCredential.api_key_header_prefix === AuthHeaderPrefix.custom}
onClick={value => setTempCredential({ ...tempCredential, api_key_header_prefix: value as AuthHeaderPrefix })}
/>
<div className="min-h-0 flex-1 overflow-y-auto px-6 pt-2">
<div className="space-y-4">
<div>
<div className="py-2 system-sm-medium text-text-primary">{t('createTool.authMethod.type', { ns: 'tools' })}</div>
<div className="flex space-x-3">
<SelectItem
text={t('createTool.authMethod.types.none', { ns: 'tools' })}
value={AuthType.none}
isChecked={tempCredential.auth_type === AuthType.none}
onClick={value => setTempCredential({
auth_type: value as AuthType,
})}
/>
<SelectItem
text={t('createTool.authMethod.types.api_key_header', { ns: 'tools' })}
value={AuthType.apiKeyHeader}
isChecked={tempCredential.auth_type === AuthType.apiKeyHeader}
onClick={value => setTempCredential({
auth_type: value as AuthType,
api_key_header: tempCredential.api_key_header || 'Authorization',
api_key_value: tempCredential.api_key_value || '',
api_key_header_prefix: tempCredential.api_key_header_prefix || AuthHeaderPrefix.custom,
})}
/>
<SelectItem
text={t('createTool.authMethod.types.api_key_query', { ns: 'tools' })}
value={AuthType.apiKeyQuery}
isChecked={tempCredential.auth_type === AuthType.apiKeyQuery}
onClick={value => setTempCredential({
auth_type: value as AuthType,
api_key_query_param: tempCredential.api_key_query_param || 'key',
api_key_value: tempCredential.api_key_value || '',
})}
/>
</div>
</div>
{tempCredential.auth_type === AuthType.apiKeyHeader && (
<>
<div>
<div className="py-2 system-sm-medium text-text-primary">{t('createTool.authHeaderPrefix.title', { ns: 'tools' })}</div>
<div className="flex space-x-3">
<SelectItem
text={t('createTool.authHeaderPrefix.types.basic', { ns: 'tools' })}
value={AuthHeaderPrefix.basic}
isChecked={tempCredential.api_key_header_prefix === AuthHeaderPrefix.basic}
onClick={value => setTempCredential({ ...tempCredential, api_key_header_prefix: value as AuthHeaderPrefix })}
/>
<SelectItem
text={t('createTool.authHeaderPrefix.types.bearer', { ns: 'tools' })}
value={AuthHeaderPrefix.bearer}
isChecked={tempCredential.api_key_header_prefix === AuthHeaderPrefix.bearer}
onClick={value => setTempCredential({ ...tempCredential, api_key_header_prefix: value as AuthHeaderPrefix })}
/>
<SelectItem
text={t('createTool.authHeaderPrefix.types.custom', { ns: 'tools' })}
value={AuthHeaderPrefix.custom}
isChecked={tempCredential.api_key_header_prefix === AuthHeaderPrefix.custom}
onClick={value => setTempCredential({ ...tempCredential, api_key_header_prefix: value as AuthHeaderPrefix })}
/>
</div>
</div>
<div>
<div className="flex items-center py-2 system-sm-medium text-text-primary">
{t('createTool.authMethod.key', { ns: 'tools' })}
<Infotip
aria-label={t('createTool.authMethod.keyTooltip', { ns: 'tools' })}
className="ml-0.5 h-4 w-4"
popupClassName="w-[261px] text-text-tertiary"
>
{t('createTool.authMethod.keyTooltip', { ns: 'tools' })}
</Infotip>
</div>
<Input
value={tempCredential.api_key_header}
onChange={e => setTempCredential({ ...tempCredential, api_key_header: e.target.value })}
placeholder={t('createTool.authMethod.types.apiKeyPlaceholder', { ns: 'tools' })!}
/>
</div>
<div>
<div className="py-2 system-sm-medium text-text-primary">{t('createTool.authMethod.value', { ns: 'tools' })}</div>
<Input
value={tempCredential.api_key_value}
onChange={e => setTempCredential({ ...tempCredential, api_key_value: e.target.value })}
placeholder={t('createTool.authMethod.types.apiValuePlaceholder', { ns: 'tools' })!}
/>
</div>
</>
)}
{tempCredential.auth_type === AuthType.apiKeyQuery && (
<>
<div>
<div className="flex items-center py-2 system-sm-medium text-text-primary">
{t('createTool.authMethod.queryParam', { ns: 'tools' })}
<Infotip
aria-label={t('createTool.authMethod.queryParamTooltip', { ns: 'tools' })}
className="ml-0.5 h-4 w-4"
popupClassName="w-[261px] text-text-tertiary"
>
{t('createTool.authMethod.queryParamTooltip', { ns: 'tools' })}
</Infotip>
</div>
<Input
value={tempCredential.api_key_query_param}
onChange={e => setTempCredential({ ...tempCredential, api_key_query_param: e.target.value })}
placeholder={t('createTool.authMethod.types.queryParamPlaceholder', { ns: 'tools' })!}
/>
</div>
<div>
<div className="py-2 system-sm-medium text-text-primary">{t('createTool.authMethod.value', { ns: 'tools' })}</div>
<Input
value={tempCredential.api_key_value}
onChange={e => setTempCredential({ ...tempCredential, api_key_value: e.target.value })}
placeholder={t('createTool.authMethod.types.apiValuePlaceholder', { ns: 'tools' })!}
/>
</div>
</>
)}
</div>
<div>
<div className="flex items-center py-2 system-sm-medium text-text-primary">
{t('createTool.authMethod.key', { ns: 'tools' })}
<Infotip
aria-label={t('createTool.authMethod.keyTooltip', { ns: 'tools' })}
className="ml-0.5 h-4 w-4"
popupClassName="w-[261px] text-text-tertiary"
>
{t('createTool.authMethod.keyTooltip', { ns: 'tools' })}
</Infotip>
</div>
<Input
value={tempCredential.api_key_header}
onChange={e => setTempCredential({ ...tempCredential, api_key_header: e.target.value })}
placeholder={t('createTool.authMethod.types.apiKeyPlaceholder', { ns: 'tools' })!}
/>
</div>
<div>
<div className="py-2 system-sm-medium text-text-primary">{t('createTool.authMethod.value', { ns: 'tools' })}</div>
<Input
value={tempCredential.api_key_value}
onChange={e => setTempCredential({ ...tempCredential, api_key_value: e.target.value })}
placeholder={t('createTool.authMethod.types.apiValuePlaceholder', { ns: 'tools' })!}
/>
</div>
</>
)}
{tempCredential.auth_type === AuthType.apiKeyQuery && (
<>
<div>
<div className="flex items-center py-2 system-sm-medium text-text-primary">
{t('createTool.authMethod.queryParam', { ns: 'tools' })}
<Infotip
aria-label={t('createTool.authMethod.queryParamTooltip', { ns: 'tools' })}
className="ml-0.5 h-4 w-4"
popupClassName="w-[261px] text-text-tertiary"
>
{t('createTool.authMethod.queryParamTooltip', { ns: 'tools' })}
</Infotip>
</div>
<Input
value={tempCredential.api_key_query_param}
onChange={e => setTempCredential({ ...tempCredential, api_key_query_param: e.target.value })}
placeholder={t('createTool.authMethod.types.queryParamPlaceholder', { ns: 'tools' })!}
/>
</div>
<div>
<div className="py-2 system-sm-medium text-text-primary">{t('createTool.authMethod.value', { ns: 'tools' })}</div>
<Input
value={tempCredential.api_key_value}
onChange={e => setTempCredential({ ...tempCredential, api_key_value: e.target.value })}
placeholder={t('createTool.authMethod.types.apiValuePlaceholder', { ns: 'tools' })!}
/>
</div>
</>
)}
</div>
<div className="mt-4 flex shrink-0 justify-end space-x-2 py-4">
<Button onClick={onHide}>{t('operation.cancel', { ns: 'common' })}</Button>
<Button
variant="primary"
onClick={() => {
onChange(tempCredential)
onHide()
}}
>
{t('operation.save', { ns: 'common' })}
</Button>
</div>
</div>
)}
/>
</div>
<div className="mt-4 flex shrink-0 justify-end space-x-2 py-4 pr-6 pl-6">
<Button onClick={onHide}>{t('operation.cancel', { ns: 'common' })}</Button>
<Button
variant="primary"
onClick={() => {
onChange(tempCredential)
onHide()
}}
>
{t('operation.save', { ns: 'common' })}
</Button>
</div>
</DrawerContent>
</DrawerPopup>
</DrawerViewport>
</DrawerPortal>
</Drawer>
)
}
export default React.memo(ConfigCredential)

View File

@ -1,12 +1,13 @@
'use client'
import type { FC } from 'react'
import { Button } from '@langgenius/dify-ui/button'
import { toast } from '@langgenius/dify-ui/toast'
import {
RiAddLine,
RiArrowDownSLine,
} from '@remixicon/react'
import { useClickAway } from 'ahooks'
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
import { toast } from '@langgenius/dify-ui/toast'
import * as React from 'react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
@ -32,7 +33,7 @@ const GetSchema: FC<Props> = ({
}
setIsParsing(true)
try {
const { schema } = await importSchemaFromURL(importUrl) as any
const { schema } = await importSchemaFromURL(importUrl)
setImportUrl('')
onChange(schema)
}
@ -42,79 +43,79 @@ const GetSchema: FC<Props> = ({
}
}
const importURLRef = React.useRef(null)
useClickAway(() => {
setShowImportFromUrl(false)
}, importURLRef)
const [showExamples, setShowExamples] = useState(false)
const showExamplesRef = React.useRef(null)
useClickAway(() => {
setShowExamples(false)
}, showExamplesRef)
return (
<div className="relative flex w-[224px] justify-end space-x-1">
<div ref={importURLRef}>
<Button
size="small"
className="space-x-1"
onClick={() => { setShowImportFromUrl(!showImportFromUrl) }}
<div className="flex w-[224px] justify-end gap-1">
<DropdownMenu open={showImportFromUrl} onOpenChange={setShowImportFromUrl}>
<DropdownMenuTrigger
render={(
<Button
size="small"
className="gap-1"
/>
)}
>
<RiAddLine className="h-3 w-3" />
<div className="system-xs-medium text-text-secondary">{t('createTool.importFromUrl', { ns: 'tools' })}</div>
</Button>
{showImportFromUrl && (
<div className="absolute top-[26px] left-[-35px] rounded-lg border border-components-panel-border bg-components-panel-bg p-2 shadow-lg">
<div className="relative">
<Input
type="text"
className="w-[244px]"
placeholder={t('createTool.importFromUrlPlaceHolder', { ns: 'tools' })!}
value={importUrl}
onChange={e => setImportUrl(e.target.value)}
/>
<Button
className="absolute top-1 right-1"
size="small"
variant="primary"
disabled={!importUrl}
onClick={handleImportFromUrl}
loading={isParsing}
>
{isParsing ? '' : t('operation.ok', { ns: 'common' })}
</Button>
</div>
</div>
)}
</div>
<div className="relative -mt-0.5" ref={showExamplesRef}>
<Button
size="small"
className="space-x-1"
onClick={() => { setShowExamples(!showExamples) }}
<span className="i-ri-add-line size-3" aria-hidden />
<span className="system-xs-medium text-text-secondary">{t('createTool.importFromUrl', { ns: 'tools' })}</span>
</DropdownMenuTrigger>
<DropdownMenuContent
placement="bottom-start"
sideOffset={2}
popupClassName="w-[300px] p-2"
>
<div className="system-xs-medium text-text-secondary">{t('createTool.examples', { ns: 'tools' })}</div>
<RiArrowDownSLine className="h-3 w-3" />
</Button>
{showExamples && (
<div className="absolute top-7 right-0 rounded-lg bg-components-panel-bg p-1 shadow-sm">
{examples.map(item => (
<div
key={item.key}
onClick={() => {
onChange(item.content)
setShowExamples(false)
}}
className="cursor-pointer rounded-lg px-3 py-1.5 system-sm-regular leading-5 whitespace-nowrap text-text-secondary hover:bg-components-panel-on-panel-item-bg-hover"
>
{t(`createTool.exampleOptions.${item.key}`, { ns: 'tools' })}
</div>
))}
<div className="relative">
<Input
type="text"
className="w-full"
placeholder={t('createTool.importFromUrlPlaceHolder', { ns: 'tools' })!}
value={importUrl}
onChange={e => setImportUrl(e.target.value)}
/>
<Button
className="absolute top-1 right-1"
size="small"
variant="primary"
disabled={!importUrl}
onClick={handleImportFromUrl}
loading={isParsing}
>
{isParsing ? '' : t('operation.ok', { ns: 'common' })}
</Button>
</div>
)}
</div>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu open={showExamples} onOpenChange={setShowExamples}>
<DropdownMenuTrigger
render={(
<Button
size="small"
className="gap-1"
/>
)}
>
<span className="system-xs-medium text-text-secondary">{t('createTool.examples', { ns: 'tools' })}</span>
<span className="i-ri-arrow-down-s-line size-3" aria-hidden />
</DropdownMenuTrigger>
<DropdownMenuContent
placement="bottom-end"
sideOffset={2}
popupClassName="min-w-max"
>
{examples.map(item => (
<DropdownMenuItem
key={item.key}
onClick={() => {
onChange(item.content)
setShowExamples(false)
}}
className="system-sm-regular whitespace-nowrap text-text-secondary"
>
{t(`createTool.exampleOptions.${item.key}`, { ns: 'tools' })}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
)
}

View File

@ -3,6 +3,16 @@ import type { FC } from 'react'
import type { Credential, CustomCollectionBackend, CustomParamSchema, Emoji } from '../types'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import {
Drawer,
DrawerBackdrop,
DrawerCloseButton,
DrawerContent,
DrawerPopup,
DrawerPortal,
DrawerTitle,
DrawerViewport,
} from '@langgenius/dify-ui/drawer'
import { toast } from '@langgenius/dify-ui/toast'
import { RiSettings2Line } from '@remixicon/react'
import { useDebounce, useGetState } from 'ahooks'
@ -11,7 +21,6 @@ import * as React from 'react'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import AppIcon from '@/app/components/base/app-icon'
import Drawer from '@/app/components/base/drawer-plus'
import EmojiPicker from '@/app/components/base/emoji-picker'
import Input from '@/app/components/base/input'
import Textarea from '@/app/components/base/textarea'
@ -191,199 +200,224 @@ const EditCustomCollectionModal: FC<Props> = ({
return (
<>
<Drawer
isShow
positionCenter={isAdd && !positionLeft}
onHide={onHide}
title={t(`createTool.${isAdd ? 'title' : 'editTitle'}`, { ns: 'tools' })!}
dialogClassName={dialogClassName}
panelClassName="mt-2 w-[640px]!"
maxWidthClassName="max-w-[640px]!"
height="calc(100vh - 16px)"
headerClassName="border-b-divider-regular!"
body={(
<div className="flex h-full flex-col">
<div className="h-0 grow space-y-4 overflow-y-auto px-6 py-3">
<div>
<div className="py-2 system-sm-medium text-text-primary">
{t('createTool.name', { ns: 'tools' })}
{' '}
<span className="ml-1 text-red-500">*</span>
</div>
<div className="flex items-center justify-between gap-3">
<AppIcon size="large" onClick={() => { setShowEmojiPicker(true) }} className="cursor-pointer" icon={emoji.content} background={emoji.background} />
<Input
className="h-10 grow"
placeholder={t('createTool.toolNamePlaceHolder', { ns: 'tools' })!}
value={customCollection.provider}
onChange={(e) => {
const newCollection = produce(customCollection, (draft) => {
draft.provider = e.target.value
})
setCustomCollection(newCollection)
}}
/>
</div>
</div>
{/* Schema */}
<div className="select-none">
<div className="flex items-center justify-between">
<div className="flex items-center">
<div className="py-2 system-sm-medium text-text-primary">
{t('createTool.schema', { ns: 'tools' })}
<span className="ml-1 text-red-500">*</span>
</div>
<div className="mx-2 h-3 w-px bg-divider-regular"></div>
<a
href="https://swagger.io/specification/"
target="_blank"
rel="noopener noreferrer"
className="flex h-[18px] items-center space-x-1 text-text-accent"
>
<div className="text-xs font-normal">{t('createTool.viewSchemaSpec', { ns: 'tools' })}</div>
<LinkExternal02 className="h-3 w-3" />
</a>
open
modal
disablePointerDismissal
swipeDirection="right"
onOpenChange={(open) => {
if (!open)
onHide()
}}
>
<DrawerPortal>
<DrawerBackdrop forceRender />
<DrawerViewport className={dialogClassName}>
<DrawerPopup
className={cn(
'data-[swipe-direction=right]:top-2 data-[swipe-direction=right]:bottom-2 data-[swipe-direction=right]:h-auto data-[swipe-direction=right]:w-160 data-[swipe-direction=right]:max-w-[calc(100vw-1rem)] data-[swipe-direction=right]:rounded-xl data-[swipe-direction=right]:border-r-[0.5px] data-[swipe-direction=right]:border-divider-subtle',
isAdd && !positionLeft
? 'data-[swipe-direction=right]:right-[max(0.5rem,calc(50%_-_320px))]'
: 'data-[swipe-direction=right]:right-2',
)}
>
<DrawerContent className="flex min-h-0 flex-1 flex-col p-0 pb-0">
<div className="shrink-0 border-b border-divider-regular py-4">
<div className="flex h-6 items-center justify-between pr-5 pl-6">
<DrawerTitle className="min-w-0 truncate system-xl-semibold text-text-primary">
{t(`createTool.${isAdd ? 'title' : 'editTitle'}`, { ns: 'tools' })}
</DrawerTitle>
<DrawerCloseButton
aria-label={t('operation.close', { ns: 'common' })}
className="h-6 w-6 rounded-md"
/>
</div>
<GetSchema onChange={setSchema} />
</div>
<Textarea
className="h-[240px] resize-none"
value={schema}
onChange={e => setSchema(e.target.value)}
placeholder={t('createTool.schemaPlaceHolder', { ns: 'tools' })!}
/>
</div>
<div className="min-h-0 flex-1">
<div className="flex h-full flex-col">
<div className="h-0 grow space-y-4 overflow-y-auto px-6 py-3">
<div>
<div className="py-2 system-sm-medium text-text-primary">
{t('createTool.name', { ns: 'tools' })}
{' '}
<span className="ml-1 text-red-500">*</span>
</div>
<div className="flex items-center justify-between gap-3">
<AppIcon size="large" onClick={() => { setShowEmojiPicker(true) }} className="cursor-pointer" icon={emoji.content} background={emoji.background} />
<Input
className="h-10 grow"
placeholder={t('createTool.toolNamePlaceHolder', { ns: 'tools' })!}
value={customCollection.provider}
onChange={(e) => {
const newCollection = produce(customCollection, (draft) => {
draft.provider = e.target.value
})
setCustomCollection(newCollection)
}}
/>
</div>
</div>
{/* Available Tools */}
<div>
<div className="py-2 system-sm-medium text-text-primary">{t('createTool.availableTools.title', { ns: 'tools' })}</div>
<div className="w-full overflow-x-auto rounded-lg border border-divider-regular">
<table className="w-full system-xs-regular text-text-secondary">
<thead className="text-text-tertiary uppercase">
<tr className={cn(paramsSchemas.length > 0 && 'border-b', 'border-divider-regular')}>
<th className="p-2 pl-3 font-medium">{t('createTool.availableTools.name', { ns: 'tools' })}</th>
<th className="w-[236px] p-2 pl-3 font-medium">{t('createTool.availableTools.description', { ns: 'tools' })}</th>
<th className="p-2 pl-3 font-medium">{t('createTool.availableTools.method', { ns: 'tools' })}</th>
<th className="p-2 pl-3 font-medium">{t('createTool.availableTools.path', { ns: 'tools' })}</th>
<th className="w-[54px] p-2 pl-3 font-medium">{t('createTool.availableTools.action', { ns: 'tools' })}</th>
</tr>
</thead>
<tbody>
{paramsSchemas.map((item, index) => (
<tr key={index} className="border-b border-divider-regular last:border-0">
<td className="p-2 pl-3">{item.operation_id}</td>
<td className="w-[236px] p-2 pl-3">{item.summary}</td>
<td className="p-2 pl-3">{item.method}</td>
<td className="p-2 pl-3">{getPath(item.server_url)}</td>
<td className="w-[62px] p-2 pl-3">
<Button
size="small"
onClick={() => {
setCurrTool(item)
setIsShowTestApi(true)
}}
{/* Schema */}
<div className="select-none">
<div className="flex items-center justify-between">
<div className="flex items-center">
<div className="py-2 system-sm-medium text-text-primary">
{t('createTool.schema', { ns: 'tools' })}
<span className="ml-1 text-red-500">*</span>
</div>
<div className="mx-2 h-3 w-px bg-divider-regular"></div>
<a
href="https://swagger.io/specification/"
target="_blank"
rel="noopener noreferrer"
className="flex h-[18px] items-center space-x-1 text-text-accent"
>
{t('createTool.availableTools.test', { ns: 'tools' })}
</Button>
</td>
</tr>
))}
</tbody>
</table>
<div className="text-xs font-normal">{t('createTool.viewSchemaSpec', { ns: 'tools' })}</div>
<LinkExternal02 className="h-3 w-3" />
</a>
</div>
<GetSchema onChange={setSchema} />
</div>
<Textarea
className="h-[240px] resize-none"
value={schema}
onChange={e => setSchema(e.target.value)}
placeholder={t('createTool.schemaPlaceHolder', { ns: 'tools' })!}
/>
</div>
{/* Available Tools */}
<div>
<div className="py-2 system-sm-medium text-text-primary">{t('createTool.availableTools.title', { ns: 'tools' })}</div>
<div className="w-full overflow-x-auto rounded-lg border border-divider-regular">
<table className="w-full system-xs-regular text-text-secondary">
<thead className="text-text-tertiary uppercase">
<tr className={cn(paramsSchemas.length > 0 && 'border-b', 'border-divider-regular')}>
<th className="p-2 pl-3 font-medium">{t('createTool.availableTools.name', { ns: 'tools' })}</th>
<th className="w-[236px] p-2 pl-3 font-medium">{t('createTool.availableTools.description', { ns: 'tools' })}</th>
<th className="p-2 pl-3 font-medium">{t('createTool.availableTools.method', { ns: 'tools' })}</th>
<th className="p-2 pl-3 font-medium">{t('createTool.availableTools.path', { ns: 'tools' })}</th>
<th className="w-[54px] p-2 pl-3 font-medium">{t('createTool.availableTools.action', { ns: 'tools' })}</th>
</tr>
</thead>
<tbody>
{paramsSchemas.map((item, index) => (
<tr key={index} className="border-b border-divider-regular last:border-0">
<td className="p-2 pl-3">{item.operation_id}</td>
<td className="w-[236px] p-2 pl-3">{item.summary}</td>
<td className="p-2 pl-3">{item.method}</td>
<td className="p-2 pl-3">{getPath(item.server_url)}</td>
<td className="w-[62px] p-2 pl-3">
<Button
size="small"
onClick={() => {
setCurrTool(item)
setIsShowTestApi(true)
}}
>
{t('createTool.availableTools.test', { ns: 'tools' })}
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* Authorization method */}
<div>
<div className="py-2 system-sm-medium text-text-primary">{t('createTool.authMethod.title', { ns: 'tools' })}</div>
<div className="flex h-9 cursor-pointer items-center justify-between rounded-lg bg-components-input-bg-normal px-2.5" onClick={() => setCredentialsModalShow(true)}>
<div className="system-xs-regular text-text-primary">{t(`createTool.authMethod.types.${credential.auth_type}`, { ns: 'tools' })}</div>
<RiSettings2Line className="h-4 w-4 text-text-secondary" />
</div>
</div>
{/* Labels */}
<div>
<div className="py-2 system-sm-medium text-text-primary">{t('createTool.toolInput.label', { ns: 'tools' })}</div>
<LabelSelector value={labels} onChange={handleLabelSelect} />
</div>
{/* Privacy Policy */}
<div>
<div className="py-2 system-sm-medium text-text-primary">{t('createTool.privacyPolicy', { ns: 'tools' })}</div>
<Input
value={customCollection.privacy_policy}
onChange={(e) => {
const newCollection = produce(customCollection, (draft) => {
draft.privacy_policy = e.target.value
})
setCustomCollection(newCollection)
}}
className="h-10 grow"
placeholder={t('createTool.privacyPolicyPlaceholder', { ns: 'tools' }) || ''}
/>
</div>
<div>
<div className="py-2 system-sm-medium text-text-primary">{t('createTool.customDisclaimer', { ns: 'tools' })}</div>
<Input
value={customCollection.custom_disclaimer}
onChange={(e) => {
const newCollection = produce(customCollection, (draft) => {
draft.custom_disclaimer = e.target.value
})
setCustomCollection(newCollection)
}}
className="h-10 grow"
placeholder={t('createTool.customDisclaimerPlaceholder', { ns: 'tools' }) || ''}
/>
</div>
</div>
<div className={cn(isEdit ? 'justify-between' : 'justify-end', 'mt-2 flex shrink-0 rounded-b-[10px] border-t border-divider-regular bg-background-section-burn px-6 py-4')}>
{
isEdit && (
<Button variant="primary" tone="destructive" onClick={onRemove}>{t('operation.delete', { ns: 'common' })}</Button>
)
}
<div className="flex space-x-2">
<Button onClick={onHide}>{t('operation.cancel', { ns: 'common' })}</Button>
<Button variant="primary" onClick={handleSave}>{t('operation.save', { ns: 'common' })}</Button>
</div>
</div>
{showEmojiPicker && (
<EmojiPicker
onSelect={(icon, icon_background) => {
setEmoji({ content: icon, background: icon_background })
setShowEmojiPicker(false)
}}
onClose={() => {
setShowEmojiPicker(false)
}}
/>
)}
{credentialsModalShow && (
<ConfigCredentials
positionCenter={isAdd}
credential={credential}
onChange={setCredential}
onHide={() => setCredentialsModalShow(false)}
/>
)}
{isShowTestApi && (
<TestApi
positionCenter={isAdd}
tool={currTool as CustomParamSchema}
customCollection={customCollection}
onHide={() => setIsShowTestApi(false)}
/>
)}
</div>
</div>
</div>
{/* Authorization method */}
<div>
<div className="py-2 system-sm-medium text-text-primary">{t('createTool.authMethod.title', { ns: 'tools' })}</div>
<div className="flex h-9 cursor-pointer items-center justify-between rounded-lg bg-components-input-bg-normal px-2.5" onClick={() => setCredentialsModalShow(true)}>
<div className="system-xs-regular text-text-primary">{t(`createTool.authMethod.types.${credential.auth_type}`, { ns: 'tools' })}</div>
<RiSettings2Line className="h-4 w-4 text-text-secondary" />
</div>
</div>
{/* Labels */}
<div>
<div className="py-2 system-sm-medium text-text-primary">{t('createTool.toolInput.label', { ns: 'tools' })}</div>
<LabelSelector value={labels} onChange={handleLabelSelect} />
</div>
{/* Privacy Policy */}
<div>
<div className="py-2 system-sm-medium text-text-primary">{t('createTool.privacyPolicy', { ns: 'tools' })}</div>
<Input
value={customCollection.privacy_policy}
onChange={(e) => {
const newCollection = produce(customCollection, (draft) => {
draft.privacy_policy = e.target.value
})
setCustomCollection(newCollection)
}}
className="h-10 grow"
placeholder={t('createTool.privacyPolicyPlaceholder', { ns: 'tools' }) || ''}
/>
</div>
<div>
<div className="py-2 system-sm-medium text-text-primary">{t('createTool.customDisclaimer', { ns: 'tools' })}</div>
<Input
value={customCollection.custom_disclaimer}
onChange={(e) => {
const newCollection = produce(customCollection, (draft) => {
draft.custom_disclaimer = e.target.value
})
setCustomCollection(newCollection)
}}
className="h-10 grow"
placeholder={t('createTool.customDisclaimerPlaceholder', { ns: 'tools' }) || ''}
/>
</div>
</div>
<div className={cn(isEdit ? 'justify-between' : 'justify-end', 'mt-2 flex shrink-0 rounded-b-[10px] border-t border-divider-regular bg-background-section-burn px-6 py-4')}>
{
isEdit && (
<Button variant="primary" tone="destructive" onClick={onRemove}>{t('operation.delete', { ns: 'common' })}</Button>
)
}
<div className="flex space-x-2">
<Button onClick={onHide}>{t('operation.cancel', { ns: 'common' })}</Button>
<Button variant="primary" onClick={handleSave}>{t('operation.save', { ns: 'common' })}</Button>
</div>
</div>
{showEmojiPicker && (
<EmojiPicker
onSelect={(icon, icon_background) => {
setEmoji({ content: icon, background: icon_background })
setShowEmojiPicker(false)
}}
onClose={() => {
setShowEmojiPicker(false)
}}
/>
)}
{credentialsModalShow && (
<ConfigCredentials
positionCenter={isAdd}
credential={credential}
onChange={setCredential}
onHide={() => setCredentialsModalShow(false)}
/>
)}
{isShowTestApi && (
<TestApi
positionCenter={isAdd}
tool={currTool as CustomParamSchema}
customCollection={customCollection}
onHide={() => setIsShowTestApi(false)}
/>
)}
</div>
)}
isShowMask={true}
clickOutsideNotOpen={true}
/>
</DrawerContent>
</DrawerPopup>
</DrawerViewport>
</DrawerPortal>
</Drawer>
</>
)

View File

@ -2,11 +2,21 @@
import type { FC } from 'react'
import type { Credential, CustomCollectionBackend, CustomParamSchema } from '@/app/components/tools/types'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import {
Drawer,
DrawerBackdrop,
DrawerCloseButton,
DrawerContent,
DrawerPopup,
DrawerPortal,
DrawerTitle,
DrawerViewport,
} from '@langgenius/dify-ui/drawer'
import { RiSettings2Line } from '@remixicon/react'
import * as React from 'react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import Drawer from '@/app/components/base/drawer-plus'
import Input from '@/app/components/base/input'
import { AuthType } from '@/app/components/tools/types'
import { useLocale } from '@/context/i18n'
@ -63,70 +73,96 @@ const TestApi: FC<Props> = ({
return (
<>
<Drawer
isShow
positionCenter={positionCenter}
onHide={onHide}
title={`${t('test.title', { ns: 'tools' })} ${toolName}`}
panelClassName="mt-2 w-[600px]!"
maxWidthClassName="max-w-[600px]!"
height="calc(100vh - 16px)"
headerClassName="border-b-divider-regular!"
body={(
<div className="overflow-y-auto px-6 pt-2">
<div className="space-y-4">
<div>
<div className="py-2 system-sm-medium text-text-primary">{t('createTool.authMethod.title', { ns: 'tools' })}</div>
<div className="flex h-9 cursor-pointer items-center justify-between rounded-lg bg-components-input-bg-normal px-2.5" onClick={() => setCredentialsModalShow(true)}>
<div className="system-xs-regular text-text-primary">{t(`createTool.authMethod.types.${tempCredential.auth_type}`, { ns: 'tools' })}</div>
<RiSettings2Line className="h-4 w-4 text-text-secondary" />
open
modal
disablePointerDismissal
swipeDirection="right"
onOpenChange={(open) => {
if (!open)
onHide()
}}
>
<DrawerPortal>
<DrawerBackdrop forceRender />
<DrawerViewport>
<DrawerPopup
className={cn(
'data-[swipe-direction=right]:top-2 data-[swipe-direction=right]:bottom-2 data-[swipe-direction=right]:h-auto data-[swipe-direction=right]:w-150 data-[swipe-direction=right]:max-w-[calc(100vw-1rem)] data-[swipe-direction=right]:rounded-xl data-[swipe-direction=right]:border-r-[0.5px] data-[swipe-direction=right]:border-divider-subtle',
positionCenter
? 'data-[swipe-direction=right]:right-[max(0.5rem,calc(50%_-_300px))]'
: 'data-[swipe-direction=right]:right-2',
)}
>
<DrawerContent className="flex min-h-0 flex-1 flex-col p-0 pb-0">
<div className="shrink-0 border-b border-divider-regular py-4">
<div className="flex h-6 items-center justify-between pr-5 pl-6">
<DrawerTitle className="min-w-0 truncate system-xl-semibold text-text-primary">
{`${t('test.title', { ns: 'tools' })} ${toolName}`}
</DrawerTitle>
<DrawerCloseButton
aria-label={t('operation.close', { ns: 'common' })}
className="h-6 w-6 rounded-md"
/>
</div>
</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-6 pt-2">
<div className="space-y-4">
<div>
<div className="py-2 system-sm-medium text-text-primary">{t('createTool.authMethod.title', { ns: 'tools' })}</div>
<div className="flex h-9 cursor-pointer items-center justify-between rounded-lg bg-components-input-bg-normal px-2.5" onClick={() => setCredentialsModalShow(true)}>
<div className="system-xs-regular text-text-primary">{t(`createTool.authMethod.types.${tempCredential.auth_type}`, { ns: 'tools' })}</div>
<RiSettings2Line className="h-4 w-4 text-text-secondary" />
</div>
</div>
<div>
<div className="py-2 system-sm-medium text-text-primary">{t('test.parametersValue', { ns: 'tools' })}</div>
<div className="rounded-lg border border-divider-regular">
<table className="w-full system-xs-regular font-normal text-text-secondary">
<thead className="text-text-tertiary uppercase">
<tr className="border-b border-divider-regular">
<th className="p-2 pl-3 font-medium">{t('test.parameters', { ns: 'tools' })}</th>
<th className="p-2 pl-3 font-medium">{t('test.value', { ns: 'tools' })}</th>
</tr>
</thead>
<tbody>
{parameters.map((item, index) => (
<tr key={index} className="border-b border-divider-regular last:border-0">
<td className="py-2 pr-2.5 pl-3">
{item.label[language]}
</td>
<td className="">
<Input
value={parametersValue[item.name] || ''}
onChange={e => setParametersValue({ ...parametersValue, [item.name]: e.target.value })}
type="text"
className="!hover:border-transparent !hover:bg-transparent !focus:border-transparent !focus:bg-transparent border-transparent! bg-transparent!"
/>
</td>
</tr>
))}
</tbody>
</table>
<div>
<div className="py-2 system-sm-medium text-text-primary">{t('test.parametersValue', { ns: 'tools' })}</div>
<div className="rounded-lg border border-divider-regular">
<table className="w-full system-xs-regular font-normal text-text-secondary">
<thead className="text-text-tertiary uppercase">
<tr className="border-b border-divider-regular">
<th className="p-2 pl-3 font-medium">{t('test.parameters', { ns: 'tools' })}</th>
<th className="p-2 pl-3 font-medium">{t('test.value', { ns: 'tools' })}</th>
</tr>
</thead>
<tbody>
{parameters.map((item, index) => (
<tr key={index} className="border-b border-divider-regular last:border-0">
<td className="py-2 pr-2.5 pl-3">
{item.label[language]}
</td>
<td className="">
<Input
value={parametersValue[item.name] || ''}
onChange={e => setParametersValue({ ...parametersValue, [item.name]: e.target.value })}
type="text"
className="!hover:border-transparent !hover:bg-transparent !focus:border-transparent !focus:bg-transparent border-transparent! bg-transparent!"
/>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
<Button variant="primary" className="mt-4 h-10 w-full" loading={testing} disabled={testing} onClick={handleTest}>{t('test.title', { ns: 'tools' })}</Button>
<div className="mt-6">
<div className="flex items-center space-x-3">
<div className="system-xs-semibold text-text-tertiary">{t('test.testResult', { ns: 'tools' })}</div>
<div className="bg-[rgb(243, 244, 246)] h-px w-0 grow"></div>
</div>
<div className="mt-2 h-[200px] overflow-x-hidden overflow-y-auto rounded-lg bg-components-input-bg-normal px-3 py-2 system-xs-regular text-text-secondary">
{result || <span className="text-text-quaternary">{t('test.testResultPlaceholder', { ns: 'tools' })}</span>}
</div>
</div>
</div>
</div>
</div>
<Button variant="primary" className="mt-4 h-10 w-full" loading={testing} disabled={testing} onClick={handleTest}>{t('test.title', { ns: 'tools' })}</Button>
<div className="mt-6">
<div className="flex items-center space-x-3">
<div className="system-xs-semibold text-text-tertiary">{t('test.testResult', { ns: 'tools' })}</div>
<div className="bg-[rgb(243, 244, 246)] h-px w-0 grow"></div>
</div>
<div className="mt-2 h-[200px] overflow-x-hidden overflow-y-auto rounded-lg bg-components-input-bg-normal px-3 py-2 system-xs-regular text-text-secondary">
{result || <span className="text-text-quaternary">{t('test.testResultPlaceholder', { ns: 'tools' })}</span>}
</div>
</div>
</div>
)}
/>
</DrawerContent>
</DrawerPopup>
</DrawerViewport>
</DrawerPortal>
</Drawer>
{credentialsModalShow && (
<ConfigCredentials
positionCenter={positionCenter}

View File

@ -23,16 +23,6 @@ vi.mock('../../../utils/to-form-schema', () => ({
addDefaultValue: (value: Record<string, unknown>, _schemas: unknown[]) => ({ ...value }),
}))
vi.mock('@/app/components/base/drawer-plus', () => ({
default: ({ body, title, onHide }: { body: React.ReactNode, title: string, onHide: () => void }) => (
<div data-testid="drawer">
<span data-testid="drawer-title">{title}</span>
<button data-testid="drawer-close" onClick={onHide}>Close</button>
{body}
</div>
),
}))
vi.mock('@langgenius/dify-ui/toast', () => ({
default: { notify: vi.fn() },
}))
@ -104,7 +94,7 @@ describe('ConfigCredential', () => {
onSaved={mockOnSaved}
/>,
)
expect(screen.getByTestId('drawer-title')).toHaveTextContent('tools.auth.setupModalTitle')
expect(screen.getByText('tools.auth.setupModalTitle')).toBeInTheDocument()
})
it('calls onCancel when cancel button is clicked', async () => {

View File

@ -3,12 +3,22 @@ import type { FC } from 'react'
import type { Collection } from '../../types'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import {
Drawer,
DrawerBackdrop,
DrawerCloseButton,
DrawerContent,
DrawerDescription,
DrawerPopup,
DrawerPortal,
DrawerTitle,
DrawerViewport,
} from '@langgenius/dify-ui/drawer'
import { toast } from '@langgenius/dify-ui/toast'
import { noop } from 'es-toolkit/function'
import * as React from 'react'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Drawer from '@/app/components/base/drawer-plus'
import { LinkExternal02 } from '@/app/components/base/icons/src/vender/line/general'
import Loading from '@/app/components/base/loading'
import { useLanguage } from '@/app/components/header/account-setting/model-provider-page/hooks'
@ -69,64 +79,81 @@ const ConfigCredential: FC<Props> = ({
return (
<Drawer
isShow
onHide={onCancel}
title={t('auth.setupModalTitle', { ns: 'tools' }) as string}
titleDescription={t('auth.setupModalTitleDescription', { ns: 'tools' }) as string}
panelClassName="mt-[64px] mb-2 w-[420px]! border-components-panel-border"
maxWidthClassName="max-w-[420px]!"
height="calc(100vh - 64px)"
contentClassName="bg-components-panel-bg!"
headerClassName="border-b-divider-subtle!"
body={(
<div className="h-full px-6 py-3">
{!credentialSchema
? <Loading type="app" />
: (
<>
<Form
value={tempCredential}
onChange={(v) => {
setTempCredential(v)
}}
formSchemas={credentialSchema}
isEditMode={true}
showOnVariableMap={{}}
validating={false}
inputClassName="bg-components-input-bg-normal!"
fieldMoreInfo={item => item.url
? (
<a
href={item.url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center text-xs text-text-accent"
>
{t('howToGet', { ns: 'tools' })}
<LinkExternal02 className="ml-1 h-3 w-3" />
</a>
)
: null}
open
modal
swipeDirection="right"
onOpenChange={(open) => {
if (!open)
onCancel()
}}
>
<DrawerPortal>
<DrawerBackdrop />
<DrawerViewport>
<DrawerPopup className="data-[swipe-direction=right]:top-16 data-[swipe-direction=right]:right-2 data-[swipe-direction=right]:bottom-2 data-[swipe-direction=right]:h-auto data-[swipe-direction=right]:w-105 data-[swipe-direction=right]:max-w-[calc(100vw-1rem)] data-[swipe-direction=right]:rounded-xl data-[swipe-direction=right]:border-[0.5px] data-[swipe-direction=right]:border-components-panel-border">
<DrawerContent className="flex min-h-0 flex-1 flex-col bg-components-panel-bg p-0 pb-0">
<div className="shrink-0 border-b border-divider-subtle py-4">
<div className="flex h-6 items-center justify-between pr-5 pl-6">
<DrawerTitle className="min-w-0 truncate system-xl-semibold text-text-primary">
{t('auth.setupModalTitle', { ns: 'tools' })}
</DrawerTitle>
<DrawerCloseButton
aria-label={t('operation.close', { ns: 'common' })}
className="h-6 w-6 rounded-md"
/>
<div className={cn((collection.is_team_authorization && !isHideRemoveBtn) ? 'justify-between' : 'justify-end', 'mt-2 flex')}>
{
(collection.is_team_authorization && !isHideRemoveBtn) && (
<Button onClick={onRemove}>{t('operation.remove', { ns: 'common' })}</Button>
)
}
<div className="flex space-x-2">
<Button onClick={onCancel}>{t('operation.cancel', { ns: 'common' })}</Button>
<Button loading={isLoading || isSaving} disabled={isLoading || isSaving} variant="primary" onClick={handleSave}>{t('operation.save', { ns: 'common' })}</Button>
</div>
</div>
</>
)}
</div>
)}
isShowMask={true}
clickOutsideNotOpen={false}
/>
</div>
<DrawerDescription className="pr-10 pl-6 system-xs-regular text-text-tertiary">
{t('auth.setupModalTitleDescription', { ns: 'tools' })}
</DrawerDescription>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-6 py-3">
{!credentialSchema
? <Loading type="app" />
: (
<>
<Form
value={tempCredential}
onChange={(v) => {
setTempCredential(v)
}}
formSchemas={credentialSchema}
isEditMode={true}
showOnVariableMap={{}}
validating={false}
inputClassName="bg-components-input-bg-normal!"
fieldMoreInfo={item => item.url
? (
<a
href={item.url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center text-xs text-text-accent"
>
{t('howToGet', { ns: 'tools' })}
<LinkExternal02 className="ml-1 h-3 w-3" />
</a>
)
: null}
/>
<div className={cn((collection.is_team_authorization && !isHideRemoveBtn) ? 'justify-between' : 'justify-end', 'mt-2 flex')}>
{
(collection.is_team_authorization && !isHideRemoveBtn) && (
<Button onClick={onRemove}>{t('operation.remove', { ns: 'common' })}</Button>
)
}
<div className="flex space-x-2">
<Button onClick={onCancel}>{t('operation.cancel', { ns: 'common' })}</Button>
<Button loading={isLoading || isSaving} disabled={isLoading || isSaving} variant="primary" onClick={handleSave}>{t('operation.save', { ns: 'common' })}</Button>
</div>
</div>
</>
)}
</div>
</DrawerContent>
</DrawerPopup>
</DrawerViewport>
</DrawerPortal>
</Drawer>
)
}
export default React.memo(ConfigCredential)

View File

@ -62,7 +62,7 @@ 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].
For overlay import policy and composition rules, see [Overlay Guide].
## Type Check
@ -78,7 +78,7 @@ Type checking is powered by [`tsgo`] (the native TypeScript 7 compiler), which i
[ESLint bulk suppressions blog post]: https://eslint.org/blog/2025/04/introducing-bulk-suppressions
[ESLint multi-thread linting blog post]: https://eslint.org/blog/2025/08/multithread-linting
[Overlay Migration Guide]: ./overlay-migration.md
[Overlay Guide]: ./overlay.md
[TSSLint]: https://github.com/johnsoncodehk/tsslint
[`tsgo`]: https://devblogs.microsoft.com/typescript/announcing-typescript-7-0-beta
[no-leaked-conditional-rendering]: https://www.eslint-react.xyz/docs/rules/no-leaked-conditional-rendering

View File

@ -1,91 +0,0 @@
# Overlay Migration Guide
This document tracks the Dify-web migration away from legacy overlay APIs.
> **See also:** [`packages/dify-ui/README.md`] for the permanent overlay / portal / z-index contract of the replacement primitives. This document covers the one-off migration mechanics (deprecated import paths and coexistence z-index strategy) and is expected to shrink and eventually be removed once the legacy overlays are gone.
## Scope
- Deprecated imports:
- `@/app/components/base/modal`
- `@/app/components/base/dialog`
- `@/app/components/base/drawer`
- `@/app/components/base/drawer-plus`
- Replacement primitives:
- `@langgenius/dify-ui/tooltip`
- `@langgenius/dify-ui/dropdown-menu`
- `@langgenius/dify-ui/context-menu`
- `@langgenius/dify-ui/popover`
- `@langgenius/dify-ui/dialog`
- `@langgenius/dify-ui/drawer`
- `@langgenius/dify-ui/alert-dialog`
- `@langgenius/dify-ui/autocomplete`
- `@langgenius/dify-ui/combobox`
- `@langgenius/dify-ui/select`
- `@langgenius/dify-ui/toast`
- Tracking issue: <https://github.com/langgenius/dify/issues/32767>
## ESLint policy
- `no-restricted-imports` blocks all deprecated imports listed above.
- The rule is enabled for normal source files (`.ts` / `.tsx`) and test files are excluded.
## Migration phases
1. Business/UI features outside `app/components/base/**`
- Migrate old calls to semantic primitives from `@langgenius/dify-ui/*`.
- Keep deprecated imports out of newly touched files.
- Use `@langgenius/dify-ui/tooltip` only for short, non-interactive labels where the trigger already has its own accessible name.
- Use `@langgenius/dify-ui/popover` or the web `Infotip` wrapper for explanatory, long-form, structured, or interactive content.
1. Legacy base components
- Migrate legacy base callers gradually.
- Keep deprecated imports out of newly touched files.
1. Cleanup
- Remove legacy overlay implementations when import count reaches zero.
## z-index strategy
All new overlay primitives in `@langgenius/dify-ui/*` share a single z-index value:
**`z-1002`**, except Toast which stays one layer above at **`z-1003`**.
### Why z-[1002]?
During the migration period, legacy and new overlays coexist. Legacy overlays
portal to `document.body` with explicit z-index values:
| Layer | z-index | Components |
| --------------------- | ------------ | ---------------------------------------------------------------------------------------- |
| Legacy Drawer | `z-30` | `base/drawer`, `base/drawer-plus` |
| Legacy Modal | `z-60` | `base/modal` (default) |
| **New UI primitives** | **`z-1002`** | `@langgenius/dify-ui/*` (Drawer, Popover, Dialog, Autocomplete, Combobox, Tooltip, etc.) |
| Toast | `z-1003` | `@langgenius/dify-ui/toast` |
`z-1002` sits above all common legacy overlays, so new primitives always
render on top without needing per-call-site z-index hacks. Among themselves,
new primitives share the same z-index and rely on **DOM order** for stacking
(later portal = on top).
Toast stays one layer above the overlay primitives so notifications remain
visible above dialogs, popovers, and other portalled surfaces without falling
back to `z-9999`.
### Rules
- **Do NOT add z-index overrides** (e.g. `className="z-1003"`) on new
`@langgenius/dify-ui/*` components. If you find yourself needing one, the
parent legacy overlay should be migrated instead.
- When migrating a legacy overlay that has a high z-index, remove the z-index
entirely — the new primitive's default `z-1002` handles it.
- When using Base UI trigger `render`, render a real `button` for button-like
triggers. If the trigger must render a non-button element, the primitive must
explicitly opt out of the native button behavior where that API is available.
### Post-migration cleanup
Once all legacy overlays are removed:
1. Reduce `z-1002` back to `z-50` across all `@langgenius/dify-ui/*` primitives.
1. Reduce Toast from `z-1003` to `z-51`.
1. Remove this section from the migration guide.
[`packages/dify-ui/README.md`]: ../../packages/dify-ui/README.md

44
web/docs/overlay.md Normal file
View File

@ -0,0 +1,44 @@
# Overlay Best Practices
Use `@langgenius/dify-ui/*` primitives for overlays in new and modified web
code. Do not import raw Base UI overlays or legacy web overlays from
`@/app/components/base/modal`, `@/app/components/base/dialog`, or
`@/app/components/base/drawer`.
## Primitive choice
- Use `@langgenius/dify-ui/dialog` for modal surfaces that need focus
management, scroll locking, escape handling, and outside-press dismissal.
- Use `@langgenius/dify-ui/alert-dialog` only for destructive or must-confirm
decisions.
- Use `@langgenius/dify-ui/drawer` for side panels, setup panels, and nested
editor panels that must behave like a drawer. Do not add separate web drawer
wrappers.
- Use `@langgenius/dify-ui/popover` or the web `Infotip` wrapper for
explanatory content, long help text, rich layout, or interactive content.
- Use `@langgenius/dify-ui/tooltip` only for short, non-interactive labels where
the trigger already has its own accessible name.
## Preferences
- Prefer the most specific semantic primitive over styling a generic `Dialog`.
- Prefer controlled `open` / `onOpenChange` when business state, analytics, or
cleanup must react to open state changes.
- Prefer the primitive-owned portal or content component. Do not create manual
portals around overlay primitives.
- Prefer native button trigger semantics. When passing a Base UI trigger
`render` prop, render a real `<button type="button">` for button-like
triggers; use `nativeButton={false}` only for intentional non-button triggers.
- Use `Infotip` for visible `?` help triggers. Give icon-only triggers an
accessible name.
- Keep overlay chrome inside the shared primitive or business wrapper instead of
repeating backdrop, z-index, and portal styles at call sites.
## Layering
All body-portalled Dify UI overlays use `z-50`. Toast uses `z-60`. The app root
must keep an isolated stacking context.
Do not add call-site z-index overrides such as `z-9999`. If an overlay is
clipped or hidden, fix the parent overlay structure instead of raising the
child primitive.

View File

@ -172,7 +172,7 @@ export default antfu(
},
},
{
name: 'dify/overlay-migration',
name: 'dify/overlay-import-policy',
files: [GLOB_TS, GLOB_TSX],
ignores: [
'next/**',

View File

@ -55,26 +55,10 @@ export const WEB_RESTRICTED_IMPORT_PATTERNS = [
]
export const OVERLAY_RESTRICTED_IMPORT_PATTERNS = [
{
group: [
'**/base/modal',
'**/base/modal/index',
],
message: 'Deprecated: use @langgenius/dify-ui/dialog instead. See issue #32767.',
},
{
group: [
'**/base/dialog',
'**/base/dialog/index',
],
message: 'Deprecated: use @langgenius/dify-ui/dialog instead. See issue #32767.',
},
{
group: [
'**/base/drawer',
'**/base/drawer/index',
'**/base/drawer-plus',
'**/base/drawer-plus/index',
],
message: 'Deprecated: use @langgenius/dify-ui/drawer instead. See issue #32767.',
},

View File

@ -92,7 +92,7 @@ export const removeCustomCollection = (collectionName: string) => {
}
export const importSchemaFromURL = (url: string) => {
return get('/workspaces/current/tool-provider/api/remote', {
return get<{ schema: string }>('/workspaces/current/tool-provider/api/remote', {
params: {
url,
},