@firestitch/report 18.5.0 → 18.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/app/reports/components/component-list/component-list.component.d.ts +3 -6
- package/app/reports/components/report-canvas/report-canvas.component.d.ts +4 -4
- package/app/reports/components/report-component/report-component.component.d.ts +5 -5
- package/app/reports/components/timezone-select/timezone-select.component.d.ts +4 -6
- package/app/reports/consts/report-week.const.d.ts +2 -0
- package/app/reports/data/report-ai-chat.data.d.ts +12 -0
- package/app/reports/data/report.data.d.ts +3 -3
- package/app/reports/dialogs/component-settings/component-settings.component.d.ts +6 -6
- package/app/reports/dialogs/report-settings/report-settings.component.d.ts +5 -0
- package/app/reports/export/report-export-collector.service.d.ts +14 -2
- package/app/reports/export/report-pdf.service.d.ts +14 -1
- package/app/reports/export/report-pptx.service.d.ts +2 -1
- package/app/reports/format.d.ts +7 -0
- package/app/reports/interfaces/report-chat-viewer-filter.interface.d.ts +7 -0
- package/app/reports/interfaces/report-chat-viewer-state.interface.d.ts +7 -0
- package/app/reports/interfaces/report.interface.d.ts +17 -2
- package/app/reports/pipes/report-date.pipe.d.ts +7 -0
- package/app/reports/report-filter-items.d.ts +5 -0
- package/app/reports/report-url-state.d.ts +14 -0
- package/app/reports/services/report-filter-state.service.d.ts +24 -2
- package/app/reports/theme/typography.d.ts +2 -0
- package/app/reports/views/report/fs-ai-report.component.d.ts +16 -8
- package/app/reports/views/report/report.component.d.ts +6 -6
- package/esm2022/app/reports/components/component-list/component-list.component.mjs +29 -104
- package/esm2022/app/reports/components/report-canvas/report-canvas.component.mjs +52 -44
- package/esm2022/app/reports/components/report-component/report-component.component.mjs +55 -35
- package/esm2022/app/reports/components/timezone-select/timezone-select.component.mjs +23 -23
- package/esm2022/app/reports/consts/report-week.const.mjs +7 -0
- package/esm2022/app/reports/data/report-ai-chat.data.mjs +25 -0
- package/esm2022/app/reports/data/report.data.mjs +22 -9
- package/esm2022/app/reports/dialogs/component-settings/component-settings.component.mjs +25 -26
- package/esm2022/app/reports/dialogs/report-settings/report-settings.component.mjs +38 -13
- package/esm2022/app/reports/export/report-export-collector.service.mjs +92 -28
- package/esm2022/app/reports/export/report-export.service.mjs +11 -8
- package/esm2022/app/reports/export/report-pdf.service.mjs +241 -81
- package/esm2022/app/reports/export/report-pptx.service.mjs +60 -45
- package/esm2022/app/reports/format.mjs +119 -8
- package/esm2022/app/reports/interfaces/report-chat-viewer-filter.interface.mjs +2 -0
- package/esm2022/app/reports/interfaces/report-chat-viewer-state.interface.mjs +2 -0
- package/esm2022/app/reports/interfaces/report.interface.mjs +1 -1
- package/esm2022/app/reports/option-builder.mjs +131 -15
- package/esm2022/app/reports/pipes/report-date.pipe.mjs +21 -0
- package/esm2022/app/reports/report-filter-items.mjs +42 -10
- package/esm2022/app/reports/report-url-state.mjs +144 -0
- package/esm2022/app/reports/services/report-filter-state.service.mjs +308 -64
- package/esm2022/app/reports/theme/echarts.mjs +6 -5
- package/esm2022/app/reports/theme/typography.mjs +6 -0
- package/esm2022/app/reports/views/report/fs-ai-report.component.mjs +191 -90
- package/esm2022/app/reports/views/report/report.component.mjs +64 -62
- package/fesm2022/{firestitch-report-echarts-CtKYuRcT.mjs → firestitch-report-echarts-B9nl9XKM.mjs} +6 -6
- package/fesm2022/firestitch-report-echarts-B9nl9XKM.mjs.map +1 -0
- package/fesm2022/{firestitch-report-firestitch-report-Df1IXrEs.mjs → firestitch-report-firestitch-report-Bb65HEYB.mjs} +1707 -703
- package/fesm2022/firestitch-report-firestitch-report-Bb65HEYB.mjs.map +1 -0
- package/fesm2022/firestitch-report.mjs +1 -1
- package/package.json +1 -1
- package/app/reports/dialogs/report-settings/report-settings-default.d.ts +0 -2
- package/esm2022/app/reports/dialogs/report-settings/report-settings-default.mjs +0 -22
- package/fesm2022/firestitch-report-echarts-CtKYuRcT.mjs.map +0 -1
- package/fesm2022/firestitch-report-firestitch-report-Df1IXrEs.mjs.map +0 -1
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import * as i0 from '@angular/core';
|
|
2
|
-
import { inject, Injectable, Component, ChangeDetectionStrategy, Input,
|
|
2
|
+
import { inject, Injectable, signal, Component, ChangeDetectionStrategy, Input, Pipe, DestroyRef, ViewChild, input, output, computed, viewChild, ElementRef, effect, untracked, EventEmitter, NgZone, Output, HostBinding, ViewChildren, CUSTOM_ELEMENTS_SCHEMA, model } from '@angular/core';
|
|
3
3
|
import * as i1$1 from '@angular/forms';
|
|
4
4
|
import { FormsModule, ControlContainer, NgForm } from '@angular/forms';
|
|
5
5
|
import * as i3$3 from '@angular/material/button';
|
|
@@ -17,22 +17,22 @@ import * as i2$1 from '@firestitch/menu';
|
|
|
17
17
|
import { FsMenuModule } from '@firestitch/menu';
|
|
18
18
|
import { FsProcess } from '@firestitch/process';
|
|
19
19
|
import { FsPrompt } from '@firestitch/prompt';
|
|
20
|
-
import { map, shareReplay, filter, switchMap as switchMap$1, tap, mergeMap, toArray, catchError, finalize, takeUntil, take } from 'rxjs/operators';
|
|
20
|
+
import { map, shareReplay, filter, switchMap as switchMap$1, tap as tap$1, mergeMap, toArray, catchError, finalize, takeUntil, take, auditTime } from 'rxjs/operators';
|
|
21
21
|
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
|
22
22
|
import { NgxEchartsDirective, provideEchartsCore } from 'ngx-echarts';
|
|
23
|
-
import { FsAiChatComponent } from '@firestitch/ai';
|
|
23
|
+
import { FsAiChatComponent, FsAiChatData } from '@firestitch/ai';
|
|
24
24
|
import * as i1$3 from '@angular/cdk/drag-drop';
|
|
25
25
|
import { moveItemInArray, DragDropModule } from '@angular/cdk/drag-drop';
|
|
26
26
|
import * as i5$1 from '@firestitch/message';
|
|
27
27
|
import { FsMessage, FsMessageModule } from '@firestitch/message';
|
|
28
28
|
import * as i3 from '@firestitch/zoom-pan';
|
|
29
29
|
import { FsZoomPanComponent, FsZoomPanModule } from '@firestitch/zoom-pan';
|
|
30
|
-
import { of, Subject, map as map$1, debounceTime, switchMap, forkJoin, from, EMPTY, BehaviorSubject } from 'rxjs';
|
|
30
|
+
import { of, Subject, map as map$1, debounceTime, switchMap, tap, forkJoin, from, EMPTY, BehaviorSubject, merge } from 'rxjs';
|
|
31
31
|
import { FsApi, StreamEventType } from '@firestitch/api';
|
|
32
32
|
import * as i1$2 from '@angular/material/menu';
|
|
33
33
|
import { MatMenuModule } from '@angular/material/menu';
|
|
34
|
-
import { format, parseISO, startOfYear, startOfQuarter, startOfMonth, subMonths, subDays, subWeeks, startOfWeek, endOfWeek
|
|
35
|
-
import {
|
|
34
|
+
import { format, parseISO, isValid, startOfYear, startOfQuarter, startOfMonth, subMonths, subDays, subWeeks, startOfWeek, endOfWeek } from 'date-fns';
|
|
35
|
+
import { formatInTimeZone, toZonedTime } from 'date-fns-tz';
|
|
36
36
|
import * as i1 from '@firestitch/list';
|
|
37
37
|
import { PaginationStrategy, FsListComponent, FsListModule } from '@firestitch/list';
|
|
38
38
|
import { DomSanitizer } from '@angular/platform-browser';
|
|
@@ -262,22 +262,35 @@ class ReportData {
|
|
|
262
262
|
...config,
|
|
263
263
|
});
|
|
264
264
|
}
|
|
265
|
-
// Exports
|
|
266
|
-
//
|
|
267
|
-
// progress while
|
|
265
|
+
// Exports one component to CSV server-side: the file is what the screen
|
|
266
|
+
// shows (a chart or KPI as rendered, a list in full in the screen's order).
|
|
267
|
+
// The backend streams percentage progress while it writes.
|
|
268
|
+
//
|
|
269
|
+
// key: null on purpose, unlike the key: 'url' presigned-export pattern: the
|
|
270
|
+
// response also carries `notes`, the sentences saying how the file falls
|
|
271
|
+
// short of the full result, and the caller has to show them before it hands
|
|
272
|
+
// the url to FsProcess.download.
|
|
268
273
|
exportComponent(reportId, componentId, filters = [], state = {}, config = {}) {
|
|
269
274
|
return this._api.post(this._path(`${reportId}/components/${componentId}/export`), { filters, state }, {
|
|
270
|
-
key:
|
|
275
|
+
key: null,
|
|
271
276
|
...config,
|
|
272
277
|
});
|
|
273
278
|
}
|
|
274
279
|
// Exports EVERY component on the report — or on one page — server-side and
|
|
275
280
|
// resolves to a presigned URL for a zip of one CSV per component, foldered by
|
|
276
|
-
// page. `filters` carries the resolved values for
|
|
277
|
-
// each one picks out its own.
|
|
281
|
+
// page, plus an 'Export Notes.txt'. `filters` carries the resolved values for
|
|
282
|
+
// every component together; each one picks out its own. `frequency` is the
|
|
283
|
+
// report's group-by grain (the server re-buckets only time-series charts to
|
|
284
|
+
// it), and `filterSummary` is the filter bar as the viewer reads it, written
|
|
285
|
+
// verbatim into the notes file. Same keep-alive contract as exportComponent,
|
|
278
286
|
// over a longer run; consume through FsProcess.download.
|
|
279
|
-
exportReport(reportId, filters = [], pageId = null, config = {}) {
|
|
280
|
-
return this._api.post(this._path(`${reportId}/export`), {
|
|
287
|
+
exportReport(reportId, filters = [], pageId = null, frequency = null, filterSummary = [], config = {}) {
|
|
288
|
+
return this._api.post(this._path(`${reportId}/export`), {
|
|
289
|
+
filters,
|
|
290
|
+
...(pageId ? { pageId } : {}),
|
|
291
|
+
...(frequency ? { frequency } : {}),
|
|
292
|
+
filterSummary,
|
|
293
|
+
}, {
|
|
281
294
|
key: 'url',
|
|
282
295
|
...config,
|
|
283
296
|
});
|
|
@@ -540,21 +553,53 @@ function dateBoundLabel(value) {
|
|
|
540
553
|
const date = parseISO(bound);
|
|
541
554
|
return Number.isNaN(date.getTime()) ? String(value) : format(date, 'MMM d, yyyy');
|
|
542
555
|
}
|
|
556
|
+
// A stored yyyy-MM-dd boundary as a local Date for a date picker, or null.
|
|
557
|
+
// parseISO reads a bare Y-M-D as LOCAL midnight, so the picker shows the day
|
|
558
|
+
// that was stored, whatever the browser's zone.
|
|
559
|
+
function dateFromBound(value) {
|
|
560
|
+
if (!value) {
|
|
561
|
+
return null;
|
|
562
|
+
}
|
|
563
|
+
const date = parseISO(value.slice(0, 10));
|
|
564
|
+
return Number.isNaN(date.getTime()) ? null : date;
|
|
565
|
+
}
|
|
566
|
+
// A picked Date as a yyyy-MM-dd boundary for storage, or null. Uses the local
|
|
567
|
+
// calendar day (format, not toISOString) so the boundary is the day that was
|
|
568
|
+
// picked, regardless of the browser's zone.
|
|
569
|
+
function dateToBound(value) {
|
|
570
|
+
return value instanceof Date && !Number.isNaN(value.getTime())
|
|
571
|
+
? format(value, 'yyyy-MM-dd')
|
|
572
|
+
: null;
|
|
573
|
+
}
|
|
574
|
+
// A relative date-range preset key as a sentence-case label, derived from the
|
|
575
|
+
// camelCase key itself ('last12Months' reads 'Last 12 months', 'thisWeek'
|
|
576
|
+
// reads 'This week'), so no list of presets is mirrored here and a key the
|
|
577
|
+
// server adds later still reads sensibly.
|
|
578
|
+
function relativeLabel(relative) {
|
|
579
|
+
const words = relative
|
|
580
|
+
.replace(/([a-z])([A-Z0-9])/g, '$1 $2')
|
|
581
|
+
.replace(/(\d)([A-Z])/g, '$1 $2')
|
|
582
|
+
.toLowerCase();
|
|
583
|
+
return words.charAt(0).toUpperCase() + words.slice(1);
|
|
584
|
+
}
|
|
543
585
|
// Maps a report filter GROUP to an fs-filter config item, and reads fs-filter
|
|
544
586
|
// query values back into per-group FilterGroupValues. Shared by the report-bar
|
|
545
587
|
// fs-filter and the per-component fs-filter so both render through the same
|
|
546
588
|
// library and write into ReportFilterStateService identically. Items are named
|
|
547
589
|
// `g<groupId>` so the query keys map straight back to the group.
|
|
548
|
-
//
|
|
549
|
-
|
|
550
|
-
//
|
|
551
|
-
//
|
|
552
|
-
|
|
590
|
+
// The prefix of a group's item name, and of its key in the report URL.
|
|
591
|
+
const GROUP_PARAM_PREFIX = 'g';
|
|
592
|
+
// The query key fs-filter uses for a group's item(s), and the report URL's key
|
|
593
|
+
// for the group's value (report-url-state.ts). One naming for both.
|
|
594
|
+
function groupItemName(group) {
|
|
595
|
+
return `${GROUP_PARAM_PREFIX}${group.id}`;
|
|
596
|
+
}
|
|
553
597
|
// The query key for the report's group-by control. Fixed rather than derived
|
|
554
598
|
// from an id because a report has exactly one — and it is NOT a filter group,
|
|
555
599
|
// so it is read straight off the change query instead of through
|
|
556
|
-
// groupValuesFromQuery.
|
|
557
|
-
// the
|
|
600
|
+
// groupValuesFromQuery. It is also the report URL's key for the grain
|
|
601
|
+
// (report-url-state.ts). The grain travels to the server as the data
|
|
602
|
+
// request's `state.frequency`.
|
|
558
603
|
const GROUP_BY_ITEM = 'groupBy';
|
|
559
604
|
// A select group's distinct options, as { name, value } pairs. The server sends
|
|
560
605
|
// { value, label }: the value is what the filter matches on (an entity's stable
|
|
@@ -598,7 +643,7 @@ function matchOptions(options$, keyword) {
|
|
|
598
643
|
// Build the fs-filter config item for a group, seeding its default from the
|
|
599
644
|
// current session value so authored/relative defaults show selected on open.
|
|
600
645
|
function filterItemForGroup(group, reportData, reportId, initial) {
|
|
601
|
-
const name =
|
|
646
|
+
const name = groupItemName(group);
|
|
602
647
|
const label = group.label || group.filters?.[0]?.filterColumn || '';
|
|
603
648
|
switch (group.type) {
|
|
604
649
|
case 'dateRange':
|
|
@@ -701,7 +746,7 @@ function groupBySignature(report) {
|
|
|
701
746
|
// ReportFilterStateService.setValue.
|
|
702
747
|
function groupValuesFromQuery(query, groups) {
|
|
703
748
|
return groups.map((group) => {
|
|
704
|
-
const name =
|
|
749
|
+
const name = groupItemName(group);
|
|
705
750
|
if (group.type === 'dateRange') {
|
|
706
751
|
const start = query[`${name}From`];
|
|
707
752
|
const end = query[`${name}To`];
|
|
@@ -733,6 +778,305 @@ function selectQueryValues(raw) {
|
|
|
733
778
|
return String(raw).split(',');
|
|
734
779
|
}
|
|
735
780
|
|
|
781
|
+
// Report weeks start on Monday. This is a rule, not a setting: the server's
|
|
782
|
+
// weekly time buckets (ReportTimeBucket::getPeriodStart() at the Week grain)
|
|
783
|
+
// and its relative date presets (ReportRelativeRangeEnum) both start the week
|
|
784
|
+
// on Monday, so the viewer's thisWeek/lastWeek presets must too, or a weekly
|
|
785
|
+
// chart's buckets would straddle the range its own filter names.
|
|
786
|
+
const REPORT_WEEK_STARTS_ON = 1;
|
|
787
|
+
|
|
788
|
+
// Measure-value formatting shared by the KPI renderer and the exports — the
|
|
789
|
+
// config's `format` (number | percent | currency) and `precision` mean the same
|
|
790
|
+
// thing on screen, in PowerPoint and in PDF.
|
|
791
|
+
// percent takes a 0-1 ratio and shows it as a percentage: 0.3 reads 30%. With
|
|
792
|
+
// no precision it shows at most one decimal (0.037 reads 3.7%); a named
|
|
793
|
+
// precision counts the decimals of the percentage, not of the ratio, so
|
|
794
|
+
// precision 1 shows 0.3 as 30.0%.
|
|
795
|
+
//
|
|
796
|
+
// When the measure names a precision it is pinned as BOTH the minimum and the
|
|
797
|
+
// maximum, so a column of rates lines up instead of each value showing however
|
|
798
|
+
// many decimals it happens to need. Undefined leaves each format's own default
|
|
799
|
+
// alone.
|
|
800
|
+
function getFractionDigits(precision) {
|
|
801
|
+
return Number.isFinite(precision)
|
|
802
|
+
? { minimumFractionDigits: precision, maximumFractionDigits: precision }
|
|
803
|
+
: {};
|
|
804
|
+
}
|
|
805
|
+
function formatMeasureValue(value, format, precision) {
|
|
806
|
+
if (value === null || value === undefined || value === '') {
|
|
807
|
+
return '—';
|
|
808
|
+
}
|
|
809
|
+
const numeric = Number(value);
|
|
810
|
+
if (!Number.isFinite(numeric)) {
|
|
811
|
+
return String(value);
|
|
812
|
+
}
|
|
813
|
+
const fractionDigits = getFractionDigits(precision);
|
|
814
|
+
switch (format) {
|
|
815
|
+
case 'percent':
|
|
816
|
+
return new Intl.NumberFormat(undefined, {
|
|
817
|
+
style: 'percent',
|
|
818
|
+
maximumFractionDigits: 1,
|
|
819
|
+
...fractionDigits,
|
|
820
|
+
}).format(numeric);
|
|
821
|
+
case 'currency':
|
|
822
|
+
return new Intl.NumberFormat(undefined, {
|
|
823
|
+
style: 'currency',
|
|
824
|
+
currency: 'USD',
|
|
825
|
+
// Whole dollars unless the measure asks for decimals — cents were
|
|
826
|
+
// unrepresentable before, because this was hardcoded to 0.
|
|
827
|
+
maximumFractionDigits: 0,
|
|
828
|
+
...fractionDigits,
|
|
829
|
+
}).format(numeric);
|
|
830
|
+
default:
|
|
831
|
+
return new Intl.NumberFormat(undefined, fractionDigits).format(numeric);
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
// The axis peak at which a native chart's value axis gains thousands
|
|
835
|
+
// separators. From here up the ticks are whole numbers in practice; below it
|
|
836
|
+
// neither the screen nor PowerPoint's 'General' shows a separator.
|
|
837
|
+
const AXIS_SEPARATOR_THRESHOLD = 1000;
|
|
838
|
+
// PowerPoint's number format for a native chart's value axis, matching what the
|
|
839
|
+
// on-screen axis prints by default: thousands separators with each tick's own
|
|
840
|
+
// decimals. Excel has no "General with separators", so an axis reaching 1,000
|
|
841
|
+
// (where ticks are whole numbers) gets '#,##0', and a smaller range keeps
|
|
842
|
+
// 'General' so fractional ticks (0.25, 0.5) are not rounded away.
|
|
843
|
+
//
|
|
844
|
+
// The peak counts a missing value as 0. A stacked chart's peak is its tallest
|
|
845
|
+
// stack: the largest sum, per category, of every series' absolute value.
|
|
846
|
+
function chartAxisFormatCode(series, stacked) {
|
|
847
|
+
const magnitudes = series.map((values) => values.map((value) => Math.abs(value ?? 0)));
|
|
848
|
+
const peak = stacked
|
|
849
|
+
? Math.max(0, ...(magnitudes[0] ?? []).map((_, index) => magnitudes
|
|
850
|
+
.reduce((sum, values) => sum + (values[index] ?? 0), 0)))
|
|
851
|
+
: Math.max(0, ...magnitudes.flat());
|
|
852
|
+
return peak >= AXIS_SEPARATOR_THRESHOLD ? '#,##0' : 'General';
|
|
853
|
+
}
|
|
854
|
+
// The zone every date in a report is read in. The server resolves it (the
|
|
855
|
+
// report's own zone, else the application's default reporting zone) and sends
|
|
856
|
+
// it as `effectiveTimezone`; the browser's zone is only the fallback for a
|
|
857
|
+
// server too old to send the field. This is the one place that fallback lives,
|
|
858
|
+
// so every date the viewer renders or resolves agrees with every other.
|
|
859
|
+
function resolveReportTimezone(report) {
|
|
860
|
+
return report.effectiveTimezone ?? Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
861
|
+
}
|
|
862
|
+
const DATE_ONLY_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
|
|
863
|
+
const DATE_TIME_PATTERN = /^(\d{4}-\d{2}-\d{2})[ T](\d{2}:\d{2}:\d{2})(\.\d+)?$/;
|
|
864
|
+
// A report row's date cell as a calendar day in the report's zone.
|
|
865
|
+
//
|
|
866
|
+
// Report rows are raw database strings, not serialized models, so their shape
|
|
867
|
+
// is the wire contract: a DATE column arrives as 'YYYY-MM-DD' and a
|
|
868
|
+
// date-and-time column as 'YYYY-MM-DD HH:MM:SS' in UTC. Reading that shape
|
|
869
|
+
// here does not re-derive a server rule; it only stops the browser guessing.
|
|
870
|
+
// - A calendar day is never shifted: parseISO reads it as local midnight, so
|
|
871
|
+
// the day printed is the day stored, whatever the zone.
|
|
872
|
+
// - An instant is pinned to UTC with an explicit 'Z', then shown as the day it
|
|
873
|
+
// fell on in the report's zone, the same day the filters and time buckets use.
|
|
874
|
+
// - Anything else is shown as it came, and so is a value that has the shape
|
|
875
|
+
// but names no real day (a zero date '0000-00-00', or '2026-02-30'): one
|
|
876
|
+
// bad cell must not throw out of a pipe or abort an export.
|
|
877
|
+
function formatReportDate(value, timezone) {
|
|
878
|
+
if (value === null || value === undefined || value === '') {
|
|
879
|
+
return '';
|
|
880
|
+
}
|
|
881
|
+
const text = String(value);
|
|
882
|
+
if (DATE_ONLY_PATTERN.test(text)) {
|
|
883
|
+
const day = parseISO(text);
|
|
884
|
+
return isValid(day) ? format(day, 'MMM d, yyyy') : text;
|
|
885
|
+
}
|
|
886
|
+
const dateTime = DATE_TIME_PATTERN.exec(text);
|
|
887
|
+
if (!dateTime) {
|
|
888
|
+
return text;
|
|
889
|
+
}
|
|
890
|
+
const instant = parseISO(`${dateTime[1]}T${dateTime[2]}Z`);
|
|
891
|
+
if (!isValid(instant)) {
|
|
892
|
+
return text;
|
|
893
|
+
}
|
|
894
|
+
// An unknown zone makes date-fns-tz throw; UTC still names a real day.
|
|
895
|
+
try {
|
|
896
|
+
return formatInTimeZone(instant, timezone, 'MMM d, yyyy');
|
|
897
|
+
}
|
|
898
|
+
catch {
|
|
899
|
+
return formatInTimeZone(instant, 'UTC', 'MMM d, yyyy');
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
// A short, human label for a zone: its city plus the current abbreviation,
|
|
903
|
+
// e.g. 'America/New_York' reads 'New York (EDT)'. UTC stays 'UTC', and an
|
|
904
|
+
// unknown zone is shown as its id. An 'Etc/' zone names no city, and its POSIX
|
|
905
|
+
// sign is the reverse of the offset ('Etc/GMT+5' is GMT-5), so it reads as the
|
|
906
|
+
// abbreviation alone: 'Etc/UTC' is 'UTC', 'Etc/GMT+5' is 'GMT-5'.
|
|
907
|
+
function getTimezoneLabel(timezone) {
|
|
908
|
+
if (timezone === 'UTC') {
|
|
909
|
+
return timezone;
|
|
910
|
+
}
|
|
911
|
+
const segments = timezone.split('/');
|
|
912
|
+
try {
|
|
913
|
+
const abbreviation = formatInTimeZone(new Date(), timezone, 'zzz');
|
|
914
|
+
return segments[0] === 'Etc'
|
|
915
|
+
? abbreviation
|
|
916
|
+
: `${segments[segments.length - 1].replace(/_/g, ' ')} (${abbreviation})`;
|
|
917
|
+
}
|
|
918
|
+
catch {
|
|
919
|
+
return timezone;
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
// "Now" as a Date whose local fields read the wall clock in the given zone, so
|
|
923
|
+
// date-fns calendar arithmetic (startOfWeek, startOfMonth, ...) and
|
|
924
|
+
// dateBoundString() land on the zone's calendar day rather than the browser's.
|
|
925
|
+
// An unknown zone falls back to the browser's clock.
|
|
926
|
+
function getZonedNow(timezone) {
|
|
927
|
+
const now = new Date();
|
|
928
|
+
try {
|
|
929
|
+
const zoned = toZonedTime(now, timezone);
|
|
930
|
+
return Number.isNaN(zoned.getTime()) ? now : zoned;
|
|
931
|
+
}
|
|
932
|
+
catch {
|
|
933
|
+
return now;
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
// The report viewer's state in its URL: a pure codec, with no knowledge of a
|
|
938
|
+
// loaded report. ReportFilterStateService decides WHAT is carried (only values
|
|
939
|
+
// that differ from the authored defaults) and checks what is read back against
|
|
940
|
+
// the report; this file only turns values into query params and back.
|
|
941
|
+
//
|
|
942
|
+
// ?page=7&groupBy=week&g12=last30Days&g15=3,5&g16=2026-01-01..&s31=-create_date
|
|
943
|
+
//
|
|
944
|
+
// - g<groupId>: one key per filter group. A dateRange is its relative preset
|
|
945
|
+
// name when it carries one, else 'YYYY-MM-DD..YYYY-MM-DD' (either side may be
|
|
946
|
+
// empty); a select is its values joined by ','; a keyword is its text. An
|
|
947
|
+
// empty value is an explicit clear of a group that has a default.
|
|
948
|
+
// - s<componentId>: a list's sort column, '-' prefixed when descending.
|
|
949
|
+
// - groupBy: the group-by grain (GROUP_BY_ITEM).
|
|
950
|
+
// The page key is the host's (FsAiReportComponent.pageQueryParam) and is
|
|
951
|
+
// written beside these, never by this codec.
|
|
952
|
+
// The prefix of a list's sort key.
|
|
953
|
+
const LIST_SORT_PARAM_PREFIX = 's';
|
|
954
|
+
// Between the two days of an encoded fixed date range.
|
|
955
|
+
const DATE_RANGE_SEPARATOR = '..';
|
|
956
|
+
// A key this codec owns: a group value or a list sort. Any such key the
|
|
957
|
+
// snapshot no longer carries is removed from the URL.
|
|
958
|
+
const OWNED_PARAM = new RegExp(`^[${GROUP_PARAM_PREFIX}${LIST_SORT_PARAM_PREFIX}]\\d+$`);
|
|
959
|
+
const DAY_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
|
|
960
|
+
// The viewer-state params of a URL as raw strings. Keys this codec does not
|
|
961
|
+
// own are ignored.
|
|
962
|
+
function readReportUrlParams(params) {
|
|
963
|
+
const groups = new Map();
|
|
964
|
+
const listSorts = new Map();
|
|
965
|
+
for (const key of params.keys) {
|
|
966
|
+
if (!OWNED_PARAM.test(key)) {
|
|
967
|
+
continue;
|
|
968
|
+
}
|
|
969
|
+
const id = Number(key.slice(1));
|
|
970
|
+
const raw = params.get(key) ?? '';
|
|
971
|
+
if (key.startsWith(GROUP_PARAM_PREFIX)) {
|
|
972
|
+
groups.set(id, raw);
|
|
973
|
+
}
|
|
974
|
+
else {
|
|
975
|
+
listSorts.set(id, raw);
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
return { groups, listSorts, groupBy: params.get(GROUP_BY_ITEM) };
|
|
979
|
+
}
|
|
980
|
+
// A group value read back from its URL param. null is an explicit clear (an
|
|
981
|
+
// empty param). undefined means the param is unusable for a group of this type
|
|
982
|
+
// (an unknown preset name, a malformed day, a wrong shape) and is ignored.
|
|
983
|
+
// `resolveRelative` turns a preset name into today's range for it, or null for
|
|
984
|
+
// a name it does not know.
|
|
985
|
+
function decodeFilterValue(type, raw, resolveRelative) {
|
|
986
|
+
if (raw === '') {
|
|
987
|
+
return null;
|
|
988
|
+
}
|
|
989
|
+
switch (type) {
|
|
990
|
+
case 'dateRange':
|
|
991
|
+
return raw.includes(DATE_RANGE_SEPARATOR)
|
|
992
|
+
? decodeFixedRange(raw)
|
|
993
|
+
: decodePreset(raw, resolveRelative);
|
|
994
|
+
case 'select': {
|
|
995
|
+
const values = raw.split(',').filter((value) => value !== '');
|
|
996
|
+
return values.length ? { values } : undefined;
|
|
997
|
+
}
|
|
998
|
+
case 'keyword':
|
|
999
|
+
return raw.trim() ? { value: raw } : undefined;
|
|
1000
|
+
default:
|
|
1001
|
+
return undefined;
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
// A group value as its URL param. The shape says how: a preset name, then a
|
|
1005
|
+
// date range, then select values, then keyword text. null (a cleared group)
|
|
1006
|
+
// is the empty param.
|
|
1007
|
+
function encodeFilterValue(value) {
|
|
1008
|
+
if (!value) {
|
|
1009
|
+
return '';
|
|
1010
|
+
}
|
|
1011
|
+
if (value.preset) {
|
|
1012
|
+
return value.preset;
|
|
1013
|
+
}
|
|
1014
|
+
if (value.start || value.end) {
|
|
1015
|
+
return [dateBoundString(value.start) ?? '', dateBoundString(value.end) ?? '']
|
|
1016
|
+
.join(DATE_RANGE_SEPARATOR);
|
|
1017
|
+
}
|
|
1018
|
+
if (value.values) {
|
|
1019
|
+
return value.values.map((item) => String(item)).join(',');
|
|
1020
|
+
}
|
|
1021
|
+
return value.value ?? '';
|
|
1022
|
+
}
|
|
1023
|
+
function encodeSort(sort) {
|
|
1024
|
+
return `${sort.direction === 'desc' ? '-' : ''}${sort.column}`;
|
|
1025
|
+
}
|
|
1026
|
+
// A list sort read back from its URL param, or null when it names no column.
|
|
1027
|
+
// Whether the column is one the list can sort by is the caller's check.
|
|
1028
|
+
function decodeSort(raw) {
|
|
1029
|
+
const descending = raw.startsWith('-');
|
|
1030
|
+
const column = descending ? raw.slice(1) : raw;
|
|
1031
|
+
return column
|
|
1032
|
+
? { column, direction: descending ? 'desc' : 'asc' }
|
|
1033
|
+
: null;
|
|
1034
|
+
}
|
|
1035
|
+
// The query params that make the URL carry `snapshot`: every snapshot key is
|
|
1036
|
+
// set, and each owned key (or the grain) that `current` carries but the
|
|
1037
|
+
// snapshot does not is set to null, which removes it under the router's
|
|
1038
|
+
// 'merge' handling. Keys this codec does not own are left alone.
|
|
1039
|
+
function buildReportUrlParams(snapshot, current) {
|
|
1040
|
+
const params = {};
|
|
1041
|
+
for (const key of current.keys) {
|
|
1042
|
+
if (OWNED_PARAM.test(key) || key === GROUP_BY_ITEM) {
|
|
1043
|
+
params[key] = null;
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
snapshot.groups.forEach((value, groupId) => {
|
|
1047
|
+
params[groupItemName({ id: groupId })] = encodeFilterValue(value);
|
|
1048
|
+
});
|
|
1049
|
+
snapshot.listSorts.forEach((sort, componentId) => {
|
|
1050
|
+
params[`${LIST_SORT_PARAM_PREFIX}${componentId}`] = encodeSort(sort);
|
|
1051
|
+
});
|
|
1052
|
+
if (snapshot.frequency) {
|
|
1053
|
+
params[GROUP_BY_ITEM] = snapshot.frequency;
|
|
1054
|
+
}
|
|
1055
|
+
return params;
|
|
1056
|
+
}
|
|
1057
|
+
// 'YYYY-MM-DD..YYYY-MM-DD', either side optional but not both. Each side must
|
|
1058
|
+
// be a real calendar day.
|
|
1059
|
+
function decodeFixedRange(raw) {
|
|
1060
|
+
const sides = raw.split(DATE_RANGE_SEPARATOR);
|
|
1061
|
+
if (sides.length !== 2 || (!sides[0] && !sides[1])) {
|
|
1062
|
+
return undefined;
|
|
1063
|
+
}
|
|
1064
|
+
const [start, end] = sides.map((side) => side ? decodeDay(side) : null);
|
|
1065
|
+
if (start === undefined || end === undefined) {
|
|
1066
|
+
return undefined;
|
|
1067
|
+
}
|
|
1068
|
+
return { start, end };
|
|
1069
|
+
}
|
|
1070
|
+
// One side of a fixed range as a local Date, or undefined when it is not a
|
|
1071
|
+
// real Y-M-D day.
|
|
1072
|
+
function decodeDay(side) {
|
|
1073
|
+
return DAY_PATTERN.test(side) ? (dateFromBound(side) ?? undefined) : undefined;
|
|
1074
|
+
}
|
|
1075
|
+
function decodePreset(name, resolveRelative) {
|
|
1076
|
+
const range = resolveRelative(name);
|
|
1077
|
+
return range ? { start: range.start, end: range.end, preset: name } : undefined;
|
|
1078
|
+
}
|
|
1079
|
+
|
|
736
1080
|
// Whether two stored filter-group defaults are the same. Compared on the fields a
|
|
737
1081
|
// default can actually carry, so key order in the payload can't read as a change.
|
|
738
1082
|
function sameDefault(a, b) {
|
|
@@ -746,6 +1090,18 @@ function sameDefault(a, b) {
|
|
|
746
1090
|
&& (a.values ?? []).length === (b.values ?? []).length
|
|
747
1091
|
&& (a.values ?? []).every((value, index) => value === (b.values ?? [])[index]);
|
|
748
1092
|
}
|
|
1093
|
+
// Whether two list sorts order the rows the same way. No direction reads as
|
|
1094
|
+
// ascending, the way the list applies it.
|
|
1095
|
+
function sameSort(a, b) {
|
|
1096
|
+
return !!b
|
|
1097
|
+
&& a.column === b.column
|
|
1098
|
+
&& (a.direction ?? 'asc') === (b.direction ?? 'asc');
|
|
1099
|
+
}
|
|
1100
|
+
// Whether two date-range values cover the same calendar days.
|
|
1101
|
+
function sameDays(a, b) {
|
|
1102
|
+
return dateBoundString(a.start) === dateBoundString(b.start)
|
|
1103
|
+
&& dateBoundString(a.end) === dateBoundString(b.end);
|
|
1104
|
+
}
|
|
749
1105
|
// A stable signature for a group's stored default. The report toolbar keys its
|
|
750
1106
|
// fs-filter on this, because fs-filter reads its config once at init — without a
|
|
751
1107
|
// key change it keeps rendering the values it was created with even after the
|
|
@@ -763,72 +1119,107 @@ function defaultSignature(group) {
|
|
|
763
1119
|
(groupDefault.values ?? []).join(','),
|
|
764
1120
|
].join('~');
|
|
765
1121
|
}
|
|
766
|
-
//
|
|
767
|
-
// — this service holds them per filter GROUP (the propagation unit),
|
|
768
|
-
// them into per-component member-filter values for each data request,
|
|
769
|
-
// notifies the components whose groups changed so only they refetch.
|
|
1122
|
+
// Viewer filter state for the open report. Filter VALUES are never saved to
|
|
1123
|
+
// the report — this service holds them per filter GROUP (the propagation unit),
|
|
1124
|
+
// resolves them into per-component member-filter values for each data request,
|
|
1125
|
+
// and notifies the components whose groups changed so only they refetch.
|
|
1126
|
+
//
|
|
1127
|
+
// It is also the one source of the viewer state the report URL carries (group
|
|
1128
|
+
// values, the group-by grain, list sorts): urlSnapshot() says what differs from
|
|
1129
|
+
// the authored defaults, init() restores it, and urlChanges() tells the host's
|
|
1130
|
+
// single URL writer when to write.
|
|
770
1131
|
//
|
|
771
|
-
//
|
|
772
|
-
// filter menu (the `enabled` column is the authored default; a
|
|
773
|
-
// shouldn't write to the report).
|
|
1132
|
+
// Session-only, never in the URL: per-filter enable/disable toggles from a
|
|
1133
|
+
// component's filter menu (the `enabled` column is the authored default; a
|
|
1134
|
+
// viewer's toggle shouldn't write to the report).
|
|
774
1135
|
class ReportFilterStateService {
|
|
1136
|
+
// The open report's effective zone (resolveReportTimezone). Relative presets
|
|
1137
|
+
// resolve against today in it, and date cells render in it.
|
|
1138
|
+
timezone = signal('UTC');
|
|
775
1139
|
_values = new Map();
|
|
776
1140
|
_groups = new Map();
|
|
777
1141
|
_sessionDisabled = new Set();
|
|
778
|
-
//
|
|
1142
|
+
// Groups that have a default the viewer cleared. Without this a reload of the
|
|
1143
|
+
// report (a chat edit, a dialog) would find no value for the group and seed
|
|
1144
|
+
// its default straight back. A change to the stored default releases it.
|
|
1145
|
+
_cleared = new Set();
|
|
1146
|
+
// The report's group-by control: a single report-wide bucket override for all
|
|
779
1147
|
// time-series charts. Unset = each chart uses its authored granularity. Never
|
|
780
|
-
//
|
|
1148
|
+
// saved to the report (the URL carries it), and carried in the data request's
|
|
781
1149
|
// `state`, so it propagates through to the PDF/PowerPoint exports too. Named
|
|
782
1150
|
// for the `frequency` wire key it travels under, not for the control's label.
|
|
783
1151
|
_frequency = null;
|
|
784
1152
|
_frequencyChanged$ = new Subject();
|
|
1153
|
+
// Each list's viewer-chosen sort, by component id. Held here rather than only
|
|
1154
|
+
// in the list's FsList so it survives the list being recreated (a page switch,
|
|
1155
|
+
// a reload after an edit) and so every consumer of viewer state reads one place.
|
|
1156
|
+
_listSorts = new Map();
|
|
785
1157
|
// Emits the group id that changed; components listen filtered to their own
|
|
786
1158
|
// group ids. null = everything changed (re-init).
|
|
787
1159
|
_changed$ = new Subject();
|
|
1160
|
+
// Fires whenever anything the URL carries may have changed.
|
|
1161
|
+
_urlChanged$ = new Subject();
|
|
788
1162
|
// Seed groups + their defaults when a report loads. Existing session values
|
|
789
1163
|
// for still-existing groups survive a structure re-fetch (a chat edit
|
|
790
|
-
// shouldn't reset the viewer's filters)
|
|
791
|
-
//
|
|
792
|
-
//
|
|
793
|
-
//
|
|
794
|
-
//
|
|
795
|
-
|
|
1164
|
+
// shouldn't reset the viewer's filters), and so does a group the viewer
|
|
1165
|
+
// cleared — UNLESS the group's stored default itself changed, which only
|
|
1166
|
+
// happens when an operator saved a new default in report settings or the
|
|
1167
|
+
// agent set one. That is an explicit instruction to re-seed, so it wins over
|
|
1168
|
+
// the surviving session value; without it the bar keeps showing whatever it
|
|
1169
|
+
// was first seeded with until a full page reload.
|
|
1170
|
+
//
|
|
1171
|
+
// A change of the report's zone moves "today", so a value still carrying its
|
|
1172
|
+
// group default's preset name is resolved again in the new zone. A value the
|
|
1173
|
+
// viewer picked, or one carrying another preset name, is kept as it is.
|
|
1174
|
+
//
|
|
1175
|
+
// `restore` is the viewer state read from the URL. The host passes it only on
|
|
1176
|
+
// the first load of a report; a reload after a chat or dialog edit passes
|
|
1177
|
+
// null, so the in-memory state wins. Params that name a group, list or column
|
|
1178
|
+
// the report no longer has, or that do not decode, are ignored.
|
|
1179
|
+
init(report, restore = null) {
|
|
1180
|
+
const previousTimezone = this.timezone();
|
|
1181
|
+
// Set first: the defaults seeded below resolve their relative presets in it.
|
|
1182
|
+
this.timezone.set(resolveReportTimezone(report));
|
|
1183
|
+
const timezoneChanged = previousTimezone !== this.timezone();
|
|
796
1184
|
const previousGroups = this._groups;
|
|
797
1185
|
this._groups = new Map();
|
|
798
|
-
const validIds = new Set();
|
|
799
1186
|
for (const group of report.filterGroups ?? []) {
|
|
800
1187
|
this._groups.set(group.id, group);
|
|
801
|
-
|
|
802
|
-
const defaultChanged = !sameDefault(previousGroups.get(group.id)?.config?.default ?? null, group.config?.default ?? null);
|
|
803
|
-
if (!this._values.has(group.id) || defaultChanged) {
|
|
804
|
-
const defaultValue = this._defaultValue(group);
|
|
805
|
-
if (defaultValue) {
|
|
806
|
-
this._values.set(group.id, defaultValue);
|
|
807
|
-
}
|
|
808
|
-
else if (defaultChanged) {
|
|
809
|
-
// The default was cleared, so the value it seeded goes with it.
|
|
810
|
-
this._values.delete(group.id);
|
|
811
|
-
}
|
|
812
|
-
}
|
|
1188
|
+
this._seed(group, previousGroups.get(group.id) ?? null, timezoneChanged);
|
|
813
1189
|
}
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
this._values.delete(groupId);
|
|
817
|
-
}
|
|
1190
|
+
if (restore) {
|
|
1191
|
+
this._restore(report, restore);
|
|
818
1192
|
}
|
|
1193
|
+
this._prune(report);
|
|
819
1194
|
this._changed$.next(null);
|
|
1195
|
+
this._urlChanged$.next();
|
|
820
1196
|
}
|
|
821
1197
|
value(groupId) {
|
|
822
1198
|
return this._values.get(groupId);
|
|
823
1199
|
}
|
|
1200
|
+
// A bar's value for a group. A date range keeps the stored preset name only
|
|
1201
|
+
// while its days are still the stored days: the bars re-set every group they
|
|
1202
|
+
// show whenever any one of them changes, and an untouched rolling range must
|
|
1203
|
+
// keep its name rather than turn into two fixed dates. Any other range, a
|
|
1204
|
+
// picker shortcut included, is stored as the fixed dates it shows.
|
|
824
1205
|
setValue(groupId, value) {
|
|
825
1206
|
if (value === null) {
|
|
826
1207
|
this._values.delete(groupId);
|
|
1208
|
+
if (this._groups.get(groupId)?.config?.default) {
|
|
1209
|
+
this._cleared.add(groupId);
|
|
1210
|
+
}
|
|
827
1211
|
}
|
|
828
1212
|
else {
|
|
829
|
-
this._values.set(groupId, value);
|
|
1213
|
+
this._values.set(groupId, this._withPreset(this._values.get(groupId), value));
|
|
1214
|
+
this._cleared.delete(groupId);
|
|
830
1215
|
}
|
|
831
1216
|
this._changed$.next(groupId);
|
|
1217
|
+
this._urlChanged$.next();
|
|
1218
|
+
}
|
|
1219
|
+
// A relative preset name as today's range in the report's zone, or null for
|
|
1220
|
+
// a name it does not know. The URL codec decodes preset names through this.
|
|
1221
|
+
resolveRelative(name) {
|
|
1222
|
+
return this._relativeRange(name);
|
|
832
1223
|
}
|
|
833
1224
|
// The active group-by grain, or null when the viewer hasn't chosen one.
|
|
834
1225
|
frequency() {
|
|
@@ -837,6 +1228,7 @@ class ReportFilterStateService {
|
|
|
837
1228
|
setFrequency(frequency) {
|
|
838
1229
|
this._frequency = frequency;
|
|
839
1230
|
this._frequencyChanged$.next();
|
|
1231
|
+
this._urlChanged$.next();
|
|
840
1232
|
}
|
|
841
1233
|
// Fires whenever the group-by grain changes — every time-series chart listens
|
|
842
1234
|
// so it refetches and re-buckets (the override isn't scoped to any filter
|
|
@@ -844,6 +1236,54 @@ class ReportFilterStateService {
|
|
|
844
1236
|
frequencyChanges() {
|
|
845
1237
|
return this._frequencyChanged$.asObservable();
|
|
846
1238
|
}
|
|
1239
|
+
// The sort the viewer last fetched a list with, or null when it hasn't sorted
|
|
1240
|
+
// (the list then falls back to its authored sort).
|
|
1241
|
+
listSort(componentId) {
|
|
1242
|
+
return this._listSorts.get(componentId) ?? null;
|
|
1243
|
+
}
|
|
1244
|
+
// Records a list's sort. Deliberately emits no change: the list sets this from
|
|
1245
|
+
// the fetch that already ran with that sort, so a notification would only make
|
|
1246
|
+
// it refetch the same rows. Only the URL hears about it.
|
|
1247
|
+
setListSort(componentId, sort) {
|
|
1248
|
+
if (sort) {
|
|
1249
|
+
this._listSorts.set(componentId, sort);
|
|
1250
|
+
}
|
|
1251
|
+
else {
|
|
1252
|
+
this._listSorts.delete(componentId);
|
|
1253
|
+
}
|
|
1254
|
+
this._urlChanged$.next();
|
|
1255
|
+
}
|
|
1256
|
+
// Fires whenever the viewer state the URL carries may have changed: a group
|
|
1257
|
+
// value, the grain, a list sort, or a (re)load of the report.
|
|
1258
|
+
urlChanges() {
|
|
1259
|
+
return this._urlChanged$.asObservable();
|
|
1260
|
+
}
|
|
1261
|
+
// The viewer state the URL should carry for `report`: only what differs from
|
|
1262
|
+
// the authored defaults, so an untouched report has a bare URL and keeps
|
|
1263
|
+
// following its defaults when they change. A group the viewer cleared is
|
|
1264
|
+
// null. A date range is compared on its days against its default resolved
|
|
1265
|
+
// today, so a value that is the default under another preset name (thisYear
|
|
1266
|
+
// against a yearToDate default) is still the default.
|
|
1267
|
+
urlSnapshot(report) {
|
|
1268
|
+
const groups = new Map();
|
|
1269
|
+
for (const group of this._groups.values()) {
|
|
1270
|
+
const value = this._values.get(group.id);
|
|
1271
|
+
if (this._cleared.has(group.id)) {
|
|
1272
|
+
groups.set(group.id, null);
|
|
1273
|
+
}
|
|
1274
|
+
else if (value && !this._isDefault(group, value)) {
|
|
1275
|
+
groups.set(group.id, value);
|
|
1276
|
+
}
|
|
1277
|
+
}
|
|
1278
|
+
const listSorts = new Map();
|
|
1279
|
+
for (const component of this._listComponents(report)) {
|
|
1280
|
+
const sort = this._listSorts.get(component.id);
|
|
1281
|
+
if (sort && !sameSort(sort, component.config?.sort ?? null)) {
|
|
1282
|
+
listSorts.set(component.id, sort);
|
|
1283
|
+
}
|
|
1284
|
+
}
|
|
1285
|
+
return { groups, listSorts, frequency: this._frequency };
|
|
1286
|
+
}
|
|
847
1287
|
isFilterDisabled(filterId) {
|
|
848
1288
|
return this._sessionDisabled.has(filterId);
|
|
849
1289
|
}
|
|
@@ -873,28 +1313,28 @@ class ReportFilterStateService {
|
|
|
873
1313
|
continue;
|
|
874
1314
|
}
|
|
875
1315
|
const value = this._values.get(filter.filterGroupId);
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
const entry = { filterId: filter.id };
|
|
880
|
-
if (value.start) {
|
|
881
|
-
entry.start = dateBoundString(value.start);
|
|
882
|
-
}
|
|
883
|
-
if (value.end) {
|
|
884
|
-
entry.end = dateBoundString(value.end);
|
|
885
|
-
}
|
|
886
|
-
if (value.values?.length) {
|
|
887
|
-
entry.values = value.values;
|
|
888
|
-
}
|
|
889
|
-
if (value.value?.trim()) {
|
|
890
|
-
entry.value = value.value.trim();
|
|
891
|
-
}
|
|
892
|
-
if (entry.start || entry.end || entry.values || entry.value) {
|
|
893
|
-
resolved.push(entry);
|
|
1316
|
+
const fields = value ? this._resolvedFields(value) : null;
|
|
1317
|
+
if (fields) {
|
|
1318
|
+
resolved.push({ filterId: filter.id, ...fields });
|
|
894
1319
|
}
|
|
895
1320
|
}
|
|
896
1321
|
return resolved;
|
|
897
1322
|
}
|
|
1323
|
+
// What the viewer has set, one entry per filter group, for the report chat:
|
|
1324
|
+
// the agent reads it to know what is on screen. Group order, not the order
|
|
1325
|
+
// the values were set in, so the same state always reads the same. A group
|
|
1326
|
+
// with nothing in it is left out, and a saved default counts until the
|
|
1327
|
+
// viewer changes it. Per-filter toggles are a component's own business, so
|
|
1328
|
+
// they are not part of it.
|
|
1329
|
+
viewerFilters() {
|
|
1330
|
+
return [...this._values.entries()]
|
|
1331
|
+
.sort(([a], [b]) => a - b)
|
|
1332
|
+
.map(([groupId, value]) => {
|
|
1333
|
+
const fields = this._resolvedFields(value);
|
|
1334
|
+
return fields ? { groupId, ...fields } : null;
|
|
1335
|
+
})
|
|
1336
|
+
.filter((entry) => entry !== null);
|
|
1337
|
+
}
|
|
898
1338
|
// Every component's resolved values in one array — what a report-level export
|
|
899
1339
|
// carries so each file in the archive is filtered exactly as the screen is.
|
|
900
1340
|
// Filter ids are unique per component, so the backend picks each component's
|
|
@@ -906,10 +1346,148 @@ class ReportFilterStateService {
|
|
|
906
1346
|
.flatMap((page) => (page.components ?? [])
|
|
907
1347
|
.flatMap((component) => this.resolveForComponent(component)));
|
|
908
1348
|
}
|
|
1349
|
+
// A group value as the fields a request carries: date bounds as calendar
|
|
1350
|
+
// days, select values when any are picked, keyword text trimmed. Null when
|
|
1351
|
+
// all of them are empty. The preset name is URL state only, so it is left
|
|
1352
|
+
// behind.
|
|
1353
|
+
_resolvedFields(value) {
|
|
1354
|
+
const fields = {};
|
|
1355
|
+
if (value.start) {
|
|
1356
|
+
fields.start = dateBoundString(value.start);
|
|
1357
|
+
}
|
|
1358
|
+
if (value.end) {
|
|
1359
|
+
fields.end = dateBoundString(value.end);
|
|
1360
|
+
}
|
|
1361
|
+
if (value.values?.length) {
|
|
1362
|
+
fields.values = value.values;
|
|
1363
|
+
}
|
|
1364
|
+
if (value.value?.trim()) {
|
|
1365
|
+
fields.value = value.value.trim();
|
|
1366
|
+
}
|
|
1367
|
+
return fields.start || fields.end || fields.values || fields.value
|
|
1368
|
+
? fields
|
|
1369
|
+
: null;
|
|
1370
|
+
}
|
|
1371
|
+
// Seeds one group on (re)load; see init(). `previous` is the group as the
|
|
1372
|
+
// last load had it, or null when it is new.
|
|
1373
|
+
_seed(group, previous, timezoneChanged) {
|
|
1374
|
+
const defaultChanged = !sameDefault(previous?.config?.default ?? null, group.config?.default ?? null);
|
|
1375
|
+
if (defaultChanged) {
|
|
1376
|
+
this._cleared.delete(group.id);
|
|
1377
|
+
}
|
|
1378
|
+
if (this._cleared.has(group.id)) {
|
|
1379
|
+
return;
|
|
1380
|
+
}
|
|
1381
|
+
const value = this._values.get(group.id);
|
|
1382
|
+
const presetToResolve = timezoneChanged
|
|
1383
|
+
&& !!value?.preset
|
|
1384
|
+
&& value.preset === group.config?.default?.relative;
|
|
1385
|
+
if (value && !defaultChanged && !presetToResolve) {
|
|
1386
|
+
return;
|
|
1387
|
+
}
|
|
1388
|
+
const defaultValue = this._defaultValue(group);
|
|
1389
|
+
if (defaultValue) {
|
|
1390
|
+
this._values.set(group.id, defaultValue);
|
|
1391
|
+
}
|
|
1392
|
+
else if (defaultChanged) {
|
|
1393
|
+
// The default was cleared, so the value it seeded goes with it.
|
|
1394
|
+
this._values.delete(group.id);
|
|
1395
|
+
}
|
|
1396
|
+
}
|
|
1397
|
+
// Applies the viewer state read from the URL on top of the seeded defaults.
|
|
1398
|
+
_restore(report, restore) {
|
|
1399
|
+
restore.groups.forEach((raw, groupId) => {
|
|
1400
|
+
const group = this._groups.get(groupId);
|
|
1401
|
+
const value = group
|
|
1402
|
+
? decodeFilterValue(group.type, raw, (name) => this.resolveRelative(name))
|
|
1403
|
+
: undefined;
|
|
1404
|
+
if (value === null) {
|
|
1405
|
+
this._values.delete(groupId);
|
|
1406
|
+
if (group?.config?.default) {
|
|
1407
|
+
this._cleared.add(groupId);
|
|
1408
|
+
}
|
|
1409
|
+
}
|
|
1410
|
+
else if (value) {
|
|
1411
|
+
this._values.set(groupId, value);
|
|
1412
|
+
this._cleared.delete(groupId);
|
|
1413
|
+
}
|
|
1414
|
+
});
|
|
1415
|
+
const lists = new Map(this._listComponents(report).map((component) => [component.id, component]));
|
|
1416
|
+
restore.listSorts.forEach((raw, componentId) => {
|
|
1417
|
+
const component = lists.get(componentId);
|
|
1418
|
+
const sort = decodeSort(raw);
|
|
1419
|
+
if (component && sort && this._sortableColumns(component).includes(sort.column)) {
|
|
1420
|
+
this._listSorts.set(componentId, sort);
|
|
1421
|
+
}
|
|
1422
|
+
});
|
|
1423
|
+
// The URL is the whole truth on a first load: no grain in it means the
|
|
1424
|
+
// authored ones. The host still drops a grain the report does not offer.
|
|
1425
|
+
this._frequency = GRANULARITY_OPTIONS
|
|
1426
|
+
.find((option) => option.value === restore.groupBy)?.value ?? null;
|
|
1427
|
+
}
|
|
1428
|
+
// Drops state kept for groups and lists the report no longer has.
|
|
1429
|
+
_prune(report) {
|
|
1430
|
+
for (const groupId of [...this._values.keys(), ...this._cleared]) {
|
|
1431
|
+
if (!this._groups.has(groupId)) {
|
|
1432
|
+
this._values.delete(groupId);
|
|
1433
|
+
this._cleared.delete(groupId);
|
|
1434
|
+
}
|
|
1435
|
+
}
|
|
1436
|
+
const listIds = new Set(this._listComponents(report).map((component) => component.id));
|
|
1437
|
+
for (const componentId of [...this._listSorts.keys()]) {
|
|
1438
|
+
if (!listIds.has(componentId)) {
|
|
1439
|
+
this._listSorts.delete(componentId);
|
|
1440
|
+
}
|
|
1441
|
+
}
|
|
1442
|
+
}
|
|
1443
|
+
// The value a bar sets, keeping the stored preset name while the days are
|
|
1444
|
+
// still the stored days (see setValue).
|
|
1445
|
+
_withPreset(stored, value) {
|
|
1446
|
+
const fixed = { ...value };
|
|
1447
|
+
delete fixed.preset;
|
|
1448
|
+
return stored?.preset && sameDays(stored, fixed)
|
|
1449
|
+
? { ...fixed, preset: stored.preset }
|
|
1450
|
+
: fixed;
|
|
1451
|
+
}
|
|
1452
|
+
// Whether a value is the group's authored default, by type: a date range on
|
|
1453
|
+
// its days (the default resolved today), a select on its values in any
|
|
1454
|
+
// order, a keyword on its text.
|
|
1455
|
+
_isDefault(group, value) {
|
|
1456
|
+
const defaultValue = this._defaultValue(group);
|
|
1457
|
+
if (!defaultValue) {
|
|
1458
|
+
return false;
|
|
1459
|
+
}
|
|
1460
|
+
switch (group.type) {
|
|
1461
|
+
case 'dateRange':
|
|
1462
|
+
return sameDays(value, defaultValue);
|
|
1463
|
+
case 'select': {
|
|
1464
|
+
const values = (value.values ?? []).map(String).sort();
|
|
1465
|
+
const defaults = (defaultValue.values ?? []).map(String).sort();
|
|
1466
|
+
return values.length === defaults.length
|
|
1467
|
+
&& values.every((item, index) => item === defaults[index]);
|
|
1468
|
+
}
|
|
1469
|
+
default:
|
|
1470
|
+
return (value.value ?? '').trim() === (defaultValue.value ?? '').trim();
|
|
1471
|
+
}
|
|
1472
|
+
}
|
|
1473
|
+
_listComponents(report) {
|
|
1474
|
+
return (report.pages ?? [])
|
|
1475
|
+
.flatMap((page) => page.components ?? [])
|
|
1476
|
+
.filter((component) => component.type === 'list');
|
|
1477
|
+
}
|
|
1478
|
+
// The columns a list can be sorted by: every authored column, or a
|
|
1479
|
+
// cross-tab's row column only (its pivot columns come and go with the data).
|
|
1480
|
+
_sortableColumns(component) {
|
|
1481
|
+
const config = component.config;
|
|
1482
|
+
if (config?.pivotBy?.column) {
|
|
1483
|
+
return config.rowBy?.column ? [config.rowBy.column] : [];
|
|
1484
|
+
}
|
|
1485
|
+
return (config?.columns ?? []).map((column) => column.column);
|
|
1486
|
+
}
|
|
909
1487
|
// A group's authored default, mapped to a session value by type. Relative
|
|
910
1488
|
// date presets resolve against "now" each time the report opens, so a saved
|
|
911
|
-
// "last 12 months" stays relative
|
|
912
|
-
// text seed verbatim.
|
|
1489
|
+
// "last 12 months" stays relative and carries its preset name; a fixed
|
|
1490
|
+
// range, select values and keyword text seed verbatim.
|
|
913
1491
|
_defaultValue(group) {
|
|
914
1492
|
const groupDefault = group.config?.default;
|
|
915
1493
|
if (!groupDefault) {
|
|
@@ -917,7 +1495,7 @@ class ReportFilterStateService {
|
|
|
917
1495
|
}
|
|
918
1496
|
if (groupDefault.relative) {
|
|
919
1497
|
const range = this._relativeRange(groupDefault.relative);
|
|
920
|
-
return range ? { start: range.start, end: range.end } : null;
|
|
1498
|
+
return range ? { start: range.start, end: range.end, preset: groupDefault.relative } : null;
|
|
921
1499
|
}
|
|
922
1500
|
if (groupDefault.start || groupDefault.end) {
|
|
923
1501
|
return {
|
|
@@ -933,22 +1511,28 @@ class ReportFilterStateService {
|
|
|
933
1511
|
}
|
|
934
1512
|
return null;
|
|
935
1513
|
}
|
|
1514
|
+
// A relative preset as a calendar-day range. "Today" is the day in the
|
|
1515
|
+
// report's effective zone, not the browser's, so every viewer seeds the same
|
|
1516
|
+
// range. The server twin is ReportRelativeRangeEnum (framework/Report/Enum),
|
|
1517
|
+
// and ReportRelativeRangeEnumTest pins the table both sides must produce.
|
|
936
1518
|
_relativeRange(relative) {
|
|
937
|
-
const now =
|
|
1519
|
+
const now = getZonedNow(this.timezone());
|
|
938
1520
|
switch (relative) {
|
|
939
|
-
// A calendar week,
|
|
1521
|
+
// A calendar week, Monday to Sunday (REPORT_WEEK_STARTS_ON, the same week
|
|
1522
|
+
// the server's weekly buckets use), in whole days — not the rolling
|
|
940
1523
|
// seven days `last7Days` gives, which never lines up with the week a
|
|
941
1524
|
// partner has already been sent. `lastWeek` is the one a weekly report
|
|
942
|
-
// wants: it ends
|
|
943
|
-
// names has closed. `thisWeek` follows every other
|
|
944
|
-
// up to now, so it is deliberately a part-finished
|
|
1525
|
+
// wants: it ends on the previous week's Sunday rather than at `now`,
|
|
1526
|
+
// because the week it names has closed. `thisWeek` follows every other
|
|
1527
|
+
// preset here and runs up to now, so it is deliberately a part-finished
|
|
1528
|
+
// week (on a Sunday it is the whole Monday-to-Sunday week).
|
|
945
1529
|
case 'thisWeek':
|
|
946
|
-
return { start: startOfWeek(now, { weekStartsOn:
|
|
1530
|
+
return { start: startOfWeek(now, { weekStartsOn: REPORT_WEEK_STARTS_ON }), end: now };
|
|
947
1531
|
case 'lastWeek': {
|
|
948
1532
|
const previous = subWeeks(now, 1);
|
|
949
1533
|
return {
|
|
950
|
-
start: startOfWeek(previous, { weekStartsOn:
|
|
951
|
-
end: endOfWeek(previous, { weekStartsOn:
|
|
1534
|
+
start: startOfWeek(previous, { weekStartsOn: REPORT_WEEK_STARTS_ON }),
|
|
1535
|
+
end: endOfWeek(previous, { weekStartsOn: REPORT_WEEK_STARTS_ON }),
|
|
952
1536
|
};
|
|
953
1537
|
}
|
|
954
1538
|
case 'last7Days':
|
|
@@ -979,46 +1563,6 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImpo
|
|
|
979
1563
|
type: Injectable
|
|
980
1564
|
}] });
|
|
981
1565
|
|
|
982
|
-
// Measure-value formatting shared by the KPI renderer and the exports — the
|
|
983
|
-
// config's `format` (number | percent | currency) and `precision` mean the same
|
|
984
|
-
// thing on screen, in PowerPoint and in PDF.
|
|
985
|
-
// A bare `new Intl.NumberFormat()` defaults to maximumFractionDigits: 3, which
|
|
986
|
-
// is why an unconfigured percentage reads 38.298%. When the measure names a
|
|
987
|
-
// precision it is pinned as BOTH the minimum and the maximum, so a column of
|
|
988
|
-
// rates lines up instead of each value showing however many decimals it happens
|
|
989
|
-
// to need. Undefined leaves the locale default alone, so a report authored
|
|
990
|
-
// before precision existed renders exactly as it did.
|
|
991
|
-
function getFractionDigits(precision) {
|
|
992
|
-
return Number.isFinite(precision)
|
|
993
|
-
? { minimumFractionDigits: precision, maximumFractionDigits: precision }
|
|
994
|
-
: {};
|
|
995
|
-
}
|
|
996
|
-
function formatMeasureValue(value, format, precision) {
|
|
997
|
-
if (value === null || value === undefined || value === '') {
|
|
998
|
-
return '—';
|
|
999
|
-
}
|
|
1000
|
-
const numeric = Number(value);
|
|
1001
|
-
if (!Number.isFinite(numeric)) {
|
|
1002
|
-
return String(value);
|
|
1003
|
-
}
|
|
1004
|
-
const fractionDigits = getFractionDigits(precision);
|
|
1005
|
-
switch (format) {
|
|
1006
|
-
case 'percent':
|
|
1007
|
-
return `${new Intl.NumberFormat(undefined, fractionDigits).format(numeric)}%`;
|
|
1008
|
-
case 'currency':
|
|
1009
|
-
return new Intl.NumberFormat(undefined, {
|
|
1010
|
-
style: 'currency',
|
|
1011
|
-
currency: 'USD',
|
|
1012
|
-
// Whole dollars unless the measure asks for decimals — cents were
|
|
1013
|
-
// unrepresentable before, because this was hardcoded to 0.
|
|
1014
|
-
maximumFractionDigits: 0,
|
|
1015
|
-
...fractionDigits,
|
|
1016
|
-
}).format(numeric);
|
|
1017
|
-
default:
|
|
1018
|
-
return new Intl.NumberFormat(undefined, fractionDigits).format(numeric);
|
|
1019
|
-
}
|
|
1020
|
-
}
|
|
1021
|
-
|
|
1022
1566
|
// Text blocks — shared by the canvas and both exports, so a block reads the
|
|
1023
1567
|
// same on screen, in the deck and in the PDF.
|
|
1024
1568
|
//
|
|
@@ -1258,13 +1802,37 @@ const REPORT_WATERFALL_COLORS = {
|
|
|
1258
1802
|
total: REPORT_CHART_COLORS[9],
|
|
1259
1803
|
};
|
|
1260
1804
|
|
|
1805
|
+
// The report charts' typeface and axis-label size. The ECharts theme draws with
|
|
1806
|
+
// them, and the option builder measures axis labels with them, so the room it
|
|
1807
|
+
// leaves around the labels matches what is drawn.
|
|
1808
|
+
const REPORT_CHART_FONT_FAMILY = 'Roboto, "Helvetica Neue", sans-serif';
|
|
1809
|
+
const REPORT_AXIS_LABEL_FONT_SIZE = 11;
|
|
1810
|
+
|
|
1811
|
+
// Room for the category axis's title. ECharts 5's containLabel keeps the TICK
|
|
1812
|
+
// labels inside the grid but not the axis name, so without this a vertical
|
|
1813
|
+
// chart's title runs off the bottom edge or under a bottom legend.
|
|
1814
|
+
const AXIS_NAME_ROOM = 20;
|
|
1815
|
+
// ECharts' own default gap between the axis line and its tick labels.
|
|
1816
|
+
const AXIS_LABEL_MARGIN = 8;
|
|
1817
|
+
// Clearance between the longest tick label and a horizontal chart's rotated
|
|
1818
|
+
// title, so the two never touch.
|
|
1819
|
+
const AXIS_NAME_CLEARANCE = 6;
|
|
1820
|
+
// A horizontal chart's category labels are cut short with an ellipsis past
|
|
1821
|
+
// this width (the tooltip still shows them in full), so one long label cannot
|
|
1822
|
+
// squeeze the plot away.
|
|
1823
|
+
const CATEGORY_LABEL_MAX_WIDTH = 160;
|
|
1824
|
+
const LABEL_ELLIPSIS = '…';
|
|
1825
|
+
// The font the theme draws axis labels in, as a canvas font string. Labels are
|
|
1826
|
+
// measured in it the way ECharts measures them for containLabel, so a
|
|
1827
|
+
// horizontal chart's title lands exactly beyond the widest label.
|
|
1828
|
+
const AXIS_LABEL_FONT = `${REPORT_AXIS_LABEL_FONT_SIZE}px ${REPORT_CHART_FONT_FAMILY}`;
|
|
1261
1829
|
// colorBy applies only to single-dimension bar charts, and only when splitBy
|
|
1262
1830
|
// isn't already driving multiple series: it colors each bar by its category and
|
|
1263
1831
|
// shows a legend (one series per category, overlapped to full width).
|
|
1264
1832
|
//
|
|
1265
1833
|
// It is the DEFAULT for a categorical bar chart rather than something the config
|
|
1266
|
-
// has to ask for. A bar chart whose x-axis is a category —
|
|
1267
|
-
//
|
|
1834
|
+
// has to ask for. A bar chart whose x-axis is a category — regions, statuses,
|
|
1835
|
+
// stores — is comparing those categories to each other, and one flat color with
|
|
1268
1836
|
// no legend is the wrong reading of it in essentially every case. Requiring
|
|
1269
1837
|
// colorBy to opt in meant every such chart rendered monochrome unless whoever
|
|
1270
1838
|
// wrote the config remembered an optional property, which is a default set the
|
|
@@ -1399,11 +1967,11 @@ function buildAxisOption(component, data, chartType) {
|
|
|
1399
1967
|
// a combo each type stacks only with its own kind, so a line of a rate never
|
|
1400
1968
|
// sits on top of the bars' total.
|
|
1401
1969
|
const series = extracted.series.map((extractedSeries, index) => buildSeries(extractedSeries.name, extractedSeries.values, types[index], config.stacked ? (combo ? `total-${types[index]}` : 'total') : undefined, colorBy && index === 0, combo));
|
|
1402
|
-
const categoryAxis = categoryAxisOption(config, data, extracted.categories);
|
|
1970
|
+
const categoryAxis = categoryAxisOption(config, data, extracted.categories, horizontal);
|
|
1403
1971
|
const valueAxis = { type: 'value' };
|
|
1404
1972
|
const legendPosition = resolveLegendPosition(config, series.length, colorBy);
|
|
1405
1973
|
return {
|
|
1406
|
-
grid: axisGrid(legendPosition),
|
|
1974
|
+
grid: axisGrid(legendPosition, categoryTitleSide(config, horizontal)),
|
|
1407
1975
|
legend: legendOption(legendPosition),
|
|
1408
1976
|
// colorBy charts overlap N mostly-null series, so an axis tooltip would list
|
|
1409
1977
|
// every category; an item tooltip shows just the hovered bar.
|
|
@@ -1415,28 +1983,111 @@ function buildAxisOption(component, data, chartType) {
|
|
|
1415
1983
|
}
|
|
1416
1984
|
// The category axis of an axis chart or a waterfall: the categories in row
|
|
1417
1985
|
// order, labeled as periods when the rows were bucketed by time.
|
|
1418
|
-
|
|
1986
|
+
//
|
|
1987
|
+
// Horizontal bars read top-down: ECharts draws category 0 at the bottom, so the
|
|
1988
|
+
// axis is inverted to put the first row (the largest of a top-N, a waterfall's
|
|
1989
|
+
// first step) at the top. The rotated title sits just beyond the longest label
|
|
1990
|
+
// rather than at a fixed gap that would put it on top of the labels.
|
|
1991
|
+
function categoryAxisOption(config, data, categories, horizontal) {
|
|
1419
1992
|
const granularity = bucketGranularity(data);
|
|
1420
|
-
|
|
1993
|
+
const periodLabel = periodAxisLabel(granularity, categories);
|
|
1994
|
+
const axis = {
|
|
1421
1995
|
type: 'category',
|
|
1422
1996
|
data: categories,
|
|
1997
|
+
inverse: horizontal,
|
|
1423
1998
|
name: config.xAxis?.label,
|
|
1424
1999
|
nameLocation: 'middle',
|
|
1425
|
-
nameGap: granularity && spansMultipleYears(categories) ? 40 : 28,
|
|
1426
|
-
...periodAxisLabel(granularity, categories),
|
|
1427
2000
|
};
|
|
2001
|
+
if (!horizontal) {
|
|
2002
|
+
return {
|
|
2003
|
+
...axis,
|
|
2004
|
+
nameGap: granularity && spansMultipleYears(categories) ? 40 : 28,
|
|
2005
|
+
...periodLabel,
|
|
2006
|
+
};
|
|
2007
|
+
}
|
|
2008
|
+
// A period label ('May', 'Q2') is always short, so only a plain category is
|
|
2009
|
+
// cut short. The labels are cut here rather than by ECharts' own truncation,
|
|
2010
|
+
// so the title's gap is measured on exactly the text the axis draws.
|
|
2011
|
+
const shown = granularity
|
|
2012
|
+
? null
|
|
2013
|
+
: new Map(categories.map((category) => [category, truncateAxisLabel(category)]));
|
|
2014
|
+
const labels = shown ? [...shown.values()] : displayedCategoryLabels(categories, granularity);
|
|
2015
|
+
return {
|
|
2016
|
+
...axis,
|
|
2017
|
+
nameGap: widestAxisLabel(labels) + AXIS_LABEL_MARGIN + AXIS_NAME_CLEARANCE,
|
|
2018
|
+
...(shown ? { axisLabel: { formatter: (value) => shown.get(value) ?? value } } : periodLabel),
|
|
2019
|
+
};
|
|
2020
|
+
}
|
|
2021
|
+
// The category axis's title side, or null when the axis has no title to make
|
|
2022
|
+
// room for.
|
|
2023
|
+
function categoryTitleSide(config, horizontal) {
|
|
2024
|
+
if (!config.xAxis?.label) {
|
|
2025
|
+
return null;
|
|
2026
|
+
}
|
|
2027
|
+
return horizontal ? 'left' : 'bottom';
|
|
2028
|
+
}
|
|
2029
|
+
// The tick labels as the axis shows them: a bucket's primary period label
|
|
2030
|
+
// ('May', 'Q2'), otherwise the category itself. The year a multi-year axis
|
|
2031
|
+
// prints under a period is left out, as it is never the wider line.
|
|
2032
|
+
function displayedCategoryLabels(categories, granularity) {
|
|
2033
|
+
return granularity
|
|
2034
|
+
? categories.map((category) => periodLabelParts(category, granularity)?.primary ?? category)
|
|
2035
|
+
: categories;
|
|
2036
|
+
}
|
|
2037
|
+
// The pixel width of the widest label. A label that breaks over several lines
|
|
2038
|
+
// is as wide as its longest line.
|
|
2039
|
+
function widestAxisLabel(labels) {
|
|
2040
|
+
return labels.reduce((widest, label) => label.split('\n').reduce((lineWidest, line) => Math.max(lineWidest, axisLabelWidth(line)), widest), 0);
|
|
2041
|
+
}
|
|
2042
|
+
// A category label cut to CATEGORY_LABEL_MAX_WIDTH, line by line: the longest
|
|
2043
|
+
// start of each line that still fits with the ellipsis after it.
|
|
2044
|
+
function truncateAxisLabel(label) {
|
|
2045
|
+
return label.split('\n').map((line) => {
|
|
2046
|
+
if (axisLabelWidth(line) <= CATEGORY_LABEL_MAX_WIDTH) {
|
|
2047
|
+
return line;
|
|
2048
|
+
}
|
|
2049
|
+
// Code points, so an emoji or other surrogate pair is never split.
|
|
2050
|
+
const characters = Array.from(line);
|
|
2051
|
+
const cut = (length) => `${characters.slice(0, length).join('').trimEnd()}${LABEL_ELLIPSIS}`;
|
|
2052
|
+
let fits = 0;
|
|
2053
|
+
let tooLong = characters.length;
|
|
2054
|
+
while (tooLong - fits > 1) {
|
|
2055
|
+
const length = Math.floor((fits + tooLong) / 2);
|
|
2056
|
+
if (axisLabelWidth(cut(length)) <= CATEGORY_LABEL_MAX_WIDTH) {
|
|
2057
|
+
fits = length;
|
|
2058
|
+
}
|
|
2059
|
+
else {
|
|
2060
|
+
tooLong = length;
|
|
2061
|
+
}
|
|
2062
|
+
}
|
|
2063
|
+
return cut(fits);
|
|
2064
|
+
}).join('\n');
|
|
2065
|
+
}
|
|
2066
|
+
// One line's width in the axis-label font, measured on a canvas the way
|
|
2067
|
+
// ECharts measures it for containLabel. Outside a browser there is no canvas
|
|
2068
|
+
// to measure with, so the line counts as zero width.
|
|
2069
|
+
let axisLabelContext = null;
|
|
2070
|
+
function axisLabelWidth(line) {
|
|
2071
|
+
if (!axisLabelContext && typeof document !== 'undefined') {
|
|
2072
|
+
axisLabelContext = document.createElement('canvas').getContext('2d');
|
|
2073
|
+
}
|
|
2074
|
+
if (!axisLabelContext) {
|
|
2075
|
+
return 0;
|
|
2076
|
+
}
|
|
2077
|
+
axisLabelContext.font = AXIS_LABEL_FONT;
|
|
2078
|
+
return axisLabelContext.measureText(line).width;
|
|
1428
2079
|
}
|
|
1429
2080
|
function buildWaterfallOption(component, data) {
|
|
1430
2081
|
const config = component.config;
|
|
1431
2082
|
const waterfall = extractWaterfall(component, data);
|
|
1432
|
-
const categoryAxis = categoryAxisOption(config, data, waterfall.steps.map((step) => step.category));
|
|
1433
|
-
const valueAxis = { type: 'value' };
|
|
1434
2083
|
const horizontal = config.orientation === 'horizontal';
|
|
2084
|
+
const categoryAxis = categoryAxisOption(config, data, waterfall.steps.map((step) => step.category), horizontal);
|
|
2085
|
+
const valueAxis = { type: 'value' };
|
|
1435
2086
|
// One visible series, so the legend is hidden unless the config pins it.
|
|
1436
2087
|
const legendPosition = resolveLegendPosition(config, 1);
|
|
1437
2088
|
const granularity = bucketGranularity(data);
|
|
1438
2089
|
return {
|
|
1439
|
-
grid: axisGrid(legendPosition),
|
|
2090
|
+
grid: axisGrid(legendPosition, categoryTitleSide(config, horizontal)),
|
|
1440
2091
|
legend: { ...legendOption(legendPosition), data: [waterfall.name] },
|
|
1441
2092
|
// Per bar: the invisible base is silent, so only a step answers the hover.
|
|
1442
2093
|
tooltip: {
|
|
@@ -1524,9 +2175,23 @@ function legendOption(position) {
|
|
|
1524
2175
|
return { ...base, top: 0 };
|
|
1525
2176
|
}
|
|
1526
2177
|
}
|
|
1527
|
-
//
|
|
1528
|
-
//
|
|
1529
|
-
|
|
2178
|
+
// The plot's grid: room on the legend's side so it never overlaps the
|
|
2179
|
+
// bars/lines, plus room for the category axis's title on its side, which
|
|
2180
|
+
// containLabel does not reserve (it covers the tick labels only).
|
|
2181
|
+
function axisGrid(position, titleSide) {
|
|
2182
|
+
const grid = legendGrid(position);
|
|
2183
|
+
switch (titleSide) {
|
|
2184
|
+
case 'bottom':
|
|
2185
|
+
return { ...grid, bottom: grid.bottom + AXIS_NAME_ROOM };
|
|
2186
|
+
case 'left':
|
|
2187
|
+
return { ...grid, left: grid.left + AXIS_NAME_ROOM };
|
|
2188
|
+
default:
|
|
2189
|
+
return grid;
|
|
2190
|
+
}
|
|
2191
|
+
}
|
|
2192
|
+
// Plot-edge room on whichever side the legend occupies; containLabel keeps the
|
|
2193
|
+
// tick labels inside what is left.
|
|
2194
|
+
function legendGrid(position) {
|
|
1530
2195
|
const base = { left: 8, right: 16, top: 8, bottom: 8, containLabel: true };
|
|
1531
2196
|
switch (position) {
|
|
1532
2197
|
case 'bottom':
|
|
@@ -1769,15 +2434,33 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImpo
|
|
|
1769
2434
|
type: Input
|
|
1770
2435
|
}] } });
|
|
1771
2436
|
|
|
1772
|
-
// A
|
|
1773
|
-
//
|
|
1774
|
-
//
|
|
1775
|
-
|
|
2437
|
+
// A report row's date cell as the calendar day in the report's zone. Use it in
|
|
2438
|
+
// place of Angular's DatePipe, which reads a 'YYYY-MM-DD HH:MM:SS' value as the
|
|
2439
|
+
// browser's local wall time instead of the UTC instant it is.
|
|
2440
|
+
class ReportDatePipe {
|
|
2441
|
+
transform(value, timezone) {
|
|
2442
|
+
return formatReportDate(value, timezone);
|
|
2443
|
+
}
|
|
2444
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: ReportDatePipe, deps: [], target: i0.ɵɵFactoryTarget.Pipe });
|
|
2445
|
+
static ɵpipe = i0.ɵɵngDeclarePipe({ minVersion: "14.0.0", version: "18.2.14", ngImport: i0, type: ReportDatePipe, isStandalone: true, name: "fsReportDate" });
|
|
2446
|
+
}
|
|
2447
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: ReportDatePipe, decorators: [{
|
|
2448
|
+
type: Pipe,
|
|
2449
|
+
args: [{
|
|
2450
|
+
name: 'fsReportDate',
|
|
2451
|
+
standalone: true,
|
|
2452
|
+
}]
|
|
2453
|
+
}] });
|
|
2454
|
+
|
|
2455
|
+
// A list component: an FsList rendered from the component's config. It has no
|
|
2456
|
+
// filter bar of its own: its filters are group-keyed like every other
|
|
2457
|
+
// component's, so component-level groups render in the component header strip
|
|
2458
|
+
// (ReportComponentComponent) and report-level groups in the report bar, and
|
|
2459
|
+
// every request takes its values from the shared filter-state service.
|
|
1776
2460
|
// Paging and sorting are pushed into the SQL wrap server-side.
|
|
1777
2461
|
class ComponentListComponent {
|
|
1778
2462
|
reportId;
|
|
1779
2463
|
component;
|
|
1780
|
-
groups;
|
|
1781
2464
|
listComponent;
|
|
1782
2465
|
listConfig;
|
|
1783
2466
|
// A plain list's columns are authored and fixed at init. A cross-tab's are
|
|
@@ -1796,6 +2479,10 @@ class ComponentListComponent {
|
|
|
1796
2479
|
// (it only renders what _load() fetched, which lists skip), so the warning
|
|
1797
2480
|
// belongs here.
|
|
1798
2481
|
truncated = signal(false);
|
|
2482
|
+
// The report's effective zone, which date cells render in. Injected right
|
|
2483
|
+
// here rather than read off _filterState, which is declared further down and
|
|
2484
|
+
// is still undefined while this initializer runs.
|
|
2485
|
+
timezone = inject(ReportFilterStateService).timezone;
|
|
1799
2486
|
_reportData = inject(ReportData);
|
|
1800
2487
|
_filterState = inject(ReportFilterStateService);
|
|
1801
2488
|
_destroyRef = inject(DestroyRef);
|
|
@@ -1804,23 +2491,28 @@ class ComponentListComponent {
|
|
|
1804
2491
|
this.pivoted.set(!!config.pivotBy?.column);
|
|
1805
2492
|
this.columns.set(config.columns ?? []);
|
|
1806
2493
|
this._initListConfig(config);
|
|
1807
|
-
//
|
|
2494
|
+
// A change to any group this list's filters belong to (from the report bar
|
|
2495
|
+
// or the component header strip) re-runs the list.
|
|
1808
2496
|
this._filterState.changesFor(this.component)
|
|
1809
2497
|
.pipe(takeUntilDestroyed(this._destroyRef))
|
|
1810
2498
|
.subscribe(() => this.listComponent?.reload());
|
|
1811
2499
|
}
|
|
1812
2500
|
_initListConfig(config) {
|
|
2501
|
+
// The viewer's last sort wins over the authored one, so a list recreated
|
|
2502
|
+
// in the same session (page switch, reload after an edit) keeps it.
|
|
2503
|
+
const sort = this._filterState.listSort(this.component.id) ?? config.sort;
|
|
1813
2504
|
this.listConfig = {
|
|
1814
2505
|
// Inside a report component the list is a clean data pane — no status
|
|
1815
2506
|
// bar, no reload button (filter changes already refetch).
|
|
1816
2507
|
status: false,
|
|
1817
2508
|
reload: false,
|
|
1818
|
-
//
|
|
1819
|
-
//
|
|
2509
|
+
// FsList never touches the URL or persists sort/paging state. The sort
|
|
2510
|
+
// reaches the URL through ReportFilterStateService (setListSort), and
|
|
2511
|
+
// paging stays out of it.
|
|
1820
2512
|
queryParam: false,
|
|
1821
2513
|
persist: false,
|
|
1822
|
-
sort:
|
|
1823
|
-
? { value:
|
|
2514
|
+
sort: sort
|
|
2515
|
+
? { value: sort.column, direction: sort.direction ?? 'asc' }
|
|
1824
2516
|
: undefined,
|
|
1825
2517
|
// Load-more: the list keeps a fixed, scrollable height and appends the
|
|
1826
2518
|
// next page in place instead of swapping pages. The button is pinned to
|
|
@@ -1841,50 +2533,9 @@ class ComponentListComponent {
|
|
|
1841
2533
|
label: 'Load more',
|
|
1842
2534
|
buttonType: 'stroked',
|
|
1843
2535
|
},
|
|
1844
|
-
filters: this._ownFilters().map((filter) => this._filterItem(filter)),
|
|
1845
2536
|
fetch: (query) => this._fetch(query),
|
|
1846
2537
|
};
|
|
1847
2538
|
}
|
|
1848
|
-
// The filters this list renders in its own bar: its report_filters whose
|
|
1849
|
-
// group is component-level. Report-level groups render in the report bar.
|
|
1850
|
-
_ownFilters() {
|
|
1851
|
-
return (this.component.filters ?? [])
|
|
1852
|
-
.filter((filter) => {
|
|
1853
|
-
const group = this.groups?.get(filter.filterGroupId);
|
|
1854
|
-
return filter.enabled && (!group || group.level === 'component' || group.level === 'both');
|
|
1855
|
-
});
|
|
1856
|
-
}
|
|
1857
|
-
_filterItem(filter) {
|
|
1858
|
-
const group = this.groups?.get(filter.filterGroupId);
|
|
1859
|
-
const name = `f${filter.id}`;
|
|
1860
|
-
const label = filter.label || group?.label || filter.filterColumn;
|
|
1861
|
-
switch (group?.type) {
|
|
1862
|
-
case 'dateRange':
|
|
1863
|
-
return {
|
|
1864
|
-
name,
|
|
1865
|
-
type: ItemType.DateRange,
|
|
1866
|
-
label: [`${label} From`, `${label} To`],
|
|
1867
|
-
};
|
|
1868
|
-
case 'select':
|
|
1869
|
-
return {
|
|
1870
|
-
name,
|
|
1871
|
-
type: ItemType.AutoCompleteChips,
|
|
1872
|
-
label,
|
|
1873
|
-
// The server sends { value, label }: the filter matches on the value
|
|
1874
|
-
// (an entity's stable id) while the viewer reads the label.
|
|
1875
|
-
values: () => this._reportData.filterOptions(this.reportId, filter.id)
|
|
1876
|
-
.pipe(map$1((options) => (options ?? [])
|
|
1877
|
-
.map((option) => ({ name: option.label, value: option.value })))),
|
|
1878
|
-
};
|
|
1879
|
-
case 'keyword':
|
|
1880
|
-
default:
|
|
1881
|
-
return {
|
|
1882
|
-
name,
|
|
1883
|
-
type: ItemType.Keyword,
|
|
1884
|
-
label,
|
|
1885
|
-
};
|
|
1886
|
-
}
|
|
1887
|
-
}
|
|
1888
2539
|
_fetch(query) {
|
|
1889
2540
|
// Many/load-more sends `offset` + `limit` (not `page`); derive the page so
|
|
1890
2541
|
// the server-side wrap keeps working unchanged. Page strategy still sends
|
|
@@ -1901,17 +2552,12 @@ class ComponentListComponent {
|
|
|
1901
2552
|
const [column, direction] = order.split(',');
|
|
1902
2553
|
state.sort = { column, direction: direction === 'desc' ? 'desc' : 'asc' };
|
|
1903
2554
|
}
|
|
1904
|
-
|
|
1905
|
-
//
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
resolved.set(value.filterId, value);
|
|
1909
|
-
}
|
|
1910
|
-
for (const value of this._barValues(query)) {
|
|
1911
|
-
resolved.set(value.filterId, value);
|
|
1912
|
-
}
|
|
2555
|
+
this._filterState.setListSort(this.component.id, state.sort ?? null);
|
|
2556
|
+
// One source for filter values, the same one charts, KPIs and the exports
|
|
2557
|
+
// read, so the list is filtered exactly as its header strip shows.
|
|
2558
|
+
const filters = this._filterState.resolveForComponent(this.component);
|
|
1913
2559
|
return this._reportData
|
|
1914
|
-
.componentData(this.reportId, this.component.id,
|
|
2560
|
+
.componentData(this.reportId, this.component.id, filters, state)
|
|
1915
2561
|
.pipe(map$1((data) => {
|
|
1916
2562
|
// A cross-tab's headers come back with the rows: the row-key column
|
|
1917
2563
|
// first, then one per pivot value. Re-read them on every fetch, since
|
|
@@ -1959,54 +2605,18 @@ class ComponentListComponent {
|
|
|
1959
2605
|
.map((match) => match[1]));
|
|
1960
2606
|
return years.size > 1;
|
|
1961
2607
|
}
|
|
1962
|
-
// The list's own FsFilter values, read back out of the fetch query by the
|
|
1963
|
-
// `f<filterId>` naming convention.
|
|
1964
|
-
_barValues(query) {
|
|
1965
|
-
const values = [];
|
|
1966
|
-
for (const filter of this._ownFilters()) {
|
|
1967
|
-
const group = this.groups?.get(filter.filterGroupId);
|
|
1968
|
-
const name = `f${filter.id}`;
|
|
1969
|
-
if (group?.type === 'dateRange') {
|
|
1970
|
-
const from = dateBoundString(query[`${name}From`]);
|
|
1971
|
-
const to = dateBoundString(query[`${name}To`]);
|
|
1972
|
-
if (from || to) {
|
|
1973
|
-
values.push({ filterId: filter.id, start: from, end: to });
|
|
1974
|
-
}
|
|
1975
|
-
}
|
|
1976
|
-
else if (group?.type === 'select') {
|
|
1977
|
-
// fs-filter joins a multi-select into one "a,b" string; split it back
|
|
1978
|
-
// so each value binds separately (else `col IN ('a,b')` matches nothing).
|
|
1979
|
-
const selected = query[name];
|
|
1980
|
-
const selectedValues = Array.isArray(selected)
|
|
1981
|
-
? selected
|
|
1982
|
-
: (selected ? String(selected).split(',') : []);
|
|
1983
|
-
if (selectedValues.length) {
|
|
1984
|
-
values.push({ filterId: filter.id, values: selectedValues });
|
|
1985
|
-
}
|
|
1986
|
-
}
|
|
1987
|
-
else {
|
|
1988
|
-
const keyword = String(query[name] ?? '').trim();
|
|
1989
|
-
if (keyword) {
|
|
1990
|
-
values.push({ filterId: filter.id, value: keyword });
|
|
1991
|
-
}
|
|
1992
|
-
}
|
|
1993
|
-
}
|
|
1994
|
-
return values;
|
|
1995
|
-
}
|
|
1996
2608
|
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: ComponentListComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
1997
|
-
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "18.2.14", type: ComponentListComponent, isStandalone: true, selector: "app-report-component-list", inputs: { reportId: "reportId", component: "component"
|
|
2609
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "18.2.14", type: ComponentListComponent, isStandalone: true, selector: "app-report-component-list", inputs: { reportId: "reportId", component: "component" }, viewQueries: [{ propertyName: "listComponent", first: true, predicate: FsListComponent, descendants: true }], ngImport: i0, template: "@if (truncated()) {\n <div class=\"pivot-truncated\">\n This grid was built from a partial result, so some columns or values may be missing. Narrow it with a filter.\n </div>\n}\n\n<fs-list [config]=\"listConfig\">\n @for (column of columns(); track column.column) {\n <fs-list-column\n [name]=\"column.column\"\n [title]=\"column.label || column.column\"\n [sortable]=\"!pivoted() || $first\">\n <ng-template\n fs-list-cell\n let-row=\"row\">\n @if (column.format === 'date') {\n {{ row[column.column] | fsReportDate: timezone() }}\n } @else {\n {{ row[column.column] }}\n }\n </ng-template>\n </fs-list-column>\n }\n</fs-list>\n", styles: [":host{display:block;width:100%;height:100%;min-height:0}:host ::ng-deep fs-list{display:flex;flex-direction:column;height:100%;min-height:0}:host ::ng-deep fs-list .fs-list-container{display:flex;flex-direction:column;flex:1 1 auto;min-height:0;height:100%}:host ::ng-deep fs-list .fs-list-header-container{flex:0 0 auto}:host ::ng-deep fs-list .fs-list-container .fs-list-table-container{flex:1 1 auto;min-height:0;overflow-y:auto!important}:host ::ng-deep fs-list .fs-list-table thead th{position:sticky;top:0;z-index:1;background-color:#fff}:host ::ng-deep fs-list fs-list-pagination{flex:0 0 auto}:host ::ng-deep fs-list .fs-list-load-more{position:sticky;bottom:0;background-color:#fff}:host ::ng-deep fs-list .fs-list-load-more button{margin-top:0}.pivot-truncated{flex:0 0 auto;padding:8px 12px;margin-bottom:8px;border-radius:4px;background-color:#fff4e5;color:#663c00}\n"], dependencies: [{ kind: "ngmodule", type: FsListModule }, { kind: "component", type: i1.FsListComponent, selector: "fs-list", inputs: ["config", "loaderLines", "cellRowType"], outputs: ["filtersReady"] }, { kind: "directive", type: i1.FsListColumnDirective, selector: "fs-list-column", inputs: ["show", "title", "name", "customizable", "sortable", "sortableDefault", "sortableDirection", "direction", "align", "width", "class"] }, { kind: "directive", type: i1.FsListCellDirective, selector: "[fs-list-cell],[fsListCell]", inputs: ["colspan", "align", "class", "fsListCell", "configTyping"] }, { kind: "pipe", type: ReportDatePipe, name: "fsReportDate" }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
1998
2610
|
}
|
|
1999
2611
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: ComponentListComponent, decorators: [{
|
|
2000
2612
|
type: Component,
|
|
2001
2613
|
args: [{ selector: 'app-report-component-list', changeDetection: ChangeDetectionStrategy.OnPush, standalone: true, imports: [
|
|
2002
|
-
DatePipe,
|
|
2003
2614
|
FsListModule,
|
|
2004
|
-
|
|
2615
|
+
ReportDatePipe,
|
|
2616
|
+
], template: "@if (truncated()) {\n <div class=\"pivot-truncated\">\n This grid was built from a partial result, so some columns or values may be missing. Narrow it with a filter.\n </div>\n}\n\n<fs-list [config]=\"listConfig\">\n @for (column of columns(); track column.column) {\n <fs-list-column\n [name]=\"column.column\"\n [title]=\"column.label || column.column\"\n [sortable]=\"!pivoted() || $first\">\n <ng-template\n fs-list-cell\n let-row=\"row\">\n @if (column.format === 'date') {\n {{ row[column.column] | fsReportDate: timezone() }}\n } @else {\n {{ row[column.column] }}\n }\n </ng-template>\n </fs-list-column>\n }\n</fs-list>\n", styles: [":host{display:block;width:100%;height:100%;min-height:0}:host ::ng-deep fs-list{display:flex;flex-direction:column;height:100%;min-height:0}:host ::ng-deep fs-list .fs-list-container{display:flex;flex-direction:column;flex:1 1 auto;min-height:0;height:100%}:host ::ng-deep fs-list .fs-list-header-container{flex:0 0 auto}:host ::ng-deep fs-list .fs-list-container .fs-list-table-container{flex:1 1 auto;min-height:0;overflow-y:auto!important}:host ::ng-deep fs-list .fs-list-table thead th{position:sticky;top:0;z-index:1;background-color:#fff}:host ::ng-deep fs-list fs-list-pagination{flex:0 0 auto}:host ::ng-deep fs-list .fs-list-load-more{position:sticky;bottom:0;background-color:#fff}:host ::ng-deep fs-list .fs-list-load-more button{margin-top:0}.pivot-truncated{flex:0 0 auto;padding:8px 12px;margin-bottom:8px;border-radius:4px;background-color:#fff4e5;color:#663c00}\n"] }]
|
|
2005
2617
|
}], propDecorators: { reportId: [{
|
|
2006
2618
|
type: Input
|
|
2007
|
-
}], component: [{
|
|
2008
|
-
type: Input
|
|
2009
|
-
}], groups: [{
|
|
2619
|
+
}], component: [{
|
|
2010
2620
|
type: Input
|
|
2011
2621
|
}], listComponent: [{
|
|
2012
2622
|
type: ViewChild,
|
|
@@ -2248,20 +2858,22 @@ class ReportComponentComponent {
|
|
|
2248
2858
|
}
|
|
2249
2859
|
// The component's header IS an fs-filter: heading template = its title (and
|
|
2250
2860
|
// the drag handle), actions = the Settings / filter-toggles / Export menu,
|
|
2251
|
-
// items = its component-level filter groups
|
|
2252
|
-
//
|
|
2253
|
-
|
|
2254
|
-
|
|
2255
|
-
|
|
2256
|
-
|
|
2861
|
+
// items = its component-level filter groups, for every component type, lists
|
|
2862
|
+
// included. Rebuilt when a filter toggle changes which groups show,
|
|
2863
|
+
// and when the settings dialog changes the title or padding: setting it
|
|
2864
|
+
// repaints the header and body.
|
|
2865
|
+
filterConfig = signal(null);
|
|
2866
|
+
data = signal(null);
|
|
2867
|
+
loading = signal(false);
|
|
2868
|
+
error = signal(null);
|
|
2257
2869
|
_refresh$ = new Subject();
|
|
2258
2870
|
_reportData = inject(ReportData);
|
|
2259
2871
|
_filterState = inject(ReportFilterStateService);
|
|
2260
2872
|
_process = inject(FsProcess);
|
|
2873
|
+
_message = inject(FsMessage);
|
|
2261
2874
|
_prompt = inject(FsPrompt);
|
|
2262
2875
|
_lastPress = 0;
|
|
2263
2876
|
_destroyRef = inject(DestroyRef);
|
|
2264
|
-
_cdRef = inject(ChangeDetectorRef);
|
|
2265
2877
|
_zone = inject(NgZone);
|
|
2266
2878
|
_host = inject(ElementRef);
|
|
2267
2879
|
ngOnInit() {
|
|
@@ -2342,7 +2954,7 @@ class ReportComponentComponent {
|
|
|
2342
2954
|
.subscribe(() => this.removed.emit(this.component.id));
|
|
2343
2955
|
}
|
|
2344
2956
|
get truncated() {
|
|
2345
|
-
return !!this.data?.truncated;
|
|
2957
|
+
return !!this.data()?.truncated;
|
|
2346
2958
|
}
|
|
2347
2959
|
// Content padding (inches → px at 96/in; the canvas zoom scales the page as
|
|
2348
2960
|
// a whole). Each edge defaults to 1/8".
|
|
@@ -2354,10 +2966,10 @@ class ReportComponentComponent {
|
|
|
2354
2966
|
// The component-level filter groups that should render as an inline control
|
|
2355
2967
|
// strip on THIS component: a group is included when it's level=component and
|
|
2356
2968
|
// has an enabled, session-on filter here. Report-level groups live in the
|
|
2357
|
-
// top bar instead
|
|
2358
|
-
//
|
|
2969
|
+
// top bar instead. Lists use this strip like charts do: a value set here is
|
|
2970
|
+
// the group's value, so it reaches every component in the group.
|
|
2359
2971
|
get componentFilterGroups() {
|
|
2360
|
-
if (
|
|
2972
|
+
if (!this.groups) {
|
|
2361
2973
|
return [];
|
|
2362
2974
|
}
|
|
2363
2975
|
const groupIds = new Set();
|
|
@@ -2378,17 +2990,36 @@ class ReportComponentComponent {
|
|
|
2378
2990
|
// Presentation settings saved from the settings dialog. Title and padding are
|
|
2379
2991
|
// not part of the query, so the data already fetched still stands — this only
|
|
2380
2992
|
// repaints. The header config is rebuilt because `filterKey` changes with the
|
|
2381
|
-
// title and recreates the fs-filter block around it
|
|
2382
|
-
//
|
|
2993
|
+
// title and recreates the fs-filter block around it; setting the config signal
|
|
2994
|
+
// is also what repaints the new padding. The chart follows its box on its own
|
|
2995
|
+
// (ngx-echarts observes the host), so nothing is told to resize.
|
|
2383
2996
|
applySettings(settings) {
|
|
2384
2997
|
this.component.title = settings.title;
|
|
2385
2998
|
this.component.config = { ...this.component.config, padding: settings.padding };
|
|
2386
2999
|
this._buildFilterConfig();
|
|
2387
|
-
this._cdRef.markForCheck();
|
|
2388
3000
|
}
|
|
3001
|
+
// The CSV is what the screen shows, so it carries the same interaction state
|
|
3002
|
+
// the screen fetched with: the group-by grain for a time-series chart and the
|
|
3003
|
+
// viewer's header sort for a list. A note the server returns (the file was
|
|
3004
|
+
// cut) is shown before the download starts, so a short file never passes for
|
|
3005
|
+
// the whole result.
|
|
2389
3006
|
exportCsv() {
|
|
2390
3007
|
const filters = this._filterState.resolveForComponent(this.component);
|
|
2391
|
-
|
|
3008
|
+
const state = {};
|
|
3009
|
+
// A deliberate mirror of the server's ReportQueryRunner::_isTimeSeriesChart,
|
|
3010
|
+
// which is canonical and drops the grain for every other component. Kept so
|
|
3011
|
+
// a product pinned to an older framework does not re-bucket a non-time chart.
|
|
3012
|
+
if (this._isTimeSeries && this._filterState.frequency()) {
|
|
3013
|
+
state.frequency = this._filterState.frequency();
|
|
3014
|
+
}
|
|
3015
|
+
const sort = this.component.type === 'list'
|
|
3016
|
+
? this._filterState.listSort(this.component.id)
|
|
3017
|
+
: null;
|
|
3018
|
+
if (sort) {
|
|
3019
|
+
state.sort = sort;
|
|
3020
|
+
}
|
|
3021
|
+
this._process.download(`Exporting ${this.component.title ?? 'component'}`, this._reportData.exportComponent(this.reportId, this.component.id, filters, state)
|
|
3022
|
+
.pipe(tap(({ notes }) => notes?.length && this._message.warning(notes.join(' '))), map$1(({ url }) => url)));
|
|
2392
3023
|
}
|
|
2393
3024
|
// Any interaction inside a component must never start a canvas pan (the
|
|
2394
3025
|
// zoom-pan host listens for mousedown/touchstart at the container level).
|
|
@@ -2478,9 +3109,10 @@ class ReportComponentComponent {
|
|
|
2478
3109
|
// top-right in the template, so its button height no longer drives the header
|
|
2479
3110
|
// row's height (which would otherwise inflate the heading by ~40px).
|
|
2480
3111
|
_buildFilterConfig() {
|
|
2481
|
-
this.filterConfig
|
|
2482
|
-
//
|
|
2483
|
-
//
|
|
3112
|
+
this.filterConfig.set({
|
|
3113
|
+
// fs-filter never touches the URL or persists filter state. The values
|
|
3114
|
+
// are owned by ReportFilterStateService, which feeds the report view's
|
|
3115
|
+
// one URL writer (report-url-state.ts).
|
|
2484
3116
|
queryParam: false,
|
|
2485
3117
|
persist: false,
|
|
2486
3118
|
items: this.componentFilterGroups.map((group) => filterItemForGroup(group, this._reportData, this.reportId, this._filterState.value(group.id))),
|
|
@@ -2489,7 +3121,7 @@ class ReportComponentComponent {
|
|
|
2489
3121
|
this._filterState.setValue(groupId, value);
|
|
2490
3122
|
}
|
|
2491
3123
|
},
|
|
2492
|
-
};
|
|
3124
|
+
});
|
|
2493
3125
|
}
|
|
2494
3126
|
// Flow: only the east handle resizes (width %). Height is always auto in flow
|
|
2495
3127
|
// (the box fits its content), so there's no south handle here.
|
|
@@ -2557,7 +3189,6 @@ class ReportComponentComponent {
|
|
|
2557
3189
|
this._zone.run(() => {
|
|
2558
3190
|
this.snapper?.clear();
|
|
2559
3191
|
onEnd();
|
|
2560
|
-
this._cdRef.markForCheck();
|
|
2561
3192
|
});
|
|
2562
3193
|
};
|
|
2563
3194
|
handle.addEventListener('pointermove', move);
|
|
@@ -2581,7 +3212,8 @@ class ReportComponentComponent {
|
|
|
2581
3212
|
});
|
|
2582
3213
|
}
|
|
2583
3214
|
// A chart on a time x-axis — the only component the report's group-by control
|
|
2584
|
-
// applies to.
|
|
3215
|
+
// applies to. The server owns this rule (ReportQueryRunner::_isTimeSeriesChart);
|
|
3216
|
+
// this is its documented browser-side mirror, for products on an older framework.
|
|
2585
3217
|
get _isTimeSeries() {
|
|
2586
3218
|
return this.component.type === 'chart' && this.component.config?.xAxis?.kind === 'time';
|
|
2587
3219
|
}
|
|
@@ -2631,9 +3263,8 @@ class ReportComponentComponent {
|
|
|
2631
3263
|
}
|
|
2632
3264
|
// ----- data ----------------------------------------------------------------
|
|
2633
3265
|
_load() {
|
|
2634
|
-
this.loading
|
|
2635
|
-
this.error
|
|
2636
|
-
this._cdRef.markForCheck();
|
|
3266
|
+
this.loading.set(true);
|
|
3267
|
+
this.error.set(null);
|
|
2637
3268
|
const filters = this._filterState.resolveForComponent(this.component);
|
|
2638
3269
|
// Time-series charts carry the group-by grain (if the viewer picked one) so
|
|
2639
3270
|
// the backend re-buckets; everything else sends no interaction state here.
|
|
@@ -2644,19 +3275,17 @@ class ReportComponentComponent {
|
|
|
2644
3275
|
.pipe(takeUntilDestroyed(this._destroyRef))
|
|
2645
3276
|
.subscribe({
|
|
2646
3277
|
next: (data) => {
|
|
2647
|
-
this.data
|
|
2648
|
-
this.loading
|
|
2649
|
-
this._cdRef.markForCheck();
|
|
3278
|
+
this.data.set(data);
|
|
3279
|
+
this.loading.set(false);
|
|
2650
3280
|
},
|
|
2651
3281
|
error: (error) => {
|
|
2652
|
-
this.loading
|
|
2653
|
-
this.error
|
|
2654
|
-
this._cdRef.markForCheck();
|
|
3282
|
+
this.loading.set(false);
|
|
3283
|
+
this.error.set(error?.error?.message ?? error?.message ?? 'Failed to load data');
|
|
2655
3284
|
},
|
|
2656
3285
|
});
|
|
2657
3286
|
}
|
|
2658
3287
|
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: ReportComponentComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
2659
|
-
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "18.2.14", type: ReportComponentComponent, isStandalone: true, selector: "app-report-component", inputs: { reportId: { classPropertyName: "reportId", publicName: "reportId", isSignal: false, isRequired: false, transformFunction: null }, component: { classPropertyName: "component", publicName: "component", isSignal: false, isRequired: false, transformFunction: null }, groups: { classPropertyName: "groups", publicName: "groups", isSignal: false, isRequired: false, transformFunction: null }, editMode: { classPropertyName: "editMode", publicName: "editMode", isSignal: false, isRequired: false, transformFunction: null }, layout: { classPropertyName: "layout", publicName: "layout", isSignal: false, isRequired: false, transformFunction: null }, zoom: { classPropertyName: "zoom", publicName: "zoom", isSignal: false, isRequired: false, transformFunction: null }, snapper: { classPropertyName: "snapper", publicName: "snapper", isSignal: false, isRequired: false, transformFunction: null }, selected: { classPropertyName: "selected", publicName: "selected", isSignal: false, isRequired: false, transformFunction: null }, tokens: { classPropertyName: "tokens", publicName: "tokens", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { selectComponent: "selectComponent", positionChanged: "positionChanged", flowReorder: "flowReorder", openSettings: "openSettings", removed: "removed" }, host: { properties: { "class.height-auto": "this.heightAuto" } }, viewQueries: [{ propertyName: "chartComponent", first: true, predicate: ComponentChartComponent, descendants: true }], usesOnChanges: true, ngImport: i0, template: "<div\n class=\"component-box\"\n [class.editing]=\"editMode\"\n [class.text]=\"isText\"\n [class.selected]=\"selected\"\n [style.padding]=\"bodyPadding\"\n (pointerdown)=\"onComponentPointerDown($event)\"\n (mousedown)=\"stopCanvasPan($event)\"\n (touchstart)=\"stopCanvasPan($event)\">\n <!-- The header is an fs-filter: heading template = title (and the edit-mode\n drag handle), actions = the menu, items = component-level filters.\n Lists carry no items here \u2014 they filter through FsList. fs-filter reads\n its config once, so the block is keyed on the component-filter set to be\n recreated when it changes. -->\n <!-- A text block has no title bar \u2014 it IS its words, like a text box on a\n slide \u2014 so it gets neither the header nor the data actions. -->\n @for (key of isText ? [] : [filterKey]; track key) {\n <fs-filter\n class=\"component-filter\"\n [config]=\"filterConfig\">\n <ng-template fsFilterHeading>\n <div\n class=\"component-title\"\n (pointerdown)=\"onDragStart($event)\">\n {{ component.title }}\n @if (truncated) {\n <mat-icon\n class=\"truncated-icon\"\n matTooltip=\"Showing a truncated result \u2014 refine the filters to see everything.\">\n warning_amber\n </mat-icon>\n }\n </div>\n </ng-template>\n </fs-filter>\n }\n <!-- The actions menu is floated top-right (absolute) rather than living in\n fs-filter's actions slot, so its ~40px button height no longer drives the\n header row's height. It overlays the box corner; the heading reserves\n right padding so a long title ellipsises before it. -->\n @if (!isText || (editMode && !textEditing())) {\n <button\n mat-icon-button\n class=\"component-menu\"\n [matMenuTriggerFor]=\"menu\"\n (pointerdown)=\"$event.stopPropagation()\"\n (mousedown)=\"stopCanvasPan($event)\">\n <mat-icon>more_vert</mat-icon>\n </button>\n }\n <mat-menu #menu=\"matMenu\">\n @if (isText) {\n <button mat-menu-item (click)=\"editText()\">\n <mat-icon>edit</mat-icon>\n <span>Edit text</span>\n </button>\n <button mat-menu-item (click)=\"deleteText()\">\n <mat-icon>delete</mat-icon>\n <span>Delete</span>\n </button>\n } @else {\n <button mat-menu-item (click)=\"settings()\">\n <mat-icon>tune</mat-icon>\n <span>Settings</span>\n </button>\n <button mat-menu-item (click)=\"exportCsv()\">\n <mat-icon>download</mat-icon>\n <span>Export CSV</span>\n </button>\n }\n </mat-menu>\n <div class=\"component-body\">\n @if (isText) {\n <app-report-component-text\n [html]=\"component.config?.html ?? ''\"\n [tokens]=\"tokens()\"\n [editing]=\"textEditing()\"\n (saved)=\"onTextSaved($event)\"\n (editEnded)=\"textEditing.set(false)\">\n </app-report-component-text>\n } @else if (component.type === 'list') {\n <app-report-component-list\n [reportId]=\"reportId\"\n [component]=\"component\"\n [groups]=\"groups\">\n </app-report-component-list>\n } @else if (loading) {\n <div class=\"component-state\">\n <div class=\"loading-shimmer\"></div>\n </div>\n } @else if (error) {\n <div class=\"component-state error\">\n {{ error }}\n </div>\n } @else if (!data?.rows?.length) {\n <div class=\"component-state\">\n No data\n </div>\n } @else if (component.type === 'kpi') {\n <app-report-component-kpi\n [component]=\"component\"\n [data]=\"data\">\n </app-report-component-kpi>\n } @else {\n <app-report-component-chart\n [component]=\"component\"\n [data]=\"data\">\n </app-report-component-chart>\n }\n <!-- In edit mode a transparent veil over the body makes the WHOLE\n component draggable (industry standard: you grab the object, not just\n its title bar) and keeps inner widgets from swallowing the gesture. -->\n @if (editMode && !textEditing()) {\n <div\n class=\"drag-veil\"\n (pointerdown)=\"onDragStart($event)\">\n </div>\n }\n </div>\n</div>\n<!-- Handles live OUTSIDE the clipped box so they straddle its edges. -->\n@if (editMode && selected) {\n @if (layout === 'freeform') {\n @for (handle of resizeHandles; track handle) {\n <div\n class=\"handle handle-{{ handle }}\"\n (mousedown)=\"stopCanvasPan($event)\"\n (pointerdown)=\"onResizeStart($event, handle)\">\n </div>\n }\n } @else {\n <!-- Flow height is always auto (the box fits its content), so only the\n east handle (width %) is offered here. -->\n <div\n class=\"handle handle-e\"\n matTooltip=\"Width (% of page)\"\n (mousedown)=\"stopCanvasPan($event)\"\n (pointerdown)=\"onResizeStart($event, 'e')\">\n </div>\n }\n}", styles: [":host{position:absolute;display:flex;flex-direction:column;font-size:10px;--mdc-outlined-button-container-height: 30px;--mdc-outlined-button-label-text-size: 10px;--mat-outlined-button-horizontal-padding: 15px;--mdc-text-button-container-height: 30px;--mdc-text-button-label-text-size: 10px;--mat-text-button-horizontal-padding: 15px;--mat-button-outlined-container-height: 30px;--mat-button-outlined-label-text-size: 10px;--mat-button-outlined-horizontal-padding: 15px;--mat-button-text-container-height: 30px;--mat-button-text-label-text-size: 10px;--mat-button-text-horizontal-padding: 15px}:host.flow-item{position:relative;left:auto;top:auto}:host.flow-dragging{z-index:40;opacity:.7;pointer-events:none}.component-box{position:relative;width:100%;flex:1 1 auto;min-height:0;box-sizing:border-box;display:flex;flex-direction:column;background:#fff;border:1px solid #e4e7eb;border-radius:6px;overflow:hidden;transition:box-shadow .12s ease,border-color .12s ease;cursor:default}.component-box.editing .component-title{cursor:grab}.component-box.editing .component-title:active{cursor:grabbing}.component-box.editing:hover{border-color:#9db3c8;box-shadow:0 2px 8px #0f172a14}.component-box.selected{border-color:var(--brand-primary-color);box-shadow:0 0 0 1px var(--brand-primary-color),0 4px 14px #2196f32e}.component-box.text{background:transparent;border-color:transparent;border-radius:0;overflow:visible}.component-box.text.editing{border-style:dashed;border-color:#cbd2d9}.component-box.text.editing:hover{box-shadow:none}.component-box.text.selected{border-style:solid;border-color:var(--brand-primary-color);box-shadow:0 0 0 1px var(--brand-primary-color)}:host ::ng-deep .component-filter .filter-bar-container{align-items:center!important}.component-filter{flex:0 0 auto;display:block;padding:0 0 4px}.component-filter .component-title{display:flex;align-items:center;gap:4px;-webkit-user-select:none;user-select:none;padding-right:40px;font-size:var(--report-heading-size);font-weight:600;color:#1f2933;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.component-filter .component-title .truncated-icon{font-size:16px;width:16px;height:16px;color:#f59e0b}.component-menu{position:absolute;top:2px;right:2px;z-index:30}.component-body{flex:1 1 auto;min-height:0;position:relative}:host(.height-auto) .component-state{min-height:120px}.drag-veil{position:absolute;inset:0;cursor:grab;touch-action:none}.drag-veil:active{cursor:grabbing}.component-state{width:100%;height:100%;display:flex;align-items:center;justify-content:center;font-size:12px;color:#7b8794;padding:8px;text-align:center}.component-state.error{color:#e15759}.loading-shimmer{width:70%;height:60%;border-radius:6px;background:linear-gradient(100deg,#f0f4f8 40%,#e4ebf2,#f0f4f8 60%);background-size:200% 100%;animation:shimmer 1.2s infinite linear}@keyframes shimmer{to{background-position-x:-200%}}.handle{position:absolute;width:14px;height:14px;display:flex;align-items:center;justify-content:center;touch-action:none;z-index:20;transform:scale(calc(1 / var(--canvas-zoom, 1)))}.handle:before{content:\"\";width:8px;height:8px;background:#fff;border:1.5px solid var(--brand-primary-color);border-radius:2px;box-shadow:0 1px 2px #0f172a33}.handle-nw{left:-7px;top:-7px;cursor:nwse-resize}.handle-n{left:calc(50% - 7px);top:-7px;cursor:ns-resize}.handle-ne{right:-7px;top:-7px;cursor:nesw-resize}.handle-e{right:-7px;top:calc(50% - 7px);cursor:ew-resize}.handle-se{right:-7px;bottom:-7px;cursor:nwse-resize}.handle-s{left:calc(50% - 7px);bottom:-7px;cursor:ns-resize}.handle-sw{left:-7px;bottom:-7px;cursor:nesw-resize}.handle-w{left:-7px;top:calc(50% - 7px);cursor:ew-resize}\n"], dependencies: [{ kind: "component", type: MatIcon, selector: "mat-icon", inputs: ["color", "inline", "svgIcon", "fontSet", "fontIcon"], exportAs: ["matIcon"] }, { kind: "component", type: MatIconButton, selector: "button[mat-icon-button]", exportAs: ["matButton"] }, { kind: "ngmodule", type: MatMenuModule }, { kind: "component", type: i1$2.MatMenu, selector: "mat-menu", inputs: ["backdropClass", "aria-label", "aria-labelledby", "aria-describedby", "xPosition", "yPosition", "overlapTrigger", "hasBackdrop", "class", "classList"], outputs: ["closed", "close"], exportAs: ["matMenu"] }, { kind: "component", type: i1$2.MatMenuItem, selector: "[mat-menu-item]", inputs: ["role", "disabled", "disableRipple"], exportAs: ["matMenuItem"] }, { kind: "directive", type: i1$2.MatMenuTrigger, selector: "[mat-menu-trigger-for], [matMenuTriggerFor]", inputs: ["mat-menu-trigger-for", "matMenuTriggerFor", "matMenuTriggerData", "matMenuTriggerRestoreFocus"], outputs: ["menuOpened", "onMenuOpen", "menuClosed", "onMenuClose"], exportAs: ["matMenuTrigger"] }, { kind: "directive", type: MatTooltip, selector: "[matTooltip]", inputs: ["matTooltipPosition", "matTooltipPositionAtOrigin", "matTooltipDisabled", "matTooltipShowDelay", "matTooltipHideDelay", "matTooltipTouchGestures", "matTooltip", "matTooltipClass"], exportAs: ["matTooltip"] }, { kind: "ngmodule", type: FsFilterModule }, { kind: "component", type: i2.FilterComponent, selector: "fs-filter", inputs: ["config"], outputs: ["closed", "opened", "ready"] }, { kind: "directive", type: i2.FilterHeadingDirective, selector: "[fsFilterHeading]" }, { kind: "component", type: ComponentChartComponent, selector: "app-report-component-chart", inputs: ["component", "data"] }, { kind: "component", type: ComponentKpiComponent, selector: "app-report-component-kpi", inputs: ["component", "data"] }, { kind: "component", type: ComponentListComponent, selector: "app-report-component-list", inputs: ["reportId", "component", "groups"] }, { kind: "component", type: ComponentTextComponent, selector: "app-report-component-text", inputs: ["html", "tokens", "editing"], outputs: ["saved", "editEnded"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
3288
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "18.2.14", type: ReportComponentComponent, isStandalone: true, selector: "app-report-component", inputs: { reportId: { classPropertyName: "reportId", publicName: "reportId", isSignal: false, isRequired: false, transformFunction: null }, component: { classPropertyName: "component", publicName: "component", isSignal: false, isRequired: false, transformFunction: null }, groups: { classPropertyName: "groups", publicName: "groups", isSignal: false, isRequired: false, transformFunction: null }, editMode: { classPropertyName: "editMode", publicName: "editMode", isSignal: false, isRequired: false, transformFunction: null }, layout: { classPropertyName: "layout", publicName: "layout", isSignal: false, isRequired: false, transformFunction: null }, zoom: { classPropertyName: "zoom", publicName: "zoom", isSignal: false, isRequired: false, transformFunction: null }, snapper: { classPropertyName: "snapper", publicName: "snapper", isSignal: false, isRequired: false, transformFunction: null }, selected: { classPropertyName: "selected", publicName: "selected", isSignal: false, isRequired: false, transformFunction: null }, tokens: { classPropertyName: "tokens", publicName: "tokens", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { selectComponent: "selectComponent", positionChanged: "positionChanged", flowReorder: "flowReorder", openSettings: "openSettings", removed: "removed" }, host: { properties: { "class.height-auto": "this.heightAuto" } }, viewQueries: [{ propertyName: "chartComponent", first: true, predicate: ComponentChartComponent, descendants: true }], usesOnChanges: true, ngImport: i0, template: "<div\n class=\"component-box\"\n [class.editing]=\"editMode\"\n [class.text]=\"isText\"\n [class.selected]=\"selected\"\n [style.padding]=\"bodyPadding\"\n (pointerdown)=\"onComponentPointerDown($event)\"\n (mousedown)=\"stopCanvasPan($event)\"\n (touchstart)=\"stopCanvasPan($event)\">\n <!-- The header is an fs-filter: heading template = title (and the edit-mode\n drag handle), actions = the menu, items = component-level filter groups,\n the same strip for every type (lists included). fs-filter reads its\n config once, so the block is keyed on the component-filter set to be\n recreated when it changes. -->\n <!-- A text block has no title bar \u2014 it IS its words, like a text box on a\n slide \u2014 so it gets neither the header nor the data actions. -->\n @for (key of isText ? [] : [filterKey]; track key) {\n <fs-filter\n class=\"component-filter\"\n [config]=\"filterConfig()\">\n <ng-template fsFilterHeading>\n <div\n class=\"component-title\"\n (pointerdown)=\"onDragStart($event)\">\n {{ component.title }}\n @if (truncated) {\n <mat-icon\n class=\"truncated-icon\"\n matTooltip=\"Showing a truncated result \u2014 refine the filters to see everything.\">\n warning_amber\n </mat-icon>\n }\n </div>\n </ng-template>\n </fs-filter>\n }\n <!-- The actions menu is floated top-right (absolute) rather than living in\n fs-filter's actions slot, so its ~40px button height no longer drives the\n header row's height. It overlays the box corner; the heading reserves\n right padding so a long title ellipsises before it. -->\n @if (!isText || (editMode && !textEditing())) {\n <button\n mat-icon-button\n class=\"component-menu\"\n [matMenuTriggerFor]=\"menu\"\n (pointerdown)=\"$event.stopPropagation()\"\n (mousedown)=\"stopCanvasPan($event)\">\n <mat-icon>more_vert</mat-icon>\n </button>\n }\n <mat-menu #menu=\"matMenu\">\n @if (isText) {\n <button mat-menu-item (click)=\"editText()\">\n <mat-icon>edit</mat-icon>\n <span>Edit text</span>\n </button>\n <button mat-menu-item (click)=\"deleteText()\">\n <mat-icon>delete</mat-icon>\n <span>Delete</span>\n </button>\n } @else {\n <button mat-menu-item (click)=\"settings()\">\n <mat-icon>tune</mat-icon>\n <span>Settings</span>\n </button>\n <button mat-menu-item (click)=\"exportCsv()\">\n <mat-icon>download</mat-icon>\n <span>Export CSV</span>\n </button>\n }\n </mat-menu>\n <div class=\"component-body\">\n @if (isText) {\n <app-report-component-text\n [html]=\"component.config?.html ?? ''\"\n [tokens]=\"tokens()\"\n [editing]=\"textEditing()\"\n (saved)=\"onTextSaved($event)\"\n (editEnded)=\"textEditing.set(false)\">\n </app-report-component-text>\n } @else if (component.type === 'list') {\n <app-report-component-list\n [reportId]=\"reportId\"\n [component]=\"component\">\n </app-report-component-list>\n } @else if (loading()) {\n <div class=\"component-state\">\n <div class=\"loading-shimmer\"></div>\n </div>\n } @else if (error()) {\n <div class=\"component-state error\">\n {{ error() }}\n </div>\n } @else if (!data()?.rows?.length) {\n <div class=\"component-state\">\n No data\n </div>\n } @else if (component.type === 'kpi') {\n <app-report-component-kpi\n [component]=\"component\"\n [data]=\"data()\">\n </app-report-component-kpi>\n } @else {\n <app-report-component-chart\n [component]=\"component\"\n [data]=\"data()\">\n </app-report-component-chart>\n }\n <!-- In edit mode a transparent veil over the body makes the WHOLE\n component draggable (industry standard: you grab the object, not just\n its title bar) and keeps inner widgets from swallowing the gesture. -->\n @if (editMode && !textEditing()) {\n <div\n class=\"drag-veil\"\n (pointerdown)=\"onDragStart($event)\">\n </div>\n }\n </div>\n</div>\n<!-- Handles live OUTSIDE the clipped box so they straddle its edges. -->\n@if (editMode && selected) {\n @if (layout === 'freeform') {\n @for (handle of resizeHandles; track handle) {\n <div\n class=\"handle handle-{{ handle }}\"\n (mousedown)=\"stopCanvasPan($event)\"\n (pointerdown)=\"onResizeStart($event, handle)\">\n </div>\n }\n } @else {\n <!-- Flow height is always auto (the box fits its content), so only the\n east handle (width %) is offered here. -->\n <div\n class=\"handle handle-e\"\n matTooltip=\"Width (% of page)\"\n (mousedown)=\"stopCanvasPan($event)\"\n (pointerdown)=\"onResizeStart($event, 'e')\">\n </div>\n }\n}", styles: [":host{position:absolute;display:flex;flex-direction:column;font-size:10px;--mdc-outlined-button-container-height: 30px;--mdc-outlined-button-label-text-size: 10px;--mat-outlined-button-horizontal-padding: 15px;--mdc-text-button-container-height: 30px;--mdc-text-button-label-text-size: 10px;--mat-text-button-horizontal-padding: 15px;--mat-button-outlined-container-height: 30px;--mat-button-outlined-label-text-size: 10px;--mat-button-outlined-horizontal-padding: 15px;--mat-button-text-container-height: 30px;--mat-button-text-label-text-size: 10px;--mat-button-text-horizontal-padding: 15px}:host.flow-item{position:relative;left:auto;top:auto}:host.flow-dragging{z-index:40;opacity:.7;pointer-events:none}.component-box{position:relative;width:100%;flex:1 1 auto;min-height:0;box-sizing:border-box;display:flex;flex-direction:column;background:#fff;border:1px solid #e4e7eb;border-radius:6px;overflow:hidden;transition:box-shadow .12s ease,border-color .12s ease;cursor:default}.component-box.editing .component-title{cursor:grab}.component-box.editing .component-title:active{cursor:grabbing}.component-box.editing:hover{border-color:#9db3c8;box-shadow:0 2px 8px #0f172a14}.component-box.selected{border-color:var(--brand-primary-color);box-shadow:0 0 0 1px var(--brand-primary-color),0 4px 14px #2196f32e}.component-box.text{background:transparent;border-color:transparent;border-radius:0;overflow:visible}.component-box.text.editing{border-style:dashed;border-color:#cbd2d9}.component-box.text.editing:hover{box-shadow:none}.component-box.text.selected{border-style:solid;border-color:var(--brand-primary-color);box-shadow:0 0 0 1px var(--brand-primary-color)}:host ::ng-deep .component-filter .filter-bar-container{align-items:center!important}.component-filter{flex:0 0 auto;display:block;padding:0 0 4px}.component-filter .component-title{display:flex;align-items:center;gap:4px;-webkit-user-select:none;user-select:none;padding-right:40px;font-size:var(--report-heading-size);font-weight:600;color:#1f2933;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.component-filter .component-title .truncated-icon{font-size:16px;width:16px;height:16px;color:#f59e0b}.component-menu{position:absolute;top:2px;right:2px;z-index:30}.component-body{flex:1 1 auto;min-height:0;position:relative}:host(.height-auto) .component-state{min-height:120px}.drag-veil{position:absolute;inset:0;cursor:grab;touch-action:none}.drag-veil:active{cursor:grabbing}.component-state{width:100%;height:100%;display:flex;align-items:center;justify-content:center;font-size:12px;color:#7b8794;padding:8px;text-align:center}.component-state.error{color:#e15759}.loading-shimmer{width:70%;height:60%;border-radius:6px;background:linear-gradient(100deg,#f0f4f8 40%,#e4ebf2,#f0f4f8 60%);background-size:200% 100%;animation:shimmer 1.2s infinite linear}@keyframes shimmer{to{background-position-x:-200%}}.handle{position:absolute;width:14px;height:14px;display:flex;align-items:center;justify-content:center;touch-action:none;z-index:20;transform:scale(calc(1 / var(--canvas-zoom, 1)))}.handle:before{content:\"\";width:8px;height:8px;background:#fff;border:1.5px solid var(--brand-primary-color);border-radius:2px;box-shadow:0 1px 2px #0f172a33}.handle-nw{left:-7px;top:-7px;cursor:nwse-resize}.handle-n{left:calc(50% - 7px);top:-7px;cursor:ns-resize}.handle-ne{right:-7px;top:-7px;cursor:nesw-resize}.handle-e{right:-7px;top:calc(50% - 7px);cursor:ew-resize}.handle-se{right:-7px;bottom:-7px;cursor:nwse-resize}.handle-s{left:calc(50% - 7px);bottom:-7px;cursor:ns-resize}.handle-sw{left:-7px;bottom:-7px;cursor:nesw-resize}.handle-w{left:-7px;top:calc(50% - 7px);cursor:ew-resize}\n"], dependencies: [{ kind: "component", type: MatIcon, selector: "mat-icon", inputs: ["color", "inline", "svgIcon", "fontSet", "fontIcon"], exportAs: ["matIcon"] }, { kind: "component", type: MatIconButton, selector: "button[mat-icon-button]", exportAs: ["matButton"] }, { kind: "ngmodule", type: MatMenuModule }, { kind: "component", type: i1$2.MatMenu, selector: "mat-menu", inputs: ["backdropClass", "aria-label", "aria-labelledby", "aria-describedby", "xPosition", "yPosition", "overlapTrigger", "hasBackdrop", "class", "classList"], outputs: ["closed", "close"], exportAs: ["matMenu"] }, { kind: "component", type: i1$2.MatMenuItem, selector: "[mat-menu-item]", inputs: ["role", "disabled", "disableRipple"], exportAs: ["matMenuItem"] }, { kind: "directive", type: i1$2.MatMenuTrigger, selector: "[mat-menu-trigger-for], [matMenuTriggerFor]", inputs: ["mat-menu-trigger-for", "matMenuTriggerFor", "matMenuTriggerData", "matMenuTriggerRestoreFocus"], outputs: ["menuOpened", "onMenuOpen", "menuClosed", "onMenuClose"], exportAs: ["matMenuTrigger"] }, { kind: "directive", type: MatTooltip, selector: "[matTooltip]", inputs: ["matTooltipPosition", "matTooltipPositionAtOrigin", "matTooltipDisabled", "matTooltipShowDelay", "matTooltipHideDelay", "matTooltipTouchGestures", "matTooltip", "matTooltipClass"], exportAs: ["matTooltip"] }, { kind: "ngmodule", type: FsFilterModule }, { kind: "component", type: i2.FilterComponent, selector: "fs-filter", inputs: ["config"], outputs: ["closed", "opened", "ready"] }, { kind: "directive", type: i2.FilterHeadingDirective, selector: "[fsFilterHeading]" }, { kind: "component", type: ComponentChartComponent, selector: "app-report-component-chart", inputs: ["component", "data"] }, { kind: "component", type: ComponentKpiComponent, selector: "app-report-component-kpi", inputs: ["component", "data"] }, { kind: "component", type: ComponentListComponent, selector: "app-report-component-list", inputs: ["reportId", "component"] }, { kind: "component", type: ComponentTextComponent, selector: "app-report-component-text", inputs: ["html", "tokens", "editing"], outputs: ["saved", "editEnded"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
2660
3289
|
}
|
|
2661
3290
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: ReportComponentComponent, decorators: [{
|
|
2662
3291
|
type: Component,
|
|
@@ -2670,7 +3299,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImpo
|
|
|
2670
3299
|
ComponentKpiComponent,
|
|
2671
3300
|
ComponentListComponent,
|
|
2672
3301
|
ComponentTextComponent,
|
|
2673
|
-
], template: "<div\n class=\"component-box\"\n [class.editing]=\"editMode\"\n [class.text]=\"isText\"\n [class.selected]=\"selected\"\n [style.padding]=\"bodyPadding\"\n (pointerdown)=\"onComponentPointerDown($event)\"\n (mousedown)=\"stopCanvasPan($event)\"\n (touchstart)=\"stopCanvasPan($event)\">\n <!-- The header is an fs-filter: heading template = title (and the edit-mode\n drag handle), actions = the menu, items = component-level
|
|
3302
|
+
], template: "<div\n class=\"component-box\"\n [class.editing]=\"editMode\"\n [class.text]=\"isText\"\n [class.selected]=\"selected\"\n [style.padding]=\"bodyPadding\"\n (pointerdown)=\"onComponentPointerDown($event)\"\n (mousedown)=\"stopCanvasPan($event)\"\n (touchstart)=\"stopCanvasPan($event)\">\n <!-- The header is an fs-filter: heading template = title (and the edit-mode\n drag handle), actions = the menu, items = component-level filter groups,\n the same strip for every type (lists included). fs-filter reads its\n config once, so the block is keyed on the component-filter set to be\n recreated when it changes. -->\n <!-- A text block has no title bar \u2014 it IS its words, like a text box on a\n slide \u2014 so it gets neither the header nor the data actions. -->\n @for (key of isText ? [] : [filterKey]; track key) {\n <fs-filter\n class=\"component-filter\"\n [config]=\"filterConfig()\">\n <ng-template fsFilterHeading>\n <div\n class=\"component-title\"\n (pointerdown)=\"onDragStart($event)\">\n {{ component.title }}\n @if (truncated) {\n <mat-icon\n class=\"truncated-icon\"\n matTooltip=\"Showing a truncated result \u2014 refine the filters to see everything.\">\n warning_amber\n </mat-icon>\n }\n </div>\n </ng-template>\n </fs-filter>\n }\n <!-- The actions menu is floated top-right (absolute) rather than living in\n fs-filter's actions slot, so its ~40px button height no longer drives the\n header row's height. It overlays the box corner; the heading reserves\n right padding so a long title ellipsises before it. -->\n @if (!isText || (editMode && !textEditing())) {\n <button\n mat-icon-button\n class=\"component-menu\"\n [matMenuTriggerFor]=\"menu\"\n (pointerdown)=\"$event.stopPropagation()\"\n (mousedown)=\"stopCanvasPan($event)\">\n <mat-icon>more_vert</mat-icon>\n </button>\n }\n <mat-menu #menu=\"matMenu\">\n @if (isText) {\n <button mat-menu-item (click)=\"editText()\">\n <mat-icon>edit</mat-icon>\n <span>Edit text</span>\n </button>\n <button mat-menu-item (click)=\"deleteText()\">\n <mat-icon>delete</mat-icon>\n <span>Delete</span>\n </button>\n } @else {\n <button mat-menu-item (click)=\"settings()\">\n <mat-icon>tune</mat-icon>\n <span>Settings</span>\n </button>\n <button mat-menu-item (click)=\"exportCsv()\">\n <mat-icon>download</mat-icon>\n <span>Export CSV</span>\n </button>\n }\n </mat-menu>\n <div class=\"component-body\">\n @if (isText) {\n <app-report-component-text\n [html]=\"component.config?.html ?? ''\"\n [tokens]=\"tokens()\"\n [editing]=\"textEditing()\"\n (saved)=\"onTextSaved($event)\"\n (editEnded)=\"textEditing.set(false)\">\n </app-report-component-text>\n } @else if (component.type === 'list') {\n <app-report-component-list\n [reportId]=\"reportId\"\n [component]=\"component\">\n </app-report-component-list>\n } @else if (loading()) {\n <div class=\"component-state\">\n <div class=\"loading-shimmer\"></div>\n </div>\n } @else if (error()) {\n <div class=\"component-state error\">\n {{ error() }}\n </div>\n } @else if (!data()?.rows?.length) {\n <div class=\"component-state\">\n No data\n </div>\n } @else if (component.type === 'kpi') {\n <app-report-component-kpi\n [component]=\"component\"\n [data]=\"data()\">\n </app-report-component-kpi>\n } @else {\n <app-report-component-chart\n [component]=\"component\"\n [data]=\"data()\">\n </app-report-component-chart>\n }\n <!-- In edit mode a transparent veil over the body makes the WHOLE\n component draggable (industry standard: you grab the object, not just\n its title bar) and keeps inner widgets from swallowing the gesture. -->\n @if (editMode && !textEditing()) {\n <div\n class=\"drag-veil\"\n (pointerdown)=\"onDragStart($event)\">\n </div>\n }\n </div>\n</div>\n<!-- Handles live OUTSIDE the clipped box so they straddle its edges. -->\n@if (editMode && selected) {\n @if (layout === 'freeform') {\n @for (handle of resizeHandles; track handle) {\n <div\n class=\"handle handle-{{ handle }}\"\n (mousedown)=\"stopCanvasPan($event)\"\n (pointerdown)=\"onResizeStart($event, handle)\">\n </div>\n }\n } @else {\n <!-- Flow height is always auto (the box fits its content), so only the\n east handle (width %) is offered here. -->\n <div\n class=\"handle handle-e\"\n matTooltip=\"Width (% of page)\"\n (mousedown)=\"stopCanvasPan($event)\"\n (pointerdown)=\"onResizeStart($event, 'e')\">\n </div>\n }\n}", styles: [":host{position:absolute;display:flex;flex-direction:column;font-size:10px;--mdc-outlined-button-container-height: 30px;--mdc-outlined-button-label-text-size: 10px;--mat-outlined-button-horizontal-padding: 15px;--mdc-text-button-container-height: 30px;--mdc-text-button-label-text-size: 10px;--mat-text-button-horizontal-padding: 15px;--mat-button-outlined-container-height: 30px;--mat-button-outlined-label-text-size: 10px;--mat-button-outlined-horizontal-padding: 15px;--mat-button-text-container-height: 30px;--mat-button-text-label-text-size: 10px;--mat-button-text-horizontal-padding: 15px}:host.flow-item{position:relative;left:auto;top:auto}:host.flow-dragging{z-index:40;opacity:.7;pointer-events:none}.component-box{position:relative;width:100%;flex:1 1 auto;min-height:0;box-sizing:border-box;display:flex;flex-direction:column;background:#fff;border:1px solid #e4e7eb;border-radius:6px;overflow:hidden;transition:box-shadow .12s ease,border-color .12s ease;cursor:default}.component-box.editing .component-title{cursor:grab}.component-box.editing .component-title:active{cursor:grabbing}.component-box.editing:hover{border-color:#9db3c8;box-shadow:0 2px 8px #0f172a14}.component-box.selected{border-color:var(--brand-primary-color);box-shadow:0 0 0 1px var(--brand-primary-color),0 4px 14px #2196f32e}.component-box.text{background:transparent;border-color:transparent;border-radius:0;overflow:visible}.component-box.text.editing{border-style:dashed;border-color:#cbd2d9}.component-box.text.editing:hover{box-shadow:none}.component-box.text.selected{border-style:solid;border-color:var(--brand-primary-color);box-shadow:0 0 0 1px var(--brand-primary-color)}:host ::ng-deep .component-filter .filter-bar-container{align-items:center!important}.component-filter{flex:0 0 auto;display:block;padding:0 0 4px}.component-filter .component-title{display:flex;align-items:center;gap:4px;-webkit-user-select:none;user-select:none;padding-right:40px;font-size:var(--report-heading-size);font-weight:600;color:#1f2933;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.component-filter .component-title .truncated-icon{font-size:16px;width:16px;height:16px;color:#f59e0b}.component-menu{position:absolute;top:2px;right:2px;z-index:30}.component-body{flex:1 1 auto;min-height:0;position:relative}:host(.height-auto) .component-state{min-height:120px}.drag-veil{position:absolute;inset:0;cursor:grab;touch-action:none}.drag-veil:active{cursor:grabbing}.component-state{width:100%;height:100%;display:flex;align-items:center;justify-content:center;font-size:12px;color:#7b8794;padding:8px;text-align:center}.component-state.error{color:#e15759}.loading-shimmer{width:70%;height:60%;border-radius:6px;background:linear-gradient(100deg,#f0f4f8 40%,#e4ebf2,#f0f4f8 60%);background-size:200% 100%;animation:shimmer 1.2s infinite linear}@keyframes shimmer{to{background-position-x:-200%}}.handle{position:absolute;width:14px;height:14px;display:flex;align-items:center;justify-content:center;touch-action:none;z-index:20;transform:scale(calc(1 / var(--canvas-zoom, 1)))}.handle:before{content:\"\";width:8px;height:8px;background:#fff;border:1.5px solid var(--brand-primary-color);border-radius:2px;box-shadow:0 1px 2px #0f172a33}.handle-nw{left:-7px;top:-7px;cursor:nwse-resize}.handle-n{left:calc(50% - 7px);top:-7px;cursor:ns-resize}.handle-ne{right:-7px;top:-7px;cursor:nesw-resize}.handle-e{right:-7px;top:calc(50% - 7px);cursor:ew-resize}.handle-se{right:-7px;bottom:-7px;cursor:nwse-resize}.handle-s{left:calc(50% - 7px);bottom:-7px;cursor:ns-resize}.handle-sw{left:-7px;bottom:-7px;cursor:nesw-resize}.handle-w{left:-7px;top:calc(50% - 7px);cursor:ew-resize}\n"] }]
|
|
2674
3303
|
}], propDecorators: { reportId: [{
|
|
2675
3304
|
type: Input
|
|
2676
3305
|
}], component: [{
|
|
@@ -2709,7 +3338,8 @@ const PX_PER_INCH = 96;
|
|
|
2709
3338
|
// The fixed-size page canvas — the PowerPoint model. Pages are tabs (only the
|
|
2710
3339
|
// ACTIVE page's components exist → only they fetch); the page is a
|
|
2711
3340
|
// pageWidth×pageHeight INCHES sheet rendered at 96px/in inside fs-zoom-pan
|
|
2712
|
-
// (wheel zoom, background drag pan, toolbar buttons
|
|
3341
|
+
// (Ctrl/Cmd + wheel or pinch to zoom, background drag pan, toolbar buttons; a
|
|
3342
|
+
// plain wheel scrolls the list under the pointer).
|
|
2713
3343
|
//
|
|
2714
3344
|
// Freeform layout: components are absolutely positioned; dragging gets smart
|
|
2715
3345
|
// alignment guides (edges + centers of siblings and the page) with snapping.
|
|
@@ -2746,12 +3376,18 @@ class ReportCanvasComponent {
|
|
|
2746
3376
|
_guides;
|
|
2747
3377
|
PX = PX_PER_INCH;
|
|
2748
3378
|
flowPadding = FLOW_PADDING;
|
|
2749
|
-
|
|
2750
|
-
|
|
3379
|
+
// The report's pages as the canvas draws them. This is the host's own
|
|
3380
|
+
// `report.pages` array, patched in place (a rename, a delete, a reorder) so
|
|
3381
|
+
// the host's exports and page links see the change too. Because the array
|
|
3382
|
+
// and its pages keep their identity across a patch, the signal notifies on
|
|
3383
|
+
// every set rather than only when the reference changes.
|
|
3384
|
+
pages = signal([], { equal: () => false });
|
|
3385
|
+
activePageIndex = signal(0);
|
|
3386
|
+
zoom = signal(1);
|
|
2751
3387
|
// Editing the header and footer (the master layer) rather than the page.
|
|
2752
3388
|
masterMode = signal(false);
|
|
2753
3389
|
groups = new Map();
|
|
2754
|
-
selectedComponentId = null;
|
|
3390
|
+
selectedComponentId = signal(null);
|
|
2755
3391
|
// The snapper handed to every component (stable identity for OnPush).
|
|
2756
3392
|
snapper = {
|
|
2757
3393
|
snapMove: (component, x, y) => this._snapMove(component, x, y),
|
|
@@ -2761,7 +3397,6 @@ class ReportCanvasComponent {
|
|
|
2761
3397
|
_reportData = inject(ReportData);
|
|
2762
3398
|
_prompt = inject(FsPrompt);
|
|
2763
3399
|
_message = inject(FsMessage);
|
|
2764
|
-
_cdRef = inject(ChangeDetectorRef);
|
|
2765
3400
|
_destroyRef = inject(DestroyRef);
|
|
2766
3401
|
_fitted = false;
|
|
2767
3402
|
_tokens = null;
|
|
@@ -2777,11 +3412,12 @@ class ReportCanvasComponent {
|
|
|
2777
3412
|
this.masterMode.set(false);
|
|
2778
3413
|
}
|
|
2779
3414
|
this.groups = new Map((this.report?.filterGroups ?? []).map((group) => [group.id, group]));
|
|
2780
|
-
|
|
3415
|
+
this.pages.set(this.report?.pages ?? []);
|
|
3416
|
+
const pageCount = this.pages().length;
|
|
2781
3417
|
// A page was just added (the host re-fetched the report) — land on it.
|
|
2782
3418
|
const pageAdded = pageCount > this._previousPageCount && this._previousPageCount > 0;
|
|
2783
3419
|
if (pageAdded) {
|
|
2784
|
-
this.activePageIndex
|
|
3420
|
+
this.activePageIndex.set(pageCount - 1);
|
|
2785
3421
|
}
|
|
2786
3422
|
this._previousPageCount = pageCount;
|
|
2787
3423
|
// Honour an incoming deep link, but never over a just-added page: adding a
|
|
@@ -2790,13 +3426,13 @@ class ReportCanvasComponent {
|
|
|
2790
3426
|
// alone rather than forced to the first page — the report may still be
|
|
2791
3427
|
// loading, and the clamp below is the real guard.
|
|
2792
3428
|
if (!pageAdded && this.pageId != null) {
|
|
2793
|
-
const index = this.
|
|
3429
|
+
const index = this.pages().findIndex((page) => page.id === this.pageId);
|
|
2794
3430
|
if (index > -1) {
|
|
2795
|
-
this.activePageIndex
|
|
3431
|
+
this.activePageIndex.set(index);
|
|
2796
3432
|
}
|
|
2797
3433
|
}
|
|
2798
|
-
if (this.activePageIndex >= pageCount) {
|
|
2799
|
-
this.activePageIndex
|
|
3434
|
+
if (this.activePageIndex() >= pageCount) {
|
|
3435
|
+
this.activePageIndex.set(0);
|
|
2800
3436
|
}
|
|
2801
3437
|
// Tell the host which page is open whenever that changes — including the
|
|
2802
3438
|
// FIRST time the report renders, not only when someone clicks a page.
|
|
@@ -2824,7 +3460,7 @@ class ReportCanvasComponent {
|
|
|
2824
3460
|
return this.report?.layout === 'flow';
|
|
2825
3461
|
}
|
|
2826
3462
|
get activePage() {
|
|
2827
|
-
return this.
|
|
3463
|
+
return this.pages()[this.activePageIndex()] ?? null;
|
|
2828
3464
|
}
|
|
2829
3465
|
// The header/footer bands, in inches (0 = no band).
|
|
2830
3466
|
get bands() {
|
|
@@ -2852,8 +3488,8 @@ class ReportCanvasComponent {
|
|
|
2852
3488
|
// of them repaint its html on every change-detection pass.
|
|
2853
3489
|
get tokens() {
|
|
2854
3490
|
const page = this.activePage;
|
|
2855
|
-
const pageNumber = this.activePageIndex + 1;
|
|
2856
|
-
const pageName = page ? this.pageLabel(page, this.activePageIndex) : '';
|
|
3491
|
+
const pageNumber = this.activePageIndex() + 1;
|
|
3492
|
+
const pageName = page ? this.pageLabel(page, this.activePageIndex()) : '';
|
|
2857
3493
|
const reportName = this.report?.name ?? '';
|
|
2858
3494
|
const tokens = this._tokens;
|
|
2859
3495
|
if (tokens?.pageNumber !== pageNumber || tokens.pageName !== pageName || tokens.reportName !== reportName) {
|
|
@@ -2863,7 +3499,7 @@ class ReportCanvasComponent {
|
|
|
2863
3499
|
}
|
|
2864
3500
|
toggleMasterMode() {
|
|
2865
3501
|
this.masterMode.update((value) => !value);
|
|
2866
|
-
this.selectedComponentId
|
|
3502
|
+
this.selectedComponentId.set(null);
|
|
2867
3503
|
}
|
|
2868
3504
|
// Drop a text block where it will be found: into the footer (or header) band
|
|
2869
3505
|
// while editing the header and footer, below the header band on a page. The
|
|
@@ -2901,9 +3537,9 @@ class ReportCanvasComponent {
|
|
|
2901
3537
|
components.splice(index, 1);
|
|
2902
3538
|
}
|
|
2903
3539
|
}
|
|
2904
|
-
this.selectedComponentId
|
|
3540
|
+
this.selectedComponentId.set(null);
|
|
3541
|
+
this.pages.set(this.pages());
|
|
2905
3542
|
this._message.success('Deleted text');
|
|
2906
|
-
this._cdRef.markForCheck();
|
|
2907
3543
|
}
|
|
2908
3544
|
// Show or hide the header and footer on one page, from its tab's kebab.
|
|
2909
3545
|
toggleMasterHidden(page) {
|
|
@@ -2912,8 +3548,8 @@ class ReportCanvasComponent {
|
|
|
2912
3548
|
.pipe(takeUntilDestroyed(this._destroyRef))
|
|
2913
3549
|
.subscribe(() => {
|
|
2914
3550
|
page.masterHidden = masterHidden;
|
|
3551
|
+
this.pages.set(this.pages());
|
|
2915
3552
|
this._message.success(masterHidden ? 'Header & footer hidden on this page' : 'Header & footer shown on this page');
|
|
2916
|
-
this._cdRef.markForCheck();
|
|
2917
3553
|
});
|
|
2918
3554
|
}
|
|
2919
3555
|
// Flow render order (the assembler already sorts, but a local reorder
|
|
@@ -2922,8 +3558,8 @@ class ReportCanvasComponent {
|
|
|
2922
3558
|
return [...(this.activePage?.components ?? [])].sort((a, b) => a.order - b.order);
|
|
2923
3559
|
}
|
|
2924
3560
|
selectPage(index) {
|
|
2925
|
-
this.activePageIndex
|
|
2926
|
-
this.selectedComponentId
|
|
3561
|
+
this.activePageIndex.set(index);
|
|
3562
|
+
this.selectedComponentId.set(null);
|
|
2927
3563
|
// Kept in step with the ngOnChanges announcement so the two can't disagree
|
|
2928
3564
|
// about what the host was last told.
|
|
2929
3565
|
this._emittedPageId = this.activePage?.id ?? null;
|
|
@@ -2959,8 +3595,8 @@ class ReportCanvasComponent {
|
|
|
2959
3595
|
.pipe(map$1(() => name))), takeUntilDestroyed(this._destroyRef))
|
|
2960
3596
|
.subscribe((name) => {
|
|
2961
3597
|
page.name = name;
|
|
3598
|
+
this.pages.set(this.pages());
|
|
2962
3599
|
this._message.success('Saved page name');
|
|
2963
|
-
this._cdRef.markForCheck();
|
|
2964
3600
|
});
|
|
2965
3601
|
}
|
|
2966
3602
|
// Delete from the tab's kebab. The confirm names the page and says the
|
|
@@ -2969,22 +3605,23 @@ class ReportCanvasComponent {
|
|
|
2969
3605
|
deletePage(page) {
|
|
2970
3606
|
this._prompt.confirm({
|
|
2971
3607
|
title: 'Confirm',
|
|
2972
|
-
template: `Are you sure you would like to delete "${this.pageLabel(page, this.
|
|
3608
|
+
template: `Are you sure you would like to delete "${this.pageLabel(page, this.pages().indexOf(page))}" and everything on it?`,
|
|
2973
3609
|
})
|
|
2974
3610
|
.pipe(switchMap(() => this._reportData.deletePage(this.report.id, page.id)), takeUntilDestroyed(this._destroyRef))
|
|
2975
3611
|
.subscribe(() => {
|
|
2976
|
-
const
|
|
2977
|
-
|
|
3612
|
+
const pages = this.pages();
|
|
3613
|
+
const index = pages.indexOf(page);
|
|
3614
|
+
pages.splice(index, 1);
|
|
3615
|
+
this.pages.set(pages);
|
|
2978
3616
|
// Land on the neighbour rather than snapping back to the first page —
|
|
2979
3617
|
// deleting page 4 of 6 should leave you looking at what is now page 4.
|
|
2980
|
-
this.activePageIndex
|
|
2981
|
-
this._previousPageCount =
|
|
2982
|
-
this.selectedComponentId
|
|
3618
|
+
this.activePageIndex.set(Math.min(index, pages.length - 1));
|
|
3619
|
+
this._previousPageCount = pages.length;
|
|
3620
|
+
this.selectedComponentId.set(null);
|
|
2983
3621
|
// The id in the URL just stopped existing — replace it with the
|
|
2984
3622
|
// neighbour we landed on, or a refresh would 404 its way to page one.
|
|
2985
3623
|
this.pageChanged.emit(this.activePage?.id ?? null);
|
|
2986
3624
|
this._message.success('Deleted page');
|
|
2987
|
-
this._cdRef.markForCheck();
|
|
2988
3625
|
});
|
|
2989
3626
|
}
|
|
2990
3627
|
// A tab was dragged to a new slot: reorder locally so the chips settle
|
|
@@ -2995,14 +3632,16 @@ class ReportCanvasComponent {
|
|
|
2995
3632
|
return;
|
|
2996
3633
|
}
|
|
2997
3634
|
const activePage = this.activePage;
|
|
2998
|
-
|
|
2999
|
-
|
|
3635
|
+
const pages = this.pages();
|
|
3636
|
+
moveItemInArray(pages, event.previousIndex, event.currentIndex);
|
|
3637
|
+
this.pages.set(pages);
|
|
3638
|
+
const positions = pages.map((page, order) => {
|
|
3000
3639
|
page.order = order;
|
|
3001
3640
|
return { pageId: page.id, order };
|
|
3002
3641
|
});
|
|
3003
|
-
|
|
3004
|
-
|
|
3005
|
-
|
|
3642
|
+
if (activePage) {
|
|
3643
|
+
this.activePageIndex.set(pages.indexOf(activePage));
|
|
3644
|
+
}
|
|
3006
3645
|
this._reportData.savePagePositions(this.report.id, positions)
|
|
3007
3646
|
.pipe(takeUntilDestroyed(this._destroyRef))
|
|
3008
3647
|
.subscribe();
|
|
@@ -3016,19 +3655,17 @@ class ReportCanvasComponent {
|
|
|
3016
3655
|
?.applySettings(settings);
|
|
3017
3656
|
}
|
|
3018
3657
|
selectComponent(componentId) {
|
|
3019
|
-
this.selectedComponentId
|
|
3020
|
-
this._cdRef.markForCheck();
|
|
3658
|
+
this.selectedComponentId.set(componentId);
|
|
3021
3659
|
}
|
|
3022
3660
|
onCanvasPointerDown() {
|
|
3023
3661
|
// Background click (pan start) deselects.
|
|
3024
|
-
if (this.selectedComponentId !== null) {
|
|
3662
|
+
if (this.selectedComponentId() !== null) {
|
|
3025
3663
|
this.selectComponent(null);
|
|
3026
3664
|
}
|
|
3027
3665
|
}
|
|
3028
3666
|
// ----- zoom toolbar ---------------------------------------------------------
|
|
3029
3667
|
onZoomed(scale) {
|
|
3030
|
-
this.zoom
|
|
3031
|
-
this._cdRef.markForCheck();
|
|
3668
|
+
this.zoom.set(scale);
|
|
3032
3669
|
}
|
|
3033
3670
|
zoomIn() {
|
|
3034
3671
|
this.zoomPan?.zoomIn();
|
|
@@ -3075,7 +3712,7 @@ class ReportCanvasComponent {
|
|
|
3075
3712
|
component.order = order;
|
|
3076
3713
|
return { componentId: component.id, order };
|
|
3077
3714
|
});
|
|
3078
|
-
this.
|
|
3715
|
+
this.pages.set(this.pages());
|
|
3079
3716
|
this._reportData.savePositions(this.report.id, positions)
|
|
3080
3717
|
.pipe(takeUntilDestroyed(this._destroyRef))
|
|
3081
3718
|
.subscribe();
|
|
@@ -3098,7 +3735,7 @@ class ReportCanvasComponent {
|
|
|
3098
3735
|
// ----- smart guides ---------------------------------------------------------
|
|
3099
3736
|
// Pixel feel of the snap, converted to inches at the current zoom.
|
|
3100
3737
|
get _snapThreshold() {
|
|
3101
|
-
return 8 / (PX_PER_INCH * Math.max(0.1, this.zoom));
|
|
3738
|
+
return 8 / (PX_PER_INCH * Math.max(0.1, this.zoom()));
|
|
3102
3739
|
}
|
|
3103
3740
|
_snapMove(component, x, y) {
|
|
3104
3741
|
const { vertical, horizontal } = this._candidates(component);
|
|
@@ -3237,7 +3874,7 @@ class ReportCanvasComponent {
|
|
|
3237
3874
|
this._guides?.nativeElement?.replaceChildren();
|
|
3238
3875
|
}
|
|
3239
3876
|
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: ReportCanvasComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
3240
|
-
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "18.2.14", type: ReportCanvasComponent, isStandalone: true, selector: "app-report-canvas", inputs: { report: "report", editMode: "editMode", pageId: "pageId" }, outputs: { componentSettings: "componentSettings", reportChanged: "reportChanged", pageChanged: "pageChanged" }, viewQueries: [{ propertyName: "zoomPan", first: true, predicate: FsZoomPanComponent, descendants: true }, { propertyName: "_viewport", first: true, predicate: ["viewport"], descendants: true, static: true }, { propertyName: "_guides", first: true, predicate: ["guides"], descendants: true }, { propertyName: "_renderedComponents", predicate: ReportComponentComponent, descendants: true }], usesOnChanges: true, ngImport: i0, template: "<div class=\"canvas\">\n <div\n #viewport\n class=\"page-viewport\"\n (pointerdown)=\"onCanvasPointerDown()\">\n <fs-zoom-pan\n [zoomMin]=\"0.1\"\n [zoomMax]=\"3\"\n (zoomed)=\"onZoomed($event)\">\n @if (activePage; as page) {\n <div\n class=\"page\"\n [class.flow]=\"isFlow\"\n [class.editing]=\"editMode\"\n [class.master-editing]=\"isMasterEditing\"\n [style.width.px]=\"report.pageWidth * PX\"\n [style.height.px]=\"report.pageHeight * PX\"\n [style.padding.px]=\"isFlow ? flowPadding * PX : 0\"\n [style.--canvas-zoom]=\"zoom\"\n [style.--report-heading-size.px]=\"headingSizePx\">\n <!-- The header and footer: the master's components, always freeform,\n drawn under the page's own. Out of the flow (absolute), so a flow\n page's rows are laid out as if it were not there. -->\n @if (isMasterShown && masterComponents.length) {\n <div\n class=\"master-layer\"\n [class.editable]=\"isMasterEditing\">\n @for (component of masterComponents; track component.id) {\n <app-report-component\n [reportId]=\"report.id\"\n [component]=\"component\"\n [groups]=\"groups\"\n [editMode]=\"isMasterEditing\"\n [layout]=\"'freeform'\"\n [zoom]=\"zoom\"\n [snapper]=\"snapper\"\n [tokens]=\"tokens\"\n [selected]=\"component.id === selectedComponentId\"\n [style.left.px]=\"component.x * PX\"\n [style.top.px]=\"component.y * PX\"\n [style.width.px]=\"component.w * PX\"\n [style.height.px]=\"component.autoHeight ? null : component.h * PX\"\n (selectComponent)=\"selectComponent($event)\"\n (positionChanged)=\"onPositionChanged($event)\"\n (removed)=\"onComponentRemoved($event)\"\n (openSettings)=\"componentSettings.emit($event)\">\n </app-report-component>\n }\n </div>\n }\n\n <!-- The header and footer bands, as guides while editing. -->\n @if (editMode) {\n @if (bands.headerHeight) {\n <div\n class=\"band band-header\"\n [style.height.px]=\"bands.headerHeight * PX\">\n @if (isMasterEditing) {\n <span class=\"band-label small\">Header</span>\n }\n </div>\n }\n @if (bands.footerHeight) {\n <div\n class=\"band band-footer\"\n [style.height.px]=\"bands.footerHeight * PX\">\n @if (isMasterEditing) {\n <span class=\"band-label small\">Footer</span>\n }\n </div>\n }\n }\n\n @if (isFlow) {\n @for (component of flowComponents; track component.id) {\n <app-report-component\n class=\"flow-item\"\n [reportId]=\"report.id\"\n [component]=\"component\"\n [groups]=\"groups\"\n [editMode]=\"isPageEditing\"\n [layout]=\"'flow'\"\n [zoom]=\"zoom\"\n [snapper]=\"snapper\"\n [tokens]=\"tokens\"\n [selected]=\"component.id === selectedComponentId\"\n [style.width.%]=\"component.flowWidth\"\n [style.height.px]=\"component.autoHeight ? null : component.h * PX\"\n (selectComponent)=\"selectComponent($event)\"\n (positionChanged)=\"onPositionChanged($event)\"\n (flowReorder)=\"onFlowReorder($event)\"\n (removed)=\"onComponentRemoved($event)\"\n (openSettings)=\"componentSettings.emit($event)\">\n </app-report-component>\n }\n } @else {\n @for (component of page.components; track component.id) {\n <app-report-component\n [reportId]=\"report.id\"\n [component]=\"component\"\n [groups]=\"groups\"\n [editMode]=\"isPageEditing\"\n [layout]=\"'freeform'\"\n [zoom]=\"zoom\"\n [snapper]=\"snapper\"\n [tokens]=\"tokens\"\n [selected]=\"component.id === selectedComponentId\"\n [style.left.px]=\"component.x * PX\"\n [style.top.px]=\"component.y * PX\"\n [style.width.px]=\"component.w * PX\"\n [style.height.px]=\"component.autoHeight ? null : component.h * PX\"\n (selectComponent)=\"selectComponent($event)\"\n (positionChanged)=\"onPositionChanged($event)\"\n (removed)=\"onComponentRemoved($event)\"\n (openSettings)=\"componentSettings.emit($event)\">\n </app-report-component>\n }\n }\n\n <div\n #guides\n class=\"guides\">\n </div>\n\n @if (!page.components.length && !masterMode()) {\n <div class=\"page-empty\">\n Ask the assistant to add a chart, list or KPI to this page.\n </div>\n }\n </div>\n }\n </fs-zoom-pan>\n </div>\n\n <div class=\"canvas-toolbar\">\n <!-- Tabs are one horizontal drop list: dragging a chip reorders the pages.\n The drop INDEX is computed along X (orientation), but the preview is\n deliberately left unbounded on both axes \u2014 pinning it to the row with\n cdkDragBoundary means the chip you are dragging covers the chips you\n are dragging it past, and you cannot see where it is going to land.\n Every page gets a chip, including a lone one: the chip is what tells\n you which page you are on, and a report that hid it left you unable to\n tell page 1 of 1 from a page that failed to load. Deleting the last\n page is refused server-side, so the kebab is safe to expose here. -->\n <div\n class=\"page-tabs\"\n cdkDropList\n cdkDropListOrientation=\"horizontal\"\n (cdkDropListDropped)=\"onPageDropped($event)\">\n @for (page of report?.pages ?? []; track page.id; let index = $index) {\n <!-- The chip is the drag handle AND the tab: clicking selects,\n dragging reorders. The kebab lives inside it, so the whole\n control stays one chip instead of growing a second button. -->\n <div\n cdkDrag\n class=\"page-tab\"\n [class.active]=\"index === activePageIndex\"\n (click)=\"selectPage(index)\">\n <span class=\"page-tab-label\">{{ pageLabel(page, index) }}</span>\n <!-- The kebab opens the menu rather than starting a drag or\n switching pages, so it swallows its own click. -->\n <span\n class=\"page-tab-menu\"\n [fsMenuTriggerFor]=\"pageMenu\"\n (click)=\"$event.stopPropagation()\"\n (mousedown)=\"$event.stopPropagation()\">\n <mat-icon>more_vert</mat-icon>\n </span>\n </div>\n <!-- Outside the chip: cdkDrag clones its host into the drag preview,\n and a menu overlay anchored to a cloned trigger opens detached\n from the real tab. -->\n <fs-menu #pageMenu>\n <ng-template\n fs-menu-item\n (click)=\"renamePage(page)\">\n Edit name\n </ng-template>\n <ng-template\n fs-menu-item\n (click)=\"toggleMasterHidden(page)\">\n {{ page.masterHidden ? 'Show header & footer' : 'Hide header & footer' }}\n </ng-template>\n <ng-template\n fs-menu-item\n (click)=\"deletePage(page)\">\n Delete\n </ng-template>\n </fs-menu>\n }\n <button\n type=\"button\"\n class=\"page-tab add-page\"\n matTooltip=\"Add page\"\n (click)=\"addPage()\">\n <mat-icon>add</mat-icon>\n Page\n </button>\n </div>\n\n <div class=\"zoom-controls\">\n <button\n type=\"button\"\n class=\"zoom-button\"\n matTooltip=\"Zoom out\"\n (click)=\"zoomOut()\">\n <mat-icon>remove</mat-icon>\n </button>\n <button\n type=\"button\"\n class=\"zoom-label\"\n matTooltip=\"Zoom to 100%\"\n (click)=\"zoomActual()\">\n {{ (zoom * 100).toFixed(0) }}%\n </button>\n <button\n type=\"button\"\n class=\"zoom-button\"\n matTooltip=\"Zoom in\"\n (click)=\"zoomIn()\">\n <mat-icon>add</mat-icon>\n </button>\n <button\n type=\"button\"\n class=\"zoom-button\"\n matTooltip=\"Fit page\"\n (click)=\"zoomFit()\">\n <mat-icon>fit_screen</mat-icon>\n </button>\n </div>\n </div>\n</div>\n", styles: [":host{display:flex;flex-direction:column;min-width:0;min-height:0;flex:1;margin-top:10px}.canvas{display:flex;flex-direction:column;gap:8px;flex:1;min-height:0}.canvas-toolbar{display:flex;align-items:center;justify-content:space-between;gap:8px}.canvas-toolbar .page-tabs{display:flex;align-items:center;gap:4px;flex-wrap:wrap}.canvas-toolbar .page-tabs fs-menu{display:none}.canvas-toolbar .page-tabs .page-tab{display:inline-flex;align-items:center;box-sizing:border-box;border:1px solid #e4e7eb;border-radius:16px;background:#fff;padding:4px 6px 4px 14px;font-size:12px;line-height:16px;color:#52606d;cursor:pointer}.canvas-toolbar .page-tabs .page-tab.active{background:var(--brand-primary-color, #3b82f6);border-color:var(--brand-primary-color, #3b82f6);color:#fff}.canvas-toolbar .page-tabs .page-tab .page-tab-label{white-space:nowrap}.canvas-toolbar .page-tabs .page-tab .page-tab-menu{display:inline-flex;align-items:center;justify-content:center;flex:0 0 auto;width:16px;height:16px;margin-left:2px;border-radius:50%;opacity:.75}.canvas-toolbar .page-tabs .page-tab .page-tab-menu mat-icon{font-size:14px;width:14px;height:14px;line-height:14px}.canvas-toolbar .page-tabs .page-tab .page-tab-menu:hover{background:#0f172a14;opacity:1}.canvas-toolbar .page-tabs .page-tab.active .page-tab-menu:hover{background:#ffffff38}.canvas-toolbar .page-tabs .page-tab.add-page{gap:2px;padding:4px 14px 4px 8px;border-style:dashed;color:#52606d}.canvas-toolbar .page-tabs .page-tab.add-page mat-icon{font-size:16px;width:16px;height:16px}.canvas-toolbar .page-tabs .page-tab.cdk-drag-placeholder{background:transparent;border-style:dashed;border-color:#cbd2d9}.canvas-toolbar .page-tabs .page-tab.cdk-drag-placeholder .page-tab-label,.canvas-toolbar .page-tabs .page-tab.cdk-drag-placeholder .page-tab-menu{visibility:hidden}.canvas-toolbar .page-tabs .cdk-drag-animating,.canvas-toolbar .page-tabs.cdk-drop-list-dragging .page-tab:not(.cdk-drag-placeholder){transition:transform .18s cubic-bezier(0,0,.2,1)}.canvas-toolbar .zoom-controls{display:flex;align-items:center;gap:2px;margin-left:auto;border:1px solid #e4e7eb;border-radius:18px;background:#fff;padding:2px 6px}.canvas-toolbar .zoom-controls .zoom-button{display:flex;align-items:center;justify-content:center;border:none;border-radius:50%;background:transparent;color:#52606d;cursor:pointer}.canvas-toolbar .zoom-controls .zoom-button:hover{background:#f0f4f8}.canvas-toolbar .zoom-controls .zoom-button mat-icon{font-size:18px;width:18px;height:18px}.canvas-toolbar .zoom-controls .zoom-label{border:none;background:transparent;font-size:12px;color:#52606d;min-width:44px;cursor:pointer}.canvas-toolbar .zoom-controls .zoom-label:hover{color:#1f2933}.page-tab.cdk-drag-preview{display:inline-flex;align-items:center;box-sizing:border-box;border:1px solid #e4e7eb;border-radius:16px;background:#fff;padding:4px 6px 4px 14px;font-size:12px;line-height:16px;color:#52606d;box-shadow:0 3px 10px #0f172a3d}.page-tab.cdk-drag-preview.active{background:var(--brand-primary-color, #3b82f6);border-color:var(--brand-primary-color, #3b82f6);color:#fff}.page-tab.cdk-drag-preview .page-tab-label{white-space:nowrap}.page-tab.cdk-drag-preview .page-tab-menu{display:inline-flex;align-items:center;justify-content:center;flex:0 0 auto;width:16px;height:16px;margin-left:2px;border-radius:50%;opacity:.75}.page-tab.cdk-drag-preview .page-tab-menu mat-icon{font-size:14px;width:14px;height:14px;line-height:14px}.page-viewport{position:relative;flex:1;min-height:420px;border:1px solid #e4e7eb;border-radius:8px;background:#eef1f5;overflow:hidden}.page-viewport fs-zoom-pan{width:100%;height:100%}.page{position:relative;background:#fff;border-radius:2px;box-shadow:0 2px 12px #0f172a1f,0 0 0 1px #0f172a0a;box-sizing:border-box}.page.flow{display:flex;flex-wrap:wrap;align-content:flex-start;align-items:flex-start}.page.editing{background-image:radial-gradient(circle,#d8dee6 1px,transparent 1px);background-size:24px 24px}.page.master-editing>app-report-component{opacity:.35;pointer-events:none}.master-layer{position:absolute;inset:0;pointer-events:none}.master-layer.editable{z-index:10}.master-layer.editable app-report-component{pointer-events:auto}.band{position:absolute;left:0;right:0;z-index:5;pointer-events:none;background:#2196f30a;border-color:#2196f373;border-style:dashed;border-width:0}.band.band-header{top:0;border-bottom-width:calc(1px / var(--canvas-zoom, 1))}.band.band-footer{bottom:0;border-top-width:calc(1px / var(--canvas-zoom, 1))}.band .band-label{position:absolute;top:calc(10px / var(--canvas-zoom, 1));left:calc(10px / var(--canvas-zoom, 1));transform:scale(calc(1 / var(--canvas-zoom, 1)));transform-origin:top left;color:#2196f3cc;font-weight:600;text-transform:uppercase;letter-spacing:.06em}.guides{position:absolute;inset:0;pointer-events:none;z-index:30}.guides ::ng-deep .guide{position:absolute;background:#2196f3}.guides ::ng-deep .guide.guide-v{top:0;bottom:0;width:calc(1.25px / var(--canvas-zoom, 1))}.guides ::ng-deep .guide.guide-h{left:0;right:0;height:calc(1.25px / var(--canvas-zoom, 1))}.page-empty{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;color:#9aa5b1;font-size:14px;pointer-events:none}\n"], dependencies: [{ kind: "ngmodule", type: DragDropModule }, { kind: "directive", type: i1$3.CdkDropList, selector: "[cdkDropList], cdk-drop-list", inputs: ["cdkDropListConnectedTo", "cdkDropListData", "cdkDropListOrientation", "id", "cdkDropListLockAxis", "cdkDropListDisabled", "cdkDropListSortingDisabled", "cdkDropListEnterPredicate", "cdkDropListSortPredicate", "cdkDropListAutoScrollDisabled", "cdkDropListAutoScrollStep", "cdkDropListElementContainer"], outputs: ["cdkDropListDropped", "cdkDropListEntered", "cdkDropListExited", "cdkDropListSorted"], exportAs: ["cdkDropList"] }, { kind: "directive", type: i1$3.CdkDrag, selector: "[cdkDrag]", inputs: ["cdkDragData", "cdkDragLockAxis", "cdkDragRootElement", "cdkDragBoundary", "cdkDragStartDelay", "cdkDragFreeDragPosition", "cdkDragDisabled", "cdkDragConstrainPosition", "cdkDragPreviewClass", "cdkDragPreviewContainer", "cdkDragScale"], outputs: ["cdkDragStarted", "cdkDragReleased", "cdkDragEnded", "cdkDragEntered", "cdkDragExited", "cdkDragDropped", "cdkDragMoved"], exportAs: ["cdkDrag"] }, { kind: "component", type: MatIcon, selector: "mat-icon", inputs: ["color", "inline", "svgIcon", "fontSet", "fontIcon"], exportAs: ["matIcon"] }, { kind: "directive", type: MatTooltip, selector: "[matTooltip]", inputs: ["matTooltipPosition", "matTooltipPositionAtOrigin", "matTooltipDisabled", "matTooltipShowDelay", "matTooltipHideDelay", "matTooltipTouchGestures", "matTooltip", "matTooltipClass"], exportAs: ["matTooltip"] }, { kind: "ngmodule", type: FsMenuModule }, { kind: "component", type: i2$1.FsMenuComponent, selector: "fs-menu", inputs: ["class", "buttonClass", "buttonType", "buttonColor"], outputs: ["opened", "closed"] }, { kind: "directive", type: i2$1.FsMenuItemDirective, selector: "fs-menu-group,[fs-menu-item]" }, { kind: "directive", type: i2$1.FsMenuTriggerDirective, selector: "[fsMenuTriggerFor]", inputs: ["fsMenuTriggerFor"] }, { kind: "ngmodule", type: FsZoomPanModule }, { kind: "component", type: i3.FsZoomPanComponent, selector: "fs-zoom-pan", inputs: ["zoomMax", "zoomMin", "zoomScale", "zoomDefault", "zoomFactor", "top", "left"], outputs: ["moved", "zoomed"] }, { kind: "component", type: ReportComponentComponent, selector: "app-report-component", inputs: ["reportId", "component", "groups", "editMode", "layout", "zoom", "snapper", "selected", "tokens"], outputs: ["selectComponent", "positionChanged", "flowReorder", "openSettings", "removed"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
3877
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "18.2.14", type: ReportCanvasComponent, isStandalone: true, selector: "app-report-canvas", inputs: { report: "report", editMode: "editMode", pageId: "pageId" }, outputs: { componentSettings: "componentSettings", reportChanged: "reportChanged", pageChanged: "pageChanged" }, viewQueries: [{ propertyName: "zoomPan", first: true, predicate: FsZoomPanComponent, descendants: true }, { propertyName: "_viewport", first: true, predicate: ["viewport"], descendants: true, static: true }, { propertyName: "_guides", first: true, predicate: ["guides"], descendants: true }, { propertyName: "_renderedComponents", predicate: ReportComponentComponent, descendants: true }], usesOnChanges: true, ngImport: i0, template: "<div class=\"canvas\">\n <div\n #viewport\n class=\"page-viewport\"\n (pointerdown)=\"onCanvasPointerDown()\">\n <fs-zoom-pan\n wheel=\"modifier\"\n [zoomMin]=\"0.1\"\n [zoomMax]=\"3\"\n (zoomed)=\"onZoomed($event)\">\n @if (activePage; as page) {\n <div\n class=\"page\"\n [class.flow]=\"isFlow\"\n [class.editing]=\"editMode\"\n [class.master-editing]=\"isMasterEditing\"\n [style.width.px]=\"report.pageWidth * PX\"\n [style.height.px]=\"report.pageHeight * PX\"\n [style.padding.px]=\"isFlow ? flowPadding * PX : 0\"\n [style.--canvas-zoom]=\"zoom()\"\n [style.--report-heading-size.px]=\"headingSizePx\">\n <!-- The header and footer: the master's components, always freeform,\n drawn under the page's own. Out of the flow (absolute), so a flow\n page's rows are laid out as if it were not there. -->\n @if (isMasterShown && masterComponents.length) {\n <div\n class=\"master-layer\"\n [class.editable]=\"isMasterEditing\">\n @for (component of masterComponents; track component.id) {\n <app-report-component\n [reportId]=\"report.id\"\n [component]=\"component\"\n [groups]=\"groups\"\n [editMode]=\"isMasterEditing\"\n [layout]=\"'freeform'\"\n [zoom]=\"zoom()\"\n [snapper]=\"snapper\"\n [tokens]=\"tokens\"\n [selected]=\"component.id === selectedComponentId()\"\n [style.left.px]=\"component.x * PX\"\n [style.top.px]=\"component.y * PX\"\n [style.width.px]=\"component.w * PX\"\n [style.height.px]=\"component.autoHeight ? null : component.h * PX\"\n (selectComponent)=\"selectComponent($event)\"\n (positionChanged)=\"onPositionChanged($event)\"\n (removed)=\"onComponentRemoved($event)\"\n (openSettings)=\"componentSettings.emit($event)\">\n </app-report-component>\n }\n </div>\n }\n\n <!-- The header and footer bands, as guides while editing. -->\n @if (editMode) {\n @if (bands.headerHeight) {\n <div\n class=\"band band-header\"\n [style.height.px]=\"bands.headerHeight * PX\">\n @if (isMasterEditing) {\n <span class=\"band-label small\">Header</span>\n }\n </div>\n }\n @if (bands.footerHeight) {\n <div\n class=\"band band-footer\"\n [style.height.px]=\"bands.footerHeight * PX\">\n @if (isMasterEditing) {\n <span class=\"band-label small\">Footer</span>\n }\n </div>\n }\n }\n\n @if (isFlow) {\n @for (component of flowComponents; track component.id) {\n <app-report-component\n class=\"flow-item\"\n [reportId]=\"report.id\"\n [component]=\"component\"\n [groups]=\"groups\"\n [editMode]=\"isPageEditing\"\n [layout]=\"'flow'\"\n [zoom]=\"zoom()\"\n [snapper]=\"snapper\"\n [tokens]=\"tokens\"\n [selected]=\"component.id === selectedComponentId()\"\n [style.width.%]=\"component.flowWidth\"\n [style.height.px]=\"component.autoHeight ? null : component.h * PX\"\n (selectComponent)=\"selectComponent($event)\"\n (positionChanged)=\"onPositionChanged($event)\"\n (flowReorder)=\"onFlowReorder($event)\"\n (removed)=\"onComponentRemoved($event)\"\n (openSettings)=\"componentSettings.emit($event)\">\n </app-report-component>\n }\n } @else {\n @for (component of page.components; track component.id) {\n <app-report-component\n [reportId]=\"report.id\"\n [component]=\"component\"\n [groups]=\"groups\"\n [editMode]=\"isPageEditing\"\n [layout]=\"'freeform'\"\n [zoom]=\"zoom()\"\n [snapper]=\"snapper\"\n [tokens]=\"tokens\"\n [selected]=\"component.id === selectedComponentId()\"\n [style.left.px]=\"component.x * PX\"\n [style.top.px]=\"component.y * PX\"\n [style.width.px]=\"component.w * PX\"\n [style.height.px]=\"component.autoHeight ? null : component.h * PX\"\n (selectComponent)=\"selectComponent($event)\"\n (positionChanged)=\"onPositionChanged($event)\"\n (removed)=\"onComponentRemoved($event)\"\n (openSettings)=\"componentSettings.emit($event)\">\n </app-report-component>\n }\n }\n\n <div\n #guides\n class=\"guides\">\n </div>\n\n @if (!page.components.length && !masterMode()) {\n <div class=\"page-empty\">\n Ask the assistant to add a chart, list or KPI to this page.\n </div>\n }\n </div>\n }\n </fs-zoom-pan>\n </div>\n\n <div class=\"canvas-toolbar\">\n <!-- Tabs are one horizontal drop list: dragging a chip reorders the pages.\n The drop INDEX is computed along X (orientation), but the preview is\n deliberately left unbounded on both axes \u2014 pinning it to the row with\n cdkDragBoundary means the chip you are dragging covers the chips you\n are dragging it past, and you cannot see where it is going to land.\n Every page gets a chip, including a lone one: the chip is what tells\n you which page you are on, and a report that hid it left you unable to\n tell page 1 of 1 from a page that failed to load. Deleting the last\n page is refused server-side, so the kebab is safe to expose here. -->\n <div\n class=\"page-tabs\"\n cdkDropList\n cdkDropListOrientation=\"horizontal\"\n (cdkDropListDropped)=\"onPageDropped($event)\">\n @for (page of pages(); track page.id; let index = $index) {\n <!-- The chip is the drag handle AND the tab: clicking selects,\n dragging reorders. The kebab lives inside it, so the whole\n control stays one chip instead of growing a second button. -->\n <div\n cdkDrag\n class=\"page-tab\"\n [class.active]=\"index === activePageIndex()\"\n (click)=\"selectPage(index)\">\n <span class=\"page-tab-label\">{{ pageLabel(page, index) }}</span>\n <!-- The kebab opens the menu rather than starting a drag or\n switching pages, so it swallows its own click. -->\n <span\n class=\"page-tab-menu\"\n [fsMenuTriggerFor]=\"pageMenu\"\n (click)=\"$event.stopPropagation()\"\n (mousedown)=\"$event.stopPropagation()\">\n <mat-icon>more_vert</mat-icon>\n </span>\n </div>\n <!-- Outside the chip: cdkDrag clones its host into the drag preview,\n and a menu overlay anchored to a cloned trigger opens detached\n from the real tab. -->\n <fs-menu #pageMenu>\n <ng-template\n fs-menu-item\n (click)=\"renamePage(page)\">\n Edit name\n </ng-template>\n <ng-template\n fs-menu-item\n (click)=\"toggleMasterHidden(page)\">\n {{ page.masterHidden ? 'Show header & footer' : 'Hide header & footer' }}\n </ng-template>\n <ng-template\n fs-menu-item\n (click)=\"deletePage(page)\">\n Delete\n </ng-template>\n </fs-menu>\n }\n <button\n type=\"button\"\n class=\"page-tab add-page\"\n matTooltip=\"Add page\"\n (click)=\"addPage()\">\n <mat-icon>add</mat-icon>\n Page\n </button>\n </div>\n\n <div class=\"zoom-controls\">\n <button\n type=\"button\"\n class=\"zoom-button\"\n matTooltip=\"Zoom out (Ctrl/Cmd + scroll)\"\n (click)=\"zoomOut()\">\n <mat-icon>remove</mat-icon>\n </button>\n <button\n type=\"button\"\n class=\"zoom-label\"\n matTooltip=\"Zoom to 100%\"\n (click)=\"zoomActual()\">\n {{ (zoom() * 100).toFixed(0) }}%\n </button>\n <button\n type=\"button\"\n class=\"zoom-button\"\n matTooltip=\"Zoom in (Ctrl/Cmd + scroll)\"\n (click)=\"zoomIn()\">\n <mat-icon>add</mat-icon>\n </button>\n <button\n type=\"button\"\n class=\"zoom-button\"\n matTooltip=\"Fit page\"\n (click)=\"zoomFit()\">\n <mat-icon>fit_screen</mat-icon>\n </button>\n </div>\n </div>\n</div>\n", styles: [":host{display:flex;flex-direction:column;min-width:0;min-height:0;flex:1;margin-top:10px}.canvas{display:flex;flex-direction:column;gap:8px;flex:1;min-height:0}.canvas-toolbar{display:flex;align-items:center;justify-content:space-between;gap:8px}.canvas-toolbar .page-tabs{display:flex;align-items:center;gap:4px;flex-wrap:wrap}.canvas-toolbar .page-tabs fs-menu{display:none}.canvas-toolbar .page-tabs .page-tab{display:inline-flex;align-items:center;box-sizing:border-box;border:1px solid #e4e7eb;border-radius:16px;background:#fff;padding:4px 6px 4px 14px;font-size:12px;line-height:16px;color:#52606d;cursor:pointer}.canvas-toolbar .page-tabs .page-tab.active{background:var(--brand-primary-color, #3b82f6);border-color:var(--brand-primary-color, #3b82f6);color:#fff}.canvas-toolbar .page-tabs .page-tab .page-tab-label{white-space:nowrap}.canvas-toolbar .page-tabs .page-tab .page-tab-menu{display:inline-flex;align-items:center;justify-content:center;flex:0 0 auto;width:16px;height:16px;margin-left:2px;border-radius:50%;opacity:.75}.canvas-toolbar .page-tabs .page-tab .page-tab-menu mat-icon{font-size:14px;width:14px;height:14px;line-height:14px}.canvas-toolbar .page-tabs .page-tab .page-tab-menu:hover{background:#0f172a14;opacity:1}.canvas-toolbar .page-tabs .page-tab.active .page-tab-menu:hover{background:#ffffff38}.canvas-toolbar .page-tabs .page-tab.add-page{gap:2px;padding:4px 14px 4px 8px;border-style:dashed;color:#52606d}.canvas-toolbar .page-tabs .page-tab.add-page mat-icon{font-size:16px;width:16px;height:16px}.canvas-toolbar .page-tabs .page-tab.cdk-drag-placeholder{background:transparent;border-style:dashed;border-color:#cbd2d9}.canvas-toolbar .page-tabs .page-tab.cdk-drag-placeholder .page-tab-label,.canvas-toolbar .page-tabs .page-tab.cdk-drag-placeholder .page-tab-menu{visibility:hidden}.canvas-toolbar .page-tabs .cdk-drag-animating,.canvas-toolbar .page-tabs.cdk-drop-list-dragging .page-tab:not(.cdk-drag-placeholder){transition:transform .18s cubic-bezier(0,0,.2,1)}.canvas-toolbar .zoom-controls{display:flex;align-items:center;gap:2px;margin-left:auto;border:1px solid #e4e7eb;border-radius:18px;background:#fff;padding:2px 6px}.canvas-toolbar .zoom-controls .zoom-button{display:flex;align-items:center;justify-content:center;border:none;border-radius:50%;background:transparent;color:#52606d;cursor:pointer}.canvas-toolbar .zoom-controls .zoom-button:hover{background:#f0f4f8}.canvas-toolbar .zoom-controls .zoom-button mat-icon{font-size:18px;width:18px;height:18px}.canvas-toolbar .zoom-controls .zoom-label{border:none;background:transparent;font-size:12px;color:#52606d;min-width:44px;cursor:pointer}.canvas-toolbar .zoom-controls .zoom-label:hover{color:#1f2933}.page-tab.cdk-drag-preview{display:inline-flex;align-items:center;box-sizing:border-box;border:1px solid #e4e7eb;border-radius:16px;background:#fff;padding:4px 6px 4px 14px;font-size:12px;line-height:16px;color:#52606d;box-shadow:0 3px 10px #0f172a3d}.page-tab.cdk-drag-preview.active{background:var(--brand-primary-color, #3b82f6);border-color:var(--brand-primary-color, #3b82f6);color:#fff}.page-tab.cdk-drag-preview .page-tab-label{white-space:nowrap}.page-tab.cdk-drag-preview .page-tab-menu{display:inline-flex;align-items:center;justify-content:center;flex:0 0 auto;width:16px;height:16px;margin-left:2px;border-radius:50%;opacity:.75}.page-tab.cdk-drag-preview .page-tab-menu mat-icon{font-size:14px;width:14px;height:14px;line-height:14px}.page-viewport{position:relative;flex:1;min-height:420px;border:1px solid #e4e7eb;border-radius:8px;background:#eef1f5;overflow:hidden}.page-viewport fs-zoom-pan{width:100%;height:100%}.page{position:relative;background:#fff;border-radius:2px;box-shadow:0 2px 12px #0f172a1f,0 0 0 1px #0f172a0a;box-sizing:border-box}.page.flow{display:flex;flex-wrap:wrap;align-content:flex-start;align-items:flex-start}.page.editing{background-image:radial-gradient(circle,#d8dee6 1px,transparent 1px);background-size:24px 24px}.page.master-editing>app-report-component{opacity:.35;pointer-events:none}.master-layer{position:absolute;inset:0;pointer-events:none}.master-layer.editable{z-index:10}.master-layer.editable app-report-component{pointer-events:auto}.band{position:absolute;left:0;right:0;z-index:5;pointer-events:none;background:#2196f30a;border-color:#2196f373;border-style:dashed;border-width:0}.band.band-header{top:0;border-bottom-width:calc(1px / var(--canvas-zoom, 1))}.band.band-footer{bottom:0;border-top-width:calc(1px / var(--canvas-zoom, 1))}.band .band-label{position:absolute;top:calc(10px / var(--canvas-zoom, 1));left:calc(10px / var(--canvas-zoom, 1));transform:scale(calc(1 / var(--canvas-zoom, 1)));transform-origin:top left;color:#2196f3cc;font-weight:600;text-transform:uppercase;letter-spacing:.06em}.guides{position:absolute;inset:0;pointer-events:none;z-index:30}.guides ::ng-deep .guide{position:absolute;background:#2196f3}.guides ::ng-deep .guide.guide-v{top:0;bottom:0;width:calc(1.25px / var(--canvas-zoom, 1))}.guides ::ng-deep .guide.guide-h{left:0;right:0;height:calc(1.25px / var(--canvas-zoom, 1))}.page-empty{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;color:#9aa5b1;font-size:14px;pointer-events:none}\n"], dependencies: [{ kind: "ngmodule", type: DragDropModule }, { kind: "directive", type: i1$3.CdkDropList, selector: "[cdkDropList], cdk-drop-list", inputs: ["cdkDropListConnectedTo", "cdkDropListData", "cdkDropListOrientation", "id", "cdkDropListLockAxis", "cdkDropListDisabled", "cdkDropListSortingDisabled", "cdkDropListEnterPredicate", "cdkDropListSortPredicate", "cdkDropListAutoScrollDisabled", "cdkDropListAutoScrollStep", "cdkDropListElementContainer"], outputs: ["cdkDropListDropped", "cdkDropListEntered", "cdkDropListExited", "cdkDropListSorted"], exportAs: ["cdkDropList"] }, { kind: "directive", type: i1$3.CdkDrag, selector: "[cdkDrag]", inputs: ["cdkDragData", "cdkDragLockAxis", "cdkDragRootElement", "cdkDragBoundary", "cdkDragStartDelay", "cdkDragFreeDragPosition", "cdkDragDisabled", "cdkDragConstrainPosition", "cdkDragPreviewClass", "cdkDragPreviewContainer", "cdkDragScale"], outputs: ["cdkDragStarted", "cdkDragReleased", "cdkDragEnded", "cdkDragEntered", "cdkDragExited", "cdkDragDropped", "cdkDragMoved"], exportAs: ["cdkDrag"] }, { kind: "component", type: MatIcon, selector: "mat-icon", inputs: ["color", "inline", "svgIcon", "fontSet", "fontIcon"], exportAs: ["matIcon"] }, { kind: "directive", type: MatTooltip, selector: "[matTooltip]", inputs: ["matTooltipPosition", "matTooltipPositionAtOrigin", "matTooltipDisabled", "matTooltipShowDelay", "matTooltipHideDelay", "matTooltipTouchGestures", "matTooltip", "matTooltipClass"], exportAs: ["matTooltip"] }, { kind: "ngmodule", type: FsMenuModule }, { kind: "component", type: i2$1.FsMenuComponent, selector: "fs-menu", inputs: ["class", "buttonClass", "buttonType", "buttonColor"], outputs: ["opened", "closed"] }, { kind: "directive", type: i2$1.FsMenuItemDirective, selector: "fs-menu-group,[fs-menu-item]" }, { kind: "directive", type: i2$1.FsMenuTriggerDirective, selector: "[fsMenuTriggerFor]", inputs: ["fsMenuTriggerFor"] }, { kind: "ngmodule", type: FsZoomPanModule }, { kind: "component", type: i3.FsZoomPanComponent, selector: "fs-zoom-pan", inputs: ["zoomMax", "zoomMin", "zoomScale", "zoomDefault", "zoomFactor", "zoomFit", "fitMode", "fitPosition", "fitPadding", "fitMaxScale", "wheel", "top", "left"], outputs: ["moved", "zoomed"] }, { kind: "component", type: ReportComponentComponent, selector: "app-report-component", inputs: ["reportId", "component", "groups", "editMode", "layout", "zoom", "snapper", "selected", "tokens"], outputs: ["selectComponent", "positionChanged", "flowReorder", "openSettings", "removed"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
3241
3878
|
}
|
|
3242
3879
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: ReportCanvasComponent, decorators: [{
|
|
3243
3880
|
type: Component,
|
|
@@ -3248,7 +3885,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImpo
|
|
|
3248
3885
|
FsMenuModule,
|
|
3249
3886
|
FsZoomPanModule,
|
|
3250
3887
|
ReportComponentComponent,
|
|
3251
|
-
], template: "<div class=\"canvas\">\n <div\n #viewport\n class=\"page-viewport\"\n (pointerdown)=\"onCanvasPointerDown()\">\n <fs-zoom-pan\n [zoomMin]=\"0.1\"\n [zoomMax]=\"3\"\n (zoomed)=\"onZoomed($event)\">\n @if (activePage; as page) {\n <div\n class=\"page\"\n [class.flow]=\"isFlow\"\n [class.editing]=\"editMode\"\n [class.master-editing]=\"isMasterEditing\"\n [style.width.px]=\"report.pageWidth * PX\"\n [style.height.px]=\"report.pageHeight * PX\"\n [style.padding.px]=\"isFlow ? flowPadding * PX : 0\"\n [style.--canvas-zoom]=\"zoom\"\n [style.--report-heading-size.px]=\"headingSizePx\">\n <!-- The header and footer: the master's components, always freeform,\n drawn under the page's own. Out of the flow (absolute), so a flow\n page's rows are laid out as if it were not there. -->\n @if (isMasterShown && masterComponents.length) {\n <div\n class=\"master-layer\"\n [class.editable]=\"isMasterEditing\">\n @for (component of masterComponents; track component.id) {\n <app-report-component\n [reportId]=\"report.id\"\n [component]=\"component\"\n [groups]=\"groups\"\n [editMode]=\"isMasterEditing\"\n [layout]=\"'freeform'\"\n [zoom]=\"zoom\"\n [snapper]=\"snapper\"\n [tokens]=\"tokens\"\n [selected]=\"component.id === selectedComponentId\"\n [style.left.px]=\"component.x * PX\"\n [style.top.px]=\"component.y * PX\"\n [style.width.px]=\"component.w * PX\"\n [style.height.px]=\"component.autoHeight ? null : component.h * PX\"\n (selectComponent)=\"selectComponent($event)\"\n (positionChanged)=\"onPositionChanged($event)\"\n (removed)=\"onComponentRemoved($event)\"\n (openSettings)=\"componentSettings.emit($event)\">\n </app-report-component>\n }\n </div>\n }\n\n <!-- The header and footer bands, as guides while editing. -->\n @if (editMode) {\n @if (bands.headerHeight) {\n <div\n class=\"band band-header\"\n [style.height.px]=\"bands.headerHeight * PX\">\n @if (isMasterEditing) {\n <span class=\"band-label small\">Header</span>\n }\n </div>\n }\n @if (bands.footerHeight) {\n <div\n class=\"band band-footer\"\n [style.height.px]=\"bands.footerHeight * PX\">\n @if (isMasterEditing) {\n <span class=\"band-label small\">Footer</span>\n }\n </div>\n }\n }\n\n @if (isFlow) {\n @for (component of flowComponents; track component.id) {\n <app-report-component\n class=\"flow-item\"\n [reportId]=\"report.id\"\n [component]=\"component\"\n [groups]=\"groups\"\n [editMode]=\"isPageEditing\"\n [layout]=\"'flow'\"\n [zoom]=\"zoom\"\n [snapper]=\"snapper\"\n [tokens]=\"tokens\"\n [selected]=\"component.id === selectedComponentId\"\n [style.width.%]=\"component.flowWidth\"\n [style.height.px]=\"component.autoHeight ? null : component.h * PX\"\n (selectComponent)=\"selectComponent($event)\"\n (positionChanged)=\"onPositionChanged($event)\"\n (flowReorder)=\"onFlowReorder($event)\"\n (removed)=\"onComponentRemoved($event)\"\n (openSettings)=\"componentSettings.emit($event)\">\n </app-report-component>\n }\n } @else {\n @for (component of page.components; track component.id) {\n <app-report-component\n [reportId]=\"report.id\"\n [component]=\"component\"\n [groups]=\"groups\"\n [editMode]=\"isPageEditing\"\n [layout]=\"'freeform'\"\n [zoom]=\"zoom\"\n [snapper]=\"snapper\"\n [tokens]=\"tokens\"\n [selected]=\"component.id === selectedComponentId\"\n [style.left.px]=\"component.x * PX\"\n [style.top.px]=\"component.y * PX\"\n [style.width.px]=\"component.w * PX\"\n [style.height.px]=\"component.autoHeight ? null : component.h * PX\"\n (selectComponent)=\"selectComponent($event)\"\n (positionChanged)=\"onPositionChanged($event)\"\n (removed)=\"onComponentRemoved($event)\"\n (openSettings)=\"componentSettings.emit($event)\">\n </app-report-component>\n }\n }\n\n <div\n #guides\n class=\"guides\">\n </div>\n\n @if (!page.components.length && !masterMode()) {\n <div class=\"page-empty\">\n Ask the assistant to add a chart, list or KPI to this page.\n </div>\n }\n </div>\n }\n </fs-zoom-pan>\n </div>\n\n <div class=\"canvas-toolbar\">\n <!-- Tabs are one horizontal drop list: dragging a chip reorders the pages.\n The drop INDEX is computed along X (orientation), but the preview is\n deliberately left unbounded on both axes \u2014 pinning it to the row with\n cdkDragBoundary means the chip you are dragging covers the chips you\n are dragging it past, and you cannot see where it is going to land.\n Every page gets a chip, including a lone one: the chip is what tells\n you which page you are on, and a report that hid it left you unable to\n tell page 1 of 1 from a page that failed to load. Deleting the last\n page is refused server-side, so the kebab is safe to expose here. -->\n <div\n class=\"page-tabs\"\n cdkDropList\n cdkDropListOrientation=\"horizontal\"\n (cdkDropListDropped)=\"onPageDropped($event)\">\n @for (page of report?.pages ?? []; track page.id; let index = $index) {\n <!-- The chip is the drag handle AND the tab: clicking selects,\n dragging reorders. The kebab lives inside it, so the whole\n control stays one chip instead of growing a second button. -->\n <div\n cdkDrag\n class=\"page-tab\"\n [class.active]=\"index === activePageIndex\"\n (click)=\"selectPage(index)\">\n <span class=\"page-tab-label\">{{ pageLabel(page, index) }}</span>\n <!-- The kebab opens the menu rather than starting a drag or\n switching pages, so it swallows its own click. -->\n <span\n class=\"page-tab-menu\"\n [fsMenuTriggerFor]=\"pageMenu\"\n (click)=\"$event.stopPropagation()\"\n (mousedown)=\"$event.stopPropagation()\">\n <mat-icon>more_vert</mat-icon>\n </span>\n </div>\n <!-- Outside the chip: cdkDrag clones its host into the drag preview,\n and a menu overlay anchored to a cloned trigger opens detached\n from the real tab. -->\n <fs-menu #pageMenu>\n <ng-template\n fs-menu-item\n (click)=\"renamePage(page)\">\n Edit name\n </ng-template>\n <ng-template\n fs-menu-item\n (click)=\"toggleMasterHidden(page)\">\n {{ page.masterHidden ? 'Show header & footer' : 'Hide header & footer' }}\n </ng-template>\n <ng-template\n fs-menu-item\n (click)=\"deletePage(page)\">\n Delete\n </ng-template>\n </fs-menu>\n }\n <button\n type=\"button\"\n class=\"page-tab add-page\"\n matTooltip=\"Add page\"\n (click)=\"addPage()\">\n <mat-icon>add</mat-icon>\n Page\n </button>\n </div>\n\n <div class=\"zoom-controls\">\n <button\n type=\"button\"\n class=\"zoom-button\"\n matTooltip=\"Zoom out\"\n (click)=\"zoomOut()\">\n <mat-icon>remove</mat-icon>\n </button>\n <button\n type=\"button\"\n class=\"zoom-label\"\n matTooltip=\"Zoom to 100%\"\n (click)=\"zoomActual()\">\n {{ (zoom * 100).toFixed(0) }}%\n </button>\n <button\n type=\"button\"\n class=\"zoom-button\"\n matTooltip=\"Zoom in\"\n (click)=\"zoomIn()\">\n <mat-icon>add</mat-icon>\n </button>\n <button\n type=\"button\"\n class=\"zoom-button\"\n matTooltip=\"Fit page\"\n (click)=\"zoomFit()\">\n <mat-icon>fit_screen</mat-icon>\n </button>\n </div>\n </div>\n</div>\n", styles: [":host{display:flex;flex-direction:column;min-width:0;min-height:0;flex:1;margin-top:10px}.canvas{display:flex;flex-direction:column;gap:8px;flex:1;min-height:0}.canvas-toolbar{display:flex;align-items:center;justify-content:space-between;gap:8px}.canvas-toolbar .page-tabs{display:flex;align-items:center;gap:4px;flex-wrap:wrap}.canvas-toolbar .page-tabs fs-menu{display:none}.canvas-toolbar .page-tabs .page-tab{display:inline-flex;align-items:center;box-sizing:border-box;border:1px solid #e4e7eb;border-radius:16px;background:#fff;padding:4px 6px 4px 14px;font-size:12px;line-height:16px;color:#52606d;cursor:pointer}.canvas-toolbar .page-tabs .page-tab.active{background:var(--brand-primary-color, #3b82f6);border-color:var(--brand-primary-color, #3b82f6);color:#fff}.canvas-toolbar .page-tabs .page-tab .page-tab-label{white-space:nowrap}.canvas-toolbar .page-tabs .page-tab .page-tab-menu{display:inline-flex;align-items:center;justify-content:center;flex:0 0 auto;width:16px;height:16px;margin-left:2px;border-radius:50%;opacity:.75}.canvas-toolbar .page-tabs .page-tab .page-tab-menu mat-icon{font-size:14px;width:14px;height:14px;line-height:14px}.canvas-toolbar .page-tabs .page-tab .page-tab-menu:hover{background:#0f172a14;opacity:1}.canvas-toolbar .page-tabs .page-tab.active .page-tab-menu:hover{background:#ffffff38}.canvas-toolbar .page-tabs .page-tab.add-page{gap:2px;padding:4px 14px 4px 8px;border-style:dashed;color:#52606d}.canvas-toolbar .page-tabs .page-tab.add-page mat-icon{font-size:16px;width:16px;height:16px}.canvas-toolbar .page-tabs .page-tab.cdk-drag-placeholder{background:transparent;border-style:dashed;border-color:#cbd2d9}.canvas-toolbar .page-tabs .page-tab.cdk-drag-placeholder .page-tab-label,.canvas-toolbar .page-tabs .page-tab.cdk-drag-placeholder .page-tab-menu{visibility:hidden}.canvas-toolbar .page-tabs .cdk-drag-animating,.canvas-toolbar .page-tabs.cdk-drop-list-dragging .page-tab:not(.cdk-drag-placeholder){transition:transform .18s cubic-bezier(0,0,.2,1)}.canvas-toolbar .zoom-controls{display:flex;align-items:center;gap:2px;margin-left:auto;border:1px solid #e4e7eb;border-radius:18px;background:#fff;padding:2px 6px}.canvas-toolbar .zoom-controls .zoom-button{display:flex;align-items:center;justify-content:center;border:none;border-radius:50%;background:transparent;color:#52606d;cursor:pointer}.canvas-toolbar .zoom-controls .zoom-button:hover{background:#f0f4f8}.canvas-toolbar .zoom-controls .zoom-button mat-icon{font-size:18px;width:18px;height:18px}.canvas-toolbar .zoom-controls .zoom-label{border:none;background:transparent;font-size:12px;color:#52606d;min-width:44px;cursor:pointer}.canvas-toolbar .zoom-controls .zoom-label:hover{color:#1f2933}.page-tab.cdk-drag-preview{display:inline-flex;align-items:center;box-sizing:border-box;border:1px solid #e4e7eb;border-radius:16px;background:#fff;padding:4px 6px 4px 14px;font-size:12px;line-height:16px;color:#52606d;box-shadow:0 3px 10px #0f172a3d}.page-tab.cdk-drag-preview.active{background:var(--brand-primary-color, #3b82f6);border-color:var(--brand-primary-color, #3b82f6);color:#fff}.page-tab.cdk-drag-preview .page-tab-label{white-space:nowrap}.page-tab.cdk-drag-preview .page-tab-menu{display:inline-flex;align-items:center;justify-content:center;flex:0 0 auto;width:16px;height:16px;margin-left:2px;border-radius:50%;opacity:.75}.page-tab.cdk-drag-preview .page-tab-menu mat-icon{font-size:14px;width:14px;height:14px;line-height:14px}.page-viewport{position:relative;flex:1;min-height:420px;border:1px solid #e4e7eb;border-radius:8px;background:#eef1f5;overflow:hidden}.page-viewport fs-zoom-pan{width:100%;height:100%}.page{position:relative;background:#fff;border-radius:2px;box-shadow:0 2px 12px #0f172a1f,0 0 0 1px #0f172a0a;box-sizing:border-box}.page.flow{display:flex;flex-wrap:wrap;align-content:flex-start;align-items:flex-start}.page.editing{background-image:radial-gradient(circle,#d8dee6 1px,transparent 1px);background-size:24px 24px}.page.master-editing>app-report-component{opacity:.35;pointer-events:none}.master-layer{position:absolute;inset:0;pointer-events:none}.master-layer.editable{z-index:10}.master-layer.editable app-report-component{pointer-events:auto}.band{position:absolute;left:0;right:0;z-index:5;pointer-events:none;background:#2196f30a;border-color:#2196f373;border-style:dashed;border-width:0}.band.band-header{top:0;border-bottom-width:calc(1px / var(--canvas-zoom, 1))}.band.band-footer{bottom:0;border-top-width:calc(1px / var(--canvas-zoom, 1))}.band .band-label{position:absolute;top:calc(10px / var(--canvas-zoom, 1));left:calc(10px / var(--canvas-zoom, 1));transform:scale(calc(1 / var(--canvas-zoom, 1)));transform-origin:top left;color:#2196f3cc;font-weight:600;text-transform:uppercase;letter-spacing:.06em}.guides{position:absolute;inset:0;pointer-events:none;z-index:30}.guides ::ng-deep .guide{position:absolute;background:#2196f3}.guides ::ng-deep .guide.guide-v{top:0;bottom:0;width:calc(1.25px / var(--canvas-zoom, 1))}.guides ::ng-deep .guide.guide-h{left:0;right:0;height:calc(1.25px / var(--canvas-zoom, 1))}.page-empty{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;color:#9aa5b1;font-size:14px;pointer-events:none}\n"] }]
|
|
3888
|
+
], template: "<div class=\"canvas\">\n <div\n #viewport\n class=\"page-viewport\"\n (pointerdown)=\"onCanvasPointerDown()\">\n <fs-zoom-pan\n wheel=\"modifier\"\n [zoomMin]=\"0.1\"\n [zoomMax]=\"3\"\n (zoomed)=\"onZoomed($event)\">\n @if (activePage; as page) {\n <div\n class=\"page\"\n [class.flow]=\"isFlow\"\n [class.editing]=\"editMode\"\n [class.master-editing]=\"isMasterEditing\"\n [style.width.px]=\"report.pageWidth * PX\"\n [style.height.px]=\"report.pageHeight * PX\"\n [style.padding.px]=\"isFlow ? flowPadding * PX : 0\"\n [style.--canvas-zoom]=\"zoom()\"\n [style.--report-heading-size.px]=\"headingSizePx\">\n <!-- The header and footer: the master's components, always freeform,\n drawn under the page's own. Out of the flow (absolute), so a flow\n page's rows are laid out as if it were not there. -->\n @if (isMasterShown && masterComponents.length) {\n <div\n class=\"master-layer\"\n [class.editable]=\"isMasterEditing\">\n @for (component of masterComponents; track component.id) {\n <app-report-component\n [reportId]=\"report.id\"\n [component]=\"component\"\n [groups]=\"groups\"\n [editMode]=\"isMasterEditing\"\n [layout]=\"'freeform'\"\n [zoom]=\"zoom()\"\n [snapper]=\"snapper\"\n [tokens]=\"tokens\"\n [selected]=\"component.id === selectedComponentId()\"\n [style.left.px]=\"component.x * PX\"\n [style.top.px]=\"component.y * PX\"\n [style.width.px]=\"component.w * PX\"\n [style.height.px]=\"component.autoHeight ? null : component.h * PX\"\n (selectComponent)=\"selectComponent($event)\"\n (positionChanged)=\"onPositionChanged($event)\"\n (removed)=\"onComponentRemoved($event)\"\n (openSettings)=\"componentSettings.emit($event)\">\n </app-report-component>\n }\n </div>\n }\n\n <!-- The header and footer bands, as guides while editing. -->\n @if (editMode) {\n @if (bands.headerHeight) {\n <div\n class=\"band band-header\"\n [style.height.px]=\"bands.headerHeight * PX\">\n @if (isMasterEditing) {\n <span class=\"band-label small\">Header</span>\n }\n </div>\n }\n @if (bands.footerHeight) {\n <div\n class=\"band band-footer\"\n [style.height.px]=\"bands.footerHeight * PX\">\n @if (isMasterEditing) {\n <span class=\"band-label small\">Footer</span>\n }\n </div>\n }\n }\n\n @if (isFlow) {\n @for (component of flowComponents; track component.id) {\n <app-report-component\n class=\"flow-item\"\n [reportId]=\"report.id\"\n [component]=\"component\"\n [groups]=\"groups\"\n [editMode]=\"isPageEditing\"\n [layout]=\"'flow'\"\n [zoom]=\"zoom()\"\n [snapper]=\"snapper\"\n [tokens]=\"tokens\"\n [selected]=\"component.id === selectedComponentId()\"\n [style.width.%]=\"component.flowWidth\"\n [style.height.px]=\"component.autoHeight ? null : component.h * PX\"\n (selectComponent)=\"selectComponent($event)\"\n (positionChanged)=\"onPositionChanged($event)\"\n (flowReorder)=\"onFlowReorder($event)\"\n (removed)=\"onComponentRemoved($event)\"\n (openSettings)=\"componentSettings.emit($event)\">\n </app-report-component>\n }\n } @else {\n @for (component of page.components; track component.id) {\n <app-report-component\n [reportId]=\"report.id\"\n [component]=\"component\"\n [groups]=\"groups\"\n [editMode]=\"isPageEditing\"\n [layout]=\"'freeform'\"\n [zoom]=\"zoom()\"\n [snapper]=\"snapper\"\n [tokens]=\"tokens\"\n [selected]=\"component.id === selectedComponentId()\"\n [style.left.px]=\"component.x * PX\"\n [style.top.px]=\"component.y * PX\"\n [style.width.px]=\"component.w * PX\"\n [style.height.px]=\"component.autoHeight ? null : component.h * PX\"\n (selectComponent)=\"selectComponent($event)\"\n (positionChanged)=\"onPositionChanged($event)\"\n (removed)=\"onComponentRemoved($event)\"\n (openSettings)=\"componentSettings.emit($event)\">\n </app-report-component>\n }\n }\n\n <div\n #guides\n class=\"guides\">\n </div>\n\n @if (!page.components.length && !masterMode()) {\n <div class=\"page-empty\">\n Ask the assistant to add a chart, list or KPI to this page.\n </div>\n }\n </div>\n }\n </fs-zoom-pan>\n </div>\n\n <div class=\"canvas-toolbar\">\n <!-- Tabs are one horizontal drop list: dragging a chip reorders the pages.\n The drop INDEX is computed along X (orientation), but the preview is\n deliberately left unbounded on both axes \u2014 pinning it to the row with\n cdkDragBoundary means the chip you are dragging covers the chips you\n are dragging it past, and you cannot see where it is going to land.\n Every page gets a chip, including a lone one: the chip is what tells\n you which page you are on, and a report that hid it left you unable to\n tell page 1 of 1 from a page that failed to load. Deleting the last\n page is refused server-side, so the kebab is safe to expose here. -->\n <div\n class=\"page-tabs\"\n cdkDropList\n cdkDropListOrientation=\"horizontal\"\n (cdkDropListDropped)=\"onPageDropped($event)\">\n @for (page of pages(); track page.id; let index = $index) {\n <!-- The chip is the drag handle AND the tab: clicking selects,\n dragging reorders. The kebab lives inside it, so the whole\n control stays one chip instead of growing a second button. -->\n <div\n cdkDrag\n class=\"page-tab\"\n [class.active]=\"index === activePageIndex()\"\n (click)=\"selectPage(index)\">\n <span class=\"page-tab-label\">{{ pageLabel(page, index) }}</span>\n <!-- The kebab opens the menu rather than starting a drag or\n switching pages, so it swallows its own click. -->\n <span\n class=\"page-tab-menu\"\n [fsMenuTriggerFor]=\"pageMenu\"\n (click)=\"$event.stopPropagation()\"\n (mousedown)=\"$event.stopPropagation()\">\n <mat-icon>more_vert</mat-icon>\n </span>\n </div>\n <!-- Outside the chip: cdkDrag clones its host into the drag preview,\n and a menu overlay anchored to a cloned trigger opens detached\n from the real tab. -->\n <fs-menu #pageMenu>\n <ng-template\n fs-menu-item\n (click)=\"renamePage(page)\">\n Edit name\n </ng-template>\n <ng-template\n fs-menu-item\n (click)=\"toggleMasterHidden(page)\">\n {{ page.masterHidden ? 'Show header & footer' : 'Hide header & footer' }}\n </ng-template>\n <ng-template\n fs-menu-item\n (click)=\"deletePage(page)\">\n Delete\n </ng-template>\n </fs-menu>\n }\n <button\n type=\"button\"\n class=\"page-tab add-page\"\n matTooltip=\"Add page\"\n (click)=\"addPage()\">\n <mat-icon>add</mat-icon>\n Page\n </button>\n </div>\n\n <div class=\"zoom-controls\">\n <button\n type=\"button\"\n class=\"zoom-button\"\n matTooltip=\"Zoom out (Ctrl/Cmd + scroll)\"\n (click)=\"zoomOut()\">\n <mat-icon>remove</mat-icon>\n </button>\n <button\n type=\"button\"\n class=\"zoom-label\"\n matTooltip=\"Zoom to 100%\"\n (click)=\"zoomActual()\">\n {{ (zoom() * 100).toFixed(0) }}%\n </button>\n <button\n type=\"button\"\n class=\"zoom-button\"\n matTooltip=\"Zoom in (Ctrl/Cmd + scroll)\"\n (click)=\"zoomIn()\">\n <mat-icon>add</mat-icon>\n </button>\n <button\n type=\"button\"\n class=\"zoom-button\"\n matTooltip=\"Fit page\"\n (click)=\"zoomFit()\">\n <mat-icon>fit_screen</mat-icon>\n </button>\n </div>\n </div>\n</div>\n", styles: [":host{display:flex;flex-direction:column;min-width:0;min-height:0;flex:1;margin-top:10px}.canvas{display:flex;flex-direction:column;gap:8px;flex:1;min-height:0}.canvas-toolbar{display:flex;align-items:center;justify-content:space-between;gap:8px}.canvas-toolbar .page-tabs{display:flex;align-items:center;gap:4px;flex-wrap:wrap}.canvas-toolbar .page-tabs fs-menu{display:none}.canvas-toolbar .page-tabs .page-tab{display:inline-flex;align-items:center;box-sizing:border-box;border:1px solid #e4e7eb;border-radius:16px;background:#fff;padding:4px 6px 4px 14px;font-size:12px;line-height:16px;color:#52606d;cursor:pointer}.canvas-toolbar .page-tabs .page-tab.active{background:var(--brand-primary-color, #3b82f6);border-color:var(--brand-primary-color, #3b82f6);color:#fff}.canvas-toolbar .page-tabs .page-tab .page-tab-label{white-space:nowrap}.canvas-toolbar .page-tabs .page-tab .page-tab-menu{display:inline-flex;align-items:center;justify-content:center;flex:0 0 auto;width:16px;height:16px;margin-left:2px;border-radius:50%;opacity:.75}.canvas-toolbar .page-tabs .page-tab .page-tab-menu mat-icon{font-size:14px;width:14px;height:14px;line-height:14px}.canvas-toolbar .page-tabs .page-tab .page-tab-menu:hover{background:#0f172a14;opacity:1}.canvas-toolbar .page-tabs .page-tab.active .page-tab-menu:hover{background:#ffffff38}.canvas-toolbar .page-tabs .page-tab.add-page{gap:2px;padding:4px 14px 4px 8px;border-style:dashed;color:#52606d}.canvas-toolbar .page-tabs .page-tab.add-page mat-icon{font-size:16px;width:16px;height:16px}.canvas-toolbar .page-tabs .page-tab.cdk-drag-placeholder{background:transparent;border-style:dashed;border-color:#cbd2d9}.canvas-toolbar .page-tabs .page-tab.cdk-drag-placeholder .page-tab-label,.canvas-toolbar .page-tabs .page-tab.cdk-drag-placeholder .page-tab-menu{visibility:hidden}.canvas-toolbar .page-tabs .cdk-drag-animating,.canvas-toolbar .page-tabs.cdk-drop-list-dragging .page-tab:not(.cdk-drag-placeholder){transition:transform .18s cubic-bezier(0,0,.2,1)}.canvas-toolbar .zoom-controls{display:flex;align-items:center;gap:2px;margin-left:auto;border:1px solid #e4e7eb;border-radius:18px;background:#fff;padding:2px 6px}.canvas-toolbar .zoom-controls .zoom-button{display:flex;align-items:center;justify-content:center;border:none;border-radius:50%;background:transparent;color:#52606d;cursor:pointer}.canvas-toolbar .zoom-controls .zoom-button:hover{background:#f0f4f8}.canvas-toolbar .zoom-controls .zoom-button mat-icon{font-size:18px;width:18px;height:18px}.canvas-toolbar .zoom-controls .zoom-label{border:none;background:transparent;font-size:12px;color:#52606d;min-width:44px;cursor:pointer}.canvas-toolbar .zoom-controls .zoom-label:hover{color:#1f2933}.page-tab.cdk-drag-preview{display:inline-flex;align-items:center;box-sizing:border-box;border:1px solid #e4e7eb;border-radius:16px;background:#fff;padding:4px 6px 4px 14px;font-size:12px;line-height:16px;color:#52606d;box-shadow:0 3px 10px #0f172a3d}.page-tab.cdk-drag-preview.active{background:var(--brand-primary-color, #3b82f6);border-color:var(--brand-primary-color, #3b82f6);color:#fff}.page-tab.cdk-drag-preview .page-tab-label{white-space:nowrap}.page-tab.cdk-drag-preview .page-tab-menu{display:inline-flex;align-items:center;justify-content:center;flex:0 0 auto;width:16px;height:16px;margin-left:2px;border-radius:50%;opacity:.75}.page-tab.cdk-drag-preview .page-tab-menu mat-icon{font-size:14px;width:14px;height:14px;line-height:14px}.page-viewport{position:relative;flex:1;min-height:420px;border:1px solid #e4e7eb;border-radius:8px;background:#eef1f5;overflow:hidden}.page-viewport fs-zoom-pan{width:100%;height:100%}.page{position:relative;background:#fff;border-radius:2px;box-shadow:0 2px 12px #0f172a1f,0 0 0 1px #0f172a0a;box-sizing:border-box}.page.flow{display:flex;flex-wrap:wrap;align-content:flex-start;align-items:flex-start}.page.editing{background-image:radial-gradient(circle,#d8dee6 1px,transparent 1px);background-size:24px 24px}.page.master-editing>app-report-component{opacity:.35;pointer-events:none}.master-layer{position:absolute;inset:0;pointer-events:none}.master-layer.editable{z-index:10}.master-layer.editable app-report-component{pointer-events:auto}.band{position:absolute;left:0;right:0;z-index:5;pointer-events:none;background:#2196f30a;border-color:#2196f373;border-style:dashed;border-width:0}.band.band-header{top:0;border-bottom-width:calc(1px / var(--canvas-zoom, 1))}.band.band-footer{bottom:0;border-top-width:calc(1px / var(--canvas-zoom, 1))}.band .band-label{position:absolute;top:calc(10px / var(--canvas-zoom, 1));left:calc(10px / var(--canvas-zoom, 1));transform:scale(calc(1 / var(--canvas-zoom, 1)));transform-origin:top left;color:#2196f3cc;font-weight:600;text-transform:uppercase;letter-spacing:.06em}.guides{position:absolute;inset:0;pointer-events:none;z-index:30}.guides ::ng-deep .guide{position:absolute;background:#2196f3}.guides ::ng-deep .guide.guide-v{top:0;bottom:0;width:calc(1.25px / var(--canvas-zoom, 1))}.guides ::ng-deep .guide.guide-h{left:0;right:0;height:calc(1.25px / var(--canvas-zoom, 1))}.page-empty{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;color:#9aa5b1;font-size:14px;pointer-events:none}\n"] }]
|
|
3252
3889
|
}], propDecorators: { report: [{
|
|
3253
3890
|
type: Input
|
|
3254
3891
|
}], editMode: [{
|
|
@@ -3305,18 +3942,22 @@ class ComponentSettingsComponent {
|
|
|
3305
3942
|
// ---- SQL tab state ----
|
|
3306
3943
|
// The statement, once fetched. Null while it is in flight, and null when the
|
|
3307
3944
|
// component's document could not be compiled (sqlError then says why).
|
|
3308
|
-
sql = null;
|
|
3309
|
-
// True when the statement is a DERIVED baseline: no filters,
|
|
3310
|
-
//
|
|
3311
|
-
// this statement plus the viewer's own choices
|
|
3312
|
-
|
|
3313
|
-
|
|
3945
|
+
sql = signal(null);
|
|
3946
|
+
// True when the statement is a DERIVED baseline: no filters, the authored
|
|
3947
|
+
// grain, times in the report's effective zone. The tab says so, because the
|
|
3948
|
+
// numbers on screen come from this statement plus the viewer's own choices
|
|
3949
|
+
// and the two can differ.
|
|
3950
|
+
sqlBaseline = signal(false);
|
|
3951
|
+
// The zone the statement was compiled in: the report's effective zone, sent
|
|
3952
|
+
// with the SQL. A server too old to send it compiled in UTC.
|
|
3953
|
+
sqlTimezone = signal('UTC');
|
|
3954
|
+
sqlError = signal(null);
|
|
3314
3955
|
// ---- filter management state ----
|
|
3315
3956
|
// One row per filterable column. The two booleans drive the toggles; we flip
|
|
3316
3957
|
// them optimistically on click (so the UI never blinks while the request is
|
|
3317
3958
|
// in flight) and reconcile from the server on reload.
|
|
3318
|
-
rows = [];
|
|
3319
|
-
columnsError = null;
|
|
3959
|
+
rows = signal([]);
|
|
3960
|
+
columnsError = signal(null);
|
|
3320
3961
|
// The raw inputs the rows are built from.
|
|
3321
3962
|
_filterableColumns = [];
|
|
3322
3963
|
_filters = [];
|
|
@@ -3332,7 +3973,6 @@ class ComponentSettingsComponent {
|
|
|
3332
3973
|
_message = inject(FsMessage);
|
|
3333
3974
|
_prompt = inject(FsPrompt);
|
|
3334
3975
|
_destroyRef = inject(DestroyRef);
|
|
3335
|
-
_cdRef = inject(ChangeDetectorRef);
|
|
3336
3976
|
ngOnInit() {
|
|
3337
3977
|
this.report = this._data.report;
|
|
3338
3978
|
this.component = this._data.component;
|
|
@@ -3379,7 +4019,7 @@ class ComponentSettingsComponent {
|
|
|
3379
4019
|
},
|
|
3380
4020
|
};
|
|
3381
4021
|
return this._reportData.updateComponent(this.report.id, this.component.id, { ...settings })
|
|
3382
|
-
.pipe(tap(() => {
|
|
4022
|
+
.pipe(tap$1(() => {
|
|
3383
4023
|
this._message.success('Component saved');
|
|
3384
4024
|
this._dialogRef.close({ settings });
|
|
3385
4025
|
}));
|
|
@@ -3429,7 +4069,7 @@ class ComponentSettingsComponent {
|
|
|
3429
4069
|
// Build the row view-model from the loaded columns + filters/groups. Each
|
|
3430
4070
|
// row's toggles reflect where its filter group is currently exposed.
|
|
3431
4071
|
_rebuildRows() {
|
|
3432
|
-
this.rows
|
|
4072
|
+
this.rows.set(this._filterableColumns.map((column) => {
|
|
3433
4073
|
const filter = this._filters.find((candidate) => candidate.filterColumn === column.column);
|
|
3434
4074
|
const level = filter ? this._groupsById.get(filter.filterGroupId)?.level : undefined;
|
|
3435
4075
|
return {
|
|
@@ -3441,7 +4081,7 @@ class ComponentSettingsComponent {
|
|
|
3441
4081
|
report: level === 'report' || level === 'both',
|
|
3442
4082
|
component: level === 'component' || level === 'both',
|
|
3443
4083
|
};
|
|
3444
|
-
});
|
|
4084
|
+
}));
|
|
3445
4085
|
}
|
|
3446
4086
|
// After any filter mutation: re-fetch the report so the dialog's filter list
|
|
3447
4087
|
// (and the parent canvas, on close) reflect the new groups/levels.
|
|
@@ -3451,7 +4091,6 @@ class ComponentSettingsComponent {
|
|
|
3451
4091
|
.subscribe((report) => {
|
|
3452
4092
|
this.report = report;
|
|
3453
4093
|
this._seedFilters(report, this.component.id);
|
|
3454
|
-
this._cdRef.markForCheck();
|
|
3455
4094
|
});
|
|
3456
4095
|
}
|
|
3457
4096
|
_mutationError(error) {
|
|
@@ -3481,15 +4120,14 @@ class ComponentSettingsComponent {
|
|
|
3481
4120
|
this._reportData.componentSql(this.report.id, this.component.id)
|
|
3482
4121
|
.subscribe({
|
|
3483
4122
|
next: (preview) => {
|
|
3484
|
-
this.sql
|
|
3485
|
-
this.sqlBaseline
|
|
3486
|
-
this.
|
|
3487
|
-
this.
|
|
4123
|
+
this.sql.set(preview?.sql ?? null);
|
|
4124
|
+
this.sqlBaseline.set(!!preview?.baseline);
|
|
4125
|
+
this.sqlTimezone.set(preview?.timezone || 'UTC');
|
|
4126
|
+
this.sqlError.set(preview?.error ?? null);
|
|
3488
4127
|
},
|
|
3489
4128
|
error: (error) => {
|
|
3490
|
-
this.sql
|
|
3491
|
-
this.sqlError
|
|
3492
|
-
this._cdRef.markForCheck();
|
|
4129
|
+
this.sql.set(null);
|
|
4130
|
+
this.sqlError.set(error?.error?.message ?? 'Could not read the component SQL');
|
|
3493
4131
|
},
|
|
3494
4132
|
});
|
|
3495
4133
|
}
|
|
@@ -3499,11 +4137,9 @@ class ComponentSettingsComponent {
|
|
|
3499
4137
|
next: (columns) => {
|
|
3500
4138
|
this._filterableColumns = columns ?? [];
|
|
3501
4139
|
this._rebuildRows();
|
|
3502
|
-
this._cdRef.markForCheck();
|
|
3503
4140
|
},
|
|
3504
4141
|
error: (error) => {
|
|
3505
|
-
this.columnsError
|
|
3506
|
-
this._cdRef.markForCheck();
|
|
4142
|
+
this.columnsError.set(error?.error?.message ?? 'Could not read the component columns');
|
|
3507
4143
|
},
|
|
3508
4144
|
});
|
|
3509
4145
|
}
|
|
@@ -3515,7 +4151,7 @@ class ComponentSettingsComponent {
|
|
|
3515
4151
|
.replace(/\b\w/g, (character) => character.toUpperCase());
|
|
3516
4152
|
}
|
|
3517
4153
|
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: ComponentSettingsComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
3518
|
-
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "18.2.14", type: ComponentSettingsComponent, isStandalone: true, selector: "ng-component", ngImport: i0, template: "<form\n fsForm\n [submit]=\"save\">\n <fs-dialog>\n <h1 mat-dialog-title>\n {{ component.title || 'Component' }}\n </h1>\n <mat-dialog-content>\n <mat-tab-group [(selected)]=\"selectedTab\">\n <mat-tab\n label=\"Settings\"\n name=\"settings\">\n <div class=\"fs-column tab-body\">\n <mat-form-field>\n <mat-label>\n Title\n </mat-label>\n <input\n matInput\n [(ngModel)]=\"title\"\n name=\"title\">\n </mat-form-field>\n <div class=\"subheading-2\">\n Padding\n </div>\n <div class=\"geometry-row\">\n <mat-form-field>\n <mat-label>\n Top\n </mat-label>\n <input\n matInput\n fsMaskNumber\n [scale]=\"3\"\n [(ngModel)]=\"paddingTop\"\n name=\"paddingTop\"\n [fsFormMin]=\"0\">\n <div matTextSuffix>\n in\n </div>\n </mat-form-field>\n <mat-form-field>\n <mat-label>\n Right\n </mat-label>\n <input\n matInput\n fsMaskNumber\n [scale]=\"3\"\n [(ngModel)]=\"paddingRight\"\n name=\"paddingRight\"\n [fsFormMin]=\"0\">\n <div matTextSuffix>\n in\n </div>\n </mat-form-field>\n <mat-form-field>\n <mat-label>\n Bottom\n </mat-label>\n <input\n matInput\n fsMaskNumber\n [scale]=\"3\"\n [(ngModel)]=\"paddingBottom\"\n name=\"paddingBottom\"\n [fsFormMin]=\"0\">\n <div matTextSuffix>\n in\n </div>\n </mat-form-field>\n <mat-form-field>\n <mat-label>\n Left\n </mat-label>\n <input\n matInput\n fsMaskNumber\n [scale]=\"3\"\n [(ngModel)]=\"paddingLeft\"\n name=\"paddingLeft\"\n [fsFormMin]=\"0\">\n <div matTextSuffix>\n in\n </div>\n </mat-form-field>\n </div>\n </div>\n </mat-tab>\n <mat-tab\n label=\"Filters\"\n name=\"filters\">\n <div class=\"tab-body\">\n @if (columnsError) {\n <div class=\"filters-empty error\">\n {{ columnsError }}\n </div>\n } @else if (rows.length) {\n <!-- Every filterable column. Toggle where its control shows: in\n the report bar, on this component, both, or neither. The\n interface (date range vs value picker) follows the data. -->\n <table class=\"filter-table\">\n <thead>\n <tr>\n <th class=\"filter-col-name\">\n Filter\n </th>\n <th class=\"filter-col-type\">\n Type\n </th>\n <th class=\"filter-col-toggle\">\n Report bar\n </th>\n <th class=\"filter-col-toggle\">\n This component\n </th>\n </tr>\n </thead>\n <tbody>\n @for (row of rows; track row.column) {\n <tr>\n <td class=\"filter-col-name\">\n {{ row.label }}\n </td>\n <td class=\"filter-col-type\">\n {{ typeLabel(row.type) }}\n </td>\n <td class=\"filter-col-toggle\">\n <mat-slide-toggle\n [ngModel]=\"row.report\"\n (ngModelChange)=\"setExposure(row, 'report', $event)\"\n [ngModelOptions]=\"{ standalone: true }\"\n matTooltip=\"Show in the report bar\">\n </mat-slide-toggle>\n </td>\n <td class=\"filter-col-toggle\">\n <mat-slide-toggle\n [ngModel]=\"row.component\"\n (ngModelChange)=\"setExposure(row, 'component', $event)\"\n [ngModelOptions]=\"{ standalone: true }\"\n matTooltip=\"Show on this component\">\n </mat-slide-toggle>\n </td>\n </tr>\n }\n </tbody>\n </table>\n } @else {\n <div class=\"filters-empty\">\n This component has no filterable columns yet \u2014 give it some SQL first.\n </div>\n }\n </div>\n </mat-tab>\n <mat-tab\n label=\"SQL\"\n name=\"sql\">\n <div class=\"tab-body\">\n @if (sqlError) {\n <div class=\"filters-empty error\">\n {{ sqlError }}\n </div>\n } @else if (sql) {\n @if (sqlBaseline) {\n <!-- A derived component's statement is compiled from its\n document with no viewer state, so it is not byte-for-byte\n what produced the numbers on screen. Said here rather than\n left for the reader to discover. -->\n <div class=\"filters-empty\">\n Compiled from this component's query \u2014 no filters applied, times in UTC.\n </div>\n }\n <pre class=\"code-block\">{{ sql }}</pre>\n } @else {\n <div class=\"filters-empty\">\n This component has no SQL yet.\n </div>\n }\n </div>\n </mat-tab>\n <mat-tab\n label=\"Config\"\n name=\"config\">\n <div class=\"tab-body\">\n <pre class=\"code-block\">\n {{ configJson }}\n </pre>\n </div>\n </mat-tab>\n </mat-tab-group>\n </mat-dialog-content>\n <mat-dialog-actions>\n <!-- Save | Delete | Cancel, the standard order. fs-form-dialog-actions\n renders its own Save/Done BEFORE the projected content, which would\n put Delete after Cancel, so `[save]=\"false\"` foots the row here and\n all three buttons are projected in order. Save submits the Settings\n tab; the other tabs persist as you touch them, so they get Done. -->\n <fs-form-dialog-actions [save]=\"false\">\n @if (selectedTab === 'settings') {\n <button\n mat-button\n type=\"submit\"\n color=\"primary\">\n Save\n </button>\n } @else {\n <button\n mat-button\n type=\"button\"\n color=\"primary\"\n [mat-dialog-close]=\"null\">\n Done\n </button>\n }\n <button\n mat-button\n type=\"button\"\n color=\"warn\"\n (click)=\"delete()\">\n Delete\n </button>\n <button\n mat-button\n type=\"button\"\n class=\"cancel-button\"\n [mat-dialog-close]=\"null\">\n Cancel\n </button>\n </fs-form-dialog-actions>\n </mat-dialog-actions>\n </fs-dialog>\n</form>", styles: ["mat-form-field{width:100%}.tab-body{padding-top:16px}.geometry-row{display:flex;gap:8px}.geometry-row mat-form-field{flex:1}.filter-table{border-spacing:0;width:100%;border-collapse:collapse}.filter-table thead th{color:#8f8f8f;font-weight:400;font-size:85%;text-align:left;padding:8px 16px}.filter-table tbody tr{clip-path:xywh(0 3px 100% calc(100% - 6px) round 10px)}.filter-table tbody td{border:none;background-color:#fafafa;padding:8px 16px;vertical-align:middle;text-align:left}.filter-table .filter-col-name{font-weight:600;color:#1f2933}.filter-table .filter-col-type{color:#7b8794;width:120px}.filter-table .filter-col-toggle,.filter-table th.filter-col-toggle{width:130px;text-align:center}.filters-empty{font-size:13px;color:#7b8794;padding:8px 0 4px}.filters-empty.error{color:#e15759}.code-block{margin:0;padding:10px 12px;background:#1f2933;color:#e4e7eb;border-radius:6px;font-family:JetBrains Mono,Consolas,monospace;font-size:12px;line-height:1.5;white-space:pre-wrap;word-break:break-word;max-height:240px;overflow:auto}\n"], dependencies: [{ kind: "ngmodule", type: FormsModule }, { kind: "directive", type: i1$1.ɵNgNoValidate, selector: "form:not([ngNoForm]):not([ngNativeValidate])" }, { kind: "directive", type: i1$1.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1$1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1$1.NgControlStatusGroup, selector: "[formGroupName],[formArrayName],[ngModelGroup],[formGroup],form:not([ngNoForm]),[ngForm]" }, { kind: "directive", type: i1$1.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }, { kind: "directive", type: i1$1.NgForm, selector: "form:not([ngNoForm]):not([formGroup]),ng-form,[ngForm]", inputs: ["ngFormOptions"], outputs: ["ngSubmit"], exportAs: ["ngForm"] }, { kind: "ngmodule", type: FsFormModule }, { kind: "directive", type: i2$2.FsFormDirective, selector: "[fsForm]", inputs: ["wrapperSelector", "messageSelector", "hintSelector", "labelSelector", "autocomplete", "shortcuts", "confirm", "confirmDialog", "confirmDrawer", "confirmBrowser", "dirtySubmitButton", "submit", "successDelay", "errorDelay", "deactivationGuard"], outputs: ["fsForm", "invalid", "valid", "submitted", "reseted", "cleared"], exportAs: ["fsForm"] }, { kind: "directive", type: i2$2.FsFormMinDirective, selector: "[fsFormMin]", inputs: ["fsFormMin", "fsFormMinMessage"] }, { kind: "component", type: i2$2.FsFormDialogActionsComponent, selector: "fs-form-dialog-actions", inputs: ["save", "create", "close", "done", "closeData", "name"] }, { kind: "directive", type: i2$2.FsFormNoFsValidatorsDirective, selector: "[ngModel]:not([required]):not([fsFormRequired]):not([fsFormCompare]):not([fsFormDateRange]):not([fsFormEmail]):not([fsFormEmails]):not([fsFormFunction]):not([fsFormGreater]):not([fsFormGreaterEqual]):not([fsFormInteger]):not([fsFormLesser]):not([fsFormMax]):not([fsFormMaxLength]):not([fsFormMin]):not([fsFormMinLength]):not([fsFormNumeric]):not([fsFormPattern]):not([fsFormPhone]):not([fsFormUrl]):not([validate])" }, { kind: "directive", type: i2$2.FsButtonDirective, selector: "[mat-raised-button],[mat-button],[mat-flat-button],[mat-stroked-button]", inputs: ["name", "dirtySubmit"] }, { kind: "ngmodule", type: FsMaskModule }, { kind: "directive", type: i3$1.FsMaskNumberDirective, selector: "[fsMaskNumber]" }, { kind: "ngmodule", type: FsDialogModule }, { kind: "component", type: i3$2.FsDialogComponent, selector: "fs-dialog", inputs: ["mobileMode", "mobileButtonPlacement", "mobileWidth", "mode", "buttonLayout"] }, { kind: "ngmodule", type: FsLabelModule }, { kind: "ngmodule", type: FsTabsModule }, { kind: "directive", type: i5.FsTabsHeaderTabGroupDirective, selector: "mat-tab-group, matTabGroup, [matTabGroup]", inputs: ["orientation", "selected", "selectedData"], outputs: ["selectedChange", "selectedDataChange"], exportAs: ["fsTabsHeaderTabGroup"] }, { kind: "directive", type: i5.FsTabsTabDirective, selector: "mat-tab,matTab", inputs: ["name", "data"], exportAs: ["fsTabsTab"] }, { kind: "ngmodule", type: MatTabsModule }, { kind: "component", type: i6.MatTab, selector: "mat-tab", inputs: ["disabled", "label", "aria-label", "aria-labelledby", "labelClass", "bodyClass"], exportAs: ["matTab"] }, { kind: "component", type: i6.MatTabGroup, selector: "mat-tab-group", inputs: ["color", "fitInkBarToContent", "mat-stretch-tabs", "dynamicHeight", "selectedIndex", "headerPosition", "animationDuration", "contentTabIndex", "disablePagination", "disableRipple", "preserveContent", "backgroundColor", "aria-label", "aria-labelledby"], outputs: ["selectedIndexChange", "focusChange", "animationDone", "selectedTabChange"], exportAs: ["matTabGroup"] }, { kind: "directive", type: MatDialogTitle, selector: "[mat-dialog-title], [matDialogTitle]", inputs: ["id"], exportAs: ["matDialogTitle"] }, { kind: "directive", type: MatDialogContent, selector: "[mat-dialog-content], mat-dialog-content, [matDialogContent]" }, { kind: "directive", type: MatDialogActions, selector: "[mat-dialog-actions], mat-dialog-actions, [matDialogActions]", inputs: ["align"] }, { kind: "directive", type: MatDialogClose, selector: "[mat-dialog-close], [matDialogClose]", inputs: ["aria-label", "type", "mat-dialog-close", "matDialogClose"], exportAs: ["matDialogClose"] }, { kind: "component", type: MatFormField, selector: "mat-form-field", inputs: ["hideRequiredMarker", "color", "floatLabel", "appearance", "subscriptSizing", "hintLabel"], exportAs: ["matFormField"] }, { kind: "directive", type: MatLabel, selector: "mat-label" }, { kind: "directive", type: MatSuffix, selector: "[matSuffix], [matIconSuffix], [matTextSuffix]", inputs: ["matTextSuffix"] }, { kind: "component", type: MatButton, selector: " button[mat-button], button[mat-raised-button], button[mat-flat-button], button[mat-stroked-button] ", exportAs: ["matButton"] }, { kind: "directive", type: MatInput, selector: "input[matInput], textarea[matInput], select[matNativeControl], input[matNativeControl], textarea[matNativeControl]", inputs: ["disabled", "id", "placeholder", "name", "required", "type", "errorStateMatcher", "aria-describedby", "value", "readonly"], exportAs: ["matInput"] }, { kind: "component", type: MatSlideToggle, selector: "mat-slide-toggle", inputs: ["name", "id", "labelPosition", "aria-label", "aria-labelledby", "aria-describedby", "required", "color", "disabled", "disableRipple", "tabIndex", "checked", "hideIcon", "disabledInteractive"], outputs: ["change", "toggleChange"], exportAs: ["matSlideToggle"] }, { kind: "directive", type: MatTooltip, selector: "[matTooltip]", inputs: ["matTooltipPosition", "matTooltipPositionAtOrigin", "matTooltipDisabled", "matTooltipShowDelay", "matTooltipHideDelay", "matTooltipTouchGestures", "matTooltip", "matTooltipClass"], exportAs: ["matTooltip"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
4154
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "18.2.14", type: ComponentSettingsComponent, isStandalone: true, selector: "ng-component", ngImport: i0, template: "<form\n fsForm\n [submit]=\"save\">\n <fs-dialog>\n <h1 mat-dialog-title>\n {{ component.title || 'Component' }}\n </h1>\n <mat-dialog-content>\n <mat-tab-group [(selected)]=\"selectedTab\">\n <mat-tab\n label=\"Settings\"\n name=\"settings\">\n <div class=\"fs-column tab-body\">\n <mat-form-field>\n <mat-label>\n Title\n </mat-label>\n <input\n matInput\n [(ngModel)]=\"title\"\n name=\"title\">\n </mat-form-field>\n <div class=\"subheading-2\">\n Padding\n </div>\n <div class=\"geometry-row\">\n <mat-form-field>\n <mat-label>\n Top\n </mat-label>\n <input\n matInput\n fsMaskNumber\n [scale]=\"3\"\n [(ngModel)]=\"paddingTop\"\n name=\"paddingTop\"\n [fsFormMin]=\"0\">\n <div matTextSuffix>\n in\n </div>\n </mat-form-field>\n <mat-form-field>\n <mat-label>\n Right\n </mat-label>\n <input\n matInput\n fsMaskNumber\n [scale]=\"3\"\n [(ngModel)]=\"paddingRight\"\n name=\"paddingRight\"\n [fsFormMin]=\"0\">\n <div matTextSuffix>\n in\n </div>\n </mat-form-field>\n <mat-form-field>\n <mat-label>\n Bottom\n </mat-label>\n <input\n matInput\n fsMaskNumber\n [scale]=\"3\"\n [(ngModel)]=\"paddingBottom\"\n name=\"paddingBottom\"\n [fsFormMin]=\"0\">\n <div matTextSuffix>\n in\n </div>\n </mat-form-field>\n <mat-form-field>\n <mat-label>\n Left\n </mat-label>\n <input\n matInput\n fsMaskNumber\n [scale]=\"3\"\n [(ngModel)]=\"paddingLeft\"\n name=\"paddingLeft\"\n [fsFormMin]=\"0\">\n <div matTextSuffix>\n in\n </div>\n </mat-form-field>\n </div>\n </div>\n </mat-tab>\n <mat-tab\n label=\"Filters\"\n name=\"filters\">\n <div class=\"tab-body\">\n @if (columnsError()) {\n <div class=\"filters-empty error\">\n {{ columnsError() }}\n </div>\n } @else if (rows().length) {\n <!-- Every filterable column. Toggle where its control shows: in\n the report bar, on this component, both, or neither. The\n interface (date range vs value picker) follows the data. -->\n <table class=\"filter-table\">\n <thead>\n <tr>\n <th class=\"filter-col-name\">\n Filter\n </th>\n <th class=\"filter-col-type\">\n Type\n </th>\n <th class=\"filter-col-toggle\">\n Report bar\n </th>\n <th class=\"filter-col-toggle\">\n This component\n </th>\n </tr>\n </thead>\n <tbody>\n @for (row of rows(); track row.column) {\n <tr>\n <td class=\"filter-col-name\">\n {{ row.label }}\n </td>\n <td class=\"filter-col-type\">\n {{ typeLabel(row.type) }}\n </td>\n <td class=\"filter-col-toggle\">\n <mat-slide-toggle\n [ngModel]=\"row.report\"\n (ngModelChange)=\"setExposure(row, 'report', $event)\"\n [ngModelOptions]=\"{ standalone: true }\"\n matTooltip=\"Show in the report bar\">\n </mat-slide-toggle>\n </td>\n <td class=\"filter-col-toggle\">\n <mat-slide-toggle\n [ngModel]=\"row.component\"\n (ngModelChange)=\"setExposure(row, 'component', $event)\"\n [ngModelOptions]=\"{ standalone: true }\"\n matTooltip=\"Show on this component\">\n </mat-slide-toggle>\n </td>\n </tr>\n }\n </tbody>\n </table>\n } @else {\n <div class=\"filters-empty\">\n This component has no filterable columns yet \u2014 give it some SQL first.\n </div>\n }\n </div>\n </mat-tab>\n <mat-tab\n label=\"SQL\"\n name=\"sql\">\n <div class=\"tab-body\">\n @if (sqlError()) {\n <div class=\"filters-empty error\">\n {{ sqlError() }}\n </div>\n } @else if (sql()) {\n @if (sqlBaseline()) {\n <!-- A derived component's statement is compiled from its\n document with no viewer state, so it is not byte-for-byte\n what produced the numbers on screen. Said here rather than\n left for the reader to discover. -->\n <div class=\"filters-empty\">\n Compiled from this component's query \u2014 no filters applied, times in {{ sqlTimezone() }}.\n </div>\n }\n <pre class=\"code-block\">{{ sql() }}</pre>\n } @else {\n <div class=\"filters-empty\">\n This component has no SQL yet.\n </div>\n }\n </div>\n </mat-tab>\n <mat-tab\n label=\"Config\"\n name=\"config\">\n <div class=\"tab-body\">\n <pre class=\"code-block\">\n {{ configJson }}\n </pre>\n </div>\n </mat-tab>\n </mat-tab-group>\n </mat-dialog-content>\n <mat-dialog-actions>\n <!-- Save | Delete | Cancel, the standard order. fs-form-dialog-actions\n renders its own Save/Done BEFORE the projected content, which would\n put Delete after Cancel, so `[save]=\"false\"` foots the row here and\n all three buttons are projected in order. Save submits the Settings\n tab; the other tabs persist as you touch them, so they get Done. -->\n <fs-form-dialog-actions [save]=\"false\">\n @if (selectedTab === 'settings') {\n <button\n mat-button\n type=\"submit\"\n color=\"primary\">\n Save\n </button>\n } @else {\n <button\n mat-button\n type=\"button\"\n color=\"primary\"\n [mat-dialog-close]=\"null\">\n Done\n </button>\n }\n <button\n mat-button\n type=\"button\"\n color=\"warn\"\n (click)=\"delete()\">\n Delete\n </button>\n <button\n mat-button\n type=\"button\"\n class=\"cancel-button\"\n [mat-dialog-close]=\"null\">\n Cancel\n </button>\n </fs-form-dialog-actions>\n </mat-dialog-actions>\n </fs-dialog>\n</form>", styles: ["mat-form-field{width:100%}.tab-body{padding-top:16px}.geometry-row{display:flex;gap:8px}.geometry-row mat-form-field{flex:1}.filter-table{border-spacing:0;width:100%;border-collapse:collapse}.filter-table thead th{color:#8f8f8f;font-weight:400;font-size:85%;text-align:left;padding:8px 16px}.filter-table tbody tr{clip-path:xywh(0 3px 100% calc(100% - 6px) round 10px)}.filter-table tbody td{border:none;background-color:#fafafa;padding:8px 16px;vertical-align:middle;text-align:left}.filter-table .filter-col-name{font-weight:600;color:#1f2933}.filter-table .filter-col-type{color:#7b8794;width:120px}.filter-table .filter-col-toggle,.filter-table th.filter-col-toggle{width:130px;text-align:center}.filters-empty{font-size:13px;color:#7b8794;padding:8px 0 4px}.filters-empty.error{color:#e15759}.code-block{margin:0;padding:10px 12px;background:#1f2933;color:#e4e7eb;border-radius:6px;font-family:JetBrains Mono,Consolas,monospace;font-size:12px;line-height:1.5;white-space:pre-wrap;word-break:break-word;max-height:240px;overflow:auto}\n"], dependencies: [{ kind: "ngmodule", type: FormsModule }, { kind: "directive", type: i1$1.ɵNgNoValidate, selector: "form:not([ngNoForm]):not([ngNativeValidate])" }, { kind: "directive", type: i1$1.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1$1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1$1.NgControlStatusGroup, selector: "[formGroupName],[formArrayName],[ngModelGroup],[formGroup],form:not([ngNoForm]),[ngForm]" }, { kind: "directive", type: i1$1.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }, { kind: "directive", type: i1$1.NgForm, selector: "form:not([ngNoForm]):not([formGroup]),ng-form,[ngForm]", inputs: ["ngFormOptions"], outputs: ["ngSubmit"], exportAs: ["ngForm"] }, { kind: "ngmodule", type: FsFormModule }, { kind: "directive", type: i2$2.FsFormDirective, selector: "[fsForm]", inputs: ["wrapperSelector", "messageSelector", "hintSelector", "labelSelector", "autocomplete", "shortcuts", "confirm", "confirmDialog", "confirmDrawer", "confirmBrowser", "dirtySubmitButton", "submit", "successDelay", "errorDelay", "deactivationGuard"], outputs: ["fsForm", "invalid", "valid", "submitted", "reseted", "cleared"], exportAs: ["fsForm"] }, { kind: "directive", type: i2$2.FsFormMinDirective, selector: "[fsFormMin]", inputs: ["fsFormMin", "fsFormMinMessage"] }, { kind: "component", type: i2$2.FsFormDialogActionsComponent, selector: "fs-form-dialog-actions", inputs: ["save", "create", "close", "done", "closeData", "name"] }, { kind: "directive", type: i2$2.FsFormNoFsValidatorsDirective, selector: "[ngModel]:not([required]):not([fsFormRequired]):not([fsFormCompare]):not([fsFormDateRange]):not([fsFormEmail]):not([fsFormEmails]):not([fsFormFunction]):not([fsFormGreater]):not([fsFormGreaterEqual]):not([fsFormInteger]):not([fsFormLesser]):not([fsFormMax]):not([fsFormMaxLength]):not([fsFormMin]):not([fsFormMinLength]):not([fsFormNumeric]):not([fsFormPattern]):not([fsFormPhone]):not([fsFormUrl]):not([validate])" }, { kind: "directive", type: i2$2.FsButtonDirective, selector: "[mat-raised-button],[mat-button],[mat-flat-button],[mat-stroked-button]", inputs: ["name", "dirtySubmit"] }, { kind: "ngmodule", type: FsMaskModule }, { kind: "directive", type: i3$1.FsMaskNumberDirective, selector: "[fsMaskNumber]" }, { kind: "ngmodule", type: FsDialogModule }, { kind: "component", type: i3$2.FsDialogComponent, selector: "fs-dialog", inputs: ["mobileMode", "mobileButtonPlacement", "mobileWidth", "mode", "buttonLayout"] }, { kind: "ngmodule", type: FsLabelModule }, { kind: "ngmodule", type: FsTabsModule }, { kind: "directive", type: i5.FsTabsHeaderTabGroupDirective, selector: "mat-tab-group, matTabGroup, [matTabGroup]", inputs: ["orientation", "selected", "selectedData"], outputs: ["selectedChange", "selectedDataChange"], exportAs: ["fsTabsHeaderTabGroup"] }, { kind: "directive", type: i5.FsTabsTabDirective, selector: "mat-tab,matTab", inputs: ["name", "data"], exportAs: ["fsTabsTab"] }, { kind: "ngmodule", type: MatTabsModule }, { kind: "component", type: i6.MatTab, selector: "mat-tab", inputs: ["disabled", "label", "aria-label", "aria-labelledby", "labelClass", "bodyClass"], exportAs: ["matTab"] }, { kind: "component", type: i6.MatTabGroup, selector: "mat-tab-group", inputs: ["color", "fitInkBarToContent", "mat-stretch-tabs", "dynamicHeight", "selectedIndex", "headerPosition", "animationDuration", "contentTabIndex", "disablePagination", "disableRipple", "preserveContent", "backgroundColor", "aria-label", "aria-labelledby"], outputs: ["selectedIndexChange", "focusChange", "animationDone", "selectedTabChange"], exportAs: ["matTabGroup"] }, { kind: "directive", type: MatDialogTitle, selector: "[mat-dialog-title], [matDialogTitle]", inputs: ["id"], exportAs: ["matDialogTitle"] }, { kind: "directive", type: MatDialogContent, selector: "[mat-dialog-content], mat-dialog-content, [matDialogContent]" }, { kind: "directive", type: MatDialogActions, selector: "[mat-dialog-actions], mat-dialog-actions, [matDialogActions]", inputs: ["align"] }, { kind: "directive", type: MatDialogClose, selector: "[mat-dialog-close], [matDialogClose]", inputs: ["aria-label", "type", "mat-dialog-close", "matDialogClose"], exportAs: ["matDialogClose"] }, { kind: "component", type: MatFormField, selector: "mat-form-field", inputs: ["hideRequiredMarker", "color", "floatLabel", "appearance", "subscriptSizing", "hintLabel"], exportAs: ["matFormField"] }, { kind: "directive", type: MatLabel, selector: "mat-label" }, { kind: "directive", type: MatSuffix, selector: "[matSuffix], [matIconSuffix], [matTextSuffix]", inputs: ["matTextSuffix"] }, { kind: "component", type: MatButton, selector: " button[mat-button], button[mat-raised-button], button[mat-flat-button], button[mat-stroked-button] ", exportAs: ["matButton"] }, { kind: "directive", type: MatInput, selector: "input[matInput], textarea[matInput], select[matNativeControl], input[matNativeControl], textarea[matNativeControl]", inputs: ["disabled", "id", "placeholder", "name", "required", "type", "errorStateMatcher", "aria-describedby", "value", "readonly"], exportAs: ["matInput"] }, { kind: "component", type: MatSlideToggle, selector: "mat-slide-toggle", inputs: ["name", "id", "labelPosition", "aria-label", "aria-labelledby", "aria-describedby", "required", "color", "disabled", "disableRipple", "tabIndex", "checked", "hideIcon", "disabledInteractive"], outputs: ["change", "toggleChange"], exportAs: ["matSlideToggle"] }, { kind: "directive", type: MatTooltip, selector: "[matTooltip]", inputs: ["matTooltipPosition", "matTooltipPositionAtOrigin", "matTooltipDisabled", "matTooltipShowDelay", "matTooltipHideDelay", "matTooltipTouchGestures", "matTooltip", "matTooltipClass"], exportAs: ["matTooltip"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
3519
4155
|
}
|
|
3520
4156
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: ComponentSettingsComponent, decorators: [{
|
|
3521
4157
|
type: Component,
|
|
@@ -3538,46 +4174,46 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImpo
|
|
|
3538
4174
|
MatInput,
|
|
3539
4175
|
MatSlideToggle,
|
|
3540
4176
|
MatTooltip,
|
|
3541
|
-
], template: "<form\n fsForm\n [submit]=\"save\">\n <fs-dialog>\n <h1 mat-dialog-title>\n {{ component.title || 'Component' }}\n </h1>\n <mat-dialog-content>\n <mat-tab-group [(selected)]=\"selectedTab\">\n <mat-tab\n label=\"Settings\"\n name=\"settings\">\n <div class=\"fs-column tab-body\">\n <mat-form-field>\n <mat-label>\n Title\n </mat-label>\n <input\n matInput\n [(ngModel)]=\"title\"\n name=\"title\">\n </mat-form-field>\n <div class=\"subheading-2\">\n Padding\n </div>\n <div class=\"geometry-row\">\n <mat-form-field>\n <mat-label>\n Top\n </mat-label>\n <input\n matInput\n fsMaskNumber\n [scale]=\"3\"\n [(ngModel)]=\"paddingTop\"\n name=\"paddingTop\"\n [fsFormMin]=\"0\">\n <div matTextSuffix>\n in\n </div>\n </mat-form-field>\n <mat-form-field>\n <mat-label>\n Right\n </mat-label>\n <input\n matInput\n fsMaskNumber\n [scale]=\"3\"\n [(ngModel)]=\"paddingRight\"\n name=\"paddingRight\"\n [fsFormMin]=\"0\">\n <div matTextSuffix>\n in\n </div>\n </mat-form-field>\n <mat-form-field>\n <mat-label>\n Bottom\n </mat-label>\n <input\n matInput\n fsMaskNumber\n [scale]=\"3\"\n [(ngModel)]=\"paddingBottom\"\n name=\"paddingBottom\"\n [fsFormMin]=\"0\">\n <div matTextSuffix>\n in\n </div>\n </mat-form-field>\n <mat-form-field>\n <mat-label>\n Left\n </mat-label>\n <input\n matInput\n fsMaskNumber\n [scale]=\"3\"\n [(ngModel)]=\"paddingLeft\"\n name=\"paddingLeft\"\n [fsFormMin]=\"0\">\n <div matTextSuffix>\n in\n </div>\n </mat-form-field>\n </div>\n </div>\n </mat-tab>\n <mat-tab\n label=\"Filters\"\n name=\"filters\">\n <div class=\"tab-body\">\n @if (columnsError) {\n <div class=\"filters-empty error\">\n {{ columnsError }}\n </div>\n } @else if (rows.length) {\n <!-- Every filterable column. Toggle where its control shows: in\n the report bar, on this component, both, or neither. The\n interface (date range vs value picker) follows the data. -->\n <table class=\"filter-table\">\n <thead>\n <tr>\n <th class=\"filter-col-name\">\n Filter\n </th>\n <th class=\"filter-col-type\">\n Type\n </th>\n <th class=\"filter-col-toggle\">\n Report bar\n </th>\n <th class=\"filter-col-toggle\">\n This component\n </th>\n </tr>\n </thead>\n <tbody>\n @for (row of rows; track row.column) {\n <tr>\n <td class=\"filter-col-name\">\n {{ row.label }}\n </td>\n <td class=\"filter-col-type\">\n {{ typeLabel(row.type) }}\n </td>\n <td class=\"filter-col-toggle\">\n <mat-slide-toggle\n [ngModel]=\"row.report\"\n (ngModelChange)=\"setExposure(row, 'report', $event)\"\n [ngModelOptions]=\"{ standalone: true }\"\n matTooltip=\"Show in the report bar\">\n </mat-slide-toggle>\n </td>\n <td class=\"filter-col-toggle\">\n <mat-slide-toggle\n [ngModel]=\"row.component\"\n (ngModelChange)=\"setExposure(row, 'component', $event)\"\n [ngModelOptions]=\"{ standalone: true }\"\n matTooltip=\"Show on this component\">\n </mat-slide-toggle>\n </td>\n </tr>\n }\n </tbody>\n </table>\n } @else {\n <div class=\"filters-empty\">\n This component has no filterable columns yet \u2014 give it some SQL first.\n </div>\n }\n </div>\n </mat-tab>\n <mat-tab\n label=\"SQL\"\n name=\"sql\">\n <div class=\"tab-body\">\n @if (sqlError) {\n <div class=\"filters-empty error\">\n {{ sqlError }}\n </div>\n } @else if (sql) {\n @if (sqlBaseline) {\n <!-- A derived component's statement is compiled from its\n document with no viewer state, so it is not byte-for-byte\n what produced the numbers on screen. Said here rather than\n left for the reader to discover. -->\n <div class=\"filters-empty\">\n Compiled from this component's query \u2014 no filters applied, times in
|
|
4177
|
+
], template: "<form\n fsForm\n [submit]=\"save\">\n <fs-dialog>\n <h1 mat-dialog-title>\n {{ component.title || 'Component' }}\n </h1>\n <mat-dialog-content>\n <mat-tab-group [(selected)]=\"selectedTab\">\n <mat-tab\n label=\"Settings\"\n name=\"settings\">\n <div class=\"fs-column tab-body\">\n <mat-form-field>\n <mat-label>\n Title\n </mat-label>\n <input\n matInput\n [(ngModel)]=\"title\"\n name=\"title\">\n </mat-form-field>\n <div class=\"subheading-2\">\n Padding\n </div>\n <div class=\"geometry-row\">\n <mat-form-field>\n <mat-label>\n Top\n </mat-label>\n <input\n matInput\n fsMaskNumber\n [scale]=\"3\"\n [(ngModel)]=\"paddingTop\"\n name=\"paddingTop\"\n [fsFormMin]=\"0\">\n <div matTextSuffix>\n in\n </div>\n </mat-form-field>\n <mat-form-field>\n <mat-label>\n Right\n </mat-label>\n <input\n matInput\n fsMaskNumber\n [scale]=\"3\"\n [(ngModel)]=\"paddingRight\"\n name=\"paddingRight\"\n [fsFormMin]=\"0\">\n <div matTextSuffix>\n in\n </div>\n </mat-form-field>\n <mat-form-field>\n <mat-label>\n Bottom\n </mat-label>\n <input\n matInput\n fsMaskNumber\n [scale]=\"3\"\n [(ngModel)]=\"paddingBottom\"\n name=\"paddingBottom\"\n [fsFormMin]=\"0\">\n <div matTextSuffix>\n in\n </div>\n </mat-form-field>\n <mat-form-field>\n <mat-label>\n Left\n </mat-label>\n <input\n matInput\n fsMaskNumber\n [scale]=\"3\"\n [(ngModel)]=\"paddingLeft\"\n name=\"paddingLeft\"\n [fsFormMin]=\"0\">\n <div matTextSuffix>\n in\n </div>\n </mat-form-field>\n </div>\n </div>\n </mat-tab>\n <mat-tab\n label=\"Filters\"\n name=\"filters\">\n <div class=\"tab-body\">\n @if (columnsError()) {\n <div class=\"filters-empty error\">\n {{ columnsError() }}\n </div>\n } @else if (rows().length) {\n <!-- Every filterable column. Toggle where its control shows: in\n the report bar, on this component, both, or neither. The\n interface (date range vs value picker) follows the data. -->\n <table class=\"filter-table\">\n <thead>\n <tr>\n <th class=\"filter-col-name\">\n Filter\n </th>\n <th class=\"filter-col-type\">\n Type\n </th>\n <th class=\"filter-col-toggle\">\n Report bar\n </th>\n <th class=\"filter-col-toggle\">\n This component\n </th>\n </tr>\n </thead>\n <tbody>\n @for (row of rows(); track row.column) {\n <tr>\n <td class=\"filter-col-name\">\n {{ row.label }}\n </td>\n <td class=\"filter-col-type\">\n {{ typeLabel(row.type) }}\n </td>\n <td class=\"filter-col-toggle\">\n <mat-slide-toggle\n [ngModel]=\"row.report\"\n (ngModelChange)=\"setExposure(row, 'report', $event)\"\n [ngModelOptions]=\"{ standalone: true }\"\n matTooltip=\"Show in the report bar\">\n </mat-slide-toggle>\n </td>\n <td class=\"filter-col-toggle\">\n <mat-slide-toggle\n [ngModel]=\"row.component\"\n (ngModelChange)=\"setExposure(row, 'component', $event)\"\n [ngModelOptions]=\"{ standalone: true }\"\n matTooltip=\"Show on this component\">\n </mat-slide-toggle>\n </td>\n </tr>\n }\n </tbody>\n </table>\n } @else {\n <div class=\"filters-empty\">\n This component has no filterable columns yet \u2014 give it some SQL first.\n </div>\n }\n </div>\n </mat-tab>\n <mat-tab\n label=\"SQL\"\n name=\"sql\">\n <div class=\"tab-body\">\n @if (sqlError()) {\n <div class=\"filters-empty error\">\n {{ sqlError() }}\n </div>\n } @else if (sql()) {\n @if (sqlBaseline()) {\n <!-- A derived component's statement is compiled from its\n document with no viewer state, so it is not byte-for-byte\n what produced the numbers on screen. Said here rather than\n left for the reader to discover. -->\n <div class=\"filters-empty\">\n Compiled from this component's query \u2014 no filters applied, times in {{ sqlTimezone() }}.\n </div>\n }\n <pre class=\"code-block\">{{ sql() }}</pre>\n } @else {\n <div class=\"filters-empty\">\n This component has no SQL yet.\n </div>\n }\n </div>\n </mat-tab>\n <mat-tab\n label=\"Config\"\n name=\"config\">\n <div class=\"tab-body\">\n <pre class=\"code-block\">\n {{ configJson }}\n </pre>\n </div>\n </mat-tab>\n </mat-tab-group>\n </mat-dialog-content>\n <mat-dialog-actions>\n <!-- Save | Delete | Cancel, the standard order. fs-form-dialog-actions\n renders its own Save/Done BEFORE the projected content, which would\n put Delete after Cancel, so `[save]=\"false\"` foots the row here and\n all three buttons are projected in order. Save submits the Settings\n tab; the other tabs persist as you touch them, so they get Done. -->\n <fs-form-dialog-actions [save]=\"false\">\n @if (selectedTab === 'settings') {\n <button\n mat-button\n type=\"submit\"\n color=\"primary\">\n Save\n </button>\n } @else {\n <button\n mat-button\n type=\"button\"\n color=\"primary\"\n [mat-dialog-close]=\"null\">\n Done\n </button>\n }\n <button\n mat-button\n type=\"button\"\n color=\"warn\"\n (click)=\"delete()\">\n Delete\n </button>\n <button\n mat-button\n type=\"button\"\n class=\"cancel-button\"\n [mat-dialog-close]=\"null\">\n Cancel\n </button>\n </fs-form-dialog-actions>\n </mat-dialog-actions>\n </fs-dialog>\n</form>", styles: ["mat-form-field{width:100%}.tab-body{padding-top:16px}.geometry-row{display:flex;gap:8px}.geometry-row mat-form-field{flex:1}.filter-table{border-spacing:0;width:100%;border-collapse:collapse}.filter-table thead th{color:#8f8f8f;font-weight:400;font-size:85%;text-align:left;padding:8px 16px}.filter-table tbody tr{clip-path:xywh(0 3px 100% calc(100% - 6px) round 10px)}.filter-table tbody td{border:none;background-color:#fafafa;padding:8px 16px;vertical-align:middle;text-align:left}.filter-table .filter-col-name{font-weight:600;color:#1f2933}.filter-table .filter-col-type{color:#7b8794;width:120px}.filter-table .filter-col-toggle,.filter-table th.filter-col-toggle{width:130px;text-align:center}.filters-empty{font-size:13px;color:#7b8794;padding:8px 0 4px}.filters-empty.error{color:#e15759}.code-block{margin:0;padding:10px 12px;background:#1f2933;color:#e4e7eb;border-radius:6px;font-family:JetBrains Mono,Consolas,monospace;font-size:12px;line-height:1.5;white-space:pre-wrap;word-break:break-word;max-height:240px;overflow:auto}\n"] }]
|
|
3542
4178
|
}] });
|
|
3543
4179
|
|
|
3544
4180
|
// Self-contained timezone picker for the report settings dialog. The IANA zone
|
|
3545
4181
|
// list comes from the browser (Intl.supportedValuesOf), so the package carries no
|
|
3546
|
-
// dependency on any app timezone API.
|
|
4182
|
+
// dependency on any app timezone API. It shows the zone it is given and never
|
|
4183
|
+
// picks one itself: the dialog always seeds the zone the report uses, and that
|
|
4184
|
+
// zone is listed even when the browser's list lacks it (some browsers leave
|
|
4185
|
+
// 'UTC' out).
|
|
3547
4186
|
class TimezoneSelectComponent {
|
|
3548
4187
|
placeholder = 'Timezone';
|
|
3549
4188
|
required = false;
|
|
3550
4189
|
disabled = false;
|
|
3551
4190
|
timezoneChange = new EventEmitter();
|
|
3552
4191
|
set timezone(value) {
|
|
3553
|
-
if (value && value !== this._timezone) {
|
|
3554
|
-
this._timezone
|
|
3555
|
-
this.timezoneChange.emit(
|
|
4192
|
+
if (value && value !== this._timezone()) {
|
|
4193
|
+
this._timezone.set(value);
|
|
4194
|
+
this.timezoneChange.emit(value);
|
|
3556
4195
|
}
|
|
3557
4196
|
}
|
|
3558
4197
|
get timezone() {
|
|
3559
|
-
return this._timezone;
|
|
4198
|
+
return this._timezone();
|
|
3560
4199
|
}
|
|
3561
4200
|
guid = guid();
|
|
3562
|
-
timezones =
|
|
3563
|
-
|
|
3564
|
-
|
|
3565
|
-
|
|
3566
|
-
|
|
3567
|
-
|
|
3568
|
-
this.timezone = this._browserTimezone;
|
|
3569
|
-
}
|
|
3570
|
-
}
|
|
3571
|
-
// The browser's IANA zone list where available; fall back to at least the
|
|
3572
|
-
// viewer's own zone so the control is never empty.
|
|
3573
|
-
_supportedTimezones() {
|
|
4201
|
+
timezones = computed(() => this._supportedTimezones(this._timezone()));
|
|
4202
|
+
_timezone = signal('');
|
|
4203
|
+
// The browser's IANA zone list where available, with the current value
|
|
4204
|
+
// prepended when the list lacks it, so the select always shows the zone the
|
|
4205
|
+
// report uses.
|
|
4206
|
+
_supportedTimezones(current) {
|
|
3574
4207
|
const intl = Intl;
|
|
3575
|
-
|
|
4208
|
+
const zones = typeof intl.supportedValuesOf === 'function'
|
|
3576
4209
|
? intl.supportedValuesOf('timeZone')
|
|
3577
|
-
: [
|
|
4210
|
+
: [];
|
|
4211
|
+
return current && !zones.includes(current)
|
|
4212
|
+
? [current, ...zones]
|
|
4213
|
+
: zones;
|
|
3578
4214
|
}
|
|
3579
4215
|
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: TimezoneSelectComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
3580
|
-
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "18.2.14", type: TimezoneSelectComponent, isStandalone: true, selector: "fs-ai-report-timezone-select", inputs: { placeholder: "placeholder", required: "required", disabled: "disabled", timezone: "timezone" }, outputs: { timezoneChange: "timezoneChange" }, ngImport: i0, template: "<mat-form-field class=\"full-width\">\n <mat-label>{{placeholder}}</mat-label>\n <mat-select\n [(ngModel)]=\"timezone\"\n [fsFormRequired]=\"required\"\n [disabled]=\"disabled\"\n name=\"timezone_select_{{ guid }}\">\n @for (item of timezones; track item) {\n <mat-option [value]=\"item\">{{ item }}</mat-option>\n }\n </mat-select>\n</mat-form-field>\n", styles: [".full-width{width:100%}\n"], dependencies: [{ kind: "component", type: MatFormField, selector: "mat-form-field", inputs: ["hideRequiredMarker", "color", "floatLabel", "appearance", "subscriptSizing", "hintLabel"], exportAs: ["matFormField"] }, { kind: "directive", type: MatLabel, selector: "mat-label" }, { kind: "component", type: MatSelect, selector: "mat-select", inputs: ["aria-describedby", "panelClass", "disabled", "disableRipple", "tabIndex", "hideSingleSelectionIndicator", "placeholder", "required", "multiple", "disableOptionCentering", "compareWith", "value", "aria-label", "aria-labelledby", "errorStateMatcher", "typeaheadDebounceInterval", "sortComparator", "id", "panelWidth"], outputs: ["openedChange", "opened", "closed", "selectionChange", "valueChange"], exportAs: ["matSelect"] }, { kind: "component", type: MatOption, selector: "mat-option", inputs: ["value", "id", "disabled"], outputs: ["onSelectionChange"], exportAs: ["matOption"] }, { kind: "ngmodule", type: FormsModule }, { kind: "directive", type: i1$1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1$1.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }, { kind: "ngmodule", type: FsFormModule }, { kind: "directive", type: i2$2.FsFormRequiredDirective, selector: "[fsFormRequired],[ngModel][required]", inputs: ["fsFormRequired", "required", "fsFormRequiredMessage"] }], viewProviders: [{ provide: ControlContainer, useExisting: NgForm }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
4216
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "18.2.14", type: TimezoneSelectComponent, isStandalone: true, selector: "fs-ai-report-timezone-select", inputs: { placeholder: "placeholder", required: "required", disabled: "disabled", timezone: "timezone" }, outputs: { timezoneChange: "timezoneChange" }, ngImport: i0, template: "<mat-form-field class=\"full-width\">\n <mat-label>{{placeholder}}</mat-label>\n <mat-select\n [(ngModel)]=\"timezone\"\n [fsFormRequired]=\"required\"\n [disabled]=\"disabled\"\n name=\"timezone_select_{{ guid }}\">\n @for (item of timezones(); track item) {\n <mat-option [value]=\"item\">{{ item }}</mat-option>\n }\n </mat-select>\n</mat-form-field>\n", styles: [".full-width{width:100%}\n"], dependencies: [{ kind: "component", type: MatFormField, selector: "mat-form-field", inputs: ["hideRequiredMarker", "color", "floatLabel", "appearance", "subscriptSizing", "hintLabel"], exportAs: ["matFormField"] }, { kind: "directive", type: MatLabel, selector: "mat-label" }, { kind: "component", type: MatSelect, selector: "mat-select", inputs: ["aria-describedby", "panelClass", "disabled", "disableRipple", "tabIndex", "hideSingleSelectionIndicator", "placeholder", "required", "multiple", "disableOptionCentering", "compareWith", "value", "aria-label", "aria-labelledby", "errorStateMatcher", "typeaheadDebounceInterval", "sortComparator", "id", "panelWidth"], outputs: ["openedChange", "opened", "closed", "selectionChange", "valueChange"], exportAs: ["matSelect"] }, { kind: "component", type: MatOption, selector: "mat-option", inputs: ["value", "id", "disabled"], outputs: ["onSelectionChange"], exportAs: ["matOption"] }, { kind: "ngmodule", type: FormsModule }, { kind: "directive", type: i1$1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1$1.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }, { kind: "ngmodule", type: FsFormModule }, { kind: "directive", type: i2$2.FsFormRequiredDirective, selector: "[fsFormRequired],[ngModel][required]", inputs: ["fsFormRequired", "required", "fsFormRequiredMessage"] }], viewProviders: [{ provide: ControlContainer, useExisting: NgForm }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
3581
4217
|
}
|
|
3582
4218
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: TimezoneSelectComponent, decorators: [{
|
|
3583
4219
|
type: Component,
|
|
@@ -3588,7 +4224,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImpo
|
|
|
3588
4224
|
MatOption,
|
|
3589
4225
|
FormsModule,
|
|
3590
4226
|
FsFormModule,
|
|
3591
|
-
], template: "<mat-form-field class=\"full-width\">\n <mat-label>{{placeholder}}</mat-label>\n <mat-select\n [(ngModel)]=\"timezone\"\n [fsFormRequired]=\"required\"\n [disabled]=\"disabled\"\n name=\"timezone_select_{{ guid }}\">\n @for (item of timezones; track item) {\n <mat-option [value]=\"item\">{{ item }}</mat-option>\n }\n </mat-select>\n</mat-form-field>\n", styles: [".full-width{width:100%}\n"] }]
|
|
4227
|
+
], template: "<mat-form-field class=\"full-width\">\n <mat-label>{{placeholder}}</mat-label>\n <mat-select\n [(ngModel)]=\"timezone\"\n [fsFormRequired]=\"required\"\n [disabled]=\"disabled\"\n name=\"timezone_select_{{ guid }}\">\n @for (item of timezones(); track item) {\n <mat-option [value]=\"item\">{{ item }}</mat-option>\n }\n </mat-select>\n</mat-form-field>\n", styles: [".full-width{width:100%}\n"] }]
|
|
3592
4228
|
}], propDecorators: { placeholder: [{
|
|
3593
4229
|
type: Input
|
|
3594
4230
|
}], required: [{
|
|
@@ -3601,27 +4237,6 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImpo
|
|
|
3601
4237
|
type: Input
|
|
3602
4238
|
}] } });
|
|
3603
4239
|
|
|
3604
|
-
// Date-range defaults are stored as calendar-day strings (yyyy-MM-dd, no time,
|
|
3605
|
-
// no timezone) so the backend never shifts them; the date picker works in Date
|
|
3606
|
-
// objects. These two convert between the two forms — the only translation the
|
|
3607
|
-
// Filters tab needs for a fixed date range.
|
|
3608
|
-
// A stored yyyy-MM-dd boundary as a local Date for the picker, or null.
|
|
3609
|
-
function dateFromBound(value) {
|
|
3610
|
-
if (!value) {
|
|
3611
|
-
return null;
|
|
3612
|
-
}
|
|
3613
|
-
const date = parseISO(value.slice(0, 10));
|
|
3614
|
-
return isNaN(date.getTime()) ? null : date;
|
|
3615
|
-
}
|
|
3616
|
-
// A picked Date as a yyyy-MM-dd boundary for storage, or null. Uses the local
|
|
3617
|
-
// calendar day (format, not toISOString) so the boundary is the day the admin
|
|
3618
|
-
// picked, regardless of timezone.
|
|
3619
|
-
function dateToBound(value) {
|
|
3620
|
-
return value instanceof Date && !isNaN(value.getTime())
|
|
3621
|
-
? format(value, 'yyyy-MM-dd')
|
|
3622
|
-
: null;
|
|
3623
|
-
}
|
|
3624
|
-
|
|
3625
4240
|
// Report settings, in three tabs:
|
|
3626
4241
|
// Settings — name + page setup (size in inches, header/footer band depths,
|
|
3627
4242
|
// layout mode).
|
|
@@ -3652,12 +4267,15 @@ class ReportSettingsComponent {
|
|
|
3652
4267
|
footerHeight = 0;
|
|
3653
4268
|
headerFooterMax = HEADER_FOOTER_MAX;
|
|
3654
4269
|
layout = 'freeform';
|
|
3655
|
-
//
|
|
3656
|
-
//
|
|
4270
|
+
// Seeded with the zone the report actually uses: its own stored zone, else
|
|
4271
|
+
// the application default the server resolved (effectiveTimezone). Save sends
|
|
4272
|
+
// it only when it differs from that effective zone, so opening and saving a
|
|
4273
|
+
// report that follows the default never pins it to a zone.
|
|
3657
4274
|
timezone = '';
|
|
3658
4275
|
headingSize = DEFAULT_HEADING_SIZE;
|
|
3659
4276
|
// The report-level filters and their editable default values (Filters tab).
|
|
3660
4277
|
filterRows = signal([]);
|
|
4278
|
+
relativeLabel = relativeLabel;
|
|
3661
4279
|
_data = inject(MAT_DIALOG_DATA);
|
|
3662
4280
|
_dialogRef = inject(MatDialogRef);
|
|
3663
4281
|
_reportData = inject(ReportData);
|
|
@@ -3672,7 +4290,7 @@ class ReportSettingsComponent {
|
|
|
3672
4290
|
this.headerHeight = report.config?.headerFooter?.headerHeight ?? 0;
|
|
3673
4291
|
this.footerHeight = report.config?.headerFooter?.footerHeight ?? 0;
|
|
3674
4292
|
this.layout = report.layout ?? 'freeform';
|
|
3675
|
-
this.timezone = report.timezone ?? '';
|
|
4293
|
+
this.timezone = report.timezone ?? report.effectiveTimezone ?? '';
|
|
3676
4294
|
this.headingSize = report.config?.styles?.heading?.size ?? DEFAULT_HEADING_SIZE;
|
|
3677
4295
|
this._buildFilterRows(report);
|
|
3678
4296
|
}
|
|
@@ -3700,7 +4318,7 @@ class ReportSettingsComponent {
|
|
|
3700
4318
|
pageWidth: this._pageDimension(this.pageWidth),
|
|
3701
4319
|
pageHeight: this._pageDimension(this.pageHeight),
|
|
3702
4320
|
layout: this.layout,
|
|
3703
|
-
|
|
4321
|
+
...this._timezoneSetting(),
|
|
3704
4322
|
styles: { heading: { size: this.headingSize } },
|
|
3705
4323
|
headerFooter: {
|
|
3706
4324
|
headerHeight: this._pageDimension(this.headerHeight) || 0,
|
|
@@ -3713,11 +4331,17 @@ class ReportSettingsComponent {
|
|
|
3713
4331
|
default: this._rowDefault(row),
|
|
3714
4332
|
}));
|
|
3715
4333
|
return forkJoin([settings$, ...defaults$])
|
|
3716
|
-
.pipe(tap(() => {
|
|
4334
|
+
.pipe(tap$1(() => {
|
|
3717
4335
|
this._message.success('Report settings saved');
|
|
3718
4336
|
this._dialogRef.close({ action: 'saved' });
|
|
3719
4337
|
}));
|
|
3720
4338
|
};
|
|
4339
|
+
// Drop the row's stored rolling preset (Clear). A new row object rather than
|
|
4340
|
+
// a mutation, so the signal sees the change; the @for tracks group.id, so
|
|
4341
|
+
// the row's bindings survive the swap.
|
|
4342
|
+
clearRelative(row) {
|
|
4343
|
+
this.filterRows.update((rows) => rows.map((item) => (item === row ? { ...item, relative: null } : item)));
|
|
4344
|
+
}
|
|
3721
4345
|
// The page dimension as a real number for the wire. fsMaskNumber hands back
|
|
3722
4346
|
// the unmasked string it built with a fixed '.' radix (which is the whole
|
|
3723
4347
|
// point of the mask - a native number input paints the viewer's locale
|
|
@@ -3727,6 +4351,16 @@ class ReportSettingsComponent {
|
|
|
3727
4351
|
_pageDimension(value) {
|
|
3728
4352
|
return typeof value === 'number' ? value : parseFloat(value);
|
|
3729
4353
|
}
|
|
4354
|
+
// The zone for the settings PUT, sent only when it moves the report off the
|
|
4355
|
+
// zone it already uses, so an untouched dialog never pins a report that
|
|
4356
|
+
// follows the application default. A server too old to send
|
|
4357
|
+
// effectiveTimezone gets the zone every time, as it always did.
|
|
4358
|
+
_timezoneSetting() {
|
|
4359
|
+
const effectiveTimezone = this._data.report.effectiveTimezone;
|
|
4360
|
+
return effectiveTimezone === undefined || this.timezone !== effectiveTimezone
|
|
4361
|
+
? { timezone: this.timezone }
|
|
4362
|
+
: {};
|
|
4363
|
+
}
|
|
3730
4364
|
// Build one editable row per report-level filter group (level report or both),
|
|
3731
4365
|
// in bar order, seeding each draft from the group's stored default.
|
|
3732
4366
|
_buildFilterRows(report) {
|
|
@@ -3744,6 +4378,7 @@ class ReportSettingsComponent {
|
|
|
3744
4378
|
label: group.label || group.filters?.[0]?.filterColumn || 'Filter',
|
|
3745
4379
|
start: dateFromBound(groupDefault?.start),
|
|
3746
4380
|
end: dateFromBound(groupDefault?.end),
|
|
4381
|
+
relative: groupDefault?.relative ?? null,
|
|
3747
4382
|
// Named below, once the option list carrying their labels is in.
|
|
3748
4383
|
values: [],
|
|
3749
4384
|
value: groupDefault?.value ?? '',
|
|
@@ -3772,13 +4407,18 @@ class ReportSettingsComponent {
|
|
|
3772
4407
|
});
|
|
3773
4408
|
}
|
|
3774
4409
|
// The draft default normalized to the wire shape for this row's type, or null
|
|
3775
|
-
// when empty (which clears the stored default).
|
|
4410
|
+
// when empty (which clears the stored default). A date range is the picked
|
|
4411
|
+
// dates when there are any, else the carried rolling preset, never both: the
|
|
4412
|
+
// server refuses a relative preset combined with dates.
|
|
3776
4413
|
_rowDefault(row) {
|
|
3777
4414
|
switch (row.group.type) {
|
|
3778
4415
|
case 'dateRange': {
|
|
3779
4416
|
const start = dateToBound(row.start);
|
|
3780
4417
|
const end = dateToBound(row.end);
|
|
3781
|
-
|
|
4418
|
+
if (start || end) {
|
|
4419
|
+
return { start: start ?? undefined, end: end ?? undefined };
|
|
4420
|
+
}
|
|
4421
|
+
return row.relative ? { relative: row.relative } : null;
|
|
3782
4422
|
}
|
|
3783
4423
|
case 'select':
|
|
3784
4424
|
return row.values.length ? { values: row.values.map((option) => option.value) } : null;
|
|
@@ -3796,8 +4436,9 @@ class ReportSettingsComponent {
|
|
|
3796
4436
|
const after = JSON.stringify(this._normalize(this._rowDefault(row)));
|
|
3797
4437
|
return before !== after;
|
|
3798
4438
|
}
|
|
3799
|
-
// A stable comparison form:
|
|
3800
|
-
//
|
|
4439
|
+
// A stable comparison form: empty and undefined keys are dropped so a no-op
|
|
4440
|
+
// edit doesn't read as a change. relative is compared like the rest: the row
|
|
4441
|
+
// carries it, so an untouched rolling default reads as unchanged.
|
|
3801
4442
|
_normalize(value) {
|
|
3802
4443
|
if (!value) {
|
|
3803
4444
|
return null;
|
|
@@ -3821,7 +4462,7 @@ class ReportSettingsComponent {
|
|
|
3821
4462
|
return Object.keys(normalized).length ? normalized : null;
|
|
3822
4463
|
}
|
|
3823
4464
|
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: ReportSettingsComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
3824
|
-
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "18.2.14", type: ReportSettingsComponent, isStandalone: true, selector: "ng-component", ngImport: i0, template: "<form\n fsForm\n [submit]=\"save\">\n <fs-dialog>\n <h1 mat-dialog-title>\n Report Settings\n </h1>\n <mat-dialog-content>\n <mat-tab-group [(selected)]=\"selectedTab\">\n <mat-tab\n label=\"Settings\"\n name=\"settings\">\n <div class=\"fs-column tab-body\">\n <mat-form-field>\n <mat-label>\n Name\n </mat-label>\n <input\n matInput\n [(ngModel)]=\"name\"\n name=\"name\"\n [fsFormRequired]=\"true\">\n </mat-form-field>\n <!--\n The page, typed in inches. Orientation is not asked for because it\n is not an independent choice - a page wider than it is tall IS\n landscape - so the two numbers say everything there is to say.\n -->\n <div class=\"fs-row.gap-sm.align-start\">\n <mat-form-field class=\"fs-flex\">\n <mat-label>\n Width\n </mat-label>\n <input\n matInput\n fsMaskNumber\n [scale]=\"1\"\n [(ngModel)]=\"pageWidth\"\n name=\"pageWidth\"\n [fsFormRequired]=\"true\"\n [fsFormMin]=\"pageDimensionMin\"\n [fsFormMax]=\"pageDimensionMax\">\n <div matTextSuffix>\n inches\n </div>\n </mat-form-field>\n <mat-form-field class=\"fs-flex\">\n <mat-label>\n Height\n </mat-label>\n <input\n matInput\n fsMaskNumber\n [scale]=\"1\"\n [(ngModel)]=\"pageHeight\"\n name=\"pageHeight\"\n [fsFormRequired]=\"true\"\n [fsFormMin]=\"pageDimensionMin\"\n [fsFormMax]=\"pageDimensionMax\">\n <div matTextSuffix>\n inches\n </div>\n </mat-form-field>\n </div>\n <!--\n How deep the header and footer bands are. Their content is edited\n on the canvas (Edit layout \u2192 Header & footer); these reserve the\n space for it, and 0 means no band.\n -->\n <mat-form-field class=\"full-width\">\n <mat-label>\n Header height\n </mat-label>\n <input\n matInput\n fsMaskNumber\n [scale]=\"2\"\n [(ngModel)]=\"headerHeight\"\n name=\"headerHeight\"\n [fsFormMin]=\"0\"\n [fsFormMax]=\"headerFooterMax\">\n <div matTextSuffix>\n inches\n </div>\n </mat-form-field>\n <mat-form-field class=\"full-width\">\n <mat-label>\n Footer height\n </mat-label>\n <input\n matInput\n fsMaskNumber\n [scale]=\"2\"\n [(ngModel)]=\"footerHeight\"\n name=\"footerHeight\"\n [fsFormMin]=\"0\"\n [fsFormMax]=\"headerFooterMax\">\n <div matTextSuffix>\n inches\n </div>\n </mat-form-field>\n <mat-form-field>\n <mat-label>\n Layout\n </mat-label>\n <mat-select\n [(ngModel)]=\"layout\"\n name=\"layout\">\n <mat-option value=\"freeform\">\n Freeform \u2014 position components anywhere on the page\n </mat-option>\n <mat-option value=\"flow\">\n Flow \u2014 components flow into rows by width %\n </mat-option>\n </mat-select>\n <mat-hint>\n Freeform is like PowerPoint; Flow is like a responsive dashboard.\n </mat-hint>\n </mat-form-field>\n <fs-ai-report-timezone-select [(timezone)]=\"timezone\"></fs-ai-report-timezone-select>\n </div>\n </mat-tab>\n <mat-tab\n label=\"Styles\"\n name=\"styles\">\n <div class=\"fs-column tab-body\">\n <mat-form-field>\n <mat-label>\n Heading Size\n </mat-label>\n <input\n matInput\n type=\"number\"\n step=\"1\"\n min=\"6\"\n max=\"96\"\n [(ngModel)]=\"headingSize\"\n name=\"headingSize\"\n [fsFormMin]=\"6\"\n [fsFormMax]=\"96\">\n <span matTextSuffix>\n pt\n </span>\n <mat-hint>\n Applies to component titles. Points map 1:1 to PDF and PowerPoint.\n </mat-hint>\n </mat-form-field>\n </div>\n </mat-tab>\n <mat-tab\n label=\"Filters\"\n name=\"filters\">\n <div class=\"fs-column tab-body\">\n @if (filterRows().length) {\n @for (row of filterRows(); track row.group.id) {\n @switch (row.group.type) {\n @case ('dateRange') {\n <div class=\"fs-row.gap-sm.align-center fs-flex\">\n <mat-form-field class=\"fs-flex\">\n <mat-label>\n Default From {{ row.label }}\n </mat-label>\n <input\n matInput\n fsDatePicker\n [(ngModel)]=\"row.start\"\n [name]=\"'start' + row.group.id\">\n </mat-form-field>\n <mat-form-field class=\"fs-flex\">\n <mat-label>\n Default To {{ row.label }}\n </mat-label>\n <input\n matInput\n fsDatePicker\n [(ngModel)]=\"row.end\"\n [name]=\"'end' + row.group.id\">\n </mat-form-field>\n </div>\n }\n @case ('select') {\n <fs-autocomplete-chips\n class=\"fs-flex\"\n [label]=\"'Default ' + row.label\"\n [fetch]=\"row.fetch\"\n [(ngModel)]=\"row.values\"\n [name]=\"'values' + row.group.id\"\n [multiple]=\"true\"\n [fetchOnFocus]=\"true\">\n <ng-template\n fsAutocompleteChipsTemplate\n let-object=\"object\">\n {{ object.name }}\n </ng-template>\n </fs-autocomplete-chips>\n }\n @default {\n <mat-form-field class=\"fs-flex\">\n <mat-label>\n Default {{ row.label }}\n </mat-label>\n <input\n matInput\n [(ngModel)]=\"row.value\"\n [name]=\"'value' + row.group.id\">\n </mat-form-field>\n }\n }\n }\n } @else {\n <fs-message-info>\n This report has no report-level filters yet. Add a filter to a\n component (its settings \u2192 Filters) and show it in the report bar,\n then set its default here.\n </fs-message-info>\n }\n </div>\n </mat-tab>\n </mat-tab-group>\n </mat-dialog-content>\n <mat-dialog-actions>\n <fs-form-dialog-actions>\n <button\n type=\"button\"\n mat-button\n color=\"warn\"\n (click)=\"delete()\">\n Delete\n </button>\n </fs-form-dialog-actions>\n </mat-dialog-actions>\n </fs-dialog>\n</form>", styles: ["mat-form-field{width:100%}.tab-body{padding-top:16px}\n"], dependencies: [{ kind: "ngmodule", type: FormsModule }, { kind: "directive", type: i1$1.ɵNgNoValidate, selector: "form:not([ngNoForm]):not([ngNativeValidate])" }, { kind: "directive", type: i1$1.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1$1.NumberValueAccessor, selector: "input[type=number][formControlName],input[type=number][formControl],input[type=number][ngModel]" }, { kind: "directive", type: i1$1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1$1.NgControlStatusGroup, selector: "[formGroupName],[formArrayName],[ngModelGroup],[formGroup],form:not([ngNoForm]),[ngForm]" }, { kind: "directive", type: i1$1.MinValidator, selector: "input[type=number][min][formControlName],input[type=number][min][formControl],input[type=number][min][ngModel]", inputs: ["min"] }, { kind: "directive", type: i1$1.MaxValidator, selector: "input[type=number][max][formControlName],input[type=number][max][formControl],input[type=number][max][ngModel]", inputs: ["max"] }, { kind: "directive", type: i1$1.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }, { kind: "directive", type: i1$1.NgForm, selector: "form:not([ngNoForm]):not([formGroup]),ng-form,[ngForm]", inputs: ["ngFormOptions"], outputs: ["ngSubmit"], exportAs: ["ngForm"] }, { kind: "ngmodule", type: FsFormModule }, { kind: "directive", type: i2$2.FsFormDirective, selector: "[fsForm]", inputs: ["wrapperSelector", "messageSelector", "hintSelector", "labelSelector", "autocomplete", "shortcuts", "confirm", "confirmDialog", "confirmDrawer", "confirmBrowser", "dirtySubmitButton", "submit", "successDelay", "errorDelay", "deactivationGuard"], outputs: ["fsForm", "invalid", "valid", "submitted", "reseted", "cleared"], exportAs: ["fsForm"] }, { kind: "directive", type: i2$2.FsFormRequiredDirective, selector: "[fsFormRequired],[ngModel][required]", inputs: ["fsFormRequired", "required", "fsFormRequiredMessage"] }, { kind: "directive", type: i2$2.FsFormMinDirective, selector: "[fsFormMin]", inputs: ["fsFormMin", "fsFormMinMessage"] }, { kind: "directive", type: i2$2.FsFormMaxDirective, selector: "[fsFormMax]", inputs: ["fsFormMax", "fsFormMaxMessage"] }, { kind: "component", type: i2$2.FsFormDialogActionsComponent, selector: "fs-form-dialog-actions", inputs: ["save", "create", "close", "done", "closeData", "name"] }, { kind: "directive", type: i2$2.FsFormNoFsValidatorsDirective, selector: "[ngModel]:not([required]):not([fsFormRequired]):not([fsFormCompare]):not([fsFormDateRange]):not([fsFormEmail]):not([fsFormEmails]):not([fsFormFunction]):not([fsFormGreater]):not([fsFormGreaterEqual]):not([fsFormInteger]):not([fsFormLesser]):not([fsFormMax]):not([fsFormMaxLength]):not([fsFormMin]):not([fsFormMinLength]):not([fsFormNumeric]):not([fsFormPattern]):not([fsFormPhone]):not([fsFormUrl]):not([validate])" }, { kind: "directive", type: i2$2.FsButtonDirective, selector: "[mat-raised-button],[mat-button],[mat-flat-button],[mat-stroked-button]", inputs: ["name", "dirtySubmit"] }, { kind: "ngmodule", type: FsMaskModule }, { kind: "directive", type: i3$1.FsMaskNumberDirective, selector: "[fsMaskNumber]" }, { kind: "ngmodule", type: FsDialogModule }, { kind: "component", type: i3$2.FsDialogComponent, selector: "fs-dialog", inputs: ["mobileMode", "mobileButtonPlacement", "mobileWidth", "mode", "buttonLayout"] }, { kind: "ngmodule", type: FsMessageModule }, { kind: "component", type: i5$1.FsMessageInfoComponent, selector: "fs-message-info" }, { kind: "ngmodule", type: FsTabsModule }, { kind: "directive", type: i5.FsTabsHeaderTabGroupDirective, selector: "mat-tab-group, matTabGroup, [matTabGroup]", inputs: ["orientation", "selected", "selectedData"], outputs: ["selectedChange", "selectedDataChange"], exportAs: ["fsTabsHeaderTabGroup"] }, { kind: "directive", type: i5.FsTabsTabDirective, selector: "mat-tab,matTab", inputs: ["name", "data"], exportAs: ["fsTabsTab"] }, { kind: "ngmodule", type: FsDatePickerModule }, { kind: "component", type: i7.FsDatePickerComponent, selector: "[fsDatePicker]", inputs: ["minYear", "maxYear", "minDate", "maxDate", "startOfDay", "view", "format", "minutes", "width"], outputs: ["change"] }, { kind: "ngmodule", type: FsAutocompleteChipsModule }, { kind: "component", type: i2$3.FsAutocompleteChipsComponent, selector: "fs-autocomplete-chips", inputs: ["fetch", "appearance", "floatLabel", "readonly", "size", "label", "placeholder", "chipImage", "chipBackground", "chipColor", "chipIcon", "chipIconColor", "chipClass", "chipPadding", "shape", "hint", "allowText", "allowObject", "delay", "minPanelWidth", "maxPanelHeight", "validateText", "removable", "allowClear", "color", "background", "orderable", "padless", "initOnClick", "fetchOnFocus", "multiple", "multipleAdd", "confirm", "disabled", "groupBy", "panelWidth", "panelClass", "compareWith"], outputs: ["selected", "removed", "reordered", "clear", "panelOpened", "panelClosed"] }, { kind: "directive", type: i2$3.FsAutocompleteObjectDirective, selector: "[fsAutocompleteObject],[fsAutocompleteChipsTemplate]" }, { kind: "ngmodule", type: MatTabsModule }, { kind: "component", type: i6.MatTab, selector: "mat-tab", inputs: ["disabled", "label", "aria-label", "aria-labelledby", "labelClass", "bodyClass"], exportAs: ["matTab"] }, { kind: "component", type: i6.MatTabGroup, selector: "mat-tab-group", inputs: ["color", "fitInkBarToContent", "mat-stretch-tabs", "dynamicHeight", "selectedIndex", "headerPosition", "animationDuration", "contentTabIndex", "disablePagination", "disableRipple", "preserveContent", "backgroundColor", "aria-label", "aria-labelledby"], outputs: ["selectedIndexChange", "focusChange", "animationDone", "selectedTabChange"], exportAs: ["matTabGroup"] }, { kind: "component", type: MatButton, selector: " button[mat-button], button[mat-raised-button], button[mat-flat-button], button[mat-stroked-button] ", exportAs: ["matButton"] }, { kind: "directive", type: MatDialogTitle, selector: "[mat-dialog-title], [matDialogTitle]", inputs: ["id"], exportAs: ["matDialogTitle"] }, { kind: "directive", type: MatDialogContent, selector: "[mat-dialog-content], mat-dialog-content, [matDialogContent]" }, { kind: "directive", type: MatDialogActions, selector: "[mat-dialog-actions], mat-dialog-actions, [matDialogActions]", inputs: ["align"] }, { kind: "component", type: MatFormField, selector: "mat-form-field", inputs: ["hideRequiredMarker", "color", "floatLabel", "appearance", "subscriptSizing", "hintLabel"], exportAs: ["matFormField"] }, { kind: "directive", type: MatLabel, selector: "mat-label" }, { kind: "directive", type: MatHint, selector: "mat-hint", inputs: ["align", "id"] }, { kind: "directive", type: MatSuffix, selector: "[matSuffix], [matIconSuffix], [matTextSuffix]", inputs: ["matTextSuffix"] }, { kind: "directive", type: MatInput, selector: "input[matInput], textarea[matInput], select[matNativeControl], input[matNativeControl], textarea[matNativeControl]", inputs: ["disabled", "id", "placeholder", "name", "required", "type", "errorStateMatcher", "aria-describedby", "value", "readonly"], exportAs: ["matInput"] }, { kind: "component", type: MatSelect, selector: "mat-select", inputs: ["aria-describedby", "panelClass", "disabled", "disableRipple", "tabIndex", "hideSingleSelectionIndicator", "placeholder", "required", "multiple", "disableOptionCentering", "compareWith", "value", "aria-label", "aria-labelledby", "errorStateMatcher", "typeaheadDebounceInterval", "sortComparator", "id", "panelWidth"], outputs: ["openedChange", "opened", "closed", "selectionChange", "valueChange"], exportAs: ["matSelect"] }, { kind: "component", type: MatOption$1, selector: "mat-option", inputs: ["value", "id", "disabled"], outputs: ["onSelectionChange"], exportAs: ["matOption"] }, { kind: "component", type: TimezoneSelectComponent, selector: "fs-ai-report-timezone-select", inputs: ["placeholder", "required", "disabled", "timezone"], outputs: ["timezoneChange"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
4465
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "18.2.14", type: ReportSettingsComponent, isStandalone: true, selector: "ng-component", ngImport: i0, template: "<form\n fsForm\n [submit]=\"save\">\n <fs-dialog>\n <h1 mat-dialog-title>\n Report Settings\n </h1>\n <mat-dialog-content>\n <mat-tab-group [(selected)]=\"selectedTab\">\n <mat-tab\n label=\"Settings\"\n name=\"settings\">\n <div class=\"fs-column tab-body\">\n <mat-form-field>\n <mat-label>\n Name\n </mat-label>\n <input\n matInput\n [(ngModel)]=\"name\"\n name=\"name\"\n [fsFormRequired]=\"true\">\n </mat-form-field>\n <!--\n The page, typed in inches. Orientation is not asked for because it\n is not an independent choice - a page wider than it is tall IS\n landscape - so the two numbers say everything there is to say.\n -->\n <div class=\"fs-row.gap-sm.align-start\">\n <mat-form-field class=\"fs-flex\">\n <mat-label>\n Width\n </mat-label>\n <input\n matInput\n fsMaskNumber\n [scale]=\"1\"\n [(ngModel)]=\"pageWidth\"\n name=\"pageWidth\"\n [fsFormRequired]=\"true\"\n [fsFormMin]=\"pageDimensionMin\"\n [fsFormMax]=\"pageDimensionMax\">\n <div matTextSuffix>\n inches\n </div>\n </mat-form-field>\n <mat-form-field class=\"fs-flex\">\n <mat-label>\n Height\n </mat-label>\n <input\n matInput\n fsMaskNumber\n [scale]=\"1\"\n [(ngModel)]=\"pageHeight\"\n name=\"pageHeight\"\n [fsFormRequired]=\"true\"\n [fsFormMin]=\"pageDimensionMin\"\n [fsFormMax]=\"pageDimensionMax\">\n <div matTextSuffix>\n inches\n </div>\n </mat-form-field>\n </div>\n <!--\n How deep the header and footer bands are. Their content is edited\n on the canvas (Edit layout \u2192 Header & footer); these reserve the\n space for it, and 0 means no band.\n -->\n <mat-form-field class=\"full-width\">\n <mat-label>\n Header height\n </mat-label>\n <input\n matInput\n fsMaskNumber\n [scale]=\"2\"\n [(ngModel)]=\"headerHeight\"\n name=\"headerHeight\"\n [fsFormMin]=\"0\"\n [fsFormMax]=\"headerFooterMax\">\n <div matTextSuffix>\n inches\n </div>\n </mat-form-field>\n <mat-form-field class=\"full-width\">\n <mat-label>\n Footer height\n </mat-label>\n <input\n matInput\n fsMaskNumber\n [scale]=\"2\"\n [(ngModel)]=\"footerHeight\"\n name=\"footerHeight\"\n [fsFormMin]=\"0\"\n [fsFormMax]=\"headerFooterMax\">\n <div matTextSuffix>\n inches\n </div>\n </mat-form-field>\n <mat-form-field>\n <mat-label>\n Layout\n </mat-label>\n <mat-select\n [(ngModel)]=\"layout\"\n name=\"layout\">\n <mat-option value=\"freeform\">\n Freeform \u2014 position components anywhere on the page\n </mat-option>\n <mat-option value=\"flow\">\n Flow \u2014 components flow into rows by width %\n </mat-option>\n </mat-select>\n <mat-hint>\n Freeform is like PowerPoint; Flow is like a responsive dashboard.\n </mat-hint>\n </mat-form-field>\n <fs-ai-report-timezone-select [(timezone)]=\"timezone\"></fs-ai-report-timezone-select>\n </div>\n </mat-tab>\n <mat-tab\n label=\"Styles\"\n name=\"styles\">\n <div class=\"fs-column tab-body\">\n <mat-form-field>\n <mat-label>\n Heading Size\n </mat-label>\n <input\n matInput\n type=\"number\"\n step=\"1\"\n min=\"6\"\n max=\"96\"\n [(ngModel)]=\"headingSize\"\n name=\"headingSize\"\n [fsFormMin]=\"6\"\n [fsFormMax]=\"96\">\n <span matTextSuffix>\n pt\n </span>\n <mat-hint>\n Applies to component titles. Points map 1:1 to PDF and PowerPoint.\n </mat-hint>\n </mat-form-field>\n </div>\n </mat-tab>\n <mat-tab\n label=\"Filters\"\n name=\"filters\">\n <div class=\"fs-column tab-body\">\n @if (filterRows().length) {\n @for (row of filterRows(); track row.group.id) {\n @switch (row.group.type) {\n @case ('dateRange') {\n <div class=\"fs-row.gap-sm.align-center fs-flex\">\n <mat-form-field class=\"fs-flex\">\n <mat-label>\n Default From {{ row.label }}\n </mat-label>\n <input\n matInput\n fsDatePicker\n [(ngModel)]=\"row.start\"\n [name]=\"'start' + row.group.id\">\n </mat-form-field>\n <mat-form-field class=\"fs-flex\">\n <mat-label>\n Default To {{ row.label }}\n </mat-label>\n <input\n matInput\n fsDatePicker\n [(ngModel)]=\"row.end\"\n [name]=\"'end' + row.group.id\">\n </mat-form-field>\n </div>\n <!-- A stored rolling preset cannot be authored here, only\n kept, replaced by picking dates, or cleared. type=\"button\"\n keeps Clear from submitting the form. -->\n @if (row.relative && !row.start && !row.end) {\n <div class=\"fs-row.gap-sm.align-center\">\n <mat-hint class=\"fs-flex\">\n Rolling default: {{ relativeLabel(row.relative) }}. Pick dates to replace it.\n </mat-hint>\n <button\n mat-button\n type=\"button\"\n (click)=\"clearRelative(row)\">\n Clear\n </button>\n </div>\n }\n }\n @case ('select') {\n <fs-autocomplete-chips\n class=\"fs-flex\"\n [label]=\"'Default ' + row.label\"\n [fetch]=\"row.fetch\"\n [(ngModel)]=\"row.values\"\n [name]=\"'values' + row.group.id\"\n [multiple]=\"true\"\n [fetchOnFocus]=\"true\">\n <ng-template\n fsAutocompleteChipsTemplate\n let-object=\"object\">\n {{ object.name }}\n </ng-template>\n </fs-autocomplete-chips>\n }\n @default {\n <mat-form-field class=\"fs-flex\">\n <mat-label>\n Default {{ row.label }}\n </mat-label>\n <input\n matInput\n [(ngModel)]=\"row.value\"\n [name]=\"'value' + row.group.id\">\n </mat-form-field>\n }\n }\n }\n } @else {\n <fs-message-info>\n This report has no report-level filters yet. Add a filter to a\n component (its settings \u2192 Filters) and show it in the report bar,\n then set its default here.\n </fs-message-info>\n }\n </div>\n </mat-tab>\n </mat-tab-group>\n </mat-dialog-content>\n <mat-dialog-actions>\n <fs-form-dialog-actions>\n <button\n type=\"button\"\n mat-button\n color=\"warn\"\n (click)=\"delete()\">\n Delete\n </button>\n </fs-form-dialog-actions>\n </mat-dialog-actions>\n </fs-dialog>\n</form>", styles: ["mat-form-field{width:100%}.tab-body{padding-top:16px}\n"], dependencies: [{ kind: "ngmodule", type: FormsModule }, { kind: "directive", type: i1$1.ɵNgNoValidate, selector: "form:not([ngNoForm]):not([ngNativeValidate])" }, { kind: "directive", type: i1$1.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1$1.NumberValueAccessor, selector: "input[type=number][formControlName],input[type=number][formControl],input[type=number][ngModel]" }, { kind: "directive", type: i1$1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1$1.NgControlStatusGroup, selector: "[formGroupName],[formArrayName],[ngModelGroup],[formGroup],form:not([ngNoForm]),[ngForm]" }, { kind: "directive", type: i1$1.MinValidator, selector: "input[type=number][min][formControlName],input[type=number][min][formControl],input[type=number][min][ngModel]", inputs: ["min"] }, { kind: "directive", type: i1$1.MaxValidator, selector: "input[type=number][max][formControlName],input[type=number][max][formControl],input[type=number][max][ngModel]", inputs: ["max"] }, { kind: "directive", type: i1$1.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }, { kind: "directive", type: i1$1.NgForm, selector: "form:not([ngNoForm]):not([formGroup]),ng-form,[ngForm]", inputs: ["ngFormOptions"], outputs: ["ngSubmit"], exportAs: ["ngForm"] }, { kind: "ngmodule", type: FsFormModule }, { kind: "directive", type: i2$2.FsFormDirective, selector: "[fsForm]", inputs: ["wrapperSelector", "messageSelector", "hintSelector", "labelSelector", "autocomplete", "shortcuts", "confirm", "confirmDialog", "confirmDrawer", "confirmBrowser", "dirtySubmitButton", "submit", "successDelay", "errorDelay", "deactivationGuard"], outputs: ["fsForm", "invalid", "valid", "submitted", "reseted", "cleared"], exportAs: ["fsForm"] }, { kind: "directive", type: i2$2.FsFormRequiredDirective, selector: "[fsFormRequired],[ngModel][required]", inputs: ["fsFormRequired", "required", "fsFormRequiredMessage"] }, { kind: "directive", type: i2$2.FsFormMinDirective, selector: "[fsFormMin]", inputs: ["fsFormMin", "fsFormMinMessage"] }, { kind: "directive", type: i2$2.FsFormMaxDirective, selector: "[fsFormMax]", inputs: ["fsFormMax", "fsFormMaxMessage"] }, { kind: "component", type: i2$2.FsFormDialogActionsComponent, selector: "fs-form-dialog-actions", inputs: ["save", "create", "close", "done", "closeData", "name"] }, { kind: "directive", type: i2$2.FsFormNoFsValidatorsDirective, selector: "[ngModel]:not([required]):not([fsFormRequired]):not([fsFormCompare]):not([fsFormDateRange]):not([fsFormEmail]):not([fsFormEmails]):not([fsFormFunction]):not([fsFormGreater]):not([fsFormGreaterEqual]):not([fsFormInteger]):not([fsFormLesser]):not([fsFormMax]):not([fsFormMaxLength]):not([fsFormMin]):not([fsFormMinLength]):not([fsFormNumeric]):not([fsFormPattern]):not([fsFormPhone]):not([fsFormUrl]):not([validate])" }, { kind: "directive", type: i2$2.FsButtonDirective, selector: "[mat-raised-button],[mat-button],[mat-flat-button],[mat-stroked-button]", inputs: ["name", "dirtySubmit"] }, { kind: "ngmodule", type: FsMaskModule }, { kind: "directive", type: i3$1.FsMaskNumberDirective, selector: "[fsMaskNumber]" }, { kind: "ngmodule", type: FsDialogModule }, { kind: "component", type: i3$2.FsDialogComponent, selector: "fs-dialog", inputs: ["mobileMode", "mobileButtonPlacement", "mobileWidth", "mode", "buttonLayout"] }, { kind: "ngmodule", type: FsMessageModule }, { kind: "component", type: i5$1.FsMessageInfoComponent, selector: "fs-message-info" }, { kind: "ngmodule", type: FsTabsModule }, { kind: "directive", type: i5.FsTabsHeaderTabGroupDirective, selector: "mat-tab-group, matTabGroup, [matTabGroup]", inputs: ["orientation", "selected", "selectedData"], outputs: ["selectedChange", "selectedDataChange"], exportAs: ["fsTabsHeaderTabGroup"] }, { kind: "directive", type: i5.FsTabsTabDirective, selector: "mat-tab,matTab", inputs: ["name", "data"], exportAs: ["fsTabsTab"] }, { kind: "ngmodule", type: FsDatePickerModule }, { kind: "component", type: i7.FsDatePickerComponent, selector: "[fsDatePicker]", inputs: ["minYear", "maxYear", "minDate", "maxDate", "startOfDay", "view", "format", "minutes", "width"], outputs: ["change"] }, { kind: "ngmodule", type: FsAutocompleteChipsModule }, { kind: "component", type: i2$3.FsAutocompleteChipsComponent, selector: "fs-autocomplete-chips", inputs: ["fetch", "appearance", "floatLabel", "readonly", "size", "label", "placeholder", "chipImage", "chipBackground", "chipColor", "chipIcon", "chipIconColor", "chipClass", "chipPadding", "shape", "hint", "allowText", "allowObject", "delay", "minPanelWidth", "maxPanelHeight", "validateText", "removable", "allowClear", "color", "background", "orderable", "padless", "initOnClick", "fetchOnFocus", "multiple", "multipleAdd", "confirm", "disabled", "groupBy", "panelWidth", "panelClass", "compareWith"], outputs: ["selected", "removed", "reordered", "clear", "panelOpened", "panelClosed"] }, { kind: "directive", type: i2$3.FsAutocompleteObjectDirective, selector: "[fsAutocompleteObject],[fsAutocompleteChipsTemplate]" }, { kind: "ngmodule", type: MatTabsModule }, { kind: "component", type: i6.MatTab, selector: "mat-tab", inputs: ["disabled", "label", "aria-label", "aria-labelledby", "labelClass", "bodyClass"], exportAs: ["matTab"] }, { kind: "component", type: i6.MatTabGroup, selector: "mat-tab-group", inputs: ["color", "fitInkBarToContent", "mat-stretch-tabs", "dynamicHeight", "selectedIndex", "headerPosition", "animationDuration", "contentTabIndex", "disablePagination", "disableRipple", "preserveContent", "backgroundColor", "aria-label", "aria-labelledby"], outputs: ["selectedIndexChange", "focusChange", "animationDone", "selectedTabChange"], exportAs: ["matTabGroup"] }, { kind: "component", type: MatButton, selector: " button[mat-button], button[mat-raised-button], button[mat-flat-button], button[mat-stroked-button] ", exportAs: ["matButton"] }, { kind: "directive", type: MatDialogTitle, selector: "[mat-dialog-title], [matDialogTitle]", inputs: ["id"], exportAs: ["matDialogTitle"] }, { kind: "directive", type: MatDialogContent, selector: "[mat-dialog-content], mat-dialog-content, [matDialogContent]" }, { kind: "directive", type: MatDialogActions, selector: "[mat-dialog-actions], mat-dialog-actions, [matDialogActions]", inputs: ["align"] }, { kind: "component", type: MatFormField, selector: "mat-form-field", inputs: ["hideRequiredMarker", "color", "floatLabel", "appearance", "subscriptSizing", "hintLabel"], exportAs: ["matFormField"] }, { kind: "directive", type: MatLabel, selector: "mat-label" }, { kind: "directive", type: MatHint, selector: "mat-hint", inputs: ["align", "id"] }, { kind: "directive", type: MatSuffix, selector: "[matSuffix], [matIconSuffix], [matTextSuffix]", inputs: ["matTextSuffix"] }, { kind: "directive", type: MatInput, selector: "input[matInput], textarea[matInput], select[matNativeControl], input[matNativeControl], textarea[matNativeControl]", inputs: ["disabled", "id", "placeholder", "name", "required", "type", "errorStateMatcher", "aria-describedby", "value", "readonly"], exportAs: ["matInput"] }, { kind: "component", type: MatSelect, selector: "mat-select", inputs: ["aria-describedby", "panelClass", "disabled", "disableRipple", "tabIndex", "hideSingleSelectionIndicator", "placeholder", "required", "multiple", "disableOptionCentering", "compareWith", "value", "aria-label", "aria-labelledby", "errorStateMatcher", "typeaheadDebounceInterval", "sortComparator", "id", "panelWidth"], outputs: ["openedChange", "opened", "closed", "selectionChange", "valueChange"], exportAs: ["matSelect"] }, { kind: "component", type: MatOption$1, selector: "mat-option", inputs: ["value", "id", "disabled"], outputs: ["onSelectionChange"], exportAs: ["matOption"] }, { kind: "component", type: TimezoneSelectComponent, selector: "fs-ai-report-timezone-select", inputs: ["placeholder", "required", "disabled", "timezone"], outputs: ["timezoneChange"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
3825
4466
|
}
|
|
3826
4467
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: ReportSettingsComponent, decorators: [{
|
|
3827
4468
|
type: Component,
|
|
@@ -3847,12 +4488,9 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImpo
|
|
|
3847
4488
|
MatSelect,
|
|
3848
4489
|
MatOption$1,
|
|
3849
4490
|
TimezoneSelectComponent,
|
|
3850
|
-
], template: "<form\n fsForm\n [submit]=\"save\">\n <fs-dialog>\n <h1 mat-dialog-title>\n Report Settings\n </h1>\n <mat-dialog-content>\n <mat-tab-group [(selected)]=\"selectedTab\">\n <mat-tab\n label=\"Settings\"\n name=\"settings\">\n <div class=\"fs-column tab-body\">\n <mat-form-field>\n <mat-label>\n Name\n </mat-label>\n <input\n matInput\n [(ngModel)]=\"name\"\n name=\"name\"\n [fsFormRequired]=\"true\">\n </mat-form-field>\n <!--\n The page, typed in inches. Orientation is not asked for because it\n is not an independent choice - a page wider than it is tall IS\n landscape - so the two numbers say everything there is to say.\n -->\n <div class=\"fs-row.gap-sm.align-start\">\n <mat-form-field class=\"fs-flex\">\n <mat-label>\n Width\n </mat-label>\n <input\n matInput\n fsMaskNumber\n [scale]=\"1\"\n [(ngModel)]=\"pageWidth\"\n name=\"pageWidth\"\n [fsFormRequired]=\"true\"\n [fsFormMin]=\"pageDimensionMin\"\n [fsFormMax]=\"pageDimensionMax\">\n <div matTextSuffix>\n inches\n </div>\n </mat-form-field>\n <mat-form-field class=\"fs-flex\">\n <mat-label>\n Height\n </mat-label>\n <input\n matInput\n fsMaskNumber\n [scale]=\"1\"\n [(ngModel)]=\"pageHeight\"\n name=\"pageHeight\"\n [fsFormRequired]=\"true\"\n [fsFormMin]=\"pageDimensionMin\"\n [fsFormMax]=\"pageDimensionMax\">\n <div matTextSuffix>\n inches\n </div>\n </mat-form-field>\n </div>\n <!--\n How deep the header and footer bands are. Their content is edited\n on the canvas (Edit layout \u2192 Header & footer); these reserve the\n space for it, and 0 means no band.\n -->\n <mat-form-field class=\"full-width\">\n <mat-label>\n Header height\n </mat-label>\n <input\n matInput\n fsMaskNumber\n [scale]=\"2\"\n [(ngModel)]=\"headerHeight\"\n name=\"headerHeight\"\n [fsFormMin]=\"0\"\n [fsFormMax]=\"headerFooterMax\">\n <div matTextSuffix>\n inches\n </div>\n </mat-form-field>\n <mat-form-field class=\"full-width\">\n <mat-label>\n Footer height\n </mat-label>\n <input\n matInput\n fsMaskNumber\n [scale]=\"2\"\n [(ngModel)]=\"footerHeight\"\n name=\"footerHeight\"\n [fsFormMin]=\"0\"\n [fsFormMax]=\"headerFooterMax\">\n <div matTextSuffix>\n inches\n </div>\n </mat-form-field>\n <mat-form-field>\n <mat-label>\n Layout\n </mat-label>\n <mat-select\n [(ngModel)]=\"layout\"\n name=\"layout\">\n <mat-option value=\"freeform\">\n Freeform \u2014 position components anywhere on the page\n </mat-option>\n <mat-option value=\"flow\">\n Flow \u2014 components flow into rows by width %\n </mat-option>\n </mat-select>\n <mat-hint>\n Freeform is like PowerPoint; Flow is like a responsive dashboard.\n </mat-hint>\n </mat-form-field>\n <fs-ai-report-timezone-select [(timezone)]=\"timezone\"></fs-ai-report-timezone-select>\n </div>\n </mat-tab>\n <mat-tab\n label=\"Styles\"\n name=\"styles\">\n <div class=\"fs-column tab-body\">\n <mat-form-field>\n <mat-label>\n Heading Size\n </mat-label>\n <input\n matInput\n type=\"number\"\n step=\"1\"\n min=\"6\"\n max=\"96\"\n [(ngModel)]=\"headingSize\"\n name=\"headingSize\"\n [fsFormMin]=\"6\"\n [fsFormMax]=\"96\">\n <span matTextSuffix>\n pt\n </span>\n <mat-hint>\n Applies to component titles. Points map 1:1 to PDF and PowerPoint.\n </mat-hint>\n </mat-form-field>\n </div>\n </mat-tab>\n <mat-tab\n label=\"Filters\"\n name=\"filters\">\n <div class=\"fs-column tab-body\">\n @if (filterRows().length) {\n @for (row of filterRows(); track row.group.id) {\n @switch (row.group.type) {\n @case ('dateRange') {\n <div class=\"fs-row.gap-sm.align-center fs-flex\">\n <mat-form-field class=\"fs-flex\">\n <mat-label>\n Default From {{ row.label }}\n </mat-label>\n <input\n matInput\n fsDatePicker\n [(ngModel)]=\"row.start\"\n [name]=\"'start' + row.group.id\">\n </mat-form-field>\n <mat-form-field class=\"fs-flex\">\n <mat-label>\n Default To {{ row.label }}\n </mat-label>\n <input\n matInput\n fsDatePicker\n [(ngModel)]=\"row.end\"\n [name]=\"'end' + row.group.id\">\n </mat-form-field>\n </div>\n }\n @case ('select') {\n <fs-autocomplete-chips\n class=\"fs-flex\"\n [label]=\"'Default ' + row.label\"\n [fetch]=\"row.fetch\"\n [(ngModel)]=\"row.values\"\n [name]=\"'values' + row.group.id\"\n [multiple]=\"true\"\n [fetchOnFocus]=\"true\">\n <ng-template\n fsAutocompleteChipsTemplate\n let-object=\"object\">\n {{ object.name }}\n </ng-template>\n </fs-autocomplete-chips>\n }\n @default {\n <mat-form-field class=\"fs-flex\">\n <mat-label>\n Default {{ row.label }}\n </mat-label>\n <input\n matInput\n [(ngModel)]=\"row.value\"\n [name]=\"'value' + row.group.id\">\n </mat-form-field>\n }\n }\n }\n } @else {\n <fs-message-info>\n This report has no report-level filters yet. Add a filter to a\n component (its settings \u2192 Filters) and show it in the report bar,\n then set its default here.\n </fs-message-info>\n }\n </div>\n </mat-tab>\n </mat-tab-group>\n </mat-dialog-content>\n <mat-dialog-actions>\n <fs-form-dialog-actions>\n <button\n type=\"button\"\n mat-button\n color=\"warn\"\n (click)=\"delete()\">\n Delete\n </button>\n </fs-form-dialog-actions>\n </mat-dialog-actions>\n </fs-dialog>\n</form>", styles: ["mat-form-field{width:100%}.tab-body{padding-top:16px}\n"] }]
|
|
4491
|
+
], template: "<form\n fsForm\n [submit]=\"save\">\n <fs-dialog>\n <h1 mat-dialog-title>\n Report Settings\n </h1>\n <mat-dialog-content>\n <mat-tab-group [(selected)]=\"selectedTab\">\n <mat-tab\n label=\"Settings\"\n name=\"settings\">\n <div class=\"fs-column tab-body\">\n <mat-form-field>\n <mat-label>\n Name\n </mat-label>\n <input\n matInput\n [(ngModel)]=\"name\"\n name=\"name\"\n [fsFormRequired]=\"true\">\n </mat-form-field>\n <!--\n The page, typed in inches. Orientation is not asked for because it\n is not an independent choice - a page wider than it is tall IS\n landscape - so the two numbers say everything there is to say.\n -->\n <div class=\"fs-row.gap-sm.align-start\">\n <mat-form-field class=\"fs-flex\">\n <mat-label>\n Width\n </mat-label>\n <input\n matInput\n fsMaskNumber\n [scale]=\"1\"\n [(ngModel)]=\"pageWidth\"\n name=\"pageWidth\"\n [fsFormRequired]=\"true\"\n [fsFormMin]=\"pageDimensionMin\"\n [fsFormMax]=\"pageDimensionMax\">\n <div matTextSuffix>\n inches\n </div>\n </mat-form-field>\n <mat-form-field class=\"fs-flex\">\n <mat-label>\n Height\n </mat-label>\n <input\n matInput\n fsMaskNumber\n [scale]=\"1\"\n [(ngModel)]=\"pageHeight\"\n name=\"pageHeight\"\n [fsFormRequired]=\"true\"\n [fsFormMin]=\"pageDimensionMin\"\n [fsFormMax]=\"pageDimensionMax\">\n <div matTextSuffix>\n inches\n </div>\n </mat-form-field>\n </div>\n <!--\n How deep the header and footer bands are. Their content is edited\n on the canvas (Edit layout \u2192 Header & footer); these reserve the\n space for it, and 0 means no band.\n -->\n <mat-form-field class=\"full-width\">\n <mat-label>\n Header height\n </mat-label>\n <input\n matInput\n fsMaskNumber\n [scale]=\"2\"\n [(ngModel)]=\"headerHeight\"\n name=\"headerHeight\"\n [fsFormMin]=\"0\"\n [fsFormMax]=\"headerFooterMax\">\n <div matTextSuffix>\n inches\n </div>\n </mat-form-field>\n <mat-form-field class=\"full-width\">\n <mat-label>\n Footer height\n </mat-label>\n <input\n matInput\n fsMaskNumber\n [scale]=\"2\"\n [(ngModel)]=\"footerHeight\"\n name=\"footerHeight\"\n [fsFormMin]=\"0\"\n [fsFormMax]=\"headerFooterMax\">\n <div matTextSuffix>\n inches\n </div>\n </mat-form-field>\n <mat-form-field>\n <mat-label>\n Layout\n </mat-label>\n <mat-select\n [(ngModel)]=\"layout\"\n name=\"layout\">\n <mat-option value=\"freeform\">\n Freeform \u2014 position components anywhere on the page\n </mat-option>\n <mat-option value=\"flow\">\n Flow \u2014 components flow into rows by width %\n </mat-option>\n </mat-select>\n <mat-hint>\n Freeform is like PowerPoint; Flow is like a responsive dashboard.\n </mat-hint>\n </mat-form-field>\n <fs-ai-report-timezone-select [(timezone)]=\"timezone\"></fs-ai-report-timezone-select>\n </div>\n </mat-tab>\n <mat-tab\n label=\"Styles\"\n name=\"styles\">\n <div class=\"fs-column tab-body\">\n <mat-form-field>\n <mat-label>\n Heading Size\n </mat-label>\n <input\n matInput\n type=\"number\"\n step=\"1\"\n min=\"6\"\n max=\"96\"\n [(ngModel)]=\"headingSize\"\n name=\"headingSize\"\n [fsFormMin]=\"6\"\n [fsFormMax]=\"96\">\n <span matTextSuffix>\n pt\n </span>\n <mat-hint>\n Applies to component titles. Points map 1:1 to PDF and PowerPoint.\n </mat-hint>\n </mat-form-field>\n </div>\n </mat-tab>\n <mat-tab\n label=\"Filters\"\n name=\"filters\">\n <div class=\"fs-column tab-body\">\n @if (filterRows().length) {\n @for (row of filterRows(); track row.group.id) {\n @switch (row.group.type) {\n @case ('dateRange') {\n <div class=\"fs-row.gap-sm.align-center fs-flex\">\n <mat-form-field class=\"fs-flex\">\n <mat-label>\n Default From {{ row.label }}\n </mat-label>\n <input\n matInput\n fsDatePicker\n [(ngModel)]=\"row.start\"\n [name]=\"'start' + row.group.id\">\n </mat-form-field>\n <mat-form-field class=\"fs-flex\">\n <mat-label>\n Default To {{ row.label }}\n </mat-label>\n <input\n matInput\n fsDatePicker\n [(ngModel)]=\"row.end\"\n [name]=\"'end' + row.group.id\">\n </mat-form-field>\n </div>\n <!-- A stored rolling preset cannot be authored here, only\n kept, replaced by picking dates, or cleared. type=\"button\"\n keeps Clear from submitting the form. -->\n @if (row.relative && !row.start && !row.end) {\n <div class=\"fs-row.gap-sm.align-center\">\n <mat-hint class=\"fs-flex\">\n Rolling default: {{ relativeLabel(row.relative) }}. Pick dates to replace it.\n </mat-hint>\n <button\n mat-button\n type=\"button\"\n (click)=\"clearRelative(row)\">\n Clear\n </button>\n </div>\n }\n }\n @case ('select') {\n <fs-autocomplete-chips\n class=\"fs-flex\"\n [label]=\"'Default ' + row.label\"\n [fetch]=\"row.fetch\"\n [(ngModel)]=\"row.values\"\n [name]=\"'values' + row.group.id\"\n [multiple]=\"true\"\n [fetchOnFocus]=\"true\">\n <ng-template\n fsAutocompleteChipsTemplate\n let-object=\"object\">\n {{ object.name }}\n </ng-template>\n </fs-autocomplete-chips>\n }\n @default {\n <mat-form-field class=\"fs-flex\">\n <mat-label>\n Default {{ row.label }}\n </mat-label>\n <input\n matInput\n [(ngModel)]=\"row.value\"\n [name]=\"'value' + row.group.id\">\n </mat-form-field>\n }\n }\n }\n } @else {\n <fs-message-info>\n This report has no report-level filters yet. Add a filter to a\n component (its settings \u2192 Filters) and show it in the report bar,\n then set its default here.\n </fs-message-info>\n }\n </div>\n </mat-tab>\n </mat-tab-group>\n </mat-dialog-content>\n <mat-dialog-actions>\n <fs-form-dialog-actions>\n <button\n type=\"button\"\n mat-button\n color=\"warn\"\n (click)=\"delete()\">\n Delete\n </button>\n </fs-form-dialog-actions>\n </mat-dialog-actions>\n </fs-dialog>\n</form>", styles: ["mat-form-field{width:100%}.tab-body{padding-top:16px}\n"] }]
|
|
3851
4492
|
}] });
|
|
3852
4493
|
|
|
3853
|
-
// Rows fetched for a list component's export table — enough to fill a
|
|
3854
|
-
// slide/page table; the cap note covers the rest.
|
|
3855
|
-
const LIST_EXPORT_ROWS = 100;
|
|
3856
4494
|
// How many tile queries an export runs at once.
|
|
3857
4495
|
//
|
|
3858
4496
|
// Not a politeness setting — it is the difference between a deck and a broken
|
|
@@ -3879,6 +4517,55 @@ function emptyLabel(item) {
|
|
|
3879
4517
|
function isFailed(item) {
|
|
3880
4518
|
return !item.data && item.component.type !== 'text';
|
|
3881
4519
|
}
|
|
4520
|
+
// The lines under the report's name on the PDF title page and the title slide:
|
|
4521
|
+
// the export date, the active filters, then what the numbers are measured in.
|
|
4522
|
+
// The date is today in the report's zone, the same day its filters resolve
|
|
4523
|
+
// "today" to.
|
|
4524
|
+
function coverLines(collected) {
|
|
4525
|
+
return [
|
|
4526
|
+
format(getZonedNow(resolveReportTimezone(collected.report)), 'MMMM d, yyyy'),
|
|
4527
|
+
...collected.filterSummary,
|
|
4528
|
+
...collected.contextLines,
|
|
4529
|
+
];
|
|
4530
|
+
}
|
|
4531
|
+
// An exported list cell, reading like the on-screen cell: the SQL already
|
|
4532
|
+
// carries the formatting, so the value goes out verbatim. A date column is the
|
|
4533
|
+
// one value the browser renders, and it renders through the same function as
|
|
4534
|
+
// the screen, in the report's zone.
|
|
4535
|
+
function exportCell(value, columnFormat, timezone) {
|
|
4536
|
+
if (value === null || value === undefined) {
|
|
4537
|
+
return '';
|
|
4538
|
+
}
|
|
4539
|
+
return columnFormat === 'date' ? formatReportDate(value, timezone) : String(value);
|
|
4540
|
+
}
|
|
4541
|
+
// The columns an exported list table draws. A cross-tab's columns arrive with
|
|
4542
|
+
// its rows (the row key first, then one per pivot value), since only the
|
|
4543
|
+
// result knows the pivot values; a plain list draws its configured columns,
|
|
4544
|
+
// else every column the query returned.
|
|
4545
|
+
function listColumns(component, data) {
|
|
4546
|
+
const config = (component.config ?? {});
|
|
4547
|
+
if (!config.pivotBy?.column && config.columns?.length) {
|
|
4548
|
+
return config.columns;
|
|
4549
|
+
}
|
|
4550
|
+
return data.columns.map((column) => ({ column }));
|
|
4551
|
+
}
|
|
4552
|
+
// The heading over each column of listColumns(). A cross-tab's first column
|
|
4553
|
+
// is headed by rowBy.label, else its column name, and every other heading is
|
|
4554
|
+
// the raw pivot value; a plain list's columns keep their configured labels.
|
|
4555
|
+
function listHeadings(component, data) {
|
|
4556
|
+
const config = (component.config ?? {});
|
|
4557
|
+
if (config.pivotBy?.column) {
|
|
4558
|
+
return data.columns.map((column, index) => (index === 0 ? config.rowBy?.label || column : column));
|
|
4559
|
+
}
|
|
4560
|
+
return listColumns(component, data).map((column) => column.label || column.column);
|
|
4561
|
+
}
|
|
4562
|
+
// The last row of an exported list table that stops short of its result: how
|
|
4563
|
+
// many rows the file leaves out and where the whole set can be had. Each
|
|
4564
|
+
// format fetches its own number of rows (PDF_LIST_ROWS, PPTX_LIST_ROWS), and
|
|
4565
|
+
// both name what is left with this one sentence.
|
|
4566
|
+
function moreRowsNotice(remaining) {
|
|
4567
|
+
return `… ${remaining} more row(s) — use the component's CSV export for the full dataset`;
|
|
4568
|
+
}
|
|
3882
4569
|
// The pages of a collected report that have at least one tile whose query
|
|
3883
4570
|
// failed — what the export gate names to the user, and what makes "some of this
|
|
3884
4571
|
// file is missing" a fact the caller can act on rather than a thing they notice
|
|
@@ -3901,7 +4588,9 @@ function failedPageNames(collected) {
|
|
|
3901
4588
|
class ReportExportCollectorService {
|
|
3902
4589
|
_reportData = inject(ReportData);
|
|
3903
4590
|
_filterState = inject(ReportFilterStateService);
|
|
3904
|
-
|
|
4591
|
+
// listRows is how many rows each list fetches: the renderer's own cap, since
|
|
4592
|
+
// a PDF table continues onto extra pages and a slide table cannot.
|
|
4593
|
+
collect(report, listRows) {
|
|
3905
4594
|
const slots = [];
|
|
3906
4595
|
report.pages.forEach((page, pageIndex) => {
|
|
3907
4596
|
for (const component of page.components) {
|
|
@@ -3917,12 +4606,12 @@ class ReportExportCollectorService {
|
|
|
3917
4606
|
// happened to finish first.
|
|
3918
4607
|
const all$ = slots.length
|
|
3919
4608
|
? from(slots.map((slot, index) => ({ slot, index })))
|
|
3920
|
-
.pipe(mergeMap(({ slot, index }) => this._componentData(report, slot.component)
|
|
4609
|
+
.pipe(mergeMap(({ slot, index }) => this._componentData(report, slot.component, listRows)
|
|
3921
4610
|
.pipe(map$1((item) => ({ item, index }))), EXPORT_CONCURRENCY), toArray())
|
|
3922
4611
|
: of([]);
|
|
3923
4612
|
// The summary rides alongside the component data rather than being built at
|
|
3924
4613
|
// the end, because naming a select filter's values is itself a request.
|
|
3925
|
-
return forkJoin({ collected: all$, filterSummary: this.
|
|
4614
|
+
return forkJoin({ collected: all$, filterSummary: this.getFilterSummary(report) })
|
|
3926
4615
|
.pipe(map$1(({ collected, filterSummary }) => {
|
|
3927
4616
|
const pages = report.pages.map((page) => ({ page, components: [] }));
|
|
3928
4617
|
[...collected]
|
|
@@ -3934,34 +4623,19 @@ class ReportExportCollectorService {
|
|
|
3934
4623
|
report,
|
|
3935
4624
|
pages,
|
|
3936
4625
|
filterSummary,
|
|
4626
|
+
contextLines: this._contextLines(report),
|
|
3937
4627
|
};
|
|
3938
4628
|
}));
|
|
3939
4629
|
}
|
|
3940
|
-
|
|
3941
|
-
|
|
3942
|
-
|
|
3943
|
-
|
|
3944
|
-
const filters = this._filterState.resolveForComponent(component);
|
|
3945
|
-
// A time-series chart carries the viewer's group-by grain so the export
|
|
3946
|
-
// re-buckets exactly as the screen does; lists carry their export paging.
|
|
3947
|
-
const frequency = this._filterState.frequency();
|
|
3948
|
-
const isTimeSeries = component.type === 'chart' && component.config?.xAxis?.kind === 'time';
|
|
3949
|
-
const state = component.type === 'list'
|
|
3950
|
-
? { page: 1, pageSize: LIST_EXPORT_ROWS }
|
|
3951
|
-
: (isTimeSeries && frequency ? { frequency } : {});
|
|
3952
|
-
// handlers: false keeps the app's global error dialog out of this. A failed
|
|
3953
|
-
// tile is reported once, by the export gate, naming the pages it cost —
|
|
3954
|
-
// never as ten stacked modals carrying the database's own wording.
|
|
3955
|
-
return this._reportData.componentData(report.id, component.id, filters, state, true, { handlers: false })
|
|
3956
|
-
.pipe(map$1((data) => ({ component, data })), catchError(() => of({ component, data: null })));
|
|
3957
|
-
}
|
|
3958
|
-
// "Report Period: Jun 12, 2025 – Jun 12, 2026", "Organization: Alnylam - AHP,
|
|
3959
|
-
// Alnylam - PH1" — one line per filter group that has an active session value.
|
|
4630
|
+
// "Report Period: Jun 12, 2025 – Jun 12, 2026", "Region: North, East" — one
|
|
4631
|
+
// line per filter group that has an active session value, then the group-by
|
|
4632
|
+
// line. The filter bar as the viewer reads it: the PDF and PowerPoint covers
|
|
4633
|
+
// print it, and the report zip export sends it for its notes file.
|
|
3960
4634
|
//
|
|
3961
4635
|
// A select group stores ids, so its line has to be named off the option list
|
|
3962
4636
|
// before it can be printed; a date or keyword group already holds its own
|
|
3963
4637
|
// display value and resolves synchronously.
|
|
3964
|
-
|
|
4638
|
+
getFilterSummary(report) {
|
|
3965
4639
|
const lines = [];
|
|
3966
4640
|
for (const group of report.filterGroups ?? []) {
|
|
3967
4641
|
const value = this._filterState.value(group.id);
|
|
@@ -3990,6 +4664,35 @@ class ReportExportCollectorService {
|
|
|
3990
4664
|
}
|
|
3991
4665
|
return lines.length ? forkJoin(lines) : of([]);
|
|
3992
4666
|
}
|
|
4667
|
+
_componentData(report, component, listRows) {
|
|
4668
|
+
if (component.type === 'text') {
|
|
4669
|
+
return of({ component, data: null });
|
|
4670
|
+
}
|
|
4671
|
+
const filters = this._filterState.resolveForComponent(component);
|
|
4672
|
+
// A time-series chart carries the viewer's group-by grain so the export
|
|
4673
|
+
// re-buckets exactly as the screen does; lists carry their export paging
|
|
4674
|
+
// and the viewer's header sort, so the table reads in the screen's order.
|
|
4675
|
+
//
|
|
4676
|
+
// The time-series check deliberately mirrors the server's canonical
|
|
4677
|
+
// ReportQueryRunner::_isTimeSeriesChart, so a product pinned to an older
|
|
4678
|
+
// framework does not re-bucket a non-time chart.
|
|
4679
|
+
const frequency = this._filterState.frequency();
|
|
4680
|
+
const isTimeSeries = component.type === 'chart' && component.config?.xAxis?.kind === 'time';
|
|
4681
|
+
const state = component.type === 'list'
|
|
4682
|
+
? { page: 1, pageSize: listRows, sort: this._filterState.listSort(component.id) ?? undefined }
|
|
4683
|
+
: (isTimeSeries && frequency ? { frequency } : {});
|
|
4684
|
+
// handlers: false keeps the app's global error dialog out of this. A failed
|
|
4685
|
+
// tile is reported once, by the export gate, naming the pages it cost —
|
|
4686
|
+
// never as ten stacked modals carrying the database's own wording.
|
|
4687
|
+
return this._reportData.componentData(report.id, component.id, filters, state, true, { handlers: false })
|
|
4688
|
+
.pipe(map$1((data) => ({ component, data })), catchError(() => of({ component, data: null })));
|
|
4689
|
+
}
|
|
4690
|
+
// The zone the server resolved, as its IANA id. Only the server's value is
|
|
4691
|
+
// printed: the browser's zone (resolveReportTimezone's fallback for an older
|
|
4692
|
+
// server) is not what the numbers were measured in.
|
|
4693
|
+
_contextLines(report) {
|
|
4694
|
+
return report.effectiveTimezone ? [`Timezone: ${report.effectiveTimezone}`] : [];
|
|
4695
|
+
}
|
|
3993
4696
|
// One select group's line, with its stored ids resolved to the same names the
|
|
3994
4697
|
// on-screen chips show — a cover reading "Organization: 8774, 8777" tells the
|
|
3995
4698
|
// client nothing about what the report covers.
|
|
@@ -4022,7 +4725,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImpo
|
|
|
4022
4725
|
// complete synchronously after setOption.
|
|
4023
4726
|
async function renderChartImage(component, data, width, height, pixelRatio = 3) {
|
|
4024
4727
|
// The same tree-shaken echarts build + 'report' theme the live canvas uses.
|
|
4025
|
-
const echarts = (await import('./firestitch-report-echarts-
|
|
4728
|
+
const echarts = (await import('./firestitch-report-echarts-B9nl9XKM.mjs')).default;
|
|
4026
4729
|
const host = document.createElement('div');
|
|
4027
4730
|
host.style.width = `${width}px`;
|
|
4028
4731
|
host.style.height = `${height}px`;
|
|
@@ -4051,8 +4754,19 @@ async function renderChartImage(component, data, width, height, pixelRatio = 3)
|
|
|
4051
4754
|
const PT = 72;
|
|
4052
4755
|
// Component title strip height, points.
|
|
4053
4756
|
const TITLE_H$1 = 18;
|
|
4054
|
-
//
|
|
4055
|
-
|
|
4757
|
+
// Rows fetched for a list's PDF table. A table that outgrows its box
|
|
4758
|
+
// continues onto pages of its own, so this is how far it may run: about 12-15
|
|
4759
|
+
// continuation pages on a letter page, and a tenth of the server's 5000-row
|
|
4760
|
+
// ceiling. A longer result ends in a moreRowsNotice() row.
|
|
4761
|
+
const PDF_LIST_ROWS = 500;
|
|
4762
|
+
// How far a continuation table sits inside the header and footer bands,
|
|
4763
|
+
// points. A page with no band (or with the master hidden) keeps
|
|
4764
|
+
// NO_BAND_MARGIN from that edge instead.
|
|
4765
|
+
const CONTINUATION_GAP = 0.25 * PT;
|
|
4766
|
+
const NO_BAND_MARGIN = 0.5 * PT;
|
|
4767
|
+
const NO_BANDS = { headerHeight: 0, footerHeight: 0 };
|
|
4768
|
+
// The last row of a list box whose rows carry on past it.
|
|
4769
|
+
const CONTINUED_NOTICE = 'Continued on the following pages';
|
|
4056
4770
|
// A text line's height as a multiple of its largest font size — the canvas
|
|
4057
4771
|
// stylesheet's line-height, so a block wraps and stacks the same way.
|
|
4058
4772
|
const TEXT_LINE_HEIGHT = 1.25;
|
|
@@ -4065,40 +4779,74 @@ const LIST_INDENT = 16;
|
|
|
4065
4779
|
// Charts are rendered through an offscreen ECharts instance at 3× pixel
|
|
4066
4780
|
// ratio — pixel-identical to the screen, crisp at print size — and tables are
|
|
4067
4781
|
// drawn with jspdf-autotable. Both libraries load lazily into their own chunks.
|
|
4782
|
+
//
|
|
4783
|
+
// A list table is fitted to its box by laying it out first, never by a
|
|
4784
|
+
// row-height estimate, so it cannot spill past the box. The rows that do not
|
|
4785
|
+
// fit continue on pages of their own after the report page, under the same
|
|
4786
|
+
// header/footer and with the same column widths, up to PDF_LIST_ROWS; a
|
|
4787
|
+
// longer result ends in a row pointing to the component's CSV export.
|
|
4068
4788
|
class ReportPdfService {
|
|
4069
4789
|
async export(collected) {
|
|
4790
|
+
const context = await this._context(collected.report);
|
|
4791
|
+
this._titlePage(context.doc, collected, context.pageW, context.pageH);
|
|
4792
|
+
// The physical page number {{page.number}} prints: report pages and
|
|
4793
|
+
// their continuation pages alike, the title page not counted.
|
|
4794
|
+
let pageNumber = 0;
|
|
4795
|
+
for (const [index, collectedPage] of collected.pages.entries()) {
|
|
4796
|
+
const overflows = [];
|
|
4797
|
+
pageNumber += 1;
|
|
4798
|
+
await this._renderPage(context, collectedPage, index, pageNumber, overflows);
|
|
4799
|
+
// Each continuation appends its pages at the end of the document, so
|
|
4800
|
+
// two lists that overflowed on one page follow it back to back.
|
|
4801
|
+
for (const overflow of overflows) {
|
|
4802
|
+
pageNumber = this._renderContinuation(context, collectedPage.page, index, pageNumber, overflow);
|
|
4803
|
+
}
|
|
4804
|
+
}
|
|
4805
|
+
context.doc.save(`${this._slug(collected.report.name)}.pdf`);
|
|
4806
|
+
}
|
|
4807
|
+
async _context(report) {
|
|
4070
4808
|
const { jsPDF } = await import('jspdf');
|
|
4071
4809
|
const autoTable = (await import('jspdf-autotable')).default;
|
|
4072
|
-
const report = collected.report;
|
|
4073
4810
|
const pageW = report.pageWidth * PT;
|
|
4074
4811
|
const pageH = report.pageHeight * PT;
|
|
4075
|
-
const
|
|
4076
|
-
|
|
4077
|
-
unit: 'pt',
|
|
4078
|
-
|
|
4079
|
-
|
|
4080
|
-
|
|
4081
|
-
|
|
4082
|
-
|
|
4083
|
-
|
|
4084
|
-
|
|
4085
|
-
|
|
4086
|
-
|
|
4087
|
-
|
|
4088
|
-
|
|
4089
|
-
|
|
4812
|
+
const orientation = pageW >= pageH ? 'landscape' : 'portrait';
|
|
4813
|
+
return {
|
|
4814
|
+
doc: new jsPDF({ orientation, unit: 'pt', format: [pageW, pageH] }),
|
|
4815
|
+
autoTable,
|
|
4816
|
+
jsPDF,
|
|
4817
|
+
report,
|
|
4818
|
+
timezone: resolveReportTimezone(report),
|
|
4819
|
+
pageW,
|
|
4820
|
+
pageH,
|
|
4821
|
+
orientation,
|
|
4822
|
+
};
|
|
4823
|
+
}
|
|
4824
|
+
// One report page: the header and footer first, under the page's own
|
|
4825
|
+
// components. A list that outgrows its box leaves its remaining rows in
|
|
4826
|
+
// overflows.
|
|
4827
|
+
async _renderPage(context, collectedPage, index, pageNumber, overflows) {
|
|
4828
|
+
const { doc, report } = context;
|
|
4829
|
+
const geometry = pageGeometry(report, collectedPage.page);
|
|
4830
|
+
const tokens = this._tokens(report, collectedPage.page, index, pageNumber);
|
|
4831
|
+
doc.addPage([context.pageW, context.pageH], context.orientation);
|
|
4832
|
+
this._renderMaster(context, collectedPage.page, tokens);
|
|
4833
|
+
for (const item of collectedPage.components) {
|
|
4834
|
+
const box = geometry.get(item.component.id);
|
|
4835
|
+
if (item.component.type === 'text') {
|
|
4836
|
+
this._renderText(doc, item.component, box ?? item.component, tokens);
|
|
4090
4837
|
}
|
|
4091
|
-
|
|
4092
|
-
|
|
4093
|
-
if (item.component.type === 'text') {
|
|
4094
|
-
this._renderText(doc, item.component, box ?? item.component, tokens);
|
|
4095
|
-
}
|
|
4096
|
-
else {
|
|
4097
|
-
await this._renderComponent(doc, autoTable, item, box);
|
|
4098
|
-
}
|
|
4838
|
+
else {
|
|
4839
|
+
await this._renderComponent(context, item, overflows, box);
|
|
4099
4840
|
}
|
|
4100
4841
|
}
|
|
4101
|
-
|
|
4842
|
+
}
|
|
4843
|
+
_renderMaster(context, page, tokens) {
|
|
4844
|
+
if (page.masterHidden) {
|
|
4845
|
+
return;
|
|
4846
|
+
}
|
|
4847
|
+
for (const component of context.report.master?.components ?? []) {
|
|
4848
|
+
this._renderText(context.doc, component, component, tokens);
|
|
4849
|
+
}
|
|
4102
4850
|
}
|
|
4103
4851
|
_titlePage(doc, collected, pageW, pageH) {
|
|
4104
4852
|
doc.setFont('helvetica', 'bold');
|
|
@@ -4108,11 +4856,7 @@ class ReportPdfService {
|
|
|
4108
4856
|
doc.setFont('helvetica', 'normal');
|
|
4109
4857
|
doc.setFontSize(11);
|
|
4110
4858
|
doc.setTextColor('#52606d');
|
|
4111
|
-
|
|
4112
|
-
format(new Date(), 'MMMM d, yyyy'),
|
|
4113
|
-
...collected.filterSummary,
|
|
4114
|
-
];
|
|
4115
|
-
doc.text(lines, 40, pageH * 0.35 + 26);
|
|
4859
|
+
doc.text(coverLines(collected), 40, pageH * 0.35 + 26);
|
|
4116
4860
|
}
|
|
4117
4861
|
// A text block, laid out by hand: jsPDF draws strings, not rich text, so each
|
|
4118
4862
|
// paragraph's runs are broken into words, wrapped to the block's width and
|
|
@@ -4237,16 +4981,19 @@ class ReportPdfService {
|
|
|
4237
4981
|
return 0;
|
|
4238
4982
|
}
|
|
4239
4983
|
}
|
|
4240
|
-
// What a text block's tokens fill with on this page
|
|
4241
|
-
// among the report's pages
|
|
4242
|
-
|
|
4984
|
+
// What a text block's tokens fill with on this page. The number is the
|
|
4985
|
+
// physical page among the report's pages, continuation pages included — the
|
|
4986
|
+
// title page is not a report page. The name stays the report page's, so a
|
|
4987
|
+
// continuation page is named for the page its table started on.
|
|
4988
|
+
_tokens(report, page, index, pageNumber) {
|
|
4243
4989
|
return {
|
|
4244
|
-
pageNumber
|
|
4990
|
+
pageNumber,
|
|
4245
4991
|
pageName: page.name || `Page ${index + 1}`,
|
|
4246
4992
|
reportName: report.name,
|
|
4247
4993
|
};
|
|
4248
4994
|
}
|
|
4249
|
-
async _renderComponent(
|
|
4995
|
+
async _renderComponent(context, item, overflows, geometry) {
|
|
4996
|
+
const { doc } = context;
|
|
4250
4997
|
const component = item.component;
|
|
4251
4998
|
// Geometry arrives in inches (flow layout pre-resolved); the PDF draws in
|
|
4252
4999
|
// points.
|
|
@@ -4256,18 +5003,12 @@ class ReportPdfService {
|
|
|
4256
5003
|
const w = inches.w * PT;
|
|
4257
5004
|
const h = inches.h * PT;
|
|
4258
5005
|
if (component.title) {
|
|
4259
|
-
|
|
4260
|
-
doc.setFontSize(9);
|
|
4261
|
-
doc.setTextColor('#1f2933');
|
|
4262
|
-
doc.text(component.title, x, y + 10, { maxWidth: w });
|
|
5006
|
+
this._componentTitle(doc, component.title, x, y, w);
|
|
4263
5007
|
}
|
|
4264
5008
|
const bodyY = y + TITLE_H$1;
|
|
4265
5009
|
const bodyH = Math.max(20, h - TITLE_H$1);
|
|
4266
5010
|
if (!item.data || !item.data.rows?.length) {
|
|
4267
|
-
|
|
4268
|
-
doc.setFontSize(9);
|
|
4269
|
-
doc.setTextColor('#7b8794');
|
|
4270
|
-
doc.text(emptyLabel(item), x + w / 2, bodyY + bodyH / 2, { align: 'center' });
|
|
5011
|
+
this._boxNote(doc, emptyLabel(item), x, bodyY, w, bodyH);
|
|
4271
5012
|
return;
|
|
4272
5013
|
}
|
|
4273
5014
|
switch (component.type) {
|
|
@@ -4277,36 +5018,173 @@ class ReportPdfService {
|
|
|
4277
5018
|
break;
|
|
4278
5019
|
}
|
|
4279
5020
|
case 'list':
|
|
4280
|
-
this._renderTable(
|
|
5021
|
+
this._renderTable(context, component, item.data, x, bodyY, w, bodyH, overflows);
|
|
4281
5022
|
break;
|
|
4282
5023
|
case 'kpi':
|
|
4283
5024
|
this._renderKpi(doc, component, item.data, x, bodyY, w, bodyH);
|
|
4284
5025
|
break;
|
|
4285
5026
|
}
|
|
4286
5027
|
}
|
|
4287
|
-
|
|
4288
|
-
|
|
4289
|
-
|
|
4290
|
-
|
|
4291
|
-
|
|
4292
|
-
|
|
4293
|
-
|
|
4294
|
-
|
|
4295
|
-
|
|
4296
|
-
|
|
4297
|
-
|
|
5028
|
+
// The bold strip over a component, and over each of its continuation pages.
|
|
5029
|
+
_componentTitle(doc, title, x, y, w) {
|
|
5030
|
+
doc.setFont('helvetica', 'bold');
|
|
5031
|
+
doc.setFontSize(9);
|
|
5032
|
+
doc.setTextColor('#1f2933');
|
|
5033
|
+
doc.text(title, x, y + 10, { maxWidth: w });
|
|
5034
|
+
}
|
|
5035
|
+
// An italic grey line centered in a component's body, for a body that holds
|
|
5036
|
+
// something other than its content.
|
|
5037
|
+
_boxNote(doc, text, x, y, w, h) {
|
|
5038
|
+
doc.setFont('helvetica', 'italic');
|
|
5039
|
+
doc.setFontSize(9);
|
|
5040
|
+
doc.setTextColor('#7b8794');
|
|
5041
|
+
doc.text(text, x + w / 2, y + h / 2, { align: 'center' });
|
|
5042
|
+
}
|
|
5043
|
+
// A list's on-page table. All of its fetched rows are laid out once to learn
|
|
5044
|
+
// how many the box holds; when they do not all fit, the box's last row says
|
|
5045
|
+
// the table continues and the rest go to overflows for continuation pages.
|
|
5046
|
+
// A box too short for even the head carries the notice as a line instead.
|
|
5047
|
+
// Points throughout.
|
|
5048
|
+
_renderTable(context, component, data, x, y, w, h, overflows) {
|
|
5049
|
+
const head = listHeadings(component, data);
|
|
5050
|
+
const cells = this._tableCells(component, data, context.timezone);
|
|
5051
|
+
const omitted = Math.max(0, (data.paging?.total ?? cells.length) - cells.length);
|
|
5052
|
+
const bottom = context.pageH - (y + h);
|
|
5053
|
+
const { fit, columnStyles } = this._measure(context, head, cells, x, y, w, bottom);
|
|
5054
|
+
if (fit === cells.length && omitted === 0) {
|
|
5055
|
+
this._drawTable(context, this._tableOptions(context, head, cells, x, y, w, bottom, columnStyles));
|
|
5056
|
+
return;
|
|
5057
|
+
}
|
|
5058
|
+
// The notice takes the place of the last row that fit, so that row moves
|
|
5059
|
+
// to the continuation. omitted > 0 means a full PDF_LIST_ROWS fetch, which
|
|
5060
|
+
// never fits one box, so the CSV notice always lands on a continuation
|
|
5061
|
+
// page.
|
|
5062
|
+
const kept = Math.max(0, fit - 1);
|
|
5063
|
+
overflows.push({ component, head, rows: cells.slice(kept), omitted, x, w, columnStyles });
|
|
5064
|
+
if (fit >= 1) {
|
|
5065
|
+
const body = [...cells.slice(0, kept), this._noticeRow(CONTINUED_NOTICE, head.length, true)];
|
|
5066
|
+
this._drawTable(context, this._tableOptions(context, head, body, x, y, w, bottom, columnStyles));
|
|
5067
|
+
}
|
|
5068
|
+
else {
|
|
5069
|
+
this._boxNote(context.doc, CONTINUED_NOTICE, x, y, w, h);
|
|
4298
5070
|
}
|
|
4299
|
-
|
|
4300
|
-
|
|
5071
|
+
}
|
|
5072
|
+
// Each fetched row as the strings its table cells print.
|
|
5073
|
+
_tableCells(component, data, timezone) {
|
|
5074
|
+
const columns = listColumns(component, data);
|
|
5075
|
+
return data.rows.map((row) => columns.map((column) => exportCell(row[column.column], column.format, timezone)));
|
|
5076
|
+
}
|
|
5077
|
+
// The autoTable options every list table shares: the dry run, the on-page
|
|
5078
|
+
// draw and the continuation pages, so the three lay a table out alike. The
|
|
5079
|
+
// font is named rather than inherited, since the doc carries whatever the
|
|
5080
|
+
// last text drawn left set. bottom is the margin under the table, points.
|
|
5081
|
+
_tableOptions(context, head, body, x, startY, w, bottom, columnStyles) {
|
|
5082
|
+
return {
|
|
5083
|
+
theme: 'grid',
|
|
5084
|
+
head: [head],
|
|
4301
5085
|
body,
|
|
4302
|
-
startY
|
|
4303
|
-
margin: { left: x },
|
|
5086
|
+
startY,
|
|
5087
|
+
margin: { left: x, right: context.pageW - x - w, bottom },
|
|
4304
5088
|
tableWidth: w,
|
|
4305
|
-
|
|
5089
|
+
rowPageBreak: 'avoid',
|
|
5090
|
+
styles: { font: 'helvetica', fontStyle: 'normal', fontSize: 8, cellPadding: 3, textColor: '#1f2933' },
|
|
4306
5091
|
headStyles: { fillColor: '#4e79a7', textColor: '#ffffff', fontStyle: 'bold' },
|
|
4307
5092
|
alternateRowStyles: { fillColor: '#f5f7fa' },
|
|
4308
|
-
|
|
5093
|
+
...(columnStyles ? { columnStyles } : {}),
|
|
5094
|
+
};
|
|
5095
|
+
}
|
|
5096
|
+
// A notice as a table row: one italic grey cell spanning every column. A
|
|
5097
|
+
// single-line notice is ellipsized at the box's width and keeps the data
|
|
5098
|
+
// cells' font size and padding, so it is never taller than the data row
|
|
5099
|
+
// whose place it takes.
|
|
5100
|
+
_noticeRow(text, span, singleLine) {
|
|
5101
|
+
return [{
|
|
5102
|
+
content: text,
|
|
5103
|
+
colSpan: span,
|
|
5104
|
+
styles: { fontStyle: 'italic', textColor: '#7b8794', ...(singleLine ? { overflow: 'ellipsize' } : {}) },
|
|
5105
|
+
}];
|
|
5106
|
+
}
|
|
5107
|
+
// Lays every fetched row out once on a scratch document of the same page
|
|
5108
|
+
// size, with the options the real draw uses, and reads back how many body
|
|
5109
|
+
// rows the box's page holds (fit) and the column widths autoTable chose.
|
|
5110
|
+
// fit 0 means even the head did not fit: autoTable moved to a new page
|
|
5111
|
+
// before drawing. The real document is untouched.
|
|
5112
|
+
_measure(context, head, cells, x, y, w, bottom) {
|
|
5113
|
+
const scratch = new context.jsPDF({
|
|
5114
|
+
unit: 'pt',
|
|
5115
|
+
format: [context.pageW, context.pageH],
|
|
5116
|
+
orientation: context.orientation,
|
|
5117
|
+
});
|
|
5118
|
+
let fit = 0;
|
|
5119
|
+
context.autoTable(scratch, {
|
|
5120
|
+
...this._tableOptions(context, head, cells, x, y, w, bottom),
|
|
5121
|
+
didDrawCell: (hook) => {
|
|
5122
|
+
if (hook.section === 'body' && scratch.getCurrentPageInfo().pageNumber === 1) {
|
|
5123
|
+
fit = Math.max(fit, hook.row.index + 1);
|
|
5124
|
+
}
|
|
5125
|
+
},
|
|
4309
5126
|
});
|
|
5127
|
+
const columns = scratch.lastAutoTable.columns;
|
|
5128
|
+
const columnStyles = {};
|
|
5129
|
+
columns.forEach((column, index) => {
|
|
5130
|
+
columnStyles[index] = { cellWidth: column.width };
|
|
5131
|
+
});
|
|
5132
|
+
// A row taller than a whole page is split rather than moved, so a last
|
|
5133
|
+
// row that started on the first page can still have run past it.
|
|
5134
|
+
if (fit === cells.length && scratch.getNumberOfPages() > 1) {
|
|
5135
|
+
fit -= 1;
|
|
5136
|
+
}
|
|
5137
|
+
return { fit, columnStyles };
|
|
5138
|
+
}
|
|
5139
|
+
// Draws an on-page table, then removes any page the draw added. That should
|
|
5140
|
+
// never happen: the measured fit, the identical column widths and a
|
|
5141
|
+
// single-line notice keep the table inside its box. The check exists so a
|
|
5142
|
+
// stray page can never catch the report page's remaining components.
|
|
5143
|
+
_drawTable(context, options) {
|
|
5144
|
+
const { doc } = context;
|
|
5145
|
+
const page = doc.getCurrentPageInfo().pageNumber;
|
|
5146
|
+
const pages = doc.getNumberOfPages();
|
|
5147
|
+
context.autoTable(doc, options);
|
|
5148
|
+
while (doc.getNumberOfPages() > pages) {
|
|
5149
|
+
doc.deletePage(doc.getNumberOfPages());
|
|
5150
|
+
}
|
|
5151
|
+
doc.setPage(page);
|
|
5152
|
+
}
|
|
5153
|
+
// The rows of one list that did not fit its box, on pages appended after
|
|
5154
|
+
// the report page, each with the page's header and footer, the table's
|
|
5155
|
+
// title and its head again. The table sits CONTINUATION_GAP inside the
|
|
5156
|
+
// bands. Returns the physical page counter after these pages.
|
|
5157
|
+
_renderContinuation(context, page, index, pageNumber, overflow) {
|
|
5158
|
+
const { doc, report } = context;
|
|
5159
|
+
const bands = page.masterHidden ? NO_BANDS : report.config?.headerFooter ?? NO_BANDS;
|
|
5160
|
+
const top = this._continuationMargin(bands.headerHeight);
|
|
5161
|
+
const bottom = this._continuationMargin(bands.footerHeight);
|
|
5162
|
+
const { x, w } = overflow;
|
|
5163
|
+
const body = overflow.omitted > 0
|
|
5164
|
+
? [...overflow.rows, this._noticeRow(moreRowsNotice(overflow.omitted), overflow.head.length, false)]
|
|
5165
|
+
: overflow.rows;
|
|
5166
|
+
let counter = pageNumber;
|
|
5167
|
+
doc.addPage([context.pageW, context.pageH], context.orientation);
|
|
5168
|
+
context.autoTable(doc, {
|
|
5169
|
+
...this._tableOptions(context, overflow.head, body, x, top + TITLE_H$1, w, bottom, overflow.columnStyles),
|
|
5170
|
+
margin: { top: top + TITLE_H$1, bottom, left: x, right: context.pageW - x - w },
|
|
5171
|
+
showHead: 'everyPage',
|
|
5172
|
+
// Fires on every page the table draws on, the first included.
|
|
5173
|
+
willDrawPage: () => {
|
|
5174
|
+
counter += 1;
|
|
5175
|
+
this._renderMaster(context, page, this._tokens(report, page, index, counter));
|
|
5176
|
+
this._componentTitle(doc, this._continuedTitle(overflow.component), x, top, w);
|
|
5177
|
+
},
|
|
5178
|
+
});
|
|
5179
|
+
return counter;
|
|
5180
|
+
}
|
|
5181
|
+
// The margin a continuation table keeps from one edge: inside the band when
|
|
5182
|
+
// the page has one.
|
|
5183
|
+
_continuationMargin(band) {
|
|
5184
|
+
return band ? band * PT + CONTINUATION_GAP : NO_BAND_MARGIN;
|
|
5185
|
+
}
|
|
5186
|
+
_continuedTitle(component) {
|
|
5187
|
+
return component.title ? `${component.title} (continued)` : 'Continued';
|
|
4310
5188
|
}
|
|
4311
5189
|
_renderKpi(doc, component, data, x, y, w, h) {
|
|
4312
5190
|
const config = component.config;
|
|
@@ -4324,20 +5202,6 @@ class ReportPdfService {
|
|
|
4324
5202
|
doc.text(config.measure.label, x + w / 2, y + h / 2 + 16, { align: 'center' });
|
|
4325
5203
|
}
|
|
4326
5204
|
}
|
|
4327
|
-
// The exported cell must read like the on-screen cell: the SQL already
|
|
4328
|
-
// carries the formatting, so the value goes out verbatim. A raw date is the
|
|
4329
|
-
// one value the client renders (the column stays a RAW timestamp for the
|
|
4330
|
-
// report timezone), and it is rendered here too.
|
|
4331
|
-
_cell(value, columnFormat) {
|
|
4332
|
-
if (value === null || value === undefined) {
|
|
4333
|
-
return '';
|
|
4334
|
-
}
|
|
4335
|
-
if (columnFormat === 'date') {
|
|
4336
|
-
const date = new Date(String(value));
|
|
4337
|
-
return Number.isNaN(date.getTime()) ? String(value) : format(date, 'MMM d, yyyy');
|
|
4338
|
-
}
|
|
4339
|
-
return String(value);
|
|
4340
|
-
}
|
|
4341
5205
|
_slug(name) {
|
|
4342
5206
|
return name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'report';
|
|
4343
5207
|
}
|
|
@@ -4351,7 +5215,11 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImpo
|
|
|
4351
5215
|
// Component title strip height on the slide, inches.
|
|
4352
5216
|
const TITLE_H = 0.28;
|
|
4353
5217
|
// Approximate table row height, inches — drives the rows-that-fit cap.
|
|
4354
|
-
const
|
|
5218
|
+
const SLIDE_ROW_H = 0.26;
|
|
5219
|
+
// Rows fetched for a list's slide table. A slide table never continues onto
|
|
5220
|
+
// another slide, so this only has to cover the rows a box can show; the
|
|
5221
|
+
// moreRowsNotice() row names the rest.
|
|
5222
|
+
const PPTX_LIST_ROWS = 100;
|
|
4355
5223
|
// Our legend positions mapped to pptxgenjs's single-letter legendPos codes.
|
|
4356
5224
|
// 'hidden' has no slot — showLegend is turned off instead — so it falls back
|
|
4357
5225
|
// to top.
|
|
@@ -4369,6 +5237,10 @@ const WATERFALL_SERIES_NAMES = {
|
|
|
4369
5237
|
fall: 'Decrease',
|
|
4370
5238
|
total: 'Total',
|
|
4371
5239
|
};
|
|
5240
|
+
// A null is a period with no value. The screen breaks the line there, and
|
|
5241
|
+
// pptxgenjs's default ('span') would draw straight across it. The setting is
|
|
5242
|
+
// chart-level, so a combo's every type inherits it through the chart options.
|
|
5243
|
+
const BLANKS_AS_GAPS = { displayBlanksAs: 'gap' };
|
|
4372
5244
|
// Builds the PowerPoint deck client-side with pptxgenjs. Charts are NATIVE
|
|
4373
5245
|
// editable PowerPoint charts (slide.addChart writes real chart XML with an
|
|
4374
5246
|
// embedded data workbook — "Edit Data" works), never images. The page IS the
|
|
@@ -4386,6 +5258,7 @@ class ReportPptxService {
|
|
|
4386
5258
|
pptx.layout = 'REPORT';
|
|
4387
5259
|
pptx.title = report.name;
|
|
4388
5260
|
this._titleSlide(pptx, collected, pageW, pageH);
|
|
5261
|
+
const timezone = resolveReportTimezone(report);
|
|
4389
5262
|
collected.pages.forEach((collectedPage, index) => {
|
|
4390
5263
|
const slide = pptx.addSlide();
|
|
4391
5264
|
const geometry = pageGeometry(report, collectedPage.page);
|
|
@@ -4403,7 +5276,7 @@ class ReportPptxService {
|
|
|
4403
5276
|
this._renderText(slide, item.component, box ?? item.component, tokens);
|
|
4404
5277
|
}
|
|
4405
5278
|
else {
|
|
4406
|
-
this._renderComponent(pptx, slide, item, box);
|
|
5279
|
+
this._renderComponent(pptx, slide, item, timezone, box);
|
|
4407
5280
|
}
|
|
4408
5281
|
}
|
|
4409
5282
|
});
|
|
@@ -4415,10 +5288,7 @@ class ReportPptxService {
|
|
|
4415
5288
|
x: 0.5, y: pageH * 0.3, w: pageW - 1, h: 0.8,
|
|
4416
5289
|
fontSize: 30, bold: true, color: '1F2933',
|
|
4417
5290
|
});
|
|
4418
|
-
const lines =
|
|
4419
|
-
format(new Date(), 'MMMM d, yyyy'),
|
|
4420
|
-
...collected.filterSummary,
|
|
4421
|
-
];
|
|
5291
|
+
const lines = coverLines(collected);
|
|
4422
5292
|
slide.addText(lines.join('\n'), {
|
|
4423
5293
|
x: 0.5, y: pageH * 0.3 + 0.9, w: pageW - 1, h: Math.max(0.4, lines.length * 0.25),
|
|
4424
5294
|
fontSize: 12, color: '52606D',
|
|
@@ -4480,7 +5350,7 @@ class ReportPptxService {
|
|
|
4480
5350
|
reportName: report.name,
|
|
4481
5351
|
};
|
|
4482
5352
|
}
|
|
4483
|
-
_renderComponent(pptx, slide, item, geometry) {
|
|
5353
|
+
_renderComponent(pptx, slide, item, timezone, geometry) {
|
|
4484
5354
|
const component = item.component;
|
|
4485
5355
|
// Geometry is in inches — PowerPoint's native unit (flow layout arrives
|
|
4486
5356
|
// pre-resolved into rows by pageGeometry).
|
|
@@ -4505,7 +5375,7 @@ class ReportPptxService {
|
|
|
4505
5375
|
this._renderChart(pptx, slide, component, item.data, x, bodyY, w, bodyH);
|
|
4506
5376
|
break;
|
|
4507
5377
|
case 'list':
|
|
4508
|
-
this._renderTable(slide, component, item.data, x, bodyY, w, bodyH);
|
|
5378
|
+
this._renderTable(slide, component, item.data, timezone, x, bodyY, w, bodyH);
|
|
4509
5379
|
break;
|
|
4510
5380
|
case 'kpi':
|
|
4511
5381
|
this._renderKpi(slide, component, item.data, x, bodyY, w, bodyH);
|
|
@@ -4539,19 +5409,27 @@ class ReportPptxService {
|
|
|
4539
5409
|
? [{
|
|
4540
5410
|
name: config.measures?.[0]?.label ?? config.colorBy?.label ?? '',
|
|
4541
5411
|
labels,
|
|
4542
|
-
values: extracted.categories.map((_, index) => extracted.series[index]?.values[index] ??
|
|
5412
|
+
values: extracted.categories.map((_, index) => extracted.series[index]?.values[index] ?? null),
|
|
4543
5413
|
}]
|
|
4544
5414
|
: (pie ? extracted.series.slice(0, 1) : extracted.series)
|
|
4545
5415
|
.map((series) => ({
|
|
4546
5416
|
name: series.name,
|
|
4547
5417
|
labels,
|
|
4548
|
-
|
|
5418
|
+
// A pie keeps the 0 the screen's pie uses; an axis chart keeps its
|
|
5419
|
+
// null so the point is a gap.
|
|
5420
|
+
values: pie ? series.values.map((value) => value ?? 0) : series.values,
|
|
4549
5421
|
}));
|
|
4550
5422
|
// A single-series colorBy chart can't carry a per-category legend in a native
|
|
4551
5423
|
// PowerPoint bar chart; the category axis labels identify each bar instead.
|
|
4552
5424
|
const legendPosition = colorBy
|
|
4553
5425
|
? 'hidden'
|
|
4554
5426
|
: resolveLegendPosition(config, extracted.series.length);
|
|
5427
|
+
// The type each chartData entry is drawn as. A pie is one entry of its own
|
|
5428
|
+
// type; colorBy collapsed its series into one, drawn as the lone measure's.
|
|
5429
|
+
const seriesDataTypes = pie
|
|
5430
|
+
? [chartType]
|
|
5431
|
+
: seriesTypes(extracted, chartType).slice(0, colorBy ? 1 : undefined);
|
|
5432
|
+
const dataTypes = seriesDataTypes.length ? seriesDataTypes : [chartType];
|
|
4555
5433
|
const options = {
|
|
4556
5434
|
x, y, w, h,
|
|
4557
5435
|
chartColors: REPORT_CHART_COLORS,
|
|
@@ -4562,14 +5440,28 @@ class ReportPptxService {
|
|
|
4562
5440
|
barGrouping: config.stacked ? 'stacked' : 'clustered',
|
|
4563
5441
|
holeSize: chartType === 'donut' ? 55 : undefined,
|
|
4564
5442
|
...this._axisTextOptions(config, pie),
|
|
5443
|
+
...BLANKS_AS_GAPS,
|
|
5444
|
+
// A pie has no axes. A combo's types inherit these from the chart level.
|
|
5445
|
+
...(pie ? {} : {
|
|
5446
|
+
valAxisLabelFormatCode: chartAxisFormatCode(chartData.map((entry) => entry.values), !!config.stacked),
|
|
5447
|
+
...this._categoryOrderOptions(config, dataTypes),
|
|
5448
|
+
}),
|
|
4565
5449
|
dataBorder: { pt: 0, color: 'FFFFFF' },
|
|
4566
5450
|
};
|
|
4567
|
-
|
|
4568
|
-
|
|
4569
|
-
|
|
4570
|
-
|
|
4571
|
-
|
|
4572
|
-
|
|
5451
|
+
this._addChart(slide, typeMap, dataTypes, chartData, options);
|
|
5452
|
+
}
|
|
5453
|
+
// A horizontal bar chart reads top-down, first category at the top, as it
|
|
5454
|
+
// does on screen. PowerPoint draws category 0 next to the value axis, at the
|
|
5455
|
+
// bottom, so the category axis is reversed ('maxMin'); the value axis then
|
|
5456
|
+
// crosses at the last category ('max'), which now sits at the bottom, and
|
|
5457
|
+
// 'nextTo' keeps its labels on that line (pptxgenjs's default for a bar
|
|
5458
|
+
// direction, 'low', would follow the first category to the top). Only an
|
|
5459
|
+
// all-bar chart: a line or area is never drawn sideways.
|
|
5460
|
+
_categoryOrderOptions(config, types) {
|
|
5461
|
+
if (config.orientation !== 'horizontal' || types.some((type) => type !== 'bar')) {
|
|
5462
|
+
return {};
|
|
5463
|
+
}
|
|
5464
|
+
return { catAxisOrientation: 'maxMin', catAxisCrossesAt: 'max', valAxisLabelPos: 'nextTo' };
|
|
4573
5465
|
}
|
|
4574
5466
|
// One native chart when every entry is drawn as the same type. A combo is
|
|
4575
5467
|
// pptxgenjs's multi-type chart: one entry per type, all on the same category
|
|
@@ -4609,6 +5501,11 @@ class ReportPptxService {
|
|
|
4609
5501
|
barDir: config.orientation === 'horizontal' ? 'bar' : 'col',
|
|
4610
5502
|
barGrouping: 'stacked',
|
|
4611
5503
|
...this._axisTextOptions(config, false),
|
|
5504
|
+
...BLANKS_AS_GAPS,
|
|
5505
|
+
// The axis spans the running total, so its peak is the furthest a step
|
|
5506
|
+
// ends from zero; the pieces it is cut into never stack past that.
|
|
5507
|
+
valAxisLabelFormatCode: chartAxisFormatCode([waterfall.steps.map((step) => step.start + (step.change ?? 0))], false),
|
|
5508
|
+
...this._categoryOrderOptions(config, ['bar']),
|
|
4612
5509
|
// A running total can go below zero, where the category axis crosses;
|
|
4613
5510
|
// its labels stay at the plot's edge instead of across the bars.
|
|
4614
5511
|
catAxisLabelPos: 'low',
|
|
@@ -4663,25 +5560,22 @@ class ReportPptxService {
|
|
|
4663
5560
|
valAxisTitleFontSize: 10,
|
|
4664
5561
|
};
|
|
4665
5562
|
}
|
|
4666
|
-
_renderTable(slide, component, data, x, y, w, h) {
|
|
4667
|
-
const
|
|
4668
|
-
const
|
|
4669
|
-
? config.columns
|
|
4670
|
-
: data.columns.map((column) => ({ column, label: column }));
|
|
4671
|
-
const cap = Math.max(2, Math.floor(h / TABLE_ROW_H) - 1);
|
|
5563
|
+
_renderTable(slide, component, data, timezone, x, y, w, h) {
|
|
5564
|
+
const columns = listColumns(component, data);
|
|
5565
|
+
const cap = Math.max(2, Math.floor(h / SLIDE_ROW_H) - 1);
|
|
4672
5566
|
const rows = data.rows.slice(0, cap);
|
|
4673
5567
|
const remaining = (data.paging?.total ?? data.rows.length) - rows.length;
|
|
4674
|
-
const header =
|
|
4675
|
-
text:
|
|
5568
|
+
const header = listHeadings(component, data).map((heading) => ({
|
|
5569
|
+
text: heading,
|
|
4676
5570
|
options: { bold: true, color: 'FFFFFF', fill: { color: '4E79A7' } },
|
|
4677
5571
|
}));
|
|
4678
5572
|
const body = rows.map((row) => columns.map((column) => ({
|
|
4679
|
-
text:
|
|
5573
|
+
text: exportCell(row[column.column], column.format, timezone),
|
|
4680
5574
|
options: {},
|
|
4681
5575
|
})));
|
|
4682
5576
|
if (remaining > 0) {
|
|
4683
5577
|
body.push([{
|
|
4684
|
-
text:
|
|
5578
|
+
text: moreRowsNotice(remaining),
|
|
4685
5579
|
options: { colspan: columns.length, italic: true, color: '7B8794' },
|
|
4686
5580
|
}]);
|
|
4687
5581
|
}
|
|
@@ -4691,7 +5585,7 @@ class ReportPptxService {
|
|
|
4691
5585
|
color: '1F2933',
|
|
4692
5586
|
border: { type: 'solid', pt: 0.5, color: 'E4E7EB' },
|
|
4693
5587
|
autoPage: false,
|
|
4694
|
-
rowH:
|
|
5588
|
+
rowH: SLIDE_ROW_H,
|
|
4695
5589
|
valign: 'middle',
|
|
4696
5590
|
});
|
|
4697
5591
|
}
|
|
@@ -4709,20 +5603,6 @@ class ReportPptxService {
|
|
|
4709
5603
|
valign: 'middle',
|
|
4710
5604
|
});
|
|
4711
5605
|
}
|
|
4712
|
-
// The exported cell must read like the on-screen cell: the SQL already
|
|
4713
|
-
// carries the formatting, so the value goes out verbatim. A raw date is the
|
|
4714
|
-
// one value the client renders (the column stays a RAW timestamp for the
|
|
4715
|
-
// report timezone), and it is rendered here too.
|
|
4716
|
-
_cell(value, columnFormat) {
|
|
4717
|
-
if (value === null || value === undefined) {
|
|
4718
|
-
return '';
|
|
4719
|
-
}
|
|
4720
|
-
if (columnFormat === 'date') {
|
|
4721
|
-
const date = new Date(String(value));
|
|
4722
|
-
return Number.isNaN(date.getTime()) ? String(value) : format(date, 'MMM d, yyyy');
|
|
4723
|
-
}
|
|
4724
|
-
return String(value);
|
|
4725
|
-
}
|
|
4726
5606
|
_slug(name) {
|
|
4727
5607
|
return name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'report';
|
|
4728
5608
|
}
|
|
@@ -4755,20 +5635,23 @@ class ReportExportService {
|
|
|
4755
5635
|
_pptxService = inject(ReportPptxService);
|
|
4756
5636
|
_prompt = inject(FsPrompt);
|
|
4757
5637
|
pdf(report) {
|
|
4758
|
-
return this._run(report, (collected) => this._pdfService.export(collected));
|
|
5638
|
+
return this._run(report, PDF_LIST_ROWS, (collected) => this._pdfService.export(collected));
|
|
4759
5639
|
}
|
|
4760
5640
|
powerpoint(report) {
|
|
4761
|
-
return this._run(report, (collected) => this._pptxService.export(collected));
|
|
5641
|
+
return this._run(report, PPTX_LIST_ROWS, (collected) => this._pptxService.export(collected));
|
|
4762
5642
|
}
|
|
4763
5643
|
// Retry re-collects the WHOLE report, not just the tiles that failed. Every
|
|
4764
5644
|
// number in one file has to have been true at one moment: refetching four
|
|
4765
5645
|
// tiles and keeping nine from three minutes ago produces a deck whose pages
|
|
4766
5646
|
// disagree with each other, and nothing on it would say so.
|
|
4767
|
-
|
|
4768
|
-
|
|
5647
|
+
//
|
|
5648
|
+
// listRows is the format's own list cap, carried into the retry so a second
|
|
5649
|
+
// attempt fetches what the first did.
|
|
5650
|
+
_run(report, listRows, render) {
|
|
5651
|
+
return this._collector.collect(report, listRows)
|
|
4769
5652
|
.pipe(switchMap$1((collected) => this._gate(collected)
|
|
4770
5653
|
.pipe(switchMap$1((choice) => choice === RETRY
|
|
4771
|
-
? this._run(report, render)
|
|
5654
|
+
? this._run(report, listRows, render)
|
|
4772
5655
|
: from(render(collected))))));
|
|
4773
5656
|
}
|
|
4774
5657
|
// Resolves to DOWNLOAD when the export is whole (or the user accepted an
|
|
@@ -4813,7 +5696,7 @@ class ReportService {
|
|
|
4813
5696
|
}
|
|
4814
5697
|
load() {
|
|
4815
5698
|
return this._reportData.reports()
|
|
4816
|
-
.pipe(tap((reports) => this._reports$.next(reports ?? [])));
|
|
5699
|
+
.pipe(tap$1((reports) => this._reports$.next(reports ?? [])));
|
|
4817
5700
|
}
|
|
4818
5701
|
// Filters the cached list locally — what the autocomplete's [fetch] calls.
|
|
4819
5702
|
filter(keyword) {
|
|
@@ -4826,20 +5709,20 @@ class ReportService {
|
|
|
4826
5709
|
}
|
|
4827
5710
|
create(name) {
|
|
4828
5711
|
return this._reportData.create(name)
|
|
4829
|
-
.pipe(tap((report) => {
|
|
5712
|
+
.pipe(tap$1((report) => {
|
|
4830
5713
|
this._reports$.next([...this._reports$.getValue(), report]);
|
|
4831
5714
|
}));
|
|
4832
5715
|
}
|
|
4833
5716
|
rename(reportId, name) {
|
|
4834
5717
|
return this._reportData.rename(reportId, name)
|
|
4835
|
-
.pipe(tap((report) => {
|
|
5718
|
+
.pipe(tap$1((report) => {
|
|
4836
5719
|
this._reports$.next(this._reports$.getValue()
|
|
4837
5720
|
.map((existing) => (existing.id === report.id ? report : existing)));
|
|
4838
5721
|
}));
|
|
4839
5722
|
}
|
|
4840
5723
|
delete(reportId) {
|
|
4841
5724
|
return this._reportData.delete(reportId)
|
|
4842
|
-
.pipe(tap(() => {
|
|
5725
|
+
.pipe(tap$1(() => {
|
|
4843
5726
|
this._reports$.next(this._reports$.getValue().filter((report) => report.id !== reportId));
|
|
4844
5727
|
}), map(() => undefined));
|
|
4845
5728
|
}
|
|
@@ -4859,15 +5742,15 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImpo
|
|
|
4859
5742
|
// turn via [requestData].
|
|
4860
5743
|
class ReportComponent {
|
|
4861
5744
|
// The single report the viewer and chat are focused on.
|
|
4862
|
-
selected = null;
|
|
4863
|
-
report = null;
|
|
4864
|
-
loadingReports = true;
|
|
4865
|
-
editMode = false;
|
|
5745
|
+
selected = signal(null);
|
|
5746
|
+
report = signal(null);
|
|
5747
|
+
loadingReports = signal(true);
|
|
5748
|
+
editMode = signal(false);
|
|
4866
5749
|
// The report toolbar IS an fs-filter: the picker rides in its heading
|
|
4867
5750
|
// template, the menu becomes its actions, and report-level filter groups
|
|
4868
5751
|
// become its items. Rebuilt whenever the loaded report (and thus its groups)
|
|
4869
5752
|
// changes; the menu's show-guards keep working while no report is selected.
|
|
4870
|
-
reportFilterConfig;
|
|
5753
|
+
reportFilterConfig = signal(null);
|
|
4871
5754
|
_split;
|
|
4872
5755
|
_chatPanel;
|
|
4873
5756
|
// Only the canvas knows which page is on screen, and a page-scoped export
|
|
@@ -4880,18 +5763,17 @@ class ReportComponent {
|
|
|
4880
5763
|
_reportService = inject(ReportService);
|
|
4881
5764
|
_filterState = inject(ReportFilterStateService);
|
|
4882
5765
|
_exportService = inject(ReportExportService);
|
|
5766
|
+
_collector = inject(ReportExportCollectorService);
|
|
4883
5767
|
_process = inject(FsProcess);
|
|
4884
5768
|
_prompt = inject(FsPrompt);
|
|
4885
5769
|
_dialog = inject(MatDialog);
|
|
4886
5770
|
_destroyRef = inject(DestroyRef);
|
|
4887
|
-
_cdRef = inject(ChangeDetectorRef);
|
|
4888
5771
|
_zone = inject(NgZone);
|
|
4889
5772
|
constructor() {
|
|
4890
5773
|
this._buildReportConfig();
|
|
4891
5774
|
this._reportService.load()
|
|
4892
5775
|
.pipe(finalize(() => {
|
|
4893
|
-
this.loadingReports
|
|
4894
|
-
this._cdRef.markForCheck();
|
|
5776
|
+
this.loadingReports.set(false);
|
|
4895
5777
|
}), takeUntilDestroyed(this._destroyRef))
|
|
4896
5778
|
.subscribe({ error: () => undefined });
|
|
4897
5779
|
}
|
|
@@ -4899,8 +5781,8 @@ class ReportComponent {
|
|
|
4899
5781
|
return this._reportService.filter(keyword);
|
|
4900
5782
|
};
|
|
4901
5783
|
reportChange(report) {
|
|
4902
|
-
this.report
|
|
4903
|
-
this.editMode
|
|
5784
|
+
this.report.set(null);
|
|
5785
|
+
this.editMode.set(false);
|
|
4904
5786
|
this._buildReportConfig();
|
|
4905
5787
|
if (report) {
|
|
4906
5788
|
this._loadReport(report.id);
|
|
@@ -4915,13 +5797,12 @@ class ReportComponent {
|
|
|
4915
5797
|
})
|
|
4916
5798
|
.pipe(switchMap$1((name) => this._reportService.create(name)), takeUntilDestroyed(this._destroyRef))
|
|
4917
5799
|
.subscribe((report) => {
|
|
4918
|
-
this.selected
|
|
4919
|
-
this._cdRef.markForCheck();
|
|
5800
|
+
this.selected.set(report);
|
|
4920
5801
|
this._loadReport(report.id);
|
|
4921
5802
|
});
|
|
4922
5803
|
}
|
|
4923
5804
|
toggleEditMode() {
|
|
4924
|
-
this.editMode
|
|
5805
|
+
this.editMode.update((editMode) => !editMode);
|
|
4925
5806
|
}
|
|
4926
5807
|
// The edit tools live in this toolbar, but the canvas owns what they act on
|
|
4927
5808
|
// (the active page, the header/footer bands, which layer is live).
|
|
@@ -4938,11 +5819,11 @@ class ReportComponent {
|
|
|
4938
5819
|
// list and structure both reload on save (a layout switch re-renders the
|
|
4939
5820
|
// whole canvas).
|
|
4940
5821
|
reportSettings() {
|
|
4941
|
-
if (!this.report) {
|
|
5822
|
+
if (!this.report()) {
|
|
4942
5823
|
return;
|
|
4943
5824
|
}
|
|
4944
5825
|
this._dialog.open(ReportSettingsComponent, {
|
|
4945
|
-
data: { report: this.report },
|
|
5826
|
+
data: { report: this.report() },
|
|
4946
5827
|
width: '460px',
|
|
4947
5828
|
})
|
|
4948
5829
|
.afterClosed()
|
|
@@ -4951,26 +5832,25 @@ class ReportComponent {
|
|
|
4951
5832
|
// The dialog confirms + performs the delete itself; we just react to
|
|
4952
5833
|
// what it reports back.
|
|
4953
5834
|
if (result?.action === 'deleted') {
|
|
4954
|
-
this.selected
|
|
4955
|
-
this.report
|
|
5835
|
+
this.selected.set(null);
|
|
5836
|
+
this.report.set(null);
|
|
4956
5837
|
this._buildReportConfig();
|
|
4957
|
-
this._cdRef.markForCheck();
|
|
4958
5838
|
return;
|
|
4959
5839
|
}
|
|
4960
|
-
if (result?.action === 'saved' && this.selected) {
|
|
4961
|
-
this._loadReport(this.selected.id);
|
|
4962
|
-
this._refreshReportName(this.selected.id);
|
|
5840
|
+
if (result?.action === 'saved' && this.selected()) {
|
|
5841
|
+
this._loadReport(this.selected().id);
|
|
5842
|
+
this._refreshReportName(this.selected().id);
|
|
4963
5843
|
}
|
|
4964
5844
|
});
|
|
4965
5845
|
}
|
|
4966
5846
|
// Component settings — title, exact geometry, and the diagnostic views
|
|
4967
5847
|
// (SQL, config, filter wiring).
|
|
4968
5848
|
componentSettings(component) {
|
|
4969
|
-
if (!this.report) {
|
|
5849
|
+
if (!this.report()) {
|
|
4970
5850
|
return;
|
|
4971
5851
|
}
|
|
4972
5852
|
const dialogRef = this._dialog.open(ComponentSettingsComponent, {
|
|
4973
|
-
data: { report: this.report, component },
|
|
5853
|
+
data: { report: this.report(), component },
|
|
4974
5854
|
width: '640px',
|
|
4975
5855
|
});
|
|
4976
5856
|
// Filter add/remove/level changes persist immediately inside the dialog (no
|
|
@@ -4988,16 +5868,16 @@ class ReportComponent {
|
|
|
4988
5868
|
// gone from the report, so reload rather than patching it out locally —
|
|
4989
5869
|
// its filters went with it, which the report bar has to be rebuilt for.
|
|
4990
5870
|
if (result?.deleted) {
|
|
4991
|
-
if (this.selected) {
|
|
4992
|
-
this._loadReport(this.selected.id);
|
|
5871
|
+
if (this.selected()) {
|
|
5872
|
+
this._loadReport(this.selected().id);
|
|
4993
5873
|
}
|
|
4994
5874
|
return;
|
|
4995
5875
|
}
|
|
4996
5876
|
// The report's filter groups changed, so the report bar and the
|
|
4997
5877
|
// on-component strips have to be rebuilt from a fresh report.
|
|
4998
5878
|
if (filtersChanged) {
|
|
4999
|
-
if (this.selected) {
|
|
5000
|
-
this._loadReport(this.selected.id);
|
|
5879
|
+
if (this.selected()) {
|
|
5880
|
+
this._loadReport(this.selected().id);
|
|
5001
5881
|
}
|
|
5002
5882
|
return;
|
|
5003
5883
|
}
|
|
@@ -5012,8 +5892,8 @@ class ReportComponent {
|
|
|
5012
5892
|
// The canvas changed the report's structure (added a page) — re-fetch so the
|
|
5013
5893
|
// new page renders; the canvas lands the view on it.
|
|
5014
5894
|
onCanvasReportChanged() {
|
|
5015
|
-
if (this.selected) {
|
|
5016
|
-
this._loadReport(this.selected.id);
|
|
5895
|
+
if (this.selected()) {
|
|
5896
|
+
this._loadReport(this.selected().id);
|
|
5017
5897
|
}
|
|
5018
5898
|
}
|
|
5019
5899
|
// Export the report to PowerPoint, entirely client-side: every component's
|
|
@@ -5021,32 +5901,37 @@ class ReportComponent {
|
|
|
5021
5901
|
// the visible one), then pptxgenjs builds a deck of NATIVE editable charts —
|
|
5022
5902
|
// page = slide, positions 1:1. The dock shows progress until the file saves.
|
|
5023
5903
|
exportPowerpoint() {
|
|
5024
|
-
if (!this.report) {
|
|
5904
|
+
if (!this.report()) {
|
|
5025
5905
|
return;
|
|
5026
5906
|
}
|
|
5027
|
-
this._process.run('Exporting PowerPoint', this._exportService.powerpoint(this.report));
|
|
5907
|
+
this._process.run('Exporting PowerPoint', this._exportService.powerpoint(this.report()));
|
|
5028
5908
|
}
|
|
5029
5909
|
// Export to PDF, entirely client-side: same collection pass, charts rendered
|
|
5030
5910
|
// through an offscreen ECharts instance at 3× pixel ratio (pixel-identical
|
|
5031
5911
|
// to the screen), tables via jspdf-autotable — page = PDF page.
|
|
5032
5912
|
exportPdf() {
|
|
5033
|
-
if (!this.report) {
|
|
5913
|
+
if (!this.report()) {
|
|
5034
5914
|
return;
|
|
5035
5915
|
}
|
|
5036
|
-
this._process.run('Exporting PDF', this._exportService.pdf(this.report));
|
|
5916
|
+
this._process.run('Exporting PDF', this._exportService.pdf(this.report()));
|
|
5037
5917
|
}
|
|
5038
|
-
// The
|
|
5039
|
-
// component
|
|
5040
|
-
// by
|
|
5041
|
-
//
|
|
5042
|
-
//
|
|
5918
|
+
// The whole report as files in one action: the server writes one CSV per
|
|
5919
|
+
// component, each what the screen shows (a chart or KPI as drawn, at the
|
|
5920
|
+
// viewer's group-by grain; a list in full), and zips them foldered by page
|
|
5921
|
+
// with an Export Notes.txt that records the filters as the bar reads them.
|
|
5922
|
+
// Lists use their saved default sort; a header click is carried only by a
|
|
5923
|
+
// component's own CSV export. `thisPage` narrows it to the page the canvas is
|
|
5924
|
+
// showing. Unlike the PDF/PowerPoint exports the files are written
|
|
5925
|
+
// server-side, so they are not capped by what a slide can hold.
|
|
5043
5926
|
exportData(thisPage = false) {
|
|
5044
|
-
const report = this.report;
|
|
5927
|
+
const report = this.report();
|
|
5045
5928
|
if (!report) {
|
|
5046
5929
|
return;
|
|
5047
5930
|
}
|
|
5048
5931
|
const pageId = thisPage ? this._canvas?.activePage?.id ?? null : null;
|
|
5049
|
-
|
|
5932
|
+
const filters = this._filterState.resolveForReport(report, pageId);
|
|
5933
|
+
this._process.download('Exporting data', this._collector.getFilterSummary(report)
|
|
5934
|
+
.pipe(switchMap$1((lines) => this._reportData.exportReport(report.id, filters, pageId, this._filterState.frequency(), lines))));
|
|
5050
5935
|
}
|
|
5051
5936
|
// React to each chat turn (fs-chat handles the request + persistence); we
|
|
5052
5937
|
// only care about the report side effects this context's backend flags.
|
|
@@ -5054,12 +5939,12 @@ class ReportComponent {
|
|
|
5054
5939
|
// The agent changed the report's structure — re-fetch it. Session filter
|
|
5055
5940
|
// values survive (the filter-state service keeps values for groups that
|
|
5056
5941
|
// still exist).
|
|
5057
|
-
if (response.reportChanged && this.selected) {
|
|
5058
|
-
this._loadReport(this.selected.id);
|
|
5942
|
+
if (response.reportChanged && this.selected()) {
|
|
5943
|
+
this._loadReport(this.selected().id);
|
|
5059
5944
|
}
|
|
5060
5945
|
// A rename changes nothing structural — refresh the picker chip label.
|
|
5061
|
-
if (response.reportRenamed && this.selected) {
|
|
5062
|
-
this._refreshReportName(this.selected.id);
|
|
5946
|
+
if (response.reportRenamed && this.selected()) {
|
|
5947
|
+
this._refreshReportName(this.selected().id);
|
|
5063
5948
|
}
|
|
5064
5949
|
}
|
|
5065
5950
|
// Drag the divider between the chat and canvas panes (pointer-capture loop
|
|
@@ -5096,16 +5981,17 @@ class ReportComponent {
|
|
|
5096
5981
|
this._reportData.get(reportId)
|
|
5097
5982
|
.pipe(takeUntilDestroyed(this._destroyRef))
|
|
5098
5983
|
.subscribe((report) => {
|
|
5099
|
-
this.report
|
|
5984
|
+
this.report.set(report);
|
|
5985
|
+
// No URL state in this view: nothing is restored from the URL and
|
|
5986
|
+
// nothing listens to urlChanges(), so its filters stay session-only.
|
|
5100
5987
|
this._filterState.init(report);
|
|
5101
5988
|
this._buildReportConfig();
|
|
5102
|
-
this._cdRef.markForCheck();
|
|
5103
5989
|
});
|
|
5104
5990
|
}
|
|
5105
5991
|
// Report-level filter groups in their saved order — the ones that render in
|
|
5106
5992
|
// the toolbar (component-level groups render on their components).
|
|
5107
5993
|
_reportGroups() {
|
|
5108
|
-
return (this.report?.filterGroups ?? [])
|
|
5994
|
+
return (this.report()?.filterGroups ?? [])
|
|
5109
5995
|
.filter((group) => group.level === 'report' || group.level === 'both')
|
|
5110
5996
|
.sort((a, b) => a.order - b.order);
|
|
5111
5997
|
}
|
|
@@ -5123,7 +6009,7 @@ class ReportComponent {
|
|
|
5123
6009
|
// The stored default is part of the signature: saving a new default in report
|
|
5124
6010
|
// settings rebuilds the config, but without a key change fs-filter keeps
|
|
5125
6011
|
// rendering the values it was created with until a full page reload.
|
|
5126
|
-
groupBySignature(this.report),
|
|
6012
|
+
groupBySignature(this.report()),
|
|
5127
6013
|
...this._reportGroups().map((group) => `${group.id}:${group.level}:${group.label}:${defaultSignature(group)}`),
|
|
5128
6014
|
].join('|');
|
|
5129
6015
|
}
|
|
@@ -5132,10 +6018,10 @@ class ReportComponent {
|
|
|
5132
6018
|
// the picker, not here. Only built/used once a report is loaded (the template
|
|
5133
6019
|
// gates it on `report`).
|
|
5134
6020
|
_buildReportConfig() {
|
|
5135
|
-
const reportId = this.report?.id ?? null;
|
|
6021
|
+
const reportId = this.report()?.id ?? null;
|
|
5136
6022
|
// Drop a grain the control no longer offers — the agent can narrow the list
|
|
5137
6023
|
// mid-session, and an override the viewer can't see is worse than none.
|
|
5138
|
-
if (this.report && !isGranularityOffered(this.report, this._filterState.frequency())) {
|
|
6024
|
+
if (this.report() && !isGranularityOffered(this.report(), this._filterState.frequency())) {
|
|
5139
6025
|
this._filterState.setFrequency(null);
|
|
5140
6026
|
}
|
|
5141
6027
|
const items = reportId
|
|
@@ -5149,7 +6035,7 @@ class ReportComponent {
|
|
|
5149
6035
|
if (groupByItem) {
|
|
5150
6036
|
items.unshift(groupByItem);
|
|
5151
6037
|
}
|
|
5152
|
-
this.reportFilterConfig
|
|
6038
|
+
this.reportFilterConfig.set({
|
|
5153
6039
|
// Never touch the URL or persist filter state — report filters are
|
|
5154
6040
|
// session-only, owned by ReportFilterStateService.
|
|
5155
6041
|
queryParam: false,
|
|
@@ -5162,14 +6048,14 @@ class ReportComponent {
|
|
|
5162
6048
|
// Not a filter group — the grain is read straight off the query.
|
|
5163
6049
|
this._filterState.setFrequency(query?.[GROUP_BY_ITEM] || null);
|
|
5164
6050
|
},
|
|
5165
|
-
};
|
|
6051
|
+
});
|
|
5166
6052
|
}
|
|
5167
6053
|
// The report's group-by control as an fs-filter item, or null when the report
|
|
5168
6054
|
// has none to show. Seeded from the session state so the grain the viewer
|
|
5169
6055
|
// picked survives the report being re-fetched after a chat edit.
|
|
5170
6056
|
_groupByItem() {
|
|
5171
|
-
return this.report
|
|
5172
|
-
? groupByFilterItem(this.report, this._filterState.frequency())
|
|
6057
|
+
return this.report()
|
|
6058
|
+
? groupByFilterItem(this.report(), this._filterState.frequency())
|
|
5173
6059
|
: null;
|
|
5174
6060
|
}
|
|
5175
6061
|
_refreshReportName(reportId) {
|
|
@@ -5178,11 +6064,10 @@ class ReportComponent {
|
|
|
5178
6064
|
.subscribe((reports) => {
|
|
5179
6065
|
const renamed = reports.find((report) => report.id === reportId);
|
|
5180
6066
|
if (renamed) {
|
|
5181
|
-
this.selected
|
|
5182
|
-
if (this.report) {
|
|
5183
|
-
this.report
|
|
6067
|
+
this.selected.set(renamed);
|
|
6068
|
+
if (this.report()) {
|
|
6069
|
+
this.report.set({ ...this.report(), name: renamed.name });
|
|
5184
6070
|
}
|
|
5185
|
-
this._cdRef.markForCheck();
|
|
5186
6071
|
}
|
|
5187
6072
|
});
|
|
5188
6073
|
}
|
|
@@ -5197,8 +6082,8 @@ class ReportComponent {
|
|
|
5197
6082
|
ReportExportService,
|
|
5198
6083
|
// Tree-shaken ECharts core + the 'report' house theme, loaded lazily with
|
|
5199
6084
|
// this route's chunk.
|
|
5200
|
-
provideEchartsCore({ echarts: () => import('./firestitch-report-echarts-
|
|
5201
|
-
], viewQueries: [{ propertyName: "_split", first: true, predicate: ["split"], descendants: true, static: true }, { propertyName: "_chatPanel", first: true, predicate: ["chatPanel"], descendants: true, read: ElementRef, static: true }, { propertyName: "_canvas", first: true, predicate: ReportCanvasComponent, descendants: true }], ngImport: i0, template: "<div\n #split\n class=\"report fs-row.align-start\">\n <fs-ai-chat\n #chatPanel\n class=\"chat\"\n style=\"min-height: 500px;\"\n basePath=\"reports\"\n [requestData]=\"{ reportId: selected?.id ?? null }\"\n [introMessage]=\"introMessage\"\n (response)=\"onChatResponse($event)\">\n </fs-ai-chat>\n <div\n class=\"resizer\"\n (pointerdown)=\"onResizeStart($event)\">\n </div>\n <div class=\"viewer fs-flex fs-column\">\n <div class=\"fs-row.align-center.gap-sm\">\n <fs-autocomplete-chips\n class=\"fs-flex\"\n [fetch]=\"fetchReports\"\n [(ngModel)]=\"selected\"\n [disabled]=\"loadingReports\"\n [padless]=\"true\"\n [multiple]=\"false\"\n [fetchOnFocus]=\"true\"\n (ngModelChange)=\"reportChange($event)\"\n placeholder=\"Report\"\n name=\"report\">\n <ng-template\n fsAutocompleteChipsTemplate\n let-object=\"object\">\n {{ object.name }}\n </ng-template>\n <ng-template\n fsAutocompleteChipsStatic\n (click)=\"createReport()\">\n Create Report\n </ng-template>\n </fs-autocomplete-chips>\n @if (report && editMode) {\n <!-- What layout editing can add or switch to, and the way out of it \u2014\n the same outlined buttons as the fs-ai-report toolbar. -->\n <button\n mat-stroked-button\n type=\"button\"\n matTooltip=\"Add a block of text\"\n (click)=\"addText()\">\n <mat-icon>title</mat-icon>\n Add text\n </button>\n <button\n mat-stroked-button\n type=\"button\"\n class=\"master-toggle\"\n [class.active]=\"isMasterMode\"\n [matTooltip]=\"isMasterMode ? 'Back to editing this page' : 'Edit the header and footer shown on every page'\"\n (click)=\"toggleMasterMode()\">\n <mat-icon>web_asset</mat-icon>\n Header & footer\n </button>\n <button\n mat-stroked-button\n type=\"button\"\n (click)=\"toggleEditMode()\">\n <mat-icon>check</mat-icon>\n Done editing\n </button>\n }\n @if (report) {\n <fs-menu>\n <ng-template\n fs-menu-item\n (click)=\"reportSettings()\">\n <mat-icon>\n tune\n </mat-icon>\n Report settings\n </ng-template>\n <ng-template\n fs-menu-item\n (click)=\"toggleEditMode()\"\n [disabled]=\"editMode\">\n <mat-icon>\n dashboard_customize\n </mat-icon>\n {{ editMode ? 'Done editing layout' : 'Edit layout' }}\n </ng-template>\n <ng-template\n fs-menu-item\n (click)=\"exportPowerpoint()\">\n <mat-icon>\n slideshow\n </mat-icon>\n Export PowerPoint\n </ng-template>\n <ng-template\n fs-menu-item\n (click)=\"exportPdf()\">\n <mat-icon>\n picture_as_pdf\n </mat-icon>\n Export PDF\n </ng-template>\n <ng-template\n fs-menu-item\n (click)=\"exportData()\">\n <mat-icon>\n table_view\n </mat-icon>\n Export Raw Data\n </ng-template>\n @if ((report?.pages?.length ?? 0) > 1) {\n <ng-template\n fs-menu-item\n (click)=\"exportData(true)\">\n <mat-icon>\n table_view\n </mat-icon>\n Export Raw Data (This Page)\n </ng-template>\n }\n </fs-menu>\n }\n </div>\n @if (report) {\n <!-- Report-level filters only (the report's actions live in the menu\n above). fs-filter reads its config once at init, so it's keyed on the\n filter signature: when the report-level filter set changes the block\n is recreated and re-reads the rebuilt config. -->\n @if (reportHasFilters) {\n @for (key of [reportFilterKey]; track key) {\n <fs-filter [config]=\"reportFilterConfig\"></fs-filter>\n }\n }\n <app-report-canvas\n [report]=\"report\"\n [editMode]=\"editMode\"\n (componentSettings)=\"componentSettings($event)\"\n (reportChanged)=\"onCanvasReportChanged()\">\n </app-report-canvas>\n }\n </div>\n</div>", styles: [".report{height:100%;min-height:500px}.report .chat{display:block;flex:0 0 25%;width:100%;height:100%;min-height:500px;min-width:0;border:none}.report .viewer{display:flex;flex-direction:column;min-width:0;height:100%;overflow:hidden}.report .viewer fs-filter{flex:0 0 auto;margin-top:10px;margin-bottom:0}.report .viewer ::ng-deep .mat-mdc-form-field-subscript-wrapper{display:none}.report .resizer{flex:0 0 11px;align-self:stretch;display:flex;justify-content:center;cursor:col-resize;touch-action:none;-webkit-user-select:none;user-select:none}.report .resizer:before{content:\"\";width:0px;background:#0000001f;transition:width .12s ease,background-color .12s ease}.report .resizer:hover:before{width:3px;background:var(--brand-primary-color)}.report.resizing{cursor:col-resize;-webkit-user-select:none;user-select:none}.report.resizing .resizer:before{width:3px;background:var(--brand-primary-color)}.report.resizing .chat,.report.resizing .viewer{pointer-events:none}::ng-deep body.body-report-reports .mat-mdc-card-content{display:flex;flex-direction:column;box-sizing:border-box}::ng-deep body.body-report-reports .mat-mdc-card-content mat-tab-nav-panel{flex:1;min-height:0}::ng-deep body.body-report-reports .mat-mdc-card-content mat-tab-nav-panel router-outlet+ng-component{height:100%}.master-toggle.active{background:#eceff3}\n"], dependencies: [{ kind: "ngmodule", type: FormsModule }, { kind: "directive", type: i1$1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1$1.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }, { kind: "ngmodule", type: FsAutocompleteChipsModule }, { kind: "component", type: i2$3.FsAutocompleteChipsComponent, selector: "fs-autocomplete-chips", inputs: ["fetch", "appearance", "floatLabel", "readonly", "size", "label", "placeholder", "chipImage", "chipBackground", "chipColor", "chipIcon", "chipIconColor", "chipClass", "chipPadding", "shape", "hint", "allowText", "allowObject", "delay", "minPanelWidth", "maxPanelHeight", "validateText", "removable", "allowClear", "color", "background", "orderable", "padless", "initOnClick", "fetchOnFocus", "multiple", "multipleAdd", "confirm", "disabled", "groupBy", "panelWidth", "panelClass", "compareWith"], outputs: ["selected", "removed", "reordered", "clear", "panelOpened", "panelClosed"] }, { kind: "directive", type: i2$3.FsAutocompleteObjectDirective, selector: "[fsAutocompleteObject],[fsAutocompleteChipsTemplate]" }, { kind: "directive", type: i2$3.FsAutocompleteChipsStaticDirective, selector: "[fsAutocompleteChipsStatic]", inputs: ["show", "disable"], outputs: ["click", "selected"] }, { kind: "ngmodule", type: FsFilterModule }, { kind: "component", type: i2.FilterComponent, selector: "fs-filter", inputs: ["config"], outputs: ["closed", "opened", "ready"] }, { kind: "ngmodule", type: FsMenuModule }, { kind: "component", type: i2$1.FsMenuComponent, selector: "fs-menu", inputs: ["class", "buttonClass", "buttonType", "buttonColor"], outputs: ["opened", "closed"] }, { kind: "directive", type: i2$1.FsMenuItemDirective, selector: "fs-menu-group,[fs-menu-item]" }, { kind: "component", type: FsAiChatComponent, selector: "fs-ai-chat", inputs: ["basePath", "requestData", "ready", "introMessage", "showThinking"], outputs: ["response", "showThinkingChange"] }, { kind: "component", type: MatButton, selector: " button[mat-button], button[mat-raised-button], button[mat-flat-button], button[mat-stroked-button] ", exportAs: ["matButton"] }, { kind: "component", type: MatIcon, selector: "mat-icon", inputs: ["color", "inline", "svgIcon", "fontSet", "fontIcon"], exportAs: ["matIcon"] }, { kind: "directive", type: MatTooltip, selector: "[matTooltip]", inputs: ["matTooltipPosition", "matTooltipPositionAtOrigin", "matTooltipDisabled", "matTooltipShowDelay", "matTooltipHideDelay", "matTooltipTouchGestures", "matTooltip", "matTooltipClass"], exportAs: ["matTooltip"] }, { kind: "component", type: ReportCanvasComponent, selector: "app-report-canvas", inputs: ["report", "editMode", "pageId"], outputs: ["componentSettings", "reportChanged", "pageChanged"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
6085
|
+
provideEchartsCore({ echarts: () => import('./firestitch-report-echarts-B9nl9XKM.mjs').then((module) => module.default) }),
|
|
6086
|
+
], viewQueries: [{ propertyName: "_split", first: true, predicate: ["split"], descendants: true, static: true }, { propertyName: "_chatPanel", first: true, predicate: ["chatPanel"], descendants: true, read: ElementRef, static: true }, { propertyName: "_canvas", first: true, predicate: ReportCanvasComponent, descendants: true }], ngImport: i0, template: "<div\n #split\n class=\"report fs-row.align-start\">\n <fs-ai-chat\n #chatPanel\n class=\"chat\"\n style=\"min-height: 500px;\"\n basePath=\"reports\"\n [requestData]=\"{ reportId: selected()?.id ?? null }\"\n [introMessage]=\"introMessage\"\n (response)=\"onChatResponse($event)\">\n </fs-ai-chat>\n <div\n class=\"resizer\"\n (pointerdown)=\"onResizeStart($event)\">\n </div>\n <div class=\"viewer fs-flex fs-column\">\n <div class=\"fs-row.align-center.gap-sm\">\n <fs-autocomplete-chips\n class=\"fs-flex\"\n [fetch]=\"fetchReports\"\n [(ngModel)]=\"selected\"\n [disabled]=\"loadingReports()\"\n [padless]=\"true\"\n [multiple]=\"false\"\n [fetchOnFocus]=\"true\"\n (ngModelChange)=\"reportChange($event)\"\n placeholder=\"Report\"\n name=\"report\">\n <ng-template\n fsAutocompleteChipsTemplate\n let-object=\"object\">\n {{ object.name }}\n </ng-template>\n <ng-template\n fsAutocompleteChipsStatic\n (click)=\"createReport()\">\n Create Report\n </ng-template>\n </fs-autocomplete-chips>\n @if (report() && editMode()) {\n <!-- What layout editing can add or switch to, and the way out of it \u2014\n the same outlined buttons as the fs-ai-report toolbar. -->\n <button\n mat-stroked-button\n type=\"button\"\n matTooltip=\"Add a block of text\"\n (click)=\"addText()\">\n <mat-icon>title</mat-icon>\n Add text\n </button>\n <button\n mat-stroked-button\n type=\"button\"\n class=\"master-toggle\"\n [class.active]=\"isMasterMode\"\n [matTooltip]=\"isMasterMode ? 'Back to editing this page' : 'Edit the header and footer shown on every page'\"\n (click)=\"toggleMasterMode()\">\n <mat-icon>web_asset</mat-icon>\n Header & footer\n </button>\n <button\n mat-stroked-button\n type=\"button\"\n (click)=\"toggleEditMode()\">\n <mat-icon>check</mat-icon>\n Done editing\n </button>\n }\n @if (report()) {\n <fs-menu>\n <ng-template\n fs-menu-item\n (click)=\"reportSettings()\">\n <mat-icon>\n tune\n </mat-icon>\n Report settings\n </ng-template>\n <ng-template\n fs-menu-item\n (click)=\"toggleEditMode()\"\n [disabled]=\"editMode()\">\n <mat-icon>\n dashboard_customize\n </mat-icon>\n {{ editMode() ? 'Done editing layout' : 'Edit layout' }}\n </ng-template>\n <ng-template\n fs-menu-item\n (click)=\"exportPowerpoint()\">\n <mat-icon>\n slideshow\n </mat-icon>\n Export PowerPoint\n </ng-template>\n <ng-template\n fs-menu-item\n (click)=\"exportPdf()\">\n <mat-icon>\n picture_as_pdf\n </mat-icon>\n Export PDF\n </ng-template>\n <ng-template\n fs-menu-item\n (click)=\"exportData()\">\n <mat-icon>\n table_view\n </mat-icon>\n Export Raw Data\n </ng-template>\n @if ((report()?.pages?.length ?? 0) > 1) {\n <ng-template\n fs-menu-item\n (click)=\"exportData(true)\">\n <mat-icon>\n table_view\n </mat-icon>\n Export Raw Data (This Page)\n </ng-template>\n }\n </fs-menu>\n }\n </div>\n @if (report()) {\n <!-- Report-level filters only (the report's actions live in the menu\n above). fs-filter reads its config once at init, so it's keyed on the\n filter signature: when the report-level filter set changes the block\n is recreated and re-reads the rebuilt config. -->\n @if (reportHasFilters) {\n @for (key of [reportFilterKey]; track key) {\n <fs-filter [config]=\"reportFilterConfig()\"></fs-filter>\n }\n }\n <app-report-canvas\n [report]=\"report()\"\n [editMode]=\"editMode()\"\n (componentSettings)=\"componentSettings($event)\"\n (reportChanged)=\"onCanvasReportChanged()\">\n </app-report-canvas>\n }\n </div>\n</div>", styles: [".report{height:100%;min-height:500px}.report .chat{display:block;flex:0 0 25%;width:100%;height:100%;min-height:500px;min-width:0;border:none}.report .viewer{display:flex;flex-direction:column;min-width:0;height:100%;overflow:hidden}.report .viewer fs-filter{flex:0 0 auto;margin-top:10px;margin-bottom:0}.report .viewer ::ng-deep .mat-mdc-form-field-subscript-wrapper{display:none}.report .resizer{flex:0 0 11px;align-self:stretch;display:flex;justify-content:center;cursor:col-resize;touch-action:none;-webkit-user-select:none;user-select:none}.report .resizer:before{content:\"\";width:0px;background:#0000001f;transition:width .12s ease,background-color .12s ease}.report .resizer:hover:before{width:3px;background:var(--brand-primary-color)}.report.resizing{cursor:col-resize;-webkit-user-select:none;user-select:none}.report.resizing .resizer:before{width:3px;background:var(--brand-primary-color)}.report.resizing .chat,.report.resizing .viewer{pointer-events:none}::ng-deep body.body-report-reports .mat-mdc-card-content{display:flex;flex-direction:column;box-sizing:border-box}::ng-deep body.body-report-reports .mat-mdc-card-content mat-tab-nav-panel{flex:1;min-height:0}::ng-deep body.body-report-reports .mat-mdc-card-content mat-tab-nav-panel router-outlet+ng-component{height:100%}.master-toggle.active{background:#eceff3}\n"], dependencies: [{ kind: "ngmodule", type: FormsModule }, { kind: "directive", type: i1$1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1$1.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }, { kind: "ngmodule", type: FsAutocompleteChipsModule }, { kind: "component", type: i2$3.FsAutocompleteChipsComponent, selector: "fs-autocomplete-chips", inputs: ["fetch", "appearance", "floatLabel", "readonly", "size", "label", "placeholder", "chipImage", "chipBackground", "chipColor", "chipIcon", "chipIconColor", "chipClass", "chipPadding", "shape", "hint", "allowText", "allowObject", "delay", "minPanelWidth", "maxPanelHeight", "validateText", "removable", "allowClear", "color", "background", "orderable", "padless", "initOnClick", "fetchOnFocus", "multiple", "multipleAdd", "confirm", "disabled", "groupBy", "panelWidth", "panelClass", "compareWith"], outputs: ["selected", "removed", "reordered", "clear", "panelOpened", "panelClosed"] }, { kind: "directive", type: i2$3.FsAutocompleteObjectDirective, selector: "[fsAutocompleteObject],[fsAutocompleteChipsTemplate]" }, { kind: "directive", type: i2$3.FsAutocompleteChipsStaticDirective, selector: "[fsAutocompleteChipsStatic]", inputs: ["show", "disable"], outputs: ["click", "selected"] }, { kind: "ngmodule", type: FsFilterModule }, { kind: "component", type: i2.FilterComponent, selector: "fs-filter", inputs: ["config"], outputs: ["closed", "opened", "ready"] }, { kind: "ngmodule", type: FsMenuModule }, { kind: "component", type: i2$1.FsMenuComponent, selector: "fs-menu", inputs: ["class", "buttonClass", "buttonType", "buttonColor"], outputs: ["opened", "closed"] }, { kind: "directive", type: i2$1.FsMenuItemDirective, selector: "fs-menu-group,[fs-menu-item]" }, { kind: "component", type: FsAiChatComponent, selector: "fs-ai-chat", inputs: ["basePath", "requestData", "ready", "introMessage", "showThinking"], outputs: ["response", "showThinkingChange"] }, { kind: "component", type: MatButton, selector: " button[mat-button], button[mat-raised-button], button[mat-flat-button], button[mat-stroked-button] ", exportAs: ["matButton"] }, { kind: "component", type: MatIcon, selector: "mat-icon", inputs: ["color", "inline", "svgIcon", "fontSet", "fontIcon"], exportAs: ["matIcon"] }, { kind: "directive", type: MatTooltip, selector: "[matTooltip]", inputs: ["matTooltipPosition", "matTooltipPositionAtOrigin", "matTooltipDisabled", "matTooltipShowDelay", "matTooltipHideDelay", "matTooltipTouchGestures", "matTooltip", "matTooltipClass"], exportAs: ["matTooltip"] }, { kind: "component", type: ReportCanvasComponent, selector: "app-report-canvas", inputs: ["report", "editMode", "pageId"], outputs: ["componentSettings", "reportChanged", "pageChanged"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
5202
6087
|
}
|
|
5203
6088
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: ReportComponent, decorators: [{
|
|
5204
6089
|
type: Component,
|
|
@@ -5212,7 +6097,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImpo
|
|
|
5212
6097
|
ReportExportService,
|
|
5213
6098
|
// Tree-shaken ECharts core + the 'report' house theme, loaded lazily with
|
|
5214
6099
|
// this route's chunk.
|
|
5215
|
-
provideEchartsCore({ echarts: () => import('./firestitch-report-echarts-
|
|
6100
|
+
provideEchartsCore({ echarts: () => import('./firestitch-report-echarts-B9nl9XKM.mjs').then((module) => module.default) }),
|
|
5216
6101
|
], imports: [
|
|
5217
6102
|
FormsModule,
|
|
5218
6103
|
FsAutocompleteChipsModule,
|
|
@@ -5223,7 +6108,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImpo
|
|
|
5223
6108
|
MatIcon,
|
|
5224
6109
|
MatTooltip,
|
|
5225
6110
|
ReportCanvasComponent,
|
|
5226
|
-
], template: "<div\n #split\n class=\"report fs-row.align-start\">\n <fs-ai-chat\n #chatPanel\n class=\"chat\"\n style=\"min-height: 500px;\"\n basePath=\"reports\"\n [requestData]=\"{ reportId: selected?.id ?? null }\"\n [introMessage]=\"introMessage\"\n (response)=\"onChatResponse($event)\">\n </fs-ai-chat>\n <div\n class=\"resizer\"\n (pointerdown)=\"onResizeStart($event)\">\n </div>\n <div class=\"viewer fs-flex fs-column\">\n <div class=\"fs-row.align-center.gap-sm\">\n <fs-autocomplete-chips\n class=\"fs-flex\"\n [fetch]=\"fetchReports\"\n [(ngModel)]=\"selected\"\n [disabled]=\"loadingReports\"\n [padless]=\"true\"\n [multiple]=\"false\"\n [fetchOnFocus]=\"true\"\n (ngModelChange)=\"reportChange($event)\"\n placeholder=\"Report\"\n name=\"report\">\n <ng-template\n fsAutocompleteChipsTemplate\n let-object=\"object\">\n {{ object.name }}\n </ng-template>\n <ng-template\n fsAutocompleteChipsStatic\n (click)=\"createReport()\">\n Create Report\n </ng-template>\n </fs-autocomplete-chips>\n @if (report && editMode) {\n <!-- What layout editing can add or switch to, and the way out of it \u2014\n the same outlined buttons as the fs-ai-report toolbar. -->\n <button\n mat-stroked-button\n type=\"button\"\n matTooltip=\"Add a block of text\"\n (click)=\"addText()\">\n <mat-icon>title</mat-icon>\n Add text\n </button>\n <button\n mat-stroked-button\n type=\"button\"\n class=\"master-toggle\"\n [class.active]=\"isMasterMode\"\n [matTooltip]=\"isMasterMode ? 'Back to editing this page' : 'Edit the header and footer shown on every page'\"\n (click)=\"toggleMasterMode()\">\n <mat-icon>web_asset</mat-icon>\n Header & footer\n </button>\n <button\n mat-stroked-button\n type=\"button\"\n (click)=\"toggleEditMode()\">\n <mat-icon>check</mat-icon>\n Done editing\n </button>\n }\n @if (report) {\n <fs-menu>\n <ng-template\n fs-menu-item\n (click)=\"reportSettings()\">\n <mat-icon>\n tune\n </mat-icon>\n Report settings\n </ng-template>\n <ng-template\n fs-menu-item\n (click)=\"toggleEditMode()\"\n [disabled]=\"editMode\">\n <mat-icon>\n dashboard_customize\n </mat-icon>\n {{ editMode ? 'Done editing layout' : 'Edit layout' }}\n </ng-template>\n <ng-template\n fs-menu-item\n (click)=\"exportPowerpoint()\">\n <mat-icon>\n slideshow\n </mat-icon>\n Export PowerPoint\n </ng-template>\n <ng-template\n fs-menu-item\n (click)=\"exportPdf()\">\n <mat-icon>\n picture_as_pdf\n </mat-icon>\n Export PDF\n </ng-template>\n <ng-template\n fs-menu-item\n (click)=\"exportData()\">\n <mat-icon>\n table_view\n </mat-icon>\n Export Raw Data\n </ng-template>\n @if ((report?.pages?.length ?? 0) > 1) {\n <ng-template\n fs-menu-item\n (click)=\"exportData(true)\">\n <mat-icon>\n table_view\n </mat-icon>\n Export Raw Data (This Page)\n </ng-template>\n }\n </fs-menu>\n }\n </div>\n @if (report) {\n <!-- Report-level filters only (the report's actions live in the menu\n above). fs-filter reads its config once at init, so it's keyed on the\n filter signature: when the report-level filter set changes the block\n is recreated and re-reads the rebuilt config. -->\n @if (reportHasFilters) {\n @for (key of [reportFilterKey]; track key) {\n <fs-filter [config]=\"reportFilterConfig\"></fs-filter>\n }\n }\n <app-report-canvas\n [report]=\"report\"\n [editMode]=\"editMode\"\n (componentSettings)=\"componentSettings($event)\"\n (reportChanged)=\"onCanvasReportChanged()\">\n </app-report-canvas>\n }\n </div>\n</div>", styles: [".report{height:100%;min-height:500px}.report .chat{display:block;flex:0 0 25%;width:100%;height:100%;min-height:500px;min-width:0;border:none}.report .viewer{display:flex;flex-direction:column;min-width:0;height:100%;overflow:hidden}.report .viewer fs-filter{flex:0 0 auto;margin-top:10px;margin-bottom:0}.report .viewer ::ng-deep .mat-mdc-form-field-subscript-wrapper{display:none}.report .resizer{flex:0 0 11px;align-self:stretch;display:flex;justify-content:center;cursor:col-resize;touch-action:none;-webkit-user-select:none;user-select:none}.report .resizer:before{content:\"\";width:0px;background:#0000001f;transition:width .12s ease,background-color .12s ease}.report .resizer:hover:before{width:3px;background:var(--brand-primary-color)}.report.resizing{cursor:col-resize;-webkit-user-select:none;user-select:none}.report.resizing .resizer:before{width:3px;background:var(--brand-primary-color)}.report.resizing .chat,.report.resizing .viewer{pointer-events:none}::ng-deep body.body-report-reports .mat-mdc-card-content{display:flex;flex-direction:column;box-sizing:border-box}::ng-deep body.body-report-reports .mat-mdc-card-content mat-tab-nav-panel{flex:1;min-height:0}::ng-deep body.body-report-reports .mat-mdc-card-content mat-tab-nav-panel router-outlet+ng-component{height:100%}.master-toggle.active{background:#eceff3}\n"] }]
|
|
6111
|
+
], template: "<div\n #split\n class=\"report fs-row.align-start\">\n <fs-ai-chat\n #chatPanel\n class=\"chat\"\n style=\"min-height: 500px;\"\n basePath=\"reports\"\n [requestData]=\"{ reportId: selected()?.id ?? null }\"\n [introMessage]=\"introMessage\"\n (response)=\"onChatResponse($event)\">\n </fs-ai-chat>\n <div\n class=\"resizer\"\n (pointerdown)=\"onResizeStart($event)\">\n </div>\n <div class=\"viewer fs-flex fs-column\">\n <div class=\"fs-row.align-center.gap-sm\">\n <fs-autocomplete-chips\n class=\"fs-flex\"\n [fetch]=\"fetchReports\"\n [(ngModel)]=\"selected\"\n [disabled]=\"loadingReports()\"\n [padless]=\"true\"\n [multiple]=\"false\"\n [fetchOnFocus]=\"true\"\n (ngModelChange)=\"reportChange($event)\"\n placeholder=\"Report\"\n name=\"report\">\n <ng-template\n fsAutocompleteChipsTemplate\n let-object=\"object\">\n {{ object.name }}\n </ng-template>\n <ng-template\n fsAutocompleteChipsStatic\n (click)=\"createReport()\">\n Create Report\n </ng-template>\n </fs-autocomplete-chips>\n @if (report() && editMode()) {\n <!-- What layout editing can add or switch to, and the way out of it \u2014\n the same outlined buttons as the fs-ai-report toolbar. -->\n <button\n mat-stroked-button\n type=\"button\"\n matTooltip=\"Add a block of text\"\n (click)=\"addText()\">\n <mat-icon>title</mat-icon>\n Add text\n </button>\n <button\n mat-stroked-button\n type=\"button\"\n class=\"master-toggle\"\n [class.active]=\"isMasterMode\"\n [matTooltip]=\"isMasterMode ? 'Back to editing this page' : 'Edit the header and footer shown on every page'\"\n (click)=\"toggleMasterMode()\">\n <mat-icon>web_asset</mat-icon>\n Header & footer\n </button>\n <button\n mat-stroked-button\n type=\"button\"\n (click)=\"toggleEditMode()\">\n <mat-icon>check</mat-icon>\n Done editing\n </button>\n }\n @if (report()) {\n <fs-menu>\n <ng-template\n fs-menu-item\n (click)=\"reportSettings()\">\n <mat-icon>\n tune\n </mat-icon>\n Report settings\n </ng-template>\n <ng-template\n fs-menu-item\n (click)=\"toggleEditMode()\"\n [disabled]=\"editMode()\">\n <mat-icon>\n dashboard_customize\n </mat-icon>\n {{ editMode() ? 'Done editing layout' : 'Edit layout' }}\n </ng-template>\n <ng-template\n fs-menu-item\n (click)=\"exportPowerpoint()\">\n <mat-icon>\n slideshow\n </mat-icon>\n Export PowerPoint\n </ng-template>\n <ng-template\n fs-menu-item\n (click)=\"exportPdf()\">\n <mat-icon>\n picture_as_pdf\n </mat-icon>\n Export PDF\n </ng-template>\n <ng-template\n fs-menu-item\n (click)=\"exportData()\">\n <mat-icon>\n table_view\n </mat-icon>\n Export Raw Data\n </ng-template>\n @if ((report()?.pages?.length ?? 0) > 1) {\n <ng-template\n fs-menu-item\n (click)=\"exportData(true)\">\n <mat-icon>\n table_view\n </mat-icon>\n Export Raw Data (This Page)\n </ng-template>\n }\n </fs-menu>\n }\n </div>\n @if (report()) {\n <!-- Report-level filters only (the report's actions live in the menu\n above). fs-filter reads its config once at init, so it's keyed on the\n filter signature: when the report-level filter set changes the block\n is recreated and re-reads the rebuilt config. -->\n @if (reportHasFilters) {\n @for (key of [reportFilterKey]; track key) {\n <fs-filter [config]=\"reportFilterConfig()\"></fs-filter>\n }\n }\n <app-report-canvas\n [report]=\"report()\"\n [editMode]=\"editMode()\"\n (componentSettings)=\"componentSettings($event)\"\n (reportChanged)=\"onCanvasReportChanged()\">\n </app-report-canvas>\n }\n </div>\n</div>", styles: [".report{height:100%;min-height:500px}.report .chat{display:block;flex:0 0 25%;width:100%;height:100%;min-height:500px;min-width:0;border:none}.report .viewer{display:flex;flex-direction:column;min-width:0;height:100%;overflow:hidden}.report .viewer fs-filter{flex:0 0 auto;margin-top:10px;margin-bottom:0}.report .viewer ::ng-deep .mat-mdc-form-field-subscript-wrapper{display:none}.report .resizer{flex:0 0 11px;align-self:stretch;display:flex;justify-content:center;cursor:col-resize;touch-action:none;-webkit-user-select:none;user-select:none}.report .resizer:before{content:\"\";width:0px;background:#0000001f;transition:width .12s ease,background-color .12s ease}.report .resizer:hover:before{width:3px;background:var(--brand-primary-color)}.report.resizing{cursor:col-resize;-webkit-user-select:none;user-select:none}.report.resizing .resizer:before{width:3px;background:var(--brand-primary-color)}.report.resizing .chat,.report.resizing .viewer{pointer-events:none}::ng-deep body.body-report-reports .mat-mdc-card-content{display:flex;flex-direction:column;box-sizing:border-box}::ng-deep body.body-report-reports .mat-mdc-card-content mat-tab-nav-panel{flex:1;min-height:0}::ng-deep body.body-report-reports .mat-mdc-card-content mat-tab-nav-panel router-outlet+ng-component{height:100%}.master-toggle.active{background:#eceff3}\n"] }]
|
|
5227
6112
|
}], ctorParameters: () => [], propDecorators: { _split: [{
|
|
5228
6113
|
type: ViewChild,
|
|
5229
6114
|
args: ['split', { static: true }]
|
|
@@ -5590,7 +6475,7 @@ class SemanticDatabaseComponent {
|
|
|
5590
6475
|
.pipe(
|
|
5591
6476
|
// A hard stream/transport failure (the build couldn't even start)
|
|
5592
6477
|
// finalizes here — completed$ won't fire in that case.
|
|
5593
|
-
tap({ error: (error) => this._finalizeBuild(error) }), filter((event) => event?.type === StreamEventType.Data
|
|
6478
|
+
tap$1({ error: (error) => this._finalizeBuild(error) }), filter((event) => event?.type === StreamEventType.Data
|
|
5594
6479
|
&& SemanticDatabaseComponent.LOG_KINDS.includes(event.data?.kind)
|
|
5595
6480
|
&& typeof event.data.line === 'string'), map((event) => event.data.line));
|
|
5596
6481
|
this._process
|
|
@@ -5729,7 +6614,7 @@ class SemanticDatabaseComponent {
|
|
|
5729
6614
|
// quietly, so the tab settles and callers never see the error.
|
|
5730
6615
|
_loadStatus() {
|
|
5731
6616
|
return this._reportData.semanticBuildStatus()
|
|
5732
|
-
.pipe(tap((status) => {
|
|
6617
|
+
.pipe(tap$1((status) => {
|
|
5733
6618
|
this.statusError.set(false);
|
|
5734
6619
|
this.status.set(status);
|
|
5735
6620
|
}), catchError(() => {
|
|
@@ -5816,13 +6701,13 @@ class GlobalSettingsComponent {
|
|
|
5816
6701
|
save = () => {
|
|
5817
6702
|
if (this.selectedTab === 'general') {
|
|
5818
6703
|
return this._reportData.saveSettings({ semanticBuildConcurrency: this.concurrency() })
|
|
5819
|
-
.pipe(tap((settings) => {
|
|
6704
|
+
.pipe(tap$1((settings) => {
|
|
5820
6705
|
this._applySettings(settings);
|
|
5821
6706
|
this._message.success('Settings saved');
|
|
5822
6707
|
}));
|
|
5823
6708
|
}
|
|
5824
6709
|
return this._reportData.saveInstructions(this.content())
|
|
5825
|
-
.pipe(tap((instructions) => {
|
|
6710
|
+
.pipe(tap$1((instructions) => {
|
|
5826
6711
|
this._message.success('AI knowledge saved');
|
|
5827
6712
|
this._dialogRef.close(instructions);
|
|
5828
6713
|
}));
|
|
@@ -6398,9 +7283,32 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImpo
|
|
|
6398
7283
|
], template: "<fs-filter [config]=\"filterConfig\"></fs-filter>\n\n<ng-container *fsSkeleton=\"nodes()\">\n <fs-tree [config]=\"treeConfig\">\n <ng-template\n fsTreeNode\n let-data=\"data\">\n <div\n class=\"fs-row.gap-sm.align-center node-row\"\n [class.deleted]=\"data.deleted\">\n <mat-icon class=\"node-icon\">\n {{ data.type === nodeType.Folder ? 'folder' : 'insert_chart_outlined' }}\n </mat-icon>\n\n @if (data.type === nodeType.Folder || data.deleted) {\n <!-- A deleted report can't be opened (the backend only assembles\n live ones, and one under a deleted folder is not on the live\n listing), so in Show Deleted mode the name is plain text \u2014\n restore it from the row menu first. -->\n <span class=\"node-name\">{{ data.name }}</span>\n } @else {\n <a\n class=\"node-name\"\n [routerLink]=\"openLink(data.report)\">\n {{ data.name }}\n </a>\n }\n\n <!-- Only on the row somebody deleted. Everything under a deleted\n folder is muted with it, but was not deleted itself. -->\n @if (data.stateDeleted) {\n <fs-chip size=\"tiny\">Deleted</fs-chip>\n }\n </div>\n </ng-template>\n </fs-tree>\n\n @if (!nodes().length) {\n <p class=\"small tree-empty\">\n No reports yet. Create one, or add a folder to group them.\n </p>\n }\n</ng-container>\n", styles: ["a{cursor:pointer}fs-tree .node>.container{flex:1;min-width:0}fs-tree .node>.container>.content{min-width:0}fs-tree .node>.container>button{margin-top:-5px}.node-row{min-width:0}.node-row.deleted{opacity:.55}.node-icon{font-size:18px;width:18px;height:18px;flex:none}.node-name{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.tree-empty{margin-top:10px}\n"] }]
|
|
6399
7284
|
}], ctorParameters: () => [] });
|
|
6400
7285
|
|
|
7286
|
+
// The report view's chat gateway: the stock one, plus fields read at the
|
|
7287
|
+
// moment each turn is sent and merged into that turn's body only. The chat's
|
|
7288
|
+
// requestData can't carry them, because it is also the key of the conversation
|
|
7289
|
+
// list, and a change there reloads the list and drops the open conversation.
|
|
7290
|
+
//
|
|
7291
|
+
// It relies on FsAiChatData.chatStream(basePath, body, config) being the one
|
|
7292
|
+
// path a turn takes. Nothing else is overridden, so the conversation list and
|
|
7293
|
+
// the create never see these fields. It stands in for a per-turn input on
|
|
7294
|
+
// fs-ai-chat itself, and can be removed when @firestitch/ai ships one.
|
|
7295
|
+
class ReportAiChatData extends FsAiChatData {
|
|
7296
|
+
// Set by the host view. A function, so it is read at send time.
|
|
7297
|
+
turnData = () => ({});
|
|
7298
|
+
chatStream(basePath, body, config = {}) {
|
|
7299
|
+
return super.chatStream(basePath, { ...body, ...this.turnData() }, config);
|
|
7300
|
+
}
|
|
7301
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: ReportAiChatData, deps: null, target: i0.ɵɵFactoryTarget.Injectable });
|
|
7302
|
+
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: ReportAiChatData });
|
|
7303
|
+
}
|
|
7304
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: ReportAiChatData, decorators: [{
|
|
7305
|
+
type: Injectable
|
|
7306
|
+
}] });
|
|
7307
|
+
|
|
6401
7308
|
// A single report: the AI chat on the left, the report canvas on the right,
|
|
6402
7309
|
// under an H2 title + settings gear. The host supplies [reportId] (from its
|
|
6403
|
-
// route) and switches reports
|
|
7310
|
+
// route) and switches reports through its own routes — this component never
|
|
7311
|
+
// picks one.
|
|
6404
7312
|
class FsAiReportComponent {
|
|
6405
7313
|
// The report this view is focused on — supplied by the host from the route.
|
|
6406
7314
|
reportId = input(null);
|
|
@@ -6410,21 +7318,35 @@ class FsAiReportComponent {
|
|
|
6410
7318
|
// default; a host that mounts several reports on one route, or wants no URL
|
|
6411
7319
|
// writes at all, sets this to null to turn it off.
|
|
6412
7320
|
pageQueryParam = input('page');
|
|
7321
|
+
// Whether the viewer's filters, group-by grain and list sorts live in the URL
|
|
7322
|
+
// (g<groupId>, groupBy, s<componentId>; see report-url-state.ts), so a
|
|
7323
|
+
// refresh or a shared link opens the report as it was seen. On by default; a
|
|
7324
|
+
// host that mounts several reports on one route, or uses query keys shaped
|
|
7325
|
+
// like g<n> or s<n> itself, turns it off.
|
|
7326
|
+
urlState = input(true);
|
|
6413
7327
|
// The report was deleted from its settings dialog — the host routes back to
|
|
6414
7328
|
// the listing.
|
|
6415
7329
|
deleted = output();
|
|
6416
7330
|
// The report was renamed (settings, or the agent) — the host can refresh a
|
|
6417
7331
|
// breadcrumb / document title.
|
|
6418
7332
|
renamed = output();
|
|
6419
|
-
// The viewer moved to another page.
|
|
6420
|
-
// this is for hosts that track the open page for their
|
|
7333
|
+
// The viewer moved to another page. This view writes the URL itself (right
|
|
7334
|
+
// after this fires) — this is for hosts that track the open page for their
|
|
7335
|
+
// own reasons.
|
|
6421
7336
|
pageChanged = output();
|
|
6422
|
-
report = null;
|
|
6423
|
-
editMode = false;
|
|
7337
|
+
report = signal(null);
|
|
7338
|
+
editMode = signal(false);
|
|
7339
|
+
// The toolbar's zone indicator ('New York (EDT)'): the zone every date in
|
|
7340
|
+
// this report is filtered and grouped in. Empty when the server is too old
|
|
7341
|
+
// to send the effective zone, and the indicator is not shown.
|
|
7342
|
+
timezoneLabel = computed(() => {
|
|
7343
|
+
const effectiveTimezone = this.report()?.effectiveTimezone;
|
|
7344
|
+
return effectiveTimezone ? getTimezoneLabel(effectiveTimezone) : '';
|
|
7345
|
+
});
|
|
6424
7346
|
// The page the canvas should open on, read from the URL once per report.
|
|
6425
7347
|
// Cleared as soon as the canvas reports back, so a later in-app page change
|
|
6426
7348
|
// is never fought by a stale value.
|
|
6427
|
-
pageId = null;
|
|
7349
|
+
pageId = signal(null);
|
|
6428
7350
|
// The page the chat is bound to: the one on screen. A report conversation
|
|
6429
7351
|
// belongs to exactly one page, so this both scopes the conversation list and
|
|
6430
7352
|
// becomes the new conversation's page. A signal because it feeds
|
|
@@ -6454,23 +7376,61 @@ class FsAiReportComponent {
|
|
|
6454
7376
|
});
|
|
6455
7377
|
// The report-level filter toolbar (fs-filter). Rebuilt whenever the loaded
|
|
6456
7378
|
// report (and thus its groups) changes.
|
|
6457
|
-
reportFilterConfig;
|
|
7379
|
+
reportFilterConfig = signal(null);
|
|
7380
|
+
// Gates the toolbar fs-filter so it isn't rendered when there is nothing to
|
|
7381
|
+
// put in it — no report-level filters and no group-by control.
|
|
7382
|
+
reportHasFilters = computed(() => {
|
|
7383
|
+
return this._reportGroups().length > 0 || !!this._groupByItem();
|
|
7384
|
+
});
|
|
7385
|
+
// fs-filter reads its config only once (at init). The template keys the
|
|
7386
|
+
// toolbar fs-filter on this signature so it's recreated (and re-reads the
|
|
7387
|
+
// rebuilt config) whenever the report-level filter set changes.
|
|
7388
|
+
reportFilterKey = computed(() => {
|
|
7389
|
+
return [
|
|
7390
|
+
// The stored default is part of the signature: saving a new default in report
|
|
7391
|
+
// settings rebuilds the config, but without a key change fs-filter keeps
|
|
7392
|
+
// rendering the values it was created with until a full page reload.
|
|
7393
|
+
groupBySignature(this.report()),
|
|
7394
|
+
...this._reportGroups().map((group) => `${group.id}:${group.level}:${group.label}:${defaultSignature(group)}`),
|
|
7395
|
+
].join('|');
|
|
7396
|
+
});
|
|
6458
7397
|
_split;
|
|
6459
7398
|
_chatPanel;
|
|
6460
7399
|
// Only the canvas knows which page is on screen, and a page-scoped export
|
|
6461
7400
|
// has to ask it.
|
|
6462
7401
|
_canvas;
|
|
6463
7402
|
_reportData = inject(ReportData);
|
|
7403
|
+
_reportAiChatData = inject(ReportAiChatData);
|
|
6464
7404
|
_router = inject(Router);
|
|
6465
7405
|
_route = inject(ActivatedRoute);
|
|
6466
7406
|
_filterState = inject(ReportFilterStateService);
|
|
6467
7407
|
_exportService = inject(ReportExportService);
|
|
7408
|
+
_collector = inject(ReportExportCollectorService);
|
|
6468
7409
|
_process = inject(FsProcess);
|
|
6469
7410
|
_dialog = inject(MatDialog);
|
|
6470
7411
|
_destroyRef = inject(DestroyRef);
|
|
6471
|
-
_cdRef = inject(ChangeDetectorRef);
|
|
6472
7412
|
_zone = inject(NgZone);
|
|
7413
|
+
// The report whose URL state has been read. The URL is read only on a
|
|
7414
|
+
// report's first load; a reload after a chat or dialog edit keeps the state
|
|
7415
|
+
// in memory, which is newer than anything the URL holds.
|
|
7416
|
+
_restoredReportId = null;
|
|
7417
|
+
// Whether the canvas has reported the open page for this report. Until it
|
|
7418
|
+
// has, the page param in the URL is the deep link still being honored, and
|
|
7419
|
+
// the URL writer leaves it alone.
|
|
7420
|
+
_pageReported = false;
|
|
7421
|
+
// The canvas moved to another page. Merged into the single URL writer.
|
|
7422
|
+
_pageWrite$ = new Subject();
|
|
6473
7423
|
constructor() {
|
|
7424
|
+
// Each chat turn carries what the viewer has on screen. Only the function
|
|
7425
|
+
// is set here; it is called when a turn is sent.
|
|
7426
|
+
this._reportAiChatData.turnData = () => ({ viewerState: this._chatViewerState() });
|
|
7427
|
+
// The ONE writer of this view's query params. Page moves and viewer-state
|
|
7428
|
+
// changes both funnel here, and auditTime(0) folds a burst (a bar re-sets
|
|
7429
|
+
// every group it shows) into a single write. Every write carries the whole
|
|
7430
|
+
// state, so a write that supersedes another loses nothing.
|
|
7431
|
+
merge(this._filterState.urlChanges(), this._pageWrite$)
|
|
7432
|
+
.pipe(auditTime(0), tap$1(() => this._writeUrl()), takeUntilDestroyed(this._destroyRef))
|
|
7433
|
+
.subscribe();
|
|
6474
7434
|
// Point the shared data gateway at the configured base first (before any
|
|
6475
7435
|
// load below reads it).
|
|
6476
7436
|
effect(() => {
|
|
@@ -6479,27 +7439,32 @@ class FsAiReportComponent {
|
|
|
6479
7439
|
// (Re)load whenever the host switches the report id.
|
|
6480
7440
|
effect(() => {
|
|
6481
7441
|
const reportId = this.reportId();
|
|
6482
|
-
this.editMode = false;
|
|
6483
|
-
// A new report's pages are not this report's pages — drop the binding
|
|
6484
|
-
// until its canvas says which page it landed on.
|
|
6485
|
-
this.chatPageId.set(null);
|
|
6486
7442
|
// Seed the open page from the URL for this report. Read per load rather
|
|
6487
7443
|
// than once at construction: the host may swap reports on the same route,
|
|
6488
7444
|
// and each arrival carries its own ?page=.
|
|
6489
|
-
|
|
6490
|
-
|
|
6491
|
-
|
|
6492
|
-
|
|
6493
|
-
|
|
6494
|
-
this
|
|
6495
|
-
|
|
6496
|
-
this.
|
|
6497
|
-
|
|
7445
|
+
const pageId = this._pageIdFromUrl();
|
|
7446
|
+
// Only the report id and the page param re-run this. The view state below
|
|
7447
|
+
// is written here and read by the config build, so it stays untracked.
|
|
7448
|
+
untracked(() => {
|
|
7449
|
+
this.editMode.set(false);
|
|
7450
|
+
// A new report's pages are not this report's pages — drop the binding
|
|
7451
|
+
// until its canvas says which page it landed on.
|
|
7452
|
+
this.chatPageId.set(null);
|
|
7453
|
+
this.pageId.set(pageId);
|
|
7454
|
+
this._pageReported = false;
|
|
7455
|
+
if (reportId != null) {
|
|
7456
|
+
this._loadReport(reportId);
|
|
7457
|
+
}
|
|
7458
|
+
else {
|
|
7459
|
+
this.report.set(null);
|
|
7460
|
+
this._buildReportConfig();
|
|
7461
|
+
}
|
|
7462
|
+
});
|
|
6498
7463
|
});
|
|
6499
7464
|
this._buildReportConfig();
|
|
6500
7465
|
}
|
|
6501
7466
|
toggleEditMode() {
|
|
6502
|
-
this.editMode
|
|
7467
|
+
this.editMode.update((editMode) => !editMode);
|
|
6503
7468
|
}
|
|
6504
7469
|
// The edit tools live in this toolbar, but the canvas owns what they act on
|
|
6505
7470
|
// (the active page, the header/footer bands, which layer is live).
|
|
@@ -6515,12 +7480,13 @@ class FsAiReportComponent {
|
|
|
6515
7480
|
// Report settings — name, page size, orientation, layout mode, delete. The
|
|
6516
7481
|
// dialog performs the delete itself; we react to what it reports back.
|
|
6517
7482
|
reportSettings() {
|
|
6518
|
-
|
|
7483
|
+
const report = this.report();
|
|
7484
|
+
if (!report) {
|
|
6519
7485
|
return;
|
|
6520
7486
|
}
|
|
6521
|
-
const reportId =
|
|
7487
|
+
const reportId = report.id;
|
|
6522
7488
|
this._dialog.open(ReportSettingsComponent, {
|
|
6523
|
-
data: { report
|
|
7489
|
+
data: { report },
|
|
6524
7490
|
width: '460px',
|
|
6525
7491
|
})
|
|
6526
7492
|
.afterClosed()
|
|
@@ -6538,12 +7504,13 @@ class FsAiReportComponent {
|
|
|
6538
7504
|
// Component settings — title, exact geometry, and the diagnostic views
|
|
6539
7505
|
// (SQL, config, filter wiring).
|
|
6540
7506
|
componentSettings(component) {
|
|
6541
|
-
|
|
7507
|
+
const report = this.report();
|
|
7508
|
+
if (!report) {
|
|
6542
7509
|
return;
|
|
6543
7510
|
}
|
|
6544
|
-
const reportId =
|
|
7511
|
+
const reportId = report.id;
|
|
6545
7512
|
const dialogRef = this._dialog.open(ComponentSettingsComponent, {
|
|
6546
|
-
data: { report
|
|
7513
|
+
data: { report, component },
|
|
6547
7514
|
width: '640px',
|
|
6548
7515
|
});
|
|
6549
7516
|
// Filter add/remove/level changes persist immediately inside the dialog (no
|
|
@@ -6579,58 +7546,57 @@ class FsAiReportComponent {
|
|
|
6579
7546
|
});
|
|
6580
7547
|
}
|
|
6581
7548
|
// The canvas moved to another page: reflect it in the URL so a refresh or a
|
|
6582
|
-
// shared link comes back here
|
|
6583
|
-
// history — Back should leave the report, not walk every page visited.
|
|
7549
|
+
// shared link comes back here (the write itself is _writeUrl's).
|
|
6584
7550
|
onCanvasPageChanged(pageId) {
|
|
6585
7551
|
// The deep link has been consumed; from here the canvas owns the page.
|
|
6586
|
-
this.pageId
|
|
7552
|
+
this.pageId.set(null);
|
|
6587
7553
|
this.chatPageId.set(pageId);
|
|
7554
|
+
this._pageReported = true;
|
|
6588
7555
|
this.pageChanged.emit(pageId);
|
|
6589
|
-
|
|
6590
|
-
if (!param) {
|
|
6591
|
-
return;
|
|
6592
|
-
}
|
|
6593
|
-
this._router.navigate([], {
|
|
6594
|
-
relativeTo: this._route,
|
|
6595
|
-
queryParams: { [param]: pageId },
|
|
6596
|
-
queryParamsHandling: 'merge',
|
|
6597
|
-
replaceUrl: true,
|
|
6598
|
-
});
|
|
7556
|
+
this._pageWrite$.next();
|
|
6599
7557
|
}
|
|
6600
7558
|
// The canvas changed the report's structure (added a page) — re-fetch so the
|
|
6601
7559
|
// new page renders; the canvas lands the view on it.
|
|
6602
7560
|
onCanvasReportChanged() {
|
|
6603
|
-
|
|
6604
|
-
|
|
7561
|
+
const report = this.report();
|
|
7562
|
+
if (report) {
|
|
7563
|
+
this._loadReport(report.id);
|
|
6605
7564
|
}
|
|
6606
7565
|
}
|
|
6607
7566
|
// Export the report to PowerPoint, entirely client-side (native editable
|
|
6608
7567
|
// charts; page = slide). The dock shows progress until the file saves.
|
|
6609
7568
|
exportPowerpoint() {
|
|
6610
|
-
|
|
7569
|
+
const report = this.report();
|
|
7570
|
+
if (!report) {
|
|
6611
7571
|
return;
|
|
6612
7572
|
}
|
|
6613
|
-
this._process.run('Exporting PowerPoint', this._exportService.powerpoint(
|
|
7573
|
+
this._process.run('Exporting PowerPoint', this._exportService.powerpoint(report));
|
|
6614
7574
|
}
|
|
6615
7575
|
// Export to PDF, entirely client-side (offscreen ECharts at 3×; page = PDF page).
|
|
6616
7576
|
exportPdf() {
|
|
6617
|
-
|
|
7577
|
+
const report = this.report();
|
|
7578
|
+
if (!report) {
|
|
6618
7579
|
return;
|
|
6619
7580
|
}
|
|
6620
|
-
this._process.run('Exporting PDF', this._exportService.pdf(
|
|
7581
|
+
this._process.run('Exporting PDF', this._exportService.pdf(report));
|
|
6621
7582
|
}
|
|
6622
|
-
// The
|
|
6623
|
-
// component
|
|
6624
|
-
// by
|
|
6625
|
-
//
|
|
6626
|
-
//
|
|
7583
|
+
// The whole report as files in one action: the server writes one CSV per
|
|
7584
|
+
// component, each what the screen shows (a chart or KPI as drawn, at the
|
|
7585
|
+
// viewer's group-by grain; a list in full), and zips them foldered by page
|
|
7586
|
+
// with an Export Notes.txt that records the filters as the bar reads them.
|
|
7587
|
+
// Lists use their saved default sort; a header click is carried only by a
|
|
7588
|
+
// component's own CSV export. `thisPage` narrows it to the page the canvas is
|
|
7589
|
+
// showing. Unlike the PDF/PowerPoint exports the files are written
|
|
7590
|
+
// server-side, so they are not capped by what a slide can hold.
|
|
6627
7591
|
exportData(thisPage = false) {
|
|
6628
|
-
const report = this.report;
|
|
7592
|
+
const report = this.report();
|
|
6629
7593
|
if (!report) {
|
|
6630
7594
|
return;
|
|
6631
7595
|
}
|
|
6632
7596
|
const pageId = thisPage ? this._canvas?.activePage?.id ?? null : null;
|
|
6633
|
-
|
|
7597
|
+
const filters = this._filterState.resolveForReport(report, pageId);
|
|
7598
|
+
this._process.download('Exporting data', this._collector.getFilterSummary(report)
|
|
7599
|
+
.pipe(switchMap$1((lines) => this._reportData.exportReport(report.id, filters, pageId, this._filterState.frequency(), lines))));
|
|
6634
7600
|
}
|
|
6635
7601
|
// React to each chat turn (fs-chat handles the request + persistence); we
|
|
6636
7602
|
// only care about the report side effects this context's backend flags.
|
|
@@ -6668,6 +7634,16 @@ class FsAiReportComponent {
|
|
|
6668
7634
|
handle.addEventListener('pointercancel', end);
|
|
6669
7635
|
});
|
|
6670
7636
|
}
|
|
7637
|
+
// What the viewer has on screen as a chat turn sends it. The canvas
|
|
7638
|
+
// selection counts only in edit mode: it is drawn only then, and it is kept
|
|
7639
|
+
// (not cleared) when edit mode is left.
|
|
7640
|
+
_chatViewerState() {
|
|
7641
|
+
return {
|
|
7642
|
+
granularity: this._filterState.frequency(),
|
|
7643
|
+
selectedComponentId: this.editMode() ? this._canvas?.selectedComponentId() ?? null : null,
|
|
7644
|
+
filters: this._filterState.viewerFilters(),
|
|
7645
|
+
};
|
|
7646
|
+
}
|
|
6671
7647
|
_resizeTo(clientX) {
|
|
6672
7648
|
const rect = this._split.nativeElement.getBoundingClientRect();
|
|
6673
7649
|
if (rect.width === 0) {
|
|
@@ -6687,50 +7663,67 @@ class FsAiReportComponent {
|
|
|
6687
7663
|
const pageId = Number(this._route.snapshot.queryParamMap.get(param));
|
|
6688
7664
|
return Number.isFinite(pageId) && pageId > 0 ? pageId : null;
|
|
6689
7665
|
}
|
|
7666
|
+
// Writes the open page and the viewer state into the URL in one go. Only
|
|
7667
|
+
// the keys this view owns are touched ('merge'), and replaceUrl keeps the
|
|
7668
|
+
// writes out of the browser history: Back leaves the report rather than
|
|
7669
|
+
// walking every page and filter visited.
|
|
7670
|
+
_writeUrl() {
|
|
7671
|
+
const report = this.report();
|
|
7672
|
+
// A report still on screen while the host's next one loads must not write
|
|
7673
|
+
// its state over the new report's URL.
|
|
7674
|
+
if (!report || report.id !== this.reportId()) {
|
|
7675
|
+
return;
|
|
7676
|
+
}
|
|
7677
|
+
const queryParams = this.urlState()
|
|
7678
|
+
? buildReportUrlParams(this._filterState.urlSnapshot(report), this._route.snapshot.queryParamMap)
|
|
7679
|
+
: {};
|
|
7680
|
+
const pageParam = this.pageQueryParam();
|
|
7681
|
+
if (pageParam && this._pageReported) {
|
|
7682
|
+
queryParams[pageParam] = this.chatPageId();
|
|
7683
|
+
}
|
|
7684
|
+
if (!Object.keys(queryParams).length) {
|
|
7685
|
+
return;
|
|
7686
|
+
}
|
|
7687
|
+
this._router.navigate([], {
|
|
7688
|
+
relativeTo: this._route,
|
|
7689
|
+
queryParams,
|
|
7690
|
+
queryParamsHandling: 'merge',
|
|
7691
|
+
replaceUrl: true,
|
|
7692
|
+
});
|
|
7693
|
+
}
|
|
6690
7694
|
_loadReport(reportId, emitRenamed = false) {
|
|
6691
7695
|
this._reportData.get(reportId)
|
|
6692
7696
|
.pipe(takeUntilDestroyed(this._destroyRef))
|
|
6693
7697
|
.subscribe((report) => {
|
|
6694
|
-
|
|
6695
|
-
|
|
7698
|
+
// The URL is read once per report, on its first load; every later load
|
|
7699
|
+
// keeps the state in memory.
|
|
7700
|
+
const restore = this.urlState() && reportId !== this._restoredReportId
|
|
7701
|
+
? readReportUrlParams(this._route.snapshot.queryParamMap)
|
|
7702
|
+
: null;
|
|
7703
|
+
this._restoredReportId = reportId;
|
|
7704
|
+
this.report.set(report);
|
|
7705
|
+
this._filterState.init(report, restore);
|
|
6696
7706
|
this._buildReportConfig();
|
|
6697
7707
|
if (emitRenamed) {
|
|
6698
7708
|
this.renamed.emit(report);
|
|
6699
7709
|
}
|
|
6700
|
-
this._cdRef.markForCheck();
|
|
6701
7710
|
});
|
|
6702
7711
|
}
|
|
6703
7712
|
// Report-level filter groups in their saved order — the ones that render in
|
|
6704
7713
|
// the toolbar (component-level groups render on their components).
|
|
6705
7714
|
_reportGroups() {
|
|
6706
|
-
return (this.report?.filterGroups ?? [])
|
|
7715
|
+
return (this.report()?.filterGroups ?? [])
|
|
6707
7716
|
.filter((group) => group.level === 'report' || group.level === 'both')
|
|
6708
7717
|
.sort((a, b) => a.order - b.order);
|
|
6709
7718
|
}
|
|
6710
|
-
// Gates the toolbar fs-filter so it isn't rendered when there is nothing to
|
|
6711
|
-
// put in it — no report-level filters and no group-by control.
|
|
6712
|
-
get reportHasFilters() {
|
|
6713
|
-
return this._reportGroups().length > 0 || !!this._groupByItem();
|
|
6714
|
-
}
|
|
6715
|
-
// fs-filter reads its config only once (at init). The template keys the
|
|
6716
|
-
// toolbar fs-filter on this signature so it's recreated (and re-reads the
|
|
6717
|
-
// rebuilt config) whenever the report-level filter set changes.
|
|
6718
|
-
get reportFilterKey() {
|
|
6719
|
-
return [
|
|
6720
|
-
// The stored default is part of the signature: saving a new default in report
|
|
6721
|
-
// settings rebuilds the config, but without a key change fs-filter keeps
|
|
6722
|
-
// rendering the values it was created with until a full page reload.
|
|
6723
|
-
groupBySignature(this.report),
|
|
6724
|
-
...this._reportGroups().map((group) => `${group.id}:${group.level}:${group.label}:${defaultSignature(group)}`),
|
|
6725
|
-
].join('|');
|
|
6726
|
-
}
|
|
6727
7719
|
// (Re)build the toolbar fs-filter config — the report-level filter items and
|
|
6728
7720
|
// then the group-by control.
|
|
6729
7721
|
_buildReportConfig() {
|
|
6730
|
-
const
|
|
7722
|
+
const report = this.report();
|
|
7723
|
+
const reportId = report?.id ?? null;
|
|
6731
7724
|
// Drop a grain the control no longer offers — the agent can narrow the list
|
|
6732
7725
|
// mid-session, and an override the viewer can't see is worse than none.
|
|
6733
|
-
if (
|
|
7726
|
+
if (report && !isGranularityOffered(report, this._filterState.frequency())) {
|
|
6734
7727
|
this._filterState.setFrequency(null);
|
|
6735
7728
|
}
|
|
6736
7729
|
const items = reportId
|
|
@@ -6744,9 +7737,11 @@ class FsAiReportComponent {
|
|
|
6744
7737
|
if (groupByItem) {
|
|
6745
7738
|
items.unshift(groupByItem);
|
|
6746
7739
|
}
|
|
6747
|
-
this.reportFilterConfig
|
|
6748
|
-
//
|
|
6749
|
-
//
|
|
7740
|
+
this.reportFilterConfig.set({
|
|
7741
|
+
// fs-filter never touches the URL or persists filter state: its keys are
|
|
7742
|
+
// not namespaced, it writes outside the router, and it would freeze a
|
|
7743
|
+
// rolling range as two dates. ReportFilterStateService owns the values,
|
|
7744
|
+
// and _writeUrl is the one writer of the URL.
|
|
6750
7745
|
queryParam: false,
|
|
6751
7746
|
persist: false,
|
|
6752
7747
|
items,
|
|
@@ -6757,18 +7752,19 @@ class FsAiReportComponent {
|
|
|
6757
7752
|
// Not a filter group — the grain is read straight off the query.
|
|
6758
7753
|
this._filterState.setFrequency(query?.[GROUP_BY_ITEM] || null);
|
|
6759
7754
|
},
|
|
6760
|
-
};
|
|
7755
|
+
});
|
|
6761
7756
|
}
|
|
6762
7757
|
// The report's group-by control as an fs-filter item, or null when the report
|
|
6763
7758
|
// has none to show. Seeded from the session state so the grain the viewer
|
|
6764
7759
|
// picked survives the report being re-fetched after a chat edit.
|
|
6765
7760
|
_groupByItem() {
|
|
6766
|
-
|
|
6767
|
-
|
|
7761
|
+
const report = this.report();
|
|
7762
|
+
return report
|
|
7763
|
+
? groupByFilterItem(report, this._filterState.frequency())
|
|
6768
7764
|
: null;
|
|
6769
7765
|
}
|
|
6770
7766
|
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: FsAiReportComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
6771
|
-
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "18.2.14", type: FsAiReportComponent, isStandalone: true, selector: "fs-ai-report", inputs: { reportId: { classPropertyName: "reportId", publicName: "reportId", isSignal: true, isRequired: false, transformFunction: null }, basePath: { classPropertyName: "basePath", publicName: "basePath", isSignal: true, isRequired: false, transformFunction: null }, pageQueryParam: { classPropertyName: "pageQueryParam", publicName: "pageQueryParam", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { deleted: "deleted", renamed: "renamed", pageChanged: "pageChanged" }, providers: [
|
|
7767
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "18.2.14", type: FsAiReportComponent, isStandalone: true, selector: "fs-ai-report", inputs: { reportId: { classPropertyName: "reportId", publicName: "reportId", isSignal: true, isRequired: false, transformFunction: null }, basePath: { classPropertyName: "basePath", publicName: "basePath", isSignal: true, isRequired: false, transformFunction: null }, pageQueryParam: { classPropertyName: "pageQueryParam", publicName: "pageQueryParam", isSignal: true, isRequired: false, transformFunction: null }, urlState: { classPropertyName: "urlState", publicName: "urlState", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { deleted: "deleted", renamed: "renamed", pageChanged: "pageChanged" }, providers: [
|
|
6772
7768
|
// Session filter state is per-open-report, not app-global; the export
|
|
6773
7769
|
// services resolve filters through it, so they live at the same level.
|
|
6774
7770
|
ReportFilterStateService,
|
|
@@ -6776,9 +7772,13 @@ class FsAiReportComponent {
|
|
|
6776
7772
|
ReportPptxService,
|
|
6777
7773
|
ReportPdfService,
|
|
6778
7774
|
ReportExportService,
|
|
7775
|
+
// fs-ai-chat sits in this template, so it resolves its gateway here: the
|
|
7776
|
+
// report one, which adds the viewer's state to each turn it sends.
|
|
7777
|
+
ReportAiChatData,
|
|
7778
|
+
{ provide: FsAiChatData, useExisting: ReportAiChatData },
|
|
6779
7779
|
// Tree-shaken ECharts core + the 'report' house theme, loaded lazily.
|
|
6780
|
-
provideEchartsCore({ echarts: () => import('./firestitch-report-echarts-
|
|
6781
|
-
], viewQueries: [{ propertyName: "_split", first: true, predicate: ["split"], descendants: true, static: true }, { propertyName: "_chatPanel", first: true, predicate: ["chatPanel"], descendants: true, read: ElementRef, static: true }, { propertyName: "_canvas", first: true, predicate: ReportCanvasComponent, descendants: true }], ngImport: i0, template: "<div class=\"fs-ai-report fs-column\">\n\n <div class=\"fs-ai-report__header fs-row.align-center\">\n <h2 class=\"fs-ai-report__title fs-flex\">{{ report?.name }}</h2>\n\n @if (report) {\n <!-- Report-level filters, inline with the toolbar actions. fs-filter\n reads its config once at init, so it's keyed on the filter\n signature: when the report-level filter set changes the block is\n recreated and re-reads the rebuilt config. -->\n @if (reportHasFilters) {\n @for (key of [reportFilterKey]; track key) {\n <fs-filter\n class=\"fs-ai-report__filters\"\n [config]=\"reportFilterConfig\">\n </fs-filter>\n }\n }\n\n <!-- What layout editing can add or switch to, and the way out of it.\n One style for all three \u2014 outlined, icon and text. \"Header & footer\"\n is a toggle: it keeps its label and turns gray while it is on. -->\n @if (editMode) {\n <div class=\"fs-ai-report__edit-tools fs-row.align-center.gap-xs\">\n <button\n mat-stroked-button\n type=\"button\"\n matTooltip=\"Add a block of text\"\n (click)=\"addText()\">\n <mat-icon>title</mat-icon>\n Add text\n </button>\n <button\n mat-stroked-button\n type=\"button\"\n class=\"fs-ai-report__master\"\n [class.active]=\"isMasterMode\"\n [matTooltip]=\"isMasterMode ? 'Back to editing this page' : 'Edit the header and footer shown on every page'\"\n (click)=\"toggleMasterMode()\">\n <mat-icon>web_asset</mat-icon>\n Header & footer\n </button>\n <button\n mat-stroked-button\n type=\"button\"\n (click)=\"toggleEditMode()\">\n <mat-icon>check</mat-icon>\n Done editing\n </button>\n </div>\n }\n\n <!-- One icon for both states: it reads as pressed while editing rather\n than swapping to something else. -->\n <button\n mat-icon-button\n type=\"button\"\n class=\"fs-ai-report__toggle\"\n [class.active]=\"editMode\"\n [matTooltip]=\"editMode ? 'Done editing' : 'Edit layout'\"\n (click)=\"toggleEditMode()\">\n <mat-icon>dashboard_customize</mat-icon>\n </button>\n\n <button\n mat-icon-button\n type=\"button\"\n matTooltip=\"Export\"\n [fsMenuTriggerFor]=\"exportMenu\">\n <mat-icon>download</mat-icon>\n </button>\n <fs-menu #exportMenu>\n <ng-template\n fs-menu-item\n (click)=\"exportPowerpoint()\">\n Export PowerPoint\n </ng-template>\n <ng-template\n fs-menu-item\n (click)=\"exportPdf()\">\n Export PDF\n </ng-template>\n <ng-template\n fs-menu-item\n (click)=\"exportData()\">\n Export Raw Data\n </ng-template>\n @if ((report?.pages?.length ?? 0) > 1) {\n <ng-template\n fs-menu-item\n (click)=\"exportData(true)\">\n Export Raw Data (This Page)\n </ng-template>\n }\n </fs-menu>\n\n <button\n mat-icon-button\n type=\"button\"\n matTooltip=\"Report settings\"\n (click)=\"reportSettings()\">\n <mat-icon>settings</mat-icon>\n </button>\n }\n </div>\n\n <div\n #split\n class=\"fs-ai-report__split fs-row.align-start\">\n <fs-ai-chat\n #chatPanel\n class=\"chat\"\n [basePath]=\"basePath()\"\n [requestData]=\"chatRequestData()\"\n [ready]=\"chatPageId() !== null\"\n (response)=\"onChatResponse($event)\">\n </fs-ai-chat>\n\n <div\n class=\"resizer\"\n (pointerdown)=\"onResizeStart($event)\">\n </div>\n\n <div class=\"viewer fs-flex fs-column\">\n @if (report) {\n <app-report-canvas\n [report]=\"report\"\n [editMode]=\"editMode\"\n [pageId]=\"pageId\"\n (componentSettings)=\"componentSettings($event)\"\n (reportChanged)=\"onCanvasReportChanged()\"\n (pageChanged)=\"onCanvasPageChanged($event)\">\n </app-report-canvas>\n }\n </div>\n </div>\n</div>\n", styles: [".fs-ai-report{height:100%;min-height:500px;display:flex;flex-direction:column}.fs-ai-report__header{flex:0 0 auto;margin-bottom:10px}.fs-ai-report__title{margin:0;min-width:120px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.fs-ai-report__filters{flex:0 1 auto;min-width:0;display:block;margin:0 5px 0 0}.fs-ai-report__filters ::ng-deep .filter-bar-container{align-items:center!important}.fs-ai-report__filters ::ng-deep .mat-mdc-form-field-subscript-wrapper{display:none}.fs-ai-report__toggle.active{background:#e3ecf7;color:var(--brand-primary-color, #2196f3)}.fs-ai-report__master.active{background:#eceff3}.fs-ai-report__edit-tools{margin:0 5px}.fs-ai-report__split{flex:1 1 auto;min-height:0}.fs-ai-report .chat{display:block;flex:0 0 25%;width:100%;height:100%;min-height:400px;min-width:0;border:none}.fs-ai-report .viewer{display:flex;flex-direction:column;min-width:0;height:100%;overflow:hidden}.fs-ai-report .resizer{flex:0 0 11px;align-self:stretch;display:flex;justify-content:center;cursor:col-resize;touch-action:none;-webkit-user-select:none;user-select:none}.fs-ai-report .resizer:before{content:\"\";width:3px;border-radius:3px;background:transparent;transition:background-color .12s ease}.fs-ai-report .resizer:hover:before{background:var(--brand-primary-color)}.fs-ai-report__split.resizing{cursor:col-resize;-webkit-user-select:none;user-select:none}.fs-ai-report__split.resizing .resizer:before{background:var(--brand-primary-color)}.fs-ai-report__split.resizing .chat,.fs-ai-report__split.resizing .viewer{pointer-events:none}\n"], dependencies: [{ kind: "ngmodule", type: FsFilterModule }, { kind: "component", type: i2.FilterComponent, selector: "fs-filter", inputs: ["config"], outputs: ["closed", "opened", "ready"] }, { kind: "ngmodule", type: FsMenuModule }, { kind: "component", type: i2$1.FsMenuComponent, selector: "fs-menu", inputs: ["class", "buttonClass", "buttonType", "buttonColor"], outputs: ["opened", "closed"] }, { kind: "directive", type: i2$1.FsMenuItemDirective, selector: "fs-menu-group,[fs-menu-item]" }, { kind: "directive", type: i2$1.FsMenuTriggerDirective, selector: "[fsMenuTriggerFor]", inputs: ["fsMenuTriggerFor"] }, { kind: "component", type: FsAiChatComponent, selector: "fs-ai-chat", inputs: ["basePath", "requestData", "ready", "introMessage", "showThinking"], outputs: ["response", "showThinkingChange"] }, { kind: "ngmodule", type: MatButtonModule }, { kind: "component", type: i3$3.MatButton, selector: " button[mat-button], button[mat-raised-button], button[mat-flat-button], button[mat-stroked-button] ", exportAs: ["matButton"] }, { kind: "component", type: i3$3.MatIconButton, selector: "button[mat-icon-button]", exportAs: ["matButton"] }, { kind: "ngmodule", type: MatIconModule }, { kind: "component", type: i5$2.MatIcon, selector: "mat-icon", inputs: ["color", "inline", "svgIcon", "fontSet", "fontIcon"], exportAs: ["matIcon"] }, { kind: "ngmodule", type: MatTooltipModule }, { kind: "directive", type: i2$5.MatTooltip, selector: "[matTooltip]", inputs: ["matTooltipPosition", "matTooltipPositionAtOrigin", "matTooltipDisabled", "matTooltipShowDelay", "matTooltipHideDelay", "matTooltipTouchGestures", "matTooltip", "matTooltipClass"], exportAs: ["matTooltip"] }, { kind: "component", type: ReportCanvasComponent, selector: "app-report-canvas", inputs: ["report", "editMode", "pageId"], outputs: ["componentSettings", "reportChanged", "pageChanged"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
7780
|
+
provideEchartsCore({ echarts: () => import('./firestitch-report-echarts-B9nl9XKM.mjs').then((module) => module.default) }),
|
|
7781
|
+
], viewQueries: [{ propertyName: "_split", first: true, predicate: ["split"], descendants: true, static: true }, { propertyName: "_chatPanel", first: true, predicate: ["chatPanel"], descendants: true, read: ElementRef, static: true }, { propertyName: "_canvas", first: true, predicate: ReportCanvasComponent, descendants: true }], ngImport: i0, template: "<div class=\"fs-ai-report fs-column\">\n\n <div class=\"fs-ai-report__header fs-row.align-center\">\n <h2 class=\"fs-ai-report__title fs-flex\">{{ report()?.name }}</h2>\n\n @if (report()) {\n <!-- Report-level filters, inline with the toolbar actions. fs-filter\n reads its config once at init, so it's keyed on the filter\n signature: when the report-level filter set changes the block is\n recreated and re-reads the rebuilt config. -->\n @if (reportHasFilters()) {\n @for (key of [reportFilterKey()]; track key) {\n <fs-filter\n class=\"fs-ai-report__filters\"\n [config]=\"reportFilterConfig()\">\n </fs-filter>\n }\n }\n\n <!-- What layout editing can add or switch to, and the way out of it.\n One style for all three \u2014 outlined, icon and text. \"Header & footer\"\n is a toggle: it keeps its label and turns gray while it is on. -->\n @if (editMode()) {\n <div class=\"fs-ai-report__edit-tools fs-row.align-center.gap-xs\">\n <button\n mat-stroked-button\n type=\"button\"\n matTooltip=\"Add a block of text\"\n (click)=\"addText()\">\n <mat-icon>title</mat-icon>\n Add text\n </button>\n <button\n mat-stroked-button\n type=\"button\"\n class=\"fs-ai-report__master\"\n [class.active]=\"isMasterMode\"\n [matTooltip]=\"isMasterMode ? 'Back to editing this page' : 'Edit the header and footer shown on every page'\"\n (click)=\"toggleMasterMode()\">\n <mat-icon>web_asset</mat-icon>\n Header & footer\n </button>\n <button\n mat-stroked-button\n type=\"button\"\n (click)=\"toggleEditMode()\">\n <mat-icon>check</mat-icon>\n Done editing\n </button>\n </div>\n }\n\n <!-- The zone every date in this report is filtered and grouped in, the\n same for every viewer. Shown only when the server sends it; a click\n opens Report settings, where it is changed. -->\n @if (report()?.effectiveTimezone; as effectiveTimezone) {\n <button\n mat-button\n type=\"button\"\n class=\"fs-ai-report__timezone\"\n [matTooltip]=\"'Dates are filtered and grouped in ' + effectiveTimezone + ' for everyone. Change it in Report settings.'\"\n (click)=\"reportSettings()\">\n <mat-icon>schedule</mat-icon>\n {{ timezoneLabel() }}\n </button>\n }\n\n <!-- One icon for both states: it reads as pressed while editing rather\n than swapping to something else. -->\n <button\n mat-icon-button\n type=\"button\"\n class=\"fs-ai-report__toggle\"\n [class.active]=\"editMode()\"\n [matTooltip]=\"editMode() ? 'Done editing' : 'Edit layout'\"\n (click)=\"toggleEditMode()\">\n <mat-icon>dashboard_customize</mat-icon>\n </button>\n\n <button\n mat-icon-button\n type=\"button\"\n matTooltip=\"Export\"\n [fsMenuTriggerFor]=\"exportMenu\">\n <mat-icon>download</mat-icon>\n </button>\n <fs-menu #exportMenu>\n <ng-template\n fs-menu-item\n (click)=\"exportPowerpoint()\">\n Export PowerPoint\n </ng-template>\n <ng-template\n fs-menu-item\n (click)=\"exportPdf()\">\n Export PDF\n </ng-template>\n <ng-template\n fs-menu-item\n (click)=\"exportData()\">\n Export Raw Data\n </ng-template>\n @if ((report()?.pages?.length ?? 0) > 1) {\n <ng-template\n fs-menu-item\n (click)=\"exportData(true)\">\n Export Raw Data (This Page)\n </ng-template>\n }\n </fs-menu>\n\n <button\n mat-icon-button\n type=\"button\"\n matTooltip=\"Report settings\"\n (click)=\"reportSettings()\">\n <mat-icon>settings</mat-icon>\n </button>\n }\n </div>\n\n <div\n #split\n class=\"fs-ai-report__split fs-row.align-start\">\n <fs-ai-chat\n #chatPanel\n class=\"chat\"\n [basePath]=\"basePath()\"\n [requestData]=\"chatRequestData()\"\n [ready]=\"chatPageId() !== null\"\n (response)=\"onChatResponse($event)\">\n </fs-ai-chat>\n\n <div\n class=\"resizer\"\n (pointerdown)=\"onResizeStart($event)\">\n </div>\n\n <div class=\"viewer fs-flex fs-column\">\n @if (report()) {\n <app-report-canvas\n [report]=\"report()\"\n [editMode]=\"editMode()\"\n [pageId]=\"pageId()\"\n (componentSettings)=\"componentSettings($event)\"\n (reportChanged)=\"onCanvasReportChanged()\"\n (pageChanged)=\"onCanvasPageChanged($event)\">\n </app-report-canvas>\n }\n </div>\n </div>\n</div>\n", styles: [".fs-ai-report{height:100%;min-height:500px;display:flex;flex-direction:column}.fs-ai-report__header{flex:0 0 auto;margin-bottom:10px}.fs-ai-report__title{margin:0;min-width:120px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.fs-ai-report__filters{flex:0 1 auto;min-width:0;display:block;margin:0 5px 0 0}.fs-ai-report__filters ::ng-deep .filter-bar-container{align-items:center!important}.fs-ai-report__filters ::ng-deep .mat-mdc-form-field-subscript-wrapper{display:none}.fs-ai-report__timezone{color:var(--fs-color-text-muted, rgba(0, 0, 0, .54));white-space:nowrap;flex:0 0 auto}.fs-ai-report__toggle.active{background:#e3ecf7;color:var(--brand-primary-color, #2196f3)}.fs-ai-report__master.active{background:#eceff3}.fs-ai-report__edit-tools{margin:0 5px}.fs-ai-report__split{flex:1 1 auto;min-height:0}.fs-ai-report .chat{display:block;flex:0 0 25%;width:100%;height:100%;min-height:400px;min-width:0;border:none}.fs-ai-report .viewer{display:flex;flex-direction:column;min-width:0;height:100%;overflow:hidden}.fs-ai-report .resizer{flex:0 0 11px;align-self:stretch;display:flex;justify-content:center;cursor:col-resize;touch-action:none;-webkit-user-select:none;user-select:none}.fs-ai-report .resizer:before{content:\"\";width:3px;border-radius:3px;background:transparent;transition:background-color .12s ease}.fs-ai-report .resizer:hover:before{background:var(--brand-primary-color)}.fs-ai-report__split.resizing{cursor:col-resize;-webkit-user-select:none;user-select:none}.fs-ai-report__split.resizing .resizer:before{background:var(--brand-primary-color)}.fs-ai-report__split.resizing .chat,.fs-ai-report__split.resizing .viewer{pointer-events:none}\n"], dependencies: [{ kind: "ngmodule", type: FsFilterModule }, { kind: "component", type: i2.FilterComponent, selector: "fs-filter", inputs: ["config"], outputs: ["closed", "opened", "ready"] }, { kind: "ngmodule", type: FsMenuModule }, { kind: "component", type: i2$1.FsMenuComponent, selector: "fs-menu", inputs: ["class", "buttonClass", "buttonType", "buttonColor"], outputs: ["opened", "closed"] }, { kind: "directive", type: i2$1.FsMenuItemDirective, selector: "fs-menu-group,[fs-menu-item]" }, { kind: "directive", type: i2$1.FsMenuTriggerDirective, selector: "[fsMenuTriggerFor]", inputs: ["fsMenuTriggerFor"] }, { kind: "component", type: FsAiChatComponent, selector: "fs-ai-chat", inputs: ["basePath", "requestData", "ready", "introMessage", "showThinking"], outputs: ["response", "showThinkingChange"] }, { kind: "ngmodule", type: MatButtonModule }, { kind: "component", type: i3$3.MatButton, selector: " button[mat-button], button[mat-raised-button], button[mat-flat-button], button[mat-stroked-button] ", exportAs: ["matButton"] }, { kind: "component", type: i3$3.MatIconButton, selector: "button[mat-icon-button]", exportAs: ["matButton"] }, { kind: "ngmodule", type: MatIconModule }, { kind: "component", type: i5$2.MatIcon, selector: "mat-icon", inputs: ["color", "inline", "svgIcon", "fontSet", "fontIcon"], exportAs: ["matIcon"] }, { kind: "ngmodule", type: MatTooltipModule }, { kind: "directive", type: i2$5.MatTooltip, selector: "[matTooltip]", inputs: ["matTooltipPosition", "matTooltipPositionAtOrigin", "matTooltipDisabled", "matTooltipShowDelay", "matTooltipHideDelay", "matTooltipTouchGestures", "matTooltip", "matTooltipClass"], exportAs: ["matTooltip"] }, { kind: "component", type: ReportCanvasComponent, selector: "app-report-canvas", inputs: ["report", "editMode", "pageId"], outputs: ["componentSettings", "reportChanged", "pageChanged"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
6782
7782
|
}
|
|
6783
7783
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: FsAiReportComponent, decorators: [{
|
|
6784
7784
|
type: Component,
|
|
@@ -6790,8 +7790,12 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImpo
|
|
|
6790
7790
|
ReportPptxService,
|
|
6791
7791
|
ReportPdfService,
|
|
6792
7792
|
ReportExportService,
|
|
7793
|
+
// fs-ai-chat sits in this template, so it resolves its gateway here: the
|
|
7794
|
+
// report one, which adds the viewer's state to each turn it sends.
|
|
7795
|
+
ReportAiChatData,
|
|
7796
|
+
{ provide: FsAiChatData, useExisting: ReportAiChatData },
|
|
6793
7797
|
// Tree-shaken ECharts core + the 'report' house theme, loaded lazily.
|
|
6794
|
-
provideEchartsCore({ echarts: () => import('./firestitch-report-echarts-
|
|
7798
|
+
provideEchartsCore({ echarts: () => import('./firestitch-report-echarts-B9nl9XKM.mjs').then((module) => module.default) }),
|
|
6795
7799
|
], imports: [
|
|
6796
7800
|
FsFilterModule,
|
|
6797
7801
|
FsMenuModule,
|
|
@@ -6800,7 +7804,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImpo
|
|
|
6800
7804
|
MatIconModule,
|
|
6801
7805
|
MatTooltipModule,
|
|
6802
7806
|
ReportCanvasComponent,
|
|
6803
|
-
], template: "<div class=\"fs-ai-report fs-column\">\n\n <div class=\"fs-ai-report__header fs-row.align-center\">\n <h2 class=\"fs-ai-report__title fs-flex\">{{ report?.name }}</h2>\n\n @if (report) {\n <!-- Report-level filters, inline with the toolbar actions. fs-filter\n reads its config once at init, so it's keyed on the filter\n signature: when the report-level filter set changes the block is\n recreated and re-reads the rebuilt config. -->\n @if (reportHasFilters) {\n @for (key of [reportFilterKey]; track key) {\n <fs-filter\n class=\"fs-ai-report__filters\"\n [config]=\"reportFilterConfig\">\n </fs-filter>\n }\n }\n\n <!-- What layout editing can add or switch to, and the way out of it.\n One style for all three \u2014 outlined, icon and text. \"Header & footer\"\n is a toggle: it keeps its label and turns gray while it is on. -->\n @if (editMode) {\n <div class=\"fs-ai-report__edit-tools fs-row.align-center.gap-xs\">\n <button\n mat-stroked-button\n type=\"button\"\n matTooltip=\"Add a block of text\"\n (click)=\"addText()\">\n <mat-icon>title</mat-icon>\n Add text\n </button>\n <button\n mat-stroked-button\n type=\"button\"\n class=\"fs-ai-report__master\"\n [class.active]=\"isMasterMode\"\n [matTooltip]=\"isMasterMode ? 'Back to editing this page' : 'Edit the header and footer shown on every page'\"\n (click)=\"toggleMasterMode()\">\n <mat-icon>web_asset</mat-icon>\n Header & footer\n </button>\n <button\n mat-stroked-button\n type=\"button\"\n (click)=\"toggleEditMode()\">\n <mat-icon>check</mat-icon>\n Done editing\n </button>\n </div>\n }\n\n <!-- One icon for both states: it reads as pressed while editing rather\n than swapping to something else. -->\n <button\n mat-icon-button\n type=\"button\"\n class=\"fs-ai-report__toggle\"\n [class.active]=\"editMode\"\n [matTooltip]=\"editMode ? 'Done editing' : 'Edit layout'\"\n (click)=\"toggleEditMode()\">\n <mat-icon>dashboard_customize</mat-icon>\n </button>\n\n <button\n mat-icon-button\n type=\"button\"\n matTooltip=\"Export\"\n [fsMenuTriggerFor]=\"exportMenu\">\n <mat-icon>download</mat-icon>\n </button>\n <fs-menu #exportMenu>\n <ng-template\n fs-menu-item\n (click)=\"exportPowerpoint()\">\n Export PowerPoint\n </ng-template>\n <ng-template\n fs-menu-item\n (click)=\"exportPdf()\">\n Export PDF\n </ng-template>\n <ng-template\n fs-menu-item\n (click)=\"exportData()\">\n Export Raw Data\n </ng-template>\n @if ((report?.pages?.length ?? 0) > 1) {\n <ng-template\n fs-menu-item\n (click)=\"exportData(true)\">\n Export Raw Data (This Page)\n </ng-template>\n }\n </fs-menu>\n\n <button\n mat-icon-button\n type=\"button\"\n matTooltip=\"Report settings\"\n (click)=\"reportSettings()\">\n <mat-icon>settings</mat-icon>\n </button>\n }\n </div>\n\n <div\n #split\n class=\"fs-ai-report__split fs-row.align-start\">\n <fs-ai-chat\n #chatPanel\n class=\"chat\"\n [basePath]=\"basePath()\"\n [requestData]=\"chatRequestData()\"\n [ready]=\"chatPageId() !== null\"\n (response)=\"onChatResponse($event)\">\n </fs-ai-chat>\n\n <div\n class=\"resizer\"\n (pointerdown)=\"onResizeStart($event)\">\n </div>\n\n <div class=\"viewer fs-flex fs-column\">\n @if (report) {\n <app-report-canvas\n [report]=\"report\"\n [editMode]=\"editMode\"\n [pageId]=\"pageId\"\n (componentSettings)=\"componentSettings($event)\"\n (reportChanged)=\"onCanvasReportChanged()\"\n (pageChanged)=\"onCanvasPageChanged($event)\">\n </app-report-canvas>\n }\n </div>\n </div>\n</div>\n", styles: [".fs-ai-report{height:100%;min-height:500px;display:flex;flex-direction:column}.fs-ai-report__header{flex:0 0 auto;margin-bottom:10px}.fs-ai-report__title{margin:0;min-width:120px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.fs-ai-report__filters{flex:0 1 auto;min-width:0;display:block;margin:0 5px 0 0}.fs-ai-report__filters ::ng-deep .filter-bar-container{align-items:center!important}.fs-ai-report__filters ::ng-deep .mat-mdc-form-field-subscript-wrapper{display:none}.fs-ai-report__toggle.active{background:#e3ecf7;color:var(--brand-primary-color, #2196f3)}.fs-ai-report__master.active{background:#eceff3}.fs-ai-report__edit-tools{margin:0 5px}.fs-ai-report__split{flex:1 1 auto;min-height:0}.fs-ai-report .chat{display:block;flex:0 0 25%;width:100%;height:100%;min-height:400px;min-width:0;border:none}.fs-ai-report .viewer{display:flex;flex-direction:column;min-width:0;height:100%;overflow:hidden}.fs-ai-report .resizer{flex:0 0 11px;align-self:stretch;display:flex;justify-content:center;cursor:col-resize;touch-action:none;-webkit-user-select:none;user-select:none}.fs-ai-report .resizer:before{content:\"\";width:3px;border-radius:3px;background:transparent;transition:background-color .12s ease}.fs-ai-report .resizer:hover:before{background:var(--brand-primary-color)}.fs-ai-report__split.resizing{cursor:col-resize;-webkit-user-select:none;user-select:none}.fs-ai-report__split.resizing .resizer:before{background:var(--brand-primary-color)}.fs-ai-report__split.resizing .chat,.fs-ai-report__split.resizing .viewer{pointer-events:none}\n"] }]
|
|
7807
|
+
], template: "<div class=\"fs-ai-report fs-column\">\n\n <div class=\"fs-ai-report__header fs-row.align-center\">\n <h2 class=\"fs-ai-report__title fs-flex\">{{ report()?.name }}</h2>\n\n @if (report()) {\n <!-- Report-level filters, inline with the toolbar actions. fs-filter\n reads its config once at init, so it's keyed on the filter\n signature: when the report-level filter set changes the block is\n recreated and re-reads the rebuilt config. -->\n @if (reportHasFilters()) {\n @for (key of [reportFilterKey()]; track key) {\n <fs-filter\n class=\"fs-ai-report__filters\"\n [config]=\"reportFilterConfig()\">\n </fs-filter>\n }\n }\n\n <!-- What layout editing can add or switch to, and the way out of it.\n One style for all three \u2014 outlined, icon and text. \"Header & footer\"\n is a toggle: it keeps its label and turns gray while it is on. -->\n @if (editMode()) {\n <div class=\"fs-ai-report__edit-tools fs-row.align-center.gap-xs\">\n <button\n mat-stroked-button\n type=\"button\"\n matTooltip=\"Add a block of text\"\n (click)=\"addText()\">\n <mat-icon>title</mat-icon>\n Add text\n </button>\n <button\n mat-stroked-button\n type=\"button\"\n class=\"fs-ai-report__master\"\n [class.active]=\"isMasterMode\"\n [matTooltip]=\"isMasterMode ? 'Back to editing this page' : 'Edit the header and footer shown on every page'\"\n (click)=\"toggleMasterMode()\">\n <mat-icon>web_asset</mat-icon>\n Header & footer\n </button>\n <button\n mat-stroked-button\n type=\"button\"\n (click)=\"toggleEditMode()\">\n <mat-icon>check</mat-icon>\n Done editing\n </button>\n </div>\n }\n\n <!-- The zone every date in this report is filtered and grouped in, the\n same for every viewer. Shown only when the server sends it; a click\n opens Report settings, where it is changed. -->\n @if (report()?.effectiveTimezone; as effectiveTimezone) {\n <button\n mat-button\n type=\"button\"\n class=\"fs-ai-report__timezone\"\n [matTooltip]=\"'Dates are filtered and grouped in ' + effectiveTimezone + ' for everyone. Change it in Report settings.'\"\n (click)=\"reportSettings()\">\n <mat-icon>schedule</mat-icon>\n {{ timezoneLabel() }}\n </button>\n }\n\n <!-- One icon for both states: it reads as pressed while editing rather\n than swapping to something else. -->\n <button\n mat-icon-button\n type=\"button\"\n class=\"fs-ai-report__toggle\"\n [class.active]=\"editMode()\"\n [matTooltip]=\"editMode() ? 'Done editing' : 'Edit layout'\"\n (click)=\"toggleEditMode()\">\n <mat-icon>dashboard_customize</mat-icon>\n </button>\n\n <button\n mat-icon-button\n type=\"button\"\n matTooltip=\"Export\"\n [fsMenuTriggerFor]=\"exportMenu\">\n <mat-icon>download</mat-icon>\n </button>\n <fs-menu #exportMenu>\n <ng-template\n fs-menu-item\n (click)=\"exportPowerpoint()\">\n Export PowerPoint\n </ng-template>\n <ng-template\n fs-menu-item\n (click)=\"exportPdf()\">\n Export PDF\n </ng-template>\n <ng-template\n fs-menu-item\n (click)=\"exportData()\">\n Export Raw Data\n </ng-template>\n @if ((report()?.pages?.length ?? 0) > 1) {\n <ng-template\n fs-menu-item\n (click)=\"exportData(true)\">\n Export Raw Data (This Page)\n </ng-template>\n }\n </fs-menu>\n\n <button\n mat-icon-button\n type=\"button\"\n matTooltip=\"Report settings\"\n (click)=\"reportSettings()\">\n <mat-icon>settings</mat-icon>\n </button>\n }\n </div>\n\n <div\n #split\n class=\"fs-ai-report__split fs-row.align-start\">\n <fs-ai-chat\n #chatPanel\n class=\"chat\"\n [basePath]=\"basePath()\"\n [requestData]=\"chatRequestData()\"\n [ready]=\"chatPageId() !== null\"\n (response)=\"onChatResponse($event)\">\n </fs-ai-chat>\n\n <div\n class=\"resizer\"\n (pointerdown)=\"onResizeStart($event)\">\n </div>\n\n <div class=\"viewer fs-flex fs-column\">\n @if (report()) {\n <app-report-canvas\n [report]=\"report()\"\n [editMode]=\"editMode()\"\n [pageId]=\"pageId()\"\n (componentSettings)=\"componentSettings($event)\"\n (reportChanged)=\"onCanvasReportChanged()\"\n (pageChanged)=\"onCanvasPageChanged($event)\">\n </app-report-canvas>\n }\n </div>\n </div>\n</div>\n", styles: [".fs-ai-report{height:100%;min-height:500px;display:flex;flex-direction:column}.fs-ai-report__header{flex:0 0 auto;margin-bottom:10px}.fs-ai-report__title{margin:0;min-width:120px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.fs-ai-report__filters{flex:0 1 auto;min-width:0;display:block;margin:0 5px 0 0}.fs-ai-report__filters ::ng-deep .filter-bar-container{align-items:center!important}.fs-ai-report__filters ::ng-deep .mat-mdc-form-field-subscript-wrapper{display:none}.fs-ai-report__timezone{color:var(--fs-color-text-muted, rgba(0, 0, 0, .54));white-space:nowrap;flex:0 0 auto}.fs-ai-report__toggle.active{background:#e3ecf7;color:var(--brand-primary-color, #2196f3)}.fs-ai-report__master.active{background:#eceff3}.fs-ai-report__edit-tools{margin:0 5px}.fs-ai-report__split{flex:1 1 auto;min-height:0}.fs-ai-report .chat{display:block;flex:0 0 25%;width:100%;height:100%;min-height:400px;min-width:0;border:none}.fs-ai-report .viewer{display:flex;flex-direction:column;min-width:0;height:100%;overflow:hidden}.fs-ai-report .resizer{flex:0 0 11px;align-self:stretch;display:flex;justify-content:center;cursor:col-resize;touch-action:none;-webkit-user-select:none;user-select:none}.fs-ai-report .resizer:before{content:\"\";width:3px;border-radius:3px;background:transparent;transition:background-color .12s ease}.fs-ai-report .resizer:hover:before{background:var(--brand-primary-color)}.fs-ai-report__split.resizing{cursor:col-resize;-webkit-user-select:none;user-select:none}.fs-ai-report__split.resizing .resizer:before{background:var(--brand-primary-color)}.fs-ai-report__split.resizing .chat,.fs-ai-report__split.resizing .viewer{pointer-events:none}\n"] }]
|
|
6804
7808
|
}], ctorParameters: () => [], propDecorators: { _split: [{
|
|
6805
7809
|
type: ViewChild,
|
|
6806
7810
|
args: ['split', { static: true }]
|
|
@@ -6824,5 +7828,5 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImpo
|
|
|
6824
7828
|
* Generated bundle index. Do not edit.
|
|
6825
7829
|
*/
|
|
6826
7830
|
|
|
6827
|
-
export { FsAiReportsComponent as F, GRANULARITY_OPTIONS as G, REPORT_CHART_COLORS_CSS as R,
|
|
6828
|
-
//# sourceMappingURL=firestitch-report-firestitch-report-
|
|
7831
|
+
export { FsAiReportsComponent as F, GRANULARITY_OPTIONS as G, REPORT_CHART_COLORS_CSS as R, REPORT_CHART_FONT_FAMILY as a, REPORT_AXIS_LABEL_FONT_SIZE as b, ReportComponent as c, FsAiReportComponent as d, ReportData as e, ReportService as f, ReportFilterStateService as g, ReportNodeType as h, ReportState as i };
|
|
7832
|
+
//# sourceMappingURL=firestitch-report-firestitch-report-Bb65HEYB.mjs.map
|