structured-fw 0.7.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Config.ts +47 -0
- package/LICENSE +21 -0
- package/README.md +332 -0
- package/app/Types.ts +1 -0
- package/app/models/README.md +9 -0
- package/app/routes/README.md +19 -0
- package/app/views/README.md +1 -0
- package/app/views/layout.html +1 -0
- package/bin/structured +114 -0
- package/build/Config.d.ts +2 -0
- package/build/Config.js +31 -0
- package/build/app/Types.d.ts +1 -0
- package/build/app/Types.js +1 -0
- package/build/app/models/Users.d.ts +0 -0
- package/build/app/models/Users.js +1 -0
- package/build/app/routes/Auth.d.ts +0 -0
- package/build/app/routes/Auth.js +1 -0
- package/build/app/routes/Test.d.ts +2 -0
- package/build/app/routes/Test.js +101 -0
- package/build/app/routes/Todo.d.ts +0 -0
- package/build/app/routes/Todo.js +1 -0
- package/build/app/routes/Upload.d.ts +0 -0
- package/build/app/routes/Upload.js +1 -0
- package/build/app/routes/Validation.d.ts +2 -0
- package/build/app/routes/Validation.js +34 -0
- package/build/app/views/components/ClientImport/ClientImport.client.d.ts +2 -0
- package/build/app/views/components/ClientImport/ClientImport.client.js +4 -0
- package/build/app/views/components/ClientImport/Export.d.ts +1 -0
- package/build/app/views/components/ClientImport/Export.js +1 -0
- package/build/app/views/components/Conditionals/Conditionals.client.d.ts +2 -0
- package/build/app/views/components/Conditionals/Conditionals.client.js +43 -0
- package/build/app/views/components/FormTest/FormTestNested/FormTestNested.d.ts +8 -0
- package/build/app/views/components/FormTest/FormTestNested/FormTestNested.js +7 -0
- package/build/app/views/components/ModelsTest/ModelsTest.client.d.ts +2 -0
- package/build/app/views/components/ModelsTest/ModelsTest.client.js +5 -0
- package/build/app/views/components/MultipartForm/MultipartForm.client.d.ts +0 -0
- package/build/app/views/components/MultipartForm/MultipartForm.client.js +1 -0
- package/build/app/views/components/PassObject/PassObject.d.ts +10 -0
- package/build/app/views/components/PassObject/PassObject.js +10 -0
- package/build/app/views/components/PassObject/ReceiveObj/ReceiveObj.d.ts +6 -0
- package/build/app/views/components/PassObject/ReceiveObj/ReceiveObj.js +6 -0
- package/build/app/views/components/RedrawAbort/RedrawAbort.client.d.ts +2 -0
- package/build/app/views/components/RedrawAbort/RedrawAbort.client.js +6 -0
- package/build/app/views/components/RedrawAbort/RedrawAbort.d.ts +8 -0
- package/build/app/views/components/RedrawAbort/RedrawAbort.js +8 -0
- package/build/app/views/components/ServerSideContext/ServerSideContext.d.ts +7 -0
- package/build/app/views/components/ServerSideContext/ServerSideContext.js +10 -0
- package/build/assets/ts/Export.d.ts +1 -0
- package/build/assets/ts/Export.js +1 -0
- package/build/index.d.ts +1 -0
- package/build/index.js +3 -0
- package/build/system/Helpers.d.ts +3 -0
- package/build/system/Helpers.js +72 -0
- package/build/system/Symbols.d.ts +3 -0
- package/build/system/Symbols.js +3 -0
- package/build/system/Types.d.ts +171 -0
- package/build/system/Types.js +1 -0
- package/build/system/Util.d.ts +20 -0
- package/build/system/Util.js +336 -0
- package/build/system/client/App.d.ts +7 -0
- package/build/system/client/App.js +8 -0
- package/build/system/client/Client.d.ts +6 -0
- package/build/system/client/Client.js +9 -0
- package/build/system/client/ClientComponent.d.ts +68 -0
- package/build/system/client/ClientComponent.js +734 -0
- package/build/system/client/DataStore.d.ts +22 -0
- package/build/system/client/DataStore.js +64 -0
- package/build/system/client/DataStoreView.d.ts +19 -0
- package/build/system/client/DataStoreView.js +56 -0
- package/build/system/client/EventEmitter.d.ts +7 -0
- package/build/system/client/EventEmitter.js +31 -0
- package/build/system/client/Net.d.ts +13 -0
- package/build/system/client/Net.js +39 -0
- package/build/system/client/NetRequest.d.ts +13 -0
- package/build/system/client/NetRequest.js +45 -0
- package/build/system/server/Application.d.ts +31 -0
- package/build/system/server/Application.js +171 -0
- package/build/system/server/Component.d.ts +27 -0
- package/build/system/server/Component.js +249 -0
- package/build/system/server/Components.d.ts +12 -0
- package/build/system/server/Components.js +77 -0
- package/build/system/server/Cookies.d.ts +6 -0
- package/build/system/server/Cookies.js +19 -0
- package/build/system/server/Document.d.ts +24 -0
- package/build/system/server/Document.js +107 -0
- package/build/system/server/DocumentHead.d.ts +32 -0
- package/build/system/server/DocumentHead.js +118 -0
- package/build/system/server/FormValidation.d.ts +16 -0
- package/build/system/server/FormValidation.js +197 -0
- package/build/system/server/Handlebars.d.ts +11 -0
- package/build/system/server/Handlebars.js +34 -0
- package/build/system/server/Request.d.ts +21 -0
- package/build/system/server/Request.js +356 -0
- package/build/system/server/Session.d.ts +23 -0
- package/build/system/server/Session.js +114 -0
- package/build/system/server/dom/DOMFragment.d.ts +4 -0
- package/build/system/server/dom/DOMFragment.js +6 -0
- package/build/system/server/dom/DOMNode.d.ts +31 -0
- package/build/system/server/dom/DOMNode.js +110 -0
- package/build/system/server/dom/HTMLParser.d.ts +21 -0
- package/build/system/server/dom/HTMLParser.js +204 -0
- package/index.ts +4 -0
- package/package.json +31 -0
- package/system/Helpers.ts +97 -0
- package/system/Symbols.ts +6 -0
- package/system/Types.ts +234 -0
- package/system/Util.ts +488 -0
- package/system/client/App.ts +11 -0
- package/system/client/Client.ts +9 -0
- package/system/client/ClientComponent.ts +1117 -0
- package/system/client/DataStore.ts +101 -0
- package/system/client/DataStoreView.ts +82 -0
- package/system/client/EventEmitter.ts +38 -0
- package/system/client/Net.ts +58 -0
- package/system/client/NetRequest.ts +64 -0
- package/system/server/Application.ts +230 -0
- package/system/server/Component.ts +404 -0
- package/system/server/Components.ts +111 -0
- package/system/server/Cookies.ts +29 -0
- package/system/server/Document.ts +163 -0
- package/system/server/DocumentHead.ts +150 -0
- package/system/server/FormValidation.ts +231 -0
- package/system/server/Handlebars.ts +51 -0
- package/system/server/Request.ts +497 -0
- package/system/server/Session.ts +151 -0
- package/system/server/dom/DOMFragment.ts +7 -0
- package/system/server/dom/DOMNode.ts +140 -0
- package/system/server/dom/HTMLParser.ts +238 -0
- package/tsconfig.json +35 -0
|
@@ -0,0 +1,1117 @@
|
|
|
1
|
+
import { ClientComponentTransition, ClientComponentTransitions, InitializerFunction, LooseObject, StoreChangeCallback, StructuredClientConfig } from '../Types.js';
|
|
2
|
+
import { attributeValueFromString, attributeValueToString, mergeDeep, objectEach, queryStringDecodedSetValue, toCamelCase } from '../Util.js';
|
|
3
|
+
import { DataStoreView } from './DataStoreView.js';
|
|
4
|
+
import { DataStore } from './DataStore.js';
|
|
5
|
+
import { Net } from './Net.js';
|
|
6
|
+
import { NetRequest } from './NetRequest.js';
|
|
7
|
+
import { EventEmitter } from './EventEmitter.js';
|
|
8
|
+
|
|
9
|
+
// window.initializers will always be present
|
|
10
|
+
// each Document has a list of initializers used in components within it
|
|
11
|
+
// and they will be output as initializers = { componentName : initializer }
|
|
12
|
+
declare global {
|
|
13
|
+
interface Window {
|
|
14
|
+
initializers: Record<string, InitializerFunction | string>;
|
|
15
|
+
structuredClientConfig: StructuredClientConfig;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export class ClientComponent extends EventEmitter {
|
|
20
|
+
readonly name: string;
|
|
21
|
+
children: Array<ClientComponent> = [];
|
|
22
|
+
readonly parent: ClientComponent;
|
|
23
|
+
readonly domNode: HTMLElement;
|
|
24
|
+
readonly isRoot: boolean;
|
|
25
|
+
readonly root: ClientComponent;
|
|
26
|
+
store: DataStoreView;
|
|
27
|
+
private storeGlobal: DataStore;
|
|
28
|
+
readonly net: Net = new Net();
|
|
29
|
+
private initializerExecuted: boolean = false;
|
|
30
|
+
|
|
31
|
+
destroyed: boolean = false;
|
|
32
|
+
|
|
33
|
+
private redrawRequest: XMLHttpRequest | null = null;
|
|
34
|
+
|
|
35
|
+
// optional user defined callbacks
|
|
36
|
+
onDestroy?: Function;
|
|
37
|
+
onRedraw?: Function;
|
|
38
|
+
|
|
39
|
+
// callbacks bound using bind method
|
|
40
|
+
private bound: Array<{
|
|
41
|
+
element: HTMLElement;
|
|
42
|
+
event: string;
|
|
43
|
+
callback: (e: Event) => void;
|
|
44
|
+
}> = [];
|
|
45
|
+
|
|
46
|
+
// DOM elements within the component that have a data-if attribute
|
|
47
|
+
private conditionals: Array<HTMLElement> = [];
|
|
48
|
+
|
|
49
|
+
// available for use in data-if and data-classname-[className]
|
|
50
|
+
private conditionalCallbacks: Record<string, (args?: any) => boolean> = {};
|
|
51
|
+
|
|
52
|
+
private conditionalClassNames: Array<{
|
|
53
|
+
element: HTMLElement,
|
|
54
|
+
className: string
|
|
55
|
+
}> = [];
|
|
56
|
+
|
|
57
|
+
private refs: {
|
|
58
|
+
[key: string]: HTMLElement | ClientComponent;
|
|
59
|
+
} = {};
|
|
60
|
+
private refsArray: {
|
|
61
|
+
[key: string]: Array<HTMLElement | ClientComponent>;
|
|
62
|
+
} = {};
|
|
63
|
+
|
|
64
|
+
loaded: boolean = false;
|
|
65
|
+
|
|
66
|
+
// callback executed each time the component is redrawn
|
|
67
|
+
// this is the ideal place for binding any event listeners within component
|
|
68
|
+
private initializer: InitializerFunction | null = null;
|
|
69
|
+
|
|
70
|
+
// data-attr are parsed into an object
|
|
71
|
+
private data: {
|
|
72
|
+
[key: string]: any;
|
|
73
|
+
} = {};
|
|
74
|
+
|
|
75
|
+
constructor(parent: ClientComponent | null, name: string, domNode: HTMLElement, store: DataStore, runInitializer: boolean = true) {
|
|
76
|
+
super();
|
|
77
|
+
this.name = name;
|
|
78
|
+
this.domNode = domNode;
|
|
79
|
+
if (parent === null) {
|
|
80
|
+
this.isRoot = true;
|
|
81
|
+
this.root = this;
|
|
82
|
+
this.parent = this;
|
|
83
|
+
} else {
|
|
84
|
+
this.isRoot = false;
|
|
85
|
+
this.root = parent.root;
|
|
86
|
+
this.parent = parent;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
this.storeGlobal = store;
|
|
90
|
+
this.store = new DataStoreView(this.storeGlobal, this);
|
|
91
|
+
|
|
92
|
+
if (this.isRoot) {
|
|
93
|
+
// only root gets initialized by itself
|
|
94
|
+
// rest of the component tree is initialized during initChildren
|
|
95
|
+
// this is in order to be able to await the children to initialize
|
|
96
|
+
// and in extension be able to tell when all components are initialized
|
|
97
|
+
this.init(runInitializer);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// initialize component and it's children recursively
|
|
102
|
+
private async init(runInitializer: boolean) {
|
|
103
|
+
const initializerExists = window.initializers !== undefined && this.name in window.initializers;
|
|
104
|
+
this.initRefs();
|
|
105
|
+
this.initData();
|
|
106
|
+
this.initModels();
|
|
107
|
+
this.initConditionals();
|
|
108
|
+
await this.initChildren();
|
|
109
|
+
this.promoteRefs();
|
|
110
|
+
|
|
111
|
+
// update conditionals whenever any data in component's store has changed
|
|
112
|
+
this.store.onChange('*', () => {
|
|
113
|
+
this.updateConditionals(true);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// run initializer, if one exists for current component
|
|
117
|
+
// if autoInit = false component will not be automatically initialized
|
|
118
|
+
if (runInitializer && initializerExists) {
|
|
119
|
+
await this.runInitializer();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// update conditionals as soon as component is initialized
|
|
123
|
+
if (this.conditionals.length > 0) {
|
|
124
|
+
if (! initializerExists) {
|
|
125
|
+
// component has no initializer, import all exported fields
|
|
126
|
+
this.store.import(undefined, false, false);
|
|
127
|
+
}
|
|
128
|
+
this.updateConditionals(false);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// deferred component, redraw it immediately
|
|
132
|
+
if (this.data.deferred === true) {
|
|
133
|
+
this.setData('deferred', false, false);
|
|
134
|
+
this.redraw();
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
this.loaded = true;
|
|
138
|
+
|
|
139
|
+
// component emits "ready" when initialized
|
|
140
|
+
// when a component emits "ready" it means it and all of it's children recursively have been initialized
|
|
141
|
+
// if root emits "ready", that means all components in current document are initialized
|
|
142
|
+
this.emit('ready');
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// set initializer callback and execute it
|
|
146
|
+
private async runInitializer(isRedraw: boolean = false) {
|
|
147
|
+
const initializer = window.initializers[this.name];
|
|
148
|
+
if (! initializer) {return;}
|
|
149
|
+
if (! this.initializerExecuted && ! this.destroyed) {
|
|
150
|
+
let initializerFunction: InitializerFunction | null = null;
|
|
151
|
+
if (typeof initializer === 'string') {
|
|
152
|
+
// create an async function using AsyncFunction constructor
|
|
153
|
+
const AsyncFunction = async function () {}.constructor;
|
|
154
|
+
// @ts-ignore
|
|
155
|
+
initializerFunction = new AsyncFunction('const init = ' + initializer + '; await init.apply(this, [...arguments]);') as InitializerFunction;
|
|
156
|
+
} else {
|
|
157
|
+
initializerFunction = initializer;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (initializerFunction) {
|
|
161
|
+
this.initializer = initializerFunction;
|
|
162
|
+
await this.initializer.apply(this, [{
|
|
163
|
+
net: this.net,
|
|
164
|
+
isRedraw
|
|
165
|
+
}]);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
this.initializerExecuted = true;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// parse all data-[key] attributes found on this.domNode into this.data object
|
|
172
|
+
// key converted to camelCase
|
|
173
|
+
// values are expected to be encoded using attributeValueToString
|
|
174
|
+
// and will be decoded using attributeValueFromString
|
|
175
|
+
private initData(): void {
|
|
176
|
+
for (let i = 0; i < this.domNode.attributes.length; i++) {
|
|
177
|
+
// data-attr, convert to dataAttr and store value
|
|
178
|
+
if (/^((number|string|boolean|object|any):)?data-[^\s]+/.test(this.domNode.attributes[i].name)) {
|
|
179
|
+
const value = this.domNode.attributes[i].value;
|
|
180
|
+
const attrData = attributeValueFromString(value);
|
|
181
|
+
|
|
182
|
+
if (typeof attrData === 'object') {
|
|
183
|
+
this.setData(attrData.key, attrData.value, false);
|
|
184
|
+
} else {
|
|
185
|
+
// not a valid attribute data string, assign as is (string)
|
|
186
|
+
const key = toCamelCase(this.domNode.attributes[i].name.substring(5));
|
|
187
|
+
this.setData(key, attrData, false);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// array of attribute data all the way up to root, or the first component with no dependencies (no data-use attribute)
|
|
194
|
+
// used for redraw
|
|
195
|
+
public pathData(): Array<{
|
|
196
|
+
[key: string]: string;
|
|
197
|
+
}> {
|
|
198
|
+
let current: ClientComponent = this;
|
|
199
|
+
const data = [];
|
|
200
|
+
do {
|
|
201
|
+
data.push(current.data);
|
|
202
|
+
if (current.isRoot || !current.data.use) {
|
|
203
|
+
break;
|
|
204
|
+
}
|
|
205
|
+
current = current.parent;
|
|
206
|
+
} while (true);
|
|
207
|
+
return data.reverse();
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// sets this.data[key] and optionally this.store[key], key is passed through toCamelCase
|
|
211
|
+
// sets data-[key]="value" attribute on this.domNode, value passed through attributeValueToString
|
|
212
|
+
// if updateStore is true (default) value is also applied to this.store
|
|
213
|
+
// returns this to allow chaining
|
|
214
|
+
public setData(key: string, value: any, updateStore: boolean = true): ClientComponent {
|
|
215
|
+
const dataKey = `data-${key}`;
|
|
216
|
+
this.domNode.setAttribute(dataKey, attributeValueToString(key, value));
|
|
217
|
+
const keyCamelCase = toCamelCase(key);
|
|
218
|
+
this.data[keyCamelCase] = value;
|
|
219
|
+
if (updateStore) {
|
|
220
|
+
this.store.set(keyCamelCase, value);
|
|
221
|
+
}
|
|
222
|
+
return this;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// find all DOM nodes with data-structured-component attribute within this component,
|
|
226
|
+
// instantiate a ClientComponent with them and add them to this.children
|
|
227
|
+
// if callback is a function, for each instantiated child
|
|
228
|
+
// callback is executed with child as first argument
|
|
229
|
+
private async initChildren(scope?: HTMLElement, callback?: (component: ClientComponent) => void): Promise<void> {
|
|
230
|
+
scope = scope || this.domNode;
|
|
231
|
+
|
|
232
|
+
// array of promises that are resolved when child nodes are recursively initialized
|
|
233
|
+
const childInitPromises: Array<Promise<void>> = [];
|
|
234
|
+
|
|
235
|
+
for (let i = 0; i < scope.childNodes.length; i++) {
|
|
236
|
+
const childNode = scope.childNodes[i];
|
|
237
|
+
if (childNode.nodeType == 1) {
|
|
238
|
+
if ((childNode as HTMLElement).hasAttribute(`data-${window.structuredClientConfig.componentNameAttribute}`)) {
|
|
239
|
+
// found a child component, add to children
|
|
240
|
+
const component = new ClientComponent(this, (childNode as HTMLElement).getAttribute(`data-${window.structuredClientConfig.componentNameAttribute}`) || '', childNode as HTMLElement, this.storeGlobal);
|
|
241
|
+
this.children.push(component);
|
|
242
|
+
if (typeof callback === 'function') {
|
|
243
|
+
callback(component);
|
|
244
|
+
}
|
|
245
|
+
childInitPromises.push(component.init(true));
|
|
246
|
+
} else {
|
|
247
|
+
// not a component, resume from here recursively
|
|
248
|
+
childInitPromises.push(this.initChildren((childNode as HTMLElement), callback));
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// await children to be initialized
|
|
254
|
+
await Promise.all(childInitPromises);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// fetch from server and replace with new HTML
|
|
258
|
+
// if data is provided, each key will be set on component using this.setData
|
|
259
|
+
// and as such, component will receive it when rendering
|
|
260
|
+
public async redraw(data?: LooseObject): Promise<void> {
|
|
261
|
+
|
|
262
|
+
if (window.structuredClientConfig.componentRender === false) {
|
|
263
|
+
console.error(`Can't redraw component, component rendering URL disabled`);
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
if (this.destroyed) {return;}
|
|
268
|
+
|
|
269
|
+
// set data if provided
|
|
270
|
+
if (data) {
|
|
271
|
+
objectEach(data, (key, val) => {
|
|
272
|
+
this.setData(key, val, false);
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// abort existing redraw call, if in progress
|
|
277
|
+
if (this.redrawRequest !== null) {
|
|
278
|
+
this.redrawRequest.abort();
|
|
279
|
+
this.redrawRequest = null;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// request a component to be re-rendered on the server
|
|
283
|
+
// unwrap = true so that component container is excluded
|
|
284
|
+
// this component already has it's own container and we only care about what changed within it
|
|
285
|
+
const redrawRequest = new NetRequest('POST', window.structuredClientConfig.componentRender, {
|
|
286
|
+
'content-type': 'application/json'
|
|
287
|
+
});
|
|
288
|
+
this.redrawRequest = redrawRequest.xhr;
|
|
289
|
+
const componentDataJSON = await redrawRequest.send(JSON.stringify({
|
|
290
|
+
component: this.name,
|
|
291
|
+
attributes: this.data,
|
|
292
|
+
unwrap: true
|
|
293
|
+
}));
|
|
294
|
+
// clear redraw request as the request is executed and does not need to be cancelled
|
|
295
|
+
// in case component gets redrawn again
|
|
296
|
+
this.redrawRequest = null;
|
|
297
|
+
|
|
298
|
+
// should only happen if a previous redraw attempt was aborted
|
|
299
|
+
if (componentDataJSON.length === 0) { return; }
|
|
300
|
+
|
|
301
|
+
// mark component as not loaded
|
|
302
|
+
this.loaded = false;
|
|
303
|
+
|
|
304
|
+
// if user has defined onRedraw callback, run it
|
|
305
|
+
if (typeof this.onRedraw === 'function') {
|
|
306
|
+
this.onRedraw.apply(this)
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// remove all bound event listeners as DOM will get replaced in the process
|
|
310
|
+
this.unbindAll();
|
|
311
|
+
|
|
312
|
+
// destroy existing children as their associated domNode is no longer part of the DOM
|
|
313
|
+
// new children will be initialized based on the new DOM
|
|
314
|
+
// new DOM may contain same children (same componentId) however by destroying the children
|
|
315
|
+
// any store change listeners will be lost, before destroying each child
|
|
316
|
+
// keep a copy of the store change listeners, which we'll use later to restore those listeners
|
|
317
|
+
const childStoreChangeCallbacks: Record<string, Record<string, Array<StoreChangeCallback>>> = {}
|
|
318
|
+
Array.from(this.children).forEach((child) => {
|
|
319
|
+
childStoreChangeCallbacks[child.getData<string>('componentId')] = child.store.onChangeCallbacks();
|
|
320
|
+
child.remove();
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
const componentData: {
|
|
324
|
+
html: string;
|
|
325
|
+
initializers: Record<string, string>;
|
|
326
|
+
data: LooseObject;
|
|
327
|
+
} = JSON.parse(componentDataJSON);
|
|
328
|
+
|
|
329
|
+
// populate this.domNode with new HTML
|
|
330
|
+
this.domNode.innerHTML = componentData.html;
|
|
331
|
+
|
|
332
|
+
// apply new data received from the server as it may have changed
|
|
333
|
+
// only exported data is included here
|
|
334
|
+
objectEach(componentData.data, (key, val) => {
|
|
335
|
+
this.setData(key, val, false);
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
// add any new initializers to global initializers list
|
|
339
|
+
for (const key in componentData.initializers) {
|
|
340
|
+
if (!window.initializers[key]) {
|
|
341
|
+
window.initializers[key] = componentData.initializers[key];
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// init new children, restoring their store change listeners in the process
|
|
346
|
+
await this.initChildren(this.domNode, (childNew) => {
|
|
347
|
+
const childNewId = childNew.getData<string>('componentId');
|
|
348
|
+
const existingChild = childNewId in childStoreChangeCallbacks;
|
|
349
|
+
if (existingChild) {
|
|
350
|
+
// child existed before redraw, re-apply onChange callbacks
|
|
351
|
+
objectEach(childStoreChangeCallbacks[childNewId], (key, callbacks) => {
|
|
352
|
+
callbacks.forEach((callback) => {
|
|
353
|
+
childNew.store.onChange(key, callback);
|
|
354
|
+
});
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// idea was that existing child nodes would be initialized with isRedraw = true
|
|
359
|
+
// however after giving it some thought - probably not desirable
|
|
360
|
+
// the whole idea with isRedraw is to inform the initializer whether it's
|
|
361
|
+
// a fresh instance of ClientComponent or an existing one
|
|
362
|
+
// while child (even existing ones) are technically redrawn in this case,
|
|
363
|
+
// they do get a fresh instance of a ClientComponent, hence isRedraw = true would be misleading
|
|
364
|
+
// keeping this comment here in case in the future a need arises to inform children
|
|
365
|
+
// they were redrawn as a consequence of parent redraw
|
|
366
|
+
// if ever done, make sure to set 4th argument of initChildren to false (disabling autoInit)
|
|
367
|
+
// childNew.init(existingChild);
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
// re-init conditionals and refs
|
|
371
|
+
this.refs = {};
|
|
372
|
+
this.refsArray = {};
|
|
373
|
+
this.conditionals = [];
|
|
374
|
+
this.initRefs();
|
|
375
|
+
this.initModels();
|
|
376
|
+
this.initConditionals();
|
|
377
|
+
this.promoteRefs();
|
|
378
|
+
|
|
379
|
+
// run the initializer
|
|
380
|
+
if (this.initializer) {
|
|
381
|
+
this.initializerExecuted = false;
|
|
382
|
+
this.runInitializer(true);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
this.updateConditionals(false);
|
|
386
|
+
|
|
387
|
+
// mark component as loaded
|
|
388
|
+
this.loaded = true;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// populates conditionals and conditionalClassNames
|
|
392
|
+
// these react to changes to store data
|
|
393
|
+
// to show/hide elements or apply/remove class names to/from them
|
|
394
|
+
private initConditionals(node?: HTMLElement): void {
|
|
395
|
+
const isSelf = node === undefined;
|
|
396
|
+
if (node === undefined) {
|
|
397
|
+
node = this.domNode;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
for (const attribute of node.attributes) {
|
|
401
|
+
// data-if
|
|
402
|
+
if (attribute.name === 'data-if') {
|
|
403
|
+
this.conditionals.push(node);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// data-classname-[className]
|
|
407
|
+
if (attribute.name.startsWith('data-classname')) {
|
|
408
|
+
const className = attribute.name.substring(15);
|
|
409
|
+
this.conditionalClassNames.push({
|
|
410
|
+
element: node,
|
|
411
|
+
className
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
node.childNodes.forEach((child) => {
|
|
417
|
+
if (child.nodeType === 1 && (isSelf || !node?.hasAttribute(`data-${window.structuredClientConfig.componentNameAttribute}`))) {
|
|
418
|
+
this.initConditionals(child as HTMLElement);
|
|
419
|
+
}
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// initialize refs and refsArray within this component
|
|
424
|
+
// ref="refName"
|
|
425
|
+
// any DOM nodes with attribute ref="refName" will be stored under refs as { refName: HTMLElement }
|
|
426
|
+
// and can be returned using the ref method, refName should be unique,
|
|
427
|
+
// if multiple DOM nodes have the same refName, only the last one will be kept
|
|
428
|
+
// if ref is on a component tag, that ref will get promoted to ClientComponent
|
|
429
|
+
// array:ref="refName"
|
|
430
|
+
// any DOM nodes with attribute array:ref="refName" will be grouped
|
|
431
|
+
// under refsArray as { refName: Array<HTMLElement> } and can be returned using refArray method
|
|
432
|
+
// in contrast to ref, array:ref's refName does not need to be unique, in fact, since it's used
|
|
433
|
+
// to group as set of DOM nodes, it only makes sense if multiple DOM nodes share the same refName
|
|
434
|
+
private initRefs(node?: HTMLElement): void {
|
|
435
|
+
const isSelf = node === undefined;
|
|
436
|
+
if (node === undefined) {
|
|
437
|
+
node = this.domNode;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
if (node.hasAttribute('ref')) {
|
|
441
|
+
this.refs[node.getAttribute('ref') || 'undefined'] = node;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
if (node.hasAttribute('array:ref')) {
|
|
445
|
+
const key = node.getAttribute('array:ref') || 'undefined';
|
|
446
|
+
if (!(key in this.refsArray)) {
|
|
447
|
+
this.refsArray[key] = [];
|
|
448
|
+
}
|
|
449
|
+
this.refsArray[key].push(node);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
node.childNodes.forEach((child) => {
|
|
453
|
+
if (child.nodeType === 1 && (isSelf || !node?.hasAttribute(`data-${window.structuredClientConfig.componentNameAttribute}`))) {
|
|
454
|
+
this.initRefs(child as HTMLElement);
|
|
455
|
+
}
|
|
456
|
+
});
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// make inputs with data-model="field" work
|
|
460
|
+
// nested data works too, data-model="obj[nested][key]" or data-model="obj[nested][key][]"
|
|
461
|
+
private initModels(node?: HTMLElement, modelNodes: Array<HTMLInputElement> = []) {
|
|
462
|
+
const isSelf = node === undefined;
|
|
463
|
+
if (node === undefined) {
|
|
464
|
+
node = this.domNode;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// given a HTMLInput element that has data-model attribute, returns an object with the data
|
|
468
|
+
// for example:
|
|
469
|
+
// data-model="name" may result with { name: "John" }
|
|
470
|
+
// data-model="user[name]" may result with { user: { name: "John" } }
|
|
471
|
+
const modelData = (node: HTMLInputElement): LooseObject => {
|
|
472
|
+
const field = node.getAttribute('data-model');
|
|
473
|
+
if (field) {
|
|
474
|
+
const isCheckbox = node.tagName === 'INPUT' && node.type === 'checkbox';
|
|
475
|
+
const valueRaw = isCheckbox ? node.checked : node.value;
|
|
476
|
+
const value = queryStringDecodedSetValue(field, valueRaw);
|
|
477
|
+
return value;
|
|
478
|
+
}
|
|
479
|
+
return {}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// given a loose object, sets all keys with corresponding value on current component
|
|
483
|
+
const update = (data: LooseObject) => {
|
|
484
|
+
objectEach(data, (key, val) => {
|
|
485
|
+
this.store.set(key, val);
|
|
486
|
+
});
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
if (node.hasAttribute('data-model') && (node.tagName === 'INPUT' || node.tagName === 'SELECT' || node.tagName === 'TEXTAREA')) {
|
|
490
|
+
// found a model node, store to array modelNodes
|
|
491
|
+
modelNodes.push(node as HTMLInputElement);
|
|
492
|
+
} else {
|
|
493
|
+
// not a model, but may contain models
|
|
494
|
+
// init model nodes recursively from here
|
|
495
|
+
node.childNodes.forEach((child) => {
|
|
496
|
+
if (child.nodeType === 1 && (isSelf || !node?.hasAttribute(`data-${window.structuredClientConfig.componentNameAttribute}`))) {
|
|
497
|
+
this.initModels(child as HTMLElement, modelNodes);
|
|
498
|
+
}
|
|
499
|
+
});
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
if (isSelf) {
|
|
503
|
+
// all model nodes are now contained in modelNodes array
|
|
504
|
+
|
|
505
|
+
// data for the initial update, we want to gather all data up in one object
|
|
506
|
+
// so that nested keys don't trigger more updates than necessary
|
|
507
|
+
let data: LooseObject = {}
|
|
508
|
+
modelNodes.forEach((modelNode) => {
|
|
509
|
+
// on change, update component data
|
|
510
|
+
modelNode.addEventListener('input', () => {
|
|
511
|
+
let data = modelData(modelNode);
|
|
512
|
+
const key = Object.keys(data)[0];
|
|
513
|
+
if (typeof data[key] === 'object') {
|
|
514
|
+
const dataExisting = this.store.get<LooseObject>(key);
|
|
515
|
+
if (dataExisting !== undefined) {
|
|
516
|
+
data = mergeDeep({}, {[key]: dataExisting}, data);
|
|
517
|
+
} else {
|
|
518
|
+
data = mergeDeep({}, data);
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
update(data);
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
// include current node's data into initial update data
|
|
525
|
+
const field = modelNode.getAttribute('data-model');
|
|
526
|
+
if (field) {
|
|
527
|
+
const isCheckbox = modelNode.tagName === 'INPUT' && modelNode.type === 'checkbox';
|
|
528
|
+
const valueRaw = isCheckbox ? modelNode.checked : modelNode.value;
|
|
529
|
+
const value = queryStringDecodedSetValue(field, valueRaw);
|
|
530
|
+
data = mergeDeep(data, value);
|
|
531
|
+
}
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
// run the initial data update with data gathered from all model nodes
|
|
535
|
+
update(data);
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
// normally, ref will return a HTMLElement, however if ref attribute is found on a component tag
|
|
540
|
+
// this will upgrade it to ClientComponent
|
|
541
|
+
private promoteRefs() {
|
|
542
|
+
this.children.forEach((child) => {
|
|
543
|
+
// promote regular refs
|
|
544
|
+
const ref = child.domNode.getAttribute('ref');
|
|
545
|
+
if (ref) {
|
|
546
|
+
this.refs[ref] = child;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// promote array refs
|
|
550
|
+
const refArray = child.domNode.getAttribute('array:ref');
|
|
551
|
+
if (refArray !== null && refArray in this.refsArray) {
|
|
552
|
+
const nodeIndex = this.refsArray[refArray].indexOf(child.domNode);
|
|
553
|
+
if (nodeIndex > -1) {
|
|
554
|
+
this.refsArray[refArray].splice(nodeIndex, 1, child);
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
});
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// returns a single HTMLElement or ClientComponent that has ref="refName" attribute
|
|
561
|
+
// if ref attribute is on a component tag, the ref will be promoted to ClientComponent
|
|
562
|
+
// in other cases it returns the HTMLElement
|
|
563
|
+
// this does not check if the ref exists
|
|
564
|
+
// you should make sure it does, otherwise you will get undefined at runtime
|
|
565
|
+
public ref<T>(refName: string): T {
|
|
566
|
+
return this.refs[refName] as T;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// returns an array of HTMLElement (type of the elements can be specified) that have array:ref="refName"
|
|
570
|
+
public refArray<T>(refName: string): Array<T> {
|
|
571
|
+
return (this.refsArray[refName] || []) as Array<T>;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// condition can be one of:
|
|
575
|
+
// 1) access to a boolean property in component store: [key]
|
|
576
|
+
// 2) comparison of a store property [key] ==|===|!=|<|>|<=|>= [comparison value or key]
|
|
577
|
+
// 3) method methodName() or methodName(arg)
|
|
578
|
+
private execCondition(conditionRaw: string): boolean {
|
|
579
|
+
const condition = conditionRaw.trim();
|
|
580
|
+
const isMethod = condition.endsWith(')');
|
|
581
|
+
|
|
582
|
+
if (isMethod) {
|
|
583
|
+
// method (case 3)
|
|
584
|
+
// method has to be in format !?[a-zA-Z]+[a-zA-Z0-9_]+\([^)]+\)
|
|
585
|
+
// extract expression parts
|
|
586
|
+
const parts = /^(!?)\s*([a-zA-Z]+[a-zA-Z0-9_]*)\(([^)]*)\)$/.exec(condition);
|
|
587
|
+
if (parts === null) {
|
|
588
|
+
console.error(`Could not parse condition ${condition}`);
|
|
589
|
+
return false;
|
|
590
|
+
}
|
|
591
|
+
const negated = parts[1] === '!';
|
|
592
|
+
const functionName = parts[2];
|
|
593
|
+
const args = parts[3].trim();
|
|
594
|
+
|
|
595
|
+
// make sure there is a registered callback with this name
|
|
596
|
+
if (typeof this.conditionalCallbacks[functionName] !== 'function') {
|
|
597
|
+
console.warn(`No registered conditional callback '${functionName}'`);
|
|
598
|
+
return false;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
// run registered callback
|
|
602
|
+
const isTrue = this.conditionalCallbacks[functionName](args === '' ? undefined : eval(`(${args})`));
|
|
603
|
+
if (negated) {
|
|
604
|
+
return ! isTrue;
|
|
605
|
+
}
|
|
606
|
+
return isTrue;
|
|
607
|
+
} else {
|
|
608
|
+
// expression not a method
|
|
609
|
+
const parts = /^(!)?\s*([a-zA-Z]+[a-zA-Z0-9_]*)\s*((?:==)|(?:===)|(?:!=)|(?:!==)|<|>|(?:<=)|(?:>=))?\s*([^=].+)?$/.exec(condition);
|
|
610
|
+
if (parts === null) {
|
|
611
|
+
console.error(`Could not parse condition ${condition}`);
|
|
612
|
+
return false;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
const property = parts[2];
|
|
616
|
+
const value = this.store.get(property);
|
|
617
|
+
const isComparison = parts[3] !== undefined;
|
|
618
|
+
if (isComparison) {
|
|
619
|
+
// comparison (case 2)
|
|
620
|
+
// left hand side is the property name, right hand side is an expression
|
|
621
|
+
let rightHandSide = null;
|
|
622
|
+
try {
|
|
623
|
+
// this won't fail as long as parts[4] is a recognized primitive (number, boolean, string...)
|
|
624
|
+
rightHandSide = eval(`${parts[4]}`);
|
|
625
|
+
} catch(e) {
|
|
626
|
+
// parts[4] failed to be parsed as a primitive
|
|
627
|
+
// assume it's a store value to allow comparing one store value to another
|
|
628
|
+
rightHandSide = this.store.get(parts[4]);
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
const comparisonSymbol = parts[3];
|
|
632
|
+
|
|
633
|
+
if (comparisonSymbol === '==') {
|
|
634
|
+
return value == rightHandSide;
|
|
635
|
+
} else if (comparisonSymbol === '===') {
|
|
636
|
+
return value === rightHandSide;
|
|
637
|
+
} else if (comparisonSymbol === '!=') {
|
|
638
|
+
return value != rightHandSide;
|
|
639
|
+
} else if (comparisonSymbol === '!==') {
|
|
640
|
+
return value !== rightHandSide;
|
|
641
|
+
} else {
|
|
642
|
+
// number comparison
|
|
643
|
+
if (typeof value !== 'number') {
|
|
644
|
+
// if value is not a number, these comparisons makes no sense, return false
|
|
645
|
+
return false;
|
|
646
|
+
}
|
|
647
|
+
if (comparisonSymbol === '>') {
|
|
648
|
+
return value > rightHandSide;
|
|
649
|
+
} else if (comparisonSymbol === '>=') {
|
|
650
|
+
return value >= rightHandSide;
|
|
651
|
+
} else if (comparisonSymbol === '<') {
|
|
652
|
+
return value < rightHandSide;
|
|
653
|
+
} else if (comparisonSymbol === '<=') {
|
|
654
|
+
return value <= rightHandSide;
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
return false;
|
|
659
|
+
|
|
660
|
+
} else {
|
|
661
|
+
// not a comparison (case 1)
|
|
662
|
+
const negated = parts[1] === '!';
|
|
663
|
+
const isTrue = this.store.get<boolean>(property);
|
|
664
|
+
if (negated) {
|
|
665
|
+
return !isTrue;
|
|
666
|
+
}
|
|
667
|
+
// value may not be a boolean, coerce to boolean without changing value
|
|
668
|
+
return !!isTrue;
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
// conditionals (data-if and data-classname-[className]) both support methods in condition
|
|
674
|
+
// eg. data-if="someMethod()"
|
|
675
|
+
// this allows users to define callbacks used in conditionals' conditions
|
|
676
|
+
// by default, this also runs updateConditionals
|
|
677
|
+
// as there might be conditionals that are using this callback
|
|
678
|
+
public conditionalCallback(name: string, callback: (args?: any) => boolean, updateConditionals: boolean = true): void {
|
|
679
|
+
this.conditionalCallbacks[name] = callback;
|
|
680
|
+
if (updateConditionals) {
|
|
681
|
+
this.updateConditionals(false);
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
// updates conditionals (data-if and data-classname-[className])
|
|
686
|
+
// data-if (conditionally show/hide DOM node)
|
|
687
|
+
// data-classname-[className] (conditionally add className to classList of the DOM node)
|
|
688
|
+
private updateConditionals(enableTransition: boolean) {
|
|
689
|
+
if (this.destroyed) {return;}
|
|
690
|
+
|
|
691
|
+
// data-if conditions
|
|
692
|
+
this.conditionals.forEach((node) => {
|
|
693
|
+
const condition = node.getAttribute('data-if');
|
|
694
|
+
|
|
695
|
+
if (typeof condition === 'string') {
|
|
696
|
+
const show = this.execCondition(condition);
|
|
697
|
+
|
|
698
|
+
if (show === true) {
|
|
699
|
+
// node.style.display = '';
|
|
700
|
+
this.show(node, enableTransition);
|
|
701
|
+
} else {
|
|
702
|
+
// node.style.display = 'none';
|
|
703
|
+
this.hide(node, enableTransition);
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
});
|
|
707
|
+
|
|
708
|
+
// data-classname conditions
|
|
709
|
+
this.conditionalClassNames.forEach((conditional) => {
|
|
710
|
+
const condition = conditional.element.getAttribute(`data-classname-${conditional.className}`);
|
|
711
|
+
|
|
712
|
+
if (typeof condition === 'string') {
|
|
713
|
+
const enableClassName = this.execCondition(condition);
|
|
714
|
+
|
|
715
|
+
if (enableClassName === true) {
|
|
716
|
+
conditional.element.classList.add(conditional.className);
|
|
717
|
+
} else {
|
|
718
|
+
conditional.element.classList.remove(conditional.className);
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
});
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
// remove the DOM node and delete from parent.children effectively removing self from the tree
|
|
725
|
+
// the method could be sync, but since we want to allow for potentially async user destructors
|
|
726
|
+
// it is async
|
|
727
|
+
public async remove(): Promise<void> {
|
|
728
|
+
if (!this.isRoot) {
|
|
729
|
+
// remove children recursively
|
|
730
|
+
const children = Array.from(this.children);
|
|
731
|
+
for (let i = 0; i < children.length; i++) {
|
|
732
|
+
await children[i].remove();
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
// remove from parent's children array
|
|
736
|
+
if (this.parent) {
|
|
737
|
+
this.parent.children.splice(this.parent.children.indexOf(this), 1);
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
// remove DOM node
|
|
741
|
+
this.domNode.parentElement?.removeChild(this.domNode);
|
|
742
|
+
await this.destroy();
|
|
743
|
+
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
// travel up the tree until a parent with given parentName is found
|
|
748
|
+
// if no such parent is found returns null
|
|
749
|
+
public parentFind(parentName: string): ClientComponent | null {
|
|
750
|
+
let parent = this.parent;
|
|
751
|
+
while (true) {
|
|
752
|
+
if (parent.name === parentName) {
|
|
753
|
+
return parent;
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
if (parent.isRoot) {
|
|
757
|
+
break;
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
parent = parent.parent;
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
return null;
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
// find a component with given name within this component
|
|
767
|
+
// if recursive = true, it searches recursively
|
|
768
|
+
// returns the first found component or null if no components were found
|
|
769
|
+
public find(componentName: string, recursive: boolean = true): null | ClientComponent {
|
|
770
|
+
for (let i = 0; i < this.children.length; i++) {
|
|
771
|
+
const child = this.children[i];
|
|
772
|
+
if (child.name == componentName) {
|
|
773
|
+
// found it
|
|
774
|
+
return child;
|
|
775
|
+
} else {
|
|
776
|
+
if (recursive) {
|
|
777
|
+
// search recursively, if found return
|
|
778
|
+
const inChild = child.find(componentName, recursive);
|
|
779
|
+
if (inChild) {
|
|
780
|
+
return inChild;
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
return null;
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
// find all components with given name within this component
|
|
789
|
+
// if recursive = true, it searches recursively
|
|
790
|
+
// returns an array of found components
|
|
791
|
+
public query(componentName: string, recursive: boolean = true, results: Array<ClientComponent> = []): Array<ClientComponent> {
|
|
792
|
+
for (let i = 0; i < this.children.length; i++) {
|
|
793
|
+
const child = this.children[i];
|
|
794
|
+
if (child.name == componentName) {
|
|
795
|
+
// found a component with name = componentName, add to results
|
|
796
|
+
results.push(child);
|
|
797
|
+
} else {
|
|
798
|
+
if (recursive) {
|
|
799
|
+
// search recursively, if found return
|
|
800
|
+
child.query(componentName, recursive, results);
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
return results;
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
// adds a new component to DOM/component tree
|
|
808
|
+
// appendTo is a selector within this component's DOM or a HTMLElement (which can be outside this component)
|
|
809
|
+
// data can be an object which is passed to added component
|
|
810
|
+
// regardless whether appendTo is within this component or not,
|
|
811
|
+
// added component will always be a child of this component
|
|
812
|
+
// returns a promise that resolves with the added component
|
|
813
|
+
public async add(appendTo: string | HTMLElement, componentName: string, data?: LooseObject): Promise<ClientComponent | null> {
|
|
814
|
+
if (window.structuredClientConfig.componentRender === false) {
|
|
815
|
+
console.error(`Can't add component, component rendering URL disabled`);
|
|
816
|
+
return null;
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
const container = typeof appendTo === 'string' ? this.domNode.querySelector(appendTo) : appendTo;
|
|
820
|
+
|
|
821
|
+
if (! (container instanceof HTMLElement)) {
|
|
822
|
+
throw new Error(`${this.name}.add() - appendTo selector not found within this component`);
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
// request rendered component from the server
|
|
826
|
+
// expected result is JSON, containing { html, initializers, data }
|
|
827
|
+
// unwrap set to false as we want the component container to be returned (unlike redraw)
|
|
828
|
+
const req = new NetRequest('POST', window.structuredClientConfig.componentRender, {
|
|
829
|
+
'content-type': 'application/json'
|
|
830
|
+
});
|
|
831
|
+
const componentDataJSON = await req.send(JSON.stringify({
|
|
832
|
+
component: componentName,
|
|
833
|
+
attributes: data,
|
|
834
|
+
unwrap: false
|
|
835
|
+
}));
|
|
836
|
+
|
|
837
|
+
const res: {
|
|
838
|
+
html: string;
|
|
839
|
+
initializers: Record<string, string>;
|
|
840
|
+
data: LooseObject;
|
|
841
|
+
} = JSON.parse(componentDataJSON);
|
|
842
|
+
|
|
843
|
+
// if the current document did not include the added component (or components loaded within it)
|
|
844
|
+
// it's initializer will not be present in window.initializers
|
|
845
|
+
// add any missing initializers to global initializers list
|
|
846
|
+
for (let key in res.initializers) {
|
|
847
|
+
if (!window.initializers[key]) {
|
|
848
|
+
window.initializers[key] = res.initializers[key];
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
// create a temporary container to load the returned HTML into
|
|
853
|
+
const tmpContainer = document.createElement('div');
|
|
854
|
+
tmpContainer.innerHTML = res.html;
|
|
855
|
+
|
|
856
|
+
// get the first child, which is always the component wrapper <div data-structured-component="..."></div>
|
|
857
|
+
const componentNode = tmpContainer.firstChild as HTMLElement;
|
|
858
|
+
|
|
859
|
+
// create an instance of ClientComponent for the added component and add it to this.children
|
|
860
|
+
const component = new ClientComponent(this, componentName, componentNode, this.storeGlobal);
|
|
861
|
+
this.children.push(component);
|
|
862
|
+
await component.init(true);
|
|
863
|
+
|
|
864
|
+
// add the component's DOM node to container
|
|
865
|
+
container.appendChild(componentNode);
|
|
866
|
+
|
|
867
|
+
return component;
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
public getData<T>(key?: string): T {
|
|
871
|
+
if (!key) {
|
|
872
|
+
return this.data as T;
|
|
873
|
+
}
|
|
874
|
+
return this.data[key] as T;
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
// shows a previously hidden DOM node (domNode.style.display = '')
|
|
878
|
+
// if the DOM node has data-transition attributes, it will run the transition while showing the node
|
|
879
|
+
public show(domNode: HTMLElement, enableTransition: boolean = true): void {
|
|
880
|
+
if (!enableTransition) {
|
|
881
|
+
domNode.style.display = '';
|
|
882
|
+
return;
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
if (domNode.style.display !== 'none') { return; }
|
|
886
|
+
|
|
887
|
+
// const transitions = this.transitions.show;
|
|
888
|
+
const transitions = this.transitionAttributes(domNode).show;
|
|
889
|
+
|
|
890
|
+
const transitionsActive = Object.keys(transitions).filter((key: keyof ClientComponentTransition) => {
|
|
891
|
+
return transitions[key] !== false;
|
|
892
|
+
}).reduce((prev, curr) => {
|
|
893
|
+
const key = curr as keyof ClientComponentTransition;
|
|
894
|
+
prev[key] = transitions[key];
|
|
895
|
+
return prev;
|
|
896
|
+
}, {} as {
|
|
897
|
+
[key in keyof ClientComponentTransition]: false | number;
|
|
898
|
+
});
|
|
899
|
+
|
|
900
|
+
if (Object.keys(transitionsActive).length === 0) {
|
|
901
|
+
domNode.style.display = '';
|
|
902
|
+
return;
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
domNode.style.display = '';
|
|
906
|
+
|
|
907
|
+
const onTransitionEnd = (e: any) => {
|
|
908
|
+
domNode.style.opacity = '1';
|
|
909
|
+
domNode.style.transition = '';
|
|
910
|
+
domNode.style.transformOrigin = 'unset';
|
|
911
|
+
domNode.removeEventListener('transitionend', onTransitionEnd);
|
|
912
|
+
domNode.removeEventListener('transitioncancel', onTransitionEnd);
|
|
913
|
+
};
|
|
914
|
+
|
|
915
|
+
domNode.addEventListener('transitionend', onTransitionEnd);
|
|
916
|
+
domNode.addEventListener('transitioncancel', onTransitionEnd);
|
|
917
|
+
|
|
918
|
+
if (transitionsActive.slide) {
|
|
919
|
+
|
|
920
|
+
// if specified use given transformOrigin
|
|
921
|
+
const transformOrigin = domNode.getAttribute('data-transform-origin-show') || '50% 0';
|
|
922
|
+
|
|
923
|
+
domNode.style.transformOrigin = transformOrigin;
|
|
924
|
+
const axis = this.transitionAxis(domNode, 'show');
|
|
925
|
+
domNode.style.transform = `scale${axis}(0.01)`;
|
|
926
|
+
domNode.style.transition = `transform ${transitionsActive.slide / 1000}s`;
|
|
927
|
+
setTimeout(() => {
|
|
928
|
+
// domNode.style.height = height + 'px';
|
|
929
|
+
domNode.style.transform = `scale${axis}(1)`;
|
|
930
|
+
}, 100);
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
if (transitionsActive.fade) {
|
|
934
|
+
domNode.style.opacity = '0';
|
|
935
|
+
domNode.style.transition = `opacity ${transitionsActive.fade / 1000}s`;
|
|
936
|
+
setTimeout(() => {
|
|
937
|
+
domNode.style.opacity = '1';
|
|
938
|
+
}, 100);
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
// hides the given DOM node (domNode.style.display = 'none')
|
|
944
|
+
// if the DOM node has data-transition attributes, it will run the transition before hiding the node
|
|
945
|
+
public hide(domNode: HTMLElement, enableTransition: boolean = true): void {
|
|
946
|
+
if (!enableTransition) {
|
|
947
|
+
domNode.style.display = 'none';
|
|
948
|
+
return;
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
if (domNode.style.display === 'none') { return; }
|
|
952
|
+
|
|
953
|
+
// const transitions = this.transitions.hide;
|
|
954
|
+
const transitions = this.transitionAttributes(domNode).hide;
|
|
955
|
+
|
|
956
|
+
const transitionsActive = Object.keys(transitions).filter((key: keyof ClientComponentTransition) => {
|
|
957
|
+
return transitions[key] !== false;
|
|
958
|
+
}).reduce((prev, curr) => {
|
|
959
|
+
const key = curr as keyof ClientComponentTransition;
|
|
960
|
+
prev[key] = transitions[key];
|
|
961
|
+
return prev;
|
|
962
|
+
}, {} as {
|
|
963
|
+
[key in keyof ClientComponentTransition]: false | number;
|
|
964
|
+
});
|
|
965
|
+
|
|
966
|
+
if (Object.keys(transitionsActive).length === 0) {
|
|
967
|
+
// no transitions
|
|
968
|
+
domNode.style.display = 'none';
|
|
969
|
+
} else {
|
|
970
|
+
|
|
971
|
+
const onTransitionEnd = (e: any) => {
|
|
972
|
+
domNode.style.display = 'none';
|
|
973
|
+
domNode.style.opacity = '1';
|
|
974
|
+
domNode.style.transition = '';
|
|
975
|
+
domNode.style.transformOrigin = 'unset';
|
|
976
|
+
domNode.removeEventListener('transitionend', onTransitionEnd);
|
|
977
|
+
domNode.removeEventListener('transitioncancel', onTransitionEnd);
|
|
978
|
+
};
|
|
979
|
+
|
|
980
|
+
domNode.addEventListener('transitionend', onTransitionEnd);
|
|
981
|
+
domNode.addEventListener('transitioncancel', onTransitionEnd);
|
|
982
|
+
|
|
983
|
+
if (transitionsActive.slide) {
|
|
984
|
+
// domNode.style.overflowY = 'hidden';
|
|
985
|
+
// domNode.style.height = domNode.clientHeight + 'px';
|
|
986
|
+
// if specified use given transformOrigin
|
|
987
|
+
const transformOrigin = domNode.getAttribute('data-transform-origin-hide') || '50% 100%';
|
|
988
|
+
|
|
989
|
+
domNode.style.transformOrigin = transformOrigin;
|
|
990
|
+
domNode.style.transition = `transform ${transitionsActive.slide / 1000}s ease`;
|
|
991
|
+
setTimeout(() => {
|
|
992
|
+
// domNode.style.height = '2px';
|
|
993
|
+
const axis = this.transitionAxis(domNode, 'hide');
|
|
994
|
+
domNode.style.transform = `scale${axis}(0.01)`;
|
|
995
|
+
}, 100);
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
if (transitionsActive.fade) {
|
|
999
|
+
domNode.style.opacity = '1';
|
|
1000
|
+
domNode.style.transition = `opacity ${transitionsActive.fade / 1000}s`;
|
|
1001
|
+
setTimeout(() => {
|
|
1002
|
+
domNode.style.opacity = '0';
|
|
1003
|
+
}, 100);
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
// reads attribute values of
|
|
1011
|
+
// data-transition-show-slide, data-transition-show-fade,
|
|
1012
|
+
// data-transition-hide-slide, data-transition-hide-fade
|
|
1013
|
+
// and parses them into ClientComponentTransitions object
|
|
1014
|
+
private transitionAttributes(domNode: HTMLElement): ClientComponentTransitions {
|
|
1015
|
+
const transitions: ClientComponentTransitions = {
|
|
1016
|
+
show: {
|
|
1017
|
+
slide: false,
|
|
1018
|
+
fade: false
|
|
1019
|
+
},
|
|
1020
|
+
hide: {
|
|
1021
|
+
slide: false,
|
|
1022
|
+
fade: false
|
|
1023
|
+
}
|
|
1024
|
+
};
|
|
1025
|
+
|
|
1026
|
+
objectEach(transitions, (transitionEvent, transition) => {
|
|
1027
|
+
objectEach(transition, (transitionType) => {
|
|
1028
|
+
const attributeName = `data-transition-${transitionEvent}-${transitionType}`;
|
|
1029
|
+
if (domNode.hasAttribute(attributeName)) {
|
|
1030
|
+
const valueRaw = domNode.getAttribute(attributeName);
|
|
1031
|
+
let value: number | false = false;
|
|
1032
|
+
if (typeof valueRaw === 'string' && /^\d+$/.test(valueRaw)) {
|
|
1033
|
+
value = parseInt(valueRaw);
|
|
1034
|
+
}
|
|
1035
|
+
transition[transitionType] = value;
|
|
1036
|
+
}
|
|
1037
|
+
});
|
|
1038
|
+
});
|
|
1039
|
+
|
|
1040
|
+
return transitions;
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
// reads data-transition-axis-[show|hide] of given domNode
|
|
1044
|
+
// returns "" if attribute is missing or has an unrecognized value
|
|
1045
|
+
// return "X" or "Y" if a proper value is found
|
|
1046
|
+
private transitionAxis(domNode: HTMLElement, showHide: 'show' | 'hide'): 'X' | 'Y' | '' {
|
|
1047
|
+
const attributeName = `data-transition-axis-${showHide}`;
|
|
1048
|
+
if (! domNode.hasAttribute(attributeName)) {return '';}
|
|
1049
|
+
let val = domNode.getAttribute(attributeName);
|
|
1050
|
+
if (typeof val === 'string') {
|
|
1051
|
+
val = val.trim().toUpperCase();
|
|
1052
|
+
if (val.length > 0) {
|
|
1053
|
+
val = val.substring(0, 1);
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
if (val != 'X' && val != 'Y') {
|
|
1057
|
+
// unrecognized value
|
|
1058
|
+
return '';
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
return val;
|
|
1062
|
+
}
|
|
1063
|
+
return '';
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
private async destroy(): Promise<void> {
|
|
1067
|
+
|
|
1068
|
+
// if being redrawn, abort redraw request
|
|
1069
|
+
if (this.redrawRequest) {
|
|
1070
|
+
this.redrawRequest.abort();
|
|
1071
|
+
this.redrawRequest = null;
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
// if the user has defined a destroy callback, run it
|
|
1075
|
+
if (typeof this.onDestroy === 'function') {
|
|
1076
|
+
await this.onDestroy.apply(this);
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
this.store.destroy();
|
|
1080
|
+
|
|
1081
|
+
// remove all event listeners attached to DOM elements
|
|
1082
|
+
this.unbindAll();
|
|
1083
|
+
|
|
1084
|
+
// clean up and free memory
|
|
1085
|
+
this.conditionals = [];
|
|
1086
|
+
this.conditionalClassNames = [];
|
|
1087
|
+
this.conditionalCallbacks = {};
|
|
1088
|
+
this.refs = {};
|
|
1089
|
+
this.refsArray = {};
|
|
1090
|
+
this.initializer = null;
|
|
1091
|
+
this.data = {};
|
|
1092
|
+
|
|
1093
|
+
// mark destroyed
|
|
1094
|
+
this.destroyed = true;
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
// add an event listener to given DOM node
|
|
1098
|
+
// stores it to ClientComponent.bound so it can be unbound when needed using unbindAll
|
|
1099
|
+
public bind(element: HTMLElement, event: string, callback: (e: Event) => void): void {
|
|
1100
|
+
if (element instanceof HTMLElement) {
|
|
1101
|
+
this.bound.push({
|
|
1102
|
+
element,
|
|
1103
|
+
event,
|
|
1104
|
+
callback
|
|
1105
|
+
});
|
|
1106
|
+
element.addEventListener(event, callback);
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
// remove all bound event listeners using ClientComponent.bind
|
|
1111
|
+
private unbindAll() {
|
|
1112
|
+
this.bound.forEach((bound) => {
|
|
1113
|
+
bound.element.removeEventListener(bound.event, bound.callback);
|
|
1114
|
+
});
|
|
1115
|
+
this.bound = [];
|
|
1116
|
+
}
|
|
1117
|
+
}
|