You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@druid.apache.org by vo...@apache.org on 2021/06/27 01:55:05 UTC

[druid] branch master updated: Web console: allow encoding of ASCII control chars (#10795)

This is an automated email from the ASF dual-hosted git repository.

vogievetsky pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/druid.git


The following commit(s) were added to refs/heads/master by this push:
     new 561cc71  Web console: allow encoding of ASCII control chars (#10795)
561cc71 is described below

commit 561cc718389e6556cdd8246e140f59c06f2d7d29
Author: Vadim Ogievetsky <va...@ogievetsky.com>
AuthorDate: Sat Jun 26 18:54:41 2021 -0700

    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
---
 web-console/package.json                           |  2 +-
 .../formatted-input-group.spec.tsx.snap            | 25 +++++++
 .../formatted-input-group.spec.tsx}                | 23 +++++-
 .../formatted-input-group.tsx                      | 69 ++++++++++++++++++
 web-console/src/components/index.ts                |  1 +
 .../__snapshots__/suggestible-input.spec.tsx.snap  | 49 ++++++++++++-
 .../suggestible-input/suggestible-input.spec.tsx   | 15 +++-
 .../suggestible-input/suggestible-input.tsx        | 44 +++++------
 .../__snapshots__/retention-dialog.spec.tsx.snap   |  2 +-
 web-console/src/druid-models/input-format.tsx      |  4 +-
 web-console/src/utils/formatter.spec.ts            | 85 ++++++++++++++++++++++
 web-console/src/utils/formatter.ts                 | 68 +++++++++++++++++
 web-console/src/utils/index.tsx                    |  1 +
 13 files changed, 353 insertions(+), 35 deletions(-)

diff --git a/web-console/package.json b/web-console/package.json
index 9d687ec..f64366a 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 0000000..672f572
--- /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`] = `
+<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>
+`;
diff --git a/web-console/src/components/suggestible-input/suggestible-input.spec.tsx b/web-console/src/components/formatted-input-group/formatted-input-group.spec.tsx
similarity index 61%
copy from web-console/src/components/suggestible-input/suggestible-input.spec.tsx
copy to web-console/src/components/formatted-input-group/formatted-input-group.spec.tsx
index 13f86cf..dfa6739 100644
--- a/web-console/src/components/suggestible-input/suggestible-input.spec.tsx
+++ b/web-console/src/components/formatted-input-group/formatted-input-group.spec.tsx
@@ -19,12 +19,27 @@
 import { render } from '@testing-library/react';
 import React from 'react';
 
-import { SuggestibleInput } from './suggestible-input';
+import { JSON_STRING_FORMATTER } from '../../utils';
 
-describe('suggestible input', () => {
-  it('matches snapshot', () => {
+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 = (
-      <SuggestibleInput onValueChange={() => {}} suggestions={['a', 'b', 'c']} />
+      <FormattedInputGroup
+        value={`Here are some chars \t\r\n lol`}
+        onValueChange={() => {}}
+        formatter={JSON_STRING_FORMATTER}
+      />
     );
 
     const { container } = render(suggestibleInput);
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 0000000..5622906
--- /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<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}
+    />
+  );
+});
diff --git a/web-console/src/components/index.ts b/web-console/src/components/index.ts
index 0ee151b..7b41296 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 1c18aef..5686c8a 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`] = `
 <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>
+`;
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 13f86cf..41461c9 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 = (
       <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();
+  });
 });
diff --git a/web-console/src/components/suggestible-input/suggestible-input.tsx b/web-console/src/components/suggestible-input/suggestible-input.tsx
index dc34f07..710bc13 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<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)}
                       />
                     );
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 487091c..9884096 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`] = `
                               </span>
                             </div>
                             <div
-                              class="bp3-input-group suggestible-input"
+                              class="bp3-input-group formatted-input-group suggestible-input"
                             >
                               <input
                                 class="bp3-input"
diff --git a/web-console/src/druid-models/input-format.tsx b/web-console/src/druid-models/input-format.tsx
index 4bd9702..7c2f324 100644
--- a/web-console/src/druid-models/input-format.tsx
+++ b/web-console/src/druid-models/input-format.tsx
@@ -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.</>,
   },
   {
diff --git a/web-console/src/utils/formatter.spec.ts b/web-console/src/utils/formatter.spec.ts
new file mode 100644
index 0000000..1031263
--- /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 0000000..2efbb00
--- /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<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;
+  },
+};
diff --git a/web-console/src/utils/index.tsx b/web-console/src/utils/index.tsx
index 0b40e73..0ac487b 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';

---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@druid.apache.org
For additional commands, e-mail: commits-help@druid.apache.org