terrier-engine 4.9.1 → 4.10.3
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/package.json +1 -1
- package/terrier/list-viewer.ts +212 -78
- package/terrier/parts/page-part.ts +10 -0
package/package.json
CHANGED
package/terrier/list-viewer.ts
CHANGED
|
@@ -1,23 +1,166 @@
|
|
|
1
1
|
import TerrierPart from "./parts/terrier-part"
|
|
2
|
-
import {PartTag} from "tuff-core/parts"
|
|
2
|
+
import {Part, PartConstructor, PartTag, StatelessPart} from "tuff-core/parts"
|
|
3
3
|
import Messages from "tuff-core/messages"
|
|
4
4
|
import {Logger} from "tuff-core/logging"
|
|
5
|
-
import
|
|
5
|
+
import {PageBreakpoints} from "./parts/page-part"
|
|
6
6
|
|
|
7
7
|
const log = new Logger('List Viewer')
|
|
8
|
+
Logger.level = 'debug'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Optional values to return from rendering a list item that control
|
|
12
|
+
* its rendering behavior.
|
|
13
|
+
*/
|
|
14
|
+
export type ListItemRenderOptions = {
|
|
15
|
+
style?: 'panel' | 'header'
|
|
16
|
+
clickable?: boolean
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* All list items should have an `id` value so that we can distinguish them.
|
|
21
|
+
*/
|
|
22
|
+
export type ListItem = {id: string}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* One of these gets created for each item in the list.
|
|
26
|
+
*/
|
|
27
|
+
class ListItemPart<T extends ListItem> extends Part<T> {
|
|
28
|
+
|
|
29
|
+
viewer!: ListViewerPart<T>
|
|
30
|
+
|
|
31
|
+
render(parent: PartTag) {
|
|
32
|
+
const isCurrent = this.viewer.detailsContext?.id == this.state.id
|
|
33
|
+
|
|
34
|
+
parent.div('.tt-list-viewer-item', itemView => {
|
|
35
|
+
const opts = this.viewer.renderListItem(itemView, this.state)
|
|
36
|
+
const style = opts?.style || 'panel'
|
|
37
|
+
itemView.class(style)
|
|
38
|
+
if (opts?.clickable) {
|
|
39
|
+
itemView.class('clickable')
|
|
40
|
+
itemView.emitClick(this.viewer.itemClickedKey, {id: this.state.id})
|
|
41
|
+
}
|
|
42
|
+
if (isCurrent) {
|
|
43
|
+
itemView.class('current')
|
|
44
|
+
}
|
|
45
|
+
}).id(`item-${this.state.id}`)
|
|
46
|
+
|
|
47
|
+
// render the details if this is the current item and it's supposed to be rendered inline
|
|
48
|
+
if (isCurrent && this.viewer.layout == 'inline') {
|
|
49
|
+
log.debug(`ListItemPart: rendering inline item details`)
|
|
50
|
+
if (this.viewer.currentDetailsPart) {
|
|
51
|
+
parent.part(this.viewer.currentDetailsPart)
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Allows ListViewerPart subclasses to either directly render content for
|
|
60
|
+
* the list details or make a part to do it.
|
|
61
|
+
*/
|
|
62
|
+
export class ListViewerDetailsContext<T extends ListItem> {
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The item id for which this context is representing the details.
|
|
66
|
+
*/
|
|
67
|
+
get id(): string {
|
|
68
|
+
return this.item.id
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The part created by `makePart()` to render the details.
|
|
73
|
+
*/
|
|
74
|
+
part?: StatelessPart
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The part representing this item in the list.
|
|
78
|
+
*/
|
|
79
|
+
itemPart?: ListItemPart<T>
|
|
80
|
+
|
|
81
|
+
constructor(readonly viewer: ListViewerPart<T>, readonly item: T) {
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Make a part to render the details for the list item.
|
|
86
|
+
* @param partType
|
|
87
|
+
* @param state
|
|
88
|
+
*/
|
|
89
|
+
makePart<PartType extends Part<StateType>, StateType>(partType: PartConstructor<PartType, StateType>, state: StateType) {
|
|
90
|
+
if (this.viewer.layout == 'side') {
|
|
91
|
+
this.part = this.viewer.sideContainerPart.makePart(partType, state)
|
|
92
|
+
}
|
|
93
|
+
else {
|
|
94
|
+
this.part = this.viewer.currentItemPart?.makePart(partType, state)
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Ensure that the rendered part is disposed and the related item part is marked dirty.
|
|
100
|
+
*/
|
|
101
|
+
clear() {
|
|
102
|
+
if (this.part) {
|
|
103
|
+
this.viewer.removeChild(this.part)
|
|
104
|
+
this.part = undefined
|
|
105
|
+
}
|
|
106
|
+
if (this.itemPart) {
|
|
107
|
+
this.itemPart.dirty()
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* This part sits permanently on the side and will render the details if
|
|
114
|
+
* the viewer's detailsLocation = 'side'.
|
|
115
|
+
*/
|
|
116
|
+
class SideContainerPart extends Part<{viewer: ListViewerPart<any>}> {
|
|
117
|
+
|
|
118
|
+
viewer!: ListViewerPart<any>
|
|
119
|
+
|
|
120
|
+
async init() {
|
|
121
|
+
this.viewer = this.state.viewer
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
get parentClasses(): Array<string> {
|
|
125
|
+
return ['tt-list-viewer-side-details']
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
render(parent: PartTag) {
|
|
129
|
+
if (this.viewer.currentDetailsPart) {
|
|
130
|
+
log.debug(`[SideContainerPart] Rendering details part`, this.viewer.currentDetailsPart)
|
|
131
|
+
parent.part(this.viewer.currentDetailsPart)
|
|
132
|
+
}
|
|
133
|
+
else {
|
|
134
|
+
log.debug(`[SideContainerPart] No details part to render`)
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
}
|
|
8
139
|
|
|
9
|
-
const detailsSelector = '.tt-list-viewer-details'
|
|
10
140
|
|
|
11
141
|
/**
|
|
12
142
|
* Part for viewing a list of items and the details associated with them.
|
|
13
143
|
* Each item must have an `id` so that they can be distinguished.
|
|
14
144
|
*/
|
|
15
|
-
export abstract class ListViewerPart<T extends
|
|
145
|
+
export abstract class ListViewerPart<T extends ListItem> extends TerrierPart<any> {
|
|
146
|
+
|
|
147
|
+
sideContainerPart!: SideContainerPart
|
|
148
|
+
|
|
149
|
+
detailsContext?: ListViewerDetailsContext<T>
|
|
150
|
+
layout: 'inline' | 'side' = 'side'
|
|
151
|
+
|
|
152
|
+
get currentDetailsPart(): StatelessPart | undefined {
|
|
153
|
+
return this.detailsContext?.part
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
get currentItemPart(): StatelessPart | undefined {
|
|
157
|
+
return this.detailsContext?.itemPart
|
|
158
|
+
}
|
|
16
159
|
|
|
17
160
|
items: T[] = []
|
|
18
|
-
|
|
161
|
+
itemPartMap: Record<string, ListItemPart<T>> = {}
|
|
19
162
|
|
|
20
|
-
itemClickedKey = Messages.typedKey<
|
|
163
|
+
itemClickedKey = Messages.typedKey<ListItem>()
|
|
21
164
|
|
|
22
165
|
/**
|
|
23
166
|
* A message with this key gets emitted whenever the details are shown.
|
|
@@ -27,10 +170,12 @@ export abstract class ListViewerPart<T extends {id: string}> extends TerrierPart
|
|
|
27
170
|
async init() {
|
|
28
171
|
await super.init()
|
|
29
172
|
|
|
173
|
+
this.sideContainerPart = this.makePart(SideContainerPart, {viewer: this})
|
|
174
|
+
|
|
30
175
|
await this.reload()
|
|
31
176
|
|
|
32
177
|
this.onClick(this.itemClickedKey, m => {
|
|
33
|
-
log.
|
|
178
|
+
log.debug(`Clicked on list item ${m.data.id}`, m)
|
|
34
179
|
this.showDetails(m.data.id)
|
|
35
180
|
})
|
|
36
181
|
}
|
|
@@ -38,13 +183,36 @@ export abstract class ListViewerPart<T extends {id: string}> extends TerrierPart
|
|
|
38
183
|
|
|
39
184
|
/// Fetching
|
|
40
185
|
|
|
186
|
+
/**
|
|
187
|
+
* Subclasses must implement this to provide a list of items to render.
|
|
188
|
+
*/
|
|
41
189
|
abstract fetchItems(): Promise<T[]>
|
|
42
190
|
|
|
191
|
+
/**
|
|
192
|
+
* Fetches the items with `fetchItems()` and re-renders the list.
|
|
193
|
+
*/
|
|
43
194
|
async reload() {
|
|
44
195
|
this.items = await this.fetchItems()
|
|
45
|
-
this.items.
|
|
46
|
-
|
|
196
|
+
this.assignCollection('items', ListItemPart, this.items)
|
|
197
|
+
this.getCollectionParts('items').forEach(itemPart => {
|
|
198
|
+
// HACK
|
|
199
|
+
(itemPart as ListItemPart<T>).viewer = this
|
|
200
|
+
// log.debug(`Adding item part ${itemPart.state.id}`, itemPart)
|
|
201
|
+
this.itemPartMap[itemPart.state.id] = (itemPart as ListItemPart<T>)
|
|
47
202
|
})
|
|
203
|
+
this.relayout()
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Determine whether the details should be shown inline with the list or
|
|
208
|
+
* off to the side, based on screen size.
|
|
209
|
+
*/
|
|
210
|
+
relayout() {
|
|
211
|
+
if (window.innerWidth > PageBreakpoints.phone) {
|
|
212
|
+
this.layout = 'side'
|
|
213
|
+
} else {
|
|
214
|
+
this.layout = 'inline'
|
|
215
|
+
}
|
|
48
216
|
this.dirty()
|
|
49
217
|
}
|
|
50
218
|
|
|
@@ -56,91 +224,57 @@ export abstract class ListViewerPart<T extends {id: string}> extends TerrierPart
|
|
|
56
224
|
}
|
|
57
225
|
|
|
58
226
|
render(parent: PartTag): any {
|
|
227
|
+
log.debug(`Rendering the viewer`)
|
|
59
228
|
parent.div('.tt-list-viewer-list', list => {
|
|
60
|
-
|
|
61
|
-
list.a('.tt-list-viewer-item', {id: `item-${item.id}`}, itemView => {
|
|
62
|
-
this.renderListItem(itemView, item)
|
|
63
|
-
}).emitClick(this.itemClickedKey, {id: item.id})
|
|
64
|
-
}
|
|
65
|
-
})
|
|
66
|
-
parent.div('.tt-list-viewer-details-container', detailsView => {
|
|
67
|
-
detailsView.div(detailsSelector)
|
|
229
|
+
this.renderCollection(list, 'items')
|
|
68
230
|
})
|
|
231
|
+
if (this.layout == 'side') {
|
|
232
|
+
log.debug(`Rendering sideContainerPart inside the viewer`)
|
|
233
|
+
parent.part(this.sideContainerPart)
|
|
234
|
+
}
|
|
69
235
|
}
|
|
70
|
-
abstract renderListItem(parent: PartTag, item: T): any
|
|
71
|
-
|
|
72
|
-
abstract renderItemDetail(parent: PartTag, item: T): any
|
|
73
236
|
|
|
237
|
+
/**
|
|
238
|
+
* Subclasses must override this to render the content of an item.
|
|
239
|
+
* @param parent
|
|
240
|
+
* @param item
|
|
241
|
+
*/
|
|
242
|
+
abstract renderListItem(parent: PartTag, item: T): ListItemRenderOptions | void
|
|
74
243
|
|
|
75
|
-
|
|
244
|
+
/**
|
|
245
|
+
* Subclasses must implement this to render an item details or provide a part to do so.
|
|
246
|
+
* @param context
|
|
247
|
+
*/
|
|
248
|
+
abstract renderDetails(context: ListViewerDetailsContext<T>): any
|
|
76
249
|
|
|
77
|
-
private setCurrent(id: string) {
|
|
78
|
-
// clear any existing current item
|
|
79
|
-
const existingCurrents = this.element!.querySelectorAll('.tt-list-viewer-list a.current')
|
|
80
|
-
existingCurrents.forEach((elem) => {
|
|
81
|
-
elem.classList.remove('current')
|
|
82
|
-
})
|
|
83
250
|
|
|
84
|
-
|
|
85
|
-
const itemView = this.element!.querySelector(`#item-${id}`)
|
|
86
|
-
if (itemView) {
|
|
87
|
-
itemView.classList.add('current')
|
|
88
|
-
}
|
|
89
|
-
}
|
|
251
|
+
// Details
|
|
90
252
|
|
|
91
253
|
/**
|
|
92
254
|
* Show the details view for the item with the given id
|
|
93
255
|
* @param id
|
|
94
256
|
*/
|
|
95
257
|
showDetails(id: string) {
|
|
96
|
-
this.
|
|
97
|
-
const item = this.itemMap[id]
|
|
98
|
-
if (!item) {
|
|
99
|
-
throw `No item ${id}`
|
|
100
|
-
}
|
|
101
|
-
const container = this.element!.querySelector(detailsSelector)
|
|
102
|
-
if (container) {
|
|
103
|
-
// render the details
|
|
104
|
-
const detailsView = Html.createElement('div', div => {
|
|
105
|
-
this.renderItemDetail(div, item)
|
|
106
|
-
})
|
|
107
|
-
container.innerHTML = detailsView.innerHTML
|
|
108
|
-
this.arrangeDetails(id, container as HTMLElement)
|
|
109
|
-
|
|
110
|
-
// let the world know
|
|
111
|
-
this.emitMessage(this.detailsShownKey, {id})
|
|
112
|
-
}
|
|
113
|
-
else {
|
|
114
|
-
log.warn(`Tried to show item ${id} but there was no ${detailsSelector}`)
|
|
115
|
-
}
|
|
116
|
-
}
|
|
258
|
+
this.detailsContext?.clear()
|
|
117
259
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
*/
|
|
123
|
-
arrangeDetails(id: string, detailsView: HTMLElement) {
|
|
124
|
-
const itemView = this.element!.querySelector(`#item-${id}`)
|
|
125
|
-
if (itemView) {
|
|
126
|
-
// const listView = itemIVew.parentElement
|
|
127
|
-
log.info(`Item is ${itemView.clientWidth} wide and the window is ${window.innerWidth} wide`)
|
|
128
|
-
// crude but effective way to determine if the list is collapsed due to the media breakpoint
|
|
129
|
-
if (itemView.clientWidth > window.innerWidth * 0.8) {
|
|
130
|
-
// move the details to right after the list item
|
|
131
|
-
itemView.after(detailsView)
|
|
132
|
-
}
|
|
133
|
-
else {
|
|
134
|
-
// move the details back to the container
|
|
135
|
-
const detailsContainer = this.element!.querySelector(`.tt-list-viewer-details-container`)
|
|
136
|
-
if (detailsContainer) {
|
|
137
|
-
detailsContainer.append(detailsView)
|
|
138
|
-
}
|
|
139
|
-
}
|
|
260
|
+
const itemPart = this.itemPartMap[id]
|
|
261
|
+
if (!itemPart) {
|
|
262
|
+
log.debug(`${Object.keys(this.itemPartMap).length} item parts: `, this.itemPartMap)
|
|
263
|
+
throw `No item part ${id}`
|
|
140
264
|
}
|
|
141
|
-
|
|
142
|
-
|
|
265
|
+
|
|
266
|
+
this.detailsContext = new ListViewerDetailsContext(this, itemPart.state)
|
|
267
|
+
this.detailsContext.itemPart = itemPart
|
|
268
|
+
this.renderDetails(this.detailsContext)
|
|
269
|
+
if (this.layout == 'side') {
|
|
270
|
+
this.sideContainerPart.dirty()
|
|
143
271
|
}
|
|
272
|
+
itemPart.dirty()
|
|
273
|
+
|
|
274
|
+
// let the world know
|
|
275
|
+
this.emitMessage(this.detailsShownKey, {id})
|
|
276
|
+
|
|
144
277
|
}
|
|
145
278
|
|
|
279
|
+
|
|
146
280
|
}
|
|
@@ -12,6 +12,16 @@ const log = new Logger("Terrier PagePart")
|
|
|
12
12
|
*/
|
|
13
13
|
export type ContentWidth = "normal" | "wide" | "fill"
|
|
14
14
|
|
|
15
|
+
/**
|
|
16
|
+
* The width breakpoints for various device classes used by the styles.
|
|
17
|
+
*/
|
|
18
|
+
export const PageBreakpoints = {
|
|
19
|
+
phone: 550,
|
|
20
|
+
mobile: 850,
|
|
21
|
+
tablet: 1050,
|
|
22
|
+
fixed: 320
|
|
23
|
+
}
|
|
24
|
+
|
|
15
25
|
/// Toolbar fields
|
|
16
26
|
|
|
17
27
|
type BaseFieldDef = { name: string } & ToolbarFieldDefOptions
|