Web console: show formatted JSON value (#16632)

* show formatted json value

* update snapshot

* window functions

* count star can also have a window

* better edit query context
This commit is contained in:
Vadim Ogievetsky 2024-06-21 18:33:15 -07:00 committed by GitHub
parent 4eced9b3c9
commit 51c73b5a4e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 282 additions and 157 deletions

View File

@ -5094,7 +5094,7 @@ license_category: binary
module: web-console module: web-console
license_name: Apache License version 2.0 license_name: Apache License version 2.0
copyright: Imply Data copyright: Imply Data
version: 0.22.15 version: 0.22.20
--- ---

View File

@ -47,6 +47,7 @@ exports.SQL_KEYWORDS = [
'FULL', 'FULL',
'CROSS', 'CROSS',
'USING', 'USING',
'NATURAL',
'FETCH', 'FETCH',
'FIRST', 'FIRST',
'NEXT', 'NEXT',
@ -67,6 +68,8 @@ exports.SQL_KEYWORDS = [
'RANGE', 'RANGE',
'PRECEDING', 'PRECEDING',
'FOLLOWING', 'FOLLOWING',
'CURRENT',
'UNBOUNDED',
'EXTEND', 'EXTEND',
'PIVOT', 'PIVOT',
'UNPIVOT', 'UNPIVOT',

View File

@ -15,7 +15,7 @@
"@blueprintjs/icons": "^4.16.0", "@blueprintjs/icons": "^4.16.0",
"@blueprintjs/popover2": "^1.14.9", "@blueprintjs/popover2": "^1.14.9",
"@blueprintjs/select": "^4.9.24", "@blueprintjs/select": "^4.9.24",
"@druid-toolkit/query": "^0.22.15", "@druid-toolkit/query": "^0.22.20",
"@druid-toolkit/visuals-core": "^0.3.3", "@druid-toolkit/visuals-core": "^0.3.3",
"@druid-toolkit/visuals-react": "^0.3.3", "@druid-toolkit/visuals-react": "^0.3.3",
"ace-builds": "~1.4.14", "ace-builds": "~1.4.14",
@ -1005,9 +1005,9 @@
} }
}, },
"node_modules/@druid-toolkit/query": { "node_modules/@druid-toolkit/query": {
"version": "0.22.15", "version": "0.22.20",
"resolved": "https://registry.npmjs.org/@druid-toolkit/query/-/query-0.22.15.tgz", "resolved": "https://registry.npmjs.org/@druid-toolkit/query/-/query-0.22.20.tgz",
"integrity": "sha512-LyQVIVkVNhduscf2wnBO/oGBvj353tS5ElIws20xQzApvEIwNNxmlkA+8npqwy77BkJj3nRQvlenbSEDHQdqow==", "integrity": "sha512-GmmSd27y7zLVTjgTBQy+XoGeSSGhSDNmwyiwWtSua7I5LX8XqHV7Chi8HIH25YQoVgTK1pLK4RS8eRXxthRAzg==",
"dependencies": { "dependencies": {
"tslib": "^2.5.2" "tslib": "^2.5.2"
} }
@ -19147,9 +19147,9 @@
"dev": true "dev": true
}, },
"@druid-toolkit/query": { "@druid-toolkit/query": {
"version": "0.22.15", "version": "0.22.20",
"resolved": "https://registry.npmjs.org/@druid-toolkit/query/-/query-0.22.15.tgz", "resolved": "https://registry.npmjs.org/@druid-toolkit/query/-/query-0.22.20.tgz",
"integrity": "sha512-LyQVIVkVNhduscf2wnBO/oGBvj353tS5ElIws20xQzApvEIwNNxmlkA+8npqwy77BkJj3nRQvlenbSEDHQdqow==", "integrity": "sha512-GmmSd27y7zLVTjgTBQy+XoGeSSGhSDNmwyiwWtSua7I5LX8XqHV7Chi8HIH25YQoVgTK1pLK4RS8eRXxthRAzg==",
"requires": { "requires": {
"tslib": "^2.5.2" "tslib": "^2.5.2"
} }

View File

@ -69,7 +69,7 @@
"@blueprintjs/icons": "^4.16.0", "@blueprintjs/icons": "^4.16.0",
"@blueprintjs/popover2": "^1.14.9", "@blueprintjs/popover2": "^1.14.9",
"@blueprintjs/select": "^4.9.24", "@blueprintjs/select": "^4.9.24",
"@druid-toolkit/query": "^0.22.15", "@druid-toolkit/query": "^0.22.20",
"@druid-toolkit/visuals-core": "^0.3.3", "@druid-toolkit/visuals-core": "^0.3.3",
"@druid-toolkit/visuals-react": "^0.3.3", "@druid-toolkit/visuals-react": "^0.3.3",
"ace-builds": "~1.4.14", "ace-builds": "~1.4.14",

View File

@ -174,7 +174,9 @@ export const RecordTablePane = React.memo(function RecordTablePane(props: Record
})} })}
/> />
)} )}
{showValue && <ShowValueDialog onClose={() => setShowValue(undefined)} str={showValue} />} {showValue && (
<ShowValueDialog onClose={() => setShowValue(undefined)} str={showValue} size="large" />
)}
</div> </div>
); );
}); });

