diff --git a/licenses.yaml b/licenses.yaml index c1d33df3712d..8dea5baac471 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 cca418864894..daae46a60fbb 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 dc485c442d55..784b46737ff4 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", @@ -26618,6 +26615,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", @@ -31017,14 +31022,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 e79321f07431..03c68a9157be 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 d8fd232795e7..7e8fc0994610 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 64c6e41420cd..1eaeb5e6aa14 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 a1e19174ffcd..63a591e87b92 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 59840215ce35..69aafe08b02b 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 3db2a5b880a4..939db2fc9d45 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 f4a9deb68c42..8e3979a2583c 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 000000000000..c82556dbc6c4 --- /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 dff5bd066502..2ede11e381f1 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 000000000000..573366cf6e57 --- /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)); + } + }} + /> + +