terrier-engine 4.0.21 → 4.1.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.
@@ -0,0 +1,188 @@
1
+ import Theme, {Action, ThemeType} from "../theme"
2
+ import {TerrierApp} from "../app"
3
+ import {PartParent, PartTag} from "tuff-core/parts"
4
+ import {Dropdown} from "../dropdowns"
5
+ import TerrierPart from "./terrier-part"
6
+
7
+ export type PanelActions<TT extends ThemeType> = {
8
+ primary: Array<Action<TT>>
9
+ secondary: Array<Action<TT>>
10
+ tertiary: Array<Action<TT>>
11
+ }
12
+ export type ActionLevel = keyof PanelActions<any>
13
+
14
+ /**
15
+ * Base class for all Parts that render some main content, like pages, panels, and modals.
16
+ */
17
+ export default abstract class ContentPart<
18
+ TState,
19
+ TThemeType extends ThemeType,
20
+ TApp extends TerrierApp<TThemeType, TApp, TTheme>,
21
+ TTheme extends Theme<TThemeType>
22
+ > extends TerrierPart<TState, TThemeType, TApp, TTheme> {
23
+
24
+ /**
25
+ * All ContentParts must implement this to render their actual content.
26
+ * @param parent
27
+ */
28
+ abstract renderContent(parent: PartTag): void
29
+
30
+
31
+ protected _title = ''
32
+
33
+ /**
34
+ * Sets the page, panel, or modal title.
35
+ * @param title
36
+ */
37
+ setTitle(title: string) {
38
+ this._title = title
39
+ }
40
+
41
+ protected _icon: TThemeType['icons'] | null = null
42
+
43
+ setIcon(icon: TThemeType['icons']) {
44
+ this._icon = icon
45
+ }
46
+
47
+ protected _titleClasses: string[] = []
48
+
49
+ addTitleClass(c: string) {
50
+ this._titleClasses.push(c)
51
+ }
52
+
53
+
54
+ /// Actions
55
+
56
+ // stored actions can be either an action object or a reference to a named action
57
+ private _actions = {
58
+ primary: Array<Action<TThemeType> | string>(),
59
+ secondary: Array<Action<TThemeType> | string>(),
60
+ tertiary: Array<Action<TThemeType> | string>()
61
+ }
62
+
63
+ private _namedActions: Record<string, { action: Action<TThemeType>, level: ActionLevel }> = {}
64
+
65
+ /**
66
+ * Add an action to the part, or replace a named action if it already exists.
67
+ * @param action the action to add
68
+ * @param level whether it's a primary, secondary, or tertiary action
69
+ * @param name a name to be given to this action, so it can be accessed later
70
+ */
71
+ addAction(action: Action<TThemeType>, level: ActionLevel = 'primary', name?: string) {
72
+ if (name?.length) {
73
+ if (name in this._namedActions) {
74
+ const currentLevel = this._namedActions[name].level
75
+ if (level != currentLevel) {
76
+ const index = this._actions[currentLevel].indexOf(name)
77
+ this._actions[currentLevel].splice(index, 1)
78
+ this._actions[level].push(name)
79
+ }
80
+ this._namedActions[name].action = action
81
+ } else {
82
+ this._namedActions[name] = {action, level}
83
+ this._actions[level].push(name)
84
+ }
85
+ } else {
86
+ this._actions[level].push(action)
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Returns the action definition for the action with the given name, or undefined if there is no action with that name
92
+ * @param name
93
+ */
94
+ getNamedAction(name: string): Action<TThemeType> | undefined {
95
+ return this._namedActions[name].action
96
+ }
97
+
98
+ /**
99
+ * Removes the action with the given name
100
+ * @param name
101
+ */
102
+ removeNamedAction(name: string) {
103
+ if (!(name in this._namedActions)) return
104
+ const level = this._actions[this._namedActions[name].level]
105
+ delete this._namedActions[name]
106
+ const actionIndex = level.indexOf(name)
107
+ if (actionIndex >= 0) {
108
+ level.splice(actionIndex, 1)
109
+ }
110
+ }
111
+
112
+ /**
113
+ * Clears the actions for this part
114
+ * @param level whether to clear the primary, secondary, or both sets of actions
115
+ */
116
+ clearActions(level: ActionLevel) {
117
+ for (const action of this._actions[level]) {
118
+ if (typeof action === 'string') {
119
+ delete this._namedActions[action]
120
+ }
121
+ }
122
+ this._actions[level] = []
123
+ }
124
+
125
+ getAllActions(): PanelActions<TThemeType> {
126
+ return {
127
+ primary: this.getActions('primary'),
128
+ secondary: this.getActions('secondary'),
129
+ tertiary: this.getActions('tertiary'),
130
+ }
131
+ }
132
+
133
+ getActions(level: ActionLevel): Action<TThemeType>[] {
134
+ return this._actions[level].map(action => {
135
+ return (typeof action === 'string') ? this._namedActions[action].action : action
136
+ })
137
+ }
138
+
139
+ hasActions(level: ActionLevel): boolean {
140
+ return this._actions[level].length > 0
141
+ }
142
+
143
+
144
+ /// Dropdowns
145
+
146
+ /**
147
+ * Shows the given dropdown part on the page.
148
+ * It's generally better to call `toggleDropdown` instead so that the dropdown will be
149
+ * hidden upon a subsequent click on the target.
150
+ * @param constructor a constructor for a dropdown part
151
+ * @param state the dropdown's state
152
+ * @param target the target element around which to show the dropdown
153
+ */
154
+ makeDropdown<DropdownType extends Dropdown<DropdownStateType, TThemeType, TApp, TTheme>, DropdownStateType extends {}>(
155
+ constructor: { new(p: PartParent, id: string, state: DropdownStateType): DropdownType; },
156
+ state: DropdownStateType,
157
+ target: EventTarget | null) {
158
+ if (!(target && target instanceof HTMLElement)) {
159
+ throw "Trying to show a dropdown without an element target!"
160
+ }
161
+ const dropdown = this.app.addOverlay(constructor, state, 'dropdown')
162
+ dropdown.parentPart = this
163
+ dropdown.anchor(target)
164
+ this.app.lastDropdownTarget = target
165
+ }
166
+
167
+ clearDropdowns() {
168
+ this.app.clearDropdowns()
169
+ }
170
+
171
+ /**
172
+ * Calls `makeDropdown` only if there's not a dropdown currently originating from the target.
173
+ * @param constructor a constructor for a dropdown part
174
+ * @param state the dropdown's state
175
+ * @param target the target element around which to show the dropdown
176
+ */
177
+ toggleDropdown<DropdownType extends Dropdown<DropdownStateType, TThemeType, TApp, TTheme>, DropdownStateType extends {}>(
178
+ constructor: { new(p: PartParent, id: string, state: DropdownStateType): DropdownType; },
179
+ state: DropdownStateType,
180
+ target: EventTarget | null) {
181
+ if (target && target instanceof HTMLElement && target == this.app.lastDropdownTarget) {
182
+ this.clearDropdowns()
183
+ } else {
184
+ this.makeDropdown(constructor, state, target)
185
+ }
186
+ }
187
+
188
+ }
@@ -0,0 +1,26 @@
1
+ import {Logger} from "tuff-core/logging"
2
+ import Theme, {ThemeType} from "../theme"
3
+ import {TerrierApp} from "../app"
4
+ import PagePart from "./page-part"
5
+ import {NoState, PartTag} from "tuff-core/parts"
6
+
7
+ const log = new Logger('NotFoundRoute')
8
+
9
+ /**
10
+ * Default page part if the router can't find the path.
11
+ */
12
+ export default class NotFoundRoute<
13
+ TT extends ThemeType,
14
+ TApp extends TerrierApp<TT, TApp, TTheme>,
15
+ TTheme extends Theme<TT>
16
+ > extends PagePart<NoState, TT, TApp, TTheme> {
17
+ async init() {
18
+ this.setTitle("Page Not Found")
19
+ }
20
+
21
+ renderContent(parent: PartTag) {
22
+ log.warn(`Not found: ${this.context.href}`)
23
+ parent.h1({text: "Not Found"})
24
+ }
25
+
26
+ }
@@ -0,0 +1,196 @@
1
+ import Theme, {Action, RenderActionOptions, ThemeType} from "../theme"
2
+ import {TerrierApp} from "../app"
3
+ import ContentPart, {ActionLevel} from "./content-part"
4
+ import {PartTag} from "tuff-core/parts"
5
+ import {optionsForSelect, SelectOptions} from "tuff-core/forms"
6
+ import {UntypedKey} from "tuff-core/messages"
7
+ import {Logger} from "tuff-core/logging"
8
+ import {HtmlParentTag} from "tuff-core/html"
9
+
10
+ const log = new Logger("Terrier PagePart")
11
+
12
+ /**
13
+ * Whether some content should be constrained to a reasonable width or span the entire screen.
14
+ */
15
+ export type ContentWidth = "normal" | "wide"
16
+
17
+ /// Top row fields
18
+
19
+ type BaseFieldDef = { name: string } & ToprowFieldDefOptions
20
+
21
+ type ToprowFieldDefOptions = {
22
+ onChangeKey?: UntypedKey,
23
+ onInputKey?: UntypedKey,
24
+ defaultValue?: string,
25
+ tooltip?: string
26
+ }
27
+
28
+ type ToprowSelectDef = { type: 'select', options: SelectOptions } & BaseFieldDef
29
+
30
+ type ValuedInputType = 'text' | 'color' | 'date' | 'datetime-local' | 'email' | 'hidden' | 'month' | 'number' | 'password' | 'search' | 'tel' | 'time' | 'url' | 'week'
31
+ type ToprowValuedInputDef = { type: ValuedInputType } & BaseFieldDef
32
+
33
+ /**
34
+ * Defines a field to be rendered in the page's top row
35
+ */
36
+ type ToprowFieldDef = ToprowSelectDef | ToprowValuedInputDef
37
+
38
+ /**
39
+ * A part that renders content to a full page.
40
+ */
41
+ export default abstract class PagePart<
42
+ TState,
43
+ TThemeType extends ThemeType,
44
+ TApp extends TerrierApp<TThemeType, TApp, TTheme>,
45
+ TTheme extends Theme<TThemeType>
46
+ > extends ContentPart<TState, TThemeType, TApp, TTheme> {
47
+
48
+ /// Content Width
49
+
50
+ /**
51
+ * Whether the main content should be constrained to a reasonable width (default) or span the entire screen.
52
+ */
53
+ protected mainContentWidth: ContentWidth = "normal"
54
+
55
+ /// Breadcrumbs
56
+
57
+ private _breadcrumbs = Array<Action<TThemeType>>()
58
+
59
+ addBreadcrumb(crumb: Action<TThemeType>) {
60
+ this._breadcrumbs.push(crumb)
61
+ }
62
+
63
+ private _titleHref?: string
64
+
65
+ /**
66
+ * Adds an href to the title (last) breadcrumb.
67
+ * @param href
68
+ */
69
+ setTitleHref(href: string) {
70
+ this._titleHref = href
71
+ }
72
+
73
+ /// Top Bar Fields
74
+
75
+ private _toprowFieldsOrder: string[] = []
76
+ private _toprowFields: Record<string, ToprowFieldDef> = {}
77
+
78
+ protected get hasToprowFields() {
79
+ return this._toprowFieldsOrder.length > 0
80
+ }
81
+
82
+ /**
83
+ * Adds a select to the toolbar with the given options.
84
+ * @param name the name of the select
85
+ * @param selectOptions an array of select options
86
+ * @param opts
87
+ */
88
+ addToprowSelect(name: string, selectOptions: SelectOptions, opts?: ToprowFieldDefOptions) {
89
+ this.addToprowFieldDef({ type: 'select', name, options: selectOptions, ...opts })
90
+ }
91
+
92
+ /**
93
+ * Adds a select to the toolbar with the given options.
94
+ * @param name the name of the select
95
+ * @param type the type attribute of the input field
96
+ * @param opts
97
+ */
98
+ addToprowInput(name: string, type: ValuedInputType, opts?: ToprowFieldDefOptions) {
99
+ this.addToprowFieldDef({ type, name, ...opts })
100
+ }
101
+
102
+ private addToprowFieldDef(def: ToprowFieldDef) {
103
+ this._toprowFieldsOrder.push(def.name)
104
+ this._toprowFields[def.name] = def
105
+ }
106
+
107
+ /// Rendering
108
+
109
+ protected get topRowClasses() : string[] {
110
+ return []
111
+ }
112
+
113
+ render(parent: PartTag) {
114
+ parent.div(`.tt-page-part.content-width-${this.mainContentWidth}`, page => {
115
+ page.div('.tt-flex.top-row', topRow => {
116
+ topRow.class(...this.topRowClasses)
117
+ topRow.div('.page-title', col => this.renderBreadcrumbs(col));
118
+ topRow.div('.page-top-actions', col => {
119
+ this.renderCustomToprow(col)
120
+ if (this.hasToprowFields) this.renderToprowFields(col);
121
+ if (this.hasActions('tertiary')) this.renderActions(col, 'tertiary');
122
+ });
123
+
124
+ })
125
+
126
+ page.div('.lighting')
127
+ page.div('.full-width-page', conatiner => {
128
+ conatiner.div('.page-content', main => {
129
+ this.renderContent(main)
130
+ main.div('.page-actions', actions => {
131
+ this.renderActions(actions, 'secondary', {iconColor: null, defaultClass: 'secondary'})
132
+ this.renderActions(actions, 'primary', {iconColor: null, defaultClass: 'primary'})
133
+ })
134
+ })
135
+ })
136
+ })
137
+ }
138
+
139
+ protected renderActions(parent: PartTag, level: ActionLevel, options?: RenderActionOptions<TThemeType>) {
140
+ parent.div(`.${level}-actions`, actions => {
141
+ this.app.theme.renderActions(actions, this.getActions(level), options)
142
+ })
143
+ }
144
+
145
+ protected renderBreadcrumbs(parent: PartTag) {
146
+ if (!this._breadcrumbs.length && !this._title?.length) return
147
+
148
+ parent.h1('.breadcrumbs', h1 => {
149
+ const crumbs = Array.from(this._breadcrumbs)
150
+
151
+ // add a breadcrumb for the page title
152
+ const titleCrumb: Action<TThemeType> = {
153
+ title: this._title,
154
+ icon: this._icon || undefined,
155
+ }
156
+ if (this._titleHref) {
157
+ titleCrumb.href = this._titleHref
158
+ }
159
+ if (this._titleClasses?.length) {
160
+ titleCrumb.classes = this._titleClasses
161
+ }
162
+ crumbs.push(titleCrumb)
163
+
164
+ this.app.theme.renderActions(h1, crumbs)
165
+ })
166
+ }
167
+
168
+ protected renderCustomToprow(_parent: PartTag): void {
169
+
170
+ }
171
+
172
+ protected renderToprowFields(parent: PartTag) {
173
+ parent.div('.fields.tt-flex.align-center.small-gap', fields => {
174
+ for (const name of this._toprowFieldsOrder) {
175
+ const def = this._toprowFields[name]
176
+ if (!def) {
177
+ log.warn(`No select def with name ${name} could be found!`)
178
+ continue;
179
+ }
180
+
181
+ let field!: HtmlParentTag
182
+ if (def.type === 'select') {
183
+ field = fields.select({name: def.name}, select => {
184
+ optionsForSelect(select, def.options, def.defaultValue)
185
+ })
186
+ } else {
187
+ field = fields.input({name: def.name, type: def.type})
188
+ }
189
+
190
+ if (def.onChangeKey) field.emitChange(def.onChangeKey)
191
+ if (def.onInputKey) field.emitInput(def.onInputKey)
192
+ if (def.tooltip?.length) field.dataAttr('tooltip', def.tooltip)
193
+ }
194
+ })
195
+ }
196
+ }
@@ -0,0 +1,47 @@
1
+ import Theme, {ThemeType} from "../theme"
2
+ import {TerrierApp} from "../app"
3
+ import ContentPart from "./content-part"
4
+ import {PartTag} from "tuff-core/parts"
5
+ import Fragments from "../fragments"
6
+
7
+ /**
8
+ * A part that renders content inside a panel.
9
+ */
10
+ export default abstract class PanelPart<
11
+ TState,
12
+ TThemeType extends ThemeType,
13
+ TApp extends TerrierApp<TThemeType, TApp, TTheme>,
14
+ TTheme extends Theme<TThemeType>
15
+ > extends ContentPart<TState, TThemeType, TApp, TTheme> {
16
+
17
+ getLoadingContainer() {
18
+ return this.element?.getElementsByClassName('tt-panel')[0]
19
+ }
20
+
21
+ protected get panelClasses(): string[] {
22
+ return []
23
+ }
24
+
25
+ render(parent: PartTag) {
26
+ parent.div('.tt-panel', panel => {
27
+ panel.class(...this.panelClasses)
28
+ if (this._title?.length || this.hasActions('tertiary')) {
29
+ panel.div('.panel-header', header => {
30
+ header.h2(h2 => {
31
+ if (this._icon) {
32
+ this.app.theme.renderIcon(h2, this._icon, 'link')
33
+ }
34
+ h2.div('.title', {text: this._title || 'Call setTitle()'})
35
+ })
36
+ header.div('.tertiary-actions', actions => {
37
+ this.theme.renderActions(actions, this.getActions('tertiary'))
38
+ })
39
+ })
40
+ }
41
+ panel.div('.panel-content', content => {
42
+ this.renderContent(content)
43
+ })
44
+ Fragments.panelActions(panel, this.getAllActions(), this.theme)
45
+ })
46
+ }
47
+ }
@@ -0,0 +1,94 @@
1
+ import {Part} from "tuff-core/parts"
2
+ import {TerrierApp} from "../app"
3
+ import Loading from "../loading"
4
+ import Theme, {ThemeType} from "../theme"
5
+ import Toasts, {ToastOptions} from "../toasts"
6
+
7
+ /**
8
+ * Base class for ALL parts in a Terrier application.
9
+ */
10
+ export default abstract class TerrierPart<
11
+ TState,
12
+ TThemeType extends ThemeType,
13
+ TApp extends TerrierApp<TThemeType, TApp, TTheme>,
14
+ TTheme extends Theme<TThemeType>
15
+ > extends Part<TState> {
16
+
17
+ get app(): TApp {
18
+ return this.root as TApp // this should always be true
19
+ }
20
+
21
+ get theme(): TTheme {
22
+ return this.app.theme
23
+ }
24
+
25
+ /// Loading
26
+
27
+ /**
28
+ * This can be overloaded if the loading overlay should go
29
+ * somewhere other than the part's root element.
30
+ */
31
+ getLoadingContainer(): Element | null | undefined {
32
+ return this.element
33
+ }
34
+
35
+
36
+ /**
37
+ * Shows the loading animation on top of the part.
38
+ */
39
+ startLoading() {
40
+ const elem = this.getLoadingContainer()
41
+ if (!elem) {
42
+ return
43
+ }
44
+ Loading.showOverlay(elem, this.theme)
45
+ }
46
+
47
+ /**
48
+ * Removes the loading animation from the part.
49
+ */
50
+ stopLoading() {
51
+ const elem = this.getLoadingContainer()
52
+ if (!elem) {
53
+ return
54
+ }
55
+ Loading.removeOverlay(elem)
56
+ }
57
+
58
+ /**
59
+ * Shows the loading overlay until the given function completes (either returns successfully or throws an exception)
60
+ * @param func
61
+ */
62
+ showLoading(func: () => void): void
63
+ showLoading(func: () => Promise<void>): Promise<void>
64
+ showLoading(func: () => void | Promise<void>): void | Promise<void> {
65
+ this.startLoading()
66
+ let stopImmediately = true
67
+ try {
68
+ const res = func()
69
+ if (res) {
70
+ stopImmediately = false
71
+ return res.finally(() => {
72
+ this.stopLoading()
73
+ })
74
+ }
75
+ } finally {
76
+ if (stopImmediately) {
77
+ this.stopLoading()
78
+ }
79
+ }
80
+ }
81
+
82
+
83
+ /// Toasts
84
+
85
+ /**
86
+ * Shows a toast message in a bubble in the upper right corner.
87
+ * @param message the message text
88
+ * @param options
89
+ */
90
+ showToast(message: string, options: ToastOptions<TThemeType>) {
91
+ Toasts.show(message, options, this.theme)
92
+ }
93
+
94
+ }
@@ -0,0 +1,25 @@
1
+ import {FormPart, FormPartData} from "tuff-core/forms"
2
+ import Theme, {ThemeType} from "../theme"
3
+ import {TerrierApp} from "../app"
4
+
5
+ export default abstract class ThemedFormPart<
6
+ TState extends FormPartData,
7
+ TThemeType extends ThemeType,
8
+ TApp extends TerrierApp<TThemeType, TApp, TTheme>,
9
+ TTheme extends Theme<TThemeType>
10
+ > extends FormPart<TState> {
11
+
12
+
13
+ get app(): TApp {
14
+ return this.root as TApp // this should always be true
15
+ }
16
+
17
+ get theme(): TTheme {
18
+ return this.app.theme
19
+ }
20
+
21
+
22
+ get parentClasses(): Array<string> {
23
+ return ['tt-form']
24
+ }
25
+ }
package/schema.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import Api from "./api"
2
+ import inflection from "inflection";
2
3
 
3
4
  ////////////////////////////////////////////////////////////////////////////////
4
5
  // Schema Definitions
@@ -60,8 +61,34 @@ async function get(): Promise<SchemaDef> {
60
61
  return res.schema
61
62
  }
62
63
 
64
+
65
+ ////////////////////////////////////////////////////////////////////////////////
66
+ // Utilities
67
+ ////////////////////////////////////////////////////////////////////////////////
68
+
69
+ /**
70
+ * Generated a string used to display a `BelongsToDef` to the user.
71
+ * If the name differs from the model, the name will be included in parentheses.
72
+ * @param belongsTo
73
+ */
74
+ function belongsToDisplay(belongsTo: BelongsToDef): string {
75
+ if (belongsTo.name != inflection.singularize(inflection.tableize(belongsTo.model))) {
76
+ // the model is different than the name of the association
77
+ return `${belongsTo.model} (${belongsTo.name})`
78
+ }
79
+ else {
80
+ return belongsTo.model
81
+ }
82
+ }
83
+
84
+
85
+ ////////////////////////////////////////////////////////////////////////////////
86
+ // Export
87
+ ////////////////////////////////////////////////////////////////////////////////
88
+
63
89
  const Schema = {
64
- get
90
+ get,
91
+ belongsToDisplay
65
92
  }
66
93
 
67
94
  export default Schema
package/toasts.ts CHANGED
@@ -23,7 +23,7 @@ function show<TT extends ThemeType>(message: string, options: ToastOptions<TT>,
23
23
  if (!container) {
24
24
  log.debug(`Creating toasts container`)
25
25
  container = createElement('div', (div) => {
26
- div.sel('#tt-toasts.flex.column.padded')
26
+ div.sel('#tt-toasts.tt-flex.column.padded')
27
27
  })
28
28
  document.body.appendChild(container)
29
29
  }