View File

@ -57,18 +57,102 @@ exports[`EditContextDialog matches snapshot 1`] = `
</span> </span>
</button> </button>
</div> </div>
<textarea
class="bp4-input"
>
{
}
</textarea>
<div <div
class="bp4-dialog-footer-actions" class=" ace_editor ace_hidpi ace-solarized-dark ace_dark query-string"
id="ace-editor"
style="width: 100%; height: 100%; font-size: 12px;"
>
<textarea
autocapitalize="off"
autocorrect="off"
class="ace_text-input"
spellcheck="false"
style="opacity: 0; font-size: 1px;"
wrap="off"
/>
<div
aria-hidden="true"
class="ace_gutter"
>
<div
class="ace_layer ace_gutter-layer ace_folding-enabled"
style="height: 1000000px;"
/>
</div>
<div
class="ace_scroller"
style="line-height: 0px;"
>
<div
class="ace_content"
>
<div
class="ace_layer ace_print-margin-layer"
>
<div
class="ace_print-margin"
style="left: 4px; visibility: hidden;"
/>
</div>
<div
class="ace_layer ace_marker-layer"
/>
<div
class="ace_layer ace_text-layer"
style="height: 1000000px; margin: 0px 4px;"
/>
<div
class="ace_layer ace_marker-layer"
/>
<div
class="ace_layer ace_cursor-layer ace_hidden-cursors"
>
<div
class="ace_cursor"
/>
</div>
</div>
</div>
<div
class="ace_scrollbar ace_scrollbar-v"
style="display: none; width: 20px;"
>
<div
class="ace_scrollbar-inner"
style="width: 20px;"
>
 
</div>
</div>
<div
class="ace_scrollbar ace_scrollbar-h"
style="display: none; height: 20px;"
>
<div
class="ace_scrollbar-inner"
style="height: 20px;"
>
 
</div>
</div>
<div
style="height: auto; width: auto; top: 0px; left: 0px; visibility: hidden; position: absolute; white-space: pre; overflow: hidden;"
>
<div
style="height: auto; width: auto; top: 0px; left: 0px; visibility: hidden; position: absolute; white-space: pre; overflow: visible;"
/>
<div
style="height: auto; width: auto; top: 0px; left: 0px; visibility: hidden; position: absolute; white-space: pre; overflow: visible;"
>
XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
</div>
</div>
</div>
<div
class="bp4-dialog-footer"
> >
<div <div
class="edit-context-dialog-buttons" class="bp4-dialog-footer-actions"
> >
<button <button
class="bp4-button" class="bp4-button"

View File

@ -20,25 +20,6 @@
.edit-context-dialog { .edit-context-dialog {
&.#{$bp-ns}-dialog { &.#{$bp-ns}-dialog {
padding-bottom: 10px; height: 60vh;
}
.#{$bp-ns}-input {
margin: 10px;
height: 400px;
}
.#{$bp-ns}-dialog-footer-actions {
padding: 0px 10px 0px 10px;
display: grid;
grid-template-columns: 340px 1fr;
grid-template-areas: 'error buttons';
}
.edit-context-dialog-error {
grid-area: error;
}
.edit-context-dialog-buttons {
grid-area: buttons;
} }
} }

View File

@ -24,7 +24,11 @@ import { EditContextDialog } from './edit-context-dialog';
describe('EditContextDialog', () => { describe('EditContextDialog', () => {
it('matches snapshot', () => { it('matches snapshot', () => {
const compactionDialog = ( const compactionDialog = (
<EditContextDialog queryContext={{}} onQueryContextChange={() => null} onClose={() => {}} /> <EditContextDialog
initQueryContext={{}}
onQueryContextChange={() => null}
onClose={() => {}}
/>
); );
render(compactionDialog); render(compactionDialog);
expect(document.body.lastChild).toMatchSnapshot(); expect(document.body.lastChild).toMatchSnapshot();

View File

@ -16,86 +16,72 @@
* limitations under the License. * limitations under the License.
*/ */
import { Button, Callout, Classes, Dialog, Intent, TextArea } from '@blueprintjs/core'; import { Button, Classes, Dialog, Intent } from '@blueprintjs/core';
import Hjson from 'hjson'; import Hjson from 'hjson';
import * as JSONBig from 'json-bigint-native'; import * as JSONBig from 'json-bigint-native';
import React, { useState } from 'react'; import React, { useState } from 'react';
import AceEditor from 'react-ace';
import type { QueryContext } from '../../druid-models'; import type { QueryContext } from '../../druid-models';
import { AppToaster } from '../../singletons';
import './edit-context-dialog.scss'; import './edit-context-dialog.scss';
export interface EditContextDialogProps { function formatContext(context: QueryContext | undefined): string {
queryContext: QueryContext; const str = JSONBig.stringify(context || {}, undefined, 2);
onQueryContextChange: (queryContext: QueryContext) => void; return str === '{}' ? '{\n\n}' : str;
onClose: () => void;
} }
export interface EditContextDialogState { export interface EditContextDialogProps {
queryContextString: string; initQueryContext: QueryContext | undefined;
queryContext?: QueryContext; onQueryContextChange(queryContext: QueryContext): void;
error?: string; onClose(): void;
} }
export const EditContextDialog = React.memo(function EditContextDialog( export const EditContextDialog = React.memo(function EditContextDialog(
props: EditContextDialogProps, props: EditContextDialogProps,
) { ) {
const { onQueryContextChange, onClose } = props; const { initQueryContext, onQueryContextChange, onClose } = props;
const [state, setState] = useState<EditContextDialogState>(() => ({ const [queryContextString, setQueryContextString] = useState<string>(
queryContext: props.queryContext, formatContext(initQueryContext),
queryContextString: Object.keys(props.queryContext).length );
? JSONBig.stringify(props.queryContext, undefined, 2)
: '{\n\n}',
}));
const { queryContext, queryContextString, error } = state;
function handleTextChange(e: any) {
const queryContextString = (e.target as HTMLInputElement).value;
let error: string | undefined;
let queryContext: QueryContext | undefined;
try {
queryContext = Hjson.parse(queryContextString);
} catch (e) {
error = e.message;
}
if (!error && (!queryContext || typeof queryContext !== 'object')) {
error = 'Input is not a valid object';
queryContext = undefined;
}
setState({
queryContextString,
queryContext,
error,
});
}
return ( return (
<Dialog className="edit-context-dialog" isOpen onClose={onClose} title="Edit query context"> <Dialog className="edit-context-dialog" isOpen onClose={onClose} title="Edit query context">
<TextArea value={queryContextString} onChange={handleTextChange} autoFocus /> <AceEditor
<div className={Classes.DIALOG_FOOTER_ACTIONS}> mode="hjson"
{error && ( theme="solarized_dark"
<Callout intent={Intent.DANGER} className="edit-context-dialog-error"> className="query-string"
{error} name="ace-editor"
</Callout> fontSize={12}
)} width="100%"
<div className="edit-context-dialog-buttons"> height="100%"
showGutter
showPrintMargin={false}
value={queryContextString}
onChange={v => setQueryContextString(v)}
/>
<div className={Classes.DIALOG_FOOTER}>
<div className={Classes.DIALOG_FOOTER_ACTIONS}>
<Button text="Close" onClick={onClose} /> <Button text="Close" onClick={onClose} />
<Button <Button
text="Save" text="Save"
intent={Intent.PRIMARY} intent={Intent.PRIMARY}
disabled={Boolean(error)} onClick={() => {
onClick={ let queryContext: QueryContext;
queryContext try {
? () => { queryContext = Hjson.parse(queryContextString);
onQueryContextChange(queryContext); } catch (e) {
onClose(); AppToaster.show({
} message: e.message,
: undefined intent: Intent.DANGER,
} });
return;
}
onQueryContextChange(queryContext);
onClose();
}}
/> />
</div> </div>
</div> </div>

View File

@ -57,53 +57,61 @@ exports[`ShowValueDialog matches snapshot 1`] = `
</span> </span>
</button> </button>
</div> </div>
<textarea
class="bp4-input"
spellcheck="false"
>
Bot: Automatska zamjena teksta (-[[Administrativna podjela Meksika|Admin]] +[[Administrativna podjela Meksika|Admi]])
</textarea>
<div <div
class="bp4-dialog-footer-actions" class="bp4-dialog-body"
> >
<button <textarea
class="bp4-button" class="bp4-input"
type="button" spellcheck="false"
> >
<span Bot: Automatska zamjena teksta (-[[Administrativna podjela Meksika|Admin]] +[[Administrativna podjela Meksika|Admi]])
aria-hidden="true" </textarea>
class="bp4-icon bp4-icon-duplicate" </div>
icon="duplicate" <div
class="bp4-dialog-footer"
>
<div
class="bp4-dialog-footer-actions"
>
<button
class="bp4-button"
type="button"
> >
<svg <span
data-icon="duplicate" aria-hidden="true"
height="16" class="bp4-icon bp4-icon-duplicate"
role="img" icon="duplicate"
viewBox="0 0 16 16"
width="16"
> >
<path <svg
d="M15 0H5c-.55 0-1 .45-1 1v2h2V2h8v7h-1v2h2c.55 0 1-.45 1-1V1c0-.55-.45-1-1-1zm-4 4H1c-.55 0-1 .45-1 1v10c0 .55.45 1 1 1h10c.55 0 1-.45 1-1V5c0-.55-.45-1-1-1zm-1 10H2V6h8v8z" data-icon="duplicate"
fill-rule="evenodd" height="16"
/> role="img"
</svg> viewBox="0 0 16 16"
</span> width="16"
<span >
class="bp4-button-text" <path
d="M15 0H5c-.55 0-1 .45-1 1v2h2V2h8v7h-1v2h2c.55 0 1-.45 1-1V1c0-.55-.45-1-1-1zm-4 4H1c-.55 0-1 .45-1 1v10c0 .55.45 1 1 1h10c.55 0 1-.45 1-1V5c0-.55-.45-1-1-1zm-1 10H2V6h8v8z"
fill-rule="evenodd"
/>
</svg>
</span>
<span
class="bp4-button-text"
>
Copy
</span>
</button>
<button
class="bp4-button bp4-intent-primary"
type="button"
> >
Copy <span
</span> class="bp4-button-text"
</button> >
<button Close
class="bp4-button bp4-intent-primary" </span>
type="button" </button>
> </div>
<span
class="bp4-button-text"
>
Close
</span>
</button>
</div> </div>
</div> </div>
</div> </div>

