sql plugins - angular to react - base sql datasource (#51655)

* base sql datasource and components
pull/51609/head
Scott Lepper 3 years ago committed by GitHub
parent a14ca8fb62
commit fa560d96b6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 14
      .betterer.results
  2. 1
      package.json
  3. 51
      public/app/features/plugins/sql/components/ConfirmModal.tsx
  4. 61
      public/app/features/plugins/sql/components/DatasetSelector.tsx
  5. 25
      public/app/features/plugins/sql/components/ErrorBoundary.tsx
  6. 119
      public/app/features/plugins/sql/components/QueryEditor.tsx
  7. 245
      public/app/features/plugins/sql/components/QueryHeader.tsx
  8. 39
      public/app/features/plugins/sql/components/TableSelector.tsx
  9. 47
      public/app/features/plugins/sql/components/query-editor-raw/QueryEditorRaw.tsx
  10. 91
      public/app/features/plugins/sql/components/query-editor-raw/QueryToolbox.tsx
  11. 110
      public/app/features/plugins/sql/components/query-editor-raw/QueryValidator.tsx
  12. 121
      public/app/features/plugins/sql/components/query-editor-raw/RawEditor.tsx
  13. 259
      public/app/features/plugins/sql/components/visual-query-builder/AwesomeQueryBuilder.tsx
  14. 59
      public/app/features/plugins/sql/components/visual-query-builder/GroupByRow.tsx
  15. 92
      public/app/features/plugins/sql/components/visual-query-builder/OrderByRow.tsx
  16. 46
      public/app/features/plugins/sql/components/visual-query-builder/Preview.tsx
  17. 22
      public/app/features/plugins/sql/components/visual-query-builder/SQLGroupByRow.tsx
  18. 40
      public/app/features/plugins/sql/components/visual-query-builder/SQLOrderByRow.tsx
  19. 22
      public/app/features/plugins/sql/components/visual-query-builder/SQLSelectRow.tsx
  20. 51
      public/app/features/plugins/sql/components/visual-query-builder/SQLWhereRow.tsx
  21. 146
      public/app/features/plugins/sql/components/visual-query-builder/SelectRow.tsx
  22. 68
      public/app/features/plugins/sql/components/visual-query-builder/VisualEditor.tsx
  23. 92
      public/app/features/plugins/sql/components/visual-query-builder/WhereRow.tsx
  24. 112
      public/app/features/plugins/sql/constants.ts
  25. 229
      public/app/features/plugins/sql/datasource/SqlDatasource.ts
  26. 29
      public/app/features/plugins/sql/defaults.ts
  27. 66
      public/app/features/plugins/sql/expressions.ts
  28. 161
      public/app/features/plugins/sql/types.ts
  29. 8
      public/app/features/plugins/sql/utils/formatSQL.ts
  30. 105
      public/app/features/plugins/sql/utils/sql.utils.ts
  31. 25
      public/app/features/plugins/sql/utils/useSqlChange.ts
  32. 104
      yarn.lock

@ -6510,6 +6510,20 @@ exports[`better eslint`] = {
[173, 23, 47, "Do not use any type assertions.", "3309878203"],
[195, 43, 45, "Do not use any type assertions.", "15355460"]
],
"public/app/features/plugins/sql/components/visual-query-builder/AwesomeQueryBuilder.tsx:760854115": [
[175, 24, 63, "Do not use any type assertions.", "2252455532"],
[198, 13, 47, "Do not use any type assertions.", "2763495851"],
[253, 30, 3, "Unexpected any. Specify a different type.", "193409811"]
],
"public/app/features/plugins/sql/datasource/SqlDatasource.ts:178321817": [
[124, 33, 3, "Unexpected any. Specify a different type.", "193409811"],
[159, 56, 3, "Unexpected any. Specify a different type.", "193409811"],
[177, 19, 3, "Unexpected any. Specify a different type.", "193409811"],
[182, 57, 3, "Unexpected any. Specify a different type.", "193409811"],
[184, 18, 135, "Do not use any type assertions.", "3270957200"],
[194, 28, 3, "Unexpected any. Specify a different type.", "193409811"],
[225, 33, 3, "Unexpected any. Specify a different type.", "193409811"]
],
"public/app/features/plugins/tests/datasource_srv.test.ts:2399414445": [
[10, 19, 3, "Unexpected any. Specify a different type.", "193409811"],
[44, 48, 21, "Do not use any type assertions.", "932413114"],

@ -354,6 +354,7 @@
"rc-time-picker": "3.7.3",
"re-resizable": "6.9.9",
"react": "17.0.2",
"react-awesome-query-builder": "^5.1.2",
"react-beautiful-dnd": "13.1.0",
"react-diff-viewer": "^3.1.1",
"react-dom": "17.0.2",

@ -0,0 +1,51 @@
import React, { useRef, useEffect } from 'react';
import { Button, Icon, Modal } from '@grafana/ui';
type ConfirmModalProps = {
isOpen: boolean;
onCancel?: () => void;
onDiscard?: () => void;
onCopy?: () => void;
};
export function ConfirmModal({ isOpen, onCancel, onDiscard, onCopy }: ConfirmModalProps) {
const buttonRef = useRef<HTMLButtonElement>(null);
// Moved from grafana/ui
useEffect(() => {
// for some reason autoFocus property did no work on this button, but this does
if (isOpen) {
buttonRef.current?.focus();
}
}, [isOpen]);
return (
<Modal
title={
<div className="modal-header-title">
<Icon name="exclamation-triangle" size="lg" />
<span className="p-l-1">Warning</span>
</div>
}
onDismiss={onCancel}
isOpen={isOpen}
>
<p>
Builder mode does not display changes made in code. The query builder will display the last changes you made in
builder mode.
</p>
<p>Do you want to copy your code to the clipboard?</p>
<Modal.ButtonRow>
<Button type="button" variant="secondary" onClick={onCancel} fill="outline">
Cancel
</Button>
<Button variant="destructive" type="button" onClick={onDiscard} ref={buttonRef}>
Discard code and switch
</Button>
<Button variant="primary" onClick={onCopy}>
Copy code and switch
</Button>
</Modal.ButtonRow>
</Modal>
);
}

@ -0,0 +1,61 @@
import React, { useEffect } from 'react';
import { useAsync } from 'react-use';
import { SelectableValue } from '@grafana/data';
import { Select } from '@grafana/ui';
import { DB, ResourceSelectorProps, toOption } from '../types';
interface DatasetSelectorProps extends ResourceSelectorProps {
db: DB;
value: string | null;
applyDefault?: boolean;
disabled?: boolean;
onChange: (v: SelectableValue) => void;
}
export const DatasetSelector: React.FC<DatasetSelectorProps> = ({
db,
value,
onChange,
disabled,
className,
applyDefault,
}) => {
const state = useAsync(async () => {
const datasets = await db.datasets();
return datasets.map(toOption);
}, []);
useEffect(() => {
if (!applyDefault) {
return;
}
// Set default dataset when values are fetched
if (!value) {
if (state.value && state.value[0]) {
onChange(state.value[0]);
}
} else {
if (state.value && state.value.find((v) => v.value === value) === undefined) {
// if value is set and newly fetched values does not contain selected value
if (state.value.length > 0) {
onChange(state.value[0]);
}
}
}
}, [state.value, value, applyDefault, onChange]);
return (
<Select
className={className}
aria-label="Dataset selector"
value={value}
options={state.value}
onChange={onChange}
disabled={disabled}
isLoading={state.loading}
menuShouldPortal={true}
/>
);
};

@ -0,0 +1,25 @@
import React from 'react';
type Props = {
fallBackComponent?: React.ReactNode;
};
export class ErrorBoundary extends React.Component<Props, { hasError: boolean }> {
constructor(props: Props) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError() {
return { hasError: true };
}
render() {
if (this.state.hasError) {
const FallBack = this.props.fallBackComponent || <div>Error</div>;
return FallBack;
}
return this.props.children;
}
}

@ -0,0 +1,119 @@
import React, { useCallback, useEffect, useState } from 'react';
import { useAsync } from 'react-use';
import { QueryEditorProps } from '@grafana/data';
import { EditorMode, Space } from '@grafana/experimental';
import { SqlDatasource } from '../datasource/SqlDatasource';
import { applyQueryDefaults } from '../defaults';
import { SQLQuery, QueryRowFilter, SQLOptions } from '../types';
import { haveColumns } from '../utils/sql.utils';
import { QueryHeader } from './QueryHeader';
import { RawEditor } from './query-editor-raw/RawEditor';
import { VisualEditor } from './visual-query-builder/VisualEditor';
type Props = QueryEditorProps<SqlDatasource, SQLQuery, SQLOptions>;
export function SqlQueryEditor({ datasource, query, onChange, onRunQuery, range }: Props) {
const [isQueryRunnable, setIsQueryRunnable] = useState(true);
const db = datasource.getDB();
const { loading, error } = useAsync(async () => {
return () => {
if (datasource.getDB(datasource.id).init !== undefined) {
datasource.getDB(datasource.id).init!();
}
};
}, [datasource]);
const queryWithDefaults = applyQueryDefaults(query);
const [queryRowFilter, setQueryRowFilter] = useState<QueryRowFilter>({
filter: !!queryWithDefaults.sql?.whereString,
group: !!queryWithDefaults.sql?.groupBy?.[0]?.property.name,
order: !!queryWithDefaults.sql?.orderBy?.property.name,
preview: true,
});
const [queryToValidate, setQueryToValidate] = useState(queryWithDefaults);
useEffect(() => {
return () => {
if (datasource.getDB(datasource.id).dispose !== undefined) {
datasource.getDB(datasource.id).dispose!();
}
};
}, [datasource]);
const processQuery = useCallback(
(q: SQLQuery) => {
if (isQueryValid(q) && onRunQuery) {
onRunQuery();
}
},
[onRunQuery]
);
const onQueryChange = (q: SQLQuery, process = true) => {
setQueryToValidate(q);
onChange(q);
if (haveColumns(q.sql?.columns) && q.sql?.columns.some((c) => c.name) && !queryRowFilter.group) {
setQueryRowFilter({ ...queryRowFilter, group: true });
}
if (process) {
processQuery(q);
}
};
const onQueryHeaderChange = (q: SQLQuery) => {
setQueryToValidate(q);
onChange(q);
};
if (loading || error) {
return null;
}
return (
<>
<QueryHeader
db={db}
onChange={onQueryHeaderChange}
onRunQuery={onRunQuery}
onQueryRowChange={setQueryRowFilter}
queryRowFilter={queryRowFilter}
query={queryWithDefaults}
isQueryRunnable={isQueryRunnable}
/>
<Space v={0.5} />
{queryWithDefaults.editorMode !== EditorMode.Code && (
<VisualEditor
db={db}
query={queryWithDefaults}
onChange={(q: SQLQuery) => onQueryChange(q, false)}
queryRowFilter={queryRowFilter}
onValidate={setIsQueryRunnable}
range={range}
/>
)}
{queryWithDefaults.editorMode === EditorMode.Code && (
<RawEditor
db={db}
query={queryWithDefaults}
queryToValidate={queryToValidate}
onChange={onQueryChange}
onRunQuery={onRunQuery}
onValidate={setIsQueryRunnable}
range={range}
/>
)}
</>
);
}
const isQueryValid = (q: SQLQuery) => {
return Boolean(q.rawSql);
};

@ -0,0 +1,245 @@
import React, { useCallback, useState } from 'react';
import { useCopyToClipboard } from 'react-use';
import { SelectableValue } from '@grafana/data';
import { EditorField, EditorHeader, EditorMode, EditorRow, FlexItem, InlineSelect, Space } from '@grafana/experimental';
import { Button, InlineField, InlineSwitch, RadioButtonGroup, Select, Tooltip } from '@grafana/ui';
import { QueryWithDefaults } from '../defaults';
import { SQLQuery, QueryFormat, QueryRowFilter, QUERY_FORMAT_OPTIONS, DB } from '../types';
import { defaultToRawSql } from '../utils/sql.utils';
import { ConfirmModal } from './ConfirmModal';
import { DatasetSelector } from './DatasetSelector';
import { ErrorBoundary } from './ErrorBoundary';
import { TableSelector } from './TableSelector';
interface QueryHeaderProps {
db: DB;
query: QueryWithDefaults;
onChange: (query: SQLQuery) => void;
onRunQuery: () => void;
onQueryRowChange: (queryRowFilter: QueryRowFilter) => void;
queryRowFilter: QueryRowFilter;
isQueryRunnable: boolean;
}
const editorModes = [
{ label: 'Builder', value: EditorMode.Builder },
{ label: 'Code', value: EditorMode.Code },
];
export function QueryHeader({
db,
query,
queryRowFilter,
onChange,
onRunQuery,
onQueryRowChange,
isQueryRunnable,
}: QueryHeaderProps) {
const { editorMode } = query;
const [_, copyToClipboard] = useCopyToClipboard();
const [showConfirm, setShowConfirm] = useState(false);
const toRawSql = db.toRawSql || defaultToRawSql;
const onEditorModeChange = useCallback(
(newEditorMode: EditorMode) => {
if (editorMode === EditorMode.Code) {
setShowConfirm(true);
return;
}
onChange({ ...query, editorMode: newEditorMode });
},
[editorMode, onChange, query]
);
const onFormatChange = (e: SelectableValue) => {
const next = { ...query, format: e.value !== undefined ? e.value : QueryFormat.Table };
onChange(next);
};
const onDatasetChange = (e: SelectableValue) => {
if (e.value === query.dataset) {
return;
}
const next = {
...query,
dataset: e.value,
table: undefined,
sql: undefined,
rawSql: '',
};
onChange(next);
};
const onTableChange = (e: SelectableValue) => {
if (e.value === query.table) {
return;
}
const next: SQLQuery = {
...query,
table: e.value,
sql: undefined,
rawSql: '',
};
onChange(next);
};
return (
<>
<EditorHeader>
{/* Backward compatibility check. Inline select uses SelectContainer that was added in 8.3 */}
<ErrorBoundary
fallBackComponent={
<InlineField label="Format" labelWidth={15}>
<Select
placeholder="Select format"
value={query.format}
onChange={onFormatChange}
options={QUERY_FORMAT_OPTIONS}
/>
</InlineField>
}
>
<InlineSelect
label="Format"
value={query.format}
placeholder="Select format"
menuShouldPortal
onChange={onFormatChange}
options={QUERY_FORMAT_OPTIONS}
/>
</ErrorBoundary>
{editorMode === EditorMode.Builder && (
<>
<InlineSwitch
id="sql-filter"
label="Filter"
transparent={true}
showLabel={true}
value={queryRowFilter.filter}
onChange={(ev) =>
ev.target instanceof HTMLInputElement &&
onQueryRowChange({ ...queryRowFilter, filter: ev.target.checked })
}
/>
<InlineSwitch
id="sql-group"
label="Group"
transparent={true}
showLabel={true}
value={queryRowFilter.group}
onChange={(ev) =>
ev.target instanceof HTMLInputElement &&
onQueryRowChange({ ...queryRowFilter, group: ev.target.checked })
}
/>
<InlineSwitch
id="sql-order"
label="Order"
transparent={true}
showLabel={true}
value={queryRowFilter.order}
onChange={(ev) =>
ev.target instanceof HTMLInputElement &&
onQueryRowChange({ ...queryRowFilter, order: ev.target.checked })
}
/>
<InlineSwitch
id="sql-preview"
label="Preview"
transparent={true}
showLabel={true}
value={queryRowFilter.preview}
onChange={(ev) =>
ev.target instanceof HTMLInputElement &&
onQueryRowChange({ ...queryRowFilter, preview: ev.target.checked })
}
/>
</>
)}
<FlexItem grow={1} />
{isQueryRunnable ? (
<Button icon="play" variant="primary" size="sm" onClick={() => onRunQuery()}>
Run query
</Button>
) : (
<Tooltip
theme="error"
content={
<>
Your query is invalid. Check below for details. <br />
However, you can still run this query.
</>
}
placement="top"
>
<Button icon="exclamation-triangle" variant="secondary" size="sm" onClick={() => onRunQuery()}>
Run query
</Button>
</Tooltip>
)}
<RadioButtonGroup options={editorModes} size="sm" value={editorMode} onChange={onEditorModeChange} />
<ConfirmModal
isOpen={showConfirm}
onCopy={() => {
setShowConfirm(false);
copyToClipboard(query.rawSql!);
onChange({
...query,
rawSql: toRawSql(query),
editorMode: EditorMode.Builder,
});
}}
onDiscard={() => {
setShowConfirm(false);
onChange({
...query,
rawSql: toRawSql(query),
editorMode: EditorMode.Builder,
});
}}
onCancel={() => setShowConfirm(false)}
/>
</EditorHeader>
{editorMode === EditorMode.Builder && (
<>
<Space v={0.5} />
<EditorRow>
<EditorField label="Dataset" width={25}>
<DatasetSelector
db={db}
value={query.dataset === undefined ? null : query.dataset}
onChange={onDatasetChange}
/>
</EditorField>
<EditorField label="Table" width={25}>
<TableSelector
db={db}
query={query}
value={query.table === undefined ? null : query.table}
onChange={onTableChange}
applyDefault
/>
</EditorField>
</EditorRow>
</>
)}
</>
);
}

@ -0,0 +1,39 @@
import React from 'react';
import { useAsync } from 'react-use';
import { SelectableValue, toOption } from '@grafana/data';
import { Select } from '@grafana/ui';
import { QueryWithDefaults } from '../defaults';
import { DB, ResourceSelectorProps } from '../types';
interface TableSelectorProps extends ResourceSelectorProps {
db: DB;
value: string | null;
query: QueryWithDefaults;
onChange: (v: SelectableValue) => void;
}
export const TableSelector: React.FC<TableSelectorProps> = ({ db, query, value, className, onChange }) => {
const state = useAsync(async () => {
if (!query.dataset) {
return [];
}
const tables = await db.tables(query.dataset);
return tables.map(toOption);
}, [query.dataset]);
return (
<Select
className={className}
disabled={state.loading}
aria-label="Table selector"
value={value}
options={state.value}
onChange={onChange}
isLoading={state.loading}
menuShouldPortal={true}
placeholder={state.loading ? 'Loading tables' : 'Select table'}
/>
);
};

@ -0,0 +1,47 @@
import React, { useCallback, useEffect, useRef } from 'react';
import { LanguageCompletionProvider, SQLEditor } from '@grafana/experimental';
import { SQLQuery } from '../../types';
import { formatSQL } from '../../utils/formatSQL';
type Props = {
query: SQLQuery;
onChange: (value: SQLQuery, processQuery: boolean) => void;
children?: (props: { formatQuery: () => void }) => React.ReactNode;
width?: number;
height?: number;
completionProvider: LanguageCompletionProvider;
};
export function QueryEditorRaw({ children, onChange, query, width, height, completionProvider }: Props) {
// We need to pass query via ref to SQLEditor as onChange is executed via monacoEditor.onDidChangeModelContent callback, not onChange property
const queryRef = useRef<SQLQuery>(query);
useEffect(() => {
queryRef.current = query;
}, [query]);
const onRawQueryChange = useCallback(
(rawSql: string, processQuery: boolean) => {
const newQuery = {
...queryRef.current,
rawQuery: true,
rawSql,
};
onChange(newQuery, processQuery);
},
[onChange]
);
return (
<SQLEditor
width={width}
height={height}
query={query.rawSql!}
onChange={onRawQueryChange}
language={{ id: 'sql', completionProvider, formatter: formatSQL }}
>
{children}
</SQLEditor>
);
}

@ -0,0 +1,91 @@
import { css } from '@emotion/css';
import React, { useMemo, useState } from 'react';
import { HorizontalGroup, Icon, IconButton, Tooltip, useTheme2 } from '@grafana/ui';
import { QueryValidator, QueryValidatorProps } from './QueryValidator';
interface QueryToolboxProps extends Omit<QueryValidatorProps, 'onValidate'> {
showTools?: boolean;
isExpanded?: boolean;
onFormatCode?: () => void;
onExpand?: (expand: boolean) => void;
onValidate?: (isValid: boolean) => void;
}
export function QueryToolbox({ showTools, onFormatCode, onExpand, isExpanded, ...validatorProps }: QueryToolboxProps) {
const theme = useTheme2();
const [validationResult, setValidationResult] = useState<boolean>();
const styles = useMemo(() => {
return {
container: css`
border: 1px solid ${theme.colors.border.medium};
border-top: none;
padding: ${theme.spacing(0.5, 0.5, 0.5, 0.5)};
display: flex;
flex-grow: 1;
justify-content: space-between;
font-size: ${theme.typography.bodySmall.fontSize};
`,
error: css`
color: ${theme.colors.error.text};
font-size: ${theme.typography.bodySmall.fontSize};
font-family: ${theme.typography.fontFamilyMonospace};
`,
valid: css`
color: ${theme.colors.success.text};
`,
info: css`
color: ${theme.colors.text.secondary};
`,
hint: css`
color: ${theme.colors.text.disabled};
white-space: nowrap;
cursor: help;
`,
};
}, [theme]);
let style = {};
if (!showTools && validationResult === undefined) {
style = { height: 0, padding: 0, visibility: 'hidden' };
}
return (
<div className={styles.container} style={style}>
<div>
{validatorProps.onValidate && (
<QueryValidator
{...validatorProps}
onValidate={(result: boolean) => {
setValidationResult(result);
validatorProps.onValidate!(result);
}}
/>
)}
</div>
{showTools && (
<div>
<HorizontalGroup spacing="sm">
{onFormatCode && (
<IconButton onClick={onFormatCode} name="brackets-curly" size="xs" tooltip="Format query" />
)}
{onExpand && (
<IconButton
onClick={() => onExpand(!isExpanded)}
name={isExpanded ? 'angle-up' : 'angle-down'}
size="xs"
tooltip={isExpanded ? 'Collapse editor' : 'Expand editor'}
/>
)}
<Tooltip content="Hit CTRL/CMD+Return to run query">
<Icon className={styles.hint} name="keyboard" />
</Tooltip>
</HorizontalGroup>
</div>
)}
</div>
);
}

@ -0,0 +1,110 @@
import { css } from '@emotion/css';
import React, { useState, useMemo, useEffect } from 'react';
import { useAsyncFn } from 'react-use';
import useDebounce from 'react-use/lib/useDebounce';
import { formattedValueToString, getValueFormat, TimeRange } from '@grafana/data';
import { Icon, Spinner, useTheme2 } from '@grafana/ui';
import { DB, SQLQuery, ValidationResults } from '../../types';
export interface QueryValidatorProps {
db: DB;
query: SQLQuery;
range?: TimeRange;
onValidate: (isValid: boolean) => void;
}
export function QueryValidator({ db, query, onValidate, range }: QueryValidatorProps) {
const [validationResult, setValidationResult] = useState<ValidationResults | null>();
const theme = useTheme2();
const valueFormatter = useMemo(() => getValueFormat('bytes'), []);
const styles = useMemo(() => {
return {
error: css`
color: ${theme.colors.error.text};
font-size: ${theme.typography.bodySmall.fontSize};
font-family: ${theme.typography.fontFamilyMonospace};
`,
valid: css`
color: ${theme.colors.success.text};
`,
info: css`
color: ${theme.colors.text.secondary};
`,
};
}, [theme]);
const [state, validateQuery] = useAsyncFn(
async (q: SQLQuery) => {
if (q.rawSql?.trim() === '') {
return null;
}
return await db.validateQuery(q, range);
},
[db]
);
const [,] = useDebounce(
async () => {
const result = await validateQuery(query);
if (result) {
setValidationResult(result);
}
return null;
},
1000,
[query, validateQuery]
);
useEffect(() => {
if (validationResult?.isError) {
onValidate(false);
}
if (validationResult?.isValid) {
onValidate(true);
}
}, [validationResult, onValidate]);
if (!state.value && !state.loading) {
return null;
}
const error = state.value?.error ? processErrorMessage(state.value.error) : '';
return (
<>
{state.loading && (
<div className={styles.info}>
<Spinner inline={true} size={12} /> Validating query...
</div>
)}
{!state.loading && state.value && (
<>
<>
{state.value.isValid && state.value.statistics && (
<div className={styles.valid}>
<Icon name="check" /> This query will process{' '}
<strong>{formattedValueToString(valueFormatter(state.value.statistics.TotalBytesProcessed))}</strong>{' '}
when run.
</div>
)}
</>
<>{state.value.isError && <div className={styles.error}>{error}</div>}</>
</>
)}
</>
);
}
function processErrorMessage(error: string) {
const splat = error.split(':');
if (splat.length > 2) {
return splat.slice(2).join(':');
}
return error;
}

@ -0,0 +1,121 @@
import { css } from '@emotion/css';
import React, { useMemo, useState } from 'react';
import { useMeasure } from 'react-use';
import AutoSizer from 'react-virtualized-auto-sizer';
import { GrafanaTheme2 } from '@grafana/data';
import { Modal, useStyles2, useTheme2 } from '@grafana/ui';
import { SQLQuery, QueryEditorProps } from '../../types';
import { QueryEditorRaw } from './QueryEditorRaw';
import { QueryToolbox } from './QueryToolbox';
interface RawEditorProps extends Omit<QueryEditorProps, 'onChange'> {
onRunQuery: () => void;
onChange: (q: SQLQuery, processQuery: boolean) => void;
onValidate: (isValid: boolean) => void;
queryToValidate: SQLQuery;
}
export function RawEditor({ db, query, onChange, onRunQuery, onValidate, queryToValidate, range }: RawEditorProps) {
const theme = useTheme2();
const styles = useStyles2(getStyles);
const [isExpanded, setIsExpanded] = useState(false);
const [toolboxRef, toolboxMeasure] = useMeasure<HTMLDivElement>();
const [editorRef, editorMeasure] = useMeasure<HTMLDivElement>();
const completionProvider = useMemo(() => db.getSqlCompletionProvider(), [db]);
const renderQueryEditor = (width?: number, height?: number) => {
return (
<QueryEditorRaw
completionProvider={completionProvider}
query={query}
width={width}
height={height ? height - toolboxMeasure.height : undefined}
onChange={onChange}
>
{({ formatQuery }) => {
return (
<div ref={toolboxRef}>
<QueryToolbox
db={db}
query={queryToValidate}
onValidate={onValidate}
onFormatCode={formatQuery}
showTools
range={range}
onExpand={setIsExpanded}
isExpanded={isExpanded}
/>
</div>
);
}}
</QueryEditorRaw>
);
};
const renderEditor = (standalone = false) => {
return standalone ? (
<AutoSizer>
{({ width, height }) => {
return renderQueryEditor(width, height);
}}
</AutoSizer>
) : (
<div ref={editorRef}>{renderQueryEditor()}</div>
);
};
const renderPlaceholder = () => {
return (
<div
style={{
width: editorMeasure.width,
height: editorMeasure.height,
background: theme.colors.background.primary,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
Editing in expanded code editor
</div>
);
};
return (
<>
{isExpanded ? renderPlaceholder() : renderEditor()}
{isExpanded && (
<Modal
title={`Query ${query.refId}`}
closeOnBackdropClick={false}
closeOnEscape={false}
className={styles.modal}
contentClassName={styles.modalContent}
isOpen={isExpanded}
onDismiss={() => {
setIsExpanded(false);
}}
>
{renderEditor(true)}
</Modal>
)}
</>
);
}
function getStyles(theme: GrafanaTheme2) {
return {
modal: css`
width: 95vw;
height: 95vh;
`,
modalContent: css`
height: 100%;
padding-top: 0;
`,
};
}

@ -0,0 +1,259 @@
import { List } from 'immutable';
import { isString } from 'lodash';
import React from 'react';
import {
BasicConfig,
Config,
JsonItem,
JsonTree,
Operator,
Settings,
SimpleField,
Utils,
ValueSource,
Widgets,
} from 'react-awesome-query-builder';
import { dateTime, toOption } from '@grafana/data';
import { Button, DateTimePicker, Input, Select } from '@grafana/ui';
const buttonLabels = {
add: 'Add',
remove: 'Remove',
};
export const emptyInitValue: JsonItem = {
id: Utils.uuid(),
type: 'group' as const,
children1: {
[Utils.uuid()]: {
type: 'rule',
properties: {
field: null,
operator: null,
value: [],
valueSrc: [],
},
},
},
};
export const emptyInitTree: JsonTree = {
id: Utils.uuid(),
type: 'group' as const,
children1: {
[Utils.uuid()]: {
type: 'rule',
properties: {
field: null,
operator: null,
value: [],
valueSrc: [],
},
},
},
};
export const widgets: Widgets = {
...BasicConfig.widgets,
text: {
...BasicConfig.widgets.text,
factory: function TextInput(props) {
return (
<Input
value={props?.value || ''}
placeholder={props?.placeholder}
onChange={(e) => props?.setValue(e.currentTarget.value)}
/>
);
},
},
number: {
...BasicConfig.widgets.number,
factory: function NumberInput(props) {
return (
<Input
value={props?.value}
placeholder={props?.placeholder}
type="number"
onChange={(e) => props?.setValue(Number.parseInt(e.currentTarget.value, 10))}
/>
);
},
},
datetime: {
...BasicConfig.widgets.datetime,
factory: function DateTimeInput(props) {
return (
<DateTimePicker
onChange={(e) => {
props?.setValue(e.format(BasicConfig.widgets.datetime.valueFormat));
}}
date={dateTime(props?.value).utc()}
/>
);
},
},
};
export const settings: Settings = {
...BasicConfig.settings,
canRegroup: false,
maxNesting: 1,
canReorder: false,
showNot: false,
addRuleLabel: buttonLabels.add,
deleteLabel: buttonLabels.remove,
renderConjs: function Conjunctions(conjProps) {
return (
<Select
id={conjProps?.id}
aria-label="Conjunction"
menuShouldPortal
options={conjProps?.conjunctionOptions ? Object.keys(conjProps?.conjunctionOptions).map(toOption) : undefined}
value={conjProps?.selectedConjunction}
onChange={(val) => conjProps?.setConjunction(val.value!)}
/>
);
},
renderField: function Field(fieldProps) {
const fields = fieldProps?.config?.fields || {};
return (
<Select
id={fieldProps?.id}
width={25}
aria-label="Field"
menuShouldPortal
options={fieldProps?.items.map((f) => {
// @ts-ignore
const icon = fields[f.key].mainWidgetProps?.customProps?.icon;
return {
label: f.label,
value: f.key,
icon,
};
})}
value={fieldProps?.selectedKey}
onChange={(val) => {
fieldProps?.setField(val.label!);
}}
/>
);
},
renderButton: function RAQBButton(buttonProps) {
return (
<Button
type="button"
title={`${buttonProps?.label} filter`}
onClick={buttonProps?.onClick}
variant="secondary"
size="md"
icon={buttonProps?.label === buttonLabels.add ? 'plus' : 'times'}
/>
);
},
renderOperator: function Operator(operatorProps) {
return (
<Select
options={operatorProps?.items.map((op) => ({ label: op.label, value: op.key }))}
aria-label="Operator"
menuShouldPortal
value={operatorProps?.selectedKey}
onChange={(val) => {
operatorProps?.setField(val.value || '');
}}
/>
);
},
};
// add IN / NOT IN operators to text to support multi-value variables
const enum Op {
IN = 'select_any_in',
NOT_IN = 'select_not_any_in',
}
// eslint-ignore
const customOperators = getCustomOperators(BasicConfig) as typeof BasicConfig.operators;
const textWidget = BasicConfig.types.text.widgets.text;
const opers = [...(textWidget.operators || []), Op.IN, Op.NOT_IN];
const customTextWidget = {
...textWidget,
operators: opers,
};
const customTypes = {
...BasicConfig.types,
text: {
...BasicConfig.types.text,
widgets: {
...BasicConfig.types.text.widgets,
text: customTextWidget,
},
},
};
export const raqbConfig: Config = {
...BasicConfig,
widgets,
settings,
operators: customOperators as typeof BasicConfig.operators,
types: customTypes,
};
export type { Config };
function getCustomOperators(config: BasicConfig) {
const { ...supportedOperators } = config.operators;
const noop = () => '';
// IN operator expects array, override IN formatter for multi-value variables
const sqlFormatInOp = supportedOperators[Op.IN].sqlFormatOp || noop;
const customSqlInFormatter = (
field: string,
op: string,
value: string | List<string>,
valueSrc: ValueSource,
valueType: string,
opDef: Operator,
operatorOptions: object,
fieldDef: SimpleField
) => {
return sqlFormatInOp(field, op, splitIfString(value), valueSrc, valueType, opDef, operatorOptions, fieldDef);
};
// NOT IN operator expects array, override NOT IN formatter for multi-value variables
const sqlFormatNotInOp = supportedOperators[Op.NOT_IN].sqlFormatOp || noop;
const customSqlNotInFormatter = (
field: string,
op: string,
value: string | List<string>,
valueSrc: ValueSource,
valueType: string,
opDef: Operator,
operatorOptions: object,
fieldDef: SimpleField
) => {
return sqlFormatNotInOp(field, op, splitIfString(value), valueSrc, valueType, opDef, operatorOptions, fieldDef);
};
const customOperators = {
...supportedOperators,
[Op.IN]: {
...supportedOperators[Op.IN],
sqlFormatOp: customSqlInFormatter,
},
[Op.NOT_IN]: {
...supportedOperators[Op.NOT_IN],
sqlFormatOp: customSqlNotInFormatter,
},
};
return customOperators;
}
// value: string | List<string> but AQB uses a different version of Immutable
// eslint-ignore
function splitIfString(value: any) {
if (isString(value)) {
return value.split(',');
}
return value;
}

@ -0,0 +1,59 @@
import React, { useCallback } from 'react';
import { SelectableValue, toOption } from '@grafana/data';
import { AccessoryButton, EditorList, InputGroup } from '@grafana/experimental';
import { Select } from '@grafana/ui';
import { QueryEditorGroupByExpression } from '../../expressions';
import { SQLExpression } from '../../types';
import { setGroupByField } from '../../utils/sql.utils';
interface GroupByRowProps {
sql: SQLExpression;
onSqlChange: (sql: SQLExpression) => void;
columns?: Array<SelectableValue<string>>;
}
export function GroupByRow({ sql, columns, onSqlChange }: GroupByRowProps) {
const onGroupByChange = useCallback(
(item: Array<Partial<QueryEditorGroupByExpression>>) => {
// As new (empty object) items come in, we need to make sure they have the correct type
const cleaned = item.map((v) => setGroupByField(v.property?.name));
const newSql = { ...sql, groupBy: cleaned };
onSqlChange(newSql);
},
[onSqlChange, sql]
);
return (
<EditorList<QueryEditorGroupByExpression>
items={sql.groupBy!}
onChange={onGroupByChange}
renderItem={makeRenderColumn({
options: columns,
})}
/>
);
}
function makeRenderColumn({ options }: { options?: Array<SelectableValue<string>> }) {
const renderColumn = function (
item: Partial<QueryEditorGroupByExpression>,
onChangeItem: (item: QueryEditorGroupByExpression) => void,
onDeleteItem: () => void
) {
return (
<InputGroup>
<Select
value={item.property?.name ? toOption(item.property.name) : null}
aria-label="Group by"
options={options}
menuShouldPortal
onChange={({ value }) => value && onChangeItem(setGroupByField(value))}
/>
<AccessoryButton aria-label="Remove group by column" icon="times" variant="secondary" onClick={onDeleteItem} />
</InputGroup>
);
};
return renderColumn;
}

@ -0,0 +1,92 @@
import { uniqueId } from 'lodash';
import React, { useCallback } from 'react';
import { SelectableValue, toOption } from '@grafana/data';
import { EditorField, InputGroup, Space } from '@grafana/experimental';
import { Input, RadioButtonGroup, Select } from '@grafana/ui';
import { SQLExpression } from '../../types';
import { setPropertyField } from '../../utils/sql.utils';
type OrderByRowProps = {
sql: SQLExpression;
onSqlChange: (sql: SQLExpression) => void;
columns?: Array<SelectableValue<string>>;
showOffset?: boolean;
};
const sortOrderOptions = [
{ description: 'Sort by ascending', value: 'ASC', icon: 'sort-amount-up' } as const,
{ description: 'Sort by descending', value: 'DESC', icon: 'sort-amount-down' } as const,
];
export function OrderByRow({ sql, onSqlChange, columns, showOffset }: OrderByRowProps) {
const onSortOrderChange = useCallback(
(item: 'ASC' | 'DESC') => {
const newSql: SQLExpression = { ...sql, orderByDirection: item };
onSqlChange(newSql);
},
[onSqlChange, sql]
);
const onLimitChange = useCallback(
(event: React.FormEvent<HTMLInputElement>) => {
const newSql: SQLExpression = { ...sql, limit: Number.parseInt(event.currentTarget.value, 10) };
onSqlChange(newSql);
},
[onSqlChange, sql]
);
const onOffsetChange = useCallback(
(event: React.FormEvent<HTMLInputElement>) => {
const newSql: SQLExpression = { ...sql, offset: Number.parseInt(event.currentTarget.value, 10) };
onSqlChange(newSql);
},
[onSqlChange, sql]
);
const onOrderByChange = useCallback(
(item: SelectableValue<string>) => {
const newSql: SQLExpression = { ...sql, orderBy: setPropertyField(item?.value) };
if (item === null) {
newSql.orderByDirection = undefined;
}
onSqlChange(newSql);
},
[onSqlChange, sql]
);
return (
<>
<EditorField label="Order by" width={25}>
<InputGroup>
<Select
aria-label="Order by"
options={columns}
value={sql.orderBy?.property.name ? toOption(sql.orderBy.property.name) : null}
isClearable
menuShouldPortal
onChange={onOrderByChange}
/>
<Space h={1.5} />
<RadioButtonGroup
options={sortOrderOptions}
disabled={!sql?.orderBy?.property.name}
value={sql.orderByDirection}
onChange={onSortOrderChange}
/>
</InputGroup>
</EditorField>
<EditorField label="Limit" optional width={25}>
<Input type="number" min={0} id={uniqueId('limit-')} value={sql.limit || ''} onChange={onLimitChange} />
</EditorField>
{showOffset && (
<EditorField label="Offset" optional width={25}>
<Input type="number" id={uniqueId('offset-')} value={sql.offset || ''} onChange={onOffsetChange} />
</EditorField>
)}
</>
);
}

@ -0,0 +1,46 @@
import { css } from '@emotion/css';
import React from 'react';
import { useCopyToClipboard } from 'react-use';
import { GrafanaTheme2 } from '@grafana/data';
import { CodeEditor, Field, IconButton, useStyles2 } from '@grafana/ui';
import { formatSQL } from '../../utils/formatSQL';
type PreviewProps = {
rawSql: string;
};
export function Preview({ rawSql }: PreviewProps) {
// TODO: use zero index to give feedback about copy success
const [_, copyToClipboard] = useCopyToClipboard();
const styles = useStyles2(getStyles);
const labelElement = (
<div className={styles.labelWrapper}>
<label className={styles.label}>Preview</label>
<IconButton tooltip="Copy to clipboard" onClick={() => copyToClipboard(rawSql)} name="copy" />
</div>
);
return (
<Field label={labelElement} className={styles.grow}>
<CodeEditor
language="sql"
height={80}
value={formatSQL(rawSql)}
monacoOptions={{ scrollbar: { vertical: 'hidden' }, scrollBeyondLastLine: false }}
readOnly={true}
showMiniMap={false}
/>
</Field>
);
}
function getStyles(theme: GrafanaTheme2) {
return {
grow: css({ flexGrow: 1 }),
label: css({ fontSize: 12, fontWeight: theme.typography.fontWeightMedium }),
labelWrapper: css({ display: 'flex', justifyContent: 'space-between', paddingBottom: theme.spacing(0.5) }),
};
}

@ -0,0 +1,22 @@
import React from 'react';
import { SelectableValue } from '@grafana/data';
import { QueryWithDefaults } from '../../defaults';
import { DB, SQLQuery } from '../../types';
import { useSqlChange } from '../../utils/useSqlChange';
import { GroupByRow } from './GroupByRow';
interface SQLGroupByRowProps {
fields: SelectableValue[];
query: QueryWithDefaults;
onQueryChange: (query: SQLQuery) => void;
db: DB;
}
export function SQLGroupByRow({ fields, query, onQueryChange, db }: SQLGroupByRowProps) {
const { onSqlChange } = useSqlChange({ query, onQueryChange, db });
return <GroupByRow columns={fields} sql={query.sql!} onSqlChange={onSqlChange} />;
}

@ -0,0 +1,40 @@
import React from 'react';
import { SelectableValue } from '@grafana/data';
import { QueryWithDefaults } from '../../defaults';
import { DB, SQLQuery } from '../../types';
import { useSqlChange } from '../../utils/useSqlChange';
import { OrderByRow } from './OrderByRow';
type SQLOrderByRowProps = {
fields: SelectableValue[];
query: QueryWithDefaults;
onQueryChange: (query: SQLQuery) => void;
db: DB;
};
export function SQLOrderByRow({ fields, query, onQueryChange, db }: SQLOrderByRowProps) {
const { onSqlChange } = useSqlChange({ query, onQueryChange, db });
let columnsWithIndices: SelectableValue[] = [];
if (fields) {
columnsWithIndices = [
{
value: '',
label: 'Selected columns',
options: query.sql?.columns?.map((c, i) => ({
value: i + 1,
label: c.name
? `${i + 1} - ${c.name}(${c.parameters?.map((p) => `${p.name}`)})`
: c.parameters?.map((p) => `${i + 1} - ${p.name}`),
})),
expanded: true,
},
...fields,
];
}
return <OrderByRow sql={query.sql!} onSqlChange={onSqlChange} columns={columnsWithIndices} />;
}

@ -0,0 +1,22 @@
import React from 'react';
import { SelectableValue } from '@grafana/data';
import { QueryWithDefaults } from '../../defaults';
import { DB, SQLQuery } from '../../types';
import { useSqlChange } from '../../utils/useSqlChange';
import { SelectRow } from './SelectRow';
interface SQLSelectRowProps {
fields: SelectableValue[];
query: QueryWithDefaults;
onQueryChange: (query: SQLQuery) => void;
db: DB;
}
export function SQLSelectRow({ fields, query, onQueryChange, db }: SQLSelectRowProps) {
const { onSqlChange } = useSqlChange({ query, onQueryChange, db });
return <SelectRow columns={fields} sql={query.sql!} onSqlChange={onSqlChange} />;
}

@ -0,0 +1,51 @@
import React from 'react';
import useAsync from 'react-use/lib/useAsync';
import { SelectableValue } from '@grafana/data';
import { QueryWithDefaults } from '../../defaults';
import { DB, SQLExpression, SQLQuery, SQLSelectableValue } from '../../types';
import { useSqlChange } from '../../utils/useSqlChange';
import { Config } from './AwesomeQueryBuilder';
import { WhereRow } from './WhereRow';
interface WhereRowProps {
query: QueryWithDefaults;
fields: SelectableValue[];
onQueryChange: (query: SQLQuery) => void;
db: DB;
}
export function SQLWhereRow({ query, fields, onQueryChange, db }: WhereRowProps) {
const state = useAsync(async () => {
return mapFieldsToTypes(fields);
}, [fields]);
const { onSqlChange } = useSqlChange({ query, onQueryChange, db });
return (
<WhereRow
// TODO: fix key that's used to force clean render or SQLWhereRow - otherwise it doesn't render operators correctly
key={JSON.stringify(state.value)}
config={{ fields: state.value || {} }}
sql={query.sql!}
onSqlChange={(val: SQLExpression) => {
onSqlChange(val);
}}
/>
);
}
// needed for awesome query builder
function mapFieldsToTypes(columns: SQLSelectableValue[]) {
const fields: Config['fields'] = {};
for (const col of columns) {
fields[col.value] = {
type: col.raqbFieldType || 'text',
valueSources: ['value'],
mainWidgetProps: { customProps: { icon: col.icon } },
};
}
return fields;
}

@ -0,0 +1,146 @@
import { css } from '@emotion/css';
import { uniqueId } from 'lodash';
import React, { useCallback } from 'react';
import { SelectableValue, toOption } from '@grafana/data';
import { EditorField, Stack } from '@grafana/experimental';
import { Button, Select, useStyles2 } from '@grafana/ui';
import { AGGREGATE_FNS } from '../../constants';
import { QueryEditorExpressionType, QueryEditorFunctionExpression } from '../../expressions';
import { SQLExpression } from '../../types';
import { createFunctionField } from '../../utils/sql.utils';
interface SelectRowProps {
sql: SQLExpression;
onSqlChange: (sql: SQLExpression) => void;
columns?: Array<SelectableValue<string>>;
}
const asteriskValue = { label: '*', value: '*' };
export function SelectRow({ sql, columns, onSqlChange }: SelectRowProps) {
const styles = useStyles2(getStyles);
const columnsWithAsterisk = [asteriskValue, ...(columns || [])];
const onColumnChange = useCallback(
(item: QueryEditorFunctionExpression, index: number) => (column: SelectableValue<string>) => {
let modifiedItem = { ...item };
if (!item.parameters?.length) {
modifiedItem.parameters = [{ type: QueryEditorExpressionType.FunctionParameter, name: column.value } as const];
} else {
modifiedItem.parameters = item.parameters.map((p) =>
p.type === QueryEditorExpressionType.FunctionParameter ? { ...p, name: column.value } : p
);
}
const newSql: SQLExpression = {
...sql,
columns: sql.columns?.map((c, i) => (i === index ? modifiedItem : c)),
};
onSqlChange(newSql);
},
[onSqlChange, sql]
);
const onAggregationChange = useCallback(
(item: QueryEditorFunctionExpression, index: number) => (aggregation: SelectableValue<string>) => {
const newItem = {
...item,
name: aggregation?.value,
};
const newSql: SQLExpression = {
...sql,
columns: sql.columns?.map((c, i) => (i === index ? newItem : c)),
};
onSqlChange(newSql);
},
[onSqlChange, sql]
);
const removeColumn = useCallback(
(index: number) => () => {
const clone = [...sql.columns!];
clone.splice(index, 1);
const newSql: SQLExpression = {
...sql,
columns: clone,
};
onSqlChange(newSql);
},
[onSqlChange, sql]
);
const addColumn = useCallback(() => {
const newSql: SQLExpression = { ...sql, columns: [...sql.columns!, createFunctionField()] };
onSqlChange(newSql);
}, [onSqlChange, sql]);
return (
<Stack gap={2} alignItems="end" wrap direction="column">
{sql.columns?.map((item, index) => (
<div key={index}>
<Stack gap={2} alignItems="end">
<EditorField label="Column" width={25}>
<Select
value={getColumnValue(item)}
options={columnsWithAsterisk}
inputId={`select-column-${index}-${uniqueId()}`}
menuShouldPortal
allowCustomValue
onChange={onColumnChange(item, index)}
/>
</EditorField>
<EditorField label="Aggregation" optional width={25}>
<Select
value={item.name ? toOption(item.name) : null}
inputId={`select-aggregation-${index}-${uniqueId()}`}
isClearable
menuShouldPortal
allowCustomValue
options={aggregateFnOptions}
onChange={onAggregationChange(item, index)}
/>
</EditorField>
<Button
aria-label="Remove"
type="button"
icon="trash-alt"
variant="secondary"
size="md"
onClick={removeColumn(index)}
/>
</Stack>
</div>
))}
<Button
type="button"
onClick={addColumn}
variant="secondary"
size="md"
icon="plus"
aria-label="Add"
className={styles.addButton}
/>
</Stack>
);
}
const getStyles = () => {
return { addButton: css({ alignSelf: 'flex-start' }) };
};
const aggregateFnOptions = AGGREGATE_FNS.map((v: { id: string; name: string; description: string }) =>
toOption(v.name)
);
function getColumnValue({ parameters }: QueryEditorFunctionExpression): SelectableValue<string> | null {
const column = parameters?.find((p) => p.type === QueryEditorExpressionType.FunctionParameter);
if (column?.name) {
return toOption(column.name);
}
return null;
}

@ -0,0 +1,68 @@
import React from 'react';
import { useAsync } from 'react-use';
import { EditorField, EditorRow, EditorRows } from '@grafana/experimental';
import { DB, QueryEditorProps, QueryRowFilter } from '../../types';
import { QueryToolbox } from '../query-editor-raw/QueryToolbox';
import { Preview } from './Preview';
import { SQLGroupByRow } from './SQLGroupByRow';
import { SQLOrderByRow } from './SQLOrderByRow';
import { SQLSelectRow } from './SQLSelectRow';
import { SQLWhereRow } from './SQLWhereRow';
interface VisualEditorProps extends QueryEditorProps {
db: DB;
queryRowFilter: QueryRowFilter;
onValidate: (isValid: boolean) => void;
}
export const VisualEditor: React.FC<VisualEditorProps> = ({
query,
db,
queryRowFilter,
onChange,
onValidate,
range,
}) => {
const state = useAsync(async () => {
const fields = await db.fields(query);
return fields;
}, [db, query.dataset, query.table]);
return (
<>
<EditorRows>
<EditorRow>
<SQLSelectRow fields={state.value || []} query={query} onQueryChange={onChange} db={db} />
</EditorRow>
{queryRowFilter.filter && (
<EditorRow>
<EditorField label="Filter by column value" optional>
<SQLWhereRow fields={state.value || []} query={query} onQueryChange={onChange} db={db} />
</EditorField>
</EditorRow>
)}
{queryRowFilter.group && (
<EditorRow>
<EditorField label="Group by column">
<SQLGroupByRow fields={state.value || []} query={query} onQueryChange={onChange} db={db} />
</EditorField>
</EditorRow>
)}
{queryRowFilter.order && (
<EditorRow>
<SQLOrderByRow fields={state.value || []} query={query} onQueryChange={onChange} db={db} />
</EditorRow>
)}
{queryRowFilter.preview && query.rawSql && (
<EditorRow>
<Preview rawSql={query.rawSql} />
</EditorRow>
)}
</EditorRows>
<QueryToolbox db={db} query={query} onValidate={onValidate} range={range} />
</>
);
};

@ -0,0 +1,92 @@
import { injectGlobal } from '@emotion/css';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Builder, Config, ImmutableTree, Query, Utils } from 'react-awesome-query-builder';
import { SQLExpression } from '../../types';
import { emptyInitTree, raqbConfig } from './AwesomeQueryBuilder';
interface SQLBuilderWhereRowProps {
sql: SQLExpression;
onSqlChange: (sql: SQLExpression) => void;
config?: Partial<Config>;
}
export function WhereRow({ sql, config, onSqlChange }: SQLBuilderWhereRowProps) {
const [tree, setTree] = useState<ImmutableTree>();
const configWithDefaults = useMemo(() => ({ ...raqbConfig, ...config }), [config]);
useEffect(() => {
// Set the initial tree
if (!tree) {
const initTree = Utils.checkTree(Utils.loadTree(sql.whereJsonTree ?? emptyInitTree), configWithDefaults);
setTree(initTree);
}
}, [configWithDefaults, sql.whereJsonTree, tree]);
useEffect(() => {
if (!sql.whereJsonTree) {
setTree(Utils.checkTree(Utils.loadTree(emptyInitTree), configWithDefaults));
}
}, [configWithDefaults, sql.whereJsonTree]);
const onTreeChange = useCallback(
(changedTree: ImmutableTree, config: Config) => {
setTree(changedTree);
const newSql = {
...sql,
whereJsonTree: Utils.getTree(changedTree),
whereString: Utils.sqlFormat(changedTree, config),
};
onSqlChange(newSql);
},
[onSqlChange, sql]
);
if (!tree) {
return null;
}
return (
<Query
{...configWithDefaults}
value={tree}
onChange={onTreeChange}
renderBuilder={(props) => <Builder {...props} />}
/>
);
}
function flex(direction: string) {
return `
display: flex;
gap: 8px;
flex-direction: ${direction};`;
}
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
injectGlobal`
.group--header {
${flex('row')}
}
.group-or-rule {
${flex('column')}
.rule {
flex-direction: row;
}
}
.rule--body {
${flex('row')}
}
.group--children {
${flex('column')}
}
.group--conjunctions:empty {
display: none;
}
`;

@ -0,0 +1,112 @@
import { OperatorType } from '@grafana/experimental';
export const AGGREGATE_FNS = [
{
id: 'AVG',
name: 'AVG',
description: `AVG(
[DISTINCT]
expression
)
[OVER (...)]
Returns the average of non-NULL input values, or NaN if the input contains a NaN.`,
},
{
id: 'COUNT',
name: 'COUNT',
description: `COUNT(*) [OVER (...)]
Returns the number of rows in the input.
COUNT(
[DISTINCT]
expression
)
[OVER (...)]
Returns the number of rows with expression evaluated to any value other than NULL.
`,
},
{
id: 'MAX',
name: 'MAX',
description: `MAX(
expression
)
[OVER (...)]
Returns the maximum value of non-NULL expressions. Returns NULL if there are zero input rows or expression evaluates to NULL for all rows. Returns NaN if the input contains a NaN.
`,
},
{
id: 'MIN',
name: 'MIN',
description: `MIN(
expression
)
[OVER (...)]
Returns the minimum value of non-NULL expressions. Returns NULL if there are zero input rows or expression evaluates to NULL for all rows. Returns NaN if the input contains a NaN.
`,
},
{
id: 'SUM',
name: 'SUM',
description: `SUM(
[DISTINCT]
expression
)
[OVER (...)]
Returns the sum of non-null values.
If the expression is a floating point value, the sum is non-deterministic, which means you might receive a different result each time you use this function.
`,
},
];
export const OPERATORS = [
{ type: OperatorType.Comparison, id: 'LESS_THAN', operator: '<', description: 'Returns TRUE if X is less than Y.' },
{
type: OperatorType.Comparison,
id: 'LESS_THAN_EQUAL',
operator: '<=',
description: 'Returns TRUE if X is less than or equal to Y.',
},
{
type: OperatorType.Comparison,
id: 'GREATER_THAN',
operator: '>',
description: 'Returns TRUE if X is greater than Y.',
},
{
type: OperatorType.Comparison,
id: 'GREATER_THAN_EQUAL',
operator: '>=',
description: 'Returns TRUE if X is greater than or equal to Y.',
},
{ type: OperatorType.Comparison, id: 'EQUAL', operator: '=', description: 'Returns TRUE if X is equal to Y.' },
{
type: OperatorType.Comparison,
id: 'NOT_EQUAL',
operator: '!=',
description: 'Returns TRUE if X is not equal to Y.',
},
{
type: OperatorType.Comparison,
id: 'NOT_EQUAL_ALT',
operator: '<>',
description: 'Returns TRUE if X is not equal to Y.',
},
{
type: OperatorType.Comparison,
id: 'LIKE',
operator: 'LIKE',
description: `Checks if the STRING in the first operand X matches a pattern specified by the second operand Y. Expressions can contain these characters:
- A percent sign "%" matches any number of characters or bytes
- An underscore "_" matches a single character or byte
- You can escape "\", "_", or "%" using two backslashes. For example, "\\%". If you are using raw strings, only a single backslash is required. For example, r"\%".`,
},
{ type: OperatorType.Logical, id: 'AND', operator: 'AND' },
{ type: OperatorType.Logical, id: 'OR', operator: 'OR' },
];

@ -0,0 +1,229 @@
import { lastValueFrom, of } from 'rxjs';
import { catchError, map } from 'rxjs/operators';
import {
AnnotationEvent,
DataFrame,
DataFrameView,
DataQueryRequest,
DataQueryResponse,
DataSourceInstanceSettings,
DataSourceRef,
MetricFindValue,
ScopedVars,
} from '@grafana/data';
import {
BackendDataSourceResponse,
DataSourceWithBackend,
FetchResponse,
getBackendSrv,
getTemplateSrv,
TemplateSrv,
} from '@grafana/runtime';
import { toTestingStatus } from '@grafana/runtime/src/utils/queryResponse';
import { VariableWithMultiSupport } from '../../../variables/types';
import { getSearchFilterScopedVar } from '../../../variables/utils';
import {
DB,
SQLQuery,
SQLOptions,
SqlQueryForInterpolation,
ResponseParser,
SqlQueryModel,
QueryFormat,
} from '../types';
export abstract class SqlDatasource extends DataSourceWithBackend<SQLQuery, SQLOptions> {
id: number;
name: string;
interval: string;
db: DB;
constructor(
instanceSettings: DataSourceInstanceSettings<SQLOptions>,
protected readonly templateSrv: TemplateSrv = getTemplateSrv()
) {
super(instanceSettings);
this.name = instanceSettings.name;
this.id = instanceSettings.id;
const settingsData = instanceSettings.jsonData || {};
this.interval = settingsData.timeInterval || '1m';
this.db = this.getDB();
}
abstract getDB(dsID?: number): DB;
abstract getQueryModel(target?: SQLQuery, templateSrv?: TemplateSrv, scopedVars?: ScopedVars): SqlQueryModel;
abstract getResponseParser(): ResponseParser;
interpolateVariable = (value: string | string[] | number, variable: VariableWithMultiSupport) => {
if (typeof value === 'string') {
if (variable.multi || variable.includeAll) {
const result = this.getQueryModel().quoteLiteral(value);
return result;
} else {
return value;
}
}
if (typeof value === 'number') {
return value;
}
if (Array.isArray(value)) {
const quotedValues = value.map((v) => this.getQueryModel().quoteLiteral(v));
return quotedValues.join(',');
}
return value;
};
interpolateVariablesInQueries(
queries: SqlQueryForInterpolation[],
scopedVars: ScopedVars
): SqlQueryForInterpolation[] {
let expandedQueries = queries;
if (queries && queries.length > 0) {
expandedQueries = queries.map((query) => {
const expandedQuery = {
...query,
datasource: this.getRef(),
rawSql: this.templateSrv.replace(query.rawSql, scopedVars, this.interpolateVariable),
rawQuery: true,
};
return expandedQuery;
});
}
return expandedQueries;
}
filterQuery(query: SQLQuery): boolean {
return !query.hide;
}
applyTemplateVariables(
target: SQLQuery,
scopedVars: ScopedVars
): Record<string, string | DataSourceRef | SQLQuery['format']> {
const queryModel = this.getQueryModel(target, this.templateSrv, scopedVars);
const rawSql = this.clean(queryModel.interpolate());
return {
refId: target.refId,
datasource: this.getRef(),
rawSql,
format: target.format,
};
}
clean(value: string) {
return value.replace(/''/g, "'");
}
// eslint-ignore @typescript-eslint/no-explicit-any
async annotationQuery(options: any): Promise<AnnotationEvent[]> {
if (!options.annotation.rawQuery) {
return Promise.reject({
message: 'Query missing in annotation definition',
});
}
const query = {
refId: options.annotation.name,
datasource: this.getRef(),
rawSql: this.templateSrv.replace(options.annotation.rawQuery, options.scopedVars, this.interpolateVariable),
format: 'table',
};
return lastValueFrom(
getBackendSrv()
.fetch<BackendDataSourceResponse>({
url: '/api/ds/query',
method: 'POST',
data: {
from: options.range.from.valueOf().toString(),
to: options.range.to.valueOf().toString(),
queries: [query],
},
requestId: options.annotation.name,
})
.pipe(
map(
async (res: FetchResponse<BackendDataSourceResponse>) =>
await this.getResponseParser().transformAnnotationResponse(options, res.data)
)
)
);
}
async metricFindQuery(query: string, optionalOptions: any): Promise<MetricFindValue[]> {
const rawSql = this.templateSrv.replace(
query,
getSearchFilterScopedVar({ query, wildcardChar: '%', options: optionalOptions }),
this.interpolateVariable
);
const interpolatedQuery = {
datasourceId: this.id,
datasource: this.getRef(),
rawSql,
format: QueryFormat.Table,
};
const response = await this.runQuery(interpolatedQuery, optionalOptions);
return this.getResponseParser().transformMetricFindResponse(response);
}
async runSql<T = any>(query: string) {
const frame = await this.runQuery({ rawSql: query, format: QueryFormat.Table }, {});
return new DataFrameView<T>(frame);
}
private runQuery(request: Partial<SQLQuery>, options?: any): Promise<DataFrame> {
return new Promise((resolve) => {
const req = {
targets: [{ ...request, refId: String(Math.random()) }],
range: options?.range,
} as DataQueryRequest<SQLQuery>;
this.query(req).subscribe((res: DataQueryResponse) => {
resolve(res.data[0] || { fields: [] });
});
});
}
testDatasource(): Promise<any> {
return lastValueFrom(
getBackendSrv()
.fetch({
url: '/api/ds/query',
method: 'POST',
data: {
from: '5m',
to: 'now',
queries: [
{
refId: 'A',
intervalMs: 1,
maxDataPoints: 1,
datasource: this.getRef(),
datasourceId: this.id,
rawSql: 'SELECT 1',
format: 'table',
},
],
},
})
.pipe(
map(() => ({ status: 'success', message: 'Database Connection OK' })),
catchError((err) => {
return of(toTestingStatus(err));
})
)
);
}
targetContainsTemplate(target: any) {
return this.templateSrv.containsTemplate(target.rawSql);
}
}

@ -0,0 +1,29 @@
import { EditorMode } from '@grafana/experimental';
import { QueryFormat, SQLQuery } from './types';
import { createFunctionField, setGroupByField } from './utils/sql.utils';
export function applyQueryDefaults(q: SQLQuery): SQLQuery {
let editorMode = q.editorMode || EditorMode.Builder;
// Switching to code editor if the query was created before visual query builder was introduced.
if (q.editorMode === undefined && q.rawSql !== undefined) {
editorMode = EditorMode.Code;
}
const result = {
...q,
format: q.format !== undefined ? q.format : QueryFormat.Table,
rawSql: q.rawSql || '',
editorMode,
sql: q.sql || {
columns: [createFunctionField()],
groupBy: [setGroupByField()],
limit: 50,
},
};
return result;
}
export type QueryWithDefaults = ReturnType<typeof applyQueryDefaults>;

@ -0,0 +1,66 @@
export enum QueryEditorPropertyType {
String = 'string',
}
export interface QueryEditorProperty {
type: QueryEditorPropertyType;
name?: string;
}
export type QueryEditorOperatorType = string | boolean | number;
type QueryEditorOperatorValueType = QueryEditorOperatorType | QueryEditorOperatorType[];
export interface QueryEditorOperator<T extends QueryEditorOperatorValueType> {
name?: string;
value?: T;
}
export interface QueryEditorOperatorExpression {
type: QueryEditorExpressionType.Operator;
property: QueryEditorProperty;
operator: QueryEditorOperator<QueryEditorOperatorValueType>;
}
export interface QueryEditorArrayExpression {
type: QueryEditorExpressionType.And | QueryEditorExpressionType.Or;
expressions: QueryEditorExpression[] | QueryEditorArrayExpression[];
}
export interface QueryEditorPropertyExpression {
type: QueryEditorExpressionType.Property;
property: QueryEditorProperty;
}
export enum QueryEditorExpressionType {
Property = 'property',
Operator = 'operator',
Or = 'or',
And = 'and',
GroupBy = 'groupBy',
Function = 'function',
FunctionParameter = 'functionParameter',
}
export type QueryEditorExpression =
| QueryEditorArrayExpression
| QueryEditorPropertyExpression
| QueryEditorGroupByExpression
| QueryEditorFunctionExpression
| QueryEditorFunctionParameterExpression
| QueryEditorOperatorExpression;
export interface QueryEditorGroupByExpression {
type: QueryEditorExpressionType.GroupBy;
property: QueryEditorProperty;
}
export interface QueryEditorFunctionExpression {
type: QueryEditorExpressionType.Function;
name?: string;
parameters?: QueryEditorFunctionParameterExpression[];
}
export interface QueryEditorFunctionParameterExpression {
type: QueryEditorExpressionType.FunctionParameter;
name?: string;
}

@ -0,0 +1,161 @@
import { JsonTree } from 'react-awesome-query-builder';
import {
AnnotationEvent,
DataFrame,
DataQuery,
DataSourceJsonData,
MetricFindValue,
SelectableValue,
TimeRange,
toOption as toOptionFromData,
} from '@grafana/data';
import { CompletionItemKind, EditorMode, LanguageCompletionProvider } from '@grafana/experimental';
import { BackendDataSourceResponse } from '@grafana/runtime';
import { QueryWithDefaults } from './defaults';
import {
QueryEditorFunctionExpression,
QueryEditorGroupByExpression,
QueryEditorPropertyExpression,
} from './expressions';
export interface SqlQueryForInterpolation {
dataset?: string;
alias?: string;
format?: ResultFormat;
rawSql?: string;
refId: string;
hide?: boolean;
}
export interface SQLOptions extends DataSourceJsonData {
timeInterval: string;
database: string;
}
export type ResultFormat = 'time_series' | 'table';
export enum QueryFormat {
Timeseries = 'time_series',
Table = 'table',
}
export interface SQLQuery extends DataQuery {
alias?: string;
format?: ResultFormat | QueryFormat | string | undefined;
rawSql?: string;
dataset?: string;
table?: string;
sql?: SQLExpression;
editorMode?: EditorMode;
rawQuery?: boolean;
}
export interface NameValue {
name: string;
value: string;
}
export type SQLFilters = NameValue[];
export interface SQLExpression {
columns?: QueryEditorFunctionExpression[];
whereJsonTree?: JsonTree;
whereString?: string;
filters?: SQLFilters;
groupBy?: QueryEditorGroupByExpression[];
orderBy?: QueryEditorPropertyExpression;
orderByDirection?: 'ASC' | 'DESC';
limit?: number;
offset?: number;
}
export interface TableSchema {
name?: string;
schema?: TableFieldSchema[];
}
export interface TableFieldSchema {
name: string;
description?: string;
type: string;
repeated: boolean;
schema: TableFieldSchema[];
}
export interface QueryRowFilter {
filter: boolean;
group: boolean;
order: boolean;
preview: boolean;
}
export const QUERY_FORMAT_OPTIONS = [
{ label: 'Time series', value: QueryFormat.Timeseries },
{ label: 'Table', value: QueryFormat.Table },
];
const backWardToOption = (value: string) => ({ label: value, value });
export const toOption = toOptionFromData ?? backWardToOption;
export interface ResourceSelectorProps {
disabled?: boolean;
className?: string;
applyDefault?: boolean;
}
// React Awesome Query builder field types.
// These are responsible for rendering the correct UI for the field.
export type RAQBFieldTypes = 'text' | 'number' | 'boolean' | 'datetime' | 'date' | 'time';
export interface SQLSelectableValue extends SelectableValue {
type?: string;
raqbFieldType?: RAQBFieldTypes;
}
export interface DB {
init?: (datasourceId?: string) => Promise<boolean>;
datasets: () => Promise<string[]>;
tables: (dataset?: string) => Promise<string[]>;
fields: (query: SQLQuery, order?: boolean) => Promise<SQLSelectableValue[]>;
validateQuery: (query: SQLQuery, range?: TimeRange) => Promise<ValidationResults>;
dsID: () => string;
dispose?: (dsID?: string) => void;
lookup: (path?: string) => Promise<Array<{ name: string; completion: string }>>;
getSqlCompletionProvider: () => LanguageCompletionProvider;
toRawSql?: (query: SQLQuery) => string;
}
export interface QueryEditorProps {
db: DB;
query: QueryWithDefaults;
onChange: (query: SQLQuery) => void;
range?: TimeRange;
}
export interface ValidationResults {
query: SQLQuery;
rawSql: string;
error: string;
isError: boolean;
isValid: boolean;
statistics?: {
TotalBytesProcessed: number;
} | null;
}
export interface SqlQueryModel {
interpolate: () => string;
quoteLiteral: (v: string) => string;
}
export interface ResponseParser {
transformAnnotationResponse: (options: object, data: BackendDataSourceResponse) => Promise<AnnotationEvent[]>;
transformMetricFindResponse: (frame: DataFrame) => MetricFindValue[];
}
export interface MetaDefinition {
name: string;
completion?: string;
kind: CompletionItemKind;
}

@ -0,0 +1,8 @@
// @ts-ignore
import sqlFormatter from 'sql-formatter-plus';
export function formatSQL(q: string) {
return sqlFormatter.format(q).replace(/(\$ \{ .* \})|(\$ __)|(\$ \w+)/g, (m: string) => {
return m.replace(/\s/g, '');
});
}

@ -0,0 +1,105 @@
import { isEmpty } from 'lodash';
import {
QueryEditorExpressionType,
QueryEditorFunctionExpression,
QueryEditorGroupByExpression,
QueryEditorPropertyExpression,
QueryEditorPropertyType,
} from '../expressions';
import { SQLQuery, SQLExpression } from '../types';
export function defaultToRawSql({ sql, dataset, table }: SQLQuery): string {
let rawQuery = '';
// Return early with empty string if there is no sql column
if (!sql || !haveColumns(sql.columns)) {
return rawQuery;
}
rawQuery += createSelectClause(sql.columns);
if (dataset && table) {
rawQuery += `FROM ${dataset}.${table} `;
}
if (sql.whereString) {
rawQuery += `WHERE ${sql.whereString} `;
}
if (sql.groupBy?.[0]?.property.name) {
const groupBy = sql.groupBy.map((g) => g.property.name).filter((g) => !isEmpty(g));
rawQuery += `GROUP BY ${groupBy.join(', ')} `;
}
if (sql.orderBy?.property.name) {
rawQuery += `ORDER BY ${sql.orderBy.property.name} `;
}
if (sql.orderBy?.property.name && sql.orderByDirection) {
rawQuery += `${sql.orderByDirection} `;
}
// Altough LIMIT 0 doesn't make sense, it is still possible to have LIMIT 0
if (sql.limit !== undefined && sql.limit >= 0) {
rawQuery += `LIMIT ${sql.limit} `;
}
return rawQuery;
}
function createSelectClause(sqlColumns: NonNullable<SQLExpression['columns']>): string {
const columns = sqlColumns.map((c) => {
let rawColumn = '';
if (c.name) {
rawColumn += `${c.name}(${c.parameters?.map((p) => `${p.name}`)})`;
} else {
rawColumn += `${c.parameters?.map((p) => `${p.name}`)}`;
}
return rawColumn;
});
return `SELECT ${columns.join(', ')} `;
}
export const haveColumns = (columns: SQLExpression['columns']): columns is NonNullable<SQLExpression['columns']> => {
if (!columns) {
return false;
}
const haveColumn = columns.some((c) => c.parameters?.length || c.parameters?.some((p) => p.name));
const haveFunction = columns.some((c) => c.name);
return haveColumn || haveFunction;
};
/**
* Creates a GroupByExpression for a specified field
*/
export function setGroupByField(field?: string): QueryEditorGroupByExpression {
return {
type: QueryEditorExpressionType.GroupBy,
property: {
type: QueryEditorPropertyType.String,
name: field,
},
};
}
/**
* Creates a PropertyExpression for a specified field
*/
export function setPropertyField(field?: string): QueryEditorPropertyExpression {
return {
type: QueryEditorExpressionType.Property,
property: {
type: QueryEditorPropertyType.String,
name: field,
},
};
}
export function createFunctionField(functionName?: string): QueryEditorFunctionExpression {
return {
type: QueryEditorExpressionType.Function,
name: functionName,
parameters: [],
};
}

@ -0,0 +1,25 @@
import { useCallback } from 'react';
import { DB, SQLExpression, SQLQuery } from '../types';
import { defaultToRawSql } from './sql.utils';
interface UseSqlChange {
db: DB;
query: SQLQuery;
onQueryChange: (query: SQLQuery) => void;
}
export function useSqlChange({ query, onQueryChange, db }: UseSqlChange) {
const onSqlChange = useCallback(
(sql: SQLExpression) => {
const toRawSql = db.toRawSql || defaultToRawSql;
const rawSql = toRawSql({ sql, dataset: query.dataset, table: query.table, refId: db.dsID() });
const newQuery: SQLQuery = { ...query, sql, rawSql };
onQueryChange(newQuery);
},
[db, onQueryChange, query]
);
return { onSqlChange };
}

@ -4096,6 +4096,24 @@ __metadata:
languageName: node
linkType: hard
"@date-io/core@npm:^1.3.13":
version: 1.3.13
resolution: "@date-io/core@npm:1.3.13"
checksum: 5a9e9d1de20f0346a3c7d2d5946190caef4bfb0b64d82ba1f4c566657a9192667c94ebe7f438d11d4286d9c190974daad4fb2159294225cd8af4d9a140239879
languageName: node
linkType: hard
"@date-io/moment@npm:^1.3.13":
version: 1.3.13
resolution: "@date-io/moment@npm:1.3.13"
dependencies:
"@date-io/core": ^1.3.13
peerDependencies:
moment: ^2.24.0
checksum: c4847f9d1bf09bb22c1acc5806fc50825c0bdb52408c9fec8cc5f9a65f16a8014870b5890a0eeb73b2c53a6487c35acbd2ab2a5c49068ff5a5dccbcb5c9e654b
languageName: node
linkType: hard
"@daybrush/utils@npm:1.6.0, @daybrush/utils@npm:^1.0.0, @daybrush/utils@npm:^1.1.1, @daybrush/utils@npm:^1.3.1, @daybrush/utils@npm:^1.4.0":
version: 1.6.0
resolution: "@daybrush/utils@npm:1.6.0"
@ -15607,6 +15625,13 @@ __metadata:
languageName: node
linkType: hard
"clone@npm:^2.1.2":
version: 2.1.2
resolution: "clone@npm:2.1.2"
checksum: aaf106e9bc025b21333e2f4c12da539b568db4925c0501a1bf4070836c9e848c892fa22c35548ce0d1132b08bbbfa17a00144fe58fccdab6fa900fec4250f67d
languageName: node
linkType: hard
"clsx@npm:^1.1.1":
version: 1.1.1
resolution: "clsx@npm:1.1.1"
@ -21292,6 +21317,7 @@ __metadata:
rc-time-picker: 3.7.3
re-resizable: 6.9.9
react: 17.0.2
react-awesome-query-builder: ^5.1.2
react-beautiful-dnd: 13.1.0
react-diff-viewer: ^3.1.1
react-dom: 17.0.2
@ -26765,7 +26791,7 @@ __metadata:
languageName: node
linkType: hard
"moment@npm:2.29.3, moment@npm:2.x, moment@npm:>= 2.9.0, moment@npm:^2.19.4, moment@npm:^2.20.1":
"moment@npm:2.29.3, moment@npm:2.x, moment@npm:>= 2.9.0, moment@npm:^2.19.4, moment@npm:^2.20.1, moment@npm:^2.29.1":
version: 2.29.3
resolution: "moment@npm:2.29.3"
checksum: 2e780e36d9a1823c08a1b6313cbb08bd01ecbb2a9062095820a34f42c878991ccba53abaa6abb103fd5c01e763724f295162a8c50b7e95b4f1c992ef0772d3f0
@ -30856,6 +30882,45 @@ __metadata:
languageName: node
linkType: hard
"react-awesome-query-builder@npm:^5.1.2":
version: 5.1.2
resolution: "react-awesome-query-builder@npm:5.1.2"
dependencies:
"@date-io/moment": ^1.3.13
classnames: ^2.3.1
clone: ^2.1.2
immutable: ^3.8.2
lodash: ^4.17.21
moment: ^2.29.1
prop-types: ^15.7.2
react-redux: ^7.2.2
redux: ^4.1.0
spel2js: ^0.2.8
sqlstring: ^2.3.2
peerDependencies:
"@ant-design/icons": ^4.0.0
"@emotion/react": ^11.7.1
"@emotion/styled": ^11.6.0
"@fortawesome/fontawesome-svg-core": ^1.2.36
"@fortawesome/free-solid-svg-icons": ^5.15.4
"@fortawesome/react-fontawesome": ^0.1.16
"@material-ui/core": ^4.12.3
"@material-ui/icons": ^4.0.0
"@material-ui/lab": ^4.0.0-alpha.57
"@material-ui/pickers": ^3.2.10
"@mui/icons-material": ^5.2.4
"@mui/lab": ^5.0.0-alpha.60
"@mui/material": ^5.2.4
antd: ^4.0.0
bootstrap: ^5.1.3
material-ui-confirm: ^2.0.1 || ^3.0.0
react: ^16.8.4 || ^17.0.1
react-dom: ^16.8.4 || ^17.0.1
reactstrap: ^9.0.0
checksum: 6efab0fcbb98d4843fbe3b5036a7831fd097774869fc0e175ca40c4770d7939dd3efcb2c3365c6fe539c8ba6b6bb19ddef55d93f491770a9bfdc3f1355cc5e4e
languageName: node
linkType: hard
"react-beautiful-dnd@npm:13.1.0":
version: 13.1.0
resolution: "react-beautiful-dnd@npm:13.1.0"
@ -31440,6 +31505,27 @@ __metadata:
languageName: node
linkType: hard
"react-redux@npm:^7.2.2":
version: 7.2.8
resolution: "react-redux@npm:7.2.8"
dependencies:
"@babel/runtime": ^7.15.4
"@types/react-redux": ^7.1.20
hoist-non-react-statics: ^3.3.2
loose-envify: ^1.4.0
prop-types: ^15.7.2
react-is: ^17.0.2
peerDependencies:
react: ^16.8.3 || ^17 || ^18
peerDependenciesMeta:
react-dom:
optional: true
react-native:
optional: true
checksum: ecf1933e91013f2d41bfc781515b536bf81eb1f70ff228607841094c8330fe77d522372b359687e51c0b52b9888dba73db9ac0486aace1896ab9eb9daec102d5
languageName: node
linkType: hard
"react-refresh@npm:0.14.0":
version: 0.14.0
resolution: "react-refresh@npm:0.14.0"
@ -32064,7 +32150,7 @@ __metadata:
languageName: node
linkType: hard
"redux@npm:4.2.0":
"redux@npm:4.2.0, redux@npm:^4.1.0":
version: 4.2.0
resolution: "redux@npm:4.2.0"
dependencies:
@ -34065,6 +34151,13 @@ __metadata:
languageName: node
linkType: hard
"spel2js@npm:^0.2.8":
version: 0.2.8
resolution: "spel2js@npm:0.2.8"
checksum: a81f30b90438c6fef27627d8f91e2ce9040cc3743918846e535e44bdbf76be4d404d281e83b1be8287f36c1566a22e20d711532b941838f9ca7673e5caebf75f
languageName: node
linkType: hard
"split-on-first@npm:^1.0.0":
version: 1.1.0
resolution: "split-on-first@npm:1.1.0"
@ -34115,6 +34208,13 @@ __metadata:
languageName: node
linkType: hard
"sqlstring@npm:^2.3.2":
version: 2.3.3
resolution: "sqlstring@npm:2.3.3"
checksum: 1e7e2d51c38a0cf7372e875408ca100b6e0c9a941ab7773975ea41fb36e5528e404dc787689be855780cf6d0a829ff71027964ae3a05a7446e91dce26672fda7
languageName: node
linkType: hard
"sshpk@npm:^1.14.1, sshpk@npm:^1.7.0":
version: 1.16.1
resolution: "sshpk@npm:1.16.1"

Loading…
Cancel
Save