terrier-engine 4.24.3 → 4.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,9 +4,19 @@ import TerrierPart from "../../terrier/parts/terrier-part"
4
4
  import {RegularSchedule, RegularScheduleForm} from "../../terrier/schedules"
5
5
  import {Logger} from "tuff-core/logging"
6
6
  import {EmailListForm} from "../../terrier/emails"
7
+ import {DdDiveRun} from "../gen/models"
8
+ import Db from "../dd-db"
9
+ import dayjs from "dayjs"
10
+ import Dates from "../queries/dates"
11
+ import DiveRuns from "./dive-runs";
7
12
 
8
13
  const log = new Logger("Dive Delivery")
9
14
 
15
+
16
+ ////////////////////////////////////////////////////////////////////////////////
17
+ // Delivery Form
18
+ ////////////////////////////////////////////////////////////////////////////////
19
+
10
20
  export type DiveDeliverySettings = {
11
21
  delivery_schedule: RegularSchedule
12
22
  delivery_recipients: string[]
@@ -16,11 +26,13 @@ export class DiveDeliveryForm extends TerrierPart<DiveEditorState> {
16
26
 
17
27
  scheduleForm!: RegularScheduleForm
18
28
  recipientsForm!: EmailListForm
29
+ deliveryList!: DiveDeliveryList
19
30
 
20
31
  async init() {
21
32
  const schedule = this.state.dive.delivery_schedule || {schedule_type: 'none'}
22
33
  this.scheduleForm = this.makePart(RegularScheduleForm, schedule)
23
34
  this.recipientsForm = this.makePart(EmailListForm, {emails: this.state.dive.delivery_recipients || []})
35
+ this.deliveryList = this.makePart(DiveDeliveryList, this.state)
24
36
 
25
37
  this.listen('datachanged', this.scheduleForm.dataChangedKey, m => {
26
38
  log.info(`Schedule form data changed`, m.data)
@@ -42,6 +54,11 @@ export class DiveDeliveryForm extends TerrierPart<DiveEditorState> {
42
54
  parent.part(this.scheduleForm)
43
55
  parent.h3(".glyp-users").text("Recipients")
44
56
  parent.part(this.recipientsForm)
57
+ parent.div('.deliveries', deliveriesContainer => {
58
+ // it looks better without the gap between the header and list
59
+ deliveriesContainer.h3(".glyp-inbox").text("Deliveries")
60
+ deliveriesContainer.part(this.deliveryList)
61
+ })
45
62
  }
46
63
 
47
64
  /**
@@ -55,3 +72,46 @@ export class DiveDeliveryForm extends TerrierPart<DiveEditorState> {
55
72
  }
56
73
 
57
74
  }
75
+
76
+
77
+ ////////////////////////////////////////////////////////////////////////////////
78
+ // Delivery List
79
+ ////////////////////////////////////////////////////////////////////////////////
80
+
81
+ class DiveDeliveryList extends TerrierPart<DiveEditorState> {
82
+
83
+ runs: DdDiveRun[] = []
84
+
85
+ async init() {
86
+ this.runs = await Db().query("dd_dive_run")
87
+ .where({dd_dive_id: this.state.dive.id})
88
+ .where("delivery_recipients is not null")
89
+ .orderBy("created_at desc")
90
+ .limit(100)
91
+ .exec()
92
+
93
+ this.dirty()
94
+ }
95
+
96
+ get parentClasses(): Array<string> {
97
+ return ['dive-delivery-list']
98
+ }
99
+
100
+ render(parent: PartTag) {
101
+ for (const run of this.runs) {
102
+ parent.div(".run", view => {
103
+ view.div(".recipients.glyp-users.with-icon").text(run.delivery_recipients?.join("; ") || "No Recipients")
104
+ view.div('.datetime', dateTimeView => {
105
+ const d = dayjs(run.created_at)
106
+ dateTimeView.div(".date").text(d.format(Dates.displayFormat))
107
+ dateTimeView.div(".time").text(d.format("H:mm A"))
108
+ })
109
+ run.dd_dive = this.state.dive // so that the filename is better
110
+ view.a(".download.glyp-download", {href: DiveRuns.outputUrl(run), target: '_blank'})
111
+ .data({tooltip: "Download the dive results for this delivery"})
112
+ })
113
+ }
114
+ }
115
+
116
+ }
117
+
@@ -15,12 +15,18 @@ import {TerrierFormFields} from "../../terrier/forms"
15
15
  import * as inflection from "inflection"
16
16
  import Dates, {DateLiteral, DatePeriodPickerPart, DatePeriodPickerState, LiteralDateRange} from "../queries/dates"
17
17
  import dayjs from "dayjs"
18
- import {ProgressBarPart} from "../../terrier/progress";
19
- import {LogListPart} from "../../terrier/logging";
18
+ import {ProgressBarPart} from "../../terrier/progress"
19
+ import {LogListPart} from "../../terrier/logging"
20
20
  import Messages from "tuff-core/messages"
21
+ import Format from "../../terrier/format"
21
22
 
22
23
  const log = new Logger("DiveRuns")
23
24
 
25
+
26
+ ////////////////////////////////////////////////////////////////////////////////
27
+ // Run Modal
28
+ ////////////////////////////////////////////////////////////////////////////////
29
+
24
30
  type RunQueryResult = {
25
31
  id: string
26
32
  time: string
@@ -309,4 +315,34 @@ export class DiveRunModal extends ModalPart<{dive: DdDive }> {
309
315
  })
310
316
  }
311
317
 
312
- }
318
+ }
319
+
320
+
321
+ ////////////////////////////////////////////////////////////////////////////////
322
+ // Utilities
323
+ ////////////////////////////////////////////////////////////////////////////////
324
+
325
+ /**
326
+ * Generates a relative URL to download the output of the given run with an optional custom file name.
327
+ * @param run
328
+ * @param filename
329
+ */
330
+ function outputUrl(run: DdDiveRun, filename?: string): string {
331
+ if (!filename?.length) {
332
+ const nameComps: string[] = []
333
+ const dive = run.dd_dive
334
+ if (dive) {
335
+ nameComps.push(inflection.underscore(dive.name))
336
+ }
337
+ const d = dayjs(run.created_at)
338
+ nameComps.push(d.format('YYYY-MM-DD_HHmmss'))
339
+ filename = nameComps.join('_') + '.xlsx'
340
+ }
341
+ return `/data_dive/download_run/${run.id}/${filename}?t=${dayjs().format(Format.timestamp)}`
342
+ }
343
+
344
+ const DiveRuns = {
345
+ outputUrl
346
+ }
347
+ export default DiveRuns
348
+
package/package.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "files": [
5
5
  "*"
6
6
  ],
7
- "version": "4.24.3",
7
+ "version": "4.26.0",
8
8
  "repository": {
9
9
  "type": "git",
10
10
  "url": "https://github.com/Terrier-Tech/terrier-engine"
package/terrier/format.ts CHANGED
@@ -12,13 +12,20 @@ function cents(c: number | string): string {
12
12
  return '$' + (num/100).toFixed(2)
13
13
  }
14
14
 
15
+ ////////////////////////////////////////////////////////////////////////////////
16
+ // Dates and Times
17
+ ////////////////////////////////////////////////////////////////////////////////
18
+
19
+ const timestamp = 'YYYYMMDDHHMMSS'
20
+
15
21
 
16
22
  ////////////////////////////////////////////////////////////////////////////////
17
23
  // Export
18
24
  ////////////////////////////////////////////////////////////////////////////////
19
25
 
20
26
  const Format = {
21
- cents
27
+ cents,
28
+ timestamp
22
29
  }
23
30
 
24
31
  export default Format
@@ -0,0 +1,182 @@
1
+ import Html, {DivTag, HtmlParentTag} from "tuff-core/html"
2
+ import Messages from "tuff-core/messages"
3
+ import {PartPlugin} from "tuff-core/plugins"
4
+ import Theme, {IconName} from "./theme"
5
+ import PagePart from "./parts/page-part"
6
+ import TerrierPart from "./parts/terrier-part"
7
+
8
+ export type Hint = {
9
+ title?: string
10
+ icon?: IconName
11
+ tooltip?: string
12
+ }
13
+
14
+ export type HintRenderOptions = {
15
+ classes?: string[]
16
+ side?: 'inline' | 'top' | 'right' | 'bottom' | 'left' | 'inline-top' | 'inline-right' | 'inline-bottom' | 'inline-left'
17
+ hideIcon?: boolean
18
+ }
19
+
20
+ /**
21
+ * Render a hint into the given tag
22
+ * @param theme the theme to render the hint icon with
23
+ * @param parent the parent tag to render the hint into
24
+ * @param hint the hint to render
25
+ * @param options options to modify how the hint is rendered
26
+ */
27
+ function renderHint(theme: Theme, parent: HtmlParentTag, hint: Hint, options?: HintRenderOptions): DivTag {
28
+ const hintTag = parent.div('.tt-hint')
29
+
30
+ if (hint.tooltip) hintTag.dataAttr('tooltip', hint.tooltip)
31
+ if (options?.classes) hintTag.class(...options.classes)
32
+ if (options?.side && options.side != 'inline') hintTag.dataAttr('tt-hint-side', options.side)
33
+
34
+ const hideIcon = options?.hideIcon ?? false
35
+ if (!hideIcon) {
36
+ const icon = hint.icon ?? 'glyp-hint'
37
+ theme.renderIcon(hintTag, icon)
38
+ }
39
+
40
+ if (hint.title) hintTag.span('.tt-hint-title').text(hint.title)
41
+
42
+ return hintTag
43
+ }
44
+
45
+ /**
46
+ * Add a hint element to the given parent element
47
+ * @param theme the theme to render the hint icon with
48
+ * @param parentElement the element to add the hint element to
49
+ * @param hint the hint to add
50
+ * @param options options to modify how the hint is rendered
51
+ * @param insertPosition specify where to insert the hint relative to the parent element
52
+ */
53
+ function injectHint(theme: Theme, parentElement: Element, hint: Hint, options?: HintRenderOptions, insertPosition: InsertPosition = 'beforeend'): HTMLDivElement {
54
+ const elem = Html.createElement('div', div => renderHint(theme, div, hint, options)).firstElementChild as HTMLDivElement
55
+ parentElement.insertAdjacentElement(insertPosition, elem)
56
+ return elem
57
+ }
58
+
59
+ export type DynamicHint = {
60
+ selector: string
61
+ hint: Hint
62
+ options?: HintRenderOptions
63
+ insertPosition?: InsertPosition
64
+ onlyFirstMatch?: boolean // only add a hint to the first element that matches the selector
65
+ }
66
+
67
+ /**
68
+ * Adds dynamic hints to a given part. Dynamic hints will detect changes to the DOM to update where they are rendered
69
+ * @param part the part to add dynamic hints to. Dynamic hints will only be added to matching elements under the part's root element
70
+ * @param hints the hints to add.
71
+ */
72
+ function addDynamicHints(part: TerrierPart<any>, hints: DynamicHint[]) {
73
+ part.makePlugin(DynamicHintsPlugin, hints)
74
+ }
75
+
76
+ class DynamicHintsPlugin extends PartPlugin<DynamicHint[]> {
77
+ observer: MutationObserver = new MutationObserver(this.handleMutations.bind(this))
78
+
79
+ async init() {
80
+ if (!('theme' in this.part)) throw new Error("DynamicHintsPlugin requires a TerrierPart")
81
+ }
82
+
83
+ update(elem: HTMLElement) {
84
+ super.update(elem)
85
+
86
+ this.addDynamicHints(elem)
87
+ this.observer.disconnect()
88
+ this.observer.observe(elem, { childList: true, subtree: true })
89
+ }
90
+
91
+ private handleMutations(mutations: MutationRecord[], _observer: MutationObserver): void {
92
+ // Ignore mutations that added hint elements (prevents infinite recursion)
93
+ const addedHints = mutations.some(m =>
94
+ Array.from(m.addedNodes).some(n => n instanceof HTMLElement && n.dataset.hintSource === 'DynamicHintsPlugin')
95
+ )
96
+ if (addedHints) return
97
+ if (!this.part.element) return
98
+ this.addDynamicHints(this.part.element)
99
+ }
100
+
101
+ private addDynamicHints(elem: HTMLElement) {
102
+ const theme = (this.part as TerrierPart<any>).theme
103
+
104
+ elem.querySelectorAll('.tt-hint[data-hint-source=DynamicHintsPlugin]').forEach(e => e.remove())
105
+
106
+ for (const dynamicHint of this.state) {
107
+ const matches = elem.querySelectorAll(dynamicHint.selector)
108
+ if (!matches.length) continue
109
+ if (dynamicHint.onlyFirstMatch) {
110
+ this.addDynamicHint(theme, matches[0], dynamicHint)
111
+ } else {
112
+ matches.forEach(match => this.addDynamicHint(theme, match, dynamicHint))
113
+ }
114
+ }
115
+ }
116
+
117
+ private addDynamicHint(theme: Theme, elem: Element, hint: DynamicHint) {
118
+ const hintElement = injectHint(theme, elem, hint.hint, hint.options, hint.insertPosition ?? 'beforeend')
119
+ hintElement.dataset.hintSource = 'DynamicHintsPlugin'
120
+ }
121
+ }
122
+
123
+ /**
124
+ * Adds a hints toggle checkbox to a page's toolbar and handles persisting the state of the checkbox between visits.
125
+ * @param part the page to add a checkbox to
126
+ * @param hintKey a key for this page so that the checkbox state can be persisted
127
+ */
128
+ function addHintToggle(part: PagePart<any>, hintKey: string) {
129
+ part.makePlugin(HintTogglePlugin, { hintKey })
130
+ }
131
+
132
+ class HintTogglePlugin extends PartPlugin<{ hintKey: string }> {
133
+
134
+ checkboxChangedKey = Messages.untypedKey()
135
+ storageKey = `show-hints-${this.state.hintKey}`
136
+
137
+ get storedValue() {
138
+ const stored = localStorage.getItem(this.storageKey)
139
+ return stored == null ? true : stored == 'true'
140
+ }
141
+
142
+ set storedValue(value: boolean) {
143
+ localStorage.setItem(this.storageKey, value.toString())
144
+ }
145
+
146
+ async init() {
147
+ document.body.classList.toggle('tt-hints-hidden', !this.storedValue)
148
+
149
+ const part = this.part as PagePart<any>
150
+ part.addToolbarInput('show-hints', 'checkbox', {
151
+ title: "Hints",
152
+ icon: 'glyp-hint',
153
+ onChangeKey: this.checkboxChangedKey,
154
+ onInputKey: this.checkboxChangedKey,
155
+ })
156
+
157
+ part.onInput(this.checkboxChangedKey, m => {
158
+ const checked = (m.event.target as HTMLInputElement).checked
159
+ this.storedValue = checked
160
+ document.body.classList.toggle('tt-hints-hidden', !checked)
161
+ })
162
+ }
163
+
164
+ update(elem: HTMLElement) {
165
+ super.update(elem)
166
+
167
+ const checkbox = elem.querySelector(`[data-toolbar-field-name=show-hints]`)
168
+
169
+ if (checkbox instanceof HTMLInputElement) {
170
+ checkbox.checked = this.storedValue
171
+ }
172
+ }
173
+ }
174
+
175
+ const Hints = {
176
+ renderHint,
177
+ injectHint,
178
+ addDynamicHints,
179
+ addHintToggle,
180
+ }
181
+
182
+ export default Hints
@@ -4,6 +4,7 @@ import {PartTag} from "tuff-core/parts"
4
4
  import {optionsForSelect, SelectOptions} from "tuff-core/forms"
5
5
  import {UntypedKey} from "tuff-core/messages"
6
6
  import {Logger} from "tuff-core/logging"
7
+ import {InputTagAttrs, SelectTagAttrs} from "tuff-core/html"
7
8
 
8
9
  const log = new Logger("Terrier PagePart")
9
10
 
@@ -35,10 +36,10 @@ type ToolbarFieldDefOptions = {
35
36
  icon?: IconName
36
37
  }
37
38
 
38
- type ToolbarSelectDef = { type: 'select', options: SelectOptions } & BaseFieldDef
39
+ type ToolbarSelectDef = { type: 'select', options: SelectOptions, attrs?: SelectTagAttrs } & BaseFieldDef
39
40
 
40
41
  type ValuedInputType = 'text' | 'color' | 'date' | 'datetime-local' | 'email' | 'hidden' | 'month' | 'number' | 'password' | 'search' | 'tel' | 'time' | 'url' | 'week' | 'checkbox'
41
- type ToolbarValuedInputDef = { type: ValuedInputType } & BaseFieldDef
42
+ type ToolbarValuedInputDef = { type: ValuedInputType, attrs?: InputTagAttrs } & BaseFieldDef
42
43
 
43
44
  /**
44
45
  * Defines a field to be rendered in the page's toolbar
@@ -90,7 +91,7 @@ export default abstract class PagePart<TState> extends ContentPart<TState> {
90
91
  * @param selectOptions an array of select options
91
92
  * @param opts
92
93
  */
93
- addToolbarSelect(name: string, selectOptions: SelectOptions, opts?: ToolbarFieldDefOptions) {
94
+ addToolbarSelect(name: string, selectOptions: SelectOptions, opts?: ToolbarFieldDefOptions & { attrs?: SelectTagAttrs }) {
94
95
  this.addToolbarFieldDef({ type: 'select', name, options: selectOptions, ...opts })
95
96
  }
96
97
 
@@ -100,7 +101,7 @@ export default abstract class PagePart<TState> extends ContentPart<TState> {
100
101
  * @param type the type attribute of the input field
101
102
  * @param opts
102
103
  */
103
- addToolbarInput(name: string, type: ValuedInputType, opts?: ToolbarFieldDefOptions) {
104
+ addToolbarInput(name: string, type: ValuedInputType, opts?: ToolbarFieldDefOptions & { attrs?: InputTagAttrs }) {
104
105
  this.addToolbarFieldDef({ type, name, ...opts })
105
106
  }
106
107
 
@@ -203,6 +204,10 @@ export default abstract class PagePart<TState> extends ContentPart<TState> {
203
204
  })
204
205
  if (def.onChangeKey) select.emitChange(def.onChangeKey)
205
206
  if (def.onInputKey) select.emitInput(def.onInputKey)
207
+
208
+ if (def.attrs) select.attrs(def.attrs)
209
+
210
+ select.dataAttr('toolbar-field-name', name)
206
211
  } else {
207
212
  const input = label.input({name: def.name, type: def.type, value: def.defaultValue})
208
213
  if (def.type == 'checkbox' && def.defaultValue?.length) {
@@ -210,8 +215,11 @@ export default abstract class PagePart<TState> extends ContentPart<TState> {
210
215
  }
211
216
  if (def.onChangeKey) input.emitChange(def.onChangeKey)
212
217
  if (def.onInputKey) input.emitInput(def.onInputKey)
213
- }
214
218
 
219
+ if (def.attrs) input.attrs(def.attrs)
220
+
221
+ input.dataAttr('toolbar-field-name', name)
222
+ }
215
223
 
216
224
  if (def.tooltip?.length) label.dataAttr('tooltip', def.tooltip)
217
225
  })
package/terrier/theme.ts CHANGED
@@ -2,6 +2,7 @@ import {PartTag} from "tuff-core/parts"
2
2
  import {GlypName} from "./glyps"
3
3
  import HubIcons, {HubIconName} from "./gen/hub-icons"
4
4
  import {Key} from "tuff-core/messages"
5
+ import Hints, {Hint, HintRenderOptions} from "./hints";
5
6
 
6
7
  export interface ThemeType {
7
8
  readonly icons: string
@@ -38,6 +39,10 @@ export type Action = {
38
39
  classes?: string[]
39
40
  click?: Packet
40
41
  badge?: string
42
+ hint?: {
43
+ hint: Hint
44
+ options?: HintRenderOptions
45
+ }
41
46
  }
42
47
 
43
48
  /**
@@ -115,6 +120,10 @@ export default class Theme {
115
120
  const badgeColor = options?.badgeColor || 'alert'
116
121
  a.div(`.badge.${badgeColor}`, {text: action.badge})
117
122
  }
123
+
124
+ if (action.hint) {
125
+ Hints.renderHint(this, a, action.hint.hint, action.hint.options)
126
+ }
118
127
  })
119
128
  }
120
129
  }