terrier-engine 4.7.8 → 4.8.2

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.
@@ -62,7 +62,7 @@ export default class DiveEditor extends ContentPart<DiveEditorState> {
62
62
 
63
63
  this.queries = this.state.dive.query_data?.queries || []
64
64
  for (const query of this.queries) {
65
- this.addQuery(query)
65
+ this.addQueryTag(query)
66
66
  }
67
67
 
68
68
  this.listenMessage(QueryForm.settingsChangedKey, m => {
@@ -82,7 +82,20 @@ export default class DiveEditor extends ContentPart<DiveEditorState> {
82
82
  })
83
83
  }
84
84
 
85
+ /**
86
+ * Add a new query to the dive.
87
+ * @param query
88
+ */
85
89
  addQuery(query: Query) {
90
+ this.queries.push(query)
91
+ this.addQueryTag(query)
92
+ }
93
+
94
+ /**
95
+ * Adds a tab for an existing query.
96
+ * @param query
97
+ */
98
+ private addQueryTag(query: Query) {
86
99
  const state = {...this.state, query}
87
100
  this.tabs.upsertTab({key: query.id, title: query.name}, QueryEditor, state)
88
101
  }
@@ -11,7 +11,9 @@ import {Dropdown} from "../../terrier/dropdowns"
11
11
  import DiveEditor from "../dives/dive-editor"
12
12
  import Messages from "tuff-core/messages"
13
13
  import Arrays from "tuff-core/arrays"
14
- import {ColumnValidationError} from "./validation"
14
+ import Dom from "tuff-core/dom"
15
+ import Validation, {ColumnValidationError} from "./validation"
16
+ import Queries, {Query} from "./queries"
15
17
 
16
18
  const log = new Logger("Columns")
17
19
 
@@ -22,7 +24,7 @@ const log = new Logger("Columns")
22
24
  /**
23
25
  * Possible functions used to aggregate a column.
24
26
  */
25
- const AggFunctions = ['count', 'min', 'max'] as const
27
+ const AggFunctions = ['count', 'sum', 'min', 'max'] as const
26
28
 
27
29
  export type AggFunction = typeof AggFunctions[number]
28
30
 
@@ -40,7 +42,7 @@ export type Function = AggFunction | DateFunction
40
42
  * @param fun a function name
41
43
  * @return the type of function
42
44
  */
43
- function functionType(fun: Function): 'aggregate' | 'time' | undefined {
45
+ function functionType(fun: Function | undefined): 'aggregate' | 'time' | undefined {
44
46
  if (AggFunctions.includes(fun as AggFunction)) {
45
47
  return 'aggregate'
46
48
  }
@@ -105,6 +107,7 @@ function render(parent: PartTag, col: ColumnRef) {
105
107
 
106
108
  export type ColumnsEditorState = {
107
109
  schema: SchemaDef
110
+ query: Query
108
111
  tableView: TableView<TableRef>
109
112
  }
110
113
 
@@ -112,6 +115,7 @@ const saveKey = Messages.untypedKey()
112
115
  const addKey = Messages.untypedKey()
113
116
  const addSingleKey = Messages.typedKey<{ name: string }>()
114
117
  const removeKey = Messages.typedKey<{id: string}>()
118
+ const valueChangedKey = Messages.untypedKey()
115
119
 
116
120
  /**
117
121
  * A modal that lets the user edit the columns being referenced for a particular table.
@@ -172,33 +176,35 @@ export class ColumnsEditorModal extends ModalPart<ColumnsEditorState> {
172
176
  })
173
177
 
174
178
  this.onClick(addSingleKey, m => {
175
- const colDef = this.modelDef.columns[m.data.name]
176
- log.info(`Add column ${m.data.name}`)
177
- if (colDef) {
178
- this.addState({name: colDef.name})
179
- this.updateColumnEditors()
180
- this.dirty()
181
- }
182
- else {
183
- alert(`Unknown column name '${m.data.name}'`)
184
- }
179
+ this.addColumn(m.data.name)
185
180
  })
186
181
 
187
182
  this.onClick(addKey, m => {
188
- const onSelected = (columns: string[]) => {
189
- log.info(`Adding ${columns.length} columns`, columns)
190
- for (const col of columns) {
191
- const colDef = this.modelDef.columns[col]
192
- if (colDef) {
193
- this.addState({name: colDef.name})
194
- }
195
- }
196
- this.updateColumnEditors()
197
- }
198
- this.toggleDropdown(SelectColumnsDropdown, {modelDef: this.modelDef, callback: onSelected}, m.event.target)
183
+ this.toggleDropdown(SelectColumnsDropdown, {editor: this as ColumnsEditorModal}, m.event.target)
184
+ })
185
+
186
+ this.onChange(valueChangedKey, m => {
187
+ log.info(`Column value changed`, m)
188
+ this.validate().then()
199
189
  })
200
190
  }
201
191
 
192
+ addColumn(col: string) {
193
+ const colDef = this.modelDef.columns[col]
194
+ log.info(`Add column ${col}`, colDef)
195
+ if (colDef) {
196
+ this.addState({name: colDef.name})
197
+ this.updateColumnEditors()
198
+ this.dirty()
199
+ } else {
200
+ alert(`Unknown column name '${col}'`)
201
+ }
202
+ }
203
+
204
+ get currentColumnNames(): Set<string> {
205
+ return new Set(this.columnStates.map(s => s.name))
206
+ }
207
+
202
208
  renderContent(parent: PartTag): void {
203
209
  // fields
204
210
  parent.div('.tt-flex.tt-form.padded.gap.justify-end.align-center', row => {
@@ -222,7 +228,7 @@ export class ColumnsEditorModal extends ModalPart<ColumnsEditorState> {
222
228
  })
223
229
 
224
230
  // common column quick links
225
- const includedNames = new Set(this.columnStates.map(s => s.name))
231
+ const includedNames = this.currentColumnNames
226
232
  const commonCols = Object.values(this.modelDef.columns).filter(c => c.metadata?.visibility == 'common' && !includedNames.has(c.name))
227
233
  if (commonCols.length) {
228
234
  parent.h3('.centered.large-top-padding', h3 => {
@@ -260,10 +266,44 @@ export class ColumnsEditorModal extends ModalPart<ColumnsEditorState> {
260
266
  }
261
267
  }
262
268
 
263
- async save() {
264
- const columns = this.columnStates.map(state => {
269
+ serialize(): ColumnRef[] {
270
+ return this.columnStates.map(state => {
265
271
  return Objects.omit(state, 'schema', 'columnsEditor', 'id') as ColumnRef
266
272
  })
273
+ }
274
+
275
+ /**
276
+ * Performs client-side validation against the current values in the editors.
277
+ */
278
+ async validate() {
279
+ // serialize the columns and table settings
280
+ const columns = this.serialize()
281
+ const tableData = await this.tableFields.serialize()
282
+
283
+ // make a deep copy of the query and update this table's columns and settings
284
+ this.table._id = this.id // we need this to identify the table after the deep copy
285
+ const query = Objects.deepCopy(this.state.query)
286
+ Queries.eachTable(query, table => {
287
+ if (table._id == this.id) {
288
+ table.columns = columns
289
+ table.prefix = tableData.prefix
290
+ }
291
+ })
292
+
293
+ // validate the temporary query
294
+ log.info(`Validating temporary query with column changes`, query)
295
+ Validation.validateQuery(query)
296
+
297
+ // copy the column errors over
298
+ for (let i = 0; i < columns.length; i++) {
299
+ this.columnStates[i].errors = columns[i].errors
300
+ }
301
+
302
+ this.dirty()
303
+ }
304
+
305
+ async save() {
306
+ const columns = this.serialize()
267
307
  const tableData = await this.tableFields.serialize()
268
308
  this.state.tableView.updateColumns(columns, tableData.prefix)
269
309
  this.emitMessage(DiveEditor.diveChangedKey, {})
@@ -312,18 +352,26 @@ class ColumnEditor extends TerrierFormPart<ColumnState> {
312
352
  })
313
353
  parent.div('.alias', col => {
314
354
  this.textInput(col, "alias", {placeholder: "Alias"})
355
+ .emitChange(valueChangedKey)
315
356
  })
316
357
  parent.div('.function', col => {
317
358
  this.select(col, "function", this.functionOptions)
359
+ .emitChange(valueChangedKey)
318
360
  })
319
361
  parent.div('.group-by', col => {
320
362
  this.checkbox(col, "grouped")
363
+ .emitChange(valueChangedKey)
321
364
  })
322
365
  parent.div('.actions', actions => {
323
366
  actions.a(a => {
324
367
  a.i('.glyp-close')
325
368
  }).emitClick(removeKey, {id: this.state.id})
326
369
  })
370
+ if (this.state.errors?.length) {
371
+ for (const error of this.state.errors) {
372
+ parent.div('.error.tt-bubble.alert').text(error.message)
373
+ }
374
+ }
327
375
  }
328
376
 
329
377
  }
@@ -333,19 +381,21 @@ class ColumnEditor extends TerrierFormPart<ColumnState> {
333
381
  // Add Column Dropdown
334
382
  ////////////////////////////////////////////////////////////////////////////////
335
383
 
336
- const checkAllKey = Messages.untypedKey()
337
- const applySelectionKey = Messages.untypedKey()
338
- const checkChangedKey = Messages.typedKey<{column: string}>()
339
-
340
- type SelectColumnsCallback = (columns: string[]) => any
384
+ type SelectableColumn = ColumnDef & {
385
+ included: boolean
386
+ sortOrder: string
387
+ }
341
388
 
342
389
  /**
343
390
  * Shows a dropdown that allows the user to select one or more columns from the given model.
344
391
  */
345
- class SelectColumnsDropdown extends Dropdown<{modelDef: ModelDef, callback: SelectColumnsCallback}> {
392
+ class SelectColumnsDropdown extends Dropdown<{editor: ColumnsEditorModal}> {
346
393
 
394
+ addAllKey = Messages.untypedKey()
395
+ addKey = Messages.typedKey<{ name: string }>()
347
396
  checked: Set<string> = new Set()
348
- columns!: string[]
397
+ columns!: SelectableColumn[]
398
+ modelDef!: ModelDef
349
399
 
350
400
  get autoClose(): boolean {
351
401
  return true
@@ -354,63 +404,61 @@ class SelectColumnsDropdown extends Dropdown<{modelDef: ModelDef, callback: Sele
354
404
  async init() {
355
405
  await super.init()
356
406
 
357
- this.columns = Object.keys(this.state.modelDef.columns).sort()
407
+ this.modelDef = this.state.editor.modelDef
358
408
 
359
- this.onClick(checkAllKey, _ => {
360
- // toggle them all being checked
361
- log.info(`${this.checked.size} of ${this.columns.length} checked`)
362
- if (this.checked.size > this.columns.length / 2) {
363
- this.checked = new Set()
364
- }
365
- else {
366
- for (const c of this.columns) {
367
- this.checked.add(c)
368
- }
369
- }
370
- this.dirty()
409
+ // sort the columns by whether they're in the editor already
410
+ const includedNames = this.state.editor.currentColumnNames
411
+ this.columns = Object.values(this.modelDef.columns).map(col => {
412
+ const included = includedNames.has(col.name)
413
+ const sortOrder = `${included ? '1' : '0'}${col.name}`
414
+ return {included, sortOrder,...col}
371
415
  })
416
+ this.columns = Arrays.sortBy(this.columns, 'sortOrder')
372
417
 
373
- this.onClick(applySelectionKey, _ => {
374
- this.state.callback(Array.from(this.checked))
375
- this.clear()
418
+ this.onClick(this.addKey, m => {
419
+ log.info(`Adding column ${m.data.name}`)
420
+ this.state.editor.addColumn(m.data.name)
421
+
422
+ // remove the link
423
+ const link = Dom.queryAncestorClass(m.event.target as HTMLInputElement, 'column')
424
+ link?.remove()
376
425
  })
377
426
 
378
- this.onChange(checkChangedKey, m => {
379
- const col = m.data.column
380
- const checked = (m.event.target as HTMLInputElement).checked
381
- log.info(`Column '${col}' checkbox changed to`, checked)
382
- if (checked) {
383
- this.checked.add(col)
384
- }
385
- else {
386
- this.checked.delete(col)
427
+ this.onClick(this.addAllKey, _ => {
428
+ // add all of the unincluded columns and close the dropdown
429
+ for (const col of this.columns) {
430
+ if (!col.included) {
431
+ this.state.editor.addColumn(col.name)
432
+ }
387
433
  }
434
+ this.clear()
388
435
  })
389
436
  }
390
437
 
391
438
 
392
439
  get parentClasses(): Array<string> {
393
- return super.parentClasses.concat(['dd-select-columns-dropdown']);
440
+ return super.parentClasses.concat(['tt-actions-dropdown']);
394
441
  }
395
442
 
396
443
  renderContent(parent: PartTag) {
397
- parent.a('.header', a => {
398
- a.i('.glyp-check_all')
399
- a.span({text: "Toggle All"})
400
- }).emitClick(checkAllKey)
401
-
402
444
  for (const col of this.columns) {
403
- parent.label(label => {
404
- label.input({type: 'checkbox', checked: this.checked.has(col)})
405
- .emitChange(checkChangedKey, {column: col})
406
- label.div().text(col)
407
- })
445
+ parent.a('.column', a => {
446
+ a.div('.name').text(col.name)
447
+ a.div('.right-title').text(col.type)
448
+ if (col.included) {
449
+ // style the columns that are already included differently
450
+ a.class('inactive')
451
+ }
452
+ if (col.metadata?.description?.length) {
453
+ a.div('.subtitle').text(col.metadata.description)
454
+ }
455
+ }).emitClick(this.addKey, {name: col.name})
408
456
  }
409
457
 
410
- parent.a('.add', a => {
411
- a.i('.glyp-checkmark')
412
- a.span({text: "Add Selected"})
413
- }).emitClick(applySelectionKey)
458
+ parent.a('.primary', a => {
459
+ a.i('.glyp-check_all')
460
+ a.span({text: "Add All"})
461
+ }).emitClick(this.addAllKey)
414
462
  }
415
463
 
416
464
  }
@@ -618,7 +618,7 @@ class AddFilterDropdown extends Dropdown<{modelDef: ModelDef, callback: AddFilte
618
618
  }
619
619
 
620
620
  get parentClasses(): Array<string> {
621
- return super.parentClasses.concat(['dd-select-columns-dropdown', 'tt-actions-dropdown']);
621
+ return super.parentClasses.concat(['tt-actions-dropdown'])
622
622
  }
623
623
 
624
624
  renderContent(parent: PartTag) {
@@ -32,6 +32,28 @@ export type Query = {
32
32
  // Utilities
33
33
  ////////////////////////////////////////////////////////////////////////////////
34
34
 
35
+ type TableFunction = (table: TableRef) => any
36
+
37
+ function eachChildTable(table: TableRef, fn: TableFunction) {
38
+ if (!table.joins) {
39
+ return
40
+ }
41
+ for (const joinedTable of Object.values(table.joins)) {
42
+ fn(joinedTable)
43
+ eachChildTable(joinedTable, fn)
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Recursively iterates through each table reference in the query and evaluates the function.
49
+ * @param query
50
+ * @param fn
51
+ */
52
+ function eachTable(query: Query, fn: TableFunction) {
53
+ fn(query.from)
54
+ eachChildTable(query.from, fn)
55
+ }
56
+
35
57
  type ColumnFunction = (table: TableRef, col: ColumnRef) => any
36
58
 
37
59
  function eachColumnForTable(table: TableRef, fn: ColumnFunction) {
@@ -50,6 +72,7 @@ function eachColumnForTable(table: TableRef, fn: ColumnFunction) {
50
72
  /**
51
73
  * Recursively iterates over all columns in a query.
52
74
  * @param query
75
+ * @param fn a function to evaluate for each column in the query
53
76
  */
54
77
  function eachColumn(query: Query, fn: ColumnFunction) {
55
78
  eachColumnForTable(query.from, fn)
@@ -288,6 +311,7 @@ export class QueryModelPicker extends TerrierPart<QueryModelPickerState> {
288
311
 
289
312
  const Queries = {
290
313
  eachColumn,
314
+ eachTable,
291
315
  validate,
292
316
  preview,
293
317
  renderPreview
@@ -120,7 +120,10 @@ class PreviewPart extends ContentPart<SubEditorState> {
120
120
 
121
121
  renderContent(parent: PartTag) {
122
122
  if (this.result) {
123
- if (this.result.columns?.length) {
123
+ if (this.result.status == 'error') {
124
+ parent.div('.tt-bubble.alert').text(this.result.message)
125
+ }
126
+ else if (this.result.columns?.length) {
124
127
  parent.div('.table-container', col => {
125
128
  Queries.renderPreview(col, this.result!)
126
129
  })
@@ -12,6 +12,7 @@ import DiveEditor from "../dives/dive-editor"
12
12
  import Messages from "tuff-core/messages"
13
13
  import Arrays from "tuff-core/arrays"
14
14
  import QueryEditor from "./query-editor"
15
+ import {Query} from "./queries";
15
16
 
16
17
  const log = new Logger("Tables")
17
18
 
@@ -25,6 +26,7 @@ export type TableRef = {
25
26
  columns?: ColumnRef[]
26
27
  joins?: Record<string, JoinedTableRef>
27
28
  filters?: Filter[]
29
+ _id?: string // ephemeral
28
30
  }
29
31
 
30
32
  export type JoinedTableRef = TableRef & {
@@ -73,6 +75,7 @@ function computeFilterInputs(schema: SchemaDef, table: TableRef, filters: Record
73
75
  export class TableView<T extends TableRef> extends ContentPart<{ schema: SchemaDef, queryEditor: QueryEditor, table: T }> {
74
76
 
75
77
  schema!: SchemaDef
78
+ query!: Query
76
79
  table!: T
77
80
  tableName!: string
78
81
  displayName!: string
@@ -87,6 +90,7 @@ export class TableView<T extends TableRef> extends ContentPart<{ schema: SchemaD
87
90
 
88
91
  async init() {
89
92
  this.schema = this.state.schema
93
+ this.query = this.state.queryEditor.state.query
90
94
  this.table = this.state.table
91
95
  this.modelDef = this.schema.models[this.table.model]
92
96
  this.tableName = inflection.titleize(inflection.tableize(this.table.model))
@@ -95,7 +99,7 @@ export class TableView<T extends TableRef> extends ContentPart<{ schema: SchemaD
95
99
 
96
100
  this.onClick(this.editColumnsKey, _ => {
97
101
  log.info(`Edit ${this.displayName} Columns`)
98
- this.app.showModal(ColumnsEditorModal, {schema: this.schema, tableView: this as TableView<TableRef>})
102
+ this.app.showModal(ColumnsEditorModal, {schema: this.schema, query: this.query, tableView: this as TableView<TableRef>})
99
103
  })
100
104
 
101
105
  this.onClick(this.editFiltersKey, _ => {
@@ -1,5 +1,6 @@
1
1
  import Queries, {Query} from "./queries"
2
2
  import Columns, {ColumnRef} from "./columns"
3
+ import {TableRef} from "./tables"
3
4
 
4
5
  export type ColumnValidationError = {
5
6
  message: string
@@ -32,6 +33,7 @@ function validateQuery(query: Query): QueryClientValidation {
32
33
  const usedNames: Set<string> = new Set<string>()
33
34
 
34
35
  let isGrouped = false
36
+ const groupedTables: Set<TableRef> = new Set()
35
37
  Queries.eachColumn(query, (table, col) => {
36
38
  // clear the errors
37
39
  col.errors = undefined
@@ -39,6 +41,10 @@ function validateQuery(query: Query): QueryClientValidation {
39
41
  // determine if there's a _group by_ in the query
40
42
  if (col.grouped) {
41
43
  isGrouped = true
44
+ if (col.name == 'id') {
45
+ // ungrouped columns on this table are okay
46
+ groupedTables.add(table)
47
+ }
42
48
  }
43
49
 
44
50
  // each select name should only be used once
@@ -50,10 +56,10 @@ function validateQuery(query: Query): QueryClientValidation {
50
56
  })
51
57
 
52
58
  // if the query is grouped, ensure that all other column refs
53
- // are either grouped or have an aggregate function
59
+ // are either grouped, have an aggregate function, or are on a grouped table
54
60
  if (isGrouped) {
55
- Queries.eachColumn(query, (_, col) => {
56
- if (!col.grouped && !col.function) {
61
+ Queries.eachColumn(query, (table, col) => {
62
+ if (!col.grouped && Columns.functionType(col.function) != 'aggregate' && !groupedTables.has(table)) {
57
63
  addColumnError(col, `<strong>${col.name}</strong> must be grouped or have an aggregate function`)
58
64
  }
59
65
  })
package/package.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "files": [
5
5
  "*"
6
6
  ],
7
- "version": "4.7.8",
7
+ "version": "4.8.2",
8
8
  "repository": {
9
9
  "type": "git",
10
10
  "url": "https://github.com/Terrier-Tech/terrier-engine"