mirror of
https://github.com/langgenius/dify.git
synced 2026-05-10 05:56:31 +08:00
feat(dify-ui): add Tabs/ToggleGroup (#35965)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
parent
1efd365b62
commit
861f73267c
@ -1051,14 +1051,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/base/form/components/field/variable-or-constant-input.tsx": {
|
||||
"no-console": {
|
||||
"count": 2
|
||||
},
|
||||
"ts/no-explicit-any": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"web/app/components/base/form/components/field/variable-selector.tsx": {
|
||||
"no-console": {
|
||||
"count": 1
|
||||
@ -2307,11 +2299,8 @@
|
||||
}
|
||||
},
|
||||
"web/app/components/develop/code.tsx": {
|
||||
"ts/no-empty-object-type": {
|
||||
"count": 1
|
||||
},
|
||||
"ts/no-explicit-any": {
|
||||
"count": 9
|
||||
"count": 7
|
||||
}
|
||||
},
|
||||
"web/app/components/develop/md.tsx": {
|
||||
|
||||
@ -77,6 +77,14 @@
|
||||
"types": "./src/switch/index.tsx",
|
||||
"import": "./src/switch/index.tsx"
|
||||
},
|
||||
"./tabs": {
|
||||
"types": "./src/tabs/index.tsx",
|
||||
"import": "./src/tabs/index.tsx"
|
||||
},
|
||||
"./toggle-group": {
|
||||
"types": "./src/toggle-group/index.tsx",
|
||||
"import": "./src/toggle-group/index.tsx"
|
||||
},
|
||||
"./toast": {
|
||||
"types": "./src/toast/index.tsx",
|
||||
"import": "./src/toast/index.tsx"
|
||||
|
||||
80
packages/dify-ui/src/tabs/__tests__/index.spec.tsx
Normal file
80
packages/dify-ui/src/tabs/__tests__/index.spec.tsx
Normal file
@ -0,0 +1,80 @@
|
||||
import { render } from 'vitest-browser-react'
|
||||
import {
|
||||
Tabs,
|
||||
TabsList,
|
||||
TabsPanel,
|
||||
TabsTab,
|
||||
} from '../index'
|
||||
|
||||
const asHTMLElement = (element: HTMLElement | SVGElement) => element as HTMLElement
|
||||
|
||||
describe('Tabs wrappers', () => {
|
||||
it('renders Base UI tabs with accessible roles', async () => {
|
||||
const screen = await render(
|
||||
<Tabs defaultValue="js">
|
||||
<TabsList>
|
||||
<TabsTab value="js">JavaScript</TabsTab>
|
||||
<TabsTab value="py">Python</TabsTab>
|
||||
</TabsList>
|
||||
<TabsPanel value="js">JS panel</TabsPanel>
|
||||
<TabsPanel value="py">Python panel</TabsPanel>
|
||||
</Tabs>,
|
||||
)
|
||||
|
||||
await expect.element(screen.getByRole('tablist')).toBeInTheDocument()
|
||||
await expect.element(screen.getByRole('tab', { name: 'JavaScript' })).toHaveAttribute('aria-selected', 'true')
|
||||
await expect.element(screen.getByRole('tab', { name: 'Python' })).toHaveAttribute('aria-selected', 'false')
|
||||
await expect.element(screen.getByText('JS panel')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps tabs styling minimal by default', async () => {
|
||||
const screen = await render(
|
||||
<Tabs defaultValue="first">
|
||||
<TabsList>
|
||||
<TabsTab value="first">First</TabsTab>
|
||||
<TabsTab value="second">Second</TabsTab>
|
||||
</TabsList>
|
||||
</Tabs>,
|
||||
)
|
||||
|
||||
await expect.element(screen.getByRole('tablist')).toHaveClass(
|
||||
'flex',
|
||||
)
|
||||
await expect.element(screen.getByRole('tab', { name: 'First' })).toHaveClass(
|
||||
'touch-manipulation',
|
||||
'focus-visible:outline-hidden',
|
||||
)
|
||||
})
|
||||
|
||||
it('calls onValueChange while leaving controlled value to the caller', async () => {
|
||||
const onValueChange = vi.fn()
|
||||
const screen = await render(
|
||||
<Tabs value="js" onValueChange={onValueChange}>
|
||||
<TabsList>
|
||||
<TabsTab value="js">JavaScript</TabsTab>
|
||||
<TabsTab value="py">Python</TabsTab>
|
||||
</TabsList>
|
||||
</Tabs>,
|
||||
)
|
||||
|
||||
asHTMLElement(screen.getByRole('tab', { name: 'Python' }).element()).click()
|
||||
|
||||
expect(onValueChange).toHaveBeenCalledWith('py', expect.anything())
|
||||
await expect.element(screen.getByRole('tab', { name: 'JavaScript' })).toHaveAttribute('aria-selected', 'true')
|
||||
})
|
||||
|
||||
it('forwards className to composable parts', async () => {
|
||||
const screen = await render(
|
||||
<Tabs defaultValue="first">
|
||||
<TabsList className="custom-list">
|
||||
<TabsTab value="first" className="custom-tab">First</TabsTab>
|
||||
</TabsList>
|
||||
<TabsPanel value="first" className="custom-panel">Panel</TabsPanel>
|
||||
</Tabs>,
|
||||
)
|
||||
|
||||
await expect.element(screen.getByRole('tablist')).toHaveClass('custom-list')
|
||||
await expect.element(screen.getByRole('tab', { name: 'First' })).toHaveClass('custom-tab')
|
||||
expect(screen.getByText('Panel').element()).toHaveClass('custom-panel')
|
||||
})
|
||||
})
|
||||
51
packages/dify-ui/src/tabs/index.stories.tsx
Normal file
51
packages/dify-ui/src/tabs/index.stories.tsx
Normal file
@ -0,0 +1,51 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite'
|
||||
import {
|
||||
Tabs,
|
||||
TabsList,
|
||||
TabsPanel,
|
||||
TabsTab,
|
||||
} from '.'
|
||||
|
||||
const meta = {
|
||||
title: 'Base/UI/Tabs',
|
||||
component: Tabs,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Composable tabs built on Base UI. Use this when a tab controls a corresponding tab panel.',
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
} satisfies Meta<typeof Tabs>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Basic: Story = {
|
||||
render: () => (
|
||||
<Tabs defaultValue="overview" className="w-96">
|
||||
<TabsList className="gap-4 border-b border-divider-subtle">
|
||||
<TabsTab
|
||||
value="overview"
|
||||
className="border-b border-transparent px-0 py-2 system-sm-medium text-text-tertiary data-active:border-text-accent data-active:text-text-primary"
|
||||
>
|
||||
Overview
|
||||
</TabsTab>
|
||||
<TabsTab
|
||||
value="activity"
|
||||
className="border-b border-transparent px-0 py-2 system-sm-medium text-text-tertiary data-active:border-text-accent data-active:text-text-primary"
|
||||
>
|
||||
Activity
|
||||
</TabsTab>
|
||||
</TabsList>
|
||||
<TabsPanel value="overview" className="py-3 system-sm-regular text-text-secondary">
|
||||
Overview panel
|
||||
</TabsPanel>
|
||||
<TabsPanel value="activity" className="py-3 system-sm-regular text-text-secondary">
|
||||
Activity panel
|
||||
</TabsPanel>
|
||||
</Tabs>
|
||||
),
|
||||
}
|
||||
59
packages/dify-ui/src/tabs/index.tsx
Normal file
59
packages/dify-ui/src/tabs/index.tsx
Normal file
@ -0,0 +1,59 @@
|
||||
'use client'
|
||||
|
||||
import type { Tabs as BaseTabsNS } from '@base-ui/react/tabs'
|
||||
import { Tabs as BaseTabs } from '@base-ui/react/tabs'
|
||||
import { cn } from '../cn'
|
||||
|
||||
export type TabsProps = BaseTabsNS.Root.Props
|
||||
|
||||
export const Tabs = BaseTabs.Root
|
||||
|
||||
export type TabsListProps = Omit<BaseTabsNS.List.Props, 'className'> & {
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function TabsList({
|
||||
className,
|
||||
...props
|
||||
}: TabsListProps) {
|
||||
return (
|
||||
<BaseTabs.List
|
||||
className={cn('flex', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export type TabsTabProps = Omit<BaseTabsNS.Tab.Props, 'className'> & {
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function TabsTab({
|
||||
className,
|
||||
...props
|
||||
}: TabsTabProps) {
|
||||
return (
|
||||
<BaseTabs.Tab
|
||||
className={cn('touch-manipulation focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-components-input-border-hover data-disabled:cursor-not-allowed data-disabled:text-text-disabled', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export type TabsPanelProps = Omit<BaseTabsNS.Panel.Props, 'className'> & {
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function TabsPanel({
|
||||
className,
|
||||
...props
|
||||
}: TabsPanelProps) {
|
||||
return (
|
||||
<BaseTabs.Panel
|
||||
className={className}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export const TabsIndicator = BaseTabs.Indicator
|
||||
74
packages/dify-ui/src/toggle-group/__tests__/index.spec.tsx
Normal file
74
packages/dify-ui/src/toggle-group/__tests__/index.spec.tsx
Normal file
@ -0,0 +1,74 @@
|
||||
import { render } from 'vitest-browser-react'
|
||||
import {
|
||||
ToggleGroup,
|
||||
ToggleGroupDivider,
|
||||
ToggleGroupItem,
|
||||
} from '../index'
|
||||
|
||||
const asHTMLElement = (element: HTMLElement | SVGElement) => element as HTMLElement
|
||||
|
||||
describe('ToggleGroup wrappers', () => {
|
||||
it('renders a segmented control with Base UI pressed state', async () => {
|
||||
const screen = await render(
|
||||
<ToggleGroup defaultValue={['one']} aria-label="View">
|
||||
<ToggleGroupItem value="one">One</ToggleGroupItem>
|
||||
<ToggleGroupItem value="two">Two</ToggleGroupItem>
|
||||
</ToggleGroup>,
|
||||
)
|
||||
|
||||
await expect.element(screen.getByRole('group')).toHaveClass(
|
||||
'bg-components-segmented-control-bg-normal',
|
||||
'p-0.5',
|
||||
'rounded-[10px]',
|
||||
)
|
||||
await expect.element(screen.getByRole('button', { name: 'One' })).toHaveAttribute('aria-pressed', 'true')
|
||||
await expect.element(screen.getByRole('button', { name: 'One' })).toHaveClass(
|
||||
'data-pressed:bg-components-segmented-control-item-active-bg',
|
||||
'data-pressed:text-text-accent-light-mode-only',
|
||||
)
|
||||
})
|
||||
|
||||
it('uses single selection by default', async () => {
|
||||
const screen = await render(
|
||||
<ToggleGroup defaultValue={['one']} aria-label="View">
|
||||
<ToggleGroupItem value="one">One</ToggleGroupItem>
|
||||
<ToggleGroupItem value="two">Two</ToggleGroupItem>
|
||||
</ToggleGroup>,
|
||||
)
|
||||
|
||||
asHTMLElement(screen.getByRole('button', { name: 'Two' }).element()).click()
|
||||
|
||||
await expect.element(screen.getByRole('button', { name: 'One' })).toHaveAttribute('aria-pressed', 'false')
|
||||
await expect.element(screen.getByRole('button', { name: 'Two' })).toHaveAttribute('aria-pressed', 'true')
|
||||
})
|
||||
|
||||
it('calls onValueChange while leaving controlled value to the caller', async () => {
|
||||
const onValueChange = vi.fn()
|
||||
const screen = await render(
|
||||
<ToggleGroup value={['one']} onValueChange={onValueChange} aria-label="View">
|
||||
<ToggleGroupItem value="one">One</ToggleGroupItem>
|
||||
<ToggleGroupItem value="two">Two</ToggleGroupItem>
|
||||
</ToggleGroup>,
|
||||
)
|
||||
|
||||
asHTMLElement(screen.getByRole('button', { name: 'Two' }).element()).click()
|
||||
|
||||
expect(onValueChange).toHaveBeenCalledWith(['two'], expect.anything())
|
||||
await expect.element(screen.getByRole('button', { name: 'One' })).toHaveAttribute('aria-pressed', 'true')
|
||||
})
|
||||
|
||||
it('forwards disabled and className to composable parts', async () => {
|
||||
const screen = await render(
|
||||
<ToggleGroup defaultValue={['one']} aria-label="View" className="custom-group">
|
||||
<ToggleGroupItem value="one" className="custom-item">One</ToggleGroupItem>
|
||||
<ToggleGroupDivider className="custom-divider" data-testid="divider" />
|
||||
<ToggleGroupItem value="two" disabled>Two</ToggleGroupItem>
|
||||
</ToggleGroup>,
|
||||
)
|
||||
|
||||
await expect.element(screen.getByRole('group')).toHaveClass('custom-group')
|
||||
await expect.element(screen.getByRole('button', { name: 'One' })).toHaveClass('custom-item')
|
||||
await expect.element(screen.getByRole('button', { name: 'Two' })).toBeDisabled()
|
||||
await expect.element(screen.getByTestId('divider')).toHaveClass('custom-divider')
|
||||
})
|
||||
})
|
||||
177
packages/dify-ui/src/toggle-group/index.stories.tsx
Normal file
177
packages/dify-ui/src/toggle-group/index.stories.tsx
Normal file
@ -0,0 +1,177 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite'
|
||||
import type { ReactNode } from 'react'
|
||||
import {
|
||||
ToggleGroup,
|
||||
ToggleGroupDivider,
|
||||
ToggleGroupItem,
|
||||
} from '.'
|
||||
|
||||
const meta = {
|
||||
title: 'Base/UI/ToggleGroup',
|
||||
component: ToggleGroup,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Segmented control built on Base UI ToggleGroup and Toggle. Use this for mode, filter, and view selection that does not need tabpanel semantics.',
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
} satisfies Meta<typeof ToggleGroup>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
type SegmentedControlProps = {
|
||||
defaultValue: string
|
||||
values: string[]
|
||||
iconOnly?: boolean
|
||||
noPadding?: boolean
|
||||
}
|
||||
|
||||
const Icon = () => (
|
||||
<i className="i-ri-information-line size-4 shrink-0" aria-hidden="true" />
|
||||
)
|
||||
|
||||
const Item = () => (
|
||||
<>
|
||||
<Icon />
|
||||
<span className="px-0.5">Item</span>
|
||||
</>
|
||||
)
|
||||
|
||||
function SegmentedControl({
|
||||
defaultValue,
|
||||
values,
|
||||
iconOnly = false,
|
||||
noPadding = false,
|
||||
}: SegmentedControlProps) {
|
||||
return (
|
||||
<ToggleGroup
|
||||
defaultValue={[defaultValue]}
|
||||
aria-label="Segmented control"
|
||||
className={noPadding ? 'rounded-lg border-[0.5px] border-divider-subtle p-0' : undefined}
|
||||
>
|
||||
{values.map((itemValue, index) => (
|
||||
<span key={itemValue} className="relative flex items-center">
|
||||
<ToggleGroupItem
|
||||
value={itemValue}
|
||||
aria-label={iconOnly ? `Item ${index + 1}` : undefined}
|
||||
>
|
||||
<Icon />
|
||||
{!iconOnly && (
|
||||
<span className="px-0.5">Item</span>
|
||||
)}
|
||||
</ToggleGroupItem>
|
||||
{index === 1 && (
|
||||
<span className="pointer-events-none absolute top-0 -right-px flex h-full items-center" aria-hidden="true">
|
||||
<ToggleGroupDivider />
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</ToggleGroup>
|
||||
)
|
||||
}
|
||||
|
||||
function SpecColumn() {
|
||||
const values = ['one', 'two', 'three']
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-6">
|
||||
<SegmentedControl defaultValue="one" values={values} />
|
||||
<SegmentedControl defaultValue="one" values={values} iconOnly />
|
||||
<SegmentedControl defaultValue="one" values={values} noPadding />
|
||||
<SegmentedControl defaultValue="one" values={values} iconOnly noPadding />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SpecPanel({
|
||||
className,
|
||||
children,
|
||||
}: {
|
||||
className?: string
|
||||
children: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className="flex min-h-105 items-center justify-center">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const DesignSpec: Story = {
|
||||
render: () => (
|
||||
<div className="overflow-hidden rounded-3xl bg-components-panel-bg-alt p-4">
|
||||
<SpecPanel className="w-120 overflow-hidden rounded-2xl bg-components-chart-bg">
|
||||
<SpecColumn />
|
||||
</SpecPanel>
|
||||
</div>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Figma node 2473:9851: segmented control examples with text+icon and icon-only rows, with and without outer padding.',
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export const DataAttributeStates: Story = {
|
||||
render: () => (
|
||||
<div className="flex flex-col gap-5">
|
||||
<ToggleGroup defaultValue={['active']} aria-label="Basic states">
|
||||
<ToggleGroupItem value="default">
|
||||
<Item />
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="active">
|
||||
<Item />
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="disabled" disabled>
|
||||
<Item />
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
|
||||
<ToggleGroup defaultValue={['accent-light']} aria-label="Active states">
|
||||
<ToggleGroupItem value="accent-light">
|
||||
<Item />
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem
|
||||
value="neutral"
|
||||
className="data-pressed:text-text-primary"
|
||||
>
|
||||
<Item />
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem
|
||||
value="accent"
|
||||
className="data-pressed:border-components-segmented-control-item-active-accent-border data-pressed:bg-components-segmented-control-item-active-accent-bg data-pressed:text-text-accent"
|
||||
>
|
||||
<Item />
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
|
||||
<ToggleGroup defaultValue={['one', 'three']} multiple aria-label="Multiple selection">
|
||||
<ToggleGroupItem value="one">
|
||||
<Item />
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="two">
|
||||
<Item />
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="three">
|
||||
<Item />
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: '`ToggleGroupItem` gets `data-pressed` and `data-disabled` from Base UI. Accent, neutral, and multiple-selection examples are composed through props and className.',
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
58
packages/dify-ui/src/toggle-group/index.tsx
Normal file
58
packages/dify-ui/src/toggle-group/index.tsx
Normal file
@ -0,0 +1,58 @@
|
||||
'use client'
|
||||
|
||||
import type { Toggle as BaseToggleNS } from '@base-ui/react/toggle'
|
||||
import type { ToggleGroup as BaseToggleGroupNS } from '@base-ui/react/toggle-group'
|
||||
import type { HTMLAttributes } from 'react'
|
||||
import { Toggle as BaseToggle } from '@base-ui/react/toggle'
|
||||
import { ToggleGroup as BaseToggleGroup } from '@base-ui/react/toggle-group'
|
||||
import { cn } from '../cn'
|
||||
|
||||
export type ToggleGroupProps<Value extends string = string> = Omit<BaseToggleGroupNS.Props<Value>, 'className'> & {
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function ToggleGroup<Value extends string = string>({
|
||||
className,
|
||||
...props
|
||||
}: ToggleGroupProps<Value>) {
|
||||
return (
|
||||
<BaseToggleGroup
|
||||
className={cn('inline-flex items-center gap-px rounded-[10px] bg-components-segmented-control-bg-normal p-0.5', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export type ToggleGroupItemProps<Value extends string = string> = Omit<BaseToggleNS.Props<Value>, 'className'> & {
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function ToggleGroupItem<Value extends string = string>({
|
||||
className,
|
||||
...props
|
||||
}: ToggleGroupItemProps<Value>) {
|
||||
return (
|
||||
<BaseToggle
|
||||
className={cn('relative flex h-7 min-w-0 touch-manipulation items-center justify-center gap-0.5 overflow-hidden whitespace-nowrap rounded-lg border-[0.5px] border-transparent px-2 py-1 system-sm-medium text-text-secondary transition-colors duration-150 hover:bg-state-base-hover hover:text-text-secondary focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-components-input-border-hover data-pressed:border-components-segmented-control-item-active-border data-pressed:bg-components-segmented-control-item-active-bg data-pressed:text-text-accent-light-mode-only data-pressed:shadow-xs data-pressed:shadow-shadow-shadow-3 data-disabled:cursor-not-allowed data-disabled:bg-transparent data-disabled:text-text-disabled data-disabled:shadow-none data-disabled:hover:bg-transparent data-disabled:hover:text-text-disabled motion-reduce:transition-none', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export type ToggleGroupDividerProps = Omit<HTMLAttributes<HTMLSpanElement>, 'className'> & {
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function ToggleGroupDivider({
|
||||
className,
|
||||
...props
|
||||
}: ToggleGroupDividerProps) {
|
||||
return (
|
||||
<span
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className={cn('h-3.5 w-px shrink-0 bg-divider-regular', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@ -1,59 +0,0 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import VariableOrConstantInputField from '../variable-or-constant-input'
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/_base/components/variable/var-reference-picker', () => ({
|
||||
default: ({ onChange }: { onChange?: () => void }) => (
|
||||
<button onClick={() => onChange?.()}>
|
||||
Variable picker
|
||||
</button>
|
||||
),
|
||||
}))
|
||||
|
||||
describe('VariableOrConstantInputField', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should render variable picker by default', () => {
|
||||
render(<VariableOrConstantInputField label="Input source" />)
|
||||
expect(screen.getByRole('button', { name: 'Variable picker' }))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should switch to constant input when users choose constant', () => {
|
||||
render(<VariableOrConstantInputField label="Input source" />)
|
||||
fireEvent.click(screen.getAllByRole('button')[1]!)
|
||||
expect(screen.queryByRole('button', { name: 'Variable picker' })).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('textbox'))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show typed constant value in the input', () => {
|
||||
render(<VariableOrConstantInputField label="Input source" />)
|
||||
fireEvent.click(screen.getAllByRole('button')[1]!)
|
||||
const textbox = screen.getByRole('textbox')
|
||||
fireEvent.change(textbox, { target: { value: 'constant-value' } })
|
||||
expect(textbox)!.toHaveValue('constant-value')
|
||||
})
|
||||
|
||||
it('should switch back to variable mode when users choose variable again', () => {
|
||||
render(<VariableOrConstantInputField label="Input source" />)
|
||||
const modeButtons = screen.getAllByRole('button')
|
||||
|
||||
fireEvent.click(modeButtons[1]!)
|
||||
expect(screen.getByRole('textbox'))!.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(modeButtons[0]!)
|
||||
expect(screen.getByRole('button', { name: 'Variable picker' }))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle variable picker changes', () => {
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => { })
|
||||
try {
|
||||
render(<VariableOrConstantInputField label="Input source" />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Variable picker' }))
|
||||
expect(logSpy).toHaveBeenCalledWith('Variable value changed')
|
||||
}
|
||||
finally {
|
||||
logSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
})
|
||||
@ -1,86 +0,0 @@
|
||||
import type { ChangeEvent } from 'react'
|
||||
import type { LabelProps } from '../label'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { RiEditLine } from '@remixicon/react'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { VariableX } from '@/app/components/base/icons/src/vender/workflow'
|
||||
import Input from '@/app/components/base/input'
|
||||
import SegmentedControl from '@/app/components/base/segmented-control'
|
||||
import VarReferencePicker from '@/app/components/workflow/nodes/_base/components/variable/var-reference-picker'
|
||||
import Label from '../label'
|
||||
|
||||
type VariableOrConstantInputFieldProps = {
|
||||
label: string
|
||||
labelOptions?: Omit<LabelProps, 'htmlFor' | 'label'>
|
||||
className?: string
|
||||
}
|
||||
|
||||
const VariableOrConstantInputField = ({
|
||||
className,
|
||||
label,
|
||||
labelOptions,
|
||||
}: VariableOrConstantInputFieldProps) => {
|
||||
const [variableType, setVariableType] = useState('variable')
|
||||
|
||||
const options = [
|
||||
{
|
||||
Icon: VariableX,
|
||||
value: 'variable',
|
||||
},
|
||||
{
|
||||
Icon: RiEditLine,
|
||||
value: 'constant',
|
||||
},
|
||||
]
|
||||
|
||||
const handleVariableOrConstantChange = useCallback((value: string) => {
|
||||
setVariableType(value)
|
||||
}, [setVariableType])
|
||||
|
||||
const handleVariableValueChange = () => {
|
||||
console.log('Variable value changed')
|
||||
}
|
||||
|
||||
const handleConstantValueChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
console.log('Constant value changed:', e.target.value)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-col gap-y-0.5', className)}>
|
||||
<Label
|
||||
htmlFor="variable-or-constant"
|
||||
label={label}
|
||||
{...(labelOptions ?? {})}
|
||||
/>
|
||||
<div className="flex items-center">
|
||||
<SegmentedControl
|
||||
className="mr-1 shrink-0"
|
||||
value={variableType}
|
||||
onChange={handleVariableOrConstantChange as any}
|
||||
options={options as any}
|
||||
/>
|
||||
{
|
||||
variableType === 'variable' && (
|
||||
<VarReferencePicker
|
||||
className="grow"
|
||||
nodeId=""
|
||||
readonly={false}
|
||||
value={[]}
|
||||
onChange={handleVariableValueChange}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
variableType === 'constant' && (
|
||||
<Input
|
||||
className="ml-1"
|
||||
onChange={handleConstantValueChange}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default VariableOrConstantInputField
|
||||
@ -1,114 +0,0 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import SegmentedControl from '../index'
|
||||
|
||||
describe('SegmentedControl', () => {
|
||||
const options = [
|
||||
{ value: 'option1', text: 'Option 1' },
|
||||
{ value: 'option2', text: 'Option 2' },
|
||||
{ value: 'option3', text: 'Option 3' },
|
||||
]
|
||||
|
||||
const optionsWithDisabled = [
|
||||
{ value: 'option1', text: 'Option 1' },
|
||||
{ value: 'option2', text: 'Option 2', disabled: true },
|
||||
{ value: 'option3', text: 'Option 3' },
|
||||
]
|
||||
|
||||
const onSelectMock = vi.fn((value: string | number | symbol) => value)
|
||||
|
||||
beforeEach(() => {
|
||||
onSelectMock.mockClear()
|
||||
})
|
||||
|
||||
it('renders all options correctly', () => {
|
||||
render(<SegmentedControl options={options} value="option1" onChange={onSelectMock} />)
|
||||
|
||||
options.forEach((option) => {
|
||||
expect(screen.getByText(option.text)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
const divider = screen.getByTestId('segmented-control-divider-1')
|
||||
expect(divider).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders with custom activeClassName when provided', () => {
|
||||
render(
|
||||
<SegmentedControl
|
||||
options={options}
|
||||
value="option1"
|
||||
onChange={onSelectMock}
|
||||
activeClassName="custom-active-class"
|
||||
/>,
|
||||
)
|
||||
|
||||
const selectedOption = screen.getByText('Option 1').closest('button')
|
||||
expect(selectedOption).toHaveClass('custom-active-class')
|
||||
})
|
||||
|
||||
it('highlights the selected option', () => {
|
||||
render(<SegmentedControl options={options} value="option2" onChange={onSelectMock} />)
|
||||
|
||||
const selectedOption = screen.getByText('Option 2').closest('button')
|
||||
expect(selectedOption).toHaveClass('sc-active')
|
||||
})
|
||||
|
||||
it('calls onChange when an option is clicked', () => {
|
||||
render(<SegmentedControl options={options} value="option1" onChange={onSelectMock} />)
|
||||
|
||||
fireEvent.click(screen.getByText('Option 3'))
|
||||
expect(onSelectMock).toHaveBeenCalledWith('option3')
|
||||
})
|
||||
|
||||
it('does not call onChange when clicking the already selected option', () => {
|
||||
render(<SegmentedControl options={options} value="option1" onChange={onSelectMock} />)
|
||||
|
||||
fireEvent.click(screen.getByText('Option 1'))
|
||||
expect(onSelectMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('handles disabled state correctly', () => {
|
||||
render(<SegmentedControl options={optionsWithDisabled} value="option1" onChange={onSelectMock} />)
|
||||
|
||||
fireEvent.click(screen.getByText('Option 2'))
|
||||
expect(onSelectMock).not.toHaveBeenCalled()
|
||||
|
||||
const optionElement = screen.getByText('Option 2').closest('button')
|
||||
expect(optionElement).toHaveAttribute('disabled')
|
||||
expect(optionElement).toHaveClass('sc-disabled')
|
||||
|
||||
fireEvent.click(screen.getByText('Option 3'))
|
||||
expect(onSelectMock).toHaveBeenCalledWith('option3')
|
||||
})
|
||||
|
||||
it('renders with custom className when provided', () => {
|
||||
const customClass = 'my-custom-class'
|
||||
render(
|
||||
<SegmentedControl
|
||||
options={options}
|
||||
value="option1"
|
||||
onChange={onSelectMock}
|
||||
className={customClass}
|
||||
/>,
|
||||
)
|
||||
|
||||
const selectedOption = screen.getByText('Option 1').closest('button')?.closest('div')
|
||||
expect(selectedOption).toHaveClass(customClass)
|
||||
})
|
||||
|
||||
it('renders Icon when provided', () => {
|
||||
const MockIcon = () => <svg data-testid="mock-icon" />
|
||||
const optionsWithIcon = [
|
||||
{ value: 'option1', text: 'Option 1', Icon: MockIcon },
|
||||
]
|
||||
render(<SegmentedControl options={optionsWithIcon} value="option1" onChange={onSelectMock} />)
|
||||
expect(screen.getByTestId('mock-icon')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders count when provided and size is large', () => {
|
||||
const optionsWithCount = [
|
||||
{ value: 'option1', text: 'Option 1', count: 42 },
|
||||
]
|
||||
render(<SegmentedControl options={optionsWithCount} value="option1" onChange={onSelectMock} size="large" />)
|
||||
expect(screen.getByText('42')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@ -1,109 +0,0 @@
|
||||
@utility segmented-control {
|
||||
@apply flex items-center bg-components-segmented-control-bg-normal gap-x-px;
|
||||
}
|
||||
|
||||
@utility segmented-control-regular {
|
||||
@apply rounded-lg;
|
||||
|
||||
&.sc-padding {
|
||||
@apply p-0.5;
|
||||
}
|
||||
}
|
||||
|
||||
@utility segmented-control-large {
|
||||
@apply rounded-lg;
|
||||
|
||||
&.sc-padding {
|
||||
@apply p-0.5;
|
||||
}
|
||||
}
|
||||
|
||||
@utility sc-padding {
|
||||
&.segmented-control-large {
|
||||
@apply p-0.5;
|
||||
}
|
||||
|
||||
& .segmented-control-regular {
|
||||
@apply p-0.5;
|
||||
}
|
||||
|
||||
&.segmented-control-small {
|
||||
@apply p-px;
|
||||
}
|
||||
}
|
||||
|
||||
@utility segmented-control-small {
|
||||
@apply rounded-md;
|
||||
|
||||
&.sc-padding {
|
||||
@apply p-px;
|
||||
}
|
||||
}
|
||||
|
||||
@utility sc-no-padding {
|
||||
@apply border-[0.5px] border-divider-subtle;
|
||||
}
|
||||
|
||||
@utility segmented-control-item {
|
||||
@apply flex items-center justify-center relative border-[0.5px] border-transparent;
|
||||
}
|
||||
|
||||
@utility segmented-control-item-regular {
|
||||
@apply px-2 h-7 gap-x-0.5 rounded-lg;
|
||||
}
|
||||
|
||||
@utility segmented-control-item-small {
|
||||
@apply p-px h-[22px] rounded-md;
|
||||
}
|
||||
|
||||
@utility segmented-control-item-large {
|
||||
@apply px-2.5 h-8 gap-x-0.5 rounded-lg;
|
||||
}
|
||||
|
||||
@utility segmented-control-item-disabled {
|
||||
@apply cursor-not-allowed text-text-disabled;
|
||||
}
|
||||
|
||||
@utility sc-default {
|
||||
@apply hover:bg-state-base-hover text-text-tertiary hover:text-text-secondary;
|
||||
}
|
||||
|
||||
@utility sc-active {
|
||||
@apply border-components-segmented-control-item-active-border bg-components-segmented-control-item-active-bg shadow-xs shadow-shadow-shadow-3 text-text-secondary;
|
||||
|
||||
&.sc-accent {
|
||||
@apply text-text-accent;
|
||||
}
|
||||
|
||||
&.sc-accent-light {
|
||||
@apply text-text-accent-light-mode-only;
|
||||
}
|
||||
}
|
||||
|
||||
@utility sc-disabled {
|
||||
@apply cursor-not-allowed text-text-disabled hover:text-text-disabled bg-transparent hover:bg-transparent;
|
||||
}
|
||||
|
||||
@utility sc-accent {
|
||||
&.sc-active {
|
||||
@apply text-text-accent;
|
||||
}
|
||||
}
|
||||
|
||||
@utility sc-accent-light {
|
||||
&.sc-active {
|
||||
@apply text-text-accent-light-mode-only;
|
||||
}
|
||||
}
|
||||
|
||||
@utility sc-item-text-regular {
|
||||
@apply p-0.5;
|
||||
}
|
||||
|
||||
@utility sc-item-text-small {
|
||||
@apply p-0.5 pr-1;
|
||||
}
|
||||
|
||||
@utility sc-item-text-large {
|
||||
@apply px-0.5;
|
||||
}
|
||||
@ -1,94 +0,0 @@
|
||||
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
|
||||
import { RiLineChartLine, RiListCheck2, RiRobot2Line } from '@remixicon/react'
|
||||
import { useState } from 'react'
|
||||
import { SegmentedControl } from '.'
|
||||
|
||||
const SEGMENTS = [
|
||||
{ value: 'overview', text: 'Overview', Icon: RiLineChartLine },
|
||||
{ value: 'tasks', text: 'Tasks', Icon: RiListCheck2, count: 8 },
|
||||
{ value: 'agents', text: 'Agents', Icon: RiRobot2Line },
|
||||
]
|
||||
|
||||
const SegmentedControlDemo = ({
|
||||
initialValue = 'overview',
|
||||
size = 'regular',
|
||||
padding = 'with',
|
||||
activeState = 'default',
|
||||
}: {
|
||||
initialValue?: string
|
||||
size?: 'regular' | 'small' | 'large'
|
||||
padding?: 'none' | 'with'
|
||||
activeState?: 'default' | 'accent' | 'accentLight'
|
||||
}) => {
|
||||
const [value, setValue] = useState(initialValue)
|
||||
|
||||
return (
|
||||
<div className="flex w-full max-w-lg flex-col gap-4 rounded-2xl border border-divider-subtle bg-components-panel-bg p-6">
|
||||
<div className="flex items-center justify-between text-xs tracking-[0.18em] text-text-tertiary uppercase">
|
||||
<span>Segmented control</span>
|
||||
<code className="rounded-md bg-background-default px-2 py-1 text-[11px] text-text-tertiary">
|
||||
value="
|
||||
{value}
|
||||
"
|
||||
</code>
|
||||
</div>
|
||||
<SegmentedControl
|
||||
options={SEGMENTS}
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
size={size}
|
||||
padding={padding}
|
||||
activeState={activeState}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const meta = {
|
||||
title: 'Base/Data Entry/SegmentedControl',
|
||||
component: SegmentedControlDemo,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Multi-tab segmented control with optional icons and badge counts. Adjust sizing and accent states via controls.',
|
||||
},
|
||||
},
|
||||
},
|
||||
argTypes: {
|
||||
initialValue: {
|
||||
control: 'radio',
|
||||
options: SEGMENTS.map(segment => segment.value),
|
||||
},
|
||||
size: {
|
||||
control: 'inline-radio',
|
||||
options: ['small', 'regular', 'large'],
|
||||
},
|
||||
padding: {
|
||||
control: 'inline-radio',
|
||||
options: ['none', 'with'],
|
||||
},
|
||||
activeState: {
|
||||
control: 'inline-radio',
|
||||
options: ['default', 'accent', 'accentLight'],
|
||||
},
|
||||
},
|
||||
args: {
|
||||
initialValue: 'overview',
|
||||
size: 'regular',
|
||||
padding: 'with',
|
||||
activeState: 'default',
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
} satisfies Meta<typeof SegmentedControlDemo>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Playground: Story = {}
|
||||
|
||||
export const AccentState: Story = {
|
||||
args: {
|
||||
activeState: 'accent',
|
||||
},
|
||||
}
|
||||
@ -1,151 +0,0 @@
|
||||
import type { RemixiconComponentType } from '@remixicon/react'
|
||||
import type { VariantProps } from 'class-variance-authority'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { cva } from 'class-variance-authority'
|
||||
import * as React from 'react'
|
||||
import Divider from '../divider'
|
||||
|
||||
type SegmentedControlOption<T> = {
|
||||
value: T
|
||||
text?: string
|
||||
Icon?: RemixiconComponentType
|
||||
count?: number
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
type SegmentedControlProps<T extends string | number | symbol> = {
|
||||
options: SegmentedControlOption<T>[]
|
||||
value: T
|
||||
onChange: (value: T) => void
|
||||
className?: string
|
||||
activeClassName?: string
|
||||
btnClassName?: string
|
||||
}
|
||||
|
||||
const SegmentedControlVariants = cva(
|
||||
'segmented-control',
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
regular: 'segmented-control-regular',
|
||||
small: 'segmented-control-small',
|
||||
large: 'segmented-control-large',
|
||||
},
|
||||
padding: {
|
||||
none: 'sc-no-padding',
|
||||
with: 'sc-padding',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: 'regular',
|
||||
padding: 'with',
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
const SegmentedControlItemVariants = cva(
|
||||
'segmented-control-item disabled:segmented-control-item-disabled',
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
regular: ['segmented-control-item-regular', 'system-sm-medium'],
|
||||
small: ['segmented-control-item-small', 'system-xs-medium'],
|
||||
large: ['segmented-control-item-large', 'system-md-semibold'],
|
||||
},
|
||||
activeState: {
|
||||
default: '',
|
||||
accent: 'sc-accent',
|
||||
accentLight: 'sc-accent-light',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: 'regular',
|
||||
activeState: 'default',
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
const ItemTextWrapperVariants = cva(
|
||||
'',
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
regular: 'sc-item-text-regular',
|
||||
small: 'sc-item-text-small',
|
||||
large: 'sc-item-text-large',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: 'regular',
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
export const SegmentedControl = <T extends string | number | symbol>({
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
className,
|
||||
size,
|
||||
padding,
|
||||
activeState,
|
||||
activeClassName,
|
||||
btnClassName,
|
||||
}: SegmentedControlProps<T>
|
||||
& VariantProps<typeof SegmentedControlVariants>
|
||||
& VariantProps<typeof SegmentedControlItemVariants>
|
||||
& VariantProps<typeof ItemTextWrapperVariants>) => {
|
||||
const selectedOptionIndex = options.findIndex(option => option.value === value)
|
||||
|
||||
return (
|
||||
<div className={cn(
|
||||
SegmentedControlVariants({ size, padding }),
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{options.map((option, index) => {
|
||||
const { Icon, text, count, disabled } = option
|
||||
const isSelected = index === selectedOptionIndex
|
||||
const isNextSelected = index === selectedOptionIndex - 1
|
||||
const isLast = index === options.length - 1
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={String(option.value)}
|
||||
className={cn(
|
||||
isSelected ? 'sc-active' : 'sc-default',
|
||||
SegmentedControlItemVariants({ size, activeState: isSelected ? activeState : 'default' }),
|
||||
isSelected && activeClassName,
|
||||
disabled && 'sc-disabled',
|
||||
btnClassName,
|
||||
)}
|
||||
onClick={() => {
|
||||
if (!isSelected)
|
||||
onChange(option.value)
|
||||
}}
|
||||
disabled={disabled}
|
||||
>
|
||||
{Icon && <Icon className="size-4 shrink-0" />}
|
||||
{text && (
|
||||
<div className={cn('inline-flex items-center gap-x-1', ItemTextWrapperVariants({ size }))}>
|
||||
<span>{text}</span>
|
||||
{!!(count && size === 'large') && (
|
||||
<div className="inline-flex h-[18px] min-w-[18px] items-center justify-center rounded-[5px] border border-divider-deep bg-components-badge-bg-dimm px-[5px] system-2xs-medium-uppercase text-text-tertiary">
|
||||
{count}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!isLast && !isSelected && !isNextSelected && (
|
||||
<div data-testid={`segmented-control-divider-${index}`} className="absolute top-0 -right-px flex h-full items-center">
|
||||
<Divider type="vertical" className="mx-0 h-3.5" />
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(SegmentedControl)
|
||||
@ -139,6 +139,7 @@ describe('code.tsx components', () => {
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('second content')).toBeInTheDocument()
|
||||
})
|
||||
expect(tab2).toHaveAttribute('aria-selected', 'true')
|
||||
})
|
||||
|
||||
it('should use "Code" as default title when title not provided', () => {
|
||||
@ -329,7 +330,8 @@ describe('code.tsx components', () => {
|
||||
<pre><code>fallback</code></pre>
|
||||
</CodeGroup>,
|
||||
)
|
||||
expect(screen.getByRole('tablist')).toBeInTheDocument()
|
||||
expect(screen.getByRole('tablist')).toHaveClass('-mb-px', 'gap-4', 'bg-transparent')
|
||||
expect(screen.getByRole('tab', { name: 'cURL' })).toHaveClass('data-active:text-emerald-400')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -1,7 +1,12 @@
|
||||
'use client'
|
||||
import type { PropsWithChildren, ReactElement, ReactNode } from 'react'
|
||||
import { Tab, TabGroup, TabList, TabPanel, TabPanels } from '@headlessui/react'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import {
|
||||
Tabs,
|
||||
TabsList,
|
||||
TabsPanel,
|
||||
TabsTab,
|
||||
} from '@langgenius/dify-ui/tabs'
|
||||
import {
|
||||
Children,
|
||||
createContext,
|
||||
@ -103,6 +108,11 @@ type CodeExample = {
|
||||
code: string
|
||||
}
|
||||
|
||||
type CodeTab = {
|
||||
title: string
|
||||
value: string
|
||||
}
|
||||
|
||||
type ICodePanelProps = {
|
||||
children?: React.ReactNode
|
||||
tag?: string
|
||||
@ -142,12 +152,11 @@ function CodePanel({ tag, label, children, targetCode }: ICodePanelProps) {
|
||||
|
||||
type CodeGroupHeaderProps = {
|
||||
title?: string
|
||||
tabTitles?: string[]
|
||||
selectedIndex?: number
|
||||
tabs?: CodeTab[]
|
||||
}
|
||||
|
||||
function CodeGroupHeader({ title, tabTitles, selectedIndex }: CodeGroupHeaderProps) {
|
||||
const hasTabs = (tabTitles?.length ?? 0) > 1
|
||||
function CodeGroupHeader({ title, tabs }: CodeGroupHeaderProps) {
|
||||
const hasTabs = (tabs?.length ?? 0) > 1
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[calc(--spacing(12)+1px)] flex-wrap items-start gap-x-4 border-b border-zinc-700 bg-zinc-800 px-4 dark:border-zinc-800 dark:bg-transparent">
|
||||
@ -157,18 +166,19 @@ function CodeGroupHeader({ title, tabTitles, selectedIndex }: CodeGroupHeaderPro
|
||||
</h3>
|
||||
)}
|
||||
{hasTabs && (
|
||||
<TabList className="-mb-px flex gap-4 text-xs font-medium">
|
||||
{tabTitles!.map((tabTitle, tabIndex) => (
|
||||
<Tab
|
||||
key={tabIndex}
|
||||
className={cn('border-b py-3 transition focus:not-focus-visible:outline-hidden', tabIndex === selectedIndex
|
||||
? 'border-emerald-500 text-emerald-400'
|
||||
: 'border-transparent text-zinc-400 hover:text-zinc-300')}
|
||||
<TabsList
|
||||
className="-mb-px flex gap-4 rounded-none bg-transparent p-0 text-xs font-medium"
|
||||
>
|
||||
{tabs!.map(tab => (
|
||||
<TabsTab
|
||||
key={tab.value}
|
||||
value={tab.value}
|
||||
className="h-auto rounded-none border-0 border-b border-transparent bg-transparent px-0 py-3 text-xs font-medium text-zinc-400 shadow-none transition hover:bg-transparent hover:text-zinc-300 focus:not-focus-visible:outline-hidden focus-visible:ring-0 data-active:border-emerald-500 data-active:bg-transparent data-active:text-emerald-400 data-active:shadow-none"
|
||||
>
|
||||
{tabTitle}
|
||||
</Tab>
|
||||
{tab.title}
|
||||
</TabsTab>
|
||||
))}
|
||||
</TabList>
|
||||
</TabsList>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
@ -176,19 +186,24 @@ function CodeGroupHeader({ title, tabTitles, selectedIndex }: CodeGroupHeaderPro
|
||||
|
||||
type ICodeGroupPanelsProps = PropsWithChildren<{
|
||||
targetCode?: CodeExample[]
|
||||
tabs?: CodeTab[]
|
||||
[key: string]: any
|
||||
}>
|
||||
|
||||
function CodeGroupPanels({ children, targetCode, ...props }: ICodeGroupPanelsProps) {
|
||||
if ((targetCode?.length ?? 0) > 1) {
|
||||
function CodeGroupPanels({ children, targetCode, tabs, ...props }: ICodeGroupPanelsProps) {
|
||||
if ((targetCode?.length ?? 0) > 1 && tabs) {
|
||||
return (
|
||||
<TabPanels>
|
||||
{targetCode!.map((code, index) => (
|
||||
<TabPanel key={code.title || code.tag || index}>
|
||||
<CodePanel {...props} targetCode={code} />
|
||||
</TabPanel>
|
||||
))}
|
||||
</TabPanels>
|
||||
<>
|
||||
{targetCode!.map((code, index) => {
|
||||
const tab = tabs[index]
|
||||
|
||||
return (
|
||||
<TabsPanel key={code.title || code.tag || index} value={tab?.value ?? String(index)}>
|
||||
<CodePanel {...props} targetCode={code} />
|
||||
</TabsPanel>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@ -201,18 +216,27 @@ function usePreventLayoutShift() {
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
window.cancelAnimationFrame(rafRef.current)
|
||||
if (rafRef.current)
|
||||
window.cancelAnimationFrame(rafRef.current)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return {
|
||||
positionRef,
|
||||
preventLayoutShift(callback: () => {}) {
|
||||
preventLayoutShift(callback: () => void) {
|
||||
if (!positionRef.current) {
|
||||
callback()
|
||||
return
|
||||
}
|
||||
|
||||
const initialTop = positionRef.current.getBoundingClientRect().top
|
||||
|
||||
callback()
|
||||
|
||||
rafRef.current = window.requestAnimationFrame(() => {
|
||||
if (!positionRef.current)
|
||||
return
|
||||
|
||||
const newTop = positionRef.current.getBoundingClientRect().top
|
||||
window.scrollBy(0, newTop - initialTop)
|
||||
})
|
||||
@ -220,27 +244,27 @@ function usePreventLayoutShift() {
|
||||
}
|
||||
}
|
||||
|
||||
function useTabGroupProps(availableLanguages: string[]) {
|
||||
const [preferredLanguages, addPreferredLanguage] = useState<any>([])
|
||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||
const activeLanguage = [...(availableLanguages || [])].sort(
|
||||
(a, z) => preferredLanguages.indexOf(z) - preferredLanguages.indexOf(a),
|
||||
)[0]
|
||||
const languageIndex = availableLanguages?.indexOf(activeLanguage!) || 0
|
||||
const newSelectedIndex = languageIndex === -1 ? selectedIndex : languageIndex
|
||||
if (newSelectedIndex !== selectedIndex)
|
||||
setSelectedIndex(newSelectedIndex)
|
||||
|
||||
function useTabGroupProps(tabValues: string[]) {
|
||||
const [selectedValue, setSelectedValue] = useState(tabValues[0] ?? '')
|
||||
const { positionRef, preventLayoutShift } = usePreventLayoutShift()
|
||||
const value = tabValues.includes(selectedValue)
|
||||
? selectedValue
|
||||
: tabValues[0] ?? ''
|
||||
|
||||
return {
|
||||
as: 'div',
|
||||
ref: positionRef,
|
||||
selectedIndex,
|
||||
onChange: (newSelectedIndex: number) => {
|
||||
preventLayoutShift(() =>
|
||||
(addPreferredLanguage(availableLanguages[newSelectedIndex]) as any),
|
||||
)
|
||||
value,
|
||||
onValueChange: (newValue: string | number | null) => {
|
||||
if (newValue == null)
|
||||
return
|
||||
|
||||
const nextValue = String(newValue)
|
||||
if (!tabValues.includes(nextValue))
|
||||
return
|
||||
|
||||
preventLayoutShift(() => {
|
||||
setSelectedValue(nextValue)
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
@ -260,24 +284,35 @@ type CodeGroupProps = PropsWithChildren<{
|
||||
|
||||
export function CodeGroup({ children, title, targetCode, ...props }: CodeGroupProps) {
|
||||
const examples = typeof targetCode === 'string' ? [{ code: targetCode }] as CodeExample[] : targetCode
|
||||
const tabTitles = examples?.map(({ title }) => title || 'Code') || []
|
||||
const tabGroupProps = useTabGroupProps(tabTitles)
|
||||
const hasTabs = tabTitles.length > 1
|
||||
const Container = hasTabs ? TabGroup : 'div'
|
||||
const containerProps = hasTabs ? tabGroupProps : {}
|
||||
const headerProps = hasTabs
|
||||
? { selectedIndex: tabGroupProps.selectedIndex, tabTitles }
|
||||
: {}
|
||||
const tabs = examples?.map(({ title }, index) => ({
|
||||
title: title || 'Code',
|
||||
value: String(index),
|
||||
})) || []
|
||||
const tabGroupProps = useTabGroupProps(tabs.map(tab => tab.value))
|
||||
const hasTabs = tabs.length > 1
|
||||
const content = (
|
||||
<>
|
||||
<CodeGroupHeader title={title} tabs={hasTabs ? tabs : undefined} />
|
||||
<CodeGroupPanels {...props} targetCode={examples} tabs={hasTabs ? tabs : undefined}>{children}</CodeGroupPanels>
|
||||
</>
|
||||
)
|
||||
|
||||
return (
|
||||
<CodeGroupContext.Provider value={true}>
|
||||
<Container
|
||||
{...containerProps}
|
||||
className="not-prose my-6 overflow-hidden rounded-2xl bg-zinc-900 shadow-md dark:ring-1 dark:ring-white/10"
|
||||
>
|
||||
<CodeGroupHeader title={title} {...headerProps} />
|
||||
<CodeGroupPanels {...props} targetCode={examples}>{children}</CodeGroupPanels>
|
||||
</Container>
|
||||
{hasTabs
|
||||
? (
|
||||
<Tabs
|
||||
{...tabGroupProps}
|
||||
className="not-prose my-6 overflow-hidden rounded-2xl bg-zinc-900 shadow-md dark:ring-1 dark:ring-white/10"
|
||||
>
|
||||
{content}
|
||||
</Tabs>
|
||||
)
|
||||
: (
|
||||
<div className="not-prose my-6 overflow-hidden rounded-2xl bg-zinc-900 shadow-md dark:ring-1 dark:ring-white/10">
|
||||
{content}
|
||||
</div>
|
||||
)}
|
||||
</CodeGroupContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
@ -33,7 +33,7 @@ vi.mock('@/app/components/workflow/nodes/_base/components/output-vars', () => ({
|
||||
|
||||
vi.mock('../structure-output', () => ({
|
||||
__esModule: true,
|
||||
default: (props: { className?: string, value?: StructuredOutput, onChange: (value: StructuredOutput) => void }) => {
|
||||
StructureOutput: (props: { className?: string, value?: StructuredOutput, onChange: (value: StructuredOutput) => void }) => {
|
||||
mockStructureOutput(props)
|
||||
return <div data-testid="structure-output">structured-output</div>
|
||||
},
|
||||
|
||||
@ -1,8 +1,6 @@
|
||||
import type { FC } from 'react'
|
||||
import type { SchemaRoot } from '../../types'
|
||||
import * as React from 'react'
|
||||
import Modal from '../../../../../base/modal'
|
||||
import JsonSchemaConfig from './json-schema-config'
|
||||
import { JsonSchemaConfig } from './json-schema-config'
|
||||
|
||||
type JsonSchemaConfigModalProps = {
|
||||
isShow: boolean
|
||||
@ -11,12 +9,12 @@ type JsonSchemaConfigModalProps = {
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const JsonSchemaConfigModal: FC<JsonSchemaConfigModalProps> = ({
|
||||
export function JsonSchemaConfigModal({
|
||||
isShow,
|
||||
defaultSchema,
|
||||
onSave,
|
||||
onClose,
|
||||
}) => {
|
||||
}: JsonSchemaConfigModalProps) {
|
||||
return (
|
||||
<Modal
|
||||
isShow={isShow}
|
||||
@ -31,5 +29,3 @@ const JsonSchemaConfigModal: FC<JsonSchemaConfigModalProps> = ({
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export default JsonSchemaConfigModal
|
||||
|
||||
@ -1,13 +1,12 @@
|
||||
import type { FC } from 'react'
|
||||
import type { SchemaRoot } from '../../types'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { ToggleGroup, ToggleGroupItem } from '@langgenius/dify-ui/toggle-group'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Divider from '@/app/components/base/divider'
|
||||
import { JSON_SCHEMA_MAX_DEPTH } from '@/config'
|
||||
import { SegmentedControl } from '../../../../../base/segmented-control'
|
||||
import { Type } from '../../types'
|
||||
import {
|
||||
checkJsonSchemaDepth,
|
||||
@ -35,11 +34,15 @@ enum SchemaView {
|
||||
JsonSchema = 'jsonSchema',
|
||||
}
|
||||
|
||||
const TimelineViewIcon: FC<{ className?: string }> = ({ className }) => {
|
||||
type IconProps = {
|
||||
className?: string
|
||||
}
|
||||
|
||||
function TimelineViewIcon({ className }: IconProps) {
|
||||
return <span className={cn('i-ri-timeline-view', className)} />
|
||||
}
|
||||
|
||||
const BracesIcon: FC<{ className?: string }> = ({ className }) => {
|
||||
function BracesIcon({ className }: IconProps) {
|
||||
return <span className={cn('i-ri-braces-line', className)} />
|
||||
}
|
||||
|
||||
@ -55,13 +58,13 @@ const DEFAULT_SCHEMA: SchemaRoot = {
|
||||
additionalProperties: false,
|
||||
}
|
||||
|
||||
const JsonSchemaConfig: FC<JsonSchemaConfigProps> = ({
|
||||
function JsonSchemaConfigContent({
|
||||
defaultSchema,
|
||||
onSave,
|
||||
onClose,
|
||||
}) => {
|
||||
}: JsonSchemaConfigProps) {
|
||||
const { t } = useTranslation()
|
||||
const [currentTab, setCurrentTab] = useState(SchemaView.VisualEditor)
|
||||
const [currentTab, setCurrentTab] = useState<readonly SchemaView[]>([SchemaView.VisualEditor])
|
||||
const [jsonSchema, setJsonSchema] = useState(defaultSchema || DEFAULT_SCHEMA)
|
||||
const [json, setJson] = useState(() => JSON.stringify(jsonSchema, null, 2))
|
||||
const [btnWidth, setBtnWidth] = useState(0)
|
||||
@ -73,15 +76,16 @@ const JsonSchemaConfig: FC<JsonSchemaConfigProps> = ({
|
||||
const setIsAddingNewField = useVisualEditorStore(state => state.setIsAddingNewField)
|
||||
const setHoveringProperty = useVisualEditorStore(state => state.setHoveringProperty)
|
||||
const { emit } = useMittContext()
|
||||
const selectedTab = currentTab[0] ?? SchemaView.VisualEditor
|
||||
|
||||
const updateBtnWidth = useCallback((width: number) => {
|
||||
function updateBtnWidth(width: number) {
|
||||
setBtnWidth(width + 32)
|
||||
}, [])
|
||||
}
|
||||
|
||||
const handleTabChange = useCallback((value: SchemaView) => {
|
||||
if (currentTab === value)
|
||||
function handleTabChange(value: SchemaView) {
|
||||
if (selectedTab === value)
|
||||
return
|
||||
if (currentTab === SchemaView.JsonSchema) {
|
||||
if (selectedTab === SchemaView.JsonSchema) {
|
||||
try {
|
||||
const schema = JSON.parse(json)
|
||||
setParseError(null)
|
||||
@ -112,41 +116,41 @@ const JsonSchemaConfig: FC<JsonSchemaConfigProps> = ({
|
||||
return
|
||||
}
|
||||
}
|
||||
else if (currentTab === SchemaView.VisualEditor) {
|
||||
else if (selectedTab === SchemaView.VisualEditor) {
|
||||
if (advancedEditing || isAddingNewField)
|
||||
emit('quitEditing', { callback: (backup: SchemaRoot) => setJson(JSON.stringify(backup || jsonSchema, null, 2)) })
|
||||
else
|
||||
setJson(JSON.stringify(jsonSchema, null, 2))
|
||||
}
|
||||
|
||||
setCurrentTab(value)
|
||||
}, [currentTab, jsonSchema, json, advancedEditing, isAddingNewField, emit])
|
||||
setCurrentTab([value])
|
||||
}
|
||||
|
||||
const handleApplySchema = useCallback((schema: SchemaRoot) => {
|
||||
if (currentTab === SchemaView.VisualEditor)
|
||||
function handleApplySchema(schema: SchemaRoot) {
|
||||
if (selectedTab === SchemaView.VisualEditor)
|
||||
setJsonSchema(schema)
|
||||
else if (currentTab === SchemaView.JsonSchema)
|
||||
else if (selectedTab === SchemaView.JsonSchema)
|
||||
setJson(JSON.stringify(schema, null, 2))
|
||||
}, [currentTab])
|
||||
}
|
||||
|
||||
const handleSubmit = useCallback((schema: Record<string, unknown>) => {
|
||||
function handleSubmit(schema: Record<string, unknown>) {
|
||||
const jsonSchema = jsonToSchema(schema) as SchemaRoot
|
||||
if (currentTab === SchemaView.VisualEditor)
|
||||
if (selectedTab === SchemaView.VisualEditor)
|
||||
setJsonSchema(jsonSchema)
|
||||
else if (currentTab === SchemaView.JsonSchema)
|
||||
else if (selectedTab === SchemaView.JsonSchema)
|
||||
setJson(JSON.stringify(jsonSchema, null, 2))
|
||||
}, [currentTab])
|
||||
}
|
||||
|
||||
const handleVisualEditorUpdate = useCallback((schema: SchemaRoot) => {
|
||||
function handleVisualEditorUpdate(schema: SchemaRoot) {
|
||||
setJsonSchema(schema)
|
||||
}, [])
|
||||
}
|
||||
|
||||
const handleSchemaEditorUpdate = useCallback((schema: string) => {
|
||||
function handleSchemaEditorUpdate(schema: string) {
|
||||
setJson(schema)
|
||||
}, [])
|
||||
}
|
||||
|
||||
const handleResetDefaults = useCallback(() => {
|
||||
if (currentTab === SchemaView.VisualEditor) {
|
||||
function handleResetDefaults() {
|
||||
if (selectedTab === SchemaView.VisualEditor) {
|
||||
setHoveringProperty(null)
|
||||
if (advancedEditing)
|
||||
setAdvancedEditing(false)
|
||||
@ -155,15 +159,15 @@ const JsonSchemaConfig: FC<JsonSchemaConfigProps> = ({
|
||||
}
|
||||
setJsonSchema(DEFAULT_SCHEMA)
|
||||
setJson(JSON.stringify(DEFAULT_SCHEMA, null, 2))
|
||||
}, [currentTab, advancedEditing, isAddingNewField, setAdvancedEditing, setIsAddingNewField, setHoveringProperty])
|
||||
}
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
function handleCancel() {
|
||||
onClose()
|
||||
}, [onClose])
|
||||
}
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
function handleSave() {
|
||||
let schema = jsonSchema
|
||||
if (currentTab === SchemaView.JsonSchema) {
|
||||
if (selectedTab === SchemaView.JsonSchema) {
|
||||
try {
|
||||
schema = JSON.parse(json)
|
||||
setParseError(null)
|
||||
@ -194,7 +198,7 @@ const JsonSchemaConfig: FC<JsonSchemaConfigProps> = ({
|
||||
return
|
||||
}
|
||||
}
|
||||
else if (currentTab === SchemaView.VisualEditor) {
|
||||
else if (selectedTab === SchemaView.VisualEditor) {
|
||||
if (advancedEditing || isAddingNewField) {
|
||||
toast.warning(t('nodes.llm.jsonSchema.warningTips.saveSchema', { ns: 'workflow' }))
|
||||
return
|
||||
@ -202,7 +206,7 @@ const JsonSchemaConfig: FC<JsonSchemaConfigProps> = ({
|
||||
}
|
||||
onSave(schema)
|
||||
onClose()
|
||||
}, [currentTab, jsonSchema, json, onSave, onClose, advancedEditing, isAddingNewField, t])
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
@ -211,18 +215,34 @@ const JsonSchemaConfig: FC<JsonSchemaConfigProps> = ({
|
||||
<div className="grow truncate title-2xl-semi-bold text-text-primary">
|
||||
{t('nodes.llm.jsonSchema.title', { ns: 'workflow' })}
|
||||
</div>
|
||||
<div className="absolute top-5 right-5 flex h-8 w-8 items-center justify-center p-1.5" onClick={onClose}>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute top-5 right-5 flex h-8 w-8 items-center justify-center p-1.5"
|
||||
aria-label={t('operation.close', { ns: 'common' })}
|
||||
onClick={onClose}
|
||||
>
|
||||
<span className="i-ri-close-line h-[18px] w-[18px] text-text-tertiary" />
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
{/* Content */}
|
||||
<div className="flex items-center justify-between px-6 py-2">
|
||||
{/* Tab */}
|
||||
<SegmentedControl<SchemaView>
|
||||
options={VIEW_TABS}
|
||||
<ToggleGroup<SchemaView>
|
||||
aria-label={t('nodes.llm.jsonSchema.title', { ns: 'workflow' })}
|
||||
value={currentTab}
|
||||
onChange={handleTabChange}
|
||||
/>
|
||||
onValueChange={(nextTab) => {
|
||||
const value = nextTab[0]
|
||||
if (value)
|
||||
handleTabChange(value)
|
||||
}}
|
||||
>
|
||||
{VIEW_TABS.map(({ Icon, text, value }) => (
|
||||
<ToggleGroupItem key={value} value={value}>
|
||||
<Icon className="size-4 shrink-0" />
|
||||
<span className="p-0.5">{text}</span>
|
||||
</ToggleGroupItem>
|
||||
))}
|
||||
</ToggleGroup>
|
||||
<div className="flex items-center gap-x-0.5">
|
||||
{/* JSON Schema Generator */}
|
||||
<JsonSchemaGenerator
|
||||
@ -238,13 +258,13 @@ const JsonSchemaConfig: FC<JsonSchemaConfigProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex grow flex-col gap-y-1 overflow-hidden px-6">
|
||||
{currentTab === SchemaView.VisualEditor && (
|
||||
{selectedTab === SchemaView.VisualEditor && (
|
||||
<VisualEditor
|
||||
schema={jsonSchema}
|
||||
onChange={handleVisualEditorUpdate}
|
||||
/>
|
||||
)}
|
||||
{currentTab === SchemaView.JsonSchema && (
|
||||
{selectedTab === SchemaView.JsonSchema && (
|
||||
<SchemaEditor
|
||||
schema={json}
|
||||
onUpdate={handleSchemaEditorUpdate}
|
||||
@ -276,14 +296,12 @@ const JsonSchemaConfig: FC<JsonSchemaConfigProps> = ({
|
||||
)
|
||||
}
|
||||
|
||||
const JsonSchemaConfigWrapper: FC<JsonSchemaConfigProps> = (props) => {
|
||||
export function JsonSchemaConfig(props: JsonSchemaConfigProps) {
|
||||
return (
|
||||
<MittProvider>
|
||||
<VisualEditorContextProvider>
|
||||
<JsonSchemaConfig {...props} />
|
||||
<JsonSchemaConfigContent {...props} />
|
||||
</VisualEditorContextProvider>
|
||||
</MittProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export default JsonSchemaConfigWrapper
|
||||
|
||||
@ -7,7 +7,7 @@ import { useTranslation } from 'react-i18next'
|
||||
import { Infotip } from '@/app/components/base/infotip'
|
||||
import OutputVars, { VarItem } from '@/app/components/workflow/nodes/_base/components/output-vars'
|
||||
import Split from '@/app/components/workflow/nodes/_base/components/split'
|
||||
import StructureOutput from './structure-output'
|
||||
import { StructureOutput } from './structure-output'
|
||||
|
||||
type Props = {
|
||||
readOnly: boolean
|
||||
|
||||
@ -1,16 +1,12 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import type { SchemaRoot, StructuredOutput } from '../types'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { RiEditLine } from '@remixicon/react'
|
||||
import { useBoolean } from 'ahooks'
|
||||
import * as React from 'react'
|
||||
import { useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import ShowPanel from '@/app/components/workflow/nodes/_base/components/variable/object-child-tree-panel/show'
|
||||
import { Type } from '../types'
|
||||
import JsonSchemaConfigModal from './json-schema-config-modal'
|
||||
import { JsonSchemaConfigModal } from './json-schema-config-modal'
|
||||
|
||||
type Props = {
|
||||
className?: string
|
||||
@ -18,22 +14,23 @@ type Props = {
|
||||
onChange: (value: StructuredOutput) => void
|
||||
}
|
||||
|
||||
const StructureOutput: FC<Props> = ({
|
||||
export function StructureOutput({
|
||||
className,
|
||||
value,
|
||||
onChange,
|
||||
}) => {
|
||||
}: Props) {
|
||||
const { t } = useTranslation()
|
||||
const [showConfig, {
|
||||
setTrue: showConfigModal,
|
||||
setFalse: hideConfigModal,
|
||||
}] = useBoolean(false)
|
||||
|
||||
const handleChange = useCallback((value: SchemaRoot) => {
|
||||
function handleChange(value: SchemaRoot) {
|
||||
onChange({
|
||||
schema: value,
|
||||
})
|
||||
}, [onChange])
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn(className)}>
|
||||
<div className="flex justify-between">
|
||||
@ -47,7 +44,7 @@ const StructureOutput: FC<Props> = ({
|
||||
className="flex"
|
||||
onClick={showConfigModal}
|
||||
>
|
||||
<RiEditLine className="mr-1 size-3.5" />
|
||||
<i className="mr-1 i-ri-edit-line size-3.5" aria-hidden="true" />
|
||||
<div className="system-xs-medium text-components-button-secondary-text">{t('structOutput.configure', { ns: 'app' })}</div>
|
||||
</Button>
|
||||
</div>
|
||||
@ -58,7 +55,13 @@ const StructureOutput: FC<Props> = ({
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<div className="mt-1.5 flex h-10 cursor-pointer items-center justify-center rounded-[10px] bg-background-section system-xs-regular text-text-tertiary" onClick={showConfigModal}>{t('structOutput.notConfiguredTip', { ns: 'app' })}</div>
|
||||
<button
|
||||
type="button"
|
||||
className="mt-1.5 flex h-10 w-full cursor-pointer items-center justify-center rounded-[10px] bg-background-section system-xs-regular text-text-tertiary"
|
||||
onClick={showConfigModal}
|
||||
>
|
||||
{t('structOutput.notConfiguredTip', { ns: 'app' })}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{showConfig && (
|
||||
@ -77,4 +80,3 @@ const StructureOutput: FC<Props> = ({
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default React.memo(StructureOutput)
|
||||
|
||||
@ -3,7 +3,6 @@ import type { ScheduleTriggerNodeType } from '../../types'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import FrequencySelector from '../frequency-selector'
|
||||
import ModeSwitcher from '../mode-switcher'
|
||||
import ModeToggle from '../mode-toggle'
|
||||
import MonthlyDaysSelector from '../monthly-days-selector'
|
||||
import NextExecutionTimes from '../next-execution-times'
|
||||
@ -53,16 +52,6 @@ describe('trigger-schedule components', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('should switch between visual and cron modes', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onChange = vi.fn()
|
||||
render(<ModeSwitcher mode="visual" onChange={onChange} />)
|
||||
|
||||
await user.click(screen.getByText('workflow.nodes.triggerSchedule.modeCron'))
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith('cron')
|
||||
})
|
||||
|
||||
it('should toggle the mode from visual to cron', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onChange = vi.fn()
|
||||
|
||||
@ -1,16 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import ModeSwitcher from '../mode-switcher'
|
||||
|
||||
describe('trigger-schedule/mode-switcher', () => {
|
||||
it('switches between visual and cron modes', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onChange = vi.fn()
|
||||
|
||||
render(<ModeSwitcher mode="visual" onChange={onChange} />)
|
||||
|
||||
await user.click(screen.getByText('workflow.nodes.triggerSchedule.modeCron'))
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith('cron')
|
||||
})
|
||||
})
|
||||
@ -1,37 +0,0 @@
|
||||
import type { ScheduleMode } from '../types'
|
||||
import { RiCalendarLine, RiCodeLine } from '@remixicon/react'
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { SegmentedControl } from '@/app/components/base/segmented-control'
|
||||
|
||||
type ModeSwitcherProps = {
|
||||
mode: ScheduleMode
|
||||
onChange: (mode: ScheduleMode) => void
|
||||
}
|
||||
|
||||
const ModeSwitcher = ({ mode, onChange }: ModeSwitcherProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const options = [
|
||||
{
|
||||
Icon: RiCalendarLine,
|
||||
text: t('nodes.triggerSchedule.modeVisual', { ns: 'workflow' }),
|
||||
value: 'visual' as const,
|
||||
},
|
||||
{
|
||||
Icon: RiCodeLine,
|
||||
text: t('nodes.triggerSchedule.modeCron', { ns: 'workflow' }),
|
||||
value: 'cron' as const,
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<SegmentedControl
|
||||
options={options}
|
||||
value={mode}
|
||||
onChange={onChange}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default ModeSwitcher
|
||||
@ -1,5 +1,5 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import DisplayContent from '../display-content'
|
||||
import { DisplayContent } from '../display-content'
|
||||
import { PreviewType } from '../types'
|
||||
|
||||
describe('variable inspect display content', () => {
|
||||
|
||||
@ -2,12 +2,10 @@ import type { VarType } from '../types'
|
||||
import type { ChunkInfo } from '@/app/components/rag-pipeline/components/chunk-card-list/types'
|
||||
import type { ParentMode } from '@/models/datasets'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { RiBracesLine, RiEyeLine } from '@remixicon/react'
|
||||
import * as React from 'react'
|
||||
import { ToggleGroup, ToggleGroupItem } from '@langgenius/dify-ui/toggle-group'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Markdown } from '@/app/components/base/markdown'
|
||||
import { SegmentedControl } from '@/app/components/base/segmented-control'
|
||||
import Textarea from '@/app/components/base/textarea'
|
||||
import { ChunkCardList } from '@/app/components/rag-pipeline/components/chunk-card-list'
|
||||
import SchemaEditor from '@/app/components/workflow/nodes/llm/components/json-schema-config-modal/schema-editor'
|
||||
@ -26,11 +24,16 @@ type DisplayContentProps = {
|
||||
className?: string
|
||||
}
|
||||
|
||||
const DisplayContent = (props: DisplayContentProps) => {
|
||||
export function DisplayContent(props: DisplayContentProps) {
|
||||
const { previewType, varType, schemaType, mdString, jsonString, readonly, handleTextChange, handleEditorChange, className } = props
|
||||
const [viewMode, setViewMode] = useState<ViewMode>(ViewMode.Code)
|
||||
const [viewMode, setViewMode] = useState<readonly ViewMode[]>([ViewMode.Code])
|
||||
const [isFocused, setIsFocused] = useState(false)
|
||||
const { t } = useTranslation()
|
||||
const viewOptions = [
|
||||
{ value: ViewMode.Code, label: t('nodes.templateTransform.code', { ns: 'workflow' }), iconClassName: 'i-ri-braces-line' },
|
||||
{ value: ViewMode.Preview, label: t('common.preview', { ns: 'workflow' }), iconClassName: 'i-ri-eye-line' },
|
||||
]
|
||||
const selectedViewMode = viewMode[0] ?? ViewMode.Code
|
||||
|
||||
const chunkType = useMemo(() => {
|
||||
if (previewType !== PreviewType.Chunks || !schemaType)
|
||||
@ -65,22 +68,26 @@ const DisplayContent = (props: DisplayContentProps) => {
|
||||
{schemaType ? `(${schemaType})` : ''}
|
||||
</div>
|
||||
)}
|
||||
<SegmentedControl
|
||||
options={[
|
||||
{ value: ViewMode.Code, text: t('nodes.templateTransform.code', { ns: 'workflow' }), Icon: RiBracesLine },
|
||||
{ value: ViewMode.Preview, text: t('common.preview', { ns: 'workflow' }), Icon: RiEyeLine },
|
||||
]}
|
||||
<ToggleGroup<ViewMode>
|
||||
aria-label={t('common.preview', { ns: 'workflow' })}
|
||||
value={viewMode}
|
||||
onChange={setViewMode}
|
||||
size="small"
|
||||
padding="with"
|
||||
activeClassName="text-text-accent-light-mode-only!"
|
||||
btnClassName="pl-1.5! pr-0.5! gap-[3px]"
|
||||
className="shrink-0"
|
||||
/>
|
||||
onValueChange={setViewMode}
|
||||
className="shrink-0 rounded-md p-px"
|
||||
>
|
||||
{viewOptions.map(({ value, label, iconClassName }) => (
|
||||
<ToggleGroupItem
|
||||
key={value}
|
||||
value={value}
|
||||
className="h-[22px] gap-[3px] rounded-md p-px pr-0.5 pl-1.5 text-text-tertiary data-pressed:text-text-accent-light-mode-only"
|
||||
>
|
||||
<i className={cn('size-4 shrink-0', iconClassName)} aria-hidden="true" />
|
||||
<span className="p-0.5 pr-1">{label}</span>
|
||||
</ToggleGroupItem>
|
||||
))}
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
<div className="flex flex-1 overflow-auto rounded-b-[10px] pr-1 pl-3">
|
||||
{viewMode === ViewMode.Code && (
|
||||
{selectedViewMode === ViewMode.Code && (
|
||||
previewType === PreviewType.Markdown
|
||||
? (
|
||||
<Textarea
|
||||
@ -105,7 +112,7 @@ const DisplayContent = (props: DisplayContentProps) => {
|
||||
/>
|
||||
)
|
||||
)}
|
||||
{viewMode === ViewMode.Preview && (
|
||||
{selectedViewMode === ViewMode.Preview && (
|
||||
previewType === PreviewType.Markdown
|
||||
? <Markdown className="grow overflow-auto rounded-lg px-4 py-3" content={(mdString ?? '') as string} />
|
||||
: (
|
||||
@ -120,5 +127,3 @@ const DisplayContent = (props: DisplayContentProps) => {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(DisplayContent)
|
||||
|
||||
@ -11,7 +11,7 @@ import { SupportUploadFileTypes } from '@/app/components/workflow/types'
|
||||
import { TransferMethod } from '@/types/app'
|
||||
import { PreviewMode } from '../../base/features/types'
|
||||
import BoolValue from '../panel/chat-variable-panel/components/bool-value'
|
||||
import DisplayContent from './display-content'
|
||||
import { DisplayContent } from './display-content'
|
||||
import LargeDataAlert from './large-data-alert'
|
||||
import { PreviewType } from './types'
|
||||
|
||||
|
||||
@ -28,7 +28,6 @@
|
||||
@import '../components/base/action-button/index.css';
|
||||
@import '../components/base/badge/index.css';
|
||||
@import '../components/base/premium-badge/index.css';
|
||||
@import '../components/base/segmented-control/index.css';
|
||||
|
||||
/* ---------- JS plugins ------------------------------------------------ */
|
||||
@plugin './plugins/icons.ts';
|
||||
|
||||
Loading…
Reference in New Issue
Block a user