@meshmakers/octo-meshboard 3.5.490 → 3.5.510
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/README.md +163 -0
- package/fesm2022/meshmakers-octo-meshboard.mjs +2511 -257
- package/fesm2022/meshmakers-octo-meshboard.mjs.map +1 -1
- package/package.json +1 -1
- package/types/meshmakers-octo-meshboard.d.ts +1069 -338
|
@@ -1,20 +1,20 @@
|
|
|
1
1
|
import { FieldFilterOperatorsDto, CkTypeSelectorService, RuntimeEntitySelectDataSource, RuntimeEntityDialogDataSource, DeleteStrategiesDto, AssociationModOptionsDto, CkModelService, TENANT_ID_PROVIDER, QueryModeDto, GraphDirectionDto, AttributeSelectorService, GraphQL, GetCkTypeAvailableQueryColumnsDtoGQL, SortOrdersDto, SeriesResolutionSignalDto, HealthService, AssetRepoService, JobManagementService, CONFIGURATION_SERVICE, SystemCommunicationDeploymentStateDto, SystemCommunicationConfigurationStateDto, HealthStatus } from '@meshmakers/octo-services';
|
|
2
2
|
export { RuntimeEntityDialogDataSource, RuntimeEntitySelectDataSource, TENANT_ID_PROVIDER } from '@meshmakers/octo-services';
|
|
3
3
|
import * as i0 from '@angular/core';
|
|
4
|
-
import { Injectable, inject, EventEmitter, forwardRef, Output, Input, ViewChild, ChangeDetectionStrategy, Component, Injector, EnvironmentInjector, ApplicationRef, signal, computed, effect, untracked, Directive, InjectionToken, isSignal, NgZone, DestroyRef, ElementRef, input, createEnvironmentInjector, ViewContainerRef, makeEnvironmentProviders, provideAppInitializer, output, viewChild } from '@angular/core';
|
|
4
|
+
import { Injectable, inject, EventEmitter, forwardRef, Output, Input, ViewChild, ChangeDetectionStrategy, Component, Injector, EnvironmentInjector, ApplicationRef, signal, computed, effect, untracked, Directive, InjectionToken, isSignal, LOCALE_ID, NgZone, DestroyRef, ElementRef, input, createEnvironmentInjector, ViewContainerRef, makeEnvironmentProviders, provideAppInitializer, ChangeDetectorRef, ViewChildren, output, viewChild } from '@angular/core';
|
|
5
5
|
import * as i1$1 from '@angular/forms';
|
|
6
6
|
import { NG_VALUE_ACCESSOR, FormsModule } from '@angular/forms';
|
|
7
|
-
import { firstValueFrom, from, map, Observable, of, switchMap as switchMap$1, catchError, interval, filter, combineLatest, defer, EMPTY } from 'rxjs';
|
|
7
|
+
import { firstValueFrom, from, map, Observable, of, switchMap as switchMap$1, forkJoin, catchError, NEVER, interval, Subject, filter, combineLatest, defer, EMPTY } from 'rxjs';
|
|
8
8
|
import * as i1$8 from '@meshmakers/shared-ui';
|
|
9
|
-
import { EntitySelectInputComponent, WindowStateService, TimeRangeUtils, MmDialogActionsDirective, ListViewComponent, FetchResultTyped, DataSourceBase, MM_ACTION_ICONS,
|
|
9
|
+
import { EntitySelectInputComponent, WindowStateService, TimeRangeUtils, MmDialogActionsDirective, ListViewComponent, FetchResultTyped, DataSourceBase, MM_ACTION_ICONS, ConfirmationService, TimeRangePickerComponent, ImportStrategyDialogService, HAS_UNSAVED_CHANGES, UnsavedChangesDirective } from '@meshmakers/shared-ui';
|
|
10
10
|
import * as i1 from 'apollo-angular';
|
|
11
11
|
import { gql, Apollo } from 'apollo-angular';
|
|
12
12
|
import * as i1$3 from '@angular/common';
|
|
13
|
-
import { CommonModule, NgTemplateOutlet } from '@angular/common';
|
|
13
|
+
import { CommonModule, formatDate, formatNumber, NgTemplateOutlet } from '@angular/common';
|
|
14
14
|
import { CkTypeSelectorInputComponent, SecretSafeAttributeNamesService, AttributeValueTypeDto, PropertyValueDisplayComponent, FieldFilterEditorComponent, PropertyGridComponent, OctoGraphQlDataSource, AttributeSelectorDialogService, AttributeSortSelectorDialogService } from '@meshmakers/octo-ui';
|
|
15
15
|
import * as i1$5 from '@progress/kendo-angular-dialog';
|
|
16
16
|
import { WindowService, WindowCloseResult, WindowRef, DialogsModule, DialogRef, DialogModule, DialogService } from '@progress/kendo-angular-dialog';
|
|
17
|
-
import { switchMap, map as map$1, catchError as catchError$1, startWith, take, shareReplay, tap } from 'rxjs/operators';
|
|
17
|
+
import { switchMap, map as map$1, catchError as catchError$1, debounceTime, startWith, take, shareReplay, tap } from 'rxjs/operators';
|
|
18
18
|
import * as i2 from '@progress/kendo-angular-buttons';
|
|
19
19
|
import { ButtonsModule, ButtonModule } from '@progress/kendo-angular-buttons';
|
|
20
20
|
import * as i3 from '@progress/kendo-angular-inputs';
|
|
@@ -39,6 +39,7 @@ import { LabelModule, KENDO_LABELS } from '@progress/kendo-angular-label';
|
|
|
39
39
|
import { DomSanitizer } from '@angular/platform-browser';
|
|
40
40
|
import * as i5 from '@progress/kendo-angular-layout';
|
|
41
41
|
import { LayoutModule, TabStripModule, TileLayoutModule, TileLayoutComponent } from '@progress/kendo-angular-layout';
|
|
42
|
+
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
|
42
43
|
import * as i4$2 from '@progress/kendo-angular-dateinputs';
|
|
43
44
|
import { DatePickerModule, DateTimePickerModule } from '@progress/kendo-angular-dateinputs';
|
|
44
45
|
import { BreadCrumbService } from '@meshmakers/shared-services';
|
|
@@ -58,6 +59,8 @@ function isCockpitWidgetType(type) {
|
|
|
58
59
|
}
|
|
59
60
|
/** The board-level default timezone mode applied when none is persisted. */
|
|
60
61
|
const DEFAULT_TIME_ZONE_MODE = 'local';
|
|
62
|
+
/** Maximum number of hops of a child-scope path, enforced by editor and runtime (AB#7723). */
|
|
63
|
+
const MAX_SCOPE_HOPS = 5;
|
|
61
64
|
|
|
62
65
|
// Re-export from octo-services (moved there for reuse by octo-ui)
|
|
63
66
|
|
|
@@ -492,6 +495,24 @@ function queryFamily(queryCkTypeId) {
|
|
|
492
495
|
return classifyQuery(queryCkTypeId)?.family ?? null;
|
|
493
496
|
}
|
|
494
497
|
|
|
498
|
+
/**
|
|
499
|
+
* The result a widget renders instead of executing its query when its scope is
|
|
500
|
+
* `empty` (AB#7721). Shape-compatible with a real empty response, so each
|
|
501
|
+
* widget's existing no-data rendering applies unchanged.
|
|
502
|
+
*/
|
|
503
|
+
function emptyQueryExecutionResult(family) {
|
|
504
|
+
return {
|
|
505
|
+
family: family ?? 'streamData',
|
|
506
|
+
queryRtId: null,
|
|
507
|
+
associatedCkTypeId: null,
|
|
508
|
+
columns: [],
|
|
509
|
+
rows: [],
|
|
510
|
+
totalCount: 0,
|
|
511
|
+
hasNextPage: false,
|
|
512
|
+
endCursor: null
|
|
513
|
+
};
|
|
514
|
+
}
|
|
515
|
+
|
|
495
516
|
const GetSystemPersistentQueriesDocumentDto = gql `
|
|
496
517
|
query getSystemPersistentQueries($after: String, $first: Int, $searchFilter: SearchFilter, $fieldFilters: [FieldFilter], $sort: [Sort]) {
|
|
497
518
|
runtime {
|
|
@@ -2181,7 +2202,17 @@ class MeshBoardPersistenceService {
|
|
|
2181
2202
|
attributeMappings: es.attributeMappings,
|
|
2182
2203
|
showInToolbar: es.showInToolbar,
|
|
2183
2204
|
defaultRtId: es.defaultRtId,
|
|
2184
|
-
|
|
2205
|
+
// Carried verbatim: the one-hop form and the multi-hop `path`
|
|
2206
|
+
// (AB#7723) keep their keys and order, so older boards encode unchanged.
|
|
2207
|
+
childScope: es.childScope,
|
|
2208
|
+
// AB#7722 — omitted (undefined → dropped by JSON.stringify) for
|
|
2209
|
+
// selectors that follow nobody, so older boards encode unchanged.
|
|
2210
|
+
followsSelectorId: es.followsSelectorId || undefined,
|
|
2211
|
+
// AB#7724 — omitted when empty, so older boards encode unchanged;
|
|
2212
|
+
// rows are reduced to the persisted filter fields (no editor state).
|
|
2213
|
+
filters: es.filters?.length
|
|
2214
|
+
? es.filters.map(f => ({ attributePath: f.attributePath, operator: f.operator, comparisonValue: f.comparisonValue }))
|
|
2215
|
+
: undefined
|
|
2185
2216
|
}));
|
|
2186
2217
|
}
|
|
2187
2218
|
const dataJson = JSON.stringify(data);
|
|
@@ -2425,13 +2456,22 @@ class MeshBoardStateService {
|
|
|
2425
2456
|
_variableResolutionErrors = signal([], /* @ts-ignore */
|
|
2426
2457
|
...(ngDevMode ? [{ debugName: "_variableResolutionErrors" }] : /* istanbul ignore next */ []));
|
|
2427
2458
|
/**
|
|
2428
|
-
* Transient
|
|
2429
|
-
*
|
|
2430
|
-
*
|
|
2431
|
-
*
|
|
2459
|
+
* Transient scope state of each entity selector's current selection (keyed
|
|
2460
|
+
* by selector id): `pending` while the view resolves the pick, `resolved`
|
|
2461
|
+
* with the source rtIds (childScope hop or path, or the picked entity's
|
|
2462
|
+
* own rtId; possibly none), or `failed`. No entry = no active pick. Not
|
|
2463
|
+
* persisted — derived fresh from each selection, cleared on board switch
|
|
2464
|
+
* (AB#7721).
|
|
2465
|
+
*/
|
|
2466
|
+
_entitySelectorScopes = signal({}, /* @ts-ignore */
|
|
2467
|
+
...(ngDevMode ? [{ debugName: "_entitySelectorScopes" }] : /* istanbul ignore next */ []));
|
|
2468
|
+
/**
|
|
2469
|
+
* Period set by the page that embeds the board (`MeshBoardViewComponent.timeRange`, AB#7582):
|
|
2470
|
+
* exact instants, or `invalid` when the host passed an unusable range. `null` = the board's own
|
|
2471
|
+
* selection applies. Transient — never persisted, never written into the board config.
|
|
2432
2472
|
*/
|
|
2433
|
-
|
|
2434
|
-
...(ngDevMode ? [{ debugName: "
|
|
2473
|
+
_hostTimeRange = signal(null, /* @ts-ignore */
|
|
2474
|
+
...(ngDevMode ? [{ debugName: "_hostTimeRange" }] : /* istanbul ignore next */ []));
|
|
2435
2475
|
/**
|
|
2436
2476
|
* Transient variables published by widgets (KPI output variables, AB#5364).
|
|
2437
2477
|
* Kept apart from `_meshBoardConfig` so publishing triggers no persistence and
|
|
@@ -2565,6 +2605,7 @@ class MeshBoardStateService {
|
|
|
2565
2605
|
console.warn(`Resolved ${movedWidgets.length} overlapping widget(s)`);
|
|
2566
2606
|
}
|
|
2567
2607
|
this._widgetVariables.set([]);
|
|
2608
|
+
this._entitySelectorScopes.set({});
|
|
2568
2609
|
this._hiddenForViewer.set(new Set());
|
|
2569
2610
|
this._widgetContentHeights.set(new Map());
|
|
2570
2611
|
this._loadedTenantId.set(tenantId);
|
|
@@ -3044,10 +3085,22 @@ class MeshBoardStateService {
|
|
|
3044
3085
|
* Boundaries are resolved on the board's {@link timeZoneMode} basis (local by
|
|
3045
3086
|
* default, or UTC), the same basis widgets format their datetime display on —
|
|
3046
3087
|
* so the selected range and the displayed timestamps stay consistent.
|
|
3088
|
+
*
|
|
3089
|
+
* While an embedding host has set a period ({@link setHostTimeRange}, AB#7582)
|
|
3090
|
+
* and the board's time filter is enabled, the host's instants are returned
|
|
3091
|
+
* unchanged (no day snapping, no zone conversion) and the stored selection is
|
|
3092
|
+
* ignored; an invalid host period resolves to `null`.
|
|
3047
3093
|
*/
|
|
3048
3094
|
resolveCurrentTimeRange() {
|
|
3049
3095
|
const config = this._meshBoardConfig().timeFilter;
|
|
3050
|
-
if (!config?.enabled
|
|
3096
|
+
if (!config?.enabled) {
|
|
3097
|
+
return null;
|
|
3098
|
+
}
|
|
3099
|
+
const host = this._hostTimeRange();
|
|
3100
|
+
if (host) {
|
|
3101
|
+
return host.status === 'valid' ? { from: new Date(host.from), to: new Date(host.to) } : null;
|
|
3102
|
+
}
|
|
3103
|
+
if (!config.selection) {
|
|
3051
3104
|
return null;
|
|
3052
3105
|
}
|
|
3053
3106
|
const showTime = config.pickerConfig?.showTime ?? false;
|
|
@@ -3104,21 +3157,41 @@ class MeshBoardStateService {
|
|
|
3104
3157
|
}
|
|
3105
3158
|
return mode;
|
|
3106
3159
|
}
|
|
3160
|
+
/**
|
|
3161
|
+
* Marks an entity selector's scope as being resolved (AB#7721). Bound
|
|
3162
|
+
* stream-data widgets show their loading state and send no query until the
|
|
3163
|
+
* scope is resolved, failed or cleared — so they never flash rows of the
|
|
3164
|
+
* query's persisted (unscoped) scope while a pick is resolving.
|
|
3165
|
+
*/
|
|
3166
|
+
setEntitySelectorScopePending(selectorId) {
|
|
3167
|
+
this.setEntitySelectorScope(selectorId, { status: 'pending' });
|
|
3168
|
+
}
|
|
3107
3169
|
/**
|
|
3108
3170
|
* Stores the source rtIds resolved from an entity selector's current
|
|
3109
3171
|
* selection. Called by the view component after a selection resolves (the
|
|
3110
|
-
* one
|
|
3111
|
-
* selector has no childScope).
|
|
3172
|
+
* childScope traversal (one hop or a path), or the picked entity's own rtId when the
|
|
3173
|
+
* selector has no childScope). An empty array is a resolved EMPTY scope:
|
|
3174
|
+
* bound widgets show no data and send no query (AB#7721). Transient — not
|
|
3175
|
+
* persisted.
|
|
3112
3176
|
*/
|
|
3113
3177
|
setEntitySelectorRtIds(selectorId, rtIds) {
|
|
3114
|
-
this.
|
|
3178
|
+
this.setEntitySelectorScope(selectorId, { status: 'resolved', rtIds: [...rtIds] });
|
|
3179
|
+
}
|
|
3180
|
+
/**
|
|
3181
|
+
* Marks an entity selector's scope resolution as failed (e.g. the association
|
|
3182
|
+
* fetch threw). Bound widgets treat it like an empty scope — no data, no
|
|
3183
|
+
* query — and the view tells the user the scope could not be resolved
|
|
3184
|
+
* (AB#7721).
|
|
3185
|
+
*/
|
|
3186
|
+
setEntitySelectorScopeFailed(selectorId) {
|
|
3187
|
+
this.setEntitySelectorScope(selectorId, { status: 'failed' });
|
|
3115
3188
|
}
|
|
3116
3189
|
/**
|
|
3117
|
-
* Clears the
|
|
3118
|
-
*
|
|
3190
|
+
* Clears the scope state for an entity selector (its selection was cleared).
|
|
3191
|
+
* Bound widgets then query with the persisted scope again.
|
|
3119
3192
|
*/
|
|
3120
3193
|
clearEntitySelectorRtIds(selectorId) {
|
|
3121
|
-
this.
|
|
3194
|
+
this._entitySelectorScopes.update(map => {
|
|
3122
3195
|
if (!(selectorId in map)) {
|
|
3123
3196
|
return map;
|
|
3124
3197
|
}
|
|
@@ -3127,18 +3200,117 @@ class MeshBoardStateService {
|
|
|
3127
3200
|
return next;
|
|
3128
3201
|
});
|
|
3129
3202
|
}
|
|
3203
|
+
/** The raw scope state of a selector, or `undefined` when it has no active pick. */
|
|
3204
|
+
getEntitySelectorScopeState(selectorId) {
|
|
3205
|
+
return this._entitySelectorScopes()[selectorId];
|
|
3206
|
+
}
|
|
3130
3207
|
/**
|
|
3131
|
-
*
|
|
3132
|
-
*
|
|
3133
|
-
* current selection, or `undefined` when the selector id is absent, unknown,
|
|
3134
|
-
* or has no active selection (so the query keeps its persisted scope).
|
|
3208
|
+
* Ids of the selectors whose scope resolution failed (for the view's
|
|
3209
|
+
* "could not resolve the scope" notice, AB#7721).
|
|
3135
3210
|
*/
|
|
3136
|
-
|
|
3211
|
+
failedEntitySelectorScopeIds = computed(() => Object.entries(this._entitySelectorScopes())
|
|
3212
|
+
.filter(([, state]) => state.status === 'failed')
|
|
3213
|
+
.map(([id]) => id), /* @ts-ignore */
|
|
3214
|
+
...(ngDevMode ? [{ debugName: "failedEntitySelectorScopeIds" }] : /* istanbul ignore next */ []));
|
|
3215
|
+
/**
|
|
3216
|
+
* Decides how a widget runs its persistent query — the entry point every
|
|
3217
|
+
* built-in widget calls with its binding, query family and time-filter opt-out:
|
|
3218
|
+
*
|
|
3219
|
+
* 1. Host gate (AB#7582): while the embedding host's period is invalid, every
|
|
3220
|
+
* stream-data query bound to the time filter is `empty` (`invalidTimeRange`),
|
|
3221
|
+
* whatever its selector binding; pass the widget's `ignoreTimeFilter` so an
|
|
3222
|
+
* opted-out widget, whose data does not depend on the period, keeps querying.
|
|
3223
|
+
* Runtime queries have no time window and are not gated. A legacy config
|
|
3224
|
+
* without `queryFamily` is treated as stream data here.
|
|
3225
|
+
* 2. Entity-selector scope ({@link resolveEntitySelectorScope}, AB#7721) — since
|
|
3226
|
+
* AB#7725 for runtime queries too: `unscoped` / `pending` / `scoped` / `empty`.
|
|
3227
|
+
*/
|
|
3228
|
+
resolveStreamDataScope(entitySelectorId, family, ignoreTimeFilter) {
|
|
3229
|
+
if (family !== 'runtime' && this.isStreamDataBlockedByHostTimeRange(ignoreTimeFilter)) {
|
|
3230
|
+
return { kind: 'empty', reason: 'invalidTimeRange' };
|
|
3231
|
+
}
|
|
3232
|
+
return this.resolveEntitySelectorScope(entitySelectorId);
|
|
3233
|
+
}
|
|
3234
|
+
/**
|
|
3235
|
+
* The entity-selector part of {@link resolveStreamDataScope}, without the host
|
|
3236
|
+
* gate — stream data (AB#7721) and runtime queries alike (AB#7725):
|
|
3237
|
+
*
|
|
3238
|
+
* - `unscoped` — no binding, or no active pick: keep the query as saved.
|
|
3239
|
+
* - `pending` — the pick is still resolving: show loading, send no query.
|
|
3240
|
+
* - `scoped` — query restricted to the resolved rtIds: stream data via
|
|
3241
|
+
* `streamDataArgs.rtIds`, runtime queries via the `rtId IN` field filter
|
|
3242
|
+
* (`QueryExecutionOptions.scopeRtIds`).
|
|
3243
|
+
* - `empty` — the pick has nothing in scope or the lookup failed: show the
|
|
3244
|
+
* no-data state and send no query. Never fall back to the persisted scope.
|
|
3245
|
+
*/
|
|
3246
|
+
resolveEntitySelectorScope(entitySelectorId) {
|
|
3137
3247
|
if (!entitySelectorId) {
|
|
3138
|
-
return
|
|
3248
|
+
return { kind: 'unscoped' };
|
|
3249
|
+
}
|
|
3250
|
+
const state = this._entitySelectorScopes()[entitySelectorId];
|
|
3251
|
+
if (!state) {
|
|
3252
|
+
return { kind: 'unscoped' };
|
|
3253
|
+
}
|
|
3254
|
+
switch (state.status) {
|
|
3255
|
+
case 'pending':
|
|
3256
|
+
return { kind: 'pending' };
|
|
3257
|
+
case 'failed':
|
|
3258
|
+
return { kind: 'empty', reason: 'failed' };
|
|
3259
|
+
case 'resolved':
|
|
3260
|
+
return state.rtIds.length > 0
|
|
3261
|
+
? { kind: 'scoped', rtIds: state.rtIds }
|
|
3262
|
+
: { kind: 'empty', reason: 'noTargets' };
|
|
3263
|
+
}
|
|
3264
|
+
}
|
|
3265
|
+
/**
|
|
3266
|
+
* Resolves the stream-data `rtIds` for a persistent-query widget bound to an
|
|
3267
|
+
* entity selector: the resolved source rtIds when the scope is `scoped`,
|
|
3268
|
+
* otherwise `undefined`.
|
|
3269
|
+
*
|
|
3270
|
+
* `undefined` does NOT mean "query with the persisted scope" for a pending or
|
|
3271
|
+
* empty scope — callers must check {@link resolveStreamDataScope} first and
|
|
3272
|
+
* skip the query for `pending` / `empty` (AB#7721).
|
|
3273
|
+
*/
|
|
3274
|
+
resolveStreamDataRtIds(entitySelectorId) {
|
|
3275
|
+
const scope = this.resolveEntitySelectorScope(entitySelectorId);
|
|
3276
|
+
return scope.kind === 'scoped' ? scope.rtIds : undefined;
|
|
3277
|
+
}
|
|
3278
|
+
setEntitySelectorScope(selectorId, state) {
|
|
3279
|
+
this._entitySelectorScopes.update(map => ({ ...map, [selectorId]: state }));
|
|
3280
|
+
}
|
|
3281
|
+
/**
|
|
3282
|
+
* Sets (or with `null` clears) the period of the page embedding the board
|
|
3283
|
+
* (AB#7582). Called by `MeshBoardViewComponent` for its `timeRange` input; a
|
|
3284
|
+
* host normally sets the input instead of calling this. Transient: not
|
|
3285
|
+
* persisted, not written into the board config, so it never causes unsaved
|
|
3286
|
+
* changes. Only takes effect while the board's time filter is enabled.
|
|
3287
|
+
*/
|
|
3288
|
+
setHostTimeRange(state, owner) {
|
|
3289
|
+
this._hostTimeRange.set(state);
|
|
3290
|
+
this.hostTimeRangeOwner = state ? owner ?? null : null;
|
|
3291
|
+
}
|
|
3292
|
+
/**
|
|
3293
|
+
* Clears the host period only if `owner` set it (AB#7582). The service is
|
|
3294
|
+
* root-provided, so a view that is destroyed (or drops its `timeRange`) must not
|
|
3295
|
+
* clear a period another view set in the meantime.
|
|
3296
|
+
*/
|
|
3297
|
+
clearHostTimeRange(owner) {
|
|
3298
|
+
if (this.hostTimeRangeOwner === owner) {
|
|
3299
|
+
this.setHostTimeRange(null);
|
|
3139
3300
|
}
|
|
3140
|
-
|
|
3141
|
-
|
|
3301
|
+
}
|
|
3302
|
+
/** The view (or other caller) that set the current host period, if it said so. */
|
|
3303
|
+
hostTimeRangeOwner = null;
|
|
3304
|
+
/** The period set by the embedding host, or `null` when the board's own selection applies. */
|
|
3305
|
+
hostTimeRange = this._hostTimeRange.asReadonly();
|
|
3306
|
+
/**
|
|
3307
|
+
* True when an invalid host period blocks a stream-data query that is bound to
|
|
3308
|
+
* the time filter (AB#7582): the period is invalid, the board's time filter is
|
|
3309
|
+
* enabled and the caller did not opt out with `ignoreTimeFilter`. Such a query
|
|
3310
|
+
* must not be sent — without a window it would read the whole archive.
|
|
3311
|
+
*/
|
|
3312
|
+
isStreamDataBlockedByHostTimeRange(ignoreTimeFilter) {
|
|
3313
|
+
return !ignoreTimeFilter && this._hostTimeRange()?.status === 'invalid' && this.isTimeFilterEnabled();
|
|
3142
3314
|
}
|
|
3143
3315
|
/**
|
|
3144
3316
|
* Updates the time filter configuration.
|
|
@@ -3656,7 +3828,7 @@ class QueryExecutorService {
|
|
|
3656
3828
|
rtId: queryRtId,
|
|
3657
3829
|
first: options.first ?? undefined,
|
|
3658
3830
|
after: options.after ?? undefined,
|
|
3659
|
-
fieldFilter: options.fieldFilter
|
|
3831
|
+
fieldFilter: QueryExecutorService.withRtIdScope(options.fieldFilter, options.scopeRtIds)
|
|
3660
3832
|
},
|
|
3661
3833
|
fetchPolicy: options.forceRefresh ? 'network-only' : 'cache-first'
|
|
3662
3834
|
}).pipe(map$1(result => {
|
|
@@ -3677,7 +3849,8 @@ class QueryExecutorService {
|
|
|
3677
3849
|
}));
|
|
3678
3850
|
}
|
|
3679
3851
|
executeStreamData(queryRtId, options = {}) {
|
|
3680
|
-
const
|
|
3852
|
+
const streamDataArgs = QueryExecutorService.withStreamDataScope(options.streamDataArgs, options.scopeRtIds);
|
|
3853
|
+
const arg = streamDataArgs ? this.buildStreamDataArg(streamDataArgs) : undefined;
|
|
3681
3854
|
return this.streamDataGql.fetch({
|
|
3682
3855
|
variables: {
|
|
3683
3856
|
rtId: queryRtId,
|
|
@@ -3854,6 +4027,29 @@ class QueryExecutorService {
|
|
|
3854
4027
|
}
|
|
3855
4028
|
return groups;
|
|
3856
4029
|
}
|
|
4030
|
+
/**
|
|
4031
|
+
* Runtime entity-selector scope (AB#7725): appends `rtId IN (scopeRtIds)` to the
|
|
4032
|
+
* caller's field filters. The backend AND-combines all runtime field filters with
|
|
4033
|
+
* the persisted query's filters before the database query, so simple rows as well
|
|
4034
|
+
* as aggregation / grouping results cover the scoped entities only. Returns the
|
|
4035
|
+
* caller's filters unchanged (or `undefined`) when there is no scope.
|
|
4036
|
+
*/
|
|
4037
|
+
static withRtIdScope(fieldFilter, scopeRtIds) {
|
|
4038
|
+
if (!scopeRtIds || scopeRtIds.length === 0) {
|
|
4039
|
+
return fieldFilter ?? undefined;
|
|
4040
|
+
}
|
|
4041
|
+
return [
|
|
4042
|
+
...(fieldFilter ?? []),
|
|
4043
|
+
{ attributePath: 'rtId', operator: FieldFilterOperatorsDto.InDto, comparisonValue: scopeRtIds }
|
|
4044
|
+
];
|
|
4045
|
+
}
|
|
4046
|
+
/** Stream-data entity-selector scope: `scopeRtIds` fill `rtIds` unless the caller set them. */
|
|
4047
|
+
static withStreamDataScope(args, scopeRtIds) {
|
|
4048
|
+
if (!scopeRtIds || scopeRtIds.length === 0 || (args?.rtIds && args.rtIds.length > 0)) {
|
|
4049
|
+
return args;
|
|
4050
|
+
}
|
|
4051
|
+
return { ...args, rtIds: scopeRtIds };
|
|
4052
|
+
}
|
|
3857
4053
|
buildStreamDataArg(args) {
|
|
3858
4054
|
// Skip the entire `arg` field when the caller has nothing to override —
|
|
3859
4055
|
// the persisted query then runs with its intrinsic from/to/limit and the
|
|
@@ -3995,6 +4191,45 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.5", ngImpor
|
|
|
3995
4191
|
}]
|
|
3996
4192
|
}], ctorParameters: () => [{ type: i1.Apollo }] });
|
|
3997
4193
|
|
|
4194
|
+
const GetAssociationTargetRtIdsDocumentDto = gql `
|
|
4195
|
+
query getAssociationTargetRtIds($rtIds: [OctoObjectId]!, $ckTypeId: String!, $targetCkTypeId: String!, $roleId: String!, $direction: GraphDirection!, $first: Int!, $targetsFirst: Int!) {
|
|
4196
|
+
runtime {
|
|
4197
|
+
runtimeEntities(rtIds: $rtIds, ckId: $ckTypeId, first: $first) {
|
|
4198
|
+
items {
|
|
4199
|
+
rtId
|
|
4200
|
+
associations {
|
|
4201
|
+
targets(
|
|
4202
|
+
ckId: $targetCkTypeId
|
|
4203
|
+
roleId: $roleId
|
|
4204
|
+
direction: $direction
|
|
4205
|
+
first: $targetsFirst
|
|
4206
|
+
) {
|
|
4207
|
+
totalCount
|
|
4208
|
+
items {
|
|
4209
|
+
rtId
|
|
4210
|
+
}
|
|
4211
|
+
}
|
|
4212
|
+
}
|
|
4213
|
+
}
|
|
4214
|
+
}
|
|
4215
|
+
}
|
|
4216
|
+
}
|
|
4217
|
+
`;
|
|
4218
|
+
class GetAssociationTargetRtIdsDtoGQL extends i1.Query {
|
|
4219
|
+
document = GetAssociationTargetRtIdsDocumentDto;
|
|
4220
|
+
constructor(apollo) {
|
|
4221
|
+
super(apollo);
|
|
4222
|
+
}
|
|
4223
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.5", ngImport: i0, type: GetAssociationTargetRtIdsDtoGQL, deps: [{ token: i1.Apollo }], target: i0.ɵɵFactoryTarget.Injectable });
|
|
4224
|
+
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.1.5", ngImport: i0, type: GetAssociationTargetRtIdsDtoGQL, providedIn: 'root' });
|
|
4225
|
+
}
|
|
4226
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.5", ngImport: i0, type: GetAssociationTargetRtIdsDtoGQL, decorators: [{
|
|
4227
|
+
type: Injectable,
|
|
4228
|
+
args: [{
|
|
4229
|
+
providedIn: 'root'
|
|
4230
|
+
}]
|
|
4231
|
+
}], ctorParameters: () => [{ type: i1.Apollo }] });
|
|
4232
|
+
|
|
3998
4233
|
const GetCkTypeAttributesForMeshboardDocumentDto = gql `
|
|
3999
4234
|
query getCkTypeAttributesForMeshboard($ckTypeId: String!, $first: Int) {
|
|
4000
4235
|
constructionKit {
|
|
@@ -4217,12 +4452,17 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.5", ngImpor
|
|
|
4217
4452
|
}]
|
|
4218
4453
|
}] });
|
|
4219
4454
|
|
|
4455
|
+
/** Source rtIds per request of one batched association hop (AB#7723). */
|
|
4456
|
+
const ASSOCIATION_HOP_SOURCE_BATCH = 100;
|
|
4457
|
+
/** Targets read per source entity in one association hop (AB#7723). */
|
|
4458
|
+
const ASSOCIATION_HOP_TARGETS_PER_SOURCE = 1000;
|
|
4220
4459
|
class MeshBoardDataService {
|
|
4221
4460
|
getDashboardEntityGQL = inject(GetDashboardEntityDtoGQL);
|
|
4222
4461
|
getCkModelsWithStateGQL = inject(GetCkModelsWithStateDtoGQL);
|
|
4223
4462
|
getEntitiesByCkTypeGQL = inject(GetEntitiesByCkTypeDtoGQL);
|
|
4224
4463
|
queryExecutor = inject(QueryExecutorService);
|
|
4225
4464
|
getAssociationTargetsGQL = inject(GetAssociationTargetsDtoGQL);
|
|
4465
|
+
getAssociationTargetRtIdsGQL = inject(GetAssociationTargetRtIdsDtoGQL);
|
|
4226
4466
|
secretSafeNames = inject(SecretSafeAttributeNamesService);
|
|
4227
4467
|
getCkTypeAttributesGQL = inject(GetCkTypeAttributesForMeshboardDtoGQL);
|
|
4228
4468
|
apollo = inject(Apollo);
|
|
@@ -4461,6 +4701,13 @@ class MeshBoardDataService {
|
|
|
4461
4701
|
*/
|
|
4462
4702
|
async fetchRepeaterFromQuery(family, queryRtId, maxItems) {
|
|
4463
4703
|
try {
|
|
4704
|
+
// AB#7582: while the embedding host's period is invalid, a stream-data
|
|
4705
|
+
// repeater query is not sent (it would read the whole archive); the group
|
|
4706
|
+
// shows no items. Runtime queries are unaffected.
|
|
4707
|
+
if (this.stateService.isStreamDataBlockedByHostTimeRange()
|
|
4708
|
+
&& (family ?? await this.queryExecutor.resolveFamily(queryRtId)) === 'streamData') {
|
|
4709
|
+
return [];
|
|
4710
|
+
}
|
|
4464
4711
|
const streamDataArgs = this.buildRepeaterStreamDataArgs();
|
|
4465
4712
|
const result = await firstValueFrom(this.queryExecutor.execute(family, queryRtId, {
|
|
4466
4713
|
first: maxItems,
|
|
@@ -4585,6 +4832,56 @@ class MeshBoardDataService {
|
|
|
4585
4832
|
}));
|
|
4586
4833
|
}));
|
|
4587
4834
|
}
|
|
4835
|
+
/**
|
|
4836
|
+
* One association hop for MANY source entities (AB#7723, multi-hop child
|
|
4837
|
+
* scope): the rtIds of the `targetCkTypeId` entities reached from each of
|
|
4838
|
+
* `sourceRtIds` (all of `sourceCkTypeId`) over `roleId` in `direction`.
|
|
4839
|
+
*
|
|
4840
|
+
* Query cost: the sources go out in batches of
|
|
4841
|
+
* {@link ASSOCIATION_HOP_SOURCE_BATCH} rtIds per request (`runtimeEntities(rtIds:)`
|
|
4842
|
+
* with the association targets nested), the batches of one hop in parallel —
|
|
4843
|
+
* never one request per entity. Only rtIds are selected. Per source at most
|
|
4844
|
+
* {@link ASSOCIATION_HOP_TARGETS_PER_SOURCE} targets are read; a truncated
|
|
4845
|
+
* source is logged as a warning. Duplicates are not removed here.
|
|
4846
|
+
* Errors propagate (the caller turns them into a failed scope).
|
|
4847
|
+
*/
|
|
4848
|
+
fetchAssociationTargetRtIds(sourceRtIds, sourceCkTypeId, targetCkTypeId, roleId, direction) {
|
|
4849
|
+
if (sourceRtIds.length === 0) {
|
|
4850
|
+
return of([]);
|
|
4851
|
+
}
|
|
4852
|
+
const graphDirection = direction === 'out' ? GraphDirectionDto.OutboundDto : GraphDirectionDto.InboundDto;
|
|
4853
|
+
const batches = [];
|
|
4854
|
+
for (let i = 0; i < sourceRtIds.length; i += ASSOCIATION_HOP_SOURCE_BATCH) {
|
|
4855
|
+
batches.push(sourceRtIds.slice(i, i + ASSOCIATION_HOP_SOURCE_BATCH));
|
|
4856
|
+
}
|
|
4857
|
+
return forkJoin(batches.map(batch => this.getAssociationTargetRtIdsGQL.fetch({
|
|
4858
|
+
variables: {
|
|
4859
|
+
rtIds: batch,
|
|
4860
|
+
ckTypeId: sourceCkTypeId,
|
|
4861
|
+
targetCkTypeId,
|
|
4862
|
+
roleId,
|
|
4863
|
+
direction: graphDirection,
|
|
4864
|
+
first: batch.length,
|
|
4865
|
+
targetsFirst: ASSOCIATION_HOP_TARGETS_PER_SOURCE
|
|
4866
|
+
}
|
|
4867
|
+
}).pipe(map(result => {
|
|
4868
|
+
const rtIds = [];
|
|
4869
|
+
for (const source of result.data?.runtime?.runtimeEntities?.items ?? []) {
|
|
4870
|
+
const targets = source?.associations?.targets;
|
|
4871
|
+
const items = targets?.items ?? [];
|
|
4872
|
+
if ((targets?.totalCount ?? 0) > items.length) {
|
|
4873
|
+
console.warn(`Child scope hop ${roleId} → ${targetCkTypeId}: source ${String(source?.rtId)} has `
|
|
4874
|
+
+ `${targets?.totalCount} targets, only the first ${items.length} are used.`);
|
|
4875
|
+
}
|
|
4876
|
+
for (const target of items) {
|
|
4877
|
+
if (target?.rtId !== null && target?.rtId !== undefined) {
|
|
4878
|
+
rtIds.push(String(target.rtId));
|
|
4879
|
+
}
|
|
4880
|
+
}
|
|
4881
|
+
}
|
|
4882
|
+
return rtIds;
|
|
4883
|
+
})))).pipe(map(perBatch => perBatch.flat()));
|
|
4884
|
+
}
|
|
4588
4885
|
/**
|
|
4589
4886
|
* Fetches CK type attribute definitions (name + type) for config dialog.
|
|
4590
4887
|
*/
|
|
@@ -6520,10 +6817,24 @@ class KpiWidgetComponent {
|
|
|
6520
6817
|
// queryFamily may be undefined for legacy widget configs — the executor
|
|
6521
6818
|
// falls back to a one-time lookup by rtId. streamDataArgs is sent
|
|
6522
6819
|
// unconditionally because the runtime path ignores it.
|
|
6820
|
+
// Entity-selector scope (AB#7721): pending → stay in the loading state and
|
|
6821
|
+
// send no query (the view refreshes once the scope is known); empty → show
|
|
6822
|
+
// the no-data value '-' without querying the persisted (unscoped) scope.
|
|
6823
|
+
const scope = this.stateService.resolveStreamDataScope(dataSource.entitySelectorId, dataSource.queryFamily, dataSource.ignoreTimeFilter);
|
|
6824
|
+
if (scope.kind === 'pending') {
|
|
6825
|
+
return;
|
|
6826
|
+
}
|
|
6827
|
+
if (scope.kind === 'empty') {
|
|
6828
|
+
this._data.set(null);
|
|
6829
|
+
this._isLoading.set(false);
|
|
6830
|
+
return;
|
|
6831
|
+
}
|
|
6523
6832
|
const streamDataArgs = this.buildStreamDataArgs();
|
|
6524
6833
|
const result = await firstValueFrom(this.queryExecutor.execute(dataSource.queryFamily, dataSource.queryRtId, {
|
|
6525
6834
|
fieldFilter: fieldFilter ?? undefined,
|
|
6526
|
-
streamDataArgs
|
|
6835
|
+
streamDataArgs,
|
|
6836
|
+
// Runtime queries: `rtId IN` field filter (AB#7725); stream data: arg.rtIds.
|
|
6837
|
+
scopeRtIds: scope.kind === 'scoped' ? scope.rtIds : undefined
|
|
6527
6838
|
}).pipe(catchError(err => {
|
|
6528
6839
|
console.error('Error loading KPI query data:', err);
|
|
6529
6840
|
throw err;
|
|
@@ -6763,19 +7074,24 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.5", ngImpor
|
|
|
6763
7074
|
}] } });
|
|
6764
7075
|
|
|
6765
7076
|
/**
|
|
6766
|
-
* Per-widget asset-scope binding for
|
|
7077
|
+
* Per-widget asset-scope binding for persistent-query widgets — stream-data and
|
|
7078
|
+
* runtime queries (AB#7725).
|
|
6767
7079
|
*
|
|
6768
|
-
*
|
|
6769
|
-
*
|
|
6770
|
-
* (
|
|
6771
|
-
*
|
|
6772
|
-
*
|
|
6773
|
-
*
|
|
6774
|
-
*
|
|
7080
|
+
* This control lets the designer bind a single widget to one of the MeshBoard's
|
|
7081
|
+
* entity selectors: when the selector's entity is picked, its resolved rtIds
|
|
7082
|
+
* (the selector's `childScope` hop or path, or the picked entity itself) scope
|
|
7083
|
+
* this widget's query on the server (see
|
|
7084
|
+
* `MeshBoardStateService.resolveEntitySelectorScope` and
|
|
7085
|
+
* `QueryExecutionOptions.scopeRtIds`):
|
|
7086
|
+
* - stream-data queries get the rtIds as `streamDataArgs.rtIds` (the backend
|
|
7087
|
+
* `StreamDataArguments.rtIds` override) — simple, aggregation, grouping and
|
|
7088
|
+
* downsampling queries alike;
|
|
7089
|
+
* - runtime queries (SimpleRtQuery, AggregationRtQuery,
|
|
7090
|
+
* GroupingAggregationRtQuery) get the field filter `rtId IN (rtIds)`,
|
|
7091
|
+
* AND-combined with the widget's and the saved query's own filters.
|
|
6775
7092
|
*
|
|
6776
|
-
* The control renders
|
|
6777
|
-
*
|
|
6778
|
-
* the picker stays hidden (mirrors `SdTimeFilterToggleComponent`).
|
|
7093
|
+
* The control renders once a query is selected (`family` is known); without a
|
|
7094
|
+
* query there is nothing to scope.
|
|
6779
7095
|
*
|
|
6780
7096
|
* Two-way bindable:
|
|
6781
7097
|
* ```html
|
|
@@ -6787,7 +7103,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.5", ngImpor
|
|
|
6787
7103
|
* ```
|
|
6788
7104
|
*/
|
|
6789
7105
|
class EntitySelectorScopePickerComponent {
|
|
6790
|
-
/** Family of the currently selected query. Picker is shown
|
|
7106
|
+
/** Family of the currently selected query. Picker is shown once a query (either family) is selected. */
|
|
6791
7107
|
family;
|
|
6792
7108
|
/** Currently bound entity selector id (`undefined` ⇒ no binding). */
|
|
6793
7109
|
entitySelectorId;
|
|
@@ -6802,7 +7118,7 @@ class EntitySelectorScopePickerComponent {
|
|
|
6802
7118
|
/** Emits on change (enables `[(entitySelectorId)]` two-way binding). */
|
|
6803
7119
|
entitySelectorIdChange = new EventEmitter();
|
|
6804
7120
|
get visible() {
|
|
6805
|
-
return this.family === 'streamData';
|
|
7121
|
+
return this.family === 'streamData' || this.family === 'runtime';
|
|
6806
7122
|
}
|
|
6807
7123
|
/** "None" row first, then one row per available selector. */
|
|
6808
7124
|
get options() {
|
|
@@ -6829,7 +7145,7 @@ class EntitySelectorScopePickerComponent {
|
|
|
6829
7145
|
(valueChange)="onSelect($event)">
|
|
6830
7146
|
</kendo-dropdownlist>
|
|
6831
7147
|
<p class="field-hint">
|
|
6832
|
-
|
|
7148
|
+
Restricts this widget's query to the entities resolved from the
|
|
6833
7149
|
selected asset. Leave unset to use the saved query's own scope.
|
|
6834
7150
|
</p>
|
|
6835
7151
|
</div>
|
|
@@ -6851,7 +7167,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.5", ngImpor
|
|
|
6851
7167
|
(valueChange)="onSelect($event)">
|
|
6852
7168
|
</kendo-dropdownlist>
|
|
6853
7169
|
<p class="field-hint">
|
|
6854
|
-
|
|
7170
|
+
Restricts this widget's query to the entities resolved from the
|
|
6855
7171
|
selected asset. Leave unset to use the saved query's own scope.
|
|
6856
7172
|
</p>
|
|
6857
7173
|
</div>
|
|
@@ -8768,7 +9084,7 @@ const ISO_NAIVE_DATE_TIME_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2})?(\.\d+)?$
|
|
|
8768
9084
|
* instant. A naive ISO date-time string (no `Z`/offset) is treated as UTC — the
|
|
8769
9085
|
* wire's serialization of an instant — never as browser-local time (AB#4818).
|
|
8770
9086
|
*/
|
|
8771
|
-
function toInstant(value) {
|
|
9087
|
+
function toInstant$1(value) {
|
|
8772
9088
|
if (value instanceof Date) {
|
|
8773
9089
|
return isNaN(value.getTime()) ? null : value;
|
|
8774
9090
|
}
|
|
@@ -8826,7 +9142,7 @@ function partsFormatter(mode) {
|
|
|
8826
9142
|
* non-instants so callers can skip the row.
|
|
8827
9143
|
*/
|
|
8828
9144
|
function getZonedDateParts(value, mode) {
|
|
8829
|
-
const date = toInstant(value);
|
|
9145
|
+
const date = toInstant$1(value);
|
|
8830
9146
|
if (!date) {
|
|
8831
9147
|
return null;
|
|
8832
9148
|
}
|
|
@@ -8858,7 +9174,7 @@ function zonedDateKey(parts) {
|
|
|
8858
9174
|
* instant, so callers can fall back to their own rendering.
|
|
8859
9175
|
*/
|
|
8860
9176
|
function formatInstant(value, mode, options) {
|
|
8861
|
-
const date = toInstant(value);
|
|
9177
|
+
const date = toInstant$1(value);
|
|
8862
9178
|
if (!date) {
|
|
8863
9179
|
return null;
|
|
8864
9180
|
}
|
|
@@ -10159,6 +10475,17 @@ class TableWidgetDataSourceDirective extends OctoGraphQlDataSource {
|
|
|
10159
10475
|
* `'runtime'` for legacy configs that predate stream-data support.
|
|
10160
10476
|
*/
|
|
10161
10477
|
fetchPersistentQueryData(dataSource, queryOptions) {
|
|
10478
|
+
// Entity-selector scope (AB#7721): pending → keep the list in its loading
|
|
10479
|
+
// state (an observable that never emits; the next refresh after the scope
|
|
10480
|
+
// resolves cancels it) and send no query; empty → no rows, no query — never
|
|
10481
|
+
// the persisted (unscoped) scope.
|
|
10482
|
+
const scope = this.stateService.resolveStreamDataScope(dataSource.entitySelectorId, dataSource.queryFamily, dataSource.ignoreTimeFilter);
|
|
10483
|
+
if (scope.kind === 'pending') {
|
|
10484
|
+
return NEVER;
|
|
10485
|
+
}
|
|
10486
|
+
if (scope.kind === 'empty') {
|
|
10487
|
+
return of(new FetchResultTyped([], 0));
|
|
10488
|
+
}
|
|
10162
10489
|
const fieldFilter = this.convertFiltersToDto(this._config?.filters);
|
|
10163
10490
|
// queryFamily may be undefined for legacy widget configs — the executor
|
|
10164
10491
|
// falls back to a one-time lookup by rtId. streamDataArgs is sent
|
|
@@ -10171,7 +10498,9 @@ class TableWidgetDataSourceDirective extends OctoGraphQlDataSource {
|
|
|
10171
10498
|
first: queryOptions.state.take ?? this._config?.pageSize ?? 10,
|
|
10172
10499
|
after: GraphQL.offsetToCursor(queryOptions.state.skip ?? 0),
|
|
10173
10500
|
fieldFilter: fieldFilter ?? undefined,
|
|
10174
|
-
streamDataArgs
|
|
10501
|
+
streamDataArgs,
|
|
10502
|
+
// Runtime queries: `rtId IN` field filter (AB#7725); stream data: arg.rtIds.
|
|
10503
|
+
scopeRtIds: scope.kind === 'scoped' ? scope.rtIds : undefined
|
|
10175
10504
|
}).pipe(map$1(result => {
|
|
10176
10505
|
// Extract columns from query response and update signal.
|
|
10177
10506
|
// Replace dots with underscores for grid compatibility (Kendo treats dots as nested paths).
|
|
@@ -10302,6 +10631,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.5", ngImpor
|
|
|
10302
10631
|
/** Default texts of {@link MeshBoardMessages} (the list texts default inside `mm-list-view`). */
|
|
10303
10632
|
const DEFAULT_MESHBOARD_MESSAGES = {
|
|
10304
10633
|
tableNotConfigured: 'Table not configured',
|
|
10634
|
+
scopeResolutionFailed: 'Could not resolve the scope of "{label}" - widgets bound to it show no data.',
|
|
10305
10635
|
};
|
|
10306
10636
|
/**
|
|
10307
10637
|
* Optional: the host's translations of the MeshBoard widget texts (English without it). Set it
|
|
@@ -10326,6 +10656,7 @@ function resolveMeshBoardMessages(messages) {
|
|
|
10326
10656
|
...DEFAULT_MESHBOARD_MESSAGES,
|
|
10327
10657
|
...(messages?.tableList ? { tableList: messages.tableList } : {}),
|
|
10328
10658
|
...(messages?.tableNotConfigured ? { tableNotConfigured: messages.tableNotConfigured } : {}),
|
|
10659
|
+
...(messages?.scopeResolutionFailed ? { scopeResolutionFailed: messages.scopeResolutionFailed } : {}),
|
|
10329
10660
|
};
|
|
10330
10661
|
}
|
|
10331
10662
|
/** The resolved MeshBoard messages of {@link MESHBOARD_MESSAGES} as a signal. Call in an injection context. */
|
|
@@ -10356,6 +10687,286 @@ function resolveMeshBoardLabel(resolver, request) {
|
|
|
10356
10687
|
return typeof text === 'string' && text !== '' ? text : request.defaultText;
|
|
10357
10688
|
}
|
|
10358
10689
|
|
|
10690
|
+
/**
|
|
10691
|
+
* Optional: the host's current UI language. MeshBoard does not derive it from `LOCALE_ID` (that is
|
|
10692
|
+
* static in many apps); a host that wants per-language texts in board configurations (table column
|
|
10693
|
+
* `titles`, `valueLabels`) hands its language over once, via `provideMeshBoard({ language })`,
|
|
10694
|
+
* `provideMeshBoardLanguage(language)` or this token directly. Not provided = the configured default
|
|
10695
|
+
* texts (e.g. the column `title`).
|
|
10696
|
+
*
|
|
10697
|
+
* ```ts
|
|
10698
|
+
* providers: [provideMeshBoard({ language: languageService.currentLanguage })] // Signal<string>
|
|
10699
|
+
* ```
|
|
10700
|
+
*/
|
|
10701
|
+
const MESHBOARD_LANGUAGE = new InjectionToken('MESHBOARD_LANGUAGE');
|
|
10702
|
+
/** Reads a {@link MeshBoardLanguageSource} (fixed or signal) as a normalised culture code, or `null`. */
|
|
10703
|
+
function readMeshBoardLanguageSource(source) {
|
|
10704
|
+
if (!source) {
|
|
10705
|
+
return null;
|
|
10706
|
+
}
|
|
10707
|
+
const value = isSignal(source) ? source() : source;
|
|
10708
|
+
if (typeof value !== 'string') {
|
|
10709
|
+
return null;
|
|
10710
|
+
}
|
|
10711
|
+
const normalised = value.trim().replace(/_/g, '-');
|
|
10712
|
+
return normalised === '' ? null : normalised;
|
|
10713
|
+
}
|
|
10714
|
+
/** The host language of {@link MESHBOARD_LANGUAGE} as a signal (`null` without one). Call in an injection context. */
|
|
10715
|
+
function injectMeshBoardLanguage() {
|
|
10716
|
+
const source = inject(MESHBOARD_LANGUAGE, { optional: true });
|
|
10717
|
+
return computed(() => readMeshBoardLanguageSource(source));
|
|
10718
|
+
}
|
|
10719
|
+
/**
|
|
10720
|
+
* Well-formed culture key of a per-language text map: a 2-3 letter language, optionally followed by
|
|
10721
|
+
* subtags (`de`, `de-AT`, `en-GB`, `zh-Hant-TW`). Keys such as `german` or `de_AT` are not culture codes.
|
|
10722
|
+
*/
|
|
10723
|
+
const CULTURE_KEY = /^[A-Za-z]{2,3}(-[A-Za-z0-9]{1,8})*$/;
|
|
10724
|
+
/** Whether `key` is a well-formed culture key of a per-language text map. */
|
|
10725
|
+
function isCultureKey(key) {
|
|
10726
|
+
return CULTURE_KEY.test(key);
|
|
10727
|
+
}
|
|
10728
|
+
/**
|
|
10729
|
+
* Picks the text for `language` from a per-language map (AB#7655). Lookup order:
|
|
10730
|
+
* 1. the exact culture or language (`de-AT`, `de`; case-insensitive);
|
|
10731
|
+
* 2. a culture (`de-AT`) without an exact key: a key equal to its language part (`de`). A culture
|
|
10732
|
+
* never picks a sibling culture (`de-DE` does not take `de-AT`);
|
|
10733
|
+
* 3. a bare language (`de`) without an exact key: the first authored key of that language
|
|
10734
|
+
* (`de-AT` in `{ 'en-GB': …, 'de-AT': …, 'de-DE': … }`), in the map's key order.
|
|
10735
|
+
* Returns `undefined` when there is no language, no map or no match, so the caller applies its own
|
|
10736
|
+
* fallback (e.g. the column `title`).
|
|
10737
|
+
*/
|
|
10738
|
+
function pickLocalizedText(texts, language) {
|
|
10739
|
+
if (!texts || !language) {
|
|
10740
|
+
return undefined;
|
|
10741
|
+
}
|
|
10742
|
+
const wanted = language.toLowerCase();
|
|
10743
|
+
const isBareLanguage = !wanted.includes('-');
|
|
10744
|
+
const languagePart = wanted.split('-')[0];
|
|
10745
|
+
let languageMatch;
|
|
10746
|
+
for (const [key, text] of Object.entries(texts)) {
|
|
10747
|
+
if (typeof text !== 'string' || text === '') {
|
|
10748
|
+
continue;
|
|
10749
|
+
}
|
|
10750
|
+
const candidate = key.toLowerCase();
|
|
10751
|
+
if (candidate === wanted) {
|
|
10752
|
+
return text;
|
|
10753
|
+
}
|
|
10754
|
+
if (languageMatch !== undefined) {
|
|
10755
|
+
continue;
|
|
10756
|
+
}
|
|
10757
|
+
if (isBareLanguage ? candidate.split('-')[0] === wanted : candidate === languagePart) {
|
|
10758
|
+
languageMatch = text;
|
|
10759
|
+
}
|
|
10760
|
+
}
|
|
10761
|
+
return languageMatch;
|
|
10762
|
+
}
|
|
10763
|
+
|
|
10764
|
+
/** An empty problems record. */
|
|
10765
|
+
function emptyLabelProblems() {
|
|
10766
|
+
return { unknownCultureKeys: [], malformedValueKeys: [], malformedTitleKeys: [], statusMappingIgnored: false };
|
|
10767
|
+
}
|
|
10768
|
+
function isPlainObject(value) {
|
|
10769
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
10770
|
+
}
|
|
10771
|
+
/**
|
|
10772
|
+
* Keeps the usable entries of a per-language text map: culture keys with a non-empty string text.
|
|
10773
|
+
* Bad culture keys are reported; the keys of entries with a non-string or empty text are returned
|
|
10774
|
+
* as `malformedKeys`.
|
|
10775
|
+
*/
|
|
10776
|
+
function cleanLocalizedTexts(texts, unknownCultureKeys) {
|
|
10777
|
+
const clean = {};
|
|
10778
|
+
const malformedKeys = [];
|
|
10779
|
+
for (const [key, text] of Object.entries(texts)) {
|
|
10780
|
+
if (!isCultureKey(key)) {
|
|
10781
|
+
unknownCultureKeys.push(key);
|
|
10782
|
+
continue;
|
|
10783
|
+
}
|
|
10784
|
+
if (typeof text !== 'string' || text === '') {
|
|
10785
|
+
malformedKeys.push(key);
|
|
10786
|
+
continue;
|
|
10787
|
+
}
|
|
10788
|
+
clean[key] = text;
|
|
10789
|
+
}
|
|
10790
|
+
return { texts: clean, malformedKeys };
|
|
10791
|
+
}
|
|
10792
|
+
/**
|
|
10793
|
+
* The header text of a column: `titles` looked up with {@link pickLocalizedText} (exact culture,
|
|
10794
|
+
* then language part; a bare language takes the first key of that language), then `title`.
|
|
10795
|
+
* Without a language (no `MESHBOARD_LANGUAGE`) it is always `title`, as before AB#7655.
|
|
10796
|
+
* Unknown culture keys and malformed entries (non-string or empty text, a `titles` that is no
|
|
10797
|
+
* object) are reported in `problems` and never matched.
|
|
10798
|
+
*/
|
|
10799
|
+
function resolveColumnTitle(column, language, problems) {
|
|
10800
|
+
const titles = column.titles;
|
|
10801
|
+
if (!isPlainObject(titles)) {
|
|
10802
|
+
if (titles !== undefined && titles !== null) {
|
|
10803
|
+
problems?.malformedTitleKeys?.push('*');
|
|
10804
|
+
}
|
|
10805
|
+
return column.title;
|
|
10806
|
+
}
|
|
10807
|
+
const { texts, malformedKeys } = cleanLocalizedTexts(titles, problems?.unknownCultureKeys ?? []);
|
|
10808
|
+
problems?.malformedTitleKeys?.push(...malformedKeys);
|
|
10809
|
+
return pickLocalizedText(texts, language) ?? column.title;
|
|
10810
|
+
}
|
|
10811
|
+
/**
|
|
10812
|
+
* Resolves a column's `valueLabels` for the current language into a lookup by raw value (`"1"`,
|
|
10813
|
+
* `"2"`, ...). A label given per language follows the language like `titles`; without a match that
|
|
10814
|
+
* entry is left out, so the cell shows the value in the column's normal rendering. Malformed
|
|
10815
|
+
* entries and unknown culture keys are ignored and reported in `problems`. Returns `null` when no
|
|
10816
|
+
* label applies for the current language (no usable `valueLabels`, or only per-language labels
|
|
10817
|
+
* without a match), so the column keeps its `dataType` / `format` rendering and the label resolver.
|
|
10818
|
+
*/
|
|
10819
|
+
function resolveValueLabels(column, language, problems) {
|
|
10820
|
+
const valueLabels = column.valueLabels;
|
|
10821
|
+
if (valueLabels === undefined || valueLabels === null) {
|
|
10822
|
+
return null;
|
|
10823
|
+
}
|
|
10824
|
+
const report = problems ?? { unknownCultureKeys: [], malformedValueKeys: [] };
|
|
10825
|
+
if (!isPlainObject(valueLabels)) {
|
|
10826
|
+
report.malformedValueKeys.push('*');
|
|
10827
|
+
return null;
|
|
10828
|
+
}
|
|
10829
|
+
const resolved = new Map();
|
|
10830
|
+
for (const [key, entry] of Object.entries(valueLabels)) {
|
|
10831
|
+
if (!isPlainObject(entry)) {
|
|
10832
|
+
report.malformedValueKeys.push(key);
|
|
10833
|
+
continue;
|
|
10834
|
+
}
|
|
10835
|
+
const { label, color } = entry;
|
|
10836
|
+
if (color !== undefined && typeof color !== 'string') {
|
|
10837
|
+
report.malformedValueKeys.push(key);
|
|
10838
|
+
continue;
|
|
10839
|
+
}
|
|
10840
|
+
let text;
|
|
10841
|
+
if (typeof label === 'string' && label !== '') {
|
|
10842
|
+
text = label;
|
|
10843
|
+
}
|
|
10844
|
+
else if (isPlainObject(label)) {
|
|
10845
|
+
const cleaned = cleanLocalizedTexts(label, report.unknownCultureKeys);
|
|
10846
|
+
if (cleaned.malformedKeys.length > 0 || Object.keys(label).length === 0) {
|
|
10847
|
+
report.malformedValueKeys.push(key);
|
|
10848
|
+
continue;
|
|
10849
|
+
}
|
|
10850
|
+
text = pickLocalizedText(cleaned.texts, language);
|
|
10851
|
+
}
|
|
10852
|
+
else {
|
|
10853
|
+
report.malformedValueKeys.push(key);
|
|
10854
|
+
continue;
|
|
10855
|
+
}
|
|
10856
|
+
if (text !== undefined) {
|
|
10857
|
+
resolved.set(key, { text, ...(color ? { color } : {}) });
|
|
10858
|
+
}
|
|
10859
|
+
}
|
|
10860
|
+
return resolved.size > 0 ? resolved : null;
|
|
10861
|
+
}
|
|
10862
|
+
/**
|
|
10863
|
+
* Text of a value-label cell whose value has no label: the value as the list view renders the
|
|
10864
|
+
* column's `dataType` - `numeric` with `format` like the `number` pipe, `iso8601` with `format`
|
|
10865
|
+
* like the `date` pipe (both with the app's `locale`), plain text through `textFormatter` (the
|
|
10866
|
+
* board's date-time formatter), anything else as string. A value the pipe cannot format shows as is.
|
|
10867
|
+
*/
|
|
10868
|
+
function formatUnlabelledValue(value, column, locale, textFormatter) {
|
|
10869
|
+
const format = column.format || undefined;
|
|
10870
|
+
try {
|
|
10871
|
+
switch (column.dataType) {
|
|
10872
|
+
case 'numeric': {
|
|
10873
|
+
const number = typeof value === 'number'
|
|
10874
|
+
? value
|
|
10875
|
+
: typeof value === 'string' && value.trim() !== '' ? Number(value) : Number.NaN;
|
|
10876
|
+
return Number.isFinite(number) ? formatNumber(number, locale, format) : String(value);
|
|
10877
|
+
}
|
|
10878
|
+
case 'iso8601':
|
|
10879
|
+
return typeof value === 'string' || typeof value === 'number' || value instanceof Date
|
|
10880
|
+
? formatDate(value, format ?? 'mediumDate', locale)
|
|
10881
|
+
: String(value);
|
|
10882
|
+
case undefined:
|
|
10883
|
+
case 'text':
|
|
10884
|
+
return textFormatter(value);
|
|
10885
|
+
default:
|
|
10886
|
+
return String(value);
|
|
10887
|
+
}
|
|
10888
|
+
}
|
|
10889
|
+
catch {
|
|
10890
|
+
return String(value);
|
|
10891
|
+
}
|
|
10892
|
+
}
|
|
10893
|
+
/** Whether a problems record holds anything worth a warning. */
|
|
10894
|
+
function hasLabelProblems(problems) {
|
|
10895
|
+
return problems.unknownCultureKeys.length > 0
|
|
10896
|
+
|| problems.malformedValueKeys.length > 0
|
|
10897
|
+
|| (problems.malformedTitleKeys?.length ?? 0) > 0
|
|
10898
|
+
|| problems.statusMappingIgnored === true;
|
|
10899
|
+
}
|
|
10900
|
+
/**
|
|
10901
|
+
* Columns the table config dialog saves (AB#7655 AC 6). The dialog edits which fields are shown,
|
|
10902
|
+
* not their presentation, so it must not erase hand-authored column properties (`titles`,
|
|
10903
|
+
* `valueLabels`, `width`, `format`, `statusMapping`, ...):
|
|
10904
|
+
* - same data source (same CK type / same persistent query): a field that stays selected keeps its
|
|
10905
|
+
* existing column object as is; a newly selected field gets a fresh column with `defaultTitle(field)`;
|
|
10906
|
+
* - persistent query: see {@link mergePersistentQueryColumns}.
|
|
10907
|
+
*/
|
|
10908
|
+
function mergeRuntimeEntityColumns(selectedFields, initialColumns, sameDataSource, defaultTitle) {
|
|
10909
|
+
const existing = new Map();
|
|
10910
|
+
if (sameDataSource) {
|
|
10911
|
+
for (const column of initialColumns ?? []) {
|
|
10912
|
+
if (column && typeof column.field === 'string' && !existing.has(column.field)) {
|
|
10913
|
+
existing.set(column.field, column);
|
|
10914
|
+
}
|
|
10915
|
+
}
|
|
10916
|
+
}
|
|
10917
|
+
return selectedFields.map(field => existing.get(field) ?? { field, title: defaultTitle(field), width: undefined });
|
|
10918
|
+
}
|
|
10919
|
+
/**
|
|
10920
|
+
* Explicit columns of a persistent-query table after the dialog saved (AB#7655):
|
|
10921
|
+
* - a different query, or no explicit columns: `[]` (columns are derived from the query at run time);
|
|
10922
|
+
* - the same query with known `queryFields` (the columns the query returns now): the explicit columns
|
|
10923
|
+
* whose field the query still returns, unchanged and in their order, followed by the query's other
|
|
10924
|
+
* fields as derived columns (`defaultTitle(field)`, like the widget derives them);
|
|
10925
|
+
* - the same query, `queryFields` unknown (`null`, empty, or the lookup failed): the explicit columns unchanged.
|
|
10926
|
+
*/
|
|
10927
|
+
function mergePersistentQueryColumns(initialColumns, sameQuery, queryFields, defaultTitle = field => field) {
|
|
10928
|
+
if (!sameQuery || !initialColumns || initialColumns.length === 0) {
|
|
10929
|
+
return [];
|
|
10930
|
+
}
|
|
10931
|
+
if (!queryFields || queryFields.length === 0) {
|
|
10932
|
+
return [...initialColumns];
|
|
10933
|
+
}
|
|
10934
|
+
const returned = new Set(queryFields);
|
|
10935
|
+
const kept = initialColumns.filter(column => column && typeof column.field === 'string' && returned.has(column.field));
|
|
10936
|
+
const keptFields = new Set(kept.map(column => column.field));
|
|
10937
|
+
const added = [...returned]
|
|
10938
|
+
.filter(field => !keptFields.has(field))
|
|
10939
|
+
.map(field => ({ field, title: defaultTitle(field) }));
|
|
10940
|
+
return [...kept, ...added];
|
|
10941
|
+
}
|
|
10942
|
+
|
|
10943
|
+
/**
|
|
10944
|
+
* Cell of a table widget column with `valueLabels` (AB#7655): plain text like every other cell,
|
|
10945
|
+
* optionally in the configured colour. Without a colour it inherits the theme text colour; an
|
|
10946
|
+
* empty text renders an empty cell.
|
|
10947
|
+
*/
|
|
10948
|
+
class TableValueLabelCellComponent {
|
|
10949
|
+
/** Text of the cell: the label, or the raw value when unmapped; `''` for null / missing. */
|
|
10950
|
+
text = '';
|
|
10951
|
+
/** Optional CSS colour of the text. */
|
|
10952
|
+
color;
|
|
10953
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.5", ngImport: i0, type: TableValueLabelCellComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
10954
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.1.5", type: TableValueLabelCellComponent, isStandalone: true, selector: "mm-table-value-label-cell", inputs: { text: "text", color: "color" }, ngImport: i0, template: `@if (text) {<span class="mm-table-value-label" [style.color]="color || null">{{ text }}</span>}`, isInline: true, changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
10955
|
+
}
|
|
10956
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.5", ngImport: i0, type: TableValueLabelCellComponent, decorators: [{
|
|
10957
|
+
type: Component,
|
|
10958
|
+
args: [{
|
|
10959
|
+
selector: 'mm-table-value-label-cell',
|
|
10960
|
+
standalone: true,
|
|
10961
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
10962
|
+
template: `@if (text) {<span class="mm-table-value-label" [style.color]="color || null">{{ text }}</span>}`
|
|
10963
|
+
}]
|
|
10964
|
+
}], propDecorators: { text: [{
|
|
10965
|
+
type: Input
|
|
10966
|
+
}], color: [{
|
|
10967
|
+
type: Input
|
|
10968
|
+
}] } });
|
|
10969
|
+
|
|
10359
10970
|
const ICON_MAP = {
|
|
10360
10971
|
'check-circle': checkCircleIcon,
|
|
10361
10972
|
'x-circle': xCircleIcon,
|
|
@@ -10371,6 +10982,35 @@ function resolveStatusMapping(config) {
|
|
|
10371
10982
|
{ icon: ICON_MAP[val.icon] ?? questionCircleIcon, tooltip: val.tooltip, color: val.color }
|
|
10372
10983
|
]));
|
|
10373
10984
|
}
|
|
10985
|
+
/** Per-instance fallback for the warning key of widgets without an id. */
|
|
10986
|
+
let nextTableWidgetInstance = 0;
|
|
10987
|
+
/** Short, stable hash of a text (FNV-1a, base 36) for warning keys. */
|
|
10988
|
+
function shortHash(text) {
|
|
10989
|
+
let hash = 0x811c9dc5;
|
|
10990
|
+
for (let i = 0; i < text.length; i++) {
|
|
10991
|
+
hash ^= text.charCodeAt(i);
|
|
10992
|
+
hash = Math.imul(hash, 0x01000193);
|
|
10993
|
+
}
|
|
10994
|
+
return (hash >>> 0).toString(36);
|
|
10995
|
+
}
|
|
10996
|
+
/** A row's value for a column field, read like `mm-list-view` does (flat key first, then nested path). */
|
|
10997
|
+
function readCellValue(item, field) {
|
|
10998
|
+
if (item === null || typeof item !== 'object') {
|
|
10999
|
+
return undefined;
|
|
11000
|
+
}
|
|
11001
|
+
const row = item;
|
|
11002
|
+
if (!field.includes('.') || row[field] !== undefined) {
|
|
11003
|
+
return row[field];
|
|
11004
|
+
}
|
|
11005
|
+
let value = row;
|
|
11006
|
+
for (const key of field.split('.')) {
|
|
11007
|
+
if (value === null || value === undefined || typeof value !== 'object') {
|
|
11008
|
+
return undefined;
|
|
11009
|
+
}
|
|
11010
|
+
value = value[key];
|
|
11011
|
+
}
|
|
11012
|
+
return value;
|
|
11013
|
+
}
|
|
10374
11014
|
class TableWidgetComponent {
|
|
10375
11015
|
config;
|
|
10376
11016
|
dataSource;
|
|
@@ -10382,6 +11022,27 @@ class TableWidgetComponent {
|
|
|
10382
11022
|
/** CK value type per derived column field (persistent queries), for the label resolver. */
|
|
10383
11023
|
_queryColumnValueTypes = signal(new Map(), /* @ts-ignore */
|
|
10384
11024
|
...(ngDevMode ? [{ debugName: "_queryColumnValueTypes" }] : /* istanbul ignore next */ []));
|
|
11025
|
+
/** Host UI language (`MESHBOARD_LANGUAGE`, AB#7655) for column `titles` / `valueLabels`; `null` = `title` / raw texts. */
|
|
11026
|
+
language;
|
|
11027
|
+
/**
|
|
11028
|
+
* Problems already warned about (`<widget id or instance>|<field>|<problem hash>`), so a bad config
|
|
11029
|
+
* logs once, not on every language change, while a new problem after a config edit is reported again.
|
|
11030
|
+
*/
|
|
11031
|
+
warnedColumns = new Set();
|
|
11032
|
+
/** Fallback warning key of a widget without an id. */
|
|
11033
|
+
instanceKey = `#${++nextTableWidgetInstance}`;
|
|
11034
|
+
/** App locale for the `number` / `date` rendering of unlabelled values in value-label columns. */
|
|
11035
|
+
locale = inject(LOCALE_ID);
|
|
11036
|
+
/**
|
|
11037
|
+
* Bumped on every `config` change: `config` is a plain `@Input`, so the computeds that read it
|
|
11038
|
+
* depend on this signal to pick up a new config on the same widget instance (AB#7655).
|
|
11039
|
+
*/
|
|
11040
|
+
_configVersion = signal(0, /* @ts-ignore */
|
|
11041
|
+
...(ngDevMode ? [{ debugName: "_configVersion" }] : /* istanbul ignore next */ []));
|
|
11042
|
+
constructor() {
|
|
11043
|
+
// Assigned here, not as a field initialiser: relative-import initialisers break under the Vitest runner (CLAUDE.md).
|
|
11044
|
+
this.language = injectMeshBoardLanguage();
|
|
11045
|
+
}
|
|
10385
11046
|
// Widget state signals
|
|
10386
11047
|
_isLoading = signal(false, /* @ts-ignore */
|
|
10387
11048
|
...(ngDevMode ? [{ debugName: "_isLoading" }] : /* istanbul ignore next */ []));
|
|
@@ -10414,6 +11075,8 @@ class TableWidgetComponent {
|
|
|
10414
11075
|
* For persistent queries, uses dynamically derived columns from the query response.
|
|
10415
11076
|
*/
|
|
10416
11077
|
listViewColumns = computed(() => {
|
|
11078
|
+
// A new config on the same instance recomputes the columns (titles, valueLabels, columns).
|
|
11079
|
+
this._configVersion();
|
|
10417
11080
|
// Read the board's timezone mode so datetime cells (e.g. archive
|
|
10418
11081
|
// window_start/window_end, which arrive as raw ISO-8601 strings) render on
|
|
10419
11082
|
// the same basis the time filter is computed on. Reading it here makes the
|
|
@@ -10445,30 +11108,66 @@ class TableWidgetComponent {
|
|
|
10445
11108
|
...column,
|
|
10446
11109
|
formatter: (value) => {
|
|
10447
11110
|
const text = formatTableCellValue(value, mode);
|
|
10448
|
-
|
|
11111
|
+
// String cells as before; numeric cells only when the query reports an enum value
|
|
11112
|
+
// type (numeric enum codes, AB#7655) — the resolver gets the number as string.
|
|
11113
|
+
const isEnumNumber = typeof value === 'number' && Number.isFinite(value) && !!valueType && /ENUM/i.test(valueType);
|
|
11114
|
+
if (!isEnumNumber && (typeof value !== 'string' || value === '')) {
|
|
10449
11115
|
return text;
|
|
10450
11116
|
}
|
|
10451
11117
|
return resolveMeshBoardLabel(resolver, {
|
|
10452
11118
|
kind: 'tableCell',
|
|
10453
11119
|
...(ckTypeId ? { ckTypeId } : {}),
|
|
10454
11120
|
attribute: column.field,
|
|
10455
|
-
value,
|
|
11121
|
+
value: String(value),
|
|
10456
11122
|
...(valueType ? { valueType } : {}),
|
|
10457
11123
|
defaultText: text
|
|
10458
11124
|
});
|
|
10459
11125
|
}
|
|
10460
11126
|
};
|
|
10461
11127
|
};
|
|
10462
|
-
// If explicit columns are configured, use them (works for both data source types)
|
|
11128
|
+
// If explicit columns are configured, use them (works for both data source types).
|
|
11129
|
+
// Header texts and value labels follow the host language (AB#7655); reading the
|
|
11130
|
+
// language signal here re-labels the columns without refetching data.
|
|
10463
11131
|
if (this.config?.columns && this.config.columns.length > 0) {
|
|
10464
|
-
|
|
10465
|
-
|
|
10466
|
-
|
|
10467
|
-
|
|
10468
|
-
|
|
10469
|
-
|
|
10470
|
-
|
|
10471
|
-
|
|
11132
|
+
const language = this.language();
|
|
11133
|
+
const textFormatter = (value) => formatTableCellValue(value, mode);
|
|
11134
|
+
return this.config.columns.map(col => {
|
|
11135
|
+
const problems = emptyLabelProblems();
|
|
11136
|
+
const base = {
|
|
11137
|
+
field: col.field,
|
|
11138
|
+
displayName: resolveColumnTitle(col, language, problems),
|
|
11139
|
+
dataType: (col.dataType ?? 'text'),
|
|
11140
|
+
width: col.width,
|
|
11141
|
+
...(col.format ? { format: col.format } : {}),
|
|
11142
|
+
...(col.statusMapping ? { statusMapping: resolveStatusMapping(col.statusMapping) } : {})
|
|
11143
|
+
};
|
|
11144
|
+
const labels = resolveValueLabels(col, language, problems);
|
|
11145
|
+
if (labels && (col.statusMapping || col.dataType === 'statusIcons')) {
|
|
11146
|
+
// valueLabels together with statusMapping (status icons) is not supported: the labels win.
|
|
11147
|
+
problems.statusMappingIgnored = true;
|
|
11148
|
+
}
|
|
11149
|
+
this.warnOnce(col, problems);
|
|
11150
|
+
if (!labels) {
|
|
11151
|
+
return withDateFormatter(base);
|
|
11152
|
+
}
|
|
11153
|
+
// valueLabels win over the label resolver: a plain-text cell with the label (and its optional
|
|
11154
|
+
// colour); an unmapped value keeps the column's dataType / format rendering; empty for null.
|
|
11155
|
+
return {
|
|
11156
|
+
...base,
|
|
11157
|
+
dataType: 'component',
|
|
11158
|
+
cellComponent: TableValueLabelCellComponent,
|
|
11159
|
+
cellInputs: (item) => {
|
|
11160
|
+
const value = readCellValue(item, col.field);
|
|
11161
|
+
if (value === null || value === undefined || value === '') {
|
|
11162
|
+
return { text: '' };
|
|
11163
|
+
}
|
|
11164
|
+
const label = labels.get(String(value));
|
|
11165
|
+
return label
|
|
11166
|
+
? { text: label.text, color: label.color }
|
|
11167
|
+
: { text: formatUnlabelledValue(value, col, this.locale, textFormatter) };
|
|
11168
|
+
}
|
|
11169
|
+
};
|
|
11170
|
+
});
|
|
10472
11171
|
}
|
|
10473
11172
|
// For persistent query without explicit columns, use derived columns
|
|
10474
11173
|
if (this.config?.dataSource?.type === 'persistentQuery') {
|
|
@@ -10503,6 +11202,7 @@ class TableWidgetComponent {
|
|
|
10503
11202
|
* Returns true once columns are available.
|
|
10504
11203
|
*/
|
|
10505
11204
|
columnsReady = computed(() => {
|
|
11205
|
+
this._configVersion();
|
|
10506
11206
|
if (!this.config?.dataSource)
|
|
10507
11207
|
return false;
|
|
10508
11208
|
if (this.config.dataSource.type === 'persistentQuery') {
|
|
@@ -10513,6 +11213,38 @@ class TableWidgetComponent {
|
|
|
10513
11213
|
return (this.config.columns?.length ?? 0) > 0;
|
|
10514
11214
|
}, /* @ts-ignore */
|
|
10515
11215
|
...(ngDevMode ? [{ debugName: "columnsReady" }] : /* istanbul ignore next */ []));
|
|
11216
|
+
/**
|
|
11217
|
+
* Logs one console warning per widget, column and set of problems for ignored `titles` /
|
|
11218
|
+
* `valueLabels` entries (unknown culture keys, malformed titles or value labels, `valueLabels`
|
|
11219
|
+
* together with `statusMapping`, AB#7655). The table still renders. The key carries a hash of the
|
|
11220
|
+
* problem list, so a different problem after a config edit is reported again; widgets without an
|
|
11221
|
+
* id use a per-instance key.
|
|
11222
|
+
*/
|
|
11223
|
+
warnOnce(column, problems) {
|
|
11224
|
+
if (!hasLabelProblems(problems)) {
|
|
11225
|
+
return;
|
|
11226
|
+
}
|
|
11227
|
+
const parts = [];
|
|
11228
|
+
const quoted = (keys) => keys.map(k => `"${k}"`).join(', ');
|
|
11229
|
+
if (problems.unknownCultureKeys.length > 0) {
|
|
11230
|
+
parts.push(`unknown culture keys ${quoted(problems.unknownCultureKeys)}`);
|
|
11231
|
+
}
|
|
11232
|
+
if (problems.malformedTitleKeys && problems.malformedTitleKeys.length > 0) {
|
|
11233
|
+
parts.push(`malformed titles entries ${quoted(problems.malformedTitleKeys)}`);
|
|
11234
|
+
}
|
|
11235
|
+
if (problems.malformedValueKeys.length > 0) {
|
|
11236
|
+
parts.push(`malformed valueLabels entries ${quoted(problems.malformedValueKeys)}`);
|
|
11237
|
+
}
|
|
11238
|
+
if (problems.statusMappingIgnored) {
|
|
11239
|
+
parts.push('statusMapping (valueLabels together with status icons are not supported; the labels are shown)');
|
|
11240
|
+
}
|
|
11241
|
+
const key = `${this.config?.id || this.instanceKey}|${column.field}|${shortHash(parts.join(';'))}`;
|
|
11242
|
+
if (this.warnedColumns.has(key)) {
|
|
11243
|
+
return;
|
|
11244
|
+
}
|
|
11245
|
+
this.warnedColumns.add(key);
|
|
11246
|
+
console.warn(`octo-meshboard: table widget "${this.config?.title ?? ''}" (${this.config?.id ?? ''}), column "${column.field}": ignoring ${parts.join('; ')}`);
|
|
11247
|
+
}
|
|
10516
11248
|
formatColumnTitle(field) {
|
|
10517
11249
|
return field
|
|
10518
11250
|
.replace(/([A-Z])/g, ' $1')
|
|
@@ -10520,6 +11252,9 @@ class TableWidgetComponent {
|
|
|
10520
11252
|
.trim();
|
|
10521
11253
|
}
|
|
10522
11254
|
ngOnChanges(changes) {
|
|
11255
|
+
if (changes['config']) {
|
|
11256
|
+
this._configVersion.update(version => version + 1);
|
|
11257
|
+
}
|
|
10523
11258
|
if (changes['config'] && !changes['config'].firstChange) {
|
|
10524
11259
|
// Clear cached query columns when config changes (for persistent queries)
|
|
10525
11260
|
if (this.config?.dataSource?.type === 'persistentQuery') {
|
|
@@ -10597,7 +11332,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.5", ngImpor
|
|
|
10597
11332
|
}
|
|
10598
11333
|
</div>
|
|
10599
11334
|
`, changeDetection: ChangeDetectionStrategy.Eager, styles: [".table-widget{height:100%;width:100%;display:flex;flex-direction:column}.no-config-overlay{display:flex;align-items:center;justify-content:center;height:100%;color:var(--kendo-color-subtle, #6c757d);font-style:italic}:host ::ng-deep mm-list-view{height:100%}:host ::ng-deep .k-grid{height:100%}\n"] }]
|
|
10600
|
-
}], propDecorators: { config: [{
|
|
11335
|
+
}], ctorParameters: () => [], propDecorators: { config: [{
|
|
10601
11336
|
type: Input
|
|
10602
11337
|
}], dataSource: [{
|
|
10603
11338
|
type: ViewChild,
|
|
@@ -10616,6 +11351,10 @@ class TableConfigDialogComponent {
|
|
|
10616
11351
|
getCkTypeAvailableQueryColumnsGQL = inject(GetCkTypeAvailableQueryColumnsDtoGQL);
|
|
10617
11352
|
stateService = inject(MeshBoardStateService);
|
|
10618
11353
|
windowRef = inject(WindowRef);
|
|
11354
|
+
getRuntimeQueryColumnsGQL = inject(GetRuntimeQueryColumnsDtoGQL);
|
|
11355
|
+
queryExecutor = inject(QueryExecutorService);
|
|
11356
|
+
/** Fields the saved query returns now, loaded once per query rtId (AB#7655, kept explicit columns). */
|
|
11357
|
+
queryFieldsLoad;
|
|
10619
11358
|
ckTypeSelectorInput;
|
|
10620
11359
|
filterEditor;
|
|
10621
11360
|
querySelector;
|
|
@@ -10833,14 +11572,48 @@ class TableConfigDialogComponent {
|
|
|
10833
11572
|
return 'No sorting configured';
|
|
10834
11573
|
return this.sorting.map(s => `${s.attributePath} ${s.sortOrder === SortOrdersDto.AscendingDto ? '↑' : '↓'}`).join(', ');
|
|
10835
11574
|
}
|
|
10836
|
-
|
|
11575
|
+
/**
|
|
11576
|
+
* The fields a persistent query returns now (`null` when unknown or the lookup failed). Runtime
|
|
11577
|
+
* queries use the metadata-only column resolver; stream-data queries execute with `first: 1`
|
|
11578
|
+
* (no column-introspection endpoint), like the chart dialogs.
|
|
11579
|
+
*/
|
|
11580
|
+
loadQueryFields(rtId) {
|
|
11581
|
+
if (this.queryFieldsLoad?.rtId === rtId) {
|
|
11582
|
+
return this.queryFieldsLoad.fields;
|
|
11583
|
+
}
|
|
11584
|
+
const fields = (async () => {
|
|
11585
|
+
try {
|
|
11586
|
+
const family = queryFamily(this.selectedPersistentQuery?.ckTypeId) ?? this.initialQueryFamily
|
|
11587
|
+
?? await this.queryExecutor.resolveFamily(rtId);
|
|
11588
|
+
if (family === 'runtime') {
|
|
11589
|
+
// network-only: the query may have changed since its columns were cached.
|
|
11590
|
+
const result = await firstValueFrom(this.getRuntimeQueryColumnsGQL.fetch({
|
|
11591
|
+
variables: { rtId },
|
|
11592
|
+
fetchPolicy: 'network-only'
|
|
11593
|
+
}));
|
|
11594
|
+
const columns = result.data?.runtime?.runtimeQuery?.items?.[0]?.columns ?? [];
|
|
11595
|
+
return columns
|
|
11596
|
+
.map(c => c?.attributePath ?? '')
|
|
11597
|
+
.filter(path => path !== '');
|
|
11598
|
+
}
|
|
11599
|
+
const sdResult = await firstValueFrom(this.queryExecutor.executeStreamData(rtId, { first: 1, forceRefresh: true }));
|
|
11600
|
+
return sdResult.columns.map(c => c.attributePath).filter(path => !!path);
|
|
11601
|
+
}
|
|
11602
|
+
catch (error) {
|
|
11603
|
+
console.error('Error loading query columns:', error);
|
|
11604
|
+
return null;
|
|
11605
|
+
}
|
|
11606
|
+
})();
|
|
11607
|
+
this.queryFieldsLoad = { rtId, fields };
|
|
11608
|
+
return fields;
|
|
11609
|
+
}
|
|
11610
|
+
async onSave() {
|
|
10837
11611
|
if (this.dataSourceType === 'runtimeEntity' && this.selectedCkType) {
|
|
10838
|
-
// Convert selected columns to TableColumn format
|
|
10839
|
-
|
|
10840
|
-
|
|
10841
|
-
|
|
10842
|
-
|
|
10843
|
-
}));
|
|
11612
|
+
// Convert selected columns to TableColumn format. Columns that stay selected on the same
|
|
11613
|
+
// CK type keep their authored properties (titles, valueLabels, width, format, ... — AB#7655).
|
|
11614
|
+
const sameDataSource = this.initialDataSourceType !== 'persistentQuery'
|
|
11615
|
+
&& !!this.initialCkTypeId && this.selectedCkType.rtCkTypeId === this.initialCkTypeId;
|
|
11616
|
+
const columns = mergeRuntimeEntityColumns(this.selectedColumns, this.initialColumns, sameDataSource, field => this.formatColumnTitle(field));
|
|
10844
11617
|
// Convert filters (remove IDs)
|
|
10845
11618
|
const filterDtos = this.filters.map(f => ({
|
|
10846
11619
|
attributePath: f.attributePath,
|
|
@@ -10866,15 +11639,24 @@ class TableConfigDialogComponent {
|
|
|
10866
11639
|
}));
|
|
10867
11640
|
// Derive family from the selected query's CK type so the runtime executor
|
|
10868
11641
|
// can route correctly without an extra lookup.
|
|
10869
|
-
const
|
|
11642
|
+
const query = this.selectedPersistentQuery;
|
|
11643
|
+
const family = queryFamily(query.ckTypeId) ?? this.initialQueryFamily ?? undefined;
|
|
11644
|
+
// Explicit (hand-authored) columns survive a save on the same query (AB#7655), minus the ones
|
|
11645
|
+
// the query no longer returns, plus the query's other columns as derived columns; a new query
|
|
11646
|
+
// starts without explicit columns (derived from the query at run time).
|
|
11647
|
+
const sameQuery = this.initialDataSourceType === 'persistentQuery' && query.rtId === this.initialQueryRtId;
|
|
11648
|
+
const queryFields = sameQuery && (this.initialColumns?.length ?? 0) > 0
|
|
11649
|
+
? await this.loadQueryFields(query.rtId)
|
|
11650
|
+
: null;
|
|
11651
|
+
const columns = mergePersistentQueryColumns(this.initialColumns, sameQuery, queryFields, field => this.formatColumnTitle(field));
|
|
10870
11652
|
this.windowRef.close({
|
|
10871
11653
|
dataSourceType: 'persistentQuery',
|
|
10872
11654
|
ckTypeId: '', // Not used for persistent query
|
|
10873
|
-
columns
|
|
11655
|
+
columns,
|
|
10874
11656
|
sorting: [],
|
|
10875
11657
|
filters: queryFilterDtos,
|
|
10876
|
-
queryRtId:
|
|
10877
|
-
queryName:
|
|
11658
|
+
queryRtId: query.rtId,
|
|
11659
|
+
queryName: query.name,
|
|
10878
11660
|
queryFamily: family,
|
|
10879
11661
|
ignoreTimeFilter: this.ignoreTimeFilter,
|
|
10880
11662
|
entitySelectorId: this.entitySelectorId,
|
|
@@ -11446,6 +12228,9 @@ class GaugeWidgetComponent {
|
|
|
11446
12228
|
}, /* @ts-ignore */
|
|
11447
12229
|
...(ngDevMode ? [{ debugName: "numericValue" }] : /* istanbul ignore next */ []));
|
|
11448
12230
|
formattedValue = computed(() => {
|
|
12231
|
+
// Nothing loaded (e.g. an empty entity-selector scope, AB#7721): no value, not "0".
|
|
12232
|
+
if (!this._data())
|
|
12233
|
+
return '-';
|
|
11449
12234
|
const value = this.numericValue();
|
|
11450
12235
|
return value.toLocaleString('de-AT', {
|
|
11451
12236
|
minimumFractionDigits: value % 1 !== 0 ? 1 : 0,
|
|
@@ -11517,10 +12302,24 @@ class GaugeWidgetComponent {
|
|
|
11517
12302
|
// queryFamily may be undefined for legacy widget configs — the executor
|
|
11518
12303
|
// falls back to a one-time lookup by rtId. streamDataArgs is sent
|
|
11519
12304
|
// unconditionally because the runtime path ignores it.
|
|
12305
|
+
// Entity-selector scope (AB#7721): pending → stay in the loading state and
|
|
12306
|
+
// send no query (the view refreshes once the scope is known); empty → show
|
|
12307
|
+
// no value ('-') without querying the persisted (unscoped) scope.
|
|
12308
|
+
const scope = this.stateService.resolveStreamDataScope(dataSource.entitySelectorId, dataSource.queryFamily, dataSource.ignoreTimeFilter);
|
|
12309
|
+
if (scope.kind === 'pending') {
|
|
12310
|
+
return;
|
|
12311
|
+
}
|
|
12312
|
+
if (scope.kind === 'empty') {
|
|
12313
|
+
this._data.set(null);
|
|
12314
|
+
this._isLoading.set(false);
|
|
12315
|
+
return;
|
|
12316
|
+
}
|
|
11520
12317
|
const streamDataArgs = this.buildStreamDataArgs();
|
|
11521
12318
|
const result = await firstValueFrom(this.queryExecutor.execute(dataSource.queryFamily, dataSource.queryRtId, {
|
|
11522
12319
|
fieldFilter: fieldFilter ?? undefined,
|
|
11523
|
-
streamDataArgs
|
|
12320
|
+
streamDataArgs,
|
|
12321
|
+
// Runtime queries: `rtId IN` field filter (AB#7725); stream data: arg.rtIds.
|
|
12322
|
+
scopeRtIds: scope.kind === 'scoped' ? scope.rtIds : undefined
|
|
11524
12323
|
}).pipe(catchError(err => {
|
|
11525
12324
|
console.error('Error loading Gauge query data:', err);
|
|
11526
12325
|
throw err;
|
|
@@ -13619,6 +14418,18 @@ class PieChartWidgetComponent {
|
|
|
13619
14418
|
* Note: isNotConfigured() check in loadData() ensures queryRtId is set.
|
|
13620
14419
|
*/
|
|
13621
14420
|
async loadPersistentQueryData(dataSource) {
|
|
14421
|
+
// Entity-selector scope (AB#7721): pending → stay in the loading state and
|
|
14422
|
+
// send no query (the view refreshes once the scope is known); empty → render
|
|
14423
|
+
// the empty chart without querying the persisted (unscoped) scope.
|
|
14424
|
+
const scope = this.stateService.resolveStreamDataScope(dataSource.entitySelectorId, dataSource.queryFamily, dataSource.ignoreTimeFilter);
|
|
14425
|
+
if (scope.kind === 'pending') {
|
|
14426
|
+
return;
|
|
14427
|
+
}
|
|
14428
|
+
if (scope.kind === 'empty') {
|
|
14429
|
+
this._rawData.set([]);
|
|
14430
|
+
this._isLoading.set(false);
|
|
14431
|
+
return;
|
|
14432
|
+
}
|
|
13622
14433
|
const fieldFilter = this.convertFiltersToDto(this.config.filters);
|
|
13623
14434
|
// queryFamily may be undefined for legacy widget configs — the executor
|
|
13624
14435
|
// falls back to a one-time lookup by rtId. streamDataArgs is sent
|
|
@@ -13626,7 +14437,9 @@ class PieChartWidgetComponent {
|
|
|
13626
14437
|
const streamDataArgs = this.buildStreamDataArgs();
|
|
13627
14438
|
const result = await firstValueFrom(this.queryExecutor.execute(dataSource.queryFamily, dataSource.queryRtId, {
|
|
13628
14439
|
fieldFilter: fieldFilter ?? undefined,
|
|
13629
|
-
streamDataArgs
|
|
14440
|
+
streamDataArgs,
|
|
14441
|
+
// Runtime queries: `rtId IN` field filter (AB#7725); stream data: arg.rtIds.
|
|
14442
|
+
scopeRtIds: scope.kind === 'scoped' ? scope.rtIds : undefined
|
|
13630
14443
|
}).pipe(catchError(err => {
|
|
13631
14444
|
console.error('Error loading Pie Chart data:', err);
|
|
13632
14445
|
throw err;
|
|
@@ -14833,14 +15646,25 @@ class BarChartWidgetComponent {
|
|
|
14833
15646
|
// queryFamily may be undefined for legacy widget configs — the executor
|
|
14834
15647
|
// falls back to a one-time lookup by rtId. streamDataArgs is sent
|
|
14835
15648
|
// unconditionally because the runtime path ignores it.
|
|
15649
|
+
// Entity-selector scope (AB#7721): pending → stay in the loading state and
|
|
15650
|
+
// send no query (the view refreshes once the scope is known); empty → render
|
|
15651
|
+
// the empty chart without querying the persisted (unscoped) scope.
|
|
15652
|
+
const scope = this.stateService.resolveStreamDataScope(queryDataSource.entitySelectorId, queryDataSource.queryFamily, queryDataSource.ignoreTimeFilter);
|
|
15653
|
+
if (scope.kind === 'pending') {
|
|
15654
|
+
return;
|
|
15655
|
+
}
|
|
14836
15656
|
const streamDataArgs = this.buildStreamDataArgs();
|
|
14837
|
-
const result =
|
|
14838
|
-
|
|
14839
|
-
|
|
14840
|
-
|
|
14841
|
-
|
|
14842
|
-
|
|
14843
|
-
|
|
15657
|
+
const result = scope.kind === 'empty'
|
|
15658
|
+
? emptyQueryExecutionResult(queryDataSource.queryFamily)
|
|
15659
|
+
: await firstValueFrom(this.queryExecutor.execute(queryDataSource.queryFamily, queryDataSource.queryRtId, {
|
|
15660
|
+
fieldFilter: fieldFilter ?? undefined,
|
|
15661
|
+
streamDataArgs,
|
|
15662
|
+
// Runtime queries: `rtId IN` field filter (AB#7725); stream data: arg.rtIds.
|
|
15663
|
+
scopeRtIds: scope.kind === 'scoped' ? scope.rtIds : undefined
|
|
15664
|
+
}).pipe(catchError(err => {
|
|
15665
|
+
console.error('Error loading Bar Chart data:', err);
|
|
15666
|
+
throw err;
|
|
15667
|
+
})));
|
|
14844
15668
|
const filteredRows = result.rows.filter(row => BarChartWidgetComponent.SUPPORTED_ROW_TYPES.has(row.__typename ?? ''));
|
|
14845
15669
|
if (this.isDynamicSeriesMode()) {
|
|
14846
15670
|
this.processDynamicSeriesData(filteredRows);
|
|
@@ -16385,6 +17209,21 @@ class LineChartWidgetComponent {
|
|
|
16385
17209
|
this._isLoading.set(true);
|
|
16386
17210
|
this._error.set(null);
|
|
16387
17211
|
try {
|
|
17212
|
+
// Entity-selector scope (AB#7721): pending → stay in the loading state and
|
|
17213
|
+
// send no query (the view refreshes once the scope is known); empty → render
|
|
17214
|
+
// the empty chart without querying the persisted (unscoped) scope.
|
|
17215
|
+
const scope = this.stateService.resolveStreamDataScope(queryDataSource.entitySelectorId, queryDataSource.queryFamily, queryDataSource.ignoreTimeFilter);
|
|
17216
|
+
if (scope.kind === 'pending') {
|
|
17217
|
+
return;
|
|
17218
|
+
}
|
|
17219
|
+
if (scope.kind === 'empty') {
|
|
17220
|
+
this._resolutionSignal.set(null);
|
|
17221
|
+
this._resolutionTarget.set(null);
|
|
17222
|
+
this.processData([], false);
|
|
17223
|
+
this._dataInfo.set({ rows: 0, points: 0, total: 0 });
|
|
17224
|
+
this._isLoading.set(false);
|
|
17225
|
+
return;
|
|
17226
|
+
}
|
|
16388
17227
|
const fieldFilter = this.convertFiltersToDto(this.config.filters);
|
|
16389
17228
|
// Resolution-aware routing (AB#4290): let the server pick the archive/rollup for the visible
|
|
16390
17229
|
// window + target point count, then downsample each source series against it. Requires a
|
|
@@ -16413,7 +17252,9 @@ class LineChartWidgetComponent {
|
|
|
16413
17252
|
this.lastLimit = streamDataArgs?.limit ?? 0;
|
|
16414
17253
|
const result = await firstValueFrom(this.queryExecutor.execute(queryDataSource.queryFamily, queryDataSource.queryRtId, {
|
|
16415
17254
|
fieldFilter: fieldFilter ?? undefined,
|
|
16416
|
-
streamDataArgs
|
|
17255
|
+
streamDataArgs,
|
|
17256
|
+
// Runtime queries: `rtId IN` field filter (AB#7725); stream data: arg.rtIds.
|
|
17257
|
+
scopeRtIds: scope.kind === 'scoped' ? scope.rtIds : undefined
|
|
16417
17258
|
}).pipe(catchError(err => {
|
|
16418
17259
|
console.error('Error loading Line Chart data:', err);
|
|
16419
17260
|
throw err;
|
|
@@ -16629,7 +17470,7 @@ class LineChartWidgetComponent {
|
|
|
16629
17470
|
// as UTC — a bare `new Date(...)` would reinterpret it as browser-local time and shift
|
|
16630
17471
|
// the axis by the UTC offset (AB#4818).
|
|
16631
17472
|
if (!allCategories.has(categoryValue)) {
|
|
16632
|
-
allCategories.set(categoryValue, toInstant(categoryValue) ?? new Date(NaN));
|
|
17473
|
+
allCategories.set(categoryValue, toInstant$1(categoryValue) ?? new Date(NaN));
|
|
16633
17474
|
}
|
|
16634
17475
|
allSeriesGroups.add(seriesGroupValue);
|
|
16635
17476
|
if (!dataMap.has(categoryValue)) {
|
|
@@ -17966,14 +18807,25 @@ class HeatmapWidgetComponent {
|
|
|
17966
18807
|
// queryFamily may be undefined for legacy widget configs — the executor
|
|
17967
18808
|
// falls back to a one-time lookup by rtId. streamDataArgs is sent
|
|
17968
18809
|
// unconditionally because the runtime path ignores it.
|
|
18810
|
+
// Entity-selector scope (AB#7721): pending → stay in the loading state and
|
|
18811
|
+
// send no query (the view refreshes once the scope is known); empty → render
|
|
18812
|
+
// the empty grid without querying the persisted (unscoped) scope.
|
|
18813
|
+
const scope = this.stateService.resolveStreamDataScope(queryDataSource.entitySelectorId, queryDataSource.queryFamily, queryDataSource.ignoreTimeFilter);
|
|
18814
|
+
if (scope.kind === 'pending') {
|
|
18815
|
+
return;
|
|
18816
|
+
}
|
|
17969
18817
|
const streamDataArgs = this.buildStreamDataArgs();
|
|
17970
|
-
const result =
|
|
17971
|
-
|
|
17972
|
-
|
|
17973
|
-
|
|
17974
|
-
|
|
17975
|
-
|
|
17976
|
-
|
|
18818
|
+
const result = scope.kind === 'empty'
|
|
18819
|
+
? emptyQueryExecutionResult(queryDataSource.queryFamily)
|
|
18820
|
+
: await firstValueFrom(this.queryExecutor.execute(queryDataSource.queryFamily, queryDataSource.queryRtId, {
|
|
18821
|
+
fieldFilter: fieldFilter ?? undefined,
|
|
18822
|
+
streamDataArgs,
|
|
18823
|
+
// Runtime queries: `rtId IN` field filter (AB#7725); stream data: arg.rtIds.
|
|
18824
|
+
scopeRtIds: scope.kind === 'scoped' ? scope.rtIds : undefined
|
|
18825
|
+
}).pipe(catchError(err => {
|
|
18826
|
+
console.error('Error loading Heatmap data:', err);
|
|
18827
|
+
throw err;
|
|
18828
|
+
})));
|
|
17977
18829
|
const filteredRows = result.rows.filter(row => HeatmapWidgetComponent.SUPPORTED_ROW_TYPES.has(row.__typename ?? ''));
|
|
17978
18830
|
this.processHeatmapData(filteredRows);
|
|
17979
18831
|
this._isLoading.set(false);
|
|
@@ -18237,7 +19089,7 @@ class HeatmapWidgetComponent {
|
|
|
18237
19089
|
parseDate(value) {
|
|
18238
19090
|
if (!value)
|
|
18239
19091
|
return null;
|
|
18240
|
-
return toInstant(value);
|
|
19092
|
+
return toInstant$1(value);
|
|
18241
19093
|
}
|
|
18242
19094
|
convertFiltersToDto(filters) {
|
|
18243
19095
|
const variables = this.stateService.getVariables();
|
|
@@ -19308,6 +20160,13 @@ class StatsGridWidgetComponent {
|
|
|
19308
20160
|
this._statValues.set(initialValues);
|
|
19309
20161
|
this._isLoading.set(true);
|
|
19310
20162
|
this._error.set(null);
|
|
20163
|
+
// Entity-selector scope (AB#7721): while a bound stat's scope is still
|
|
20164
|
+
// resolving, stay in the loading state and send no query at all; the view
|
|
20165
|
+
// refreshes the widget once the scope is known.
|
|
20166
|
+
if (this.config.stats.some(stat => stat.persistentQuerySource
|
|
20167
|
+
&& this.scopeOf(stat.persistentQuerySource).kind === 'pending')) {
|
|
20168
|
+
return;
|
|
20169
|
+
}
|
|
19311
20170
|
try {
|
|
19312
20171
|
// Runtime aggregation queries are only needed for stats WITHOUT a
|
|
19313
20172
|
// per-stat persistent-query source — those run through the executor below.
|
|
@@ -19347,10 +20206,19 @@ class StatsGridWidgetComponent {
|
|
|
19347
20206
|
return aggregationResults.get(stat.queryId) ?? null;
|
|
19348
20207
|
}
|
|
19349
20208
|
async loadPersistentQueryValue(source) {
|
|
20209
|
+
// Pending or empty scope (still resolving, nothing in scope or lookup failed,
|
|
20210
|
+
// AB#7721/AB#7725): no value, no query — never the persisted (unscoped) scope.
|
|
20211
|
+
// The view refreshes the widget once a pending scope is resolved.
|
|
20212
|
+
const scope = this.scopeOf(source);
|
|
20213
|
+
if (scope.kind === 'pending' || scope.kind === 'empty') {
|
|
20214
|
+
return null;
|
|
20215
|
+
}
|
|
19350
20216
|
try {
|
|
19351
20217
|
const result = await firstValueFrom(this.queryExecutor.execute(source.queryFamily, source.queryRtId, {
|
|
19352
20218
|
fieldFilter: this.convertFiltersToDto(source.filters),
|
|
19353
|
-
streamDataArgs: this.buildStreamDataArgs(source)
|
|
20219
|
+
streamDataArgs: this.buildStreamDataArgs(source),
|
|
20220
|
+
// Runtime queries: `rtId IN` field filter (AB#7725); stream data: arg.rtIds.
|
|
20221
|
+
scopeRtIds: scope.kind === 'scoped' ? scope.rtIds : undefined
|
|
19354
20222
|
}));
|
|
19355
20223
|
return extractPersistentQueryCellValue(result, source);
|
|
19356
20224
|
}
|
|
@@ -19359,6 +20227,9 @@ class StatsGridWidgetComponent {
|
|
|
19359
20227
|
return null;
|
|
19360
20228
|
}
|
|
19361
20229
|
}
|
|
20230
|
+
scopeOf(source) {
|
|
20231
|
+
return this.stateService.resolveStreamDataScope(source.entitySelectorId, source.queryFamily, source.ignoreTimeFilter);
|
|
20232
|
+
}
|
|
19362
20233
|
buildStreamDataArgs(source) {
|
|
19363
20234
|
const timeArgs = this.stateService.resolveStreamDataTimeArgs(source.ignoreTimeFilter);
|
|
19364
20235
|
const rtIds = this.stateService.resolveStreamDataRtIds(source.entitySelectorId);
|
|
@@ -19530,7 +20401,7 @@ class PersistentQueryCellEditorComponent {
|
|
|
19530
20401
|
</kendo-combobox>
|
|
19531
20402
|
</div>
|
|
19532
20403
|
|
|
19533
|
-
<!--
|
|
20404
|
+
<!-- Time filter: stream data only. Scope binding: both families (AB#7725). -->
|
|
19534
20405
|
<mm-sd-time-filter-toggle
|
|
19535
20406
|
[family]="family"
|
|
19536
20407
|
[(ignoreTimeFilter)]="ignoreTimeFilter"
|
|
@@ -19595,7 +20466,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.5", ngImpor
|
|
|
19595
20466
|
</kendo-combobox>
|
|
19596
20467
|
</div>
|
|
19597
20468
|
|
|
19598
|
-
<!--
|
|
20469
|
+
<!-- Time filter: stream data only. Scope binding: both families (AB#7725). -->
|
|
19599
20470
|
<mm-sd-time-filter-toggle
|
|
19600
20471
|
[family]="family"
|
|
19601
20472
|
[(ignoreTimeFilter)]="ignoreTimeFilter"
|
|
@@ -22983,6 +23854,13 @@ class SummaryCardWidgetComponent {
|
|
|
22983
23854
|
return;
|
|
22984
23855
|
this._isLoading.set(true);
|
|
22985
23856
|
this._error.set(null);
|
|
23857
|
+
// Entity-selector scope (AB#7721): while a bound tile's scope is still
|
|
23858
|
+
// resolving, stay in the loading state and send no query at all; the view
|
|
23859
|
+
// refreshes the widget once the scope is known.
|
|
23860
|
+
if (this.config.tiles.some(tile => tile.persistentQuerySource
|
|
23861
|
+
&& this.scopeOf(tile.persistentQuerySource).kind === 'pending')) {
|
|
23862
|
+
return;
|
|
23863
|
+
}
|
|
22986
23864
|
try {
|
|
22987
23865
|
// Cache entities by rtId to avoid duplicate fetches
|
|
22988
23866
|
const entityCache = new Map();
|
|
@@ -23031,12 +23909,24 @@ class SummaryCardWidgetComponent {
|
|
|
23031
23909
|
return null;
|
|
23032
23910
|
}
|
|
23033
23911
|
async fetchPersistentQueryValue(source) {
|
|
23912
|
+
// Pending or empty scope (still resolving, nothing in scope or lookup failed,
|
|
23913
|
+
// AB#7721/AB#7725): no value, no query — never the persisted (unscoped) scope.
|
|
23914
|
+
// The view refreshes the widget once a pending scope is resolved.
|
|
23915
|
+
const scope = this.scopeOf(source);
|
|
23916
|
+
if (scope.kind === 'pending' || scope.kind === 'empty') {
|
|
23917
|
+
return null;
|
|
23918
|
+
}
|
|
23034
23919
|
const result = await firstValueFrom(this.queryExecutor.execute(source.queryFamily, source.queryRtId, {
|
|
23035
23920
|
fieldFilter: this.convertFiltersToDto(source.filters),
|
|
23036
|
-
streamDataArgs: this.buildStreamDataArgs(source)
|
|
23921
|
+
streamDataArgs: this.buildStreamDataArgs(source),
|
|
23922
|
+
// Runtime queries: `rtId IN` field filter (AB#7725); stream data: arg.rtIds.
|
|
23923
|
+
scopeRtIds: scope.kind === 'scoped' ? scope.rtIds : undefined
|
|
23037
23924
|
}));
|
|
23038
23925
|
return extractPersistentQueryCellValue(result, source);
|
|
23039
23926
|
}
|
|
23927
|
+
scopeOf(source) {
|
|
23928
|
+
return this.stateService.resolveStreamDataScope(source.entitySelectorId, source.queryFamily, source.ignoreTimeFilter);
|
|
23929
|
+
}
|
|
23040
23930
|
buildStreamDataArgs(source) {
|
|
23041
23931
|
const timeArgs = this.stateService.resolveStreamDataTimeArgs(source.ignoreTimeFilter);
|
|
23042
23932
|
const rtIds = this.stateService.resolveStreamDataRtIds(source.entitySelectorId);
|
|
@@ -24547,6 +25437,13 @@ class ProcessDataService {
|
|
|
24547
25437
|
if (!queryRtId) {
|
|
24548
25438
|
throw new Error('Persistent query binding requires queryRtId');
|
|
24549
25439
|
}
|
|
25440
|
+
// AB#7582: while the embedding host's period is invalid, a stream-data query
|
|
25441
|
+
// bound to the time filter is not sent (it would read the whole archive); the
|
|
25442
|
+
// binding gets no value. Runtime queries are unaffected.
|
|
25443
|
+
if (this.stateService.isStreamDataBlockedByHostTimeRange()
|
|
25444
|
+
&& await this.queryExecutor.resolveFamily(queryRtId) === 'streamData') {
|
|
25445
|
+
return undefined;
|
|
25446
|
+
}
|
|
24550
25447
|
// Execute via the unified executor (family resolved lazily). The active time
|
|
24551
25448
|
// filter is applied by default for stream-data queries; runtime queries
|
|
24552
25449
|
// ignore it. The element binding has no asset-scope, so no rtIds override.
|
|
@@ -24743,6 +25640,21 @@ class ProcessDataService {
|
|
|
24743
25640
|
if (!bindingQueryRtId) {
|
|
24744
25641
|
throw new Error('Persistent query binding requires a Query rtId');
|
|
24745
25642
|
}
|
|
25643
|
+
// Entity-selector scope (AB#7721): NO query is sent while the scope is pending
|
|
25644
|
+
// or empty — never the persisted (unscoped) scope. Pending → a `pending` result
|
|
25645
|
+
// (the widget stays loading; a refresh follows once the scope resolves);
|
|
25646
|
+
// empty → no rows.
|
|
25647
|
+
const scope = this.stateService.resolveStreamDataScope(config.bindingEntitySelectorId, bindingQueryFamily, config.bindingIgnoreTimeFilter);
|
|
25648
|
+
if (scope.kind === 'pending') {
|
|
25649
|
+
return { type: 'query', pending: true, loadedAt: new Date() };
|
|
25650
|
+
}
|
|
25651
|
+
if (scope.kind === 'empty') {
|
|
25652
|
+
return {
|
|
25653
|
+
type: 'query',
|
|
25654
|
+
queryResult: this.mapExecutionResult(emptyQueryExecutionResult(bindingQueryFamily)),
|
|
25655
|
+
loadedAt: new Date()
|
|
25656
|
+
};
|
|
25657
|
+
}
|
|
24746
25658
|
// Convert widget filters to GraphQL format with variable resolution
|
|
24747
25659
|
const fieldFilters = this.convertFiltersToDto(bindingFilters);
|
|
24748
25660
|
// Execute via the unified executor — it routes runtime vs stream-data from
|
|
@@ -24752,7 +25664,9 @@ class ProcessDataService {
|
|
|
24752
25664
|
const result = await firstValueFrom(this.queryExecutor.execute(bindingQueryFamily, bindingQueryRtId, {
|
|
24753
25665
|
first: 1000, // Reasonable limit for process diagram data
|
|
24754
25666
|
fieldFilter: fieldFilters,
|
|
24755
|
-
streamDataArgs: this.buildBindingStreamDataArgs(config)
|
|
25667
|
+
streamDataArgs: this.buildBindingStreamDataArgs(config),
|
|
25668
|
+
// Runtime queries: `rtId IN` field filter (AB#7725); stream data: arg.rtIds.
|
|
25669
|
+
scopeRtIds: scope.kind === 'scoped' ? scope.rtIds : undefined
|
|
24756
25670
|
}));
|
|
24757
25671
|
return {
|
|
24758
25672
|
type: 'query',
|
|
@@ -24971,13 +25885,16 @@ class ProcessWidgetComponent {
|
|
|
24971
25885
|
this._isLoading.set(true);
|
|
24972
25886
|
this._error.set(null);
|
|
24973
25887
|
this._boundData.set(null);
|
|
25888
|
+
// A bound entity-selector scope that is still resolving keeps the widget loading
|
|
25889
|
+
// until the refresh that follows the resolution (AB#7721).
|
|
25890
|
+
let scopePending = false;
|
|
24974
25891
|
try {
|
|
24975
25892
|
// Check for inline config first
|
|
24976
25893
|
if (this.config.inlineConfig) {
|
|
24977
25894
|
this._diagramConfig.set(this.config.inlineConfig);
|
|
24978
25895
|
await this.loadSymbolDefinitions(this.config.inlineConfig);
|
|
24979
25896
|
await this.loadRuntimeData();
|
|
24980
|
-
await this.loadBoundData();
|
|
25897
|
+
scopePending = await this.loadBoundData();
|
|
24981
25898
|
return;
|
|
24982
25899
|
}
|
|
24983
25900
|
// Load from backend by rtId
|
|
@@ -24986,7 +25903,7 @@ class ProcessWidgetComponent {
|
|
|
24986
25903
|
this._diagramConfig.set(diagram);
|
|
24987
25904
|
await this.loadSymbolDefinitions(diagram);
|
|
24988
25905
|
await this.loadRuntimeData();
|
|
24989
|
-
await this.loadBoundData();
|
|
25906
|
+
scopePending = await this.loadBoundData();
|
|
24990
25907
|
return;
|
|
24991
25908
|
}
|
|
24992
25909
|
// No configuration
|
|
@@ -24997,25 +25914,32 @@ class ProcessWidgetComponent {
|
|
|
24997
25914
|
this._error.set('Failed to load process diagram');
|
|
24998
25915
|
}
|
|
24999
25916
|
finally {
|
|
25000
|
-
|
|
25917
|
+
if (!scopePending) {
|
|
25918
|
+
this._isLoading.set(false);
|
|
25919
|
+
}
|
|
25001
25920
|
}
|
|
25002
25921
|
}
|
|
25003
25922
|
/**
|
|
25004
|
-
* Loads bound data based on widget data binding configuration
|
|
25923
|
+
* Loads bound data based on widget data binding configuration.
|
|
25924
|
+
* Returns `true` when the bound entity-selector scope is still resolving (AB#7721).
|
|
25005
25925
|
*/
|
|
25006
25926
|
async loadBoundData() {
|
|
25007
25927
|
// Only load if data binding is configured
|
|
25008
25928
|
if (!this.config.dataBindingMode || this.config.dataBindingMode === 'none') {
|
|
25009
|
-
return;
|
|
25929
|
+
return false;
|
|
25010
25930
|
}
|
|
25011
25931
|
try {
|
|
25012
25932
|
const boundData = await this.dataService.loadBoundData(this.config);
|
|
25933
|
+
if (boundData?.pending) {
|
|
25934
|
+
return true;
|
|
25935
|
+
}
|
|
25013
25936
|
this._boundData.set(boundData);
|
|
25014
25937
|
}
|
|
25015
25938
|
catch (err) {
|
|
25016
25939
|
console.error('Error loading bound data:', err);
|
|
25017
25940
|
// Don't set error - diagram still displays, just without bound data
|
|
25018
25941
|
}
|
|
25942
|
+
return false;
|
|
25019
25943
|
}
|
|
25020
25944
|
/**
|
|
25021
25945
|
* Loads symbol definitions for all symbol instances in the diagram
|
|
@@ -30361,7 +31285,8 @@ function registerDefaultWidgets(registry) {
|
|
|
30361
31285
|
return {
|
|
30362
31286
|
...widget,
|
|
30363
31287
|
dataSource,
|
|
30364
|
-
columns
|
|
31288
|
+
// Explicit columns the dialog kept (same query, AB#7655); [] = derived from the query at runtime
|
|
31289
|
+
columns: result.columns ?? [],
|
|
30365
31290
|
sorting: [],
|
|
30366
31291
|
filters,
|
|
30367
31292
|
pageSize: result.pageSize,
|
|
@@ -32048,6 +32973,9 @@ function provideMeshBoard(options) {
|
|
|
32048
32973
|
if (opts.messages) {
|
|
32049
32974
|
providers.push({ provide: MESHBOARD_MESSAGES, useValue: opts.messages });
|
|
32050
32975
|
}
|
|
32976
|
+
if (opts.language) {
|
|
32977
|
+
providers.push({ provide: MESHBOARD_LANGUAGE, useValue: opts.language });
|
|
32978
|
+
}
|
|
32051
32979
|
// Include default widgets unless explicitly disabled
|
|
32052
32980
|
if (opts.includeDefaultWidgets !== false) {
|
|
32053
32981
|
providers.push(provideAppInitializer(() => {
|
|
@@ -32064,6 +32992,13 @@ function provideMeshBoard(options) {
|
|
|
32064
32992
|
function provideMeshBoardMessages(messages) {
|
|
32065
32993
|
return makeEnvironmentProviders([{ provide: MESHBOARD_MESSAGES, useValue: messages }]);
|
|
32066
32994
|
}
|
|
32995
|
+
/**
|
|
32996
|
+
* Provides the host's UI language (`MESHBOARD_LANGUAGE`, AB#7655) without `provideMeshBoard()` —
|
|
32997
|
+
* for apps that register the widgets lazily. A string or a signal (the widgets follow it).
|
|
32998
|
+
*/
|
|
32999
|
+
function provideMeshBoardLanguage(language) {
|
|
33000
|
+
return makeEnvironmentProviders([{ provide: MESHBOARD_LANGUAGE, useValue: language }]);
|
|
33001
|
+
}
|
|
32067
33002
|
/**
|
|
32068
33003
|
* Provider function for registering custom widgets.
|
|
32069
33004
|
* Use this to add your own widget types to MeshBoard.
|
|
@@ -32105,6 +33040,596 @@ function provideWidgetRegistrations(registrations) {
|
|
|
32105
33040
|
]);
|
|
32106
33041
|
}
|
|
32107
33042
|
|
|
33043
|
+
/** True when the selector is configured to follow another selector. */
|
|
33044
|
+
function isFollowerSelector(selector) {
|
|
33045
|
+
return !!selector.followsSelectorId;
|
|
33046
|
+
}
|
|
33047
|
+
/** True when the selector renders a picker in the toolbar. Followers never do. */
|
|
33048
|
+
function showsInToolbar(selector) {
|
|
33049
|
+
return selector.showInToolbar !== false && !isFollowerSelector(selector);
|
|
33050
|
+
}
|
|
33051
|
+
/** Resolves the leader of `selector` among `selectors`. */
|
|
33052
|
+
function resolveFollowLeader(selectors, selector) {
|
|
33053
|
+
const leaderId = selector.followsSelectorId;
|
|
33054
|
+
if (!leaderId) {
|
|
33055
|
+
return { kind: 'none' };
|
|
33056
|
+
}
|
|
33057
|
+
if (leaderId === selector.id) {
|
|
33058
|
+
return { kind: 'invalid', problem: 'self', leaderId };
|
|
33059
|
+
}
|
|
33060
|
+
const leader = selectors.find(s => s.id === leaderId);
|
|
33061
|
+
if (!leader) {
|
|
33062
|
+
return { kind: 'invalid', problem: 'unknown', leaderId };
|
|
33063
|
+
}
|
|
33064
|
+
if (isFollowerSelector(leader)) {
|
|
33065
|
+
return { kind: 'invalid', problem: 'chained', leaderId };
|
|
33066
|
+
}
|
|
33067
|
+
return { kind: 'leader', leader };
|
|
33068
|
+
}
|
|
33069
|
+
/**
|
|
33070
|
+
* The valid followers of `leaderId`: selectors whose `followsSelectorId` is
|
|
33071
|
+
* `leaderId`, provided the leader exists and is not itself a follower.
|
|
33072
|
+
*/
|
|
33073
|
+
function followersOf(selectors, leaderId) {
|
|
33074
|
+
return selectors.filter(s => s.followsSelectorId === leaderId && resolveFollowLeader(selectors, s).kind === 'leader');
|
|
33075
|
+
}
|
|
33076
|
+
/**
|
|
33077
|
+
* Selectors that `selectorId` may follow (the editor's "Follows" list): every
|
|
33078
|
+
* other selector that does not follow one itself.
|
|
33079
|
+
*/
|
|
33080
|
+
function eligibleLeaders(selectors, selectorId) {
|
|
33081
|
+
return selectors.filter(s => s.id !== selectorId && !isFollowerSelector(s));
|
|
33082
|
+
}
|
|
33083
|
+
/** Ids of the selectors that name `selectorId` as their leader (valid or not). */
|
|
33084
|
+
function selectorsFollowing(selectors, selectorId) {
|
|
33085
|
+
return selectors.filter(s => s.id !== selectorId && s.followsSelectorId === selectorId);
|
|
33086
|
+
}
|
|
33087
|
+
/**
|
|
33088
|
+
* The selector as it is resolved at runtime: a follower with a valid leader
|
|
33089
|
+
* picks from the leader's CK type (the editor fixes it to the leader's anyway;
|
|
33090
|
+
* this keeps an out-of-date stored type from breaking the entity fetch).
|
|
33091
|
+
*/
|
|
33092
|
+
function effectiveSelector(selectors, selector) {
|
|
33093
|
+
const resolution = resolveFollowLeader(selectors, selector);
|
|
33094
|
+
if (resolution.kind === 'leader' && resolution.leader.ckTypeId !== selector.ckTypeId) {
|
|
33095
|
+
return { ...selector, ckTypeId: resolution.leader.ckTypeId };
|
|
33096
|
+
}
|
|
33097
|
+
return selector;
|
|
33098
|
+
}
|
|
33099
|
+
/**
|
|
33100
|
+
* Editor validation of a "Follows" choice for the selector `selectorId`.
|
|
33101
|
+
* Returns the message that tells the author why the choice is not allowed, or
|
|
33102
|
+
* `null` when it is valid (including "follows nobody").
|
|
33103
|
+
*/
|
|
33104
|
+
function followValidationError(selectors, selectorId, followsSelectorId) {
|
|
33105
|
+
if (!followsSelectorId) {
|
|
33106
|
+
return null;
|
|
33107
|
+
}
|
|
33108
|
+
if (followsSelectorId === selectorId) {
|
|
33109
|
+
return 'A selector cannot follow itself.';
|
|
33110
|
+
}
|
|
33111
|
+
const followedBy = selectorsFollowing(selectors, selectorId);
|
|
33112
|
+
if (followedBy.length > 0) {
|
|
33113
|
+
return `Followed by ${labelList(followedBy)} — a selector that is followed cannot follow another one.`;
|
|
33114
|
+
}
|
|
33115
|
+
const leader = selectors.find(s => s.id === followsSelectorId);
|
|
33116
|
+
if (!leader) {
|
|
33117
|
+
return `The leader '${followsSelectorId}' does not exist — pick another leader or none.`;
|
|
33118
|
+
}
|
|
33119
|
+
if (isFollowerSelector(leader)) {
|
|
33120
|
+
return `The leader '${leader.label || leader.id}' follows another selector — pick another leader or none.`;
|
|
33121
|
+
}
|
|
33122
|
+
return null;
|
|
33123
|
+
}
|
|
33124
|
+
/** "A, B and C" from the selectors' labels (falling back to their ids). */
|
|
33125
|
+
function labelList(selectors) {
|
|
33126
|
+
const labels = selectors.map(s => s.label || s.id);
|
|
33127
|
+
if (labels.length <= 1) {
|
|
33128
|
+
return labels.join('');
|
|
33129
|
+
}
|
|
33130
|
+
return `${labels.slice(0, -1).join(', ')} and ${labels[labels.length - 1]}`;
|
|
33131
|
+
}
|
|
33132
|
+
|
|
33133
|
+
/** Turns a selector's child scope into its resolution plan. */
|
|
33134
|
+
function scopePlan(childScope) {
|
|
33135
|
+
if (!childScope) {
|
|
33136
|
+
return { kind: 'none' };
|
|
33137
|
+
}
|
|
33138
|
+
const path = childScope.path;
|
|
33139
|
+
if (Array.isArray(path) && path.length > 0) {
|
|
33140
|
+
if (path.length > MAX_SCOPE_HOPS) {
|
|
33141
|
+
return { kind: 'invalid', reason: `the path has ${path.length} hops (at most ${MAX_SCOPE_HOPS})` };
|
|
33142
|
+
}
|
|
33143
|
+
const hops = [];
|
|
33144
|
+
for (let i = 0; i < path.length; i++) {
|
|
33145
|
+
const hop = normalizeHop(path[i]);
|
|
33146
|
+
if (!hop) {
|
|
33147
|
+
return { kind: 'invalid', reason: `hop ${i + 1} needs an association role and a target type` };
|
|
33148
|
+
}
|
|
33149
|
+
hops.push(hop);
|
|
33150
|
+
}
|
|
33151
|
+
return { kind: 'path', hops };
|
|
33152
|
+
}
|
|
33153
|
+
// Original one-hop form; incomplete top-level fields mean "no child scope",
|
|
33154
|
+
// exactly as before AB#7723.
|
|
33155
|
+
if (!childScope.targetCkTypeId || !childScope.roleId) {
|
|
33156
|
+
return { kind: 'none' };
|
|
33157
|
+
}
|
|
33158
|
+
return {
|
|
33159
|
+
kind: 'single',
|
|
33160
|
+
hop: {
|
|
33161
|
+
roleId: childScope.roleId,
|
|
33162
|
+
direction: childScope.direction ?? 'in',
|
|
33163
|
+
targetCkTypeId: childScope.targetCkTypeId
|
|
33164
|
+
}
|
|
33165
|
+
};
|
|
33166
|
+
}
|
|
33167
|
+
/** The hops of a child scope as the editor shows them (one-hop form = one hop). */
|
|
33168
|
+
function scopeHopsOf(childScope) {
|
|
33169
|
+
if (!childScope) {
|
|
33170
|
+
return [];
|
|
33171
|
+
}
|
|
33172
|
+
if (Array.isArray(childScope.path) && childScope.path.length > 0) {
|
|
33173
|
+
return childScope.path.map(h => ({ roleId: h.roleId, direction: h.direction ?? 'in', targetCkTypeId: h.targetCkTypeId }));
|
|
33174
|
+
}
|
|
33175
|
+
// Incomplete top-level fields are "no child scope" at runtime; show them as none.
|
|
33176
|
+
if (childScope.targetCkTypeId && childScope.roleId) {
|
|
33177
|
+
return [{ roleId: childScope.roleId, direction: childScope.direction ?? 'in', targetCkTypeId: childScope.targetCkTypeId }];
|
|
33178
|
+
}
|
|
33179
|
+
return [];
|
|
33180
|
+
}
|
|
33181
|
+
/**
|
|
33182
|
+
* Editor validation of a hop list: the message for the first hop without role
|
|
33183
|
+
* or target type, or for more than {@link MAX_SCOPE_HOPS} hops; `null` when the
|
|
33184
|
+
* list can be saved (an empty list = no child scope).
|
|
33185
|
+
*/
|
|
33186
|
+
function scopeHopsValidationError(hops) {
|
|
33187
|
+
if (hops.length > MAX_SCOPE_HOPS) {
|
|
33188
|
+
return `At most ${MAX_SCOPE_HOPS} hops.`;
|
|
33189
|
+
}
|
|
33190
|
+
const index = hops.findIndex(h => !h.roleId?.trim() || !h.targetCkTypeId?.trim());
|
|
33191
|
+
return index < 0 ? null : `Hop ${index + 1} needs an association and a target type.`;
|
|
33192
|
+
}
|
|
33193
|
+
/**
|
|
33194
|
+
* The child scope the editor saves for a list of hops: none for zero hops, the
|
|
33195
|
+
* original top-level form for one hop (older library versions keep reading
|
|
33196
|
+
* it), and `path` only for two or more hops. Roles are trimmed.
|
|
33197
|
+
*/
|
|
33198
|
+
function childScopeFromHops(hops) {
|
|
33199
|
+
const clean = hops.map(h => ({
|
|
33200
|
+
roleId: h.roleId.trim(),
|
|
33201
|
+
direction: h.direction ?? 'in',
|
|
33202
|
+
targetCkTypeId: h.targetCkTypeId.trim()
|
|
33203
|
+
}));
|
|
33204
|
+
if (clean.length === 0) {
|
|
33205
|
+
return undefined;
|
|
33206
|
+
}
|
|
33207
|
+
if (clean.length === 1) {
|
|
33208
|
+
const [hop] = clean;
|
|
33209
|
+
return { targetCkTypeId: hop.targetCkTypeId, roleId: hop.roleId, direction: hop.direction };
|
|
33210
|
+
}
|
|
33211
|
+
return { path: clean };
|
|
33212
|
+
}
|
|
33213
|
+
/**
|
|
33214
|
+
* Resolves a scope path hop by hop from the picked entity: each hop is ONE
|
|
33215
|
+
* batched step over all entities the previous hop reached (the fetcher decides
|
|
33216
|
+
* how to batch), the source type of hop n is the target type of hop n-1, and
|
|
33217
|
+
* the result is the distinct rtIds of the last hop.
|
|
33218
|
+
*
|
|
33219
|
+
* - An empty hop ends the resolution: no further hop is requested and the
|
|
33220
|
+
* scope is empty (AB#7721 empty-scope rule).
|
|
33221
|
+
* - A failing hop rejects; the caller turns that into a failed scope.
|
|
33222
|
+
* - `isCurrent` is checked after every hop: when a newer pick has started, the
|
|
33223
|
+
* resolution stops and returns `null` (stale — must not be applied).
|
|
33224
|
+
*/
|
|
33225
|
+
async function resolveScopePath(pickedRtId, pickedCkTypeId, hops, fetchHop, isCurrent = () => true) {
|
|
33226
|
+
let sources = [pickedRtId];
|
|
33227
|
+
let sourceCkTypeId = pickedCkTypeId;
|
|
33228
|
+
for (const hop of hops) {
|
|
33229
|
+
const targets = await fetchHop(sources, sourceCkTypeId, hop);
|
|
33230
|
+
if (!isCurrent()) {
|
|
33231
|
+
return null;
|
|
33232
|
+
}
|
|
33233
|
+
sources = distinctRtIds(targets);
|
|
33234
|
+
if (sources.length === 0) {
|
|
33235
|
+
return [];
|
|
33236
|
+
}
|
|
33237
|
+
sourceCkTypeId = hop.targetCkTypeId;
|
|
33238
|
+
}
|
|
33239
|
+
return sources;
|
|
33240
|
+
}
|
|
33241
|
+
/** Non-empty rtIds in first-seen order, without duplicates. */
|
|
33242
|
+
function distinctRtIds(rtIds) {
|
|
33243
|
+
const seen = new Set();
|
|
33244
|
+
for (const id of rtIds) {
|
|
33245
|
+
const value = id === null || id === undefined ? '' : String(id);
|
|
33246
|
+
if (value.length > 0) {
|
|
33247
|
+
seen.add(value);
|
|
33248
|
+
}
|
|
33249
|
+
}
|
|
33250
|
+
return [...seen];
|
|
33251
|
+
}
|
|
33252
|
+
/** "Test/Site → Test/System → Test/Device" for the editor's path line. */
|
|
33253
|
+
function scopePathLabel(startCkTypeId, hops) {
|
|
33254
|
+
return [startCkTypeId, ...hops.map(h => h.targetCkTypeId || '?')].join(' → ');
|
|
33255
|
+
}
|
|
33256
|
+
function normalizeHop(hop) {
|
|
33257
|
+
const roleId = typeof hop?.roleId === 'string' ? hop.roleId.trim() : '';
|
|
33258
|
+
const targetCkTypeId = typeof hop?.targetCkTypeId === 'string' ? hop.targetCkTypeId.trim() : '';
|
|
33259
|
+
if (!roleId || !targetCkTypeId) {
|
|
33260
|
+
return null;
|
|
33261
|
+
}
|
|
33262
|
+
return { roleId, direction: hop?.direction === 'out' ? 'out' : 'in', targetCkTypeId };
|
|
33263
|
+
}
|
|
33264
|
+
|
|
33265
|
+
/**
|
|
33266
|
+
* Picker filter of an entity selector (AB#7724).
|
|
33267
|
+
*
|
|
33268
|
+
* `EntitySelectorConfig.filters` narrows what the selector's pickers list. The
|
|
33269
|
+
* rows are converted exactly like widget filters (variables substituted, AND)
|
|
33270
|
+
* and sent as GraphQL `fieldFilter` of the `runtimeEntities` query, so the
|
|
33271
|
+
* server filters — the picker never loads all entities of the type. Pure
|
|
33272
|
+
* helpers, shared by the toolbar (runtime) and the selector editor.
|
|
33273
|
+
*/
|
|
33274
|
+
/** True when the selector carries at least one filter row that applies (never for followers). */
|
|
33275
|
+
function hasPickerFilter(selector) {
|
|
33276
|
+
return !isFollowerSelector(selector) && (selector.filters?.length ?? 0) > 0;
|
|
33277
|
+
}
|
|
33278
|
+
/**
|
|
33279
|
+
* The GraphQL field filters of the selector's picker, with board variables
|
|
33280
|
+
* substituted; `undefined` when the selector has no filter or is a follower
|
|
33281
|
+
* (a follower has no picker and ignores a filter).
|
|
33282
|
+
*/
|
|
33283
|
+
function pickerFieldFilters(selector, variables, variableService) {
|
|
33284
|
+
if (!hasPickerFilter(selector)) {
|
|
33285
|
+
return undefined;
|
|
33286
|
+
}
|
|
33287
|
+
return variableService.convertToFieldFilterDto(selector.filters, variables);
|
|
33288
|
+
}
|
|
33289
|
+
/**
|
|
33290
|
+
* Wraps the picker query so every fetch carries `baseFilters` in front of the
|
|
33291
|
+
* fetch's own field filters (the text search of the dropdown and the dialog).
|
|
33292
|
+
* GraphQL combines field filters with AND, so the search works on top of the
|
|
33293
|
+
* selector filter. Without base filters the original query is returned
|
|
33294
|
+
* unchanged — an unfiltered picker sends exactly the query it sent before.
|
|
33295
|
+
*
|
|
33296
|
+
* The wrapper inherits from the original service object, so it is usable
|
|
33297
|
+
* wherever the picker data sources (octo-services) expect the query service.
|
|
33298
|
+
*/
|
|
33299
|
+
function withPickerFilters(gql, baseFilters) {
|
|
33300
|
+
if (!baseFilters?.length) {
|
|
33301
|
+
return gql;
|
|
33302
|
+
}
|
|
33303
|
+
const wrapped = Object.create(gql);
|
|
33304
|
+
wrapped.fetch = ((options) => {
|
|
33305
|
+
const variables = options?.variables;
|
|
33306
|
+
const own = variables?.fieldFilters;
|
|
33307
|
+
const ownList = own === undefined || own === null ? [] : Array.isArray(own) ? own : [own];
|
|
33308
|
+
return gql.fetch({
|
|
33309
|
+
...options,
|
|
33310
|
+
variables: { ...variables, fieldFilters: [...baseFilters, ...ownList] }
|
|
33311
|
+
});
|
|
33312
|
+
});
|
|
33313
|
+
return wrapped;
|
|
33314
|
+
}
|
|
33315
|
+
/**
|
|
33316
|
+
* Variable names a filter row still references after substitution — a board
|
|
33317
|
+
* variable without a value. Such a value is sent literally, exactly like in
|
|
33318
|
+
* widget filters (`MeshBoardVariableService.resolveVariables` keeps the
|
|
33319
|
+
* placeholder), so it usually matches nothing; the picker names the variable
|
|
33320
|
+
* in its empty-list text instead of showing an unexplained empty list.
|
|
33321
|
+
*/
|
|
33322
|
+
function unresolvedFilterVariables(filters) {
|
|
33323
|
+
const names = new Set();
|
|
33324
|
+
for (const filter of filters ?? []) {
|
|
33325
|
+
const value = typeof filter?.comparisonValue === 'string' ? filter.comparisonValue : '';
|
|
33326
|
+
for (const match of value.matchAll(/\$\{([^}]+)\}|\$([a-zA-Z_]\w*)/g)) {
|
|
33327
|
+
names.add(match[1] ?? match[2]);
|
|
33328
|
+
}
|
|
33329
|
+
}
|
|
33330
|
+
return [...names];
|
|
33331
|
+
}
|
|
33332
|
+
/**
|
|
33333
|
+
* Empty-list text of a filtered picker (wording agreed with the Studio PO):
|
|
33334
|
+
* the search alone may have found nothing, so it names the filter as a
|
|
33335
|
+
* possible reason without claiming it is the reason.
|
|
33336
|
+
*/
|
|
33337
|
+
function filteredNoEntitiesMessage(label, unresolvedVariables = []) {
|
|
33338
|
+
const name = label || 'entity';
|
|
33339
|
+
const base = `No ${name} found. This board only lists ${name} that match its filter.`;
|
|
33340
|
+
if (unresolvedVariables.length === 0) {
|
|
33341
|
+
return base;
|
|
33342
|
+
}
|
|
33343
|
+
const vars = unresolvedVariables.map(v => `$${v}`).join(', ');
|
|
33344
|
+
return `${base} The filter uses ${vars}, which has no value yet.`;
|
|
33345
|
+
}
|
|
33346
|
+
/** Title of the picker's search dialog when a filter is set. */
|
|
33347
|
+
function filteredDialogTitle(label) {
|
|
33348
|
+
return `Select ${label} (filtered)`;
|
|
33349
|
+
}
|
|
33350
|
+
|
|
33351
|
+
/** Key of the "Other role…" entry: free-text role and direction. */
|
|
33352
|
+
const OTHER_ASSOCIATION = '__other__';
|
|
33353
|
+
/**
|
|
33354
|
+
* Hop list of an entity selector's child scope (AB#7723; wireframe agreed by
|
|
33355
|
+
* Freya, AB#7723 comment 6625315). Each hop picks an association of its source
|
|
33356
|
+
* type from the CK metadata (role + direction + base target in one entry, or
|
|
33357
|
+
* "Other role…" for free text) and a target type restricted to types derived
|
|
33358
|
+
* from the association's target. The source type of hop 1 is the selector's
|
|
33359
|
+
* CK type, of every later hop the previous hop's target type. Hops can be
|
|
33360
|
+
* added (at most {@link MAX_SCOPE_HOPS}), removed and moved up/down.
|
|
33361
|
+
*
|
|
33362
|
+
* Two-way bindable; emits the hops as configured (validation is the host's,
|
|
33363
|
+
* see `scopeHopsValidationError`):
|
|
33364
|
+
* ```html
|
|
33365
|
+
* <mm-entity-selector-scope-path-editor
|
|
33366
|
+
* [sourceCkTypeId]="ckTypeId"
|
|
33367
|
+
* [(hops)]="hops">
|
|
33368
|
+
* </mm-entity-selector-scope-path-editor>
|
|
33369
|
+
* ```
|
|
33370
|
+
*/
|
|
33371
|
+
class EntitySelectorScopePathEditorComponent {
|
|
33372
|
+
rolesGQL = inject(GetCkTypeAssociationRolesDtoGQL);
|
|
33373
|
+
cdr = inject(ChangeDetectorRef);
|
|
33374
|
+
hopsChange = new EventEmitter();
|
|
33375
|
+
hopCards;
|
|
33376
|
+
rows = [];
|
|
33377
|
+
plusIcon = plusIcon;
|
|
33378
|
+
arrowUpIcon = arrowUpIcon;
|
|
33379
|
+
arrowDownIcon = arrowDownIcon;
|
|
33380
|
+
trashIcon = MM_ACTION_ICONS.delete;
|
|
33381
|
+
_sourceCkTypeId = '';
|
|
33382
|
+
lastEmitted = null;
|
|
33383
|
+
associations = new Map();
|
|
33384
|
+
/** CK type of the picked entity — the source type of hop 1. */
|
|
33385
|
+
set sourceCkTypeId(value) {
|
|
33386
|
+
this._sourceCkTypeId = value ?? '';
|
|
33387
|
+
this.syncAssociationKeys();
|
|
33388
|
+
}
|
|
33389
|
+
get sourceCkTypeId() {
|
|
33390
|
+
return this._sourceCkTypeId;
|
|
33391
|
+
}
|
|
33392
|
+
/** The configured hops (one-hop form = one hop). */
|
|
33393
|
+
set hops(value) {
|
|
33394
|
+
// Our own emission coming back through the two-way binding: keep the rows
|
|
33395
|
+
// (free-text mode) as they are.
|
|
33396
|
+
if (value && value === this.lastEmitted)
|
|
33397
|
+
return;
|
|
33398
|
+
this.rows = (value ?? []).map(h => ({
|
|
33399
|
+
roleId: h.roleId ?? '',
|
|
33400
|
+
direction: h.direction ?? 'in',
|
|
33401
|
+
targetCkTypeId: h.targetCkTypeId ?? '',
|
|
33402
|
+
targetItem: h.targetCkTypeId ? ckTypeItem(h.targetCkTypeId) : null,
|
|
33403
|
+
associationKey: ''
|
|
33404
|
+
}));
|
|
33405
|
+
this.syncAssociationKeys();
|
|
33406
|
+
}
|
|
33407
|
+
/** Maximum hop count (getter: relative imports must not initialise fields, see CLAUDE.md). */
|
|
33408
|
+
get maxHops() {
|
|
33409
|
+
return MAX_SCOPE_HOPS;
|
|
33410
|
+
}
|
|
33411
|
+
get canAddHop() {
|
|
33412
|
+
return this.rows.length < MAX_SCOPE_HOPS;
|
|
33413
|
+
}
|
|
33414
|
+
/** "Test/Site → Test/System → Test/Device". */
|
|
33415
|
+
get pathLabel() {
|
|
33416
|
+
return scopePathLabel(this.sourceCkTypeId || '?', this.rows);
|
|
33417
|
+
}
|
|
33418
|
+
/** True when the association metadata of any shown source type could not be loaded. */
|
|
33419
|
+
get metadataFailed() {
|
|
33420
|
+
return this.rows.some((_, i) => this.loadOf(i)?.state === 'failed');
|
|
33421
|
+
}
|
|
33422
|
+
/** Source type of hop `index`. */
|
|
33423
|
+
sourceTypeOf(index) {
|
|
33424
|
+
return index === 0 ? this.sourceCkTypeId : this.rows[index - 1]?.targetCkTypeId ?? '';
|
|
33425
|
+
}
|
|
33426
|
+
/** Dropdown entries of hop `index`: the source type's associations plus "Other role…". */
|
|
33427
|
+
associationOptions(index) {
|
|
33428
|
+
const load = this.loadOf(index);
|
|
33429
|
+
const options = load?.state === 'loaded' ? load.options : [];
|
|
33430
|
+
return [...options, {
|
|
33431
|
+
key: OTHER_ASSOCIATION,
|
|
33432
|
+
roleId: '',
|
|
33433
|
+
direction: 'in',
|
|
33434
|
+
baseTargetCkTypeId: '',
|
|
33435
|
+
label: 'Other role…'
|
|
33436
|
+
}];
|
|
33437
|
+
}
|
|
33438
|
+
/** True when hop `index` offers the association dropdown (metadata loaded with ≥1 association). */
|
|
33439
|
+
hasAssociationList(index) {
|
|
33440
|
+
const load = this.loadOf(index);
|
|
33441
|
+
return load?.state === 'loaded' && load.options.length > 0;
|
|
33442
|
+
}
|
|
33443
|
+
/** True when hop `index` shows the free-text role and direction fields. */
|
|
33444
|
+
isFreeText(index) {
|
|
33445
|
+
return !this.hasAssociationList(index) || this.rows[index].associationKey === OTHER_ASSOCIATION;
|
|
33446
|
+
}
|
|
33447
|
+
/** Base type the target-type picker of hop `index` is restricted to (undefined = any type). */
|
|
33448
|
+
baseTargetOf(index) {
|
|
33449
|
+
const row = this.rows[index];
|
|
33450
|
+
const option = this.associationOptions(index).find(o => o.key === row.associationKey && o.key !== OTHER_ASSOCIATION);
|
|
33451
|
+
return option?.baseTargetCkTypeId || undefined;
|
|
33452
|
+
}
|
|
33453
|
+
/**
|
|
33454
|
+
* Warning of hop `index` when its role is not an association of its (new)
|
|
33455
|
+
* source type — after a reorder, a removal or a changed earlier target. The
|
|
33456
|
+
* values are kept; it does not block saving.
|
|
33457
|
+
*/
|
|
33458
|
+
associationWarning(index) {
|
|
33459
|
+
const row = this.rows[index];
|
|
33460
|
+
if (!row.roleId.trim() || !this.hasAssociationList(index))
|
|
33461
|
+
return '';
|
|
33462
|
+
const load = this.loadOf(index);
|
|
33463
|
+
const defined = load?.state === 'loaded'
|
|
33464
|
+
&& load.options.some(o => o.roleId === row.roleId.trim() && o.direction === row.direction);
|
|
33465
|
+
return defined ? '' : `Association ${row.roleId.trim()} is not defined on ${this.sourceTypeOf(index) || 'the source type'}.`;
|
|
33466
|
+
}
|
|
33467
|
+
/** Validation message of hop `index`: an incomplete hop blocks saving (see `scopeHopsValidationError`). */
|
|
33468
|
+
hopError(index) {
|
|
33469
|
+
const row = this.rows[index];
|
|
33470
|
+
return !row.roleId.trim() || !row.targetCkTypeId.trim()
|
|
33471
|
+
? `Hop ${index + 1} needs an association and a target type.`
|
|
33472
|
+
: '';
|
|
33473
|
+
}
|
|
33474
|
+
isRoleInvalid(index) {
|
|
33475
|
+
return !this.rows[index].roleId.trim();
|
|
33476
|
+
}
|
|
33477
|
+
isTargetInvalid(index) {
|
|
33478
|
+
return !this.rows[index].targetCkTypeId.trim();
|
|
33479
|
+
}
|
|
33480
|
+
addHop() {
|
|
33481
|
+
if (!this.canAddHop)
|
|
33482
|
+
return;
|
|
33483
|
+
this.rows = [...this.rows, {
|
|
33484
|
+
roleId: 'System/ParentChild',
|
|
33485
|
+
direction: 'in',
|
|
33486
|
+
targetCkTypeId: '',
|
|
33487
|
+
targetItem: null,
|
|
33488
|
+
associationKey: ''
|
|
33489
|
+
}];
|
|
33490
|
+
this.syncAssociationKeys();
|
|
33491
|
+
this.emit();
|
|
33492
|
+
}
|
|
33493
|
+
removeHop(index) {
|
|
33494
|
+
this.rows = this.rows.filter((_, i) => i !== index);
|
|
33495
|
+
this.syncAssociationKeys();
|
|
33496
|
+
this.emit();
|
|
33497
|
+
}
|
|
33498
|
+
moveHop(index, delta) {
|
|
33499
|
+
const target = index + delta;
|
|
33500
|
+
if (target < 0 || target >= this.rows.length)
|
|
33501
|
+
return;
|
|
33502
|
+
const rows = [...this.rows];
|
|
33503
|
+
[rows[index], rows[target]] = [rows[target], rows[index]];
|
|
33504
|
+
this.rows = rows;
|
|
33505
|
+
this.syncAssociationKeys();
|
|
33506
|
+
this.emit();
|
|
33507
|
+
this.focusHop(target);
|
|
33508
|
+
}
|
|
33509
|
+
onAssociationChanged(index, key) {
|
|
33510
|
+
const row = this.rows[index];
|
|
33511
|
+
row.associationKey = key;
|
|
33512
|
+
const option = this.associationOptions(index).find(o => o.key === key);
|
|
33513
|
+
if (option && key !== OTHER_ASSOCIATION) {
|
|
33514
|
+
row.roleId = option.roleId;
|
|
33515
|
+
row.direction = option.direction;
|
|
33516
|
+
}
|
|
33517
|
+
this.emit();
|
|
33518
|
+
}
|
|
33519
|
+
onRoleChanged(index, roleId) {
|
|
33520
|
+
this.rows[index].roleId = roleId;
|
|
33521
|
+
this.emit();
|
|
33522
|
+
}
|
|
33523
|
+
onDirectionChanged(index, direction) {
|
|
33524
|
+
this.rows[index].direction = direction;
|
|
33525
|
+
this.emit();
|
|
33526
|
+
}
|
|
33527
|
+
onTargetSelected(index, ckType) {
|
|
33528
|
+
const row = this.rows[index];
|
|
33529
|
+
row.targetCkTypeId = ckType.rtCkTypeId;
|
|
33530
|
+
row.targetItem = ckType;
|
|
33531
|
+
// Later hops keep their values; their source type changed, so re-match them.
|
|
33532
|
+
this.syncAssociationKeys();
|
|
33533
|
+
this.emit();
|
|
33534
|
+
}
|
|
33535
|
+
onTargetCleared(index) {
|
|
33536
|
+
const row = this.rows[index];
|
|
33537
|
+
row.targetCkTypeId = '';
|
|
33538
|
+
row.targetItem = null;
|
|
33539
|
+
this.syncAssociationKeys();
|
|
33540
|
+
this.emit();
|
|
33541
|
+
}
|
|
33542
|
+
emit() {
|
|
33543
|
+
this.lastEmitted = this.rows.map(r => ({ roleId: r.roleId, direction: r.direction, targetCkTypeId: r.targetCkTypeId }));
|
|
33544
|
+
this.hopsChange.emit(this.lastEmitted);
|
|
33545
|
+
}
|
|
33546
|
+
loadOf(index) {
|
|
33547
|
+
const type = this.sourceTypeOf(index);
|
|
33548
|
+
return type ? this.associations.get(type) : undefined;
|
|
33549
|
+
}
|
|
33550
|
+
/**
|
|
33551
|
+
* Matches every hop's role/direction to an entry of its source type's
|
|
33552
|
+
* associations (loading missing metadata); a hop whose association is not
|
|
33553
|
+
* listed switches to "Other role…" with its values kept.
|
|
33554
|
+
*/
|
|
33555
|
+
syncAssociationKeys() {
|
|
33556
|
+
this.rows.forEach((row, index) => {
|
|
33557
|
+
const type = this.sourceTypeOf(index);
|
|
33558
|
+
if (type && !this.associations.has(type)) {
|
|
33559
|
+
void this.loadAssociations(type);
|
|
33560
|
+
}
|
|
33561
|
+
const load = this.loadOf(index);
|
|
33562
|
+
if (load?.state !== 'loaded' || load.options.length === 0) {
|
|
33563
|
+
row.associationKey = '';
|
|
33564
|
+
return;
|
|
33565
|
+
}
|
|
33566
|
+
const match = load.options.find(o => o.roleId === row.roleId.trim() && o.direction === row.direction);
|
|
33567
|
+
row.associationKey = match ? match.key : (row.roleId.trim() ? OTHER_ASSOCIATION : '');
|
|
33568
|
+
});
|
|
33569
|
+
}
|
|
33570
|
+
async loadAssociations(ckTypeId) {
|
|
33571
|
+
this.associations.set(ckTypeId, { state: 'loading' });
|
|
33572
|
+
try {
|
|
33573
|
+
const result = await firstValueFrom(this.rolesGQL.fetch({ variables: { ckTypeId } }));
|
|
33574
|
+
const ckType = result.data?.constructionKit?.types?.items?.[0];
|
|
33575
|
+
const options = [];
|
|
33576
|
+
const add = (direction, all) => {
|
|
33577
|
+
for (const assoc of all ?? []) {
|
|
33578
|
+
const roleId = assoc?.rtRoleId ? String(assoc.rtRoleId) : '';
|
|
33579
|
+
if (!roleId)
|
|
33580
|
+
continue;
|
|
33581
|
+
const base = assoc?.rtTargetCkTypeId ? String(assoc.rtTargetCkTypeId) : '';
|
|
33582
|
+
const key = `${direction}|${roleId}|${base}`;
|
|
33583
|
+
if (options.some(o => o.key === key))
|
|
33584
|
+
continue;
|
|
33585
|
+
options.push({
|
|
33586
|
+
key,
|
|
33587
|
+
roleId,
|
|
33588
|
+
direction,
|
|
33589
|
+
baseTargetCkTypeId: base,
|
|
33590
|
+
label: `${roleId} · ${direction === 'in' ? 'inbound' : 'outbound'}${base ? ` · ${base}` : ''}`
|
|
33591
|
+
});
|
|
33592
|
+
}
|
|
33593
|
+
};
|
|
33594
|
+
add('in', ckType?.associations?.in?.all);
|
|
33595
|
+
add('out', ckType?.associations?.out?.all);
|
|
33596
|
+
this.associations.set(ckTypeId, { state: 'loaded', options });
|
|
33597
|
+
}
|
|
33598
|
+
catch (error) {
|
|
33599
|
+
console.warn(`Could not load the associations of ${ckTypeId}:`, error);
|
|
33600
|
+
this.associations.set(ckTypeId, { state: 'failed' });
|
|
33601
|
+
}
|
|
33602
|
+
this.syncAssociationKeys();
|
|
33603
|
+
this.cdr.markForCheck();
|
|
33604
|
+
}
|
|
33605
|
+
/** Keeps the keyboard focus on a moved hop (wireframe change 4). */
|
|
33606
|
+
focusHop(index) {
|
|
33607
|
+
setTimeout(() => {
|
|
33608
|
+
const card = this.hopCards?.get(index)?.nativeElement;
|
|
33609
|
+
const button = card?.querySelector(`[data-hop-action="${index > 0 ? 'up' : 'down'}"]`);
|
|
33610
|
+
(button ?? card)?.focus();
|
|
33611
|
+
});
|
|
33612
|
+
}
|
|
33613
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.5", ngImport: i0, type: EntitySelectorScopePathEditorComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
33614
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.1.5", type: EntitySelectorScopePathEditorComponent, isStandalone: true, selector: "mm-entity-selector-scope-path-editor", inputs: { sourceCkTypeId: "sourceCkTypeId", hops: "hops" }, outputs: { hopsChange: "hopsChange" }, viewQueries: [{ propertyName: "hopCards", predicate: ["hopCard"], descendants: true }], ngImport: i0, template: "<div class=\"scope-path-editor\">\n <span class=\"field-hint\">\n Optional. Path of associations from the picked entity to the entities whose\n rtIds scope bound stream-data widgets (up to {{ maxHops }} hops).\n </span>\n\n @if (rows.length === 0) {\n <p class=\"scope-empty\" data-test=\"scope-empty\">No child scope: widgets use the picked entity's own rtId.</p>\n } @else {\n <span class=\"scope-from\">From: <code>{{ sourceCkTypeId || '?' }}</code> (picked entity)</span>\n @if (metadataFailed) {\n <span class=\"field-hint scope-metadata-failed\" data-test=\"metadata-failed\">\n Associations could not be loaded; enter the role manually.\n </span>\n }\n\n @for (row of rows; track $index; let i = $index; let first = $first; let last = $last) {\n <div class=\"hop-card\" #hopCard tabindex=\"-1\" [attr.data-test]=\"'hop-' + (i + 1)\">\n <div class=\"hop-header\">\n <span class=\"hop-title\">\n Hop {{ i + 1 }}\n @if (i > 0) {\n <span class=\"hop-source\">(from <code>{{ sourceTypeOf(i) || '?' }}</code>)</span>\n }\n </span>\n <span class=\"hop-actions\">\n @if (!first) {\n <button kendoButton fillMode=\"flat\" size=\"small\" [svgIcon]=\"arrowUpIcon\"\n data-hop-action=\"up\"\n [attr.aria-label]=\"'Move hop ' + (i + 1) + ' up'\" [title]=\"'Move hop ' + (i + 1) + ' up'\"\n (click)=\"moveHop(i, -1)\"></button>\n }\n @if (!last) {\n <button kendoButton fillMode=\"flat\" size=\"small\" [svgIcon]=\"arrowDownIcon\"\n data-hop-action=\"down\"\n [attr.aria-label]=\"'Move hop ' + (i + 1) + ' down'\" [title]=\"'Move hop ' + (i + 1) + ' down'\"\n (click)=\"moveHop(i, 1)\"></button>\n }\n <button kendoButton fillMode=\"flat\" size=\"small\" [svgIcon]=\"trashIcon\" class=\"hop-remove\"\n data-hop-action=\"remove\"\n [attr.aria-label]=\"'Remove hop ' + (i + 1)\" [title]=\"'Remove hop ' + (i + 1)\"\n (click)=\"removeHop(i)\"></button>\n </span>\n </div>\n\n <div class=\"hop-field\">\n <label class=\"field-label\" [attr.for]=\"'hop-assoc-' + i\">Association</label>\n @if (hasAssociationList(i)) {\n <kendo-dropdownlist\n [id]=\"'hop-assoc-' + i\"\n [data]=\"associationOptions(i)\"\n textField=\"label\"\n valueField=\"key\"\n [valuePrimitive]=\"true\"\n [value]=\"row.associationKey || null\"\n [defaultItem]=\"{ key: '', label: 'Select an association\u2026' }\"\n [class.k-invalid]=\"isRoleInvalid(i)\"\n (valueChange)=\"onAssociationChanged(i, $event)\">\n <ng-template kendoDropDownListValueTemplate let-item>\n <span class=\"assoc-text\" [title]=\"item?.label ?? ''\">{{ item?.label }}</span>\n </ng-template>\n <ng-template kendoDropDownListItemTemplate let-item>\n <span class=\"assoc-text\" [title]=\"item.label\">{{ item.label }}</span>\n </ng-template>\n </kendo-dropdownlist>\n }\n @if (isFreeText(i)) {\n <div class=\"hop-free-text\">\n <kendo-textbox\n [id]=\"hasAssociationList(i) ? null : 'hop-assoc-' + i\"\n [value]=\"row.roleId\"\n placeholder=\"Association role (e.g. System/ParentChild)\"\n [class.k-invalid]=\"isRoleInvalid(i)\"\n (valueChange)=\"onRoleChanged(i, $event)\">\n </kendo-textbox>\n <select class=\"hop-direction\" [attr.aria-label]=\"'Direction of hop ' + (i + 1)\"\n [ngModel]=\"row.direction\" (ngModelChange)=\"onDirectionChanged(i, $event)\">\n <option value=\"in\">Inbound</option>\n <option value=\"out\">Outbound</option>\n </select>\n </div>\n }\n @if (associationWarning(i); as warning) {\n <span class=\"field-warning\" data-test=\"association-warning\">{{ warning }}</span>\n }\n </div>\n\n <div class=\"hop-field\">\n <label class=\"field-label\">Target type</label>\n <mm-ck-type-selector-input\n [ngModel]=\"row.targetItem\"\n [derivedFromRtCkTypeId]=\"baseTargetOf(i)\"\n [placeholder]=\"'Target CK type\u2026'\"\n [class.k-invalid]=\"isTargetInvalid(i)\"\n (ckTypeSelected)=\"onTargetSelected(i, $event)\"\n (ckTypeCleared)=\"onTargetCleared(i)\">\n </mm-ck-type-selector-input>\n </div>\n\n @if (hopError(i); as error) {\n <span class=\"field-error\" data-test=\"hop-error\">{{ error }}</span>\n }\n </div>\n }\n }\n\n <div class=\"scope-footer\">\n <button kendoButton fillMode=\"flat\" [svgIcon]=\"plusIcon\" [disabled]=\"!canAddHop\"\n data-test=\"add-hop\" (click)=\"addHop()\">Add hop</button>\n @if (!canAddHop) {\n <span class=\"field-hint\">At most {{ maxHops }} hops.</span>\n }\n </div>\n\n @if (rows.length > 0) {\n <span class=\"scope-path\" data-test=\"scope-path\">Path: {{ pathLabel }}</span>\n }\n</div>\n", styles: [".scope-path-editor{display:flex;flex-direction:column;gap:.5rem;min-width:0}.field-label{font-weight:500;font-size:.875rem;color:var(--kendo-color-on-app-surface, #424242)}.field-hint,.scope-from,.scope-empty{font-size:.75rem;color:var(--kendo-color-subtle, #757575);margin:0}.field-error{font-size:.75rem;color:var(--kendo-color-error, #f44336)}.field-warning{font-size:.75rem;color:var(--kendo-color-warning, #b26a00)}code{font-family:var(--theme-font-mono, monospace)}.hop-card{display:flex;flex-direction:column;gap:.5rem;padding:.5rem .75rem .75rem;border:1px solid var(--kendo-color-border, #e0e0e0);border-radius:4px;background:var(--kendo-color-surface-alt, #fafafa);min-width:0}.hop-header{display:flex;align-items:center;justify-content:space-between;gap:.5rem}.hop-title{font-weight:500;font-size:.875rem;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.hop-source{font-weight:400;color:var(--kendo-color-subtle, #757575)}.hop-actions{display:flex;flex-wrap:nowrap;flex-shrink:0}.hop-remove{color:var(--kendo-color-error, #f44336)}.hop-field{display:flex;flex-direction:column;gap:.25rem;min-width:0}.hop-field kendo-dropdownlist{width:100%;min-width:0}.assoc-text{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.hop-free-text{display:flex;gap:.5rem;min-width:0}.hop-free-text kendo-textbox{flex:1 1 auto;min-width:0}.hop-direction{flex:0 0 auto}.scope-footer{display:flex;align-items:center;gap:.5rem}.scope-path{font-size:.75rem;font-family:var(--theme-font-mono, monospace);color:var(--kendo-color-on-app-surface, #424242);overflow-wrap:break-word}\n"], dependencies: [{ kind: "ngmodule", type: FormsModule }, { kind: "directive", type: i1$1.NgSelectOption, selector: "option", inputs: ["ngValue", "value"] }, { kind: "directive", type: i1$1.ɵNgSelectMultipleOption, selector: "option", inputs: ["ngValue", "value"] }, { kind: "directive", type: i1$1.SelectControlValueAccessor, selector: "select:not([multiple]):not([ngNoCva])[formControlName],select:not([multiple]):not([ngNoCva])[formControl],select:not([multiple]):not([ngNoCva])[ngModel]", inputs: ["compareWith"] }, { kind: "directive", type: i1$1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1$1.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }, { kind: "ngmodule", type: ButtonModule }, { kind: "component", type: i2.ButtonComponent, selector: "button[kendoButton]", inputs: ["arrowIcon", "toggleable", "togglable", "selected", "tabIndex", "imageUrl", "iconPosition", "iconClass", "icon", "disabled", "size", "rounded", "fillMode", "themeColor", "svgIcon"], outputs: ["selectedChange", "click"], exportAs: ["kendoButton"] }, { kind: "ngmodule", type: DropDownsModule }, { kind: "directive", type: i4.ItemTemplateDirective, selector: "[kendoDropDownListItemTemplate],[kendoComboBoxItemTemplate],[kendoAutoCompleteItemTemplate],[kendoMultiSelectItemTemplate]" }, { kind: "component", type: i4.DropDownListComponent, selector: "kendo-dropdownlist", inputs: ["customIconClass", "showStickyHeader", "icon", "svgIcon", "loading", "data", "value", "textField", "valueField", "adaptiveMode", "adaptiveTitle", "adaptiveSubtitle", "popupSettings", "listHeight", "defaultItem", "disabled", "itemDisabled", "readonly", "filterable", "virtual", "ignoreCase", "delay", "valuePrimitive", "tabindex", "tabIndex", "size", "rounded", "fillMode", "leftRightArrowsNavigation", "id"], outputs: ["valueChange", "filterChange", "selectionChange", "open", "opened", "close", "closed", "focus", "blur"], exportAs: ["kendoDropDownList"] }, { kind: "directive", type: i4.ValueTemplateDirective, selector: "[kendoDropDownListValueTemplate],[kendoDropDownTreeValueTemplate]" }, { kind: "ngmodule", type: InputsModule }, { kind: "component", type: i3.TextBoxComponent, selector: "kendo-textbox", inputs: ["focusableId", "title", "type", "disabled", "readonly", "tabindex", "value", "selectOnFocus", "showSuccessIcon", "showErrorIcon", "clearButton", "successIcon", "successSvgIcon", "errorIcon", "errorSvgIcon", "clearButtonIcon", "clearButtonSvgIcon", "size", "rounded", "fillMode", "tabIndex", "placeholder", "maxlength", "inputAttributes"], outputs: ["valueChange", "inputFocus", "inputBlur", "focus", "blur"], exportAs: ["kendoTextBox"] }, { kind: "component", type: CkTypeSelectorInputComponent, selector: "mm-ck-type-selector-input", inputs: ["placeholder", "minSearchLength", "maxResults", "debounceMs", "ckModelIds", "allowAbstract", "dialogTitle", "advancedSearchLabel", "derivedFromRtCkTypeId", "messages", "dialogMessages", "disabled", "required"], outputs: ["ckTypeSelected", "ckTypeCleared"] }], changeDetection: i0.ChangeDetectionStrategy.Eager });
|
|
33615
|
+
}
|
|
33616
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.5", ngImport: i0, type: EntitySelectorScopePathEditorComponent, decorators: [{
|
|
33617
|
+
type: Component,
|
|
33618
|
+
args: [{ selector: 'mm-entity-selector-scope-path-editor', standalone: true, imports: [FormsModule, ButtonModule, DropDownsModule, InputsModule, CkTypeSelectorInputComponent], changeDetection: ChangeDetectionStrategy.Eager, template: "<div class=\"scope-path-editor\">\n <span class=\"field-hint\">\n Optional. Path of associations from the picked entity to the entities whose\n rtIds scope bound stream-data widgets (up to {{ maxHops }} hops).\n </span>\n\n @if (rows.length === 0) {\n <p class=\"scope-empty\" data-test=\"scope-empty\">No child scope: widgets use the picked entity's own rtId.</p>\n } @else {\n <span class=\"scope-from\">From: <code>{{ sourceCkTypeId || '?' }}</code> (picked entity)</span>\n @if (metadataFailed) {\n <span class=\"field-hint scope-metadata-failed\" data-test=\"metadata-failed\">\n Associations could not be loaded; enter the role manually.\n </span>\n }\n\n @for (row of rows; track $index; let i = $index; let first = $first; let last = $last) {\n <div class=\"hop-card\" #hopCard tabindex=\"-1\" [attr.data-test]=\"'hop-' + (i + 1)\">\n <div class=\"hop-header\">\n <span class=\"hop-title\">\n Hop {{ i + 1 }}\n @if (i > 0) {\n <span class=\"hop-source\">(from <code>{{ sourceTypeOf(i) || '?' }}</code>)</span>\n }\n </span>\n <span class=\"hop-actions\">\n @if (!first) {\n <button kendoButton fillMode=\"flat\" size=\"small\" [svgIcon]=\"arrowUpIcon\"\n data-hop-action=\"up\"\n [attr.aria-label]=\"'Move hop ' + (i + 1) + ' up'\" [title]=\"'Move hop ' + (i + 1) + ' up'\"\n (click)=\"moveHop(i, -1)\"></button>\n }\n @if (!last) {\n <button kendoButton fillMode=\"flat\" size=\"small\" [svgIcon]=\"arrowDownIcon\"\n data-hop-action=\"down\"\n [attr.aria-label]=\"'Move hop ' + (i + 1) + ' down'\" [title]=\"'Move hop ' + (i + 1) + ' down'\"\n (click)=\"moveHop(i, 1)\"></button>\n }\n <button kendoButton fillMode=\"flat\" size=\"small\" [svgIcon]=\"trashIcon\" class=\"hop-remove\"\n data-hop-action=\"remove\"\n [attr.aria-label]=\"'Remove hop ' + (i + 1)\" [title]=\"'Remove hop ' + (i + 1)\"\n (click)=\"removeHop(i)\"></button>\n </span>\n </div>\n\n <div class=\"hop-field\">\n <label class=\"field-label\" [attr.for]=\"'hop-assoc-' + i\">Association</label>\n @if (hasAssociationList(i)) {\n <kendo-dropdownlist\n [id]=\"'hop-assoc-' + i\"\n [data]=\"associationOptions(i)\"\n textField=\"label\"\n valueField=\"key\"\n [valuePrimitive]=\"true\"\n [value]=\"row.associationKey || null\"\n [defaultItem]=\"{ key: '', label: 'Select an association\u2026' }\"\n [class.k-invalid]=\"isRoleInvalid(i)\"\n (valueChange)=\"onAssociationChanged(i, $event)\">\n <ng-template kendoDropDownListValueTemplate let-item>\n <span class=\"assoc-text\" [title]=\"item?.label ?? ''\">{{ item?.label }}</span>\n </ng-template>\n <ng-template kendoDropDownListItemTemplate let-item>\n <span class=\"assoc-text\" [title]=\"item.label\">{{ item.label }}</span>\n </ng-template>\n </kendo-dropdownlist>\n }\n @if (isFreeText(i)) {\n <div class=\"hop-free-text\">\n <kendo-textbox\n [id]=\"hasAssociationList(i) ? null : 'hop-assoc-' + i\"\n [value]=\"row.roleId\"\n placeholder=\"Association role (e.g. System/ParentChild)\"\n [class.k-invalid]=\"isRoleInvalid(i)\"\n (valueChange)=\"onRoleChanged(i, $event)\">\n </kendo-textbox>\n <select class=\"hop-direction\" [attr.aria-label]=\"'Direction of hop ' + (i + 1)\"\n [ngModel]=\"row.direction\" (ngModelChange)=\"onDirectionChanged(i, $event)\">\n <option value=\"in\">Inbound</option>\n <option value=\"out\">Outbound</option>\n </select>\n </div>\n }\n @if (associationWarning(i); as warning) {\n <span class=\"field-warning\" data-test=\"association-warning\">{{ warning }}</span>\n }\n </div>\n\n <div class=\"hop-field\">\n <label class=\"field-label\">Target type</label>\n <mm-ck-type-selector-input\n [ngModel]=\"row.targetItem\"\n [derivedFromRtCkTypeId]=\"baseTargetOf(i)\"\n [placeholder]=\"'Target CK type\u2026'\"\n [class.k-invalid]=\"isTargetInvalid(i)\"\n (ckTypeSelected)=\"onTargetSelected(i, $event)\"\n (ckTypeCleared)=\"onTargetCleared(i)\">\n </mm-ck-type-selector-input>\n </div>\n\n @if (hopError(i); as error) {\n <span class=\"field-error\" data-test=\"hop-error\">{{ error }}</span>\n }\n </div>\n }\n }\n\n <div class=\"scope-footer\">\n <button kendoButton fillMode=\"flat\" [svgIcon]=\"plusIcon\" [disabled]=\"!canAddHop\"\n data-test=\"add-hop\" (click)=\"addHop()\">Add hop</button>\n @if (!canAddHop) {\n <span class=\"field-hint\">At most {{ maxHops }} hops.</span>\n }\n </div>\n\n @if (rows.length > 0) {\n <span class=\"scope-path\" data-test=\"scope-path\">Path: {{ pathLabel }}</span>\n }\n</div>\n", styles: [".scope-path-editor{display:flex;flex-direction:column;gap:.5rem;min-width:0}.field-label{font-weight:500;font-size:.875rem;color:var(--kendo-color-on-app-surface, #424242)}.field-hint,.scope-from,.scope-empty{font-size:.75rem;color:var(--kendo-color-subtle, #757575);margin:0}.field-error{font-size:.75rem;color:var(--kendo-color-error, #f44336)}.field-warning{font-size:.75rem;color:var(--kendo-color-warning, #b26a00)}code{font-family:var(--theme-font-mono, monospace)}.hop-card{display:flex;flex-direction:column;gap:.5rem;padding:.5rem .75rem .75rem;border:1px solid var(--kendo-color-border, #e0e0e0);border-radius:4px;background:var(--kendo-color-surface-alt, #fafafa);min-width:0}.hop-header{display:flex;align-items:center;justify-content:space-between;gap:.5rem}.hop-title{font-weight:500;font-size:.875rem;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.hop-source{font-weight:400;color:var(--kendo-color-subtle, #757575)}.hop-actions{display:flex;flex-wrap:nowrap;flex-shrink:0}.hop-remove{color:var(--kendo-color-error, #f44336)}.hop-field{display:flex;flex-direction:column;gap:.25rem;min-width:0}.hop-field kendo-dropdownlist{width:100%;min-width:0}.assoc-text{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.hop-free-text{display:flex;gap:.5rem;min-width:0}.hop-free-text kendo-textbox{flex:1 1 auto;min-width:0}.hop-direction{flex:0 0 auto}.scope-footer{display:flex;align-items:center;gap:.5rem}.scope-path{font-size:.75rem;font-family:var(--theme-font-mono, monospace);color:var(--kendo-color-on-app-surface, #424242);overflow-wrap:break-word}\n"] }]
|
|
33619
|
+
}], propDecorators: { hopsChange: [{
|
|
33620
|
+
type: Output
|
|
33621
|
+
}], hopCards: [{
|
|
33622
|
+
type: ViewChildren,
|
|
33623
|
+
args: ['hopCard']
|
|
33624
|
+
}], sourceCkTypeId: [{
|
|
33625
|
+
type: Input
|
|
33626
|
+
}], hops: [{
|
|
33627
|
+
type: Input
|
|
33628
|
+
}] } });
|
|
33629
|
+
function ckTypeItem(ckTypeId) {
|
|
33630
|
+
return { fullName: ckTypeId, rtCkTypeId: ckTypeId, isAbstract: false, isFinal: false };
|
|
33631
|
+
}
|
|
33632
|
+
|
|
32108
33633
|
/**
|
|
32109
33634
|
* Component for editing entity selector configurations in MeshBoard settings.
|
|
32110
33635
|
* Allows adding, editing, and removing entity selectors.
|
|
@@ -32115,9 +33640,17 @@ class EntitySelectorEditorComponent {
|
|
|
32115
33640
|
attributeSelectorService = inject(AttributeSelectorDialogService);
|
|
32116
33641
|
variableService = inject(MeshBoardVariableService);
|
|
32117
33642
|
getEntitiesByCkTypeGQL = inject(GetEntitiesByCkTypeDtoGQL);
|
|
33643
|
+
confirmationService = inject(ConfirmationService, { optional: true });
|
|
33644
|
+
cdr = inject(ChangeDetectorRef);
|
|
33645
|
+
destroyRef = inject(DestroyRef);
|
|
32118
33646
|
entitySelectors = [];
|
|
32119
33647
|
/** Existing variable names from other sources (static variables, other selectors) for duplicate detection */
|
|
32120
33648
|
existingVariableNames = [];
|
|
33649
|
+
/**
|
|
33650
|
+
* The board's static variables (AB#7724): offered in the picker filter and
|
|
33651
|
+
* substituted when the editor lists default entities and checks the default.
|
|
33652
|
+
*/
|
|
33653
|
+
variables = [];
|
|
32121
33654
|
entitySelectorsChange = new EventEmitter();
|
|
32122
33655
|
/** Emits true when entering edit mode, false when leaving — allows parent to hide its own buttons */
|
|
32123
33656
|
editingStateChange = new EventEmitter();
|
|
@@ -32134,18 +33667,32 @@ class EntitySelectorEditorComponent {
|
|
|
32134
33667
|
editMappings = [];
|
|
32135
33668
|
editShowInToolbar = true;
|
|
32136
33669
|
editDefaultRtId = '';
|
|
32137
|
-
// Child scope (stream-data):
|
|
32138
|
-
//
|
|
32139
|
-
|
|
32140
|
-
|
|
32141
|
-
|
|
32142
|
-
|
|
32143
|
-
|
|
32144
|
-
|
|
32145
|
-
|
|
33670
|
+
// Child scope (stream-data, AB#7723): the hops from the picked entity to the
|
|
33671
|
+
// entities whose rtIds scope bound widgets. Empty = the picked entity itself.
|
|
33672
|
+
// One hop saves in the original top-level form, two or more as `path`.
|
|
33673
|
+
editScopeHops = [];
|
|
33674
|
+
// Leader/follower (AB#7722): id of the selector this one follows ('' = none).
|
|
33675
|
+
editFollowsSelectorId = '';
|
|
33676
|
+
/** Why choosing the leader changed the form (CK type fixed, mappings/default cleared). */
|
|
33677
|
+
followNotice = '';
|
|
33678
|
+
// Picker filter (AB#7724): attribute rows on the CK type, AND-combined.
|
|
33679
|
+
editFilters = [];
|
|
33680
|
+
/** Filter rows a follower carries from hand-editing; ignored and dropped on save. */
|
|
33681
|
+
followerFilterCount = 0;
|
|
33682
|
+
/** Result of the server check whether the default entity matches the filter. */
|
|
33683
|
+
defaultFilterCheck = { state: 'none' };
|
|
33684
|
+
defaultCheckRequests = new Subject();
|
|
32146
33685
|
// Data sources for the default entity picker (created when CK type is set)
|
|
32147
33686
|
defaultEntityDataSource = null;
|
|
32148
33687
|
defaultEntityDialogDataSource = null;
|
|
33688
|
+
constructor() {
|
|
33689
|
+
// Debounced so typing in the filter grid does not fire a request per key;
|
|
33690
|
+
// switchMap drops the answer of an outdated check.
|
|
33691
|
+
this.defaultCheckRequests.pipe(debounceTime(DEFAULT_CHECK_DEBOUNCE_MS), switchMap(request => 'state' in request ? of(request) : this.checkDefaultEntity(request)), takeUntilDestroyed(this.destroyRef)).subscribe(check => {
|
|
33692
|
+
this.defaultFilterCheck = check;
|
|
33693
|
+
this.cdr.markForCheck();
|
|
33694
|
+
});
|
|
33695
|
+
}
|
|
32149
33696
|
/**
|
|
32150
33697
|
* Starts adding a new entity selector.
|
|
32151
33698
|
*/
|
|
@@ -32158,10 +33705,12 @@ class EntitySelectorEditorComponent {
|
|
|
32158
33705
|
this.editMappings = [];
|
|
32159
33706
|
this.editShowInToolbar = true;
|
|
32160
33707
|
this.editDefaultRtId = '';
|
|
32161
|
-
this.
|
|
32162
|
-
this.
|
|
32163
|
-
this.
|
|
32164
|
-
this.
|
|
33708
|
+
this.editScopeHops = [];
|
|
33709
|
+
this.editFollowsSelectorId = '';
|
|
33710
|
+
this.followNotice = '';
|
|
33711
|
+
this.editFilters = [];
|
|
33712
|
+
this.followerFilterCount = 0;
|
|
33713
|
+
this.defaultFilterCheck = { state: 'none' };
|
|
32165
33714
|
this.defaultEntityDataSource = null;
|
|
32166
33715
|
this.defaultEntityDialogDataSource = null;
|
|
32167
33716
|
this.isEditing = true;
|
|
@@ -32186,38 +33735,125 @@ class EntitySelectorEditorComponent {
|
|
|
32186
33735
|
this.editMappings = [...selector.attributeMappings];
|
|
32187
33736
|
this.editShowInToolbar = selector.showInToolbar !== false;
|
|
32188
33737
|
this.editDefaultRtId = selector.defaultRtId ?? '';
|
|
32189
|
-
this.
|
|
32190
|
-
this.
|
|
32191
|
-
|
|
32192
|
-
|
|
32193
|
-
|
|
32194
|
-
|
|
32195
|
-
|
|
32196
|
-
|
|
32197
|
-
|
|
32198
|
-
this.
|
|
32199
|
-
this.
|
|
33738
|
+
this.editScopeHops = scopeHopsOf(selector.childScope);
|
|
33739
|
+
this.editFollowsSelectorId = selector.followsSelectorId ?? '';
|
|
33740
|
+
this.followNotice = '';
|
|
33741
|
+
this.editFilters = (selector.filters ?? []).map((f, i) => ({
|
|
33742
|
+
id: i + 1,
|
|
33743
|
+
attributePath: f.attributePath,
|
|
33744
|
+
operator: f.operator,
|
|
33745
|
+
comparisonValue: f.comparisonValue
|
|
33746
|
+
}));
|
|
33747
|
+
this.followerFilterCount = selector.followsSelectorId ? this.editFilters.length : 0;
|
|
33748
|
+
this.defaultFilterCheck = { state: 'none' };
|
|
33749
|
+
// A follower picks from its leader's CK type; re-sync it in case the leader's
|
|
33750
|
+
// type changed after the follower was saved.
|
|
33751
|
+
const leader = resolveFollowLeader(this.entitySelectors, selector);
|
|
33752
|
+
if (leader.kind === 'leader' && leader.leader.ckTypeId !== this.editCkTypeId) {
|
|
33753
|
+
this.applyLeaderCkType(leader.leader.ckTypeId);
|
|
33754
|
+
}
|
|
32200
33755
|
this.updateDefaultEntityDataSources();
|
|
33756
|
+
this.requestDefaultCheck();
|
|
32201
33757
|
this.isEditing = true;
|
|
32202
33758
|
this.editingStateChange.emit(true);
|
|
32203
33759
|
}
|
|
32204
33760
|
/**
|
|
32205
33761
|
* Removes an entity selector at the given index.
|
|
32206
33762
|
*/
|
|
32207
|
-
removeSelector(index) {
|
|
33763
|
+
async removeSelector(index) {
|
|
33764
|
+
const selector = this.entitySelectors[index];
|
|
33765
|
+
const followers = selector ? selectorsFollowing(this.entitySelectors, selector.id) : [];
|
|
33766
|
+
if (followers.length > 0 && this.confirmationService) {
|
|
33767
|
+
// AB#7722: removing a leader orphans its followers — name them.
|
|
33768
|
+
const name = selector.label || selector.id;
|
|
33769
|
+
const confirmed = await this.confirmationService.showDangerConfirm({
|
|
33770
|
+
title: `Remove selector ${name}?`,
|
|
33771
|
+
targetName: name,
|
|
33772
|
+
consequence: `${labelList(followers)} ${followers.length === 1 ? 'follows' : 'follow'} this selector. `
|
|
33773
|
+
+ 'Without a leader they behave as if the leader had no pick until you choose another leader.',
|
|
33774
|
+
confirmText: 'Remove selector'
|
|
33775
|
+
});
|
|
33776
|
+
if (!confirmed)
|
|
33777
|
+
return;
|
|
33778
|
+
}
|
|
32208
33779
|
this.entitySelectors = this.entitySelectors.filter((_, i) => i !== index);
|
|
32209
33780
|
this.emitChange();
|
|
32210
33781
|
}
|
|
33782
|
+
/** Selectors the edited selector may follow (non-followers, never itself). */
|
|
33783
|
+
get leaderOptions() {
|
|
33784
|
+
return eligibleLeaders(this.entitySelectors, this.editId);
|
|
33785
|
+
}
|
|
33786
|
+
/** Selectors that follow the edited selector (it then cannot follow one itself). */
|
|
33787
|
+
get followedBy() {
|
|
33788
|
+
return this.editingIndex === null ? [] : selectorsFollowing(this.entitySelectors, this.editId);
|
|
33789
|
+
}
|
|
33790
|
+
/** True when the stored leader is not among the selectable leaders (deleted or a follower). */
|
|
33791
|
+
get isLeaderUnavailable() {
|
|
33792
|
+
return !!this.editFollowsSelectorId && !this.leaderOptions.some(l => l.id === this.editFollowsSelectorId);
|
|
33793
|
+
}
|
|
33794
|
+
/** "A, B and C" of the selectors following the edited one. */
|
|
33795
|
+
get followedByLabels() {
|
|
33796
|
+
return labelList(this.followedBy);
|
|
33797
|
+
}
|
|
33798
|
+
/** Validation message of the "Follows" choice, or '' when it is valid. */
|
|
33799
|
+
get followError() {
|
|
33800
|
+
return followValidationError(this.entitySelectors, this.editId, this.editFollowsSelectorId || undefined) ?? '';
|
|
33801
|
+
}
|
|
33802
|
+
/** True while the edited selector follows another one. */
|
|
33803
|
+
get isFollower() {
|
|
33804
|
+
return !!this.editFollowsSelectorId;
|
|
33805
|
+
}
|
|
33806
|
+
/**
|
|
33807
|
+
* Handles a change of the "Follows" choice. Choosing a leader fixes the CK type
|
|
33808
|
+
* to the leader's (clearing default entity and mappings with a notice when the
|
|
33809
|
+
* type changes) and hides the toolbar option; back to none makes the CK type
|
|
33810
|
+
* editable again with "Show in Toolbar" unchecked.
|
|
33811
|
+
*/
|
|
33812
|
+
onFollowsChanged(leaderId) {
|
|
33813
|
+
this.editFollowsSelectorId = leaderId;
|
|
33814
|
+
this.followNotice = '';
|
|
33815
|
+
this.editShowInToolbar = false;
|
|
33816
|
+
// A follower has no picker: its filter rows are ignored and dropped on save (AB#7724).
|
|
33817
|
+
this.followerFilterCount = leaderId ? this.editFilters.length : 0;
|
|
33818
|
+
this.requestDefaultCheck();
|
|
33819
|
+
this.updateDefaultEntityDataSources();
|
|
33820
|
+
if (!leaderId)
|
|
33821
|
+
return;
|
|
33822
|
+
const leader = this.entitySelectors.find(s => s.id === leaderId);
|
|
33823
|
+
if (leader && leader.ckTypeId !== this.editCkTypeId) {
|
|
33824
|
+
const cleared = this.editMappings.length > 0 || !!this.editDefaultRtId;
|
|
33825
|
+
this.applyLeaderCkType(leader.ckTypeId);
|
|
33826
|
+
this.updateDefaultEntityDataSources();
|
|
33827
|
+
if (cleared) {
|
|
33828
|
+
this.followNotice = `The CK type is now the leader's (${leader.ckTypeId}); `
|
|
33829
|
+
+ 'the default entity and attribute mappings of the previous type were cleared.';
|
|
33830
|
+
}
|
|
33831
|
+
}
|
|
33832
|
+
}
|
|
33833
|
+
/** True when the selector names a leader that does not exist or is itself a follower. */
|
|
33834
|
+
isOrphanFollower(selector) {
|
|
33835
|
+
return resolveFollowLeader(this.entitySelectors, selector).kind === 'invalid';
|
|
33836
|
+
}
|
|
33837
|
+
applyLeaderCkType(ckTypeId) {
|
|
33838
|
+
this.editCkTypeId = ckTypeId;
|
|
33839
|
+
this.editCkTypeItem = { fullName: ckTypeId, rtCkTypeId: ckTypeId, isAbstract: false, isFinal: false };
|
|
33840
|
+
this.editMappings = [];
|
|
33841
|
+
this.editDefaultRtId = '';
|
|
33842
|
+
this.editFilters = [];
|
|
33843
|
+
this.followerFilterCount = 0;
|
|
33844
|
+
}
|
|
32211
33845
|
/**
|
|
32212
33846
|
* Handles CK type selection in the editor form.
|
|
32213
33847
|
*/
|
|
32214
33848
|
onCkTypeSelected(ckType) {
|
|
32215
33849
|
this.editCkTypeId = ckType.rtCkTypeId;
|
|
32216
33850
|
this.editCkTypeItem = ckType;
|
|
32217
|
-
// Clear mappings
|
|
33851
|
+
// Clear mappings, default entity and filter rows when CK type changes
|
|
32218
33852
|
this.editMappings = [];
|
|
32219
33853
|
this.editDefaultRtId = '';
|
|
33854
|
+
this.editFilters = [];
|
|
32220
33855
|
this.updateDefaultEntityDataSources();
|
|
33856
|
+
this.requestDefaultCheck();
|
|
32221
33857
|
}
|
|
32222
33858
|
/**
|
|
32223
33859
|
* Handles CK type cleared in the editor form.
|
|
@@ -32227,8 +33863,10 @@ class EntitySelectorEditorComponent {
|
|
|
32227
33863
|
this.editCkTypeItem = null;
|
|
32228
33864
|
this.editMappings = [];
|
|
32229
33865
|
this.editDefaultRtId = '';
|
|
33866
|
+
this.editFilters = [];
|
|
32230
33867
|
this.defaultEntityDataSource = null;
|
|
32231
33868
|
this.defaultEntityDialogDataSource = null;
|
|
33869
|
+
this.requestDefaultCheck();
|
|
32232
33870
|
}
|
|
32233
33871
|
/**
|
|
32234
33872
|
* Opens the attribute selector dialog to pick attributes for variable mappings.
|
|
@@ -32249,31 +33887,70 @@ class EntitySelectorEditorComponent {
|
|
|
32249
33887
|
};
|
|
32250
33888
|
});
|
|
32251
33889
|
}
|
|
32252
|
-
/**
|
|
32253
|
-
|
|
32254
|
-
|
|
32255
|
-
onChildScopeCkTypeSelected(ckType) {
|
|
32256
|
-
this.editChildScopeTargetCkTypeId = ckType.rtCkTypeId;
|
|
32257
|
-
this.editChildScopeTargetCkTypeItem = ckType;
|
|
33890
|
+
/** Takes the hop list of the child-scope editor (AB#7723). */
|
|
33891
|
+
onScopeHopsChanged(hops) {
|
|
33892
|
+
this.editScopeHops = hops;
|
|
32258
33893
|
}
|
|
32259
|
-
/**
|
|
32260
|
-
|
|
32261
|
-
|
|
32262
|
-
onChildScopeCkTypeCleared() {
|
|
32263
|
-
this.editChildScopeTargetCkTypeId = '';
|
|
32264
|
-
this.editChildScopeTargetCkTypeItem = null;
|
|
33894
|
+
/** Validation message of the hop list, or '' when it can be saved. */
|
|
33895
|
+
get scopeHopsError() {
|
|
33896
|
+
return scopeHopsValidationError(this.editScopeHops) ?? '';
|
|
32265
33897
|
}
|
|
32266
33898
|
/**
|
|
32267
33899
|
* Handles default entity selection from the entity picker.
|
|
32268
33900
|
*/
|
|
32269
33901
|
onDefaultEntitySelected(entity) {
|
|
32270
33902
|
this.editDefaultRtId = entity.rtId;
|
|
33903
|
+
this.requestDefaultCheck();
|
|
32271
33904
|
}
|
|
32272
33905
|
/**
|
|
32273
33906
|
* Handles default entity cleared.
|
|
32274
33907
|
*/
|
|
32275
33908
|
onDefaultEntityCleared() {
|
|
32276
33909
|
this.editDefaultRtId = '';
|
|
33910
|
+
this.requestDefaultCheck();
|
|
33911
|
+
}
|
|
33912
|
+
/** Takes the rows of the picker filter editor (AB#7724). */
|
|
33913
|
+
onFiltersChange(filters) {
|
|
33914
|
+
this.editFilters = filters;
|
|
33915
|
+
this.updateDefaultEntityDataSources();
|
|
33916
|
+
this.requestDefaultCheck();
|
|
33917
|
+
}
|
|
33918
|
+
/**
|
|
33919
|
+
* Variables offered in filter values: the board's static variables and the
|
|
33920
|
+
* variables other selectors map. The edited selector's own mappings are
|
|
33921
|
+
* left out — they are filled from its own pick.
|
|
33922
|
+
*/
|
|
33923
|
+
get filterVariables() {
|
|
33924
|
+
const result = new Map();
|
|
33925
|
+
for (const v of this.variables) {
|
|
33926
|
+
result.set(v.name, { name: v.name, label: v.label || v.name, type: v.type });
|
|
33927
|
+
}
|
|
33928
|
+
this.entitySelectors
|
|
33929
|
+
.filter((_, i) => i !== this.editingIndex)
|
|
33930
|
+
.forEach(es => es.attributeMappings.forEach(m => {
|
|
33931
|
+
if (!result.has(m.variableName)) {
|
|
33932
|
+
result.set(m.variableName, { name: m.variableName, label: `${m.variableName} (${es.label || es.id})` });
|
|
33933
|
+
}
|
|
33934
|
+
}));
|
|
33935
|
+
return [...result.values()];
|
|
33936
|
+
}
|
|
33937
|
+
/** "2 filters" chip of a selector in the list, or '' without filter. */
|
|
33938
|
+
getFilterSummary(selector) {
|
|
33939
|
+
const count = selector.followsSelectorId ? 0 : selector.filters?.length ?? 0;
|
|
33940
|
+
if (count === 0)
|
|
33941
|
+
return '';
|
|
33942
|
+
return count === 1 ? '1 filter' : `${count} filters`;
|
|
33943
|
+
}
|
|
33944
|
+
/** "$a, $b" of the variables the default-entity check cannot substitute. */
|
|
33945
|
+
get uncheckedVariablesText() {
|
|
33946
|
+
return this.defaultFilterCheck.state === 'unchecked'
|
|
33947
|
+
? this.defaultFilterCheck.variables.map(v => `$${v}`).join(', ')
|
|
33948
|
+
: '';
|
|
33949
|
+
}
|
|
33950
|
+
/** Label of the leader the edited follower takes its pick from. */
|
|
33951
|
+
get leaderLabel() {
|
|
33952
|
+
const leader = this.entitySelectors.find(s => s.id === this.editFollowsSelectorId);
|
|
33953
|
+
return leader ? (leader.label || leader.id) : this.editFollowsSelectorId;
|
|
32277
33954
|
}
|
|
32278
33955
|
/**
|
|
32279
33956
|
* Saves the current entity selector being edited.
|
|
@@ -32281,15 +33958,10 @@ class EntitySelectorEditorComponent {
|
|
|
32281
33958
|
saveEdit() {
|
|
32282
33959
|
if (!this.isEditValid())
|
|
32283
33960
|
return;
|
|
32284
|
-
//
|
|
32285
|
-
//
|
|
32286
|
-
|
|
32287
|
-
|
|
32288
|
-
targetCkTypeId: this.editChildScopeTargetCkTypeId,
|
|
32289
|
-
roleId: this.editChildScopeRoleId.trim(),
|
|
32290
|
-
direction: this.editChildScopeDirection
|
|
32291
|
-
}
|
|
32292
|
-
: undefined;
|
|
33961
|
+
// No hops = no childScope (the picked entity itself is the scope); one hop
|
|
33962
|
+
// in the original top-level form (older library versions keep reading it);
|
|
33963
|
+
// two or more hops as `path` (AB#7723).
|
|
33964
|
+
const childScope = childScopeFromHops(this.editScopeHops);
|
|
32293
33965
|
const selector = {
|
|
32294
33966
|
id: this.editId,
|
|
32295
33967
|
label: this.editLabel.trim(),
|
|
@@ -32297,7 +33969,11 @@ class EntitySelectorEditorComponent {
|
|
|
32297
33969
|
attributeMappings: this.editMappings,
|
|
32298
33970
|
showInToolbar: this.editShowInToolbar,
|
|
32299
33971
|
defaultRtId: this.editDefaultRtId.trim() || undefined,
|
|
32300
|
-
childScope
|
|
33972
|
+
childScope,
|
|
33973
|
+
// Picker filter (AB#7724); omitted when empty and never written for a follower.
|
|
33974
|
+
filters: !this.editFollowsSelectorId && this.editFilters.length > 0 ? this.toFilterConfigs(this.editFilters) : undefined,
|
|
33975
|
+
// A follower never renders a picker (AB#7722).
|
|
33976
|
+
...(this.editFollowsSelectorId ? { showInToolbar: false, followsSelectorId: this.editFollowsSelectorId } : {})
|
|
32301
33977
|
};
|
|
32302
33978
|
if (this.editingIndex !== null) {
|
|
32303
33979
|
this.entitySelectors = this.entitySelectors.map((es, i) => i === this.editingIndex ? selector : es);
|
|
@@ -32325,13 +34001,22 @@ class EntitySelectorEditorComponent {
|
|
|
32325
34001
|
const hasRequiredFields = this.editId.trim().length > 0 &&
|
|
32326
34002
|
this.editLabel.trim().length > 0 &&
|
|
32327
34003
|
this.editCkTypeId.length > 0 &&
|
|
32328
|
-
|
|
34004
|
+
// A follower mostly exists for its child scope: mappings are optional (AB#7722).
|
|
34005
|
+
(this.isFollower || this.editMappings.length > 0) &&
|
|
32329
34006
|
this.editMappings.every(m => m.variableName.trim().length > 0 &&
|
|
32330
34007
|
this.isValidVariableName(m.variableName) &&
|
|
32331
34008
|
!this.isDuplicateVariableName(m.variableName, m.attributePath)) &&
|
|
32332
34009
|
!this.isDuplicateId(this.editId, this.editingIndex);
|
|
32333
|
-
// If not shown in toolbar, a default entity must be selected
|
|
32334
|
-
|
|
34010
|
+
// If not shown in toolbar, a default entity must be selected — except for a
|
|
34011
|
+
// follower, which takes its leader's pick (AB#7722).
|
|
34012
|
+
if (!this.isFollower && !this.editShowInToolbar && !this.editDefaultRtId.trim()) {
|
|
34013
|
+
return false;
|
|
34014
|
+
}
|
|
34015
|
+
if (this.followError) {
|
|
34016
|
+
return false;
|
|
34017
|
+
}
|
|
34018
|
+
// Every hop needs an association and a target type; at most 5 hops (AB#7723).
|
|
34019
|
+
if (this.scopeHopsError) {
|
|
32335
34020
|
return false;
|
|
32336
34021
|
}
|
|
32337
34022
|
return hasRequiredFields;
|
|
@@ -32354,6 +34039,13 @@ class EntitySelectorEditorComponent {
|
|
|
32354
34039
|
* Gets a display label indicating the selector mode.
|
|
32355
34040
|
*/
|
|
32356
34041
|
getModeLabel(selector) {
|
|
34042
|
+
const leader = resolveFollowLeader(this.entitySelectors, selector);
|
|
34043
|
+
if (leader.kind === 'leader') {
|
|
34044
|
+
return `Follows: ${leader.leader.label || leader.leader.id}`;
|
|
34045
|
+
}
|
|
34046
|
+
if (leader.kind === 'invalid') {
|
|
34047
|
+
return 'Follows: missing';
|
|
34048
|
+
}
|
|
32357
34049
|
return selector.showInToolbar !== false ? 'Toolbar' : 'Fixed';
|
|
32358
34050
|
}
|
|
32359
34051
|
/**
|
|
@@ -32405,19 +34097,74 @@ class EntitySelectorEditorComponent {
|
|
|
32405
34097
|
}
|
|
32406
34098
|
updateDefaultEntityDataSources() {
|
|
32407
34099
|
if (this.editCkTypeId) {
|
|
32408
|
-
|
|
32409
|
-
|
|
34100
|
+
// The default-entity picker lists what the toolbar picker lists (AB#7724).
|
|
34101
|
+
const gql = withPickerFilters(this.getEntitiesByCkTypeGQL, this.resolvedEditorFilters().filters);
|
|
34102
|
+
this.defaultEntityDataSource = new RuntimeEntitySelectDataSource(gql, this.editCkTypeId);
|
|
34103
|
+
this.defaultEntityDialogDataSource = new RuntimeEntityDialogDataSource(gql, this.editCkTypeId);
|
|
32410
34104
|
}
|
|
32411
34105
|
else {
|
|
32412
34106
|
this.defaultEntityDataSource = null;
|
|
32413
34107
|
this.defaultEntityDialogDataSource = null;
|
|
32414
34108
|
}
|
|
32415
34109
|
}
|
|
34110
|
+
toFilterConfigs(rows) {
|
|
34111
|
+
return rows.map(f => ({
|
|
34112
|
+
attributePath: f.attributePath,
|
|
34113
|
+
operator: f.operator,
|
|
34114
|
+
comparisonValue: f.comparisonValue == null ? '' : String(f.comparisonValue)
|
|
34115
|
+
}));
|
|
34116
|
+
}
|
|
34117
|
+
/**
|
|
34118
|
+
* The filter rows that apply in the editor, with the board's static
|
|
34119
|
+
* variables substituted. Rows whose value still names a variable without a
|
|
34120
|
+
* value (e.g. one a selector pick fills at view time) are left out and
|
|
34121
|
+
* reported in `unresolved`; the editor cannot know their value.
|
|
34122
|
+
*/
|
|
34123
|
+
resolvedEditorFilters() {
|
|
34124
|
+
if (this.isFollower || this.editFilters.length === 0) {
|
|
34125
|
+
return { filters: undefined, unresolved: [] };
|
|
34126
|
+
}
|
|
34127
|
+
const converted = this.variableService.convertToFieldFilterDto(this.toFilterConfigs(this.editFilters), this.variables) ?? [];
|
|
34128
|
+
const unresolved = unresolvedFilterVariables(converted);
|
|
34129
|
+
const applicable = converted.filter(f => unresolvedFilterVariables([f]).length === 0);
|
|
34130
|
+
return { filters: applicable.length > 0 ? applicable : undefined, unresolved };
|
|
34131
|
+
}
|
|
34132
|
+
/** Queues the (debounced) check whether the default entity matches the filter. */
|
|
34133
|
+
requestDefaultCheck() {
|
|
34134
|
+
const rtId = this.editDefaultRtId.trim();
|
|
34135
|
+
if (!rtId || !this.editCkTypeId || this.isFollower || this.editFilters.length === 0) {
|
|
34136
|
+
this.defaultCheckRequests.next({ state: 'none' });
|
|
34137
|
+
return;
|
|
34138
|
+
}
|
|
34139
|
+
const { filters, unresolved } = this.resolvedEditorFilters();
|
|
34140
|
+
if (unresolved.length > 0) {
|
|
34141
|
+
this.defaultCheckRequests.next({ state: 'unchecked', variables: unresolved });
|
|
34142
|
+
return;
|
|
34143
|
+
}
|
|
34144
|
+
this.defaultCheckRequests.next({ ckTypeId: this.editCkTypeId, rtId, filters: filters ?? [] });
|
|
34145
|
+
}
|
|
34146
|
+
/** Asks the server whether the entity is among the filtered ones (rtId + filter, first 1). */
|
|
34147
|
+
checkDefaultEntity(request) {
|
|
34148
|
+
return this.getEntitiesByCkTypeGQL.fetch({
|
|
34149
|
+
variables: {
|
|
34150
|
+
ckTypeId: request.ckTypeId,
|
|
34151
|
+
attributeNames: [],
|
|
34152
|
+
rtId: request.rtId,
|
|
34153
|
+
first: 1,
|
|
34154
|
+
fieldFilters: request.filters
|
|
34155
|
+
},
|
|
34156
|
+
fetchPolicy: 'network-only'
|
|
34157
|
+
}).pipe(map$1(result => {
|
|
34158
|
+
const count = result.data?.runtime?.runtimeEntities?.totalCount
|
|
34159
|
+
?? result.data?.runtime?.runtimeEntities?.items?.length ?? 0;
|
|
34160
|
+
return (count > 0 ? { state: 'match' } : { state: 'mismatch' });
|
|
34161
|
+
}), catchError$1(() => of({ state: 'error' })));
|
|
34162
|
+
}
|
|
32416
34163
|
emitChange() {
|
|
32417
34164
|
this.entitySelectorsChange.emit([...this.entitySelectors]);
|
|
32418
34165
|
}
|
|
32419
34166
|
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.5", ngImport: i0, type: EntitySelectorEditorComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
32420
|
-
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.1.5", type: EntitySelectorEditorComponent, isStandalone: true, selector: "mm-entity-selector-editor", inputs: { entitySelectors: "entitySelectors", existingVariableNames: "existingVariableNames" }, outputs: { entitySelectorsChange: "entitySelectorsChange", editingStateChange: "editingStateChange" }, ngImport: i0, template: "<div class=\"entity-selector-editor\">\n @if (!isEditing) {\n <!-- Selector List -->\n <div class=\"selector-list\">\n @for (selector of entitySelectors; track selector.id; let i = $index) {\n <div class=\"selector-item\">\n <div class=\"selector-info\">\n <span class=\"selector-label\">{{ selector.label }}</span>\n <span class=\"selector-mode\">{{ getModeLabel(selector) }}</span>\n <span class=\"selector-ck-type\">{{ selector.ckTypeId }}</span>\n <span class=\"selector-mappings\">{{ getMappingSummary(selector) }}</span>\n </div>\n <div class=\"selector-actions\">\n <button\n kendoButton\n [svgIcon]=\"pencilIcon\"\n (click)=\"editSelector(i)\"\n fillMode=\"flat\"\n size=\"small\"\n title=\"Edit selector\"\n [attr.aria-label]=\"'Edit selector ' + (selector.label || selector.ckTypeId)\">\n </button>\n <button\n kendoButton\n [svgIcon]=\"trashIcon\"\n (click)=\"removeSelector(i)\"\n fillMode=\"flat\"\n size=\"small\"\n themeColor=\"error\"\n title=\"Remove selector\"\n [attr.aria-label]=\"'Remove selector ' + (selector.label || selector.ckTypeId)\"\n class=\"remove-btn\">\n </button>\n </div>\n </div>\n }\n\n @if (entitySelectors.length === 0) {\n <div class=\"empty-state\">\n No entity selectors configured. Add one to enable entity-based variables.\n </div>\n }\n </div>\n\n <div class=\"add-button-row\">\n <button\n kendoButton\n [svgIcon]=\"plusIcon\"\n (click)=\"addSelector()\"\n themeColor=\"primary\">\n Add Entity Selector\n </button>\n </div>\n } @else {\n <!-- Edit Form -->\n <div class=\"edit-panel\">\n <div class=\"edit-panel-header\">\n {{ editingIndex !== null ? 'Edit Entity Selector' : 'New Entity Selector' }}\n </div>\n <div class=\"edit-form\">\n <div class=\"form-field\">\n <label class=\"field-label\">ID *</label>\n <kendo-textbox\n [(ngModel)]=\"editId\"\n placeholder=\"e.g., mp\"\n [readonly]=\"editingIndex !== null\">\n </kendo-textbox>\n @if (isDuplicateId(editId, editingIndex)) {\n <span class=\"field-error\">ID must be unique</span>\n }\n <span class=\"field-hint\">Used in URL parameters (es_<id>=<rtId>)</span>\n </div>\n\n <div class=\"form-field\">\n <label class=\"field-label\">Label *</label>\n <kendo-textbox\n [(ngModel)]=\"editLabel\"\n placeholder=\"e.g., Metering Point\">\n </kendo-textbox>\n </div>\n\n <div class=\"form-field\">\n <label class=\"field-label\">CK Type *</label>\n <mm-ck-type-selector-input\n [ngModel]=\"editCkTypeItem\"\n [placeholder]=\"'Select CK type...'\"\n (ckTypeSelected)=\"onCkTypeSelected($event)\"\n (ckTypeCleared)=\"onCkTypeCleared()\">\n </mm-ck-type-selector-input>\n </div>\n\n <div class=\"form-field\">\n <label class=\"field-label\">Attribute Mappings *</label>\n <button\n kendoButton\n (click)=\"selectAttributes()\"\n [disabled]=\"!editCkTypeId\"\n fillMode=\"outline\"\n size=\"small\">\n Select Attributes...\n </button>\n\n @if (editMappings.length > 0) {\n <div class=\"mappings-list\">\n <div class=\"mappings-header\">\n <span class=\"mappings-header-attr\">Attribute</span>\n <span class=\"mappings-header-var\">Variable Name</span>\n </div>\n @for (mapping of editMappings; track mapping.attributePath; let j = $index) {\n <div class=\"mapping-item\">\n <span class=\"mapping-path\">{{ mapping.attributePath }}</span>\n <span class=\"mapping-arrow\">→</span>\n <div class=\"mapping-var-field\">\n <div class=\"mapping-var-input-wrapper\">\n <span class=\"mapping-var-prefix\">$</span>\n <kendo-textbox\n [(ngModel)]=\"mapping.variableName\"\n size=\"small\"\n class=\"mapping-var-input\"\n placeholder=\"variableName\"\n [class.invalid]=\"getVariableNameError(mapping)\">\n </kendo-textbox>\n </div>\n @if (getVariableNameError(mapping); as error) {\n <span class=\"field-error\">{{ error }}</span>\n }\n </div>\n </div>\n }\n </div>\n }\n </div>\n\n <div class=\"form-field\">\n <div class=\"checkbox-row\">\n <input\n type=\"checkbox\"\n kendoCheckBox\n [(ngModel)]=\"editShowInToolbar\"\n id=\"showInToolbar\" />\n <label class=\"field-label checkbox-label\" for=\"showInToolbar\">Show in Toolbar</label>\n </div>\n <span class=\"field-hint\">\n @if (editShowInToolbar) {\n Users can select an entity from a dropdown in the toolbar.\n } @else {\n Hidden selector \u2014 variables are resolved from the default entity below.\n }\n </span>\n </div>\n\n <div class=\"form-field\">\n <label class=\"field-label\">Default Entity{{ !editShowInToolbar ? ' *' : '' }}</label>\n @if (defaultEntityDataSource) {\n <mm-entity-select-input\n [dataSource]=\"defaultEntityDataSource\"\n [dialogDataSource]=\"defaultEntityDialogDataSource!\"\n [placeholder]=\"'Select default entity...'\"\n [dialogTitle]=\"'Select Default ' + editLabel\"\n (entitySelected)=\"onDefaultEntitySelected($event)\"\n (entityCleared)=\"onDefaultEntityCleared()\">\n </mm-entity-select-input>\n } @else {\n <span class=\"field-hint\">Select a CK type first to pick a default entity.</span>\n }\n @if (editDefaultRtId) {\n <span class=\"field-hint\">Selected: {{ editDefaultRtId }}</span>\n }\n @if (!editShowInToolbar && !editDefaultRtId) {\n <span class=\"field-error\">A default entity is required when not shown in toolbar.</span>\n }\n <span class=\"field-hint\">\n @if (editShowInToolbar) {\n Optional \u2014 pre-selected entity when the MeshBoard loads.\n } @else {\n This entity's attributes will be used to populate the variables.\n }\n </span>\n </div>\n\n <div class=\"form-field\">\n <label class=\"field-label\">Child Scope (stream-data)</label>\n <span class=\"field-hint\">\n Optional. Resolves the picked entity to its child source rtIds (one\n association hop) so a stream-data widget can scope to them \u2014 e.g. a\n MeteringPoint resolved to its EnergyMeasurement rtIds. Leave empty to\n scope by the picked entity's own rtId.\n </span>\n <mm-ck-type-selector-input\n [ngModel]=\"editChildScopeTargetCkTypeItem\"\n [placeholder]=\"'Child CK type (e.g. EnergyMeasurement)...'\"\n (ckTypeSelected)=\"onChildScopeCkTypeSelected($event)\"\n (ckTypeCleared)=\"onChildScopeCkTypeCleared()\">\n </mm-ck-type-selector-input>\n <kendo-textbox\n [(ngModel)]=\"editChildScopeRoleId\"\n placeholder=\"Association role (e.g. System/ParentChild)\">\n </kendo-textbox>\n <label class=\"field-label\">Direction</label>\n <select [(ngModel)]=\"editChildScopeDirection\" class=\"child-scope-direction\">\n <option value=\"out\">Outbound (parent \u2192 child)</option>\n <option value=\"in\">Inbound (child \u2192 parent)</option>\n </select>\n </div>\n\n <div class=\"edit-actions mm-dialog-actions\">\n <button kendoButton (click)=\"cancelEdit()\" fillMode=\"flat\">\n Cancel\n </button>\n <button\n kendoButton\n (click)=\"saveEdit()\"\n [disabled]=\"!isEditValid()\"\n themeColor=\"primary\">\n {{ editingIndex !== null ? 'Update' : 'Add' }}\n </button>\n </div>\n </div>\n </div>\n }\n</div>\n", styles: [".entity-selector-editor{display:flex;flex-direction:column;gap:1.25rem}.selector-list{display:flex;flex-direction:column;gap:.5rem}.selector-item{display:flex;justify-content:space-between;align-items:center;padding:.75rem 1rem;background:var(--kendo-color-surface-alt, #fafafa);border:1px solid var(--kendo-color-border, #e0e0e0);border-radius:4px}.selector-item .selector-info{display:flex;flex-direction:column;gap:.25rem;min-width:0;flex:1}.selector-item .selector-label{font-weight:500;color:var(--kendo-color-on-app-surface, #424242)}.selector-item .selector-mode{font-size:.75rem;font-weight:500;color:var(--kendo-color-info, #0288d1)}.selector-item .selector-ck-type{font-size:.8rem;color:var(--kendo-color-subtle, #757575);font-family:var(--theme-font-mono)}.selector-item .selector-mappings{font-size:.8rem;color:var(--kendo-color-primary, #3f51b5);font-family:var(--theme-font-mono)}.selector-item .selector-actions{display:flex;gap:.25rem;flex-shrink:0}.selector-item .remove-btn{color:var(--kendo-color-error, #f44336)}.empty-state{padding:1.5rem;text-align:center;color:var(--kendo-color-subtle, #757575);font-size:.875rem}.add-button-row{display:flex;justify-content:flex-start}.edit-panel{border:1px solid var(--kendo-color-border, #e0e0e0);border-radius:4px;overflow:hidden}.edit-panel-header{padding:.625rem 1rem;font-weight:600;font-size:.875rem;color:var(--kendo-color-on-primary, #ffffff);background:var(--kendo-color-primary, #3f51b5)}.edit-form{display:flex;flex-direction:column;gap:1.25rem;padding:1.25rem}.edit-form .form-field{display:flex;flex-direction:column;gap:.5rem}.edit-form .field-label{font-weight:500;font-size:.875rem;color:var(--kendo-color-on-app-surface, #424242)}.edit-form .field-hint{font-size:.75rem;color:var(--kendo-color-subtle, #757575)}.edit-form .field-error{font-size:.75rem;color:var(--kendo-color-error, #f44336)}.edit-form .checkbox-row{display:flex;align-items:center;gap:.5rem}.edit-form .checkbox-row .checkbox-label{margin:0;cursor:pointer}.mappings-list{display:flex;flex-direction:column;gap:.5rem;margin-top:.5rem;padding:.75rem;background:var(--kendo-color-surface-alt, #fafafa);border:1px solid var(--kendo-color-border, #e0e0e0);border-radius:4px}.mappings-header{display:flex;align-items:center;gap:.5rem;font-size:.75rem;font-weight:600;color:var(--kendo-color-subtle, #757575);padding-bottom:.25rem;border-bottom:1px solid var(--kendo-color-border, #e0e0e0)}.mappings-header .mappings-header-attr{min-width:150px}.mappings-header .mappings-header-var{margin-left:1.75rem}.mapping-item{display:flex;align-items:flex-start;gap:.5rem}.mapping-item .mapping-path{font-size:.8rem;font-family:var(--theme-font-mono);color:var(--kendo-color-subtle, #757575);min-width:150px;padding-top:5px}.mapping-item .mapping-arrow{color:var(--kendo-color-subtle, #757575);flex-shrink:0;padding-top:5px}.mapping-item .mapping-var-field{display:flex;flex-direction:column;gap:2px;flex:1;min-width:100px}.mapping-item .mapping-var-input-wrapper{display:flex;align-items:center;gap:0}.mapping-item .mapping-var-prefix{font-family:var(--theme-font-mono);font-size:.875rem;font-weight:600;color:var(--kendo-color-primary, #3f51b5);padding-right:2px;flex-shrink:0}.mapping-item .mapping-var-input{flex:1}.mapping-item .mapping-var-input.invalid ::ng-deep .k-input{border-color:var(--kendo-color-error, #f44336)}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "ngmodule", type: FormsModule }, { kind: "directive", type: i1$1.NgSelectOption, selector: "option", inputs: ["ngValue", "value"] }, { kind: "directive", type: i1$1.ɵNgSelectMultipleOption, selector: "option", inputs: ["ngValue", "value"] }, { kind: "directive", type: i1$1.CheckboxControlValueAccessor, selector: "input[type=checkbox]:not([ngNoCva])[formControlName],input[type=checkbox]:not([ngNoCva])[formControl],input[type=checkbox]:not([ngNoCva])[ngModel]" }, { kind: "directive", type: i1$1.SelectControlValueAccessor, selector: "select:not([multiple]):not([ngNoCva])[formControlName],select:not([multiple]):not([ngNoCva])[formControl],select:not([multiple]):not([ngNoCva])[ngModel]", inputs: ["compareWith"] }, { kind: "directive", type: i1$1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1$1.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }, { kind: "ngmodule", type: ButtonModule }, { kind: "component", type: i2.ButtonComponent, selector: "button[kendoButton]", inputs: ["arrowIcon", "toggleable", "togglable", "selected", "tabIndex", "imageUrl", "iconPosition", "iconClass", "icon", "disabled", "size", "rounded", "fillMode", "themeColor", "svgIcon"], outputs: ["selectedChange", "click"], exportAs: ["kendoButton"] }, { kind: "ngmodule", type: InputsModule }, { kind: "component", type: i3.TextBoxComponent, selector: "kendo-textbox", inputs: ["focusableId", "title", "type", "disabled", "readonly", "tabindex", "value", "selectOnFocus", "showSuccessIcon", "showErrorIcon", "clearButton", "successIcon", "successSvgIcon", "errorIcon", "errorSvgIcon", "clearButtonIcon", "clearButtonSvgIcon", "size", "rounded", "fillMode", "tabIndex", "placeholder", "maxlength", "inputAttributes"], outputs: ["valueChange", "inputFocus", "inputBlur", "focus", "blur"], exportAs: ["kendoTextBox"] }, { kind: "directive", type: i3.CheckBoxDirective, selector: "input[kendoCheckBox]", inputs: ["size", "rounded"] }, { kind: "ngmodule", type: LabelModule }, { kind: "directive", type: i4$1.LabelDirective, selector: "label[for]", inputs: ["for", "labelClass"] }, { kind: "ngmodule", type: SVGIconModule }, { kind: "component", type: CkTypeSelectorInputComponent, selector: "mm-ck-type-selector-input", inputs: ["placeholder", "minSearchLength", "maxResults", "debounceMs", "ckModelIds", "allowAbstract", "dialogTitle", "advancedSearchLabel", "derivedFromRtCkTypeId", "messages", "dialogMessages", "disabled", "required"], outputs: ["ckTypeSelected", "ckTypeCleared"] }, { kind: "component", type: EntitySelectInputComponent, selector: "mm-entity-select-input", inputs: ["dataSource", "placeholder", "minSearchLength", "maxResults", "debounceMs", "prefix", "initialDisplayValue", "dialogDataSource", "dialogTitle", "multiSelect", "advancedSearchLabel", "dialogMessages", "messages", "disabled", "required"], outputs: ["entitySelected", "entityCleared", "entitiesSelected"] }], changeDetection: i0.ChangeDetectionStrategy.Eager });
|
|
34167
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.1.5", type: EntitySelectorEditorComponent, isStandalone: true, selector: "mm-entity-selector-editor", inputs: { entitySelectors: "entitySelectors", existingVariableNames: "existingVariableNames", variables: "variables" }, outputs: { entitySelectorsChange: "entitySelectorsChange", editingStateChange: "editingStateChange" }, ngImport: i0, template: "<div class=\"entity-selector-editor\">\n @if (!isEditing) {\n <!-- Selector List -->\n <div class=\"selector-list\">\n @for (selector of entitySelectors; track selector.id; let i = $index) {\n <div class=\"selector-item\">\n <div class=\"selector-info\">\n <span class=\"selector-label\">{{ selector.label }}</span>\n <span class=\"selector-mode\" [class.selector-mode-error]=\"isOrphanFollower(selector)\">{{ getModeLabel(selector) }}</span>\n @if (getFilterSummary(selector); as filterSummary) {\n <span class=\"selector-mode selector-filter-chip\">{{ filterSummary }}</span>\n }\n <span class=\"selector-ck-type\">{{ selector.ckTypeId }}</span>\n <span class=\"selector-mappings\">{{ getMappingSummary(selector) }}</span>\n </div>\n <div class=\"selector-actions\">\n <button\n kendoButton\n [svgIcon]=\"pencilIcon\"\n (click)=\"editSelector(i)\"\n fillMode=\"flat\"\n size=\"small\"\n title=\"Edit selector\"\n [attr.aria-label]=\"'Edit selector ' + (selector.label || selector.ckTypeId)\">\n </button>\n <button\n kendoButton\n [svgIcon]=\"trashIcon\"\n (click)=\"removeSelector(i)\"\n fillMode=\"flat\"\n size=\"small\"\n themeColor=\"error\"\n title=\"Remove selector\"\n [attr.aria-label]=\"'Remove selector ' + (selector.label || selector.ckTypeId)\"\n class=\"remove-btn\">\n </button>\n </div>\n </div>\n }\n\n @if (entitySelectors.length === 0) {\n <div class=\"empty-state\">\n No entity selectors configured. Add one to enable entity-based variables.\n </div>\n }\n </div>\n\n <div class=\"add-button-row\">\n <button\n kendoButton\n [svgIcon]=\"plusIcon\"\n (click)=\"addSelector()\"\n themeColor=\"primary\">\n Add Entity Selector\n </button>\n </div>\n } @else {\n <!-- Edit Form -->\n <div class=\"edit-panel\">\n <div class=\"edit-panel-header\">\n {{ editingIndex !== null ? 'Edit Entity Selector' : 'New Entity Selector' }}\n </div>\n <div class=\"edit-form\">\n <div class=\"form-field\">\n <label class=\"field-label\">ID *</label>\n <kendo-textbox\n [(ngModel)]=\"editId\"\n placeholder=\"e.g., mp\"\n [readonly]=\"editingIndex !== null\">\n </kendo-textbox>\n @if (isDuplicateId(editId, editingIndex)) {\n <span class=\"field-error\">ID must be unique</span>\n }\n <span class=\"field-hint\">Used in URL parameters (es_<id>=<rtId>)</span>\n </div>\n\n <div class=\"form-field\">\n <label class=\"field-label\">Label *</label>\n <kendo-textbox\n [(ngModel)]=\"editLabel\"\n placeholder=\"e.g., Metering Point\">\n </kendo-textbox>\n </div>\n\n <div class=\"form-field\">\n <label class=\"field-label\" for=\"followsSelectorId\">Follows</label>\n <select\n id=\"followsSelectorId\"\n class=\"follows-select\"\n [ngModel]=\"editFollowsSelectorId\"\n (ngModelChange)=\"onFollowsChanged($event)\"\n [disabled]=\"followedBy.length > 0 && !editFollowsSelectorId\">\n <option value=\"\">\u2014 none (own picker / default entity) \u2014</option>\n @for (leader of leaderOptions; track leader.id) {\n <option [value]=\"leader.id\">{{ leader.label || leader.id }} ({{ leader.id }})</option>\n }\n @if (isLeaderUnavailable) {\n <option [value]=\"editFollowsSelectorId\">{{ editFollowsSelectorId }} (unavailable)</option>\n }\n </select>\n @if (followError) {\n <span class=\"field-error\">{{ followError }}</span>\n } @else if (followedBy.length > 0) {\n <span class=\"field-hint\">Followed by {{ followedByLabels }} \u2014 a selector that is followed cannot follow another one.</span>\n }\n @if (followNotice) {\n <span class=\"field-hint follow-notice\">{{ followNotice }}</span>\n }\n <span class=\"field-hint\">\n Takes the pick of the selected selector. The CK type is fixed to the leader's.\n A follower never shows a picker and is not written to the URL.\n </span>\n </div>\n\n <div class=\"form-field\">\n <label class=\"field-label\">CK Type *</label>\n @if (isFollower) {\n <kendo-textbox [value]=\"editCkTypeId\" [readonly]=\"true\"></kendo-textbox>\n <span class=\"field-hint\">Fixed to the leader's CK type.</span>\n } @else {\n <mm-ck-type-selector-input\n [ngModel]=\"editCkTypeItem\"\n [placeholder]=\"'Select CK type...'\"\n (ckTypeSelected)=\"onCkTypeSelected($event)\"\n (ckTypeCleared)=\"onCkTypeCleared()\">\n </mm-ck-type-selector-input>\n }\n </div>\n\n @if (editCkTypeId) {\n <div class=\"form-field picker-filter-field\">\n <label class=\"field-label\">Picker filter</label>\n @if (isFollower) {\n <span class=\"field-hint\">A follower takes the leader's pick. Set the filter on {{ leaderLabel }}.</span>\n @if (followerFilterCount > 0) {\n <span class=\"field-hint follow-notice\">Filters on a follower are ignored and are removed when you save.</span>\n }\n } @else {\n <span class=\"field-hint\">\n Only entities that match all rows are listed in the toolbar picker and in the default-entity picker below.\n </span>\n <mm-field-filter-editor\n [ckTypeId]=\"editCkTypeId\"\n [filters]=\"editFilters\"\n [enableVariables]=\"filterVariables.length > 0\"\n [availableVariables]=\"filterVariables\"\n (filtersChange)=\"onFiltersChange($event)\">\n </mm-field-filter-editor>\n }\n </div>\n }\n\n <div class=\"form-field\">\n <label class=\"field-label\">Attribute Mappings{{ isFollower ? '' : ' *' }}</label>\n <button\n kendoButton\n (click)=\"selectAttributes()\"\n [disabled]=\"!editCkTypeId\"\n fillMode=\"outline\"\n size=\"small\">\n Select Attributes...\n </button>\n\n @if (editMappings.length > 0) {\n <div class=\"mappings-list\">\n <div class=\"mappings-header\">\n <span class=\"mappings-header-attr\">Attribute</span>\n <span class=\"mappings-header-var\">Variable Name</span>\n </div>\n @for (mapping of editMappings; track mapping.attributePath; let j = $index) {\n <div class=\"mapping-item\">\n <span class=\"mapping-path\">{{ mapping.attributePath }}</span>\n <span class=\"mapping-arrow\">→</span>\n <div class=\"mapping-var-field\">\n <div class=\"mapping-var-input-wrapper\">\n <span class=\"mapping-var-prefix\">$</span>\n <kendo-textbox\n [(ngModel)]=\"mapping.variableName\"\n size=\"small\"\n class=\"mapping-var-input\"\n placeholder=\"variableName\"\n [class.invalid]=\"getVariableNameError(mapping)\">\n </kendo-textbox>\n </div>\n @if (getVariableNameError(mapping); as error) {\n <span class=\"field-error\">{{ error }}</span>\n }\n </div>\n </div>\n }\n </div>\n }\n </div>\n\n @if (!isFollower) {\n <div class=\"form-field\">\n <div class=\"checkbox-row\">\n <input\n type=\"checkbox\"\n kendoCheckBox\n [(ngModel)]=\"editShowInToolbar\"\n id=\"showInToolbar\" />\n <label class=\"field-label checkbox-label\" for=\"showInToolbar\">Show in Toolbar</label>\n </div>\n <span class=\"field-hint\">\n @if (editShowInToolbar) {\n Users can select an entity from a dropdown in the toolbar.\n } @else {\n Hidden selector \u2014 variables are resolved from the default entity below.\n }\n </span>\n </div>\n }\n\n <div class=\"form-field\">\n <label class=\"field-label\">Default Entity{{ !editShowInToolbar && !isFollower ? ' *' : '' }}</label>\n @if (defaultEntityDataSource) {\n <mm-entity-select-input\n [dataSource]=\"defaultEntityDataSource\"\n [dialogDataSource]=\"defaultEntityDialogDataSource!\"\n [placeholder]=\"'Select default entity...'\"\n [dialogTitle]=\"'Select Default ' + editLabel\"\n (entitySelected)=\"onDefaultEntitySelected($event)\"\n (entityCleared)=\"onDefaultEntityCleared()\">\n </mm-entity-select-input>\n } @else {\n <span class=\"field-hint\">Select a CK type first to pick a default entity.</span>\n }\n @if (editDefaultRtId) {\n <span class=\"field-hint\">Selected: {{ editDefaultRtId }}</span>\n }\n @switch (defaultFilterCheck.state) {\n @case ('mismatch') {\n <span class=\"field-warning default-filter-warning\">The default entity does not match the picker filter. It still loads, but users cannot pick it again.</span>\n }\n @case ('error') {\n <span class=\"field-hint default-filter-warning\">Could not check the default entity.</span>\n }\n @case ('unchecked') {\n <span class=\"field-hint default-filter-warning\">Cannot check the default entity: the filter uses {{ uncheckedVariablesText }}, which is filled at view time.</span>\n }\n }\n @if (!editShowInToolbar && !isFollower && !editDefaultRtId) {\n <span class=\"field-error\">A default entity is required when not shown in toolbar.</span>\n }\n <span class=\"field-hint\">\n @if (isFollower) {\n Optional \u2014 used only while the leader has no pick.\n } @else if (editShowInToolbar) {\n Optional \u2014 pre-selected entity when the MeshBoard loads.\n } @else {\n This entity's attributes will be used to populate the variables.\n }\n </span>\n </div>\n\n <div class=\"form-field\">\n <label class=\"field-label\">Child Scope (stream-data)</label>\n <mm-entity-selector-scope-path-editor\n [sourceCkTypeId]=\"editCkTypeId\"\n [hops]=\"editScopeHops\"\n (hopsChange)=\"onScopeHopsChanged($event)\">\n </mm-entity-selector-scope-path-editor>\n </div>\n\n <div class=\"edit-actions mm-dialog-actions\">\n <button kendoButton (click)=\"cancelEdit()\" fillMode=\"flat\">\n Cancel\n </button>\n <button\n kendoButton\n (click)=\"saveEdit()\"\n [disabled]=\"!isEditValid()\"\n themeColor=\"primary\">\n {{ editingIndex !== null ? 'Update' : 'Add' }}\n </button>\n </div>\n </div>\n </div>\n }\n</div>\n", styles: [".entity-selector-editor{display:flex;flex-direction:column;gap:1.25rem}.selector-list{display:flex;flex-direction:column;gap:.5rem}.selector-item{display:flex;justify-content:space-between;align-items:center;padding:.75rem 1rem;background:var(--kendo-color-surface-alt, #fafafa);border:1px solid var(--kendo-color-border, #e0e0e0);border-radius:4px}.selector-item .selector-info{display:flex;flex-direction:column;gap:.25rem;min-width:0;flex:1}.selector-item .selector-label{font-weight:500;color:var(--kendo-color-on-app-surface, #424242)}.selector-item .selector-mode{font-size:.75rem;font-weight:500;color:var(--kendo-color-info, #0288d1)}.selector-item .selector-mode-error{color:var(--kendo-color-error, #d32f2f)}.selector-item .selector-filter-chip{color:var(--kendo-color-subtle, #757575)}.selector-item .selector-ck-type{font-size:.8rem;color:var(--kendo-color-subtle, #757575);font-family:var(--theme-font-mono)}.selector-item .selector-mappings{font-size:.8rem;color:var(--kendo-color-primary, #3f51b5);font-family:var(--theme-font-mono)}.selector-item .selector-actions{display:flex;gap:.25rem;flex-shrink:0}.selector-item .remove-btn{color:var(--kendo-color-error, #f44336)}.empty-state{padding:1.5rem;text-align:center;color:var(--kendo-color-subtle, #757575);font-size:.875rem}.add-button-row{display:flex;justify-content:flex-start}.edit-panel{border:1px solid var(--kendo-color-border, #e0e0e0);border-radius:4px;overflow:hidden}.edit-panel-header{padding:.625rem 1rem;font-weight:600;font-size:.875rem;color:var(--kendo-color-on-primary, #ffffff);background:var(--kendo-color-primary, #3f51b5)}.edit-form{display:flex;flex-direction:column;gap:1.25rem;padding:1.25rem}.edit-form .form-field{display:flex;flex-direction:column;gap:.5rem}.edit-form .field-label{font-weight:500;font-size:.875rem;color:var(--kendo-color-on-app-surface, #424242)}.edit-form .field-hint{font-size:.75rem;color:var(--kendo-color-subtle, #757575)}.edit-form .field-error{font-size:.75rem;color:var(--kendo-color-error, #f44336)}.edit-form .field-warning{font-size:.75rem;color:var(--kendo-color-warning, #f57c00)}.edit-form .checkbox-row{display:flex;align-items:center;gap:.5rem}.edit-form .checkbox-row .checkbox-label{margin:0;cursor:pointer}.mappings-list{display:flex;flex-direction:column;gap:.5rem;margin-top:.5rem;padding:.75rem;background:var(--kendo-color-surface-alt, #fafafa);border:1px solid var(--kendo-color-border, #e0e0e0);border-radius:4px}.mappings-header{display:flex;align-items:center;gap:.5rem;font-size:.75rem;font-weight:600;color:var(--kendo-color-subtle, #757575);padding-bottom:.25rem;border-bottom:1px solid var(--kendo-color-border, #e0e0e0)}.mappings-header .mappings-header-attr{min-width:150px}.mappings-header .mappings-header-var{margin-left:1.75rem}.mapping-item{display:flex;align-items:flex-start;gap:.5rem}.mapping-item .mapping-path{font-size:.8rem;font-family:var(--theme-font-mono);color:var(--kendo-color-subtle, #757575);min-width:150px;padding-top:5px}.mapping-item .mapping-arrow{color:var(--kendo-color-subtle, #757575);flex-shrink:0;padding-top:5px}.mapping-item .mapping-var-field{display:flex;flex-direction:column;gap:2px;flex:1;min-width:100px}.mapping-item .mapping-var-input-wrapper{display:flex;align-items:center;gap:0}.mapping-item .mapping-var-prefix{font-family:var(--theme-font-mono);font-size:.875rem;font-weight:600;color:var(--kendo-color-primary, #3f51b5);padding-right:2px;flex-shrink:0}.mapping-item .mapping-var-input{flex:1}.mapping-item .mapping-var-input.invalid ::ng-deep .k-input{border-color:var(--kendo-color-error, #f44336)}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "ngmodule", type: FormsModule }, { kind: "directive", type: i1$1.NgSelectOption, selector: "option", inputs: ["ngValue", "value"] }, { kind: "directive", type: i1$1.ɵNgSelectMultipleOption, selector: "option", inputs: ["ngValue", "value"] }, { kind: "directive", type: i1$1.CheckboxControlValueAccessor, selector: "input[type=checkbox]:not([ngNoCva])[formControlName],input[type=checkbox]:not([ngNoCva])[formControl],input[type=checkbox]:not([ngNoCva])[ngModel]" }, { kind: "directive", type: i1$1.SelectControlValueAccessor, selector: "select:not([multiple]):not([ngNoCva])[formControlName],select:not([multiple]):not([ngNoCva])[formControl],select:not([multiple]):not([ngNoCva])[ngModel]", inputs: ["compareWith"] }, { kind: "directive", type: i1$1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1$1.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }, { kind: "ngmodule", type: ButtonModule }, { kind: "component", type: i2.ButtonComponent, selector: "button[kendoButton]", inputs: ["arrowIcon", "toggleable", "togglable", "selected", "tabIndex", "imageUrl", "iconPosition", "iconClass", "icon", "disabled", "size", "rounded", "fillMode", "themeColor", "svgIcon"], outputs: ["selectedChange", "click"], exportAs: ["kendoButton"] }, { kind: "ngmodule", type: InputsModule }, { kind: "component", type: i3.TextBoxComponent, selector: "kendo-textbox", inputs: ["focusableId", "title", "type", "disabled", "readonly", "tabindex", "value", "selectOnFocus", "showSuccessIcon", "showErrorIcon", "clearButton", "successIcon", "successSvgIcon", "errorIcon", "errorSvgIcon", "clearButtonIcon", "clearButtonSvgIcon", "size", "rounded", "fillMode", "tabIndex", "placeholder", "maxlength", "inputAttributes"], outputs: ["valueChange", "inputFocus", "inputBlur", "focus", "blur"], exportAs: ["kendoTextBox"] }, { kind: "directive", type: i3.CheckBoxDirective, selector: "input[kendoCheckBox]", inputs: ["size", "rounded"] }, { kind: "ngmodule", type: LabelModule }, { kind: "directive", type: i4$1.LabelDirective, selector: "label[for]", inputs: ["for", "labelClass"] }, { kind: "ngmodule", type: SVGIconModule }, { kind: "component", type: CkTypeSelectorInputComponent, selector: "mm-ck-type-selector-input", inputs: ["placeholder", "minSearchLength", "maxResults", "debounceMs", "ckModelIds", "allowAbstract", "dialogTitle", "advancedSearchLabel", "derivedFromRtCkTypeId", "messages", "dialogMessages", "disabled", "required"], outputs: ["ckTypeSelected", "ckTypeCleared"] }, { kind: "component", type: EntitySelectInputComponent, selector: "mm-entity-select-input", inputs: ["dataSource", "placeholder", "minSearchLength", "maxResults", "debounceMs", "prefix", "initialDisplayValue", "dialogDataSource", "dialogTitle", "multiSelect", "advancedSearchLabel", "dialogMessages", "messages", "disabled", "required"], outputs: ["entitySelected", "entityCleared", "entitiesSelected"] }, { kind: "component", type: EntitySelectorScopePathEditorComponent, selector: "mm-entity-selector-scope-path-editor", inputs: ["sourceCkTypeId", "hops"], outputs: ["hopsChange"] }, { kind: "component", type: FieldFilterEditorComponent, selector: "mm-field-filter-editor", inputs: ["availableAttributes", "ckTypeId", "hideNavigationProperties", "attributePaths", "enableVariables", "availableVariables", "filters"], outputs: ["filtersChange"] }], changeDetection: i0.ChangeDetectionStrategy.Eager });
|
|
32421
34168
|
}
|
|
32422
34169
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.5", ngImport: i0, type: EntitySelectorEditorComponent, decorators: [{
|
|
32423
34170
|
type: Component,
|
|
@@ -32429,38 +34176,67 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.5", ngImpor
|
|
|
32429
34176
|
LabelModule,
|
|
32430
34177
|
SVGIconModule,
|
|
32431
34178
|
CkTypeSelectorInputComponent,
|
|
32432
|
-
EntitySelectInputComponent
|
|
32433
|
-
], changeDetection: ChangeDetectionStrategy.Eager, template: "<div class=\"entity-selector-editor\">\n @if (!isEditing) {\n <!-- Selector List -->\n <div class=\"selector-list\">\n @for (selector of entitySelectors; track selector.id; let i = $index) {\n <div class=\"selector-item\">\n <div class=\"selector-info\">\n <span class=\"selector-label\">{{ selector.label }}</span>\n <span class=\"selector-mode\">{{ getModeLabel(selector) }}</span>\n <span class=\"selector-ck-type\">{{ selector.ckTypeId }}</span>\n <span class=\"selector-mappings\">{{ getMappingSummary(selector) }}</span>\n </div>\n <div class=\"selector-actions\">\n <button\n kendoButton\n [svgIcon]=\"pencilIcon\"\n (click)=\"editSelector(i)\"\n fillMode=\"flat\"\n size=\"small\"\n title=\"Edit selector\"\n [attr.aria-label]=\"'Edit selector ' + (selector.label || selector.ckTypeId)\">\n </button>\n <button\n kendoButton\n [svgIcon]=\"trashIcon\"\n (click)=\"removeSelector(i)\"\n fillMode=\"flat\"\n size=\"small\"\n themeColor=\"error\"\n title=\"Remove selector\"\n [attr.aria-label]=\"'Remove selector ' + (selector.label || selector.ckTypeId)\"\n class=\"remove-btn\">\n </button>\n </div>\n </div>\n }\n\n @if (entitySelectors.length === 0) {\n <div class=\"empty-state\">\n No entity selectors configured. Add one to enable entity-based variables.\n </div>\n }\n </div>\n\n <div class=\"add-button-row\">\n <button\n kendoButton\n [svgIcon]=\"plusIcon\"\n (click)=\"addSelector()\"\n themeColor=\"primary\">\n Add Entity Selector\n </button>\n </div>\n } @else {\n <!-- Edit Form -->\n <div class=\"edit-panel\">\n <div class=\"edit-panel-header\">\n {{ editingIndex !== null ? 'Edit Entity Selector' : 'New Entity Selector' }}\n </div>\n <div class=\"edit-form\">\n <div class=\"form-field\">\n <label class=\"field-label\">ID *</label>\n <kendo-textbox\n [(ngModel)]=\"editId\"\n placeholder=\"e.g., mp\"\n [readonly]=\"editingIndex !== null\">\n </kendo-textbox>\n @if (isDuplicateId(editId, editingIndex)) {\n <span class=\"field-error\">ID must be unique</span>\n }\n <span class=\"field-hint\">Used in URL parameters (es_<id>=<rtId>)</span>\n </div>\n\n <div class=\"form-field\">\n <label class=\"field-label\">Label *</label>\n <kendo-textbox\n [(ngModel)]=\"editLabel\"\n placeholder=\"e.g., Metering Point\">\n </kendo-textbox>\n </div>\n\n <div class=\"form-field\">\n <label class=\"field-label\">CK Type *</label>\n <mm-ck-type-selector-input\n [ngModel]=\"editCkTypeItem\"\n [placeholder]=\"'Select CK type...'\"\n (ckTypeSelected)=\"onCkTypeSelected($event)\"\n (ckTypeCleared)=\"onCkTypeCleared()\">\n </mm-ck-type-selector-input>\n </div>\n\n <div class=\"form-field\">\n <label class=\"field-label\">Attribute Mappings *</label>\n <button\n kendoButton\n (click)=\"selectAttributes()\"\n [disabled]=\"!editCkTypeId\"\n fillMode=\"outline\"\n size=\"small\">\n Select Attributes...\n </button>\n\n @if (editMappings.length > 0) {\n <div class=\"mappings-list\">\n <div class=\"mappings-header\">\n <span class=\"mappings-header-attr\">Attribute</span>\n <span class=\"mappings-header-var\">Variable Name</span>\n </div>\n @for (mapping of editMappings; track mapping.attributePath; let j = $index) {\n <div class=\"mapping-item\">\n <span class=\"mapping-path\">{{ mapping.attributePath }}</span>\n <span class=\"mapping-arrow\">→</span>\n <div class=\"mapping-var-field\">\n <div class=\"mapping-var-input-wrapper\">\n <span class=\"mapping-var-prefix\">$</span>\n <kendo-textbox\n [(ngModel)]=\"mapping.variableName\"\n size=\"small\"\n class=\"mapping-var-input\"\n placeholder=\"variableName\"\n [class.invalid]=\"getVariableNameError(mapping)\">\n </kendo-textbox>\n </div>\n @if (getVariableNameError(mapping); as error) {\n <span class=\"field-error\">{{ error }}</span>\n }\n </div>\n </div>\n }\n </div>\n }\n </div>\n\n <div class=\"form-field\">\n <div class=\"checkbox-row\">\n <input\n type=\"checkbox\"\n kendoCheckBox\n [(ngModel)]=\"editShowInToolbar\"\n id=\"showInToolbar\" />\n <label class=\"field-label checkbox-label\" for=\"showInToolbar\">Show in Toolbar</label>\n </div>\n <span class=\"field-hint\">\n @if (editShowInToolbar) {\n Users can select an entity from a dropdown in the toolbar.\n } @else {\n Hidden selector \u2014 variables are resolved from the default entity below.\n }\n </span>\n </div>\n\n <div class=\"form-field\">\n <label class=\"field-label\">Default Entity{{ !editShowInToolbar ? ' *' : '' }}</label>\n @if (defaultEntityDataSource) {\n <mm-entity-select-input\n [dataSource]=\"defaultEntityDataSource\"\n [dialogDataSource]=\"defaultEntityDialogDataSource!\"\n [placeholder]=\"'Select default entity...'\"\n [dialogTitle]=\"'Select Default ' + editLabel\"\n (entitySelected)=\"onDefaultEntitySelected($event)\"\n (entityCleared)=\"onDefaultEntityCleared()\">\n </mm-entity-select-input>\n } @else {\n <span class=\"field-hint\">Select a CK type first to pick a default entity.</span>\n }\n @if (editDefaultRtId) {\n <span class=\"field-hint\">Selected: {{ editDefaultRtId }}</span>\n }\n @if (!editShowInToolbar && !editDefaultRtId) {\n <span class=\"field-error\">A default entity is required when not shown in toolbar.</span>\n }\n <span class=\"field-hint\">\n @if (editShowInToolbar) {\n Optional \u2014 pre-selected entity when the MeshBoard loads.\n } @else {\n This entity's attributes will be used to populate the variables.\n }\n </span>\n </div>\n\n <div class=\"form-field\">\n <label class=\"field-label\">Child Scope (stream-data)</label>\n <span class=\"field-hint\">\n Optional. Resolves the picked entity to its child source rtIds (one\n association hop) so a stream-data widget can scope to them \u2014 e.g. a\n MeteringPoint resolved to its EnergyMeasurement rtIds. Leave empty to\n scope by the picked entity's own rtId.\n </span>\n <mm-ck-type-selector-input\n [ngModel]=\"editChildScopeTargetCkTypeItem\"\n [placeholder]=\"'Child CK type (e.g. EnergyMeasurement)...'\"\n (ckTypeSelected)=\"onChildScopeCkTypeSelected($event)\"\n (ckTypeCleared)=\"onChildScopeCkTypeCleared()\">\n </mm-ck-type-selector-input>\n <kendo-textbox\n [(ngModel)]=\"editChildScopeRoleId\"\n placeholder=\"Association role (e.g. System/ParentChild)\">\n </kendo-textbox>\n <label class=\"field-label\">Direction</label>\n <select [(ngModel)]=\"editChildScopeDirection\" class=\"child-scope-direction\">\n <option value=\"out\">Outbound (parent \u2192 child)</option>\n <option value=\"in\">Inbound (child \u2192 parent)</option>\n </select>\n </div>\n\n <div class=\"edit-actions mm-dialog-actions\">\n <button kendoButton (click)=\"cancelEdit()\" fillMode=\"flat\">\n Cancel\n </button>\n <button\n kendoButton\n (click)=\"saveEdit()\"\n [disabled]=\"!isEditValid()\"\n themeColor=\"primary\">\n {{ editingIndex !== null ? 'Update' : 'Add' }}\n </button>\n </div>\n </div>\n </div>\n }\n</div>\n", styles: [".entity-selector-editor{display:flex;flex-direction:column;gap:1.25rem}.selector-list{display:flex;flex-direction:column;gap:.5rem}.selector-item{display:flex;justify-content:space-between;align-items:center;padding:.75rem 1rem;background:var(--kendo-color-surface-alt, #fafafa);border:1px solid var(--kendo-color-border, #e0e0e0);border-radius:4px}.selector-item .selector-info{display:flex;flex-direction:column;gap:.25rem;min-width:0;flex:1}.selector-item .selector-label{font-weight:500;color:var(--kendo-color-on-app-surface, #424242)}.selector-item .selector-mode{font-size:.75rem;font-weight:500;color:var(--kendo-color-info, #0288d1)}.selector-item .selector-ck-type{font-size:.8rem;color:var(--kendo-color-subtle, #757575);font-family:var(--theme-font-mono)}.selector-item .selector-mappings{font-size:.8rem;color:var(--kendo-color-primary, #3f51b5);font-family:var(--theme-font-mono)}.selector-item .selector-actions{display:flex;gap:.25rem;flex-shrink:0}.selector-item .remove-btn{color:var(--kendo-color-error, #f44336)}.empty-state{padding:1.5rem;text-align:center;color:var(--kendo-color-subtle, #757575);font-size:.875rem}.add-button-row{display:flex;justify-content:flex-start}.edit-panel{border:1px solid var(--kendo-color-border, #e0e0e0);border-radius:4px;overflow:hidden}.edit-panel-header{padding:.625rem 1rem;font-weight:600;font-size:.875rem;color:var(--kendo-color-on-primary, #ffffff);background:var(--kendo-color-primary, #3f51b5)}.edit-form{display:flex;flex-direction:column;gap:1.25rem;padding:1.25rem}.edit-form .form-field{display:flex;flex-direction:column;gap:.5rem}.edit-form .field-label{font-weight:500;font-size:.875rem;color:var(--kendo-color-on-app-surface, #424242)}.edit-form .field-hint{font-size:.75rem;color:var(--kendo-color-subtle, #757575)}.edit-form .field-error{font-size:.75rem;color:var(--kendo-color-error, #f44336)}.edit-form .checkbox-row{display:flex;align-items:center;gap:.5rem}.edit-form .checkbox-row .checkbox-label{margin:0;cursor:pointer}.mappings-list{display:flex;flex-direction:column;gap:.5rem;margin-top:.5rem;padding:.75rem;background:var(--kendo-color-surface-alt, #fafafa);border:1px solid var(--kendo-color-border, #e0e0e0);border-radius:4px}.mappings-header{display:flex;align-items:center;gap:.5rem;font-size:.75rem;font-weight:600;color:var(--kendo-color-subtle, #757575);padding-bottom:.25rem;border-bottom:1px solid var(--kendo-color-border, #e0e0e0)}.mappings-header .mappings-header-attr{min-width:150px}.mappings-header .mappings-header-var{margin-left:1.75rem}.mapping-item{display:flex;align-items:flex-start;gap:.5rem}.mapping-item .mapping-path{font-size:.8rem;font-family:var(--theme-font-mono);color:var(--kendo-color-subtle, #757575);min-width:150px;padding-top:5px}.mapping-item .mapping-arrow{color:var(--kendo-color-subtle, #757575);flex-shrink:0;padding-top:5px}.mapping-item .mapping-var-field{display:flex;flex-direction:column;gap:2px;flex:1;min-width:100px}.mapping-item .mapping-var-input-wrapper{display:flex;align-items:center;gap:0}.mapping-item .mapping-var-prefix{font-family:var(--theme-font-mono);font-size:.875rem;font-weight:600;color:var(--kendo-color-primary, #3f51b5);padding-right:2px;flex-shrink:0}.mapping-item .mapping-var-input{flex:1}.mapping-item .mapping-var-input.invalid ::ng-deep .k-input{border-color:var(--kendo-color-error, #f44336)}\n"] }]
|
|
32434
|
-
|
|
34179
|
+
EntitySelectInputComponent,
|
|
34180
|
+
EntitySelectorScopePathEditorComponent,
|
|
34181
|
+
FieldFilterEditorComponent
|
|
34182
|
+
], changeDetection: ChangeDetectionStrategy.Eager, template: "<div class=\"entity-selector-editor\">\n @if (!isEditing) {\n <!-- Selector List -->\n <div class=\"selector-list\">\n @for (selector of entitySelectors; track selector.id; let i = $index) {\n <div class=\"selector-item\">\n <div class=\"selector-info\">\n <span class=\"selector-label\">{{ selector.label }}</span>\n <span class=\"selector-mode\" [class.selector-mode-error]=\"isOrphanFollower(selector)\">{{ getModeLabel(selector) }}</span>\n @if (getFilterSummary(selector); as filterSummary) {\n <span class=\"selector-mode selector-filter-chip\">{{ filterSummary }}</span>\n }\n <span class=\"selector-ck-type\">{{ selector.ckTypeId }}</span>\n <span class=\"selector-mappings\">{{ getMappingSummary(selector) }}</span>\n </div>\n <div class=\"selector-actions\">\n <button\n kendoButton\n [svgIcon]=\"pencilIcon\"\n (click)=\"editSelector(i)\"\n fillMode=\"flat\"\n size=\"small\"\n title=\"Edit selector\"\n [attr.aria-label]=\"'Edit selector ' + (selector.label || selector.ckTypeId)\">\n </button>\n <button\n kendoButton\n [svgIcon]=\"trashIcon\"\n (click)=\"removeSelector(i)\"\n fillMode=\"flat\"\n size=\"small\"\n themeColor=\"error\"\n title=\"Remove selector\"\n [attr.aria-label]=\"'Remove selector ' + (selector.label || selector.ckTypeId)\"\n class=\"remove-btn\">\n </button>\n </div>\n </div>\n }\n\n @if (entitySelectors.length === 0) {\n <div class=\"empty-state\">\n No entity selectors configured. Add one to enable entity-based variables.\n </div>\n }\n </div>\n\n <div class=\"add-button-row\">\n <button\n kendoButton\n [svgIcon]=\"plusIcon\"\n (click)=\"addSelector()\"\n themeColor=\"primary\">\n Add Entity Selector\n </button>\n </div>\n } @else {\n <!-- Edit Form -->\n <div class=\"edit-panel\">\n <div class=\"edit-panel-header\">\n {{ editingIndex !== null ? 'Edit Entity Selector' : 'New Entity Selector' }}\n </div>\n <div class=\"edit-form\">\n <div class=\"form-field\">\n <label class=\"field-label\">ID *</label>\n <kendo-textbox\n [(ngModel)]=\"editId\"\n placeholder=\"e.g., mp\"\n [readonly]=\"editingIndex !== null\">\n </kendo-textbox>\n @if (isDuplicateId(editId, editingIndex)) {\n <span class=\"field-error\">ID must be unique</span>\n }\n <span class=\"field-hint\">Used in URL parameters (es_<id>=<rtId>)</span>\n </div>\n\n <div class=\"form-field\">\n <label class=\"field-label\">Label *</label>\n <kendo-textbox\n [(ngModel)]=\"editLabel\"\n placeholder=\"e.g., Metering Point\">\n </kendo-textbox>\n </div>\n\n <div class=\"form-field\">\n <label class=\"field-label\" for=\"followsSelectorId\">Follows</label>\n <select\n id=\"followsSelectorId\"\n class=\"follows-select\"\n [ngModel]=\"editFollowsSelectorId\"\n (ngModelChange)=\"onFollowsChanged($event)\"\n [disabled]=\"followedBy.length > 0 && !editFollowsSelectorId\">\n <option value=\"\">\u2014 none (own picker / default entity) \u2014</option>\n @for (leader of leaderOptions; track leader.id) {\n <option [value]=\"leader.id\">{{ leader.label || leader.id }} ({{ leader.id }})</option>\n }\n @if (isLeaderUnavailable) {\n <option [value]=\"editFollowsSelectorId\">{{ editFollowsSelectorId }} (unavailable)</option>\n }\n </select>\n @if (followError) {\n <span class=\"field-error\">{{ followError }}</span>\n } @else if (followedBy.length > 0) {\n <span class=\"field-hint\">Followed by {{ followedByLabels }} \u2014 a selector that is followed cannot follow another one.</span>\n }\n @if (followNotice) {\n <span class=\"field-hint follow-notice\">{{ followNotice }}</span>\n }\n <span class=\"field-hint\">\n Takes the pick of the selected selector. The CK type is fixed to the leader's.\n A follower never shows a picker and is not written to the URL.\n </span>\n </div>\n\n <div class=\"form-field\">\n <label class=\"field-label\">CK Type *</label>\n @if (isFollower) {\n <kendo-textbox [value]=\"editCkTypeId\" [readonly]=\"true\"></kendo-textbox>\n <span class=\"field-hint\">Fixed to the leader's CK type.</span>\n } @else {\n <mm-ck-type-selector-input\n [ngModel]=\"editCkTypeItem\"\n [placeholder]=\"'Select CK type...'\"\n (ckTypeSelected)=\"onCkTypeSelected($event)\"\n (ckTypeCleared)=\"onCkTypeCleared()\">\n </mm-ck-type-selector-input>\n }\n </div>\n\n @if (editCkTypeId) {\n <div class=\"form-field picker-filter-field\">\n <label class=\"field-label\">Picker filter</label>\n @if (isFollower) {\n <span class=\"field-hint\">A follower takes the leader's pick. Set the filter on {{ leaderLabel }}.</span>\n @if (followerFilterCount > 0) {\n <span class=\"field-hint follow-notice\">Filters on a follower are ignored and are removed when you save.</span>\n }\n } @else {\n <span class=\"field-hint\">\n Only entities that match all rows are listed in the toolbar picker and in the default-entity picker below.\n </span>\n <mm-field-filter-editor\n [ckTypeId]=\"editCkTypeId\"\n [filters]=\"editFilters\"\n [enableVariables]=\"filterVariables.length > 0\"\n [availableVariables]=\"filterVariables\"\n (filtersChange)=\"onFiltersChange($event)\">\n </mm-field-filter-editor>\n }\n </div>\n }\n\n <div class=\"form-field\">\n <label class=\"field-label\">Attribute Mappings{{ isFollower ? '' : ' *' }}</label>\n <button\n kendoButton\n (click)=\"selectAttributes()\"\n [disabled]=\"!editCkTypeId\"\n fillMode=\"outline\"\n size=\"small\">\n Select Attributes...\n </button>\n\n @if (editMappings.length > 0) {\n <div class=\"mappings-list\">\n <div class=\"mappings-header\">\n <span class=\"mappings-header-attr\">Attribute</span>\n <span class=\"mappings-header-var\">Variable Name</span>\n </div>\n @for (mapping of editMappings; track mapping.attributePath; let j = $index) {\n <div class=\"mapping-item\">\n <span class=\"mapping-path\">{{ mapping.attributePath }}</span>\n <span class=\"mapping-arrow\">→</span>\n <div class=\"mapping-var-field\">\n <div class=\"mapping-var-input-wrapper\">\n <span class=\"mapping-var-prefix\">$</span>\n <kendo-textbox\n [(ngModel)]=\"mapping.variableName\"\n size=\"small\"\n class=\"mapping-var-input\"\n placeholder=\"variableName\"\n [class.invalid]=\"getVariableNameError(mapping)\">\n </kendo-textbox>\n </div>\n @if (getVariableNameError(mapping); as error) {\n <span class=\"field-error\">{{ error }}</span>\n }\n </div>\n </div>\n }\n </div>\n }\n </div>\n\n @if (!isFollower) {\n <div class=\"form-field\">\n <div class=\"checkbox-row\">\n <input\n type=\"checkbox\"\n kendoCheckBox\n [(ngModel)]=\"editShowInToolbar\"\n id=\"showInToolbar\" />\n <label class=\"field-label checkbox-label\" for=\"showInToolbar\">Show in Toolbar</label>\n </div>\n <span class=\"field-hint\">\n @if (editShowInToolbar) {\n Users can select an entity from a dropdown in the toolbar.\n } @else {\n Hidden selector \u2014 variables are resolved from the default entity below.\n }\n </span>\n </div>\n }\n\n <div class=\"form-field\">\n <label class=\"field-label\">Default Entity{{ !editShowInToolbar && !isFollower ? ' *' : '' }}</label>\n @if (defaultEntityDataSource) {\n <mm-entity-select-input\n [dataSource]=\"defaultEntityDataSource\"\n [dialogDataSource]=\"defaultEntityDialogDataSource!\"\n [placeholder]=\"'Select default entity...'\"\n [dialogTitle]=\"'Select Default ' + editLabel\"\n (entitySelected)=\"onDefaultEntitySelected($event)\"\n (entityCleared)=\"onDefaultEntityCleared()\">\n </mm-entity-select-input>\n } @else {\n <span class=\"field-hint\">Select a CK type first to pick a default entity.</span>\n }\n @if (editDefaultRtId) {\n <span class=\"field-hint\">Selected: {{ editDefaultRtId }}</span>\n }\n @switch (defaultFilterCheck.state) {\n @case ('mismatch') {\n <span class=\"field-warning default-filter-warning\">The default entity does not match the picker filter. It still loads, but users cannot pick it again.</span>\n }\n @case ('error') {\n <span class=\"field-hint default-filter-warning\">Could not check the default entity.</span>\n }\n @case ('unchecked') {\n <span class=\"field-hint default-filter-warning\">Cannot check the default entity: the filter uses {{ uncheckedVariablesText }}, which is filled at view time.</span>\n }\n }\n @if (!editShowInToolbar && !isFollower && !editDefaultRtId) {\n <span class=\"field-error\">A default entity is required when not shown in toolbar.</span>\n }\n <span class=\"field-hint\">\n @if (isFollower) {\n Optional \u2014 used only while the leader has no pick.\n } @else if (editShowInToolbar) {\n Optional \u2014 pre-selected entity when the MeshBoard loads.\n } @else {\n This entity's attributes will be used to populate the variables.\n }\n </span>\n </div>\n\n <div class=\"form-field\">\n <label class=\"field-label\">Child Scope (stream-data)</label>\n <mm-entity-selector-scope-path-editor\n [sourceCkTypeId]=\"editCkTypeId\"\n [hops]=\"editScopeHops\"\n (hopsChange)=\"onScopeHopsChanged($event)\">\n </mm-entity-selector-scope-path-editor>\n </div>\n\n <div class=\"edit-actions mm-dialog-actions\">\n <button kendoButton (click)=\"cancelEdit()\" fillMode=\"flat\">\n Cancel\n </button>\n <button\n kendoButton\n (click)=\"saveEdit()\"\n [disabled]=\"!isEditValid()\"\n themeColor=\"primary\">\n {{ editingIndex !== null ? 'Update' : 'Add' }}\n </button>\n </div>\n </div>\n </div>\n }\n</div>\n", styles: [".entity-selector-editor{display:flex;flex-direction:column;gap:1.25rem}.selector-list{display:flex;flex-direction:column;gap:.5rem}.selector-item{display:flex;justify-content:space-between;align-items:center;padding:.75rem 1rem;background:var(--kendo-color-surface-alt, #fafafa);border:1px solid var(--kendo-color-border, #e0e0e0);border-radius:4px}.selector-item .selector-info{display:flex;flex-direction:column;gap:.25rem;min-width:0;flex:1}.selector-item .selector-label{font-weight:500;color:var(--kendo-color-on-app-surface, #424242)}.selector-item .selector-mode{font-size:.75rem;font-weight:500;color:var(--kendo-color-info, #0288d1)}.selector-item .selector-mode-error{color:var(--kendo-color-error, #d32f2f)}.selector-item .selector-filter-chip{color:var(--kendo-color-subtle, #757575)}.selector-item .selector-ck-type{font-size:.8rem;color:var(--kendo-color-subtle, #757575);font-family:var(--theme-font-mono)}.selector-item .selector-mappings{font-size:.8rem;color:var(--kendo-color-primary, #3f51b5);font-family:var(--theme-font-mono)}.selector-item .selector-actions{display:flex;gap:.25rem;flex-shrink:0}.selector-item .remove-btn{color:var(--kendo-color-error, #f44336)}.empty-state{padding:1.5rem;text-align:center;color:var(--kendo-color-subtle, #757575);font-size:.875rem}.add-button-row{display:flex;justify-content:flex-start}.edit-panel{border:1px solid var(--kendo-color-border, #e0e0e0);border-radius:4px;overflow:hidden}.edit-panel-header{padding:.625rem 1rem;font-weight:600;font-size:.875rem;color:var(--kendo-color-on-primary, #ffffff);background:var(--kendo-color-primary, #3f51b5)}.edit-form{display:flex;flex-direction:column;gap:1.25rem;padding:1.25rem}.edit-form .form-field{display:flex;flex-direction:column;gap:.5rem}.edit-form .field-label{font-weight:500;font-size:.875rem;color:var(--kendo-color-on-app-surface, #424242)}.edit-form .field-hint{font-size:.75rem;color:var(--kendo-color-subtle, #757575)}.edit-form .field-error{font-size:.75rem;color:var(--kendo-color-error, #f44336)}.edit-form .field-warning{font-size:.75rem;color:var(--kendo-color-warning, #f57c00)}.edit-form .checkbox-row{display:flex;align-items:center;gap:.5rem}.edit-form .checkbox-row .checkbox-label{margin:0;cursor:pointer}.mappings-list{display:flex;flex-direction:column;gap:.5rem;margin-top:.5rem;padding:.75rem;background:var(--kendo-color-surface-alt, #fafafa);border:1px solid var(--kendo-color-border, #e0e0e0);border-radius:4px}.mappings-header{display:flex;align-items:center;gap:.5rem;font-size:.75rem;font-weight:600;color:var(--kendo-color-subtle, #757575);padding-bottom:.25rem;border-bottom:1px solid var(--kendo-color-border, #e0e0e0)}.mappings-header .mappings-header-attr{min-width:150px}.mappings-header .mappings-header-var{margin-left:1.75rem}.mapping-item{display:flex;align-items:flex-start;gap:.5rem}.mapping-item .mapping-path{font-size:.8rem;font-family:var(--theme-font-mono);color:var(--kendo-color-subtle, #757575);min-width:150px;padding-top:5px}.mapping-item .mapping-arrow{color:var(--kendo-color-subtle, #757575);flex-shrink:0;padding-top:5px}.mapping-item .mapping-var-field{display:flex;flex-direction:column;gap:2px;flex:1;min-width:100px}.mapping-item .mapping-var-input-wrapper{display:flex;align-items:center;gap:0}.mapping-item .mapping-var-prefix{font-family:var(--theme-font-mono);font-size:.875rem;font-weight:600;color:var(--kendo-color-primary, #3f51b5);padding-right:2px;flex-shrink:0}.mapping-item .mapping-var-input{flex:1}.mapping-item .mapping-var-input.invalid ::ng-deep .k-input{border-color:var(--kendo-color-error, #f44336)}\n"] }]
|
|
34183
|
+
}], ctorParameters: () => [], propDecorators: { entitySelectors: [{
|
|
32435
34184
|
type: Input
|
|
32436
34185
|
}], existingVariableNames: [{
|
|
32437
34186
|
type: Input
|
|
34187
|
+
}], variables: [{
|
|
34188
|
+
type: Input
|
|
32438
34189
|
}], entitySelectorsChange: [{
|
|
32439
34190
|
type: Output
|
|
32440
34191
|
}], editingStateChange: [{
|
|
32441
34192
|
type: Output
|
|
32442
34193
|
}] } });
|
|
34194
|
+
/** Debounce of the default-entity filter check (ms). */
|
|
34195
|
+
const DEFAULT_CHECK_DEBOUNCE_MS = 400;
|
|
32443
34196
|
|
|
32444
34197
|
/**
|
|
32445
34198
|
* Toolbar component that renders entity select inputs for each configured entity selector.
|
|
32446
|
-
* Creates data sources per CK type and emits selection/clear events.
|
|
34199
|
+
* Creates data sources per CK type and picker filter (AB#7724) and emits selection/clear events.
|
|
32447
34200
|
*/
|
|
32448
34201
|
class EntitySelectorToolbarComponent {
|
|
32449
34202
|
getEntitiesByCkTypeGQL = inject(GetEntitiesByCkTypeDtoGQL);
|
|
34203
|
+
variableService = inject(MeshBoardVariableService);
|
|
32450
34204
|
entitySelectors = [];
|
|
34205
|
+
/**
|
|
34206
|
+
* Board variables substituted into the selectors' picker filters (AB#7724),
|
|
34207
|
+
* the same substitution widget filters get.
|
|
34208
|
+
*/
|
|
34209
|
+
variables = [];
|
|
32451
34210
|
entitySelected = new EventEmitter();
|
|
32452
34211
|
entityCleared = new EventEmitter();
|
|
32453
|
-
// Cached data sources per CK type
|
|
34212
|
+
// Cached data sources per CK type and resolved picker filter
|
|
32454
34213
|
dataSourceCache = new Map();
|
|
32455
34214
|
dialogDataSourceCache = new Map();
|
|
32456
34215
|
// Map of selector ID -> data sources for template
|
|
32457
34216
|
selectorDataSources = new Map();
|
|
32458
34217
|
selectorDialogDataSources = new Map();
|
|
34218
|
+
// Picker texts of filtered selectors (absent ⇒ the input's defaults)
|
|
34219
|
+
selectorMessages = new Map();
|
|
32459
34220
|
ngOnChanges(changes) {
|
|
32460
|
-
if (changes['entitySelectors']) {
|
|
34221
|
+
if (changes['entitySelectors'] || changes['variables']) {
|
|
32461
34222
|
this.updateDataSources();
|
|
32462
34223
|
}
|
|
32463
34224
|
}
|
|
34225
|
+
/** Texts of the selector's picker: a filtered picker explains its empty list (AB#7724). */
|
|
34226
|
+
getMessages(selector) {
|
|
34227
|
+
return this.selectorMessages.get(selector.id) ?? NO_MESSAGES;
|
|
34228
|
+
}
|
|
34229
|
+
/** Title of the selector's search dialog; marks a filtered picker (AB#7724). */
|
|
34230
|
+
getDialogTitle(selector) {
|
|
34231
|
+
return this.selectorMessages.has(selector.id) ? filteredDialogTitle(selector.label) : 'Select ' + selector.label;
|
|
34232
|
+
}
|
|
34233
|
+
/**
|
|
34234
|
+
* True when the selector renders a picker. Hidden selectors and followers
|
|
34235
|
+
* (AB#7722) never do; a follower's pick is derived from its leader.
|
|
34236
|
+
*/
|
|
34237
|
+
showsInToolbar(selector) {
|
|
34238
|
+
return showsInToolbar(selector);
|
|
34239
|
+
}
|
|
32464
34240
|
/**
|
|
32465
34241
|
* Returns the data source for a selector.
|
|
32466
34242
|
*/
|
|
@@ -32486,23 +34262,45 @@ class EntitySelectorToolbarComponent {
|
|
|
32486
34262
|
this.entityCleared.emit({ selectorId });
|
|
32487
34263
|
}
|
|
32488
34264
|
/**
|
|
32489
|
-
* Creates or retrieves cached data sources for each selector.
|
|
34265
|
+
* Creates or retrieves cached data sources for each selector. Selectors of
|
|
34266
|
+
* the same CK type and the same resolved filter share their data sources; a
|
|
34267
|
+
* selector without filter uses the plain query, exactly as before AB#7724.
|
|
34268
|
+
* Entries no selector uses any more (e.g. after a filter variable changed)
|
|
34269
|
+
* are dropped, so the caches never outgrow the current selectors.
|
|
32490
34270
|
*/
|
|
32491
34271
|
updateDataSources() {
|
|
34272
|
+
this.selectorMessages = new Map();
|
|
34273
|
+
const usedKeys = new Set();
|
|
32492
34274
|
for (const selector of this.entitySelectors) {
|
|
32493
|
-
|
|
32494
|
-
|
|
32495
|
-
|
|
34275
|
+
const filters = pickerFieldFilters(selector, this.variables, this.variableService);
|
|
34276
|
+
const key = filters ? `${selector.ckTypeId}|${JSON.stringify(filters)}` : selector.ckTypeId;
|
|
34277
|
+
usedKeys.add(key);
|
|
34278
|
+
if (!this.dataSourceCache.has(key) || !this.dialogDataSourceCache.has(key)) {
|
|
34279
|
+
const gql = withPickerFilters(this.getEntitiesByCkTypeGQL, filters);
|
|
34280
|
+
this.dataSourceCache.set(key, new RuntimeEntitySelectDataSource(gql, selector.ckTypeId));
|
|
34281
|
+
this.dialogDataSourceCache.set(key, new RuntimeEntityDialogDataSource(gql, selector.ckTypeId));
|
|
34282
|
+
}
|
|
34283
|
+
this.selectorDataSources.set(selector.id, this.dataSourceCache.get(key));
|
|
34284
|
+
this.selectorDialogDataSources.set(selector.id, this.dialogDataSourceCache.get(key));
|
|
34285
|
+
if (filters) {
|
|
34286
|
+
this.selectorMessages.set(selector.id, {
|
|
34287
|
+
noEntitiesFound: filteredNoEntitiesMessage(selector.label, unresolvedFilterVariables(filters))
|
|
34288
|
+
});
|
|
32496
34289
|
}
|
|
32497
|
-
|
|
32498
|
-
|
|
34290
|
+
}
|
|
34291
|
+
for (const key of [...this.dataSourceCache.keys()]) {
|
|
34292
|
+
if (!usedKeys.has(key)) {
|
|
34293
|
+
this.dataSourceCache.delete(key);
|
|
34294
|
+
this.dialogDataSourceCache.delete(key);
|
|
32499
34295
|
}
|
|
32500
|
-
this.selectorDataSources.set(selector.id, this.dataSourceCache.get(selector.ckTypeId));
|
|
32501
|
-
this.selectorDialogDataSources.set(selector.id, this.dialogDataSourceCache.get(selector.ckTypeId));
|
|
32502
34296
|
}
|
|
32503
34297
|
}
|
|
34298
|
+
/** Number of cached data source pairs (for tests). */
|
|
34299
|
+
get cachedDataSourceCount() {
|
|
34300
|
+
return this.dataSourceCache.size;
|
|
34301
|
+
}
|
|
32504
34302
|
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.5", ngImport: i0, type: EntitySelectorToolbarComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
32505
|
-
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.1.5", type: EntitySelectorToolbarComponent, isStandalone: true, selector: "mm-entity-selector-toolbar", inputs: { entitySelectors: "entitySelectors" }, outputs: { entitySelected: "entitySelected", entityCleared: "entityCleared" }, usesOnChanges: true, ngImport: i0, template: "<div class=\"entity-selector-toolbar\">\n @for (selector of entitySelectors; track selector.id) {\n @if (selector
|
|
34303
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.1.5", type: EntitySelectorToolbarComponent, isStandalone: true, selector: "mm-entity-selector-toolbar", inputs: { entitySelectors: "entitySelectors", variables: "variables" }, outputs: { entitySelected: "entitySelected", entityCleared: "entityCleared" }, usesOnChanges: true, ngImport: i0, template: "<div class=\"entity-selector-toolbar\">\n @for (selector of entitySelectors; track selector.id) {\n @if (showsInToolbar(selector)) {\n <div class=\"entity-selector-item\">\n <kendo-label [text]=\"selector.label\">\n <mm-entity-select-input\n [dataSource]=\"getDataSource(selector)\"\n [dialogDataSource]=\"getDialogDataSource(selector)\"\n [initialDisplayValue]=\"selector.selectedDisplayName\"\n [placeholder]=\"'Select ' + selector.label + '...'\"\n [dialogTitle]=\"getDialogTitle(selector)\"\n [messages]=\"getMessages(selector)\"\n (entitySelected)=\"onEntitySelected(selector.id, $event)\"\n (entityCleared)=\"onEntityCleared(selector.id)\">\n </mm-entity-select-input>\n </kendo-label>\n </div>\n }\n }\n</div>\n", styles: [".entity-selector-toolbar{display:flex;align-items:flex-end;gap:1rem;flex-wrap:wrap}.entity-selector-item{display:flex;flex-direction:column}.entity-selector-item kendo-label{display:flex;flex-direction:column;gap:.25rem}.entity-selector-item mm-entity-select-input{min-width:200px;max-width:300px}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "component", type: i4$1.LabelComponent, selector: "kendo-label", inputs: ["text", "for", "optional", "labelCssStyle", "labelCssClass"], exportAs: ["kendoLabel"] }, { kind: "component", type: EntitySelectInputComponent, selector: "mm-entity-select-input", inputs: ["dataSource", "placeholder", "minSearchLength", "maxResults", "debounceMs", "prefix", "initialDisplayValue", "dialogDataSource", "dialogTitle", "multiSelect", "advancedSearchLabel", "dialogMessages", "messages", "disabled", "required"], outputs: ["entitySelected", "entityCleared", "entitiesSelected"] }], changeDetection: i0.ChangeDetectionStrategy.Eager });
|
|
32506
34304
|
}
|
|
32507
34305
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.5", ngImport: i0, type: EntitySelectorToolbarComponent, decorators: [{
|
|
32508
34306
|
type: Component,
|
|
@@ -32510,14 +34308,17 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.5", ngImpor
|
|
|
32510
34308
|
CommonModule,
|
|
32511
34309
|
KENDO_LABELS,
|
|
32512
34310
|
EntitySelectInputComponent
|
|
32513
|
-
], changeDetection: ChangeDetectionStrategy.Eager, template: "<div class=\"entity-selector-toolbar\">\n @for (selector of entitySelectors; track selector.id) {\n @if (selector
|
|
34311
|
+
], changeDetection: ChangeDetectionStrategy.Eager, template: "<div class=\"entity-selector-toolbar\">\n @for (selector of entitySelectors; track selector.id) {\n @if (showsInToolbar(selector)) {\n <div class=\"entity-selector-item\">\n <kendo-label [text]=\"selector.label\">\n <mm-entity-select-input\n [dataSource]=\"getDataSource(selector)\"\n [dialogDataSource]=\"getDialogDataSource(selector)\"\n [initialDisplayValue]=\"selector.selectedDisplayName\"\n [placeholder]=\"'Select ' + selector.label + '...'\"\n [dialogTitle]=\"getDialogTitle(selector)\"\n [messages]=\"getMessages(selector)\"\n (entitySelected)=\"onEntitySelected(selector.id, $event)\"\n (entityCleared)=\"onEntityCleared(selector.id)\">\n </mm-entity-select-input>\n </kendo-label>\n </div>\n }\n }\n</div>\n", styles: [".entity-selector-toolbar{display:flex;align-items:flex-end;gap:1rem;flex-wrap:wrap}.entity-selector-item{display:flex;flex-direction:column}.entity-selector-item kendo-label{display:flex;flex-direction:column;gap:.25rem}.entity-selector-item mm-entity-select-input{min-width:200px;max-width:300px}\n"] }]
|
|
32514
34312
|
}], propDecorators: { entitySelectors: [{
|
|
32515
34313
|
type: Input
|
|
34314
|
+
}], variables: [{
|
|
34315
|
+
type: Input
|
|
32516
34316
|
}], entitySelected: [{
|
|
32517
34317
|
type: Output
|
|
32518
34318
|
}], entityCleared: [{
|
|
32519
34319
|
type: Output
|
|
32520
34320
|
}] } });
|
|
34321
|
+
const NO_MESSAGES = {};
|
|
32521
34322
|
|
|
32522
34323
|
/** Below this container width the board stacks in a single column. */
|
|
32523
34324
|
const PHONE_MAX_WIDTH = 700;
|
|
@@ -32865,6 +34666,90 @@ function applyTimeFilterParams(url, selection) {
|
|
|
32865
34666
|
return applyQueryParams(url, timeFilterQueryParams(selection));
|
|
32866
34667
|
}
|
|
32867
34668
|
|
|
34669
|
+
/**
|
|
34670
|
+
* RFC 3339 date-time: `YYYY-MM-DDTHH:MM:SS`, optional 1-3 fraction digits, then `Z` or `±HH:MM`.
|
|
34671
|
+
* Uppercase `T`/`Z` only, seconds required, no compact offset (`+0100`): those forms parse
|
|
34672
|
+
* differently across browsers (Safari), and a string without an offset would be read in the
|
|
34673
|
+
* browser's own zone.
|
|
34674
|
+
*/
|
|
34675
|
+
const RFC3339 = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,3}))?(?:(Z)|([+-])(\d{2}):(\d{2}))$/;
|
|
34676
|
+
function daysInMonth(year, month) {
|
|
34677
|
+
return new Date(Date.UTC(year, month, 0)).getUTCDate();
|
|
34678
|
+
}
|
|
34679
|
+
/**
|
|
34680
|
+
* The instant of an RFC 3339 string, computed from its fields (never from `Date.parse`, so every
|
|
34681
|
+
* browser gets the same result), or `null` when the string is not RFC 3339 or names a date or time
|
|
34682
|
+
* that does not exist (`2026-02-30`, `24:00`, `23:60`, leap second `:60`, offset hour > 23).
|
|
34683
|
+
*/
|
|
34684
|
+
function parseRfc3339(value) {
|
|
34685
|
+
const m = RFC3339.exec(value);
|
|
34686
|
+
if (!m) {
|
|
34687
|
+
return null;
|
|
34688
|
+
}
|
|
34689
|
+
const [year, month, day, hour, minute, second] = m.slice(1, 7).map(Number);
|
|
34690
|
+
const millis = m[7] ? Number(m[7].padEnd(3, '0')) : 0;
|
|
34691
|
+
if (month < 1 || month > 12 || day < 1 || day > daysInMonth(year, month)
|
|
34692
|
+
|| hour > 23 || minute > 59 || second > 59) {
|
|
34693
|
+
return null;
|
|
34694
|
+
}
|
|
34695
|
+
let offsetMinutes = 0;
|
|
34696
|
+
if (!m[8]) {
|
|
34697
|
+
const offsetHours = Number(m[10]);
|
|
34698
|
+
const offsetMins = Number(m[11]);
|
|
34699
|
+
if (offsetHours > 23 || offsetMins > 59) {
|
|
34700
|
+
return null;
|
|
34701
|
+
}
|
|
34702
|
+
offsetMinutes = (m[9] === '-' ? -1 : 1) * (offsetHours * 60 + offsetMins);
|
|
34703
|
+
}
|
|
34704
|
+
// setUTCFullYear (not Date.UTC, which maps years 0-99 to 1900-1999) for the wall-clock fields,
|
|
34705
|
+
// then the offset: local wall clock = UTC + offset.
|
|
34706
|
+
const date = new Date(0);
|
|
34707
|
+
date.setUTCFullYear(year, month - 1, day);
|
|
34708
|
+
date.setUTCHours(hour, minute, second, millis);
|
|
34709
|
+
date.setTime(date.getTime() - offsetMinutes * 60_000);
|
|
34710
|
+
return Number.isNaN(date.getTime()) ? null : date;
|
|
34711
|
+
}
|
|
34712
|
+
function toInstant(value) {
|
|
34713
|
+
if (value instanceof Date) {
|
|
34714
|
+
return Number.isNaN(value.getTime()) ? null : new Date(value.getTime());
|
|
34715
|
+
}
|
|
34716
|
+
return typeof value === 'string' ? parseRfc3339(value) : null;
|
|
34717
|
+
}
|
|
34718
|
+
/**
|
|
34719
|
+
* Parses the host's `timeRange` input. `undefined`/`null` = the host sets no period.
|
|
34720
|
+
* Never snaps or converts: the instants are taken as given. `Date` objects are accepted as they
|
|
34721
|
+
* are; strings must be RFC 3339 with an offset, e.g. `2026-03-28T05:00:00Z` or
|
|
34722
|
+
* `2026-03-28T06:00:00+01:00` (see {@link RFC3339}).
|
|
34723
|
+
*/
|
|
34724
|
+
function parseHostTimeRange(value) {
|
|
34725
|
+
if (value === undefined || value === null) {
|
|
34726
|
+
return undefined;
|
|
34727
|
+
}
|
|
34728
|
+
const from = toInstant(value.from);
|
|
34729
|
+
const to = toInstant(value.to);
|
|
34730
|
+
if (!from || !to) {
|
|
34731
|
+
return {
|
|
34732
|
+
status: 'invalid',
|
|
34733
|
+
reason: 'from and to must be Date objects or RFC 3339 date-times with an offset '
|
|
34734
|
+
+ '(YYYY-MM-DDTHH:MM:SS[.sss] followed by Z or ±HH:MM)'
|
|
34735
|
+
};
|
|
34736
|
+
}
|
|
34737
|
+
if (from.getTime() >= to.getTime()) {
|
|
34738
|
+
return { status: 'invalid', reason: 'from must be before to' };
|
|
34739
|
+
}
|
|
34740
|
+
return { status: 'valid', from, to };
|
|
34741
|
+
}
|
|
34742
|
+
/** True when two parsed ranges mean the same period (same instants, or both invalid for the same reason). */
|
|
34743
|
+
function sameHostTimeRange(a, b) {
|
|
34744
|
+
if (!a || !b) {
|
|
34745
|
+
return a === b;
|
|
34746
|
+
}
|
|
34747
|
+
if (a.status === 'valid' && b.status === 'valid') {
|
|
34748
|
+
return a.from.getTime() === b.from.getTime() && a.to.getTime() === b.to.getTime();
|
|
34749
|
+
}
|
|
34750
|
+
return a.status === 'invalid' && b.status === 'invalid' && a.reason === b.reason;
|
|
34751
|
+
}
|
|
34752
|
+
|
|
32868
34753
|
/**
|
|
32869
34754
|
* Component for editing MeshBoard variables.
|
|
32870
34755
|
* Allows adding, editing, and removing static variables with type-specific input controls.
|
|
@@ -33201,7 +35086,7 @@ class MeshBoardSettingsDialogComponent {
|
|
|
33201
35086
|
this.windowRef.close();
|
|
33202
35087
|
}
|
|
33203
35088
|
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.5", ngImport: i0, type: MeshBoardSettingsDialogComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
33204
|
-
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.1.5", type: MeshBoardSettingsDialogComponent, isStandalone: true, selector: "mm-meshboard-settings-dialog", ngImport: i0, template: "<div class=\"meshboard-settings-dialog\">\n <kendo-tabstrip [animate]=\"false\">\n <!-- General Tab -->\n <kendo-tabstrip-tab [title]=\"'General'\" [selected]=\"true\">\n <ng-template kendoTabContent>\n <div class=\"tab-content\">\n <form class=\"settings-form\">\n <!-- Name Field -->\n <kendo-formfield>\n <kendo-label [for]=\"nameInput\" text=\"Name *\"></kendo-label>\n <kendo-textbox\n #nameInput\n [(ngModel)]=\"name\"\n name=\"name\"\n placeholder=\"Enter MeshBoard name\"\n required>\n </kendo-textbox>\n @if (name.trim().length === 0) {\n <kendo-formerror>Name is required</kendo-formerror>\n }\n </kendo-formfield>\n\n <!-- Description Field -->\n <kendo-formfield>\n <kendo-label [for]=\"descriptionInput\" text=\"Description\"></kendo-label>\n <kendo-textarea\n #descriptionInput\n [(ngModel)]=\"description\"\n name=\"description\"\n placeholder=\"Enter MeshBoard description (optional)\"\n [rows]=\"3\">\n </kendo-textarea>\n </kendo-formfield>\n\n <!-- Well-Known Name Field -->\n <kendo-formfield>\n <kendo-label [for]=\"wellKnownNameInput\" text=\"Well-Known Name\"></kendo-label>\n <kendo-textbox\n #wellKnownNameInput\n [(ngModel)]=\"rtWellKnownName\"\n name=\"rtWellKnownName\"\n placeholder=\"e.g., cockpit, dashboard-main\">\n </kendo-textbox>\n <kendo-formhint>Unique identifier for routing. Use lowercase with hyphens (e.g., 'cockpit', 'sales-dashboard').</kendo-formhint>\n </kendo-formfield>\n\n <!-- Layout Settings -->\n <div class=\"section-title\">Layout Settings</div>\n\n <!-- Columns Field -->\n <kendo-formfield>\n <kendo-label [for]=\"columnsInput\" text=\"Columns *\"></kendo-label>\n <kendo-numerictextbox\n #columnsInput\n [(ngModel)]=\"columns\"\n name=\"columns\"\n [min]=\"1\"\n [max]=\"12\"\n [decimals]=\"0\"\n [format]=\"'n0'\"\n [spinners]=\"true\"\n required>\n </kendo-numerictextbox>\n <kendo-formhint>Number of columns in the grid (1-12)</kendo-formhint>\n @if (columns < 1 || columns > 12) {\n <kendo-formerror>Columns must be between 1 and 12</kendo-formerror>\n }\n </kendo-formfield>\n\n <!-- Row Height Field -->\n <kendo-formfield>\n <kendo-label [for]=\"rowHeightInput\" text=\"Row Height *\"></kendo-label>\n <kendo-numerictextbox\n #rowHeightInput\n [(ngModel)]=\"rowHeight\"\n name=\"rowHeight\"\n [min]=\"100\"\n [max]=\"1000\"\n [decimals]=\"0\"\n [format]=\"'n0'\"\n [spinners]=\"true\"\n required>\n </kendo-numerictextbox>\n <kendo-formhint>Height of each row in pixels (100-1000)</kendo-formhint>\n @if (rowHeight < 100 || rowHeight > 1000) {\n <kendo-formerror>Row height must be between 100 and 1000</kendo-formerror>\n }\n </kendo-formfield>\n\n <!-- Gap Field -->\n <kendo-formfield>\n <kendo-label [for]=\"gapInput\" text=\"Gap *\"></kendo-label>\n <kendo-numerictextbox\n #gapInput\n [(ngModel)]=\"gap\"\n name=\"gap\"\n [min]=\"0\"\n [max]=\"100\"\n [decimals]=\"0\"\n [format]=\"'n0'\"\n [spinners]=\"true\"\n required>\n </kendo-numerictextbox>\n <kendo-formhint>Space between widgets in pixels (0-100)</kendo-formhint>\n @if (gap < 0 || gap > 100) {\n <kendo-formerror>Gap must be between 0 and 100</kendo-formerror>\n }\n </kendo-formfield>\n\n <!-- Auto-Refresh Field -->\n <kendo-formfield>\n <kendo-label [for]=\"autoRefreshInput\" text=\"Auto-refresh\"></kendo-label>\n <kendo-numerictextbox\n #autoRefreshInput\n [(ngModel)]=\"autoRefreshSeconds\"\n name=\"autoRefreshSeconds\"\n [min]=\"0\"\n [max]=\"3600\"\n [decimals]=\"0\"\n [format]=\"'n0'\"\n [spinners]=\"true\">\n </kendo-numerictextbox>\n <kendo-formhint>Refresh interval in seconds (0 = disabled, max 3600). Pauses while the tab is hidden.</kendo-formhint>\n @if (autoRefreshSeconds < 0 || autoRefreshSeconds > 3600) {\n <kendo-formerror>Auto-refresh must be between 0 and 3600 seconds</kendo-formerror>\n }\n </kendo-formfield>\n </form>\n </div>\n </ng-template>\n </kendo-tabstrip-tab>\n\n <!-- Variables Tab -->\n <kendo-tabstrip-tab [title]=\"'Variables'\">\n <ng-template kendoTabContent>\n <div class=\"tab-content\">\n <mm-variables-editor\n [(variables)]=\"variables\">\n </mm-variables-editor>\n </div>\n </ng-template>\n </kendo-tabstrip-tab>\n\n <!-- Time Filter Tab -->\n <kendo-tabstrip-tab [title]=\"'Time Filter'\">\n <ng-template kendoTabContent>\n <div class=\"tab-content\">\n <form class=\"settings-form\">\n <kendo-formfield>\n <kendo-label text=\"Timezone\"></kendo-label>\n <div class=\"radio-group\">\n <div class=\"radio-wrapper\">\n <input\n type=\"radio\"\n kendoRadioButton\n #tzLocal\n [(ngModel)]=\"timeZoneMode\"\n name=\"timeZoneMode\"\n value=\"local\"\n id=\"timeZoneModeLocal\"/>\n <kendo-label [for]=\"tzLocal\" text=\"Local time (browser)\" class=\"radio-label\"></kendo-label>\n </div>\n <div class=\"radio-wrapper\">\n <input\n type=\"radio\"\n kendoRadioButton\n #tzUtc\n [(ngModel)]=\"timeZoneMode\"\n name=\"timeZoneMode\"\n value=\"utc\"\n id=\"timeZoneModeUtc\"/>\n <kendo-label [for]=\"tzUtc\" text=\"UTC\" class=\"radio-label\"></kendo-label>\n </div>\n <div class=\"radio-wrapper\">\n <input\n type=\"radio\"\n kendoRadioButton\n #tzZone\n [(ngModel)]=\"timeZoneMode\"\n name=\"timeZoneMode\"\n [value]=\"zoneSelection\"\n id=\"timeZoneModeZone\"/>\n <kendo-label [for]=\"tzZone\" text=\"Specific time zone\" class=\"radio-label\"></kendo-label>\n <kendo-dropdownlist\n class=\"tz-zone-select\"\n [data]=\"commonTimeZones\"\n [value]=\"zoneSelection\"\n (valueChange)=\"onZoneSelectionChange($event)\"\n [disabled]=\"!isSpecificZone\"\n [filterable]=\"true\"\n aria-label=\"IANA time zone\">\n </kendo-dropdownlist>\n </div>\n </div>\n <kendo-formhint>\n Basis for the time filter and for how all widgets display dates and times. Local time is the default.\n A specific time zone resolves civil days (today / yesterday / this month) DST-correctly in that\n zone and selects matching calendar rollups.\n </kendo-formhint>\n </kendo-formfield>\n\n <kendo-formfield>\n <div class=\"checkbox-wrapper\">\n <input\n type=\"checkbox\"\n kendoCheckBox\n #timeFilterCheckbox\n [(ngModel)]=\"timeFilterEnabled\"\n name=\"timeFilterEnabled\"\n id=\"timeFilterEnabled\"/>\n <kendo-label\n [for]=\"timeFilterCheckbox\"\n text=\"Enable Time Filter\"\n class=\"checkbox-label\">\n </kendo-label>\n </div>\n <kendo-formhint>\n Shows a time range picker in the toolbar. Sets $timeRangeFrom and $timeRangeTo variables.\n </kendo-formhint>\n </kendo-formfield>\n\n @if (timeFilterEnabled) {\n <kendo-formfield>\n <kendo-label text=\"Default Selection\"></kendo-label>\n <mm-time-range-picker\n [initialSelection]=\"initialDefaultSelection\"\n (selectionChange)=\"onDefaultSelectionChange($event)\">\n </mm-time-range-picker>\n <kendo-formhint>Initial time filter shown when no URL parameters are set. Note: User-selected filters override this default. Use the reset button in the toolbar to revert to the default.</kendo-formhint>\n </kendo-formfield>\n }\n </form>\n </div>\n </ng-template>\n </kendo-tabstrip-tab>\n\n <!-- Entity Selectors Tab -->\n <kendo-tabstrip-tab [title]=\"'Entity Selectors'\">\n <ng-template kendoTabContent>\n <div class=\"tab-content\">\n <mm-entity-selector-editor\n [(entitySelectors)]=\"entitySelectors\"\n [existingVariableNames]=\"staticVariableNames\"\n (editingStateChange)=\"entitySelectorEditing = $event\">\n </mm-entity-selector-editor>\n </div>\n </ng-template>\n </kendo-tabstrip-tab>\n </kendo-tabstrip>\n\n <!-- Dialog Actions -->\n @if (!entitySelectorEditing) {\n <div class=\"dialog-actions mm-dialog-actions\">\n <button kendoButton (click)=\"cancel()\" fillMode=\"flat\">\n Cancel\n </button>\n <button\n kendoButton\n (click)=\"save()\"\n [disabled]=\"!isValid\"\n themeColor=\"primary\">\n Save\n </button>\n </div>\n }\n</div>\n", styles: [".meshboard-settings-dialog{display:flex;flex-direction:column;height:100%;overflow:hidden}.meshboard-settings-dialog kendo-tabstrip{flex:1;min-height:0;display:flex;flex-direction:column}.meshboard-settings-dialog kendo-tabstrip ::ng-deep .k-tabstrip-content{flex:1;min-height:0;overflow:hidden}.meshboard-settings-dialog .tab-content{height:100%;overflow-y:auto;padding:1.5rem}.meshboard-settings-dialog .settings-form{display:flex;flex-direction:column;gap:1.25rem}.meshboard-settings-dialog .settings-form kendo-formfield{display:flex;flex-direction:column;gap:.5rem}.meshboard-settings-dialog .settings-form kendo-formfield kendo-label{font-weight:500;color:var(--kendo-color-on-app-surface, #424242)}.meshboard-settings-dialog .settings-form kendo-formfield kendo-textbox,.meshboard-settings-dialog .settings-form kendo-formfield kendo-textarea,.meshboard-settings-dialog .settings-form kendo-formfield kendo-numerictextbox{width:100%}.meshboard-settings-dialog .settings-form kendo-formfield kendo-formhint{font-size:.75rem;color:var(--kendo-color-subtle, #757575)}.meshboard-settings-dialog .settings-form kendo-formfield kendo-formerror{font-size:.75rem;color:var(--kendo-color-error, #f44336)}.meshboard-settings-dialog .settings-form .section-title{font-size:.875rem;font-weight:600;color:var(--kendo-color-on-app-surface, #424242);margin-top:.5rem;padding-bottom:.5rem;border-bottom:1px solid var(--kendo-color-border, #e0e0e0)}.meshboard-settings-dialog .settings-form .checkbox-wrapper,.meshboard-settings-dialog .settings-form .radio-wrapper{display:flex;align-items:center;gap:.5rem}.meshboard-settings-dialog .settings-form .checkbox-wrapper .checkbox-label,.meshboard-settings-dialog .settings-form .checkbox-wrapper .radio-label,.meshboard-settings-dialog .settings-form .radio-wrapper .checkbox-label,.meshboard-settings-dialog .settings-form .radio-wrapper .radio-label{font-weight:400;cursor:pointer}.meshboard-settings-dialog .settings-form .checkbox-wrapper .tz-zone-select,.meshboard-settings-dialog .settings-form .radio-wrapper .tz-zone-select{min-width:12rem}.meshboard-settings-dialog .settings-form .radio-group{display:flex;flex-direction:column;gap:.5rem;margin-top:.25rem}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "ngmodule", type: FormsModule }, { kind: "directive", type: i1$1.ɵNgNoValidate, selector: "form:not([ngNoForm]):not([ngNativeValidate])" }, { kind: "directive", type: i1$1.DefaultValueAccessor, selector: "input:not([type=checkbox]):not([ngNoCva])[formControlName],textarea:not([ngNoCva])[formControlName],input:not([type=checkbox]):not([ngNoCva])[formControl],textarea:not([ngNoCva])[formControl],input:not([type=checkbox]):not([ngNoCva])[ngModel],textarea:not([ngNoCva])[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1$1.CheckboxControlValueAccessor, selector: "input[type=checkbox]:not([ngNoCva])[formControlName],input[type=checkbox]:not([ngNoCva])[formControl],input[type=checkbox]:not([ngNoCva])[ngModel]" }, { kind: "directive", type: i1$1.RadioControlValueAccessor, selector: "input[type=radio]:not([ngNoCva])[formControlName],input[type=radio]:not([ngNoCva])[formControl],input[type=radio]:not([ngNoCva])[ngModel]", inputs: ["name", "formControlName", "value"] }, { kind: "directive", type: i1$1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1$1.NgControlStatusGroup, selector: "[formGroupName],[formArrayName],[ngModelGroup],[formGroup],[formArray],form:not([ngNoForm]),[ngForm]" }, { kind: "directive", type: i1$1.RequiredValidator, selector: ":not([type=checkbox])[required][formControlName],:not([type=checkbox])[required][formControl],:not([type=checkbox])[required][ngModel]", inputs: ["required"] }, { kind: "directive", type: i1$1.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }, { kind: "directive", type: i1$1.NgForm, selector: "form:not([ngNoForm]):not([formGroup]):not([formArray]),ng-form,[ngForm]", inputs: ["ngFormOptions"], outputs: ["ngSubmit"], exportAs: ["ngForm"] }, { kind: "ngmodule", type: ButtonModule }, { kind: "component", type: i2.ButtonComponent, selector: "button[kendoButton]", inputs: ["arrowIcon", "toggleable", "togglable", "selected", "tabIndex", "imageUrl", "iconPosition", "iconClass", "icon", "disabled", "size", "rounded", "fillMode", "themeColor", "svgIcon"], outputs: ["selectedChange", "click"], exportAs: ["kendoButton"] }, { kind: "ngmodule", type: InputsModule }, { kind: "component", type: i3.TextBoxComponent, selector: "kendo-textbox", inputs: ["focusableId", "title", "type", "disabled", "readonly", "tabindex", "value", "selectOnFocus", "showSuccessIcon", "showErrorIcon", "clearButton", "successIcon", "successSvgIcon", "errorIcon", "errorSvgIcon", "clearButtonIcon", "clearButtonSvgIcon", "size", "rounded", "fillMode", "tabIndex", "placeholder", "maxlength", "inputAttributes"], outputs: ["valueChange", "inputFocus", "inputBlur", "focus", "blur"], exportAs: ["kendoTextBox"] }, { kind: "component", type: i3.NumericTextBoxComponent, selector: "kendo-numerictextbox", inputs: ["focusableId", "disabled", "readonly", "title", "autoCorrect", "format", "max", "min", "decimals", "placeholder", "step", "spinners", "rangeValidation", "tabindex", "tabIndex", "changeValueOnScroll", "selectOnFocus", "value", "maxlength", "size", "rounded", "fillMode", "inputAttributes"], outputs: ["valueChange", "focus", "blur", "inputFocus", "inputBlur"], exportAs: ["kendoNumericTextBox"] }, { kind: "component", type: i3.TextAreaComponent, selector: "kendo-textarea", inputs: ["focusableId", "flow", "inputAttributes", "adornmentsOrientation", "rows", "cols", "maxlength", "maxResizableRows", "tabindex", "tabIndex", "resizable", "size", "rounded", "fillMode", "showPrefixSeparator", "showSuffixSeparator"], outputs: ["focus", "blur", "valueChange"], exportAs: ["kendoTextArea"] }, { kind: "directive", type: i3.CheckBoxDirective, selector: "input[kendoCheckBox]", inputs: ["size", "rounded"] }, { kind: "directive", type: i3.RadioButtonDirective, selector: "input[kendoRadioButton]", inputs: ["size"] }, { kind: "component", type: i3.FormFieldComponent, selector: "kendo-formfield", inputs: ["showHints", "orientation", "showErrors", "colSpan"] }, { kind: "component", type: i3.HintComponent, selector: "kendo-formhint", inputs: ["align"] }, { kind: "component", type: i3.ErrorComponent, selector: "kendo-formerror", inputs: ["align"] }, { kind: "ngmodule", type: CheckBoxModule }, { kind: "ngmodule", type: LabelModule }, { kind: "component", type: i4$1.LabelComponent, selector: "kendo-label", inputs: ["text", "for", "optional", "labelCssStyle", "labelCssClass"], exportAs: ["kendoLabel"] }, { kind: "ngmodule", type: FormFieldModule }, { kind: "ngmodule", type: TabStripModule }, { kind: "component", type: i5.TabStripComponent, selector: "kendo-tabstrip", inputs: ["height", "animate", "tabAlignment", "tabPosition", "keepTabContent", "closable", "scrollable", "size", "closeIcon", "closeIconClass", "closeSVGIcon", "showContentArea"], outputs: ["tabSelect", "tabClose", "tabScroll"], exportAs: ["kendoTabStrip"] }, { kind: "component", type: i5.TabStripTabComponent, selector: "kendo-tabstrip-tab", inputs: ["title", "disabled", "cssClass", "cssStyle", "selected", "closable", "closeIcon", "closeIconClass", "closeSVGIcon"], exportAs: ["kendoTabStripTab"] }, { kind: "directive", type: i5.TabContentDirective, selector: "[kendoTabContent]" }, { kind: "ngmodule", type: DropDownListModule }, { kind: "component", type: i4.DropDownListComponent, selector: "kendo-dropdownlist", inputs: ["customIconClass", "showStickyHeader", "icon", "svgIcon", "loading", "data", "value", "textField", "valueField", "adaptiveMode", "adaptiveTitle", "adaptiveSubtitle", "popupSettings", "listHeight", "defaultItem", "disabled", "itemDisabled", "readonly", "filterable", "virtual", "ignoreCase", "delay", "valuePrimitive", "tabindex", "tabIndex", "size", "rounded", "fillMode", "leftRightArrowsNavigation", "id"], outputs: ["valueChange", "filterChange", "selectionChange", "open", "opened", "close", "closed", "focus", "blur"], exportAs: ["kendoDropDownList"] }, { kind: "component", type: VariablesEditorComponent, selector: "mm-variables-editor", inputs: ["variables"], outputs: ["variablesChange"] }, { kind: "component", type: EntitySelectorEditorComponent, selector: "mm-entity-selector-editor", inputs: ["entitySelectors", "existingVariableNames"], outputs: ["entitySelectorsChange", "editingStateChange"] }, { kind: "component", type: TimeRangePickerComponent, selector: "mm-time-range-picker", inputs: ["config", "labels", "initialSelection"], outputs: ["rangeChange", "rangeChangeISO", "selectionChange"] }], changeDetection: i0.ChangeDetectionStrategy.Eager });
|
|
35089
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.1.5", type: MeshBoardSettingsDialogComponent, isStandalone: true, selector: "mm-meshboard-settings-dialog", ngImport: i0, template: "<div class=\"meshboard-settings-dialog\">\n <kendo-tabstrip [animate]=\"false\">\n <!-- General Tab -->\n <kendo-tabstrip-tab [title]=\"'General'\" [selected]=\"true\">\n <ng-template kendoTabContent>\n <div class=\"tab-content\">\n <form class=\"settings-form\">\n <!-- Name Field -->\n <kendo-formfield>\n <kendo-label [for]=\"nameInput\" text=\"Name *\"></kendo-label>\n <kendo-textbox\n #nameInput\n [(ngModel)]=\"name\"\n name=\"name\"\n placeholder=\"Enter MeshBoard name\"\n required>\n </kendo-textbox>\n @if (name.trim().length === 0) {\n <kendo-formerror>Name is required</kendo-formerror>\n }\n </kendo-formfield>\n\n <!-- Description Field -->\n <kendo-formfield>\n <kendo-label [for]=\"descriptionInput\" text=\"Description\"></kendo-label>\n <kendo-textarea\n #descriptionInput\n [(ngModel)]=\"description\"\n name=\"description\"\n placeholder=\"Enter MeshBoard description (optional)\"\n [rows]=\"3\">\n </kendo-textarea>\n </kendo-formfield>\n\n <!-- Well-Known Name Field -->\n <kendo-formfield>\n <kendo-label [for]=\"wellKnownNameInput\" text=\"Well-Known Name\"></kendo-label>\n <kendo-textbox\n #wellKnownNameInput\n [(ngModel)]=\"rtWellKnownName\"\n name=\"rtWellKnownName\"\n placeholder=\"e.g., cockpit, dashboard-main\">\n </kendo-textbox>\n <kendo-formhint>Unique identifier for routing. Use lowercase with hyphens (e.g., 'cockpit', 'sales-dashboard').</kendo-formhint>\n </kendo-formfield>\n\n <!-- Layout Settings -->\n <div class=\"section-title\">Layout Settings</div>\n\n <!-- Columns Field -->\n <kendo-formfield>\n <kendo-label [for]=\"columnsInput\" text=\"Columns *\"></kendo-label>\n <kendo-numerictextbox\n #columnsInput\n [(ngModel)]=\"columns\"\n name=\"columns\"\n [min]=\"1\"\n [max]=\"12\"\n [decimals]=\"0\"\n [format]=\"'n0'\"\n [spinners]=\"true\"\n required>\n </kendo-numerictextbox>\n <kendo-formhint>Number of columns in the grid (1-12)</kendo-formhint>\n @if (columns < 1 || columns > 12) {\n <kendo-formerror>Columns must be between 1 and 12</kendo-formerror>\n }\n </kendo-formfield>\n\n <!-- Row Height Field -->\n <kendo-formfield>\n <kendo-label [for]=\"rowHeightInput\" text=\"Row Height *\"></kendo-label>\n <kendo-numerictextbox\n #rowHeightInput\n [(ngModel)]=\"rowHeight\"\n name=\"rowHeight\"\n [min]=\"100\"\n [max]=\"1000\"\n [decimals]=\"0\"\n [format]=\"'n0'\"\n [spinners]=\"true\"\n required>\n </kendo-numerictextbox>\n <kendo-formhint>Height of each row in pixels (100-1000)</kendo-formhint>\n @if (rowHeight < 100 || rowHeight > 1000) {\n <kendo-formerror>Row height must be between 100 and 1000</kendo-formerror>\n }\n </kendo-formfield>\n\n <!-- Gap Field -->\n <kendo-formfield>\n <kendo-label [for]=\"gapInput\" text=\"Gap *\"></kendo-label>\n <kendo-numerictextbox\n #gapInput\n [(ngModel)]=\"gap\"\n name=\"gap\"\n [min]=\"0\"\n [max]=\"100\"\n [decimals]=\"0\"\n [format]=\"'n0'\"\n [spinners]=\"true\"\n required>\n </kendo-numerictextbox>\n <kendo-formhint>Space between widgets in pixels (0-100)</kendo-formhint>\n @if (gap < 0 || gap > 100) {\n <kendo-formerror>Gap must be between 0 and 100</kendo-formerror>\n }\n </kendo-formfield>\n\n <!-- Auto-Refresh Field -->\n <kendo-formfield>\n <kendo-label [for]=\"autoRefreshInput\" text=\"Auto-refresh\"></kendo-label>\n <kendo-numerictextbox\n #autoRefreshInput\n [(ngModel)]=\"autoRefreshSeconds\"\n name=\"autoRefreshSeconds\"\n [min]=\"0\"\n [max]=\"3600\"\n [decimals]=\"0\"\n [format]=\"'n0'\"\n [spinners]=\"true\">\n </kendo-numerictextbox>\n <kendo-formhint>Refresh interval in seconds (0 = disabled, max 3600). Pauses while the tab is hidden.</kendo-formhint>\n @if (autoRefreshSeconds < 0 || autoRefreshSeconds > 3600) {\n <kendo-formerror>Auto-refresh must be between 0 and 3600 seconds</kendo-formerror>\n }\n </kendo-formfield>\n </form>\n </div>\n </ng-template>\n </kendo-tabstrip-tab>\n\n <!-- Variables Tab -->\n <kendo-tabstrip-tab [title]=\"'Variables'\">\n <ng-template kendoTabContent>\n <div class=\"tab-content\">\n <mm-variables-editor\n [(variables)]=\"variables\">\n </mm-variables-editor>\n </div>\n </ng-template>\n </kendo-tabstrip-tab>\n\n <!-- Time Filter Tab -->\n <kendo-tabstrip-tab [title]=\"'Time Filter'\">\n <ng-template kendoTabContent>\n <div class=\"tab-content\">\n <form class=\"settings-form\">\n <kendo-formfield>\n <kendo-label text=\"Timezone\"></kendo-label>\n <div class=\"radio-group\">\n <div class=\"radio-wrapper\">\n <input\n type=\"radio\"\n kendoRadioButton\n #tzLocal\n [(ngModel)]=\"timeZoneMode\"\n name=\"timeZoneMode\"\n value=\"local\"\n id=\"timeZoneModeLocal\"/>\n <kendo-label [for]=\"tzLocal\" text=\"Local time (browser)\" class=\"radio-label\"></kendo-label>\n </div>\n <div class=\"radio-wrapper\">\n <input\n type=\"radio\"\n kendoRadioButton\n #tzUtc\n [(ngModel)]=\"timeZoneMode\"\n name=\"timeZoneMode\"\n value=\"utc\"\n id=\"timeZoneModeUtc\"/>\n <kendo-label [for]=\"tzUtc\" text=\"UTC\" class=\"radio-label\"></kendo-label>\n </div>\n <div class=\"radio-wrapper\">\n <input\n type=\"radio\"\n kendoRadioButton\n #tzZone\n [(ngModel)]=\"timeZoneMode\"\n name=\"timeZoneMode\"\n [value]=\"zoneSelection\"\n id=\"timeZoneModeZone\"/>\n <kendo-label [for]=\"tzZone\" text=\"Specific time zone\" class=\"radio-label\"></kendo-label>\n <kendo-dropdownlist\n class=\"tz-zone-select\"\n [data]=\"commonTimeZones\"\n [value]=\"zoneSelection\"\n (valueChange)=\"onZoneSelectionChange($event)\"\n [disabled]=\"!isSpecificZone\"\n [filterable]=\"true\"\n aria-label=\"IANA time zone\">\n </kendo-dropdownlist>\n </div>\n </div>\n <kendo-formhint>\n Basis for the time filter and for how all widgets display dates and times. Local time is the default.\n A specific time zone resolves civil days (today / yesterday / this month) DST-correctly in that\n zone and selects matching calendar rollups.\n </kendo-formhint>\n </kendo-formfield>\n\n <kendo-formfield>\n <div class=\"checkbox-wrapper\">\n <input\n type=\"checkbox\"\n kendoCheckBox\n #timeFilterCheckbox\n [(ngModel)]=\"timeFilterEnabled\"\n name=\"timeFilterEnabled\"\n id=\"timeFilterEnabled\"/>\n <kendo-label\n [for]=\"timeFilterCheckbox\"\n text=\"Enable Time Filter\"\n class=\"checkbox-label\">\n </kendo-label>\n </div>\n <kendo-formhint>\n Shows a time range picker in the toolbar. Sets $timeRangeFrom and $timeRangeTo variables.\n </kendo-formhint>\n </kendo-formfield>\n\n @if (timeFilterEnabled) {\n <kendo-formfield>\n <kendo-label text=\"Default Selection\"></kendo-label>\n <mm-time-range-picker\n [initialSelection]=\"initialDefaultSelection\"\n (selectionChange)=\"onDefaultSelectionChange($event)\">\n </mm-time-range-picker>\n <kendo-formhint>Initial time filter shown when no URL parameters are set. Note: User-selected filters override this default. Use the reset button in the toolbar to revert to the default.</kendo-formhint>\n </kendo-formfield>\n }\n </form>\n </div>\n </ng-template>\n </kendo-tabstrip-tab>\n\n <!-- Entity Selectors Tab -->\n <kendo-tabstrip-tab [title]=\"'Entity Selectors'\">\n <ng-template kendoTabContent>\n <div class=\"tab-content\">\n <mm-entity-selector-editor\n [(entitySelectors)]=\"entitySelectors\"\n [existingVariableNames]=\"staticVariableNames\"\n [variables]=\"variables\"\n (editingStateChange)=\"entitySelectorEditing = $event\">\n </mm-entity-selector-editor>\n </div>\n </ng-template>\n </kendo-tabstrip-tab>\n </kendo-tabstrip>\n\n <!-- Dialog Actions -->\n @if (!entitySelectorEditing) {\n <div class=\"dialog-actions mm-dialog-actions\">\n <button kendoButton (click)=\"cancel()\" fillMode=\"flat\">\n Cancel\n </button>\n <button\n kendoButton\n (click)=\"save()\"\n [disabled]=\"!isValid\"\n themeColor=\"primary\">\n Save\n </button>\n </div>\n }\n</div>\n", styles: [".meshboard-settings-dialog{display:flex;flex-direction:column;height:100%;overflow:hidden}.meshboard-settings-dialog kendo-tabstrip{flex:1;min-height:0;display:flex;flex-direction:column}.meshboard-settings-dialog kendo-tabstrip ::ng-deep .k-tabstrip-content{flex:1;min-height:0;overflow:hidden}.meshboard-settings-dialog .tab-content{height:100%;overflow-y:auto;padding:1.5rem}.meshboard-settings-dialog .settings-form{display:flex;flex-direction:column;gap:1.25rem}.meshboard-settings-dialog .settings-form kendo-formfield{display:flex;flex-direction:column;gap:.5rem}.meshboard-settings-dialog .settings-form kendo-formfield kendo-label{font-weight:500;color:var(--kendo-color-on-app-surface, #424242)}.meshboard-settings-dialog .settings-form kendo-formfield kendo-textbox,.meshboard-settings-dialog .settings-form kendo-formfield kendo-textarea,.meshboard-settings-dialog .settings-form kendo-formfield kendo-numerictextbox{width:100%}.meshboard-settings-dialog .settings-form kendo-formfield kendo-formhint{font-size:.75rem;color:var(--kendo-color-subtle, #757575)}.meshboard-settings-dialog .settings-form kendo-formfield kendo-formerror{font-size:.75rem;color:var(--kendo-color-error, #f44336)}.meshboard-settings-dialog .settings-form .section-title{font-size:.875rem;font-weight:600;color:var(--kendo-color-on-app-surface, #424242);margin-top:.5rem;padding-bottom:.5rem;border-bottom:1px solid var(--kendo-color-border, #e0e0e0)}.meshboard-settings-dialog .settings-form .checkbox-wrapper,.meshboard-settings-dialog .settings-form .radio-wrapper{display:flex;align-items:center;gap:.5rem}.meshboard-settings-dialog .settings-form .checkbox-wrapper .checkbox-label,.meshboard-settings-dialog .settings-form .checkbox-wrapper .radio-label,.meshboard-settings-dialog .settings-form .radio-wrapper .checkbox-label,.meshboard-settings-dialog .settings-form .radio-wrapper .radio-label{font-weight:400;cursor:pointer}.meshboard-settings-dialog .settings-form .checkbox-wrapper .tz-zone-select,.meshboard-settings-dialog .settings-form .radio-wrapper .tz-zone-select{min-width:12rem}.meshboard-settings-dialog .settings-form .radio-group{display:flex;flex-direction:column;gap:.5rem;margin-top:.25rem}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "ngmodule", type: FormsModule }, { kind: "directive", type: i1$1.ɵNgNoValidate, selector: "form:not([ngNoForm]):not([ngNativeValidate])" }, { kind: "directive", type: i1$1.DefaultValueAccessor, selector: "input:not([type=checkbox]):not([ngNoCva])[formControlName],textarea:not([ngNoCva])[formControlName],input:not([type=checkbox]):not([ngNoCva])[formControl],textarea:not([ngNoCva])[formControl],input:not([type=checkbox]):not([ngNoCva])[ngModel],textarea:not([ngNoCva])[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1$1.CheckboxControlValueAccessor, selector: "input[type=checkbox]:not([ngNoCva])[formControlName],input[type=checkbox]:not([ngNoCva])[formControl],input[type=checkbox]:not([ngNoCva])[ngModel]" }, { kind: "directive", type: i1$1.RadioControlValueAccessor, selector: "input[type=radio]:not([ngNoCva])[formControlName],input[type=radio]:not([ngNoCva])[formControl],input[type=radio]:not([ngNoCva])[ngModel]", inputs: ["name", "formControlName", "value"] }, { kind: "directive", type: i1$1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1$1.NgControlStatusGroup, selector: "[formGroupName],[formArrayName],[ngModelGroup],[formGroup],[formArray],form:not([ngNoForm]),[ngForm]" }, { kind: "directive", type: i1$1.RequiredValidator, selector: ":not([type=checkbox])[required][formControlName],:not([type=checkbox])[required][formControl],:not([type=checkbox])[required][ngModel]", inputs: ["required"] }, { kind: "directive", type: i1$1.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }, { kind: "directive", type: i1$1.NgForm, selector: "form:not([ngNoForm]):not([formGroup]):not([formArray]),ng-form,[ngForm]", inputs: ["ngFormOptions"], outputs: ["ngSubmit"], exportAs: ["ngForm"] }, { kind: "ngmodule", type: ButtonModule }, { kind: "component", type: i2.ButtonComponent, selector: "button[kendoButton]", inputs: ["arrowIcon", "toggleable", "togglable", "selected", "tabIndex", "imageUrl", "iconPosition", "iconClass", "icon", "disabled", "size", "rounded", "fillMode", "themeColor", "svgIcon"], outputs: ["selectedChange", "click"], exportAs: ["kendoButton"] }, { kind: "ngmodule", type: InputsModule }, { kind: "component", type: i3.TextBoxComponent, selector: "kendo-textbox", inputs: ["focusableId", "title", "type", "disabled", "readonly", "tabindex", "value", "selectOnFocus", "showSuccessIcon", "showErrorIcon", "clearButton", "successIcon", "successSvgIcon", "errorIcon", "errorSvgIcon", "clearButtonIcon", "clearButtonSvgIcon", "size", "rounded", "fillMode", "tabIndex", "placeholder", "maxlength", "inputAttributes"], outputs: ["valueChange", "inputFocus", "inputBlur", "focus", "blur"], exportAs: ["kendoTextBox"] }, { kind: "component", type: i3.NumericTextBoxComponent, selector: "kendo-numerictextbox", inputs: ["focusableId", "disabled", "readonly", "title", "autoCorrect", "format", "max", "min", "decimals", "placeholder", "step", "spinners", "rangeValidation", "tabindex", "tabIndex", "changeValueOnScroll", "selectOnFocus", "value", "maxlength", "size", "rounded", "fillMode", "inputAttributes"], outputs: ["valueChange", "focus", "blur", "inputFocus", "inputBlur"], exportAs: ["kendoNumericTextBox"] }, { kind: "component", type: i3.TextAreaComponent, selector: "kendo-textarea", inputs: ["focusableId", "flow", "inputAttributes", "adornmentsOrientation", "rows", "cols", "maxlength", "maxResizableRows", "tabindex", "tabIndex", "resizable", "size", "rounded", "fillMode", "showPrefixSeparator", "showSuffixSeparator"], outputs: ["focus", "blur", "valueChange"], exportAs: ["kendoTextArea"] }, { kind: "directive", type: i3.CheckBoxDirective, selector: "input[kendoCheckBox]", inputs: ["size", "rounded"] }, { kind: "directive", type: i3.RadioButtonDirective, selector: "input[kendoRadioButton]", inputs: ["size"] }, { kind: "component", type: i3.FormFieldComponent, selector: "kendo-formfield", inputs: ["showHints", "orientation", "showErrors", "colSpan"] }, { kind: "component", type: i3.HintComponent, selector: "kendo-formhint", inputs: ["align"] }, { kind: "component", type: i3.ErrorComponent, selector: "kendo-formerror", inputs: ["align"] }, { kind: "ngmodule", type: CheckBoxModule }, { kind: "ngmodule", type: LabelModule }, { kind: "component", type: i4$1.LabelComponent, selector: "kendo-label", inputs: ["text", "for", "optional", "labelCssStyle", "labelCssClass"], exportAs: ["kendoLabel"] }, { kind: "ngmodule", type: FormFieldModule }, { kind: "ngmodule", type: TabStripModule }, { kind: "component", type: i5.TabStripComponent, selector: "kendo-tabstrip", inputs: ["height", "animate", "tabAlignment", "tabPosition", "keepTabContent", "closable", "scrollable", "size", "closeIcon", "closeIconClass", "closeSVGIcon", "showContentArea"], outputs: ["tabSelect", "tabClose", "tabScroll"], exportAs: ["kendoTabStrip"] }, { kind: "component", type: i5.TabStripTabComponent, selector: "kendo-tabstrip-tab", inputs: ["title", "disabled", "cssClass", "cssStyle", "selected", "closable", "closeIcon", "closeIconClass", "closeSVGIcon"], exportAs: ["kendoTabStripTab"] }, { kind: "directive", type: i5.TabContentDirective, selector: "[kendoTabContent]" }, { kind: "ngmodule", type: DropDownListModule }, { kind: "component", type: i4.DropDownListComponent, selector: "kendo-dropdownlist", inputs: ["customIconClass", "showStickyHeader", "icon", "svgIcon", "loading", "data", "value", "textField", "valueField", "adaptiveMode", "adaptiveTitle", "adaptiveSubtitle", "popupSettings", "listHeight", "defaultItem", "disabled", "itemDisabled", "readonly", "filterable", "virtual", "ignoreCase", "delay", "valuePrimitive", "tabindex", "tabIndex", "size", "rounded", "fillMode", "leftRightArrowsNavigation", "id"], outputs: ["valueChange", "filterChange", "selectionChange", "open", "opened", "close", "closed", "focus", "blur"], exportAs: ["kendoDropDownList"] }, { kind: "component", type: VariablesEditorComponent, selector: "mm-variables-editor", inputs: ["variables"], outputs: ["variablesChange"] }, { kind: "component", type: EntitySelectorEditorComponent, selector: "mm-entity-selector-editor", inputs: ["entitySelectors", "existingVariableNames", "variables"], outputs: ["entitySelectorsChange", "editingStateChange"] }, { kind: "component", type: TimeRangePickerComponent, selector: "mm-time-range-picker", inputs: ["config", "labels", "initialSelection"], outputs: ["rangeChange", "rangeChangeISO", "selectionChange"] }], changeDetection: i0.ChangeDetectionStrategy.Eager });
|
|
33205
35090
|
}
|
|
33206
35091
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.5", ngImport: i0, type: MeshBoardSettingsDialogComponent, decorators: [{
|
|
33207
35092
|
type: Component,
|
|
@@ -33218,7 +35103,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.5", ngImpor
|
|
|
33218
35103
|
VariablesEditorComponent,
|
|
33219
35104
|
EntitySelectorEditorComponent,
|
|
33220
35105
|
TimeRangePickerComponent
|
|
33221
|
-
], changeDetection: ChangeDetectionStrategy.Eager, template: "<div class=\"meshboard-settings-dialog\">\n <kendo-tabstrip [animate]=\"false\">\n <!-- General Tab -->\n <kendo-tabstrip-tab [title]=\"'General'\" [selected]=\"true\">\n <ng-template kendoTabContent>\n <div class=\"tab-content\">\n <form class=\"settings-form\">\n <!-- Name Field -->\n <kendo-formfield>\n <kendo-label [for]=\"nameInput\" text=\"Name *\"></kendo-label>\n <kendo-textbox\n #nameInput\n [(ngModel)]=\"name\"\n name=\"name\"\n placeholder=\"Enter MeshBoard name\"\n required>\n </kendo-textbox>\n @if (name.trim().length === 0) {\n <kendo-formerror>Name is required</kendo-formerror>\n }\n </kendo-formfield>\n\n <!-- Description Field -->\n <kendo-formfield>\n <kendo-label [for]=\"descriptionInput\" text=\"Description\"></kendo-label>\n <kendo-textarea\n #descriptionInput\n [(ngModel)]=\"description\"\n name=\"description\"\n placeholder=\"Enter MeshBoard description (optional)\"\n [rows]=\"3\">\n </kendo-textarea>\n </kendo-formfield>\n\n <!-- Well-Known Name Field -->\n <kendo-formfield>\n <kendo-label [for]=\"wellKnownNameInput\" text=\"Well-Known Name\"></kendo-label>\n <kendo-textbox\n #wellKnownNameInput\n [(ngModel)]=\"rtWellKnownName\"\n name=\"rtWellKnownName\"\n placeholder=\"e.g., cockpit, dashboard-main\">\n </kendo-textbox>\n <kendo-formhint>Unique identifier for routing. Use lowercase with hyphens (e.g., 'cockpit', 'sales-dashboard').</kendo-formhint>\n </kendo-formfield>\n\n <!-- Layout Settings -->\n <div class=\"section-title\">Layout Settings</div>\n\n <!-- Columns Field -->\n <kendo-formfield>\n <kendo-label [for]=\"columnsInput\" text=\"Columns *\"></kendo-label>\n <kendo-numerictextbox\n #columnsInput\n [(ngModel)]=\"columns\"\n name=\"columns\"\n [min]=\"1\"\n [max]=\"12\"\n [decimals]=\"0\"\n [format]=\"'n0'\"\n [spinners]=\"true\"\n required>\n </kendo-numerictextbox>\n <kendo-formhint>Number of columns in the grid (1-12)</kendo-formhint>\n @if (columns < 1 || columns > 12) {\n <kendo-formerror>Columns must be between 1 and 12</kendo-formerror>\n }\n </kendo-formfield>\n\n <!-- Row Height Field -->\n <kendo-formfield>\n <kendo-label [for]=\"rowHeightInput\" text=\"Row Height *\"></kendo-label>\n <kendo-numerictextbox\n #rowHeightInput\n [(ngModel)]=\"rowHeight\"\n name=\"rowHeight\"\n [min]=\"100\"\n [max]=\"1000\"\n [decimals]=\"0\"\n [format]=\"'n0'\"\n [spinners]=\"true\"\n required>\n </kendo-numerictextbox>\n <kendo-formhint>Height of each row in pixels (100-1000)</kendo-formhint>\n @if (rowHeight < 100 || rowHeight > 1000) {\n <kendo-formerror>Row height must be between 100 and 1000</kendo-formerror>\n }\n </kendo-formfield>\n\n <!-- Gap Field -->\n <kendo-formfield>\n <kendo-label [for]=\"gapInput\" text=\"Gap *\"></kendo-label>\n <kendo-numerictextbox\n #gapInput\n [(ngModel)]=\"gap\"\n name=\"gap\"\n [min]=\"0\"\n [max]=\"100\"\n [decimals]=\"0\"\n [format]=\"'n0'\"\n [spinners]=\"true\"\n required>\n </kendo-numerictextbox>\n <kendo-formhint>Space between widgets in pixels (0-100)</kendo-formhint>\n @if (gap < 0 || gap > 100) {\n <kendo-formerror>Gap must be between 0 and 100</kendo-formerror>\n }\n </kendo-formfield>\n\n <!-- Auto-Refresh Field -->\n <kendo-formfield>\n <kendo-label [for]=\"autoRefreshInput\" text=\"Auto-refresh\"></kendo-label>\n <kendo-numerictextbox\n #autoRefreshInput\n [(ngModel)]=\"autoRefreshSeconds\"\n name=\"autoRefreshSeconds\"\n [min]=\"0\"\n [max]=\"3600\"\n [decimals]=\"0\"\n [format]=\"'n0'\"\n [spinners]=\"true\">\n </kendo-numerictextbox>\n <kendo-formhint>Refresh interval in seconds (0 = disabled, max 3600). Pauses while the tab is hidden.</kendo-formhint>\n @if (autoRefreshSeconds < 0 || autoRefreshSeconds > 3600) {\n <kendo-formerror>Auto-refresh must be between 0 and 3600 seconds</kendo-formerror>\n }\n </kendo-formfield>\n </form>\n </div>\n </ng-template>\n </kendo-tabstrip-tab>\n\n <!-- Variables Tab -->\n <kendo-tabstrip-tab [title]=\"'Variables'\">\n <ng-template kendoTabContent>\n <div class=\"tab-content\">\n <mm-variables-editor\n [(variables)]=\"variables\">\n </mm-variables-editor>\n </div>\n </ng-template>\n </kendo-tabstrip-tab>\n\n <!-- Time Filter Tab -->\n <kendo-tabstrip-tab [title]=\"'Time Filter'\">\n <ng-template kendoTabContent>\n <div class=\"tab-content\">\n <form class=\"settings-form\">\n <kendo-formfield>\n <kendo-label text=\"Timezone\"></kendo-label>\n <div class=\"radio-group\">\n <div class=\"radio-wrapper\">\n <input\n type=\"radio\"\n kendoRadioButton\n #tzLocal\n [(ngModel)]=\"timeZoneMode\"\n name=\"timeZoneMode\"\n value=\"local\"\n id=\"timeZoneModeLocal\"/>\n <kendo-label [for]=\"tzLocal\" text=\"Local time (browser)\" class=\"radio-label\"></kendo-label>\n </div>\n <div class=\"radio-wrapper\">\n <input\n type=\"radio\"\n kendoRadioButton\n #tzUtc\n [(ngModel)]=\"timeZoneMode\"\n name=\"timeZoneMode\"\n value=\"utc\"\n id=\"timeZoneModeUtc\"/>\n <kendo-label [for]=\"tzUtc\" text=\"UTC\" class=\"radio-label\"></kendo-label>\n </div>\n <div class=\"radio-wrapper\">\n <input\n type=\"radio\"\n kendoRadioButton\n #tzZone\n [(ngModel)]=\"timeZoneMode\"\n name=\"timeZoneMode\"\n [value]=\"zoneSelection\"\n id=\"timeZoneModeZone\"/>\n <kendo-label [for]=\"tzZone\" text=\"Specific time zone\" class=\"radio-label\"></kendo-label>\n <kendo-dropdownlist\n class=\"tz-zone-select\"\n [data]=\"commonTimeZones\"\n [value]=\"zoneSelection\"\n (valueChange)=\"onZoneSelectionChange($event)\"\n [disabled]=\"!isSpecificZone\"\n [filterable]=\"true\"\n aria-label=\"IANA time zone\">\n </kendo-dropdownlist>\n </div>\n </div>\n <kendo-formhint>\n Basis for the time filter and for how all widgets display dates and times. Local time is the default.\n A specific time zone resolves civil days (today / yesterday / this month) DST-correctly in that\n zone and selects matching calendar rollups.\n </kendo-formhint>\n </kendo-formfield>\n\n <kendo-formfield>\n <div class=\"checkbox-wrapper\">\n <input\n type=\"checkbox\"\n kendoCheckBox\n #timeFilterCheckbox\n [(ngModel)]=\"timeFilterEnabled\"\n name=\"timeFilterEnabled\"\n id=\"timeFilterEnabled\"/>\n <kendo-label\n [for]=\"timeFilterCheckbox\"\n text=\"Enable Time Filter\"\n class=\"checkbox-label\">\n </kendo-label>\n </div>\n <kendo-formhint>\n Shows a time range picker in the toolbar. Sets $timeRangeFrom and $timeRangeTo variables.\n </kendo-formhint>\n </kendo-formfield>\n\n @if (timeFilterEnabled) {\n <kendo-formfield>\n <kendo-label text=\"Default Selection\"></kendo-label>\n <mm-time-range-picker\n [initialSelection]=\"initialDefaultSelection\"\n (selectionChange)=\"onDefaultSelectionChange($event)\">\n </mm-time-range-picker>\n <kendo-formhint>Initial time filter shown when no URL parameters are set. Note: User-selected filters override this default. Use the reset button in the toolbar to revert to the default.</kendo-formhint>\n </kendo-formfield>\n }\n </form>\n </div>\n </ng-template>\n </kendo-tabstrip-tab>\n\n <!-- Entity Selectors Tab -->\n <kendo-tabstrip-tab [title]=\"'Entity Selectors'\">\n <ng-template kendoTabContent>\n <div class=\"tab-content\">\n <mm-entity-selector-editor\n [(entitySelectors)]=\"entitySelectors\"\n [existingVariableNames]=\"staticVariableNames\"\n (editingStateChange)=\"entitySelectorEditing = $event\">\n </mm-entity-selector-editor>\n </div>\n </ng-template>\n </kendo-tabstrip-tab>\n </kendo-tabstrip>\n\n <!-- Dialog Actions -->\n @if (!entitySelectorEditing) {\n <div class=\"dialog-actions mm-dialog-actions\">\n <button kendoButton (click)=\"cancel()\" fillMode=\"flat\">\n Cancel\n </button>\n <button\n kendoButton\n (click)=\"save()\"\n [disabled]=\"!isValid\"\n themeColor=\"primary\">\n Save\n </button>\n </div>\n }\n</div>\n", styles: [".meshboard-settings-dialog{display:flex;flex-direction:column;height:100%;overflow:hidden}.meshboard-settings-dialog kendo-tabstrip{flex:1;min-height:0;display:flex;flex-direction:column}.meshboard-settings-dialog kendo-tabstrip ::ng-deep .k-tabstrip-content{flex:1;min-height:0;overflow:hidden}.meshboard-settings-dialog .tab-content{height:100%;overflow-y:auto;padding:1.5rem}.meshboard-settings-dialog .settings-form{display:flex;flex-direction:column;gap:1.25rem}.meshboard-settings-dialog .settings-form kendo-formfield{display:flex;flex-direction:column;gap:.5rem}.meshboard-settings-dialog .settings-form kendo-formfield kendo-label{font-weight:500;color:var(--kendo-color-on-app-surface, #424242)}.meshboard-settings-dialog .settings-form kendo-formfield kendo-textbox,.meshboard-settings-dialog .settings-form kendo-formfield kendo-textarea,.meshboard-settings-dialog .settings-form kendo-formfield kendo-numerictextbox{width:100%}.meshboard-settings-dialog .settings-form kendo-formfield kendo-formhint{font-size:.75rem;color:var(--kendo-color-subtle, #757575)}.meshboard-settings-dialog .settings-form kendo-formfield kendo-formerror{font-size:.75rem;color:var(--kendo-color-error, #f44336)}.meshboard-settings-dialog .settings-form .section-title{font-size:.875rem;font-weight:600;color:var(--kendo-color-on-app-surface, #424242);margin-top:.5rem;padding-bottom:.5rem;border-bottom:1px solid var(--kendo-color-border, #e0e0e0)}.meshboard-settings-dialog .settings-form .checkbox-wrapper,.meshboard-settings-dialog .settings-form .radio-wrapper{display:flex;align-items:center;gap:.5rem}.meshboard-settings-dialog .settings-form .checkbox-wrapper .checkbox-label,.meshboard-settings-dialog .settings-form .checkbox-wrapper .radio-label,.meshboard-settings-dialog .settings-form .radio-wrapper .checkbox-label,.meshboard-settings-dialog .settings-form .radio-wrapper .radio-label{font-weight:400;cursor:pointer}.meshboard-settings-dialog .settings-form .checkbox-wrapper .tz-zone-select,.meshboard-settings-dialog .settings-form .radio-wrapper .tz-zone-select{min-width:12rem}.meshboard-settings-dialog .settings-form .radio-group{display:flex;flex-direction:column;gap:.5rem;margin-top:.25rem}\n"] }]
|
|
35106
|
+
], changeDetection: ChangeDetectionStrategy.Eager, template: "<div class=\"meshboard-settings-dialog\">\n <kendo-tabstrip [animate]=\"false\">\n <!-- General Tab -->\n <kendo-tabstrip-tab [title]=\"'General'\" [selected]=\"true\">\n <ng-template kendoTabContent>\n <div class=\"tab-content\">\n <form class=\"settings-form\">\n <!-- Name Field -->\n <kendo-formfield>\n <kendo-label [for]=\"nameInput\" text=\"Name *\"></kendo-label>\n <kendo-textbox\n #nameInput\n [(ngModel)]=\"name\"\n name=\"name\"\n placeholder=\"Enter MeshBoard name\"\n required>\n </kendo-textbox>\n @if (name.trim().length === 0) {\n <kendo-formerror>Name is required</kendo-formerror>\n }\n </kendo-formfield>\n\n <!-- Description Field -->\n <kendo-formfield>\n <kendo-label [for]=\"descriptionInput\" text=\"Description\"></kendo-label>\n <kendo-textarea\n #descriptionInput\n [(ngModel)]=\"description\"\n name=\"description\"\n placeholder=\"Enter MeshBoard description (optional)\"\n [rows]=\"3\">\n </kendo-textarea>\n </kendo-formfield>\n\n <!-- Well-Known Name Field -->\n <kendo-formfield>\n <kendo-label [for]=\"wellKnownNameInput\" text=\"Well-Known Name\"></kendo-label>\n <kendo-textbox\n #wellKnownNameInput\n [(ngModel)]=\"rtWellKnownName\"\n name=\"rtWellKnownName\"\n placeholder=\"e.g., cockpit, dashboard-main\">\n </kendo-textbox>\n <kendo-formhint>Unique identifier for routing. Use lowercase with hyphens (e.g., 'cockpit', 'sales-dashboard').</kendo-formhint>\n </kendo-formfield>\n\n <!-- Layout Settings -->\n <div class=\"section-title\">Layout Settings</div>\n\n <!-- Columns Field -->\n <kendo-formfield>\n <kendo-label [for]=\"columnsInput\" text=\"Columns *\"></kendo-label>\n <kendo-numerictextbox\n #columnsInput\n [(ngModel)]=\"columns\"\n name=\"columns\"\n [min]=\"1\"\n [max]=\"12\"\n [decimals]=\"0\"\n [format]=\"'n0'\"\n [spinners]=\"true\"\n required>\n </kendo-numerictextbox>\n <kendo-formhint>Number of columns in the grid (1-12)</kendo-formhint>\n @if (columns < 1 || columns > 12) {\n <kendo-formerror>Columns must be between 1 and 12</kendo-formerror>\n }\n </kendo-formfield>\n\n <!-- Row Height Field -->\n <kendo-formfield>\n <kendo-label [for]=\"rowHeightInput\" text=\"Row Height *\"></kendo-label>\n <kendo-numerictextbox\n #rowHeightInput\n [(ngModel)]=\"rowHeight\"\n name=\"rowHeight\"\n [min]=\"100\"\n [max]=\"1000\"\n [decimals]=\"0\"\n [format]=\"'n0'\"\n [spinners]=\"true\"\n required>\n </kendo-numerictextbox>\n <kendo-formhint>Height of each row in pixels (100-1000)</kendo-formhint>\n @if (rowHeight < 100 || rowHeight > 1000) {\n <kendo-formerror>Row height must be between 100 and 1000</kendo-formerror>\n }\n </kendo-formfield>\n\n <!-- Gap Field -->\n <kendo-formfield>\n <kendo-label [for]=\"gapInput\" text=\"Gap *\"></kendo-label>\n <kendo-numerictextbox\n #gapInput\n [(ngModel)]=\"gap\"\n name=\"gap\"\n [min]=\"0\"\n [max]=\"100\"\n [decimals]=\"0\"\n [format]=\"'n0'\"\n [spinners]=\"true\"\n required>\n </kendo-numerictextbox>\n <kendo-formhint>Space between widgets in pixels (0-100)</kendo-formhint>\n @if (gap < 0 || gap > 100) {\n <kendo-formerror>Gap must be between 0 and 100</kendo-formerror>\n }\n </kendo-formfield>\n\n <!-- Auto-Refresh Field -->\n <kendo-formfield>\n <kendo-label [for]=\"autoRefreshInput\" text=\"Auto-refresh\"></kendo-label>\n <kendo-numerictextbox\n #autoRefreshInput\n [(ngModel)]=\"autoRefreshSeconds\"\n name=\"autoRefreshSeconds\"\n [min]=\"0\"\n [max]=\"3600\"\n [decimals]=\"0\"\n [format]=\"'n0'\"\n [spinners]=\"true\">\n </kendo-numerictextbox>\n <kendo-formhint>Refresh interval in seconds (0 = disabled, max 3600). Pauses while the tab is hidden.</kendo-formhint>\n @if (autoRefreshSeconds < 0 || autoRefreshSeconds > 3600) {\n <kendo-formerror>Auto-refresh must be between 0 and 3600 seconds</kendo-formerror>\n }\n </kendo-formfield>\n </form>\n </div>\n </ng-template>\n </kendo-tabstrip-tab>\n\n <!-- Variables Tab -->\n <kendo-tabstrip-tab [title]=\"'Variables'\">\n <ng-template kendoTabContent>\n <div class=\"tab-content\">\n <mm-variables-editor\n [(variables)]=\"variables\">\n </mm-variables-editor>\n </div>\n </ng-template>\n </kendo-tabstrip-tab>\n\n <!-- Time Filter Tab -->\n <kendo-tabstrip-tab [title]=\"'Time Filter'\">\n <ng-template kendoTabContent>\n <div class=\"tab-content\">\n <form class=\"settings-form\">\n <kendo-formfield>\n <kendo-label text=\"Timezone\"></kendo-label>\n <div class=\"radio-group\">\n <div class=\"radio-wrapper\">\n <input\n type=\"radio\"\n kendoRadioButton\n #tzLocal\n [(ngModel)]=\"timeZoneMode\"\n name=\"timeZoneMode\"\n value=\"local\"\n id=\"timeZoneModeLocal\"/>\n <kendo-label [for]=\"tzLocal\" text=\"Local time (browser)\" class=\"radio-label\"></kendo-label>\n </div>\n <div class=\"radio-wrapper\">\n <input\n type=\"radio\"\n kendoRadioButton\n #tzUtc\n [(ngModel)]=\"timeZoneMode\"\n name=\"timeZoneMode\"\n value=\"utc\"\n id=\"timeZoneModeUtc\"/>\n <kendo-label [for]=\"tzUtc\" text=\"UTC\" class=\"radio-label\"></kendo-label>\n </div>\n <div class=\"radio-wrapper\">\n <input\n type=\"radio\"\n kendoRadioButton\n #tzZone\n [(ngModel)]=\"timeZoneMode\"\n name=\"timeZoneMode\"\n [value]=\"zoneSelection\"\n id=\"timeZoneModeZone\"/>\n <kendo-label [for]=\"tzZone\" text=\"Specific time zone\" class=\"radio-label\"></kendo-label>\n <kendo-dropdownlist\n class=\"tz-zone-select\"\n [data]=\"commonTimeZones\"\n [value]=\"zoneSelection\"\n (valueChange)=\"onZoneSelectionChange($event)\"\n [disabled]=\"!isSpecificZone\"\n [filterable]=\"true\"\n aria-label=\"IANA time zone\">\n </kendo-dropdownlist>\n </div>\n </div>\n <kendo-formhint>\n Basis for the time filter and for how all widgets display dates and times. Local time is the default.\n A specific time zone resolves civil days (today / yesterday / this month) DST-correctly in that\n zone and selects matching calendar rollups.\n </kendo-formhint>\n </kendo-formfield>\n\n <kendo-formfield>\n <div class=\"checkbox-wrapper\">\n <input\n type=\"checkbox\"\n kendoCheckBox\n #timeFilterCheckbox\n [(ngModel)]=\"timeFilterEnabled\"\n name=\"timeFilterEnabled\"\n id=\"timeFilterEnabled\"/>\n <kendo-label\n [for]=\"timeFilterCheckbox\"\n text=\"Enable Time Filter\"\n class=\"checkbox-label\">\n </kendo-label>\n </div>\n <kendo-formhint>\n Shows a time range picker in the toolbar. Sets $timeRangeFrom and $timeRangeTo variables.\n </kendo-formhint>\n </kendo-formfield>\n\n @if (timeFilterEnabled) {\n <kendo-formfield>\n <kendo-label text=\"Default Selection\"></kendo-label>\n <mm-time-range-picker\n [initialSelection]=\"initialDefaultSelection\"\n (selectionChange)=\"onDefaultSelectionChange($event)\">\n </mm-time-range-picker>\n <kendo-formhint>Initial time filter shown when no URL parameters are set. Note: User-selected filters override this default. Use the reset button in the toolbar to revert to the default.</kendo-formhint>\n </kendo-formfield>\n }\n </form>\n </div>\n </ng-template>\n </kendo-tabstrip-tab>\n\n <!-- Entity Selectors Tab -->\n <kendo-tabstrip-tab [title]=\"'Entity Selectors'\">\n <ng-template kendoTabContent>\n <div class=\"tab-content\">\n <mm-entity-selector-editor\n [(entitySelectors)]=\"entitySelectors\"\n [existingVariableNames]=\"staticVariableNames\"\n [variables]=\"variables\"\n (editingStateChange)=\"entitySelectorEditing = $event\">\n </mm-entity-selector-editor>\n </div>\n </ng-template>\n </kendo-tabstrip-tab>\n </kendo-tabstrip>\n\n <!-- Dialog Actions -->\n @if (!entitySelectorEditing) {\n <div class=\"dialog-actions mm-dialog-actions\">\n <button kendoButton (click)=\"cancel()\" fillMode=\"flat\">\n Cancel\n </button>\n <button\n kendoButton\n (click)=\"save()\"\n [disabled]=\"!isValid\"\n themeColor=\"primary\">\n Save\n </button>\n </div>\n }\n</div>\n", styles: [".meshboard-settings-dialog{display:flex;flex-direction:column;height:100%;overflow:hidden}.meshboard-settings-dialog kendo-tabstrip{flex:1;min-height:0;display:flex;flex-direction:column}.meshboard-settings-dialog kendo-tabstrip ::ng-deep .k-tabstrip-content{flex:1;min-height:0;overflow:hidden}.meshboard-settings-dialog .tab-content{height:100%;overflow-y:auto;padding:1.5rem}.meshboard-settings-dialog .settings-form{display:flex;flex-direction:column;gap:1.25rem}.meshboard-settings-dialog .settings-form kendo-formfield{display:flex;flex-direction:column;gap:.5rem}.meshboard-settings-dialog .settings-form kendo-formfield kendo-label{font-weight:500;color:var(--kendo-color-on-app-surface, #424242)}.meshboard-settings-dialog .settings-form kendo-formfield kendo-textbox,.meshboard-settings-dialog .settings-form kendo-formfield kendo-textarea,.meshboard-settings-dialog .settings-form kendo-formfield kendo-numerictextbox{width:100%}.meshboard-settings-dialog .settings-form kendo-formfield kendo-formhint{font-size:.75rem;color:var(--kendo-color-subtle, #757575)}.meshboard-settings-dialog .settings-form kendo-formfield kendo-formerror{font-size:.75rem;color:var(--kendo-color-error, #f44336)}.meshboard-settings-dialog .settings-form .section-title{font-size:.875rem;font-weight:600;color:var(--kendo-color-on-app-surface, #424242);margin-top:.5rem;padding-bottom:.5rem;border-bottom:1px solid var(--kendo-color-border, #e0e0e0)}.meshboard-settings-dialog .settings-form .checkbox-wrapper,.meshboard-settings-dialog .settings-form .radio-wrapper{display:flex;align-items:center;gap:.5rem}.meshboard-settings-dialog .settings-form .checkbox-wrapper .checkbox-label,.meshboard-settings-dialog .settings-form .checkbox-wrapper .radio-label,.meshboard-settings-dialog .settings-form .radio-wrapper .checkbox-label,.meshboard-settings-dialog .settings-form .radio-wrapper .radio-label{font-weight:400;cursor:pointer}.meshboard-settings-dialog .settings-form .checkbox-wrapper .tz-zone-select,.meshboard-settings-dialog .settings-form .radio-wrapper .tz-zone-select{min-width:12rem}.meshboard-settings-dialog .settings-form .radio-group{display:flex;flex-direction:column;gap:.5rem;margin-top:.25rem}\n"] }]
|
|
33222
35107
|
}], ctorParameters: () => [] });
|
|
33223
35108
|
|
|
33224
35109
|
/**
|
|
@@ -34277,6 +36162,46 @@ class MeshBoardViewComponent {
|
|
|
34277
36162
|
...(ngDevMode ? [{ debugName: "routeChrome" }] : /* istanbul ignore next */ []));
|
|
34278
36163
|
effectiveChrome = computed(() => resolveMeshBoardChrome(this.chrome(), this.routeChrome()), /* @ts-ignore */
|
|
34279
36164
|
...(ngDevMode ? [{ debugName: "effectiveChrome" }] : /* istanbul ignore next */ []));
|
|
36165
|
+
/**
|
|
36166
|
+
* The period set by the host page (AB#7582): two exact instants, half-open `[from, to)`, as
|
|
36167
|
+
* `Date` objects or ISO 8601 strings with a zone designator. While set (and the board's time
|
|
36168
|
+
* filter is enabled) the view publishes exactly these instants as `$timeRangeFrom` /
|
|
36169
|
+
* `$timeRangeTo` and as the stream-data window - no day snapping, no zone conversion - and
|
|
36170
|
+
* ignores the `tf_*` query parameters and the stored/default selection. It writes no `tf_*`
|
|
36171
|
+
* parameters and causes no unsaved changes. A change reloads the widgets once; `undefined`
|
|
36172
|
+
* returns the board to its own selection. Day rules (e.g. a 06:00 gas day) belong to the host.
|
|
36173
|
+
* An invalid range (unparseable, or `from >= to`) sends no stream-data query and logs one
|
|
36174
|
+
* console error. A board with the time filter disabled ignores the input.
|
|
36175
|
+
*/
|
|
36176
|
+
timeRange = input(undefined, /* @ts-ignore */
|
|
36177
|
+
...(ngDevMode ? [{ debugName: "timeRange" }] : /* istanbul ignore next */ []));
|
|
36178
|
+
/**
|
|
36179
|
+
* Who controls the period: `board` (default, unchanged behaviour) shows the board's time-range
|
|
36180
|
+
* picker; `host` hides the picker and its reset button - entity selectors and actions stay.
|
|
36181
|
+
* While `timeRange` is set the picker is hidden and its events are ignored either way.
|
|
36182
|
+
*/
|
|
36183
|
+
timeFilterControl = input('board', /* @ts-ignore */
|
|
36184
|
+
...(ngDevMode ? [{ debugName: "timeFilterControl" }] : /* istanbul ignore next */ []));
|
|
36185
|
+
/** The parsed `timeRange` input; `undefined` = the host sets no period. */
|
|
36186
|
+
parsedHostTimeRange = computed(() => parseHostTimeRange(this.timeRange()), /* @ts-ignore */
|
|
36187
|
+
...(ngDevMode ? [{ debugName: "parsedHostTimeRange" }] : /* istanbul ignore next */ []));
|
|
36188
|
+
/** True while the host owns the period (a valid or an invalid `timeRange` is set). */
|
|
36189
|
+
isHostTimeRangeActive = computed(() => this.parsedHostTimeRange() !== undefined, /* @ts-ignore */
|
|
36190
|
+
...(ngDevMode ? [{ debugName: "isHostTimeRangeActive" }] : /* istanbul ignore next */ []));
|
|
36191
|
+
/** Whether the board's own time-range picker (and its reset button) is rendered. */
|
|
36192
|
+
showTimeRangePicker = computed(() => this.isTimeFilterEnabled() && this.timeFilterControl() !== 'host' && !this.isHostTimeRangeActive(), /* @ts-ignore */
|
|
36193
|
+
...(ngDevMode ? [{ debugName: "showTimeRangePicker" }] : /* istanbul ignore next */ []));
|
|
36194
|
+
/** The host range last applied to the state service (to reload only on a real change). */
|
|
36195
|
+
appliedHostTimeRange;
|
|
36196
|
+
/** The invalid host range already reported, so a board switch does not log it again. */
|
|
36197
|
+
reportedInvalidHostTimeRange;
|
|
36198
|
+
/**
|
|
36199
|
+
* Set once the host has owned the period: the `tf_*` parameters of the URL this view was opened
|
|
36200
|
+
* with are stale from then on and are not applied again when `timeRange` goes back to undefined.
|
|
36201
|
+
*/
|
|
36202
|
+
hostHasOwnedPeriod = false;
|
|
36203
|
+
/** Set in ngOnDestroy; the async initial load stops touching shared state after it (AB#7582). */
|
|
36204
|
+
destroyed = false;
|
|
34280
36205
|
// Computed link to MeshBoard page with tenant
|
|
34281
36206
|
meshBoardPageLink = computed(() => {
|
|
34282
36207
|
// Get tenant from route parameters (synchronously from snapshot)
|
|
@@ -34358,13 +36283,16 @@ class MeshBoardViewComponent {
|
|
|
34358
36283
|
// Entity Selector computed signals
|
|
34359
36284
|
entitySelectorsConfig = computed(() => this.stateService.getEntitySelectors(), /* @ts-ignore */
|
|
34360
36285
|
...(ngDevMode ? [{ debugName: "entitySelectorsConfig" }] : /* istanbul ignore next */ []));
|
|
34361
|
-
|
|
36286
|
+
/** Board variables substituted into the selectors' picker filters (AB#7724). */
|
|
36287
|
+
boardVariables = computed(() => this.stateService.getVariables(), /* @ts-ignore */
|
|
36288
|
+
...(ngDevMode ? [{ debugName: "boardVariables" }] : /* istanbul ignore next */ []));
|
|
36289
|
+
hasEntitySelectors = computed(() => !this.hideEntitySelectors() && (this.entitySelectorsConfig()?.some(es => showsInToolbar(es)) ?? false), /* @ts-ignore */
|
|
34362
36290
|
...(ngDevMode ? [{ debugName: "hasEntitySelectors" }] : /* istanbul ignore next */ []));
|
|
34363
36291
|
unselectedToolbarSelectors = computed(() => {
|
|
34364
36292
|
if (this.hideEntitySelectors())
|
|
34365
36293
|
return [];
|
|
34366
36294
|
const selectors = this.entitySelectorsConfig() ?? [];
|
|
34367
|
-
return selectors.filter(es => es
|
|
36295
|
+
return selectors.filter(es => showsInToolbar(es) && !es.selectedRtId);
|
|
34368
36296
|
}, /* @ts-ignore */
|
|
34369
36297
|
...(ngDevMode ? [{ debugName: "unselectedToolbarSelectors" }] : /* istanbul ignore next */ []));
|
|
34370
36298
|
/**
|
|
@@ -34381,6 +36309,7 @@ class MeshBoardViewComponent {
|
|
|
34381
36309
|
}, /* @ts-ignore */
|
|
34382
36310
|
...(ngDevMode ? [{ debugName: "canResetTimeFilter" }] : /* istanbul ignore next */ []));
|
|
34383
36311
|
constructor() {
|
|
36312
|
+
this.messages = injectMeshBoardMessages();
|
|
34384
36313
|
// Effect to sync URL when dashboard changes (after initial load)
|
|
34385
36314
|
effect(() => {
|
|
34386
36315
|
const currentRtId = this.stateService.persistedMeshBoardId();
|
|
@@ -34398,6 +36327,15 @@ class MeshBoardViewComponent {
|
|
|
34398
36327
|
}
|
|
34399
36328
|
}
|
|
34400
36329
|
});
|
|
36330
|
+
// Host period (AB#7582): apply every change of the timeRange input after the initial load
|
|
36331
|
+
// (ngOnInit applies the initial value) and reload the widgets once.
|
|
36332
|
+
effect(() => {
|
|
36333
|
+
const parsed = this.parsedHostTimeRange();
|
|
36334
|
+
if (!this._isInitialized()) {
|
|
36335
|
+
return;
|
|
36336
|
+
}
|
|
36337
|
+
untracked(() => this.onHostTimeRangeChanged(parsed));
|
|
36338
|
+
});
|
|
34401
36339
|
// Effect to re-evaluate the auto-refresh timer whenever the MeshBoard
|
|
34402
36340
|
// config changes (e.g. user edits autoRefreshSeconds in settings, or a
|
|
34403
36341
|
// different board is loaded). Re-reads on the same interval skip the
|
|
@@ -34451,8 +36389,16 @@ class MeshBoardViewComponent {
|
|
|
34451
36389
|
this.router.navigateByUrl(applyTimeFilterParams(targetUrl, this.activeTimeFilterSelection()), { replaceUrl: true });
|
|
34452
36390
|
}
|
|
34453
36391
|
}
|
|
34454
|
-
/**
|
|
36392
|
+
/**
|
|
36393
|
+
* The active time-filter selection to mirror into the URL, or null when the filter is off or
|
|
36394
|
+
* the host owns the period or controls the filter (AB#7582: the view then writes no tf_*
|
|
36395
|
+
* parameters; with `timeFilterControl='host'` and no `timeRange` the board selection still
|
|
36396
|
+
* applies, but the URL is the host's).
|
|
36397
|
+
*/
|
|
34455
36398
|
activeTimeFilterSelection() {
|
|
36399
|
+
if (this.isHostTimeRangeActive() || this.timeFilterControl() === 'host') {
|
|
36400
|
+
return null;
|
|
36401
|
+
}
|
|
34456
36402
|
const config = this.stateService.getTimeFilterConfig();
|
|
34457
36403
|
return config?.enabled ? config.selection ?? null : null;
|
|
34458
36404
|
}
|
|
@@ -34495,6 +36441,7 @@ class MeshBoardViewComponent {
|
|
|
34495
36441
|
// Load MeshBoard by well-known name from route data
|
|
34496
36442
|
const widgets = await this.stateService.switchToMeshBoardByWellKnownName(wellKnownName);
|
|
34497
36443
|
if (widgets) {
|
|
36444
|
+
this.primeEntitySelectorScopes();
|
|
34498
36445
|
await this.preloadWidgetData(widgets);
|
|
34499
36446
|
}
|
|
34500
36447
|
else {
|
|
@@ -34505,12 +36452,21 @@ class MeshBoardViewComponent {
|
|
|
34505
36452
|
else {
|
|
34506
36453
|
// Load initial MeshBoard (first available)
|
|
34507
36454
|
const widgets = await this.stateService.loadInitialMeshBoard();
|
|
36455
|
+
this.primeEntitySelectorScopes();
|
|
34508
36456
|
await this.preloadWidgetData(widgets);
|
|
34509
36457
|
}
|
|
36458
|
+
// The view may have been destroyed while the board loaded (e.g. a fast switch on the host
|
|
36459
|
+
// page): it must not publish its host period or selection into the root-provided state.
|
|
36460
|
+
if (this.destroyed) {
|
|
36461
|
+
return;
|
|
36462
|
+
}
|
|
34510
36463
|
// Initialize time filter variables if enabled with stored selection
|
|
34511
36464
|
this.initializeTimeFilterVariables();
|
|
34512
36465
|
// Initialize entity selectors from URL params, route data, or defaults
|
|
34513
36466
|
await this.initializeEntitySelectors();
|
|
36467
|
+
if (this.destroyed) {
|
|
36468
|
+
return;
|
|
36469
|
+
}
|
|
34514
36470
|
// Mark initial load as complete so the effect can handle subsequent board switches
|
|
34515
36471
|
this.initialLoadComplete = true;
|
|
34516
36472
|
// `?edit=1` (e.g. "Edit" in a host's board list) opens the board in edit mode;
|
|
@@ -34566,15 +36522,25 @@ class MeshBoardViewComponent {
|
|
|
34566
36522
|
if (exists) {
|
|
34567
36523
|
// Switch to the requested MeshBoard
|
|
34568
36524
|
const switchedWidgets = await this.stateService.switchToMeshBoard(rtId);
|
|
36525
|
+
this.primeEntitySelectorScopes();
|
|
34569
36526
|
await this.preloadWidgetData(switchedWidgets);
|
|
34570
36527
|
}
|
|
34571
36528
|
else {
|
|
34572
36529
|
// MeshBoard not found - use whatever was loaded initially
|
|
34573
36530
|
console.warn(`MeshBoard with rtId ${rtId} not found, using default`);
|
|
36531
|
+
this.primeEntitySelectorScopes();
|
|
34574
36532
|
await this.preloadWidgetData(widgets);
|
|
34575
36533
|
}
|
|
34576
36534
|
}
|
|
34577
36535
|
ngOnDestroy() {
|
|
36536
|
+
this.destroyed = true;
|
|
36537
|
+
// The state service is root-provided: a host period must not outlive the view that set it.
|
|
36538
|
+
// Owner-aware, so a period another view set in the meantime stays. Only a view that set a
|
|
36539
|
+
// period clears one (the default path never touches it); a view destroyed while its board was
|
|
36540
|
+
// still loading never set one (see the guards after the awaits in ngOnInit).
|
|
36541
|
+
if (this.hostHasOwnedPeriod) {
|
|
36542
|
+
this.stateService.clearHostTimeRange(this);
|
|
36543
|
+
}
|
|
34578
36544
|
this.resizeObserver?.disconnect();
|
|
34579
36545
|
this.closeConfigDialog();
|
|
34580
36546
|
this.navigationSubscription?.unsubscribe();
|
|
@@ -34617,14 +36583,19 @@ class MeshBoardViewComponent {
|
|
|
34617
36583
|
/**
|
|
34618
36584
|
* Initializes time filter variables based on URL query params or stored selection.
|
|
34619
36585
|
* URL params take precedence over stored selection to support page reload.
|
|
36586
|
+
* A period set by the host (`timeRange`, AB#7582) takes precedence over both.
|
|
34620
36587
|
*/
|
|
34621
36588
|
initializeTimeFilterVariables() {
|
|
36589
|
+
if (this.applyHostTimeRange(this.parsedHostTimeRange())) {
|
|
36590
|
+
return;
|
|
36591
|
+
}
|
|
34622
36592
|
const timeFilter = this.stateService.getTimeFilterConfig();
|
|
34623
36593
|
if (!timeFilter?.enabled) {
|
|
34624
36594
|
return;
|
|
34625
36595
|
}
|
|
34626
|
-
// Check URL query params first (takes precedence over stored selection)
|
|
34627
|
-
|
|
36596
|
+
// Check URL query params first (takes precedence over stored selection). Once the host has
|
|
36597
|
+
// owned the period, the tf_* of the opening URL are stale and are not applied again.
|
|
36598
|
+
const urlSelection = this.hostHasOwnedPeriod ? null : this.readTimeFilterFromUrl();
|
|
34628
36599
|
if (urlSelection) {
|
|
34629
36600
|
// Signal the URL selection so the picker picks it up
|
|
34630
36601
|
this._urlTimeSelection.set(urlSelection);
|
|
@@ -34664,6 +36635,59 @@ class MeshBoardViewComponent {
|
|
|
34664
36635
|
const rangeISO = TimeRangeUtils.toISO(range);
|
|
34665
36636
|
this.stateService.setTimeFilterVariables(rangeISO.from, rangeISO.to);
|
|
34666
36637
|
}
|
|
36638
|
+
/**
|
|
36639
|
+
* Hands the host period to the state service and publishes `$timeRangeFrom` / `$timeRangeTo`
|
|
36640
|
+
* as exactly its instants (AB#7582). Returns true while the host owns the period, so the
|
|
36641
|
+
* caller skips the board's own selection. With the time filter disabled the period is only
|
|
36642
|
+
* stored (the state service ignores it then) and nothing is logged.
|
|
36643
|
+
*/
|
|
36644
|
+
applyHostTimeRange(parsed) {
|
|
36645
|
+
if (this.destroyed) {
|
|
36646
|
+
return parsed !== undefined;
|
|
36647
|
+
}
|
|
36648
|
+
const previous = this.appliedHostTimeRange;
|
|
36649
|
+
this.appliedHostTimeRange = parsed;
|
|
36650
|
+
if (!parsed) {
|
|
36651
|
+
// Without a host period nothing changes for the board (default path untouched). When the
|
|
36652
|
+
// host drops its period, its variables go too; the board's own selection (if any) re-sets them.
|
|
36653
|
+
if (previous) {
|
|
36654
|
+
this.stateService.clearHostTimeRange(this);
|
|
36655
|
+
this.stateService.clearTimeFilterVariables();
|
|
36656
|
+
}
|
|
36657
|
+
return false;
|
|
36658
|
+
}
|
|
36659
|
+
this.hostHasOwnedPeriod = true;
|
|
36660
|
+
this.stateService.setHostTimeRange(parsed, this);
|
|
36661
|
+
if (!this.stateService.isTimeFilterEnabled()) {
|
|
36662
|
+
return true;
|
|
36663
|
+
}
|
|
36664
|
+
if (parsed.status === 'valid') {
|
|
36665
|
+
this.stateService.setTimeFilterVariables(parsed.from.toISOString(), parsed.to.toISOString());
|
|
36666
|
+
}
|
|
36667
|
+
else {
|
|
36668
|
+
this.stateService.clearTimeFilterVariables();
|
|
36669
|
+
if (this.reportedInvalidHostTimeRange !== parsed) {
|
|
36670
|
+
this.reportedInvalidHostTimeRange = parsed;
|
|
36671
|
+
console.error(`mm-meshboard-view: input "timeRange" is invalid (${parsed.reason}); `
|
|
36672
|
+
+ 'the board sends no stream-data query until the host sets a valid range.');
|
|
36673
|
+
}
|
|
36674
|
+
}
|
|
36675
|
+
return true;
|
|
36676
|
+
}
|
|
36677
|
+
/**
|
|
36678
|
+
* Applies a change of the `timeRange` input after the initial load (AB#7582): the host period,
|
|
36679
|
+
* or the board's own selection again when the input went back to `undefined`. Reloads the
|
|
36680
|
+
* widgets once; an equal period (same instants) changes nothing.
|
|
36681
|
+
*/
|
|
36682
|
+
onHostTimeRangeChanged(parsed) {
|
|
36683
|
+
if (sameHostTimeRange(parsed, this.appliedHostTimeRange)) {
|
|
36684
|
+
return;
|
|
36685
|
+
}
|
|
36686
|
+
this.initializeTimeFilterVariables();
|
|
36687
|
+
if (this.stateService.isTimeFilterEnabled()) {
|
|
36688
|
+
this.stateService.triggerRefresh();
|
|
36689
|
+
}
|
|
36690
|
+
}
|
|
34667
36691
|
/**
|
|
34668
36692
|
* Recomputes the active time-filter range from the current selection on the
|
|
34669
36693
|
* board's (possibly just-changed) timezone mode and republishes the
|
|
@@ -34671,6 +36695,11 @@ class MeshBoardViewComponent {
|
|
|
34671
36695
|
* disabled or no selection is active.
|
|
34672
36696
|
*/
|
|
34673
36697
|
reapplyTimeFilterRange() {
|
|
36698
|
+
// A host period (valid or invalid) takes the same path as on every other change.
|
|
36699
|
+
if (this.isHostTimeRangeActive()) {
|
|
36700
|
+
this.applyHostTimeRange(this.parsedHostTimeRange());
|
|
36701
|
+
return;
|
|
36702
|
+
}
|
|
34674
36703
|
const range = this.stateService.resolveCurrentTimeRange();
|
|
34675
36704
|
if (!range) {
|
|
34676
36705
|
return;
|
|
@@ -34877,6 +36906,11 @@ class MeshBoardViewComponent {
|
|
|
34877
36906
|
console.warn(`Entity selector '${selectorId}' not found`);
|
|
34878
36907
|
return;
|
|
34879
36908
|
}
|
|
36909
|
+
if (isFollowerSelector(selector)) {
|
|
36910
|
+
// A follower's pick is derived from its leader (AB#7722).
|
|
36911
|
+
console.warn(`Entity selector '${selectorId}' follows '${selector.followsSelectorId}' and cannot be set directly`);
|
|
36912
|
+
return;
|
|
36913
|
+
}
|
|
34880
36914
|
// Update selection state
|
|
34881
36915
|
this.stateService.updateEntitySelectorSelection(selectorId, rtId, displayName);
|
|
34882
36916
|
// Fetch entity and set variables from attribute mappings
|
|
@@ -34891,12 +36925,80 @@ class MeshBoardViewComponent {
|
|
|
34891
36925
|
* Programmatically clears an entity selector value from outside the component.
|
|
34892
36926
|
*/
|
|
34893
36927
|
clearEntitySelector(selectorId) {
|
|
36928
|
+
const selector = this.stateService.getEntitySelector(selectorId);
|
|
36929
|
+
if (selector && isFollowerSelector(selector)) {
|
|
36930
|
+
console.warn(`Entity selector '${selectorId}' follows '${selector.followsSelectorId}' and cannot be cleared directly`);
|
|
36931
|
+
return;
|
|
36932
|
+
}
|
|
36933
|
+
this.clearSelectorPick(selectorId);
|
|
36934
|
+
// Followers fall back to their own defaultRtId, or to no scope (AB#7722).
|
|
36935
|
+
const followers = this.resetFollowersOf(selectorId);
|
|
36936
|
+
this.stateService.triggerRefresh();
|
|
36937
|
+
if (followers) {
|
|
36938
|
+
void followers.then(() => this.stateService.triggerRefresh());
|
|
36939
|
+
}
|
|
36940
|
+
}
|
|
36941
|
+
/**
|
|
36942
|
+
* Clears one selector's pick, variables and scope state. Without a pick bound
|
|
36943
|
+
* widgets fall back to the persisted scope (AB#7721).
|
|
36944
|
+
*/
|
|
36945
|
+
clearSelectorPick(selectorId) {
|
|
34894
36946
|
this.stateService.updateEntitySelectorSelection(selectorId, undefined);
|
|
34895
36947
|
this.stateService.clearEntitySelectorVariables(selectorId);
|
|
34896
|
-
this.
|
|
36948
|
+
this.scopeRequestSeq.set(selectorId, (this.scopeRequestSeq.get(selectorId) ?? 0) + 1);
|
|
36949
|
+
this.stateService.clearEntitySelectorRtIds(selectorId);
|
|
36950
|
+
}
|
|
36951
|
+
/**
|
|
36952
|
+
* The leader `leaderId` has no pick any more: each of its followers resolves
|
|
36953
|
+
* its own `defaultRtId`, or — without one — has no pick and no scope
|
|
36954
|
+
* (AB#7722). Followers with a default are marked pending synchronously.
|
|
36955
|
+
* Returns a promise for the default resolutions, or `null` when there are none.
|
|
36956
|
+
*/
|
|
36957
|
+
resetFollowersOf(leaderId) {
|
|
36958
|
+
const followers = followersOf(this.stateService.getEntitySelectors(), leaderId);
|
|
36959
|
+
const resolutions = [];
|
|
36960
|
+
for (const follower of followers) {
|
|
36961
|
+
if (follower.defaultRtId) {
|
|
36962
|
+
const rtId = follower.defaultRtId;
|
|
36963
|
+
const resolution = this.resolveEntitySelectorVariables(follower, rtId);
|
|
36964
|
+
// The resolution marked the follower pending synchronously; remember
|
|
36965
|
+
// its sequence so a leader pick made meanwhile keeps its selection.
|
|
36966
|
+
const seq = this.scopeRequestSeq.get(follower.id);
|
|
36967
|
+
resolutions.push(resolution.then(displayName => {
|
|
36968
|
+
if (this.scopeRequestSeq.get(follower.id) === seq) {
|
|
36969
|
+
this.stateService.updateEntitySelectorSelection(follower.id, rtId, displayName);
|
|
36970
|
+
}
|
|
36971
|
+
}));
|
|
36972
|
+
}
|
|
36973
|
+
else {
|
|
36974
|
+
this.clearSelectorPick(follower.id);
|
|
36975
|
+
}
|
|
36976
|
+
}
|
|
36977
|
+
return resolutions.length > 0 ? Promise.all(resolutions).then(() => undefined) : null;
|
|
34897
36978
|
}
|
|
34898
36979
|
/** Resolution errors for runtime entity variables */
|
|
34899
36980
|
variableResolutionErrors = this.stateService.variableResolutionErrors;
|
|
36981
|
+
// Assigned in the constructor: a field initialised from a relative import breaks under the
|
|
36982
|
+
// Vitest module runner (see the frontend-libraries CLAUDE.md).
|
|
36983
|
+
messages;
|
|
36984
|
+
/**
|
|
36985
|
+
* One notice per entity selector whose stream-data scope could not be
|
|
36986
|
+
* resolved (AB#7721), so "nothing in scope" (empty widgets, no notice) and
|
|
36987
|
+
* "lookup failed" (empty widgets plus this notice) are distinguishable.
|
|
36988
|
+
*/
|
|
36989
|
+
scopeResolutionErrors = computed(() => {
|
|
36990
|
+
const template = this.messages().scopeResolutionFailed;
|
|
36991
|
+
return this.stateService.failedEntitySelectorScopeIds().map(id => {
|
|
36992
|
+
const label = this.stateService.getEntitySelector(id)?.label || id;
|
|
36993
|
+
return { selectorId: id, message: template.split('{label}').join(label) };
|
|
36994
|
+
});
|
|
36995
|
+
}, /* @ts-ignore */
|
|
36996
|
+
...(ngDevMode ? [{ debugName: "scopeResolutionErrors" }] : /* istanbul ignore next */ []));
|
|
36997
|
+
/**
|
|
36998
|
+
* Per-selector sequence number of the latest scope resolution, so a slower
|
|
36999
|
+
* resolution of an earlier pick never overwrites the scope of a newer pick.
|
|
37000
|
+
*/
|
|
37001
|
+
scopeRequestSeq = new Map();
|
|
34900
37002
|
// ============================================================================
|
|
34901
37003
|
// Time Filter Event Handlers
|
|
34902
37004
|
// ============================================================================
|
|
@@ -34914,6 +37016,10 @@ class MeshBoardViewComponent {
|
|
|
34914
37016
|
* Updates the time filter variables, persists the selection, syncs to URL, and triggers refresh.
|
|
34915
37017
|
*/
|
|
34916
37018
|
async onTimeSelectionChange(sharedSelection) {
|
|
37019
|
+
// The host owns the period (AB#7582): no selection, URL or refresh from the picker.
|
|
37020
|
+
if (this.isHostTimeRangeActive()) {
|
|
37021
|
+
return;
|
|
37022
|
+
}
|
|
34917
37023
|
// Convert shared-ui selection to our model
|
|
34918
37024
|
const selection = {
|
|
34919
37025
|
type: sharedSelection.type,
|
|
@@ -34956,6 +37062,9 @@ class MeshBoardViewComponent {
|
|
|
34956
37062
|
* Clears the stored selection and applies the default.
|
|
34957
37063
|
*/
|
|
34958
37064
|
resetTimeFilterToDefault() {
|
|
37065
|
+
if (this.isHostTimeRangeActive()) {
|
|
37066
|
+
return;
|
|
37067
|
+
}
|
|
34959
37068
|
const config = this.timeFilterConfig();
|
|
34960
37069
|
if (!config?.defaultSelection)
|
|
34961
37070
|
return;
|
|
@@ -35330,7 +37439,8 @@ class MeshBoardViewComponent {
|
|
|
35330
37439
|
async onEntitySelectorSelected(event) {
|
|
35331
37440
|
const { selectorId, entity } = event;
|
|
35332
37441
|
const selector = this.stateService.getEntitySelector(selectorId);
|
|
35333
|
-
|
|
37442
|
+
// Followers render no picker; their pick is derived from the leader (AB#7722).
|
|
37443
|
+
if (!selector || isFollowerSelector(selector))
|
|
35334
37444
|
return;
|
|
35335
37445
|
// Update selector selection state
|
|
35336
37446
|
this.stateService.updateEntitySelectorSelection(selectorId, entity.rtId, entity.displayName);
|
|
@@ -35347,12 +37457,14 @@ class MeshBoardViewComponent {
|
|
|
35347
37457
|
*/
|
|
35348
37458
|
async onEntitySelectorCleared(event) {
|
|
35349
37459
|
const { selectorId } = event;
|
|
35350
|
-
|
|
35351
|
-
|
|
35352
|
-
|
|
35353
|
-
|
|
35354
|
-
//
|
|
35355
|
-
this.
|
|
37460
|
+
const selector = this.stateService.getEntitySelector(selectorId);
|
|
37461
|
+
if (selector && isFollowerSelector(selector))
|
|
37462
|
+
return;
|
|
37463
|
+
// Clear selection, variables and the resolved stream-data scope: without a
|
|
37464
|
+
// pick bound widgets fall back to the persisted scope (AB#7721).
|
|
37465
|
+
this.clearSelectorPick(selectorId);
|
|
37466
|
+
// Followers fall back to their own defaultRtId, or to no scope (AB#7722).
|
|
37467
|
+
await this.resetFollowersOf(selectorId);
|
|
35356
37468
|
// Sync to URL
|
|
35357
37469
|
this.writeEntitySelectorsToUrl();
|
|
35358
37470
|
// Refresh widgets
|
|
@@ -35362,114 +37474,253 @@ class MeshBoardViewComponent {
|
|
|
35362
37474
|
* Initializes entity selectors from URL params, route data, or defaults.
|
|
35363
37475
|
*/
|
|
35364
37476
|
async initializeEntitySelectors() {
|
|
37477
|
+
const picks = this.initialEntitySelectorPicks(true);
|
|
37478
|
+
if (picks.length === 0)
|
|
37479
|
+
return;
|
|
37480
|
+
// Mark every picked scope pending before the first await, so no bound
|
|
37481
|
+
// widget queries the persisted scope while the picks resolve (AB#7721).
|
|
37482
|
+
this.primeEntitySelectorScopes();
|
|
37483
|
+
for (const { selector, rtId, viaLeader } of picks) {
|
|
37484
|
+
// A follower whose leader has a pick is resolved together with the
|
|
37485
|
+
// leader (one entity fetch, AB#7722).
|
|
37486
|
+
if (viaLeader)
|
|
37487
|
+
continue;
|
|
37488
|
+
const displayName = await this.resolveEntitySelectorVariables(selector, rtId);
|
|
37489
|
+
this.stateService.updateEntitySelectorSelection(selector.id, rtId, displayName);
|
|
37490
|
+
}
|
|
37491
|
+
// Widgets reload only when their config reference changes. Bound widgets
|
|
37492
|
+
// rendered while their scope was pending must now load with the resolved
|
|
37493
|
+
// scope (AB#7721).
|
|
37494
|
+
this.stateService.triggerRefresh();
|
|
37495
|
+
}
|
|
37496
|
+
/**
|
|
37497
|
+
* The initial pick of each entity selector. Priority: route data > URL
|
|
37498
|
+
* params > defaultRtId. Selectors without a pick are left out.
|
|
37499
|
+
*
|
|
37500
|
+
* Followers (AB#7722) never read route data or the URL: a follower takes its
|
|
37501
|
+
* leader's initial pick (`viaLeader: true`, resolved together with the
|
|
37502
|
+
* leader) and falls back to its own `defaultRtId` only when the leader has
|
|
37503
|
+
* none. An unknown or chained leader counts as "leader has no pick"; with
|
|
37504
|
+
* `warn` set this logs a console warning.
|
|
37505
|
+
*/
|
|
37506
|
+
initialEntitySelectorPicks(warn = false) {
|
|
35365
37507
|
const selectors = this.stateService.getEntitySelectors();
|
|
35366
37508
|
if (selectors.length === 0)
|
|
35367
|
-
return;
|
|
35368
|
-
// Read URL params
|
|
37509
|
+
return [];
|
|
35369
37510
|
const urlSelections = this.readEntitySelectorsFromUrl();
|
|
35370
|
-
// Read route data
|
|
35371
37511
|
const routeData = this.route.snapshot.data['entitySelectors'];
|
|
37512
|
+
const ownPick = (selector) => routeData?.[selector.id] ?? urlSelections.get(selector.id) ?? selector.defaultRtId;
|
|
37513
|
+
const picks = [];
|
|
35372
37514
|
for (const selector of selectors) {
|
|
35373
|
-
|
|
35374
|
-
|
|
35375
|
-
|
|
35376
|
-
|
|
35377
|
-
|
|
35378
|
-
|
|
35379
|
-
|
|
37515
|
+
const leader = resolveFollowLeader(selectors, selector);
|
|
37516
|
+
if (leader.kind === 'none') {
|
|
37517
|
+
const rtId = ownPick(selector);
|
|
37518
|
+
if (rtId)
|
|
37519
|
+
picks.push({ selector, rtId, viaLeader: false });
|
|
37520
|
+
continue;
|
|
37521
|
+
}
|
|
37522
|
+
if (leader.kind === 'invalid' && warn) {
|
|
37523
|
+
console.warn(`Entity selector '${selector.id}' follows '${leader.leaderId}', which ${leader.problem === 'unknown' ? 'does not exist' : leader.problem === 'self' ? 'is itself' : 'follows another selector'} — treated as "leader has no pick"`);
|
|
37524
|
+
}
|
|
37525
|
+
const leaderRtId = leader.kind === 'leader' ? ownPick(leader.leader) : undefined;
|
|
37526
|
+
if (leaderRtId) {
|
|
37527
|
+
picks.push({ selector, rtId: leaderRtId, viaLeader: true });
|
|
37528
|
+
}
|
|
37529
|
+
else if (selector.defaultRtId) {
|
|
37530
|
+
picks.push({ selector, rtId: selector.defaultRtId, viaLeader: false });
|
|
37531
|
+
}
|
|
37532
|
+
}
|
|
37533
|
+
return picks;
|
|
37534
|
+
}
|
|
37535
|
+
/**
|
|
37536
|
+
* Marks the scope of every selector that has an initial pick as pending
|
|
37537
|
+
* (AB#7721). Called synchronously right after a board is loaded, before
|
|
37538
|
+
* change detection renders its widgets, so a bound stream-data widget starts
|
|
37539
|
+
* in its loading state instead of querying the persisted (unscoped) scope.
|
|
37540
|
+
* Selectors without a pick stay without scope state (persisted scope).
|
|
37541
|
+
*/
|
|
37542
|
+
primeEntitySelectorScopes() {
|
|
37543
|
+
for (const { selector } of this.initialEntitySelectorPicks()) {
|
|
37544
|
+
if (!this.stateService.getEntitySelectorScopeState(selector.id)) {
|
|
37545
|
+
this.stateService.setEntitySelectorScopePending(selector.id);
|
|
35380
37546
|
}
|
|
35381
37547
|
}
|
|
35382
37548
|
}
|
|
35383
37549
|
/**
|
|
35384
|
-
* Resolves variables for an entity selector by fetching entity attributes
|
|
37550
|
+
* Resolves variables for an entity selector by fetching entity attributes,
|
|
37551
|
+
* then its stream-data scope.
|
|
37552
|
+
*
|
|
37553
|
+
* A leader's pick is also the pick of all its followers (AB#7722): they are
|
|
37554
|
+
* marked pending together with the leader, the picked entity is fetched
|
|
37555
|
+
* once, and each follower gets its own variables (its attribute mappings,
|
|
37556
|
+
* `<id>_rtId`, `<id>_rtCkTypeId`), its own scope (its `childScope`) and the
|
|
37557
|
+
* leader's selection. Returns the picked entity's display name.
|
|
35385
37558
|
*/
|
|
35386
37559
|
async resolveEntitySelectorVariables(selector, rtId) {
|
|
37560
|
+
const selectors = this.stateService.getEntitySelectors();
|
|
37561
|
+
const resolved = effectiveSelector(selectors, selector);
|
|
37562
|
+
const followers = followersOf(selectors, selector.id).map(f => effectiveSelector(selectors, f));
|
|
37563
|
+
const targets = [resolved, ...followers];
|
|
37564
|
+
// The scope of the new pick is unknown until it resolves: bound widgets
|
|
37565
|
+
// must neither keep the previous pick's scope nor fall back to the
|
|
37566
|
+
// persisted one (AB#7721). Followers are pending while the leader is.
|
|
37567
|
+
const seqs = targets.map(t => this.beginScopeResolution(t.id));
|
|
37568
|
+
const failAll = () => targets.forEach((t, i) => this.failScopeResolution(t.id, seqs[i]));
|
|
35387
37569
|
try {
|
|
35388
37570
|
const entity = await firstValueFrom(this.dataService.fetchRuntimeEntity({
|
|
35389
37571
|
type: 'runtimeEntity',
|
|
35390
|
-
ckTypeId:
|
|
37572
|
+
ckTypeId: resolved.ckTypeId,
|
|
35391
37573
|
rtId
|
|
35392
37574
|
}));
|
|
35393
37575
|
if (!entity) {
|
|
35394
37576
|
console.warn(`Entity not found for selector '${selector.id}': ${rtId}`);
|
|
37577
|
+
// The pick cannot be resolved: bound widgets show no data instead of
|
|
37578
|
+
// waiting forever or falling back to the persisted scope (AB#7721).
|
|
37579
|
+
failAll();
|
|
35395
37580
|
return undefined;
|
|
35396
37581
|
}
|
|
35397
|
-
|
|
35398
|
-
|
|
35399
|
-
|
|
35400
|
-
|
|
35401
|
-
|
|
35402
|
-
|
|
35403
|
-
|
|
35404
|
-
: 'string'
|
|
35405
|
-
};
|
|
37582
|
+
// A newer pick (of the leader, or a follower's own default) may have
|
|
37583
|
+
// started meanwhile; never overwrite its variables or selection.
|
|
37584
|
+
const isCurrent = (i) => this.scopeRequestSeq.get(targets[i].id) === seqs[i];
|
|
37585
|
+
targets.forEach((target, i) => {
|
|
37586
|
+
if (isCurrent(i)) {
|
|
37587
|
+
this.stateService.setEntitySelectorVariables(target.id, this.selectorVariables(target, entity, rtId));
|
|
37588
|
+
}
|
|
35406
37589
|
});
|
|
35407
|
-
// Auto-expose the selected entity's rtId as `$<selectorId>_rtId` so SD/runtime
|
|
35408
|
-
// widgets can scope to the picked asset (parent-association filter) without the
|
|
35409
|
-
// designer adding a fake attribute mapping. An explicit mapping to the same
|
|
35410
|
-
// variable name wins (skip the synthetic one if already present).
|
|
35411
|
-
const rtIdVariableName = `${selector.id}_rtId`;
|
|
35412
|
-
if (!values.some(v => v.name === rtIdVariableName)) {
|
|
35413
|
-
values.push({ name: rtIdVariableName, value: rtId, type: 'string' });
|
|
35414
|
-
}
|
|
35415
|
-
// Auto-expose the selected entity's CK type as `$<selectorId>_rtCkTypeId`
|
|
35416
|
-
// so entity-display widgets (e.g. the entity card) can resolve both the
|
|
35417
|
-
// rtId and its type from a single board-level selection. Prefer the picked
|
|
35418
|
-
// entity's actual type (it may be a subtype of the selector's configured
|
|
35419
|
-
// `ckTypeId`); fall back to the selector's type. An explicit mapping to the
|
|
35420
|
-
// same variable name wins.
|
|
35421
|
-
const rtCkTypeIdVariableName = `${selector.id}_rtCkTypeId`;
|
|
35422
|
-
if (!values.some(v => v.name === rtCkTypeIdVariableName)) {
|
|
35423
|
-
values.push({ name: rtCkTypeIdVariableName, value: entity.ckTypeId ?? selector.ckTypeId, type: 'string' });
|
|
35424
|
-
}
|
|
35425
|
-
this.stateService.setEntitySelectorVariables(selector.id, values);
|
|
35426
|
-
// Resolve the stream-data scope rtIds for this selection (one-hop
|
|
35427
|
-
// childScope traversal, or the picked entity itself). Runs alongside the
|
|
35428
|
-
// variable resolution so widgets bound to this selector can scope their
|
|
35429
|
-
// stream-data query by the resolved source rtIds.
|
|
35430
|
-
await this.resolveEntitySelectorScopeRtIds(selector, rtId);
|
|
35431
37590
|
// Label the selection the way the pick dialog does: by the engine's
|
|
35432
37591
|
// display name (the type's displayNameRule — "2026" for a fiscal year),
|
|
35433
37592
|
// not the well-known name, which most entities do not carry. Without it
|
|
35434
37593
|
// a board opened on its defaultRtId showed the raw rtId in the toolbar
|
|
35435
37594
|
// until the user re-picked the very same entity.
|
|
35436
|
-
|
|
37595
|
+
const displayName = entity.rtDisplayName || entity.rtWellKnownName || entity.rtId;
|
|
37596
|
+
followers.forEach((follower, i) => {
|
|
37597
|
+
if (isCurrent(i + 1)) {
|
|
37598
|
+
this.stateService.updateEntitySelectorSelection(follower.id, rtId, displayName);
|
|
37599
|
+
}
|
|
37600
|
+
});
|
|
37601
|
+
// Resolve the stream-data scope rtIds for this selection (childScope
|
|
37602
|
+
// hop or path, or the picked entity itself), per selector, so
|
|
37603
|
+
// widgets bound to the leader or any follower scope their stream-data
|
|
37604
|
+
// query by the resolved source rtIds.
|
|
37605
|
+
await Promise.all(targets.map((t, i) => this.resolveEntitySelectorScopeRtIds(t, rtId, seqs[i])));
|
|
37606
|
+
return displayName;
|
|
35437
37607
|
}
|
|
35438
37608
|
catch (error) {
|
|
35439
37609
|
console.error(`Error resolving entity selector '${selector.id}':`, error);
|
|
37610
|
+
// Same as above: a failed pick is an empty scope, never the persisted one.
|
|
37611
|
+
failAll();
|
|
35440
37612
|
return undefined;
|
|
35441
37613
|
}
|
|
35442
37614
|
}
|
|
37615
|
+
/**
|
|
37616
|
+
* The variables one selector derives from the picked entity: its attribute
|
|
37617
|
+
* mappings plus the auto-exposed `<id>_rtId` and `<id>_rtCkTypeId`.
|
|
37618
|
+
*/
|
|
37619
|
+
selectorVariables(selector, entity, rtId) {
|
|
37620
|
+
const values = selector.attributeMappings.map(mapping => {
|
|
37621
|
+
const attr = entity.attributes.find(a => a.attributeName === mapping.attributePath);
|
|
37622
|
+
return {
|
|
37623
|
+
name: mapping.variableName,
|
|
37624
|
+
value: attr ? String(attr.value ?? '') : '',
|
|
37625
|
+
type: mapping.attributeValueType
|
|
37626
|
+
? this.variableService.mapAttributeTypeToVariableType(mapping.attributeValueType)
|
|
37627
|
+
: 'string'
|
|
37628
|
+
};
|
|
37629
|
+
});
|
|
37630
|
+
// Auto-expose the selected entity's rtId as `$<selectorId>_rtId` so SD/runtime
|
|
37631
|
+
// widgets can scope to the picked asset (parent-association filter) without the
|
|
37632
|
+
// designer adding a fake attribute mapping. An explicit mapping to the same
|
|
37633
|
+
// variable name wins (skip the synthetic one if already present).
|
|
37634
|
+
const rtIdVariableName = `${selector.id}_rtId`;
|
|
37635
|
+
if (!values.some(v => v.name === rtIdVariableName)) {
|
|
37636
|
+
values.push({ name: rtIdVariableName, value: rtId, type: 'string' });
|
|
37637
|
+
}
|
|
37638
|
+
// Auto-expose the selected entity's CK type as `$<selectorId>_rtCkTypeId`
|
|
37639
|
+
// so entity-display widgets (e.g. the entity card) can resolve both the
|
|
37640
|
+
// rtId and its type from a single board-level selection. Prefer the picked
|
|
37641
|
+
// entity's actual type (it may be a subtype of the selector's configured
|
|
37642
|
+
// `ckTypeId`); fall back to the selector's type. An explicit mapping to the
|
|
37643
|
+
// same variable name wins.
|
|
37644
|
+
const rtCkTypeIdVariableName = `${selector.id}_rtCkTypeId`;
|
|
37645
|
+
if (!values.some(v => v.name === rtCkTypeIdVariableName)) {
|
|
37646
|
+
values.push({ name: rtCkTypeIdVariableName, value: entity.ckTypeId ?? selector.ckTypeId, type: 'string' });
|
|
37647
|
+
}
|
|
37648
|
+
return values;
|
|
37649
|
+
}
|
|
35443
37650
|
/**
|
|
35444
37651
|
* Resolves the set of source rtIds that a stream-data widget bound to this
|
|
35445
37652
|
* selector should be scoped to, and caches them on the state service.
|
|
35446
37653
|
*
|
|
35447
|
-
*
|
|
35448
|
-
*
|
|
35449
|
-
*
|
|
35450
|
-
*
|
|
35451
|
-
|
|
35452
|
-
|
|
35453
|
-
|
|
35454
|
-
|
|
37654
|
+
* - No {@link EntitySelectorChildScope}: the picked entity's own rtId is the
|
|
37655
|
+
* scope (direct keying).
|
|
37656
|
+
* - One-hop form (top-level fields): the picked entity's targets of
|
|
37657
|
+
* `targetCkTypeId` reached via `roleId`/`direction` (e.g. MeteringPoint →
|
|
37658
|
+
* its EnergyMeasurement rtIds) — exactly as before AB#7723.
|
|
37659
|
+
* - `path` (AB#7723): hop by hop, each hop one batched request over all
|
|
37660
|
+
* entities the previous hop reached; the scope is the distinct rtIds of the
|
|
37661
|
+
* last hop. An empty hop ends the resolution with an empty scope; a failed
|
|
37662
|
+
* hop, or an invalid path (more than 5 hops, a hop without role or target
|
|
37663
|
+
* type), is a failed scope (AB#7721).
|
|
37664
|
+
*/
|
|
37665
|
+
async resolveEntitySelectorScopeRtIds(selector, rtId, seq) {
|
|
37666
|
+
const isCurrent = () => this.scopeRequestSeq.get(selector.id) === seq;
|
|
37667
|
+
if (!isCurrent())
|
|
37668
|
+
return;
|
|
37669
|
+
const plan = scopePlan(selector.childScope);
|
|
37670
|
+
if (plan.kind === 'none') {
|
|
35455
37671
|
this.stateService.setEntitySelectorRtIds(selector.id, [rtId]);
|
|
35456
37672
|
return;
|
|
35457
37673
|
}
|
|
37674
|
+
if (plan.kind === 'invalid') {
|
|
37675
|
+
console.error(`Invalid child scope path for selector '${selector.id}': ${plan.reason}`);
|
|
37676
|
+
this.stateService.setEntitySelectorScopeFailed(selector.id);
|
|
37677
|
+
return;
|
|
37678
|
+
}
|
|
35458
37679
|
try {
|
|
35459
|
-
|
|
35460
|
-
|
|
35461
|
-
|
|
35462
|
-
|
|
35463
|
-
|
|
35464
|
-
|
|
35465
|
-
.
|
|
35466
|
-
|
|
37680
|
+
let scopeRtIds;
|
|
37681
|
+
if (plan.kind === 'single') {
|
|
37682
|
+
// The original one-hop request, unchanged (AB#7723 keeps old boards byte-identical).
|
|
37683
|
+
const targets = await firstValueFrom(this.dataService.fetchAssociationTargets(rtId, selector.ckTypeId, plan.hop.targetCkTypeId, plan.hop.roleId,
|
|
37684
|
+
// Parent → children via System/ParentChild is an INBOUND hop (the child
|
|
37685
|
+
// owns the association to its parent), so 'in' is the default.
|
|
37686
|
+
plan.hop.direction));
|
|
37687
|
+
scopeRtIds = isCurrent()
|
|
37688
|
+
? (targets ?? []).map(t => String(t.rtId)).filter(id => id.length > 0)
|
|
37689
|
+
: null;
|
|
37690
|
+
}
|
|
37691
|
+
else {
|
|
37692
|
+
scopeRtIds = await resolveScopePath(rtId, selector.ckTypeId, plan.hops, (sources, sourceCkTypeId, hop) => firstValueFrom(this.dataService.fetchAssociationTargetRtIds(sources, sourceCkTypeId, hop.targetCkTypeId, hop.roleId, hop.direction)), isCurrent);
|
|
37693
|
+
}
|
|
37694
|
+
if (scopeRtIds === null || !isCurrent())
|
|
37695
|
+
return;
|
|
37696
|
+
// An empty list is a resolved EMPTY scope: bound widgets show no data and
|
|
37697
|
+
// send no query — never the persisted scope, which would show rows of
|
|
37698
|
+
// other entities (AB#7721).
|
|
37699
|
+
this.stateService.setEntitySelectorRtIds(selector.id, scopeRtIds);
|
|
35467
37700
|
}
|
|
35468
37701
|
catch (error) {
|
|
37702
|
+
if (!isCurrent())
|
|
37703
|
+
return;
|
|
37704
|
+
// Logged once per failed resolution; the view shows a notice and bound
|
|
37705
|
+
// widgets show no data (AB#7721).
|
|
35469
37706
|
console.error(`Error resolving child scope for selector '${selector.id}':`, error);
|
|
35470
|
-
|
|
35471
|
-
|
|
35472
|
-
|
|
37707
|
+
this.stateService.setEntitySelectorScopeFailed(selector.id);
|
|
37708
|
+
}
|
|
37709
|
+
}
|
|
37710
|
+
/**
|
|
37711
|
+
* Starts the scope resolution of a new pick: marks the scope pending and
|
|
37712
|
+
* returns the sequence number that identifies this resolution (AB#7721).
|
|
37713
|
+
*/
|
|
37714
|
+
beginScopeResolution(selectorId) {
|
|
37715
|
+
const seq = (this.scopeRequestSeq.get(selectorId) ?? 0) + 1;
|
|
37716
|
+
this.scopeRequestSeq.set(selectorId, seq);
|
|
37717
|
+
this.stateService.setEntitySelectorScopePending(selectorId);
|
|
37718
|
+
return seq;
|
|
37719
|
+
}
|
|
37720
|
+
/** Marks the scope failed — unless a newer pick has started resolving meanwhile. */
|
|
37721
|
+
failScopeResolution(selectorId, seq) {
|
|
37722
|
+
if (this.scopeRequestSeq.get(selectorId) === seq) {
|
|
37723
|
+
this.stateService.setEntitySelectorScopeFailed(selectorId);
|
|
35473
37724
|
}
|
|
35474
37725
|
}
|
|
35475
37726
|
/**
|
|
@@ -35480,6 +37731,9 @@ class MeshBoardViewComponent {
|
|
|
35480
37731
|
const selectors = this.stateService.getEntitySelectors();
|
|
35481
37732
|
const params = {};
|
|
35482
37733
|
for (const selector of selectors) {
|
|
37734
|
+
// Followers are derived from their leader and never serialized (AB#7722).
|
|
37735
|
+
if (isFollowerSelector(selector))
|
|
37736
|
+
continue;
|
|
35483
37737
|
params[`es_${selector.id}`] = selector.selectedRtId ?? null;
|
|
35484
37738
|
}
|
|
35485
37739
|
// Same single-URL serialization as writeTimeFilterToUrl: no relative
|
|
@@ -35530,10 +37784,10 @@ class MeshBoardViewComponent {
|
|
|
35530
37784
|
}
|
|
35531
37785
|
}
|
|
35532
37786
|
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.5", ngImport: i0, type: MeshBoardViewComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
35533
|
-
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.1.5", type: MeshBoardViewComponent, isStandalone: true, selector: "mm-meshboard-view", inputs: { headerMode: { classPropertyName: "headerMode", publicName: "headerMode", isSignal: true, isRequired: false, transformFunction: null }, chrome: { classPropertyName: "chrome", publicName: "chrome", isSignal: true, isRequired: false, transformFunction: null } }, providers: [
|
|
37787
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.1.5", type: MeshBoardViewComponent, isStandalone: true, selector: "mm-meshboard-view", inputs: { headerMode: { classPropertyName: "headerMode", publicName: "headerMode", isSignal: true, isRequired: false, transformFunction: null }, chrome: { classPropertyName: "chrome", publicName: "chrome", isSignal: true, isRequired: false, transformFunction: null }, timeRange: { classPropertyName: "timeRange", publicName: "timeRange", isSignal: true, isRequired: false, transformFunction: null }, timeFilterControl: { classPropertyName: "timeFilterControl", publicName: "timeFilterControl", isSignal: true, isRequired: false, transformFunction: null } }, providers: [
|
|
35534
37788
|
{ provide: HAS_UNSAVED_CHANGES, useExisting: MeshBoardViewComponent },
|
|
35535
37789
|
AutoRefreshTimerService
|
|
35536
|
-
], viewQueries: [{ propertyName: "tileLayout", first: true, predicate: TileLayoutComponent, descendants: true }], hostDirectives: [{ directive: i1$8.UnsavedChangesDirective }], ngImport: i0, template: "<div class=\"meshboard-view\" [class.chrome-plain]=\"effectiveChrome() === 'plain'\">\n @if (isLoading()) {\n <div class=\"loading-container\">\n <div class=\"k-loading-mask\">\n <span class=\"k-loading-text\">Loading MeshBoard...</span>\n <div class=\"k-loading-image\"></div>\n <div class=\"k-loading-color\"></div>\n </div>\n </div>\n } @else if (isModelAvailable() === false) {\n <div class=\"model-unavailable\">\n <div class=\"message-card\">\n <kendo-svg-icon [icon]=\"xIcon\" size=\"xlarge\"></kendo-svg-icon>\n <h2>MeshBoard Not Available</h2>\n <p>The MeshBoard feature requires the CK model 'System.UI' version 1.0.1 or higher.</p>\n <p>Please install the required model in your tenant to use this feature.</p>\n </div>\n </div>\n } @else if (notFoundError()) {\n <div class=\"model-unavailable\">\n <div class=\"message-card\">\n <kendo-svg-icon [icon]=\"xIcon\" size=\"xlarge\"></kendo-svg-icon>\n <h2>MeshBoard Not Found</h2>\n <p>{{ notFoundError() }}</p>\n <p>To create a MeshBoard with a Well-Known Name:</p>\n <ol>\n <li>Go to the <a [routerLink]=\"meshBoardPageLink()\">MeshBoard</a> page</li>\n <li>Open Settings</li>\n <li>Set the \"Well-Known Name\" field</li>\n <li>Save the MeshBoard</li>\n </ol>\n </div>\n </div>\n } @else if (isInitialized()) {\n <!-- Toolbar (headerMode: full = name + description + controls, compact = controls only, none = no row) -->\n @if (effectiveHeaderMode() !== 'none') {\n <div class=\"meshboard-toolbar\" [class.compact]=\"effectiveHeaderMode() === 'compact'\">\n @if (effectiveHeaderMode() === 'full') {\n <div class=\"toolbar-left\">\n <h2 class=\"meshboard-title\">{{ config().name }}</h2>\n @if (config().description) {\n <span class=\"meshboard-description\">{{ config().description }}</span>\n }\n </div>\n }\n\n <!-- Time Filter (center) -->\n @if (isTimeFilterEnabled()) {\n <div class=\"toolbar-center\">\n <mm-time-range-picker\n [config]=\"timeFilterConfig()?.pickerConfig ?? {}\"\n [labels]=\"timeRangeLabels()\"\n [initialSelection]=\"initialTimeSelection()\"\n (rangeChange)=\"onTimeRangeChange($event)\"\n (selectionChange)=\"onTimeSelectionChange($event)\">\n </mm-time-range-picker>\n @if (canResetTimeFilter()) {\n <button\n kendoButton\n [svgIcon]=\"undoIcon\"\n (click)=\"resetTimeFilterToDefault()\"\n title=\"Reset to default time filter\"\n aria-label=\"Reset to default time filter\"\n fillMode=\"flat\"\n size=\"small\">\n </button>\n }\n </div>\n }\n\n <!-- Entity Selectors -->\n @if (hasEntitySelectors()) {\n @if (isTimeFilterEnabled()) {\n <div class=\"toolbar-separator\"></div>\n }\n <div class=\"toolbar-entity-selectors\">\n <mm-entity-selector-toolbar\n [entitySelectors]=\"entitySelectorsConfig()\"\n (entitySelected)=\"onEntitySelectorSelected($event)\"\n (entityCleared)=\"onEntitySelectorCleared($event)\">\n </mm-entity-selector-toolbar>\n </div>\n }\n\n @if (isTimeFilterEnabled() || hasEntitySelectors()) {\n <div class=\"toolbar-separator\"></div>\n }\n <div class=\"toolbar-right mm-toolbar-actions\">\n @if (!isReadonly()) {\n <!-- Manager Button (icon only) -->\n <button\n kendoButton\n [svgIcon]=\"gridLayoutIcon\"\n (click)=\"openManager()\"\n title=\"Manage MeshBoards\"\n aria-label=\"Manage MeshBoards\"\n fillMode=\"flat\">\n </button>\n\n <!-- Settings Button (icon only) -->\n <button\n kendoButton\n [svgIcon]=\"gearIcon\"\n (click)=\"openSettings()\"\n title=\"MeshBoard Settings\"\n aria-label=\"MeshBoard Settings\"\n fillMode=\"flat\">\n </button>\n }\n\n <!-- Refresh Button (icon only) -->\n <button\n kendoButton\n [svgIcon]=\"arrowRotateCwIcon\"\n (click)=\"refresh()\"\n title=\"Refresh All Widgets\"\n aria-label=\"Refresh All Widgets\"\n fillMode=\"flat\">\n </button>\n\n @if (!isReadonly()) {\n <!-- Edit Mode Toggle (icon only) -->\n @if (!isEditMode()) {\n <button\n kendoButton\n [svgIcon]=\"pencilIcon\"\n (click)=\"toggleEditMode()\"\n title=\"Enter Edit Mode\"\n aria-label=\"Enter Edit Mode\"\n fillMode=\"flat\">\n </button>\n } @else {\n <button\n kendoButton\n [svgIcon]=\"xIcon\"\n (click)=\"cancelEdit()\"\n title=\"Cancel Edit Mode\"\n aria-label=\"Cancel Edit Mode\"\n fillMode=\"flat\">\n </button>\n }\n\n <!-- Add Widget Button (with text) -->\n <button\n kendoButton\n [svgIcon]=\"plusIcon\"\n (click)=\"openAddWidget()\"\n title=\"Add Widget\"\n themeColor=\"primary\">\n Add Widget\n </button>\n\n <!-- Save Button (with text) -->\n @if (canSave()) {\n <button\n kendoButton\n [svgIcon]=\"saveIcon\"\n (click)=\"save()\"\n [disabled]=\"isSaving()\"\n title=\"Save MeshBoard\"\n themeColor=\"primary\">\n {{ isSaving() ? 'Saving...' : 'Save' }}\n </button>\n }\n }\n </div>\n </div>\n }\n\n <!-- Entity Selector Hint -->\n @if (unselectedToolbarSelectors().length > 0) {\n <div class=\"entity-selector-hint\">\n <kendo-svg-icon [icon]=\"infoCircleIcon\" size=\"medium\"></kendo-svg-icon>\n <span>\n Please select\n @for (selector of unselectedToolbarSelectors(); track selector.id; let last = $last) {\n <strong>{{ selector.label }}</strong>@if (!last) {, }\n }\n to display dashboard data.\n </span>\n </div>\n }\n\n <!-- Variable Resolution Errors -->\n @if (variableResolutionErrors().length > 0) {\n <div class=\"variable-resolution-errors\">\n <strong>Variable resolution errors:</strong>\n @for (error of variableResolutionErrors(); track error.variableName) {\n <span class=\"error-item\">{{ error.variableName }}: {{ error.message }}</span>\n }\n </div>\n }\n\n <!-- Banner Zone: widgets placed in the banner stack above the grid -->\n @if (isEditMode()) {\n <div class=\"zone-label banner-zone-label\">Banner Zone</div>\n }\n @for (widget of bannerWidgets(); track trackByWidgetId($index, widget); let first = $first; let last = $last) {\n <div class=\"banner-zone-item\" [class.banner-edit]=\"isEditMode()\">\n @if (isEditMode()) {\n <div class=\"banner-edit-header\">\n <span class=\"widget-title\">{{ widget.title }}</span>\n <div class=\"widget-actions\">\n <button\n kendoButton\n [svgIcon]=\"arrowUpIcon\"\n (click)=\"moveBannerUp(widget.id)\"\n fillMode=\"flat\"\n size=\"small\"\n [disabled]=\"first\"\n title=\"Move up\"\n [attr.aria-label]=\"'Move up ' + widget.title\">\n </button>\n <button\n kendoButton\n [svgIcon]=\"arrowDownIcon\"\n (click)=\"moveBannerDown(widget.id)\"\n fillMode=\"flat\"\n size=\"small\"\n [disabled]=\"last\"\n title=\"Move down\"\n [attr.aria-label]=\"'Move down ' + widget.title\">\n </button>\n @if (supportsConfiguration(widget)) {\n <button\n kendoButton\n [svgIcon]=\"linkIcon\"\n (click)=\"openWidgetConfig(widget)\"\n fillMode=\"flat\"\n size=\"small\"\n class=\"config-widget-btn\"\n [class.needs-config]=\"isWidgetUnconfigured(widget)\"\n [title]=\"isWidgetUnconfigured(widget) ? 'Configure data source (required)' : 'Configure data source'\"\n [attr.aria-label]=\"(isWidgetUnconfigured(widget) ? 'Configure data source (required) ' : 'Configure data source ') + widget.title\">\n </button>\n }\n <button\n kendoButton\n [svgIcon]=\"gearIcon\"\n (click)=\"openEditWidgetDialog(widget)\"\n fillMode=\"flat\"\n size=\"small\"\n class=\"edit-widget-btn\"\n title=\"Edit widget\"\n [attr.aria-label]=\"'Edit widget ' + widget.title\">\n </button>\n <button\n kendoButton\n [svgIcon]=\"trashIcon\"\n (click)=\"removeWidget(widget.id)\"\n fillMode=\"flat\"\n themeColor=\"error\"\n size=\"small\"\n class=\"remove-widget-btn\"\n title=\"Delete widget\"\n [attr.aria-label]=\"'Delete widget ' + widget.title\">\n </button>\n </div>\n </div>\n }\n @if (getWidgetComponentType(widget.type); as componentType) {\n <ng-container *ngComponentOutlet=\"componentType; inputs: { config: widget }\"></ng-container>\n }\n </div>\n }\n\n <!-- MeshBoard Grid -->\n @if (isEditMode()) {\n <div class=\"zone-label grid-zone-label\">Grid Zone</div>\n }\n @if (hasGridWidgets()) {\n <kendo-tilelayout\n [columns]=\"displayColumns()\"\n [autoFlow]=\"displayAutoFlow()\"\n [rowHeight]=\"config().rowHeight\"\n [gap]=\"config().gap\"\n [class.has-banners]=\"bannerWidgets().length > 0 && !isEditMode()\"\n [resizable]=\"canEditLayout()\"\n [reorderable]=\"canEditLayout()\"\n (reorder)=\"onReorder($event)\"\n (resize)=\"onResize($event)\">\n @for (placement of displayGridWidgets(); track placement.widget.id) {\n @let widget = placement.widget;\n <kendo-tilelayout-item\n [col]=\"$any(placement.col)\"\n [row]=\"$any(placement.row)\"\n [colSpan]=\"placement.colSpan\"\n [rowSpan]=\"placement.rowSpan\">\n <kendo-tilelayout-item-header>\n <div class=\"widget-header\"\n [class.unconfigured]=\"isWidgetUnconfigured(widget)\"\n [class.chromeless-header]=\"widget.chromeless && !isEditMode()\">\n @if (!(widget.chromeless && !isEditMode())) {\n <span class=\"widget-title\">{{ widget.title }}</span>\n @if (isWidgetUnconfigured(widget)) {\n <span class=\"unconfigured-badge\" title=\"Widget needs configuration\">!</span>\n }\n }\n @if (isEditMode()) {\n <div class=\"widget-actions\" (pointerdown)=\"$event.stopPropagation()\">\n @if (supportsConfiguration(widget)) {\n <button\n kendoButton\n [svgIcon]=\"linkIcon\"\n (click)=\"openWidgetConfig(widget)\"\n fillMode=\"flat\"\n size=\"small\"\n class=\"config-widget-btn\"\n [class.needs-config]=\"isWidgetUnconfigured(widget)\"\n [title]=\"isWidgetUnconfigured(widget) ? 'Configure data source (required)' : 'Configure data source'\"\n [attr.aria-label]=\"(isWidgetUnconfigured(widget) ? 'Configure data source (required) ' : 'Configure data source ') + widget.title\">\n </button>\n }\n <button\n kendoButton\n [svgIcon]=\"gearIcon\"\n (click)=\"openEditWidgetDialog(widget)\"\n fillMode=\"flat\"\n size=\"small\"\n class=\"edit-widget-btn\"\n title=\"Edit position\"\n [attr.aria-label]=\"'Edit position ' + widget.title\">\n </button>\n <button\n kendoButton\n [svgIcon]=\"copyIcon\"\n (click)=\"duplicateWidget(widget)\"\n fillMode=\"flat\"\n size=\"small\"\n class=\"duplicate-widget-btn\"\n title=\"Duplicate widget\"\n [attr.aria-label]=\"'Duplicate widget ' + widget.title\">\n </button>\n <button\n kendoButton\n [svgIcon]=\"trashIcon\"\n (click)=\"removeWidget(widget.id)\"\n fillMode=\"flat\"\n themeColor=\"error\"\n size=\"small\"\n class=\"remove-widget-btn\"\n title=\"Delete widget\"\n [attr.aria-label]=\"'Delete widget ' + widget.title\">\n </button>\n </div>\n }\n </div>\n </kendo-tilelayout-item-header>\n <kendo-tilelayout-item-body>\n @if (getWidgetComponentType(widget.type); as componentType) {\n <ng-container *ngComponentOutlet=\"componentType; inputs: { config: widget }\"></ng-container>\n } @else {\n <div class=\"widget-error\">\n <p>Unknown widget type: {{ widget.type }}</p>\n </div>\n }\n </kendo-tilelayout-item-body>\n </kendo-tilelayout-item>\n }\n </kendo-tilelayout>\n } @else {\n <mm-meshboard-empty-state [readonly]=\"isReadonly()\" (addWidget)=\"openAddWidget()\"></mm-meshboard-empty-state>\n }\n\n <!-- Edit Widget Dialog -->\n @if (showEditWidgetDialog && editingWidget) {\n <mm-edit-widget-dialog\n [widget]=\"editingWidget\"\n [widgets]=\"config().widgets\"\n [maxColumns]=\"config().columns\"\n [gridService]=\"gridService\"\n (save)=\"onEditWidgetSave($event)\"\n (cancelled)=\"closeEditWidgetDialog()\">\n </mm-edit-widget-dialog>\n }\n }\n</div>\n", styles: [":host{display:flex;flex-direction:column;flex:1;min-height:0;height:100%;width:100%}.meshboard-view{display:flex;flex-direction:column;flex:1;min-height:0;width:100%;background-color:var(--kendo-color-surface, #f5f5f5)}.loading-container{display:flex;align-items:center;justify-content:center;height:100%;width:100%}.entity-selector-hint{display:flex;align-items:center;gap:8px;padding:10px 16px;margin-bottom:8px;background:color-mix(in srgb,var(--kendo-color-info, #2196f3) 10%,var(--kendo-color-surface-alt, #fafafa));border:1px solid color-mix(in srgb,var(--kendo-color-info, #2196f3) 40%,transparent);border-radius:4px;font-size:13px;color:var(--kendo-color-on-app-surface, #424242)}.entity-selector-hint strong{color:var(--kendo-color-info, #2196f3)}.variable-resolution-errors{display:flex;flex-wrap:wrap;align-items:center;gap:8px;padding:8px 16px;margin-bottom:8px;background:color-mix(in srgb,var(--kendo-color-error, #f44336) 10%,var(--kendo-color-surface-alt, #fafafa));border:1px solid var(--kendo-color-error, #f44336);border-radius:4px;font-size:13px;color:var(--kendo-color-on-app-surface, #424242)}.variable-resolution-errors strong{color:var(--kendo-color-error, #f44336)}.variable-resolution-errors .error-item{padding:2px 8px;background:color-mix(in srgb,var(--kendo-color-error, #f44336) 8%,transparent);border-radius:3px;font-family:var(--theme-font-mono);font-size:12px}.model-unavailable{display:flex;align-items:center;justify-content:center;height:100%;width:100%;padding:2rem;overflow:hidden;box-sizing:border-box}.model-unavailable .message-card{text-align:center;max-width:500px;padding:2rem;background:var(--kendo-color-surface-alt, #fafafa);border:1px solid var(--kendo-color-border, #e0e0e0);border-radius:8px;overflow:hidden}.model-unavailable .message-card kendo-svg-icon{color:var(--kendo-color-warning, #ff9800);margin-bottom:1rem}.model-unavailable .message-card h2{margin:1rem 0;color:var(--kendo-color-on-app-surface, #424242)}.model-unavailable .message-card p{margin:.5rem 0;color:var(--kendo-color-subtle, #757575);line-height:1.5}.model-unavailable .message-card ol{text-align:left;margin:1rem 0;padding-left:1.5rem;color:var(--kendo-color-subtle, #757575);line-height:1.8}.model-unavailable .message-card ol li{margin:.25rem 0}.model-unavailable .message-card ol a{color:var(--kendo-color-primary, #3f51b5);text-decoration:none}.model-unavailable .message-card ol a:hover{text-decoration:underline}.meshboard-toolbar{display:flex;justify-content:space-between;align-items:flex-end;padding:1rem 1.5rem;background-color:var(--kendo-color-surface-alt, white);border-bottom:1px solid var(--kendo-color-border, #e0e0e0);gap:1rem;flex-shrink:0}.meshboard-toolbar .toolbar-left{display:flex;align-items:baseline;gap:1rem;flex:1;min-width:0;align-self:center}.meshboard-toolbar .toolbar-left .meshboard-title{margin:0;font-size:1.5rem;font-weight:500;color:var(--kendo-color-on-app-surface, #212121);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.meshboard-toolbar .toolbar-left .meshboard-description{color:var(--kendo-color-subtle, #757575);font-size:.875rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.meshboard-toolbar .toolbar-center{display:flex;justify-content:center;align-items:flex-end;flex-shrink:0}.meshboard-toolbar .toolbar-center mm-time-range-picker ::ng-deep .time-range-picker{gap:.5rem}.meshboard-toolbar .toolbar-separator{width:1px;align-self:stretch;background-color:var(--kendo-color-border, #e0e0e0);flex-shrink:0}.meshboard-toolbar .toolbar-entity-selectors{display:flex;align-items:flex-end;flex-shrink:0}.meshboard-toolbar .toolbar-right{display:flex;gap:.5rem;align-items:center;flex-shrink:0}.meshboard-toolbar.compact{justify-content:flex-end;align-items:center;padding:.25rem 1.5rem 0;background-color:transparent;border-bottom:none}kendo-tilelayout{padding:1.5rem;overflow:auto;flex:1;min-height:0}.widget-header{display:flex;justify-content:space-between;align-items:center;width:100%;gap:.5rem}.widget-header.unconfigured{background-color:#ff98001a}.widget-header .widget-title{flex:1;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.widget-header .unconfigured-badge{display:inline-flex;align-items:center;justify-content:center;width:18px;height:18px;border-radius:50%;background-color:var(--kendo-color-warning, #ff9800);color:#fff;font-size:12px;font-weight:700;flex-shrink:0}.widget-header .widget-actions{display:flex;align-items:center;gap:.25rem;flex-shrink:0}.widget-header .config-widget-btn,.widget-header .edit-widget-btn,.widget-header .remove-widget-btn{opacity:.6;transition:opacity .2s}.widget-header .config-widget-btn:hover,.widget-header .edit-widget-btn:hover,.widget-header .remove-widget-btn:hover{opacity:1}.widget-header .config-widget-btn.needs-config{color:var(--kendo-color-warning, #ff9800);opacity:1}.widget-header .remove-widget-btn{color:var(--kendo-color-error, #f44336)}.widget-error{display:flex;align-items:center;justify-content:center;height:100%;padding:1rem;color:var(--kendo-color-error, #f44336);text-align:center}.meshboard-view.edit-mode kendo-tilelayout-item:hover{box-shadow:0 0 0 2px var(--kendo-color-primary, #3f51b5)}@media(max-width:768px){.meshboard-toolbar{flex-direction:column;align-items:stretch;gap:.75rem}.meshboard-toolbar .toolbar-left{flex-direction:column;align-items:flex-start;gap:.25rem}.meshboard-toolbar .toolbar-right{flex-wrap:wrap;justify-content:flex-end}.meshboard-toolbar.compact{align-items:flex-end;padding:.25rem 1rem 0}kendo-tilelayout{padding:1rem}}:host ::ng-deep kendo-tilelayout.has-banners{padding-top:0!important}:host ::ng-deep kendo-tilelayout{padding-bottom:1.5rem!important}.widget-header.chromeless-header{display:none}:host ::ng-deep .k-tilelayout-item-header:has(.chromeless-header){display:none}:host ::ng-deep kendo-tilelayout-item:has(.chromeless-header).k-tilelayout-item,:host ::ng-deep kendo-tilelayout-item:has(.chromeless-header).k-card{border:none;box-shadow:none;background:transparent}:host ::ng-deep kendo-tilelayout-item:has(kendo-dialog),:host ::ng-deep kendo-tilelayout-item:has(kendo-window){z-index:9999}.zone-label{font-size:.65rem;opacity:.4;padding:6px 1rem 2px;font-weight:600}.banner-zone-item{margin:0;padding:8px 1rem}.banner-zone-item.banner-edit{border:1px dashed var(--kendo-color-border, rgba(255, 255, 255, .2));border-radius:4px;margin:0 1rem 12px;padding:4px 0}.banner-edit-header{display:flex;align-items:center;justify-content:space-between;padding:4px 8px;opacity:.7}.banner-edit-header .widget-title{font-size:.75rem}.banner-edit-header .widget-actions{display:flex;gap:2px}.meshboard-view.chrome-plain{background:transparent!important}.meshboard-view.chrome-plain .meshboard-toolbar{padding:0 0 var(--theme-space-3, 12px);background:transparent!important;border-bottom:none!important}.meshboard-view.chrome-plain kendo-tilelayout{padding:0!important}.meshboard-view.chrome-plain .banner-zone-item:not(.banner-edit){padding-left:0;padding-right:0}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "directive", type: i1$3.NgComponentOutlet, selector: "[ngComponentOutlet]", inputs: ["ngComponentOutlet", "ngComponentOutletInputs", "ngComponentOutletInjector", "ngComponentOutletEnvironmentInjector", "ngComponentOutletContent", "ngComponentOutletNgModule"], exportAs: ["ngComponentOutlet"] }, { kind: "ngmodule", type: RouterModule }, { kind: "directive", type: i3$1.RouterLink, selector: "[routerLink]", inputs: ["target", "queryParams", "fragment", "queryParamsHandling", "state", "info", "relativeTo", "preserveFragment", "skipLocationChange", "replaceUrl", "browserUrl", "routerLink"] }, { kind: "ngmodule", type: TileLayoutModule }, { kind: "component", type: i5.TileLayoutComponent, selector: "kendo-tilelayout", inputs: ["columns", "columnWidth", "gap", "reorderable", "resizable", "rowHeight", "autoFlow", "navigable"], outputs: ["reorder", "resize"] }, { kind: "component", type: i5.TileLayoutItemBodyComponent, selector: "kendo-tilelayout-item-body" }, { kind: "component", type: i5.TileLayoutItemComponent, selector: "kendo-tilelayout-item", inputs: ["title", "rowSpan", "colSpan", "order", "col", "row", "reorderable", "resizable"] }, { kind: "component", type: i5.TileLayoutItemHeaderComponent, selector: "kendo-tilelayout-item-header" }, { kind: "ngmodule", type: ButtonModule }, { kind: "component", type: i2.ButtonComponent, selector: "button[kendoButton]", inputs: ["arrowIcon", "toggleable", "togglable", "selected", "tabIndex", "imageUrl", "iconPosition", "iconClass", "icon", "disabled", "size", "rounded", "fillMode", "themeColor", "svgIcon"], outputs: ["selectedChange", "click"], exportAs: ["kendoButton"] }, { kind: "ngmodule", type: DialogModule }, { kind: "ngmodule", type: SVGIconModule }, { kind: "component", type: i1$4.SVGIconComponent, selector: "kendo-svg-icon, kendo-svgicon", inputs: ["icon", "variant"], exportAs: ["kendoSVGIcon"] }, { kind: "component", type: EditWidgetDialogComponent, selector: "mm-edit-widget-dialog", inputs: ["widget", "widgets", "maxColumns", "gridService"], outputs: ["save", "cancelled"] }, { kind: "component", type: TimeRangePickerComponent, selector: "mm-time-range-picker", inputs: ["config", "labels", "initialSelection"], outputs: ["rangeChange", "rangeChangeISO", "selectionChange"] }, { kind: "component", type: EntitySelectorToolbarComponent, selector: "mm-entity-selector-toolbar", inputs: ["entitySelectors"], outputs: ["entitySelected", "entityCleared"] }, { kind: "component", type: MeshBoardEmptyStateComponent, selector: "mm-meshboard-empty-state", inputs: ["readonly"], outputs: ["addWidget"] }], changeDetection: i0.ChangeDetectionStrategy.Eager });
|
|
37790
|
+
], viewQueries: [{ propertyName: "tileLayout", first: true, predicate: TileLayoutComponent, descendants: true }], hostDirectives: [{ directive: i1$8.UnsavedChangesDirective }], ngImport: i0, template: "<div class=\"meshboard-view\" [class.chrome-plain]=\"effectiveChrome() === 'plain'\">\n @if (isLoading()) {\n <div class=\"loading-container\">\n <div class=\"k-loading-mask\">\n <span class=\"k-loading-text\">Loading MeshBoard...</span>\n <div class=\"k-loading-image\"></div>\n <div class=\"k-loading-color\"></div>\n </div>\n </div>\n } @else if (isModelAvailable() === false) {\n <div class=\"model-unavailable\">\n <div class=\"message-card\">\n <kendo-svg-icon [icon]=\"xIcon\" size=\"xlarge\"></kendo-svg-icon>\n <h2>MeshBoard Not Available</h2>\n <p>The MeshBoard feature requires the CK model 'System.UI' version 1.0.1 or higher.</p>\n <p>Please install the required model in your tenant to use this feature.</p>\n </div>\n </div>\n } @else if (notFoundError()) {\n <div class=\"model-unavailable\">\n <div class=\"message-card\">\n <kendo-svg-icon [icon]=\"xIcon\" size=\"xlarge\"></kendo-svg-icon>\n <h2>MeshBoard Not Found</h2>\n <p>{{ notFoundError() }}</p>\n <p>To create a MeshBoard with a Well-Known Name:</p>\n <ol>\n <li>Go to the <a [routerLink]=\"meshBoardPageLink()\">MeshBoard</a> page</li>\n <li>Open Settings</li>\n <li>Set the \"Well-Known Name\" field</li>\n <li>Save the MeshBoard</li>\n </ol>\n </div>\n </div>\n } @else if (isInitialized()) {\n <!-- Toolbar (headerMode: full = name + description + controls, compact = controls only, none = no row) -->\n @if (effectiveHeaderMode() !== 'none') {\n <div class=\"meshboard-toolbar\" [class.compact]=\"effectiveHeaderMode() === 'compact'\">\n @if (effectiveHeaderMode() === 'full') {\n <div class=\"toolbar-left\">\n <h2 class=\"meshboard-title\">{{ config().name }}</h2>\n @if (config().description) {\n <span class=\"meshboard-description\">{{ config().description }}</span>\n }\n </div>\n }\n\n <!-- Time Filter (center); hidden when the host owns the period (AB#7582) -->\n @if (showTimeRangePicker()) {\n <div class=\"toolbar-center\">\n <mm-time-range-picker\n [config]=\"timeFilterConfig()?.pickerConfig ?? {}\"\n [labels]=\"timeRangeLabels()\"\n [initialSelection]=\"initialTimeSelection()\"\n (rangeChange)=\"onTimeRangeChange($event)\"\n (selectionChange)=\"onTimeSelectionChange($event)\">\n </mm-time-range-picker>\n @if (canResetTimeFilter()) {\n <button\n kendoButton\n [svgIcon]=\"undoIcon\"\n (click)=\"resetTimeFilterToDefault()\"\n title=\"Reset to default time filter\"\n aria-label=\"Reset to default time filter\"\n fillMode=\"flat\"\n size=\"small\">\n </button>\n }\n </div>\n }\n\n <!-- Entity Selectors -->\n @if (hasEntitySelectors()) {\n @if (showTimeRangePicker()) {\n <div class=\"toolbar-separator\"></div>\n }\n <div class=\"toolbar-entity-selectors\">\n <mm-entity-selector-toolbar\n [entitySelectors]=\"entitySelectorsConfig()\"\n [variables]=\"boardVariables()\"\n (entitySelected)=\"onEntitySelectorSelected($event)\"\n (entityCleared)=\"onEntitySelectorCleared($event)\">\n </mm-entity-selector-toolbar>\n </div>\n }\n\n @if (showTimeRangePicker() || hasEntitySelectors()) {\n <div class=\"toolbar-separator\"></div>\n }\n <div class=\"toolbar-right mm-toolbar-actions\">\n @if (!isReadonly()) {\n <!-- Manager Button (icon only) -->\n <button\n kendoButton\n [svgIcon]=\"gridLayoutIcon\"\n (click)=\"openManager()\"\n title=\"Manage MeshBoards\"\n aria-label=\"Manage MeshBoards\"\n fillMode=\"flat\">\n </button>\n\n <!-- Settings Button (icon only) -->\n <button\n kendoButton\n [svgIcon]=\"gearIcon\"\n (click)=\"openSettings()\"\n title=\"MeshBoard Settings\"\n aria-label=\"MeshBoard Settings\"\n fillMode=\"flat\">\n </button>\n }\n\n <!-- Refresh Button (icon only) -->\n <button\n kendoButton\n [svgIcon]=\"arrowRotateCwIcon\"\n (click)=\"refresh()\"\n title=\"Refresh All Widgets\"\n aria-label=\"Refresh All Widgets\"\n fillMode=\"flat\">\n </button>\n\n @if (!isReadonly()) {\n <!-- Edit Mode Toggle (icon only) -->\n @if (!isEditMode()) {\n <button\n kendoButton\n [svgIcon]=\"pencilIcon\"\n (click)=\"toggleEditMode()\"\n title=\"Enter Edit Mode\"\n aria-label=\"Enter Edit Mode\"\n fillMode=\"flat\">\n </button>\n } @else {\n <button\n kendoButton\n [svgIcon]=\"xIcon\"\n (click)=\"cancelEdit()\"\n title=\"Cancel Edit Mode\"\n aria-label=\"Cancel Edit Mode\"\n fillMode=\"flat\">\n </button>\n }\n\n <!-- Add Widget Button (with text) -->\n <button\n kendoButton\n [svgIcon]=\"plusIcon\"\n (click)=\"openAddWidget()\"\n title=\"Add Widget\"\n themeColor=\"primary\">\n Add Widget\n </button>\n\n <!-- Save Button (with text) -->\n @if (canSave()) {\n <button\n kendoButton\n [svgIcon]=\"saveIcon\"\n (click)=\"save()\"\n [disabled]=\"isSaving()\"\n title=\"Save MeshBoard\"\n themeColor=\"primary\">\n {{ isSaving() ? 'Saving...' : 'Save' }}\n </button>\n }\n }\n </div>\n </div>\n }\n\n <!-- Entity Selector Hint -->\n @if (unselectedToolbarSelectors().length > 0) {\n <div class=\"entity-selector-hint\">\n <kendo-svg-icon [icon]=\"infoCircleIcon\" size=\"medium\"></kendo-svg-icon>\n <span>\n Please select\n @for (selector of unselectedToolbarSelectors(); track selector.id; let last = $last) {\n <strong>{{ selector.label }}</strong>@if (!last) {, }\n }\n to display dashboard data.\n </span>\n </div>\n }\n\n <!-- Variable Resolution Errors -->\n @if (variableResolutionErrors().length > 0) {\n <div class=\"variable-resolution-errors\">\n <strong>Variable resolution errors:</strong>\n @for (error of variableResolutionErrors(); track error.variableName) {\n <span class=\"error-item\">{{ error.variableName }}: {{ error.message }}</span>\n }\n </div>\n }\n\n <!-- Entity selector scopes that could not be resolved (AB#7721) -->\n @if (scopeResolutionErrors().length > 0) {\n <div class=\"variable-resolution-errors scope-resolution-errors\" role=\"status\">\n @for (error of scopeResolutionErrors(); track error.selectorId) {\n <span class=\"error-item\">{{ error.message }}</span>\n }\n </div>\n }\n\n <!-- Banner Zone: widgets placed in the banner stack above the grid -->\n @if (isEditMode()) {\n <div class=\"zone-label banner-zone-label\">Banner Zone</div>\n }\n @for (widget of bannerWidgets(); track trackByWidgetId($index, widget); let first = $first; let last = $last) {\n <div class=\"banner-zone-item\" [class.banner-edit]=\"isEditMode()\">\n @if (isEditMode()) {\n <div class=\"banner-edit-header\">\n <span class=\"widget-title\">{{ widget.title }}</span>\n <div class=\"widget-actions\">\n <button\n kendoButton\n [svgIcon]=\"arrowUpIcon\"\n (click)=\"moveBannerUp(widget.id)\"\n fillMode=\"flat\"\n size=\"small\"\n [disabled]=\"first\"\n title=\"Move up\"\n [attr.aria-label]=\"'Move up ' + widget.title\">\n </button>\n <button\n kendoButton\n [svgIcon]=\"arrowDownIcon\"\n (click)=\"moveBannerDown(widget.id)\"\n fillMode=\"flat\"\n size=\"small\"\n [disabled]=\"last\"\n title=\"Move down\"\n [attr.aria-label]=\"'Move down ' + widget.title\">\n </button>\n @if (supportsConfiguration(widget)) {\n <button\n kendoButton\n [svgIcon]=\"linkIcon\"\n (click)=\"openWidgetConfig(widget)\"\n fillMode=\"flat\"\n size=\"small\"\n class=\"config-widget-btn\"\n [class.needs-config]=\"isWidgetUnconfigured(widget)\"\n [title]=\"isWidgetUnconfigured(widget) ? 'Configure data source (required)' : 'Configure data source'\"\n [attr.aria-label]=\"(isWidgetUnconfigured(widget) ? 'Configure data source (required) ' : 'Configure data source ') + widget.title\">\n </button>\n }\n <button\n kendoButton\n [svgIcon]=\"gearIcon\"\n (click)=\"openEditWidgetDialog(widget)\"\n fillMode=\"flat\"\n size=\"small\"\n class=\"edit-widget-btn\"\n title=\"Edit widget\"\n [attr.aria-label]=\"'Edit widget ' + widget.title\">\n </button>\n <button\n kendoButton\n [svgIcon]=\"trashIcon\"\n (click)=\"removeWidget(widget.id)\"\n fillMode=\"flat\"\n themeColor=\"error\"\n size=\"small\"\n class=\"remove-widget-btn\"\n title=\"Delete widget\"\n [attr.aria-label]=\"'Delete widget ' + widget.title\">\n </button>\n </div>\n </div>\n }\n @if (getWidgetComponentType(widget.type); as componentType) {\n <ng-container *ngComponentOutlet=\"componentType; inputs: { config: widget }\"></ng-container>\n }\n </div>\n }\n\n <!-- MeshBoard Grid -->\n @if (isEditMode()) {\n <div class=\"zone-label grid-zone-label\">Grid Zone</div>\n }\n @if (hasGridWidgets()) {\n <kendo-tilelayout\n [columns]=\"displayColumns()\"\n [autoFlow]=\"displayAutoFlow()\"\n [rowHeight]=\"config().rowHeight\"\n [gap]=\"config().gap\"\n [class.has-banners]=\"bannerWidgets().length > 0 && !isEditMode()\"\n [resizable]=\"canEditLayout()\"\n [reorderable]=\"canEditLayout()\"\n (reorder)=\"onReorder($event)\"\n (resize)=\"onResize($event)\">\n @for (placement of displayGridWidgets(); track placement.widget.id) {\n @let widget = placement.widget;\n <kendo-tilelayout-item\n [col]=\"$any(placement.col)\"\n [row]=\"$any(placement.row)\"\n [colSpan]=\"placement.colSpan\"\n [rowSpan]=\"placement.rowSpan\">\n <kendo-tilelayout-item-header>\n <div class=\"widget-header\"\n [class.unconfigured]=\"isWidgetUnconfigured(widget)\"\n [class.chromeless-header]=\"widget.chromeless && !isEditMode()\">\n @if (!(widget.chromeless && !isEditMode())) {\n <span class=\"widget-title\">{{ widget.title }}</span>\n @if (isWidgetUnconfigured(widget)) {\n <span class=\"unconfigured-badge\" title=\"Widget needs configuration\">!</span>\n }\n }\n @if (isEditMode()) {\n <div class=\"widget-actions\" (pointerdown)=\"$event.stopPropagation()\">\n @if (supportsConfiguration(widget)) {\n <button\n kendoButton\n [svgIcon]=\"linkIcon\"\n (click)=\"openWidgetConfig(widget)\"\n fillMode=\"flat\"\n size=\"small\"\n class=\"config-widget-btn\"\n [class.needs-config]=\"isWidgetUnconfigured(widget)\"\n [title]=\"isWidgetUnconfigured(widget) ? 'Configure data source (required)' : 'Configure data source'\"\n [attr.aria-label]=\"(isWidgetUnconfigured(widget) ? 'Configure data source (required) ' : 'Configure data source ') + widget.title\">\n </button>\n }\n <button\n kendoButton\n [svgIcon]=\"gearIcon\"\n (click)=\"openEditWidgetDialog(widget)\"\n fillMode=\"flat\"\n size=\"small\"\n class=\"edit-widget-btn\"\n title=\"Edit position\"\n [attr.aria-label]=\"'Edit position ' + widget.title\">\n </button>\n <button\n kendoButton\n [svgIcon]=\"copyIcon\"\n (click)=\"duplicateWidget(widget)\"\n fillMode=\"flat\"\n size=\"small\"\n class=\"duplicate-widget-btn\"\n title=\"Duplicate widget\"\n [attr.aria-label]=\"'Duplicate widget ' + widget.title\">\n </button>\n <button\n kendoButton\n [svgIcon]=\"trashIcon\"\n (click)=\"removeWidget(widget.id)\"\n fillMode=\"flat\"\n themeColor=\"error\"\n size=\"small\"\n class=\"remove-widget-btn\"\n title=\"Delete widget\"\n [attr.aria-label]=\"'Delete widget ' + widget.title\">\n </button>\n </div>\n }\n </div>\n </kendo-tilelayout-item-header>\n <kendo-tilelayout-item-body>\n @if (getWidgetComponentType(widget.type); as componentType) {\n <ng-container *ngComponentOutlet=\"componentType; inputs: { config: widget }\"></ng-container>\n } @else {\n <div class=\"widget-error\">\n <p>Unknown widget type: {{ widget.type }}</p>\n </div>\n }\n </kendo-tilelayout-item-body>\n </kendo-tilelayout-item>\n }\n </kendo-tilelayout>\n } @else {\n <mm-meshboard-empty-state [readonly]=\"isReadonly()\" (addWidget)=\"openAddWidget()\"></mm-meshboard-empty-state>\n }\n\n <!-- Edit Widget Dialog -->\n @if (showEditWidgetDialog && editingWidget) {\n <mm-edit-widget-dialog\n [widget]=\"editingWidget\"\n [widgets]=\"config().widgets\"\n [maxColumns]=\"config().columns\"\n [gridService]=\"gridService\"\n (save)=\"onEditWidgetSave($event)\"\n (cancelled)=\"closeEditWidgetDialog()\">\n </mm-edit-widget-dialog>\n }\n }\n</div>\n", styles: [":host{display:flex;flex-direction:column;flex:1;min-height:0;height:100%;width:100%}.meshboard-view{display:flex;flex-direction:column;flex:1;min-height:0;width:100%;background-color:var(--kendo-color-surface, #f5f5f5)}.loading-container{display:flex;align-items:center;justify-content:center;height:100%;width:100%}.entity-selector-hint{display:flex;align-items:center;gap:8px;padding:10px 16px;margin-bottom:8px;background:color-mix(in srgb,var(--kendo-color-info, #2196f3) 10%,var(--kendo-color-surface-alt, #fafafa));border:1px solid color-mix(in srgb,var(--kendo-color-info, #2196f3) 40%,transparent);border-radius:4px;font-size:13px;color:var(--kendo-color-on-app-surface, #424242)}.entity-selector-hint strong{color:var(--kendo-color-info, #2196f3)}.variable-resolution-errors{display:flex;flex-wrap:wrap;align-items:center;gap:8px;padding:8px 16px;margin-bottom:8px;background:color-mix(in srgb,var(--kendo-color-error, #f44336) 10%,var(--kendo-color-surface-alt, #fafafa));border:1px solid var(--kendo-color-error, #f44336);border-radius:4px;font-size:13px;color:var(--kendo-color-on-app-surface, #424242)}.variable-resolution-errors strong{color:var(--kendo-color-error, #f44336)}.variable-resolution-errors .error-item{padding:2px 8px;background:color-mix(in srgb,var(--kendo-color-error, #f44336) 8%,transparent);border-radius:3px;font-family:var(--theme-font-mono);font-size:12px}.scope-resolution-errors .error-item{font-family:inherit}.model-unavailable{display:flex;align-items:center;justify-content:center;height:100%;width:100%;padding:2rem;overflow:hidden;box-sizing:border-box}.model-unavailable .message-card{text-align:center;max-width:500px;padding:2rem;background:var(--kendo-color-surface-alt, #fafafa);border:1px solid var(--kendo-color-border, #e0e0e0);border-radius:8px;overflow:hidden}.model-unavailable .message-card kendo-svg-icon{color:var(--kendo-color-warning, #ff9800);margin-bottom:1rem}.model-unavailable .message-card h2{margin:1rem 0;color:var(--kendo-color-on-app-surface, #424242)}.model-unavailable .message-card p{margin:.5rem 0;color:var(--kendo-color-subtle, #757575);line-height:1.5}.model-unavailable .message-card ol{text-align:left;margin:1rem 0;padding-left:1.5rem;color:var(--kendo-color-subtle, #757575);line-height:1.8}.model-unavailable .message-card ol li{margin:.25rem 0}.model-unavailable .message-card ol a{color:var(--kendo-color-primary, #3f51b5);text-decoration:none}.model-unavailable .message-card ol a:hover{text-decoration:underline}.meshboard-toolbar{display:flex;justify-content:space-between;align-items:flex-end;padding:1rem 1.5rem;background-color:var(--kendo-color-surface-alt, white);border-bottom:1px solid var(--kendo-color-border, #e0e0e0);gap:1rem;flex-shrink:0}.meshboard-toolbar .toolbar-left{display:flex;align-items:baseline;gap:1rem;flex:1;min-width:0;align-self:center}.meshboard-toolbar .toolbar-left .meshboard-title{margin:0;font-size:1.5rem;font-weight:500;color:var(--kendo-color-on-app-surface, #212121);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.meshboard-toolbar .toolbar-left .meshboard-description{color:var(--kendo-color-subtle, #757575);font-size:.875rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.meshboard-toolbar .toolbar-center{display:flex;justify-content:center;align-items:flex-end;flex-shrink:0}.meshboard-toolbar .toolbar-center mm-time-range-picker ::ng-deep .time-range-picker{gap:.5rem}.meshboard-toolbar .toolbar-separator{width:1px;align-self:stretch;background-color:var(--kendo-color-border, #e0e0e0);flex-shrink:0}.meshboard-toolbar .toolbar-entity-selectors{display:flex;align-items:flex-end;flex-shrink:0}.meshboard-toolbar .toolbar-right{display:flex;gap:.5rem;align-items:center;flex-shrink:0}.meshboard-toolbar.compact{justify-content:flex-end;align-items:center;padding:.25rem 1.5rem 0;background-color:transparent;border-bottom:none}kendo-tilelayout{padding:1.5rem;overflow:auto;flex:1;min-height:0}.widget-header{display:flex;justify-content:space-between;align-items:center;width:100%;gap:.5rem}.widget-header.unconfigured{background-color:#ff98001a}.widget-header .widget-title{flex:1;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.widget-header .unconfigured-badge{display:inline-flex;align-items:center;justify-content:center;width:18px;height:18px;border-radius:50%;background-color:var(--kendo-color-warning, #ff9800);color:#fff;font-size:12px;font-weight:700;flex-shrink:0}.widget-header .widget-actions{display:flex;align-items:center;gap:.25rem;flex-shrink:0}.widget-header .config-widget-btn,.widget-header .edit-widget-btn,.widget-header .remove-widget-btn{opacity:.6;transition:opacity .2s}.widget-header .config-widget-btn:hover,.widget-header .edit-widget-btn:hover,.widget-header .remove-widget-btn:hover{opacity:1}.widget-header .config-widget-btn.needs-config{color:var(--kendo-color-warning, #ff9800);opacity:1}.widget-header .remove-widget-btn{color:var(--kendo-color-error, #f44336)}.widget-error{display:flex;align-items:center;justify-content:center;height:100%;padding:1rem;color:var(--kendo-color-error, #f44336);text-align:center}.meshboard-view.edit-mode kendo-tilelayout-item:hover{box-shadow:0 0 0 2px var(--kendo-color-primary, #3f51b5)}@media(max-width:768px){.meshboard-toolbar{flex-direction:column;align-items:stretch;gap:.75rem}.meshboard-toolbar .toolbar-left{flex-direction:column;align-items:flex-start;gap:.25rem}.meshboard-toolbar .toolbar-right{flex-wrap:wrap;justify-content:flex-end}.meshboard-toolbar.compact{align-items:flex-end;padding:.25rem 1rem 0}kendo-tilelayout{padding:1rem}}:host ::ng-deep kendo-tilelayout.has-banners{padding-top:0!important}:host ::ng-deep kendo-tilelayout{padding-bottom:1.5rem!important}.widget-header.chromeless-header{display:none}:host ::ng-deep .k-tilelayout-item-header:has(.chromeless-header){display:none}:host ::ng-deep kendo-tilelayout-item:has(.chromeless-header).k-tilelayout-item,:host ::ng-deep kendo-tilelayout-item:has(.chromeless-header).k-card{border:none;box-shadow:none;background:transparent}:host ::ng-deep kendo-tilelayout-item:has(kendo-dialog),:host ::ng-deep kendo-tilelayout-item:has(kendo-window){z-index:9999}.zone-label{font-size:.65rem;opacity:.4;padding:6px 1rem 2px;font-weight:600}.banner-zone-item{margin:0;padding:8px 1rem}.banner-zone-item.banner-edit{border:1px dashed var(--kendo-color-border, rgba(255, 255, 255, .2));border-radius:4px;margin:0 1rem 12px;padding:4px 0}.banner-edit-header{display:flex;align-items:center;justify-content:space-between;padding:4px 8px;opacity:.7}.banner-edit-header .widget-title{font-size:.75rem}.banner-edit-header .widget-actions{display:flex;gap:2px}.meshboard-view.chrome-plain{background:transparent!important}.meshboard-view.chrome-plain .meshboard-toolbar{padding:0 0 var(--theme-space-3, 12px);background:transparent!important;border-bottom:none!important}.meshboard-view.chrome-plain kendo-tilelayout{padding:0!important}.meshboard-view.chrome-plain .banner-zone-item:not(.banner-edit){padding-left:0;padding-right:0}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "directive", type: i1$3.NgComponentOutlet, selector: "[ngComponentOutlet]", inputs: ["ngComponentOutlet", "ngComponentOutletInputs", "ngComponentOutletInjector", "ngComponentOutletEnvironmentInjector", "ngComponentOutletContent", "ngComponentOutletNgModule"], exportAs: ["ngComponentOutlet"] }, { kind: "ngmodule", type: RouterModule }, { kind: "directive", type: i3$1.RouterLink, selector: "[routerLink]", inputs: ["target", "queryParams", "fragment", "queryParamsHandling", "state", "info", "relativeTo", "preserveFragment", "skipLocationChange", "replaceUrl", "browserUrl", "routerLink"] }, { kind: "ngmodule", type: TileLayoutModule }, { kind: "component", type: i5.TileLayoutComponent, selector: "kendo-tilelayout", inputs: ["columns", "columnWidth", "gap", "reorderable", "resizable", "rowHeight", "autoFlow", "navigable"], outputs: ["reorder", "resize"] }, { kind: "component", type: i5.TileLayoutItemBodyComponent, selector: "kendo-tilelayout-item-body" }, { kind: "component", type: i5.TileLayoutItemComponent, selector: "kendo-tilelayout-item", inputs: ["title", "rowSpan", "colSpan", "order", "col", "row", "reorderable", "resizable"] }, { kind: "component", type: i5.TileLayoutItemHeaderComponent, selector: "kendo-tilelayout-item-header" }, { kind: "ngmodule", type: ButtonModule }, { kind: "component", type: i2.ButtonComponent, selector: "button[kendoButton]", inputs: ["arrowIcon", "toggleable", "togglable", "selected", "tabIndex", "imageUrl", "iconPosition", "iconClass", "icon", "disabled", "size", "rounded", "fillMode", "themeColor", "svgIcon"], outputs: ["selectedChange", "click"], exportAs: ["kendoButton"] }, { kind: "ngmodule", type: DialogModule }, { kind: "ngmodule", type: SVGIconModule }, { kind: "component", type: i1$4.SVGIconComponent, selector: "kendo-svg-icon, kendo-svgicon", inputs: ["icon", "variant"], exportAs: ["kendoSVGIcon"] }, { kind: "component", type: EditWidgetDialogComponent, selector: "mm-edit-widget-dialog", inputs: ["widget", "widgets", "maxColumns", "gridService"], outputs: ["save", "cancelled"] }, { kind: "component", type: TimeRangePickerComponent, selector: "mm-time-range-picker", inputs: ["config", "labels", "initialSelection"], outputs: ["rangeChange", "rangeChangeISO", "selectionChange"] }, { kind: "component", type: EntitySelectorToolbarComponent, selector: "mm-entity-selector-toolbar", inputs: ["entitySelectors", "variables"], outputs: ["entitySelected", "entityCleared"] }, { kind: "component", type: MeshBoardEmptyStateComponent, selector: "mm-meshboard-empty-state", inputs: ["readonly"], outputs: ["addWidget"] }], changeDetection: i0.ChangeDetectionStrategy.Eager });
|
|
35537
37791
|
}
|
|
35538
37792
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.5", ngImport: i0, type: MeshBoardViewComponent, decorators: [{
|
|
35539
37793
|
type: Component,
|
|
@@ -35551,11 +37805,11 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.5", ngImpor
|
|
|
35551
37805
|
], hostDirectives: [UnsavedChangesDirective], providers: [
|
|
35552
37806
|
{ provide: HAS_UNSAVED_CHANGES, useExisting: MeshBoardViewComponent },
|
|
35553
37807
|
AutoRefreshTimerService
|
|
35554
|
-
], changeDetection: ChangeDetectionStrategy.Eager, template: "<div class=\"meshboard-view\" [class.chrome-plain]=\"effectiveChrome() === 'plain'\">\n @if (isLoading()) {\n <div class=\"loading-container\">\n <div class=\"k-loading-mask\">\n <span class=\"k-loading-text\">Loading MeshBoard...</span>\n <div class=\"k-loading-image\"></div>\n <div class=\"k-loading-color\"></div>\n </div>\n </div>\n } @else if (isModelAvailable() === false) {\n <div class=\"model-unavailable\">\n <div class=\"message-card\">\n <kendo-svg-icon [icon]=\"xIcon\" size=\"xlarge\"></kendo-svg-icon>\n <h2>MeshBoard Not Available</h2>\n <p>The MeshBoard feature requires the CK model 'System.UI' version 1.0.1 or higher.</p>\n <p>Please install the required model in your tenant to use this feature.</p>\n </div>\n </div>\n } @else if (notFoundError()) {\n <div class=\"model-unavailable\">\n <div class=\"message-card\">\n <kendo-svg-icon [icon]=\"xIcon\" size=\"xlarge\"></kendo-svg-icon>\n <h2>MeshBoard Not Found</h2>\n <p>{{ notFoundError() }}</p>\n <p>To create a MeshBoard with a Well-Known Name:</p>\n <ol>\n <li>Go to the <a [routerLink]=\"meshBoardPageLink()\">MeshBoard</a> page</li>\n <li>Open Settings</li>\n <li>Set the \"Well-Known Name\" field</li>\n <li>Save the MeshBoard</li>\n </ol>\n </div>\n </div>\n } @else if (isInitialized()) {\n <!-- Toolbar (headerMode: full = name + description + controls, compact = controls only, none = no row) -->\n @if (effectiveHeaderMode() !== 'none') {\n <div class=\"meshboard-toolbar\" [class.compact]=\"effectiveHeaderMode() === 'compact'\">\n @if (effectiveHeaderMode() === 'full') {\n <div class=\"toolbar-left\">\n <h2 class=\"meshboard-title\">{{ config().name }}</h2>\n @if (config().description) {\n <span class=\"meshboard-description\">{{ config().description }}</span>\n }\n </div>\n }\n\n <!-- Time Filter (center) -->\n @if (isTimeFilterEnabled()) {\n <div class=\"toolbar-center\">\n <mm-time-range-picker\n [config]=\"timeFilterConfig()?.pickerConfig ?? {}\"\n [labels]=\"timeRangeLabels()\"\n [initialSelection]=\"initialTimeSelection()\"\n (rangeChange)=\"onTimeRangeChange($event)\"\n (selectionChange)=\"onTimeSelectionChange($event)\">\n </mm-time-range-picker>\n @if (canResetTimeFilter()) {\n <button\n kendoButton\n [svgIcon]=\"undoIcon\"\n (click)=\"resetTimeFilterToDefault()\"\n title=\"Reset to default time filter\"\n aria-label=\"Reset to default time filter\"\n fillMode=\"flat\"\n size=\"small\">\n </button>\n }\n </div>\n }\n\n <!-- Entity Selectors -->\n @if (hasEntitySelectors()) {\n @if (isTimeFilterEnabled()) {\n <div class=\"toolbar-separator\"></div>\n }\n <div class=\"toolbar-entity-selectors\">\n <mm-entity-selector-toolbar\n [entitySelectors]=\"entitySelectorsConfig()\"\n (entitySelected)=\"onEntitySelectorSelected($event)\"\n (entityCleared)=\"onEntitySelectorCleared($event)\">\n </mm-entity-selector-toolbar>\n </div>\n }\n\n @if (isTimeFilterEnabled() || hasEntitySelectors()) {\n <div class=\"toolbar-separator\"></div>\n }\n <div class=\"toolbar-right mm-toolbar-actions\">\n @if (!isReadonly()) {\n <!-- Manager Button (icon only) -->\n <button\n kendoButton\n [svgIcon]=\"gridLayoutIcon\"\n (click)=\"openManager()\"\n title=\"Manage MeshBoards\"\n aria-label=\"Manage MeshBoards\"\n fillMode=\"flat\">\n </button>\n\n <!-- Settings Button (icon only) -->\n <button\n kendoButton\n [svgIcon]=\"gearIcon\"\n (click)=\"openSettings()\"\n title=\"MeshBoard Settings\"\n aria-label=\"MeshBoard Settings\"\n fillMode=\"flat\">\n </button>\n }\n\n <!-- Refresh Button (icon only) -->\n <button\n kendoButton\n [svgIcon]=\"arrowRotateCwIcon\"\n (click)=\"refresh()\"\n title=\"Refresh All Widgets\"\n aria-label=\"Refresh All Widgets\"\n fillMode=\"flat\">\n </button>\n\n @if (!isReadonly()) {\n <!-- Edit Mode Toggle (icon only) -->\n @if (!isEditMode()) {\n <button\n kendoButton\n [svgIcon]=\"pencilIcon\"\n (click)=\"toggleEditMode()\"\n title=\"Enter Edit Mode\"\n aria-label=\"Enter Edit Mode\"\n fillMode=\"flat\">\n </button>\n } @else {\n <button\n kendoButton\n [svgIcon]=\"xIcon\"\n (click)=\"cancelEdit()\"\n title=\"Cancel Edit Mode\"\n aria-label=\"Cancel Edit Mode\"\n fillMode=\"flat\">\n </button>\n }\n\n <!-- Add Widget Button (with text) -->\n <button\n kendoButton\n [svgIcon]=\"plusIcon\"\n (click)=\"openAddWidget()\"\n title=\"Add Widget\"\n themeColor=\"primary\">\n Add Widget\n </button>\n\n <!-- Save Button (with text) -->\n @if (canSave()) {\n <button\n kendoButton\n [svgIcon]=\"saveIcon\"\n (click)=\"save()\"\n [disabled]=\"isSaving()\"\n title=\"Save MeshBoard\"\n themeColor=\"primary\">\n {{ isSaving() ? 'Saving...' : 'Save' }}\n </button>\n }\n }\n </div>\n </div>\n }\n\n <!-- Entity Selector Hint -->\n @if (unselectedToolbarSelectors().length > 0) {\n <div class=\"entity-selector-hint\">\n <kendo-svg-icon [icon]=\"infoCircleIcon\" size=\"medium\"></kendo-svg-icon>\n <span>\n Please select\n @for (selector of unselectedToolbarSelectors(); track selector.id; let last = $last) {\n <strong>{{ selector.label }}</strong>@if (!last) {, }\n }\n to display dashboard data.\n </span>\n </div>\n }\n\n <!-- Variable Resolution Errors -->\n @if (variableResolutionErrors().length > 0) {\n <div class=\"variable-resolution-errors\">\n <strong>Variable resolution errors:</strong>\n @for (error of variableResolutionErrors(); track error.variableName) {\n <span class=\"error-item\">{{ error.variableName }}: {{ error.message }}</span>\n }\n </div>\n }\n\n <!-- Banner Zone: widgets placed in the banner stack above the grid -->\n @if (isEditMode()) {\n <div class=\"zone-label banner-zone-label\">Banner Zone</div>\n }\n @for (widget of bannerWidgets(); track trackByWidgetId($index, widget); let first = $first; let last = $last) {\n <div class=\"banner-zone-item\" [class.banner-edit]=\"isEditMode()\">\n @if (isEditMode()) {\n <div class=\"banner-edit-header\">\n <span class=\"widget-title\">{{ widget.title }}</span>\n <div class=\"widget-actions\">\n <button\n kendoButton\n [svgIcon]=\"arrowUpIcon\"\n (click)=\"moveBannerUp(widget.id)\"\n fillMode=\"flat\"\n size=\"small\"\n [disabled]=\"first\"\n title=\"Move up\"\n [attr.aria-label]=\"'Move up ' + widget.title\">\n </button>\n <button\n kendoButton\n [svgIcon]=\"arrowDownIcon\"\n (click)=\"moveBannerDown(widget.id)\"\n fillMode=\"flat\"\n size=\"small\"\n [disabled]=\"last\"\n title=\"Move down\"\n [attr.aria-label]=\"'Move down ' + widget.title\">\n </button>\n @if (supportsConfiguration(widget)) {\n <button\n kendoButton\n [svgIcon]=\"linkIcon\"\n (click)=\"openWidgetConfig(widget)\"\n fillMode=\"flat\"\n size=\"small\"\n class=\"config-widget-btn\"\n [class.needs-config]=\"isWidgetUnconfigured(widget)\"\n [title]=\"isWidgetUnconfigured(widget) ? 'Configure data source (required)' : 'Configure data source'\"\n [attr.aria-label]=\"(isWidgetUnconfigured(widget) ? 'Configure data source (required) ' : 'Configure data source ') + widget.title\">\n </button>\n }\n <button\n kendoButton\n [svgIcon]=\"gearIcon\"\n (click)=\"openEditWidgetDialog(widget)\"\n fillMode=\"flat\"\n size=\"small\"\n class=\"edit-widget-btn\"\n title=\"Edit widget\"\n [attr.aria-label]=\"'Edit widget ' + widget.title\">\n </button>\n <button\n kendoButton\n [svgIcon]=\"trashIcon\"\n (click)=\"removeWidget(widget.id)\"\n fillMode=\"flat\"\n themeColor=\"error\"\n size=\"small\"\n class=\"remove-widget-btn\"\n title=\"Delete widget\"\n [attr.aria-label]=\"'Delete widget ' + widget.title\">\n </button>\n </div>\n </div>\n }\n @if (getWidgetComponentType(widget.type); as componentType) {\n <ng-container *ngComponentOutlet=\"componentType; inputs: { config: widget }\"></ng-container>\n }\n </div>\n }\n\n <!-- MeshBoard Grid -->\n @if (isEditMode()) {\n <div class=\"zone-label grid-zone-label\">Grid Zone</div>\n }\n @if (hasGridWidgets()) {\n <kendo-tilelayout\n [columns]=\"displayColumns()\"\n [autoFlow]=\"displayAutoFlow()\"\n [rowHeight]=\"config().rowHeight\"\n [gap]=\"config().gap\"\n [class.has-banners]=\"bannerWidgets().length > 0 && !isEditMode()\"\n [resizable]=\"canEditLayout()\"\n [reorderable]=\"canEditLayout()\"\n (reorder)=\"onReorder($event)\"\n (resize)=\"onResize($event)\">\n @for (placement of displayGridWidgets(); track placement.widget.id) {\n @let widget = placement.widget;\n <kendo-tilelayout-item\n [col]=\"$any(placement.col)\"\n [row]=\"$any(placement.row)\"\n [colSpan]=\"placement.colSpan\"\n [rowSpan]=\"placement.rowSpan\">\n <kendo-tilelayout-item-header>\n <div class=\"widget-header\"\n [class.unconfigured]=\"isWidgetUnconfigured(widget)\"\n [class.chromeless-header]=\"widget.chromeless && !isEditMode()\">\n @if (!(widget.chromeless && !isEditMode())) {\n <span class=\"widget-title\">{{ widget.title }}</span>\n @if (isWidgetUnconfigured(widget)) {\n <span class=\"unconfigured-badge\" title=\"Widget needs configuration\">!</span>\n }\n }\n @if (isEditMode()) {\n <div class=\"widget-actions\" (pointerdown)=\"$event.stopPropagation()\">\n @if (supportsConfiguration(widget)) {\n <button\n kendoButton\n [svgIcon]=\"linkIcon\"\n (click)=\"openWidgetConfig(widget)\"\n fillMode=\"flat\"\n size=\"small\"\n class=\"config-widget-btn\"\n [class.needs-config]=\"isWidgetUnconfigured(widget)\"\n [title]=\"isWidgetUnconfigured(widget) ? 'Configure data source (required)' : 'Configure data source'\"\n [attr.aria-label]=\"(isWidgetUnconfigured(widget) ? 'Configure data source (required) ' : 'Configure data source ') + widget.title\">\n </button>\n }\n <button\n kendoButton\n [svgIcon]=\"gearIcon\"\n (click)=\"openEditWidgetDialog(widget)\"\n fillMode=\"flat\"\n size=\"small\"\n class=\"edit-widget-btn\"\n title=\"Edit position\"\n [attr.aria-label]=\"'Edit position ' + widget.title\">\n </button>\n <button\n kendoButton\n [svgIcon]=\"copyIcon\"\n (click)=\"duplicateWidget(widget)\"\n fillMode=\"flat\"\n size=\"small\"\n class=\"duplicate-widget-btn\"\n title=\"Duplicate widget\"\n [attr.aria-label]=\"'Duplicate widget ' + widget.title\">\n </button>\n <button\n kendoButton\n [svgIcon]=\"trashIcon\"\n (click)=\"removeWidget(widget.id)\"\n fillMode=\"flat\"\n themeColor=\"error\"\n size=\"small\"\n class=\"remove-widget-btn\"\n title=\"Delete widget\"\n [attr.aria-label]=\"'Delete widget ' + widget.title\">\n </button>\n </div>\n }\n </div>\n </kendo-tilelayout-item-header>\n <kendo-tilelayout-item-body>\n @if (getWidgetComponentType(widget.type); as componentType) {\n <ng-container *ngComponentOutlet=\"componentType; inputs: { config: widget }\"></ng-container>\n } @else {\n <div class=\"widget-error\">\n <p>Unknown widget type: {{ widget.type }}</p>\n </div>\n }\n </kendo-tilelayout-item-body>\n </kendo-tilelayout-item>\n }\n </kendo-tilelayout>\n } @else {\n <mm-meshboard-empty-state [readonly]=\"isReadonly()\" (addWidget)=\"openAddWidget()\"></mm-meshboard-empty-state>\n }\n\n <!-- Edit Widget Dialog -->\n @if (showEditWidgetDialog && editingWidget) {\n <mm-edit-widget-dialog\n [widget]=\"editingWidget\"\n [widgets]=\"config().widgets\"\n [maxColumns]=\"config().columns\"\n [gridService]=\"gridService\"\n (save)=\"onEditWidgetSave($event)\"\n (cancelled)=\"closeEditWidgetDialog()\">\n </mm-edit-widget-dialog>\n }\n }\n</div>\n", styles: [":host{display:flex;flex-direction:column;flex:1;min-height:0;height:100%;width:100%}.meshboard-view{display:flex;flex-direction:column;flex:1;min-height:0;width:100%;background-color:var(--kendo-color-surface, #f5f5f5)}.loading-container{display:flex;align-items:center;justify-content:center;height:100%;width:100%}.entity-selector-hint{display:flex;align-items:center;gap:8px;padding:10px 16px;margin-bottom:8px;background:color-mix(in srgb,var(--kendo-color-info, #2196f3) 10%,var(--kendo-color-surface-alt, #fafafa));border:1px solid color-mix(in srgb,var(--kendo-color-info, #2196f3) 40%,transparent);border-radius:4px;font-size:13px;color:var(--kendo-color-on-app-surface, #424242)}.entity-selector-hint strong{color:var(--kendo-color-info, #2196f3)}.variable-resolution-errors{display:flex;flex-wrap:wrap;align-items:center;gap:8px;padding:8px 16px;margin-bottom:8px;background:color-mix(in srgb,var(--kendo-color-error, #f44336) 10%,var(--kendo-color-surface-alt, #fafafa));border:1px solid var(--kendo-color-error, #f44336);border-radius:4px;font-size:13px;color:var(--kendo-color-on-app-surface, #424242)}.variable-resolution-errors strong{color:var(--kendo-color-error, #f44336)}.variable-resolution-errors .error-item{padding:2px 8px;background:color-mix(in srgb,var(--kendo-color-error, #f44336) 8%,transparent);border-radius:3px;font-family:var(--theme-font-mono);font-size:12px}.model-unavailable{display:flex;align-items:center;justify-content:center;height:100%;width:100%;padding:2rem;overflow:hidden;box-sizing:border-box}.model-unavailable .message-card{text-align:center;max-width:500px;padding:2rem;background:var(--kendo-color-surface-alt, #fafafa);border:1px solid var(--kendo-color-border, #e0e0e0);border-radius:8px;overflow:hidden}.model-unavailable .message-card kendo-svg-icon{color:var(--kendo-color-warning, #ff9800);margin-bottom:1rem}.model-unavailable .message-card h2{margin:1rem 0;color:var(--kendo-color-on-app-surface, #424242)}.model-unavailable .message-card p{margin:.5rem 0;color:var(--kendo-color-subtle, #757575);line-height:1.5}.model-unavailable .message-card ol{text-align:left;margin:1rem 0;padding-left:1.5rem;color:var(--kendo-color-subtle, #757575);line-height:1.8}.model-unavailable .message-card ol li{margin:.25rem 0}.model-unavailable .message-card ol a{color:var(--kendo-color-primary, #3f51b5);text-decoration:none}.model-unavailable .message-card ol a:hover{text-decoration:underline}.meshboard-toolbar{display:flex;justify-content:space-between;align-items:flex-end;padding:1rem 1.5rem;background-color:var(--kendo-color-surface-alt, white);border-bottom:1px solid var(--kendo-color-border, #e0e0e0);gap:1rem;flex-shrink:0}.meshboard-toolbar .toolbar-left{display:flex;align-items:baseline;gap:1rem;flex:1;min-width:0;align-self:center}.meshboard-toolbar .toolbar-left .meshboard-title{margin:0;font-size:1.5rem;font-weight:500;color:var(--kendo-color-on-app-surface, #212121);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.meshboard-toolbar .toolbar-left .meshboard-description{color:var(--kendo-color-subtle, #757575);font-size:.875rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.meshboard-toolbar .toolbar-center{display:flex;justify-content:center;align-items:flex-end;flex-shrink:0}.meshboard-toolbar .toolbar-center mm-time-range-picker ::ng-deep .time-range-picker{gap:.5rem}.meshboard-toolbar .toolbar-separator{width:1px;align-self:stretch;background-color:var(--kendo-color-border, #e0e0e0);flex-shrink:0}.meshboard-toolbar .toolbar-entity-selectors{display:flex;align-items:flex-end;flex-shrink:0}.meshboard-toolbar .toolbar-right{display:flex;gap:.5rem;align-items:center;flex-shrink:0}.meshboard-toolbar.compact{justify-content:flex-end;align-items:center;padding:.25rem 1.5rem 0;background-color:transparent;border-bottom:none}kendo-tilelayout{padding:1.5rem;overflow:auto;flex:1;min-height:0}.widget-header{display:flex;justify-content:space-between;align-items:center;width:100%;gap:.5rem}.widget-header.unconfigured{background-color:#ff98001a}.widget-header .widget-title{flex:1;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.widget-header .unconfigured-badge{display:inline-flex;align-items:center;justify-content:center;width:18px;height:18px;border-radius:50%;background-color:var(--kendo-color-warning, #ff9800);color:#fff;font-size:12px;font-weight:700;flex-shrink:0}.widget-header .widget-actions{display:flex;align-items:center;gap:.25rem;flex-shrink:0}.widget-header .config-widget-btn,.widget-header .edit-widget-btn,.widget-header .remove-widget-btn{opacity:.6;transition:opacity .2s}.widget-header .config-widget-btn:hover,.widget-header .edit-widget-btn:hover,.widget-header .remove-widget-btn:hover{opacity:1}.widget-header .config-widget-btn.needs-config{color:var(--kendo-color-warning, #ff9800);opacity:1}.widget-header .remove-widget-btn{color:var(--kendo-color-error, #f44336)}.widget-error{display:flex;align-items:center;justify-content:center;height:100%;padding:1rem;color:var(--kendo-color-error, #f44336);text-align:center}.meshboard-view.edit-mode kendo-tilelayout-item:hover{box-shadow:0 0 0 2px var(--kendo-color-primary, #3f51b5)}@media(max-width:768px){.meshboard-toolbar{flex-direction:column;align-items:stretch;gap:.75rem}.meshboard-toolbar .toolbar-left{flex-direction:column;align-items:flex-start;gap:.25rem}.meshboard-toolbar .toolbar-right{flex-wrap:wrap;justify-content:flex-end}.meshboard-toolbar.compact{align-items:flex-end;padding:.25rem 1rem 0}kendo-tilelayout{padding:1rem}}:host ::ng-deep kendo-tilelayout.has-banners{padding-top:0!important}:host ::ng-deep kendo-tilelayout{padding-bottom:1.5rem!important}.widget-header.chromeless-header{display:none}:host ::ng-deep .k-tilelayout-item-header:has(.chromeless-header){display:none}:host ::ng-deep kendo-tilelayout-item:has(.chromeless-header).k-tilelayout-item,:host ::ng-deep kendo-tilelayout-item:has(.chromeless-header).k-card{border:none;box-shadow:none;background:transparent}:host ::ng-deep kendo-tilelayout-item:has(kendo-dialog),:host ::ng-deep kendo-tilelayout-item:has(kendo-window){z-index:9999}.zone-label{font-size:.65rem;opacity:.4;padding:6px 1rem 2px;font-weight:600}.banner-zone-item{margin:0;padding:8px 1rem}.banner-zone-item.banner-edit{border:1px dashed var(--kendo-color-border, rgba(255, 255, 255, .2));border-radius:4px;margin:0 1rem 12px;padding:4px 0}.banner-edit-header{display:flex;align-items:center;justify-content:space-between;padding:4px 8px;opacity:.7}.banner-edit-header .widget-title{font-size:.75rem}.banner-edit-header .widget-actions{display:flex;gap:2px}.meshboard-view.chrome-plain{background:transparent!important}.meshboard-view.chrome-plain .meshboard-toolbar{padding:0 0 var(--theme-space-3, 12px);background:transparent!important;border-bottom:none!important}.meshboard-view.chrome-plain kendo-tilelayout{padding:0!important}.meshboard-view.chrome-plain .banner-zone-item:not(.banner-edit){padding-left:0;padding-right:0}\n"] }]
|
|
37808
|
+
], changeDetection: ChangeDetectionStrategy.Eager, template: "<div class=\"meshboard-view\" [class.chrome-plain]=\"effectiveChrome() === 'plain'\">\n @if (isLoading()) {\n <div class=\"loading-container\">\n <div class=\"k-loading-mask\">\n <span class=\"k-loading-text\">Loading MeshBoard...</span>\n <div class=\"k-loading-image\"></div>\n <div class=\"k-loading-color\"></div>\n </div>\n </div>\n } @else if (isModelAvailable() === false) {\n <div class=\"model-unavailable\">\n <div class=\"message-card\">\n <kendo-svg-icon [icon]=\"xIcon\" size=\"xlarge\"></kendo-svg-icon>\n <h2>MeshBoard Not Available</h2>\n <p>The MeshBoard feature requires the CK model 'System.UI' version 1.0.1 or higher.</p>\n <p>Please install the required model in your tenant to use this feature.</p>\n </div>\n </div>\n } @else if (notFoundError()) {\n <div class=\"model-unavailable\">\n <div class=\"message-card\">\n <kendo-svg-icon [icon]=\"xIcon\" size=\"xlarge\"></kendo-svg-icon>\n <h2>MeshBoard Not Found</h2>\n <p>{{ notFoundError() }}</p>\n <p>To create a MeshBoard with a Well-Known Name:</p>\n <ol>\n <li>Go to the <a [routerLink]=\"meshBoardPageLink()\">MeshBoard</a> page</li>\n <li>Open Settings</li>\n <li>Set the \"Well-Known Name\" field</li>\n <li>Save the MeshBoard</li>\n </ol>\n </div>\n </div>\n } @else if (isInitialized()) {\n <!-- Toolbar (headerMode: full = name + description + controls, compact = controls only, none = no row) -->\n @if (effectiveHeaderMode() !== 'none') {\n <div class=\"meshboard-toolbar\" [class.compact]=\"effectiveHeaderMode() === 'compact'\">\n @if (effectiveHeaderMode() === 'full') {\n <div class=\"toolbar-left\">\n <h2 class=\"meshboard-title\">{{ config().name }}</h2>\n @if (config().description) {\n <span class=\"meshboard-description\">{{ config().description }}</span>\n }\n </div>\n }\n\n <!-- Time Filter (center); hidden when the host owns the period (AB#7582) -->\n @if (showTimeRangePicker()) {\n <div class=\"toolbar-center\">\n <mm-time-range-picker\n [config]=\"timeFilterConfig()?.pickerConfig ?? {}\"\n [labels]=\"timeRangeLabels()\"\n [initialSelection]=\"initialTimeSelection()\"\n (rangeChange)=\"onTimeRangeChange($event)\"\n (selectionChange)=\"onTimeSelectionChange($event)\">\n </mm-time-range-picker>\n @if (canResetTimeFilter()) {\n <button\n kendoButton\n [svgIcon]=\"undoIcon\"\n (click)=\"resetTimeFilterToDefault()\"\n title=\"Reset to default time filter\"\n aria-label=\"Reset to default time filter\"\n fillMode=\"flat\"\n size=\"small\">\n </button>\n }\n </div>\n }\n\n <!-- Entity Selectors -->\n @if (hasEntitySelectors()) {\n @if (showTimeRangePicker()) {\n <div class=\"toolbar-separator\"></div>\n }\n <div class=\"toolbar-entity-selectors\">\n <mm-entity-selector-toolbar\n [entitySelectors]=\"entitySelectorsConfig()\"\n [variables]=\"boardVariables()\"\n (entitySelected)=\"onEntitySelectorSelected($event)\"\n (entityCleared)=\"onEntitySelectorCleared($event)\">\n </mm-entity-selector-toolbar>\n </div>\n }\n\n @if (showTimeRangePicker() || hasEntitySelectors()) {\n <div class=\"toolbar-separator\"></div>\n }\n <div class=\"toolbar-right mm-toolbar-actions\">\n @if (!isReadonly()) {\n <!-- Manager Button (icon only) -->\n <button\n kendoButton\n [svgIcon]=\"gridLayoutIcon\"\n (click)=\"openManager()\"\n title=\"Manage MeshBoards\"\n aria-label=\"Manage MeshBoards\"\n fillMode=\"flat\">\n </button>\n\n <!-- Settings Button (icon only) -->\n <button\n kendoButton\n [svgIcon]=\"gearIcon\"\n (click)=\"openSettings()\"\n title=\"MeshBoard Settings\"\n aria-label=\"MeshBoard Settings\"\n fillMode=\"flat\">\n </button>\n }\n\n <!-- Refresh Button (icon only) -->\n <button\n kendoButton\n [svgIcon]=\"arrowRotateCwIcon\"\n (click)=\"refresh()\"\n title=\"Refresh All Widgets\"\n aria-label=\"Refresh All Widgets\"\n fillMode=\"flat\">\n </button>\n\n @if (!isReadonly()) {\n <!-- Edit Mode Toggle (icon only) -->\n @if (!isEditMode()) {\n <button\n kendoButton\n [svgIcon]=\"pencilIcon\"\n (click)=\"toggleEditMode()\"\n title=\"Enter Edit Mode\"\n aria-label=\"Enter Edit Mode\"\n fillMode=\"flat\">\n </button>\n } @else {\n <button\n kendoButton\n [svgIcon]=\"xIcon\"\n (click)=\"cancelEdit()\"\n title=\"Cancel Edit Mode\"\n aria-label=\"Cancel Edit Mode\"\n fillMode=\"flat\">\n </button>\n }\n\n <!-- Add Widget Button (with text) -->\n <button\n kendoButton\n [svgIcon]=\"plusIcon\"\n (click)=\"openAddWidget()\"\n title=\"Add Widget\"\n themeColor=\"primary\">\n Add Widget\n </button>\n\n <!-- Save Button (with text) -->\n @if (canSave()) {\n <button\n kendoButton\n [svgIcon]=\"saveIcon\"\n (click)=\"save()\"\n [disabled]=\"isSaving()\"\n title=\"Save MeshBoard\"\n themeColor=\"primary\">\n {{ isSaving() ? 'Saving...' : 'Save' }}\n </button>\n }\n }\n </div>\n </div>\n }\n\n <!-- Entity Selector Hint -->\n @if (unselectedToolbarSelectors().length > 0) {\n <div class=\"entity-selector-hint\">\n <kendo-svg-icon [icon]=\"infoCircleIcon\" size=\"medium\"></kendo-svg-icon>\n <span>\n Please select\n @for (selector of unselectedToolbarSelectors(); track selector.id; let last = $last) {\n <strong>{{ selector.label }}</strong>@if (!last) {, }\n }\n to display dashboard data.\n </span>\n </div>\n }\n\n <!-- Variable Resolution Errors -->\n @if (variableResolutionErrors().length > 0) {\n <div class=\"variable-resolution-errors\">\n <strong>Variable resolution errors:</strong>\n @for (error of variableResolutionErrors(); track error.variableName) {\n <span class=\"error-item\">{{ error.variableName }}: {{ error.message }}</span>\n }\n </div>\n }\n\n <!-- Entity selector scopes that could not be resolved (AB#7721) -->\n @if (scopeResolutionErrors().length > 0) {\n <div class=\"variable-resolution-errors scope-resolution-errors\" role=\"status\">\n @for (error of scopeResolutionErrors(); track error.selectorId) {\n <span class=\"error-item\">{{ error.message }}</span>\n }\n </div>\n }\n\n <!-- Banner Zone: widgets placed in the banner stack above the grid -->\n @if (isEditMode()) {\n <div class=\"zone-label banner-zone-label\">Banner Zone</div>\n }\n @for (widget of bannerWidgets(); track trackByWidgetId($index, widget); let first = $first; let last = $last) {\n <div class=\"banner-zone-item\" [class.banner-edit]=\"isEditMode()\">\n @if (isEditMode()) {\n <div class=\"banner-edit-header\">\n <span class=\"widget-title\">{{ widget.title }}</span>\n <div class=\"widget-actions\">\n <button\n kendoButton\n [svgIcon]=\"arrowUpIcon\"\n (click)=\"moveBannerUp(widget.id)\"\n fillMode=\"flat\"\n size=\"small\"\n [disabled]=\"first\"\n title=\"Move up\"\n [attr.aria-label]=\"'Move up ' + widget.title\">\n </button>\n <button\n kendoButton\n [svgIcon]=\"arrowDownIcon\"\n (click)=\"moveBannerDown(widget.id)\"\n fillMode=\"flat\"\n size=\"small\"\n [disabled]=\"last\"\n title=\"Move down\"\n [attr.aria-label]=\"'Move down ' + widget.title\">\n </button>\n @if (supportsConfiguration(widget)) {\n <button\n kendoButton\n [svgIcon]=\"linkIcon\"\n (click)=\"openWidgetConfig(widget)\"\n fillMode=\"flat\"\n size=\"small\"\n class=\"config-widget-btn\"\n [class.needs-config]=\"isWidgetUnconfigured(widget)\"\n [title]=\"isWidgetUnconfigured(widget) ? 'Configure data source (required)' : 'Configure data source'\"\n [attr.aria-label]=\"(isWidgetUnconfigured(widget) ? 'Configure data source (required) ' : 'Configure data source ') + widget.title\">\n </button>\n }\n <button\n kendoButton\n [svgIcon]=\"gearIcon\"\n (click)=\"openEditWidgetDialog(widget)\"\n fillMode=\"flat\"\n size=\"small\"\n class=\"edit-widget-btn\"\n title=\"Edit widget\"\n [attr.aria-label]=\"'Edit widget ' + widget.title\">\n </button>\n <button\n kendoButton\n [svgIcon]=\"trashIcon\"\n (click)=\"removeWidget(widget.id)\"\n fillMode=\"flat\"\n themeColor=\"error\"\n size=\"small\"\n class=\"remove-widget-btn\"\n title=\"Delete widget\"\n [attr.aria-label]=\"'Delete widget ' + widget.title\">\n </button>\n </div>\n </div>\n }\n @if (getWidgetComponentType(widget.type); as componentType) {\n <ng-container *ngComponentOutlet=\"componentType; inputs: { config: widget }\"></ng-container>\n }\n </div>\n }\n\n <!-- MeshBoard Grid -->\n @if (isEditMode()) {\n <div class=\"zone-label grid-zone-label\">Grid Zone</div>\n }\n @if (hasGridWidgets()) {\n <kendo-tilelayout\n [columns]=\"displayColumns()\"\n [autoFlow]=\"displayAutoFlow()\"\n [rowHeight]=\"config().rowHeight\"\n [gap]=\"config().gap\"\n [class.has-banners]=\"bannerWidgets().length > 0 && !isEditMode()\"\n [resizable]=\"canEditLayout()\"\n [reorderable]=\"canEditLayout()\"\n (reorder)=\"onReorder($event)\"\n (resize)=\"onResize($event)\">\n @for (placement of displayGridWidgets(); track placement.widget.id) {\n @let widget = placement.widget;\n <kendo-tilelayout-item\n [col]=\"$any(placement.col)\"\n [row]=\"$any(placement.row)\"\n [colSpan]=\"placement.colSpan\"\n [rowSpan]=\"placement.rowSpan\">\n <kendo-tilelayout-item-header>\n <div class=\"widget-header\"\n [class.unconfigured]=\"isWidgetUnconfigured(widget)\"\n [class.chromeless-header]=\"widget.chromeless && !isEditMode()\">\n @if (!(widget.chromeless && !isEditMode())) {\n <span class=\"widget-title\">{{ widget.title }}</span>\n @if (isWidgetUnconfigured(widget)) {\n <span class=\"unconfigured-badge\" title=\"Widget needs configuration\">!</span>\n }\n }\n @if (isEditMode()) {\n <div class=\"widget-actions\" (pointerdown)=\"$event.stopPropagation()\">\n @if (supportsConfiguration(widget)) {\n <button\n kendoButton\n [svgIcon]=\"linkIcon\"\n (click)=\"openWidgetConfig(widget)\"\n fillMode=\"flat\"\n size=\"small\"\n class=\"config-widget-btn\"\n [class.needs-config]=\"isWidgetUnconfigured(widget)\"\n [title]=\"isWidgetUnconfigured(widget) ? 'Configure data source (required)' : 'Configure data source'\"\n [attr.aria-label]=\"(isWidgetUnconfigured(widget) ? 'Configure data source (required) ' : 'Configure data source ') + widget.title\">\n </button>\n }\n <button\n kendoButton\n [svgIcon]=\"gearIcon\"\n (click)=\"openEditWidgetDialog(widget)\"\n fillMode=\"flat\"\n size=\"small\"\n class=\"edit-widget-btn\"\n title=\"Edit position\"\n [attr.aria-label]=\"'Edit position ' + widget.title\">\n </button>\n <button\n kendoButton\n [svgIcon]=\"copyIcon\"\n (click)=\"duplicateWidget(widget)\"\n fillMode=\"flat\"\n size=\"small\"\n class=\"duplicate-widget-btn\"\n title=\"Duplicate widget\"\n [attr.aria-label]=\"'Duplicate widget ' + widget.title\">\n </button>\n <button\n kendoButton\n [svgIcon]=\"trashIcon\"\n (click)=\"removeWidget(widget.id)\"\n fillMode=\"flat\"\n themeColor=\"error\"\n size=\"small\"\n class=\"remove-widget-btn\"\n title=\"Delete widget\"\n [attr.aria-label]=\"'Delete widget ' + widget.title\">\n </button>\n </div>\n }\n </div>\n </kendo-tilelayout-item-header>\n <kendo-tilelayout-item-body>\n @if (getWidgetComponentType(widget.type); as componentType) {\n <ng-container *ngComponentOutlet=\"componentType; inputs: { config: widget }\"></ng-container>\n } @else {\n <div class=\"widget-error\">\n <p>Unknown widget type: {{ widget.type }}</p>\n </div>\n }\n </kendo-tilelayout-item-body>\n </kendo-tilelayout-item>\n }\n </kendo-tilelayout>\n } @else {\n <mm-meshboard-empty-state [readonly]=\"isReadonly()\" (addWidget)=\"openAddWidget()\"></mm-meshboard-empty-state>\n }\n\n <!-- Edit Widget Dialog -->\n @if (showEditWidgetDialog && editingWidget) {\n <mm-edit-widget-dialog\n [widget]=\"editingWidget\"\n [widgets]=\"config().widgets\"\n [maxColumns]=\"config().columns\"\n [gridService]=\"gridService\"\n (save)=\"onEditWidgetSave($event)\"\n (cancelled)=\"closeEditWidgetDialog()\">\n </mm-edit-widget-dialog>\n }\n }\n</div>\n", styles: [":host{display:flex;flex-direction:column;flex:1;min-height:0;height:100%;width:100%}.meshboard-view{display:flex;flex-direction:column;flex:1;min-height:0;width:100%;background-color:var(--kendo-color-surface, #f5f5f5)}.loading-container{display:flex;align-items:center;justify-content:center;height:100%;width:100%}.entity-selector-hint{display:flex;align-items:center;gap:8px;padding:10px 16px;margin-bottom:8px;background:color-mix(in srgb,var(--kendo-color-info, #2196f3) 10%,var(--kendo-color-surface-alt, #fafafa));border:1px solid color-mix(in srgb,var(--kendo-color-info, #2196f3) 40%,transparent);border-radius:4px;font-size:13px;color:var(--kendo-color-on-app-surface, #424242)}.entity-selector-hint strong{color:var(--kendo-color-info, #2196f3)}.variable-resolution-errors{display:flex;flex-wrap:wrap;align-items:center;gap:8px;padding:8px 16px;margin-bottom:8px;background:color-mix(in srgb,var(--kendo-color-error, #f44336) 10%,var(--kendo-color-surface-alt, #fafafa));border:1px solid var(--kendo-color-error, #f44336);border-radius:4px;font-size:13px;color:var(--kendo-color-on-app-surface, #424242)}.variable-resolution-errors strong{color:var(--kendo-color-error, #f44336)}.variable-resolution-errors .error-item{padding:2px 8px;background:color-mix(in srgb,var(--kendo-color-error, #f44336) 8%,transparent);border-radius:3px;font-family:var(--theme-font-mono);font-size:12px}.scope-resolution-errors .error-item{font-family:inherit}.model-unavailable{display:flex;align-items:center;justify-content:center;height:100%;width:100%;padding:2rem;overflow:hidden;box-sizing:border-box}.model-unavailable .message-card{text-align:center;max-width:500px;padding:2rem;background:var(--kendo-color-surface-alt, #fafafa);border:1px solid var(--kendo-color-border, #e0e0e0);border-radius:8px;overflow:hidden}.model-unavailable .message-card kendo-svg-icon{color:var(--kendo-color-warning, #ff9800);margin-bottom:1rem}.model-unavailable .message-card h2{margin:1rem 0;color:var(--kendo-color-on-app-surface, #424242)}.model-unavailable .message-card p{margin:.5rem 0;color:var(--kendo-color-subtle, #757575);line-height:1.5}.model-unavailable .message-card ol{text-align:left;margin:1rem 0;padding-left:1.5rem;color:var(--kendo-color-subtle, #757575);line-height:1.8}.model-unavailable .message-card ol li{margin:.25rem 0}.model-unavailable .message-card ol a{color:var(--kendo-color-primary, #3f51b5);text-decoration:none}.model-unavailable .message-card ol a:hover{text-decoration:underline}.meshboard-toolbar{display:flex;justify-content:space-between;align-items:flex-end;padding:1rem 1.5rem;background-color:var(--kendo-color-surface-alt, white);border-bottom:1px solid var(--kendo-color-border, #e0e0e0);gap:1rem;flex-shrink:0}.meshboard-toolbar .toolbar-left{display:flex;align-items:baseline;gap:1rem;flex:1;min-width:0;align-self:center}.meshboard-toolbar .toolbar-left .meshboard-title{margin:0;font-size:1.5rem;font-weight:500;color:var(--kendo-color-on-app-surface, #212121);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.meshboard-toolbar .toolbar-left .meshboard-description{color:var(--kendo-color-subtle, #757575);font-size:.875rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.meshboard-toolbar .toolbar-center{display:flex;justify-content:center;align-items:flex-end;flex-shrink:0}.meshboard-toolbar .toolbar-center mm-time-range-picker ::ng-deep .time-range-picker{gap:.5rem}.meshboard-toolbar .toolbar-separator{width:1px;align-self:stretch;background-color:var(--kendo-color-border, #e0e0e0);flex-shrink:0}.meshboard-toolbar .toolbar-entity-selectors{display:flex;align-items:flex-end;flex-shrink:0}.meshboard-toolbar .toolbar-right{display:flex;gap:.5rem;align-items:center;flex-shrink:0}.meshboard-toolbar.compact{justify-content:flex-end;align-items:center;padding:.25rem 1.5rem 0;background-color:transparent;border-bottom:none}kendo-tilelayout{padding:1.5rem;overflow:auto;flex:1;min-height:0}.widget-header{display:flex;justify-content:space-between;align-items:center;width:100%;gap:.5rem}.widget-header.unconfigured{background-color:#ff98001a}.widget-header .widget-title{flex:1;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.widget-header .unconfigured-badge{display:inline-flex;align-items:center;justify-content:center;width:18px;height:18px;border-radius:50%;background-color:var(--kendo-color-warning, #ff9800);color:#fff;font-size:12px;font-weight:700;flex-shrink:0}.widget-header .widget-actions{display:flex;align-items:center;gap:.25rem;flex-shrink:0}.widget-header .config-widget-btn,.widget-header .edit-widget-btn,.widget-header .remove-widget-btn{opacity:.6;transition:opacity .2s}.widget-header .config-widget-btn:hover,.widget-header .edit-widget-btn:hover,.widget-header .remove-widget-btn:hover{opacity:1}.widget-header .config-widget-btn.needs-config{color:var(--kendo-color-warning, #ff9800);opacity:1}.widget-header .remove-widget-btn{color:var(--kendo-color-error, #f44336)}.widget-error{display:flex;align-items:center;justify-content:center;height:100%;padding:1rem;color:var(--kendo-color-error, #f44336);text-align:center}.meshboard-view.edit-mode kendo-tilelayout-item:hover{box-shadow:0 0 0 2px var(--kendo-color-primary, #3f51b5)}@media(max-width:768px){.meshboard-toolbar{flex-direction:column;align-items:stretch;gap:.75rem}.meshboard-toolbar .toolbar-left{flex-direction:column;align-items:flex-start;gap:.25rem}.meshboard-toolbar .toolbar-right{flex-wrap:wrap;justify-content:flex-end}.meshboard-toolbar.compact{align-items:flex-end;padding:.25rem 1rem 0}kendo-tilelayout{padding:1rem}}:host ::ng-deep kendo-tilelayout.has-banners{padding-top:0!important}:host ::ng-deep kendo-tilelayout{padding-bottom:1.5rem!important}.widget-header.chromeless-header{display:none}:host ::ng-deep .k-tilelayout-item-header:has(.chromeless-header){display:none}:host ::ng-deep kendo-tilelayout-item:has(.chromeless-header).k-tilelayout-item,:host ::ng-deep kendo-tilelayout-item:has(.chromeless-header).k-card{border:none;box-shadow:none;background:transparent}:host ::ng-deep kendo-tilelayout-item:has(kendo-dialog),:host ::ng-deep kendo-tilelayout-item:has(kendo-window){z-index:9999}.zone-label{font-size:.65rem;opacity:.4;padding:6px 1rem 2px;font-weight:600}.banner-zone-item{margin:0;padding:8px 1rem}.banner-zone-item.banner-edit{border:1px dashed var(--kendo-color-border, rgba(255, 255, 255, .2));border-radius:4px;margin:0 1rem 12px;padding:4px 0}.banner-edit-header{display:flex;align-items:center;justify-content:space-between;padding:4px 8px;opacity:.7}.banner-edit-header .widget-title{font-size:.75rem}.banner-edit-header .widget-actions{display:flex;gap:2px}.meshboard-view.chrome-plain{background:transparent!important}.meshboard-view.chrome-plain .meshboard-toolbar{padding:0 0 var(--theme-space-3, 12px);background:transparent!important;border-bottom:none!important}.meshboard-view.chrome-plain kendo-tilelayout{padding:0!important}.meshboard-view.chrome-plain .banner-zone-item:not(.banner-edit){padding-left:0;padding-right:0}\n"] }]
|
|
35555
37809
|
}], ctorParameters: () => [], propDecorators: { tileLayout: [{
|
|
35556
37810
|
type: ViewChild,
|
|
35557
37811
|
args: [TileLayoutComponent]
|
|
35558
|
-
}], headerMode: [{ type: i0.Input, args: [{ isSignal: true, alias: "headerMode", required: false }] }], chrome: [{ type: i0.Input, args: [{ isSignal: true, alias: "chrome", required: false }] }] } });
|
|
37812
|
+
}], headerMode: [{ type: i0.Input, args: [{ isSignal: true, alias: "headerMode", required: false }] }], chrome: [{ type: i0.Input, args: [{ isSignal: true, alias: "chrome", required: false }] }], timeRange: [{ type: i0.Input, args: [{ isSignal: true, alias: "timeRange", required: false }] }], timeFilterControl: [{ type: i0.Input, args: [{ isSignal: true, alias: "timeFilterControl", required: false }] }] } });
|
|
35559
37813
|
|
|
35560
37814
|
/** English defaults — today's texts. Complete: every member (optional ones too) has a value. */
|
|
35561
37815
|
const DEFAULT_COCKPIT_WIDGET_MESSAGES = {
|
|
@@ -39001,5 +41255,5 @@ function provideCockpitWidgets() {
|
|
|
39001
41255
|
* Generated bundle index. Do not edit.
|
|
39002
41256
|
*/
|
|
39003
41257
|
|
|
39004
|
-
export { ADAPTER_OFFLINE_GRACE_MS, ATTENTION_SEVERITY_ORDER, AdaptersAttentionProvider, AddWidgetDialogComponent, AiInsightsConfigDialogComponent, AiInsightsService, AiInsightsWidgetComponent, AlertBannerConfigDialogComponent, AlertBannerWidgetComponent, AlertListConfigDialogComponent, AlertListWidgetComponent, AssociationsConfigDialogComponent, AttentionListConfigDialogComponent, AttentionListWidgetComponent, AutoRefreshTimerService, BUILT_IN_ATTENTION_PROVIDERS, BarChartConfigDialogComponent, BarChartWidgetComponent, BlueprintUpdatesAttentionProvider, COCKPIT_ADAPTER_LIMIT, COCKPIT_ATTENTION_PROVIDERS, COCKPIT_BLUEPRINT_CATALOG_LIMIT, COCKPIT_CHILD_TENANT_IDS, COCKPIT_DATA_FLOW_LIMIT, COCKPIT_EXPLAIN_HANDLER, COCKPIT_FAILED_EXECUTIONS_OPTIONS, COCKPIT_KPI_GATES, COCKPIT_KPI_SLOW_LOADING_MS, COCKPIT_LINK_RESOLVER, COCKPIT_RECENT_ITEMS, COCKPIT_ROLES, COCKPIT_SERVICE_NAMES, COCKPIT_VERSION_SOURCE, COCKPIT_VIEWER_ACCESS, COCKPIT_WIDGET_MESSAGES, COCKPIT_WIDGET_TYPES, CkModelsResolveFailedAttentionProvider, CockpitAdapterStatesService, CockpitAttentionService, CockpitBlueprintStatusService, CockpitChildTenantsService, CockpitCkModelStatesService, CockpitContextService, CockpitDataFlowExecutionsService, CockpitKpiConfigDialogComponent, CockpitKpiService, CockpitKpiWidgetComponent, CockpitServiceHealthService, DAY_MS, DEFAULT_ATTENTION_MAX_ITEMS, DEFAULT_COCKPIT_WIDGET_MESSAGES, DEFAULT_FAILED_EXECUTIONS_OPTIONS, DEFAULT_MESHBOARD_MESSAGES, DEFAULT_RECENT_ITEMS_MAX, DEFAULT_TIME_ZONE_MODE, DashboardDataService, DashboardGridService, EditModeStateService, EditWidgetDialogComponent, EntityAssociationsWidgetComponent, EntityCardConfigDialogComponent, EntityCardWidgetComponent, EntityDetailDialogComponent, EntitySelectorEditorComponent, EntitySelectorToolbarComponent, FALLBACK_SERIES_COLORS, FailedExecutionsAttentionProvider, FeaturesNotInstalledAttentionProvider, GaugeConfigDialogComponent, GaugeWidgetComponent, HISTOGRAM_HOURS, HOUR_MS, HeatmapConfigDialogComponent, HeatmapWidgetComponent, KPI_ERROR_TEXT, KpiConfigDialogComponent, KpiWidgetComponent, LazyMarkdownComponent, LineChartConfigDialogComponent, LineChartWidgetComponent, MAX_RECENT_ITEMS, MESHBOARD_CHROME_ROUTE_DATA, MESHBOARD_DESCRIPTION_MARKER, MESHBOARD_EDIT_QUERY_PARAM, MESHBOARD_HEADER_MODE_ROUTE_DATA, MESHBOARD_LABEL_RESOLVER, MESHBOARD_MESSAGES, MESHBOARD_OPTIONS, MESHBOARD_TENANT_ID_PROVIDER, MarkdownConfigDialogComponent, MarkdownWidgetComponent, MeshBoardDataService, MeshBoardGridService, MeshBoardManagerDialogComponent, MeshBoardPersistenceService, MeshBoardSettingsDialogComponent, MeshBoardSettingsResult, MeshBoardStateService, MeshBoardViewComponent, NOT_AVAILABLE_TEXT, PieChartConfigDialogComponent, PieChartWidgetComponent, QueryExecutorService, QuerySelectorComponent, RecentItemsConfigDialogComponent, RecentItemsWidgetComponent, RuntimeEntitySelectorComponent, SERVICE_MANAGED_BLUEPRINT_PREFIX, STATE_STATUS_BY_KEY, ServiceHealthConfigDialogComponent, ServiceHealthWidgetComponent, ServicesHealthAttentionProvider, StatsGridConfigDialogComponent, StatsGridWidgetComponent, StatusIndicatorConfigDialogComponent, StatusIndicatorWidgetComponent, StatusListConfigDialogComponent, StatusListWidgetComponent, SummaryCardConfigDialogComponent, SummaryCardWidgetComponent, TableConfigDialogComponent, TableWidgetComponent, TableWidgetDataSourceDirective, UnregisteredPoolsAttentionProvider, WidgetFactoryService, WidgetGroupComponent, WidgetGroupConfigDialogComponent, WidgetNotConfiguredComponent, WidgetRegistryService, adapterKpi, adaptersInError, adaptersOffline, blueprintUpdatesKpi, buildHourlyHistogram, categoryStatus, chartSeriesColors, ckModelKpi, classifyQuery, compareBlueprintVersions, compareMeshBoardNavigation, countFlowExecutions, countLabel, countPipelineExecutions, enabledButNotInstalled, executionKpi, failedExecutionsSeverity, formatCockpitMessage, formatCount, humanizeCategory, isAdapterAtRest, isAdapterExpectedToRun, isAdapterOnline, isCockpitWidgetType, isMeshBoardChrome, isMeshBoardHeaderMode, isServiceConfigured, joinEncodedDescription, kpiKindOf, latestStatisticsUpdate, legacyCockpitLinkTarget, loadNgxMarkdown, nameList, newMeshBoardConfig, normalizeNavigation, observeThemeChanges, pipelineExecutionInputs, plural, provideCockpitWidgetHost, provideCockpitWidgets, provideDefaultWidgets, provideMeshBoard, provideMeshBoardMessages, provideProcessWidget, provideWidgetRegistrations, queryFamily, readMeshBoardNavigation, recentRelativeTime, registerCockpitWidgets, registerDefaultWidgets, registerProcessWidget, resolveChartColor, resolveCockpitWidgetMessages, resolveMeshBoardChrome, resolveMeshBoardHeaderMode, resolveMeshBoardLabel, resolveMeshBoardMessages, selectProviders, serviceStateText, servicesHealthKpi, sortAttentionFindings, sparklineGeometry, splitBlueprintId, splitEncodedDescription, statusColor, summarizeAdapterOnline, tenantCountKpi, toBlueprintStatus, toServiceHealthStatus, truncationNote, versionKpi, withMeshBoardNavigation };
|
|
41258
|
+
export { ADAPTER_OFFLINE_GRACE_MS, ASSOCIATION_HOP_SOURCE_BATCH, ASSOCIATION_HOP_TARGETS_PER_SOURCE, ATTENTION_SEVERITY_ORDER, AdaptersAttentionProvider, AddWidgetDialogComponent, AiInsightsConfigDialogComponent, AiInsightsService, AiInsightsWidgetComponent, AlertBannerConfigDialogComponent, AlertBannerWidgetComponent, AlertListConfigDialogComponent, AlertListWidgetComponent, AssociationsConfigDialogComponent, AttentionListConfigDialogComponent, AttentionListWidgetComponent, AutoRefreshTimerService, BUILT_IN_ATTENTION_PROVIDERS, BarChartConfigDialogComponent, BarChartWidgetComponent, BlueprintUpdatesAttentionProvider, COCKPIT_ADAPTER_LIMIT, COCKPIT_ATTENTION_PROVIDERS, COCKPIT_BLUEPRINT_CATALOG_LIMIT, COCKPIT_CHILD_TENANT_IDS, COCKPIT_DATA_FLOW_LIMIT, COCKPIT_EXPLAIN_HANDLER, COCKPIT_FAILED_EXECUTIONS_OPTIONS, COCKPIT_KPI_GATES, COCKPIT_KPI_SLOW_LOADING_MS, COCKPIT_LINK_RESOLVER, COCKPIT_RECENT_ITEMS, COCKPIT_ROLES, COCKPIT_SERVICE_NAMES, COCKPIT_VERSION_SOURCE, COCKPIT_VIEWER_ACCESS, COCKPIT_WIDGET_MESSAGES, COCKPIT_WIDGET_TYPES, CkModelsResolveFailedAttentionProvider, CockpitAdapterStatesService, CockpitAttentionService, CockpitBlueprintStatusService, CockpitChildTenantsService, CockpitCkModelStatesService, CockpitContextService, CockpitDataFlowExecutionsService, CockpitKpiConfigDialogComponent, CockpitKpiService, CockpitKpiWidgetComponent, CockpitServiceHealthService, DAY_MS, DEFAULT_ATTENTION_MAX_ITEMS, DEFAULT_COCKPIT_WIDGET_MESSAGES, DEFAULT_FAILED_EXECUTIONS_OPTIONS, DEFAULT_MESHBOARD_MESSAGES, DEFAULT_RECENT_ITEMS_MAX, DEFAULT_TIME_ZONE_MODE, DashboardDataService, DashboardGridService, EditModeStateService, EditWidgetDialogComponent, EntityAssociationsWidgetComponent, EntityCardConfigDialogComponent, EntityCardWidgetComponent, EntityDetailDialogComponent, EntitySelectorEditorComponent, EntitySelectorScopePathEditorComponent, EntitySelectorToolbarComponent, FALLBACK_SERIES_COLORS, FailedExecutionsAttentionProvider, FeaturesNotInstalledAttentionProvider, GaugeConfigDialogComponent, GaugeWidgetComponent, HISTOGRAM_HOURS, HOUR_MS, HeatmapConfigDialogComponent, HeatmapWidgetComponent, KPI_ERROR_TEXT, KpiConfigDialogComponent, KpiWidgetComponent, LazyMarkdownComponent, LineChartConfigDialogComponent, LineChartWidgetComponent, MAX_RECENT_ITEMS, MAX_SCOPE_HOPS, MESHBOARD_CHROME_ROUTE_DATA, MESHBOARD_DESCRIPTION_MARKER, MESHBOARD_EDIT_QUERY_PARAM, MESHBOARD_HEADER_MODE_ROUTE_DATA, MESHBOARD_LABEL_RESOLVER, MESHBOARD_LANGUAGE, MESHBOARD_MESSAGES, MESHBOARD_OPTIONS, MESHBOARD_TENANT_ID_PROVIDER, MarkdownConfigDialogComponent, MarkdownWidgetComponent, MeshBoardDataService, MeshBoardGridService, MeshBoardManagerDialogComponent, MeshBoardPersistenceService, MeshBoardSettingsDialogComponent, MeshBoardSettingsResult, MeshBoardStateService, MeshBoardViewComponent, NOT_AVAILABLE_TEXT, PieChartConfigDialogComponent, PieChartWidgetComponent, QueryExecutorService, QuerySelectorComponent, RecentItemsConfigDialogComponent, RecentItemsWidgetComponent, RuntimeEntitySelectorComponent, SERVICE_MANAGED_BLUEPRINT_PREFIX, STATE_STATUS_BY_KEY, ServiceHealthConfigDialogComponent, ServiceHealthWidgetComponent, ServicesHealthAttentionProvider, StatsGridConfigDialogComponent, StatsGridWidgetComponent, StatusIndicatorConfigDialogComponent, StatusIndicatorWidgetComponent, StatusListConfigDialogComponent, StatusListWidgetComponent, SummaryCardConfigDialogComponent, SummaryCardWidgetComponent, TableConfigDialogComponent, TableWidgetComponent, TableWidgetDataSourceDirective, UnregisteredPoolsAttentionProvider, WidgetFactoryService, WidgetGroupComponent, WidgetGroupConfigDialogComponent, WidgetNotConfiguredComponent, WidgetRegistryService, adapterKpi, adaptersInError, adaptersOffline, blueprintUpdatesKpi, buildHourlyHistogram, categoryStatus, chartSeriesColors, ckModelKpi, classifyQuery, compareBlueprintVersions, compareMeshBoardNavigation, countFlowExecutions, countLabel, countPipelineExecutions, emptyQueryExecutionResult, enabledButNotInstalled, executionKpi, failedExecutionsSeverity, formatCockpitMessage, formatCount, humanizeCategory, isAdapterAtRest, isAdapterExpectedToRun, isAdapterOnline, isCockpitWidgetType, isMeshBoardChrome, isMeshBoardHeaderMode, isServiceConfigured, joinEncodedDescription, kpiKindOf, latestStatisticsUpdate, legacyCockpitLinkTarget, loadNgxMarkdown, nameList, newMeshBoardConfig, normalizeNavigation, observeThemeChanges, parseHostTimeRange, pickLocalizedText, pipelineExecutionInputs, plural, provideCockpitWidgetHost, provideCockpitWidgets, provideDefaultWidgets, provideMeshBoard, provideMeshBoardLanguage, provideMeshBoardMessages, provideProcessWidget, provideWidgetRegistrations, queryFamily, readMeshBoardNavigation, recentRelativeTime, registerCockpitWidgets, registerDefaultWidgets, registerProcessWidget, resolveChartColor, resolveCockpitWidgetMessages, resolveMeshBoardChrome, resolveMeshBoardHeaderMode, resolveMeshBoardLabel, resolveMeshBoardMessages, selectProviders, serviceStateText, servicesHealthKpi, sortAttentionFindings, sparklineGeometry, splitBlueprintId, splitEncodedDescription, statusColor, summarizeAdapterOnline, tenantCountKpi, toBlueprintStatus, toServiceHealthStatus, truncationNote, versionKpi, withMeshBoardNavigation };
|
|
39005
41259
|
//# sourceMappingURL=meshmakers-octo-meshboard.mjs.map
|