Skip to content

Commit 44a26b5

Browse files
arthur-polidorioCSimoesJr
authored andcommitted
feat(chart): adiciona customização da tooltip via innerHtml
Adiciona customização da tooltip via innerHtml; Adiciona tratamento para formatação customizada; Fixes: DTHFUI-11702
1 parent 58cd9c0 commit 44a26b5

File tree

6 files changed

+174
-29
lines changed

6 files changed

+174
-29
lines changed

projects/ui/src/lib/components/po-chart/interfaces/po-chart-serie.interface.ts

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,13 +54,44 @@ export interface PoChartSerie {
5454
*
5555
* @description
5656
*
57-
* Define o texto que será exibido ao passar o mouse por cima das séries do *chart*.
57+
* Define o texto que será exibido na tooltip ao passar o mouse por cima das séries do *chart*.
58+
*
59+
* Formatos aceitos:
60+
*
61+
* - **string**: pode conter marcadores dinâmicos e HTML simples.
62+
* - Marcadores disponíveis:
63+
* - `{name}` → Nome do item/categoria.
64+
* - `{seriesName}` → Nome da série.
65+
* - `{value}` → Valor correspondente.
66+
*
67+
* - **function**: função que recebe o objeto `params` e deve retornar uma *string* com o conteúdo da tooltip.
68+
*
69+
* > É possível utilizar marcação HTML simples (`<b>`, `<i>`, `<br>`, `<hr>`, etc.) que será interpretada via `innerHTML`.
70+
*
71+
* > Formatação customizada (será convertido internamente para HTML):
72+
* - `\n` → quebra de linha (`<br>`).
73+
* - `**texto**` → negrito (`<b>`).
74+
* - `__texto__` → itálico (`<i>`).
5875
*
5976
* > Caso não seja informado um valor para o *tooltip*, será exibido da seguinte forma:
6077
* - `donut`: `label`: valor proporcional ao total em porcentagem.
6178
* - `area`, `bar`, `column`, `line` e `pie`: `label`: `data`.
79+
*
80+
* ### Exemplos:
81+
*
82+
* **Usando string com placeholders:**
83+
* ```ts
84+
* tooltip: 'Ano: {name}<br>Série: {seriesName}<br>Valor: <b>{value}</b>'
85+
* ```
86+
*
87+
* **Usando função de callback:**
88+
* ```ts
89+
* tooltip = (params) => {
90+
* return `Ano: ${params.name}<br><i>Valor:</i> ${params.value}`;
91+
* }
92+
* ```
6293
*/
63-
tooltip?: string;
94+
tooltip?: string | ((params: any) => string);
6495

6596
/**
6697
* @optional

projects/ui/src/lib/components/po-chart/po-chart.component.spec.ts

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -934,6 +934,61 @@ describe('PoChartComponent', () => {
934934
});
935935
});
936936

937+
describe('parseTooltipText', () => {
938+
it('should return the same value when text is undefined', () => {
939+
const result = (component as any).parseTooltipText(undefined);
940+
expect(result).toBeUndefined();
941+
});
942+
943+
it('should return the same value when text is null', () => {
944+
const result = (component as any).parseTooltipText(null);
945+
expect(result).toBeNull();
946+
});
947+
948+
it('should return the same value when text is empty string', () => {
949+
const result = (component as any).parseTooltipText('');
950+
expect(result).toBe('');
951+
});
952+
953+
it('should parse \\n into <br>', () => {
954+
const result = (component as any).parseTooltipText('linha1\nlinha2');
955+
expect(result).toBe('linha1<br>linha2');
956+
});
957+
958+
it('should parse ** into <b>', () => {
959+
const result = (component as any).parseTooltipText('valor **negrito** aqui');
960+
expect(result).toBe('valor <b>negrito</b> aqui');
961+
});
962+
963+
it('should parse __ into <i>', () => {
964+
const result = (component as any).parseTooltipText('um __itálico__ aqui');
965+
expect(result).toBe('um <i>itálico</i> aqui');
966+
});
967+
});
968+
969+
describe('resolveCustomTooltip', () => {
970+
it('should call tooltip function when serie.tooltip is a function', () => {
971+
const params = {
972+
name: 'Jan',
973+
seriesName: 'Serie 1',
974+
value: 100,
975+
seriesIndex: 0,
976+
dataIndex: 0,
977+
seriesType: 'line'
978+
};
979+
980+
const tooltipFn = jasmine.createSpy().and.returnValue('Custom **tooltip**\nValue: __100__');
981+
982+
component.series = [{ label: 'Serie 1', data: [100], tooltip: tooltipFn }];
983+
984+
const result = (component as any).resolveCustomTooltip(params, params.name, params.seriesName, params.value);
985+
986+
expect(tooltipFn).toHaveBeenCalledWith(params);
987+
988+
expect(result).toBe('Custom <b>tooltip</b><br>Value: <i>100</i>');
989+
});
990+
});
991+
937992
describe('setTooltipProperties', () => {
938993
const divTooltipElement = document.createElement('div');
939994

@@ -953,7 +1008,7 @@ describe('PoChartComponent', () => {
9531008
};
9541009

9551010
component['setTooltipProperties'](divTooltipElement, params);
956-
expect(component['tooltipText']).toBe('<b>Jan</b><br>\n Serie 1: <b>20%</b>');
1011+
expect(component['tooltipText']).toBe('<b>Jan</b><br>Serie 1: <b>20%</b>');
9571012

9581013
component.series[0].tooltip = 'Custom tooltip';
9591014
component['itemsTypeDonut'] = undefined;

projects/ui/src/lib/components/po-chart/po-chart.component.ts

Lines changed: 42 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -290,6 +290,13 @@ export class PoChartComponent extends PoChartBaseComponent implements OnInit, Af
290290
});
291291
}
292292

293+
private applyStringFormatter(template: string, context: { name: string; seriesName: string; value: any }): string {
294+
return template
295+
.replace('{name}', context.name)
296+
.replace('{seriesName}', context.seriesName)
297+
.replace('{value}', context.value);
298+
}
299+
293300
private observeContainerResize(): void {
294301
if (!this.series?.length) return;
295302

@@ -425,6 +432,38 @@ export class PoChartComponent extends PoChartBaseComponent implements OnInit, Af
425432
});
426433
}
427434

435+
private parseTooltipText(text: string): string {
436+
if (!text) return text;
437+
438+
return text
439+
.replace(/\n/g, '<br>')
440+
.replace(/\*\*(.*?)\*\*/g, '<b>$1</b>')
441+
.replace(/__(.*?)__/g, '<i>$1</i>');
442+
}
443+
444+
private resolveCustomTooltip(params: any, name: string, seriesName: string, valueLabel: string): string {
445+
let text: string | undefined;
446+
447+
const serie = params.seriesType === 'pie' ? this.series[params.dataIndex] : this.series[params.seriesIndex];
448+
449+
if (serie?.tooltip) {
450+
if (typeof serie.tooltip === 'function') {
451+
text = serie.tooltip(params);
452+
} else {
453+
text = this.applyStringFormatter(serie.tooltip, { name, seriesName, value: valueLabel });
454+
}
455+
}
456+
457+
if (!text) {
458+
text =
459+
seriesName && !seriesName.includes('\u00000')
460+
? `<b>${name}</b><br>${seriesName}: <b>${valueLabel}</b>`
461+
: `${name}: <b>${valueLabel}</b>`;
462+
}
463+
464+
return this.parseTooltipText(text);
465+
}
466+
428467
private setTooltipProperties(divTooltipElement, params) {
429468
const chartElement = this.el.nativeElement.querySelector('#chart-id');
430469
let valueLabel = params.value;
@@ -436,22 +475,9 @@ export class PoChartComponent extends PoChartBaseComponent implements OnInit, Af
436475
}
437476
const name = params.name === ' ' ? this.literals.item : params.name;
438477
const seriesName = params.seriesName === ' ' ? this.literals.item : params.seriesName;
439-
const customTooltipText =
440-
seriesName && !seriesName.includes('\u00000')
441-
? `<b>${name}</b><br>
442-
${seriesName}: <b>${valueLabel}</b>`
443-
: `${name}: <b>${valueLabel}</b>`;
444-
445-
const isPie = params.seriesType === 'pie';
446-
if (isPie) {
447-
this.tooltipText = this.series[params.dataIndex].tooltip
448-
? this.series[params.dataIndex].tooltip
449-
: customTooltipText;
450-
} else {
451-
this.tooltipText = this.series[params.seriesIndex].tooltip
452-
? this.series[params.seriesIndex].tooltip
453-
: customTooltipText;
454-
}
478+
479+
this.tooltipText = this.resolveCustomTooltip(params, name, seriesName, valueLabel);
480+
455481
divTooltipElement.style.left = `${params.event.offsetX + chartElement.offsetLeft + 3}px`;
456482
divTooltipElement.style.top = `${chartElement.offsetTop + params.event.offsetY - 2}px`;
457483
this.poTooltip.last.toggleTooltipVisibility(true);

