mirror of
https://github.com/langgenius/dify.git
synced 2026-05-10 05:56:31 +08:00
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:
parent
8581a68174
commit
24ea21db25
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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.
|
||||
|
||||
|
||||
@ -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,
|
||||
)}
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
)}
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
@ -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))]',
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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>
|
||||
)
|
||||
|
||||
@ -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' })}
|
||||
|
||||
{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' })}
|
||||
|
||||
{formatTime(createdAt, t('dateTimeFormat', { ns: 'appLog' }) as string)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</DrawerPopup>
|
||||
</DrawerViewport>
|
||||
</DrawerPortal>
|
||||
</Drawer>
|
||||
</div>
|
||||
|
||||
)
|
||||
|
||||
@ -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' })}
|
||||
|
||||
{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>
|
||||
|
||||
)
|
||||
|
||||
@ -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)', () => {
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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,
|
||||
},
|
||||
}
|
||||
@ -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)
|
||||
@ -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">
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
|
||||
)
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
44
web/docs/overlay.md
Normal 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.
|
||||
@ -172,7 +172,7 @@ export default antfu(
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'dify/overlay-migration',
|
||||
name: 'dify/overlay-import-policy',
|
||||
files: [GLOB_TS, GLOB_TSX],
|
||||
ignores: [
|
||||
'next/**',
|
||||
|
||||
@ -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.',
|
||||
},
|
||||
|
||||
@ -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,
|
||||
},
|
||||
|
||||
Loading…
Reference in New Issue
Block a user