structured-fw 1.0.6 → 1.0.8

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/README.md CHANGED
@@ -644,7 +644,7 @@ Methods:
644
644
  - `find(componentName: string, recursive: boolean = true): ClientComponent | null` - find a child component
645
645
  - `findParent(componentName: string): ClientComponent | null` - find the first parent with given name
646
646
  - `query(componentName: string, recursive: boolean = true): Array<ClientComponent>` - return all components with given name found within this component, if `recursive = false`, only direct children are considered
647
- - `bind<T extends LooseObject | undefined = undefined>(element: HTMLElement, eventName: string | Array<string>, callback: (e: Event, data: T) => void): void` - adds event listener(s) to given element. This is preferred over addEventListener because when the component is redrawn/removed, the event listeners added using bind method are automatically restored/removed. Callback receives event as the first argument. Any "data-" prefixed attributes found on `element` are parsed into an object and provided as second argument to callback (you can specify data using attr helper if you want to pass in something other than a string). The method is generic, allowing you to specify expected data type
647
+ - `bind<T extends LooseObject | undefined = undefined>(element: HTMLElement | Window | Array<HTMLElement | Window>, eventName: string | Array<string>, callback: (e: Event, data: T) => void): void` - adds event listener(s) to given element(s). This is preferred over addEventListener because when the component is redrawn/removed, the event listeners added using bind method are automatically restored/removed. Callback receives event as the first argument. Any "data-" prefixed attributes found on `element` are parsed into an object and provided as second argument to callback (you can specify data using attr helper if you want to pass in something other than a string). Third argument provided to callback is the `element`. The method is generic, allowing you to specify expected data type received as the second argument.
648
648
  - `ref<T>(refName: string): T` - get a HTMLElement or ClientComponent that has attribute `ref="[refName]"`
649
649
  - `arrayRef<T>(refName: string): Array<T>` - get an array of HTMLElement or ClientComponent that have attribute `array:ref="[refName]"`
650
650
  - `add(appendTo: HTMLElement, componentName: string, data?: LooseObject): Promise<ClientComponent | null>` - add `componentName` component to `appendTo` element, optionally passing `data` to the component when it's being rendered. Returns a promise that resolves with added ClientComponent or null if something went wrong
@@ -689,6 +689,51 @@ then in ComponentName.html:
689
689
  <div data-if="showDiv()"></div>
690
690
  ```
691
691
 
692
+ ### Models
693
+ Every component client side part has it's own data store accessed by this.store. That is the primary way of storing data for your components client side, because it will survive on redraw and you can subscribe to data changes in the store using `this.store.onChange`.
694
+
695
+ That being said, we need an easy way to use input fields to set values in the store, as that's often what we do when we make web apps.
696
+
697
+ You can, of course, bind an even listener to the input and set the store value, that's quite easy, but we can accomplis this using `data-model` attribute.
698
+
699
+ You can add data-model to any HTMLInput within your component, and it will automatically update the store on input value change.
700
+
701
+ For example:
702
+
703
+ Direct key:\
704
+ `<input type="text" data-model="name">`
705
+
706
+ Direct key access:\
707
+ `this.store.get<string>('name')`
708
+ `// returns string`
709
+
710
+ Nested keys:\
711
+ `<input type="text" data-model="user[name]">`
712
+
713
+ Nested key access:\
714
+ `this.store.get<LooseObject>('user')`
715
+ `// returns { name: string }`
716
+
717
+
718
+ You can nest the keys to any depth, or even make the value an array member if you end the key with `[]`, for example:
719
+ ```
720
+ <input type="text" data-model="user[hobbies][]">
721
+ this.store.get<LooseObject>('user')
722
+ // returns { user: { hobbies: Array<string> } }
723
+ ```
724
+
725
+ You can use two modifier attributes with `data-model`:
726
+ - `data-type`
727
+ - `data-nullable`
728
+
729
+ `data-type` - cast value to given type. Can be one of number | boolean | string, string has no effect as HTMLInput values are already a string by default.\
730
+ If number: if input is empty or value casts to `NaN` then `0` (unless `data-nullable` in which case `null`), othrwise the casted number (uses parseFloat so it works with decimal numbers)\
731
+ If boolean: `"1"` and `"true"` casted to `true`, otherwise `false`\
732
+ If string no type casting is attempted.
733
+
734
+ `data-nullable` - value of this attribute is unused, as long as the attribute is present on the input, empty values will be casted to `null`. Can be used in conjunction with `data-type`
735
+
736
+
692
737
  ### Layout
