Web console: Better data loader flow (#8763)

* filter table

* go over the entire data loader flow
This commit is contained in:
Vadim Ogievetsky 2019-10-28 08:08:46 -07:00 committed by Fangjin Yang
parent b65d2ac648
commit ec8ce74f1c
14 changed files with 415 additions and 244 deletions

View File

@ -79,8 +79,6 @@ writeFile(
* limitations under the License.
*/
import { IconNames } from '@blueprintjs/icons';
import classNames from 'classnames';
import React from 'react';
import './${name}.scss';
@ -88,22 +86,13 @@ import './${name}.scss';
export interface ${camelName}Props {
}
export interface ${camelName}State {
}
export class ${camelName} extends React.PureComponent<${camelName}Props, ${camelName}State> {
constructor(props: ${camelName}Props, context: any) {
super(props, context);
// this.state = {};
}
render(): JSX.Element {
return <div className="${name}">
export const ${camelName} = React.memo(function ${camelName}(props: ${camelName}Props) {
return (
<div className="${name}">
Stuff...
</div>;
}
}
</div>
);
});
`,
);

View File

@ -17,7 +17,7 @@
*/
import { Intent, TextArea } from '@blueprintjs/core';
import React from 'react';
import React, { useState } from 'react';
import { compact } from '../../utils';
@ -31,40 +31,33 @@ export interface ArrayInputProps {
intent?: Intent;
}
export class ArrayInput extends React.PureComponent<ArrayInputProps, { stringValue: string }> {
constructor(props: ArrayInputProps) {
super(props);
this.state = {
stringValue: Array.isArray(props.values) ? props.values.join(', ') : '',
};
}
export const ArrayInput = React.memo(function ArrayInput(props: ArrayInputProps) {
const { className, placeholder, large, disabled, intent } = props;
const [stringValue, setStringValue] = useState();
private handleChange = (e: any) => {
const { onChange } = this.props;
const handleChange = (e: any) => {
const { onChange } = props;
const stringValue = e.target.value;
const newValues: string[] = stringValue.split(',').map((v: string) => v.trim());
const newValues: string[] = stringValue.split(/[,\s]+/).map((v: string) => v.trim());
const newValuesFiltered = compact(newValues);
this.setState({
stringValue:
newValues.length === newValuesFiltered.length ? newValues.join(', ') : stringValue,
});
if (onChange) onChange(stringValue === '' ? undefined : newValuesFiltered);
if (newValues.length === newValuesFiltered.length) {
onChange(stringValue === '' ? undefined : newValuesFiltered);
setStringValue(undefined);
} else {
setStringValue(stringValue);
}
};
render(): JSX.Element {
const { className, placeholder, large, disabled, intent } = this.props;
const { stringValue } = this.state;
return (
<TextArea
className={className}
value={stringValue}
onChange={this.handleChange}
placeholder={placeholder}
large={large}
disabled={disabled}
intent={intent}
fill
/>
);
}
}
return (
<TextArea
className={className}
value={stringValue || props.values.join(', ')}
onChange={handleChange}
placeholder={placeholder}
large={large}
disabled={disabled}
intent={intent}
fill
/>
);
});

View File

@ -81,7 +81,7 @@ function ingestionTypeToIoAndTuningConfigType(ingestionType: IngestionType): str
}
}
export function getIngestionComboType(spec: IngestionSpec): IngestionComboType | null {
export function getIngestionComboType(spec: IngestionSpec): IngestionComboType | undefined {
const ioConfig = deepGet(spec, 'ioConfig') || EMPTY_OBJECT;
switch (ioConfig.type) {
@ -103,7 +103,7 @@ export function getIngestionComboType(spec: IngestionSpec): IngestionComboType |
}
}
return null;
return;
}
export function getIngestionTitle(ingestionType: IngestionComboTypeWithExtra): string {
@ -152,6 +152,21 @@ export function getIngestionImage(ingestionType: IngestionComboTypeWithExtra): s
return ingestionType;
}
export function getIngestionDocLink(spec: IngestionSpec): string {
const type = getSpecType(spec);
switch (type) {
case 'kafka':
return 'https://druid.apache.org/docs/latest/development/extensions-core/kafka-ingestion.html';
case 'kinesis':
return 'https://druid.apache.org/docs/latest/development/extensions-core/kinesis-ingestion.html';
default:
return 'https://druid.apache.org/docs/latest/ingestion/native-batch.html#firehoses';
}
}
export function getRequiredModule(ingestionType: IngestionComboTypeWithExtra): string | undefined {
switch (ingestionType) {
case 'index:static-s3':
@ -326,6 +341,8 @@ const PARSE_SPEC_FORM_FIELDS: Field<ParseSpec>[] = [
{
name: 'columns',
type: 'string-array',
required: (p: ParseSpec) =>
((p.format === 'csv' || p.format === 'tsv') && !p.hasHeaderRow) || p.format === 'regex',
defined: (p: ParseSpec) =>
((p.format === 'csv' || p.format === 'tsv') && !p.hasHeaderRow) || p.format === 'regex',
},

View File

@ -16,7 +16,7 @@
* limitations under the License.
*/
import { deepDelete, deepGet, deepSet, makePath, parsePath } from './object-change';
import { deepDelete, deepExtend, deepGet, deepSet, makePath, parsePath } from './object-change';
describe('object-change', () => {
describe('parsePath', () => {
@ -149,4 +149,43 @@ describe('object-change', () => {
});
});
});
describe('deepExtend', () => {
it('works', () => {
const obj1 = {
money: 1,
bag: 2,
nice: {
a: 1,
b: [],
c: { an: 123, ice: 321, bag: 1 },
},
swag: {
diamond: ['collar'],
},
pockets: { ice: 3 },
f: ['bag'],
};
const obj2 = {
bag: 3,
nice: null,
pockets: { need: 1, an: 2 },
swag: {
diamond: ['collar', 'molar'],
},
};
expect(deepExtend(obj1, obj2)).toEqual({
money: 1,
bag: 3,
nice: null,
swag: {
diamond: ['collar', 'molar'],
},
pockets: { need: 1, an: 2, ice: 3 },
f: ['bag'],
});
});
});
});

View File

@ -20,10 +20,14 @@ export function shallowCopy(v: any): any {
return Array.isArray(v) ? v.slice() : Object.assign({}, v);
}
function isEmpty(v: any): boolean {
export function isEmpty(v: any): boolean {
return !(Array.isArray(v) ? v.length : Object.keys(v).length);
}
function isObjectOrArray(v: any): boolean {
return Boolean(v && typeof v === 'object');
}
export function parsePath(path: string): string[] {
const parts: string[] = [];
let rest = path;
@ -107,6 +111,28 @@ export function deepDelete<T extends Record<string, any>>(value: T, path: string
return valueCopy;
}
export function deepExtend<T extends Record<string, any>>(target: T, diff: Record<string, any>): T {
if (typeof target !== 'object') throw new TypeError(`Invalid target`);
if (typeof diff !== 'object') throw new TypeError(`Invalid diff`);
const newValue = shallowCopy(target);
for (const key in diff) {
const targetValue = target[key];
const diffValue = diff[key];
if (typeof diffValue === 'undefined') {
delete newValue[key];
} else {
if (isObjectOrArray(targetValue) && isObjectOrArray(diffValue)) {
newValue[key] = deepExtend(targetValue, diffValue);
} else {
newValue[key] = diffValue;
}
}
}
return newValue;
}
export function whitelistKeys(obj: Record<string, any>, whitelist: string[]): Record<string, any> {
const newObj: Record<string, any> = {};
for (const w of whitelist) {

View File

@ -38,7 +38,7 @@ describe('filter table', () => {
sampleData={sampleData}
columnFilter=""
dimensionFilters={[]}
selectedFilterIndex={-1}
selectedFilterName={undefined}
onShowGlobalFilter={() => {}}
onFilterSelect={() => {}}
/>

View File

@ -27,11 +27,21 @@ import { HeaderAndRows, SampleEntry } from '../../../utils/sampler';
import './filter-table.scss';
export function filterTableSelectedColumnName(
sampleData: HeaderAndRows,
selectedFilter: DruidFilter | undefined,
): string | undefined {
if (!selectedFilter) return;
const selectedFilterName = selectedFilter.dimension;
if (!sampleData.header.includes(selectedFilterName)) return;
return selectedFilterName;
}
export interface FilterTableProps {
sampleData: HeaderAndRows;
columnFilter: string;
dimensionFilters: DruidFilter[];
selectedFilterIndex: number;
selectedFilterName: string | undefined;
onShowGlobalFilter: () => void;
onFilterSelect: (filter: DruidFilter, index: number) => void;
}
@ -41,7 +51,7 @@ export const FilterTable = React.memo(function FilterTable(props: FilterTablePro
sampleData,
columnFilter,
dimensionFilters,
selectedFilterIndex,
selectedFilterName,
onShowGlobalFilter,
onFilterSelect,
} = props;
@ -58,7 +68,7 @@ export const FilterTable = React.memo(function FilterTable(props: FilterTablePro
const columnClassName = classNames({
filtered: filter,
selected: filter && filterIndex === selectedFilterIndex,
selected: columnName === selectedFilterName,
});
return {
Header: (

View File

@ -0,0 +1,15 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`learn more matches snapshot 1`] = `
<p
class="learn-more"
>
<a
href="https://druid.apache.org/docs/latest/development/extensions-core/kinesis-ingestion.html"
rel="noopener noreferrer"
target="_blank"
>
Learn more
</a>
</p>
`;

View File

@ -0,0 +1,33 @@
/*
* 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 { LearnMore } from './learn-more';
describe('learn more', () => {
it('matches snapshot', () => {
const learnMore = (
<LearnMore href="https://druid.apache.org/docs/latest/development/extensions-core/kinesis-ingestion.html" />
);
const { container } = render(learnMore);
expect(container.firstChild).toMatchSnapshot();
});
});

View File

@ -0,0 +1,35 @@
/*
* 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 React from 'react';
import { ExternalLink } from '../../../components';
export interface LearnMoreProps {
href: string;
}
export const LearnMore = React.memo(function LearnMore(props: LearnMoreProps) {
const { href } = props;
return (
<p className="learn-more">
<ExternalLink href={href}>Learn more</ExternalLink>
</p>
);
});

View File

@ -206,12 +206,20 @@
&.timestamp {
background: rgba(19, 129, 201, 0.5);
}
&.used {
background: rgba(24, 201, 201, 0.5);
}
}
.rt-td {
&.timestamp {
background: rgba(19, 129, 201, 0.15);
}
&.used {
background: rgba(24, 201, 201, 0.15);
}
}
}
}
@ -235,6 +243,12 @@
}
}
.apply-button-bar {
.revert {
margin-left: 15px;
}
}
.next-bar {
grid-area: next;
text-align: right;
@ -255,7 +269,7 @@
border-radius: 2px;
margin-bottom: 15px;
.controls-buttons {
.control-buttons {
position: relative;
.bp3-button {
@ -265,6 +279,7 @@
.cancel {
position: absolute;
right: 0;
margin-right: 0;
}
}
}

View File

@ -80,6 +80,7 @@ import {
getFilterFormFields,
getFlattenFieldFormFields,
getIngestionComboType,
getIngestionDocLink,
getIngestionImage,
getIngestionTitle,
getIoConfigFormFields,
@ -141,9 +142,13 @@ import {
import { computeFlattenPathsForData } from '../../utils/spec-utils';
import { ExamplePicker } from './example-picker/example-picker';
import { FilterTable } from './filter-table/filter-table';
import { FilterTable, filterTableSelectedColumnName } from './filter-table/filter-table';
import { LearnMore } from './learn-more/learn-more';
import { ParseDataTable } from './parse-data-table/parse-data-table';
import { ParseTimeTable } from './parse-time-table/parse-time-table';
import {
ParseTimeTable,
parseTimeTableSelectedColumnName,
} from './parse-time-table/parse-time-table';
import { SchemaTable } from './schema-table/schema-table';
import {
TransformTable,
@ -250,6 +255,7 @@ export interface LoadDataViewProps {
export interface LoadDataViewState {
step: Step;
spec: IngestionSpec;
specPreview: IngestionSpec;
cacheKey?: string;
// dialogs / modals
continueToSpec: boolean;
@ -318,6 +324,7 @@ export class LoadDataView extends React.PureComponent<LoadDataViewProps, LoadDat
this.state = {
step: 'welcome',
spec,
specPreview: spec,
// dialogs / modals
showResetConfirm: false,
@ -422,41 +429,70 @@ export class LoadDataView extends React.PureComponent<LoadDataViewProps, LoadDat
}
private updateStep = (newStep: Step) => {
this.doQueryForStep(newStep);
this.setState({ step: newStep });
this.setState(state => ({ step: newStep, specPreview: state.spec }));
};
doQueryForStep(step: Step): any {
private updateSpec = (newSpec: IngestionSpec) => {
newSpec = normalizeSpec(newSpec);
this.setState({ spec: newSpec, specPreview: newSpec });
localStorageSet(LocalStorageKeys.INGESTION_SPEC, JSON.stringify(newSpec));
};
private updateSpecPreview = (newSpecPreview: IngestionSpec) => {
this.setState({ specPreview: newSpecPreview });
};
private applyPreviewSpec = () => {
this.setState(state => {
localStorageSet(LocalStorageKeys.INGESTION_SPEC, JSON.stringify(state.specPreview));
return { spec: state.specPreview };
});
};
private revertPreviewSpec = () => {
this.setState(state => ({ specPreview: state.spec }));
};
isPreviewSpecSame() {
const { spec, specPreview } = this.state;
return spec === specPreview;
}
componentDidUpdate(_prevProps: LoadDataViewProps, prevState: LoadDataViewState) {
const { spec, step } = this.state;
const { spec: prevSpec, step: prevStep } = prevState;
if (spec !== prevSpec || step !== prevStep) {
this.doQueryForStep(step !== prevStep);
}
}
doQueryForStep(initRun: boolean): any {
const { step } = this.state;
switch (step) {
case 'welcome':
return this.queryForWelcome();
case 'connect':
return this.queryForConnect(true);
return this.queryForConnect(initRun);
case 'parser':
return this.queryForParser(true);
return this.queryForParser(initRun);
case 'timestamp':
return this.queryForTimestamp(true);
return this.queryForTimestamp(initRun);
case 'transform':
return this.queryForTransform(true);
return this.queryForTransform(initRun);
case 'filter':
return this.queryForFilter(true);
return this.queryForFilter(initRun);
case 'schema':
return this.queryForSchema(true);
return this.queryForSchema(initRun);
}
}
private updateSpec = (newSpec: IngestionSpec) => {
newSpec = normalizeSpec(newSpec);
this.setState({ spec: newSpec });
localStorageSet(LocalStorageKeys.INGESTION_SPEC, JSON.stringify(newSpec));
};
renderActionCard(icon: IconName, title: string, caption: string, onClick: () => void) {
return (
<Card className={'spec-card'} interactive onClick={onClick}>
@ -515,6 +551,29 @@ export class LoadDataView extends React.PureComponent<LoadDataViewProps, LoadDat
);
}
renderApplyButtonBar() {
const previewSpecSame = this.isPreviewSpecSame();
return (
<FormGroup className="apply-button-bar">
<Button
text="Apply"
disabled={previewSpecSame}
intent={Intent.PRIMARY}
onClick={this.applyPreviewSpec}
/>
{!previewSpecSame && (
<Button
className="revert"
icon={IconNames.UNDO}
disabled={this.isPreviewSpecSame()}
onClick={this.revertPreviewSpec}
/>
)}
</FormGroup>
);
}
renderStepNav() {
const { step } = this.state;
@ -567,9 +626,7 @@ export class LoadDataView extends React.PureComponent<LoadDataViewProps, LoadDat
if (disabled) return;
if (onNextStep) onNextStep();
setTimeout(() => {
this.updateStep(nextStep);
}, 10);
this.updateStep(nextStep);
}}
/>
</div>
@ -787,9 +844,7 @@ export class LoadDataView extends React.PureComponent<LoadDataViewProps, LoadDat
intent={Intent.PRIMARY}
onClick={() => {
this.updateSpec(updateIngestionType(spec, selectedComboType as any));
setTimeout(() => {
this.updateStep('connect');
}, 10);
this.updateStep('connect');
}}
/>
</FormGroup>
@ -814,9 +869,7 @@ export class LoadDataView extends React.PureComponent<LoadDataViewProps, LoadDat
exampleManifests={exampleManifests}
onSelectExample={exampleManifest => {
this.updateSpec(exampleManifest.spec);
setTimeout(() => {
this.updateStep('connect');
}, 10);
this.updateStep('connect');
}}
/>
);
@ -949,10 +1002,9 @@ export class LoadDataView extends React.PureComponent<LoadDataViewProps, LoadDat
}
renderConnectStep() {
const { spec, inputQueryState, sampleStrategy } = this.state;
const { specPreview: spec, inputQueryState, sampleStrategy } = this.state;
const specType = getSpecType(spec);
const ioConfig: IoConfig = deepGet(spec, 'ioConfig') || EMPTY_OBJECT;
const isBlank = !ioConfig.type;
const inlineMode = deepGet(spec, 'ioConfig.firehose.type') === 'inline';
let mainFill: JSX.Element | string = '';
@ -964,7 +1016,7 @@ export class LoadDataView extends React.PureComponent<LoadDataViewProps, LoadDat
value={deepGet(spec, 'ioConfig.firehose.data')}
onChange={(e: any) => {
const stringValue = e.target.value.substr(0, MAX_INLINE_DATA_LENGTH);
this.updateSpec(deepSet(spec, 'ioConfig.firehose.data', stringValue));
this.updateSpecPreview(deepSet(spec, 'ioConfig.firehose.data', stringValue));
}}
/>
);
@ -1006,36 +1058,31 @@ export class LoadDataView extends React.PureComponent<LoadDataViewProps, LoadDat
<p>
Druid ingests raw data and converts it into a custom,{' '}
<ExternalLink href="https://druid.apache.org/docs/latest/design/segments.html">
indexed
indexed format
</ExternalLink>{' '}
format that is optimized for analytic queries.
that is optimized for analytic queries.
</p>
{inlineMode ? (
<>
<p>To get started, please paste some data in the box to the left.</p>
<p>Click "Register" to verify your data with Druid.</p>
<p>Click "Apply" to verify your data with Druid.</p>
</>
) : (
<>
<p>
To get started, please specify where your raw data is stored and what data you
want to ingest.
</p>
<p>Click "Preview" to look at the sampled raw data.</p>
</>
<p>To get started, please specify what data you want to ingest.</p>
)}
<LearnMore href={getIngestionDocLink(spec)} />
</Callout>
{ingestionComboType ? (
<AutoForm
fields={getIoConfigFormFields(ingestionComboType)}
model={ioConfig}
onChange={c => this.updateSpec(deepSet(spec, 'ioConfig', c))}
onChange={c => this.updateSpecPreview(deepSet(spec, 'ioConfig', c))}
/>
) : (
<FormGroup label="IO Config">
<JsonInput
value={ioConfig}
onChange={c => this.updateSpec(deepSet(spec, 'ioConfig', c))}
onChange={c => this.updateSpecPreview(deepSet(spec, 'ioConfig', c))}
height="300px"
/>
</FormGroup>
@ -1058,12 +1105,7 @@ export class LoadDataView extends React.PureComponent<LoadDataViewProps, LoadDat
</HTMLSelect>
</FormGroup>
)}
<Button
text={inlineMode ? 'Register data' : 'Preview'}
disabled={isBlank}
intent={inputQueryState.data ? undefined : Intent.PRIMARY}
onClick={() => this.queryForConnect()}
/>
{this.renderApplyButtonBar()}
</div>
{this.renderNextBar({
disabled: !inputQueryState.data,
@ -1176,7 +1218,7 @@ export class LoadDataView extends React.PureComponent<LoadDataViewProps, LoadDat
renderParserStep() {
const {
spec,
specPreview: spec,
columnFilter,
specialColumnsOnly,
parserQueryState,
@ -1186,7 +1228,6 @@ export class LoadDataView extends React.PureComponent<LoadDataViewProps, LoadDat
const flattenFields: FlattenField[] =
deepGet(spec, 'dataSchema.parser.parseSpec.flattenSpec.fields') || EMPTY_ARRAY;
const isBlank = !parseSpec.format;
const canFlatten = parseSpec.format === 'json';
let mainFill: JSX.Element | string = '';
@ -1251,23 +1292,28 @@ export class LoadDataView extends React.PureComponent<LoadDataViewProps, LoadDat
{canFlatten && (
<p>
If you have nested data, you can{' '}
<ExternalLink href="https://druid.apache.org/docs/latest/ingestion/flatten-json.html">
<ExternalLink href="https://druid.apache.org/docs/latest/ingestion/index.html#flattenspec">
flatten
</ExternalLink>{' '}
it here. If the provided flattening capabilities are not sufficient, please
pre-process your data before ingesting it into Druid.
</p>
)}
<p>
Click "Preview" to ensure that your data appears correctly in a row/column
orientation.
</p>
<p>Ensure that your data appears correctly in a row/column orientation.</p>
<LearnMore href="https://druid.apache.org/docs/latest/ingestion/data-formats.html" />
</Callout>
<AutoForm
fields={getParseSpecFormFields()}
model={parseSpec}
onChange={p => this.updateSpec(deepSet(spec, 'dataSchema.parser.parseSpec', p))}
/>
{!selectedFlattenField && (
<>
<AutoForm
fields={getParseSpecFormFields()}
model={parseSpec}
onChange={p =>
this.updateSpecPreview(deepSet(spec, 'dataSchema.parser.parseSpec', p))
}
/>
{this.renderApplyButtonBar()}
</>
)}
{this.renderFlattenControls()}
{Boolean(sugestedFlattenFields && sugestedFlattenFields.length) && (
<FormGroup>
@ -1282,16 +1328,10 @@ export class LoadDataView extends React.PureComponent<LoadDataViewProps, LoadDat
sugestedFlattenFields,
),
);
setTimeout(() => {
this.queryForParser();
}, 10);
}}
/>
</FormGroup>
)}
{!selectedFlattenField && (
<Button text="Preview" disabled={isBlank} onClick={() => this.queryForParser()} />
)}
</div>
{this.renderNextBar({
disabled: !parserQueryState.data,
@ -1340,13 +1380,6 @@ export class LoadDataView extends React.PureComponent<LoadDataViewProps, LoadDat
});
};
const closeAndQuery = () => {
close();
setTimeout(() => {
this.queryForParser();
}, 10);
};
if (selectedFlattenField) {
return (
<div className="edit-controls">
@ -1355,7 +1388,7 @@ export class LoadDataView extends React.PureComponent<LoadDataViewProps, LoadDat
model={selectedFlattenField}
onChange={f => this.setState({ selectedFlattenField: f })}
/>
<div className="controls-buttons">
<div className="control-buttons">
<Button
className="add-update"
text={selectedFlattenFieldIndex === -1 ? 'Add' : 'Update'}
@ -1368,7 +1401,7 @@ export class LoadDataView extends React.PureComponent<LoadDataViewProps, LoadDat
selectedFlattenField,
),
);
closeAndQuery();
close();
}}
/>
{selectedFlattenFieldIndex !== -1 && (
@ -1382,7 +1415,7 @@ export class LoadDataView extends React.PureComponent<LoadDataViewProps, LoadDat
`dataSchema.parser.parseSpec.flattenSpec.fields.${selectedFlattenFieldIndex}`,
),
);
closeAndQuery();
close();
}}
/>
)}
@ -1467,14 +1500,11 @@ export class LoadDataView extends React.PureComponent<LoadDataViewProps, LoadDat
}
renderTimestampStep() {
const { spec, columnFilter, specialColumnsOnly, timestampQueryState } = this.state;
const parseSpec: ParseSpec = deepGet(spec, 'dataSchema.parser.parseSpec') || EMPTY_OBJECT;
const { specPreview: spec, columnFilter, specialColumnsOnly, timestampQueryState } = this.state;
const timestampSpec: TimestampSpec =
deepGet(spec, 'dataSchema.parser.parseSpec.timestampSpec') || EMPTY_OBJECT;
const timestampSpecFromColumn = isColumnTimestampSpec(timestampSpec);
const isBlank = !parseSpec.format;
let mainFill: JSX.Element | string = '';
if (timestampQueryState.isInit()) {
mainFill = (
@ -1506,6 +1536,10 @@ export class LoadDataView extends React.PureComponent<LoadDataViewProps, LoadDat
sampleBundle={timestampQueryState.data}
columnFilter={columnFilter}
possibleTimestampColumnsOnly={specialColumnsOnly}
selectedColumnName={parseTimeTableSelectedColumnName(
timestampQueryState.data.headerAndRows,
timestampSpec,
)}
onTimestampColumnSelect={this.onTimestampColumnSelect}
/>
</div>
@ -1520,10 +1554,10 @@ export class LoadDataView extends React.PureComponent<LoadDataViewProps, LoadDat
<p>
Druid partitions data based on the primary time column of your data. This column is
stored internally in Druid as <Code>__time</Code>. Please specify the primary time
column. If you do not have any time columns, you can choose "Constant Value" to create
column. If you do not have any time columns, you can choose "Constant value" to create
a default one.
</p>
<p>Click "Preview" to check if Druid can properly parse your time values.</p>
<LearnMore href="https://druid.apache.org/docs/latest/ingestion/index.html#timestampspec" />
</Callout>
<FormGroup label="Timestamp spec">
<ButtonGroup>
@ -1535,28 +1569,22 @@ export class LoadDataView extends React.PureComponent<LoadDataViewProps, LoadDat
column: 'timestamp',
format: 'auto',
};
this.updateSpec(
this.updateSpecPreview(
deepSet(spec, 'dataSchema.parser.parseSpec.timestampSpec', timestampSpec),
);
setTimeout(() => {
this.queryForTimestamp();
}, 10);
}}
/>
<Button
text="Constant value"
active={!timestampSpecFromColumn}
onClick={() => {
this.updateSpec(
this.updateSpecPreview(
deepSet(
spec,
'dataSchema.parser.parseSpec.timestampSpec',
getEmptyTimestampSpec(),
),
);
setTimeout(() => {
this.queryForTimestamp();
}, 10);
}}
/>
</ButtonGroup>
@ -1565,12 +1593,12 @@ export class LoadDataView extends React.PureComponent<LoadDataViewProps, LoadDat
fields={getTimestampSpecFormFields(timestampSpec)}
model={timestampSpec}
onChange={timestampSpec => {
this.updateSpec(
this.updateSpecPreview(
deepSet(spec, 'dataSchema.parser.parseSpec.timestampSpec', timestampSpec),
);
}}
/>
<Button text="Preview" disabled={isBlank} onClick={() => this.queryForTimestamp()} />
{this.renderApplyButtonBar()}
</div>
{this.renderNextBar({
disabled: !timestampQueryState.data,
@ -1580,8 +1608,10 @@ export class LoadDataView extends React.PureComponent<LoadDataViewProps, LoadDat
}
private onTimestampColumnSelect = (newTimestampSpec: TimestampSpec) => {
const { spec } = this.state;
this.updateSpec(deepSet(spec, 'dataSchema.parser.parseSpec.timestampSpec', newTimestampSpec));
const { specPreview } = this.state;
this.updateSpecPreview(
deepSet(specPreview, 'dataSchema.parser.parseSpec.timestampSpec', newTimestampSpec),
);
};
// ==================================================================
@ -1689,13 +1719,13 @@ export class LoadDataView extends React.PureComponent<LoadDataViewProps, LoadDat
<Callout className="intro">
<p className="optional">Optional</p>
<p>
Druid can perform simple{' '}
Druid can perform per-row{' '}
<ExternalLink href="https://druid.apache.org/docs/latest/ingestion/transform-spec.html#transforms">
transforms
</ExternalLink>{' '}
of column values.
of column values allowing you to create new derived columns or alter existing column.
</p>
<p>Click "Preview" to see the result of any specified transforms.</p>
<LearnMore href="https://druid.apache.org/docs/latest/ingestion/index.html#transforms" />
</Callout>
{Boolean(transformQueryState.error && transforms.length) && (
<FormGroup>
@ -1713,7 +1743,6 @@ export class LoadDataView extends React.PureComponent<LoadDataViewProps, LoadDat
</FormGroup>
)}
{this.renderTransformControls()}
<Button text="Preview" onClick={() => this.queryForTransform()} />
</div>
{this.renderNextBar({
disabled: !transformQueryState.data,
@ -1747,13 +1776,6 @@ export class LoadDataView extends React.PureComponent<LoadDataViewProps, LoadDat
});
};
const closeAndQuery = () => {
close();
setTimeout(() => {
this.queryForTransform();
}, 10);
};
if (selectedTransform) {
return (
<div className="edit-controls">
@ -1762,7 +1784,7 @@ export class LoadDataView extends React.PureComponent<LoadDataViewProps, LoadDat
model={selectedTransform}
onChange={selectedTransform => this.setState({ selectedTransform })}
/>
<div className="controls-buttons">
<div className="control-buttons">
<Button
className="add-update"
text={selectedTransformIndex === -1 ? 'Add' : 'Update'}
@ -1775,7 +1797,7 @@ export class LoadDataView extends React.PureComponent<LoadDataViewProps, LoadDat
selectedTransform,
),
);
closeAndQuery();
close();
}}
/>
{selectedTransformIndex !== -1 && (
@ -1789,7 +1811,7 @@ export class LoadDataView extends React.PureComponent<LoadDataViewProps, LoadDat
`dataSchema.transformSpec.transforms.${selectedTransformIndex}`,
),
);
closeAndQuery();
close();
}}
/>
)}
@ -1898,19 +1920,9 @@ export class LoadDataView extends React.PureComponent<LoadDataViewProps, LoadDat
});
renderFilterStep() {
const {
spec,
columnFilter,
filterQueryState,
selectedFilter,
selectedFilterIndex,
showGlobalFilter,
} = this.state;
const parseSpec: ParseSpec = deepGet(spec, 'dataSchema.parser.parseSpec') || EMPTY_OBJECT;
const { spec, columnFilter, filterQueryState, selectedFilter, showGlobalFilter } = this.state;
const dimensionFilters = this.getMemoizedDimensionFiltersFromSpec(spec);
const isBlank = !parseSpec.format;
let mainFill: JSX.Element | string = '';
if (filterQueryState.isInit()) {
mainFill = <CenterMessage>Please enter more details for the previous steps</CenterMessage>;
@ -1932,7 +1944,10 @@ export class LoadDataView extends React.PureComponent<LoadDataViewProps, LoadDat
sampleData={filterQueryState.data}
columnFilter={columnFilter}
dimensionFilters={dimensionFilters}
selectedFilterIndex={selectedFilterIndex}
selectedFilterName={filterTableSelectedColumnName(
filterQueryState.data,
selectedFilter,
)}
onShowGlobalFilter={this.onShowGlobalFilter}
onFilterSelect={this.onFilterSelect}
/>
@ -1951,15 +1966,12 @@ export class LoadDataView extends React.PureComponent<LoadDataViewProps, LoadDat
<ExternalLink href="https://druid.apache.org/docs/latest/querying/filters.html">
filter
</ExternalLink>{' '}
out unwanted data.
out unwanted data by applying per-row filters.
</p>
<p>Click "Preview" to see the impact of any specified filters.</p>
<LearnMore href="https://druid.apache.org/docs/latest/ingestion/index.html#filter" />
</Callout>
{!showGlobalFilter && this.renderColumnFilterControls()}
{!selectedFilter && this.renderGlobalFilterControls()}
{!selectedFilter && !showGlobalFilter && (
<Button text="Preview" disabled={isBlank} onClick={() => this.queryForFilter()} />
)}
</div>
{this.renderNextBar({})}
</>
@ -1987,13 +1999,6 @@ export class LoadDataView extends React.PureComponent<LoadDataViewProps, LoadDat
});
};
const closeAndQuery = () => {
close();
setTimeout(() => {
this.queryForFilter();
}, 10);
};
if (selectedFilter) {
return (
<div className="edit-controls">
@ -2003,7 +2008,7 @@ export class LoadDataView extends React.PureComponent<LoadDataViewProps, LoadDat
onChange={f => this.setState({ selectedFilter: f })}
showCustom={f => !['selector', 'in', 'regex', 'like', 'not'].includes(f.type)}
/>
<div className="controls-buttons">
<div className="control-buttons">
<Button
className="add-update"
text={selectedFilterIndex === -1 ? 'Add' : 'Update'}
@ -2014,7 +2019,7 @@ export class LoadDataView extends React.PureComponent<LoadDataViewProps, LoadDat
deepSet(curFilter, `dimensionFilters.${selectedFilterIndex}`, selectedFilter),
);
this.updateSpec(deepSet(spec, 'dataSchema.transformSpec.filter', newFilter));
closeAndQuery();
close();
}}
/>
{selectedFilterIndex !== -1 && (
@ -2027,7 +2032,7 @@ export class LoadDataView extends React.PureComponent<LoadDataViewProps, LoadDat
deepDelete(curFilter, `dimensionFilters.${selectedFilterIndex}`),
);
this.updateSpec(deepSet(spec, 'dataSchema.transformSpec.filter', newFilter));
closeAndQuery();
close();
}}
/>
)}
@ -2090,10 +2095,10 @@ export class LoadDataView extends React.PureComponent<LoadDataViewProps, LoadDat
height="200px"
/>
</FormGroup>
<div className="controls-buttons">
<div className="control-buttons">
<Button
className="add-update"
text="Preview"
text="Apply"
intent={Intent.PRIMARY}
onClick={() => this.queryForFilter()}
/>
@ -2174,7 +2179,7 @@ export class LoadDataView extends React.PureComponent<LoadDataViewProps, LoadDat
renderSchemaStep() {
const {
spec,
specPreview: spec,
columnFilter,
schemaQueryState,
selectedDimensionSpec,
@ -2221,16 +2226,14 @@ export class LoadDataView extends React.PureComponent<LoadDataViewProps, LoadDat
<Callout className="intro">
<p>
Each column in Druid must have an assigned type (string, long, float, complex, etc).
Default primitive types have been automatically assigned to your columns. If you want
to change the type, click on the column header.
</p>
<p>
Select whether or not you want to{' '}
<ExternalLink href="https://druid.apache.org/docs/latest/tutorials/tutorial-rollup.html">
roll-up
</ExternalLink>{' '}
your data.
</p>
{dimensionMode === 'specific' && (
<p>
Default primitive types have been automatically assigned to your columns. If you
want to change the type, click on the column header.
</p>
)}
<LearnMore href="https://druid.apache.org/docs/latest/ingestion/schema-design.html" />
</Callout>
{!somethingSelected && (
<>
@ -2296,10 +2299,14 @@ export class LoadDataView extends React.PureComponent<LoadDataViewProps, LoadDat
content={
<div className="label-info-text">
<p>
If you enable roll-up, Druid will try to pre-aggregate data before indexing
it to conserve storage. The primary timestamp will be truncated to the
specified query granularity, and rows containing the same string field
values will be aggregated together.
If you enable{' '}
<ExternalLink href="https://druid.apache.org/docs/latest/tutorials/tutorial-rollup.html">
roll-up
</ExternalLink>
, Druid will try to pre-aggregate data before indexing it to conserve
storage. The primary timestamp will be truncated to the specified query
granularity, and rows containing the same string field values will be
aggregated together.
</p>
<p>
If you enable rollup, you must specify which columns are{' '}
@ -2325,7 +2332,7 @@ export class LoadDataView extends React.PureComponent<LoadDataViewProps, LoadDat
name: 'dataSchema.granularitySpec.queryGranularity',
label: 'Query granularity',
type: 'string',
suggestions: ['NONE', 'MINUTE', 'HOUR', 'DAY'],
suggestions: ['NONE', 'SECOND', 'MINUTE', 'HOUR', 'DAY'],
info: (
<>
This granularity determines how timestamps will be truncated (not at all, to
@ -2336,12 +2343,8 @@ export class LoadDataView extends React.PureComponent<LoadDataViewProps, LoadDat
},
]}
model={spec}
onChange={s => this.updateSpec(s)}
onFinalize={() => {
setTimeout(() => {
this.queryForSchema();
}, 10);
}}
onChange={s => this.updateSpecPreview(s)}
onFinalize={this.applyPreviewSpec}
/>
</>
)}
@ -2387,9 +2390,6 @@ export class LoadDataView extends React.PureComponent<LoadDataViewProps, LoadDat
newRollup,
),
);
setTimeout(() => {
this.queryForSchema();
}, 10);
}}
confirmButtonText={`Yes - ${newRollup ? 'enable' : 'disable'} rollup`}
successText={`Rollup was ${newRollup ? 'enabled' : 'disabled'}. Schema has been updated.`}
@ -2420,9 +2420,6 @@ export class LoadDataView extends React.PureComponent<LoadDataViewProps, LoadDat
getRollup(spec),
),
);
setTimeout(() => {
this.queryForSchema();
}, 10);
}}
confirmButtonText={`Yes - ${autoDetect ? 'auto detect' : 'explicitly set'} columns`}
successText={`Dimension mode changes to ${
@ -2452,13 +2449,6 @@ export class LoadDataView extends React.PureComponent<LoadDataViewProps, LoadDat
});
};
const closeAndQuery = () => {
close();
setTimeout(() => {
this.queryForSchema();
}, 10);
};
if (selectedDimensionSpec) {
const curDimensions =
deepGet(spec, `dataSchema.parser.parseSpec.dimensionsSpec.dimensions`) || EMPTY_ARRAY;
@ -2470,7 +2460,7 @@ export class LoadDataView extends React.PureComponent<LoadDataViewProps, LoadDat
model={selectedDimensionSpec}
onChange={selectedDimensionSpec => this.setState({ selectedDimensionSpec })}
/>
<div className="controls-buttons">
<div className="control-buttons">
<Button
className="add-update"
text={selectedDimensionSpecIndex === -1 ? 'Add' : 'Update'}
@ -2483,7 +2473,7 @@ export class LoadDataView extends React.PureComponent<LoadDataViewProps, LoadDat
selectedDimensionSpec,
),
);
closeAndQuery();
close();
}}
/>
{selectedDimensionSpecIndex !== -1 && (
@ -2500,7 +2490,7 @@ export class LoadDataView extends React.PureComponent<LoadDataViewProps, LoadDat
`dataSchema.parser.parseSpec.dimensionsSpec.dimensions.${selectedDimensionSpecIndex}`,
),
);
closeAndQuery();
close();
}}
/>
)}
@ -2539,13 +2529,6 @@ export class LoadDataView extends React.PureComponent<LoadDataViewProps, LoadDat
});
};
const closeAndQuery = () => {
close();
setTimeout(() => {
this.queryForSchema();
}, 10);
};
if (selectedMetricSpec) {
return (
<div className="edit-controls">
@ -2554,7 +2537,7 @@ export class LoadDataView extends React.PureComponent<LoadDataViewProps, LoadDat
model={selectedMetricSpec}
onChange={selectedMetricSpec => this.setState({ selectedMetricSpec })}
/>
<div className="controls-buttons">
<div className="control-buttons">
<Button
className="add-update"
text={selectedMetricSpecIndex === -1 ? 'Add' : 'Update'}
@ -2567,7 +2550,7 @@ export class LoadDataView extends React.PureComponent<LoadDataViewProps, LoadDat
selectedMetricSpec,
),
);
closeAndQuery();
close();
}}
/>
{selectedMetricSpecIndex !== -1 && (
@ -2578,7 +2561,7 @@ export class LoadDataView extends React.PureComponent<LoadDataViewProps, LoadDat
this.updateSpec(
deepDelete(spec, `dataSchema.metricsSpec.${selectedMetricSpecIndex}`),
);
closeAndQuery();
close();
}}
/>
)}
@ -2678,6 +2661,7 @@ export class LoadDataView extends React.PureComponent<LoadDataViewProps, LoadDat
<Callout className="intro">
<p className="optional">Optional</p>
<p>Configure how Druid will partition data.</p>
<LearnMore href="https://druid.apache.org/docs/latest/ingestion/index.html#partitioning" />
</Callout>
{this.renderParallelPickerIfNeeded()}
</div>
@ -2742,6 +2726,7 @@ export class LoadDataView extends React.PureComponent<LoadDataViewProps, LoadDat
<Callout className="intro">
<p className="optional">Optional</p>
<p>Fine tune how Druid will ingest data.</p>
<LearnMore href="https://druid.apache.org/docs/latest/ingestion/index.html#tuningconfig" />
</Callout>
{this.renderParallelPickerIfNeeded()}
</div>

