Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
147 changes: 3 additions & 144 deletions src/components/configEditor/QuerySettingsConfig.test.tsx
Original file line number Diff line number Diff line change
@@ -1,157 +1,16 @@
import React from 'react';
import { render, fireEvent } from '@testing-library/react';
import { render } from '@testing-library/react';
import { QuerySettingsConfig } from './QuerySettingsConfig';
import allLabels from 'labels';

describe('QuerySettingsConfig', () => {
it('should render', () => {
const result = render(
<QuerySettingsConfig
connMaxLifetime={'5'}
dialTimeout={'5'}
maxIdleConns={'5'}
maxOpenConns={'5'}
queryTimeout={'5'}
validateSql={true}
onConnMaxIdleConnsChange={() => {}}
onConnMaxLifetimeChange={() => {}}
onConnMaxOpenConnsChange={() => {}}
onDialTimeoutChange={() => {}}
onQueryTimeoutChange={() => {}}
onValidateSqlChange={() => {}}
filterValidationEnabled={true}
onFilterValidationEnabledChange={() => {}}
/>
);
expect(result.container.firstChild).not.toBeNull();
});

it('should call onDialTimeout when changed', () => {
const onDialTimeout = jest.fn();
const result = render(
<QuerySettingsConfig
onConnMaxIdleConnsChange={() => {}}
onConnMaxLifetimeChange={() => {}}
onConnMaxOpenConnsChange={() => {}}
onDialTimeoutChange={onDialTimeout}
onQueryTimeoutChange={() => {}}
onValidateSqlChange={() => {}}
/>
);
expect(result.container.firstChild).not.toBeNull();

const input = result.getByPlaceholderText(allLabels.components.Config.QuerySettingsConfig.dialTimeout.placeholder);
expect(input).toBeInTheDocument();
fireEvent.change(input, { target: { value: '10' } });
fireEvent.blur(input);
expect(onDialTimeout).toHaveBeenCalledTimes(1);
expect(onDialTimeout).toHaveBeenCalledWith(expect.any(Object));
});

it('should call onQueryTimeout when changed', () => {
const onQueryTimeout = jest.fn();
const result = render(
<QuerySettingsConfig
onConnMaxIdleConnsChange={() => {}}
onConnMaxLifetimeChange={() => {}}
onConnMaxOpenConnsChange={() => {}}
onDialTimeoutChange={() => {}}
onQueryTimeoutChange={onQueryTimeout}
onValidateSqlChange={() => {}}
/>
);
expect(result.container.firstChild).not.toBeNull();

const input = result.getByPlaceholderText(allLabels.components.Config.QuerySettingsConfig.queryTimeout.placeholder);
expect(input).toBeInTheDocument();
fireEvent.change(input, { target: { value: '10' } });
fireEvent.blur(input);
expect(onQueryTimeout).toHaveBeenCalledTimes(1);
expect(onQueryTimeout).toHaveBeenCalledWith(expect.any(Object));
});

it('should call onValidateSqlChange when changed', () => {
const onValidateSqlChange = jest.fn();
const result = render(
<QuerySettingsConfig
onConnMaxIdleConnsChange={() => {}}
onConnMaxLifetimeChange={() => {}}
onConnMaxOpenConnsChange={() => {}}
onDialTimeoutChange={() => {}}
onQueryTimeoutChange={() => {}}
onValidateSqlChange={onValidateSqlChange}
/>
);
expect(result.container.firstChild).not.toBeNull();

const input = result.getByRole('checkbox');
expect(input).toBeInTheDocument();
fireEvent.click(input);
expect(onValidateSqlChange).toHaveBeenCalledTimes(1);
expect(onValidateSqlChange).toHaveBeenCalledWith(expect.any(Object));
});

it('should call onConnMaxIdleConnsChange when changed', () => {
const onConnMaxIdleConnsChange = jest.fn();
const result = render(
<QuerySettingsConfig
onConnMaxIdleConnsChange={onConnMaxIdleConnsChange}
onConnMaxLifetimeChange={() => {}}
onConnMaxOpenConnsChange={() => {}}
onDialTimeoutChange={() => {}}
onQueryTimeoutChange={() => {}}
onValidateSqlChange={() => {}}
/>
);
expect(result.container.firstChild).not.toBeNull();

const input = result.getByPlaceholderText(allLabels.components.Config.QuerySettingsConfig.maxIdleConns.placeholder);
expect(input).toBeInTheDocument();
fireEvent.change(input, { target: { value: '10' } });
fireEvent.blur(input);
expect(onConnMaxIdleConnsChange).toHaveBeenCalledTimes(1);
expect(onConnMaxIdleConnsChange).toHaveBeenCalledWith(expect.any(Object));
});

it('should call onConnMaxLifetimeChange when changed', () => {
const onConnMaxLifetimeChange = jest.fn();
const result = render(
<QuerySettingsConfig
onConnMaxIdleConnsChange={() => {}}
onConnMaxLifetimeChange={onConnMaxLifetimeChange}
onConnMaxOpenConnsChange={() => {}}
onDialTimeoutChange={() => {}}
onQueryTimeoutChange={() => {}}
onValidateSqlChange={() => {}}
/>
);
expect(result.container.firstChild).not.toBeNull();

const input = result.getByPlaceholderText(allLabels.components.Config.QuerySettingsConfig.connMaxLifetime.placeholder);
expect(input).toBeInTheDocument();
fireEvent.change(input, { target: { value: '10' } });
fireEvent.blur(input);
expect(onConnMaxLifetimeChange).toHaveBeenCalledTimes(1);
expect(onConnMaxLifetimeChange).toHaveBeenCalledWith(expect.any(Object));
});

it('should call onConnMaxOpenConnsChange when changed', () => {
const onConnMaxOpenConnsChange = jest.fn();
const result = render(
<QuerySettingsConfig
onConnMaxIdleConnsChange={() => {}}
onConnMaxLifetimeChange={() => {}}
onConnMaxOpenConnsChange={onConnMaxOpenConnsChange}
onDialTimeoutChange={() => {}}
onQueryTimeoutChange={() => {}}
onValidateSqlChange={() => {}}
/>
);
expect(result.container.firstChild).not.toBeNull();

const input = result.getByPlaceholderText(allLabels.components.Config.QuerySettingsConfig.maxOpenConns.placeholder);
expect(input).toBeInTheDocument();
fireEvent.change(input, { target: { value: '10' } });
fireEvent.blur(input);
expect(onConnMaxOpenConnsChange).toHaveBeenCalledTimes(1);
expect(onConnMaxOpenConnsChange).toHaveBeenCalledWith(expect.any(Object));
});
});
50 changes: 24 additions & 26 deletions src/components/configEditor/QuerySettingsConfig.tsx
Original file line number Diff line number Diff line change
@@ -1,44 +1,38 @@
import React, { FormEvent } from 'react';
import { Switch, Input, Field } from '@grafana/ui';
import { Switch, Field } from '@grafana/ui';
import { ConfigSection } from 'components/experimental/ConfigSection';
import allLabels from 'labels';

