mirror of
https://github.com/langgenius/dify.git
synced 2026-06-11 02:31:13 +08:00
feat: add dify-ui collapsible primitive and refactor workflow collapse usage (#37276)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
parent
fb70ebb8f8
commit
0d8f7c41de
@ -3529,11 +3529,6 @@
|
||||
"count": 5
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/nodes/_base/components/collapse/index.tsx": {
|
||||
"no-barrel-files/no-barrel-files": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/nodes/_base/components/editor/code-editor/editor-support-vars.tsx": {
|
||||
"ts/no-explicit-any": {
|
||||
"count": 6
|
||||
|
||||
@ -47,7 +47,7 @@ Importing from `@langgenius/dify-ui` (no subpath) is intentionally not supported
|
||||
| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------- |
|
||||
| Actions | `./button` | Design-system CTA primitive with `cva` variants. |
|
||||
| Controls | `./segmented-control` | SegmentedControl for mode, filter, and view selection. |
|
||||
| Display | `./kbd` | Keyboard input and shortcut keycap primitives. |
|
||||
| Display | `./collapsible`, `./kbd` | Collapsible disclosure primitive; keyboard input and shortcut keycap primitives. |
|
||||
| Feedback | `./meter`, `./toast` | Meter is inline status; Toast owns the `z-60` layer. |
|
||||
| Form | `./form`, `./field`, `./fieldset`, `./input`, `./textarea`, `./checkbox`, `./checkbox-group`, `./radio`, `./radio-group`, `./number-field`, `./select`, `./slider`, `./switch` | Native form boundary, field semantics, and controls. |
|
||||
| Layout | `./scroll-area` | Custom-styled scrollbar over the host viewport. |
|
||||
|
||||
@ -37,6 +37,10 @@
|
||||
"types": "./src/combobox/index.tsx",
|
||||
"import": "./src/combobox/index.tsx"
|
||||
},
|
||||
"./collapsible": {
|
||||
"types": "./src/collapsible/index.tsx",
|
||||
"import": "./src/collapsible/index.tsx"
|
||||
},
|
||||
"./context-menu": {
|
||||
"types": "./src/context-menu/index.tsx",
|
||||
"import": "./src/context-menu/index.tsx"
|
||||
|
||||
89
packages/dify-ui/src/collapsible/__tests__/index.spec.tsx
Normal file
89
packages/dify-ui/src/collapsible/__tests__/index.spec.tsx
Normal file
@ -0,0 +1,89 @@
|
||||
import { render } from 'vitest-browser-react'
|
||||
import {
|
||||
CollapsiblePanel,
|
||||
CollapsibleRoot,
|
||||
CollapsibleTrigger,
|
||||
} from '../index'
|
||||
|
||||
const asHTMLElement = (element: HTMLElement | SVGElement) => element as HTMLElement
|
||||
|
||||
describe('Collapsible wrappers', () => {
|
||||
it('renders the Base UI anatomy with an accessible trigger', async () => {
|
||||
const screen = await render(
|
||||
<CollapsibleRoot defaultOpen data-testid="collapsible-root">
|
||||
<CollapsibleTrigger>Recovery keys</CollapsibleTrigger>
|
||||
<CollapsiblePanel>Panel content</CollapsiblePanel>
|
||||
</CollapsibleRoot>,
|
||||
)
|
||||
|
||||
await expect.element(screen.getByTestId('collapsible-root')).toBeInTheDocument()
|
||||
await expect.element(screen.getByRole('button', { name: 'Recovery keys' })).toHaveAttribute('data-panel-open', '')
|
||||
await expect.element(screen.getByText('Panel content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('toggles open state through the trigger without caller-owned state', async () => {
|
||||
const screen = await render(
|
||||
<CollapsibleRoot>
|
||||
<CollapsibleTrigger>Toggle section</CollapsibleTrigger>
|
||||
<CollapsiblePanel>Hidden content</CollapsiblePanel>
|
||||
</CollapsibleRoot>,
|
||||
)
|
||||
const trigger = screen.getByRole('button', { name: 'Toggle section' })
|
||||
|
||||
await expect.element(trigger).not.toHaveAttribute('data-panel-open')
|
||||
|
||||
asHTMLElement(trigger.element()).click()
|
||||
|
||||
await expect.element(trigger).toHaveAttribute('data-panel-open', '')
|
||||
await expect.element(screen.getByText('Hidden content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('forwards className to every compound part', async () => {
|
||||
const screen = await render(
|
||||
<CollapsibleRoot defaultOpen className="custom-root">
|
||||
<CollapsibleTrigger className="custom-trigger">Custom</CollapsibleTrigger>
|
||||
<CollapsiblePanel className="custom-panel">Custom panel</CollapsiblePanel>
|
||||
</CollapsibleRoot>,
|
||||
)
|
||||
|
||||
await expect.element(screen.getByRole('button', { name: 'Custom' })).toHaveClass('custom-trigger')
|
||||
expect(screen.getByText('Custom panel').element()).toHaveClass('custom-panel')
|
||||
expect(screen.container.querySelector('.custom-root')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('passes Base UI panel props through to the panel', async () => {
|
||||
const screen = await render(
|
||||
<CollapsibleRoot defaultOpen>
|
||||
<CollapsibleTrigger>Styled trigger</CollapsibleTrigger>
|
||||
<CollapsiblePanel keepMounted>Styled panel</CollapsiblePanel>
|
||||
</CollapsibleRoot>,
|
||||
)
|
||||
|
||||
await expect.element(screen.getByRole('button', { name: 'Styled trigger' })).toHaveAttribute('data-panel-open', '')
|
||||
await expect.element(screen.getByText('Styled panel')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('applies Dify disclosure defaults without a pressed active style', async () => {
|
||||
const screen = await render(
|
||||
<CollapsibleRoot defaultOpen>
|
||||
<CollapsibleTrigger>Styled trigger</CollapsibleTrigger>
|
||||
<CollapsiblePanel>Styled panel</CollapsiblePanel>
|
||||
</CollapsibleRoot>,
|
||||
)
|
||||
const trigger = screen.getByRole('button', { name: 'Styled trigger' }).element()
|
||||
const panel = screen.getByText('Styled panel').element()
|
||||
|
||||
expect(trigger).toHaveClass(
|
||||
'hover:not-data-disabled:bg-components-panel-on-panel-item-bg-hover',
|
||||
'focus-visible:ring-2',
|
||||
'focus-visible:ring-state-accent-solid',
|
||||
'data-panel-open:text-text-primary',
|
||||
)
|
||||
expect(trigger.className).not.toContain('active:')
|
||||
expect(panel).toHaveClass(
|
||||
'h-(--collapsible-panel-height)',
|
||||
'data-ending-style:h-0',
|
||||
'data-starting-style:h-0',
|
||||
)
|
||||
})
|
||||
})
|
||||
183
packages/dify-ui/src/collapsible/index.stories.tsx
Normal file
183
packages/dify-ui/src/collapsible/index.stories.tsx
Normal file
@ -0,0 +1,183 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite'
|
||||
import * as React from 'react'
|
||||
import {
|
||||
CollapsiblePanel,
|
||||
CollapsibleRoot,
|
||||
CollapsibleTrigger,
|
||||
} from '.'
|
||||
import { cn } from '../cn'
|
||||
|
||||
const meta = {
|
||||
title: 'Base/UI/Collapsible',
|
||||
component: CollapsibleRoot,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Unstyled Base UI Collapsible primitive. The examples mirror the official Root, Trigger, and Panel anatomy, with presentation supplied at the call site using Dify UI tokens.',
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
} satisfies Meta<typeof CollapsibleRoot>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
const rootClassName = 'w-72 rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg p-1 shadow-lg shadow-shadow-shadow-5'
|
||||
const triggerClassName = 'h-8'
|
||||
const panelClassName = 'system-sm-regular text-text-secondary'
|
||||
const contentClassName = 'flex flex-col gap-2 px-2.5 pb-2 pt-1'
|
||||
const iconClassName = 'i-ri-arrow-right-s-line size-4 shrink-0 text-text-tertiary transition-transform duration-100 ease-out group-data-panel-open:rotate-90 motion-reduce:transition-none'
|
||||
const sectionRootClassName = 'w-90 rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg p-1 shadow-lg shadow-shadow-shadow-5'
|
||||
const sectionTriggerClassName = cn(
|
||||
triggerClassName,
|
||||
'h-auto min-h-12 px-3 py-2',
|
||||
)
|
||||
const sectionPanelClassName = panelClassName
|
||||
|
||||
function TriggerIcon() {
|
||||
return <span aria-hidden="true" className={iconClassName} />
|
||||
}
|
||||
|
||||
function RecoveryKeys({
|
||||
panelProps,
|
||||
}: {
|
||||
panelProps?: React.ComponentProps<typeof CollapsiblePanel>
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<CollapsibleTrigger className={triggerClassName}>
|
||||
Recovery keys
|
||||
<TriggerIcon />
|
||||
</CollapsibleTrigger>
|
||||
<CollapsiblePanel className={panelClassName} {...panelProps}>
|
||||
<div className={contentClassName}>
|
||||
<div>alien-bean-pasta</div>
|
||||
<div>wild-irish-burrito</div>
|
||||
<div>horse-battery-staple</div>
|
||||
</div>
|
||||
</CollapsiblePanel>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export const Anatomy: Story = {
|
||||
args: {
|
||||
defaultOpen: true,
|
||||
},
|
||||
render: args => (
|
||||
<CollapsibleRoot {...args} className={rootClassName}>
|
||||
<RecoveryKeys />
|
||||
</CollapsibleRoot>
|
||||
),
|
||||
}
|
||||
|
||||
export const DefaultClosed: Story = {
|
||||
render: () => (
|
||||
<CollapsibleRoot className={rootClassName}>
|
||||
<RecoveryKeys />
|
||||
</CollapsibleRoot>
|
||||
),
|
||||
}
|
||||
|
||||
export const DefaultOpen: Story = {
|
||||
render: () => (
|
||||
<CollapsibleRoot defaultOpen className={rootClassName}>
|
||||
<RecoveryKeys />
|
||||
</CollapsibleRoot>
|
||||
),
|
||||
}
|
||||
|
||||
export const Controlled: Story = {
|
||||
render: () => {
|
||||
const [open, setOpen] = React.useState(true)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-start gap-3">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-lg border border-divider-subtle bg-components-button-secondary-bg px-3 py-1.5 system-sm-medium text-components-button-secondary-text shadow-xs shadow-shadow-shadow-3 hover:bg-state-base-hover focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-state-accent-solid"
|
||||
onClick={() => setOpen(value => !value)}
|
||||
>
|
||||
{open ? 'Close panel' : 'Open panel'}
|
||||
</button>
|
||||
<CollapsibleRoot open={open} onOpenChange={setOpen} className={rootClassName}>
|
||||
<RecoveryKeys />
|
||||
</CollapsibleRoot>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
export const Disabled: Story = {
|
||||
render: () => (
|
||||
<CollapsibleRoot disabled className={rootClassName}>
|
||||
<RecoveryKeys />
|
||||
</CollapsibleRoot>
|
||||
),
|
||||
}
|
||||
|
||||
export const KeepMounted: Story = {
|
||||
render: () => (
|
||||
<CollapsibleRoot className={rootClassName}>
|
||||
<RecoveryKeys panelProps={{ keepMounted: true }} />
|
||||
</CollapsibleRoot>
|
||||
),
|
||||
}
|
||||
|
||||
export const HiddenUntilFound: Story = {
|
||||
render: () => (
|
||||
<CollapsibleRoot className={rootClassName}>
|
||||
<RecoveryKeys panelProps={{ hiddenUntilFound: true }} />
|
||||
</CollapsibleRoot>
|
||||
),
|
||||
}
|
||||
|
||||
const settingSections = [
|
||||
{
|
||||
title: 'Model routing',
|
||||
description: 'Fallback model enabled, retry budget set to 2 attempts.',
|
||||
defaultOpen: true,
|
||||
},
|
||||
{
|
||||
title: 'Knowledge access',
|
||||
description: 'Retrieval is limited to approved workspace datasets.',
|
||||
defaultOpen: false,
|
||||
},
|
||||
{
|
||||
title: 'Observability',
|
||||
description: 'Request logs and workflow traces stay available for debugging.',
|
||||
defaultOpen: false,
|
||||
},
|
||||
] as const
|
||||
|
||||
export const SettingsSections: Story = {
|
||||
parameters: {
|
||||
layout: 'padded',
|
||||
},
|
||||
render: () => (
|
||||
<div className={sectionRootClassName}>
|
||||
{settingSections.map((section, index) => (
|
||||
<CollapsibleRoot
|
||||
key={section.title}
|
||||
defaultOpen={section.defaultOpen}
|
||||
className={cn(index > 0 && 'mt-px')}
|
||||
>
|
||||
<CollapsibleTrigger className={sectionTriggerClassName}>
|
||||
<span className="flex min-w-0 flex-col gap-1">
|
||||
<span className="truncate system-sm-medium text-text-primary">{section.title}</span>
|
||||
<span className="line-clamp-2 system-xs-regular text-text-tertiary">{section.description}</span>
|
||||
</span>
|
||||
<TriggerIcon />
|
||||
</CollapsibleTrigger>
|
||||
<CollapsiblePanel className={sectionPanelClassName}>
|
||||
<div className="px-3 pb-3 pt-1 system-sm-regular text-text-secondary">
|
||||
{section.description}
|
||||
</div>
|
||||
</CollapsiblePanel>
|
||||
</CollapsibleRoot>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
}
|
||||
71
packages/dify-ui/src/collapsible/index.tsx
Normal file
71
packages/dify-ui/src/collapsible/index.tsx
Normal file
@ -0,0 +1,71 @@
|
||||
'use client'
|
||||
|
||||
import type { Collapsible as BaseCollapsibleNS } from '@base-ui/react/collapsible'
|
||||
import { Collapsible as BaseCollapsible } from '@base-ui/react/collapsible'
|
||||
import { cn } from '../cn'
|
||||
|
||||
export type CollapsibleRootProps
|
||||
= Omit<BaseCollapsibleNS.Root.Props, 'className'>
|
||||
& {
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function CollapsibleRoot({
|
||||
className,
|
||||
...props
|
||||
}: CollapsibleRootProps) {
|
||||
return (
|
||||
<BaseCollapsible.Root
|
||||
className={cn('flex min-w-0 flex-col', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export type CollapsibleTriggerProps
|
||||
= Omit<BaseCollapsibleNS.Trigger.Props, 'className'>
|
||||
& {
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function CollapsibleTrigger({
|
||||
className,
|
||||
...props
|
||||
}: CollapsibleTriggerProps) {
|
||||
return (
|
||||
<BaseCollapsible.Trigger
|
||||
className={cn(
|
||||
'group flex min-h-8 w-full touch-manipulation items-center justify-between gap-2 rounded-lg px-2.5 text-left system-sm-medium text-text-secondary outline-hidden select-none',
|
||||
'hover:not-data-disabled:bg-components-panel-on-panel-item-bg-hover hover:not-data-disabled:text-text-primary',
|
||||
'focus-visible:ring-2 focus-visible:ring-state-accent-solid',
|
||||
'data-panel-open:text-text-primary',
|
||||
'data-disabled:cursor-not-allowed data-disabled:text-text-disabled data-disabled:hover:bg-transparent',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export type CollapsiblePanelProps
|
||||
= Omit<BaseCollapsibleNS.Panel.Props, 'className'>
|
||||
& {
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function CollapsiblePanel({
|
||||
className,
|
||||
...props
|
||||
}: CollapsiblePanelProps) {
|
||||
return (
|
||||
<BaseCollapsible.Panel
|
||||
className={cn(
|
||||
'h-(--collapsible-panel-height) overflow-hidden transition-[height] duration-150 ease-out motion-reduce:transition-none',
|
||||
'[&[hidden]:not([hidden=\'until-found\'])]:hidden',
|
||||
'data-ending-style:h-0 data-starting-style:h-0',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@ -1,6 +1,47 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import Collapse from '../index'
|
||||
import {
|
||||
Collapse,
|
||||
CollapseActions,
|
||||
CollapseContent,
|
||||
CollapseHeader,
|
||||
CollapseIndicator,
|
||||
CollapseTitle,
|
||||
CollapseTrigger,
|
||||
} from '../index'
|
||||
|
||||
function TestCollapse({
|
||||
children,
|
||||
title,
|
||||
actions,
|
||||
...props
|
||||
}: {
|
||||
title: string
|
||||
children: React.ReactNode
|
||||
actions?: React.ReactNode
|
||||
disabled?: boolean
|
||||
collapsed?: boolean
|
||||
onCollapse?: (collapsed: boolean) => void
|
||||
}) {
|
||||
return (
|
||||
<Collapse {...props}>
|
||||
<CollapseHeader>
|
||||
<CollapseTrigger>
|
||||
<CollapseTitle>{title}</CollapseTitle>
|
||||
<CollapseIndicator />
|
||||
</CollapseTrigger>
|
||||
{actions != null && (
|
||||
<CollapseActions>
|
||||
{actions}
|
||||
</CollapseActions>
|
||||
)}
|
||||
</CollapseHeader>
|
||||
<CollapseContent>
|
||||
{children}
|
||||
</CollapseContent>
|
||||
</Collapse>
|
||||
)
|
||||
}
|
||||
|
||||
describe('Collapse', () => {
|
||||
beforeEach(() => {
|
||||
@ -14,12 +55,12 @@ describe('Collapse', () => {
|
||||
const onCollapse = vi.fn()
|
||||
|
||||
render(
|
||||
<Collapse
|
||||
trigger={<div>Advanced</div>}
|
||||
<TestCollapse
|
||||
title="Advanced"
|
||||
onCollapse={onCollapse}
|
||||
>
|
||||
<div>Collapse content</div>
|
||||
</Collapse>,
|
||||
</TestCollapse>,
|
||||
)
|
||||
|
||||
expect(screen.queryByText('Collapse content')).not.toBeInTheDocument()
|
||||
@ -35,13 +76,13 @@ describe('Collapse', () => {
|
||||
const onCollapse = vi.fn()
|
||||
|
||||
render(
|
||||
<Collapse
|
||||
<TestCollapse
|
||||
disabled
|
||||
trigger={<div>Disabled section</div>}
|
||||
title="Disabled section"
|
||||
onCollapse={onCollapse}
|
||||
>
|
||||
<div>Hidden content</div>
|
||||
</Collapse>,
|
||||
</TestCollapse>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByText('Disabled section'))
|
||||
@ -50,25 +91,19 @@ describe('Collapse', () => {
|
||||
expect(onCollapse).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should respect controlled collapse state and render function triggers', async () => {
|
||||
it('should respect controlled collapse state and render actions separately', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onCollapse = vi.fn()
|
||||
|
||||
render(
|
||||
<Collapse
|
||||
<TestCollapse
|
||||
collapsed={false}
|
||||
hideCollapseIcon
|
||||
operations={<button type="button">Operation</button>}
|
||||
trigger={collapseIcon => (
|
||||
<div>
|
||||
<span>Controlled section</span>
|
||||
{collapseIcon}
|
||||
</div>
|
||||
)}
|
||||
actions={<button type="button">Operation</button>}
|
||||
title="Controlled section"
|
||||
onCollapse={onCollapse}
|
||||
>
|
||||
<div>Visible content</div>
|
||||
</Collapse>,
|
||||
</TestCollapse>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Visible content')).toBeInTheDocument()
|
||||
|
||||
@ -1,36 +0,0 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import Collapse from '.'
|
||||
|
||||
type FieldCollapseProps = {
|
||||
title: string
|
||||
children: ReactNode
|
||||
collapsed?: boolean
|
||||
onCollapse?: (collapsed: boolean) => void
|
||||
operations?: ReactNode
|
||||
}
|
||||
const FieldCollapse = ({
|
||||
title,
|
||||
children,
|
||||
collapsed,
|
||||
onCollapse,
|
||||
operations,
|
||||
}: FieldCollapseProps) => {
|
||||
return (
|
||||
<div className="py-4">
|
||||
<Collapse
|
||||
trigger={
|
||||
<div className="flex h-6 cursor-pointer items-center system-sm-semibold-uppercase text-text-secondary">{title}</div>
|
||||
}
|
||||
operations={operations}
|
||||
collapsed={collapsed}
|
||||
onCollapse={onCollapse}
|
||||
>
|
||||
<div className="px-4">
|
||||
{children}
|
||||
</div>
|
||||
</Collapse>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default FieldCollapse
|
||||
@ -1,69 +1,152 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type {
|
||||
ComponentProps,
|
||||
ReactNode,
|
||||
} from 'react'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { ArrowDownRoundFill } from '@/app/components/base/icons/src/vender/solid/general'
|
||||
import {
|
||||
CollapsiblePanel,
|
||||
CollapsibleRoot,
|
||||
CollapsibleTrigger,
|
||||
} from '@langgenius/dify-ui/collapsible'
|
||||
|
||||
export { default as FieldCollapse } from './field-collapse'
|
||||
|
||||
type CollapseProps = {
|
||||
disabled?: boolean
|
||||
trigger: React.JSX.Element | ((collapseIcon: React.JSX.Element | null) => React.JSX.Element)
|
||||
children: React.JSX.Element
|
||||
type CollapseProps = Omit<ComponentProps<typeof CollapsibleRoot>, 'open' | 'onOpenChange'> & {
|
||||
collapsed?: boolean
|
||||
onCollapse?: (collapsed: boolean) => void
|
||||
operations?: ReactNode
|
||||
hideCollapseIcon?: boolean
|
||||
}
|
||||
const Collapse = ({
|
||||
disabled,
|
||||
trigger,
|
||||
children,
|
||||
|
||||
export function Collapse({
|
||||
collapsed,
|
||||
onCollapse,
|
||||
operations,
|
||||
hideCollapseIcon,
|
||||
}: CollapseProps) => {
|
||||
const [collapsedLocal, setCollapsedLocal] = useState(true)
|
||||
const collapsedMerged = collapsed !== undefined ? collapsed : collapsedLocal
|
||||
const collapseIcon = useMemo(() => {
|
||||
if (disabled)
|
||||
return null
|
||||
|
||||
return (
|
||||
<ArrowDownRoundFill
|
||||
className={cn(
|
||||
'size-4 cursor-pointer text-text-quaternary group-hover/collapse:text-text-secondary',
|
||||
collapsedMerged && 'rotate-270',
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}, [collapsedMerged, disabled])
|
||||
...props
|
||||
}: CollapseProps) {
|
||||
return (
|
||||
<>
|
||||
<div className="group/collapse flex items-center">
|
||||
<div
|
||||
className="ml-4 flex grow items-center"
|
||||
onClick={() => {
|
||||
if (!disabled) {
|
||||
setCollapsedLocal(!collapsedMerged)
|
||||
onCollapse?.(!collapsedMerged)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{typeof trigger === 'function' ? trigger(collapseIcon) : trigger}
|
||||
{!hideCollapseIcon && (
|
||||
<div className="size-4 shrink-0">
|
||||
{collapseIcon}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{operations}
|
||||
</div>
|
||||
{
|
||||
!collapsedMerged && children
|
||||
}
|
||||
</>
|
||||
<CollapsibleRoot
|
||||
open={collapsed === undefined ? undefined : !collapsed}
|
||||
onOpenChange={open => onCollapse?.(!open)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default Collapse
|
||||
type CollapseHeaderProps = {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export function CollapseHeader({
|
||||
children,
|
||||
}: CollapseHeaderProps) {
|
||||
return (
|
||||
<div className="group/collapse flex items-center">
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type CollapseActionsProps = {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export function CollapseActions({
|
||||
children,
|
||||
}: CollapseActionsProps) {
|
||||
return (
|
||||
<div className="ml-auto shrink-0">
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type CollapseTriggerProps = ComponentProps<typeof CollapsibleTrigger>
|
||||
|
||||
export function CollapseTrigger({
|
||||
className,
|
||||
...props
|
||||
}: CollapseTriggerProps) {
|
||||
return (
|
||||
<CollapsibleTrigger
|
||||
className={cn(
|
||||
'group/collapse ml-4 flex h-6 min-h-0 w-auto min-w-0 shrink-0 items-center justify-start gap-0 rounded-md px-0 py-0 text-text-secondary hover:not-data-disabled:bg-transparent hover:not-data-disabled:text-text-secondary data-panel-open:text-text-secondary',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
type CollapseTitleProps = {
|
||||
children: ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function CollapseTitle({
|
||||
children,
|
||||
className,
|
||||
}: CollapseTitleProps) {
|
||||
return (
|
||||
<span className={cn('min-w-0 truncate system-sm-semibold-uppercase text-text-secondary', className)}>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export function CollapseIndicator() {
|
||||
return (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="i-custom-vender-solid-general-arrow-down-round-fill size-4 rotate-270 cursor-pointer text-text-quaternary transition-transform group-hover/collapse:text-text-secondary group-data-panel-open/collapse:rotate-0 motion-reduce:transition-none"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
type CollapseContentProps = ComponentProps<typeof CollapsiblePanel>
|
||||
|
||||
export function CollapseContent({
|
||||
className,
|
||||
...props
|
||||
}: CollapseContentProps) {
|
||||
return (
|
||||
<CollapsiblePanel
|
||||
className={cn(className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
type FieldCollapseProps = {
|
||||
title: string
|
||||
children: ReactNode
|
||||
collapsed?: boolean
|
||||
onCollapse?: (collapsed: boolean) => void
|
||||
actions?: ReactNode
|
||||
}
|
||||
|
||||
export function FieldCollapse({
|
||||
title,
|
||||
children,
|
||||
collapsed,
|
||||
onCollapse,
|
||||
actions,
|
||||
}: FieldCollapseProps) {
|
||||
return (
|
||||
<div className="py-4">
|
||||
<Collapse collapsed={collapsed} onCollapse={onCollapse}>
|
||||
<CollapseHeader>
|
||||
<CollapseTrigger>
|
||||
<CollapseTitle>{title}</CollapseTitle>
|
||||
<CollapseIndicator />
|
||||
</CollapseTrigger>
|
||||
{actions != null && (
|
||||
<CollapseActions>
|
||||
{actions}
|
||||
</CollapseActions>
|
||||
)}
|
||||
</CollapseHeader>
|
||||
<CollapseContent>
|
||||
<div className="px-4">
|
||||
{children}
|
||||
</div>
|
||||
</CollapseContent>
|
||||
</Collapse>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -3,10 +3,17 @@ import type {
|
||||
CommonNodeType,
|
||||
Node,
|
||||
} from '@/app/components/workflow/types'
|
||||
import { useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Infotip } from '@/app/components/base/infotip'
|
||||
import Collapse from '../collapse'
|
||||
import {
|
||||
Collapse,
|
||||
CollapseActions,
|
||||
CollapseContent,
|
||||
CollapseHeader,
|
||||
CollapseIndicator,
|
||||
CollapseTitle,
|
||||
CollapseTrigger,
|
||||
} from '../collapse'
|
||||
import DefaultValue from './default-value'
|
||||
import ErrorHandleTypeSelector from './error-handle-type-selector'
|
||||
import FailBranchCard from './fail-branch-card'
|
||||
@ -17,6 +24,7 @@ import {
|
||||
import { ErrorHandleTypeEnum } from './types'
|
||||
|
||||
type ErrorHandleProps = Pick<Node, 'id' | 'data'>
|
||||
|
||||
const ErrorHandle = ({
|
||||
id,
|
||||
data,
|
||||
@ -30,64 +38,53 @@ const ErrorHandle = ({
|
||||
} = useErrorHandle(id, data)
|
||||
const { handleFormChange } = useDefaultValue(id)
|
||||
|
||||
const getHandleErrorHandleTypeChange = useCallback((data: CommonNodeType) => {
|
||||
return (value: ErrorHandleTypeEnum) => {
|
||||
handleErrorHandleTypeChange(value, data)
|
||||
}
|
||||
}, [handleErrorHandleTypeChange])
|
||||
const handleTypeChange = (value: ErrorHandleTypeEnum) => {
|
||||
handleErrorHandleTypeChange(value, data as CommonNodeType)
|
||||
}
|
||||
|
||||
const getHandleFormChange = useCallback((data: CommonNodeType) => {
|
||||
return (v: DefaultValueForm) => {
|
||||
handleFormChange(v, data)
|
||||
}
|
||||
}, [handleFormChange])
|
||||
const handleDefaultValueChange = (value: DefaultValueForm) => {
|
||||
handleFormChange(value, data as CommonNodeType)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="py-4">
|
||||
<Collapse
|
||||
disabled={!error_strategy}
|
||||
collapsed={collapsed}
|
||||
onCollapse={setCollapsed}
|
||||
hideCollapseIcon
|
||||
trigger={
|
||||
collapseIcon => (
|
||||
<div className="flex grow items-center justify-between pr-4">
|
||||
<div className="flex items-center">
|
||||
<div className="mr-0.5 system-sm-semibold-uppercase text-text-secondary">
|
||||
{t('nodes.common.errorHandle.title', { ns: 'workflow' })}
|
||||
</div>
|
||||
<Infotip aria-label={t('nodes.common.errorHandle.tip', { ns: 'workflow' })}>
|
||||
{t('nodes.common.errorHandle.tip', { ns: 'workflow' })}
|
||||
</Infotip>
|
||||
{collapseIcon}
|
||||
</div>
|
||||
<ErrorHandleTypeSelector
|
||||
value={error_strategy || ErrorHandleTypeEnum.none}
|
||||
onSelected={getHandleErrorHandleTypeChange(data)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
>
|
||||
<>
|
||||
{
|
||||
error_strategy === ErrorHandleTypeEnum.failBranch && !collapsed && (
|
||||
<FailBranchCard />
|
||||
)
|
||||
}
|
||||
{
|
||||
error_strategy === ErrorHandleTypeEnum.defaultValue && !collapsed && !!default_value?.length && (
|
||||
<DefaultValue
|
||||
forms={default_value}
|
||||
onFormChange={getHandleFormChange(data)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</>
|
||||
</Collapse>
|
||||
</div>
|
||||
</>
|
||||
<div className="py-4">
|
||||
<Collapse
|
||||
disabled={!error_strategy}
|
||||
collapsed={collapsed}
|
||||
onCollapse={setCollapsed}
|
||||
>
|
||||
<CollapseHeader>
|
||||
<CollapseTrigger>
|
||||
<CollapseTitle>
|
||||
{t('nodes.common.errorHandle.title', { ns: 'workflow' })}
|
||||
</CollapseTitle>
|
||||
{!!error_strategy && <CollapseIndicator />}
|
||||
</CollapseTrigger>
|
||||
<Infotip aria-label={t('nodes.common.errorHandle.tip', { ns: 'workflow' })}>
|
||||
{t('nodes.common.errorHandle.tip', { ns: 'workflow' })}
|
||||
</Infotip>
|
||||
<CollapseActions>
|
||||
<div className="pr-4">
|
||||
<ErrorHandleTypeSelector
|
||||
value={error_strategy || ErrorHandleTypeEnum.none}
|
||||
onSelected={handleTypeChange}
|
||||
/>
|
||||
</div>
|
||||
</CollapseActions>
|
||||
</CollapseHeader>
|
||||
<CollapseContent>
|
||||
{error_strategy === ErrorHandleTypeEnum.failBranch && !collapsed && (
|
||||
<FailBranchCard />
|
||||
)}
|
||||
{error_strategy === ErrorHandleTypeEnum.defaultValue && !collapsed && !!default_value?.length && (
|
||||
<DefaultValue
|
||||
forms={default_value}
|
||||
onFormChange={handleDefaultValueChange}
|
||||
/>
|
||||
)}
|
||||
</CollapseContent>
|
||||
</Collapse>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -26,7 +26,7 @@ const OutputVars: FC<Props> = ({
|
||||
return (
|
||||
<FieldCollapse
|
||||
title={title || t('nodes.common.outputVars', { ns: 'workflow' })}
|
||||
operations={operations}
|
||||
actions={operations}
|
||||
collapsed={collapsed}
|
||||
onCollapse={onCollapse}
|
||||
>
|
||||
|
||||
@ -7,7 +7,15 @@ import {
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Infotip } from '@/app/components/base/infotip'
|
||||
import ModelParameterModal from '@/app/components/header/account-setting/model-provider-page/model-parameter-modal'
|
||||
import Collapse from '@/app/components/workflow/nodes/_base/components/collapse'
|
||||
import {
|
||||
Collapse,
|
||||
CollapseActions,
|
||||
CollapseContent,
|
||||
CollapseHeader,
|
||||
CollapseIndicator,
|
||||
CollapseTitle,
|
||||
CollapseTrigger,
|
||||
} from '@/app/components/workflow/nodes/_base/components/collapse'
|
||||
import { MetadataFilteringModeEnum } from '@/app/components/workflow/nodes/knowledge-retrieval/types'
|
||||
import MetadataTrigger from '../metadata-trigger'
|
||||
import MetadataFilterSelector from './metadata-filter-selector'
|
||||
@ -16,6 +24,7 @@ type MetadataFilterProps = {
|
||||
metadataFilterMode?: MetadataFilteringModeEnum
|
||||
handleMetadataFilterModeChange: (mode: MetadataFilteringModeEnum) => void
|
||||
} & MetadataShape
|
||||
|
||||
const MetadataFilter = ({
|
||||
metadataFilterMode = MetadataFilteringModeEnum.disabled,
|
||||
handleMetadataFilterModeChange,
|
||||
@ -39,59 +48,54 @@ const MetadataFilter = ({
|
||||
disabled={metadataFilterMode === MetadataFilteringModeEnum.disabled || metadataFilterMode === MetadataFilteringModeEnum.manual}
|
||||
collapsed={collapsed}
|
||||
onCollapse={setCollapsed}
|
||||
hideCollapseIcon
|
||||
trigger={collapseIcon => (
|
||||
<div className="flex grow items-center justify-between pr-4">
|
||||
<div className="flex items-center">
|
||||
<div className="mr-0.5 system-sm-semibold-uppercase text-text-secondary">
|
||||
{t('nodes.knowledgeRetrieval.metadata.title', { ns: 'workflow' })}
|
||||
</div>
|
||||
<Infotip aria-label={t('nodes.knowledgeRetrieval.metadata.tip', { ns: 'workflow' })} popupClassName="w-[200px]">
|
||||
{t('nodes.knowledgeRetrieval.metadata.tip', { ns: 'workflow' })}
|
||||
</Infotip>
|
||||
{collapseIcon}
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
>
|
||||
<CollapseHeader>
|
||||
<CollapseTrigger>
|
||||
<CollapseTitle>
|
||||
{t('nodes.knowledgeRetrieval.metadata.title', { ns: 'workflow' })}
|
||||
</CollapseTitle>
|
||||
{metadataFilterMode === MetadataFilteringModeEnum.automatic && <CollapseIndicator />}
|
||||
</CollapseTrigger>
|
||||
<Infotip aria-label={t('nodes.knowledgeRetrieval.metadata.tip', { ns: 'workflow' })} popupClassName="w-[200px]">
|
||||
{t('nodes.knowledgeRetrieval.metadata.tip', { ns: 'workflow' })}
|
||||
</Infotip>
|
||||
<CollapseActions>
|
||||
<div className="flex items-center pr-4">
|
||||
<MetadataFilterSelector
|
||||
value={metadataFilterMode}
|
||||
onSelect={handleMetadataFilterModeChangeWrapped}
|
||||
/>
|
||||
{
|
||||
metadataFilterMode === MetadataFilteringModeEnum.manual && (
|
||||
<div className="ml-1">
|
||||
<MetadataTrigger {...restProps} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{metadataFilterMode === MetadataFilteringModeEnum.manual && (
|
||||
<div className="ml-1">
|
||||
<MetadataTrigger {...restProps} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<>
|
||||
{
|
||||
metadataFilterMode === MetadataFilteringModeEnum.automatic && (
|
||||
<>
|
||||
<div className="px-4 body-xs-regular text-text-tertiary">
|
||||
{t('nodes.knowledgeRetrieval.metadata.options.automatic.desc', { ns: 'workflow' })}
|
||||
</div>
|
||||
<div className="mt-1 px-4">
|
||||
<ModelParameterModal
|
||||
popupClassName="w-[387px]!"
|
||||
isInWorkflow
|
||||
isAdvancedMode={true}
|
||||
provider={metadataModelConfig?.provider || ''}
|
||||
completionParams={metadataModelConfig?.completion_params || { temperature: 0.7 }}
|
||||
modelId={metadataModelConfig?.name || ''}
|
||||
setModel={handleMetadataModelChange || noop}
|
||||
onCompletionParamsChange={handleMetadataCompletionParamsChange || noop}
|
||||
hideDebugWithMultipleModel
|
||||
debugWithMultipleModel={false}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
</>
|
||||
</CollapseActions>
|
||||
</CollapseHeader>
|
||||
<CollapseContent>
|
||||
{metadataFilterMode === MetadataFilteringModeEnum.automatic && (
|
||||
<>
|
||||
<div className="px-4 body-xs-regular text-text-tertiary">
|
||||
{t('nodes.knowledgeRetrieval.metadata.options.automatic.desc', { ns: 'workflow' })}
|
||||
</div>
|
||||
<div className="mt-1 px-4">
|
||||
<ModelParameterModal
|
||||
popupClassName="w-[387px]!"
|
||||
isInWorkflow
|
||||
isAdvancedMode={true}
|
||||
provider={metadataModelConfig?.provider || ''}
|
||||
completionParams={metadataModelConfig?.completion_params || { temperature: 0.7 }}
|
||||
modelId={metadataModelConfig?.name || ''}
|
||||
setModel={handleMetadataModelChange || noop}
|
||||
onCompletionParamsChange={handleMetadataCompletionParamsChange || noop}
|
||||
hideDebugWithMultipleModel
|
||||
debugWithMultipleModel={false}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CollapseContent>
|
||||
</Collapse>
|
||||
)
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user