@firestitch/report 18.11.1 → 18.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/app/reports/components/component-grid/component-grid.component.d.ts +33 -0
- package/app/reports/components/component-grid/report-grid-cell.pipe.d.ts +11 -0
- package/app/reports/components/component-grid/report-grid-formatter.d.ts +9 -0
- package/app/reports/components/report-component/report-component.component.d.ts +0 -1
- package/app/reports/data/report-grid-source.d.ts +22 -0
- package/app/reports/data/report.data.d.ts +3 -1
- package/app/reports/export/report-export-collector.service.d.ts +8 -1
- package/app/reports/export/report-filter-lines.d.ts +2 -0
- package/app/reports/export/report-grid-export.d.ts +20 -0
- package/app/reports/export/report-pdf.service.d.ts +2 -0
- package/app/reports/export/report-pptx.service.d.ts +1 -0
- package/app/reports/group-by.d.ts +3 -0
- package/app/reports/interfaces/report-chat-viewer-state.interface.d.ts +2 -0
- package/app/reports/interfaces/report-chat-viewer-tile.interface.d.ts +5 -0
- package/app/reports/interfaces/report-export-snapshot.interface.d.ts +2 -0
- package/app/reports/interfaces/report-grid-config.interface.d.ts +5 -0
- package/app/reports/interfaces/report-grid-line.interface.d.ts +6 -0
- package/app/reports/interfaces/report-grid-params.interface.d.ts +7 -0
- package/app/reports/interfaces/report-grid-report.interface.d.ts +2 -0
- package/app/reports/interfaces/report-grid-session-layout.interface.d.ts +5 -0
- package/app/reports/interfaces/report.interface.d.ts +10 -2
- package/app/reports/report-chat-viewer-state.d.ts +4 -1
- package/app/reports/report-filter-items.d.ts +1 -1
- package/app/reports/report-grid-layout.d.ts +10 -0
- package/app/reports/services/report-filter-state.service.d.ts +13 -0
- package/app/reports/tile-data-state.d.ts +2 -2
- package/esm2022/app/reports/components/component-grid/component-grid.component.mjs +120 -0
- package/esm2022/app/reports/components/component-grid/report-grid-cell.pipe.mjs +41 -0
- package/esm2022/app/reports/components/component-grid/report-grid-formatter.mjs +94 -0
- package/esm2022/app/reports/components/component-text/component-text.component.mjs +1 -1
- package/esm2022/app/reports/components/report-component/report-component.component.mjs +28 -25
- package/esm2022/app/reports/data/report-grid-source.mjs +105 -0
- package/esm2022/app/reports/data/report.data.mjs +15 -4
- package/esm2022/app/reports/export/report-export-collector.service.mjs +72 -7
- package/esm2022/app/reports/export/report-filter-lines.mjs +68 -1
- package/esm2022/app/reports/export/report-grid-export.mjs +48 -0
- package/esm2022/app/reports/export/report-pdf.service.mjs +47 -17
- package/esm2022/app/reports/export/report-pptx.service.mjs +48 -4
- package/esm2022/app/reports/group-by.mjs +13 -0
- package/esm2022/app/reports/interfaces/report-chat-viewer-state.interface.mjs +1 -1
- package/esm2022/app/reports/interfaces/report-chat-viewer-tile.interface.mjs +2 -0
- package/esm2022/app/reports/interfaces/report-export-snapshot.interface.mjs +1 -1
- package/esm2022/app/reports/interfaces/report-grid-config.interface.mjs +2 -0
- package/esm2022/app/reports/interfaces/report-grid-line.interface.mjs +2 -0
- package/esm2022/app/reports/interfaces/report-grid-params.interface.mjs +2 -0
- package/esm2022/app/reports/interfaces/report-grid-report.interface.mjs +2 -0
- package/esm2022/app/reports/interfaces/report-grid-session-layout.interface.mjs +2 -0
- package/esm2022/app/reports/interfaces/report.interface.mjs +1 -1
- package/esm2022/app/reports/report-chat-viewer-state.mjs +21 -6
- package/esm2022/app/reports/report-filter-items.mjs +9 -7
- package/esm2022/app/reports/report-grid-layout.mjs +67 -0
- package/esm2022/app/reports/report-tile.mjs +5 -5
- package/esm2022/app/reports/services/report-filter-state.service.mjs +170 -18
- package/esm2022/app/reports/tile-data-state.mjs +12 -17
- package/esm2022/app/reports/views/report/fs-ai-report.component.mjs +8 -4
- package/esm2022/app/reports/views/report/report.component.mjs +2 -2
- package/fesm2022/{firestitch-report-echarts-B0RWUjR8.mjs → firestitch-report-echarts-EecIloKo.mjs} +2 -2
- package/fesm2022/{firestitch-report-echarts-B0RWUjR8.mjs.map → firestitch-report-echarts-EecIloKo.mjs.map} +1 -1
- package/fesm2022/{firestitch-report-firestitch-report-CJW0lLdW.mjs → firestitch-report-firestitch-report-DbdLxQSE.mjs} +945 -109
- package/fesm2022/firestitch-report-firestitch-report-DbdLxQSE.mjs.map +1 -0
- package/fesm2022/firestitch-report.mjs +1 -1
- package/package.json +4 -2
- package/fesm2022/firestitch-report-firestitch-report-CJW0lLdW.mjs.map +0 -1
|
@@ -27,14 +27,16 @@ import * as i6$1 from '@firestitch/message';
|
|
|
27
27
|
import { FsMessage, FsMessageModule } from '@firestitch/message';
|
|
28
28
|
import * as i3$1 from '@firestitch/zoom-pan';
|
|
29
29
|
import { FsZoomPanComponent, FsZoomPanModule } from '@firestitch/zoom-pan';
|
|
30
|
-
import { of, forkJoin, Subject, defer, merge, map as map$1, switchMap as switchMap$1, timer, catchError as catchError$1, filter as filter$1, tap as tap$1, distinctUntilChanged,
|
|
31
|
-
import { FsApi,
|
|
30
|
+
import { of, forkJoin, Subject, defer, merge, map as map$1, switchMap as switchMap$1, timer, catchError as catchError$1, filter as filter$1, mergeMap, EMPTY, concatWith, throwError, tap as tap$1, distinctUntilChanged, from, debounceTime, BehaviorSubject } from 'rxjs';
|
|
31
|
+
import { FsApi, RequestMethod, StreamEventType, DisplayApiError } from '@firestitch/api';
|
|
32
|
+
import { GridFieldType, GridGranularity, canonicalGridLayout, convertDateFilter, GridFormat, GridUnits, FsGridComponent, FsGridCellDirective, GridRowKind, readGridTable } from '@firestitch/grid';
|
|
32
33
|
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
34
|
import { toZonedTime } from 'date-fns-tz';
|
|
34
35
|
import { MatProgressSpinner } from '@angular/material/progress-spinner';
|
|
35
36
|
import { MatCheckbox } from '@angular/material/checkbox';
|
|
36
37
|
import * as i3 from '@firestitch/dialog';
|
|
37
38
|
import { FsDialogModule } from '@firestitch/dialog';
|
|
39
|
+
import { HttpErrorResponse, HttpContext } from '@angular/common/http';
|
|
38
40
|
import { NgTemplateOutlet, DOCUMENT } from '@angular/common';
|
|
39
41
|
import * as i1 from '@firestitch/list';
|
|
40
42
|
import { FsListComponent, PaginationStrategy, FsListModule } from '@firestitch/list';
|
|
@@ -55,7 +57,6 @@ import * as i3$2 from '@firestitch/mask';
|
|
|
55
57
|
import { FsMaskModule } from '@firestitch/mask';
|
|
56
58
|
import * as i6 from '@firestitch/tabs';
|
|
57
59
|
import { FsTabsModule } from '@firestitch/tabs';
|
|
58
|
-
import { HttpContext } from '@angular/common/http';
|
|
59
60
|
import * as i9 from '@firestitch/datepicker';
|
|
60
61
|
import { FsDatePickerModule, FS_DATEPICKER_CONFIG_DEFAULT, FS_DATEPICKER_CONFIG } from '@firestitch/datepicker';
|
|
61
62
|
import * as i4 from '@firestitch/skeleton';
|
|
@@ -337,6 +338,17 @@ class ReportData {
|
|
|
337
338
|
...config,
|
|
338
339
|
});
|
|
339
340
|
}
|
|
341
|
+
// One grid tile's read (ReportGridSource): the same route as componentData,
|
|
342
|
+
// its body's `grid` the grid's request. The answer streams as
|
|
343
|
+
// newline-delimited JSON, one event per line: {report} first, then
|
|
344
|
+
// {grid: part} for each part of the read, a heartbeat {at} between
|
|
345
|
+
// statements, and the envelope with the read's summary last. The body's
|
|
346
|
+
// filters and state narrow the rows as they narrow any tile's; the grid's
|
|
347
|
+
// own column filters travel inside grid.layout. `isExport` is
|
|
348
|
+
// componentData's.
|
|
349
|
+
componentGrid(reportId, componentId, filters, state, grid, isExport = false, config = {}) {
|
|
350
|
+
return this._api.stream(RequestMethod.Post, this._path(`${reportId}/components/${componentId}/data`), { filters, state, export: isExport, grid }, config);
|
|
351
|
+
}
|
|
340
352
|
// The numbers of a page's values — or the master's, for the header and
|
|
341
353
|
// footer — in one request: a row per value that page reads, shown by some
|
|
342
354
|
// text or read by a formula it shows (ReportPageValue). `filters` carries
|
|
@@ -380,8 +392,8 @@ class ReportData {
|
|
|
380
392
|
// resolves to a presigned URL for a zip of one CSV per component, foldered by
|
|
381
393
|
// page, plus an 'Export Notes.txt'. `filters` carries the resolved values for
|
|
382
394
|
// every component together; each one picks out its own. `frequency` is the
|
|
383
|
-
// report's group-by grain (the server re-buckets only
|
|
384
|
-
// it), and `filterLines` is the filter bar as the viewer reads it
|
|
395
|
+
// report's group-by grain (the server re-buckets only the tiles that follow
|
|
396
|
+
// it: time-series charts and tables over a time axis), and `filterLines` is the filter bar as the viewer reads it
|
|
385
397
|
// (exportLines), which the notes file writes. Their text goes as
|
|
386
398
|
// filterSummary too, for a server that does not read filterLines; the
|
|
387
399
|
// lines are left out when there are none. `states` is each tile's own
|
|
@@ -4448,6 +4460,19 @@ function crumbLabel(step, locale, full) {
|
|
|
4448
4460
|
return formatPeriodLabel(String(step.value), step.level, false, locale, full ? ReportChartDateLabel.Long : ReportChartDateLabel.Short);
|
|
4449
4461
|
}
|
|
4450
4462
|
|
|
4463
|
+
// Whether a component is a chart on a time x-axis.
|
|
4464
|
+
function isTimeSeriesChart(component) {
|
|
4465
|
+
return component.type === 'chart' && component.config?.xAxis?.kind === 'time';
|
|
4466
|
+
}
|
|
4467
|
+
// Whether the report's group-by grain applies to a tile: the server names the
|
|
4468
|
+
// field it re-buckets (followsGroupBy) on a time-series chart and on a table
|
|
4469
|
+
// over a time axis, and drops the grain for every other tile. A server that
|
|
4470
|
+
// predates tables following the group-by names none, and there a chart on a
|
|
4471
|
+
// time x-axis is the only tile that follows it.
|
|
4472
|
+
function followsGroupBy(component) {
|
|
4473
|
+
return !!component.followsGroupBy || isTimeSeriesChart(component);
|
|
4474
|
+
}
|
|
4475
|
+
|
|
4451
4476
|
// A date-range boundary as the picked LOCAL calendar day (no time, no tz).
|
|
4452
4477
|
// Date-range filters are calendar-day semantics, so the boundary must carry
|
|
4453
4478
|
// only Y-M-D — never a timezone-bearing instant the backend would shift.
|
|
@@ -4784,11 +4809,12 @@ function listSearchItem(value) {
|
|
|
4784
4809
|
default: value ?? undefined,
|
|
4785
4810
|
};
|
|
4786
4811
|
}
|
|
4787
|
-
// Whether the report has a
|
|
4788
|
-
// x-axis
|
|
4789
|
-
//
|
|
4790
|
-
|
|
4791
|
-
|
|
4812
|
+
// Whether the report has a tile the group-by control can re-bucket: a chart
|
|
4813
|
+
// on a time x-axis or a table over a time axis (followsGroupBy). A time axis
|
|
4814
|
+
// is the whole qualification: the control changes its grain, and a category
|
|
4815
|
+
// axis has no grain to change.
|
|
4816
|
+
function hasGroupByTile(report) {
|
|
4817
|
+
return (report?.pages ?? []).some((page) => (page.components ?? []).some(followsGroupBy));
|
|
4792
4818
|
}
|
|
4793
4819
|
// The grains the report's group-by control offers, named for the viewer. WHICH
|
|
4794
4820
|
// of them comes from the report; how they read comes from GRANULARITY_OPTIONS.
|
|
@@ -4819,7 +4845,7 @@ function groupByFilterItem(report, initial) {
|
|
|
4819
4845
|
// Only an EXPLICIT false hides the control. An absent block means a report
|
|
4820
4846
|
// served by a build that predates the control being stored, and every one of
|
|
4821
4847
|
// those had one — the backend resolves it the same way (ReportGroupBy).
|
|
4822
|
-
if (groupBy?.enabled === false || !
|
|
4848
|
+
if (groupBy?.enabled === false || !hasGroupByTile(report)) {
|
|
4823
4849
|
return null;
|
|
4824
4850
|
}
|
|
4825
4851
|
const values = groupByOptions(report);
|
|
@@ -4939,10 +4965,10 @@ function selectQueryValues(raw) {
|
|
|
4939
4965
|
// read of the data. The list is positive, so a type added later (or one this
|
|
4940
4966
|
// viewer does not know) is never taken for a tile. The server's twin is
|
|
4941
4967
|
// ReportComponentModel::isTile().
|
|
4942
|
-
const TILE_TYPES = ['chart', 'list', 'kpi'];
|
|
4943
|
-
// Whether a component is a tile: a chart, a list or a KPI. Only a
|
|
4944
|
-
// drawn as one, fetched, exported as a file or counted among a filter
|
|
4945
|
-
// tiles; every loop over a report's tiles reads this.
|
|
4968
|
+
const TILE_TYPES = ['chart', 'list', 'grid', 'kpi'];
|
|
4969
|
+
// Whether a component is a tile: a chart, a list, a grid or a KPI. Only a
|
|
4970
|
+
// tile is drawn as one, fetched, exported as a file or counted among a filter
|
|
4971
|
+
// group's tiles; every loop over a report's tiles reads this.
|
|
4946
4972
|
function isTile(component) {
|
|
4947
4973
|
return !!component && TILE_TYPES.includes(component.type);
|
|
4948
4974
|
}
|
|
@@ -5129,6 +5155,10 @@ const MATCH_ANY_LINE = 'Filters in the report bar: match any';
|
|
|
5129
5155
|
const MATCH_ALL_CHIP = 'Match: all filters';
|
|
5130
5156
|
// A group's label when it has none.
|
|
5131
5157
|
const UNLABELED_GROUP = 'Filter';
|
|
5158
|
+
// How many picks a column filter's line names before 'and N more'.
|
|
5159
|
+
const GRID_FILTER_NAMED = 10;
|
|
5160
|
+
// What a blank key reads as in a column filter's line.
|
|
5161
|
+
const GRID_BLANK_KEY = '(blank)';
|
|
5132
5162
|
// The label of the group-by line when the report names no control.
|
|
5133
5163
|
const GROUP_BY_LABEL = 'Group By';
|
|
5134
5164
|
// One filter group's value as text ('Region: West, East'), or null when the
|
|
@@ -5347,6 +5377,68 @@ function pageName(report, pageId) {
|
|
|
5347
5377
|
}
|
|
5348
5378
|
return report.pages[index].name || `Page ${index + 1}`;
|
|
5349
5379
|
}
|
|
5380
|
+
// One column filter of a table as a file prints it: 'Region: East, West',
|
|
5381
|
+
// 'Exclude Region: North (rows with no Region are kept)', 'Customer: contains
|
|
5382
|
+
// "smith"', 'Amount: 10 to 50 (both included)', 'Ordered: Jan 2026 to Mar
|
|
5383
|
+
// 2026'. A key reads as its option's label, a blank key as (blank), known
|
|
5384
|
+
// from the null key itself; past GRID_FILTER_NAMED picks the rest are
|
|
5385
|
+
// counted. An exclude says what happens to the rows with no value: kept,
|
|
5386
|
+
// unless a blank key is among the keys left out. Null for a filter that
|
|
5387
|
+
// holds nothing.
|
|
5388
|
+
function gridFilterLineText(label, filter, field, locale) {
|
|
5389
|
+
const exclude = !!filter.exclude;
|
|
5390
|
+
const blanks = (excluded) => (exclude ? ` (rows with no ${label} ${excluded ? 'are left out' : 'are kept'})` : '');
|
|
5391
|
+
const line = (text, excluded = false) => `${exclude ? 'Exclude ' : ''}${label}: ${text}${blanks(excluded)}`;
|
|
5392
|
+
if (filter.keys?.length) {
|
|
5393
|
+
const names = filter.keys.slice(0, GRID_FILTER_NAMED).map((key) => gridKeyName(key, field));
|
|
5394
|
+
const more = filter.keys.length - names.length;
|
|
5395
|
+
return line(`${names.join(', ')}${more > 0 ? ` and ${more} more` : ''}`, filter.keys.some((key) => key === null));
|
|
5396
|
+
}
|
|
5397
|
+
const contains = (filter.contains ?? '').trim();
|
|
5398
|
+
if (contains) {
|
|
5399
|
+
return `${label}: ${exclude ? 'doesn\'t contain' : 'contains'} "${contains}"${blanks(false)}`;
|
|
5400
|
+
}
|
|
5401
|
+
const isDate = field?.type === GridFieldType.Date;
|
|
5402
|
+
const write = (end) => (isDate
|
|
5403
|
+
? gridDateText(String(end), filter.granularity ?? field?.granularities?.[0], locale)
|
|
5404
|
+
: formatPlainNumber(Number(end), locale));
|
|
5405
|
+
const has = (end) => end !== undefined && end !== null && end !== '';
|
|
5406
|
+
if (has(filter.from) && has(filter.to)) {
|
|
5407
|
+
return line(`${write(filter.from)} to ${write(filter.to)}${exclude || isDate ? '' : ' (both included)'}`);
|
|
5408
|
+
}
|
|
5409
|
+
if (has(filter.from)) {
|
|
5410
|
+
return line(`${write(filter.from)} ${isDate ? 'or later' : 'or more'}`);
|
|
5411
|
+
}
|
|
5412
|
+
return has(filter.to) ? line(`${write(filter.to)} ${isDate ? 'or earlier' : 'or less'}`) : null;
|
|
5413
|
+
}
|
|
5414
|
+
// A key as a column filter's line names it: its option's label, else itself.
|
|
5415
|
+
function gridKeyName(key, field) {
|
|
5416
|
+
if (key === null) {
|
|
5417
|
+
return GRID_BLANK_KEY;
|
|
5418
|
+
}
|
|
5419
|
+
return field?.options?.find((option) => option.key !== null && String(option.key) === String(key))?.label ?? String(key);
|
|
5420
|
+
}
|
|
5421
|
+
// A date end at the grain the filter reads: 'Jan 2026', 'Q1 2026', '2026',
|
|
5422
|
+
// else the day.
|
|
5423
|
+
function gridDateText(value, granularity, locale) {
|
|
5424
|
+
const match = /^(\d{4})-(\d{2})/.exec(value);
|
|
5425
|
+
if (!match) {
|
|
5426
|
+
return value;
|
|
5427
|
+
}
|
|
5428
|
+
const year = Number(match[1]);
|
|
5429
|
+
const month = Number(match[2]);
|
|
5430
|
+
switch (granularity) {
|
|
5431
|
+
case GridGranularity.Year:
|
|
5432
|
+
return String(year);
|
|
5433
|
+
case GridGranularity.Quarter:
|
|
5434
|
+
return `Q${Math.ceil(month / 3)} ${year}`;
|
|
5435
|
+
case GridGranularity.Month:
|
|
5436
|
+
return new Intl.DateTimeFormat(locale, { month: 'short', year: 'numeric', timeZone: 'UTC' })
|
|
5437
|
+
.format(new Date(Date.UTC(year, month - 1, 1)));
|
|
5438
|
+
default:
|
|
5439
|
+
return dateBoundLabel(value, locale) ?? value;
|
|
5440
|
+
}
|
|
5441
|
+
}
|
|
5350
5442
|
|
|
5351
5443
|
// Filters inside text blocks: what each {{filter.<id>}} token reads as. The
|
|
5352
5444
|
// value is the viewer's current one for the group, the one the filter bar
|
|
@@ -5524,18 +5616,10 @@ const PAGE_DIMENSION_MAX = 50;
|
|
|
5524
5616
|
// ReportHeaderFooter::MAX_HEIGHT on the server, which clamps.
|
|
5525
5617
|
const HEADER_FOOTER_MAX = 3;
|
|
5526
5618
|
|
|
5527
|
-
// Whether a component is a chart on a time x-axis: the only component the
|
|
5528
|
-
// report's group-by grain applies to. A deliberate mirror of the server's
|
|
5529
|
-
// ReportQueryRunner::_isTimeSeriesChart, which is canonical and drops the
|
|
5530
|
-
// grain for every other component; kept so a product pinned to an older
|
|
5531
|
-
// framework does not re-bucket a non-time chart.
|
|
5532
|
-
function isTimeSeriesChart(component) {
|
|
5533
|
-
return component.type === 'chart' && component.config?.xAxis?.kind === 'time';
|
|
5534
|
-
}
|
|
5535
5619
|
// 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
|
|
5537
|
-
//
|
|
5538
|
-
// chart's drill path, and, while the groups match 'any', the groups that
|
|
5620
|
+
// of the screen (the state service's snapshot()): the group-by grain for a
|
|
5621
|
+
// tile that follows it (followsGroupBy), a list's sort, search and hidden
|
|
5622
|
+
// columns, a chart's drill path, and, while the groups match 'any', the groups that
|
|
5539
5623
|
// combine (matchAnyGroupIds). The tile on screen, its CSV, the PDF and
|
|
5540
5624
|
// PowerPoint collector and the zip all build their state here, so a file
|
|
5541
5625
|
// always holds what the screen shows. A list's paging is the caller's. Never
|
|
@@ -5549,7 +5633,7 @@ function tileDataState(report, component, snapshot) {
|
|
|
5549
5633
|
return state;
|
|
5550
5634
|
}
|
|
5551
5635
|
const tile = snapshot.tiles?.[component.id];
|
|
5552
|
-
if (
|
|
5636
|
+
if (followsGroupBy(component) && isChartGranularity(snapshot.groupBy)) {
|
|
5553
5637
|
state.frequency = snapshot.groupBy;
|
|
5554
5638
|
}
|
|
5555
5639
|
if (component.type === 'list') {
|
|
@@ -5574,15 +5658,17 @@ function tileDataState(report, component, snapshot) {
|
|
|
5574
5658
|
}
|
|
5575
5659
|
// Each tile's state for the zip export (tileDataState), by component id: the
|
|
5576
5660
|
// tiles of every page, or of `pageId` alone. The zip holds no header or
|
|
5577
|
-
// footer tile. A
|
|
5578
|
-
//
|
|
5579
|
-
|
|
5661
|
+
// footer tile. A table adds the layout the viewer put it in (`gridLayout`,
|
|
5662
|
+
// null while it shows its stored one). A tile with nothing to send is left
|
|
5663
|
+
// out, so an untouched report sends no states at all.
|
|
5664
|
+
function zipExportStates(report, snapshot, pageId = null, gridLayout = () => null) {
|
|
5580
5665
|
const states = {};
|
|
5581
5666
|
(report.pages ?? [])
|
|
5582
5667
|
.filter((page) => !pageId || page.id === pageId)
|
|
5583
5668
|
.flatMap((page) => page.components ?? [])
|
|
5584
5669
|
.forEach((component) => {
|
|
5585
|
-
const
|
|
5670
|
+
const layout = component.type === 'grid' ? gridLayout(component) : null;
|
|
5671
|
+
const state = { ...tileDataState(report, component, snapshot), ...(layout ? { layout } : {}) };
|
|
5586
5672
|
if (Object.keys(state).length) {
|
|
5587
5673
|
states[component.id] = state;
|
|
5588
5674
|
}
|
|
@@ -5954,6 +6040,72 @@ function listPriorAlias(outputs, alias, output) {
|
|
|
5954
6040
|
: null;
|
|
5955
6041
|
}
|
|
5956
6042
|
|
|
6043
|
+
// A table tile's layouts: the one its config authors, the canonical key two
|
|
6044
|
+
// layouts are compared by, and a viewer's layout moved onto a new authored one.
|
|
6045
|
+
const NO_LAYOUT = {};
|
|
6046
|
+
// The report's grains by the grid's name for each.
|
|
6047
|
+
const GRID_GRANULARITIES = {
|
|
6048
|
+
day: GridGranularity.Day,
|
|
6049
|
+
week: GridGranularity.Week,
|
|
6050
|
+
month: GridGranularity.Month,
|
|
6051
|
+
quarter: GridGranularity.Quarter,
|
|
6052
|
+
year: GridGranularity.Year,
|
|
6053
|
+
};
|
|
6054
|
+
// The layout a table tile's config authors, or none.
|
|
6055
|
+
function authoredGridLayout(component) {
|
|
6056
|
+
return component.config?.layout ?? NO_LAYOUT;
|
|
6057
|
+
}
|
|
6058
|
+
// A layout's canonical key: two layouts that draw the same table have the
|
|
6059
|
+
// same key, whatever order or defaults they were written with.
|
|
6060
|
+
function gridLayoutKey(layout) {
|
|
6061
|
+
return JSON.stringify(canonicalGridLayout(layout));
|
|
6062
|
+
}
|
|
6063
|
+
// A viewer's layout moved onto the layout authored now, `base` being the key
|
|
6064
|
+
// of the authored layout they started from: the new authored layout, with the
|
|
6065
|
+
// viewer's own filters and sort (what `base` did not have) kept on the fields
|
|
6066
|
+
// it still places. A field the viewer filters takes their filters in place of
|
|
6067
|
+
// the authored ones, and a sort they changed replaces the authored sort.
|
|
6068
|
+
function rebasedGridLayout(session, base, authored) {
|
|
6069
|
+
const started = JSON.parse(base);
|
|
6070
|
+
const viewer = canonicalGridLayout(session);
|
|
6071
|
+
const placed = new Set([
|
|
6072
|
+
...(authored.rows ?? []),
|
|
6073
|
+
...(authored.columns ?? []),
|
|
6074
|
+
...(authored.values ?? []),
|
|
6075
|
+
...(authored.details ?? []),
|
|
6076
|
+
].map((placement) => placement.field));
|
|
6077
|
+
const startedFilters = new Set((started.filters ?? []).map((filter) => JSON.stringify(filter)));
|
|
6078
|
+
const filters = (viewer.filters ?? [])
|
|
6079
|
+
.filter((filter) => placed.has(filter.field) && !startedFilters.has(JSON.stringify(filter)));
|
|
6080
|
+
const filtered = new Set(filters.map((filter) => filter.field));
|
|
6081
|
+
const sorted = JSON.stringify(viewer.sort ?? []) !== JSON.stringify(started.sort ?? []);
|
|
6082
|
+
const sort = (viewer.sort ?? []).filter((term) => placed.has(term.field));
|
|
6083
|
+
return canonicalGridLayout({
|
|
6084
|
+
...authored,
|
|
6085
|
+
filters: [...(authored.filters ?? []).filter((filter) => !filtered.has(filter.field)), ...filters],
|
|
6086
|
+
sort: sorted && sort.length ? sort : authored.sort,
|
|
6087
|
+
});
|
|
6088
|
+
}
|
|
6089
|
+
// A table's layout once the grain `field` reads at moves to `granularity` (the
|
|
6090
|
+
// report's group-by changed): its column filter on `field` converts to the
|
|
6091
|
+
// same days at the new grain when its edges line up, and is taken out
|
|
6092
|
+
// otherwise (convertDateFilter), so the read never sends a filter the field no
|
|
6093
|
+
// longer offers. `cleared` names the filter taken out and the grain the table
|
|
6094
|
+
// now groups by, as fs-grid's status line reads it. A layout with no filter on
|
|
6095
|
+
// the field, or one already at the grain, comes back as it is.
|
|
6096
|
+
function grainedGridLayout(layout, field, granularity) {
|
|
6097
|
+
const grain = GRID_GRANULARITIES[granularity];
|
|
6098
|
+
const filter = (layout.filters ?? []).find((item) => item.field === field);
|
|
6099
|
+
if (!filter || (filter.granularity ?? GridGranularity.Day) === grain) {
|
|
6100
|
+
return { layout, cleared: null };
|
|
6101
|
+
}
|
|
6102
|
+
const converted = convertDateFilter(filter, grain);
|
|
6103
|
+
const filters = (layout.filters ?? [])
|
|
6104
|
+
.map((item) => (item === filter ? converted : item))
|
|
6105
|
+
.filter((item) => !!item);
|
|
6106
|
+
return { layout: { ...layout, filters }, cleared: converted ? null : { field, granularity: grain } };
|
|
6107
|
+
}
|
|
6108
|
+
|
|
5957
6109
|
// Whether two stored filter-group defaults are the same. Compared on the fields a
|
|
5958
6110
|
// default can actually carry, so key order in the payload can't read as a change.
|
|
5959
6111
|
function sameDefault(a, b) {
|
|
@@ -6054,11 +6206,11 @@ function emptyTemporaryState() {
|
|
|
6054
6206
|
//
|
|
6055
6207
|
// It is the one place the viewer's state lives while the report is open: the
|
|
6056
6208
|
// group values, the group-by grain, the match, list sorts, searches and hidden
|
|
6057
|
-
// columns, the page
|
|
6058
|
-
// the browser URL. It outlives a page switch (the canvas
|
|
6059
|
-
// this service) and goes with the report view: a reload
|
|
6060
|
-
// which opens on the report's authored defaults.
|
|
6061
|
-
// change of it.
|
|
6209
|
+
// columns, the page, each chart's drill path and each table's layout. None of
|
|
6210
|
+
// it is ever written to the browser URL. It outlives a page switch (the canvas
|
|
6211
|
+
// changes the page, not this service) and goes with the report view: a reload
|
|
6212
|
+
// starts a new service, which opens on the report's authored defaults.
|
|
6213
|
+
// changes() fires on every change of it.
|
|
6062
6214
|
//
|
|
6063
6215
|
// The durable part reads as one document: snapshot() writes it, and
|
|
6064
6216
|
// differsFromDefaults says whether the screen still shows the report's
|
|
@@ -6068,7 +6220,8 @@ function emptyTemporaryState() {
|
|
|
6068
6220
|
//
|
|
6069
6221
|
// Session-only, never in the snapshot: per-filter enable/disable toggles from
|
|
6070
6222
|
// a component's filter menu (the `enabled` column is the authored default; a
|
|
6071
|
-
// viewer's toggle shouldn't write to the report)
|
|
6223
|
+
// viewer's toggle shouldn't write to the report), and each table's layout,
|
|
6224
|
+
// which fs-grid sends with the table's own reads.
|
|
6072
6225
|
class ReportFilterStateService {
|
|
6073
6226
|
// The open report's effective zone (resolveReportTimezone). Relative presets
|
|
6074
6227
|
// resolve against today in it, and date cells render in it.
|
|
@@ -6114,9 +6267,11 @@ class ReportFilterStateService {
|
|
|
6114
6267
|
// Whether the screen differs from the report's authored defaults
|
|
6115
6268
|
// (stateDiffers): what the report bar's 'Clear filters' shows for. A switch
|
|
6116
6269
|
// of page does not count, and the temporary state never does.
|
|
6270
|
+
// A table tile in a layout of the viewer's own counts too; it stays out of
|
|
6271
|
+
// the snapshot, so no saved filter or link carries it.
|
|
6117
6272
|
differsFromDefaults = computed(() => {
|
|
6118
6273
|
this._revision();
|
|
6119
|
-
return stateDiffers(this.authoredConfig(), this.snapshot());
|
|
6274
|
+
return this._gridLayouts().size > 0 || stateDiffers(this.authoredConfig(), this.snapshot());
|
|
6120
6275
|
});
|
|
6121
6276
|
_values = new Map();
|
|
6122
6277
|
_groups = new Map();
|
|
@@ -6126,7 +6281,8 @@ class ReportFilterStateService {
|
|
|
6126
6281
|
// its default straight back. A change to the stored default releases it.
|
|
6127
6282
|
_cleared = new Set();
|
|
6128
6283
|
// The report's group-by control: a single report-wide bucket override for all
|
|
6129
|
-
//
|
|
6284
|
+
// the tiles that follow it (followsGroupBy). Unset = each one uses its
|
|
6285
|
+
// authored granularity. Never
|
|
6130
6286
|
// saved to the report, and carried in the data request's
|
|
6131
6287
|
// `state`, so it propagates through to the PDF/PowerPoint exports too. Named
|
|
6132
6288
|
// for the `frequency` wire key it travels under, not for the control's label.
|
|
@@ -6181,6 +6337,21 @@ class ReportFilterStateService {
|
|
|
6181
6337
|
_drills = signal(new Map());
|
|
6182
6338
|
// Emits the id of the chart whose drill path changed.
|
|
6183
6339
|
_drillChanged$ = new Subject();
|
|
6340
|
+
// The layout a viewer put each table tile in, by component id, while it
|
|
6341
|
+
// differs from the tile's authored layout, with the authored layout it was
|
|
6342
|
+
// changed from (ReportGridSessionLayout.base). A table showing its authored
|
|
6343
|
+
// layout has no entry, and only a table has one.
|
|
6344
|
+
_gridLayouts = signal(new Map());
|
|
6345
|
+
// The groups a viewer left open in each table tile, by component id, so a
|
|
6346
|
+
// tile drawn again (a page switch) opens them. Session only: no Clear
|
|
6347
|
+
// resets them, differsFromDefaults never reads them, and fs-grid drops
|
|
6348
|
+
// them itself once the row levels change.
|
|
6349
|
+
_gridExpansions = signal(new Map());
|
|
6350
|
+
// By table tile, the column filter a group-by change took out of it and
|
|
6351
|
+
// the grain the table now groups by (_grainGridLayouts()), for fs-grid's
|
|
6352
|
+
// status line. fs-grid hides it once the field is filtered again; a Clear
|
|
6353
|
+
// drops it.
|
|
6354
|
+
_gridClearedFilters = signal(new Map());
|
|
6184
6355
|
// The temporary layer: kept apart from everything above, so snapshot()
|
|
6185
6356
|
// and differsFromDefaults never read it.
|
|
6186
6357
|
_temporary = signal(emptyTemporaryState());
|
|
@@ -6247,14 +6418,18 @@ class ReportFilterStateService {
|
|
|
6247
6418
|
frequency() {
|
|
6248
6419
|
return this._frequency;
|
|
6249
6420
|
}
|
|
6421
|
+
// Each table that follows the group-by has its filter on the group-by's
|
|
6422
|
+
// field moved to the new grain first (_grainGridLayouts()), so a table on
|
|
6423
|
+
// screen reads once, with both.
|
|
6250
6424
|
setFrequency(frequency) {
|
|
6251
6425
|
this._frequency = frequency;
|
|
6426
|
+
this._grainGridLayouts();
|
|
6252
6427
|
this._frequencyChanged$.next();
|
|
6253
6428
|
this._stateChanged$.next();
|
|
6254
6429
|
}
|
|
6255
|
-
// Fires whenever the group-by grain changes — every
|
|
6256
|
-
// so it refetches and re-buckets (the override isn't scoped to any
|
|
6257
|
-
// group, so changesFor() can't carry it).
|
|
6430
|
+
// Fires whenever the group-by grain changes — every tile that follows it
|
|
6431
|
+
// listens so it refetches and re-buckets (the override isn't scoped to any
|
|
6432
|
+
// filter group, so changesFor() can't carry it).
|
|
6258
6433
|
frequencyChanges() {
|
|
6259
6434
|
return this._frequencyChanged$.asObservable();
|
|
6260
6435
|
}
|
|
@@ -6487,6 +6662,48 @@ class ReportFilterStateService {
|
|
|
6487
6662
|
this._stateChanged$.next();
|
|
6488
6663
|
return true;
|
|
6489
6664
|
}
|
|
6665
|
+
// The layout a table tile is on screen in: the viewer's own while they
|
|
6666
|
+
// changed it from the layout authored now, else the authored one. It reads
|
|
6667
|
+
// a signal, so a tile that shows it redraws when either moves.
|
|
6668
|
+
gridLayout(component) {
|
|
6669
|
+
return this.gridSessionLayout(component) ?? authoredGridLayout(component);
|
|
6670
|
+
}
|
|
6671
|
+
// The viewer's own layout of a table tile while it differs from the layout
|
|
6672
|
+
// authored now, else null: the tile shows its stored layout. The chat
|
|
6673
|
+
// sends it as the tile's layout.
|
|
6674
|
+
gridSessionLayout(component) {
|
|
6675
|
+
const session = this._gridLayouts().get(component.id);
|
|
6676
|
+
return session?.base === gridLayoutKey(authoredGridLayout(component))
|
|
6677
|
+
? session.layout
|
|
6678
|
+
: null;
|
|
6679
|
+
}
|
|
6680
|
+
// Records the layout a viewer put a table tile in (a re-pivot, a sort, a
|
|
6681
|
+
// column filter). One that draws the authored layout clears the entry, so
|
|
6682
|
+
// the tile reads as stored again. fs-grid reads the change itself, so only
|
|
6683
|
+
// changes() hears about it, and only on a real change.
|
|
6684
|
+
setGridLayout(component, layout) {
|
|
6685
|
+
if (this._putGridLayout(component, layout)) {
|
|
6686
|
+
this._stateChanged$.next();
|
|
6687
|
+
}
|
|
6688
|
+
}
|
|
6689
|
+
// The column filter a group-by change took out of a table tile, or null
|
|
6690
|
+
// (_gridClearedFilters).
|
|
6691
|
+
gridClearedFilter(component) {
|
|
6692
|
+
return this._gridClearedFilters().get(component.id) ?? null;
|
|
6693
|
+
}
|
|
6694
|
+
// The groups a viewer left open in a table tile, or undefined: the tile
|
|
6695
|
+
// opens as its layout says.
|
|
6696
|
+
gridExpansion(component) {
|
|
6697
|
+
return this._gridExpansions().get(component.id);
|
|
6698
|
+
}
|
|
6699
|
+
// Records the groups open in a table tile, as fs-grid writes them out. It
|
|
6700
|
+
// changes no read, so nothing hears about it.
|
|
6701
|
+
setGridExpansion(component, expansion) {
|
|
6702
|
+
if (component.type !== 'grid' || !expansion || this._gridExpansions().get(component.id) === expansion) {
|
|
6703
|
+
return;
|
|
6704
|
+
}
|
|
6705
|
+
this._gridExpansions.set(new Map(this._gridExpansions()).set(component.id, expansion));
|
|
6706
|
+
}
|
|
6490
6707
|
// The durable state on screen as one document: the page, every group the
|
|
6491
6708
|
// report has (null when it holds nothing, a cleared default included), the
|
|
6492
6709
|
// match, the grain, and each tile's sort, search, hidden columns and drill
|
|
@@ -6850,6 +7067,57 @@ class ReportFilterStateService {
|
|
|
6850
7067
|
this._listSearches.clear();
|
|
6851
7068
|
this.listHidden.set(new Map());
|
|
6852
7069
|
this._drills.set(new Map());
|
|
7070
|
+
this._gridLayouts.set(new Map());
|
|
7071
|
+
this._gridClearedFilters.set(new Map());
|
|
7072
|
+
}
|
|
7073
|
+
// setGridLayout() without the change event; true when the layout changed.
|
|
7074
|
+
_putGridLayout(component, layout) {
|
|
7075
|
+
const base = gridLayoutKey(authoredGridLayout(component));
|
|
7076
|
+
const key = gridLayoutKey(layout);
|
|
7077
|
+
const current = this._gridLayouts().get(component.id);
|
|
7078
|
+
const unchanged = key === base
|
|
7079
|
+
? !current
|
|
7080
|
+
: current?.base === base && gridLayoutKey(current.layout) === key;
|
|
7081
|
+
if (component.type !== 'grid' || unchanged) {
|
|
7082
|
+
return false;
|
|
7083
|
+
}
|
|
7084
|
+
const next = new Map(this._gridLayouts());
|
|
7085
|
+
if (key === base) {
|
|
7086
|
+
next.delete(component.id);
|
|
7087
|
+
}
|
|
7088
|
+
else {
|
|
7089
|
+
next.set(component.id, { layout, base });
|
|
7090
|
+
}
|
|
7091
|
+
this._gridLayouts.set(next);
|
|
7092
|
+
return true;
|
|
7093
|
+
}
|
|
7094
|
+
// Every table that follows the group-by (followsGroupBy), its column filter
|
|
7095
|
+
// on the group-by's field moved to the grain it now reads at: the viewer's
|
|
7096
|
+
// pick, else the table's own (grainedGridLayout()). Tables on other pages
|
|
7097
|
+
// move too, so their files, the chat and their next paint read the moved
|
|
7098
|
+
// filter. A filter taken out is noted on its tile (gridClearedFilter()); a
|
|
7099
|
+
// note already there names the new grain, unless the field is filtered
|
|
7100
|
+
// again, which ends it.
|
|
7101
|
+
_grainGridLayouts() {
|
|
7102
|
+
const cleared = new Map(this._gridClearedFilters());
|
|
7103
|
+
this._tiles().forEach((component) => {
|
|
7104
|
+
const groupBy = component.type === 'grid' ? component.followsGroupBy : null;
|
|
7105
|
+
const granularity = this._frequency ?? groupBy?.granularity;
|
|
7106
|
+
if (!groupBy || !granularity) {
|
|
7107
|
+
return;
|
|
7108
|
+
}
|
|
7109
|
+
const grained = grainedGridLayout(this.gridLayout(component), groupBy.field, granularity);
|
|
7110
|
+
const filtered = (grained.layout.filters ?? []).some((filter) => filter.field === groupBy.field);
|
|
7111
|
+
const note = grained.cleared ?? (filtered ? null : cleared.get(component.id));
|
|
7112
|
+
if (note) {
|
|
7113
|
+
cleared.set(component.id, { ...note, granularity: GRID_GRANULARITIES[granularity] });
|
|
7114
|
+
}
|
|
7115
|
+
else {
|
|
7116
|
+
cleared.delete(component.id);
|
|
7117
|
+
}
|
|
7118
|
+
this._putGridLayout(component, grained.layout);
|
|
7119
|
+
});
|
|
7120
|
+
this._gridClearedFilters.set(cleared);
|
|
6853
7121
|
}
|
|
6854
7122
|
// One chart's drill path; an empty path is the chart's top.
|
|
6855
7123
|
_setDrillPath(componentId, path) {
|
|
@@ -6862,8 +7130,8 @@ class ReportFilterStateService {
|
|
|
6862
7130
|
}
|
|
6863
7131
|
this._drills.set(next);
|
|
6864
7132
|
}
|
|
6865
|
-
// After resetToDefaults(): every bar and tile repaints, and the
|
|
6866
|
-
//
|
|
7133
|
+
// After resetToDefaults(): every bar and tile repaints, and the tiles that
|
|
7134
|
+
// follow the group-by refetch when the grain moved.
|
|
6867
7135
|
_notifyReset(frequency) {
|
|
6868
7136
|
this._stateVersion.update((version) => version + 1);
|
|
6869
7137
|
this._changed$.next(null);
|
|
@@ -6883,20 +7151,55 @@ class ReportFilterStateService {
|
|
|
6883
7151
|
return this._tiles()
|
|
6884
7152
|
.some((component) => component.id === componentId && component.type === 'chart');
|
|
6885
7153
|
}
|
|
6886
|
-
//
|
|
6887
|
-
//
|
|
6888
|
-
//
|
|
6889
|
-
//
|
|
7154
|
+
// Drops each table layout and expansion whose tile is gone or is no table
|
|
7155
|
+
// any more. A layout whose tile is now authored otherwise (an edit, the
|
|
7156
|
+
// agent's) moves onto the new authored layout, keeping the viewer's filters
|
|
7157
|
+
// and sort terms on fields it still places (rebasedGridLayout), so the tile
|
|
7158
|
+
// shows both changes; one that then draws the authored layout is dropped.
|
|
7159
|
+
_pruneGridLayouts() {
|
|
7160
|
+
const grids = new Map(this._tiles()
|
|
7161
|
+
.filter((component) => component.type === 'grid')
|
|
7162
|
+
.map((component) => [component.id, component]));
|
|
7163
|
+
const kept = new Map();
|
|
7164
|
+
this._gridLayouts().forEach((session, componentId) => {
|
|
7165
|
+
const component = grids.get(componentId);
|
|
7166
|
+
if (!component) {
|
|
7167
|
+
return;
|
|
7168
|
+
}
|
|
7169
|
+
const authored = authoredGridLayout(component);
|
|
7170
|
+
const base = gridLayoutKey(authored);
|
|
7171
|
+
const layout = session.base === base ? session.layout : rebasedGridLayout(session.layout, session.base, authored);
|
|
7172
|
+
if (gridLayoutKey(layout) !== base) {
|
|
7173
|
+
kept.set(componentId, { layout, base });
|
|
7174
|
+
}
|
|
7175
|
+
});
|
|
7176
|
+
this._gridLayouts.set(kept);
|
|
7177
|
+
this._gridExpansions.set(new Map([...this._gridExpansions()]
|
|
7178
|
+
.filter(([componentId]) => grids.has(componentId))));
|
|
7179
|
+
this._gridClearedFilters.set(new Map([...this._gridClearedFilters()]
|
|
7180
|
+
.filter(([componentId]) => grids.has(componentId))));
|
|
7181
|
+
}
|
|
7182
|
+
// Keeps the page, drill and table state to what the report (re)loaded by
|
|
7183
|
+
// init() still has: drill paths on charts it still has, the page while it
|
|
7184
|
+
// has it, and each table's layout and open groups while it is still a table
|
|
7185
|
+
// (_pruneGridLayouts). On another report it starts again: match 'all', no
|
|
7186
|
+
// drill, every table as authored and closed, and nothing temporary.
|
|
6890
7187
|
_prunePageState(report) {
|
|
6891
7188
|
if (this._report()?.id !== report.id) {
|
|
6892
7189
|
this._match.set('all');
|
|
6893
7190
|
this._pageId.set(null);
|
|
6894
7191
|
this._drills.set(new Map());
|
|
7192
|
+
this._gridLayouts.set(new Map());
|
|
7193
|
+
this._gridExpansions.set(new Map());
|
|
7194
|
+
this._gridClearedFilters.set(new Map());
|
|
6895
7195
|
this._temporary.set(emptyTemporaryState());
|
|
6896
7196
|
}
|
|
6897
7197
|
this._report.set(report);
|
|
6898
7198
|
this._drills.set(new Map([...this._drills()]
|
|
6899
7199
|
.filter(([componentId]) => this._isChart(componentId))));
|
|
7200
|
+
this._pruneGridLayouts();
|
|
7201
|
+
// A table the report now authors otherwise reads at the grain on screen.
|
|
7202
|
+
this._grainGridLayouts();
|
|
6900
7203
|
if (this._pageId() && !(report.pages ?? []).some((page) => page.id === this._pageId())) {
|
|
6901
7204
|
this._pageId.set(null);
|
|
6902
7205
|
}
|
|
@@ -8066,6 +8369,340 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImpo
|
|
|
8066
8369
|
], template: "<div\n class=\"chart-host\"\n echarts\n [options]=\"options() ?? {}\"\n [theme]=\"'report'\"\n [autoResize]=\"true\"\n (chartInit)=\"onChartInit($event)\"\n (chartClick)=\"onChartClick($event)\"\n (chartLegendSelectChanged)=\"onLegendSelectChanged($event)\">\n</div>\n<app-report-component-note [data]=\"data()\"></app-report-component-note>\n", styles: [":host{display:flex;flex-direction:column;width:100%;height:100%}.chart-host{flex:1 1 0;width:100%;min-height:0}\n"] }]
|
|
8067
8370
|
}] });
|
|
8068
8371
|
|
|
8372
|
+
// What a failed read says when the server gave no message of its own.
|
|
8373
|
+
const READ_FAILED = 'Failed to load data';
|
|
8374
|
+
// A stream that ended before its summary was cut off on the way: the parts
|
|
8375
|
+
// it brought may be short, so the read counts as failed.
|
|
8376
|
+
const STREAM_CUT = 'The table\'s answer was cut off. Try again.';
|
|
8377
|
+
// One grid tile's rows, read through the report: fs-grid asks, and each ask
|
|
8378
|
+
// is one componentGrid request. The params (ReportGridParams) are mapped onto
|
|
8379
|
+
// the request body's own names: the report filters go to `filters`, the 'any'
|
|
8380
|
+
// groups and the grain to `state`.
|
|
8381
|
+
//
|
|
8382
|
+
// The answer streams. Its report line is held in `report`, so output cells
|
|
8383
|
+
// format and the note strip fills from the first paint; each grid line is
|
|
8384
|
+
// passed on as one part, its output fields described as today's header
|
|
8385
|
+
// tooltip describes them (outputColumnNote); the envelope's summary is the
|
|
8386
|
+
// last part. A stream that ends without its summary is a failure.
|
|
8387
|
+
//
|
|
8388
|
+
// Every failure reaches the grid as an Error carrying the server's message:
|
|
8389
|
+
// a streamed request shows no error dialog of its own, so the grid's error
|
|
8390
|
+
// state is where it is read.
|
|
8391
|
+
class ReportGridSource {
|
|
8392
|
+
_reportData;
|
|
8393
|
+
_reportId;
|
|
8394
|
+
_componentId;
|
|
8395
|
+
// The report line of the latest read, null until one arrives.
|
|
8396
|
+
report = signal(null);
|
|
8397
|
+
_isExport;
|
|
8398
|
+
_locale;
|
|
8399
|
+
constructor(_reportData, _reportId, _componentId, options = {}) {
|
|
8400
|
+
this._reportData = _reportData;
|
|
8401
|
+
this._reportId = _reportId;
|
|
8402
|
+
this._componentId = _componentId;
|
|
8403
|
+
this._isExport = options.isExport ?? false;
|
|
8404
|
+
this._locale = options.locale ?? (() => REPORT_FALLBACK_LOCALE);
|
|
8405
|
+
}
|
|
8406
|
+
read(request, params) {
|
|
8407
|
+
return defer(() => {
|
|
8408
|
+
let summary = null;
|
|
8409
|
+
return this._stream$(request, params)
|
|
8410
|
+
.pipe(mergeMap((event) => {
|
|
8411
|
+
if (event.type === StreamEventType.Complete) {
|
|
8412
|
+
summary = envelopeSummary(event) ?? summary;
|
|
8413
|
+
return EMPTY;
|
|
8414
|
+
}
|
|
8415
|
+
return this._part$(event.data);
|
|
8416
|
+
}), concatWith(defer(() => (summary ? of(summary) : throwError(() => new Error(STREAM_CUT))))), catchError$1((error) => throwError(() => readError(error))));
|
|
8417
|
+
});
|
|
8418
|
+
}
|
|
8419
|
+
_stream$(request, params) {
|
|
8420
|
+
return this._reportData.componentGrid(this._reportId, this._componentId, params.filters ?? [], requestState(params), request, this._isExport);
|
|
8421
|
+
}
|
|
8422
|
+
// One data line: the report line is held, a grid line is passed on as a
|
|
8423
|
+
// part, and every other line is skipped.
|
|
8424
|
+
_part$(data) {
|
|
8425
|
+
const line = data;
|
|
8426
|
+
if (line?.report) {
|
|
8427
|
+
this.report.set(line.report);
|
|
8428
|
+
return EMPTY;
|
|
8429
|
+
}
|
|
8430
|
+
return line?.grid ? of(this._described(line.grid)) : EMPTY;
|
|
8431
|
+
}
|
|
8432
|
+
// A part whose fields say what each output holds, as today's header
|
|
8433
|
+
// tooltip says it. A field the report says nothing of keeps its own.
|
|
8434
|
+
_described(part) {
|
|
8435
|
+
if (!part.fields?.length) {
|
|
8436
|
+
return part;
|
|
8437
|
+
}
|
|
8438
|
+
const report = this.report();
|
|
8439
|
+
const locale = this._locale();
|
|
8440
|
+
return {
|
|
8441
|
+
...part,
|
|
8442
|
+
fields: part.fields.map((field) => {
|
|
8443
|
+
const note = outputColumnNote(field.name, report, locale);
|
|
8444
|
+
return note ? { ...field, description: note } : field;
|
|
8445
|
+
}),
|
|
8446
|
+
};
|
|
8447
|
+
}
|
|
8448
|
+
}
|
|
8449
|
+
// The tile state the data request carries for a grid: the 'any' groups and
|
|
8450
|
+
// the grain, each only when set.
|
|
8451
|
+
function requestState(params) {
|
|
8452
|
+
return {
|
|
8453
|
+
...(params.matchAnyGroupIds?.length ? { matchAnyGroupIds: [...params.matchAnyGroupIds] } : {}),
|
|
8454
|
+
...(params.frequency ? { frequency: params.frequency } : {}),
|
|
8455
|
+
};
|
|
8456
|
+
}
|
|
8457
|
+
// The read's summary from the envelope that closes the stream
|
|
8458
|
+
// ({data: {data: {grid}}, code}), or null for the bare end-of-response event.
|
|
8459
|
+
function envelopeSummary(event) {
|
|
8460
|
+
return event.data?.data?.data?.grid ?? null;
|
|
8461
|
+
}
|
|
8462
|
+
// Any failure as an Error: the server's message when it sent one.
|
|
8463
|
+
function readError(error) {
|
|
8464
|
+
if (error instanceof HttpErrorResponse) {
|
|
8465
|
+
const message = error.error?.message;
|
|
8466
|
+
return new Error(typeof message === 'string' && message ? message : READ_FAILED);
|
|
8467
|
+
}
|
|
8468
|
+
return error instanceof Error ? error : new Error(READ_FAILED);
|
|
8469
|
+
}
|
|
8470
|
+
|
|
8471
|
+
// A detail cell of an output field (a prior, a change, a % change, a rank)
|
|
8472
|
+
// as a list writes it: a change reads its prior beside it on the line, so a
|
|
8473
|
+
// missing baseline reads '—' and a zero one 'n/a', and the cell's title says
|
|
8474
|
+
// why, as the list's hover does. A value it holds keeps the grid's own text
|
|
8475
|
+
// (its placement's format); a rank change is written whole, since a new rank
|
|
8476
|
+
// reads apart from an unranked line. Every other cell is the grid's text,
|
|
8477
|
+
// with no title.
|
|
8478
|
+
class ReportGridCellPipe {
|
|
8479
|
+
// The cell's raw value, its field, its line's record (absent on a group or
|
|
8480
|
+
// total row) and the grid's own text, from the fsGridCell context.
|
|
8481
|
+
transform(value, field, record, text, report, format) {
|
|
8482
|
+
const name = field?.name ?? '';
|
|
8483
|
+
const output = record ? report?.outputs?.[name] : null;
|
|
8484
|
+
if (!output || !record) {
|
|
8485
|
+
return { text, title: null };
|
|
8486
|
+
}
|
|
8487
|
+
const priorAlias = output.role === 'change' || output.role === 'percent_change'
|
|
8488
|
+
? laneOutputAlias(report?.outputs, name, 'prior')
|
|
8489
|
+
: null;
|
|
8490
|
+
const formatted = formatOutputValue(value, output, null, format, {
|
|
8491
|
+
...(priorAlias ? { prior: record[priorAlias] } : {}),
|
|
8492
|
+
floor: report?.comparison?.floor ?? null,
|
|
8493
|
+
empty: '',
|
|
8494
|
+
});
|
|
8495
|
+
const isMissing = value === null || value === undefined || value === '';
|
|
8496
|
+
return { text: isMissing || output.kind === 'rank' ? formatted.text : text, title: formatted.title };
|
|
8497
|
+
}
|
|
8498
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: ReportGridCellPipe, deps: [], target: i0.ɵɵFactoryTarget.Pipe });
|
|
8499
|
+
static ɵpipe = i0.ɵɵngDeclarePipe({ minVersion: "14.0.0", version: "18.2.14", ngImport: i0, type: ReportGridCellPipe, isStandalone: true, name: "fsReportGridCell" });
|
|
8500
|
+
}
|
|
8501
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: ReportGridCellPipe, decorators: [{
|
|
8502
|
+
type: Pipe,
|
|
8503
|
+
args: [{
|
|
8504
|
+
name: 'fsReportGridCell',
|
|
8505
|
+
standalone: true,
|
|
8506
|
+
}]
|
|
8507
|
+
}] });
|
|
8508
|
+
|
|
8509
|
+
const VALUE_FORMATS = {
|
|
8510
|
+
[GridFormat.Number]: ReportValueFormat.Number,
|
|
8511
|
+
[GridFormat.Percent]: ReportValueFormat.Percent,
|
|
8512
|
+
[GridFormat.Currency]: ReportValueFormat.Currency,
|
|
8513
|
+
};
|
|
8514
|
+
const NUMBER_UNITS = {
|
|
8515
|
+
[GridUnits.None]: ReportNumberUnits.None,
|
|
8516
|
+
[GridUnits.Thousands]: ReportNumberUnits.Thousands,
|
|
8517
|
+
[GridUnits.Millions]: ReportNumberUnits.Millions,
|
|
8518
|
+
[GridUnits.Auto]: ReportNumberUnits.Auto,
|
|
8519
|
+
};
|
|
8520
|
+
// What an abbreviated number's suffix multiplies it by, when a viewer types one.
|
|
8521
|
+
const UNIT_SUFFIXES = { k: 1e3, m: 1e6 };
|
|
8522
|
+
const YES = 'Yes';
|
|
8523
|
+
const NO = 'No';
|
|
8524
|
+
// A grid tile's formatter: every value written as the report writes it on
|
|
8525
|
+
// a list today. An output number reads through formatOutputValue with what
|
|
8526
|
+
// the report line says the output holds (a change is signed, a missing prior
|
|
8527
|
+
// reads as the list's dash); a plain number through formatNumber; a date in
|
|
8528
|
+
// the report's locale, an instant in its zone. The placement's format,
|
|
8529
|
+
// precision and units win over the field's.
|
|
8530
|
+
function reportGridFormatter(context) {
|
|
8531
|
+
return {
|
|
8532
|
+
format: (value, field, placement) => formatGridValue(context, value, field, placement),
|
|
8533
|
+
toNumber: (text, field, placement) => readGridNumber(context, text, field, placement),
|
|
8534
|
+
};
|
|
8535
|
+
}
|
|
8536
|
+
function formatGridValue(context, value, field, placement) {
|
|
8537
|
+
if (value === null || value === undefined || value === '') {
|
|
8538
|
+
return '';
|
|
8539
|
+
}
|
|
8540
|
+
switch (field.type) {
|
|
8541
|
+
case GridFieldType.Number:
|
|
8542
|
+
return formatGridNumber(context, value, field, placement);
|
|
8543
|
+
case GridFieldType.Date:
|
|
8544
|
+
case GridFieldType.Datetime:
|
|
8545
|
+
return formatReportDate(value, context.timezone, context.format.locale);
|
|
8546
|
+
case GridFieldType.Boolean:
|
|
8547
|
+
return value ? YES : NO;
|
|
8548
|
+
default:
|
|
8549
|
+
return optionLabel(value, field) ?? String(value);
|
|
8550
|
+
}
|
|
8551
|
+
}
|
|
8552
|
+
function formatGridNumber(context, value, field, placement) {
|
|
8553
|
+
const spec = numberSpec(field, placement);
|
|
8554
|
+
const output = isAggregate(placement) ? null : context.report?.outputs?.[field.name];
|
|
8555
|
+
return output
|
|
8556
|
+
? formatOutputValue(value, output, spec, context.format).text
|
|
8557
|
+
: formatNumber(value, spec, context.format);
|
|
8558
|
+
}
|
|
8559
|
+
// The format, precision and units a number is written with: the placement's,
|
|
8560
|
+
// else the field's.
|
|
8561
|
+
function numberSpec(field, placement) {
|
|
8562
|
+
const placed = placement;
|
|
8563
|
+
const format = placed?.format ?? field.format;
|
|
8564
|
+
const precision = placed?.precision ?? field.precision;
|
|
8565
|
+
const units = placed?.units ?? field.units;
|
|
8566
|
+
return {
|
|
8567
|
+
...(format ? { format: VALUE_FORMATS[format] } : {}),
|
|
8568
|
+
...(typeof precision === 'number' ? { precision } : {}),
|
|
8569
|
+
...(units ? { units: NUMBER_UNITS[units] } : {}),
|
|
8570
|
+
};
|
|
8571
|
+
}
|
|
8572
|
+
// A value placement is an aggregate of the field, not the output itself.
|
|
8573
|
+
function isAggregate(placement) {
|
|
8574
|
+
return !!placement?.aggregation;
|
|
8575
|
+
}
|
|
8576
|
+
function optionLabel(value, field) {
|
|
8577
|
+
return field.options?.find((option) => option.key === value)?.label ?? null;
|
|
8578
|
+
}
|
|
8579
|
+
// A number a viewer typed, read in the report's locale: its grouping dropped,
|
|
8580
|
+
// its decimal mark read, a K or M suffix multiplied out, and a percent of a
|
|
8581
|
+
// ratio-percent report read back to its ratio. null when it is no number.
|
|
8582
|
+
function readGridNumber(context, text, field, placement) {
|
|
8583
|
+
const decimal = decimalMark(context.format.locale);
|
|
8584
|
+
const trimmed = text.trim().toLowerCase();
|
|
8585
|
+
const multiplier = UNIT_SUFFIXES[trimmed.slice(-1)] ?? 1;
|
|
8586
|
+
const digits = trimmed
|
|
8587
|
+
.split(decimal).map((part) => part.replace(/[^0-9-]/g, '')).join('.');
|
|
8588
|
+
const number = digits && digits !== '-' && digits !== '.' ? Number(digits) * multiplier : NaN;
|
|
8589
|
+
if (!Number.isFinite(number)) {
|
|
8590
|
+
return null;
|
|
8591
|
+
}
|
|
8592
|
+
const isPercent = (placement?.format ?? field.format) === GridFormat.Percent;
|
|
8593
|
+
return isPercent && context.format.ratioPercent ? number / 100 : number;
|
|
8594
|
+
}
|
|
8595
|
+
function decimalMark(locale) {
|
|
8596
|
+
return numberFormat(locale, {}).formatToParts(1.5).find((part) => part.type === 'decimal')?.value ?? '.';
|
|
8597
|
+
}
|
|
8598
|
+
|
|
8599
|
+
// A grid tile: fs-grid over the tile's rows, read through ReportGridSource.
|
|
8600
|
+
//
|
|
8601
|
+
// - The params (the report filters the tile resolves to and the 'any'
|
|
8602
|
+
// groups) are seeded before the first render, so no read goes out without
|
|
8603
|
+
// them. After that a change to one of the tile's filter groups settles for
|
|
8604
|
+
// TILE_REFRESH_DEBOUNCE_MS, so a sweep of changes costs one read; one sent
|
|
8605
|
+
// under an older state version is dropped.
|
|
8606
|
+
// - The layout on screen is the state service's (gridLayout): the viewer's
|
|
8607
|
+
// own while they changed it from the layout authored now, else the
|
|
8608
|
+
// authored one. It outlives a page switch, and the chat reads it. So do
|
|
8609
|
+
// the groups the viewer left open (gridExpansion), which a Clear leaves.
|
|
8610
|
+
// - A state version move (Clear filters, a saved filter) skips the wait; the
|
|
8611
|
+
// reset has put the authored layout back in the same tick, so it costs one
|
|
8612
|
+
// read.
|
|
8613
|
+
// - A table that follows the report's group-by (followsGroupBy) sends its
|
|
8614
|
+
// grain, and reads again at once when it changes. The state service has
|
|
8615
|
+
// moved its filter on the group-by's field to the new grain by then, or
|
|
8616
|
+
// taken it out (clearedFilter, which the status line names), so one read
|
|
8617
|
+
// goes out and nothing paints unfiltered.
|
|
8618
|
+
// - fs-grid compares the layout and params by value: a cloned value never
|
|
8619
|
+
// reads again.
|
|
8620
|
+
// - An output's detail cell is drawn by fsReportGridCell, so a change tells
|
|
8621
|
+
// a missing prior from a zero one and says why on hover, as a list does.
|
|
8622
|
+
class ComponentGridComponent {
|
|
8623
|
+
reportId = input.required();
|
|
8624
|
+
component = input.required();
|
|
8625
|
+
params = signal(null);
|
|
8626
|
+
controls = computed(() => this.component().config?.controls ?? {});
|
|
8627
|
+
source = computed(() => new ReportGridSource(this._reportData, this.reportId(), this._componentId(), {
|
|
8628
|
+
locale: () => this._filterState.formatContext().locale,
|
|
8629
|
+
}));
|
|
8630
|
+
layout = computed(() => this._filterState.gridLayout(this.component()));
|
|
8631
|
+
clearedFilter = computed(() => this._filterState.gridClearedFilter(this.component()));
|
|
8632
|
+
// Read once: fs-grid owns the open groups from then on and writes them out.
|
|
8633
|
+
expansion = signal(undefined);
|
|
8634
|
+
noteData = computed(() => this.source().report());
|
|
8635
|
+
formatContext = computed(() => this._filterState.formatContext());
|
|
8636
|
+
formatter = computed(() => reportGridFormatter({
|
|
8637
|
+
format: this._filterState.formatContext(),
|
|
8638
|
+
timezone: this._filterState.timezone(),
|
|
8639
|
+
report: this.source().report(),
|
|
8640
|
+
}));
|
|
8641
|
+
_componentId = computed(() => this.component().id);
|
|
8642
|
+
_reportData = inject(ReportData);
|
|
8643
|
+
_filterState = inject(ReportFilterStateService);
|
|
8644
|
+
_destroyRef = inject(DestroyRef);
|
|
8645
|
+
// The state version the params on screen were read under.
|
|
8646
|
+
_paramsVersion = 0;
|
|
8647
|
+
ngOnInit() {
|
|
8648
|
+
this.expansion.set(this._filterState.gridExpansion(this.component()));
|
|
8649
|
+
this._init();
|
|
8650
|
+
}
|
|
8651
|
+
setLayout(layout) {
|
|
8652
|
+
this._filterState.setGridLayout(this.component(), layout);
|
|
8653
|
+
}
|
|
8654
|
+
setExpansion(expansion) {
|
|
8655
|
+
this._filterState.setGridExpansion(this.component(), expansion);
|
|
8656
|
+
}
|
|
8657
|
+
_init() {
|
|
8658
|
+
this._paramsVersion = this._filterState.stateVersion();
|
|
8659
|
+
this.params.set(this._readParams());
|
|
8660
|
+
this._filterState.changesFor(this.component())
|
|
8661
|
+
.pipe(switchMap$1(() => this._settled$()), tap$1(() => this._applyParams()), takeUntilDestroyed(this._destroyRef))
|
|
8662
|
+
.subscribe();
|
|
8663
|
+
if (followsGroupBy(this.component())) {
|
|
8664
|
+
this._filterState.frequencyChanges()
|
|
8665
|
+
.pipe(takeUntilDestroyed(this._destroyRef))
|
|
8666
|
+
.subscribe(() => this._applyParams());
|
|
8667
|
+
}
|
|
8668
|
+
}
|
|
8669
|
+
// A change, once it has settled: at once when the state version moved,
|
|
8670
|
+
// else after the debounce; dropped when the version moves while it waits,
|
|
8671
|
+
// since that move brings its own change.
|
|
8672
|
+
_settled$() {
|
|
8673
|
+
const version = this._filterState.stateVersion();
|
|
8674
|
+
const moved = version !== this._paramsVersion;
|
|
8675
|
+
return (moved ? of(0) : timer(TILE_REFRESH_DEBOUNCE_MS))
|
|
8676
|
+
.pipe(filter$1(() => this._filterState.stateVersion() === version), map$1(() => undefined));
|
|
8677
|
+
}
|
|
8678
|
+
_applyParams() {
|
|
8679
|
+
this._paramsVersion = this._filterState.stateVersion();
|
|
8680
|
+
this.params.set(this._readParams());
|
|
8681
|
+
}
|
|
8682
|
+
// The tile's report filters, 'any' groups and group-by grain as they stand
|
|
8683
|
+
// (tileState()).
|
|
8684
|
+
_readParams() {
|
|
8685
|
+
const component = this.component();
|
|
8686
|
+
const { matchAnyGroupIds, frequency } = this._filterState.tileState(component);
|
|
8687
|
+
return {
|
|
8688
|
+
filters: this._filterState.resolveForComponent(component),
|
|
8689
|
+
...(matchAnyGroupIds?.length ? { matchAnyGroupIds } : {}),
|
|
8690
|
+
...(frequency ? { frequency } : {}),
|
|
8691
|
+
};
|
|
8692
|
+
}
|
|
8693
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: ComponentGridComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
8694
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "18.2.14", type: ComponentGridComponent, isStandalone: true, selector: "app-report-component-grid", inputs: { reportId: { classPropertyName: "reportId", publicName: "reportId", isSignal: true, isRequired: true, transformFunction: null }, component: { classPropertyName: "component", publicName: "component", isSignal: true, isRequired: true, transformFunction: null } }, ngImport: i0, template: "<!-- The comparison and window note strip above the table, from the report\n line of the latest read; the windows' descriptions and notes go in each\n column's \"About this column\" instead. -->\n<div class=\"fs-column grid-pane\">\n <app-report-component-note\n [data]=\"noteData()\"\n [windowNotes]=\"false\">\n </app-report-component-note>\n <!-- The params are seeded before the first render, so the grid's first read\n carries the tile's report filters. -->\n @if (params(); as params) {\n <fs-grid\n class=\"fs-flex\"\n [source]=\"source()\"\n [layout]=\"layout()\"\n [expansion]=\"expansion()\"\n [clearedFilter]=\"clearedFilter()\"\n (expansionChange)=\"setExpansion($event)\"\n [params]=\"params\"\n [controls]=\"controls()\"\n [formatter]=\"formatter()\"\n (layoutChange)=\"setLayout($event)\">\n <ng-template fsGridCell let-value let-field=\"field\" let-record=\"record\" let-text=\"text\">\n @if (value | fsReportGridCell: field : record : text : noteData() : formatContext(); as shown) {\n <span [attr.title]=\"shown.title\">{{ shown.text }}</span>\n }\n </ng-template>\n </fs-grid>\n }\n</div>\n", styles: [":host{display:block;height:100%}.grid-pane{height:100%;min-height:0}fs-grid{min-height:0}\n"], dependencies: [{ kind: "component", type: FsGridComponent, selector: "fs-grid", inputs: ["source", "layout", "controls", "params", "expansion", "panel", "editorOpen", "formatter", "clearedFilter"], outputs: ["layoutChange", "expansionChange", "panelChange", "editorOpenChange"] }, { kind: "directive", type: FsGridCellDirective, selector: "[fsGridCell]" }, { kind: "component", type: ComponentNoteComponent, selector: "app-report-component-note", inputs: ["data", "windows", "windowDescriptions", "windowNotes"] }, { kind: "pipe", type: ReportGridCellPipe, name: "fsReportGridCell" }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
8695
|
+
}
|
|
8696
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: ComponentGridComponent, decorators: [{
|
|
8697
|
+
type: Component,
|
|
8698
|
+
args: [{ selector: 'app-report-component-grid', changeDetection: ChangeDetectionStrategy.OnPush, standalone: true, imports: [
|
|
8699
|
+
FsGridComponent,
|
|
8700
|
+
FsGridCellDirective,
|
|
8701
|
+
ComponentNoteComponent,
|
|
8702
|
+
ReportGridCellPipe,
|
|
8703
|
+
], template: "<!-- The comparison and window note strip above the table, from the report\n line of the latest read; the windows' descriptions and notes go in each\n column's \"About this column\" instead. -->\n<div class=\"fs-column grid-pane\">\n <app-report-component-note\n [data]=\"noteData()\"\n [windowNotes]=\"false\">\n </app-report-component-note>\n <!-- The params are seeded before the first render, so the grid's first read\n carries the tile's report filters. -->\n @if (params(); as params) {\n <fs-grid\n class=\"fs-flex\"\n [source]=\"source()\"\n [layout]=\"layout()\"\n [expansion]=\"expansion()\"\n [clearedFilter]=\"clearedFilter()\"\n (expansionChange)=\"setExpansion($event)\"\n [params]=\"params\"\n [controls]=\"controls()\"\n [formatter]=\"formatter()\"\n (layoutChange)=\"setLayout($event)\">\n <ng-template fsGridCell let-value let-field=\"field\" let-record=\"record\" let-text=\"text\">\n @if (value | fsReportGridCell: field : record : text : noteData() : formatContext(); as shown) {\n <span [attr.title]=\"shown.title\">{{ shown.text }}</span>\n }\n </ng-template>\n </fs-grid>\n }\n</div>\n", styles: [":host{display:block;height:100%}.grid-pane{height:100%;min-height:0}fs-grid{min-height:0}\n"] }]
|
|
8704
|
+
}] });
|
|
8705
|
+
|
|
8069
8706
|
// A KPI's trend over its last periods: a line that stretches to the card's
|
|
8070
8707
|
// width, broken where a period has no value, with the last point marked
|
|
8071
8708
|
// (hollow while its period is still running). The markers are drawn over the
|
|
@@ -9585,7 +10222,7 @@ class ComponentTextComponent {
|
|
|
9585
10222
|
}
|
|
9586
10223
|
}
|
|
9587
10224
|
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: ComponentTextComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
9588
|
-
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "18.2.14", type: ComponentTextComponent, isStandalone: true, selector: "app-report-component-text", inputs: { html: { classPropertyName: "html", publicName: "html", isSignal: true, isRequired: false, transformFunction: null }, tokens: { classPropertyName: "tokens", publicName: "tokens", isSignal: true, isRequired: true, transformFunction: null }, editing: { classPropertyName: "editing", publicName: "editing", isSignal: true, isRequired: false, transformFunction: null }, shownElsewhere: { classPropertyName: "shownElsewhere", publicName: "shownElsewhere", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { saved: "saved", editEnded: "editEnded", valueSettings: "valueSettings" }, host: { listeners: { "keydown": "onKeydown($event)" }, properties: { "style.--text-size": "typeScale.body", "style.--text-h1-size": "typeScale.h1", "style.--text-h2-size": "typeScale.h2", "style.--text-h3-size": "typeScale.h3", "style.--text-color": "typeScale.color", "style.--text-table-border": "tableLook.border", "style.--text-table-padding": "tableLook.padding", "style.--text-table-margin": "tableLook.margin" } }, viewQueries: [{ propertyName: "_editor", first: true, predicate: FsHtmlEditorComponent, descendants: true, isSignal: true }, { propertyName: "_valuePopoverAnchor", first: true, predicate: ["valuePopoverAnchor"], descendants: true, read: ElementRef, isSignal: true }], ngImport: i0, template: "@if (editing()) {\n <!-- Floating fs-html-editor: no frame, Froala's inline toolbar pops up over\n the text. pointerdown stays here so a press in the text never starts a\n drag of the block or a pan of the canvas. -->\n <fs-html-editor\n class=\"text-content editing\"\n [config]=\"editorConfig\"\n [(ngModel)]=\"draft\"\n (pointerdown)=\"$event.stopPropagation()\"\n (mousedown)=\"$event.stopPropagation()\">\n </fs-html-editor>\n\n <!-- One notice at a time: an edit stopped because it would have removed\n values, an edit that left values no text reads, or a refused command.\n mousedown keeps the editor's focus and selection, so Undo reaches it. -->\n @if (notice(); as current) {\n <div\n class=\"value-notice fs-row.gap-sm\"\n (pointerdown)=\"$event.stopPropagation()\"\n (mousedown)=\"$event.preventDefault()\">\n @switch (current.kind) {\n @case ('blocked') {\n @if (current.block.removeSelection) {\n <span>\n This selection includes {{ current.block.count }} {{ current.block.count === 1 ? 'value' : 'values' }}.\n </span>\n <button\n mat-button\n color=\"primary\"\n type=\"button\"\n (click)=\"removeBlockedSelection()\">\n Remove text and {{ current.block.count === 1 ? 'value' : current.block.count + ' values' }}\n </button>\n } @else {\n <span>To take a value out, select it, then press Delete.</span>\n }\n }\n @case ('unused') {\n <span>{{ current.count }} {{ current.count === 1 ? 'value' : 'values' }} moved to Not used</span>\n <button\n mat-button\n color=\"primary\"\n type=\"button\"\n (click)=\"undoValuesUnused()\">\n Undo\n </button>\n }\n @case ('message') {\n <span>{{ current.text }}</span>\n }\n }\n <button\n mat-button\n type=\"button\"\n (click)=\"dismissNotice()\">\n Dismiss\n </button>\n </div>\n }\n} @else {\n <div\n class=\"text-content\"\n [innerHTML]=\"displayHtml()\">\n </div>\n}\n\n<!-- A clicked value chip's popover: an fs-popover on an invisible anchor laid\n over the chip (the chip itself belongs to the editor). Outside the editing\n block, so a popover still open when editing ends closes the usual way. -->\n<span\n #valuePopoverAnchor\n class=\"value-popover-anchor\"\n aria-hidden=\"true\"\n fsPopover\n trigger=\"click\"\n wrapperClass=\"value-popover-wrapper\"\n [template]=\"valuePopoverTemplate\"\n [indication]=\"false\"\n [maxWidth]=\"320\"\n [style.left.px]=\"valuePopover()?.left\"\n [style.top.px]=\"valuePopover()?.top\"\n [style.width.px]=\"valuePopover()?.width\"\n [style.height.px]=\"valuePopover()?.height\">\n</span>\n<ng-template\n #valuePopoverTemplate\n let-popover=\"popover\">\n <!-- What the value is, and its settings (where it can also be deleted); a\n chip naming no value on the page has no settings. -->\n <div class=\"value-popover fs-row\">\n <div class=\"fs-flex\">\n <div class=\"value-popover-name\">{{ valuePopoverValue()?.name ?? valuePopover()?.key }}</div>\n @if (!valuePopoverValue()) {\n <div class=\"small\">Not a value on this page</div>\n }\n </div>\n @if (valuePopoverValue()) {\n <button\n mat-icon-button\n type=\"button\"\n class=\"value-popover-settings\"\n matTooltip=\"Settings\"\n (click)=\"openValueSettings(popover)\">\n <mat-icon>settings</mat-icon>\n </button>\n }\n </div>\n</ng-template>\n", styles: ["@charset \"UTF-8\";:host{display:block;position:relative;width:100%;height:100%}.text-content{display:block;width:100%;height:100%;overflow:hidden;font-family:Arial,Helvetica,sans-serif;font-size:var(--text-size);line-height:1.25;color:var(--text-color);word-wrap:break-word;letter-spacing:normal;word-spacing:normal;font-weight:400;font-style:normal;text-transform:none;white-space:normal}.text-content ::ng-deep p,.text-content ::ng-deep h1,.text-content ::ng-deep h2,.text-content ::ng-deep h3,.text-content ::ng-deep ul,.text-content ::ng-deep ol{margin:0}.text-content ::ng-deep h1{font-size:var(--text-h1-size)}.text-content ::ng-deep h2{font-size:var(--text-h2-size)}.text-content ::ng-deep h3{font-size:var(--text-h3-size)}.text-content ::ng-deep h1,.text-content ::ng-deep h2,.text-content ::ng-deep h3{font-weight:700;line-height:1.2}.text-content ::ng-deep ul,.text-content ::ng-deep ol{padding-left:1.4em}.text-content ::ng-deep p:empty:before{content:\"\\200b\"}.text-content ::ng-deep a{color:inherit}.text-content ::ng-deep table{border-collapse:collapse;margin:var(--text-table-margin) 0}.text-content ::ng-deep th,.text-content ::ng-deep td{border:var(--text-table-border);padding:var(--text-table-padding);vertical-align:middle;text-align:left}.text-content ::ng-deep th{text-align:center;font-weight:700}.text-content.editing{overflow:auto;cursor:text}.text-content.editing ::ng-deep [data-embed-key],.text-content.editing ::ng-deep [data-embed-field]{padding:0 2px;border-radius:3px;background-color:#4e79a729;-webkit-user-select:all;user-select:all}.text-content.editing ::ng-deep [data-embed-key]{cursor:pointer}.text-content.editing ::ng-deep [data-embed-key]:hover{background-color:#4e79a747}.text-content.editing ::ng-deep [data-embed-missing]{background-color:#e1575924;outline:1px dashed rgba(225,87,89,.7)}.text-content.editing ::ng-deep th,.text-content.editing ::ng-deep tfoot td{background-color:transparent}.text-content.editing ::ng-deep .fr-table-selector{display:none}.text-content.editing ::ng-deep .fr-table-selection-hover{outline:none}.value-popover-anchor{position:absolute;pointer-events:none}::ng-deep .value-popover-wrapper .popover-content{padding:15px!important}.value-popover{gap:15px}.value-popover-name{font-weight:600}.value-popover-settings{--mdc-icon-button-state-layer-size: 24px;width:24px;height:24px;padding:0}.value-notice{position:absolute;top:100%;left:0;z-index:2;margin-top:4px;padding:2px 4px 2px 10px;border:1px solid #d9dee3;border-radius:4px;background:#fff;white-space:nowrap;font-family:Arial,Helvetica,sans-serif}\n"], dependencies: [{ kind: "ngmodule", type: FormsModule }, { kind: "directive", type: i1$1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1$1.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }, { kind: "component", type: MatButton, selector: " button[mat-button], button[mat-raised-button], button[mat-flat-button], button[mat-stroked-button] ", exportAs: ["matButton"] }, { kind: "component", type: MatIconButton, selector: "button[mat-icon-button]", exportAs: ["matButton"] }, { kind: "component", type: MatIcon, selector: "mat-icon", inputs: ["color", "inline", "svgIcon", "fontSet", "fontIcon"], exportAs: ["matIcon"] }, { kind: "directive", type: MatTooltip, selector: "[matTooltip]", inputs: ["matTooltipPosition", "matTooltipPositionAtOrigin", "matTooltipDisabled", "matTooltipShowDelay", "matTooltipHideDelay", "matTooltipTouchGestures", "matTooltip", "matTooltipClass"], exportAs: ["matTooltip"] }, { kind: "component", type: FsHtmlEditorComponent, selector: "fs-html-editor", inputs: ["config", "disabled"] }, { kind: "directive", type: FsPopoverDirective, selector: "[fsPopover]", inputs: ["text", "template", "data", "leaveDelay", "showDelay", "maxWidth", "maxHeight", "wrapperClass", "autoShow", "autoClose", "loadingDiameter", "loading", "indication", "position", "theme", "size", "trigger", "enabled"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
10225
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "18.2.14", type: ComponentTextComponent, isStandalone: true, selector: "app-report-component-text", inputs: { html: { classPropertyName: "html", publicName: "html", isSignal: true, isRequired: false, transformFunction: null }, tokens: { classPropertyName: "tokens", publicName: "tokens", isSignal: true, isRequired: true, transformFunction: null }, editing: { classPropertyName: "editing", publicName: "editing", isSignal: true, isRequired: false, transformFunction: null }, shownElsewhere: { classPropertyName: "shownElsewhere", publicName: "shownElsewhere", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { saved: "saved", editEnded: "editEnded", valueSettings: "valueSettings" }, host: { listeners: { "keydown": "onKeydown($event)" }, properties: { "style.--text-size": "typeScale.body", "style.--text-h1-size": "typeScale.h1", "style.--text-h2-size": "typeScale.h2", "style.--text-h3-size": "typeScale.h3", "style.--text-color": "typeScale.color", "style.--text-table-border": "tableLook.border", "style.--text-table-padding": "tableLook.padding", "style.--text-table-margin": "tableLook.margin" } }, viewQueries: [{ propertyName: "_editor", first: true, predicate: FsHtmlEditorComponent, descendants: true, isSignal: true }, { propertyName: "_valuePopoverAnchor", first: true, predicate: ["valuePopoverAnchor"], descendants: true, read: ElementRef, isSignal: true }], ngImport: i0, template: "@if (editing()) {\n <!-- Floating fs-html-editor: no frame, Froala's inline toolbar pops up over\n the text. pointerdown stays here so a press in the text never starts a\n drag of the block or a pan of the canvas. -->\n <fs-html-editor\n class=\"text-content editing\"\n [config]=\"editorConfig\"\n [(ngModel)]=\"draft\"\n (pointerdown)=\"$event.stopPropagation()\"\n (mousedown)=\"$event.stopPropagation()\">\n </fs-html-editor>\n\n <!-- One notice at a time: an edit stopped because it would have removed\n values, an edit that left values no text reads, or a refused command.\n mousedown keeps the editor's focus and selection, so Undo reaches it. -->\n @if (notice(); as current) {\n <div\n class=\"value-notice fs-row.gap-sm\"\n (pointerdown)=\"$event.stopPropagation()\"\n (mousedown)=\"$event.preventDefault()\">\n @switch (current.kind) {\n @case ('blocked') {\n @if (current.block.removeSelection) {\n <span>\n This selection includes {{ current.block.count }} {{ current.block.count === 1 ? 'value' : 'values' }}.\n </span>\n <button\n mat-button\n color=\"primary\"\n type=\"button\"\n (click)=\"removeBlockedSelection()\">\n Remove text and {{ current.block.count === 1 ? 'value' : current.block.count + ' values' }}\n </button>\n } @else {\n <span>To take a value out, select it, then press Delete.</span>\n }\n }\n @case ('unused') {\n <span>{{ current.count }} {{ current.count === 1 ? 'value' : 'values' }} moved to Not used</span>\n <button\n mat-button\n color=\"primary\"\n type=\"button\"\n (click)=\"undoValuesUnused()\">\n Undo\n </button>\n }\n @case ('message') {\n <span>{{ current.text }}</span>\n }\n }\n <button\n mat-button\n type=\"button\"\n (click)=\"dismissNotice()\">\n Dismiss\n </button>\n </div>\n }\n} @else {\n <div\n class=\"text-content\"\n [innerHTML]=\"displayHtml()\">\n </div>\n}\n\n<!-- A clicked value chip's popover: an fs-popover on an invisible anchor laid\n over the chip (the chip itself belongs to the editor). Outside the editing\n block, so a popover still open when editing ends closes the usual way. -->\n<span\n #valuePopoverAnchor\n class=\"value-popover-anchor\"\n aria-hidden=\"true\"\n fsPopover\n trigger=\"click\"\n wrapperClass=\"value-popover-wrapper\"\n [template]=\"valuePopoverTemplate\"\n [indication]=\"false\"\n [maxWidth]=\"320\"\n [style.left.px]=\"valuePopover()?.left\"\n [style.top.px]=\"valuePopover()?.top\"\n [style.width.px]=\"valuePopover()?.width\"\n [style.height.px]=\"valuePopover()?.height\">\n</span>\n<ng-template\n #valuePopoverTemplate\n let-popover=\"popover\">\n <!-- What the value is, and its settings (where it can also be deleted); a\n chip naming no value on the page has no settings. -->\n <div class=\"value-popover fs-row\">\n <div class=\"fs-flex\">\n <div class=\"value-popover-name\">{{ valuePopoverValue()?.name ?? valuePopover()?.key }}</div>\n @if (!valuePopoverValue()) {\n <div class=\"small\">Not a value on this page</div>\n }\n </div>\n @if (valuePopoverValue()) {\n <button\n mat-icon-button\n type=\"button\"\n class=\"value-popover-settings\"\n matTooltip=\"Settings\"\n (click)=\"openValueSettings(popover)\">\n <mat-icon>settings</mat-icon>\n </button>\n }\n </div>\n</ng-template>\n", styles: ["@charset \"UTF-8\";:host{display:block;position:relative;width:100%;height:100%}.text-content{display:block;width:100%;height:100%;overflow:hidden;font-family:Arial,Helvetica,sans-serif;font-size:var(--text-size);line-height:1.25;color:var(--text-color);word-wrap:break-word;letter-spacing:normal;word-spacing:normal;font-weight:400;font-style:normal;text-transform:none;white-space:normal}.text-content ::ng-deep p,.text-content ::ng-deep h1,.text-content ::ng-deep h2,.text-content ::ng-deep h3,.text-content ::ng-deep ul,.text-content ::ng-deep ol{margin:0}.text-content ::ng-deep h1{font-size:var(--text-h1-size)}.text-content ::ng-deep h2{font-size:var(--text-h2-size)}.text-content ::ng-deep h3{font-size:var(--text-h3-size)}.text-content ::ng-deep h1,.text-content ::ng-deep h2,.text-content ::ng-deep h3{font-weight:700;line-height:1.2}.text-content ::ng-deep ul,.text-content ::ng-deep ol{padding-left:1.4em}.text-content ::ng-deep p:empty:before{content:\"\\200b\"}.text-content ::ng-deep a{color:inherit}.text-content ::ng-deep table{border-collapse:collapse;margin:var(--text-table-margin) 0}.text-content ::ng-deep th,.text-content ::ng-deep td{border:var(--text-table-border);padding:var(--text-table-padding);vertical-align:middle;text-align:left}.text-content ::ng-deep th{text-align:center;font-weight:700}.text-content.editing{overflow:auto;cursor:text}.text-content.editing ::ng-deep [data-embed-key],.text-content.editing ::ng-deep [data-embed-field]{padding:0 2px;border-radius:3px;background-color:#4e79a729;-webkit-user-select:all;user-select:all}.text-content.editing ::ng-deep [data-embed-key]{cursor:pointer}.text-content.editing ::ng-deep [data-embed-key]:hover{background-color:#4e79a747}.text-content.editing ::ng-deep [data-embed-missing]{background-color:#e1575924;outline:1px dashed rgba(225,87,89,.7)}.text-content.editing ::ng-deep th,.text-content.editing ::ng-deep tfoot td{background-color:transparent}.text-content.editing ::ng-deep .fr-table-selector{display:none}.text-content.editing ::ng-deep .fr-table-selection-hover{outline:none}.value-popover-anchor{position:absolute;pointer-events:none}::ng-deep .value-popover-wrapper .popover-content{padding:15px!important}.value-popover{gap:15px}.value-popover-name{font-weight:600}.value-popover-settings{--mdc-icon-button-state-layer-size: 24px;width:24px;height:24px;padding:0}.value-notice{position:absolute;top:100%;left:0;z-index:2;margin-top:4px;padding:2px 4px 2px 10px;border:1px solid #d9dee3;border-radius:4px;background:#fff;white-space:nowrap;font-family:Arial,Helvetica,sans-serif}\n"], dependencies: [{ kind: "ngmodule", type: FormsModule }, { kind: "directive", type: i1$1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1$1.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }, { kind: "component", type: MatButton, selector: " button[mat-button], button[mat-raised-button], button[mat-flat-button], button[mat-stroked-button] ", exportAs: ["matButton"] }, { kind: "component", type: MatIconButton, selector: "button[mat-icon-button]", exportAs: ["matButton"] }, { kind: "component", type: MatIcon, selector: "mat-icon", inputs: ["color", "inline", "svgIcon", "fontSet", "fontIcon"], exportAs: ["matIcon"] }, { kind: "directive", type: MatTooltip, selector: "[matTooltip]", inputs: ["matTooltipPosition", "matTooltipPositionAtOrigin", "matTooltipDisabled", "matTooltipShowDelay", "matTooltipHideDelay", "matTooltipTouchGestures", "matTooltip", "matTooltipClass"], exportAs: ["matTooltip"] }, { kind: "component", type: FsHtmlEditorComponent, selector: "fs-html-editor", inputs: ["config", "disabled"] }, { kind: "directive", type: FsPopoverDirective, selector: "[fsPopover]", inputs: ["text", "template", "data", "leaveDelay", "showDelay", "maxWidth", "maxHeight", "wrapperClass", "autoShow", "autoClose", "loadingDiameter", "loading", "indication", "position", "theme", "size", "trigger", "enabled", "panel"], outputs: ["closed"], exportAs: ["fsPopover"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
9589
10226
|
}
|
|
9590
10227
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: ComponentTextComponent, decorators: [{
|
|
9591
10228
|
type: Component,
|
|
@@ -9617,6 +10254,9 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImpo
|
|
|
9617
10254
|
const HANDLES = ['nw', 'n', 'ne', 'e', 'se', 's', 'sw', 'w'];
|
|
9618
10255
|
const MIN_W = 0.6; // inches
|
|
9619
10256
|
const MIN_H = 0.4; // inches
|
|
10257
|
+
// The tiles that read their own rows: a list through FsList paging, a grid
|
|
10258
|
+
// through fs-grid's source. This component loads every other tile's data.
|
|
10259
|
+
const SELF_READING_TYPES = ['list', 'grid'];
|
|
9620
10260
|
// Two presses on a text block this close together open its editor. Detected
|
|
9621
10261
|
// here rather than with a dblclick listener because the drag loop claims the
|
|
9622
10262
|
// pointer on every press, and a browser is not obliged to deliver dblclick
|
|
@@ -9629,7 +10269,8 @@ const DOUBLE_PRESS_MS = 400;
|
|
|
9629
10269
|
// snapper); everything works at any zoom (pixel deltas ÷ zoom·96 → inches).
|
|
9630
10270
|
//
|
|
9631
10271
|
// Charts and KPIs fetch their own data here; lists fetch inside
|
|
9632
|
-
// ComponentListComponent through FsList paging
|
|
10272
|
+
// ComponentListComponent through FsList paging, and grids inside
|
|
10273
|
+
// ComponentGridComponent through fs-grid's source.
|
|
9633
10274
|
class ReportComponentComponent {
|
|
9634
10275
|
reportId;
|
|
9635
10276
|
component;
|
|
@@ -9757,9 +10398,10 @@ class ReportComponentComponent {
|
|
|
9757
10398
|
if (!this.isText) {
|
|
9758
10399
|
this._rebuildHeaderOnNewState();
|
|
9759
10400
|
}
|
|
9760
|
-
// Lists fetch through FsList
|
|
9761
|
-
//
|
|
9762
|
-
|
|
10401
|
+
// Lists fetch through FsList and grids through fs-grid's source (each
|
|
10402
|
+
// reads its own rows); text has nothing to fetch, and a value is never
|
|
10403
|
+
// drawn (it reads inside text).
|
|
10404
|
+
if (!SELF_READING_TYPES.includes(this.component.type) && !this.isText && isDrawn(this.component)) {
|
|
9763
10405
|
// One load stream (tileLoads): the first load at once, then one per
|
|
9764
10406
|
// settled sweep of changes, each cancelling the one in flight, and an
|
|
9765
10407
|
// answer sent before a reset is dropped.
|
|
@@ -9769,9 +10411,9 @@ class ReportComponentComponent {
|
|
|
9769
10411
|
this._filterState.changesFor(this.component)
|
|
9770
10412
|
.pipe(takeUntilDestroyed(this._destroyRef))
|
|
9771
10413
|
.subscribe(() => this._refresh$.next());
|
|
9772
|
-
// A group-by change re-buckets every
|
|
9773
|
-
// to a filter group, so it rides its own stream.
|
|
9774
|
-
if (this.
|
|
10414
|
+
// A group-by change re-buckets every tile that follows it; it isn't
|
|
10415
|
+
// scoped to a filter group, so it rides its own stream.
|
|
10416
|
+
if (followsGroupBy(this.component)) {
|
|
9775
10417
|
this._filterState.frequencyChanges()
|
|
9776
10418
|
.pipe(takeUntilDestroyed(this._destroyRef))
|
|
9777
10419
|
.subscribe(() => {
|
|
@@ -9961,17 +10603,18 @@ class ReportComponentComponent {
|
|
|
9961
10603
|
}
|
|
9962
10604
|
}
|
|
9963
10605
|
// The CSV is what the screen shows, so it carries the tile's interaction
|
|
9964
|
-
// state (tileDataState): the group-by grain for a
|
|
10606
|
+
// state (tileDataState): the group-by grain for a tile that follows it, a
|
|
9965
10607
|
// chart's drill path, the viewer's header sort, search and hidden columns
|
|
9966
10608
|
// for a list (a searched list exports only the rows the search leaves, and
|
|
9967
10609
|
// the hidden columns stay out of the file), and the groups that match
|
|
9968
|
-
// 'any';
|
|
9969
|
-
//
|
|
9970
|
-
//
|
|
9971
|
-
// for the whole result.
|
|
10610
|
+
// 'any'; a table's layout as the viewer changed it; and its title as the
|
|
10611
|
+
// header reads it (shownTitle), which the server names the file after. A
|
|
10612
|
+
// note the server returns (the file was cut) is shown before the download
|
|
10613
|
+
// starts, so a short file never passes for the whole result.
|
|
9972
10614
|
exportCsv() {
|
|
9973
10615
|
const filters = this._filterState.resolveForComponent(this.component);
|
|
9974
|
-
const
|
|
10616
|
+
const layout = this.component.type === 'grid' ? this._filterState.gridSessionLayout(this.component) : null;
|
|
10617
|
+
const state = { ...this._filterState.tileState(this.component), ...(layout ? { layout } : {}) };
|
|
9975
10618
|
const title = this.shownTitle;
|
|
9976
10619
|
this._process.download(`Exporting ${title || 'component'}`, this._reportData.exportComponent(this.reportId, this.component.id, filters, state, title)
|
|
9977
10620
|
.pipe(tap$1(({ notes }) => notes?.length && this._message.warning(notes.join(' '))), map$1(({ url }) => url)));
|
|
@@ -10224,11 +10867,6 @@ class ReportComponentComponent {
|
|
|
10224
10867
|
h: this.component.h,
|
|
10225
10868
|
});
|
|
10226
10869
|
}
|
|
10227
|
-
// A chart on a time x-axis — the only component the report's group-by control
|
|
10228
|
-
// applies to (isTimeSeriesChart, the browser-side mirror of the server's rule).
|
|
10229
|
-
get _isTimeSeries() {
|
|
10230
|
-
return isTimeSeriesChart(this.component);
|
|
10231
|
-
}
|
|
10232
10870
|
// Auto height (box fits its content) vs fixed (use `h` exactly). Derived from
|
|
10233
10871
|
// type, server-side: KPI tiles are auto; charts and lists are fixed — charts
|
|
10234
10872
|
// fit the box and never overflow, lists scroll inside it.
|
|
@@ -10275,9 +10913,9 @@ class ReportComponentComponent {
|
|
|
10275
10913
|
this.error.set(null);
|
|
10276
10914
|
const filters = this._filterState.resolveForComponent(this.component);
|
|
10277
10915
|
// The tile's interaction state, the one its CSV and every export carry
|
|
10278
|
-
// (tileDataState): a
|
|
10279
|
-
// one) so the backend re-buckets, a chart's drill
|
|
10280
|
-
// that match 'any'.
|
|
10916
|
+
// (tileDataState): the grain of a tile that follows the group-by (when
|
|
10917
|
+
// the viewer picked one) so the backend re-buckets, a chart's drill
|
|
10918
|
+
// path, and the groups that match 'any'.
|
|
10281
10919
|
const state = this._filterState.tileState(this.component);
|
|
10282
10920
|
return this._reportData.componentData(this.reportId, this.component.id, filters, state);
|
|
10283
10921
|
}
|
|
@@ -10335,7 +10973,7 @@ class ReportComponentComponent {
|
|
|
10335
10973
|
openAtPoint(anchor, click.pointer, this.zoom, () => this.markMenu?.openMenu(null));
|
|
10336
10974
|
}
|
|
10337
10975
|
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: ReportComponentComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
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 });
|
|
10976
|
+
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 (component.type === 'grid') {\n <app-report-component-grid\n [reportId]=\"reportId\"\n [component]=\"component\">\n </app-report-component-grid>\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: ComponentGridComponent, selector: "app-report-component-grid", inputs: ["reportId", "component"] }, { 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 });
|
|
10339
10977
|
}
|
|
10340
10978
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: ReportComponentComponent, decorators: [{
|
|
10341
10979
|
type: Component,
|
|
@@ -10347,10 +10985,11 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImpo
|
|
|
10347
10985
|
FsFilterModule,
|
|
10348
10986
|
FsMenuModule,
|
|
10349
10987
|
ComponentChartComponent,
|
|
10988
|
+
ComponentGridComponent,
|
|
10350
10989
|
ComponentKpiComponent,
|
|
10351
10990
|
ComponentListComponent,
|
|
10352
10991
|
ComponentTextComponent,
|
|
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"] }]
|
|
10992
|
+
], 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 (component.type === 'grid') {\n <app-report-component-grid\n [reportId]=\"reportId\"\n [component]=\"component\">\n </app-report-component-grid>\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"] }]
|
|
10354
10993
|
}], propDecorators: { reportId: [{
|
|
10355
10994
|
type: Input
|
|
10356
10995
|
}], component: [{
|
|
@@ -12696,6 +13335,52 @@ function nameInputId(groupId) {
|
|
|
12696
13335
|
return `filterName${groupId}`;
|
|
12697
13336
|
}
|
|
12698
13337
|
|
|
13338
|
+
// A table tile as the PDF and the deck draw it: the printed table
|
|
13339
|
+
// readGridTable() reads (GridTable) as heading rows, body rows and a total
|
|
13340
|
+
// row of plain cells, and the lines its notes add. Pure, so both renderers
|
|
13341
|
+
// draw one table one way.
|
|
13342
|
+
// The most pivot columns a file draws, from the first.
|
|
13343
|
+
const GRID_EXPORT_COLUMNS = 60;
|
|
13344
|
+
// The table's heading, body and total row as cells. A group's label carries
|
|
13345
|
+
// its row count after it, as the screen writes it.
|
|
13346
|
+
function gridExportTable(table) {
|
|
13347
|
+
const numeric = table.columns.map((column) => column.numeric);
|
|
13348
|
+
const head = Array.from({ length: Math.max(1, table.headingRows) }, (_, row) => table.headings
|
|
13349
|
+
.filter((heading) => heading.row === row)
|
|
13350
|
+
.sort((a, b) => a.column - b.column)
|
|
13351
|
+
.map((heading) => exportCell$1(headingText(heading), true, 0, heading.numeric, heading.columnSpan, heading.rowSpan)));
|
|
13352
|
+
const line = (row) => row.cells.map((cell, index) => {
|
|
13353
|
+
const isLabel = index === 0 && table.groupColumn;
|
|
13354
|
+
return exportCell$1(isLabel && row.count ? `${cell.text} (${row.count})` : cell.text, row.kind !== GridRowKind.Detail || !!table.columns[index]?.emphasis, isLabel ? row.depth : 0, numeric[index] ?? false);
|
|
13355
|
+
});
|
|
13356
|
+
return {
|
|
13357
|
+
head,
|
|
13358
|
+
body: table.rows.map(line),
|
|
13359
|
+
foot: table.totalRow ? line(table.totalRow) : null,
|
|
13360
|
+
numeric,
|
|
13361
|
+
moreRows: table.moreRows,
|
|
13362
|
+
};
|
|
13363
|
+
}
|
|
13364
|
+
// The lines a table adds under its box: the screen's notes, the pivot
|
|
13365
|
+
// columns it leaves out, and each column filter of the layout it was read in.
|
|
13366
|
+
function gridExportNotes(table, layout, locale) {
|
|
13367
|
+
const fields = new Map(table.fields.map((field) => [field.name, field]));
|
|
13368
|
+
const filters = (layout.filters ?? [])
|
|
13369
|
+
.map((filter) => gridFilterLineText(fields.get(filter.field)?.label ?? filter.field, filter, fields.get(filter.field), locale))
|
|
13370
|
+
.filter((text) => !!text);
|
|
13371
|
+
return [
|
|
13372
|
+
...table.notes,
|
|
13373
|
+
...(table.shownColumns < table.columnCount ? [`Showing the first ${table.shownColumns} of ${table.columnCount} columns`] : []),
|
|
13374
|
+
...filters,
|
|
13375
|
+
];
|
|
13376
|
+
}
|
|
13377
|
+
function headingText(heading) {
|
|
13378
|
+
return heading.hint ? `${heading.label} (${heading.hint})` : heading.label;
|
|
13379
|
+
}
|
|
13380
|
+
function exportCell$1(text, bold, indent, numeric, colSpan = 1, rowSpan = 1) {
|
|
13381
|
+
return { text, colSpan, rowSpan, bold, indent, numeric };
|
|
13382
|
+
}
|
|
13383
|
+
|
|
12699
13384
|
// How far a KPI card's rows shrink to fit a short tile. A tile shorter still
|
|
12700
13385
|
// lets the card run past its foot rather than print it unreadably small.
|
|
12701
13386
|
const KPI_EXPORT_MIN_SCALE = 0.5;
|
|
@@ -12736,8 +13421,11 @@ function emptyLabel(item) {
|
|
|
12736
13421
|
// the matching rows: 'Orders (matching "north")'. An untitled one reads
|
|
12737
13422
|
// 'Matching "north"'. '' when there is nothing to print. Shared by the PDF
|
|
12738
13423
|
// and the deck, continuation pages included.
|
|
12739
|
-
function exportTitle(component, tokens, search, crumbs) {
|
|
13424
|
+
function exportTitle(component, tokens, search, crumbs, filtered = false) {
|
|
12740
13425
|
const title = crumbedTitle(fillTitleTokens(component.title, tokens), crumbs ?? []);
|
|
13426
|
+
if (filtered) {
|
|
13427
|
+
return title ? `${title} (filtered)` : 'Filtered';
|
|
13428
|
+
}
|
|
12741
13429
|
if (!search) {
|
|
12742
13430
|
return title;
|
|
12743
13431
|
}
|
|
@@ -12869,7 +13557,16 @@ function exportWindowsLine(item, timezone, locale) {
|
|
|
12869
13557
|
// descriptions are in, since a file has no tooltip or header to carry them.
|
|
12870
13558
|
// Empty when the data has none of the keys.
|
|
12871
13559
|
function exportNoteLines(item, timezone, locale) {
|
|
12872
|
-
return
|
|
13560
|
+
return [
|
|
13561
|
+
...componentNoteLines(item.data, { locale, timezone }, { windows: false, windowDescriptions: true }),
|
|
13562
|
+
...(item.tableNotes ?? []),
|
|
13563
|
+
];
|
|
13564
|
+
}
|
|
13565
|
+
// Whether a tile has nothing to draw: no rows, or a table with no line.
|
|
13566
|
+
function isEmptyTile(item) {
|
|
13567
|
+
return item.table
|
|
13568
|
+
? !item.table.rows.length && !item.table.totalRow
|
|
13569
|
+
: !item.data?.rows?.length;
|
|
12873
13570
|
}
|
|
12874
13571
|
// Whether a KPI card has more to draw than its value and label. Without, both
|
|
12875
13572
|
// exports draw the tile exactly as they always have.
|
|
@@ -13062,7 +13759,10 @@ class ReportExportCollectorService {
|
|
|
13062
13759
|
filters: Object.fromEntries(tiles.map((tile) => [tile.id, this._filterState.resolveForComponent(tile)])),
|
|
13063
13760
|
valueFilters: Object.fromEntries(values
|
|
13064
13761
|
.map((value) => [value.id, this._filterState.resolveForComponent(value)])),
|
|
13065
|
-
states: Object.fromEntries(tiles.map((tile) => [tile.id,
|
|
13762
|
+
states: Object.fromEntries(tiles.map((tile) => [tile.id, this._tileState(report, tile, state)])),
|
|
13763
|
+
gridExpansions: Object.fromEntries(tiles
|
|
13764
|
+
.map((tile) => [tile.id, this._filterState.gridExpansion(tile)])
|
|
13765
|
+
.filter(([, expansion]) => !!expansion)),
|
|
13066
13766
|
frequency: this._filterState.frequency(),
|
|
13067
13767
|
features: offeredViewerFeatures(report.viewerFeatures),
|
|
13068
13768
|
formatContext: this._filterState.formatContext(),
|
|
@@ -13146,10 +13846,14 @@ class ReportExportCollectorService {
|
|
|
13146
13846
|
return of({ component, data: null, hiddenColumns: [] });
|
|
13147
13847
|
}
|
|
13148
13848
|
const filters = snapshot.filters[component.id] ?? [];
|
|
13849
|
+
if (component.type === 'grid') {
|
|
13850
|
+
return this._gridTable(report, component, filters, snapshot.states[component.id] ?? {}, listRows, snapshot);
|
|
13851
|
+
}
|
|
13149
13852
|
// The tile's own state, the one its screen and its CSV carry
|
|
13150
|
-
// (tileDataState): a
|
|
13151
|
-
//
|
|
13152
|
-
// the screen's rows. A list adds its
|
|
13853
|
+
// (tileDataState): a tile that follows the group-by re-buckets at the
|
|
13854
|
+
// viewer's grain, a time-series chart keeps its drill path, a list reads
|
|
13855
|
+
// in the screen's order and holds the screen's rows. A list adds its
|
|
13856
|
+
// export paging.
|
|
13153
13857
|
const tileState = snapshot.states[component.id] ?? {};
|
|
13154
13858
|
const state = component.type === 'list'
|
|
13155
13859
|
? { ...tileState, page: 1, pageSize: listRows }
|
|
@@ -13164,6 +13868,47 @@ class ReportExportCollectorService {
|
|
|
13164
13868
|
return this._reportData.componentData(report.id, component.id, filters, state, true, { handlers: false })
|
|
13165
13869
|
.pipe(map$1((data) => this._collected(component, data, state.search, hiddenColumns, snapshot)), catchError(() => of({ component, data: null, hiddenColumns })));
|
|
13166
13870
|
}
|
|
13871
|
+
// A tile's state as an export reads it: tileDataState, and for a table the
|
|
13872
|
+
// layout the viewer put it in, absent while it shows its stored one.
|
|
13873
|
+
_tileState(report, tile, state) {
|
|
13874
|
+
const layout = tile.type === 'grid' ? this._filterState.gridSessionLayout(tile) : null;
|
|
13875
|
+
return { ...tileDataState(report, tile, state), ...(layout ? { layout } : {}) };
|
|
13876
|
+
}
|
|
13877
|
+
// A table tile as the screen draws it, read apart from the screen through
|
|
13878
|
+
// an export source (readGridTable()): in the viewer's layout with the
|
|
13879
|
+
// groups they left open, at most `rows` lines and GRID_EXPORT_COLUMNS
|
|
13880
|
+
// pivot columns, each value written by the screen's formatter once the
|
|
13881
|
+
// read's report line is in. That line stands in as its data, so the title,
|
|
13882
|
+
// the subtitle and the notes read it as a list's. A failed read is a
|
|
13883
|
+
// failed tile.
|
|
13884
|
+
_gridTable(report, component, filters, state, rows, snapshot) {
|
|
13885
|
+
const locale = snapshot.formatContext.locale;
|
|
13886
|
+
const source = new ReportGridSource(this._reportData, report.id, component.id, { isExport: true, locale: () => locale });
|
|
13887
|
+
const screen = () => reportGridFormatter({
|
|
13888
|
+
format: snapshot.formatContext,
|
|
13889
|
+
timezone: this._filterState.timezone(),
|
|
13890
|
+
report: source.report(),
|
|
13891
|
+
});
|
|
13892
|
+
const formatter = {
|
|
13893
|
+
format: (value, field, placement) => screen().format(value, field, placement),
|
|
13894
|
+
toNumber: (text, field, placement) => screen().toNumber(text, field, placement),
|
|
13895
|
+
};
|
|
13896
|
+
const layout = state.layout ?? authoredGridLayout(component);
|
|
13897
|
+
const params = {
|
|
13898
|
+
filters,
|
|
13899
|
+
...(state.matchAnyGroupIds?.length ? { matchAnyGroupIds: state.matchAnyGroupIds } : {}),
|
|
13900
|
+
...(state.frequency ? { frequency: state.frequency } : {}),
|
|
13901
|
+
};
|
|
13902
|
+
return readGridTable(source, layout, params, snapshot.gridExpansions?.[component.id], { rows, columns: GRID_EXPORT_COLUMNS }, formatter)
|
|
13903
|
+
.pipe(map$1((table) => ({
|
|
13904
|
+
component,
|
|
13905
|
+
data: { ...(source.report() ?? {}), columns: [], rows: [] },
|
|
13906
|
+
hiddenColumns: [],
|
|
13907
|
+
table,
|
|
13908
|
+
tableNotes: gridExportNotes(table, layout, locale),
|
|
13909
|
+
...((layout.filters ?? []).length ? { filtered: true } : {}),
|
|
13910
|
+
})), catchError(() => of({ component, data: null, hiddenColumns: [] })));
|
|
13911
|
+
}
|
|
13167
13912
|
// One tile's answer as the renderers take it. A search the server could
|
|
13168
13913
|
// not apply (searchNote: the list has no text column) narrowed nothing, so
|
|
13169
13914
|
// the item does not carry it. A drilled chart carries the breadcrumb of the
|
|
@@ -13308,7 +14053,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImpo
|
|
|
13308
14053
|
// in the report's format context, as on screen.
|
|
13309
14054
|
async function renderChartImage(component, data, width, height, context, pixelRatio = 3) {
|
|
13310
14055
|
// The same tree-shaken echarts build + 'report' theme the live canvas uses.
|
|
13311
|
-
const echarts = (await import('./firestitch-report-echarts-
|
|
14056
|
+
const echarts = (await import('./firestitch-report-echarts-EecIloKo.mjs')).default;
|
|
13312
14057
|
const host = document.createElement('div');
|
|
13313
14058
|
host.style.width = `${width}px`;
|
|
13314
14059
|
host.style.height = `${height}px`;
|
|
@@ -13628,6 +14373,8 @@ const LIST_INDENT = 16;
|
|
|
13628
14373
|
// What the export's overflow gate says the PDF does with a text block that does
|
|
13629
14374
|
// not fit its box.
|
|
13630
14375
|
const PDF_OVERFLOW_NOTE = 'The PDF cuts it off at the bottom of its box.';
|
|
14376
|
+
// How far a table's group label steps in per level, points.
|
|
14377
|
+
const GRID_INDENT$1 = 8;
|
|
13631
14378
|
// Builds the PDF client-side with jspdf. The report's unit is inches and PDFs
|
|
13632
14379
|
// are points at exactly 72/inch, so each report page becomes one PDF page of
|
|
13633
14380
|
// the exact physical size and components land at their precise positions.
|
|
@@ -14069,7 +14816,7 @@ class ReportPdfService {
|
|
|
14069
14816
|
const y = component.y * PT;
|
|
14070
14817
|
const w = component.w * PT;
|
|
14071
14818
|
const h = component.h * PT;
|
|
14072
|
-
const title = exportTitle(component, tokens, item.search, item.crumbs);
|
|
14819
|
+
const title = exportTitle(component, tokens, item.search, item.crumbs, item.filtered);
|
|
14073
14820
|
if (title) {
|
|
14074
14821
|
this._componentTitle(doc, title, x, y, w);
|
|
14075
14822
|
}
|
|
@@ -14090,7 +14837,7 @@ class ReportPdfService {
|
|
|
14090
14837
|
kept.forEach((line, index) => {
|
|
14091
14838
|
this._smallLine(doc, this._pdfText(line), x, bodyY + bodyH + index * NOTE_LINE_H$1, w, 'left', 'italic');
|
|
14092
14839
|
});
|
|
14093
|
-
if (!item.data ||
|
|
14840
|
+
if (!item.data || isEmptyTile(item)) {
|
|
14094
14841
|
this._boxNote(doc, emptyLabel(item), x, bodyY, w, bodyH);
|
|
14095
14842
|
return;
|
|
14096
14843
|
}
|
|
@@ -14101,6 +14848,7 @@ class ReportPdfService {
|
|
|
14101
14848
|
break;
|
|
14102
14849
|
}
|
|
14103
14850
|
case 'list':
|
|
14851
|
+
case 'grid':
|
|
14104
14852
|
this._renderTable(context, item, x, bodyY, w, bodyH, overflows);
|
|
14105
14853
|
break;
|
|
14106
14854
|
case 'kpi':
|
|
@@ -14153,12 +14901,8 @@ class ReportPdfService {
|
|
|
14153
14901
|
// A totals row closes the table once: in the box when every row fits it,
|
|
14154
14902
|
// else after the last continuation row. Points throughout.
|
|
14155
14903
|
_renderTable(context, item, x, y, w, h, overflows) {
|
|
14156
|
-
const { component, search,
|
|
14157
|
-
const
|
|
14158
|
-
const head = listHeadings(component, data, context.formatContext.locale, hiddenColumns)
|
|
14159
|
-
.map((heading) => this._pdfText(heading));
|
|
14160
|
-
const { cells, numeric, foot } = this._tableCells(context, component, data, hiddenColumns);
|
|
14161
|
-
const omitted = Math.max(0, (data.paging?.total ?? cells.length) - cells.length);
|
|
14904
|
+
const { component, search, filtered } = item;
|
|
14905
|
+
const { head, cells, numeric, foot, omitted } = item.table ? this._gridCells(item) : this._listCells(context, item);
|
|
14162
14906
|
const bottom = context.pageH - (y + h);
|
|
14163
14907
|
const { fit, columnStyles } = this._measure(context, head, cells, numeric, foot, x, y, w, bottom);
|
|
14164
14908
|
if (fit === cells.length && omitted === 0) {
|
|
@@ -14171,16 +14915,46 @@ class ReportPdfService {
|
|
|
14171
14915
|
// page.
|
|
14172
14916
|
const kept = Math.max(0, fit - 1);
|
|
14173
14917
|
overflows.push({
|
|
14174
|
-
component, search, crumbs: item.crumbs, head, numeric, rows: cells.slice(kept), omitted, foot, x, w, columnStyles,
|
|
14918
|
+
component, search, crumbs: item.crumbs, filtered, head, numeric, rows: cells.slice(kept), omitted, foot, x, w, columnStyles,
|
|
14175
14919
|
});
|
|
14176
14920
|
if (fit >= 1) {
|
|
14177
|
-
const body = [...cells.slice(0, kept), this._noticeRow(CONTINUED_NOTICE,
|
|
14921
|
+
const body = [...cells.slice(0, kept), this._noticeRow(CONTINUED_NOTICE, numeric.length, true)];
|
|
14178
14922
|
this._drawTable(context, this._tableOptions(context, head, numeric, body, x, y, w, bottom, columnStyles));
|
|
14179
14923
|
}
|
|
14180
14924
|
else {
|
|
14181
14925
|
this._boxNote(context.doc, CONTINUED_NOTICE, x, y, w, h);
|
|
14182
14926
|
}
|
|
14183
14927
|
}
|
|
14928
|
+
// A list's heading, rows, totals row and the rows past its fetch.
|
|
14929
|
+
_listCells(context, item) {
|
|
14930
|
+
const { component, hiddenColumns } = item;
|
|
14931
|
+
const data = item.data;
|
|
14932
|
+
const head = [listHeadings(component, data, context.formatContext.locale, hiddenColumns).map((heading) => this._pdfText(heading))];
|
|
14933
|
+
const { cells, numeric, foot } = this._tableCells(context, component, data, hiddenColumns);
|
|
14934
|
+
return { head, cells, numeric, foot, omitted: Math.max(0, (data.paging?.total ?? cells.length) - cells.length) };
|
|
14935
|
+
}
|
|
14936
|
+
// A table tile's heading rows (spanned as the screen's header), its lines
|
|
14937
|
+
// (groups and totals bold, a group's label stepped in by its depth) and its
|
|
14938
|
+
// total row, as gridExportTable() lays them out, and the lines past its cap.
|
|
14939
|
+
_gridCells(item) {
|
|
14940
|
+
const table = gridExportTable(item.table);
|
|
14941
|
+
const cell = (exported) => ({
|
|
14942
|
+
content: this._pdfText(exported.text),
|
|
14943
|
+
colSpan: exported.colSpan,
|
|
14944
|
+
rowSpan: exported.rowSpan,
|
|
14945
|
+
styles: {
|
|
14946
|
+
...(exported.bold ? { fontStyle: 'bold' } : {}),
|
|
14947
|
+
...(exported.indent ? { cellPadding: { top: 3, right: 3, bottom: 3, left: 3 + exported.indent * GRID_INDENT$1 } } : {}),
|
|
14948
|
+
},
|
|
14949
|
+
});
|
|
14950
|
+
return {
|
|
14951
|
+
head: table.head.map((row) => row.map(cell)),
|
|
14952
|
+
cells: table.body.map((row) => row.map(cell)),
|
|
14953
|
+
numeric: table.numeric,
|
|
14954
|
+
foot: table.foot ? table.foot.map((exported) => this._pdfText(exported.text)) : null,
|
|
14955
|
+
omitted: table.moreRows,
|
|
14956
|
+
};
|
|
14957
|
+
}
|
|
14184
14958
|
// Each fetched row as the strings its table cells print, in the report's
|
|
14185
14959
|
// zone, locale and currency, which columns hold formatted numbers, and the
|
|
14186
14960
|
// totals row (null without one). The columns the viewer hid are left out.
|
|
@@ -14207,7 +14981,7 @@ class ReportPdfService {
|
|
|
14207
14981
|
_tableOptions(context, head, numeric, body, x, startY, w, bottom, columnStyles, foot) {
|
|
14208
14982
|
return {
|
|
14209
14983
|
theme: 'grid',
|
|
14210
|
-
head
|
|
14984
|
+
head,
|
|
14211
14985
|
body,
|
|
14212
14986
|
startY,
|
|
14213
14987
|
margin: { left: x, right: context.pageW - x - w, bottom },
|
|
@@ -14319,7 +15093,7 @@ class ReportPdfService {
|
|
|
14319
15093
|
const bottom = this._continuationMargin(bands.footerHeight);
|
|
14320
15094
|
const { x, w } = overflow;
|
|
14321
15095
|
const body = overflow.omitted > 0
|
|
14322
|
-
? [...overflow.rows, this._noticeRow(moreRowsNotice(overflow.omitted), overflow.
|
|
15096
|
+
? [...overflow.rows, this._noticeRow(moreRowsNotice(overflow.omitted), overflow.numeric.length, false)]
|
|
14323
15097
|
: overflow.rows;
|
|
14324
15098
|
// The document's pages the table draws on, in order.
|
|
14325
15099
|
const pages = [];
|
|
@@ -14332,7 +15106,7 @@ class ReportPdfService {
|
|
|
14332
15106
|
willDrawPage: () => {
|
|
14333
15107
|
pages.push(doc.getCurrentPageInfo().pageNumber);
|
|
14334
15108
|
const tokens = this._tokens(context, page, index, pageNumber + pages.length);
|
|
14335
|
-
this._componentTitle(doc, this._continuedTitle(overflow.component, tokens, overflow.search, overflow.crumbs), x, top, w);
|
|
15109
|
+
this._componentTitle(doc, this._continuedTitle(overflow.component, tokens, overflow.search, overflow.crumbs, overflow.filtered), x, top, w);
|
|
14336
15110
|
},
|
|
14337
15111
|
});
|
|
14338
15112
|
await this._renderContinuationMasters(context, page, index, pageNumber, pages);
|
|
@@ -14355,8 +15129,8 @@ class ReportPdfService {
|
|
|
14355
15129
|
_continuationMargin(band) {
|
|
14356
15130
|
return band ? band * PT + CONTINUATION_GAP : NO_BAND_MARGIN;
|
|
14357
15131
|
}
|
|
14358
|
-
_continuedTitle(component, tokens, search, crumbs) {
|
|
14359
|
-
const title = exportTitle(component, tokens, search, crumbs);
|
|
15132
|
+
_continuedTitle(component, tokens, search, crumbs, filtered) {
|
|
15133
|
+
const title = exportTitle(component, tokens, search, crumbs, filtered);
|
|
14360
15134
|
return title ? `${title} (continued)` : 'Continued';
|
|
14361
15135
|
}
|
|
14362
15136
|
// A KPI tile: the card the screen draws (kpi-card.ts decides every line),
|
|
@@ -14596,6 +15370,9 @@ const PPTX_RANK_WHOLE_STEP_PEAK = 10;
|
|
|
14596
15370
|
const PPTX_RANK_FORMAT_CODE = '0';
|
|
14597
15371
|
// Approximate table row height, inches — drives the rows-that-fit cap.
|
|
14598
15372
|
const SLIDE_ROW_H = 0.26;
|
|
15373
|
+
// The spaces a table's group label steps in by per level: a cell's text is
|
|
15374
|
+
// the one place a slide table indents.
|
|
15375
|
+
const GRID_INDENT = '\u00A0\u00A0\u00A0';
|
|
14599
15376
|
// Rows fetched for a list's slide table. A slide table never continues onto
|
|
14600
15377
|
// another slide, so this only has to cover the rows a box can show; the
|
|
14601
15378
|
// moreRowsNotice() row names the rest.
|
|
@@ -14983,7 +15760,7 @@ class ReportPptxService {
|
|
|
14983
15760
|
const component = item.component;
|
|
14984
15761
|
// Geometry is in inches — PowerPoint's native unit.
|
|
14985
15762
|
const { x, y, w, h } = component;
|
|
14986
|
-
const title = exportTitle(component, tokens, item.search, item.crumbs);
|
|
15763
|
+
const title = exportTitle(component, tokens, item.search, item.crumbs, item.filtered);
|
|
14987
15764
|
if (title) {
|
|
14988
15765
|
slide.addText(title, {
|
|
14989
15766
|
x, y, w, h: TITLE_H,
|
|
@@ -15013,7 +15790,7 @@ class ReportPptxService {
|
|
|
15013
15790
|
fontSize: SMALL_FONT_SIZE, color: MUTED_COLOR, valign: 'top', margin: 0, fit: 'none',
|
|
15014
15791
|
});
|
|
15015
15792
|
}
|
|
15016
|
-
if (!item.data ||
|
|
15793
|
+
if (!item.data || isEmptyTile(item)) {
|
|
15017
15794
|
slide.addText(emptyLabel(item), {
|
|
15018
15795
|
x, y: bodyY, w, h: bodyH,
|
|
15019
15796
|
fontSize: 10, italic: true, color: MUTED_COLOR, align: 'center', valign: 'middle',
|
|
@@ -15027,6 +15804,9 @@ class ReportPptxService {
|
|
|
15027
15804
|
case 'list':
|
|
15028
15805
|
this._renderTable(slide, component, item.data, item.hiddenColumns, timezone, context, x, bodyY, w, bodyH);
|
|
15029
15806
|
break;
|
|
15807
|
+
case 'grid':
|
|
15808
|
+
this._renderGridTable(slide, item, context, x, bodyY, w, bodyH);
|
|
15809
|
+
break;
|
|
15030
15810
|
case 'kpi':
|
|
15031
15811
|
this._renderKpi(pptx, slide, item, timezone, context, x, bodyY, w, bodyH);
|
|
15032
15812
|
break;
|
|
@@ -15626,6 +16406,43 @@ class ReportPptxService {
|
|
|
15626
16406
|
valign: 'middle',
|
|
15627
16407
|
});
|
|
15628
16408
|
}
|
|
16409
|
+
// A table tile as a native table (gridExportTable()): its heading rows
|
|
16410
|
+
// spanned as the screen's header, as many lines as the box holds (groups
|
|
16411
|
+
// and totals bold, a group's label stepped in by its depth), the
|
|
16412
|
+
// moreRowsNotice row for the rest and the total row. A number is written
|
|
16413
|
+
// in the report's locale.
|
|
16414
|
+
_renderGridTable(slide, item, context, x, y, w, h) {
|
|
16415
|
+
const table = gridExportTable(item.table);
|
|
16416
|
+
const cell = (exported, options = {}) => ({
|
|
16417
|
+
text: `${GRID_INDENT.repeat(exported.indent)}${exported.text}`,
|
|
16418
|
+
options: pptxRunOptions({
|
|
16419
|
+
...(exported.colSpan > 1 ? { colspan: exported.colSpan } : {}),
|
|
16420
|
+
...(exported.rowSpan > 1 ? { rowspan: exported.rowSpan } : {}),
|
|
16421
|
+
...(exported.bold ? { bold: true } : {}),
|
|
16422
|
+
...(exported.numeric ? { align: 'right' } : {}),
|
|
16423
|
+
...options,
|
|
16424
|
+
}, exported.numeric ? context.locale : undefined),
|
|
16425
|
+
});
|
|
16426
|
+
const cap = Math.max(1, Math.floor(h / SLIDE_ROW_H) - table.head.length - (table.foot ? 1 : 0));
|
|
16427
|
+
const lines = table.body.slice(0, cap);
|
|
16428
|
+
const remaining = table.body.length - lines.length + table.moreRows;
|
|
16429
|
+
slide.addTable([
|
|
16430
|
+
...table.head.map((row) => row.map((heading) => cell(heading, { color: 'FFFFFF', fill: { color: '4E79A7' } }))),
|
|
16431
|
+
...lines.map((row) => row.map((line) => cell(line))),
|
|
16432
|
+
...(remaining > 0
|
|
16433
|
+
? [[{ text: moreRowsNotice(remaining), options: { colspan: table.numeric.length, italic: true, color: MUTED_COLOR } }]]
|
|
16434
|
+
: []),
|
|
16435
|
+
...(table.foot ? [table.foot.map((total) => cell(total, { bold: true, fill: { color: 'E4E7EB' } }))] : []),
|
|
16436
|
+
], {
|
|
16437
|
+
x, y, w,
|
|
16438
|
+
fontSize: 9,
|
|
16439
|
+
color: '1F2933',
|
|
16440
|
+
border: { type: 'solid', pt: 0.5, color: 'E4E7EB' },
|
|
16441
|
+
autoPage: false,
|
|
16442
|
+
rowH: SLIDE_ROW_H,
|
|
16443
|
+
valign: 'middle',
|
|
16444
|
+
});
|
|
16445
|
+
}
|
|
15629
16446
|
// A KPI tile: the card the screen draws (kpi-card.ts decides every line),
|
|
15630
16447
|
// as native text boxes and shapes stacked and centered in the body. A card
|
|
15631
16448
|
// with only its value and label keeps the tile's long-standing single text
|
|
@@ -16252,7 +17069,7 @@ class ReportComponent {
|
|
|
16252
17069
|
const filters = this._filterState.resolveForReport(report, pageId);
|
|
16253
17070
|
const frequency = this._filterState.frequency();
|
|
16254
17071
|
const snapshot = this._collector.snapshot(report);
|
|
16255
|
-
const states = zipExportStates(report, snapshot.state, pageId);
|
|
17072
|
+
const states = zipExportStates(report, snapshot.state, pageId, (component) => this._filterState.gridSessionLayout(component));
|
|
16256
17073
|
this._process.download('Exporting data', this._collector.exportWording(report, snapshot, pageId)
|
|
16257
17074
|
.pipe(switchMap(({ lines, titles }) => this._reportData.exportReport(report.id, filters, pageId, frequency, lines, states, titles))));
|
|
16258
17075
|
}
|
|
@@ -16441,7 +17258,7 @@ class ReportComponent {
|
|
|
16441
17258
|
ReportExportService,
|
|
16442
17259
|
// Tree-shaken ECharts core + the 'report' house theme, loaded lazily with
|
|
16443
17260
|
// this route's chunk.
|
|
16444
|
-
provideEchartsCore({ echarts: () => import('./firestitch-report-echarts-
|
|
17261
|
+
provideEchartsCore({ echarts: () => import('./firestitch-report-echarts-EecIloKo.mjs').then((module) => module.default) }),
|
|
16445
17262
|
// The bar's date pickers start their weeks on Monday and list the built-in
|
|
16446
17263
|
// presets; a preset only sets the days.
|
|
16447
17264
|
provideReportDatePicker(),
|
|
@@ -16462,7 +17279,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImpo
|
|
|
16462
17279
|
ReportExportService,
|
|
16463
17280
|
// Tree-shaken ECharts core + the 'report' house theme, loaded lazily with
|
|
16464
17281
|
// this route's chunk.
|
|
16465
|
-
provideEchartsCore({ echarts: () => import('./firestitch-report-echarts-
|
|
17282
|
+
provideEchartsCore({ echarts: () => import('./firestitch-report-echarts-EecIloKo.mjs').then((module) => module.default) }),
|
|
16466
17283
|
// The bar's date pickers start their weeks on Monday and list the built-in
|
|
16467
17284
|
// presets; a preset only sets the days.
|
|
16468
17285
|
provideReportDatePicker(),
|
|
@@ -17673,11 +18490,6 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImpo
|
|
|
17673
18490
|
type: Injectable
|
|
17674
18491
|
}] });
|
|
17675
18492
|
|
|
17676
|
-
// The parts of the chat's viewer state (ReportChatViewerState) read off a
|
|
17677
|
-
// snapshot of the screen (the state service's snapshot()). Pure, so the
|
|
17678
|
-
// node harness checks them. The caps are the server's: it reads at most
|
|
17679
|
-
// CHAT_DRILLS_MAX drilled charts, DRILL_MAX_DEPTH steps a path and
|
|
17680
|
-
// CHAT_LABEL_MAX characters a label, and drops the rest.
|
|
17681
18493
|
const CHAT_DRILLS_MAX = 10;
|
|
17682
18494
|
const CHAT_LABEL_MAX = 100;
|
|
17683
18495
|
// The match the chat is told of: 'any' while the groups match any filter.
|
|
@@ -17704,6 +18516,26 @@ function chatViewerDrills(snapshot, locale) {
|
|
|
17704
18516
|
})),
|
|
17705
18517
|
}));
|
|
17706
18518
|
}
|
|
18519
|
+
// Every table tile on screen while `pageId` is the page on screen: the
|
|
18520
|
+
// page's own, then the header and footer's unless the page hides them, each
|
|
18521
|
+
// with the layout the viewer has it in (layoutOf; null while it shows the
|
|
18522
|
+
// stored layout, and then left out). Empty when the page is unknown or holds
|
|
18523
|
+
// no table.
|
|
18524
|
+
function chatViewerTiles(report, pageId, layoutOf) {
|
|
18525
|
+
const page = (report?.pages ?? []).find((item) => item.id === pageId);
|
|
18526
|
+
if (!report || !page) {
|
|
18527
|
+
return [];
|
|
18528
|
+
}
|
|
18529
|
+
return [
|
|
18530
|
+
...(page.components ?? []),
|
|
18531
|
+
...(page.masterHidden ? [] : report.master?.components ?? []),
|
|
18532
|
+
]
|
|
18533
|
+
.filter((component) => component.type === 'grid')
|
|
18534
|
+
.map((component) => {
|
|
18535
|
+
const layout = layoutOf(component);
|
|
18536
|
+
return layout ? { componentId: component.id, layout } : { componentId: component.id };
|
|
18537
|
+
});
|
|
18538
|
+
}
|
|
17707
18539
|
|
|
17708
18540
|
// The groups a saved filter holds: the report-wide ones.
|
|
17709
18541
|
function savedFilterGroups(groups) {
|
|
@@ -18109,7 +18941,7 @@ class FsAiReportComponent {
|
|
|
18109
18941
|
const filters = this._filterState.resolveForReport(report, pageId);
|
|
18110
18942
|
const frequency = this._filterState.frequency();
|
|
18111
18943
|
const snapshot = this._collector.snapshot(report);
|
|
18112
|
-
const states = zipExportStates(report, snapshot.state, pageId);
|
|
18944
|
+
const states = zipExportStates(report, snapshot.state, pageId, (component) => this._filterState.gridSessionLayout(component));
|
|
18113
18945
|
this._process.download('Exporting data', this._collector.exportWording(report, snapshot, pageId)
|
|
18114
18946
|
.pipe(switchMap(({ lines, titles }) => this._reportData.exportReport(report.id, filters, pageId, frequency, lines, states, titles))));
|
|
18115
18947
|
}
|
|
@@ -18152,16 +18984,20 @@ class FsAiReportComponent {
|
|
|
18152
18984
|
// What the viewer has on screen as a chat turn sends it. The canvas
|
|
18153
18985
|
// selection counts only in edit mode: it is drawn only then, and it is kept
|
|
18154
18986
|
// (not cleared) when edit mode is left. The match is sent only while it is
|
|
18155
|
-
// 'any',
|
|
18987
|
+
// 'any', the drilled charts only when one is drilled, and the tables only
|
|
18988
|
+
// when the bound page shows one, each with the layout the viewer has it in
|
|
18989
|
+
// while that differs from the stored one.
|
|
18156
18990
|
_chatViewerState() {
|
|
18157
18991
|
const snapshot = this._filterState.snapshot();
|
|
18158
18992
|
const drills = chatViewerDrills(snapshot, this._filterState.formatContext().locale);
|
|
18993
|
+
const tiles = chatViewerTiles(this._filterState.report(), this.chatPageId(), (component) => this._filterState.gridSessionLayout(component));
|
|
18159
18994
|
return {
|
|
18160
18995
|
granularity: this._filterState.frequency(),
|
|
18161
18996
|
selectedComponentId: this.editMode() ? this._canvas?.selectedComponentId() ?? null : null,
|
|
18162
18997
|
filters: this._filterState.viewerFilters(),
|
|
18163
18998
|
...chatViewerMatch(snapshot),
|
|
18164
18999
|
...(drills.length ? { drills } : {}),
|
|
19000
|
+
...(tiles.length ? { tiles } : {}),
|
|
18165
19001
|
};
|
|
18166
19002
|
}
|
|
18167
19003
|
_resizeTo(clientX) {
|
|
@@ -18419,7 +19255,7 @@ class FsAiReportComponent {
|
|
|
18419
19255
|
ReportAiChatData,
|
|
18420
19256
|
{ provide: FsAiChatData, useExisting: ReportAiChatData },
|
|
18421
19257
|
// Tree-shaken ECharts core + the 'report' house theme, loaded lazily.
|
|
18422
|
-
provideEchartsCore({ echarts: () => import('./firestitch-report-echarts-
|
|
19258
|
+
provideEchartsCore({ echarts: () => import('./firestitch-report-echarts-EecIloKo.mjs').then((module) => module.default) }),
|
|
18423
19259
|
// The bar's date pickers start their weeks on Monday and list the built-in
|
|
18424
19260
|
// presets; a preset only sets the days.
|
|
18425
19261
|
provideReportDatePicker(),
|
|
@@ -18443,7 +19279,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImpo
|
|
|
18443
19279
|
ReportAiChatData,
|
|
18444
19280
|
{ provide: FsAiChatData, useExisting: ReportAiChatData },
|
|
18445
19281
|
// Tree-shaken ECharts core + the 'report' house theme, loaded lazily.
|
|
18446
|
-
provideEchartsCore({ echarts: () => import('./firestitch-report-echarts-
|
|
19282
|
+
provideEchartsCore({ echarts: () => import('./firestitch-report-echarts-EecIloKo.mjs').then((module) => module.default) }),
|
|
18447
19283
|
// The bar's date pickers start their weeks on Monday and list the built-in
|
|
18448
19284
|
// presets; a preset only sets the days.
|
|
18449
19285
|
provideReportDatePicker(),
|
|
@@ -18483,4 +19319,4 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImpo
|
|
|
18483
19319
|
*/
|
|
18484
19320
|
|
|
18485
19321
|
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 };
|
|
18486
|
-
//# sourceMappingURL=firestitch-report-firestitch-report-
|
|
19322
|
+
//# sourceMappingURL=firestitch-report-firestitch-report-DbdLxQSE.mjs.map
|