@firestitch/report 18.10.2 → 18.11.1
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/report-canvas/report-canvas.component.d.ts +0 -1
- package/app/reports/data/report.data.d.ts +8 -12
- package/app/reports/export/report-export-collector.service.d.ts +0 -1
- package/app/reports/export/report-filter-lines.d.ts +0 -2
- package/app/reports/interfaces/report-chat-viewer-state.interface.d.ts +0 -2
- package/app/reports/interfaces/report-export-snapshot.interface.d.ts +2 -3
- package/app/reports/interfaces/report-saved-filter-changes.interface.d.ts +4 -0
- package/app/reports/interfaces/report-saved-filter.interface.d.ts +6 -0
- package/app/reports/interfaces/{report-view-config.interface.d.ts → report-state-snapshot.interface.d.ts} +1 -2
- package/app/reports/interfaces/report-viewer-feature.type.d.ts +1 -1
- package/app/reports/report-chat-viewer-state.d.ts +3 -3
- package/app/reports/report-saved-filters.d.ts +3 -0
- package/app/reports/report-state.d.ts +15 -0
- package/app/reports/services/report-filter-state.service.d.ts +9 -25
- package/app/reports/tile-data-state.d.ts +5 -5
- package/app/reports/views/report/fs-ai-report.component.d.ts +12 -5
- package/esm2022/app/reports/canvas-landing-page.mjs +3 -3
- package/esm2022/app/reports/components/component-list/component-list.component.mjs +3 -3
- package/esm2022/app/reports/components/report-canvas/report-canvas.component.mjs +8 -12
- package/esm2022/app/reports/components/report-component/report-component.component.mjs +25 -30
- package/esm2022/app/reports/data/report.data.mjs +24 -41
- package/esm2022/app/reports/export/report-export-collector.service.mjs +11 -18
- package/esm2022/app/reports/export/report-filter-lines.mjs +5 -15
- package/esm2022/app/reports/filter-settings-rules.mjs +2 -2
- package/esm2022/app/reports/interfaces/canvas-landing-input.interface.mjs +1 -1
- package/esm2022/app/reports/interfaces/report-chat-viewer-state.interface.mjs +1 -1
- package/esm2022/app/reports/interfaces/report-export-snapshot.interface.mjs +1 -1
- package/esm2022/app/reports/interfaces/report-saved-filter-changes.interface.mjs +2 -0
- package/esm2022/app/reports/interfaces/report-saved-filter.interface.mjs +2 -0
- package/esm2022/app/reports/interfaces/report-state-snapshot.interface.mjs +2 -0
- package/esm2022/app/reports/interfaces/report-temporary-state.interface.mjs +1 -1
- package/esm2022/app/reports/interfaces/report-tile-state.interface.mjs +1 -1
- package/esm2022/app/reports/interfaces/report-viewer-feature.type.mjs +1 -1
- package/esm2022/app/reports/interfaces/report.interface.mjs +1 -1
- package/esm2022/app/reports/mark-click.mjs +2 -2
- package/esm2022/app/reports/report-chat-viewer-state.mjs +3 -3
- package/esm2022/app/reports/report-date-presets.mjs +2 -2
- package/esm2022/app/reports/report-saved-filters.mjs +92 -0
- package/esm2022/app/reports/report-state.mjs +169 -0
- package/esm2022/app/reports/report-viewer-features.mjs +2 -4
- package/esm2022/app/reports/services/report-filter-state.service.mjs +83 -310
- package/esm2022/app/reports/tile-data-state.mjs +4 -4
- package/esm2022/app/reports/tile-loads.mjs +5 -6
- package/esm2022/app/reports/views/report/fs-ai-report.component.mjs +192 -102
- package/esm2022/app/reports/views/report/report.component.mjs +14 -16
- package/esm2022/public_api.mjs +4 -8
- package/fesm2022/{firestitch-report-echarts-CyHkFToO.mjs → firestitch-report-echarts-B0RWUjR8.mjs} +2 -2
- package/fesm2022/{firestitch-report-echarts-CyHkFToO.mjs.map → firestitch-report-echarts-B0RWUjR8.mjs.map} +1 -1
- package/fesm2022/{firestitch-report-firestitch-report-DftB7M1o.mjs → firestitch-report-firestitch-report-CJW0lLdW.mjs} +480 -1450
- package/fesm2022/firestitch-report-firestitch-report-CJW0lLdW.mjs.map +1 -0
- package/fesm2022/firestitch-report.mjs +1 -1
- package/package.json +1 -1
- package/public_api.d.ts +3 -7
- package/app/reports/components/report-view-picker/report-view-picker.component.d.ts +0 -21
- package/app/reports/dialogs/report-view/report-view-dialog.component.d.ts +0 -13
- package/app/reports/interfaces/report-view-create.interface.d.ts +0 -7
- package/app/reports/interfaces/report-view-deleted.interface.d.ts +0 -4
- package/app/reports/interfaces/report-view-dialog-data.interface.d.ts +0 -7
- package/app/reports/interfaces/report-view-dropped.interface.d.ts +0 -5
- package/app/reports/interfaces/report-view-menu-item.interface.d.ts +0 -10
- package/app/reports/interfaces/report-view-summary.interface.d.ts +0 -11
- package/app/reports/interfaces/report-view.interface.d.ts +0 -7
- package/app/reports/interfaces/report-views.interface.d.ts +0 -7
- package/app/reports/report-view-config.d.ts +0 -34
- package/app/reports/report-view-menu.d.ts +0 -15
- package/app/reports/services/report-view.service.d.ts +0 -39
- package/esm2022/app/reports/components/report-view-picker/report-view-picker.component.mjs +0 -115
- package/esm2022/app/reports/dialogs/report-view/report-view-dialog.component.mjs +0 -52
- package/esm2022/app/reports/interfaces/report-view-config.interface.mjs +0 -2
- package/esm2022/app/reports/interfaces/report-view-create.interface.mjs +0 -2
- package/esm2022/app/reports/interfaces/report-view-deleted.interface.mjs +0 -2
- package/esm2022/app/reports/interfaces/report-view-dialog-data.interface.mjs +0 -2
- package/esm2022/app/reports/interfaces/report-view-dropped.interface.mjs +0 -2
- package/esm2022/app/reports/interfaces/report-view-menu-item.interface.mjs +0 -2
- package/esm2022/app/reports/interfaces/report-view-summary.interface.mjs +0 -2
- package/esm2022/app/reports/interfaces/report-view.interface.mjs +0 -2
- package/esm2022/app/reports/interfaces/report-views.interface.mjs +0 -2
- package/esm2022/app/reports/report-view-config.mjs +0 -587
- package/esm2022/app/reports/report-view-menu.mjs +0 -119
- package/esm2022/app/reports/services/report-view.service.mjs +0 -218
- package/fesm2022/firestitch-report-firestitch-report-DftB7M1o.mjs.map +0 -1
|
@@ -12,9 +12,9 @@ import { MatTooltip, MatTooltipModule } from '@angular/material/tooltip';
|
|
|
12
12
|
import * as i2$2 from '@firestitch/autocomplete-chips';
|
|
13
13
|
import { FsAutocompleteChipsModule } from '@firestitch/autocomplete-chips';
|
|
14
14
|
import * as i1$2 from '@firestitch/filter';
|
|
15
|
-
import { ItemType, getExcludeName, FsFilterModule } from '@firestitch/filter';
|
|
15
|
+
import { ItemType, getExcludeName, FsFilterModule, FilterComponent } from '@firestitch/filter';
|
|
16
16
|
import * as i2 from '@firestitch/menu';
|
|
17
|
-
import { FsMenuModule
|
|
17
|
+
import { FsMenuModule } from '@firestitch/menu';
|
|
18
18
|
import { FsProcess } from '@firestitch/process';
|
|
19
19
|
import { FsPrompt } from '@firestitch/prompt';
|
|
20
20
|
import { map, shareReplay, catchError, scan, filter, take, tap, switchMap, finalize, concatMap, toArray, mergeMap as mergeMap$1, takeUntil } from 'rxjs/operators';
|
|
@@ -31,7 +31,7 @@ import { of, forkJoin, Subject, defer, merge, map as map$1, switchMap as switchM
|
|
|
31
31
|
import { FsApi, DisplayApiError, StreamEventType } from '@firestitch/api';
|
|
32
32
|
import { parseISO, isValid, addDays, min, max, startOfDay, isLastDayOfMonth, endOfMonth, addMonths, addYears, addQuarters, addWeeks, formatISO, format, startOfYear, startOfQuarter, startOfMonth, subMonths, subDays, subWeeks, startOfWeek, endOfWeek } from 'date-fns';
|
|
33
33
|
import { toZonedTime } from 'date-fns-tz';
|
|
34
|
-
import {
|
|
34
|
+
import { MatProgressSpinner } from '@angular/material/progress-spinner';
|
|
35
35
|
import { MatCheckbox } from '@angular/material/checkbox';
|
|
36
36
|
import * as i3 from '@firestitch/dialog';
|
|
37
37
|
import { FsDialogModule } from '@firestitch/dialog';
|
|
@@ -78,8 +78,8 @@ import { MatExpansionModule } from '@angular/material/expansion';
|
|
|
78
78
|
// - The same report with more pages than before: a page was just added in
|
|
79
79
|
// this tab, so the canvas lands on the last page. The asked-for page does
|
|
80
80
|
// not beat it: that is a stale value the host still holds.
|
|
81
|
-
// - Otherwise the asked-for page (a ?page= the report opened with,
|
|
82
|
-
//
|
|
81
|
+
// - Otherwise the asked-for page (a ?page= the report opened with), when the
|
|
82
|
+
// report has it.
|
|
83
83
|
// - Another report that asks for no page it has opens on its first page:
|
|
84
84
|
// the old report's page number means nothing there.
|
|
85
85
|
// - The same report keeps the page it shows when the asked-for page does not
|
|
@@ -111,8 +111,7 @@ function canvasLandingPage(input) {
|
|
|
111
111
|
// numberRange its min/max control, matchAny the report bar's Match select,
|
|
112
112
|
// and pageScope shows a page's groups in the report bar of that page.
|
|
113
113
|
// cascadingOptions narrows a select's list by the other filters of its tile
|
|
114
|
-
// (ReportFilterOptionsService).
|
|
115
|
-
// header (ReportViewPickerComponent). drill is a time chart's click down, with
|
|
114
|
+
// (ReportFilterOptionsService). drill is a time chart's click down, with
|
|
116
115
|
// the tile header's breadcrumb and its menu's Drill up and Back to top as the
|
|
117
116
|
// way back.
|
|
118
117
|
const VIEWER_FEATURES = [
|
|
@@ -122,7 +121,6 @@ const VIEWER_FEATURES = [
|
|
|
122
121
|
'matchAny',
|
|
123
122
|
'pageScope',
|
|
124
123
|
'cascadingOptions',
|
|
125
|
-
'views',
|
|
126
124
|
'drill',
|
|
127
125
|
];
|
|
128
126
|
// The features a report offers on this screen: the ones its server serves
|
|
@@ -388,14 +386,11 @@ class ReportData {
|
|
|
388
386
|
// filterSummary too, for a server that does not read filterLines; the
|
|
389
387
|
// lines are left out when there are none. `states` is each tile's own
|
|
390
388
|
// state by component id (zipExportStates), so each file holds what its
|
|
391
|
-
// tile shows; it is left out when no tile has any. `
|
|
392
|
-
// saved view, which the server names in the notes, and `viewChanged` says
|
|
393
|
-
// whether the screen differs from it; both are left out with no view open.
|
|
394
|
-
// `titles` holds each component's title as the viewer reads it, its tokens
|
|
389
|
+
// tile shows; it is left out when no tile has any. `titles` holds each component's title as the viewer reads it, its tokens
|
|
395
390
|
// filled, by component id, which the server names the component's file
|
|
396
391
|
// after; left out when there are none. Same keep-alive contract as
|
|
397
392
|
// exportComponent, over a longer run; consume through FsProcess.download.
|
|
398
|
-
exportReport(reportId, filters = [], pageId = null, frequency = null, filterLines = [], states = {},
|
|
393
|
+
exportReport(reportId, filters = [], pageId = null, frequency = null, filterLines = [], states = {}, titles = {}, config = {}) {
|
|
399
394
|
return this._api.post(this._path(`${reportId}/export`), {
|
|
400
395
|
filters,
|
|
401
396
|
...(pageId ? { pageId } : {}),
|
|
@@ -403,7 +398,6 @@ class ReportData {
|
|
|
403
398
|
filterSummary: filterLines.map((line) => line.text),
|
|
404
399
|
...(filterLines.length ? { filterLines } : {}),
|
|
405
400
|
...(Object.keys(states).length ? { states } : {}),
|
|
406
|
-
...(viewId ? { viewId, viewChanged } : {}),
|
|
407
401
|
...(Object.keys(titles).length ? { titles } : {}),
|
|
408
402
|
}, {
|
|
409
403
|
key: 'url',
|
|
@@ -563,52 +557,39 @@ class ReportData {
|
|
|
563
557
|
...config,
|
|
564
558
|
});
|
|
565
559
|
}
|
|
566
|
-
// The report's saved
|
|
567
|
-
//
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
return this._api.get(this._path(`${reportId}/views`), {}, config);
|
|
572
|
-
}
|
|
573
|
-
// One saved view with its config, pruned to the report as it is now.
|
|
574
|
-
view(reportId, reportViewId, config = {}) {
|
|
575
|
-
return this._api.get(this._path(`${reportId}/views/${reportViewId}`), {}, {
|
|
576
|
-
key: 'view',
|
|
560
|
+
// The report's saved filters, in their order. Shared by everyone who may
|
|
561
|
+
// open the report.
|
|
562
|
+
savedFilters(reportId, config = {}) {
|
|
563
|
+
return this._api.get(this._path(`${reportId}/savedfilters`), {}, {
|
|
564
|
+
key: 'savedFilters',
|
|
577
565
|
...config,
|
|
578
566
|
});
|
|
579
567
|
}
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
568
|
+
// A new saved filter: its name and the report-wide filter values it holds.
|
|
569
|
+
createSavedFilter(reportId, savedFilter, config = {}) {
|
|
570
|
+
return this._api.post(this._path(`${reportId}/savedfilters`), savedFilter, {
|
|
571
|
+
key: 'savedFilter',
|
|
583
572
|
...config,
|
|
584
573
|
});
|
|
585
574
|
}
|
|
586
|
-
// Renames a
|
|
587
|
-
|
|
588
|
-
return this._api.put(this._path(`${reportId}/
|
|
589
|
-
key: '
|
|
575
|
+
// Renames a saved filter or replaces its values; only the keys given change.
|
|
576
|
+
updateSavedFilter(reportId, reportSavedFilterId, changes, config = {}) {
|
|
577
|
+
return this._api.put(this._path(`${reportId}/savedfilters/${reportSavedFilterId}`), changes, {
|
|
578
|
+
key: 'savedFilter',
|
|
590
579
|
...config,
|
|
591
580
|
});
|
|
592
581
|
}
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
key: 'view',
|
|
582
|
+
deleteSavedFilter(reportId, reportSavedFilterId, config = {}) {
|
|
583
|
+
return this._api.delete(this._path(`${reportId}/savedfilters/${reportSavedFilterId}`), {}, {
|
|
584
|
+
key: 'savedFilter',
|
|
597
585
|
...config,
|
|
598
586
|
});
|
|
599
587
|
}
|
|
600
|
-
//
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
});
|
|
606
|
-
}
|
|
607
|
-
// Makes a view the viewer's default, or stops opening it by default. The
|
|
608
|
-
// server clears the viewer's previous default.
|
|
609
|
-
setViewDefault(reportId, reportViewId, personalDefault, config = {}) {
|
|
610
|
-
return this._api.post(this._path(`${reportId}/views/${reportViewId}/default`), { personalDefault }, {
|
|
611
|
-
key: 'view',
|
|
588
|
+
// Persist the saved filters' order after a drag: every id, in its new
|
|
589
|
+
// order. Resolves to the list in that order.
|
|
590
|
+
saveSavedFilterPositions(reportId, reportSavedFilterIds, config = {}) {
|
|
591
|
+
return this._api.put(this._path(`${reportId}/savedfilters/positions`), { reportSavedFilterIds }, {
|
|
592
|
+
key: 'savedFilters',
|
|
612
593
|
...config,
|
|
613
594
|
});
|
|
614
595
|
}
|
|
@@ -4966,13 +4947,7 @@ function isTile(component) {
|
|
|
4966
4947
|
return !!component && TILE_TYPES.includes(component.type);
|
|
4967
4948
|
}
|
|
4968
4949
|
|
|
4969
|
-
// The
|
|
4970
|
-
const REPORT_VIEW_CONFIG_VERSION = 1;
|
|
4971
|
-
// The caps of a view document, the server's.
|
|
4972
|
-
const VIEW_CONFIG_MAX_BYTES = 32 * 1024;
|
|
4973
|
-
const VIEW_CONFIG_MAX_GROUPS = 100;
|
|
4974
|
-
const VIEW_CONFIG_MAX_TILES = 200;
|
|
4975
|
-
const GROUP_VALUE_MAX_PICKS = 500;
|
|
4950
|
+
// The server's cap on a drill path.
|
|
4976
4951
|
const DRILL_MAX_DEPTH = 5;
|
|
4977
4952
|
// Every relative range name the server knows, in its enum's order. A record
|
|
4978
4953
|
// rather than a list, so the build fails when ReportRelativeRange gains a
|
|
@@ -4993,21 +4968,9 @@ const RELATIVE_RANGES = {
|
|
|
4993
4968
|
// The same names as a list, in the server enum's order (the record's key
|
|
4994
4969
|
// order): the order the date picker offers them in.
|
|
4995
4970
|
const RELATIVE_RANGE_NAMES = Object.keys(RELATIVE_RANGES);
|
|
4996
|
-
// The keys a group value may hold, by its group's type.
|
|
4997
|
-
const GROUP_VALUE_KEYS = {
|
|
4998
|
-
dateRange: ['relative', 'start', 'end'],
|
|
4999
|
-
select: ['values', 'excludeValues'],
|
|
5000
|
-
keyword: ['value'],
|
|
5001
|
-
numberRange: ['min', 'max'],
|
|
5002
|
-
};
|
|
5003
|
-
// The keys of a view document and of a tile state this viewer reads. Any
|
|
5004
|
-
// other key is kept as it was.
|
|
5005
|
-
const VIEW_CONFIG_KEYS = ['v', 'pageId', 'groupBy', 'match', 'groups', 'tiles'];
|
|
5006
|
-
const TILE_STATE_KEYS = ['sort', 'search', 'hidden', 'drill'];
|
|
5007
4971
|
const DAY_PATTERN = /^(\d{4})-(\d{2})-(\d{2})$/;
|
|
5008
4972
|
// A number as text: what a masked input sends.
|
|
5009
4973
|
const NUMBER_PATTERN = /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?$/i;
|
|
5010
|
-
const ID_KEY_PATTERN = /^[1-9]\d*$/;
|
|
5011
4974
|
// Whether a name is a relative range the server resolves.
|
|
5012
4975
|
function isRelativeRange(name) {
|
|
5013
4976
|
return typeof name === 'string' && Object.prototype.hasOwnProperty.call(RELATIVE_RANGES, name);
|
|
@@ -5031,65 +4994,9 @@ function numberFromText(text) {
|
|
|
5031
4994
|
const number = NUMBER_PATTERN.test(text) ? Number(text) : NaN;
|
|
5032
4995
|
return Number.isFinite(number) ? number : null;
|
|
5033
4996
|
}
|
|
5034
|
-
// A group value
|
|
5035
|
-
//
|
|
5036
|
-
//
|
|
5037
|
-
function groupValueFromArray(value, type) {
|
|
5038
|
-
requireGroupKeys(value, type);
|
|
5039
|
-
switch (type) {
|
|
5040
|
-
case 'dateRange':
|
|
5041
|
-
return dateRangeValue(value);
|
|
5042
|
-
case 'select':
|
|
5043
|
-
return selectValue(value);
|
|
5044
|
-
case 'numberRange':
|
|
5045
|
-
return numberRangeValue(value);
|
|
5046
|
-
default:
|
|
5047
|
-
return keywordValue(value);
|
|
5048
|
-
}
|
|
5049
|
-
}
|
|
5050
|
-
// A group value read back (a saved view) for a group of `type`: what
|
|
5051
|
-
// groupValueFromArray gives, or null when it would refuse it.
|
|
5052
|
-
function groupValueFromStored(value, type) {
|
|
5053
|
-
if (!isPlainObject(value)) {
|
|
5054
|
-
return null;
|
|
5055
|
-
}
|
|
5056
|
-
try {
|
|
5057
|
-
return groupValueFromArray(value, type);
|
|
5058
|
-
}
|
|
5059
|
-
catch {
|
|
5060
|
-
return null;
|
|
5061
|
-
}
|
|
5062
|
-
}
|
|
5063
|
-
// A view document read for storing or applying. Throws when it is not a
|
|
5064
|
-
// version 1 document, breaks a cap or holds a malformed known key.
|
|
5065
|
-
function viewConfigFromArray(input) {
|
|
5066
|
-
const doc = requireObject(input, 'A view config must be an object.');
|
|
5067
|
-
requireSize(doc);
|
|
5068
|
-
requireVersion(doc.v);
|
|
5069
|
-
const config = { v: REPORT_VIEW_CONFIG_VERSION, ...otherKeys(doc, VIEW_CONFIG_KEYS) };
|
|
5070
|
-
const pageId = optionalId(doc.pageId, 'pageId must be a page id.');
|
|
5071
|
-
if (pageId !== null) {
|
|
5072
|
-
config.pageId = pageId;
|
|
5073
|
-
}
|
|
5074
|
-
if (isSet(doc.groupBy)) {
|
|
5075
|
-
config.groupBy = requireGranularity(doc.groupBy, 'groupBy must be a time grain.');
|
|
5076
|
-
}
|
|
5077
|
-
if (isSet(doc.match)) {
|
|
5078
|
-
config.match = requireMatch(doc.match);
|
|
5079
|
-
}
|
|
5080
|
-
if (isSet(doc.groups)) {
|
|
5081
|
-
config.groups = viewGroups(doc.groups);
|
|
5082
|
-
}
|
|
5083
|
-
if (isSet(doc.tiles)) {
|
|
5084
|
-
config.tiles = viewTiles(doc.tiles);
|
|
5085
|
-
}
|
|
5086
|
-
return config;
|
|
5087
|
-
}
|
|
5088
|
-
// A group's value as a view stores it, read for the keys its
|
|
5089
|
-
// group's type owns, or null when it holds nothing. A date range that still
|
|
5090
|
-
// carries its relative preset is stored as that preset whatever today is: the
|
|
5091
|
-
// viewer did not edit it, so a view saved after midnight with the page still
|
|
5092
|
-
// open keeps rolling.
|
|
4997
|
+
// A group's value in its stored shape, read for the keys its group's type
|
|
4998
|
+
// owns, or null when it holds nothing. A date range that still carries its
|
|
4999
|
+
// relative preset is written as that preset whatever today is.
|
|
5093
5000
|
function toStoredGroupValue(type, value) {
|
|
5094
5001
|
if (!value) {
|
|
5095
5002
|
return null;
|
|
@@ -5109,39 +5016,20 @@ function toStoredGroupValue(type, value) {
|
|
|
5109
5016
|
return storedOrNull({ value: value.value?.trim() || undefined });
|
|
5110
5017
|
}
|
|
5111
5018
|
}
|
|
5112
|
-
//
|
|
5113
|
-
//
|
|
5114
|
-
//
|
|
5115
|
-
//
|
|
5116
|
-
function toLiveGroupValue(type, stored, resolveRelative) {
|
|
5117
|
-
if (!stored || holdsNothing(stored)) {
|
|
5118
|
-
return null;
|
|
5119
|
-
}
|
|
5120
|
-
if (type !== 'dateRange') {
|
|
5121
|
-
return { ...stored };
|
|
5122
|
-
}
|
|
5123
|
-
if (stored.relative) {
|
|
5124
|
-
const range = resolveRelative(stored.relative);
|
|
5125
|
-
return range ? { start: range.start, end: range.end, preset: stored.relative } : null;
|
|
5126
|
-
}
|
|
5127
|
-
return { start: dateFromBound(stored.start), end: dateFromBound(stored.end) };
|
|
5128
|
-
}
|
|
5129
|
-
// Whether the screen's state differs from the view it opened on. `current`
|
|
5130
|
-
// is a snapshot, so it holds every group the report has; a group only `base`
|
|
5131
|
-
// holds is one the report no longer has, and is not compared. A group `base`
|
|
5132
|
-
// does not hold was added after the view was saved: it is compared with its
|
|
5133
|
-
// value in `authored` (the report's defaults), so leaving it at its default
|
|
5134
|
-
// changes nothing. The page is not compared (moving to another page does not
|
|
5135
|
-
// edit a view), and neither are keys this viewer does not know. Picks are
|
|
5019
|
+
// Whether the screen's state (`current`, a snapshot) differs from the
|
|
5020
|
+
// report's authored defaults (`defaults`): the match, the grain, a group's
|
|
5021
|
+
// value, or a tile's sort, search, hidden columns or drill path. The page is
|
|
5022
|
+
// not compared (moving to another page changes no filter). Picks are
|
|
5136
5023
|
// compared in any order.
|
|
5137
|
-
function
|
|
5138
|
-
return (
|
|
5139
|
-
|| (
|
|
5140
|
-
||
|
|
5141
|
-
|
|
5142
|
-
}
|
|
5143
|
-
|
|
5144
|
-
//
|
|
5024
|
+
function stateDiffers(defaults, current) {
|
|
5025
|
+
return (defaults.match ?? 'all') !== (current.match ?? 'all')
|
|
5026
|
+
|| (defaults.groupBy ?? null) !== (current.groupBy ?? null)
|
|
5027
|
+
|| Object.entries(current.groups ?? {})
|
|
5028
|
+
.some(([groupId, value]) => storedValueKey(defaults.groups?.[Number(groupId)]) !== storedValueKey(value))
|
|
5029
|
+
|| tilesDiffer(defaults.tiles ?? {}, current.tiles ?? {});
|
|
5030
|
+
}
|
|
5031
|
+
// Whether two stored group values are the same filter: picks in any order,
|
|
5032
|
+
// text trimmed, an empty value read as none.
|
|
5145
5033
|
function sameStoredGroupValue(a, b) {
|
|
5146
5034
|
return storedValueKey(a) === storedValueKey(b);
|
|
5147
5035
|
}
|
|
@@ -5151,7 +5039,7 @@ function sameStoredGroupValue(a, b) {
|
|
|
5151
5039
|
function cleanDrillPath(path) {
|
|
5152
5040
|
const kept = [];
|
|
5153
5041
|
for (const step of path ?? []) {
|
|
5154
|
-
if (!isPlainObject(step) || !isChartGranularity(step.level) || !isDrillValue(step.value)
|
|
5042
|
+
if (!isPlainObject$1(step) || !isChartGranularity(step.level) || !isDrillValue(step.value)
|
|
5155
5043
|
|| kept.length === DRILL_MAX_DEPTH) {
|
|
5156
5044
|
break;
|
|
5157
5045
|
}
|
|
@@ -5164,83 +5052,16 @@ function cleanDrillPath(path) {
|
|
|
5164
5052
|
function sameDrillPath(a, b) {
|
|
5165
5053
|
return drillKey(a) === drillKey(b);
|
|
5166
5054
|
}
|
|
5167
|
-
// A
|
|
5168
|
-
//
|
|
5169
|
-
|
|
5170
|
-
|
|
5171
|
-
|
|
5172
|
-
if (!stored) {
|
|
5173
|
-
return screen;
|
|
5174
|
-
}
|
|
5175
|
-
const tiles = { ...screen.tiles };
|
|
5176
|
-
Object.entries(stored.tiles ?? {}).forEach(([componentId, tile]) => {
|
|
5177
|
-
const kept = isPlainObject(tile) ? otherKeys(tile, TILE_STATE_KEYS) : {};
|
|
5178
|
-
if (Object.keys(kept).length) {
|
|
5179
|
-
tiles[Number(componentId)] = { ...kept, ...tiles[Number(componentId)] };
|
|
5180
|
-
}
|
|
5181
|
-
});
|
|
5182
|
-
const config = { ...otherKeys(stored, VIEW_CONFIG_KEYS), ...screen };
|
|
5183
|
-
delete config.tiles;
|
|
5184
|
-
return Object.keys(tiles).length ? { ...config, tiles } : config;
|
|
5185
|
-
}
|
|
5186
|
-
// How many settings of a saved view no longer apply because the report
|
|
5187
|
-
// changed: what the server left out when it read the view (`dropped`), and
|
|
5188
|
-
// what the viewer could not apply (`applied` is the view as it applied, the
|
|
5189
|
-
// state service's base): a group value that no longer fits its group, and a
|
|
5190
|
-
// list sort, search or hidden columns, or a chart's drill path, its tile can
|
|
5191
|
-
// no longer take (a list column gone, a chart turned into a list).
|
|
5192
|
-
function viewDroppedCount(view, applied) {
|
|
5193
|
-
const dropped = view.dropped;
|
|
5194
|
-
const groups = Object.entries(view.config?.groups ?? {})
|
|
5195
|
-
.filter(([groupId, value]) => value !== null && !Object.prototype.hasOwnProperty.call(applied.groups ?? {}, groupId))
|
|
5196
|
-
.length;
|
|
5197
|
-
const tiles = Object.entries(view.config?.tiles ?? {})
|
|
5198
|
-
.reduce((count, [componentId, tile]) => count + tileKeysLost(tile, applied.tiles?.[Number(componentId)]), 0);
|
|
5199
|
-
return (dropped?.groups?.length ?? 0) + (dropped?.tiles?.length ?? 0) + (dropped?.page ? 1 : 0) + groups + tiles;
|
|
5200
|
-
}
|
|
5201
|
-
// The note a view opens with when some of its settings no longer apply, or
|
|
5202
|
-
// null when every setting applies. Only a viewer who may change the view
|
|
5203
|
-
// (`canChange`) is told to save it.
|
|
5204
|
-
function viewDroppedMessage(count, canChange = true) {
|
|
5205
|
-
if (count < 1) {
|
|
5206
|
-
return null;
|
|
5207
|
-
}
|
|
5208
|
-
const note = count === 1
|
|
5209
|
-
? '1 setting in this view no longer applies, because the report changed.'
|
|
5210
|
-
: `${count} settings in this view no longer apply, because the report changed.`;
|
|
5211
|
-
return canChange ? `${note} Save changes to update the view.` : note;
|
|
5212
|
-
}
|
|
5213
|
-
// The open view as a chat turn names it: its id, and whether the screen
|
|
5214
|
-
// differs from it. Both are left out while no view is open.
|
|
5215
|
-
function chatViewFields(openView, viewChanged) {
|
|
5216
|
-
return openView ? { viewId: openView.id, viewChanged } : {};
|
|
5217
|
-
}
|
|
5218
|
-
function groupsDiffer(base, current, authored) {
|
|
5219
|
-
return Object.entries(current.groups ?? {}).some(([groupId, value]) => {
|
|
5220
|
-
const kept = base.groups && Object.prototype.hasOwnProperty.call(base.groups, groupId)
|
|
5221
|
-
? base.groups[Number(groupId)]
|
|
5222
|
-
: authored?.groups?.[Number(groupId)];
|
|
5223
|
-
return storedValueKey(kept) !== storedValueKey(value);
|
|
5224
|
-
});
|
|
5055
|
+
// A range end as a number. A masked input sends its number as text, so
|
|
5056
|
+
// numeric text is read as its number.
|
|
5057
|
+
function finiteNumber(value) {
|
|
5058
|
+
const number = typeof value === 'string' ? numberFromText(value.trim()) : value;
|
|
5059
|
+
return typeof number === 'number' && Number.isFinite(number) ? number : undefined;
|
|
5225
5060
|
}
|
|
5226
5061
|
function tilesDiffer(base, current) {
|
|
5227
5062
|
const ids = new Set([...Object.keys(base), ...Object.keys(current)].map(Number));
|
|
5228
5063
|
return [...ids].some((componentId) => tileStateKey(base[componentId]) !== tileStateKey(current[componentId]));
|
|
5229
5064
|
}
|
|
5230
|
-
// How many keys of a stored tile state the tile, as it applied, lost: a
|
|
5231
|
-
// sort, a search or a drill path it no longer has, and hidden columns it
|
|
5232
|
-
// kept fewer of.
|
|
5233
|
-
function tileKeysLost(stored, applied) {
|
|
5234
|
-
if (!stored) {
|
|
5235
|
-
return 0;
|
|
5236
|
-
}
|
|
5237
|
-
return [
|
|
5238
|
-
!!stored.sort?.column && !applied?.sort?.column,
|
|
5239
|
-
!!stored.search && !applied?.search,
|
|
5240
|
-
(stored.hidden?.length ?? 0) > (applied?.hidden?.length ?? 0),
|
|
5241
|
-
(stored.drill?.length ?? 0) > 0 && !applied?.drill?.length,
|
|
5242
|
-
].filter(Boolean).length;
|
|
5243
|
-
}
|
|
5244
5065
|
// One text for a stored group value, the same for every way of writing the
|
|
5245
5066
|
// same filter: picks in any order, text trimmed, and an empty value read as
|
|
5246
5067
|
// none.
|
|
@@ -5278,281 +5099,23 @@ function keptPicks(list) {
|
|
|
5278
5099
|
const kept = (list ?? []).filter((pick) => isSet(pick) && pick !== '');
|
|
5279
5100
|
return kept.length ? kept : undefined;
|
|
5280
5101
|
}
|
|
5281
|
-
// A range end as a number. A masked input sends its number as text, so
|
|
5282
|
-
// numeric text is read as its number, as boundPart reads it.
|
|
5283
|
-
function finiteNumber(value) {
|
|
5284
|
-
const number = typeof value === 'string' ? numberFromText(value.trim()) : value;
|
|
5285
|
-
return typeof number === 'number' && Number.isFinite(number) ? number : undefined;
|
|
5286
|
-
}
|
|
5287
|
-
// Whether a stored value reads the same as no value at all.
|
|
5288
|
-
function holdsNothing(stored) {
|
|
5289
|
-
return storedValueKey(stored) === storedValueKey(null);
|
|
5290
|
-
}
|
|
5291
5102
|
// Only the parts that hold something, or null when none does.
|
|
5292
5103
|
function storedOrNull(value) {
|
|
5293
|
-
const stored =
|
|
5104
|
+
const stored = Object.fromEntries(Object.entries(value).filter(([, part]) => isSet(part)));
|
|
5294
5105
|
return Object.keys(stored).length ? stored : null;
|
|
5295
5106
|
}
|
|
5296
|
-
function requireGroupKeys(value, type) {
|
|
5297
|
-
const allowed = GROUP_VALUE_KEYS[type] ?? GROUP_VALUE_KEYS.keyword;
|
|
5298
|
-
const unknown = Object.keys(value).filter((key) => !allowed.includes(key));
|
|
5299
|
-
if (unknown.length) {
|
|
5300
|
-
throw new Error(`A ${type} default takes ${allowed.join(', ')} — not ${unknown.join(', ')}.`);
|
|
5301
|
-
}
|
|
5302
|
-
}
|
|
5303
|
-
// A relative range, or fixed days; never both.
|
|
5304
|
-
function dateRangeValue(value) {
|
|
5305
|
-
const relative = textPart(value, 'relative');
|
|
5306
|
-
const start = dayPart(value, 'start');
|
|
5307
|
-
const end = dayPart(value, 'end');
|
|
5308
|
-
if (relative === '') {
|
|
5309
|
-
return compact({ start, end });
|
|
5310
|
-
}
|
|
5311
|
-
if (start !== null || end !== null) {
|
|
5312
|
-
throw new Error('A dateRange default is either a relative preset or a fixed start/end, not both.');
|
|
5313
|
-
}
|
|
5314
|
-
if (!isRelativeRange(relative)) {
|
|
5315
|
-
throw new Error(`relative must be one of: ${Object.keys(RELATIVE_RANGES).join(', ')}.`);
|
|
5316
|
-
}
|
|
5317
|
-
return { relative };
|
|
5318
|
-
}
|
|
5319
|
-
// The values to keep or the values to leave out; never both.
|
|
5320
|
-
function selectValue(value) {
|
|
5321
|
-
const values = pickList(value, 'values');
|
|
5322
|
-
const excludeValues = pickList(value, 'excludeValues');
|
|
5323
|
-
if (values !== null && excludeValues !== null) {
|
|
5324
|
-
throw new Error('A select default either keeps values or leaves out exclude_values, not both.');
|
|
5325
|
-
}
|
|
5326
|
-
return compact({ values, excludeValues });
|
|
5327
|
-
}
|
|
5328
|
-
function keywordValue(value) {
|
|
5329
|
-
const text = textPart(value, 'value');
|
|
5330
|
-
return text !== '' ? { value: text } : {};
|
|
5331
|
-
}
|
|
5332
|
-
// Either end may be open; 0 is an end like any other.
|
|
5333
|
-
function numberRangeValue(value) {
|
|
5334
|
-
const min = boundPart(value, 'min');
|
|
5335
|
-
const max = boundPart(value, 'max');
|
|
5336
|
-
if (min !== null && max !== null && min > max) {
|
|
5337
|
-
throw new Error(`min (${min}) is above max (${max}).`);
|
|
5338
|
-
}
|
|
5339
|
-
return compact({ min, max });
|
|
5340
|
-
}
|
|
5341
|
-
// The text `key` holds, trimmed, or '' when absent. A number is read as its
|
|
5342
|
-
// text; any other kind is refused.
|
|
5343
|
-
function textPart(value, key) {
|
|
5344
|
-
const text = value[key];
|
|
5345
|
-
if (text === undefined || text === null) {
|
|
5346
|
-
return '';
|
|
5347
|
-
}
|
|
5348
|
-
if (typeof text !== 'string' && typeof text !== 'number') {
|
|
5349
|
-
throw new Error(`${key} must be text.`);
|
|
5350
|
-
}
|
|
5351
|
-
return String(text).trim();
|
|
5352
|
-
}
|
|
5353
|
-
// A calendar-day bound: its first ten characters must be a real day.
|
|
5354
|
-
function dayPart(value, key) {
|
|
5355
|
-
const text = textPart(value, key);
|
|
5356
|
-
if (text === '') {
|
|
5357
|
-
return null;
|
|
5358
|
-
}
|
|
5359
|
-
const day = text.slice(0, 10);
|
|
5360
|
-
if (!isCalendarDay(day)) {
|
|
5361
|
-
throw new Error(`${key} must be a yyyy-MM-dd day.`);
|
|
5362
|
-
}
|
|
5363
|
-
return day;
|
|
5364
|
-
}
|
|
5365
|
-
// The picks of `key` with blanks (null, '') dropped, or null when none is
|
|
5366
|
-
// left. A lone pick is a list of one; a pick that is a list or an object is
|
|
5367
|
-
// refused.
|
|
5368
|
-
function pickList(value, key) {
|
|
5369
|
-
const raw = value[key] ?? [];
|
|
5370
|
-
const picks = Array.isArray(raw) ? raw : [raw];
|
|
5371
|
-
if (picks.some((pick) => isSet(pick) && !isScalar(pick))) {
|
|
5372
|
-
throw new Error(`Each pick in ${key} must be a plain value, not a list.`);
|
|
5373
|
-
}
|
|
5374
|
-
const kept = picks.filter((pick) => isSet(pick) && pick !== '');
|
|
5375
|
-
return kept.length ? kept : null;
|
|
5376
|
-
}
|
|
5377
|
-
// One end of a number range: null when absent or blank, else a finite number
|
|
5378
|
-
// (numeric text is read as its number).
|
|
5379
|
-
function boundPart(value, key) {
|
|
5380
|
-
const raw = typeof value[key] === 'string' ? value[key].trim() : value[key];
|
|
5381
|
-
if (!isSet(raw) || raw === '') {
|
|
5382
|
-
return null;
|
|
5383
|
-
}
|
|
5384
|
-
const bound = typeof raw === 'string' ? numberFromText(raw) : raw;
|
|
5385
|
-
if (typeof bound !== 'number' || !Number.isFinite(bound)) {
|
|
5386
|
-
throw new Error(`${key} must be a finite number.`);
|
|
5387
|
-
}
|
|
5388
|
-
return bound;
|
|
5389
|
-
}
|
|
5390
|
-
function requireSize(doc) {
|
|
5391
|
-
if (new TextEncoder().encode(JSON.stringify(doc)).length > VIEW_CONFIG_MAX_BYTES) {
|
|
5392
|
-
throw new Error('A view config is over 32 KB.');
|
|
5393
|
-
}
|
|
5394
|
-
}
|
|
5395
|
-
function requireVersion(version) {
|
|
5396
|
-
if (!isSet(version)) {
|
|
5397
|
-
throw new Error('A view config needs its version (v).');
|
|
5398
|
-
}
|
|
5399
|
-
if (version !== REPORT_VIEW_CONFIG_VERSION) {
|
|
5400
|
-
throw new Error(`This viewer reads view config version ${REPORT_VIEW_CONFIG_VERSION} only.`);
|
|
5401
|
-
}
|
|
5402
|
-
}
|
|
5403
|
-
function requireMatch(match) {
|
|
5404
|
-
if (match !== 'all' && match !== 'any') {
|
|
5405
|
-
throw new Error('match must be all or any.');
|
|
5406
|
-
}
|
|
5407
|
-
return match;
|
|
5408
|
-
}
|
|
5409
|
-
// Group id -> value. A value no group type could hold is dropped whole.
|
|
5410
|
-
function viewGroups(raw) {
|
|
5411
|
-
const entries = Object.entries(requireObject(raw, 'groups must map group ids to values.'));
|
|
5412
|
-
if (entries.length > VIEW_CONFIG_MAX_GROUPS) {
|
|
5413
|
-
throw new Error(`A view holds at most ${VIEW_CONFIG_MAX_GROUPS} groups.`);
|
|
5414
|
-
}
|
|
5415
|
-
const groups = {};
|
|
5416
|
-
entries.forEach(([key, value]) => {
|
|
5417
|
-
const id = requireIdKey(key, 'groups');
|
|
5418
|
-
requirePickCount(value);
|
|
5419
|
-
const kept = value === null ? null : storedGroupValue(value);
|
|
5420
|
-
if (kept !== undefined) {
|
|
5421
|
-
groups[id] = kept;
|
|
5422
|
-
}
|
|
5423
|
-
});
|
|
5424
|
-
return groups;
|
|
5425
|
-
}
|
|
5426
|
-
// A stored group value, read for the one type its keys belong to (an empty
|
|
5427
|
-
// value fits every type), or undefined when it fits none.
|
|
5428
|
-
function storedGroupValue(value) {
|
|
5429
|
-
if (!isPlainObject(value)) {
|
|
5430
|
-
return undefined;
|
|
5431
|
-
}
|
|
5432
|
-
const keys = Object.keys(value);
|
|
5433
|
-
const type = Object.keys(GROUP_VALUE_KEYS)
|
|
5434
|
-
.find((candidate) => keys.every((key) => GROUP_VALUE_KEYS[candidate].includes(key)));
|
|
5435
|
-
return type ? (groupValueFromStored(value, type) ?? undefined) : undefined;
|
|
5436
|
-
}
|
|
5437
|
-
function requirePickCount(value) {
|
|
5438
|
-
const lists = isPlainObject(value) ? [value.values, value.excludeValues] : [];
|
|
5439
|
-
if (lists.some((list) => Array.isArray(list) && list.length > GROUP_VALUE_MAX_PICKS)) {
|
|
5440
|
-
throw new Error(`A group value holds at most ${GROUP_VALUE_MAX_PICKS} values.`);
|
|
5441
|
-
}
|
|
5442
|
-
}
|
|
5443
|
-
// Component id -> tile state.
|
|
5444
|
-
function viewTiles(raw) {
|
|
5445
|
-
const entries = Object.entries(requireObject(raw, 'tiles must map component ids to tile states.'));
|
|
5446
|
-
if (entries.length > VIEW_CONFIG_MAX_TILES) {
|
|
5447
|
-
throw new Error(`A view holds at most ${VIEW_CONFIG_MAX_TILES} tiles.`);
|
|
5448
|
-
}
|
|
5449
|
-
const tiles = {};
|
|
5450
|
-
entries.forEach(([key, value]) => {
|
|
5451
|
-
tiles[requireIdKey(key, 'tiles')] = viewTile(value);
|
|
5452
|
-
});
|
|
5453
|
-
return tiles;
|
|
5454
|
-
}
|
|
5455
|
-
function viewTile(raw) {
|
|
5456
|
-
const tile = requireObject(raw, 'A tile state must be an object.');
|
|
5457
|
-
const state = otherKeys(tile, TILE_STATE_KEYS);
|
|
5458
|
-
if (isSet(tile.sort)) {
|
|
5459
|
-
state.sort = viewSort(tile.sort);
|
|
5460
|
-
}
|
|
5461
|
-
if (isSet(tile.search)) {
|
|
5462
|
-
state.search = requireString(tile.search, 'search must be text.');
|
|
5463
|
-
}
|
|
5464
|
-
if (isSet(tile.hidden)) {
|
|
5465
|
-
state.hidden = viewHidden(tile.hidden);
|
|
5466
|
-
}
|
|
5467
|
-
if (isSet(tile.drill)) {
|
|
5468
|
-
state.drill = viewDrill(tile.drill);
|
|
5469
|
-
}
|
|
5470
|
-
return state;
|
|
5471
|
-
}
|
|
5472
|
-
function viewSort(raw) {
|
|
5473
|
-
const sort = requireObject(raw, 'sort must be a column and a direction.');
|
|
5474
|
-
const column = requireString(sort.column, 'sort must name a column.');
|
|
5475
|
-
if (column === '' || (isSet(sort.direction) && sort.direction !== 'asc' && sort.direction !== 'desc')) {
|
|
5476
|
-
throw new Error('sort must be a column and a direction.');
|
|
5477
|
-
}
|
|
5478
|
-
return sort.direction ? { column, direction: sort.direction } : { column };
|
|
5479
|
-
}
|
|
5480
|
-
function viewHidden(raw) {
|
|
5481
|
-
if (!Array.isArray(raw) || raw.some((name) => typeof name !== 'string')) {
|
|
5482
|
-
throw new Error('hidden must list column names.');
|
|
5483
|
-
}
|
|
5484
|
-
return [...raw];
|
|
5485
|
-
}
|
|
5486
|
-
function viewDrill(raw) {
|
|
5487
|
-
if (!Array.isArray(raw)) {
|
|
5488
|
-
throw new Error('drill must list steps.');
|
|
5489
|
-
}
|
|
5490
|
-
if (raw.length > DRILL_MAX_DEPTH) {
|
|
5491
|
-
throw new Error(`A drill path holds at most ${DRILL_MAX_DEPTH} steps.`);
|
|
5492
|
-
}
|
|
5493
|
-
return raw.map((step) => {
|
|
5494
|
-
if (!isPlainObject(step) || !isChartGranularity(step.level) || !isDrillValue(step.value)) {
|
|
5495
|
-
throw new Error('A drill step needs a time level and a value.');
|
|
5496
|
-
}
|
|
5497
|
-
return { level: step.level, value: step.value };
|
|
5498
|
-
});
|
|
5499
|
-
}
|
|
5500
5107
|
function isDrillValue(value) {
|
|
5501
5108
|
return value === null || typeof value === 'string' || typeof value === 'number';
|
|
5502
5109
|
}
|
|
5503
|
-
// The keys of `source` that are not in `known`, as they were.
|
|
5504
|
-
function otherKeys(source, known) {
|
|
5505
|
-
return Object.fromEntries(Object.entries(source).filter(([key]) => !known.includes(key)));
|
|
5506
|
-
}
|
|
5507
|
-
function optionalId(raw, refusal) {
|
|
5508
|
-
if (!isSet(raw)) {
|
|
5509
|
-
return null;
|
|
5510
|
-
}
|
|
5511
|
-
if (!Number.isInteger(raw) || raw < 1) {
|
|
5512
|
-
throw new Error(refusal);
|
|
5513
|
-
}
|
|
5514
|
-
return raw;
|
|
5515
|
-
}
|
|
5516
|
-
function requireIdKey(key, where) {
|
|
5517
|
-
if (!ID_KEY_PATTERN.test(key)) {
|
|
5518
|
-
throw new Error(`${where} are keyed by id, not by '${key}'.`);
|
|
5519
|
-
}
|
|
5520
|
-
return Number(key);
|
|
5521
|
-
}
|
|
5522
|
-
function requireGranularity(raw, refusal) {
|
|
5523
|
-
if (!isChartGranularity(raw)) {
|
|
5524
|
-
throw new Error(refusal);
|
|
5525
|
-
}
|
|
5526
|
-
return raw;
|
|
5527
|
-
}
|
|
5528
|
-
function requireString(raw, refusal) {
|
|
5529
|
-
if (typeof raw !== 'string') {
|
|
5530
|
-
throw new Error(refusal);
|
|
5531
|
-
}
|
|
5532
|
-
return raw;
|
|
5533
|
-
}
|
|
5534
|
-
function requireObject(raw, refusal) {
|
|
5535
|
-
if (!isPlainObject(raw)) {
|
|
5536
|
-
throw new Error(refusal);
|
|
5537
|
-
}
|
|
5538
|
-
return raw;
|
|
5539
|
-
}
|
|
5540
5110
|
function isSet(value) {
|
|
5541
5111
|
return value !== undefined && value !== null;
|
|
5542
5112
|
}
|
|
5543
|
-
function isPlainObject(value) {
|
|
5113
|
+
function isPlainObject$1(value) {
|
|
5544
5114
|
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
5545
5115
|
}
|
|
5546
|
-
function isScalar(value) {
|
|
5547
|
-
return typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean';
|
|
5548
|
-
}
|
|
5549
|
-
// Only the parts that hold something.
|
|
5550
|
-
function compact(value) {
|
|
5551
|
-
return Object.fromEntries(Object.entries(value).filter(([, part]) => isSet(part)));
|
|
5552
|
-
}
|
|
5553
5116
|
|
|
5554
5117
|
// The words a file uses for what the screen held: each filter's value, the
|
|
5555
|
-
// group-by grain, a drilled tile
|
|
5118
|
+
// group-by grain, a drilled tile and the match. The PDF and
|
|
5556
5119
|
// PowerPoint covers print these lines, and the zip sends them (filterLines,
|
|
5557
5120
|
// with filterSummary as their text for an older server), so every export
|
|
5558
5121
|
// words one filter one way. Pure, so the node harness checks every wording.
|
|
@@ -5626,16 +5189,6 @@ function matchLineText(match, form) {
|
|
|
5626
5189
|
function drillLineText(component, path, formatContext) {
|
|
5627
5190
|
return crumbedTitle(component.title, drillCrumbLabels(path, formatContext));
|
|
5628
5191
|
}
|
|
5629
|
-
// The cover's line for the open saved view: 'View: "Q3 North" (shared)',
|
|
5630
|
-
// with ', changed since it was opened' while the screen differs from it.
|
|
5631
|
-
// Null when no view is open.
|
|
5632
|
-
function viewLineText(view, changed) {
|
|
5633
|
-
if (!view) {
|
|
5634
|
-
return null;
|
|
5635
|
-
}
|
|
5636
|
-
const line = `View: "${(view.name ?? '').trim()}" (${view.shared ? 'shared' : 'personal'})`;
|
|
5637
|
-
return changed ? `${line}, changed since it was opened` : line;
|
|
5638
|
-
}
|
|
5639
5192
|
// The filter lines of an export, in order: one per filter group with a
|
|
5640
5193
|
// value (the report's group order), the group-by line, then one per drilled
|
|
5641
5194
|
// chart (page order). A This Page export (input.pageId) lists only the
|
|
@@ -5772,10 +5325,10 @@ function drillLines(input) {
|
|
|
5772
5325
|
return (report.pages ?? [])
|
|
5773
5326
|
.filter((page) => !pageId || page.id === pageId)
|
|
5774
5327
|
.flatMap((page) => page.components ?? [])
|
|
5775
|
-
.filter((component) => component.type === 'chart' && !!snapshot.
|
|
5328
|
+
.filter((component) => component.type === 'chart' && !!snapshot.state.tiles?.[component.id]?.drill?.length)
|
|
5776
5329
|
.map((component) => ({
|
|
5777
5330
|
kind: 'drill',
|
|
5778
|
-
text: drillLineText({ title: input.titles?.[component.id] ?? component.title }, snapshot.
|
|
5331
|
+
text: drillLineText({ title: input.titles?.[component.id] ?? component.title }, snapshot.state.tiles[component.id].drill, snapshot.formatContext),
|
|
5779
5332
|
componentId: component.id,
|
|
5780
5333
|
}));
|
|
5781
5334
|
}
|
|
@@ -5979,8 +5532,8 @@ const HEADER_FOOTER_MAX = 3;
|
|
|
5979
5532
|
function isTimeSeriesChart(component) {
|
|
5980
5533
|
return component.type === 'chart' && component.config?.xAxis?.kind === 'time';
|
|
5981
5534
|
}
|
|
5982
|
-
// The interaction state one tile's data request carries, read off a
|
|
5983
|
-
//
|
|
5535
|
+
// The interaction state one tile's data request carries, read off a snapshot
|
|
5536
|
+
// of the screen (the state service's snapshot()): the group-by grain
|
|
5984
5537
|
// for a time-series chart, a list's sort, search and hidden columns, a
|
|
5985
5538
|
// chart's drill path, and, while the groups match 'any', the groups that
|
|
5986
5539
|
// combine (matchAnyGroupIds). The tile on screen, its CSV, the PDF and
|
|
@@ -6504,16 +6057,17 @@ function emptyTemporaryState() {
|
|
|
6504
6057
|
// columns, the page and each chart's drill path. None of it is ever written to
|
|
6505
6058
|
// the browser URL. It outlives a page switch (the canvas changes the page, not
|
|
6506
6059
|
// this service) and goes with the report view: a reload starts a new service,
|
|
6507
|
-
// which opens on the report's defaults
|
|
6508
|
-
//
|
|
6060
|
+
// which opens on the report's authored defaults. changes() fires on every
|
|
6061
|
+
// change of it.
|
|
6509
6062
|
//
|
|
6510
|
-
// The durable part
|
|
6511
|
-
//
|
|
6512
|
-
//
|
|
6513
|
-
//
|
|
6063
|
+
// The durable part reads as one document: snapshot() writes it, and
|
|
6064
|
+
// differsFromDefaults says whether the screen still shows the report's
|
|
6065
|
+
// authored defaults (resetToDefaults() puts it back on them). The temporary
|
|
6066
|
+
// layer (a cross-filter, an open rows panel) is its own section, so a
|
|
6067
|
+
// snapshot never carries it.
|
|
6514
6068
|
//
|
|
6515
|
-
// Session-only, never in
|
|
6516
|
-
// component's filter menu (the `enabled` column is the authored default; a
|
|
6069
|
+
// Session-only, never in the snapshot: per-filter enable/disable toggles from
|
|
6070
|
+
// a component's filter menu (the `enabled` column is the authored default; a
|
|
6517
6071
|
// viewer's toggle shouldn't write to the report).
|
|
6518
6072
|
class ReportFilterStateService {
|
|
6519
6073
|
// The open report's effective zone (resolveReportTimezone). Relative presets
|
|
@@ -6536,14 +6090,10 @@ class ReportFilterStateService {
|
|
|
6536
6090
|
listHidden = signal(new Map(), {
|
|
6537
6091
|
equal: sameHidden,
|
|
6538
6092
|
});
|
|
6539
|
-
// Moves each time
|
|
6540
|
-
//
|
|
6093
|
+
// Moves each time resetToDefaults() replaces the whole durable state. Bars
|
|
6094
|
+
// and tiles key on it, so a reset repaints all of them; the tiles refetch
|
|
6095
|
+
// in place, so their data stays on screen while they reload.
|
|
6541
6096
|
stateVersion = computed(() => this._stateVersion());
|
|
6542
|
-
// Moves each time apply() opens a view (or the report's defaults). The
|
|
6543
|
-
// canvas keys its tiles on it, so an opened view draws each one again.
|
|
6544
|
-
// resetToBase() leaves it: a reset refetches the tiles in place, so their
|
|
6545
|
-
// data stays on screen while they reload.
|
|
6546
|
-
appliedVersion = computed(() => this._appliedVersion());
|
|
6547
6097
|
// By list component id, how many resets moved that list's sort. FsList
|
|
6548
6098
|
// reads its sort only when it is created, so a list whose sort a reset
|
|
6549
6099
|
// moved is drawn again (keyed on its count); every other tile reloads in
|
|
@@ -6561,20 +6111,12 @@ class ReportFilterStateService {
|
|
|
6561
6111
|
// The report init() last read, or null before one is open. A tile header
|
|
6562
6112
|
// lists its select options from it (ReportFilterOptionsService).
|
|
6563
6113
|
report = computed(() => this._report());
|
|
6564
|
-
//
|
|
6565
|
-
//
|
|
6566
|
-
//
|
|
6567
|
-
|
|
6568
|
-
base = computed(() => {
|
|
6114
|
+
// Whether the screen differs from the report's authored defaults
|
|
6115
|
+
// (stateDiffers): what the report bar's 'Clear filters' shows for. A switch
|
|
6116
|
+
// of page does not count, and the temporary state never does.
|
|
6117
|
+
differsFromDefaults = computed(() => {
|
|
6569
6118
|
this._revision();
|
|
6570
|
-
return this.
|
|
6571
|
-
});
|
|
6572
|
-
// Whether the screen differs from base (viewDiffers): what a view picker
|
|
6573
|
-
// marks '(edited)'. A switch of page does not count, and the temporary
|
|
6574
|
-
// state never does.
|
|
6575
|
-
viewChanged = computed(() => {
|
|
6576
|
-
this._revision();
|
|
6577
|
-
return viewDiffers(this.base(), this.snapshot(), this.authoredConfig());
|
|
6119
|
+
return stateDiffers(this.authoredConfig(), this.snapshot());
|
|
6578
6120
|
});
|
|
6579
6121
|
_values = new Map();
|
|
6580
6122
|
_groups = new Map();
|
|
@@ -6585,7 +6127,7 @@ class ReportFilterStateService {
|
|
|
6585
6127
|
_cleared = new Set();
|
|
6586
6128
|
// The report's group-by control: a single report-wide bucket override for all
|
|
6587
6129
|
// time-series charts. Unset = each chart uses its authored granularity. Never
|
|
6588
|
-
// saved to the report
|
|
6130
|
+
// saved to the report, and carried in the data request's
|
|
6589
6131
|
// `state`, so it propagates through to the PDF/PowerPoint exports too. Named
|
|
6590
6132
|
// for the `frequency` wire key it travels under, not for the control's label.
|
|
6591
6133
|
_frequency = null;
|
|
@@ -6613,10 +6155,11 @@ class ReportFilterStateService {
|
|
|
6613
6155
|
// Fires whenever anything in the viewer state may have changed.
|
|
6614
6156
|
_stateChanged$ = new Subject();
|
|
6615
6157
|
// Counts the changes changes() reports. The group values, the grain and
|
|
6616
|
-
// the list state that
|
|
6158
|
+
// the list state that differsFromDefaults compares are the plain fields
|
|
6617
6159
|
// above, which every setter reports through changes() (they are shared
|
|
6618
6160
|
// with the inline values build, so they stay as they are); this counter is
|
|
6619
|
-
// how the
|
|
6161
|
+
// how the computed hears of them. The page, match and drills below are
|
|
6162
|
+
// signals and
|
|
6620
6163
|
// is read directly. The subject is this service's own and is never
|
|
6621
6164
|
// completed, so the signal listens to it exactly as long as the service
|
|
6622
6165
|
// lives.
|
|
@@ -6625,13 +6168,11 @@ class ReportFilterStateService {
|
|
|
6625
6168
|
manualCleanup: true,
|
|
6626
6169
|
});
|
|
6627
6170
|
_stateVersion = signal(0);
|
|
6628
|
-
_appliedVersion = signal(0);
|
|
6629
6171
|
_listSortResets = signal(new Map());
|
|
6630
|
-
// The report init() last read: the tiles and pages
|
|
6631
|
-
//
|
|
6172
|
+
// The report init() last read: the tiles and pages the state is checked
|
|
6173
|
+
// against.
|
|
6632
6174
|
_report = signal(null);
|
|
6633
|
-
// The open page, set by the host as the viewer moves between pages.
|
|
6634
|
-
// stores it, but moving to another page never edits a view.
|
|
6175
|
+
// The open page, set by the host as the viewer moves between pages.
|
|
6635
6176
|
_pageId = signal(null);
|
|
6636
6177
|
// How the groups combine: 'all' (the default) or 'any'.
|
|
6637
6178
|
_match = signal('all');
|
|
@@ -6640,11 +6181,8 @@ class ReportFilterStateService {
|
|
|
6640
6181
|
_drills = signal(new Map());
|
|
6641
6182
|
// Emits the id of the chart whose drill path changed.
|
|
6642
6183
|
_drillChanged$ = new Subject();
|
|
6643
|
-
// The base as apply() applied it, or null when the base is the authored
|
|
6644
|
-
// defaults (see base).
|
|
6645
|
-
_baseConfig = signal(null);
|
|
6646
6184
|
// The temporary layer: kept apart from everything above, so snapshot()
|
|
6647
|
-
// and
|
|
6185
|
+
// and differsFromDefaults never read it.
|
|
6648
6186
|
_temporary = signal(emptyTemporaryState());
|
|
6649
6187
|
// Seed groups + their defaults when a report loads. Existing session values
|
|
6650
6188
|
// for still-existing groups survive a structure re-fetch (a chat edit
|
|
@@ -6674,7 +6212,7 @@ class ReportFilterStateService {
|
|
|
6674
6212
|
}
|
|
6675
6213
|
this._lists = new Map(this._listComponents(report).map((component) => [component.id, component]));
|
|
6676
6214
|
this._prune(report);
|
|
6677
|
-
this.
|
|
6215
|
+
this._prunePageState(report);
|
|
6678
6216
|
this._changed$.next(null);
|
|
6679
6217
|
this._stateChanged$.next();
|
|
6680
6218
|
}
|
|
@@ -6701,7 +6239,7 @@ class ReportFilterStateService {
|
|
|
6701
6239
|
this._stateChanged$.next();
|
|
6702
6240
|
}
|
|
6703
6241
|
// A relative preset name as today's range in the report's zone, or null for
|
|
6704
|
-
// a name it does not know.
|
|
6242
|
+
// a name it does not know.
|
|
6705
6243
|
resolveRelative(name) {
|
|
6706
6244
|
return this._relativeRange(name);
|
|
6707
6245
|
}
|
|
@@ -6749,8 +6287,8 @@ class ReportFilterStateService {
|
|
|
6749
6287
|
// Whether a list shows the search box over its text columns: it does when the
|
|
6750
6288
|
// server answers a list search (see _serverSearches), unless one of its live
|
|
6751
6289
|
// filters belongs to a keyword group its header shows. A bar shows one search
|
|
6752
|
-
// box (its last keyword item), and that one is the author's. The header,
|
|
6753
|
-
//
|
|
6290
|
+
// box (its last keyword item), and that one is the author's. The header, the
|
|
6291
|
+
// pruning, the data request state and the exports all read
|
|
6754
6292
|
// this one rule, so no search narrows a list whose screen has no box showing
|
|
6755
6293
|
// it, and no export titles a search the server did not apply.
|
|
6756
6294
|
listSearchable(component) {
|
|
@@ -6810,7 +6348,7 @@ class ReportFilterStateService {
|
|
|
6810
6348
|
}
|
|
6811
6349
|
// Fires whenever the viewer state may have changed: a group value, the
|
|
6812
6350
|
// grain, the match, the page, a list sort, search or hidden columns, a
|
|
6813
|
-
// drill path,
|
|
6351
|
+
// drill path, a reset or a (re)load of the report.
|
|
6814
6352
|
changes() {
|
|
6815
6353
|
return this._stateChanged$.asObservable();
|
|
6816
6354
|
}
|
|
@@ -6949,117 +6487,86 @@ class ReportFilterStateService {
|
|
|
6949
6487
|
this._stateChanged$.next();
|
|
6950
6488
|
return true;
|
|
6951
6489
|
}
|
|
6952
|
-
// The durable state on screen as one
|
|
6953
|
-
//
|
|
6954
|
-
//
|
|
6955
|
-
//
|
|
6956
|
-
//
|
|
6957
|
-
//
|
|
6490
|
+
// The durable state on screen as one document: the page, every group the
|
|
6491
|
+
// report has (null when it holds nothing, a cleared default included), the
|
|
6492
|
+
// match, the grain, and each tile's sort, search, hidden columns and drill
|
|
6493
|
+
// path where they differ from the authored tile. A date range that still
|
|
6494
|
+
// carries its relative preset is written as that preset, whatever today is
|
|
6495
|
+
// (toStoredGroupValue). The temporary layer is never part of it.
|
|
6958
6496
|
snapshot() {
|
|
6959
|
-
const
|
|
6497
|
+
const snapshot = {};
|
|
6960
6498
|
if (this._pageId()) {
|
|
6961
|
-
|
|
6499
|
+
snapshot.pageId = this._pageId();
|
|
6962
6500
|
}
|
|
6963
6501
|
if (this._frequency) {
|
|
6964
|
-
|
|
6502
|
+
snapshot.groupBy = this._frequency;
|
|
6965
6503
|
}
|
|
6966
6504
|
if (this._match() === 'any') {
|
|
6967
|
-
|
|
6505
|
+
snapshot.match = 'any';
|
|
6968
6506
|
}
|
|
6969
|
-
|
|
6507
|
+
snapshot.groups = this._snapshotGroups();
|
|
6970
6508
|
const tiles = this._snapshotTiles();
|
|
6971
6509
|
if (Object.keys(tiles).length) {
|
|
6972
|
-
|
|
6510
|
+
snapshot.tiles = tiles;
|
|
6973
6511
|
}
|
|
6974
|
-
return
|
|
6512
|
+
return snapshot;
|
|
6975
6513
|
}
|
|
6976
|
-
// The report's authored defaults as a
|
|
6977
|
-
//
|
|
6514
|
+
// The report's authored defaults as a snapshot: every group at its authored
|
|
6515
|
+
// default (null when it has none), no match, grain or tile state.
|
|
6978
6516
|
authoredConfig() {
|
|
6979
6517
|
const groups = {};
|
|
6980
6518
|
this._groups.forEach((group) => {
|
|
6981
6519
|
groups[group.id] = toStoredGroupValue(group.type, this._defaultValue(group));
|
|
6982
6520
|
});
|
|
6983
|
-
return {
|
|
6984
|
-
}
|
|
6985
|
-
//
|
|
6986
|
-
//
|
|
6987
|
-
//
|
|
6988
|
-
//
|
|
6989
|
-
//
|
|
6990
|
-
|
|
6991
|
-
// the report opened with beats its default view's page). The temporary
|
|
6992
|
-
// layer is kept. Every change is announced once, at the end.
|
|
6993
|
-
apply(config, pageId = null) {
|
|
6994
|
-
const frequency = this._frequency;
|
|
6995
|
-
this._setBase(config);
|
|
6996
|
-
this._applyPage(config?.pageId);
|
|
6997
|
-
this._applyPage(pageId);
|
|
6998
|
-
this._appliedVersion.update((version) => version + 1);
|
|
6999
|
-
this._notifyApplied(frequency);
|
|
7000
|
-
}
|
|
7001
|
-
// Puts the screen back on its base (the open view, else the authored
|
|
7002
|
-
// defaults) on the page it is on, and clears the temporary layer. The
|
|
7003
|
-
// tiles stay and reload in place (appliedVersion does not move), except a
|
|
7004
|
-
// list whose sort this moved (listSortResets).
|
|
7005
|
-
resetToBase() {
|
|
6521
|
+
return { groups };
|
|
6522
|
+
}
|
|
6523
|
+
// Puts the screen back on the report's authored defaults on the page it is
|
|
6524
|
+
// on: every group at its default, match 'all', no grain, and every tile at
|
|
6525
|
+
// its authored sort, search, columns and top. Clears the temporary layer.
|
|
6526
|
+
// The tiles stay and reload in place, except a list whose sort this moved
|
|
6527
|
+
// (listSortResets). Every change is announced once, at the end.
|
|
6528
|
+
resetToDefaults() {
|
|
7006
6529
|
const frequency = this._frequency;
|
|
7007
6530
|
const sorts = new Map(this._listSorts);
|
|
7008
6531
|
this._temporary.set(emptyTemporaryState());
|
|
7009
|
-
this.
|
|
6532
|
+
this._groups.forEach((group) => this._seedAuthored(group));
|
|
6533
|
+
this._match.set('all');
|
|
6534
|
+
this._frequency = null;
|
|
6535
|
+
this._clearTiles();
|
|
7010
6536
|
this._countSortResets(sorts);
|
|
7011
|
-
this.
|
|
7012
|
-
}
|
|
7013
|
-
//
|
|
7014
|
-
//
|
|
7015
|
-
|
|
7016
|
-
|
|
7017
|
-
|
|
7018
|
-
|
|
7019
|
-
// The report's authored defaults become the base again (the open view was
|
|
7020
|
-
// deleted); the screen stays as it is.
|
|
7021
|
-
clearBase() {
|
|
7022
|
-
this._baseConfig.set(null);
|
|
7023
|
-
this._stateChanged$.next();
|
|
7024
|
-
}
|
|
7025
|
-
// Puts only `groupIds` back on the base: the open view's value for a group
|
|
7026
|
-
// it holds, else the group's authored default. A tile header's 'Clear
|
|
7027
|
-
// filters' owns only its own groups; the report bar's calls resetToBase().
|
|
7028
|
-
// Each group is announced as a viewer's change to it is, so the tiles in
|
|
7029
|
-
// it refetch and a bar showing it with another value is built again.
|
|
7030
|
-
resetGroupsToBase(groupIds) {
|
|
7031
|
-
const held = this._baseConfig()?.groups ?? {};
|
|
6537
|
+
this._notifyReset(frequency);
|
|
6538
|
+
}
|
|
6539
|
+
// Puts only `groupIds` back on their authored defaults. A tile header's
|
|
6540
|
+
// 'Clear filters' owns only its own groups; the report bar's calls
|
|
6541
|
+
// resetToDefaults(). Each group is announced as a viewer's change to it is,
|
|
6542
|
+
// so the tiles in it refetch and a bar showing it with another value is
|
|
6543
|
+
// built again.
|
|
6544
|
+
resetGroupsToDefaults(groupIds) {
|
|
7032
6545
|
groupIds.forEach((groupId) => {
|
|
7033
6546
|
const group = this._groups.get(groupId);
|
|
7034
6547
|
if (!group) {
|
|
7035
6548
|
return;
|
|
7036
6549
|
}
|
|
7037
|
-
|
|
7038
|
-
this._seedAuthored(group);
|
|
7039
|
-
}
|
|
6550
|
+
this._seedAuthored(group);
|
|
7040
6551
|
this._changed$.next(groupId);
|
|
7041
6552
|
});
|
|
7042
6553
|
this._stateChanged$.next();
|
|
7043
6554
|
}
|
|
7044
|
-
// Whether one of `groupIds` shows a value other than its
|
|
7045
|
-
//
|
|
7046
|
-
//
|
|
7047
|
-
//
|
|
7048
|
-
//
|
|
7049
|
-
|
|
7050
|
-
groupsDifferFromBase(groupIds) {
|
|
6555
|
+
// Whether one of `groupIds` shows a value other than its authored default,
|
|
6556
|
+
// the value resetGroupsToDefaults() puts back. A tile header's Clear chip
|
|
6557
|
+
// shows exactly while one of its own groups does. It reads the revision
|
|
6558
|
+
// differsFromDefaults reads, so a caller on each check of an OnPush view
|
|
6559
|
+
// stays current.
|
|
6560
|
+
groupsDifferFromDefaults(groupIds) {
|
|
7051
6561
|
this._revision();
|
|
7052
|
-
const held = this._baseConfig()?.groups ?? {};
|
|
7053
6562
|
return groupIds.some((groupId) => {
|
|
7054
6563
|
const group = this._groups.get(groupId);
|
|
7055
6564
|
if (!group) {
|
|
7056
6565
|
return false;
|
|
7057
6566
|
}
|
|
7058
|
-
const
|
|
7059
|
-
? held[groupId]
|
|
7060
|
-
: toStoredGroupValue(group.type, this._defaultValue(group));
|
|
6567
|
+
const authored = toStoredGroupValue(group.type, this._defaultValue(group));
|
|
7061
6568
|
const screen = this._cleared.has(groupId) ? null : toStoredGroupValue(group.type, this._values.get(groupId));
|
|
7062
|
-
return !sameStoredGroupValue(
|
|
6569
|
+
return !sameStoredGroupValue(authored, screen);
|
|
7063
6570
|
});
|
|
7064
6571
|
}
|
|
7065
6572
|
// The interaction state a tile's data request carries now: tileDataState
|
|
@@ -7326,77 +6833,6 @@ class ReportFilterStateService {
|
|
|
7326
6833
|
}
|
|
7327
6834
|
return tiles;
|
|
7328
6835
|
}
|
|
7329
|
-
// The view apply() was given, applied as the screen's state and kept as
|
|
7330
|
-
// the base. What is kept is the snapshot of it, with only the groups it
|
|
7331
|
-
// held: a group it did not hold, or held in a shape that does not fit,
|
|
7332
|
-
// follows its authored default (viewDiffers compares it with
|
|
7333
|
-
// authoredConfig()).
|
|
7334
|
-
_setBase(base) {
|
|
7335
|
-
const held = this._applyLayer(base);
|
|
7336
|
-
if (!base) {
|
|
7337
|
-
this._baseConfig.set(null);
|
|
7338
|
-
return;
|
|
7339
|
-
}
|
|
7340
|
-
// The page is the base's own, not the one on screen.
|
|
7341
|
-
const applied = this.snapshot();
|
|
7342
|
-
delete applied.pageId;
|
|
7343
|
-
applied.groups = Object.fromEntries(Object.entries(applied.groups ?? {})
|
|
7344
|
-
.filter(([groupId]) => held.has(Number(groupId))));
|
|
7345
|
-
if (base.pageId && (this._report()?.pages ?? []).some((page) => page.id === base.pageId)) {
|
|
7346
|
-
applied.pageId = base.pageId;
|
|
7347
|
-
}
|
|
7348
|
-
this._baseConfig.set(applied);
|
|
7349
|
-
}
|
|
7350
|
-
// Applies a view's state in place of the whole durable state, and gives the
|
|
7351
|
-
// ids of the groups it set: what `config` does not hold goes back to the
|
|
7352
|
-
// authored report. Nothing is announced here: the caller announces once
|
|
7353
|
-
// (_notifyApplied).
|
|
7354
|
-
_applyLayer(config) {
|
|
7355
|
-
const held = this._applyGroups(config?.groups ?? {});
|
|
7356
|
-
this._match.set(config?.match === 'any' ? 'any' : 'all');
|
|
7357
|
-
const groupBy = config?.groupBy;
|
|
7358
|
-
this._frequency = isChartGranularity(groupBy) ? groupBy : null;
|
|
7359
|
-
this._clearTiles();
|
|
7360
|
-
Object.entries(config?.tiles ?? {})
|
|
7361
|
-
.forEach(([componentId, tile]) => this._applyTile(Number(componentId), tile ?? {}));
|
|
7362
|
-
return held;
|
|
7363
|
-
}
|
|
7364
|
-
// Each group's value from a view; a group it does not set goes back to its
|
|
7365
|
-
// authored default.
|
|
7366
|
-
_applyGroups(groups) {
|
|
7367
|
-
const held = new Set();
|
|
7368
|
-
this._groups.forEach((group) => {
|
|
7369
|
-
if (Object.prototype.hasOwnProperty.call(groups, group.id) && this._applyGroup(group, groups[group.id])) {
|
|
7370
|
-
held.add(group.id);
|
|
7371
|
-
}
|
|
7372
|
-
else {
|
|
7373
|
-
this._seedAuthored(group);
|
|
7374
|
-
}
|
|
7375
|
-
});
|
|
7376
|
-
return held;
|
|
7377
|
-
}
|
|
7378
|
-
// One group's stored value, or false when the group's type cannot hold it
|
|
7379
|
-
// (it is then dropped whole, and the caller seeds the group's authored
|
|
7380
|
-
// default). null, or a value that holds nothing, clears the group.
|
|
7381
|
-
_applyGroup(group, stored) {
|
|
7382
|
-
const kept = stored === null ? {} : groupValueFromStored(stored, group.type);
|
|
7383
|
-
if (kept === null) {
|
|
7384
|
-
return false;
|
|
7385
|
-
}
|
|
7386
|
-
const value = toLiveGroupValue(group.type, kept, (name) => this.resolveRelative(name));
|
|
7387
|
-
if (value) {
|
|
7388
|
-
this._values.set(group.id, value);
|
|
7389
|
-
this._cleared.delete(group.id);
|
|
7390
|
-
}
|
|
7391
|
-
else {
|
|
7392
|
-
this._values.delete(group.id);
|
|
7393
|
-
this._cleared.delete(group.id);
|
|
7394
|
-
if (group.config?.default) {
|
|
7395
|
-
this._cleared.add(group.id);
|
|
7396
|
-
}
|
|
7397
|
-
}
|
|
7398
|
-
return true;
|
|
7399
|
-
}
|
|
7400
6836
|
// A group back at its authored default.
|
|
7401
6837
|
_seedAuthored(group) {
|
|
7402
6838
|
const value = this._defaultValue(group);
|
|
@@ -7415,54 +6851,6 @@ class ReportFilterStateService {
|
|
|
7415
6851
|
this.listHidden.set(new Map());
|
|
7416
6852
|
this._drills.set(new Map());
|
|
7417
6853
|
}
|
|
7418
|
-
// One tile's stored state, each key checked: a sort on a column the list
|
|
7419
|
-
// can sort by, a search on a list that shows the box, hidden columns the
|
|
7420
|
-
// list can hide, a drill path on a chart.
|
|
7421
|
-
_applyTile(componentId, tile) {
|
|
7422
|
-
const list = this._lists.get(componentId);
|
|
7423
|
-
if (list) {
|
|
7424
|
-
this._applyListTile(list, tile);
|
|
7425
|
-
}
|
|
7426
|
-
if (Array.isArray(tile.drill) && this._isChart(componentId)) {
|
|
7427
|
-
this._setDrillPath(componentId, cleanDrillPath(tile.drill));
|
|
7428
|
-
}
|
|
7429
|
-
}
|
|
7430
|
-
// A list's sort, search and hidden columns from a tile state, kept by the
|
|
7431
|
-
// rules setListSort, setListSearch and setHiddenColumns apply, but set
|
|
7432
|
-
// straight on the maps: those setters each announce a change, and an apply
|
|
7433
|
-
// announces once, at its end. A sort that is the list's authored first
|
|
7434
|
-
// term is its default, so it clears the entry.
|
|
7435
|
-
_applyListTile(list, tile) {
|
|
7436
|
-
const sort = tile.sort;
|
|
7437
|
-
if (sort?.column && this._sortableColumns(list).includes(sort.column)) {
|
|
7438
|
-
if (sameSort(sort, listSortTerms(list.config)[0] ?? null)) {
|
|
7439
|
-
this._listSorts.delete(list.id);
|
|
7440
|
-
}
|
|
7441
|
-
else {
|
|
7442
|
-
this._listSorts.set(list.id, { ...sort });
|
|
7443
|
-
}
|
|
7444
|
-
}
|
|
7445
|
-
if (typeof tile.search === 'string') {
|
|
7446
|
-
const search = listSearchText(tile.search);
|
|
7447
|
-
if (search && this.listSearchable(list)) {
|
|
7448
|
-
this._listSearches.set(list.id, search);
|
|
7449
|
-
}
|
|
7450
|
-
else {
|
|
7451
|
-
this._listSearches.delete(list.id);
|
|
7452
|
-
}
|
|
7453
|
-
}
|
|
7454
|
-
if (Array.isArray(tile.hidden)) {
|
|
7455
|
-
const hidden = listHiddenColumns(list.config, tile.hidden);
|
|
7456
|
-
const next = new Map(this.listHidden());
|
|
7457
|
-
if (hidden.length) {
|
|
7458
|
-
next.set(list.id, hidden);
|
|
7459
|
-
}
|
|
7460
|
-
else {
|
|
7461
|
-
next.delete(list.id);
|
|
7462
|
-
}
|
|
7463
|
-
this.listHidden.set(next);
|
|
7464
|
-
}
|
|
7465
|
-
}
|
|
7466
6854
|
// One chart's drill path; an empty path is the chart's top.
|
|
7467
6855
|
_setDrillPath(componentId, path) {
|
|
7468
6856
|
const next = new Map(this._drills());
|
|
@@ -7474,15 +6862,9 @@ class ReportFilterStateService {
|
|
|
7474
6862
|
}
|
|
7475
6863
|
this._drills.set(next);
|
|
7476
6864
|
}
|
|
7477
|
-
//
|
|
7478
|
-
_applyPage(pageId) {
|
|
7479
|
-
if (pageId && (this._report()?.pages ?? []).some((page) => page.id === pageId)) {
|
|
7480
|
-
this._pageId.set(pageId);
|
|
7481
|
-
}
|
|
7482
|
-
}
|
|
7483
|
-
// After apply() or resetToBase(): every bar and tile repaints, and the
|
|
6865
|
+
// After resetToDefaults(): every bar and tile repaints, and the
|
|
7484
6866
|
// time-series charts refetch when the grain moved.
|
|
7485
|
-
|
|
6867
|
+
_notifyReset(frequency) {
|
|
7486
6868
|
this._stateVersion.update((version) => version + 1);
|
|
7487
6869
|
this._changed$.next(null);
|
|
7488
6870
|
if (frequency !== this._frequency) {
|
|
@@ -7501,13 +6883,12 @@ class ReportFilterStateService {
|
|
|
7501
6883
|
return this._tiles()
|
|
7502
6884
|
.some((component) => component.id === componentId && component.type === 'chart');
|
|
7503
6885
|
}
|
|
7504
|
-
// Keeps the
|
|
7505
|
-
// drill paths on charts it still has, the page while it has it
|
|
7506
|
-
//
|
|
7507
|
-
//
|
|
7508
|
-
|
|
6886
|
+
// Keeps the page and drill state to what the report (re)loaded by init()
|
|
6887
|
+
// still has: drill paths on charts it still has, the page while it has it.
|
|
6888
|
+
// On another report it starts again: match 'all', no drill and nothing
|
|
6889
|
+
// temporary.
|
|
6890
|
+
_prunePageState(report) {
|
|
7509
6891
|
if (this._report()?.id !== report.id) {
|
|
7510
|
-
this._baseConfig.set(null);
|
|
7511
6892
|
this._match.set('all');
|
|
7512
6893
|
this._pageId.set(null);
|
|
7513
6894
|
this._drills.set(new Map());
|
|
@@ -7519,58 +6900,6 @@ class ReportFilterStateService {
|
|
|
7519
6900
|
if (this._pageId() && !(report.pages ?? []).some((page) => page.id === this._pageId())) {
|
|
7520
6901
|
this._pageId.set(null);
|
|
7521
6902
|
}
|
|
7522
|
-
this._pruneBase(previousGroups);
|
|
7523
|
-
}
|
|
7524
|
-
// Keeps the base to what the screen can still show, by the rules the live
|
|
7525
|
-
// state is pruned by (_prune, _seed and the drill pruning above), so an
|
|
7526
|
-
// edit of the report alone never reads as '(edited)'. A group whose
|
|
7527
|
-
// authored default changed leaves the base and follows the new default,
|
|
7528
|
-
// as the live value does (_seed re-seeds it); so does a group the report
|
|
7529
|
-
// no longer has. Each tile keeps only the state its component can still
|
|
7530
|
-
// take (_baseTile).
|
|
7531
|
-
_pruneBase(previousGroups) {
|
|
7532
|
-
const base = this._baseConfig();
|
|
7533
|
-
if (!base) {
|
|
7534
|
-
return;
|
|
7535
|
-
}
|
|
7536
|
-
const groups = Object.fromEntries(Object.entries(base.groups ?? {})
|
|
7537
|
-
.filter(([groupId]) => {
|
|
7538
|
-
const group = this._groups.get(Number(groupId));
|
|
7539
|
-
return !!group
|
|
7540
|
-
&& sameDefault(previousGroups.get(group.id)?.config?.default ?? null, group.config?.default ?? null);
|
|
7541
|
-
}));
|
|
7542
|
-
const tiles = Object.fromEntries(Object.entries(base.tiles ?? {})
|
|
7543
|
-
.map(([componentId, tile]) => [componentId, this._baseTile(Number(componentId), tile)])
|
|
7544
|
-
.filter(([, tile]) => Object.keys(tile).length));
|
|
7545
|
-
const pruned = { ...base, groups };
|
|
7546
|
-
delete pruned.tiles;
|
|
7547
|
-
if (Object.keys(tiles).length) {
|
|
7548
|
-
pruned.tiles = tiles;
|
|
7549
|
-
}
|
|
7550
|
-
this._baseConfig.set(pruned);
|
|
7551
|
-
}
|
|
7552
|
-
// A base tile's state, kept to what its component can still take: a sort
|
|
7553
|
-
// on a column the list can still sort by that is not now its authored
|
|
7554
|
-
// first term, a search while the list shows the box, the hidden columns
|
|
7555
|
-
// the list still has, a drill path while the component is a chart.
|
|
7556
|
-
_baseTile(componentId, tile) {
|
|
7557
|
-
const kept = {};
|
|
7558
|
-
const list = this._lists.get(componentId);
|
|
7559
|
-
const hidden = list && tile.hidden ? listHiddenColumns(list.config, tile.hidden) : [];
|
|
7560
|
-
if (list && tile.sort && this._sortableColumns(list).includes(tile.sort.column)
|
|
7561
|
-
&& !sameSort(tile.sort, listSortTerms(list.config)[0] ?? null)) {
|
|
7562
|
-
kept.sort = tile.sort;
|
|
7563
|
-
}
|
|
7564
|
-
if (list && tile.search && this.listSearchable(list)) {
|
|
7565
|
-
kept.search = tile.search;
|
|
7566
|
-
}
|
|
7567
|
-
if (hidden.length) {
|
|
7568
|
-
kept.hidden = hidden;
|
|
7569
|
-
}
|
|
7570
|
-
if (tile.drill?.length && this._isChart(componentId)) {
|
|
7571
|
-
kept.drill = tile.drill;
|
|
7572
|
-
}
|
|
7573
|
-
return kept;
|
|
7574
6903
|
}
|
|
7575
6904
|
// A select's values to leave out (only when it keeps none) and a number
|
|
7576
6905
|
// range's ends, an open end left out and 0 kept, written onto `fields`.
|
|
@@ -8337,8 +7666,8 @@ function finite(value) {
|
|
|
8337
7666
|
return typeof value === 'number' && Number.isFinite(value) ? value : null;
|
|
8338
7667
|
}
|
|
8339
7668
|
|
|
8340
|
-
// A sweep of changes (a few filters set in a row, every value of
|
|
8341
|
-
//
|
|
7669
|
+
// A sweep of changes (a few filters set in a row, every value of a picked
|
|
7670
|
+
// saved filter) settles for this long before a tile refetches, so the sweep costs one
|
|
8342
7671
|
// request, not one per change.
|
|
8343
7672
|
const TILE_REFRESH_DEBOUNCE_MS = 300;
|
|
8344
7673
|
// A tile's data as one stream: the first load at once, then one load per
|
|
@@ -8349,9 +7678,8 @@ const TILE_REFRESH_DEBOUNCE_MS = 300;
|
|
|
8349
7678
|
// So an answer to an older request never lands, not even while the next
|
|
8350
7679
|
// load still waits, and the bar stays on until the settled load answers.
|
|
8351
7680
|
// - Each load notes the state version it was sent under (stateVersion), and
|
|
8352
|
-
// its answer is dropped when the version has moved since.
|
|
8353
|
-
//
|
|
8354
|
-
// the load for the new state is already on its way.
|
|
7681
|
+
// its answer is dropped when the version has moved since. resetToDefaults()
|
|
7682
|
+
// moves the version and announces a full change together, so the load for the new state is already on its way.
|
|
8355
7683
|
// - A failed load ends only itself: its message comes through as the answer,
|
|
8356
7684
|
// and the next trigger loads again.
|
|
8357
7685
|
function tileLoads(refresh$, load, version) {
|
|
@@ -8941,8 +8269,8 @@ class ComponentListComponent {
|
|
|
8941
8269
|
// How many leading columns stay in view while the list scrolls sideways.
|
|
8942
8270
|
pinned = computed(() => listPinnedColumns(this.component().config));
|
|
8943
8271
|
columnClasses = columnClasses;
|
|
8944
|
-
// The config columns the viewer hid on this list (the Columns dialog
|
|
8945
|
-
//
|
|
8272
|
+
// The config columns the viewer hid on this list (the Columns dialog). fs-list
|
|
8273
|
+
// hides and shows them in place through [show], so the rows
|
|
8946
8274
|
// already fetched stand.
|
|
8947
8275
|
hidden = computed(() => new Set(this._filterState.hiddenColumns(this.component().id)));
|
|
8948
8276
|
totalLabel = LIST_TOTAL_LABEL;
|
|
@@ -10358,8 +9686,9 @@ class ReportComponentComponent {
|
|
|
10358
9686
|
// repaints the header and body.
|
|
10359
9687
|
filterConfig = signal(null);
|
|
10360
9688
|
data = signal(null);
|
|
10361
|
-
//
|
|
10362
|
-
//
|
|
9689
|
+
// A load shows a spinner centred on the tile. `loading` is the first load,
|
|
9690
|
+
// with nothing to show yet; a refetch keeps the data on screen, dimmed
|
|
9691
|
+
// under the spinner (`refreshing`). The title is never dimmed.
|
|
10363
9692
|
loading = signal(false);
|
|
10364
9693
|
refreshing = signal(false);
|
|
10365
9694
|
error = signal(null);
|
|
@@ -10383,7 +9712,7 @@ class ReportComponentComponent {
|
|
|
10383
9712
|
// The breadcrumb after the title while the chart is drilled ('2026', 'Q3'),
|
|
10384
9713
|
// one label per step of its path, in the report's locale. Empty at the top
|
|
10385
9714
|
// and while the viewer is not offered drill: a path an older server never
|
|
10386
|
-
// applies is kept
|
|
9715
|
+
// applies is kept in the state, but not shown.
|
|
10387
9716
|
drillCrumbs = computed(() => (this._offeredFeatures().includes('drill')
|
|
10388
9717
|
? drillCrumbLabels(this._filterState.drill(this.component?.id), this._filterState.formatContext())
|
|
10389
9718
|
: []));
|
|
@@ -10433,7 +9762,7 @@ class ReportComponentComponent {
|
|
|
10433
9762
|
if (this.component.type !== 'list' && !this.isText && isDrawn(this.component)) {
|
|
10434
9763
|
// One load stream (tileLoads): the first load at once, then one per
|
|
10435
9764
|
// settled sweep of changes, each cancelling the one in flight, and an
|
|
10436
|
-
// answer sent before a
|
|
9765
|
+
// answer sent before a reset is dropped.
|
|
10437
9766
|
tileLoads(this._refresh$, () => this._load(), () => this._filterState.stateVersion())
|
|
10438
9767
|
.pipe(tap$1((result) => this._loaded(result)), takeUntilDestroyed(this._destroyRef))
|
|
10439
9768
|
.subscribe();
|
|
@@ -10592,7 +9921,7 @@ class ReportComponentComponent {
|
|
|
10592
9921
|
return this.component.type === 'list' && listHideableColumns(this.component.config).length > 1;
|
|
10593
9922
|
}
|
|
10594
9923
|
// Which of a plain list's columns show. The choice is the viewer's (kept in
|
|
10595
|
-
// the report's state
|
|
9924
|
+
// the report's state, never saved to the report), and the
|
|
10596
9925
|
// list hides the columns in place, with no refetch.
|
|
10597
9926
|
chooseColumns() {
|
|
10598
9927
|
const columns = (this.component.config?.columns ?? [])
|
|
@@ -10816,25 +10145,20 @@ class ReportComponentComponent {
|
|
|
10816
10145
|
this._filterState.setListSearch(this.component.id, String(query?.[LIST_SEARCH_ITEM] ?? ''));
|
|
10817
10146
|
}
|
|
10818
10147
|
},
|
|
10819
|
-
// 'Clear filters' puts this header's own groups back on
|
|
10820
|
-
//
|
|
10821
|
-
//
|
|
10822
|
-
|
|
10823
|
-
clear: features.includes('views')
|
|
10824
|
-
? () => this._filterState.resetGroupsToBase(groups.map((group) => group.id))
|
|
10825
|
-
: undefined,
|
|
10148
|
+
// 'Clear filters' puts this header's own groups back on their authored
|
|
10149
|
+
// defaults. fs-filter reports its own cleared items (change) just
|
|
10150
|
+
// before this, so the reset wins.
|
|
10151
|
+
clear: () => this._filterState.resetGroupsToDefaults(groups.map((group) => group.id)),
|
|
10826
10152
|
// The chip shows exactly while one of those groups differs from what
|
|
10827
|
-
// that clear puts back (
|
|
10828
|
-
// compares the items with the values this header was built with,
|
|
10829
|
-
//
|
|
10830
|
-
// nothing.
|
|
10831
|
-
clearVisible:
|
|
10832
|
-
? () => this._filterState.groupsDifferFromBase(groups.map((group) => group.id))
|
|
10833
|
-
: undefined,
|
|
10153
|
+
// that clear puts back (groupsDifferFromDefaults). fs-filter's own rule
|
|
10154
|
+
// compares the items with the values this header was built with, which
|
|
10155
|
+
// may be the viewer's own picks, and would offer a clear that changes
|
|
10156
|
+
// nothing.
|
|
10157
|
+
clearVisible: () => this._filterState.groupsDifferFromDefaults(groups.map((group) => group.id)),
|
|
10834
10158
|
});
|
|
10835
10159
|
}
|
|
10836
|
-
//
|
|
10837
|
-
//
|
|
10160
|
+
// resetToDefaults() moves the state version and announces a full change
|
|
10161
|
+
// (null). The header is built again for the new version, and
|
|
10838
10162
|
// filterKey, which carries it, recreates the block: fs-filter reads its
|
|
10839
10163
|
// config once. Any other full change (a reload, the match) leaves it be.
|
|
10840
10164
|
// A change to one group the header shows with another value (another bar
|
|
@@ -10943,8 +10267,7 @@ class ReportComponentComponent {
|
|
|
10943
10267
|
}
|
|
10944
10268
|
// ----- data ----------------------------------------------------------------
|
|
10945
10269
|
// One request of the load stream (tileLoads), built from the state now. The
|
|
10946
|
-
//
|
|
10947
|
-
// data stays and the thin bar shows.
|
|
10270
|
+
// spinner shows either way; a tile with data on screen keeps it, dimmed.
|
|
10948
10271
|
_load() {
|
|
10949
10272
|
const first = !this.data();
|
|
10950
10273
|
this.loading.set(first);
|
|
@@ -10959,7 +10282,7 @@ class ReportComponentComponent {
|
|
|
10959
10282
|
return this._reportData.componentData(this.reportId, this.component.id, filters, state);
|
|
10960
10283
|
}
|
|
10961
10284
|
// A failed load clears the data with its message, so the next load starts
|
|
10962
|
-
// from
|
|
10285
|
+
// from an empty tile and never shows the data the failure replaced. A
|
|
10963
10286
|
// chart's answer gives its drill path.
|
|
10964
10287
|
_loaded(result) {
|
|
10965
10288
|
this.data.set(result.data);
|
|
@@ -10972,7 +10295,7 @@ class ReportComponentComponent {
|
|
|
10972
10295
|
}
|
|
10973
10296
|
// A chart takes the drill path its answer applied (adoptedDrillPath) while
|
|
10974
10297
|
// the viewer is offered drill; an older server applies none, and the path
|
|
10975
|
-
// stays
|
|
10298
|
+
// stays in the state. When the path it sent was cut, the viewer is told
|
|
10976
10299
|
// once (drillResetNoted): the shorter path is what the chart reads from
|
|
10977
10300
|
// now on, so the server cuts nothing again.
|
|
10978
10301
|
_adoptDrill(data) {
|
|
@@ -11012,14 +10335,14 @@ class ReportComponentComponent {
|
|
|
11012
10335
|
openAtPoint(anchor, click.pointer, this.zoom, () => this.markMenu?.openMenu(null));
|
|
11013
10336
|
}
|
|
11014
10337
|
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: ReportComponentComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
11015
|
-
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 }, 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 }, valueKeysElsewhere: { classPropertyName: "valueKeysElsewhere", publicName: "valueKeysElsewhere", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { selectComponent: "selectComponent", positionChanged: "positionChanged", openSettings: "openSettings", removed: "removed", valueSettings: "valueSettings", textSaved: "textSaved", valuesRemoved: "valuesRemoved" }, host: { properties: { "class.height-auto": "this.heightAuto" } }, viewQueries: [{ propertyName: "_text", first: true, predicate: ComponentTextComponent, descendants: true, isSignal: true }, { propertyName: "chartComponent", first: true, predicate: ComponentChartComponent, descendants: true }, { propertyName: "markMenu", first: true, predicate: ["markMenu"], descendants: true }, { propertyName: "markAnchor", first: true, predicate: ["markAnchor"], 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. The title shows its tokens filled, and a\n filter change repaints it in place (shownTitle). -->\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 <!-- While the chart is drilled the title leads its breadcrumb:\n 'Title \u203A 2026 \u203A Q3', the title as the viewer reads it\n (shownTitle). The title goes back to the top and each\n crumb to the period it names; the last is where the chart is.\n A long path is cut at its left end, so the crumbs nearest the\n chart stay in view. While the layout is edited the crumbs are\n plain text (the stylesheet; drillTo does nothing then). -->\n @if (drillCrumbs().length) {\n <span class=\"drill-path\">\n <span dir=\"ltr\">\n <a\n class=\"drill-crumb\"\n tabindex=\"0\"\n (click)=\"drillTo(0)\"\n (keydown.enter)=\"drillTo(0)\">\n {{ shownTitle }}\n </a>\n @for (label of drillCrumbs(); track $index; let last = $last) {\n <span class=\"drill-separator\">{{ drillSeparator }}</span>\n @if (last) {\n <span>{{ label }}</span>\n } @else {\n <a\n class=\"drill-crumb\"\n tabindex=\"0\"\n (click)=\"drillTo($index + 1)\"\n (keydown.enter)=\"drillTo($index + 1)\">\n {{ label }}\n </a>\n }\n }\n </span>\n </span>\n } @else {\n {{ shownTitle }}\n }\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 [fsMenuTriggerFor]=\"menu\"\n (pointerdown)=\"$event.stopPropagation()\"\n (mousedown)=\"stopCanvasPan($event)\">\n <mat-icon>more_vert</mat-icon>\n </button>\n <!-- Beside its trigger, inside the same block: an fs-menu that finds no\n trigger of its own draws one. -->\n <fs-menu #menu>\n <ng-template\n fs-menu-item\n [hidden]=\"!isText\"\n (click)=\"editText()\">\n <mat-icon>edit</mat-icon>\n <span>Edit text</span>\n </ng-template>\n <ng-template\n fs-menu-item\n [hidden]=\"!isText\"\n (click)=\"deleteText()\">\n <mat-icon>delete</mat-icon>\n <span>Delete</span>\n </ng-template>\n <ng-template\n fs-menu-item\n [hidden]=\"!drillCrumbs().length || editMode\"\n (click)=\"drillUp()\">\n <mat-icon>arrow_upward</mat-icon>\n <span>Drill up</span>\n </ng-template>\n <ng-template\n fs-menu-item\n [hidden]=\"!drillCrumbs().length || editMode\"\n (click)=\"drillTo(0)\">\n <mat-icon>vertical_align_top</mat-icon>\n <span>Back to top</span>\n </ng-template>\n <ng-template\n fs-menu-item\n [hidden]=\"isText\"\n (click)=\"settings()\">\n <mat-icon>tune</mat-icon>\n <span>Settings</span>\n </ng-template>\n <ng-template\n fs-menu-item\n [hidden]=\"isText\"\n (click)=\"exportCsv()\">\n <mat-icon>download</mat-icon>\n <span>Export CSV</span>\n </ng-template>\n <ng-template\n fs-menu-item\n [hidden]=\"!hidesColumns\"\n (click)=\"chooseColumns()\">\n <mat-icon>view_column</mat-icon>\n <span>Columns</span>\n </ng-template>\n </fs-menu>\n }\n <div class=\"component-body\">\n <!-- A refetch keeps the data on screen under a thin bar; the shimmer\n below is for the first load only. -->\n @if (refreshing()) {\n <mat-progress-bar\n class=\"refresh-bar\"\n aria-label=\"Refreshing data\"\n [mode]=\"'indeterminate'\">\n </mat-progress-bar>\n }\n @if (isText) {\n <app-report-component-text\n [html]=\"textHtml() ?? component.config?.html ?? ''\"\n [tokens]=\"tokens()\"\n [editing]=\"textEditing()\"\n [shownElsewhere]=\"shownElsewhere\"\n (saved)=\"onTextSaved($event)\"\n (editEnded)=\"textEditing.set(false)\"\n (valueSettings)=\"valueSettings.emit($event)\">\n </app-report-component-text>\n } @else if (component.type === 'list') {\n @for (key of [listResetKey()]; track key) {\n <app-report-component-list\n [reportId]=\"reportId\"\n [component]=\"component\">\n </app-report-component-list>\n }\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 {{ emptyMessage() }}\n </div>\n } @else if (component.type === 'kpi') {\n <app-report-component-kpi\n [component]=\"component\"\n [data]=\"data()\"\n (markClick)=\"onMarkClick($event)\">\n </app-report-component-kpi>\n } @else {\n <app-report-component-chart\n [component]=\"component\"\n [data]=\"data()\"\n [actions]=\"tileActions()\"\n [(legendSelected)]=\"legendSelected\"\n (markClick)=\"onMarkClick($event)\"\n (markMiss)=\"onMarkMiss()\">\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<!-- The mark menu, for a click whose mark offers two or more actions. It\n opens at the click: its trigger is a zero-size anchor that\n anchor-at-point.ts moves there first. -->\n@if (!isText && !editMode) {\n <span\n #markAnchor\n [style.position]=\"'absolute'\"\n [fsMenuTriggerFor]=\"markMenu\">\n </span>\n <fs-menu #markMenu>\n @for (action of markMenuActions(); track action) {\n <ng-template\n fs-menu-item\n (click)=\"runMarkAction(action)\">\n {{ markActionLabels[action] }}\n </ng-template>\n }\n </fs-menu>\n}\n<!-- Handles live OUTSIDE the clipped box so they straddle its edges. -->\n@if (editMode && selected) {\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}", 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}.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 .drill-crumb{pointer-events:none}.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-filter .component-title .drill-path{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;direction:rtl;text-align:left}.component-filter .component-title .drill-crumb{color:inherit;cursor:pointer;text-decoration:none}.component-filter .component-title .drill-crumb:hover,.component-filter .component-title .drill-crumb:focus-visible{text-decoration:underline}.component-filter .component-title .drill-separator{opacity:.6}.component-menu{position:absolute;top:2px;right:2px;z-index:30}.component-body{flex:1 1 auto;min-height:0;position:relative}.refresh-bar{position:absolute;top:0;left:0;right:0;z-index:1;pointer-events:none;--mdc-linear-progress-track-height: 2px;--mdc-linear-progress-active-indicator-height: 2px;--mat-progress-bar-track-height: 2px;--mat-progress-bar-active-indicator-height: 2px}: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: "component", type: MatProgressBar, selector: "mat-progress-bar", inputs: ["color", "value", "bufferValue", "mode"], outputs: ["animationEnd"], exportAs: ["matProgressBar"] }, { kind: "directive", type: MatTooltip, selector: "[matTooltip]", inputs: ["matTooltipPosition", "matTooltipPositionAtOrigin", "matTooltipDisabled", "matTooltipShowDelay", "matTooltipHideDelay", "matTooltipTouchGestures", "matTooltip", "matTooltipClass"], exportAs: ["matTooltip"] }, { kind: "ngmodule", type: FsFilterModule }, { kind: "component", type: i1$2.FilterComponent, selector: "fs-filter", inputs: ["config"], outputs: ["closed", "opened", "ready"] }, { kind: "directive", type: i1$2.FilterHeadingDirective, selector: "[fsFilterHeading]" }, { kind: "ngmodule", type: FsMenuModule }, { kind: "component", type: i2.FsMenuComponent, selector: "fs-menu", inputs: ["class", "buttonClass", "buttonType", "buttonColor"], outputs: ["opened", "closed"] }, { kind: "directive", type: i2.FsMenuItemDirective, selector: "fs-menu-group,[fs-menu-item]" }, { kind: "directive", type: i2.FsMenuTriggerDirective, selector: "[fsMenuTriggerFor]", inputs: ["fsMenuTriggerFor"] }, { kind: "component", type: ComponentChartComponent, selector: "app-report-component-chart", inputs: ["component", "data", "legendSelected", "actions"], outputs: ["legendSelectedChange", "markClick", "markMiss"] }, { kind: "component", type: ComponentKpiComponent, selector: "app-report-component-kpi", inputs: ["component", "data"], outputs: ["markClick"] }, { 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", "shownElsewhere"], outputs: ["saved", "editEnded", "valueSettings"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
10338
|
+
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 }, 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 }, valueKeysElsewhere: { classPropertyName: "valueKeysElsewhere", publicName: "valueKeysElsewhere", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { selectComponent: "selectComponent", positionChanged: "positionChanged", openSettings: "openSettings", removed: "removed", valueSettings: "valueSettings", textSaved: "textSaved", valuesRemoved: "valuesRemoved" }, host: { properties: { "class.height-auto": "this.heightAuto" } }, viewQueries: [{ propertyName: "_text", first: true, predicate: ComponentTextComponent, descendants: true, isSignal: true }, { propertyName: "chartComponent", first: true, predicate: ComponentChartComponent, descendants: true }, { propertyName: "markMenu", first: true, predicate: ["markMenu"], descendants: true }, { propertyName: "markAnchor", first: true, predicate: ["markAnchor"], 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 [class.refreshing]=\"refreshing()\"\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. The title shows its tokens filled, and a\n filter change repaints it in place (shownTitle). -->\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 <!-- While the chart is drilled the title leads its breadcrumb:\n 'Title \u203A 2026 \u203A Q3', the title as the viewer reads it\n (shownTitle). The title goes back to the top and each\n crumb to the period it names; the last is where the chart is.\n A long path is cut at its left end, so the crumbs nearest the\n chart stay in view. While the layout is edited the crumbs are\n plain text (the stylesheet; drillTo does nothing then). -->\n @if (drillCrumbs().length) {\n <span class=\"drill-path\">\n <span dir=\"ltr\">\n <a\n class=\"drill-crumb\"\n tabindex=\"0\"\n (click)=\"drillTo(0)\"\n (keydown.enter)=\"drillTo(0)\">\n {{ shownTitle }}\n </a>\n @for (label of drillCrumbs(); track $index; let last = $last) {\n <span class=\"drill-separator\">{{ drillSeparator }}</span>\n @if (last) {\n <span>{{ label }}</span>\n } @else {\n <a\n class=\"drill-crumb\"\n tabindex=\"0\"\n (click)=\"drillTo($index + 1)\"\n (keydown.enter)=\"drillTo($index + 1)\">\n {{ label }}\n </a>\n }\n }\n </span>\n </span>\n } @else {\n {{ shownTitle }}\n }\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 [fsMenuTriggerFor]=\"menu\"\n (pointerdown)=\"$event.stopPropagation()\"\n (mousedown)=\"stopCanvasPan($event)\">\n <mat-icon>more_vert</mat-icon>\n </button>\n <!-- Beside its trigger, inside the same block: an fs-menu that finds no\n trigger of its own draws one. -->\n <fs-menu #menu>\n <ng-template\n fs-menu-item\n [hidden]=\"!isText\"\n (click)=\"editText()\">\n <mat-icon>edit</mat-icon>\n <span>Edit text</span>\n </ng-template>\n <ng-template\n fs-menu-item\n [hidden]=\"!isText\"\n (click)=\"deleteText()\">\n <mat-icon>delete</mat-icon>\n <span>Delete</span>\n </ng-template>\n <ng-template\n fs-menu-item\n [hidden]=\"!drillCrumbs().length || editMode\"\n (click)=\"drillUp()\">\n <mat-icon>arrow_upward</mat-icon>\n <span>Drill up</span>\n </ng-template>\n <ng-template\n fs-menu-item\n [hidden]=\"!drillCrumbs().length || editMode\"\n (click)=\"drillTo(0)\">\n <mat-icon>vertical_align_top</mat-icon>\n <span>Back to top</span>\n </ng-template>\n <ng-template\n fs-menu-item\n [hidden]=\"isText\"\n (click)=\"settings()\">\n <mat-icon>tune</mat-icon>\n <span>Settings</span>\n </ng-template>\n <ng-template\n fs-menu-item\n [hidden]=\"isText\"\n (click)=\"exportCsv()\">\n <mat-icon>download</mat-icon>\n <span>Export CSV</span>\n </ng-template>\n <ng-template\n fs-menu-item\n [hidden]=\"!hidesColumns\"\n (click)=\"chooseColumns()\">\n <mat-icon>view_column</mat-icon>\n <span>Columns</span>\n </ng-template>\n </fs-menu>\n }\n <div class=\"component-body\">\n @if (isText) {\n <app-report-component-text\n [html]=\"textHtml() ?? component.config?.html ?? ''\"\n [tokens]=\"tokens()\"\n [editing]=\"textEditing()\"\n [shownElsewhere]=\"shownElsewhere\"\n (saved)=\"onTextSaved($event)\"\n (editEnded)=\"textEditing.set(false)\"\n (valueSettings)=\"valueSettings.emit($event)\">\n </app-report-component-text>\n } @else if (component.type === 'list') {\n @for (key of [listResetKey()]; track key) {\n <app-report-component-list\n [reportId]=\"reportId\"\n [component]=\"component\">\n </app-report-component-list>\n }\n } @else if (loading()) {\n <!-- Holds the tile's height for the spinner on the first load. -->\n <div class=\"component-state\"></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 {{ emptyMessage() }}\n </div>\n } @else if (component.type === 'kpi') {\n <app-report-component-kpi\n [component]=\"component\"\n [data]=\"data()\"\n (markClick)=\"onMarkClick($event)\">\n </app-report-component-kpi>\n } @else {\n <app-report-component-chart\n [component]=\"component\"\n [data]=\"data()\"\n [actions]=\"tileActions()\"\n [(legendSelected)]=\"legendSelected\"\n (markClick)=\"onMarkClick($event)\"\n (markMiss)=\"onMarkMiss()\">\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 <!-- A load: a spinner centred on the whole tile. On a refetch the body and\n the menu are dimmed under it (.refreshing); the title is not. -->\n @if (loading() || refreshing()) {\n <mat-progress-spinner\n class=\"load-spinner\"\n aria-label=\"Loading data\"\n mode=\"indeterminate\"\n [diameter]=\"15\">\n </mat-progress-spinner>\n }\n</div>\n<!-- The mark menu, for a click whose mark offers two or more actions. It\n opens at the click: its trigger is a zero-size anchor that\n anchor-at-point.ts moves there first. -->\n@if (!isText && !editMode) {\n <span\n #markAnchor\n [style.position]=\"'absolute'\"\n [fsMenuTriggerFor]=\"markMenu\">\n </span>\n <fs-menu #markMenu>\n @for (action of markMenuActions(); track action) {\n <ng-template\n fs-menu-item\n (click)=\"runMarkAction(action)\">\n {{ markActionLabels[action] }}\n </ng-template>\n }\n </fs-menu>\n}\n<!-- Handles live OUTSIDE the clipped box so they straddle its edges. -->\n@if (editMode && selected) {\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}", 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}.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 .drill-crumb{pointer-events:none}.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-filter .component-title .drill-path{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;direction:rtl;text-align:left}.component-filter .component-title .drill-crumb{color:inherit;cursor:pointer;text-decoration:none}.component-filter .component-title .drill-crumb:hover,.component-filter .component-title .drill-crumb:focus-visible{text-decoration:underline}.component-filter .component-title .drill-separator{opacity:.6}.component-menu{position:absolute;top:2px;right:2px;z-index:30}.component-body{flex:1 1 auto;min-height:0;position:relative}.component-box.refreshing .component-body,.component-box.refreshing .component-menu{opacity:.35;transition:opacity .15s ease}.load-spinner{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%) scale(calc(1 / var(--canvas-zoom, 1)));z-index:25;pointer-events:none}: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}.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: "component", type: MatProgressSpinner, selector: "mat-progress-spinner, mat-spinner", inputs: ["color", "mode", "value", "diameter", "strokeWidth"], exportAs: ["matProgressSpinner"] }, { kind: "directive", type: MatTooltip, selector: "[matTooltip]", inputs: ["matTooltipPosition", "matTooltipPositionAtOrigin", "matTooltipDisabled", "matTooltipShowDelay", "matTooltipHideDelay", "matTooltipTouchGestures", "matTooltip", "matTooltipClass"], exportAs: ["matTooltip"] }, { kind: "ngmodule", type: FsFilterModule }, { kind: "component", type: i1$2.FilterComponent, selector: "fs-filter", inputs: ["config"], outputs: ["closed", "opened", "ready"] }, { kind: "directive", type: i1$2.FilterHeadingDirective, selector: "[fsFilterHeading]" }, { kind: "ngmodule", type: FsMenuModule }, { kind: "component", type: i2.FsMenuComponent, selector: "fs-menu", inputs: ["class", "buttonClass", "buttonType", "buttonColor"], outputs: ["opened", "closed"] }, { kind: "directive", type: i2.FsMenuItemDirective, selector: "fs-menu-group,[fs-menu-item]" }, { kind: "directive", type: i2.FsMenuTriggerDirective, selector: "[fsMenuTriggerFor]", inputs: ["fsMenuTriggerFor"] }, { kind: "component", type: ComponentChartComponent, selector: "app-report-component-chart", inputs: ["component", "data", "legendSelected", "actions"], outputs: ["legendSelectedChange", "markClick", "markMiss"] }, { kind: "component", type: ComponentKpiComponent, selector: "app-report-component-kpi", inputs: ["component", "data"], outputs: ["markClick"] }, { 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", "shownElsewhere"], outputs: ["saved", "editEnded", "valueSettings"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
11016
10339
|
}
|
|
11017
10340
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: ReportComponentComponent, decorators: [{
|
|
11018
10341
|
type: Component,
|
|
11019
10342
|
args: [{ selector: 'app-report-component', changeDetection: ChangeDetectionStrategy.OnPush, standalone: true, imports: [
|
|
11020
10343
|
MatIcon,
|
|
11021
10344
|
MatIconButton,
|
|
11022
|
-
|
|
10345
|
+
MatProgressSpinner,
|
|
11023
10346
|
MatTooltip,
|
|
11024
10347
|
FsFilterModule,
|
|
11025
10348
|
FsMenuModule,
|
|
@@ -11027,7 +10350,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImpo
|
|
|
11027
10350
|
ComponentKpiComponent,
|
|
11028
10351
|
ComponentListComponent,
|
|
11029
10352
|
ComponentTextComponent,
|
|
11030
|
-
], 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. The title shows its tokens filled, and a\n filter change repaints it in place (shownTitle). -->\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 <!-- While the chart is drilled the title leads its breadcrumb:\n 'Title \u203A 2026 \u203A Q3', the title as the viewer reads it\n (shownTitle). The title goes back to the top and each\n crumb to the period it names; the last is where the chart is.\n A long path is cut at its left end, so the crumbs nearest the\n chart stay in view. While the layout is edited the crumbs are\n plain text (the stylesheet; drillTo does nothing then). -->\n @if (drillCrumbs().length) {\n <span class=\"drill-path\">\n <span dir=\"ltr\">\n <a\n class=\"drill-crumb\"\n tabindex=\"0\"\n (click)=\"drillTo(0)\"\n (keydown.enter)=\"drillTo(0)\">\n {{ shownTitle }}\n </a>\n @for (label of drillCrumbs(); track $index; let last = $last) {\n <span class=\"drill-separator\">{{ drillSeparator }}</span>\n @if (last) {\n <span>{{ label }}</span>\n } @else {\n <a\n class=\"drill-crumb\"\n tabindex=\"0\"\n (click)=\"drillTo($index + 1)\"\n (keydown.enter)=\"drillTo($index + 1)\">\n {{ label }}\n </a>\n }\n }\n </span>\n </span>\n } @else {\n {{ shownTitle }}\n }\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 [fsMenuTriggerFor]=\"menu\"\n (pointerdown)=\"$event.stopPropagation()\"\n (mousedown)=\"stopCanvasPan($event)\">\n <mat-icon>more_vert</mat-icon>\n </button>\n <!-- Beside its trigger, inside the same block: an fs-menu that finds no\n trigger of its own draws one. -->\n <fs-menu #menu>\n <ng-template\n fs-menu-item\n [hidden]=\"!isText\"\n (click)=\"editText()\">\n <mat-icon>edit</mat-icon>\n <span>Edit text</span>\n </ng-template>\n <ng-template\n fs-menu-item\n [hidden]=\"!isText\"\n (click)=\"deleteText()\">\n <mat-icon>delete</mat-icon>\n <span>Delete</span>\n </ng-template>\n <ng-template\n fs-menu-item\n [hidden]=\"!drillCrumbs().length || editMode\"\n (click)=\"drillUp()\">\n <mat-icon>arrow_upward</mat-icon>\n <span>Drill up</span>\n </ng-template>\n <ng-template\n fs-menu-item\n [hidden]=\"!drillCrumbs().length || editMode\"\n (click)=\"drillTo(0)\">\n <mat-icon>vertical_align_top</mat-icon>\n <span>Back to top</span>\n </ng-template>\n <ng-template\n fs-menu-item\n [hidden]=\"isText\"\n (click)=\"settings()\">\n <mat-icon>tune</mat-icon>\n <span>Settings</span>\n </ng-template>\n <ng-template\n fs-menu-item\n [hidden]=\"isText\"\n (click)=\"exportCsv()\">\n <mat-icon>download</mat-icon>\n <span>Export CSV</span>\n </ng-template>\n <ng-template\n fs-menu-item\n [hidden]=\"!hidesColumns\"\n (click)=\"chooseColumns()\">\n <mat-icon>view_column</mat-icon>\n <span>Columns</span>\n </ng-template>\n </fs-menu>\n }\n <div class=\"component-body\">\n <!-- A refetch keeps the data on screen under a thin bar; the shimmer\n below is for the first load only. -->\n @if (refreshing()) {\n <mat-progress-bar\n class=\"refresh-bar\"\n aria-label=\"Refreshing data\"\n [mode]=\"'indeterminate'\">\n </mat-progress-bar>\n }\n @if (isText) {\n <app-report-component-text\n [html]=\"textHtml() ?? component.config?.html ?? ''\"\n [tokens]=\"tokens()\"\n [editing]=\"textEditing()\"\n [shownElsewhere]=\"shownElsewhere\"\n (saved)=\"onTextSaved($event)\"\n (editEnded)=\"textEditing.set(false)\"\n (valueSettings)=\"valueSettings.emit($event)\">\n </app-report-component-text>\n } @else if (component.type === 'list') {\n @for (key of [listResetKey()]; track key) {\n <app-report-component-list\n [reportId]=\"reportId\"\n [component]=\"component\">\n </app-report-component-list>\n }\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 {{ emptyMessage() }}\n </div>\n } @else if (component.type === 'kpi') {\n <app-report-component-kpi\n [component]=\"component\"\n [data]=\"data()\"\n (markClick)=\"onMarkClick($event)\">\n </app-report-component-kpi>\n } @else {\n <app-report-component-chart\n [component]=\"component\"\n [data]=\"data()\"\n [actions]=\"tileActions()\"\n [(legendSelected)]=\"legendSelected\"\n (markClick)=\"onMarkClick($event)\"\n (markMiss)=\"onMarkMiss()\">\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<!-- The mark menu, for a click whose mark offers two or more actions. It\n opens at the click: its trigger is a zero-size anchor that\n anchor-at-point.ts moves there first. -->\n@if (!isText && !editMode) {\n <span\n #markAnchor\n [style.position]=\"'absolute'\"\n [fsMenuTriggerFor]=\"markMenu\">\n </span>\n <fs-menu #markMenu>\n @for (action of markMenuActions(); track action) {\n <ng-template\n fs-menu-item\n (click)=\"runMarkAction(action)\">\n {{ markActionLabels[action] }}\n </ng-template>\n }\n </fs-menu>\n}\n<!-- Handles live OUTSIDE the clipped box so they straddle its edges. -->\n@if (editMode && selected) {\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}", 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}.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 .drill-crumb{pointer-events:none}.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-filter .component-title .drill-path{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;direction:rtl;text-align:left}.component-filter .component-title .drill-crumb{color:inherit;cursor:pointer;text-decoration:none}.component-filter .component-title .drill-crumb:hover,.component-filter .component-title .drill-crumb:focus-visible{text-decoration:underline}.component-filter .component-title .drill-separator{opacity:.6}.component-menu{position:absolute;top:2px;right:2px;z-index:30}.component-body{flex:1 1 auto;min-height:0;position:relative}.refresh-bar{position:absolute;top:0;left:0;right:0;z-index:1;pointer-events:none;--mdc-linear-progress-track-height: 2px;--mdc-linear-progress-active-indicator-height: 2px;--mat-progress-bar-track-height: 2px;--mat-progress-bar-active-indicator-height: 2px}: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"] }]
|
|
10353
|
+
], template: "<div\n class=\"component-box\"\n [class.editing]=\"editMode\"\n [class.text]=\"isText\"\n [class.selected]=\"selected\"\n [class.refreshing]=\"refreshing()\"\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. The title shows its tokens filled, and a\n filter change repaints it in place (shownTitle). -->\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 <!-- While the chart is drilled the title leads its breadcrumb:\n 'Title \u203A 2026 \u203A Q3', the title as the viewer reads it\n (shownTitle). The title goes back to the top and each\n crumb to the period it names; the last is where the chart is.\n A long path is cut at its left end, so the crumbs nearest the\n chart stay in view. While the layout is edited the crumbs are\n plain text (the stylesheet; drillTo does nothing then). -->\n @if (drillCrumbs().length) {\n <span class=\"drill-path\">\n <span dir=\"ltr\">\n <a\n class=\"drill-crumb\"\n tabindex=\"0\"\n (click)=\"drillTo(0)\"\n (keydown.enter)=\"drillTo(0)\">\n {{ shownTitle }}\n </a>\n @for (label of drillCrumbs(); track $index; let last = $last) {\n <span class=\"drill-separator\">{{ drillSeparator }}</span>\n @if (last) {\n <span>{{ label }}</span>\n } @else {\n <a\n class=\"drill-crumb\"\n tabindex=\"0\"\n (click)=\"drillTo($index + 1)\"\n (keydown.enter)=\"drillTo($index + 1)\">\n {{ label }}\n </a>\n }\n }\n </span>\n </span>\n } @else {\n {{ shownTitle }}\n }\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 [fsMenuTriggerFor]=\"menu\"\n (pointerdown)=\"$event.stopPropagation()\"\n (mousedown)=\"stopCanvasPan($event)\">\n <mat-icon>more_vert</mat-icon>\n </button>\n <!-- Beside its trigger, inside the same block: an fs-menu that finds no\n trigger of its own draws one. -->\n <fs-menu #menu>\n <ng-template\n fs-menu-item\n [hidden]=\"!isText\"\n (click)=\"editText()\">\n <mat-icon>edit</mat-icon>\n <span>Edit text</span>\n </ng-template>\n <ng-template\n fs-menu-item\n [hidden]=\"!isText\"\n (click)=\"deleteText()\">\n <mat-icon>delete</mat-icon>\n <span>Delete</span>\n </ng-template>\n <ng-template\n fs-menu-item\n [hidden]=\"!drillCrumbs().length || editMode\"\n (click)=\"drillUp()\">\n <mat-icon>arrow_upward</mat-icon>\n <span>Drill up</span>\n </ng-template>\n <ng-template\n fs-menu-item\n [hidden]=\"!drillCrumbs().length || editMode\"\n (click)=\"drillTo(0)\">\n <mat-icon>vertical_align_top</mat-icon>\n <span>Back to top</span>\n </ng-template>\n <ng-template\n fs-menu-item\n [hidden]=\"isText\"\n (click)=\"settings()\">\n <mat-icon>tune</mat-icon>\n <span>Settings</span>\n </ng-template>\n <ng-template\n fs-menu-item\n [hidden]=\"isText\"\n (click)=\"exportCsv()\">\n <mat-icon>download</mat-icon>\n <span>Export CSV</span>\n </ng-template>\n <ng-template\n fs-menu-item\n [hidden]=\"!hidesColumns\"\n (click)=\"chooseColumns()\">\n <mat-icon>view_column</mat-icon>\n <span>Columns</span>\n </ng-template>\n </fs-menu>\n }\n <div class=\"component-body\">\n @if (isText) {\n <app-report-component-text\n [html]=\"textHtml() ?? component.config?.html ?? ''\"\n [tokens]=\"tokens()\"\n [editing]=\"textEditing()\"\n [shownElsewhere]=\"shownElsewhere\"\n (saved)=\"onTextSaved($event)\"\n (editEnded)=\"textEditing.set(false)\"\n (valueSettings)=\"valueSettings.emit($event)\">\n </app-report-component-text>\n } @else if (component.type === 'list') {\n @for (key of [listResetKey()]; track key) {\n <app-report-component-list\n [reportId]=\"reportId\"\n [component]=\"component\">\n </app-report-component-list>\n }\n } @else if (loading()) {\n <!-- Holds the tile's height for the spinner on the first load. -->\n <div class=\"component-state\"></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 {{ emptyMessage() }}\n </div>\n } @else if (component.type === 'kpi') {\n <app-report-component-kpi\n [component]=\"component\"\n [data]=\"data()\"\n (markClick)=\"onMarkClick($event)\">\n </app-report-component-kpi>\n } @else {\n <app-report-component-chart\n [component]=\"component\"\n [data]=\"data()\"\n [actions]=\"tileActions()\"\n [(legendSelected)]=\"legendSelected\"\n (markClick)=\"onMarkClick($event)\"\n (markMiss)=\"onMarkMiss()\">\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 <!-- A load: a spinner centred on the whole tile. On a refetch the body and\n the menu are dimmed under it (.refreshing); the title is not. -->\n @if (loading() || refreshing()) {\n <mat-progress-spinner\n class=\"load-spinner\"\n aria-label=\"Loading data\"\n mode=\"indeterminate\"\n [diameter]=\"15\">\n </mat-progress-spinner>\n }\n</div>\n<!-- The mark menu, for a click whose mark offers two or more actions. It\n opens at the click: its trigger is a zero-size anchor that\n anchor-at-point.ts moves there first. -->\n@if (!isText && !editMode) {\n <span\n #markAnchor\n [style.position]=\"'absolute'\"\n [fsMenuTriggerFor]=\"markMenu\">\n </span>\n <fs-menu #markMenu>\n @for (action of markMenuActions(); track action) {\n <ng-template\n fs-menu-item\n (click)=\"runMarkAction(action)\">\n {{ markActionLabels[action] }}\n </ng-template>\n }\n </fs-menu>\n}\n<!-- Handles live OUTSIDE the clipped box so they straddle its edges. -->\n@if (editMode && selected) {\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}", 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}.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 .drill-crumb{pointer-events:none}.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-filter .component-title .drill-path{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;direction:rtl;text-align:left}.component-filter .component-title .drill-crumb{color:inherit;cursor:pointer;text-decoration:none}.component-filter .component-title .drill-crumb:hover,.component-filter .component-title .drill-crumb:focus-visible{text-decoration:underline}.component-filter .component-title .drill-separator{opacity:.6}.component-menu{position:absolute;top:2px;right:2px;z-index:30}.component-body{flex:1 1 auto;min-height:0;position:relative}.component-box.refreshing .component-body,.component-box.refreshing .component-menu{opacity:.35;transition:opacity .15s ease}.load-spinner{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%) scale(calc(1 / var(--canvas-zoom, 1)));z-index:25;pointer-events:none}: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}.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"] }]
|
|
11031
10354
|
}], propDecorators: { reportId: [{
|
|
11032
10355
|
type: Input
|
|
11033
10356
|
}], component: [{
|
|
@@ -11116,8 +10439,8 @@ class ReportCanvasComponent {
|
|
|
11116
10439
|
// The report's structure changed here (a page was added) — the host re-fetches
|
|
11117
10440
|
// it (it owns the assembled report + filter state).
|
|
11118
10441
|
reportChanged = new EventEmitter();
|
|
11119
|
-
// The page on screen changed — the host keeps it in the report's state
|
|
11120
|
-
//
|
|
10442
|
+
// The page on screen changed — the host keeps it in the report's state.
|
|
10443
|
+
// Carries the page id, never the index.
|
|
11121
10444
|
pageChanged = new EventEmitter();
|
|
11122
10445
|
zoomPan;
|
|
11123
10446
|
// The rendered components of the active page — only the canvas can reach the
|
|
@@ -11139,10 +10462,6 @@ class ReportCanvasComponent {
|
|
|
11139
10462
|
masterMode = signal(false);
|
|
11140
10463
|
groups = new Map();
|
|
11141
10464
|
selectedComponentId = signal(null);
|
|
11142
|
-
// Moves when a view is opened. Every tile is tracked by it, so each is
|
|
11143
|
-
// drawn again from the view's state. A reset to the open view leaves it:
|
|
11144
|
-
// the tiles reload in place and keep their data on screen meanwhile.
|
|
11145
|
-
appliedVersion = inject(ReportFilterStateService).appliedVersion;
|
|
11146
10465
|
// The components each layer draws. A value only reads inside text, so one
|
|
11147
10466
|
// that leaked into a layer's components is still never drawn.
|
|
11148
10467
|
activePageComponents = computed(() => (this.pages()[this.activePageIndex()]?.components ?? []).filter(isDrawn));
|
|
@@ -11565,7 +10884,7 @@ class ReportCanvasComponent {
|
|
|
11565
10884
|
this._watchOptionNames();
|
|
11566
10885
|
}
|
|
11567
10886
|
// The filter tokens follow every change of a filter's value, however it
|
|
11568
|
-
// comes: the bar, a saved
|
|
10887
|
+
// comes: the bar, a saved filter picked, Clear, a reload. changes()
|
|
11569
10888
|
// also announces what no token reads (a list's sort or search, its hidden
|
|
11570
10889
|
// columns, a drill, the page), which moves nothing here.
|
|
11571
10890
|
_watchFilters() {
|
|
@@ -11618,8 +10937,8 @@ class ReportCanvasComponent {
|
|
|
11618
10937
|
// Triggers landing together (a reload that also changes the page) make one
|
|
11619
10938
|
// request, and a newer request drops the one still in flight. Any other
|
|
11620
10939
|
// change of the screen's state (changes) asks again only when it moved
|
|
11621
|
-
// the request:
|
|
11622
|
-
//
|
|
10940
|
+
// the request: the bar reset (stateVersion moves on
|
|
10941
|
+
// it), the match changed, or, while the groups match 'any', a group the
|
|
11623
10942
|
// values have no member of took or lost a value.
|
|
11624
10943
|
_watchValues(layer, onScreen$) {
|
|
11625
10944
|
merge(layer.reload$, onScreen$, this._filterState.changesForAny(() => layer.source()?.values ?? []).pipe(debounceTime(300)), this._filterState.changes().pipe(debounceTime(300), filter$1(() => this._requestMoved(layer))))
|
|
@@ -11894,7 +11213,7 @@ class ReportCanvasComponent {
|
|
|
11894
11213
|
this._guides?.nativeElement?.replaceChildren();
|
|
11895
11214
|
}
|
|
11896
11215
|
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: ReportCanvasComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
11897
|
-
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) {\n <div\n class=\"page\"\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.--canvas-zoom]=\"zoom()\"\n [style.--report-heading-size.px]=\"headingSizePx\">\n <!-- The header and footer: the master's components, drawn under the\n page's own. -->\n @if (isMasterShown && masterComponents().length) {\n <div\n class=\"master-layer\"\n [class.editable]=\"isMasterEditing\">\n @for (component of masterComponents(); track component.id + ':' + appliedVersion()) {\n <app-report-component\n [reportId]=\"report.id\"\n [component]=\"component\"\n [groups]=\"groups\"\n [editMode]=\"isMasterEditing\"\n [zoom]=\"zoom()\"\n [snapper]=\"snapper\"\n [tokens]=\"masterTokens()\"\n [valueKeysElsewhere]=\"valueKeysElsewhere\"\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 (valueSettings)=\"openValueSettings(component, $event)\"\n (textSaved)=\"onTextSaved(component, $event)\"\n (valuesRemoved)=\"reportChanged.emit()\">\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 @for (component of activePageComponents(); track component.id + ':' + appliedVersion()) {\n <app-report-component\n [reportId]=\"report.id\"\n [component]=\"component\"\n [groups]=\"groups\"\n [editMode]=\"isPageEditing\"\n [zoom]=\"zoom()\"\n [snapper]=\"snapper\"\n [tokens]=\"pageTokens()\"\n [valueKeysElsewhere]=\"valueKeysElsewhere\"\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 (valueSettings)=\"openValueSettings(component, $event)\"\n (textSaved)=\"onTextSaved(component, $event)\"\n (valuesRemoved)=\"reportChanged.emit()\">\n </app-report-component>\n }\n\n <div\n #guides\n class=\"guides\">\n </div>\n\n @if (!activePageComponents().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.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.FsMenuComponent, selector: "fs-menu", inputs: ["class", "buttonClass", "buttonType", "buttonColor"], outputs: ["opened", "closed"] }, { kind: "directive", type: i2.FsMenuItemDirective, selector: "fs-menu-group,[fs-menu-item]" }, { kind: "directive", type: i2.FsMenuTriggerDirective, selector: "[fsMenuTriggerFor]", inputs: ["fsMenuTriggerFor"] }, { kind: "ngmodule", type: FsZoomPanModule }, { kind: "component", type: i3$1.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", "zoom", "snapper", "selected", "tokens", "valueKeysElsewhere"], outputs: ["selectComponent", "positionChanged", "openSettings", "removed", "valueSettings", "textSaved", "valuesRemoved"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
11216
|
+
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) {\n <div\n class=\"page\"\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.--canvas-zoom]=\"zoom()\"\n [style.--report-heading-size.px]=\"headingSizePx\">\n <!-- The header and footer: the master's components, drawn under the\n page's own. -->\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 [zoom]=\"zoom()\"\n [snapper]=\"snapper\"\n [tokens]=\"masterTokens()\"\n [valueKeysElsewhere]=\"valueKeysElsewhere\"\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 (valueSettings)=\"openValueSettings(component, $event)\"\n (textSaved)=\"onTextSaved(component, $event)\"\n (valuesRemoved)=\"reportChanged.emit()\">\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 @for (component of activePageComponents(); track component.id) {\n <app-report-component\n [reportId]=\"report.id\"\n [component]=\"component\"\n [groups]=\"groups\"\n [editMode]=\"isPageEditing\"\n [zoom]=\"zoom()\"\n [snapper]=\"snapper\"\n [tokens]=\"pageTokens()\"\n [valueKeysElsewhere]=\"valueKeysElsewhere\"\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 (valueSettings)=\"openValueSettings(component, $event)\"\n (textSaved)=\"onTextSaved(component, $event)\"\n (valuesRemoved)=\"reportChanged.emit()\">\n </app-report-component>\n }\n\n <div\n #guides\n class=\"guides\">\n </div>\n\n @if (!activePageComponents().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.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.FsMenuComponent, selector: "fs-menu", inputs: ["class", "buttonClass", "buttonType", "buttonColor"], outputs: ["opened", "closed"] }, { kind: "directive", type: i2.FsMenuItemDirective, selector: "fs-menu-group,[fs-menu-item]" }, { kind: "directive", type: i2.FsMenuTriggerDirective, selector: "[fsMenuTriggerFor]", inputs: ["fsMenuTriggerFor"] }, { kind: "ngmodule", type: FsZoomPanModule }, { kind: "component", type: i3$1.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", "zoom", "snapper", "selected", "tokens", "valueKeysElsewhere"], outputs: ["selectComponent", "positionChanged", "openSettings", "removed", "valueSettings", "textSaved", "valuesRemoved"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
11898
11217
|
}
|
|
11899
11218
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: ReportCanvasComponent, decorators: [{
|
|
11900
11219
|
type: Component,
|
|
@@ -11905,7 +11224,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImpo
|
|
|
11905
11224
|
FsMenuModule,
|
|
11906
11225
|
FsZoomPanModule,
|
|
11907
11226
|
ReportComponentComponent,
|
|
11908
|
-
], 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) {\n <div\n class=\"page\"\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.--canvas-zoom]=\"zoom()\"\n [style.--report-heading-size.px]=\"headingSizePx\">\n <!-- The header and footer: the master's components, drawn under the\n page's own. -->\n @if (isMasterShown && masterComponents().length) {\n <div\n class=\"master-layer\"\n [class.editable]=\"isMasterEditing\">\n @for (component of masterComponents(); track component.id + ':' + appliedVersion()) {\n <app-report-component\n [reportId]=\"report.id\"\n [component]=\"component\"\n [groups]=\"groups\"\n [editMode]=\"isMasterEditing\"\n [zoom]=\"zoom()\"\n [snapper]=\"snapper\"\n [tokens]=\"masterTokens()\"\n [valueKeysElsewhere]=\"valueKeysElsewhere\"\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 (valueSettings)=\"openValueSettings(component, $event)\"\n (textSaved)=\"onTextSaved(component, $event)\"\n (valuesRemoved)=\"reportChanged.emit()\">\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 @for (component of activePageComponents(); track component.id + ':' + appliedVersion()) {\n <app-report-component\n [reportId]=\"report.id\"\n [component]=\"component\"\n [groups]=\"groups\"\n [editMode]=\"isPageEditing\"\n [zoom]=\"zoom()\"\n [snapper]=\"snapper\"\n [tokens]=\"pageTokens()\"\n [valueKeysElsewhere]=\"valueKeysElsewhere\"\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 (valueSettings)=\"openValueSettings(component, $event)\"\n (textSaved)=\"onTextSaved(component, $event)\"\n (valuesRemoved)=\"reportChanged.emit()\">\n </app-report-component>\n }\n\n <div\n #guides\n class=\"guides\">\n </div>\n\n @if (!activePageComponents().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.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"] }]
|
|
11227
|
+
], 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) {\n <div\n class=\"page\"\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.--canvas-zoom]=\"zoom()\"\n [style.--report-heading-size.px]=\"headingSizePx\">\n <!-- The header and footer: the master's components, drawn under the\n page's own. -->\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 [zoom]=\"zoom()\"\n [snapper]=\"snapper\"\n [tokens]=\"masterTokens()\"\n [valueKeysElsewhere]=\"valueKeysElsewhere\"\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 (valueSettings)=\"openValueSettings(component, $event)\"\n (textSaved)=\"onTextSaved(component, $event)\"\n (valuesRemoved)=\"reportChanged.emit()\">\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 @for (component of activePageComponents(); track component.id) {\n <app-report-component\n [reportId]=\"report.id\"\n [component]=\"component\"\n [groups]=\"groups\"\n [editMode]=\"isPageEditing\"\n [zoom]=\"zoom()\"\n [snapper]=\"snapper\"\n [tokens]=\"pageTokens()\"\n [valueKeysElsewhere]=\"valueKeysElsewhere\"\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 (valueSettings)=\"openValueSettings(component, $event)\"\n (textSaved)=\"onTextSaved(component, $event)\"\n (valuesRemoved)=\"reportChanged.emit()\">\n </app-report-component>\n }\n\n <div\n #guides\n class=\"guides\">\n </div>\n\n @if (!activePageComponents().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.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"] }]
|
|
11909
11228
|
}], ctorParameters: () => [], propDecorators: { report: [{
|
|
11910
11229
|
type: Input
|
|
11911
11230
|
}], editMode: [{
|
|
@@ -13377,213 +12696,6 @@ function nameInputId(groupId) {
|
|
|
13377
12696
|
return `filterName${groupId}`;
|
|
13378
12697
|
}
|
|
13379
12698
|
|
|
13380
|
-
// The open report's saved views, per open report beside the state service:
|
|
13381
|
-
// the list the viewer may see, the view that is open, the order a report
|
|
13382
|
-
// opens in and the calls that change a view. The viewer state stays
|
|
13383
|
-
// ReportFilterStateService's: this service puts a view on screen through
|
|
13384
|
-
// apply() and reads the screen through snapshot(), base and viewChanged.
|
|
13385
|
-
// Nothing here runs for a report whose server does not list 'views'
|
|
13386
|
-
// (serves()). A view is opened from the Views menu only: a report keeps no
|
|
13387
|
-
// state in the browser URL, so there is no link to a view.
|
|
13388
|
-
//
|
|
13389
|
-
// The order a report opens in (open(), on its first load only):
|
|
13390
|
-
// 1. The viewer's default view.
|
|
13391
|
-
// 2. Else the report's authored defaults.
|
|
13392
|
-
// A ?page= the report opened with beats the default view's page.
|
|
13393
|
-
class ReportViewService {
|
|
13394
|
-
// The views the viewer may see, names and flags only: its own first, then
|
|
13395
|
-
// the ones others share. Empty until the list is read.
|
|
13396
|
-
views = computed(() => this._views());
|
|
13397
|
-
// The viewer may share its views with everyone.
|
|
13398
|
-
canShare = computed(() => this._canShare());
|
|
13399
|
-
// The open view, with its config as it was read or saved; null while the
|
|
13400
|
-
// screen is on the report's defaults.
|
|
13401
|
-
openView = computed(() => this._openView());
|
|
13402
|
-
// Whether the screen differs from the open view; false with none open.
|
|
13403
|
-
viewChanged = computed(() => !!this._openView() && this._filterState.viewChanged());
|
|
13404
|
-
_reportData = inject(ReportData);
|
|
13405
|
-
_filterState = inject(ReportFilterStateService);
|
|
13406
|
-
_message = inject(FsMessage);
|
|
13407
|
-
_destroyRef = inject(DestroyRef);
|
|
13408
|
-
_views = signal([]);
|
|
13409
|
-
_canShare = signal(false);
|
|
13410
|
-
_openView = signal(null);
|
|
13411
|
-
// The report the views belong to.
|
|
13412
|
-
_reportId = null;
|
|
13413
|
-
// Whether the open report's server keeps saved views.
|
|
13414
|
-
serves(report) {
|
|
13415
|
-
return !!report?.viewerFeatures?.includes('views');
|
|
13416
|
-
}
|
|
13417
|
-
// A report's first load, in the open order above: the state service is
|
|
13418
|
-
// set up for `report` (init) once the list is read, and the screen opened
|
|
13419
|
-
// on the viewer's default view, else on the report's defaults. `pageId` is
|
|
13420
|
-
// the ?page= the report opened with, which beats the view's page. Ends once
|
|
13421
|
-
// the screen is set.
|
|
13422
|
-
open(report, pageId) {
|
|
13423
|
-
this._start(report);
|
|
13424
|
-
return this._readViews(report)
|
|
13425
|
-
.pipe(tap((list) => {
|
|
13426
|
-
this._filterState.init(report);
|
|
13427
|
-
if (list?.myDefault) {
|
|
13428
|
-
this._openOn(list.myDefault, () => this._filterState.apply(list.myDefault.config, pageId));
|
|
13429
|
-
}
|
|
13430
|
-
}), map(() => undefined));
|
|
13431
|
-
}
|
|
13432
|
-
// A later load of the open report (a chat edit, a settings save): the
|
|
13433
|
-
// screen and the open view stay, and the list is read again behind them so
|
|
13434
|
-
// it names what the server holds now.
|
|
13435
|
-
refresh(report) {
|
|
13436
|
-
this._start(report);
|
|
13437
|
-
this._readViews(report)
|
|
13438
|
-
.pipe(takeUntilDestroyed(this._destroyRef))
|
|
13439
|
-
.subscribe();
|
|
13440
|
-
}
|
|
13441
|
-
// Opens a view picked from the list; its config is read now.
|
|
13442
|
-
pick(reportViewId) {
|
|
13443
|
-
return this._reportData.view(this._reportId, reportViewId)
|
|
13444
|
-
.pipe(tap((view) => this._openOn(view, () => this._filterState.apply(view.config))));
|
|
13445
|
-
}
|
|
13446
|
-
// Closes the open view and puts the screen on the report's defaults.
|
|
13447
|
-
openDefaults() {
|
|
13448
|
-
this._openView.set(null);
|
|
13449
|
-
this._filterState.apply(null);
|
|
13450
|
-
}
|
|
13451
|
-
// Saves the screen as a new view, which becomes the open view. The screen
|
|
13452
|
-
// is already the view, so nothing is applied again.
|
|
13453
|
-
saveAsNew(view) {
|
|
13454
|
-
return this._reportData.createView(this._reportId, { ...view, config: this._filterState.snapshot() })
|
|
13455
|
-
.pipe(tap((saved) => {
|
|
13456
|
-
this._openView.set(saved);
|
|
13457
|
-
this._putRow(saved);
|
|
13458
|
-
this._filterState.setBaseToScreen();
|
|
13459
|
-
this._message.success('View saved');
|
|
13460
|
-
}));
|
|
13461
|
-
}
|
|
13462
|
-
// Saves the screen over the open view, keeping the keys this viewer does
|
|
13463
|
-
// not know (withStoredKeys).
|
|
13464
|
-
saveChanges() {
|
|
13465
|
-
const open = this._openView();
|
|
13466
|
-
if (!open) {
|
|
13467
|
-
return EMPTY;
|
|
13468
|
-
}
|
|
13469
|
-
return this._reportData.updateView(this._reportId, open.id, { config: withStoredKeys(open.config, this._filterState.snapshot()) })
|
|
13470
|
-
.pipe(tap((saved) => {
|
|
13471
|
-
this._openView.set(saved);
|
|
13472
|
-
this._putRow(saved);
|
|
13473
|
-
this._filterState.setBaseToScreen();
|
|
13474
|
-
this._message.success('View saved');
|
|
13475
|
-
}));
|
|
13476
|
-
}
|
|
13477
|
-
rename(name) {
|
|
13478
|
-
return this._changeOpen((reportViewId) => this._reportData.updateView(this._reportId, reportViewId, { name }));
|
|
13479
|
-
}
|
|
13480
|
-
// Shares the open view with everyone who may open the report, or stops.
|
|
13481
|
-
share(shared) {
|
|
13482
|
-
return this._changeOpen((reportViewId) => this._reportData.shareView(this._reportId, reportViewId, shared));
|
|
13483
|
-
}
|
|
13484
|
-
// Makes the open view the viewer's default, or stops opening it by default.
|
|
13485
|
-
setDefault(personalDefault) {
|
|
13486
|
-
return this._changeOpen((reportViewId) => this._reportData.setViewDefault(this._reportId, reportViewId, personalDefault));
|
|
13487
|
-
}
|
|
13488
|
-
// Deletes the open view. The screen stays; the report's defaults become
|
|
13489
|
-
// what it is compared with.
|
|
13490
|
-
deleteOpen() {
|
|
13491
|
-
const open = this._openView();
|
|
13492
|
-
if (!open) {
|
|
13493
|
-
return EMPTY;
|
|
13494
|
-
}
|
|
13495
|
-
return this._reportData.deleteView(this._reportId, open.id)
|
|
13496
|
-
.pipe(tap(() => {
|
|
13497
|
-
this._openView.set(null);
|
|
13498
|
-
this._views.update((views) => views.filter((view) => view.id !== open.id));
|
|
13499
|
-
this._filterState.clearBase();
|
|
13500
|
-
this._message.success('View deleted');
|
|
13501
|
-
}));
|
|
13502
|
-
}
|
|
13503
|
-
// Starts over for another report: no list and no open view.
|
|
13504
|
-
_start(report) {
|
|
13505
|
-
if (report.id === this._reportId) {
|
|
13506
|
-
return;
|
|
13507
|
-
}
|
|
13508
|
-
this._reportId = report.id;
|
|
13509
|
-
this._views.set([]);
|
|
13510
|
-
this._canShare.set(false);
|
|
13511
|
-
this._openView.set(null);
|
|
13512
|
-
}
|
|
13513
|
-
// Makes `view` the open view, puts it on screen (`applyView`), and says
|
|
13514
|
-
// how many of its settings no longer apply (and to save it, when the
|
|
13515
|
-
// viewer may).
|
|
13516
|
-
_openOn(view, applyView) {
|
|
13517
|
-
this._openView.set(view);
|
|
13518
|
-
applyView();
|
|
13519
|
-
const note = viewDroppedMessage(viewDroppedCount(view, this._filterState.base()), view.canChange);
|
|
13520
|
-
if (note) {
|
|
13521
|
-
this._message.warning(note);
|
|
13522
|
-
}
|
|
13523
|
-
}
|
|
13524
|
-
// Runs a change on the open view and takes its answer as the open view
|
|
13525
|
-
// and its row in the list.
|
|
13526
|
-
_changeOpen(change) {
|
|
13527
|
-
const open = this._openView();
|
|
13528
|
-
if (!open) {
|
|
13529
|
-
return EMPTY;
|
|
13530
|
-
}
|
|
13531
|
-
return change(open.id)
|
|
13532
|
-
.pipe(tap((view) => {
|
|
13533
|
-
this._openView.set(view);
|
|
13534
|
-
this._putRow(view);
|
|
13535
|
-
}));
|
|
13536
|
-
}
|
|
13537
|
-
// A view's row in the list, replaced or added. A new default clears the
|
|
13538
|
-
// flag on the viewer's other views, as the server does. A new row keeps
|
|
13539
|
-
// the list's order: the viewer's own views first, then the ones others
|
|
13540
|
-
// share.
|
|
13541
|
-
_putRow(view) {
|
|
13542
|
-
const summary = viewSummary(view);
|
|
13543
|
-
this._views.update((views) => {
|
|
13544
|
-
const rows = views.map((row) => (view.personalDefault && row.personalDefault ? { ...row, personalDefault: false } : row));
|
|
13545
|
-
const index = rows.findIndex((row) => row.id === view.id);
|
|
13546
|
-
if (index >= 0) {
|
|
13547
|
-
return rows.map((row, at) => (at === index ? summary : row));
|
|
13548
|
-
}
|
|
13549
|
-
const othersAt = view.mine ? rows.findIndex((row) => !row.mine) : -1;
|
|
13550
|
-
return othersAt < 0
|
|
13551
|
-
? [...rows, summary]
|
|
13552
|
-
: [...rows.slice(0, othersAt), summary, ...rows.slice(othersAt)];
|
|
13553
|
-
});
|
|
13554
|
-
}
|
|
13555
|
-
// The list, read for `report`; null when it cannot be read. Its answer is
|
|
13556
|
-
// kept only while the report is still the open one.
|
|
13557
|
-
_readViews(report) {
|
|
13558
|
-
return this._reportData.views(report.id, { context: new HttpContext().set(DisplayApiError, false) })
|
|
13559
|
-
.pipe(catchError(() => of(null)), tap((list) => {
|
|
13560
|
-
if (list && report.id === this._reportId) {
|
|
13561
|
-
this._views.set(list.views ?? []);
|
|
13562
|
-
this._canShare.set(!!list.canShare);
|
|
13563
|
-
}
|
|
13564
|
-
}));
|
|
13565
|
-
}
|
|
13566
|
-
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: ReportViewService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
|
|
13567
|
-
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: ReportViewService });
|
|
13568
|
-
}
|
|
13569
|
-
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: ReportViewService, decorators: [{
|
|
13570
|
-
type: Injectable
|
|
13571
|
-
}] });
|
|
13572
|
-
// A view's row as the list holds it: names and flags, no config.
|
|
13573
|
-
function viewSummary(view) {
|
|
13574
|
-
return {
|
|
13575
|
-
id: view.id,
|
|
13576
|
-
name: view.name,
|
|
13577
|
-
shared: view.shared,
|
|
13578
|
-
mine: view.mine,
|
|
13579
|
-
personalDefault: view.personalDefault,
|
|
13580
|
-
ownerName: view.ownerName,
|
|
13581
|
-
canChange: view.canChange,
|
|
13582
|
-
canDelete: view.canDelete,
|
|
13583
|
-
modifyDate: view.modifyDate,
|
|
13584
|
-
};
|
|
13585
|
-
}
|
|
13586
|
-
|
|
13587
12699
|
// How far a KPI card's rows shrink to fit a short tile. A tile shorter still
|
|
13588
12700
|
// lets the card run past its foot rather than print it unreadably small.
|
|
13589
12701
|
const KPI_EXPORT_MIN_SCALE = 0.5;
|
|
@@ -13856,20 +12968,18 @@ function showsFailedValue(components, failed) {
|
|
|
13856
12968
|
.flatMap((component) => valueKeys(component.config?.html)));
|
|
13857
12969
|
return failed.some((key) => shown.has(key));
|
|
13858
12970
|
}
|
|
13859
|
-
// The cover's filter lines: the
|
|
13860
|
-
// the match while the report bar's filters match any. A drilled chart's line
|
|
12971
|
+
// The cover's filter lines: the export's filter lines, then the match while the report bar's filters match any. A drilled chart's line
|
|
13861
12972
|
// is its title as the canvas header reads it (`titles`, by component id) and
|
|
13862
12973
|
// the crumbs of the path its answer applied (the ones its title prints), not
|
|
13863
12974
|
// the path the screen asked for: the server may have cut that path short or
|
|
13864
12975
|
// refused it.
|
|
13865
12976
|
function coverFilterLines(snapshot, lines, pages, titles = {}) {
|
|
13866
|
-
const match = snapshot.features.includes('matchAny') ? matchLineText(snapshot.
|
|
12977
|
+
const match = snapshot.features.includes('matchAny') ? matchLineText(snapshot.state.match, 'line') : null;
|
|
13867
12978
|
const drilled = pages
|
|
13868
12979
|
.flatMap((page) => page.components)
|
|
13869
12980
|
.filter((item) => !!item.crumbs?.length)
|
|
13870
12981
|
.map((item) => crumbedTitle(titles[item.component.id] ?? item.component.title, item.crumbs));
|
|
13871
12982
|
return [
|
|
13872
|
-
snapshot.viewLine,
|
|
13873
12983
|
...lines.filter((line) => line.kind !== 'drill').map((line) => line.text),
|
|
13874
12984
|
...drilled,
|
|
13875
12985
|
match,
|
|
@@ -13896,9 +13006,6 @@ class ReportExportCollectorService {
|
|
|
13896
13006
|
_reportData = inject(ReportData);
|
|
13897
13007
|
_filterState = inject(ReportFilterStateService);
|
|
13898
13008
|
_filterOptions = inject(ReportFilterOptionsService);
|
|
13899
|
-
// The open saved view, for the cover's view line. Only a view that keeps
|
|
13900
|
-
// saved views provides it.
|
|
13901
|
-
_views = inject(ReportViewService, { optional: true });
|
|
13902
13009
|
// listRows is how many rows each list fetches: the renderer's own cap, since
|
|
13903
13010
|
// a PDF table continues onto extra pages and a slide table cannot.
|
|
13904
13011
|
//
|
|
@@ -13936,30 +13043,29 @@ class ReportExportCollectorService {
|
|
|
13936
13043
|
};
|
|
13937
13044
|
}));
|
|
13938
13045
|
}
|
|
13939
|
-
// The screen as one export reads it: the
|
|
13046
|
+
// The screen as one export reads it: the state snapshot, each group's value
|
|
13940
13047
|
// and live preset, each tile's filter entries and state (tileDataState),
|
|
13941
|
-
// each value's filter entries, the grain, the features served
|
|
13942
|
-
//
|
|
13048
|
+
// each value's filter entries, the grain, the features served and the
|
|
13049
|
+
// format. A value's entries are read here too, so the
|
|
13943
13050
|
// numbers in a file's text are filtered as its tiles are, whatever the bar
|
|
13944
13051
|
// does while the export runs.
|
|
13945
13052
|
snapshot(report) {
|
|
13946
|
-
const
|
|
13053
|
+
const state = this._filterState.snapshot();
|
|
13947
13054
|
const tiles = (report.pages ?? [])
|
|
13948
13055
|
.flatMap((page) => page.components ?? [])
|
|
13949
13056
|
.filter((component) => isTile(component));
|
|
13950
13057
|
const values = reportComponents(report)
|
|
13951
13058
|
.filter((component) => component.type === 'value');
|
|
13952
13059
|
return {
|
|
13953
|
-
|
|
13060
|
+
state,
|
|
13954
13061
|
...this._groupValues(report),
|
|
13955
13062
|
filters: Object.fromEntries(tiles.map((tile) => [tile.id, this._filterState.resolveForComponent(tile)])),
|
|
13956
13063
|
valueFilters: Object.fromEntries(values
|
|
13957
13064
|
.map((value) => [value.id, this._filterState.resolveForComponent(value)])),
|
|
13958
|
-
states: Object.fromEntries(tiles.map((tile) => [tile.id, tileDataState(report, tile,
|
|
13065
|
+
states: Object.fromEntries(tiles.map((tile) => [tile.id, tileDataState(report, tile, state)])),
|
|
13959
13066
|
frequency: this._filterState.frequency(),
|
|
13960
13067
|
features: offeredViewerFeatures(report.viewerFeatures),
|
|
13961
13068
|
formatContext: this._filterState.formatContext(),
|
|
13962
|
-
viewLine: viewLineText(this._views?.openView(), !!this._views?.viewChanged()),
|
|
13963
13069
|
};
|
|
13964
13070
|
}
|
|
13965
13071
|
// How an export words the screen, over a snapshot (`pageId` for a This
|
|
@@ -14108,7 +13214,7 @@ class ReportExportCollectorService {
|
|
|
14108
13214
|
_pageValues(report, pageId, values, shown, snapshot) {
|
|
14109
13215
|
const formatContext = snapshot.formatContext;
|
|
14110
13216
|
const filters = values.flatMap((component) => snapshot.valueFilters[component.id] ?? []);
|
|
14111
|
-
const matchAnyGroupIds = valuesMatchAnyGroupIds(report, pageId, snapshot.
|
|
13217
|
+
const matchAnyGroupIds = valuesMatchAnyGroupIds(report, pageId, snapshot.state);
|
|
14112
13218
|
return this._reportData.pageValues(report.id, pageId, filters, true, { handlers: false }, matchAnyGroupIds)
|
|
14113
13219
|
.pipe(map$1((rows) => new Map((rows ?? []).map((row) => [row.id, row]))), catchError(() => of(new Map())), map$1((rows) => ({
|
|
14114
13220
|
values: textValues(values, { rows }, formatContext),
|
|
@@ -14202,7 +13308,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImpo
|
|
|
14202
13308
|
// in the report's format context, as on screen.
|
|
14203
13309
|
async function renderChartImage(component, data, width, height, context, pixelRatio = 3) {
|
|
14204
13310
|
// The same tree-shaken echarts build + 'report' theme the live canvas uses.
|
|
14205
|
-
const echarts = (await import('./firestitch-report-echarts-
|
|
13311
|
+
const echarts = (await import('./firestitch-report-echarts-B0RWUjR8.mjs')).default;
|
|
14206
13312
|
const host = document.createElement('div');
|
|
14207
13313
|
host.style.width = `${width}px`;
|
|
14208
13314
|
host.style.height = `${height}px`;
|
|
@@ -16850,7 +15956,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImpo
|
|
|
16850
15956
|
// presets (Today, Last 7 days, This month, ...). The report adds none of its
|
|
16851
15957
|
// own: the built-in list covers every period a report filter names. A preset
|
|
16852
15958
|
// is a shortcut only: picking one sets the range's two days, which is all the
|
|
16853
|
-
// bar and a saved
|
|
15959
|
+
// bar and a saved filter hold. Provided on the view; the host app's
|
|
16854
15960
|
// own pickers keep their config.
|
|
16855
15961
|
function provideReportDatePicker() {
|
|
16856
15962
|
const config = {
|
|
@@ -16953,9 +16059,9 @@ class ReportComponent {
|
|
|
16953
16059
|
_pageId = signal(null);
|
|
16954
16060
|
constructor() {
|
|
16955
16061
|
this._buildReportConfig();
|
|
16956
|
-
//
|
|
16957
|
-
//
|
|
16958
|
-
//
|
|
16062
|
+
// resetToDefaults() moves the state version. The bar is built again for
|
|
16063
|
+
// it, and reportFilterKey, which carries it, recreates fs-filter: it reads
|
|
16064
|
+
// its config once.
|
|
16959
16065
|
effect(() => {
|
|
16960
16066
|
const version = this._filterState.stateVersion();
|
|
16961
16067
|
untracked(() => {
|
|
@@ -17133,10 +16239,10 @@ class ReportComponent {
|
|
|
17133
16239
|
// notes get the filter lines as the cover words them (exportLines; a This
|
|
17134
16240
|
// Page zip names only the groups that reach the page), and each file is
|
|
17135
16241
|
// named after its tile's title as the canvas header reads it (titles). All
|
|
17136
|
-
// of it is read once, when the export starts (exportWording).
|
|
17137
|
-
//
|
|
17138
|
-
// the
|
|
17139
|
-
//
|
|
16242
|
+
// of it is read once, when the export starts (exportWording). `thisPage`
|
|
16243
|
+
// narrows it to the page the canvas is showing. Unlike the PDF/PowerPoint
|
|
16244
|
+
// exports the files are written server-side, so they are not capped by
|
|
16245
|
+
// what a slide can hold.
|
|
17140
16246
|
exportData(thisPage = false) {
|
|
17141
16247
|
const report = this.report();
|
|
17142
16248
|
if (!report) {
|
|
@@ -17146,9 +16252,9 @@ class ReportComponent {
|
|
|
17146
16252
|
const filters = this._filterState.resolveForReport(report, pageId);
|
|
17147
16253
|
const frequency = this._filterState.frequency();
|
|
17148
16254
|
const snapshot = this._collector.snapshot(report);
|
|
17149
|
-
const states = zipExportStates(report, snapshot.
|
|
16255
|
+
const states = zipExportStates(report, snapshot.state, pageId);
|
|
17150
16256
|
this._process.download('Exporting data', this._collector.exportWording(report, snapshot, pageId)
|
|
17151
|
-
.pipe(switchMap(({ lines, titles }) => this._reportData.exportReport(report.id, filters, pageId, frequency, lines, states,
|
|
16257
|
+
.pipe(switchMap(({ lines, titles }) => this._reportData.exportReport(report.id, filters, pageId, frequency, lines, states, titles))));
|
|
17152
16258
|
}
|
|
17153
16259
|
// React to each chat turn (fs-chat handles the request + persistence); we
|
|
17154
16260
|
// only care about the report side effects this context's backend flags.
|
|
@@ -17274,14 +16380,12 @@ class ReportComponent {
|
|
|
17274
16380
|
persist: false,
|
|
17275
16381
|
items,
|
|
17276
16382
|
change: (query) => this._barChanged(query ?? {}, !!matchItem),
|
|
17277
|
-
// 'Clear filters' puts the screen back on the report's
|
|
17278
|
-
//
|
|
17279
|
-
|
|
17280
|
-
// only.
|
|
17281
|
-
clear: features.includes('views') ? () => this._filterState.resetToBase() : undefined,
|
|
16383
|
+
// 'Clear filters' puts the screen back on the report's authored
|
|
16384
|
+
// defaults and drops the temporary layer.
|
|
16385
|
+
clear: () => this._filterState.resetToDefaults(),
|
|
17282
16386
|
// The chip shows exactly while the screen differs from the defaults a
|
|
17283
16387
|
// clear returns to (C5).
|
|
17284
|
-
clearVisible:
|
|
16388
|
+
clearVisible: () => this._filterState.differsFromDefaults(),
|
|
17285
16389
|
});
|
|
17286
16390
|
}
|
|
17287
16391
|
// The bar's values into the state: every group it shows, the grain and,
|
|
@@ -17337,7 +16441,7 @@ class ReportComponent {
|
|
|
17337
16441
|
ReportExportService,
|
|
17338
16442
|
// Tree-shaken ECharts core + the 'report' house theme, loaded lazily with
|
|
17339
16443
|
// this route's chunk.
|
|
17340
|
-
provideEchartsCore({ echarts: () => import('./firestitch-report-echarts-
|
|
16444
|
+
provideEchartsCore({ echarts: () => import('./firestitch-report-echarts-B0RWUjR8.mjs').then((module) => module.default) }),
|
|
17341
16445
|
// The bar's date pickers start their weeks on Monday and list the built-in
|
|
17342
16446
|
// presets; a preset only sets the days.
|
|
17343
16447
|
provideReportDatePicker(),
|
|
@@ -17358,7 +16462,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImpo
|
|
|
17358
16462
|
ReportExportService,
|
|
17359
16463
|
// Tree-shaken ECharts core + the 'report' house theme, loaded lazily with
|
|
17360
16464
|
// this route's chunk.
|
|
17361
|
-
provideEchartsCore({ echarts: () => import('./firestitch-report-echarts-
|
|
16465
|
+
provideEchartsCore({ echarts: () => import('./firestitch-report-echarts-B0RWUjR8.mjs').then((module) => module.default) }),
|
|
17362
16466
|
// The bar's date pickers start their weeks on Monday and list the built-in
|
|
17363
16467
|
// presets; a preset only sets the days.
|
|
17364
16468
|
provideReportDatePicker(),
|
|
@@ -18547,260 +17651,6 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImpo
|
|
|
18547
17651
|
], 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"] }]
|
|
18548
17652
|
}], ctorParameters: () => [] });
|
|
18549
17653
|
|
|
18550
|
-
const VIEWS_LABEL = 'Views';
|
|
18551
|
-
const EDITED_TOOLTIP = 'The screen differs from this view';
|
|
18552
|
-
const SAVE_AS_TITLE = 'Save as new view';
|
|
18553
|
-
const RENAME_TITLE = 'Rename view';
|
|
18554
|
-
const CROSS_FILTER_NOTE = 'Cross-filters are temporary and are not saved.';
|
|
18555
|
-
const VIEW_NAME_BLANK = 'Enter a name';
|
|
18556
|
-
// The server's limit on a view's name.
|
|
18557
|
-
const VIEW_NAME_MAX_LENGTH = 100;
|
|
18558
|
-
// The menu for the open view (null when none is open), the views the viewer
|
|
18559
|
-
// may see, whether the screen differs from the open view, and whether the
|
|
18560
|
-
// viewer may share its views.
|
|
18561
|
-
function viewMenuItems(open, views, edited, canShare) {
|
|
18562
|
-
const rows = [
|
|
18563
|
-
...views.filter((view) => view.mine),
|
|
18564
|
-
...views.filter((view) => !view.mine),
|
|
18565
|
-
].map((view) => viewRow(view, open, edited));
|
|
18566
|
-
const actions = openViewActions(open, edited, canShare);
|
|
18567
|
-
const resets = [
|
|
18568
|
-
...(open && edited ? [action('resetToView', `Reset to "${open.name}"`)] : []),
|
|
18569
|
-
action('resetToDefaults', 'Reset to report defaults'),
|
|
18570
|
-
];
|
|
18571
|
-
return [
|
|
18572
|
-
...rows,
|
|
18573
|
-
...withDivider(actions, rows.length > 0),
|
|
18574
|
-
...withDivider(resets, true),
|
|
18575
|
-
];
|
|
18576
|
-
}
|
|
18577
|
-
// The picker's label: 'Views', or the open view's name, marked while the
|
|
18578
|
-
// screen differs from it.
|
|
18579
|
-
function pickerLabel(open, edited) {
|
|
18580
|
-
if (!open) {
|
|
18581
|
-
return VIEWS_LABEL;
|
|
18582
|
-
}
|
|
18583
|
-
return edited ? `${open.name} (edited)` : open.name;
|
|
18584
|
-
}
|
|
18585
|
-
// The picker's tooltip: only while the screen differs from the open view.
|
|
18586
|
-
function pickerTooltip(open, edited) {
|
|
18587
|
-
return open && edited ? EDITED_TOOLTIP : null;
|
|
18588
|
-
}
|
|
18589
|
-
// The question before a view is deleted. A shared view says that everyone
|
|
18590
|
-
// loses it.
|
|
18591
|
-
function deletePromptText(view) {
|
|
18592
|
-
const question = `Delete the view "${view.name}"?`;
|
|
18593
|
-
return view.shared ? `${question} Everyone it is shared with loses it.` : question;
|
|
18594
|
-
}
|
|
18595
|
-
// The line the save dialog shows under the name, or null: a cross-filter is
|
|
18596
|
-
// on, and it does not go into the view.
|
|
18597
|
-
function saveDialogNote(hasCrossFilter) {
|
|
18598
|
-
return hasCrossFilter ? CROSS_FILTER_NOTE : null;
|
|
18599
|
-
}
|
|
18600
|
-
// A name made of spaces only is no name; the server trims it.
|
|
18601
|
-
function viewNameError(name) {
|
|
18602
|
-
return (name ?? '').trim() ? null : VIEW_NAME_BLANK;
|
|
18603
|
-
}
|
|
18604
|
-
function viewRow(view, open, edited) {
|
|
18605
|
-
const isOpen = view.id === open?.id;
|
|
18606
|
-
return {
|
|
18607
|
-
kind: 'view',
|
|
18608
|
-
label: view.name,
|
|
18609
|
-
disabled: isOpen && !edited,
|
|
18610
|
-
tooltip: viewTooltip(view),
|
|
18611
|
-
divider: false,
|
|
18612
|
-
viewId: view.id,
|
|
18613
|
-
open: isOpen,
|
|
18614
|
-
icon: viewIcon(view),
|
|
18615
|
-
};
|
|
18616
|
-
}
|
|
18617
|
-
// Who shared a view of someone else's; whether one of the viewer's own opens
|
|
18618
|
-
// by default or is shared.
|
|
18619
|
-
function viewTooltip(view) {
|
|
18620
|
-
if (!view.mine) {
|
|
18621
|
-
return view.ownerName ? `Shared by ${view.ownerName}` : 'Shared with everyone';
|
|
18622
|
-
}
|
|
18623
|
-
if (view.personalDefault && view.shared) {
|
|
18624
|
-
return 'Opens by default, shared with everyone';
|
|
18625
|
-
}
|
|
18626
|
-
if (view.personalDefault) {
|
|
18627
|
-
return 'Opens by default';
|
|
18628
|
-
}
|
|
18629
|
-
return view.shared ? 'Shared with everyone' : null;
|
|
18630
|
-
}
|
|
18631
|
-
// The open row keeps this icon too; its check is drawn beside it.
|
|
18632
|
-
function viewIcon(view) {
|
|
18633
|
-
if (!view.mine) {
|
|
18634
|
-
return 'people';
|
|
18635
|
-
}
|
|
18636
|
-
return view.personalDefault ? 'star' : null;
|
|
18637
|
-
}
|
|
18638
|
-
// The rows that act on the open view; with none open, only Save as new view.
|
|
18639
|
-
function openViewActions(open, edited, canShare) {
|
|
18640
|
-
const items = [];
|
|
18641
|
-
if (open?.canChange && edited) {
|
|
18642
|
-
items.push(action('saveChanges', 'Save changes'));
|
|
18643
|
-
}
|
|
18644
|
-
items.push(action('saveAsNew', 'Save as new view...'));
|
|
18645
|
-
if (open?.canChange) {
|
|
18646
|
-
items.push(action('rename', 'Rename...'));
|
|
18647
|
-
}
|
|
18648
|
-
if (open?.mine) {
|
|
18649
|
-
items.push(open.personalDefault
|
|
18650
|
-
? action('clearDefault', 'Stop opening it by default')
|
|
18651
|
-
: action('setDefault', 'Make it my default'));
|
|
18652
|
-
}
|
|
18653
|
-
if (open?.mine && canShare) {
|
|
18654
|
-
items.push(open.shared ? action('unshare', 'Stop sharing') : action('share', 'Share with everyone'));
|
|
18655
|
-
}
|
|
18656
|
-
if (open?.canDelete) {
|
|
18657
|
-
items.push(action('delete', 'Delete...'));
|
|
18658
|
-
}
|
|
18659
|
-
return items;
|
|
18660
|
-
}
|
|
18661
|
-
function action(kind, label) {
|
|
18662
|
-
return { kind, label, disabled: false, tooltip: null, divider: false, viewId: null, open: false, icon: null };
|
|
18663
|
-
}
|
|
18664
|
-
// The same rows, the first with a line above it when `divider` is set.
|
|
18665
|
-
function withDivider(items, divider) {
|
|
18666
|
-
return items.map((item, index) => (index === 0 && divider ? { ...item, divider: true } : item));
|
|
18667
|
-
}
|
|
18668
|
-
|
|
18669
|
-
// Names a saved view: Save as new view and Rename. One name field; the
|
|
18670
|
-
// caller's save call does the work, and the dialog closes once it answers.
|
|
18671
|
-
// A refusal from the server (a name taken, too many views) shows as the
|
|
18672
|
-
// API's own error and leaves the dialog open.
|
|
18673
|
-
class ReportViewDialogComponent {
|
|
18674
|
-
data = inject(MAT_DIALOG_DATA);
|
|
18675
|
-
name = signal(this.data.name);
|
|
18676
|
-
nameMaxLength = VIEW_NAME_MAX_LENGTH;
|
|
18677
|
-
_dialogRef = inject(MatDialogRef);
|
|
18678
|
-
// A name of spaces only is refused here, where fsFormRequired lets it by.
|
|
18679
|
-
// It reads the control: the name signal catches up only after validation.
|
|
18680
|
-
validateName = (control) => {
|
|
18681
|
-
const error = viewNameError(control.value);
|
|
18682
|
-
if (error) {
|
|
18683
|
-
throw new Error(error);
|
|
18684
|
-
}
|
|
18685
|
-
};
|
|
18686
|
-
save = () => {
|
|
18687
|
-
return this.data.save(this.name().trim())
|
|
18688
|
-
.pipe(tap(() => this._dialogRef.close(true)));
|
|
18689
|
-
};
|
|
18690
|
-
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: ReportViewDialogComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
18691
|
-
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "18.2.14", type: ReportViewDialogComponent, isStandalone: true, selector: "ng-component", ngImport: i0, template: "<form\n fsForm\n [submit]=\"save\">\n <fs-dialog>\n <fs-dialog-title>\n {{ data.title }}\n </fs-dialog-title>\n <mat-dialog-content>\n <div class=\"fs-column\">\n <mat-form-field class=\"full-width\">\n <mat-label>\n Name\n </mat-label>\n <input\n matInput\n [(ngModel)]=\"name\"\n name=\"name\"\n cdkFocusInitial\n [fsFormRequired]=\"true\"\n [fsFormMaxLength]=\"nameMaxLength\"\n [validate]=\"validateName\">\n @if (data.note) {\n <mat-hint>\n {{ data.note }}\n </mat-hint>\n }\n </mat-form-field>\n </div>\n </mat-dialog-content>\n <mat-dialog-actions>\n <fs-form-dialog-actions></fs-form-dialog-actions>\n </mat-dialog-actions>\n </fs-dialog>\n</form>\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: "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: MatHint, selector: "mat-hint", inputs: ["align", "id"] }, { kind: "directive", type: MatLabel, selector: "mat-label" }, { 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: "ngmodule", type: FsDialogModule }, { kind: "component", type: i3.FsDialogComponent, selector: "fs-dialog", inputs: ["mobileMode", "mobileButtonPlacement", "mobileWidth", "mode", "buttonLayout"] }, { kind: "component", type: i3.FsDialogTitleComponent, selector: "fs-dialog-title", inputs: ["close", "dockToggle", "back"] }, { kind: "ngmodule", type: FsFormModule }, { kind: "directive", type: i2$1.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$1.FsFormRequiredDirective, selector: "[fsFormRequired],[ngModel][required]", inputs: ["fsFormRequired", "required", "fsFormRequiredMessage"] }, { kind: "directive", type: i2$1.FsFormMaxLengthDirective, selector: "[fsFormMaxLength]", inputs: ["fsFormMaxLength", "fsFormMaxLengthMessage"] }, { kind: "directive", type: i2$1.FsFormValidateDirective, selector: "[validate]", inputs: ["validate", "validateData", "validateOnSubmit"] }, { kind: "component", type: i2$1.FsFormDialogActionsComponent, selector: "fs-form-dialog-actions", inputs: ["save", "create", "close", "done", "closeData", "name"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
18692
|
-
}
|
|
18693
|
-
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: ReportViewDialogComponent, decorators: [{
|
|
18694
|
-
type: Component,
|
|
18695
|
-
args: [{ changeDetection: ChangeDetectionStrategy.OnPush, standalone: true, imports: [
|
|
18696
|
-
FormsModule,
|
|
18697
|
-
MatDialogContent,
|
|
18698
|
-
MatDialogActions,
|
|
18699
|
-
MatFormField,
|
|
18700
|
-
MatHint,
|
|
18701
|
-
MatLabel,
|
|
18702
|
-
MatInput,
|
|
18703
|
-
FsDialogModule,
|
|
18704
|
-
FsFormModule,
|
|
18705
|
-
], template: "<form\n fsForm\n [submit]=\"save\">\n <fs-dialog>\n <fs-dialog-title>\n {{ data.title }}\n </fs-dialog-title>\n <mat-dialog-content>\n <div class=\"fs-column\">\n <mat-form-field class=\"full-width\">\n <mat-label>\n Name\n </mat-label>\n <input\n matInput\n [(ngModel)]=\"name\"\n name=\"name\"\n cdkFocusInitial\n [fsFormRequired]=\"true\"\n [fsFormMaxLength]=\"nameMaxLength\"\n [validate]=\"validateName\">\n @if (data.note) {\n <mat-hint>\n {{ data.note }}\n </mat-hint>\n }\n </mat-form-field>\n </div>\n </mat-dialog-content>\n <mat-dialog-actions>\n <fs-form-dialog-actions></fs-form-dialog-actions>\n </mat-dialog-actions>\n </fs-dialog>\n</form>\n" }]
|
|
18706
|
-
}] });
|
|
18707
|
-
|
|
18708
|
-
// The Views button in the report's header and its menu: the saved views to
|
|
18709
|
-
// open and the actions on the open one. What the menu holds is decided in
|
|
18710
|
-
// report-view-menu.ts; this draws it and runs the row clicked through
|
|
18711
|
-
// ReportViewService, which says 'View saved' and 'View deleted' itself.
|
|
18712
|
-
class ReportViewPickerComponent {
|
|
18713
|
-
label = computed(() => pickerLabel(this._views.openView(), this._views.viewChanged()));
|
|
18714
|
-
tooltip = computed(() => pickerTooltip(this._views.openView(), this._views.viewChanged()) ?? '');
|
|
18715
|
-
items = computed(() => viewMenuItems(this._views.openView(), this._views.views(), this._views.viewChanged(), this._views.canShare()));
|
|
18716
|
-
// fs-menu's phone sheet has no divider row and would draw an empty one,
|
|
18717
|
-
// so the lines show only in the desktop menu. Read when the menu opens.
|
|
18718
|
-
dividerShown = () => !this._menu()?.mobile;
|
|
18719
|
-
_menu = viewChild(FsMenuComponent);
|
|
18720
|
-
_views = inject(ReportViewService);
|
|
18721
|
-
_filterState = inject(ReportFilterStateService);
|
|
18722
|
-
_dialog = inject(MatDialog);
|
|
18723
|
-
_prompt = inject(FsPrompt);
|
|
18724
|
-
_destroyRef = inject(DestroyRef);
|
|
18725
|
-
run(item) {
|
|
18726
|
-
switch (item.kind) {
|
|
18727
|
-
case 'view':
|
|
18728
|
-
if (item.viewId !== null) {
|
|
18729
|
-
this._subscribe(this._views.pick(item.viewId));
|
|
18730
|
-
}
|
|
18731
|
-
break;
|
|
18732
|
-
case 'saveChanges':
|
|
18733
|
-
this._subscribe(this._views.saveChanges());
|
|
18734
|
-
break;
|
|
18735
|
-
case 'saveAsNew':
|
|
18736
|
-
this._name(SAVE_AS_TITLE, '', (name) => this._views.saveAsNew({ name }));
|
|
18737
|
-
break;
|
|
18738
|
-
case 'rename':
|
|
18739
|
-
this._name(RENAME_TITLE, this._views.openView()?.name ?? '', (name) => this._views.rename(name));
|
|
18740
|
-
break;
|
|
18741
|
-
case 'setDefault':
|
|
18742
|
-
case 'clearDefault':
|
|
18743
|
-
this._subscribe(this._views.setDefault(item.kind === 'setDefault'));
|
|
18744
|
-
break;
|
|
18745
|
-
case 'share':
|
|
18746
|
-
case 'unshare':
|
|
18747
|
-
this._subscribe(this._views.share(item.kind === 'share'));
|
|
18748
|
-
break;
|
|
18749
|
-
case 'delete':
|
|
18750
|
-
this._delete();
|
|
18751
|
-
break;
|
|
18752
|
-
case 'resetToView':
|
|
18753
|
-
this._filterState.resetToBase();
|
|
18754
|
-
break;
|
|
18755
|
-
case 'resetToDefaults':
|
|
18756
|
-
this._views.openDefaults();
|
|
18757
|
-
break;
|
|
18758
|
-
}
|
|
18759
|
-
}
|
|
18760
|
-
// Opens the name dialog, which saves through `save`.
|
|
18761
|
-
_name(title, name, save) {
|
|
18762
|
-
this._dialog.open(ReportViewDialogComponent, {
|
|
18763
|
-
data: { title, name, note: saveDialogNote(this._hasCrossFilter()), save },
|
|
18764
|
-
width: '400px',
|
|
18765
|
-
});
|
|
18766
|
-
}
|
|
18767
|
-
_delete() {
|
|
18768
|
-
const open = this._views.openView();
|
|
18769
|
-
if (!open) {
|
|
18770
|
-
return;
|
|
18771
|
-
}
|
|
18772
|
-
this._prompt.confirm({
|
|
18773
|
-
title: 'Delete view',
|
|
18774
|
-
template: deletePromptText(open),
|
|
18775
|
-
})
|
|
18776
|
-
.pipe(switchMap(() => this._views.deleteOpen()), catchError(() => EMPTY), takeUntilDestroyed(this._destroyRef))
|
|
18777
|
-
.subscribe();
|
|
18778
|
-
}
|
|
18779
|
-
// Runs a call on the views. A failure is shown by the API layer and ends
|
|
18780
|
-
// here, so the menu stays usable.
|
|
18781
|
-
_subscribe(call) {
|
|
18782
|
-
call
|
|
18783
|
-
.pipe(catchError(() => EMPTY), takeUntilDestroyed(this._destroyRef))
|
|
18784
|
-
.subscribe();
|
|
18785
|
-
}
|
|
18786
|
-
// Whether a cross-filter is on. The viewer has none yet: the temporary
|
|
18787
|
-
// layer gets its cross-filter, and this its reader, with cross-filtering.
|
|
18788
|
-
_hasCrossFilter() {
|
|
18789
|
-
return false;
|
|
18790
|
-
}
|
|
18791
|
-
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: ReportViewPickerComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
18792
|
-
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "18.2.14", type: ReportViewPickerComponent, isStandalone: true, selector: "app-report-view-picker", viewQueries: [{ propertyName: "_menu", first: true, predicate: FsMenuComponent, descendants: true, isSignal: true }], ngImport: i0, template: "<button\n mat-button\n type=\"button\"\n [matTooltip]=\"tooltip()\"\n [fsMenuTriggerFor]=\"viewsMenu\">\n <span class=\"report-view-picker__label\">{{ label() }}</span>\n <mat-icon iconPositionEnd>arrow_drop_down</mat-icon>\n</button>\n<fs-menu #viewsMenu>\n @for (item of items(); track item.kind + ':' + item.viewId) {\n @if (item.divider) {\n <ng-template\n fs-menu-divider-item\n [show]=\"dividerShown\">\n </ng-template>\n }\n <ng-template\n fs-menu-item\n [disabled]=\"item.disabled\"\n [tooltip]=\"item.tooltip ?? ''\"\n (click)=\"run(item)\">\n @if (item.open) {\n <mat-icon>check</mat-icon>\n }\n @if (item.icon) {\n <mat-icon>{{ item.icon }}</mat-icon>\n }\n {{ item.label }}\n </ng-template>\n }\n</fs-menu>\n", styles: [":host{flex:0 0 auto}.report-view-picker__label{display:inline-block;max-width:240px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;vertical-align:bottom}\n"], dependencies: [{ kind: "ngmodule", type: FsMenuModule }, { kind: "component", type: i2.FsMenuComponent, selector: "fs-menu", inputs: ["class", "buttonClass", "buttonType", "buttonColor"], outputs: ["opened", "closed"] }, { kind: "directive", type: i2.FsMenuItemDirective, selector: "fs-menu-group,[fs-menu-item]" }, { kind: "directive", type: i2.FsMenuTriggerDirective, selector: "[fsMenuTriggerFor]", inputs: ["fsMenuTriggerFor"] }, { kind: "directive", type: i2.FsMenuDividerItemDirective, selector: "[fs-menu-divider-item]" }, { 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: "ngmodule", type: MatIconModule }, { kind: "component", type: i5$1.MatIcon, selector: "mat-icon", inputs: ["color", "inline", "svgIcon", "fontSet", "fontIcon"], exportAs: ["matIcon"] }, { kind: "ngmodule", type: MatTooltipModule }, { kind: "directive", type: i2$4.MatTooltip, selector: "[matTooltip]", inputs: ["matTooltipPosition", "matTooltipPositionAtOrigin", "matTooltipDisabled", "matTooltipShowDelay", "matTooltipHideDelay", "matTooltipTouchGestures", "matTooltip", "matTooltipClass"], exportAs: ["matTooltip"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
18793
|
-
}
|
|
18794
|
-
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: ReportViewPickerComponent, decorators: [{
|
|
18795
|
-
type: Component,
|
|
18796
|
-
args: [{ selector: 'app-report-view-picker', changeDetection: ChangeDetectionStrategy.OnPush, standalone: true, imports: [
|
|
18797
|
-
FsMenuModule,
|
|
18798
|
-
MatButtonModule,
|
|
18799
|
-
MatIconModule,
|
|
18800
|
-
MatTooltipModule,
|
|
18801
|
-
], template: "<button\n mat-button\n type=\"button\"\n [matTooltip]=\"tooltip()\"\n [fsMenuTriggerFor]=\"viewsMenu\">\n <span class=\"report-view-picker__label\">{{ label() }}</span>\n <mat-icon iconPositionEnd>arrow_drop_down</mat-icon>\n</button>\n<fs-menu #viewsMenu>\n @for (item of items(); track item.kind + ':' + item.viewId) {\n @if (item.divider) {\n <ng-template\n fs-menu-divider-item\n [show]=\"dividerShown\">\n </ng-template>\n }\n <ng-template\n fs-menu-item\n [disabled]=\"item.disabled\"\n [tooltip]=\"item.tooltip ?? ''\"\n (click)=\"run(item)\">\n @if (item.open) {\n <mat-icon>check</mat-icon>\n }\n @if (item.icon) {\n <mat-icon>{{ item.icon }}</mat-icon>\n }\n {{ item.label }}\n </ng-template>\n }\n</fs-menu>\n", styles: [":host{flex:0 0 auto}.report-view-picker__label{display:inline-block;max-width:240px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;vertical-align:bottom}\n"] }]
|
|
18802
|
-
}] });
|
|
18803
|
-
|
|
18804
17654
|
// The report view's chat gateway: the stock one, plus fields read at the
|
|
18805
17655
|
// moment each turn is sent and merged into that turn's body only. The chat's
|
|
18806
17656
|
// requestData can't carry them, because it is also the key of the conversation
|
|
@@ -18824,7 +17674,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImpo
|
|
|
18824
17674
|
}] });
|
|
18825
17675
|
|
|
18826
17676
|
// The parts of the chat's viewer state (ReportChatViewerState) read off a
|
|
18827
|
-
//
|
|
17677
|
+
// snapshot of the screen (the state service's snapshot()). Pure, so the
|
|
18828
17678
|
// node harness checks them. The caps are the server's: it reads at most
|
|
18829
17679
|
// CHAT_DRILLS_MAX drilled charts, DRILL_MAX_DEPTH steps a path and
|
|
18830
17680
|
// CHAT_LABEL_MAX characters a label, and drops the rest.
|
|
@@ -18855,6 +17705,96 @@ function chatViewerDrills(snapshot, locale) {
|
|
|
18855
17705
|
}));
|
|
18856
17706
|
}
|
|
18857
17707
|
|
|
17708
|
+
// The groups a saved filter holds: the report-wide ones.
|
|
17709
|
+
function savedFilterGroups(groups) {
|
|
17710
|
+
return (groups ?? []).filter((group) => group.level === 'report' || group.level === 'both');
|
|
17711
|
+
}
|
|
17712
|
+
// A saved filter's values kept to `groups` (savedFilterGroups): each
|
|
17713
|
+
// report-wide group's item value, in a shape its item takes, a date range's
|
|
17714
|
+
// days as yyyy-MM-dd (a day is the same day in every zone, so everyone who
|
|
17715
|
+
// picks a shared filter gets the same days). Any other key is left out, and
|
|
17716
|
+
// so is a value that holds nothing or does not fit its group's type.
|
|
17717
|
+
function savedFilterValues(filters, groups) {
|
|
17718
|
+
const kept = {};
|
|
17719
|
+
groups.forEach((group) => {
|
|
17720
|
+
const name = groupItemName(group);
|
|
17721
|
+
const value = filters && Object.prototype.hasOwnProperty.call(filters, name)
|
|
17722
|
+
? itemValue(group, filters[name])
|
|
17723
|
+
: null;
|
|
17724
|
+
if (value !== null) {
|
|
17725
|
+
kept[name] = value;
|
|
17726
|
+
}
|
|
17727
|
+
});
|
|
17728
|
+
return kept;
|
|
17729
|
+
}
|
|
17730
|
+
// One item value as a saved filter keeps it, or null.
|
|
17731
|
+
function itemValue(group, value) {
|
|
17732
|
+
switch (group.type) {
|
|
17733
|
+
case 'dateRange':
|
|
17734
|
+
return dateRangeValue(value);
|
|
17735
|
+
case 'select':
|
|
17736
|
+
return selectValue(value);
|
|
17737
|
+
case 'numberRange':
|
|
17738
|
+
// A number range shows as a keyword box where the server does not
|
|
17739
|
+
// serve numberRange, and its item then holds text.
|
|
17740
|
+
return isPlainObject(value) ? rangeValue(value) : textValue(value);
|
|
17741
|
+
default:
|
|
17742
|
+
return textValue(value);
|
|
17743
|
+
}
|
|
17744
|
+
}
|
|
17745
|
+
// A date range item's { from, to }, each end a calendar day.
|
|
17746
|
+
function dateRangeValue(value) {
|
|
17747
|
+
if (!isPlainObject(value)) {
|
|
17748
|
+
return null;
|
|
17749
|
+
}
|
|
17750
|
+
const from = dayText(value.from);
|
|
17751
|
+
const to = dayText(value.to);
|
|
17752
|
+
if (!from && !to) {
|
|
17753
|
+
return null;
|
|
17754
|
+
}
|
|
17755
|
+
return { ...(from ? { from } : {}), ...(to ? { to } : {}) };
|
|
17756
|
+
}
|
|
17757
|
+
// A select item's picks ({ name, value } pairs), or its left-out picks
|
|
17758
|
+
// ({ exclude: true, selected }).
|
|
17759
|
+
function selectValue(value) {
|
|
17760
|
+
if (isPlainObject(value)) {
|
|
17761
|
+
const selected = picks(value.selected);
|
|
17762
|
+
return selected ? { exclude: value.exclude === true, selected } : null;
|
|
17763
|
+
}
|
|
17764
|
+
return picks(value);
|
|
17765
|
+
}
|
|
17766
|
+
// A range item's { min, max }; either end may be open, and 0 is an end.
|
|
17767
|
+
function rangeValue(value) {
|
|
17768
|
+
const min = finiteNumber(value.min);
|
|
17769
|
+
const max = finiteNumber(value.max);
|
|
17770
|
+
if (min === undefined && max === undefined) {
|
|
17771
|
+
return null;
|
|
17772
|
+
}
|
|
17773
|
+
return { ...(min !== undefined ? { min } : {}), ...(max !== undefined ? { max } : {}) };
|
|
17774
|
+
}
|
|
17775
|
+
function textValue(value) {
|
|
17776
|
+
const text = typeof value === 'string' || typeof value === 'number' ? String(value).trim() : '';
|
|
17777
|
+
return text || null;
|
|
17778
|
+
}
|
|
17779
|
+
// The picks of a select item that name a value, or null when none does.
|
|
17780
|
+
function picks(value) {
|
|
17781
|
+
const kept = (Array.isArray(value) ? value : [])
|
|
17782
|
+
.filter((pick) => isPlainObject(pick)
|
|
17783
|
+
&& pick.value !== null && pick.value !== undefined && pick.value !== '');
|
|
17784
|
+
return kept.length ? kept : null;
|
|
17785
|
+
}
|
|
17786
|
+
// A day as yyyy-MM-dd: a Date's local day, or the first ten characters of a
|
|
17787
|
+
// day already written as text. Null for anything else.
|
|
17788
|
+
function dayText(value) {
|
|
17789
|
+
if (value instanceof Date) {
|
|
17790
|
+
return Number.isNaN(value.getTime()) ? null : dateBoundString(value) ?? null;
|
|
17791
|
+
}
|
|
17792
|
+
return typeof value === 'string' && /^\d{4}-\d{2}-\d{2}/.test(value) ? value.slice(0, 10) : null;
|
|
17793
|
+
}
|
|
17794
|
+
function isPlainObject(value) {
|
|
17795
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value) && !(value instanceof Date);
|
|
17796
|
+
}
|
|
17797
|
+
|
|
18858
17798
|
// A single report: the AI chat on the left, the report canvas on the right,
|
|
18859
17799
|
// under an H2 title + settings gear. The host supplies [reportId] (from its
|
|
18860
17800
|
// route) and switches reports through its own routes — this component never
|
|
@@ -18864,13 +17804,12 @@ class FsAiReportComponent {
|
|
|
18864
17804
|
reportId = input(null);
|
|
18865
17805
|
// API base for the reports + chat endpoints; override to mount elsewhere.
|
|
18866
17806
|
basePath = input('reports');
|
|
18867
|
-
// The query param
|
|
18868
|
-
//
|
|
18869
|
-
//
|
|
18870
|
-
// the
|
|
18871
|
-
// (ReportFilterStateService)
|
|
18872
|
-
//
|
|
18873
|
-
// to null.
|
|
17807
|
+
// The query param that carries the page on screen (?page=7). A report opens
|
|
17808
|
+
// on the page it names, and each move to another page writes it, in place,
|
|
17809
|
+
// so a refresh or a shared link comes back to the same page. It is the only
|
|
17810
|
+
// state a report keeps in the URL: its filters and the rest live in the
|
|
17811
|
+
// report's state (ReportFilterStateService). A host that mounts several
|
|
17812
|
+
// reports on one route, or wants no URL writes, sets this to null.
|
|
18874
17813
|
pageQueryParam = input('page');
|
|
18875
17814
|
// The report was deleted from its settings dialog — the host routes back to
|
|
18876
17815
|
// the listing.
|
|
@@ -18884,9 +17823,8 @@ class FsAiReportComponent {
|
|
|
18884
17823
|
report = signal(null);
|
|
18885
17824
|
editMode = signal(false);
|
|
18886
17825
|
// The page the canvas should open on: the ?page= the report opened with
|
|
18887
|
-
// (read once per report)
|
|
18888
|
-
//
|
|
18889
|
-
// stale value.
|
|
17826
|
+
// (read once per report). Cleared as soon as the canvas reports back, so a
|
|
17827
|
+
// later in-app page change is never fought by a stale value.
|
|
18890
17828
|
pageId = signal(null);
|
|
18891
17829
|
// The page the chat is bound to: the one on screen. A report conversation
|
|
18892
17830
|
// belongs to exactly one page, so this both scopes the conversation list and
|
|
@@ -18928,8 +17866,8 @@ class FsAiReportComponent {
|
|
|
18928
17866
|
// rebuilt config) whenever the report-level filter set changes.
|
|
18929
17867
|
reportFilterKey = computed(() => {
|
|
18930
17868
|
return [
|
|
18931
|
-
// The state version the bar was built for:
|
|
18932
|
-
//
|
|
17869
|
+
// The state version the bar was built for: a reset to the defaults
|
|
17870
|
+
// replaces every value at once.
|
|
18933
17871
|
this._barVersion(),
|
|
18934
17872
|
// The effective zone is part of it too: a zone change re-resolves relative
|
|
18935
17873
|
// presets, and the bar must be recreated to show the new days.
|
|
@@ -18944,31 +17882,37 @@ class FsAiReportComponent {
|
|
|
18944
17882
|
...(hasPageGroup(this.report()) ? [`page:${this.chatPageId()}`] : []),
|
|
18945
17883
|
].join('|');
|
|
18946
17884
|
});
|
|
18947
|
-
// The server keeps saved views for this report, so the Views picker shows.
|
|
18948
|
-
viewsServed = computed(() => this._views.serves(this.report()));
|
|
18949
17885
|
_split;
|
|
18950
17886
|
_chatPanel;
|
|
18951
17887
|
// Only the canvas knows which page is on screen, and a page-scoped export
|
|
18952
17888
|
// has to ask it.
|
|
18953
17889
|
_canvas;
|
|
17890
|
+
// The report-level filter bar, while it is drawn. A saved filter being
|
|
17891
|
+
// picked reads the bar's values off it (_keepBarOnPick).
|
|
17892
|
+
_reportBar;
|
|
18954
17893
|
_reportData = inject(ReportData);
|
|
18955
17894
|
_reportAiChatData = inject(ReportAiChatData);
|
|
18956
17895
|
_route = inject(ActivatedRoute);
|
|
17896
|
+
_router = inject(Router);
|
|
18957
17897
|
_filterState = inject(ReportFilterStateService);
|
|
18958
17898
|
_filterOptions = inject(ReportFilterOptionsService);
|
|
18959
|
-
_views = inject(ReportViewService);
|
|
18960
17899
|
_exportService = inject(ReportExportService);
|
|
18961
17900
|
_collector = inject(ReportExportCollectorService);
|
|
18962
17901
|
_process = inject(FsProcess);
|
|
18963
17902
|
_dialog = inject(MatDialog);
|
|
18964
17903
|
_destroyRef = inject(DestroyRef);
|
|
18965
17904
|
_zone = inject(NgZone);
|
|
18966
|
-
// The report that has been opened (its default view, else its defaults).
|
|
18967
|
-
// A report opens once; a reload after a chat or dialog edit keeps the state
|
|
18968
|
-
// in memory.
|
|
18969
|
-
_openedReportId = null;
|
|
18970
17905
|
// The state version the bar was last built for (reportFilterKey).
|
|
18971
17906
|
_barVersion = signal(0);
|
|
17907
|
+
// The report bar's values as they were the moment fs-filter read a saved
|
|
17908
|
+
// filter's values, or null outside that moment. A pick sets every item of
|
|
17909
|
+
// the bar from the saved filter's values; each item a saved filter never
|
|
17910
|
+
// holds (the group-by grain, the match, a page's own filters) is put back
|
|
17911
|
+
// to its noted value as it is set (_keepItemOnPick).
|
|
17912
|
+
_barValuesAtPick = null;
|
|
17913
|
+
// The report the screen was opened on its first saved filter for
|
|
17914
|
+
// (_openOnFirstSavedFilter), or null before that.
|
|
17915
|
+
_openedOnSavedFilterReportId = null;
|
|
18972
17916
|
constructor() {
|
|
18973
17917
|
// Each chat turn carries what the viewer has on screen. Only the function
|
|
18974
17918
|
// is set here; it is called when a turn is sent.
|
|
@@ -18997,17 +17941,14 @@ class FsAiReportComponent {
|
|
|
18997
17941
|
this._loadReport(reportId);
|
|
18998
17942
|
}
|
|
18999
17943
|
else {
|
|
19000
|
-
// Forget the opened report, so a host that comes back to it opens
|
|
19001
|
-
// it again.
|
|
19002
|
-
this._openedReportId = null;
|
|
19003
17944
|
this.report.set(null);
|
|
19004
17945
|
this._buildReportConfig();
|
|
19005
17946
|
}
|
|
19006
17947
|
});
|
|
19007
17948
|
});
|
|
19008
|
-
//
|
|
19009
|
-
//
|
|
19010
|
-
//
|
|
17949
|
+
// resetToDefaults() moves the state version. The bar is built again for
|
|
17950
|
+
// it, and reportFilterKey, which carries it, recreates fs-filter: it reads
|
|
17951
|
+
// its config once.
|
|
19011
17952
|
effect(() => {
|
|
19012
17953
|
const version = this._filterState.stateVersion();
|
|
19013
17954
|
untracked(() => {
|
|
@@ -19016,12 +17957,6 @@ class FsAiReportComponent {
|
|
|
19016
17957
|
}
|
|
19017
17958
|
});
|
|
19018
17959
|
});
|
|
19019
|
-
// An opened view (a saved view picked, or the report's defaults) opens
|
|
19020
|
-
// on its page.
|
|
19021
|
-
effect(() => {
|
|
19022
|
-
this._filterState.appliedVersion();
|
|
19023
|
-
untracked(() => this._openAppliedPage());
|
|
19024
|
-
});
|
|
19025
17960
|
this._buildReportConfig();
|
|
19026
17961
|
}
|
|
19027
17962
|
toggleEditMode() {
|
|
@@ -19112,14 +18047,15 @@ class FsAiReportComponent {
|
|
|
19112
18047
|
});
|
|
19113
18048
|
}
|
|
19114
18049
|
// The canvas moved to another page. The report-level filters stay as they
|
|
19115
|
-
// are (they live in the state service)
|
|
18050
|
+
// are (they live in the state service); the page goes into the URL.
|
|
19116
18051
|
onCanvasPageChanged(pageId) {
|
|
19117
18052
|
// The asked-for page has been consumed; from here the canvas owns the page.
|
|
19118
18053
|
this.pageId.set(null);
|
|
19119
18054
|
this.chatPageId.set(pageId);
|
|
19120
|
-
// The open page is part of the
|
|
18055
|
+
// The open page is part of the state the tiles and exports read.
|
|
19121
18056
|
this._filterState.setPage(pageId);
|
|
19122
18057
|
this.pageChanged.emit(pageId);
|
|
18058
|
+
this._writePageToUrl(pageId);
|
|
19123
18059
|
// The bar shows the page groups of the page on screen.
|
|
19124
18060
|
if (hasPageGroup(this.report())) {
|
|
19125
18061
|
this._buildReportConfig();
|
|
@@ -19158,12 +18094,12 @@ class FsAiReportComponent {
|
|
|
19158
18094
|
// search and hidden columns, a chart's drill path and the groups that match
|
|
19159
18095
|
// 'any'; a server that does not read it uses each list's saved sort. The
|
|
19160
18096
|
// notes get the filter lines as the cover words them (exportLines; a This
|
|
19161
|
-
// Page zip names only the groups that reach the page) and
|
|
19162
|
-
//
|
|
19163
|
-
//
|
|
19164
|
-
//
|
|
19165
|
-
//
|
|
19166
|
-
//
|
|
18097
|
+
// Page zip names only the groups that reach the page), and each file is
|
|
18098
|
+
// named after its tile's title as the canvas header reads it (titles). All
|
|
18099
|
+
// of it is read once, when the export starts (exportWording). `thisPage`
|
|
18100
|
+
// narrows it to the page the canvas is showing. Unlike the PDF/PowerPoint
|
|
18101
|
+
// exports the files are written server-side, so they are not capped by
|
|
18102
|
+
// what a slide can hold.
|
|
19167
18103
|
exportData(thisPage = false) {
|
|
19168
18104
|
const report = this.report();
|
|
19169
18105
|
if (!report) {
|
|
@@ -19173,11 +18109,9 @@ class FsAiReportComponent {
|
|
|
19173
18109
|
const filters = this._filterState.resolveForReport(report, pageId);
|
|
19174
18110
|
const frequency = this._filterState.frequency();
|
|
19175
18111
|
const snapshot = this._collector.snapshot(report);
|
|
19176
|
-
const states = zipExportStates(report, snapshot.
|
|
19177
|
-
const viewId = this._views.openView()?.id ?? null;
|
|
19178
|
-
const viewChanged = this._views.viewChanged();
|
|
18112
|
+
const states = zipExportStates(report, snapshot.state, pageId);
|
|
19179
18113
|
this._process.download('Exporting data', this._collector.exportWording(report, snapshot, pageId)
|
|
19180
|
-
.pipe(switchMap(({ lines, titles }) => this._reportData.exportReport(report.id, filters, pageId, frequency, lines, states,
|
|
18114
|
+
.pipe(switchMap(({ lines, titles }) => this._reportData.exportReport(report.id, filters, pageId, frequency, lines, states, titles))));
|
|
19181
18115
|
}
|
|
19182
18116
|
// React to each chat turn (fs-chat handles the request + persistence); we
|
|
19183
18117
|
// only care about the report side effects this context's backend flags.
|
|
@@ -19218,8 +18152,7 @@ class FsAiReportComponent {
|
|
|
19218
18152
|
// What the viewer has on screen as a chat turn sends it. The canvas
|
|
19219
18153
|
// selection counts only in edit mode: it is drawn only then, and it is kept
|
|
19220
18154
|
// (not cleared) when edit mode is left. The match is sent only while it is
|
|
19221
|
-
// 'any', the drilled charts only when one is drilled
|
|
19222
|
-
// (and whether the screen differs from it) only while one is open.
|
|
18155
|
+
// 'any', and the drilled charts only when one is drilled.
|
|
19223
18156
|
_chatViewerState() {
|
|
19224
18157
|
const snapshot = this._filterState.snapshot();
|
|
19225
18158
|
const drills = chatViewerDrills(snapshot, this._filterState.formatContext().locale);
|
|
@@ -19229,7 +18162,6 @@ class FsAiReportComponent {
|
|
|
19229
18162
|
filters: this._filterState.viewerFilters(),
|
|
19230
18163
|
...chatViewerMatch(snapshot),
|
|
19231
18164
|
...(drills.length ? { drills } : {}),
|
|
19232
|
-
...chatViewFields(this._views.openView(), this._views.viewChanged()),
|
|
19233
18165
|
};
|
|
19234
18166
|
}
|
|
19235
18167
|
_resizeTo(clientX) {
|
|
@@ -19241,8 +18173,25 @@ class FsAiReportComponent {
|
|
|
19241
18173
|
const clamped = Math.min(0.8, Math.max(0.2, ratio));
|
|
19242
18174
|
this._chatPanel.nativeElement.style.flexBasis = `${clamped * 100}%`;
|
|
19243
18175
|
}
|
|
19244
|
-
// The page
|
|
19245
|
-
//
|
|
18176
|
+
// The page on screen into the URL (?page=), replacing the current history
|
|
18177
|
+
// entry rather than adding one, so Back leaves the report instead of
|
|
18178
|
+
// stepping through its pages. Only the page param is touched; the host's
|
|
18179
|
+
// own query params stay. Nothing is written when the param is off, or when
|
|
18180
|
+
// the URL already names the page.
|
|
18181
|
+
_writePageToUrl(pageId) {
|
|
18182
|
+
const param = this.pageQueryParam();
|
|
18183
|
+
if (!param || !pageId || this._pageIdFromUrl() === pageId) {
|
|
18184
|
+
return;
|
|
18185
|
+
}
|
|
18186
|
+
this._router.navigate([], {
|
|
18187
|
+
relativeTo: this._route,
|
|
18188
|
+
queryParams: { [param]: pageId },
|
|
18189
|
+
queryParamsHandling: 'merge',
|
|
18190
|
+
replaceUrl: true,
|
|
18191
|
+
});
|
|
18192
|
+
}
|
|
18193
|
+
// The page id the URL holds right now (?page=), or null when the page param
|
|
18194
|
+
// is off or the value is not a usable id.
|
|
19246
18195
|
_pageIdFromUrl() {
|
|
19247
18196
|
const param = this.pageQueryParam();
|
|
19248
18197
|
if (!param) {
|
|
@@ -19251,45 +18200,20 @@ class FsAiReportComponent {
|
|
|
19251
18200
|
const pageId = Number(this._route.snapshot.queryParamMap.get(param));
|
|
19252
18201
|
return Number.isFinite(pageId) && pageId > 0 ? pageId : null;
|
|
19253
18202
|
}
|
|
18203
|
+
// Every load sets the viewer state up for the report (init): its first
|
|
18204
|
+
// load opens it on its authored defaults, and a later one (a chat edit, a
|
|
18205
|
+
// settings save) keeps the state in memory.
|
|
19254
18206
|
_loadReport(reportId, emitRenamed = false) {
|
|
19255
18207
|
this._reportData.get(reportId)
|
|
19256
|
-
.pipe(
|
|
18208
|
+
.pipe(tap((report) => this._filterState.init(report)), takeUntilDestroyed(this._destroyRef))
|
|
19257
18209
|
.subscribe((report) => {
|
|
19258
18210
|
this.report.set(report);
|
|
19259
|
-
this._openAppliedPage();
|
|
19260
18211
|
this._buildReportConfig();
|
|
19261
18212
|
if (emitRenamed) {
|
|
19262
18213
|
this.renamed.emit(report);
|
|
19263
18214
|
}
|
|
19264
18215
|
});
|
|
19265
18216
|
}
|
|
19266
|
-
// Sets the viewer state up for a loaded report. On its first load a server
|
|
19267
|
-
// that keeps saved views opens it on the viewer's default view, else on the
|
|
19268
|
-
// defaults (which waits for the list of views); any other server opens it
|
|
19269
|
-
// on the defaults. Every later load keeps the state in memory.
|
|
19270
|
-
_openReport(report) {
|
|
19271
|
-
const firstLoad = report.id !== this._openedReportId;
|
|
19272
|
-
this._openedReportId = report.id;
|
|
19273
|
-
if (!this._views.serves(report)) {
|
|
19274
|
-
this._filterState.init(report);
|
|
19275
|
-
return of(undefined);
|
|
19276
|
-
}
|
|
19277
|
-
if (!firstLoad) {
|
|
19278
|
-
this._filterState.init(report);
|
|
19279
|
-
this._views.refresh(report);
|
|
19280
|
-
return of(undefined);
|
|
19281
|
-
}
|
|
19282
|
-
return this._views.open(report, this.pageId());
|
|
19283
|
-
}
|
|
19284
|
-
// Moves the canvas to the page an opened view put on screen (a ?page= the
|
|
19285
|
-
// report opened with already won inside apply()). A reset, or a reload,
|
|
19286
|
-
// leaves the page.
|
|
19287
|
-
_openAppliedPage() {
|
|
19288
|
-
const pageId = this._filterState.page();
|
|
19289
|
-
if (pageId && pageId !== this.chatPageId()) {
|
|
19290
|
-
this.pageId.set(pageId);
|
|
19291
|
-
}
|
|
19292
|
-
}
|
|
19293
18217
|
// Report-level filter groups in their saved order — the ones that render in
|
|
19294
18218
|
// the toolbar (component-level groups render on their components), with
|
|
19295
18219
|
// the page groups of the page on screen.
|
|
@@ -19323,26 +18247,134 @@ class FsAiReportComponent {
|
|
|
19323
18247
|
if (matchItem) {
|
|
19324
18248
|
items.push(matchItem);
|
|
19325
18249
|
}
|
|
18250
|
+
const savedFilters = this._savedFiltersConfig();
|
|
19326
18251
|
this._barVersion.set(this._filterState.stateVersion());
|
|
19327
18252
|
this.reportFilterConfig.set({
|
|
19328
18253
|
// fs-filter never touches the URL or persists filter state: a report
|
|
19329
18254
|
// keeps no state in the URL. ReportFilterStateService owns the values.
|
|
19330
18255
|
queryParam: false,
|
|
19331
18256
|
persist: false,
|
|
19332
|
-
items,
|
|
18257
|
+
items: savedFilters ? this._keepOnPick(items) : items,
|
|
19333
18258
|
change: (query) => this._barChanged(query ?? {}, !!matchItem),
|
|
19334
|
-
// 'Clear filters' puts the screen back on the
|
|
19335
|
-
//
|
|
19336
|
-
//
|
|
19337
|
-
|
|
19338
|
-
// bar's items only.
|
|
19339
|
-
clear: this._views.serves(report) ? () => this._filterState.resetToBase() : undefined,
|
|
18259
|
+
// 'Clear filters' puts the screen back on the report's authored
|
|
18260
|
+
// defaults and drops the temporary layer. fs-filter reports its own
|
|
18261
|
+
// cleared items (change) just before this, so the reset wins.
|
|
18262
|
+
clear: () => this._filterState.resetToDefaults(),
|
|
19340
18263
|
// The chip shows exactly while the screen differs from what a clear
|
|
19341
|
-
// returns to (C5)
|
|
19342
|
-
|
|
19343
|
-
|
|
18264
|
+
// returns to (C5).
|
|
18265
|
+
clearVisible: () => this._filterState.differsFromDefaults(),
|
|
18266
|
+
savedFilters,
|
|
18267
|
+
// fs-filter calls init once its saved filters are loaded.
|
|
18268
|
+
...(savedFilters && reportId ? { init: () => this._openOnFirstSavedFilter(reportId) } : {}),
|
|
19344
18269
|
});
|
|
19345
18270
|
}
|
|
18271
|
+
// A report opens on its first saved filter: once its saved filters are
|
|
18272
|
+
// first loaded, the first one is picked as a viewer would pick it, so it
|
|
18273
|
+
// applies on every page through the same change (_barChanged). Once per
|
|
18274
|
+
// report opened: the bar is recreated as the screen changes, and a later
|
|
18275
|
+
// build must not pick it again over the viewer's own edits. Reordering the
|
|
18276
|
+
// saved filters changes which one a report opens on, for everyone.
|
|
18277
|
+
_openOnFirstSavedFilter(reportId) {
|
|
18278
|
+
const savedFilterController = this._reportBar?.savedFilterController;
|
|
18279
|
+
if (this._openedOnSavedFilterReportId === reportId || !savedFilterController) {
|
|
18280
|
+
return;
|
|
18281
|
+
}
|
|
18282
|
+
this._openedOnSavedFilterReportId = reportId;
|
|
18283
|
+
const [first] = savedFilterController.savedFilters;
|
|
18284
|
+
if (first) {
|
|
18285
|
+
savedFilterController.setActiveFilter(first);
|
|
18286
|
+
}
|
|
18287
|
+
}
|
|
18288
|
+
// The bar's saved filters, shared by everyone who may open the report, or
|
|
18289
|
+
// undefined when the bar has none to offer: a report not yet saved has no
|
|
18290
|
+
// id to keep them under, and a bar with no report-wide filter has nothing
|
|
18291
|
+
// a saved filter holds. fs-filter's own Saved filters menu lists them,
|
|
18292
|
+
// saves the bar's values as a new one or over the picked one, orders and
|
|
18293
|
+
// deletes them. What it saves is kept to the report-wide filters
|
|
18294
|
+
// (savedFilterValues): a page's own filters, the grain and the match are
|
|
18295
|
+
// left out. The list is read every time the bar is built; one that cannot
|
|
18296
|
+
// be read shows as empty rather than keeping the bar from drawing.
|
|
18297
|
+
_savedFiltersConfig() {
|
|
18298
|
+
const reportId = this.report()?.id ?? null;
|
|
18299
|
+
if (!reportId || !savedFilterGroups(this._reportGroups()).length) {
|
|
18300
|
+
return undefined;
|
|
18301
|
+
}
|
|
18302
|
+
return {
|
|
18303
|
+
load: () => this._reportData
|
|
18304
|
+
.savedFilters(reportId, { context: new HttpContext().set(DisplayApiError, false) })
|
|
18305
|
+
.pipe(catchError(() => of([])), map((savedFilters) => (savedFilters ?? []).map((savedFilter) => this._barSavedFilter(savedFilter)))),
|
|
18306
|
+
save: (savedFilter) => {
|
|
18307
|
+
const filters = savedFilterValues(savedFilter.filters, this._savedFilterGroups());
|
|
18308
|
+
const save$ = savedFilter.id
|
|
18309
|
+
? this._reportData.updateSavedFilter(reportId, Number(savedFilter.id), {
|
|
18310
|
+
...(savedFilter.name ? { name: savedFilter.name } : {}),
|
|
18311
|
+
filters,
|
|
18312
|
+
})
|
|
18313
|
+
: this._reportData.createSavedFilter(reportId, { name: savedFilter.name ?? '', filters });
|
|
18314
|
+
return save$.pipe(map((saved) => this._barSavedFilter(saved)));
|
|
18315
|
+
},
|
|
18316
|
+
order: (savedFilters) => this._reportData
|
|
18317
|
+
.saveSavedFilterPositions(reportId, savedFilters.map((savedFilter) => Number(savedFilter.id)))
|
|
18318
|
+
.pipe(map((ordered) => (ordered ?? []).map((savedFilter) => this._barSavedFilter(savedFilter)))),
|
|
18319
|
+
delete: (savedFilter) => this._reportData
|
|
18320
|
+
.deleteSavedFilter(reportId, Number(savedFilter.id))
|
|
18321
|
+
.pipe(map(() => savedFilter)),
|
|
18322
|
+
};
|
|
18323
|
+
}
|
|
18324
|
+
// The report's report-wide filter groups: the ones a saved filter holds.
|
|
18325
|
+
_savedFilterGroups() {
|
|
18326
|
+
return savedFilterGroups(this.report()?.filterGroups);
|
|
18327
|
+
}
|
|
18328
|
+
// A saved filter as the bar's menu holds it. Its values are kept to the
|
|
18329
|
+
// report-wide filters the report has now (a key it no longer has, or a
|
|
18330
|
+
// value its filter can no longer take, is dropped rather than applied), and
|
|
18331
|
+
// each read of them hands out a copy, so nothing the bar does to the values
|
|
18332
|
+
// it set changes the saved filter. A read also notes the bar's values
|
|
18333
|
+
// (_keepBarOnPick): picking a saved filter reads its values and sets the
|
|
18334
|
+
// bar from them in one go, and the items it does not hold are put back.
|
|
18335
|
+
_barSavedFilter(savedFilter) {
|
|
18336
|
+
const filters = savedFilterValues(savedFilter.filters, this._savedFilterGroups());
|
|
18337
|
+
const keepBar = () => this._keepBarOnPick();
|
|
18338
|
+
return {
|
|
18339
|
+
id: savedFilter.id,
|
|
18340
|
+
name: savedFilter.name,
|
|
18341
|
+
get filters() {
|
|
18342
|
+
keepBar();
|
|
18343
|
+
return JSON.parse(JSON.stringify(filters));
|
|
18344
|
+
},
|
|
18345
|
+
};
|
|
18346
|
+
}
|
|
18347
|
+
// Notes the bar's values for a pick of a saved filter (_barValuesAtPick).
|
|
18348
|
+
// fs-filter sets the bar from the saved filter's values in the same task
|
|
18349
|
+
// as it reads them, so the note is dropped once this task is done: any
|
|
18350
|
+
// other read of a saved filter's values (its row in View all, a save) sets
|
|
18351
|
+
// nothing.
|
|
18352
|
+
_keepBarOnPick() {
|
|
18353
|
+
if (!this._reportBar) {
|
|
18354
|
+
return;
|
|
18355
|
+
}
|
|
18356
|
+
this._barValuesAtPick = { ...this._reportBar.values };
|
|
18357
|
+
queueMicrotask(() => this._barValuesAtPick = null);
|
|
18358
|
+
}
|
|
18359
|
+
// The bar's items, each one a saved filter does not hold (the group-by
|
|
18360
|
+
// grain, the match, a page's own filters) set back to its value when a pick
|
|
18361
|
+
// of a saved filter sets it. fs-filter sets every item from a saved filter
|
|
18362
|
+
// and calls each one's change before it reports the bar's change, so the
|
|
18363
|
+
// change the bar reports carries these items as they were: the pick moves
|
|
18364
|
+
// the report-wide filters only, on every page, through the same change as
|
|
18365
|
+
// a viewer's own edit (_barChanged). On any other change an item's change
|
|
18366
|
+
// comes after the bar's, with no pick noted, and does nothing.
|
|
18367
|
+
_keepOnPick(items) {
|
|
18368
|
+
const held = new Set(this._savedFilterGroups().map((group) => groupItemName(group)));
|
|
18369
|
+
return items.map((item) => (held.has(item.name)
|
|
18370
|
+
? item
|
|
18371
|
+
: { ...item, change: (barItem) => this._keepItemOnPick(barItem) }));
|
|
18372
|
+
}
|
|
18373
|
+
_keepItemOnPick(item) {
|
|
18374
|
+
if (this._barValuesAtPick) {
|
|
18375
|
+
item.setValue(this._barValuesAtPick[item.name], false);
|
|
18376
|
+
}
|
|
18377
|
+
}
|
|
19346
18378
|
// The bar's values into the state: every group it shows, the grain and,
|
|
19347
18379
|
// when the bar has the control, the match.
|
|
19348
18380
|
_barChanged(query, hasMatch) {
|
|
@@ -19377,8 +18409,6 @@ class FsAiReportComponent {
|
|
|
19377
18409
|
ReportFilterStateService,
|
|
19378
18410
|
// The bars' select lists, narrowed by the other filters; per open report.
|
|
19379
18411
|
ReportFilterOptionsService,
|
|
19380
|
-
// The report's saved views and the order it opens in; per open report.
|
|
19381
|
-
ReportViewService,
|
|
19382
18412
|
ReportExportCollectorService,
|
|
19383
18413
|
ReportPptxService,
|
|
19384
18414
|
ReportPdfService,
|
|
@@ -19389,11 +18419,11 @@ class FsAiReportComponent {
|
|
|
19389
18419
|
ReportAiChatData,
|
|
19390
18420
|
{ provide: FsAiChatData, useExisting: ReportAiChatData },
|
|
19391
18421
|
// Tree-shaken ECharts core + the 'report' house theme, loaded lazily.
|
|
19392
|
-
provideEchartsCore({ echarts: () => import('./firestitch-report-echarts-
|
|
18422
|
+
provideEchartsCore({ echarts: () => import('./firestitch-report-echarts-B0RWUjR8.mjs').then((module) => module.default) }),
|
|
19393
18423
|
// The bar's date pickers start their weeks on Monday and list the built-in
|
|
19394
18424
|
// presets; a preset only sets the days.
|
|
19395
18425
|
provideReportDatePicker(),
|
|
19396
|
-
], 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
|
|
18426
|
+
], 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 }, { propertyName: "_reportBar", first: true, predicate: FilterComponent, 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, with the\n report's saved filters in fs-filter's own Saved filters menu.\n 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\n block is 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: i1$2.FilterComponent, selector: "fs-filter", inputs: ["config"], outputs: ["closed", "opened", "ready"] }, { kind: "ngmodule", type: FsMenuModule }, { kind: "component", type: i2.FsMenuComponent, selector: "fs-menu", inputs: ["class", "buttonClass", "buttonType", "buttonColor"], outputs: ["opened", "closed"] }, { kind: "directive", type: i2.FsMenuItemDirective, selector: "fs-menu-group,[fs-menu-item]" }, { kind: "directive", type: i2.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$1.MatIcon, selector: "mat-icon", inputs: ["color", "inline", "svgIcon", "fontSet", "fontIcon"], exportAs: ["matIcon"] }, { kind: "ngmodule", type: MatTooltipModule }, { kind: "directive", type: i2$4.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 });
|
|
19397
18427
|
}
|
|
19398
18428
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: FsAiReportComponent, decorators: [{
|
|
19399
18429
|
type: Component,
|
|
@@ -19403,8 +18433,6 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImpo
|
|
|
19403
18433
|
ReportFilterStateService,
|
|
19404
18434
|
// The bars' select lists, narrowed by the other filters; per open report.
|
|
19405
18435
|
ReportFilterOptionsService,
|
|
19406
|
-
// The report's saved views and the order it opens in; per open report.
|
|
19407
|
-
ReportViewService,
|
|
19408
18436
|
ReportExportCollectorService,
|
|
19409
18437
|
ReportPptxService,
|
|
19410
18438
|
ReportPdfService,
|
|
@@ -19415,7 +18443,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImpo
|
|
|
19415
18443
|
ReportAiChatData,
|
|
19416
18444
|
{ provide: FsAiChatData, useExisting: ReportAiChatData },
|
|
19417
18445
|
// Tree-shaken ECharts core + the 'report' house theme, loaded lazily.
|
|
19418
|
-
provideEchartsCore({ echarts: () => import('./firestitch-report-echarts-
|
|
18446
|
+
provideEchartsCore({ echarts: () => import('./firestitch-report-echarts-B0RWUjR8.mjs').then((module) => module.default) }),
|
|
19419
18447
|
// The bar's date pickers start their weeks on Monday and list the built-in
|
|
19420
18448
|
// presets; a preset only sets the days.
|
|
19421
18449
|
provideReportDatePicker(),
|
|
@@ -19427,8 +18455,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImpo
|
|
|
19427
18455
|
MatIconModule,
|
|
19428
18456
|
MatTooltipModule,
|
|
19429
18457
|
ReportCanvasComponent,
|
|
19430
|
-
|
|
19431
|
-
], 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 <!-- The saved views: shown only when the server keeps them. -->\n @if (viewsServed()) {\n <app-report-view-picker></app-report-view-picker>\n }\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"] }]
|
|
18458
|
+
], 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, with the\n report's saved filters in fs-filter's own Saved filters menu.\n 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\n block is 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"] }]
|
|
19432
18459
|
}], ctorParameters: () => [], propDecorators: { _split: [{
|
|
19433
18460
|
type: ViewChild,
|
|
19434
18461
|
args: ['split', { static: true }]
|
|
@@ -19438,6 +18465,9 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImpo
|
|
|
19438
18465
|
}], _canvas: [{
|
|
19439
18466
|
type: ViewChild,
|
|
19440
18467
|
args: [ReportCanvasComponent]
|
|
18468
|
+
}], _reportBar: [{
|
|
18469
|
+
type: ViewChild,
|
|
18470
|
+
args: [FilterComponent]
|
|
19441
18471
|
}] } });
|
|
19442
18472
|
|
|
19443
18473
|
/*
|
|
@@ -19453,4 +18483,4 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImpo
|
|
|
19453
18483
|
*/
|
|
19454
18484
|
|
|
19455
18485
|
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, ReportValueFormat as j, ReportValueFormula as k, ReportListColumnFormat as l, ReportNumberUnits as m, ReportChartDataLabels as n, ReportChartColor as o, ReportChartLineStyle as p, ReportChartMarkStat as q, ReportChartDateLabel as r, ReportChangePolarity as s };
|
|
19456
|
-
//# sourceMappingURL=firestitch-report-firestitch-report-
|
|
18486
|
+
//# sourceMappingURL=firestitch-report-firestitch-report-CJW0lLdW.mjs.map
|