projects/ui/src/lib/components/po-chart/samples/sample-po-chart-coffee-ranking/sample-po-chart-coffee-ranking.component.ts

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -62,9 +62,23 @@ export class SamplePoChartCoffeeRankingComponent {
6262
];
6363

6464
participationByCountryInWorldExports: Array<PoChartSerie> = [
65-
{ label: 'Brazil', data: [35, 32, 25, 29, 33, 33], color: 'color-10' },
66-
{ label: 'Vietnam', data: [15, 17, 23, 19, 22, 18] },
67-
{ label: 'Colombia', data: [8, 7, 6, 9, 10, 11] },
65+
{
66+
label: 'Brazil',
67+
data: [35, 32, 25, 29, 33, 33],
68+
color: 'color-10',
69+
tooltip: params =>
70+
`País: ${params.seriesName}<br><b>Ano:</b> ${params.name}<br><b>Exportações:</b> ${params.value}%`
71+
},
72+
{
73+
label: 'Vietnam',
74+
data: [15, 17, 23, 19, 22, 18],
75+
tooltip: 'Exportações de <b>{seriesName}</b><br><i>Ano:</i> {name}<br>Participação: {value}%'
76+
},
77+
{
78+
label: 'Colombia',
79+
data: [8, 7, 6, 9, 10, 11],
80+
tooltip: 'País: {seriesName}\nAno: {name}\nParticipação: {value}%'
81+
},
6882
{ label: 'India', data: [5, 6, 5, 4, 5, 5] },
6983
{ label: 'Indonesia', data: [7, 6, 10, 10, 4, 6] }
7084
];

