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.
- package/data-dive/dives/dive-delivery.ts +217 -42
- package/data-dive/dives/dive-editor.ts +9 -13
- package/data-dive/dives/dive-list.ts +1 -1
- package/data-dive/dives/dive-settings.ts +45 -5
- package/data-dive/gen/models.ts +59 -5
- package/data-dive/queries/columns.ts +94 -42
- package/data-dive/queries/filters.ts +53 -24
- package/data-dive/queries/validation.ts +14 -2
- package/package.json +1 -1
- package/terrier/glyps.ts +1 -1
- package/terrier/schedules.ts +12 -5
- package/terrier/sheets.ts +47 -3
|
@@ -1,14 +1,23 @@
|
|
|
1
|
-
import {PartTag} from "tuff-core/parts"
|
|
2
|
-
import
|
|
1
|
+
import { PartTag } from "tuff-core/parts"
|
|
2
|
+
import { DiveEditorState } from "./dive-editor"
|
|
3
3
|
import TerrierPart from "../../terrier/parts/terrier-part"
|
|
4
|
-
import
|
|
5
|
-
import {Logger} from "tuff-core/logging"
|
|
6
|
-
import {EmailListForm} from "../../terrier/emails"
|
|
7
|
-
import {
|
|
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
|
|
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
|
-
|
|
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.
|
|
38
|
-
log.info(
|
|
39
|
-
this.
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
parent.
|
|
56
|
-
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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 {
|
|
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
|
-
|
|
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-
|
|
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-
|
|
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
|
|
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: '
|
|
141
|
-
icon: 'glyp-
|
|
142
|
-
|
|
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
|
|
package/data-dive/gen/models.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// This file was automatically generated on
|
|
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
|
|
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:
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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
|
-
|
|
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 =
|
|
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<
|
|
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(
|
|
430
|
-
const included = includedNames.has(
|
|
431
|
-
const sortOrder = `${included ? '1' : '0'}${
|
|
432
|
-
|
|
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
|
-
|
|
438
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
465
|
-
|
|
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.
|
|
471
|
-
a.div('.subtitle').text(col.
|
|
521
|
+
if (col.description?.length) {
|
|
522
|
+
a.div('.subtitle').text(col.description)
|
|
472
523
|
}
|
|
473
|
-
}).emitClick(this.addKey,
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
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
|
-
|
|
424
|
-
|
|
425
|
-
|
|
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
package/terrier/glyps.ts
CHANGED
package/terrier/schedules.ts
CHANGED
|
@@ -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
|
-
|
|
106
|
-
|
|
107
|
-
.
|
|
108
|
-
|
|
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.
|