View File

@ -19,10 +19,6 @@
@import '../../variables'; @import '../../variables';
.show-value-dialog { .show-value-dialog {
&.#{$bp-ns}-dialog {
padding-bottom: 10px;
}
&.normal.#{$bp-ns}-dialog { &.normal.#{$bp-ns}-dialog {
height: 600px; height: 600px;
} }
@ -32,12 +28,21 @@
height: 90vh; height: 90vh;
} }
.#{$bp-ns}-input { .#{$bp-ns}-dialog-body {
margin: 10px; display: flex;
flex: 1; flex-direction: column;
.ace-editor {
flex: 1;
}
.#{$bp-ns}-input {
flex: 1;
resize: none;
}
} }
.#{$bp-ns}-dialog-footer-actions { .#{$bp-ns}-dialog-footer {
padding-right: 10px; margin-top: 0;
} }
} }

View File

@ -16,11 +16,21 @@
* limitations under the License. * limitations under the License.
*/ */
import { Button, Classes, Dialog, Intent, TextArea } from '@blueprintjs/core'; import {
Button,
ButtonGroup,
Classes,
Dialog,
FormGroup,
Intent,
TextArea,
} from '@blueprintjs/core';
import { IconNames } from '@blueprintjs/icons'; import { IconNames } from '@blueprintjs/icons';
import classNames from 'classnames'; import classNames from 'classnames';
import copy from 'copy-to-clipboard'; import copy from 'copy-to-clipboard';
import React from 'react'; import * as JSONBig from 'json-bigint-native';
import React, { useMemo, useState } from 'react';
import AceEditor from 'react-ace';
import { AppToaster } from '../../singletons'; import { AppToaster } from '../../singletons';
@ -35,6 +45,15 @@ export interface ShowValueDialogProps {
export const ShowValueDialog = React.memo(function ShowValueDialog(props: ShowValueDialogProps) { export const ShowValueDialog = React.memo(function ShowValueDialog(props: ShowValueDialogProps) {
const { title, onClose, str, size } = props; const { title, onClose, str, size } = props;
const [tab, setTab] = useState<'formatted' | 'raw'>('formatted');
const parsed = useMemo(() => {
try {
return JSONBig.parse(str);
} catch {}
}, [str]);
const hasParsed = typeof parsed !== 'undefined';
function handleCopy() { function handleCopy() {
copy(str, { format: 'text/plain' }); copy(str, { format: 'text/plain' });
@ -51,10 +70,41 @@ export const ShowValueDialog = React.memo(function ShowValueDialog(props: ShowVa
onClose={onClose} onClose={onClose}
title={title || 'Full value'} title={title || 'Full value'}
> >
<TextArea value={str} spellCheck={false} /> <div className={Classes.DIALOG_BODY}>
<div className={Classes.DIALOG_FOOTER_ACTIONS}> {hasParsed && (
<Button icon={IconNames.DUPLICATE} text="Copy" onClick={handleCopy} /> <FormGroup>
<Button text="Close" intent={Intent.PRIMARY} onClick={onClose} /> <ButtonGroup fill>
<Button
text="Formatted"
active={tab === 'formatted'}
onClick={() => setTab('formatted')}
/>
<Button text="Raw" active={tab === 'raw'} onClick={() => setTab('raw')} />
</ButtonGroup>
</FormGroup>
)}
{hasParsed && tab === 'formatted' && (
<AceEditor
mode="hjson"
theme="solarized_dark"
className="query-string"
name="ace-editor"
fontSize={12}
width="100%"
height="100%"
showGutter
showPrintMargin={false}
value={JSONBig.stringify(parsed, undefined, 2)}
readOnly
/>
)}
{(!hasParsed || tab === 'raw') && <TextArea value={str} spellCheck={false} />}
</div>
<div className={Classes.DIALOG_FOOTER}>
<div className={Classes.DIALOG_FOOTER_ACTIONS}>
<Button icon={IconNames.DUPLICATE} text="Copy" onClick={handleCopy} />
<Button text="Close" intent={Intent.PRIMARY} onClick={onClose} />
</div>
</div> </div>
</Dialog> </Dialog>
); );

View File

@ -136,7 +136,7 @@ export const StringMenuItems = React.memo(function StringMenuItems(props: String
<MenuItem icon={IconNames.FUNCTION} text="Aggregate"> <MenuItem icon={IconNames.FUNCTION} text="Aggregate">
{aggregateMenuItem(F.countDistinct(column), `dist_${columnName}`)} {aggregateMenuItem(F.countDistinct(column), `dist_${columnName}`)}
{aggregateMenuItem( {aggregateMenuItem(
F.count().addWhereExpression(column.equal(SqlPlaceholder.PLACEHOLDER)), F.count().addWhere(column.equal(SqlPlaceholder.PLACEHOLDER)),
`filtered_dist_${columnName}`, `filtered_dist_${columnName}`,
false, false,
)} )}

View File

@ -657,7 +657,9 @@ export const ResultTablePane = React.memo(function ResultTablePane(props: Result
})} })}
/> />
)} )}
{showValue && <ShowValueDialog onClose={() => setShowValue(undefined)} str={showValue} />} {showValue && (
<ShowValueDialog onClose={() => setShowValue(undefined)} str={showValue} size="large" />
)}
{editingExpression && ( {editingExpression && (
<ExpressionEditorDialog <ExpressionEditorDialog
includeOutputName includeOutputName

View File

@ -535,7 +535,7 @@ export const RunPanel = React.memo(function RunPanel(props: RunPanelProps) {
)} )}
{editContextDialogOpen && ( {editContextDialogOpen && (
<EditContextDialog <EditContextDialog
queryContext={queryContext} initQueryContext={queryContext}
onQueryContextChange={changeQueryContext} onQueryContextChange={changeQueryContext}
onClose={() => { onClose={() => {
setEditContextDialogOpen(false); setEditContextDialogOpen(false);