Skip to content

Commit b8928ee

Browse files
feat: Allow iterating through records in a text widget (#2824)
AB#121364 --------- Co-authored-by: Antoine Hurard <[email protected]>
1 parent 46ee9c4 commit b8928ee

4 files changed

Lines changed: 278 additions & 8 deletions

File tree

libs/shared/src/lib/services/data-template/data-template.service.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ export class DataTemplateService {
5353
...this.htmlParserService.getDataKeys(fields),
5454
...this.htmlParserService.getAggregationKeys(aggregations || []),
5555
...this.htmlParserService.getCalcKeys(),
56+
...this.htmlParserService.getHelpersKeys(),
5657
];
5758
}
5859

libs/shared/src/lib/services/html-parser/html-parser-test-values.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -308,6 +308,22 @@ export const dataFormatElement = {
308308
`,
309309
after: `<p>66fa9502760ab688bf8508e9s</p><p>Published</p><p>I can't believe this is a title</p><p>Super duper dubi dat gua trikili dup description</p><p><input type="checkbox" style="margin: 0; height: 16px; width: 16px;" checked disabled></input></p><p><span style=''>9/28/2024, 0:21 PM</span></p><p><span style=''>9/30/2024</span></p> `,
310310
};
311+
/** Html with table generated through for-loop */
312+
export const forLoopTableElement = {
313+
before: `
314+
<table border="1">
315+
<tbody>
316+
{{for row in data.rows}}
317+
<tr>
318+
<td>{{row.title}}</td>
319+
<td>{{calc.date(row.date ; 'dd/MM/yyyy')}}</td>
320+
</tr>
321+
{{endfor}}
322+
</tbody>
323+
</table>
324+
`,
325+
after: `<table border="1" style="border-width: 1px;"><tbody><tr><td>First row</td><td>28/09/2024</td></tr><tr><td>Second row</td><td>05/10/2024</td></tr></tbody></table>`,
326+
};
311327
/** Record data */
312328
export const optionsData = {
313329
__typename: 'TestsMockData',
@@ -319,6 +335,13 @@ export const optionsData = {
319335
modifiedAt: '2024-09-30T12:21:21.498Z',
320336
createdAt: '2024-09-28T12:21:21.498Z',
321337
};
338+
/** Record data for loop table */
339+
export const forLoopTableData = {
340+
rows: [
341+
{ title: 'First row', date: '2024-09-28T12:00:00.000Z' },
342+
{ title: 'Second row', date: '2024-10-05T12:00:00.000Z' },
343+
],
344+
};
322345
/** Option data fields metadata to inject */
323346
export const optionFields = [
324347
{

libs/shared/src/lib/services/html-parser/html-parser.service.spec.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import {
99
calcFormatElement,
1010
customDateFormats,
1111
dataFormatElement,
12+
forLoopTableData,
13+
forLoopTableElement,
1214
maxMinValues,
1315
notNumberValueExamples,
1416
optionFields,
@@ -363,4 +365,14 @@ describe('HtmlParserService', () => {
363365
);
364366
});
365367
});
368+
describe('Parse HTML with for-loop generated tables', () => {
369+
it('renders rows from iterable data and applies expressions inside the loop', () => {
370+
const result = service.parseHtml(forLoopTableElement.before, {
371+
data: forLoopTableData,
372+
fields: [],
373+
});
374+
const normalize = (value: string) => value.replace(/\s+/g, '');
375+
expect(normalize(result)).toEqual(normalize(forLoopTableElement.after));
376+
});
377+
});
366378
});

libs/shared/src/lib/services/html-parser/html-parser.service.ts

Lines changed: 242 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -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(/&nbsp;/g, ' ') // Replace &nbsp; 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+
/\{\{for\s+(\w+)\s+of\s+([^}]+)\}\}([\s\S]*?)\{\{endfor\}\}/gm;
372+
const dataForRegex =
373+
/<(\w+)([^>]*?)\s+data-for="(\w+)\s+of\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(/^data\./, '')
501+
);
502+
} else if (sourceExprTrimmed.startsWith('aggregation.')) {
503+
dataCollection = get(
504+
collections.aggregation,
505+
sourceExprTrimmed.replace(/^aggregation\./, '')
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(/\{\{index\}\}/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

Comments
 (0)