Skip to content

Commit a1a0724

Browse files
authored
feat(health-metrics): add per-card and full-page empty states (#642)
* feat(health-metrics): add per-card and full-page empty states Adds standardized empty-state UI for the foundation health-metrics page: - New `lfx-health-metrics-card-empty-state` component with icon, title, description, optional CTA, and a bottom-stuck info banner that supports optional inline link (used by Board Meeting card) - New `lfx-health-metrics-full-page-empty-state` component for foundations with no data at all - Each of the 9 metric cards now exposes a `hasData` signal and a `hasDataChange` output so the parent can detect "all empty" state - Parent page renders the full-page empty state only when foundation-level totals (totalValue/totalProjects/totalMembers) are all zero AND every card reports no data — preserves the cards view if any year-filter option has data so users can switch ranges Replaces silent zero-renders and bare "No X data" text with consistent guidance copy across NPS, Participating Orgs, Membership Churn, Outstanding Balance, Events, Training & Certification, Code Contribution, Flywheel Conversion, and Board Meeting Participation cards. Signed-off-by: Nuno Eufrasio <nmeufrasio@gmail.com> * fix(health-metrics): address PR #642 review feedback Address review comments from copilot-pull-request-reviewer: - health-metrics-card-empty-state, health-metrics-full-page-empty-state: add explicit standalone: true to align with the rest of the health-metrics card components (per copilot[bot]). - training-certification-card.component.ts: include revenue fields in hasData so the card does not show the empty state when only Revenue mode has values (per copilot[bot]). - membership-churn-tier-card.component.ts: include membersLost and churnRatePct in hasData so churn with zero valueLost still renders the card (per copilot[bot]). - health-metrics.component.ts: reset cardDataStates on foundation change so stale per-card hasData signals from a previous foundation cannot incorrectly trigger the full-page empty state (per copilot[bot]). Resolves 4 review threads. Two additional threads about the foundation-totals proxy and hiding the year filter in the full-page empty state are responded to inline (intentional design — foundation totals are filter-independent). Signed-off-by: Nuno Eufrasio <nmeufrasio@gmail.com> * fix(health-metrics): address PR #642 review feedback round 2 Address review comments from copilot-pull-request-reviewer: - health-metrics-card-empty-state.component.html: tighten the banner wrapper condition so it only renders when there is content to show (an action string, or both actionLinkLabel and actionLinkHref). Previously, setting actionLinkLabel without actionLinkHref produced an empty banner with only an icon (per copilot[bot]). - health-metrics.component.ts: gate allCardsEmpty on every card having reported its hasData state. Cards now have to be present in cardDataStates (not just === false) before emptiness is evaluated, which removes the flicker where the page rendered the cards first and then snapped to the full-page empty state once children emitted hasDataChange (per copilot[bot]). Also: switch the per-card empty-state banner from blue tones to gray tones (bg-gray-50 / border-gray-200 / text-gray-500/700/900) per design feedback. Resolves 2 review threads. Signed-off-by: Nuno Eufrasio <nmeufrasio@gmail.com> * fix(health-metrics): address PR #642 review feedback round 3 Address review comments from copilot-pull-request-reviewer: - health-metrics-card-empty-state.component.ts: remove RouterLink from imports. lfx-button already exposes its own routerLink input and handles navigation internally — importing RouterLink at this level attaches the directive to the host <lfx-button> element and causes duplicated routing behavior (per copilot[bot]). - health-metrics-card-empty-state.component.ts: widen ctaRoute type to string | string[] | undefined to match lfx-button's routerLink input contract and the rest of the app, which routinely passes string paths (per copilot[bot]). Resolves 2 review threads. Signed-off-by: Nuno Eufrasio <nmeufrasio@gmail.com> * fix(review): address PR #642 review feedback (round 4) Address review comments from MRashad26, coderabbitai[bot]: - 9 health-metrics card components: replace effect() with toObservable() + RxJS pipe per project rules (per MRashad26 thread BWtea) - health-metrics.component.ts: collapse multi-line comments to single lines for readability (per MRashad26 threads BWteg, BWtey) - Re-run yarn format to satisfy CI format check (per coderabbitai[bot] threads BWjQy, BWjQ5) Resolves 5 review threads. Signed-off-by: Nuno Eufrasio <nmeufrasio@gmail.com> * fix(review): address PR #642 review feedback (round 5) Address review comment from MRashad26: - 9 health-metrics card components: move public output() declaration to sit immediately after public input() declarations, per component-organization.md (public fields → signals → computed) Resolves 1 review thread. Signed-off-by: Nuno Eufrasio <nmeufrasio@gmail.com> * fix(review): address PR #642 review feedback Address review comments from MRashad26: - health-metrics-card-empty-state.component.ts: simplified input declarations to plain input<T>() with consistent ordering (per MRashad26) - board-meeting-card.component.ts: changed addPastMeetingUrl to return string | null directly so template binding can pass it through without the `|| undefined` workaround (per MRashad26) - board-meeting-card.component.html: removed `|| undefined` from [actionLinkHref] binding (per MRashad26) - packages/shared/src/interfaces/dashboard-metric.interface.ts: added HealthMetricCardName type-safe union for the parent page's per-card state tracking (per MRashad26) - health-metrics.component.ts: typed cardDataStates as Partial<Record<HealthMetricCardName, boolean>>, cardNames as readonly HealthMetricCardName[], and updateCardDataState param as HealthMetricCardName (per MRashad26) - shared/utils/health-metrics-data.util.ts: extracted emitHasDataOnLoad util to centralize the loading->hasData->emit pattern across cards (per MRashad26) - 9 card components (board-meeting, code-contribution, events, nps, membership-churn-tier, outstanding-balance, participating-orgs, training-certification, flywheel-conversion): replaced inline toObservable/filter/map/takeUntilDestroyed block in constructor with emitHasDataOnLoad util call; cleaned up unused rxjs/rxjs-interop imports (per MRashad26) Resolves 4 review threads. Signed-off-by: Nuno Eufrasio <nmeufrasio@gmail.com> --------- Signed-off-by: Nuno Eufrasio <nmeufrasio@gmail.com>
1 parent 87e319a commit a1a0724

26 files changed

Lines changed: 596 additions & 228 deletions

apps/lfx-one/src/app/modules/dashboards/health-metrics/board-meeting-card/board-meeting-card.component.html

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
<!-- Header -->
66
<div class="flex items-center justify-between gap-4" data-testid="board-meeting-card-header">
77
<h3 class="text-sm font-bold tracking-wide text-gray-900 uppercase" data-testid="board-meeting-title">Board Meeting Participation</h3>
8-
@if (addPastMeetingUrl()) {
8+
@if (addPastMeetingUrl() && hasData()) {
99
<div class="text-xs text-gray-500">
1010
Missing a meeting?
1111
<a
@@ -37,10 +37,15 @@ <h3 class="text-sm font-bold tracking-wide text-gray-900 uppercase" data-testid=
3737
<p-skeleton width="100%" height="12rem" />
3838
</div>
3939
} @else if (!hasData()) {
40-
<!-- No Data State (slug not resolved) -->
41-
<div class="flex items-center justify-center py-8" data-testid="board-meeting-card-no-data">
42-
<p class="text-sm text-gray-500">No Board Meeting Participation Data Available</p>
43-
</div>
40+
<!-- Empty State -->
41+
<lfx-health-metrics-card-empty-state
42+
icon="fa-light fa-users"
43+
title="No board meeting data yet"
44+
description="Board meeting participation appears here once meetings are recorded."
45+
action="Missing a meeting?"
46+
actionLinkLabel="Add past meeting"
47+
[actionLinkHref]="addPastMeetingUrl()"
48+
data-testid="board-meeting-card-empty-state" />
4449
} @else {
4550
<!-- Metrics Row -->
4651
<div class="flex gap-12" data-testid="board-meeting-card-metrics">

apps/lfx-one/src/app/modules/dashboards/health-metrics/board-meeting-card/board-meeting-card.component.ts

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
// SPDX-License-Identifier: MIT
33

44
import { isPlatformBrowser } from '@angular/common';
5-
import { ChangeDetectionStrategy, Component, computed, DestroyRef, inject, input, PLATFORM_ID, signal } from '@angular/core';
5+
import { ChangeDetectionStrategy, Component, computed, DestroyRef, inject, input, output, PLATFORM_ID, signal } from '@angular/core';
66
import {
77
HEALTH_METRICS_BOARD_MEETING_DEFAULT_SUMMARY,
88
HEALTH_METRICS_BOARD_MEETING_JOB_TITLE_MAX_LENGTH,
@@ -11,8 +11,9 @@ import {
1111
import { parseLocalDateString } from '@lfx-one/shared/utils';
1212
import { AnalyticsService } from '@services/analytics.service';
1313
import { ProjectContextService } from '@services/project-context.service';
14-
import { initializeRangeDataFetching } from '@shared/utils/health-metrics-data.util';
14+
import { emitHasDataOnLoad, initializeRangeDataFetching } from '@shared/utils/health-metrics-data.util';
1515
import { SkeletonModule } from 'primeng/skeleton';
16+
import { HealthMetricsCardEmptyStateComponent } from '../health-metrics-card-empty-state/health-metrics-card-empty-state.component';
1617

1718
import { environment } from '@environments/environment';
1819

@@ -29,7 +30,7 @@ import type {
2930
@Component({
3031
selector: 'lfx-board-meeting-card',
3132
standalone: true,
32-
imports: [SkeletonModule],
33+
imports: [SkeletonModule, HealthMetricsCardEmptyStateComponent],
3334
templateUrl: './board-meeting-card.component.html',
3435
styleUrl: './board-meeting-card.component.scss',
3536
changeDetection: ChangeDetectionStrategy.OnPush,
@@ -41,6 +42,7 @@ export class BoardMeetingCardComponent {
4142
private readonly platformId = inject(PLATFORM_ID);
4243

4344
public readonly range = input<HealthMetricsRange>('YTD');
45+
public readonly hasDataChange = output<boolean>();
4446

4547
protected readonly pccUrl = environment.urls.pcc;
4648

@@ -54,10 +56,9 @@ export class BoardMeetingCardComponent {
5456
protected readonly hasData = computed(() => this.summaryData().dataAvailable);
5557
protected readonly hasInvitees = computed(() => this.summaryData().invitees.length > 0);
5658

57-
protected readonly addPastMeetingUrl = computed(() => {
59+
protected readonly addPastMeetingUrl = computed<string | null>(() => {
5860
const id = this.projectId();
59-
if (!id) return '';
60-
return `${this.pccUrl}/project/${id}/collaboration/meetings/manage-meeting?isPast=true`;
61+
return id ? `${this.pccUrl}/project/${id}/collaboration/meetings/manage-meeting?isPast=true` : null;
6162
});
6263

6364
protected readonly formattedAvgAttendance = computed(() => {
@@ -146,6 +147,7 @@ export class BoardMeetingCardComponent {
146147
if (isPlatformBrowser(this.platformId)) {
147148
this.initializeDataFetching();
148149
}
150+
emitHasDataOnLoad(this.loading, this.hasData, this.hasDataChange, this.destroyRef);
149151
}
150152

151153
protected onSort(field: BoardMeetingSortField): void {

apps/lfx-one/src/app/modules/dashboards/health-metrics/code-contribution-card/code-contribution-card.component.html

Lines changed: 17 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,16 @@
55
<!-- Header -->
66
<div class="flex items-center justify-between" data-testid="code-contribution-card-header">
77
<h3 class="text-sm font-bold tracking-wide text-gray-900 uppercase">Code Contribution</h3>
8-
<button
9-
class="ignore-download flex h-8 w-8 items-center justify-center rounded text-gray-400 hover:text-gray-600 disabled:cursor-not-allowed disabled:opacity-50"
10-
[disabled]="loading()"
11-
(click)="downloadCard()"
12-
aria-label="Download card as image"
13-
data-testid="code-contribution-card-download">
14-
<i class="fa-solid fa-arrow-down-to-bracket text-sm"></i>
15-
</button>
8+
@if (hasData()) {
9+
<button
10+
class="ignore-download flex h-8 w-8 items-center justify-center rounded text-gray-400 hover:text-gray-600 disabled:cursor-not-allowed disabled:opacity-50"
11+
[disabled]="loading()"
12+
(click)="downloadCard()"
13+
aria-label="Download card as image"
14+
data-testid="code-contribution-card-download">
15+
<i class="fa-solid fa-arrow-down-to-bracket text-sm"></i>
16+
</button>
17+
}
1618
</div>
1719

1820
@if (loading()) {
@@ -48,10 +50,13 @@ <h3 class="text-sm font-bold tracking-wide text-gray-900 uppercase">Code Contrib
4850
</div>
4951
</div>
5052
} @else if (!hasContributorData()) {
51-
<!-- No Data State -->
52-
<div class="flex items-center justify-center py-8" data-testid="code-contribution-card-no-data">
53-
<p class="text-sm text-gray-500">No Contribution Data Available</p>
54-
</div>
53+
<!-- Empty State -->
54+
<lfx-health-metrics-card-empty-state
55+
icon="fa-light fa-code"
56+
title="No code contribution data yet"
57+
description="Code contribution data appears once repositories are linked to this foundation."
58+
action="Connect repositories via the LFX Project Control Center to track contribution activity."
59+
data-testid="code-contribution-card-empty-state" />
5560
} @else {
5661
<!-- Metrics Row -->
5762
<div class="flex gap-8" data-testid="code-contribution-card-metrics">

apps/lfx-one/src/app/modules/dashboards/health-metrics/code-contribution-card/code-contribution-card.component.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,21 +2,22 @@
22
// SPDX-License-Identifier: MIT
33

44
import { isPlatformBrowser } from '@angular/common';
5-
import { ChangeDetectionStrategy, Component, computed, DestroyRef, ElementRef, inject, input, PLATFORM_ID, signal } from '@angular/core';
5+
import { ChangeDetectionStrategy, Component, computed, DestroyRef, ElementRef, inject, input, output, PLATFORM_ID, signal } from '@angular/core';
66
import { SkeletonModule } from 'primeng/skeleton';
7+
import { HealthMetricsCardEmptyStateComponent } from '../health-metrics-card-empty-state/health-metrics-card-empty-state.component';
78
import { HEALTH_METRICS_CODE_CONTRIBUTION_DEFAULT_SUMMARY } from '@lfx-one/shared/constants';
89
import { buildInsightsUrl } from '@lfx-one/shared/utils';
910
import { AnalyticsService } from '@services/analytics.service';
1011
import { ProjectContextService } from '@services/project-context.service';
1112
import { downloadCardAsImage } from '@shared/utils/download-card.util';
12-
import { initializeRangeDataFetching } from '@shared/utils/health-metrics-data.util';
13+
import { emitHasDataOnLoad, initializeRangeDataFetching } from '@shared/utils/health-metrics-data.util';
1314

1415
import type { CodeContributionSummaryResponse, HealthMetricsRange } from '@lfx-one/shared/interfaces';
1516

1617
@Component({
1718
selector: 'lfx-code-contribution-card',
1819
standalone: true,
19-
imports: [SkeletonModule],
20+
imports: [SkeletonModule, HealthMetricsCardEmptyStateComponent],
2021
templateUrl: './code-contribution-card.component.html',
2122
styleUrl: './code-contribution-card.component.scss',
2223
changeDetection: ChangeDetectionStrategy.OnPush,
@@ -29,11 +30,13 @@ export class CodeContributionCardComponent {
2930
private readonly elementRef = inject(ElementRef);
3031

3132
public readonly range = input<HealthMetricsRange>('YTD');
33+
public readonly hasDataChange = output<boolean>();
3234

3335
protected readonly loading = signal(true);
3436
protected readonly summaryData = signal<CodeContributionSummaryResponse>(HEALTH_METRICS_CODE_CONTRIBUTION_DEFAULT_SUMMARY);
3537

3638
protected readonly hasContributorData = computed(() => this.summaryData().dataAvailable);
39+
protected readonly hasData = this.hasContributorData;
3740

3841
protected readonly formattedTotalContributors = computed(() => {
3942
return this.abbreviateCount(this.summaryData().totalContributors);
@@ -126,6 +129,7 @@ export class CodeContributionCardComponent {
126129
if (isPlatformBrowser(this.platformId)) {
127130
this.initializeDataFetching();
128131
}
132+
emitHasDataOnLoad(this.loading, this.hasData, this.hasDataChange, this.destroyRef);
129133
}
130134

131135
protected downloadCard(): void {

apps/lfx-one/src/app/modules/dashboards/health-metrics/events-card/events-card.component.html

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,16 @@
55
<!-- Header -->
66
<div class="flex items-center justify-between" data-testid="events-card-header">
77
<h3 class="text-sm font-bold tracking-wide text-gray-900 uppercase">Events</h3>
8-
<button
9-
class="ignore-download flex h-8 w-8 items-center justify-center rounded text-gray-400 hover:text-gray-600 disabled:cursor-not-allowed disabled:opacity-50"
10-
[disabled]="loading()"
11-
(click)="downloadCard()"
12-
aria-label="Download card as image"
13-
data-testid="events-card-download">
14-
<i class="fa-solid fa-arrow-down-to-bracket text-sm"></i>
15-
</button>
8+
@if (hasData()) {
9+
<button
10+
class="ignore-download flex h-8 w-8 items-center justify-center rounded text-gray-400 hover:text-gray-600 disabled:cursor-not-allowed disabled:opacity-50"
11+
[disabled]="loading()"
12+
(click)="downloadCard()"
13+
aria-label="Download card as image"
14+
data-testid="events-card-download">
15+
<i class="fa-solid fa-arrow-down-to-bracket text-sm"></i>
16+
</button>
17+
}
1618
</div>
1719

1820
@if (loading()) {
@@ -41,6 +43,14 @@ <h3 class="text-sm font-bold tracking-wide text-gray-900 uppercase">Events</h3>
4143
<p-skeleton width="6rem" height="0.75rem" />
4244
</div>
4345
</div>
46+
} @else if (!hasData()) {
47+
<!-- Empty State -->
48+
<lfx-health-metrics-card-empty-state
49+
icon="fa-light fa-calendar-days"
50+
title="No events yet"
51+
description="Events appear here once they are scheduled for this foundation."
52+
action="Create events to start tracking attendance and sponsorship."
53+
data-testid="events-card-empty-state" />
4454
} @else {
4555
<!-- Metrics Row: Total Events | Upcoming | Past -->
4656
<div class="flex gap-6" data-testid="events-card-metrics">

apps/lfx-one/src/app/modules/dashboards/health-metrics/events-card/events-card.component.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,20 +2,21 @@
22
// SPDX-License-Identifier: MIT
33

44
import { isPlatformBrowser } from '@angular/common';
5-
import { ChangeDetectionStrategy, Component, computed, DestroyRef, ElementRef, inject, input, PLATFORM_ID, signal } from '@angular/core';
5+
import { ChangeDetectionStrategy, Component, computed, DestroyRef, ElementRef, inject, input, output, PLATFORM_ID, signal } from '@angular/core';
66
import { SkeletonModule } from 'primeng/skeleton';
7+
import { HealthMetricsCardEmptyStateComponent } from '../health-metrics-card-empty-state/health-metrics-card-empty-state.component';
78
import { HEALTH_METRICS_EVENTS_DEFAULT_SUMMARY } from '@lfx-one/shared/constants';
89
import { AnalyticsService } from '@services/analytics.service';
910
import { ProjectContextService } from '@services/project-context.service';
1011
import { environment } from '@environments/environment';
1112
import { downloadCardAsImage } from '@shared/utils/download-card.util';
12-
import { initializeRangeDataFetching } from '@shared/utils/health-metrics-data.util';
13+
import { emitHasDataOnLoad, initializeRangeDataFetching } from '@shared/utils/health-metrics-data.util';
1314
import type { EventsSummaryResponse, HealthMetricsRange } from '@lfx-one/shared/interfaces';
1415

1516
@Component({
1617
selector: 'lfx-events-card',
1718
standalone: true,
18-
imports: [SkeletonModule],
19+
imports: [SkeletonModule, HealthMetricsCardEmptyStateComponent],
1920
templateUrl: './events-card.component.html',
2021
styleUrl: './events-card.component.scss',
2122
changeDetection: ChangeDetectionStrategy.OnPush,
@@ -28,10 +29,13 @@ export class EventsCardComponent {
2829
private readonly elementRef = inject(ElementRef);
2930

3031
public readonly range = input<HealthMetricsRange>('YTD');
32+
public readonly hasDataChange = output<boolean>();
3133

3234
protected readonly loading = signal(true);
3335
protected readonly summaryData = signal<EventsSummaryResponse>(HEALTH_METRICS_EVENTS_DEFAULT_SUMMARY);
3436

37+
protected readonly hasData = computed(() => this.summaryData().totalEvents > 0 || this.summaryData().upcomingEvents > 0);
38+
3539
protected readonly formattedTotalEvents = computed(() => {
3640
return this.summaryData().totalEvents.toLocaleString();
3741
});
@@ -99,6 +103,7 @@ export class EventsCardComponent {
99103
if (isPlatformBrowser(this.platformId)) {
100104
this.initializeDataFetching();
101105
}
106+
emitHasDataOnLoad(this.loading, this.hasData, this.hasDataChange, this.destroyRef);
102107
}
103108

104109
protected downloadCard(): void {

apps/lfx-one/src/app/modules/dashboards/health-metrics/flywheel-conversion-card/flywheel-conversion-card.component.html

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,14 @@ <h3 class="text-sm font-bold tracking-wide text-gray-900 uppercase" data-testid=
1919
</div>
2020

2121
<!-- Description -->
22-
<div class="rounded-md bg-gray-50 px-4 py-3" data-testid="health-metrics-flywheel-description">
23-
<p class="text-xs text-gray-500">
24-
Percentage of event attendees who engage with newsletter, community, or working groups within 90 days after an event. This metric reflects the latest
25-
monthly window and is not affected by the page year filter.
26-
</p>
27-
</div>
22+
@if (hasFlywheelData()) {
23+
<div class="rounded-md bg-gray-50 px-4 py-3" data-testid="health-metrics-flywheel-description">
24+
<p class="text-xs text-gray-500">
25+
Percentage of event attendees who engage with newsletter, community, or working groups within 90 days after an event. This metric reflects the latest
26+
monthly window and is not affected by the page year filter.
27+
</p>
28+
</div>
29+
}
2830

2931
@if (loading()) {
3032
<!-- Loading Skeleton -->
@@ -44,10 +46,13 @@ <h3 class="text-sm font-bold tracking-wide text-gray-900 uppercase" data-testid=
4446
<p-skeleton width="100%" height="3rem" borderRadius="8px" />
4547
</div>
4648
} @else if (!hasFlywheelData()) {
47-
<!-- No Data State -->
48-
<div class="flex items-center justify-center py-10" data-testid="health-metrics-flywheel-no-data">
49-
<p class="text-sm text-gray-500">No Flywheel Conversion Data Available</p>
50-
</div>
49+
<!-- Empty State -->
50+
<lfx-health-metrics-card-empty-state
51+
icon="fa-light fa-arrows-rotate"
52+
title="No flywheel conversion data yet"
53+
description="Flywheel conversion appears once this foundation has recorded event attendance and 90 days have elapsed to measure follow-on engagement."
54+
action="Host events and encourage attendee participation to build conversion signals."
55+
data-testid="health-metrics-flywheel-empty-state" />
5156
} @else {
5257
<!-- Summary Row -->
5358
@if (summary(); as summaryView) {

apps/lfx-one/src/app/modules/dashboards/health-metrics/flywheel-conversion-card/flywheel-conversion-card.component.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
// SPDX-License-Identifier: MIT
33

44
import { isPlatformBrowser } from '@angular/common';
5-
import { ChangeDetectionStrategy, Component, computed, ElementRef, inject, input, PLATFORM_ID, signal } from '@angular/core';
5+
import { ChangeDetectionStrategy, Component, computed, DestroyRef, ElementRef, inject, input, output, PLATFORM_ID, signal } from '@angular/core';
66
import type { Signal } from '@angular/core';
77
import { toObservable, toSignal } from '@angular/core/rxjs-interop';
88
import { ChartComponent } from '@components/chart/chart.component';
@@ -16,8 +16,10 @@ import { buildFlywheelCardSummary, buildFlywheelFunnelStages, formatNumber, sele
1616
import { AnalyticsService } from '@services/analytics.service';
1717
import { ProjectContextService } from '@services/project-context.service';
1818
import { downloadCardAsImage } from '@shared/utils/download-card.util';
19+
import { emitHasDataOnLoad } from '@shared/utils/health-metrics-data.util';
1920
import { catchError, filter, finalize, map, of, switchMap, tap } from 'rxjs';
2021
import { SkeletonModule } from 'primeng/skeleton';
22+
import { HealthMetricsCardEmptyStateComponent } from '../health-metrics-card-empty-state/health-metrics-card-empty-state.component';
2123

2224
import type { ChartData, ChartOptions } from 'chart.js';
2325
import type {
@@ -33,7 +35,7 @@ const CONVERSION_PRECISION = HEALTH_METRICS_FLYWHEEL_CONVERSION_DECIMAL_PLACES;
3335
@Component({
3436
selector: 'lfx-flywheel-conversion-card',
3537
standalone: true,
36-
imports: [ChartComponent, SkeletonModule],
38+
imports: [ChartComponent, SkeletonModule, HealthMetricsCardEmptyStateComponent],
3739
templateUrl: './flywheel-conversion-card.component.html',
3840
styleUrl: './flywheel-conversion-card.component.scss',
3941
changeDetection: ChangeDetectionStrategy.OnPush,
@@ -42,11 +44,13 @@ export class FlywheelConversionCardComponent {
4244
private readonly elementRef = inject(ElementRef);
4345
private readonly analyticsService = inject(AnalyticsService);
4446
private readonly projectContextService = inject(ProjectContextService);
47+
private readonly destroyRef = inject(DestroyRef);
4548
private readonly platformId = inject(PLATFORM_ID);
4649

47-
// === Inputs ===
50+
// === Inputs / Outputs ===
4851
// TODO: wire range through to server once dbt NORTH_STAR_FLYWHEEL_CONVERSION supports range columns
4952
public readonly range = input<HealthMetricsRange>('YTD');
53+
public readonly hasDataChange = output<boolean>();
5054

5155
// === Internal State ===
5256
protected readonly loading = signal(true);
@@ -150,6 +154,10 @@ export class FlywheelConversionCardComponent {
150154
});
151155

152156
// === Protected Methods ===
157+
public constructor() {
158+
emitHasDataOnLoad(this.loading, this.hasFlywheelData, this.hasDataChange, this.destroyRef);
159+
}
160+
153161
protected downloadCard(): void {
154162
if (this.loading() || !this.hasFlywheelData()) return;
155163
downloadCardAsImage(this.elementRef.nativeElement, 'flywheel-conversion-rate');
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
<!-- Copyright The Linux Foundation and each contributor to LFX. -->
2+
<!-- SPDX-License-Identifier: MIT -->
3+
4+
<div class="mb-6 flex flex-1 flex-col items-center justify-center gap-3 py-4 text-center" role="status" [attr.aria-label]="title()">
5+
<div class="flex h-12 w-12 items-center justify-center rounded-full bg-gray-100">
6+
<i [class]="icon() + ' text-xl text-gray-400'" aria-hidden="true"></i>
7+
</div>
8+
<div class="flex flex-col items-center gap-1">
9+
<p class="text-sm font-semibold text-gray-700">{{ title() }}</p>
10+
<p class="max-w-md text-xs text-gray-500">{{ description() }}</p>
11+
</div>
12+
@if (ctaLabel() && ctaRoute()) {
13+
<lfx-button [label]="ctaLabel()!" severity="secondary" size="small" [routerLink]="ctaRoute()!" />
14+
} @else if (ctaLabel() && ctaHref()) {
15+
<a [href]="ctaHref()!" target="_blank" rel="noopener noreferrer" class="text-xs font-medium text-blue-600 hover:underline">
16+
{{ ctaLabel() }}
17+
</a>
18+
}
19+
</div>
20+
@if (action() || (actionLinkLabel() && actionLinkHref())) {
21+
<div class="-mx-5 -mb-5 mt-auto flex items-start justify-center gap-2 rounded-b-lg border-t border-gray-200 bg-gray-50 px-5 py-3">
22+
<i class="fa-light fa-circle-info mt-0.5 text-sm text-gray-500" aria-hidden="true"></i>
23+
<p class="text-xs text-gray-700">
24+
@if (action()) {
25+
<span>{{ action() }}</span>
26+
}
27+
@if (actionLinkLabel() && actionLinkHref()) {
28+
<a [href]="actionLinkHref()!" target="_blank" rel="noopener noreferrer" class="ml-1 font-medium text-gray-700 hover:text-gray-900 hover:underline">
29+
{{ actionLinkLabel() }}
30+
</a>
31+
}
32+
</p>
33+
</div>
34+
}

0 commit comments

Comments
 (0)