Compare commits

..

1 Commits

Author SHA1 Message Date
Syngnat
e78a4d3a2c docs: attach PR 597 implementation screenshots 2026-06-27 20:24:01 +08:00
13 changed files with 65 additions and 932 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

View File

@@ -588,45 +588,7 @@ jobs:
export GOCACHE="${RUNNER_TEMP}/go-build-${{ matrix.os_name }}-${{ matrix.arch_name }}-${REVISION_HASH}"
mkdir -p "$GOCACHE"
echo "🧭 可选驱动使用隔离 GOCACHE$GOCACHE"
normalize_driver_token() {
local value
value="$(printf '%s' "${1:-}" | tr '[:upper:]' '[:lower:]' | tr -d '[:space:]')"
case "$value" in
"") return 1 ;;
doris|diros) echo "doris" ;;
open_gauss|open-gauss) echo "opengauss" ;;
gaussdb|gauss_db|gauss-db) echo "gaussdb" ;;
elastic|elasticsearch) echo "elasticsearch" ;;
mariadb|oceanbase|starrocks|sphinx|sqlserver|sqlite|duckdb|dameng|kingbase|highgo|vastbase|opengauss|gaussdb|iris|mongodb|tdengine|iotdb|clickhouse)
echo "$value"
;;
*)
echo "❌ 不支持的 driver-agent${1:-}" >&2
return 1
;;
esac
}
build_driver_name() {
case "$1" in
doris) echo "diros" ;;
*) echo "$1" ;;
esac
}
declare -a RAW_DRIVERS=()
declare -a DRIVERS=()
IFS=',' read -r -a RAW_DRIVERS <<< "$CHANGED_DRIVER_AGENTS"
for RAW_DRIVER in "${RAW_DRIVERS[@]}"; do
DRIVER="$(normalize_driver_token "$RAW_DRIVER")" || continue
DRIVERS+=("$DRIVER")
done
if [ ${#DRIVERS[@]} -eq 0 ]; then
echo "🧭 没有需要构建的 driver-agent"
exit 0
fi
NORMALIZED_CHANGED_DRIVER_AGENTS="$(IFS=,; echo "${DRIVERS[*]}")"
echo "🧭 归一后的 driver-agent 构建列表:${NORMALIZED_CHANGED_DRIVER_AGENTS}"
IFS=',' read -r -a DRIVERS <<< "$CHANGED_DRIVER_AGENTS"
OUTDIR="drivers/${{ matrix.os_name }}"
mkdir -p "$OUTDIR"
DUCKDB_WINDOWS_LIBRARY_VERSION="v1.4.4"
@@ -682,7 +644,10 @@ jobs:
}
for DRIVER in "${DRIVERS[@]}"; do
BUILD_DRIVER="$(build_driver_name "$DRIVER")"
BUILD_DRIVER="$DRIVER"
if [ "$DRIVER" = "doris" ]; then
BUILD_DRIVER="diros"
fi
if [ "$DRIVER" = "duckdb" ] && [ "$GOOS" = "windows" ] && [ "$GOARCH" != "amd64" ]; then
echo "⚠️ 跳过 DuckDB driver当前平台 ${GOOS}/${GOARCH} 不受支持,仅支持 windows/amd64"
continue
@@ -777,7 +742,7 @@ jobs:
bash ./tools/verify-driver-agent-revisions.sh \
--assets-dir drivers \
--platform "$TARGET_PLATFORM" \
--drivers "$NORMALIZED_CHANGED_DRIVER_AGENTS"
--drivers "$CHANGED_DRIVER_AGENTS"
# macOS Packaging
- name: Package macOS DMG

View File

@@ -535,45 +535,7 @@ jobs:
export GOCACHE="${RUNNER_TEMP}/go-build-${{ matrix.os_name }}-${{ matrix.arch_name }}-${REVISION_HASH}"
mkdir -p "$GOCACHE"
echo "🧭 可选驱动使用隔离 GOCACHE$GOCACHE"
normalize_driver_token() {
local value
value="$(printf '%s' "${1:-}" | tr '[:upper:]' '[:lower:]' | tr -d '[:space:]')"
case "$value" in
"") return 1 ;;
doris|diros) echo "doris" ;;
open_gauss|open-gauss) echo "opengauss" ;;
gaussdb|gauss_db|gauss-db) echo "gaussdb" ;;
elastic|elasticsearch) echo "elasticsearch" ;;
mariadb|oceanbase|starrocks|sphinx|sqlserver|sqlite|duckdb|dameng|kingbase|highgo|vastbase|opengauss|gaussdb|iris|mongodb|tdengine|iotdb|clickhouse)
echo "$value"
;;
*)
echo "❌ 不支持的 driver-agent${1:-}" >&2
return 1
;;
esac
}
build_driver_name() {
case "$1" in
doris) echo "diros" ;;
*) echo "$1" ;;
esac
}
declare -a RAW_DRIVERS=()
declare -a DRIVERS=()
IFS=',' read -r -a RAW_DRIVERS <<< "$CHANGED_DRIVER_AGENTS"
for RAW_DRIVER in "${RAW_DRIVERS[@]}"; do
DRIVER="$(normalize_driver_token "$RAW_DRIVER")" || continue
DRIVERS+=("$DRIVER")
done
if [ ${#DRIVERS[@]} -eq 0 ]; then
echo "🧭 没有需要构建的 driver-agent"
exit 0
fi
NORMALIZED_CHANGED_DRIVER_AGENTS="$(IFS=,; echo "${DRIVERS[*]}")"
echo "🧭 归一后的 driver-agent 构建列表:${NORMALIZED_CHANGED_DRIVER_AGENTS}"
IFS=',' read -r -a DRIVERS <<< "$CHANGED_DRIVER_AGENTS"
OUTDIR="drivers/${{ matrix.os_name }}"
mkdir -p "$OUTDIR"
DUCKDB_WINDOWS_LIBRARY_VERSION="v1.4.4"
@@ -629,7 +591,10 @@ jobs:
}
for DRIVER in "${DRIVERS[@]}"; do
BUILD_DRIVER="$(build_driver_name "$DRIVER")"
BUILD_DRIVER="$DRIVER"
if [ "$DRIVER" = "doris" ]; then
BUILD_DRIVER="diros"
fi
if [ "$DRIVER" = "duckdb" ] && [ "$GOOS" = "windows" ] && [ "$GOARCH" != "amd64" ]; then
echo "⚠️ 跳过 DuckDB driver当前平台 ${GOOS}/${GOARCH} 不受支持,仅支持 windows/amd64"
continue
@@ -724,7 +689,7 @@ jobs:
bash ./tools/verify-driver-agent-revisions.sh \
--assets-dir drivers \
--platform "$TARGET_PLATFORM" \
--drivers "$NORMALIZED_CHANGED_DRIVER_AGENTS"
--drivers "$CHANGED_DRIVER_AGENTS"
# macOS Packaging
- name: Package macOS DMG

View File

@@ -2463,33 +2463,6 @@ describe('DataGrid layout', () => {
expect(css).toContain('user-select: text !important;');
});
it('wires data-view column header filters through the existing filter state', () => {
const source = readDataGridSource();
const dataViewerSource = readDataViewerSource();
const filterHookSource = readFileSync(new URL('./useDataGridFilters.tsx', import.meta.url), 'utf8');
const columnTitleSource = readFileSync(new URL('./DataGridColumnTitle.tsx', import.meta.url), 'utf8');
expect(filterHookSource).toContain('export type GridColumnFilterDraft');
expect(filterHookSource).toContain('const applyColumnFilter = React.useCallback');
expect(filterHookSource).toContain('onApplyFilter(nextConditions)');
expect(source).toContain("const columnHeaderFilterEnabled = exportScope === 'table' && !!onApplyFilter;");
expect(source).toContain("filterOpOptions.filter((option) => option.value !== 'CUSTOM')");
expect(source).toContain("eventTarget?.closest?.('[data-grid-column-filter-trigger=\"true\"]')");
expect(source).toContain("eventTarget?.closest?.('[data-grid-column-filter-popover=\"true\"]')");
expect(source).toContain("eventTarget?.closest?.('.ant-select-dropdown')");
expect(source).toContain('onApply: (draft) => applyColumnHeaderFilter(normalizedName, draft)');
expect(source).toContain('onClear: () => clearColumnFilter(normalizedName)');
expect(dataViewerSource).toContain('skipNextAutoFetchRef.current = false;');
expect(dataViewerSource).toContain('setFilterConditions(normalizeViewerFilterConditions(conditions));');
expect(columnTitleSource).toContain('data-grid-column-filter-trigger="true"');
expect(columnTitleSource).toContain('const submitColumnFilter = (event?: React.SyntheticEvent<HTMLElement>) => {');
expect(columnTitleSource).toContain('onClick={submitColumnFilter}');
expect(columnTitleSource).toContain('onPressEnter={submitColumnFilter}');
expect(columnTitleSource).toContain('getPopupContainer={(triggerNode) => triggerNode.parentElement || document.body}');
expect(columnTitleSource).toContain('data-grid-column-filter-active={columnFilter.active ?');
expect(columnTitleSource).toContain('data-grid-column-filter-popover="true"');
});
it('keeps DataGrid scroll synchronization throttled to animation frames', () => {
const source = readDataGridSource();
const secondaryActionsSource = readFileSync(new URL('./DataGridSecondaryActions.tsx', import.meta.url), 'utf8');

View File

@@ -124,7 +124,7 @@ import {
type V2CellContextMenuActionKey,
type V2ColumnHeaderContextMenuActionKey,
} from './V2TableContextMenu';
import DataGridColumnTitle, { type DataGridColumnFilterDraft } from './DataGridColumnTitle';
import DataGridColumnTitle from './DataGridColumnTitle';
import DataGridColumnInfoPopoverContent from './DataGridColumnInfoPopoverContent';
import DataGridColumnQuickFind from './DataGridColumnQuickFind';
import DataGridPageFind from './DataGridPageFind';
@@ -1041,6 +1041,29 @@ const DataGrid: React.FC<DataGridProps> = ({
openTableByName(String(target?.refTableName || '').trim());
}, [openTableByName]);
const renderColumnTitle = useCallback((name: string): React.ReactNode => {
const normalizedName = String(name || '');
const meta = columnMetaMap[normalizedName] || columnMetaMapByLowerName[normalizedName.toLowerCase()];
const foreignKeyTarget = foreignKeyMap[normalizedName] || foreignKeyMapByLowerName[normalizedName.toLowerCase()];
return (
<DataGridColumnTitle
columnName={normalizedName}
columnMeta={meta}
foreignKeyTarget={foreignKeyTarget}
showColumnType={showColumnType}
showColumnComment={showColumnComment}
metaFontSize={densityParams.metaFontSize}
columnMetaHintColor={columnMetaHintColor}
columnMetaTooltipColor={columnMetaTooltipColor}
darkMode={darkMode}
highlighted={highlightedColumnName === normalizedName}
translate={translateDataGrid}
onOpenForeignKey={foreignKeyTarget ? () => openForeignKeyTarget(foreignKeyTarget) : undefined}
/>
);
}, [columnMetaHintColor, columnMetaTooltipColor, columnMetaMap, columnMetaMapByLowerName, darkMode, densityParams.metaFontSize, foreignKeyMap, foreignKeyMapByLowerName, highlightedColumnName, openForeignKeyTarget, showColumnComment, showColumnType, translateDataGrid]);
const lockVirtualInlineTableScroll = useCallback((lock: boolean) => {
if (lock) {
if (virtualInlineScrollLockRef.current) {
@@ -1238,8 +1261,6 @@ const DataGrid: React.FC<DataGridProps> = ({
addFilter,
updateFilter,
removeFilter,
applyColumnFilter,
clearColumnFilter,
applyQuickWhereCondition,
clearQuickWhereCondition,
clearAllFiltersAndSorts,
@@ -1269,102 +1290,6 @@ const DataGrid: React.FC<DataGridProps> = ({
resolveNextGridFilterOperatorForColumnChange,
});
const columnHeaderFilterEnabled = exportScope === 'table' && !!onApplyFilter;
const columnHeaderFilterOpOptions = useMemo(
() => filterOpOptions.filter((option) => option.value !== 'CUSTOM'),
[filterOpOptions],
);
const getColumnHeaderFilterState = useCallback((columnName: string) => {
const normalizedName = String(columnName || '').trim();
const columnFilterConditions = filterConditions.filter((cond) => (
String(cond?.column || '') === normalizedName && String(cond?.op || '') !== 'CUSTOM'
));
const firstCondition = columnFilterConditions[0];
const defaultOperator = resolveDefaultGridFilterOperator(getColumnFilterType(normalizedName));
return {
active: columnFilterConditions.some((cond) => cond.enabled !== false),
defaultOperator,
initialOperator: String(firstCondition?.op || defaultOperator),
initialValue: String(firstCondition?.value ?? ''),
initialValue2: String(firstCondition?.value2 ?? ''),
};
}, [filterConditions, getColumnFilterType]);
const applyColumnHeaderFilter = useCallback((columnName: string, draft: DataGridColumnFilterDraft) => {
return applyColumnFilter({
column: columnName,
op: draft.op,
value: draft.value,
value2: draft.value2,
});
}, [applyColumnFilter]);
const renderColumnTitle = useCallback((name: string): React.ReactNode => {
const normalizedName = String(name || '');
const meta = columnMetaMap[normalizedName] || columnMetaMapByLowerName[normalizedName.toLowerCase()];
const foreignKeyTarget = foreignKeyMap[normalizedName] || foreignKeyMapByLowerName[normalizedName.toLowerCase()];
const columnFilterState = columnHeaderFilterEnabled ? getColumnHeaderFilterState(normalizedName) : null;
return (
<DataGridColumnTitle
columnName={normalizedName}
columnMeta={meta}
foreignKeyTarget={foreignKeyTarget}
showColumnType={showColumnType}
showColumnComment={showColumnComment}
metaFontSize={densityParams.metaFontSize}
columnMetaHintColor={columnMetaHintColor}
columnMetaTooltipColor={columnMetaTooltipColor}
darkMode={darkMode}
highlighted={highlightedColumnName === normalizedName}
translate={translateDataGrid}
onOpenForeignKey={foreignKeyTarget ? () => openForeignKeyTarget(foreignKeyTarget) : undefined}
columnFilter={columnFilterState ? {
active: columnFilterState.active,
operatorOptions: columnHeaderFilterOpOptions,
defaultOperator: columnFilterState.defaultOperator,
initialOperator: columnFilterState.initialOperator,
initialValue: columnFilterState.initialValue,
initialValue2: columnFilterState.initialValue2,
filterLabel: translateDataGrid('data_grid.toolbar.filter'),
applyLabel: translateDataGrid('data_grid.filter.apply'),
clearLabel: translateDataGrid('data_grid.filter.clear'),
valuePlaceholder: translateDataGrid('data_grid.filter.start_value_placeholder'),
secondValuePlaceholder: translateDataGrid('data_grid.filter.end_value_placeholder'),
listValuePlaceholder: translateDataGrid('data_grid.filter.list_values_placeholder'),
noValuePlaceholder: translateDataGrid('data_grid.filter.no_value_placeholder'),
isNoValueOp,
isBetweenOp,
isListOp,
onApply: (draft) => applyColumnHeaderFilter(normalizedName, draft),
onClear: () => clearColumnFilter(normalizedName),
} : null}
/>
);
}, [
applyColumnHeaderFilter,
clearColumnFilter,
columnHeaderFilterEnabled,
columnHeaderFilterOpOptions,
columnMetaHintColor,
columnMetaTooltipColor,
columnMetaMap,
columnMetaMapByLowerName,
darkMode,
densityParams.metaFontSize,
foreignKeyMap,
foreignKeyMapByLowerName,
getColumnHeaderFilterState,
highlightedColumnName,
isBetweenOp,
isListOp,
isNoValueOp,
openForeignKeyTarget,
showColumnComment,
showColumnType,
translateDataGrid,
]);
const selectedRowKeysRef = useRef(selectedRowKeys);
const displayDataRef = useRef<any[]>([]);
@@ -2478,9 +2403,6 @@ const DataGrid: React.FC<DataGridProps> = ({
if (!onSort) return;
const eventTarget = event.target as HTMLElement | null;
if (eventTarget?.closest?.('[data-grid-fk-jump="true"]')) return;
if (eventTarget?.closest?.('[data-grid-column-filter-trigger="true"]')) return;
if (eventTarget?.closest?.('[data-grid-column-filter-popover="true"]')) return;
if (eventTarget?.closest?.('.ant-select-dropdown')) return;
const headerCell = event.currentTarget as HTMLElement;
const upArrow = headerCell.querySelector('.ant-table-column-sorter-up') as HTMLElement | null;
const downArrow = headerCell.querySelector('.ant-table-column-sorter-down') as HTMLElement | null;

View File

@@ -1,40 +1,10 @@
import React from 'react';
import { act, create } from 'react-test-renderer';
import { renderToStaticMarkup } from 'react-dom/server';
import { describe, expect, it, vi } from 'vitest';
import DataGridColumnTitle from './DataGridColumnTitle';
vi.mock('antd', () => ({
Button: ({ children, type: buttonType, htmlType, ...props }: { children?: React.ReactNode; type?: string; htmlType?: 'button' | 'submit' | 'reset' }) => (
<button type={htmlType || 'button'} data-button-type={buttonType} {...props}>
{children}
</button>
),
Form: ({ children, component: Component = 'form', onFinish: _onFinish, ...props }: { children?: React.ReactNode; component?: React.ElementType; onFinish?: () => void }) => (
<Component {...props}>{children}</Component>
),
Input: Object.assign(
({ onPressEnter: _onPressEnter, ...props }: { onPressEnter?: () => void }) => <input {...props} />,
{
TextArea: ({ autoSize: _autoSize, ...props }: { autoSize?: unknown }) => <textarea {...props} />,
},
),
Popover: ({ children, content, open }: { children: React.ReactNode; content?: React.ReactNode; open?: boolean }) => (
<span data-popover-open={open ? 'true' : 'false'}>
{content}
{children}
</span>
),
Select: ({ options = [], value, onChange }: { options?: Array<{ value: string; label: string }>; value?: string; onChange?: (value: string) => void }) => (
<select value={value} onChange={(event) => onChange?.(event.target.value)}>
{options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
),
Tooltip: ({ children, title, rootClassName }: { children: React.ReactNode; title?: React.ReactNode; rootClassName?: string }) => (
<>
<div data-testid="tooltip-title">{title}</div>
@@ -44,11 +14,6 @@ vi.mock('antd', () => ({
),
}));
vi.mock('@ant-design/icons', () => ({
FilterOutlined: () => <span data-icon="filter" />,
LinkOutlined: () => <span data-icon="link" />,
}));
describe('DataGridColumnTitle', () => {
it('marks v2 table headers as single-line when column type and comment rows are hidden', () => {
const markup = renderToStaticMarkup(
@@ -146,170 +111,6 @@ describe('DataGridColumnTitle', () => {
expect(markup).toContain('data-ref-table-name="customers"');
});
it('renders a compact column filter trigger with active state', () => {
const markup = renderToStaticMarkup(
<DataGridColumnTitle
columnName="status"
showColumnType={false}
showColumnComment={false}
metaFontSize={11}
columnMetaHintColor="#999"
columnMetaTooltipColor="#fff"
darkMode={false}
columnFilter={{
active: true,
operatorOptions: [
{ value: '=', label: '=' },
{ value: 'CONTAINS', label: 'Contains' },
],
defaultOperator: 'CONTAINS',
initialOperator: '=',
initialValue: 'active',
filterLabel: 'Filter',
applyLabel: 'Apply',
clearLabel: 'Clear',
valuePlaceholder: 'Value',
secondValuePlaceholder: 'End value',
listValuePlaceholder: 'List values',
noValuePlaceholder: 'No value needed',
isNoValueOp: () => false,
isBetweenOp: () => false,
isListOp: () => false,
onApply: () => true,
onClear: () => true,
}}
/>,
);
expect(markup).toContain('class="gn-v2-column-title-shell"');
expect(markup).toContain('data-grid-column-filter-trigger="true"');
expect(markup).toContain('data-grid-column-filter-active="true"');
expect(markup).toContain('data-grid-column-filter-popover="true"');
expect(markup).toContain('Filter status');
expect(markup).toContain('value="active"');
});
it('applies the column filter from the popover action button', () => {
const onApply = vi.fn(() => true);
const renderer = create(
<DataGridColumnTitle
columnName="code"
showColumnType={false}
showColumnComment={false}
metaFontSize={11}
columnMetaHintColor="#999"
columnMetaTooltipColor="#fff"
darkMode={false}
columnFilter={{
active: false,
operatorOptions: [
{ value: 'CONTAINS', label: 'Contains' },
],
defaultOperator: 'CONTAINS',
initialOperator: 'CONTAINS',
initialValue: '3551',
filterLabel: 'Filter',
applyLabel: 'Apply',
clearLabel: 'Clear',
valuePlaceholder: 'Value',
secondValuePlaceholder: 'End value',
listValuePlaceholder: 'List values',
noValuePlaceholder: 'No value needed',
isNoValueOp: () => false,
isBetweenOp: () => false,
isListOp: () => false,
onApply,
onClear: () => true,
}}
/>,
);
const applyButton = renderer.root
.findAllByType('button')
.find((button) => button.children.includes('Apply'));
expect(applyButton).toBeTruthy();
act(() => {
applyButton!.props.onClick({
preventDefault: vi.fn(),
stopPropagation: vi.fn(),
});
});
expect(onApply).toHaveBeenCalledWith({
op: 'CONTAINS',
value: '3551',
value2: '',
});
});
it('keeps column filter operator switching and clearing interactive', () => {
const onApply = vi.fn(() => true);
const onClear = vi.fn(() => true);
const renderer = create(
<DataGridColumnTitle
columnName="title"
showColumnType={false}
showColumnComment={false}
metaFontSize={11}
columnMetaHintColor="#999"
columnMetaTooltipColor="#fff"
darkMode={false}
columnFilter={{
active: true,
operatorOptions: [
{ value: '=', label: '=' },
{ value: 'CONTAINS', label: 'Contains' },
],
defaultOperator: 'CONTAINS',
initialOperator: 'CONTAINS',
initialValue: '3551',
filterLabel: 'Filter',
applyLabel: 'Apply',
clearLabel: 'Clear',
valuePlaceholder: 'Value',
secondValuePlaceholder: 'End value',
listValuePlaceholder: 'List values',
noValuePlaceholder: 'No value needed',
isNoValueOp: () => false,
isBetweenOp: () => false,
isListOp: () => false,
onApply,
onClear,
}}
/>,
);
const operatorSelect = renderer.root.findByType('select');
act(() => {
operatorSelect.props.onChange({ target: { value: '=' } });
});
const buttons = renderer.root.findAllByType('button');
const clearButton = buttons.find((button) => button.children.includes('Clear'));
const applyButton = buttons.find((button) => button.children.includes('Apply'));
expect(clearButton).toBeTruthy();
expect(applyButton).toBeTruthy();
act(() => {
applyButton!.props.onClick({
preventDefault: vi.fn(),
stopPropagation: vi.fn(),
});
});
expect(onApply).toHaveBeenLastCalledWith({
op: '=',
value: '3551',
value2: '',
});
act(() => {
clearButton!.props.onClick();
});
expect(onClear).toHaveBeenCalledTimes(1);
});
it('uses translated tooltip wrappers while preserving raw metadata values', () => {
const translate = vi.fn((key: string, params?: Record<string, unknown>) => {
if (key === 'data_grid.column.type_tooltip') return `TYPE ${String(params?.type)}`;

View File

@@ -1,37 +1,10 @@
import React from 'react';
import { Button, Input, Popover, Select, Tooltip } from 'antd';
import { FilterOutlined, LinkOutlined } from '@ant-design/icons';
import { Tooltip } from 'antd';
import { LinkOutlined } from '@ant-design/icons';
import { t as defaultTranslate, type I18nParams } from '../i18n';
export type DataGridColumnTitleTranslate = (key: string, params?: I18nParams) => string;
export type DataGridColumnFilterDraft = {
op: string;
value: string;
value2?: string;
};
export interface DataGridColumnFilterConfig {
active: boolean;
operatorOptions: Array<{ value: string; label: string }>;
defaultOperator: string;
initialOperator?: string;
initialValue?: string;
initialValue2?: string;
filterLabel: string;
applyLabel: string;
clearLabel: string;
valuePlaceholder: string;
secondValuePlaceholder: string;
listValuePlaceholder: string;
noValuePlaceholder: string;
isNoValueOp: (op: string) => boolean;
isBetweenOp: (op: string) => boolean;
isListOp: (op: string) => boolean;
onApply: (draft: DataGridColumnFilterDraft) => boolean | void;
onClear: () => boolean | void;
}
export interface DataGridColumnTitleProps {
columnName: string;
columnMeta?: {
@@ -51,13 +24,8 @@ export interface DataGridColumnTitleProps {
highlighted?: boolean;
translate?: DataGridColumnTitleTranslate;
onOpenForeignKey?: () => void;
columnFilter?: DataGridColumnFilterConfig | null;
}
const stopColumnHeaderInteraction = (event: React.SyntheticEvent<HTMLElement>) => {
event.stopPropagation();
};
const DataGridColumnTitle: React.FC<DataGridColumnTitleProps> = ({
columnName,
columnMeta,
@@ -71,7 +39,6 @@ const DataGridColumnTitle: React.FC<DataGridColumnTitleProps> = ({
highlighted = false,
translate = defaultTranslate,
onOpenForeignKey,
columnFilter,
}) => {
const normalizedName = String(columnName || '');
const columnType = String(columnMeta?.type || '').trim();
@@ -81,24 +48,6 @@ const DataGridColumnTitle: React.FC<DataGridColumnTitleProps> = ({
const shouldShowColumnType = showColumnType && columnType.length > 0;
const shouldShowColumnComment = showColumnComment && columnComment.length > 0;
const isSingleLineColumnTitle = !shouldShowColumnType && !shouldShowColumnComment;
const [filterPopoverOpen, setFilterPopoverOpen] = React.useState(false);
const initialFilterOperator = columnFilter?.initialOperator || columnFilter?.defaultOperator || '=';
const [draftFilterOperator, setDraftFilterOperator] = React.useState(initialFilterOperator);
const [draftFilterValue, setDraftFilterValue] = React.useState(columnFilter?.initialValue || '');
const [draftFilterValue2, setDraftFilterValue2] = React.useState(columnFilter?.initialValue2 || '');
React.useEffect(() => {
if (!filterPopoverOpen || !columnFilter) return;
setDraftFilterOperator(columnFilter.initialOperator || columnFilter.defaultOperator || '=');
setDraftFilterValue(columnFilter.initialValue || '');
setDraftFilterValue2(columnFilter.initialValue2 || '');
}, [
columnFilter?.defaultOperator,
columnFilter?.initialOperator,
columnFilter?.initialValue,
columnFilter?.initialValue2,
filterPopoverOpen,
]);
const hoverLines: string[] = [];
if (columnType) hoverLines.push(translate('data_grid.column.type_tooltip', { type: columnType }));
@@ -202,227 +151,35 @@ const DataGridColumnTitle: React.FC<DataGridColumnTitleProps> = ({
</div>
);
const titleWithOptionalTooltip = (() => {
if (hoverLines.length === 0) {
return titleNode;
}
const tooltipTextColor = darkMode ? columnMetaTooltipColor : 'var(--gn-fg-1, #fff)';
return (
<Tooltip
title={(
<pre
className="gn-data-grid-column-meta-tooltip-content"
style={{
maxHeight: 260,
overflow: 'auto',
margin: 0,
fontSize: 12,
whiteSpace: 'pre-wrap',
color: tooltipTextColor,
}}
>
{hoverLines.join('\n')}
</pre>
)}
rootClassName="gn-data-grid-column-meta-tooltip"
styles={{ root: { maxWidth: 640 } }}
{...(!darkMode ? { color: 'rgba(0, 0, 0, 0.82)' } : {})}
>
<span style={{ display: 'inline-flex', maxWidth: '100%' }}>{titleNode}</span>
</Tooltip>
);
})();
if (!columnFilter) {
return titleWithOptionalTooltip;
if (hoverLines.length === 0) {
return titleNode;
}
const noValueOperator = columnFilter.isNoValueOp(draftFilterOperator);
const betweenOperator = columnFilter.isBetweenOp(draftFilterOperator);
const listOperator = columnFilter.isListOp(draftFilterOperator);
const activeColor = darkMode ? '#74d99f' : '#16a34a';
const mutedColor = columnFilter.active
? activeColor
: (darkMode ? 'rgba(255,255,255,0.52)' : 'rgba(15, 23, 42, 0.46)');
const filterButtonTitle = `${columnFilter.filterLabel} ${normalizedName}`;
const submitColumnFilter = (event?: React.SyntheticEvent<HTMLElement>) => {
event?.preventDefault();
event?.stopPropagation();
const applied = columnFilter.onApply({
op: draftFilterOperator,
value: draftFilterValue,
value2: draftFilterValue2,
});
if (applied !== false) setFilterPopoverOpen(false);
};
const filterPopoverContent = (
<div
data-grid-column-filter-popover="true"
onClick={stopColumnHeaderInteraction}
style={{
width: 260,
display: 'flex',
flexDirection: 'column',
gap: 8,
}}
>
<div
style={{
minWidth: 0,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
fontWeight: 600,
color: darkMode ? 'rgba(255,255,255,0.88)' : 'rgba(15,23,42,0.88)',
}}
title={normalizedName}
>
{filterButtonTitle}
</div>
<Select
size="small"
value={draftFilterOperator}
options={columnFilter.operatorOptions}
popupMatchSelectWidth={false}
getPopupContainer={(triggerNode) => triggerNode.parentElement || document.body}
onChange={(value) => {
const nextOperator = String(value || columnFilter.defaultOperator || '=');
setDraftFilterOperator(nextOperator);
if (columnFilter.isNoValueOp(nextOperator)) {
setDraftFilterValue('');
setDraftFilterValue2('');
} else if (!columnFilter.isBetweenOp(nextOperator)) {
setDraftFilterValue2('');
}
}}
/>
{noValueOperator ? (
<Input
size="small"
disabled
value={columnFilter.noValuePlaceholder}
/>
) : listOperator ? (
<Input.TextArea
value={draftFilterValue}
placeholder={columnFilter.listValuePlaceholder}
autoSize={{ minRows: 2, maxRows: 4 }}
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
onChange={(event) => setDraftFilterValue(event.target.value)}
/>
) : betweenOperator ? (
<div style={{ display: 'flex', gap: 8 }}>
<Input
size="small"
value={draftFilterValue}
placeholder={columnFilter.valuePlaceholder}
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
onPressEnter={submitColumnFilter}
onChange={(event) => setDraftFilterValue(event.target.value)}
/>
<Input
size="small"
value={draftFilterValue2}
placeholder={columnFilter.secondValuePlaceholder}
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
onPressEnter={submitColumnFilter}
onChange={(event) => setDraftFilterValue2(event.target.value)}
/>
</div>
) : (
<Input
size="small"
value={draftFilterValue}
placeholder={columnFilter.valuePlaceholder}
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
onPressEnter={submitColumnFilter}
onChange={(event) => setDraftFilterValue(event.target.value)}
/>
)}
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
<Button
size="small"
onClick={() => {
const cleared = columnFilter.onClear();
if (cleared !== false) setFilterPopoverOpen(false);
}}
>
{columnFilter.clearLabel}
</Button>
<Button
type="primary"
size="small"
onClick={submitColumnFilter}
>
{columnFilter.applyLabel}
</Button>
</div>
</div>
);
const tooltipTextColor = darkMode ? columnMetaTooltipColor : 'var(--gn-fg-1, #fff)';
return (
<span
className="gn-v2-column-title-shell"
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 4,
maxWidth: '100%',
minWidth: 0,
}}
>
<span style={{ display: 'inline-flex', minWidth: 0, maxWidth: 'calc(100% - 24px)' }}>
{titleWithOptionalTooltip}
</span>
<Popover
trigger="click"
placement="bottomLeft"
open={filterPopoverOpen}
onOpenChange={setFilterPopoverOpen}
content={filterPopoverContent}
>
<button
type="button"
data-grid-column-filter-trigger="true"
data-grid-column-filter-active={columnFilter.active ? 'true' : undefined}
aria-label={filterButtonTitle}
title={filterButtonTitle}
onClick={(event) => {
event.stopPropagation();
}}
onMouseDown={stopColumnHeaderInteraction}
onPointerDown={stopColumnHeaderInteraction}
<Tooltip
title={(
<pre
className="gn-data-grid-column-meta-tooltip-content"
style={{
width: 22,
height: 22,
flex: '0 0 22px',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
padding: 0,
border: columnFilter.active ? `1px solid ${activeColor}` : '1px solid transparent',
borderRadius: 6,
background: columnFilter.active
? (darkMode ? 'rgba(34, 197, 94, 0.14)' : 'rgba(34, 197, 94, 0.12)')
: 'transparent',
color: mutedColor,
cursor: 'pointer',
maxHeight: 260,
overflow: 'auto',
margin: 0,
fontSize: 12,
whiteSpace: 'pre-wrap',
color: tooltipTextColor,
}}
>
<FilterOutlined style={{ fontSize: 12 }} />
</button>
</Popover>
</span>
{hoverLines.join('\n')}
</pre>
)}
rootClassName="gn-data-grid-column-meta-tooltip"
styles={{ root: { maxWidth: 640 } }}
{...(!darkMode ? { color: 'rgba(0, 0, 0, 0.82)' } : {})}
>
<span style={{ display: 'inline-flex', maxWidth: '100%' }}>{titleNode}</span>
</Tooltip>
);
};

View File

@@ -438,48 +438,6 @@ describe('DataViewer safe editing locator', () => {
renderer.unmount();
});
it('requeries table preview when a column header filter is applied', async () => {
storeState.connections[0].config.type = 'mysql';
storeState.connections[0].config.database = 'missav_bot';
backendApp.DBGetColumns.mockResolvedValue({
success: true,
data: [{ name: 'id', key: 'PRI' }, { name: 'code', key: '' }],
});
backendApp.DBQuery.mockResolvedValue({
success: true,
fields: ['id', 'code'],
data: [{ id: 2, code: 'EROFV-3551' }],
});
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(<DataViewer tab={createTab({ dbName: 'missav_bot', tableName: 'videos', title: 'videos' })} />);
});
await flushPromises();
backendApp.DBQuery.mockClear();
await act(async () => {
dataGridState.latestProps?.onApplyFilter([{
id: 1,
enabled: true,
logic: 'AND',
column: 'code',
op: 'CONTAINS',
value: '3551',
value2: '',
}]);
await Promise.resolve();
await Promise.resolve();
});
await flushPromises();
const filteredSelectSql = backendApp.DBQuery.mock.calls
.map((call: any[]) => String(call[2] || ''))
.find((sql: string) => /select\s+\*\s+from\s+`videos`/i.test(sql) && /where/i.test(sql));
expect(filteredSelectSql).toContain("`code` LIKE '%3551%'");
renderer!.unmount();
});
it('keeps DuckDB table preview writable when primary key metadata arrives for a qualified table name', async () => {
storeState.connections[0].config.type = 'duckdb';
storeState.connections[0].config.database = 'main';

View File

@@ -1151,17 +1151,7 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = React.memo(({
}, []);
const handlePageChange = useCallback((page: number, size: number) => fetchData(page, size), [fetchData]);
const handleToggleFilter = useCallback(() => setShowFilter(prev => !prev), []);
const handleApplyFilter = useCallback((conditions: FilterCondition[]) => {
skipNextAutoFetchRef.current = false;
initialLoadRef.current = true;
setPagination(prev => ({
...prev,
current: 1,
totalCountLoading: false,
totalCountCancelled: false,
}));
setFilterConditions(normalizeViewerFilterConditions(conditions));
}, []);
const handleApplyFilter = useCallback((conditions: FilterCondition[]) => setFilterConditions(conditions), []);
const handleApplyQuickWhereCondition = useCallback((condition: string) => {
const normalized = normalizeQuickWhereCondition(condition);
const validation = validateQuickWhereCondition(normalized);
@@ -1169,14 +1159,6 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = React.memo(({
message.error(validation.message);
return;
}
skipNextAutoFetchRef.current = false;
initialLoadRef.current = true;
setPagination(prev => ({
...prev,
current: 1,
totalCountLoading: false,
totalCountCancelled: false,
}));
setQuickWhereCondition(normalized);
}, []);

View File

@@ -1,83 +0,0 @@
import React from 'react';
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import { describe, expect, it, vi } from 'vitest';
import { useDataGridFilters, type UseDataGridFiltersResult } from './useDataGridFilters';
const createFilterHookProps = (
overrides: Partial<Parameters<typeof useDataGridFilters>[0]> = {},
): Parameters<typeof useDataGridFilters>[0] => ({
appliedFilterConditions: [],
quickWhereCondition: '',
showFilter: false,
displayColumnNames: ['id', 'code', 'title'],
allTableColumnNames: ['id', 'code', 'title'],
columnMetaMap: {
id: { type: 'bigint' },
code: { type: 'varchar(50)' },
title: { type: 'varchar(500)' },
},
dbType: 'mysql',
darkMode: false,
getColumnFilterType: (columnName) => {
if (columnName === 'id') return 'bigint';
return 'varchar(255)';
},
resolveDefaultGridFilterOperator: (columnType) => (
String(columnType || '').toLowerCase().includes('char') ? 'CONTAINS' : '='
),
resolveNextGridFilterOperatorForColumnChange: () => 'CONTAINS',
...overrides,
});
describe('useDataGridFilters', () => {
it('syncs column-header filters into the shared toolbar filter state without requiring the filter panel to be open', () => {
const onApplyFilter = vi.fn();
const hookProps = createFilterHookProps({
showFilter: false,
onApplyFilter,
});
let latest: UseDataGridFiltersResult | undefined;
let renderer: ReactTestRenderer | undefined;
const Harness = () => {
latest = useDataGridFilters(hookProps);
return null;
};
act(() => {
renderer = create(<Harness />);
});
expect(latest?.filterConditions).toEqual([]);
act(() => {
expect(latest?.applyColumnFilter({
column: 'code',
op: 'CONTAINS',
value: '3551',
})).toBe(true);
});
expect(latest?.filterConditions).toMatchObject([{
enabled: true,
logic: 'AND',
column: 'code',
op: 'CONTAINS',
value: '3551',
value2: '',
}]);
expect(onApplyFilter).toHaveBeenCalledWith([
expect.objectContaining({
enabled: true,
logic: 'AND',
column: 'code',
op: 'CONTAINS',
value: '3551',
value2: '',
}),
]);
renderer?.unmount();
});
});

View File

@@ -15,13 +15,6 @@ export type GridFilterConditionState = FilterCondition & {
value2?: string;
};
export type GridColumnFilterDraft = {
column: string;
op: string;
value?: string;
value2?: string;
};
type GridSortInfo = {
columnKey: string;
order: string;
@@ -72,8 +65,6 @@ export interface UseDataGridFiltersResult {
addFilter: () => void;
updateFilter: (id: number, field: keyof GridFilterConditionState, val: string | boolean) => void;
removeFilter: (id: number) => void;
applyColumnFilter: (draft: GridColumnFilterDraft) => boolean;
clearColumnFilter: (columnName: string) => boolean;
applyQuickWhereCondition: (condition?: string) => boolean;
clearQuickWhereCondition: () => void;
clearAllFiltersAndSorts: () => void;
@@ -354,76 +345,6 @@ export const useDataGridFilters = ({
if (onApplyFilter) onApplyFilter(filterConditions);
}, [applyQuickWhereCondition, filterConditions, onApplyFilter]);
const applyColumnFilter = React.useCallback((draft: GridColumnFilterDraft): boolean => {
const column = String(draft?.column || '').trim();
if (!column) return false;
if (!applyQuickWhereCondition()) return false;
const existingColumnConditions = filterConditions.filter((cond) => (
String(cond?.column || '') === column && String(cond?.op || '') !== 'CUSTOM'
));
const existingPrimary = existingColumnConditions[0];
const op = String(draft?.op || resolveDefaultGridFilterOperator(getColumnFilterType(column))).trim();
const id = Number.isFinite(Number(existingPrimary?.id)) ? Number(existingPrimary?.id) : nextFilterId;
existingColumnConditions.forEach((cond) => {
autoDefaultFilterIdsRef.current.delete(cond.id);
});
autoDefaultFilterIdsRef.current.delete(id);
const nextCondition: GridFilterConditionState = {
id,
enabled: true,
logic: normalizeFilterLogic(existingPrimary?.logic),
column,
op,
value: isNoValueOp(op) ? '' : String(draft?.value ?? ''),
value2: isNoValueOp(op) || !isBetweenOp(op) ? '' : String(draft?.value2 ?? ''),
};
const nextConditions = [
...filterConditions.filter((cond) => !(
String(cond?.column || '') === column && String(cond?.op || '') !== 'CUSTOM'
)),
nextCondition,
];
setFilterConditions(nextConditions);
if (!existingPrimary) {
setNextFilterId((prev) => Math.max(prev, id + 1));
}
if (onApplyFilter) onApplyFilter(nextConditions);
return true;
}, [
applyQuickWhereCondition,
filterConditions,
getColumnFilterType,
isBetweenOp,
isNoValueOp,
nextFilterId,
normalizeFilterLogic,
onApplyFilter,
resolveDefaultGridFilterOperator,
]);
const clearColumnFilter = React.useCallback((columnName: string): boolean => {
const column = String(columnName || '').trim();
if (!column) return false;
const nextConditions = filterConditions.filter((cond) => !(
String(cond?.column || '') === column && String(cond?.op || '') !== 'CUSTOM'
));
if (nextConditions.length === filterConditions.length) return true;
if (!applyQuickWhereCondition()) return false;
filterConditions.forEach((cond) => {
if (String(cond?.column || '') === column && String(cond?.op || '') !== 'CUSTOM') {
autoDefaultFilterIdsRef.current.delete(cond.id);
}
});
setFilterConditions(nextConditions);
if (onApplyFilter) onApplyFilter(nextConditions);
return true;
}, [applyQuickWhereCondition, filterConditions, onApplyFilter]);
const applyAllFiltersEnabled = React.useCallback(() => {
setFilterConditions((prev) => prev.map((cond) => ({ ...cond, enabled: true })));
}, []);
@@ -451,8 +372,6 @@ export const useDataGridFilters = ({
addFilter,
updateFilter,
removeFilter,
applyColumnFilter,
clearColumnFilter,
applyQuickWhereCondition,
clearQuickWhereCondition,
clearAllFiltersAndSorts,

View File

@@ -140,43 +140,17 @@ agent_variants_for() {
probe_agent_revision() {
local agent_path="$1"
local stdout_file stderr_file probe_exit
local request
request='{"id":1,"method":"metadata"}'
stdout_file="$(mktemp "${TMPDIR:-/tmp}/gonavi-agent-revision-stdout.XXXXXX")"
stderr_file="$(mktemp "${TMPDIR:-/tmp}/gonavi-agent-revision-stderr.XXXXXX")"
if ! printf '%s\n' "$request" | "$agent_path" >"$stdout_file" 2>"$stderr_file"; then
[[ -s "$stderr_file" ]] && sed "s/^/ stderr: /" "$stderr_file" >&2
rm -f "$stdout_file" "$stderr_file"
return 1
fi
python3 - "$stdout_file" <<'PY'
printf '%s\n' "$request" | "$agent_path" | python3 -c '
import json
import sys
from pathlib import Path
lines = [
line.strip()
for line in Path(sys.argv[1]).read_text(encoding="utf-8", errors="replace").splitlines()
if line.strip()
]
if not lines:
raise SystemExit(1)
try:
payload = json.loads(lines[0])
except json.JSONDecodeError:
raise SystemExit(1)
line = sys.stdin.readline()
payload = json.loads(line)
data = payload.get("data") or {}
print(data.get("agentRevision", ""))
PY
probe_exit=$?
if [[ "$probe_exit" -ne 0 && -s "$stderr_file" ]]; then
sed "s/^/ stderr: /" "$stderr_file" >&2
fi
rm -f "$stdout_file" "$stderr_file"
return "$probe_exit"
'
}
probe_host_agent_revision() {