Web console: Improve the lookup view UX (#11620)

* polish lookup view UX

* update snapshots

* add snapshot to git

* fixes

* update sanpshots

* restore column treatment

* update snapshot

* add gs
This commit is contained in:
Vadim Ogievetsky 2021-08-30 14:36:23 -07:00 committed by GitHub
parent a09688862e
commit e4ec3527a4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
31 changed files with 699 additions and 396 deletions

View File

@ -23,7 +23,7 @@ const fs = require('fs-extra');
const readfile = '../docs/querying/sql.md'; const readfile = '../docs/querying/sql.md';
const writefile = 'lib/sql-docs.js'; const writefile = 'lib/sql-docs.js';
const MINIMUM_EXPECTED_NUMBER_OF_FUNCTIONS = 134; const MINIMUM_EXPECTED_NUMBER_OF_FUNCTIONS = 152;
const MINIMUM_EXPECTED_NUMBER_OF_DATA_TYPES = 14; const MINIMUM_EXPECTED_NUMBER_OF_DATA_TYPES = 14;
function unwrapMarkdownLinks(str) { function unwrapMarkdownLinks(str) {
@ -41,7 +41,7 @@ const readDoc = async () => {
const functionDocs = []; const functionDocs = [];
const dataTypeDocs = []; const dataTypeDocs = [];
for (let line of lines) { for (let line of lines) {
const functionMatch = line.match(/^\|`(\w+)\(([^|]*)\)`\|([^|]+)\|(?:([^|]+)\|)?$/); const functionMatch = line.match(/^\|\s*`(\w+)\(([^|]*)\)`\s*\|([^|]+)\|(?:([^|]+)\|)?$/);
if (functionMatch) { if (functionMatch) {
functionDocs.push([ functionDocs.push([
functionMatch[1], functionMatch[1],

View File

@ -20,7 +20,7 @@ import { Button, ButtonGroup, FormGroup, Intent, NumericInput } from '@blueprint
import { IconNames } from '@blueprintjs/icons'; import { IconNames } from '@blueprintjs/icons';
import React from 'react'; import React from 'react';
import { deepDelete, deepGet, deepSet } from '../../utils'; import { deepDelete, deepGet, deepSet, durationSanitizer } from '../../utils';
import { ArrayInput } from '../array-input/array-input'; import { ArrayInput } from '../array-input/array-input';
import { FormGroupWithInfo } from '../form-group-with-info/form-group-with-info'; import { FormGroupWithInfo } from '../form-group-with-info/form-group-with-info';
import { IntervalInput } from '../interval-input/interval-input'; import { IntervalInput } from '../interval-input/interval-input';
@ -281,15 +281,16 @@ export class AutoForm<T extends Record<string, any>> extends React.PureComponent
); );
} }
private renderStringInput(field: Field<T>, sanitize?: (str: string) => string): JSX.Element { private renderStringInput(field: Field<T>, sanitizer?: (str: string) => string): JSX.Element {
const { model, large, onFinalize } = this.props; const { model, large, onFinalize } = this.props;
const { required, defaultValue, modelValue } = AutoForm.computeFieldValues(model, field); const { required, defaultValue, modelValue } = AutoForm.computeFieldValues(model, field);
return ( return (
<SuggestibleInput <SuggestibleInput
value={modelValue != null ? modelValue : defaultValue || ''} value={modelValue != null ? modelValue : defaultValue || ''}
sanitizer={sanitizer}
issueWithValue={field.issueWithValue}
onValueChange={v => { onValueChange={v => {
if (sanitize && typeof v === 'string') v = sanitize(v);
this.fieldChange(field, v); this.fieldChange(field, v);
}} }}
onBlur={() => { onBlur={() => {
@ -397,9 +398,7 @@ export class AutoForm<T extends Record<string, any>> extends React.PureComponent
case 'string': case 'string':
return this.renderStringInput(field); return this.renderStringInput(field);
case 'duration': case 'duration':
return this.renderStringInput(field, (str: string) => return this.renderStringInput(field, durationSanitizer);
str.toUpperCase().replace(/[^0-9PYMDTHS.,]/g, ''),
);
case 'boolean': case 'boolean':
return this.renderBooleanInput(field); return this.renderBooleanInput(field);
case 'string-array': case 'string-array':

View File

@ -13,7 +13,7 @@ exports[`form group with info matches snapshot 1`] = `
class="bp3-text-muted" class="bp3-text-muted"
> >
<span <span
class="bp3-popover2-target" class="info-popover bp3-popover2-target"
> >
<span <span
class="bp3-icon bp3-icon-info-sign" class="bp3-icon bp3-icon-info-sign"

View File

@ -24,7 +24,7 @@
.bp3-form-content { .bp3-form-content {
position: relative; position: relative;
& > .bp3-popover2-target { & > .info-popover {
position: absolute; position: absolute;
right: 0; right: 0;
top: 5px; top: 5px;

View File

@ -36,7 +36,7 @@ export const FormGroupWithInfo = React.memo(function FormGroupWithInfo(
const { label, info, inlineInfo, children } = props; const { label, info, inlineInfo, children } = props;
const popover = ( const popover = (
<Popover2 content={info} position="left-bottom"> <Popover2 className="info-popover" content={info} position="left-bottom">
<Icon icon={IconNames.INFO_SIGN} iconSize={14} /> <Icon icon={IconNames.INFO_SIGN} iconSize={14} />
</Popover2> </Popover2>
); );

View File

@ -1,25 +0,0 @@
// 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

@ -1,69 +0,0 @@
/*
* 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

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

View File

@ -0,0 +1,29 @@
/*
* 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.
*/
.formatted-input {
position: relative;
& > .bp3-popover2-target {
position: absolute;
width: 0;
right: 0;
top: 0;
bottom: 0;
}
}

View File

@ -21,12 +21,12 @@ import React from 'react';
import { JSON_STRING_FORMATTER } from '../../utils'; import { JSON_STRING_FORMATTER } from '../../utils';
import { FormattedInputGroup } from './formatted-input-group'; import { FormattedInput } from './formatted-input';
describe('FormattedInputGroup', () => { describe('FormattedInput', () => {
it('matches snapshot on undefined value', () => { it('matches snapshot on undefined value', () => {
const suggestibleInput = ( const suggestibleInput = (
<FormattedInputGroup onValueChange={() => {}} formatter={JSON_STRING_FORMATTER} /> <FormattedInput onValueChange={() => {}} formatter={JSON_STRING_FORMATTER} />
); );
const { container } = render(suggestibleInput); const { container } = render(suggestibleInput);
@ -35,7 +35,7 @@ describe('FormattedInputGroup', () => {
it('matches snapshot with escaped value', () => { it('matches snapshot with escaped value', () => {
const suggestibleInput = ( const suggestibleInput = (
<FormattedInputGroup <FormattedInput
value={`Here are some chars \t\r\n lol`} value={`Here are some chars \t\r\n lol`}
onValueChange={() => {}} onValueChange={() => {}}
formatter={JSON_STRING_FORMATTER} formatter={JSON_STRING_FORMATTER}

View File

@ -0,0 +1,107 @@
/*
* 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, Intent } from '@blueprintjs/core';
import { Tooltip2 } from '@blueprintjs/popover2';
import classNames from 'classnames';
import React, { useState } from 'react';
import { Formatter } from '../../utils';
import './formatted-input.scss';
export interface FormattedInputProps extends InputGroupProps2 {
formatter: Formatter<any>;
onValueChange: (newValue: undefined | string) => void;
sanitizer?: (rawValue: string) => string;
issueWithValue?: (value: any) => string | undefined;
}
export const FormattedInput = React.memo(function FormattedInput(props: FormattedInputProps) {
const {
className,
formatter,
sanitizer,
issueWithValue,
value,
defaultValue,
onValueChange,
onFocus,
onBlur,
intent,
...rest
} = props;
const [intermediateValue, setIntermediateValue] = useState<string | undefined>();
const [isFocused, setIsFocused] = useState(false);
const issue: string | undefined = issueWithValue?.(value);
const showIssue = Boolean(!isFocused && issue);
return (
<div className={classNames('formatted-input', className)}>
<InputGroup
value={
typeof intermediateValue !== 'undefined'
? intermediateValue
: typeof value !== 'undefined'
? formatter.stringify(value)
: undefined
}
defaultValue={
typeof defaultValue !== 'undefined' ? formatter.stringify(defaultValue) : undefined
}
onChange={e => {
let rawValue = e.target.value;
if (sanitizer) rawValue = sanitizer(rawValue);
setIntermediateValue(rawValue);
let parsedValue: string | undefined;
try {
parsedValue = formatter.parse(rawValue);
} catch {
return;
}
onValueChange(parsedValue);
}}
onFocus={e => {
setIsFocused(true);
onFocus?.(e);
}}
onBlur={e => {
setIntermediateValue(undefined);
setIsFocused(false);
onBlur?.(e);
}}
intent={showIssue ? Intent.DANGER : intent}
{...rest}
/>
{showIssue && (
<Tooltip2
isOpen
content={showIssue ? issue : undefined}
position="right"
intent={Intent.DANGER}
targetTagName="div"
>
<div className="target-dummy" />
</Tooltip2>
)}
</div>
);
});

View File

@ -25,7 +25,7 @@ export * from './center-message/center-message';
export * from './clearable-input/clearable-input'; export * from './clearable-input/clearable-input';
export * from './external-link/external-link'; export * from './external-link/external-link';
export * from './form-json-selector/form-json-selector'; export * from './form-json-selector/form-json-selector';
export * from './formatted-input-group/formatted-input-group'; export * from './formatted-input/formatted-input';
export * from './header-bar/header-bar'; export * from './header-bar/header-bar';
export * from './highlight-text/highlight-text'; export * from './highlight-text/highlight-text';
export * from './json-collapse/json-collapse'; export * from './json-collapse/json-collapse';

View File

@ -30,6 +30,7 @@ import {
import { IconNames } from '@blueprintjs/icons'; import { IconNames } from '@blueprintjs/icons';
import React, { useState } from 'react'; import React, { useState } from 'react';
import { durationSanitizer } from '../../utils';
import { Rule, RuleUtil } from '../../utils/load-rule'; import { Rule, RuleUtil } from '../../utils/load-rule';
import { SuggestibleInput } from '../suggestible-input/suggestible-input'; import { SuggestibleInput } from '../suggestible-input/suggestible-input';
@ -175,10 +176,9 @@ export const RuleEditor = React.memo(function RuleEditor(props: RuleEditorProps)
{RuleUtil.hasPeriod(rule) && ( {RuleUtil.hasPeriod(rule) && (
<SuggestibleInput <SuggestibleInput
value={rule.period || ''} value={rule.period || ''}
sanitizer={durationSanitizer}
onValueChange={period => { onValueChange={period => {
if (typeof period === 'undefined') return; if (typeof period === 'undefined') return;
// Ensure the period is upper case and does not contain anytihng but the allowed chars
period = period.toUpperCase().replace(/[^PYMDTHS0-9]/g, '');
onChange(RuleUtil.changePeriod(rule, period)); onChange(RuleUtil.changePeriod(rule, period));
}} }}
placeholder={PERIOD_SUGGESTIONS[0]} placeholder={PERIOD_SUGGESTIONS[0]}

View File

@ -2,90 +2,98 @@
exports[`SuggestibleInput matches snapshot 1`] = ` exports[`SuggestibleInput matches snapshot 1`] = `
<div <div
class="bp3-input-group formatted-input-group suggestible-input" class="formatted-input suggestible-input"
> >
<input <div
class="bp3-input" class="bp3-input-group"
style="padding-right: 0px;"
type="text"
value=""
/>
<span
class="bp3-input-action"
> >
<input
class="bp3-input"
style="padding-right: 0px;"
type="text"
value=""
/>
<span <span
class="bp3-popover2-target" class="bp3-input-action"
> >
<button <span
class="bp3-button bp3-minimal" class="bp3-popover2-target"
type="button"
> >
<span <button
class="bp3-icon bp3-icon-caret-down" class="bp3-button bp3-minimal"
icon="caret-down" type="button"
> >
<svg <span
data-icon="caret-down" class="bp3-icon bp3-icon-caret-down"
height="16" icon="caret-down"
viewBox="0 0 16 16"
width="16"
> >
<desc> <svg
caret-down data-icon="caret-down"
</desc> height="16"
<path viewBox="0 0 16 16"
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" width="16"
fill-rule="evenodd" >
/> <desc>
</svg> caret-down
</span> </desc>
</button> <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> </span>
</span> </div>
</div> </div>
`; `;
exports[`SuggestibleInput matches snapshot with escaped value 1`] = ` exports[`SuggestibleInput matches snapshot with escaped value 1`] = `
<div <div
class="bp3-input-group formatted-input-group suggestible-input" class="formatted-input suggestible-input"
> >
<input <div
class="bp3-input" class="bp3-input-group"
style="padding-right: 0px;"
type="text"
value="Here are some chars \\\\t\\\\r\\\\n lol"
/>
<span
class="bp3-input-action"
> >
<input
class="bp3-input"
style="padding-right: 0px;"
type="text"
value="Here are some chars \\\\t\\\\r\\\\n lol"
/>
<span <span
class="bp3-popover2-target" class="bp3-input-action"
> >
<button <span
class="bp3-button bp3-minimal" class="bp3-popover2-target"
type="button"
> >
<span <button
class="bp3-icon bp3-icon-caret-down" class="bp3-button bp3-minimal"
icon="caret-down" type="button"
> >
<svg <span
data-icon="caret-down" class="bp3-icon bp3-icon-caret-down"
height="16" icon="caret-down"
viewBox="0 0 16 16"
width="16"
> >
<desc> <svg
caret-down data-icon="caret-down"
</desc> height="16"
<path viewBox="0 0 16 16"
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" width="16"
fill-rule="evenodd" >
/> <desc>
</svg> caret-down
</span> </desc>
</button> <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> </span>
</span> </div>
</div> </div>
`; `;

View File

@ -23,10 +23,7 @@ import classNames from 'classnames';
import React, { useRef } from 'react'; import React, { useRef } from 'react';
import { JSON_STRING_FORMATTER } from '../../utils'; import { JSON_STRING_FORMATTER } from '../../utils';
import { import { FormattedInput, FormattedInputProps } from '../formatted-input/formatted-input';
FormattedInputGroup,
FormattedInputGroupProps,
} from '../formatted-input-group/formatted-input-group';
export interface SuggestionGroup { export interface SuggestionGroup {
group: string; group: string;
@ -35,7 +32,7 @@ export interface SuggestionGroup {
export type Suggestion = undefined | string | SuggestionGroup; export type Suggestion = undefined | string | SuggestionGroup;
export interface SuggestibleInputProps extends Omit<FormattedInputGroupProps, 'formatter'> { export interface SuggestibleInputProps extends Omit<FormattedInputProps, 'formatter'> {
onFinalize?: () => void; onFinalize?: () => void;
suggestions?: Suggestion[]; suggestions?: Suggestion[];
} }
@ -60,7 +57,7 @@ export const SuggestibleInput = React.memo(function SuggestibleInput(props: Sugg
} }
return ( return (
<FormattedInputGroup <FormattedInput
className={classNames('suggestible-input', className)} className={classNames('suggestible-input', className)}
formatter={JSON_STRING_FORMATTER} formatter={JSON_STRING_FORMATTER}
value={value} value={value}

View File

@ -2,6 +2,7 @@
exports[`LookupEditDialog matches snapshot 1`] = ` exports[`LookupEditDialog matches snapshot 1`] = `
<Blueprint3.Dialog <Blueprint3.Dialog
canEscapeKeyClose={false}
canOutsideClickClose={true} canOutsideClickClose={true}
className="lookup-edit-dialog" className="lookup-edit-dialog"
isOpen={true} isOpen={true}
@ -53,7 +54,7 @@ exports[`LookupEditDialog matches snapshot 1`] = `
<Blueprint3.Button <Blueprint3.Button
minimal={true} minimal={true}
onClick={[Function]} onClick={[Function]}
text="Use ISO as version" text="Set to current ISO time"
/> />
} }
value="test" value="test"
@ -86,7 +87,7 @@ exports[`LookupEditDialog matches snapshot 1`] = `
}, },
Object { Object {
"defined": [Function], "defined": [Function],
"label": "Globally cached lookup type", "label": "Extraction type",
"name": "extractionNamespace.type", "name": "extractionNamespace.type",
"placeholder": "uri", "placeholder": "uri",
"required": true, "required": true,
@ -98,7 +99,27 @@ exports[`LookupEditDialog matches snapshot 1`] = `
}, },
Object { Object {
"defined": [Function], "defined": [Function],
"info": "A URI which specifies a directory (or other searchable resource) in which to search for files", "info": <p>
A URI which specifies a directory (or other searchable resource) in which to search for files specified as a
<Unknown>
file
</Unknown>
,
<Unknown>
hdfs
</Unknown>
,
<Unknown>
s3
</Unknown>
, or
<Unknown>
gs
</Unknown>
path prefix.
</p>,
"issueWithValue": [Function],
"label": "URI prefix", "label": "URI prefix",
"name": "extractionNamespace.uriPrefix", "name": "extractionNamespace.uriPrefix",
"placeholder": "s3://bucket/some/key/prefix/", "placeholder": "s3://bucket/some/key/prefix/",
@ -109,12 +130,30 @@ exports[`LookupEditDialog matches snapshot 1`] = `
"defined": [Function], "defined": [Function],
"info": <React.Fragment> "info": <React.Fragment>
<p> <p>
URI for the file of interest, specified as a file, hdfs, or s3 path URI for the file of interest, specified as a
<Unknown>
file
</Unknown>
,
<Unknown>
hdfs
</Unknown>
,
<Unknown>
s3
</Unknown>
, or
<Unknown>
gs
</Unknown>
path
</p> </p>
<p> <p>
The URI prefix option is strictly better than URI and should be used instead The URI prefix option is strictly better than URI and should be used instead
</p> </p>
</React.Fragment>, </React.Fragment>,
"issueWithValue": [Function],
"label": "URI (deprecated)", "label": "URI (deprecated)",
"name": "extractionNamespace.uri", "name": "extractionNamespace.uri",
"placeholder": "s3://bucket/some/key/prefix/lookups-01.gz", "placeholder": "s3://bucket/some/key/prefix/lookups-01.gz",
@ -154,10 +193,22 @@ exports[`LookupEditDialog matches snapshot 1`] = `
], ],
"type": "string", "type": "string",
}, },
Object {
"defaultValue": " ",
"defined": [Function],
"name": "extractionNamespace.namespaceParseSpec.delimiter",
"suggestions": Array [
" ",
";",
"|",
"#",
],
"type": "string",
},
Object { Object {
"defaultValue": 0, "defaultValue": 0,
"defined": [Function], "defined": [Function],
"info": "Number of header rows to be skipped. The default number of header rows to be skipped is 0.", "info": "Number of header rows to be skipped.",
"name": "extractionNamespace.namespaceParseSpec.skipHeaderRows", "name": "extractionNamespace.namespaceParseSpec.skipHeaderRows",
"type": "number", "type": "number",
}, },
@ -172,7 +223,7 @@ exports[`LookupEditDialog matches snapshot 1`] = `
"defined": [Function], "defined": [Function],
"info": "The list of columns in the csv file", "info": "The list of columns in the csv file",
"name": "extractionNamespace.namespaceParseSpec.columns", "name": "extractionNamespace.namespaceParseSpec.columns",
"placeholder": "[\\"key\\", \\"value\\"]", "placeholder": "key, value",
"required": [Function], "required": [Function],
"type": "string-array", "type": "string-array",
}, },
@ -190,18 +241,6 @@ exports[`LookupEditDialog matches snapshot 1`] = `
"placeholder": "(optional - defaults to the second column)", "placeholder": "(optional - defaults to the second column)",
"type": "string", "type": "string",
}, },
Object {
"defined": [Function],
"name": "extractionNamespace.namespaceParseSpec.delimiter",
"placeholder": "(optional)",
"type": "string",
},
Object {
"defined": [Function],
"name": "extractionNamespace.namespaceParseSpec.listDelimiter",
"placeholder": "(optional)",
"type": "string",
},
Object { Object {
"defined": [Function], "defined": [Function],
"name": "extractionNamespace.namespaceParseSpec.keyFieldName", "name": "extractionNamespace.namespaceParseSpec.keyFieldName",
@ -217,15 +256,9 @@ exports[`LookupEditDialog matches snapshot 1`] = `
"type": "string", "type": "string",
}, },
Object { Object {
"defaultValue": "0",
"defined": [Function], "defined": [Function],
"info": "Period between polling for updates", "info": "Defines the connectURI for connecting to the database",
"name": "extractionNamespace.pollPeriod", "issueWithValue": [Function],
"type": "string",
},
Object {
"defined": [Function],
"info": "Defines the connectURI value on the The connector config to used",
"label": "Connect URI", "label": "Connect URI",
"name": "extractionNamespace.connectorConfig.connectURI", "name": "extractionNamespace.connectorConfig.connectURI",
"required": true, "required": true,
@ -243,12 +276,6 @@ exports[`LookupEditDialog matches snapshot 1`] = `
"name": "extractionNamespace.connectorConfig.password", "name": "extractionNamespace.connectorConfig.password",
"type": "string", "type": "string",
}, },
Object {
"defined": [Function],
"info": "Should tables be created",
"name": "extractionNamespace.connectorConfig.createTables",
"type": "boolean",
},
Object { Object {
"defined": [Function], "defined": [Function],
"info": <React.Fragment> "info": <React.Fragment>
@ -264,7 +291,7 @@ exports[`LookupEditDialog matches snapshot 1`] = `
</p> </p>
</React.Fragment>, </React.Fragment>,
"name": "extractionNamespace.table", "name": "extractionNamespace.table",
"placeholder": "some_lookup_table", "placeholder": "lookup_table",
"required": true, "required": true,
"type": "string", "type": "string",
}, },
@ -283,7 +310,7 @@ exports[`LookupEditDialog matches snapshot 1`] = `
</p> </p>
</React.Fragment>, </React.Fragment>,
"name": "extractionNamespace.keyColumn", "name": "extractionNamespace.keyColumn",
"placeholder": "my_key_value", "placeholder": "key_column",
"required": true, "required": true,
"type": "string", "type": "string",
}, },
@ -302,28 +329,10 @@ exports[`LookupEditDialog matches snapshot 1`] = `
</p> </p>
</React.Fragment>, </React.Fragment>,
"name": "extractionNamespace.valueColumn", "name": "extractionNamespace.valueColumn",
"placeholder": "my_column_value", "placeholder": "value_column",
"required": true, "required": true,
"type": "string", "type": "string",
}, },
Object {
"defined": [Function],
"info": <React.Fragment>
<p>
The filter to be used when selecting lookups, this is used to create a where clause on lookup population. This will become the expression filter in the SQL query:
</p>
<p>
SELECT keyColumn, valueColumn, tsColumn? FROM namespace.table WHERE
<strong>
filter
</strong>
</p>
</React.Fragment>,
"name": "extractionNamespace.filter",
"placeholder": "(optional)",
"type": "string",
},
Object { Object {
"defined": [Function], "defined": [Function],
"info": <React.Fragment> "info": <React.Fragment>
@ -340,9 +349,42 @@ exports[`LookupEditDialog matches snapshot 1`] = `
</React.Fragment>, </React.Fragment>,
"label": "Timestamp column", "label": "Timestamp column",
"name": "extractionNamespace.tsColumn", "name": "extractionNamespace.tsColumn",
"placeholder": "(optional)", "placeholder": "timestamp_column (optional)",
"type": "string", "type": "string",
}, },
Object {
"defined": [Function],
"info": <React.Fragment>
<p>
The filter to be used when selecting lookups, this is used to create a where clause on lookup population. This will become the expression filter in the SQL query:
</p>
<p>
SELECT keyColumn, valueColumn, tsColumn? FROM namespace.table WHERE
<strong>
filter
</strong>
</p>
</React.Fragment>,
"name": "extractionNamespace.filter",
"placeholder": "for_lookup = 1 (optional)",
"type": "string",
},
Object {
"defined": [Function],
"info": "Period between polling for updates",
"name": "extractionNamespace.pollPeriod",
"required": true,
"suggestions": Array [
"PT1M",
"PT10M",
"PT30M",
"PT1H",
"PT6H",
"P1D",
],
"type": "duration",
},
Object { Object {
"defaultValue": 0, "defaultValue": 0,
"defined": [Function], "defined": [Function],

View File

@ -28,7 +28,7 @@ describe('LookupEditDialog', () => {
onClose={() => {}} onClose={() => {}}
onSubmit={() => {}} onSubmit={() => {}}
onChange={() => {}} onChange={() => {}}
lookupName="test" lookupId="test"
lookupTier="test" lookupTier="test"
lookupVersion="test" lookupVersion="test"
lookupSpec={{ type: 'map', map: { a: 1 } }} lookupSpec={{ type: 'map', map: { a: 1 } }}

View File

@ -36,10 +36,10 @@ export interface LookupEditDialogProps {
onClose: () => void; onClose: () => void;
onSubmit: (updateLookupVersion: boolean) => void; onSubmit: (updateLookupVersion: boolean) => void;
onChange: ( onChange: (
field: 'name' | 'tier' | 'version' | 'spec', field: 'id' | 'tier' | 'version' | 'spec',
value: string | Partial<LookupSpec>, value: string | Partial<LookupSpec>,
) => void; ) => void;
lookupName: string; lookupId: string;
lookupTier: string; lookupTier: string;
lookupVersion: string; lookupVersion: string;
lookupSpec: Partial<LookupSpec>; lookupSpec: Partial<LookupSpec>;
@ -53,7 +53,7 @@ export const LookupEditDialog = React.memo(function LookupEditDialog(props: Look
onSubmit, onSubmit,
lookupSpec, lookupSpec,
lookupTier, lookupTier,
lookupName, lookupId,
lookupVersion, lookupVersion,
onChange, onChange,
isEdit, isEdit,
@ -64,7 +64,7 @@ export const LookupEditDialog = React.memo(function LookupEditDialog(props: Look
const [jsonError, setJsonError] = useState<Error | undefined>(); const [jsonError, setJsonError] = useState<Error | undefined>();
const disableSubmit = Boolean( const disableSubmit = Boolean(
jsonError || isLookupInvalid(lookupName, lookupVersion, lookupTier, lookupSpec), jsonError || isLookupInvalid(lookupId, lookupVersion, lookupTier, lookupSpec),
); );
return ( return (
@ -73,13 +73,14 @@ export const LookupEditDialog = React.memo(function LookupEditDialog(props: Look
isOpen isOpen
onClose={onClose} onClose={onClose}
title={isEdit ? 'Edit lookup' : 'Add lookup'} title={isEdit ? 'Edit lookup' : 'Add lookup'}
canEscapeKeyClose={false}
> >
<div className="content"> <div className="content">
<FormGroup label="Name"> <FormGroup label="Name">
<InputGroup <InputGroup
value={lookupName} value={lookupId}
onChange={(e: any) => onChange('name', e.target.value)} onChange={(e: any) => onChange('id', e.target.value)}
intent={lookupName ? Intent.NONE : Intent.PRIMARY} intent={lookupId ? Intent.NONE : Intent.PRIMARY}
disabled={isEdit} disabled={isEdit}
placeholder="Enter the lookup name" placeholder="Enter the lookup name"
/> />
@ -112,7 +113,7 @@ export const LookupEditDialog = React.memo(function LookupEditDialog(props: Look
rightElement={ rightElement={
<Button <Button
minimal minimal
text="Use ISO as version" text="Set to current ISO time"
onClick={() => onChange('version', new Date().toISOString())} onClick={() => onChange('version', new Date().toISOString())}
/> />
} }
@ -136,6 +137,7 @@ export const LookupEditDialog = React.memo(function LookupEditDialog(props: Look
setJsonError(undefined); setJsonError(undefined);
}} }}
onError={setJsonError} onError={setJsonError}
issueWithValue={spec => AutoForm.issueWithModel(spec, LOOKUP_FIELDS)}
/> />
)} )}
</div> </div>

View File

@ -232,47 +232,51 @@ exports[`retention dialog matches snapshot 1`] = `
</span> </span>
</div> </div>
<div <div
class="bp3-input-group formatted-input-group suggestible-input" class="formatted-input suggestible-input"
> >
<input <div
class="bp3-input" class="bp3-input-group"
placeholder="P1D"
style="padding-right: 0px;"
type="text"
value="P1000Y"
/>
<span
class="bp3-input-action"
> >
<input
class="bp3-input"
placeholder="P1D"
style="padding-right: 0px;"
type="text"
value="P1000Y"
/>
<span <span
class="bp3-popover2-target" class="bp3-input-action"
> >
<button <span
class="bp3-button bp3-minimal" class="bp3-popover2-target"
type="button"
> >
<span <button
class="bp3-icon bp3-icon-caret-down" class="bp3-button bp3-minimal"
icon="caret-down" type="button"
> >
<svg <span
data-icon="caret-down" class="bp3-icon bp3-icon-caret-down"
height="16" icon="caret-down"
viewBox="0 0 16 16"
width="16"
> >
<desc> <svg
caret-down data-icon="caret-down"
</desc> height="16"
<path viewBox="0 0 16 16"
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" width="16"
fill-rule="evenodd" >
/> <desc>
</svg> caret-down
</span> </desc>
</button> <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> </span>
</span> </div>
</div> </div>
<label <label
class="bp3-control bp3-switch include-future" class="bp3-control bp3-switch include-future"

View File

@ -32,6 +32,7 @@ export interface DimensionSpec {
readonly type: string; readonly type: string;
readonly name: string; readonly name: string;
readonly createBitmapIndex?: boolean; readonly createBitmapIndex?: boolean;
readonly multiValueHandling?: string;
} }
export const DIMENSION_SPEC_FIELDS: Field<DimensionSpec>[] = [ export const DIMENSION_SPEC_FIELDS: Field<DimensionSpec>[] = [
@ -53,6 +54,13 @@ export const DIMENSION_SPEC_FIELDS: Field<DimensionSpec>[] = [
defined: typeIs('string'), defined: typeIs('string'),
defaultValue: true, defaultValue: true,
}, },
{
name: 'multiValueHandling',
type: 'string',
defined: typeIs('string'),
defaultValue: 'SORTED_ARRAY',
suggestions: ['SORTED_ARRAY', 'SORTED_SET', 'ARRAY'],
},
]; ];
export function getDimensionSpecName(dimensionSpec: string | DimensionSpec): string { export function getDimensionSpecName(dimensionSpec: string | DimensionSpec): string {

View File

@ -156,8 +156,20 @@ describe('ingestion-spec', () => {
expect(guessInputFormat(['A,B,X,Y']).type).toEqual('csv'); expect(guessInputFormat(['A,B,X,Y']).type).toEqual('csv');
}); });
it('works for TSV with ;', () => {
const inputFormat = guessInputFormat(['A;B;X;Y']);
expect(inputFormat.type).toEqual('tsv');
expect(inputFormat.delimiter).toEqual(';');
});
it('works for TSV with |', () => {
const inputFormat = guessInputFormat(['A|B|X|Y']);
expect(inputFormat.type).toEqual('tsv');
expect(inputFormat.delimiter).toEqual('|');
});
it('works for regex', () => { it('works for regex', () => {
expect(guessInputFormat(['A|B|X|Y']).type).toEqual('regex'); expect(guessInputFormat(['A/B/X/Y']).type).toEqual('regex');
}); });
}); });
}); });

View File

@ -2106,12 +2106,24 @@ export function guessInputFormat(sampleData: string[]): InputFormat {
if (sampleDatum.split(',').length > 3) { if (sampleDatum.split(',').length > 3) {
return inputFormatFromType('csv', !/,\d+,/.test(sampleDatum)); return inputFormatFromType('csv', !/,\d+,/.test(sampleDatum));
} }
// Contains more than 3 semicolons assume semicolon separated
if (sampleDatum.split(';').length > 3) {
return inputFormatFromType('tsv', !/;\d+;/.test(sampleDatum), ';');
}
// Contains more than 3 pipes assume pipe separated
if (sampleDatum.split('|').length > 3) {
return inputFormatFromType('tsv', !/\|\d+\|/.test(sampleDatum), '|');
}
} }
return inputFormatFromType('regex'); return inputFormatFromType('regex');
} }
function inputFormatFromType(type: string, findColumnsFromHeader?: boolean): InputFormat { function inputFormatFromType(
type: string,
findColumnsFromHeader?: boolean,
delimiter?: string,
): InputFormat {
let inputFormat: InputFormat = { type }; let inputFormat: InputFormat = { type };
if (type === 'regex') { if (type === 'regex') {
@ -2123,6 +2135,10 @@ function inputFormatFromType(type: string, findColumnsFromHeader?: boolean): Inp
inputFormat = deepSet(inputFormat, 'findColumnsFromHeader', findColumnsFromHeader); inputFormat = deepSet(inputFormat, 'findColumnsFromHeader', findColumnsFromHeader);
} }
if (delimiter) {
inputFormat = deepSet(inputFormat, 'delimiter', delimiter);
}
return inputFormat; return inputFormat;
} }

View File

@ -30,6 +30,7 @@ export interface InputFormat {
readonly findColumnsFromHeader?: boolean; readonly findColumnsFromHeader?: boolean;
readonly skipHeaderRows?: number; readonly skipHeaderRows?: number;
readonly columns?: string[]; readonly columns?: string[];
readonly delimiter?: string;
readonly listDelimiter?: string; readonly listDelimiter?: string;
readonly pattern?: string; readonly pattern?: string;
readonly function?: string; readonly function?: string;
@ -113,7 +114,7 @@ export const INPUT_FORMAT_FIELDS: Field<InputFormat>[] = [
name: 'delimiter', name: 'delimiter',
type: 'string', type: 'string',
defaultValue: '\t', defaultValue: '\t',
suggestions: ['\t', '|', '#'], suggestions: ['\t', ';', '|', '#'],
defined: typeIs('tsv'), defined: typeIs('tsv'),
info: <>A custom delimiter for data values.</>, info: <>A custom delimiter for data values.</>,
}, },

View File

@ -342,6 +342,7 @@ describe('lookup-spec', () => {
format: 'csv', format: 'csv',
columns: ['key', 'value'], columns: ['key', 'value'],
}, },
pollPeriod: 'PT1H',
}, },
}), }),
).toBe(false); ).toBe(false);
@ -359,6 +360,7 @@ describe('lookup-spec', () => {
format: 'csv', format: 'csv',
hasHeaderRow: true, hasHeaderRow: true,
}, },
pollPeriod: 'PT1H',
}, },
}), }),
).toBe(false); ).toBe(false);
@ -376,6 +378,7 @@ describe('lookup-spec', () => {
format: 'tsv', format: 'tsv',
columns: ['key', 'value'], columns: ['key', 'value'],
}, },
pollPeriod: 'PT1H',
}, },
}), }),
).toBe(false); ).toBe(false);
@ -394,6 +397,7 @@ describe('lookup-spec', () => {
valueFieldName: 'value', valueFieldName: 'value',
keyFieldName: 'value', keyFieldName: 'value',
}, },
pollPeriod: 'PT1H',
}, },
}), }),
).toBe(false); ).toBe(false);
@ -416,7 +420,7 @@ describe('lookup-spec', () => {
table: 'some_lookup_table', table: 'some_lookup_table',
keyColumn: 'the_old_dim_value', keyColumn: 'the_old_dim_value',
valueColumn: 'the_new_dim_value', valueColumn: 'the_new_dim_value',
pollPeriod: 600000, pollPeriod: 'PT1H',
}, },
}), }),
).toBe(false); ).toBe(false);

View File

@ -20,7 +20,7 @@ import { Code } from '@blueprintjs/core';
import React from 'react'; import React from 'react';
import { AutoForm, Field } from '../components'; import { AutoForm, Field } from '../components';
import { deepGet, deepSet, oneOf, typeIs } from '../utils'; import { deepGet, deepSet, oneOf, pluralIfNeeded, typeIs } from '../utils';
export interface ExtractionNamespaceSpec { export interface ExtractionNamespaceSpec {
readonly type: string; readonly type: string;
@ -63,6 +63,22 @@ export interface LookupSpec {
readonly injective?: boolean; readonly injective?: boolean;
} }
function issueWithUri(uri: string): string | undefined {
if (!uri) return;
const m = /^(\w+):/.exec(uri);
if (!m) return `URI is invalid, must start with 'file:', 'hdfs:', 's3:', or 'gs:`;
if (!oneOf(m[1], 'file', 'hdfs', 's3', 'gs')) {
return `Unsupported location '${m[1]}:'. Only 'file:', 'hdfs:', 's3:', and 'gs:' locations are supported`;
}
return;
}
function issueWithConnectUri(uri: string): string | undefined {
if (!uri) return;
if (!uri.startsWith('jdbc:')) return `connectURI is invalid, must start with 'jdbc:'`;
return;
}
export const LOOKUP_FIELDS: Field<LookupSpec>[] = [ export const LOOKUP_FIELDS: Field<LookupSpec>[] = [
{ {
name: 'type', name: 'type',
@ -74,7 +90,7 @@ export const LOOKUP_FIELDS: Field<LookupSpec>[] = [
return deepSet(l, 'map', {}); return deepSet(l, 'map', {});
} }
if (l.type === 'cachedNamespace' && !deepGet(l, 'extractionNamespace.type')) { if (l.type === 'cachedNamespace' && !deepGet(l, 'extractionNamespace.type')) {
return deepSet(l, 'extractionNamespace', { type: 'uri' }); return deepSet(l, 'extractionNamespace', { type: 'uri', pollPeriod: 'PT1H' });
} }
return l; return l;
}, },
@ -103,13 +119,14 @@ export const LOOKUP_FIELDS: Field<LookupSpec>[] = [
// cachedNamespace lookups have more options // cachedNamespace lookups have more options
{ {
name: 'extractionNamespace.type', name: 'extractionNamespace.type',
label: 'Globally cached lookup type', label: 'Extraction type',
type: 'string', type: 'string',
placeholder: 'uri', placeholder: 'uri',
suggestions: ['uri', 'jdbc'], suggestions: ['uri', 'jdbc'],
defined: typeIs('cachedNamespace'), defined: typeIs('cachedNamespace'),
required: true, required: true,
}, },
{ {
name: 'extractionNamespace.uriPrefix', name: 'extractionNamespace.uriPrefix',
label: 'URI prefix', label: 'URI prefix',
@ -119,8 +136,14 @@ export const LOOKUP_FIELDS: Field<LookupSpec>[] = [
deepGet(l, 'extractionNamespace.type') === 'uri' && !deepGet(l, 'extractionNamespace.uri'), deepGet(l, 'extractionNamespace.type') === 'uri' && !deepGet(l, 'extractionNamespace.uri'),
required: l => required: l =>
!deepGet(l, 'extractionNamespace.uriPrefix') && !deepGet(l, 'extractionNamespace.uri'), !deepGet(l, 'extractionNamespace.uriPrefix') && !deepGet(l, 'extractionNamespace.uri'),
info: issueWithValue: issueWithUri,
'A URI which specifies a directory (or other searchable resource) in which to search for files', info: (
<p>
A URI which specifies a directory (or other searchable resource) in which to search for
files specified as a <Code>file</Code>, <Code>hdfs</Code>, <Code>s3</Code>, or{' '}
<Code>gs</Code> path prefix.
</p>
),
}, },
{ {
name: 'extractionNamespace.uri', name: 'extractionNamespace.uri',
@ -132,9 +155,13 @@ export const LOOKUP_FIELDS: Field<LookupSpec>[] = [
!deepGet(l, 'extractionNamespace.uriPrefix'), !deepGet(l, 'extractionNamespace.uriPrefix'),
required: l => required: l =>
!deepGet(l, 'extractionNamespace.uriPrefix') && !deepGet(l, 'extractionNamespace.uri'), !deepGet(l, 'extractionNamespace.uriPrefix') && !deepGet(l, 'extractionNamespace.uri'),
issueWithValue: issueWithUri,
info: ( info: (
<> <>
<p>URI for the file of interest, specified as a file, hdfs, or s3 path</p> <p>
URI for the file of interest, specified as a <Code>file</Code>, <Code>hdfs</Code>,{' '}
<Code>s3</Code>, or <Code>gs</Code> path
</p>
<p>The URI prefix option is strictly better than URI and should be used instead</p> <p>The URI prefix option is strictly better than URI and should be used instead</p>
</> </>
), ),
@ -170,32 +197,35 @@ export const LOOKUP_FIELDS: Field<LookupSpec>[] = [
), ),
}, },
// TSV only
{
name: 'extractionNamespace.namespaceParseSpec.delimiter',
type: 'string',
defaultValue: '\t',
suggestions: ['\t', ';', '|', '#'],
defined: l => deepGet(l, 'extractionNamespace.namespaceParseSpec.format') === 'tsv',
},
// CSV + TSV // CSV + TSV
{ {
name: 'extractionNamespace.namespaceParseSpec.skipHeaderRows', name: 'extractionNamespace.namespaceParseSpec.skipHeaderRows',
type: 'number', type: 'number',
defaultValue: 0, defaultValue: 0,
defined: l => defined: l => oneOf(deepGet(l, 'extractionNamespace.namespaceParseSpec.format'), 'csv', 'tsv'),
deepGet(l, 'extractionNamespace.type') === 'uri' && info: `Number of header rows to be skipped.`,
oneOf(deepGet(l, 'extractionNamespace.namespaceParseSpec.format'), 'csv', 'tsv'),
info: `Number of header rows to be skipped. The default number of header rows to be skipped is 0.`,
}, },
{ {
name: 'extractionNamespace.namespaceParseSpec.hasHeaderRow', name: 'extractionNamespace.namespaceParseSpec.hasHeaderRow',
type: 'boolean', type: 'boolean',
defaultValue: false, defaultValue: false,
defined: l => defined: l => oneOf(deepGet(l, 'extractionNamespace.namespaceParseSpec.format'), 'csv', 'tsv'),
deepGet(l, 'extractionNamespace.type') === 'uri' &&
oneOf(deepGet(l, 'extractionNamespace.namespaceParseSpec.format'), 'csv', 'tsv'),
info: `A flag to indicate that column information can be extracted from the input files' header row`, info: `A flag to indicate that column information can be extracted from the input files' header row`,
}, },
{ {
name: 'extractionNamespace.namespaceParseSpec.columns', name: 'extractionNamespace.namespaceParseSpec.columns',
type: 'string-array', type: 'string-array',
placeholder: `["key", "value"]`, placeholder: 'key, value',
defined: l => defined: l => oneOf(deepGet(l, 'extractionNamespace.namespaceParseSpec.format'), 'csv', 'tsv'),
deepGet(l, 'extractionNamespace.type') === 'uri' &&
oneOf(deepGet(l, 'extractionNamespace.namespaceParseSpec.format'), 'csv', 'tsv'),
required: l => !deepGet(l, 'extractionNamespace.namespaceParseSpec.hasHeaderRow'), required: l => !deepGet(l, 'extractionNamespace.namespaceParseSpec.hasHeaderRow'),
info: 'The list of columns in the csv file', info: 'The list of columns in the csv file',
}, },
@ -203,65 +233,32 @@ export const LOOKUP_FIELDS: Field<LookupSpec>[] = [
name: 'extractionNamespace.namespaceParseSpec.keyColumn', name: 'extractionNamespace.namespaceParseSpec.keyColumn',
type: 'string', type: 'string',
placeholder: '(optional - defaults to the first column)', placeholder: '(optional - defaults to the first column)',
defined: l => defined: l => oneOf(deepGet(l, 'extractionNamespace.namespaceParseSpec.format'), 'csv', 'tsv'),
deepGet(l, 'extractionNamespace.type') === 'uri' &&
oneOf(deepGet(l, 'extractionNamespace.namespaceParseSpec.format'), 'csv', 'tsv'),
info: 'The name of the column containing the key', info: 'The name of the column containing the key',
}, },
{ {
name: 'extractionNamespace.namespaceParseSpec.valueColumn', name: 'extractionNamespace.namespaceParseSpec.valueColumn',
type: 'string', type: 'string',
placeholder: '(optional - defaults to the second column)', placeholder: '(optional - defaults to the second column)',
defined: l => defined: l => oneOf(deepGet(l, 'extractionNamespace.namespaceParseSpec.format'), 'csv', 'tsv'),
deepGet(l, 'extractionNamespace.type') === 'uri' &&
oneOf(deepGet(l, 'extractionNamespace.namespaceParseSpec.format'), 'csv', 'tsv'),
info: 'The name of the column containing the value', info: 'The name of the column containing the value',
}, },
// TSV only
{
name: 'extractionNamespace.namespaceParseSpec.delimiter',
type: 'string',
placeholder: `(optional)`,
defined: l =>
deepGet(l, 'extractionNamespace.type') === 'uri' &&
deepGet(l, 'extractionNamespace.namespaceParseSpec.format') === 'tsv',
},
{
name: 'extractionNamespace.namespaceParseSpec.listDelimiter',
type: 'string',
placeholder: `(optional)`,
defined: l =>
deepGet(l, 'extractionNamespace.type') === 'uri' &&
deepGet(l, 'extractionNamespace.namespaceParseSpec.format') === 'tsv',
},
// Custom JSON // Custom JSON
{ {
name: 'extractionNamespace.namespaceParseSpec.keyFieldName', name: 'extractionNamespace.namespaceParseSpec.keyFieldName',
type: 'string', type: 'string',
placeholder: `key`, placeholder: `key`,
defined: l => defined: l => deepGet(l, 'extractionNamespace.namespaceParseSpec.format') === 'customJson',
deepGet(l, 'extractionNamespace.type') === 'uri' &&
deepGet(l, 'extractionNamespace.namespaceParseSpec.format') === 'customJson',
required: true, required: true,
}, },
{ {
name: 'extractionNamespace.namespaceParseSpec.valueFieldName', name: 'extractionNamespace.namespaceParseSpec.valueFieldName',
type: 'string', type: 'string',
placeholder: `value`, placeholder: `value`,
defined: l => defined: l => deepGet(l, 'extractionNamespace.namespaceParseSpec.format') === 'customJson',
deepGet(l, 'extractionNamespace.type') === 'uri' &&
deepGet(l, 'extractionNamespace.namespaceParseSpec.format') === 'customJson',
required: true, required: true,
}, },
{
name: 'extractionNamespace.pollPeriod',
type: 'string',
defaultValue: '0',
defined: l => oneOf(deepGet(l, 'extractionNamespace.type'), 'uri', 'jdbc'),
info: `Period between polling for updates`,
},
// JDBC stuff // JDBC stuff
{ {
@ -270,7 +267,8 @@ export const LOOKUP_FIELDS: Field<LookupSpec>[] = [
type: 'string', type: 'string',
defined: l => deepGet(l, 'extractionNamespace.type') === 'jdbc', defined: l => deepGet(l, 'extractionNamespace.type') === 'jdbc',
required: true, required: true,
info: 'Defines the connectURI value on the The connector config to used', issueWithValue: issueWithConnectUri,
info: 'Defines the connectURI for connecting to the database',
}, },
{ {
name: 'extractionNamespace.connectorConfig.user', name: 'extractionNamespace.connectorConfig.user',
@ -284,16 +282,10 @@ export const LOOKUP_FIELDS: Field<LookupSpec>[] = [
defined: l => deepGet(l, 'extractionNamespace.type') === 'jdbc', defined: l => deepGet(l, 'extractionNamespace.type') === 'jdbc',
info: 'Defines the password to be used by the connector config', info: 'Defines the password to be used by the connector config',
}, },
{
name: 'extractionNamespace.connectorConfig.createTables',
type: 'boolean',
defined: l => deepGet(l, 'extractionNamespace.type') === 'jdbc',
info: 'Should tables be created',
},
{ {
name: 'extractionNamespace.table', name: 'extractionNamespace.table',
type: 'string', type: 'string',
placeholder: 'some_lookup_table', placeholder: 'lookup_table',
defined: l => deepGet(l, 'extractionNamespace.type') === 'jdbc', defined: l => deepGet(l, 'extractionNamespace.type') === 'jdbc',
required: true, required: true,
info: ( info: (
@ -312,7 +304,7 @@ export const LOOKUP_FIELDS: Field<LookupSpec>[] = [
{ {
name: 'extractionNamespace.keyColumn', name: 'extractionNamespace.keyColumn',
type: 'string', type: 'string',
placeholder: 'my_key_value', placeholder: 'key_column',
defined: l => deepGet(l, 'extractionNamespace.type') === 'jdbc', defined: l => deepGet(l, 'extractionNamespace.type') === 'jdbc',
required: true, required: true,
info: ( info: (
@ -331,7 +323,7 @@ export const LOOKUP_FIELDS: Field<LookupSpec>[] = [
{ {
name: 'extractionNamespace.valueColumn', name: 'extractionNamespace.valueColumn',
type: 'string', type: 'string',
placeholder: 'my_column_value', placeholder: 'value_column',
defined: l => deepGet(l, 'extractionNamespace.type') === 'jdbc', defined: l => deepGet(l, 'extractionNamespace.type') === 'jdbc',
required: true, required: true,
info: ( info: (
@ -347,10 +339,29 @@ export const LOOKUP_FIELDS: Field<LookupSpec>[] = [
</> </>
), ),
}, },
{
name: 'extractionNamespace.tsColumn',
type: 'string',
label: 'Timestamp column',
placeholder: 'timestamp_column (optional)',
defined: l => deepGet(l, 'extractionNamespace.type') === 'jdbc',
info: (
<>
<p>
The column in table which contains when the key was updated. This will become the Value in
the SQL query:
</p>
<p>
SELECT keyColumn, valueColumn, <strong>tsColumn</strong>? FROM namespace.table WHERE
filter
</p>
</>
),
},
{ {
name: 'extractionNamespace.filter', name: 'extractionNamespace.filter',
type: 'string', type: 'string',
placeholder: '(optional)', placeholder: 'for_lookup = 1 (optional)',
defined: l => deepGet(l, 'extractionNamespace.type') === 'jdbc', defined: l => deepGet(l, 'extractionNamespace.type') === 'jdbc',
info: ( info: (
<> <>
@ -365,24 +376,14 @@ export const LOOKUP_FIELDS: Field<LookupSpec>[] = [
</> </>
), ),
}, },
{ {
name: 'extractionNamespace.tsColumn', name: 'extractionNamespace.pollPeriod',
type: 'string', type: 'duration',
label: 'Timestamp column', defined: l => oneOf(deepGet(l, 'extractionNamespace.type'), 'uri', 'jdbc'),
placeholder: '(optional)', info: `Period between polling for updates`,
defined: l => deepGet(l, 'extractionNamespace.type') === 'jdbc', required: true,
info: ( suggestions: ['PT1M', 'PT10M', 'PT30M', 'PT1H', 'PT6H', 'P1D'],
<>
<p>
The column in table which contains when the key was updated. This will become the Value in
the SQL query:
</p>
<p>
SELECT keyColumn, valueColumn, <strong>tsColumn</strong>? FROM namespace.table WHERE
filter
</p>
</>
),
}, },
// Extra cachedNamespace things // Extra cachedNamespace things
@ -403,15 +404,54 @@ export const LOOKUP_FIELDS: Field<LookupSpec>[] = [
]; ];
export function isLookupInvalid( export function isLookupInvalid(
lookupName: string | undefined, lookupId: string | undefined,
lookupVersion: string | undefined, lookupVersion: string | undefined,
lookupTier: string | undefined, lookupTier: string | undefined,
lookupSpec: Partial<LookupSpec>, lookupSpec: Partial<LookupSpec>,
) { ) {
return ( return (
!lookupName || !lookupId || !lookupVersion || !lookupTier || !AutoForm.isValidModel(lookupSpec, LOOKUP_FIELDS)
!lookupVersion ||
!lookupTier ||
!AutoForm.isValidModel(lookupSpec, LOOKUP_FIELDS)
); );
} }
export function lookupSpecSummary(spec: LookupSpec): string {
const { map, extractionNamespace } = spec;
if (map) {
return pluralIfNeeded(Object.keys(map).length, 'key');
}
if (extractionNamespace) {
switch (extractionNamespace.type) {
case 'uri':
if (extractionNamespace.uriPrefix) {
return `URI prefix: ${extractionNamespace.uriPrefix}, Match: ${
extractionNamespace.fileRegex || '.*'
}`;
}
if (extractionNamespace.uri) {
return `URI: ${extractionNamespace.uri}`;
}
return 'Unknown extractionNamespace lookup';
case 'jdbc': {
const columns = [
`${extractionNamespace.keyColumn} AS key`,
`${extractionNamespace.valueColumn} AS value`,
];
if (extractionNamespace.tsColumn) {
columns.push(`${extractionNamespace.tsColumn} AS ts`);
}
const queryParts = ['SELECT', columns.join(', '), `FROM ${extractionNamespace.table}`];
if (extractionNamespace.filter) {
queryParts.push(`WHERE ${extractionNamespace.filter}`);
}
return `${
extractionNamespace.connectorConfig?.connectURI || 'No connectURI'
} [${queryParts.join(' ')}]`;
}
}
}
return 'Unknown lookup';
}

View File

@ -35,7 +35,7 @@ const JSON_ESCAPES: Record<string, string> = {
// The stringifier is just JSON minus the double quotes, the parser is much more forgiving // The stringifier is just JSON minus the double quotes, the parser is much more forgiving
export const JSON_STRING_FORMATTER: Formatter<string> = { export const JSON_STRING_FORMATTER: Formatter<string> = {
stringify: (str: string) => { stringify: (str: string) => {
if (typeof str !== 'string') throw new TypeError(`must be a string`); if (typeof str !== 'string') return '';
const json = JSON.stringify(str); const json = JSON.stringify(str);
return json.substr(1, json.length - 2); return json.substr(1, json.length - 2);

View File

@ -28,3 +28,4 @@ export * from './object-change';
export * from './query-cursor'; export * from './query-cursor';
export * from './query-manager'; export * from './query-manager';
export * from './query-state'; export * from './query-state';
export * from './sanitizers';

View File

@ -0,0 +1,21 @@
/*
* 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 function durationSanitizer(str: string): string {
return str.toUpperCase().replace(/[^0-9PYMDTHS.,]/g, '');
}

View File

@ -19,7 +19,7 @@ exports[`FormEditor matches snapshot 1`] = `
class="bp3-text-muted" class="bp3-text-muted"
> >
<span <span
class="bp3-popover2-target" class="info-popover bp3-popover2-target"
> >
<span <span
class="bp3-icon bp3-icon-info-sign" class="bp3-icon bp3-icon-info-sign"
@ -47,14 +47,18 @@ exports[`FormEditor matches snapshot 1`] = `
class="bp3-form-content" class="bp3-form-content"
> >
<div <div
class="bp3-input-group bp3-intent-primary formatted-input-group suggestible-input" class="formatted-input suggestible-input"
> >
<input <div
class="bp3-input" class="bp3-input-group bp3-intent-primary"
placeholder="" >
type="text" <input
value="" class="bp3-input"
/> placeholder=""
type="text"
value=""
/>
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@ -23,6 +23,8 @@ exports[`lookups view matches snapshot 1`] = `
"Lookup tier", "Lookup tier",
"Type", "Type",
"Version", "Version",
"Poll period",
"Summary",
"Actions", "Actions",
] ]
} }
@ -93,6 +95,7 @@ exports[`lookups view matches snapshot 1`] = `
"filterable": true, "filterable": true,
"id": "lookup_name", "id": "lookup_name",
"show": true, "show": true,
"width": 200,
}, },
Object { Object {
"Header": "Lookup tier", "Header": "Lookup tier",
@ -100,6 +103,7 @@ exports[`lookups view matches snapshot 1`] = `
"filterable": true, "filterable": true,
"id": "tier", "id": "tier",
"show": true, "show": true,
"width": 100,
}, },
Object { Object {
"Header": "Type", "Header": "Type",
@ -107,6 +111,7 @@ exports[`lookups view matches snapshot 1`] = `
"filterable": true, "filterable": true,
"id": "type", "id": "type",
"show": true, "show": true,
"width": 150,
}, },
Object { Object {
"Header": "Version", "Header": "Version",
@ -114,11 +119,26 @@ exports[`lookups view matches snapshot 1`] = `
"filterable": true, "filterable": true,
"id": "version", "id": "version",
"show": true, "show": true,
"width": 190,
},
Object {
"Cell": [Function],
"Header": "Poll period",
"accessor": [Function],
"id": "poolPeriod",
"show": true,
"width": 150,
},
Object {
"Header": "Summary",
"accessor": [Function],
"id": "summary",
"show": true,
}, },
Object { Object {
"Cell": [Function], "Cell": [Function],
"Header": "Actions", "Header": "Actions",
"accessor": [Function], "accessor": "id",
"filterable": false, "filterable": false,
"id": "actions", "id": "actions",
"show": true, "show": true,
@ -135,7 +155,14 @@ exports[`lookups view matches snapshot 1`] = `
defaultResized={Array []} defaultResized={Array []}
defaultSortDesc={false} defaultSortDesc={false}
defaultSortMethod={[Function]} defaultSortMethod={[Function]}
defaultSorted={Array []} defaultSorted={
Array [
Object {
"desc": false,
"id": "lookup_name",
},
]
}
expanderDefaults={ expanderDefaults={
Object { Object {
"filterable": false, "filterable": false,

View File

@ -16,7 +16,7 @@
* limitations under the License. * limitations under the License.
*/ */
import { Button, Intent } from '@blueprintjs/core'; import { Button, Icon, Intent } from '@blueprintjs/core';
import { IconNames } from '@blueprintjs/icons'; import { IconNames } from '@blueprintjs/icons';
import React from 'react'; import React from 'react';
import ReactTable from 'react-table'; import ReactTable from 'react-table';
@ -32,9 +32,10 @@ import {
} from '../../components'; } from '../../components';
import { AsyncActionDialog, LookupEditDialog } from '../../dialogs/'; import { AsyncActionDialog, LookupEditDialog } from '../../dialogs/';
import { LookupTableActionDialog } from '../../dialogs/lookup-table-action-dialog/lookup-table-action-dialog'; import { LookupTableActionDialog } from '../../dialogs/lookup-table-action-dialog/lookup-table-action-dialog';
import { LookupSpec } from '../../druid-models'; import { LookupSpec, lookupSpecSummary } from '../../druid-models';
import { Api, AppToaster } from '../../singletons'; import { Api, AppToaster } from '../../singletons';
import { import {
deepGet,
getDruidErrorMessage, getDruidErrorMessage,
isLookupsUninitialized, isLookupsUninitialized,
LocalStorageKeys, LocalStorageKeys,
@ -51,6 +52,8 @@ const tableColumns: string[] = [
'Lookup tier', 'Lookup tier',
'Type', 'Type',
'Version', 'Version',
'Poll period',
'Summary',
ACTION_COLUMN_LABEL, ACTION_COLUMN_LABEL,
]; ];
@ -61,12 +64,19 @@ function tierNameCompare(a: string, b: string) {
} }
export interface LookupEntriesAndTiers { export interface LookupEntriesAndTiers {
lookupEntries: any[]; lookupEntries: LookupEntry[];
tiers: string[]; tiers: string[];
} }
export interface LookupEntry {
id: string;
tier: string;
version: string;
spec: LookupSpec;
}
export interface LookupEditInfo { export interface LookupEditInfo {
name: string; id: string;
tier: string; tier: string;
version: string; version: string;
spec: Partial<LookupSpec>; spec: Partial<LookupSpec>;
@ -114,9 +124,10 @@ export class LookupsView extends React.PureComponent<LookupsViewProps, LookupsVi
? tiersResp.data.sort(tierNameCompare) ? tiersResp.data.sort(tierNameCompare)
: [DEFAULT_LOOKUP_TIER]; : [DEFAULT_LOOKUP_TIER];
const lookupEntries: Record<string, string>[] = [];
const lookupResp = await Api.instance.get('/druid/coordinator/v1/lookups/config/all'); const lookupResp = await Api.instance.get('/druid/coordinator/v1/lookups/config/all');
const lookupData = lookupResp.data; const lookupData = lookupResp.data;
const lookupEntries: LookupEntry[] = [];
Object.keys(lookupData).map((tier: string) => { Object.keys(lookupData).map((tier: string) => {
const lookupIds = lookupData[tier]; const lookupIds = lookupData[tier];
Object.keys(lookupIds).map((id: string) => { Object.keys(lookupIds).map((id: string) => {
@ -178,7 +189,7 @@ export class LookupsView extends React.PureComponent<LookupsViewProps, LookupsVi
return { return {
isEdit: false, isEdit: false,
lookupEdit: { lookupEdit: {
name: '', id: '',
tier: loadingEntriesAndTiers ? loadingEntriesAndTiers.tiers[0] : '', tier: loadingEntriesAndTiers ? loadingEntriesAndTiers.tiers[0] : '',
spec: { type: 'map', map: {} }, spec: { type: 'map', map: {} },
version: new Date().toISOString(), version: new Date().toISOString(),
@ -189,8 +200,8 @@ export class LookupsView extends React.PureComponent<LookupsViewProps, LookupsVi
this.setState({ this.setState({
isEdit: true, isEdit: true,
lookupEdit: { lookupEdit: {
name: id, id,
tier: tier, tier,
spec: target.spec, spec: target.spec,
version: target.version, version: target.version,
}, },
@ -216,7 +227,7 @@ export class LookupsView extends React.PureComponent<LookupsViewProps, LookupsVi
const specJson: any = lookupEdit.spec; const specJson: any = lookupEdit.spec;
let dataJson: any; let dataJson: any;
if (isEdit) { if (isEdit) {
endpoint = `${endpoint}/${lookupEdit.tier}/${lookupEdit.name}`; endpoint = `${endpoint}/${lookupEdit.tier}/${lookupEdit.id}`;
dataJson = { dataJson = {
version: version, version: version,
lookupExtractorFactory: specJson, lookupExtractorFactory: specJson,
@ -224,7 +235,7 @@ export class LookupsView extends React.PureComponent<LookupsViewProps, LookupsVi
} else { } else {
dataJson = { dataJson = {
[lookupEdit.tier]: { [lookupEdit.tier]: {
[lookupEdit.name]: { [lookupEdit.id]: {
version: version, version: version,
lookupExtractorFactory: specJson, lookupExtractorFactory: specJson,
}, },
@ -319,6 +330,7 @@ export class LookupsView extends React.PureComponent<LookupsViewProps, LookupsVi
: lookupEntriesAndTiersState.getErrorMessage() || '' : lookupEntriesAndTiersState.getErrorMessage() || ''
} }
filterable filterable
defaultSorted={[{ id: 'lookup_name', desc: false }]}
columns={[ columns={[
{ {
Header: 'Lookup name', Header: 'Lookup name',
@ -326,6 +338,7 @@ export class LookupsView extends React.PureComponent<LookupsViewProps, LookupsVi
id: 'lookup_name', id: 'lookup_name',
accessor: 'id', accessor: 'id',
filterable: true, filterable: true,
width: 200,
}, },
{ {
Header: 'Lookup tier', Header: 'Lookup tier',
@ -333,6 +346,7 @@ export class LookupsView extends React.PureComponent<LookupsViewProps, LookupsVi
id: 'tier', id: 'tier',
accessor: 'tier', accessor: 'tier',
filterable: true, filterable: true,
width: 100,
}, },
{ {
Header: 'Type', Header: 'Type',
@ -340,6 +354,7 @@ export class LookupsView extends React.PureComponent<LookupsViewProps, LookupsVi
id: 'type', id: 'type',
accessor: 'spec.type', accessor: 'spec.type',
filterable: true, filterable: true,
width: 150,
}, },
{ {
Header: 'Version', Header: 'Version',
@ -347,17 +362,44 @@ export class LookupsView extends React.PureComponent<LookupsViewProps, LookupsVi
id: 'version', id: 'version',
accessor: 'version', accessor: 'version',
filterable: true, filterable: true,
width: 190,
},
{
Header: 'Poll period',
show: hiddenColumns.exists('Poll period'),
id: 'poolPeriod',
width: 150,
accessor: row => deepGet(row, 'spec.extractionNamespace.pollPeriod'),
Cell: ({ original }) => {
if (original.spec.type === 'map') return 'Static map';
const pollPeriod = deepGet(original, 'spec.extractionNamespace.pollPeriod');
if (!pollPeriod) {
return (
<>
<Icon icon={IconNames.WARNING_SIGN} intent={Intent.WARNING} /> No poll period
set
</>
);
}
return pollPeriod;
},
},
{
Header: 'Summary',
show: hiddenColumns.exists('Summary'),
id: 'summary',
accessor: row => lookupSpecSummary(row.spec),
}, },
{ {
Header: ACTION_COLUMN_LABEL, Header: ACTION_COLUMN_LABEL,
show: hiddenColumns.exists(ACTION_COLUMN_LABEL), show: hiddenColumns.exists(ACTION_COLUMN_LABEL),
id: ACTION_COLUMN_ID, id: ACTION_COLUMN_ID,
width: ACTION_COLUMN_WIDTH, width: ACTION_COLUMN_WIDTH,
accessor: (row: any) => ({ id: row.id, tier: row.tier }),
filterable: false, filterable: false,
Cell: (row: any) => { accessor: 'id',
const lookupId = row.value.id; Cell: ({ original }) => {
const lookupTier = row.value.tier; const lookupId = original.id;
const lookupTier = original.tier;
const lookupActions = this.getLookupActions(lookupTier, lookupId); const lookupActions = this.getLookupActions(lookupTier, lookupId);
return ( return (
<ActionCell <ActionCell
@ -391,10 +433,10 @@ export class LookupsView extends React.PureComponent<LookupsViewProps, LookupsVi
onClose={() => this.setState({ lookupEdit: undefined })} onClose={() => this.setState({ lookupEdit: undefined })}
onSubmit={updateLookupVersion => this.submitLookupEdit(updateLookupVersion)} onSubmit={updateLookupVersion => this.submitLookupEdit(updateLookupVersion)}
onChange={this.handleChangeLookup} onChange={this.handleChangeLookup}
lookupSpec={lookupEdit.spec} lookupId={lookupEdit.id}
lookupName={lookupEdit.name}
lookupTier={lookupEdit.tier} lookupTier={lookupEdit.tier}
lookupVersion={lookupEdit.version} lookupVersion={lookupEdit.version}
lookupSpec={lookupEdit.spec}
isEdit={isEdit} isEdit={isEdit}
allLookupTiers={allLookupTiers} allLookupTiers={allLookupTiers}
/> />