@ti-engine/web-framework 1.13.1
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/.env +4 -0
- package/CHANGELOG.md +274 -0
- package/README.md +26 -0
- package/bin/build/post-install.js +18 -0
- package/bin/localization/web-server-labels.json +28 -0
- package/bin/static/.well-known/appspecific/com.chrome.devtools.json +6 -0
- package/bin/static/favicon.ico +0 -0
- package/bin/static/fragments/components/component-notification-bar.html +21 -0
- package/bin/static/fragments/components/component-sidebar-flyout.html +37 -0
- package/bin/static/fragments/components/component-sidebar.html +33 -0
- package/bin/static/fragments/components/component-tooltip.html +11 -0
- package/bin/static/fragments/components/component-topbar.html +6 -0
- package/bin/static/fragments/frame-administration.html +3 -0
- package/bin/static/fragments/frame-application.html +19 -0
- package/bin/static/fragments/frame-dashboard.html +3 -0
- package/bin/static/fragments/frame-login.html +105 -0
- package/bin/static/fragments/frame-not-found.html +3 -0
- package/bin/static/fragments/frame-profile.html +3 -0
- package/bin/static/index.html +23 -0
- package/bin/static/scripts/lib/alpinejs-csp.min.js +7 -0
- package/bin/static/scripts/lib/htmx.min.js +1 -0
- package/bin/static/scripts/lib/safe-nonce.min.js +1 -0
- package/bin/static/scripts/ti-charts.js +1591 -0
- package/bin/static/scripts/ti-framework.css +3195 -0
- package/bin/static/scripts/ti-framework.js +1427 -0
- package/bin/static/scripts/ti-theme-black-glass.css +216 -0
- package/bin/static/scripts/ti-theme-daylight.css +87 -0
- package/bin/web-app-manager.js +564 -0
- package/bin/web-server.js +604 -0
- package/bin/web-server.json +49 -0
- package/components/admin-config-handlers.js +92 -0
- package/components/auth-manager.js +344 -0
- package/components/authorization.js +135 -0
- package/components/config-change-notifier.js +98 -0
- package/components/config-registry.js +246 -0
- package/components/config-service.js +349 -0
- package/components/config-store.js +246 -0
- package/components/definitions.types.js +26 -0
- package/components/session-store.js +111 -0
- package/components/user.js +133 -0
- package/components/web-handlers.js +765 -0
- package/package.json +66 -0
|
@@ -0,0 +1,1427 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* The ti-engine is an open source, free to use—both for personal and commercial projects—framework for the creation of microservice-based solutions using node.js.
|
|
3
|
+
* Copyright © 2021-2026 Boris Kostadinov <kostadinov.boris@gmail.com>
|
|
4
|
+
* This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
|
|
5
|
+
* This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
|
|
6
|
+
* You should have received a copy of the GNU General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @typedef {Object} SidebarFlyoutConfig
|
|
11
|
+
* @property {string} menuTitle
|
|
12
|
+
* @property {number} [offset]
|
|
13
|
+
* @property {string} [icon]
|
|
14
|
+
* @property {string} [placement]
|
|
15
|
+
* @property {boolean} [fixed]
|
|
16
|
+
* @property {Array<SidebarFlyoutButtonConfig>} [buttonConfigs]
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @typedef {Object} SidebarFlyoutButtonConfig
|
|
21
|
+
* @property {string} title
|
|
22
|
+
* @property {string} icon
|
|
23
|
+
* @property {Object} action
|
|
24
|
+
* @property {string} action.href
|
|
25
|
+
* @property {string} [action.method]
|
|
26
|
+
* @property {string} action.target
|
|
27
|
+
* @property {string} action.swap
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Default configuration applied as a base when an application-supplied flyout config is merged into the store.
|
|
32
|
+
*
|
|
33
|
+
* @constant
|
|
34
|
+
* @type {SidebarFlyoutConfig}
|
|
35
|
+
*/
|
|
36
|
+
const defaultSidebarFlyoutConfig = {
|
|
37
|
+
menuTitle: "Menu",
|
|
38
|
+
placement: "right-start",
|
|
39
|
+
offset: 10,
|
|
40
|
+
fixed: true,
|
|
41
|
+
buttonConfigs: []
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Returns a configuration object for the toolbox.
|
|
46
|
+
*
|
|
47
|
+
* @method
|
|
48
|
+
* @returns {Object}
|
|
49
|
+
* @public
|
|
50
|
+
*/
|
|
51
|
+
const configureToolbox = () => {
|
|
52
|
+
/**
|
|
53
|
+
* @typedef {Object} TiToolbox
|
|
54
|
+
*/
|
|
55
|
+
return {
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Used to clamp a position to a box.
|
|
59
|
+
*
|
|
60
|
+
* @method
|
|
61
|
+
* @param {number} x
|
|
62
|
+
* @param {number} y
|
|
63
|
+
* @param {number} w
|
|
64
|
+
* @param {number} h
|
|
65
|
+
* @param {Object} box
|
|
66
|
+
* @param {number} [edgePadding=0]
|
|
67
|
+
* @returns {{x: number, y: number}}
|
|
68
|
+
* @public
|
|
69
|
+
*/
|
|
70
|
+
clampToBox( x, y, w, h, box, edgePadding = 0 ) {
|
|
71
|
+
// If the box has an offset (visualViewport on some platforms), normalize appropriately.
|
|
72
|
+
// For fixed elements, x/y are in viewport coordinates; for absolute, in page coordinates.
|
|
73
|
+
const minX = box.left + edgePadding;
|
|
74
|
+
const minY = box.top + edgePadding;
|
|
75
|
+
const maxX = box.left + box.width - w - edgePadding;
|
|
76
|
+
const maxY = box.top + box.height - h - edgePadding;
|
|
77
|
+
return {
|
|
78
|
+
x: Math.min( Math.max( x, minX ), Math.max( minX, maxX ) ),
|
|
79
|
+
y: Math.min( Math.max( y, minY ), Math.max( minY, maxY ) )
|
|
80
|
+
};
|
|
81
|
+
},
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Used to deep-freeze an object.
|
|
85
|
+
*
|
|
86
|
+
* @method
|
|
87
|
+
* @param {Object} object
|
|
88
|
+
* @param {WeakSet} [seen]
|
|
89
|
+
* @returns {Object}
|
|
90
|
+
* @public
|
|
91
|
+
*/
|
|
92
|
+
deepFreeze( object, seen = new WeakSet() ) {
|
|
93
|
+
if ( object === null || typeof object !== "object" || seen.has( object ) ) {
|
|
94
|
+
return object;
|
|
95
|
+
} else {
|
|
96
|
+
seen.add( object );
|
|
97
|
+
Object.keys( object ).forEach( ( key ) => {
|
|
98
|
+
this.deepFreeze( object[ key ], seen );
|
|
99
|
+
} );
|
|
100
|
+
return Object.freeze( object );
|
|
101
|
+
}
|
|
102
|
+
},
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Used to perform a deep merge of two objects. 'base' is the object that will be modified.
|
|
106
|
+
* <br/>
|
|
107
|
+
* NOTE: If 'structuredClone' is not available, fall back to JSON.parse/JSON.stringify. The later will not preserve non-JSON-serializable
|
|
108
|
+
* values (functions, undefined, symbols) and will convert Dates to strings, RegExp to empty objects, etc.
|
|
109
|
+
*
|
|
110
|
+
* @method
|
|
111
|
+
* @param {Object} base
|
|
112
|
+
* @param {Object} source
|
|
113
|
+
* @returns {Object}
|
|
114
|
+
* @public
|
|
115
|
+
*/
|
|
116
|
+
deepMerge( base, source ) {
|
|
117
|
+
if ( !this.isPlainObject( base ) || !this.isPlainObject( source ) ) {
|
|
118
|
+
return this.structuredClone( source );
|
|
119
|
+
} else {
|
|
120
|
+
const out = { ...base };
|
|
121
|
+
for ( const key of Object.keys( source ) ) {
|
|
122
|
+
const b = base[ key ];
|
|
123
|
+
const s = source[ key ];
|
|
124
|
+
|
|
125
|
+
if ( Array.isArray( s ) ) {
|
|
126
|
+
out[ key ] = s.slice();
|
|
127
|
+
} else if ( this.isPlainObject( s ) && this.isPlainObject( b ) ) {
|
|
128
|
+
out[ key ] = this.deepMerge( b, s );
|
|
129
|
+
} else if ( this.isPlainObject( s ) ) {
|
|
130
|
+
out[ key ] = this.deepMerge( {}, s );
|
|
131
|
+
} else {
|
|
132
|
+
out[ key ] = s;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return out;
|
|
136
|
+
}
|
|
137
|
+
},
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Used to format a system string date value into a display string.
|
|
141
|
+
*
|
|
142
|
+
* @method
|
|
143
|
+
* @param {string} value
|
|
144
|
+
* @param {string} placeholder
|
|
145
|
+
* @returns {string}
|
|
146
|
+
* @public
|
|
147
|
+
*/
|
|
148
|
+
formatDate( value, placeholder = "" ) {
|
|
149
|
+
if ( !value ) return placeholder;
|
|
150
|
+
const normalized = /^\d{4}-\d{2}-\d{2}$/.test( value )
|
|
151
|
+
? `${ value }T00:00:00`
|
|
152
|
+
: value;
|
|
153
|
+
const date = new Date( normalized );
|
|
154
|
+
return this.isValidDate( date ) ? date.toLocaleDateString() : placeholder;
|
|
155
|
+
},
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Used to get a cookie value by name.
|
|
159
|
+
*
|
|
160
|
+
* @method
|
|
161
|
+
* @param {string} name
|
|
162
|
+
* @returns {string}
|
|
163
|
+
* @public
|
|
164
|
+
*/
|
|
165
|
+
getCookie( name ) {
|
|
166
|
+
const cookie = document.cookie.match( new RegExp( "(?:^|; )" + name.replace( /[$()*+.?[\]\\^{}|]/g, "\\$&" ) + "=([^;]*)" ) );
|
|
167
|
+
return cookie ? decodeURIComponent( cookie[ 1 ] ) : "";
|
|
168
|
+
},
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Used to get the Monday of the week for a given date.
|
|
172
|
+
*
|
|
173
|
+
* @method
|
|
174
|
+
* @param {Date} date
|
|
175
|
+
* @returns {Date}
|
|
176
|
+
* @public
|
|
177
|
+
*/
|
|
178
|
+
getMonday( date ) {
|
|
179
|
+
const d = new Date( date );
|
|
180
|
+
const day = d.getDay();
|
|
181
|
+
d.setDate( d.getDate() + ( day === 0 ? -6 : 1 - day ) );
|
|
182
|
+
d.setHours( 0, 0, 0, 0 );
|
|
183
|
+
return d;
|
|
184
|
+
},
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Used to get a URL query parameter value by name.
|
|
188
|
+
*
|
|
189
|
+
* @method
|
|
190
|
+
* @param {string} name
|
|
191
|
+
* @returns {string|null}
|
|
192
|
+
* @public
|
|
193
|
+
*/
|
|
194
|
+
getUrlParam( name ) {
|
|
195
|
+
return new URLSearchParams( window.location.search ).get( name );
|
|
196
|
+
},
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Used to get the visible box of the document.
|
|
200
|
+
*
|
|
201
|
+
* @method
|
|
202
|
+
* @param {boolean} isFixed
|
|
203
|
+
* @returns {Object}
|
|
204
|
+
* @public
|
|
205
|
+
*/
|
|
206
|
+
getVisibleBox( isFixed ) {
|
|
207
|
+
// Fixed coordinates are viewport-based (top/left = 0/0):
|
|
208
|
+
if ( isFixed ) {
|
|
209
|
+
if ( window.visualViewport ) {
|
|
210
|
+
const viewport = window.visualViewport;
|
|
211
|
+
return {
|
|
212
|
+
left: viewport.offsetLeft, // CSS px, where the viewport begins relative to a layout viewport
|
|
213
|
+
top: viewport.offsetTop,
|
|
214
|
+
width: viewport.width,
|
|
215
|
+
height: viewport.height,
|
|
216
|
+
pageLeft: viewport.pageLeft, // document coords
|
|
217
|
+
pageTop: viewport.pageTop
|
|
218
|
+
};
|
|
219
|
+
} else {
|
|
220
|
+
return { left: 0, top: 0, width: window.innerWidth, height: window.innerHeight, pageLeft: window.scrollX, pageTop: window.scrollY };
|
|
221
|
+
}
|
|
222
|
+
} else {
|
|
223
|
+
// Absolute coordinates are document-based (top/left = scroll position):
|
|
224
|
+
return {
|
|
225
|
+
left: window.scrollX,
|
|
226
|
+
top: window.scrollY,
|
|
227
|
+
width: document.documentElement.clientWidth,
|
|
228
|
+
height: document.documentElement.clientHeight,
|
|
229
|
+
pageLeft: window.scrollX,
|
|
230
|
+
pageTop: window.scrollY
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
},
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Used to check if a value is a plain object.
|
|
237
|
+
*
|
|
238
|
+
* @method
|
|
239
|
+
* @param {*} value
|
|
240
|
+
* @returns {boolean}
|
|
241
|
+
* @public
|
|
242
|
+
*/
|
|
243
|
+
isPlainObject( value ) {
|
|
244
|
+
return Object.prototype.toString.call( value ) === "[object Object]";
|
|
245
|
+
},
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Used to check if a value is a valid Date object.
|
|
249
|
+
*
|
|
250
|
+
* @method
|
|
251
|
+
* @param {*} value
|
|
252
|
+
* @returns {boolean}
|
|
253
|
+
* @public
|
|
254
|
+
*/
|
|
255
|
+
isValidDate( value ) {
|
|
256
|
+
return value instanceof Date && !isNaN( value );
|
|
257
|
+
},
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Used to perform a structured clone operation.
|
|
261
|
+
* <br/>
|
|
262
|
+
* NOTE: If 'structuredClone' is not available — or throws DataCloneError on a reactive Proxy
|
|
263
|
+
* (Alpine/Vue reactivity wraps assigned objects in Proxies that the browser's structured-clone
|
|
264
|
+
* algorithm can refuse) — fall back to JSON.parse/JSON.stringify. The fallback will not preserve
|
|
265
|
+
* non-JSON-serializable values (functions, undefined, symbols, Dates, RegExp).
|
|
266
|
+
*
|
|
267
|
+
* @method
|
|
268
|
+
* @param {Object} value
|
|
269
|
+
* @param {Object} [options]
|
|
270
|
+
* @returns {Object}
|
|
271
|
+
* @public
|
|
272
|
+
*/
|
|
273
|
+
structuredClone( value, options ) {
|
|
274
|
+
if ( typeof structuredClone === "function" ) {
|
|
275
|
+
try {
|
|
276
|
+
return structuredClone( value, options );
|
|
277
|
+
} catch {
|
|
278
|
+
return JSON.parse( JSON.stringify( value ) );
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
return JSON.parse( JSON.stringify( value ) );
|
|
282
|
+
},
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Used to convert a Date object to an ISO date string (YYYY-MM-DD).
|
|
286
|
+
*
|
|
287
|
+
* @method
|
|
288
|
+
* @param {Date} date
|
|
289
|
+
* @returns {string}
|
|
290
|
+
* @public
|
|
291
|
+
*/
|
|
292
|
+
toDateString( date ) {
|
|
293
|
+
const y = date.getFullYear();
|
|
294
|
+
const m = String( date.getMonth() + 1 ).padStart( 2, "0" );
|
|
295
|
+
const d = String( date.getDate() ).padStart( 2, "0" );
|
|
296
|
+
return `${ y }-${ m }-${ d }`;
|
|
297
|
+
},
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Deterministically maps a seed value (e.g. employeeID) to an HSL color string.
|
|
301
|
+
* The result is stable across sessions and consistent for the same seed.
|
|
302
|
+
*
|
|
303
|
+
* @method
|
|
304
|
+
* @param {string|number} id
|
|
305
|
+
* @param {string} name
|
|
306
|
+
* @returns {Object} HSL color string
|
|
307
|
+
* @public
|
|
308
|
+
*/
|
|
309
|
+
generateAvatarStyle( id, name ) {
|
|
310
|
+
const seed = String( id ?? "" ) + "\x00" + String( name ?? "" );
|
|
311
|
+
const djb2 = ( s ) => {
|
|
312
|
+
let h = 5381;
|
|
313
|
+
for ( let i = 0; i < s.length; i++ ) {
|
|
314
|
+
h = ( ( h << 5 ) + h + s.charCodeAt( i ) ) | 0;
|
|
315
|
+
}
|
|
316
|
+
return Math.abs( h );
|
|
317
|
+
};
|
|
318
|
+
const h0 = djb2( seed + "A" ) % 360;
|
|
319
|
+
const h1 = djb2( seed + "B" ) % 360;
|
|
320
|
+
const h2 = djb2( seed + "C" ) % 360;
|
|
321
|
+
return {
|
|
322
|
+
"--avatar-bg": `linear-gradient( 135deg, hsl( ${ h0 }, 70%, 48% ) 0%, hsl( ${ h1 }, 62%, 54% ) 50%, hsl( ${ h2 }, 65%, 44% ) 100% )`
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
};
|
|
327
|
+
};
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Returns a configuration object for the sidebar flyout component "component-sidebar-flyout.html".
|
|
331
|
+
* <br/>
|
|
332
|
+
* The component looks up its menu definition from the "tiComponentsConfig" Alpine store using the supplied
|
|
333
|
+
* configuration key. This allows each application to register its own flyout menus via the application
|
|
334
|
+
* configuration response without modifying the framework.
|
|
335
|
+
*
|
|
336
|
+
* @method
|
|
337
|
+
* @param {string} configKey Key into the "tiComponentsConfig" store identifying the flyout configuration to use.
|
|
338
|
+
* @returns {Object}
|
|
339
|
+
* @public
|
|
340
|
+
*/
|
|
341
|
+
const configureComponentSidebarFlyout = ( configKey ) => {
|
|
342
|
+
const tiToolbox = Alpine.store( "tiToolbox" );
|
|
343
|
+
const TI_EVENT_CLOSE_ALL_FLYOUT = "ti-close-all-flyout";
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* @typedef {Object} TiSidebarFlyout
|
|
347
|
+
*/
|
|
348
|
+
return {
|
|
349
|
+
configKey: configKey || "",
|
|
350
|
+
isOpen: false,
|
|
351
|
+
|
|
352
|
+
get config() {
|
|
353
|
+
const store = Alpine.store( "tiComponentsConfig" ) || {};
|
|
354
|
+
return store[ this.configKey ] || defaultSidebarFlyoutConfig;
|
|
355
|
+
},
|
|
356
|
+
get menuTitle() {
|
|
357
|
+
return this.config.menuTitle || "";
|
|
358
|
+
},
|
|
359
|
+
get placement() {
|
|
360
|
+
return this.config.placement || "right-start";
|
|
361
|
+
},
|
|
362
|
+
get side() {
|
|
363
|
+
if ( this.config.side ) return this.config.side;
|
|
364
|
+
return this.placement.split( "-" )[ 0 ] || "right";
|
|
365
|
+
},
|
|
366
|
+
get align() {
|
|
367
|
+
if ( this.config.align ) return this.config.align;
|
|
368
|
+
return this.placement.split( "-" )[ 1 ] || "start";
|
|
369
|
+
},
|
|
370
|
+
get offset() {
|
|
371
|
+
return ( typeof this.config.offset === "number" ) ? this.config.offset : 10;
|
|
372
|
+
},
|
|
373
|
+
get fixed() {
|
|
374
|
+
return ( typeof this.config.fixed === "boolean" ) ? this.config.fixed : true;
|
|
375
|
+
},
|
|
376
|
+
get icon() {
|
|
377
|
+
return this.config.icon || "";
|
|
378
|
+
},
|
|
379
|
+
get buttonConfigs() {
|
|
380
|
+
return Array.isArray( this.config.buttonConfigs ) ? this.config.buttonConfigs : [];
|
|
381
|
+
},
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Used to initialize the sidebar flyout component.
|
|
385
|
+
*
|
|
386
|
+
* @method
|
|
387
|
+
* @public
|
|
388
|
+
*/
|
|
389
|
+
init() {
|
|
390
|
+
this._reflow = this.reposition.bind( this );
|
|
391
|
+
this._close = this.close.bind( this );
|
|
392
|
+
window.addEventListener( "resize", this._reflow, { passive: true } );
|
|
393
|
+
window.addEventListener( "scroll", this._reflow, { passive: true } );
|
|
394
|
+
window.addEventListener( TI_EVENT_CLOSE_ALL_FLYOUT, this._close );
|
|
395
|
+
},
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* Used to destroy the sidebar flyout component.
|
|
399
|
+
*
|
|
400
|
+
* @method
|
|
401
|
+
* @public
|
|
402
|
+
*/
|
|
403
|
+
destroy() {
|
|
404
|
+
window.removeEventListener( "resize", this._reflow );
|
|
405
|
+
window.removeEventListener( "scroll", this._reflow );
|
|
406
|
+
window.removeEventListener( TI_EVENT_CLOSE_ALL_FLYOUT, this._close );
|
|
407
|
+
},
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* Used to toggle the sidebar flyout panel.
|
|
411
|
+
*
|
|
412
|
+
* @method
|
|
413
|
+
* @public
|
|
414
|
+
*/
|
|
415
|
+
toggle() {
|
|
416
|
+
this.isOpen ? this.close() : this.open();
|
|
417
|
+
},
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* Used to open the sidebar flyout panel.
|
|
421
|
+
*
|
|
422
|
+
* @method
|
|
423
|
+
* @public
|
|
424
|
+
*/
|
|
425
|
+
open() {
|
|
426
|
+
if ( !this.isOpen ) {
|
|
427
|
+
window.dispatchEvent( new CustomEvent( TI_EVENT_CLOSE_ALL_FLYOUT ) );
|
|
428
|
+
this.isOpen = true;
|
|
429
|
+
this.$nextTick( () => {
|
|
430
|
+
this.setAria();
|
|
431
|
+
this.reposition();
|
|
432
|
+
// The menu items bind their hx-* attributes via Alpine (x-bind), which HTMX does not pick up on its
|
|
433
|
+
// initial document scan — so without this the buttons close the flyout but never fire the request.
|
|
434
|
+
// Processing the panel attaches HTMX behaviour to the now-rendered buttons (idempotent on re-open).
|
|
435
|
+
if ( window.htmx && this.$refs.flyoutPanel ) {
|
|
436
|
+
window.htmx.process( this.$refs.flyoutPanel );
|
|
437
|
+
}
|
|
438
|
+
} );
|
|
439
|
+
}
|
|
440
|
+
},
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* Used to close the flyout panel.
|
|
444
|
+
*
|
|
445
|
+
* @method
|
|
446
|
+
* @public
|
|
447
|
+
*/
|
|
448
|
+
close() {
|
|
449
|
+
if ( this.isOpen ) {
|
|
450
|
+
this.isOpen = false;
|
|
451
|
+
this.$nextTick( () => this.setAria() );
|
|
452
|
+
}
|
|
453
|
+
},
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* Used to set the ARIA attributes for the flyout button.
|
|
457
|
+
*
|
|
458
|
+
* @method
|
|
459
|
+
* @public
|
|
460
|
+
*/
|
|
461
|
+
setAria() {
|
|
462
|
+
if ( !this.$refs.flyoutButton ) return;
|
|
463
|
+
this.$refs.flyoutButton.setAttribute( "aria-expanded", String( this.isOpen ) );
|
|
464
|
+
},
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* Used to reposition the flyout panel.
|
|
468
|
+
*
|
|
469
|
+
* @method
|
|
470
|
+
* @public
|
|
471
|
+
*/
|
|
472
|
+
reposition() {
|
|
473
|
+
if ( !this.isOpen || !this.$refs.flyoutButton || !this.$refs.flyoutPanel ) return;
|
|
474
|
+
|
|
475
|
+
const flyoutButton = this.$refs.flyoutButton;
|
|
476
|
+
const flyoutPanel = this.$refs.flyoutPanel;
|
|
477
|
+
|
|
478
|
+
const rect = flyoutButton.getBoundingClientRect();
|
|
479
|
+
const scrollX = window.scrollX;
|
|
480
|
+
const scrollY = window.scrollY;
|
|
481
|
+
|
|
482
|
+
const pw = flyoutPanel.scrollWidth;
|
|
483
|
+
const ph = flyoutPanel.scrollHeight;
|
|
484
|
+
|
|
485
|
+
let top = rect.top + ( this.fixed ? 0 : scrollY );
|
|
486
|
+
let left = rect.left + ( this.fixed ? 0 : scrollX );
|
|
487
|
+
|
|
488
|
+
const side = this.side;
|
|
489
|
+
const align = this.align;
|
|
490
|
+
if ( side === "right" ) {
|
|
491
|
+
left = rect.right + ( this.fixed ? 0 : scrollX ) + this.offset;
|
|
492
|
+
} else if ( side === "left" ) {
|
|
493
|
+
left = rect.left + ( this.fixed ? 0 : scrollX ) - pw - this.offset;
|
|
494
|
+
} else if ( side === "bottom" ) {
|
|
495
|
+
top = rect.bottom + ( this.fixed ? 0 : scrollY ) + this.offset;
|
|
496
|
+
} else if ( side === "top" ) {
|
|
497
|
+
top = rect.top + ( this.fixed ? 0 : scrollY ) - ph - this.offset;
|
|
498
|
+
}
|
|
499
|
+
// Alignment for horizontal sides (left/right) adjusts the vertical position:
|
|
500
|
+
if ( side === "left" || side === "right" ) {
|
|
501
|
+
if ( align === "start" ) {
|
|
502
|
+
top = rect.top + ( this.fixed ? 0 : scrollY );
|
|
503
|
+
} else if ( align === "center" ) {
|
|
504
|
+
top = rect.top + ( this.fixed ? 0 : scrollY ) + ( rect.height - ph ) / 2;
|
|
505
|
+
} else if ( align === "end" ) {
|
|
506
|
+
top = rect.bottom + ( this.fixed ? 0 : scrollY ) - ph;
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
// Alignment for vertical sides (top/bottom) adjusts the horizontal position:
|
|
510
|
+
else if ( side === "top" || side === "bottom" ) {
|
|
511
|
+
if ( align === "start" ) {
|
|
512
|
+
left = rect.left + ( this.fixed ? 0 : scrollX );
|
|
513
|
+
} else if ( align === "center" ) {
|
|
514
|
+
left = rect.left + ( this.fixed ? 0 : scrollX ) + ( rect.width - pw ) / 2;
|
|
515
|
+
} else if ( align === "end" ) {
|
|
516
|
+
left = rect.right + ( this.fixed ? 0 : scrollX ) - pw;
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
const box = tiToolbox.getVisibleBox( this.fixed );
|
|
521
|
+
const coords = tiToolbox.clampToBox( left, top, pw, ph, box, 10 );
|
|
522
|
+
|
|
523
|
+
flyoutPanel.style.position = this.fixed ? "fixed" : "absolute";
|
|
524
|
+
flyoutPanel.style.top = Math.round( coords.y ) + "px";
|
|
525
|
+
flyoutPanel.style.left = Math.round( coords.x ) + "px";
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
};
|
|
529
|
+
};
|
|
530
|
+
|
|
531
|
+
/**
|
|
532
|
+
* Returns a configuration object for the sidebar navigation component.
|
|
533
|
+
* The screen-to-active-key mapping is read at runtime from tiApplication.configuration.sidebarNavMapping,
|
|
534
|
+
* allowing each application to define its own mapping without changing the framework.
|
|
535
|
+
*
|
|
536
|
+
* @method
|
|
537
|
+
* @returns {Object}
|
|
538
|
+
* @public
|
|
539
|
+
*/
|
|
540
|
+
const configureSidebarNav = () => {
|
|
541
|
+
const tiApplication = Alpine.store( "tiApplication" );
|
|
542
|
+
|
|
543
|
+
const getActiveFromScreen = ( screen ) => {
|
|
544
|
+
const mapping = ( tiApplication.configuration && tiApplication.configuration.sidebarNavMapping ) || {};
|
|
545
|
+
return mapping[ screen ] || "";
|
|
546
|
+
};
|
|
547
|
+
|
|
548
|
+
const getActiveFromUrl = () => {
|
|
549
|
+
const match = window.location.pathname.match( /^\/app\/([\w-]+)/ );
|
|
550
|
+
return match ? getActiveFromScreen( match[ 1 ] ) : "";
|
|
551
|
+
};
|
|
552
|
+
|
|
553
|
+
return {
|
|
554
|
+
active: "",
|
|
555
|
+
|
|
556
|
+
init() {
|
|
557
|
+
this.$watch( () => tiApplication.isInitialized, ( isInitialized ) => {
|
|
558
|
+
if ( isInitialized ) {
|
|
559
|
+
const fromUrl = getActiveFromUrl();
|
|
560
|
+
if ( fromUrl ) {
|
|
561
|
+
this.active = fromUrl;
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
} );
|
|
565
|
+
this.$watch( () => tiApplication.currentScreen, ( screen ) => {
|
|
566
|
+
const mapped = getActiveFromScreen( screen );
|
|
567
|
+
if ( mapped ) {
|
|
568
|
+
this.active = mapped;
|
|
569
|
+
}
|
|
570
|
+
} );
|
|
571
|
+
if ( tiApplication.isInitialized ) {
|
|
572
|
+
const fromUrl = getActiveFromUrl();
|
|
573
|
+
if ( fromUrl ) {
|
|
574
|
+
this.active = fromUrl;
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
},
|
|
578
|
+
|
|
579
|
+
navigate( activeKey, screen ) {
|
|
580
|
+
this.active = activeKey;
|
|
581
|
+
tiApplication.openScreen( screen );
|
|
582
|
+
}
|
|
583
|
+
};
|
|
584
|
+
};
|
|
585
|
+
|
|
586
|
+
/**
|
|
587
|
+
* Returns a configuration object for the topbar component "component-topbar.html".
|
|
588
|
+
*
|
|
589
|
+
* @method
|
|
590
|
+
* @returns {Object}
|
|
591
|
+
* @public
|
|
592
|
+
*/
|
|
593
|
+
const configureComponentTopbar = () => {
|
|
594
|
+
/**
|
|
595
|
+
* @typedef {Object} TiTopbar
|
|
596
|
+
*/
|
|
597
|
+
return {
|
|
598
|
+
|
|
599
|
+
screenTitle: "",
|
|
600
|
+
|
|
601
|
+
init() {
|
|
602
|
+
const tiApplication = Alpine.store( "tiApplication" );
|
|
603
|
+
|
|
604
|
+
const updateTitle = () => {
|
|
605
|
+
const screen = ( tiApplication && tiApplication.currentScreen ) || "";
|
|
606
|
+
const override = ( tiApplication && tiApplication.screenTitleOverride ) || "";
|
|
607
|
+
const title = override || ( screen ? tiApplication.getLabel( `interface.topbar.${ screen }`, "" ) : "" );
|
|
608
|
+
this.screenTitle = title;
|
|
609
|
+
if ( title ) {
|
|
610
|
+
document.title = title;
|
|
611
|
+
}
|
|
612
|
+
};
|
|
613
|
+
|
|
614
|
+
// If the htmx:afterSwap listener was registered after the initial swap already fired,
|
|
615
|
+
// currentScreen won't be set yet — fall back to reading the URL that hx-push-url already updated:
|
|
616
|
+
if ( tiApplication && !tiApplication.currentScreen ) {
|
|
617
|
+
const match = window.location.pathname.match( /^\/app\/([\w-]+)/ );
|
|
618
|
+
if ( match ) {
|
|
619
|
+
tiApplication.setCurrentScreen( match[ 1 ] );
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
updateTitle();
|
|
624
|
+
this.$watch( () => tiApplication.currentScreen, updateTitle );
|
|
625
|
+
this.$watch( () => tiApplication.screenTitleOverride, updateTitle );
|
|
626
|
+
this.$watch( () => tiApplication.isInitialized, updateTitle );
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
};
|
|
630
|
+
};
|
|
631
|
+
|
|
632
|
+
/**
|
|
633
|
+
* Returns a configuration object for the notification component "component-notification-bar.html".
|
|
634
|
+
*
|
|
635
|
+
* @method
|
|
636
|
+
* @returns {Object}
|
|
637
|
+
* @public
|
|
638
|
+
*/
|
|
639
|
+
const configureComponentNotificationBar = () => {
|
|
640
|
+
/**
|
|
641
|
+
* @typedef {Object} TiNotificationBar
|
|
642
|
+
*/
|
|
643
|
+
return {
|
|
644
|
+
notifications: [],
|
|
645
|
+
timers: {},
|
|
646
|
+
|
|
647
|
+
/**
|
|
648
|
+
* Used to add a notification to the notification bar.
|
|
649
|
+
*
|
|
650
|
+
* @method
|
|
651
|
+
* @param {Object} notification
|
|
652
|
+
* @public
|
|
653
|
+
*/
|
|
654
|
+
add( notification ) {
|
|
655
|
+
if ( notification && notification.id ) {
|
|
656
|
+
this.remove( notification.id );
|
|
657
|
+
this.notifications.push( notification );
|
|
658
|
+
if ( typeof notification.timeout === "number" && notification.timeout > 0 ) {
|
|
659
|
+
this.timers[ notification.id ] = setTimeout( () => this.remove( notification.id ), notification.timeout );
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
},
|
|
663
|
+
|
|
664
|
+
/**
|
|
665
|
+
* Used to remove a notification by its ID.
|
|
666
|
+
*
|
|
667
|
+
* @method
|
|
668
|
+
* @param {string} id
|
|
669
|
+
* @public
|
|
670
|
+
*/
|
|
671
|
+
remove( id ) {
|
|
672
|
+
if ( id ) {
|
|
673
|
+
this.notifications = this.notifications.filter( notification => notification.id !== id );
|
|
674
|
+
if ( this.timers[ id ] ) {
|
|
675
|
+
clearTimeout( this.timers[ id ] );
|
|
676
|
+
delete this.timers[ id ];
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
},
|
|
680
|
+
|
|
681
|
+
/**
|
|
682
|
+
* Used to clear all notifications.
|
|
683
|
+
*
|
|
684
|
+
* @method
|
|
685
|
+
* @public
|
|
686
|
+
*/
|
|
687
|
+
destroy() {
|
|
688
|
+
Object.keys( this.timers ).forEach( ( id ) => clearTimeout( this.timers[ id ] ) );
|
|
689
|
+
this.timers = {};
|
|
690
|
+
this.notifications = [];
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
};
|
|
694
|
+
};
|
|
695
|
+
|
|
696
|
+
/**
|
|
697
|
+
* Returns a configuration object for the tooltip component "component-tooltip.html".
|
|
698
|
+
*
|
|
699
|
+
* @method
|
|
700
|
+
* @returns {Object}
|
|
701
|
+
* @public
|
|
702
|
+
*/
|
|
703
|
+
const configureComponentTooltip = () => {
|
|
704
|
+
/**
|
|
705
|
+
* @typedef {Object} TiTooltip
|
|
706
|
+
*/
|
|
707
|
+
return {
|
|
708
|
+
isVisible: false,
|
|
709
|
+
text: "This is a default tooltip. To change that, define a 'x-bind:data-ti-tooltip' attribute in the target element to set the tooltip text.",
|
|
710
|
+
|
|
711
|
+
/**
|
|
712
|
+
* Used to get the tooltip message from the target element.
|
|
713
|
+
*
|
|
714
|
+
* @method
|
|
715
|
+
* @param {HTMLElement} target
|
|
716
|
+
* @returns {string}
|
|
717
|
+
* @public
|
|
718
|
+
*/
|
|
719
|
+
getTooltipMessage( target ) {
|
|
720
|
+
if ( !target || typeof target.closest !== "function" ) return "";
|
|
721
|
+
const selector = "[data-ti-tooltip], [data-tooltip]";
|
|
722
|
+
const element = target.closest( selector );
|
|
723
|
+
if ( !element || !this.$el.contains( element ) ) return "";
|
|
724
|
+
return element.getAttribute( "data-ti-tooltip" ) || element.getAttribute( "data-tooltip" ) || "";
|
|
725
|
+
},
|
|
726
|
+
|
|
727
|
+
/**
|
|
728
|
+
* Used to handle mouse enter events on the target element.
|
|
729
|
+
*
|
|
730
|
+
* @method
|
|
731
|
+
* @param {MouseEvent} event
|
|
732
|
+
* @public
|
|
733
|
+
*/
|
|
734
|
+
handleEnter( event ) {
|
|
735
|
+
const message = this.getTooltipMessage( event?.target );
|
|
736
|
+
if ( message ) {
|
|
737
|
+
this.showTooltip( message );
|
|
738
|
+
}
|
|
739
|
+
},
|
|
740
|
+
|
|
741
|
+
/**
|
|
742
|
+
* Used to handle mouse leave events on the target element.
|
|
743
|
+
*
|
|
744
|
+
* @method
|
|
745
|
+
* @param {MouseEvent} event
|
|
746
|
+
* @public
|
|
747
|
+
*/
|
|
748
|
+
handleLeave( event ) {
|
|
749
|
+
const related = event?.relatedTarget;
|
|
750
|
+
if ( related && this.$el.contains( related ) ) return;
|
|
751
|
+
this.hideTooltip();
|
|
752
|
+
},
|
|
753
|
+
|
|
754
|
+
/**
|
|
755
|
+
* Used to show the tooltip.
|
|
756
|
+
*
|
|
757
|
+
* @method
|
|
758
|
+
* @param {string} message
|
|
759
|
+
* @public
|
|
760
|
+
*/
|
|
761
|
+
showTooltip( message ) {
|
|
762
|
+
this.text = message;
|
|
763
|
+
this.isVisible = true;
|
|
764
|
+
},
|
|
765
|
+
|
|
766
|
+
/**
|
|
767
|
+
* Used to hide the tooltip.
|
|
768
|
+
*
|
|
769
|
+
* @method
|
|
770
|
+
* @public
|
|
771
|
+
*/
|
|
772
|
+
hideTooltip() {
|
|
773
|
+
this.isVisible = false;
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
};
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
/**
|
|
780
|
+
* Returns a configuration object for the application management instance.
|
|
781
|
+
*
|
|
782
|
+
* @method
|
|
783
|
+
* @returns {Object}
|
|
784
|
+
* @public
|
|
785
|
+
*/
|
|
786
|
+
const configureApplication = () => {
|
|
787
|
+
const STORAGE_KEY_COLLAPSED = "ti-sidebar-collapsed";
|
|
788
|
+
const STORAGE_KEY_THEME = "ti-theme";
|
|
789
|
+
const DEFAULT_THEME = "daylight";
|
|
790
|
+
|
|
791
|
+
const tiToolbox = Alpine.store( "tiToolbox" );
|
|
792
|
+
|
|
793
|
+
/**
|
|
794
|
+
* Used to extract a label from a nested labels object.
|
|
795
|
+
*
|
|
796
|
+
* @method
|
|
797
|
+
* @param {Object} labels
|
|
798
|
+
* @param {String[]} keys
|
|
799
|
+
* @param {String} fallback
|
|
800
|
+
* @returns {String}
|
|
801
|
+
* @private
|
|
802
|
+
*/
|
|
803
|
+
const extractLabel = ( labels, keys, fallback ) => {
|
|
804
|
+
let key = keys.shift();
|
|
805
|
+
if ( labels && typeof labels === "object" && key && Object.prototype.hasOwnProperty.call( labels, key ) ) {
|
|
806
|
+
const value = labels[ key ];
|
|
807
|
+
if ( typeof value === "string" ) {
|
|
808
|
+
return keys.length === 0 ? value : fallback;
|
|
809
|
+
}
|
|
810
|
+
if ( value && typeof value === "object" ) {
|
|
811
|
+
return extractLabel( value, keys, fallback );
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
return fallback;
|
|
815
|
+
};
|
|
816
|
+
|
|
817
|
+
/**
|
|
818
|
+
* @typedef {Object} TiApplication
|
|
819
|
+
*/
|
|
820
|
+
return {
|
|
821
|
+
isInitialized: false,
|
|
822
|
+
user: null,
|
|
823
|
+
configuration: {},
|
|
824
|
+
currentScreen: "",
|
|
825
|
+
topbarSubtitle: "",
|
|
826
|
+
screenTitleOverride: "",
|
|
827
|
+
topbarPrimaryCta: null,
|
|
828
|
+
notificationIDCounter: 1,
|
|
829
|
+
requestControllers: new Map(),
|
|
830
|
+
collapsed: false,
|
|
831
|
+
theme: DEFAULT_THEME,
|
|
832
|
+
|
|
833
|
+
/**
|
|
834
|
+
* Used to initialize the web application.
|
|
835
|
+
*/
|
|
836
|
+
init() {
|
|
837
|
+
document.addEventListener( "ti:error", ( event ) => {
|
|
838
|
+
this.notify( this.formatException( event.detail ) );
|
|
839
|
+
} );
|
|
840
|
+
|
|
841
|
+
try {
|
|
842
|
+
const savedCollapsed = localStorage.getItem( STORAGE_KEY_COLLAPSED );
|
|
843
|
+
if ( savedCollapsed !== null ) this.collapsed = savedCollapsed === "true";
|
|
844
|
+
|
|
845
|
+
const savedTheme = localStorage.getItem( STORAGE_KEY_THEME );
|
|
846
|
+
if ( savedTheme ) this.theme = savedTheme;
|
|
847
|
+
} catch {
|
|
848
|
+
// localStorage may be unavailable (private browsing, security policy).
|
|
849
|
+
}
|
|
850
|
+
this._applyTheme( this.theme );
|
|
851
|
+
|
|
852
|
+
// Use application settings to configure the application at load-time:
|
|
853
|
+
this.sendRequest( "/app/config" ).then( ( result ) => {
|
|
854
|
+
this.configuration = result?.data || {};
|
|
855
|
+
this._mergeComponentsConfig( this.configuration?.componentsConfig );
|
|
856
|
+
return ( this.configuration?.auth?.isAuthenticated ) ? this.sendRequest( "/me" ) : {};
|
|
857
|
+
} ).then( ( result ) => {
|
|
858
|
+
this.user = result?.data?.user || null;
|
|
859
|
+
this.isInitialized = true;
|
|
860
|
+
} ).catch( ( error ) => {
|
|
861
|
+
if ( error?.name === "AbortError" || error?.isAborted ) {
|
|
862
|
+
return;
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
this.user = null;
|
|
866
|
+
this.isInitialized = false;
|
|
867
|
+
const formatted = this.formatException( error );
|
|
868
|
+
this.notify( { message: this.getLabel( "error.application.init-failed" ) + " " + formatted.message, details: formatted.details } );
|
|
869
|
+
} );
|
|
870
|
+
},
|
|
871
|
+
|
|
872
|
+
/**
|
|
873
|
+
* Used to send a request to the application server.
|
|
874
|
+
*
|
|
875
|
+
* @method
|
|
876
|
+
* @param {string} url
|
|
877
|
+
* @param {"POST"|"GET"|"PUT"|"DELETE"} [method="GET"]
|
|
878
|
+
* @param {Object} [data=null]
|
|
879
|
+
* @returns {Promise<Object>}
|
|
880
|
+
* @public
|
|
881
|
+
*/
|
|
882
|
+
sendRequest( url, method = "GET", data = null ) {
|
|
883
|
+
return new Promise( ( resolve, reject ) => {
|
|
884
|
+
const xsrf = tiToolbox.getCookie( "ti-xsrf-token" ) || "";
|
|
885
|
+
const normalizedMethod = String( method || "GET" ).toUpperCase();
|
|
886
|
+
const requestKey = `${ normalizedMethod } ${ String( url || "" ).split( "?" )[ 0 ] }`;
|
|
887
|
+
const abortController = ( typeof AbortController === "function" ) ? new AbortController() : null;
|
|
888
|
+
|
|
889
|
+
if ( abortController && normalizedMethod === "GET" ) {
|
|
890
|
+
const existing = this.requestControllers.get( requestKey );
|
|
891
|
+
if ( existing ) {
|
|
892
|
+
existing.abort();
|
|
893
|
+
}
|
|
894
|
+
this.requestControllers.set( requestKey, abortController );
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
const cleanup = () => {
|
|
898
|
+
if ( !abortController || normalizedMethod !== "GET" ) return;
|
|
899
|
+
const active = this.requestControllers.get( requestKey );
|
|
900
|
+
if ( active === abortController ) {
|
|
901
|
+
this.requestControllers.delete( requestKey );
|
|
902
|
+
}
|
|
903
|
+
};
|
|
904
|
+
|
|
905
|
+
const options = {
|
|
906
|
+
method: normalizedMethod,
|
|
907
|
+
headers: {
|
|
908
|
+
"Accept": "application/json",
|
|
909
|
+
"x-xsrf-token": xsrf
|
|
910
|
+
},
|
|
911
|
+
credentials: "same-origin",
|
|
912
|
+
cache: "no-store",
|
|
913
|
+
signal: abortController?.signal,
|
|
914
|
+
};
|
|
915
|
+
|
|
916
|
+
if ( data ) {
|
|
917
|
+
options.headers[ "Content-Type" ] = "application/json";
|
|
918
|
+
options.body = JSON.stringify( data );
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
fetch( url, options ).then( ( response ) => {
|
|
922
|
+
const contentType = ( response.headers.get( "content-type" ) || "" ).toLowerCase();
|
|
923
|
+
if ( contentType.includes( "application/json" ) ) {
|
|
924
|
+
return response.json().then( ( body ) => ( {
|
|
925
|
+
isSuccessful: response.ok,
|
|
926
|
+
...body
|
|
927
|
+
} ) );
|
|
928
|
+
} else {
|
|
929
|
+
return { isSuccessful: response.ok, message: response.statusText };
|
|
930
|
+
}
|
|
931
|
+
} ).then( ( result ) => {
|
|
932
|
+
if ( !result || result.isSuccessful === false ) {
|
|
933
|
+
reject( result || {} );
|
|
934
|
+
} else {
|
|
935
|
+
resolve( result );
|
|
936
|
+
}
|
|
937
|
+
} ).catch( ( error ) => {
|
|
938
|
+
reject( error );
|
|
939
|
+
} ).finally( () => {
|
|
940
|
+
cleanup();
|
|
941
|
+
} );
|
|
942
|
+
} );
|
|
943
|
+
},
|
|
944
|
+
|
|
945
|
+
/**
|
|
946
|
+
* Redirect the user to the specified screen.
|
|
947
|
+
*
|
|
948
|
+
* @method
|
|
949
|
+
* @param {string} screen
|
|
950
|
+
* @public
|
|
951
|
+
*/
|
|
952
|
+
openScreen( screen ) {
|
|
953
|
+
const [ basePath, ...queryParts ] = ( screen || "" ).split( "?" );
|
|
954
|
+
const query = queryParts.length ? "?" + queryParts.join( "?" ) : "";
|
|
955
|
+
if ( !basePath || !/^[\w-]+$/.test( basePath ) || !window.htmx ) {
|
|
956
|
+
window.location.href = "/";
|
|
957
|
+
} else {
|
|
958
|
+
const screenUrl = "/app/" + basePath + query;
|
|
959
|
+
// Push the URL before the HTMX swap so that any Alpine component initialized
|
|
960
|
+
// during the swap (via MutationObserver microtask) already sees the correct URL.
|
|
961
|
+
window.history.pushState( null, "", screenUrl );
|
|
962
|
+
this.screenTitleOverride = "";
|
|
963
|
+
this.currentScreen = basePath;
|
|
964
|
+
window.htmx.ajax( "get", screenUrl, { target: "#ti-content", swap: "innerHTML" } ).catch( () => {
|
|
965
|
+
window.location.href = "/";
|
|
966
|
+
} );
|
|
967
|
+
}
|
|
968
|
+
},
|
|
969
|
+
|
|
970
|
+
/**
|
|
971
|
+
* Used to update the current screen name and push the URL to history.
|
|
972
|
+
*
|
|
973
|
+
* @method
|
|
974
|
+
* @param {string} screen
|
|
975
|
+
* @public
|
|
976
|
+
*/
|
|
977
|
+
setCurrentScreen( screen ) {
|
|
978
|
+
if ( screen ) {
|
|
979
|
+
this.currentScreen = screen;
|
|
980
|
+
this.topbarSubtitle = "";
|
|
981
|
+
this.screenTitleOverride = "";
|
|
982
|
+
this.topbarPrimaryCta = null;
|
|
983
|
+
}
|
|
984
|
+
},
|
|
985
|
+
|
|
986
|
+
/**
|
|
987
|
+
* Used to override the topbar/document title for the current screen, replacing the default
|
|
988
|
+
* `interface.topbar.<screen>` label. Pass an empty string to clear it and fall back to the label.
|
|
989
|
+
* Automatically cleared on screen navigation.
|
|
990
|
+
*
|
|
991
|
+
* @method
|
|
992
|
+
* @param {string} title
|
|
993
|
+
* @public
|
|
994
|
+
*/
|
|
995
|
+
setScreenTitle( title ) {
|
|
996
|
+
this.screenTitleOverride = String( title || "" ).trim();
|
|
997
|
+
},
|
|
998
|
+
|
|
999
|
+
/**
|
|
1000
|
+
* Used to set a per-screen subtitle in the topbar, overriding the default cycle name.
|
|
1001
|
+
* Automatically cleared on screen navigation.
|
|
1002
|
+
*
|
|
1003
|
+
* @method
|
|
1004
|
+
* @param {string} subtitle
|
|
1005
|
+
* @public
|
|
1006
|
+
*/
|
|
1007
|
+
setTopbarSubtitle( subtitle ) {
|
|
1008
|
+
this.topbarSubtitle = String( subtitle || "" ).trim();
|
|
1009
|
+
},
|
|
1010
|
+
|
|
1011
|
+
/**
|
|
1012
|
+
* Used to register a primary call-to-action button in the topbar for the current screen. The CTA is
|
|
1013
|
+
* automatically cleared on screen navigation so each screen owns its own slot. Pass {@link null} to remove
|
|
1014
|
+
* the CTA without leaving the screen.
|
|
1015
|
+
*
|
|
1016
|
+
* @typedef {Object} TiTopbarPrimaryCta
|
|
1017
|
+
* @property {string} labelKey Localization key for the button text.
|
|
1018
|
+
* @property {string} [icon] Optional icon class name (e.g. "plus", "send"); rendered as a leading glyph.
|
|
1019
|
+
* @property {string} [tone] Button tone class — "primary" (default), "danger", "ghost".
|
|
1020
|
+
* @property {Function} handler Click handler invoked when the button is activated.
|
|
1021
|
+
* @property {boolean} [disabled] Optional disabled flag.
|
|
1022
|
+
*
|
|
1023
|
+
* @method
|
|
1024
|
+
* @param {TiTopbarPrimaryCta|null} cta
|
|
1025
|
+
* @public
|
|
1026
|
+
*/
|
|
1027
|
+
setTopbarPrimaryCta( cta ) {
|
|
1028
|
+
this.topbarPrimaryCta = ( cta && typeof cta === "object" ) ? cta : null;
|
|
1029
|
+
},
|
|
1030
|
+
|
|
1031
|
+
/**
|
|
1032
|
+
* Used to mutate just the disabled flag of the active topbar CTA without re-registering the full object.
|
|
1033
|
+
* Useful when validation state changes while the screen is open (e.g. cycle setup becoming lockable).
|
|
1034
|
+
*
|
|
1035
|
+
* @method
|
|
1036
|
+
* @param {boolean} disabled
|
|
1037
|
+
* @public
|
|
1038
|
+
*/
|
|
1039
|
+
setTopbarPrimaryCtaDisabled( disabled ) {
|
|
1040
|
+
if ( this.topbarPrimaryCta ) {
|
|
1041
|
+
this.topbarPrimaryCta = { ...this.topbarPrimaryCta, disabled: disabled === true };
|
|
1042
|
+
}
|
|
1043
|
+
},
|
|
1044
|
+
|
|
1045
|
+
/**
|
|
1046
|
+
* Used to format an exception into a notification payload: a generic localized `message` plus optional, more
|
|
1047
|
+
* specific `details`. The returned object stringifies to its `message`, so existing string usages
|
|
1048
|
+
* (concatenation, `x-text`) keep working, while {@link notify} can surface the `details` on a second line.
|
|
1049
|
+
*
|
|
1050
|
+
* @method
|
|
1051
|
+
* @param {Object} error
|
|
1052
|
+
* @returns {{message: string, details: string, toString: function(): string}}
|
|
1053
|
+
* @public
|
|
1054
|
+
*/
|
|
1055
|
+
formatException( error ) {
|
|
1056
|
+
const exception = error && error.exception;
|
|
1057
|
+
const message = ( error && error.message ) || this.getLabel( exception && exception.label );
|
|
1058
|
+
const rawDetails = exception && exception.data && exception.data.details;
|
|
1059
|
+
// Resolve the details as a label when it is a known label key; otherwise show the raw text (using it as its
|
|
1060
|
+
// own fallback so dynamic, non-localized messages pass through unchanged).
|
|
1061
|
+
const details = rawDetails ? this.getLabel( rawDetails, rawDetails ) : "";
|
|
1062
|
+
return {
|
|
1063
|
+
message: message, details: details, toString() {
|
|
1064
|
+
return this.message;
|
|
1065
|
+
}
|
|
1066
|
+
};
|
|
1067
|
+
},
|
|
1068
|
+
|
|
1069
|
+
/**
|
|
1070
|
+
* Used to display a notification in the notification bar. Accepts either a plain message string or a payload
|
|
1071
|
+
* object `{ message, details }` (e.g. the result of {@link formatException}); the optional `details` render on
|
|
1072
|
+
* a smaller second line.
|
|
1073
|
+
*
|
|
1074
|
+
* @method
|
|
1075
|
+
* @param {string|{message: string, details?: string}} message
|
|
1076
|
+
* @param {number} [timeout=6000]
|
|
1077
|
+
* @public
|
|
1078
|
+
*/
|
|
1079
|
+
notify( message, timeout = 6000 ) {
|
|
1080
|
+
const notificationBar = document.querySelector( "#ti-notifications" );
|
|
1081
|
+
if ( notificationBar ) {
|
|
1082
|
+
const payload = ( message && typeof message === "object" ) ? message : { message: message };
|
|
1083
|
+
Alpine.$data( notificationBar ).add( {
|
|
1084
|
+
id: this.notificationIDCounter++,
|
|
1085
|
+
message: payload.message || this.getLabel( "error.application.unexpected" ),
|
|
1086
|
+
details: payload.details || "",
|
|
1087
|
+
timeout: timeout
|
|
1088
|
+
} );
|
|
1089
|
+
}
|
|
1090
|
+
},
|
|
1091
|
+
|
|
1092
|
+
/**
|
|
1093
|
+
* Used to extract a label from the application configuration.
|
|
1094
|
+
*
|
|
1095
|
+
* @method
|
|
1096
|
+
* @param {string} label
|
|
1097
|
+
* @param {string} fallback
|
|
1098
|
+
* @returns {string}
|
|
1099
|
+
* @public
|
|
1100
|
+
*/
|
|
1101
|
+
getLabel( label, fallback = "LABEL NOT FOUND" ) {
|
|
1102
|
+
if ( !label ) {
|
|
1103
|
+
return fallback;
|
|
1104
|
+
} else {
|
|
1105
|
+
return extractLabel( this.configuration.labels || {}, label.split( "." ).filter( Boolean ), fallback );
|
|
1106
|
+
}
|
|
1107
|
+
},
|
|
1108
|
+
|
|
1109
|
+
/**
|
|
1110
|
+
* Returns true when the current session user holds the given role code. Safe to call before the session has
|
|
1111
|
+
* loaded (returns false). Provided as a helper because the Alpine CSP evaluator does not expose `Array`, so
|
|
1112
|
+
* `Array.isArray( ... )` cannot be written inline in templates.
|
|
1113
|
+
*
|
|
1114
|
+
* @method
|
|
1115
|
+
* @param {number|string} roleCode
|
|
1116
|
+
* @returns {boolean}
|
|
1117
|
+
* @public
|
|
1118
|
+
*/
|
|
1119
|
+
hasRole( roleCode ) {
|
|
1120
|
+
const roles = this.user && this.user.roles;
|
|
1121
|
+
if ( !roles || typeof roles.indexOf !== "function" ) {
|
|
1122
|
+
return false;
|
|
1123
|
+
}
|
|
1124
|
+
return roles.indexOf( roleCode ) >= 0;
|
|
1125
|
+
},
|
|
1126
|
+
|
|
1127
|
+
/**
|
|
1128
|
+
* Toggle the sidebar between expanded and collapsed states.
|
|
1129
|
+
*
|
|
1130
|
+
* @method
|
|
1131
|
+
* @public
|
|
1132
|
+
*/
|
|
1133
|
+
toggleCollapse() {
|
|
1134
|
+
this.collapsed = !this.collapsed;
|
|
1135
|
+
try {
|
|
1136
|
+
localStorage.setItem( STORAGE_KEY_COLLAPSED, String( this.collapsed ) );
|
|
1137
|
+
} catch { /* ignore */
|
|
1138
|
+
}
|
|
1139
|
+
},
|
|
1140
|
+
|
|
1141
|
+
/**
|
|
1142
|
+
* Toggle between daylight and glass themes.
|
|
1143
|
+
*
|
|
1144
|
+
* @method
|
|
1145
|
+
* @public
|
|
1146
|
+
*/
|
|
1147
|
+
toggleTheme() {
|
|
1148
|
+
this.theme = ( this.theme === "daylight" ) ? "glass" : "daylight";
|
|
1149
|
+
this._applyTheme( this.theme );
|
|
1150
|
+
try {
|
|
1151
|
+
localStorage.setItem( STORAGE_KEY_THEME, this.theme );
|
|
1152
|
+
} catch { /* ignore */
|
|
1153
|
+
}
|
|
1154
|
+
},
|
|
1155
|
+
|
|
1156
|
+
/**
|
|
1157
|
+
* Apply a theme by setting the data-theme attribute on <html>.
|
|
1158
|
+
*
|
|
1159
|
+
* @method
|
|
1160
|
+
* @param {string} theme
|
|
1161
|
+
* @private
|
|
1162
|
+
*/
|
|
1163
|
+
_applyTheme( theme ) {
|
|
1164
|
+
document.documentElement.dataset.theme = theme;
|
|
1165
|
+
},
|
|
1166
|
+
|
|
1167
|
+
/**
|
|
1168
|
+
* Merges app-supplied component configurations into the "tiComponentsConfig" Alpine store.
|
|
1169
|
+
* Each entry is deep-merged on top of the framework's default flyout config, so apps only need
|
|
1170
|
+
* to supply the differences.
|
|
1171
|
+
*
|
|
1172
|
+
* @method
|
|
1173
|
+
* @param {Object} [componentsConfig]
|
|
1174
|
+
* @private
|
|
1175
|
+
*/
|
|
1176
|
+
_mergeComponentsConfig( componentsConfig ) {
|
|
1177
|
+
if ( !componentsConfig || typeof componentsConfig !== "object" ) {
|
|
1178
|
+
return;
|
|
1179
|
+
}
|
|
1180
|
+
const store = Alpine.store( "tiComponentsConfig" );
|
|
1181
|
+
Object.keys( componentsConfig ).forEach( ( key ) => {
|
|
1182
|
+
store[ key ] = tiToolbox.deepMerge( defaultSidebarFlyoutConfig, componentsConfig[ key ] || {} );
|
|
1183
|
+
} );
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
};
|
|
1187
|
+
};
|
|
1188
|
+
|
|
1189
|
+
/**
|
|
1190
|
+
* Returns a callback function for the Alpine.js "text-label" directive.
|
|
1191
|
+
* This directive can be used to localize the text content of an element or its attributes.
|
|
1192
|
+
*
|
|
1193
|
+
* Usage instructions:
|
|
1194
|
+
* - To localize text content:
|
|
1195
|
+
* `<span x-text-label="translation.key">Fallback Text</span>`
|
|
1196
|
+
* - To localize an element attribute (e.g., aria-label, placeholder, title):
|
|
1197
|
+
* `<button x-text-label:aria-label="translation.key" aria-label="Fallback Text">...</button>`
|
|
1198
|
+
*
|
|
1199
|
+
* @method
|
|
1200
|
+
* @returns {Function}
|
|
1201
|
+
* @public
|
|
1202
|
+
*/
|
|
1203
|
+
const configureDirectiveTextLabel = () => {
|
|
1204
|
+
return ( element, { value, expression }, { effect } ) => {
|
|
1205
|
+
const targetAttribute = value;
|
|
1206
|
+
const fallback = targetAttribute ? ( element.getAttribute( targetAttribute ) || "" ) : ( element.textContent || "" );
|
|
1207
|
+
|
|
1208
|
+
effect( () => {
|
|
1209
|
+
const tiApplication = Alpine.store( "tiApplication" );
|
|
1210
|
+
if ( !tiApplication || typeof tiApplication.getLabel !== "function" ) {
|
|
1211
|
+
return;
|
|
1212
|
+
}
|
|
1213
|
+
let path = ( expression || "" ).trim();
|
|
1214
|
+
if (
|
|
1215
|
+
( path.startsWith( "'" ) && path.endsWith( "'" ) ) ||
|
|
1216
|
+
( path.startsWith( "\"" ) && path.endsWith( "\"" ) )
|
|
1217
|
+
) {
|
|
1218
|
+
path = path.slice( 1, -1 );
|
|
1219
|
+
}
|
|
1220
|
+
const translatedText = tiApplication.getLabel( path, fallback );
|
|
1221
|
+
if ( targetAttribute ) {
|
|
1222
|
+
element.setAttribute( targetAttribute, translatedText );
|
|
1223
|
+
} else {
|
|
1224
|
+
element.textContent = translatedText;
|
|
1225
|
+
}
|
|
1226
|
+
} );
|
|
1227
|
+
};
|
|
1228
|
+
};
|
|
1229
|
+
|
|
1230
|
+
/**
|
|
1231
|
+
* Returns a callback for the Alpine.js "ti-chart" directive. Renders a themeable SVG chart from a
|
|
1232
|
+
* reactive spec property path into the host <figure class="ti-chart"> element.
|
|
1233
|
+
*
|
|
1234
|
+
* Usage (CSP-legal — the expression is a bare reactive property path or a registered method call):
|
|
1235
|
+
* <figure class="ti-chart" x-ti-chart="coverageSpec"
|
|
1236
|
+
* role="img" x-bind:aria-label="coverageSpec.a11yLabel"></figure>
|
|
1237
|
+
*
|
|
1238
|
+
* The geometry/format math and the SVG assembly live in ti-charts.js (window.TiCharts); this
|
|
1239
|
+
* directive only wires the reactive spec to TiCharts.renderChart.
|
|
1240
|
+
*
|
|
1241
|
+
* @method
|
|
1242
|
+
* @returns {Function}
|
|
1243
|
+
* @public
|
|
1244
|
+
*/
|
|
1245
|
+
const configureDirectiveTiChart = () => {
|
|
1246
|
+
return ( element, { expression }, { effect, evaluateLater } ) => {
|
|
1247
|
+
const getSpec = evaluateLater( expression );
|
|
1248
|
+
effect( () => {
|
|
1249
|
+
getSpec( ( spec ) => {
|
|
1250
|
+
if ( typeof window !== "undefined" && window.TiCharts && typeof window.TiCharts.renderChart === "function" ) {
|
|
1251
|
+
window.TiCharts.renderChart( element, spec );
|
|
1252
|
+
}
|
|
1253
|
+
} );
|
|
1254
|
+
} );
|
|
1255
|
+
};
|
|
1256
|
+
};
|
|
1257
|
+
|
|
1258
|
+
/**
|
|
1259
|
+
* Perform a one-time configuration of the HTMX framework.
|
|
1260
|
+
*/
|
|
1261
|
+
document.addEventListener( "htmx:configRequest", ( event ) => {
|
|
1262
|
+
const tiToolbox = Alpine.store( "tiToolbox" );
|
|
1263
|
+
event.detail.headers[ 'x-xsrf-token' ] = tiToolbox?.getCookie( "ti-xsrf-token" ) || "";
|
|
1264
|
+
// Reuse the existing nonce from the active document:
|
|
1265
|
+
const styleNonce = ( htmx?.config?.inlineStyleNonce ) || "";
|
|
1266
|
+
const scriptNonce = ( htmx?.config?.inlineScriptNonce ) || "";
|
|
1267
|
+
event.detail.headers[ 'x-csp-nonce' ] = styleNonce || scriptNonce || "";
|
|
1268
|
+
} );
|
|
1269
|
+
|
|
1270
|
+
/**
|
|
1271
|
+
* Add a custom event listener to the HTMX framework.
|
|
1272
|
+
*/
|
|
1273
|
+
document.addEventListener( "htmx:afterSwap", ( event ) => {
|
|
1274
|
+
const target = event.detail.target;
|
|
1275
|
+
if ( target.id !== "ti-content" && target.tagName !== "TI-NESTED-FRAME-PLACEHOLDER" ) return;
|
|
1276
|
+
const path = ( event.detail.pathInfo && event.detail.pathInfo.requestPath ) || "";
|
|
1277
|
+
const match = path.match( /^\/app\/([\w-]+)/ );
|
|
1278
|
+
if ( match ) {
|
|
1279
|
+
const tiApplication = Alpine.store( "tiApplication" );
|
|
1280
|
+
if ( tiApplication ) {
|
|
1281
|
+
tiApplication.setCurrentScreen( match[ 1 ] );
|
|
1282
|
+
}
|
|
1283
|
+
}
|
|
1284
|
+
} );
|
|
1285
|
+
|
|
1286
|
+
document.addEventListener( "htmx:responseError", ( event ) => {
|
|
1287
|
+
// If the server sent HX-Trigger with our payload, it will also emit a separate event,
|
|
1288
|
+
// But here we parse body as fallback when body is JSON
|
|
1289
|
+
try {
|
|
1290
|
+
const xhr = event.detail.xhr;
|
|
1291
|
+
const contentType = xhr.getResponseHeader( "Content-Type" ) || "";
|
|
1292
|
+
if ( contentType.includes( "application/json" ) && xhr.responseText ) {
|
|
1293
|
+
const data = JSON.parse( xhr.responseText );
|
|
1294
|
+
const tiApplication = Alpine.store( "tiApplication" );
|
|
1295
|
+
if ( tiApplication && tiApplication.isInitialized ) {
|
|
1296
|
+
tiApplication.notify( tiApplication.formatException( data ) );
|
|
1297
|
+
}
|
|
1298
|
+
}
|
|
1299
|
+
} catch {
|
|
1300
|
+
// Do nothing here...
|
|
1301
|
+
}
|
|
1302
|
+
} );
|
|
1303
|
+
|
|
1304
|
+
/**
|
|
1305
|
+
* Returns a configuration object for the login screen test user pill panel.
|
|
1306
|
+
* <br/>
|
|
1307
|
+
* NOTE: This is a TEMPORARY testing aid that injects an employeeID into the session via a cookie which the
|
|
1308
|
+
* server-side {@link augmentSession} reads; roles are derived by the app unless the opt-in "override roles (dev)"
|
|
1309
|
+
* toggle is on, in which case the profile's roles are written too. Remove together with the panel HTML once real
|
|
1310
|
+
* identity propagation is in place.
|
|
1311
|
+
*
|
|
1312
|
+
* @method
|
|
1313
|
+
* @returns {Object}
|
|
1314
|
+
* @public
|
|
1315
|
+
*/
|
|
1316
|
+
const configureLoginTestUserPanel = () => {
|
|
1317
|
+
const COOKIE_NAME = "ti-test-user";
|
|
1318
|
+
const COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 30;
|
|
1319
|
+
|
|
1320
|
+
const readCookie = () => {
|
|
1321
|
+
try {
|
|
1322
|
+
const tiToolbox = Alpine.store( "tiToolbox" );
|
|
1323
|
+
const raw = tiToolbox.getCookie( COOKIE_NAME );
|
|
1324
|
+
if ( !raw ) return null;
|
|
1325
|
+
const parsed = JSON.parse( raw );
|
|
1326
|
+
return ( parsed && parsed.employeeID ) ? parsed : null;
|
|
1327
|
+
} catch {
|
|
1328
|
+
return null;
|
|
1329
|
+
}
|
|
1330
|
+
};
|
|
1331
|
+
|
|
1332
|
+
const writeCookie = ( value ) => {
|
|
1333
|
+
const encoded = encodeURIComponent( JSON.stringify( value ) );
|
|
1334
|
+
document.cookie = `${ COOKIE_NAME }=${ encoded }; path=/; max-age=${ COOKIE_MAX_AGE_SECONDS }; SameSite=Lax`;
|
|
1335
|
+
};
|
|
1336
|
+
|
|
1337
|
+
const clearCookie = () => {
|
|
1338
|
+
document.cookie = `${ COOKIE_NAME }=; path=/; max-age=0; SameSite=Lax`;
|
|
1339
|
+
};
|
|
1340
|
+
|
|
1341
|
+
return {
|
|
1342
|
+
profiles: [
|
|
1343
|
+
{ employeeID: "22", roles: [ 1, 2, 3 ] },
|
|
1344
|
+
{ employeeID: "20", roles: [ 1, 2 ] },
|
|
1345
|
+
{ employeeID: "11", roles: [ 1, 2 ] },
|
|
1346
|
+
{ employeeID: "1", roles: [ 1 ] },
|
|
1347
|
+
{ employeeID: "3", roles: [ 1 ] },
|
|
1348
|
+
{ employeeID: "4", roles: [ 1 ] },
|
|
1349
|
+
{ employeeID: "8", roles: [ 1, 2 ] },
|
|
1350
|
+
{ employeeID: "9", roles: [ 1 ] }
|
|
1351
|
+
],
|
|
1352
|
+
selected: null,
|
|
1353
|
+
overrideRoles: false,
|
|
1354
|
+
|
|
1355
|
+
init() {
|
|
1356
|
+
this.selected = readCookie();
|
|
1357
|
+
this.overrideRoles = Boolean( this.selected && Array.isArray( this.selected.roles ) && this.selected.roles.length > 0 );
|
|
1358
|
+
},
|
|
1359
|
+
|
|
1360
|
+
isSelected( profile ) {
|
|
1361
|
+
return Boolean( this.selected && this.selected.employeeID === profile.employeeID );
|
|
1362
|
+
},
|
|
1363
|
+
|
|
1364
|
+
select( profile ) {
|
|
1365
|
+
this.selected = this.overrideRoles
|
|
1366
|
+
? { employeeID: profile.employeeID, roles: profile.roles.slice() }
|
|
1367
|
+
: { employeeID: profile.employeeID };
|
|
1368
|
+
writeCookie( this.selected );
|
|
1369
|
+
},
|
|
1370
|
+
|
|
1371
|
+
onOverrideChanged() {
|
|
1372
|
+
// `overrideRoles` is already updated by x-model; just re-write the cookie for the current selection
|
|
1373
|
+
// so the new override setting takes effect immediately.
|
|
1374
|
+
if ( this.selected ) {
|
|
1375
|
+
// Turning the override OFF must always strip any persisted roles from the cookie — even when the
|
|
1376
|
+
// selected employee is no longer in `profiles` (cookie from an older profile list or set manually) —
|
|
1377
|
+
// so a stale roles array can't keep overriding the org-derived roles on the next login.
|
|
1378
|
+
if ( !this.overrideRoles ) {
|
|
1379
|
+
this.selected = { employeeID: this.selected.employeeID };
|
|
1380
|
+
writeCookie( this.selected );
|
|
1381
|
+
return;
|
|
1382
|
+
}
|
|
1383
|
+
const profile = this.profiles.find( ( candidate ) => candidate.employeeID === this.selected.employeeID );
|
|
1384
|
+
if ( profile ) {
|
|
1385
|
+
this.select( profile );
|
|
1386
|
+
}
|
|
1387
|
+
}
|
|
1388
|
+
},
|
|
1389
|
+
|
|
1390
|
+
clear() {
|
|
1391
|
+
this.selected = null;
|
|
1392
|
+
clearCookie();
|
|
1393
|
+
}
|
|
1394
|
+
};
|
|
1395
|
+
};
|
|
1396
|
+
|
|
1397
|
+
/**
|
|
1398
|
+
* Register on-initialization tasks for the Alpine.js framework.
|
|
1399
|
+
*/
|
|
1400
|
+
document.addEventListener( "alpine:init", () => {
|
|
1401
|
+
// Note: Sequence here is important!
|
|
1402
|
+
Alpine.directive( "text-label", configureDirectiveTextLabel() );
|
|
1403
|
+
Alpine.directive( "ti-chart", configureDirectiveTiChart() );
|
|
1404
|
+
Alpine.store( "tiToolbox", configureToolbox() );
|
|
1405
|
+
Alpine.store( "tiApplication", configureApplication() );
|
|
1406
|
+
Alpine.store( "tiComponentsConfig", {} );
|
|
1407
|
+
Alpine.data( "tiApplication", () => ( {
|
|
1408
|
+
get collapsed() {
|
|
1409
|
+
return Alpine.store( "tiApplication" ).collapsed;
|
|
1410
|
+
},
|
|
1411
|
+
get theme() {
|
|
1412
|
+
return Alpine.store( "tiApplication" ).theme;
|
|
1413
|
+
},
|
|
1414
|
+
toggleCollapse() {
|
|
1415
|
+
Alpine.store( "tiApplication" ).toggleCollapse();
|
|
1416
|
+
},
|
|
1417
|
+
toggleTheme() {
|
|
1418
|
+
Alpine.store( "tiApplication" ).toggleTheme();
|
|
1419
|
+
}
|
|
1420
|
+
} ) );
|
|
1421
|
+
Alpine.data( "tiComponentSidebarNav", configureSidebarNav );
|
|
1422
|
+
Alpine.data( "tiComponentTopbar", configureComponentTopbar );
|
|
1423
|
+
Alpine.data( "tiComponentSidebarFlyout", configureComponentSidebarFlyout );
|
|
1424
|
+
Alpine.data( "tiComponentNotificationBar", configureComponentNotificationBar );
|
|
1425
|
+
Alpine.data( "tiComponentTooltip", configureComponentTooltip );
|
|
1426
|
+
Alpine.data( "tiLoginTestUserPanel", configureLoginTestUserPanel );
|
|
1427
|
+
} );
|