refactor(web): Restructure the operation buttons layout in the app information component (#21742) (#21818)
This commit is contained in:
@@ -10,7 +10,6 @@ import {
|
|||||||
RiFileCopy2Line,
|
RiFileCopy2Line,
|
||||||
RiFileDownloadLine,
|
RiFileDownloadLine,
|
||||||
RiFileUploadLine,
|
RiFileUploadLine,
|
||||||
RiMoreLine,
|
|
||||||
} from '@remixicon/react'
|
} from '@remixicon/react'
|
||||||
import AppIcon from '../base/app-icon'
|
import AppIcon from '../base/app-icon'
|
||||||
import SwitchAppModal from '../app/switch-app-modal'
|
import SwitchAppModal from '../app/switch-app-modal'
|
||||||
@@ -35,7 +34,8 @@ import ContentDialog from '@/app/components/base/content-dialog'
|
|||||||
import Button from '@/app/components/base/button'
|
import Button from '@/app/components/base/button'
|
||||||
import CardView from '@/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/cardView'
|
import CardView from '@/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/cardView'
|
||||||
import Divider from '../base/divider'
|
import Divider from '../base/divider'
|
||||||
import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '../base/portal-to-follow-elem'
|
import type { Operation } from './app-operations'
|
||||||
|
import AppOperations from './app-operations'
|
||||||
|
|
||||||
export type IAppInfoProps = {
|
export type IAppInfoProps = {
|
||||||
expand: boolean
|
expand: boolean
|
||||||
@@ -186,14 +186,58 @@ const AppInfo = ({ expand, onlyShowDetail = false, openState = false, onDetailEx
|
|||||||
|
|
||||||
const { isCurrentWorkspaceEditor } = useAppContext()
|
const { isCurrentWorkspaceEditor } = useAppContext()
|
||||||
|
|
||||||
const [showMore, setShowMore] = useState(false)
|
|
||||||
const handleTriggerMore = useCallback(() => {
|
|
||||||
setShowMore(true)
|
|
||||||
}, [setShowMore])
|
|
||||||
|
|
||||||
if (!appDetail)
|
if (!appDetail)
|
||||||
return null
|
return null
|
||||||
|
|
||||||
|
const operations = [
|
||||||
|
{
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
(appDetail.mode !== 'agent-chat' && (appDetail.mode === 'advanced-chat' || appDetail.mode === 'workflow')) ? {
|
||||||
|
id: 'import',
|
||||||
|
title: t('workflow.common.importDSL'),
|
||||||
|
icon: <RiFileUploadLine />,
|
||||||
|
onClick: () => {
|
||||||
|
setOpen(false)
|
||||||
|
onDetailExpand?.(false)
|
||||||
|
setShowImportDSLModal(true)
|
||||||
|
},
|
||||||
|
} : undefined,
|
||||||
|
(appDetail.mode !== 'agent-chat' && (appDetail.mode === 'completion' || appDetail.mode === 'chat')) ? {
|
||||||
|
id: 'switch',
|
||||||
|
title: t('app.switch'),
|
||||||
|
icon: <RiExchange2Line />,
|
||||||
|
onClick: () => {
|
||||||
|
setOpen(false)
|
||||||
|
onDetailExpand?.(false)
|
||||||
|
setShowSwitchModal(true)
|
||||||
|
},
|
||||||
|
} : undefined,
|
||||||
|
].filter((op): op is Operation => Boolean(op))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{!onlyShowDetail && (
|
{!onlyShowDetail && (
|
||||||
@@ -259,88 +303,10 @@ const AppInfo = ({ expand, onlyShowDetail = false, openState = false, onDetailEx
|
|||||||
<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>
|
<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 */}
|
{/* operations */}
|
||||||
<div className='flex flex-wrap items-center gap-1 self-stretch'>
|
<AppOperations
|
||||||
<Button
|
gap={4}
|
||||||
size={'small'}
|
operations={operations}
|
||||||
variant={'secondary'}
|
/>
|
||||||
className='gap-[1px]'
|
|
||||||
onClick={() => {
|
|
||||||
setOpen(false)
|
|
||||||
onDetailExpand?.(false)
|
|
||||||
setShowEditModal(true)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<RiEditLine className='h-3.5 w-3.5 text-components-button-secondary-text' />
|
|
||||||
<span className='system-xs-medium text-components-button-secondary-text'>{t('app.editApp')}</span>
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size={'small'}
|
|
||||||
variant={'secondary'}
|
|
||||||
className='gap-[1px]'
|
|
||||||
onClick={() => {
|
|
||||||
setOpen(false)
|
|
||||||
onDetailExpand?.(false)
|
|
||||||
setShowDuplicateModal(true)
|
|
||||||
}}>
|
|
||||||
<RiFileCopy2Line className='h-3.5 w-3.5 text-components-button-secondary-text' />
|
|
||||||
<span className='system-xs-medium text-components-button-secondary-text'>{t('app.duplicate')}</span>
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size={'small'}
|
|
||||||
variant={'secondary'}
|
|
||||||
className='gap-[1px]'
|
|
||||||
onClick={exportCheck}
|
|
||||||
>
|
|
||||||
<RiFileDownloadLine className='h-3.5 w-3.5 text-components-button-secondary-text' />
|
|
||||||
<span className='system-xs-medium text-components-button-secondary-text'>{t('app.export')}</span>
|
|
||||||
</Button>
|
|
||||||
{appDetail.mode !== 'agent-chat' && <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-[21]'>
|
|
||||||
<div className='flex 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]'>
|
|
||||||
{
|
|
||||||
(appDetail.mode === 'advanced-chat' || appDetail.mode === 'workflow')
|
|
||||||
&& <div className='flex h-8 cursor-pointer items-center gap-x-1 rounded-lg p-1.5 hover:bg-state-base-hover'
|
|
||||||
onClick={() => {
|
|
||||||
setOpen(false)
|
|
||||||
onDetailExpand?.(false)
|
|
||||||
setShowImportDSLModal(true)
|
|
||||||
}}>
|
|
||||||
<RiFileUploadLine className='h-4 w-4 text-text-tertiary' />
|
|
||||||
<span className='system-md-regular text-text-secondary'>{t('workflow.common.importDSL')}</span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
{
|
|
||||||
(appDetail.mode === 'completion' || appDetail.mode === 'chat')
|
|
||||||
&& <div className='flex h-8 cursor-pointer items-center gap-x-1 rounded-lg p-1.5 hover:bg-state-base-hover'
|
|
||||||
onClick={() => {
|
|
||||||
setOpen(false)
|
|
||||||
onDetailExpand?.(false)
|
|
||||||
setShowSwitchModal(true)
|
|
||||||
}}>
|
|
||||||
<RiExchange2Line className='h-4 w-4 text-text-tertiary' />
|
|
||||||
<span className='system-md-regular text-text-secondary'>{t('app.switch')}</span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</PortalToFollowElemContent>
|
|
||||||
</PortalToFollowElem>}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className='flex flex-1'>
|
<div className='flex flex-1'>
|
||||||
<CardView
|
<CardView
|
||||||
|
145
web/app/components/app-sidebar/app-operations.tsx
Normal file
145
web/app/components/app-sidebar/app-operations.tsx
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
import type { ReactElement } from 'react'
|
||||||
|
import { cloneElement, useCallback } from 'react'
|
||||||
|
import { useEffect, 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: ReactElement; onClick: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const AppOperations = ({ operations, gap }: {
|
||||||
|
operations: Operation[]
|
||||||
|
gap: number
|
||||||
|
}) => {
|
||||||
|
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])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const moreElement = document.getElementById('more')
|
||||||
|
const navElement = document.getElementById('nav')
|
||||||
|
let width = 0
|
||||||
|
const containerWidth = navElement?.clientWidth ?? 0
|
||||||
|
const moreWidth = moreElement?.clientWidth ?? 0
|
||||||
|
|
||||||
|
if (containerWidth === 0 || moreWidth === 0) return
|
||||||
|
|
||||||
|
const updatedEntries: Record<string, boolean> = operations.reduce((pre, cur) => {
|
||||||
|
pre[cur.id] = false
|
||||||
|
return pre
|
||||||
|
}, {} as Record<string, boolean>)
|
||||||
|
const childrens = Array.from(navRef.current!.children).slice(0, -1)
|
||||||
|
for (let i = 0; i < childrens.length; i++) {
|
||||||
|
const child: any = childrens[i]
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setVisibleOperations(operations.filter(item => updatedEntries[item.id]))
|
||||||
|
setMoreOperations(operations.filter(item => !updatedEntries[item.id]))
|
||||||
|
}, [operations, gap])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{!visibleOpreations.length && <div
|
||||||
|
id="nav"
|
||||||
|
ref={navRef}
|
||||||
|
className="flex h-0 items-center self-stretch overflow-hidden"
|
||||||
|
style={{ gap }}
|
||||||
|
>
|
||||||
|
{operations.map((operation, index) =>
|
||||||
|
<Button
|
||||||
|
key={index}
|
||||||
|
data-targetid={operation.id}
|
||||||
|
size={'small'}
|
||||||
|
variant={'secondary'}
|
||||||
|
className="gap-[1px]">
|
||||||
|
{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"
|
||||||
|
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>
|
||||||
|
</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>,
|
||||||
|
)}
|
||||||
|
{visibleOpreations.length < operations.length && <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-[21]'>
|
||||||
|
<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 => <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
|
Reference in New Issue
Block a user