Web console: allow encoding of ASCII control chars (#10795)

* allow encoding of ascii control chars

* change to JSON

* make json escpaes work

* update snapshot

* break out component

* fix test

* update test script

* update formatter to be more chill
This commit is contained in:
Vadim Ogievetsky 2021-06-26 18:54:41 -07:00 committed by GitHub
parent af2ab98574
commit 561cc71838
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 382 additions and 31 deletions

View File

@ -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",

View File

@ -0,0 +1,25 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`FormattedInputGroup matches snapshot on undefined value 1`] = `
<div
class="bp3-input-group formatted-input-group"
>
<input
class="bp3-input"
type="text"
value=""
/>
</div>
`;
exports[`FormattedInputGroup matches snapshot with escaped value 1`] = `
<div
class="bp3-input-group formatted-input-group"
>
<input
class="bp3-input"
type="text"
value="Here are some chars \\\\t\\\\r\\\\n lol"
/>
</div>
`;

View File

@ -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 = (
<FormattedInputGroup onValueChange={() => {}} formatter={JSON_STRING_FORMATTER} />
);
const { container } = render(suggestibleInput);
expect(container.firstChild).toMatchSnapshot();
});
it('matches snapshot with escaped value', () => {
const suggestibleInput = (
<FormattedInputGroup
value={`Here are some chars \t\r\n lol`}
onValueChange={() => {}}
formatter={JSON_STRING_FORMATTER}
/>
);
const { container } = render(suggestibleInput);
expect(container.firstChild).toMatchSnapshot();
});
});

View File

@ -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<any>;
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<string | undefined>();
return (
<InputGroup
className={classNames('formatted-input-group', className)}
value={
typeof intermediateValue !== 'undefined'
? intermediateValue
: typeof value !== 'undefined'
? formatter.stringify(value)
: undefined
}
defaultValue={
typeof defaultValue !== 'undefined' ? formatter.stringify(defaultValue) : undefined
}
onChange={e => {
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}
/>
);
});

View File

@ -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';

View File

@ -1,8 +1,8 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`suggestible input matches snapshot 1`] = `
exports[`SuggestibleInput matches snapshot 1`] = `
<div
class="bp3-input-group suggestible-input"
class="bp3-input-group formatted-input-group suggestible-input"
>
<input
class="bp3-input"
@ -44,3 +44,48 @@ exports[`suggestible input matches snapshot 1`] = `
</span>
</div>
`;
exports[`SuggestibleInput matches snapshot with escaped value 1`] = `
<div
class="bp3-input-group formatted-input-group suggestible-input"
>
<input
class="bp3-input"
style="padding-right: 0px;"
type="text"
value="Here are some chars \\\\t\\\\r\\\\n lol"
/>
<span
class="bp3-input-action"
>
<span
class="bp3-popover2-target"
>
<button
class="bp3-button bp3-minimal"
type="button"
>
<span
class="bp3-icon bp3-icon-caret-down"
icon="caret-down"
>
<svg
data-icon="caret-down"
height="16"
viewBox="0 0 16 16"
width="16"
>
<desc>
caret-down
</desc>
<path
d="M12 6.5c0-.28-.22-.5-.5-.5h-7a.495.495 0 00-.37.83l3.5 4c.09.1.22.17.37.17s.28-.07.37-.17l3.5-4c.08-.09.13-.2.13-.33z"
fill-rule="evenodd"
/>
</svg>
</span>
</button>
</span>
</span>
</div>
`;

View File

@ -21,7 +21,7 @@ import React from 'react';
import { SuggestibleInput } from './suggestible-input';
describe('suggestible input', () => {
describe('SuggestibleInput', () => {
it('matches snapshot', () => {
const suggestibleInput = (
<SuggestibleInput onValueChange={() => {}} 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 = (
<SuggestibleInput
value={`Here are some chars \t\r\n lol`}
onValueChange={() => {}}
suggestions={['a', 'b', 'c']}
/>
);
const { container } = render(suggestibleInput);
expect(container.firstChild).toMatchSnapshot();
});
});

View File

@ -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<FormattedInputGroupProps, 'formatter'> {
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 (
<InputGroup
<FormattedInputGroup
className={classNames('suggestible-input', className)}
value={value as string}
defaultValue={defaultValue as string}
onChange={(e: any) => {
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 (
<MenuItem
key={suggestion}
text={suggestion}
text={JSON_STRING_FORMATTER.stringify(suggestion)}
onClick={() => handleSuggestionSelect(suggestion)}
/>
);

View File

@ -232,7 +232,7 @@ exports[`retention dialog matches snapshot 1`] = `
</span>
</div>
<div
class="bp3-input-group suggestible-input"
class="bp3-input-group formatted-input-group suggestible-input"
>
<input
class="bp3-input"

View File

@ -113,14 +113,16 @@ export const INPUT_FORMAT_FIELDS: Field<InputFormat>[] = [
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.</>,
},
{

View File

@ -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);
});
});
});
});

View File

@ -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<T> {
stringify: (thing: T) => string;
parse: (str: string) => T;
}
const JSON_ESCAPES: Record<string, string> = {
'"': '"',
'\\': '\\',
'/': '/',
'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<string> = {
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;
},
};

View File

@ -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';