Merge pull request #925 from SuanmoSuanyangTechnology/feature/ui_zy

Feature/UI zy
This commit is contained in:
yingzhao
2026-04-17 11:59:37 +08:00
committed by GitHub
27 changed files with 769 additions and 547 deletions

View File

@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="16px" height="16px" viewBox="0 0 16 16" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<title>关闭</title>
<g id="空间里层页面优化" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="应用管理-My-Shares" transform="translate(-1396, -127)" fill="#5B6167" fill-rule="nonzero">
<g id="卡片1备份-2" transform="translate(1044, 108)">
<g id="编组-12" transform="translate(349, 16)">
<g id="关闭" transform="translate(3, 3)">
<polygon id="路径" points="9.00000098 8 13.3333333 12.3333324 12.3333324 13.3333333 8 9.00000098 3.66666764 13.3333333 2.66666667 12.3333324 6.99999902 8 2.66666667 3.66666764 3.66666764 2.66666667 8 6.99999902 12.3333324 2.66666667 13.3333333 3.66666764"></polygon>
</g>
</g>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1005 B

View File

@@ -1,17 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="16px" height="16px" viewBox="0 0 16 16" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<title>退出</title>
<g id="V1.0版" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" stroke-linecap="round" stroke-linejoin="round">
<g id="应用管理-编排-默认状态" transform="translate(-1262, -24)" stroke="#5B6167">
<g id="返回空间" transform="translate(1262, 24)">
<g id="退出" transform="translate(8, 8) scale(-1, 1) translate(-8, -8)">
<g id="编组-7" transform="translate(2.5, 2)">
<path d="M6,12 L1,12 C0.44771525,12 0,11.5522847 0,11 L0,1 C0,0.44771525 0.44771525,1.11022302e-16 1,0 L6,0 L6,0" id="路径"></path>
<line x1="11" y1="6" x2="3" y2="6" id="路径-6"></line>
<polyline id="路径" points="8 3 11 6 8 9"></polyline>
</g>
</g>
</g>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="16px" height="16px" viewBox="0 0 16 16" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<title>退出</title>
<g id="空间里层页面优化" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" stroke-linecap="round" stroke-linejoin="round">
<g id="空间配置" transform="translate(-22, -763)" stroke="#5B6167" stroke-width="1.2">
<g id="退出" transform="translate(0, 742)">
<g id="返回空间" transform="translate(12, 10)">
<g id="退出" transform="translate(10, 11)">
<g id="编组-7" transform="translate(2.5, 2)">
<path d="M6,12 L1,12 C0.44771525,12 0,11.5522847 0,11 L0,1 C0,0.44771525 0.44771525,1.11022302e-16 1,0 L6,0 L6,0" id="路径"></path>
<line x1="11" y1="6" x2="3" y2="6" id="路径-6"></line>
<polyline id="路径" points="8 3 11 6 8 9"></polyline>
</g>
</g>
</g>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -1,17 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="16px" height="16px" viewBox="0 0 16 16" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<title>退出</title>
<g id="V1.0版" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" stroke-linecap="round" stroke-linejoin="round">
<g id="应用管理-编排-默认状态" transform="translate(-1262, -24)" stroke="#155EEF">
<g id="返回空间" transform="translate(1262, 24)">
<g id="退出" transform="translate(8, 8) scale(-1, 1) translate(-8, -8)">
<g id="编组-7" transform="translate(2.5, 2)">
<path d="M6,12 L1,12 C0.44771525,12 0,11.5522847 0,11 L0,1 C0,0.44771525 0.44771525,1.11022302e-16 1,0 L6,0 L6,0" id="路径"></path>
<line x1="11" y1="6" x2="3" y2="6" id="路径-6"></line>
<polyline id="路径" points="8 3 11 6 8 9"></polyline>
</g>
</g>
</g>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -1,8 +1,8 @@
/* /*
* @Author: ZhaoYing * @Author: ZhaoYing
* @Date: 2026-02-04 17:20:52 * @Date: 2026-02-04 17:20:52
* @Last Modified by: ZhaoYing * @Last Modified by: ZhaoYing
* @Last Modified time: 2026-02-04 17:20:52 * @Last Modified time: 2026-04-16 11:46:39
*/ */
import { useEffect, useRef, useMemo } from 'react'; import { useEffect, useRef, useMemo } from 'react';
import { EditorView, basicSetup } from 'codemirror'; import { EditorView, basicSetup } from 'codemirror';
@@ -35,7 +35,7 @@ interface CodeMirrorEditorProps {
height?: string; height?: string;
size?: 'default' | 'small'; size?: 'default' | 'small';
placeholder?: string; placeholder?: string;
variant?: 'outlined' | 'borderless'; variant?: 'outlined' | 'borderless' | 'filled';
} }
/** /**
@@ -156,7 +156,7 @@ const CodeMirrorEditor = ({
<div <div
ref={editorRef} ref={editorRef}
style={{ minHeight, fontSize, lineHeight }} style={{ minHeight, fontSize, lineHeight }}
className={variant === 'borderless' ? '' : 'rb-border rb:rounded-[8px]'} className={variant === 'outlined' ? 'rb-border rb:rounded-lg' : variant === 'filled' ? 'cm-editor-filled rb:rounded-lg' : ''}
/> />
); );
}; };

View File

@@ -1,5 +1,6 @@
.page-tabs:global(.ant-segmented) { .page-tabs:global(.ant-segmented) {
padding: 4px; padding: 4px;
margin-left: 4px;
} }
.page-tabs:global(.ant-segmented .ant-segmented-item-label) { .page-tabs:global(.ant-segmented .ant-segmented-item-label) {
line-height: 24px; line-height: 24px;

View File

@@ -44,6 +44,7 @@ const RbSlider: FC<RbSliderProps> = ({
className = '', className = '',
prefix, prefix,
inputClassName, inputClassName,
disabled,
...rest ...rest
}) => { }) => {
const [curValue, setCurValue] = useState<SliderSingleProps['value']>(0) const [curValue, setCurValue] = useState<SliderSingleProps['value']>(0)
@@ -83,6 +84,7 @@ const RbSlider: FC<RbSliderProps> = ({
max={max} max={max}
step={step} step={step}
value={curValue} value={curValue}
disabled={disabled}
onChange={handleSliderChange} onChange={handleSliderChange}
classNames={size === 'small' ? { classNames={size === 'small' ? {
rail: 'rb:w-[calc(100%-6px)]!' rail: 'rb:w-[calc(100%-6px)]!'
@@ -96,6 +98,7 @@ const RbSlider: FC<RbSliderProps> = ({
max={max} max={max}
step={step as number} step={step as number}
value={curValue} value={curValue}
disabled={disabled}
onChange={handleInputChange} onChange={handleInputChange}
prefix={prefix} prefix={prefix}
className={`${inputClassName || '' } rb:w-20!`} className={`${inputClassName || '' } rb:w-20!`}

View File

@@ -337,7 +337,7 @@ const Menu: FC<{
onClick={goToSpace} onClick={goToSpace}
className="rb-border-t rb:pt-5! rb:pb-2.5! rb:absolute rb:bottom-2.5 rb:right-5 rb:left-5 rb:text-[13px] rb:text-[#5B6167] rb:hover:text-[#212332] rb:leading-4.5 rb:font-regular rb:text-center rb:mt-2.25 rb:cursor-pointer" className="rb-border-t rb:pt-5! rb:pb-2.5! rb:absolute rb:bottom-2.5 rb:right-5 rb:left-5 rb:text-[13px] rb:text-[#5B6167] rb:hover:text-[#212332] rb:leading-4.5 rb:font-regular rb:text-center rb:mt-2.25 rb:cursor-pointer"
> >
<div className="rb:cursor-pointer rb:size-4 rb:bg-cover rb:bg-[url('@/assets/images/logout.svg')]"></div> <div className="rb:cursor-pointer rb:size-4 rb:bg-cover rb:bg-[url('@/assets/images/logout_grey.svg')]"></div>
{collapsed ? null : t('common.returnToSpace')} {collapsed ? null : t('common.returnToSpace')}
</Flex> </Flex>
} }

View File

@@ -18,7 +18,7 @@ import { type FC, type ReactNode } from 'react'
/** Props interface for Tag component */ /** Props interface for Tag component */
export interface TagProps { export interface TagProps {
/** Color theme for the tag */ /** Color theme for the tag */
color?: 'processing' | 'error' | 'success' | 'warning' | 'default', color?: 'processing' | 'error' | 'success' | 'warning' | 'default' | 'purple' | 'dark',
/** Tag content */ /** Tag content */
children: ReactNode; children: ReactNode;
/** Additional CSS classes */ /** Additional CSS classes */
@@ -32,6 +32,8 @@ const colors = {
success: 'rb:text-[#369F21] rb:border-[rgba(54,159,33,0.25)] rb:bg-[rgba(54,159,33,0.06)]', success: 'rb:text-[#369F21] rb:border-[rgba(54,159,33,0.25)] rb:bg-[rgba(54,159,33,0.06)]',
warning: 'rb:text-[#FF5D34] rb:border-[rgba(255,93,52,0.30)] rb:bg-[rgba(255,93,52,0.08)]', warning: 'rb:text-[#FF5D34] rb:border-[rgba(255,93,52,0.30)] rb:bg-[rgba(255,93,52,0.08)]',
default: 'rb:text-[#5B6167] rb:border-[rgba(91,97,103,0.30)] rb:bg-[rgba(91,97,103,0.08)]', default: 'rb:text-[#5B6167] rb:border-[rgba(91,97,103,0.30)] rb:bg-[rgba(91,97,103,0.08)]',
purple: 'rb:text-[#9C6FFF] rb:border-[rgba(156,111,255,0.25)] rb:bg-[rgba(156,111,255,0.06)]',
dark: 'rb:text-[#171719] rb:border-[rgba(23,23,25,0.25)] rb:bg-[rgba(23,23,25,0.06)]'
} }
/** Custom tag component with color themes */ /** Custom tag component with color themes */

View File

@@ -353,6 +353,26 @@ body {
background-color: transparent; background-color: transparent;
border: none; border: none;
} }
.cm-editor-filled {
background: #F6F6F6;
border-radius: 8px;
}
.cm-editor-filled .ͼ1 .cm-lineNumbers .cm-gutterElement {
border-radius: 8px 0 0 8px;
}
.cm-editor-filled .ͼ4 .cm-line {
border-radius: 0 8px 8px 0;
}
.cm-editor-filled .ͼ2 .cm-activeLineGutter,
.cm-editor-filled .ͼ2 .cm-activeLine {
background: transparent;
}
.cm-editor-filled .ͼ1 .cm-placeholder {
color: rgba(23, 23, 25, 0.25);
}
.cm-editor-filled .ͼ1 .cm-lineNumbers .cm-gutterElement {
color: #212332;
}
::-webkit-scrollbar { ::-webkit-scrollbar {
width: 6px; width: 6px;
height: 8px; height: 8px;

View File

@@ -253,7 +253,7 @@ const ConfigHeader: FC<ConfigHeaderProps> = ({
: <Flex justify="flex-end"> : <Flex justify="flex-end">
<Flex align="center" gap={8} className="rb:leading-5 rb:text-[14px] rb:text-[#5B6167] rb:font-regular rb:cursor-pointer" onClick={goToApplication}> <Flex align="center" gap={8} className="rb:leading-5 rb:text-[14px] rb:text-[#5B6167] rb:font-regular rb:cursor-pointer" onClick={goToApplication}>
<div <div
className="rb:size-4 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/logout.svg')]" className="rb:size-4 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/logout_grey.svg')]"
></div> ></div>
{t('common.return')} {t('common.return')}
</Flex> </Flex>

View File

@@ -2,27 +2,33 @@
* @Author: ZhaoYing * @Author: ZhaoYing
* @Date: 2026-02-03 16:34:12 * @Date: 2026-02-03 16:34:12
* @Last Modified by: ZhaoYing * @Last Modified by: ZhaoYing
* @Last Modified time: 2026-03-26 14:39:18 * @Last Modified time: 2026-04-16 11:19:20
*/ */
import React, { useState, useEffect, useMemo, type MouseEvent } from 'react'; import React, { useState, useEffect, useMemo, type MouseEvent } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Button, App, Flex, Collapse } from 'antd'; import { App, Flex, Row, Col, Space } from 'antd';
import clsx from 'clsx'; import clsx from 'clsx';
import type { MySharedOutItem } from './types'; import type { MySharedOutItem } from './types';
import { mySharedOutList, cancelShare, cancelSpaceShare } from '@/api/application' import { mySharedOutList, cancelShare, cancelSpaceShare } from '@/api/application'
import BodyWrapper from '@/components/Empty/BodyWrapper' import BodyWrapper from '@/components/Empty/BodyWrapper'
import RbCard from '@/components/RbCard/Card'
import RbDescriptions from '@/components/RbDescriptions'
import Tag from '@/components/Tag'
const MySharing: React.FC = () => { const MySharing: React.FC = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const { modal } = App.useApp(); const { modal } = App.useApp();
const [loading, setLoading] = useState(false)
const [data, setData] = useState<MySharedOutItem[]>([]) const [data, setData] = useState<MySharedOutItem[]>([])
useEffect(() => { getList() }, []) useEffect(() => { getList() }, [])
const getList = () => { const getList = () => {
setLoading(true)
mySharedOutList() mySharedOutList()
.then(res => setData(res as MySharedOutItem[])) .then(res => setData(res as MySharedOutItem[]))
.finally(() => setLoading(false))
} }
/** Group items by target_workspace_id */ /** Group items by target_workspace_id */
@@ -80,89 +86,114 @@ const MySharing: React.FC = () => {
window.open(url); window.open(url);
} }
const [selectedWorkspace, setSelectedWorkspace] = useState<string | null>(null)
const [appList, setAppList] = useState<MySharedOutItem[]>([])
useEffect(() => {
if (grouped.length === 0) {
setSelectedWorkspace(null)
setAppList([])
return
}
const current = grouped.find(g => g.workspace.target_workspace_id === selectedWorkspace)
if (current) {
setAppList(current.items)
} else {
setSelectedWorkspace(grouped[0].workspace.target_workspace_id)
setAppList(grouped[0].items)
}
}, [grouped, selectedWorkspace])
const handleSelectWorkspace = async (target_workspace_id: string) => {
if (target_workspace_id === selectedWorkspace) return
setSelectedWorkspace(target_workspace_id);
const filterWorkspace = grouped.find(item => item.workspace.target_workspace_id === target_workspace_id);
setAppList(filterWorkspace?.items || [])
};
return ( return (
<Flex vertical gap={12} className="rb:max-h-[calc(100%-48px)]! rb:overflow-y-auto!"> <BodyWrapper loading={loading} empty={data.length === 0}>
<BodyWrapper loading={false} empty={data.length === 0}> <Row gutter={12}>
{grouped.map(({ workspace, items }) => ( <Col flex="384px">
<Collapse <Flex vertical gap={12}>
key={workspace.target_workspace_id} {grouped.map(({ workspace, items }) => (
defaultActiveKey={[workspace.target_workspace_id]} <Flex
items={[{ key={workspace.target_workspace_id}
key: workspace.target_workspace_id, gap={8}
label: ( justify="space-between"
align="center"
className={clsx("rb:cursor-pointer rb:bg-white rb:py-3! rb:px-4! rb:rounded-2xl rb:border rb:border-white rb:group", {
'rb:border-[#171719]!': selectedWorkspace === workspace.target_workspace_id
})}
onClick={() => handleSelectWorkspace(workspace.target_workspace_id)}
>
<Flex align="center" gap={12}> <Flex align="center" gap={12}>
{workspace.target_workspace_icon {workspace.target_workspace_icon
? <img src={workspace.target_workspace_icon} alt={workspace.target_workspace_icon} className="rb:w-8 rb:h-8 rb:rounded-lg rb:object-cover" /> ? <img src={workspace.target_workspace_icon} alt={workspace.target_workspace_icon} className="rb:size-8.5 rb:rounded-lg rb:object-cover" />
: <div className="rb:w-8 rb:h-8 rb:rounded-lg rb:bg-[#155eef] rb:flex rb:items-center rb:justify-center rb:text-[14px] rb:text-white"> : <div className="rb:size-8.5 rb:rounded-lg rb:bg-[#155eef] rb:flex rb:items-center rb:justify-center rb:text-[14px] rb:text-white">
{workspace.target_workspace_name[0]} {workspace.target_workspace_name[0]}
</div> </div>
} }
<div> <div>
<span className="rb:font-medium">{workspace.target_workspace_name}</span> <span className="rb:font-medium rb:text-[16px] rb:leading-5.5">{workspace.target_workspace_name}</span>
<div className="rb:text-[#5B6167] rb:text-[12px]">{t('application.appCount', { count: items.length })}</div> <div className="rb:text-[#5B6167] rb:text-[12px] rb:leading-4.5 rb:mt-0.5">{t('application.appCount', { count: items.length })}</div>
</div> </div>
</Flex> </Flex>
), <div
extra: ( className="rb:hidden rb:group-hover:block rb:size-7 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/common/delete.svg')] rb:hover:bg-[url('@/assets/images/common/delete_hover.svg')]"
<Button
size="small"
onClick={e => { e.stopPropagation(); handleAllCancel(workspace); }} onClick={e => { e.stopPropagation(); handleAllCancel(workspace); }}
> ></div>
{t('application.allCancel')} </Flex>
</Button> ))}
), </Flex>
children: ( </Col>
<div className="rb:grid rb:grid-cols-4 rb:gap-3"> <Col flex="1">
{items.map(item => ( <div className="rb:grid rb:grid-cols-2 rb:gap-3">
<div key={item.id} className="rb:bg-[#F6F6F6] rb:rounded-lg rb:py-3! rb:px-4! rb:relative rb:cursor-pointer" onClick={() => handleEdit(item)}> {appList.map(item => (
<div <RbCard
className="rb:absolute rb:top-3 rb:right-3 rb:cursor-pointer rb:size-4 rb:bg-cover rb:bg-[url('@/assets/images/close.svg')]" key={item.source_app_id}
onClick={(e) => handleCancelOne(item, e)} title={item.source_app_name}
/> avatar={<Flex align="center" justify="center" className={clsx("rb:size-12 rb:rounded-lg rb:text-[24px] rb:text-[#ffffff] rb:bg-[#155EEF]", {
<Flex gap={8} align="center"> 'rb:bg-[#155EEF]': item.source_app_type === 'agent',
<div className="rb:size-7 rb:rounded-lg rb:bg-[#155eef] rb:flex rb:items-center rb:justify-center rb:text-[14px] rb:text-white"> 'rb:bg-[#9C6FFF]!': item.source_app_type === 'multi_agent',
{item.source_app_name[0]} 'rb:bg-[#171719]': item.source_app_type === 'workflow',
</div> })}>{item.source_app_name.trim()[0]}</Flex>}
<div className="rb:font-medium">{item.source_app_name}</div> subTitle={<Space size={6}>
</Flex> <Tag color={item.source_app_type === 'agent' ? 'processing' : item.source_app_type === 'multi_agent' ? 'dark' : 'purple'}>{t(`application.${item.source_app_type}`)}</Tag>
<Flex vertical gap={4} className="rb:mt-3! rb:text-[12px]!"> <Tag color={item.source_app_is_active ? 'success' : 'error'}>{item.source_app_is_active ? t('application.sourceActive') : t('application.sourceInactive')}</Tag>
<Flex gap={5} justify="space-between"> </Space>}
<span className="rb:text-[#5B6167]">{t('application.type')}</span> extra={<div
<span className={clsx({ className="rb:-mt-6 rb:cursor-pointer rb:size-5.5 rb:rounded-lg rb:hover:bg-[#F6F6F6] rb:bg-[url('@/assets/images/common/close_grey.svg')] rb:bg-size-[16px_16px] rb:bg-center rb:bg-no-repeat"
'rb:text-[#155EEF] rb:font-medium': item.source_app_type === 'agent', onClick={(e) => handleCancelOne(item, e)}
'rb:text-[#369F21] rb:font-medium': item.source_app_type === 'multi_agent', ></div>}
})}> bodyClassName="rb:py-6! rb:px-4!"
{t(`application.${item.source_app_type}`)} className="rb:cursor-pointer"
</span> onClick={() => handleEdit(item)}
</Flex> >
<Flex gap={5} justify="space-between"> <RbDescriptions
<span className="rb:text-[#5B6167]">{t('application.version')}</span> items={[
<span>{item.source_app_version}</span> {
</Flex> key: 'version',
<Flex gap={5} justify="space-between"> label: t(`application.version`),
<span className="rb:text-[#5B6167]">{t('application.permission')}</span> children: item.source_app_version
<span className={clsx({ },
'rb:text-[#369F21] rb:font-medium': item.permission === 'editable', {
'rb:text-[#5B6167] rb:font-medium': item.permission === 'readonly', key: 'permission',
})}> label: t(`application.permission`),
{t(`application.${item.permission}`)} children: <span className={clsx('rb:font-medium', {
</span> 'rb:text-[#369F21]': item.permission === 'editable',
</Flex> })}>{t(`application.${item.permission}`)}</span>
<Flex gap={5} justify="space-between"> },
<span className="rb:text-[#5B6167]">{t('application.souceStatus')}</span> ]}
<span>{item.source_app_is_active ? t('application.sourceActive') : t('application.sourceInactive')}</span> />
</Flex> </RbCard>
</Flex> ))}
</div> </div>
))} </Col>
</div> </Row>
), </BodyWrapper>
}]} )
/>
))}
</BodyWrapper>
</Flex>
);
}; };
export default MySharing; export default MySharing;

View File

@@ -61,6 +61,7 @@ const TopCardList: FC<{data?: DashboardData}> = ({ data }) => {
<Flex align="center" className={clsx('rb:font-medium rb:mt-7.5!', { <Flex align="center" className={clsx('rb:font-medium rb:mt-7.5!', {
'rb:text-[#FF5D34]': data?.[`${item.key}_change` as keyof DashboardData] && data?.[`${item.key}_change` as keyof DashboardData] < 0, 'rb:text-[#FF5D34]': data?.[`${item.key}_change` as keyof DashboardData] && data?.[`${item.key}_change` as keyof DashboardData] < 0,
'rb:text-[#369F21]': !data?.[`${item.key}_change` as keyof DashboardData] || data?.[`${item.key}_change` as keyof DashboardData] >= 0, 'rb:text-[#369F21]': !data?.[`${item.key}_change` as keyof DashboardData] || data?.[`${item.key}_change` as keyof DashboardData] >= 0,
'rb:text-[#FFFFFF]': item.key === 'total_memory'
})}> })}>
{data?.[`${item.key}_change` as keyof DashboardData] && typeof data?.[item.key as keyof DashboardData] === 'number' {data?.[`${item.key}_change` as keyof DashboardData] && typeof data?.[item.key as keyof DashboardData] === 'number'
? (100 * data?.[`${item.key}_change` as keyof DashboardData]).toFixed(2) ? (100 * data?.[`${item.key}_change` as keyof DashboardData]).toFixed(2)

View File

@@ -1,66 +0,0 @@
/*
* @Author: ZhaoYing
* @Date: 2026-02-03 14:10:24
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-03-06 11:25:59
*/
import { type FC, type ReactNode } from 'react';
import { useNavigate } from 'react-router-dom';
import { Layout, Button } from 'antd';
import { useTranslation } from 'react-i18next';
import logoutIcon from '@/assets/images/logout_hover.svg'
const { Header } = Layout;
/**
* Props for PageHeader component
*/
interface ConfigHeaderProps {
/** Page title/name */
name?: string | ReactNode;
/** Subtitle content displayed below the title */
subTitle?: ReactNode | string;
/** Extra content displayed on the right side */
extra?: ReactNode;
}
/**
* Page header component for ontology pages
* Displays title, subtitle, back button and extra actions
* @param props - Component props
*/
const PageHeader: FC<ConfigHeaderProps> = ({
name,
subTitle,
extra
}) => {
const { t } = useTranslation();
const navigate = useNavigate();
/**
* Navigate back to previous page
*/
const goBack = () => {
navigate(-1)
}
return (
<Header className="rb:w-full rb:h-16 rb:flex rb:justify-between rb:p-[0_16px_0_24px]! rb:border-b rb:border-[#EAECEE] rb:leading-8">
<div className="rb:flex rb:flex-col rb:justify-center rb:gap-1 rb:mr-4 rb:max-w-[calc(100%-300px)]">
<div className="rb:text-[16px] rb:leading-6 rb:font-medium">
{name}
</div>
<div className="rb:text-[12px] rb:text-[#5B6167] rb:leading-4">{subTitle}</div>
</div>
<div className="rb:flex rb:items-center rb:gap-3">
<Button type="primary" ghost className="rb:h-6! rb:px-2! rb:leading-5.5!" onClick={goBack}>
<div className="rb:size-4 rb:bg-cover rb:bg-[url('@/assets/images/logout_hover.svg')]" />
{t('common.return')}
</Button>
{extra}
</div>
</Header>
);
};
export default PageHeader;

View File

@@ -122,7 +122,7 @@ const Detail: FC = () => {
</Space>)} </Space>)}
<Flex align="center" className="rb:leading-5 rb:text-[14px] rb:text-[#5B6167] rb:font-regular rb:cursor-pointer" onClick={() => navigate(-1)}> <Flex align="center" className="rb:leading-5 rb:text-[14px] rb:text-[#5B6167] rb:font-regular rb:cursor-pointer" onClick={() => navigate(-1)}>
<div <div
className="rb:mr-2 rb:size-4 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/logout.svg')]" className="rb:mr-2 rb:size-4 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/logout_grey.svg')]"
></div> ></div>
{t('common.return')} {t('common.return')}
</Flex> </Flex>

View File

@@ -2,7 +2,7 @@
* @Author: ZhaoYing * @Author: ZhaoYing
* @Date: 2026-04-02 15:15:36 * @Date: 2026-04-02 15:15:36
* @Last Modified by: ZhaoYing * @Last Modified by: ZhaoYing
* @Last Modified time: 2026-04-07 14:48:00 * @Last Modified time: 2026-04-16 11:34:41
*/ */
import { type FC, useEffect, useMemo } from 'react'; import { type FC, useEffect, useMemo } from 'react';
import { LexicalComposer } from '@lexical/react/LexicalComposer'; import { LexicalComposer } from '@lexical/react/LexicalComposer';
@@ -81,7 +81,7 @@ export interface Jinja2EditorProps {
value?: string; value?: string;
onChange?: (value: string) => void; onChange?: (value: string) => void;
options?: Suggestion[]; options?: Suggestion[];
variant?: 'outlined' | 'borderless'; variant?: 'outlined' | 'borderless' | 'filled';
height?: number; height?: number;
size?: 'default' | 'small'; size?: 'default' | 'small';
className?: string; className?: string;

View File

@@ -2,7 +2,7 @@
* @Author: ZhaoYing * @Author: ZhaoYing
* @Date: 2025-12-23 16:22:51 * @Date: 2025-12-23 16:22:51
* @Last Modified by: ZhaoYing * @Last Modified by: ZhaoYing
* @Last Modified time: 2026-04-07 16:29:36 * @Last Modified time: 2026-04-16 12:04:37
*/ */
import { type FC, useState, useMemo } from 'react'; import { type FC, useState, useMemo } from 'react';
import { LexicalComposer } from '@lexical/react/LexicalComposer'; import { LexicalComposer } from '@lexical/react/LexicalComposer';
@@ -25,7 +25,7 @@ export interface LexicalEditorProps {
value?: string; value?: string;
onChange?: (value: string) => void; onChange?: (value: string) => void;
options?: Suggestion[]; options?: Suggestion[];
variant?: 'outlined' | 'borderless'; variant?: 'outlined' | 'borderless' | 'filled';
height?: number; height?: number;
fontSize?: number; fontSize?: number;
lineHeight?: number; lineHeight?: number;
@@ -60,6 +60,7 @@ const Editor: FC<LexicalEditorProps> =({
}) => { }) => {
console.log('Editor value', value) console.log('Editor value', value)
const [_count, setCount] = useState(0); const [_count, setCount] = useState(0);
const [focused, setFocused] = useState(false);
if (language === 'jinja2') { if (language === 'jinja2') {
return ( return (
@@ -90,7 +91,7 @@ const Editor: FC<LexicalEditorProps> =({
// Calculate minimum height based on type and size // Calculate minimum height based on type and size
const minheight = useMemo(() => { const minheight = useMemo(() => {
if (type === 'input') { if (type === 'input') {
return `${height ? height : size === 'small' && variant === 'borderless' ? 18 : size === 'small' ? 26 : 30}px` return `${height ? height : size === 'small' && ['borderless', 'filled'].includes(variant) ? 18 : size === 'small' ? 26 : 30}px`
} }
return `${height ? height : size === 'small' ? 60 : 120}px` return `${height ? height : size === 'small' ? 60 : 120}px`
}, [type, size, height, variant]) }, [type, size, height, variant])
@@ -103,7 +104,7 @@ const Editor: FC<LexicalEditorProps> =({
// Calculate line height based on size prop // Calculate line height based on size prop
const lineHeight = useMemo(() => { const lineHeight = useMemo(() => {
return `${height ? height - 10 : size === 'small' && variant === 'borderless' ? 18 : size === 'small' ? 16 : 20}px` return `${height ? height - 10 : size === 'small' && variant === 'borderless' ? 18 : size === 'small' ? 16 : 20}px`
}, [size]) }, [size, height, variant])
// Calculate placeholder minimum height // Calculate placeholder minimum height
const placeHolderMinheight = useMemo(() => { const placeHolderMinheight = useMemo(() => {
@@ -112,20 +113,24 @@ const Editor: FC<LexicalEditorProps> =({
return ( return (
<LexicalComposer initialConfig={initialConfig}> <LexicalComposer initialConfig={initialConfig}>
<div style={{ position: 'relative' }} className={className}> <div style={{ position: 'relative', borderRadius: '8px', background: variant === 'filled' ? '#F6F6F6': 'transparent' }} className={className}>
<RichTextPlugin <RichTextPlugin
contentEditable={ contentEditable={
<ContentEditable <ContentEditable
style={{ style={{
minHeight: minheight, minHeight: minheight,
padding: height ? '4px 6px' : variant === 'borderless' ? '0' : '6px 8px', padding: height ? '4px 6px' : variant === 'outlined' ? '6px 8px': '0',
border: variant === 'borderless' ? 'none' : '1px solid #EBEBEB', border: type === 'input' && focused
? '1px solid #171719'
: variant === 'outlined' ? '1px solid #EBEBEB' : 'none',
borderRadius: '8px', borderRadius: '8px',
outline: 'none', outline: 'none',
resize: 'none', resize: 'none',
fontSize: fontSize, fontSize: fontSize,
lineHeight: lineHeight, lineHeight: lineHeight,
}} }}
onFocus={() => type === 'input' && setFocused(true)}
onBlur={() => type === 'input' && setFocused(false)}
/> />
} }
placeholder={ placeholder={
@@ -133,12 +138,13 @@ const Editor: FC<LexicalEditorProps> =({
style={{ style={{
minHeight: placeHolderMinheight, minHeight: placeHolderMinheight,
position: 'absolute', position: 'absolute',
top: variant === 'borderless' ? '2px' : '6px', top: variant === 'outlined' ? '6px' : type === 'input' ? '6px' : '2px',
left: variant === 'borderless' ? '0' : '11px', left: variant === 'outlined' ? '11px' : type === 'input' ? '8px' : '0',
color: '#A8A9AA', color: 'rgba(23,23,25,0.25)',
fontSize: fontSize, fontSize: fontSize,
lineHeight: placeHolderMinheight, lineHeight: placeHolderMinheight,
pointerEvents: 'none', pointerEvents: 'none',
borderRadius: '8px',
}} }}
> >
{placeholder} {placeholder}

View File

@@ -5,6 +5,7 @@
* @Last Modified time: 2026-04-13 14:00:07 * @Last Modified time: 2026-04-13 14:00:07
*/ */
import { useEffect, useLayoutEffect, useState, useRef, type FC } from 'react'; import { useEffect, useLayoutEffect, useState, useRef, type FC } from 'react';
import { createPortal } from 'react-dom';
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
import { $getSelection, $isRangeSelection, COMMAND_PRIORITY_HIGH, KEY_ENTER_COMMAND, KEY_ARROW_DOWN_COMMAND, KEY_ARROW_UP_COMMAND, KEY_ESCAPE_COMMAND } from 'lexical'; import { $getSelection, $isRangeSelection, COMMAND_PRIORITY_HIGH, KEY_ENTER_COMMAND, KEY_ARROW_DOWN_COMMAND, KEY_ARROW_UP_COMMAND, KEY_ESCAPE_COMMAND } from 'lexical';
import { Space, Flex } from 'antd'; import { Space, Flex } from 'antd';
@@ -35,61 +36,62 @@ const AutocompletePlugin: FC<{ options: Suggestion[] }> = ({ options }) => {
const [selectedIndex, setSelectedIndex] = useState(0); const [selectedIndex, setSelectedIndex] = useState(0);
const [popupPosition, setPopupPosition] = useState({ top: 0, left: 0, anchorBottom: 0 }); const [popupPosition, setPopupPosition] = useState({ top: 0, left: 0, anchorBottom: 0 });
const [expandedParent, setExpandedParent] = useState<Suggestion | null>(null); const [expandedParent, setExpandedParent] = useState<Suggestion | null>(null);
const [childPanelTop, setChildPanelTop] = useState(0); const [childPanelPos, setChildPanelPos] = useState({ top: 0, right: 0 });
const [activePanel, setActivePanel] = useState<'main' | 'child'>('main');
const [childActiveIndex, setChildActiveIndex] = useState(-1);
const popupRef = useRef<HTMLDivElement>(null); const popupRef = useRef<HTMLDivElement>(null);
const itemRefs = useRef<Map<string, HTMLElement>>(new Map()); const itemRefs = useRef<Map<string, HTMLElement>>(new Map());
const childItemRefs = useRef<Map<string, HTMLElement>>(new Map());
// Adjust popup position after render based on actual height // Adjust popup position after render based on actual size
useLayoutEffect(() => { useLayoutEffect(() => {
if (!popupRef.current || !showSuggestions) return; if (!popupRef.current || !showSuggestions) return;
const { top, anchorBottom } = popupPosition; const { top, left, anchorBottom } = popupPosition;
const popupHeight = popupRef.current.offsetHeight; const popupHeight = popupRef.current.offsetHeight;
const popupWidth = popupRef.current.offsetWidth;
const viewportHeight = window.innerHeight; const viewportHeight = window.innerHeight;
const viewportWidth = window.innerWidth;
const MARGIN = 10; const MARGIN = 10;
let finalTop: number; let finalTop: number;
if (top - popupHeight - MARGIN >= 0) { if (top - popupHeight - MARGIN >= 0) {
// Enough space above: show above cursor
finalTop = top - popupHeight - MARGIN; finalTop = top - popupHeight - MARGIN;
} else { } else {
// Not enough space above: show below cursor
finalTop = anchorBottom + MARGIN; finalTop = anchorBottom + MARGIN;
if (finalTop + popupHeight > viewportHeight - MARGIN) { if (finalTop + popupHeight > viewportHeight - MARGIN) {
finalTop = viewportHeight - popupHeight - MARGIN; finalTop = viewportHeight - popupHeight - MARGIN;
} }
} }
if (finalTop !== top) { let finalLeft = left;
setPopupPosition(prev => ({ ...prev, top: finalTop })); if (finalLeft + popupWidth > viewportWidth - MARGIN) {
finalLeft = viewportWidth - popupWidth - MARGIN;
}
if (finalLeft < MARGIN) finalLeft = MARGIN;
if (finalTop !== top || finalLeft !== left) {
setPopupPosition(prev => ({ ...prev, top: finalTop, left: finalLeft }));
} }
}, [showSuggestions, popupPosition.anchorBottom]); }, [showSuggestions, popupPosition.anchorBottom]);
const CHILD_PANEL_HEIGHT = 280; // max-h-60 (240) + header (~40) const CHILD_PANEL_HEIGHT = 280;
const calcChildPanelTop = (elRect: DOMRect, popupRect: DOMRect) => { const calcChildPanelPos = (key: string) => {
const relativeTop = elRect.top - popupRect.top; const el = itemRefs.current.get(key);
const absoluteBottom = popupRect.top + relativeTop + CHILD_PANEL_HEIGHT; if (!el || !popupRef.current) return;
const overflow = absoluteBottom - (window.innerHeight - 10); const elRect = el.getBoundingClientRect();
return overflow > 0 ? relativeTop - overflow : relativeTop; const popupRect = popupRef.current.getBoundingClientRect();
const actualChildHeight = Math.min(CHILD_PANEL_HEIGHT, popupRect.height);
const top = Math.max(10, popupRect.bottom - actualChildHeight);
setChildPanelPos({ top, right: window.innerWidth - elRect.left + 8 });
}; };
const scrollSelectedIntoView = () => { const resetState = () => {
if (!popupRef.current) return; setShowSuggestions(false);
setExpandedParent(null);
const selectedElement = popupRef.current.querySelector('[data-selected="true"]'); setChildPanelPos({ top: 0, right: 0 });
if (!selectedElement) return; setActivePanel('main');
setChildActiveIndex(-1);
const container = popupRef.current;
const element = selectedElement as HTMLElement;
const containerRect = container.getBoundingClientRect();
const elementRect = element.getBoundingClientRect();
if (elementRect.bottom > containerRect.bottom) {
container.scrollTop += elementRect.bottom - containerRect.bottom;
} else if (elementRect.top < containerRect.top) {
container.scrollTop -= containerRect.top - elementRect.top;
}
}; };
// Listen to editor updates and show suggestions when '/' is typed // Listen to editor updates and show suggestions when '/' is typed
@@ -105,11 +107,7 @@ const AutocompletePlugin: FC<{ options: Suggestion[] }> = ({ options }) => {
const anchorNode = selection.anchor.getNode(); const anchorNode = selection.anchor.getNode();
const anchorOffset = selection.anchor.offset; const anchorOffset = selection.anchor.offset;
// Get the text content of the current node
const nodeText = anchorNode.getTextContent(); const nodeText = anchorNode.getTextContent();
// Check if we have a '/' at the current position or after line break
const textBeforeCursor = nodeText.substring(0, anchorOffset); const textBeforeCursor = nodeText.substring(0, anchorOffset);
const shouldShow = textBeforeCursor.endsWith('/') || const shouldShow = textBeforeCursor.endsWith('/') ||
(textBeforeCursor === '/' && anchorOffset === 1); (textBeforeCursor === '/' && anchorOffset === 1);
@@ -118,10 +116,11 @@ const AutocompletePlugin: FC<{ options: Suggestion[] }> = ({ options }) => {
if (!shouldShow) { if (!shouldShow) {
setSelectedIndex(0); setSelectedIndex(0);
setExpandedParent(null); setExpandedParent(null);
setChildPanelTop(0); setChildPanelPos({ top: 0, right: 0 });
setActivePanel('main');
setChildActiveIndex(-1);
} }
// Calculate popup position to keep it within viewport bounds
if (shouldShow) { if (shouldShow) {
const domSelection = window.getSelection(); const domSelection = window.getSelection();
if (domSelection && domSelection.rangeCount > 0) { if (domSelection && domSelection.rangeCount > 0) {
@@ -149,9 +148,7 @@ const AutocompletePlugin: FC<{ options: Suggestion[] }> = ({ options }) => {
return editor.registerCommand( return editor.registerCommand(
CLOSE_AUTOCOMPLETE_COMMAND, CLOSE_AUTOCOMPLETE_COMMAND,
() => { () => {
setShowSuggestions(false); resetState();
setExpandedParent(null);
setChildPanelTop(0);
return true; return true;
}, },
COMMAND_PRIORITY_HIGH COMMAND_PRIORITY_HIGH
@@ -161,9 +158,7 @@ const AutocompletePlugin: FC<{ options: Suggestion[] }> = ({ options }) => {
// Insert selected suggestion into editor // Insert selected suggestion into editor
const insertMention = (suggestion: Suggestion) => { const insertMention = (suggestion: Suggestion) => {
editor.dispatchCommand(INSERT_VARIABLE_COMMAND, { data: suggestion }); editor.dispatchCommand(INSERT_VARIABLE_COMMAND, { data: suggestion });
setShowSuggestions(false); resetState();
setExpandedParent(null);
setChildPanelTop(0);
}; };
// Group suggestions by node ID // Group suggestions by node ID
@@ -177,13 +172,28 @@ const AutocompletePlugin: FC<{ options: Suggestion[] }> = ({ options }) => {
return groups; return groups;
}, {}); }, {});
// Flat list for keyboard navigation // Flat list of main-panel items for keyboard navigation
const flatOptions = Object.values(groupedSuggestions).flat().flatMap(option => { const flatOptions = Object.values(groupedSuggestions).flat();
if (option.key === expandedParent?.key && option.children?.length) {
return [option, ...option.children]; // Sync child panel position when keyboard navigates to a parent with children
useEffect(() => {
if (selectedIndex < 0 || selectedIndex >= flatOptions.length) return;
const s = flatOptions[selectedIndex];
if (s.children?.length) {
calcChildPanelPos(s.key);
setExpandedParent(s);
} else {
setExpandedParent(null);
} }
return [option]; // eslint-disable-next-line react-hooks/exhaustive-deps
}); }, [selectedIndex]);
// Scroll child active item into view
useEffect(() => {
if (!expandedParent?.children?.length || childActiveIndex < 0) return;
const child = expandedParent.children[childActiveIndex];
if (child) childItemRefs.current.get(child.key)?.scrollIntoView({ block: 'nearest' });
}, [childActiveIndex, expandedParent]);
// Handle Enter key to select suggestion // Handle Enter key to select suggestion
useEffect(() => { useEffect(() => {
@@ -192,7 +202,15 @@ const AutocompletePlugin: FC<{ options: Suggestion[] }> = ({ options }) => {
return editor.registerCommand( return editor.registerCommand(
KEY_ENTER_COMMAND, KEY_ENTER_COMMAND,
(event) => { (event) => {
if (showSuggestions && flatOptions.length > 0) { if (!showSuggestions) return false;
if (activePanel === 'child' && expandedParent?.children?.length) {
const child = expandedParent.children[childActiveIndex];
if (child && !child.disabled) {
event?.preventDefault();
insertMention(child);
return true;
}
} else if (flatOptions.length > 0) {
const selectedOption = flatOptions[selectedIndex]; const selectedOption = flatOptions[selectedIndex];
if (selectedOption && !selectedOption.disabled) { if (selectedOption && !selectedOption.disabled) {
event?.preventDefault(); event?.preventDefault();
@@ -204,57 +222,56 @@ const AutocompletePlugin: FC<{ options: Suggestion[] }> = ({ options }) => {
}, },
COMMAND_PRIORITY_HIGH COMMAND_PRIORITY_HIGH
); );
}, [showSuggestions, selectedIndex, flatOptions, insertMention, editor]); }, [showSuggestions, selectedIndex, flatOptions, insertMention, editor, activePanel, childActiveIndex, expandedParent]);
// Handle keyboard navigation (Arrow Up/Down, Escape) // Handle keyboard navigation (Arrow Up/Down/Left/Right, Escape)
useEffect(() => { useEffect(() => {
if (!showSuggestions) return; if (!showSuggestions) return;
// Navigate down through suggestions, skip disabled items
const unregisterArrowDown = editor.registerCommand( const unregisterArrowDown = editor.registerCommand(
KEY_ARROW_DOWN_COMMAND, KEY_ARROW_DOWN_COMMAND,
(event) => { (event) => {
if (showSuggestions && flatOptions.length > 0) { if (!showSuggestions) return false;
event?.preventDefault(); event?.preventDefault();
if (activePanel === 'child' && expandedParent?.children) {
setChildActiveIndex(i => Math.min(i + 1, expandedParent.children!.length - 1));
} else {
setSelectedIndex(prev => { setSelectedIndex(prev => {
let nextIndex = prev + 1; let next = prev + 1;
while (nextIndex < flatOptions.length && flatOptions[nextIndex].disabled) { // skip items that are disabled AND have no children
nextIndex++; while (next < flatOptions.length && flatOptions[next].disabled && !flatOptions[next].children?.length) next++;
} const newIndex = next >= flatOptions.length ? prev : next;
const newIndex = nextIndex >= flatOptions.length ? prev : nextIndex; setTimeout(() => itemRefs.current.get(flatOptions[newIndex]?.key)?.scrollIntoView({ block: 'nearest' }), 0);
setTimeout(() => scrollSelectedIntoView(), 0);
return newIndex; return newIndex;
}); });
return true;
} }
return false; return true;
}, },
COMMAND_PRIORITY_HIGH COMMAND_PRIORITY_HIGH
); );
// Navigate up through suggestions, skip disabled items
const unregisterArrowUp = editor.registerCommand( const unregisterArrowUp = editor.registerCommand(
KEY_ARROW_UP_COMMAND, KEY_ARROW_UP_COMMAND,
(event) => { (event) => {
if (showSuggestions && flatOptions.length > 0) { if (!showSuggestions) return false;
event?.preventDefault(); event?.preventDefault();
if (activePanel === 'child' && expandedParent?.children) {
setChildActiveIndex(i => Math.max(i - 1, 0));
} else {
setSelectedIndex(prev => { setSelectedIndex(prev => {
let prevIndex = prev - 1; let prevIdx = prev - 1;
while (prevIndex >= 0 && flatOptions[prevIndex].disabled) { // skip items that are disabled AND have no children
prevIndex--; while (prevIdx >= 0 && flatOptions[prevIdx].disabled && !flatOptions[prevIdx].children?.length) prevIdx--;
} const newIndex = prevIdx < 0 ? prev : prevIdx;
const newIndex = prevIndex < 0 ? prev : prevIndex; setTimeout(() => itemRefs.current.get(flatOptions[newIndex]?.key)?.scrollIntoView({ block: 'nearest' }), 0);
setTimeout(() => scrollSelectedIntoView(), 0);
return newIndex; return newIndex;
}); });
return true;
} }
return false; return true;
}, },
COMMAND_PRIORITY_HIGH COMMAND_PRIORITY_HIGH
); );
// Close suggestions on Escape key
const unregisterEscape = editor.registerCommand( const unregisterEscape = editor.registerCommand(
KEY_ESCAPE_COMMAND, KEY_ESCAPE_COMMAND,
(event) => { (event) => {
@@ -273,99 +290,122 @@ const AutocompletePlugin: FC<{ options: Suggestion[] }> = ({ options }) => {
unregisterArrowUp(); unregisterArrowUp();
unregisterEscape(); unregisterEscape();
}; };
}, [showSuggestions, selectedIndex, flatOptions, editor]); }, [showSuggestions, selectedIndex, flatOptions, editor, activePanel, childActiveIndex, expandedParent]);
// Handle ArrowLeft/Right for panel switching via native keydown (lexical doesn't expose these commands)
useEffect(() => {
if (!showSuggestions) return;
const handler = (e: KeyboardEvent) => {
if (e.key === 'ArrowLeft') {
const current = flatOptions[selectedIndex];
if (activePanel === 'main' && current?.children?.length) {
e.preventDefault();
setActivePanel('child');
setChildActiveIndex(0);
}
} else if (e.key === 'ArrowRight') {
if (activePanel === 'child') {
e.preventDefault();
setActivePanel('main');
setChildActiveIndex(-1);
}
}
};
document.addEventListener('keydown', handler, true);
return () => document.removeEventListener('keydown', handler, true);
}, [showSuggestions, activePanel, selectedIndex, flatOptions]);
if (!showSuggestions) return null; if (!showSuggestions) return null;
if (Object.entries(groupedSuggestions).length === 0) return null;
if (Object.entries(groupedSuggestions).length === 0) {
return null
}
return ( return (
<div <>
ref={popupRef} <div
data-autocomplete-popup="true" ref={popupRef}
onMouseDown={(e) => e.preventDefault()} data-autocomplete-popup="true"
className="rb:fixed rb:z-1000 rb:bg-white rb:rounded-lg rb:border-[0.5px] rb:border-[#EBEBEB] rb:shadow-[0px_2px_6px_0px_rgba(0,0,0,0.1)] rb:py-3 rb:px-2" onMouseDown={(e) => e.preventDefault()}
style={{ className="rb:fixed rb:z-1000 rb:bg-white rb:rounded-lg rb:border-[0.5px] rb:border-[#EBEBEB] rb:shadow-[0px_2px_6px_0px_rgba(0,0,0,0.1)] rb:py-3 rb:px-2"
top: popupPosition.top, style={{
left: popupPosition.left, top: popupPosition.top,
}} left: popupPosition.left,
> }}
<div className="rb:min-w-70 rb:max-h-57.5 rb:overflow-y-auto"> >
<Flex vertical gap={12}> <div className="rb:min-w-70 rb:max-h-57.5 rb:overflow-y-auto">
{Object.entries(groupedSuggestions).map(([nodeId, nodeOptions]) => { <Flex vertical gap={12}>
const nodeName = nodeOptions[0]?.nodeData?.name || nodeId; {Object.entries(groupedSuggestions).map(([nodeId, nodeOptions]) => {
return ( const nodeName = nodeOptions[0]?.nodeData?.name || nodeId;
<div key={nodeId} className="rb:text-[12px]"> return (
{nodeName !== 'undefined' && <div key={nodeId} className="rb:text-[12px]">
<div className="rb:px-2 rb:leading-4.25 rb:mb-1.25 rb:font-medium rb:text-[#5B6167]"> {nodeName !== 'undefined' &&
{nodeName} <div className="rb:px-2 rb:leading-4.25 rb:mb-1.25 rb:font-medium rb:text-[#5B6167]">
</div> {nodeName}
} </div>
<Flex vertical gap={2}> }
{nodeOptions.map((option) => { <Flex vertical gap={2}>
const globalIndex = flatOptions.indexOf(option); {nodeOptions.map((option) => {
const isExpanded = expandedParent?.key === option.key; const globalIndex = flatOptions.indexOf(option);
const hasChildren = !!option.children?.length; const isExpanded = expandedParent?.key === option.key;
return ( const hasChildren = !!option.children?.length;
<Flex const isActive = activePanel === 'main' && selectedIndex === globalIndex;
key={option.key} return (
ref={(el) => { if (el) itemRefs.current.set(option.key, el); }} <Flex
data-selected={selectedIndex === globalIndex} key={option.key}
className={clsx("rb:px-2! rb:py-0.75! rb:rounded-sm rb:leading-4.5 rb:text-[#5B6167] rb:hover:bg-[#F6F6F6]", { ref={(el) => { if (el) itemRefs.current.set(option.key, el); }}
'rb:bg-[#F6F6F6]': selectedIndex === globalIndex || isExpanded, className={clsx("rb:px-2! rb:py-0.75! rb:rounded-sm rb:leading-4.5 rb:text-[#5B6167] rb:hover:bg-[#F6F6F6]", {
'rb:cursor-not-allowed rb:opacity-65': option.disabled, 'rb:bg-[#F6F6F6]': isActive || isExpanded,
'rb:cursor-pointer': !option.disabled, 'rb:cursor-not-allowed rb:opacity-65': option.disabled,
})} 'rb:cursor-pointer': !option.disabled,
align="center" })}
justify="space-between" align="center"
onClick={() => { justify="space-between"
if (option.disabled) return; onClick={() => {
insertMention(option); if (option.disabled && !hasChildren) return;
}} if (!option.disabled) insertMention(option);
onMouseEnter={() => { if (hasChildren) {
setSelectedIndex(globalIndex); calcChildPanelPos(option.key);
if (hasChildren) { setExpandedParent(option);
const el = itemRefs.current.get(option.key);
if (el && popupRef.current) {
const elRect = el.getBoundingClientRect();
const popupRect = popupRef.current.getBoundingClientRect();
setChildPanelTop(calcChildPanelTop(elRect, popupRect));
} }
setExpandedParent(option); }}
} else { onMouseEnter={() => {
setExpandedParent(null); setSelectedIndex(globalIndex);
setActivePanel('main');
setChildActiveIndex(-1);
if (hasChildren) {
calcChildPanelPos(option.key);
setExpandedParent(option);
} else {
setExpandedParent(null);
}
}}
>
{option.label &&
<div className="rb:font-medium">
<span className="rb:text-[#155EEF]">{`{x}`}</span> {option.label}
</div>
} }
}} <Space size={2}>
> {option.dataType && <span>{option.dataType}</span>}
{option.label && {hasChildren && <div className="rb:size-3 rb:bg-cover rb:bg-[url('@/assets/images/common/arrow_up.svg')] rb:rotate-90"></div>}
<div className="rb:font-medium"> </Space>
<span className="rb:text-[#155EEF]">{`{x}`}</span> {option.label} </Flex>
</div> );
} })}
<Space size={2}> </Flex>
{option.dataType && <span>{option.dataType}</span>} </div>
{hasChildren && <div className="rb:size-3 rb:bg-cover rb:bg-[url('@/assets/images/common/arrow_up.svg')] rb:rotate-90"></div>} );
</Space> })}
</Flex> </Flex>
); </div>
})}
</Flex>
</div>
);
})}
</Flex>
</div> </div>
{/* Child variables panel - floats to the left */}
{expandedParent?.children?.length && ( {/* Child variables panel - fixed positioned via portal to avoid clipping */}
{expandedParent?.children?.length && createPortal(
<div <div
className="rb:absolute rb:min-w-70 rb:max-h-57.5 rb:overflow-y-auto rb:text-[12px] rb:z-1000 rb:bg-white rb:rounded-lg rb:border-[0.5px] rb:border-[#EBEBEB] rb:shadow-[0px_2px_6px_0px_rgba(0,0,0,0.1)] rb:py-3 rb:px-2" onMouseDown={(e) => e.preventDefault()}
style={{ className="rb:min-w-70 rb:max-h-57.5 rb:overflow-y-auto rb:text-[12px] rb:fixed rb:z-1000 rb:bg-white rb:rounded-lg rb:border-[0.5px] rb:border-[#EBEBEB] rb:shadow-[0px_2px_6px_0px_rgba(0,0,0,0.1)] rb:py-3 rb:px-2"
top: childPanelTop, style={{ top: childPanelPos.top, right: childPanelPos.right }}
right: 'calc(100% + 8px)', onMouseEnter={() => setActivePanel('child')}
transform: 'translateY(-8px)', onMouseLeave={() => { setActivePanel('main'); setChildActiveIndex(-1); }}
}}
onMouseEnter={() => setExpandedParent(expandedParent)}
> >
<div className="rb:pb-2 rb:mb-1 rb:font-medium rb:text-[#5B6167] rb-border-b"> <div className="rb:pb-2 rb:mb-1 rb:font-medium rb:text-[#5B6167] rb-border-b">
<Flex justify="space-between" align="center" gap={8}> <Flex justify="space-between" align="center" gap={8}>
@@ -373,21 +413,21 @@ const AutocompletePlugin: FC<{ options: Suggestion[] }> = ({ options }) => {
<span>{expandedParent.dataType}</span> <span>{expandedParent.dataType}</span>
</Flex> </Flex>
</div> </div>
{expandedParent.children.map((child) => { {expandedParent.children.map((child, ci) => {
const childIndex = flatOptions.indexOf(child); const isChildActive = activePanel === 'child' && ci === childActiveIndex;
return ( return (
<Flex <Flex
key={child.key} key={child.key}
data-selected={selectedIndex === childIndex} ref={(el) => { if (el) childItemRefs.current.set(child.key, el); }}
className={clsx("rb:px-2! rb:py-0.75! rb:rounded-sm rb:leading-4.5 rb:text-[#5B6167] rb:hover:bg-[#F6F6F6]", { className={clsx("rb:px-2! rb:py-0.75! rb:rounded-sm rb:leading-4.5 rb:text-[#5B6167] rb:hover:bg-[#F6F6F6]", {
'rb:bg-[#F6F6F6]': selectedIndex === childIndex, 'rb:bg-[#F6F6F6]': isChildActive,
'rb:cursor-not-allowed rb:opacity-65': child.disabled, 'rb:cursor-not-allowed rb:opacity-65': child.disabled,
'rb:cursor-pointer': !child.disabled, 'rb:cursor-pointer': !child.disabled,
})} })}
align="center" align="center"
justify="space-between" justify="space-between"
onClick={() => !child.disabled && insertMention(child)} onClick={() => !child.disabled && insertMention(child)}
onMouseEnter={() => setSelectedIndex(childIndex)} onMouseEnter={() => { setActivePanel('child'); setChildActiveIndex(ci); }}
> >
<span className="rb:font-medium"> <span className="rb:font-medium">
<span className="rb:text-[#155EEF]">{`{x}`}</span> {child.label} <span className="rb:text-[#155EEF]">{`{x}`}</span> {child.label}
@@ -396,9 +436,10 @@ const AutocompletePlugin: FC<{ options: Suggestion[] }> = ({ options }) => {
</Flex> </Flex>
); );
})} })}
</div> </div>,
document.body
)} )}
</div> </>
); );
} }
export default AutocompletePlugin export default AutocompletePlugin

View File

@@ -5,6 +5,7 @@
* @Last Modified time: 2026-04-07 14:50:14 * @Last Modified time: 2026-04-07 14:50:14
*/ */
import { useEffect, useLayoutEffect, useState, useRef, type FC } from 'react'; import { useEffect, useLayoutEffect, useState, useRef, type FC } from 'react';
import { createPortal } from 'react-dom';
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
import { import {
$getSelection, $isRangeSelection, $isTextNode, $getSelection, $isRangeSelection, $isTextNode,
@@ -12,6 +13,7 @@ import {
KEY_ARROW_UP_COMMAND, KEY_ESCAPE_COMMAND, KEY_ARROW_UP_COMMAND, KEY_ESCAPE_COMMAND,
} from 'lexical'; } from 'lexical';
import { Space, Flex } from 'antd'; import { Space, Flex } from 'antd';
import clsx from 'clsx';
import { CLOSE_AUTOCOMPLETE_COMMAND } from '../commands'; import { CLOSE_AUTOCOMPLETE_COMMAND } from '../commands';
import type { Suggestion } from './AutocompletePlugin'; import type { Suggestion } from './AutocompletePlugin';
@@ -22,17 +24,22 @@ const Jinja2AutocompletePlugin: FC<{ options: Suggestion[] }> = ({ options }) =>
const [selectedIndex, setSelectedIndex] = useState(0); const [selectedIndex, setSelectedIndex] = useState(0);
const [popupPosition, setPopupPosition] = useState({ top: 0, left: 0, anchorBottom: 0 }); const [popupPosition, setPopupPosition] = useState({ top: 0, left: 0, anchorBottom: 0 });
const [expandedParent, setExpandedParent] = useState<Suggestion | null>(null); const [expandedParent, setExpandedParent] = useState<Suggestion | null>(null);
const [childPanelTop, setChildPanelTop] = useState(0); const [childPanelPos, setChildPanelPos] = useState({ top: 0, right: 0 });
const [activePanel, setActivePanel] = useState<'main' | 'child'>('main');
const [childActiveIndex, setChildActiveIndex] = useState(-1);
const popupRef = useRef<HTMLDivElement>(null); const popupRef = useRef<HTMLDivElement>(null);
const itemRefs = useRef<Map<string, HTMLElement>>(new Map()); const itemRefs = useRef<Map<string, HTMLElement>>(new Map());
const childItemRefs = useRef<Map<string, HTMLElement>>(new Map());
const CHILD_PANEL_HEIGHT = 280; const CHILD_PANEL_HEIGHT = 280;
useLayoutEffect(() => { useLayoutEffect(() => {
if (!popupRef.current || !showSuggestions) return; if (!popupRef.current || !showSuggestions) return;
const { top, anchorBottom } = popupPosition; const { top, left, anchorBottom } = popupPosition;
const popupHeight = popupRef.current.offsetHeight; const popupHeight = popupRef.current.offsetHeight;
const popupWidth = popupRef.current.offsetWidth;
const MARGIN = 10; const MARGIN = 10;
let finalTop: number; let finalTop: number;
if (top - popupHeight - MARGIN >= 0) { if (top - popupHeight - MARGIN >= 0) {
finalTop = top - popupHeight - MARGIN; finalTop = top - popupHeight - MARGIN;
@@ -41,51 +48,57 @@ const Jinja2AutocompletePlugin: FC<{ options: Suggestion[] }> = ({ options }) =>
if (finalTop + popupHeight > window.innerHeight - MARGIN) if (finalTop + popupHeight > window.innerHeight - MARGIN)
finalTop = window.innerHeight - popupHeight - MARGIN; finalTop = window.innerHeight - popupHeight - MARGIN;
} }
if (finalTop !== top) setPopupPosition(prev => ({ ...prev, top: finalTop }));
let finalLeft = left;
if (finalLeft + popupWidth > window.innerWidth - MARGIN)
finalLeft = window.innerWidth - popupWidth - MARGIN;
if (finalLeft < MARGIN) finalLeft = MARGIN;
if (finalTop !== top || finalLeft !== left)
setPopupPosition(prev => ({ ...prev, top: finalTop, left: finalLeft }));
}, [showSuggestions, popupPosition.anchorBottom]); }, [showSuggestions, popupPosition.anchorBottom]);
const calcChildPanelTop = (elRect: DOMRect, popupRect: DOMRect) => { const calcChildPanelPos = (key: string) => {
const relativeTop = elRect.top - popupRect.top; const el = itemRefs.current.get(key);
const overflow = popupRect.top + relativeTop + CHILD_PANEL_HEIGHT - (window.innerHeight - 10); if (!el || !popupRef.current) return;
return overflow > 0 ? relativeTop - overflow : relativeTop; const elRect = el.getBoundingClientRect();
const popupRect = popupRef.current.getBoundingClientRect();
const actualChildHeight = Math.min(CHILD_PANEL_HEIGHT, popupRect.height);
const top = Math.max(10, popupRect.bottom - actualChildHeight);
setChildPanelPos({ top, right: window.innerWidth - elRect.left + 8 });
}; };
const scrollSelectedIntoView = () => { const resetState = () => {
if (!popupRef.current) return; setShowSuggestions(false);
const selectedElement = popupRef.current.querySelector('[data-selected="true"]'); setExpandedParent(null);
if (!selectedElement) return; setChildPanelPos({ top: 0, right: 0 });
const container = popupRef.current; setActivePanel('main');
const element = selectedElement as HTMLElement; setChildActiveIndex(-1);
const containerRect = container.getBoundingClientRect();
const elementRect = element.getBoundingClientRect();
if (elementRect.bottom > containerRect.bottom) {
container.scrollTop += elementRect.bottom - containerRect.bottom;
} else if (elementRect.top < containerRect.top) {
container.scrollTop -= containerRect.top - elementRect.top;
}
}; };
useEffect(() => { useEffect(() => {
return editor.registerUpdateListener(({ editorState }) => { return editor.registerUpdateListener(({ editorState }) => {
editorState.read(() => { editorState.read(() => {
const selection = $getSelection(); const selection = $getSelection();
if (!selection || !$isRangeSelection(selection)) { if (!selection || !$isRangeSelection(selection)) { setShowSuggestions(false); return; }
setShowSuggestions(false);
return;
}
const anchorNode = selection.anchor.getNode(); const anchorNode = selection.anchor.getNode();
const anchorOffset = selection.anchor.offset; const anchorOffset = selection.anchor.offset;
const textBeforeCursor = anchorNode.getTextContent().substring(0, anchorOffset); const textBeforeCursor = anchorNode.getTextContent().substring(0, anchorOffset);
const shouldShow = textBeforeCursor.endsWith('/'); const shouldShow = textBeforeCursor.endsWith('/');
setShowSuggestions(shouldShow); setShowSuggestions(shouldShow);
if (!shouldShow) { setSelectedIndex(0); setExpandedParent(null); setChildPanelTop(0); return; } if (!shouldShow) {
setSelectedIndex(0);
setExpandedParent(null);
setChildPanelPos({ top: 0, right: 0 });
setActivePanel('main');
setChildActiveIndex(-1);
return;
}
const domSelection = window.getSelection(); const domSelection = window.getSelection();
if (domSelection && domSelection.rangeCount > 0) { if (domSelection && domSelection.rangeCount > 0) {
const rect = domSelection.getRangeAt(0).getBoundingClientRect(); const rect = domSelection.getRangeAt(0).getBoundingClientRect();
const popupWidth = 280;
let left = rect.left; let left = rect.left;
if (left + popupWidth > window.innerWidth) left = window.innerWidth - popupWidth - 10; if (left + 280 > window.innerWidth) left = window.innerWidth - 280 - 10;
if (left < 10) left = 10; if (left < 10) left = 10;
setPopupPosition({ top: rect.top, left, anchorBottom: rect.bottom }); setPopupPosition({ top: rect.top, left, anchorBottom: rect.bottom });
} }
@@ -96,7 +109,7 @@ const Jinja2AutocompletePlugin: FC<{ options: Suggestion[] }> = ({ options }) =>
useEffect(() => { useEffect(() => {
return editor.registerCommand( return editor.registerCommand(
CLOSE_AUTOCOMPLETE_COMMAND, CLOSE_AUTOCOMPLETE_COMMAND,
() => { setShowSuggestions(false); setExpandedParent(null); setChildPanelTop(0); return true; }, () => { resetState(); return true; },
COMMAND_PRIORITY_HIGH, COMMAND_PRIORITY_HIGH,
); );
}, [editor]); }, [editor]);
@@ -119,9 +132,7 @@ const Jinja2AutocompletePlugin: FC<{ options: Suggestion[] }> = ({ options }) =>
} }
}); });
document.dispatchEvent(new CustomEvent('jinja2-variable-inserted', { detail: { value: suggestion.value } })); document.dispatchEvent(new CustomEvent('jinja2-variable-inserted', { detail: { value: suggestion.value } }));
setShowSuggestions(false); resetState();
setExpandedParent(null);
setChildPanelTop(0);
}; };
const groupedSuggestions = options.reduce((groups: Record<string, Suggestion[]>, s) => { const groupedSuggestions = options.reduce((groups: Record<string, Suggestion[]>, s) => {
@@ -131,152 +142,227 @@ const Jinja2AutocompletePlugin: FC<{ options: Suggestion[] }> = ({ options }) =>
return groups; return groups;
}, {}); }, {});
const allOptions = Object.values(groupedSuggestions).flat().flatMap(o => // Flat list of main-panel items for keyboard navigation
o.key === expandedParent?.key && o.children?.length ? [o, ...o.children] : [o] const flatOptions = Object.values(groupedSuggestions).flat();
);
// Sync child panel position when keyboard navigates to a parent with children
useEffect(() => {
if (selectedIndex < 0 || selectedIndex >= flatOptions.length) return;
const s = flatOptions[selectedIndex];
if (s.children?.length) {
calcChildPanelPos(s.key);
setExpandedParent(s);
} else {
setExpandedParent(null);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedIndex]);
// Scroll child active item into view
useEffect(() => {
if (!expandedParent?.children?.length || childActiveIndex < 0) return;
const child = expandedParent.children[childActiveIndex];
if (child) childItemRefs.current.get(child.key)?.scrollIntoView({ block: 'nearest' });
}, [childActiveIndex, expandedParent]);
useEffect(() => { useEffect(() => {
if (!showSuggestions) return; if (!showSuggestions) return;
return editor.registerCommand( return editor.registerCommand(
KEY_ENTER_COMMAND, KEY_ENTER_COMMAND,
(event) => { (event) => {
const opt = allOptions[selectedIndex]; if (!showSuggestions) return false;
if (opt && !opt.disabled) { event?.preventDefault(); insertMention(opt); return true; } if (activePanel === 'child' && expandedParent?.children?.length) {
const child = expandedParent.children[childActiveIndex];
if (child && !child.disabled) { event?.preventDefault(); insertMention(child); return true; }
} else if (flatOptions.length > 0) {
const opt = flatOptions[selectedIndex];
if (opt && !opt.disabled) { event?.preventDefault(); insertMention(opt); return true; }
}
return false; return false;
}, },
COMMAND_PRIORITY_HIGH, COMMAND_PRIORITY_HIGH,
); );
}, [showSuggestions, selectedIndex, allOptions]); }, [showSuggestions, selectedIndex, flatOptions, activePanel, childActiveIndex, expandedParent]);
useEffect(() => { useEffect(() => {
if (!showSuggestions) return; if (!showSuggestions) return;
const down = editor.registerCommand(KEY_ARROW_DOWN_COMMAND, (e) => { const down = editor.registerCommand(KEY_ARROW_DOWN_COMMAND, (e) => {
if (!showSuggestions) return false;
e?.preventDefault(); e?.preventDefault();
setSelectedIndex(prev => { if (activePanel === 'child' && expandedParent?.children) {
let next = prev + 1; setChildActiveIndex(i => Math.min(i + 1, expandedParent.children!.length - 1));
while (next < allOptions.length && allOptions[next].disabled) next++; } else {
setTimeout(scrollSelectedIntoView, 0); setSelectedIndex(prev => {
return next >= allOptions.length ? prev : next; let next = prev + 1;
}); while (next < flatOptions.length && flatOptions[next].disabled && !flatOptions[next].children?.length) next++;
const newIndex = next >= flatOptions.length ? prev : next;
setTimeout(() => itemRefs.current.get(flatOptions[newIndex]?.key)?.scrollIntoView({ block: 'nearest' }), 0);
return newIndex;
});
}
return true; return true;
}, COMMAND_PRIORITY_HIGH); }, COMMAND_PRIORITY_HIGH);
const up = editor.registerCommand(KEY_ARROW_UP_COMMAND, (e) => { const up = editor.registerCommand(KEY_ARROW_UP_COMMAND, (e) => {
if (!showSuggestions) return false;
e?.preventDefault(); e?.preventDefault();
setSelectedIndex(prev => { if (activePanel === 'child' && expandedParent?.children) {
let p = prev - 1; setChildActiveIndex(i => Math.max(i - 1, 0));
while (p >= 0 && allOptions[p].disabled) p--; } else {
setTimeout(scrollSelectedIntoView, 0); setSelectedIndex(prev => {
return p < 0 ? prev : p; let p = prev - 1;
}); while (p >= 0 && flatOptions[p].disabled && !flatOptions[p].children?.length) p--;
const newIndex = p < 0 ? prev : p;
setTimeout(() => itemRefs.current.get(flatOptions[newIndex]?.key)?.scrollIntoView({ block: 'nearest' }), 0);
return newIndex;
});
}
return true; return true;
}, COMMAND_PRIORITY_HIGH); }, COMMAND_PRIORITY_HIGH);
const esc = editor.registerCommand(KEY_ESCAPE_COMMAND, (e) => { const esc = editor.registerCommand(KEY_ESCAPE_COMMAND, (e) => {
e?.preventDefault(); setShowSuggestions(false); return true; e?.preventDefault(); setShowSuggestions(false); return true;
}, COMMAND_PRIORITY_HIGH); }, COMMAND_PRIORITY_HIGH);
return () => { down(); up(); esc(); }; return () => { down(); up(); esc(); };
}, [showSuggestions, selectedIndex, allOptions, editor]); }, [showSuggestions, selectedIndex, flatOptions, editor, activePanel, childActiveIndex, expandedParent]);
// ArrowLeft/Right for panel switching
useEffect(() => {
if (!showSuggestions) return;
const handler = (e: KeyboardEvent) => {
if (e.key === 'ArrowLeft') {
const current = flatOptions[selectedIndex];
if (activePanel === 'main' && current?.children?.length) {
e.preventDefault();
setActivePanel('child');
setChildActiveIndex(0);
}
} else if (e.key === 'ArrowRight') {
if (activePanel === 'child') {
e.preventDefault();
setActivePanel('main');
setChildActiveIndex(-1);
}
}
};
document.addEventListener('keydown', handler, true);
return () => document.removeEventListener('keydown', handler, true);
}, [showSuggestions, activePanel, selectedIndex, flatOptions]);
if (!showSuggestions || Object.keys(groupedSuggestions).length === 0) return null; if (!showSuggestions || Object.keys(groupedSuggestions).length === 0) return null;
return ( return (
<div <>
ref={popupRef} <div
data-autocomplete-popup="true" ref={popupRef}
onMouseDown={(e) => e.preventDefault()} data-autocomplete-popup="true"
className="rb:fixed rb:z-1000 rb:bg-white rb:rounded-xl rb:shadow-[0px_2px_12px_0px_rgba(23,23,25,0.12)]" onMouseDown={(e) => e.preventDefault()}
style={{ top: popupPosition.top, left: popupPosition.left }} className="rb:fixed rb:z-1000 rb:bg-white rb:rounded-lg rb:border-[0.5px] rb:border-[#EBEBEB] rb:shadow-[0px_2px_6px_0px_rgba(0,0,0,0.1)] rb:py-3 rb:px-2"
> style={{ top: popupPosition.top, left: popupPosition.left }}
<div className="rb:py-1 rb:min-w-70 rb:max-h-50 rb:overflow-y-auto"> >
<Flex vertical gap={12}> <div className="rb:min-w-70 rb:max-h-57.5 rb:overflow-y-auto">
{Object.entries(groupedSuggestions).map(([nodeId, nodeOptions]) => ( <Flex vertical gap={12}>
<div key={nodeId}> {Object.entries(groupedSuggestions).map(([nodeId, nodeOptions]) => {
<Flex align="center" gap={4} className="rb:px-3! rb:text-[12px] rb:py-1.25! rb:font-medium rb:text-[#5B6167]"> const nodeName = nodeOptions[0]?.nodeData?.name || nodeId;
{nodeOptions[0]?.nodeData?.icon && <div className={`rb:size-3 rb:bg-cover ${nodeOptions[0].nodeData.icon}`} />}
{nodeOptions[0]?.nodeData?.name || nodeId}
</Flex>
{nodeOptions.map((option) => {
const globalIndex = allOptions.indexOf(option);
const hasChildren = !!option.children?.length;
const isExpanded = expandedParent?.key === option.key;
return ( return (
<Flex <div key={nodeId} className="rb:text-[12px]">
key={option.key} {nodeName !== 'undefined' &&
ref={(el) => { if (el) itemRefs.current.set(option.key, el); }} <div className="rb:px-2 rb:leading-4.25 rb:mb-1.25 rb:font-medium rb:text-[#5B6167]">
data-selected={selectedIndex === globalIndex} {nodeName}
className="rb:pl-6! rb:pr-3! rb:py-2!" </div>
align="center" }
justify="space-between" <Flex vertical gap={2}>
style={{ {nodeOptions.map((option) => {
cursor: option.disabled ? 'not-allowed' : 'pointer', const globalIndex = flatOptions.indexOf(option);
background: (selectedIndex === globalIndex || isExpanded) ? '#f0f8ff' : 'white', const hasChildren = !!option.children?.length;
opacity: option.disabled ? 0.5 : 1, const isExpanded = expandedParent?.key === option.key;
}} const isActive = activePanel === 'main' && selectedIndex === globalIndex;
onClick={() => { if (option.disabled || hasChildren) return; insertMention(option); }} return (
onMouseEnter={() => { <Flex
setSelectedIndex(globalIndex); key={option.key}
if (hasChildren) { ref={(el) => { if (el) itemRefs.current.set(option.key, el); }}
const el = itemRefs.current.get(option.key); className={clsx('rb:px-2! rb:py-0.75! rb:rounded-sm rb:leading-4.5 rb:text-[#5B6167] rb:hover:bg-[#F6F6F6]', {
if (el && popupRef.current) { 'rb:bg-[#F6F6F6]': isActive || isExpanded,
setChildPanelTop(calcChildPanelTop(el.getBoundingClientRect(), popupRef.current.getBoundingClientRect())); 'rb:cursor-not-allowed rb:opacity-65': option.disabled && !hasChildren,
} 'rb:cursor-pointer': !option.disabled || hasChildren,
setExpandedParent(option); })}
} else { align="center"
setExpandedParent(null); justify="space-between"
} onClick={() => {
}} if (option.disabled && !hasChildren) return;
> if (!option.disabled) insertMention(option);
<Space size={4}> if (hasChildren) { calcChildPanelPos(option.key); setExpandedParent(option); }
<span className="rb:text-[#155EEF]">{option.isContext ? '📄' : '{x}'}</span> }}
<span>{option.label}</span> onMouseEnter={() => {
</Space> setSelectedIndex(globalIndex);
<Space size={4}> setActivePanel('main');
{option.dataType && <span className="rb:text-[#5B6167]">{option.dataType}</span>} setChildActiveIndex(-1);
{hasChildren && <span className="rb:text-[#5B6167] rb:ml-1"></span>} if (hasChildren) { calcChildPanelPos(option.key); setExpandedParent(option); }
</Space> else setExpandedParent(null);
</Flex> }}
>
{option.label &&
<div className="rb:font-medium">
<span className="rb:text-[#155EEF]">{`{x}`}</span> {option.label}
</div>
}
<Space size={2}>
{option.dataType && <span>{option.dataType}</span>}
{hasChildren && <div className="rb:size-3 rb:bg-cover rb:bg-[url('@/assets/images/common/arrow_up.svg')] rb:rotate-90"></div>}
</Space>
</Flex>
);
})}
</Flex>
</div>
); );
})} })}
</div> </Flex>
))} </div>
</Flex>
</div> </div>
{expandedParent?.children?.length && (
{expandedParent?.children?.length && createPortal(
<div <div
className="rb:absolute rb:bg-white rb:rounded-xl rb:py-1 rb:min-w-60 rb:max-h-60 rb:overflow-y-auto rb:shadow-[0px_2px_12px_0px_rgba(23,23,25,0.12)]" onMouseDown={(e) => e.preventDefault()}
style={{ top: childPanelTop, right: 'calc(100% + 8px)', transform: 'translateY(-8px)' }} className="rb:min-w-70 rb:max-h-57.5 rb:overflow-y-auto rb:text-[12px] rb:fixed rb:z-1000 rb:bg-white rb:rounded-lg rb:border-[0.5px] rb:border-[#EBEBEB] rb:shadow-[0px_2px_6px_0px_rgba(0,0,0,0.1)] rb:py-3 rb:px-2"
onMouseEnter={() => setExpandedParent(expandedParent)} style={{ top: childPanelPos.top, right: childPanelPos.right }}
onMouseEnter={() => setActivePanel('child')}
onMouseLeave={() => { setActivePanel('main'); setChildActiveIndex(-1); }}
> >
<div className="rb:px-3 rb:py-2 rb:text-[12px] rb:font-medium rb:text-[#5B6167] rb:border-b rb:border-[#F0F0F0]"> <div className="rb:pb-2 rb:mb-1 rb:font-medium rb:text-[#5B6167] rb-border-b">
<Flex justify="space-between" align="center"> <Flex justify="space-between" align="center" gap={8}>
<span>{expandedParent.nodeData.name}.{expandedParent.label}</span> <span>{expandedParent.nodeData.name}.{expandedParent.label}</span>
<span>{expandedParent.dataType}</span> <span>{expandedParent.dataType}</span>
</Flex> </Flex>
</div> </div>
{expandedParent.children.map((child) => { {expandedParent.children.map((child, ci) => {
const childIndex = allOptions.indexOf(child); const isChildActive = activePanel === 'child' && ci === childActiveIndex;
return ( return (
<Flex <Flex
key={child.key} key={child.key}
data-selected={selectedIndex === childIndex} ref={(el) => { if (el) childItemRefs.current.set(child.key, el); }}
className="rb:px-3! rb:py-2!" className={clsx('rb:px-2! rb:py-0.75! rb:rounded-sm rb:leading-4.5 rb:text-[#5B6167] rb:hover:bg-[#F6F6F6]', {
'rb:bg-[#F6F6F6]': isChildActive,
'rb:cursor-not-allowed rb:opacity-65': child.disabled,
'rb:cursor-pointer': !child.disabled,
})}
align="center" align="center"
justify="space-between" justify="space-between"
style={{
cursor: child.disabled ? 'not-allowed' : 'pointer',
background: selectedIndex === childIndex ? '#f0f8ff' : 'white',
opacity: child.disabled ? 0.5 : 1,
}}
onClick={() => !child.disabled && insertMention(child)} onClick={() => !child.disabled && insertMention(child)}
onMouseEnter={() => setSelectedIndex(childIndex)} onMouseEnter={() => { setActivePanel('child'); setChildActiveIndex(ci); }}
> >
<span>{child.label}</span> <span className="rb:font-medium">
{child.dataType && <span className="rb:text-[#5B6167]">{child.dataType}</span>} <span className="rb:text-[#155EEF]">{`{x}`}</span> {child.label}
</span>
{child.dataType && <span>{child.dataType}</span>}
</Flex> </Flex>
); );
})} })}
</div> </div>,
document.body
)} )}
</div> </>
); );
}; };

View File

@@ -2,7 +2,7 @@
* @Author: ZhaoYing * @Author: ZhaoYing
* @Date: 2026-02-09 18:24:53 * @Date: 2026-02-09 18:24:53
* @Last Modified by: ZhaoYing * @Last Modified by: ZhaoYing
* @Last Modified time: 2026-03-25 15:23:45 * @Last Modified time: 2026-04-16 12:06:16
*/ */
import { useMemo, type FC } from 'react' import { useMemo, type FC } from 'react'
import clsx from 'clsx' import clsx from 'clsx'
@@ -343,7 +343,7 @@ const CaseList: FC<CaseListProps> = ({
return ( return (
<Flex key={conditionField.key} gap={4} align="start" className="rb:mb-2!"> <Flex key={conditionField.key} gap={4} align="start" className="rb:mb-2!">
<div className="rb:flex-1 rb:bg-[#F6F6F6] rb:rounded-lg"> <div className="rb:flex-1 rb:bg-[#F6F6F6] rb:rounded-lg">
<Row className={clsx("rb:p-1!", { <Row className={clsx("rb:px-1!", {
'rb-border-b': !hideRightField 'rb-border-b': !hideRightField
})}> })}>
<Col flex="144px"> <Col flex="144px">
@@ -377,7 +377,7 @@ const CaseList: FC<CaseListProps> = ({
</Row> </Row>
{!hideRightField && ( {!hideRightField && (
<div className="rb:py-1 rb:px-1.5"> <div className={['boolean', 'array[boolean]'].includes(leftFieldType as string) ? "rb:py-1 rb:px-1.5" : ''}>
{leftFieldType === 'array[file]' {leftFieldType === 'array[file]'
? <>TODO</> ? <>TODO</>
: leftFieldType === 'number' : leftFieldType === 'number'
@@ -415,7 +415,7 @@ const CaseList: FC<CaseListProps> = ({
<Form.Item name={[conditionField.name, 'right']} noStyle> <Form.Item name={[conditionField.name, 'right']} noStyle>
{['boolean', 'array[boolean]'].includes(leftFieldType as string) {['boolean', 'array[boolean]'].includes(leftFieldType as string)
? <RadioGroupBtn options={[{ value: true, label: 'True' }, { value: false, label: 'False' }]} type="inner" /> ? <RadioGroupBtn options={[{ value: true, label: 'True' }, { value: false, label: 'False' }]} type="inner" />
: <Editor options={options} size="small" type="input" /> : <Editor options={options} size="small" type="input" variant='borderless' height={28} />
} }
</Form.Item> </Form.Item>
) )

View File

@@ -94,7 +94,7 @@ const CodeExecution: FC<CodeExecutionProps> = ({ options }) => {
{ label: 'JAVASCRIPT', value: 'javascript' } { label: 'JAVASCRIPT', value: 'javascript' }
]} ]}
popupMatchSelectWidth={false} popupMatchSelectWidth={false}
className={`rb:font-medium! rb:w-25! rb:h-4! rb:p-0! ${styles.editor}`} className={`rb:font-medium! rb:w-25! rb:h-4! rb:py-0! rb:px-2! ${styles.editor}`}
onChange={handleChangeLanguage} onChange={handleChangeLanguage}
variant="borderless" variant="borderless"
/> />

View File

@@ -178,7 +178,7 @@ const ConditionList: FC<CaseListProps> = ({
className="rb:mb-2!" className="rb:mb-2!"
> >
<div className="rb:flex-1 rb:bg-[#F6F6F6] rb:rounded-lg"> <div className="rb:flex-1 rb:bg-[#F6F6F6] rb:rounded-lg">
<Row className={clsx("rb:p-1!", { <Row className={clsx("rb:px-1!", {
'rb-border-b': !hideRightField 'rb-border-b': !hideRightField
})}> })}>
<Col flex="1"> <Col flex="1">
@@ -218,7 +218,7 @@ const ConditionList: FC<CaseListProps> = ({
</Row> </Row>
{!hideRightField && ( {!hideRightField && (
<div className="rb:py-1 rb:px-1.5"> <div className={leftFieldType === 'boolean' ? "rb:py-1 rb:px-1.5" : ''}>
{leftFieldType === 'number' {leftFieldType === 'number'
? ( ? (
<Flex align="center"> <Flex align="center">

View File

@@ -155,7 +155,7 @@ const CycleVarsList: FC<CycleVarsListProps> = ({
? <CodeMirrorEditor ? <CodeMirrorEditor
language="json" language="json"
placeholder={object_placeholder} placeholder={object_placeholder}
variant="outlined" variant="filled"
size="small" size="small"
/> />
: ( : (

View File

@@ -101,24 +101,20 @@ const FilterConditions: FC<FilterConditionsProps> = ({
align="start" align="start"
className="rb:mb-2!" className="rb:mb-2!"
> >
<div className="rb:flex-1 rb:bg-[#F6F6F6] rb:rounded-lg"> <div className="rb:flex-1">
{variableType === 'array[file]' && {variableType === 'array[file]' &&
<Row className="rb:p-1! rb-border-b"> <Form.Item name={[field.name, 'key']} noStyle>
<Col span={24}> <Select
<Form.Item name={[field.name, 'key']} noStyle> placeholder={t('common.pleaseSelect')}
<Select options={fileSubVariable}
placeholder={t('common.pleaseSelect')} fieldNames={{ value: 'filed', label: 'label' }}
options={fileSubVariable} onChange={(value) => handleKeyFieldChange(index, value)}
fieldNames={{ value: 'filed', label: 'label' }} className="rb:w-full! select rb:mb-1!"
onChange={(value) => handleKeyFieldChange(index, value)} variant="borderless"
variant="borderless" />
className="rb:w-full! rb:h-7!" </Form.Item>
/>
</Form.Item>
</Col>
</Row>
} }
<Row> <Row gutter={8}>
<Col flex={hideValueField ? '1' : "96px"}> <Col flex={hideValueField ? '1' : "96px"}>
<Form.Item name={[field.name, 'comparison_operator']} noStyle> <Form.Item name={[field.name, 'comparison_operator']} noStyle>
<Select <Select
@@ -129,28 +125,27 @@ const FilterConditions: FC<FilterConditionsProps> = ({
size="small" size="small"
popupMatchSelectWidth={false} popupMatchSelectWidth={false}
placeholder={t('common.pleaseSelect')} placeholder={t('common.pleaseSelect')}
className="rb:w-full! select"
variant="borderless" variant="borderless"
className="rb:w-full! rb:h-7!"
/> />
</Form.Item> </Form.Item>
</Col> </Col>
{!hideValueField && ( {!hideValueField && (
<Col flex="1"> <Col flex="1">
<Form.Item name={[field.name, 'value']} className="rb:pt-0.5! rb:mb-0! rb:pl-2!"> <Form.Item name={[field.name, 'value']} noStyle>
{innerType === 'boolean' {innerType === 'boolean'
? <RadioGroupBtn options={[{ value: true, label: 'True' }, { value: false, label: 'False' }]} type="inner" /> ? <RadioGroupBtn options={[{ value: true, label: 'True' }, { value: false, label: 'False' }]} type="inner" />
: keyFieldValue === 'type' : keyFieldValue === 'type'
? <Select ? <Select
placeholder={t('common.pleaseSelect')} placeholder={t('common.pleaseSelect')}
options={typeOptions.map(vo => ({ value: vo, label: t(`application.${vo}`) } ))} options={typeOptions.map(vo => ({ value: vo, label: t(`application.${vo}`) } ))}
variant="borderless" variant="filled"
className="rb:w-full!"
/> />
: <Editor : <Editor
variant="borderless" variant="filled"
type="input" type="input"
size="small" size="small"
height={24} height={28}
options={keyFieldType ? options.flatMap(vo => { options={keyFieldType ? options.flatMap(vo => {
if (vo.dataType === keyFieldType) return [vo]; if (vo.dataType === keyFieldType) return [vo];
const filteredChildren = vo.children?.filter(sub => sub.dataType === keyFieldType); const filteredChildren = vo.children?.filter(sub => sub.dataType === keyFieldType);
@@ -167,7 +162,7 @@ const FilterConditions: FC<FilterConditionsProps> = ({
</Row> </Row>
</div> </div>
<div <div
className="rb:size-4 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/workflow/deleteBg.svg')] rb:hover:bg-[url('@/assets/images/workflow/deleteBg_hover.svg')]" className="rb:size-4 rb:mt-1.5 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/workflow/deleteBg.svg')] rb:hover:bg-[url('@/assets/images/workflow/deleteBg_hover.svg')]"
onClick={() => remove(field.name)} onClick={() => remove(field.name)}
></div> ></div>
</Flex> </Flex>

View File

@@ -58,7 +58,7 @@ const MappingList: FC<MappingListProps> = ({ label, name, options, extra, valueK
placeholder={t('common.pleaseSelect')} placeholder={t('common.pleaseSelect')}
options={options} options={options}
size="small" size="small"
className="rb:w-51!" className="rb:flex-1!"
/> />
</Form.Item> </Form.Item>
<div <div

View File

@@ -2,7 +2,7 @@
* @Author: ZhaoYing * @Author: ZhaoYing
* @Date: 2026-02-03 15:40:13 * @Date: 2026-02-03 15:40:13
* @Last Modified by: ZhaoYing * @Last Modified by: ZhaoYing
* @Last Modified time: 2026-04-13 11:25:40 * @Last Modified time: 2026-04-16 13:57:30
*/ */
import { useState, useRef, useEffect, useLayoutEffect, type FC } from 'react' import { useState, useRef, useEffect, useLayoutEffect, type FC } from 'react'
import { createPortal } from 'react-dom' import { createPortal } from 'react-dom'
@@ -41,14 +41,33 @@ const VariableSelect: FC<VariableSelectProps> = ({
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [expandedParentKey, setExpandedParentKey] = useState<string | null>(null); const [expandedParentKey, setExpandedParentKey] = useState<string | null>(null);
const [activeIndex, setActiveIndex] = useState<number>(-1);
const [activePanel, setActivePanel] = useState<'main' | 'child'>('main');
const [childActiveIndex, setChildActiveIndex] = useState<number>(-1);
const [dropdownPos, setDropdownPos] = useState({ top: 0, left: 0, width: 0 }); const [dropdownPos, setDropdownPos] = useState({ top: 0, left: 0, width: 0 });
const [childPanelPos, setChildPanelPos] = useState({ top: 0, right: 0 }); const [childPanelPos, setChildPanelPos] = useState({ top: 0, right: 0 });
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const dropdownRef = useRef<HTMLDivElement>(null); const dropdownRef = useRef<HTMLDivElement>(null);
const itemRefs = useRef<Map<string, HTMLElement>>(new Map()); const itemRefs = useRef<Map<string, HTMLElement>>(new Map());
const childItemRefs = useRef<Map<string, HTMLElement>>(new Map());
const activeKeyRef = useRef<string | null>(null);
const CHILD_PANEL_HEIGHT = 280; // max-h-60 (240) + header (~40) const CHILD_PANEL_HEIGHT = 280; // max-h-60 (240) + header (~40)
const calcChildPos = (key: string) => {
const el = itemRefs.current.get(key);
if (!el) return;
const rect = el.getBoundingClientRect();
const dropdownEl = dropdownRef.current;
if (!dropdownEl) return;
const dropdownRect = dropdownEl.getBoundingClientRect();
const dropdownBottom = dropdownRect.bottom;
const actualChildHeight = Math.min(CHILD_PANEL_HEIGHT, dropdownRect.height);
// Bottom-align child panel with main panel
const top = Math.max(10, dropdownBottom - actualChildHeight);
setChildPanelPos({ top, right: window.innerWidth - rect.left + 8 });
};
// Calculate dropdown position (runs synchronously after DOM paint to avoid flicker) // Calculate dropdown position (runs synchronously after DOM paint to avoid flicker)
useLayoutEffect(() => { useLayoutEffect(() => {
if (!open || !containerRef.current) return; if (!open || !containerRef.current) return;
@@ -69,7 +88,9 @@ const VariableSelect: FC<VariableSelectProps> = ({
? triggerRect.bottom + MARGIN ? triggerRect.bottom + MARGIN
: Math.max(MARGIN, triggerRect.top - dropdownHeight - MARGIN); : Math.max(MARGIN, triggerRect.top - dropdownHeight - MARGIN);
setDropdownPos({ top, left, width }); setDropdownPos({ top, left, width });
}, [open, search, Array.isArray(value) ? value.length : 0]); // Re-calculate child panel position if expanded
if (expandedParentKey) calcChildPos(expandedParentKey);
}, [open, search, Array.isArray(value) ? value.length : 0, options.length, expandedParentKey]);
const filteredOptions = filterBooleanType const filteredOptions = filterBooleanType
? options.filter(o => o.dataType !== 'boolean') ? options.filter(o => o.dataType !== 'boolean')
@@ -107,6 +128,12 @@ const VariableSelect: FC<VariableSelectProps> = ({
}, {}) }, {})
: groupedSuggestions; : groupedSuggestions;
useEffect(() => {
if (!expandedParentKey) return;
calcChildPos(expandedParentKey);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dropdownPos, expandedParentKey]);
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
const updatePos = () => { const updatePos = () => {
@@ -151,6 +178,87 @@ const VariableSelect: FC<VariableSelectProps> = ({
return () => document.removeEventListener('mousedown', handler); return () => document.removeEventListener('mousedown', handler);
}, [open]); }, [open]);
// Flat list of all visible selectable items (main panel only, no children expanded inline)
const flatItems = Object.values(filteredGroups).flat();
useEffect(() => {
setActiveIndex(-1);
setActivePanel('main');
setChildActiveIndex(-1);
}, [open, search]);
useEffect(() => {
if (activeIndex < 0 || activeIndex >= flatItems.length) {
setExpandedParentKey(null);
return;
}
const s = flatItems[activeIndex];
activeKeyRef.current = s.key;
itemRefs.current.get(s.key)?.scrollIntoView({ block: 'nearest' });
if (s.children?.length) {
calcChildPos(s.key);
setExpandedParentKey(s.key);
} else {
setExpandedParentKey(null);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeIndex]);
useEffect(() => {
if (!expandedParent?.children?.length || childActiveIndex < 0) return;
const child = expandedParent.children[childActiveIndex];
if (child) childItemRefs.current.get(child.key)?.scrollIntoView({ block: 'nearest' });
}, [childActiveIndex, expandedParent]);
useEffect(() => {
if (!open) return;
const handler = (e: KeyboardEvent) => {
const children = expandedParent?.children ?? [];
if (activePanel === 'child') {
if (e.key === 'ArrowDown') {
e.preventDefault();
setChildActiveIndex(i => Math.min(i + 1, children.length - 1));
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setChildActiveIndex(i => Math.max(i - 1, 0));
} else if (e.key === 'ArrowRight') {
e.preventDefault();
setActivePanel('main');
setChildActiveIndex(-1);
} else if (e.key === 'Enter' && childActiveIndex >= 0 && childActiveIndex < children.length) {
e.preventDefault();
const child = children[childActiveIndex];
if (!child.disabled) handleSelect(child);
} else if (e.key === 'Escape') {
setOpen(false);
}
} else {
if (e.key === 'ArrowDown') {
e.preventDefault();
setActiveIndex(i => Math.min(i + 1, flatItems.length - 1));
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setActiveIndex(i => Math.max(i - 1, 0));
} else if (e.key === 'ArrowLeft') {
e.preventDefault();
if (expandedParent?.children?.length) {
setActivePanel('child');
setChildActiveIndex(0);
}
} else if (e.key === 'Enter' && activeIndex >= 0 && activeIndex < flatItems.length) {
e.preventDefault();
const s = flatItems[activeIndex];
if (!s.disabled) handleSelect(s);
} else if (e.key === 'Escape') {
setOpen(false);
}
}
};
document.addEventListener('keydown', handler);
return () => document.removeEventListener('keydown', handler);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, activeIndex, activePanel, childActiveIndex, flatItems, expandedParent]);
const handleSelect = (suggestion: Suggestion) => { const handleSelect = (suggestion: Suggestion) => {
if (multiple) { if (multiple) {
const key = `{{${suggestion.value}}}`; const key = `{{${suggestion.value}}}`;
@@ -171,19 +279,6 @@ const VariableSelect: FC<VariableSelectProps> = ({
e.stopPropagation(); e.stopPropagation();
onChange?.(multiple ? [] : '', multiple ? [] : undefined); onChange?.(multiple ? [] : '', multiple ? [] : undefined);
}; };
const updateChildPos = (key: string) => {
const el = itemRefs.current.get(key);
if (el) {
const rect = el.getBoundingClientRect();
const spaceBelow = window.innerHeight - rect.top - 10;
const top = spaceBelow >= CHILD_PANEL_HEIGHT
? rect.top
: Math.max(10, window.innerHeight - CHILD_PANEL_HEIGHT - 10);
setChildPanelPos({ top, right: window.innerWidth - rect.left + 8 });
}
};
const sep = <span className="rb:text-[#DFE4ED] rb:mx-0.5">/</span>; const sep = <span className="rb:text-[#DFE4ED] rb:mx-0.5">/</span>;
const isConversation = (parentOfSelected ?? selectedSuggestion)?.group === 'CONVERSATION' || const isConversation = (parentOfSelected ?? selectedSuggestion)?.group === 'CONVERSATION' ||
(selectedSuggestion ? filteredOptions.some(o => o.group === 'CONVERSATION' && o.children?.some(c => `{{${c.value}}}` === value)) : false); (selectedSuggestion ? filteredOptions.some(o => o.group === 'CONVERSATION' && o.children?.some(c => `{{${c.value}}}` === value)) : false);
@@ -197,7 +292,7 @@ const VariableSelect: FC<VariableSelectProps> = ({
'rb:w-full rb:flex rb:items-center rb:justify-between rb:cursor-pointer rb:rounded-lg rb:px-2 rb:transition-colors', { 'rb:w-full rb:flex rb:items-center rb:justify-between rb:cursor-pointer rb:rounded-lg rb:px-2 rb:transition-colors', {
'rb:bg-[#F6F6F6] rb:border-none rb:shadow-none': variant === 'filled', 'rb:bg-[#F6F6F6] rb:border-none rb:shadow-none': variant === 'filled',
'rb:border rb:border-[#d9d9d9] hover:rb:border-[#4096ff] rb:bg-white': variant === 'outlined', 'rb:border rb:border-[#d9d9d9] hover:rb:border-[#4096ff] rb:bg-white': variant === 'outlined',
'rb:border-[#4096ff] rb:shadow-[0_0_0_2px_rgba(5,145,255,0.1)]': variant === 'outlined' && open, 'rb:border-[#171719]!': variant === 'outlined' && open,
'rb:border-none rb:shadow-none rb:bg-transparent': variant === 'borderless', 'rb:border-none rb:shadow-none rb:bg-transparent': variant === 'borderless',
'rb:text-[12px]': size === 'small', 'rb:text-[12px]': size === 'small',
'rb:text-[14px]': size !== 'small', 'rb:text-[14px]': size !== 'small',
@@ -244,7 +339,7 @@ const VariableSelect: FC<VariableSelectProps> = ({
})} })}
</Flex> </Flex>
) : ( ) : (
<span className="rb:text-ellipsis rb:overflow-hidden rb:whitespace-nowrap rb:flex-1">{placeholder}</span> <span className="rb:text-[rgba(23,23,25,0.25)] rb:text-ellipsis rb:overflow-hidden rb:whitespace-nowrap rb:flex-1">{placeholder}</span>
) )
) : selectedSuggestion ? ( ) : selectedSuggestion ? (
<div className="rb:flex rb:flex-1 rb:min-w-0 rb:max-w-full"> <div className="rb:flex rb:flex-1 rb:min-w-0 rb:max-w-full">
@@ -260,7 +355,7 @@ const VariableSelect: FC<VariableSelectProps> = ({
</span> </span>
</div> </div>
) : ( ) : (
<span className="rb:text-[#bfbfbf] rb:flex-1">{placeholder}</span> <span className="rb:text-[rgba(23,23,25,0.25)] rb:flex-1">{placeholder}</span>
)} )}
<Space size={4} className="rb:shrink-0 rb:ml-1"> <Space size={4} className="rb:shrink-0 rb:ml-1">
{allowClear && ( {allowClear && (
@@ -306,7 +401,7 @@ const VariableSelect: FC<VariableSelectProps> = ({
key={s.key} key={s.key}
ref={(el) => { if (el) itemRefs.current.set(s.key, el); }} ref={(el) => { if (el) itemRefs.current.set(s.key, el); }}
className={clsx("rb:px-2! rb:py-0.75! rb:rounded-sm rb:leading-4.5 rb:text-[#5B6167] rb:hover:bg-[#F6F6F6]", { className={clsx("rb:px-2! rb:py-0.75! rb:rounded-sm rb:leading-4.5 rb:text-[#5B6167] rb:hover:bg-[#F6F6F6]", {
'rb:bg-[#F6F6F6]': isSelected || isExpanded, 'rb:bg-[#F6F6F6]': isSelected || isExpanded || flatItems.indexOf(s) === activeIndex,
'rb:cursor-not-allowed rb:opacity-65': s.disabled, 'rb:cursor-not-allowed rb:opacity-65': s.disabled,
'rb:cursor-pointer': !s.disabled, 'rb:cursor-pointer': !s.disabled,
})} })}
@@ -315,14 +410,14 @@ const VariableSelect: FC<VariableSelectProps> = ({
onClick={() => { onClick={() => {
if (s.disabled) return; if (s.disabled) return;
if (hasChildren) { if (hasChildren) {
updateChildPos(s.key); calcChildPos(s.key);
setExpandedParentKey(prev => prev === s.key ? null : s.key); setExpandedParentKey(prev => prev === s.key ? null : s.key);
} }
handleSelect(s); handleSelect(s);
}} }}
onMouseEnter={() => { onMouseEnter={() => {
if (hasChildren) { if (hasChildren) {
updateChildPos(s.key); calcChildPos(s.key);
setExpandedParentKey(s.key); setExpandedParentKey(s.key);
} else { } else {
setExpandedParentKey(null); setExpandedParentKey(null);
@@ -370,15 +465,17 @@ const VariableSelect: FC<VariableSelectProps> = ({
<span>{expandedParent.dataType}</span> <span>{expandedParent.dataType}</span>
</Flex> </Flex>
</div> </div>
{expandedParent.children.map(child => { {expandedParent.children.map((child, ci) => {
const isSelected = multiple const isSelected = multiple
? selectedValues.includes(`{{${child.value}}}`) ? selectedValues.includes(`{{${child.value}}}`)
: `{{${child.value}}}` === value; : `{{${child.value}}}` === value;
const isChildActive = activePanel === 'child' && ci === childActiveIndex;
return ( return (
<Flex <Flex
key={child.key} key={child.key}
ref={(el) => { if (el) childItemRefs.current.set(child.key, el); }}
className={clsx("rb:px-2! rb:py-0.75! rb:rounded-sm rb:leading-4.5 rb:text-[#5B6167] rb:hover:bg-[#F6F6F6]", { className={clsx("rb:px-2! rb:py-0.75! rb:rounded-sm rb:leading-4.5 rb:text-[#5B6167] rb:hover:bg-[#F6F6F6]", {
'rb:bg-[#F6F6F6]': isSelected, 'rb:bg-[#F6F6F6]': isSelected || isChildActive,
'rb:cursor-not-allowed rb:opacity-65': child.disabled, 'rb:cursor-not-allowed rb:opacity-65': child.disabled,
'rb:cursor-pointer': !child.disabled, 'rb:cursor-pointer': !child.disabled,
})} })}

View File

@@ -23,6 +23,11 @@
} }
.properties :global(.select.ant-select-single.ant-select-sm.ant-select-borderless) { .properties :global(.select.ant-select-single.ant-select-sm.ant-select-borderless) {
height: 28px; height: 28px;
border: 1px solid #F6F6F6;
border-radius: 8px;
}
.properties :global(.select.ant-select-single.ant-select-sm.ant-select-borderless.ant-select-focused) {
border: 1px solid #171719;
} }
.properties :global(.ant-table-wrapper .ant-table-thead>tr>th), .properties :global(.ant-table-wrapper .ant-table-thead>tr>th),
.properties :global(.ant-table-wrapper .ant-table-thead>tr>td), .properties :global(.ant-table-wrapper .ant-table-thead>tr>td),