diff --git a/config-examples.yaml b/config-examples.yaml index 27f6d63a..b23cad4a 100644 --- a/config-examples.yaml +++ b/config-examples.yaml @@ -150,3 +150,8 @@ dataCubes: title: Unique Users formula: $main.countDistinct($user) +settingsLocation: + location: file + format: 'json' + uri: 'temp.json' + writable: true \ No newline at end of file diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json index cf9b6619..48603046 100644 --- a/npm-shrinkwrap.json +++ b/npm-shrinkwrap.json @@ -1,5 +1,5 @@ { - "name": "imply-swiv", + "name": "yahoo-swiv", "version": "0.9.39", "dependencies": { "abbrev": { @@ -74,7 +74,7 @@ }, "balanced-match": { "version": "0.4.2", - "from": "balanced-match@^0.4.1", + "from": "balanced-match@>=0.4.1 <0.5.0", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-0.4.2.tgz" }, "basic-auth": { @@ -104,7 +104,7 @@ }, "brace-expansion": { "version": "1.1.6", - "from": "brace-expansion@^1.0.0", + "from": "brace-expansion@>=1.0.0 <2.0.0", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.6.tgz" }, "browser-filesaver": { @@ -344,11 +344,18 @@ "fs-promise": { "version": "0.5.0", "from": "fs-promise@0.5.0", - "resolved": "https://registry.npmjs.org/fs-promise/-/fs-promise-0.5.0.tgz" + "resolved": "https://registry.npmjs.org/fs-promise/-/fs-promise-0.5.0.tgz", + "dependencies": { + "fs-extra": { + "version": "0.26.7", + "from": "fs-extra@>=0.26.5 <0.27.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-0.26.7.tgz" + } + } }, "fs.realpath": { "version": "1.0.0", - "from": "fs.realpath@^1.0.0", + "from": "fs.realpath@>=1.0.0 <2.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz" }, "generate-function": { @@ -379,9 +386,9 @@ } }, "glob": { - "version": "7.0.5", - "from": "glob@^7.0.5", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.0.5.tgz" + "version": "7.1.1", + "from": "glob@>=7.0.5 <8.0.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.1.tgz" }, "good-listener": { "version": "1.1.7", @@ -389,9 +396,9 @@ "resolved": "https://registry.npmjs.org/good-listener/-/good-listener-1.1.7.tgz" }, "graceful-fs": { - "version": "4.1.6", - "from": "graceful-fs@^4.1.2", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.1.6.tgz" + "version": "4.1.11", + "from": "graceful-fs@>=4.1.2 <5.0.0", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.1.11.tgz" }, "graceful-readlink": { "version": "1.0.1", @@ -449,9 +456,9 @@ "resolved": "https://registry.npmjs.org/immutable-class/-/immutable-class-0.6.9.tgz" }, "inflight": { - "version": "1.0.5", - "from": "inflight@^1.0.4", - "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.5.tgz" + "version": "1.0.6", + "from": "inflight@>=1.0.4 <2.0.0", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz" }, "inherits": { "version": "2.0.1", @@ -529,9 +536,9 @@ "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz" }, "jsonfile": { - "version": "2.3.1", - "from": "jsonfile@^2.1.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-2.3.1.tgz" + "version": "2.4.0", + "from": "jsonfile@>=2.1.0 <3.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-2.4.0.tgz" }, "jsonpointer": { "version": "2.0.0", @@ -544,9 +551,9 @@ "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.3.0.tgz" }, "klaw": { - "version": "1.3.0", - "from": "klaw@^1.0.0", - "resolved": "https://registry.npmjs.org/klaw/-/klaw-1.3.0.tgz" + "version": "1.3.1", + "from": "klaw@>=1.0.0 <2.0.0", + "resolved": "https://registry.npmjs.org/klaw/-/klaw-1.3.1.tgz" }, "logger-tracker": { "version": "0.0.23", @@ -600,7 +607,7 @@ }, "minimatch": { "version": "3.0.3", - "from": "minimatch@^3.0.2", + "from": "minimatch@>=3.0.2 <4.0.0", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.3.tgz" }, "morgan": { @@ -686,9 +693,9 @@ "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.0.1.tgz" }, "once": { - "version": "1.3.3", - "from": "once@^1.3.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.3.3.tgz" + "version": "1.4.0", + "from": "once@>=1.3.0 <2.0.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz" }, "packet-reader": { "version": "0.2.0", @@ -701,9 +708,9 @@ "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.1.tgz" }, "path-is-absolute": { - "version": "1.0.0", - "from": "path-is-absolute@^1.0.0", - "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.0.tgz" + "version": "1.0.1", + "from": "path-is-absolute@>=1.0.0 <2.0.0", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz" }, "path-to-regexp": { "version": "0.1.7", @@ -746,9 +753,9 @@ "resolved": "https://registry.npmjs.org/pinkie-promise/-/pinkie-promise-2.0.1.tgz" }, "plywood": { - "version": "0.12.3", - "from": "plywood@0.12.3", - "resolved": "https://registry.npmjs.org/plywood/-/plywood-0.12.3.tgz" + "version": "0.12.5", + "from": "plywood@0.12.5", + "resolved": "https://registry.npmjs.org/plywood/-/plywood-0.12.5.tgz" }, "plywood-druid-requester": { "version": "1.5.4", @@ -857,7 +864,7 @@ }, "rimraf": { "version": "2.5.4", - "from": "rimraf@^2.2.8", + "from": "rimraf@>=2.2.8 <3.0.0", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.5.4.tgz" }, "select": { @@ -1024,7 +1031,7 @@ }, "wrappy": { "version": "1.0.2", - "from": "wrappy@1", + "from": "wrappy@>=1.0.0 <2.0.0", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz" }, "xmlhttprequest": { diff --git a/package.json b/package.json index a76ddf65..26c4869e 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,7 @@ "node-spawn-server": "1.0.1", "nopt": "3.0.6", "numeral": "1.5.3", - "plywood": "0.12.3", + "plywood": "^0.12.5", "plywood-druid-requester": "1.5.4", "plywood-mysql-requester": "1.3.1", "plywood-postgres-requester": "0.9.1", diff --git a/src/client/applications/swiv-application/collection-view-delegate/collection-view-delegate.tsx b/src/client/applications/swiv-application/collection-view-delegate/collection-view-delegate.tsx index 37932077..a9afa330 100644 --- a/src/client/applications/swiv-application/collection-view-delegate/collection-view-delegate.tsx +++ b/src/client/applications/swiv-application/collection-view-delegate/collection-view-delegate.tsx @@ -89,7 +89,7 @@ export class CollectionViewDelegate { const collectionURL = `#collection/${collection.name}`; const oldIndex = collection.tiles.indexOf(tile); - const newCollection = collection.deleteTile(tile); + const newCollection = collection.deleteTile(tile.name); const newSettings = appSettings.addOrUpdateCollection(newCollection); const undo = () => this.addTile(newCollection, tile, oldIndex); @@ -175,7 +175,7 @@ export class CollectionViewDelegate { this.save(this.getSettings().addCollectionAt(collection, oldIndex)); }; - return this.save(appSettings.deleteCollection(collection)).then( () => { + return this.save(appSettings.deleteCollection(collection.name)).then( () => { window.location.hash = `#/home`; Notifier.success('Collection removed', {label: STRINGS.undo, callback: undo}); }); diff --git a/src/client/applications/swiv-application/swiv-application.tsx b/src/client/applications/swiv-application/swiv-application.tsx index a5f4f0a9..10ec3819 100644 --- a/src/client/applications/swiv-application/swiv-application.tsx +++ b/src/client/applications/swiv-application/swiv-application.tsx @@ -24,6 +24,7 @@ import { findByName } from 'plywood'; import { replaceHash } from '../../utils/url/url'; import { DataCube, AppSettings, User, Collection, CollectionTile, Essence, Timekeeper, ViewSupervisor } from '../../../common/models/index'; import { STRINGS } from '../../config/constants'; +import { MANIFESTS } from '../../../common/manifests/index'; import { createFunctionSlot, FunctionSlot } from '../../utils/function-slot/function-slot'; import { Ajax } from '../../utils/ajax/ajax'; @@ -159,7 +160,17 @@ export class SwivApplication extends React.Component { - console.log('UPDATE!!'); + Ajax.query({ method: "GET", url: 'client-settings' }) + .then( + (resp) => { + //if (!this.mounted) return; // ToDo: this + this.setState({ + appSettings: AppSettings.fromJS(resp.clientSettings, { visualizations: MANIFESTS }) + }); + }, + (e: Error) => { + } + ); }; require.ensure(['clipboard'], (require) => { @@ -212,6 +223,10 @@ export class SwivApplication extends React.Component { if (!this.mounted) return; @@ -323,7 +324,7 @@ export class DimensionTile extends React.Component ; diff --git a/src/client/components/hiluk-menu/hiluk-menu.tsx b/src/client/components/hiluk-menu/hiluk-menu.tsx index 622b9f06..d1e1fb6c 100644 --- a/src/client/components/hiluk-menu/hiluk-menu.tsx +++ b/src/client/components/hiluk-menu/hiluk-menu.tsx @@ -76,7 +76,7 @@ export class HilukMenu extends React.Component { const { dataCube, splits } = essence; if (!getDownloadableDataset) return; - const filters = essence.getEffectiveFilter(timekeeper).getFileString(dataCube.timeAttribute); + const filters = essence.getEffectiveFilter(timekeeper).getFileString(dataCube.getPrimaryTimeExpression()); var splitsString = splits.toArray().map((split) => { var dimension = split.getDimension(dataCube.dimensions); if (!dimension) return ''; diff --git a/src/client/components/index.ts b/src/client/components/index.ts index fc407542..8042280e 100644 --- a/src/client/components/index.ts +++ b/src/client/components/index.ts @@ -48,6 +48,7 @@ export * from './immutable-input/immutable-input'; export * from './immutable-list/immutable-list'; export * from './line-chart-axis/line-chart-axis'; export * from './loader/loader'; +export * from './loading-bar/loading-bar'; export * from './manual-fallback/manual-fallback'; export * from './measures-tile/measures-tile'; export * from './modal/modal'; diff --git a/src/client/components/loading-bar/loading-bar.mocha.tsx b/src/client/components/loading-bar/loading-bar.mocha.tsx new file mode 100644 index 00000000..a2b4f40c --- /dev/null +++ b/src/client/components/loading-bar/loading-bar.mocha.tsx @@ -0,0 +1,41 @@ +/* + * Copyright 2015-2016 Imply Data, Inc. + * + * Licensed 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 { expect } from 'chai'; +import * as sinon from 'sinon'; +import * as React from 'react'; +import * as TestUtils from 'react-addons-test-utils'; +import { $, Expression } from 'plywood'; + +import { DataCubeMock, EssenceMock } from '../../../common/models/mocks'; + +import { findDOMNode } from '../../utils/test-utils/index'; + +import { LoadingBar } from './loading-bar'; + +describe('LoadingBar', () => { + it('adds the correct class', () => { + var renderedComponent = TestUtils.renderIntoDocument( + + ); + + expect(TestUtils.isCompositeComponent(renderedComponent), 'should be composite').to.equal(true); + expect((findDOMNode(renderedComponent) as any).className, 'should contain class').to.contain('loading-bar'); + }); + +}); diff --git a/src/client/components/loading-bar/loading-bar.scss b/src/client/components/loading-bar/loading-bar.scss new file mode 100644 index 00000000..4975c67b --- /dev/null +++ b/src/client/components/loading-bar/loading-bar.scss @@ -0,0 +1,51 @@ +/* + * Copyright 2015-2016 Imply Data, Inc. + * + * Licensed 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 '../../imports'; + +.loading-bar { + width: 100%; + height: 30px; + + // Inner text styling + > * { + padding-top: 9px; + text-align: center; + color: $brand; + } + + .undetermined { + $color-0: hsla(202, 83%, 95%, 1.00); + $color-1: hsla(201, 83%, 91%, 1.00); + + width: 100%; + height: 100%; + + background-image: repeating-linear-gradient(-45deg, $color-0, $color-0 7px, $color-1 7px, $color-1 10px); + animation: progress 1s linear infinite; + background-size: 150% 100%; + + @keyframes progress { + 0% { + background-position: 0 0; + } + + 100% { + background-position: -70px 0; + } + } + } +} diff --git a/src/client/components/loading-bar/loading-bar.tsx b/src/client/components/loading-bar/loading-bar.tsx new file mode 100644 index 00000000..61154b3c --- /dev/null +++ b/src/client/components/loading-bar/loading-bar.tsx @@ -0,0 +1,60 @@ +/* + * Copyright 2015-2016 Imply Data, Inc. + * + * Licensed 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. + */ + +require('./loading-bar.css'); + +import * as React from 'react'; +import * as ReactDOM from 'react-dom'; +import { $, Expression, Executor, Dataset } from 'plywood'; +import { Stage, Clicker, Essence, DataCube, Filter, Dimension, Measure } from '../../../common/models/index'; +import { SvgIcon } from '../index'; + +// I am: import { LoadingBar } from '../loading-bar/loading-bar'; + +export interface LoadingBarProps extends React.Props { + label?: string; +} + +export interface LoadingBarState { +} + +export class LoadingBar extends React.Component { + public mounted: boolean; + + constructor() { + super(); + } + + componentDidMount() { + this.mounted = true; + } + + componentWillUnmount() { + this.mounted = false; + } + + componentWillReceiveProps(nextProps: LoadingBarProps) { + + } + + render() { + const { label } = this.props; + + return
+
{label}
+
; + } +} diff --git a/src/client/components/modal/modal.tsx b/src/client/components/modal/modal.tsx index 9161afd2..710f2e37 100644 --- a/src/client/components/modal/modal.tsx +++ b/src/client/components/modal/modal.tsx @@ -33,6 +33,7 @@ export interface ModalProps extends React.Props { onClose?: Fn; onEnter?: Fn; startUpFocusOn?: string; + deaf?: boolean; } export interface ModalState { @@ -110,8 +111,8 @@ export class Modal extends React.Component { } onMouseDown(e: MouseEvent) { - var { onClose, mandatory } = this.props; - if (mandatory) return; + var { onClose, mandatory, deaf } = this.props; + if (mandatory || deaf) return; var { id } = this.state; // can not use ReactDOM.findDOMNode(this) because portal? @@ -124,7 +125,7 @@ export class Modal extends React.Component { } render() { - var { className, title, children, onClose } = this.props; + var { className, title, children, onClose, deaf } = this.props; var { id } = this.state; var titleElement: JSX.Element = null; @@ -138,7 +139,7 @@ export class Modal extends React.Component { } return -
+
void; } @@ -235,16 +235,22 @@ export class Questions extends React.Component, QuestionsState> if (!question) return null; + var message: JSX.Element | JSX.Element[]; + + if (Array.isArray(question.message)) { + message = question.message.map((line, i) =>

{line}

); + } else if (React.isValidElement(question.message)) { + message = question.message as JSX.Element; + } else { + message =

{question.message}

; + } + return - {Array.isArray(question.message) - ? question.message.map((line, i) =>

{line}

) - :

{question.message}

- } - + {message}
{question.choices.map(({label, callback, type, className}, i) => { return
); @@ -102,7 +180,7 @@ export class SimpleTable extends React.Component
); + >
); } return items; @@ -110,37 +188,12 @@ export class SimpleTable extends React.Component any { if (typeof column.field === 'string') { - return (row: any) => row[column.field as string]; + return (row: any) => '' + row[column.field as string]; } return column.field as (row: any) => any; } - renderRow(row: any, columns: SimpleTableColumn[], index: number): JSX.Element { - const { hoveredRowIndex } = this.state; - var items: JSX.Element[] = []; - - for (let i = 0; i < columns.length; i++) { - let col = columns[i]; - - let icon = col.cellIcon ? : null; - - items.push(
{icon}{this.labelizer(col)(row)}
); - } - - return
- {items} -
; - } - sortRows(rows: any[], sortColumn: SimpleTableColumn, sortAscending: boolean): any[] { if (!sortColumn) return rows; @@ -169,23 +222,95 @@ export class SimpleTable extends React.Component : null; + + items.push(
{icon}{this.labelizer(col)(row)}
); + } + + return
+ {items} +
; + } + renderRows(rows: any[], columns: SimpleTableColumn[], sortColumn: SimpleTableColumn, sortAscending: boolean): JSX.Element[] { - if (!rows || !rows.length) return null; + if (!rows || !rows.length || !columns || !columns.length) return null; + + const vertical = this.getYBoundaries(rows); + const horizontal = this.getXBoundaries(columns); rows = this.sortRows(rows, sortColumn, sortAscending); var items: JSX.Element[] = []; - for (let i = 0; i < rows.length; i++) { - items.push(this.renderRow(rows[i], columns, i)); + for (let i = vertical.start; i < vertical.end; i++) { + items.push(this.renderRow(rows[i], columns, i, horizontal.start, horizontal.end)); } return items; } + getXBoundaries(columns: SimpleTableColumn[]): {start: number, end: number} { + const { viewportWidth, scrollLeft } = this.state; + var headerWidth = 0; + var start = -1; + var end = -1; + + const n = columns.length; + for (let i = 0; i < n; i++) { + let col = columns[i]; + + if (start === -1 && headerWidth + col.width > scrollLeft) { + start = i; + } + + if (end === -1 && headerWidth > scrollLeft + viewportWidth) { + end = i; + } + + // To render last column + if (end === -1 && i === n - 1) { + end = n; + } + + if (start !== -1 && end !== -1) break; + + headerWidth += col.width; + } + + return {start, end}; + } + + getYBoundaries(rows: any[]): {start: number, end: number} { + const { viewportHeight, scrollTop } = this.state; + + var topIndex = Math.floor(scrollTop / ROW_HEIGHT); + + return { + start: topIndex, + end: Math.min(topIndex + Math.ceil(viewportHeight / ROW_HEIGHT), rows.length) + }; + } + getLayout(columns: SimpleTableColumn[], rows: any[], actions: SimpleTableAction[]) { const width = columns.reduce((a, b) => a + b.width, 0); + actions = actions || []; + const directActionsCount = actions.filter((a) => !a.inEllipsis).length; const indirectActionsCount = directActionsCount !== actions.length ? 1 : 0; @@ -195,7 +320,7 @@ export class SimpleTable extends React.Component { + var elements: JSX.Element[] = []; + + for (let i = vertical.start; i < vertical.end; i++) { let isRowHovered = i === hoveredRowIndex; + let row = rows[i]; let icons = directActions.map((action, j) => { return
- +
; }); - return
- {icons} -
; - }; + style={{height: ROW_HEIGHT, top: i * ROW_HEIGHT}} + >{icons}); + } - return rows.map(generator); + return elements; } getRowIndex(y: number): number { var rowIndex = -1; // -1 means header // Not in the header - if (y > HEADER_HEIGHT) { - rowIndex = Math.floor((y - HEADER_HEIGHT) / ROW_HEIGHT); + if (y > this.props.headerHeight) { + rowIndex = Math.floor((y - this.props.headerHeight) / ROW_HEIGHT); } return rowIndex; @@ -302,6 +430,11 @@ export class SimpleTable extends React.Component rows.length ? undefined : rowIndex, hoveredActionIndex: part === Scroller.RIGHT_GUTTER ? this.getActionIndex(x, headerWidth) : undefined }); @@ -327,6 +462,7 @@ export class SimpleTable extends React.Component + ; } diff --git a/src/client/components/time-filter-menu/time-filter-menu.tsx b/src/client/components/time-filter-menu/time-filter-menu.tsx index 014fad92..0afbc393 100644 --- a/src/client/components/time-filter-menu/time-filter-menu.tsx +++ b/src/client/components/time-filter-menu/time-filter-menu.tsx @@ -245,7 +245,7 @@ export class TimeFilterMenu extends React.Component; return
- { essence.dataCube.isTimeAttribute(dimension.expression) ? maxTimeBasedPresets : null} + { essence.dataCube.isPrimaryTimeExpression(dimension.expression) ? maxTimeBasedPresets : null}
{STRINGS.current}
{currentPresets.map(presetToButton)}
{STRINGS.previous}
diff --git a/src/client/delegates/immutable-form-delegate/immutable-form-delegate.tsx b/src/client/delegates/immutable-form-delegate/immutable-form-delegate.tsx new file mode 100644 index 00000000..eb3dd679 --- /dev/null +++ b/src/client/delegates/immutable-form-delegate/immutable-form-delegate.tsx @@ -0,0 +1,98 @@ +/* + * Copyright 2015-2016 Imply Data, Inc. + * + * Licensed 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 * as React from 'react'; + +export interface FormItem { + change: (propName: string, propValue: any) => FormItem; +} + +export interface ChangeFn { + (myInstance: any, valid: boolean, path?: string, error?: string): void; +} + +export interface ImmutableFormState { + newInstance?: T; + canSave?: boolean; + errors?: any; +} + +export class ImmutableFormDelegate { + + private form: React.Component>; + private callbacks: { [key: string]: (() => void)[]; }; + + constructor(form: React.Component>) { + this.form = form; + + if (!this.form.state) this.form.state = {}; + + this.form.state.canSave = false; + this.form.state.errors = {}; + + this.onChange = this.onChange.bind(this); + this.updateErrors = this.updateErrors.bind(this); + + this.callbacks = {}; + } + + on(path: string, callback: () => void) { + var listeners = this.callbacks[path] || []; + listeners.push(callback); + + this.callbacks[path] = listeners; + } + + private setState(state: ImmutableFormState, callback?: () => void) { + return this.form.setState.call(this.form, state, callback); + } + + updateErrors(path: string, isValid: boolean, error: string): {errors: any, canSave: boolean} { + var { errors } = this.form.state; + + errors[path] = isValid ? false : error; + + var canSave = true; + for (let key in errors) canSave = canSave && (errors[key] === false); + + return {errors, canSave}; + } + + onChange(newItem: any, isValid: boolean, path: string, error: string) { + var { errors, canSave } = this.updateErrors(path, isValid, error); + + if (isValid) { + this.setState({ + errors, + newInstance: newItem, + canSave + }, this.callListeners.bind(this, path)); + } else { + this.setState({ + errors, + canSave: false + }, this.callListeners.bind(this, path)); + } + } + + callListeners(path: string) { + const listeners = this.callbacks[path]; + + if (!listeners || !listeners.length) return; + + listeners.forEach(l => l()); + } +} diff --git a/src/client/delegates/index.ts b/src/client/delegates/index.ts new file mode 100644 index 00000000..54a66c14 --- /dev/null +++ b/src/client/delegates/index.ts @@ -0,0 +1,18 @@ +/* + * Copyright 2015-2016 Imply Data, Inc. + * + * Licensed 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 * from './immutable-form-delegate/immutable-form-delegate'; +export * from './loading-message-delegate/loading-message-delegate'; diff --git a/src/client/delegates/loading-message-delegate/loading-message-delegate.tsx b/src/client/delegates/loading-message-delegate/loading-message-delegate.tsx new file mode 100644 index 00000000..11a26e5a --- /dev/null +++ b/src/client/delegates/loading-message-delegate/loading-message-delegate.tsx @@ -0,0 +1,82 @@ +/* + * Copyright 2015-2016 Imply Data, Inc. + * + * Licensed 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 * as React from 'react'; +import * as Q from 'q'; + +const DELAY = 100; + +export interface LoadingMessageState { + loadingMessage?: string; + isLoading?: boolean; +} + +export class LoadingMessageDelegate { + + private component: React.Component; + private timeoutId: number; + + constructor(component: React.Component) { + this.component = component; + + if (!this.component.state) this.component.state = {}; + + this.component.state.loadingMessage = null; + } + + private setState(state: LoadingMessageState, callback?: () => void) { + return this.component.setState.call(this.component, state, callback); + } + + public start(message: string) { + this.timeoutId = window.setTimeout(() => { + this.timeoutId = undefined; + this.setState({ + isLoading: true, + loadingMessage: message + }); + }, DELAY); + } + + public startNow(message: string) { + this.setState({ + isLoading: true, + loadingMessage: message + }); + } + + public stop(): Q.Promise { + var deferred = Q.defer(); + + if (this.timeoutId) { + window.clearTimeout(this.timeoutId); + deferred.resolve(); + } else { + this.setState({ + isLoading: false, + loadingMessage: undefined + }, () => deferred.resolve()); + } + + return deferred.promise; + } + + unmount() { + if (this.timeoutId) { + window.clearTimeout(this.timeoutId); + } + } +} diff --git a/src/client/icons/data.svg b/src/client/icons/data.svg new file mode 100644 index 00000000..0dfa4d46 --- /dev/null +++ b/src/client/icons/data.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/client/icons/measures.svg b/src/client/icons/measures.svg new file mode 100644 index 00000000..58e7bae2 --- /dev/null +++ b/src/client/icons/measures.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/client/modals/attribute-modal/attribute-modal.scss b/src/client/modals/attribute-modal/attribute-modal.scss new file mode 100644 index 00000000..f6c77216 --- /dev/null +++ b/src/client/modals/attribute-modal/attribute-modal.scss @@ -0,0 +1,57 @@ +/* + * Copyright 2015-2016 Imply Data, Inc. + * + * Licensed 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 '../../imports'; +$checkbox-width: 20px; + +.attribute-modal { + @import '../../utils/styles/grid'; + + form { + @extend %form; + width: 100%; + } + + .row { + height: 27px; + cursor: pointer; + white-space: nowrap; + + .label { + position: absolute; + top: 5px; + left: $checkbox-width; + } + + &:hover { + background: $hover; + } + } + + .checkbox { + margin-top: 2px; + + &.selected { + .checkbox-body { + background: $brand; + } + } + } + + button.alternate { + @extend %button-light-action; + } +} diff --git a/src/client/modals/attribute-modal/attribute-modal.tsx b/src/client/modals/attribute-modal/attribute-modal.tsx new file mode 100644 index 00000000..e04790bf --- /dev/null +++ b/src/client/modals/attribute-modal/attribute-modal.tsx @@ -0,0 +1,172 @@ +/* + * Copyright 2015-2016 Imply Data, Inc. + * + * Licensed 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. + */ + + +require('./attribute-modal.css'); + +import { STRINGS } from "../../config/constants"; +import * as React from 'react'; +import { AttributeInfo } from 'plywood'; + +import { ATTRIBUTE as LABELS } from '../../../common/models/labels'; +import { ListItem } from "../../../common/models/list-item/list-item"; + +import { ImmutableFormDelegate, ImmutableFormState } from "../../delegates/index"; +import { FormLabel, ImmutableDropdown, Modal, ImmutableInput, Checkbox, Button } from "../../components/index"; + +export interface AttributeModalProps extends React.Props { + attributeInfo: AttributeInfo; + onSave?: (attribute: AttributeInfo) => void; + onClose?: () => void; + onAlternateClick?: () => void; + onRemove?: () => void; + mode?: 'create' | 'edit'; +} + +export interface AttributeModalState extends ImmutableFormState { +} + +export class AttributeModal extends React.Component { + static TYPES: ListItem[] = [ + {label: 'Time', value: 'TIME'}, + {label: 'String', value: 'STRING'}, + {label: 'Set/String', value: 'SET/STRING'}, + {label: 'Boolean', value: 'BOOLEAN'}, + {label: 'Number', value: 'NUMBER'} + ]; + + + static SPECIAL: ListItem[] = [ + {label: '', value: undefined}, + {label: 'Unique', value: 'unique'}, + {label: 'Theta', value: 'theta'}, + {label: 'Histogram', value: 'histogram'} + ]; + + static defaultProps = { + mode: 'edit' + }; + + public mounted: boolean; + private delegate: ImmutableFormDelegate; + + constructor() { + super(); + this.delegate = new ImmutableFormDelegate(this); + } + + initFromProps(props: AttributeModalProps) { + if (props.attributeInfo) { + this.setState({ + newInstance: new AttributeInfo(props.attributeInfo.valueOf()), + canSave: props.mode === 'create', + errors: {} + }); + } + } + + componentWillReceiveProps(nextProps: AttributeModalProps) { + this.initFromProps(nextProps); + } + + componentDidMount() { + this.initFromProps(this.props); + } + + save() { + if (!this.state.canSave) return; + this.props.onSave(this.state.newInstance); + } + + toggleSplittable() { + const { newInstance } = this.state; + let toggled = newInstance.change('unsplitable', !newInstance.unsplitable); + return this.delegate.onChange(toggled, true, 'unsplitable', undefined); + } + + renderAlternateButton() { + const { onRemove, onAlternateClick, mode } = this.props; + + if (mode === 'create' && onAlternateClick) { + return
+
+ {this.renderAlternateButton()} +
+ ; + } + + render() { + const { attributeInfo, onClose, mode, onRemove } = this.props; + const { newInstance, canSave, errors } = this.state; + const saveButtonDisabled = !canSave || attributeInfo.equals(newInstance); + if (!newInstance) return null; + let { unsplitable } = newInstance; + + var makeLabel = FormLabel.simpleGenerator(LABELS, errors, true); + var makeDropdownInput = ImmutableDropdown.simpleGenerator(newInstance, this.delegate.onChange); + + var title: string = null; + let nameDiv: JSX.Element = null; + if (mode === 'create') { + var makeTextInput = ImmutableInput.simpleGenerator(newInstance, this.delegate.onChange); + nameDiv =
+ {makeLabel('name')} + {makeTextInput('name', /^.+$/, true)} +
; + title = `${STRINGS.add} ${STRINGS.attribute}`; + } else { + title = attributeInfo.name; + } + + return +
+ {nameDiv} + {makeLabel('type')} + {makeDropdownInput('type', AttributeModal.TYPES)} + {makeLabel('special')} + {makeDropdownInput('special', AttributeModal.SPECIAL)} +
+ +
+
+ {this.renderButtons()} +
; + } +} diff --git a/src/client/modals/cluster-seed-modal/cluster-seed-modal.tsx b/src/client/modals/cluster-seed-modal/cluster-seed-modal.tsx index 0475b61f..2dd55780 100644 --- a/src/client/modals/cluster-seed-modal/cluster-seed-modal.tsx +++ b/src/client/modals/cluster-seed-modal/cluster-seed-modal.tsx @@ -19,26 +19,49 @@ require('./cluster-seed-modal.css'); import * as React from 'react'; import { SupportedType, Cluster } from "../../../common/models/cluster/cluster"; -import { FormLabel, Button, Modal, ImmutableInput, ImmutableDropdown } from '../../components/index'; +import { classNames } from '../../utils/dom/dom'; +import { FormLabel, Button, Modal, ImmutableInput, ImmutableDropdown, LoadingBar } from '../../components/index'; import { STRINGS } from "../../config/constants"; +import { Ajax } from '../../utils/ajax/ajax'; +import { Notifier } from '../../components/notifications/notifications'; import { CLUSTER as LABELS } from '../../../common/models/labels'; import { generateUniqueName } from '../../../common/utils/string/string'; import { indexByAttribute } from '../../../common/utils/array/array'; -import { ImmutableFormDelegate, ImmutableFormState } from '../../utils/immutable-form-delegate/immutable-form-delegate'; +import { + ImmutableFormDelegate, ImmutableFormState, + LoadingMessageDelegate, LoadingMessageState +} from '../../delegates/index'; export interface ClusterSeedModalProps extends React.Props { - onNext: (newCluster: Cluster) => void; + onNext: (newCluster: Cluster, sources: string[]) => void; onCancel: () => void; clusters: Cluster[]; } -export class ClusterSeedModal extends React.Component> { - private delegate: ImmutableFormDelegate; +export interface ClusterSeedModalState extends ImmutableFormState, LoadingMessageState {} + +export class ClusterSeedModal extends React.Component { + private formDelegate: ImmutableFormDelegate; + + private mounted = false; + + // This delays the loading state by 250ms so it doesn't flicker in case the + // server responds quickly + private loadingDelegate: LoadingMessageDelegate; + constructor() { super(); - this.delegate = new ImmutableFormDelegate(this); + this.formDelegate = new ImmutableFormDelegate(this); + this.formDelegate.on('type', this.makeSureHostIsValid.bind(this)); + + this.loadingDelegate = new LoadingMessageDelegate(this); + } + + makeSureHostIsValid() { + const { newInstance } = this.state; + if (!newInstance.host) this.setState({canSave: false}); } initFromProps(props: ClusterSeedModalProps) { @@ -47,8 +70,10 @@ export class ClusterSeedModal extends React.Component indexByAttribute(clusters, 'name', name) === -1), + title: 'temp', type: 'druid' }) }); @@ -59,40 +84,110 @@ export class ClusterSeedModal extends React.Component { + if (!this.mounted) return; + + this.loadingDelegate.stop(); + var cluster = Cluster.fromJS(resp.cluster); + cluster = cluster + .changeTitle(`My ${cluster.type} cluster`) + .changeTimeout(Cluster.DEFAULT_TIMEOUT) + ; + this.props.onNext(cluster, resp.sources); + }, + (xhr: XMLHttpRequest) => { + if (!this.mounted) return; + + this.loadingDelegate.stop(); + console.error((xhr as any).message); + Notifier.failure(`Couldn't connect to cluster`, 'Please check your parameters'); + } + ) + .done(); + } + onNext() { - this.props.onNext(this.state.newInstance); + const { canSave } = this.state; + if (canSave) { + this.connect(); + this.loadingDelegate.start('Creating cluster…'); + } } render(): JSX.Element { - const { onNext, onCancel } = this.props; - const { newInstance, errors } = this.state; + const { onCancel } = this.props; + const { newInstance, errors, canSave, loadingMessage, isLoading } = this.state; if (!newInstance) return null; + let clusterType = newInstance.type; var makeLabel = FormLabel.simpleGenerator(LABELS, errors, true); - var makeTextInput = ImmutableInput.simpleGenerator(newInstance, this.delegate.onChange); - var makeDropDownInput = ImmutableDropdown.simpleGenerator(newInstance, this.delegate.onChange); + var makeTextInput = ImmutableInput.simpleGenerator(newInstance, this.formDelegate.onChange); + var makeDropdownInput = ImmutableDropdown.simpleGenerator(newInstance, this.formDelegate.onChange); + + var extraSQLFields: JSX.Element = null; + + if (clusterType === 'mysql' || clusterType === 'postgres') { + extraSQLFields =
+ {makeLabel('database')} + {makeTextInput('database')} + + {makeLabel('user')} + {makeTextInput('user')} + + {makeLabel('password')} + {makeTextInput('password')} +
; + } return
{makeLabel('type')} - {makeDropDownInput('type', Cluster.TYPE_VALUES.map(type => {return {value: type, label: type}; }))} + {makeDropdownInput('type', Cluster.TYPE_VALUES.map(type => {return {value: type, label: type}; }))} {makeLabel('host')} - {makeTextInput('host', /^.+$/)} + {makeTextInput('host', /^.+$/, true)} + {extraSQLFields}
-
-
+ + {isLoading + ? + + :
+
+ }
; } diff --git a/src/client/modals/data-cube-filter-modal/data-cube-filter-modal.scss b/src/client/modals/data-cube-filter-modal/data-cube-filter-modal.scss new file mode 100644 index 00000000..559fd66a --- /dev/null +++ b/src/client/modals/data-cube-filter-modal/data-cube-filter-modal.scss @@ -0,0 +1,24 @@ +/* + * Copyright 2015-2016 Imply Data, Inc. + * + * Licensed 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 '../../imports'; + +.data-cube-filter-modal { + form { + @extend %form; + width: 100%; + } +} diff --git a/src/client/modals/data-cube-filter-modal/data-cube-filter-modal.tsx b/src/client/modals/data-cube-filter-modal/data-cube-filter-modal.tsx new file mode 100644 index 00000000..28ad542e --- /dev/null +++ b/src/client/modals/data-cube-filter-modal/data-cube-filter-modal.tsx @@ -0,0 +1,114 @@ +/* + * Copyright 2015-2016 Imply Data, Inc. + * + * Licensed 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. + */ + +require('./data-cube-filter-modal.css'); + +import * as React from 'react'; +import { findByName } from 'plywood'; + +import { DataCube } from "../../../common/models/data-cube/data-cube"; +import { DATA_CUBE as LABELS } from '../../../common/models/labels'; +import { FormLabel, Button, Modal, ImmutableInput } from '../../components/index'; + +import { + ImmutableFormDelegate, + ImmutableFormState +} from "../../delegates/immutable-form-delegate/immutable-form-delegate"; +import { STRINGS } from "../../config/constants"; + +export interface DataCubeFilterModalProps extends React.Props { + onSave?: (dataCube: DataCube) => void; + onClose?: () => void; + dataCube?: DataCube; +} + +export interface DataCubeFilterModalState extends ImmutableFormState { +} + +export class DataCubeFilterModal extends React.Component { + public mounted: boolean; + private delegate: ImmutableFormDelegate; + + constructor() { + super(); + this.delegate = new ImmutableFormDelegate(this); + } + + initFromProps(props: DataCubeFilterModalProps) { + if (props.dataCube) { + this.setState({ + newInstance: new DataCube(props.dataCube.valueOf()), + canSave: false, + errors: {} + }); + } + } + + componentWillReceiveProps(nextProps: DataCubeFilterModalProps) { + this.initFromProps(nextProps); + } + + componentDidMount() { + this.initFromProps(this.props); + } + + validate(input: string) { + const { newInstance } = this.state; + return newInstance.validateFormula(input); + } + + save() { + const { canSave, newInstance } = this.state; + const { onSave } = this.props; + + if (!canSave) return; + onSave(newInstance); + } + + render() { + const { onClose } = this.props; + const { newInstance, canSave, errors } = this.state; + if (!newInstance) return null; + var makeLabel = FormLabel.simpleGenerator(LABELS, errors, true); + + return +
+ {makeLabel('subsetFormula')} + +
+
+ +
; + } +} diff --git a/src/client/modals/dimension-modal/dimension-modal.tsx b/src/client/modals/dimension-modal/dimension-modal.tsx index 7734d6fd..15d8ba7f 100644 --- a/src/client/modals/dimension-modal/dimension-modal.tsx +++ b/src/client/modals/dimension-modal/dimension-modal.tsx @@ -27,13 +27,14 @@ import { Dimension, ListItem, granularityFromJS, granularityToString } from '../ import { DIMENSION as LABELS } from '../../../common/models/labels'; -import { ImmutableFormDelegate, ImmutableFormState } from '../../utils/immutable-form-delegate/immutable-form-delegate'; +import { ImmutableFormDelegate, ImmutableFormState } from '../../delegates/index'; export interface DimensionModalProps extends React.Props { dimension?: Dimension; onSave?: (dimension: Dimension) => void; onClose?: () => void; + validate?: RegExp; } export class DimensionModal extends React.Component> { @@ -81,7 +82,7 @@ export class DimensionModal extends React.Component : null} {isContinuous ? makeLabel('bucketingStrategy') : null} - {isContinuous ? makeDropDownInput('bucketingStrategy', DimensionModal.BUCKETING_STRATEGIES) : null} + {isContinuous ? makeDropdownInput('bucketingStrategy', DimensionModal.BUCKETING_STRATEGIES) : null} diff --git a/src/client/modals/index.ts b/src/client/modals/index.ts index 7a1a4ed6..3f10c496 100644 --- a/src/client/modals/index.ts +++ b/src/client/modals/index.ts @@ -17,9 +17,11 @@ export * from './about-modal/about-modal'; export * from './name-description-modal/name-description-modal'; export * from './add-collection-tile-modal/add-collection-tile-modal'; +export * from './attribute-modal/attribute-modal'; export * from './cluster-seed-modal/cluster-seed-modal'; export * from './data-cube-seed-modal/data-cube-seed-modal'; export * from './dimension-modal/dimension-modal'; export * from './measure-modal/measure-modal'; export * from './raw-data-modal/raw-data-modal'; export * from './suggestion-modal/suggestion-modal'; +export * from './data-cube-filter-modal/data-cube-filter-modal'; diff --git a/src/client/modals/measure-modal/measure-modal.tsx b/src/client/modals/measure-modal/measure-modal.tsx index b972804c..33f6f956 100644 --- a/src/client/modals/measure-modal/measure-modal.tsx +++ b/src/client/modals/measure-modal/measure-modal.tsx @@ -17,7 +17,7 @@ require('./measure-modal.css'); import * as React from 'react'; -import { classNames, enterKey } from '../../utils/dom/dom'; +import { classNames } from '../../utils/dom/dom'; import { List } from 'immutable'; import { SvgIcon, FormLabel, Button, ImmutableInput, Modal, ImmutableDropdown } from '../../components/index'; @@ -26,13 +26,14 @@ import { Measure } from '../../../common/models/index'; import { MEASURE as LABELS } from '../../../common/models/labels'; -import { ImmutableFormDelegate, ImmutableFormState } from '../../utils/immutable-form-delegate/immutable-form-delegate'; +import { ImmutableFormDelegate, ImmutableFormState } from '../../delegates/index'; export interface MeasureModalProps extends React.Props { measure?: Measure; onSave?: (measure: Measure) => void; onClose?: () => void; + validate?: RegExp; } @@ -69,7 +70,7 @@ export class MeasureModal extends React.Component
diff --git a/src/client/modals/raw-data-modal/raw-data-modal.tsx b/src/client/modals/raw-data-modal/raw-data-modal.tsx index 2c44539d..be1173fb 100644 --- a/src/client/modals/raw-data-modal/raw-data-modal.tsx +++ b/src/client/modals/raw-data-modal/raw-data-modal.tsx @@ -25,6 +25,7 @@ import { Essence, Stage, DataCube, Timekeeper } from '../../../common/models/ind import { Fn, makeTitle, arraySum } from '../../../common/utils/general/general'; import { download, makeFileName } from '../../utils/download/download'; +import { QueryRunner } from '../../utils/query-runner/query-runner'; import { formatFilterClause } from '../../../common/utils/formatter/formatter'; import { classNames } from '../../utils/dom/dom'; import { getVisibleSegments } from '../../utils/sizing/sizing'; @@ -108,7 +109,7 @@ export class RawDataModal extends React.Component { if (!this.mounted) return; @@ -153,7 +154,7 @@ export class RawDataModal extends React.Component { const name = attribute.name; @@ -268,7 +269,7 @@ export class RawDataModal extends React.Component { + label: (n: number) => string; + callback?: (suggestions?: T[]) => void; + closePromise?: (suggestions?: T[]) => Q.Promise; + loadingMessage?: string; +} -export type Option = Dimension | Measure | DataCube; -export interface Suggestion { - option: Option; +export interface Suggestion { + option: T; selected: boolean; label: string; } -export interface SuggestionModalProps extends React.Props { - onAdd: (suggestions: Option[]) => void; +export interface SuggestionModalProps extends React.Props { + onOk: SuggestionModalAction; + onDoNothing: SuggestionModalAction; + onAlternateView?: SuggestionModalAction; + + suggestions: ListItem[]; + onClose: () => void; - getLabel: (o: Option) => string; - getOptions: () => Option[]; + title: string; - okLabel?: (c: number) => string; - cancelLabel?: string; + explanation?: (c: number) => string; + + loadingState?: LoadingMessageState; } -export interface SuggestionModalState { - suggestions: Suggestion[]; +export interface SuggestionModalState extends LoadingMessageState { + selection?: boolean[]; } -export class SuggestionModal extends React.Component { +export class SuggestionModal extends React.Component, SuggestionModalState> { + static specialize() { + return SuggestionModal as { new (): SuggestionModal; }; + } + + static defaultProps = { + loadingState: {} + }; + + private loadingDelegate: LoadingMessageDelegate; + constructor() { super(); - this.state = { - suggestions: [] - }; - } + this.state = {selection: []}; - componentDidMount() { - const { getOptions } = this.props; - if (getOptions) this.initFromProps(this.props); + this.loadingDelegate = new LoadingMessageDelegate(this); } - componentWillReceiveProps(nextProps: SuggestionModalProps) { - if (nextProps) { - this.initFromProps(nextProps); - } + componentDidMount() { + const { suggestions } = this.props; + if (suggestions) this.initFromProps(this.props); } - initFromProps(props: SuggestionModalProps) { - const { getOptions, getLabel } = props; - const suggestions: Option[] = getOptions(); + initFromProps(props: SuggestionModalProps) { this.setState({ - suggestions: suggestions.map((s) => { return { option: s, selected: true, label: getLabel(s) }; }) + selection: props.suggestions.map((s) => true) }); } - onAdd() { - const { onAdd, onClose } = this.props; - const { suggestions } = this.state; - onAdd(suggestions.filter(s => s.selected).map(s => s.option)); - onClose(); - } + onPrimary() { + const { onOk, suggestions, onClose } = this.props; + const { selection } = this.state; - toggleSuggestion(toggle: Suggestion) { - const { suggestions } = this.state; - const toggleName = toggle.option.name; + const selectedSuggestions = suggestions.filter((s, i) => selection[i]).map(s => s.value); + + if (onOk.closePromise) { + this.loadingDelegate.start(onOk.loadingMessage || '…'); + onOk.closePromise(selectedSuggestions).then(() => { + this.loadingDelegate.stop().then(onClose); + }); + } else if (onOk.callback) { + onOk.callback(selectedSuggestions); + } + } - var newStateSuggestions = suggestions.map((suggestion) => { - let { option, selected, label } = suggestion; - return option.name === toggleName ? { option, selected: !selected, label } : suggestion; + selectAll() { + this.setState({ + selection: this.state.selection.map(() => true) }); + } + selectNone() { this.setState({ - suggestions: newStateSuggestions + selection: this.state.selection.map(() => false) }); } renderSuggestions() { - const { suggestions } = this.state; + const { suggestions } = this.props; + const { selection } = this.state; + if (!suggestions) return null; - return suggestions.map((s => { - let { option, selected, label } = s; - let { name } = option; - return
- -
; + + const toggle = (i: number) => { + selection[i] = !selection[i]; + + this.setState({ + selection + }); + }; + + return suggestions.map(((s, i) => { + return
+ +
; })); } + renderSecondaryButton(length: number) { + const { onClose, onDoNothing } = this.props; + + return + +
+
{this.renderSuggestions()} - -
-
+ + {this.renderButtons()} ; } } diff --git a/src/client/swiv-entry.ts b/src/client/swiv-entry.ts index cf2f1c22..39a06b36 100644 --- a/src/client/swiv-entry.ts +++ b/src/client/swiv-entry.ts @@ -58,6 +58,13 @@ require.ensure([ './applications/swiv-application/swiv-application' ], (require) => { const { WallTime } = require('chronoshift'); + + // Init chronoshift + if (!WallTime.rules) { + var tzData = require('chronoshift/lib/walltime/walltime-data.js'); + WallTime.init(tzData.rules, tzData.zones); + } + const { Ajax } = require('./utils/ajax/ajax'); const { AppSettings, Timekeeper } = require('../common/models/index'); const { MANIFESTS } = require('../common/manifests/index'); @@ -72,12 +79,6 @@ require.ensure([ } }); - // Init chronoshift - if (!WallTime.rules) { - var tzData = require('chronoshift/lib/walltime/walltime-data.js'); - WallTime.init(tzData.rules, tzData.zones); - } - ReactDOM.render( React.createElement( SwivApplication, diff --git a/src/client/utils/ajax/ajax.ts b/src/client/utils/ajax/ajax.ts index 132495aa..1ff077c1 100644 --- a/src/client/utils/ajax/ajax.ts +++ b/src/client/utils/ajax/ajax.ts @@ -16,24 +16,9 @@ import * as Q from 'q'; import * as Qajax from 'qajax'; -import { $, Expression, Executor, Dataset, ChainExpression, SplitAction, Environment } from 'plywood'; Qajax.defaults.timeout = 0; // We'll manage the timeout per request. -function getSplitsDescription(ex: Expression): string { - var splits: string[] = []; - ex.forEach((ex) => { - if (ex instanceof ChainExpression) { - ex.actions.forEach((action) => { - if (action instanceof SplitAction) { - splits.push(action.firstSplitExpression().toString()); - } - }); - } - }); - return splits.join(';'); -} - var reloadRequested = false; function reload() { if (reloadRequested) return; @@ -50,7 +35,7 @@ function parseOrNull(json: any): any { } export interface AjaxOptions { - method: 'GET' | 'POST'; + method: 'GET' | 'POST' | 'PUT'; url: string; data?: any; } @@ -64,10 +49,10 @@ export class Ajax { static query(options: AjaxOptions): Q.Promise { var data = options.data; - if (data) { + /*if (data) { if (Ajax.version) data.version = Ajax.version; if (Ajax.settingsVersionGetter) data.settingsVersion = Ajax.settingsVersionGetter(); - } + }*/ return Qajax({ method: options.method, @@ -81,7 +66,7 @@ export class Ajax { if (res && res.action === 'update' && Ajax.onUpdate) Ajax.onUpdate(); return res; }) - .catch((xhr: XMLHttpRequest | Error): Dataset => { + .catch((xhr: XMLHttpRequest | Error): any => { if (!xhr) return null; // TS needs this if (xhr instanceof Error) { throw new Error('client timeout'); @@ -101,17 +86,4 @@ export class Ajax { }); } - static queryUrlExecutorFactory(name: string, url: string): Executor { - return (ex: Expression, env: Environment = {}) => { - return Ajax.query({ - method: "POST", - url: url + '?by=' + getSplitsDescription(ex), - data: { - dataCube: name, - expression: ex.toJS(), - timezone: env ? env.timezone : null - } - }).then((res) => Dataset.fromJS(res.result)); - }; - } } diff --git a/src/client/utils/query-runner/query-runner.ts b/src/client/utils/query-runner/query-runner.ts new file mode 100644 index 00000000..a060c2ec --- /dev/null +++ b/src/client/utils/query-runner/query-runner.ts @@ -0,0 +1,53 @@ +/* + * Copyright 2015-2016 Imply Data, Inc. + * + * Licensed 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 * as Q from 'q'; +import { Timezone } from 'chronoshift'; +import { Expression, Dataset, ChainExpression, SplitAction } from 'plywood'; +import { DataCube } from "../../../common/models/data-cube/data-cube"; +import { Ajax } from '../ajax/ajax'; + + +function getSplitsDescription(ex: Expression): string { + var splits: string[] = []; + ex.forEach((ex) => { + if (ex instanceof ChainExpression) { + ex.actions.forEach((action) => { + if (action instanceof SplitAction) { + splits.push(action.firstSplitExpression().toString()); + } + }); + } + }); + return splits.join(';'); +} + + +export class QueryRunner { + static URL = 'plywood'; + + static fetch(dataCube: DataCube, expression: Expression, timezone: Timezone): Q.Promise { + return Ajax.query({ + method: "POST", + url: QueryRunner.URL + '?by=' + getSplitsDescription(expression), + data: { + dataCube: dataCube.name, + expression: expression.toJS(), + timezone + } + }).then((res) => Dataset.fromJS(res.result)); + } +} diff --git a/src/client/utils/styles/_button.scss b/src/client/utils/styles/_button.scss index 8b284f7e..c420acb9 100644 --- a/src/client/utils/styles/_button.scss +++ b/src/client/utils/styles/_button.scss @@ -121,3 +121,16 @@ background-color: $highlight; } } + +%button-light-action { + @extend %button-light; + font-size: 12px; + text-transform: uppercase; + color: $brand; + padding: 6px; + border-radius: 2px; + + &:not(:last-child) { + margin-right: 15px; + } +} diff --git a/src/client/views/collection-view/collection-view.tsx b/src/client/views/collection-view/collection-view.tsx index 89f4710c..e6349799 100644 --- a/src/client/views/collection-view/collection-view.tsx +++ b/src/client/views/collection-view/collection-view.tsx @@ -101,7 +101,7 @@ export class CollectionView extends React.Component { refreshMaxTime() { var { essence, timekeeper } = this.state; var { dataCube } = essence; - this.setState({ updatingMaxTime: true }); - DataCube.queryMaxTime(dataCube) + var maxTimeQuery = dataCube.getMaxTimeQuery(); + if (!maxTimeQuery) return; + + this.setState({ updatingMaxTime: true }); + QueryRunner.fetch(dataCube, maxTimeQuery, Timezone.UTC) + .then(DataCube.processMaxTimeQuery) .then((updatedMaxTime) => { if (!this.mounted) return; this.setState({ @@ -460,7 +465,7 @@ export class CubeView extends React.Component { resize={this.globalResizeListener.bind(this)} /> {headerBar} -
+
{ const { essence } = this.state; var newEssence = linkTile.essence; - if (essence.getTimeAttribute()) { + if (essence.dataCube.getPrimaryTimeExpression()) { newEssence = newEssence.changeTimeSelection(essence.getTimeSelection()); } diff --git a/src/client/views/settings-view/cluster-edit/cluster-edit.tsx b/src/client/views/settings-view/cluster-edit/cluster-edit.tsx index f31f57cb..255835c3 100644 --- a/src/client/views/settings-view/cluster-edit/cluster-edit.tsx +++ b/src/client/views/settings-view/cluster-edit/cluster-edit.tsx @@ -17,19 +17,16 @@ require('./cluster-edit.css'); import * as React from 'react'; -import { List } from 'immutable'; -import { Fn, pluralIfNeeded } from '../../../../common/utils/general/general'; +import * as Q from 'q'; + +import { Fn, pluralIfNeeded, makeTitle } from '../../../../common/utils/general/general'; import { classNames } from '../../../utils/dom/dom'; -import { firstUp, IP_REGEX, NUM_REGEX } from '../../../../common/utils/string/string'; +import { NUM_REGEX } from '../../../../common/utils/string/string'; import { STRINGS } from '../../../config/constants'; -import { FormLabel } from '../../../components/form-label/form-label'; -import { Button } from '../../../components/button/button'; -import { ImmutableInput } from '../../../components/immutable-input/immutable-input'; -import { ImmutableDropdown } from '../../../components/immutable-dropdown/immutable-dropdown'; -import { SuggestionModal } from "../../../modals/suggestion-modal/suggestion-modal"; +import { FormLabel, Button, ImmutableInput, ImmutableDropdown, GlobalEventListener } from '../../../components/index'; -import { ImmutableFormDelegate, ImmutableFormState } from '../../../utils/immutable-form-delegate/immutable-form-delegate'; +import { ImmutableFormDelegate, ImmutableFormState } from '../../../delegates/index'; import { AppSettings, Cluster, ListItem, DataCube } from '../../../../common/models/index'; @@ -37,11 +34,10 @@ import { CLUSTER as LABELS } from '../../../../common/models/labels'; export interface ClusterEditProps extends React.Props { cluster?: Cluster; - onSave: (newCluster: Cluster) => void; + onSave: (newCluster: Cluster) => Q.Promise; isNewCluster?: boolean; onCancel?: () => void; getSuggestedCubes?: () => DataCube[]; - addCubes?: (cubes: DataCube[]) => void; } export interface ClusterEditState extends ImmutableFormState { @@ -96,33 +92,12 @@ export class ClusterEdit extends React.Component `${m.title}`} - getOptions={getSuggestedCubes} - title={STRINGS.createCubesFromCluster} - cancelLabel={STRINGS.noIllCreateThem} - okLabel={(n: number) => `${STRINGS.create} ${pluralIfNeeded(n, 'cube')}`} - />; - } - renderGeneral(): JSX.Element { const { newInstance, errors } = this.state; var makeLabel = FormLabel.simpleGenerator(LABELS, errors); var makeTextInput = ImmutableInput.simpleGenerator(newInstance, this.delegate.onChange); - var makeDropDownInput = ImmutableDropdown.simpleGenerator(newInstance, this.delegate.onChange); + var makeDropdownInput = ImmutableDropdown.simpleGenerator(newInstance, this.delegate.onChange); var needsAuth = ['mysql', 'postgres'].indexOf(newInstance.type) > -1; @@ -131,10 +106,10 @@ export class ClusterEdit extends React.Component {return {value: type, label: type}; }))} + {makeDropdownInput('type', Cluster.TYPE_VALUES.map(type => {return {value: type, label: type}; }))} {makeLabel('timeout')} {makeTextInput('timeout', NUM_REGEX)} @@ -154,6 +129,10 @@ export class ClusterEdit extends React.Component; } + onEnter() { + if (this.state.canSave) this.save(); + } + renderButtons(): JSX.Element { const { cluster, isNewCluster } = this.props; const { canSave, newInstance } = this.state; @@ -161,7 +140,7 @@ export class ClusterEdit extends React.Component; @@ -170,7 +149,7 @@ export class ClusterEdit extends React.Component; if (!isNewCluster && !hasChanged) { @@ -191,16 +170,17 @@ export class ClusterEdit extends React.Component +
{isNewCluster ? null @@ -217,8 +197,6 @@ export class ClusterEdit extends React.Component {this.renderGeneral()}
- {showCreateCubesModal ? this.renderCreateCubesModal() : null} -
; } } diff --git a/src/client/views/settings-view/data-cube-edit/data-cube-edit.tsx b/src/client/views/settings-view/data-cube-edit/data-cube-edit.tsx index b556b83e..9d634b01 100644 --- a/src/client/views/settings-view/data-cube-edit/data-cube-edit.tsx +++ b/src/client/views/settings-view/data-cube-edit/data-cube-edit.tsx @@ -18,23 +18,27 @@ require('./data-cube-edit.css'); import * as React from 'react'; import { List } from 'immutable'; -import { AttributeInfo } from 'plywood'; +import { AttributeInfo, Attributes, findByName, Nameable } from 'plywood'; import { classNames } from '../../../utils/dom/dom'; +import { Ajax } from '../../../utils/ajax/ajax'; import { generateUniqueName } from '../../../../common/utils/string/string'; -import { ImmutableUtils } from "../../../../common/utils/immutable-utils/immutable-utils"; +import { pluralIfNeeded } from "../../../../common/utils/general/general"; +import { Notifier } from '../../../components/notifications/notifications'; import { Duration, Timezone } from 'chronoshift'; import { DATA_CUBES_STRATEGIES_LABELS, STRINGS } from '../../../config/constants'; -import { SvgIcon, FormLabel, Button, SimpleList, ImmutableInput, ImmutableList, ImmutableDropdown } from '../../../components/index'; +import { SvgIcon, FormLabel, Button, SimpleTableColumn, SimpleTable, ImmutableInput, ImmutableList, ImmutableDropdown } from '../../../components/index'; import { DimensionModal, MeasureModal, SuggestionModal } from '../../../modals/index'; -import { AppSettings, ListItem, Cluster, DataCube, Dimension, DimensionJS, Measure, MeasureJS } from '../../../../common/models/index'; +import { AppSettings, ListItem, Cluster, DataCube, Dimension, DimensionJS, Measure, MeasureJS, Customization } from '../../../../common/models/index'; import { DATA_CUBE as LABELS } from '../../../../common/models/labels'; -import { ImmutableFormDelegate, ImmutableFormState } from '../../../utils/immutable-form-delegate/immutable-form-delegate'; +import { ImmutableFormDelegate, ImmutableFormState } from '../../../delegates/index'; + +import { DataTable } from '../data-table/data-table'; export interface DataCubeEditProps extends React.Props { isNewDataCube?: boolean; @@ -47,23 +51,34 @@ export interface DataCubeEditProps extends React.Props { export interface DataCubeEditState extends ImmutableFormState { tab?: any; - showDimensionsSuggestion?: boolean; - showMeasuresSuggestion?: boolean; + modal?: Modal; } export interface Tab { label: string; value: string; render: () => JSX.Element; + icon: string; } +export interface Modal extends Nameable { + name: string; + render: (arg?: any) => JSX.Element; + active?: boolean; +} export class DataCubeEdit extends React.Component { private tabs: Tab[] = [ - {label: 'General', value: 'general', render: this.renderGeneral}, - {label: 'Attributes', value: 'attributes', render: this.renderAttributes}, - {label: 'Dimensions', value: 'dimensions', render: this.renderDimensions}, - {label: 'Measures', value: 'measures', render: this.renderMeasures} + { label: 'General', value: 'general', render: this.renderGeneral, icon: require(`../../../icons/full-settings.svg`) }, + { label: 'Data', value: 'data', render: this.renderData, icon: require(`../../../icons/data.svg`) }, + { label: 'Dimensions', value: 'dimensions', render: this.renderDimensions, icon: require(`../../../icons/full-cube.svg`) }, + { label: 'Measures', value: 'measures', render: this.renderMeasures, icon: require(`../../../icons/measures.svg`) }, + { label: 'Other', value: 'other', render: this.renderOther, icon: require(`../../../icons/full-more.svg`) } + ]; + + private modals: Modal[] = [ + { name: 'dimensions', render: this.renderDimensionSuggestions }, + { name: 'measures', render: this.renderMeasureSuggestions } ]; private delegate: ImmutableFormDelegate; @@ -86,12 +101,11 @@ export class DataCubeEdit extends React.Component tab.value === props.tab)[0], - showDimensionsSuggestion: false, - showMeasuresSuggestion: false + modal: null }); } @@ -107,11 +121,14 @@ export class DataCubeEdit extends React.Component { - return ; + />; }); } @@ -137,31 +154,24 @@ export class DataCubeEdit extends React.Component { - return {value, label: labels[value]}; - })); - } - renderGeneral(): JSX.Element { const { clusters } = this.props; const { newInstance, errors } = this.state; var makeLabel = FormLabel.simpleGenerator(LABELS, errors); var makeTextInput = ImmutableInput.simpleGenerator(newInstance, this.delegate.onChange); - var makeDropDownInput = ImmutableDropdown.simpleGenerator(newInstance, this.delegate.onChange); + var makeDropdownInput = ImmutableDropdown.simpleGenerator(newInstance, this.delegate.onChange); var possibleClusters = [ { value: 'native', label: 'Load a file and serve it natively' } ].concat(clusters.map((cluster) => { - return { value: cluster.name, label: cluster.name }; + return { value: cluster.name, label: cluster.title }; })); + var timezones = Customization.DEFAULT_TIMEZONES.map((tz) => { + return { label: tz.toString(), value: tz }; + }); + return
{makeLabel('title')} {makeTextInput('title', /.*/, true)} @@ -170,45 +180,44 @@ export class DataCubeEdit extends React.Component value ? value.toJS() : undefined} - stringToValue={(str: string) => str ? Timezone.fromJS(str) : undefined} - /> - + {makeDropdownInput('defaultTimezone', timezones) } ; } - renderAttributes(): JSX.Element { - const { newInstance, errors } = this.state; + // --------------------------------------------------- - var makeLabel = FormLabel.simpleGenerator(LABELS, errors); + renderData(): JSX.Element { + const { newInstance } = this.state; - return
+ const onChange = (newDataCube: DataCube) => { + this.setState({ + newInstance: newDataCube + }); + }; - {makeLabel('attributeOverrides')} - ; + } - valueToString={(value: AttributeInfo[]) => value ? JSON.stringify(AttributeInfo.toJSs(value), null, 2) : undefined} - stringToValue={(str: string) => str ? AttributeInfo.fromJSs(JSON.parse(str)) : undefined} - type="textarea" - /> + openModal(name: string) { + this.setState({ + modal: findByName(this.modals, name) + }); + } - ; + closeModal() { + this.setState({ + modal: null + }); } + // --------------------------------------------------- + renderDimensions(): JSX.Element { const { newInstance } = this.state; @@ -219,7 +228,9 @@ export class DataCubeEdit extends React.Component ; + const getModal = (item: Dimension) => { + return ; + }; const getNewItem = () => Dimension.fromJS({ name: generateUniqueName('d', name => !newInstance.dimensions.find(m => m.name === name)), @@ -230,7 +241,7 @@ export class DataCubeEdit extends React.Component; } - toggleDimensionsSuggestions() { - const { showDimensionsSuggestion } = this.state; - this.setState({ - showDimensionsSuggestion: !showDimensionsSuggestion - }); - } - - addToCube(property: string, additionalValues: (Dimension | Measure)[]) { + addDimensions(extraDimensions: Dimension[]) { const { newInstance } = this.state; - var newValues = additionalValues.concat((newInstance as any)[property].toArray()); this.setState({ - newInstance: ImmutableUtils.setProperty(newInstance, property, List(newValues)) + newInstance: newInstance.appendDimensions(extraDimensions) }); } renderDimensionSuggestions() { const { newInstance } = this.state; - return `${d.title} (${d.formula})`} - getOptions={newInstance.getSuggestedDimensions.bind(newInstance)} - title={`${STRINGS.dimension} ${STRINGS.suggestion}`} - />; - } - toggleMeasuresSuggestions() { - const { showMeasuresSuggestion } = this.state; - this.setState({ - showMeasuresSuggestion: !showMeasuresSuggestion + const onOk = { + label: (n: number) => `${STRINGS.add} ${pluralIfNeeded(n, 'dimension')}`, + callback: (newDimensions: Dimension[]) => { + this.addDimensions(newDimensions); + this.closeModal(); + } + }; + + const onDoNothing = { + label: () => STRINGS.cancel, + callback: this.closeModal.bind(this) + }; + + const suggestions = newInstance.getSuggestedDimensions().map(d => { + return {label: `${d.title} (${d.formula})`, value: d}; }); + + const DimensionSuggestionModal = SuggestionModal.specialize(); + + return ; } + // --------------------------------------------------- + renderMeasures(): JSX.Element { var { newInstance } = this.state; @@ -289,7 +307,7 @@ export class DataCubeEdit extends React.Component measure.name === defaultSortMeasure)) { - newInstance = newInstance.changeDefaultSortMeasure(newMeasures.get(0).name); + newInstance = newInstance.changeDefaultSortMeasure(null); } } @@ -299,7 +317,9 @@ export class DataCubeEdit extends React.Component ; + const getModal = (item: Measure) => { + return ; + }; const getNewItem = () => Measure.fromJS({ name: generateUniqueName('m', name => !newInstance.measures.find(m => m.name === name)), @@ -310,7 +330,7 @@ export class DataCubeEdit extends React.Component; } + addMeasures(extraMeasures: Measure[]) { + const { newInstance } = this.state; + this.setState({ + newInstance: newInstance.appendMeasures(extraMeasures) + }); + } + renderMeasureSuggestions() { const { newInstance } = this.state; - return `${m.title} (${m.formula})`} - getOptions={newInstance.getSuggestedMeasures.bind(newInstance)} - title={`${STRINGS.measure} ${STRINGS.suggestion}`} + + const onOk = { + label: (n: number) => `${STRINGS.add} ${pluralIfNeeded(n, 'measure')}`, + callback: (newMeasures: Measure[]) => { + this.addMeasures(newMeasures); + this.closeModal(); + } + }; + + const onDoNothing = { + label: () => STRINGS.cancel, + callback: this.closeModal.bind(this) + }; + + const suggestions = newInstance.getSuggestedMeasures().map(d => { + return {label: `${d.title} (${d.formula})`, value: d}; + }); + + const MeasureSuggestionModal = SuggestionModal.specialize(); + + return ; } + // --------------------------------------------------- + + renderOther(): JSX.Element { + const { newInstance, errors } = this.state; + + var makeLabel = FormLabel.simpleGenerator(LABELS, errors); + + return
+ + {makeLabel('options')} + value ? JSON.stringify(value, null, 2) : undefined} + stringToValue={(str: string) => str ? JSON.parse(str) : undefined} + type="textarea" + /> + + ; + } + renderButtons(): JSX.Element { const { dataCube, isNewDataCube } = this.props; const { canSave, newInstance } = this.state; @@ -380,7 +450,7 @@ export class DataCubeEdit extends React.Component
- {showDimensionsSuggestion ? this.renderDimensionSuggestions() : null} - {showMeasuresSuggestion ? this.renderMeasureSuggestions() : null} + { modal ? modal.render.bind(this)() : null }
; } } diff --git a/src/client/views/settings-view/data-table/data-table.scss b/src/client/views/settings-view/data-table/data-table.scss new file mode 100644 index 00000000..618cc293 --- /dev/null +++ b/src/client/views/settings-view/data-table/data-table.scss @@ -0,0 +1,151 @@ +/* + * Copyright 2015-2016 Imply Data, Inc. + * + * Licensed 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 '../../../imports'; + +$header-height: 34px; + +.data-table { + + .header { + height: $header-height; + + .title { + padding: 6px 0; + color: $text-lighter; + font-size: 12px; + text-transform: uppercase; + } + + .actions { + position: absolute; + right: 12px; + top: 0; + + button { + @extend %button-light-action + } + } + } + + .simple-table { + position: absolute; + top: 30px; + bottom: 10px; + left: 0; + right: 0; + width: initial; + height: initial; + + .header { + + .cell { + padding: 10px; + + &.name { + color: $white; + background-color: hsla(201, 80%, 63%, 1.00); + + border-color: hsla(201, 81%, 49%, 1.00); + border-style: solid; + + border-right-width: 1px; + border-bottom-width: 1px; + border-top-width: 1px; + + .label { + display: inline-block; + height: 20px; + padding-top: 5px; + font-size: 14px; + text-transform: initial; + } + + .svg-icon { + position: absolute; + right: 4px; + height: 20px; + top: 12px; + + path { + fill: $white; + } + } + } + + &.type { + color: $text-medium; + background-color: hsla(0, 0%, 93%, 1.00); + + border-color: hsla(0, 0%, 80%, 1.00); + border-style: solid; + + border-right-width: 1px; + border-bottom-width: 1px; + + .label { + display: inline-block; + height: 20px; + padding-top: 3px; + text-transform: initial; + font-size: 14px; + padding-left: 8px; + } + + .svg-icon { + width: 19px; + padding-top: 1px; + + path { + fill: hsla(0, 0%, 70%, 1.00); + } + } + } + } + + &:first-child .cell { + &.name { + border-left-width: 1px; + } + + &.type { + border-left-width: 1px; + } + } + } + + .body { + .row { + .cell { + height: 100%; + + border-right: 1px solid hsla(0, 0%, 87%, 1.00); + + padding: 14px 10px 5px 5px; + + &:first-child { + border-left: 1px solid hsla(0, 0%, 87%, 1.00); + } + } + } + } + } + + .loader { + @include unpin-full(0); + background-color: hsla(0, 0%, 93%, 0.5); + } +} diff --git a/src/client/views/settings-view/data-table/data-table.tsx b/src/client/views/settings-view/data-table/data-table.tsx new file mode 100644 index 00000000..c50e5258 --- /dev/null +++ b/src/client/views/settings-view/data-table/data-table.tsx @@ -0,0 +1,427 @@ +/* + * Copyright 2015-2016 Imply Data, Inc. + * + * Licensed 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. + */ + +require('./data-table.css'); + +import { Ajax } from '../../../utils/ajax/ajax'; +import * as React from 'react'; +import * as ReactDOM from 'react-dom'; +import { AttributeInfo, Attributes, findByName, Dataset } from 'plywood'; + +import { titleCase } from '../../../../common/utils/string/string'; +import { pluralIfNeeded } from "../../../../common/utils/general/general"; +import { generateUniqueName } from '../../../../common/utils/string/string'; + +import { STRINGS } from "../../../config/constants"; + +import { DataCube } from '../../../../common/models/index'; + +import { classNames } from '../../../utils/dom/dom'; + +import { SvgIcon, SimpleTable, SimpleTableColumn, Notifier, Loader } from '../../../components/index'; +import { AttributeModal, SuggestionModal, DataCubeFilterModal } from '../../../modals/index'; + +import { LoadingMessageDelegate, LoadingMessageState } from '../../../delegates/index'; + +const LOADING = { + suggestions: 'Loading suggestions…', + data: 'Loading data…' +}; + +export interface DataTableProps extends React.Props { + dataCube?: DataCube; + onChange?: (newDataCube: DataCube) => void; +} + +export interface DataTableState extends LoadingMessageState { + editedAttribute?: AttributeInfo; + + showSuggestionsModal?: boolean; + attributeSuggestions?: Attributes; + + showAddAttributeModal?: boolean; + + showSubsetFilterModal?: boolean; + + dataset?: Dataset; +} + +export class DataTable extends React.Component { + private mounted: boolean; + + private loadingDelegate: LoadingMessageDelegate; + + constructor() { + super(); + + this.state = { + dataset: null + }; + + this.loadingDelegate = new LoadingMessageDelegate(this); + } + + componentDidMount() { + this.mounted = true; + this.fetchData(this.props.dataCube); + } + + componentWillReceiveProps(nextProps: DataTableProps) { + const { dataCube } = this.props; + + if (!dataCube.equals(nextProps.dataCube)) { + this.fetchData(nextProps.dataCube); + } + } + + componentWillUnmount() { + this.mounted = false; + this.loadingDelegate.unmount(); + } + + fetchData(dataCube: DataCube): void { + this.loadingDelegate.startNow(LOADING.data); + + Ajax.query({ + method: "POST", + url: 'settings/preview', + data: { + dataCube + } + }) + .then( + (resp: any) => { + if (!this.mounted) return; + + this.loadingDelegate.stop(); + this.setState({ + dataset: Dataset.fromJS(resp.dataset) + }); + }, + (error: Error) => { + if (!this.mounted) return; + + this.loadingDelegate.stop(); + } + ); + } + + renderHeader(attribute: AttributeInfo, isPrimary: boolean, column: SimpleTableColumn, hovered: boolean): JSX.Element { + const iconPath = `dim-${attribute.type.toLowerCase().replace('/', '-')}`; + + return
+
+
{attribute.name}
+ +
+
+ +
{titleCase(attribute.type) + (isPrimary ? ' (primary)' : '')}
+
+
; + } + + onHeaderClick(column: SimpleTableColumn) { + this.loadingDelegate.stop(); + + this.setState({ + editedAttribute: column.data + }); + + // Don't sort + return false; + } + + renderEditModal() { + const { dataCube, onChange } = this.props; + const { editedAttribute } = this.state; + + if (!editedAttribute) return null; + + const onClose = () => { + this.setState({ + editedAttribute: null + }); + }; + + const onSave = (newAttribute: AttributeInfo) => { + onChange(dataCube.updateAttribute(newAttribute)); + onClose(); + }; + + const onRemove = () => { + onClose(); + this.askToRemoveAttribute(editedAttribute); + }; + + return ; + } + + askToRemoveAttribute(attribute: AttributeInfo) { + var { dataCube, onChange } = this.props; + + const dependantDimensions = dataCube.getDimensionsForAttribute(attribute.name); + const dependantMeasures = dataCube.getMeasuresForAttribute(attribute.name); + const dependants = dependantDimensions.length + dependantMeasures.length; + + const remove = () => { + onChange(dataCube.removeAttribute(attribute.name)); + Notifier.removeQuestion(); + }; + + var message: string | JSX.Element; + + if (dependants > 0) { + message =
+

This attribute has {pluralIfNeeded(dependantDimensions.length, 'dimension')} + and {pluralIfNeeded(dependantMeasures.length, 'measure')} relying on it.

+

Removing it will remove them as well.

+
+ {dependantDimensions.map(d =>

{d.title}

)} + {dependantMeasures.map(m =>

{m.title}

)} +
+
; + } else { + message = 'This cannot be undone.'; + } + + Notifier.ask({ + title: `Remove the attribute "${attribute.name}"?`, + message, + choices: [ + {label: 'Remove', callback: remove, type: 'warn'}, + {label: 'Cancel', callback: Notifier.removeQuestion, type: 'secondary'} + ], + onClose: Notifier.removeQuestion + }); + } + + getColumns(): SimpleTableColumn[] { + const dataCube = this.props.dataCube as DataCube; + const primaryTimeAttribute = dataCube.getPrimaryTimeAttribute(); + + return dataCube.attributes.map(a => { + let isPrimary = a.name === primaryTimeAttribute; + return { + label: a.name, + data: a, + field: a.name, + width: 170, + render: this.renderHeader.bind(this, a, isPrimary) + }; + }); + } + + renderFiltersModal() { + const { dataCube, onChange } = this.props; + const { showSubsetFilterModal } = this.state; + if (!showSubsetFilterModal) return null; + + const onClose = () => { + this.setState({ + showSubsetFilterModal: false + }); + }; + + const onSave = (dataCube: DataCube) => { + onChange(dataCube); + onClose(); + }; + + return ; + } + + onFiltersClick() { + this.setState({ + showSubsetFilterModal: true + }); + } + + fetchSuggestions() { + const { dataCube } = this.props; + this.loadingDelegate.startNow(LOADING.suggestions); + + Ajax.query({ + method: "POST", + url: 'settings/attributes', + data: { + clusterName: dataCube.clusterName, + source: dataCube.source + } + }) + .then( + (resp) => { + if (!this.mounted) return; + + this.loadingDelegate.stop(); + this.setState({ + attributeSuggestions: dataCube.filterAttributes(AttributeInfo.fromJSs(resp.attributes)) + }); + }, + (xhr: XMLHttpRequest) => { + if (!this.mounted) return; + + this.loadingDelegate.stop(); + Notifier.failure('Woops', 'Something bad happened'); + } + ) + .done(); + } + + openSuggestionsModal() { + this.loadingDelegate.stop(); + + this.setState({ + showSuggestionsModal: true + }); + + this.fetchSuggestions(); + } + + closeAttributeSuggestions() { + this.setState({ + attributeSuggestions: null, + showSuggestionsModal: false, + showAddAttributeModal: false + }); + } + + renderAttributeSuggestions() { + const { onChange, dataCube } = this.props; + const { attributeSuggestions, showSuggestionsModal, isLoading, loadingMessage } = this.state; + + if (!showSuggestionsModal) return null; + + + const getAttributeLabel = (a: AttributeInfo) => { + var special = a.special ? ` [${a.special}]` : ''; + return `${a.name} as ${a.type}${special}`; + }; + + const suggestions = (attributeSuggestions || []).map(a => { + return {label: getAttributeLabel(a), value: a}; + }); + + const onOk = { + label: (n: number) => `${STRINGS.add} ${pluralIfNeeded(n, 'attribute')}`, + callback: (extraAttributes: Attributes) => { + onChange(dataCube.changeAttributes(dataCube.attributes.concat(extraAttributes).sort())); + this.closeAttributeSuggestions(); + } + }; + + const onDoNothing = { + label: () => STRINGS.cancel, + callback: this.closeAttributeSuggestions.bind(this) + }; + + const onAlternateView = { + label: () => STRINGS.orAddASpecificAttribute, + callback: () => { + this.setState({ + showSuggestionsModal: false, + showAddAttributeModal: true + }); + } + }; + + const AttributeSuggestionModal = SuggestionModal.specialize(); + + return ; + } + + renderAttributeAdd() { + const { onChange, dataCube } = this.props; + const { showAddAttributeModal } = this.state; + + if (!showAddAttributeModal) return null; + + const attributes = dataCube.attributes; + + const attribute = new AttributeInfo({ + name: generateUniqueName('a', (name) => attributes.filter(a => a.name === name).length === 0 ), + type: 'STRING' + }); + + const onSave = (attribute: AttributeInfo) => { + onChange(dataCube.changeAttributes(dataCube.attributes.concat([attribute]).sort())); + this.closeAttributeSuggestions(); + }; + + const onAlternateClick = () => { + this.setState({ + showSuggestionsModal: true, + showAddAttributeModal: false + }); + }; + + return ; + } + + render() { + const { dataset, isLoading, loadingMessage } = this.state; + + return
+
+
{STRINGS.attributes}
+
+ + +
+
+ + { this.renderEditModal() } + { this.renderAttributeSuggestions() } + { this.renderAttributeAdd() } + { this.renderFiltersModal() } + { isLoading && loadingMessage !== LOADING.suggestions ? : null } +
; + } +} diff --git a/src/client/views/settings-view/other/other.scss b/src/client/views/settings-view/other/other.scss new file mode 100644 index 00000000..45028f9a --- /dev/null +++ b/src/client/views/settings-view/other/other.scss @@ -0,0 +1,21 @@ +/* + * Copyright 2015-2016 Imply Data, Inc. + * + * Licensed 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 '../../../imports'; + +.other { + height: 100%; +} diff --git a/src/client/views/settings-view/other/other.tsx b/src/client/views/settings-view/other/other.tsx new file mode 100644 index 00000000..313460f6 --- /dev/null +++ b/src/client/views/settings-view/other/other.tsx @@ -0,0 +1,99 @@ +/* + * Copyright 2015-2016 Imply Data, Inc. + * + * Licensed 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. + */ + +require('./other.css'); + +import { Timezone } from 'chronoshift'; +import * as React from 'react'; +import { classNames } from '../../../utils/dom/dom'; + +import { SvgIcon } from '../../../components/svg-icon/svg-icon'; +import { FormLabel } from '../../../components/form-label/form-label'; +import { Button } from '../../../components/button/button'; +import { ImmutableInput } from '../../../components/immutable-input/immutable-input'; + +import { GENERAL as LABELS } from '../../../../common/models/labels'; + +import { AppSettings, AppSettingsJS } from '../../../../common/models/index'; + +import { ImmutableFormDelegate, ImmutableFormState } from '../../../delegates/index'; + +export interface OtherProps extends React.Props { + settings?: AppSettings; + onSave?: (settings: AppSettings) => void; +} + +export class Other extends React.Component> { + + private delegate: ImmutableFormDelegate; + + constructor() { + super(); + + this.delegate = new ImmutableFormDelegate(this); + } + + componentWillReceiveProps(nextProps: OtherProps) { + if (nextProps.settings) this.setState({ + newInstance: nextProps.settings, + errors: {} + }); + } + + save() { + if (this.props.onSave) { + this.props.onSave(this.state.newInstance); + } + } + + parseTimezones(str: string): Timezone[] { + return str.split(/\s*,\s*/) + .map(Timezone.fromJS); + } + + render() { + const { canSave, newInstance, errors } = this.state; + + if (!newInstance) return null; + + var makeLabel = FormLabel.simpleGenerator(LABELS, errors); + var makeTextInput = ImmutableInput.simpleGenerator(newInstance, this.delegate.onChange); + + return
+
+
Other
+ {canSave ?
+
+
+ {makeLabel('customization.title')} + {makeTextInput('customization.title', /^.+$/, true)} + + {makeLabel('customization.timezones')} + value ? value.join(', ') : undefined} + stringToValue={this.parseTimezones.bind(this)} + /> + + +
+
; + } +} diff --git a/src/client/views/settings-view/settings-view.tsx b/src/client/views/settings-view/settings-view.tsx index de3fe12d..da633e51 100644 --- a/src/client/views/settings-view/settings-view.tsx +++ b/src/client/views/settings-view/settings-view.tsx @@ -19,11 +19,11 @@ require('./settings-view.css'); import * as React from 'react'; import * as Q from 'q'; -import { $, Expression, Executor, Dataset } from 'plywood'; +import { AttributeInfo } from 'plywood'; import { DataCube, User, Customization } from '../../../common/models/index'; import { MANIFESTS } from '../../../common/manifests/index'; import { STRINGS } from '../../config/constants'; -import { Fn } from '../../../common/utils/general/general'; +import { Fn, pluralIfNeeded } from '../../../common/utils/general/general'; import { Ajax } from '../../utils/ajax/ajax'; import { indexByAttribute } from '../../../common/utils/array/array'; import { ImmutableUtils } from '../../../common/utils/immutable-utils/immutable-utils'; @@ -33,17 +33,17 @@ import { Notifier } from '../../components/notifications/notifications'; import { Button, SvgIcon, Router, Route } from '../../components/index'; -import { ClusterSeedModal, DataCubeSeedModal } from '../../modals/index'; +import { ClusterSeedModal, DataCubeSeedModal, SuggestionModal } from '../../modals/index'; import { AppSettings, Cluster } from '../../../common/models/index'; import { SettingsHeaderBar } from './settings-header-bar/settings-header-bar'; -import { General } from './general/general'; + import { Clusters } from './clusters/clusters'; import { ClusterEdit } from './cluster-edit/cluster-edit'; import { DataCubes } from './data-cubes/data-cubes'; import { DataCubeEdit } from './data-cube-edit/data-cube-edit'; - +import { Other } from './other/other'; export interface SettingsViewProps extends React.Props { user?: User; @@ -52,41 +52,87 @@ export interface SettingsViewProps extends React.Props { onSettingsChange?: (settings: AppSettings) => void; } +export interface TempCubes { + names: string[]; + cluster: Cluster; +} + export interface SettingsViewState { settings?: AppSettings; breadCrumbs?: string[]; tempCluster?: Cluster; + tempClusterSources?: TempCubes; tempDataCube?: DataCube; } +const PATHS = { + clusters: 'clusters', + dataCubes: 'data-cubes', + newDataCube: 'new-data-cube', + other: 'other' +}; + const VIEWS = [ - {label: 'General', value: 'general', svg: require('../../icons/full-settings.svg')}, - {label: 'Clusters', value: 'clusters', svg: require('../../icons/full-cluster.svg')}, - {label: 'Data Cubes', value: 'data-cubes', svg: require('../../icons/full-cube.svg')} + {label: 'Clusters', value: PATHS.clusters, svg: require('../../icons/full-cluster.svg')}, + {label: 'Data Cubes', value: PATHS.dataCubes, svg: require('../../icons/full-cube.svg')}, + {label: 'Other', value: PATHS.other, svg: require('../../icons/full-more.svg')} ]; +function autoFillDataCube(dataCube: DataCube, cluster: Cluster): Q.Promise { + return Ajax.query({ + method: "POST", + url: 'settings/attributes', + data: { + cluster: cluster, + source: dataCube.source + } + }) + .then((resp) => { + var attributes = AttributeInfo.fromJSs(resp.attributes); + return dataCube.fillAllFromAttributes(attributes); + }); +} + export class SettingsView extends React.Component { + private mounted = false; + constructor() { super(); this.state = {}; } componentDidMount() { + this.mounted = true; + Ajax.query({ method: "GET", url: 'settings' }) .then( (resp) => { + if (!this.mounted) return; this.setState({ settings: AppSettings.fromJS(resp.appSettings, { visualizations: MANIFESTS }) }); }, - (xhr: XMLHttpRequest) => Notifier.failure('Sorry', `The settings couldn't be loaded`) + (e: Error) => { + if (!this.mounted) return; + Notifier.failure('Sorry', `The settings couldn't be loaded ${e.message}`); + } ).done(); } + componentWillUnmount() { + this.mounted = false; + } + onSave(settings: AppSettings, okMessage?: string): Q.Promise { const { onSettingsChange } = this.props; + Ajax.query({ + method: "PUT", + url: document.referrer + 'api/v1/dashboards/1', + data: {name: 'dashboard', description: 'dashboard', data: JSON.stringify(settings)} + }); + return Ajax.query({ method: "POST", url: 'settings', @@ -95,15 +141,16 @@ export class SettingsView extends React.Component { this.setState({settings}); - Notifier.success(okMessage ? okMessage : 'Settings saved'); + if (okMessage !== null) { + Notifier.clear(); + Notifier.success(okMessage || 'Settings saved'); + } if (onSettingsChange) { - onSettingsChange(settings.toClientSettings().attachExecutors((dataCube: DataCube) => { - return Ajax.queryUrlExecutorFactory(dataCube.name, 'plywood'); - })); + onSettingsChange(settings.toClientSettings()); } }, - (xhr: XMLHttpRequest) => Notifier.failure('Woops', 'Something bad happened') + (e: Error) => Notifier.failure('Woops', 'Something bad happened') ); } @@ -111,6 +158,7 @@ export class SettingsView extends React.Component autoFillDataCube(cube, cluster))) + .then(this.addDataCubes.bind(this)) + .then( + () => { + Notifier.clear(); + Notifier.success('Data cubes created', { + label: 'View first one', + callback: () => window.location.hash = `#settings/${PATHS.dataCubes}/${cubes[0].name}` + }); + }, + (e: Error) => { + console.error(e); + Notifier.failure('Woops', 'Something bad happened'); + } + ); + } + + renderCreateCubesModal(): JSX.Element { + const { settings, tempClusterSources } = this.state; + const { names, cluster } = tempClusterSources; + + const CubesSuggestionModal = SuggestionModal.specialize(); + + const closeModal = () => this.setState({tempClusterSources: null}); + + const onOk = { + label: (n: number) => `${STRINGS.create} ${pluralIfNeeded(n, 'data cube')}`, + closePromise: (cubes: DataCube[]) => this.addDependantCubes(cluster, cubes), + loadingMessage: 'Creating data cubes…' + }; + + const onDoNothing = { + label: () => STRINGS.noIllCreateThem, + callback: closeModal + }; + + const suggestions = tempClusterSources.names.map((source, i) => { + // ToDo: make the name generation here better; + let cube = DataCube.fromClusterAndSource(`${cluster.name}_${i}`, cluster, source); + return {label: cube.title, value: cube}; + }); + + return ; + } + // !-- Cluster creation flow // -- DataCubes creation flow @@ -191,13 +286,17 @@ export class SettingsView extends React.Component { if (key !== 'clusterId') return {key, value}; @@ -243,27 +356,29 @@ export class SettingsView extends React.Component + return
+ -
- {this.renderLeftButtons(breadCrumbs)} -
-
+ { hasLeftButtons + ?
+ {this.renderLeftButtons(breadCrumbs)} +
+ : null + } - +
- - - + - + + { tempClusterSources ? this.renderCreateCubesModal() : null } { tempCluster ? null : } @@ -272,10 +387,8 @@ export class SettingsView extends React.Component : - + - + { tempDataCube ? null : } { tempDataCube @@ -318,6 +431,11 @@ export class SettingsView extends React.Component + + + + +
; diff --git a/src/client/visualizations/base-visualization/base-visualization.tsx b/src/client/visualizations/base-visualization/base-visualization.tsx index af19b5a0..869c2db2 100644 --- a/src/client/visualizations/base-visualization/base-visualization.tsx +++ b/src/client/visualizations/base-visualization/base-visualization.tsx @@ -19,6 +19,7 @@ require('./base-visualization.css'); import * as React from 'react'; import { $, ply, Expression, Dataset } from 'plywood'; import { Measure, VisualizationProps, DatasetLoad, Essence, Timekeeper } from '../../../common/models/index'; +import { QueryRunner } from '../../utils/query-runner/query-runner'; import { SPLIT } from '../../config/constants'; @@ -133,7 +134,7 @@ export class BaseVisualization extends React.C let query = this.makeQuery(essence, timekeeper); this.precalculate(this.props, { loading: true }); - essence.dataCube.executor(query, { timezone: essence.timezone }) + QueryRunner.fetch(essence.dataCube, query, essence.timezone) .then( (dataset: Dataset) => { if (!this._isMounted) return; diff --git a/src/common/models/app-settings/app-settings.mocha.ts b/src/common/models/app-settings/app-settings.mocha.ts index 0705bc57..bbdc1979 100644 --- a/src/common/models/app-settings/app-settings.mocha.ts +++ b/src/common/models/app-settings/app-settings.mocha.ts @@ -38,7 +38,7 @@ describe('AppSettings', () => { it("errors if there is no matching cluster", () => { var js = AppSettingsMock.wikiOnlyJS(); js.clusters = []; - expect(() => AppSettings.fromJS(js, context)).to.throw("Can not find cluster 'druid' for data cube 'wiki'"); + expect(() => AppSettings.fromJS(js, context)).to.throw("data cube 'wiki' refers to an unknown cluster 'druid'"); }); }); @@ -62,9 +62,6 @@ describe('AppSettings', () => { druidHost: '192.168.99.100', timeout: 30003, sourceListScan: 'auto', - sourceListRefreshInterval: 10001, - sourceReintrospectInterval: 10002, - sourceReintrospectOnLoad: true, dataSources: [ wikiDataCubeJS ] @@ -73,12 +70,10 @@ describe('AppSettings', () => { expect(AppSettings.fromJS(oldJS, context).toJS().clusters).to.deep.equal([ { "name": "druid", + "title": "druid", "type": "druid", "host": "192.168.99.100", - "sourceListRefreshInterval": 10001, "sourceListScan": "auto", - "sourceReintrospectInterval": 10002, - "sourceReintrospectOnLoad": true, "timeout": 30003 } ]); @@ -97,6 +92,7 @@ describe('AppSettings', () => { { "host": "192.168.99.100", "name": "druid", + "title": "druid", "sourceListScan": "disable", "type": "druid" } @@ -116,12 +112,13 @@ describe('AppSettings', () => { }); it("converts to client settings", () => { - const settings = AppSettingsMock.wikiOnlyWithExecutor(); + const settings = AppSettingsMock.wikiOnly(); expect(settings.toClientSettings().toJS()).to.deep.equal({ "clusters": [ { "name": "druid", + "title": "druid", "type": "druid" } ], @@ -217,7 +214,7 @@ describe('AppSettings', () => { "time": new Date('2016-04-30T12:39:51.350Z') }, "source": "wiki", - "timeAttribute": "time", + "primaryTimeAttribute": "time", "title": "Wiki" } ] diff --git a/src/common/models/app-settings/app-settings.mock.ts b/src/common/models/app-settings/app-settings.mock.ts index d7412569..e413aee5 100644 --- a/src/common/models/app-settings/app-settings.mock.ts +++ b/src/common/models/app-settings/app-settings.mock.ts @@ -294,13 +294,12 @@ export class AppSettingsMock { clusters: [ { name: 'druid', + title: 'druid', type: 'druid', host: '192.168.99.100', version: '0.9.1', timeout: 30000, sourceListScan: 'auto', - sourceListRefreshInterval: 10000, - sourceReintrospectInterval: 10000, introspectionStrategy: 'segment-metadata-fallback' } @@ -321,13 +320,12 @@ export class AppSettingsMock { clusters: [ { name: 'druid', + title: 'druid', type: 'druid', host: '192.168.99.100', version: '0.9.1', timeout: 30000, sourceListScan: 'auto', - sourceListRefreshInterval: 10000, - sourceReintrospectInterval: 10000, introspectionStrategy: 'segment-metadata-fallback' } @@ -347,13 +345,12 @@ export class AppSettingsMock { clusters: [ { name: 'druid', + title: 'druid', type: 'druid', host: '192.168.99.100', version: '0.9.1', timeout: 30000, sourceListScan: 'auto', - sourceListRefreshInterval: 10000, - sourceReintrospectInterval: 10000, introspectionStrategy: "segment-metadata-fallback" } @@ -375,17 +372,17 @@ export class AppSettingsMock { return AppSettings.fromJS(AppSettingsMock.wikiOnlyJS(), AppSettingsMock.getContext()); } - static wikiOnlyWithExecutor() { - return AppSettingsMock.wikiOnly().attachExecutors(() => { - return basicExecutorFactory({ + static wikiTwitter() { + return AppSettings.fromJS(AppSettingsMock.wikiTwitterJS(), AppSettingsMock.getContext()); + } + + static executorsWiki(): Lookup { + return { + wiki: basicExecutorFactory({ datasets: { main: Dataset.fromJS(SMALL_WIKI_DATA) } - }); - }); - } - - static wikiTwitter() { - return AppSettings.fromJS(AppSettingsMock.wikiTwitterJS(), AppSettingsMock.getContext()); + }) + }; } } diff --git a/src/common/models/app-settings/app-settings.ts b/src/common/models/app-settings/app-settings.ts index 366719b2..80e8c020 100644 --- a/src/common/models/app-settings/app-settings.ts +++ b/src/common/models/app-settings/app-settings.ts @@ -16,7 +16,7 @@ import { Class, Instance, isInstanceOf, immutableArraysEqual, immutableEqual } from 'immutable-class'; import { ImmutableUtils } from '../../utils/index'; -import { Executor, findByName, overrideByName } from 'plywood'; +import { Executor, find, findByName, overrideByName } from 'plywood'; import { hasOwnProperty } from '../../utils/general/general'; import { Cluster, ClusterJS } from '../cluster/cluster'; import { Customization, CustomizationJS } from '../customization/customization'; @@ -24,6 +24,16 @@ import { DataCube, DataCubeJS } from '../data-cube/data-cube'; import { Collection, CollectionJS, CollectionContext } from '../collection/collection'; import { Manifest } from '../manifest/manifest'; +// ToDo: move this into an appropriate util file +function checkNamedArrayUnique(as: any[]): void { + var seen: Lookup = {}; + for (var a of as) { + var name = a.name; + if (seen[name]) throw new Error(`duplicate '${name}'`); + seen[name] = 1; + } +} + export interface AppSettingsValue { version?: number; clusters?: Cluster[]; @@ -44,7 +54,6 @@ export interface AppSettingsJS { export interface AppSettingsContext { visualizations: Manifest[]; - executorFactory?: (dataCube: DataCube) => Executor; } var check: Class; @@ -72,21 +81,7 @@ export class AppSettings implements Instance { clusters = []; } - var executorFactory = context.executorFactory; - var dataCubes = (parameters.dataCubes || (parameters as any).dataSources || []).map((dataCubeJS: DataCubeJS) => { - var dataCubeClusterName = dataCubeJS.clusterName || (dataCubeJS as any).engine; - if (dataCubeClusterName !== 'native') { - var cluster = findByName(clusters, dataCubeClusterName); - if (!cluster) throw new Error(`Can not find cluster '${dataCubeClusterName}' for data cube '${dataCubeJS.name}'`); - } - - var dataCubeObject = DataCube.fromJS(dataCubeJS, { cluster }); - if (executorFactory) { - var executor = executorFactory(dataCubeObject); - if (executor) dataCubeObject = dataCubeObject.attachExecutor(executor); - } - return dataCubeObject; - }); + var dataCubes = (parameters.dataCubes || (parameters as any).dataSources || []).map((dataCubeJS: DataCubeJS) => DataCube.fromJS(dataCubeJS)); var collectionContext = { dataCubes, visualizations: context.visualizations }; var makeCollection = (js: CollectionJS) => { @@ -125,16 +120,19 @@ export class AppSettings implements Instance { for (var dataCube of dataCubes) { if (dataCube.clusterName === 'native') continue; if (!findByName(clusters, dataCube.clusterName)) { - throw new Error(`data cube ${dataCube.name} refers to an unknown cluster ${dataCube.clusterName}`); + throw new Error(`data cube '${dataCube.name}' refers to an unknown cluster '${dataCube.clusterName}'`); } } this.version = version || 0; this.clusters = clusters; + checkNamedArrayUnique(this.clusters); this.customization = customization; this.dataCubes = dataCubes; + checkNamedArrayUnique(this.dataCubes); this.linkViewConfig = linkViewConfig; this.collections = collections; + checkNamedArrayUnique(this.collections); } public valueOf(): AppSettingsValue { @@ -179,13 +177,8 @@ export class AppSettings implements Instance { public toClientSettings(): AppSettings { var value = this.valueOf(); - value.clusters = value.clusters.map((c) => c.toClientCluster()); - - value.dataCubes = value.dataCubes - .filter((ds) => ds.isQueryable()) - .map((ds) => ds.toClientDataCube()); - + value.dataCubes = value.dataCubes.map((ds) => ds.toClientDataCube()); return new AppSettings(value); } @@ -193,14 +186,35 @@ export class AppSettings implements Instance { return this.version; } - public getDataCubesForCluster(clusterName: string): DataCube[] { + public incrementVersion(): AppSettings { + var value = this.valueOf(); + value.version++; + return new AppSettings(value); + } + + public getCollectionsInvolvingCluster(clusterName: string): Collection[] { + const dependantDataCubes = this.getDataCubesByCluster(clusterName); + return this.collections.filter((collection) => { + return dependantDataCubes.some((dataCube => collection.dependsOnDataCube(dataCube.name))); + }); + } + + public getDataCubesByCluster(clusterName: string): DataCube[] { return this.dataCubes.filter(dataCube => dataCube.clusterName === clusterName); } + public getDataCubesByClusterSource(clusterName: string, source: string): DataCube[] { + return this.dataCubes.filter((dataCube) => dataCube.clusterName === clusterName && dataCube.source === source); + } + public getDataCube(dataCubeName: string): DataCube { return findByName(this.dataCubes, dataCubeName); } + public getCluster(clusterName: string): Cluster { + return findByName(this.clusters, clusterName); + } + public addOrUpdateDataCube(dataCube: DataCube): AppSettings { var value = this.valueOf(); value.dataCubes = overrideByName(value.dataCubes, dataCube); @@ -213,33 +227,37 @@ export class AppSettings implements Instance { return new AppSettings(value); } - public deleteCollection(collection: Collection): AppSettings { + public deleteCollection(collectionName: string): AppSettings { var value = this.valueOf(); - var index = value.collections.indexOf(collection); - - if (index === -1) { - throw new Error(`Unknown collection : ${collection.toString()}`); - } - - var newCollections = value.collections.concat(); - newCollections.splice(index, 1); - - value.collections = newCollections; + value.collections = value.collections.filter(collection => collection.name !== collectionName); return new AppSettings(value); } - public deleteDataCube(dataCube: DataCube): AppSettings { + public deleteDataCube(dataCubeName: string): AppSettings { var value = this.valueOf(); - var index = value.dataCubes.indexOf(dataCube); + value.dataCubes = value.dataCubes.filter(dataCube => dataCube.name !== dataCubeName); + value.collections = value.collections.map(collection => collection.deleteTilesContainingCube(dataCubeName)); + return new AppSettings(value); + } - if (index === -1) { - throw new Error(`Unknown dataCube : ${dataCube.toString()}`); - } + public deleteCluster(clusterName: string): AppSettings { + if (clusterName === 'native') new Error(`Can not delete 'native' cluster`); - var newDataCubes = value.dataCubes.concat(); - newDataCubes.splice(index, 1); + var affectedDataCubes = this.getDataCubesByCluster(clusterName); + + var value = this.valueOf(); + value.clusters = value.clusters.filter(cluster => cluster.name !== clusterName); + + if (affectedDataCubes.length) { + value.dataCubes = value.dataCubes.filter(dataCube => dataCube.clusterName !== clusterName); + value.collections = value.collections.map(collection => { + for (var affectedDataCube of affectedDataCubes) { + collection = collection.deleteTilesContainingCube(affectedDataCube.name); + } + return collection; + }); + } - value.dataCubes = newDataCubes; return new AppSettings(value); } @@ -253,20 +271,6 @@ export class AppSettings implements Instance { return new AppSettings(value); } - public attachExecutors(executorFactory: (dataCube: DataCube) => Executor): AppSettings { - var value = this.valueOf(); - value.dataCubes = value.dataCubes.map((ds) => { - var executor = executorFactory(ds); - if (executor) ds = ds.attachExecutor(executor); - return ds; - }); - return new AppSettings(value); - } - - public getSuggestedCubes(): DataCube[] { - return this.dataCubes; - } - changeCustomization(customization: Customization): AppSettings { return this.change('customization', customization); } @@ -287,6 +291,10 @@ export class AppSettings implements Instance { return this.change('dataCubes', dataCubes); } + appendDataCubes(dataCubes: DataCube[]): AppSettings { + return this.changeDataCubes(this.dataCubes.concat(dataCubes)); + } + changeCollections(collections: Collection[]): AppSettings { return this.change('collections', collections); } diff --git a/src/common/models/cluster/cluster.ts b/src/common/models/cluster/cluster.ts index 3b3295df..6cd932e9 100644 --- a/src/common/models/cluster/cluster.ts +++ b/src/common/models/cluster/cluster.ts @@ -29,10 +29,6 @@ export interface ClusterValue { version?: string; timeout?: number; sourceListScan?: SourceListScan; - sourceListRefreshOnLoad?: boolean; - sourceListRefreshInterval?: number; - sourceReintrospectOnLoad?: boolean; - sourceReintrospectInterval?: number; introspectionStrategy?: string; requestDecorator?: string; @@ -51,10 +47,6 @@ export interface ClusterJS { version?: string; timeout?: number; sourceListScan?: SourceListScan; - sourceListRefreshOnLoad?: boolean; - sourceListRefreshInterval?: number; - sourceReintrospectOnLoad?: boolean; - sourceReintrospectInterval?: number; introspectionStrategy?: string; requestDecorator?: string; @@ -71,20 +63,11 @@ function ensureNotNative(name: string): void { } } -function ensureNotTiny(v: number): void { - if (v === 0) return; - if (v < 1000) { - throw new Error(`can not be < 1000 (is ${v})`); - } -} - export class Cluster extends BaseImmutable { static TYPE_VALUES: SupportedType[] = ['druid', 'mysql', 'postgres']; static DEFAULT_TIMEOUT = 40000; static DEFAULT_SOURCE_LIST_SCAN: SourceListScan = 'auto'; static SOURCE_LIST_SCAN_VALUES: SourceListScan[] = ['disable', 'auto']; - static DEFAULT_SOURCE_LIST_REFRESH_INTERVAL = 15000; - static DEFAULT_SOURCE_REINTROSPECT_INTERVAL = 120000; static DEFAULT_INTROSPECTION_STRATEGY = 'segment-metadata-fallback'; static isCluster(candidate: any): candidate is Cluster { @@ -98,12 +81,6 @@ export class Cluster extends BaseImmutable { if (typeof parameters.timeout === 'string') { parameters.timeout = parseInt(parameters.timeout, 10); } - if (typeof parameters.sourceListRefreshInterval === 'string') { - parameters.sourceListRefreshInterval = parseInt(parameters.sourceListRefreshInterval, 10); - } - if (typeof parameters.sourceReintrospectInterval === 'string') { - parameters.sourceReintrospectInterval = parseInt(parameters.sourceReintrospectInterval, 10); - } return new Cluster(BaseImmutable.jsToValue(Cluster.PROPERTIES, parameters)); } @@ -115,10 +92,6 @@ export class Cluster extends BaseImmutable { { name: 'version', defaultValue: null }, { name: 'timeout', defaultValue: Cluster.DEFAULT_TIMEOUT }, { name: 'sourceListScan', defaultValue: Cluster.DEFAULT_SOURCE_LIST_SCAN, possibleValues: Cluster.SOURCE_LIST_SCAN_VALUES }, - { name: 'sourceListRefreshOnLoad', defaultValue: false }, - { name: 'sourceListRefreshInterval', defaultValue: Cluster.DEFAULT_SOURCE_LIST_REFRESH_INTERVAL, validate: [BaseImmutable.ensure.number, ensureNotTiny] }, - { name: 'sourceReintrospectOnLoad', defaultValue: false }, - { name: 'sourceReintrospectInterval', defaultValue: Cluster.DEFAULT_SOURCE_REINTROSPECT_INTERVAL, validate: [BaseImmutable.ensure.number, ensureNotTiny] }, // Druid { name: 'introspectionStrategy', defaultValue: Cluster.DEFAULT_INTROSPECTION_STRATEGY }, @@ -139,10 +112,6 @@ export class Cluster extends BaseImmutable { public version: string; public timeout: number; public sourceListScan: SourceListScan; - public sourceListRefreshOnLoad: boolean; - public sourceListRefreshInterval: number; - public sourceReintrospectOnLoad: boolean; - public sourceReintrospectInterval: number; // Druid public introspectionStrategy: string; @@ -156,6 +125,7 @@ export class Cluster extends BaseImmutable { constructor(parameters: ClusterValue) { super(parameters); + if (!this.title) this.title = this.name; switch (this.type) { case 'druid': @@ -174,14 +144,13 @@ export class Cluster extends BaseImmutable { } + public getTitle: () => string; public getTimeout: () => number; public getSourceListScan: () => SourceListScan; - public getSourceListRefreshInterval: () => number; - public getSourceReintrospectInterval: () => number; public getIntrospectionStrategy: () => string; - public changeHost: (newHost: string) => Cluster; - public changeTimeout: (newTimeout: string) => Cluster; - public changeSourceListRefreshInterval: (newSourceListRefreshInterval: string) => Cluster; + public changeTitle: (title: string) => Cluster; + public changeHost: (host: string) => Cluster; + public changeTimeout: (timeout: number) => Cluster; public toClientCluster(): Cluster { return new Cluster({ @@ -190,17 +159,6 @@ export class Cluster extends BaseImmutable { }); } - public makeExternalFromSourceName(source: string, version?: string): External { - return External.fromValue({ - engine: this.type, - source, - version: version, - - allowSelectQueries: true, - allowEternity: false - }); - } - public shouldScanSources(): boolean { return this.getSourceListScan() === 'auto'; } diff --git a/src/common/models/collection/collection.ts b/src/common/models/collection/collection.ts index 9997e88c..3bb8ad55 100644 --- a/src/common/models/collection/collection.ts +++ b/src/common/models/collection/collection.ts @@ -15,9 +15,8 @@ */ import { Class, Instance, isInstanceOf, immutableArraysEqual } from 'immutable-class'; -import { findByName } from 'plywood'; +import { findByName, findIndexByName } from 'plywood'; -import { Manifest } from '../manifest/manifest'; import { CollectionTile, CollectionTileJS, CollectionTileContext } from '../index'; export interface CollectionValue { @@ -124,15 +123,16 @@ export class Collection implements Instance { return !this.findByName(name); } - public deleteTile(item: CollectionTile): Collection { - var index = this.tiles.indexOf(item); - - if (index === -1) return this; + public deleteTile(tileName: string): Collection { + return this.changeTiles(this.tiles.filter(tile => tile.name !== tileName)); + } - var newTiles = this.tiles.concat(); - newTiles.splice(index, 1); + public deleteTilesContainingCube(dataCubeName: string): Collection { + return this.changeTiles(this.tiles.filter(tile => tile.dataCube.name !== dataCubeName)); + } - return this.change('tiles', newTiles); + public dependsOnDataCube(dataCubeName: string): boolean { + return this.tiles.some((tile) => tile.dataCube.name === dataCubeName); } public change(propertyName: string, newValue: any): Collection { @@ -147,14 +147,7 @@ export class Collection implements Instance { } public updateTile(tile: CollectionTile): Collection { - var index = -1; - - this.tiles.forEach(({name}, i) => { - if (name === tile.name) { - index = i; - return; - } - }); + var index = findIndexByName(this.tiles, tile.name); if (index === -1) { throw new Error(`Can't add unknown tile : ${tile.toString()}`); @@ -167,6 +160,10 @@ export class Collection implements Instance { return this.change('tiles', newTiles); } + public getTiles(): CollectionTile[] { + return this.tiles || []; + } + public changeTiles(tiles: CollectionTile[]): Collection { return this.change('tiles', tiles); } diff --git a/src/common/models/customization/customization.ts b/src/common/models/customization/customization.ts index ba7af90d..c5cf5ba7 100644 --- a/src/common/models/customization/customization.ts +++ b/src/common/models/customization/customization.ts @@ -14,16 +14,9 @@ * limitations under the License. */ -import { Class, Instance, isInstanceOf, isImmutableClass, immutableArraysEqual } from 'immutable-class'; -import { ImmutableUtils } from '../../utils/index'; +import { BaseImmutable, Property, isInstanceOf } from 'immutable-class'; import { Timezone } from 'chronoshift'; -import { ExternalView, ExternalViewValue} from '../external-view/external-view'; - -var { WallTime } = require('chronoshift'); -if (!WallTime.rules) { - var tzData = require("chronoshift/lib/walltime/walltime-data.js"); - WallTime.init(tzData.rules, tzData.zones); -} +import { ExternalView, ExternalViewValue } from '../external-view/external-view'; export interface CustomizationValue { title?: string; @@ -43,8 +36,7 @@ export interface CustomizationJS { logoutHref?: string; } -var check: Class; -export class Customization implements Instance { +export class Customization extends BaseImmutable { static DEFAULT_TITLE = 'Swiv (%v)'; static DEFAULT_TIMEZONES: Timezone[] = [ @@ -73,112 +65,39 @@ export class Customization implements Instance ExternalView.fromJS(view)); - value.externalViews = externalViews; - } - - var timezonesJS = parameters.timezones; - var timezones: Timezone[] = null; - if (Array.isArray(timezonesJS)) { - timezones = timezonesJS.map(Timezone.fromJS); - value.timezones = timezones; - } - - return new Customization(value); + return new Customization(BaseImmutable.jsToValue(Customization.PROPERTIES, parameters)); } + static PROPERTIES: Property[] = [ + { name: 'title', defaultValue: Customization.DEFAULT_TITLE }, + { name: 'headerBackground', defaultValue: null }, + { name: 'customLogoSvg', defaultValue: null }, + { name: 'externalViews', defaultValue: [], immutableClassArray: (ExternalView as any) }, + { name: 'timezones', defaultValue: Customization.DEFAULT_TIMEZONES, immutableClassArray: (Timezone as any) }, + { name: 'logoutHref', defaultValue: Customization.DEFAULT_LOGOUT_HREF } + ]; + + public title: string; public headerBackground: string; public customLogoSvg: string; public externalViews: ExternalView[]; public timezones: Timezone[]; - public title: string; public logoutHref: string; constructor(parameters: CustomizationValue) { - this.title = parameters.title || null; - this.headerBackground = parameters.headerBackground || null; - this.customLogoSvg = parameters.customLogoSvg || null; - if (parameters.externalViews) this.externalViews = parameters.externalViews; - if (parameters.timezones) this.timezones = parameters.timezones; - this.logoutHref = parameters.logoutHref; + super(parameters); } + public getTitle: () => string; - public valueOf(): CustomizationValue { - return { - title: this.title, - headerBackground: this.headerBackground, - customLogoSvg: this.customLogoSvg, - externalViews: this.externalViews, - timezones: this.timezones, - logoutHref: this.logoutHref - }; + public getTitleWithVersion(version: string): string { + return this.getTitle().replace(/%v/g, version); } - public toJS(): CustomizationJS { - var js: CustomizationJS = {}; - if (this.title) js.title = this.title; - if (this.headerBackground) js.headerBackground = this.headerBackground; - if (this.customLogoSvg) js.customLogoSvg = this.customLogoSvg; - if (this.externalViews) { - js.externalViews = this.externalViews.map(view => view.toJS()); - } - if (this.timezones) { - js.timezones = this.timezones.map(tz => tz.toJS()); - } - if (this.logoutHref) js.logoutHref = this.logoutHref; - return js; - } - - public toJSON(): CustomizationJS { - return this.toJS(); - } + public changeTitle: (title: string) => Customization; + public getTimezones: () => Timezone[]; + public getLogoutHref: () => string; - public toString(): string { - return `[custom: (${this.headerBackground}) logo: ${Boolean(this.customLogoSvg)}, externalViews: ${Boolean(this.externalViews)}, timezones: ${Boolean(this.timezones)}]`; - } - - public equals(other: Customization): boolean { - return Customization.isCustomization(other) && - this.title === other.title && - this.headerBackground === other.headerBackground && - this.customLogoSvg === other.customLogoSvg && - immutableArraysEqual(this.externalViews, other.externalViews) && - immutableArraysEqual(this.timezones, other.timezones) && - this.logoutHref === other.logoutHref; - } - - public getTitle(version: string): string { - var title = this.title || Customization.DEFAULT_TITLE; - return title.replace(/%v/g, version); - } - - change(propertyName: string, newValue: any): Customization { - return ImmutableUtils.change(this, propertyName, newValue); - } - - public changeTitle(title: string): Customization { - return this.change('title', title); - } - - public getTimezones() { - return this.timezones || Customization.DEFAULT_TIMEZONES; - } - - public getLogoutHref() { - return this.logoutHref || Customization.DEFAULT_LOGOUT_HREF; - } } - -check = Customization; +BaseImmutable.finalize(Customization); diff --git a/src/common/models/data-cube/data-cube.mocha.ts b/src/common/models/data-cube/data-cube.mocha.ts index 10c677f2..b382e937 100644 --- a/src/common/models/data-cube/data-cube.mocha.ts +++ b/src/common/models/data-cube/data-cube.mocha.ts @@ -16,23 +16,12 @@ import { expect } from 'chai'; import { testImmutableClass } from 'immutable-class-tester'; -import * as Q from 'q'; import { $, Expression, AttributeInfo } from 'plywood'; -import { Cluster } from "../cluster/cluster"; import { DataCube, DataCubeJS } from './data-cube'; import { DataCubeMock} from './data-cube.mock'; describe('DataCube', () => { - var druidCluster = Cluster.fromJS({ - name: 'druid', - type: 'druid' - }); - - var context = { - cluster: druidCluster - }; - it('is an immutable class', () => { testImmutableClass(DataCube, [ DataCubeMock.TWITTER_JS, @@ -187,8 +176,8 @@ describe('DataCube', () => { }); - describe("#getIssues", () => { - it("raises issues", () => { + describe("#validation", () => { + it("throws accordingly", () => { var dataCube = DataCube.fromJS({ name: 'wiki', clusterName: 'druid', @@ -197,49 +186,17 @@ describe('DataCube', () => { { name: '__time', type: 'TIME' }, { name: 'articleName', type: 'STRING' }, { name: 'count', type: 'NUMBER' } - ], - dimensions: [ - { - name: 'gaga', - formula: '$gaga' - }, - { - name: 'bucketArticleName', - formula: '$articleName.numberBucket(5)' - } - ], - measures: [ - { - name: 'count', - formula: '$main.sum($count)' - }, - { - name: 'added', - formula: '$main.sum($added)' - }, - { - name: 'sumArticleName', - formula: '$main.sum($articleName)' - }, - { - name: 'koalaCount', - formula: '$koala.sum($count)' - }, - { - name: 'countByThree', - formula: '$count / 3' - } ] }); - expect(dataCube.getIssues()).to.deep.equal([ - "failed to validate dimension 'gaga': could not resolve $gaga", - "failed to validate dimension 'bucketArticleName': numberBucket must have input of type NUMBER or NUMBER_RANGE (is STRING)", - "failed to validate measure 'added': could not resolve $added", - "failed to validate measure 'sumArticleName': sum must have expression of type NUMBER (is STRING)", - "failed to validate measure 'koalaCount': measure must contain a $main reference", - "failed to validate measure 'countByThree': measure must contain a $main reference" - ]); + expect(() => dataCube.validateFormula('$gaga')).to.throw("could not resolve $gaga"); + expect(() => dataCube.validateFormula('$articleName.numberBucket(5)')).to.throw("numberBucket must have input of type NUMBER or NUMBER_RANGE (is STRING)"); + expect(() => dataCube.validateFormulaInMeasureContext('$main.sum($added)')).to.throw("Invalid formula: could not resolve $added"); + expect(dataCube.validateFormulaInMeasureContext('$main.sum($count)')).to.equal(true); + expect(() => dataCube.validateFormulaInMeasureContext('$main.sum($articleName)')).to.throw("sum must have expression of type NUMBER (is STRING)"); + expect(() => dataCube.validateFormulaInMeasureContext('$koala.sum($count)')).to.throw("Measure formula must contain a $main reference"); + expect(() => dataCube.validateFormulaInMeasureContext('$count / 3')).to.throw("Measure formula must contain a $main reference"); + }); }); @@ -252,11 +209,12 @@ describe('DataCube', () => { "engine": "druid", "source": "wiki", "subsetFilter": "$page.in(['en', 'fr'])", + "timeAttribute": "time", "dimensions": [ { "kind": "time", - "name": "__time", - "formula": "$__time" + "name": "time", + "formula": "$time" }, { "name": "page" @@ -276,12 +234,12 @@ describe('DataCube', () => { type: 'STRING' } ], - "defaultSplits": "__time", + "defaultSplits": "time", "priority": 13 } }; - var dataCube = DataCube.fromJS(legacyDataCubeJS, context); + var dataCube = DataCube.fromJS(legacyDataCubeJS); expect(dataCube.toJS()).to.deep.equal({ "attributeOverrides": [ @@ -291,11 +249,10 @@ describe('DataCube', () => { } ], "clusterName": "druid", - "defaultSortMeasure": "added", "defaultSplits": [ { "expression": { - "name": "__time", + "name": "time", "op": "ref" } } @@ -304,9 +261,9 @@ describe('DataCube', () => { "dimensions": [ { "kind": "time", - "name": "__time", + "name": "time", "title": "Time", - "formula": "$__time" + "formula": "$time" }, { "kind": "string", @@ -325,15 +282,15 @@ describe('DataCube', () => { ], "name": "wiki", "options": { - "priority": 13 + "priority": 13, + "druidTimeAttributeName": "time" }, "refreshRule": { - "refresh": "PT1M", "rule": "query" }, "source": "wiki", "subsetFormula": "$page.in(['en', 'fr'])", - "timeAttribute": "__time", + "primaryTimeAttribute": "time", "title": "Wiki" }); @@ -341,93 +298,7 @@ describe('DataCube', () => { }); - - describe("#deduceAttributes", () => { - it("works in a generic case", () => { - var dataCube = DataCube.fromJS({ - "name": "wiki", - "clusterName": "druid", - "source": "wiki", - introspection: 'autofill-all', - "defaultFilter": { "op": "literal", "value": true }, - "defaultSortMeasure": "added", - "defaultTimezone": "Etc/UTC", - "dimensions": [ - { - "kind": "time", - "name": "__time", - "formula": "$__time" - }, - { - "name": "page" - }, - { - "name": "pageInBrackets", - "formula": "'[' ++ $page ++ ']'" - }, - { - "name": "userInBrackets", - "formula": "'[' ++ $user ++ ']'" - }, - { - "name": "languageLookup", - "formula": "$language.lookup(wiki_language_lookup)" - } - ], - "measures": [ - { - "name": "added", - "formula": "$main.sum($added)" - }, - { - "name": "addedByDeleted", - "formula": "$main.sum($added) / $main.sum($deleted)" - }, - { - "name": "unique_user", - "formula": "$main.countDistinct($unique_user)" - } - ] - }, context); - - expect(AttributeInfo.toJSs(dataCube.deduceAttributes())).to.deep.equal([ - { - "name": "__time", - "type": "TIME" - }, - { - "name": "page", - "type": "STRING" - }, - { - "name": "user", - "type": "STRING" - }, - { - "name": "language", - "type": "STRING" - }, - { - "name": "added", - "type": "NUMBER" - }, - { - "name": "deleted", - "type": "NUMBER" - }, - { - "name": "unique_user", - "special": "unique", - "type": "STRING" - } - ]); - - }); - - }); - - - describe("#addAttributes", () => { + describe("#fillAllFromAttributes", () => { var dataCubeStub = DataCube.fromJS({ name: 'wiki', title: 'Wiki', @@ -449,7 +320,7 @@ describe('DataCube', () => { { name: 'unique_user', special: 'unique' } ]); - var dataCube1 = dataCubeStub.addAttributes(attributes1); + var dataCube1 = dataCubeStub.fillAllFromAttributes(attributes1); expect(dataCube1.toJS()).to.deep.equal({ "name": "wiki", "title": "Wiki", @@ -457,14 +328,11 @@ describe('DataCube', () => { "clusterName": "druid", "source": "wiki", "refreshRule": { - "refresh": "PT1M", - "rule": "fixed" + "rule": "realtime" }, introspection: 'autofill-all', "defaultFilter": { "op": "literal", "value": true }, - "defaultSortMeasure": "added", "defaultTimezone": "Etc/UTC", - "timeAttribute": '__time', "attributes": [ { name: '__time', type: 'TIME' }, { name: 'page', type: 'STRING' }, @@ -508,7 +376,7 @@ describe('DataCube', () => { { name: 'user', type: 'STRING' } ]); - var dataCube2 = dataCube1.addAttributes(attributes2); + var dataCube2 = dataCube1.fillAllFromAttributes(attributes2); expect(dataCube2.toJS()).to.deep.equal({ "name": "wiki", "title": "Wiki", @@ -516,14 +384,11 @@ describe('DataCube', () => { "clusterName": "druid", "source": "wiki", "refreshRule": { - "refresh": "PT1M", - "rule": "fixed" + "rule": "realtime" }, introspection: 'autofill-all', "defaultFilter": { "op": "literal", "value": true }, - "defaultSortMeasure": "added", "defaultTimezone": "Etc/UTC", - "timeAttribute": '__time', "attributes": [ { name: '__time', type: 'TIME' }, { name: 'page', type: 'STRING' }, @@ -580,7 +445,7 @@ describe('DataCube', () => { { name: 'unique_user:#love$', special: 'unique' } ]); - var dataCube = dataCubeStub.addAttributes(attributes1); + var dataCube = dataCubeStub.fillAllFromAttributes(attributes1); expect(dataCube.toJS()).to.deep.equal({ "attributes": [ { @@ -606,7 +471,6 @@ describe('DataCube', () => { "op": "literal", "value": true }, - "defaultSortMeasure": "added_love_", "defaultTimezone": "Etc/UTC", "dimensions": [ { @@ -637,11 +501,9 @@ describe('DataCube', () => { ], "name": "wiki", "refreshRule": { - "refresh": "PT1M", - "rule": "fixed" + "rule": "realtime" }, "source": "wiki", - "timeAttribute": "__time", "title": "Wiki", "description": "" }); @@ -679,14 +541,14 @@ describe('DataCube', () => { ] }); - var dataCube = dataCubeWithDim.addAttributes(attributes1); + var dataCube = dataCubeWithDim.fillAllFromAttributes(attributes1); expect(dataCube.toJS().measures.map(m => m.name)).to.deep.equal(['deleted']); }); }); - describe("#addAttributes (new dim)", () => { + describe("#fillAllFromAttributes (new dim)", () => { var dataCube = DataCube.fromJS({ name: 'wiki', title: 'Wiki', @@ -711,7 +573,7 @@ describe('DataCube', () => { { "name": "page_unique", "special": "unique", "type": "STRING" } ]; - var dataCube1 = dataCube.addAttributes(AttributeInfo.fromJSs(columns)); + var dataCube1 = dataCube.fillAllFromAttributes(AttributeInfo.fromJSs(columns)); expect(dataCube1.toJS().dimensions).to.deep.equal([ { @@ -729,7 +591,7 @@ describe('DataCube', () => { ]); columns.push({ "name": "channel", "type": "STRING" }); - var dataCube2 = dataCube1.addAttributes(AttributeInfo.fromJSs(columns)); + var dataCube2 = dataCube1.fillAllFromAttributes(AttributeInfo.fromJSs(columns)); expect(dataCube2.toJS().dimensions).to.deep.equal([ { diff --git a/src/common/models/data-cube/data-cube.mock.ts b/src/common/models/data-cube/data-cube.mock.ts index 7c267753..2db48330 100644 --- a/src/common/models/data-cube/data-cube.mock.ts +++ b/src/common/models/data-cube/data-cube.mock.ts @@ -14,16 +14,8 @@ * limitations under the License. */ -import { $, Executor, Dataset, basicExecutorFactory } from 'plywood'; import { DataCube, DataCubeJS } from './data-cube'; -var executor = basicExecutorFactory({ - datasets: { - wiki: Dataset.fromJS([]), - twitter: Dataset.fromJS([]) - } -}); - export class DataCubeMock { public static get WIKI_JS(): DataCubeJS { return { @@ -78,7 +70,7 @@ export class DataCubeMock { formula: '$main.sum($added)' } ], - timeAttribute: 'time', + primaryTimeAttribute: 'time', defaultTimezone: 'Etc/UTC', defaultFilter: { op: 'literal', value: true }, defaultDuration: 'P3D', @@ -88,6 +80,9 @@ export class DataCubeMock { refreshRule: { time: new Date('2016-04-30T12:39:51.350Z'), rule: "fixed" + }, + options: { + druidTimeAttributeName: 'time' } }; } @@ -127,7 +122,7 @@ export class DataCubeMock { formula: '$main.count()' } ], - timeAttribute: 'time', + primaryTimeAttribute: 'time', defaultTimezone: 'Etc/UTC', defaultFilter: { op: 'literal', value: true }, defaultDuration: 'P3D', @@ -135,15 +130,18 @@ export class DataCubeMock { defaultPinnedDimensions: ['tweet'], refreshRule: { rule: "realtime" + }, + options: { + druidTimeAttributeName: 'time' } }; } static wiki() { - return DataCube.fromJS(DataCubeMock.WIKI_JS, { executor }); + return DataCube.fromJS(DataCubeMock.WIKI_JS); } static twitter() { - return DataCube.fromJS(DataCubeMock.TWITTER_JS, { executor }); + return DataCube.fromJS(DataCubeMock.TWITTER_JS); } } diff --git a/src/common/models/data-cube/data-cube.ts b/src/common/models/data-cube/data-cube.ts index 49f486cd..5b66e11f 100644 --- a/src/common/models/data-cube/data-cube.ts +++ b/src/common/models/data-cube/data-cube.ts @@ -14,13 +14,12 @@ * limitations under the License. */ -import * as Q from 'q'; import { List, OrderedSet } from 'immutable'; import { Class, Instance, isInstanceOf, immutableEqual, immutableArraysEqual, immutableLookupsEqual } from 'immutable-class'; import { Duration, Timezone, second } from 'chronoshift'; -import { $, ply, r, Expression, ExpressionJS, Executor, External, RefExpression, basicExecutorFactory, Dataset, +import { $, ply, r, Expression, ExpressionJS, External, RefExpression, Dataset, Attributes, AttributeInfo, AttributeJSs, SortAction, SimpleFullType, DatasetFullType, PlyTypeSimple, - CustomDruidAggregations, CustomDruidTransforms, ExternalValue, findByName } from 'plywood'; + CustomDruidAggregations, CustomDruidTransforms, ExternalValue, findByName, overrideByName } from 'plywood'; import { hasOwnProperty, verifyUrlSafeName, makeUrlSafeName, makeTitle, immutableListsEqual } from '../../utils/general/general'; import { getWallTimeString } from '../../utils/time/time'; import { Dimension, DimensionJS } from '../dimension/dimension'; @@ -32,6 +31,8 @@ import { RefreshRule, RefreshRuleJS } from '../refresh-rule/refresh-rule'; import { Cluster } from '../cluster/cluster'; import { Timekeeper } from "../timekeeper/timekeeper"; +const MAX_TIME = 'maxTime'; + function formatTimeDiff(diff: number): string { diff = Math.round(Math.abs(diff) / 1000); // turn to seconds if (diff < 60) return 'less than 1 minute'; @@ -89,7 +90,7 @@ export interface DataCubeValue { dimensions?: List; measures?: List; - timeAttribute?: RefExpression; + primaryTimeAttribute?: string; defaultTimezone?: Timezone; defaultFilter?: Filter; defaultSplits?: Splits; @@ -98,9 +99,6 @@ export interface DataCubeValue { defaultSelectedMeasures?: OrderedSet; defaultPinnedDimensions?: OrderedSet; refreshRule?: RefreshRule; - - cluster?: Cluster; - executor?: Executor; } export interface DataCubeJS { @@ -120,7 +118,7 @@ export interface DataCubeJS { dimensions?: DimensionJS[]; measures?: MeasureJS[]; - timeAttribute?: string; + primaryTimeAttribute?: string; defaultTimezone?: string; defaultFilter?: FilterJS; defaultSplits?: SplitsJS; @@ -138,6 +136,7 @@ export interface DataCubeOptions { customTransforms?: CustomDruidTransforms; druidContext?: Lookup; priority?: number; + druidTimeAttributeName?: string; // Deprecated defaultSplits?: SplitsJS; @@ -150,11 +149,6 @@ export interface DataCubeOptions { [thing: string]: any; } -export interface DataCubeContext { - cluster?: Cluster; - executor?: Executor; -} - export interface LongForm { metricColumn: string; possibleAggregates: Lookup; @@ -233,42 +227,103 @@ export class DataCube implements Instance { return isInstanceOf(candidate, DataCube); } - static queryMaxTime(dataCube: DataCube): Q.Promise { - if (!dataCube.executor) { - return Q.reject(new Error('dataCube not ready')); + static processMaxTimeQuery(dataset: Dataset): Date { + var maxTimeDate = dataset.data[0][MAX_TIME]; + if (isNaN(maxTimeDate as any)) return null; + return maxTimeDate; + } + + static suggestDimensions(attributes: Attributes): Dimension[] { + var dimensions: Dimension[] = []; + + for (var attribute of attributes) { + var { name, type, special } = attribute; + var urlSafeName = makeUrlSafeName(name); + + switch (type) { + case 'TIME': + // Add to the start + dimensions.unshift(new Dimension({ + name: urlSafeName, + kind: 'time', + formula: $(name).toString() + })); + break; + + case 'STRING': + if (special !== 'unique' && special !== 'theta') { + dimensions.push(new Dimension({ + name: urlSafeName, + formula: $(name).toString() + })); + } + break; + + case 'SET/STRING': + dimensions.push(new Dimension({ + name: urlSafeName, + formula: $(name).toString() + })); + break; + + case 'BOOLEAN': + dimensions.push(new Dimension({ + name: urlSafeName, + kind: 'boolean', + formula: $(name).toString() + })); + break; + } } - var ex = ply().apply('maxTime', $('main').max(dataCube.timeAttribute)); + return dimensions; + } - return dataCube.executor(ex).then((dataset: Dataset) => { - var maxTimeDate = dataset.data[0]['maxTime']; - if (isNaN(maxTimeDate as any)) return null; - return maxTimeDate; - }); + static suggestMeasures(attributes: Attributes): Measure[] { + var measures: Measure[] = []; + + for (var attribute of attributes) { + var { name, type, special } = attribute; + + switch (type) { + case 'STRING': + if (special === 'unique' || special === 'theta') { + measures = measures.concat(Measure.measuresFromAttributeInfo(attribute)); + } + break; + + case 'NUMBER': + var newMeasures = Measure.measuresFromAttributeInfo(attribute); + newMeasures.forEach((newMeasure) => { + if (name === 'count') { + measures.unshift(newMeasure); + } else { + measures.push(newMeasure); + } + }); + break; + } + } + + return measures; } - static fromClusterAndExternal(name: string, cluster: Cluster, external: External): DataCube { - var dataCube = DataCube.fromJS({ + static fromClusterAndSource(name: string, cluster: Cluster, source: string): DataCube { + return DataCube.fromJS({ name, + title: makeTitle(source), clusterName: cluster.name, - source: String(external.source), - refreshRule: RefreshRule.query().toJS() + source, + primaryTimeAttribute: (cluster && cluster.type === 'druid') ? '__time' : null }); - - return dataCube.updateCluster(cluster).updateWithExternal(external); } - static fromJS(parameters: DataCubeJS, context: DataCubeContext = {}): DataCube { - const { cluster, executor } = context; - var clusterName = parameters.clusterName; + static fromJS(parameters: DataCubeJS): DataCube { + var clusterName = parameters.clusterName || (parameters as any).engine; var introspection = parameters.introspection; var defaultSplitsJS = parameters.defaultSplits; var attributeOverrideJSs = parameters.attributeOverrides; - - // Back compat. - if (!clusterName) { - clusterName = (parameters as any).engine; - } + var primaryTimeAttribute = parameters.primaryTimeAttribute; var options = parameters.options || {}; if (options.skipIntrospection) { @@ -291,6 +346,10 @@ export class DataCube implements Instance { if (!defaultSplitsJS) defaultSplitsJS = options.defaultSplits; delete options.defaultSplits; } + if (!primaryTimeAttribute && (parameters as any).timeAttribute) { + primaryTimeAttribute = (parameters as any).timeAttribute; + options.druidTimeAttributeName = primaryTimeAttribute; + } // End Back compat. if (introspection && DataCube.INTROSPECTION_VALUES.indexOf(introspection) === -1) { @@ -299,12 +358,6 @@ export class DataCube implements Instance { var refreshRule = parameters.refreshRule ? RefreshRule.fromJS(parameters.refreshRule) : null; - var timeAttributeName = parameters.timeAttribute; - if (cluster && cluster.type === 'druid' && !timeAttributeName) { - timeAttributeName = '__time'; - } - var timeAttribute = timeAttributeName ? $(timeAttributeName) : null; - var attributeOverrides = AttributeInfo.fromJSs(attributeOverrideJSs || []); var attributes = AttributeInfo.fromJSs(parameters.attributes || []); var derivedAttributes: Lookup = null; @@ -315,14 +368,6 @@ export class DataCube implements Instance { var dimensions = List((parameters.dimensions || []).map((d) => Dimension.fromJS(d))); var measures = List((parameters.measures || []).map((m) => Measure.fromJS(m))); - if (timeAttribute && !Dimension.getDimensionByExpression(dimensions, timeAttribute)) { - dimensions = dimensions.unshift(new Dimension({ - name: timeAttributeName, - kind: 'time', - formula: timeAttribute.toString() - })); - } - var subsetFormula = parameters.subsetFormula || (parameters as any).subsetFilter; var longForm = parameters.longForm; @@ -336,7 +381,6 @@ export class DataCube implements Instance { } var value: DataCubeValue = { - executor: null, name: parameters.name, title: parameters.title, description: parameters.description, @@ -352,21 +396,16 @@ export class DataCube implements Instance { derivedAttributes, dimensions, measures, - timeAttribute, + primaryTimeAttribute, defaultTimezone: parameters.defaultTimezone ? Timezone.fromJS(parameters.defaultTimezone) : null, defaultFilter: parameters.defaultFilter ? Filter.fromJS(parameters.defaultFilter) : null, defaultSplits: defaultSplitsJS ? Splits.fromJS(defaultSplitsJS, { dimensions }) : null, defaultDuration: parameters.defaultDuration ? Duration.fromJS(parameters.defaultDuration) : null, - defaultSortMeasure: parameters.defaultSortMeasure || (measures.size ? measures.first().name : null), + defaultSortMeasure: parameters.defaultSortMeasure, defaultSelectedMeasures: parameters.defaultSelectedMeasures ? OrderedSet(parameters.defaultSelectedMeasures) : null, defaultPinnedDimensions: parameters.defaultPinnedDimensions ? OrderedSet(parameters.defaultPinnedDimensions) : null, refreshRule }; - if (cluster) { - if (clusterName !== cluster.name) throw new Error(`Cluster name '${clusterName}' was given but '${cluster.name}' cluster was supplied (must match)`); - value.cluster = cluster; - } - if (executor) value.executor = executor; return new DataCube(value); } @@ -387,7 +426,7 @@ export class DataCube implements Instance { public derivedAttributes: Lookup; public dimensions: List; public measures: List; - public timeAttribute: RefExpression; + public primaryTimeAttribute: string; public defaultTimezone: Timezone; public defaultFilter: Filter; public defaultSplits: Splits; @@ -397,9 +436,6 @@ export class DataCube implements Instance { public defaultPinnedDimensions: OrderedSet; public refreshRule: RefreshRule; - public cluster: Cluster; - public executor: Executor; - constructor(parameters: DataCubeValue) { var name = parameters.name; if (typeof name !== 'string') throw new Error(`DataCube must have a name`); @@ -419,7 +455,7 @@ export class DataCube implements Instance { this.attributes = parameters.attributes || []; this.attributeOverrides = parameters.attributeOverrides || []; this.derivedAttributes = parameters.derivedAttributes; - this.timeAttribute = parameters.timeAttribute; + this.primaryTimeAttribute = parameters.primaryTimeAttribute; this.defaultTimezone = parameters.defaultTimezone; this.defaultFilter = parameters.defaultFilter; this.defaultSplits = parameters.defaultSplits; @@ -431,9 +467,6 @@ export class DataCube implements Instance { var refreshRule = parameters.refreshRule || RefreshRule.query(); this.refreshRule = refreshRule; - this.cluster = parameters.cluster; - this.executor = parameters.executor; - var dimensions = parameters.dimensions; var measures = parameters.measures; checkUnique(dimensions, measures, name); @@ -461,7 +494,7 @@ export class DataCube implements Instance { derivedAttributes: this.derivedAttributes, dimensions: this.dimensions, measures: this.measures, - timeAttribute: this.timeAttribute, + primaryTimeAttribute: this.primaryTimeAttribute, defaultTimezone: this.defaultTimezone, defaultFilter: this.defaultFilter, defaultSplits: this.defaultSplits, @@ -471,8 +504,6 @@ export class DataCube implements Instance { defaultPinnedDimensions: this.defaultPinnedDimensions, refreshRule: this.refreshRule }; - if (this.cluster) value.cluster = this.cluster; - if (this.executor) value.executor = this.executor; return value; } @@ -498,7 +529,7 @@ export class DataCube implements Instance { if (this.defaultSelectedMeasures) js.defaultSelectedMeasures = this.defaultSelectedMeasures.toArray(); if (this.defaultPinnedDimensions) js.defaultPinnedDimensions = this.defaultPinnedDimensions.toArray(); if (this.rollup) js.rollup = true; - if (this.timeAttribute) js.timeAttribute = this.timeAttribute.name; + if (this.primaryTimeAttribute) js.primaryTimeAttribute = this.primaryTimeAttribute; if (this.attributeOverrides.length) js.attributeOverrides = AttributeInfo.toJSs(this.attributeOverrides); if (this.attributes.length) js.attributes = AttributeInfo.toJSs(this.attributes); if (this.derivedAttributes) js.derivedAttributes = Expression.expressionLookupToJS(this.derivedAttributes); @@ -531,7 +562,7 @@ export class DataCube implements Instance { immutableLookupsEqual(this.derivedAttributes, other.derivedAttributes) && immutableListsEqual(this.dimensions, other.dimensions) && immutableListsEqual(this.measures, other.measures) && - immutableEqual(this.timeAttribute, other.timeAttribute) && + this.primaryTimeAttribute === other.primaryTimeAttribute && immutableEqual(this.defaultTimezone, other.defaultTimezone) && immutableEqual(this.defaultFilter, other.defaultFilter) && immutableEqual(this.defaultSplits, other.defaultSplits) && @@ -554,27 +585,35 @@ export class DataCube implements Instance { } } - public toExternal(): External { + public toExternal(cluster: Cluster, requester?: Requester.PlywoodRequester): External { if (this.clusterName === 'native') throw new Error(`there is no external on a native data cube`); - const { cluster, options } = this; - if (!cluster) throw new Error('must have a cluster'); + const { options } = this; var externalValue: ExternalValue = { engine: cluster.type, suppress: true, source: this.source, version: cluster.version, + attributes: this.attributes, derivedAttributes: this.derivedAttributes, customAggregations: options.customAggregations, customTransforms: options.customTransforms, filter: this.subsetExpression }; + if (requester) { + externalValue.requester = requester; + } + if (cluster.type === 'druid') { externalValue.rollup = this.rollup; - externalValue.timeAttribute = this.timeAttribute.name; externalValue.introspectionStrategy = cluster.getIntrospectionStrategy(); externalValue.allowSelectQueries = true; + externalValue.allowEternity = !this.getPrimaryTimeAttribute(); + + if (options.druidTimeAttributeName) { + externalValue.timeAttribute = options.druidTimeAttributeName; + } var externalContext: Lookup = options.druidContext || {}; externalContext['timeout'] = cluster.getTimeout(); @@ -582,14 +621,6 @@ export class DataCube implements Instance { externalValue.context = externalContext; } - if (this.introspection === 'none') { - externalValue.attributes = AttributeInfo.override(this.deduceAttributes(), this.attributeOverrides); - externalValue.derivedAttributes = this.derivedAttributes; - } else { - // ToDo: else if (we know that it will GET introspect) and there are no overrides apply special attributes as overrides - externalValue.attributeOverrides = this.attributeOverrides; - } - return External.fromValue(externalValue); } @@ -614,18 +645,13 @@ export class DataCube implements Instance { }; } - public getIssues(): string[] { - var { dimensions, measures } = this; + public validateFormulaInMeasureContext(formula: string) { var mainTypeContext = this.getMainTypeContext(); - var issues: string[] = []; + var measureExpression = Expression.parse(formula); - dimensions.forEach((dimension) => { - try { - dimension.expression.referenceCheckInTypeContext(mainTypeContext); - } catch (e) { - issues.push(`failed to validate dimension '${dimension.name}': ${e.message}`); - } - }); + if (measureExpression.getFreeReferences().indexOf('main') === -1) { + throw new Error(`Measure formula must contain a $main reference.`); + } var measureTypeContext: DatasetFullType = { type: 'DATASET', @@ -634,55 +660,24 @@ export class DataCube implements Instance { } }; - measures.forEach((measure) => { - try { - measure.expression.referenceCheckInTypeContext(measureTypeContext); - } catch (e) { - var message = e.message; - // If we get here it is possible that the user has misunderstood what the meaning of a measure is and have tried - // to do something like $volume / $volume. We detect this here by checking for a reference to $main - // If there is no main reference raise a more informative issue. - if (measure.expression.getFreeReferences().indexOf('main') === -1) { - message = 'measure must contain a $main reference'; - } - issues.push(`failed to validate measure '${measure.name}': ${message}`); - } - }); - - return issues; - } - - public updateCluster(cluster: Cluster): DataCube { - var value = this.valueOf(); - value.cluster = cluster; - return new DataCube(value); - } - - public updateWithDataset(dataset: Dataset): DataCube { - if (this.clusterName !== 'native') throw new Error('must be native to have a dataset'); - - var executor = basicExecutorFactory({ - datasets: { main: dataset } - }); - - return this.addAttributes(dataset.attributes).attachExecutor(executor); - } - - public updateWithExternal(external: External): DataCube { - if (this.clusterName === 'native') throw new Error('can not be native and have an external'); - - var executor = basicExecutorFactory({ - datasets: { main: external } - }); - - return this.addAttributes(external.attributes).attachExecutor(executor); + try { + measureExpression.referenceCheckInTypeContext(measureTypeContext); + return true; + } catch (e) { + throw new Error(`Invalid formula: ${e.message}`); + } } - public attachExecutor(executor: Executor): DataCube { - var value = this.valueOf(); - value.executor = executor; - return new DataCube(value); - } + public validateFormula(formula: string): boolean { + var mainTypeContext = this.getMainTypeContext(); + var formulaExpr = Expression.parse(formula); + try { + formulaExpr.referenceCheckInTypeContext(mainTypeContext); + } catch (e) { + throw new Error(`Invalid formula: ${e.message}`); + } + return true; + }; public toClientDataCube(): DataCube { var value = this.valueOf(); @@ -701,8 +696,10 @@ export class DataCube implements Instance { return new DataCube(value); } - public isQueryable(): boolean { - return Boolean(this.executor); + public getMaxTimeQuery(): Expression { + const primaryTimeExpression = this.getPrimaryTimeExpression(); + if (!primaryTimeExpression) return null; + return ply().apply(MAX_TIME, $('main').max(primaryTimeExpression)); } public getMaxTime(timekeeper: Timekeeper): Date { @@ -745,16 +742,29 @@ export class DataCube implements Instance { } public getSuggestedDimensions(): Dimension[] { - const { dimensions } = this; // todo: actually implement this - return dimensions.toArray().splice(0, 5).map((d) => d.change('title', `${d.title}z`).change('name', `${d.name}z`)); + return this.filterDimensions(DataCube.suggestDimensions(this.attributes)); + } + + public getPrimaryTimeAttribute(): string { + const { primaryTimeAttribute } = this; + if (primaryTimeAttribute === '!none') return null; + return primaryTimeAttribute || null; + } + + public getPrimaryTimeExpression(): Expression { + var primaryTimeAttribute = this.getPrimaryTimeAttribute(); + return primaryTimeAttribute ? $(primaryTimeAttribute) : null; } - public getTimeDimension() { - return this.getDimensionByExpression(this.timeAttribute); + public isPrimaryTimeExpression(ex: Expression): boolean { + const timeExpression = this.getPrimaryTimeExpression(); + if (!timeExpression) return false; + return timeExpression.equals(ex); } - public isTimeAttribute(ex: Expression) { - return ex.equals(this.timeAttribute); + public isMandatoryFilter(ex: Expression): boolean { + // Note: isPrimaryTimeExpression and isMandatoryFilter are the same for now, but they do not have to be + return this.isPrimaryTimeExpression(ex); } public getMeasure(measureName: string): Measure { @@ -765,9 +775,29 @@ export class DataCube implements Instance { return this.measures.find(measure => measure.expression.equals(expression)); } + public getDimensionsForAttribute(attributeName: string): Dimension[] { + return this.dimensions.toArray().filter((dimension) => { + return dimension.usesAttribute(attributeName); + }); + } + + public getMeasuresForAttribute(attributeName: string): Measure[] { + return this.measures.toArray().filter((measure) => { + return measure.usesAttribute(attributeName); + }); + } + public getSuggestedMeasures(): Measure[] { - const { measures } = this; // todo: actually implement this - return measures.toArray().splice(0, 5).map((m) => m.change('title', `${m.title}z`).change('name', `${m.name}z`)); + return this.filterMeasures(DataCube.suggestMeasures(this.attributes)); + } + + public filterMeasures(measuresToFilter: Measure[]): Measure[] { + return measuresToFilter.filter(measure => { + if (this.getMeasure(measure.name)) return false; + if (this.getDimension(measure.name)) return false; + if (this.getMeasureByExpression(measure.expression)) return false; + return true; + }); } public changeDimensions(dimensions: List): DataCube { @@ -776,164 +806,96 @@ export class DataCube implements Instance { return new DataCube(value); } - public rolledUp(): boolean { - return this.clusterName === 'druid'; - } - - /** - * This function tries to deduce the structure of the dataCube based on the dimensions and measures defined within. - * It should only be used when, for some reason, introspection if not available. - */ - public deduceAttributes(): Attributes { - const { dimensions, measures, timeAttribute, attributeOverrides } = this; - var attributes: Attributes = []; + public removeDimension(dimension: Dimension): DataCube { + var index = this.dimensions.indexOf(dimension); - if (timeAttribute) { - attributes.push(AttributeInfo.fromJS({ name: timeAttribute.name, type: 'TIME' })); + if (index === -1) { + throw new Error(`Unknown dimension : ${dimension.toString()}`); } - dimensions.forEach((dimension) => { - var expression = dimension.expression; - if (expression.equals(timeAttribute)) return; - var references = expression.getFreeReferences(); - for (var reference of references) { - if (findByName(attributes, reference)) continue; - attributes.push(AttributeInfo.fromJS({ name: reference, type: 'STRING' })); - } - }); + var newDimensions = this.dimensions.toArray().concat(); + newDimensions.splice(index, 1); - measures.forEach((measure) => { - var expression = measure.expression; - var references = Measure.getAggregateReferences(expression); - var countDistinctReferences = Measure.getCountDistinctReferences(expression); - for (var reference of references) { - if (findByName(attributes, reference)) continue; - if (countDistinctReferences.indexOf(reference) !== -1) { - attributes.push(AttributeInfo.fromJS({ name: reference, special: 'unique' })); - } else { - attributes.push(AttributeInfo.fromJS({ name: reference, type: 'NUMBER' })); - } - } - }); - - if (attributeOverrides.length) { - attributes = AttributeInfo.override(attributes, attributeOverrides); - } - - return attributes; + return this.changeDimensions(List(newDimensions)); } - public addAttributes(newAttributes: Attributes): DataCube { - var { dimensions, measures, attributes } = this; - const introspection = this.getIntrospection(); - if (introspection === 'none') return this; - - var autofillDimensions = introspection === 'autofill-dimensions-only' || introspection === 'autofill-all'; - var autofillMeasures = introspection === 'autofill-measures-only' || introspection === 'autofill-all'; + public removeMeasure(measure: Measure): DataCube { + var index = this.measures.indexOf(measure); - var $main = $('main'); + if (index === -1) { + throw new Error(`Unknown measure : ${measure.toString()}`); + } - for (var newAttribute of newAttributes) { - var { name, type, special } = newAttribute; + var newMeasures = this.measures.toArray().concat(); + newMeasures.splice(index, 1); - // Already exists as a current attribute - if (attributes && findByName(attributes, name)) continue; + return this.changeMeasures(List(newMeasures)); + } - // Already exists as a current dimension or a measure - var urlSafeName = makeUrlSafeName(name); - if (this.getDimension(urlSafeName) || this.getMeasure(urlSafeName)) continue; + public filterDimensions(dimensionsToFilter: Dimension[]): Dimension[] { + return dimensionsToFilter.filter(dimension => { + if (this.getDimension(dimension.name)) return false; + if (this.getMeasure(dimension.name)) return false; + if (this.getDimensionByExpression(dimension.expression)) return false; + return true; + }); + } - var expression: Expression; - switch (type) { - case 'TIME': - if (!autofillDimensions) continue; - expression = $(name); - if (this.getDimensionByExpression(expression)) continue; - // Add to the start - dimensions = dimensions.unshift(new Dimension({ - name: urlSafeName, - kind: 'time', - formula: expression.toString() - })); - break; + public rolledUp(): boolean { + return this.clusterName === 'druid'; + } - case 'STRING': - if (special === 'unique' || special === 'theta') { - if (!autofillMeasures) continue; - - var newMeasures = Measure.measuresFromAttributeInfo(newAttribute); - newMeasures.forEach((newMeasure) => { - if (this.getMeasureByExpression(newMeasure.expression)) return; - measures = measures.push(newMeasure); - }); - } else { - if (!autofillDimensions) continue; - expression = $(name); - if (this.getDimensionByExpression(expression)) continue; - dimensions = dimensions.push(new Dimension({ - name: urlSafeName, - formula: expression.toString() - })); - } - break; + public changeAttributes(attributes: Attributes): DataCube { + var value = this.valueOf(); + if (value.attributeOverrides) { + attributes = AttributeInfo.override(attributes, value.attributeOverrides); + } + value.attributes = attributes; + value.attributeOverrides = null; + return new DataCube(value); + } - case 'SET/STRING': - if (!autofillDimensions) continue; - expression = $(name); - if (this.getDimensionByExpression(expression)) continue; - dimensions = dimensions.push(new Dimension({ - name: urlSafeName, - formula: expression.toString() - })); - break; + public updateAttribute(attribute: AttributeInfo): DataCube { + return this.changeAttributes(overrideByName(this.attributes, attribute)); + } - case 'BOOLEAN': - if (!autofillDimensions) continue; - expression = $(name); - if (this.getDimensionByExpression(expression)) continue; - dimensions = dimensions.push(new Dimension({ - name: urlSafeName, - kind: 'boolean', - formula: expression.toString() - })); - break; + public removeAttribute(attributeName: string): DataCube { + if (!this.attributes) return this; - case 'NUMBER': - if (!autofillMeasures) continue; + var value = this.valueOf(); + value.attributes = value.attributes.filter(attribute => attribute.name !== attributeName); + value.dimensions = value.dimensions.filter(dimension => !dimension.usesAttribute(attributeName)) as List; + value.measures = value.measures.filter(measure => !measure.usesAttribute(attributeName)) as List; + return new DataCube(value); + } - var newMeasures = Measure.measuresFromAttributeInfo(newAttribute); - newMeasures.forEach((newMeasure) => { - if (this.getMeasureByExpression(newMeasure.expression)) return; - measures = (name === 'count') ? measures.unshift(newMeasure) : measures.push(newMeasure); - }); - break; + public appendAttributes(attributes: Attributes): DataCube { + return this.changeAttributes(this.attributes.concat(attributes)); + } - default: - throw new Error(`unsupported type ${type}`); - } - } + public filterAttributes(attributesToFilter: Attributes): Attributes { + const { attributes } = this; + return attributesToFilter.filter(attribute => { + return !findByName(attributes, attribute.name); + }); + } - if (!this.rolledUp() && !measures.find(m => m.name === 'count')) { - measures = measures.unshift(new Measure({ - name: 'count', - formula: $main.count().toString() - })); - } + public fillAllFromAttributes(attributes: Attributes): DataCube { + var newDataCube = this.appendAttributes(this.filterAttributes(attributes)); - var value = this.valueOf(); - value.attributes = attributes ? AttributeInfo.override(attributes, newAttributes) : newAttributes; - value.dimensions = dimensions; - value.measures = measures; + var introspection = this.getIntrospection(); + // Most of the time introspection can be assumed to be 'autofill-all' consideration of the introspection value is + // done as a backwards compatibility measure - if (!value.defaultSortMeasure) { - value.defaultSortMeasure = measures.size ? measures.first().name : null; + if (introspection === 'autofill-all' || introspection === 'autofill-dimensions-only') { + newDataCube = newDataCube.appendDimensions(newDataCube.getSuggestedDimensions()); } - if (!value.timeAttribute && dimensions.size && dimensions.first().kind === 'time') { - value.timeAttribute = dimensions.first().expression; + if (introspection === 'autofill-all' || introspection === 'autofill-measures-only') { + newDataCube = newDataCube.appendMeasures(newDataCube.getSuggestedMeasures()); } - return new DataCube(value); + return newDataCube; } public getIntrospection(): Introspection { @@ -946,9 +908,10 @@ export class DataCube implements Instance { public getDefaultFilter(): Filter { var filter = this.defaultFilter || DataCube.DEFAULT_DEFAULT_FILTER; - if (this.timeAttribute) { + var primaryTimeExpression = this.getPrimaryTimeExpression(); + if (primaryTimeExpression) { filter = filter.setSelection( - this.timeAttribute, + primaryTimeExpression, $(FilterClause.MAX_TIME_REF_NAME).timeRange(this.getDefaultDuration(), -1) ); } @@ -969,7 +932,7 @@ export class DataCube implements Instance { } if (this.measures.size > 0) { - this.measures.first().name; + return this.measures.first().name; } return null; @@ -1010,9 +973,17 @@ export class DataCube implements Instance { return this.change('measures', measures); } + public appendMeasures(measures: Measure[]) { + return this.changeMeasures(List(this.measures.toArray().concat(measures))); + } + + public appendDimensions(dimensions: Dimension[]) { + return this.changeDimensions(List(this.dimensions.toArray().concat(dimensions))); + } + public getDefaultSortAction(): SortAction { return new SortAction({ - expression: $(this.defaultSortMeasure), + expression: $(this.getDefaultSortMeasure()), direction: SortAction.DESCENDING }); } diff --git a/src/common/models/dimension/dimension.ts b/src/common/models/dimension/dimension.ts index d231cc53..0e1cfd54 100644 --- a/src/common/models/dimension/dimension.ts +++ b/src/common/models/dimension/dimension.ts @@ -16,7 +16,7 @@ import { List } from 'immutable'; import { Class, Instance, isInstanceOf, immutableArraysEqual } from 'immutable-class'; -import { $, Expression } from 'plywood'; +import { $, Expression, RefExpression } from 'plywood'; import { verifyUrlSafeName, makeTitle } from '../../utils/general/general'; import { Granularity, GranularityJS, granularityFromJS, granularityToJS, granularityEquals } from "../granularity/granularity"; @@ -249,5 +249,15 @@ export class Dimension implements Instance { return this.change('formula', newFormula); } + public usesAttribute(attributeName: string): boolean { + return this.expression.some((ex) => { + if (ex instanceof RefExpression) { + return ex.name === attributeName; + } else { + return null; + } + }); + } + } check = Dimension; diff --git a/src/common/models/essence/essence.ts b/src/common/models/essence/essence.ts index 7ab0f549..10360ce9 100644 --- a/src/common/models/essence/essence.ts +++ b/src/common/models/essence/essence.ts @@ -195,7 +195,7 @@ export class Essence implements Instance { var visualization = findByName(visualizations, visualizationName); var timezone = parameters.timezone ? Timezone.fromJS(parameters.timezone) : null; - var filter = parameters.filter ? Filter.fromJS(parameters.filter).constrainToDimensions(dataCube.dimensions, dataCube.timeAttribute) : null; + var filter = parameters.filter ? Filter.fromJS(parameters.filter).constrainToDimensions(dataCube.dimensions, dataCube.getPrimaryTimeExpression()) : null; var splits = Splits.fromJS(parameters.splits || [], dataCube).constrainToDimensionsAndMeasures(dataCube.dimensions, dataCube.measures); var defaultSortMeasureName = dataCube.getDefaultSortMeasure(); @@ -213,13 +213,13 @@ export class Essence implements Instance { var compare: Filter = null; var compareJS = parameters.compare; if (compareJS) { - compare = Filter.fromJS(compareJS).constrainToDimensions(dataCube.dimensions, dataCube.timeAttribute); + compare = Filter.fromJS(compareJS).constrainToDimensions(dataCube.dimensions, dataCube.getPrimaryTimeExpression()); } var highlight: Highlight = null; var highlightJS = parameters.highlight; if (highlightJS) { - highlight = Highlight.fromJS(highlightJS).constrainToDimensions(dataCube.dimensions, dataCube.timeAttribute); + highlight = Highlight.fromJS(highlightJS).constrainToDimensions(dataCube.dimensions, dataCube.getPrimaryTimeExpression()); } return new Essence({ @@ -433,13 +433,13 @@ export class Essence implements Instance { return urlPrefix + this.toHash(); } - public getTimeAttribute(): RefExpression { - return this.dataCube.timeAttribute; - } - - public getTimeDimension(): Dimension { - return this.dataCube.getTimeDimension(); - } + // public getTimeAttribute(): RefExpression { + // return this.dataCube.timeAttribute; + // } + // + // public getTimeDimension(): Dimension { + // return this.dataCube.getTimeDimension(); + // } public evaluateSelection(selection: Expression, timekeeper: Timekeeper): TimeRange { var { timezone, dataCube } = this; @@ -459,8 +459,8 @@ export class Essence implements Instance { } public getTimeSelection(): Expression { - const timeAttribute = this.getTimeAttribute(); - return this.filter.getSelection(timeAttribute) as Expression; + const primaryTimeExpression = this.dataCube.getPrimaryTimeExpression(); + return this.filter.getSelection(primaryTimeExpression) as Expression; } public isFixedMeasureMode(): boolean { @@ -596,15 +596,18 @@ export class Essence implements Instance { } public updateDataCube(newDataCube: DataCube): Essence { - var { dataCube, visualizations } = this; + var { dataCube } = this; if (dataCube.equals(newDataCube)) return this; // nothing to do + var newPrimaryTimeExpression = newDataCube.getPrimaryTimeExpression(); + var oldPrimaryTimeExpression = dataCube.getPrimaryTimeExpression(); + var value = this.valueOf(); value.dataCube = newDataCube; // Make sure that all the elements of state are still valid - value.filter = value.filter.constrainToDimensions(newDataCube.dimensions, newDataCube.timeAttribute, dataCube.timeAttribute); + value.filter = value.filter.constrainToDimensions(newDataCube.dimensions, newPrimaryTimeExpression, oldPrimaryTimeExpression); value.splits = value.splits.constrainToDimensionsAndMeasures(newDataCube.dimensions, newDataCube.measures); value.selectedMeasures = constrainMeasures(value.selectedMeasures, newDataCube); if (value.selectedMeasures.size === 0) { @@ -620,11 +623,11 @@ export class Essence implements Instance { if (!newDataCube.getMeasure(value.pinnedSort)) value.pinnedSort = newDataCube.getDefaultSortMeasure(); if (value.compare) { - value.compare = value.compare.constrainToDimensions(newDataCube.dimensions, newDataCube.timeAttribute); + value.compare = value.compare.constrainToDimensions(newDataCube.dimensions, newPrimaryTimeExpression, oldPrimaryTimeExpression); } if (value.highlight) { - value.highlight = value.highlight.constrainToDimensions(newDataCube.dimensions, newDataCube.timeAttribute); + value.highlight = value.highlight.constrainToDimensions(newDataCube.dimensions, newPrimaryTimeExpression, oldPrimaryTimeExpression); } return new Essence(value); @@ -653,10 +656,11 @@ export class Essence implements Instance { return new Essence(value); } - public changeTimeSelection(check: Expression): Essence { + public changeTimeSelection(selection: Expression): Essence { var { filter } = this; - var timeAttribute = this.getTimeAttribute(); - return this.changeFilter(filter.setSelection(timeAttribute, check)); + var primaryTimeExpression = this.dataCube.getPrimaryTimeExpression(); + if (!primaryTimeExpression) return this; + return this.changeFilter(filter.setSelection(primaryTimeExpression, selection)); } public convertToSpecificFilter(timekeeper: Timekeeper): Essence { diff --git a/src/common/models/filter/filter.ts b/src/common/models/filter/filter.ts index 8b780aff..fcf044c1 100644 --- a/src/common/models/filter/filter.ts +++ b/src/common/models/filter/filter.ts @@ -252,9 +252,9 @@ export class Filter implements Instance { public getFileString(timeAttribute: Expression) { var nonTimeClauseSize = this.clauses.size; const timeRange = this.getExtent(timeAttribute); // ToDo: revisit this - const nonTimeFilters = ((nonTimeClauseSize: number) => { + const nonTimeFilters = (nonTimeClauseSize: number) => { return nonTimeClauseSize === 0 ? "" : `_filters-${nonTimeClauseSize}`; - }); + }; if (timeRange) { var { start, end } = timeRange; nonTimeClauseSize--; diff --git a/src/common/models/highlight/highlight.ts b/src/common/models/highlight/highlight.ts index 9e0dfa8b..8733baf2 100644 --- a/src/common/models/highlight/highlight.ts +++ b/src/common/models/highlight/highlight.ts @@ -96,9 +96,9 @@ export class Highlight implements Instance { return filter.applyDelta(this.delta); } - public constrainToDimensions(dimensions: List, timeAttribute: Expression): Highlight { + public constrainToDimensions(dimensions: List, timeAttribute: Expression, oldTimeAttribute: Expression = null): Highlight { var { delta } = this; - var newDelta = delta.constrainToDimensions(dimensions, timeAttribute); + var newDelta = delta.constrainToDimensions(dimensions, timeAttribute, oldTimeAttribute); if (newDelta === delta) return this; if (newDelta.length() === 0) return null; diff --git a/src/common/models/labels.ts b/src/common/models/labels.ts index 28ea6991..84f82d3d 100644 --- a/src/common/models/labels.ts +++ b/src/common/models/labels.ts @@ -16,19 +16,29 @@ import { DataCube, Dimension, Measure, Cluster } from './index'; -export const DIMENSION = { +export const ATTRIBUTE = { name: { - label: `Name (you won't be able to change this later)`, - description: `The name of the dimension. This does not have to correspond to the - attribute name (but the auto generated dimensions do). This should be a - URL safe string. Changing this property will break any URLs that someone - might have generated that include this dimension, that's why you can only - set it once` + label: `Name`, + description: `The attribute's name` + }, + type: { + label: `Type`, + description: `The attribute's type` }, + splittable: { + label: `Splittable`, + description: `Whether or not the attribute can be split on.` + }, + special: { + label: `Special`, + description: `Special measures that are not numbers` + } +}; + +export const DIMENSION = { title: { - label: `Title`, - description: `The title for this dimension in the UI. Can be anything and is safe - to change at any time.` + label: `Name`, + description: `The display name for this dimension in the UI.` }, kind: { label: `Kind`, @@ -56,16 +66,9 @@ export const DIMENSION = { }; export const COLLECTION = { - name: { - label: `Name (you won't be able to change this later)`, - description: `The name of the collection. This should be a - URL safe string. Changing this property will break any URLs that someone - might have generated that include this dimension, that's why you can only - set it once` - }, title: { label: `Title`, - description: `The title for this collection in the UI. Can be anything and is safe + description: `The display name for this collection in the UI. Can be anything and is safe to change at any time.` }, description: { @@ -75,17 +78,9 @@ export const COLLECTION = { }; export const COLLECTION_ITEM = { - name: { - label: `Name (you won't be able to change this later)`, - description: `The name of the collection item. This should be a - URL safe string. Changing this property will break any URLs that someone - might have generated that include this dimension, that's why you can only - set it once` - }, title: { - label: `Title`, - description: `The title for this item in the UI. Can be anything and is safe - to change at any time.` + label: `Name`, + description: `The display name for this item in the UI.` }, description: { label: 'Description', @@ -95,17 +90,9 @@ export const COLLECTION_ITEM = { }; export const MEASURE = { - name: { - label: `Name (you won't be able to change this later)`, - description: `The name of the measure. This should be a - URL safe string. Changing this property will break any URLs that someone - might have generated that include this dimension, that's why you can only - set it once` - }, title: { - label: `Title`, - description: `The title for this measure in the UI. Can be anything and is safe - to change at any time.` + label: `Name`, + description: `The display name for this measure in the UI.` }, units: { label: `Units`, @@ -114,21 +101,15 @@ export const MEASURE = { formula: { label: `Formula`, description: `The - Plywood expression for this dimension. By default it is + Plywood expression for this measure. By default it is $main.sum($name) where name is the name of the measure.` } }; export const CLUSTER = { title: { - label: 'Title', - description: `The title of the Cluster in the UI. Can be anything and is - safe to change at anytime` - }, - - name: { label: 'Name', - description: `The name of the cluster (to be referenced later from the data cube)` + description: `The name of the Cluster in the UI.` }, type: { label: 'Type', @@ -144,34 +125,14 @@ export const CLUSTER = { 'as the version will naturally be determined through introspection.' }, timeout: { - label: 'Timeout', - description: `The timeout to set on the queries in ms. Default is ${Cluster.DEFAULT_TIMEOUT}` + label: 'Timeout (ms)', + description: `The timeout to set on the queries. Default is ${Cluster.DEFAULT_TIMEOUT}` }, sourceListScan: { label: 'Source List Scan', description: `Should the sources of this cluster be automatically scanned and new sources added as data cubes. Default: ${Cluster.DEFAULT_SOURCE_LIST_SCAN}` }, - sourceListRefreshOnLoad: { - label: 'Source List Refresh On Load', - description: `Should the list of sources be reloaded every time that Swiv is - loaded. This will put additional load on the data store but will ensure that - sources are visible in the UI as soon as they are created.` - }, - sourceListRefreshInterval: { - label: 'Source List Refresh Interval', - description: `How often should sources be reloaded in ms. Default: ${Cluster.DEFAULT_SOURCE_LIST_REFRESH_INTERVAL}` - }, - sourceReintrospectOnLoad: { - label: 'Source Reintrospect On Load', - description: `Should sources be scanned for additional dimensions every time that - Swiv is loaded. This will put additional load on the data store but will - ensure that dimension are visible in the UI as soon as they are created. Default: ${Cluster.DEFAULT_SOURCE_REINTROSPECT_INTERVAL}` - }, - sourceReintrospectInterval: { - label: 'Source Reintrospect Interval', - description: 'How often should source schema be reloaded in ms.' - }, // Druid specific introspectionStrategy: { @@ -184,7 +145,7 @@ export const CLUSTER = { description: 'The request decorator module filepath to load.' }, - // PostGres + MySQL specific + // Postgres + MySQL specific database: { label: 'Database', description: 'The database to which to connect to.' @@ -213,17 +174,9 @@ export const GENERAL = { }; export const DATA_CUBE = { - name: { - label: 'Name', - description: `The name of the data cube as used internally in Swiv and used in the - URLs. This should be a URL safe string. Changing this property for a given - data cube will break any URLs that someone might have generated for that - data cube in the past.` - }, title: { - label: 'Title', - description: `The user visible name that will be used to describe this data cube in - the UI. It is always safe to change this.` + label: 'Name', + description: `The user visible name that will be used to describe this data cube in the UI.` }, description: { label: 'Description', @@ -243,7 +196,7 @@ export const DATA_CUBE = { description: 'The name of cube\'s source. The dataSource, table, or filename of the data for this cube' }, subsetFormula: { - label: 'Subset Formula', + label: 'Subset Filter Formula', description: 'A row level filter that is applied to the cube. This filter is never represented in the UI' }, defaultDuration: { @@ -271,5 +224,9 @@ export const DATA_CUBE = { description: `While Swiv tries to learn as much as it can from your data cube from Druid directly. It can not (yet) do a perfect job. The attributeOverrides: section of the data cube is there for you to fix that.` + }, + options: { + label: `Options`, + description: `This does options.` // todo fill me in } }; diff --git a/src/common/models/measure/measure.ts b/src/common/models/measure/measure.ts index f2ba2dec..348c4396 100644 --- a/src/common/models/measure/measure.ts +++ b/src/common/models/measure/measure.ts @@ -17,7 +17,7 @@ import { List } from 'immutable'; import { BaseImmutable, Property, isInstanceOf } from 'immutable-class'; import * as numeral from 'numeral'; -import { $, Expression, Datum, ApplyAction, AttributeInfo, ChainExpression, deduplicateSort } from 'plywood'; +import { $, Expression, Datum, ApplyAction, AttributeInfo, RefExpression, deduplicateSort } from 'plywood'; import { verifyUrlSafeName, makeTitle, makeUrlSafeName } from '../../utils/general/general'; function formatFnFactory(format: string): (n: number) => string { @@ -57,46 +57,6 @@ export class Measure extends BaseImmutable { return measures.find(measure => measure.name.toLowerCase() === measureName); } - /** - * Look for all instances of aggregateAction($blah) and return the blahs - * @param ex - * @returns {string[]} - */ - static getAggregateReferences(ex: Expression): string[] { - var references: string[] = []; - ex.forEach((ex: Expression) => { - if (ex instanceof ChainExpression) { - var actions = ex.actions; - for (var action of actions) { - if (action.isAggregate()) { - references = references.concat(action.getFreeReferences()); - } - } - } - }); - return deduplicateSort(references); - } - - /** - * Look for all instances of countDistinct($blah) and return the blahs - * @param ex - * @returns {string[]} - */ - static getCountDistinctReferences(ex: Expression): string[] { - var references: string[] = []; - ex.forEach((ex: Expression) => { - if (ex instanceof ChainExpression) { - var actions = ex.actions; - for (var action of actions) { - if (action.action === 'countDistinct') { - references = references.concat(action.getFreeReferences()); - } - } - } - }); - return deduplicateSort(references); - } - static measuresFromAttributeInfo(attribute: AttributeInfo): Measure[] { var { name, special } = attribute; var $main = $('main'); @@ -204,5 +164,15 @@ export class Measure extends BaseImmutable { public getFormat: () => string; public changeFormat: (newFormat: string) => this; + + public usesAttribute(attributeName: string): boolean { + return this.expression.some((ex, index, depth, nestDiff) => { + if (nestDiff > 0 && ex instanceof RefExpression) { + return ex.name === attributeName; + } else { + return null; + } + }); + } } BaseImmutable.finalize(Measure); diff --git a/src/common/models/sort-on/sort-on.ts b/src/common/models/sort-on/sort-on.ts index e5f4858b..e663347e 100644 --- a/src/common/models/sort-on/sort-on.ts +++ b/src/common/models/sort-on/sort-on.ts @@ -81,6 +81,7 @@ export class SortOn implements Instance { constructor(parameters: SortOnValue) { this.dimension = parameters.dimension; this.measure = parameters.measure; + if (!(this.dimension || this.measure)) throw new Error('must have a dimension or a measure'); } public valueOf(): SortOnValue { diff --git a/src/common/utils/string/string.ts b/src/common/utils/string/string.ts index d510e195..963cd523 100644 --- a/src/common/utils/string/string.ts +++ b/src/common/utils/string/string.ts @@ -14,10 +14,6 @@ * limitations under the License. */ -// Shamelessly stolen from http://stackoverflow.com/a/10006499 -// (well, traded for an upvote) -export const IP_REGEX = /^(\d|[1-9]\d|1\d\d|2([0-4]\d|5[0-5]))\.(\d|[1-9]\d|1\d\d|2([0-4]\d|5[0-5]))\.(\d|[1-9]\d|1\d\d|2([0-4]\d|5[0-5]))\.(\d|[1-9]\d|1\d\d|2([0-4]\d|5[0-5]))$/; - export const NUM_REGEX = /^\d+$/; @@ -25,6 +21,10 @@ export function firstUp(str: string): string { return str ? str.charAt(0).toUpperCase() + str.slice(1) : undefined; } +export function titleCase(str: string): string { + return firstUp(str.toLowerCase()); +} + export function pad(n: number, padding = 3): string { var str = String(n); diff --git a/src/common/utils/yaml-helper/yaml-helper.ts b/src/common/utils/yaml-helper/yaml-helper.ts index 344111e9..1dab5626 100644 --- a/src/common/utils/yaml-helper/yaml-helper.ts +++ b/src/common/utils/yaml-helper/yaml-helper.ts @@ -104,12 +104,7 @@ export function clusterToYAML(cluster: Cluster, withComments: boolean): string[] .add('host') .add('version') .add('timeout', {defaultValue: Cluster.DEFAULT_TIMEOUT}) - .add('sourceListScan', {defaultValue: Cluster.DEFAULT_SOURCE_LIST_SCAN}) - .add('sourceListRefreshOnLoad', {defaultValue: false}) - .add('sourceListRefreshInterval', {defaultValue: Cluster.DEFAULT_SOURCE_LIST_REFRESH_INTERVAL}) - .add('sourceReintrospectOnLoad', {defaultValue: false}) - .add('sourceReintrospectInterval', {defaultValue: Cluster.DEFAULT_SOURCE_REINTROSPECT_INTERVAL}) - ; + .add('sourceListScan', {defaultValue: Cluster.DEFAULT_SOURCE_LIST_SCAN}); if (withComments) { @@ -241,16 +236,15 @@ export function dataCubeToYAML(dataCube: DataCube, withComments: boolean): strin `source: ${dataCube.source}` ]; - var timeAttribute = dataCube.timeAttribute; - if (timeAttribute && !(dataCube.clusterName === 'druid' && timeAttribute.name === '__time')) { + var primaryTimeAttribute = dataCube.primaryTimeAttribute; + if (primaryTimeAttribute) { if (withComments) { lines.push(`# The primary time attribute of the data refers to the attribute that must always be filtered on`); lines.push(`# This is particularly useful for Druid data cubes as they must always have a time filter.`); } - lines.push(`timeAttribute: ${timeAttribute.name}`, ''); + lines.push(`primaryTimeAttribute: ${primaryTimeAttribute}`, ''); } - var refreshRule = dataCube.refreshRule; if (withComments) { lines.push("# The refresh rule describes how often the data cube looks for new data. Default: 'query'/PT1M (every minute)"); diff --git a/src/server/app.ts b/src/server/app.ts index c79c7780..3244e5df 100644 --- a/src/server/app.ts +++ b/src/server/app.ts @@ -116,8 +116,8 @@ app.use((req: SwivRequest, res: Response, next: Function) => { req.user = null; req.version = VERSION; req.stateful = stateful; - req.getSettings = (opts: GetSettingsOptions = {}) => { - return SETTINGS_MANAGER.getSettings(opts); + req.getFullSettings = (opts: GetSettingsOptions = {}) => { + return SETTINGS_MANAGER.getFullSettings(opts); }; next(); }); diff --git a/src/server/config.ts b/src/server/config.ts index d9c5d94e..2d46b94f 100644 --- a/src/server/config.ts +++ b/src/server/config.ts @@ -18,8 +18,9 @@ import * as path from 'path'; import * as nopt from 'nopt'; import { TRACKER, LOGGER } from 'logger-tracker'; -import { arraySum } from '../common/utils/general/general'; +import { arraySum, inlineVars } from '../common/utils/general/general'; import { Cluster, DataCube, SupportedType, AppSettings } from '../common/models/index'; +import { MANIFESTS } from '../common/manifests/index'; import { appSettingsToYAML } from '../common/utils/yaml-helper/yaml-helper'; import { ServerSettings } from './models/index'; import { loadFileSync, SettingsManager, SettingsStore } from './utils/index'; @@ -45,6 +46,13 @@ function zeroOne(thing: any): number { return Number(Boolean(thing)); } +function appSettingsJSHasOnLoad(appSettingsJS: any): boolean { + if (appSettingsJS.sourceReintrospectOnLoad || appSettingsJS.sourceListRefreshOnLoad) return true; + if (Array.isArray(appSettingsJS.clusters)) { + return appSettingsJS.clusters.some((cluster: any) => cluster.sourceReintrospectOnLoad || cluster.sourceListRefreshOnLoad); + } + return false; +} var packageObj: any = null; try { @@ -79,8 +87,8 @@ Data connection options: Exactly one data connection option must be provided. -c, --config Use this local configuration (YAML) file - --examples Start Swiv with some example data for testing / demo - -f, --file Start Swiv on top of this file based data cube (must be JSON, CSV, or TSV) + --examples Start swiv with some example data for testing / demo + -f, --file Start swiv on top of this file based data cube (must be JSON, CSV, or TSV) -d, --druid The Druid broker node to connect to --postgres The Postgres cluster to connect to --mysql The MySQL cluster to connect to @@ -184,7 +192,7 @@ var serverSettingsJS: any; if (serverSettingsFilePath) { anchorPath = path.dirname(serverSettingsFilePath); try { - serverSettingsJS = loadFileSync(serverSettingsFilePath, 'yaml'); + serverSettingsJS = inlineVars(loadFileSync(serverSettingsFilePath, 'yaml'), process.env); LOGGER.log(`Using config ${serverSettingsFilePath}`); } catch (e) { exitWithError(`Could not load config from '${serverSettingsFilePath}': ${e.message}`); @@ -288,7 +296,13 @@ if (serverSettingsFilePath) { } } else { - settingsStore = SettingsStore.fromReadOnlyFile(serverSettingsFilePath, 'yaml'); + // Assume that the config holds the settings this used to be the only way to provide settings + try { + var appSettingsFromConfig = AppSettings.fromJS(serverSettingsJS, { visualizations: MANIFESTS }); + } catch (e) { + exitWithError(`Could not read setting from config file: ${e.message}`); + } + settingsStore = SettingsStore.fromTransient(appSettingsFromConfig, appSettingsJSHasOnLoad(serverSettingsJS)); } } else { var initAppSettings = AppSettings.BLANK; @@ -296,8 +310,10 @@ if (serverSettingsFilePath) { // If a file is specified add it as a dataCube var fileToLoad = parsedArgs['file']; if (fileToLoad) { + var fileName = path.basename(fileToLoad, path.extname(fileToLoad)); initAppSettings = initAppSettings.addDataCube(new DataCube({ - name: path.basename(fileToLoad, path.extname(fileToLoad)), + name: fileName, + title: fileName, clusterName: 'native', source: fileToLoad })); @@ -310,8 +326,6 @@ if (serverSettingsFilePath) { name: clusterType, type: clusterType, host: host, - sourceListScan: 'auto', - sourceListRefreshInterval: 15000, user: parsedArgs['user'], password: parsedArgs['password'], @@ -335,10 +349,10 @@ export const SETTINGS_MANAGER = new SettingsManager(settingsStore, { if (PRINT_CONFIG) { var withComments = Boolean(parsedArgs['with-comments']); - SETTINGS_MANAGER.getSettings({ + SETTINGS_MANAGER.getFullSettings({ timeout: 10000 - }).then(appSettings => { - console.log(appSettingsToYAML(appSettings, withComments, { + }).then(fullSettings => { + console.log(appSettingsToYAML(fullSettings.appSettings, withComments, { header: true, version: VERSION, verbose: VERBOSE, diff --git a/src/server/routes/collections/collections.ts b/src/server/routes/collections/collections.ts index 25278612..5c02149f 100644 --- a/src/server/routes/collections/collections.ts +++ b/src/server/routes/collections/collections.ts @@ -34,8 +34,9 @@ router.post('/', (req: SwivRequest, res: Response) => { return; } - SETTINGS_MANAGER.getSettings() - .then((appSettings) => { + req.getFullSettings() + .then((fullSettings) => { + var { appSettings } = fullSettings; var collectionContext = { dataCubes: appSettings.dataCubes, visualizations: MANIFESTS @@ -43,7 +44,7 @@ router.post('/', (req: SwivRequest, res: Response) => { return appSettings.changeCollections(collections.map((collection: CollectionJS) => { return Collection.fromJS(collection, collectionContext); - })); + })).incrementVersion(); }) .then((newAppSettings) => SETTINGS_MANAGER.updateSettings(newAppSettings)) .then( diff --git a/src/server/routes/mkurl/mkurl.mocha.ts b/src/server/routes/mkurl/mkurl.mocha.ts index a239ffdd..fff86c9e 100644 --- a/src/server/routes/mkurl/mkurl.mocha.ts +++ b/src/server/routes/mkurl/mkurl.mocha.ts @@ -32,11 +32,18 @@ var app = express(); app.use(bodyParser.json()); -var appSettings: AppSettings = AppSettingsMock.wikiOnlyWithExecutor(); +var appSettings = AppSettingsMock.wikiOnly(); +var executors = AppSettingsMock.executorsWiki(); app.use((req: SwivRequest, res: Response, next: Function) => { req.user = null; req.version = '0.9.4'; - req.getSettings = (dataCubeOfInterest?: string) => Q(appSettings); + req.getFullSettings = (dataCubeOfInterest?: string) => { + return Q({ + appSettings, + timekeeper: null, + executors + }); + }; next(); }); diff --git a/src/server/routes/mkurl/mkurl.ts b/src/server/routes/mkurl/mkurl.ts index 84cb5afc..fd32a328 100644 --- a/src/server/routes/mkurl/mkurl.ts +++ b/src/server/routes/mkurl/mkurl.ts @@ -47,9 +47,9 @@ router.post('/', (req: SwivRequest, res: Response) => { return; } - req.getSettings(dataCube) - .then((appSettings) => { - var myDataCube = appSettings.getDataCube(dataCube); + req.getFullSettings(dataCube) + .then((fullSettings) => { + var myDataCube = fullSettings.appSettings.getDataCube(dataCube); if (!myDataCube) { res.status(400).send({ error: 'unknown data cube' }); return; diff --git a/src/server/routes/plyql/plyql.mocha.ts b/src/server/routes/plyql/plyql.mocha.ts index 435c5c80..addef769 100644 --- a/src/server/routes/plyql/plyql.mocha.ts +++ b/src/server/routes/plyql/plyql.mocha.ts @@ -32,11 +32,18 @@ var app = express(); app.use(bodyParser.json()); -var appSettings: AppSettings = AppSettingsMock.wikiOnlyWithExecutor(); +var appSettings = AppSettingsMock.wikiOnly(); +var executors = AppSettingsMock.executorsWiki(); app.use((req: SwivRequest, res: Response, next: Function) => { req.user = null; req.version = '0.9.4'; - req.getSettings = (dataCubeOfInterest?: string) => Q(appSettings); + req.getFullSettings = (dataCubeOfInterest?: string) => { + return Q({ + appSettings, + timekeeper: null, + executors + }); + }; next(); }); diff --git a/src/server/routes/plyql/plyql.ts b/src/server/routes/plyql/plyql.ts index 9e7f1495..28e4b1f5 100644 --- a/src/server/routes/plyql/plyql.ts +++ b/src/server/routes/plyql/plyql.ts @@ -79,16 +79,24 @@ router.post('/', (req: SwivRequest, res: Response) => { return null; }); - req.getSettings(dataCube) - .then((appSettings) => { + req.getFullSettings(dataCube) + .then((fullSettings) => { + const { appSettings, executors } = fullSettings; + var myDataCube = appSettings.getDataCube(dataCube); if (!myDataCube) { res.status(400).send({ error: 'unknown data cube' }); - return; + return null; + } + + var myExecutor = executors[myDataCube.name]; + if (!myExecutor) { + res.status(400).send({ error: 'unqueryable data cube' }); + return null; } - myDataCube.executor(parsedQuery).then( + return myExecutor(parsedQuery).then( (data: Dataset) => { res.type(outputType); res.send(outputFn(Dataset.fromJS(data.toJS()))); diff --git a/src/server/routes/plywood/plywood.mocha.ts b/src/server/routes/plywood/plywood.mocha.ts index 23f969ce..d5ee9fe9 100644 --- a/src/server/routes/plywood/plywood.mocha.ts +++ b/src/server/routes/plywood/plywood.mocha.ts @@ -32,12 +32,19 @@ var app = express(); app.use(bodyParser.json()); -var appSettings: AppSettings = AppSettingsMock.wikiOnlyWithExecutor(); +var appSettings = AppSettingsMock.wikiOnly(); +var executors = AppSettingsMock.executorsWiki(); app.use((req: SwivRequest, res: Response, next: Function) => { req.user = null; req.version = '0.9.4'; req.stateful = false; - req.getSettings = (dataCubeOfInterest?: string) => Q(appSettings); + req.getFullSettings = (dataCubeOfInterest?: string) => { + return Q({ + appSettings, + timekeeper: null, + executors + }); + }; next(); }); diff --git a/src/server/routes/plywood/plywood.ts b/src/server/routes/plywood/plywood.ts index ae69fb70..dba40655 100644 --- a/src/server/routes/plywood/plywood.ts +++ b/src/server/routes/plywood/plywood.ts @@ -57,12 +57,14 @@ router.post('/', (req: SwivRequest, res: Response) => { return; } - req.getSettings(dataCube) // later: , settingsVersion) - .then((appSettings) => { - // var settingsBehind = false; - // if (appSettings.getVersion() < settingsVersion) { - // settingsBehind = true; - // } + req.getFullSettings(dataCube) // later: , settingsVersion) + .then((fullSettings) => { + const { appSettings, executors } = fullSettings; + var settingsBehind = false; + console.log(appSettings.getVersion(), '?', settingsVersion); + if (settingsVersion < appSettings.getVersion()) { + settingsBehind = true; + } var myDataCube = appSettings.getDataCube(dataCube); if (!myDataCube) { @@ -70,17 +72,18 @@ router.post('/', (req: SwivRequest, res: Response) => { return null; } - if (!myDataCube.executor) { - res.status(400).send({ error: 'un queryable data cube' }); + var myExecutor = executors[myDataCube.name]; + if (!myExecutor) { + res.status(400).send({ error: 'unqueryable data cube' }); return null; } - return myDataCube.executor(ex, { timezone: queryTimezone }).then( + return myExecutor(ex, { timezone: queryTimezone }).then( (data: PlywoodValue) => { var reply: any = { result: Dataset.isDataset(data) ? data.toJS() : data }; - //if (settingsBehind) reply.action = 'update'; + if (settingsBehind) reply.action = 'update'; res.json(reply); }, (e: Error) => { diff --git a/src/server/routes/settings/settings.ts b/src/server/routes/settings/settings.ts index f7658864..e089de9f 100644 --- a/src/server/routes/settings/settings.ts +++ b/src/server/routes/settings/settings.ts @@ -15,7 +15,8 @@ */ import { Router, Request, Response } from 'express'; -import { AppSettings } from '../../../common/models/index'; +import { Dataset } from 'plywood'; +import { AppSettings, Cluster, DataCube } from '../../../common/models/index'; import { MANIFESTS } from '../../../common/manifests/index'; import { SwivRequest } from '../../utils/index'; @@ -24,10 +25,10 @@ import { SETTINGS_MANAGER } from '../../config'; var router = Router(); router.get('/', (req: SwivRequest, res: Response) => { - SETTINGS_MANAGER.getSettings() + SETTINGS_MANAGER.getFullSettings() .then( - (appSettings) => { - res.send({ appSettings }); + (fullSettings) => { + res.send({ appSettings: fullSettings.appSettings }); }, (e: Error) => { console.log('error:', e.message); @@ -44,6 +45,153 @@ router.get('/', (req: SwivRequest, res: Response) => { }); +router.post('/cluster-connection', (req: SwivRequest, res: Response) => { + var { cluster } = req.body; + + if (typeof cluster === 'undefined') { + res.status(400).send({ + error: 'must have a cluster' + }); + return; + } + + try { + var testCluster = Cluster.fromJS(cluster); + } catch (e) { + res.status(400).send({ + error: 'invalid cluster', + message: e.message + }); + return; + } + + SETTINGS_MANAGER.checkClusterConnectionInfo(testCluster) + .then( + (clusterAndSources) => { + res.send(clusterAndSources); + }, + (e: Error) => { + console.log('error:', e.message); + if (e.hasOwnProperty('stack')) { + console.log((e).stack); + } + res.status(500).send({ + error: 'could not compute', + message: e.message + }); + } + ) + .done(); +}); + +router.get('/cluster-sources', (req: SwivRequest, res: Response) => { + SETTINGS_MANAGER.getAllClusterSources() + .then( + (clusterSources) => { + res.send({ clusterSources: clusterSources }); + }, + (e: Error) => { + console.log('error:', e.message); + if (e.hasOwnProperty('stack')) { + console.log((e).stack); + } + res.status(500).send({ + error: 'could not compute', + message: e.message + }); + } + ) + .done(); +}); + +router.post('/attributes', (req: SwivRequest, res: Response) => { + var { source, cluster, clusterName } = req.body; + + if (typeof source !== 'string') { + res.status(400).send({ + error: 'must have a source' + }); + return; + } + + var testCluster: Cluster = null; + if (typeof clusterName !== 'string') { + if (typeof cluster === 'undefined') { + res.status(400).send({ + error: 'must have a clusterName or cluster' + }); + return; + } + + try { + testCluster = Cluster.fromJS(cluster); + } catch (e) { + res.status(400).send({ + error: 'invalid cluster', + message: e.message + }); + return; + } + } + + SETTINGS_MANAGER.getAllAttributes(source, testCluster || clusterName) + .then( + (attributes) => { + res.send({ attributes: attributes }); + }, + (e: Error) => { + console.log('error:', e.message); + if (e.hasOwnProperty('stack')) { + console.log((e).stack); + } + res.status(500).send({ + error: 'could not compute', + message: e.message + }); + } + ) + .done(); +}); + +router.post('/preview', (req: SwivRequest, res: Response) => { + var { dataCube } = req.body; + + if (typeof dataCube === 'undefined') { + res.status(400).send({ + error: 'must have a dataCube' + }); + return; + } + + try { + var previewDataCube = DataCube.fromJS(dataCube); + } catch (e) { + res.status(400).send({ + error: 'invalid DataCube', + message: e.message + }); + return; + } + + SETTINGS_MANAGER.preview(previewDataCube) + .then( + (dataset: Dataset) => { + res.send({ dataset }); + }, + (e: Error) => { + console.log('error:', e.message); + if (e.hasOwnProperty('stack')) { + console.log((e).stack); + } + res.status(500).send({ + error: 'could not compute', + message: e.message + }); + } + ) + .done(); +}); + router.post('/', (req: SwivRequest, res: Response) => { var { appSettings } = req.body; diff --git a/src/server/routes/swiv/swiv.mocha.ts b/src/server/routes/swiv/swiv.mocha.ts index 361b07bd..440416b3 100644 --- a/src/server/routes/swiv/swiv.mocha.ts +++ b/src/server/routes/swiv/swiv.mocha.ts @@ -29,11 +29,18 @@ import * as swivRouter from './swiv'; var app = express(); -var appSettings: AppSettings = AppSettingsMock.wikiOnlyWithExecutor(); +var appSettings = AppSettingsMock.wikiOnly(); +var executors = AppSettingsMock.executorsWiki(); app.use((req: SwivRequest, res: express.Response, next: Function) => { req.user = null; req.version = '0.9.4'; - req.getSettings = (dataCubeOfInterest?: string) => Q(appSettings); + req.getFullSettings = (dataCubeOfInterest?: string) => { + return Q({ + appSettings, + timekeeper: null, + executors + }); + }; next(); }); diff --git a/src/server/routes/swiv/swiv.ts b/src/server/routes/swiv/swiv.ts index 73b462fa..6a9939aa 100644 --- a/src/server/routes/swiv/swiv.ts +++ b/src/server/routes/swiv/swiv.ts @@ -18,20 +18,20 @@ import { Router, Request, Response } from 'express'; import { SwivRequest } from '../../utils/index'; import { swivLayout } from '../../views'; -import { SETTINGS_MANAGER } from '../../config'; var router = Router(); router.get('/', (req: SwivRequest, res: Response, next: Function) => { - req.getSettings() - .then((appSettings) => { + req.getFullSettings() + .then((fullSettings) => { + const { appSettings, timekeeper } = fullSettings; var clientSettings = appSettings.toClientSettings(); res.send(swivLayout({ version: req.version, - title: appSettings.customization.getTitle(req.version), + title: appSettings.customization.getTitleWithVersion(req.version), user: req.user, appSettings: clientSettings, - timekeeper: SETTINGS_MANAGER.getTimekeeper(), + timekeeper: timekeeper, stateful: req.stateful })); }) diff --git a/src/server/utils/cluster-manager/cluster-manager.ts b/src/server/utils/cluster-manager/cluster-manager.ts index 62653e38..19a04192 100644 --- a/src/server/utils/cluster-manager/cluster-manager.ts +++ b/src/server/utils/cluster-manager/cluster-manager.ts @@ -35,21 +35,10 @@ export interface DruidRequestDecoratorModule { druidRequestDecoratorFactory: (logger: Logger, params: RequestDecoratorFactoryParams) => DruidRequestDecorator; } -// For each external we want to maintain its source and weather it should introspect at all -export interface ManagedExternal { - name: string; - external: External; - autoDiscovered?: boolean; - suppressIntrospection?: boolean; -} - export interface ClusterManagerOptions { logger: Logger; verbose?: boolean; anchorPath: string; - initialExternals?: ManagedExternal[]; - onExternalChange?: (name: string, external: External) => Q.Promise; - generateExternalName?: (external: External) => string; } function noop() {} @@ -67,16 +56,8 @@ export class ClusterManager { public introspectedSources: Lookup; public version: string; public requester: Requester.PlywoodRequester; - public managedExternals: ManagedExternal[] = []; - public onExternalChange: (name: string, external: External) => void; - public generateExternalName: (external: External) => string; public requestDecoratorModule: DruidRequestDecoratorModule; - private sourceListRefreshInterval: number = 0; - private sourceListRefreshTimer: NodeJS.Timer = null; - private sourceReintrospectInterval: number = 0; - private sourceReintrospectTimer: NodeJS.Timer = null; - private initialConnectionTimer: NodeJS.Timer = null; constructor(cluster: Cluster, options: ClusterManagerOptions) { @@ -88,62 +69,18 @@ export class ClusterManager { this.initialConnectionEstablished = false; this.introspectedSources = {}; this.version = cluster.version; - this.managedExternals = options.initialExternals || []; - this.onExternalChange = options.onExternalChange || noop; - this.generateExternalName = options.generateExternalName || getSourceFromExternal; this.updateRequestDecorator(); this.updateRequester(); - - this.managedExternals.forEach((managedExternal) => { - managedExternal.external = managedExternal.external.attachRequester(this.requester); - }); - } - - // Do initialization - public init(): Q.Promise { - const { cluster, logger } = this; - - if (cluster.sourceListRefreshOnLoad) { - logger.log(`Cluster '${cluster.name}' will refresh source list on load`); - } - - if (cluster.sourceReintrospectOnLoad) { - logger.log(`Cluster '${cluster.name}' will reintrospect sources on load`); - } - - return Q(null) - .then(() => this.establishInitialConnection()) - .then(() => this.introspectSources()) - .then(() => this.scanSourceList()); } public destroy() { - if (this.sourceListRefreshTimer) { - clearInterval(this.sourceListRefreshTimer); - this.sourceListRefreshTimer = null; - } - if (this.sourceReintrospectTimer) { - clearInterval(this.sourceReintrospectTimer); - this.sourceReintrospectTimer = null; - } if (this.initialConnectionTimer) { clearTimeout(this.initialConnectionTimer); this.initialConnectionTimer = null; } } - private addManagedExternal(managedExternal: ManagedExternal): Q.Promise { - this.managedExternals.push(managedExternal); - return Q(this.onExternalChange(managedExternal.name, managedExternal.external)); - } - - private updateManagedExternal(managedExternal: ManagedExternal, newExternal: External): Q.Promise { - if (managedExternal.external.equals(newExternal)) return null; - managedExternal.external = newExternal; - return Q(this.onExternalChange(managedExternal.name, managedExternal.external)); - } - private updateRequestDecorator(): void { const { cluster, logger, anchorPath } = this; if (!cluster.requestDecorator) return; @@ -188,55 +125,7 @@ export class ClusterManager { }); } - private updateSourceListRefreshTimer() { - const { logger, cluster } = this; - - if (this.sourceListRefreshInterval !== cluster.getSourceListRefreshInterval()) { - this.sourceListRefreshInterval = cluster.getSourceListRefreshInterval(); - - if (this.sourceListRefreshTimer) { - logger.log(`Clearing sourceListRefresh timer in cluster '${cluster.name}'`); - clearInterval(this.sourceListRefreshTimer); - this.sourceListRefreshTimer = null; - } - - if (this.sourceListRefreshInterval && cluster.shouldScanSources()) { - logger.log(`Setting up sourceListRefresh timer in cluster '${cluster.name}' (every ${this.sourceListRefreshInterval}ms)`); - this.sourceListRefreshTimer = setInterval(() => { - this.scanSourceList().catch((e) => { - logger.error(`Cluster '${cluster.name}' encountered and error during SourceListRefresh: ${e.message}`); - }); - }, this.sourceListRefreshInterval); - this.sourceListRefreshTimer.unref(); - } - } - } - - private updateSourceReintrospectTimer() { - const { logger, cluster } = this; - - if (this.sourceReintrospectInterval !== cluster.getSourceReintrospectInterval()) { - this.sourceReintrospectInterval = cluster.getSourceReintrospectInterval(); - - if (this.sourceReintrospectTimer) { - logger.log(`Clearing sourceReintrospect timer in cluster '${cluster.name}'`); - clearInterval(this.sourceReintrospectTimer); - this.sourceReintrospectTimer = null; - } - - if (this.sourceReintrospectInterval) { - logger.log(`Setting up sourceReintrospect timer in cluster '${cluster.name}' (every ${this.sourceReintrospectInterval}ms)`); - this.sourceReintrospectTimer = setInterval(() => { - this.introspectSources().catch((e) => { - logger.error(`Cluster '${cluster.name}' encountered and error during SourceReintrospect: ${e.message}`); - }); - }, this.sourceReintrospectInterval); - this.sourceReintrospectTimer.unref(); - } - } - } - - private establishInitialConnection(): Q.Promise { + public establishInitialConnection(maxRetries = Infinity): Q.Promise { const { logger, verbose, cluster } = this; var deferred: Q.Deferred = Q.defer(); @@ -262,7 +151,11 @@ export class ClusterManager { var msSinceLastTry = Date.now() - lastTryAt; var msToWait = Math.max(1, CONNECTION_RETRY_TIMEOUT - msSinceLastTry); logger.error(`Failed to connect to cluster '${cluster.name}' because: ${e.message} (will retry in ${msToWait}ms)`); - this.initialConnectionTimer = setTimeout(attemptConnection, msToWait); + if (retryNumber < maxRetries) { + this.initialConnectionTimer = setTimeout(attemptConnection, msToWait); + } else { + deferred.reject(new Error('too many failed attempts')); + } } ); }; @@ -276,9 +169,6 @@ export class ClusterManager { const { logger, cluster } = this; logger.log(`Connected to cluster '${cluster.name}'`); this.initialConnectionEstablished = true; - - this.updateSourceListRefreshTimer(); - this.updateSourceReintrospectTimer(); } private internalizeVersion(version: string): Q.Promise { @@ -290,111 +180,11 @@ export class ClusterManager { this.version = version; // Update all externals if needed - return Q.all( - this.managedExternals.map(managedExternal => { - if (managedExternal.external.version) return Q(null); - return this.updateManagedExternal(managedExternal, managedExternal.external.changeVersion(version)); - }) - ); - } - - private introspectManagedExternal(managedExternal: ManagedExternal): Q.Promise { - const { logger, verbose, cluster } = this; - if (managedExternal.suppressIntrospection) return Q(null); - - if (verbose) logger.log(`Cluster '${cluster.name}' introspecting '${managedExternal.name}'`); - return managedExternal.external.introspect() - .then( - (introspectedExternal) => { - this.introspectedSources[String(introspectedExternal.source)] = true; - return this.updateManagedExternal(managedExternal, introspectedExternal); - }, - (e: Error) => { - logger.error(`Cluster '${cluster.name}' could not introspect '${managedExternal.name}' because: ${e.message}`); - } - ); - } - - // See if any new sources were added to the cluster - public scanSourceList(): Q.Promise { - const { logger, cluster, verbose } = this; - if (!cluster.shouldScanSources()) return Q(null); - - logger.log(`Scanning cluster '${cluster.name}' for new sources`); - return (External.getConstructorFor(cluster.type) as any).getSourceList(this.requester) - .then( - (sources: string[]) => { - if (verbose) logger.log(`For cluster '${cluster.name}' got sources: [${sources.join(', ')}]`); - // For every un-accounted source: make an external and add it to the managed list. - var introspectionTasks: Q.Promise[] = []; - sources.forEach((source) => { - var existingExternalsForSource = this.managedExternals.filter(managedExternal => getSourceFromExternal(managedExternal.external) === source); - - if (existingExternalsForSource.length) { - if (verbose) logger.log(`Cluster '${cluster.name}' already has an external for '${source}' ('${existingExternalsForSource[0].name}')`); - if (!this.introspectedSources[source]) { - // If this source has never been introspected introspect all of its externals - logger.log(`Cluster '${cluster.name}' has never seen '${source}' and will introspect '${existingExternalsForSource[0].name}'`); - existingExternalsForSource.forEach(existingExternalForSource => { - introspectionTasks.push(this.introspectManagedExternal(existingExternalForSource)); - }); - } - - } else { - logger.log(`Cluster '${cluster.name}' making external for '${source}'`); - var external = cluster.makeExternalFromSourceName(source, this.version).attachRequester(this.requester); - var newManagedExternal: ManagedExternal = { - name: this.generateExternalName(external), - external: external, - autoDiscovered: true - }; - introspectionTasks.push( - this.addManagedExternal(newManagedExternal) - .then(() => this.introspectManagedExternal(newManagedExternal)) - ); - - } - }); - - return Q.all(introspectionTasks); - }, - (e: Error) => { - logger.error(`Failed to get source list from cluster '${cluster.name}' because: ${e.message}`); - } - ); - } - - // See if any new dimensions or measures were added to the existing externals - public introspectSources(): Q.Promise { - const { logger, cluster } = this; - - logger.log(`Introspecting all sources in cluster '${cluster.name}'`); - - return Q.all(this.managedExternals.map((managedExternal) => { - return this.introspectManagedExternal(managedExternal); - })); - } - - // Refresh the cluster now, will trigger onExternalUpdate and then return an empty promise when done - public refresh(): Q.Promise { - const { cluster, initialConnectionEstablished } = this; - var process = Q(null); - if (!initialConnectionEstablished) return process; - - if (cluster.sourceReintrospectOnLoad) { - process = process.then(() => this.introspectSources()); - } - - if (cluster.sourceListRefreshOnLoad) { - process = process.then(() => this.scanSourceList()); - } - - return process; + return Q(null); } - public getExternalByName(name: string): External { - var managedExternal = findByName(this.managedExternals, name); - return managedExternal ? managedExternal.external : null; + public getSources(): Q.Promise { + return (External.getConstructorFor(this.cluster.type) as any).getSourceList(this.requester); } } diff --git a/src/server/utils/file-manager/file-manager.ts b/src/server/utils/file-manager/file-manager.ts index 58a41e0f..a1823946 100644 --- a/src/server/utils/file-manager/file-manager.ts +++ b/src/server/utils/file-manager/file-manager.ts @@ -46,7 +46,6 @@ export interface FileManagerOptions { anchorPath: string; uri: string; subsetExpression?: Expression; - onDatasetChange?: (dataset: Dataset) => void; } function noop() {} @@ -58,7 +57,6 @@ export class FileManager { public uri: string; public dataset: Dataset; public subsetExpression: Expression; - public onDatasetChange: (dataset: Dataset) => void; constructor(options: FileManagerOptions) { this.logger = options.logger; @@ -67,19 +65,17 @@ export class FileManager { this.uri = options.uri; this.subsetExpression = options.subsetExpression; this.verbose = Boolean(options.verbose); - this.onDatasetChange = options.onDatasetChange || noop; } - // Do initialization - public init(): Q.Promise { + public loadDataset(): Q.Promise { const { logger, anchorPath, uri } = this; var filePath = path.resolve(anchorPath, uri); logger.log(`Loading file ${filePath}`); - return getFileData(filePath) + return (getFileData(filePath) as any) .then( - (rawData) => { + (rawData: any[]): Dataset => { logger.log(`Loaded file ${filePath} (rows = ${rawData.length})`); var dataset = Dataset.fromJS(rawData).hide(); @@ -88,9 +84,9 @@ export class FileManager { } this.dataset = dataset; - this.onDatasetChange(dataset); + return dataset; }, - (e) => { + (e: Error) => { logger.error(`Failed to load file ${filePath} because: ${e.message}`); } ); diff --git a/src/server/utils/general/general.ts b/src/server/utils/general/general.ts index 4454ac6f..fe8e5cec 100644 --- a/src/server/utils/general/general.ts +++ b/src/server/utils/general/general.ts @@ -16,12 +16,12 @@ import * as Q from 'q'; import { Request } from 'express'; -import { User, AppSettings } from '../../../common/models/index'; -import { GetSettingsOptions } from '../settings-manager/settings-manager'; +import { User } from '../../../common/models/index'; +import { GetSettingsOptions, FullSettings } from '../settings-manager/settings-manager'; export interface SwivRequest extends Request { version: string; stateful: boolean; user: User; - getSettings(opts?: GetSettingsOptions): Q.Promise; + getFullSettings(opts?: GetSettingsOptions): Q.Promise; } diff --git a/src/server/utils/settings-manager/settings-manager.ts b/src/server/utils/settings-manager/settings-manager.ts index c537724b..2913585b 100644 --- a/src/server/utils/settings-manager/settings-manager.ts +++ b/src/server/utils/settings-manager/settings-manager.ts @@ -15,9 +15,9 @@ */ import * as Q from 'q'; -import { External, Dataset, basicExecutorFactory, find } from 'plywood'; +import { Timezone, day } from 'chronoshift'; +import { $, Executor, basicExecutorFactory, find, Attributes, Dataset, TimeRange } from 'plywood'; import { Logger } from 'logger-tracker'; -import { pluralIfNeeded } from '../../../common/utils/general/general'; import { TimeMonitor } from "../../../common/utils/time-monitor/time-monitor"; import { AppSettings, Timekeeper, Cluster, DataCube } from '../../../common/models/index'; import { SettingsStore } from '../settings-store/settings-store'; @@ -25,6 +25,12 @@ import { FileManager } from '../file-manager/file-manager'; import { ClusterManager } from '../cluster-manager/cluster-manager'; import { updater } from '../updater/updater'; +const PREVIEW_LIMIT = 20; +const LOTS_OF_TIME = TimeRange.fromJS({ + start: new Date('1000-01-01Z'), + end: new Date('4000-01-01Z') +}); + export interface SettingsManagerOptions { logger: Logger; verbose?: boolean; @@ -37,6 +43,26 @@ export interface GetSettingsOptions { timeout?: number; } +export interface FullSettings { + appSettings: AppSettings; + timekeeper: Timekeeper; + executors: Lookup; +} + +export interface ClusterAndSources { + cluster: Cluster; + sources: string[]; +} + +export interface ClusterNameAndSource { + clusterName: string; + source: string; +} + +function flatten(as: T[][]): T[] { + return Array.prototype.concat.apply([], as); +} + export class SettingsManager { public logger: Logger; public verbose: boolean; @@ -44,6 +70,7 @@ export class SettingsManager { public settingsStore: SettingsStore; public appSettings: AppSettings; public timeMonitor: TimeMonitor; + public executors: Lookup; public fileManagers: FileManager[]; public clusterManagers: ClusterManager[]; public currentWork: Q.Promise; @@ -56,6 +83,7 @@ export class SettingsManager { this.anchorPath = options.anchorPath; this.timeMonitor = new TimeMonitor(logger); + this.executors = {}; this.settingsStore = settingsStore; this.fileManagers = []; this.clusterManagers = []; @@ -63,10 +91,26 @@ export class SettingsManager { this.initialLoadTimeout = options.initialLoadTimeout || 30000; this.appSettings = AppSettings.BLANK; + // do initial load of initial settings this.currentWork = settingsStore.readSettings() .then((appSettings) => { - return this.reviseSettings(appSettings); - }) + return this.synchronizeSettings(appSettings); + }); + + // add the auto loader if need after completing initial read + if (this.settingsStore.needsAutoLoader) { + this.settingsStore.autoLoader = this.autoLoadDataCubes.bind(this); + + // Reread the settings + this.currentWork = this.currentWork + .then(() => settingsStore.readSettings()) + .then((appSettings) => { + return this.synchronizeSettings(appSettings); + }); + } + + // log error if something goes wrong + this.currentWork = this.currentWork .catch(e => { logger.error(`Fatal settings load error: ${e.message}`); logger.error(e.stack); @@ -82,30 +126,69 @@ export class SettingsManager { return find(this.clusterManagers, (clusterManager) => clusterManager.cluster.name === clusterName); } - private addClusterManager(cluster: Cluster, dataCubes: DataCube[]): Q.Promise { - const { verbose, logger, anchorPath } = this; + private getFileManagerFor(uri: string): FileManager { + return find(this.fileManagers, (fileManager) => fileManager.uri === uri); + } - var initialExternals = dataCubes.map(dataCube => { + getFullSettings(opts: GetSettingsOptions = {}): Q.Promise { + const { settingsStore } = this; + var currentWork = this.currentWork; + + if (settingsStore.hasUpdateOnLoad) { + currentWork = currentWork + .then(() => { + return settingsStore.hasUpdateOnLoad().then(hasUpdate => { + if (!hasUpdate) return null; + + // There is an update so re-read and sync teh settings + return settingsStore.readSettings() + .then((appSettings) => { + return this.synchronizeSettings(appSettings); + }); + }); + }); + } + + var timeout = opts.timeout || this.initialLoadTimeout; + if (timeout !== 0) { + currentWork = currentWork.timeout(timeout) + .catch(e => { + this.logger.error(`Settings load timeout hit, continuing`); + }); + } + + return currentWork.then(() => { return { - name: dataCube.name, - external: dataCube.toExternal(), - suppressIntrospection: dataCube.getIntrospection() === 'none' + appSettings: this.appSettings, + timekeeper: this.timeMonitor.timekeeper, + executors: this.executors }; }); + } + + synchronizeSettings(newSettings: AppSettings): Q.Promise { + var tasks = [ + this.synchronizeClusters(newSettings), + this.synchronizeDataCubes(newSettings) + ]; + this.appSettings = newSettings; + + return Q.all(tasks); + } + + // === Clusters ============================== + + private addClusterManager(cluster: Cluster): Q.Promise { + const { verbose, logger, anchorPath } = this; - // Make a cluster manager for each cluster and assign the correct initial externals to it. - logger.log(`Adding cluster manager for '${cluster.name}' with ${pluralIfNeeded(dataCubes.length, 'dataCube')}`); var clusterManager = new ClusterManager(cluster, { logger, verbose, - anchorPath, - initialExternals, - onExternalChange: this.onExternalChange.bind(this, cluster), - generateExternalName: this.generateDataCubeName.bind(this) + anchorPath }); this.clusterManagers.push(clusterManager); - return clusterManager.init(); + return clusterManager.establishInitialConnection(); } private removeClusterManager(cluster: Cluster): void { @@ -116,11 +199,56 @@ export class SettingsManager { }); } - private getFileManagerFor(uri: string): FileManager { - return find(this.fileManagers, (fileManager) => fileManager.uri === uri); + + synchronizeClusters(newSettings: AppSettings): Q.Promise { + const { verbose, logger } = this; + var oldSettings = this.appSettings; + var tasks: Q.Promise[] = []; + + updater(oldSettings.clusters, newSettings.clusters, { + onExit: (oldCluster) => { + logger.log(`Removing cluster manager for '${oldCluster.name}'`); + this.removeClusterManager(oldCluster); + }, + onUpdate: (newCluster, oldCluster) => { + logger.log(`Updating cluster manager for '${newCluster.name}'`); + this.removeClusterManager(oldCluster); + tasks.push(this.addClusterManager(newCluster)); + }, + onEnter: (newCluster) => { + logger.log(`Adding cluster manager for '${newCluster.name}'`); + tasks.push(this.addClusterManager(newCluster)); + } + }); + + return Q.all(tasks); + } + + // === Cubes ============================== + + private addExecutor(dataCube: DataCube, executor: Executor): void { + this.executors[dataCube.name] = executor; + } + + private removeExecutor(dataCube: DataCube): void { + delete this.executors[dataCube.name]; + } + + private addTimeCheckIfNeeded(dataCube: DataCube, executor: Executor): void { + if (!dataCube.refreshRule.isQuery()) return; + + var maxTimeQuery = dataCube.getMaxTimeQuery(); + if (!maxTimeQuery) return; + this.timeMonitor.addCheck(dataCube.name, () => { + return executor(maxTimeQuery).then(DataCube.processMaxTimeQuery); + }); + } + + private removeTimeCheck(dataCube: DataCube): void { + this.timeMonitor.removeCheck(dataCube.name); } - private addFileManager(dataCube: DataCube): Q.Promise { + private addNativeCube(dataCube: DataCube): Q.Promise { if (dataCube.clusterName !== 'native') throw new Error(`data cube '${dataCube.name}' must be native to have a file manager`); const { verbose, logger, anchorPath } = this; @@ -129,15 +257,21 @@ export class SettingsManager { verbose, anchorPath, uri: dataCube.source, - subsetExpression: dataCube.subsetExpression, - onDatasetChange: this.onDatasetChange.bind(this, dataCube.name) + subsetExpression: dataCube.subsetExpression + }); this.fileManagers.push(fileManager); - return fileManager.init(); + return fileManager.loadDataset().then((dataset) => { + var newExecutor = basicExecutorFactory({ + datasets: { main: dataset } + }); + this.addExecutor(dataCube, newExecutor); + this.addTimeCheckIfNeeded(dataCube, newExecutor); + }); } - private removeFileManager(dataCube: DataCube): void { + private removeNativeCube(dataCube: DataCube): void { if (dataCube.clusterName !== 'native') throw new Error(`data cube '${dataCube.name}' must be native to have a file manager`); this.fileManagers = this.fileManagers.filter((fileManager) => { @@ -145,85 +279,59 @@ export class SettingsManager { fileManager.destroy(); return false; }); + this.removeExecutor(dataCube); + this.removeTimeCheck(dataCube); } - getTimekeeper(): Timekeeper { - return this.timeMonitor.timekeeper; - } - - getSettings(opts: GetSettingsOptions = {}): Q.Promise { - var currentWork = this.currentWork; - - // Refresh all clusters - var currentWork = currentWork.then(() => { - // ToDo: utilize dataCubeOfInterest - return Q.all(this.clusterManagers.map(clusterManager => clusterManager.refresh())) as any; - }); - - var timeout = opts.timeout || this.initialLoadTimeout; - if (timeout !== 0) { - currentWork = currentWork.timeout(timeout) - .catch(e => { - this.logger.error(`Settings load timeout hit, continuing`); - }); + private addClusterCube(dataCube: DataCube): void { + var clusterManager = this.getClusterManagerFor(dataCube.clusterName); + if (clusterManager) { + var newExecutor = basicExecutorFactory({ + datasets: { main: dataCube.toExternal(clusterManager.cluster, clusterManager.requester) } + }); + this.addExecutor(dataCube, newExecutor); + this.addTimeCheckIfNeeded(dataCube, newExecutor); } - - return currentWork.then(() => this.appSettings); - } - - reviseSettings(newSettings: AppSettings): Q.Promise { - var tasks = [ - this.reviseClusters(newSettings), - this.reviseDataCubes(newSettings) - ]; - this.appSettings = newSettings; - - return Q.all(tasks); } - reviseClusters(newSettings: AppSettings): Q.Promise { - const { verbose, logger } = this; - var oldSettings = this.appSettings; - var tasks: Q.Promise[] = []; - - updater(oldSettings.clusters, newSettings.clusters, { - onExit: (oldCluster) => { - this.removeClusterManager(oldCluster); - }, - onUpdate: (newCluster) => { - logger.log(`${newCluster.name} UPDATED cluster`); - }, - onEnter: (newCluster) => { - tasks.push(this.addClusterManager(newCluster, newSettings.getDataCubesForCluster(newCluster.name))); - } - }); - - return Q.all(tasks); + private removeClusterCube(dataCube: DataCube): void { + this.removeExecutor(dataCube); + this.removeTimeCheck(dataCube); } - reviseDataCubes(newSettings: AppSettings): Q.Promise { + synchronizeDataCubes(newSettings: AppSettings): Q.Promise { const { verbose, logger } = this; var oldSettings = this.appSettings; var tasks: Q.Promise[] = []; - var oldNativeDataCubes = oldSettings.getDataCubesForCluster('native'); - var newNativeDataCubes = newSettings.getDataCubesForCluster('native'); - updater(oldNativeDataCubes, newNativeDataCubes, { + updater(oldSettings.dataCubes, newSettings.dataCubes, { onExit: (oldDataCube) => { + logger.log(`Removing data cube manager for '${oldDataCube.name}'`); if (oldDataCube.clusterName === 'native') { - this.removeFileManager(oldDataCube); + this.removeNativeCube(oldDataCube); } else { - throw new Error(`only native data cubes work for now`); // ToDo: fix + this.removeClusterCube(oldDataCube); } }, - onUpdate: (newDataCube) => { - logger.log(`${newDataCube.name} UPDATED datasource`); + onUpdate: (newDataCube, oldDataCube) => { + // If native sources are the same, nothing to do. + if (newDataCube.clusterName === 'native' && oldDataCube.clusterName === 'native' && newDataCube.source === oldDataCube.source) return; + + logger.log(`Updating data cube manager for '${newDataCube.name}'`); + if (oldDataCube.clusterName === 'native') { + this.removeNativeCube(oldDataCube); + tasks.push(this.addNativeCube(newDataCube)); + } else { + this.removeClusterCube(oldDataCube); + this.addClusterCube(newDataCube); + } }, onEnter: (newDataCube) => { + logger.log(`Adding data cube manager for '${newDataCube.name}'`); if (newDataCube.clusterName === 'native') { - tasks.push(this.addFileManager(newDataCube)); + tasks.push(this.addNativeCube(newDataCube)); } else { - throw new Error(`only native data cube work for now`); // ToDo: fix + this.addClusterCube(newDataCube); } } }); @@ -234,88 +342,186 @@ export class SettingsManager { updateSettings(newSettings: AppSettings): Q.Promise { if (!this.settingsStore.writeSettings) return Q.reject(new Error('must be writable')); - var loadedNewSettings = newSettings.attachExecutors((dataCube) => { - if (dataCube.clusterName === 'native') { - var fileManager = this.getFileManagerFor(dataCube.source); - if (fileManager) { - var dataset = fileManager.dataset; - if (!dataset) return null; - return basicExecutorFactory({ - datasets: { main: dataset } - }); - } + return this.settingsStore.writeSettings(newSettings) + .then(() => { + this.synchronizeSettings(newSettings); + }); + } - } else { - var clusterManager = this.getClusterManagerFor(dataCube.clusterName); - if (clusterManager) { - var external = clusterManager.getExternalByName(dataCube.name); - if (!external) return null; - return basicExecutorFactory({ - datasets: { main: external } - }); - } + checkClusterConnectionInfo(cluster: Cluster): Q.Promise { + const { verbose, logger, anchorPath } = this; - } - return null; + var clusterManager = new ClusterManager(cluster, { + logger, + verbose, + anchorPath }); - return this.settingsStore.writeSettings(loadedNewSettings) - .then(() => { - this.appSettings = loadedNewSettings; + return clusterManager.establishInitialConnection(0) + .then( + () => clusterManager.getSources(), + (e) => { + throw new Error('Unable to connect tp cluster'); + } + ) + .then((sources) => { + return { + cluster: clusterManager.cluster, + sources + }; }); } - generateDataCubeName(external: External): string { - const { appSettings } = this; - var source = String(external.source); + getAllClusterSources(): Q.Promise { + var clusterSources = this.clusterManagers.map((clusterManager) => { + var clusterName = clusterManager.cluster.name; + return clusterManager.getSources().then((sources) => { + return sources.map((source): ClusterNameAndSource => { + return { clusterName, source }; + }); + }); + }); - var candidateName = source; - var i = 0; - while (appSettings.getDataCube(candidateName)) { - i++; - candidateName = source + i; - } - return candidateName; + return Q.all(clusterSources).then((things: ClusterNameAndSource[][]) => flatten(things)); } - onDatasetChange(dataCubeName: string, changedDataset: Dataset): void { - const { logger, verbose } = this; - - logger.log(`Got native dataset update for ${dataCubeName}`); + getAllAttributes(source: string, cluster: string | Cluster, templateDataCube: DataCube = null): Q.Promise { + const { verbose, logger, anchorPath } = this; - var dataCube = this.appSettings.getDataCube(dataCubeName); - if (!dataCube) throw new Error(`Unknown dataset ${dataCubeName}`); - dataCube = dataCube.updateWithDataset(changedDataset); + var clusterName: string = typeof cluster === 'string' ? cluster : cluster.name; - if (dataCube.refreshRule.isQuery()) { - this.timeMonitor.addCheck(dataCube.name, () => { - return DataCube.queryMaxTime(dataCube); + logger.log(`Getting attributes for source '${source}' in cluster '${clusterName}'`); + if (cluster === 'native') { + return Q.fcall(() => { + var fileManager = this.getFileManagerFor(source); + if (!fileManager) throw new Error(`no file manager for ${source}`); + return fileManager.dataset.attributes; }); + } else { + return Q.fcall(() => { + if (typeof cluster === 'string') { + var clusterManager = this.getClusterManagerFor(cluster); + if (!clusterManager) throw new Error(`no cluster manager for ${cluster}`); + return clusterManager; + } else { + var clusterManager = new ClusterManager(cluster, { + logger, + verbose, + anchorPath + }); + + return clusterManager.establishInitialConnection().then(() => clusterManager); + } + }) + .then((clusterManager: ClusterManager) => { + return (templateDataCube || DataCube.fromClusterAndSource('test_cube', clusterManager.cluster, source)) + .toExternal(clusterManager.cluster, clusterManager.requester) + .introspect() + .then((introspectedExternal) => introspectedExternal.attributes) as any; + }); } + } - this.appSettings = this.appSettings.addOrUpdateDataCube(dataCube); + preview(dataCube: DataCube): Q.Promise { + var clusterName = dataCube.clusterName; + if (clusterName === 'native') { + return Q.fcall(() => { + var fileManager = this.getFileManagerFor(dataCube.source); + if (!fileManager) throw new Error(`no file manager for ${dataCube.source}`); + var context: any = { temp: fileManager.dataset }; + return $('temp').limit(PREVIEW_LIMIT).compute(context) as any; + }); + } else { + return Q.fcall(() => { + var clusterManager = this.getClusterManagerFor(clusterName); + if (!clusterManager) throw new Error(`no cluster manager for ${clusterName}`); + var context: any = { temp: dataCube.toExternal(clusterManager.cluster, clusterManager.requester) }; + + var primaryTimeExpression = dataCube.getPrimaryTimeExpression(); + if (primaryTimeExpression) { + return $('temp') + .filter(primaryTimeExpression.in(LOTS_OF_TIME)) + .max(primaryTimeExpression) + .compute(context) + .then((maxTime: Date) => { + maxTime = new Date(maxTime); + if (isNaN(maxTime as any)) throw new Error('invalid maxTime'); + var lastTwoWeeks = TimeRange.fromJS({ + start: day.move(maxTime, Timezone.UTC, -14), + end: maxTime + }); + return $('temp').filter(primaryTimeExpression.in(lastTwoWeeks)).limit(PREVIEW_LIMIT).compute(context) as any; + }); + } else { + return $('temp').limit(PREVIEW_LIMIT).compute(context) as any; + } + }); + } } - onExternalChange(cluster: Cluster, dataCubeName: string, changedExternal: External): Q.Promise { - if (!changedExternal.attributes || !changedExternal.requester) return Q(null); - const { logger, verbose } = this; + private autoLoadDataCubes(initSettings: AppSettings): Q.Promise { + const { verbose, logger } = this; - logger.log(`Got queryable external dataset update for ${dataCubeName} in cluster ${cluster.name}`); + logger.log(`Auto loading`); + return this.getAllClusterSources() + .then((clusterNameAndSources) => { + var dataCubeFillTasks: Q.Promise[] = []; + var fullDataCubes: DataCube[] = []; + var fullExtraDataCubes: DataCube[] = []; + + initSettings.getDataCubesByCluster('native').forEach((nativeDataCube) => { + dataCubeFillTasks.push( + this.getAllAttributes(nativeDataCube.source, 'native') + .then( + (attributes) => { + fullDataCubes.push(nativeDataCube.fillAllFromAttributes(attributes)); + }, + () => { + // fullDataCubes.push(nativeDataCube); + } + ) + ); + }); - var dataCube = this.appSettings.getDataCube(dataCubeName); - if (!dataCube) { - dataCube = DataCube.fromClusterAndExternal(dataCubeName, cluster, changedExternal); - } - dataCube = dataCube.updateWithExternal(changedExternal); + clusterNameAndSources.forEach((clusterNameAndSource, i) => { + const { clusterName, source } = clusterNameAndSource; + + var baseDataCubes = initSettings.getDataCubesByClusterSource(clusterName, source); + var isNewDataCube = baseDataCubes.length === 0; + if (isNewDataCube) { + var newName = `${clusterName}-${source}-${i}`; + if (verbose) logger.log(`Adding DataCube '${newName}'`); + var cluster = initSettings.getCluster(clusterName); + if (cluster.getSourceListScan() === 'auto') { // Respect sourceListScan property + baseDataCubes = [DataCube.fromClusterAndSource(newName, cluster, source)]; + } + } + + baseDataCubes.forEach(baseDataCube => { + dataCubeFillTasks.push( + this.getAllAttributes(source, clusterName, baseDataCube) + .then( + (attributes) => { + var fullDataCube = baseDataCube.fillAllFromAttributes(attributes); + if (isNewDataCube) { + fullExtraDataCubes.push(fullDataCube); + } else { + fullDataCubes.push(fullDataCube); + } + }, + (e: Error) => { + logger.error(`Could get attributes for '${baseDataCube.name}' because: ${e.message}`); + // if (!isNewDataCube) fullDataCubes.push(baseDataCube); + } + ) + ); + }); + }); - if (dataCube.refreshRule.isQuery()) { - this.timeMonitor.addCheck(dataCube.name, () => { - return DataCube.queryMaxTime(dataCube); + return Q.all(dataCubeFillTasks).then(() => { + return initSettings.changeDataCubes(fullDataCubes.concat(fullExtraDataCubes)); + }); }); - } - - this.appSettings = this.appSettings.addOrUpdateDataCube(dataCube); - return Q(null); } } diff --git a/src/server/utils/settings-store/settings-store.ts b/src/server/utils/settings-store/settings-store.ts index e106e671..375c4d8a 100644 --- a/src/server/utils/settings-store/settings-store.ts +++ b/src/server/utils/settings-store/settings-store.ts @@ -62,15 +62,24 @@ export interface StateStore { } export class SettingsStore { - static fromTransient(initAppSettings: AppSettings): SettingsStore { - var settingsStore = new SettingsStore(); - settingsStore.readSettings = () => Q(initAppSettings); + static fromTransient(initAppSettings: AppSettings, updateOnLoad = true): SettingsStore { + var settingsStore = new SettingsStore(true); + settingsStore.readSettings = () => { + if (settingsStore.autoLoader) { + return settingsStore.autoLoader(initAppSettings); + } else { + return Q(initAppSettings); + } + }; + if (updateOnLoad) { + settingsStore.hasUpdateOnLoad = () => Q(true); + } return settingsStore; } static fromReadOnlyFile(filepath: string, format: Format): SettingsStore { var settingsStore = new SettingsStore(); - settingsStore.readSettings = readSettingsFactory(filepath, format, true); + settingsStore.readSettings = readSettingsFactory(filepath, format); return settingsStore; } @@ -101,10 +110,15 @@ export class SettingsStore { } + public needsAutoLoader: boolean; + public autoLoader: (initAppSettings: AppSettings) => Q.Promise; public readSettings: () => Q.Promise; public writeSettings: (appSettings: AppSettings) => Q.Promise; + public hasUpdateOnLoad: () => Q.Promise; - constructor() {} + constructor(needsAutoLoader = false) { + this.needsAutoLoader = needsAutoLoader; + } }