Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -31,18 +31,22 @@
<div
class="bar-chart__list flex flex-1 flex-col gap-3"
data-testid="analytics-bar-chart-list">
@for (item of $topItems(); track item.name) {
<div data-testid="analytics-bar-row">
@for (row of $displayRows(); track row.name; let i = $index) {
<div
data-testid="analytics-bar-row"
[attr.aria-label]="row.ariaLabel"
(mouseenter)="onRowMouseEnter(i, row)"
(mouseleave)="onRowMouseLeave(i)">
<div class="flex items-center justify-between mb-1">
<span
class="text-sm text-color truncate"
data-testid="analytics-bar-row-label">
{{ item.name }}
{{ row.name }}
</span>
<span
class="text-sm font-semibold text-color shrink-0 ml-2"
data-testid="analytics-bar-row-value">
{{ item.percentage }}%
{{ row.percentage }}%
</span>
</div>
<div
Expand All @@ -51,7 +55,10 @@
<div
class="h-full rounded-full bg-primary transition-[width] duration-300 ease-in-out min-w-1"
data-testid="analytics-bar-row-fill"
[style.width.%]="item.percentage"></div>
tooltipPosition="top"
[pTooltip]="row.viewsTooltip"
[tooltipDisabled]="!row.viewsTooltip"
[style.width.%]="row.percentage"></div>
</div>
</div>
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { byTestId, createComponentFactory, Spectator } from '@ngneat/spectator/jest';

import { By } from '@angular/platform-browser';

import { DialogService } from 'primeng/dynamicdialog';
import { Tooltip } from 'primeng/tooltip';

import { DotMessageService } from '@dotcms/data-access';
import { ComponentStatus } from '@dotcms/dotcms-models';
Expand Down Expand Up @@ -34,7 +37,15 @@ describe('DotAnalyticsBarChartComponent', () => {
{
provide: DotMessageService,
useValue: {
get: jest.fn().mockReturnValue('Translated title')
get: jest.fn((key: string, ...args: string[]) => {
if (args.length) {
return `${key}[${args.join(',')}]`;
}

return key === 'analytics.charts.browser-breakdown.title'
? 'Translated title'
: key;
})
}
}
]
Expand All @@ -61,6 +72,49 @@ describe('DotAnalyticsBarChartComponent', () => {
expect(rows.length).toBe(SAMPLE_DATA.length);
});

it('should expose views tooltip and aria-label on bar rows with formatted view count', () => {
const rows = spectator.queryAll(byTestId('analytics-bar-row'));
const firstRow = rows[0] as HTMLElement;
expect(firstRow.getAttribute('aria-label')).toBe(
'Chrome, analytics.pageview.charts.multi-views-tooltip[750]'
);
Comment thread
nicobytes marked this conversation as resolved.
expect(rows[0].querySelector('[data-testid="analytics-bar-row-fill"]')).toBeTruthy();
});

it('should activate bar fill tooltip when the row is hovered', () => {
const fillTooltip = spectator.debugElement
.queryAll(By.directive(Tooltip))
.map((de) => de.injector.get(Tooltip))[0];
const activateSpy = jest.spyOn(fillTooltip, 'activate');

spectator.triggerEventHandler('[data-testid="analytics-bar-row"]', 'mouseenter', {});

expect(activateSpy).toHaveBeenCalled();
activateSpy.mockRestore();
});

it('should use one-view tooltip key when views equals 1', () => {
spectator.setInput({
data: [{ name: 'Single', views: 1, percentage: 100, totalSessions: 1, time: '' }]
});
spectator.detectChanges();

const row = spectator.query(byTestId('analytics-bar-row')) as HTMLElement;
expect(row.getAttribute('aria-label')).toBe(
'Single, analytics.pageview.charts.one-view-tooltip'
);
});

it('should not set views tooltip when views is zero', () => {
spectator.setInput({
data: [{ name: 'Empty', views: 0, percentage: 0, totalSessions: 0, time: '' }]
});
spectator.detectChanges();

const row = spectator.query(byTestId('analytics-bar-row')) as HTMLElement;
expect(row.getAttribute('aria-label')).toBe('Empty');
});

it('should display the label and percentage for each bar row', () => {
const rows = spectator.queryAll(byTestId('analytics-bar-row'));
const firstRow = rows[0];
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,17 @@
import { ChangeDetectionStrategy, Component, computed, inject, input } from '@angular/core';
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
input,
viewChildren
} from '@angular/core';

import { ButtonModule } from 'primeng/button';
import { CardModule } from 'primeng/card';
import { DialogService, DynamicDialogModule } from 'primeng/dynamicdialog';
import { SkeletonModule } from 'primeng/skeleton';
import { Tooltip, TooltipModule } from 'primeng/tooltip';

import { DotMessageService } from '@dotcms/data-access';
import { ComponentStatus } from '@dotcms/dotcms-models';
Expand All @@ -12,16 +20,41 @@ import { DotMessagePipe } from '@dotcms/ui';

import { DotAnalyticsPageviewDetailTableDialogComponent } from '../../dialogs/pageview-detail-table-dialog/dot-analytics-pageview-detail-table-dialog.component';
import { buildPageviewDetailTableRows } from '../../dialogs/pageview-detail-table-dialog/dot-analytics-pageview-detail-table-dialog.models';
import { formatAnalyticsCount } from '../../utils/format-analytics-count.util';
import { DotAnalyticsEmptyStateComponent } from '../dot-analytics-empty-state/dot-analytics-empty-state.component';
import { DotAnalyticsStateMessageComponent } from '../dot-analytics-state-message/dot-analytics-state-message.component';

/** View model for one bar chart row (tooltip and aria-label precomputed). */
export interface DotAnalyticsBarChartRowVm {
name: string;
percentage: number;
viewsTooltip: string;
ariaLabel: string;
}

function buildViewsTooltip(messageService: DotMessageService, views: number): string {
if (!Number.isFinite(views) || views <= 0) {
return '';
}

if (Math.round(views) === 1) {
return messageService.get('analytics.pageview.charts.one-view-tooltip');
}

return messageService.get(
'analytics.pageview.charts.multi-views-tooltip',
formatAnalyticsCount(views, 'full')
);
}

@Component({
selector: 'dot-analytics-bar-chart',
imports: [
ButtonModule,
CardModule,
DynamicDialogModule,
SkeletonModule,
TooltipModule,
DotMessagePipe,
DotAnalyticsEmptyStateComponent,
DotAnalyticsStateMessageComponent
Expand All @@ -35,6 +68,7 @@ import { DotAnalyticsStateMessageComponent } from '../dot-analytics-state-messag
export class DotAnalyticsBarChartComponent {
readonly #messageService = inject(DotMessageService);
readonly #dialogService = inject(DialogService);
protected readonly $barFillTooltips = viewChildren(Tooltip);

readonly $data = input.required<EngagementPlatformMetrics[]>({ alias: 'data' });
readonly $status = input<ComponentStatus>(ComponentStatus.INIT, { alias: 'status' });
Expand All @@ -44,11 +78,22 @@ export class DotAnalyticsBarChartComponent {
/** Message key for the first column heading in the details modal table. */
readonly $detailsDimensionHeaderKey = input('', { alias: 'detailsDimensionHeaderKey' });

protected readonly $topItems = computed(() => {
protected readonly $displayRows = computed<DotAnalyticsBarChartRowVm[]>(() => {
const data = this.$data();
const max = this.$maxItems();

return [...data].sort((a, b) => b.percentage - a.percentage).slice(0, max);
const sorted = [...data].sort((a, b) => b.percentage - a.percentage).slice(0, max);

return sorted.map((item) => {
const viewsTooltip = buildViewsTooltip(this.#messageService, item.views);
const ariaLabel = viewsTooltip ? `${item.name}, ${viewsTooltip}` : item.name;

return {
name: item.name,
percentage: item.percentage,
viewsTooltip,
ariaLabel
};
});
});

protected readonly $resolvedCardHeader = computed(() => {
Expand All @@ -68,16 +113,29 @@ export class DotAnalyticsBarChartComponent {

protected readonly $isError = computed(() => this.$status() === ComponentStatus.ERROR);

protected readonly $isEmpty = computed(() => this.$topItems().length === 0);
protected readonly $isEmpty = computed(() => this.$displayRows().length === 0);

/** Footer link visibility: enabled, dimension key present, and chart rows exist. */
protected readonly $showDetailsFooter = computed(
() =>
this.$detailsEnabled() &&
!!this.$detailsDimensionHeaderKey().trim() &&
this.$topItems().length > 0
this.$displayRows().length > 0
);

/** Row hover shows tooltip anchored to the bar fill (center of the blue segment). */
protected onRowMouseEnter(index: number, row: DotAnalyticsBarChartRowVm): void {
if (!row.viewsTooltip) {
return;
}

this.$barFillTooltips()[index]?.activate();
}

protected onRowMouseLeave(index: number): void {
this.$barFillTooltips()[index]?.deactivate();
}

protected openPageviewDetailDialog(): void {
const firstColumnHeaderKey = this.$detailsDimensionHeaderKey().trim();
if (!firstColumnHeaderKey) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,19 @@
<p-card
class="bar-engagement-chart-card"
[dt]="{ body: { padding: '1.5rem' } }"
[header]="$resolvedCardHeader()"
data-testid="analytics-bar-engagement-chart">
<ng-template #title>
@if ($resolvedCardHeader(); as cardTitle) {
<span data-testid="analytics-bar-engagement-chart-title">{{ cardTitle }}</span>
}
</ng-template>
<ng-template #subtitle>
@if (!isError && !isLoading && !isEmpty) {
<span data-testid="analytics-bar-engagement-chart-subtitle">
{{ 'analytics.engagement.charts.subtitle' | dm }}
</span>
}
</ng-template>
@if (isError) {
<div
class="bar-engagement-chart__pane flex min-h-0 w-full flex-1 flex-col items-center justify-center">
Expand All @@ -18,12 +29,8 @@
<div
class="bar-engagement-chart__pane flex min-h-0 w-full flex-1 flex-col gap-3 py-2"
data-testid="analytics-bar-engagement-chart-skeleton">
<div class="flex gap-5">
<p-skeleton height="0.75rem" width="6rem" />
<p-skeleton height="0.75rem" width="6rem" />
</div>
<div
class="grid w-full min-w-0 items-center gap-x-2 gap-y-3 md:gap-x-3 grid-cols-[5.5rem_minmax(0,1fr)_auto]">
class="grid w-full min-w-0 items-center gap-x-2 gap-y-3 md:gap-x-3 grid-cols-[minmax(8rem,max-content)_minmax(0,1fr)_auto]">
@for (i of [1, 2, 3, 4, 5]; track i) {
<div class="contents">
<p-skeleton height="0.75rem" width="4rem" />
Expand All @@ -37,32 +44,18 @@
<dot-analytics-empty-state class="bar-engagement-chart__pane min-h-48 w-full flex-1" />
} @else {
<div
class="bar-engagement-chart__pane flex min-h-0 w-full min-w-0 flex-1 flex-col gap-4"
class="bar-engagement-chart__pane flex min-h-0 w-full min-w-0 flex-1 flex-col gap-3"
data-testid="analytics-bar-engagement-chart-body">
<div
class="flex flex-wrap items-center gap-4 text-sm text-color-secondary"
data-testid="analytics-bar-engagement-legend">
<span class="inline-flex items-center gap-2">
<span class="inline-block size-[0.65rem] rounded-xs bg-primary"></span>
{{ 'analytics.engagement.charts.legend.engaged' | dm }}
</span>
<span class="inline-flex items-center gap-2">
<span
class="inline-block size-[0.65rem] rounded-xs bg-(--bar-engagement-not-engaged)"></span>
{{ 'analytics.engagement.charts.legend.not-engaged' | dm }}
</span>
</div>

<div
class="grid w-full min-w-0 items-center gap-x-2 gap-y-3 md:gap-x-3 grid-cols-[5.5rem_minmax(0,1fr)_auto]"
class="grid w-full min-w-0 items-center gap-x-2 gap-y-3 md:gap-x-3 grid-cols-[minmax(8rem,max-content)_minmax(0,1fr)_auto]"
data-testid="analytics-bar-engagement-list">
@for (row of $displayRows(); track $index) {
@for (row of $displayRows(); track row.name) {
<div
class="contents"
data-testid="analytics-bar-engagement-row"
[attr.aria-label]="rowAriaLabel(row)">
[attr.aria-label]="row.ariaLabel">
<span
class="min-w-0 truncate text-sm text-color"
class="whitespace-nowrap text-sm text-color"
data-testid="analytics-bar-engagement-row-label">
{{ row.name }}
</span>
Expand All @@ -73,25 +66,12 @@
[totalSessions]="row.totalSessions"
height="1rem" />
</div>
<div
class="justify-self-start text-left min-w-0 text-xs tabular-nums text-color-secondary sm:text-sm"
<span
class="text-sm tabular-nums text-color"
data-testid="analytics-bar-engagement-sessions-total"
[attr.title]="sessionsTotalsTitle(row.totalSessions)">
<div class="inline-flex flex-row flex-nowrap items-center gap-1">
<span class="whitespace-nowrap">
{{ formatSessionsCount(row.totalSessions) }}
</span>
@if (row.totalSessions === 1) {
<span class="shrink-0 whitespace-nowrap">
{{ 'analytics.engagement.charts.session-label' | dm }}
</span>
} @else {
<span class="shrink-0 whitespace-nowrap">
{{ 'analytics.engagement.charts.sessions-label' | dm }}
</span>
}
</div>
</div>
[attr.title]="row.sessionsTotalsTitle">
{{ row.totalSessions | dotAnalyticsCount }}
</span>
</div>
}
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,24 +17,29 @@
min-height: 0;
display: flex;
flex-direction: column;
gap: 0;
}

:host ::ng-deep .bar-engagement-chart-card.p-card > .p-card-body > .p-card-title {
flex: 0 0 auto;
margin-bottom: 0;
}

:host ::ng-deep .bar-engagement-chart-card.p-card > .p-card-body > .p-card-subtitle {
flex: 0 0 auto;
margin-top: 0;
margin-bottom: 0;
}

/* `.p-card-content` holds pane (legend + matrix); inner footer uses margin-top:auto. */
/* `.p-card-content` holds chart rows; inner footer uses margin-top:auto. */
:host ::ng-deep .bar-engagement-chart-card.p-card > .p-card-body > .p-card-content {
flex: 1;
width: 100%;
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
margin-top: 1rem;
}

/* "View details" p-button-link override scoped to this component. */
Expand Down
Loading
Loading