Skip to content

Commit

Permalink
Add Query builder widget (#18314)
Browse files Browse the repository at this point in the history
* add query builder widget

* locales

* add unit tests

* add debounce for on change
  • Loading branch information
karanh37 authored Oct 17, 2024
1 parent 99c7b13 commit 47c993b
Show file tree
Hide file tree
Showing 17 changed files with 318 additions and 2 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ import React, { FC } from 'react';
import { AdvanceSearchProvider } from '../../components/Explore/AdvanceSearchProvider/AdvanceSearchProvider.component';

export const withAdvanceSearch =
(Component: FC) =>
(props: JSX.IntrinsicAttributes & { children?: React.ReactNode }) => {
<P extends Record<string, unknown>>(Component: FC<P>) =>
(props: P) => {
return (
<AdvanceSearchProvider>
<Component {...props} />
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
/*
* Copyright 2024 Collate.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export enum QueryBuilderOutputType {
ELASTICSEARCH = 'elasticsearch',
JSON_LOGIC = 'jsonlogic',
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
/*
* Copyright 2024 Collate.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Registry } from '@rjsf/utils';
import { render, screen } from '@testing-library/react';
import React from 'react';
import { BasicConfig } from 'react-awesome-query-builder';
import AntdConfig from 'react-awesome-query-builder/lib/config/antd';
import QueryBuilderWidget from './QueryBuilderWidget';

const mockOnFocus = jest.fn();
const mockOnBlur = jest.fn();
const mockOnChange = jest.fn();
const baseConfig = AntdConfig as BasicConfig;

jest.mock(
'../../../../../Explore/AdvanceSearchProvider/AdvanceSearchProvider.component',
() => ({
AdvanceSearchProvider: ({ children }: { children: React.ReactNode }) => (
<div data-testid="advance-search-provider-mock">{children}</div>
),
useAdvanceSearch: jest.fn().mockImplementation(() => ({
toggleModal: jest.fn(),
sqlQuery: '',
onResetAllFilters: jest.fn(),
onChangeSearchIndex: jest.fn(),
config: {
...baseConfig,
fields: {},
},
})),
})
);

jest.mock('react-router-dom', () => ({
useLocation: jest.fn(),
}));

const mockProps = {
onFocus: mockOnFocus,
onBlur: mockOnBlur,
onChange: mockOnChange,
registry: {} as Registry,
schema: {
description: 'this is query builder field',
title: 'rules',
format: 'queryBuilder',
entityType: 'table',
},
value: '',
id: 'root/queryBuilder',
label: 'Query Builder',
name: 'queryBuilder',
options: {
enumOptions: [],
},
};

describe('QueryBuilderWidget', () => {
it('should render the query builder', () => {
render(<QueryBuilderWidget {...mockProps} />);
const builder = screen.getByTestId('query-builder-form-field');

expect(builder).toBeInTheDocument();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
/*
* Copyright 2024 Collate.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { InfoCircleOutlined } from '@ant-design/icons';
import { WidgetProps } from '@rjsf/utils';
import { Alert, Button, Col, Typography } from 'antd';
import { t } from 'i18next';
import { debounce, isEmpty, isUndefined } from 'lodash';
import Qs from 'qs';
import React, { FC, useCallback, useEffect, useMemo, useState } from 'react';
import {
Builder,
Config,
ImmutableTree,
Query,
Utils as QbUtils,
} from 'react-awesome-query-builder';
import { getExplorePath } from '../../../../../../constants/constants';
import { EntityType } from '../../../../../../enums/entity.enum';
import { SearchIndex } from '../../../../../../enums/search.enum';
import { searchQuery } from '../../../../../../rest/searchAPI';
import searchClassBase from '../../../../../../utils/SearchClassBase';
import { withAdvanceSearch } from '../../../../../AppRouter/withAdvanceSearch';
import { useAdvanceSearch } from '../../../../../Explore/AdvanceSearchProvider/AdvanceSearchProvider.component';
import './query-builder-widget.less';
import { QueryBuilderOutputType } from './QueryBuilderWidget.interface';

const QueryBuilderWidget: FC<WidgetProps> = ({
onChange,
schema,
value,
...props
}: WidgetProps) => {
const { config, treeInternal, onTreeUpdate, onChangeSearchIndex } =
useAdvanceSearch();
const [searchResults, setSearchResults] = useState<number>(0);
const entityType =
(props.formContext?.entityType ?? props?.entityType) || EntityType.ALL;
const searchIndexMapping = searchClassBase.getEntityTypeSearchIndexMapping();
const searchIndex = searchIndexMapping[entityType as string];
const outputType = props?.outputType ?? QueryBuilderOutputType.ELASTICSEARCH;

const fetchEntityCount = useCallback(
async (queryFilter: Record<string, unknown>) => {
try {
const res = await searchQuery({
query: '',
pageNumber: 0,
pageSize: 0,
queryFilter,
searchIndex: SearchIndex.ALL,
includeDeleted: false,
trackTotalHits: true,
fetchSource: false,
});
setSearchResults(res.hits.total.value ?? 0);
} catch (_) {
// silent fail
}
},
[]
);

const debouncedFetchEntityCount = useMemo(
() => debounce(fetchEntityCount, 300),
[fetchEntityCount]
);

const queryURL = useMemo(() => {
const queryFilterString = !isEmpty(treeInternal)
? Qs.stringify({ queryFilter: JSON.stringify(treeInternal) })
: '';

return `${getExplorePath({})}${queryFilterString}`;
}, [treeInternal]);

const handleChange = (nTree: ImmutableTree, nConfig: Config) => {
onTreeUpdate(nTree, nConfig);

if (outputType === QueryBuilderOutputType.ELASTICSEARCH) {
const data = QbUtils.elasticSearchFormat(nTree, config) ?? {};
const qFilter = {
query: data,
};
if (data) {
debouncedFetchEntityCount(qFilter);
}

onChange(JSON.stringify(qFilter));
} else {
const data = QbUtils.jsonLogicFormat(nTree, config);
onChange(JSON.stringify(data.logic ?? '{}'));
}
};

useEffect(() => {
onChangeSearchIndex(searchIndex);
}, [searchIndex]);

return (
<div
className="query-builder-form-field"
data-testid="query-builder-form-field">
<Query
{...config}
renderBuilder={(props) => (
<div className="query-builder-container query-builder qb-lite">
<Builder {...props} />
</div>
)}
value={treeInternal}
onChange={handleChange}
/>
{outputType === QueryBuilderOutputType.ELASTICSEARCH &&
!isUndefined(value) && (
<Col span={24}>
<Button
className="w-full p-0 text-left"
data-testid="view-assets-banner-button"
disabled={false}
href={queryURL}
target="_blank"
type="link">
<Alert
closable
showIcon
icon={<InfoCircleOutlined height={16} />}
message={
<>
<Typography.Text>
{t('message.search-entity-count', {
count: searchResults,
})}
</Typography.Text>

<Typography.Text className="m-l-sm text-xs text-grey-muted">
{t('message.click-here-to-view-assets-on-explore')}
</Typography.Text>
</>
}
type="info"
/>
</Button>
</Col>
)}
</div>
);
};

export default withAdvanceSearch(QueryBuilderWidget);
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/*
* Copyright 2024 Collate.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
.query-builder-form-field {
.hide--line.one--child {
margin-top: 0;
padding-top: 16px;
}

.group.rule_group {
border: none !important;
padding: 0;

.group--children {
padding-top: 0;
padding-bottom: 0;
margin: 0;
}
}

.group--field {
width: 180px;

.ant-select {
width: 100% !important;
}

label {
font-weight: normal;
margin-bottom: 6px;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { FieldErrorTemplate } from '../Form/JSONSchema/JSONSchemaTemplate/FieldE
import { ObjectFieldTemplate } from '../Form/JSONSchema/JSONSchemaTemplate/ObjectFieldTemplate';
import AsyncSelectWidget from '../Form/JSONSchema/JsonSchemaWidgets/AsyncSelectWidget';
import PasswordWidget from '../Form/JSONSchema/JsonSchemaWidgets/PasswordWidget';
import QueryBuilderWidget from '../Form/JSONSchema/JsonSchemaWidgets/QueryBuilderWidget/QueryBuilderWidget';
import SelectWidget from '../Form/JSONSchema/JsonSchemaWidgets/SelectWidget';
import Loader from '../Loader/Loader';

Expand Down Expand Up @@ -70,6 +71,7 @@ const FormBuilder: FunctionComponent<Props> = forwardRef(
const widgets = {
PasswordWidget: PasswordWidget,
autoComplete: AsyncSelectWidget,
queryBuilder: QueryBuilderWidget,
...(useSelectWidget && { SelectWidget: SelectWidget }),
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1393,6 +1393,7 @@
"can-not-add-widget": "Can not add the widget to this section due to size restrictions.",
"can-you-add-a-description": "Können Sie eine Beschreibung hinzufügen?",
"checkout-service-connectors-doc": "Es gibt viele Konnektoren hier, um Daten von Ihren Diensten zu indizieren. Bitte schauen Sie sich unsere Konnektoren an.",
"click-here-to-view-assets-on-explore": "(Click to view the filtered assets on Explore page.)",
"click-text-to-view-details": "Klicken Sie auf <0>{{text}}</0>, um Details anzuzeigen.",
"closed-this-task": "hat diese Aufgabe geschlossen",
"collaborate-with-other-user": "um mit anderen Benutzern zusammenzuarbeiten.",
Expand Down Expand Up @@ -1782,6 +1783,7 @@
"schedule-for-ingestion-description": "Die Planung kann im stündlichen, täglichen oder wöchentlichen Rhythmus eingerichtet werden. Die Zeitzone ist UTC.",
"scheduled-run-every": "Geplant, alle auszuführen",
"scopes-comma-separated": "Fügen Sie den Wert der Bereiche hinzu, getrennt durch Kommata",
"search-entity-count": "{{count}} assets have been found with this filter.",
"search-for-edge": "Suche nach Pipeline, StoredProcedures",
"search-for-entity-types": "Suche nach Tabellen, Themen, Dashboards, Pipelines, ML-Modellen, Glossar und Tags.",
"search-for-ingestion": "Suche nach Ingestion",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1393,6 +1393,7 @@
"can-not-add-widget": "Can not add the widget to this section due to size restrictions.",
"can-you-add-a-description": "Can you add a description?",
"checkout-service-connectors-doc": "There are a lot of connectors available here to index data from your services. Please checkout our connectors.",
"click-here-to-view-assets-on-explore": "(Click to view the filtered assets on Explore page.)",
"click-text-to-view-details": "Click <0>{{text}}</0> to view details.",
"closed-this-task": "closed this task",
"collaborate-with-other-user": "to collaborate with other users.",
Expand Down Expand Up @@ -1782,6 +1783,7 @@
"schedule-for-ingestion-description": "Scheduling can be set up at an hourly, daily, or weekly cadence. The timezone is in UTC.",
"scheduled-run-every": "Scheduled to run every",
"scopes-comma-separated": "Add the Scopes value, separated by commas",
"search-entity-count": "{{count}} assets have been found with this filter.",
"search-for-edge": "Search for Pipeline, StoredProcedures",
"search-for-entity-types": "Search for Tables, Topics, Dashboards, Pipelines, ML Models, Glossary and Tags.",
"search-for-ingestion": "Search for ingestion",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1393,6 +1393,7 @@
"can-not-add-widget": "No se puede agregar el widget a esta sección debido a restricciones de tamaño.",
"can-you-add-a-description": "¿Puedes agregar una descripción?",
"checkout-service-connectors-doc": "Hay muchos conectores disponibles para ingesta de datos de tus servicios. Por favor, revisa nuestra documentación sobre conectores.",
"click-here-to-view-assets-on-explore": "(Click to view the filtered assets on Explore page.)",
"click-text-to-view-details": "Haz clic en <0>{{text}}</0> para ver detalles.",
"closed-this-task": "cerró esta tarea",
"collaborate-with-other-user": "para colaborar con otros usuarios.",
Expand Down Expand Up @@ -1782,6 +1783,7 @@
"schedule-for-ingestion-description": "La programación se puede configurar en una cadencia horaria, diaria o semanal.",
"scheduled-run-every": "Programado para ejecutarse cada",
"scopes-comma-separated": "Agrega el valor de ámbitos, separados por comas",
"search-entity-count": "{{count}} assets have been found with this filter.",
"search-for-edge": "Buscar Pipeline, StoredProcedures",
"search-for-entity-types": "Buscar Tablas, Temas, Paneles, Pipelines, Modelos de ML, Glosarios y Etiquetas.",
"search-for-ingestion": "Buscar orígenes de datos",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1393,6 +1393,7 @@
"can-not-add-widget": "Le widget ne peut être ajouté à cette section à cause des restrictions de taille.$",
"can-you-add-a-description": "Pouvez-vous ajouter une description?",
"checkout-service-connectors-doc": "Il y a de nombreux connecteurs disponibles ici pour indexer les données de vos services. Veuillez consulter nos connecteurs.",
"click-here-to-view-assets-on-explore": "(Click to view the filtered assets on Explore page.)",
"click-text-to-view-details": "Cliquez sur <0>{{text}}</0> pour voir les détails.",
"closed-this-task": "fermer cette tâche",
"collaborate-with-other-user": "pour collaborer avec d'autres utilisateurs.",
Expand Down Expand Up @@ -1782,6 +1783,7 @@
"schedule-for-ingestion-description": "La programmation peut être configurée à une cadence horaire, quotidienne ou hebdomadaire. Le fuseau horaire est en UTC.",
"scheduled-run-every": "Programmer pour être exécuté tous les",
"scopes-comma-separated": "Liste de scopes séparée par une virgule.",
"search-entity-count": "{{count}} assets have been found with this filter.",
"search-for-edge": "Rechercher Pipeline, Procédures Stockées",
"search-for-entity-types": "Rechercher Tables, Topics, Tableaux de Bord, Pipelines et Modèles d'IA",
"search-for-ingestion": "Rechercher une ingestion",
Expand Down
Loading

0 comments on commit 47c993b

Please sign in to comment.