693
738
  Prior to version 0.8.7:
694
739
 
@@ -1,7 +1,7 @@
1
1
  import { EventEmitterCallback } from "./Types.js";
2
2
  export declare class EventEmitter<T extends Record<string, any> = Record<string, any>> {
3
- protected listeners: Partial<Record<keyof T, Array<EventEmitterCallback<any>>>>;
4
- on<K extends keyof T>(eventName: K, callback: EventEmitterCallback<T[K]>): void;
5
- emit(eventName: keyof T, payload?: any): void;
3
+ protected listeners: Partial<Record<Extract<keyof T, string>, Array<EventEmitterCallback<any>>>>;
4
+ on<K extends Extract<keyof T, string>>(eventName: K, callback: EventEmitterCallback<T[K]>): void;
5
+ emit(eventName: Extract<keyof T, string>, payload?: any): void;
6
6
  off(eventName: keyof T, callback: EventEmitterCallback<any>): void;
7
7
  }
@@ -9,9 +9,9 @@ export class EventEmitter {
9
9
  this.listeners[eventName].push(callback);
10
10
  }
11
11
  emit(eventName, payload) {
12
- if (Array.isArray(this.listeners[eventName])) {
13
- this.listeners[eventName].forEach((callback) => {
14
- callback(payload);
12
+ if (Array.isArray(this.listeners[eventName]) || Array.isArray(this.listeners['*'])) {
13
+ (this.listeners[eventName] || []).concat(this.listeners['*'] || []).forEach((callback) => {
14
+ callback(payload, eventName);
15
15
  });
16
16
  }
17
17
  }
@@ -159,7 +159,7 @@ export type InitializerFunctionContext = {
159
159
  isRedraw: boolean;
160
160
  };
161
161
  export type StoreChangeCallback = (key: string, value: any, oldValue: any, componentId: string) => void;
162
- export type ClientComponentEventCallback<T> = T extends undefined ? (e: Event) => void : (e: Event, data: T) => void;
162
+ export type ClientComponentEventCallback<T> = (e: Event, data: T, element: HTMLElement | Window) => void;
163
163
  export type ClientComponentBoundEvent<T extends LooseObject | undefined = undefined> = {
164
164
  element: HTMLElement | Window;
165
165
  event: keyof HTMLElementEventMap;
@@ -172,4 +172,4 @@ export type ClientComponentTransition = {
172
172
  };
173
173
  export type ClientComponentTransitionEvent = 'show' | 'hide';
174
174
  export type ClientComponentTransitions = Record<ClientComponentTransitionEvent, ClientComponentTransition>;
175
- export type EventEmitterCallback<T> = (payload: T) => void;
175
+ export type EventEmitterCallback<T> = (payload: T, eventName: string) => void;
@@ -6,7 +6,7 @@ export declare function toCamelCase(dataKey: string, separator?: string): string
6
6
  export declare function toSnakeCase(str: string, joinWith?: string): string;
7
7
  export declare function capitalize(str: string): string;
8
8
  export declare function isAsync(fn: Function): boolean;
9
- export declare function randomString(len: number): string;
9
+ export declare function randomString(len: number, method?: 'alphanumeric' | 'numbers' | 'letters' | 'lettersUppercase' | 'lettersLowercase'): string;
10
10
  export declare function unique<T>(arr: Array<T>): Array<T>;
11
11
  export declare function stripTags(contentWithHTML: string, keepTags?: Array<string>): string;
12
12
  export declare function attributeValueToString(key: string, value: any): string;
@@ -168,7 +168,7 @@ export function capitalize(str) {
168
168
  export function isAsync(fn) {
169
169
  return fn.constructor.name === 'AsyncFunction';
170
170
  }
171
- export function randomString(len) {
171
+ export function randomString(len, method = 'alphanumeric') {
172
172
  const charCodes = new Uint8Array(len);
173
173
  const generators = [
174
174
  function () {
@@ -181,8 +181,24 @@ export function randomString(len) {
181
181
  return 48 + Math.floor(Math.random() * 10);
182
182
  }
183
183
  ];
184
+ const generatorsUsed = [];
185
+ if (method === 'alphanumeric') {
186
+ generatorsUsed.push(...generators);
187
+ }
188
+ else if (method === 'numbers') {
189
+ generatorsUsed.push(generators[2]);
190
+ }
191
+ else if (method === 'letters') {
192
+ generatorsUsed.push(...generators.slice(0, 2));
193
+ }
194
+ else if (method === 'lettersLowercase') {
195
+ generatorsUsed.push(generators[1]);
196
+ }
197
+ else if (method === 'lettersUppercase') {
198
+ generatorsUsed.push(generators[0]);
199
+ }
184
200
  for (let i = 0; i < len; i++) {
185
- charCodes[i] = generators[Math.floor(Math.random() * generators.length)]();
201
+ charCodes[i] = generatorsUsed[Math.floor(Math.random() * generatorsUsed.length)]();
186
202
  }
187
203
  return String.fromCodePoint(...charCodes);
188
204
  }
@@ -56,7 +56,7 @@ export declare class ClientComponent extends EventEmitter {
56
56
  private transitionAttributes;
57
57
  private transitionAxis;
58
58
  private destroy;
59
- bind<T extends LooseObject | undefined = undefined>(element: HTMLElement | Window, event: keyof HTMLElementEventMap | Array<keyof HTMLElementEventMap>, callback: ClientComponentEventCallback<T>): void;
59
+ bind<T extends LooseObject | undefined = undefined>(element: HTMLElement | Window | Array<HTMLElement | Window>, event: keyof HTMLElementEventMap | Array<keyof HTMLElementEventMap>, callback: ClientComponentEventCallback<T>): void;
60
60
  unbind<T extends LooseObject | undefined = undefined>(element: HTMLElement, event: keyof HTMLElementEventMap | Array<keyof HTMLElementEventMap>, callback: ClientComponentEventCallback<T>): void;
61
61
  private unbindAll;
62
62
  }
@@ -277,21 +277,6 @@ export class ClientComponent extends EventEmitter {
277
277
  if (node === undefined) {
278
278
  node = this.domNode;
279
279
  }
280
- const modelData = (node) => {
281
- const field = node.getAttribute('data-model');
282
- if (field) {
283
- const isCheckbox = node.tagName === 'INPUT' && node.type === 'checkbox';
284
- const valueRaw = isCheckbox ? node.checked : node.value;
285
- const value = queryStringDecodedSetValue(field, valueRaw);
286
- return value;
287
- }
288
- return {};
289
- };
290
- const update = (data) => {
291
- objectEach(data, (key, val) => {
292
- this.store.set(key, val);
293
- });
294
- };
295
280
  if (node.hasAttribute('data-model') && (node.tagName === 'INPUT' || node.tagName === 'SELECT' || node.tagName === 'TEXTAREA')) {
296
281
  modelNodes.push(node);
297
282
  }
@@ -303,6 +288,53 @@ export class ClientComponent extends EventEmitter {
303
288
  });
304
289
  }
305
290
  if (isSelf) {
291
+ const modelData = (node) => {
292
+ const field = node.getAttribute('data-model');
293
+ if (field) {
294
+ const isCheckbox = node.tagName === 'INPUT' && node.type === 'checkbox';
295
+ const valueRaw = isCheckbox ? node.checked : node.value;
296
+ let valueCasted = valueRaw;
297
+ if (!isCheckbox && typeof valueRaw === 'string') {
298
+ const dataType = isCheckbox ? 'boolean' : node.getAttribute('data-type') || 'string';
299
+ const nullable = node.hasAttribute('data-nullable');
300
+ if (nullable && valueRaw.trim().length === 0) {
301
+ valueCasted = null;
302
+ }
303
+ else {
304
+ if (dataType === 'number') {
305
+ if (valueRaw.trim().length === 0) {
306
+ valueCasted = 0;
307
+ }
308
+ else {
309
+ const num = parseFloat(valueRaw);
310
+ if (isNaN(num)) {
311
+ valueCasted = nullable ? null : 0;
312
+ }
313
+ else {
314
+ valueCasted = num;
315
+ }
316
+ }
317
+ }
318
+ else if (dataType === 'boolean') {
319
+ if (valueRaw === '1' || valueRaw === 'true') {
320
+ valueCasted = true;
321
+ }
322
+ else {
323
+ valueCasted = false;
324
+ }
325
+ }
326
+ }
327
+ }
328
+ const value = queryStringDecodedSetValue(field, valueCasted);
329
+ return value;
330
+ }
331
+ return {};
332
+ };
333
+ const update = (data) => {
334
+ objectEach(data, (key, val) => {
335
+ this.store.set(key, val);
336
+ });
337
+ };
306
338
  let data = {};
307
339
  modelNodes.forEach((modelNode) => {
308
340
  modelNode.addEventListener('input', () => {
@@ -321,10 +353,11 @@ export class ClientComponent extends EventEmitter {
321
353
  });
322
354
  const field = modelNode.getAttribute('data-model');
323
355
  if (field) {
324
- const isCheckbox = modelNode.tagName === 'INPUT' && modelNode.type === 'checkbox';
325
- const valueRaw = isCheckbox ? modelNode.checked : modelNode.value;
326
- const value = queryStringDecodedSetValue(field, valueRaw);
327
- data = mergeDeep(data, value);
356
+ const updateModel = modelNode.type !== 'radio' || modelNode.checked;
357
+ if (updateModel) {
358
+ const valueObject = modelData(modelNode);
359
+ data = mergeDeep(data, valueObject);
360
+ }
328
361
  }
329
362
  });
330
363
  update(data);
@@ -721,6 +754,12 @@ export class ClientComponent extends EventEmitter {
721
754
  this.emit('afterDestroy');
722
755
  }
723
756
  bind(element, event, callback) {
757
+ if (Array.isArray(element)) {
758
+ element.forEach((el) => {
759
+ this.bind(el, event, callback);
760
+ });
761
+ return;
762
+ }
724
763
  if (Array.isArray(event)) {
725
764
  event.forEach((eventName) => {
726
765
  this.bind(element, eventName, callback);
@@ -730,7 +769,7 @@ export class ClientComponent extends EventEmitter {
730
769
  const isWindow = element instanceof Window;
731
770
  if (element instanceof HTMLElement || isWindow) {
732
771
  const callbackWrapper = (e) => {
733
- callback.apply(this, [e, isWindow ? {} : this.attributeData(element)]);
772
+ callback.apply(this, [e, isWindow ? undefined : this.attributeData(element), element]);
734
773
  };
735
774
  this.bound.push({
736
775
  element,
@@ -89,8 +89,14 @@ export class Component extends EventEmitter {
89
89
  return;
90
90
  }
91
91
  const importedParentData = this.parent ? this.importedParentData(this.parent.data) : {};
92
- const dataServerSidePart = (this.entry && this.entry.module ?
93
- await this.entry.module.getData(Object.assign(importedParentData, this.attributes, data || {}), this.document.ctx, this.document.application, this) : {}) || {};
92
+ let dataServerSidePart = {};
93
+ try {
94
+ dataServerSidePart = (this.entry && this.entry.module ?
95
+ await this.entry.module.getData(Object.assign(importedParentData, this.attributes, data || {}), this.document.ctx, this.document.application, this) : {}) || {};
96
+ }
97
+ catch (e) {
98
+ throw new Error(`Error executing getData in component ${this.name}: ${e.message}`);
99
+ }
94
100
  if (data === undefined) {
95
101
  if (this.entry && this.entry.module) {
96
102
  this.data = Object.assign(this.data, dataServerSidePart);
@@ -250,6 +256,11 @@ export class Component extends EventEmitter {
250
256
  return;
251
257
  }
252
258
  const html = this.entry ? this.entry.html : this.dom.innerHTML;
253
- this.dom.innerHTML = this.document.application.handlebars.compile(html, data);
259
+ try {
260
+ this.dom.innerHTML = this.document.application.handlebars.compile(html, data);
261
+ }
262
+ catch (e) {
263
+ throw new Error(`Error compiling Handlebars template in component ${this.name}, error: ${e.message}`);
264
+ }
254
265
  }
255
266
  }
@@ -25,6 +25,10 @@ export class Layout {
25
25
  }
26
26
  const component = new Component(componentName, layoutComponent[0], doc, false);
27
27
  await component.init(`<${componentName}></${componentName}>`, data);
28
+ const conditionals = component.dom.queryByHasAttribute('data-if');
29
+ for (let i = 0; i < conditionals.length; i++) {
30
+ conditionals[i].style.display = 'none';
31
+ }
28
32
  return doc;
29
33
  }
30
34
  }
@@ -328,15 +328,15 @@ export class Request {
328
328
  const files = readdirSync(routesPath);
329
329
  for (let i = 0; i < files.length; i++) {
330
330
  const file = files[i];
331
- if (!(file.endsWith('.js') || file.endsWith('.ts')) || file.endsWith('.d.ts')) {
332
- continue;
333
- }
334
331
  const filePath = path.resolve(routesPath + '/' + file);
335
332
  const isDirectory = statSync(filePath).isDirectory();
336
333
  if (isDirectory) {
337
334
  await this.loadHandlers(filePath);
338
335
  }
339
336
  else {
337
+ if (!(file.endsWith('.js') || file.endsWith('.ts')) || file.endsWith('.d.ts')) {
338
+ continue;
339
+ }
340
340
  const fn = (await import('file:///' + filePath)).default;
341
341
  if (typeof fn === 'function') {
342
342
  fn(this.app);
@@ -1,6 +1,6 @@
1
1
  import { HTMLParser } from "./HTMLParser.js";
2
- export const selfClosingTags = ['br', 'wbr', 'hr', 'input', 'img', 'link', 'meta', 'source', 'embed', 'path', 'area'];
3
- export const recognizedHTMLTags = ['body', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'p', 'b', 'i', 'a', 'em', 'strong', 'br', 'wbr', 'hr', 'abbr', 'bdi', 'bdo', 'blockquote', 'cite', 'code', 'del', 'dfn', 'ins', 'kbd', 'mark', 'pre', 'q', 'rp', 'rt', 'ruby', 's', 'samp', 'small', 'span', 'sub', 'sup', 'time', 'u', 'var', 'ul', 'ol', 'li', 'dl', 'dt', 'dd', 'img', 'area', 'map', 'object', 'param', 'table', 'tr', 'td', 'th', 'caption', 'colgroup', 'col', 'form', 'input', 'label', 'select', 'option', 'textarea', 'button', 'fieldset', 'datalist', 'iframe', 'audio', 'video', 'source', 'track', 'script', 'noscript', 'div', 'nav', 'aside', 'canvas', 'embed', 'template'];
2
+ export const selfClosingTags = ['br', 'wbr', 'hr', 'input', 'img', 'link', 'meta', 'source', 'embed', 'path', 'area', 'rect'];
3
+ export const recognizedHTMLTags = ['body', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'p', 'b', 'i', 'a', 'em', 'strong', 'br', 'wbr', 'hr', 'abbr', 'bdi', 'bdo', 'blockquote', 'cite', 'code', 'del', 'dfn', 'ins', 'kbd', 'mark', 'pre', 'q', 'rp', 'rt', 'ruby', 's', 'samp', 'small', 'span', 'sub', 'sup', 'time', 'u', 'var', 'ul', 'ol', 'li', 'dl', 'dt', 'dd', 'img', 'area', 'map', 'object', 'param', 'table', 'tr', 'td', 'th', 'caption', 'colgroup', 'col', 'form', 'input', 'label', 'select', 'option', 'textarea', 'button', 'fieldset', 'datalist', 'iframe', 'audio', 'video', 'source', 'track', 'script', 'noscript', 'div', 'nav', 'aside', 'canvas', 'embed', 'template', 'rect'];
4
4
  export class DOMNode {
5
5
  constructor(root, parentNode, tagName) {
6
6
  this.parentNode = null;
package/package.json CHANGED
@@ -19,13 +19,13 @@
19
19
  "license": "MIT",
20
20
  "type": "module",
21
21
  "main": "build/index",
22
- "version": "1.0.6",
22
+ "version": "1.0.8",
23
23
  "scripts": {
24
24
  "develop": "tsc --watch",
25
25
  "startDev": "cd build && nodemon --watch '../app/**/*' --watch '../build/**/*' -e js,html,css index.js",
26
26
  "start": "cd build && node index.js",
27
- "pack": "tsc && npm pack",
28
- "publish": "tsc && npm publish"
27
+ "compileAndPack": "tsc && npm pack",
28
+ "compileAndPublish": "tsc && npm publish"
29
29
  },
30
30
  "bin": {
31
31
  "structured": "./build/system/bin/structured.js"
@@ -55,6 +55,7 @@
55
55
  "./Document": "./build/system/server/Document.js",
56
56
  "./Component": "./build/system/server/Component.js",
57
57
  "./Layout": "./build/system/server/Layout.js",
58
+ "./DOMNode": "./build/system/server/dom/DOMNode.js",
58
59
  "./FormValidation": "./build/system/server/FormValidation.js",
59
60
  "./ClientComponent": "./build/system/client/ClientComponent.js",
60
61
  "./Net": "./build/system/client/Net.js"