terrier-engine 4.46.0 → 4.50.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.
@@ -1,14 +1,23 @@
1
- import {PartTag} from "tuff-core/parts"
2
- import DiveEditor, {DiveEditorState} from "./dive-editor"
1
+ import { PartTag } from "tuff-core/parts"
2
+ import { DiveEditorState } from "./dive-editor"
3
3
  import TerrierPart from "../../terrier/parts/terrier-part"
4
- import {RegularSchedule, RegularScheduleFields} from "../../terrier/schedules"
5
- import {Logger} from "tuff-core/logging"
6
- import {EmailListForm} from "../../terrier/emails"
7
- import {DdDiveRun} from "../gen/models"
4
+ import Schedules, { RegularScheduleFields } from "../../terrier/schedules"
5
+ import { Logger } from "tuff-core/logging"
6
+ import { EmailListForm } from "../../terrier/emails"
7
+ import {
8
+ DdDive,
9
+ DdDiveDistribution,
10
+ DdDiveRun,
11
+ UnpersistedDdDiveDistribution
12
+ } from "../gen/models";
8
13
  import Db from "../dd-db"
9
14
  import dayjs from "dayjs"
10
15
  import Dates from "../queries/dates"
11
16
  import DiveRuns from "./dive-runs"
17
+ import { ModalPart } from "../../terrier/modals"
18
+ import { TerrierFormFields } from "../../terrier/forms"
19
+ import Messages from "tuff-core/messages"
20
+ import Fragments from "../../terrier/fragments"
12
21
 
13
22
  const log = new Logger("Dive Delivery")
14
23
 
@@ -17,43 +26,71 @@ const log = new Logger("Dive Delivery")
17
26
  // Delivery Form
18
27
  ////////////////////////////////////////////////////////////////////////////////
19
28
 
