🎨 style(data-grid/query-editor): 统一工具栏图标交互

- 将 V2 数据查看、查询结果和 SQL 编辑器操作改为纯图标按钮
- 统一工具栏操作尺寸,并保留本地化 Tooltip 与无障碍标签
- 使用状态图标精简 SQL 提交模式和提交时机选择器
- 修复事务下拉选项截断及悬浮提示与菜单重叠问题
- 补充图标布局、下拉交互和旧版兼容测试
This commit is contained in:
Syngnat
2026-07-23 16:17:56 +08:00
parent 84d6007be9
commit 3c24068190
13 changed files with 743 additions and 191 deletions

View File

@@ -218,6 +218,7 @@ vi.mock('@ant-design/icons', () => {
CloseOutlined: Icon,
BugOutlined: Icon,
ConsoleSqlOutlined: Icon,
ControlOutlined: Icon,
FileTextOutlined: Icon,
CopyOutlined: Icon,
ClearOutlined: Icon,
@@ -508,7 +509,13 @@ const textContent = (node: any): string =>
.join('');
const findButton = (renderer: ReactTestRenderer, text: string) =>
renderer.root.findAll((node) => node.type === 'button' && textContent(node).includes(text))[0];
renderer.root.findAll((node) => (
node.type === 'button'
&& (
textContent(node).includes(text)
|| String(node.props['aria-label'] || '').includes(text)
)
))[0];
const renderHeaderText = (columnKey: string): string => {
const column = testRenderState.latestColumns.find((item) => item.key === columnKey);

View File

@@ -2135,6 +2135,7 @@ describe('DataGrid layout', () => {
expect(markup).toContain('gn-v2-toolbar-divider');
expect(markup).toContain('gn-v2-commit-button');
expect(markup).toContain('gn-v2-ai-insight-button');
expect(markup).toContain('gn-v2-data-grid-toolbar-action');
expect(markup).toContain('gn-v2-smart-filter-panel');
expect(markup).toContain('gn-v2-data-grid-table-shell');
expect(markup).toContain('gn-v2-data-grid-table-wrap');
@@ -2142,6 +2143,39 @@ describe('DataGrid layout', () => {
expect(markup).toContain('提交事务');
expect(markup).toContain('手动提交');
expect(markup).toContain('AI 洞察');
const getButtonBody = (label: string) => {
const escapedLabel = label.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const match = markup.match(new RegExp(`<button(?=[^>]*aria-label="${escapedLabel}")[^>]*>([\\s\\S]*?)<\\/button>`));
expect(match, `missing toolbar button: ${label}`).not.toBeNull();
return match?.[1] ?? '';
};
[
'刷新',
'筛选',
'新增行',
'删除选中',
'单元格编辑',
'提交事务',
'手动提交',
'导入',
'导出',
'AI 洞察',
].forEach((label) => {
expect(getButtonBody(label)).not.toContain(label);
});
expect(markup).toContain('aria-haspopup="menu"');
expect(markup).toContain('aria-expanded="false"');
const css = readV2ThemeCss();
const iconActionCss = css.slice(
css.indexOf('body[data-ui-version="v2"] .gn-v2-data-grid .gn-v2-data-grid-toolbar-action {'),
css.indexOf('body[data-ui-version="v2"] .gn-v2-data-grid-toolbar-title {'),
);
expect(iconActionCss).toContain('width: 28px !important;');
expect(iconActionCss).toContain('min-width: 28px !important;');
expect(iconActionCss).toContain('padding-inline: 0 !important;');
});
it('renders a non-data row number column when enabled', () => {
@@ -2470,6 +2504,8 @@ describe('DataGrid layout', () => {
expect(markup).toContain('data-grid-query-copy-action="true"');
expect(markup).not.toMatch(/data-grid-query-copy-action="true"[^>]*disabled/);
expect(markup).toContain('复制');
expect(markup).toContain('aria-haspopup="menu"');
expect(markup).toContain('aria-expanded="false"');
expect(markup.match(/data-grid-query-copy-action="true"/g)?.length).toBe(1);
});

View File

@@ -1,8 +1,9 @@
import React from 'react';
import { AutoComplete, Button, Checkbox, Dropdown, Input, Select, Tooltip } from 'antd';
import type { MenuProps } from 'antd';
import type { ButtonProps, MenuProps } from 'antd';
import {
ClearOutlined,
ControlOutlined,
CloseOutlined,
ConsoleSqlOutlined,
CopyOutlined,
@@ -37,6 +38,8 @@ type GridSortInfo = {
enabled?: boolean;
};
type ToolbarMenuKey = 'commit-mode' | 'query-copy';
export interface DataGridToolbarFrameProps {
isV2Ui: boolean;
tableName?: string;
@@ -244,6 +247,10 @@ const DataGridToolbarFrame: React.FC<DataGridToolbarFrameProps> = ({
(key: string, params?: Record<string, string | number>) => translateProp?.(key, params) ?? key,
[translateProp],
);
const [openToolbarMenu, setOpenToolbarMenu] = React.useState<ToolbarMenuKey | null>(null);
const updateToolbarMenuOpen = (menuKey: ToolbarMenuKey, open: boolean) => {
setOpenToolbarMenu((current) => (open ? menuKey : current === menuKey ? null : current));
};
const renderToolbarDivider = () => (
<div
className={isV2Ui ? 'gn-v2-toolbar-divider' : undefined}
@@ -256,6 +263,34 @@ const DataGridToolbarFrame: React.FC<DataGridToolbarFrameProps> = ({
? translate('data_grid.filter.mongodb_query_placeholder')
: translate('data_grid.filter.quick_where_placeholder');
const toolbarTitle = tableName || translate('data_grid.table_fallback.query_result');
const aiInsightTooltip = aiShortcutLabel !== '-'
? `${translate('data_grid.toolbar.ai_insight_tooltip')} · ${aiShortcutLabel}`
: translate('data_grid.toolbar.ai_insight_tooltip');
const commitModeLabel = translate(`data_grid.toolbar.commit_mode.${dataEditCommitMode}`);
const renderToolbarAction = ({
label,
tooltip = label,
legacyContent = label,
className,
...buttonProps
}: Omit<ButtonProps, 'aria-label' | 'children'> & {
label: string;
tooltip?: React.ReactNode;
legacyContent?: React.ReactNode;
}) => {
const resolvedClassName = [
isV2Ui ? 'gn-v2-data-grid-toolbar-action' : undefined,
className,
].filter(Boolean).join(' ') || undefined;
return (
<Tooltip title={isV2Ui ? tooltip : undefined}>
<Button {...buttonProps} className={resolvedClassName} aria-label={label}>
{isV2Ui ? null : legacyContent}
</Button>
</Tooltip>
);
};
return (
<div
@@ -300,99 +335,184 @@ const DataGridToolbarFrame: React.FC<DataGridToolbarFrameProps> = ({
</>
)}
{onReload && (
<Button icon={<ReloadOutlined />} disabled={loading} onClick={onRefresh}>
{translate('data_grid.toolbar.refresh')}
</Button>
renderToolbarAction({
label: translate('data_grid.toolbar.refresh'),
icon: <ReloadOutlined />,
disabled: loading,
onClick: onRefresh,
})
)}
{onToggleFilter && (
<>
{renderToolbarDivider()}
<Button icon={<FilterOutlined />} type={showFilter ? 'primary' : 'default'} onClick={onToggleFilterClick}>
{translate('data_grid.toolbar.filter')}
</Button>
{renderToolbarAction({
label: translate('data_grid.toolbar.filter'),
icon: <FilterOutlined />,
type: showFilter ? 'primary' : 'default',
onClick: onToggleFilterClick,
})}
</>
)}
{canModifyData && (
<>
{renderToolbarDivider()}
<Button icon={<PlusOutlined />} onClick={onAddRow}>{translate('data_grid.toolbar.add_row')}</Button>
{renderToolbarAction({
label: translate('data_grid.toolbar.add_row'),
icon: <PlusOutlined />,
onClick: onAddRow,
})}
{allSelectedAreDeleted ? (
<Button icon={<UndoOutlined />} disabled={deleteTargetRowCount === 0} onClick={onUndoDeleteSelected}>{translate('data_grid.toolbar.undo_delete')}</Button>
renderToolbarAction({
label: translate('data_grid.toolbar.undo_delete'),
tooltip: deleteTargetRowCount > 0
? `${translate('data_grid.toolbar.undo_delete')} · ${translate('data_grid.toolbar.selected_count', { count: deleteTargetRowCount })}`
: translate('data_grid.toolbar.undo_delete'),
icon: <UndoOutlined />,
disabled: deleteTargetRowCount === 0,
onClick: onUndoDeleteSelected,
})
) : (
<Button icon={<DeleteOutlined />} danger disabled={deleteTargetRowCount === 0} onClick={onDeleteSelected}>{translate('data_grid.toolbar.delete_selected')}</Button>
renderToolbarAction({
label: translate('data_grid.toolbar.delete_selected'),
tooltip: deleteTargetRowCount > 0
? `${translate('data_grid.toolbar.delete_selected')} · ${translate('data_grid.toolbar.selected_count', { count: deleteTargetRowCount })}`
: translate('data_grid.toolbar.delete_selected'),
icon: <DeleteOutlined />,
danger: true,
disabled: deleteTargetRowCount === 0,
onClick: onDeleteSelected,
})
)}
{deleteTargetRowCount > 0 && <span style={{ fontSize: '12px', color: '#888' }}>{translate('data_grid.toolbar.selected_count', { count: deleteTargetRowCount })}</span>}
{!isV2Ui && deleteTargetRowCount > 0 && <span style={{ fontSize: '12px', color: '#888' }}>{translate('data_grid.toolbar.selected_count', { count: deleteTargetRowCount })}</span>}
{renderToolbarDivider()}
<Button
data-grid-cell-editor-action="true"
icon={<EditOutlined />}
type={cellEditMode ? 'primary' : 'default'}
onClick={onToggleCellEditMode}
>
{translate('data_grid.toolbar.cell_editor')}
</Button>
<Tooltip title={isV2Ui ? translate('data_grid.toolbar.cell_editor') : undefined}>
<Button
data-grid-cell-editor-action="true"
className={isV2Ui ? 'gn-v2-data-grid-toolbar-action' : undefined}
aria-label={translate('data_grid.toolbar.cell_editor')}
icon={<EditOutlined />}
type={cellEditMode ? 'primary' : 'default'}
onClick={onToggleCellEditMode}
>
{isV2Ui ? null : translate('data_grid.toolbar.cell_editor')}
</Button>
</Tooltip>
{cellEditMode && selectedCellsSize > 0 && (
<>
<Button icon={<CopyOutlined />} onClick={onCopySelectedCellsToClipboard}>
{translate('data_grid.toolbar.copy_selection', { count: selectedCellsSize })}
</Button>
<Button icon={<CopyOutlined />} onClick={onCopySelectedColumnsFromRow}>
{translate('data_grid.toolbar.copy_selection_columns', { count: selectedCellsSize })}
</Button>
<Button type="primary" onClick={onOpenBatchEditModal}>
{translate('data_grid.toolbar.batch_fill', { count: selectedCellsSize })}
</Button>
{renderToolbarAction({
label: translate('data_grid.toolbar.copy_selection', { count: selectedCellsSize }),
icon: <CopyOutlined />,
onClick: onCopySelectedCellsToClipboard,
})}
{renderToolbarAction({
label: translate('data_grid.toolbar.copy_selection_columns', { count: selectedCellsSize }),
icon: <CopyOutlined />,
onClick: onCopySelectedColumnsFromRow,
})}
{renderToolbarAction({
label: translate('data_grid.toolbar.batch_fill', { count: selectedCellsSize }),
icon: <EditOutlined />,
type: 'primary',
onClick: onOpenBatchEditModal,
})}
</>
)}
{cellEditMode && copiedCellPatchColumnCount > 0 && (
<>
<Button
icon={<VerticalAlignBottomOutlined />}
disabled={selectedRowKeysLength === 0}
onClick={onPasteCopiedColumnsToSelectedRows}
>
{translate('data_grid.toolbar.paste_to_selected_rows', { count: selectedRowKeysLength })}
</Button>
<span style={{ fontSize: '12px', color: '#888' }}>
{renderToolbarAction({
label: translate('data_grid.toolbar.paste_to_selected_rows', { count: selectedRowKeysLength }),
tooltip: `${translate('data_grid.toolbar.paste_to_selected_rows', { count: selectedRowKeysLength })} · ${translate('data_grid.toolbar.copied_columns_count', { count: copiedCellPatchColumnCount })}`,
icon: <VerticalAlignBottomOutlined />,
disabled: selectedRowKeysLength === 0,
onClick: onPasteCopiedColumnsToSelectedRows,
})}
{!isV2Ui && <span style={{ fontSize: '12px', color: '#888' }}>
{translate('data_grid.toolbar.copied_columns_count', { count: copiedCellPatchColumnCount })}
</span>
</span>}
</>
)}
{renderToolbarDivider()}
<Button
className={isV2Ui ? 'gn-v2-commit-button' : undefined}
icon={<SaveOutlined />}
type="primary"
disabled={!hasChanges}
onClick={onCommit}
>
{isV2Ui ? (
<>
<span>{translate('data_grid.toolbar.commit_label')}</span>
<span className="gn-v2-toolbar-kbd">{pendingChangeCount}</span>
</>
) : translate('data_grid.toolbar.commit', { count: pendingChangeCount })}
</Button>
{hasChanges && (
{renderToolbarAction({
label: translate('data_grid.toolbar.commit_label'),
tooltip: translate('data_grid.toolbar.commit', { count: pendingChangeCount }),
legacyContent: translate('data_grid.toolbar.commit', { count: pendingChangeCount }),
className: isV2Ui ? 'gn-v2-commit-button' : undefined,
icon: <SaveOutlined />,
type: 'primary',
disabled: !hasChanges,
onClick: onCommit,
})}
{hasChanges && (isV2Ui ? (
renderToolbarAction({
label: translate('data_grid.toolbar.preview_sql'),
icon: <ConsoleSqlOutlined />,
onClick: onPreviewChanges,
})
) : (
<Dropdown menu={{ items: [{ key: 'preview-sql', label: translate('data_grid.toolbar.preview_sql_generate'), icon: <ConsoleSqlOutlined />, onClick: onPreviewChanges }] }}>
<Button icon={<ConsoleSqlOutlined />}>{translate('data_grid.toolbar.preview_sql')} <DownOutlined /></Button>
<Button
aria-label={translate('data_grid.toolbar.preview_sql')}
icon={<ConsoleSqlOutlined />}
>
{translate('data_grid.toolbar.preview_sql')} <DownOutlined />
</Button>
</Dropdown>
))}
{hasChanges && renderToolbarAction({
label: translate('data_grid.toolbar.rollback'),
icon: <UndoOutlined />,
onClick: onResetPendingChanges,
})}
{isV2Ui ? (
<Tooltip
title={`${commitModeLabel} · ${translate('data_grid.toolbar.commit_mode.tooltip')}`}
open={openToolbarMenu === 'commit-mode' ? false : undefined}
>
<span className="gn-v2-data-grid-toolbar-menu-trigger">
<Dropdown
autoFocus
trigger={['click']}
open={openToolbarMenu === 'commit-mode'}
onOpenChange={(open) => updateToolbarMenuOpen('commit-mode', open)}
menu={{
selectable: true,
selectedKeys: [dataEditCommitMode],
items: [
{ key: 'manual', label: translate('data_grid.toolbar.commit_mode.manual') },
{ key: 'auto', label: translate('data_grid.toolbar.commit_mode.auto') },
],
onClick: ({ key }) => {
setOpenToolbarMenu(null);
onDataEditCommitModeChange(key as 'manual' | 'auto');
},
}}
>
<Button
className="gn-v2-data-grid-toolbar-action"
aria-label={commitModeLabel}
aria-haspopup="menu"
aria-expanded={openToolbarMenu === 'commit-mode'}
icon={<ControlOutlined />}
/>
</Dropdown>
</span>
</Tooltip>
) : (
<Tooltip title={translate('data_grid.toolbar.commit_mode.tooltip')}>
<Select
size="small"
value={dataEditCommitMode}
onChange={onDataEditCommitModeChange}
style={{ width: 118, flex: '0 0 auto' }}
options={[
{ value: 'manual', label: translate('data_grid.toolbar.commit_mode.manual') },
{ value: 'auto', label: translate('data_grid.toolbar.commit_mode.auto') },
]}
/>
</Tooltip>
)}
{hasChanges && <Button icon={<UndoOutlined />} onClick={onResetPendingChanges}>{translate('data_grid.toolbar.rollback')}</Button>}
<Tooltip title={translate('data_grid.toolbar.commit_mode.tooltip')}>
<Select
size="small"
value={dataEditCommitMode}
onChange={onDataEditCommitModeChange}
style={{ width: 118, flex: '0 0 auto' }}
options={[
{ value: 'manual', label: translate('data_grid.toolbar.commit_mode.manual') },
{ value: 'auto', label: translate('data_grid.toolbar.commit_mode.auto') },
]}
/>
</Tooltip>
{dataEditCommitMode === 'auto' && (
<Select
size="small"
@@ -413,45 +533,87 @@ const DataGridToolbarFrame: React.FC<DataGridToolbarFrameProps> = ({
{(canImport || canExport) && (
<>
{renderToolbarDivider()}
{canImport && <Button icon={<ImportOutlined />} onClick={onImport}>{translate('data_grid.toolbar.import')}</Button>}
{canExport && <Button icon={<ExportOutlined />} onClick={onOpenExportModal}>{translate('data_grid.toolbar.export')}</Button>}
{canImport && renderToolbarAction({
label: translate('data_grid.toolbar.import'),
icon: <ImportOutlined />,
onClick: onImport,
})}
{canExport && renderToolbarAction({
label: translate('data_grid.toolbar.export'),
icon: <ExportOutlined />,
onClick: onOpenExportModal,
})}
</>
)}
{isQueryResultExport && (
<>
{renderToolbarDivider()}
<Dropdown menu={{ items: queryResultCopyMenu }} disabled={!canCopyQueryResult}>
<Button
data-grid-query-copy-action="true"
icon={<CopyOutlined />}
disabled={!canCopyQueryResult}
onClick={onCopyQueryResultCsv}
{isV2Ui ? (
<Tooltip
title={translate('data_grid.toolbar.copy')}
open={openToolbarMenu === 'query-copy' ? false : undefined}
>
{translate('data_grid.toolbar.copy')} <DownOutlined />
</Button>
</Dropdown>
<span className="gn-v2-data-grid-toolbar-menu-trigger">
<Dropdown
autoFocus
trigger={['click']}
open={openToolbarMenu === 'query-copy'}
onOpenChange={(open) => updateToolbarMenuOpen('query-copy', open)}
menu={{ items: queryResultCopyMenu }}
disabled={!canCopyQueryResult}
>
<Button
data-grid-query-copy-action="true"
className="gn-v2-data-grid-toolbar-action"
aria-label={translate('data_grid.toolbar.copy')}
aria-haspopup="menu"
aria-expanded={openToolbarMenu === 'query-copy'}
icon={<CopyOutlined />}
disabled={!canCopyQueryResult}
/>
</Dropdown>
</span>
</Tooltip>
) : (
<Dropdown menu={{ items: queryResultCopyMenu }} disabled={!canCopyQueryResult}>
<Button
data-grid-query-copy-action="true"
aria-label={translate('data_grid.toolbar.copy')}
icon={<CopyOutlined />}
disabled={!canCopyQueryResult}
onClick={onCopyQueryResultCsv}
>
{translate('data_grid.toolbar.copy')} <DownOutlined />
</Button>
</Dropdown>
)}
</>
)}
{!canModifyData && selectedCellsSize > 0 && (
<>
{renderToolbarDivider()}
<Button
data-grid-copy-selection-action="true"
icon={<CopyOutlined />}
onClick={onCopySelectedCellsToClipboard}
>
{translate('data_grid.toolbar.copy_selection', { count: selectedCellsSize })}
</Button>
<Tooltip title={isV2Ui ? translate('data_grid.toolbar.copy_selection', { count: selectedCellsSize }) : undefined}>
<Button
data-grid-copy-selection-action="true"
className={isV2Ui ? 'gn-v2-data-grid-toolbar-action' : undefined}
aria-label={translate('data_grid.toolbar.copy_selection', { count: selectedCellsSize })}
icon={<CopyOutlined />}
onClick={onCopySelectedCellsToClipboard}
>
{isV2Ui ? null : translate('data_grid.toolbar.copy_selection', { count: selectedCellsSize })}
</Button>
</Tooltip>
</>
)}
<>
{renderToolbarDivider()}
<Tooltip title={translate('data_grid.toolbar.ai_insight_tooltip')}>
<Tooltip title={aiInsightTooltip}>
<Button
className={isV2Ui ? 'gn-v2-ai-insight-button' : undefined}
className={isV2Ui ? 'gn-v2-data-grid-toolbar-action gn-v2-ai-insight-button' : undefined}
aria-label={isV2Ui ? translate('data_grid.toolbar.ai_insight_short') : translate('data_grid.toolbar.ai_insight')}
icon={<RobotOutlined />}
style={legacyAiButtonStyle}
onMouseEnter={(event) => {
@@ -466,8 +628,7 @@ const DataGridToolbarFrame: React.FC<DataGridToolbarFrameProps> = ({
}}
onClick={onRequestAiInsight}
>
<span>{isV2Ui ? translate('data_grid.toolbar.ai_insight_short') : translate('data_grid.toolbar.ai_insight')}</span>
{isV2Ui && aiShortcutLabel !== '-' && <span className="gn-v2-toolbar-kbd">{aiShortcutLabel}</span>}
{isV2Ui ? null : <span>{translate('data_grid.toolbar.ai_insight')}</span>}
</Button>
</Tooltip>
</>
@@ -484,10 +645,12 @@ const DataGridToolbarFrame: React.FC<DataGridToolbarFrameProps> = ({
{renderToolbarDivider()}
<Tooltip title={paginationTotalCountLoading ? translate('data_grid.toolbar.cancel_count_tooltip') : translate('data_grid.toolbar.count_total_tooltip')}>
<Button
className={isV2Ui ? 'gn-v2-data-grid-toolbar-action' : undefined}
aria-label={paginationTotalCountLoading ? translate('data_grid.toolbar.cancel_count') : translate('data_grid.toolbar.count_total')}
icon={paginationTotalCountLoading ? <CloseOutlined /> : <VerticalAlignBottomOutlined />}
onClick={onToggleTotalCount}
>
{paginationTotalCountLoading ? translate('data_grid.toolbar.cancel_count') : translate('data_grid.toolbar.count_total')}
{isV2Ui ? null : (paginationTotalCountLoading ? translate('data_grid.toolbar.cancel_count') : translate('data_grid.toolbar.count_total'))}
</Button>
</Tooltip>
</>

View File

@@ -474,6 +474,7 @@ vi.mock('@ant-design/icons', () => {
BugOutlined: Icon,
ClearOutlined: Icon,
CopyOutlined: Icon,
DiffOutlined: Icon,
PlayCircleOutlined: Icon,
SaveOutlined: Icon,
FormatPainterOutlined: Icon,
@@ -486,6 +487,7 @@ vi.mock('@ant-design/icons', () => {
DownOutlined: Icon,
EyeOutlined: Icon,
EyeInvisibleOutlined: Icon,
EllipsisOutlined: Icon,
};
});
@@ -620,11 +622,18 @@ const queryResultMessageText = (renderer: ReactTestRenderer): string => {
return values.join('\n');
};
const findButton = (renderer: ReactTestRenderer, text: string) =>
renderer.root.findAll((node) => node.type === 'button' && textContent(node).includes(text))[0];
const findButtons = (renderer: ReactTestRenderer, text: string) => {
const visibleTextMatches = renderer.root.findAll(
(node) => node.type === 'button' && textContent(node).includes(text),
);
return visibleTextMatches.length > 0
? visibleTextMatches
: renderer.root.findAll((node) => (
node.type === 'button' && String(node.props?.['aria-label'] || '').includes(text)
));
};
const findButtons = (renderer: ReactTestRenderer, text: string) =>
renderer.root.findAll((node) => node.type === 'button' && textContent(node).includes(text));
const findButton = (renderer: ReactTestRenderer, text: string) => findButtons(renderer, text)[0];
const findExactButton = (renderer: ReactTestRenderer, text: string) =>
renderer.root.findAll((node) => node.type === 'button' && textContent(node) === text)[0];
@@ -963,6 +972,42 @@ describe('QueryEditor external SQL save', () => {
expect(textContent(renderer.toJSON())).not.toContain('等待执行 SQL');
});
it('renders the v2 SQL toolbar actions as icon-only buttons', async () => {
storeState.appearance.uiVersion = 'v2';
let renderer!: ReactTestRenderer;
await act(async () => {
renderer = create(<QueryEditor tab={createTab()} />);
});
const exactLabels = [
'运行',
'保存',
'AI · 更多',
'更多',
'搜索',
'开启自动换行',
'美化 SQL',
'美化 SQL · 设置',
];
const iconOnlyButtons = exactLabels.map((label) => renderer.root.find(
(node) => node.type === 'button' && node.props?.['aria-label'] === label,
));
iconOnlyButtons.push(renderer.root.find(
(node) => node.type === 'button'
&& String(node.props?.['aria-label'] || '').startsWith('触发 SQL AI 自动补全'),
));
for (const button of iconOnlyButtons) {
expect(textContent(button)).toBe('');
expect(button.props.className).toContain('gn-v2-query-toolbar-icon-action');
}
await act(async () => {
renderer.unmount();
});
});
it('shows the empty query results panel after toggling the results button', async () => {
storeState.appearance.uiVersion = 'v2';
@@ -1058,8 +1103,14 @@ describe('QueryEditor external SQL save', () => {
expect(textContent(renderer.toJSON())).toContain('结果 1');
const hideButton = renderer.root.find(
(node) => node.type === 'button' && node.props['aria-label'] === '隐藏结果区',
);
expect(textContent(hideButton)).toBe('');
expect(hideButton.props.className).toContain('gn-v2-data-grid-toolbar-action');
await act(async () => {
findButton(renderer, '隐藏').props.onClick();
hideButton.props.onClick();
});
expect(textContent(renderer.toJSON())).not.toContain('结果 1');
@@ -11590,8 +11641,8 @@ describe('QueryEditor external SQL save', () => {
expect(css).toContain('body[data-ui-version="v2"] .gn-v2-query-toolbar-selects');
expect(css).toContain('body[data-ui-version="v2"] .gn-v2-query-toolbar-actions');
expect(css).toContain('width: 78px !important;');
expect(css).toContain('width: 104px !important;');
expect(css).toContain('width: 48px !important;');
expect(css).toContain('flex: 0 0 48px !important;');
expect(css).toContain('flex: 0 0 auto !important;');
expect(css).toContain('justify-content: flex-start;');
expect(css).toContain('height: 32px !important;');

View File

@@ -407,6 +407,7 @@ vi.mock('@ant-design/icons', () => {
BugOutlined: Icon,
ClearOutlined: Icon,
CopyOutlined: Icon,
DiffOutlined: Icon,
PlayCircleOutlined: Icon,
SaveOutlined: Icon,
FormatPainterOutlined: Icon,
@@ -420,6 +421,7 @@ vi.mock('@ant-design/icons', () => {
EyeOutlined: Icon,
EyeInvisibleOutlined: Icon,
EnterOutlined: Icon,
EllipsisOutlined: Icon,
};
});
@@ -523,11 +525,18 @@ const textContent = (node: any): string => {
.join('');
};
const findButton = (renderer: ReactTestRenderer, text: string) =>
renderer.root.findAll((node) => node.type === 'button' && textContent(node).includes(text))[0];
const findButtons = (renderer: ReactTestRenderer, text: string) => {
const visibleTextMatches = renderer.root.findAll(
(node) => node.type === 'button' && textContent(node).includes(text),
);
return visibleTextMatches.length > 0
? visibleTextMatches
: renderer.root.findAll((node) => (
node.type === 'button' && String(node.props?.['aria-label'] || '').includes(text)
));
};
const findButtons = (renderer: ReactTestRenderer, text: string) =>
renderer.root.findAll((node) => node.type === 'button' && textContent(node).includes(text));
const findButton = (renderer: ReactTestRenderer, text: string) => findButtons(renderer, text)[0];
const findExactButton = (renderer: ReactTestRenderer, text: string) =>
renderer.root.findAll((node) => node.type === 'button' && textContent(node) === text)[0];
@@ -3811,8 +3820,8 @@ describe('QueryEditor external SQL save', () => {
expect(css).toContain('body[data-ui-version="v2"] .gn-v2-query-toolbar-selects');
expect(css).toContain('body[data-ui-version="v2"] .gn-v2-query-toolbar-actions');
expect(css).toContain('width: 78px !important;');
expect(css).toContain('width: 104px !important;');
expect(css).toContain('width: 48px !important;');
expect(css).toContain('flex: 0 0 48px !important;');
expect(css).toContain('flex: 0 0 auto !important;');
expect(css).toContain('justify-content: flex-start;');
expect(css).toContain('height: 32px !important;');

View File

@@ -457,9 +457,13 @@ const QueryEditorResultsPanel: React.FC<QueryEditorResultsPanelProps> = ({
const toolbarHideButton = (
<Tooltip title={hideTooltipTitle}>
<Button className={isV2Ui ? 'gn-v2-query-result-toolbar-hide' : undefined} icon={<EyeInvisibleOutlined />} onClick={onHide}>
<span>{t('query_editor.results_panel.action.hide')}</span>
{isV2Ui && toggleShortcutLabel && <span className="gn-v2-toolbar-kbd">{toggleShortcutLabel}</span>}
<Button
aria-label={t('query_editor.results_panel.aria.hide')}
className={isV2Ui ? 'gn-v2-data-grid-toolbar-action gn-v2-query-result-toolbar-hide' : undefined}
icon={<EyeInvisibleOutlined />}
onClick={onHide}
>
{!isV2Ui && <span>{t('query_editor.results_panel.action.hide')}</span>}
</Button>
</Tooltip>
);

View File

@@ -8,11 +8,13 @@ describe('QueryEditorToolbar AI trigger affordance', () => {
expect(source).toContain('onMouseDown={onCaptureEditorCursorPosition}');
expect(source).toContain('onClick={onTriggerSqlAiCompletion}');
expect(source).toContain('triggerSqlAiCompletionLabel');
expect(source).toMatch(/aria-label="AI more actions"\s+onMouseDown=\{onCaptureEditorCursorPosition\}/);
expect(source).toContain('const aiMoreTitle');
expect(source).toMatch(/aria-label=\{aiMoreTitle\}[\s\S]*?onMouseDown=\{onCaptureEditorCursorPosition\}/);
});
it('keeps the secondary AI dropdown for other actions', () => {
expect(source).toContain('icon={<DownOutlined />}');
expect(source).toContain('menu={{ items: aiMenuItems }}');
expect(source).toContain('title={isV2Ui ? aiMoreTitle : undefined}');
});
});

View File

@@ -40,7 +40,7 @@ describe('QueryEditorToolbar layout', () => {
expect(css).toContain('gap: 6px;');
});
it('optically aligns the word-wrap icon with its visible label', () => {
it('optically centers the word-wrap icon in its v2 icon-only action', () => {
const toolbarSource = readFileSync(new URL('./QueryEditorToolbar.tsx', import.meta.url), 'utf8');
const css = readV2ThemeCss();
const wordWrapCss = css.slice(
@@ -50,6 +50,7 @@ describe('QueryEditorToolbar layout', () => {
expect(toolbarSource).toContain('gn-v2-query-toolbar-word-wrap-action');
expect(toolbarSource).toContain('gn-query-toolbar-word-wrap-icon');
expect(toolbarSource).toContain('{!isV2Ui && t("query_editor.action.word_wrap")}');
expect(wordWrapCss).toContain('align-items: center;');
expect(wordWrapCss).toContain('justify-content: center;');
expect(wordWrapCss).toContain('width: 16px;');
@@ -80,7 +81,7 @@ describe('QueryEditorToolbar layout', () => {
expect(commitKbdHoverCss).toContain('background:');
});
it('keeps transaction selects wide enough for localized auto-commit labels', () => {
it('keeps transaction selectors compact after replacing selected labels with icons', () => {
const css = readV2ThemeCss();
const transactionModeCss = css.slice(
css.indexOf('body[data-ui-version="v2"] .gn-v2-query-toolbar-transaction-mode-select {'),
@@ -91,10 +92,32 @@ describe('QueryEditorToolbar layout', () => {
css.indexOf('body[data-ui-version="v2"] .gn-v2-query-toolbar .ant-select-selector {'),
);
expect(transactionModeCss).toContain('width: 78px !important;');
expect(transactionModeCss).toContain('flex: 0 0 78px !important;');
expect(transactionDelayCss).toContain('width: 104px !important;');
expect(transactionDelayCss).toContain('flex: 0 0 104px !important;');
expect(transactionModeCss).toContain('width: 48px !important;');
expect(transactionModeCss).toContain('flex: 0 0 48px !important;');
expect(transactionDelayCss).toContain('width: 48px !important;');
expect(transactionDelayCss).toContain('flex: 0 0 48px !important;');
expect(css).toContain(
'body[data-ui-version="v2"] .gn-v2-query-toolbar .gn-v2-query-toolbar-icon-select .ant-select-selector {',
);
});
it('uses a stable icon-button width for every v2 SQL toolbar action', () => {
const toolbarSource = readFileSync(new URL('./QueryEditorToolbar.tsx', import.meta.url), 'utf8');
const css = readV2ThemeCss();
const iconActionCss = css.slice(
css.indexOf('body[data-ui-version="v2"] .gn-v2-query-toolbar-icon-action.ant-btn,'),
css.indexOf('body[data-ui-version="v2"] .gn-v2-query-toolbar-run-action.ant-btn {'),
);
expect(toolbarSource).toContain('EllipsisOutlined');
expect(toolbarSource).toContain('gn-v2-query-toolbar-menu-trigger');
expect(toolbarSource).toContain('aria-haspopup="menu"');
expect(toolbarSource).toContain('aria-expanded={isV2Ui ? openToolbarMenu === "ai" : undefined}');
expect(toolbarSource).toContain('aria-expanded={isV2Ui ? openToolbarMenu === "more" : undefined}');
expect(toolbarSource).toContain('aria-expanded={isV2Ui ? openToolbarMenu === "format" : undefined}');
expect(iconActionCss).toContain('width: 34px !important;');
expect(iconActionCss).toContain('min-width: 34px !important;');
expect(iconActionCss).toContain('padding: 0 !important;');
});
it('keeps editor search action grouped with formatting actions', () => {

View File

@@ -5,6 +5,7 @@ import {
DownOutlined,
EyeInvisibleOutlined,
EyeOutlined,
EllipsisOutlined,
FormatPainterOutlined,
PlayCircleOutlined,
RobotOutlined,
@@ -90,6 +91,8 @@ type FullNameSelectOption = {
fullName: string;
};
type QueryToolbarMenuKey = "ai" | "more" | "format";
const renderFullNameSelectTooltip = (fullName: React.ReactNode) => {
const fullNameText = String(fullName ?? "");
@@ -150,6 +153,10 @@ const QueryEditorToolbar: React.FC<QueryEditorToolbarProps> = ({
}) => {
const i18n = useOptionalI18n();
const t = i18n?.t ?? defaultTranslate;
const [openToolbarMenu, setOpenToolbarMenu] = React.useState<QueryToolbarMenuKey | null>(null);
const updateToolbarMenuOpen = (key: QueryToolbarMenuKey, open: boolean) => {
setOpenToolbarMenu((current) => open ? key : current === key ? null : current);
};
const baseMoreMenuItems = saveMoreMenuItems ?? [];
const connectionSelectOptions: FullNameSelectOption[] =
queryCapableConnections.map((connection) => ({
@@ -212,6 +219,8 @@ const QueryEditorToolbar: React.FC<QueryEditorToolbarProps> = ({
activeShortcutPlatform,
)}`
: t("app.shortcuts.action.triggerSqlAiCompletion.label");
const aiMoreTitle = `AI · ${t("query_editor.action.more")}`;
const formatSettingsTitle = `${t("query_editor.action.format_sql")} · ${t("settings.title")}`;
const aiMenuItems: MenuProps["items"] = [
{
key: "ai-inline-completion",
@@ -362,36 +371,43 @@ const QueryEditorToolbar: React.FC<QueryEditorToolbarProps> = ({
}
>
<Button
className={isV2Ui ? "gn-v2-query-toolbar-run-action" : undefined}
aria-label={t("query_editor.action.run")}
className={isV2Ui ? "gn-v2-query-toolbar-icon-action gn-v2-query-toolbar-run-action" : undefined}
type="primary"
icon={<PlayCircleOutlined />}
onMouseDown={onCaptureEditorCursorPosition}
onClick={onRun}
loading={loading}
>
{t("query_editor.action.run")}
{!isV2Ui && t("query_editor.action.run")}
</Button>
{showViewDataVerify && onViewDataVerify && (
<Tooltip title={t("result_diff.view_verify.toolbar.tooltip")}>
<Button
icon={<DiffOutlined />}
disabled={loading}
onClick={onViewDataVerify}
>
{t("result_diff.view_verify.toolbar")}
</Button>
</Tooltip>
)}
</Tooltip>
{showViewDataVerify && onViewDataVerify && (
<Tooltip title={t("result_diff.view_verify.toolbar.tooltip")}>
<Button
aria-label={t("result_diff.view_verify.toolbar")}
className={isV2Ui ? "gn-v2-query-toolbar-icon-action" : undefined}
icon={<DiffOutlined />}
disabled={loading}
onClick={onViewDataVerify}
>
{!isV2Ui && t("result_diff.view_verify.toolbar")}
</Button>
</Tooltip>
)}
{loading && (
<Button
type="primary"
danger
icon={<StopOutlined />}
onClick={onCancel}
>
{t("query_editor.action.stop")}
</Button>
<Tooltip title={t("query_editor.action.stop")}>
<Button
aria-label={t("query_editor.action.stop")}
className={isV2Ui ? "gn-v2-query-toolbar-icon-action" : undefined}
type="primary"
danger
icon={<StopOutlined />}
onClick={onCancel}
>
{!isV2Ui && t("query_editor.action.stop")}
</Button>
</Tooltip>
)}
</div>
{isV2Ui && pendingTransactionToolbar}
@@ -412,46 +428,76 @@ const QueryEditorToolbar: React.FC<QueryEditorToolbarProps> = ({
}
>
<Button
className={isV2Ui ? "gn-v2-query-toolbar-save-action" : undefined}
aria-label={t("query_editor.action.save")}
className={isV2Ui ? "gn-v2-query-toolbar-icon-action gn-v2-query-toolbar-save-action" : undefined}
type="primary"
icon={<SaveOutlined />}
onClick={onQuickSave}
>
{t("query_editor.action.save")}
{!isV2Ui && t("query_editor.action.save")}
</Button>
</Tooltip>
<div style={{ display: "flex", gap: "4px", alignItems: "center" }}>
<Tooltip title={triggerSqlAiCompletionLabel}>
<Button
className={isV2Ui ? "gn-v2-query-toolbar-ai-action" : undefined}
aria-label={triggerSqlAiCompletionLabel}
className={isV2Ui ? "gn-v2-query-toolbar-icon-action gn-v2-query-toolbar-ai-action" : undefined}
icon={<RobotOutlined />}
style={{ color: "#818cf8" }}
onMouseDown={onCaptureEditorCursorPosition}
onClick={onTriggerSqlAiCompletion}
>
AI
{!isV2Ui && "AI"}
</Button>
</Tooltip>
<Dropdown
menu={{ items: aiMenuItems }}
placement="bottomRight"
trigger={["click"]}
<Tooltip
title={isV2Ui ? aiMoreTitle : undefined}
open={isV2Ui && openToolbarMenu === "ai" ? false : undefined}
>
<Button
className={isV2Ui ? "gn-v2-query-toolbar-icon-action" : undefined}
icon={<DownOutlined />}
aria-label="AI more actions"
onMouseDown={onCaptureEditorCursorPosition}
/>
</Dropdown>
<span className={isV2Ui ? "gn-v2-query-toolbar-menu-trigger" : undefined}>
<Dropdown
menu={{ items: aiMenuItems }}
placement="bottomRight"
trigger={["click"]}
open={isV2Ui ? openToolbarMenu === "ai" : undefined}
onOpenChange={isV2Ui ? (open) => updateToolbarMenuOpen("ai", open) : undefined}
>
<Button
className={isV2Ui ? "gn-v2-query-toolbar-icon-action" : undefined}
icon={<DownOutlined />}
aria-label={aiMoreTitle}
aria-haspopup="menu"
aria-expanded={isV2Ui ? openToolbarMenu === "ai" : undefined}
onMouseDown={onCaptureEditorCursorPosition}
/>
</Dropdown>
</span>
</Tooltip>
</div>
<Dropdown
menu={{ items: moreMenuItems }}
placement="bottomRight"
trigger={["click"]}
<Tooltip
title={isV2Ui ? t("query_editor.action.more") : undefined}
open={isV2Ui && openToolbarMenu === "more" ? false : undefined}
>
<Button>{t("query_editor.action.more")}</Button>
</Dropdown>
<span className={isV2Ui ? "gn-v2-query-toolbar-menu-trigger" : undefined}>
<Dropdown
menu={{ items: moreMenuItems }}
placement="bottomRight"
trigger={["click"]}
open={isV2Ui ? openToolbarMenu === "more" : undefined}
onOpenChange={isV2Ui ? (open) => updateToolbarMenuOpen("more", open) : undefined}
>
<Button
aria-label={t("query_editor.action.more")}
className={isV2Ui ? "gn-v2-query-toolbar-icon-action" : undefined}
icon={isV2Ui ? <EllipsisOutlined /> : undefined}
aria-haspopup="menu"
aria-expanded={isV2Ui ? openToolbarMenu === "more" : undefined}
>
{!isV2Ui && t("query_editor.action.more")}
</Button>
</Dropdown>
</span>
</Tooltip>
</div>
<div
@@ -459,8 +505,13 @@ const QueryEditorToolbar: React.FC<QueryEditorToolbarProps> = ({
style={{ display: "flex", gap: "8px", alignItems: "center" }}
>
<Tooltip title={findInEditorTitle}>
<Button icon={<SearchOutlined />} onClick={onFindInEditor}>
{t("query_editor.action.find_in_editor")}
<Button
aria-label={t("query_editor.action.find_in_editor")}
className={isV2Ui ? "gn-v2-query-toolbar-icon-action" : undefined}
icon={<SearchOutlined />}
onClick={onFindInEditor}
>
{!isV2Ui && t("query_editor.action.find_in_editor")}
</Button>
</Tooltip>
<Tooltip
@@ -471,7 +522,7 @@ const QueryEditorToolbar: React.FC<QueryEditorToolbarProps> = ({
)}
>
<Button
className={isV2Ui ? "gn-v2-query-toolbar-word-wrap-action" : undefined}
className={isV2Ui ? "gn-v2-query-toolbar-icon-action gn-v2-query-toolbar-word-wrap-action" : undefined}
type={wordWrapEnabled ? "primary" : "default"}
icon={<WrapTextIcon />}
aria-label={t(
@@ -482,24 +533,41 @@ const QueryEditorToolbar: React.FC<QueryEditorToolbarProps> = ({
aria-pressed={wordWrapEnabled}
onClick={onToggleWordWrap}
>
{t("query_editor.action.word_wrap")}
{!isV2Ui && t("query_editor.action.word_wrap")}
</Button>
</Tooltip>
<Tooltip title={formatSqlTitle}>
<Button icon={<FormatPainterOutlined />} onClick={onFormat}>
{t("query_editor.action.format")}
<Button
aria-label={t("query_editor.action.format_sql")}
className={isV2Ui ? "gn-v2-query-toolbar-icon-action" : undefined}
icon={<FormatPainterOutlined />}
onClick={onFormat}
>
{!isV2Ui && t("query_editor.action.format")}
</Button>
</Tooltip>
<Dropdown
menu={{ items: formatSettingsMenu }}
placement="bottomRight"
trigger={["click"]}
<Tooltip
title={isV2Ui ? formatSettingsTitle : undefined}
open={isV2Ui && openToolbarMenu === "format" ? false : undefined}
>
<Button
className={isV2Ui ? "gn-v2-query-toolbar-icon-action" : undefined}
icon={<SettingOutlined />}
/>
</Dropdown>
<span className={isV2Ui ? "gn-v2-query-toolbar-menu-trigger" : undefined}>
<Dropdown
menu={{ items: formatSettingsMenu }}
placement="bottomRight"
trigger={["click"]}
open={isV2Ui ? openToolbarMenu === "format" : undefined}
onOpenChange={isV2Ui ? (open) => updateToolbarMenuOpen("format", open) : undefined}
>
<Button
aria-label={formatSettingsTitle}
className={isV2Ui ? "gn-v2-query-toolbar-icon-action" : undefined}
icon={<SettingOutlined />}
aria-haspopup="menu"
aria-expanded={isV2Ui ? openToolbarMenu === "format" : undefined}
/>
</Dropdown>
</span>
</Tooltip>
</div>
{!isV2Ui && (

View File

@@ -26,7 +26,13 @@ vi.mock('../i18n/provider', () => ({
}),
}));
const latestTooltipProps = () => antdState.tooltipProps[antdState.tooltipProps.length - 1];
const latestSelectProps = (className: string) => [...antdState.selectProps].reverse().find(
(props) => String(props.className).includes(className),
);
const latestTooltipProps = (selectClassName: string) => [...antdState.tooltipProps].reverse().find(
(props) => String(props.children?.props?.className).includes(selectClassName),
);
describe('QueryEditorTransactionSettings', () => {
let renderer: ReactTestRenderer | null = null;
@@ -54,9 +60,97 @@ describe('QueryEditorTransactionSettings', () => {
);
});
expect(latestTooltipProps().placement).toBe('topLeft');
expect(latestTooltipProps()).not.toHaveProperty('autoAdjustOverflow', false);
expect(latestTooltipProps()).not.toHaveProperty('open');
expect(antdState.selectProps[0]).not.toHaveProperty('onOpenChange');
expect(antdState.tooltipProps[0].placement).toBe('topLeft');
expect(antdState.tooltipProps[0]).not.toHaveProperty('autoAdjustOverflow', false);
expect(antdState.tooltipProps[0].title).toContain('query_editor.transaction.mode.tooltip');
expect(antdState.selectProps[0].onOpenChange).toBeTypeOf('function');
});
it('shows only state icons in the v2 selectors while keeping localized accessible labels', () => {
act(() => {
renderer = create(
<QueryEditorTransactionSettings
isV2Ui
commitMode="auto"
autoCommitDelayMs={0}
onCommitModeChange={vi.fn()}
onAutoCommitDelayMsChange={vi.fn()}
/>,
);
});
const [modeSelect, delaySelect] = antdState.selectProps;
expect(modeSelect.className).toContain('gn-v2-query-toolbar-icon-select');
expect(modeSelect['aria-label']).toBe('query_editor.transaction.mode.auto');
expect(modeSelect.popupMatchSelectWidth).toBe(false);
expect(typeof modeSelect.labelRender).toBe('function');
expect(React.isValidElement(modeSelect.labelRender({ value: 'auto' }))).toBe(true);
expect(modeSelect.options.map(({ label }: { label: string }) => label)).toEqual([
'query_editor.transaction.mode.manual',
'query_editor.transaction.mode.auto',
]);
expect(delaySelect.className).toContain('gn-v2-query-toolbar-icon-select');
expect(delaySelect['aria-label']).toBe('query_editor.transaction.delay.immediate_commit');
expect(delaySelect.popupMatchSelectWidth).toBe(false);
expect(typeof delaySelect.labelRender).toBe('function');
expect(React.isValidElement(delaySelect.labelRender({ value: 0 }))).toBe(true);
expect(delaySelect.options.map(({ label }: { label: string }) => label)).toEqual([
'query_editor.transaction.delay.immediate_commit',
'3s后提交',
'5s后提交',
'10s后提交',
'30s后提交',
]);
expect(antdState.tooltipProps[0].title).toContain('query_editor.transaction.mode.auto');
expect(antdState.tooltipProps[1].title).toContain('query_editor.transaction.delay.immediate_commit');
});
it('hides each selector tooltip while its v2 dropdown is open', () => {
act(() => {
renderer = create(
<QueryEditorTransactionSettings
isV2Ui
commitMode="auto"
autoCommitDelayMs={0}
onCommitModeChange={vi.fn()}
onAutoCommitDelayMsChange={vi.fn()}
/>,
);
});
act(() => {
latestSelectProps('gn-v2-query-toolbar-transaction-mode-select').onOpenChange(true);
});
expect(latestTooltipProps('gn-v2-query-toolbar-transaction-mode-select').title).toBeNull();
act(() => {
latestSelectProps('gn-v2-query-toolbar-transaction-mode-select').onOpenChange(false);
latestSelectProps('gn-v2-query-toolbar-transaction-delay-select').onOpenChange(true);
});
expect(latestTooltipProps('gn-v2-query-toolbar-transaction-mode-select').title).toContain(
'query_editor.transaction.mode.tooltip',
);
expect(latestTooltipProps('gn-v2-query-toolbar-transaction-delay-select').title).toBeNull();
});
it('keeps textual select labels in the legacy toolbar', () => {
act(() => {
renderer = create(
<QueryEditorTransactionSettings
isV2Ui={false}
commitMode="manual"
autoCommitDelayMs={0}
onCommitModeChange={vi.fn()}
onAutoCommitDelayMsChange={vi.fn()}
/>,
);
});
expect(antdState.selectProps[0].labelRender).toBeUndefined();
expect(antdState.selectProps[0].className).toBeUndefined();
expect(antdState.selectProps[0].popupMatchSelectWidth).toBeUndefined();
expect(antdState.selectProps[0].onOpenChange).toBeUndefined();
});
});

View File

@@ -1,5 +1,11 @@
import React from 'react';
import { Select, Tooltip } from 'antd';
import {
ClockCircleOutlined,
ControlOutlined,
SyncOutlined,
ThunderboltOutlined,
} from '@ant-design/icons';
import { t as defaultTranslate } from '../i18n';
import { useOptionalI18n } from '../i18n/provider';
@@ -26,6 +32,8 @@ type QueryEditorTransactionSettingsProps = {
onAutoCommitDelayMsChange: (delayMs: number) => void;
};
type OpenTransactionSelect = 'mode' | 'delay' | null;
const QueryEditorTransactionSettings: React.FC<QueryEditorTransactionSettingsProps> = ({
isV2Ui,
commitMode,
@@ -35,24 +43,45 @@ const QueryEditorTransactionSettings: React.FC<QueryEditorTransactionSettingsPro
}) => {
const i18n = useOptionalI18n();
const t = i18n?.t ?? defaultTranslate;
const [openTransactionSelect, setOpenTransactionSelect] = React.useState<OpenTransactionSelect>(null);
const updateTransactionSelectOpen = (key: Exclude<OpenTransactionSelect, null>, open: boolean) => {
setOpenTransactionSelect((current) => open ? key : current === key ? null : current);
};
const autoCommitDelayOptions = SQL_EDITOR_AUTO_COMMIT_DELAY_OPTIONS.map((option) => ({
value: option.value,
label: option.value === 0
? t('query_editor.transaction.delay.immediate_commit')
: t('query_editor.transaction.delay.seconds_commit', { seconds: Math.round(option.value / 1000) }),
}));
const commitModeLabel = t(`query_editor.transaction.mode.${commitMode}`);
const autoCommitDelayLabel = autoCommitDelayOptions.find(
(option) => option.value === autoCommitDelayMs,
)?.label ?? t('query_editor.transaction.delay.immediate_commit');
const commitModeTooltip = `${commitModeLabel} · ${t('query_editor.transaction.mode.tooltip')}`;
const autoCommitDelayTooltip = `${t('query_editor.transaction.mode.auto')} · ${autoCommitDelayLabel}`;
return (
<>
<Tooltip
title={t('query_editor.transaction.mode.tooltip')}
title={isV2Ui && openTransactionSelect === 'mode' ? null : commitModeTooltip}
placement="topLeft"
>
<Select
className={isV2Ui ? 'gn-v2-query-toolbar-select gn-v2-query-toolbar-transaction-mode-select' : undefined}
aria-label={commitModeLabel}
className={isV2Ui ? 'gn-v2-query-toolbar-select gn-v2-query-toolbar-icon-select gn-v2-query-toolbar-transaction-mode-select' : undefined}
style={isV2Ui ? undefined : { width: 78 }}
value={commitMode}
onChange={(mode) => onCommitModeChange(mode === 'auto' ? 'auto' : 'manual')}
popupMatchSelectWidth={isV2Ui ? false : undefined}
onOpenChange={isV2Ui ? (open) => updateTransactionSelectOpen('mode', open) : undefined}
onChange={(mode) => {
setOpenTransactionSelect(null);
onCommitModeChange(mode === 'auto' ? 'auto' : 'manual');
}}
labelRender={isV2Ui ? (option) => (
<span className="gn-v2-query-toolbar-select-icon" aria-hidden="true">
{option.value === 'auto' ? <SyncOutlined /> : <ControlOutlined />}
</span>
) : undefined}
options={[
{ label: t('query_editor.transaction.mode.manual'), value: 'manual' },
{ label: t('query_editor.transaction.mode.auto'), value: 'auto' },
@@ -60,13 +89,29 @@ const QueryEditorTransactionSettings: React.FC<QueryEditorTransactionSettingsPro
/>
</Tooltip>
{commitMode === 'auto' && (
<Select
className={isV2Ui ? 'gn-v2-query-toolbar-select gn-v2-query-toolbar-transaction-delay-select' : undefined}
style={isV2Ui ? undefined : { width: 68 }}
value={autoCommitDelayMs}
onChange={(delayMs) => onAutoCommitDelayMsChange(Number(delayMs))}
options={autoCommitDelayOptions}
/>
<Tooltip
title={isV2Ui && openTransactionSelect === 'delay' ? null : autoCommitDelayTooltip}
placement="topLeft"
>
<Select
aria-label={autoCommitDelayLabel}
className={isV2Ui ? 'gn-v2-query-toolbar-select gn-v2-query-toolbar-icon-select gn-v2-query-toolbar-transaction-delay-select' : undefined}
style={isV2Ui ? undefined : { width: 68 }}
value={autoCommitDelayMs}
popupMatchSelectWidth={isV2Ui ? false : undefined}
onOpenChange={isV2Ui ? (open) => updateTransactionSelectOpen('delay', open) : undefined}
onChange={(delayMs) => {
setOpenTransactionSelect(null);
onAutoCommitDelayMsChange(Number(delayMs));
}}
labelRender={isV2Ui ? () => (
<span className="gn-v2-query-toolbar-select-icon" aria-hidden="true">
{autoCommitDelayMs === 0 ? <ThunderboltOutlined /> : <ClockCircleOutlined />}
</span>
) : undefined}
options={autoCommitDelayOptions}
/>
</Tooltip>
)}
</>
);

View File

@@ -162,13 +162,37 @@ body[data-ui-version="v2"] .gn-v2-query-toolbar-max-rows-select {
}
body[data-ui-version="v2"] .gn-v2-query-toolbar-transaction-mode-select {
width: 78px !important;
flex: 0 0 78px !important;
width: 48px !important;
flex: 0 0 48px !important;
}
body[data-ui-version="v2"] .gn-v2-query-toolbar-transaction-delay-select {
width: 104px !important;
flex: 0 0 104px !important;
width: 48px !important;
flex: 0 0 48px !important;
}
body[data-ui-version="v2"] .gn-v2-query-toolbar .gn-v2-query-toolbar-icon-select .ant-select-selector {
padding: 0 23px 0 7px !important;
}
body[data-ui-version="v2"] .gn-v2-query-toolbar-icon-select .ant-select-selection-item {
display: inline-flex;
align-items: center;
justify-content: center;
padding-inline-end: 0 !important;
}
body[data-ui-version="v2"] .gn-v2-query-toolbar-icon-select .ant-select-arrow {
inset-inline-end: 7px;
}
body[data-ui-version="v2"] .gn-v2-query-toolbar-select-icon {
display: inline-flex;
width: 16px;
height: 16px;
align-items: center;
justify-content: center;
font-size: 15px;
}
body[data-ui-version="v2"] .gn-v2-query-toolbar .ant-select-selector {
@@ -275,12 +299,21 @@ body[data-ui-version="v2"] .gn-v2-query-transaction-commit-button:active .gn-v2-
body[data-ui-version="v2"] .gn-v2-query-toolbar-icon-action.ant-btn,
body[data-ui-version="v2"] .gn-v2-query-toolbar .ant-btn-icon-only {
width: 34px !important;
min-width: 34px !important;
padding: 0 !important;
flex: 0 0 34px;
}
body[data-ui-version="v2"] .gn-v2-query-toolbar-menu-trigger {
display: inline-flex;
width: 34px;
min-width: 34px;
flex: 0 0 34px;
}
body[data-ui-version="v2"] .gn-v2-query-toolbar-run-action.ant-btn {
min-width: 64px;
padding: 0 13px !important;
min-width: 34px;
padding: 0 !important;
font-weight: 650 !important;
}

View File

@@ -3930,6 +3930,23 @@ body[data-ui-version="v2"] .gn-v2-data-grid .data-grid-toolbar-scroll .ant-btn {
border-radius: 7px !important;
}
body[data-ui-version="v2"] .gn-v2-data-grid .gn-v2-data-grid-toolbar-action {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px !important;
min-width: 28px !important;
padding-inline: 0 !important;
flex: 0 0 28px;
}
body[data-ui-version="v2"] .gn-v2-data-grid .gn-v2-data-grid-toolbar-menu-trigger {
display: inline-flex;
width: 28px;
min-width: 28px;
flex: 0 0 28px;
}
body[data-ui-version="v2"] .gn-v2-data-grid-toolbar-title {
display: inline-flex;
align-items: center;