dify
This commit is contained in:
52
dify/web/app/components/base/button/add-button.stories.tsx
Normal file
52
dify/web/app/components/base/button/add-button.stories.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import type { Meta, StoryObj } from '@storybook/nextjs'
|
||||
import AddButton from './add-button'
|
||||
|
||||
const meta = {
|
||||
title: 'Base/General/AddButton',
|
||||
component: AddButton,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Compact icon-only button used for inline “add” actions in lists, cards, and modals.',
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
className: {
|
||||
control: 'text',
|
||||
description: 'Extra classes appended to the clickable container.',
|
||||
},
|
||||
onClick: {
|
||||
control: false,
|
||||
description: 'Triggered when the add button is pressed.',
|
||||
},
|
||||
},
|
||||
args: {
|
||||
onClick: () => console.log('Add button clicked'),
|
||||
},
|
||||
} satisfies Meta<typeof AddButton>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
className: 'bg-white/80 shadow-sm backdrop-blur-sm',
|
||||
},
|
||||
}
|
||||
|
||||
export const InToolbar: Story = {
|
||||
render: args => (
|
||||
<div className="flex items-center gap-2 rounded-lg border border-divider-subtle bg-components-panel-bg p-3">
|
||||
<span className="text-xs text-text-tertiary">Attachments</span>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<AddButton {...args} />
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
args: {
|
||||
className: 'border border-dashed border-primary-200',
|
||||
},
|
||||
}
|
||||
22
dify/web/app/components/base/button/add-button.tsx
Normal file
22
dify/web/app/components/base/button/add-button.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React from 'react'
|
||||
import { RiAddLine } from '@remixicon/react'
|
||||
import cn from '@/utils/classnames'
|
||||
|
||||
type Props = {
|
||||
className?: string
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
const AddButton: FC<Props> = ({
|
||||
className,
|
||||
onClick,
|
||||
}) => {
|
||||
return (
|
||||
<div className={cn(className, 'cursor-pointer select-none rounded-md p-1 hover:bg-state-base-hover')} onClick={onClick}>
|
||||
<RiAddLine className='h-4 w-4 text-text-tertiary' />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default React.memo(AddButton)
|
||||
188
dify/web/app/components/base/button/index.css
Normal file
188
dify/web/app/components/base/button/index.css
Normal file
@@ -0,0 +1,188 @@
|
||||
@tailwind components;
|
||||
|
||||
@layer components {
|
||||
.btn {
|
||||
@apply inline-flex justify-center items-center cursor-pointer whitespace-nowrap;
|
||||
}
|
||||
|
||||
.btn-disabled {
|
||||
@apply cursor-not-allowed;
|
||||
}
|
||||
|
||||
.btn-small {
|
||||
@apply px-2 h-6 rounded-md text-xs font-medium;
|
||||
}
|
||||
|
||||
.btn-medium {
|
||||
@apply px-3.5 h-8 rounded-lg text-[13px] leading-4 font-medium;
|
||||
}
|
||||
|
||||
.btn-large {
|
||||
@apply px-4 h-9 rounded-[10px] text-sm font-semibold;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
@apply
|
||||
shadow
|
||||
bg-components-button-primary-bg
|
||||
border-components-button-primary-border
|
||||
hover:bg-components-button-primary-bg-hover
|
||||
hover:border-components-button-primary-border-hover
|
||||
text-components-button-primary-text;
|
||||
}
|
||||
|
||||
.btn-primary.btn-destructive {
|
||||
@apply
|
||||
bg-components-button-destructive-primary-bg
|
||||
border-components-button-destructive-primary-border
|
||||
hover:bg-components-button-destructive-primary-bg-hover
|
||||
hover:border-components-button-destructive-primary-border-hover
|
||||
text-components-button-destructive-primary-text;
|
||||
}
|
||||
|
||||
.btn-primary.btn-disabled {
|
||||
@apply
|
||||
shadow-none
|
||||
bg-components-button-primary-bg-disabled
|
||||
border-components-button-primary-border-disabled
|
||||
text-components-button-primary-text-disabled;
|
||||
}
|
||||
|
||||
.btn-primary.btn-destructive.btn-disabled {
|
||||
@apply
|
||||
shadow-none
|
||||
bg-components-button-destructive-primary-bg-disabled
|
||||
border-components-button-destructive-primary-border-disabled
|
||||
text-components-button-destructive-primary-text-disabled;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
@apply
|
||||
border-[0.5px]
|
||||
shadow-xs
|
||||
backdrop-blur-[5px]
|
||||
bg-components-button-secondary-bg
|
||||
border-components-button-secondary-border
|
||||
hover:bg-components-button-secondary-bg-hover
|
||||
hover:border-components-button-secondary-border-hover
|
||||
text-components-button-secondary-text;
|
||||
}
|
||||
|
||||
.btn-secondary.btn-disabled {
|
||||
@apply
|
||||
backdrop-blur-sm
|
||||
bg-components-button-secondary-bg-disabled
|
||||
border-components-button-secondary-border-disabled
|
||||
text-components-button-secondary-text-disabled;
|
||||
}
|
||||
|
||||
.btn-secondary.btn-destructive {
|
||||
@apply
|
||||
bg-components-button-destructive-secondary-bg
|
||||
border-components-button-destructive-secondary-border
|
||||
hover:bg-components-button-destructive-secondary-bg-hover
|
||||
hover:border-components-button-destructive-secondary-border-hover
|
||||
text-components-button-destructive-secondary-text;
|
||||
}
|
||||
|
||||
.btn-secondary.btn-destructive.btn-disabled {
|
||||
@apply
|
||||
bg-components-button-destructive-secondary-bg-disabled
|
||||
border-components-button-destructive-secondary-border-disabled
|
||||
text-components-button-destructive-secondary-text-disabled;
|
||||
}
|
||||
|
||||
|
||||
.btn-secondary-accent {
|
||||
@apply
|
||||
border-[0.5px]
|
||||
shadow-xs
|
||||
bg-components-button-secondary-bg
|
||||
border-components-button-secondary-border
|
||||
hover:bg-components-button-secondary-bg-hover
|
||||
hover:border-components-button-secondary-border-hover
|
||||
text-components-button-secondary-accent-text;
|
||||
}
|
||||
|
||||
.btn-secondary-accent.btn-disabled {
|
||||
@apply
|
||||
bg-components-button-secondary-bg-disabled
|
||||
border-components-button-secondary-border-disabled
|
||||
text-components-button-secondary-accent-text-disabled;
|
||||
}
|
||||
|
||||
.btn-warning {
|
||||
@apply
|
||||
bg-components-button-destructive-primary-bg
|
||||
border-components-button-destructive-primary-border
|
||||
hover:bg-components-button-destructive-primary-bg-hover
|
||||
hover:border-components-button-destructive-primary-border-hover
|
||||
text-components-button-destructive-primary-text;
|
||||
}
|
||||
|
||||
.btn-warning.btn-disabled {
|
||||
@apply
|
||||
bg-components-button-destructive-primary-bg-disabled
|
||||
border-components-button-destructive-primary-border-disabled
|
||||
text-components-button-destructive-primary-text-disabled;
|
||||
}
|
||||
|
||||
.btn-tertiary {
|
||||
@apply
|
||||
bg-components-button-tertiary-bg
|
||||
hover:bg-components-button-tertiary-bg-hover
|
||||
text-components-button-tertiary-text;
|
||||
}
|
||||
|
||||
.btn-tertiary.btn-disabled {
|
||||
@apply
|
||||
bg-components-button-tertiary-bg-disabled
|
||||
text-components-button-tertiary-text-disabled;
|
||||
}
|
||||
|
||||
.btn-tertiary.btn-destructive {
|
||||
@apply
|
||||
bg-components-button-destructive-tertiary-bg
|
||||
hover:bg-components-button-destructive-tertiary-bg-hover
|
||||
text-components-button-destructive-tertiary-text;
|
||||
}
|
||||
|
||||
.btn-tertiary.btn-destructive.btn-disabled {
|
||||
@apply
|
||||
bg-components-button-destructive-tertiary-bg-disabled
|
||||
text-components-button-destructive-tertiary-text-disabled;
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
@apply
|
||||
hover:bg-components-button-ghost-bg-hover
|
||||
text-components-button-ghost-text;
|
||||
}
|
||||
|
||||
.btn-ghost.btn-disabled {
|
||||
@apply
|
||||
text-components-button-ghost-text-disabled;
|
||||
}
|
||||
|
||||
.btn-ghost.btn-destructive {
|
||||
@apply
|
||||
hover:bg-components-button-destructive-ghost-bg-hover
|
||||
text-components-button-destructive-ghost-text;
|
||||
}
|
||||
|
||||
.btn-ghost.btn-destructive.btn-disabled {
|
||||
@apply
|
||||
text-components-button-destructive-ghost-text-disabled;
|
||||
}
|
||||
|
||||
.btn-ghost-accent {
|
||||
@apply
|
||||
hover:bg-state-accent-hover
|
||||
text-components-button-secondary-accent-text;
|
||||
}
|
||||
|
||||
.btn-ghost-accent.btn-disabled {
|
||||
@apply
|
||||
text-components-button-secondary-accent-text-disabled;
|
||||
}
|
||||
}
|
||||
110
dify/web/app/components/base/button/index.spec.tsx
Normal file
110
dify/web/app/components/base/button/index.spec.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import React from 'react'
|
||||
import { cleanup, fireEvent, render } from '@testing-library/react'
|
||||
import Button from './index'
|
||||
|
||||
afterEach(cleanup)
|
||||
// https://testing-library.com/docs/queries/about
|
||||
describe('Button', () => {
|
||||
describe('Button text', () => {
|
||||
test('Button text should be same as children', async () => {
|
||||
const { getByRole, container } = render(<Button>Click me</Button>)
|
||||
expect(getByRole('button').textContent).toBe('Click me')
|
||||
expect(container.querySelector('button')?.textContent).toBe('Click me')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Button loading', () => {
|
||||
test('Loading button text should include same as children', async () => {
|
||||
const { getByRole } = render(<Button loading>Click me</Button>)
|
||||
expect(getByRole('button').textContent?.includes('Loading')).toBe(true)
|
||||
})
|
||||
test('Not loading button text should include same as children', async () => {
|
||||
const { getByRole } = render(<Button loading={false}>Click me</Button>)
|
||||
expect(getByRole('button').textContent?.includes('Loading')).toBe(false)
|
||||
})
|
||||
|
||||
test('Loading button should have loading classname', async () => {
|
||||
const animClassName = 'anim-breath'
|
||||
const { getByRole } = render(<Button loading spinnerClassName={animClassName}>Click me</Button>)
|
||||
expect(getByRole('button').getElementsByClassName('animate-spin')[0]?.className).toContain(animClassName)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Button style', () => {
|
||||
test('Button should have default variant', async () => {
|
||||
const { getByRole } = render(<Button>Click me</Button>)
|
||||
expect(getByRole('button').className).toContain('btn-secondary')
|
||||
})
|
||||
|
||||
test('Button should have primary variant', async () => {
|
||||
const { getByRole } = render(<Button variant='primary'>Click me</Button>)
|
||||
expect(getByRole('button').className).toContain('btn-primary')
|
||||
})
|
||||
|
||||
test('Button should have warning variant', async () => {
|
||||
const { getByRole } = render(<Button variant='warning'>Click me</Button>)
|
||||
expect(getByRole('button').className).toContain('btn-warning')
|
||||
})
|
||||
|
||||
test('Button should have secondary variant', async () => {
|
||||
const { getByRole } = render(<Button variant='secondary'>Click me</Button>)
|
||||
expect(getByRole('button').className).toContain('btn-secondary')
|
||||
})
|
||||
|
||||
test('Button should have secondary-accent variant', async () => {
|
||||
const { getByRole } = render(<Button variant='secondary-accent'>Click me</Button>)
|
||||
expect(getByRole('button').className).toContain('btn-secondary-accent')
|
||||
})
|
||||
test('Button should have ghost variant', async () => {
|
||||
const { getByRole } = render(<Button variant='ghost'>Click me</Button>)
|
||||
expect(getByRole('button').className).toContain('btn-ghost')
|
||||
})
|
||||
test('Button should have ghost-accent variant', async () => {
|
||||
const { getByRole } = render(<Button variant='ghost-accent'>Click me</Button>)
|
||||
expect(getByRole('button').className).toContain('btn-ghost-accent')
|
||||
})
|
||||
|
||||
test('Button disabled should have disabled variant', async () => {
|
||||
const { getByRole } = render(<Button disabled>Click me</Button>)
|
||||
expect(getByRole('button').className).toContain('btn-disabled')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Button size', () => {
|
||||
test('Button should have default size', async () => {
|
||||
const { getByRole } = render(<Button>Click me</Button>)
|
||||
expect(getByRole('button').className).toContain('btn-medium')
|
||||
})
|
||||
|
||||
test('Button should have small size', async () => {
|
||||
const { getByRole } = render(<Button size='small'>Click me</Button>)
|
||||
expect(getByRole('button').className).toContain('btn-small')
|
||||
})
|
||||
|
||||
test('Button should have medium size', async () => {
|
||||
const { getByRole } = render(<Button size='medium'>Click me</Button>)
|
||||
expect(getByRole('button').className).toContain('btn-medium')
|
||||
})
|
||||
|
||||
test('Button should have large size', async () => {
|
||||
const { getByRole } = render(<Button size='large'>Click me</Button>)
|
||||
expect(getByRole('button').className).toContain('btn-large')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Button destructive', () => {
|
||||
test('Button should have destructive classname', async () => {
|
||||
const { getByRole } = render(<Button destructive>Click me</Button>)
|
||||
expect(getByRole('button').className).toContain('btn-destructive')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Button events', () => {
|
||||
test('onClick should been call after clicked', async () => {
|
||||
const onClick = jest.fn()
|
||||
const { getByRole } = render(<Button onClick={onClick}>Click me</Button>)
|
||||
fireEvent.click(getByRole('button'))
|
||||
expect(onClick).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
108
dify/web/app/components/base/button/index.stories.tsx
Normal file
108
dify/web/app/components/base/button/index.stories.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import type { Meta, StoryObj } from '@storybook/nextjs'
|
||||
|
||||
import { RocketLaunchIcon } from '@heroicons/react/20/solid'
|
||||
import { Button } from '.'
|
||||
|
||||
const meta = {
|
||||
title: 'Base/General/Button',
|
||||
component: Button,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
loading: { control: 'boolean' },
|
||||
variant: {
|
||||
control: 'select',
|
||||
options: ['primary', 'warning', 'secondary', 'secondary-accent', 'ghost', 'ghost-accent', 'tertiary'],
|
||||
},
|
||||
},
|
||||
args: {
|
||||
variant: 'ghost',
|
||||
children: 'Button',
|
||||
},
|
||||
} satisfies Meta<typeof Button>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
variant: 'primary',
|
||||
loading: false,
|
||||
children: 'Primary Button',
|
||||
styleCss: {},
|
||||
spinnerClassName: '',
|
||||
destructive: false,
|
||||
},
|
||||
}
|
||||
|
||||
export const Secondary: Story = {
|
||||
args: {
|
||||
variant: 'secondary',
|
||||
children: 'Secondary Button',
|
||||
},
|
||||
}
|
||||
|
||||
export const SecondaryAccent: Story = {
|
||||
args: {
|
||||
variant: 'secondary-accent',
|
||||
children: 'Secondary Accent Button',
|
||||
},
|
||||
}
|
||||
|
||||
export const Ghost: Story = {
|
||||
args: {
|
||||
variant: 'ghost',
|
||||
children: 'Ghost Button',
|
||||
},
|
||||
}
|
||||
|
||||
export const GhostAccent: Story = {
|
||||
args: {
|
||||
variant: 'ghost-accent',
|
||||
children: 'Ghost Accent Button',
|
||||
},
|
||||
}
|
||||
|
||||
export const Tertiary: Story = {
|
||||
args: {
|
||||
variant: 'tertiary',
|
||||
children: 'Tertiary Button',
|
||||
},
|
||||
}
|
||||
|
||||
export const Warning: Story = {
|
||||
args: {
|
||||
variant: 'warning',
|
||||
children: 'Warning Button',
|
||||
},
|
||||
}
|
||||
|
||||
export const Disabled: Story = {
|
||||
args: {
|
||||
variant: 'primary',
|
||||
disabled: true,
|
||||
children: 'Disabled Button',
|
||||
},
|
||||
}
|
||||
|
||||
export const Loading: Story = {
|
||||
args: {
|
||||
variant: 'primary',
|
||||
loading: true,
|
||||
children: 'Loading Button',
|
||||
},
|
||||
}
|
||||
|
||||
export const WithIcon: Story = {
|
||||
args: {
|
||||
variant: 'primary',
|
||||
children: (
|
||||
<>
|
||||
<RocketLaunchIcon className="mr-1.5 h-4 w-4 stroke-[1.8px]" />
|
||||
Launch
|
||||
</>
|
||||
),
|
||||
},
|
||||
}
|
||||
61
dify/web/app/components/base/button/index.tsx
Normal file
61
dify/web/app/components/base/button/index.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import type { CSSProperties } from 'react'
|
||||
import React from 'react'
|
||||
import { type VariantProps, cva } from 'class-variance-authority'
|
||||
import Spinner from '../spinner'
|
||||
import classNames from '@/utils/classnames'
|
||||
|
||||
const buttonVariants = cva(
|
||||
'btn disabled:btn-disabled',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
'primary': 'btn-primary',
|
||||
'warning': 'btn-warning',
|
||||
'secondary': 'btn-secondary',
|
||||
'secondary-accent': 'btn-secondary-accent',
|
||||
'ghost': 'btn-ghost',
|
||||
'ghost-accent': 'btn-ghost-accent',
|
||||
'tertiary': 'btn-tertiary',
|
||||
},
|
||||
size: {
|
||||
small: 'btn-small',
|
||||
medium: 'btn-medium',
|
||||
large: 'btn-large',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'secondary',
|
||||
size: 'medium',
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
export type ButtonProps = {
|
||||
destructive?: boolean
|
||||
loading?: boolean
|
||||
styleCss?: CSSProperties
|
||||
spinnerClassName?: string
|
||||
ref?: React.Ref<HTMLButtonElement>
|
||||
} & React.ButtonHTMLAttributes<HTMLButtonElement> & VariantProps<typeof buttonVariants>
|
||||
|
||||
const Button = ({ className, variant, size, destructive, loading, styleCss, children, spinnerClassName, ref, ...props }: ButtonProps) => {
|
||||
return (
|
||||
<button
|
||||
type='button'
|
||||
className={classNames(
|
||||
buttonVariants({ variant, size, className }),
|
||||
destructive && 'btn-destructive',
|
||||
)}
|
||||
ref={ref}
|
||||
style={styleCss}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{loading && <Spinner loading={loading} className={classNames('!ml-1 !h-3 !w-3 !border-2 !text-white', spinnerClassName)} />}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
Button.displayName = 'Button'
|
||||
|
||||
export default Button
|
||||
export { Button, buttonVariants }
|
||||
57
dify/web/app/components/base/button/sync-button.stories.tsx
Normal file
57
dify/web/app/components/base/button/sync-button.stories.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import type { Meta, StoryObj } from '@storybook/nextjs'
|
||||
import SyncButton from './sync-button'
|
||||
|
||||
const meta = {
|
||||
title: 'Base/General/SyncButton',
|
||||
component: SyncButton,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Icon-only refresh button that surfaces a tooltip and is used for manual sync actions across the UI.',
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
className: {
|
||||
control: 'text',
|
||||
description: 'Additional classes appended to the clickable container.',
|
||||
},
|
||||
popupContent: {
|
||||
control: 'text',
|
||||
description: 'Tooltip text shown on hover.',
|
||||
},
|
||||
onClick: {
|
||||
control: false,
|
||||
description: 'Triggered when the sync button is pressed.',
|
||||
},
|
||||
},
|
||||
args: {
|
||||
popupContent: 'Sync now',
|
||||
onClick: () => console.log('Sync button clicked'),
|
||||
},
|
||||
} satisfies Meta<typeof SyncButton>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
className: 'bg-white/80 shadow-sm backdrop-blur-sm',
|
||||
},
|
||||
}
|
||||
|
||||
export const InHeader: Story = {
|
||||
render: args => (
|
||||
<div className="flex items-center gap-2 rounded-lg border border-divider-subtle bg-components-panel-bg p-3">
|
||||
<span className="text-xs text-text-tertiary">Logs</span>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<SyncButton {...args} />
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
args: {
|
||||
popupContent: 'Refresh logs',
|
||||
},
|
||||
}
|
||||
27
dify/web/app/components/base/button/sync-button.tsx
Normal file
27
dify/web/app/components/base/button/sync-button.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React from 'react'
|
||||
import { RiRefreshLine } from '@remixicon/react'
|
||||
import cn from '@/utils/classnames'
|
||||
import TooltipPlus from '@/app/components/base/tooltip'
|
||||
|
||||
type Props = {
|
||||
className?: string,
|
||||
popupContent?: string,
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
const SyncButton: FC<Props> = ({
|
||||
className,
|
||||
popupContent = '',
|
||||
onClick,
|
||||
}) => {
|
||||
return (
|
||||
<TooltipPlus popupContent={popupContent}>
|
||||
<div className={cn(className, 'cursor-pointer select-none rounded-md p-1 hover:bg-state-base-hover')} onClick={onClick}>
|
||||
<RiRefreshLine className='h-4 w-4 text-text-tertiary' />
|
||||
</div>
|
||||
</TooltipPlus>
|
||||
)
|
||||
}
|
||||
export default React.memo(SyncButton)
|
||||
Reference in New Issue
Block a user