@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.
@@ -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, TimeRangePickerComponent, ConfirmationService, ImportStrategyDialogService, HAS_UNSAVED_CHANGES, UnsavedChangesDirective } from '@meshmakers/shared-ui';
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
- childScope: es.childScope
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 cache of the source rtIds resolved from each entity selector's
2429
- * current selection (keyed by selector id). Populated by the view component
2430
- * when a selection resolves (one-hop childScope traversal, or the picked
2431
- * entity's own rtId). Not persisted — derived fresh from each selection.
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
- _entitySelectorRtIds = signal({}, /* @ts-ignore */
2434
- ...(ngDevMode ? [{ debugName: "_entitySelectorRtIds" }] : /* istanbul ignore next */ []));
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 || !config.selection) {
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-hop childScope traversal, or the picked entity's own rtId when the
3111
- * selector has no childScope). Transient — not persisted.
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._entitySelectorRtIds.update(map => ({ ...map, [selectorId]: rtIds }));
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 cached source rtIds for an entity selector (e.g. when its
3118
- * selection is cleared).
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._entitySelectorRtIds.update(map => {
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
- * Resolves the stream-data `rtIds` scope for a persistent-query widget bound
3132
- * to an entity selector. Returns the source rtIds cached from the selector's
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
- resolveStreamDataRtIds(entitySelectorId) {
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 undefined;
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
- const rtIds = this._entitySelectorRtIds()[entitySelectorId];
3141
- return rtIds && rtIds.length > 0 ? rtIds : undefined;
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 ?? undefined
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 arg = options.streamDataArgs ? this.buildStreamDataArg(options.streamDataArgs) : undefined;
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 stream-data persistent-query widgets.
7077
+ * Per-widget asset-scope binding for persistent-query widgets — stream-data and
7078
+ * runtime queries (AB#7725).
6767
7079
  *
6768
- * Stream-data archives are keyed by their source rtIds, so a widget is scoped
6769
- * to a picked asset by passing those source rtIds as `streamDataArgs.rtIds`
6770
- * (see `MeshBoardStateService.resolveStreamDataRtIds` and the backend
6771
- * `StreamDataArguments.rtIds` override). This control lets the designer bind a
6772
- * single widget to one of the MeshBoard's entity selectors: when the selector's
6773
- * entity is picked, its resolved source rtIds (the selector's `childScope`
6774
- * one-hop, or the picked entity itself) scope this widget's query.
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 only when the selected query is a stream-data query
6777
- * (`family === 'streamData'`); for runtime queries the binding has no effect and
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 only for 'streamData'. */
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
- Scopes this widget's stream-data query to the rtIds resolved from the
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
- Scopes this widget's stream-data query to the rtIds resolved from the
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
- if (typeof value !== 'string' || value === '') {
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
- return this.config.columns.map(col => withDateFormatter({
10465
- field: col.field,
10466
- displayName: col.title,
10467
- dataType: (col.dataType ?? 'text'),
10468
- width: col.width,
10469
- ...(col.format ? { format: col.format } : {}),
10470
- ...(col.statusMapping ? { statusMapping: resolveStatusMapping(col.statusMapping) } : {})
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
- onSave() {
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
- const columns = this.selectedColumns.map(field => ({
10840
- field,
10841
- title: this.formatColumnTitle(field),
10842
- width: undefined
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 family = queryFamily(this.selectedPersistentQuery.ckTypeId) ?? this.initialQueryFamily ?? undefined;
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: [], // Will be derived from query
11655
+ columns,
10874
11656
  sorting: [],
10875
11657
  filters: queryFilterDtos,
10876
- queryRtId: this.selectedPersistentQuery.rtId,
10877
- queryName: this.selectedPersistentQuery.name,
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 = await firstValueFrom(this.queryExecutor.execute(queryDataSource.queryFamily, queryDataSource.queryRtId, {
14838
- fieldFilter: fieldFilter ?? undefined,
14839
- streamDataArgs
14840
- }).pipe(catchError(err => {
14841
- console.error('Error loading Bar Chart data:', err);
14842
- throw err;
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 = await firstValueFrom(this.queryExecutor.execute(queryDataSource.queryFamily, queryDataSource.queryRtId, {
17971
- fieldFilter: fieldFilter ?? undefined,
17972
- streamDataArgs
17973
- }).pipe(catchError(err => {
17974
- console.error('Error loading Heatmap data:', err);
17975
- throw err;
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
- <!-- Stream-data-only options (hidden for runtime queries) -->
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
- <!-- Stream-data-only options (hidden for runtime queries) -->
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
- this._isLoading.set(false);
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: [], // Will be derived from query at runtime
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): optional one-hop traversal resolving the picked
32138
- // entity to the child source rtIds a stream-data widget scopes by.
32139
- editChildScopeTargetCkTypeId = '';
32140
- editChildScopeTargetCkTypeItem = null;
32141
- editChildScopeRoleId = '';
32142
- // Parent → children via System/ParentChild is reached INBOUND (the child holds
32143
- // the association to its parent), so 'in' is the correct default for a
32144
- // childScope that resolves a picked parent to its children.
32145
- editChildScopeDirection = 'in';
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.editChildScopeTargetCkTypeId = '';
32162
- this.editChildScopeTargetCkTypeItem = null;
32163
- this.editChildScopeRoleId = 'System/ParentChild';
32164
- this.editChildScopeDirection = 'in';
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.editChildScopeTargetCkTypeId = selector.childScope?.targetCkTypeId ?? '';
32190
- this.editChildScopeTargetCkTypeItem = selector.childScope?.targetCkTypeId
32191
- ? {
32192
- fullName: selector.childScope.targetCkTypeId,
32193
- rtCkTypeId: selector.childScope.targetCkTypeId,
32194
- isAbstract: false,
32195
- isFinal: false
32196
- }
32197
- : null;
32198
- this.editChildScopeRoleId = selector.childScope?.roleId ?? 'System/ParentChild';
32199
- this.editChildScopeDirection = selector.childScope?.direction ?? 'in';
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 and default entity when CK type changes
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
- * Handles child-scope target CK type selection.
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
- * Handles child-scope target CK type cleared.
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
- // Only persist a childScope when both the target type and role are set;
32285
- // otherwise leave it undefined (the picked entity itself is the scope).
32286
- const childScope = this.editChildScopeTargetCkTypeId && this.editChildScopeRoleId.trim()
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
- this.editMappings.length > 0 &&
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
- if (!this.editShowInToolbar && !this.editDefaultRtId.trim()) {
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
- this.defaultEntityDataSource = new RuntimeEntitySelectDataSource(this.getEntitiesByCkTypeGQL, this.editCkTypeId);
32409
- this.defaultEntityDialogDataSource = new RuntimeEntityDialogDataSource(this.getEntitiesByCkTypeGQL, this.editCkTypeId);
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_&lt;id&gt;=&lt;rtId&gt;)</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\">&rarr;</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_&lt;id&gt;=&lt;rtId&gt;)</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\">&rarr;</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_&lt;id&gt;=&lt;rtId&gt;)</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\">&rarr;</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
- }], propDecorators: { entitySelectors: [{
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_&lt;id&gt;=&lt;rtId&gt;)</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\">&rarr;</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
- // Reuse cached data source if same CK type
32494
- if (!this.dataSourceCache.has(selector.ckTypeId)) {
32495
- this.dataSourceCache.set(selector.ckTypeId, new RuntimeEntitySelectDataSource(this.getEntitiesByCkTypeGQL, selector.ckTypeId));
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
- if (!this.dialogDataSourceCache.has(selector.ckTypeId)) {
32498
- this.dialogDataSourceCache.set(selector.ckTypeId, new RuntimeEntityDialogDataSource(this.getEntitiesByCkTypeGQL, selector.ckTypeId));
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.showInToolbar !== false) {\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]=\"'Select ' + selector.label\"\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 });
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.showInToolbar !== false) {\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]=\"'Select ' + selector.label\"\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"] }]
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
- hasEntitySelectors = computed(() => !this.hideEntitySelectors() && (this.entitySelectorsConfig()?.some(es => es.showInToolbar !== false) ?? false), /* @ts-ignore */
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.showInToolbar !== false && !es.selectedRtId);
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
- /** The active time-filter selection to mirror into the URL, or null when the filter is off. */
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
- const urlSelection = this.readTimeFilterFromUrl();
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.stateService.triggerRefresh();
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
- if (!selector)
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
- // Clear selection state
35351
- this.stateService.updateEntitySelectorSelection(selectorId, undefined);
35352
- // Clear variables for this selector
35353
- this.stateService.clearEntitySelectorVariables(selectorId);
35354
- // Clear the resolved stream-data scope rtIds for this selector
35355
- this.stateService.clearEntitySelectorRtIds(selectorId);
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
- // Priority: route data > URL params > defaultRtId
35374
- const rtId = routeData?.[selector.id]
35375
- ?? urlSelections.get(selector.id)
35376
- ?? selector.defaultRtId;
35377
- if (rtId) {
35378
- const displayName = await this.resolveEntitySelectorVariables(selector, rtId);
35379
- this.stateService.updateEntitySelectorSelection(selector.id, rtId, displayName);
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: selector.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
- const values = selector.attributeMappings.map(mapping => {
35398
- const attr = entity.attributes.find(a => a.attributeName === mapping.attributePath);
35399
- return {
35400
- name: mapping.variableName,
35401
- value: attr ? String(attr.value ?? '') : '',
35402
- type: mapping.attributeValueType
35403
- ? this.variableService.mapAttributeTypeToVariableType(mapping.attributeValueType)
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
- return entity.rtDisplayName || entity.rtWellKnownName || entity.rtId;
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
- * When the selector defines a {@link EntitySelectorChildScope}, the picked
35448
- * entity's children of `targetCkTypeId` reached via `roleId`/`direction`
35449
- * become the scope (e.g. MeteringPoint → its EnergyMeasurement rtIds). With
35450
- * no childScope, the picked entity's own rtId is the scope (direct keying).
35451
- */
35452
- async resolveEntitySelectorScopeRtIds(selector, rtId) {
35453
- const childScope = selector.childScope;
35454
- if (!childScope?.targetCkTypeId || !childScope.roleId) {
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
- const targets = await firstValueFrom(this.dataService.fetchAssociationTargets(rtId, selector.ckTypeId, childScope.targetCkTypeId, childScope.roleId,
35460
- // Parent → children via System/ParentChild is an INBOUND hop (the child
35461
- // owns the association to its parent), so 'in' is the default.
35462
- childScope.direction ?? 'in'));
35463
- const childRtIds = (targets ?? [])
35464
- .map(t => String(t.rtId))
35465
- .filter(id => id.length > 0);
35466
- this.stateService.setEntitySelectorRtIds(selector.id, childRtIds);
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
- // Empty scope is safer than a stale one: the bound widget falls back to
35471
- // its persisted scope rather than querying the wrong asset.
35472
- this.stateService.setEntitySelectorRtIds(selector.id, []);
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