This commit is contained in:
Mor Shemesh 2017-08-09 08:34:29 +00:00 коммит произвёл GitHub
Родитель 5bc8048632 da0e0bee3c
Коммит 819f6da37f
52 изменённых файлов: 2291 добавлений и 1238 удалений

6
.vscode/settings.json поставляемый
Просмотреть файл

@ -1,7 +1,7 @@
// Place your settings in this file to overwrite default and user settings.
{
"files.exclude": {
"**/*.css": { "when": "$(basename).scss"}
"**/*.css": true
},
"editor.tabSize": 2,
"editor.insertSpaces": true,
@ -14,5 +14,7 @@
"**/node_modules": true,
"**/build": true,
"**/coverage": true
}
},
"tslint.configFile": "client/tslint.json",
"tslint.nodePath": "client/node_modules"
}

2
client/@types/types.d.ts поставляемый
Просмотреть файл

@ -118,6 +118,7 @@ interface IElement {
title?: string;
subtitle?: string;
theme?: string[];
source?: string | IStringDictionary;
dependencies?: IStringDictionary;
props?: IDictionary;
actions?: IDictionary;
@ -125,6 +126,7 @@ interface IElement {
interface IFilter {
type: string,
source?: string,
dependencies?: IStringDictionary,
actions?: IStringDictionary,
title?: string,

Просмотреть файл

@ -1,30 +0,0 @@
import * as React from 'react';
import { Media } from 'react-md/lib/Media';
import { Card, CardTitle } from 'react-md/lib/Cards';
import TooltipFontIcon from './TooltipFontIcon';
import Button from 'react-md/lib/Buttons';
export default ({children = {}, title = '', subtitle = ''}) => {
const titleNode = <span key={0}>{title}</span>;
const tooltipNode = (
<TooltipFontIcon
key={1}
tooltipLabel={subtitle}
tooltipPosition="top"
forceIconFontSize={true}
forceIconSize={16}
className="card-icon"
>
info
</TooltipFontIcon>
);
return (
<Card>
<CardTitle title={''} subtitle={[titleNode, tooltipNode]} />
<Media>
{children}
</Media>
</Card>
);
};

Просмотреть файл

@ -0,0 +1,127 @@
import * as React from 'react';
import { Media } from 'react-md/lib/Media';
import { Card as MDCard, CardTitle } from 'react-md/lib/Cards';
import TooltipFontIcon from '../Tooltip/TooltipFontIcon';
import Button from 'react-md/lib/Buttons';
import { Settings, SettingsActions } from './Settings';
import { SpinnerActions } from '../Spinner';
const styles = {
noTitle: {
margin: 0,
padding: 0,
background: 'transparent',
} as React.CSSProperties,
noTitleContent: {
margin: 0,
padding: 0,
} as React.CSSProperties
};
interface ICardProps {
id?: string;
title?: string;
subtitle?: string;
widgets?: React.ReactNode;
className?: string;
style?: React.CSSProperties;
titleStyle?: React.CSSProperties;
contentStyle?: React.CSSProperties;
hideTitle?: boolean;
}
interface ICardState {
hover: boolean;
}
export default class Card extends React.PureComponent<ICardProps, ICardState> {
static defaultProps = {
hideTitle: false,
};
state = {
hover: false,
};
constructor(props: ICardProps) {
super(props);
}
render() {
const { id, title, subtitle, children, className, style, titleStyle, contentStyle, hideTitle } = this.props;
const { hover } = this.state;
let elements: React.ReactNode[] = [];
if (title && !hideTitle) {
elements.push(
<span key={0}>{title}</span>
);
}
if (subtitle) {
elements.push(
<TooltipFontIcon
key={1}
tooltipLabel={subtitle}
tooltipPosition="top"
forceIconFontSize={true}
forceIconSize={16}
className="card-icon"
>
info
</TooltipFontIcon>
);
}
if (hover) {
elements.push( this.renderWidgets() );
}
// NB: Fix for Card scroll content when no title
let cardTitleStyle = titleStyle || {};
let cardContentStyle = contentStyle || {};
if (hideTitle) {
Object.assign(cardTitleStyle, styles.noTitle);
Object.assign(cardContentStyle, styles.noTitleContent);
}
return (
<MDCard
onMouseOver={() => this.setState({ hover: true })}
onMouseLeave={() => this.setState({ hover: false })}
className={className}
style={style}>
<CardTitle title="" subtitle={elements} style={cardTitleStyle} />
<Media style={cardContentStyle}>
{children}
</Media>
</MDCard>
);
}
renderWidgets() {
const { id, title, widgets } = this.props;
const settingsButton = (
<Button
icon
key="settings"
onClick={() => SettingsActions.openDialog(title, id)}
className="card-settings-btn"
>
settings
</Button>
);
return !widgets ? (
<div className="card-settings" key="widgets">
{settingsButton}
</div>
) : (
<div className="card-settings" key="widgets">
<span>{widgets}</span>
<span>{settingsButton}</span>
</div>
);
}
}

Просмотреть файл

@ -0,0 +1,283 @@
import * as React from 'react';
import Dialog from 'react-md/lib/Dialogs';
import Button from 'react-md/lib/Buttons/Button';
import Toolbar from 'react-md/lib/Toolbars';
import CircularProgress from 'react-md/lib/Progress/CircularProgress';
import SelectField from 'react-md/lib/SelectFields';
import AceEditor, { EditorProps, Annotation } from 'react-ace';
import * as brace from 'brace';
import 'brace/mode/text';
import 'brace/mode/json';
import 'brace/theme/github';
import SettingsActions from './SettingsActions';
import SettingsStore, { IExportData } from './SettingsStore';
import { Toast, ToastActions, IToast } from '../../Toast';
const editorProps: EditorProps = {
$blockScrolling: 1
};
interface ISettingsProps {
offsetHeight?: number;
dashboard: IDashboardConfig;
}
interface ISettingsState {
visible: boolean;
title: string;
elementId: string;
dashboard?: IDashboardConfig;
selectedIndex: number;
exportData?: IExportData[];
result?: string;
}
export default class Edit extends React.PureComponent<ISettingsProps, ISettingsState> {
static defaultProps = {
offsetHeight: 64 // set to height of header / toolbar
};
constructor(props: ISettingsProps) {
super(props);
this.state = SettingsStore.getState();
this.onChange = this.onChange.bind(this);
this.copyData = this.copyData.bind(this);
this.copyQuery = this.copyQuery.bind(this);
}
componentDidMount() {
SettingsStore.listen(this.onChange);
}
componentWillUnmount() {
SettingsStore.unlisten(this.onChange);
}
onChange(state: ISettingsState) {
const { visible, elementId, title, selectedIndex, exportData } = state;
this.setState({ visible, elementId, title, selectedIndex, exportData });
}
openDialog = (title: string, elementId: string) => {
SettingsActions.openDialog(title, elementId);
}
closeDialog = () => {
SettingsActions.closeDialog();
}
copyData() {
const { exportData, selectedIndex} = this.state;
if (!exportData) {
return;
}
const selected = exportData[selectedIndex];
const text = selected.isJSON ? JSON.stringify(selected.data, null, 2) : selected.data.toString();
this.copyToClipboard(text);
}
copyQuery() {
const { exportData, selectedIndex} = this.state;
if (!exportData) {
return;
}
const selected = exportData[selectedIndex];
const text = selected.query;
this.copyToClipboard(text);
}
componentWillUpdate(nextProps: any, nextState: any) {
const { visible } = this.state;
const { dashboard } = this.props;
if (nextState.visible === true && visible !== nextState.visible) {
SettingsActions.getExportData(dashboard);
}
}
render() {
const { visible, title, elementId, selectedIndex, exportData } = this.state;
const { offsetHeight, dashboard } = this.props;
const aceHeight = 'calc(100vh - ' + (offsetHeight * 4) + 'px)';
const titleStyle = { height: offsetHeight + 'px)' } as React.CSSProperties;
let actions = null;
let json = '';
let query = '';
let mode = 'text';
let dataActions = null;
let queryActions = null;
if (exportData && exportData.length > 0) {
const options = exportData.map(item => item.id);
const selectedValue = options[selectedIndex];
actions = options.length > 1 ? [
(
<SelectField
id="theme"
placeholder="Theme"
position={SelectField.Positions.BELOW}
defaultValue={selectedValue}
menuItems={options}
onChange={(newValue, index) => SettingsActions.selectIndex(index)}
tabIndex={-1}
toolbar
/>
)
] : <Button flat disabled label={selectedValue} style={{ textTransform: 'none', fontWeight: 'normal' }} />;
const selected: IExportData = exportData[selectedIndex];
// data
const data = selected.data;
switch (typeof data) {
case 'object':
json = data ? JSON.stringify(data, null, 2) : 'null';
mode = 'json';
break;
case 'string':
json = data;
break;
case 'boolean':
json = (data === true) ? 'true' : 'false';
break;
case 'undefined':
json = 'undefined';
break;
case 'number':
default:
json = data.toString();
}
// query
if (selected.query) {
query = selected.query;
}
// actions
dataActions = [
(
<Button icon tooltipLabel="Copy" onClick={this.copyData} tabIndex={-1}>
content_copy
</Button>
),
(
<Button icon tooltipLabel="Download" onClick={SettingsActions.downloadData} tabIndex={-1}>
file_download
</Button>
)
];
queryActions = [
(
<Button icon tooltipLabel="Copy" onClick={this.copyQuery} tabIndex={-1}>
content_copy
</Button>
)
];
}
let id = 'Element id error';
if ( elementId) {
id = elementId.split('@')[0] || 'Element index error';
}
const content = !query ? (
<div className="md-toolbar-relative md-grid">
<div className="md-cell--12">
<h3>{id}</h3>
</div>
<div className="md-cell--12">
<p>Use the same id for the element and data source to unwind the query and data.</p>
</div>
</div>
) : (
<div className="md-toolbar-relative md-grid md-grid--no-spacing">
<div className="md-cell--6">
<Toolbar title="Data" actions={dataActions} themed style={{ width: '100%' }} />
<AceEditor className="md-cell--12"
name="ace"
mode={mode}
theme="github"
value={json}
readOnly={true}
showGutter={true}
showPrintMargin={false}
highlightActiveLine={true}
tabSize={2}
width="100%"
height={aceHeight}
editorProps={editorProps}
/>
</div>
<div className="md-cell--6">
<Toolbar title="Query" actions={queryActions} themed style={{ width: '100%' }} />
<AceEditor className="md-cell--12"
name="ace"
mode="text"
theme="github"
value={query}
readOnly={true}
showGutter={true}
showPrintMargin={false}
highlightActiveLine={true}
tabSize={2}
width="100%"
height={aceHeight}
editorProps={editorProps}
/>
</div>
</div>
);
return (
<Dialog
id="editElementDialog"
visible={visible}
aria-label="Element settings"
focusOnMount={false}
onHide={this.closeDialog}
dialogStyle={{ width: '80%' }}
contentStyle={{ margin: '0px', padding: '0px' }}
lastChild={true}
>
<Toolbar
colored
nav={<Button icon onClick={this.closeDialog} tabIndex={-1}>close</Button>}
actions={actions}
title={title}
fixed
style={{ width: '100%' }}
/>
{content}
</Dialog>
);
}
private toast(text: string) {
ToastActions.showText(text);
}
private copyToClipboard(text: string) {
if (!document.queryCommandSupported('copy')) {
this.toast('Browser not supported');
return;
}
const input = document.createElement('textarea');
input.style.position = 'fixed';
input.style.opacity = '0';
input.value = text;
document.body.appendChild(input);
input.select();
document.execCommand('copy');
document.body.removeChild(input);
}
}

Просмотреть файл

@ -0,0 +1,40 @@
import alt, { AbstractActions } from '../../../alt';
interface ISettingsActions {
openDialog(title: string, elementId: string): IDict<string>;
closeDialog(): any;
selectIndex(index: number): number;
getExportData(dashboard: IDashboardConfig): IDashboardConfig;
downloadData(): void;
}
class SettingsActions extends AbstractActions implements ISettingsActions {
constructor(alt: AltJS.Alt) {
super(alt);
}
openDialog(title: string, elementId: string) {
return {title, elementId};
}
closeDialog() {
return {};
}
selectIndex(index: number) {
return index;
}
getExportData(dashboard: IDashboardConfig) {
return dashboard;
}
downloadData() {
return {};
}
}
const settingsActions = alt.createActions<ISettingsActions>(SettingsActions);
export default settingsActions;

Просмотреть файл

@ -0,0 +1,312 @@
import * as React from 'react';
import alt, { AbstractStoreModel } from '../../../alt';
import { DataSourceConnector, IDataSourceDictionary, IDataSource } from '../../../data-sources/DataSourceConnector';
import settingsActions from './SettingsActions';
import { downloadBlob } from '../../Dashboard/DownloadFile';
export interface IExportData {
id: string;
data: any;
isJSON: boolean;
query: string;
group: string;
isGroupedJSON: boolean;
}
interface ISettingsStoreState {
visible: boolean;
title: string;
elementId: string;
dashboard?: IDashboardConfig;
selectedIndex: number;
exportData?: IExportData[];
result?: string;
}
class SettingsStore extends AbstractStoreModel<ISettingsStoreState> implements ISettingsStoreState {
visible: boolean;
title: string;
elementId: string;
dashboard?: IDashboardConfig;
selectedIndex: number;
exportData?: IExportData[];
result?: string;
constructor() {
super();
this.visible = false;
this.selectedIndex = 0;
this.bindListeners({
openDialog: settingsActions.openDialog,
closeDialog: settingsActions.closeDialog,
selectIndex: settingsActions.selectIndex,
getExportData: settingsActions.getExportData,
downloadData: settingsActions.downloadData,
});
}
openDialog(state: IDict<string>) {
this.title = state.title;
this.elementId = state.elementId;
this.visible = true;
}
closeDialog() {
this.visible = false;
this.title = '';
this.exportData = null;
}
selectIndex(index: number) {
this.selectedIndex = index;
}
downloadData() {
const selected = this.exportData[this.selectedIndex];
if (!selected) {
return;
}
const text = selected.isJSON ? JSON.stringify(selected.data) : selected.data.toString();
const filename = selected.id + '.json';
downloadBlob(text, 'application/json', filename);
}
getExportData(dashboard: IDashboardConfig) {
if (!this.elementId) {
console.warn('Requires element "id" prop:', this.elementId);
return;
}
const matches = this.elementId.split('@');
if (matches.length !== 2) {
console.warn('Element index not found:', this.elementId);
return;
}
const id = matches[0];
const index = parseInt(matches[1], 10);
let elements = dashboard.elements;
if (isNaN(index) || index >= elements.length || index < 0) {
console.warn('Element index invalid value:', index);
return;
}
if (elements[index].id === id) {
this.getElement(elements, index);
return;
}
// handle dialog element
dashboard.dialogs.every(dialog => {
if (dialog.elements.length > index && dialog.elements[index].id === id) {
elements = dialog.elements;
this.getElement(elements, index);
return false;
} else {
return true;
}
});
}
private getElement(elements: IElement[], index: number) {
const element: IElement = elements[index];
this.exportData = this.extrapolateElementExportData(element.dependencies, element.source, element.id);
this.selectedIndex = 0; // resets dialog menu selection
}
private extrapolateElementExportData(elementDependencies: IStringDictionary,
sources: string | IStringDictionary,
elementId: string): IExportData[] {
let result: IExportData[] = [];
let dependencies = {};
Object.assign(dependencies, elementDependencies);
if (typeof sources === 'string') {
let source = {};
source[elementId] = sources;
Object.assign(dependencies, source);
} else if (sources && typeof sources === 'object' && Object.keys(sources).length > 0 ) {
Object.assign(dependencies, sources);
}
if (!dependencies || Object.keys(dependencies).length === 0 ) {
console.warn('Missing element dependencies');
return result;
}
const datasources = DataSourceConnector.getDataSources();
Object.keys(dependencies).forEach((id) => {
const dependency = dependencies[id];
let dependencySource = dependency;
let dependencyProperty = 'values';
const dependencyPath = dependency.split(':');
if (dependencyPath.length === 2) {
dependencySource = dependencyPath[0];
dependencyProperty = dependencyPath[1];
}
const datasource = Object.keys(datasources).find(key => dependencySource === key);
if (!datasource) {
return;
}
if (datasource === 'mode' || datasource === 'modes') {
return;
}
// Data (JSON)
let data: any;
let isJSON = false;
let isGroupedJSON = false;
let group = datasource;
const values = datasources[datasource].store.state[dependencyProperty];
if (values === null || typeof values === undefined) {
console.warn('Missing data:', datasource, dependency);
return;
}
if (typeof values === 'object' || Array.isArray(values)) {
isJSON = true;
}
data = values;
// Query
let queryFn, queryFilters;
let queryId = dependencyProperty;
const forkedQueryComponents = dependencyProperty.split('-');
const params = datasources[datasource].config.params;
const isForked = !params.query && !!params.table;
if (!isForked) {
// unforked
queryFn = params.query;
} else {
// forked
if (!params.queries[queryId] && forkedQueryComponents.length === 2) {
queryId = forkedQueryComponents[0];
group = queryId;
}
if (params.queries[dependencySource]) {
queryId = dependencySource; // dialog case
}
if (!params.queries[queryId]) {
console.warn(`Unable to locate query id '${queryId}' in datasource '${dependencySource}'.`);
return;
}
queryFn = params.queries[queryId].query;
queryFilters = params.queries[queryId].filters;
}
// Query dependencies
let query, filter = '';
let queryDependencies: IDict<any> = {};
if (typeof queryFn === 'function') {
// Get query function dependencies
const queryDependenciesDict = datasources[datasource].config.dependencies || {};
Object.keys(queryDependenciesDict).forEach((dependenciesKey) => {
const value = queryDependenciesDict[dependenciesKey];
const path = value.split(':');
let source = value;
let property;
if (path.length === 2) {
source = path[0];
property = path[1];
}
if (source.startsWith('::') || source === 'connection') {
return;
}
if (source === 'args') {
const args = datasources[datasource].plugin['lastArgs'];
const arg = Object.keys(args).find(key => property === key);
if (!arg) {
console.warn('Unable to find arg property:', property);
return;
}
const argValue = args[arg] || '';
let append = {};
append[property] = argValue;
Object.assign(queryDependencies, append);
} else {
const datasourceId = Object.keys(datasources).find(key => source === key);
if (!datasourceId) {
console.warn('Unable to find data source id:', source);
return;
}
const resolvedValues = !property ? JSON.parse(JSON.stringify(datasources[datasourceId].store.state))
: datasources[datasourceId].store.state[property];
let append = {};
append[dependenciesKey] = resolvedValues;
Object.assign(queryDependencies, append);
}
});
query = queryFn(queryDependencies);
} else {
query = queryFn ? queryFn.toString() : 'n/a';
}
query = this.formatQueryString(query);
const dataSource: IDataSource = datasources[datasource];
const elementQuery = dataSource.plugin.getElementQuery(dataSource, queryDependencies, query, queryFilters);
if (elementQuery !== null) {
query = elementQuery;
}
const exportData: IExportData = { id, data, isJSON, query, group, isGroupedJSON };
result.push(exportData);
});
// Group primative (scorecard) results
result = result.reduce((a: IExportData[], c: IExportData) => {
if (c.isJSON) {
a.push(c);
return a;
}
const target = a.find((i) => i.group === c.group);
let data = {};
data[c.id] = c.data;
// new
if (!target) {
c.data = data;
c.isGroupedJSON = true;
c.isJSON = true;
c.id = c.group;
a.push(c);
return a;
}
// skip
if (target.isGroupedJSON !== true) {
a.push(c);
return a;
}
// merge
target.data = Object.assign(target.data, data);
return a;
}, []);
// Order by largest data set
result.sort((a, b) => b.data.toString().length - a.data.toString().length);
return result;
}
private formatQueryString(query: string) {
// Strip indent whitespaces
return query.replace(/(\s{2,})?(.+)?/gm, '$2\n').trim();
}
}
const settingsStore = alt.createStore<ISettingsStoreState>(SettingsStore, 'SettingsStore');
export default settingsStore;

Просмотреть файл

@ -0,0 +1,9 @@
import Settings from './Settings';
import SettingsActions from './SettingsActions';
import SettingsStore from './SettingsStore';
export {
Settings,
SettingsActions,
SettingsStore
}

Просмотреть файл

@ -0,0 +1,3 @@
import Card from './Card';
export default Card;

Просмотреть файл

@ -1,26 +1,3 @@
import * as _ from 'lodash';
import { DataSourceConnector } from '../../data-sources/DataSourceConnector';
interface IDownloadFile {
filename: string;
csv: string;
json: string;
source: string;
}
export default class DownloadFile implements IDownloadFile {
filename: string;
json: string;
csv: string;
source: string;
constructor(filename: string, json: string, csv: string, source: string = '') {
this.filename = filename;
this.json = json;
this.csv = csv;
this.source = source;
}
}
function downloadBlob(data: string, mimeType: string, filename: string) {
const blob = new Blob([data], {
@ -35,101 +12,4 @@ function downloadBlob(data: string, mimeType: string, filename: string) {
document.body.removeChild(el);
}
function exportDataSources() {
const sources = DataSourceConnector.getDataSources();
let states = {};
Object.keys(sources).forEach(key => {
let state = sources[key].store.state;
if (_.isEmpty(state)) {
return;
}
let isEmpty = Object.keys(state).every(prop => {
if (state[prop] === null || state[prop] === undefined) {
return true;
}
return false;
});
if (isEmpty) {
return;
}
states[key] = state;
});
return states;
}
function createDownloadFiles(json: any) {
let files: IDownloadFile[] = [];
if (Array.isArray(json)) {
let csv = arrayToFileData(json, 'data', true);
return [csv];
}
if (typeof json === 'object') {
return objectArraysToFiles(json, files);
}
return [];
}
function objectArraysToFiles(obj: any, container: IDownloadFile[], sourceName: string = '') {
if (obj === null || obj === undefined) {
return container;
}
Object.keys(obj).forEach(key => {
const value = obj[key];
if (Array.isArray(value) && value.length > 0) {
const csv = arrayToFileData(value, key, true, sourceName);
container.push(csv);
} else if (typeof value === 'object') {
objectArraysToFiles(value, container, key);
}
});
return container;
}
function arrayToFileData(arr: any[], title: string, useColumnNames: boolean = true, source: string = '') {
let rows = [];
let keys = Object.keys(arr[0]);
let columnNames = (keys).join(',');
if (typeof arr[0] === 'object') {
const collection = arr.reduce((a, c) => {
let values = [];
Object.keys(c).forEach(key => {
const i = keys.findIndex(k => k === key);
const value = typeof c[key] === 'object' ? JSON.stringify(c[key]) : c[key];
if (i > -1) {
values[i] = escapeValue(value);
} else {
keys.push(key);
values[keys.length - 1] = escapeValue(value);
}
});
a.push(values.join(','));
return a;
}, []);
rows.push(collection.join('\n'));
columnNames = (keys).join(',');
} else {
rows.push(arr.join('\n')); // single value string array
columnNames = title;
}
if (useColumnNames) {
rows.unshift(columnNames);
}
return new DownloadFile(title, stripSlashes(JSON.stringify(arr)), rows.join('\n'), source);
}
function escapeValue(value: string) {
if (typeof value === 'string' && value.indexOf(',') > -1) {
return (value.indexOf('"') === -1) ? `"${value}"` : '"' + stripSlashes(value.replace(/"/g, '""')) + '"';
}
return value;
}
function stripSlashes(value: string) {
return value.replace(/\\\\/g, '\\');
}
export { exportDataSources, createDownloadFiles, downloadBlob };
export { downloadBlob };

Просмотреть файл

@ -14,7 +14,7 @@ ResponsiveReactGridLayout = WidthProvider(ResponsiveReactGridLayout);
import ElementConnector from '../ElementConnector';
import { loadDialogsFromDashboard } from '../generic/Dialogs';
import IDownloadFile, { exportDataSources, createDownloadFiles, downloadBlob } from './DownloadFile';
import { downloadBlob } from './DownloadFile';
import { SettingsButton } from '../Settings';
import ConfigurationsActions from '../../actions/ConfigurationsActions';
@ -22,6 +22,7 @@ import ConfigurationsStore from '../../stores/ConfigurationsStore';
import VisibilityStore from '../../stores/VisibilityStore';
import {Editor, EditorActions} from './Editor';
import { Settings } from '../Card/Settings';
const renderHTML = require('react-render-html');
@ -41,10 +42,7 @@ interface IDashboardProps {
interface IDashboardState {
editMode?: boolean;
askDelete?: boolean;
askDownload?: boolean;
askSaveAsTemplate?: boolean;
downloadFiles?: IDownloadFile[];
downloadFormat?: string;
mounted?: boolean;
currentBreakpoint?: string;
layouts?: ILayouts;
@ -65,9 +63,6 @@ export default class Dashboard extends React.Component<IDashboardProps, IDashboa
editMode: false,
askDelete: false,
askSaveAsTemplate: false,
askDownload: false,
downloadFiles: [],
downloadFormat: 'json',
currentBreakpoint: 'lg',
mounted: false,
layouts: {},
@ -84,7 +79,8 @@ export default class Dashboard extends React.Component<IDashboardProps, IDashboa
super(props);
this.onBreakpointChange = this.onBreakpointChange.bind(this);
this.onLayoutChange = this.onLayoutChange.bind(this);
this.onLayoutChangeActive = this.onLayoutChangeActive.bind(this);
this.onLayoutChangeInactive = this.onLayoutChangeInactive.bind(this);
this.onConfigDashboard = this.onConfigDashboard.bind(this);
this.toggleEditMode = this.toggleEditMode.bind(this);
this.onDeleteDashboard = this.onDeleteDashboard.bind(this);
@ -93,20 +89,16 @@ export default class Dashboard extends React.Component<IDashboardProps, IDashboa
this.onUpdateLayout = this.onUpdateLayout.bind(this);
this.onOpenInfo = this.onOpenInfo.bind(this);
this.onCloseInfo = this.onCloseInfo.bind(this);
this.onExport = this.onExport.bind(this);
this.onCloseExport = this.onCloseExport.bind(this);
this.onClickDownloadFile = this.onClickDownloadFile.bind(this);
this.onChangeDownloadFormat = this.onChangeDownloadFormat.bind(this);
this.onDownloadDashboard = this.onDownloadDashboard.bind(this);
this.onSaveAsTemplate = this.onSaveAsTemplate.bind(this);
this.newTemplateNameChange = this.newTemplateNameChange.bind(this);
this.onSaveAsTemplateApprove = this.onSaveAsTemplateApprove.bind(this);
this.onSaveAsTemplateCancel = this.onSaveAsTemplateCancel.bind(this);
this.newTemplateDescriptionChange = this.newTemplateDescriptionChange.bind(this);
this.onVisibilityStoreChange = this.onVisibilityStoreChange.bind(this);
VisibilityStore.listen(this.onVisibilityStoreChange);
VisibilityStore.listen(state => {
this.setState({ visibilityFlags: state.flags });
});
this.state.newTemplateName = this.props.dashboard.name;
this.state.newTemplateDescription = this.props.dashboard.description;
}
@ -114,6 +106,7 @@ export default class Dashboard extends React.Component<IDashboardProps, IDashboa
componentDidMount() {
let { dashboard } = this.props;
let { mounted } = this.state;
this.onLayoutChange = this.onLayoutChangeActive;
if (dashboard && !mounted) {
@ -142,6 +135,15 @@ export default class Dashboard extends React.Component<IDashboardProps, IDashboa
this.componentDidMount();
}
componentWillUnmount() {
this.onLayoutChange = this.onLayoutChangeInactive;
VisibilityStore.unlisten(this.onVisibilityStoreChange);
}
onVisibilityStoreChange(state: any) {
this.setState({ visibilityFlags: state.flags });
}
onBreakpointChange(breakpoint: any) {
var layouts = this.state.layouts;
layouts[breakpoint] = layouts[breakpoint] || this.layouts[breakpoint];
@ -151,31 +153,29 @@ export default class Dashboard extends React.Component<IDashboardProps, IDashboa
});
}
onLayoutChange(layout: any, layouts: any) {
onLayoutChange(layout: any, layouts: any) { }
onLayoutChangeActive(layout: any, layouts: any) {
// Waiting for breakpoint to change
let currentBreakpoint = this.state.currentBreakpoint;
setTimeout(
() => {
if (currentBreakpoint !== this.state.currentBreakpoint) { return; }
let breakpoint = this.state.currentBreakpoint;
let newLayouts = this.state.layouts;
newLayouts[breakpoint] = layout;
this.setState({
layouts: newLayouts
});
var breakpoint = this.state.currentBreakpoint;
var newLayouts = this.state.layouts;
newLayouts[breakpoint] = layout;
this.setState({
layouts: newLayouts
});
// Saving layout to API
let { dashboard } = this.props;
dashboard.layouts = dashboard.layouts || {};
dashboard.layouts[breakpoint] = layout;
// Saving layout to API
let { dashboard } = this.props;
dashboard.layouts = dashboard.layouts || {};
dashboard.layouts[breakpoint] = layout;
if (this.state.editMode) {
ConfigurationsActions.saveConfiguration(dashboard);
}
}
if (this.state.editMode) {
ConfigurationsActions.saveConfiguration(dashboard);
}
},
500);
onLayoutChangeInactive(layout: any, layouts: any) {
}
onConfigDashboard() {
@ -244,6 +244,7 @@ export default class Dashboard extends React.Component<IDashboardProps, IDashboa
this.setState({ editMode: !this.state.editMode });
this.setState({ editMode: !this.state.editMode });
}
onOpenInfo(html: string) {
this.setState({ infoVisible: true, infoHtml: html });
}
@ -252,15 +253,6 @@ export default class Dashboard extends React.Component<IDashboardProps, IDashboa
this.setState({ infoVisible: false });
}
onExport() {
const data = exportDataSources();
let downloadFiles: IDownloadFile[] = createDownloadFiles(data);
downloadFiles.sort((a, b) => {
return a.source === b.source ? a.filename > b.filename ? 1 : -1 : a.source > b.source ? 1 : -1 ;
});
this.setState({ askDownload: true, downloadFiles: downloadFiles });
}
onDownloadDashboard() {
let { dashboard } = this.props;
dashboard.layouts = dashboard.layouts || {};
@ -270,34 +262,13 @@ export default class Dashboard extends React.Component<IDashboardProps, IDashboa
downloadBlob('return ' + stringDashboard, 'application/json', dashboardName + '.private.js');
}
onCloseExport(event: any) {
this.setState({ askDownload: false });
}
onClickDownloadFile(file: IDownloadFile, event: any) {
const { downloadFormat } = this.state;
if (downloadFormat === 'json') {
downloadBlob(file.json, 'application/json', file.filename + '.json');
} else {
downloadBlob(file.csv, 'text/csv', file.filename + '.csv');
}
}
onChangeDownloadFormat(value: string, event: any) {
this.setState({ downloadFormat: value });
}
render() {
const { dashboard } = this.props;
const {
currentBreakpoint,
grid,
editMode,
askDelete,
askDownload,
downloadFiles,
downloadFormat,
askConfig ,
askSaveAsTemplate,
newTemplateName,
@ -324,26 +295,19 @@ export default class Dashboard extends React.Component<IDashboardProps, IDashboa
if (!editMode) {
toolbarActions.push(
(
<span>
<Button key="downloadDashboard" icon tooltipLabel="Download Dashboard" onClick={this.onDownloadDashboard}>
file_download
</Button>
</span>
),
(
<span>
<Button key="export" icon tooltipLabel="Export data" onClick={this.onExport}>
play_for_work
</Button>
</span>
),
(
<span>
<Button key="info" icon tooltipLabel="Info" onClick={this.onOpenInfo.bind(this, dashboard.html)}>
info
</Button>
</span>
),
(
<span>
<Button key="downloadDashboard" icon tooltipLabel="Download Dashboard" onClick={this.onDownloadDashboard}>
file_download
</Button>
</span>
)
);
} else {
@ -386,37 +350,6 @@ export default class Dashboard extends React.Component<IDashboardProps, IDashboa
</Button></span>
)
);
const fileAvatar = (downloadFormat === 'json') ?
<Avatar suffix="red" icon={<FontIcon>insert_drive_file</FontIcon>} />
: <Avatar suffix="green" icon={<FontIcon>description</FontIcon>} /> ;
let downloadItems = [];
let prevSection = '';
if (!_.isEmpty(downloadFiles)) {
Object.keys(downloadFiles).forEach((key, index) => {
const item: IDownloadFile = downloadFiles[key];
if ( prevSection !== item.source ) {
if (prevSection !== '') {
downloadItems.push(<Divider key={item.source + '_' + index} className="md-cell md-cell--12" />);
}
downloadItems.push(
<Subheader primaryText={item.source} key={item.source + index} className="md-cell md-cell--12" />);
}
downloadItems.push(
<ListItem
key={item.filename + index}
leftAvatar={fileAvatar}
rightIcon={<FontIcon>file_download</FontIcon>}
primaryText={item.filename}
secondaryText={'.' + downloadFormat}
onClick={this.onClickDownloadFile.bind(this, item)}
className="md-cell md-cell--3"
/>
);
prevSection = item.source;
});
}
return (
<div style={{width: '100%'}}>
@ -457,51 +390,23 @@ export default class Dashboard extends React.Component<IDashboardProps, IDashboa
{renderHTML(infoHtml)}
</div>
</Dialog>
<Dialog
id="downloadData"
title={(
<Toolbar
title="Export Data"
fixed
style={{ width: '100%' }}
actions={(
<SelectField
id="selectExportFormat"
placeholder="File format"
position={SelectField.Positions.BELOW}
menuItems={['json', 'csv']}
defaultValue={downloadFormat}
onChange={this.onChangeDownloadFormat.bind(this)}
/>
)}
/>
)}
visible={askDownload}
focusOnMount={false}
onHide={this.onCloseExport}
dialogStyle={{ width: '80%' }}
contentStyle={{ marginTop: '20px' }}
>
<List className="md-grid" style={{ maxHeight: 400 }}>
{downloadItems}
</List>
</Dialog>
<Editor dashboard={dashboard} />
<Settings dashboard={dashboard} />
<Dialog
id="speedBoost"
id="deleteDashboard"
visible={askDelete}
title="Are you sure?"
aria-labelledby="speedBoostDescription"
aria-labelledby="deleteDashboardDescription"
modal
actions={[
{ onClick: this.onDeleteDashboardApprove, primary: false, label: 'Permanently Delete', },
{ onClick: this.onDeleteDashboardCancel, primary: true, label: 'Cancel' }
]}
>
<p id="speedBoostDescription" className="md-color--secondary-text">
<p id="deleteDashboardDescription" className="md-color--secondary-text">
Deleting this dashboard will remove all Connections/Customization you have made to it.
Are you sure you want to permanently delete this dashboard?
</p>

Просмотреть файл

@ -1,6 +1,7 @@
import * as React from 'react';
import * as _ from 'lodash';
import plugins from './generic/plugins';
import * as formats from '../utils/data-formats';
import { DataSourceConnector } from '../data-sources/DataSourceConnector';
import VisibilityActions from '../actions/VisibilityActions';
@ -58,9 +59,14 @@ export default class ElementConnector {
dashboard.elements.forEach((element, idx) => {
var ReactElement = plugins[element.type];
var { id, dependencies, actions, props, title, subtitle, size, theme, location } = element;
var { id, dependencies, source, actions, props, title, subtitle, size, theme, location } = element;
var layoutProps = _.find(layout, { 'i': id });
if (source && typeof ReactElement.fromSource === 'function') {
let fromSource = ReactElement.fromSource(source);
dependencies = _.extend({}, dependencies, fromSource);
}
if (dependencies && dependencies.visible && !visibilityFlags[dependencies.visible]) {
if (typeof visibilityFlags[dependencies.visible] === 'undefined') {
let flagDependencies = DataSourceConnector.extrapolateDependencies({ value: dependencies.visible });
@ -79,7 +85,7 @@ export default class ElementConnector {
elements.push(
<div key={id}>
<ReactElement
id={id + idx}
id={id + '@' + idx}
dependencies={dependencies}
actions={actions || {}}
props={props || {}}
@ -99,18 +105,25 @@ export default class ElementConnector {
filters: React.Component<any, any>[],
additionalFilters: React.Component<any, any>[]
} {
var filters = [];
var additionalFilters = [];
let filters = [];
let additionalFilters = [];
dashboard.filters.forEach((element, idx) => {
var ReactElement = plugins[element.type];
let ReactElement = plugins[element.type];
let { dependencies, source, actions, title, subtitle, icon } = element;
if (source && typeof ReactElement.fromSource === 'function') {
let fromSource = ReactElement.fromSource(source);
dependencies = _.extend({}, dependencies, fromSource);
}
(element.first ? filters : additionalFilters).push(
<ReactElement
key={idx}
dependencies={element.dependencies}
actions={element.actions}
title={element.title}
subtitle={element.subtitle}
icon={element.icon}
dependencies={dependencies}
actions={actions}
title={title}
subtitle={subtitle}
icon={icon}
/>
);
});

Просмотреть файл

@ -16,7 +16,7 @@ import ConfigurationStore from '../../stores/ConfigurationsStore';
import ConfigurationsActions from '../../actions/ConfigurationsActions';
import utils from '../../utils';
import IDownloadFile, { exportDataSources, createDownloadFiles, downloadBlob } from '../Dashboard/DownloadFile';
import { downloadBlob } from '../Dashboard/DownloadFile';
const renderHTML = require('react-render-html');

Просмотреть файл

@ -29,7 +29,7 @@ export default class Spinner extends React.Component<any, ISpinnerState> {
var sendOriginal = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.open = function(method: string, url: string, async?: boolean, _?: string, __?: string) {
SpinnerActions.startRequestLoading();
SpinnerActions.startRequestLoading.defer(null);
openOriginal.apply(this, arguments);
};
@ -39,7 +39,7 @@ export default class Spinner extends React.Component<any, ISpinnerState> {
// readyState === 4: means the response is complete
if (_xhr.readyState === 4) {
SpinnerActions.endRequestLoading();
SpinnerActions.endRequestLoading.defer(null);
if (_xhr.status === 429) {
self._429ApplicationInsights();
@ -53,9 +53,14 @@ export default class Spinner extends React.Component<any, ISpinnerState> {
}
componentDidMount() {
this.onChange(SpinnerStore.getState());
SpinnerStore.listen(this.onChange);
}
componentWillUnmount() {
SpinnerStore.unlisten(this.onChange);
}
_429ApplicationInsights() {
let toast: IToast = { text: 'You have reached the maximum number of Application Insights requests.' };
ToastActions.addToast(toast);

Просмотреть файл

@ -1,10 +1,10 @@
import alt, { AbstractActions } from '../../alt';
interface ISpinnerActions {
startPageLoading(): void;
endPageLoading(): void;
startRequestLoading(): void;
endRequestLoading(): void;
startPageLoading: AltJS.Action<any>;
endPageLoading: AltJS.Action<any>;
startRequestLoading: AltJS.Action<any>;
endRequestLoading: AltJS.Action<any>;
}
class SpinnerActions extends AbstractActions /*implements ISpinnerActions*/ {

Просмотреть файл

@ -0,0 +1,3 @@
import Tooltip from './Tooltip';
export default Tooltip;

Просмотреть файл

@ -29,11 +29,36 @@ interface IAreaState extends IGenericState {
export default class Area extends GenericComponent<IAreaProps, IAreaState> {
static editor = AreaSettings;
static defaultProps = {
isStacked: true
isStacked: false
};
state = {
timeFormat: '',
values: [],
lines: [],
isStacked: this.props.isStacked
};
static fromSource(source: string) {
return {
values: GenericComponent.sourceFormat(source, 'graphData'),
lines: GenericComponent.sourceFormat(source, 'lines'),
timeFormat: GenericComponent.sourceFormat(source, 'timeFormat')
};
}
constructor(props: IAreaProps) {
super(props);
// apply nested props
if (props && props.props) {
if (props.props.isStacked !== undefined) {
this.state.isStacked = props.props.isStacked as boolean;
}
}
}
dateFormat(time: string): string {
return moment(time).format('MMM-DD');
}
@ -43,18 +68,16 @@ export default class Area extends GenericComponent<IAreaProps, IAreaState> {
}
generateWidgets() {
let checked = this.is('isStacked');
const { isStacked } = this.state;
return (
<div className="widgets">
<Switch
id="stack"
name="stack"
label="Stack"
checked={checked}
defaultChecked
onChange={this.handleStackChange}
/>
</div>
<Switch
id="stack"
name="stack"
label="Stack"
checked={isStacked}
defaultChecked
onChange={this.handleStackChange}
/>
);
}
@ -64,23 +87,18 @@ export default class Area extends GenericComponent<IAreaProps, IAreaState> {
}
render() {
var { timeFormat, values, lines } = this.state;
var { title, subtitle, theme, props } = this.props;
var { showLegend, areaProps } = props;
const { timeFormat, values, lines, isStacked } = this.state;
const { id, title, subtitle, theme, props } = this.props;
const { showLegend, areaProps } = props;
var format = timeFormat === 'hour' ? this.hourFormat : this.dateFormat;
var themeColors = theme || ThemeColors;
const format = timeFormat === 'hour' ? this.hourFormat : this.dateFormat;
const themeColors = theme || ThemeColors;
// gets the 'isStacked' boolean option from state, passed props or default values (in that order).
var isStacked = this.is('isStacked');
let stackProps = {};
if (isStacked) {
stackProps['stackId'] = '1';
}
const stackProps = !isStacked ? {} : { stackId : 1 };
var widgets = this.generateWidgets();
const widgets = this.generateWidgets();
var fillElements = [];
let fillElements = [];
if (values && values.length && lines) {
fillElements = lines.map((line, idx) => {
return (
@ -97,8 +115,7 @@ export default class Area extends GenericComponent<IAreaProps, IAreaState> {
}
return (
<Card title={title} subtitle={subtitle}>
{widgets}
<Card id={id} title={title} subtitle={subtitle} widgets={widgets}>
<ResponsiveContainer>
<AreaChart
margin={{ top: 5, right: 30, left: 20, bottom: 5 }}

Просмотреть файл

@ -21,23 +21,28 @@ interface IBarProps extends IGenericProps {
};
interface IBarState extends IGenericState {
values: Object[];
bars: Object[];
values: any[];
bars: any[];
}
export default class BarData extends GenericComponent<IBarProps, IBarState> {
static editor = settings;
state = {
values: [],
bars: []
};
static fromSource(source: string) {
return {
values: GenericComponent.sourceFormat(source, 'values'),
bars: GenericComponent.sourceFormat(source, 'bars')
};
}
constructor(props: any) {
super(props);
this.handleClick = this.handleClick.bind(this);
this.state = {
values: [],
bars: []
};
}
handleClick(data: any, index: number) {
@ -45,9 +50,11 @@ export default class BarData extends GenericComponent<IBarProps, IBarState> {
}
render() {
var { values, bars } = this.state;
var { title, subtitle, props } = this.props;
var { barProps, showLegend, nameKey } = props;
let { values, bars } = this.state;
let { id, title, subtitle, props } = this.props;
let { barProps, showLegend, nameKey } = props;
nameKey = nameKey || 'value';
if (!values) {
return null;
@ -55,7 +62,7 @@ export default class BarData extends GenericComponent<IBarProps, IBarState> {
if (!values || !values.length) {
return (
<Card title={title} subtitle={subtitle}>
<Card id={id} title={title} subtitle={subtitle}>
<div style={{ padding: 20 }}>No data is available</div>
</Card>
);
@ -78,7 +85,7 @@ export default class BarData extends GenericComponent<IBarProps, IBarState> {
// Todo: Receive the width of the SVG component from the container
return (
<Card title={title} subtitle={subtitle}>
<Card id={id} title={title} subtitle={subtitle}>
<ResponsiveContainer>
<BarChart
data={values}

Просмотреть файл

@ -1,11 +1,11 @@
import * as React from 'react';
import { GenericComponent, IGenericProps, IGenericState } from '../GenericComponent';
import * as _ from 'lodash';
import * as moment from 'moment';
import { Card, CardText } from 'react-md/lib/Cards';
import FontIcon from 'react-md/lib/FontIcons';
import Button from 'react-md/lib/Buttons/Button';
import CircularProgress from 'react-md/lib/Progress/CircularProgress';
import { GenericComponent, IGenericProps, IGenericState } from '../GenericComponent';
import Card from '../../Card';
const styles = {
autoscroll: {
@ -40,7 +40,7 @@ export default class Detail extends GenericComponent<IDetailProps, IDetailState>
}
render() {
const { props } = this.props;
const { props, id, title } = this.props;
const { cols, hideBorders } = props;
const { values } = this.state;
@ -74,7 +74,12 @@ export default class Detail extends GenericComponent<IDetailProps, IDetailState>
});
return (
<Card className={hideBorders ? 'hide-borders' : ''} style={styles.autoscroll}>
<Card
id={id}
title={title}
hideTitle={true}
className={hideBorders ? 'hide-borders' : ''}
contentStyle={styles.autoscroll}>
{lists}
</Card>
);

Просмотреть файл

@ -23,6 +23,15 @@ export abstract class GenericComponent<T1 extends IGenericProps, T2 extends IGen
private id: string = null;
static sourceFormat(source: string, variable: string) {
return source + ((source || '').indexOf(':') >= 0 ? '-' : ':') + variable;
}
static sourceAction(source: string, variable: string, action: string) {
let sourceFormat = GenericComponent.sourceFormat(source, variable).split(':');
return sourceFormat.join(`:${action}:`);
}
constructor(props: T1) {
super(props);
@ -87,24 +96,6 @@ export abstract class GenericComponent<T1 extends IGenericProps, T2 extends IGen
abstract render();
/**
* returns boolean option from state, passed props or default values (in that order).
* @param property name of property
*/
protected is(property: string): boolean {
if (this.state[property] !== undefined && typeof(this.state[property]) === 'boolean') {
return this.state[property];
}
let { props } = this.props;
if (props && props[property] !== undefined && typeof(props[property]) === 'boolean') {
return props[property] as boolean;
}
if (this.props[property] !== undefined && typeof(this.props[property]) === 'boolean') {
return this.props[property];
}
return false;
}
private onStateChange(state: any) {
var result = DataSourceConnector.extrapolateDependencies(this.props.dependencies);
var updatedState: IGenericState = {};

Просмотреть файл

@ -44,7 +44,7 @@ interface IMapDataProps extends IGenericProps {
};
interface IMapDataState extends IGenericState {
markers: Object[];
markers: any[];
locations: any[];
}
@ -56,13 +56,19 @@ export default class MapData extends GenericComponent<IMapDataProps, IMapDataSta
maxZoom: 8,
};
state = {
markers: [],
locations: [],
};
static fromSource(source: string) {
return {
locations: source
};
}
constructor(props: IMapDataProps) {
super(props);
this.state = {
markers: [],
locations: [],
};
}
componentWillMount() {
@ -127,7 +133,7 @@ export default class MapData extends GenericComponent<IMapDataProps, IMapDataSta
render() {
const { markers } = this.state;
const { title, subtitle, props, mapProps } = this.props;
const { id, title, subtitle, props, mapProps } = this.props;
if (!markers) {
return null;
@ -148,7 +154,7 @@ export default class MapData extends GenericComponent<IMapDataProps, IMapDataSta
const mapProperties = { ...MapData.defaultProps, ...mapProps };
return (
<Card title={title} subtitle={subtitle}>
<Card id={id} title={title} subtitle={subtitle}>
<Map
className="markercluster-map"
style={styles.map}

Просмотреть файл

@ -53,12 +53,12 @@ export default class MenuFilter extends GenericComponent<any, any> {
selectNone: 'Clear filters'
};
state = {
overlay: false,
values: [],
selectedValues: [],
originalSelectedValues: []
};
static fromSource(source: string) {
return {
selectedValue: GenericComponent.sourceFormat(source, 'values-selected'),
values: GenericComponent.sourceFormat(source, 'values-all')
};
}
constructor(props: any) {
super(props);
@ -68,6 +68,13 @@ export default class MenuFilter extends GenericComponent<any, any> {
this.hideOverlay = this.hideOverlay.bind(this);
this.selectAll = this.selectAll.bind(this);
this.selectNone = this.selectNone.bind(this);
this.state = {
overlay: false,
values: [],
selectedValues: [],
originalSelectedValues: []
};
}
toggleOverlay() {

Просмотреть файл

@ -38,6 +38,12 @@ export default class PieData extends GenericComponent<IPieProps, IPieState> {
values: null
};
static fromSource(source: string) {
return {
values: GenericComponent.sourceFormat(source, 'pieData')
};
}
constructor(props: any) {
super(props);
@ -128,7 +134,7 @@ export default class PieData extends GenericComponent<IPieProps, IPieState> {
render() {
var { values } = this.state;
var { props, title, subtitle, layout, theme } = this.props;
var { id, props, title, subtitle, layout, theme } = this.props;
var { pieProps, showLegend, legendVerticalAlign } = props;
if (!values) {
@ -139,7 +145,7 @@ export default class PieData extends GenericComponent<IPieProps, IPieState> {
// Todo: Receive the width of the SVG component from the container
return (
<Card title={title} subtitle={subtitle}>
<Card id={id} title={title} subtitle={subtitle}>
<ResponsiveContainer>
<PieChart>
<Pie

Просмотреть файл

@ -36,7 +36,7 @@ export default class Scatter extends GenericComponent<IScatterProps, IScatterSta
render() {
var { groupedValues } = this.state;
var { title, subtitle, theme, props } = this.props;
var { id, title, subtitle, theme, props } = this.props;
var { scatterProps, groupTitles } = props;
var { xDataKey, yDataKey, zDataKey, zRange } = this.props.props;
@ -70,7 +70,7 @@ export default class Scatter extends GenericComponent<IScatterProps, IScatterSta
}
return (
<Card title={title} subtitle={subtitle}>
<Card id={id} title={title} subtitle={subtitle}>
<ResponsiveContainer>
<ScatterChart margin={{ top: 5, right: 30, left: 20, bottom: 5 }} {...scatterProps}>
<XAxis dataKey={xDataKey} />

Просмотреть файл

@ -1,7 +1,7 @@
import * as React from 'react';
import * as _ from 'lodash';
import { Card } from 'react-md/lib/Cards';
import Card from '../../Card';
import FontIcon from 'react-md/lib/FontIcons';
import Tooltip from '../../Tooltip';
@ -15,7 +15,16 @@ const styles = {
float: 'none',
padding: 0,
verticalAlign: 'middle'
}
} as React.CSSProperties,
title: {
margin: 0,
padding: 0,
} as React.CSSProperties,
content: {
margin: 0,
padding: 0,
overflow: 'visible'
} as React.CSSProperties
};
interface IScorecardProps extends IGenericProps {
@ -31,6 +40,20 @@ interface IScorecardProps extends IGenericProps {
export default class Scorecard extends GenericComponent<IScorecardProps, any> {
static editor = settings;
static fromSource(source: any) {
if (!source || typeof source !== 'object') { return {}; }
let mappings = {};
_.keys(source).forEach(key => {
mappings['card_' + key + '_value'] = GenericComponent.sourceFormat(source[key], 'value');
mappings['card_' + key + '_heading'] = GenericComponent.sourceFormat(source[key], 'heading');
mappings['card_' + key + '_color'] = GenericComponent.sourceFormat(source[key], 'color');
mappings['card_' + key + '_icon'] = GenericComponent.sourceFormat(source[key], 'icon');
mappings['card_' + key + '_subvalue'] = GenericComponent.sourceFormat(source[key], 'subvalue');
mappings['card_' + key + '_subheading'] = GenericComponent.sourceFormat(source[key], 'subheading');
});
return mappings;
}
constructor(props: IScorecardProps) {
super(props);
@ -47,7 +70,7 @@ export default class Scorecard extends GenericComponent<IScorecardProps, any> {
render() {
let { values, value, icon, subvalue, color, className } = this.state;
let { title, props, actions } = this.props;
let { id, title, props, actions } = this.props;
let { subheading, colorPosition, scorecardWidth, onClick, tooltip } = props;
if (_.has(this.state, 'values')) {
@ -90,8 +113,8 @@ export default class Scorecard extends GenericComponent<IScorecardProps, any> {
this.valueToCard(val, idx, className, colorPosition, scorecardWidth));
return (
<Card className="md-card-scorecard">
<div className="md-grid--no-spacing">
<Card id={id} title={title} hideTitle={true} titleStyle={styles.title} contentStyle={styles.content}>
<div className="md-grid--no-spacing md-card-scorecard">
{cards}
</div>
</Card>

Просмотреть файл

@ -93,7 +93,7 @@ export default class SplitPanel extends GenericComponent<ISplitViewProps, ISplit
}
render() {
const { props } = this.props;
const { props, id } = this.props;
const { cols, group, hideBorders, compact } = props;
const { groups, values } = this.state;
@ -123,11 +123,12 @@ export default class SplitPanel extends GenericComponent<ISplitViewProps, ISplit
/>
);
});
const table = (!values || values.length === 0) ?
<CircularProgress key="loading" id="spinner" />
: (
<Table
id={id}
props={this.props.props}
dependencies={this.props.dependencies}
actions={this.props.actions || {}}

Просмотреть файл

@ -5,10 +5,10 @@ import * as moment from 'moment';
import utils from '../../../utils';
import { DataTable, TableHeader, TableBody, TableRow, TableColumn, TablePagination } from 'react-md/lib/DataTables';
import { Card, CardText, TableCardHeader } from 'react-md/lib/Cards';
import FontIcon from 'react-md/lib/FontIcons';
import Button from 'react-md/lib/Buttons/Button';
import CircularProgress from 'react-md/lib/Progress/CircularProgress';
import Card from '../../Card';
const styles = {
autoscroll: {
@ -100,7 +100,7 @@ export default class Table extends GenericComponent<ITableProps, ITableState> {
}
render() {
const { props } = this.props;
const { props, id, title } = this.props;
const { checkboxes, cols, rowClassNameField, hideBorders, compact } = props;
const { values, rowIndex, rowsPerPage, currentPage, rowsPerPageItems } = this.state;
@ -109,7 +109,7 @@ export default class Table extends GenericComponent<ITableProps, ITableState> {
}
let totalRows = values.length;
let pageValues = values.slice(rowIndex, rowIndex + rowsPerPage) || [];
let pageValues = Array.isArray(values) && values.slice(rowIndex, rowIndex + rowsPerPage) || [];
let renderColumn = (col: ITableColumnProps, value: any): JSX.Element => {
let style = { color: col.color ? value[col.color] : null };
@ -181,7 +181,11 @@ export default class Table extends GenericComponent<ITableProps, ITableState> {
className += compact ? 'table-compact' : '';
return (
<Card className={hideBorders ? 'hide-borders' : ''} style={styles.autoscroll}>
<Card id={id}
title={title}
hideTitle={true}
className={hideBorders ? 'hide-borders' : ''}
contentStyle={styles.autoscroll}>
<DataTable plain={!checkboxes} data={checkboxes} className={className} baseId="pagination" responsive={false}>
<TableHeader>
<TableRow>

Просмотреть файл

@ -7,6 +7,13 @@ export default class TextFilter extends GenericComponent<any, any> {
static defaultProps = {
title: 'Select'
};
static fromSource(source: string) {
return {
selectedValue: GenericComponent.sourceFormat(source, 'values-selected'),
values: GenericComponent.sourceFormat(source, 'values-all')
};
}
constructor(props: any) {
super(props);

Просмотреть файл

@ -25,6 +25,13 @@ interface ITimelineState extends IGenericState {
export default class Timeline extends GenericComponent<ITimelineProps, ITimelineState> {
static editor = settings;
static fromSource(source: string) {
return {
values: GenericComponent.sourceFormat(source, 'graphData'),
lines: GenericComponent.sourceFormat(source, 'lines'),
timeFormat: GenericComponent.sourceFormat(source, 'timeFormat')
};
}
dateFormat(time: string) {
return moment(time).format('MMM-DD');
@ -36,7 +43,7 @@ export default class Timeline extends GenericComponent<ITimelineProps, ITimeline
render() {
var { timeFormat, values, lines } = this.state;
var { title, subtitle, theme, props } = this.props;
var { id, title, subtitle, theme, props } = this.props;
var { lineProps } = props;
var format = timeFormat === 'hour' ? this.hourFormat : this.dateFormat;
@ -59,7 +66,7 @@ export default class Timeline extends GenericComponent<ITimelineProps, ITimeline
}
return (
<Card title={title} subtitle={subtitle}>
<Card id={id} title={title} subtitle={subtitle}>
<ResponsiveContainer>
<LineChart data={values} margin={{ top: 5, right: 30, left: 20, bottom: 5 }} {...lineProps}>
<XAxis dataKey="time" tickFormatter={format} minTickGap={20} />

Просмотреть файл

@ -81,6 +81,12 @@
text-overflow: ellipsis;
}
/* MD tooltip to appear on top of Ace editor */
.md-tooltip-container {
z-index: 2 !important;
}
/* Table */
.table > .primary {

Просмотреть файл

@ -5,6 +5,9 @@ import DialogsActions from '../components/generic/Dialogs/DialogsActions';
import datasourcePluginsMappings from './plugins/PluginsMapping';
import VisibilityActions from '../actions/VisibilityActions';
import VisibilityStore from '../stores/VisibilityStore';
import * as formats from '../utils/data-formats';
const DataFormatTypes = formats.DataFormatTypes;
export interface IDataSource {
id: string;
@ -204,6 +207,28 @@ export class DataSourceConnector {
return this.dataSources[name];
}
static handleDataFormat(
format: string | formats.IDataFormat,
plugin: IDataSourcePlugin,
state: any,
dependencies: IDictionary) {
if (!format) { return null; }
const prevState = DataSourceConnector.dataSources[plugin._props.id].store.getState();
let result = {};
let formatName = (typeof format === 'string' ? format : format.type) || DataFormatTypes.none.toString();
if (formatName && typeof formats[formatName] === 'function') {
let additionalValues = formats[formatName](format, state, dependencies, plugin, prevState) || {};
Object.assign(result, additionalValues);
}
return result;
}
private static connectDataSource(sourceDS: IDataSource) {
// Connect sources and dependencies
sourceDS.store.listen((state) => {
@ -307,7 +332,7 @@ export class DataSourceConnector {
(<any> this).setState(newData);
}
}
var StoreClass = alt.createStore(NewStoreClass, config.id + '-Store');
var StoreClass = alt.createStore(NewStoreClass as any, config.id + '-Store');
return StoreClass;
}
@ -323,24 +348,26 @@ export class DataSourceConnector {
}
// Callibrate calculated values
var calculated = plugin._props.calculated;
var state = DataSourceConnector.dataSources[plugin._props.id].store.getState();
const calculated = plugin._props.calculated;
let state = DataSourceConnector.dataSources[plugin._props.id].store.getState();
state = _.extend(state, result);
let format = plugin.getFormat();
let formatExtract = DataSourceConnector.handleDataFormat(format, plugin, state, dependencies);
if (formatExtract) {
Object.assign(result, formatExtract);
}
if (typeof calculated === 'function') {
var additionalValues = calculated(state, dependencies) || {};
Object.keys(additionalValues).forEach(key => {
result[key] = additionalValues[key];
});
let additionalValues = calculated(state, dependencies) || {};
Object.keys(additionalValues).forEach(key => { result[key] = additionalValues[key]; });
}
if (Array.isArray(calculated)) {
calculated.forEach(calc => {
var additionalValues = calc(state, dependencies) || {};
Object.keys(additionalValues).forEach(key => {
result[key] = additionalValues[key];
});
let additionalValues = calc(state, dependencies) || {};
Object.keys(additionalValues).forEach(key => { result[key] = additionalValues[key]; });
});
}

Просмотреть файл

@ -1,8 +1,10 @@
import * as _ from 'lodash';
import * as request from 'xhr-request';
import { DataSourcePlugin, IOptions } from '../DataSourcePlugin';
import { appInsightsUri } from './common';
import ApplicationInsightsConnection from '../../connections/application-insights';
import { DataSourceConnector } from '../../DataSourceConnector';
import { DataSourceConnector, IDataSource } from '../../DataSourceConnector';
import * as formats from '../../../utils/data-formats';
let connectionType = new ApplicationInsightsConnection();
@ -71,7 +73,7 @@ export default class ApplicationInsightsQuery extends DataSourcePlugin<IQueryPar
let mappings: Array<any> = [];
let queries: IDictionary = {};
let table: string = null;
let filters: Array<IFilterParams> = params.filters;
let filters: Array<IFilterParams> = params.filters || [];
// Checking if this is a single query or a fork query
let query: string;
@ -142,10 +144,24 @@ export default class ApplicationInsightsQuery extends DataSourcePlugin<IQueryPar
returnedResults[aTable] = resultTables.length > idx ? resultTables[idx] : null;
// Get state for filter selection
const prevState = DataSourceConnector.getDataSource(this._props.id).store.getState();
// Extract data formats
let format = queries[aTable].format;
if (format) {
format = typeof format === 'string' ? { type: format } : format;
format = _.extend({ args: {} }, format);
format.args = _.extend({ prefix: aTable + '-' }, format.args);
let result = { values: returnedResults[aTable] };
let formatExtract = DataSourceConnector.handleDataFormat(format, this, result, dependencies);
if (formatExtract) {
Object.assign(returnedResults, formatExtract);
}
}
// Extracting calculated values
let calc = queries[aTable].calculated;
if (typeof calc === 'function') {
var additionalValues = calc(returnedResults[aTable], dependencies, prevState) || {};
let additionalValues = calc(returnedResults[aTable], dependencies, prevState) || {};
Object.assign(returnedResults, additionalValues);
}
});
@ -164,6 +180,23 @@ export default class ApplicationInsightsQuery extends DataSourcePlugin<IQueryPar
}
}
getElementQuery(dataSource: IDataSource, dependencies: IDict<any>, partialQuery: string, queryFilters: any): string {
let timespan = '30d';
const table = dataSource && dataSource['config'] && dataSource['config'].params &&
dataSource['config'].params.table || null;
if (dependencies && dependencies['timespan'] && dependencies['timespan']['queryTimespan']) {
timespan = dependencies['timespan']['queryTimespan'];
timespan = this.convertApplicationInsightsTimespan(timespan);
} else if (dependencies && dependencies['queryTimespan']) {
// handle dialog params
timespan = dependencies['queryTimespan'];
timespan = this.convertApplicationInsightsTimespan(timespan);
}
const filter = this.formatApplicationInsightsFilterString(queryFilters, dependencies);
const query = this.formatApplicationInsightsQueryString(partialQuery, timespan, filter, table);
return query;
}
private mapAllTables(results: IQueryResults, mappings: Array<IDictionary>): any[][] {
if (!results || !results.Tables || !results.Tables.length) {
@ -215,8 +248,8 @@ export default class ApplicationInsightsQuery extends DataSourcePlugin<IQueryPar
const { dependency, queryProperty } = filter;
const selectedFilters = dependencies[dependency] || [];
if (selectedFilters.length > 0) {
const f = 'where ' + selectedFilters.map((value) => `${queryProperty}=="${value}"`).join(' or ') + ' | ';
q = ` ${f} \n ${q} `;
const f = 'where ' + selectedFilters.map((value) => `${queryProperty}=="${value}"`).join(' or ');
q = isForked ? ` ${f} |\n ${q} ` : q.replace(/^(\s?\w+\s*?){1}(.)*/gim, '$1 | ' + f + ' $2');
return true;
}
return false;
@ -259,4 +292,51 @@ export default class ApplicationInsightsQuery extends DataSourcePlugin<IQueryPar
throw new Error('{ table, queries } should be of types { "string", { query1: {...}, query2: {...} } }.');
}
}
// element export formatting functions
private formatApplicationInsightsQueryString(query: string, timespan: string, filter: string, table?: string) {
let str = query.replace(/(\|)((\s??\n\s??)(.+?))/gm, '\n| $4'); // move '|' to start of each line
str = str.replace(/(\s?\|\s?)([^\|])/gim, '\n| $2'); // newline on '|'
str = str.replace(/(\sand\s)/gm, '\n $1'); // newline on 'and'
str = str.replace(/([^\(\'\"])(,\s*)(?=(\w+[^\)\'\"]\w+))/gim, '$1,\n '); // newline on ','
const timespanQuery = '| where timestamp > ago(' + timespan + ') \n';
// Checks for '|' at start
const matches = str.match(/^(\s*\||\s*\w+\s*\|)/ig);
const start = (!matches || matches.length !== 1) ? '| ' : '';
if (table) {
str = table + ' \n' + timespanQuery + filter + start + str;
} else {
// Insert timespan and filter after table name
str = str.replace(/^\s*(\w+)\s*/gi, '$1 \n' + timespanQuery + filter + start);
}
return str;
}
private formatApplicationInsightsFilterString(filters: IStringDictionary[], dependencies: IDict<any>) {
let str = '';
if (!filters) {
return str;
}
// Apply selected filters to connected query
filters.forEach((filter) => {
const { dependency, queryProperty } = filter;
const selectedFilters: string[] = dependencies[dependency] || [];
if (selectedFilters.length > 0) {
const f = '| where ' + selectedFilters.map((value) => `${queryProperty}=="${value}"`).join(' or ');
str = `${str}${f} \n`;
}
});
return str;
}
private convertApplicationInsightsTimespan(timespan: string) {
let str = timespan;
if (timespan.substr(0, 2) === 'PT') {
str = str.substring(2).toLowerCase();
} else if (timespan.substr(0, 1) === 'P') {
str = str.substring(1).toLowerCase();
}
return str;
}
}

Просмотреть файл

@ -1,5 +1,6 @@
import { IDataSource } from '../DataSourceConnector';
import { ToastActions } from '../../components/Toast';
import { DataFormatTypes, IDataFormat } from '../../utils/data-formats';
export interface IDataSourceOptions {
dependencies: (string | Object)[];
@ -27,6 +28,7 @@ export interface IDataSourcePlugin {
dependables: string[],
actions: string[],
params: IDictionary,
format: string | IDataFormat,
calculated: ICalculated
};
@ -37,8 +39,10 @@ export interface IDataSourcePlugin {
getActions(): string[];
getParamKeys(): string[];
getParams(): IDictionary;
getFormat(): string | IDataFormat;
getCalculated(): ICalculated;
getConnection(): IStringDictionary;
getElementQuery(dataSource: IDataSource, dependencies: IDict<any>, aQuery: string, queryFilters: any): string;
}
export abstract class DataSourcePlugin<T> implements IDataSourcePlugin {
@ -53,6 +57,7 @@ export abstract class DataSourcePlugin<T> implements IDataSourcePlugin {
dependables: [],
actions: [ 'updateDependencies', 'failure', 'updateSelectedValues' ],
params: <T> {},
format: DataFormatTypes.none.toString(),
calculated: {},
autoUpdateIntervalMs: -1,
};
@ -73,6 +78,7 @@ export abstract class DataSourcePlugin<T> implements IDataSourcePlugin {
props.dependables = options.dependables || [];
props.actions.push.apply(props.actions, options.actions || []);
props.params = <T> (options.params || {});
props.format = options.format || DataFormatTypes.none.toString();
props.calculated = options.calculated || {};
props.autoUpdateIntervalMs = options.autoUpdateIntervalMs || -1;
@ -139,10 +145,20 @@ export abstract class DataSourcePlugin<T> implements IDataSourcePlugin {
return this._props.params;
}
getFormat(): string | IDataFormat {
return this._props.format || DataFormatTypes.none.toString();
}
getCalculated() {
return this._props.calculated;
}
getElementQuery(dataSource: IDataSource, dependencies: IDict<any>, aQuery: string, queryFilters: any): string {
const plugin = dataSource.plugin.type;
console.warn(`'getElementQuery' function may not be fully implemented for the ${plugin} plugin.`);
return null;
}
failure(error: any): void {
ToastActions.addToast({ text: this.errorToMessage(error) });
return error;

Просмотреть файл

@ -103,6 +103,25 @@ h2, .md-subheading-2 {
float: right;
padding: 2px 7px;
}
.card-settings {
position: absolute;
top: 0;
right: 0;
z-index: 1;
}
.card-settings > span {
float: left;
}
.card-settings-btn {
color: lightgrey;
}
.card-settings-btn:hover {
color: $md-primary-color;
}
}
.react-draggable {

Просмотреть файл

@ -20,28 +20,28 @@ describe('Spinner', () => {
});
it ('Start page loading', () => {
SpinnerActions.startPageLoading();
SpinnerActions.startPageLoading(null);
let progress = TestUtils.scryRenderedComponentsWithType(spinner, CircularProgress);
expect(progress.length).toBe(1);
});
it ('Stop page loading', () => {
SpinnerActions.endPageLoading();
SpinnerActions.endPageLoading(null);
let progress = TestUtils.scryRenderedComponentsWithType(spinner, CircularProgress);
expect(progress.length).toBe(0);
});
it ('Start request loading', () => {
SpinnerActions.startRequestLoading();
SpinnerActions.startRequestLoading(null);
let progress = TestUtils.scryRenderedComponentsWithType(spinner, CircularProgress);
expect(progress.length).toBe(1);
});
it ('Start request loading', () => {
SpinnerActions.endRequestLoading();
SpinnerActions.endRequestLoading(null);
let progress = TestUtils.scryRenderedComponentsWithType(spinner, CircularProgress);
expect(progress.length).toBe(0);

Просмотреть файл

@ -0,0 +1,23 @@
import { ToastActions } from '../../components/Toast';
import { IDataSourcePlugin } from '../../data-sources/plugins/DataSourcePlugin';
export enum DataFormatTypes {
none,
timespan,
flags,
retention,
timeline
}
export interface IDataFormat {
type: string;
args: any;
}
export function formatWarn(text: string, format: string, plugin: IDataSourcePlugin) {
ToastActions.addToast({ text: `[format:${format}] text [data source:${plugin._props.id}]` });
}
export function getPrefix(format: string | IDataFormat) {
return (format && typeof format !== 'string' && format.args && format.args.prefix) || '';
}

Просмотреть файл

@ -0,0 +1,109 @@
import * as _ from 'lodash';
import utils from '../../index';
import { DataFormatTypes, IDataFormat, formatWarn, getPrefix } from '../common';
import { IDataSourcePlugin } from '../../../data-sources/plugins/DataSourcePlugin';
/**
* Formats a result to suite a bar chart
*
* Receives a list of filtering values:
* values: [
* { count: 10, barField: 'bar 1', seriesField: 'series1Value' },
* { count: 15, barField: 'bar 2', seriesField: 'series1Value' },
* { count: 20, barField: 'bar 1', seriesField: 'series2Value' },
* { count: 44, barField: 'bar 3', seriesField: 'series2Value' },
* ]
*
* And outputs the result in a consumable filter way:
* result: {
* "prefix-bars": [ 'bar 1', 'bar 2', 'bar 3' ],
* "prefix-values": [
* { value: 'bar 1', series1Value: 10, series2Value: 20 },
* { value: 'bar 2', series1Value: 15, series2Value: 0 },
* { value: 'bar 3', series1Value: 0, series2Value: 44 },
* ],
* }
*
* "prefix-selected" will be able to hold the selected values from the filter component
*
* @param format {
* type: 'bars',
* args: {
* prefix: string - a prefix string for the exported variables (default to id).
* valueField: string - The field name holding the value/y value of the bar
* barsField: string - The field name holding the names for the bars
* seriesField: string - The field name holding the series name (aggregation in a specific field)
* valueMaxLength: number - At what length to cut string values (default: 13),
* threshold: number - Under this threshold, the values will be aggregated to others (default: 0 - none)
* othersValue: string - Name for the 'Others' field (default: 'Others')
* }
* }
* @param state Current received state from data source
* @param dependencies Dependencies for the plugin
* @param plugin The entire plugin (for id generation, params etc...)
* @param prevState The previous state to compare for changing filters
*/
export function bars(
format: string | IDataFormat,
state: any,
dependencies: IDictionary,
plugin: IDataSourcePlugin,
prevState: any) {
if (typeof format === 'string') {
return formatWarn('format should be an object with args', 'bars', plugin);
}
const args = format.args || {};
const prefix = getPrefix(format);
const valueField = args.valueField || 'count';
const barsField = args.barsField || null;
const seriesField = args.seriesField || null;
const valueMaxLength = args.valueMaxLength && parseInt(args.valueMaxLength, 10) || 13;
const threshold = args.threshold || 0;
const othersValue = args.othersValue || 'Others';
let values: any[] = state.values;
// Concating values with '...'
if (values && values.length && valueMaxLength && (seriesField || barsField)) {
const cutLength = Math.max(valueMaxLength - 3, 0);
values.forEach(val => {
if (seriesField && val[seriesField] && val[seriesField].length > valueMaxLength) {
val[seriesField] = val[seriesField].substring(0, cutLength) + '...';
}
if (barsField && val[barsField] && val[barsField].length > valueMaxLength) {
val[barsField] = val[barsField].substring(0, cutLength) + '...';
}
});
}
let result = {};
let barValues = {};
// Setting the field describing the bars
if (barsField) {
let series = {};
values.forEach(val => {
barValues[val[barsField]] = barValues[val[barsField]] || { value: val[barsField] };
if (threshold && val[valueField] < threshold) {
barValues[val[barsField]][othersValue] = (barValues[val[barsField]][othersValue] || 0) + val[valueField];
series[othersValue] = true;
} else {
barValues[val[barsField]][val[seriesField]] = val[valueField];
series[val[seriesField]] = true;
}
});
result[prefix + 'bars'] = _.keys(series);
result[prefix + 'values'] = _.values(barValues);
} else {
result[prefix + 'bars'] = [ valueField ];
result[prefix + 'values'] = values;
}
return result;
}

Просмотреть файл

@ -0,0 +1,66 @@
import * as _ from 'lodash';
import utils from '../../index';
import { DataFormatTypes, IDataFormat, formatWarn, getPrefix } from '../common';
import { IDataSourcePlugin } from '../../../data-sources/plugins/DataSourcePlugin';
/**
* Formats a result to fit a filter.
*
* Receives a list of filtering values:
* values: [
* { field: 'value 1' },
* { field: 'value 2' },
* { field: 'value 3' },
* ]
*
* And outputs the result in a consumable filter way:
* result: {
* "prefix-filters": [ 'value 1', 'value 2', 'value 3' ],
* "prefix-selected": [ ],
* }
*
* "prefix-selected" will be able to hold the selected values from the filter component
*
* @param format {
* type: 'filter',
* args: {
* prefix: string - a prefix string for the exported variables (default to id).
* field: string - the field holding the filter values in the results (default = "value")
* }
* }
* @param state Current received state from data source
* @param dependencies Dependencies for the plugin
* @param plugin The entire plugin (for id generation, params etc...)
* @param prevState The previous state to compare for changing filters
*/
export function filter (
format: string | IDataFormat,
state: any,
dependencies: IDictionary,
plugin: IDataSourcePlugin,
prevState: any) {
const { values } = state;
if (!values) { return null; }
const args = typeof format !== 'string' ? format.args : {};
const prefix = getPrefix(format);
const field = args.field || 'value';
const unknown = args.unknown || 'unknown';
// This code is meant to fix the following scenario:
// When "Timespan" filter changes, to "channels-selected" variable
// is going to be reset into an empty set.
// For this reason, using previous state to copy filter
const filters = values.map(x => x[field] || unknown);
let selectedValues = [];
if (prevState[prefix + 'values-selected'] !== undefined) {
selectedValues = prevState[prefix + 'values-selected'];
}
let result = {};
result[prefix + 'values-all'] = filters;
result[prefix + 'values-selected'] = selectedValues;
return result;
}

Просмотреть файл

@ -0,0 +1,58 @@
import * as _ from 'lodash';
import utils from '../../index';
import { DataFormatTypes, IDataFormat, formatWarn, getPrefix } from '../common';
import { IDataSourcePlugin } from '../../../data-sources/plugins/DataSourcePlugin';
/**
* Turns a list of values into a list of flags
*
* Receives a list of filtering values (on the data source params variable):
* params: {
* values: [ 'value1', 'value2', 'value3' ]
* }
*
* And outputs the result in a consumable filter way:
* result: {
* "value1": false, (will be true if prefix-selected contains "value1")
* "value2": false,
* "value3": false,
* "prefix-filters": [ 'value 1', 'value 2', 'value 3' ],
* "prefix-selected": [ ],
* }
*
* "prefix-selected" will be able to hold the selected values from the filter component
*
* @param format 'filter' | {
* type: 'filter',
* args: {
* prefix: string - a prefix string for the exported variables (default to id).
* }
* }
* @param state Current received state from data source
* @param dependencies Dependencies for the plugin
* @param plugin The entire plugin (for id generation, params etc...)
* @param prevState The previous state to compare for changing filters
*/
export function flags(
format: string | IDataFormat,
state: any,
dependencies: IDictionary,
plugin: IDataSourcePlugin,
prevState: any) {
const prefix = getPrefix(format);
const params = plugin.getParams();
if (!params || !Array.isArray(params.values)) {
return formatWarn('A paramerter "values" is expected as an array on "params" in the data source', 'filter', plugin);
}
if (!state) { return null; }
let flags = {};
params.values.forEach(key => { flags[key] = state.selectedValue === key; });
flags[prefix + 'values-all'] = params.values;
flags[prefix + 'values-selected'] = state.selectedValue;
return flags;
}

Просмотреть файл

@ -0,0 +1,124 @@
import * as _ from 'lodash';
import utils from '../../index';
import { DataFormatTypes, IDataFormat, formatWarn, getPrefix } from '../common';
import { IDataSourcePlugin } from '../../../data-sources/plugins/DataSourcePlugin';
/**
* Received a result in the form of:
* values: [
* {
* totalUnique: number
* totalUniqueUsersIn24hr: number
* totalUniqueUsersIn7d: number
* totalUniqueUsersIn30d: number
* returning24hr: number
* returning7d: number
* returning30d: number
* }
* ]
*
* And returns the following format:
* {
* total: number
* returning: number
* values: [
* {
* timespan: '24 hours',
* retention: '%',
* returning: number,
* unique:number
* },
* {
* timespan: '7 days',
* retention: '%',
* returning: number,
* unique:number
* }
* {
* timespan: '30 days',
* retention: '%',
* returning: number,
* unique:number
* }
* ]
* }
*
* @param format Plugin format parameter
* @param state Current received state from data source
* @param dependencies Dependencies for the plugin
* should contain "selectedTimespan" equals to 'PT24H', 'P7D' etc...
* @param plugin The entire plugin (for id generation, params etc...)
*/
export function retention (
format: string | IDataFormat,
state: any,
dependencies: IDictionary,
plugin: IDataSourcePlugin,
prevState: any) {
const { values } = state;
const { selectedTimespan } = dependencies;
if (!values || !values.length) { return null; }
const prefix = getPrefix(format);
let result = {
totalUnique: 0,
totalUniqueUsersIn24hr: 0,
totalUniqueUsersIn7d: 0,
totalUniqueUsersIn30d: 0,
returning24hr: 0,
returning7d: 0,
returning30d: 0,
total: 0,
returning: 0,
values: []
};
_.extend(result, values[0]);
switch (selectedTimespan) {
case 'PT24H':
result.total = result.totalUniqueUsersIn24hr;
result.returning = result.returning24hr;
break;
case 'P7D':
result.total = result.totalUniqueUsersIn7d;
result.returning = result.returning7d;
break;
case 'P30D':
result.total = result.totalUniqueUsersIn30d;
result.returning = result.returning30d;
break;
default:
result.total = 0;
result.returning = 0;
break;
}
result[prefix + 'values'] = [
{
timespan: '24 hours',
retention: Math.round(100 * result.returning24hr / result.totalUniqueUsersIn24hr || 0) + '%',
returning: result.returning24hr,
unique: result.totalUniqueUsersIn24hr
},
{
timespan: '7 days',
retention: Math.round(100 * result.returning7d / result.totalUniqueUsersIn7d || 0) + '%',
returning: result.returning7d,
unique: result.totalUniqueUsersIn7d
},
{
timespan: '30 days',
retention: Math.round(100 * result.returning30d / result.totalUniqueUsersIn30d || 0) + '%',
returning: result.returning30d,
unique: result.totalUniqueUsersIn30d
},
];
return result;
}

Просмотреть файл

@ -0,0 +1,114 @@
import * as _ from 'lodash';
import utils from '../../index';
import { DataFormatTypes, IDataFormat, getPrefix } from '../common';
import { IDataSourcePlugin } from '../../../data-sources/plugins/DataSourcePlugin';
/**
* Formats a result to suite a scorecard
*
* Receives a list of one value:
* values: [{ count: 99 }]
*
* And outputs the result in a consumable filter way:
* result: {
* "prefix-value": 99,
* "prefix-heading": "Heading",
* "prefix-color": "#fff",
* "prefix-icon": "chat",
* "prefix-subvalue": 44,
* "prefix-subheading": "Subheading"
* }
*
* "prefix-selected" will be able to hold the selected values from the filter component
*
* @param format 'scorecard' | {
* type: 'scorecard',
* args: {
* prefix: string - a prefix string for the exported variables (default to id).
* countField: 'count' - Field name with count value (default: 'count')
* postfix: '%' - String to add after the value (default: null)
* thresholds: [{ value: 0, heading: '', color: '#000', icon: 'done' }]
* subvalueField: 'other_count' - Other number field to check
* subvalueThresholds: [{ subvalue: 0, subheading: '' }]
* }
* }
* @param state Current received state from data source
* @param dependencies Dependencies for the plugin
* @param plugin The entire plugin (for id generation, params etc...)
* @param prevState The previous state to compare for changing filters
*/
export function scorecard (
format: string | IDataFormat,
state: any,
dependencies: IDictionary,
plugin: IDataSourcePlugin,
prevState: any) {
let { values } = state;
const args = typeof format !== 'string' && format.args || { thresholds: null };
const countField = args.countField || 'count';
const postfix = args.postfix || null;
let checkValue = (values && values[0] && values[0][countField]) || 0;
let createValue = (value: any, heading: string, color: string, icon: string, subvalue?: any, subheading?: string) => {
let item = {};
const prefix = getPrefix(format);
item[prefix + 'value'] = utils.kmNumber(value, postfix);
item[prefix + 'heading'] = heading;
item[prefix + 'color'] = color;
item[prefix + 'icon'] = icon;
item[prefix + 'subvalue'] = subvalue || '';
item[prefix + 'subheading'] = subheading || '';
return item;
};
let thresholds = args.thresholds || [ ];
if (!thresholds.length) {
thresholds.push({ value: checkValue, heading: '', color: '#000', icon: 'done' });
}
let firstThreshold = thresholds[0];
if (!values || !values.length) {
return createValue(
firstThreshold.value,
firstThreshold.heading,
firstThreshold.color,
firstThreshold.icon
);
}
// Todo: check validity of thresholds and each value
let thresholdIdx = 0;
let threshold = thresholds[thresholdIdx];
while (thresholds.length > (thresholdIdx + 1) &&
checkValue > threshold.value &&
checkValue >= thresholds[++thresholdIdx].value) {
threshold = thresholds[thresholdIdx];
}
let subvalue = null;
let subheading = null;
if (args.subvalueField || args.subvalueThresholds) {
let subvalueField = args.subvalueField || null;
let subvalueThresholds = args.subvalueThresholds || [];
if (!subvalueThresholds.length) { subvalueThresholds.push({ subvalue: 0, subheading: '' }); }
checkValue = values[0][subvalueField || countField] || 0;
thresholdIdx = 0;
let subvalueThreshold = subvalueThresholds[thresholdIdx];
while (subvalueThresholds.length > (thresholdIdx + 1) &&
checkValue > subvalueThreshold.value &&
checkValue >= subvalueThresholds[++thresholdIdx].value) {
subvalueThreshold = subvalueThresholds[thresholdIdx];
}
subvalue = checkValue;
subheading = subvalueThreshold.subheading;
}
return createValue(checkValue, threshold.heading, threshold.color, threshold.icon, subvalue, subheading);
}

Просмотреть файл

@ -0,0 +1,93 @@
import * as _ from 'lodash';
import utils from '../../index';
import { DataFormatTypes, IDataFormat, formatWarn, getPrefix } from '../common';
import { IDataSourcePlugin } from '../../../data-sources/plugins/DataSourcePlugin';
/**
* Formats a result to suite a timeline (time series) chart
*
* Receives a list of filtering values:
* values: [
* { field: 'value 1' },
* { field: 'value 2' },
* { field: 'value 3' },
* ]
*
* And outputs the result in a consumable filter way:
* result: {
* "prefix-filters": [ 'value 1', 'value 2', 'value 3' ],
* "prefix-selected": [ ],
* }
*
* "prefix-selected" will be able to hold the selected values from the filter component
*
* @param format {
* type: 'filter',
* args: {
* prefix: string - a prefix string for the exported variables (default to id).
* timeField: 'timestamp' - The field containing timestamp
* lineField: 'channel' - A field to hold/group by different lines in the graph
* valueField: 'count' - holds the value/y value of the current point
* }
* }
* @param state Current received state from data source
* @param dependencies Dependencies for the plugin
* @param plugin The entire plugin (for id generation, params etc...)
* @param prevState The previous state to compare for changing filters
*/
export function timeline(
format: string | IDataFormat,
state: any,
dependencies: IDictionary,
plugin: IDataSourcePlugin,
prevState: any) {
if (typeof format === 'string') {
return formatWarn('format should be an object with args', 'timeline', plugin);
}
const timeline = state.values;
const { timespan } = dependencies;
const args = format.args || {};
const { timeField, lineField, valueField } = args;
const prefix = getPrefix(format);
let _timeline = {};
let _lines = {};
timeline.forEach(row => {
let timestamp = row[timeField];
let lineFieldValue = row[lineField];
let valueFieldValue = row[valueField];
let timeValue = (new Date(timestamp)).getTime();
if (!_timeline[timeValue]) {
_timeline[timeValue] = { time: (new Date(timestamp)).toUTCString() };
}
if (!_lines[lineFieldValue]) {
_lines[lineFieldValue] = { name: lineFieldValue, value: 0 };
}
_timeline[timeValue][lineFieldValue] = valueFieldValue;
_lines[lineFieldValue].value += valueFieldValue;
});
let lines = Object.keys(_lines);
let usage = _.values(_lines);
let timelineValues = _.map(_timeline, value => {
lines.forEach(line => {
if (!value[line]) { value[line] = 0; }
});
return value;
});
let result = {};
result[prefix + 'graphData'] = timelineValues;
result[prefix + 'timeFormat'] = (timespan === '24 hours' ? 'hour' : 'date');
result[prefix + 'lines'] = lines;
result[prefix + 'pieData'] = usage;
return result;
}

Просмотреть файл

@ -0,0 +1,65 @@
import * as _ from 'lodash';
import utils from '../../index';
import { DataFormatTypes, IDataFormat, formatWarn, getPrefix } from '../common';
import { IDataSourcePlugin } from '../../../data-sources/plugins/DataSourcePlugin';
/**
* Receives a timespan data source and formats is accordingly
*
* Receives a list of filtering values (on the data source params variable):
* params: {
* values: ["24 hours","1 week","1 month","3 months"]
* selectedValue: "1 month",
* queryTimespan: "PT24H",
* granularity: "5m"
* }
*
* And outputs the result in a consumable filter way:
* result: {
* "prefix-values": ["24 hours","1 week","1 month","3 months"]
* "prefix-selected": "1 month",
* }
*
* "prefix-selected" will be able to hold the selected values from the filter component
*
* @param format 'timespan' | {
* type: 'timespan',
* args: {
* prefix: string - a prefix string for the exported variables (default to id).
* }
* }
* @param state Current received state from data source
* @param dependencies Dependencies for the plugin
* @param plugin The entire plugin (for id generation, params etc...)
* @param prevState The previous state to compare for changing filters
*/
export function timespan(
format: string | IDataFormat,
state: any,
dependencies: IDictionary,
plugin: IDataSourcePlugin,
prevState: any) {
if (!state) { return null; }
const params = plugin.getParams();
const prefix = getPrefix(format);
let queryTimespan =
state.selectedValue === '24 hours' ? 'PT24H' :
state.selectedValue === '1 week' ? 'P7D' :
state.selectedValue === '1 month' ? 'P30D' :
'P90D';
let granularity =
state.selectedValue === '24 hours' ? '5m' :
state.selectedValue === '1 week' ? '1d' : '1d';
let result = {
queryTimespan,
granularity
};
result[prefix + 'values-all'] = params.values;
result[prefix + 'values-selected'] = state.selectedValue;
return result;
}

Просмотреть файл

@ -0,0 +1,8 @@
export * from './common';
export * from './formats/bars';
export * from './formats/filter';
export * from './formats/flags';
export * from './formats/retention';
export * from './formats/scorecard';
export * from './formats/timeline';
export * from './formats/timespan';

Просмотреть файл

@ -1,16 +1,18 @@
import * as moment from 'moment';
export default class Utils {
static kmNumber(num: number): string {
if (isNaN(num)) { return ''; }
static kmNumber(num: any, postfix?: string): string {
if (isNaN(num)) { return num + (postfix || ''); }
let value = parseFloat(num);
return (
num > 999999 ?
(num / 1000000).toFixed(1) + 'M' :
num > 999 ?
(num / 1000).toFixed(1) + 'K' :
(num % 1 * 10) !== 0 ?
num.toFixed(1).toString() : num.toString());
value > 999999 ?
(value / 1000000).toFixed(1) + 'M' :
value > 999 ?
(value / 1000).toFixed(1) + 'K' :
(value % 1 * 10) !== 0 ?
value.toFixed(1).toString() : value.toString()) + (postfix || '');
}
static ago(date: Date): string {
@ -52,7 +54,7 @@ export default class Utils {
objectValues.push(mapping);
});
if (valuesStringLength <= 120) {
if (valuesStringLength + sind.length <= 100) {
result += `{ ${objectValues.join()} }`;
} else {
result += `{\n${sind}\t${objectValues.join(',\n' + sind + '\t')}\n${sind}}`;
@ -91,7 +93,7 @@ export default class Utils {
return res;
});
if (arrayStringLength <= 120) {
if (arrayStringLength + sind.length <= 100) {
result += `[${mappedValues.join()}]`;
} else {
result += `[\n${sind}\t${mappedValues.join(',\n' + sind + '\t')}\n${sind}]`;

0
docs/data-formats.md Normal file
Просмотреть файл

Разница между файлами не показана из-за своего большого размера Загрузить разницу

Просмотреть файл

@ -201,7 +201,7 @@ export const config: IDashboardConfig = /*return*/ {
};
}
},
users_timeline: {
timeline_users: {
query: (dependencies) => {
var { granularity } = dependencies;
return `
@ -246,10 +246,10 @@ export const config: IDashboardConfig = /*return*/ {
});
return {
"timeline-users-graphData": timelineValues,
"timeline-users-channelUsage": channelUsage,
"timeline-users-timeFormat": (timespan === "24 hours" ? 'hour' : 'date'),
"timeline-users-channels": channels
"timeline_users-graphData": timelineValues,
"timeline_users-channelUsage": channelUsage,
"timeline_users-timeFormat": (timespan === "24 hours" ? 'hour' : 'date'),
"timeline_users-channels": channels
};
}
},
@ -323,7 +323,7 @@ export const config: IDashboardConfig = /*return*/ {
};
}
},
sentiments: {
sentiment: {
query: () => `
where name startswith 'MBFEvent.Sentiment' |
extend score=customDimensions.score|
@ -596,9 +596,9 @@ export const config: IDashboardConfig = /*return*/ {
size: { w: 5,h: 8 },
dependencies: {
visible: "modes:users",
values: "ai:timeline-users-graphData",
lines: "ai:timeline-users-channels",
timeFormat: "ai:timeline-users-timeFormat"
values: "ai:timeline_users-graphData",
lines: "ai:timeline_users-channels",
timeFormat: "ai:timeline_users-timeFormat"
}
},
{
@ -616,7 +616,7 @@ export const config: IDashboardConfig = /*return*/ {
title: "Channel Usage (Users)",
subtitle: "Total users sent per channel",
size: { w: 3,h: 8 },
dependencies: { visible: "modes:users",values: "ai:timeline-users-channelUsage" },
dependencies: { visible: "modes:users",values: "ai:timeline_users-channelUsage" },
props: { showLegend: true,compact: true,entityType: "Users" }
},
{
@ -701,7 +701,7 @@ export const config: IDashboardConfig = /*return*/ {
params: ["title","intent","queryspan"],
dataSources: [
{
id: "intentsDialog-data",
id: "intentsDialog",
type: "ApplicationInsights/Query",
dependencies: {
intent: "dialog_intentsDialog:intent",
@ -712,7 +712,7 @@ export const config: IDashboardConfig = /*return*/ {
params: {
table: "customEvents",
queries: {
"intent-usage": {
"intentTimeline": {
query: ({ intent, granularity }) => `
extend intent=(customDimensions.intent)
| where timestamp > ago(30d) and intent =~ "${intent}"
@ -737,13 +737,13 @@ export const config: IDashboardConfig = /*return*/ {
});
return {
"timeline-graphData": _timeline,
"timeline-values": ["value"],
"timeline-timeFormat": (timespan === "24 hours" ? 'hour' : 'date')
"intentTimeline-graphData": _timeline,
"intentTimeline-values": ["value"],
"intentTimeline-timeFormat": (timespan === "24 hours" ? 'hour' : 'date')
};
}
},
"entities-usage": {
"entitiesUsage": {
query: ({ intent }) => `
extend conversation=tostring(customDimensions.conversationId),
entityType=tostring(customDimensions.entityType),
@ -766,12 +766,12 @@ export const config: IDashboardConfig = /*return*/ {
});
return {
"entities-usage": _.values(barResults),
"entities-usage-bars": entity_values
"entitiesUsage": _.values(barResults),
"entitiesUsage-bars": entity_values
};
}
},
"total-conversations": {
"intentConversions": {
query: ({ intent }) => `
extend conversation=tostring(customDimensions.conversationId), intent=customDimensions.intent |
where name=='MBFEvent.Intent' and intent =~ '${intent}' |
@ -780,7 +780,7 @@ export const config: IDashboardConfig = /*return*/ {
`,
calculated: (results) => {
return {
"total-conversations": (results && results.length && results[0].Count) || 0
"intentConversions-totalConversations": (results && results.length && results[0].Count) || 0
}
}
},
@ -946,29 +946,29 @@ export const config: IDashboardConfig = /*return*/ {
title: "Entity count appearances in intent",
subtitle: "Entity usage and count for the selected intent",
size: { w: 6,h: 8 },
dependencies: { values: "intentsDialog-data:entities-usage",bars: "intentsDialog-data:entities-usage-bars" },
dependencies: { values: "intentsDialog:entitiesUsage",bars: "intentsDialog:entitiesUsage-bars" },
props: { nameKey: "entityType" }
},
{
id: "utterances",
type: "Table",
size: { w: 4,h: 8 },
dependencies: { values: "intentsDialog-data:intent_utterances" },
dependencies: { values: "intentsDialog:intent_utterances" },
props: {
cols: [{ header: "Top Utterances",width: "200px",field: "text" },{ header: "Count",field: "count_utterances",type: "number" }]
}
},
{
id: "intent-timeline",
id: "intentTimeline",
type: "Timeline",
title: "Message Rate",
subtitle: "How many messages were sent per timeframe",
size: { w: 8,h: 8 },
dependencies: {
visible: "modes:messages",
values: "intentsDialog-data:timeline-graphData",
lines: "intentsDialog-data:timeline-values",
timeFormat: "intentsDialog-data:timeline-timeFormat"
values: "intentsDialog:intentTimeline-graphData",
lines: "intentsDialog:intentTimeline-values",
timeFormat: "intentsDialog:intentTimeline-timeFormat"
}
},
{
@ -976,7 +976,7 @@ export const config: IDashboardConfig = /*return*/ {
type: "Scorecard",
size: { w: 2,h: 8 },
dependencies: {
card_conversations_value: "intentsDialog-data:total-conversations",
card_conversations_value: "intentsDialog:intentConversions-totalConversations",
card_conversations_heading: "::Conversations",
card_conversations_color: "::#2196F3",
card_conversations_icon: "::chat",
@ -1052,7 +1052,7 @@ export const config: IDashboardConfig = /*return*/ {
params: ["title","queryspan"],
dataSources: [
{
id: "sentiment-conversations-data",
id: "sentimentConversations",
type: "ApplicationInsights/Query",
dependencies: { queryTimespan: "dialog_sentimentConversations:queryspan" },
params: {
@ -1088,10 +1088,10 @@ export const config: IDashboardConfig = /*return*/ {
],
elements: [
{
id: "top5positive",
id: "sentimentConversationsTop5positive",
type: "Table",
size: { w: 5,h: 8 },
dependencies: { values: "sentiment-conversations-data:top5Positive" },
dependencies: { values: "sentimentConversations:top5Positive" },
props: {
compact: true,
cols: [
@ -1110,10 +1110,10 @@ export const config: IDashboardConfig = /*return*/ {
}
},
{
id: "top5negative",
id: "sentimentConversationsTop5negative",
type: "Table",
size: { w: 5,h: 8 },
dependencies: { values: "sentiment-conversations-data:top5Positive" },
dependencies: { values: "sentimentConversations:top5Negative" },
props: {
compact: true,
cols: [
@ -1275,7 +1275,7 @@ export const config: IDashboardConfig = /*return*/ {
],
elements: [
{
id: "errors-list",
id: "errors-selection",
type: "SplitPanel",
title: "Errors",
size: { w: 12,h: 14 },

Просмотреть файл

@ -177,14 +177,14 @@ export const config: IDashboardConfig = /*return*/ {
return t;
};
return {
'transcriptsAverageTimeWaiting-value': isFinite(avgTimeWaiting) ? timeFormat(avgTimeWaiting) : '-',
'transcriptsLongestTimeWaiting-value': isFinite(avgTimeWaiting) ? timeFormat(maxTimeWaiting) : '-',
'transcriptsShortestTimeWaiting-value': isFinite(avgTimeWaiting) ? timeFormat(minTimeWaiting) : '-',
'transcriptsTimeWaiting-avg': isFinite(avgTimeWaiting) ? timeFormat(avgTimeWaiting) : '-',
'transcriptsTimeWaiting-longest': isFinite(avgTimeWaiting) ? timeFormat(maxTimeWaiting) : '-',
'transcriptsTimeWaiting-shortest': isFinite(avgTimeWaiting) ? timeFormat(minTimeWaiting) : '-',
};
}
},
transcriptsTimeline: {
timeline: {
query: (dependencies) => {
var { granularity } = dependencies;
return `where name == 'Transcript'
@ -240,9 +240,9 @@ export const config: IDashboardConfig = /*return*/ {
'timeline-graphData': graphData,
'timeline-recipients': keys,
'timeline-timeFormat': (timespan === '24 hours' ? 'hour' : 'date'),
'transcriptsBot-value': totalBot,
'transcriptsAgent-value': totalAgent,
'transcriptsTotal-value': totalMessages,
'timeline-bot': totalBot,
'timeline-agent': totalAgent,
'timeline-total': totalMessages,
};
}
},
@ -266,10 +266,10 @@ export const config: IDashboardConfig = /*return*/ {
const waiting = customerTranscripts.filter((customer) => customer.state === 1);
const agent = customerTranscripts.filter((customer) => customer.state === 2);
return {
'customerTotal-value': customerTranscripts.length,
'customerBot-value': bot.length,
'customerWaiting-value': waiting.length,
'customerAgent-value': agent.length,
'customerTranscripts-total': customerTranscripts.length,
'customerTranscripts-bot': bot.length,
'customerTranscripts-waiting': waiting.length,
'customerTranscripts-agent': agent.length,
};
}
}
@ -280,29 +280,29 @@ export const config: IDashboardConfig = /*return*/ {
],
elements: [
{
id: 'customerTotal',
id: 'customerTranscripts',
type: 'Scorecard',
title: 'Users',
size: { w: 6, h: 3 },
dependencies: {
card_total_heading: '::Total Users',
card_total_tooltip: "::Total users",
card_total_value: 'ai:customerTotal-value',
card_total_value: 'ai:customerTranscripts-total',
card_total_color: '::#666666',
card_total_icon: '::account_circle',
card_bot_heading: '::Bot',
card_bot_tooltip: "::Total users talking to the bot",
card_bot_value: 'ai:customerBot-value',
card_bot_value: 'ai:customerTranscripts-bot',
card_bot_color: '::#00FF00',
card_bot_icon: '::memory',
card_agent_heading: '::Agent',
card_agent_tooltip: "::Total users talking to a human agent",
card_agent_value: 'ai:customerAgent-value',
card_agent_value: 'ai:customerTranscripts-agent',
card_agent_color: '::#0066FF',
card_agent_icon: '::perm_identity',
card_waiting_heading: '::Waiting',
card_waiting_tooltip: "::Total users waiting for a human agent to respond",
card_waiting_value: 'ai:customerWaiting-value',
card_waiting_value: 'ai:customerTranscripts-waiting',
card_waiting_color: '::#FF6600',
card_waiting_icon: '::more_horiz',
}
@ -316,48 +316,48 @@ export const config: IDashboardConfig = /*return*/ {
dependencies: {
card_average_heading: '::Average',
card_average_tooltip: "::Average time for human agent to respond",
card_average_value: 'ai:transcriptsAverageTimeWaiting-value',
card_average_value: 'ai:transcriptsTimeWaiting-avg',
card_average_color: '::#333333',
card_average_icon: '::av_timer',
card_max_heading: '::Slowest',
card_max_tooltip: "::Slowest time for human agent to respond",
card_max_value: 'ai:transcriptsLongestTimeWaiting-value',
card_max_value: 'ai:transcriptsTimeWaiting-longest',
card_max_color: '::#ff0000',
card_max_icon: '::timer',
card_min_heading: '::Fastest',
card_min_tooltip: "::Fastest time for human agent to respond",
card_min_value: 'ai:transcriptsShortestTimeWaiting-value',
card_min_value: 'ai:transcriptsTimeWaiting-shortest',
card_min_color: '::#0066ff',
card_min_icon: '::timer',
}
},
{
id: 'transcriptsTotal',
id: 'timelineScores',
type: 'Scorecard',
title: 'Transcripts',
size: { w: 2, h: 8 },
dependencies: {
card_total_heading: '::Total Msgs',
card_total_tooltip: "::Total messages",
card_total_value: 'ai:transcriptsTotal-value',
card_total_value: 'ai:timeline-total',
card_total_color: '::#666666',
card_total_icon: '::question_answer',
card_bot_heading: '::Bot',
card_bot_tooltip: "::Total messages with bot",
card_bot_value: 'ai:transcriptsBot-value',
card_bot_value: 'ai:timeline-bot',
card_bot_color: '::#00FF00',
card_bot_icon: '::memory',
card_agent_heading: '::Agent',
card_agent_tooltip: "::Total messages with a human",
card_agent_value: 'ai:transcriptsAgent-value',
card_agent_value: 'ai:timeline-agent',
card_agent_color: '::#0066FF',
card_agent_icon: '::perm_identity'
}
},
{
id: 'timelineHandoffConversations',
id: 'timeline',
type: 'Area',
title: 'Conversations with bot / human',
subtitle: 'How many conversations required hand-off to human',
@ -374,7 +374,7 @@ export const config: IDashboardConfig = /*return*/ {
},
{
id: 'conversations',
id: 'transcripts',
type: 'Table',
title: 'Recent Conversations',
subtitle: 'Monitor bot communications',
@ -406,7 +406,7 @@ export const config: IDashboardConfig = /*return*/ {
params: ['title', 'conversationId', 'queryspan'],
dataSources: [
{
id: 'transcripts-data',
id: 'transcriptsData',
type: 'ApplicationInsights/Query',
dependencies: {
username: 'dialog_transcriptsDialog:title',
@ -415,52 +415,45 @@ export const config: IDashboardConfig = /*return*/ {
secret: 'connection:bot-framework.directLine'
},
params: {
table: 'customEvents',
queries: {
'userConversationTranscripts':
{
query: ({ conversationId }) => {
return `where name == 'Transcript'
| where customDimensions.customerConversationId == '${conversationId}'
| extend timestamp=tostring(customDimensions.timestamp)
| project timestamp,
text=tostring(customDimensions.text),
sentimentScore=todouble(customDimensions.sentimentScore),
from=tostring(customDimensions.from),
state=toint(customDimensions.state)
| order by timestamp asc`; },
calculated: (transcripts, dependencies) => {
if (!transcripts || transcripts.length < 1) {
return null;
}
const { secret } = dependencies;
const { conversationId } = dependencies;
let values = transcripts || [];
let body, headers = {};
let disabled = transcripts[transcripts.length - 1].state !== 0 ? true : false;
values.map(v => {
const lastSentimentScore = v.sentimentScore || 0.5;
v['sentiment'] = lastSentimentScore < 0 ? 'error_outline' :
lastSentimentScore < 0.2 ? 'sentiment_very_dissatisfied' :
lastSentimentScore < 0.4 ? 'sentiment_dissatisfied' :
lastSentimentScore < 0.6 ? 'sentiment_neutral' :
lastSentimentScore < 0.8 ? 'sentiment_satisfied' : 'sentiment_very_satisfied';
});
body = {
'conversationId': conversationId,
};
headers = {
'Authorization': `Bearer ${secret}`
};
return { 'values': values, 'headers': headers, 'body': body, 'disabled': disabled };
}
}
query: ({ conversationId }) => `customEvents
| where name == 'Transcript'
| where customDimensions.customerConversationId == '${conversationId}'
| extend timestamp=tostring(customDimensions.timestamp)
| project timestamp,
text=tostring(customDimensions.text),
sentimentScore=todouble(customDimensions.sentimentScore),
from=tostring(customDimensions.from),
state=toint(customDimensions.state)
| order by timestamp asc`
},
calculated: (state, dependencies) => {
let { values } = state || [];
if (!values || values.length < 1) {
return null;
}
const { secret } = dependencies;
const { conversationId } = dependencies;
let body, headers = {};
let disabled = values[values.length - 1].state !== 0 ? true : false;
values.map(v => {
const lastSentimentScore = v.sentimentScore || 0.5;
v['sentiment'] = lastSentimentScore < 0 ? 'error_outline' :
lastSentimentScore < 0.2 ? 'sentiment_very_dissatisfied' :
lastSentimentScore < 0.4 ? 'sentiment_dissatisfied' :
lastSentimentScore < 0.6 ? 'sentiment_neutral' :
lastSentimentScore < 0.8 ? 'sentiment_satisfied' : 'sentiment_very_satisfied';
});
body = {
'conversationId': conversationId,
};
headers = {
'Authorization': `Bearer ${secret}`
};
return { values, headers, body, disabled };
}
}
],
@ -472,9 +465,9 @@ export const config: IDashboardConfig = /*return*/ {
size: { w: 2, h: 1 },
location: { x: 0, y: 0 },
dependencies: {
body: 'transcripts-data:body',
headers: 'transcripts-data:headers',
disabled: 'transcripts-data:disabled',
body: 'transcriptsData:body',
headers: 'transcriptsData:headers',
disabled: 'transcriptsData:disabled',
conversationsEndpoint: 'connection:bot-framework.conversationsEndpoint'
},
props: {
@ -494,7 +487,7 @@ export const config: IDashboardConfig = /*return*/ {
dependencies: {
token: 'connection:bot-framework.directLine',
webchatEndpoint: 'connection:bot-framework.webchatEndpoint',
dependsOn: 'transcripts-data:disabled'
dependsOn: 'transcriptsData:disabled'
},
props: {
url: ({ token, webchatEndpoint }) => `${webchatEndpoint}/?s=${token}`,
@ -504,12 +497,12 @@ export const config: IDashboardConfig = /*return*/ {
}
},
{
id: 'transcripts-list',
id: 'transcriptsData',
type: 'Table',
title: 'Transcripts',
size: { w: 12, h: 11 },
location: { x: 0, y: 1 },
dependencies: { values: 'transcripts-data:values' },
dependencies: { values: 'transcriptsData:values' },
props: {
rowClassNameField: 'from',
cols: [

Просмотреть файл

@ -86,10 +86,10 @@ export const config: IDashboardConfig = /*return*/ {
return null;
}
return {
'avg-score-value': avgscore[0].avg + '%',
'avg-score-color': avgscore[0].avg >= 80 ? '#4caf50' :
'avgScore-value': avgscore[0].avg + '%',
'avgScore-color': avgscore[0].avg >= 80 ? '#4caf50' :
(avgscore[0].avg > 60 ? '#FFc107' : '#F44336'),
'avg-score-icon': avgscore[0].avg >= 80 ? 'sentiment_very_satisfied' :
'avgScore-icon': avgscore[0].avg >= 80 ? 'sentiment_very_satisfied' :
(avgscore[0].avg > 60 ?
'sentiment_satisfied' :
'sentiment_dissatisfied')
@ -100,9 +100,9 @@ export const config: IDashboardConfig = /*return*/ {
query: () => `
where name == 'MBFEvent.QNAEvent'
| summarize hits=count() `,
calculated: hits =>({ 'score-hits': hits[0].hits })
calculated: hits =>({ 'totalHits-value': hits[0].hits })
},
scoreHits: {
'timeline_hits': {
query: () => `
where name == 'MBFEvent.QNAEvent'
| summarize hits=count() by bin(timestamp,1d)
@ -144,14 +144,14 @@ export const config: IDashboardConfig = /*return*/ {
});
return {
"timeline-hits-graphData": timelineValues,
"timeline-hits-channelUsage": channelUsage,
"timeline-hits-timeFormat": (timespan === "24 hours" ? 'hour' : 'date'),
"timeline-hits-channels": channels
"timeline_hits-graphData": timelineValues,
"timeline_hits-channelUsage": channelUsage,
"timeline_hits-timeFormat": (timespan === "24 hours" ? 'hour' : 'date'),
"timeline_hits-channels": channels
};
}
},
users_timeline: {
'timeline_users': {
query: ({ granularity }) => `
where name == 'MBFEvent.QNAEvent'
| extend userName=tostring(customDimensions.userName)
@ -194,10 +194,10 @@ export const config: IDashboardConfig = /*return*/ {
});
return {
"timeline-users-graphData": timelineValues,
"timeline-users-channelUsage": channelUsage,
"timeline-users-timeFormat": (timespan === "24 hours" ? 'hour' : 'date'),
"timeline-users-channels": channels
"timeline_users-graphData": timelineValues,
"timeline_users-channelUsage": channelUsage,
"timeline_users-timeFormat": (timespan === "24 hours" ? 'hour' : 'date'),
"timeline_users-channels": channels
};
}
},
@ -235,7 +235,7 @@ export const config: IDashboardConfig = /*return*/ {
title: "Hit Rate",
subtitle: "How many questions were asked per timeframe",
size: { w: 5,h: 8 },
dependencies: { values: "ai:timeline-hits-graphData",lines: "ai:timeline-hits-channels",timeFormat: "ai:timeline-hits-timeFormat" }
dependencies: { values: "ai:timeline_hits-graphData",lines: "ai:timeline_hits-channels",timeFormat: "ai:timeline_hits-timeFormat" }
},
{
id: "channels",
@ -243,22 +243,22 @@ export const config: IDashboardConfig = /*return*/ {
title: "Channel Usage (Users)",
subtitle: "Total users sent per channel",
size: { w: 5,h: 8 },
dependencies: { values: "ai:timeline-users-channelUsage" },
dependencies: { values: "ai:timeline_users-channelUsage" },
props: { showLegend: true, entityType: 'messages' }
},
{
id: "scorecardAvgScore",
type: "Scorecard",
title: "Avg Score",
size: { w: 2,h: 3 },
size: { w: 2,h: 8 },
dependencies: {
card_avgscore_value: "ai:avg-score-value",
card_avgscore_color: "ai:avg-score-color",
card_avgscore_icon: "ai:avg-score-icon",
card_avgscore_value: "ai:avgScore-value",
card_avgscore_color: "ai:avgScore-color",
card_avgscore_icon: "ai:avgScore-icon",
card_avgscore_heading: "::Avg Score",
card_avgscore_onClick: "::onScoreClick",
card_totalhits_value: "ai:score-hits",
card_totalhits_value: "ai:totalHits-value",
card_totalhits_color: "::#2196F3",
card_totalhits_icon: "::av_timer",
card_totalhits_heading: "::Total hits"
@ -343,7 +343,7 @@ export const config: IDashboardConfig = /*return*/ {
id: "top5negative",
type: "Table",
size: { w: 5,h: 8 },
dependencies: { values: "sentiment-conversations-data:top5Positive" },
dependencies: { values: "sentiment-conversations-data:top5Negative" },
props: {
compact: true,
cols: [