20
- export type DiveDeliverySettings = {
21
- delivery_schedule: RegularSchedule
22
- delivery_recipients: string[]
23
- }
24
-
25
- export class DiveDeliveryForm extends TerrierPart<DiveEditorState> {
29
+ export class DiveDeliveryPanel extends TerrierPart<DiveEditorState> {
26
30
 
27
- scheduleFields!: RegularScheduleFields
28
- recipientsForm!: EmailListForm
31
+ distributions!: DdDiveDistribution[]
29
32
  deliveryList!: DiveDeliveryList
30
33
 
34
+ newKey = Messages.untypedKey()
35
+ editKey = Messages.typedKey<{id: string}>()
36
+ static reloadKey = Messages.untypedKey()
37
+
31
38
  async init() {
32
- const schedule = this.state.dive.delivery_schedule || {schedule_type: 'none'}
33
- this.scheduleFields = new RegularScheduleFields(this, schedule)
34
- this.recipientsForm = this.makePart(EmailListForm, {emails: this.state.dive.delivery_recipients || []})
35
39
  this.deliveryList = this.makePart(DiveDeliveryList, this.state)
36
40
 
37
- this.listen('datachanged', this.scheduleFields.dataChangedKey, m => {
38
- log.info(`Schedule form data changed`, m.data)
39
- this.emitMessage(DiveEditor.diveChangedKey, {})
41
+ this.listenMessage(DiveDeliveryPanel.reloadKey, _ => {
42
+ log.info("Reloading dive delivery panel")
43
+ this.reload()
44
+ }, {attach: 'passive'})
45
+
46
+ this.onClick(this.newKey, _ => {
47
+ const dist: UnpersistedDdDiveDistribution = {
48
+ dd_dive_id: this.state.dive.id,
49
+ recipients: [],
50
+ schedule: {
51
+ schedule_type: 'daily',
52
+ hour_of_day: "0"
53
+ }
54
+ }
55
+ this.app.showModal(DiveDistributionModal, { ...this.state, distribution: dist })
40
56
  })
41
- this.listenMessage(this.recipientsForm.changedKey, m => {
42
- log.info(`Recipients form data changed`, m.data)
43
- this.emitMessage(DiveEditor.diveChangedKey, {})
57
+
58
+ this.onClick(this.editKey, m => {
59
+ const id = m.data.id
60
+ log.info(`Editing distribution ${id}`)
61
+ const dist = this.distributions.filter(d => d.id == id)[0]
62
+ if (dist) {
63
+ this.app.showModal(DiveDistributionModal, { ...this.state, distribution: dist })
64
+ }
65
+ else {
66
+ log.warn(`Could not find distribution ${id}`)
67
+ }
44
68
  })
69
+
70
+ await this.reload()
45
71
  }
46
72
 
73
+ async reload() {
74
+ this.distributions = await getDistributions(this.state.dive)
75
+ this.dirty()
76
+ }
47
77
 
48
78
  get parentClasses(): Array<string> {
49
79
  return ['tt-flex', 'column', 'gap', 'dd-dive-tool', 'tt-typography']
50
80
  }
51
81
 
52
82
  render(parent: PartTag): any {
53
- parent.h3(".glyp-setup").text("Schedule")
54
- this.scheduleFields.render(parent)
55
- parent.h3(".glyp-users").text("Recipients")
56
- parent.part(this.recipientsForm)
83
+ // distributions
84
+ parent.h3(".glyp-setup").text("Distributions")
85
+ parent.div('.distributions.tt-flex.column.gap', distContainer => {
86
+ for (const dist of this.distributions) {
87
+ this.renderDistribution(distContainer, dist)
88
+ }
89
+ })
90
+ Fragments.button(parent, this.theme, "New", "glyp-plus_outline", "secondary")
91
+ .emitClick(this.newKey)
92
+
93
+ // deliveries
57
94
  parent.div('.deliveries', deliveriesContainer => {
58
95
  // it looks better without the gap between the header and list
59
96
  deliveriesContainer.h3(".glyp-inbox").text("Deliveries")
@@ -61,21 +98,26 @@ export class DiveDeliveryForm extends TerrierPart<DiveEditorState> {
61
98
  })
62
99
  }
63
100
 
64
- /**
65
- * Serializes just the fields needed for the delivery settings.
66
- */
67
- async serialize(): Promise<DiveDeliverySettings> {
68
- if (!this.isAttached) {
69
- // it was never actually rendered
70
- log.info("Skipping delivery setting serialization since it was never rendered", this)
71
- const delivery_schedule = this.state.dive.delivery_schedule || {schedule_type: 'none'}
72
- const delivery_recipients = this.state.dive.delivery_recipients || []
73
- return {delivery_schedule, delivery_recipients}
74
- }
75
- const delivery_schedule = await this.scheduleFields.serializeConcrete()
76
- log.info(`Serialized ${delivery_schedule.schedule_type} delivery schedule`, delivery_schedule)
77
- const delivery_recipients = this.recipientsForm.state.emails
78
- return {delivery_schedule, delivery_recipients}
101
+ renderDistribution(parent: PartTag, dist: DdDiveDistribution) {
102
+ parent.a('.distribution', view => {
103
+ // schedule
104
+ view.div(".schedule", scheduleView => {
105
+ const scheduleDesc = Schedules.describeRegular(dist.schedule)
106
+ scheduleView.text(scheduleDesc)
107
+ })
108
+
109
+ // recipients
110
+ view.div(".recipients", recipientsList => {
111
+ if (dist.recipients?.length) {
112
+ dist.recipients.forEach((recipient) => {
113
+ recipientsList.div(".recipient.glyp-users.with-icon").text(recipient)
114
+ })
115
+ } else {
116
+ // no recipients
117
+ recipientsList.div().text("No Recipients")
118
+ }
119
+ })
120
+ }).emitClick(this.editKey, {id: dist.id})
79
121
  }
80
122
 
81
123
  }
@@ -132,3 +174,136 @@ class DiveDeliveryList extends TerrierPart<DiveEditorState> {
132
174
 
133
175
  }
134
176
 
177
+
178
+ ////////////////////////////////////////////////////////////////////////////////
179
+ // Distribution Persistence
180
+ ////////////////////////////////////////////////////////////////////////////////
181
+
182
+
183
+ /**
184
+ * Get all distributions for the given dive.
185
+ * @param dive
186
+ */
187
+ async function getDistributions(dive: DdDive): Promise<DdDiveDistribution[]> {
188
+ return await Db().query('dd_dive_distribution')
189
+ .where({ dd_dive_id: dive.id, _state: 0 })
190
+ .orderBy("created_at ASC")
191
+ .exec()
192
+ }
193
+
194
+ /**
195
+ * Soft delete a distribution.
196
+ * @param dist
197
+ */
198
+ async function softDeleteDistribution(dist: DdDiveDistribution) {
199
+ dist._state = 2
200
+ return await Db().update('dd_dive_distribution', dist)
201
+ }
202
+
203
+
204
+ ////////////////////////////////////////////////////////////////////////////////
205
+ // Distribution Modal
206
+ ////////////////////////////////////////////////////////////////////////////////
207
+
208
+ export type DiveDistributionEditorState = {
209
+ dive: DdDive
210
+ distribution: UnpersistedDdDiveDistribution
211
+
212
+ }
213
+
214
+ class DiveDistributionModal extends ModalPart<DiveDistributionEditorState> {
215
+
216
+ fields!: TerrierFormFields<UnpersistedDdDiveDistribution>
217
+ dist!: UnpersistedDdDiveDistribution
218
+
219
+ scheduleFields!: RegularScheduleFields
220
+ recipientsForm!: EmailListForm
221
+
222
+ saveKey = Messages.untypedKey()
223
+ deleteKey = Messages.untypedKey()
224
+
225
+ async init() {
226
+ this.dist = this.state.distribution
227
+
228
+ this.setIcon('glyp-email')
229
+
230
+ this.scheduleFields = new RegularScheduleFields(this, this.dist.schedule)
231
+ this.scheduleFields.showNoneOption = false
232
+ this.recipientsForm = this.makePart(EmailListForm, { emails: this.dist.recipients || [] })
233
+
234
+ // save
235
+ this.addAction({
236
+ title: "Save",
237
+ icon: "glyp-checkmark",
238
+ click: { key: this.saveKey }
239
+ })
240
+ this.onClick(this.saveKey, _ => {
241
+ log.debug("Saving distribution", this.dist)
242
+ this.save()
243
+ })
244
+
245
+ // delete
246
+ if (this.dist.id?.length) {
247
+ this.setTitle("Edit Distribution")
248
+ this.addAction({
249
+ title: "Delete",
250
+ icon: "glyp-delete",
251
+ classes: ['alert'],
252
+ click: { key: this.deleteKey }
253
+ }, 'secondary')
254
+ }
255
+ else {
256
+ this.setTitle("New Distribution")
257
+ }
258
+ this.onClick(this.deleteKey, async _ => {
259
+ if (confirm("Are you sure you want to delete this distribution?")) {
260
+ log.info("Deleting distribution", this.dist)
261
+ await softDeleteDistribution(this.dist as DdDiveDistribution)
262
+ this.emitMessage(DiveDeliveryPanel.reloadKey, {})
263
+ this.successToast("Successfully Deleted Distribution")
264
+ this.pop()
265
+ }
266
+ })
267
+ }
268
+
269
+
270
+ renderContent(parent: PartTag): void {
271
+ parent.div(".tt-flex.padded.large-gap", row => {
272
+ row.div('.stretch.tt-flex.column.gap', col => {
273
+ col.h3(".glyp-setup.text-center").text("Schedule")
274
+ this.scheduleFields.render(col)
275
+ })
276
+ row.div('.stretch.tt-flex.column.gap', col => {
277
+ col.h3(".glyp-users.text-center").text("Recipients")
278
+ col.part(this.recipientsForm)
279
+ })
280
+ })
281
+ }
282
+
283
+ async save() {
284
+ this.dist.recipients = this.recipientsForm.state.emails
285
+ if (!this.dist.recipients?.length) {
286
+ this.alertToast("You must set at least one recipient")
287
+ return
288
+ }
289
+
290
+ this.dist.schedule = await this.scheduleFields.serializeConcrete()
291
+
292
+ log.info("Saving distribution", this.dist)
293
+
294
+ const res = await Db().upsert('dd_dive_distribution', this.dist)
295
+ if (res.status == 'success') {
296
+ this.state.distribution = res.record
297
+ this.emitMessage(DiveDeliveryPanel.reloadKey, {})
298
+ this.successToast("Successfully Saved Distribution")
299
+ return this.pop()
300
+ }
301
+
302
+ // errors
303
+ log.warn("Error saving distribution", res)
304
+ this.alertToast(res.message)
305
+
306
+ }
307
+
308
+ }
309
+
@@ -18,7 +18,7 @@ import Messages from "tuff-core/messages"
18
18
  import Arrays from "tuff-core/arrays"
19
19
  import {FormFields} from "tuff-core/forms"
20
20
  import Fragments from "../../terrier/fragments"
21
- import {DiveDeliveryForm} from "./dive-delivery"
21
+ import {DiveDeliveryPanel} from "./dive-delivery"
22
22
  import DivePlotList from "../plots/dive-plot-list"
23
23
  import {DivePage} from "./dive-page"
24
24
 
@@ -40,7 +40,7 @@ export default class DiveEditor extends ContentPart<DiveEditorState> {
40
40
  queryTabs!: TabContainerPart
41
41
  settingsTabs!: TabContainerPart
42
42
 
43
- deliveryForm!: DiveDeliveryForm
43
+ deliveryPanel!: DiveDeliveryPanel
44
44
 
45
45
  plotList!: DivePlotList
46
46
 
@@ -119,17 +119,17 @@ export default class DiveEditor extends ContentPart<DiveEditorState> {
119
119
  })
120
120
  })
121
121
 
122
+ this.deliveryPanel = this.settingsTabs.upsertTab({
123
+ key: 'delivery',
124
+ title: "Delivery",
125
+ icon: "glyp-email"
126
+ }, DiveDeliveryPanel, this.state)
127
+
122
128
  this.plotList = this.settingsTabs.upsertTab({
123
129
  key: 'plots',
124
130
  title: "Plots",
125
131
  icon: "glyp-differential"
126
132
  }, DivePlotList, this.state)
127
-
128
- this.deliveryForm = this.settingsTabs.upsertTab({
129
- key: 'delivery',
130
- title: "Delivery",
131
- icon: "glyp-email"
132
- }, DiveDeliveryForm, this.state)
133
133
  }
134
134
 
135
135
  /**
@@ -176,12 +176,8 @@ export default class DiveEditor extends ContentPart<DiveEditorState> {
176
176
  async serialize(): Promise<DdDive> {
177
177
  const queries = this.queries
178
178
 
179
- const deliverySettings = await this.deliveryForm.serialize()
180
-
181
179
  return {
182
180
  ...this.state.dive,
183
- delivery_schedule: deliverySettings.delivery_schedule,
184
- delivery_recipients: deliverySettings.delivery_recipients,
185
181
  query_data: {queries}
186
182
  }
187
183
  }
@@ -235,7 +231,7 @@ export class DiveEditorPage extends DivePage<{id: string}> {
235
231
 
236
232
  this.addAction({
237
233
  title: 'Sync to Terrier',
238
- icon: 'glyp-terrier',
234
+ icon: 'glyp-terrier_sync',
239
235
  classes: ['terrier-record-sync'],
240
236
  data: { id: this.state.id, table: 'dd_dive', title: dive.name, direction: 'up' }
241
237
  }, 'tertiary')
@@ -220,7 +220,7 @@ export class DiveListPage extends DivePage<{}> {
220
220
 
221
221
  this.addAction({
222
222
  title: 'Sync from Terrier',
223
- icon: 'glyp-terrier',
223
+ icon: 'glyp-terrier_sync',
224
224
  classes: ['terrier-record-sync'],
225
225
  data: { table: 'dd_dive', direction: 'down' }
226
226
  }, 'tertiary')
@@ -1,6 +1,7 @@
1
1
  import TerrierPart from "../../terrier/parts/terrier-part"
2
2
  import {Logger} from "tuff-core/logging"
3
3
  import {PartTag} from "tuff-core/parts"
4
+ import { SheetInput } from "../../terrier/sheets"
4
5
  import {DdDive, DdDiveEnumFields, UnpersistedDdDive} from "../gen/models"
5
6
  import * as inflection from "inflection"
6
7
  import {SchemaDef} from "../../terrier/schema"
@@ -111,6 +112,7 @@ export class DiveSettingsModal extends ModalPart<DiveSettingsState> {
111
112
  modelPicker!: QueryModelPicker
112
113
  saveKey = Messages.untypedKey()
113
114
  deleteKey = Messages.untypedKey()
115
+ duplicateKey = Messages.untypedKey()
114
116
  isNew = true
115
117
 
116
118
  async init() {
@@ -135,12 +137,20 @@ export class DiveSettingsModal extends ModalPart<DiveSettingsState> {
135
137
  click: {key: this.saveKey}
136
138
  })
137
139
 
138
- if (!this.isNew && Dives.canDelete(this.state.dive, this.state.session)) {
140
+ if (!this.isNew) {
141
+ if (Dives.canDelete(this.state.dive, this.state.session)) {
142
+ this.addAction({
143
+ title: 'Delete',
144
+ icon: 'glyp-delete',
145
+ classes: ['alert'],
146
+ click: {key: this.deleteKey}
147
+ }, 'secondary')
148
+ }
149
+
139
150
  this.addAction({
140
- title: 'Delete',
141
- icon: 'glyp-delete',
142
- classes: ['alert'],
143
- click: {key: this.deleteKey}
151
+ title: 'Duplicate',
152
+ icon: 'glyp-duplicate',
153
+ click: {key: this.duplicateKey}
144
154
  }, 'secondary')
145
155
  }
146
156
 
@@ -153,6 +163,23 @@ export class DiveSettingsModal extends ModalPart<DiveSettingsState> {
153
163
  this.delete()
154
164
  })
155
165
  })
166
+
167
+ this.onClick(this.duplicateKey, async _ => {
168
+ const nameInput: SheetInput = {
169
+ type: 'text',
170
+ key: 'name',
171
+ value: this.state.dive.name + ' (Copy)',
172
+ label: "New Dive Name"
173
+ }
174
+ this.app.confirm({
175
+ title: 'Duplicate this dive?',
176
+ body: "Are you sure you want to duplicate this dive?",
177
+ icon: 'glyp-duplicate',
178
+ inputs: [nameInput]
179
+ }, () => {
180
+ this.duplicate(nameInput.value)
181
+ })
182
+ })
156
183
  }
157
184
 
158
185
  renderContent(parent: PartTag): void {
@@ -223,5 +250,18 @@ export class DiveSettingsModal extends ModalPart<DiveSettingsState> {
223
250
  }
224
251
  }
225
252
 
253
+ async duplicate(name: string) {
254
+ const dive = {...this.state.dive, name}
255
+ delete dive.id
256
+ const res = await Db().upsert('dd_dive', dive)
257
+ if (res.status == 'success') {
258
+ this.pop()
259
+ this.successToast(`Duplicated dive ${dive.name}`)
260
+ Nav.visit(routes.list.path({}))
261
+ } else {
262
+ this.alertToast(`Error duplicating dive: ${res.message}`)
263
+ }
264
+ }
265
+
226
266
  }
227
267
 
@@ -1,4 +1,4 @@
1
- // This file was automatically generated on 2024-09-06 11:24:15 -0500, DO NOT EDIT IT MANUALLY!
1
+ // This file was automatically generated on 2025-04-25 14:11:58 -0500, DO NOT EDIT IT MANUALLY!
2
2
 
3
3
  import { Query } from "../queries/queries"
4
4
 
@@ -35,6 +35,7 @@ export type DdDive = {
35
35
  sort_order?: number
36
36
  query_data?: { queries: Query[] }
37
37
  dive_types: string[]
38
+ delivery_mode?: string
38
39
  delivery_recipients?: string[]
39
40
  delivery_schedule?: RegularSchedule
40
41
  created_by?: DdUser
@@ -42,6 +43,7 @@ export type DdDive = {
42
43
  dd_dive_group?: DdDiveGroup
43
44
  dd_dive_runs?: DdDiveRun[]
44
45
  owner?: DdUser
46
+ dd_dive_distributions?: DdDiveDistribution[]
45
47
  }
46
48
 
47
49
  export type UnpersistedDdDive = {
@@ -63,6 +65,7 @@ export type UnpersistedDdDive = {
63
65
  sort_order?: number
64
66
  query_data?: { queries: Query[] }
65
67
  dive_types: string[]
68
+ delivery_mode?: string
66
69
  delivery_recipients?: string[]
67
70
  delivery_schedule?: RegularSchedule
68
71
  created_by?: DdUser
@@ -70,12 +73,53 @@ export type UnpersistedDdDive = {
70
73
  dd_dive_group?: DdDiveGroup
71
74
  dd_dive_runs?: OptionalProps<UnpersistedDdDiveRun, "dd_dive_id">[]
72
75
  owner?: DdUser
76
+ dd_dive_distributions?: OptionalProps<UnpersistedDdDiveDistribution, "dd_dive_id">[]
73
77
  }
74
78
 
75
79
  export const DdDiveEnumFields = {
76
80
  visibility: ["public", "private"] as const,
77
81
  }
78
82
 
83
+ export type DdDiveDistribution = {
84
+ id: string
85
+ created_at: string
86
+ updated_at: string
87
+ _state: number
88
+ created_by_id?: string
89
+ created_by_name: string
90
+ extern_id?: string
91
+ updated_by_id?: string
92
+ updated_by_name?: string
93
+ dd_dive_id?: string
94
+ recipients?: string[]
95
+ schedule: RegularSchedule
96
+ notes?: string
97
+ created_by?: DdUser
98
+ updated_by?: DdUser
99
+ dd_dive_runs?: DdDiveRun[]
100
+ dd_dive?: DdDive
101
+ }
102
+
103
+ export type UnpersistedDdDiveDistribution = {
104
+ id?: string
105
+ created_at?: string
106
+ updated_at?: string
107
+ _state?: number
108
+ created_by_id?: string
109
+ created_by_name?: string
110
+ extern_id?: string
111
+ updated_by_id?: string
112
+ updated_by_name?: string
113
+ dd_dive_id?: string
114
+ recipients?: string[]
115
+ schedule: RegularSchedule
116
+ notes?: string
117
+ created_by?: DdUser
118
+ updated_by?: DdUser
119
+ dd_dive_runs?: OptionalProps<UnpersistedDdDiveRun, "dd_dive_distribution_id">[]
120
+ dd_dive?: DdDive
121
+ }
122
+
79
123
  export type DdDiveGroup = {
80
124
  id: string
81
125
  created_at: string
@@ -171,11 +215,14 @@ export type DdDiveRun = {
171
215
  output_data?: object
172
216
  output_file_data?: Attachment | { path: string }
173
217
  status: "initial" | "running" | "success" | "error"
218
+ delivery_mode?: string
174
219
  delivery_recipients?: string[]
175
220
  delivery_data?: object
221
+ dd_dive_distribution_id?: string
176
222
  created_by?: DdUser
177
223
  updated_by?: DdUser
178
224
  dd_dive?: DdDive
225
+ dd_dive_distribution?: DdDiveDistribution
179
226
  output_file?: File
180
227
  }
181
228
 
@@ -194,11 +241,14 @@ export type UnpersistedDdDiveRun = {
194
241
  output_data?: object
195
242
  output_file_data?: Attachment | { path: string }
196
243
  status: "initial" | "running" | "success" | "error"
244
+ delivery_mode?: string
197
245
  delivery_recipients?: string[]
198
246
  delivery_data?: object
247
+ dd_dive_distribution_id?: string
199
248
  created_by?: DdUser
200
249
  updated_by?: DdUser
201
250
  dd_dive?: DdDive
251
+ dd_dive_distribution?: DdDiveDistribution
202
252
  output_file?: File
203
253
  }
204
254
 
@@ -211,6 +261,7 @@ export const DdDiveRunEnumFields = {
211
261
  */
212
262
  export type PersistedModelTypeMap = {
213
263
  dd_dive: DdDive
264
+ dd_dive_distribution: DdDiveDistribution
214
265
  dd_dive_group: DdDiveGroup
215
266
  dd_dive_plot: DdDivePlot
216
267
  dd_dive_run: DdDiveRun
@@ -221,6 +272,7 @@ export type PersistedModelTypeMap = {
221
272
  */
222
273
  export type UnpersistedModelTypeMap = {
223
274
  dd_dive: UnpersistedDdDive
275
+ dd_dive_distribution: UnpersistedDdDiveDistribution
224
276
  dd_dive_group: UnpersistedDdDiveGroup
225
277
  dd_dive_plot: UnpersistedDdDivePlot
226
278
  dd_dive_run: UnpersistedDdDiveRun
@@ -230,20 +282,22 @@ export type UnpersistedModelTypeMap = {
230
282
  * Map model names to their association names.
231
283
  */
232
284
  export type ModelIncludesMap = {
233
- dd_dive: "created_by" | "dd_dive_group" | "dd_dive_runs" | "owner" | "updated_by"
285
+ dd_dive: "created_by" | "dd_dive_distributions" | "dd_dive_group" | "dd_dive_runs" | "owner" | "updated_by"
286
+ dd_dive_distribution: "created_by" | "dd_dive" | "dd_dive_runs" | "updated_by"
234
287
  dd_dive_group: "created_by" | "dd_dives" | "updated_by"
235
288
  dd_dive_plot: "created_by" | "dd_dive" | "updated_by"
236
- dd_dive_run: "created_by" | "dd_dive" | "updated_by"
289
+ dd_dive_run: "created_by" | "dd_dive" | "dd_dive_distribution" | "updated_by"
237
290
  }
238
291
 
239
292
  /**
240
293
  * Map model names to an array of association names.
241
294
  */
242
295
  export const ModelIncludesArrayMap = {
243
- dd_dive: ["created_by", "dd_dive_group", "dd_dive_runs", "owner", "updated_by"] as const,
296
+ dd_dive: ["created_by", "dd_dive_distributions", "dd_dive_group", "dd_dive_runs", "owner", "updated_by"] as const,
297
+ dd_dive_distribution: ["created_by", "dd_dive", "dd_dive_runs", "updated_by"] as const,
244
298
  dd_dive_group: ["created_by", "dd_dives", "updated_by"] as const,
245
299
  dd_dive_plot: ["created_by", "dd_dive", "updated_by"] as const,
246
- dd_dive_run: ["created_by", "dd_dive", "updated_by"] as const,
300
+ dd_dive_run: ["created_by", "dd_dive", "dd_dive_distribution", "updated_by"] as const,
247
301
  }
248
302
 
249
303
  /**
@@ -130,12 +130,6 @@ export class ColumnsEditorModal extends ModalPart<ColumnsEditorState> {
130
130
 
131
131
  tableFields!: FormFields<TableRef>
132
132
 
133
- addEditor(col: ColumnRef) {
134
- this.columnCount += 1
135
- const state = {schema: this.state.schema, columnsEditor: this, id: `column-${this.columnCount}`, column: col}
136
- this.columnEditors[state.id] = this.makePart(ColumnEditor, state)
137
- }
138
-
139
133
 
140
134
  async init () {
141
135
  this.table = this.state.tableView.table
@@ -173,7 +167,7 @@ export class ColumnsEditorModal extends ModalPart<ColumnsEditorState> {
173
167
  })
174
168
 
175
169
  this.onClick(addSingleKey, m => {
176
- this.addColumn(m.data.name)
170
+ this.addColumn(m.data)
177
171
  })
178
172
 
179
173
  this.onClick(addKey, m => {
@@ -186,14 +180,28 @@ export class ColumnsEditorModal extends ModalPart<ColumnsEditorState> {
186
180
  })
187
181
  }
188
182
 
189
- addColumn(col: string) {
190
- const colDef = this.modelDef.columns[col]
191
- log.info(`Add column ${col}`, colDef)
192
- if (colDef) {
193
- this.addEditor({name: colDef.name})
194
- this.dirty()
183
+ addColumn(col: ColumnRef) {
184
+ log.info(`Add column ${col.name}`, col)
185
+ this.addEditor(col)
186
+ this.validate().then()
187
+ this.dirty()
188
+ }
189
+
190
+ addEditor(col: ColumnRef) {
191
+ this.columnCount += 1
192
+ const state = {schema: this.state.schema, columnsEditor: this, id: `column-${this.columnCount}`, column: col}
193
+ this.columnEditors[state.id] = this.makePart(ColumnEditor, state)
194
+ }
195
+
196
+ removeEditor(id: string) {
197
+ const editor = this.columnEditors[id]
198
+ if (editor) {
199
+ log.info(`Removing column ${id}`)
200
+ this.removeChild(editor)
201
+ delete this.columnEditors[id]
202
+ this.validate().then()
195
203
  } else {
196
- alert(`Unknown column name '${col}'`)
204
+ log.warn(`No editor for column ${id}`)
197
205
  }
198
206
  }
199
207
 
@@ -262,18 +270,6 @@ export class ColumnsEditorModal extends ModalPart<ColumnsEditorState> {
262
270
 
263
271
  }
264
272
 
265
- removeEditor(id: string) {
266
- const editor = this.columnEditors[id]
267
- if (editor) {
268
- log.info(`Removing column ${id}`)
269
- this.removeChild(editor)
270
- delete this.columnEditors[id]
271
- }
272
- else {
273
- log.warn(`No editor for column ${id}`)
274
- }
275
- }
276
-
277
273
  async serialize(): Promise<ColumnRef[]> {
278
274
  const editors = Object.values(this.columnEditors)
279
275
  return await Promise.all(editors.map(async part => {
@@ -399,8 +395,11 @@ class ColumnEditor extends TerrierPart<ColumnState> {
399
395
  // Add Column Dropdown
400
396
  ////////////////////////////////////////////////////////////////////////////////
401
397
 
402
- type SelectableColumn = ColumnDef & {
398
+ type SelectableColumn = {
399
+ ref: ColumnRef
400
+ def: ColumnDef
403
401
  included: boolean
402
+ description: string
404
403
  sortOrder: string
405
404
  }
406
405
 
@@ -410,7 +409,7 @@ type SelectableColumn = ColumnDef & {
410
409
  class SelectColumnsDropdown extends Dropdown<{editor: ColumnsEditorModal}> {
411
410
 
412
411
  addAllKey = Messages.untypedKey()
413
- addKey = Messages.typedKey<{ name: string }>()
412
+ addKey = Messages.typedKey<ColumnRef>()
414
413
  checked: Set<string> = new Set()
415
414
  columns!: SelectableColumn[]
416
415
  modelDef!: ModelDef
@@ -426,16 +425,39 @@ class SelectColumnsDropdown extends Dropdown<{editor: ColumnsEditorModal}> {
426
425
 
427
426
  // sort the columns by whether they're in the editor already
428
427
  const includedNames = this.state.editor.currentColumnNames
429
- this.columns = Object.values(this.modelDef.columns).map(col => {
430
- const included = includedNames.has(col.name)
431
- const sortOrder = `${included ? '1' : '0'}${col.name}`
432
- return {included, sortOrder,...col}
428
+ this.columns = Object.values(this.modelDef.columns).map(colDef => {
429
+ const included = includedNames.has(colDef.name)
430
+ const sortOrder = `${included ? '1' : '0'}${colDef.name}`
431
+ const description = colDef.metadata?.description || ''
432
+ return {
433
+ def: colDef,
434
+ ref: {name: colDef.name},
435
+ included,
436
+ sortOrder, description}
433
437
  })
434
438
  this.columns = Arrays.sortBy(this.columns, 'sortOrder')
435
439
 
440
+ // add an option for a "count" column
441
+ const idDef = this.modelDef.columns['id']
442
+ if (idDef) {
443
+ const countColumn: SelectableColumn = {
444
+ def: idDef,
445
+ ref: {
446
+ name: idDef.name,
447
+ alias: 'count',
448
+ function: 'count'
449
+ },
450
+ included: false,
451
+ sortOrder: '',
452
+ description: "Count all matching rows (when grouped)"
453
+ }
454
+ this.columns = this.columns.concat([countColumn])
455
+ }
456
+
436
457
  this.onClick(this.addKey, m => {
437
- log.info(`Adding column ${m.data.name}`)
438
- this.state.editor.addColumn(m.data.name)
458
+ const colRef = m.data
459
+ log.info(`Adding column ${colRef.name}`)
460
+ this.state.editor.addColumn(colRef)
439
461
 
440
462
  // remove the link
441
463
  const link = Dom.queryAncestorClass(m.event.target as HTMLInputElement, 'column')
@@ -443,10 +465,10 @@ class SelectColumnsDropdown extends Dropdown<{editor: ColumnsEditorModal}> {
443
465
  })
444
466
 
445
467
  this.onClick(this.addAllKey, _ => {
446
- // add all of the unincluded columns and close the dropdown
468
+ // add all the unincluded, non-function columns and close the dropdown
447
469
  for (const col of this.columns) {
448
- if (!col.included) {
449
- this.state.editor.addColumn(col.name)
470
+ if (!col.included && !col.ref.function?.length) {
471
+ this.state.editor.addColumn(col.ref)
450
472
  }
451
473
  }
452
474
  this.clear()
@@ -459,18 +481,48 @@ class SelectColumnsDropdown extends Dropdown<{editor: ColumnsEditorModal}> {
459
481
  }
460
482
 
461
483
  renderContent(parent: PartTag) {
484
+ // keep track of the last column rendered to see if we need a separator
485
+ let lastCol: SelectableColumn | null = null
462
486
  for (const col of this.columns) {
487
+ const colRef = col.ref
488
+ const colDef = col.def
489
+
490
+ // add a separator if necessary
491
+ if (lastCol) {
492
+ if ((!lastCol.included && col.included)) {
493
+ parent.div('.separator')
494
+ }
495
+ if ((!lastCol.ref.function?.length && colRef.function?.length)) {
496
+ parent.div('.separator')
497
+ }
498
+ }
499
+
500
+ // the actual link
463
501
  parent.a('.column', a => {
464
- a.div('.name').text(col.name)
465
- a.div('.right-title').text(col.type)
502
+ if (colRef.function?.length) {
503
+ if (colRef.function == 'count') {
504
+ // no point showing the column name or type for count()
505
+ a.div('.name').text("count(*)")
506
+ }
507
+ else {
508
+ // non-count function, so show the type
509
+ a.div('.name').text(`${colRef.function}(${colDef.name})`)
510
+ a.div('.right-title').text(colDef.type)
511
+ }
512
+ }
513
+ else {
514
+ a.div('.name').text(colDef.name)
515
+ a.div('.right-title').text(colDef.type)
516
+ }
466
517
  if (col.included) {
467
518
  // style the columns that are already included differently
468
519
  a.class('inactive')
469
520
  }
470
- if (col.metadata?.description?.length) {
471
- a.div('.subtitle').text(col.metadata.description)
521
+ if (col.description?.length) {
522
+ a.div('.subtitle').text(col.description)
472
523
  }
473
- }).emitClick(this.addKey, {name: col.name})
524
+ }).emitClick(this.addKey, col.ref)
525
+ lastCol = col
474
526
  }
475
527
 
476
528
  parent.a('.primary', a => {
@@ -31,7 +31,7 @@ type BaseFilter = {
31
31
  edit_label?: string
32
32
  }
33
33
 
34
- const directOperators = ['eq', 'ne', 'ilike', 'lt', 'gt', 'lte', 'gte', 'contains', 'excludes'] as const
34
+ const directOperators = ['eq', 'ne', 'ilike', 'lt', 'gt', 'lte', 'gte', 'present', 'empty', 'contains', 'excludes', 'any'] as const
35
35
  export type DirectOperator = typeof directOperators[number]
36
36
 
37
37
  /**
@@ -45,16 +45,16 @@ function operatorOptions(colDef?: ColumnDef): SelectOptions {
45
45
  case 'text':
46
46
  case 'string':
47
47
  if (colDef?.array) {
48
- operators = ['contains', 'excludes']
48
+ operators = ['contains', 'excludes', 'any']
49
49
  }
50
50
  else {
51
- operators = ['eq', 'ne', 'ilike']
51
+ operators = ['eq', 'ne', 'ilike', 'present', 'empty']
52
52
  }
53
53
  break
54
54
  case 'float':
55
55
  case 'integer':
56
56
  case 'cents':
57
- operators = ['eq', 'ne', 'lt', 'gt', 'lte', 'gte']
57
+ operators = ['eq', 'ne', 'lt', 'gt', 'lte', 'gte', 'present', 'empty']
58
58
  break
59
59
  }
60
60
  return operators.map(op => {
@@ -117,24 +117,44 @@ function operatorDisplay(op: DirectOperator): string {
117
117
  return '>'
118
118
  case 'gte':
119
119
  return '≥'
120
+ case 'present':
121
+ return "Is Present?"
122
+ case 'empty':
123
+ return "Is Empty?"
124
+ case 'contains':
125
+ return "Contains ALL of"
126
+ case 'excludes':
127
+ return "Contains NONE of"
128
+ case 'any':
129
+ return "Contains ANY of"
120
130
  default:
121
131
  return op
122
132
  }
123
133
  }
124
134
 
135
+ /**
136
+ * Determine whether the given operator needs an argument.
137
+ * @param op
138
+ */
139
+ function operatorNeedsArgument(op: DirectOperator): boolean {
140
+ return !(op == 'present' || op == 'empty');
141
+ }
142
+
125
143
 
126
144
  function renderStatic(parent: PartTag, filter: Filter) {
127
145
  switch (filter.filter_type) {
128
146
  case 'direct':
129
147
  parent.div('.column').text(filter.column)
130
148
  parent.div('.operator').text(operatorDisplay(filter.operator))
131
- switch (filter.column_type) {
132
- case 'cents':
133
- parent.div('.value').text(Format.cents(filter.value))
134
- break
135
- default:
136
- parent.div('.value').text(filter.value)
137
- break
149
+ if (operatorNeedsArgument(filter.operator)) {
150
+ switch (filter.column_type) {
151
+ case 'cents':
152
+ parent.div('.value').text(Format.cents(filter.value))
153
+ break
154
+ default:
155
+ parent.div('.value').text(filter.value)
156
+ break
157
+ }
138
158
  }
139
159
  break
140
160
  case 'date_range':
@@ -377,6 +397,7 @@ abstract class FilterFields<F extends BaseFilter> extends TerrierFormFields<F> {
377
397
  class DirectFilterEditor extends FilterFields<DirectFilter> {
378
398
 
379
399
  numericChangeKey = Messages.untypedKey()
400
+ operatorChangeKey = Messages.untypedKey()
380
401
 
381
402
  constructor(container: FilterEditorContainer, filter: DirectFilter) {
382
403
  super(container, filter)
@@ -391,6 +412,11 @@ class DirectFilterEditor extends FilterFields<DirectFilter> {
391
412
  }
392
413
  log.info(`Direct filter for ${this.columnDef?.name} initialized`, this.data)
393
414
 
415
+ this.part.onChange(this.operatorChangeKey, m => {
416
+ log.info(`Operator changed`, m)
417
+ this.part.dirty()
418
+ })
419
+
394
420
  // for numeric types, we use a number input and translate the
395
421
  // value back to the string value field whenever it changes
396
422
  this.part.onChange(this.numericChangeKey, m => {
@@ -411,22 +437,25 @@ class DirectFilterEditor extends FilterFields<DirectFilter> {
411
437
  parent.div('.operator', col => {
412
438
  const opts = operatorOptions(this.columnDef)
413
439
  this.select(col, 'operator', opts)
440
+ .emitChange(this.operatorChangeKey)
414
441
  })
415
442
  parent.div('.filter', col => {
416
- switch (this.data.column_type) {
417
- case 'cents':
418
- col.div('.tt-compound-field', field => {
419
- field.label().text('$')
420
- this.numberInput(field, 'numeric_value', {placeholder: "Value"})
443
+ if (operatorNeedsArgument(this.data.operator)) {
444
+ switch (this.data.column_type) {
445
+ case 'cents':
446
+ col.div('.tt-compound-field', field => {
447
+ field.label().text('$')
448
+ this.numberInput(field, 'numeric_value', { placeholder: "Value" })
449
+ .emitChange(this.numericChangeKey)
450
+ })
451
+ break
452
+ case 'number':
453
+ this.numberInput(col, 'numeric_value', { placeholder: "Value" })
421
454
  .emitChange(this.numericChangeKey)
422
- })
423
- break
424
- case 'number':
425
- this.numberInput(col, 'numeric_value', {placeholder: "Value"})
426
- .emitChange(this.numericChangeKey)
427
- break
428
- default:
429
- this.textInput(col, 'value', {placeholder: "Value"})
455
+ break
456
+ default:
457
+ this.textInput(col, 'value', { placeholder: "Value" })
458
+ }
430
459
  }
431
460
  })
432
461
  this.renderActions(parent)
@@ -32,12 +32,18 @@ function validateQuery(query: Query): QueryClientValidation {
32
32
 
33
33
  const usedNames: Set<string> = new Set<string>()
34
34
 
35
+ const aggCols: ColumnRef[] = [] // keep track of columns with an aggregate function
35
36
  let isGrouped = false
36
37
  const groupedTables: Set<TableRef> = new Set()
37
38
  Queries.eachColumn(query, (table, col) => {
38
39
  // clear the errors
39
40
  col.errors = undefined
40
41
 
42
+ // determine if there's an aggregate function
43
+ if (col.function?.length && Columns.functionType(col.function) == 'aggregate') {
44
+ aggCols.push(col)
45
+ }
46
+
41
47
  // determine if there's a _group by_ in the query
42
48
  if (col.grouped) {
43
49
  isGrouped = true
@@ -55,15 +61,21 @@ function validateQuery(query: Query): QueryClientValidation {
55
61
  usedNames.add(selectName)
56
62
  })
57
63
 
58
- // if the query is grouped, ensure that all other column refs
59
- // are either grouped, have an aggregate function, or are on a grouped table
60
64
  if (isGrouped) {
65
+ // if the query is grouped, ensure that all other column refs
66
+ // are either grouped, have an aggregate function, or are on a grouped table
61
67
  Queries.eachColumn(query, (table, col) => {
62
68
  if (!col.grouped && Columns.functionType(col.function) != 'aggregate' && !groupedTables.has(table)) {
63
69
  addColumnError(col, `<strong>${col.name}</strong> must be grouped or have an aggregate function`)
64
70
  }
65
71
  })
66
72
  }
73
+ else if (aggCols.length) {
74
+ // if the query isn't grouped, aggregate functions are an error
75
+ aggCols.forEach(col => {
76
+ addColumnError(col, `<strong>${col.name}</strong> has an aggregate function but the query isn't grouped`)
77
+ })
78
+ }
67
79
 
68
80
  return validation
69
81
  }
package/package.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "files": [
5
5
  "*"
6
6
  ],
7
- "version": "4.46.0",
7
+ "version": "4.50.2",
8
8
  "repository": {
9
9
  "type": "git",
10
10
  "url": "https://github.com/Terrier-Tech/terrier-engine"
package/terrier/glyps.ts CHANGED
@@ -1,4 +1,4 @@
1
- // This file was automatically generated by glyps:compile on 04/08/25 5:24 PM, DO NOT MANUALLY EDIT!
1
+ // This file was automatically generated by glyps:compile on 04/09/25 10:46 AM, DO NOT MANUALLY EDIT!
2
2
 
3
3
  import Strings from "tuff-core/strings"
4
4
 
@@ -86,6 +86,11 @@ export class RegularScheduleFields extends TerrierFormFields<CombinedRegularSche
86
86
 
87
87
  scheduleTypeChangeKey = Messages.typedKey<{schedule_type: ScheduleType}>()
88
88
 
89
+ /**
90
+ * Set false to not render the 'none' option
91
+ */
92
+ showNoneOption: boolean = true
93
+
89
94
  constructor(part: TerrierPart<any>, data: CombinedRegularSchedule) {
90
95
  super(part, data)
91
96
 
@@ -102,11 +107,13 @@ export class RegularScheduleFields extends TerrierFormFields<CombinedRegularSche
102
107
 
103
108
  render(parent: PartTag): any {
104
109
  parent.div('.tt-flex.column.gap.regular-schedule-form.tt-form', col => {
105
- col.label('.caption-size', label => {
106
- this.radio(label, 'schedule_type', 'none')
107
- .emitChange(this.scheduleTypeChangeKey, {schedule_type: 'none'})
108
- label.span().text("Do Not Deliver")
109
- })
110
+ if (this.showNoneOption) {
111
+ col.label('.caption-size', label => {
112
+ this.radio(label, 'schedule_type', 'none')
113
+ .emitChange(this.scheduleTypeChangeKey, { schedule_type: 'none' })
114
+ label.span().text("Do Not Deliver")
115
+ })
116
+ }
110
117
 
111
118
  col.label('.caption-size', label => {
112
119
  this.radio(label, 'schedule_type', 'daily')
package/terrier/sheets.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { GlypName } from "./glyps"
1
2
  import {Action, IconName} from "./theme"
2
3
  import TerrierPart from "./parts/terrier-part"
3
4
  import {PartTag} from "tuff-core/parts"
@@ -8,6 +9,19 @@ import Messages from "tuff-core/messages"
8
9
  const log = new Logger('Sheets')
9
10
 
10
11
 
12
+ ////////////////////////////////////////////////////////////////////////////////
13
+ // Inputs
14
+ ////////////////////////////////////////////////////////////////////////////////
15
+
16
+ export type SheetInput = {
17
+ type: 'text'
18
+ key: string
19
+ value: string
20
+ label?: string
21
+ icon?: GlypName
22
+ }
23
+
24
+
11
25
  ////////////////////////////////////////////////////////////////////////////////
12
26
  // Parts
13
27
  ////////////////////////////////////////////////////////////////////////////////
@@ -18,12 +32,18 @@ export type SheetState = {
18
32
  body: string
19
33
  primaryActions?: Action[]
20
34
  secondaryActions?: Action[]
35
+ inputs?: SheetInput[]
21
36
  }
22
37
 
23
38
  const clearKey = Messages.untypedKey()
24
39
 
40
+ /**
41
+ * Show a little popup sheet at the bottom of the screen that's much nicer than a native alert() or confirm().
42
+ */
25
43
  export class Sheet<TState extends SheetState> extends TerrierPart<TState> {
26
44
 
45
+ inputChangedKey = Messages.typedKey<{key: string}>()
46
+
27
47
  /**
28
48
  * Removes itself from the DOM.
29
49
  */
@@ -36,6 +56,17 @@ export class Sheet<TState extends SheetState> extends TerrierPart<TState> {
36
56
  this.onClick(clearKey, _ => {
37
57
  this.clear()
38
58
  })
59
+
60
+ this.onChange(this.inputChangedKey, m => {
61
+ const key = m.data.key
62
+ const value = m.value
63
+ log.info(`Input ${key} changed to ${value}`)
64
+ this.state.inputs?.forEach(input => {
65
+ if (input.key === key) {
66
+ input.value = value
67
+ }
68
+ })
69
+ })
39
70
  }
40
71
 
41
72
  get parentClasses(): Array<string> {
@@ -48,8 +79,11 @@ export class Sheet<TState extends SheetState> extends TerrierPart<TState> {
48
79
  .title(this.state.title)
49
80
  .icon(this.state.icon)
50
81
  .content(panel => {
51
- panel.class('padded')
82
+ panel.class('padded', 'tt-form')
52
83
  panel.div('.body').text(this.state.body)
84
+ for (const input of this.state.inputs || []) {
85
+ this.renderInput(panel, input)
86
+ }
53
87
  })
54
88
  for (const action of this.state.primaryActions || []) {
55
89
  // if it doesn't have a click key, it must be a close button
@@ -64,12 +98,22 @@ export class Sheet<TState extends SheetState> extends TerrierPart<TState> {
64
98
  panel.render(parent)
65
99
  }
66
100
 
101
+ renderInput(parent: PartTag, input: SheetInput) {
102
+ parent.div('.tt-sheet-input', container => {
103
+ if (input.label?.length) {
104
+ container.label().text(input.label)
105
+ }
106
+ container.input({type: 'text', value: input.value})
107
+ .data({key: input.key})
108
+ .emitChange(this.inputChangedKey, {key: input.key})
109
+ })
110
+ }
111
+
67
112
  update(_elem: HTMLElement) {
68
113
  setTimeout(
69
114
  () => _elem.classList.add('show'),
70
115
  10
71
116
  )
72
-
73
117
  }
74
118
  }
75
119
 
@@ -81,7 +125,7 @@ export class Sheet<TState extends SheetState> extends TerrierPart<TState> {
81
125
  /**
82
126
  * State type for a sheet that asks the user to confirm a choice.
83
127
  */
84
- export type ConfirmSheetState = Pick<SheetState, 'title' | 'body' | 'icon'>
128
+ export type ConfirmSheetState = Pick<SheetState, 'title' | 'body' | 'icon' | 'inputs'>
85
129
 
86
130
  /**
87
131
  * State type for a sheet that tells the user something with no options.