mirror of https://github.com/apache/druid.git
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:
parent
af2ab98574
commit
561cc71838
|
@ -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",
|
||||
|
|
|
@ -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>
|
||||
`;
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
});
|
|
@ -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';
|
||||
|
|
|
@ -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>
|
||||
`;
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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)}
|
||||
/>
|
||||
);
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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.</>,
|
||||
},
|
||||
{
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
},
|
||||
};
|
|
@ -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';
|
||||
|
|
Loading…
Reference in New Issue