From e3f64af5d3a9569fb2f29679fb4072e8d3fc88bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9D=9E=E6=B3=95=E6=93=8D=E4=BD=9C?= Date: Wed, 29 Oct 2025 14:33:43 +0800 Subject: [PATCH] chore: add more stories (#27403) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- web/.storybook/utils/form-story-wrapper.tsx | 83 +++ .../base/action-button/index.stories.tsx | 2 +- .../base/agent-log-modal/index.stories.tsx | 146 +++++ .../base/answer-icon/index.stories.tsx | 107 ++++ .../base/app-icon-picker/index.stories.tsx | 91 +++ .../base/app-icon/index.stories.tsx | 108 ++++ .../base/audio-btn/index.stories.tsx | 2 +- .../base/audio-gallery/index.stories.tsx | 37 ++ .../auto-height-textarea/index.stories.tsx | 2 +- .../components/base/avatar/index.stories.tsx | 73 +++ .../components/base/badge/index.stories.tsx | 73 +++ .../base/block-input/index.stories.tsx | 2 +- .../base/button/add-button.stories.tsx | 2 +- .../components/base/button/index.stories.tsx | 2 +- .../base/button/sync-button.stories.tsx | 2 +- .../base/chat/chat/answer/index.stories.tsx | 2 +- .../base/chat/chat/question.stories.tsx | 2 +- .../base/checkbox/index.stories.tsx | 2 +- .../components/base/chip/index.stories.tsx | 88 +++ .../components/base/confirm/index.stories.tsx | 2 +- .../base/content-dialog/index.stories.tsx | 6 +- .../base/copy-feedback/index.stories.tsx | 54 ++ .../base/copy-icon/index.stories.tsx | 68 +++ .../base/corner-label/index.stories.tsx | 53 ++ .../date-and-time-picker/index.stories.tsx | 101 ++++ .../components/base/dialog/index.stories.tsx | 3 +- .../components/base/divider/index.stories.tsx | 46 ++ .../base/drawer-plus/index.stories.tsx | 124 ++++ .../components/base/drawer/index.stories.tsx | 114 ++++ .../base/dropdown/index.stories.tsx | 85 +++ .../components/base/effect/index.stories.tsx | 39 ++ .../base/emoji-picker/Inner.stories.tsx | 57 ++ .../base/emoji-picker/index.stories.tsx | 91 +++ .../base/features/index.stories.tsx | 73 +++ .../base/file-icon/index.stories.tsx | 79 +++ .../file-image-render.stories.tsx | 32 + .../base/file-uploader/file-list.stories.tsx | 96 +++ .../file-uploader/file-type-icon.stories.tsx | 38 ++ .../index.stories.tsx | 110 ++++ .../index.stories.tsx | 95 +++ .../float-right-container/index.stories.tsx | 74 +++ .../components/base/form/index.stories.tsx | 559 ++++++++++++++++++ .../base/fullscreen-modal/index.stories.tsx | 59 ++ .../base/grid-mask/index.stories.tsx | 51 ++ .../base/image-gallery/index.stories.tsx | 39 ++ .../image-uploader/image-list.stories.tsx | 182 ++++++ .../inline-delete-confirm/index.stories.tsx | 87 +++ .../base/input-number/index.stories.tsx | 2 +- .../components/base/input/index.stories.tsx | 2 +- .../base/linked-apps-panel/index.stories.tsx | 72 +++ .../base/list-empty/index.stories.tsx | 49 ++ .../components/base/loading/index.stories.tsx | 52 ++ .../components/base/logo/index.stories.tsx | 82 +++ .../markdown-blocks/code-block.stories.tsx | 70 +++ .../markdown-blocks/think-block.stories.tsx | 78 +++ .../base/markdown/index.stories.tsx | 88 +++ .../components/base/mermaid/index.stories.tsx | 64 ++ .../base/message-log-modal/index.stories.tsx | 185 ++++++ .../base/modal-like-wrap/index.stories.tsx | 2 +- .../components/base/modal/index.stories.tsx | 2 +- .../components/base/modal/modal.stories.tsx | 2 +- .../base/new-audio-button/index.stories.tsx | 2 +- .../base/notion-connector/index.stories.tsx | 26 + .../base/notion-icon/index.stories.tsx | 129 ++++ .../notion-page-selector/index.stories.tsx | 200 +++++++ .../base/pagination/index.stories.tsx | 81 +++ .../base/param-item/index.stories.tsx | 121 ++++ .../components/base/popover/index.stories.tsx | 120 ++++ .../portal-to-follow-elem/index.stories.tsx | 103 ++++ .../base/premium-badge/index.stories.tsx | 64 ++ .../progress-bar/progress-circle.stories.tsx | 89 +++ .../base/prompt-editor/index.stories.tsx | 2 +- .../base/prompt-log-modal/index.stories.tsx | 74 +++ .../components/base/qrcode/index.stories.tsx | 52 ++ .../base/radio-card/index.stories.tsx | 2 +- .../components/base/radio/index.stories.tsx | 2 +- .../base/search-input/index.stories.tsx | 2 +- .../base/segmented-control/index.stories.tsx | 92 +++ .../components/base/select/index.stories.tsx | 2 +- .../base/simple-pie-chart/index.stories.tsx | 89 +++ .../base/skeleton/index.stories.tsx | 59 ++ .../components/base/slider/index.stories.tsx | 2 +- .../components/base/sort/index.stories.tsx | 59 ++ .../components/base/spinner/index.stories.tsx | 50 ++ .../base/svg-gallery/index.stories.tsx | 51 ++ web/app/components/base/svg/index.stories.tsx | 36 ++ .../components/base/switch/index.stories.tsx | 2 +- .../base/tab-header/index.stories.tsx | 64 ++ .../base/tab-slider-new/index.stories.tsx | 52 ++ .../base/tab-slider-plain/index.stories.tsx | 56 ++ .../base/tab-slider/index.stories.tsx | 93 +++ .../base/tag-input/index.stories.tsx | 2 +- .../base/tag-management/index.stories.tsx | 131 ++++ web/app/components/base/tag/index.stories.tsx | 62 ++ .../base/textarea/index.stories.tsx | 2 +- .../components/base/toast/index.stories.tsx | 104 ++++ .../components/base/tooltip/index.stories.tsx | 60 ++ .../base/video-gallery/index.stories.tsx | 40 ++ .../base/voice-input/index.stories.tsx | 2 +- .../with-input-validation/index.stories.tsx | 2 +- 100 files changed, 6144 insertions(+), 30 deletions(-) create mode 100644 web/.storybook/utils/form-story-wrapper.tsx create mode 100644 web/app/components/base/agent-log-modal/index.stories.tsx create mode 100644 web/app/components/base/answer-icon/index.stories.tsx create mode 100644 web/app/components/base/app-icon-picker/index.stories.tsx create mode 100644 web/app/components/base/app-icon/index.stories.tsx create mode 100644 web/app/components/base/audio-gallery/index.stories.tsx create mode 100644 web/app/components/base/avatar/index.stories.tsx create mode 100644 web/app/components/base/badge/index.stories.tsx create mode 100644 web/app/components/base/chip/index.stories.tsx create mode 100644 web/app/components/base/copy-feedback/index.stories.tsx create mode 100644 web/app/components/base/copy-icon/index.stories.tsx create mode 100644 web/app/components/base/corner-label/index.stories.tsx create mode 100644 web/app/components/base/date-and-time-picker/index.stories.tsx create mode 100644 web/app/components/base/divider/index.stories.tsx create mode 100644 web/app/components/base/drawer-plus/index.stories.tsx create mode 100644 web/app/components/base/drawer/index.stories.tsx create mode 100644 web/app/components/base/dropdown/index.stories.tsx create mode 100644 web/app/components/base/effect/index.stories.tsx create mode 100644 web/app/components/base/emoji-picker/Inner.stories.tsx create mode 100644 web/app/components/base/emoji-picker/index.stories.tsx create mode 100644 web/app/components/base/features/index.stories.tsx create mode 100644 web/app/components/base/file-icon/index.stories.tsx create mode 100644 web/app/components/base/file-uploader/file-image-render.stories.tsx create mode 100644 web/app/components/base/file-uploader/file-list.stories.tsx create mode 100644 web/app/components/base/file-uploader/file-type-icon.stories.tsx create mode 100644 web/app/components/base/file-uploader/file-uploader-in-attachment/index.stories.tsx create mode 100644 web/app/components/base/file-uploader/file-uploader-in-chat-input/index.stories.tsx create mode 100644 web/app/components/base/float-right-container/index.stories.tsx create mode 100644 web/app/components/base/form/index.stories.tsx create mode 100644 web/app/components/base/fullscreen-modal/index.stories.tsx create mode 100644 web/app/components/base/grid-mask/index.stories.tsx create mode 100644 web/app/components/base/image-gallery/index.stories.tsx create mode 100644 web/app/components/base/image-uploader/image-list.stories.tsx create mode 100644 web/app/components/base/inline-delete-confirm/index.stories.tsx create mode 100644 web/app/components/base/linked-apps-panel/index.stories.tsx create mode 100644 web/app/components/base/list-empty/index.stories.tsx create mode 100644 web/app/components/base/loading/index.stories.tsx create mode 100644 web/app/components/base/logo/index.stories.tsx create mode 100644 web/app/components/base/markdown-blocks/code-block.stories.tsx create mode 100644 web/app/components/base/markdown-blocks/think-block.stories.tsx create mode 100644 web/app/components/base/markdown/index.stories.tsx create mode 100644 web/app/components/base/mermaid/index.stories.tsx create mode 100644 web/app/components/base/message-log-modal/index.stories.tsx create mode 100644 web/app/components/base/notion-connector/index.stories.tsx create mode 100644 web/app/components/base/notion-icon/index.stories.tsx create mode 100644 web/app/components/base/notion-page-selector/index.stories.tsx create mode 100644 web/app/components/base/pagination/index.stories.tsx create mode 100644 web/app/components/base/param-item/index.stories.tsx create mode 100644 web/app/components/base/popover/index.stories.tsx create mode 100644 web/app/components/base/portal-to-follow-elem/index.stories.tsx create mode 100644 web/app/components/base/premium-badge/index.stories.tsx create mode 100644 web/app/components/base/progress-bar/progress-circle.stories.tsx create mode 100644 web/app/components/base/prompt-log-modal/index.stories.tsx create mode 100644 web/app/components/base/qrcode/index.stories.tsx create mode 100644 web/app/components/base/segmented-control/index.stories.tsx create mode 100644 web/app/components/base/simple-pie-chart/index.stories.tsx create mode 100644 web/app/components/base/skeleton/index.stories.tsx create mode 100644 web/app/components/base/sort/index.stories.tsx create mode 100644 web/app/components/base/spinner/index.stories.tsx create mode 100644 web/app/components/base/svg-gallery/index.stories.tsx create mode 100644 web/app/components/base/svg/index.stories.tsx create mode 100644 web/app/components/base/tab-header/index.stories.tsx create mode 100644 web/app/components/base/tab-slider-new/index.stories.tsx create mode 100644 web/app/components/base/tab-slider-plain/index.stories.tsx create mode 100644 web/app/components/base/tab-slider/index.stories.tsx create mode 100644 web/app/components/base/tag-management/index.stories.tsx create mode 100644 web/app/components/base/tag/index.stories.tsx create mode 100644 web/app/components/base/toast/index.stories.tsx create mode 100644 web/app/components/base/tooltip/index.stories.tsx create mode 100644 web/app/components/base/video-gallery/index.stories.tsx diff --git a/web/.storybook/utils/form-story-wrapper.tsx b/web/.storybook/utils/form-story-wrapper.tsx new file mode 100644 index 0000000000..689c3a20ff --- /dev/null +++ b/web/.storybook/utils/form-story-wrapper.tsx @@ -0,0 +1,83 @@ +import { useState } from 'react' +import type { ReactNode } from 'react' +import { useStore } from '@tanstack/react-form' +import { useAppForm } from '@/app/components/base/form' + +type UseAppFormOptions = Parameters[0] +type AppFormInstance = ReturnType + +type FormStoryWrapperProps = { + options?: UseAppFormOptions + children: (form: AppFormInstance) => ReactNode + title?: string + subtitle?: string +} + +export const FormStoryWrapper = ({ + options, + children, + title, + subtitle, +}: FormStoryWrapperProps) => { + const [lastSubmitted, setLastSubmitted] = useState(null) + const [submitCount, setSubmitCount] = useState(0) + + const form = useAppForm({ + ...options, + onSubmit: (context) => { + setSubmitCount(count => count + 1) + setLastSubmitted(context.value) + options?.onSubmit?.(context) + }, + }) + + const values = useStore(form.store, state => state.values) + const isSubmitting = useStore(form.store, state => state.isSubmitting) + const canSubmit = useStore(form.store, state => state.canSubmit) + + return ( +
+
+ {(title || subtitle) && ( +
+ {title &&

{title}

} + {subtitle &&

{subtitle}

} +
+ )} + {children(form)} +
+ +
+ ) +} + +export type FormStoryRender = (form: AppFormInstance) => ReactNode diff --git a/web/app/components/base/action-button/index.stories.tsx b/web/app/components/base/action-button/index.stories.tsx index dd826c41ba..07e0592374 100644 --- a/web/app/components/base/action-button/index.stories.tsx +++ b/web/app/components/base/action-button/index.stories.tsx @@ -3,7 +3,7 @@ import { RiAddLine, RiDeleteBinLine, RiEditLine, RiMore2Fill, RiSaveLine, RiShar import ActionButton, { ActionButtonState } from '.' const meta = { - title: 'Base/Button/ActionButton', + title: 'Base/General/ActionButton', component: ActionButton, parameters: { layout: 'centered', diff --git a/web/app/components/base/agent-log-modal/index.stories.tsx b/web/app/components/base/agent-log-modal/index.stories.tsx new file mode 100644 index 0000000000..b512c8c581 --- /dev/null +++ b/web/app/components/base/agent-log-modal/index.stories.tsx @@ -0,0 +1,146 @@ +import type { Meta, StoryObj } from '@storybook/nextjs' +import { useEffect, useRef } from 'react' +import AgentLogModal from '.' +import { ToastProvider } from '@/app/components/base/toast' +import { useStore as useAppStore } from '@/app/components/app/store' +import type { IChatItem } from '@/app/components/base/chat/chat/type' +import type { AgentLogDetailResponse } from '@/models/log' + +const MOCK_RESPONSE: AgentLogDetailResponse = { + meta: { + status: 'finished', + executor: 'Agent Runner', + start_time: '2024-03-12T10:00:00Z', + elapsed_time: 12.45, + total_tokens: 2589, + agent_mode: 'ReACT', + iterations: 2, + error: undefined, + }, + iterations: [ + { + created_at: '2024-03-12T10:00:05Z', + files: [], + thought: JSON.stringify({ reasoning: 'Summarise conversation' }, null, 2), + tokens: 934, + tool_calls: [ + { + status: 'success', + tool_icon: null, + tool_input: { query: 'Latest revenue numbers' }, + tool_output: { answer: 'Revenue up 12% QoQ' }, + tool_name: 'search', + tool_label: { + 'en-US': 'Revenue Search', + }, + time_cost: 1.8, + }, + ], + tool_raw: { + inputs: JSON.stringify({ context: 'Summaries' }, null, 2), + outputs: JSON.stringify({ observation: 'Revenue up 12% QoQ' }, null, 2), + }, + }, + { + created_at: '2024-03-12T10:00:09Z', + files: [], + thought: JSON.stringify({ final: 'Revenue increased 12% quarter-over-quarter.' }, null, 2), + tokens: 642, + tool_calls: [], + tool_raw: { + inputs: JSON.stringify({ context: 'Compose summary' }, null, 2), + outputs: JSON.stringify({ observation: 'Final answer ready' }, null, 2), + }, + }, + ], + files: [], +} + +const MOCK_CHAT_ITEM: IChatItem = { + id: 'message-1', + content: JSON.stringify({ answer: 'Revenue grew 12% QoQ.' }, null, 2), + input: JSON.stringify({ question: 'Summarise revenue trends.' }, null, 2), + isAnswer: true, + conversationId: 'conv-123', +} + +const AgentLogModalDemo = ({ + width = 960, +}: { + width?: number +}) => { + const originalFetchRef = useRef(null) + const setAppDetail = useAppStore(state => state.setAppDetail) + + useEffect(() => { + setAppDetail({ + id: 'app-1', + name: 'Analytics Agent', + mode: 'agent-chat', + } as any) + + originalFetchRef.current = globalThis.fetch?.bind(globalThis) + + const handler = async (input: RequestInfo | URL, init?: RequestInit) => { + const request = input instanceof Request ? input : new Request(input, init) + const url = request.url + const parsed = new URL(url, window.location.origin) + + if (parsed.pathname.endsWith('/apps/app-1/agent/logs')) { + return new Response(JSON.stringify(MOCK_RESPONSE), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + } + + if (originalFetchRef.current) + return originalFetchRef.current(request) + + throw new Error(`Unhandled request: ${url}`) + } + + globalThis.fetch = handler as typeof globalThis.fetch + + return () => { + if (originalFetchRef.current) + globalThis.fetch = originalFetchRef.current + setAppDetail(undefined) + } + }, [setAppDetail]) + + return ( + +
+ { + console.log('Agent log modal closed') + }} + /> +
+
+ ) +} + +const meta = { + title: 'Base/Other/AgentLogModal', + component: AgentLogModalDemo, + parameters: { + layout: 'fullscreen', + docs: { + description: { + component: 'Agent execution viewer showing iterations, tool calls, and metadata. Fetch responses are mocked for Storybook.', + }, + }, + }, + args: { + width: 960, + }, + tags: ['autodocs'], +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Playground: Story = {} diff --git a/web/app/components/base/answer-icon/index.stories.tsx b/web/app/components/base/answer-icon/index.stories.tsx new file mode 100644 index 0000000000..0928d9cda6 --- /dev/null +++ b/web/app/components/base/answer-icon/index.stories.tsx @@ -0,0 +1,107 @@ +import type { Meta, StoryObj } from '@storybook/nextjs' +import type { ReactNode } from 'react' +import AnswerIcon from '.' + +const SAMPLE_IMAGE = 'data:image/svg+xml;utf8,AI' + +const meta = { + title: 'Base/General/AnswerIcon', + component: AnswerIcon, + parameters: { + docs: { + description: { + component: 'Circular avatar used for assistant answers. Supports emoji, solid background colour, or uploaded imagery.', + }, + }, + }, + tags: ['autodocs'], + args: { + icon: '🤖', + background: '#D5F5F6', + }, +} satisfies Meta + +export default meta +type Story = StoryObj + +const StoryWrapper = (children: ReactNode) => ( +
+ {children} +
+) + +export const Default: Story = { + render: args => StoryWrapper( +
+ +
, + ), + parameters: { + docs: { + source: { + language: 'tsx', + code: ` +
+ +
+ `.trim(), + }, + }, + }, +} + +export const CustomEmoji: Story = { + render: args => StoryWrapper( + <> +
+ +
+
+ +
+ , + ), + parameters: { + docs: { + source: { + language: 'tsx', + code: ` +
+
+ +
+
+ +
+
+ `.trim(), + }, + }, + }, +} + +export const ImageIcon: Story = { + render: args => StoryWrapper( +
+ +
, + ), + parameters: { + docs: { + source: { + language: 'tsx', + code: ` + + `.trim(), + }, + }, + }, +} diff --git a/web/app/components/base/app-icon-picker/index.stories.tsx b/web/app/components/base/app-icon-picker/index.stories.tsx new file mode 100644 index 0000000000..bd0ec0e200 --- /dev/null +++ b/web/app/components/base/app-icon-picker/index.stories.tsx @@ -0,0 +1,91 @@ +import type { Meta, StoryObj } from '@storybook/nextjs' +import { useState } from 'react' +import AppIconPicker, { type AppIconSelection } from '.' + +const meta = { + title: 'Base/Data Entry/AppIconPicker', + component: AppIconPicker, + parameters: { + layout: 'fullscreen', + docs: { + description: { + component: 'Modal workflow for choosing an application avatar. Users can switch between emoji selections and image uploads (when enabled).', + }, + }, + nextjs: { + appDirectory: true, + navigation: { + pathname: '/apps/demo-app/icon-picker', + params: { appId: 'demo-app' }, + }, + }, + }, + tags: ['autodocs'], +} satisfies Meta + +export default meta +type Story = StoryObj + +const AppIconPickerDemo = () => { + const [open, setOpen] = useState(false) + const [selection, setSelection] = useState(null) + + return ( +
+ + +
+
Selection preview
+
+          {selection ? JSON.stringify(selection, null, 2) : 'No icon selected yet.'}
+        
+
+ + {open && ( + { + setSelection(result) + setOpen(false) + }} + onClose={() => setOpen(false)} + /> + )} +
+ ) +} + +export const Playground: Story = { + render: () => , + parameters: { + docs: { + source: { + language: 'tsx', + code: ` +const [open, setOpen] = useState(false) +const [selection, setSelection] = useState(null) + +return ( + <> + + {open && ( + { + setSelection(result) + setOpen(false) + }} + onClose={() => setOpen(false)} + /> + )} + +) + `.trim(), + }, + }, + }, +} diff --git a/web/app/components/base/app-icon/index.stories.tsx b/web/app/components/base/app-icon/index.stories.tsx new file mode 100644 index 0000000000..9fdffb54b0 --- /dev/null +++ b/web/app/components/base/app-icon/index.stories.tsx @@ -0,0 +1,108 @@ +import type { Meta, StoryObj } from '@storybook/nextjs' +import type { ComponentProps } from 'react' +import AppIcon from '.' + +const meta = { + title: 'Base/General/AppIcon', + component: AppIcon, + parameters: { + docs: { + description: { + component: 'Reusable avatar for applications and workflows. Supports emoji or uploaded imagery, rounded mode, edit overlays, and multiple sizes.', + }, + }, + }, + tags: ['autodocs'], + args: { + icon: '🧭', + background: '#FFEAD5', + size: 'medium', + rounded: false, + }, +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Default: Story = { + render: args => ( +
+ + +
+ ), + parameters: { + docs: { + source: { + language: 'tsx', + code: ` + + + `.trim(), + }, + }, + }, +} + +export const Sizes: Story = { + render: (args) => { + const sizes: Array['size']> = ['xs', 'tiny', 'small', 'medium', 'large', 'xl', 'xxl'] + return ( +
+ {sizes.map(size => ( +
+ + {size} +
+ ))} +
+ ) + }, + parameters: { + docs: { + source: { + language: 'tsx', + code: ` +{(['xs','tiny','small','medium','large','xl','xxl'] as const).map(size => ( + +))} + `.trim(), + }, + }, + }, +} + +export const WithEditOverlay: Story = { + render: args => ( +
+ + +
+ ), + parameters: { + docs: { + source: { + language: 'tsx', + code: ` + + + `.trim(), + }, + }, + }, +} diff --git a/web/app/components/base/audio-btn/index.stories.tsx b/web/app/components/base/audio-btn/index.stories.tsx index 8dc82d3413..1c989b80a6 100644 --- a/web/app/components/base/audio-btn/index.stories.tsx +++ b/web/app/components/base/audio-btn/index.stories.tsx @@ -20,7 +20,7 @@ const StoryWrapper = (props: ComponentProps) => { } const meta = { - title: 'Base/Button/AudioBtn', + title: 'Base/General/AudioBtn', component: AudioBtn, tags: ['autodocs'], parameters: { diff --git a/web/app/components/base/audio-gallery/index.stories.tsx b/web/app/components/base/audio-gallery/index.stories.tsx new file mode 100644 index 0000000000..539ab9e332 --- /dev/null +++ b/web/app/components/base/audio-gallery/index.stories.tsx @@ -0,0 +1,37 @@ +import type { Meta, StoryObj } from '@storybook/nextjs' +import AudioGallery from '.' + +const AUDIO_SOURCES = [ + 'https://interactive-examples.mdn.mozilla.net/media/cc0-audio/t-rex-roar.mp3', +] + +const meta = { + title: 'Base/Data Display/AudioGallery', + component: AudioGallery, + parameters: { + docs: { + description: { + component: 'List of audio players that render waveform previews and playback controls for each source.', + }, + source: { + language: 'tsx', + code: ` + + `.trim(), + }, + }, + }, + tags: ['autodocs'], + args: { + srcs: AUDIO_SOURCES, + }, +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Default: Story = {} diff --git a/web/app/components/base/auto-height-textarea/index.stories.tsx b/web/app/components/base/auto-height-textarea/index.stories.tsx index a9234fac9d..d0f36e4736 100644 --- a/web/app/components/base/auto-height-textarea/index.stories.tsx +++ b/web/app/components/base/auto-height-textarea/index.stories.tsx @@ -3,7 +3,7 @@ import { useState } from 'react' import AutoHeightTextarea from '.' const meta = { - title: 'Base/Input/AutoHeightTextarea', + title: 'Base/Data Entry/AutoHeightTextarea', component: AutoHeightTextarea, parameters: { layout: 'centered', diff --git a/web/app/components/base/avatar/index.stories.tsx b/web/app/components/base/avatar/index.stories.tsx new file mode 100644 index 0000000000..1b3dc3eb3b --- /dev/null +++ b/web/app/components/base/avatar/index.stories.tsx @@ -0,0 +1,73 @@ +import type { Meta, StoryObj } from '@storybook/nextjs' +import Avatar from '.' + +const meta = { + title: 'Base/Data Display/Avatar', + component: Avatar, + parameters: { + docs: { + description: { + component: 'Initials or image-based avatar used across contacts and member lists. Falls back to the first letter when the image fails to load.', + }, + source: { + language: 'tsx', + code: ` + + `.trim(), + }, + }, + }, + tags: ['autodocs'], + args: { + name: 'Alex Doe', + avatar: 'https://cloud.dify.ai/logo/logo.svg', + size: 40, + }, +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Default: Story = {} + +export const WithFallback: Story = { + args: { + avatar: null, + name: 'Fallback', + }, + parameters: { + docs: { + source: { + language: 'tsx', + code: ` + + `.trim(), + }, + }, + }, +} + +export const CustomSizes: Story = { + render: args => ( +
+ {[24, 32, 48, 64].map(size => ( +
+ + {size}px +
+ ))} +
+ ), + parameters: { + docs: { + source: { + language: 'tsx', + code: ` +{[24, 32, 48, 64].map(size => ( + +))} + `.trim(), + }, + }, + }, +} diff --git a/web/app/components/base/badge/index.stories.tsx b/web/app/components/base/badge/index.stories.tsx new file mode 100644 index 0000000000..e1fe8cb271 --- /dev/null +++ b/web/app/components/base/badge/index.stories.tsx @@ -0,0 +1,73 @@ +import type { Meta, StoryObj } from '@storybook/nextjs' +import Badge from '../badge' + +const meta = { + title: 'Base/Data Display/Badge', + component: Badge, + parameters: { + docs: { + description: { + component: 'Compact label used for statuses and counts. Supports uppercase styling and optional red corner marks.', + }, + source: { + language: 'tsx', + code: ` + + `.trim(), + }, + }, + }, + tags: ['autodocs'], + args: { + text: 'beta', + uppercase: true, + }, +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Default: Story = {} + +export const WithCornerMark: Story = { + args: { + text: 'new', + hasRedCornerMark: true, + }, + parameters: { + docs: { + source: { + language: 'tsx', + code: ` + + `.trim(), + }, + }, + }, +} + +export const CustomContent: Story = { + render: args => ( + + + + Production + + + ), + parameters: { + docs: { + source: { + language: 'tsx', + code: ` + + + + Production + + + `.trim(), + }, + }, + }, +} diff --git a/web/app/components/base/block-input/index.stories.tsx b/web/app/components/base/block-input/index.stories.tsx index 5f1967b9d0..d05cc221b6 100644 --- a/web/app/components/base/block-input/index.stories.tsx +++ b/web/app/components/base/block-input/index.stories.tsx @@ -3,7 +3,7 @@ import { useState } from 'react' import BlockInput from '.' const meta = { - title: 'Base/Input/BlockInput', + title: 'Base/Data Entry/BlockInput', component: BlockInput, parameters: { layout: 'centered', diff --git a/web/app/components/base/button/add-button.stories.tsx b/web/app/components/base/button/add-button.stories.tsx index a46441aefe..edd52b2b78 100644 --- a/web/app/components/base/button/add-button.stories.tsx +++ b/web/app/components/base/button/add-button.stories.tsx @@ -2,7 +2,7 @@ import type { Meta, StoryObj } from '@storybook/nextjs' import AddButton from './add-button' const meta = { - title: 'Base/Button/AddButton', + title: 'Base/General/AddButton', component: AddButton, parameters: { layout: 'centered', diff --git a/web/app/components/base/button/index.stories.tsx b/web/app/components/base/button/index.stories.tsx index f369e2f71a..02d20b4af4 100644 --- a/web/app/components/base/button/index.stories.tsx +++ b/web/app/components/base/button/index.stories.tsx @@ -4,7 +4,7 @@ import { RocketLaunchIcon } from '@heroicons/react/20/solid' import { Button } from '.' const meta = { - title: 'Base/Button/Button', + title: 'Base/General/Button', component: Button, parameters: { layout: 'centered', diff --git a/web/app/components/base/button/sync-button.stories.tsx b/web/app/components/base/button/sync-button.stories.tsx index d55a7acf47..dcfbf6daf3 100644 --- a/web/app/components/base/button/sync-button.stories.tsx +++ b/web/app/components/base/button/sync-button.stories.tsx @@ -2,7 +2,7 @@ import type { Meta, StoryObj } from '@storybook/nextjs' import SyncButton from './sync-button' const meta = { - title: 'Base/Button/SyncButton', + title: 'Base/General/SyncButton', component: SyncButton, parameters: { layout: 'centered', diff --git a/web/app/components/base/chat/chat/answer/index.stories.tsx b/web/app/components/base/chat/chat/answer/index.stories.tsx index 822bdf7326..95bc3bd5c0 100644 --- a/web/app/components/base/chat/chat/answer/index.stories.tsx +++ b/web/app/components/base/chat/chat/answer/index.stories.tsx @@ -6,7 +6,7 @@ import { markdownContentSVG } from './__mocks__/markdownContentSVG' import Answer from '.' const meta = { - title: 'Base/Chat/Chat Answer', + title: 'Base/Other/Chat Answer', component: Answer, parameters: { layout: 'fullscreen', diff --git a/web/app/components/base/chat/chat/question.stories.tsx b/web/app/components/base/chat/chat/question.stories.tsx index 0b84ee91a8..f0ee860c89 100644 --- a/web/app/components/base/chat/chat/question.stories.tsx +++ b/web/app/components/base/chat/chat/question.stories.tsx @@ -5,7 +5,7 @@ import Question from './question' import { User } from '@/app/components/base/icons/src/public/avatar' const meta = { - title: 'Base/Chat/Chat Question', + title: 'Base/Other/Chat Question', component: Question, parameters: { layout: 'centered', diff --git a/web/app/components/base/checkbox/index.stories.tsx b/web/app/components/base/checkbox/index.stories.tsx index ba928baa6f..3f8d4606eb 100644 --- a/web/app/components/base/checkbox/index.stories.tsx +++ b/web/app/components/base/checkbox/index.stories.tsx @@ -13,7 +13,7 @@ const createToggleItem = ( } const meta = { - title: 'Base/Input/Checkbox', + title: 'Base/Data Entry/Checkbox', component: Checkbox, parameters: { layout: 'centered', diff --git a/web/app/components/base/chip/index.stories.tsx b/web/app/components/base/chip/index.stories.tsx new file mode 100644 index 0000000000..0ea018ef95 --- /dev/null +++ b/web/app/components/base/chip/index.stories.tsx @@ -0,0 +1,88 @@ +import type { Meta, StoryObj } from '@storybook/nextjs' +import { useState } from 'react' +import Chip, { type Item } from '.' + +const ITEMS: Item[] = [ + { value: 'all', name: 'All items' }, + { value: 'active', name: 'Active' }, + { value: 'archived', name: 'Archived' }, + { value: 'draft', name: 'Drafts' }, +] + +const meta = { + title: 'Base/Data Entry/Chip', + component: Chip, + parameters: { + docs: { + description: { + component: 'Filter chip with dropdown panel and optional left icon. Commonly used for status pickers in toolbars.', + }, + }, + }, + tags: ['autodocs'], + args: { + items: ITEMS, + value: 'all', + }, +} satisfies Meta + +export default meta +type Story = StoryObj + +const ChipDemo = (props: React.ComponentProps) => { + const [selection, setSelection] = useState(props.value) + + return ( +
+ setSelection(item.value)} + onClear={() => setSelection('all')} + /> +
+ Current value: {selection} +
+
+ ) +} + +export const Playground: Story = { + render: args => , + parameters: { + docs: { + source: { + language: 'tsx', + code: ` +const [selection, setSelection] = useState('all') + + setSelection(item.value)} + onClear={() => setSelection('all')} +/> + `.trim(), + }, + }, + }, +} + +export const WithoutLeftIcon: Story = { + render: args => ( + + ), + parameters: { + docs: { + source: { + language: 'tsx', + code: ` + + `.trim(), + }, + }, + }, +} diff --git a/web/app/components/base/confirm/index.stories.tsx b/web/app/components/base/confirm/index.stories.tsx index 9ec21cbd50..12cb46d9e4 100644 --- a/web/app/components/base/confirm/index.stories.tsx +++ b/web/app/components/base/confirm/index.stories.tsx @@ -4,7 +4,7 @@ import Confirm from '.' import Button from '../button' const meta = { - title: 'Base/Dialog/Confirm', + title: 'Base/Feedback/Confirm', component: Confirm, parameters: { layout: 'centered', diff --git a/web/app/components/base/content-dialog/index.stories.tsx b/web/app/components/base/content-dialog/index.stories.tsx index 67781a17a0..aaebcad1b7 100644 --- a/web/app/components/base/content-dialog/index.stories.tsx +++ b/web/app/components/base/content-dialog/index.stories.tsx @@ -5,7 +5,7 @@ import ContentDialog from '.' type Props = React.ComponentProps const meta = { - title: 'Base/Dialog/ContentDialog', + title: 'Base/Feedback/ContentDialog', component: ContentDialog, parameters: { layout: 'fullscreen', @@ -29,6 +29,10 @@ const meta = { control: false, description: 'Invoked when the overlay/backdrop is clicked.', }, + children: { + control: false, + table: { disable: true }, + }, }, args: { show: false, diff --git a/web/app/components/base/copy-feedback/index.stories.tsx b/web/app/components/base/copy-feedback/index.stories.tsx new file mode 100644 index 0000000000..3bab620aec --- /dev/null +++ b/web/app/components/base/copy-feedback/index.stories.tsx @@ -0,0 +1,54 @@ +import type { Meta, StoryObj } from '@storybook/nextjs' +import { useState } from 'react' +import CopyFeedback, { CopyFeedbackNew } from '.' + +const meta = { + title: 'Base/Feedback/CopyFeedback', + component: CopyFeedback, + parameters: { + docs: { + description: { + component: 'Copy-to-clipboard button that shows instant feedback and a tooltip. Includes the original ActionButton wrapper and the newer ghost-button variant.', + }, + }, + }, + tags: ['autodocs'], + args: { + content: 'acc-3f92fa', + }, +} satisfies Meta + +export default meta +type Story = StoryObj + +const CopyDemo = ({ content }: { content: string }) => { + const [value] = useState(content) + return ( +
+
+ Client ID: + {value} + +
+
+ Use the new ghost variant: + +
+
+ ) +} + +export const Playground: Story = { + render: args => , + parameters: { + docs: { + source: { + language: 'tsx', + code: ` + + + `.trim(), + }, + }, + }, +} diff --git a/web/app/components/base/copy-icon/index.stories.tsx b/web/app/components/base/copy-icon/index.stories.tsx new file mode 100644 index 0000000000..5962773792 --- /dev/null +++ b/web/app/components/base/copy-icon/index.stories.tsx @@ -0,0 +1,68 @@ +import type { Meta, StoryObj } from '@storybook/nextjs' +import CopyIcon from '.' + +const meta = { + title: 'Base/General/CopyIcon', + component: CopyIcon, + parameters: { + docs: { + description: { + component: 'Interactive copy-to-clipboard glyph that swaps to a checkmark once the content has been copied. Tooltips rely on the app locale.', + }, + }, + }, + tags: ['autodocs'], + args: { + content: 'https://console.dify.ai/apps/12345', + }, +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Default: Story = { + render: args => ( +
+ Hover or click to copy the app link: + +
+ ), + parameters: { + docs: { + source: { + language: 'tsx', + code: ` +
+ Hover or click to copy the app link: + +
+ `.trim(), + }, + }, + }, +} + +export const InlineUsage: Story = { + render: args => ( +
+

+ Use the copy icon inline with labels or metadata. Clicking the icon copies the value to the clipboard and shows a success tooltip. +

+
+ Client ID + acc-3f92fa + +
+
+ ), + parameters: { + docs: { + source: { + language: 'tsx', + code: ` + + `.trim(), + }, + }, + }, +} diff --git a/web/app/components/base/corner-label/index.stories.tsx b/web/app/components/base/corner-label/index.stories.tsx new file mode 100644 index 0000000000..1592f94259 --- /dev/null +++ b/web/app/components/base/corner-label/index.stories.tsx @@ -0,0 +1,53 @@ +import type { Meta, StoryObj } from '@storybook/nextjs' +import CornerLabel from '.' + +const meta = { + title: 'Base/Data Display/CornerLabel', + component: CornerLabel, + parameters: { + docs: { + description: { + component: 'Decorative label that anchors to card corners. Useful for marking “beta”, “deprecated”, or similar callouts.', + }, + source: { + language: 'tsx', + code: ` + + `.trim(), + }, + }, + }, + tags: ['autodocs'], + args: { + label: 'beta', + }, +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Default: Story = {} + +export const OnCard: Story = { + render: args => ( +
+ +
+ Showcase how the label sits on a card header. Pair with contextual text or status information. +
+
+ ), + parameters: { + docs: { + source: { + language: 'tsx', + code: ` +
+ + ...card content... +
+ `.trim(), + }, + }, + }, +} diff --git a/web/app/components/base/date-and-time-picker/index.stories.tsx b/web/app/components/base/date-and-time-picker/index.stories.tsx new file mode 100644 index 0000000000..1789407d03 --- /dev/null +++ b/web/app/components/base/date-and-time-picker/index.stories.tsx @@ -0,0 +1,101 @@ +import type { Meta, StoryObj } from '@storybook/nextjs' +import { fn } from 'storybook/test' +import { useState } from 'react' +import DatePicker from './date-picker' +import dayjs from './utils/dayjs' +import { getDateWithTimezone } from './utils/dayjs' +import type { DatePickerProps } from './types' + +const meta = { + title: 'Base/Data Entry/DateAndTimePicker', + component: DatePicker, + parameters: { + docs: { + description: { + component: 'Combined date and time picker with timezone support. Includes shortcuts for “now”, year-month navigation, and optional time selection.', + }, + }, + }, + tags: ['autodocs'], + args: { + value: getDateWithTimezone({}), + timezone: dayjs.tz.guess(), + needTimePicker: true, + placeholder: 'Select schedule time', + onChange: fn(), + onClear: fn(), + }, +} satisfies Meta + +export default meta +type Story = StoryObj + +const DatePickerPlayground = (props: DatePickerProps) => { + const [value, setValue] = useState(props.value) + + return ( +
+ setValue(undefined)} + /> +
+ Selected datetime: {value ? value.format() : 'undefined'} +
+
+ ) +} + +export const Playground: Story = { + render: args => , + args: { + ...meta.args, + needTimePicker: false, + placeholder: 'Select due date', + }, + parameters: { + docs: { + source: { + language: 'tsx', + code: ` +const [value, setValue] = useState(getDateWithTimezone({})) + + setValue(undefined)} +/> + `.trim(), + }, + }, + }, +} + +export const DateOnly: Story = { + render: args => ( + + ), + args: { + ...meta.args, + needTimePicker: false, + placeholder: 'Select due date', + }, + parameters: { + docs: { + source: { + language: 'tsx', + code: ` + + `.trim(), + }, + }, + }, +} diff --git a/web/app/components/base/dialog/index.stories.tsx b/web/app/components/base/dialog/index.stories.tsx index 94998c6d21..5fd833b666 100644 --- a/web/app/components/base/dialog/index.stories.tsx +++ b/web/app/components/base/dialog/index.stories.tsx @@ -3,7 +3,7 @@ import { useEffect, useState } from 'react' import Dialog from '.' const meta = { - title: 'Base/Dialog/Dialog', + title: 'Base/Feedback/Dialog', component: Dialog, parameters: { layout: 'fullscreen', @@ -130,6 +130,7 @@ export const CustomStyling: Story = { bodyClassName: 'bg-gray-50 rounded-xl p-5', footerClassName: 'justify-between px-4 pb-4 pt-4', titleClassName: 'text-lg text-primary-600', + children: null, footer: ( <> Last synced 2 minutes ago diff --git a/web/app/components/base/divider/index.stories.tsx b/web/app/components/base/divider/index.stories.tsx new file mode 100644 index 0000000000..c634173202 --- /dev/null +++ b/web/app/components/base/divider/index.stories.tsx @@ -0,0 +1,46 @@ +import type { Meta, StoryObj } from '@storybook/nextjs' +import Divider from '.' + +const meta = { + title: 'Base/Layout/Divider', + component: Divider, + parameters: { + docs: { + description: { + component: 'Lightweight separator supporting horizontal and vertical orientations with gradient or solid backgrounds.', + }, + source: { + language: 'tsx', + code: ` + + `.trim(), + }, + }, + }, + tags: ['autodocs'], +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Horizontal: Story = {} + +export const Vertical: Story = { + render: args => ( +
+ Filters + + Tags +
+ ), + parameters: { + docs: { + source: { + language: 'tsx', + code: ` + + `.trim(), + }, + }, + }, +} diff --git a/web/app/components/base/drawer-plus/index.stories.tsx b/web/app/components/base/drawer-plus/index.stories.tsx new file mode 100644 index 0000000000..ddb39f2d63 --- /dev/null +++ b/web/app/components/base/drawer-plus/index.stories.tsx @@ -0,0 +1,124 @@ +import type { Meta, StoryObj } from '@storybook/nextjs' +import { fn } from 'storybook/test' +import { useState } from 'react' +import DrawerPlus from '.' + +const meta = { + title: 'Base/Feedback/DrawerPlus', + component: DrawerPlus, + parameters: { + layout: 'fullscreen', + docs: { + description: { + component: 'Enhanced drawer built atop the base drawer component. Provides header/foot slots, mask control, and mobile breakpoints.', + }, + }, + }, + tags: ['autodocs'], +} satisfies Meta + +export default meta +type Story = StoryObj + +type DrawerPlusProps = React.ComponentProps + +const storyBodyElement: React.JSX.Element = ( +
+

+ DrawerPlus allows rich content with sticky header/footer and responsive masking on mobile. Great for editing flows or showing execution logs. +

+
+ Body content scrolls if it exceeds the allotted height. +
+
+) + +const DrawerPlusDemo = (props: Partial) => { + const [open, setOpen] = useState(false) + + const { + body, + title, + foot, + isShow: _isShow, + onHide: _onHide, + ...rest + } = props + + const resolvedBody: React.JSX.Element = body ?? storyBodyElement + + return ( +
+ + + } + isShow={open} + onHide={() => setOpen(false)} + title={title ?? 'Workflow execution details'} + body={resolvedBody} + foot={foot} + /> +
+ ) +} + +export const Playground: Story = { + render: args => , + args: { + isShow: false, + onHide: fn(), + title: 'Edit configuration', + body: storyBodyElement, + }, +} + +export const WithFooter: Story = { + render: (args) => { + const FooterDemo = () => { + const [open, setOpen] = useState(false) + return ( +
+ + + setOpen(false)} + title={args.title ?? 'Workflow execution details'} + body={args.body ?? ( +
+

Populate the body with scrollable content. Footer stays pinned.

+
+ )} + foot={ +
+ + +
+ } + /> +
+ ) + } + return + }, + args: { + isShow: false, + onHide: fn(), + title: 'Edit configuration!', + body: storyBodyElement, + }, +} diff --git a/web/app/components/base/drawer/index.stories.tsx b/web/app/components/base/drawer/index.stories.tsx new file mode 100644 index 0000000000..e7711bc1a2 --- /dev/null +++ b/web/app/components/base/drawer/index.stories.tsx @@ -0,0 +1,114 @@ +import type { Meta, StoryObj } from '@storybook/nextjs' +import { fn } from 'storybook/test' +import { useState } from 'react' +import Drawer from '.' + +const meta = { + title: 'Base/Feedback/Drawer', + component: Drawer, + parameters: { + layout: 'fullscreen', + docs: { + description: { + component: 'Sliding panel built on Headless UI dialog primitives. Supports optional mask, custom footer, and close behaviour.', + }, + }, + }, + tags: ['autodocs'], +} satisfies Meta + +export default meta +type Story = StoryObj + +const DrawerDemo = (props: React.ComponentProps) => { + const [open, setOpen] = useState(false) + + return ( +
+ + + setOpen(false)} + title={props.title ?? 'Edit configuration'} + description={props.description ?? 'Adjust settings in the side panel and save.'} + footer={props.footer ?? undefined} + > +
+

+ This example renders arbitrary content inside the drawer body. Use it for contextual forms, settings, or informational panels. +

+
+ Content area +
+
+
+
+ ) +} + +export const Playground: Story = { + render: args => , + args: { + children: null, + isOpen: false, + onClose: fn(), + }, + parameters: { + docs: { + source: { + language: 'tsx', + code: ` +const [open, setOpen] = useState(false) + + setOpen(false)} + title="Edit configuration" + description="Adjust settings in the side panel and save." +> + ... + + `.trim(), + }, + }, + }, +} + +export const CustomFooter: Story = { + render: args => ( + + + + + } + /> + ), + args: { + children: null, + isOpen: false, + onClose: fn(), + }, + parameters: { + docs: { + source: { + language: 'tsx', + code: ` +}> + ... + + `.trim(), + }, + }, + }, +} diff --git a/web/app/components/base/dropdown/index.stories.tsx b/web/app/components/base/dropdown/index.stories.tsx new file mode 100644 index 0000000000..da70730744 --- /dev/null +++ b/web/app/components/base/dropdown/index.stories.tsx @@ -0,0 +1,85 @@ +import type { Meta, StoryObj } from '@storybook/nextjs' +import { fn } from 'storybook/test' +import { useState } from 'react' +import Dropdown, { type Item } from '.' + +const PRIMARY_ITEMS: Item[] = [ + { value: 'rename', text: 'Rename' }, + { value: 'duplicate', text: 'Duplicate' }, +] + +const SECONDARY_ITEMS: Item[] = [ + { value: 'archive', text: Archive }, + { value: 'delete', text: Delete }, +] + +const meta = { + title: 'Base/Navigation/Dropdown', + component: Dropdown, + parameters: { + docs: { + description: { + component: 'Small contextual menu with optional destructive section. Uses portal positioning utilities for precise placement.', + }, + }, + }, + tags: ['autodocs'], + args: { + items: PRIMARY_ITEMS, + secondItems: SECONDARY_ITEMS, + }, +} satisfies Meta + +export default meta +type Story = StoryObj + +const DropdownDemo = (props: React.ComponentProps) => { + const [lastAction, setLastAction] = useState('None') + + return ( +
+ { + setLastAction(String(item.value)) + props.onSelect?.(item) + }} + /> +
+ Last action: {lastAction} +
+
+ ) +} + +export const Playground: Story = { + render: args => , + args: { + items: PRIMARY_ITEMS, + secondItems: SECONDARY_ITEMS, + onSelect: fn(), + }, +} + +export const CustomTrigger: Story = { + render: args => ( + ( + + )} + /> + ), + args: { + items: PRIMARY_ITEMS, + onSelect: fn(), + }, +} diff --git a/web/app/components/base/effect/index.stories.tsx b/web/app/components/base/effect/index.stories.tsx new file mode 100644 index 0000000000..a7f316fe7e --- /dev/null +++ b/web/app/components/base/effect/index.stories.tsx @@ -0,0 +1,39 @@ +/* eslint-disable tailwindcss/classnames-order */ +import type { Meta, StoryObj } from '@storybook/nextjs' +import Effect from '.' + +const meta = { + title: 'Base/Other/Effect', + component: Effect, + parameters: { + docs: { + description: { + component: 'Blurred circular glow used as a decorative background accent. Combine with relatively positioned containers.', + }, + source: { + language: 'tsx', + code: ` +
+ +
+ `.trim(), + }, + }, + }, + tags: ['autodocs'], +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Playground: Story = { + render: () => ( +
+ + +
+ Accent glow +
+
+ ), +} diff --git a/web/app/components/base/emoji-picker/Inner.stories.tsx b/web/app/components/base/emoji-picker/Inner.stories.tsx new file mode 100644 index 0000000000..5341d63ee3 --- /dev/null +++ b/web/app/components/base/emoji-picker/Inner.stories.tsx @@ -0,0 +1,57 @@ +import type { Meta, StoryObj } from '@storybook/nextjs' +import { useState } from 'react' +import EmojiPickerInner from './Inner' + +const meta = { + title: 'Base/Data Entry/EmojiPickerInner', + component: EmojiPickerInner, + parameters: { + layout: 'fullscreen', + docs: { + description: { + component: 'Core emoji grid with search and style swatches. Use this when embedding the selector inline without a modal frame.', + }, + }, + }, + tags: ['autodocs'], +} satisfies Meta + +export default meta +type Story = StoryObj + +const InnerDemo = () => { + const [selection, setSelection] = useState<{ emoji: string; background: string } | null>(null) + + return ( +
+ setSelection({ emoji, background })} + className="flex-1 overflow-hidden rounded-xl border border-divider-subtle bg-white" + /> +
+
Latest selection
+
+          {selection ? JSON.stringify(selection, null, 2) : 'Tap an emoji to set background options.'}
+        
+
+
+ ) +} + +export const Playground: Story = { + render: () => , + parameters: { + docs: { + source: { + language: 'tsx', + code: ` +const [selection, setSelection] = useState<{ emoji: string; background: string } | null>(null) + +return ( + setSelection({ emoji, background })} /> +) + `.trim(), + }, + }, + }, +} diff --git a/web/app/components/base/emoji-picker/index.stories.tsx b/web/app/components/base/emoji-picker/index.stories.tsx new file mode 100644 index 0000000000..7c9b07f138 --- /dev/null +++ b/web/app/components/base/emoji-picker/index.stories.tsx @@ -0,0 +1,91 @@ +import type { Meta, StoryObj } from '@storybook/nextjs' +import { useState } from 'react' +import EmojiPicker from '.' + +const meta = { + title: 'Base/Data Entry/EmojiPicker', + component: EmojiPicker, + parameters: { + layout: 'fullscreen', + docs: { + description: { + component: 'Modal-based emoji selector that powers the icon picker. Supports search, background swatches, and confirmation callbacks.', + }, + }, + nextjs: { + appDirectory: true, + navigation: { + pathname: '/apps/demo-app/emoji-picker', + params: { appId: 'demo-app' }, + }, + }, + }, + tags: ['autodocs'], +} satisfies Meta + +export default meta +type Story = StoryObj + +const EmojiPickerDemo = () => { + const [open, setOpen] = useState(false) + const [selection, setSelection] = useState<{ emoji: string; background: string } | null>(null) + + return ( +
+ + +
+
Selection preview
+
+          {selection ? JSON.stringify(selection, null, 2) : 'No emoji selected yet.'}
+        
+
+ + {open && ( + { + setSelection({ emoji, background }) + setOpen(false) + }} + onClose={() => setOpen(false)} + /> + )} +
+ ) +} + +export const Playground: Story = { + render: () => , + parameters: { + docs: { + source: { + language: 'tsx', + code: ` +const [open, setOpen] = useState(false) +const [selection, setSelection] = useState<{ emoji: string; background: string } | null>(null) + +return ( + <> + + {open && ( + { + setSelection({ emoji, background }) + setOpen(false) + }} + onClose={() => setOpen(false)} + /> + )} + +) + `.trim(), + }, + }, + }, +} diff --git a/web/app/components/base/features/index.stories.tsx b/web/app/components/base/features/index.stories.tsx new file mode 100644 index 0000000000..f1eaf048b8 --- /dev/null +++ b/web/app/components/base/features/index.stories.tsx @@ -0,0 +1,73 @@ +import type { Meta, StoryObj } from '@storybook/nextjs' +import { useState } from 'react' +import { FeaturesProvider } from '.' +import NewFeaturePanel from './new-feature-panel' +import type { Features } from './types' + +const DEFAULT_FEATURES: Features = { + moreLikeThis: { enabled: false }, + opening: { enabled: false }, + suggested: { enabled: false }, + text2speech: { enabled: false }, + speech2text: { enabled: false }, + citation: { enabled: false }, + moderation: { enabled: false }, + file: { enabled: false }, + annotationReply: { enabled: false }, +} + +const meta = { + title: 'Base/Other/FeaturesProvider', + component: FeaturesProvider, + parameters: { + layout: 'fullscreen', + docs: { + description: { + component: 'Zustand-backed provider used for feature toggles. Paired with `NewFeaturePanel` for workflow settings.', + }, + }, + }, + tags: ['autodocs'], +} satisfies Meta + +export default meta +type Story = StoryObj + +const FeaturesDemo = () => { + const [show, setShow] = useState(true) + const [features, setFeatures] = useState(DEFAULT_FEATURES) + + return ( + +
+
+
Feature toggles preview
+
+ +
+
+
+ + setFeatures(prev => ({ ...prev, ...next }))} + onClose={() => setShow(false)} + /> +
+ ) +} + +export const Playground: Story = { + render: () => , + args: { + children: null, + }, +} diff --git a/web/app/components/base/file-icon/index.stories.tsx b/web/app/components/base/file-icon/index.stories.tsx new file mode 100644 index 0000000000..dbd3e13fea --- /dev/null +++ b/web/app/components/base/file-icon/index.stories.tsx @@ -0,0 +1,79 @@ +import type { Meta, StoryObj } from '@storybook/nextjs' +import FileIcon from '.' + +const meta = { + title: 'Base/General/FileIcon', + component: FileIcon, + parameters: { + docs: { + description: { + component: 'Maps a file extension to the appropriate SVG icon used across upload and attachment surfaces.', + }, + }, + }, + tags: ['autodocs'], + argTypes: { + type: { + control: 'text', + description: 'File extension or identifier used to resolve the icon.', + }, + className: { + control: 'text', + description: 'Custom classes passed to the SVG wrapper.', + }, + }, + args: { + type: 'pdf', + className: 'h-10 w-10', + }, +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Default: Story = { + render: args => ( +
+ + Extension: {args.type} +
+ ), + parameters: { + docs: { + source: { + language: 'tsx', + code: ` + + `.trim(), + }, + }, + }, +} + +export const Gallery: Story = { + render: () => { + const examples = ['pdf', 'docx', 'xlsx', 'csv', 'json', 'md', 'txt', 'html', 'notion', 'unknown'] + return ( +
+ {examples.map(type => ( +
+ + {type} +
+ ))} +
+ ) + }, + parameters: { + docs: { + source: { + language: 'tsx', + code: ` +{['pdf','docx','xlsx','csv','json','md','txt','html','notion','unknown'].map(type => ( + +))} + `.trim(), + }, + }, + }, +} diff --git a/web/app/components/base/file-uploader/file-image-render.stories.tsx b/web/app/components/base/file-uploader/file-image-render.stories.tsx new file mode 100644 index 0000000000..132c0b61a3 --- /dev/null +++ b/web/app/components/base/file-uploader/file-image-render.stories.tsx @@ -0,0 +1,32 @@ +import type { Meta, StoryObj } from '@storybook/nextjs' +import FileImageRender from './file-image-render' + +const SAMPLE_IMAGE = 'data:image/svg+xml;utf8,Preview' + +const meta = { + title: 'Base/General/FileImageRender', + component: FileImageRender, + parameters: { + docs: { + description: { + component: 'Renders image previews inside a bordered frame. Often used in upload galleries and logs.', + }, + source: { + language: 'tsx', + code: ` + + `.trim(), + }, + }, + }, + tags: ['autodocs'], + args: { + imageUrl: SAMPLE_IMAGE, + className: 'h-32 w-52', + }, +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Playground: Story = {} diff --git a/web/app/components/base/file-uploader/file-list.stories.tsx b/web/app/components/base/file-uploader/file-list.stories.tsx new file mode 100644 index 0000000000..89c0568735 --- /dev/null +++ b/web/app/components/base/file-uploader/file-list.stories.tsx @@ -0,0 +1,96 @@ +import type { Meta, StoryObj } from '@storybook/nextjs' +import { useState } from 'react' +import { FileList } from './file-uploader-in-chat-input/file-list' +import type { FileEntity } from './types' +import { SupportUploadFileTypes } from '@/app/components/workflow/types' +import { TransferMethod } from '@/types/app' + +const SAMPLE_IMAGE = 'data:image/svg+xml;utf8,IMG' + +const filesSample: FileEntity[] = [ + { + id: '1', + name: 'Project Brief.pdf', + size: 256000, + type: 'application/pdf', + progress: 100, + transferMethod: TransferMethod.local_file, + supportFileType: SupportUploadFileTypes.document, + url: '', + }, + { + id: '2', + name: 'Design.png', + size: 128000, + type: 'image/png', + progress: 100, + transferMethod: TransferMethod.local_file, + supportFileType: SupportUploadFileTypes.image, + base64Url: SAMPLE_IMAGE, + }, + { + id: '3', + name: 'Voiceover.mp3', + size: 512000, + type: 'audio/mpeg', + progress: 45, + transferMethod: TransferMethod.remote_url, + supportFileType: SupportUploadFileTypes.audio, + url: '', + }, +] + +const meta = { + title: 'Base/Data Display/FileList', + component: FileList, + parameters: { + docs: { + description: { + component: 'Renders a responsive gallery of uploaded files, handling icons, previews, and progress states.', + }, + }, + }, + tags: ['autodocs'], + args: { + files: filesSample, + }, +} satisfies Meta + +export default meta +type Story = StoryObj + +const FileListPlayground = (args: React.ComponentProps) => { + const [items, setItems] = useState(args.files || []) + + return ( +
+ setItems(list => list.filter(file => file.id !== fileId))} + /> +
+ ) +} + +export const Playground: Story = { + render: args => , + parameters: { + docs: { + source: { + language: 'tsx', + code: ` +const [files, setFiles] = useState(initialFiles) + + setFiles(list => list.filter(file => file.id !== id))} /> + `.trim(), + }, + }, + }, +} + +export const UploadStates: Story = { + args: { + files: filesSample.map(file => ({ ...file, progress: file.id === '3' ? 45 : 100 })), + }, +} diff --git a/web/app/components/base/file-uploader/file-type-icon.stories.tsx b/web/app/components/base/file-uploader/file-type-icon.stories.tsx new file mode 100644 index 0000000000..c317afab68 --- /dev/null +++ b/web/app/components/base/file-uploader/file-type-icon.stories.tsx @@ -0,0 +1,38 @@ +import type { Meta, StoryObj } from '@storybook/nextjs' +import FileTypeIcon from './file-type-icon' +import { FileAppearanceTypeEnum } from './types' + +const meta = { + title: 'Base/General/FileTypeIcon', + component: FileTypeIcon, + parameters: { + docs: { + description: { + component: 'Displays the appropriate icon and accent colour for a file appearance type. Useful in lists and attachments.', + }, + }, + }, + tags: ['autodocs'], + args: { + type: FileAppearanceTypeEnum.document, + size: 'md', + }, +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Playground: Story = {} + +export const Gallery: Story = { + render: () => ( +
+ {Object.values(FileAppearanceTypeEnum).map(type => ( +
+ + {type} +
+ ))} +
+ ), +} diff --git a/web/app/components/base/file-uploader/file-uploader-in-attachment/index.stories.tsx b/web/app/components/base/file-uploader/file-uploader-in-attachment/index.stories.tsx new file mode 100644 index 0000000000..dabb8b6615 --- /dev/null +++ b/web/app/components/base/file-uploader/file-uploader-in-attachment/index.stories.tsx @@ -0,0 +1,110 @@ +import type { Meta, StoryObj } from '@storybook/nextjs' +import { fn } from 'storybook/test' +import { useState } from 'react' +import FileUploaderInAttachmentWrapper from './index' +import type { FileEntity } from '../types' +import type { FileUpload } from '@/app/components/base/features/types' +import { PreviewMode } from '@/app/components/base/features/types' +import { TransferMethod } from '@/types/app' +import { ToastProvider } from '@/app/components/base/toast' +import { SupportUploadFileTypes } from '@/app/components/workflow/types' + +const SAMPLE_IMAGE = 'data:image/svg+xml;utf8,IMG' + +const mockFiles: FileEntity[] = [ + { + id: 'file-1', + name: 'Requirements.pdf', + size: 256000, + type: 'application/pdf', + progress: 100, + transferMethod: TransferMethod.local_file, + supportFileType: SupportUploadFileTypes.document, + url: '', + }, + { + id: 'file-2', + name: 'Interface.png', + size: 128000, + type: 'image/png', + progress: 100, + transferMethod: TransferMethod.local_file, + supportFileType: SupportUploadFileTypes.image, + base64Url: SAMPLE_IMAGE, + }, + { + id: 'file-3', + name: 'Voiceover.mp3', + size: 512000, + type: 'audio/mpeg', + progress: 35, + transferMethod: TransferMethod.remote_url, + supportFileType: SupportUploadFileTypes.audio, + url: '', + }, +] + +const fileConfig: FileUpload = { + enabled: true, + allowed_file_upload_methods: [TransferMethod.local_file, TransferMethod.remote_url], + allowed_file_types: ['document', 'image', 'audio'], + number_limits: 5, + preview_config: { mode: PreviewMode.NewPage, file_type_list: ['pdf', 'png'] }, +} + +const meta = { + title: 'Base/Data Entry/FileUploaderInAttachment', + component: FileUploaderInAttachmentWrapper, + parameters: { + layout: 'centered', + docs: { + description: { + component: 'Attachment-style uploader that supports local files and remote links. Demonstrates upload progress, re-upload, and preview actions.', + }, + }, + nextjs: { + appDirectory: true, + navigation: { + pathname: '/apps/demo-app/uploads', + params: { appId: 'demo-app' }, + }, + }, + }, + tags: ['autodocs'], + args: { + fileConfig, + }, +} satisfies Meta + +export default meta +type Story = StoryObj + +const AttachmentDemo = (props: React.ComponentProps) => { + const [files, setFiles] = useState(mockFiles) + + return ( + +
+ +
+
+ ) +} + +export const Playground: Story = { + render: args => , + args: { + onChange: fn(), + }, +} + +export const Disabled: Story = { + render: args => , + args: { + onChange: fn(), + }, +} diff --git a/web/app/components/base/file-uploader/file-uploader-in-chat-input/index.stories.tsx b/web/app/components/base/file-uploader/file-uploader-in-chat-input/index.stories.tsx new file mode 100644 index 0000000000..f4165f64cb --- /dev/null +++ b/web/app/components/base/file-uploader/file-uploader-in-chat-input/index.stories.tsx @@ -0,0 +1,95 @@ +import type { Meta, StoryObj } from '@storybook/nextjs' +import { useState } from 'react' +import FileUploaderInChatInput from '.' +import { FileContextProvider } from '../store' +import type { FileEntity } from '../types' +import type { FileUpload } from '@/app/components/base/features/types' +import { SupportUploadFileTypes } from '@/app/components/workflow/types' +import { TransferMethod } from '@/types/app' +import { FileList } from '../file-uploader-in-chat-input/file-list' +import { ToastProvider } from '@/app/components/base/toast' + +const mockFiles: FileEntity[] = [ + { + id: '1', + name: 'Dataset.csv', + size: 64000, + type: 'text/csv', + progress: 100, + transferMethod: TransferMethod.local_file, + supportFileType: SupportUploadFileTypes.document, + }, +] + +const chatUploadConfig: FileUpload = { + enabled: true, + allowed_file_upload_methods: [TransferMethod.local_file, TransferMethod.remote_url], + allowed_file_types: ['image', 'document'], + number_limits: 3, +} + +type ChatInputDemoProps = React.ComponentProps & { + initialFiles?: FileEntity[] +} + +const ChatInputDemo = ({ initialFiles = mockFiles, ...props }: ChatInputDemoProps) => { + const [files, setFiles] = useState(initialFiles) + + return ( + + +
+
Simulated chat input
+
+ +
Type a message...
+
+
+ +
+
+
+
+ ) +} + +const meta = { + title: 'Base/Data Entry/FileUploaderInChatInput', + component: ChatInputDemo, + parameters: { + docs: { + description: { + component: 'Attachment trigger suited for chat inputs. Demonstrates integration with the shared file store and preview list.', + }, + }, + nextjs: { + appDirectory: true, + navigation: { + pathname: '/chats/demo', + params: { appId: 'demo-app' }, + }, + }, + }, + tags: ['autodocs'], + args: { + fileConfig: chatUploadConfig, + initialFiles: mockFiles, + }, +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Playground: Story = { + render: args => , +} + +export const RemoteOnly: Story = { + args: { + fileConfig: { + ...chatUploadConfig, + allowed_file_upload_methods: [TransferMethod.remote_url], + }, + initialFiles: [], + }, +} diff --git a/web/app/components/base/float-right-container/index.stories.tsx b/web/app/components/base/float-right-container/index.stories.tsx new file mode 100644 index 0000000000..18173f086d --- /dev/null +++ b/web/app/components/base/float-right-container/index.stories.tsx @@ -0,0 +1,74 @@ +import type { Meta, StoryObj } from '@storybook/nextjs' +import { fn } from 'storybook/test' +import { useState } from 'react' +import FloatRightContainer from '.' + +const meta = { + title: 'Base/Feedback/FloatRightContainer', + component: FloatRightContainer, + parameters: { + layout: 'fullscreen', + docs: { + description: { + component: 'Wrapper that renders content in a drawer on mobile and inline on desktop. Useful for responsive settings panels.', + }, + }, + }, + tags: ['autodocs'], +} satisfies Meta + +export default meta +type Story = StoryObj + +const ContainerDemo = () => { + const [open, setOpen] = useState(false) + const [isMobile, setIsMobile] = useState(false) + + return ( +
+
+ + +
+ + setOpen(false)} + title="Responsive panel" + description="Switch the toggle to see drawer vs inline behaviour." + mask + > +
+

Panel Content

+

+ On desktop, this block renders inline when `isOpen` is true. On mobile it appears inside the drawer wrapper. +

+
+
+
+ ) +} + +export const Playground: Story = { + render: () => , + args: { + isMobile: false, + isOpen: false, + onClose: fn(), + children: null, + }, +} diff --git a/web/app/components/base/form/index.stories.tsx b/web/app/components/base/form/index.stories.tsx new file mode 100644 index 0000000000..c1b9e894e0 --- /dev/null +++ b/web/app/components/base/form/index.stories.tsx @@ -0,0 +1,559 @@ +import type { Meta, StoryObj } from '@storybook/nextjs' +import { useMemo, useState } from 'react' +import { useStore } from '@tanstack/react-form' +import ContactFields from './form-scenarios/demo/contact-fields' +import { demoFormOpts } from './form-scenarios/demo/shared-options' +import { ContactMethods, UserSchema } from './form-scenarios/demo/types' +import BaseForm from './components/base/base-form' +import type { FormSchema } from './types' +import { FormTypeEnum } from './types' +import { type FormStoryRender, FormStoryWrapper } from '../../../../.storybook/utils/form-story-wrapper' +import Button from '../button' +import { TransferMethod } from '@/types/app' +import { PreviewMode } from '@/app/components/base/features/types' + +const FormStoryHost = () => null + +const meta = { + title: 'Base/Data Entry/AppForm', + component: FormStoryHost, + parameters: { + layout: 'fullscreen', + docs: { + description: { + component: 'Helper utilities built on top of `@tanstack/react-form` that power form rendering across Dify. These stories demonstrate the `useAppForm` hook, field primitives, conditional visibility, and custom actions.', + }, + }, + }, + tags: ['autodocs'], +} satisfies Meta + +export default meta +type Story = StoryObj + +type AppFormInstance = Parameters[0] +type ContactFieldsProps = React.ComponentProps +type ContactFieldsFormApi = ContactFieldsProps['form'] + +type PlaygroundFormFieldsProps = { + form: AppFormInstance + status: string +} + +const PlaygroundFormFields = ({ form, status }: PlaygroundFormFieldsProps) => { + type PlaygroundFormValues = typeof demoFormOpts.defaultValues + const name = useStore(form.store, state => (state.values as PlaygroundFormValues).name) + const contactFormApi = form as ContactFieldsFormApi + + return ( +
{ + event.preventDefault() + event.stopPropagation() + form.handleSubmit() + }} + > + ( + + )} + /> + ( + + )} + /> + ( + + )} + /> + + {!!name && } + + + + + +

{status}

+ + ) +} + +const FormPlayground = () => { + const [status, setStatus] = useState('Fill in the form and submit to see results.') + + return ( + { + const result = UserSchema.safeParse(value as typeof demoFormOpts.defaultValues) + if (!result.success) + return result.error.issues[0].message + return undefined + }, + }, + onSubmit: ({ value }) => { + setStatus('Successfully saved profile.') + }, + }} + > + {form => } + + ) +} + +const mockFileUploadConfig = { + enabled: true, + allowed_file_extensions: ['pdf', 'png'], + allowed_file_upload_methods: [TransferMethod.local_file, TransferMethod.remote_url], + number_limits: 3, + preview_config: { + mode: PreviewMode.CurrentPage, + file_type_list: ['pdf', 'png'], + }, +} + +const mockFieldDefaults = { + headline: 'Dify App', + description: 'Streamline your AI workflows with configurable building blocks.', + category: 'workbench', + allowNotifications: true, + dailyLimit: 40, + attachment: [], +} + +const FieldGallery = () => { + const selectOptions = useMemo(() => [ + { value: 'workbench', label: 'Workbench' }, + { value: 'playground', label: 'Playground' }, + { value: 'production', label: 'Production' }, + ], []) + + return ( + + {form => ( +
{ + event.preventDefault() + event.stopPropagation() + form.handleSubmit() + }} + > + ( + + )} + /> + ( + + )} + /> + ( + + )} + /> + ( + + )} + /> + ( + + )} + /> + ( + + )} + /> +
+ + + +
+ + )} +
+ ) +} + +const conditionalSchemas: FormSchema[] = [ + { + type: FormTypeEnum.select, + name: 'channel', + label: 'Preferred channel', + required: true, + default: 'email', + options: ContactMethods, + }, + { + type: FormTypeEnum.textInput, + name: 'contactEmail', + label: 'Email address', + required: true, + placeholder: 'user@example.com', + show_on: [{ variable: 'channel', value: 'email' }], + }, + { + type: FormTypeEnum.textInput, + name: 'contactPhone', + label: 'Phone number', + required: true, + placeholder: '+1 555 123 4567', + show_on: [{ variable: 'channel', value: 'phone' }], + }, + { + type: FormTypeEnum.boolean, + name: 'optIn', + label: 'Opt in to marketing messages', + required: false, + }, +] + +const ConditionalFieldsStory = () => { + const [values, setValues] = useState>({ + channel: 'email', + optIn: false, + }) + + return ( +
+
+ { + setValues(prev => ({ + ...prev, + [field]: value, + })) + }} + /> +
+ +
+ ) +} + +const CustomActionsStory = () => { + return ( + { + const nextValues = value as { datasetName?: string } + if (!nextValues.datasetName || nextValues.datasetName.length < 3) + return 'Dataset name must contain at least 3 characters.' + return undefined + }, + }, + }} + > + {form => ( +
{ + event.preventDefault() + event.stopPropagation() + form.handleSubmit() + }} + > + ( + + )} + /> + ( + + )} + /> + + ( +
+ + + +
+ )} + /> +
+ + )} +
+ ) +} + +export const Playground: Story = { + render: () => , + parameters: { + docs: { + source: { + language: 'tsx', + code: ` +const form = useAppForm({ + ...demoFormOpts, + validators: { + onSubmit: ({ value }) => UserSchema.safeParse(value).success ? undefined : 'Validation failed', + }, + onSubmit: ({ value }) => { + setStatus(\`Successfully saved profile for \${value.name}\`) + }, +}) + +return ( +
+ + {field => } + + + {field => } + + + {field => } + + {!!form.store.state.values.name && } + + + + +) + `.trim(), + }, + }, + }, +} + +export const FieldExplorer: Story = { + render: () => , + parameters: { + nextjs: { + appDirectory: true, + navigation: { + pathname: '/apps/demo-app/form', + params: { appId: 'demo-app' }, + }, + }, + docs: { + source: { + language: 'tsx', + code: ` +const form = useAppForm({ + defaultValues: { + headline: 'Dify App', + description: 'Streamline your AI workflows', + category: 'workbench', + allowNotifications: true, + dailyLimit: 40, + attachment: [], + }, +}) + +return ( +
+ + {field => } + + + {field => } + + + {field => } + + + {field => } + + + {field => } + + + {field => } + + + + +
+) + `.trim(), + }, + }, + }, +} + +export const ConditionalVisibility: Story = { + render: () => , + parameters: { + docs: { + description: { + story: 'Demonstrates schema-driven visibility using `show_on` conditions rendered through the reusable `BaseForm` component.', + }, + source: { + language: 'tsx', + code: ` +const conditionalSchemas: FormSchema[] = [ + { type: FormTypeEnum.select, name: 'channel', label: 'Preferred channel', options: ContactMethods }, + { type: FormTypeEnum.textInput, name: 'contactEmail', label: 'Email', show_on: [{ variable: 'channel', value: 'email' }] }, + { type: FormTypeEnum.textInput, name: 'contactPhone', label: 'Phone', show_on: [{ variable: 'channel', value: 'phone' }] }, + { type: FormTypeEnum.boolean, name: 'optIn', label: 'Opt in to marketing messages' }, +] + +return ( + setValues(prev => ({ ...prev, [field]: value }))} + /> +) + `.trim(), + }, + }, + }, +} + +export const CustomActions: Story = { + render: () => , + parameters: { + docs: { + description: { + story: 'Shows how to replace the default submit button with a fully custom footer leveraging contextual form state.', + }, + source: { + language: 'tsx', + code: ` +const form = useAppForm({ + defaultValues: { + datasetName: 'Support FAQ', + datasetDescription: 'Knowledge base snippets sourced from Zendesk exports.', + }, + validators: { + onChange: ({ value }) => value.datasetName?.length >= 3 ? undefined : 'Dataset name must contain at least 3 characters.', + }, +}) + +return ( +
+ + {field => } + + + {field => } + + + ( +
+ + + +
+ )} + /> +
+
+) + `.trim(), + }, + }, + }, +} diff --git a/web/app/components/base/fullscreen-modal/index.stories.tsx b/web/app/components/base/fullscreen-modal/index.stories.tsx new file mode 100644 index 0000000000..72fd28df66 --- /dev/null +++ b/web/app/components/base/fullscreen-modal/index.stories.tsx @@ -0,0 +1,59 @@ +import type { Meta, StoryObj } from '@storybook/nextjs' +import { useState } from 'react' +import FullScreenModal from '.' + +const meta = { + title: 'Base/Feedback/FullScreenModal', + component: FullScreenModal, + parameters: { + layout: 'fullscreen', + docs: { + description: { + component: 'Backdrop-blurred fullscreen modal. Supports close button, custom content, and optional overflow visibility.', + }, + }, + }, + tags: ['autodocs'], +} satisfies Meta + +export default meta +type Story = StoryObj + +const ModalDemo = (props: React.ComponentProps) => { + const [open, setOpen] = useState(false) + + return ( +
+ + + setOpen(false)} + closable + > +
+
+ Full-screen experience +
+
+ Place dashboards, flow builders, or immersive previews here. +
+
+
+
+ ) +} + +export const Playground: Story = { + render: args => , + args: { + open: false, + }, +} diff --git a/web/app/components/base/grid-mask/index.stories.tsx b/web/app/components/base/grid-mask/index.stories.tsx new file mode 100644 index 0000000000..1b67a1510d --- /dev/null +++ b/web/app/components/base/grid-mask/index.stories.tsx @@ -0,0 +1,51 @@ +import type { Meta, StoryObj } from '@storybook/nextjs' +import GridMask from '.' + +const meta = { + title: 'Base/Layout/GridMask', + component: GridMask, + parameters: { + layout: 'fullscreen', + docs: { + description: { + component: 'Displays a soft grid overlay with gradient mask, useful for framing hero sections or marketing callouts.', + }, + }, + }, + args: { + wrapperClassName: 'rounded-2xl p-10', + canvasClassName: '', + gradientClassName: '', + children: ( +
+ Grid Mask Demo + Beautiful backgrounds for feature highlights +

+ Place any content inside the mask. On dark backgrounds the grid and soft gradient add depth without distracting from the main message. +

+
+ ), + }, + tags: ['autodocs'], +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Playground: Story = {} + +export const CustomBackground: Story = { + args: { + wrapperClassName: 'rounded-3xl p-10 bg-[#0A0A1A]', + gradientClassName: 'bg-gradient-to-r from-[#0A0A1A]/90 via-[#101030]/60 to-[#05050A]/90', + children: ( +
+ Custom gradient + Use your own colors +

+ Override gradient and canvas classes to match brand palettes while keeping the grid texture. +

+
+ ), + }, +} diff --git a/web/app/components/base/image-gallery/index.stories.tsx b/web/app/components/base/image-gallery/index.stories.tsx new file mode 100644 index 0000000000..c1b463170c --- /dev/null +++ b/web/app/components/base/image-gallery/index.stories.tsx @@ -0,0 +1,39 @@ +import type { Meta, StoryObj } from '@storybook/nextjs' +import ImageGallery from '.' + +const IMAGE_SOURCES = [ + 'data:image/svg+xml;utf8,Dataset', + 'data:image/svg+xml;utf8,Playground', + 'data:image/svg+xml;utf8,Workflow', + 'data:image/svg+xml;utf8,Prompts', +] + +const meta = { + title: 'Base/Data Display/ImageGallery', + component: ImageGallery, + parameters: { + docs: { + description: { + component: 'Responsive thumbnail grid with lightbox preview for larger imagery.', + }, + source: { + language: 'tsx', + code: ` +', + 'data:image/svg+xml;utf8,', +]} /> + `.trim(), + }, + }, + }, + tags: ['autodocs'], + args: { + srcs: IMAGE_SOURCES, + }, +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Default: Story = {} diff --git a/web/app/components/base/image-uploader/image-list.stories.tsx b/web/app/components/base/image-uploader/image-list.stories.tsx new file mode 100644 index 0000000000..530ef69556 --- /dev/null +++ b/web/app/components/base/image-uploader/image-list.stories.tsx @@ -0,0 +1,182 @@ +import type { Meta, StoryObj } from '@storybook/nextjs' +import { useMemo, useState } from 'react' +import ImageList from './image-list' +import ImageLinkInput from './image-link-input' +import type { ImageFile } from '@/types/app' +import { TransferMethod } from '@/types/app' + +const SAMPLE_BASE64 + = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAMgAAADICAYAAACtWK6eAAAACXBIWXMAAAsSAAALEgHS3X78AAABbElEQVR4nO3SsQkAIBDARMT+V20sTg6LXhWEATnnMHDx4sWLFi1atGjRokWLFi1atGjRokWLFi1atGjRokWLFi1atGjRokWLFi1atGjRokWLFi1atGjRokWLFi1atGjRokWLFi1atGjRokWLFi1atGjRokWLFi1atGjRokWLFi1atGjRokWLFi1atGjRokWLFi1atGjRokWLFi1atGjRokWLFi1atGjRokWLFi1atGjRokWLFi1atGjRokWLFi1atGjRokWLFu2r/H3n4BG518Gr4AAAAASUVORK5CYII=' + +const createRemoteImage = ( + id: string, + progress: number, + url: string, +): ImageFile => ({ + type: TransferMethod.remote_url, + _id: id, + fileId: `remote-${id}`, + progress, + url, +}) + +const createLocalImage = (id: string, progress: number): ImageFile => ({ + type: TransferMethod.local_file, + _id: id, + fileId: `local-${id}`, + progress, + url: SAMPLE_BASE64, + base64Url: SAMPLE_BASE64, +}) + +const initialImages: ImageFile[] = [ + createLocalImage('local-initial', 100), + createRemoteImage( + 'remote-loading', + 40, + 'https://images.unsplash.com/photo-1500530855697-b586d89ba3ee?auto=format&fit=crop&w=300&q=80', + ), + { + ...createRemoteImage( + 'remote-error', + -1, + 'https://example.com/not-an-image.jpg', + ), + url: 'https://example.com/not-an-image.jpg', + }, +] + +const meta = { + title: 'Base/Data Entry/ImageList', + component: ImageList, + parameters: { + layout: 'centered', + docs: { + description: { + component: 'Renders thumbnails for uploaded images and manages their states like uploading, error, and deletion.', + }, + }, + }, + argTypes: { + list: { control: false }, + onRemove: { control: false }, + onReUpload: { control: false }, + onImageLinkLoadError: { control: false }, + onImageLinkLoadSuccess: { control: false }, + }, + tags: ['autodocs'], +} satisfies Meta + +export default meta +type Story = StoryObj + +const ImageUploaderPlayground = ({ readonly }: Story['args']) => { + const [images, setImages] = useState(() => initialImages) + + const activeImages = useMemo(() => images.filter(item => !item.deleted), [images]) + + const handleRemove = (id: string) => { + setImages(prev => prev.map(item => (item._id === id ? { ...item, deleted: true } : item))) + } + + const handleReUpload = (id: string) => { + setImages(prev => prev.map((item) => { + if (item._id !== id) + return item + + return { + ...item, + progress: 60, + } + })) + + setTimeout(() => { + setImages(prev => prev.map((item) => { + if (item._id !== id) + return item + + return { + ...item, + progress: 100, + } + })) + }, 1200) + } + + const handleImageLinkLoadSuccess = (id: string) => { + setImages(prev => prev.map(item => (item._id === id ? { ...item, progress: 100 } : item))) + } + + const handleImageLinkLoadError = (id: string) => { + setImages(prev => prev.map(item => (item._id === id ? { ...item, progress: -1 } : item))) + } + + const handleUploadFromLink = (imageFile: ImageFile) => { + setImages(prev => [ + ...prev, + { + ...imageFile, + fileId: `remote-${imageFile._id}`, + }, + ]) + } + + const handleAddLocalImage = () => { + const id = `local-${Date.now()}` + setImages(prev => [ + ...prev, + createLocalImage(id, 100), + ]) + } + + return ( +
+
+ Add images +
+ + +
+
+ + + +
+ + Files state + +
+          {JSON.stringify(activeImages, null, 2)}
+        
+
+
+ ) +} + +export const Playground: Story = { + render: args => , + args: { + list: [], + }, +} + +export const ReadonlyList: Story = { + render: args => , + args: { + list: [], + }, +} diff --git a/web/app/components/base/inline-delete-confirm/index.stories.tsx b/web/app/components/base/inline-delete-confirm/index.stories.tsx new file mode 100644 index 0000000000..e0b0757718 --- /dev/null +++ b/web/app/components/base/inline-delete-confirm/index.stories.tsx @@ -0,0 +1,87 @@ +import type { Meta, StoryObj } from '@storybook/nextjs' +import { fn } from 'storybook/test' +import { useState } from 'react' +import InlineDeleteConfirm from '.' + +const meta = { + title: 'Base/Feedback/InlineDeleteConfirm', + component: InlineDeleteConfirm, + parameters: { + layout: 'centered', + docs: { + description: { + component: 'Compact confirmation prompt that appears inline, commonly used near delete buttons or destructive controls.', + }, + }, + }, + argTypes: { + variant: { + control: 'select', + options: ['delete', 'warning', 'info'], + }, + }, + args: { + title: 'Delete this item?', + confirmText: 'Delete', + cancelText: 'Cancel', + onConfirm: fn(), + onCancel: fn(), + }, + tags: ['autodocs'], +} satisfies Meta + +export default meta +type Story = StoryObj + +const InlineDeleteConfirmDemo = (args: Story['args']) => { + const [visible, setVisible] = useState(true) + + return ( +
+ + {visible && ( + { + console.log('✅ Confirm clicked') + setVisible(false) + }} + onCancel={() => { + console.log('❎ Cancel clicked') + setVisible(false) + }} + /> + )} +
+ ) +} + +export const Playground: Story = { + render: args => , +} + +export const WarningVariant: Story = { + render: args => , + args: { + variant: 'warning', + title: 'Archive conversation?', + confirmText: 'Archive', + cancelText: 'Keep', + }, +} + +export const InfoVariant: Story = { + render: args => , + args: { + variant: 'info', + title: 'Remove collaborator?', + confirmText: 'Remove', + cancelText: 'Keep', + }, +} diff --git a/web/app/components/base/input-number/index.stories.tsx b/web/app/components/base/input-number/index.stories.tsx index aa075b0ff1..88999af9e0 100644 --- a/web/app/components/base/input-number/index.stories.tsx +++ b/web/app/components/base/input-number/index.stories.tsx @@ -3,7 +3,7 @@ import { useState } from 'react' import { InputNumber } from '.' const meta = { - title: 'Base/Input/InputNumber', + title: 'Base/Data Entry/InputNumber', component: InputNumber, parameters: { layout: 'centered', diff --git a/web/app/components/base/input/index.stories.tsx b/web/app/components/base/input/index.stories.tsx index 04df0bf943..c877579879 100644 --- a/web/app/components/base/input/index.stories.tsx +++ b/web/app/components/base/input/index.stories.tsx @@ -3,7 +3,7 @@ import { useState } from 'react' import Input from '.' const meta = { - title: 'Base/Input/Input', + title: 'Base/Data Entry/Input', component: Input, parameters: { layout: 'centered', diff --git a/web/app/components/base/linked-apps-panel/index.stories.tsx b/web/app/components/base/linked-apps-panel/index.stories.tsx new file mode 100644 index 0000000000..786d1bdf56 --- /dev/null +++ b/web/app/components/base/linked-apps-panel/index.stories.tsx @@ -0,0 +1,72 @@ +import type { Meta, StoryObj } from '@storybook/nextjs' +import LinkedAppsPanel from '.' +import type { RelatedApp } from '@/models/datasets' + +const mockRelatedApps: RelatedApp[] = [ + { + id: 'app-cx', + name: 'Customer Support Assistant', + mode: 'chat', + icon_type: 'emoji', + icon: '\u{1F4AC}', + icon_background: '#EEF2FF', + icon_url: '', + }, + { + id: 'app-ops', + name: 'Ops Workflow Orchestrator', + mode: 'workflow', + icon_type: 'emoji', + icon: '\u{1F6E0}\u{FE0F}', + icon_background: '#ECFDF3', + icon_url: '', + }, + { + id: 'app-research', + name: 'Research Synthesizer', + mode: 'advanced-chat', + icon_type: 'emoji', + icon: '\u{1F9E0}', + icon_background: '#FDF2FA', + icon_url: '', + }, +] + +const meta = { + title: 'Base/Feedback/LinkedAppsPanel', + component: LinkedAppsPanel, + parameters: { + layout: 'centered', + docs: { + description: { + component: 'Shows a curated list of related applications, pairing each app icon with quick navigation links.', + }, + }, + }, + args: { + relatedApps: mockRelatedApps, + isMobile: false, + }, + argTypes: { + isMobile: { + control: 'boolean', + }, + }, + tags: ['autodocs'], +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Desktop: Story = {} + +export const Mobile: Story = { + args: { + isMobile: true, + }, + parameters: { + viewport: { + defaultViewport: 'mobile2', + }, + }, +} diff --git a/web/app/components/base/list-empty/index.stories.tsx b/web/app/components/base/list-empty/index.stories.tsx new file mode 100644 index 0000000000..36c0e3c7a7 --- /dev/null +++ b/web/app/components/base/list-empty/index.stories.tsx @@ -0,0 +1,49 @@ +import type { Meta, StoryObj } from '@storybook/nextjs' +import ListEmpty from '.' + +const meta = { + title: 'Base/Data Display/ListEmpty', + component: ListEmpty, + parameters: { + layout: 'centered', + docs: { + description: { + component: 'Large empty state card used in panels and drawers to hint at the next action for the user.', + }, + }, + }, + args: { + title: 'No items yet', + description: ( +

+ Add your first entry to see it appear here. Empty states help users discover what happens next. +

+ ), + }, + argTypes: { + description: { control: false }, + icon: { control: false }, + }, + tags: ['autodocs'], +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Default: Story = {} + +export const WithCustomIcon: Story = { + args: { + title: 'Connect a data source', + description: ( +

+ Choose a database, knowledge base, or upload documents to get started with retrieval. +

+ ), + icon: ( +
+ {'\u{26A1}\u{FE0F}'} +
+ ), + }, +} diff --git a/web/app/components/base/loading/index.stories.tsx b/web/app/components/base/loading/index.stories.tsx new file mode 100644 index 0000000000..f22f87516c --- /dev/null +++ b/web/app/components/base/loading/index.stories.tsx @@ -0,0 +1,52 @@ +import type { Meta, StoryObj } from '@storybook/nextjs' +import Loading from '.' + +const meta = { + title: 'Base/Feedback/Loading', + component: Loading, + parameters: { + layout: 'centered', + docs: { + description: { + component: 'Spinner used while fetching data (`area`) or bootstrapping the full application shell (`app`).', + }, + }, + }, + argTypes: { + type: { + control: 'radio', + options: ['area', 'app'], + }, + }, + args: { + type: 'area', + }, + tags: ['autodocs'], +} satisfies Meta + +export default meta +type Story = StoryObj + +const LoadingPreview = ({ type }: { type: 'area' | 'app' }) => { + const containerHeight = type === 'app' ? 'h-48' : 'h-20' + const title = type === 'app' ? 'App loading state' : 'Inline loading state' + + return ( +
+ {title} +
+ +
+
+ ) +} + +export const AreaSpinner: Story = { + render: () => , +} + +export const AppSpinner: Story = { + render: () => , +} diff --git a/web/app/components/base/logo/index.stories.tsx b/web/app/components/base/logo/index.stories.tsx new file mode 100644 index 0000000000..01464b8c13 --- /dev/null +++ b/web/app/components/base/logo/index.stories.tsx @@ -0,0 +1,82 @@ +import type { Meta, StoryObj } from '@storybook/nextjs' +import { ThemeProvider } from 'next-themes' +import type { ReactNode } from 'react' +import DifyLogo from './dify-logo' +import LogoSite from './logo-site' +import LogoEmbeddedChatHeader from './logo-embedded-chat-header' +import LogoEmbeddedChatAvatar from './logo-embedded-chat-avatar' + +const meta = { + title: 'Base/General/Logo', + component: DifyLogo, + parameters: { + layout: 'centered', + docs: { + description: { + component: 'Brand assets rendered in different contexts. DifyLogo adapts to the active theme while other variants target specific surfaces.', + }, + }, + }, + args: { + size: 'medium', + style: 'default', + }, + argTypes: { + size: { + control: 'radio', + options: ['large', 'medium', 'small'], + }, + style: { + control: 'radio', + options: ['default', 'monochromeWhite'], + }, + }, + tags: ['autodocs'], +} satisfies Meta + +export default meta +type Story = StoryObj + +const ThemePreview = ({ theme, children }: { theme: 'light' | 'dark'; children: ReactNode }) => { + return ( + +
+ {children} +
+
+ ) +} + +export const Playground: Story = { + render: ({ size, style }) => { + return ( + +
+
+ Primary logo +
+ + {`size="${size}" | style="${style}"`} +
+
+
+
+ Site favicon + +
+
+ Embedded header + +
+
+ Embedded avatar + +
+
+
+
+ ) + }, +} diff --git a/web/app/components/base/markdown-blocks/code-block.stories.tsx b/web/app/components/base/markdown-blocks/code-block.stories.tsx new file mode 100644 index 0000000000..98473bdf57 --- /dev/null +++ b/web/app/components/base/markdown-blocks/code-block.stories.tsx @@ -0,0 +1,70 @@ +import type { Meta, StoryObj } from '@storybook/nextjs' +import CodeBlock from './code-block' + +const SAMPLE_CODE = `const greet = (name: string) => { + return \`Hello, \${name}\` +} + +console.log(greet('Dify'))` + +const CodeBlockDemo = ({ + language = 'typescript', +}: { + language?: string +}) => { + return ( +
+
Code block
+ + {SAMPLE_CODE} + +
+ ) +} + +const meta = { + title: 'Base/Data Display/CodeBlock', + component: CodeBlockDemo, + parameters: { + layout: 'centered', + docs: { + description: { + component: 'Syntax highlighted code block with copy button and SVG toggle support.', + }, + }, + }, + argTypes: { + language: { + control: 'radio', + options: ['typescript', 'json', 'mermaid'], + }, + }, + args: { + language: 'typescript', + }, + tags: ['autodocs'], +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Playground: Story = {} + +export const Mermaid: Story = { + args: { + language: 'mermaid', + }, + render: ({ language }) => ( +
+ + {`graph TD + Start --> Decision{User message?} + Decision -->|Tool| ToolCall[Call web search] + Decision -->|Respond| Answer[Compose draft] +`} + +
+ ), +} diff --git a/web/app/components/base/markdown-blocks/think-block.stories.tsx b/web/app/components/base/markdown-blocks/think-block.stories.tsx new file mode 100644 index 0000000000..571959259a --- /dev/null +++ b/web/app/components/base/markdown-blocks/think-block.stories.tsx @@ -0,0 +1,78 @@ +import type { Meta, StoryObj } from '@storybook/nextjs' +import { useState } from 'react' +import ThinkBlock from './think-block' +import { ChatContextProvider } from '@/app/components/base/chat/chat/context' + +const THOUGHT_TEXT = ` +Gather docs from knowledge base. +Score snippets against query. +[ENDTHINKFLAG] +` + +const ThinkBlockDemo = ({ + responding = false, +}: { + responding?: boolean +}) => { + const [isResponding, setIsResponding] = useState(responding) + + return ( + +
+
+ Think block + +
+ +
+            {THOUGHT_TEXT}
+          
+
+
+
+ ) +} + +const meta = { + title: 'Base/Data Display/ThinkBlock', + component: ThinkBlockDemo, + parameters: { + layout: 'centered', + docs: { + description: { + component: 'Expandable chain-of-thought block used in chat responses. Toggles between “thinking” and completed states.', + }, + }, + }, + argTypes: { + responding: { control: 'boolean' }, + }, + args: { + responding: false, + }, + tags: ['autodocs'], +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Playground: Story = {} diff --git a/web/app/components/base/markdown/index.stories.tsx b/web/app/components/base/markdown/index.stories.tsx new file mode 100644 index 0000000000..8c940e01cf --- /dev/null +++ b/web/app/components/base/markdown/index.stories.tsx @@ -0,0 +1,88 @@ +import type { Meta, StoryObj } from '@storybook/nextjs' +import { useState } from 'react' +import { Markdown } from '.' + +const SAMPLE_MD = ` +# Product Update + +Our agent now supports **tool-runs** with structured outputs. + +## Highlights +- Faster reasoning with \\(O(n \\log n)\\) planning. +- Inline chain-of-thought: + +
+Thinking aloud + +Check cached metrics first. +If missing, fetch raw warehouse data. +[ENDTHINKFLAG] + +
+ +## Mermaid Diagram +\`\`\`mermaid +graph TD + Start[User Message] --> Parse{Detect Intent?} + Parse -->|Tool| ToolCall[Call search tool] + Parse -->|Answer| Respond[Stream response] + ToolCall --> Respond +\`\`\` + +## Code Example +\`\`\`typescript +const reply = await client.chat({ + message: 'Summarise weekly metrics.', + tags: ['analytics'], +}) +\`\`\` +` + +const MarkdownDemo = ({ + compact = false, +}: { + compact?: boolean +}) => { + const [content] = useState(SAMPLE_MD.trim()) + + return ( +
+
Markdown renderer
+ +
+ ) +} + +const meta = { + title: 'Base/Data Display/Markdown', + component: MarkdownDemo, + parameters: { + layout: 'centered', + docs: { + description: { + component: 'Markdown wrapper with GitHub-flavored markdown, Mermaid diagrams, math, and custom blocks (details, audio, etc.).', + }, + }, + }, + argTypes: { + compact: { control: 'boolean' }, + }, + args: { + compact: false, + }, + tags: ['autodocs'], +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Playground: Story = {} + +export const Compact: Story = { + args: { + compact: true, + }, +} diff --git a/web/app/components/base/mermaid/index.stories.tsx b/web/app/components/base/mermaid/index.stories.tsx new file mode 100644 index 0000000000..73030d7905 --- /dev/null +++ b/web/app/components/base/mermaid/index.stories.tsx @@ -0,0 +1,64 @@ +import type { Meta, StoryObj } from '@storybook/nextjs' +import { useState } from 'react' +import Flowchart from '.' + +const SAMPLE = ` +flowchart LR + A[User Message] --> B{Agent decides} + B -->|Needs tool| C[Search Tool] + C --> D[Combine result] + B -->|Direct answer| D + D --> E[Send response] +` + +const MermaidDemo = ({ + theme = 'light', +}: { + theme?: 'light' | 'dark' +}) => { + const [currentTheme, setCurrentTheme] = useState<'light' | 'dark'>(theme) + + return ( +
+
+ Mermaid diagram + +
+ +
+ ) +} + +const meta = { + title: 'Base/Data Display/Mermaid', + component: MermaidDemo, + parameters: { + layout: 'centered', + docs: { + description: { + component: 'Mermaid renderer with custom theme toggle and caching. Useful for visualizing agent flows.', + }, + }, + }, + argTypes: { + theme: { + control: 'inline-radio', + options: ['light', 'dark'], + }, + }, + args: { + theme: 'light', + }, + tags: ['autodocs'], +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Playground: Story = {} diff --git a/web/app/components/base/message-log-modal/index.stories.tsx b/web/app/components/base/message-log-modal/index.stories.tsx new file mode 100644 index 0000000000..3dd4b06a55 --- /dev/null +++ b/web/app/components/base/message-log-modal/index.stories.tsx @@ -0,0 +1,185 @@ +import type { Meta, StoryObj } from '@storybook/nextjs' +import { useEffect } from 'react' +import MessageLogModal from '.' +import type { IChatItem } from '@/app/components/base/chat/chat/type' +import { useStore } from '@/app/components/app/store' +import type { WorkflowRunDetailResponse } from '@/models/log' +import type { NodeTracing, NodeTracingListResponse } from '@/types/workflow' +import { BlockEnum } from '@/app/components/workflow/types' + +const SAMPLE_APP_DETAIL = { + id: 'app-demo-1', + name: 'Support Assistant', + mode: 'chat', +} as any + +const mockRunDetail: WorkflowRunDetailResponse = { + id: 'run-demo-1', + version: 'v1.0.0', + graph: { + nodes: [], + edges: [], + }, + inputs: JSON.stringify({ question: 'How do I reset my password?' }, null, 2), + inputs_truncated: false, + status: 'succeeded', + outputs: JSON.stringify({ answer: 'Follow the reset link we just emailed you.' }, null, 2), + outputs_truncated: false, + total_steps: 3, + created_by_role: 'account', + created_by_account: { + id: 'account-1', + name: 'Demo Admin', + email: 'demo@example.com', + }, + created_at: 1700000000, + finished_at: 1700000006, + elapsed_time: 5.2, + total_tokens: 864, +} + +const buildNode = (override: Partial): NodeTracing => ({ + id: 'node-start', + index: 0, + predecessor_node_id: '', + node_id: 'node-start', + node_type: BlockEnum.Start, + title: 'Start', + inputs: {}, + inputs_truncated: false, + process_data: {}, + process_data_truncated: false, + outputs: {}, + outputs_truncated: false, + status: 'succeeded', + metadata: { + iterator_length: 1, + iterator_index: 0, + loop_length: 1, + loop_index: 0, + }, + created_at: 1700000000, + created_by: { + id: 'account-1', + name: 'Demo Admin', + email: 'demo@example.com', + }, + finished_at: 1700000001, + elapsed_time: 1.1, + extras: {}, + ...override, +}) + +const mockTracingList: NodeTracingListResponse = { + data: [ + buildNode({}), + buildNode({ + id: 'node-answer', + node_id: 'node-answer', + node_type: BlockEnum.Answer, + title: 'Answer', + inputs: { prompt: 'How do I reset my password?' }, + outputs: { output: 'Follow the reset link we just emailed you.' }, + finished_at: 1700000005, + elapsed_time: 2.6, + }), + ], +} + +const mockCurrentLogItem: IChatItem = { + id: 'message-1', + content: 'Follow the reset link we just emailed you.', + isAnswer: true, + workflow_run_id: 'run-demo-1', +} + +const useMessageLogMocks = () => { + useEffect(() => { + const store = useStore.getState() + store.setAppDetail(SAMPLE_APP_DETAIL) + + const originalFetch = globalThis.fetch?.bind(globalThis) ?? null + + const handle = async (input: RequestInfo | URL, init?: RequestInit) => { + const url = typeof input === 'string' + ? input + : input instanceof URL + ? input.toString() + : input.url + + if (url.includes('/workflow-runs/run-demo-1/') && url.endsWith('/node-executions')) { + return new Response( + JSON.stringify(mockTracingList), + { headers: { 'Content-Type': 'application/json' }, status: 200 }, + ) + } + + if (url.endsWith('/workflow-runs/run-demo-1')) { + return new Response( + JSON.stringify(mockRunDetail), + { headers: { 'Content-Type': 'application/json' }, status: 200 }, + ) + } + + if (originalFetch) + return originalFetch(input, init) + + throw new Error(`Unmocked fetch call for ${url}`) + } + + globalThis.fetch = handle as typeof globalThis.fetch + + return () => { + globalThis.fetch = originalFetch || globalThis.fetch + useStore.getState().setAppDetail(undefined) + } + }, []) +} + +type MessageLogModalProps = React.ComponentProps + +const MessageLogPreview = (props: MessageLogModalProps) => { + useMessageLogMocks() + + return ( +
+ +
+ ) +} + +const meta = { + title: 'Base/Feedback/MessageLogModal', + component: MessageLogPreview, + parameters: { + layout: 'fullscreen', + docs: { + description: { + component: 'Workflow run inspector presented alongside chat transcripts. This Storybook mock provides canned run details and tracing metadata.', + }, + }, + }, + args: { + defaultTab: 'DETAIL', + width: 960, + fixedWidth: true, + onCancel: () => { + console.log('Modal closed') + }, + }, + tags: ['autodocs'], +} satisfies Meta + +export default meta +type Story = StoryObj + +export const FixedPanel: Story = {} + +export const FloatingPanel: Story = { + args: { + fixedWidth: false, + }, +} diff --git a/web/app/components/base/modal-like-wrap/index.stories.tsx b/web/app/components/base/modal-like-wrap/index.stories.tsx index 1de38e14c9..c7d66b8e6a 100644 --- a/web/app/components/base/modal-like-wrap/index.stories.tsx +++ b/web/app/components/base/modal-like-wrap/index.stories.tsx @@ -2,7 +2,7 @@ import type { Meta, StoryObj } from '@storybook/nextjs' import ModalLikeWrap from '.' const meta = { - title: 'Base/Dialog/ModalLikeWrap', + title: 'Base/Feedback/ModalLikeWrap', component: ModalLikeWrap, parameters: { layout: 'centered', diff --git a/web/app/components/base/modal/index.stories.tsx b/web/app/components/base/modal/index.stories.tsx index e561acebbb..c0ea31eb42 100644 --- a/web/app/components/base/modal/index.stories.tsx +++ b/web/app/components/base/modal/index.stories.tsx @@ -3,7 +3,7 @@ import { useEffect, useState } from 'react' import Modal from '.' const meta = { - title: 'Base/Dialog/Modal', + title: 'Base/Feedback/Modal', component: Modal, parameters: { layout: 'fullscreen', diff --git a/web/app/components/base/modal/modal.stories.tsx b/web/app/components/base/modal/modal.stories.tsx index 3e5be78a5b..adb80aebe6 100644 --- a/web/app/components/base/modal/modal.stories.tsx +++ b/web/app/components/base/modal/modal.stories.tsx @@ -3,7 +3,7 @@ import { useEffect, useState } from 'react' import Modal from './modal' const meta = { - title: 'Base/Dialog/RichModal', + title: 'Base/Feedback/RichModal', component: Modal, parameters: { layout: 'fullscreen', diff --git a/web/app/components/base/new-audio-button/index.stories.tsx b/web/app/components/base/new-audio-button/index.stories.tsx index d2f9b8b4d5..c672392562 100644 --- a/web/app/components/base/new-audio-button/index.stories.tsx +++ b/web/app/components/base/new-audio-button/index.stories.tsx @@ -20,7 +20,7 @@ const StoryWrapper = (props: ComponentProps) => { } const meta = { - title: 'Base/Button/NewAudioButton', + title: 'Base/General/NewAudioButton', component: AudioBtn, tags: ['autodocs'], parameters: { diff --git a/web/app/components/base/notion-connector/index.stories.tsx b/web/app/components/base/notion-connector/index.stories.tsx new file mode 100644 index 0000000000..eb8b17df3f --- /dev/null +++ b/web/app/components/base/notion-connector/index.stories.tsx @@ -0,0 +1,26 @@ +import type { Meta, StoryObj } from '@storybook/nextjs' +import NotionConnector from '.' + +const meta = { + title: 'Base/Other/NotionConnector', + component: NotionConnector, + parameters: { + layout: 'centered', + docs: { + description: { + component: 'Call-to-action card inviting users to connect a Notion workspace. Shows the product icon, copy, and primary button.', + }, + }, + }, + args: { + onSetting: () => { + console.log('Open Notion settings') + }, + }, + tags: ['autodocs'], +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Playground: Story = {} diff --git a/web/app/components/base/notion-icon/index.stories.tsx b/web/app/components/base/notion-icon/index.stories.tsx new file mode 100644 index 0000000000..5389a6f935 --- /dev/null +++ b/web/app/components/base/notion-icon/index.stories.tsx @@ -0,0 +1,129 @@ +import type { Meta, StoryObj } from '@storybook/nextjs' +import NotionIcon from '.' + +const meta = { + title: 'Base/General/NotionIcon', + component: NotionIcon, + parameters: { + docs: { + description: { + component: 'Renders workspace and page icons returned from Notion APIs, falling back to text initials or the default document glyph.', + }, + }, + }, + tags: ['autodocs'], + args: { + type: 'workspace', + name: 'Knowledge Base', + src: 'https://cloud.dify.ai/logo/logo.svg', + }, +} satisfies Meta + +export default meta +type Story = StoryObj + +export const WorkspaceIcon: Story = { + render: args => ( +
+ + Workspace icon pulled from a remote URL. +
+ ), + parameters: { + docs: { + source: { + language: 'tsx', + code: ` +` + .trim(), + }, + }, + }, +} + +export const WorkspaceInitials: Story = { + render: args => ( +
+ + Fallback initial rendered when no icon URL is available. +
+ ), + parameters: { + docs: { + source: { + language: 'tsx', + code: ` +` + .trim(), + }, + }, + }, +} + +export const PageEmoji: Story = { + render: args => ( +
+ + Page-level emoji icon returned by the API. +
+ ), + parameters: { + docs: { + source: { + language: 'tsx', + code: ` +` + .trim(), + }, + }, + }, +} + +export const PageImage: Story = { + render: args => ( +
+ + Page icon resolved from an image URL. +
+ ), + parameters: { + docs: { + source: { + language: 'tsx', + code: ` +` + .trim(), + }, + }, + }, +} + +export const DefaultIcon: Story = { + render: args => ( +
+ + When neither emoji nor URL is provided, the generic document icon is shown. +
+ ), + parameters: { + docs: { + source: { + language: 'tsx', + code: ` +` + .trim(), + }, + }, + }, +} diff --git a/web/app/components/base/notion-page-selector/index.stories.tsx b/web/app/components/base/notion-page-selector/index.stories.tsx new file mode 100644 index 0000000000..6fdee03adb --- /dev/null +++ b/web/app/components/base/notion-page-selector/index.stories.tsx @@ -0,0 +1,200 @@ +import type { Meta, StoryObj } from '@storybook/nextjs' +import { useEffect, useMemo, useState } from 'react' +import { CredentialTypeEnum } from '@/app/components/plugins/plugin-auth/types' +import { NotionPageSelector } from '.' +import type { DataSourceCredential } from '@/app/components/header/account-setting/data-source-page-new/types' +import type { NotionPage } from '@/models/common' + +const DATASET_ID = 'dataset-demo' +const CREDENTIALS: DataSourceCredential[] = [ + { + id: 'cred-1', + name: 'Marketing Workspace', + type: CredentialTypeEnum.OAUTH2, + is_default: true, + avatar_url: '', + credential: { + workspace_name: 'Marketing Workspace', + workspace_icon: null, + workspace_id: 'workspace-1', + }, + }, + { + id: 'cred-2', + name: 'Product Workspace', + type: CredentialTypeEnum.OAUTH2, + is_default: false, + avatar_url: '', + credential: { + workspace_name: 'Product Workspace', + workspace_icon: null, + workspace_id: 'workspace-2', + }, + }, +] + +const marketingPages = { + notion_info: [ + { + workspace_name: 'Marketing Workspace', + workspace_id: 'workspace-1', + workspace_icon: null, + pages: [ + { + page_icon: { type: 'emoji', emoji: '\u{1F4CB}', url: null }, + page_id: 'briefs', + page_name: 'Campaign Briefs', + parent_id: 'root', + type: 'page', + is_bound: false, + }, + { + page_icon: { type: 'emoji', emoji: '\u{1F4DD}', url: null }, + page_id: 'notes', + page_name: 'Meeting Notes', + parent_id: 'root', + type: 'page', + is_bound: true, + }, + { + page_icon: { type: 'emoji', emoji: '\u{1F30D}', url: null }, + page_id: 'localizations', + page_name: 'Localization Pipeline', + parent_id: 'briefs', + type: 'page', + is_bound: false, + }, + ], + }, + ], +} + +const productPages = { + notion_info: [ + { + workspace_name: 'Product Workspace', + workspace_id: 'workspace-2', + workspace_icon: null, + pages: [ + { + page_icon: { type: 'emoji', emoji: '\u{1F4A1}', url: null }, + page_id: 'ideas', + page_name: 'Idea Backlog', + parent_id: 'root', + type: 'page', + is_bound: false, + }, + { + page_icon: { type: 'emoji', emoji: '\u{1F9EA}', url: null }, + page_id: 'experiments', + page_name: 'Experiments', + parent_id: 'ideas', + type: 'page', + is_bound: false, + }, + ], + }, + ], +} + +type NotionApiResponse = typeof marketingPages +const emptyNotionResponse: NotionApiResponse = { notion_info: [] } + +const useMockNotionApi = () => { + const responseMap = useMemo(() => ({ + [`${DATASET_ID}:cred-1`]: marketingPages, + [`${DATASET_ID}:cred-2`]: productPages, + }) satisfies Record<`${typeof DATASET_ID}:${typeof CREDENTIALS[number]['id']}`, NotionApiResponse>, []) + + useEffect(() => { + const originalFetch = globalThis.fetch?.bind(globalThis) + + const handler = async (input: RequestInfo | URL, init?: RequestInit) => { + const url = typeof input === 'string' + ? input + : input instanceof URL + ? input.toString() + : input.url + + if (url.includes('/notion/pre-import/pages')) { + const parsed = new URL(url, globalThis.location.origin) + const datasetId = parsed.searchParams.get('dataset_id') || '' + const credentialId = parsed.searchParams.get('credential_id') || '' + let payload: NotionApiResponse = emptyNotionResponse + + if (datasetId === DATASET_ID) { + const credential = CREDENTIALS.find(item => item.id === credentialId) + if (credential) { + const mapKey = `${DATASET_ID}:${credential.id}` as keyof typeof responseMap + payload = responseMap[mapKey] + } + } + + return new Response( + JSON.stringify(payload), + { headers: { 'Content-Type': 'application/json' }, status: 200 }, + ) + } + + if (originalFetch) + return originalFetch(input, init) + + throw new Error(`Unmocked fetch call for ${url}`) + } + + globalThis.fetch = handler as typeof globalThis.fetch + + return () => { + if (originalFetch) + globalThis.fetch = originalFetch + } + }, [responseMap]) +} + +const NotionSelectorPreview = () => { + const [selectedPages, setSelectedPages] = useState([]) + const [credentialId, setCredentialId] = useState() + + useMockNotionApi() + + return ( +
+ page.page_id)} + onSelect={setSelectedPages} + onSelectCredential={setCredentialId} + canPreview + /> +
+
+ Debug state +
+

Active credential: {credentialId || 'None'}

+
+          {JSON.stringify(selectedPages, null, 2)}
+        
+
+
+ ) +} + +const meta = { + title: 'Base/Other/NotionPageSelector', + component: NotionSelectorPreview, + parameters: { + layout: 'centered', + docs: { + description: { + component: 'Credential-aware selector that fetches Notion pages and lets users choose which ones to sync.', + }, + }, + }, + tags: ['autodocs'], +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Playground: Story = {} diff --git a/web/app/components/base/pagination/index.stories.tsx b/web/app/components/base/pagination/index.stories.tsx new file mode 100644 index 0000000000..4ad5488b96 --- /dev/null +++ b/web/app/components/base/pagination/index.stories.tsx @@ -0,0 +1,81 @@ +import type { Meta, StoryObj } from '@storybook/nextjs' +import { useMemo, useState } from 'react' +import Pagination from '.' + +const TOTAL_ITEMS = 120 + +const PaginationDemo = ({ + initialPage = 0, + initialLimit = 10, +}: { + initialPage?: number + initialLimit?: number +}) => { + const [current, setCurrent] = useState(initialPage) + const [limit, setLimit] = useState(initialLimit) + + const pageSummary = useMemo(() => { + const start = current * limit + 1 + const end = Math.min((current + 1) * limit, TOTAL_ITEMS) + return `${start}-${end} of ${TOTAL_ITEMS}` + }, [current, limit]) + + return ( +
+
+ Log pagination + + {pageSummary} + +
+ { + setCurrent(0) + setLimit(nextLimit) + }} + /> +
+ ) +} + +const meta = { + title: 'Base/Navigation/Pagination', + component: PaginationDemo, + parameters: { + layout: 'centered', + docs: { + description: { + component: 'Paginate long lists with optional per-page selector. Demonstrates the inline page jump input and quick limit toggles.', + }, + }, + }, + args: { + initialPage: 0, + initialLimit: 10, + }, + argTypes: { + initialPage: { + control: { type: 'number', min: 0, max: 9, step: 1 }, + }, + initialLimit: { + control: { type: 'radio' }, + options: [10, 25, 50], + }, + }, + tags: ['autodocs'], +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Playground: Story = {} + +export const StartAtMiddle: Story = { + args: { + initialPage: 4, + }, +} diff --git a/web/app/components/base/param-item/index.stories.tsx b/web/app/components/base/param-item/index.stories.tsx new file mode 100644 index 0000000000..a256b56dbf --- /dev/null +++ b/web/app/components/base/param-item/index.stories.tsx @@ -0,0 +1,121 @@ +import type { Meta, StoryObj } from '@storybook/nextjs' +import { useState } from 'react' +import ParamItem from '.' + +type ParamConfig = { + id: string + name: string + tip: string + value: number + min: number + max: number + step: number + allowToggle?: boolean +} + +const PARAMS: ParamConfig[] = [ + { + id: 'temperature', + name: 'Temperature', + tip: 'Controls randomness. Lower values make the model more deterministic, higher values encourage creativity.', + value: 0.7, + min: 0, + max: 2, + step: 0.1, + allowToggle: true, + }, + { + id: 'top_p', + name: 'Top P', + tip: 'Nucleus sampling keeps only the most probable tokens whose cumulative probability exceeds this threshold.', + value: 0.9, + min: 0, + max: 1, + step: 0.05, + }, + { + id: 'frequency_penalty', + name: 'Frequency Penalty', + tip: 'Discourages repeating tokens. Increase to reduce repetition.', + value: 0.2, + min: 0, + max: 1, + step: 0.05, + }, +] + +const ParamItemPlayground = () => { + const [state, setState] = useState>(() => { + return PARAMS.reduce((acc, item) => { + acc[item.id] = { value: item.value, enabled: true } + return acc + }, {} as Record) + }) + + const handleChange = (id: string, value: number) => { + setState(prev => ({ + ...prev, + [id]: { + ...prev[id], + value: Number.parseFloat(value.toFixed(3)), + }, + })) + } + + const handleToggle = (id: string, enabled: boolean) => { + setState(prev => ({ + ...prev, + [id]: { + ...prev[id], + enabled, + }, + })) + } + + return ( +
+
+ Generation parameters + + {JSON.stringify(state, null, 0)} + +
+ {PARAMS.map(param => ( + + ))} +
+ ) +} + +const meta = { + title: 'Base/Data Entry/ParamItem', + component: ParamItemPlayground, + parameters: { + layout: 'centered', + docs: { + description: { + component: 'Slider + numeric input pairing used for model parameter tuning. Supports optional enable toggles per parameter.', + }, + }, + }, + tags: ['autodocs'], +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Playground: Story = {} diff --git a/web/app/components/base/popover/index.stories.tsx b/web/app/components/base/popover/index.stories.tsx new file mode 100644 index 0000000000..1977c89116 --- /dev/null +++ b/web/app/components/base/popover/index.stories.tsx @@ -0,0 +1,120 @@ +import type { Meta, StoryObj } from '@storybook/nextjs' +import { useState } from 'react' +import CustomPopover from '.' + +type PopoverContentProps = { + open?: boolean + onClose?: () => void + onClick?: () => void + title: string + description: string +} + +const PopoverContent = ({ title, description, onClose }: PopoverContentProps) => { + return ( +
+
+ {title} +
+

{description}

+ +
+ ) +} + +const Template = ({ + trigger = 'hover', + position = 'bottom', + manualClose, + disabled, +}: { + trigger?: 'click' | 'hover' + position?: 'bottom' | 'bl' | 'br' + manualClose?: boolean + disabled?: boolean +}) => { + const [hoverHint] = useState( + trigger === 'hover' + ? 'Hover over the badge to reveal quick tips.' + : 'Click the badge to open the contextual menu.', + ) + + return ( +
+

{hoverHint}

+
+ Popover trigger} + htmlContent={ + + } + /> +
+
+ ) +} + +const meta = { + title: 'Base/Feedback/Popover', + component: Template, + parameters: { + layout: 'centered', + docs: { + description: { + component: 'Headless UI popover wrapper supporting hover and click triggers. These examples highlight alignment controls and manual closing.', + }, + }, + }, + argTypes: { + trigger: { + control: 'radio', + options: ['hover', 'click'], + }, + position: { + control: 'radio', + options: ['bottom', 'bl', 'br'], + }, + manualClose: { control: 'boolean' }, + disabled: { control: 'boolean' }, + }, + args: { + trigger: 'hover', + position: 'bottom', + manualClose: false, + disabled: false, + }, + tags: ['autodocs'], +} satisfies Meta + +export default meta +type Story = StoryObj + +export const HoverPopover: Story = {} + +export const ClickPopover: Story = { + args: { + trigger: 'click', + position: 'br', + }, +} + +export const DisabledState: Story = { + args: { + disabled: true, + }, +} diff --git a/web/app/components/base/portal-to-follow-elem/index.stories.tsx b/web/app/components/base/portal-to-follow-elem/index.stories.tsx new file mode 100644 index 0000000000..44c8e964ce --- /dev/null +++ b/web/app/components/base/portal-to-follow-elem/index.stories.tsx @@ -0,0 +1,103 @@ +import type { Meta, StoryObj } from '@storybook/nextjs' +import { useState } from 'react' +import { + PortalToFollowElem, + PortalToFollowElemContent, + PortalToFollowElemTrigger, +} from '.' + +const TooltipCard = ({ title, description }: { title: string; description: string }) => ( +
+
+ {title} +
+

{description}

+
+) + +const PortalDemo = ({ + placement = 'bottom', + triggerPopupSameWidth = false, +}: { + placement?: Parameters[0]['placement'] + triggerPopupSameWidth?: boolean +}) => { + const [controlledOpen, setControlledOpen] = useState(false) + + return ( +
+
+ + + Hover me + + + + + + + + + + + + + + +
+
+ ) +} + +const meta = { + title: 'Base/Feedback/PortalToFollowElem', + component: PortalDemo, + parameters: { + layout: 'centered', + docs: { + description: { + component: 'Floating UI based portal that tracks trigger positioning. Demonstrates both hover-driven and controlled usage.', + }, + }, + }, + argTypes: { + placement: { + control: 'select', + options: ['top', 'top-start', 'top-end', 'bottom', 'bottom-start', 'bottom-end'], + }, + triggerPopupSameWidth: { control: 'boolean' }, + }, + args: { + placement: 'bottom', + triggerPopupSameWidth: false, + }, + tags: ['autodocs'], +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Playground: Story = {} + +export const SameWidthPanel: Story = { + args: { + triggerPopupSameWidth: true, + }, +} diff --git a/web/app/components/base/premium-badge/index.stories.tsx b/web/app/components/base/premium-badge/index.stories.tsx new file mode 100644 index 0000000000..c1f6ede869 --- /dev/null +++ b/web/app/components/base/premium-badge/index.stories.tsx @@ -0,0 +1,64 @@ +import type { Meta, StoryObj } from '@storybook/nextjs' +import PremiumBadge from '.' + +const colors: Array['color']>> = ['blue', 'indigo', 'gray', 'orange'] + +const PremiumBadgeGallery = ({ + size = 'm', + allowHover = false, +}: { + size?: 's' | 'm' + allowHover?: boolean +}) => { + return ( +
+

Brand badge variants

+
+ {colors.map(color => ( +
+ + Premium + + {color} +
+ ))} +
+
+ ) +} + +const meta = { + title: 'Base/General/PremiumBadge', + component: PremiumBadgeGallery, + parameters: { + layout: 'centered', + docs: { + description: { + component: 'Gradient badge used for premium features and upsell prompts. Hover animations can be toggled per instance.', + }, + }, + }, + argTypes: { + size: { + control: 'radio', + options: ['s', 'm'], + }, + allowHover: { control: 'boolean' }, + }, + args: { + size: 'm', + allowHover: false, + }, + tags: ['autodocs'], +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Playground: Story = {} + +export const HoverEnabled: Story = { + args: { + allowHover: true, + }, +} diff --git a/web/app/components/base/progress-bar/progress-circle.stories.tsx b/web/app/components/base/progress-bar/progress-circle.stories.tsx new file mode 100644 index 0000000000..a6a21d2695 --- /dev/null +++ b/web/app/components/base/progress-bar/progress-circle.stories.tsx @@ -0,0 +1,89 @@ +import type { Meta, StoryObj } from '@storybook/nextjs' +import { useState } from 'react' +import ProgressCircle from './progress-circle' + +const ProgressCircleDemo = ({ + initialPercentage = 42, + size = 24, +}: { + initialPercentage?: number + size?: number +}) => { + const [percentage, setPercentage] = useState(initialPercentage) + + return ( +
+
+ Upload progress + + {percentage}% + +
+
+ + setPercentage(Number.parseInt(event.target.value, 10))} + className="h-2 w-full cursor-pointer appearance-none rounded-full bg-divider-subtle accent-primary-600" + /> +
+
+ +
+
+ ProgressCircle renders a deterministic SVG slice. Advance the slider to preview how the arc grows for upload indicators. +
+
+ ) +} + +const meta = { + title: 'Base/Feedback/ProgressCircle', + component: ProgressCircleDemo, + parameters: { + layout: 'centered', + docs: { + description: { + component: 'Compact radial progress indicator wired to upload flows. The story provides a slider to scrub through percentages.', + }, + }, + }, + argTypes: { + initialPercentage: { + control: { type: 'range', min: 0, max: 100, step: 1 }, + }, + size: { + control: { type: 'number', min: 12, max: 48, step: 2 }, + }, + }, + args: { + initialPercentage: 42, + size: 24, + }, + tags: ['autodocs'], +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Playground: Story = {} + +export const NearComplete: Story = { + args: { + initialPercentage: 92, + }, +} diff --git a/web/app/components/base/prompt-editor/index.stories.tsx b/web/app/components/base/prompt-editor/index.stories.tsx index e0d0777306..35058ac37d 100644 --- a/web/app/components/base/prompt-editor/index.stories.tsx +++ b/web/app/components/base/prompt-editor/index.stories.tsx @@ -25,7 +25,7 @@ const PromptEditorMock = ({ value, onChange, placeholder, editable, compact, cla } const meta = { - title: 'Base/Input/PromptEditor', + title: 'Base/Data Entry/PromptEditor', component: PromptEditorMock, parameters: { layout: 'centered', diff --git a/web/app/components/base/prompt-log-modal/index.stories.tsx b/web/app/components/base/prompt-log-modal/index.stories.tsx new file mode 100644 index 0000000000..55389874cd --- /dev/null +++ b/web/app/components/base/prompt-log-modal/index.stories.tsx @@ -0,0 +1,74 @@ +import type { Meta, StoryObj } from '@storybook/nextjs' +import { useEffect } from 'react' +import PromptLogModal from '.' +import { useStore } from '@/app/components/app/store' +import type { IChatItem } from '@/app/components/base/chat/chat/type' + +type PromptLogModalProps = React.ComponentProps + +const mockLogItem: IChatItem = { + id: 'message-1', + isAnswer: true, + content: 'Summarize our meeting notes about launch blockers.', + log: [ + { + role: 'system', + text: 'You are an assistant that extracts key launch blockers from the dialogue.', + }, + { + role: 'user', + text: 'Team discussed QA, marketing assets, and infra readiness. Highlight risks.', + }, + { + role: 'assistant', + text: 'Blocking items:\n1. QA needs staging data by Friday.\n2. Marketing awaiting final visuals.\n3. Infra rollout still missing approval.', + }, + ], +} + +const usePromptLogMocks = () => { + useEffect(() => { + useStore.getState().setCurrentLogItem(mockLogItem) + return () => { + useStore.getState().setCurrentLogItem(undefined) + } + }, []) +} + +const PromptLogPreview = (props: PromptLogModalProps) => { + usePromptLogMocks() + + return ( +
+ +
+ ) +} + +const meta = { + title: 'Base/Feedback/PromptLogModal', + component: PromptLogPreview, + parameters: { + layout: 'fullscreen', + docs: { + description: { + component: 'Shows the prompt and message transcript used for a chat completion, with copy-to-clipboard support for single prompts.', + }, + }, + }, + args: { + width: 960, + onCancel: () => { + console.log('Prompt log closed') + }, + }, + tags: ['autodocs'], +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Playground: Story = {} diff --git a/web/app/components/base/qrcode/index.stories.tsx b/web/app/components/base/qrcode/index.stories.tsx new file mode 100644 index 0000000000..312dc6a5a8 --- /dev/null +++ b/web/app/components/base/qrcode/index.stories.tsx @@ -0,0 +1,52 @@ +import type { Meta, StoryObj } from '@storybook/nextjs' +import ShareQRCode from '.' + +const QRDemo = ({ + content = 'https://dify.ai', +}: { + content?: string +}) => { + return ( +
+

Share QR

+
+ Generated URL: + {content} +
+ +
+ ) +} + +const meta = { + title: 'Base/Data Display/QRCode', + component: QRDemo, + parameters: { + layout: 'centered', + docs: { + description: { + component: 'Toggleable QR code generator for sharing app URLs. Clicking the trigger reveals the code with a download CTA.', + }, + }, + }, + argTypes: { + content: { + control: 'text', + }, + }, + args: { + content: 'https://dify.ai', + }, + tags: ['autodocs'], +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Playground: Story = {} + +export const DemoLink: Story = { + args: { + content: 'https://dify.ai/docs', + }, +} diff --git a/web/app/components/base/radio-card/index.stories.tsx b/web/app/components/base/radio-card/index.stories.tsx index bb45db622c..63dd1ad1ec 100644 --- a/web/app/components/base/radio-card/index.stories.tsx +++ b/web/app/components/base/radio-card/index.stories.tsx @@ -4,7 +4,7 @@ import { RiCloudLine, RiCpuLine, RiDatabase2Line, RiLightbulbLine, RiRocketLine, import RadioCard from '.' const meta = { - title: 'Base/Input/RadioCard', + title: 'Base/Data Entry/RadioCard', component: RadioCard, parameters: { layout: 'centered', diff --git a/web/app/components/base/radio/index.stories.tsx b/web/app/components/base/radio/index.stories.tsx index 0f917320bb..699372097f 100644 --- a/web/app/components/base/radio/index.stories.tsx +++ b/web/app/components/base/radio/index.stories.tsx @@ -3,7 +3,7 @@ import { useState } from 'react' import Radio from '.' const meta = { - title: 'Base/Input/Radio', + title: 'Base/Data Entry/Radio', component: Radio, parameters: { layout: 'centered', diff --git a/web/app/components/base/search-input/index.stories.tsx b/web/app/components/base/search-input/index.stories.tsx index eb051f892f..6b2326322b 100644 --- a/web/app/components/base/search-input/index.stories.tsx +++ b/web/app/components/base/search-input/index.stories.tsx @@ -3,7 +3,7 @@ import { useState } from 'react' import SearchInput from '.' const meta = { - title: 'Base/Input/SearchInput', + title: 'Base/Data Entry/SearchInput', component: SearchInput, parameters: { layout: 'centered', diff --git a/web/app/components/base/segmented-control/index.stories.tsx b/web/app/components/base/segmented-control/index.stories.tsx new file mode 100644 index 0000000000..c83112bd54 --- /dev/null +++ b/web/app/components/base/segmented-control/index.stories.tsx @@ -0,0 +1,92 @@ +import type { Meta, StoryObj } from '@storybook/nextjs' +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 ( +
+
+ Segmented control + + value="{value}" + +
+ +
+ ) +} + +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 + +export default meta +type Story = StoryObj + +export const Playground: Story = {} + +export const AccentState: Story = { + args: { + activeState: 'accent', + }, +} diff --git a/web/app/components/base/select/index.stories.tsx b/web/app/components/base/select/index.stories.tsx index 48a715498b..f1b46f2d55 100644 --- a/web/app/components/base/select/index.stories.tsx +++ b/web/app/components/base/select/index.stories.tsx @@ -4,7 +4,7 @@ import Select, { PortalSelect, SimpleSelect } from '.' import type { Item } from '.' const meta = { - title: 'Base/Input/Select', + title: 'Base/Data Entry/Select', component: SimpleSelect, parameters: { layout: 'centered', diff --git a/web/app/components/base/simple-pie-chart/index.stories.tsx b/web/app/components/base/simple-pie-chart/index.stories.tsx new file mode 100644 index 0000000000..d08c8fa0ce --- /dev/null +++ b/web/app/components/base/simple-pie-chart/index.stories.tsx @@ -0,0 +1,89 @@ +import type { Meta, StoryObj } from '@storybook/nextjs' +import { useMemo, useState } from 'react' +import SimplePieChart from '.' + +const PieChartPlayground = ({ + initialPercentage = 65, + fill = '#fdb022', + stroke = '#f79009', +}: { + initialPercentage?: number + fill?: string + stroke?: string +}) => { + const [percentage, setPercentage] = useState(initialPercentage) + + const label = useMemo(() => `${percentage}%`, [percentage]) + + return ( +
+
+ Conversion snapshot + + {label} + +
+
+ +
+ + setPercentage(Number.parseInt(event.target.value, 10))} + className="h-2 w-full cursor-pointer appearance-none rounded-full bg-divider-subtle accent-primary-600" + /> +
+
+
+ ) +} + +const meta = { + title: 'Base/Data Display/SimplePieChart', + component: PieChartPlayground, + parameters: { + layout: 'centered', + docs: { + description: { + component: 'Thin radial indicator built with ECharts. Use it for quick percentage snapshots inside cards.', + }, + }, + }, + argTypes: { + initialPercentage: { + control: { type: 'range', min: 0, max: 100, step: 1 }, + }, + fill: { control: 'color' }, + stroke: { control: 'color' }, + }, + args: { + initialPercentage: 65, + fill: '#fdb022', + stroke: '#f79009', + }, + tags: ['autodocs'], +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Playground: Story = {} + +export const BrandAccent: Story = { + args: { + fill: '#155EEF', + stroke: '#0040C1', + initialPercentage: 82, + }, +} diff --git a/web/app/components/base/skeleton/index.stories.tsx b/web/app/components/base/skeleton/index.stories.tsx new file mode 100644 index 0000000000..b5ea649b34 --- /dev/null +++ b/web/app/components/base/skeleton/index.stories.tsx @@ -0,0 +1,59 @@ +import type { Meta, StoryObj } from '@storybook/nextjs' +import { + SkeletonContainer, + SkeletonPoint, + SkeletonRectangle, + SkeletonRow, +} from '.' + +const SkeletonDemo = () => { + return ( +
+
Loading skeletons
+
+ + + + + + + + + + + + + +
+
+ + + + + + + + +
+
+ ) +} + +const meta = { + title: 'Base/Feedback/Skeleton', + component: SkeletonDemo, + parameters: { + layout: 'centered', + docs: { + description: { + component: 'Composable skeleton primitives (container, row, rectangle, point) to sketch loading states for panels and lists.', + }, + }, + }, + tags: ['autodocs'], +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Playground: Story = {} diff --git a/web/app/components/base/slider/index.stories.tsx b/web/app/components/base/slider/index.stories.tsx index 691c75d7ad..4d06381d16 100644 --- a/web/app/components/base/slider/index.stories.tsx +++ b/web/app/components/base/slider/index.stories.tsx @@ -3,7 +3,7 @@ import { useState } from 'react' import Slider from '.' const meta = { - title: 'Base/Input/Slider', + title: 'Base/Data Entry/Slider', component: Slider, parameters: { layout: 'centered', diff --git a/web/app/components/base/sort/index.stories.tsx b/web/app/components/base/sort/index.stories.tsx new file mode 100644 index 0000000000..fea21e8edc --- /dev/null +++ b/web/app/components/base/sort/index.stories.tsx @@ -0,0 +1,59 @@ +import type { Meta, StoryObj } from '@storybook/nextjs' +import { useMemo, useState } from 'react' +import Sort from '.' + +const SORT_ITEMS = [ + { value: 'created_at', name: 'Created time' }, + { value: 'updated_at', name: 'Updated time' }, + { value: 'latency', name: 'Latency' }, +] + +const SortPlayground = () => { + const [sortBy, setSortBy] = useState('-created_at') + + const { order, value } = useMemo(() => { + const isDesc = sortBy.startsWith('-') + return { + order: isDesc ? '-' : '', + value: sortBy.replace('-', '') || 'created_at', + } + }, [sortBy]) + + return ( +
+
+ Sort control + + sort_by="{sortBy}" + +
+ { + setSortBy(next as string) + }} + /> +
+ ) +} + +const meta = { + title: 'Base/Data Display/Sort', + component: SortPlayground, + parameters: { + layout: 'centered', + docs: { + description: { + component: 'Sorting trigger used in log tables. Includes dropdown selection and quick toggle between ascending and descending.', + }, + }, + }, + tags: ['autodocs'], +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Playground: Story = {} diff --git a/web/app/components/base/spinner/index.stories.tsx b/web/app/components/base/spinner/index.stories.tsx new file mode 100644 index 0000000000..9792b9b2fc --- /dev/null +++ b/web/app/components/base/spinner/index.stories.tsx @@ -0,0 +1,50 @@ +import type { Meta, StoryObj } from '@storybook/nextjs' +import { useState } from 'react' +import Spinner from '.' + +const SpinnerPlayground = ({ + loading = true, +}: { + loading?: boolean +}) => { + const [isLoading, setIsLoading] = useState(loading) + + return ( +
+

Spinner

+ + +
+ ) +} + +const meta = { + title: 'Base/Feedback/Spinner', + component: SpinnerPlayground, + parameters: { + layout: 'centered', + docs: { + description: { + component: 'Minimal spinner powered by Tailwind utilities. Toggle the state to inspect motion-reduced behaviour.', + }, + }, + }, + argTypes: { + loading: { control: 'boolean' }, + }, + args: { + loading: true, + }, + tags: ['autodocs'], +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Playground: Story = {} diff --git a/web/app/components/base/svg-gallery/index.stories.tsx b/web/app/components/base/svg-gallery/index.stories.tsx new file mode 100644 index 0000000000..65da97d243 --- /dev/null +++ b/web/app/components/base/svg-gallery/index.stories.tsx @@ -0,0 +1,51 @@ +import type { Meta, StoryObj } from '@storybook/nextjs' +import SVGRenderer from '.' + +const SAMPLE_SVG = ` + + + + + + + + + + SVG Preview + Click to open high-resolution preview + + + + + Inline SVG asset + +`.trim() + +const meta = { + title: 'Base/Data Display/SVGRenderer', + component: SVGRenderer, + parameters: { + docs: { + description: { + component: 'Renders sanitized SVG markup with zoom-to-preview capability.', + }, + source: { + language: 'tsx', + code: ` +... +\`} /> + `.trim(), + }, + }, + }, + tags: ['autodocs'], + args: { + content: SAMPLE_SVG, + }, +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Default: Story = {} diff --git a/web/app/components/base/svg/index.stories.tsx b/web/app/components/base/svg/index.stories.tsx new file mode 100644 index 0000000000..0b7d8d23c9 --- /dev/null +++ b/web/app/components/base/svg/index.stories.tsx @@ -0,0 +1,36 @@ +import type { Meta, StoryObj } from '@storybook/nextjs' +import { useState } from 'react' +import SVGBtn from '.' + +const SvgToggleDemo = () => { + const [isSVG, setIsSVG] = useState(false) + + return ( +
+

SVG toggle

+ + + Mode: {isSVG ? 'SVG' : 'PNG'} + +
+ ) +} + +const meta = { + title: 'Base/General/SVGBtn', + component: SvgToggleDemo, + parameters: { + layout: 'centered', + docs: { + description: { + component: 'Small toggle used in icon pickers to switch between SVG and bitmap assets.', + }, + }, + }, + tags: ['autodocs'], +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Playground: Story = {} diff --git a/web/app/components/base/switch/index.stories.tsx b/web/app/components/base/switch/index.stories.tsx index 2753a6a309..5b2b6e59c4 100644 --- a/web/app/components/base/switch/index.stories.tsx +++ b/web/app/components/base/switch/index.stories.tsx @@ -3,7 +3,7 @@ import { useState } from 'react' import Switch from '.' const meta = { - title: 'Base/Input/Switch', + title: 'Base/Data Entry/Switch', component: Switch, parameters: { layout: 'centered', diff --git a/web/app/components/base/tab-header/index.stories.tsx b/web/app/components/base/tab-header/index.stories.tsx new file mode 100644 index 0000000000..cb383947d9 --- /dev/null +++ b/web/app/components/base/tab-header/index.stories.tsx @@ -0,0 +1,64 @@ +import type { Meta, StoryObj } from '@storybook/nextjs' +import { useState } from 'react' +import TabHeader from '.' +import type { ITabHeaderProps } from '.' + +const items: ITabHeaderProps['items'] = [ + { id: 'overview', name: 'Overview' }, + { id: 'playground', name: 'Playground' }, + { id: 'changelog', name: 'Changelog', extra: New }, + { id: 'docs', name: 'Docs', isRight: true }, + { id: 'settings', name: 'Settings', isRight: true, disabled: true }, +] + +const TabHeaderDemo = ({ + initialTab = 'overview', +}: { + initialTab?: string +}) => { + const [activeTab, setActiveTab] = useState(initialTab) + + return ( +
+
+ Tabs + + active="{activeTab}" + +
+ +
+ ) +} + +const meta = { + title: 'Base/Navigation/TabHeader', + component: TabHeaderDemo, + parameters: { + layout: 'centered', + docs: { + description: { + component: 'Two-sided header tabs with optional right-aligned actions. Disabled items illustrate read-only states.', + }, + }, + }, + argTypes: { + initialTab: { + control: 'radio', + options: items.map(item => item.id), + }, + }, + args: { + initialTab: 'overview', + }, + tags: ['autodocs'], +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Playground: Story = {} diff --git a/web/app/components/base/tab-slider-new/index.stories.tsx b/web/app/components/base/tab-slider-new/index.stories.tsx new file mode 100644 index 0000000000..669ec9eed9 --- /dev/null +++ b/web/app/components/base/tab-slider-new/index.stories.tsx @@ -0,0 +1,52 @@ +import type { Meta, StoryObj } from '@storybook/nextjs' +import { useState } from 'react' +import { RiSparklingFill, RiTerminalBoxLine } from '@remixicon/react' +import TabSliderNew from '.' + +const OPTIONS = [ + { value: 'visual', text: 'Visual builder', icon: }, + { value: 'code', text: 'Code', icon: }, +] + +const TabSliderNewDemo = ({ + initialValue = 'visual', +}: { + initialValue?: string +}) => { + const [value, setValue] = useState(initialValue) + + return ( +
+
Pill tabs
+ +
+ ) +} + +const meta = { + title: 'Base/Navigation/TabSliderNew', + component: TabSliderNewDemo, + parameters: { + layout: 'centered', + docs: { + description: { + component: 'Rounded pill tabs suited for switching between editors. Icons illustrate mixed text/icon options.', + }, + }, + }, + argTypes: { + initialValue: { + control: 'radio', + options: OPTIONS.map(option => option.value), + }, + }, + args: { + initialValue: 'visual', + }, + tags: ['autodocs'], +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Playground: Story = {} diff --git a/web/app/components/base/tab-slider-plain/index.stories.tsx b/web/app/components/base/tab-slider-plain/index.stories.tsx new file mode 100644 index 0000000000..dd8c7e0d30 --- /dev/null +++ b/web/app/components/base/tab-slider-plain/index.stories.tsx @@ -0,0 +1,56 @@ +import type { Meta, StoryObj } from '@storybook/nextjs' +import { useState } from 'react' +import TabSliderPlain from '.' + +const OPTIONS = [ + { value: 'analytics', text: 'Analytics' }, + { value: 'activity', text: 'Recent activity' }, + { value: 'alerts', text: 'Alerts' }, +] + +const TabSliderPlainDemo = ({ + initialValue = 'analytics', +}: { + initialValue?: string +}) => { + const [value, setValue] = useState(initialValue) + + return ( +
+
Underline tabs
+ +
+ ) +} + +const meta = { + title: 'Base/Navigation/TabSliderPlain', + component: TabSliderPlainDemo, + parameters: { + layout: 'centered', + docs: { + description: { + component: 'Underline-style navigation commonly used in dashboards. Toggle between three sections.', + }, + }, + }, + argTypes: { + initialValue: { + control: 'radio', + options: OPTIONS.map(option => option.value), + }, + }, + args: { + initialValue: 'analytics', + }, + tags: ['autodocs'], +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Playground: Story = {} diff --git a/web/app/components/base/tab-slider/index.stories.tsx b/web/app/components/base/tab-slider/index.stories.tsx new file mode 100644 index 0000000000..703116fe19 --- /dev/null +++ b/web/app/components/base/tab-slider/index.stories.tsx @@ -0,0 +1,93 @@ +import type { Meta, StoryObj } from '@storybook/nextjs' +import { useEffect, useState } from 'react' +import TabSlider from '.' + +const OPTIONS = [ + { value: 'models', text: 'Models' }, + { value: 'datasets', text: 'Datasets' }, + { value: 'plugins', text: 'Plugins' }, +] + +const TabSliderDemo = ({ + initialValue = 'models', +}: { + initialValue?: string +}) => { + const [value, setValue] = useState(initialValue) + + useEffect(() => { + const originalFetch = globalThis.fetch?.bind(globalThis) + + const handler = async (input: RequestInfo | URL, init?: RequestInit) => { + const url = typeof input === 'string' + ? input + : input instanceof URL + ? input.toString() + : input.url + + if (url.includes('/workspaces/current/plugin/list')) { + return new Response( + JSON.stringify({ + total: 6, + plugins: [], + }), + { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }, + ) + } + + if (originalFetch) + return originalFetch(input, init) + + throw new Error(`Unhandled request for ${url}`) + } + + globalThis.fetch = handler as typeof globalThis.fetch + + return () => { + if (originalFetch) + globalThis.fetch = originalFetch + } + }, []) + + return ( +
+
Segmented tabs
+ +
+ ) +} + +const meta = { + title: 'Base/Navigation/TabSlider', + component: TabSliderDemo, + parameters: { + layout: 'centered', + docs: { + description: { + component: 'Animated segmented control with sliding highlight. A badge appears when plugins are installed (mocked in Storybook).', + }, + }, + }, + argTypes: { + initialValue: { + control: 'radio', + options: OPTIONS.map(option => option.value), + }, + }, + args: { + initialValue: 'models', + }, + tags: ['autodocs'], +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Playground: Story = {} diff --git a/web/app/components/base/tag-input/index.stories.tsx b/web/app/components/base/tag-input/index.stories.tsx index bbb314cf3a..7aae9f2773 100644 --- a/web/app/components/base/tag-input/index.stories.tsx +++ b/web/app/components/base/tag-input/index.stories.tsx @@ -3,7 +3,7 @@ import { useState } from 'react' import TagInput from '.' const meta = { - title: 'Base/Input/TagInput', + title: 'Base/Data Entry/TagInput', component: TagInput, parameters: { layout: 'centered', diff --git a/web/app/components/base/tag-management/index.stories.tsx b/web/app/components/base/tag-management/index.stories.tsx new file mode 100644 index 0000000000..51f4233461 --- /dev/null +++ b/web/app/components/base/tag-management/index.stories.tsx @@ -0,0 +1,131 @@ +import type { Meta, StoryObj } from '@storybook/nextjs' +import { useEffect, useRef } from 'react' +import TagManagementModal from '.' +import { ToastProvider } from '@/app/components/base/toast' +import { useStore as useTagStore } from './store' +import type { Tag } from './constant' + +const INITIAL_TAGS: Tag[] = [ + { id: 'tag-product', name: 'Product', type: 'app', binding_count: 12 }, + { id: 'tag-growth', name: 'Growth', type: 'app', binding_count: 4 }, + { id: 'tag-beta', name: 'Beta User', type: 'app', binding_count: 2 }, + { id: 'tag-rag', name: 'RAG', type: 'knowledge', binding_count: 3 }, + { id: 'tag-updates', name: 'Release Notes', type: 'knowledge', binding_count: 6 }, +] + +const TagManagementPlayground = ({ + type = 'app', +}: { + type?: 'app' | 'knowledge' +}) => { + const originalFetchRef = useRef(null) + const tagsRef = useRef(INITIAL_TAGS) + const setTagList = useTagStore(s => s.setTagList) + const showModal = useTagStore(s => s.showTagManagementModal) + const setShowModal = useTagStore(s => s.setShowTagManagementModal) + + useEffect(() => { + setTagList(tagsRef.current) + setShowModal(true) + }, [setTagList, setShowModal]) + + useEffect(() => { + originalFetchRef.current = globalThis.fetch?.bind(globalThis) + + const handler = async (input: RequestInfo | URL, init?: RequestInit) => { + const request = input instanceof Request ? input : new Request(input, init) + const url = request.url + const method = request.method.toUpperCase() + const parsedUrl = new URL(url, window.location.origin) + + if (parsedUrl.pathname.endsWith('/tags')) { + if (method === 'GET') { + const tagType = parsedUrl.searchParams.get('type') || 'app' + const payload = tagsRef.current.filter(tag => tag.type === tagType) + return new Response(JSON.stringify(payload), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + } + if (method === 'POST') { + const body = await request.clone().json() as { name: string; type: string } + const newTag: Tag = { + id: `tag-${Date.now()}`, + name: body.name, + type: body.type, + binding_count: 0, + } + tagsRef.current = [newTag, ...tagsRef.current] + setTagList(tagsRef.current) + return new Response(JSON.stringify(newTag), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + } + } + + if (parsedUrl.pathname.endsWith('/tag-bindings/create') || parsedUrl.pathname.endsWith('/tag-bindings/remove')) { + return new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + } + + if (originalFetchRef.current) + return originalFetchRef.current(request) + + throw new Error(`Unhandled request in mock fetch: ${url}`) + } + + globalThis.fetch = handler as typeof globalThis.fetch + + return () => { + if (originalFetchRef.current) + globalThis.fetch = originalFetchRef.current + } + }, [setTagList]) + + return ( + +
+ +

Mocked tag management flows with create and bind actions.

+
+ +
+ ) +} + +const meta = { + title: 'Base/Data Display/TagManagementModal', + component: TagManagementPlayground, + parameters: { + layout: 'centered', + docs: { + description: { + component: 'Complete tag management modal with mocked service calls for browsing and creating tags.', + }, + }, + }, + argTypes: { + type: { + control: 'radio', + options: ['app', 'knowledge'], + }, + }, + args: { + type: 'app', + }, + tags: ['autodocs'], +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Playground: Story = {} diff --git a/web/app/components/base/tag/index.stories.tsx b/web/app/components/base/tag/index.stories.tsx new file mode 100644 index 0000000000..8ca15c0c8b --- /dev/null +++ b/web/app/components/base/tag/index.stories.tsx @@ -0,0 +1,62 @@ +import type { Meta, StoryObj } from '@storybook/nextjs' +import Tag from '.' + +const COLORS: Array['color']>> = ['green', 'yellow', 'red', 'gray'] + +const TagGallery = ({ + bordered = false, + hideBg = false, +}: { + bordered?: boolean + hideBg?: boolean +}) => { + return ( +
+
Tag variants
+
+ {COLORS.map(color => ( +
+ + {color.charAt(0).toUpperCase() + color.slice(1)} + + {color} +
+ ))} +
+
+ ) +} + +const meta = { + title: 'Base/Data Display/Tag', + component: TagGallery, + parameters: { + layout: 'centered', + docs: { + description: { + component: 'Color-coded label component. Toggle borders or remove background to fit dark/light surfaces.', + }, + }, + }, + argTypes: { + bordered: { control: 'boolean' }, + hideBg: { control: 'boolean' }, + }, + args: { + bordered: false, + hideBg: false, + }, + tags: ['autodocs'], +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Playground: Story = {} + +export const Outlined: Story = { + args: { + bordered: true, + hideBg: true, + }, +} diff --git a/web/app/components/base/textarea/index.stories.tsx b/web/app/components/base/textarea/index.stories.tsx index ec27aac22b..41d8bda458 100644 --- a/web/app/components/base/textarea/index.stories.tsx +++ b/web/app/components/base/textarea/index.stories.tsx @@ -3,7 +3,7 @@ import { useState } from 'react' import Textarea from '.' const meta = { - title: 'Base/Input/Textarea', + title: 'Base/Data Entry/Textarea', component: Textarea, parameters: { layout: 'centered', diff --git a/web/app/components/base/toast/index.stories.tsx b/web/app/components/base/toast/index.stories.tsx new file mode 100644 index 0000000000..6ef65475cb --- /dev/null +++ b/web/app/components/base/toast/index.stories.tsx @@ -0,0 +1,104 @@ +import type { Meta, StoryObj } from '@storybook/nextjs' +import { useCallback } from 'react' +import Toast, { ToastProvider, useToastContext } from '.' + +const ToastControls = () => { + const { notify } = useToastContext() + + const trigger = useCallback((type: 'success' | 'error' | 'warning' | 'info') => { + notify({ + type, + message: `This is a ${type} toast`, + children: type === 'info' ? 'Additional details can live here.' : undefined, + }) + }, [notify]) + + return ( +
+ + + + +
+ ) +} + +const ToastProviderDemo = () => { + return ( + +
+
Toast provider
+ +
+
+ ) +} + +const StaticToastDemo = () => { + return ( +
+
Static API
+ +
+ ) +} + +const meta = { + title: 'Base/Feedback/Toast', + component: ToastProviderDemo, + parameters: { + layout: 'centered', + docs: { + description: { + component: 'ToastProvider based notifications and the static Toast.notify helper. Buttons showcase each toast variant.', + }, + }, + }, + tags: ['autodocs'], +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Provider: Story = {} + +export const StaticApi: Story = { + render: () => , +} diff --git a/web/app/components/base/tooltip/index.stories.tsx b/web/app/components/base/tooltip/index.stories.tsx new file mode 100644 index 0000000000..aeca69464f --- /dev/null +++ b/web/app/components/base/tooltip/index.stories.tsx @@ -0,0 +1,60 @@ +import type { Meta, StoryObj } from '@storybook/nextjs' +import Tooltip from '.' + +const TooltipGrid = () => { + return ( +
+
Hover tooltips
+
+ + + + + + Right tooltip + + +
+
Click tooltips
+
+ + + + + + Plain content + + +
+
+ ) +} + +const meta = { + title: 'Base/Feedback/Tooltip', + component: TooltipGrid, + parameters: { + layout: 'centered', + docs: { + description: { + component: 'Portal-based tooltip component supporting hover and click triggers, custom placements, and decorated content.', + }, + }, + }, + tags: ['autodocs'], +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Playground: Story = {} diff --git a/web/app/components/base/video-gallery/index.stories.tsx b/web/app/components/base/video-gallery/index.stories.tsx new file mode 100644 index 0000000000..7e17ee208c --- /dev/null +++ b/web/app/components/base/video-gallery/index.stories.tsx @@ -0,0 +1,40 @@ +import type { Meta, StoryObj } from '@storybook/nextjs' +import VideoGallery from '.' + +const VIDEO_SOURCES = [ + 'https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4', + 'https://interactive-examples.mdn.mozilla.net/media/cc0-videos/forest.mp4', +] + +const meta = { + title: 'Base/Data Display/VideoGallery', + component: VideoGallery, + parameters: { + layout: 'fullscreen', + docs: { + description: { + component: 'Stacked list of video players with custom controls for progress, volume, and fullscreen.', + }, + source: { + language: 'tsx', + code: ` + + `.trim(), + }, + }, + }, + tags: ['autodocs'], + args: { + srcs: VIDEO_SOURCES, + }, +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Default: Story = {} diff --git a/web/app/components/base/voice-input/index.stories.tsx b/web/app/components/base/voice-input/index.stories.tsx index 0a7980e9ac..714cde72b5 100644 --- a/web/app/components/base/voice-input/index.stories.tsx +++ b/web/app/components/base/voice-input/index.stories.tsx @@ -81,7 +81,7 @@ const VoiceInputMock = ({ onConverted, onCancel }: any) => { } const meta = { - title: 'Base/Input/VoiceInput', + title: 'Base/Data Entry/VoiceInput', component: VoiceInputMock, parameters: { layout: 'centered', diff --git a/web/app/components/base/with-input-validation/index.stories.tsx b/web/app/components/base/with-input-validation/index.stories.tsx index 5a7e4bc678..26fa9747d8 100644 --- a/web/app/components/base/with-input-validation/index.stories.tsx +++ b/web/app/components/base/with-input-validation/index.stories.tsx @@ -63,7 +63,7 @@ const ValidatedUserCard = withValidation(UserCard, userSchema) const ValidatedProductCard = withValidation(ProductCard, productSchema) const meta = { - title: 'Base/Input/WithInputValidation', + title: 'Base/Data Entry/WithInputValidation', parameters: { layout: 'centered', docs: {