projects/ui/src/lib/directives/po-tooltip/po-tooltip.directive.spec.ts

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,17 @@ describe('PoTooltipDirective', () => {
214214
expect(renderer.setProperty).not.toHaveBeenCalledWith(jasmine.any(Object), 'innerHTML', jasmine.any(String));
215215
});
216216

217+
it('should sanitize tooltip and fallback to empty string when sanitize returns null', () => {
218+
directive.tooltip = '<b>test</b>';
219+
directive.innerHtml = true;
220+
221+
spyOn(directive['sanitizer'], 'sanitize').and.returnValue(null as any);
222+
223+
directive['createTooltip']();
224+
225+
expect(directive.divContent.innerHTML).toBe('');
226+
});
227+
217228
it('onMouseEnter: should create tooltip ', fakeAsync(() => {
218229
directive.tooltip = 'TEXT';
219230
directive.tooltipContent = false;
@@ -404,13 +415,16 @@ describe('PoTooltipDirective', () => {
404415
expect(directive.tooltipContent).toBe(undefined);
405416
}));
406417

407-
it('should call update Text and set innerHTML when innerHtml is true', () => {
418+
it('should call update Text and set innerHTML as empty string when sanitizer returns null', () => {
408419
directive.lastTooltipText = 'abc';
409-
directive.tooltip = '<b>def</b>';
420+
directive.tooltip = '<b>ghi</b>';
410421
directive.innerHtml = true;
422+
423+
spyOn(directive['sanitizer'], 'sanitize').and.returnValue(null);
424+
411425
directive.updateTextContent();
412426

413-
expect(directive.divContent.innerHTML).toBe('<b>def</b>');
427+
expect(directive.divContent.innerHTML).toBe('');
414428
});
415429

416430
it('should keep text without changes', () => {

projects/ui/src/lib/directives/po-tooltip/po-tooltip.directive.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1-
import { Directive, ElementRef, HostListener, OnInit, Renderer2, OnDestroy } from '@angular/core';
1+
import { Directive, ElementRef, HostListener, OnInit, Renderer2, OnDestroy, SecurityContext } from '@angular/core';
22

33
import { PoTooltipBaseDirective } from './po-tooltip-base.directive';
44
import { PoTooltipControlPositionService } from './po-tooltip-control-position.service';
5+
import { DomSanitizer } from '@angular/platform-browser';
56

67
const nativeElements = ['input', 'button'];
78

@@ -45,7 +46,8 @@ export class PoTooltipDirective extends PoTooltipBaseDirective implements OnInit
4546
constructor(
4647
private elementRef: ElementRef,
4748
private renderer: Renderer2,
48-
private poControlPosition: PoTooltipControlPositionService
49+
private poControlPosition: PoTooltipControlPositionService,
50+
private readonly sanitizer: DomSanitizer
4951
) {
5052
super();
5153
}
@@ -162,7 +164,8 @@ export class PoTooltipDirective extends PoTooltipBaseDirective implements OnInit
162164

163165
if (this.innerHtml) {
164166
this.textContent = this.renderer.createText('');
165-
this.renderer.setProperty(this.divContent, 'innerHTML', this.tooltip);
167+
const securityContent = this.sanitizer.sanitize(SecurityContext.HTML, this.tooltip) || '';
168+
this.renderer.setProperty(this.divContent, 'innerHTML', securityContent);
166169
}
167170
this.renderer.appendChild(this.divContent, this.textContent);
168171
this.renderer.appendChild(this.tooltipContent, this.divArrow);
@@ -224,7 +227,9 @@ export class PoTooltipDirective extends PoTooltipBaseDirective implements OnInit
224227
this.textContent = this.renderer.createText(this.tooltip);
225228
this.renderer.appendChild(this.divContent, this.textContent);
226229
if (this.innerHtml) {
227-
this.renderer.setProperty(this.divContent, 'innerHTML', this.tooltip);
230+
this.textContent = this.renderer.createText('');
231+
const securityContent = this.sanitizer.sanitize(SecurityContext.HTML, this.tooltip) || '';
232+
this.renderer.setProperty(this.divContent, 'innerHTML', securityContent);
228233
}
229234
}
230235
}

0 commit comments

Comments
 (0)