mirror of
https://github.com/featurebasedb/featurebase.git
synced 2026-09-11 23:31:03 +00:00
initial refactor commit
This commit is contained in:
parent
6935ac2667
commit
fa353faf72
21 changed files with 1666 additions and 2 deletions
|
|
@ -9,7 +9,7 @@ import { Nav } from 'shared/Nav';
|
|||
import { NotFound } from 'App/NotFound';
|
||||
import { MoleculaTablesContainer } from 'App/MoleculaTables';
|
||||
import { QueryContainer } from 'App/Query';
|
||||
import { QueryBuilderContainer } from 'App/QueryBuilder';
|
||||
import { QBuilderContainer } from 'App/QBuilder';
|
||||
import css from './App.module.scss';
|
||||
|
||||
const App = () => {
|
||||
|
|
@ -45,7 +45,7 @@ const App = () => {
|
|||
<Route exact path="/" component={Home} />
|
||||
<Route path="/tables/:id?" component={MoleculaTablesContainer} />
|
||||
<Route exact path="/query" component={QueryContainer} />
|
||||
<Route exact path="/querybuilder" component={QueryBuilderContainer} />
|
||||
<Route exact path="/querybuilder" component={QBuilderContainer} />
|
||||
<Route component={NotFound} />
|
||||
</Switch>
|
||||
</div>
|
||||
|
|
|
|||
36
lattice/src/App/QBuilder/CountBuilder/CountBuilder.tsx
Normal file
36
lattice/src/App/QBuilder/CountBuilder/CountBuilder.tsx
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import React, { FC } from 'react';
|
||||
import { RowCallBuilder } from 'App/QBuilder/RowCallBuilder';
|
||||
|
||||
type CountBuilderProps = {
|
||||
table: any;
|
||||
query: any;
|
||||
showInvalid: boolean;
|
||||
onChange: (query: any) => void;
|
||||
};
|
||||
|
||||
export const CountBuilder: FC<CountBuilderProps> = ({
|
||||
table,
|
||||
query,
|
||||
showInvalid,
|
||||
onChange
|
||||
}) => {
|
||||
const { rowCalls } = query ? query : { rowCalls: [] };
|
||||
|
||||
return (
|
||||
<div>
|
||||
<RowCallBuilder
|
||||
rowCalls={rowCalls}
|
||||
fields={table.fields}
|
||||
showInvalid={showInvalid}
|
||||
onChange={(rowCalls, operator, isInvalid) => {
|
||||
onChange({
|
||||
...query,
|
||||
rowCalls,
|
||||
operator,
|
||||
isInvalid
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
1
lattice/src/App/QBuilder/CountBuilder/index.ts
Normal file
1
lattice/src/App/QBuilder/CountBuilder/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from './CountBuilder';
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
.columnsSelector {
|
||||
margin: 4px 14px 4px 0;
|
||||
display: flex;
|
||||
text-align: center;
|
||||
|
||||
.info {
|
||||
margin-left: 4px;
|
||||
fill: var(--text-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.textLink {
|
||||
color: var(--primary);
|
||||
font-size: 12px;
|
||||
|
||||
&:hover {
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
color: var(--primary);
|
||||
}
|
||||
}
|
||||
92
lattice/src/App/QBuilder/ExtractBuilder/ExtractBuilder.tsx
Normal file
92
lattice/src/App/QBuilder/ExtractBuilder/ExtractBuilder.tsx
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
import React, { FC, useState } from 'react';
|
||||
import InfoIcon from '@material-ui/icons/Info';
|
||||
import Tooltip from '@material-ui/core/Tooltip';
|
||||
import { ColumnSelector } from 'App/QueryBuilder/ColumnSelector';
|
||||
import { RowCallBuilder } from 'App/QBuilder/RowCallBuilder';
|
||||
import { useEffectOnce } from 'react-use';
|
||||
import css from './ExtractBuilder.module.scss';
|
||||
|
||||
type ExtractBuilderProps = {
|
||||
table: any;
|
||||
query: any;
|
||||
showInvalid: boolean;
|
||||
onChange: (query: any) => void;
|
||||
};
|
||||
|
||||
export const ExtractBuilder: FC<ExtractBuilderProps> = ({
|
||||
table,
|
||||
query,
|
||||
showInvalid,
|
||||
onChange
|
||||
}) => {
|
||||
const { columns, rowCalls } = query
|
||||
? query
|
||||
: {
|
||||
columns: [],
|
||||
rowCalls: []
|
||||
};
|
||||
const [showColumnSelector, setShowColumnSelector] = useState<boolean>(false);
|
||||
|
||||
useEffectOnce(() => {
|
||||
if (!query) {
|
||||
onChange({
|
||||
columns: table.fields.map((field) => field.name),
|
||||
operation: 'Extract',
|
||||
rowCalls: []
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={css.extract}>
|
||||
<div className={css.columnsSelector}>
|
||||
<span
|
||||
className={css.textLink}
|
||||
onClick={() => setShowColumnSelector(true)}
|
||||
>
|
||||
Configure result fields
|
||||
</span>
|
||||
<Tooltip
|
||||
className={css.info}
|
||||
title="This controls the fields that will show up in the results table after querying"
|
||||
placement="top"
|
||||
arrow
|
||||
>
|
||||
<InfoIcon fontSize="inherit" />
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<ColumnSelector
|
||||
open={showColumnSelector}
|
||||
fieldsList={table.fields.map((field) => ({
|
||||
name: field.name,
|
||||
show: columns ? columns.includes(field.name) : true
|
||||
}))}
|
||||
onChange={(allColumns) => {
|
||||
let cols: string[] = [];
|
||||
allColumns.forEach((col) => {
|
||||
if (col.show) {
|
||||
cols.push(col.name);
|
||||
}
|
||||
});
|
||||
onChange({ ...query, columns: cols });
|
||||
}}
|
||||
onClose={() => setShowColumnSelector(false)}
|
||||
/>
|
||||
|
||||
<RowCallBuilder
|
||||
rowCalls={rowCalls}
|
||||
fields={table.fields}
|
||||
showInvalid={showInvalid}
|
||||
onChange={(rowCalls, operator, isInvalid) => {
|
||||
onChange({
|
||||
...query,
|
||||
rowCalls,
|
||||
operator,
|
||||
isInvalid
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
1
lattice/src/App/QBuilder/ExtractBuilder/index.ts
Normal file
1
lattice/src/App/QBuilder/ExtractBuilder/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from './ExtractBuilder';
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
.groupBy {
|
||||
.fieldSelector {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.filtersHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.filtersInfo {
|
||||
vertical-align: text-top;
|
||||
margin-left: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.textLink {
|
||||
color: var(--primary);
|
||||
font-size: 12px;
|
||||
|
||||
&:hover {
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
color: var(--primary);
|
||||
}
|
||||
}
|
||||
|
||||
.filtersSection {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
179
lattice/src/App/QBuilder/GroupByBuilder/GroupByBuilder.tsx
Normal file
179
lattice/src/App/QBuilder/GroupByBuilder/GroupByBuilder.tsx
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
import React, { FC } from 'react';
|
||||
import InfoIcon from '@material-ui/icons/Info';
|
||||
import Tooltip from '@material-ui/core/Tooltip';
|
||||
import Typography from '@material-ui/core/Typography';
|
||||
import { GroupBySort } from 'App/QueryBuilder/GroupBySort';
|
||||
import { SavedQueries } from 'App/QueryBuilder/SavedQueries';
|
||||
import { Select } from 'shared/Select';
|
||||
import { stringifyRowData } from 'App/QueryBuilder/stringifyRowData';
|
||||
import css from './GroupByBuilder.module.scss';
|
||||
|
||||
type GroupByBuilderProps = {
|
||||
table: any;
|
||||
query: any;
|
||||
showInvalid: boolean;
|
||||
onChange: (query: any) => void;
|
||||
};
|
||||
|
||||
export const GroupByBuilder: FC<GroupByBuilderProps> = ({
|
||||
table,
|
||||
query,
|
||||
showInvalid,
|
||||
onChange
|
||||
}) => {
|
||||
const { groupByCall, filter, sort } = query;
|
||||
const filters = JSON.parse(
|
||||
localStorage.getItem('saved-queries') || '[]'
|
||||
).filter(
|
||||
(q) =>
|
||||
q.table === table.name &&
|
||||
q.operation === 'Extract' &&
|
||||
q.rowCalls.length > 0
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={css.groupBy}>
|
||||
<Select
|
||||
className={css.fieldSelector}
|
||||
label="Primary Field"
|
||||
value={
|
||||
groupByCall ? (groupByCall.primary ? groupByCall.primary : '') : ''
|
||||
}
|
||||
options={table.fields
|
||||
.filter((f) =>
|
||||
['set', 'time', 'mutex', 'bool', 'int', 'timestamp'].includes(
|
||||
f.options.type
|
||||
)
|
||||
)
|
||||
.map((field) => {
|
||||
return {
|
||||
label: `${field.name} (${field.options.type})`,
|
||||
value: field.name
|
||||
};
|
||||
})}
|
||||
onChange={(value) =>
|
||||
onChange({
|
||||
...query,
|
||||
groupByCall: {
|
||||
...query.groupByCall,
|
||||
primary: value.toString()
|
||||
}
|
||||
})
|
||||
}
|
||||
error={showInvalid && !groupByCall.primary}
|
||||
/>
|
||||
|
||||
<Select
|
||||
className={css.fieldSelector}
|
||||
label="Secondary Field (optional)"
|
||||
value={
|
||||
groupByCall
|
||||
? groupByCall.secondary
|
||||
? groupByCall.secondary
|
||||
: ''
|
||||
: ''
|
||||
}
|
||||
allowEmpty={true}
|
||||
options={table.fields
|
||||
.filter((f) =>
|
||||
['set', 'time', 'mutex', 'bool', 'int', 'timestamp'].includes(
|
||||
f.options.type
|
||||
)
|
||||
)
|
||||
.map((field) => {
|
||||
return {
|
||||
label: `${field.name} (${field.options.type})`,
|
||||
value: field.name
|
||||
};
|
||||
})}
|
||||
onChange={(value) =>
|
||||
onChange({
|
||||
...query,
|
||||
groupByCall: {
|
||||
...query.groupByCall,
|
||||
secondary: value.toString()
|
||||
}
|
||||
})
|
||||
}
|
||||
/>
|
||||
|
||||
<div className={css.filtersSection}>
|
||||
<div className={css.filtersHeader}>
|
||||
<Typography variant="caption" color="textSecondary">
|
||||
Filter (optional)
|
||||
{filters.length > 0 && (
|
||||
<Tooltip
|
||||
className={css.filtersInfo}
|
||||
title={`To use filters, save an Extract query for ${table.name} with at least one field constraint.`}
|
||||
placement="top"
|
||||
arrow
|
||||
>
|
||||
<InfoIcon fontSize="inherit" />
|
||||
</Tooltip>
|
||||
)}
|
||||
</Typography>
|
||||
{filter ? (
|
||||
<span
|
||||
className={css.textLink}
|
||||
onClick={() => onChange({ ...query, filter: undefined })}
|
||||
>
|
||||
Clear Filter
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{filter ? (
|
||||
<Typography variant="caption">{filter}</Typography>
|
||||
) : filters.length > 0 ? (
|
||||
<SavedQueries
|
||||
queries={filters}
|
||||
tables={[table]}
|
||||
onClickSaved={(queryIdx) => {
|
||||
const { rowCalls, operator } = filters[queryIdx];
|
||||
const res = stringifyRowData(rowCalls, operator);
|
||||
if (!res.error) {
|
||||
onChange({ ...query, filter: res.query });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className={css.infoMessage}>
|
||||
No available filters. To use filters, save an Extract query for{' '}
|
||||
{table.name} with at least one field constraint.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={css.sortSection}>
|
||||
<Typography variant="caption" color="textSecondary">
|
||||
Sort (optional)
|
||||
</Typography>
|
||||
<GroupBySort
|
||||
sort={sort ? sort : []}
|
||||
onUpdate={(value) => {
|
||||
let isInvalid = false;
|
||||
if (
|
||||
value.length > 0 &&
|
||||
value[0].sortValue.includes('sum') &&
|
||||
!value[0].field
|
||||
) {
|
||||
isInvalid = true;
|
||||
} else if (
|
||||
value.length > 1 &&
|
||||
value[1].sortValue.includes('sum') &&
|
||||
!value[1].field
|
||||
) {
|
||||
isInvalid = true;
|
||||
}
|
||||
onChange({ ...query, sort: value, isInvalid });
|
||||
}}
|
||||
fields={table.fields
|
||||
.filter((field) => field.options.type === 'int')
|
||||
.map((field) => {
|
||||
return { label: field.name, value: field.name };
|
||||
})}
|
||||
showErrors={showInvalid}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
1
lattice/src/App/QBuilder/GroupByBuilder/index.ts
Normal file
1
lattice/src/App/QBuilder/GroupByBuilder/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from './GroupByBuilder';
|
||||
52
lattice/src/App/QBuilder/QBuilder.module.scss
Normal file
52
lattice/src/App/QBuilder/QBuilder.module.scss
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
.sharedConfig {
|
||||
padding: 32px 32px 0;
|
||||
|
||||
.configSelector {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
.textLink {
|
||||
color: var(--primary);
|
||||
font-size: 12px;
|
||||
|
||||
.icon {
|
||||
margin-right: 4px;
|
||||
vertical-align: text-top;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
color: var(--primary);
|
||||
}
|
||||
}
|
||||
|
||||
.builder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
|
||||
.queryMetadata {
|
||||
border-bottom: 1px solid var(--divider);
|
||||
padding-bottom: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.config {
|
||||
padding: 24px 32px 0;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.builderActions {
|
||||
padding: 16px 32px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
||||
.mainActions {
|
||||
display: grid;
|
||||
grid-template-columns: auto auto;
|
||||
grid-gap: 8px;
|
||||
}
|
||||
}
|
||||
388
lattice/src/App/QBuilder/QBuilder.tsx
Normal file
388
lattice/src/App/QBuilder/QBuilder.tsx
Normal file
|
|
@ -0,0 +1,388 @@
|
|||
import React, { FC, Fragment, useEffect, useState } from 'react';
|
||||
import ArrowBackIcon from '@material-ui/icons/ArrowBack';
|
||||
import Button from '@material-ui/core/Button';
|
||||
import CircularProgress from '@material-ui/core/CircularProgress';
|
||||
import Divider from '@material-ui/core/Divider';
|
||||
import Popover from '@material-ui/core/Popover';
|
||||
import TextField from '@material-ui/core/TextField';
|
||||
import Typography from '@material-ui/core/Typography';
|
||||
import {
|
||||
cleanupRows,
|
||||
stringifyCount,
|
||||
stringifyExtract,
|
||||
stringifyGroupBy
|
||||
} from './utils';
|
||||
import { CountBuilder } from './CountBuilder';
|
||||
import { ExtractBuilder } from './ExtractBuilder';
|
||||
import { GroupByBuilder } from './GroupByBuilder';
|
||||
import { Select } from 'shared/Select';
|
||||
import css from './QBuilder.module.scss';
|
||||
|
||||
type QBuilderProps = {
|
||||
tables: any[];
|
||||
savedQuery: number;
|
||||
onRun: (
|
||||
table: string,
|
||||
operation: string,
|
||||
query: string,
|
||||
countQuery?: string
|
||||
) => void;
|
||||
onClear: () => void;
|
||||
onExitEdit: () => void;
|
||||
onSaveQuery: () => void;
|
||||
};
|
||||
|
||||
export const QBuilder: FC<QBuilderProps> = ({
|
||||
tables,
|
||||
savedQuery,
|
||||
onRun,
|
||||
onClear,
|
||||
onExitEdit,
|
||||
onSaveQuery
|
||||
}) => {
|
||||
const [saveButtonEl, setSaveButtonEl] = useState<null | HTMLElement>(null);
|
||||
const queriesList = JSON.parse(localStorage.getItem('saved-queries') || '[]');
|
||||
const [editMode, setEditMode] = useState<boolean>(false);
|
||||
const [selectedTable, setSelectedTable] = useState<any>(tables[0]);
|
||||
const [operation, setOperation] = useState<string>('Extract');
|
||||
const [query, setQuery] = useState<any>({
|
||||
table: tables[0].name,
|
||||
operation: 'Extract',
|
||||
columns: tables[0].fields.map((field) => field.name)
|
||||
});
|
||||
const [showInvalid, setShowInvalid] = useState<boolean>(false);
|
||||
const [updating, setUpdating] = useState<boolean>(false);
|
||||
const [queryNameIdx, setQueryNameIdx] = useState<number>(-1);
|
||||
|
||||
useEffect(() => {
|
||||
if (savedQuery > -1) {
|
||||
const updatedList = JSON.parse(
|
||||
localStorage.getItem('saved-queries') || '[]'
|
||||
);
|
||||
setQuery(updatedList[savedQuery]);
|
||||
setEditMode(true);
|
||||
setOperation(updatedList[savedQuery].operation);
|
||||
const table = tables.find(
|
||||
(table) => table.name === updatedList[savedQuery].table
|
||||
);
|
||||
setSelectedTable(table);
|
||||
}
|
||||
}, [savedQuery, tables]);
|
||||
|
||||
useEffect(() => {
|
||||
if (query?.name) {
|
||||
setQueryNameIdx(queriesList.findIndex((q) => q.name === query.name));
|
||||
}
|
||||
}, [query, queriesList]);
|
||||
|
||||
const cleanRows = () => {
|
||||
const cleanRows = cleanupRows(query?.rowCalls);
|
||||
return {
|
||||
...query,
|
||||
rowCalls: cleanRows.cleanRowCalls,
|
||||
isInvalid: cleanRows.isInvalid
|
||||
};
|
||||
};
|
||||
|
||||
const runQuery = () => {
|
||||
setShowInvalid(true);
|
||||
let queryString = '';
|
||||
let countQuery = '';
|
||||
|
||||
switch (operation) {
|
||||
case 'Extract':
|
||||
const cleanExtractQuery = cleanRows();
|
||||
setQuery(cleanExtractQuery);
|
||||
const extract = stringifyExtract(cleanExtractQuery);
|
||||
if (!extract.error && !cleanExtractQuery.isInvalid) {
|
||||
queryString = extract.queryString;
|
||||
countQuery = extract.countQuery;
|
||||
}
|
||||
break;
|
||||
case 'GroupBy':
|
||||
queryString = stringifyGroupBy(query);
|
||||
break;
|
||||
case 'Count':
|
||||
const cleanCountQuery = cleanRows();
|
||||
setQuery(cleanCountQuery);
|
||||
const count = stringifyCount(cleanCountQuery);
|
||||
if (!count.error && !cleanCountQuery.isInvalid) {
|
||||
queryString = count.queryString;
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
if (queryString && !query.isInvalid) {
|
||||
setShowInvalid(false);
|
||||
onRun(selectedTable.name, operation, queryString, countQuery);
|
||||
} else {
|
||||
setShowInvalid(true);
|
||||
}
|
||||
};
|
||||
|
||||
const reset = () => {
|
||||
setQuery({
|
||||
table: selectedTable.name,
|
||||
columns: selectedTable.fields.map(field => field.name),
|
||||
operation,
|
||||
isInvalid: false
|
||||
});
|
||||
setOperation('Extract');
|
||||
setShowInvalid(false);
|
||||
setEditMode(false);
|
||||
onExitEdit();
|
||||
onClear();
|
||||
};
|
||||
|
||||
const onSave = () => {
|
||||
setUpdating(true);
|
||||
setTimeout(() => {
|
||||
setUpdating(false);
|
||||
}, 1500);
|
||||
const cleanQuery = cleanRows();
|
||||
let queries = [...queriesList];
|
||||
|
||||
if (editMode && queries.length > 0) {
|
||||
const clone = [...queries];
|
||||
clone.splice(savedQuery, 1, cleanQuery);
|
||||
localStorage.setItem('saved-queries', JSON.stringify(clone));
|
||||
} else {
|
||||
queries.push(cleanQuery);
|
||||
localStorage.setItem('saved-queries', JSON.stringify(queries));
|
||||
}
|
||||
onSaveQuery();
|
||||
};
|
||||
|
||||
const renderBuilder = () => {
|
||||
switch (operation) {
|
||||
case 'Extract':
|
||||
return (
|
||||
<ExtractBuilder
|
||||
table={selectedTable}
|
||||
showInvalid={showInvalid}
|
||||
query={query}
|
||||
onChange={(q) => setQuery(q)}
|
||||
/>
|
||||
);
|
||||
case 'GroupBy':
|
||||
return (
|
||||
<GroupByBuilder
|
||||
table={selectedTable}
|
||||
showInvalid={showInvalid}
|
||||
query={query}
|
||||
onChange={(q) => setQuery(q)}
|
||||
/>
|
||||
);
|
||||
case 'Count':
|
||||
return (
|
||||
<CountBuilder
|
||||
table={selectedTable}
|
||||
showInvalid={showInvalid}
|
||||
query={query}
|
||||
onChange={(q) => setQuery(q)}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return <div>Invalid Operation</div>;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={css.builder}>
|
||||
<div className={css.sharedConfig}>
|
||||
{editMode ? (
|
||||
<span className={css.textLink} onClick={reset}>
|
||||
<ArrowBackIcon fontSize="inherit" className={css.icon} />
|
||||
Back to new query
|
||||
</span>
|
||||
) : null}
|
||||
<Typography variant="h5" color="textSecondary" paragraph>
|
||||
{editMode ? 'Edit Saved Query' : 'New Query'}
|
||||
</Typography>
|
||||
|
||||
{editMode ? (
|
||||
<div className={css.queryMetadata}>
|
||||
<TextField
|
||||
variant="outlined"
|
||||
label="Query Name"
|
||||
placeholder="Unique name to identify query"
|
||||
value={query?.name}
|
||||
error={
|
||||
!query?.name ||
|
||||
(queryNameIdx >= 0 && queryNameIdx !== savedQuery)
|
||||
}
|
||||
helperText={
|
||||
queryNameIdx >= 0 && queryNameIdx !== savedQuery
|
||||
? 'Query name must be unique'
|
||||
: ''
|
||||
}
|
||||
onChange={(event) =>
|
||||
setQuery({ ...query, name: event.target.value })
|
||||
}
|
||||
margin="normal"
|
||||
size="small"
|
||||
required
|
||||
fullWidth
|
||||
/>
|
||||
|
||||
<TextField
|
||||
variant="outlined"
|
||||
label="Description"
|
||||
value={query?.description}
|
||||
onChange={(event) =>
|
||||
setQuery({ ...query, description: event.target.value })
|
||||
}
|
||||
margin="normal"
|
||||
size="small"
|
||||
fullWidth
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<Select
|
||||
className={css.configSelector}
|
||||
label="Table"
|
||||
value={selectedTable.name}
|
||||
options={tables.map((table) => {
|
||||
return { label: table.name, value: table.name };
|
||||
})}
|
||||
onChange={(value) => {
|
||||
const table = tables.find((t) => t.name === value);
|
||||
setSelectedTable(table);
|
||||
setShowInvalid(false);
|
||||
setQuery({
|
||||
table: table.name,
|
||||
columns: table.fields.map((field) => field.name),
|
||||
operation: query.operation,
|
||||
isInvalid: false
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
<Select
|
||||
className={css.configSelector}
|
||||
label="Operation"
|
||||
value={operation}
|
||||
options={[
|
||||
{ label: 'Extract', value: 'Extract' },
|
||||
{ label: 'Count', value: 'Count' },
|
||||
{ label: 'GroupBy', value: 'GroupBy' }
|
||||
]}
|
||||
onChange={(value) => {
|
||||
setOperation(value);
|
||||
setShowInvalid(false);
|
||||
setQuery({
|
||||
table: selectedTable.name,
|
||||
operation: value,
|
||||
isInvalid: false
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<Divider />
|
||||
</div>
|
||||
<div className={css.config}>{renderBuilder()}</div>
|
||||
<div className={css.builderActions}>
|
||||
<div className={css.mainActions}>
|
||||
<Button variant="contained" color="primary" onClick={runQuery}>
|
||||
Run
|
||||
</Button>
|
||||
{editMode ? (
|
||||
<Button
|
||||
variant="contained"
|
||||
color="default"
|
||||
onClick={onSave}
|
||||
disabled={
|
||||
!query?.name || (savedQuery >= 0 && queryNameIdx !== savedQuery)
|
||||
}
|
||||
>
|
||||
{updating ? <CircularProgress size="0.875rem" /> : 'Update'}
|
||||
</Button>
|
||||
) : (
|
||||
<Fragment>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="default"
|
||||
onClick={(event) => setSaveButtonEl(event.currentTarget)}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
<Popover
|
||||
open={!!saveButtonEl}
|
||||
anchorEl={saveButtonEl}
|
||||
onClose={() => setSaveButtonEl(null)}
|
||||
anchorOrigin={{
|
||||
vertical: 'top',
|
||||
horizontal: 'left'
|
||||
}}
|
||||
transformOrigin={{
|
||||
vertical: 'bottom',
|
||||
horizontal: 'left'
|
||||
}}
|
||||
>
|
||||
<div className={css.savePopover}>
|
||||
<Typography variant="caption" paragraph>
|
||||
Give your query a unique name and a short description to
|
||||
help you identify it for future use.
|
||||
</Typography>
|
||||
|
||||
<TextField
|
||||
variant="outlined"
|
||||
label="Query Name"
|
||||
placeholder="Unique name to identify query"
|
||||
value={query?.name ? query.name : ''}
|
||||
error={queryNameIdx >= 0}
|
||||
helperText={
|
||||
queryNameIdx >= 0 ? 'Query name must be unique' : ''
|
||||
}
|
||||
onChange={(event) =>
|
||||
setQuery({ ...query, name: event.target.value })
|
||||
}
|
||||
margin="dense"
|
||||
required
|
||||
fullWidth
|
||||
/>
|
||||
|
||||
<TextField
|
||||
variant="outlined"
|
||||
label="Description"
|
||||
value={query?.description ? query.description : ''}
|
||||
onChange={(event) =>
|
||||
setQuery({ ...query, description: event.target.value })
|
||||
}
|
||||
margin="dense"
|
||||
fullWidth
|
||||
/>
|
||||
|
||||
<div className={css.popoverActions}>
|
||||
<div className={css.saveActions}>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="default"
|
||||
onClick={() => {
|
||||
onSave();
|
||||
setSaveButtonEl(null);
|
||||
}}
|
||||
disabled={!query?.name || queryNameIdx >= 0}
|
||||
>
|
||||
Ok
|
||||
</Button>
|
||||
<Button
|
||||
color="default"
|
||||
onClick={() => setSaveButtonEl(null)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Popover>
|
||||
</Fragment>
|
||||
)}
|
||||
</div>
|
||||
<Button onClick={reset}>Clear</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
66
lattice/src/App/QBuilder/QBuilderContainer.module.scss
Normal file
66
lattice/src/App/QBuilder/QBuilderContainer.module.scss
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
.builderColumn,
|
||||
.results {
|
||||
height: calc(100vh - 64px);
|
||||
overflow: scroll;
|
||||
position: relative;
|
||||
|
||||
.openClose {
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
top: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.split {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.collapsedBuilder {
|
||||
border-right: 1px solid rgba(var(--contrast-rgb), 0.1);
|
||||
text-align: center;
|
||||
padding-top: 10px;
|
||||
}
|
||||
|
||||
.resultsBlock {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.resultsHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
.download {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.downloadInfo {
|
||||
margin-right: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.infoMessage {
|
||||
padding: 16px;
|
||||
border: 1px solid rgba(var(--primary-rgb), 0.5);
|
||||
background: rgba(var(--primary-rgb), 0.1);
|
||||
border-radius: 4px;
|
||||
margin: 4px 0 16px;
|
||||
}
|
||||
|
||||
.spacer{
|
||||
padding-bottom: 16px;
|
||||
}
|
||||
|
||||
.resultsLoading {
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.savedQueries {
|
||||
margin-top: 48px;
|
||||
}
|
||||
|
||||
.noTables {
|
||||
margin-top: 20px;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
323
lattice/src/App/QBuilder/QBuilderContainer.tsx
Normal file
323
lattice/src/App/QBuilder/QBuilderContainer.tsx
Normal file
|
|
@ -0,0 +1,323 @@
|
|||
import React, { Fragment, useState } from 'react';
|
||||
import Alert from '@material-ui/lab/Alert';
|
||||
import ArrowForwardIosIcon from '@material-ui/icons/ArrowForwardIos';
|
||||
import Button from '@material-ui/core/Button';
|
||||
import classNames from 'classnames';
|
||||
import CloseIcon from '@material-ui/icons/Close';
|
||||
import IconButton from '@material-ui/core/IconButton';
|
||||
import InfoIcon from '@material-ui/icons/Info';
|
||||
import moment, { Moment } from 'moment';
|
||||
import Paper from '@material-ui/core/Paper';
|
||||
import Split from 'react-split';
|
||||
import Snackbar from '@material-ui/core/Snackbar';
|
||||
import Tooltip from '@material-ui/core/Tooltip';
|
||||
import Typography from '@material-ui/core/Typography';
|
||||
import { Block } from 'shared/Block';
|
||||
import { formatDuration } from 'shared/utils/formatDuration';
|
||||
import { grpc } from '@improbable-eng/grpc-web';
|
||||
import { pilosa } from 'services/eventServices';
|
||||
import { QBuilder } from './QBuilder';
|
||||
import { queryPQL } from 'services/grpcServices';
|
||||
import { QueryResults } from 'App/Query/QueryResults';
|
||||
import { ResultType } from 'App/Query/QueryContainer';
|
||||
import { RowResponse } from 'proto/pilosa_pb';
|
||||
import { SavedQueries } from 'App/QueryBuilder/SavedQueries';
|
||||
import { useEffectOnce } from 'react-use';
|
||||
import css from './QBuilderContainer.module.scss';
|
||||
|
||||
let streamingResults: ResultType = {
|
||||
query: '',
|
||||
operation: '',
|
||||
type: 'PQL',
|
||||
headers: [],
|
||||
rows: [],
|
||||
roundtrip: 0,
|
||||
error: ''
|
||||
};
|
||||
|
||||
export const QBuilderContainer = () => {
|
||||
let startTime: Moment;
|
||||
let exportRows: any[] = [];
|
||||
const colSizes = JSON.parse(
|
||||
localStorage.getItem('builderColSizes') || '[25, 75]'
|
||||
);
|
||||
const [queriesList, setQueriesList] = useState(
|
||||
JSON.parse(localStorage.getItem('saved-queries') || '[]')
|
||||
);
|
||||
|
||||
const [tables, setTables] = useState<any[]>([]);
|
||||
const [showBuilder, setShowBuilder] = useState<boolean>(true);
|
||||
const [results, setResults] = useState<ResultType>();
|
||||
const [fullCount, setFullCount] = useState<number>();
|
||||
const [recordsCount, setRecordsCount] = useState<number>();
|
||||
const [errorResult, setErrorResult] = useState<ResultType>();
|
||||
const [error, setError] = useState<string>('');
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
const [savedQuery, setSavedQuery] = useState<number>(-1);
|
||||
|
||||
useEffectOnce(() => {
|
||||
pilosa.get.schema().then((res) => {
|
||||
setTables(res.data.indexes);
|
||||
});
|
||||
});
|
||||
|
||||
const handleQueryMessages = (message: RowResponse) => {
|
||||
const response = message.toObject();
|
||||
if (response.headersList.length > 0 && response.duration > 0) {
|
||||
streamingResults.headers = response.headersList;
|
||||
streamingResults.duration = response.duration;
|
||||
}
|
||||
streamingResults.rows.push(response.columnsList);
|
||||
};
|
||||
|
||||
const handleQueryEnd = (status: grpc.Code, statusMessage: string) => {
|
||||
if (status !== grpc.Code.OK) {
|
||||
streamingResults.error = statusMessage;
|
||||
setErrorResult(streamingResults);
|
||||
} else {
|
||||
streamingResults.roundtrip = moment
|
||||
.duration(moment().diff(startTime))
|
||||
.as('milliseconds');
|
||||
setErrorResult(undefined);
|
||||
setResults(streamingResults);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const handleExternalLookup = (message: RowResponse) => {
|
||||
const response = message.toObject();
|
||||
let rowStr: string[] = [];
|
||||
if (exportRows.length === 0) {
|
||||
const headers = response.headersList.map((header) => header.name);
|
||||
exportRows.push(headers.join('\t'));
|
||||
}
|
||||
response.headersList.forEach((header, idx) =>
|
||||
rowStr.push(response.columnsList[idx][`${header.datatype}val`])
|
||||
);
|
||||
exportRows.push(rowStr.join('\t'));
|
||||
};
|
||||
|
||||
const handleExternalLookupEnd = (
|
||||
status: grpc.Code,
|
||||
statusMessage: string
|
||||
) => {
|
||||
if (status !== grpc.Code.OK) {
|
||||
setError(statusMessage);
|
||||
} else if (exportRows.length === 0) {
|
||||
setError('No record attributes for current query.');
|
||||
} else {
|
||||
const dateTime = moment().unix();
|
||||
const element = document.createElement('a');
|
||||
const file = new Blob([exportRows.join('\n')], {
|
||||
type: 'text/plain;charset=utf-8'
|
||||
});
|
||||
element.href = URL.createObjectURL(file);
|
||||
element.download = `molecula-${results?.index}-${dateTime}.csv`;
|
||||
document.body.appendChild(element);
|
||||
element.click();
|
||||
exportRows = [];
|
||||
}
|
||||
};
|
||||
|
||||
const onRunQuery = (
|
||||
table: string,
|
||||
operation: string,
|
||||
query: string,
|
||||
countQuery?: string
|
||||
) => {
|
||||
streamingResults = {
|
||||
query,
|
||||
operation,
|
||||
type: 'PQL',
|
||||
headers: [],
|
||||
rows: [],
|
||||
index: table,
|
||||
roundtrip: 0,
|
||||
error: ''
|
||||
};
|
||||
startTime = moment();
|
||||
setLoading(true);
|
||||
|
||||
if (operation !== 'Count') {
|
||||
if (countQuery) {
|
||||
pilosa.post.query(table, countQuery).then((res) => {
|
||||
setFullCount(res.data.results[0]);
|
||||
});
|
||||
}
|
||||
|
||||
pilosa.post
|
||||
.query(table, `Count(All())`)
|
||||
.then((res) => setRecordsCount(res.data.results[0]));
|
||||
} else {
|
||||
setFullCount(undefined);
|
||||
}
|
||||
|
||||
queryPQL(table, query, handleQueryMessages, handleQueryEnd);
|
||||
};
|
||||
|
||||
const onClear = () => {
|
||||
setResults(undefined);
|
||||
setFullCount(undefined);
|
||||
setErrorResult(undefined);
|
||||
};
|
||||
|
||||
const onRemoveQuery = (queryIdx: number) => {
|
||||
let queries = [...queriesList];
|
||||
queries.splice(queryIdx, 1);
|
||||
localStorage.setItem('saved-queries', JSON.stringify(queries));
|
||||
setQueriesList(queries);
|
||||
};
|
||||
|
||||
const onSaveQuery = () => {
|
||||
const updatedList = JSON.parse(
|
||||
localStorage.getItem('saved-queries') || '[]'
|
||||
);
|
||||
if (savedQuery < 0) {
|
||||
setSavedQuery(updatedList.length - 1);
|
||||
}
|
||||
setQueriesList(updatedList);
|
||||
};
|
||||
|
||||
const onExportLogs = () => {
|
||||
if (results) {
|
||||
const columns = results.rows.map((row) => row[0].uint64val);
|
||||
const table = results.index ? results.index : '';
|
||||
onExternalLookup(table, columns);
|
||||
}
|
||||
};
|
||||
|
||||
const onExternalLookup = (table: string, columns: number[]) => {
|
||||
const cols = JSON.stringify(columns);
|
||||
const query = `ExternalLookup(ConstRow(columns=${cols}), query='select id, "rawlog" from "${table}" where id = ANY($1)')`;
|
||||
queryPQL(table, query, handleExternalLookup, handleExternalLookupEnd);
|
||||
};
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
{tables.length > 0 ? (
|
||||
<Split
|
||||
sizes={showBuilder ? colSizes : [0, 100]}
|
||||
cursor="col-resize"
|
||||
minSize={showBuilder ? 350 : 50}
|
||||
onDragEnd={(sizes) =>
|
||||
localStorage.setItem('builderColSizes', JSON.stringify(sizes))
|
||||
}
|
||||
gutter={(_index, direction) => {
|
||||
const gutter = document.createElement('div');
|
||||
gutter.className = `gutter gutter-${direction}`;
|
||||
const dragbars = document.createElement('div');
|
||||
dragbars.className = 'dragBar';
|
||||
gutter.appendChild(dragbars);
|
||||
return gutter;
|
||||
}}
|
||||
className={classNames(css.split, !showBuilder ? 'hide-gutter' : '')}
|
||||
>
|
||||
{showBuilder ? (
|
||||
<div className={css.builderColumn}>
|
||||
<div className={css.openClose}>
|
||||
<IconButton onClick={() => setShowBuilder(false)} size="small">
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
</div>
|
||||
<QBuilder
|
||||
tables={tables}
|
||||
savedQuery={savedQuery}
|
||||
onRun={onRunQuery}
|
||||
onClear={onClear}
|
||||
onExitEdit={() => setSavedQuery(-1)}
|
||||
onSaveQuery={onSaveQuery}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className={css.collapsedBuilder}>
|
||||
<IconButton onClick={() => setShowBuilder(true)} size="small">
|
||||
<ArrowForwardIosIcon />
|
||||
</IconButton>
|
||||
</div>
|
||||
)}
|
||||
<div className={css.results}>
|
||||
<Block className={css.resultsBlock}>
|
||||
<div className={css.resultsHeader}>
|
||||
<Typography variant="h5" color="textSecondary">
|
||||
Results{' '}
|
||||
</Typography>
|
||||
{results?.query.includes('Extract(') ? (
|
||||
<div className={css.download}>
|
||||
<Tooltip
|
||||
className={css.downloadInfo}
|
||||
title="Download raw data from query results"
|
||||
placement="top"
|
||||
arrow
|
||||
>
|
||||
<InfoIcon fontSize="inherit" />
|
||||
</Tooltip>
|
||||
<Button onClick={onExportLogs}>Download</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{loading ? <div>Loading...</div> : null}
|
||||
{results && !loading ? (
|
||||
<Fragment>
|
||||
{fullCount && recordsCount ? (
|
||||
<div className={css.infoMessage}>
|
||||
{results.duration ? (
|
||||
<div>
|
||||
{recordsCount.toLocaleString()} records scanned in{' '}
|
||||
{formatDuration(results.duration, true)}.
|
||||
</div>
|
||||
) : null}
|
||||
<div>
|
||||
Showing{' '}
|
||||
{fullCount > 1000 ? 'first 1,000 rows of' : 'all'}{' '}
|
||||
{fullCount.toLocaleString()} results.
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<QueryResults results={results} />
|
||||
<div className={css.spacer} />
|
||||
</Fragment>
|
||||
) : null}
|
||||
{errorResult && !loading ? <div>{errorResult.error}</div> : null}
|
||||
{!loading && !results && !error ? (
|
||||
<Fragment>
|
||||
<Typography variant="caption" color="textSecondary">
|
||||
Build a query to see results
|
||||
</Typography>
|
||||
{queriesList.length > 0 ? (
|
||||
<div className={css.savedQueries}>
|
||||
<Typography variant="h5" color="textSecondary">
|
||||
Saved Queries
|
||||
</Typography>
|
||||
<SavedQueries
|
||||
queries={queriesList}
|
||||
tables={tables}
|
||||
onClickSaved={(queryIdx) => setSavedQuery(queryIdx)}
|
||||
onRemoveQuery={onRemoveQuery}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</Fragment>
|
||||
) : null}
|
||||
</Block>
|
||||
</div>
|
||||
<Snackbar open={!!error}>
|
||||
<Alert severity="info" onClose={() => setError('')}>
|
||||
{error}
|
||||
</Alert>
|
||||
</Snackbar>
|
||||
</Split>
|
||||
) : (
|
||||
<Block>
|
||||
<Typography variant="h5" color="textSecondary">
|
||||
Query Builder
|
||||
</Typography>
|
||||
<Paper className={css.noTables}>
|
||||
<Typography variant="caption" color="textSecondary">
|
||||
There are no tables to query.
|
||||
</Typography>
|
||||
</Paper>
|
||||
</Block>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
};
|
||||
52
lattice/src/App/QBuilder/RowCallBuilder/RowCall/helpers.ts
Normal file
52
lattice/src/App/QBuilder/RowCallBuilder/RowCall/helpers.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
export const operators = {
|
||||
decimal: [
|
||||
{ label: '>', value: '>' },
|
||||
{ label: '<', value: '<' },
|
||||
{ label: '>=', value: '>=' },
|
||||
{ label: '<=', value: '<=' },
|
||||
{ label: '==', value: '=' },
|
||||
{ label: '!=', value: '!=' },
|
||||
],
|
||||
int: [
|
||||
{ label: '>', value: '>' },
|
||||
{ label: '<', value: '<' },
|
||||
{ label: '>=', value: '>=' },
|
||||
{ label: '<=', value: '<=' },
|
||||
{ label: '==', value: '=' },
|
||||
{ label: '!=', value: '!=' },
|
||||
],
|
||||
'mutex-id': [
|
||||
{ label: 'is', value: '=' },
|
||||
{ label: 'is not', value: '!=' }
|
||||
],
|
||||
'mutex-keys': [
|
||||
{ label: 'is', value: '=' },
|
||||
{ label: 'is not', value: '!=' },
|
||||
{ label: 'like', value: 'like' },
|
||||
{ label: 'CIDR', value: 'cidr' }
|
||||
],
|
||||
"set-id": [
|
||||
{ label: 'is', value: '=' },
|
||||
{ label: 'is not', value: '!=' }
|
||||
],
|
||||
"set-keys": [
|
||||
{ label: 'is', value: '=' },
|
||||
{ label: 'is not', value: '!=' },
|
||||
{ label: 'like', value: 'like' },
|
||||
{ label: 'CIDR', value: 'cidr' }
|
||||
],
|
||||
"time-id": [
|
||||
{ label: 'is', value: '=' },
|
||||
{ label: 'is not', value: '!=' }
|
||||
],
|
||||
"time-keys": [
|
||||
{ label: 'is', value: '=' },
|
||||
{ label: 'is not', value: '!=' },
|
||||
{ label: 'like', value: 'like' }
|
||||
],
|
||||
timestamp: [
|
||||
{ label: 'is before', value: '<' },
|
||||
{ label: 'is after', value: '>' },
|
||||
{ label: 'is', value: '=' },
|
||||
]
|
||||
}
|
||||
1
lattice/src/App/QBuilder/RowCallBuilder/RowCall/index.ts
Normal file
1
lattice/src/App/QBuilder/RowCallBuilder/RowCall/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from './helpers';
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
.newGroup,
|
||||
.operator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
|
||||
.line {
|
||||
flex-grow: 1;
|
||||
height: 1px;
|
||||
background: var(--divider);
|
||||
}
|
||||
|
||||
.addButton {
|
||||
margin: 0 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.groupBy {
|
||||
.line {
|
||||
flex-grow: 1;
|
||||
height: 1px;
|
||||
background: var(--divider);
|
||||
}
|
||||
|
||||
.fieldSelector {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.filterHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.filterInfo {
|
||||
vertical-align: text-top;
|
||||
margin-left: 4px;
|
||||
}
|
||||
}
|
||||
180
lattice/src/App/QBuilder/RowCallBuilder/RowCallBuilder.tsx
Normal file
180
lattice/src/App/QBuilder/RowCallBuilder/RowCallBuilder.tsx
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
import React, { FC, Fragment, useState } from 'react';
|
||||
import AddIcon from '@material-ui/icons/Add';
|
||||
import Button from '@material-ui/core/Button';
|
||||
import IconButton from '@material-ui/core/IconButton';
|
||||
import Menu from '@material-ui/core/Menu';
|
||||
import MenuItem from '@material-ui/core/MenuItem';
|
||||
import {
|
||||
groupOperators,
|
||||
Operator,
|
||||
RowCallType,
|
||||
RowGrouping
|
||||
} from 'App/QueryBuilder/rowTypes';
|
||||
import { RowCall } from 'App/QueryBuilder/RowCall';
|
||||
import css from './RowCallBuilder.module.scss';
|
||||
|
||||
type RowCallBuilderProps = {
|
||||
rowCalls: RowGrouping[];
|
||||
fields: any[];
|
||||
showInvalid: boolean;
|
||||
onChange: (
|
||||
rows: RowGrouping[],
|
||||
operator?: Operator,
|
||||
hasInvalid?: boolean
|
||||
) => void;
|
||||
};
|
||||
|
||||
export const RowCallBuilder: FC<RowCallBuilderProps> = ({
|
||||
rowCalls = [],
|
||||
fields,
|
||||
showInvalid,
|
||||
onChange
|
||||
}) => {
|
||||
const [newOperatorEl, setNewOperatorEl] = useState<null | HTMLElement>(null);
|
||||
const [operatorEl, setOperatorEl] = useState<null | HTMLElement>(null);
|
||||
const [operator, setOperator] = useState<Operator>();
|
||||
|
||||
const onNewGroup = () => {
|
||||
const newRow: RowCallType[] = [
|
||||
{
|
||||
field: '',
|
||||
rowOperator: '=',
|
||||
value: '',
|
||||
type: 'set',
|
||||
keys: true
|
||||
}
|
||||
];
|
||||
|
||||
onChange([...rowCalls, { row: newRow }]);
|
||||
};
|
||||
|
||||
const onRemoveGroup = (groupIdx: number) => {
|
||||
if (groupIdx === 0) {
|
||||
onChange(rowCalls.slice(1));
|
||||
} else {
|
||||
const updatedRowCalls = [...rowCalls];
|
||||
updatedRowCalls.splice(groupIdx, 1);
|
||||
onChange(updatedRowCalls);
|
||||
|
||||
if (updatedRowCalls.length < 1) {
|
||||
setOperator(undefined);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const onUpdateRow = (
|
||||
groupIdx: number,
|
||||
newRowData?: RowCallType[],
|
||||
operator?: Operator,
|
||||
isNot?: boolean
|
||||
) => {
|
||||
const updatedRowCalls = [...rowCalls];
|
||||
if (newRowData) {
|
||||
const clone: RowGrouping = {
|
||||
...rowCalls[groupIdx],
|
||||
row: newRowData,
|
||||
isNot
|
||||
};
|
||||
if (newRowData.length <= 1) {
|
||||
delete clone.operator;
|
||||
} else if (operator) {
|
||||
clone.operator = operator;
|
||||
}
|
||||
updatedRowCalls.splice(groupIdx, 1, clone);
|
||||
} else {
|
||||
updatedRowCalls.splice(groupIdx, 1);
|
||||
}
|
||||
onChange(updatedRowCalls);
|
||||
};
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
{rowCalls?.map((rowCall, idx) => (
|
||||
<Fragment key={`group-${idx}`}>
|
||||
{idx > 0 ? (
|
||||
<Fragment>
|
||||
<div className={css.operator}>
|
||||
<div className={css.line} />
|
||||
<Button
|
||||
className={css.addButton}
|
||||
size="small"
|
||||
onClick={(event) => setOperatorEl(event.currentTarget)}
|
||||
>
|
||||
{operator}
|
||||
</Button>
|
||||
<div className={css.line} />
|
||||
</div>
|
||||
<Menu
|
||||
open={!!operatorEl}
|
||||
anchorEl={operatorEl}
|
||||
classes={{ paper: css.menuPaper }}
|
||||
onClose={() => setOperatorEl(null)}
|
||||
>
|
||||
{groupOperators.map((op: Operator) => (
|
||||
<MenuItem
|
||||
key={`${idx}-${op}`}
|
||||
onClick={() => {
|
||||
setOperator(op);
|
||||
setOperatorEl(null);
|
||||
}}
|
||||
>
|
||||
{op}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Menu>
|
||||
</Fragment>
|
||||
) : null}
|
||||
<RowCall
|
||||
fields={fields}
|
||||
rowData={rowCall.row}
|
||||
isNot={rowCall.isNot}
|
||||
operator={rowCall.operator}
|
||||
showErrors={showInvalid}
|
||||
onUpdate={(updatedRow, operator, isNot) =>
|
||||
onUpdateRow(idx, updatedRow, operator, isNot)
|
||||
}
|
||||
onRemoveGroup={() => onRemoveGroup(idx)}
|
||||
/>
|
||||
</Fragment>
|
||||
))}
|
||||
|
||||
<div className={css.newGroup}>
|
||||
<div className={css.line} />
|
||||
<IconButton
|
||||
className={css.addButton}
|
||||
size="small"
|
||||
onClick={(event) => {
|
||||
if (rowCalls.length === 0 || !!operator) {
|
||||
onNewGroup();
|
||||
} else {
|
||||
setNewOperatorEl(event.currentTarget);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<AddIcon fontSize="inherit" />
|
||||
</IconButton>
|
||||
<div className={css.line} />
|
||||
</div>
|
||||
|
||||
<Menu
|
||||
open={!!newOperatorEl}
|
||||
anchorEl={newOperatorEl}
|
||||
classes={{ paper: css.menuPaper }}
|
||||
onClose={() => setNewOperatorEl(null)}
|
||||
>
|
||||
{groupOperators.map((op: Operator) => (
|
||||
<MenuItem
|
||||
key={`operator-${op}`}
|
||||
onClick={() => {
|
||||
setOperator(op);
|
||||
onNewGroup();
|
||||
setNewOperatorEl(null);
|
||||
}}
|
||||
>
|
||||
{op}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Menu>
|
||||
</Fragment>
|
||||
);
|
||||
};
|
||||
1
lattice/src/App/QBuilder/RowCallBuilder/index.ts
Normal file
1
lattice/src/App/QBuilder/RowCallBuilder/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from './RowCallBuilder';
|
||||
2
lattice/src/App/QBuilder/index.ts
Normal file
2
lattice/src/App/QBuilder/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export * from './QBuilderContainer';
|
||||
export * from './rowTypes';
|
||||
22
lattice/src/App/QBuilder/rowTypes.ts
Normal file
22
lattice/src/App/QBuilder/rowTypes.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
export type Operator = 'and' | 'or';
|
||||
|
||||
export const groupOperators: Operator[] = ['and', 'or'];
|
||||
|
||||
export type RowGrouping = {
|
||||
row: RowCallType[];
|
||||
isNot?: boolean;
|
||||
operator?: Operator;
|
||||
}
|
||||
|
||||
export type RowCallType = {
|
||||
field: string;
|
||||
rowOperator: string;
|
||||
value: string;
|
||||
type: string;
|
||||
keys?: boolean;
|
||||
};
|
||||
|
||||
export type RowsCallType = {
|
||||
primary: string;
|
||||
secondary: string;
|
||||
};
|
||||
174
lattice/src/App/QBuilder/utils.ts
Normal file
174
lattice/src/App/QBuilder/utils.ts
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
import { RowGrouping } from './rowTypes';
|
||||
import { getIPRange } from 'get-ip-range';
|
||||
|
||||
export const stringifyExtract = (query: any) => {
|
||||
const { columns, operator, rowCalls } = query;
|
||||
|
||||
const rowData = stringifyRowData(rowCalls, operator);
|
||||
if (!rowData.error) {
|
||||
const fields = columns.map((field) => `Rows(${field})`);
|
||||
const allRows = fields.join(', ');
|
||||
return {
|
||||
error: false,
|
||||
queryString: `Extract(Limit(${rowData.queryString}, limit=1000), ${allRows})`,
|
||||
countQuery: `Count(${rowData.queryString})`
|
||||
};
|
||||
} else {
|
||||
return { ...rowData, countQuery: '' };
|
||||
}
|
||||
}
|
||||
|
||||
export const stringifyCount = (query: any) => {
|
||||
const { operator, rowCalls } = query;
|
||||
|
||||
const rowData = stringifyRowData(rowCalls, operator);
|
||||
if (!rowData.error) {
|
||||
return { error: false, queryString: `Count(${rowData.queryString})` };
|
||||
} else {
|
||||
return rowData;
|
||||
}
|
||||
}
|
||||
|
||||
const stringifyRowData = (rowCalls, operator) => {
|
||||
let queryString = '';
|
||||
if (rowCalls.length === 0) {
|
||||
queryString = 'All()';
|
||||
} else {
|
||||
let rowsMap: string[][] = [];
|
||||
rowCalls.forEach((group, groupIdx) => {
|
||||
rowsMap.push([]);
|
||||
group.row.forEach((row) => {
|
||||
let rowString = '';
|
||||
const { field, rowOperator, value, type, keys } = row;
|
||||
const isNegatory = rowOperator === '!=';
|
||||
const isUnion =
|
||||
['=', '!='].includes(rowOperator) && value.split(',').length > 1;
|
||||
const operator = isNegatory ? '=' : rowOperator;
|
||||
if (isUnion) {
|
||||
const values = value.split(',');
|
||||
const unionRows = values
|
||||
.map((v) => keys ? `Row(${field}="${v.trim()}")` : `Row(${field}=${v.trim()})`)
|
||||
.join(', ');
|
||||
rowString = `Union(${unionRows})`;
|
||||
} else if (rowOperator === 'cidr') {
|
||||
try {
|
||||
const ipRange = getIPRange(value);
|
||||
const ipRows = ipRange
|
||||
.map((ip) => `Row(${field}="${ip}")`)
|
||||
.join(', ');
|
||||
rowString = `Union(${ipRows})`;
|
||||
} catch (error) {
|
||||
return { error: true, query: error.message };
|
||||
}
|
||||
} else if (rowOperator === 'like') {
|
||||
if (value.includes('%') || value.includes('_')) {
|
||||
rowString = `UnionRows(Rows(field=${field}, like="${value}"))`;
|
||||
} else {
|
||||
rowString = `UnionRows(Rows(field=${field}, like="%${value}%"))`;
|
||||
}
|
||||
} else {
|
||||
rowString = keys || type === 'timestamp'
|
||||
? `Row(${field}${operator}"${value}")`
|
||||
: `Row(${field}${operator}${value})`;
|
||||
}
|
||||
|
||||
if (isNegatory) {
|
||||
rowsMap[groupIdx].push(`Not(${rowString})`);
|
||||
} else {
|
||||
rowsMap[groupIdx].push(rowString);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
queryString = rowsMap
|
||||
.map((group, idx) => {
|
||||
let joined = '';
|
||||
if (group.length > 1) {
|
||||
joined = group.map((r) => r).join(', ');
|
||||
const operator = rowCalls[idx].operator;
|
||||
if (operator === 'and') {
|
||||
joined = `Intersect(${joined})`;
|
||||
} else if (operator === 'or') {
|
||||
joined = `Union(${joined})`;
|
||||
}
|
||||
} else {
|
||||
joined = group[0];
|
||||
}
|
||||
return rowCalls[idx].isNot ? `Not(${joined})` : joined;
|
||||
})
|
||||
.join(', ');
|
||||
|
||||
if (rowCalls.length > 1 && operator) {
|
||||
if (operator === 'and') {
|
||||
queryString = `Intersect(${queryString})`;
|
||||
} else if (operator === 'or') {
|
||||
queryString = `Union(${queryString})`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { error: false, queryString };
|
||||
}
|
||||
|
||||
export const stringifyGroupBy = (query: any) => {
|
||||
const { groupByCall, filter, sort } = query;
|
||||
|
||||
let sortString = '';
|
||||
let aggregateString = '';
|
||||
if (sort?.length > 0) {
|
||||
sortString = sort[0].sortValue;
|
||||
if (sort[0].sortValue.includes('sum')) {
|
||||
aggregateString = `, aggregate=Sum(field=${sort[0].field})`;
|
||||
}
|
||||
if (sort.length > 1) {
|
||||
sortString = `${sortString}, ${sort[1].sortValue}`;
|
||||
if (sort[1].sortValue.includes('sum')) {
|
||||
aggregateString = `, aggregate=Sum(field=${sort[1].field})`;
|
||||
}
|
||||
}
|
||||
sortString = `, sort="${sortString}"`;
|
||||
}
|
||||
const filterString = filter ? `, filter=${filter}` : '';
|
||||
const queryString = groupByCall.secondary
|
||||
? `GroupBy(Rows(${groupByCall.primary}), Rows(${groupByCall.secondary})${filterString}${sortString}${aggregateString})`
|
||||
: `GroupBy(Rows(${groupByCall.primary})${filterString}${sortString}${aggregateString})`;
|
||||
|
||||
return queryString;
|
||||
}
|
||||
|
||||
export const cleanupRows = (rowCalls: RowGrouping[]) => {
|
||||
// remove empty groups and row calls
|
||||
let isInvalid = false;
|
||||
let cleanRowCalls: RowGrouping[] = [];
|
||||
let cleanGroups: RowGrouping[] = [];
|
||||
|
||||
if (rowCalls?.length > 0) {
|
||||
rowCalls.forEach((group, groupIdx) => {
|
||||
cleanGroups.push({ ...group, row: [] });
|
||||
group.row.forEach((row) => {
|
||||
const { field, value, rowOperator } = row;
|
||||
if (field || value) {
|
||||
cleanGroups[groupIdx].row.push(row);
|
||||
|
||||
if (!field || !value) {
|
||||
isInvalid = true;
|
||||
} else if (rowOperator === 'cidr') {
|
||||
try {
|
||||
getIPRange(value);
|
||||
} catch (err) {
|
||||
isInvalid = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
cleanGroups.forEach((group) => {
|
||||
if (group.row.length > 0) {
|
||||
cleanRowCalls.push(group);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return { cleanRowCalls, isInvalid };
|
||||
};
|
||||
Loading…
Add table
Reference in a new issue