diff --git a/dist/advanced_table.js b/dist/advanced_table.js new file mode 100644 index 00000000..43191e51 --- /dev/null +++ b/dist/advanced_table.js @@ -0,0 +1,1328 @@ +/* eslint-disable arrow-body-style, no-undef, no-use-before-define */ + +class GlobalConfig { + constructor() { + this.selectedFields = []; + } + + addSelectedField(field) { + if (this.selectedFields.indexOf(field) === -1) { + this.selectedFields.push(field); + } + } + + removeSelectedField(field) { + this.selectedFields = this.selectedFields.filter(selectedField => { + return selectedField !== field; + }); + } +} + +class AgColumn { + constructor(config) { + this.config = config; + this.formatColumns(); + } + + // Format the columns based on the queryResponse into an object ag-grid can handle. + formatColumns() { + const { queryResponse } = gridOptions.context.globalConfig; + const { pivots, measures, dimensions: dims } = queryResponse.fields; + const dimensions = basicDimensions(dims, this.config); + + const tableCalcs = queryResponse.fields.table_calculations; + + // Measures and table calcs are only shown in the context of pivots when present. + if (!_.isEmpty(pivots)) { + addPivots(dimensions, this.config); + } else { + // When there are no pivots, show measures and table calcs in own column. + if (!_.isEmpty(measures)) { + addMeasures(dimensions, measures, this.config); + } + if (!_.isEmpty(tableCalcs)) { + addTableCalculations(dimensions, tableCalcs); + } + } + const { config } = gridOptions.context.globalConfig; + if (!config.autoSizeEnabled) { + addWidths(dimensions); + } + this.formattedColumns = dimensions; + } +} + +const addWidths = dimensions => { + const { widths } = gridOptions.context; + _.forEach(dimensions, dim => { + const width = widths[dim.field]; + if (!_.isUndefined(width)) { + dim.width = width; + } + if (widths['Group']) { + autoGroupColumnDef.width = widths['Group']; + } + }); +}; + +class AgData { + constructor(data, formattedColumns) { + this.data = data; + this.formattedColumns = formattedColumns; + this.formatData(); + } + + // TODO: Maybe here is where we can save a value but also an indication of whether it should be + // drillable, so that the renderer understands to make an href. Then, the drillableCallback or w/e + // is going to have to dig and get the proper links from something we've indicated on the data obj. + formatData() { + this.formattedData = this.data.map(datum => { + const formattedDatum = {}; + + this.formattedColumns.forEach(col => { + const { + children, colType, field: colField, lookup, + } = col; + if (colType === 'row') { return; } + + if (colType === 'pivot') { + children.forEach(child => { + formattedDatum[child.field] = displayData(datum[child.measure][child.pivotKey]); + }); + } else { + formattedDatum[colField] = displayData(datum[lookup]); + } + }); + + return formattedDatum; + }); + } +} + +// +// User-defined grouped header class +// + +// TODO: Make multiple pivots work. +class PivotHeader { + init(agParams) { + this.agParams = agParams; + const pivots = this.agParams.displayName.split(', '); + this.eGui = document.createElement('div'); + this.eGui.classList.add('outerPivotHeader'); + _.forEach(pivots, pivot => { + const pivotDiv = document.createElement('div'); + pivotDiv.classList.add('pivotHeader'); + pivotDiv.innerHTML = pivot; + this.eGui.appendChild(pivotDiv); + }); + // this.eGui.innerHTML = this.agParams.displayName; + } + + getGui() { + return this.eGui; + } + + destroy() { + return null; + } +} + +const adjustFonts = () => { + const { config } = gridOptions.context.globalConfig; + + if ('fontFamily' in config) { + const mainDiv = document.getElementById('ag-grid-vis'); + mainDiv.style.fontFamily = config.fontFamily; + } + + // TODO: Fix the header font resizing (keeping header text centered properly). + if ('fontSize' in config) { + const agRows = document.getElementsByClassName('ag-row'); + _.forEach(agRows, row => row.style.fontSize = `${config.fontSize}px`); + } + + if ('rowHeight' in config) { + gridOptions.rowHeight = config.rowHeight; + } +}; + +// +// Display-related constants and functions +// + +const autoSize = () => { + const { config } = gridOptions.context.globalConfig; + if (config.autoSizeEnabled) { + gridOptions.columnApi.autoSizeAllColumns(); + const { gridPanel } = gridOptions.api; + if (gridPanel.eBodyContainer.scrollWidth < gridPanel.eBody.scrollWidth) { + gridOptions.api.sizeColumnsToFit(); + } + } +}; + +// Removes the current stylesheet in favor of user-selected theme in config. +const updateTheme = (classList, theme) => { + const currentClass = _.find(classList, klass => { + const match = klass.match('ag-theme'); + if (match !== null) { + return match.input; + } + return null; + }); + if (currentClass !== null) { + classList.remove(currentClass); + } + classList.add(theme); +}; + +// All of the currently supported ag-grid stylesheets. +const themes = [ + { Looker: 'ag-theme-looker' }, + { Balham: 'ag-theme-balham' }, + // { 'Balham Dark': 'ag-theme-balham-dark' }, + { Fresh: 'ag-theme-fresh' }, + { Dark: 'ag-theme-dark' }, + { Blue: 'ag-theme-blue' }, + // { Material: 'ag-theme-material' }, // TODO: bug in header. + { Bootstrap: 'ag-theme-bootstrap' }, +]; + +const defaultTheme = 'ag-theme-looker'; + +const addCSS = link => { + const linkElement = document.createElement('link'); + + linkElement.setAttribute('rel', 'stylesheet'); + linkElement.setAttribute('href', link); + + document.getElementsByTagName('head')[0].appendChild(linkElement); +}; + +// Load all ag-grid default style themes. +const loadStylesheets = () => { + addCSS('https://unpkg.com/ag-grid-community/dist/styles/ag-grid.css'); + addCSS('https://4mile.github.io/ag_grid/ag-theme-looker.css'); + // XXX For development only: + // addCSS('https://localhost:4443/ag-theme-looker.css'); + themes.forEach(theme => { + const themeName = theme[Object.keys(theme)]; + if (themeName !== 'ag-theme-looker') { + addCSS(`https://unpkg.com/ag-grid-community/dist/styles/${themeName}.css`); + } + }); +}; + +const drillingCallback = event => { // eslint-disable-line + const ds = event.currentTarget.dataset; + const keys = Object.keys(ds); + let links = []; + _.forEach(keys, key => { + const [k, i] = key.split('-'); + if (!links[i]) { links[i] = {}; } + links[i][k] = ds[key]; + }); + LookerCharts.Utils.openDrillMenu({ links, event }); +}; + +// +// User-defined cell renderers +// + +// The mere presence of this renderer is enough to actually render HTML. +const baseCellRenderer = obj => obj.value; + +// Looker's table is 1-indexed. +const rowIndexRenderer = obj => obj.rowIndex + 1; + +// +// User-defined aggregation functions +// + +const aggregate = (values, mType, valueFormat) => { + if (_.isEmpty(values)) { return; } + let agg; + // TODO Support for more types of aggregations: + // https://docs.looker.com/reference/field-reference/measure-type-reference + if (mType === 'count') { + agg = countAggFn(values); + } else if (mType === 'average') { + agg = avgAggFn(values); + } else if (mType === 'max') { + agg = maxAggFn(values); + } else if (mType === 'min') { + agg = minAggFn(values); + } else { + // Default to sum. + agg = sumAggFn(values); + } + let value; + if (_.isEmpty(valueFormat)) { + value = isFloat(agg) ? truncFloat(agg, values) : numeral(agg).format(','); + } else { + // TODO: EUR and GBP symbols don't play nice. It fails gracefully though. + value = numeral(agg).format(valueFormat); + } + return value; +}; + +const sumAggFn = values => { + return _.reduce(values, (sum, n) => { + return sum + n; + }, 0); +}; + +const avgAggFn = values => { + const total = _.reduce(values, (sum, n) => { + return sum + n; + }, 0); + + return total / values.length; +}; + +const maxAggFn = values => { + return _.max(values); +}; + +const minAggFn = values => { + return _.min(values); +}; + +const countAggFn = values => { + return _.reduce(values, (sum, n) => { + return sum + parseInt(n, 10); + }, 0); +}; + +// +// Aggregation helper functions +// + +// This attempts to apply a reasonable truncation amount if Looker's data does not +// specifically indicate one, based on the first value of the column. If not a float, +// keeps as int. +const truncFloat = (float, values) => { + const firstVal = values[0].toString().split('.'); + let digits; + if (firstVal.length > 1) { + digits = firstVal.pop().length; + return float.toFixed(digits); + } + return numeral(float.toFixed(0)).format(','); +}; + +const isFloat = num => { + return Number.isInteger(num) === false && num % 1 !== 0; +}; + +// In order to maintain proper formatting for aggregate columns, we are using +// a group aggregate function, which requires us to calculate aggregates for +// all columns at once. As a result, the code is significantly more complex +// than if we had used the simpler ag-grid individual column aggregate. +const groupRowAggNodes = nodes => { + if (!_.isEmpty(gridOptions.columnDefs)) { return; } + // This method is called often by ag-grid, sometimes with no nodes. + const { queryResponse } = gridOptions.context.globalConfig; + if (_.isEmpty(nodes) || queryResponse === undefined) { return; } + + const { measure_like: measures } = queryResponse.fields; + const result = {}; + if (!_.isEmpty(queryResponse.pivots)) { + const { pivots } = queryResponse; + const fields = pivots.flatMap(pivot => { + return measures.map(measure => { return `${pivot.key}_${measure.name}`; }); + }); + fields.forEach(field => { result[field] = []; }); + nodes.forEach(node => { + const data = node.group ? node.aggData : node.data; + fields.forEach(field => { + if (typeof data[field] !== 'undefined') { + const value = numeral(data[field]).value(); + if (value !== null) { + result[field].push(value); + } + } + }); + }); + pivots.forEach(pivot => { + // Map over again to calculate a final result value and convert to value_format. + measures.forEach(measure => { + const { type: mType, value_format: valueFormat } = measure; + const formattedField = `${pivot.key}_${measure.name}`; + result[formattedField] = aggregate( + result[formattedField], mType, valueFormat, + ) || LookerCharts.Utils.textForCell({ value: null }); + }); + }); + } else { + // XXX Merge this loop below. + measures.forEach(measure => { + result[measure.name] = []; + }); + // Map over once to determine type and populate results array. + nodes.forEach(node => { + const data = node.group ? node.aggData : node.data; + measures.forEach(measure => { + const { name } = measure; + if (typeof data[name] !== 'undefined') { + let val = cellValue(data[name]); + const value = numeral(val).value(); + if (value !== null) { + result[name].push(value); + } + } + }); + }); + + // Map over again to calculate a final result value and convert to value_format. + measures.forEach(measure => { + const { name, type: mType, value_format: valueFormat } = measure; + result[name] = aggregate(result[name], mType, valueFormat); + }); + } + + const { config, range } = gridOptions.context.globalConfig; + // The top level aggregate here isn't actually shown on our grouped table, and + // shouldn't be counted towards the conditional formatting ranges. + const includeInRange = nodes[0].level !== 0; + if (includeInRange && config.enableConditionalFormatting && config.conditionalFormattingType !== 'non_subtotals_only') { + // We want to add subtotal values to the ranges. Note: This isn't ideal/finalized behavior; + // final behavior would have these values exist within their own range, which will require + // cellStyle to understand if the cell is a subtotal (I think possible), and then draw from a diff. range. + _.forEach(result, (value, key) => { + const val = numeral(value).value(); + updateRange(key, val, range); + }); + } + + return result; +}; + +const updateRange = (key, value, range) => { + if (!range) { return; } + // Global: + if (!('min' in range)) { range.min = value; } + if (!('max' in range)) { range.max = value; } + if (value < range.min) { + range.min = value; + } + if (value > range.max) { + range.max = value; + } + // Per column: + if (!(key in range)) { + range[key] = { min: value, max: value }; + } + if (value < range[key].min) { + range[key].min = value; + } + if (value > range[key].max) { + range[key].max = value; + } +}; + +// Take into account config prefs for truncation and brevity. +const headerName = (dimension, config) => { + let label; + const customLabel = config[`customLabel_${dimension.name}`]; + if (customLabel !== undefined && customLabel !== '') { + label = config[`customLabel_${dimension.name}`]; + } else if (config.showFullFieldName) { + label = dimension.label; // eslint-disable-line + } else { + label = dimension.label_short || dimension.label; + } + + // TODO requires a _little_ more finesse. + if (config.truncateColumnNames && label.length > 15) { + label = `${label.substring(0, 12)}...`; + } + + return label; +}; + +const alignText = (styling, config, cell) => { + const { measure } = cell.colDef; + const alignment = `align_${measure}`; + if (alignment in config) { + styling['text-align'] = config[alignment]; + } +}; + +const formatText = (styling, config, cell) => { + const { measure } = cell.colDef; + const fontFormat = `fontFormat_${measure}`; + if (fontFormat in config && config[fontFormat] !== 'none') { + switch (config[fontFormat]) { + case 'bold': + styling['font-weight'] = '800'; + break; + case 'italic': + styling['font-style'] = 'italic'; + break; + case 'underline': + styling['text-decoration'] = 'underline'; + break; + case 'strikethrough': + styling['text-decoration'] = 'line-through'; + break; + } + } +}; + +// Some measures contain just a value, others are an href/html for drilling. +// This function derives a raw numerical value for either case. +const cellValue = value => { + let val; + if (value && value[0] === '<') { + const span = document.createElement('span'); + span.innerHTML = value; + val = span.textContent || span.innerText; + } else { + val = value; + } + return numeral(val).value(); +}; + +const conditionallyFormat = (styling, config, cell) => { + const { range } = globalConfig; + const { field, measure } = cell.colDef; + if (config.enableConditionalFormatting === undefined || !config.enableConditionalFormatting) { return styling; } + if (config.conditionalFormattingType === 'non_subtotals_only' && cell.node.group === true) { return styling; } + if (config.conditionalFormattingType === 'subtotals_only' && cell.node.group === false) { return styling; } + + if (!(range.keys.includes(measure)) && !(range.keys.includes(field))) { return styling; } + const { lowColor, midColor, highColor } = config; + let colorScheme = [lowColor, midColor, highColor]; + if (config.formattingStyle === 'high_to_low') { + colorScheme = [highColor, midColor, lowColor]; + } + const scale = chroma.scale(colorScheme.filter(color => !!color)); + let supportedRange = range; + if (config.perColumnRange) { + supportedRange = range[field]; + } + // Normalize number between 0 and 1 + const v = cellValue(cell.value); + if (!config.includeNullValuesAsZero && _.isNull(v)) { + return; + } + let normalizedValue = normalize(v, supportedRange); + if (isNaN(normalizedValue) || _.isNull(normalizedValue)) { + if (!config.includeNullValuesAsZero) { return; } + normalizedValue = 0; + } + + styling['background-color'] = scale(normalizedValue).hex(); +} + +// Used to apply conditional formatting to cells, if enabled. +const cellStyle = cell => { + const { config } = gridOptions.context.globalConfig; + const styling = {}; + + alignText(styling, config, cell); + formatText(styling, config, cell); + conditionallyFormat(styling, config, cell); + + return styling; +}; + +const normalize = (value, range) => { + // Edge case when there is only one value to avoid NaN response. + if (range.max === range.min && value === range.max) { return 1; } + return (value - range.min) / (range.max - range.min); +}; + +const setNonPivotRange = (datum, key, range) => { + const val = getValue(datum[key].value); + if (!_.isNull(val)) { updateRange(key, val, range); } +}; + +const setPivotRange = (datum, key, range) => { + // datum[key] is a hash with all the pivot keys. + const pivotKeys = Object.keys(datum[key]); + _.forEach(pivotKeys, pk => { + const val = getValue(datum[key][pk].value); + if (!_.isNull(val)) { updateRange(`${pk}_${key}`, val, range); } + }); +}; + +const getValue = val => { + const { config } = gridOptions.context.globalConfig; + if (_.isUndefined(config)) { return; } + if (!('includeNullValuesAsZero' in config)) { return; } + let value = numeral(val).value(); + if (_.isNull(value) && config.includeNullValuesAsZero) { + value = 0; + } + return value; +}; + +// For each column, calculate and store the min/max values for optional conditional formatting. +const calculateRange = (data, queryResponse, config) => { + if (!('applyTo' in config)) { return {}; } + let keys = _.map(queryResponse.fields.measure_like, measureLike => measureLike.name); + if (config.applyTo === 'select_fields') { + keys = keys.filter(key => globalConfig.selectedFields.includes(key)); + } + const range = { keys }; + if (config.conditionalFormattingType === 'subtotals_only') { return range; } + + data.forEach(datum => { + keys.forEach(key => { + if (_.isEmpty(queryResponse.pivots)) { + setNonPivotRange(datum, key, range); + } else { + setPivotRange(datum, key, range); + } + }); + }); + + return range; +}; + +const addRowNumbers = basics => { + basics.unshift({ + cellClass: ['rowNumber', 'groupCell'], + cellRenderer: rowIndexRenderer, + colType: 'row', + headerName: '', + headerClass: 'rowNumberHeader', + lockPosition: true, + // Arbitrary width, doesn't always seem to be respected. + width: 50, + rowGroup: false, + suppressMenu: true, + suppressResize: true, + suppressSizeToFit: true, + }); +}; + +// Base dimensions before table calcs, pivots, measures, etc added. +const basicDimensions = (dimensions, config) => { + const finalDimension = dimensions[dimensions.length - 1]; + const basics = _.map(dimensions, dimension => { + let rowGroup; + // If there is only 1 dimension, then we are going to display without grouping. + if (dimensions.length <= 1) { + rowGroup = false; + } else { + rowGroup = !(dimension.name === finalDimension.name); + } + const hide = dimensions.length > 1; + return { + cellClass: dimension.category, + cellRenderer: baseCellRenderer, + cellStyle, + colType: 'default', + field: dimension.name, + headerClass: dimension.category, + headerName: headerName(dimension, config), + hide, + lookup: dimension.name, + rowGroup: rowGroup, + suppressMenu: true, + }; + }); + + if (config.showRowNumbers) { + addRowNumbers(basics); + } + + if (dimensions.length > 1) { + autoGroupColumnDef.setLastGroup(finalDimension.name); + } + + return basics; +}; + +const addTableCalculations = (dimensions, tableCalcs) => { + let dimension; + const klass = 'tableCalc'; + tableCalcs.forEach(calc => { + dimension = { + cellClass: klass, + cellStyle, + cellRenderer: baseCellRenderer, + colType: 'table_calculation', + field: calc.name, + headerClass: klass, + headerName: calc.label, + lookup: calc.name, + rowGroup: false, + suppressMenu: true, + }; + dimensions.push(dimension); + }); +}; + +const addMeasures = (dimensions, measures, config) => { + let dimension; + const klass = 'measure'; + measures.forEach(measure => { + const { name } = measure; + dimension = { + cellClass: klass, + cellStyle, + cellRenderer: baseCellRenderer, + colType: 'measure', + field: name, + headerClass: klass, + headerName: headerName(measure, config), + lookup: name, + measure: name, + rowGroup: false, + suppressMenu: true, + }; + dimensions.push(dimension); + }); +}; + +// For every pivot there will be a column for all measures and table calcs. +const addPivots = (dimensions, config) => { + const { queryResponse } = globalConfig; + const { measure_like: measureLike } = queryResponse.fields; + const { pivots } = queryResponse; + + let dimension; + pivots.forEach(pivot => { + const { key } = pivot; + const keys = key.split('|FIELD|').join(', '); + + const outerDimension = { + children: [], + colType: 'pivot', + field: key, + headerGroupComponent: PivotHeader, + headerName: keys, + rowGroup: false, + suppressMenu: true, + }; + + measureLike.forEach(measure => { + const { name } = measure; + let klass = measure.category; + if (_.isUndefined(klass) && measure.is_table_calculation) { + klass = 'tableCalc'; // XXX standardize with snake case? + } + + dimension = { + cellClass: klass, + cellStyle, + cellRenderer: baseCellRenderer, + colType: 'pivotChild', + // colId: measure.category, + columnGroupShow: 'open', + field: `${key}_${name}`, + headerClass: klass, + headerName: headerName(measure, config), + measure: name, + pivotKey: key, + rowGroup: false, + suppressMenu: true, + }; + outerDimension.children.push(dimension); + }); + + dimensions.push(outerDimension); + }); + // Add the title: + globalConfig.hasPivot = true; +}; + +// Attempt to display in this order: HTML/drill -> rendered -> value +// TODO: Return an object here, with value, and then also with pertinent info for links/drilling. +const displayData = cell => { + if (_.isEmpty(cell)) { return null; } + let formattedCell; + if (cell.links) { + let dataset = ''; + _.forEach(cell.links, (link, i) => { + dataset += `data-label-${i}=${JSON.stringify(link.label)} data-url-${i}=${JSON.stringify(link.url)} data-type-${i}=${JSON.stringify(link.type)} `; + }); + formattedCell = `${cell.value}`; + } else if (cell.html) { + // TODO: This seems to be a diff func than table. OK? + formattedCell = LookerCharts.Utils.htmlForCell(cell).replace(' { + _.forEach(e.columns, col => { + let { field } = col.colDef; + if (col.colDef.headerName === 'Group') { + field = 'Group'; + } + gridOptions.context.widths[field] = col.actualWidth; + }); +}; + + +const options = { + // FORMATTING + enableConditionalFormatting: { + default: false, + label: 'Enable Conditional Formatting', + order: 1, + section: 'Formatting', + type: 'boolean', + }, + perColumnRange: { + default: true, + hidden: true, + label: 'Per column range', + order: 2, + section: 'Formatting', + type: 'boolean', + }, + conditionalFormattingType: { + default: 'all', + display: 'select', + label: 'Formatting Type', + order: 3, + section: 'Formatting', + type: 'string', + values: [ + { 'All': 'all' }, + { 'Subtotals only': 'subtotals_only' }, + { 'Non-subtotals only': 'non_subtotals_only' }, + ], + }, + includeNullValuesAsZero: { + default: false, + label: 'Include Null Values as Zero', + order: 4, + section: 'Formatting', + type: 'boolean', + }, + formattingStyle: { + default: 'low_to_high', + display: 'select', + label: 'Format', + order: 5, + section: 'Formatting', + type: 'string', + values: [ + { 'From low to high': 'low_to_high' }, + { 'From high to low': 'high_to_low' }, + ], + }, + formattingPalette: { + default: 'red_yellow_green', + display: 'select', + label: 'Palette', + order: 6, + section: 'Formatting', + type: 'string', + values: [ + { 'Red to Yellow to Green': 'red_yellow_green' }, + { 'Red to White to Green': 'red_white_green' }, + { 'Red to White': 'red_white' }, + { 'White to Green': 'white_green' }, + { 'Custom...': 'custom' }, + ], + }, + lowColor: { + display: 'color', + display_size: 'third', + label: 'Low', // These values updated in updateAsync + order: 7, + section: 'Formatting', + type: 'string', + }, + midColor: { + display: 'color', + display_size: 'third', + label: 'Middle', + order: 8, + section: 'Formatting', + type: 'string', + }, + highColor: { + display: 'color', + display_size: 'third', + label: 'High', + order: 9, + section: 'Formatting', + type: 'string', + }, + applyTo: { + default: 'all_numeric_fields', + display: 'select', + label: 'Apply to', + order: 10, + section: 'Formatting', + type: 'string', + values: [ + { 'All numeric fields': 'all_numeric_fields' }, + { 'Select fields...': 'select_fields' }, + ], + }, + // CONFIG + fontSize: { + default: 12, + display_size: 'third', + label: 'Font size (pt)', + order: 1, + section: 'Config', + type: 'number', + }, + fontFamily: { + default: 'Open Sans, Helvetica, Arial, sans-serif', + display: 'select', + display_size: 'two-thirds', + label: 'Font Family', + order: 2, + section: 'Config', + type: 'string', + values: [ + { 'Looker': 'Open Sans, Helvetica, Arial, sans-serif' }, + { 'Helvetica': 'BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif' }, + { 'Times New Roman': 'Times, "Times New Roman", serif' }, + ], + }, + rowHeight: { + default: 25, + display_size: 'third', + label: 'Row Height', + order: 3, + section: 'Config', + type: 'number', + }, + // SERIES + truncateColumnNames: { + default: false, + label: 'Truncate Column Names', + order: 1, + section: 'Series', + type: 'boolean', + }, + showFullFieldName: { + default: false, + label: 'Show Full Field Name', + order: 2, + section: 'Series', + type: 'boolean', + }, + // CUSTOMIZATIONS + + // PLOT + theme: { + default: defaultTheme, + display: 'select', + label: 'Table Theme', + order: 1, + section: 'Plot', + type: 'string', + values: themes, + }, + showRowNumbers: { + default: false, + label: 'Show Row Numbers', + order: 2, + section: 'Plot', + type: 'boolean', + }, + autoSizeEnabled: { + default: true, + label: 'Enable Auto Sizing', + order: 3, + section: 'Plot', + type: 'boolean', + }, +}; + +const defaultColors = { + red: '#F36254', + green: '#4FBC89', + yellow: '#FCF758', + white: '#FFFFFF', +}; + +const addOptionCustomLabels = fields => { + fields.forEach(field => { + const { label, name } = field; + const cl = `customLabel_${name}`; + options[cl] = { + display: 'text', + placeholder: `Label: ${label}`, + label, + section: 'Series', + type: 'string', + }; + }); +}; + +const addOptionAlignments = fields => { + fields.forEach(field => { + const { label, name } = field; + const alignment = `align_${name}`; + // Radio freaks out here. Maybe flip display/type? + options[alignment] = { + default: 'left', + display: 'select', + label: `Text-align: ${label}`, + section: 'Config', + type: 'string', + values: [ + { 'Left': 'left' }, + { 'Center': 'center' }, + { 'Right': 'right' }, + ], + }; + }); +}; + +const addOptionFontFormats = fields => { + fields.forEach(field => { + const { label, name } = field; + const fontFormat = `fontFormat_${name}`; + options[fontFormat] = { + default: 'none', + display: 'select', + label: `Format: ${label}`, + section: 'Config', + type: 'string', + values: [ + { 'None': 'none' }, + { 'Bold': 'bold' }, + { 'Italic': 'italic' }, + { 'Underline': 'underline' }, + { 'Strikethrough': 'strikethrough' }, + ], + }; + }); +}; + +updateColorConfig = (vis, config) => { + const originalMidColor = { + display: 'color', + display_size: 'third', + label: 'Middle', + order: 7, + section: 'Formatting', + type: 'string', + }; + // Automatically set the colors to defaults when selected. + if ('formattingPalette' in config && config.formattingPalette !== 'custom') { + let colors; + switch (config.formattingPalette) { + case 'red_yellow_green': + if (!('midColor' in options)) { options.midColor = originalMidColor; } + colors = [ + { lowColor: defaultColors.red }, + { midColor: defaultColors.yellow }, + { highColor: defaultColors.green }, + ]; + break; + case 'red_white_green': + if (!('midColor' in options)) { options.midColor = originalMidColor; } + colors = [ + { lowColor: defaultColors.red }, + { midColor: defaultColors.white }, + { highColor: defaultColors.green }, + ]; + break; + case 'red_white': + if ('midColor' in options) { delete(options.midColor); } + colors = [ + { lowColor: defaultColors.red }, + { highColor: defaultColors.white }, + ]; + break; + case 'white_green': + if ('midColor' in options) { delete(options.midColor); } + colors = [ + { lowColor: defaultColors.white }, + { highColor: defaultColors.green }, + ]; + break; + } + _.forEach(colors, color => vis.trigger('updateConfig', [color])); + } + + // Flip the labels accordingly. + if (config.formattingStyle === 'high_to_low') { + options.lowColor.label = 'High'; + options.highColor.label = 'Low'; + } else { + options.lowColor.label = 'Low'; + options.highColor.label = 'High'; + } +}; + +// Decide which columns will be getting conditional formatting applied. +const selectFormattedFields = (fields, config) => { + if (config.applyTo === 'select_fields') { + fields.forEach(field => { + const { label, name } = field; + const id = `selectedField_${name}` + options[id] = { + label, + default: 'false', + section: 'Formatting', + type: 'boolean', + }; + }); + fields.forEach(field => { + const { name } = field; + if (config[`selectedField_${name}`] === true) { + globalConfig.addSelectedField(name); + } else { + globalConfig.removeSelectedField(name); + } + }); + } else if (config.applyTo === 'all_numeric_fields') { + fields.forEach(field => { + const { name } = field; + const id = `selectedField_${name}` + if (id in options) { delete(options[id]); } + }); + } +}; + +const setupConditionalFormatting = (vis, config, measureLike) => { + updateColorConfig(vis, config); + selectFormattedFields(measureLike, config); + + if ('enableConditionalFormatting' in config) { + options.perColumnRange.hidden = !config.enableConditionalFormatting; + } + + if ('formattingPalette' in config) { + const showColors = config.formattingPalette === 'custom'; + options.lowColor.hidden = !showColors; + if ('midColor' in options) { options.midColor.hidden = !showColors; } + options.highColor.hidden = !showColors; + } +}; + + +// Once columns are available to ag-grid, we can update the options hash / config +// and add/remove custom configurations. +// This triggers two events on the visualization object: +// vis.trigger('registerOptions', options) +// vis.trigger('updateConfig', [config]) +const modifyOptions = (vis, config) => { + const { measure_like: measureLike } = globalConfig.queryResponse.fields; + + addOptionCustomLabels(measureLike); + addOptionAlignments(measureLike); + addOptionFontFormats(measureLike); + + setupConditionalFormatting(vis, config, measureLike); + + vis.trigger('registerOptions', options); +}; + +const addPivotHeader = () => { + if (!globalConfig.hasPivot) { return; } + const { config, queryResponse } = gridOptions.context.globalConfig; + if (!('showRowNumbers' in config)) { return; } + const pivots = _.map(queryResponse.fields.pivots, pivot => headerName(pivot, config)); + const labelDivs = document.getElementsByClassName('ag-header-group-cell-label'); + const titleDiv = labelDivs[labelDivs.length - 1]; + if (!_.isUndefined(titleDiv)) { + titleDiv.classList.add('pivotHeaderNameContainer'); + _.forEach(pivots, pivot => { + const pivotDiv = document.createElement('div'); + pivotDiv.innerHTML = `${pivot}:`; + pivotDiv.classList.add('pivotHeaderName'); + pivotDiv.style.float = 'right'; + titleDiv.appendChild(pivotDiv); + }); + } +}; + +const setColumns = () => { + gridOptions.api.setColumnDefs(globalConfig.formattedColumns); +}; + +// Certain config changes require a refresh of the column headers - we only +// will refresh them if needed. +const refreshColumns = details => { + // If something on the grid has changed, we want to refresh it. + if (details.changed) { + const agColumn = new AgColumn(globalConfig.config); + gridOptions.api.setColumnDefs(agColumn.formattedColumns); + } + // However, if we determine that the subtotaling isn't showing, we also want to redraw. + const values = document.getElementsByClassName('ag-cell-value'); + if (values && _.some(values, value => value.childElementCount === 0)) { + const agColumn = new AgColumn(globalConfig.config); + gridOptions.api.setColumnDefs(agColumn.formattedColumns); + } +}; + +const setLookerClasses = () => { + // Pivot stuff + const pivotHeaders = document.getElementsByClassName('pivotHeader'); + if (!_.isEmpty(pivotHeaders)) { + const parentRow = pivotHeaders[0].parentNode.parentNode.parentNode; + parentRow.classList.add('pivotHeaderRow'); + + // Set height according to how many pivots are present: + const numPivots = globalConfig.queryResponse.fields.pivots.length; + // XXX Magic number corresponding to .ag-header-group-cell + gridOptions.api.setGroupHeaderHeight(26 * numPivots); + } +}; + +const hideOverlay = (vis, element, config) => { + if (config.theme) { + const style = _.find(document.head.children, c => c.href && c.href.includes(config.theme)); + if (style.sheet && vis.loadingGrid.parentNode === element) { + element.removeChild(vis.loadingGrid); + } + } +}; + +const gridOptions = { + context: { + globalConfig: new GlobalConfig, + widths: {}, + }, + // debug: true, // for dev purposes. + animateRows: true, + autoGroupColumnDef, + columnDefs: [], + enableFilter: false, + enableSorting: false, + groupDefaultExpanded: -1, // for dev purposes. 0, + groupRowAggNodes, + onFirstDataRendered: setColumns, + onRowGroupOpened: adjustFonts, + rowSelection: 'multiple', + suppressAggFuncInHeader: true, + suppressFieldDotNotation: true, + suppressMovableColumns: true, + enableColResize: true, + onColumnResized: columnResized, + colResizeDefault: 'shift', +}; + +const { globalConfig } = gridOptions.context; + +looker.plugins.visualizations.add({ + options: options, + + create(element, _config) { + loadStylesheets(); + + element.innerHTML = ` + + `; + + this.loadingGrid = element.appendChild(document.createElement('div')); + this.loadingGrid.id = 'loading'; + this.loadingGrid.className = 'loading'; + + // Create an element to contain the grid. + this.grid = element.appendChild(document.createElement('div')); + this.grid.id = 'ag-grid-vis'; + this.grid.className = 'ag-grid-vis'; + + this.grid.classList.add(defaultTheme); + new agGrid.Grid(this.grid, gridOptions); // eslint-disable-line + }, + + updateAsync(data, element, config, queryResponse, details, done) { + hideOverlay(this, element, config); + this.clearErrors(); + + globalConfig.queryResponse = queryResponse; + modifyOptions(this, config); + + const { fields } = queryResponse; + const { dimensions, measures, pivots, table_calculations: tableCalcs } = fields; + if (dimensions.length === 0) { + this.addError({ + message: 'This chart requires dimensions.', + title: 'No Dimensions', + }); + return; + } + + if (!_.isEmpty(pivots) && (_.isEmpty(measures) && _.isEmpty(tableCalcs))) { + this.addError({ + message: 'Add a measure or table calculation to pivot on.', + title: 'Empty Pivot(s)', + }); + return; + } + + updateTheme(this.grid.classList, config.theme); + + // Gets a range for use by conditional formatting. + const range = calculateRange(data, queryResponse, config); + globalConfig.range = range; + globalConfig.config = config; + + // Manipulates Looker's queryResponse into a format suitable for ag-grid. + this.agColumn = new AgColumn(config); + const { formattedColumns } = this.agColumn; + + globalConfig.formattedColumns = formattedColumns; + refreshColumns(details); + + // Manipulates Looker's data response into a format suitable for ag-grid. + this.agData = new AgData(data, formattedColumns); + globalConfig.agData = this.agData; + + gridOptions.api.setRowData(this.agData.formattedData); + + addPivotHeader(); + + if (details.changed) { + autoSize(); + } + setLookerClasses(); + adjustFonts(); + + done(); + }, +});