@isoftdata/utility-dashboard-backend 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.gitattributes +2 -0
- package/README.md +133 -0
- package/chart-helper.js +254 -0
- package/formatter-templates.json +9 -0
- package/index.js +181 -0
- package/package.json +19 -0
- package/schema.md +120 -0
package/.gitattributes
ADDED
package/README.md
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# utility-dashboard-backend
|
|
2
|
+
A backend utility for formatting and loading dashboard chart and report data to be usable by the [frontend dashboard component](https://github.com/ISoft-Data-Systems/ractive-component-web-dashboard).
|
|
3
|
+
|
|
4
|
+
## Prerequisites
|
|
5
|
+
|
|
6
|
+
* A MySQL database schema as outlined in [schema.md](schema.md)
|
|
7
|
+
* A backend that can fetches rows from the chart, report, and report chart tables from the database with their primary keys. Any json columns should be parsed as objects.
|
|
8
|
+
* A connection or pool of connections from the `mysql` NPM module for running the charts' queries
|
|
9
|
+
* The [dashboard Ractive component](https://github.com/ISoft-Data-Systems/ractive-component-web-dashboard) on the client
|
|
10
|
+
|
|
11
|
+
## API
|
|
12
|
+
|
|
13
|
+
### formatReportMetadata
|
|
14
|
+
|
|
15
|
+
This function returns data in the format expected for the `loadReportMetadata` function on the dashboard client component.
|
|
16
|
+
|
|
17
|
+
#### Function signature:
|
|
18
|
+
|
|
19
|
+
```js
|
|
20
|
+
async formatReportMetadata(mysqlConnection, { report, chartsInReport })
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
#### Example Usage (From Pro Web `server/chart-loader.js`):
|
|
24
|
+
|
|
25
|
+
```js
|
|
26
|
+
async function loadReportMetadata({ dashboardReportId, chartId }) {
|
|
27
|
+
const report = await db.dashboardReport.loadById({ dashboardReportId }) // gets a report by its id
|
|
28
|
+
const chartsInReport = await db.dashboardChart.loadByReport({ dashboardReportId, chartId }) // gets all charts for a specified report, or if `chartId` is specified, only gets that chart for that report.
|
|
29
|
+
return await formatReportMetadata(db.pool, { report, chartsInReport })
|
|
30
|
+
}
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
#### mysqlConnection
|
|
34
|
+
A connection or pool of connections from the `mysql` npm module.
|
|
35
|
+
|
|
36
|
+
#### report
|
|
37
|
+
|
|
38
|
+
A report `object`, a single row from the `dashboard_report`/`wa_dashboard_report` table/view. The `parameters` Array from the `json` column should also be parsed from JSON and appended to this object.
|
|
39
|
+
|
|
40
|
+
Example:
|
|
41
|
+
|
|
42
|
+
```js
|
|
43
|
+
{
|
|
44
|
+
dashboardReportId: 1,
|
|
45
|
+
reportName: 'report_overview',
|
|
46
|
+
reportTitle: 'Overview',
|
|
47
|
+
shareType: 'everyone',
|
|
48
|
+
shareId: null,
|
|
49
|
+
ownerId: null,
|
|
50
|
+
parameters: [ // parameters deconstructed from `json` column after parsing
|
|
51
|
+
{
|
|
52
|
+
name: 'date_range',
|
|
53
|
+
type: 'dateRange',
|
|
54
|
+
title: 'Date Range',
|
|
55
|
+
default: 'This Year'
|
|
56
|
+
}
|
|
57
|
+
]
|
|
58
|
+
}
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
#### chartsInReport
|
|
62
|
+
|
|
63
|
+
An `Array` of chart `object`s, one for each chart in the report, multiple rows from the `dashboard_chart`/`wa_dashboard_chart` table/view joined with `dashboard_report_chart`/`wa_dashboard_report_chart` to get the chart's rank.
|
|
64
|
+
|
|
65
|
+
Example:
|
|
66
|
+
|
|
67
|
+
```js
|
|
68
|
+
[
|
|
69
|
+
{
|
|
70
|
+
chartId: 10,
|
|
71
|
+
name: 'line_sales_over_time_and_previous_period',
|
|
72
|
+
title: 'Sales over Time',
|
|
73
|
+
supertype: 'google',
|
|
74
|
+
formatting: { Sales: [Object] },
|
|
75
|
+
multiSeries: {
|
|
76
|
+
value: 'Sales',
|
|
77
|
+
series: 'Period',
|
|
78
|
+
tooltip: 'Tooltip',
|
|
79
|
+
groupXAxisBy: 'Date'
|
|
80
|
+
},
|
|
81
|
+
chartWrapper: { options: [Object], chartType: 'LineChart' },
|
|
82
|
+
rank: 1
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
chartId: 16,
|
|
86
|
+
name: 'map_sales_by_lat_long',
|
|
87
|
+
title: 'Sales Map',
|
|
88
|
+
supertype: 'google',
|
|
89
|
+
formatting: { Sales: [Object] },
|
|
90
|
+
chartWrapper: { options: [Object], chartType: 'GeoChart' },
|
|
91
|
+
rank: 2
|
|
92
|
+
}
|
|
93
|
+
]
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
### formatChartDataForReport
|
|
97
|
+
|
|
98
|
+
This function returns data in the format expected for the `loadChartDataForReport` function on the dashboard client component, including applying any report-specific chart options overrides and running the chart's query.
|
|
99
|
+
|
|
100
|
+
#### Function signature
|
|
101
|
+
|
|
102
|
+
```js
|
|
103
|
+
async formatChartDataForReport(mysqlConnection, { reportParameters, chart, reportChart, parameterSelectionList })
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
#### Example usage: (from Pro Web `server/chart-loader.js`)
|
|
107
|
+
|
|
108
|
+
```js
|
|
109
|
+
async function loadChartDataForReport ({ dashboardReportId, chartId, parameterSelectionList }) {
|
|
110
|
+
const report = await db.dashboardReport.loadById({ dashboardReportId }) // get the report object
|
|
111
|
+
const chart = await db.dashboardChart.loadById({ chartId }) // get the chart object
|
|
112
|
+
const reportChart = await db.dashboardReportChart.loadByReportAndChartId({ chartId, dashboardReportId }) // get the report chart for the rank/overrides
|
|
113
|
+
try {
|
|
114
|
+
const chartData = await formatChartDataForReport(db.pool, { reportParameters: report.parameters, chart, reportChart, parameterSelectionList })
|
|
115
|
+
return chartData
|
|
116
|
+
} catch (err) {
|
|
117
|
+
console.error(err)
|
|
118
|
+
return { isErrored: true }
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
#### mysqlConnection
|
|
124
|
+
A connection or pool of connections from the `mysql` npm module.
|
|
125
|
+
|
|
126
|
+
#### reportParameters
|
|
127
|
+
The `parameters` array from a report object.
|
|
128
|
+
|
|
129
|
+
#### chartId
|
|
130
|
+
The id of a chart row in `wa_dashboard_chart`.
|
|
131
|
+
|
|
132
|
+
#### parameterSelectionList
|
|
133
|
+
The array of selected parameters sent from the client dashboard.
|
package/chart-helper.js
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
const { formatterTemplates } = require('./formatter-templates.json')
|
|
2
|
+
const toSnakeCase = require('to-snake-case')
|
|
3
|
+
const mysqlDataTypes = require('mysql/lib/protocol/constants/types')
|
|
4
|
+
const klona = require('klona')
|
|
5
|
+
|
|
6
|
+
const mysqlToGoogleChartsDataTypeMap = mysqlDataType => {
|
|
7
|
+
switch (mysqlDataType) {
|
|
8
|
+
case 'VAR_STRING':
|
|
9
|
+
case 'STRING':
|
|
10
|
+
case 'ENUM':
|
|
11
|
+
case 'VARCHAR':
|
|
12
|
+
return 'string'
|
|
13
|
+
case 'DECIMAL':
|
|
14
|
+
case 'NEWDECIMAL':
|
|
15
|
+
case 'BIT':
|
|
16
|
+
case 'TINY':
|
|
17
|
+
case 'SHORT':
|
|
18
|
+
case 'LONG':
|
|
19
|
+
case 'FLOAT':
|
|
20
|
+
case 'DOUBLE':
|
|
21
|
+
case 'LONGLONG':
|
|
22
|
+
case 'INT24':
|
|
23
|
+
case 'YEAR':
|
|
24
|
+
return 'number'
|
|
25
|
+
case 'TIMESTAMP':
|
|
26
|
+
case 'DATETIME':
|
|
27
|
+
case 'TIMESTAMP2':
|
|
28
|
+
case 'DATETIME2':
|
|
29
|
+
return 'datetime'
|
|
30
|
+
case 'DATE':
|
|
31
|
+
case 'NEWDATE':
|
|
32
|
+
return 'date'
|
|
33
|
+
case 'TIME':
|
|
34
|
+
case 'TIME2':
|
|
35
|
+
return 'timeofday'
|
|
36
|
+
default:
|
|
37
|
+
return 'string'
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const makeDataTableCol = (key, fields, role = null) => {
|
|
42
|
+
return {
|
|
43
|
+
id: toSnakeCase(key).toLowerCase(),
|
|
44
|
+
label: key,
|
|
45
|
+
type: mysqlToGoogleChartsDataTypeMap(fields[key]),
|
|
46
|
+
role,
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
module.exports = {
|
|
51
|
+
queryWithFields: (connection, query) => { // the only function here that talks to the server at all
|
|
52
|
+
return new Promise((resolve, reject) => {
|
|
53
|
+
connection.query(query, (err, results, fields) => {
|
|
54
|
+
if (err) {
|
|
55
|
+
reject(err)
|
|
56
|
+
} else {
|
|
57
|
+
resolve({
|
|
58
|
+
results,
|
|
59
|
+
fields: fields.reduce((sum, field) => {
|
|
60
|
+
return {
|
|
61
|
+
...sum,
|
|
62
|
+
[field.name]: mysqlDataTypes[field.type] || field.type,
|
|
63
|
+
}
|
|
64
|
+
}, {}),
|
|
65
|
+
})
|
|
66
|
+
}
|
|
67
|
+
})
|
|
68
|
+
})
|
|
69
|
+
},
|
|
70
|
+
getMultiSeriesData: ({ data: queryResult, seriesOptions, formatting, fields }) => {
|
|
71
|
+
const { groupXAxisBy, series, value, tooltip } = seriesOptions
|
|
72
|
+
//Get an array of all of the keys for the series
|
|
73
|
+
const allOfTheKeysInTheSeries = new Set(queryResult.map(row => row[series]))
|
|
74
|
+
|
|
75
|
+
let objectOfAllKeysWithNullValues = {}
|
|
76
|
+
for (const key of allOfTheKeysInTheSeries) {
|
|
77
|
+
objectOfAllKeysWithNullValues[key] = null
|
|
78
|
+
//add in a spot for the tooltips for each column to go
|
|
79
|
+
if (tooltip) {
|
|
80
|
+
objectOfAllKeysWithNullValues[`${tooltip} ${key}`] = null
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
let grouped = {}
|
|
85
|
+
|
|
86
|
+
//create objects for each thing they're grouping by
|
|
87
|
+
// console.time(`create objects for each thing they're grouping by`)
|
|
88
|
+
for (const row of queryResult) {
|
|
89
|
+
//if we don't yet have a prop for what we're grouping by, then we need to initialize an object of all null values for it
|
|
90
|
+
if (!grouped[row[groupXAxisBy]]) {
|
|
91
|
+
grouped[row[groupXAxisBy]] = klona(objectOfAllKeysWithNullValues) //give nulls for all values by default
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
grouped[row[groupXAxisBy]][row[series]] = row[value]
|
|
95
|
+
// add the tooltips for each series in each row
|
|
96
|
+
if (tooltip) {
|
|
97
|
+
grouped[row[groupXAxisBy]][`${tooltip} ${row[series]}`] = row[tooltip]
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
// console.timeEnd(`create objects for each thing they're grouping by`)
|
|
101
|
+
|
|
102
|
+
//The mysql field types need to map to our output data
|
|
103
|
+
let seriesFields = { [groupXAxisBy]: fields[groupXAxisBy] }
|
|
104
|
+
let seriesFormatting = {}
|
|
105
|
+
for (const key of allOfTheKeysInTheSeries) {
|
|
106
|
+
seriesFields[key] = fields[value]
|
|
107
|
+
//add tooltips here, so a tooltip column is created for every series column
|
|
108
|
+
if (tooltip) {
|
|
109
|
+
seriesFields[`${tooltip} ${key}`] = fields[tooltip]
|
|
110
|
+
}
|
|
111
|
+
//If there is any formatting applied to the value, we need to apply it to all of the other keys
|
|
112
|
+
if (formatting && value in formatting) {
|
|
113
|
+
seriesFormatting[key] = formatting[value]
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
//turn it back to an array and make sure the grouping column is first as required by a pie chart(at least)
|
|
118
|
+
// console.time(`group it back together`)
|
|
119
|
+
let multiSeriesData = []
|
|
120
|
+
for (const key in grouped) {
|
|
121
|
+
multiSeriesData.push({
|
|
122
|
+
[groupXAxisBy]: key,
|
|
123
|
+
...grouped[key],
|
|
124
|
+
})
|
|
125
|
+
}
|
|
126
|
+
// console.timeEnd(`group it back together`)
|
|
127
|
+
// console.log(`${multiSeriesData.length} rows with ${Object.keys(multiSeriesData[0]).length} props each.`)
|
|
128
|
+
return {
|
|
129
|
+
data: multiSeriesData,
|
|
130
|
+
formatting: {
|
|
131
|
+
...formatting,
|
|
132
|
+
...seriesFormatting,
|
|
133
|
+
},
|
|
134
|
+
fields: seriesFields,
|
|
135
|
+
}
|
|
136
|
+
},
|
|
137
|
+
makeNegativeNumbericValuesZero: resultSet => {
|
|
138
|
+
const numberCols = Object.keys(resultSet[0]).filter(key => typeof resultSet[0][key] === 'number')
|
|
139
|
+
|
|
140
|
+
return resultSet.map(row => {
|
|
141
|
+
numberCols.forEach(col => {
|
|
142
|
+
if (row[col] < 0) {
|
|
143
|
+
row[col] = 0
|
|
144
|
+
}
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
return row
|
|
148
|
+
})
|
|
149
|
+
},
|
|
150
|
+
coerceNullColumn: (columnProp, data, destinationType) => {
|
|
151
|
+
const hasNullValue = data.find(row => row[columnProp] === null)
|
|
152
|
+
|
|
153
|
+
if (hasNullValue) {
|
|
154
|
+
const rowWithValidValue = data.find(row => row[columnProp] !== null)
|
|
155
|
+
let nullReplacement = ''
|
|
156
|
+
|
|
157
|
+
if ((destinationType && destinationType === 'number') || (typeof rowWithValidValue[columnProp] === 'number')) {
|
|
158
|
+
nullReplacement = 0
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return data.map(row => {
|
|
162
|
+
return {
|
|
163
|
+
...row,
|
|
164
|
+
[columnProp]: row[columnProp] ?? nullReplacement,
|
|
165
|
+
}
|
|
166
|
+
})
|
|
167
|
+
} else {
|
|
168
|
+
return data
|
|
169
|
+
}
|
|
170
|
+
},
|
|
171
|
+
handleFormattingTemplates: formatting => {
|
|
172
|
+
if (formatting && typeof formatting === 'object') {
|
|
173
|
+
for (const key in formatting) {
|
|
174
|
+
if (typeof formatting[key].format === 'string') {
|
|
175
|
+
if (formatting[key].format in formatterTemplates) {
|
|
176
|
+
formatting[key].format = formatterTemplates[formatting[key].format]
|
|
177
|
+
} else {
|
|
178
|
+
formatting[key].format = {}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return formatting
|
|
185
|
+
},
|
|
186
|
+
getDataTableFormat: (data, fields, multiSeries) => {
|
|
187
|
+
let cols = []
|
|
188
|
+
// domain column for multiseries charts
|
|
189
|
+
if (multiSeries) {
|
|
190
|
+
cols = [ makeDataTableCol(multiSeries.groupXAxisBy, fields) ]
|
|
191
|
+
}
|
|
192
|
+
// All other columns (including domain for non multiseries charts)
|
|
193
|
+
for (const key in fields) {
|
|
194
|
+
if (!multiSeries || key !== multiSeries.groupXAxisBy) {
|
|
195
|
+
if (multiSeries && multiSeries.tooltip && key.includes(multiSeries.tooltip)) {
|
|
196
|
+
// If it's a tooltip, set the column's role exlicitly
|
|
197
|
+
cols.push(makeDataTableCol(key, fields, 'tooltip'))
|
|
198
|
+
} else {
|
|
199
|
+
// Otherwise, role is implicitly assigned
|
|
200
|
+
cols.push(makeDataTableCol(key, fields))
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const rows = data.map(row => {
|
|
206
|
+
let items = []
|
|
207
|
+
|
|
208
|
+
for (const column in row) {
|
|
209
|
+
items.push({ v: row[column] })
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return {
|
|
213
|
+
c: items,
|
|
214
|
+
}
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
return { cols, rows }
|
|
218
|
+
},
|
|
219
|
+
makeDataCumulative: dataTable => {
|
|
220
|
+
dataTable.rows.forEach((row, rowIndex) => {
|
|
221
|
+
if (rowIndex > 0) {
|
|
222
|
+
row.c.forEach((col, colIndex) => {
|
|
223
|
+
if (colIndex > 0 && col.v == null) {
|
|
224
|
+
col.v = dataTable.rows[rowIndex - 1].c[colIndex].v
|
|
225
|
+
} else if (colIndex > 0) {
|
|
226
|
+
col.v = col.v + dataTable.rows[rowIndex - 1].c[colIndex].v
|
|
227
|
+
}
|
|
228
|
+
})
|
|
229
|
+
}
|
|
230
|
+
})
|
|
231
|
+
return dataTable
|
|
232
|
+
},
|
|
233
|
+
getRactiveTableDataFormat: (data, fields, formatting) => {
|
|
234
|
+
const columns = Object.keys(fields).map(field => {
|
|
235
|
+
// right align currency values
|
|
236
|
+
if (formatting[field]?.type === 'NumberFormat' && formatting?.[field].format.prefix === '$') {
|
|
237
|
+
return {
|
|
238
|
+
property: field,
|
|
239
|
+
name: field,
|
|
240
|
+
class: 'text-right',
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
return {
|
|
244
|
+
property: field,
|
|
245
|
+
name: field,
|
|
246
|
+
}
|
|
247
|
+
})
|
|
248
|
+
return {
|
|
249
|
+
columns,
|
|
250
|
+
rows: data,
|
|
251
|
+
sortedRows: [],
|
|
252
|
+
}
|
|
253
|
+
},
|
|
254
|
+
}
|
package/index.js
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
const db = require('@isoftdata/utility-db')
|
|
2
|
+
const {
|
|
3
|
+
getMultiSeriesData,
|
|
4
|
+
makeNegativeNumbericValuesZero,
|
|
5
|
+
coerceNullColumn,
|
|
6
|
+
handleFormattingTemplates,
|
|
7
|
+
getDataTableFormat,
|
|
8
|
+
queryWithFields,
|
|
9
|
+
makeDataCumulative,
|
|
10
|
+
getRactiveTableDataFormat: getRactiveTableDataFormat } = require('./chart-helper.js')
|
|
11
|
+
const { datesFromRange } = require('@isoftdata/utility-date-time')
|
|
12
|
+
const formatDate = require('date-fns/format')
|
|
13
|
+
|
|
14
|
+
const handleChartTypeSpecificQuirks = (chartType, resultSet) => {
|
|
15
|
+
if (resultSet.length) {
|
|
16
|
+
switch (chartType) {
|
|
17
|
+
case 'PieChart':
|
|
18
|
+
//PieCharts error if you have negative numbers, so we'll just set those to 0
|
|
19
|
+
resultSet = makeNegativeNumbericValuesZero(resultSet)
|
|
20
|
+
break
|
|
21
|
+
case 'TreeMap':
|
|
22
|
+
//tree charts need the 2nd columns to be not null
|
|
23
|
+
resultSet = coerceNullColumn(Object.keys(resultSet[0])[1], resultSet)
|
|
24
|
+
break
|
|
25
|
+
default:
|
|
26
|
+
//all charts need the first column to be not null
|
|
27
|
+
resultSet = coerceNullColumn(Object.keys(resultSet[0])[0], resultSet)
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return resultSet
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const loadChartData = async(mysqlConnection, { name, query: queryObject, formatting, chartWrapper, multiSeries, parameters, table, ...theRest }) => { // needs database connection
|
|
35
|
+
let { results: queryResultSet, fields } = (queryObject && queryObject.sql) ? await queryWithFields(mysqlConnection, queryObject) : { fields: [], results: [] }
|
|
36
|
+
let data = handleChartTypeSpecificQuirks(chartWrapper.chartType, queryResultSet)
|
|
37
|
+
|
|
38
|
+
if (multiSeries) {
|
|
39
|
+
({ data, formatting, fields } = getMultiSeriesData({
|
|
40
|
+
data,
|
|
41
|
+
seriesOptions: multiSeries,
|
|
42
|
+
formatting,
|
|
43
|
+
fields,
|
|
44
|
+
}))
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const proccessedFormatting = handleFormattingTemplates(formatting)
|
|
48
|
+
|
|
49
|
+
let dataTable = getDataTableFormat(data, fields, multiSeries) //per the Google Charts docs, the DataTable object literal format is the most performant, so we'll turn it into that format before returning
|
|
50
|
+
|
|
51
|
+
// Stacked area charts will have holes / jagged lines if there are nulls in the middle of a column
|
|
52
|
+
// Presumably we'll also want to be able to have non-cumulative area charts at some point
|
|
53
|
+
if (chartWrapper.chartType == 'AreaChart') {
|
|
54
|
+
dataTable = makeDataCumulative(dataTable)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (chartWrapper.chartType === 'Table') {
|
|
58
|
+
return {
|
|
59
|
+
...theRest,
|
|
60
|
+
chartWrapper,
|
|
61
|
+
table: { ...table, ...getRactiveTableDataFormat(data, fields, formatting) },
|
|
62
|
+
formatting: proccessedFormatting,
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return {
|
|
66
|
+
...theRest,
|
|
67
|
+
chartWrapper: {
|
|
68
|
+
...chartWrapper,
|
|
69
|
+
dataTable,
|
|
70
|
+
},
|
|
71
|
+
formatting: proccessedFormatting,
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const loadOptionList = async(mysqlConnection, optionListQuery, optionList = []) => {
|
|
76
|
+
return [
|
|
77
|
+
...optionList,
|
|
78
|
+
// ...(optionListQuery && await query(mysqlConnection, optionListQuery)) || [],
|
|
79
|
+
...(optionListQuery && await db.query(mysqlConnection, optionListQuery)) || [],
|
|
80
|
+
|
|
81
|
+
]
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const loadOutputParameterValues = async(mysqlConnection, definitionList, selectionList = []) => {
|
|
85
|
+
let parameterValues = []
|
|
86
|
+
|
|
87
|
+
for (const definition of definitionList) {
|
|
88
|
+
const matchingParameterSelection = selectionList.find(param => param.name === definition.name)
|
|
89
|
+
|
|
90
|
+
let parameter = {
|
|
91
|
+
name: definition.name,
|
|
92
|
+
type: definition.type,
|
|
93
|
+
title: definition.title || definition.name,
|
|
94
|
+
default: definition.default,
|
|
95
|
+
value: (matchingParameterSelection && matchingParameterSelection.value) || definition.default,
|
|
96
|
+
queryValues: {},
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (definition.type === 'dateRange') {
|
|
100
|
+
parameter.dates = matchingParameterSelection?.dates || { to: null, from: null }
|
|
101
|
+
parameter.value = parameter.value || 'Last 30 Days'
|
|
102
|
+
if (parameter.value !== 'Custom') {
|
|
103
|
+
parameter.dates = datesFromRange(parameter.value)
|
|
104
|
+
}
|
|
105
|
+
parameter.queryValues = {
|
|
106
|
+
[`${parameter.name}_start_date`]: parameter.dates.from,
|
|
107
|
+
[`${parameter.name}_end_date`]: parameter.dates.to,
|
|
108
|
+
}
|
|
109
|
+
} else if (definition.type === 'selection') {
|
|
110
|
+
parameter.optionList = await loadOptionList(mysqlConnection, definition.optionListQuery, definition.optionList) // queries db
|
|
111
|
+
parameter.queryValues = {
|
|
112
|
+
[parameter.name]: parameter.value,
|
|
113
|
+
}
|
|
114
|
+
} else if (definition.type === 'date') {
|
|
115
|
+
parameter.value = parameter.value || formatDate(new Date(), 'yyyy-MM-dd')
|
|
116
|
+
|
|
117
|
+
parameter.queryValues = {
|
|
118
|
+
[parameter.name]: parameter.value,
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
parameterValues.push(parameter)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return parameterValues
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const parameterizeQuery = (query, parameterValues) => {
|
|
129
|
+
const queryParameterValues = parameterValues.reduce((acc, val) => {
|
|
130
|
+
return val.queryValues ? {
|
|
131
|
+
...acc,
|
|
132
|
+
...val.queryValues,
|
|
133
|
+
} : acc
|
|
134
|
+
}, {})
|
|
135
|
+
|
|
136
|
+
//match on a token that starts with ${ and ends with a closing }.
|
|
137
|
+
//Then include a capture group to get the inner part of that to use as a key
|
|
138
|
+
const regexp = /\${([^}]+)}/g
|
|
139
|
+
|
|
140
|
+
const queryParameterKeys = [ ...query.matchAll(regexp) ].map(([ fullToken, insideToken ]) => insideToken.trim())
|
|
141
|
+
//if they put a token in the query that isn't one of the defined parameters of the report, just return null?
|
|
142
|
+
const queryParameters = queryParameterKeys.map(key => queryParameterValues[key] || null)
|
|
143
|
+
|
|
144
|
+
return {
|
|
145
|
+
sql: query.replace(regexp, '?'),
|
|
146
|
+
values: queryParameters,
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
module.exports = {
|
|
151
|
+
formatReportMetadata: async(mysqlConnection, { report, chartsInReport }) => {
|
|
152
|
+
const parameterValues = await loadOutputParameterValues(mysqlConnection, report.parameters)
|
|
153
|
+
const parameterValuesForClient = parameterValues.map(param => {
|
|
154
|
+
const { queryValues, ...parameterValuesForClient } = param
|
|
155
|
+
return parameterValuesForClient
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
return {
|
|
159
|
+
dashboardReportId: report.dashboardReportId,
|
|
160
|
+
name: report.reportName,
|
|
161
|
+
title: report.reportTitle,
|
|
162
|
+
charts: chartsInReport,
|
|
163
|
+
parameterValues: parameterValuesForClient,
|
|
164
|
+
ownerId: report.ownerId,
|
|
165
|
+
}
|
|
166
|
+
},
|
|
167
|
+
formatChartDataForReport: async(mysqlConnection, { reportParameters, chart, reportChart, parameterSelectionList }) => {
|
|
168
|
+
try {
|
|
169
|
+
const parameterValues = await loadOutputParameterValues(mysqlConnection, reportParameters, parameterSelectionList)
|
|
170
|
+
return await loadChartData(mysqlConnection, {
|
|
171
|
+
...chart,
|
|
172
|
+
reportChartId: reportChart.reportChartId,
|
|
173
|
+
...reportChart.jsonOverride, // overwrites whole chart wrapper :\
|
|
174
|
+
query: parameterizeQuery(chart.query, parameterValues),
|
|
175
|
+
})
|
|
176
|
+
} catch (err) {
|
|
177
|
+
console.error(err)
|
|
178
|
+
return { isErrored: true, err }
|
|
179
|
+
}
|
|
180
|
+
},
|
|
181
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@isoftdata/utility-dashboard-backend",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "A utility for formatting chart and report data to be usable by the frontend dashboard component.",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"test": "echo \"Error: no test specified\" && exit 1"
|
|
8
|
+
},
|
|
9
|
+
"author": "Charles Kaup",
|
|
10
|
+
"license": "ISC",
|
|
11
|
+
"dependencies": {
|
|
12
|
+
"@isoftdata/utility-date-time": "^2.0.3",
|
|
13
|
+
"@isoftdata/utility-db": "^2.2.0",
|
|
14
|
+
"date-fns": "^2.23.0",
|
|
15
|
+
"klona": "^2.0.4",
|
|
16
|
+
"mysql": "^2.18.1",
|
|
17
|
+
"to-snake-case": "^1.0.0"
|
|
18
|
+
}
|
|
19
|
+
}
|
package/schema.md
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
## Reporting/Charting
|
|
2
|
+
The dashboard component has a set of three database tables where reports, charts, and their associations are defined: `dashboard_report`, `dashboard_chart`, and `dashboard_report_chart`. Reports are a collection of various charts. Reports have parameters and charts can use said parameters. Currently Charts are rendered using [Google Charts](https://developers.google.com/chart/).
|
|
3
|
+
|
|
4
|
+
### Defining a Report
|
|
5
|
+
Reports can be created either with the frontend dashboard component or manually in the database. The frontend component supports setting the report's name, the charts contained within it, who it is shared with, and the default value of any parameters. Currently it is not possible to add or remove parameters for reports within the frontend component, as charts' queries also have to support any additional parameters.
|
|
6
|
+
|
|
7
|
+
### Report Database Row - `dashboard_report`
|
|
8
|
+
Property | Type | Description
|
|
9
|
+
---- | ---- | -----------
|
|
10
|
+
dashboard_report_id | `Unsigned integer` | **Required** - Primary key. Auto incremented.
|
|
11
|
+
report_name | `String` | **Required** - a unique name for the report.
|
|
12
|
+
report_title | `String` | When rendered, this title will be displayed at the top of the card. If omitted, `name` will be used.
|
|
13
|
+
json | `JSON` | Should be parsed into a JS object. This field contains JSON for an array of parameter objects, the format of which is detailed below.
|
|
14
|
+
share_type | `Enum` | Controls access to reports within the dashboard. Possible values include: `everyone`, `group`, `store`, and `user`. Defaults to `everyone`.
|
|
15
|
+
share_id | `Unsigned integer` | Used in conjunction with `share_type` to control access to reports. When `share_type` is 'everyone', this field is null. Otherwise, it contains the id of the user, group, or store to share the report with. Defaults to `NULL`.
|
|
16
|
+
owner_id | `Unsigned integer` | The `user_account_id` of the user who created the report in the dashboard. This user will always have access to the given report, regardless of its sharing settings, and will be shown as the report owner on the configuration screen. Defaults to `NULL`, which designates it as a default report.
|
|
17
|
+
|
|
18
|
+
#### Parameter Object
|
|
19
|
+
Property | Type | Description
|
|
20
|
+
---- | ---- | -----------
|
|
21
|
+
name | `String` | **Required** - a unique name for the parameter. This name is also used to reference param in a query.
|
|
22
|
+
title | `String` | This is used to label the parameter for the user.
|
|
23
|
+
type | `String` | Can be one of `dateRange`, `date`, or `selection`. See the tables below for parameter-type-specific properties
|
|
24
|
+
|
|
25
|
+
##### `dateRange` Type Properties
|
|
26
|
+
> In the UI, the user will see a dropdown with the possible date range values(see `default` description below).
|
|
27
|
+
|
|
28
|
+
Property | Type | Description
|
|
29
|
+
---- | ---- | -----------
|
|
30
|
+
default | `String` | One of the following values: `'Today'`, `'Yesterday'`, `'Last 7 Days'`, `'Last 30 Days'`, `'Last 90 Days'`, `'Last 365 Days'`, `'This Week'`, `'This Month'`, `'This Quarter'`, `'This Year'`, `'Previous Week'`, `'Previous Month'`, `'Previous Quarter'`, or `'Previous Year'`. If the `default` property is omitted or [falsy](https://developer.mozilla.org/en-US/docs/Glossary/Falsy), `'Last 30 Days'` will be used.
|
|
31
|
+
|
|
32
|
+
##### `date` Type Properties
|
|
33
|
+
> In the UI, the user will see a dropdown with a date picker.
|
|
34
|
+
|
|
35
|
+
Property | Type | Description
|
|
36
|
+
---- | ---- | -----------
|
|
37
|
+
default | `String` | Can be an [ISO 8601](https://en.wikipedia.org/wiki/ISO_8601) date string(eg. `'2021-01-13'`). If the `default` property is omitted or [falsy](https://developer.mozilla.org/en-US/docs/Glossary/Falsy), the today's date will be used.
|
|
38
|
+
|
|
39
|
+
##### `selection` Type properties
|
|
40
|
+
> In the UI, the user will see a dropdown with the `optionList` values(if any) and the values returned from the `optionListQuery`(if any).
|
|
41
|
+
|
|
42
|
+
Property | Type | Description
|
|
43
|
+
---- | ---- | -----------
|
|
44
|
+
default | `String` | The `id` of the option you'd like to be selected by default
|
|
45
|
+
optionList | `Object` | Each object should have an `id`, and `name`. `id` is the value that will be given to you at query time and `name` is what will be displayed in the dropdown to the user.
|
|
46
|
+
optionListQuery | `String` | A query that selects 2 columns `id` and `name`. The results of this query will be merged with `optionList` and put in the dropdown for the user.
|
|
47
|
+
|
|
48
|
+
#### Example
|
|
49
|
+
'dashboard_report_id' | 'report_name' | 'report_title' | 'json' | 'share_type' | 'share_id' | 'owner_id'
|
|
50
|
+
-- | ---- | ---- | ------- | -- | -- | --
|
|
51
|
+
1 | report_overview | Overview | `{"parameters": [{"name": "date_range", "type": "dateRange", "title": "Date Range", "default": "This Year"}]}` | everyone | `NULL` | `NULL`
|
|
52
|
+
2 | shared_report | Shared Report | `{"parameters":[{"name":"date_range","type":"dateRange","title":"Date Range","default":"This Month"}]}` | user | 1 | 2
|
|
53
|
+
|
|
54
|
+
### Defining a Chart
|
|
55
|
+
Currently, charts cannot be defined within Pro web, and must be created in the database table `dashboard_chart`. Currently, 19 charts are defined, but some are set to not show as options in the user interface.
|
|
56
|
+
|
|
57
|
+
### Chart Database Row - `dashboard_chart`
|
|
58
|
+
Property | Type | Description
|
|
59
|
+
---- | ---- | -----------
|
|
60
|
+
chart_id | `Unsigned integer` | **Required** - primary key. Auto incremented.
|
|
61
|
+
chart_name | `String` | **Required** - a unique name for the chart
|
|
62
|
+
chart_title | `String` | When rendered, this title will be displayed at the top of the card. If omitted, `name` will be used.
|
|
63
|
+
supertype | `Enum` | **Required** - designates how the chart should be loaded. Options include `google`, `table`, and `embed`. Defaults to `google`.
|
|
64
|
+
json | `json` | Should be parsed to a JS object on the server. Contains several properties: `query` (string, **required**), [`chartWrapper`](https://developers.google.com/chart/interactive/docs/reference#chartwrapper-class) (object, **required** for Google charts), `multiSeries` (object, **required** for multi-series charts), `formatting` (object).
|
|
65
|
+
show_chart | `Bit` | **Required** - Should be cast as a boolean on the server. Whether or not to show a chart in the configuration UI. Defaults to `1`.
|
|
66
|
+
|
|
67
|
+
#### MultiSeries Object
|
|
68
|
+
Property | Type | Description
|
|
69
|
+
---- | ---- | -----------
|
|
70
|
+
groupXAxisBy | `String` | **Required** - The name of the query column that determines the X axis of the chart.
|
|
71
|
+
series | `String` | **Required** - The name of the query column that determines which series a query row corresponds to.
|
|
72
|
+
value | `String` | **Required** - The namem of the query column that contains the value for that data point.
|
|
73
|
+
tooltip | `String` | The name of the (optional) query column that contains a custom plaintext tooltip, which replaces the default Google Charts tooltip. Does not work for Tree Map type charts, which have special tooltip requirements.
|
|
74
|
+
|
|
75
|
+
#### Formatting Object
|
|
76
|
+
An object whose property names are the columns in the query to be formatted. Currently only currency formatting is supported.
|
|
77
|
+
|
|
78
|
+
Property | Type | Description
|
|
79
|
+
---- | ---- | ---------
|
|
80
|
+
type | `String` | The name of a [Google Charts Formatter](https://developers.google.com/chart/interactive/docs/reference?hl=en#formatters).
|
|
81
|
+
format | `String` | The name of a formatter template, defined in `formatter-templates.json`.
|
|
82
|
+
#### JSON Example
|
|
83
|
+
|
|
84
|
+
```json
|
|
85
|
+
{
|
|
86
|
+
"chartWrapper": {
|
|
87
|
+
"chartType": "AreaChart",
|
|
88
|
+
"options": {
|
|
89
|
+
"isStacked": true,
|
|
90
|
+
"width": 1000,
|
|
91
|
+
"height": 1000
|
|
92
|
+
}
|
|
93
|
+
},
|
|
94
|
+
"multiSeries": {
|
|
95
|
+
"groupXAxisBy": "Date",
|
|
96
|
+
"series": "Part Type",
|
|
97
|
+
"value": "Sales",
|
|
98
|
+
"tooltip": "Tooltip"
|
|
99
|
+
},
|
|
100
|
+
"formatting": {
|
|
101
|
+
"Sales": {
|
|
102
|
+
"type": "NumberFormat",
|
|
103
|
+
"format": "CURRENCY"
|
|
104
|
+
}
|
|
105
|
+
},
|
|
106
|
+
"query": "SELECT t.date AS `Date`,\r\n IFNULL (t.part_type, 'Misc. Lineitems') AS `Part Type`,\r\n ROUND(@running_total:=@running_total + t.sales, 2) AS `Sales`\r\nFROM\r\n(SELECT \r\n`wa_sale`.`document_date` AS `Date`,\r\nIF(wa_sale_line.part_type = '', 'Misc. Lineitems', wa_sale_line.part_type) AS `part_type`,\r\nFORMAT(SUM(wa_sale_line.quantity * `wa_sale_line`.`price`), 2) AS `sales`\r\nFROM \r\n`wa_sale_line`\r\nINNER JOIN `wa_sale` USING(`sale_id`)\r\nWHERE\r\n`wa_sale`.`status` = 'Invoice' AND wa_sale.document_date IS NOT NULL\r\n GROUP BY \r\n `wa_sale`.`document_date`, `wa_sale_line`.`part_type`) t\r\n JOIN (SELECT @running_total:=0) r\r\n ORDER BY t.Date;"
|
|
107
|
+
}
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
### Adding charts to reports
|
|
111
|
+
Adding charts to reports can be handled entirely within the dashboard component. Each row in the database table `dashboard_report_chart` defines the association of one chart to one report, the rank of the chart within the report, and any additional object properties to apply to the chart when displayed in that report.
|
|
112
|
+
|
|
113
|
+
### Report Chart Database Row - `dashboard_report_chart`
|
|
114
|
+
Property | Type | Description
|
|
115
|
+
---- | ---- | --------
|
|
116
|
+
report_chart_id | `Unsigned integer` | **Required** - Primary key. Auto incremented.
|
|
117
|
+
dashboard_report_id | `Unsigned integer` | **Required** - Foreign key constraint with `dashboard_report.dashboard_report_id`.
|
|
118
|
+
chart_id | `Unsigned integer` | **Required** - Foreign key constraint with `dashboard_chart.dashboad_chart_id`. `chart_id` and `dashboard_report_id` must be a unique pair.
|
|
119
|
+
rank | `Unsigned integer` | The order to display the chart in on the report. Defaults to 1.
|
|
120
|
+
json_override | `Text` | Parsed as JSON on the server. Contains any JSON properties to be applied to the chart for the given report, overriding any of the same property on the base chart. Primarily used for chartWarpper options. Default `NULL`.
|