simplyflow 0.3.3 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,422 @@
1
+ /*
2
+ * Default renderers for data binding
3
+ * Will be used unless overriden in the SimplyBind options parameter
4
+ */
5
+
6
+ /**
7
+ * This function is used by default to render dom elements with the `data-flow-field` attribute.
8
+ * It will switch to only switching in template content if the context has any templates.
9
+ * Otherwise it will call the matching render function depending on the tagName of the
10
+ * context.element
11
+ */
12
+ export function field(context)
13
+ {
14
+ if (context.templates?.length) {
15
+ fieldByTemplates.call(this, context)
16
+ // TODO: check if existence of one or more templates must mean that
17
+ // only the template rendering is applied, instead of also rendering attributes
18
+ } else if (Object.hasOwnProperty.call(this.options.renderers, context.element.tagName)) {
19
+ const renderer = this.options.renderers[context.element.tagName]
20
+ if (renderer) {
21
+ renderer.call(this, context)
22
+ }
23
+ } else if (this.options.renderers['*']) {
24
+ this.options.renderers['*'].call(this, context)
25
+ }
26
+ return context
27
+ }
28
+
29
+ /**
30
+ * This function is used by default to render DOM elements with the `data-flow-list` attribute.
31
+ * The context.value must be an array. And context.templates must not be empty.
32
+ */
33
+ export function list(context)
34
+ {
35
+ if (!Array.isArray(context.value)) {
36
+ console.error('Value is not an array.', context.element, context.path, context.value)
37
+ } else if (!context.templates?.length) {
38
+ console.error('No templates found in', context.element)
39
+ } else {
40
+ arrayByTemplates.call(this, context)
41
+ }
42
+ return context
43
+ }
44
+
45
+ /**
46
+ * This function is used by default to render DOM elements with the `data-flow-map` attribute.
47
+ * The context.value must be a non-null object. And context.templates must not be empty.
48
+ */
49
+ export function map(context)
50
+ {
51
+ if (typeof context.value != 'object' || !context.value) {
52
+ console.error('Value is not an object.', context.element, context.path, context.value)
53
+ } else if (!context.templates?.length) {
54
+ console.error('No templates found in', context.element)
55
+ } else {
56
+ objectByTemplates.call(this, context)
57
+ }
58
+ return context
59
+ }
60
+
61
+ /**
62
+ * Renders an array value by applying templates for each entry
63
+ * Replaces or removes existing DOM children if needed
64
+ * Reuses (doesn't touch) DOM children if template doesn't change
65
+ * FIXME: this doesn't handle situations where there is no matching template
66
+ * this messes up self healing. check renderObjectByTemplates for a better implementation
67
+ */
68
+ export function arrayByTemplates(context)
69
+ {
70
+ const attribute = this.options.attribute
71
+
72
+ let items = context.element.querySelectorAll(':scope > ['+attribute+'-key]')
73
+ // do single merge strategy for now, in future calculate optimal merge strategy from a number
74
+ // now just do a delete if a key <= last key, insert if a key >= last key
75
+ let lastKey = 0
76
+ let skipped = 0
77
+ context.list = context.value
78
+ for (let item of items) {
79
+ let currentKey = parseInt(item.getAttribute(attribute+'-key'))
80
+ if (currentKey>lastKey) {
81
+ // insert before
82
+ context.index = lastKey
83
+ context.element.insertBefore(this.applyTemplate(context), item)
84
+ } else if (currentKey<lastKey) {
85
+ // remove this
86
+ item.remove()
87
+ } else {
88
+ // check that all data-bind params start with current json path or ':root', otherwise replaceChild
89
+ let bindings = Array.from(item.querySelectorAll(`[${attribute}]`))
90
+ if (item.matches(`[${attribute}]`)) {
91
+ bindings.unshift(item)
92
+ }
93
+ let needsReplacement = bindings.find(b => {
94
+ let databind = b.getAttribute(attribute)
95
+ return (databind.substr(0,5)!==':root'
96
+ && databind.substr(0, context.path.length)!==context.path)
97
+ })
98
+ if (!needsReplacement) {
99
+ if (item[Symbol.bindTemplate]) {
100
+ let newTemplate = this.findTemplate(context.templates, context.list[lastKey])
101
+ if (newTemplate != item[Symbol.bindTemplate]){
102
+ needsReplacement = true
103
+ if (!newTemplate) {
104
+ skipped++
105
+ }
106
+ }
107
+ }
108
+ }
109
+ if (needsReplacement) {
110
+ context.index = lastKey
111
+ context.element.replaceChild(this.applyTemplate(context), item)
112
+ }
113
+ }
114
+ lastKey++
115
+ if (lastKey>=context.value.length) {
116
+ break
117
+ }
118
+ }
119
+ items = context.element.querySelectorAll(':scope > ['+attribute+'-key]')
120
+ let length = items.length + skipped
121
+ if (length > context.value.length) {
122
+ while (length > context.value.length) {
123
+ let child = context.element.querySelectorAll(':scope > :not(template)')?.[length-1]
124
+ child?.remove()
125
+ length--
126
+ }
127
+ } else if (length < context.value.length ) {
128
+ while (length < context.value.length) {
129
+ context.index = length
130
+ context.element.appendChild(this.applyTemplate(context))
131
+ length++
132
+ }
133
+ }
134
+ }
135
+
136
+ /**
137
+ * Renders an object value by applying templates for each entry (Object.entries)
138
+ * Replaces,moves or removes existing DOM children if needed
139
+ * Reuses (doesn't touch) DOM children if template doesn't change
140
+ */
141
+ export function objectByTemplates(context)
142
+ {
143
+ const attribute = this.options.attribute
144
+ context.list = context.value
145
+
146
+ let items = Array.from(context.element.querySelectorAll(':scope > ['+attribute+'-key]'))
147
+ for (let key in context.list) {
148
+ context.index = key
149
+ let item = items.shift()
150
+ if (!item) { // more properties than rendered items
151
+ let clone = this.applyTemplate(context)
152
+ context.element.appendChild(clone)
153
+ continue
154
+ }
155
+ if (item.getAttribute[attribute+'-key']!=key) {
156
+ // next item doesn't match key
157
+ items.unshift(item) // put item back for next cycle
158
+ let outOfOrderItem = context.element.querySelector(':scope > ['+attribute+'-key="'+key+'"]') //FIXME: escape key
159
+ if (!outOfOrderItem) {
160
+ let clone = this.applyTemplate(context)
161
+ context.element.insertBefore(clone, item)
162
+ continue // new template doesn't need replacement, so continue
163
+ } else {
164
+ context.element.insertBefore(outOfOrderItem, item)
165
+ item = outOfOrderItem // check needsreplacement next
166
+ items = items.filter(i => i!=outOfOrderItem)
167
+ }
168
+ }
169
+ let newTemplate = this.findTemplate(context.templates, context.list[context.index])
170
+ if (newTemplate != item[Symbol.bindTemplate]){
171
+ let clone = this.applyTemplate(context)
172
+ context.element.replaceChild(clone, item)
173
+ }
174
+ }
175
+ // clean up remaining items
176
+ while (items.length) {
177
+ let item = items.shift()
178
+ item.remove()
179
+ }
180
+ }
181
+
182
+ /**
183
+ * renders the contents of an html element by rendering
184
+ * a matching template, once.
185
+ */
186
+ export function fieldByTemplates(context)
187
+ {
188
+ const rendered = context.element.querySelector(':scope > :not(template)')
189
+ const template = this.findTemplate(context.templates, context.value)
190
+ context.parent = getParentPath(context.element)
191
+ if (rendered) {
192
+ if (template) {
193
+ if (rendered?.[Symbol.bindTemplate] != template) {
194
+ const clone = this.applyTemplate(context)
195
+ context.element.replaceChild(clone, rendered)
196
+ }
197
+ } else {
198
+ context.element.removeChild(rendered)
199
+ }
200
+ } else if (template) {
201
+ const clone = this.applyTemplate(context)
202
+ context.element.appendChild(clone)
203
+ }
204
+ }
205
+
206
+ function getParentPath(el, attribute)
207
+ {
208
+ const parentEl = el.parentElement?.closest(`[${attribute}-list],[${attribute}-map]`)
209
+ if (!parentEl) {
210
+ return ''
211
+ }
212
+ if (parentEl.hasAttribute(`${attribute}-list`)) {
213
+ return parentEl.getAttribute(`${attribute}-list`)+'.'
214
+ }
215
+ return parentEl.getAttribute(`${attribute}-map`)+'.'
216
+ }
217
+
218
+ /**
219
+ * renders a single input type
220
+ * for radio/checkbox inputs it only sets the checked attribute to true/false
221
+ * if the value attribute matches the current value
222
+ * for other inputs the value attribute is updated
223
+ */
224
+ export function input(context)
225
+ {
226
+ const el = context.element
227
+ let value = context.value
228
+
229
+ element(context)
230
+ if (typeof value == 'undefined') {
231
+ value = ''
232
+ }
233
+ if (el.type=='checkbox' || el.type=='radio') {
234
+ if (matchValue(el.value, value)) {
235
+ el.checked = true
236
+ } else {
237
+ el.checked = false
238
+ }
239
+ } else if (!matchValue(el.value, value)) {
240
+ el.value = ''+value
241
+ }
242
+ }
243
+
244
+ /**
245
+ * Sets the value of the button, doesn't touch the innerHTML
246
+ */
247
+ export function button(context)
248
+ {
249
+ element(context)
250
+ setProperties(context.element, context.value, 'value')
251
+ }
252
+
253
+ /**
254
+ * Sets the selected attribute of select options
255
+ */
256
+ export function select(context)
257
+ {
258
+ const el = context.element
259
+ let value = context.value
260
+
261
+ if (value === null) {
262
+ value = ''
263
+ }
264
+ if (typeof value!='object') {
265
+ if (el.multiple) {
266
+ if (Array.isArray(value)) { //FIXME: cannot be true, since typeof != 'object'
267
+ for (let option of el.options) {
268
+ if (value.indexOf(option.value)===false) {
269
+ option.selected = false
270
+ } else {
271
+ option.selected = true
272
+ }
273
+ }
274
+ }
275
+ } else {
276
+ let option = el.options.find(o => matchValue(o.value,value))
277
+ if (option) {
278
+ option.selected = true
279
+ option.setAttribute('selected', true)
280
+ }
281
+ }
282
+ } else { // value is a non-null object
283
+ if (value.options) {
284
+ setSelectOptions(el, value.options)
285
+ }
286
+ if (value.selected) {
287
+ select(Object.asssign({}, context, {value:value.selected}))
288
+ }
289
+ setProperties(el, value, 'name', 'id', 'selectedIndex', 'className') // allow innerHTML? if so call element instead
290
+ }
291
+ }
292
+
293
+ /**
294
+ * adds a single option to a select element. The option.text property is optional, if not set option.value is used.
295
+ * @param select The select element
296
+ * @param option An option descriptor, either a string, object with {text,value,defaultSelected,selected} properties or an Option object
297
+ */
298
+ export function addOption(select, option)
299
+ {
300
+ if (!option) {
301
+ return
302
+ }
303
+ if (typeof option !== 'object') {
304
+ select.options.add(new Option(''+option))
305
+ } else if (option.text) {
306
+ select.options.add(new Option(option.text, option.value, option.defaultSelected, option.selected))
307
+ } else if (typeof option.value != 'undefined') {
308
+ select.options.add(new Option(''+option.value, option.value, option.defaultSelected, option.selected))
309
+ }
310
+ }
311
+
312
+ /**
313
+ * This function clears all existing options of a select element, and adds the specified options.
314
+ */
315
+ export function setSelectOptions(select,options)
316
+ {
317
+ //@TODO: only update in case of changes?
318
+ select.innerHTML = ''
319
+ if (Array.isArray(options)) {
320
+ for (const option of options) {
321
+ addOption(select, option)
322
+ }
323
+ } else if (options && typeof options == 'object') {
324
+ for (const option in options) {
325
+ addOption(select, { text: options[option], value: option })
326
+ }
327
+ }
328
+ }
329
+
330
+ /**
331
+ * Sets the innerHTML and href, id, title, target, name, newwindow, nofollow attributes of an anchor
332
+ */
333
+ export function anchor(context)
334
+ {
335
+ element(context)
336
+ setProperties(context.element, context.value, 'target', 'href', 'name', 'newwindow', 'nofollow')
337
+ }
338
+
339
+ /**
340
+ * Sets the title, id, alt and src attributes of an image.
341
+ */
342
+ export function image(context)
343
+ {
344
+ setProperties(context.element, context.value, 'title', 'alt', 'src', 'id')
345
+ }
346
+
347
+ /**
348
+ * Sets the title, id and src attribute of an iframe
349
+ */
350
+ export function iframe(context)
351
+ {
352
+ setProperties(context.element, context.value, 'title', 'src', 'id')
353
+ }
354
+
355
+ /**
356
+ * Sets the content and id attribute of a meta element
357
+ */
358
+ export function meta(context)
359
+ {
360
+ setProperties(context.element, context.value, 'content', 'id')
361
+ }
362
+
363
+ /**
364
+ * sets the innerHTML and title and id properties of any HTML element
365
+ */
366
+ export function element(context)
367
+ {
368
+ const el = context.element
369
+ let value = context.value
370
+
371
+ if (typeof value=='undefined' || value==null) {
372
+ value = ''
373
+ }
374
+ let strValue = ''+value
375
+ if (typeof value!='object' || strValue.substring(0,8)!='[object ') {
376
+ el.innerHTML = strValue
377
+ return
378
+ }
379
+ setProperties(el, value, 'innerHTML', 'title', 'id', 'className')
380
+ }
381
+
382
+ /**
383
+ * Sets a list of properties on a dom element, equal to
384
+ * the string value of a data object
385
+ * only updates the dom element if the property doesn't match
386
+ */
387
+ export function setProperties(el, data, ...properties) {
388
+ if (!data || typeof data!=='object') {
389
+ return
390
+ }
391
+ for (const property of properties) {
392
+ if (typeof data[property] === 'undefined') {
393
+ continue
394
+ }
395
+ if (matchValue(el[property], data[property])) {
396
+ continue
397
+ }
398
+ if (data[property] === null) {
399
+ el[property] = ''
400
+ } else {
401
+ el[property] = ''+data[property]
402
+ }
403
+ }
404
+ }
405
+
406
+ /**
407
+ * Returns true if a matches b, either by having the
408
+ * same string value, or matching string :empty against a falsy value
409
+ */
410
+ export function matchValue(a,b)
411
+ {
412
+ if (a==':empty' && !b) {
413
+ return true
414
+ }
415
+ if (b==':empty' && !a) {
416
+ return true
417
+ }
418
+ if (''+a == ''+b) {
419
+ return true
420
+ }
421
+ return false
422
+ }
@@ -0,0 +1,25 @@
1
+ export function escape_html(context, next) {
2
+ let content = context.value.innerHTML
3
+ if (typeof context.value == 'string') {
4
+ content = context.value
5
+ context.value = { innerHTML: content }
6
+ }
7
+ if (content) {
8
+ content = content.replace(/&/g, '&amp;')
9
+ .replace(/</g, '&lt;')
10
+ .replace(/>/g, '&gt;')
11
+ .replace(/"/g, '&quot;')
12
+ .replace(/'/g, '&#39;');
13
+ context.value.innerHTML = content
14
+ }
15
+ next(context)
16
+ }
17
+
18
+ export function fixed_content(context, next) {
19
+ if (typeof context.value == 'string') {
20
+ context.value = {}
21
+ } else {
22
+ delete context.value.innerHTML
23
+ }
24
+ next(context)
25
+ }