View File

@ -43,6 +43,7 @@ describe('parse time table', () => {
}}
columnFilter=""
possibleTimestampColumnsOnly={false}
selectedColumnName={undefined}
onTimestampColumnSelect={() => {}}
/>
);

View File

@ -32,6 +32,16 @@ import { HeaderAndRows, SampleEntry } from '../../../utils/sampler';
import './parse-time-table.scss';
export function parseTimeTableSelectedColumnName(
sampleData: HeaderAndRows,
timestampSpec: TimestampSpec | undefined,
): string | undefined {
if (!timestampSpec) return;
const timestampColumn = timestampSpec.column;
if (!timestampColumn || !sampleData.header.includes(timestampColumn)) return;
return timestampColumn;
}
export interface ParseTimeTableProps {
sampleBundle: {
headerAndRows: HeaderAndRows;
@ -39,6 +49,7 @@ export interface ParseTimeTableProps {
};
columnFilter: string;
possibleTimestampColumnsOnly: boolean;
selectedColumnName: string | undefined;
onTimestampColumnSelect: (newTimestampSpec: TimestampSpec) => void;
}
@ -47,6 +58,7 @@ export const ParseTimeTable = React.memo(function ParseTimeTable(props: ParseTim
sampleBundle,
columnFilter,
possibleTimestampColumnsOnly,
selectedColumnName,
onTimestampColumnSelect,
} = props;
const { headerAndRows, timestampSpec } = sampleBundle;
@ -62,7 +74,7 @@ export const ParseTimeTable = React.memo(function ParseTimeTable(props: ParseTim
(columnName, i) => {
const timestamp = columnName === '__time';
if (!timestamp && !caseInsensitiveContains(columnName, columnFilter)) return;
const selected = timestampSpec.column === columnName;
const used = timestampSpec.column === columnName;
const possibleFormat = timestamp
? null
: possibleDruidFormatForValues(
@ -72,7 +84,8 @@ export const ParseTimeTable = React.memo(function ParseTimeTable(props: ParseTim
const columnClassName = classNames({
timestamp,
selected,
used,
selected: selectedColumnName === columnName,
});
return {
Header: (