From d5f6749aa365ead1f5823649c9da7b161153d21d Mon Sep 17 00:00:00 2001 From: Vadim Ogievetsky Date: Sun, 16 Jul 2023 22:56:46 -0700 Subject: [PATCH] Web console: catchup to all the backend changes (#14540) This PR catches the console up to all the backend changes for Druid 27 Specifically: Add page information to SqlStatementResource API #14512 Allow empty tiered replicants map for load rules #14432 Adding Interactive API's for MSQ engine #14416 Add replication factor column to sys table #14403 Account for data format and compression in MSQ auto taskAssignment #14307 Errors take 3 #14004 --- licenses.yaml | 18 +- web-console/e2e-tests/tutorial-batch.spec.ts | 2 +- web-console/package-lock.json | 37 +- web-console/package.json | 2 +- web-console/src/bootstrap/json-parser.tsx | 2 +- .../__snapshots__/auto-form.spec.tsx.snap | 2 +- .../components/auto-form/auto-form.spec.tsx | 21 + .../src/components/auto-form/auto-form.tsx | 73 +- .../components/braced-text/braced-text.tsx | 1 + .../cell-filter-menu/cell-filter-menu.tsx | 4 +- .../src/components/deferred/deferred.tsx | 1 + .../fancy-numeric-input.spec.tsx.snap | 32 + .../fancy-numeric-input.spec.tsx} | 8 +- .../fancy-numeric-input.tsx | 224 +++++++ .../form-group-with-info.tsx | 1 + .../__snapshots__/header-bar.spec.tsx.snap | 11 + .../src/components/header-bar/header-bar.tsx | 14 + .../highlight-text/highlight-text.tsx | 31 +- .../numeric-input-with-default.spec.tsx.snap | 20 - .../numeric-input-with-default.tsx | 50 -- .../query-error-pane/query-error-pane.tsx | 15 +- .../record-table-pane/record-table-pane.tsx | 2 +- .../refresh-button/refresh-button.tsx | 7 +- .../components/segment-timeline/bar-group.tsx | 2 +- .../segment-timeline.spec.tsx | 2 +- .../segment-timeline/segment-timeline.tsx | 2 +- .../src/components/show-log/show-log.tsx | 2 +- .../src/components/table-cell/table-cell.tsx | 4 +- web-console/src/console-application.tsx | 2 +- .../compaction-dynamic-config-dialog.tsx | 164 +++++ .../datasource-columns-table.tsx | 2 +- .../datasource-preview-pane.tsx | 4 +- .../dialogs/doctor-dialog/doctor-dialog.tsx | 2 +- .../dialogs/history-dialog/history-dialog.tsx | 1 + web-console/src/dialogs/index.ts | 1 + .../lookup-values-table.tsx | 2 +- .../numeric-input-dialog.tsx | 1 + .../segments-preview-pane.tsx | 4 +- .../dialogs/snitch-dialog/snitch-dialog.tsx | 8 +- .../dialogs/status-dialog/status-dialog.tsx | 2 +- .../async-query/async-query.mock.ts | 97 +++ .../druid-models/async-query/async-query.ts | 37 + .../compaction-config/compaction-config.tsx | 60 +- .../dimension-spec/dimension-spec.ts | 9 +- .../execution-ingest-complete.mock.ts | 594 +++++++++------- .../execution/execution-ingest-error.mock.ts | 634 ++++++++++-------- .../druid-models/execution/execution.spec.ts | 188 +++++- .../src/druid-models/execution/execution.ts | 156 +++-- .../external-config/external-config.ts | 4 +- .../src/druid-models/filter/filter.tsx | 16 +- .../flatten-spec/flatten-spec.tsx | 7 +- web-console/src/druid-models/index.ts | 2 + .../ingest-query-pattern.spec.ts | 2 +- .../ingest-query-pattern.ts | 2 +- .../ingestion-spec/ingestion-spec.tsx | 70 +- .../input-format/input-format.tsx | 42 +- .../input-source/input-source.tsx | 25 +- .../druid-models/lookup-spec/lookup-spec.tsx | 130 +++- .../druid-models/metric-spec/metric-spec.tsx | 66 +- web-console/src/druid-models/mocks.ts | 1 + web-console/src/druid-models/stages/stages.ts | 7 +- web-console/src/druid-models/task/task.ts | 102 +++ .../workbench-query/workbench-query-part.ts | 4 +- .../workbench-query/workbench-query.spec.ts | 5 +- .../workbench-query/workbench-query.ts | 9 +- web-console/src/entry.ts | 2 +- web-console/src/helpers/execution/general.ts | 2 +- .../helpers/execution/sql-task-execution.ts | 133 ++-- web-console/src/helpers/spec-conversion.ts | 2 +- .../src/react-table/react-table-utils.ts | 4 +- .../singletons/workbench-running-promises.ts | 2 +- web-console/src/utils/basic-action.tsx | 1 + web-console/src/utils/download.ts | 2 +- web-console/src/utils/druid-query.spec.ts | 92 +-- web-console/src/utils/druid-query.ts | 138 ++-- web-console/src/utils/general.tsx | 36 +- web-console/src/utils/load-rule.ts | 16 +- web-console/src/utils/query-action.ts | 2 +- web-console/src/utils/query-cursor.ts | 6 +- web-console/src/utils/query-manager.tsx | 13 +- web-console/src/utils/sample-query.spec.tsx | 2 +- web-console/src/utils/sample-query.tsx | 4 +- web-console/src/utils/sampler.ts | 2 +- web-console/src/utils/sql.ts | 2 +- web-console/src/utils/table-helpers.ts | 2 +- web-console/src/utils/types.ts | 2 +- .../datasources-view.spec.tsx.snap | 6 +- .../datasources-view/datasources-view.tsx | 81 ++- .../home-view/segments-card/segments-card.tsx | 38 +- .../views/load-data-view/load-data-view.tsx | 3 +- .../src/views/lookups-view/lookups-view.tsx | 2 +- .../__snapshots__/segments-view.spec.tsx.snap | 30 +- .../src/views/segments-view/segments-view.tsx | 59 +- .../src/views/services-view/services-view.tsx | 2 +- .../column-actions/column-actions.tsx | 5 +- .../column-editor/column-editor.tsx | 5 +- .../expression-editor-dialog.tsx | 2 +- .../ingestion-progress-dialog.tsx | 2 +- .../schema-step/column-list/column-list.tsx | 2 +- .../expression-entry/expression-entry.tsx | 4 +- .../preview-table/preview-table.tsx | 4 +- .../rollup-analysis-pane.tsx | 5 +- .../schema-step/schema-step.tsx | 7 +- .../sql-data-loader-view.tsx | 3 +- .../supervisors-view/supervisors-view.tsx | 59 +- .../src/views/tasks-view/tasks-view.tsx | 2 +- .../capacity-alert/capacity-alert.tsx | 4 +- .../number-menu-items.spec.tsx | 2 +- .../number-menu-items/number-menu-items.tsx | 5 +- .../string-menu-items.spec.tsx | 2 +- .../string-menu-items/string-menu-items.tsx | 5 +- .../time-menu-items/time-menu-items.spec.tsx | 2 +- .../time-menu-items/time-menu-items.tsx | 5 +- .../column-tree/column-tree.spec.tsx | 2 +- .../column-tree/column-tree.tsx | 6 +- .../connect-external-data-dialog.tsx | 2 +- .../execution-details-pane.spec.tsx.snap | 311 +++++---- .../execution-details-pane.tsx | 2 +- .../execution-error-pane.spec.tsx.snap | 2 +- .../execution-progress-bar-pane.tsx | 29 +- .../execution-progress-pane.tsx | 2 +- .../execution-stages-pane.spec.tsx.snap | 45 +- .../execution-stages-pane.tsx | 41 +- .../execution-submit-dialog.tsx | 6 +- .../execution-summary-panel.tsx | 1 + .../explain-dialog/explain-dialog.tsx | 1 + .../flexible-query-input.tsx | 4 +- .../helper-query/helper-query.tsx | 9 +- .../ingest-success-pane.spec.tsx.snap | 2 +- .../ingest-success-pane.tsx | 18 +- .../input-format-step/input-format-step.tsx | 4 +- .../input-source-step/input-source-step.tsx | 5 +- .../max-tasks-button.spec.tsx.snap | 24 +- .../max-tasks-button/max-tasks-button.tsx | 9 +- .../workbench-view/query-tab/query-tab.tsx | 9 +- .../recent-query-task-panel.tsx | 22 +- .../result-table-pane/result-table-pane.tsx | 5 +- .../workbench-view/run-panel/run-panel.tsx | 1 + .../time-floor-menu-item.tsx | 4 +- .../workbench-history-dialog.tsx | 1 + .../views/workbench-view/workbench-view.tsx | 4 +- 141 files changed, 3015 insertions(+), 1419 deletions(-) create mode 100644 web-console/src/components/fancy-numeric-input/__snapshots__/fancy-numeric-input.spec.tsx.snap rename web-console/src/components/{numeric-input-with-default/numeric-input-with-default.spec.tsx => fancy-numeric-input/fancy-numeric-input.spec.tsx} (81%) create mode 100644 web-console/src/components/fancy-numeric-input/fancy-numeric-input.tsx delete mode 100644 web-console/src/components/numeric-input-with-default/__snapshots__/numeric-input-with-default.spec.tsx.snap delete mode 100644 web-console/src/components/numeric-input-with-default/numeric-input-with-default.tsx create mode 100644 web-console/src/dialogs/compaction-dynamic-config-dialog/compaction-dynamic-config-dialog.tsx create mode 100644 web-console/src/druid-models/async-query/async-query.mock.ts create mode 100644 web-console/src/druid-models/async-query/async-query.ts create mode 100644 web-console/src/druid-models/task/task.ts diff --git a/licenses.yaml b/licenses.yaml index c1d33df3712..8dea5baac47 100644 --- a/licenses.yaml +++ b/licenses.yaml @@ -5358,6 +5358,15 @@ version: 4.9.22 --- +name: "@druid-toolkit/query" +license_category: binary +module: web-console +license_name: Apache License version 2.0 +copyright: Imply Data +version: 0.20.5 + +--- + name: "@emotion/cache" license_category: binary module: web-console @@ -5926,15 +5935,6 @@ license_file_path: licenses/bin/dot-case.MIT --- -name: "druid-query-toolkit" -license_category: binary -module: web-console -license_name: Apache License version 2.0 -copyright: Imply Data -version: 0.18.12 - ---- - name: "emotion" license_category: binary module: web-console diff --git a/web-console/e2e-tests/tutorial-batch.spec.ts b/web-console/e2e-tests/tutorial-batch.spec.ts index cca41886489..daae46a60fb 100644 --- a/web-console/e2e-tests/tutorial-batch.spec.ts +++ b/web-console/e2e-tests/tutorial-batch.spec.ts @@ -16,7 +16,7 @@ * limitations under the License. */ -import { T } from 'druid-query-toolkit'; +import { T } from '@druid-toolkit/query'; import type * as playwright from 'playwright-chromium'; import { DatasourcesOverview } from './component/datasources/overview'; diff --git a/web-console/package-lock.json b/web-console/package-lock.json index 96897f85783..be6d61c6d6f 100644 --- a/web-console/package-lock.json +++ b/web-console/package-lock.json @@ -14,6 +14,7 @@ "@blueprintjs/datetime2": "^0.9.35", "@blueprintjs/icons": "^4.16.0", "@blueprintjs/popover2": "^1.14.9", + "@druid-toolkit/query": "^0.20.5", "ace-builds": "~1.4.14", "axios": "^0.26.1", "classnames": "^2.2.6", @@ -23,7 +24,6 @@ "d3-axis": "^2.1.0", "d3-scale": "^3.3.0", "d3-selection": "^2.0.0", - "druid-query-toolkit": "^0.18.12", "file-saver": "^2.0.2", "follow-redirects": "^1.14.7", "fontsource-open-sans": "^3.0.9", @@ -2578,6 +2578,14 @@ "node": ">=10.0.0" } }, + "node_modules/@druid-toolkit/query": { + "version": "0.20.5", + "resolved": "https://registry.npmjs.org/@druid-toolkit/query/-/query-0.20.5.tgz", + "integrity": "sha512-EY0131z611tklnui+vyRqsoPjTBbonkF7WwsNvT0KsBQYm5qtuvX/QlXGfX66f4KQzoo5G/4dRIVmZ9JbSRgzw==", + "dependencies": { + "tslib": "^2.5.2" + } + }, "node_modules/@emotion/cache": { "version": "10.0.29", "resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-10.0.29.tgz", @@ -8115,17 +8123,6 @@ "tslib": "^2.0.3" } }, - "node_modules/druid-query-toolkit": { - "version": "0.18.12", - "resolved": "https://registry.npmjs.org/druid-query-toolkit/-/druid-query-toolkit-0.18.12.tgz", - "integrity": "sha512-wDcZUW8vhiJXARC44EFFwUeZW6lawXWv++bxHIUKaxq3M5byBuWPKjEDTCdPEHprxmR2sxaTpsPw4A6KiRmBog==", - "dependencies": { - "tslib": "^2.3.1" - }, - "engines": { - "node": ">=14" - } - }, "node_modules/duplexer": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.2.tgz", @@ -26628,6 +26625,14 @@ "integrity": "sha512-HyYEUDeIj5rRQU2Hk5HTB2uHsbRQpF70nvMhVzi+VJR0X+xNEhjPui4/kBf3VeH/wqD28PT4sVOm8qqLjBrSZg==", "dev": true }, + "@druid-toolkit/query": { + "version": "0.20.5", + "resolved": "https://registry.npmjs.org/@druid-toolkit/query/-/query-0.20.5.tgz", + "integrity": "sha512-EY0131z611tklnui+vyRqsoPjTBbonkF7WwsNvT0KsBQYm5qtuvX/QlXGfX66f4KQzoo5G/4dRIVmZ9JbSRgzw==", + "requires": { + "tslib": "^2.5.2" + } + }, "@emotion/cache": { "version": "10.0.29", "resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-10.0.29.tgz", @@ -31027,14 +31032,6 @@ "tslib": "^2.0.3" } }, - "druid-query-toolkit": { - "version": "0.18.12", - "resolved": "https://registry.npmjs.org/druid-query-toolkit/-/druid-query-toolkit-0.18.12.tgz", - "integrity": "sha512-wDcZUW8vhiJXARC44EFFwUeZW6lawXWv++bxHIUKaxq3M5byBuWPKjEDTCdPEHprxmR2sxaTpsPw4A6KiRmBog==", - "requires": { - "tslib": "^2.3.1" - } - }, "duplexer": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.2.tgz", diff --git a/web-console/package.json b/web-console/package.json index e79321f0743..03c68a9157b 100644 --- a/web-console/package.json +++ b/web-console/package.json @@ -68,6 +68,7 @@ "@blueprintjs/datetime2": "^0.9.35", "@blueprintjs/icons": "^4.16.0", "@blueprintjs/popover2": "^1.14.9", + "@druid-toolkit/query": "^0.20.5", "ace-builds": "~1.4.14", "axios": "^0.26.1", "classnames": "^2.2.6", @@ -77,7 +78,6 @@ "d3-axis": "^2.1.0", "d3-scale": "^3.3.0", "d3-selection": "^2.0.0", - "druid-query-toolkit": "^0.18.12", "file-saver": "^2.0.2", "follow-redirects": "^1.14.7", "fontsource-open-sans": "^3.0.9", diff --git a/web-console/src/bootstrap/json-parser.tsx b/web-console/src/bootstrap/json-parser.tsx index d8fd232795e..7e8fc099461 100644 --- a/web-console/src/bootstrap/json-parser.tsx +++ b/web-console/src/bootstrap/json-parser.tsx @@ -16,7 +16,7 @@ * limitations under the License. */ -import { QueryResult } from 'druid-query-toolkit'; +import { QueryResult } from '@druid-toolkit/query'; import * as JSONBig from 'json-bigint-native'; export function bootstrapJsonParse() { diff --git a/web-console/src/components/auto-form/__snapshots__/auto-form.spec.tsx.snap b/web-console/src/components/auto-form/__snapshots__/auto-form.spec.tsx.snap index 64c6e41420c..1eaeb5e6aa1 100644 --- a/web-console/src/components/auto-form/__snapshots__/auto-form.spec.tsx.snap +++ b/web-console/src/components/auto-form/__snapshots__/auto-form.spec.tsx.snap @@ -7,7 +7,7 @@ exports[`AutoForm matches snapshot 1`] = ` - { COMPACTION_CONFIG_FIELDS, ), ).toEqual('field tuningConfig.totalNumMergeTasks is defined but it should not be'); + + expect( + AutoForm.issueWithModel( + { + dataSource: 'ds', + taskPriority: 25, + skipOffsetFromLatest: 'P4D', + tuningConfig: { + partitionsSpec: { + type: 'not_a_know_partition_spec', + maxRowsPerSegment: 5000000, + }, + totalNumMergeTasks: 5, + type: 'index_parallel', + forceGuaranteedRollup: false, + }, + taskContext: null, + }, + COMPACTION_CONFIG_FIELDS, + ), + ).toBeUndefined(); }); }); diff --git a/web-console/src/components/auto-form/auto-form.tsx b/web-console/src/components/auto-form/auto-form.tsx index a1e19174ffc..63a591e87b9 100644 --- a/web-console/src/components/auto-form/auto-form.tsx +++ b/web-console/src/components/auto-form/auto-form.tsx @@ -25,14 +25,15 @@ import { NumericInput, } from '@blueprintjs/core'; import { IconNames } from '@blueprintjs/icons'; +import type { JSX } from 'react'; import React from 'react'; import { deepDelete, deepGet, deepSet, durationSanitizer } from '../../utils'; import { ArrayInput } from '../array-input/array-input'; +import { FancyNumericInput } from '../fancy-numeric-input/fancy-numeric-input'; import { FormGroupWithInfo } from '../form-group-with-info/form-group-with-info'; import { IntervalInput } from '../interval-input/interval-input'; import { JsonInput } from '../json-input/json-input'; -import { NumericInputWithDefault } from '../numeric-input-with-default/numeric-input-with-default'; import { PopoverText } from '../popover-text/popover-text'; import { SuggestibleInput } from '../suggestible-input/suggestible-input'; import type { Suggestion } from '../suggestion-menu/suggestion-menu'; @@ -47,6 +48,7 @@ export interface Field { info?: React.ReactNode; type: | 'number' + | 'ratio' | 'size-bytes' | 'string' | 'duration' @@ -64,7 +66,7 @@ export interface Field { zeroMeansUndefined?: boolean; height?: string; disabled?: Functor; - defined?: Functor; + defined?: Functor; required?: Functor; multiline?: Functor; hide?: Functor; @@ -81,6 +83,11 @@ export interface Field { }) => JSX.Element; } +function toNumberOrUndefined(n: unknown): number | undefined { + const r = Number(n); + return isNaN(r) ? undefined : r; +} + interface ComputedFieldValues { required: boolean; defaultValue?: any; @@ -155,10 +162,13 @@ export class AutoForm> extends React.PureComponent // Precompute which fields are defined because fields could be defined twice and only one should do the checking const definedFields: Record> = {}; + const notDefinedFields: Record> = {}; for (const field of fields) { const fieldDefined = AutoForm.evaluateFunctor(field.defined, model, true); if (fieldDefined) { definedFields[field.name] = field; + } else if (fieldDefined === false) { + notDefinedFields[field.name] = field; } } @@ -180,7 +190,7 @@ export class AutoForm> extends React.PureComponent if (valueIssue) return `field ${field.name} has issue ${valueIssue}`; } } - } else { + } else if (notDefinedFields[field.name]) { // The field is undefined if (fieldValueDefined) { return `field ${field.name} is defined but it should not be`; @@ -249,15 +259,14 @@ export class AutoForm> extends React.PureComponent const { required, defaultValue, modelValue } = AutoForm.computeFieldValues(model, field); return ( - { - let newValue: number | undefined; - if (valueAsString !== '' && !isNaN(valueAsNumber)) { - newValue = valueAsNumber === 0 && field.zeroMeansUndefined ? undefined : valueAsNumber; - } - this.fieldChange(field, newValue); + { + this.fieldChange( + field, + valueAsNumber === 0 && field.zeroMeansUndefined ? undefined : valueAsNumber, + ); }} onBlur={e => { if (e.target.value === '') { @@ -265,7 +274,7 @@ export class AutoForm> extends React.PureComponent } if (onFinalize) onFinalize(); }} - min={field.min || 0} + min={field.min ?? 0} max={field.max} fill large={large} @@ -276,6 +285,40 @@ export class AutoForm> extends React.PureComponent ); } + private renderRatioInput(field: Field): JSX.Element { + const { model, large, onFinalize } = this.props; + const { required, defaultValue, modelValue } = AutoForm.computeFieldValues(model, field); + + return ( + { + this.fieldChange( + field, + valueAsNumber === 0 && field.zeroMeansUndefined ? undefined : valueAsNumber, + ); + }} + onBlur={e => { + if (e.target.value === '') { + this.fieldChange(field, undefined); + } + if (onFinalize) onFinalize(); + }} + min={field.min ?? 0} + max={field.max ?? 1} + minorStepSize={0.001} + stepSize={0.01} + majorStepSize={0.05} + fill + large={large} + disabled={AutoForm.evaluateFunctor(field.disabled, model, false)} + placeholder={AutoForm.evaluateFunctor(field.placeholder, model, '')} + intent={required && modelValue == null ? AutoForm.REQUIRED_INTENT : undefined} + /> + ); + } + private renderSizeBytesInput(field: Field): JSX.Element { const { model, large, onFinalize } = this.props; const { required, defaultValue, modelValue } = AutoForm.computeFieldValues(model, field); @@ -445,6 +488,8 @@ export class AutoForm> extends React.PureComponent switch (field.type) { case 'number': return this.renderNumberInput(field); + case 'ratio': + return this.renderRatioInput(field); case 'size-bytes': return this.renderSizeBytesInput(field); case 'string': @@ -510,7 +555,7 @@ export class AutoForm> extends React.PureComponent ); } - render(): JSX.Element { + render() { const { fields, model, showCustom } = this.props; const { showMore, customDialog } = this.state; diff --git a/web-console/src/components/braced-text/braced-text.tsx b/web-console/src/components/braced-text/braced-text.tsx index 59840215ce3..69aafe08b02 100644 --- a/web-console/src/components/braced-text/braced-text.tsx +++ b/web-console/src/components/braced-text/braced-text.tsx @@ -18,6 +18,7 @@ import classNames from 'classnames'; import { max } from 'd3-array'; +import type { JSX } from 'react'; import React, { Fragment } from 'react'; import './braced-text.scss'; diff --git a/web-console/src/components/cell-filter-menu/cell-filter-menu.tsx b/web-console/src/components/cell-filter-menu/cell-filter-menu.tsx index 3db2a5b880a..939db2fc9d4 100644 --- a/web-console/src/components/cell-filter-menu/cell-filter-menu.tsx +++ b/web-console/src/components/cell-filter-menu/cell-filter-menu.tsx @@ -18,8 +18,8 @@ import { Menu, MenuItem } from '@blueprintjs/core'; import { IconNames } from '@blueprintjs/icons'; -import type { Column, SqlExpression, SqlQuery } from 'druid-query-toolkit'; -import { C, L, SqlComparison, SqlLiteral, SqlRecord, trimString } from 'druid-query-toolkit'; +import type { Column, SqlExpression, SqlQuery } from '@druid-toolkit/query'; +import { C, L, SqlComparison, SqlLiteral, SqlRecord, trimString } from '@druid-toolkit/query'; import React from 'react'; import type { QueryAction } from '../../utils'; diff --git a/web-console/src/components/deferred/deferred.tsx b/web-console/src/components/deferred/deferred.tsx index f4a9deb68c4..8e3979a2583 100644 --- a/web-console/src/components/deferred/deferred.tsx +++ b/web-console/src/components/deferred/deferred.tsx @@ -16,6 +16,7 @@ * limitations under the License. */ +import type { JSX } from 'react'; import React from 'react'; export interface DeferredProps { diff --git a/web-console/src/components/fancy-numeric-input/__snapshots__/fancy-numeric-input.spec.tsx.snap b/web-console/src/components/fancy-numeric-input/__snapshots__/fancy-numeric-input.spec.tsx.snap new file mode 100644 index 00000000000..c82556dbc6c --- /dev/null +++ b/web-console/src/components/fancy-numeric-input/__snapshots__/fancy-numeric-input.spec.tsx.snap @@ -0,0 +1,32 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`FancyNumericInput matches snapshot 1`] = ` + + + + + + + +`; diff --git a/web-console/src/components/numeric-input-with-default/numeric-input-with-default.spec.tsx b/web-console/src/components/fancy-numeric-input/fancy-numeric-input.spec.tsx similarity index 81% rename from web-console/src/components/numeric-input-with-default/numeric-input-with-default.spec.tsx rename to web-console/src/components/fancy-numeric-input/fancy-numeric-input.spec.tsx index dff5bd06650..2ede11e381f 100644 --- a/web-console/src/components/numeric-input-with-default/numeric-input-with-default.spec.tsx +++ b/web-console/src/components/fancy-numeric-input/fancy-numeric-input.spec.tsx @@ -20,11 +20,13 @@ import React from 'react'; import { shallow } from '../../utils/shallow-renderer'; -import { NumericInputWithDefault } from './numeric-input-with-default'; +import { FancyNumericInput } from './fancy-numeric-input'; -describe('NumericInputWithDefault', () => { +describe('FancyNumericInput', () => { it('matches snapshot', () => { - const numericInputWithDefault = shallow(); + const numericInputWithDefault = shallow( + {}} />, + ); expect(numericInputWithDefault).toMatchSnapshot(); }); diff --git a/web-console/src/components/fancy-numeric-input/fancy-numeric-input.tsx b/web-console/src/components/fancy-numeric-input/fancy-numeric-input.tsx new file mode 100644 index 00000000000..573366cf6e5 --- /dev/null +++ b/web-console/src/components/fancy-numeric-input/fancy-numeric-input.tsx @@ -0,0 +1,224 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { InputGroupProps2, Intent } from '@blueprintjs/core'; +import { Button, ButtonGroup, Classes, ControlGroup, InputGroup, Keys } from '@blueprintjs/core'; +import { IconNames } from '@blueprintjs/icons'; +import { SqlExpression, SqlFunction, SqlLiteral, SqlMulti } from '@druid-toolkit/query'; +import classNames from 'classnames'; +import React, { useEffect, useState } from 'react'; + +import { clamp } from '../../utils'; + +const MULTI_OP_TO_REDUCER: Record number> = { + '+': (a, b) => a + b, + '-': (a, b) => a - b, + '*': (a, b) => a * b, + '/': (a, b) => (b ? a / b : 0), +}; + +function evaluateSqlSimple(sql: SqlExpression): number | undefined { + if (sql instanceof SqlLiteral) { + return sql.getNumberValue(); + } else if (sql instanceof SqlMulti) { + const evaluatedArgs = sql.getArgArray().map(evaluateSqlSimple); + if (evaluatedArgs.some(x => typeof x === 'undefined')) return; + const reducer = MULTI_OP_TO_REDUCER[sql.op]; + if (!reducer) return; + return (evaluatedArgs as number[]).reduce(reducer); + } else if (sql instanceof SqlFunction && sql.getEffectiveFunctionName() === 'PI') { + return Math.PI; + } else { + return; + } +} + +function numberToShown(n: number | undefined): string { + if (typeof n === 'undefined') return ''; + return String(n); +} + +function shownToNumber(s: string): number | undefined { + const parsed = SqlExpression.maybeParse(s); + if (!parsed) return; + return evaluateSqlSimple(parsed); +} + +export interface FancyNumericInputProps { + className?: string; + intent?: Intent; + fill?: boolean; + large?: boolean; + small?: boolean; + disabled?: boolean; + readOnly?: boolean; + placeholder?: string; + onBlur?: InputGroupProps2['onBlur']; + + value: number | undefined; + defaultValue?: number; + onValueChange(value: number): void; + + min?: number; + max?: number; + minorStepSize?: number; + stepSize?: number; + majorStepSize?: number; +} + +export const FancyNumericInput = React.memo(function FancyNumericInput( + props: FancyNumericInputProps, +) { + const { + className, + intent, + fill, + large, + small, + disabled, + readOnly, + placeholder, + onBlur, + + value, + defaultValue, + onValueChange, + + min, + max, + } = props; + + const stepSize = props.stepSize || 1; + const minorStepSize = props.minorStepSize || stepSize; + const majorStepSize = props.majorStepSize || stepSize * 10; + + function roundAndClamp(n: number): number { + const inv = 1 / minorStepSize; + return clamp(Math.floor(n * inv) / inv, min, max); + } + + const effectiveValue = value ?? defaultValue; + const [shownValue, setShownValue] = useState(numberToShown(effectiveValue)); + const shownNumberRaw = shownToNumber(shownValue); + const shownNumberClamped = shownNumberRaw ? roundAndClamp(shownNumberRaw) : undefined; + + useEffect(() => { + if (effectiveValue !== shownNumberClamped) { + setShownValue(numberToShown(effectiveValue)); + } + }, [effectiveValue]); + + const containerClasses = classNames( + 'fancy-numeric-input', + Classes.NUMERIC_INPUT, + { [Classes.LARGE]: large, [Classes.SMALL]: small }, + className, + ); + + const effectiveDisabled = disabled || readOnly; + const isIncrementDisabled = max !== undefined && value !== undefined && +value >= max; + const isDecrementDisabled = min !== undefined && value !== undefined && +value <= min; + + function changeValue(newValue: number): void { + onValueChange(roundAndClamp(newValue)); + } + + function increment(delta: number): void { + if (typeof shownNumberRaw !== 'number') return; + changeValue(shownNumberRaw + delta); + } + + function getIncrementSize(isShiftKeyPressed: boolean, isAltKeyPressed: boolean): number { + if (isShiftKeyPressed) { + return majorStepSize; + } + if (isAltKeyPressed) { + return minorStepSize; + } + return stepSize; + } + + return ( + + { + const valueAsString = (e.target as HTMLInputElement).value; + setShownValue(valueAsString); + + const shownNumber = shownToNumber(valueAsString); + if (typeof shownNumber === 'number') { + changeValue(shownNumber); + } + }} + onBlur={e => { + setShownValue(numberToShown(effectiveValue)); + onBlur?.(e); + }} + onKeyDown={e => { + const { keyCode } = e; + + if (keyCode === Keys.ENTER && typeof shownNumberClamped === 'number') { + setShownValue(numberToShown(shownNumberClamped)); + return; + } + + let direction = 0; + if (keyCode === Keys.ARROW_UP) { + direction = 1; + } else if (keyCode === Keys.ARROW_DOWN) { + direction = -1; + } + + if (direction) { + // when the input field has focus, some key combinations will modify + // the field's selection range. we'll actually want to select all + // text in the field after we modify the value on the following + // lines. preventing the default selection behavior lets us do that + // without interference. + e.preventDefault(); + + increment(direction * getIncrementSize(e.shiftKey, e.altKey)); + } + }} + /> + +