dify
This commit is contained in:
464
dify/web/app/components/app-sidebar/app-info.tsx
Normal file
464
dify/web/app/components/app-sidebar/app-info.tsx
Normal file
@@ -0,0 +1,464 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useContext } from 'use-context-selector'
|
||||
import React, { useCallback, useState } from 'react'
|
||||
import {
|
||||
RiDeleteBinLine,
|
||||
RiEditLine,
|
||||
RiEqualizer2Line,
|
||||
RiExchange2Line,
|
||||
RiFileCopy2Line,
|
||||
RiFileDownloadLine,
|
||||
RiFileUploadLine,
|
||||
} from '@remixicon/react'
|
||||
import AppIcon from '../base/app-icon'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import { ToastContext } from '@/app/components/base/toast'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { copyApp, deleteApp, exportAppConfig, updateAppInfo } from '@/service/apps'
|
||||
import type { DuplicateAppModalProps } from '@/app/components/app/duplicate-modal'
|
||||
import type { CreateAppModalProps } from '@/app/components/explore/create-app-modal'
|
||||
import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
|
||||
import { getRedirection } from '@/utils/app-redirection'
|
||||
import type { EnvironmentVariable } from '@/app/components/workflow/types'
|
||||
import { fetchWorkflowDraft } from '@/service/workflow'
|
||||
import ContentDialog from '@/app/components/base/content-dialog'
|
||||
import Button from '@/app/components/base/button'
|
||||
import CardView from '@/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/card-view'
|
||||
import type { Operation } from './app-operations'
|
||||
import AppOperations from './app-operations'
|
||||
import dynamic from 'next/dynamic'
|
||||
import cn from '@/utils/classnames'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
|
||||
const SwitchAppModal = dynamic(() => import('@/app/components/app/switch-app-modal'), {
|
||||
ssr: false,
|
||||
})
|
||||
const CreateAppModal = dynamic(() => import('@/app/components/explore/create-app-modal'), {
|
||||
ssr: false,
|
||||
})
|
||||
const DuplicateAppModal = dynamic(() => import('@/app/components/app/duplicate-modal'), {
|
||||
ssr: false,
|
||||
})
|
||||
const Confirm = dynamic(() => import('@/app/components/base/confirm'), {
|
||||
ssr: false,
|
||||
})
|
||||
const UpdateDSLModal = dynamic(() => import('@/app/components/workflow/update-dsl-modal'), {
|
||||
ssr: false,
|
||||
})
|
||||
const DSLExportConfirmModal = dynamic(() => import('@/app/components/workflow/dsl-export-confirm-modal'), {
|
||||
ssr: false,
|
||||
})
|
||||
|
||||
export type IAppInfoProps = {
|
||||
expand: boolean
|
||||
onlyShowDetail?: boolean
|
||||
openState?: boolean
|
||||
onDetailExpand?: (expand: boolean) => void
|
||||
}
|
||||
|
||||
const AppInfo = ({ expand, onlyShowDetail = false, openState = false, onDetailExpand }: IAppInfoProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { notify } = useContext(ToastContext)
|
||||
const { replace } = useRouter()
|
||||
const { onPlanInfoChanged } = useProviderContext()
|
||||
const appDetail = useAppStore(state => state.appDetail)
|
||||
const setAppDetail = useAppStore(state => state.setAppDetail)
|
||||
const [open, setOpen] = useState(openState)
|
||||
const [showEditModal, setShowEditModal] = useState(false)
|
||||
const [showDuplicateModal, setShowDuplicateModal] = useState(false)
|
||||
const [showConfirmDelete, setShowConfirmDelete] = useState(false)
|
||||
const [showSwitchModal, setShowSwitchModal] = useState<boolean>(false)
|
||||
const [showImportDSLModal, setShowImportDSLModal] = useState<boolean>(false)
|
||||
const [secretEnvList, setSecretEnvList] = useState<EnvironmentVariable[]>([])
|
||||
const [showExportWarning, setShowExportWarning] = useState(false)
|
||||
|
||||
const onEdit: CreateAppModalProps['onConfirm'] = useCallback(async ({
|
||||
name,
|
||||
icon_type,
|
||||
icon,
|
||||
icon_background,
|
||||
description,
|
||||
use_icon_as_answer_icon,
|
||||
max_active_requests,
|
||||
}) => {
|
||||
if (!appDetail)
|
||||
return
|
||||
try {
|
||||
const app = await updateAppInfo({
|
||||
appID: appDetail.id,
|
||||
name,
|
||||
icon_type,
|
||||
icon,
|
||||
icon_background,
|
||||
description,
|
||||
use_icon_as_answer_icon,
|
||||
max_active_requests,
|
||||
})
|
||||
setShowEditModal(false)
|
||||
notify({
|
||||
type: 'success',
|
||||
message: t('app.editDone'),
|
||||
})
|
||||
setAppDetail(app)
|
||||
}
|
||||
catch {
|
||||
notify({ type: 'error', message: t('app.editFailed') })
|
||||
}
|
||||
}, [appDetail, notify, setAppDetail, t])
|
||||
|
||||
const onCopy: DuplicateAppModalProps['onConfirm'] = async ({ name, icon_type, icon, icon_background }) => {
|
||||
if (!appDetail)
|
||||
return
|
||||
try {
|
||||
const newApp = await copyApp({
|
||||
appID: appDetail.id,
|
||||
name,
|
||||
icon_type,
|
||||
icon,
|
||||
icon_background,
|
||||
mode: appDetail.mode,
|
||||
})
|
||||
setShowDuplicateModal(false)
|
||||
notify({
|
||||
type: 'success',
|
||||
message: t('app.newApp.appCreated'),
|
||||
})
|
||||
localStorage.setItem(NEED_REFRESH_APP_LIST_KEY, '1')
|
||||
onPlanInfoChanged()
|
||||
getRedirection(true, newApp, replace)
|
||||
}
|
||||
catch {
|
||||
notify({ type: 'error', message: t('app.newApp.appCreateFailed') })
|
||||
}
|
||||
}
|
||||
|
||||
const onExport = async (include = false) => {
|
||||
if (!appDetail)
|
||||
return
|
||||
try {
|
||||
const { data } = await exportAppConfig({
|
||||
appID: appDetail.id,
|
||||
include,
|
||||
})
|
||||
const a = document.createElement('a')
|
||||
const file = new Blob([data], { type: 'application/yaml' })
|
||||
const url = URL.createObjectURL(file)
|
||||
a.href = url
|
||||
a.download = `${appDetail.name}.yml`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
catch {
|
||||
notify({ type: 'error', message: t('app.exportFailed') })
|
||||
}
|
||||
}
|
||||
|
||||
const exportCheck = async () => {
|
||||
if (!appDetail)
|
||||
return
|
||||
if (appDetail.mode !== AppModeEnum.WORKFLOW && appDetail.mode !== AppModeEnum.ADVANCED_CHAT) {
|
||||
onExport()
|
||||
return
|
||||
}
|
||||
|
||||
setShowExportWarning(true)
|
||||
}
|
||||
|
||||
const handleConfirmExport = async () => {
|
||||
if (!appDetail)
|
||||
return
|
||||
setShowExportWarning(false)
|
||||
try {
|
||||
const workflowDraft = await fetchWorkflowDraft(`/apps/${appDetail.id}/workflows/draft`)
|
||||
const list = (workflowDraft.environment_variables || []).filter(env => env.value_type === 'secret')
|
||||
if (list.length === 0) {
|
||||
onExport()
|
||||
return
|
||||
}
|
||||
setSecretEnvList(list)
|
||||
}
|
||||
catch {
|
||||
notify({ type: 'error', message: t('app.exportFailed') })
|
||||
}
|
||||
}
|
||||
|
||||
const onConfirmDelete = useCallback(async () => {
|
||||
if (!appDetail)
|
||||
return
|
||||
try {
|
||||
await deleteApp(appDetail.id)
|
||||
notify({ type: 'success', message: t('app.appDeleted') })
|
||||
onPlanInfoChanged()
|
||||
setAppDetail()
|
||||
replace('/apps')
|
||||
}
|
||||
catch (e: any) {
|
||||
notify({
|
||||
type: 'error',
|
||||
message: `${t('app.appDeleteFailed')}${'message' in e ? `: ${e.message}` : ''}`,
|
||||
})
|
||||
}
|
||||
setShowConfirmDelete(false)
|
||||
}, [appDetail, notify, onPlanInfoChanged, replace, setAppDetail, t])
|
||||
|
||||
const { isCurrentWorkspaceEditor } = useAppContext()
|
||||
|
||||
if (!appDetail)
|
||||
return null
|
||||
|
||||
const primaryOperations = [
|
||||
{
|
||||
id: 'edit',
|
||||
title: t('app.editApp'),
|
||||
icon: <RiEditLine />,
|
||||
onClick: () => {
|
||||
setOpen(false)
|
||||
onDetailExpand?.(false)
|
||||
setShowEditModal(true)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'duplicate',
|
||||
title: t('app.duplicate'),
|
||||
icon: <RiFileCopy2Line />,
|
||||
onClick: () => {
|
||||
setOpen(false)
|
||||
onDetailExpand?.(false)
|
||||
setShowDuplicateModal(true)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'export',
|
||||
title: t('app.export'),
|
||||
icon: <RiFileDownloadLine />,
|
||||
onClick: exportCheck,
|
||||
},
|
||||
]
|
||||
|
||||
const secondaryOperations: Operation[] = [
|
||||
// Import DSL (conditional)
|
||||
...(appDetail.mode === AppModeEnum.ADVANCED_CHAT || appDetail.mode === AppModeEnum.WORKFLOW) ? [{
|
||||
id: 'import',
|
||||
title: t('workflow.common.importDSL'),
|
||||
icon: <RiFileUploadLine />,
|
||||
onClick: () => {
|
||||
setOpen(false)
|
||||
onDetailExpand?.(false)
|
||||
setShowImportDSLModal(true)
|
||||
},
|
||||
}] : [],
|
||||
// Divider
|
||||
{
|
||||
id: 'divider-1',
|
||||
title: '',
|
||||
icon: <></>,
|
||||
onClick: () => { /* divider has no action */ },
|
||||
type: 'divider' as const,
|
||||
},
|
||||
// Delete operation
|
||||
{
|
||||
id: 'delete',
|
||||
title: t('common.operation.delete'),
|
||||
icon: <RiDeleteBinLine />,
|
||||
onClick: () => {
|
||||
setOpen(false)
|
||||
onDetailExpand?.(false)
|
||||
setShowConfirmDelete(true)
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
// Keep the switch operation separate as it's not part of the main operations
|
||||
const switchOperation = (appDetail.mode === AppModeEnum.COMPLETION || appDetail.mode === AppModeEnum.CHAT) ? {
|
||||
id: 'switch',
|
||||
title: t('app.switch'),
|
||||
icon: <RiExchange2Line />,
|
||||
onClick: () => {
|
||||
setOpen(false)
|
||||
onDetailExpand?.(false)
|
||||
setShowSwitchModal(true)
|
||||
},
|
||||
} : null
|
||||
|
||||
return (
|
||||
<div>
|
||||
{!onlyShowDetail && (
|
||||
<button type="button"
|
||||
onClick={() => {
|
||||
if (isCurrentWorkspaceEditor)
|
||||
setOpen(v => !v)
|
||||
}}
|
||||
className='block w-full'
|
||||
>
|
||||
<div className='flex flex-col gap-2 rounded-lg p-1 hover:bg-state-base-hover'>
|
||||
<div className='flex items-center gap-1'>
|
||||
<div className={cn(!expand && 'ml-1')}>
|
||||
<AppIcon
|
||||
size={expand ? 'large' : 'small'}
|
||||
iconType={appDetail.icon_type}
|
||||
icon={appDetail.icon}
|
||||
background={appDetail.icon_background}
|
||||
imageUrl={appDetail.icon_url}
|
||||
/>
|
||||
</div>
|
||||
{expand && (
|
||||
<div className='ml-auto flex items-center justify-center rounded-md p-0.5'>
|
||||
<div className='flex h-5 w-5 items-center justify-center'>
|
||||
<RiEqualizer2Line className='h-4 w-4 text-text-tertiary' />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!expand && (
|
||||
<div className='flex items-center justify-center'>
|
||||
<div className='flex h-5 w-5 items-center justify-center rounded-md p-0.5'>
|
||||
<RiEqualizer2Line className='h-4 w-4 text-text-tertiary' />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{expand && (
|
||||
<div className='flex flex-col items-start gap-1'>
|
||||
<div className='flex w-full'>
|
||||
<div className='system-md-semibold truncate whitespace-nowrap text-text-secondary'>{appDetail.name}</div>
|
||||
</div>
|
||||
<div className='system-2xs-medium-uppercase whitespace-nowrap text-text-tertiary'>
|
||||
{appDetail.mode === AppModeEnum.ADVANCED_CHAT ? t('app.types.advanced')
|
||||
: appDetail.mode === AppModeEnum.AGENT_CHAT ? t('app.types.agent')
|
||||
: appDetail.mode === AppModeEnum.CHAT ? t('app.types.chatbot')
|
||||
: appDetail.mode === AppModeEnum.COMPLETION ? t('app.types.completion')
|
||||
: t('app.types.workflow')}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
<ContentDialog
|
||||
show={onlyShowDetail ? openState : open}
|
||||
onClose={() => {
|
||||
setOpen(false)
|
||||
onDetailExpand?.(false)
|
||||
}}
|
||||
className='absolute bottom-2 left-2 top-2 flex w-[420px] flex-col rounded-2xl !p-0'
|
||||
>
|
||||
<div className='flex shrink-0 flex-col items-start justify-center gap-3 self-stretch p-4'>
|
||||
<div className='flex items-center gap-3 self-stretch'>
|
||||
<AppIcon
|
||||
size='large'
|
||||
iconType={appDetail.icon_type}
|
||||
icon={appDetail.icon}
|
||||
background={appDetail.icon_background}
|
||||
imageUrl={appDetail.icon_url}
|
||||
/>
|
||||
<div className='flex flex-1 flex-col items-start justify-center overflow-hidden'>
|
||||
<div className='system-md-semibold w-full truncate text-text-secondary'>{appDetail.name}</div>
|
||||
<div className='system-2xs-medium-uppercase text-text-tertiary'>{appDetail.mode === AppModeEnum.ADVANCED_CHAT ? t('app.types.advanced') : appDetail.mode === AppModeEnum.AGENT_CHAT ? t('app.types.agent') : appDetail.mode === AppModeEnum.CHAT ? t('app.types.chatbot') : appDetail.mode === AppModeEnum.COMPLETION ? t('app.types.completion') : t('app.types.workflow')}</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* description */}
|
||||
{appDetail.description && (
|
||||
<div className='system-xs-regular overflow-wrap-anywhere max-h-[105px] w-full max-w-full overflow-y-auto whitespace-normal break-words text-text-tertiary'>{appDetail.description}</div>
|
||||
)}
|
||||
{/* operations */}
|
||||
<AppOperations
|
||||
gap={4}
|
||||
primaryOperations={primaryOperations}
|
||||
secondaryOperations={secondaryOperations}
|
||||
/>
|
||||
</div>
|
||||
<CardView
|
||||
appId={appDetail.id}
|
||||
isInPanel={true}
|
||||
className='flex flex-1 flex-col gap-2 overflow-auto px-2 py-1'
|
||||
/>
|
||||
{/* Switch operation (if available) */}
|
||||
{switchOperation && (
|
||||
<div className='flex min-h-fit shrink-0 flex-col items-start justify-center gap-3 self-stretch pb-2'>
|
||||
<Button
|
||||
size={'medium'}
|
||||
variant={'ghost'}
|
||||
className='gap-0.5'
|
||||
onClick={switchOperation.onClick}
|
||||
>
|
||||
{switchOperation.icon}
|
||||
<span className='system-sm-medium text-text-tertiary'>{switchOperation.title}</span>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</ContentDialog>
|
||||
{showSwitchModal && (
|
||||
<SwitchAppModal
|
||||
inAppDetail
|
||||
show={showSwitchModal}
|
||||
appDetail={appDetail}
|
||||
onClose={() => setShowSwitchModal(false)}
|
||||
onSuccess={() => setShowSwitchModal(false)}
|
||||
/>
|
||||
)}
|
||||
{showEditModal && (
|
||||
<CreateAppModal
|
||||
isEditModal
|
||||
appName={appDetail.name}
|
||||
appIconType={appDetail.icon_type}
|
||||
appIcon={appDetail.icon}
|
||||
appIconBackground={appDetail.icon_background}
|
||||
appIconUrl={appDetail.icon_url}
|
||||
appDescription={appDetail.description}
|
||||
appMode={appDetail.mode}
|
||||
appUseIconAsAnswerIcon={appDetail.use_icon_as_answer_icon}
|
||||
max_active_requests={appDetail.max_active_requests ?? null}
|
||||
show={showEditModal}
|
||||
onConfirm={onEdit}
|
||||
onHide={() => setShowEditModal(false)}
|
||||
/>
|
||||
)}
|
||||
{showDuplicateModal && (
|
||||
<DuplicateAppModal
|
||||
appName={appDetail.name}
|
||||
icon_type={appDetail.icon_type}
|
||||
icon={appDetail.icon}
|
||||
icon_background={appDetail.icon_background}
|
||||
icon_url={appDetail.icon_url}
|
||||
show={showDuplicateModal}
|
||||
onConfirm={onCopy}
|
||||
onHide={() => setShowDuplicateModal(false)}
|
||||
/>
|
||||
)}
|
||||
{showConfirmDelete && (
|
||||
<Confirm
|
||||
title={t('app.deleteAppConfirmTitle')}
|
||||
content={t('app.deleteAppConfirmContent')}
|
||||
isShow={showConfirmDelete}
|
||||
onConfirm={onConfirmDelete}
|
||||
onCancel={() => setShowConfirmDelete(false)}
|
||||
/>
|
||||
)}
|
||||
{showImportDSLModal && (
|
||||
<UpdateDSLModal
|
||||
onCancel={() => setShowImportDSLModal(false)}
|
||||
onBackup={exportCheck}
|
||||
/>
|
||||
)}
|
||||
{secretEnvList.length > 0 && (
|
||||
<DSLExportConfirmModal
|
||||
envList={secretEnvList}
|
||||
onConfirm={onExport}
|
||||
onClose={() => setSecretEnvList([])}
|
||||
/>
|
||||
)}
|
||||
{showExportWarning && (
|
||||
<Confirm
|
||||
type="info"
|
||||
isShow={showExportWarning}
|
||||
title={t('workflow.sidebar.exportWarning')}
|
||||
content={t('workflow.sidebar.exportWarningDesc')}
|
||||
onConfirm={handleConfirmExport}
|
||||
onCancel={() => setShowExportWarning(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(AppInfo)
|
||||
214
dify/web/app/components/app-sidebar/app-operations.tsx
Normal file
214
dify/web/app/components/app-sidebar/app-operations.tsx
Normal file
@@ -0,0 +1,214 @@
|
||||
import type { JSX } from 'react'
|
||||
import { cloneElement, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Button from '@/app/components/base/button'
|
||||
import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '../base/portal-to-follow-elem'
|
||||
import { RiMoreLine } from '@remixicon/react'
|
||||
|
||||
export type Operation = {
|
||||
id: string
|
||||
title: string
|
||||
icon: JSX.Element
|
||||
onClick: () => void
|
||||
type?: 'divider'
|
||||
}
|
||||
|
||||
type AppOperationsProps = {
|
||||
gap: number
|
||||
operations?: Operation[]
|
||||
primaryOperations?: Operation[]
|
||||
secondaryOperations?: Operation[]
|
||||
}
|
||||
|
||||
const EMPTY_OPERATIONS: Operation[] = []
|
||||
|
||||
const AppOperations = ({
|
||||
operations,
|
||||
primaryOperations,
|
||||
secondaryOperations,
|
||||
gap,
|
||||
}: AppOperationsProps) => {
|
||||
const { t } = useTranslation()
|
||||
const [visibleOpreations, setVisibleOperations] = useState<Operation[]>([])
|
||||
const [moreOperations, setMoreOperations] = useState<Operation[]>([])
|
||||
const [showMore, setShowMore] = useState(false)
|
||||
const navRef = useRef<HTMLDivElement>(null)
|
||||
const handleTriggerMore = useCallback(() => {
|
||||
setShowMore(true)
|
||||
}, [setShowMore])
|
||||
|
||||
const primaryOps = useMemo(() => {
|
||||
if (operations)
|
||||
return operations
|
||||
if (primaryOperations)
|
||||
return primaryOperations
|
||||
return EMPTY_OPERATIONS
|
||||
}, [operations, primaryOperations])
|
||||
|
||||
const secondaryOps = useMemo(() => {
|
||||
if (operations)
|
||||
return EMPTY_OPERATIONS
|
||||
if (secondaryOperations)
|
||||
return secondaryOperations
|
||||
return EMPTY_OPERATIONS
|
||||
}, [operations, secondaryOperations])
|
||||
const inlineOperations = primaryOps.filter(operation => operation.type !== 'divider')
|
||||
|
||||
useEffect(() => {
|
||||
const applyState = (visible: Operation[], overflow: Operation[]) => {
|
||||
const combinedMore = [...overflow, ...secondaryOps]
|
||||
if (!overflow.length && combinedMore[0]?.type === 'divider')
|
||||
combinedMore.shift()
|
||||
setVisibleOperations(visible)
|
||||
setMoreOperations(combinedMore)
|
||||
}
|
||||
|
||||
const inline = primaryOps.filter(operation => operation.type !== 'divider')
|
||||
|
||||
if (!inline.length) {
|
||||
applyState([], [])
|
||||
return
|
||||
}
|
||||
|
||||
const navElement = navRef.current
|
||||
const moreElement = document.getElementById('more-measure')
|
||||
|
||||
if (!navElement || !moreElement)
|
||||
return
|
||||
|
||||
let width = 0
|
||||
const containerWidth = navElement.clientWidth
|
||||
const moreWidth = moreElement.clientWidth
|
||||
|
||||
if (containerWidth === 0 || moreWidth === 0)
|
||||
return
|
||||
|
||||
const updatedEntries: Record<string, boolean> = inline.reduce((pre, cur) => {
|
||||
pre[cur.id] = false
|
||||
return pre
|
||||
}, {} as Record<string, boolean>)
|
||||
const childrens = Array.from(navElement.children).slice(0, -1)
|
||||
for (let i = 0; i < childrens.length; i++) {
|
||||
const child = childrens[i] as HTMLElement
|
||||
const id = child.dataset.targetid
|
||||
if (!id) break
|
||||
const childWidth = child.clientWidth
|
||||
|
||||
if (width + gap + childWidth + moreWidth <= containerWidth) {
|
||||
updatedEntries[id] = true
|
||||
width += gap + childWidth
|
||||
}
|
||||
else {
|
||||
if (i === childrens.length - 1 && width + childWidth <= containerWidth)
|
||||
updatedEntries[id] = true
|
||||
else
|
||||
updatedEntries[id] = false
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const visible = inline.filter(item => updatedEntries[item.id])
|
||||
const overflow = inline.filter(item => !updatedEntries[item.id])
|
||||
|
||||
applyState(visible, overflow)
|
||||
}, [gap, primaryOps, secondaryOps])
|
||||
|
||||
const shouldShowMoreButton = moreOperations.length > 0
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
ref={navRef}
|
||||
className="pointer-events-none flex h-0 items-center self-stretch overflow-hidden"
|
||||
style={{ gap }}
|
||||
>
|
||||
{inlineOperations.map(operation => (
|
||||
<Button
|
||||
key={operation.id}
|
||||
data-targetid={operation.id}
|
||||
size={'small'}
|
||||
variant={'secondary'}
|
||||
className="gap-[1px]"
|
||||
tabIndex={-1}
|
||||
>
|
||||
{cloneElement(operation.icon, { className: 'h-3.5 w-3.5 text-components-button-secondary-text' })}
|
||||
<span className="system-xs-medium text-components-button-secondary-text">
|
||||
{operation.title}
|
||||
</span>
|
||||
</Button>
|
||||
))}
|
||||
<Button
|
||||
id="more-measure"
|
||||
size={'small'}
|
||||
variant={'secondary'}
|
||||
className="gap-[1px]"
|
||||
tabIndex={-1}
|
||||
>
|
||||
<RiMoreLine className="h-3.5 w-3.5 text-components-button-secondary-text" />
|
||||
<span className="system-xs-medium text-components-button-secondary-text">
|
||||
{t('common.operation.more')}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center self-stretch overflow-hidden" style={{ gap }}>
|
||||
{visibleOpreations.map(operation => (
|
||||
<Button
|
||||
key={operation.id}
|
||||
data-targetid={operation.id}
|
||||
size={'small'}
|
||||
variant={'secondary'}
|
||||
className="gap-[1px]"
|
||||
onClick={operation.onClick}
|
||||
>
|
||||
{cloneElement(operation.icon, { className: 'h-3.5 w-3.5 text-components-button-secondary-text' })}
|
||||
<span className="system-xs-medium text-components-button-secondary-text">
|
||||
{operation.title}
|
||||
</span>
|
||||
</Button>
|
||||
))}
|
||||
{shouldShowMoreButton && (
|
||||
<PortalToFollowElem
|
||||
open={showMore}
|
||||
onOpenChange={setShowMore}
|
||||
placement="bottom-end"
|
||||
offset={{ mainAxis: 4 }}
|
||||
>
|
||||
<PortalToFollowElemTrigger onClick={handleTriggerMore}>
|
||||
<Button
|
||||
size={'small'}
|
||||
variant={'secondary'}
|
||||
className="gap-[1px]"
|
||||
>
|
||||
<RiMoreLine className="h-3.5 w-3.5 text-components-button-secondary-text" />
|
||||
<span className="system-xs-medium text-components-button-secondary-text">
|
||||
{t('common.operation.more')}
|
||||
</span>
|
||||
</Button>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className="z-[30]">
|
||||
<div className="flex min-w-[264px] flex-col rounded-[12px] border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-1 shadow-lg backdrop-blur-[5px]">
|
||||
{moreOperations.map(item => item.type === 'divider'
|
||||
? (
|
||||
<div key={item.id} className="my-1 h-px bg-divider-subtle" />
|
||||
)
|
||||
: (
|
||||
<div
|
||||
key={item.id}
|
||||
className="flex h-8 cursor-pointer items-center gap-x-1 rounded-lg p-1.5 hover:bg-state-base-hover"
|
||||
onClick={item.onClick}
|
||||
>
|
||||
{cloneElement(item.icon, { className: 'h-4 w-4 text-text-tertiary' })}
|
||||
<span className="system-md-regular text-text-secondary">{item.title}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default AppOperations
|
||||
126
dify/web/app/components/app-sidebar/app-sidebar-dropdown.tsx
Normal file
126
dify/web/app/components/app-sidebar/app-sidebar-dropdown.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
import React, { useCallback, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import {
|
||||
RiEqualizer2Line,
|
||||
RiMenuLine,
|
||||
} from '@remixicon/react'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import AppIcon from '../base/app-icon'
|
||||
import Divider from '../base/divider'
|
||||
import AppInfo from './app-info'
|
||||
import NavLink from './navLink'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import type { NavIcon } from './navLink'
|
||||
import cn from '@/utils/classnames'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
|
||||
type Props = {
|
||||
navigation: Array<{
|
||||
name: string
|
||||
href: string
|
||||
icon: NavIcon
|
||||
selectedIcon: NavIcon
|
||||
}>
|
||||
}
|
||||
|
||||
const AppSidebarDropdown = ({ navigation }: Props) => {
|
||||
const { t } = useTranslation()
|
||||
const { isCurrentWorkspaceEditor } = useAppContext()
|
||||
const appDetail = useAppStore(state => state.appDetail)
|
||||
const [detailExpand, setDetailExpand] = useState(false)
|
||||
|
||||
const [open, doSetOpen] = useState(false)
|
||||
const openRef = useRef(open)
|
||||
const setOpen = useCallback((v: boolean) => {
|
||||
doSetOpen(v)
|
||||
openRef.current = v
|
||||
}, [doSetOpen])
|
||||
const handleTrigger = useCallback(() => {
|
||||
setOpen(!openRef.current)
|
||||
}, [setOpen])
|
||||
|
||||
if (!appDetail)
|
||||
return null
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='fixed left-2 top-2 z-20'>
|
||||
<PortalToFollowElem
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
placement='bottom-start'
|
||||
offset={{
|
||||
mainAxis: -41,
|
||||
}}
|
||||
>
|
||||
<PortalToFollowElemTrigger onClick={handleTrigger}>
|
||||
<div className={cn('flex cursor-pointer items-center rounded-[10px] border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-1 shadow-lg backdrop-blur-sm hover:bg-background-default-hover', open && 'bg-background-default-hover')}>
|
||||
<AppIcon
|
||||
size='small'
|
||||
iconType={appDetail.icon_type}
|
||||
icon={appDetail.icon}
|
||||
background={appDetail.icon_background}
|
||||
imageUrl={appDetail.icon_url}
|
||||
/>
|
||||
<RiMenuLine className='h-4 w-4 text-text-tertiary' />
|
||||
</div>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className='z-[1000]'>
|
||||
<div className={cn('w-[305px] rounded-xl border-[0.5px] border-components-panel-border bg-background-default-subtle shadow-lg')}>
|
||||
<div className='p-2'>
|
||||
<div
|
||||
className={cn('flex flex-col gap-2 rounded-lg p-2 pb-2.5', isCurrentWorkspaceEditor && 'cursor-pointer hover:bg-state-base-hover')}
|
||||
onClick={() => {
|
||||
setDetailExpand(true)
|
||||
setOpen(false)
|
||||
}}
|
||||
>
|
||||
<div className='flex items-center justify-between self-stretch'>
|
||||
<AppIcon
|
||||
size='large'
|
||||
iconType={appDetail.icon_type}
|
||||
icon={appDetail.icon}
|
||||
background={appDetail.icon_background}
|
||||
imageUrl={appDetail.icon_url}
|
||||
/>
|
||||
<div className='flex items-center justify-center rounded-md p-0.5'>
|
||||
<div className='flex h-5 w-5 items-center justify-center'>
|
||||
<RiEqualizer2Line className='h-4 w-4 text-text-tertiary' />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex flex-col items-start gap-1'>
|
||||
<div className='flex w-full'>
|
||||
<div className='system-md-semibold truncate text-text-secondary'>{appDetail.name}</div>
|
||||
</div>
|
||||
<div className='system-2xs-medium-uppercase text-text-tertiary'>{appDetail.mode === AppModeEnum.ADVANCED_CHAT ? t('app.types.advanced') : appDetail.mode === AppModeEnum.AGENT_CHAT ? t('app.types.agent') : appDetail.mode === AppModeEnum.CHAT ? t('app.types.chatbot') : appDetail.mode === AppModeEnum.COMPLETION ? t('app.types.completion') : t('app.types.workflow')}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='px-4'>
|
||||
<Divider bgStyle='gradient' />
|
||||
</div>
|
||||
<nav className='space-y-0.5 px-3 pb-6 pt-4'>
|
||||
{navigation.map((item, index) => {
|
||||
return (
|
||||
<NavLink key={index} mode='expand' iconMap={{ selected: item.selectedIcon, normal: item.icon }} name={item.name} href={item.href} />
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
</div>
|
||||
<div className='z-20'>
|
||||
<AppInfo expand onlyShowDetail openState={detailExpand} onDetailExpand={setDetailExpand} />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default AppSidebarDropdown
|
||||
96
dify/web/app/components/app-sidebar/basic.tsx
Normal file
96
dify/web/app/components/app-sidebar/basic.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import AppIcon from '../base/app-icon'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import {
|
||||
ApiAggregate,
|
||||
WindowCursor,
|
||||
} from '@/app/components/base/icons/src/vender/workflow'
|
||||
|
||||
export type IAppBasicProps = {
|
||||
iconType?: 'app' | 'api' | 'dataset' | 'webapp' | 'notion'
|
||||
icon?: string
|
||||
icon_background?: string | null
|
||||
isExternal?: boolean
|
||||
name: string
|
||||
type: string | React.ReactNode
|
||||
hoverTip?: string
|
||||
textStyle?: { main?: string; extra?: string }
|
||||
isExtraInLine?: boolean
|
||||
mode?: string
|
||||
hideType?: boolean
|
||||
}
|
||||
|
||||
const DatasetSvg = <svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fillRule="evenodd" clipRule="evenodd" d="M0.833497 5.13481C0.833483 4.69553 0.83347 4.31654 0.858973 4.0044C0.88589 3.67495 0.94532 3.34727 1.10598 3.03195C1.34567 2.56155 1.72812 2.17909 2.19852 1.93941C2.51384 1.77875 2.84152 1.71932 3.17097 1.6924C3.48312 1.6669 3.86209 1.66691 4.30137 1.66693L7.62238 1.66684C8.11701 1.66618 8.55199 1.66561 8.95195 1.80356C9.30227 1.92439 9.62134 2.12159 9.88607 2.38088C10.1883 2.67692 10.3823 3.06624 10.603 3.50894L11.3484 5.00008H14.3679C15.0387 5.00007 15.5924 5.00006 16.0434 5.03691C16.5118 5.07518 16.9424 5.15732 17.3468 5.36339C17.974 5.68297 18.4839 6.19291 18.8035 6.82011C19.0096 7.22456 19.0917 7.65515 19.13 8.12356C19.1668 8.57455 19.1668 9.12818 19.1668 9.79898V13.5345C19.1668 14.2053 19.1668 14.7589 19.13 15.2099C19.0917 15.6784 19.0096 16.1089 18.8035 16.5134C18.4839 17.1406 17.974 17.6505 17.3468 17.9701C16.9424 18.1762 16.5118 18.2583 16.0434 18.2966C15.5924 18.3334 15.0387 18.3334 14.3679 18.3334H5.63243C4.96163 18.3334 4.40797 18.3334 3.95698 18.2966C3.48856 18.2583 3.05798 18.1762 2.65353 17.9701C2.02632 17.6505 1.51639 17.1406 1.19681 16.5134C0.990734 16.1089 0.908597 15.6784 0.870326 15.2099C0.833478 14.7589 0.833487 14.2053 0.833497 13.5345V5.13481ZM7.51874 3.33359C8.17742 3.33359 8.30798 3.34447 8.4085 3.37914C8.52527 3.41942 8.63163 3.48515 8.71987 3.57158C8.79584 3.64598 8.86396 3.7579 9.15852 4.34704L9.48505 5.00008L2.50023 5.00008C2.50059 4.61259 2.50314 4.34771 2.5201 4.14012C2.5386 3.91374 2.57 3.82981 2.59099 3.7886C2.67089 3.6318 2.79837 3.50432 2.95517 3.42442C2.99638 3.40343 3.08031 3.37203 3.30669 3.35353C3.54281 3.33424 3.85304 3.33359 4.3335 3.33359H7.51874Z" fill="#444CE7" />
|
||||
</svg>
|
||||
|
||||
const NotionSvg = <svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clipPath="url(#clip0_6294_13848)">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M4.287 21.9133L1.70748 18.6999C1.08685 17.9267 0.75 16.976 0.75 15.9974V4.36124C0.75 2.89548 1.92269 1.67923 3.43553 1.57594L15.3991 0.759137C16.2682 0.699797 17.1321 0.930818 17.8461 1.41353L22.0494 4.25543C22.8018 4.76414 23.25 5.59574 23.25 6.48319V19.7124C23.25 21.1468 22.0969 22.3345 20.6157 22.4256L7.3375 23.243C6.1555 23.3158 5.01299 22.8178 4.287 21.9133Z" fill="white" />
|
||||
<path d="M8.43607 10.1842V10.0318C8.43607 9.64564 8.74535 9.32537 9.14397 9.29876L12.0475 9.10491L16.0628 15.0178V9.82823L15.0293 9.69046V9.6181C15.0293 9.22739 15.3456 8.90501 15.7493 8.88433L18.3912 8.74899V9.12918C18.3912 9.30765 18.2585 9.46031 18.0766 9.49108L17.4408 9.59861V18.0029L16.6429 18.2773C15.9764 18.5065 15.2343 18.2611 14.8527 17.6853L10.9545 11.803V17.4173L12.1544 17.647L12.1377 17.7583C12.0853 18.1069 11.7843 18.3705 11.4202 18.3867L8.43607 18.5195C8.39662 18.1447 8.67758 17.8093 9.06518 17.7686L9.45771 17.7273V10.2416L8.43607 10.1842Z" fill="black" />
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M15.5062 2.22521L3.5426 3.04201C2.82599 3.09094 2.27051 3.66706 2.27051 4.36136V15.9975C2.27051 16.6499 2.49507 17.2837 2.90883 17.7992L5.48835 21.0126C5.90541 21.5322 6.56174 21.8183 7.24076 21.7765L20.519 20.9591C21.1995 20.9172 21.7293 20.3716 21.7293 19.7125V6.48332C21.7293 6.07557 21.5234 5.69348 21.1777 5.45975L16.9743 2.61784C16.546 2.32822 16.0277 2.1896 15.5062 2.22521ZM4.13585 4.54287C3.96946 4.41968 4.04865 4.16303 4.25768 4.14804L15.5866 3.33545C15.9476 3.30956 16.3063 3.40896 16.5982 3.61578L18.8713 5.22622C18.9576 5.28736 18.9171 5.41935 18.8102 5.42516L6.8129 6.07764C6.44983 6.09739 6.09144 5.99073 5.80276 5.77699L4.13585 4.54287ZM6.25018 8.12315C6.25018 7.7334 6.56506 7.41145 6.9677 7.38952L19.6523 6.69871C20.0447 6.67734 20.375 6.97912 20.375 7.35898V18.8141C20.375 19.2031 20.0613 19.5247 19.6594 19.5476L7.05516 20.2648C6.61845 20.2896 6.25018 19.954 6.25018 19.5312V8.12315Z" fill="black" />
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_6294_13848">
|
||||
<rect width="24" height="24" fill="white" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
||||
const ICON_MAP = {
|
||||
app: <AppIcon className='border !border-[rgba(0,0,0,0.05)]' />,
|
||||
api: <div className='rounded-lg border-[0.5px] border-divider-subtle bg-util-colors-blue-brand-blue-brand-500 p-1 shadow-md'>
|
||||
<ApiAggregate className='h-4 w-4 text-text-primary-on-surface' />
|
||||
</div>,
|
||||
dataset: <AppIcon innerIcon={DatasetSvg} className='!border-[0.5px] !border-indigo-100 !bg-indigo-25' />,
|
||||
webapp: <div className='rounded-lg border-[0.5px] border-divider-subtle bg-util-colors-blue-brand-blue-brand-500 p-1 shadow-md'>
|
||||
<WindowCursor className='h-4 w-4 text-text-primary-on-surface' />
|
||||
</div>,
|
||||
notion: <AppIcon innerIcon={NotionSvg} className='!border-[0.5px] !border-indigo-100 !bg-white' />,
|
||||
}
|
||||
|
||||
export default function AppBasic({ icon, icon_background, name, isExternal, type, hoverTip, textStyle, isExtraInLine, mode = 'expand', iconType = 'app', hideType }: IAppBasicProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className="flex grow items-center">
|
||||
{icon && icon_background && iconType === 'app' && (
|
||||
<div className='mr-2 shrink-0'>
|
||||
<AppIcon icon={icon} background={icon_background} />
|
||||
</div>
|
||||
)}
|
||||
{iconType !== 'app'
|
||||
&& <div className='mr-2 shrink-0'>
|
||||
{ICON_MAP[iconType]}
|
||||
</div>
|
||||
|
||||
}
|
||||
{mode === 'expand' && <div className="group w-full">
|
||||
<div className={`system-md-semibold flex flex-row items-center text-text-secondary group-hover:text-text-primary ${textStyle?.main ?? ''}`}>
|
||||
<div className="min-w-0 overflow-hidden text-ellipsis break-normal">
|
||||
{name}
|
||||
</div>
|
||||
{hoverTip
|
||||
&& <Tooltip
|
||||
popupContent={
|
||||
<div className='w-[240px]'>
|
||||
{hoverTip}
|
||||
</div>
|
||||
}
|
||||
popupClassName='ml-1'
|
||||
triggerClassName='w-4 h-4 ml-1'
|
||||
position='top'
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
{!hideType && isExtraInLine && (
|
||||
<div className="system-2xs-medium-uppercase flex text-text-tertiary">{type}</div>
|
||||
)}
|
||||
{!hideType && !isExtraInLine && (
|
||||
<div className='system-2xs-medium-uppercase text-text-tertiary'>{isExternal ? t('dataset.externalTag') : type}</div>
|
||||
)}
|
||||
</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
BIN
dify/web/app/components/app-sidebar/completion.png
Normal file
BIN
dify/web/app/components/app-sidebar/completion.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 70 KiB |
152
dify/web/app/components/app-sidebar/dataset-info/dropdown.tsx
Normal file
152
dify/web/app/components/app-sidebar/dataset-info/dropdown.tsx
Normal file
@@ -0,0 +1,152 @@
|
||||
import React, { useCallback, useState } from 'react'
|
||||
import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '../../base/portal-to-follow-elem'
|
||||
import ActionButton from '../../base/action-button'
|
||||
import { RiMoreFill } from '@remixicon/react'
|
||||
import cn from '@/utils/classnames'
|
||||
import Menu from './menu'
|
||||
import { useSelector as useAppContextWithSelector } from '@/context/app-context'
|
||||
import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail'
|
||||
import type { DataSet } from '@/models/datasets'
|
||||
import { datasetDetailQueryKeyPrefix, useInvalidDatasetList } from '@/service/knowledge/use-dataset'
|
||||
import { useInvalid } from '@/service/use-base'
|
||||
import { useExportPipelineDSL } from '@/service/use-pipeline'
|
||||
import Toast from '../../base/toast'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import RenameDatasetModal from '../../datasets/rename-modal'
|
||||
import { checkIsUsedInApp, deleteDataset } from '@/service/datasets'
|
||||
import Confirm from '../../base/confirm'
|
||||
import { useRouter } from 'next/navigation'
|
||||
|
||||
type DropDownProps = {
|
||||
expand: boolean
|
||||
}
|
||||
|
||||
const DropDown = ({
|
||||
expand,
|
||||
}: DropDownProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { replace } = useRouter()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [showRenameModal, setShowRenameModal] = useState(false)
|
||||
const [confirmMessage, setConfirmMessage] = useState<string>('')
|
||||
const [showConfirmDelete, setShowConfirmDelete] = useState(false)
|
||||
|
||||
const isCurrentWorkspaceDatasetOperator = useAppContextWithSelector(state => state.isCurrentWorkspaceDatasetOperator)
|
||||
const dataset = useDatasetDetailContextWithSelector(state => state.dataset) as DataSet
|
||||
|
||||
const handleTrigger = useCallback(() => {
|
||||
setOpen(prev => !prev)
|
||||
}, [])
|
||||
|
||||
const invalidDatasetList = useInvalidDatasetList()
|
||||
const invalidDatasetDetail = useInvalid([...datasetDetailQueryKeyPrefix, dataset.id])
|
||||
|
||||
const refreshDataset = useCallback(() => {
|
||||
invalidDatasetList()
|
||||
invalidDatasetDetail()
|
||||
}, [invalidDatasetDetail, invalidDatasetList])
|
||||
|
||||
const openRenameModal = useCallback(() => {
|
||||
setShowRenameModal(true)
|
||||
handleTrigger()
|
||||
}, [handleTrigger])
|
||||
|
||||
const { mutateAsync: exportPipelineConfig } = useExportPipelineDSL()
|
||||
|
||||
const handleExportPipeline = useCallback(async (include = false) => {
|
||||
const { pipeline_id, name } = dataset
|
||||
if (!pipeline_id)
|
||||
return
|
||||
handleTrigger()
|
||||
try {
|
||||
const { data } = await exportPipelineConfig({
|
||||
pipelineId: pipeline_id,
|
||||
include,
|
||||
})
|
||||
const a = document.createElement('a')
|
||||
const file = new Blob([data], { type: 'application/yaml' })
|
||||
const url = URL.createObjectURL(file)
|
||||
a.href = url
|
||||
a.download = `${name}.pipeline`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
catch {
|
||||
Toast.notify({ type: 'error', message: t('app.exportFailed') })
|
||||
}
|
||||
}, [dataset, exportPipelineConfig, handleTrigger, t])
|
||||
|
||||
const detectIsUsedByApp = useCallback(async () => {
|
||||
try {
|
||||
const { is_using: isUsedByApp } = await checkIsUsedInApp(dataset.id)
|
||||
setConfirmMessage(isUsedByApp ? t('dataset.datasetUsedByApp')! : t('dataset.deleteDatasetConfirmContent')!)
|
||||
setShowConfirmDelete(true)
|
||||
}
|
||||
catch (e: any) {
|
||||
const res = await e.json()
|
||||
Toast.notify({ type: 'error', message: res?.message || 'Unknown error' })
|
||||
}
|
||||
finally {
|
||||
handleTrigger()
|
||||
}
|
||||
}, [dataset.id, handleTrigger, t])
|
||||
|
||||
const onConfirmDelete = useCallback(async () => {
|
||||
try {
|
||||
await deleteDataset(dataset.id)
|
||||
Toast.notify({ type: 'success', message: t('dataset.datasetDeleted') })
|
||||
invalidDatasetList()
|
||||
replace('/datasets')
|
||||
}
|
||||
finally {
|
||||
setShowConfirmDelete(false)
|
||||
}
|
||||
}, [dataset.id, replace, invalidDatasetList, t])
|
||||
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
placement={expand ? 'bottom-end' : 'right'}
|
||||
offset={expand ? {
|
||||
mainAxis: 4,
|
||||
crossAxis: 10,
|
||||
} : {
|
||||
mainAxis: 4,
|
||||
}}
|
||||
>
|
||||
<PortalToFollowElemTrigger onClick={handleTrigger}>
|
||||
<ActionButton className={cn(expand ? 'size-8 rounded-lg' : 'size-6 rounded-md')}>
|
||||
<RiMoreFill className='size-4' />
|
||||
</ActionButton>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className='z-[60]'>
|
||||
<Menu
|
||||
showDelete={!isCurrentWorkspaceDatasetOperator}
|
||||
openRenameModal={openRenameModal}
|
||||
handleExportPipeline={handleExportPipeline}
|
||||
detectIsUsedByApp={detectIsUsedByApp}
|
||||
/>
|
||||
</PortalToFollowElemContent>
|
||||
{showRenameModal && (
|
||||
<RenameDatasetModal
|
||||
show={showRenameModal}
|
||||
dataset={dataset!}
|
||||
onClose={() => setShowRenameModal(false)}
|
||||
onSuccess={refreshDataset}
|
||||
/>
|
||||
)}
|
||||
{showConfirmDelete && (
|
||||
<Confirm
|
||||
title={t('dataset.deleteDatasetConfirmTitle')}
|
||||
content={confirmMessage}
|
||||
isShow={showConfirmDelete}
|
||||
onConfirm={onConfirmDelete}
|
||||
onCancel={() => setShowConfirmDelete(false)}
|
||||
/>
|
||||
)}
|
||||
</PortalToFollowElem>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(DropDown)
|
||||
91
dify/web/app/components/app-sidebar/dataset-info/index.tsx
Normal file
91
dify/web/app/components/app-sidebar/dataset-info/index.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React, { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import AppIcon from '../../base/app-icon'
|
||||
import Effect from '../../base/effect'
|
||||
import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail'
|
||||
import type { DataSet } from '@/models/datasets'
|
||||
import { DOC_FORM_TEXT } from '@/models/datasets'
|
||||
import { useKnowledge } from '@/hooks/use-knowledge'
|
||||
import cn from '@/utils/classnames'
|
||||
import Dropdown from './dropdown'
|
||||
|
||||
type DatasetInfoProps = {
|
||||
expand: boolean
|
||||
}
|
||||
|
||||
const DatasetInfo: FC<DatasetInfoProps> = ({
|
||||
expand,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const dataset = useDatasetDetailContextWithSelector(state => state.dataset) as DataSet
|
||||
const iconInfo = dataset.icon_info || {
|
||||
icon: '📙',
|
||||
icon_type: 'emoji',
|
||||
icon_background: '#FFF4ED',
|
||||
icon_url: '',
|
||||
}
|
||||
const isExternalProvider = dataset.provider === 'external'
|
||||
const isPipelinePublished = useMemo(() => {
|
||||
return dataset.runtime_mode === 'rag_pipeline' && dataset.is_published
|
||||
}, [dataset.runtime_mode, dataset.is_published])
|
||||
const { formatIndexingTechniqueAndMethod } = useKnowledge()
|
||||
|
||||
return (
|
||||
<div className={cn('relative flex flex-col', expand ? '' : 'p-1')}>
|
||||
{expand && (
|
||||
<Effect className='-left-5 top-[-22px] opacity-15' />
|
||||
)}
|
||||
|
||||
<div className='flex flex-col gap-2 p-2'>
|
||||
<div className='flex items-center gap-1'>
|
||||
<div className={cn(!expand && '-ml-1')}>
|
||||
<AppIcon
|
||||
size={expand ? 'large' : 'small'}
|
||||
iconType={iconInfo.icon_type}
|
||||
icon={iconInfo.icon}
|
||||
background={iconInfo.icon_background}
|
||||
imageUrl={iconInfo.icon_url}
|
||||
/>
|
||||
</div>
|
||||
{expand && (
|
||||
<div className='ml-auto'>
|
||||
<Dropdown expand />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!expand && (
|
||||
<div className='-mb-2 -mt-1 flex items-center justify-center'>
|
||||
<Dropdown expand={false} />
|
||||
</div>
|
||||
)}
|
||||
{expand && (
|
||||
<div className='flex flex-col gap-y-1 pb-0.5'>
|
||||
<div
|
||||
className='system-md-semibold truncate text-text-secondary'
|
||||
title={dataset.name}
|
||||
>
|
||||
{dataset.name}
|
||||
</div>
|
||||
<div className='system-2xs-medium-uppercase text-text-tertiary'>
|
||||
{isExternalProvider && t('dataset.externalTag')}
|
||||
{!isExternalProvider && isPipelinePublished && dataset.doc_form && dataset.indexing_technique && (
|
||||
<div className='flex items-center gap-x-2'>
|
||||
<span>{t(`dataset.chunkingMode.${DOC_FORM_TEXT[dataset.doc_form]}`)}</span>
|
||||
<span>{formatIndexingTechniqueAndMethod(dataset.indexing_technique, dataset.retrieval_model_dict?.search_method)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!!dataset.description && (
|
||||
<p className='system-xs-regular line-clamp-3 text-text-tertiary first-letter:capitalize'>
|
||||
{dataset.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default React.memo(DatasetInfo)
|
||||
@@ -0,0 +1,30 @@
|
||||
import React from 'react'
|
||||
import type { RemixiconComponentType } from '@remixicon/react'
|
||||
|
||||
type MenuItemProps = {
|
||||
name: string
|
||||
Icon: RemixiconComponentType
|
||||
handleClick?: () => void
|
||||
}
|
||||
|
||||
const MenuItem = ({
|
||||
Icon,
|
||||
name,
|
||||
handleClick,
|
||||
}: MenuItemProps) => {
|
||||
return (
|
||||
<div
|
||||
className='flex items-center gap-x-1 rounded-lg px-2 py-1.5 hover:bg-state-base-hover'
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
handleClick?.()
|
||||
}}
|
||||
>
|
||||
<Icon className='size-4 text-text-tertiary' />
|
||||
<span className='system-md-regular px-1 text-text-secondary'>{name}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(MenuItem)
|
||||
56
dify/web/app/components/app-sidebar/dataset-info/menu.tsx
Normal file
56
dify/web/app/components/app-sidebar/dataset-info/menu.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import MenuItem from './menu-item'
|
||||
import { RiDeleteBinLine, RiEditLine, RiFileDownloadLine } from '@remixicon/react'
|
||||
import Divider from '../../base/divider'
|
||||
import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail'
|
||||
|
||||
type MenuProps = {
|
||||
showDelete: boolean
|
||||
openRenameModal: () => void
|
||||
handleExportPipeline: () => void
|
||||
detectIsUsedByApp: () => void
|
||||
}
|
||||
|
||||
const Menu = ({
|
||||
showDelete,
|
||||
openRenameModal,
|
||||
handleExportPipeline,
|
||||
detectIsUsedByApp,
|
||||
}: MenuProps) => {
|
||||
const { t } = useTranslation()
|
||||
const runtimeMode = useDatasetDetailContextWithSelector(state => state.dataset?.runtime_mode)
|
||||
|
||||
return (
|
||||
<div className='flex w-[200px] flex-col rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg shadow-shadow-shadow-5 backdrop-blur-[5px]'>
|
||||
<div className='flex flex-col p-1'>
|
||||
<MenuItem
|
||||
Icon={RiEditLine}
|
||||
name={t('common.operation.edit')}
|
||||
handleClick={openRenameModal}
|
||||
/>
|
||||
{runtimeMode === 'rag_pipeline' && (
|
||||
<MenuItem
|
||||
Icon={RiFileDownloadLine}
|
||||
name={t('datasetPipeline.operations.exportPipeline')}
|
||||
handleClick={handleExportPipeline}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{showDelete && (
|
||||
<>
|
||||
<Divider type='horizontal' className='my-0 bg-divider-subtle' />
|
||||
<div className='flex flex-col p-1'>
|
||||
<MenuItem
|
||||
Icon={RiDeleteBinLine}
|
||||
name={t('common.operation.delete')}
|
||||
handleClick={detectIsUsedByApp}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(Menu)
|
||||
164
dify/web/app/components/app-sidebar/dataset-sidebar-dropdown.tsx
Normal file
164
dify/web/app/components/app-sidebar/dataset-sidebar-dropdown.tsx
Normal file
@@ -0,0 +1,164 @@
|
||||
import React, { useCallback, useRef, useState } from 'react'
|
||||
import {
|
||||
RiMenuLine,
|
||||
} from '@remixicon/react'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import AppIcon from '../base/app-icon'
|
||||
import Divider from '../base/divider'
|
||||
import NavLink from './navLink'
|
||||
import type { NavIcon } from './navLink'
|
||||
import cn from '@/utils/classnames'
|
||||
import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail'
|
||||
import Effect from '../base/effect'
|
||||
import Dropdown from './dataset-info/dropdown'
|
||||
import type { DataSet } from '@/models/datasets'
|
||||
import { DOC_FORM_TEXT } from '@/models/datasets'
|
||||
import { useKnowledge } from '@/hooks/use-knowledge'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useDatasetRelatedApps } from '@/service/knowledge/use-dataset'
|
||||
import ExtraInfo from '../datasets/extra-info'
|
||||
|
||||
type DatasetSidebarDropdownProps = {
|
||||
navigation: Array<{
|
||||
name: string
|
||||
href: string
|
||||
icon: NavIcon
|
||||
selectedIcon: NavIcon
|
||||
disabled?: boolean
|
||||
}>
|
||||
}
|
||||
|
||||
const DatasetSidebarDropdown = ({
|
||||
navigation,
|
||||
}: DatasetSidebarDropdownProps) => {
|
||||
const { t } = useTranslation()
|
||||
const dataset = useDatasetDetailContextWithSelector(state => state.dataset) as DataSet
|
||||
|
||||
const { data: relatedApps } = useDatasetRelatedApps(dataset.id)
|
||||
|
||||
const [open, doSetOpen] = useState(false)
|
||||
const openRef = useRef(open)
|
||||
const setOpen = useCallback((v: boolean) => {
|
||||
doSetOpen(v)
|
||||
openRef.current = v
|
||||
}, [doSetOpen])
|
||||
const handleTrigger = useCallback(() => {
|
||||
setOpen(!openRef.current)
|
||||
}, [setOpen])
|
||||
|
||||
const iconInfo = dataset.icon_info || {
|
||||
icon: '📙',
|
||||
icon_type: 'emoji',
|
||||
icon_background: '#FFF4ED',
|
||||
icon_url: '',
|
||||
}
|
||||
const isExternalProvider = dataset.provider === 'external'
|
||||
const { formatIndexingTechniqueAndMethod } = useKnowledge()
|
||||
|
||||
if (!dataset)
|
||||
return null
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='fixed left-2 top-2 z-20'>
|
||||
<PortalToFollowElem
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
placement='bottom-start'
|
||||
offset={{
|
||||
mainAxis: -41,
|
||||
}}
|
||||
>
|
||||
<PortalToFollowElemTrigger onClick={handleTrigger}>
|
||||
<div
|
||||
className={cn(
|
||||
'flex cursor-pointer items-center rounded-[10px] border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-1 shadow-lg backdrop-blur-sm hover:bg-background-default-hover',
|
||||
open && 'bg-background-default-hover',
|
||||
)}
|
||||
>
|
||||
<AppIcon
|
||||
size='small'
|
||||
iconType={iconInfo.icon_type}
|
||||
icon={iconInfo.icon}
|
||||
background={iconInfo.icon_background}
|
||||
imageUrl={iconInfo.icon_url}
|
||||
/>
|
||||
<RiMenuLine className='size-4 text-text-tertiary' />
|
||||
</div>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className='z-50'>
|
||||
<div className='relative w-[216px] rounded-xl border-[0.5px] border-components-panel-border bg-background-default-subtle shadow-lg'>
|
||||
<Effect className='-left-5 top-[-22px] opacity-15' />
|
||||
<div className='flex flex-col gap-y-2 p-4'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<AppIcon
|
||||
size='medium'
|
||||
iconType={iconInfo.icon_type}
|
||||
icon={iconInfo.icon}
|
||||
background={iconInfo.icon_background}
|
||||
imageUrl={iconInfo.icon_url}
|
||||
/>
|
||||
<Dropdown expand />
|
||||
</div>
|
||||
<div className='flex flex-col gap-y-1 pb-0.5'>
|
||||
<div
|
||||
className='system-md-semibold truncate text-text-secondary'
|
||||
title={dataset.name}
|
||||
>
|
||||
{dataset.name}
|
||||
</div>
|
||||
<div className='system-2xs-medium-uppercase text-text-tertiary'>
|
||||
{isExternalProvider && t('dataset.externalTag')}
|
||||
{!isExternalProvider && dataset.doc_form && dataset.indexing_technique && (
|
||||
<div className='flex items-center gap-x-2'>
|
||||
<span>{t(`dataset.chunkingMode.${DOC_FORM_TEXT[dataset.doc_form]}`)}</span>
|
||||
<span>{formatIndexingTechniqueAndMethod(dataset.indexing_technique, dataset.retrieval_model_dict?.search_method)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{!!dataset.description && (
|
||||
<p className='system-xs-regular line-clamp-3 text-text-tertiary first-letter:capitalize'>
|
||||
{dataset.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className='px-4 py-2'>
|
||||
<Divider
|
||||
type='horizontal'
|
||||
bgStyle='gradient'
|
||||
className='my-0 h-px bg-gradient-to-r from-divider-subtle to-background-gradient-mask-transparent'
|
||||
/>
|
||||
</div>
|
||||
<nav className='flex min-h-[200px] grow flex-col gap-y-0.5 px-3 py-2'>
|
||||
{navigation.map((item, index) => {
|
||||
return (
|
||||
<NavLink
|
||||
key={index}
|
||||
mode='expand'
|
||||
iconMap={{ selected: item.selectedIcon, normal: item.icon }}
|
||||
name={item.name}
|
||||
href={item.href}
|
||||
disabled={!!item.disabled}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
<ExtraInfo
|
||||
relatedApps={relatedApps}
|
||||
expand
|
||||
documentCount={dataset.document_count}
|
||||
/>
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default DatasetSidebarDropdown
|
||||
BIN
dify/web/app/components/app-sidebar/expert.png
Normal file
BIN
dify/web/app/components/app-sidebar/expert.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 86 KiB |
156
dify/web/app/components/app-sidebar/index.tsx
Normal file
156
dify/web/app/components/app-sidebar/index.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import NavLink from './navLink'
|
||||
import type { NavIcon } from './navLink'
|
||||
import AppInfo from './app-info'
|
||||
import DatasetInfo from './dataset-info'
|
||||
import AppSidebarDropdown from './app-sidebar-dropdown'
|
||||
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import { useEventEmitterContextContext } from '@/context/event-emitter'
|
||||
import cn from '@/utils/classnames'
|
||||
import Divider from '../base/divider'
|
||||
import { useHover, useKeyPress } from 'ahooks'
|
||||
import ToggleButton from './toggle-button'
|
||||
import { getKeyboardKeyCodeBySystem } from '../workflow/utils'
|
||||
import DatasetSidebarDropdown from './dataset-sidebar-dropdown'
|
||||
|
||||
export type IAppDetailNavProps = {
|
||||
iconType?: 'app' | 'dataset'
|
||||
navigation: Array<{
|
||||
name: string
|
||||
href: string
|
||||
icon: NavIcon
|
||||
selectedIcon: NavIcon
|
||||
disabled?: boolean
|
||||
}>
|
||||
extraInfo?: (modeState: string) => React.ReactNode
|
||||
}
|
||||
|
||||
const AppDetailNav = ({
|
||||
navigation,
|
||||
extraInfo,
|
||||
iconType = 'app',
|
||||
}: IAppDetailNavProps) => {
|
||||
const { appSidebarExpand, setAppSidebarExpand } = useAppStore(useShallow(state => ({
|
||||
appSidebarExpand: state.appSidebarExpand,
|
||||
setAppSidebarExpand: state.setAppSidebarExpand,
|
||||
})))
|
||||
const sidebarRef = React.useRef<HTMLDivElement>(null)
|
||||
const media = useBreakpoints()
|
||||
const isMobile = media === MediaType.mobile
|
||||
const expand = appSidebarExpand === 'expand'
|
||||
|
||||
const handleToggle = useCallback(() => {
|
||||
setAppSidebarExpand(appSidebarExpand === 'expand' ? 'collapse' : 'expand')
|
||||
}, [appSidebarExpand, setAppSidebarExpand])
|
||||
|
||||
const isHoveringSidebar = useHover(sidebarRef)
|
||||
|
||||
// Check if the current path is a workflow canvas & fullscreen
|
||||
const pathname = usePathname()
|
||||
const inWorkflowCanvas = pathname.endsWith('/workflow')
|
||||
const isPipelineCanvas = pathname.endsWith('/pipeline')
|
||||
const workflowCanvasMaximize = localStorage.getItem('workflow-canvas-maximize') === 'true'
|
||||
const [hideHeader, setHideHeader] = useState(workflowCanvasMaximize)
|
||||
const { eventEmitter } = useEventEmitterContextContext()
|
||||
|
||||
eventEmitter?.useSubscription((v: any) => {
|
||||
if (v?.type === 'workflow-canvas-maximize')
|
||||
setHideHeader(v.payload)
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (appSidebarExpand) {
|
||||
localStorage.setItem('app-detail-collapse-or-expand', appSidebarExpand)
|
||||
setAppSidebarExpand(appSidebarExpand)
|
||||
}
|
||||
}, [appSidebarExpand, setAppSidebarExpand])
|
||||
|
||||
useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.b`, (e) => {
|
||||
e.preventDefault()
|
||||
handleToggle()
|
||||
}, { exactMatch: true, useCapture: true })
|
||||
|
||||
if (inWorkflowCanvas && hideHeader) {
|
||||
return (
|
||||
<div className='flex w-0 shrink-0'>
|
||||
<AppSidebarDropdown navigation={navigation} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (isPipelineCanvas && hideHeader) {
|
||||
return (
|
||||
<div className='flex w-0 shrink-0'>
|
||||
<DatasetSidebarDropdown navigation={navigation} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={sidebarRef}
|
||||
className={cn(
|
||||
'flex shrink-0 flex-col border-r border-divider-burn bg-background-default-subtle transition-all',
|
||||
expand ? 'w-[216px]' : 'w-14',
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'shrink-0',
|
||||
expand ? 'p-2' : 'p-1',
|
||||
)}
|
||||
>
|
||||
{iconType === 'app' && (
|
||||
<AppInfo expand={expand} />
|
||||
)}
|
||||
{iconType !== 'app' && (
|
||||
<DatasetInfo expand={expand} />
|
||||
)}
|
||||
</div>
|
||||
<div className='relative px-4 py-2'>
|
||||
<Divider
|
||||
type='horizontal'
|
||||
bgStyle={expand ? 'gradient' : 'solid'}
|
||||
className={cn(
|
||||
'my-0 h-px',
|
||||
expand
|
||||
? 'bg-gradient-to-r from-divider-subtle to-background-gradient-mask-transparent'
|
||||
: 'bg-divider-subtle',
|
||||
)}
|
||||
/>
|
||||
{!isMobile && isHoveringSidebar && (
|
||||
<ToggleButton
|
||||
className='absolute -right-3 top-[-3.5px] z-20'
|
||||
expand={expand}
|
||||
handleToggle={handleToggle}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<nav
|
||||
className={cn(
|
||||
'flex grow flex-col gap-y-0.5',
|
||||
expand ? 'px-3 py-2' : 'p-3',
|
||||
)}
|
||||
>
|
||||
{navigation.map((item, index) => {
|
||||
return (
|
||||
<NavLink
|
||||
key={index}
|
||||
mode={appSidebarExpand}
|
||||
iconMap={{ selected: item.selectedIcon, normal: item.icon }}
|
||||
name={item.name}
|
||||
href={item.href}
|
||||
disabled={!!item.disabled}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
{iconType !== 'app' && extraInfo && extraInfo(appSidebarExpand)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(AppDetailNav)
|
||||
266
dify/web/app/components/app-sidebar/navLink.spec.tsx
Normal file
266
dify/web/app/components/app-sidebar/navLink.spec.tsx
Normal file
@@ -0,0 +1,266 @@
|
||||
import React from 'react'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import '@testing-library/jest-dom'
|
||||
import NavLink from './navLink'
|
||||
import type { NavLinkProps } from './navLink'
|
||||
|
||||
// Mock Next.js navigation
|
||||
jest.mock('next/navigation', () => ({
|
||||
useSelectedLayoutSegment: () => 'overview',
|
||||
}))
|
||||
|
||||
// Mock Next.js Link component
|
||||
jest.mock('next/link', () => {
|
||||
return function MockLink({ children, href, className, title }: any) {
|
||||
return (
|
||||
<a href={href} className={className} title={title} data-testid="nav-link">
|
||||
{children}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
// Mock RemixIcon components
|
||||
const MockIcon = ({ className }: { className?: string }) => (
|
||||
<svg className={className} data-testid="nav-icon" />
|
||||
)
|
||||
|
||||
describe('NavLink Animation and Layout Issues', () => {
|
||||
const mockProps: NavLinkProps = {
|
||||
name: 'Orchestrate',
|
||||
href: '/app/123/workflow',
|
||||
iconMap: {
|
||||
selected: MockIcon,
|
||||
normal: MockIcon,
|
||||
},
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
// Mock getComputedStyle for transition testing
|
||||
Object.defineProperty(window, 'getComputedStyle', {
|
||||
value: jest.fn((element) => {
|
||||
const isExpanded = element.getAttribute('data-mode') === 'expand'
|
||||
return {
|
||||
transition: 'all 0.3s ease',
|
||||
opacity: isExpanded ? '1' : '0',
|
||||
width: isExpanded ? 'auto' : '0px',
|
||||
overflow: 'hidden',
|
||||
paddingLeft: isExpanded ? '12px' : '10px', // px-3 vs px-2.5
|
||||
paddingRight: isExpanded ? '12px' : '10px',
|
||||
}
|
||||
}),
|
||||
writable: true,
|
||||
})
|
||||
})
|
||||
|
||||
describe('Text Squeeze Animation Issue', () => {
|
||||
it('should show text squeeze effect when switching from collapse to expand', async () => {
|
||||
const { rerender } = render(<NavLink {...mockProps} mode="collapse" />)
|
||||
|
||||
// In collapse mode, text should be in DOM but hidden via CSS
|
||||
const textElement = screen.getByText('Orchestrate')
|
||||
expect(textElement).toBeInTheDocument()
|
||||
expect(textElement).toHaveClass('opacity-0')
|
||||
expect(textElement).toHaveClass('max-w-0')
|
||||
expect(textElement).toHaveClass('overflow-hidden')
|
||||
|
||||
// Icon should still be present
|
||||
expect(screen.getByTestId('nav-icon')).toBeInTheDocument()
|
||||
|
||||
// Check consistent padding in collapse mode
|
||||
const linkElement = screen.getByTestId('nav-link')
|
||||
expect(linkElement).toHaveClass('pl-3')
|
||||
expect(linkElement).toHaveClass('pr-1')
|
||||
|
||||
// Switch to expand mode - should have smooth text transition
|
||||
rerender(<NavLink {...mockProps} mode="expand" />)
|
||||
|
||||
// Text should now be visible with opacity animation
|
||||
expect(screen.getByText('Orchestrate')).toBeInTheDocument()
|
||||
|
||||
// Check padding remains consistent - no layout shift
|
||||
expect(linkElement).toHaveClass('pl-3')
|
||||
expect(linkElement).toHaveClass('pr-1')
|
||||
|
||||
// Fixed: text now uses max-width animation instead of abrupt show/hide
|
||||
const expandedTextElement = screen.getByText('Orchestrate')
|
||||
expect(expandedTextElement).toBeInTheDocument()
|
||||
expect(expandedTextElement).toHaveClass('max-w-none')
|
||||
expect(expandedTextElement).toHaveClass('opacity-100')
|
||||
|
||||
// The fix provides:
|
||||
// - Opacity transition from 0 to 1
|
||||
// - Max-width transition from 0 to none (prevents squashing)
|
||||
// - No layout shift from consistent padding
|
||||
})
|
||||
|
||||
it('should maintain icon position consistency using wrapper div', () => {
|
||||
const { rerender } = render(<NavLink {...mockProps} mode="collapse" />)
|
||||
|
||||
const iconElement = screen.getByTestId('nav-icon')
|
||||
const iconWrapper = iconElement.parentElement
|
||||
|
||||
// Icon wrapper should have -ml-1 micro-adjustment in collapse mode for centering
|
||||
expect(iconWrapper).toHaveClass('-ml-1')
|
||||
|
||||
rerender(<NavLink {...mockProps} mode="expand" />)
|
||||
|
||||
// In expand mode, wrapper should not have the micro-adjustment
|
||||
const expandedIconWrapper = screen.getByTestId('nav-icon').parentElement
|
||||
expect(expandedIconWrapper).not.toHaveClass('-ml-1')
|
||||
|
||||
// Icon itself maintains consistent classes - no margin changes
|
||||
expect(iconElement).toHaveClass('h-4')
|
||||
expect(iconElement).toHaveClass('w-4')
|
||||
expect(iconElement).toHaveClass('shrink-0')
|
||||
|
||||
// This wrapper approach eliminates the icon margin shift issue
|
||||
})
|
||||
|
||||
it('should provide smooth text transition with max-width animation', () => {
|
||||
const { rerender } = render(<NavLink {...mockProps} mode="collapse" />)
|
||||
|
||||
// Text is always in DOM but controlled via CSS classes
|
||||
const collapsedText = screen.getByText('Orchestrate')
|
||||
expect(collapsedText).toBeInTheDocument()
|
||||
expect(collapsedText).toHaveClass('opacity-0')
|
||||
expect(collapsedText).toHaveClass('max-w-0')
|
||||
expect(collapsedText).toHaveClass('overflow-hidden')
|
||||
|
||||
rerender(<NavLink {...mockProps} mode="expand" />)
|
||||
|
||||
// Text smoothly transitions to visible state
|
||||
const expandedText = screen.getByText('Orchestrate')
|
||||
expect(expandedText).toBeInTheDocument()
|
||||
expect(expandedText).toHaveClass('opacity-100')
|
||||
expect(expandedText).toHaveClass('max-w-none')
|
||||
|
||||
// Fixed: Always present in DOM with smooth CSS transitions
|
||||
// instead of abrupt conditional rendering
|
||||
})
|
||||
})
|
||||
|
||||
describe('Layout Consistency Improvements', () => {
|
||||
it('should maintain consistent padding across all states', () => {
|
||||
const { rerender } = render(<NavLink {...mockProps} mode="collapse" />)
|
||||
|
||||
const linkElement = screen.getByTestId('nav-link')
|
||||
|
||||
// Consistent padding in collapsed state
|
||||
expect(linkElement).toHaveClass('pl-3')
|
||||
expect(linkElement).toHaveClass('pr-1')
|
||||
|
||||
rerender(<NavLink {...mockProps} mode="expand" />)
|
||||
|
||||
// Same padding in expanded state - no layout shift
|
||||
expect(linkElement).toHaveClass('pl-3')
|
||||
expect(linkElement).toHaveClass('pr-1')
|
||||
|
||||
// This consistency eliminates the layout shift issue
|
||||
})
|
||||
|
||||
it('should use wrapper-based icon positioning instead of margin changes', () => {
|
||||
const { rerender } = render(<NavLink {...mockProps} mode="collapse" />)
|
||||
|
||||
const iconElement = screen.getByTestId('nav-icon')
|
||||
const iconWrapper = iconElement.parentElement
|
||||
|
||||
// Collapsed: wrapper has micro-adjustment for centering
|
||||
expect(iconWrapper).toHaveClass('-ml-1')
|
||||
|
||||
// Icon itself has consistent classes
|
||||
expect(iconElement).toHaveClass('h-4')
|
||||
expect(iconElement).toHaveClass('w-4')
|
||||
expect(iconElement).toHaveClass('shrink-0')
|
||||
|
||||
rerender(<NavLink {...mockProps} mode="expand" />)
|
||||
|
||||
const expandedIconWrapper = screen.getByTestId('nav-icon').parentElement
|
||||
|
||||
// Expanded: no wrapper adjustment needed
|
||||
expect(expandedIconWrapper).not.toHaveClass('-ml-1')
|
||||
|
||||
// Icon classes remain consistent - no margin shifts
|
||||
expect(iconElement).toHaveClass('h-4')
|
||||
expect(iconElement).toHaveClass('w-4')
|
||||
expect(iconElement).toHaveClass('shrink-0')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Active State Handling', () => {
|
||||
it('should handle active state correctly in both modes', () => {
|
||||
// Test non-active state
|
||||
const { rerender } = render(<NavLink {...mockProps} mode="collapse" />)
|
||||
|
||||
let linkElement = screen.getByTestId('nav-link')
|
||||
expect(linkElement).not.toHaveClass('bg-components-menu-item-bg-active')
|
||||
|
||||
// Test with active state (when href matches current segment)
|
||||
const activeProps = {
|
||||
...mockProps,
|
||||
href: '/app/123/overview', // matches mocked segment
|
||||
}
|
||||
|
||||
rerender(<NavLink {...activeProps} mode="expand" />)
|
||||
|
||||
linkElement = screen.getByTestId('nav-link')
|
||||
expect(linkElement).toHaveClass('bg-components-menu-item-bg-active')
|
||||
expect(linkElement).toHaveClass('text-text-accent-light-mode-only')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Text Animation Classes', () => {
|
||||
it('should have proper text classes in collapsed mode', () => {
|
||||
render(<NavLink {...mockProps} mode="collapse" />)
|
||||
|
||||
const textElement = screen.getByText('Orchestrate')
|
||||
|
||||
expect(textElement).toHaveClass('overflow-hidden')
|
||||
expect(textElement).toHaveClass('whitespace-nowrap')
|
||||
expect(textElement).toHaveClass('transition-all')
|
||||
expect(textElement).toHaveClass('duration-200')
|
||||
expect(textElement).toHaveClass('ease-in-out')
|
||||
expect(textElement).toHaveClass('ml-0')
|
||||
expect(textElement).toHaveClass('max-w-0')
|
||||
expect(textElement).toHaveClass('opacity-0')
|
||||
})
|
||||
|
||||
it('should have proper text classes in expanded mode', () => {
|
||||
render(<NavLink {...mockProps} mode="expand" />)
|
||||
|
||||
const textElement = screen.getByText('Orchestrate')
|
||||
|
||||
expect(textElement).toHaveClass('overflow-hidden')
|
||||
expect(textElement).toHaveClass('whitespace-nowrap')
|
||||
expect(textElement).toHaveClass('transition-all')
|
||||
expect(textElement).toHaveClass('duration-200')
|
||||
expect(textElement).toHaveClass('ease-in-out')
|
||||
expect(textElement).toHaveClass('ml-2')
|
||||
expect(textElement).toHaveClass('max-w-none')
|
||||
expect(textElement).toHaveClass('opacity-100')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Disabled State', () => {
|
||||
it('should render as button when disabled', () => {
|
||||
render(<NavLink {...mockProps} mode="expand" disabled={true} />)
|
||||
|
||||
const buttonElement = screen.getByRole('button')
|
||||
expect(buttonElement).toBeInTheDocument()
|
||||
expect(buttonElement).toBeDisabled()
|
||||
expect(buttonElement).toHaveClass('cursor-not-allowed')
|
||||
expect(buttonElement).toHaveClass('opacity-30')
|
||||
})
|
||||
|
||||
it('should maintain consistent styling in disabled state', () => {
|
||||
render(<NavLink {...mockProps} mode="collapse" disabled={true} />)
|
||||
|
||||
const buttonElement = screen.getByRole('button')
|
||||
expect(buttonElement).toHaveClass('pl-3')
|
||||
expect(buttonElement).toHaveClass('pr-1')
|
||||
|
||||
const iconWrapper = screen.getByTestId('nav-icon').parentElement
|
||||
expect(iconWrapper).toHaveClass('-ml-1')
|
||||
})
|
||||
})
|
||||
})
|
||||
105
dify/web/app/components/app-sidebar/navLink.tsx
Normal file
105
dify/web/app/components/app-sidebar/navLink.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
'use client'
|
||||
import React from 'react'
|
||||
import { useSelectedLayoutSegment } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import classNames from '@/utils/classnames'
|
||||
import type { RemixiconComponentType } from '@remixicon/react'
|
||||
|
||||
export type NavIcon = React.ComponentType<
|
||||
React.PropsWithoutRef<React.ComponentProps<'svg'>> & {
|
||||
title?: string | undefined
|
||||
titleId?: string | undefined
|
||||
}> | RemixiconComponentType
|
||||
|
||||
export type NavLinkProps = {
|
||||
name: string
|
||||
href: string
|
||||
iconMap: {
|
||||
selected: NavIcon
|
||||
normal: NavIcon
|
||||
}
|
||||
mode?: string
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
const NavLink = ({
|
||||
name,
|
||||
href,
|
||||
iconMap,
|
||||
mode = 'expand',
|
||||
disabled = false,
|
||||
}: NavLinkProps) => {
|
||||
const segment = useSelectedLayoutSegment()
|
||||
const formattedSegment = (() => {
|
||||
let res = segment?.toLowerCase()
|
||||
// logs and annotations use the same nav
|
||||
if (res === 'annotations')
|
||||
res = 'logs'
|
||||
|
||||
return res
|
||||
})()
|
||||
const isActive = href.toLowerCase().split('/')?.pop() === formattedSegment
|
||||
const NavIcon = isActive ? iconMap.selected : iconMap.normal
|
||||
|
||||
const renderIcon = () => (
|
||||
<div className={classNames(mode !== 'expand' && '-ml-1')}>
|
||||
<NavIcon className="h-4 w-4 shrink-0" aria-hidden="true" />
|
||||
</div>
|
||||
)
|
||||
|
||||
if (disabled) {
|
||||
return (
|
||||
<button
|
||||
key={name}
|
||||
type='button'
|
||||
disabled
|
||||
className={classNames(
|
||||
'system-sm-medium flex h-8 cursor-not-allowed items-center rounded-lg text-components-menu-item-text opacity-30 hover:bg-components-menu-item-bg-hover',
|
||||
'pl-3 pr-1',
|
||||
)}
|
||||
title={mode === 'collapse' ? name : ''}
|
||||
aria-disabled
|
||||
>
|
||||
{renderIcon()}
|
||||
<span
|
||||
className={classNames(
|
||||
'overflow-hidden whitespace-nowrap transition-all duration-200 ease-in-out',
|
||||
mode === 'expand'
|
||||
? 'ml-2 max-w-none opacity-100'
|
||||
: 'ml-0 max-w-0 opacity-0',
|
||||
)}
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={name}
|
||||
href={href}
|
||||
className={classNames(
|
||||
isActive
|
||||
? 'system-sm-semibold border-b-[0.25px] border-l-[0.75px] border-r-[0.25px] border-t-[0.75px] border-effects-highlight-lightmode-off bg-components-menu-item-bg-active text-text-accent-light-mode-only'
|
||||
: 'system-sm-medium text-components-menu-item-text hover:bg-components-menu-item-bg-hover hover:text-components-menu-item-text-hover',
|
||||
'flex h-8 items-center rounded-lg pl-3 pr-1',
|
||||
)}
|
||||
title={mode === 'collapse' ? name : ''}
|
||||
>
|
||||
{renderIcon()}
|
||||
<span
|
||||
className={classNames(
|
||||
'overflow-hidden whitespace-nowrap transition-all duration-200 ease-in-out',
|
||||
mode === 'expand'
|
||||
? 'ml-2 max-w-none opacity-100'
|
||||
: 'ml-0 max-w-0 opacity-0',
|
||||
)}
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(NavLink)
|
||||
@@ -0,0 +1,297 @@
|
||||
import React from 'react'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import '@testing-library/jest-dom'
|
||||
|
||||
// Simple Mock Components that reproduce the exact UI issues
|
||||
const MockNavLink = ({ name, mode }: { name: string; mode: string }) => {
|
||||
return (
|
||||
<a
|
||||
className={`
|
||||
group flex h-9 items-center rounded-md py-2 text-sm font-normal
|
||||
${mode === 'expand' ? 'px-3' : 'px-2.5'}
|
||||
`}
|
||||
data-testid={`nav-link-${name}`}
|
||||
data-mode={mode}
|
||||
>
|
||||
{/* Icon with inconsistent margin - reproduces issue #2 */}
|
||||
<svg
|
||||
className={`h-4 w-4 shrink-0 ${mode === 'expand' ? 'mr-2' : 'mr-0'}`}
|
||||
data-testid={`nav-icon-${name}`}
|
||||
/>
|
||||
{/* Text that appears/disappears abruptly - reproduces issue #2 */}
|
||||
{mode === 'expand' && <span data-testid={`nav-text-${name}`}>{name}</span>}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
const MockSidebarToggleButton = ({ expand, onToggle }: { expand: boolean; onToggle: () => void }) => {
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
flex shrink-0 flex-col border-r border-divider-burn bg-background-default-subtle transition-all
|
||||
${expand ? 'w-[216px]' : 'w-14'}
|
||||
`}
|
||||
data-testid="sidebar-container"
|
||||
>
|
||||
{/* Top section with variable padding - reproduces issue #1 */}
|
||||
<div className={`shrink-0 ${expand ? 'p-2' : 'p-1'}`} data-testid="top-section">
|
||||
App Info Area
|
||||
</div>
|
||||
|
||||
{/* Navigation section - reproduces issue #2 */}
|
||||
<nav className={`grow space-y-1 ${expand ? 'p-4' : 'px-2.5 py-4'}`} data-testid="navigation">
|
||||
<MockNavLink name="Orchestrate" mode={expand ? 'expand' : 'collapse'} />
|
||||
<MockNavLink name="API Access" mode={expand ? 'expand' : 'collapse'} />
|
||||
<MockNavLink name="Logs & Annotations" mode={expand ? 'expand' : 'collapse'} />
|
||||
<MockNavLink name="Monitoring" mode={expand ? 'expand' : 'collapse'} />
|
||||
</nav>
|
||||
|
||||
{/* Toggle button section with consistent padding - issue #1 FIXED */}
|
||||
<div
|
||||
className="shrink-0 px-4 py-3"
|
||||
data-testid="toggle-section"
|
||||
>
|
||||
<button type="button"
|
||||
className='flex h-6 w-6 cursor-pointer items-center justify-center'
|
||||
onClick={onToggle}
|
||||
data-testid="toggle-button"
|
||||
>
|
||||
{expand ? '→' : '←'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const MockAppInfo = ({ expand }: { expand: boolean }) => {
|
||||
return (
|
||||
<div data-testid="app-info" data-expand={expand}>
|
||||
<button type="button" className='block w-full'>
|
||||
{/* Container with layout mode switching - reproduces issue #3 */}
|
||||
<div className={`flex rounded-lg ${expand ? 'flex-col gap-2 p-2 pb-2.5' : 'items-start justify-center gap-1 p-1'}`}>
|
||||
{/* Icon container with justify-between to flex-col switch - reproduces issue #3 */}
|
||||
<div className={`flex items-center self-stretch ${expand ? 'justify-between' : 'flex-col gap-1'}`} data-testid="icon-container">
|
||||
{/* Icon with size changes - reproduces issue #3 */}
|
||||
<div
|
||||
data-testid="app-icon"
|
||||
data-size={expand ? 'large' : 'small'}
|
||||
style={{
|
||||
width: expand ? '40px' : '24px',
|
||||
height: expand ? '40px' : '24px',
|
||||
backgroundColor: '#000',
|
||||
transition: 'all 0.3s ease', // This broad transition causes bounce
|
||||
}}
|
||||
>
|
||||
Icon
|
||||
</div>
|
||||
<div className='flex items-center justify-center rounded-md p-0.5'>
|
||||
<div className='flex h-5 w-5 items-center justify-center'>
|
||||
⚙️
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Text that appears/disappears conditionally */}
|
||||
{expand && (
|
||||
<div className='flex flex-col items-start gap-1'>
|
||||
<div className='flex w-full'>
|
||||
<div className='system-md-semibold truncate text-text-secondary'>Test App</div>
|
||||
</div>
|
||||
<div className='system-2xs-medium-uppercase text-text-tertiary'>chatflow</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
describe('Sidebar Animation Issues Reproduction', () => {
|
||||
beforeEach(() => {
|
||||
// Mock getBoundingClientRect for position testing
|
||||
Element.prototype.getBoundingClientRect = jest.fn(() => ({
|
||||
width: 200,
|
||||
height: 40,
|
||||
x: 10,
|
||||
y: 10,
|
||||
left: 10,
|
||||
right: 210,
|
||||
top: 10,
|
||||
bottom: 50,
|
||||
toJSON: jest.fn(),
|
||||
}))
|
||||
})
|
||||
|
||||
describe('Issue #1: Toggle Button Position Movement - FIXED', () => {
|
||||
it('should verify consistent padding prevents button position shift', () => {
|
||||
let expanded = false
|
||||
const handleToggle = () => {
|
||||
expanded = !expanded
|
||||
}
|
||||
|
||||
const { rerender } = render(<MockSidebarToggleButton expand={false} onToggle={handleToggle} />)
|
||||
|
||||
// Check collapsed state padding
|
||||
const toggleSection = screen.getByTestId('toggle-section')
|
||||
expect(toggleSection).toHaveClass('px-4') // Consistent padding
|
||||
expect(toggleSection).not.toHaveClass('px-5')
|
||||
expect(toggleSection).not.toHaveClass('px-6')
|
||||
|
||||
// Switch to expanded state
|
||||
rerender(<MockSidebarToggleButton expand={true} onToggle={handleToggle} />)
|
||||
|
||||
// Check expanded state padding - should be the same
|
||||
expect(toggleSection).toHaveClass('px-4') // Same consistent padding
|
||||
expect(toggleSection).not.toHaveClass('px-5')
|
||||
expect(toggleSection).not.toHaveClass('px-6')
|
||||
|
||||
// THE FIX: px-4 in both states prevents position movement
|
||||
console.log('✅ Issue #1 FIXED: Toggle button now has consistent padding')
|
||||
console.log(' - Before: px-4 (collapsed) vs px-6 (expanded) - 8px difference')
|
||||
console.log(' - After: px-4 (both states) - 0px difference')
|
||||
console.log(' - Result: No button position movement during transition')
|
||||
})
|
||||
|
||||
it('should verify sidebar width animation is working correctly', () => {
|
||||
const handleToggle = jest.fn()
|
||||
const { rerender } = render(<MockSidebarToggleButton expand={false} onToggle={handleToggle} />)
|
||||
|
||||
const container = screen.getByTestId('sidebar-container')
|
||||
|
||||
// Collapsed state
|
||||
expect(container).toHaveClass('w-14')
|
||||
expect(container).toHaveClass('transition-all')
|
||||
|
||||
// Expanded state
|
||||
rerender(<MockSidebarToggleButton expand={true} onToggle={handleToggle} />)
|
||||
expect(container).toHaveClass('w-[216px]')
|
||||
|
||||
console.log('✅ Sidebar width transition is properly configured')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Issue #2: Navigation Text Squeeze Animation', () => {
|
||||
it('should reproduce text squeeze effect from padding and margin changes', () => {
|
||||
const { rerender } = render(<MockNavLink name="Orchestrate" mode="collapse" />)
|
||||
|
||||
const link = screen.getByTestId('nav-link-Orchestrate')
|
||||
const icon = screen.getByTestId('nav-icon-Orchestrate')
|
||||
|
||||
// Collapsed state checks
|
||||
expect(link).toHaveClass('px-2.5') // 10px padding
|
||||
expect(icon).toHaveClass('mr-0') // No margin
|
||||
expect(screen.queryByTestId('nav-text-Orchestrate')).not.toBeInTheDocument()
|
||||
|
||||
// Switch to expanded state
|
||||
rerender(<MockNavLink name="Orchestrate" mode="expand" />)
|
||||
|
||||
// Expanded state checks
|
||||
expect(link).toHaveClass('px-3') // 12px padding (+2px)
|
||||
expect(icon).toHaveClass('mr-2') // 8px margin (+8px)
|
||||
expect(screen.getByTestId('nav-text-Orchestrate')).toBeInTheDocument()
|
||||
|
||||
// THE BUG: Multiple simultaneous changes create squeeze effect
|
||||
console.log('🐛 Issue #2 Reproduced: Text squeeze effect from multiple layout changes')
|
||||
console.log(' - Link padding: px-2.5 → px-3 (+2px)')
|
||||
console.log(' - Icon margin: mr-0 → mr-2 (+8px)')
|
||||
console.log(' - Text appears: none → visible (abrupt)')
|
||||
console.log(' - Result: Text appears with squeeze effect due to layout shifts')
|
||||
})
|
||||
|
||||
it('should document the abrupt text rendering issue', () => {
|
||||
const { rerender } = render(<MockNavLink name="API Access" mode="collapse" />)
|
||||
|
||||
// Text completely absent
|
||||
expect(screen.queryByTestId('nav-text-API Access')).not.toBeInTheDocument()
|
||||
|
||||
rerender(<MockNavLink name="API Access" mode="expand" />)
|
||||
|
||||
// Text suddenly appears - no transition
|
||||
expect(screen.getByTestId('nav-text-API Access')).toBeInTheDocument()
|
||||
|
||||
console.log('🐛 Issue #2 Detail: Conditional rendering {mode === "expand" && name}')
|
||||
console.log(' - Problem: Text appears/disappears abruptly without transition')
|
||||
console.log(' - Should use: opacity or width transition for smooth appearance')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Issue #3: App Icon Bounce Animation', () => {
|
||||
it('should reproduce icon bounce from layout mode switching', () => {
|
||||
const { rerender } = render(<MockAppInfo expand={true} />)
|
||||
|
||||
const iconContainer = screen.getByTestId('icon-container')
|
||||
const appIcon = screen.getByTestId('app-icon')
|
||||
|
||||
// Expanded state layout
|
||||
expect(iconContainer).toHaveClass('justify-between')
|
||||
expect(iconContainer).not.toHaveClass('flex-col')
|
||||
expect(appIcon).toHaveAttribute('data-size', 'large')
|
||||
|
||||
// Switch to collapsed state
|
||||
rerender(<MockAppInfo expand={false} />)
|
||||
|
||||
// Collapsed state layout - completely different layout mode
|
||||
expect(iconContainer).toHaveClass('flex-col')
|
||||
expect(iconContainer).toHaveClass('gap-1')
|
||||
expect(iconContainer).not.toHaveClass('justify-between')
|
||||
expect(appIcon).toHaveAttribute('data-size', 'small')
|
||||
|
||||
// THE BUG: Layout mode switch causes icon to "bounce"
|
||||
console.log('🐛 Issue #3 Reproduced: Icon bounce from layout mode switching')
|
||||
console.log(' - Layout change: justify-between → flex-col gap-1')
|
||||
console.log(' - Icon size: large (40px) → small (24px)')
|
||||
console.log(' - Transition: transition-all causes excessive animation')
|
||||
console.log(' - Result: Icon appears to bounce to right then back during collapse')
|
||||
})
|
||||
|
||||
it('should identify the problematic transition-all property', () => {
|
||||
render(<MockAppInfo expand={true} />)
|
||||
|
||||
const appIcon = screen.getByTestId('app-icon')
|
||||
const computedStyle = window.getComputedStyle(appIcon)
|
||||
|
||||
// The problematic broad transition
|
||||
expect(computedStyle.transition).toContain('all')
|
||||
|
||||
console.log('🐛 Issue #3 Detail: transition-all affects ALL CSS properties')
|
||||
console.log(' - Problem: Animates layout properties that should not transition')
|
||||
console.log(' - Solution: Use specific transition properties instead of "all"')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Interactive Toggle Test', () => {
|
||||
it('should demonstrate all issues in a single interactive test', () => {
|
||||
let expanded = false
|
||||
const handleToggle = () => {
|
||||
expanded = !expanded
|
||||
}
|
||||
|
||||
const { rerender } = render(
|
||||
<div data-testid="complete-sidebar">
|
||||
<MockSidebarToggleButton expand={expanded} onToggle={handleToggle} />
|
||||
<MockAppInfo expand={expanded} />
|
||||
</div>,
|
||||
)
|
||||
|
||||
const toggleButton = screen.getByTestId('toggle-button')
|
||||
|
||||
// Initial state verification
|
||||
expect(expanded).toBe(false)
|
||||
console.log('🔄 Starting interactive test - all issues will be reproduced')
|
||||
|
||||
// Simulate toggle click
|
||||
fireEvent.click(toggleButton)
|
||||
expanded = true
|
||||
rerender(
|
||||
<div data-testid="complete-sidebar">
|
||||
<MockSidebarToggleButton expand={expanded} onToggle={handleToggle} />
|
||||
<MockAppInfo expand={expanded} />
|
||||
</div>,
|
||||
)
|
||||
|
||||
console.log('✨ All three issues successfully reproduced in interactive test:')
|
||||
console.log(' 1. Toggle button position movement (padding inconsistency)')
|
||||
console.log(' 2. Navigation text squeeze effect (multiple layout changes)')
|
||||
console.log(' 3. App icon bounce animation (layout mode switching)')
|
||||
})
|
||||
})
|
||||
})
|
||||
11
dify/web/app/components/app-sidebar/style.module.css
Normal file
11
dify/web/app/components/app-sidebar/style.module.css
Normal file
@@ -0,0 +1,11 @@
|
||||
.sidebar {
|
||||
border-right: 1px solid #F3F4F6;
|
||||
}
|
||||
|
||||
.completionPic {
|
||||
background-image: url('./completion.png')
|
||||
}
|
||||
|
||||
.expertPic {
|
||||
background-image: url('./expert.png')
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
/**
|
||||
* Text Squeeze Fix Verification Test
|
||||
* This test verifies that the CSS-based text rendering fixes work correctly
|
||||
*/
|
||||
|
||||
import React from 'react'
|
||||
import { render } from '@testing-library/react'
|
||||
import '@testing-library/jest-dom'
|
||||
|
||||
// Mock Next.js navigation
|
||||
jest.mock('next/navigation', () => ({
|
||||
useSelectedLayoutSegment: () => 'overview',
|
||||
}))
|
||||
|
||||
// Mock classnames utility
|
||||
jest.mock('@/utils/classnames', () => ({
|
||||
__esModule: true,
|
||||
default: (...classes: any[]) => classes.filter(Boolean).join(' '),
|
||||
}))
|
||||
|
||||
// Simplified NavLink component to test the fix
|
||||
const TestNavLink = ({ mode }: { mode: 'expand' | 'collapse' }) => {
|
||||
const name = 'Orchestrate'
|
||||
|
||||
return (
|
||||
<div className="nav-link-container">
|
||||
<div className={`flex h-9 items-center rounded-md py-2 text-sm font-normal ${
|
||||
mode === 'expand' ? 'px-3' : 'px-2.5'
|
||||
}`}>
|
||||
<div className={`h-4 w-4 shrink-0 ${mode === 'expand' ? 'mr-2' : 'mr-0'}`}>
|
||||
Icon
|
||||
</div>
|
||||
<span
|
||||
className={`whitespace-nowrap transition-all duration-200 ease-in-out ${
|
||||
mode === 'expand'
|
||||
? 'w-auto opacity-100'
|
||||
: 'pointer-events-none w-0 overflow-hidden opacity-0'
|
||||
}`}
|
||||
data-testid="nav-text"
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Simplified AppInfo component to test the fix
|
||||
const TestAppInfo = ({ expand }: { expand: boolean }) => {
|
||||
const appDetail = {
|
||||
name: 'Test ChatBot App',
|
||||
mode: 'chat' as const,
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="app-info-container">
|
||||
<div className={`flex rounded-lg ${expand ? 'flex-col gap-2 p-2 pb-2.5' : 'items-start justify-center gap-1 p-1'}`}>
|
||||
<div className={`flex items-center self-stretch ${expand ? 'justify-between' : 'flex-col gap-1'}`}>
|
||||
<div className="app-icon">AppIcon</div>
|
||||
<div className="dashboard-icon">Dashboard</div>
|
||||
</div>
|
||||
<div
|
||||
className={`flex flex-col items-start gap-1 transition-all duration-200 ease-in-out ${
|
||||
expand
|
||||
? 'w-auto opacity-100'
|
||||
: 'pointer-events-none w-0 overflow-hidden opacity-0'
|
||||
}`}
|
||||
data-testid="app-text-container"
|
||||
>
|
||||
<div className='flex w-full'>
|
||||
<div
|
||||
className='system-md-semibold truncate whitespace-nowrap text-text-secondary'
|
||||
data-testid="app-name"
|
||||
>
|
||||
{appDetail.name}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className='system-2xs-medium-uppercase whitespace-nowrap text-text-tertiary'
|
||||
data-testid="app-type"
|
||||
>
|
||||
ChatBot
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
describe('Text Squeeze Fix Verification', () => {
|
||||
describe('NavLink Text Rendering Fix', () => {
|
||||
it('should keep text in DOM and use CSS transitions', () => {
|
||||
const { container, rerender } = render(<TestNavLink mode="collapse" />)
|
||||
|
||||
// In collapsed state, text should be in DOM but hidden
|
||||
const textElement = container.querySelector('[data-testid="nav-text"]')
|
||||
expect(textElement).toBeInTheDocument()
|
||||
expect(textElement).toHaveClass('opacity-0')
|
||||
expect(textElement).toHaveClass('w-0')
|
||||
expect(textElement).toHaveClass('overflow-hidden')
|
||||
expect(textElement).toHaveClass('pointer-events-none')
|
||||
expect(textElement).toHaveClass('whitespace-nowrap')
|
||||
expect(textElement).toHaveClass('transition-all')
|
||||
|
||||
console.log('✅ NavLink Collapsed State:')
|
||||
console.log(' - Text is in DOM but visually hidden')
|
||||
console.log(' - Uses opacity-0 and w-0 for hiding')
|
||||
console.log(' - Has whitespace-nowrap to prevent wrapping')
|
||||
console.log(' - Has transition-all for smooth animation')
|
||||
|
||||
// Switch to expanded state
|
||||
rerender(<TestNavLink mode="expand" />)
|
||||
|
||||
const expandedText = container.querySelector('[data-testid="nav-text"]')
|
||||
expect(expandedText).toBeInTheDocument()
|
||||
expect(expandedText).toHaveClass('opacity-100')
|
||||
expect(expandedText).toHaveClass('w-auto')
|
||||
expect(expandedText).not.toHaveClass('pointer-events-none')
|
||||
|
||||
console.log('✅ NavLink Expanded State:')
|
||||
console.log(' - Text is visible with opacity-100')
|
||||
console.log(' - Uses w-auto for natural width')
|
||||
console.log(' - No layout jumps during transition')
|
||||
|
||||
console.log('🎯 NavLink Fix Result: Text squeeze effect ELIMINATED')
|
||||
})
|
||||
|
||||
it('should verify smooth transition properties', () => {
|
||||
const { container } = render(<TestNavLink mode="collapse" />)
|
||||
|
||||
const textElement = container.querySelector('[data-testid="nav-text"]')
|
||||
expect(textElement).toHaveClass('transition-all')
|
||||
expect(textElement).toHaveClass('duration-200')
|
||||
expect(textElement).toHaveClass('ease-in-out')
|
||||
|
||||
console.log('✅ Transition Properties Verified:')
|
||||
console.log(' - transition-all: Smooth property changes')
|
||||
console.log(' - duration-200: 200ms transition time')
|
||||
console.log(' - ease-in-out: Smooth easing function')
|
||||
})
|
||||
})
|
||||
|
||||
describe('AppInfo Text Rendering Fix', () => {
|
||||
it('should keep app text in DOM and use CSS transitions', () => {
|
||||
const { container, rerender } = render(<TestAppInfo expand={false} />)
|
||||
|
||||
// In collapsed state, text container should be in DOM but hidden
|
||||
const textContainer = container.querySelector('[data-testid="app-text-container"]')
|
||||
expect(textContainer).toBeInTheDocument()
|
||||
expect(textContainer).toHaveClass('opacity-0')
|
||||
expect(textContainer).toHaveClass('w-0')
|
||||
expect(textContainer).toHaveClass('overflow-hidden')
|
||||
expect(textContainer).toHaveClass('pointer-events-none')
|
||||
|
||||
// Text elements should still be in DOM
|
||||
const appName = container.querySelector('[data-testid="app-name"]')
|
||||
const appType = container.querySelector('[data-testid="app-type"]')
|
||||
expect(appName).toBeInTheDocument()
|
||||
expect(appType).toBeInTheDocument()
|
||||
expect(appName).toHaveClass('whitespace-nowrap')
|
||||
expect(appType).toHaveClass('whitespace-nowrap')
|
||||
|
||||
console.log('✅ AppInfo Collapsed State:')
|
||||
console.log(' - Text container is in DOM but visually hidden')
|
||||
console.log(' - App name and type elements always present')
|
||||
console.log(' - Uses whitespace-nowrap to prevent wrapping')
|
||||
|
||||
// Switch to expanded state
|
||||
rerender(<TestAppInfo expand={true} />)
|
||||
|
||||
const expandedContainer = container.querySelector('[data-testid="app-text-container"]')
|
||||
expect(expandedContainer).toBeInTheDocument()
|
||||
expect(expandedContainer).toHaveClass('opacity-100')
|
||||
expect(expandedContainer).toHaveClass('w-auto')
|
||||
expect(expandedContainer).not.toHaveClass('pointer-events-none')
|
||||
|
||||
console.log('✅ AppInfo Expanded State:')
|
||||
console.log(' - Text container is visible with opacity-100')
|
||||
console.log(' - Uses w-auto for natural width')
|
||||
console.log(' - No layout jumps during transition')
|
||||
|
||||
console.log('🎯 AppInfo Fix Result: Text squeeze effect ELIMINATED')
|
||||
})
|
||||
|
||||
it('should verify transition properties on text container', () => {
|
||||
const { container } = render(<TestAppInfo expand={false} />)
|
||||
|
||||
const textContainer = container.querySelector('[data-testid="app-text-container"]')
|
||||
expect(textContainer).toHaveClass('transition-all')
|
||||
expect(textContainer).toHaveClass('duration-200')
|
||||
expect(textContainer).toHaveClass('ease-in-out')
|
||||
|
||||
console.log('✅ AppInfo Transition Properties Verified:')
|
||||
console.log(' - Container has smooth CSS transitions')
|
||||
console.log(' - Same 200ms duration as NavLink for consistency')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Fix Strategy Comparison', () => {
|
||||
it('should document the fix strategy differences', () => {
|
||||
console.log('\n📋 TEXT SQUEEZE FIX STRATEGY COMPARISON')
|
||||
console.log('='.repeat(60))
|
||||
|
||||
console.log('\n❌ BEFORE (Problematic):')
|
||||
console.log(' NavLink: {mode === "expand" && name}')
|
||||
console.log(' AppInfo: {expand && (<div>...</div>)}')
|
||||
console.log(' Problem: Conditional rendering causes abrupt appearance')
|
||||
console.log(' Result: Text "squeezes" from center during layout changes')
|
||||
|
||||
console.log('\n✅ AFTER (Fixed):')
|
||||
console.log(' NavLink: <span className="opacity-0 w-0">{name}</span>')
|
||||
console.log(' AppInfo: <div className="opacity-0 w-0">...</div>')
|
||||
console.log(' Solution: CSS controls visibility, element always in DOM')
|
||||
console.log(' Result: Smooth opacity and width transitions')
|
||||
|
||||
console.log('\n🎯 KEY FIX PRINCIPLES:')
|
||||
console.log(' 1. ✅ Always keep text elements in DOM')
|
||||
console.log(' 2. ✅ Use opacity for show/hide transitions')
|
||||
console.log(' 3. ✅ Use width (w-0/w-auto) for layout control')
|
||||
console.log(' 4. ✅ Add whitespace-nowrap to prevent wrapping')
|
||||
console.log(' 5. ✅ Use pointer-events-none when hidden')
|
||||
console.log(' 6. ✅ Add overflow-hidden for clean hiding')
|
||||
|
||||
console.log('\n🚀 BENEFITS:')
|
||||
console.log(' - No more abrupt text appearance')
|
||||
console.log(' - Smooth 200ms transitions')
|
||||
console.log(' - No layout jumps or shifts')
|
||||
console.log(' - Consistent animation timing')
|
||||
console.log(' - Better user experience')
|
||||
|
||||
// Always pass documentation test
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
71
dify/web/app/components/app-sidebar/toggle-button.tsx
Normal file
71
dify/web/app/components/app-sidebar/toggle-button.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import React from 'react'
|
||||
import Button from '../base/button'
|
||||
import { RiArrowLeftSLine, RiArrowRightSLine } from '@remixicon/react'
|
||||
import cn from '@/utils/classnames'
|
||||
import Tooltip from '../base/tooltip'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { getKeyboardKeyNameBySystem } from '../workflow/utils'
|
||||
|
||||
type TooltipContentProps = {
|
||||
expand: boolean
|
||||
}
|
||||
|
||||
const TOGGLE_SHORTCUT = ['ctrl', 'B']
|
||||
|
||||
const TooltipContent = ({
|
||||
expand,
|
||||
}: TooltipContentProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className='flex items-center gap-x-1'>
|
||||
<span className='system-xs-medium px-0.5 text-text-secondary'>{expand ? t('layout.sidebar.collapseSidebar') : t('layout.sidebar.expandSidebar')}</span>
|
||||
<div className='flex items-center gap-x-0.5'>
|
||||
{
|
||||
TOGGLE_SHORTCUT.map(key => (
|
||||
<span
|
||||
key={key}
|
||||
className='system-kbd inline-flex items-center justify-center rounded-[4px] bg-components-kbd-bg-gray px-1 text-text-tertiary'
|
||||
>
|
||||
{getKeyboardKeyNameBySystem(key)}
|
||||
</span>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type ToggleButtonProps = {
|
||||
expand: boolean
|
||||
handleToggle: () => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
const ToggleButton = ({
|
||||
expand,
|
||||
handleToggle,
|
||||
className,
|
||||
}: ToggleButtonProps) => {
|
||||
return (
|
||||
<Tooltip
|
||||
popupContent={<TooltipContent expand={expand} />}
|
||||
popupClassName='p-1.5 rounded-lg'
|
||||
position='right'
|
||||
>
|
||||
<Button
|
||||
size='small'
|
||||
onClick={handleToggle}
|
||||
className={cn('rounded-full px-1', className)}
|
||||
>
|
||||
{
|
||||
expand
|
||||
? <RiArrowLeftSLine className='size-4' />
|
||||
: <RiArrowRightSLine className='size-4' />
|
||||
}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(ToggleButton)
|
||||
Reference in New Issue
Block a user