@@ -292,9 +292,10 @@ export class HtmlParserService {
292292 * Apply the calc functions on the html body.
293293 *
294294 * @param html The html body on which we want to apply the functions
295+ * @param data available data for nested placeholders
295296 * @returns The html body with the calculated result of the functions
296297 */
297- private applyOperations ( html : string ) : string {
298+ private applyOperations ( html : string , data ?: any ) : string {
298299 const regex = new RegExp (
299300 `${ CALC_PREFIX } (\\w+)\\((.*?)\\)${ PLACEHOLDER_SUFFIX } ` ,
300301 'gm'
@@ -305,18 +306,34 @@ export class HtmlParserService {
305306 // get the function
306307 const calcFunc = get ( this . calcFunctions , result [ 1 ] ) ;
307308 if ( calcFunc ) {
309+ // Pre-process arguments for any nested placeholders
310+ let processedArgs = result [ 2 ] ;
311+ if ( data ) {
312+ const placeholderRegex = / \{ \{ ( [ ^ } ] + ) \} \} / g;
313+ processedArgs = processedArgs . replace (
314+ placeholderRegex ,
315+ ( match , placeholder ) => {
316+ const value = get ( data , placeholder . trim ( ) ) ;
317+ return value ?? match ;
318+ }
319+ ) ;
320+ }
321+
308322 // get the arguments and clean the numbers to be parsed correctly
309323 const args =
310- result [ 2 ]
324+ processedArgs
311325 . replace ( / & n b s p ; / g, ' ' ) // Replace with a regular space
312326 . match ( / (?: < [ ^ > ] + > | [ ^ < ; ] + ) + / g)
313327 ?. map ( ( arg ) => {
314328 /** Make sure that the new date case does not break any previous clean up */
315- return result ?. [ 1 ] === 'date'
316- ? arg . trim ( )
317- : // Replace below replaces the space space between span and style property from arg as elements,
318- // breaking any style application from given element
319- arg . replace ( / [ \s , ] / gm, '' ) ;
329+ if ( result ?. [ 1 ] === 'date' ) {
330+ const trimmedArg = arg . trim ( ) ;
331+ // Strip optional surrounding quotes from format/value while preserving inner quotes
332+ return trimmedArg . replace ( / ^ [ ' " ] ( .* ) [ ' " ] $ / , '$1' ) ;
333+ }
334+ // Replace below replaces the space space between span and style property from arg as elements,
335+ // breaking any style application from given element
336+ return arg . replace ( / [ \s , ] / gm, '' ) ;
320337 } )
321338 . filter ( ( arg ) => ! ! arg ) || [ ] ;
322339 // apply the function
@@ -333,6 +350,205 @@ export class HtmlParserService {
333350 return parsedHtml ;
334351 }
335352
353+ /**
354+ * Adds iteration of values within templates using for-loops. Supports data.* or aggregation.*
355+ *
356+ * @param html String with the content html.
357+ * @param collections Available collections
358+ * @param collections.data Available record data for iteration
359+ * @param collections.aggregation Available aggregation data for iteration
360+ * @returns formatted html.
361+ */
362+ private replaceForLoops (
363+ html : string ,
364+ collections : { data ?: any ; aggregation ?: any }
365+ ) : string {
366+ if ( ! html ) {
367+ return html ;
368+ }
369+
370+ const forLoopRegex =
371+ / \{ \{ f o r \s + ( \w + ) \s + o f \s + ( [ ^ } ] + ) \} \} ( [ \s \S ] * ?) \{ \{ e n d f o r \} \} / gm;
372+ const dataForRegex =
373+ / < ( \w + ) ( [ ^ > ] * ?) \s + d a t a - f o r = " ( \w + ) \s + o f \s + ( [ ^ " ] + ) " ( [ ^ > ] * ?) (?: \/ > | > ( [ \s \S ] * ?) < \/ \1> ) / gm;
374+
375+ let resultHtml = html ;
376+ let loopsFound = true ;
377+
378+ // Continue processing as long as we find loops to replace
379+ while ( loopsFound ) {
380+ const matches = [
381+ ...Array . from ( resultHtml . matchAll ( forLoopRegex ) ) . map ( ( m ) => ( {
382+ match : m ,
383+ type : 'for' ,
384+ } ) ) ,
385+ ...Array . from ( resultHtml . matchAll ( dataForRegex ) ) . map ( ( m ) => ( {
386+ match : m ,
387+ type : 'data-for' ,
388+ } ) ) ,
389+ ] ;
390+
391+ if ( matches . length === 0 ) {
392+ loopsFound = false ;
393+ continue ;
394+ }
395+
396+ // Sort by start index to process from the inside out
397+ matches . sort ( ( a , b ) => ( b . match . index ?? 0 ) - ( a . match . index ?? 0 ) ) ;
398+
399+ for ( const { match, type } of matches ) {
400+ if ( match . index === undefined ) {
401+ continue ;
402+ }
403+ let expandedValue = '' ;
404+ let fullMatch : string ;
405+
406+ if ( type === 'for' ) {
407+ const [ , itemVar , sourceExpr , innerTemplate ] = match ;
408+ fullMatch = match [ 0 ] ;
409+ const sourceExprTrimmed = sourceExpr . trim ( ) ;
410+ const dataCollection = this . getLoopDataCollection (
411+ sourceExprTrimmed ,
412+ collections
413+ ) ;
414+
415+ if ( Array . isArray ( dataCollection ) ) {
416+ for ( const el of dataCollection ) {
417+ expandedValue += this . applyItemTemplate (
418+ innerTemplate ,
419+ itemVar ,
420+ el
421+ ) ;
422+ }
423+ } else if ( dataCollection && typeof dataCollection === 'object' ) {
424+ for ( const key of Object . keys ( dataCollection ) ) {
425+ expandedValue += this . applyItemTemplate (
426+ innerTemplate ,
427+ itemVar ,
428+ dataCollection [ key ] ,
429+ key
430+ ) ;
431+ }
432+ }
433+ } else {
434+ // data-for
435+ const [
436+ fm ,
437+ tag ,
438+ attrsBefore ,
439+ itemVar ,
440+ sourceExpr ,
441+ attrsAfter ,
442+ innerTemplate = '' ,
443+ ] = match ;
444+ fullMatch = fm ;
445+ const sourceExprTrimmed = sourceExpr . trim ( ) ;
446+ const dataCollection = this . getLoopDataCollection (
447+ sourceExprTrimmed ,
448+ collections
449+ ) ;
450+
451+ if ( Array . isArray ( dataCollection ) ) {
452+ for ( const el of dataCollection ) {
453+ const itemTemplate = this . applyItemTemplate (
454+ innerTemplate ,
455+ itemVar ,
456+ el
457+ ) ;
458+ expandedValue += `<${ tag } ${ attrsBefore } ${ attrsAfter } >${ itemTemplate } </${ tag } >` ;
459+ }
460+ } else if ( dataCollection && typeof dataCollection === 'object' ) {
461+ for ( const key of Object . keys ( dataCollection ) ) {
462+ const itemTemplate = this . applyItemTemplate (
463+ innerTemplate ,
464+ itemVar ,
465+ dataCollection [ key ] ,
466+ key
467+ ) ;
468+ expandedValue += `<${ tag } ${ attrsBefore } ${ attrsAfter } >${ itemTemplate } </${ tag } >` ;
469+ }
470+ }
471+ }
472+
473+ resultHtml =
474+ resultHtml . slice ( 0 , match . index ) +
475+ expandedValue +
476+ resultHtml . slice ( match . index + fullMatch . length ) ;
477+ }
478+ }
479+
480+ return resultHtml ;
481+ }
482+
483+ /**
484+ * Gets the data collection for a loop from the given fields.
485+ *
486+ * @param sourceExprTrimmed The trimmed source expression.
487+ * @param collections Available collections
488+ * @param collections.data Available record data
489+ * @param collections.aggregation Available aggregation data
490+ * @returns The data collection.
491+ */
492+ private getLoopDataCollection (
493+ sourceExprTrimmed : string ,
494+ collections : { data ?: any ; aggregation ?: any }
495+ ) : any {
496+ let dataCollection : any ;
497+ if ( sourceExprTrimmed . startsWith ( 'data.' ) ) {
498+ dataCollection = get (
499+ collections . data ,
500+ sourceExprTrimmed . replace ( / ^ d a t a \. / , '' )
501+ ) ;
502+ } else if ( sourceExprTrimmed . startsWith ( 'aggregation.' ) ) {
503+ dataCollection = get (
504+ collections . aggregation ,
505+ sourceExprTrimmed . replace ( / ^ a g g r e g a t i o n \. / , '' )
506+ ) ;
507+ } else {
508+ dataCollection = get ( collections . data , sourceExprTrimmed ) ;
509+ if ( dataCollection === undefined ) {
510+ dataCollection = get ( collections . aggregation , sourceExprTrimmed ) ;
511+ }
512+ }
513+ return dataCollection ;
514+ }
515+
516+ /**
517+ * Replaces provided element with the item value.
518+ *
519+ * @param template Template string
520+ * @param itemVar Item variable
521+ * @param itemValue Item value
522+ * @param index Index
523+ * @returns Item value
524+ */
525+ private applyItemTemplate (
526+ template : string ,
527+ itemVar : string ,
528+ itemValue : any ,
529+ index ?: string | number
530+ ) : string {
531+ let output = template ;
532+
533+ // More specific regex to avoid conflicts.
534+ const nestedRegex = new RegExp ( `\\{\\{${ itemVar } \\.([^}]+)\\}\\}` , 'g' ) ;
535+ output = output . replace ( nestedRegex , ( _m , p1 ) => {
536+ const v = get ( itemValue , p1 . trim ( ) ) ;
537+ return v == null ? '' : `${ v } ` ;
538+ } ) ;
539+
540+ const fullItemRegex = new RegExp ( `\\{\\{${ itemVar } \\}}` , 'g' ) ;
541+ output = output . replace ( fullItemRegex , ( ) =>
542+ ! isNil ( itemValue ) ? itemValue . toString ( ) : ''
543+ ) ;
544+
545+ if ( index !== undefined ) {
546+ output = output . replace ( / \{ \{ i n d e x \} \} / g, `${ index } ` ) ;
547+ }
548+
549+ return output ;
550+ }
551+
336552 /**
337553 * Replaces the html resource fields with the resource data.
338554 *
@@ -531,6 +747,10 @@ export class HtmlParserService {
531747 options . aggregation
532748 ) ;
533749 }
750+ formattedHtml = this . replaceForLoops ( formattedHtml , {
751+ data : options . data ,
752+ aggregation : options . aggregation ,
753+ } ) ;
534754 if ( options . data ) {
535755 formattedHtml = this . replaceRecordFields (
536756 formattedHtml ,
@@ -540,7 +760,7 @@ export class HtmlParserService {
540760 ) ;
541761 }
542762 formattedHtml = applyTableStyle ( formattedHtml ) ;
543- return this . applyOperations ( formattedHtml ) ;
763+ return this . applyOperations ( formattedHtml , options . data ) ;
544764 }
545765
546766 /**
@@ -613,6 +833,20 @@ export class HtmlParserService {
613833 } ) ) ;
614834 }
615835
836+ /**
837+ * Returns an array with the helper keys.
838+ *
839+ * @returns List of helper keys
840+ */
841+ public getHelpersKeys ( ) : { value : string ; text : string } [ ] {
842+ return [
843+ {
844+ value : '{{for item of collection}}...{{endfor}}' ,
845+ text : '{{for item of collection}}...{{endfor}}' ,
846+ } ,
847+ ] ;
848+ }
849+
616850 /**
617851 * Return an array with the page keys.
618852 *
0 commit comments