interface QuerySettingsConfigProps {
connMaxLifetime?: string;
dialTimeout?: string;
maxIdleConns?: string;
maxOpenConns?: string;
queryTimeout?: string;
validateSql?: boolean;
onConnMaxIdleConnsChange: (e: FormEvent<HTMLInputElement>) => void;
onConnMaxLifetimeChange: (e: FormEvent<HTMLInputElement>) => void;
onConnMaxOpenConnsChange: (e: FormEvent<HTMLInputElement>) => void;
onDialTimeoutChange: (e: FormEvent<HTMLInputElement>) => void;
onQueryTimeoutChange: (e: FormEvent<HTMLInputElement>) => void;
onValidateSqlChange: (e: FormEvent<HTMLInputElement>) => void;

filterValidationEnabled?: boolean;

onFilterValidationEnabledChange: (e: FormEvent<HTMLInputElement>) => void;
}

export const QuerySettingsConfig = (props: QuerySettingsConfigProps) => {
const {
connMaxLifetime,
dialTimeout,
maxIdleConns,
maxOpenConns,
queryTimeout,
validateSql,
onConnMaxIdleConnsChange,
onConnMaxLifetimeChange,
onConnMaxOpenConnsChange,
onDialTimeoutChange,
onQueryTimeoutChange,
onValidateSqlChange,
// connMaxLifetime,
// dialTimeout,
// maxIdleConns,
// maxOpenConns,
// queryTimeout,
// validateSql,
filterValidationEnabled,
// onConnMaxIdleConnsChange,
// onConnMaxLifetimeChange,
// onConnMaxOpenConnsChange,
// onDialTimeoutChange,
// onQueryTimeoutChange,
// onValidateSqlChange,
onFilterValidationEnabledChange,
} = props;

const labels = allLabels.components.Config.QuerySettingsConfig;

return (
<ConfigSection title={labels.title}>
<Field label={labels.dialTimeout.label} description={labels.dialTimeout.tooltip}>
{/* <Field label={labels.dialTimeout.label} description={labels.dialTimeout.tooltip}>
<Input
name={labels.dialTimeout.name}
width={40}
Expand Down Expand Up @@ -101,6 +95,10 @@ export const QuerySettingsConfig = (props: QuerySettingsConfigProps) => {

<Field label={labels.validateSql.label} description={labels.validateSql.tooltip}>
<Switch className="gf-form" value={validateSql || false} onChange={onValidateSqlChange} role="checkbox" />
</Field> */}

<Field label="Query Builder Filter Validation" description="Enable validation to require at least one non-default time range condition">
<Switch className="gf-form" value={filterValidationEnabled || false} onChange={onFilterValidationEnabledChange} role="checkbox" />
</Field>
</ConfigSection>
);
Expand Down
31 changes: 30 additions & 1 deletion src/components/queryBuilder/FilterEditor.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { useEffect, useState } from 'react';
import { SelectableValue } from '@grafana/data';
import { Button, HorizontalGroup, InlineFormLabel, Input, MultiSelect, RadioButtonGroup, Select } from '@grafana/ui';
import { Filter, FilterOperator, TableColumn, NullFilter } from 'types/queryBuilder';
Expand All @@ -7,6 +7,7 @@ import labels from 'labels';
import { styles } from 'styles';
import { Datasource } from 'data/CHDatasource';
import useUniqueMapKeys from 'hooks/useUniqueMapKeys';
import { validateUserFilters } from './filterValidator';

const boolValues: Array<SelectableValue<boolean>> = [
{ value: true, label: 'True' },
Expand Down Expand Up @@ -418,20 +419,38 @@ export const FiltersEditor = (props: {
}) => {
const { filters = [], onFiltersChange, allColumns: fieldsList = [], datasource, database, table } = props;
const { label, tooltip, addLabel } = labels.components.FilterEditor;

// Add validation state
const [validationError, setValidationError] = useState<string | undefined>();
const isFilterValidationEnabled = datasource.settings.jsonData.filterValidationEnabled || false;
const addFilter = () => {
onFiltersChange([...filters, { ...defaultNewFilter }]);
};

const removeFilter = (index: number) => {
const newFilters = [...filters];
newFilters.splice(index, 1);
onFiltersChange(newFilters);
};

const onFilterChange = (index: number, filter: Filter) => {
const newFilters = [...filters];
newFilters[index] = filter;
onFiltersChange(newFilters);
};

useEffect(() => {
if (!isFilterValidationEnabled) {
return;
}
const validation = validateUserFilters(filters);
if (validation.isValid) {
setValidationError(undefined);
} else {
setValidationError(validation.error);
}
}, [filters, isFilterValidationEnabled]);

return (
<>
{filters.length === 0 && (
Expand All @@ -451,6 +470,9 @@ export const FiltersEditor = (props: {
</Button>
</div>
)}



{filters.map((filter, index) => {
return (
<div className="gf-form" key={index}>
Expand Down Expand Up @@ -489,6 +511,13 @@ export const FiltersEditor = (props: {
</Button>
</div>
)}
{/* Display validation error */}
{validationError && (
<div className="gf-form" style={{ color: 'orange', fontSize: '12px' }}>
<div className={`width-8 ${styles.Common.firstLabel}`}></div>
<div>⚠️ {validationError}</div>
</div>
)}
</>
);
};
52 changes: 52 additions & 0 deletions src/components/queryBuilder/filterValidator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { Filter, FilterOperator, ColumnHint } from 'types/queryBuilder';

export interface FilterValidationResult {
isValid: boolean;
error?: string;
}

/**
* Validates if there's at least one user-added filter (excluding default time range filters)
*/
export const validateUserFilters = (filters: Filter[]): FilterValidationResult => {
// Count user-added filters (excluding default time range filters)
let hasDefaultTimeRangeFilter = false;
const userFilters = filters.filter(filter => {
if (!filter.key && !filter.hint) {
return false;
}

// Exclude default time range filters that are automatically added
const isDefaultTimeRange = filter.hint === ColumnHint.Time
if (!hasDefaultTimeRangeFilter && isDefaultTimeRange) {
return false
}
hasDefaultTimeRangeFilter = true;
return true;
});

if (userFilters.length === 0) {
return {
isValid: false,
error: 'At least one non-default time range condition is required'
};
}

return { isValid: true };
};

/**
* Checks if a specific filter is a default time range filter
*/
export const isDefaultTimeRangeFilter = (filter: Filter): boolean => {
return filter.hint === ColumnHint.Time &&
filter.operator === FilterOperator.WithInGrafanaTimeRange &&
filter.id === 'timeRange';
};

/**
* Gets only user-added filters (excluding default time range filters)
*/
export const getUserFilters = (filters: Filter[]): Filter[] => {
return filters.filter(filter => !isDefaultTimeRangeFilter(filter));
};
Loading