diff --git a/web-console/package.json b/web-console/package.json index 9d687ecde74..f64366a67af 100644 --- a/web-console/package.json +++ b/web-console/package.json @@ -35,7 +35,7 @@ "test-e2e": "jest --config jest.e2e.config.js e2e-tests", "codecov": "codecov --disable=gcov -p ..", "coverage": "jest --coverage src", - "update-snapshots": "jest -u", + "update-snapshots": "jest -u --config jest.unit.config.js", "autofix": "npm run eslint-fix && npm run sasslint-fix && npm run prettify", "eslint": "eslint '{src,e2e-tests}/**/*.ts?(x)'", "eslint-fix": "npm run eslint -- --fix", diff --git a/web-console/src/components/formatted-input-group/__snapshots__/formatted-input-group.spec.tsx.snap b/web-console/src/components/formatted-input-group/__snapshots__/formatted-input-group.spec.tsx.snap new file mode 100644 index 00000000000..672f5726649 --- /dev/null +++ b/web-console/src/components/formatted-input-group/__snapshots__/formatted-input-group.spec.tsx.snap @@ -0,0 +1,25 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`FormattedInputGroup matches snapshot on undefined value 1`] = ` +
+ +
+`; + +exports[`FormattedInputGroup matches snapshot with escaped value 1`] = ` +
+ +
+`; diff --git a/web-console/src/components/formatted-input-group/formatted-input-group.spec.tsx b/web-console/src/components/formatted-input-group/formatted-input-group.spec.tsx new file mode 100644 index 00000000000..dfa6739ee8d --- /dev/null +++ b/web-console/src/components/formatted-input-group/formatted-input-group.spec.tsx @@ -0,0 +1,48 @@ +/* + * 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 { render } from '@testing-library/react'; +import React from 'react'; + +import { JSON_STRING_FORMATTER } from '../../utils'; + +import { FormattedInputGroup } from './formatted-input-group'; + +describe('FormattedInputGroup', () => { + it('matches snapshot on undefined value', () => { + const suggestibleInput = ( + {}} formatter={JSON_STRING_FORMATTER} /> + ); + + const { container } = render(suggestibleInput); + expect(container.firstChild).toMatchSnapshot(); + }); + + it('matches snapshot with escaped value', () => { + const suggestibleInput = ( + {}} + formatter={JSON_STRING_FORMATTER} + /> + ); + + const { container } = render(suggestibleInput); + expect(container.firstChild).toMatchSnapshot(); + }); +}); diff --git a/web-console/src/components/formatted-input-group/formatted-input-group.tsx b/web-console/src/components/formatted-input-group/formatted-input-group.tsx new file mode 100644 index 00000000000..5622906bfb3 --- /dev/null +++ b/web-console/src/components/formatted-input-group/formatted-input-group.tsx @@ -0,0 +1,69 @@ +/* + * 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 { InputGroup, InputGroupProps2 } from '@blueprintjs/core'; +import classNames from 'classnames'; +import React, { useState } from 'react'; + +import { Formatter } from '../../utils'; + +export interface FormattedInputGroupProps extends InputGroupProps2 { + formatter: Formatter; + onValueChange: (newValue: undefined | string) => void; +} + +export const FormattedInputGroup = React.memo(function FormattedInputGroup( + props: FormattedInputGroupProps, +) { + const { className, formatter, value, defaultValue, onValueChange, onBlur, ...rest } = props; + + const [intermediateValue, setIntermediateValue] = useState(); + + return ( + { + const rawValue = e.target.value; + setIntermediateValue(rawValue); + + let parsedValue: string | undefined; + try { + parsedValue = formatter.parse(rawValue); + } catch { + return; + } + onValueChange(parsedValue); + }} + onBlur={e => { + setIntermediateValue(undefined); + onBlur?.(e); + }} + {...rest} + /> + ); +}); diff --git a/web-console/src/components/index.ts b/web-console/src/components/index.ts index 0ee151bcd64..7b412960c5b 100644 --- a/web-console/src/components/index.ts +++ b/web-console/src/components/index.ts @@ -25,6 +25,7 @@ export * from './center-message/center-message'; export * from './clearable-input/clearable-input'; export * from './external-link/external-link'; export * from './form-json-selector/form-json-selector'; +export * from './formatted-input-group/formatted-input-group'; export * from './header-bar/header-bar'; export * from './highlight-text/highlight-text'; export * from './json-collapse/json-collapse'; diff --git a/web-console/src/components/suggestible-input/__snapshots__/suggestible-input.spec.tsx.snap b/web-console/src/components/suggestible-input/__snapshots__/suggestible-input.spec.tsx.snap index 1c18aeff841..5686c8a7203 100644 --- a/web-console/src/components/suggestible-input/__snapshots__/suggestible-input.spec.tsx.snap +++ b/web-console/src/components/suggestible-input/__snapshots__/suggestible-input.spec.tsx.snap @@ -1,8 +1,8 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`suggestible input matches snapshot 1`] = ` +exports[`SuggestibleInput matches snapshot 1`] = `
`; + +exports[`SuggestibleInput matches snapshot with escaped value 1`] = ` +
+ + + + + + +
+`; diff --git a/web-console/src/components/suggestible-input/suggestible-input.spec.tsx b/web-console/src/components/suggestible-input/suggestible-input.spec.tsx index 13f86cf89b1..41461c9a1f3 100644 --- a/web-console/src/components/suggestible-input/suggestible-input.spec.tsx +++ b/web-console/src/components/suggestible-input/suggestible-input.spec.tsx @@ -21,7 +21,7 @@ import React from 'react'; import { SuggestibleInput } from './suggestible-input'; -describe('suggestible input', () => { +describe('SuggestibleInput', () => { it('matches snapshot', () => { const suggestibleInput = ( {}} suggestions={['a', 'b', 'c']} /> @@ -30,4 +30,17 @@ describe('suggestible input', () => { const { container } = render(suggestibleInput); expect(container.firstChild).toMatchSnapshot(); }); + + it('matches snapshot with escaped value', () => { + const suggestibleInput = ( + {}} + suggestions={['a', 'b', 'c']} + /> + ); + + const { container } = render(suggestibleInput); + expect(container.firstChild).toMatchSnapshot(); + }); }); diff --git a/web-console/src/components/suggestible-input/suggestible-input.tsx b/web-console/src/components/suggestible-input/suggestible-input.tsx index dc34f075d38..710bc134ec2 100644 --- a/web-console/src/components/suggestible-input/suggestible-input.tsx +++ b/web-console/src/components/suggestible-input/suggestible-input.tsx @@ -16,20 +16,18 @@ * limitations under the License. */ -import { - Button, - HTMLInputProps, - InputGroup, - Intent, - Menu, - MenuItem, - Position, -} from '@blueprintjs/core'; +import { Button, Menu, MenuItem, Position } from '@blueprintjs/core'; import { IconNames } from '@blueprintjs/icons'; import { Popover2 } from '@blueprintjs/popover2'; import classNames from 'classnames'; import React, { useRef } from 'react'; +import { JSON_STRING_FORMATTER } from '../../utils'; +import { + FormattedInputGroup, + FormattedInputGroupProps, +} from '../formatted-input-group/formatted-input-group'; + export interface SuggestionGroup { group: string; suggestions: string[]; @@ -37,22 +35,19 @@ export interface SuggestionGroup { export type Suggestion = undefined | string | SuggestionGroup; -export interface SuggestibleInputProps extends HTMLInputProps { - onValueChange: (newValue: undefined | string) => void; +export interface SuggestibleInputProps extends Omit { onFinalize?: () => void; suggestions?: Suggestion[]; - large?: boolean; - intent?: Intent; } export const SuggestibleInput = React.memo(function SuggestibleInput(props: SuggestibleInputProps) { const { className, value, - defaultValue, onValueChange, onFinalize, onBlur, + onFocus, suggestions, ...rest } = props; @@ -65,20 +60,19 @@ export const SuggestibleInput = React.memo(function SuggestibleInput(props: Sugg } return ( - { - onValueChange(e.target.value); - }} - onFocus={(e: any) => { + formatter={JSON_STRING_FORMATTER} + value={value} + onValueChange={onValueChange} + onFocus={e => { lastFocusValue.current = e.target.value; + onFocus?.(e); }} - onBlur={(e: any) => { - if (onBlur) onBlur(e); + onBlur={e => { + onBlur?.(e); if (lastFocusValue.current === e.target.value) return; - if (onFinalize) onFinalize(); + onFinalize?.(); }} rightElement={ suggestions && ( @@ -98,7 +92,7 @@ export const SuggestibleInput = React.memo(function SuggestibleInput(props: Sugg return ( handleSuggestionSelect(suggestion)} /> ); diff --git a/web-console/src/dialogs/retention-dialog/__snapshots__/retention-dialog.spec.tsx.snap b/web-console/src/dialogs/retention-dialog/__snapshots__/retention-dialog.spec.tsx.snap index 487091c1dc2..98840963b3d 100644 --- a/web-console/src/dialogs/retention-dialog/__snapshots__/retention-dialog.spec.tsx.snap +++ b/web-console/src/dialogs/retention-dialog/__snapshots__/retention-dialog.spec.tsx.snap @@ -232,7 +232,7 @@ exports[`retention dialog matches snapshot 1`] = `
[] = [ name: 'delimiter', type: 'string', defaultValue: '\t', + suggestions: ['\t', '|', '#'], defined: (p: InputFormat) => p.type === 'tsv', info: <>A custom delimiter for data values., }, { name: 'listDelimiter', type: 'string', + defaultValue: '\x01', + suggestions: ['\x01', '\x00'], defined: (p: InputFormat) => oneOf(p.type, 'csv', 'tsv', 'regex'), - placeholder: '(optional, default = ctrl+A)', info: <>A custom delimiter for multi-value dimensions., }, { diff --git a/web-console/src/utils/formatter.spec.ts b/web-console/src/utils/formatter.spec.ts new file mode 100644 index 00000000000..1031263025b --- /dev/null +++ b/web-console/src/utils/formatter.spec.ts @@ -0,0 +1,85 @@ +/* + * 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 { JSON_STRING_FORMATTER } from './formatter'; + +describe('Formatter', () => { + describe('JSON_STRING_FORMATTER', () => { + it('has a working stringify', () => { + expect( + new Array(38).fill(0).map((_, i) => { + return JSON_STRING_FORMATTER.stringify( + i + ' : `' + String.fromCharCode(i) + '` : `' + String.fromCharCode(i) + '`', + ); + }), + ).toEqual([ + '0 : `\\u0000` : `\\u0000`', + '1 : `\\u0001` : `\\u0001`', + '2 : `\\u0002` : `\\u0002`', + '3 : `\\u0003` : `\\u0003`', + '4 : `\\u0004` : `\\u0004`', + '5 : `\\u0005` : `\\u0005`', + '6 : `\\u0006` : `\\u0006`', + '7 : `\\u0007` : `\\u0007`', + '8 : `\\b` : `\\b`', + '9 : `\\t` : `\\t`', + '10 : `\\n` : `\\n`', + '11 : `\\u000b` : `\\u000b`', + '12 : `\\f` : `\\f`', + '13 : `\\r` : `\\r`', + '14 : `\\u000e` : `\\u000e`', + '15 : `\\u000f` : `\\u000f`', + '16 : `\\u0010` : `\\u0010`', + '17 : `\\u0011` : `\\u0011`', + '18 : `\\u0012` : `\\u0012`', + '19 : `\\u0013` : `\\u0013`', + '20 : `\\u0014` : `\\u0014`', + '21 : `\\u0015` : `\\u0015`', + '22 : `\\u0016` : `\\u0016`', + '23 : `\\u0017` : `\\u0017`', + '24 : `\\u0018` : `\\u0018`', + '25 : `\\u0019` : `\\u0019`', + '26 : `\\u001a` : `\\u001a`', + '27 : `\\u001b` : `\\u001b`', + '28 : `\\u001c` : `\\u001c`', + '29 : `\\u001d` : `\\u001d`', + '30 : `\\u001e` : `\\u001e`', + '31 : `\\u001f` : `\\u001f`', + '32 : ` ` : ` `', + '33 : `!` : `!`', + '34 : `\\"` : `\\"`', + '35 : `#` : `#`', + '36 : `$` : `$`', + '37 : `%` : `%`', + ]); + + expect(JSON_STRING_FORMATTER.stringify(`hello "world"`)).toEqual(`hello \\"world\\"`); + }); + + it('has a working parse', () => { + expect(JSON_STRING_FORMATTER.parse(`h\u0065llo\t"world"\\`)).toEqual(`hello\t"world"\\`); + }); + + it('parses back and forth', () => { + new Array(38).fill(0).forEach((_, i) => { + const str = i + ' : `' + String.fromCharCode(i) + '` : `' + String.fromCharCode(i) + '`'; + expect(JSON_STRING_FORMATTER.parse(JSON_STRING_FORMATTER.stringify(str))).toEqual(str); + }); + }); + }); +}); diff --git a/web-console/src/utils/formatter.ts b/web-console/src/utils/formatter.ts new file mode 100644 index 00000000000..2efbb00be7c --- /dev/null +++ b/web-console/src/utils/formatter.ts @@ -0,0 +1,68 @@ +/* + * 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. + */ + +export interface Formatter { + stringify: (thing: T) => string; + parse: (str: string) => T; +} + +const JSON_ESCAPES: Record = { + '"': '"', + '\\': '\\', + '/': '/', + 'b': '\b', + 'f': '\f', + 'n': '\n', + 'r': '\r', + 't': '\t', +}; + +// The stringifier is just JSON minus the double quotes, the parser is much more forgiving +export const JSON_STRING_FORMATTER: Formatter = { + stringify: (str: string) => { + if (typeof str !== 'string') throw new TypeError(`must be a string`); + + const json = JSON.stringify(str); + return json.substr(1, json.length - 2); + }, + parse: (str: string) => { + const n = str.length; + let i = 0; + let parsed = ''; + while (i < n) { + const ch = str[i]; + if (ch === '\\') { + const nextCh = str[i + 1]; + if (nextCh === 'u' && /^[0-9a-f]{4}$/i.test(str.substr(i + 2, 4))) { + parsed += String.fromCharCode(parseInt(str.substr(i + 2, 4), 16)); + i += 6; + } else if (JSON_ESCAPES[nextCh]) { + parsed += JSON_ESCAPES[nextCh]; + i += 2; + } else { + parsed += ch; + i++; + } + } else { + parsed += ch; + i++; + } + } + return parsed; + }, +}; diff --git a/web-console/src/utils/index.tsx b/web-console/src/utils/index.tsx index 0b40e734d9f..0ac487bdfc2 100644 --- a/web-console/src/utils/index.tsx +++ b/web-console/src/utils/index.tsx @@ -21,6 +21,7 @@ export * from './column-metadata'; export * from './date'; export * from './druid-lookup'; export * from './druid-query'; +export * from './formatter'; export * from './general'; export * from './local-storage-keys'; export * from './object-change';