@sinequa/atomic-angular 1.19.2 → 1.19.4

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.
@@ -7,7 +7,7 @@ import highlightWords from 'highlight-words';
7
7
  import { ActivatedRoute, Router, NavigationEnd, RouterLink, RouterLinkActive, RouterModule } from '@angular/router';
8
8
  import { withDevtools } from '@angular-architects/ngrx-toolkit';
9
9
  import { signalStore, signalStoreFeature, withState, withMethods, patchState, getState, withComputed } from '@ngrx/signals';
10
- import { EngineType, fetchApp, extraColumns, sysLang, fetchPrincipal, globalConfig, filter, getQueryParamsFromUrl, isRedirectPending, info, clearSessionTokens, login, error, setGlobalConfig, initializeAppConfig, notify, addConcepts, queryParamsFromUrl, patchUserSettings, deleteUserSettings, fetchUserSettings, warn, buildPathsAndLevels, escapeExpr, fetchQuery, flattenLegacyFilterValues, isAuthenticated, isExpired, debug, AuditEventType, fetchSuggest, isObject, Audit, getMetadata, bisect, isNotInputEvent, fetchSponsoredLinks, translateAggregationToDateOptions, aggItemRegex, parseValueAndOperatorFromItem, fetchSuggestField, fetchSimilarDocuments, logout, fetchChangePassword, fetchSendPasswordResetEmail, expiresSoon, suggestionsToTreeAggregationNodes, labels, fetchLabels, guid, getRelativeDate, createUserProfile, deleteUserProfileProperty, patchUserProfile, isJsonable, addAuditAdditionalInfo, getToken, setToken, createHeaders } from '@sinequa/atomic';
10
+ import { EngineType, fetchApp, extraColumns, sysLang, fetchPrincipal, globalConfig, filter, getQueryParamsFromUrl, isRedirectPending, info, clearSessionTokens, login, error, setGlobalConfig, initializeAppConfig, notify, addConcepts, queryParamsFromUrl, patchUserSettings, deleteUserSettings, fetchUserSettings, warn, buildPathsAndLevels, escapeExpr, fetchQuery, flattenLegacyFilterValues, isAuthenticated, isExpired, debug, AuditEventType, fetchSuggest, Audit, isObject, getMetadata, bisect, isNotInputEvent, fetchSponsoredLinks, translateAggregationToDateOptions, aggItemRegex, parseValueAndOperatorFromItem, fetchSuggestField, fetchSimilarDocuments, logout, fetchChangePassword, fetchSendPasswordResetEmail, expiresSoon, suggestionsToTreeAggregationNodes, labels, fetchLabels, guid, getRelativeDate, createUserProfile, deleteUserProfileProperty, patchUserProfile, isJsonable, addAuditAdditionalInfo, getToken, setToken, createHeaders } from '@sinequa/atomic';
11
11
  import { takeUntilDestroyed, toObservable, toSignal } from '@angular/core/rxjs-interop';
12
12
  import { DatePipe, DATE_PIPE_DEFAULT_TIMEZONE, DATE_PIPE_DEFAULT_OPTIONS, Location, NgTemplateOutlet, NgStyle, NgClass, NgComponentOutlet } from '@angular/common';
13
13
  import { HttpClient, HttpParams, httpResource, HttpResponse, HttpContextToken, HttpHeaders } from '@angular/common/http';
@@ -3262,7 +3262,12 @@ class QueryService {
3262
3262
  };
3263
3263
  return r;
3264
3264
  }), tap(response => {
3265
- this.result.set(response);
3265
+ // An "open" query (expanding a tree aggregation node) only fetches the node's
3266
+ // children: its response is not the page's search result (no tabs, no records...).
3267
+ // Publishing it would wipe the state derived from `result` (e.g. the tab counts).
3268
+ if (query.action !== "open") {
3269
+ this.result.set(response);
3270
+ }
3266
3271
  info("queryService.getResults success - data: ", response);
3267
3272
  }));
3268
3273
  }
@@ -4404,116 +4409,6 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.29", ngImpo
4404
4409
  }]
4405
4410
  }] });
4406
4411
 
4407
- class JsonMethodPluginService {
4408
- http = inject(HttpClient);
4409
- API_URL = `${globalConfig.backendUrl}/api/v1`;
4410
- /**
4411
- * Call a JsonMethod plugin using an HTTP POST
4412
- *
4413
- * @param method The name of the JsonMethod plugin
4414
- * @param query Parameters to pass to the plugin
4415
- * @param options HTTP options for the request
4416
- * @returns An observable of the plugin's return value
4417
- */
4418
- post(method, query, options) {
4419
- if (!isObject(query)) {
4420
- return throwError(() => ({ error: 'invalid query object' }));
4421
- }
4422
- return this.http.post(`${this.API_URL}/plugin/${method}`, query, options);
4423
- }
4424
- /**
4425
- * Call a JsonMethod plugin using an HTTP GET
4426
- *
4427
- * @param method The name of the JsonMethod plugin
4428
- * @param query Parameters to pass to the plugin
4429
- * @param options HTTP options for the request
4430
- * @returns An observable of the plugin's return value
4431
- */
4432
- get(method, query, options) {
4433
- return this.http.get(`${this.API_URL}/plugin/${method}`, {
4434
- params: query,
4435
- ...options
4436
- });
4437
- }
4438
- static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.29", ngImport: i0, type: JsonMethodPluginService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
4439
- static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.3.29", ngImport: i0, type: JsonMethodPluginService, providedIn: 'root' });
4440
- }
4441
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.29", ngImport: i0, type: JsonMethodPluginService, decorators: [{
4442
- type: Injectable,
4443
- args: [{
4444
- providedIn: 'root'
4445
- }]
4446
- }] });
4447
-
4448
- /**
4449
- * The route an audit event names: the path, without its leading slash, query string or fragment.
4450
- * `/search?q=irsn#p2` and `/search` are the same route — arriving on one from the other is a new
4451
- * query, not a navigation.
4452
- */
4453
- function routeNameOf(url) {
4454
- return url.slice(1).split(/[?#]/)[0];
4455
- }
4456
- class NavigationService {
4457
- router = inject(Router);
4458
- auditService = inject(AuditService);
4459
- // The route of the previous navigation, used only to dedupe audit route-change notifications in
4460
- // navigationEnd$ below. Kept separate from urlAfterNavigation so that getter stays correct
4461
- // regardless of whether anything subscribes to navigationEnd$.
4462
- //
4463
- // It used to hold the full url (`/search?q=irsn`) while the comparison was against the bare route
4464
- // name (`search`): the two could never be equal, so the dedupe never fired and every navigation
4465
- // was audited — including the query-param changes that make up most of a search session.
4466
- previousRouteName = null;
4467
- /**
4468
- * The URL of the current navigation, used to redirect the user after login.
4469
- *
4470
- * Sourced directly from `Router.url`, which Angular keeps up to date on every navigation with no
4471
- * subscription required — unlike a field updated as a side effect of `navigationEnd$` below,
4472
- * which only happens for whoever actually subscribes to it.
4473
- *
4474
- * The authentication screens are never returned: sending someone back to `/logout` after they
4475
- * signed in defeats the sign-in, and back to `/login` loops. The exclusion used to be a literal
4476
- * `!== "/login"`, which let through `/logout`, `/login?mode=changepassword` — same screen, one
4477
- * query param — and any application whose `loginPath` is not spelled `/login`.
4478
- */
4479
- get urlAfterNavigation() {
4480
- const url = this.router.url;
4481
- return isAuthRoute(url, globalConfig.loginPath) ? null : url;
4482
- }
4483
- /**
4484
- * Observable that emits events of type `NavigationEnd` from the Angular Router.
4485
- *
4486
- * This observable performs the following operations:
4487
- * - Maps all router events to `RouterEvent`.
4488
- * - Filters the events to only include instances of `NavigationEnd`.
4489
- * - Taps into the event stream to notify the audit service when the route itself changes — a new
4490
- * query, a filter or another page of results stays on the same route and is not one.
4491
- * - Shares the replayed value with a buffer size of 1 to ensure subscribers receive the latest emitted value.
4492
- *
4493
- * @type Observable<RouterEvent>
4494
- */
4495
- navigationEnd$ = this.router.events.pipe(map(event => event), filter$1((event) => event instanceof NavigationEnd), tap(event => {
4496
- const routeName = routeNameOf(event.url);
4497
- // The empty name is the application root, which has never been audited.
4498
- if (routeName && routeName !== this.previousRouteName) {
4499
- this.auditService.notifyRouteChange(routeName);
4500
- }
4501
- // Every route is remembered, the authentication screens included. Skipping them would swallow
4502
- // the return: leaving `/search` for `/login` and coming back is two route changes, and the
4503
- // second one would compare against a `search` the user has meanwhile left. (The exclusion that
4504
- // used to sit here never ran — the dedupe above it could not fire.)
4505
- this.previousRouteName = routeName;
4506
- }), shareReplay(1));
4507
- static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.29", ngImport: i0, type: NavigationService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
4508
- static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.3.29", ngImport: i0, type: NavigationService, providedIn: "root" });
4509
- }
4510
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.29", ngImport: i0, type: NavigationService, decorators: [{
4511
- type: Injectable,
4512
- args: [{
4513
- providedIn: "root"
4514
- }]
4515
- }] });
4516
-
4517
4412
  // pre-configured highlights's colors
4518
4413
  const HIGHLIGHTS = new InjectionToken('highlights', {
4519
4414
  factory: () => [
@@ -4799,17 +4694,45 @@ class PreviewService {
4799
4694
  * @returns the html id
4800
4695
  */
4801
4696
  getEntityId(entity, value, index) {
4802
- if (!this.previewData)
4803
- return undefined;
4804
- // Combine filters for better performance.
4805
- // NB: positionInCategories[entity] is a zero-based index, so the first occurrence is 0.
4806
- // Use a presence check (not truthiness) to avoid dropping that first occurrence.
4807
- const entitySearched = this.previewData.highlightsPerLocation
4808
- .filter(item => item.positionInCategories[entity] !== undefined && item.values.some(v => v === value))
4809
- .map(e => ({ id: e.positionInCategories[entity] }));
4810
- const id = entitySearched[index]?.id;
4811
- return id;
4697
+ return this.getEntityLocations(entity, value)[index];
4698
+ }
4699
+ /**
4700
+ * Get the ids of every occurrence of an entity value that is actually highlighted in the preview,
4701
+ * ordered by position in the document.
4702
+ *
4703
+ * This is the source of truth for entity navigation: the `count` carried by the indexed record can be
4704
+ * greater than the number of occurrences located in the converted document (ES-32245).
4705
+ *
4706
+ * @param entity the entity type (i.e. "geo")
4707
+ * @param value the entity value (i.e. "AUSTRALIA")
4708
+ * @returns the html ids (`positionInCategories[entity]`), empty when nothing is highlighted
4709
+ */
4710
+ getEntityLocations(entity, value) {
4711
+ const data = this.previewData;
4712
+ if (!data)
4713
+ return [];
4714
+ let perEntity = this.entityLocationsCache.get(data);
4715
+ if (!perEntity) {
4716
+ perEntity = new Map();
4717
+ this.entityLocationsCache.set(data, perEntity);
4718
+ }
4719
+ const key = `${entity}\u0000${value}`;
4720
+ let ids = perEntity.get(key);
4721
+ if (!ids) {
4722
+ ids = [];
4723
+ for (const item of data.highlightsPerLocation) {
4724
+ // NB: positionInCategories[entity] is a zero-based index, so the first occurrence is 0.
4725
+ // Use a presence check (not truthiness) to avoid dropping that first occurrence.
4726
+ const position = item.positionInCategories[entity];
4727
+ if (position !== undefined && item.values.some(v => v === value))
4728
+ ids.push(position);
4729
+ }
4730
+ perEntity.set(key, ids);
4731
+ }
4732
+ return ids;
4812
4733
  }
4734
+ /** Memoized `getEntityLocations` results, dropped with the `PreviewData` they were computed from. */
4735
+ entityLocationsCache = new WeakMap();
4813
4736
  /**
4814
4737
  * Send a message to the prewiew iFrame with the required data to retrieve HTML content for a specific highlight category
4815
4738
  *
@@ -4973,6 +4896,171 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.29", ngImpo
4973
4896
  }]
4974
4897
  }], ctorParameters: () => [{ type: i0.DestroyRef }] });
4975
4898
 
4899
+ /**
4900
+ * Cursor over the occurrences of an entity value highlighted in the preview.
4901
+ *
4902
+ * The number of occurrences is the one actually located in the preview (`PreviewService.getEntityLocations`),
4903
+ * not the `count` of the indexed record, which can be greater (ES-32245).
4904
+ */
4905
+ class EntityNavigationService {
4906
+ previewService = inject(PreviewService);
4907
+ lastPreviewData;
4908
+ /** The current position, shared by the entities panels */
4909
+ navigation = signal(undefined, ...(ngDevMode ? [{ debugName: "navigation" }] : []));
4910
+ /** The value hovered in the panel */
4911
+ hovering = signal(undefined, ...(ngDevMode ? [{ debugName: "hovering" }] : []));
4912
+ /** The 1-based position of the hovered value, `0` when it is not the one being navigated */
4913
+ hoverIndex = computed(() => (this.navigation()?.value === this.hovering() ? (this.navigation()?.index ?? 0) : 0), ...(ngDevMode ? [{ debugName: "hoverIndex" }] : []));
4914
+ /**
4915
+ * Number of occurrences of `value` highlighted in the preview.
4916
+ * `fallback` (the indexed count) is only used while no preview is loaded.
4917
+ */
4918
+ total(entity, value, fallback = 0) {
4919
+ if (!this.previewService.previewData)
4920
+ return fallback;
4921
+ return this.previewService.getEntityLocations(entity, value).length;
4922
+ }
4923
+ navigate(entity, value, direction) {
4924
+ const ids = this.previewService.getEntityLocations(entity, value);
4925
+ const total = ids.length;
4926
+ if (total === 0) {
4927
+ warn(`No highlighted occurrence for entity ${entity}:`, value);
4928
+ notify.warning(`No occurrence of "${value}" can be reached in the document.`);
4929
+ return;
4930
+ }
4931
+ // The cursor belongs to one document: start over when another preview has been loaded
4932
+ if (this.lastPreviewData !== this.previewService.previewData) {
4933
+ this.lastPreviewData = this.previewService.previewData;
4934
+ this.navigation.set(undefined);
4935
+ }
4936
+ const current = this.navigation();
4937
+ // Start from the first (next) or last (prev) occurrence when the value changes
4938
+ const position = current?.value === value ? current.index : direction === "next" ? 0 : 1;
4939
+ const index = ((position - 1 + (direction === "next" ? 1 : -1) + total) % total) + 1;
4940
+ this.navigation.set({ value, index });
4941
+ const id = ids[index - 1];
4942
+ this.previewService.events.set("scrollTo");
4943
+ this.previewService.sendMessage({ action: "select", id: `${entity}_${id}`, usePassageHighlighter: false });
4944
+ debug(`${direction} navigation:`, { id, entity, value, index, total });
4945
+ }
4946
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.29", ngImport: i0, type: EntityNavigationService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
4947
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.3.29", ngImport: i0, type: EntityNavigationService, providedIn: "root" });
4948
+ }
4949
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.29", ngImport: i0, type: EntityNavigationService, decorators: [{
4950
+ type: Injectable,
4951
+ args: [{ providedIn: "root" }]
4952
+ }] });
4953
+
4954
+ class JsonMethodPluginService {
4955
+ http = inject(HttpClient);
4956
+ API_URL = `${globalConfig.backendUrl}/api/v1`;
4957
+ /**
4958
+ * Call a JsonMethod plugin using an HTTP POST
4959
+ *
4960
+ * @param method The name of the JsonMethod plugin
4961
+ * @param query Parameters to pass to the plugin
4962
+ * @param options HTTP options for the request
4963
+ * @returns An observable of the plugin's return value
4964
+ */
4965
+ post(method, query, options) {
4966
+ if (!isObject(query)) {
4967
+ return throwError(() => ({ error: 'invalid query object' }));
4968
+ }
4969
+ return this.http.post(`${this.API_URL}/plugin/${method}`, query, options);
4970
+ }
4971
+ /**
4972
+ * Call a JsonMethod plugin using an HTTP GET
4973
+ *
4974
+ * @param method The name of the JsonMethod plugin
4975
+ * @param query Parameters to pass to the plugin
4976
+ * @param options HTTP options for the request
4977
+ * @returns An observable of the plugin's return value
4978
+ */
4979
+ get(method, query, options) {
4980
+ return this.http.get(`${this.API_URL}/plugin/${method}`, {
4981
+ params: query,
4982
+ ...options
4983
+ });
4984
+ }
4985
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.29", ngImport: i0, type: JsonMethodPluginService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
4986
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.3.29", ngImport: i0, type: JsonMethodPluginService, providedIn: 'root' });
4987
+ }
4988
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.29", ngImport: i0, type: JsonMethodPluginService, decorators: [{
4989
+ type: Injectable,
4990
+ args: [{
4991
+ providedIn: 'root'
4992
+ }]
4993
+ }] });
4994
+
4995
+ /**
4996
+ * The route an audit event names: the path, without its leading slash, query string or fragment.
4997
+ * `/search?q=irsn#p2` and `/search` are the same route — arriving on one from the other is a new
4998
+ * query, not a navigation.
4999
+ */
5000
+ function routeNameOf(url) {
5001
+ return url.slice(1).split(/[?#]/)[0];
5002
+ }
5003
+ class NavigationService {
5004
+ router = inject(Router);
5005
+ auditService = inject(AuditService);
5006
+ // The route of the previous navigation, used only to dedupe audit route-change notifications in
5007
+ // navigationEnd$ below. Kept separate from urlAfterNavigation so that getter stays correct
5008
+ // regardless of whether anything subscribes to navigationEnd$.
5009
+ //
5010
+ // It used to hold the full url (`/search?q=irsn`) while the comparison was against the bare route
5011
+ // name (`search`): the two could never be equal, so the dedupe never fired and every navigation
5012
+ // was audited — including the query-param changes that make up most of a search session.
5013
+ previousRouteName = null;
5014
+ /**
5015
+ * The URL of the current navigation, used to redirect the user after login.
5016
+ *
5017
+ * Sourced directly from `Router.url`, which Angular keeps up to date on every navigation with no
5018
+ * subscription required — unlike a field updated as a side effect of `navigationEnd$` below,
5019
+ * which only happens for whoever actually subscribes to it.
5020
+ *
5021
+ * The authentication screens are never returned: sending someone back to `/logout` after they
5022
+ * signed in defeats the sign-in, and back to `/login` loops. The exclusion used to be a literal
5023
+ * `!== "/login"`, which let through `/logout`, `/login?mode=changepassword` — same screen, one
5024
+ * query param — and any application whose `loginPath` is not spelled `/login`.
5025
+ */
5026
+ get urlAfterNavigation() {
5027
+ const url = this.router.url;
5028
+ return isAuthRoute(url, globalConfig.loginPath) ? null : url;
5029
+ }
5030
+ /**
5031
+ * Observable that emits events of type `NavigationEnd` from the Angular Router.
5032
+ *
5033
+ * This observable performs the following operations:
5034
+ * - Maps all router events to `RouterEvent`.
5035
+ * - Filters the events to only include instances of `NavigationEnd`.
5036
+ * - Taps into the event stream to notify the audit service when the route itself changes — a new
5037
+ * query, a filter or another page of results stays on the same route and is not one.
5038
+ * - Shares the replayed value with a buffer size of 1 to ensure subscribers receive the latest emitted value.
5039
+ *
5040
+ * @type Observable<RouterEvent>
5041
+ */
5042
+ navigationEnd$ = this.router.events.pipe(map(event => event), filter$1((event) => event instanceof NavigationEnd), tap(event => {
5043
+ const routeName = routeNameOf(event.url);
5044
+ // The empty name is the application root, which has never been audited.
5045
+ if (routeName && routeName !== this.previousRouteName) {
5046
+ this.auditService.notifyRouteChange(routeName);
5047
+ }
5048
+ // Every route is remembered, the authentication screens included. Skipping them would swallow
5049
+ // the return: leaving `/search` for `/login` and coming back is two route changes, and the
5050
+ // second one would compare against a `search` the user has meanwhile left. (The exclusion that
5051
+ // used to sit here never ran — the dedupe above it could not fire.)
5052
+ this.previousRouteName = routeName;
5053
+ }), shareReplay(1));
5054
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.29", ngImport: i0, type: NavigationService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
5055
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.3.29", ngImport: i0, type: NavigationService, providedIn: "root" });
5056
+ }
5057
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.29", ngImport: i0, type: NavigationService, decorators: [{
5058
+ type: Injectable,
5059
+ args: [{
5060
+ providedIn: "root"
5061
+ }]
5062
+ }] });
5063
+
4976
5064
  /**
4977
5065
  * @deprecated This service is deprecated and should not be used directly. Please use the PrincipalStore instead.
4978
5066
  */
@@ -10281,12 +10369,13 @@ class ArticleEntities {
10281
10369
  cn = cn;
10282
10370
  appStore = inject(AppStore);
10283
10371
  previewService = inject(PreviewService);
10372
+ entityNavigation = inject(EntityNavigationService);
10284
10373
  article = input.required(...(ngDevMode ? [{ debugName: "article" }] : []));
10285
10374
  previewHighlights = computed(() => {
10286
10375
  const highlights = this.appStore.getWebServiceByType("preview")?.highlights
10287
10376
  ?.split(",")
10288
- .filter((h) => h !== "extractslocations" && h !== "matchlocations" && h !== "matchingpassages");
10289
- return highlights?.map((highlight) => ({
10377
+ .filter(h => h !== "extractslocations" && h !== "matchlocations" && h !== "matchingpassages");
10378
+ return highlights?.map(highlight => ({
10290
10379
  name: highlight,
10291
10380
  entity: highlight,
10292
10381
  metadata: this.getMetadata(this.article(), highlight)
@@ -10296,54 +10385,24 @@ class ArticleEntities {
10296
10385
  const m = article[entity];
10297
10386
  return m ?? [];
10298
10387
  }
10299
- navigation = signal(undefined, ...(ngDevMode ? [{ debugName: "navigation" }] : []));
10300
- hovering = signal(undefined, ...(ngDevMode ? [{ debugName: "hovering" }] : []));
10301
- hoverIndex = computed(() => this.navigation()?.value === this.hovering() ? (this.navigation()?.index ?? 0) : 0, ...(ngDevMode ? [{ debugName: "hoverIndex" }] : []));
10388
+ navigation = this.entityNavigation.navigation;
10389
+ hovering = this.entityNavigation.hovering;
10390
+ hoverIndex = this.entityNavigation.hoverIndex;
10391
+ /** Number of occurrences of the entity value highlighted in the preview (not the indexed `count`, see ES-32245) */
10392
+ total(entity, data) {
10393
+ return this.entityNavigation.total(entity, data.value, data.count);
10394
+ }
10302
10395
  scrollTo(type, index, usePassageHighlighter = false) {
10303
10396
  // if previewData contains "matchingpassages" then we use the passage highlighter otherwise we use "extractslocations"
10304
10397
  const category = type || this.previewService.highlightCategory;
10305
10398
  this.previewService.events.set("scrollTo");
10306
10399
  this.previewService.sendMessage({ action: "select", id: `${category}_${index}`, usePassageHighlighter });
10307
10400
  }
10308
- navigate(entity, data, direction) {
10309
- if (!data.count || data.count <= 0) {
10310
- warn(`Invalid count for entity ${entity}:`, data.count);
10311
- return;
10312
- }
10313
- const currentNavigation = this.navigation();
10314
- const isCurrentValue = currentNavigation?.value === data.value;
10315
- let newIndex;
10316
- if (direction === "next") {
10317
- newIndex = isCurrentValue && currentNavigation.index < data.count ? currentNavigation.index + 1 : 1;
10318
- }
10319
- else {
10320
- newIndex = isCurrentValue && currentNavigation.index > 1 ? currentNavigation.index - 1 : data.count;
10321
- }
10322
- this.navigation.set({
10323
- value: data.value,
10324
- index: newIndex
10325
- });
10326
- const id = this.previewService.getEntityId(entity, data.value, newIndex - 1);
10327
- if (id !== undefined) {
10328
- this.scrollTo(entity, id);
10329
- debug(`${direction} navigation:`, {
10330
- id,
10331
- entity,
10332
- value: data.value,
10333
- index: newIndex,
10334
- totalCount: data.count
10335
- });
10336
- }
10337
- else {
10338
- warn(`Unable to get ID for ${direction} navigation:`, { entity, value: data.value, index: newIndex });
10339
- notify.warning(`Unable to navigate to the ${direction} occurrence of "${data.value}".`);
10340
- }
10341
- }
10342
10401
  navigateNext(entity, data) {
10343
- this.navigate(entity, data, "next");
10402
+ this.entityNavigation.navigate(entity, data.value, "next");
10344
10403
  }
10345
10404
  navigatePrev(entity, data) {
10346
- this.navigate(entity, data, "prev");
10405
+ this.entityNavigation.navigate(entity, data.value, "prev");
10347
10406
  }
10348
10407
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.29", ngImport: i0, type: ArticleEntities, deps: [], target: i0.ɵɵFactoryTarget.Component });
10349
10408
  static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "20.3.29", type: ArticleEntities, isStandalone: true, selector: "article-entities,ArticleEntities,articleentities", inputs: { article: { classPropertyName: "article", publicName: "article", isSignal: true, isRequired: true, transformFunction: null } }, ngImport: i0, template: `
@@ -10387,13 +10446,13 @@ class ArticleEntities {
10387
10446
  <chevronleft />
10388
10447
  </button>
10389
10448
 
10390
- <span>{{ hoverIndex() }} / {{ data.count }}</span>
10449
+ <span>{{ hoverIndex() }} / {{ total(entity, data) }}</span>
10391
10450
 
10392
10451
  <button class="hover:scale-125" aria-label="next entity" role="button" (click)="navigateNext(entity, data)">
10393
10452
  <chevronright />
10394
10453
  </button>
10395
10454
  } @else {
10396
- {{ data.count }}
10455
+ {{ total(entity, data) }}
10397
10456
  }
10398
10457
  </p>
10399
10458
  </li>
@@ -10449,13 +10508,13 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.29", ngImpo
10449
10508
  <chevronleft />
10450
10509
  </button>
10451
10510
 
10452
- <span>{{ hoverIndex() }} / {{ data.count }}</span>
10511
+ <span>{{ hoverIndex() }} / {{ total(entity, data) }}</span>
10453
10512
 
10454
10513
  <button class="hover:scale-125" aria-label="next entity" role="button" (click)="navigateNext(entity, data)">
10455
10514
  <chevronright />
10456
10515
  </button>
10457
10516
  } @else {
10458
- {{ data.count }}
10517
+ {{ total(entity, data) }}
10459
10518
  }
10460
10519
  </p>
10461
10520
  </li>
@@ -13654,12 +13713,13 @@ class EntitiesPanel {
13654
13713
  cn = cn;
13655
13714
  appStore = inject(AppStore);
13656
13715
  previewService = inject(PreviewService);
13716
+ entityNavigation = inject(EntityNavigationService);
13657
13717
  article = input.required(...(ngDevMode ? [{ debugName: "article" }] : []));
13658
13718
  previewHighlights = computed(() => {
13659
13719
  const highlights = this.appStore.getWebServiceByType("preview")?.highlights
13660
13720
  ?.split(",")
13661
- .filter((h) => h !== "extractslocations" && h !== "matchlocations" && h !== "matchingpassages");
13662
- return highlights?.map((highlight) => ({
13721
+ .filter(h => h !== "extractslocations" && h !== "matchlocations" && h !== "matchingpassages");
13722
+ return highlights?.map(highlight => ({
13663
13723
  name: highlight,
13664
13724
  entity: highlight,
13665
13725
  metadata: this.getMetadata(this.article(), highlight)
@@ -13669,54 +13729,24 @@ class EntitiesPanel {
13669
13729
  const m = article[entity];
13670
13730
  return m ?? [];
13671
13731
  }
13672
- navigation = signal(undefined, ...(ngDevMode ? [{ debugName: "navigation" }] : []));
13673
- hovering = signal(undefined, ...(ngDevMode ? [{ debugName: "hovering" }] : []));
13674
- hoverIndex = computed(() => this.navigation()?.value === this.hovering() ? (this.navigation()?.index ?? 0) : 0, ...(ngDevMode ? [{ debugName: "hoverIndex" }] : []));
13732
+ navigation = this.entityNavigation.navigation;
13733
+ hovering = this.entityNavigation.hovering;
13734
+ hoverIndex = this.entityNavigation.hoverIndex;
13735
+ /** Number of occurrences of the entity value highlighted in the preview (not the indexed `count`, see ES-32245) */
13736
+ total(entity, data) {
13737
+ return this.entityNavigation.total(entity, data.value, data.count);
13738
+ }
13675
13739
  scrollTo(type, index, usePassageHighlighter = false) {
13676
13740
  // if previewData contains "matchingpassages" then we use the passage highlighter otherwise we use "extractslocations"
13677
13741
  const category = type || this.previewService.highlightCategory;
13678
13742
  this.previewService.events.set("scrollTo");
13679
13743
  this.previewService.sendMessage({ action: "select", id: `${category}_${index}`, usePassageHighlighter });
13680
13744
  }
13681
- navigate(entity, data, direction) {
13682
- if (!data.count || data.count <= 0) {
13683
- warn(`Invalid count for entity ${entity}:`, data.count);
13684
- return;
13685
- }
13686
- const currentNavigation = this.navigation();
13687
- const isCurrentValue = currentNavigation?.value === data.value;
13688
- let newIndex;
13689
- if (direction === "next") {
13690
- newIndex = isCurrentValue && currentNavigation.index < data.count ? currentNavigation.index + 1 : 1;
13691
- }
13692
- else {
13693
- newIndex = isCurrentValue && currentNavigation.index > 1 ? currentNavigation.index - 1 : data.count;
13694
- }
13695
- this.navigation.set({
13696
- value: data.value,
13697
- index: newIndex
13698
- });
13699
- const id = this.previewService.getEntityId(entity, data.value, newIndex - 1);
13700
- if (id !== undefined) {
13701
- this.scrollTo(entity, id);
13702
- debug(`${direction} navigation:`, {
13703
- id,
13704
- entity,
13705
- value: data.value,
13706
- index: newIndex,
13707
- totalCount: data.count
13708
- });
13709
- }
13710
- else {
13711
- warn(`Unable to get ID for ${direction} navigation:`, { entity, value: data.value, index: newIndex });
13712
- notify.warning(`Unable to navigate to the ${direction} occurrence of "${data.value}".`);
13713
- }
13714
- }
13715
13745
  navigateNext(entity, data) {
13716
- this.navigate(entity, data, "next");
13746
+ this.entityNavigation.navigate(entity, data.value, "next");
13717
13747
  }
13718
13748
  navigatePrev(entity, data) {
13719
- this.navigate(entity, data, "prev");
13749
+ this.entityNavigation.navigate(entity, data.value, "prev");
13720
13750
  }
13721
13751
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.29", ngImport: i0, type: EntitiesPanel, deps: [], target: i0.ɵɵFactoryTarget.Component });
13722
13752
  static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "20.3.29", type: EntitiesPanel, isStandalone: true, selector: "entities-panel,EntitiesPanel,entitiesPanel", inputs: { article: { classPropertyName: "article", publicName: "article", isSignal: true, isRequired: true, transformFunction: null } }, ngImport: i0, template: `
@@ -13760,13 +13790,13 @@ class EntitiesPanel {
13760
13790
  <chevronleft />
13761
13791
  </button>
13762
13792
 
13763
- <span>{{ hoverIndex() }} / {{ data.count }}</span>
13793
+ <span>{{ hoverIndex() }} / {{ total(entity, data) }}</span>
13764
13794
 
13765
13795
  <button class="hover:scale-125" aria-label="next entity" role="button" (click)="navigateNext(entity, data)">
13766
13796
  <chevronright />
13767
13797
  </button>
13768
13798
  } @else {
13769
- {{ data.count }}
13799
+ {{ total(entity, data) }}
13770
13800
  }
13771
13801
  </p>
13772
13802
  </li>
@@ -13822,13 +13852,13 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.29", ngImpo
13822
13852
  <chevronleft />
13823
13853
  </button>
13824
13854
 
13825
- <span>{{ hoverIndex() }} / {{ data.count }}</span>
13855
+ <span>{{ hoverIndex() }} / {{ total(entity, data) }}</span>
13826
13856
 
13827
13857
  <button class="hover:scale-125" aria-label="next entity" role="button" (click)="navigateNext(entity, data)">
13828
13858
  <chevronright />
13829
13859
  </button>
13830
13860
  } @else {
13831
- {{ data.count }}
13861
+ {{ total(entity, data) }}
13832
13862
  }
13833
13863
  </p>
13834
13864
  </li>
@@ -18383,6 +18413,18 @@ class UserProfileFormComponent {
18383
18413
  this.feedback.set(undefined);
18384
18414
  this.propertyToEdit.set(undefined);
18385
18415
  }
18416
+ /**
18417
+ * Whether the profile holds a value for a property. Only then is there something to delete: a property
18418
+ * that shows its configured default, or that is simply not set, is absent from the profile and the
18419
+ * server answers 404 to a delete on it (ES-34462).
18420
+ *
18421
+ * @param category data or customData
18422
+ * @param propertyName property name
18423
+ */
18424
+ hasStoredValue(category, propertyName) {
18425
+ const stored = this.userProfile()?.[category]?.[propertyName];
18426
+ return stored !== undefined && stored !== null;
18427
+ }
18386
18428
  /**
18387
18429
  * On data property deletion, for now we make the property an empty string
18388
18430
  *
@@ -18392,6 +18434,11 @@ class UserProfileFormComponent {
18392
18434
  async onDeleteData(category, propertyName) {
18393
18435
  if (this.pending())
18394
18436
  return;
18437
+ // Nothing stored, nothing to delete: not a failure, and no request to send.
18438
+ if (!this.hasStoredValue(category, propertyName)) {
18439
+ info("User profile property not stored, nothing to delete", { category, propertyName });
18440
+ return;
18441
+ }
18395
18442
  const key = `${category}.${propertyName}`;
18396
18443
  this.feedback.set(undefined);
18397
18444
  this.pending.set(key);
@@ -18399,9 +18446,7 @@ class UserProfileFormComponent {
18399
18446
  const response = await deleteUserProfileProperty(this.userProfile(), category, propertyName);
18400
18447
  if (response) {
18401
18448
  info("Deleted user profile property", response);
18402
- const userProfile = JSON.parse(JSON.stringify(this.userProfile()));
18403
- userProfile[category][propertyName] = undefined;
18404
- this.userProfileResource.set(userProfile);
18449
+ this.forgetProperty(category, propertyName);
18405
18450
  this.feedback.set({ key, type: "success", message: this.message("deleted", category, propertyName) });
18406
18451
  }
18407
18452
  else {
@@ -18409,13 +18454,30 @@ class UserProfileFormComponent {
18409
18454
  this.feedback.set({ key, type: "error", message: this.message("deleteFailed", category, propertyName) });
18410
18455
  }
18411
18456
  }
18412
- catch {
18413
- this.feedback.set({ key, type: "error", message: this.message("deleteFailed", category, propertyName) });
18457
+ catch (err) {
18458
+ if (err?.status === 404) {
18459
+ // The property is not in the profile (anymore), e.g. removed from another tab: the state asked for is
18460
+ // already reached, so it is not an error to show.
18461
+ info("User profile property already absent", { category, propertyName });
18462
+ this.forgetProperty(category, propertyName);
18463
+ this.feedback.set({ key, type: "success", message: this.message("deleted", category, propertyName) });
18464
+ }
18465
+ else {
18466
+ this.feedback.set({ key, type: "error", message: this.message("deleteFailed", category, propertyName) });
18467
+ }
18414
18468
  }
18415
18469
  finally {
18416
18470
  this.pending.set(undefined);
18417
18471
  }
18418
18472
  }
18473
+ /** Takes a deleted property out of the stored profile; a custom property with a default shows that default again. */
18474
+ forgetProperty(category, propertyName) {
18475
+ const userProfile = JSON.parse(JSON.stringify(this.userProfile()));
18476
+ if (userProfile[category]) {
18477
+ userProfile[category][propertyName] = undefined;
18478
+ }
18479
+ this.userProfileResource.set(userProfile);
18480
+ }
18419
18481
  /**
18420
18482
  * Saving a data property
18421
18483
  *
@@ -18432,18 +18494,11 @@ class UserProfileFormComponent {
18432
18494
  const userProfile = JSON.parse(JSON.stringify(this.userProfile()));
18433
18495
  if (!userProfile[category])
18434
18496
  userProfile[category] = {};
18435
- userProfile[category][propertyName] = this.value()?.trim() || "";
18497
+ const written = this.value()?.trim() || "";
18498
+ userProfile[category][propertyName] = written;
18436
18499
  try {
18437
18500
  const response = await patchUserProfile(userProfile, category, propertyName);
18438
- if (response) {
18439
- const newUserProfile = this.userProfile();
18440
- this.userProfileResource.set({
18441
- ...newUserProfile,
18442
- ...response,
18443
- data: { ...(newUserProfile?.data || {}), ...(response.data || {}) },
18444
- customData: { ...(newUserProfile?.customData || {}), ...(response.customData || {}) }
18445
- });
18446
- }
18501
+ this.foldSavedValue(category, propertyName, response, written);
18447
18502
  this.value.set(undefined);
18448
18503
  this.propertyToEdit.set(undefined);
18449
18504
  this.feedback.set({ key, type: "success", message: this.message("saved", category, propertyName) });
@@ -18455,6 +18510,32 @@ class UserProfileFormComponent {
18455
18510
  this.pending.set(undefined);
18456
18511
  }
18457
18512
  }
18513
+ /**
18514
+ * Folds the answer of a PATCH back into the stored profile, so the form shows what was just saved.
18515
+ *
18516
+ * The answer depends on the server: before ES-34373 it is the whole updated profile (`{ data, customData, … }`),
18517
+ * after it is the written value alone (`"fav-1AAA"`). Reading the latter as a profile used to leave the stored
18518
+ * profile unchanged, and the form kept showing the old value (or the default) until the page was reloaded.
18519
+ */
18520
+ foldSavedValue(category, propertyName, response, written) {
18521
+ const current = this.userProfile();
18522
+ if (isPlainObject(response) && ("data" in response || "customData" in response)) {
18523
+ const profile = response;
18524
+ this.userProfileResource.set({
18525
+ ...current,
18526
+ ...profile,
18527
+ data: { ...(current?.data || {}), ...(profile.data || {}) },
18528
+ customData: { ...(current?.customData || {}), ...(profile.customData || {}) }
18529
+ });
18530
+ return;
18531
+ }
18532
+ // The value alone: what the server read back, or what was sent when it answers with nothing usable.
18533
+ const value = response === undefined || response === null ? written : response;
18534
+ this.userProfileResource.set({
18535
+ ...current,
18536
+ [category]: { ...(current?.[category] || {}), [propertyName]: value }
18537
+ });
18538
+ }
18458
18539
  /**
18459
18540
  * Get the value of a data key
18460
18541
  *
@@ -18501,7 +18582,7 @@ class UserProfileFormComponent {
18501
18582
  }
18502
18583
  }
18503
18584
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.29", ngImport: i0, type: UserProfileFormComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
18504
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "20.3.29", type: UserProfileFormComponent, isStandalone: true, selector: "user-profile-form, UserProfileForm, userprofileform", inputs: { value: { classPropertyName: "value", publicName: "value", isSignal: true, isRequired: false, transformFunction: null }, currentLanguage: { classPropertyName: "currentLanguage", publicName: "currentLanguage", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { value: "valueChange", currentLanguage: "currentLanguageChange" }, host: { classAttribute: "flex flex-col h-full overflow-hidden" }, providers: [provideTranslocoScope("user-profile", "login")], viewQueries: [{ propertyName: "createInputElement", first: true, predicate: ["avatarInput"], descendants: true, isSignal: true }], ngImport: i0, template: "@if (userProfileResource.hasValue()) {\n @let profile = userProfile();\n <div class=\"flex min-h-0 flex-1 flex-col gap-2 p-4\">\n <div class=\"mb-4 flex\">\n @if (allowProfilePhoto()) {\n <!-- AVATAR -->\n <Avatar\n class=\"size-14 cursor-pointer\"\n [class.ring-2]=\"!!fieldState('data.profilePhoto')\"\n [class.ring-destructive]=\"fieldState('data.profilePhoto') === 'error'\"\n [class.ring-green-600]=\"fieldState('data.profilePhoto') === 'success'\"\n (click)=\"avatarInput.click()\">\n @if (profile?.data?.profilePhoto) {\n <AvatarImage\n [src]=\"profile?.data?.profilePhoto!\"\n width=\"44\"\n height=\"44\"\n alt=\"avatar\" />\n }\n <AvatarFallback class=\"text-lg\">\n @if (initials()) {\n <span>{{ initials() }}</span>\n } @else {\n <UserIcon class=\"size-7\" />\n }\n </AvatarFallback>\n </Avatar>\n @if (profile?.data?.profilePhoto) {\n <button\n variant=\"ghost\"\n [iconOnly]=\"true\"\n class=\"text-destructive cursor-pointer\"\n title=\"{{ 'userProfile.deleteProperty' | transloco }}\"\n [attr.aria-label]=\"'userProfile.deleteProperty' | transloco\"\n [disabled]=\"!!pending()\"\n (click)=\"onDeleteData('data', 'profilePhoto')\">\n <TrashIcon />\n </button>\n }\n }\n <div class=\"grow\"></div>\n <div class=\"flex flex-col gap-4\">\n <!-- LANGUAGE -->\n <select\n class=\"hover:outline-primary focus:outline-primary border-foreground/10 bg-background hover:bg-muted focus:bg-muted h-8 rounded-md border px-2 hover:outline focus:outline\"\n [(ngModel)]=\"currentLanguage\"\n (change)=\"changeLanguage()\">\n @for (lang of AllLanguages; track lang.code) {\n <option [value]=\"lang.code\">\n {{ lang.label }}\n </option>\n }\n </select>\n <!-- CHANGE PASSWORD -->\n @if (allowChangePassword()) {\n <button\n [disabled]=\"changingPassword()\"\n (click)=\"changingPassword.set(true)\">\n {{ \"login.changePassword\" | transloco }}\n </button>\n }\n </div>\n </div>\n <!-- always rendered, so the room is reserved and the layout doesn't shift when a message appears -->\n <p\n class=\"min-h-5 text-sm\"\n role=\"status\"\n [attr.aria-live]=\"feedback()?.type === 'error' ? 'assertive' : 'polite'\"\n [class.text-destructive]=\"feedback()?.type === 'error'\"\n [class.text-green-600]=\"feedback()?.type === 'success'\">\n {{ feedback()?.message }}\n </p>\n @if (changingPassword()) {\n <ChangePassword\n [username]=\"username()\"\n [redirectAfterSuccess]=\"false\"\n [redirectAfterCancel]=\"false\"\n (cancel)=\"changingPassword.set(false)\"\n (success)=\"changingPassword.set(false)\" />\n } @else {\n <!-- -m-1 p-1: room for the outline of a field, which the scroll container would otherwise clip -->\n <div class=\"-m-1 min-h-0 flex-1 overflow-auto p-1\">\n <div>\n @for (key of formKeys(); track key) {\n <div>\n <p class=\"px-2\">{{ `userProfile.data.${key}` | transloco }}</p>\n <div\n class=\"group flex flex-row rounded-md px-2 py-0.5\"\n [class.ring-1]=\"!!fieldState('data.' + key)\"\n [class.ring-destructive]=\"fieldState('data.' + key) === 'error'\"\n [class.ring-green-600]=\"\n fieldState('data.' + key) === 'success'\n \">\n <div\n class=\"min-w-0 grow break-words whitespace-pre-line\"\n [class.text-muted-foreground]=\"!getDataValue('data', key)\">\n @if (propertyToEdit() === \"data.\" + key) {\n <textarea\n class=\"hover:outline-primary focus:outline-primary border-foreground/20 hover:bg-muted focus:bg-muted mt-2 w-full rounded-md border px-2 pt-1 hover:outline focus:outline\"\n id=\"user-profile-{{ key }}\"\n [(ngModel)]=\"value\"></textarea>\n } @else {\n {{\n getDataValue(\"data\", key) ||\n (\"userProfile.notDefined\" | transloco)\n }}\n }\n </div>\n @if (propertyToEdit() === \"data.\" + key) {\n <button\n variant=\"ghost\"\n [iconOnly]=\"true\"\n class=\"visible mx-2 cursor-pointer sm:invisible sm:group-hover:visible\"\n title=\"{{ 'userProfile.editProperty' | transloco }}\"\n [attr.aria-label]=\"'userProfile.editProperty' | transloco\"\n [disabled]=\"!!pending()\"\n (click)=\"onSaveData('data', key)\">\n <save-icon class=\"ml-2\" />\n </button>\n <button\n variant=\"ghost\"\n [iconOnly]=\"true\"\n class=\"visible cursor-pointer sm:invisible sm:group-hover:visible\"\n title=\"{{ 'userProfile.editProperty' | transloco }}\"\n [attr.aria-label]=\"'userProfile.editProperty' | transloco\"\n (click)=\"onCancelEdit()\">\n <UndoIcon />\n </button>\n } @else {\n <button\n variant=\"ghost\"\n [iconOnly]=\"true\"\n class=\"visible mx-2 cursor-pointer sm:invisible sm:group-hover:visible\"\n title=\"{{ 'userProfile.editProperty' | transloco }}\"\n [attr.aria-label]=\"'userProfile.editProperty' | transloco\"\n (click)=\"onEdit('data', key)\">\n <EditIcon />\n </button>\n <button\n variant=\"ghost\"\n [iconOnly]=\"true\"\n class=\"text-destructive visible cursor-pointer sm:invisible sm:group-hover:visible\"\n title=\"{{ 'userProfile.deleteProperty' | transloco }}\"\n [attr.aria-label]=\"'userProfile.deleteProperty' | transloco\"\n [disabled]=\"!!pending()\"\n (click)=\"onDeleteData('data', key)\">\n <TrashIcon />\n </button>\n }\n </div>\n </div>\n }\n @for (key of customData(); track key) {\n <div>\n <p class=\"px-2\">{{ key }}</p>\n <div\n class=\"group flex flex-row rounded-md px-2 py-0.5\"\n [class.ring-1]=\"!!fieldState('customData.' + key)\"\n [class.ring-destructive]=\"\n fieldState('customData.' + key) === 'error'\n \"\n [class.ring-green-600]=\"\n fieldState('customData.' + key) === 'success'\n \">\n <div\n class=\"min-w-0 grow break-words whitespace-pre-line\"\n [class.text-muted-foreground]=\"\n !getDataValue('customData', key)\n \">\n @if (propertyToEdit() === \"customData.\" + key) {\n <textarea\n class=\"hover:outline-primary focus:outline-primary border-foreground/20 hover:bg-muted focus:bg-muted mt-2 w-full rounded-md border px-2 pt-1 hover:outline focus:outline\"\n id=\"user-profile-{{ key }}\"\n [(ngModel)]=\"value\"></textarea>\n } @else {\n {{\n getDataValue(\"customData\", key) ||\n (\"userProfile.notDefined\" | transloco)\n }}\n }\n </div>\n @if (propertyToEdit() === \"customData.\" + key) {\n <button\n variant=\"ghost\"\n [iconOnly]=\"true\"\n class=\"visible mx-2 cursor-pointer sm:invisible sm:group-hover:visible\"\n title=\"{{ 'userProfile.editProperty' | transloco }}\"\n [attr.aria-label]=\"'userProfile.editProperty' | transloco\"\n [disabled]=\"!!pending()\"\n (click)=\"onSaveData('customData', key)\">\n <save-icon class=\"ml-2\" />\n </button>\n <button\n variant=\"ghost\"\n [iconOnly]=\"true\"\n class=\"visible cursor-pointer sm:invisible sm:group-hover:visible\"\n title=\"{{ 'userProfile.editProperty' | transloco }}\"\n [attr.aria-label]=\"'userProfile.editProperty' | transloco\"\n (click)=\"onCancelEdit()\">\n <UndoIcon />\n </button>\n } @else {\n <button\n variant=\"ghost\"\n [iconOnly]=\"true\"\n class=\"visible mx-2 cursor-pointer sm:invisible sm:group-hover:visible\"\n title=\"{{ 'userProfile.editProperty' | transloco }}\"\n [attr.aria-label]=\"'userProfile.editProperty' | transloco\"\n (click)=\"onEdit('customData', key)\">\n <EditIcon />\n </button>\n <button\n variant=\"ghost\"\n [iconOnly]=\"true\"\n class=\"text-destructive visible cursor-pointer sm:invisible sm:group-hover:visible\"\n title=\"{{ 'userProfile.deleteProperty' | transloco }}\"\n [attr.aria-label]=\"'userProfile.deleteProperty' | transloco\"\n [disabled]=\"!!pending()\"\n (click)=\"onDeleteData('customData', key)\">\n <TrashIcon />\n </button>\n }\n </div>\n </div>\n }\n </div>\n </div>\n }\n </div>\n} @else if (userProfileResource.isLoading()) {\n <span>Loading...</span>\n} @else if (userProfileResource.error()) {\n <p>Please contact an administrator to create your user profile.</p>\n}\n\n<!-- AVATAR UPLOAD -->\n<input\n #avatarInput\n class=\"hidden\"\n type=\"file\"\n accept=\"image/*\"\n (change)=\"uploadAvatar($event)\" />\n", dependencies: [{ kind: "ngmodule", type: FormsModule }, { kind: "directive", type: i1.NgSelectOption, selector: "option", inputs: ["ngValue", "value"] }, { kind: "directive", type: i1.ɵNgSelectMultipleOption, selector: "option", inputs: ["ngValue", "value"] }, { kind: "directive", type: i1.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1.SelectControlValueAccessor, selector: "select:not([multiple])[formControlName],select:not([multiple])[formControl],select:not([multiple])[ngModel]", inputs: ["compareWith"] }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }, { kind: "ngmodule", type: RouterModule }, { kind: "component", type: TrashIcon, selector: "trash-icon, TrashIcon", inputs: ["class"] }, { kind: "component", type: EditIcon, selector: "edit-icon, EditIcon, editicon", inputs: ["class"] }, { kind: "component", type: UndoIcon, selector: "UndoIcon, undoicon, undo-icon", inputs: ["class"] }, { kind: "component", type: SaveIcon, selector: "save-icon, SaveIcon", inputs: ["class"] }, { kind: "component", type: AvatarComponent, selector: "avatar, Avatar", inputs: ["class", "size"] }, { kind: "component", type: AvatarFallbackComponent, selector: "avatar-fallback, avatarfallback, AvatarFallback", inputs: ["class", "size"] }, { kind: "component", type: UserIcon, selector: "user-icon, UserIcon", inputs: ["class"] }, { kind: "component", type: AvatarImageComponent, selector: "avatar-image, AvatarImage, avatarimage", inputs: ["class", "src", "alt", "width", "height", "referrerPolicy", "crossOrigin"] }, { kind: "directive", type: ButtonComponent, selector: "button", inputs: ["class", "variant", "decoration", "scheme", "iconOnly", "size", "solid"] }, { kind: "component", type: ChangePasswordComponent, selector: "change-password, ChangePassword, changepassword", inputs: ["username", "alert", "redirectAfterSuccess", "redirectAfterCancel", "currentPassword", "newPassword", "confirmPassword"], outputs: ["success", "cancel", "currentPasswordChange", "newPasswordChange", "confirmPasswordChange"] }, { kind: "pipe", type: TranslocoPipe, name: "transloco" }] });
18585
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "20.3.29", type: UserProfileFormComponent, isStandalone: true, selector: "user-profile-form, UserProfileForm, userprofileform", inputs: { value: { classPropertyName: "value", publicName: "value", isSignal: true, isRequired: false, transformFunction: null }, currentLanguage: { classPropertyName: "currentLanguage", publicName: "currentLanguage", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { value: "valueChange", currentLanguage: "currentLanguageChange" }, host: { classAttribute: "flex flex-col h-full overflow-hidden" }, providers: [provideTranslocoScope("user-profile", "login")], viewQueries: [{ propertyName: "createInputElement", first: true, predicate: ["avatarInput"], descendants: true, isSignal: true }], ngImport: i0, template: "@if (userProfileResource.hasValue()) {\n @let profile = userProfile();\n <div class=\"flex min-h-0 flex-1 flex-col gap-2 p-4\">\n <div class=\"mb-4 flex\">\n @if (allowProfilePhoto()) {\n <!-- AVATAR -->\n <Avatar\n class=\"size-14 cursor-pointer\"\n [class.ring-2]=\"!!fieldState('data.profilePhoto')\"\n [class.ring-destructive]=\"fieldState('data.profilePhoto') === 'error'\"\n [class.ring-green-600]=\"fieldState('data.profilePhoto') === 'success'\"\n (click)=\"avatarInput.click()\">\n @if (profile?.data?.profilePhoto) {\n <AvatarImage\n [src]=\"profile?.data?.profilePhoto!\"\n width=\"44\"\n height=\"44\"\n alt=\"avatar\" />\n }\n <AvatarFallback class=\"text-lg\">\n @if (initials()) {\n <span>{{ initials() }}</span>\n } @else {\n <UserIcon class=\"size-7\" />\n }\n </AvatarFallback>\n </Avatar>\n @if (profile?.data?.profilePhoto) {\n <button\n variant=\"ghost\"\n [iconOnly]=\"true\"\n class=\"text-destructive cursor-pointer\"\n title=\"{{ 'userProfile.deleteProperty' | transloco }}\"\n [attr.aria-label]=\"'userProfile.deleteProperty' | transloco\"\n [disabled]=\"!!pending()\"\n (click)=\"onDeleteData('data', 'profilePhoto')\">\n <TrashIcon />\n </button>\n }\n }\n <div class=\"grow\"></div>\n <div class=\"flex flex-col gap-4\">\n <!-- LANGUAGE -->\n <select\n class=\"hover:outline-primary focus:outline-primary border-foreground/10 bg-background hover:bg-muted focus:bg-muted h-8 rounded-md border px-2 hover:outline focus:outline\"\n [(ngModel)]=\"currentLanguage\"\n (change)=\"changeLanguage()\">\n @for (lang of AllLanguages; track lang.code) {\n <option [value]=\"lang.code\">\n {{ lang.label }}\n </option>\n }\n </select>\n <!-- CHANGE PASSWORD -->\n @if (allowChangePassword()) {\n <button\n [disabled]=\"changingPassword()\"\n (click)=\"changingPassword.set(true)\">\n {{ \"login.changePassword\" | transloco }}\n </button>\n }\n </div>\n </div>\n <!-- always rendered, so the room is reserved and the layout doesn't shift when a message appears -->\n <p\n class=\"min-h-5 text-sm\"\n role=\"status\"\n [attr.aria-live]=\"feedback()?.type === 'error' ? 'assertive' : 'polite'\"\n [class.text-destructive]=\"feedback()?.type === 'error'\"\n [class.text-green-600]=\"feedback()?.type === 'success'\">\n {{ feedback()?.message }}\n </p>\n @if (changingPassword()) {\n <ChangePassword\n [username]=\"username()\"\n [redirectAfterSuccess]=\"false\"\n [redirectAfterCancel]=\"false\"\n (cancel)=\"changingPassword.set(false)\"\n (success)=\"changingPassword.set(false)\" />\n } @else {\n <!-- -m-1 p-1: room for the outline of a field, which the scroll container would otherwise clip -->\n <div class=\"-m-1 min-h-0 flex-1 overflow-auto p-1\">\n <div>\n @for (key of formKeys(); track key) {\n <div>\n <p class=\"px-2\">{{ `userProfile.data.${key}` | transloco }}</p>\n <div\n class=\"group flex flex-row rounded-md px-2 py-0.5\"\n [class.ring-1]=\"!!fieldState('data.' + key)\"\n [class.ring-destructive]=\"fieldState('data.' + key) === 'error'\"\n [class.ring-green-600]=\"\n fieldState('data.' + key) === 'success'\n \">\n <div\n class=\"min-w-0 grow break-words whitespace-pre-line\"\n [class.text-muted-foreground]=\"!getDataValue('data', key)\">\n @if (propertyToEdit() === \"data.\" + key) {\n <textarea\n class=\"hover:outline-primary focus:outline-primary border-foreground/20 hover:bg-muted focus:bg-muted mt-2 w-full rounded-md border px-2 pt-1 hover:outline focus:outline\"\n id=\"user-profile-{{ key }}\"\n [(ngModel)]=\"value\"></textarea>\n } @else {\n {{\n getDataValue(\"data\", key) ||\n (\"userProfile.notDefined\" | transloco)\n }}\n }\n </div>\n @if (propertyToEdit() === \"data.\" + key) {\n <button\n variant=\"ghost\"\n [iconOnly]=\"true\"\n class=\"visible mx-2 cursor-pointer sm:invisible sm:group-hover:visible\"\n title=\"{{ 'userProfile.editProperty' | transloco }}\"\n [attr.aria-label]=\"'userProfile.editProperty' | transloco\"\n [disabled]=\"!!pending()\"\n (click)=\"onSaveData('data', key)\">\n <save-icon class=\"ml-2\" />\n </button>\n <button\n variant=\"ghost\"\n [iconOnly]=\"true\"\n class=\"visible cursor-pointer sm:invisible sm:group-hover:visible\"\n title=\"{{ 'userProfile.editProperty' | transloco }}\"\n [attr.aria-label]=\"'userProfile.editProperty' | transloco\"\n (click)=\"onCancelEdit()\">\n <UndoIcon />\n </button>\n } @else {\n <button\n variant=\"ghost\"\n [iconOnly]=\"true\"\n class=\"visible mx-2 cursor-pointer sm:invisible sm:group-hover:visible\"\n title=\"{{ 'userProfile.editProperty' | transloco }}\"\n [attr.aria-label]=\"'userProfile.editProperty' | transloco\"\n (click)=\"onEdit('data', key)\">\n <EditIcon />\n </button>\n <button\n variant=\"ghost\"\n [iconOnly]=\"true\"\n class=\"text-destructive visible cursor-pointer sm:invisible sm:group-hover:visible\"\n title=\"{{ 'userProfile.deleteProperty' | transloco }}\"\n [attr.aria-label]=\"'userProfile.deleteProperty' | transloco\"\n [disabled]=\"!!pending() || !hasStoredValue('data', key)\"\n (click)=\"onDeleteData('data', key)\">\n <TrashIcon />\n </button>\n }\n </div>\n </div>\n }\n @for (key of customData(); track key) {\n <div>\n <p class=\"px-2\">{{ key }}</p>\n <div\n class=\"group flex flex-row rounded-md px-2 py-0.5\"\n [class.ring-1]=\"!!fieldState('customData.' + key)\"\n [class.ring-destructive]=\"\n fieldState('customData.' + key) === 'error'\n \"\n [class.ring-green-600]=\"\n fieldState('customData.' + key) === 'success'\n \">\n <div\n class=\"min-w-0 grow break-words whitespace-pre-line\"\n [class.text-muted-foreground]=\"\n !getDataValue('customData', key)\n \">\n @if (propertyToEdit() === \"customData.\" + key) {\n <textarea\n class=\"hover:outline-primary focus:outline-primary border-foreground/20 hover:bg-muted focus:bg-muted mt-2 w-full rounded-md border px-2 pt-1 hover:outline focus:outline\"\n id=\"user-profile-{{ key }}\"\n [(ngModel)]=\"value\"></textarea>\n } @else {\n {{\n getDataValue(\"customData\", key) ||\n (\"userProfile.notDefined\" | transloco)\n }}\n }\n </div>\n @if (propertyToEdit() === \"customData.\" + key) {\n <button\n variant=\"ghost\"\n [iconOnly]=\"true\"\n class=\"visible mx-2 cursor-pointer sm:invisible sm:group-hover:visible\"\n title=\"{{ 'userProfile.editProperty' | transloco }}\"\n [attr.aria-label]=\"'userProfile.editProperty' | transloco\"\n [disabled]=\"!!pending()\"\n (click)=\"onSaveData('customData', key)\">\n <save-icon class=\"ml-2\" />\n </button>\n <button\n variant=\"ghost\"\n [iconOnly]=\"true\"\n class=\"visible cursor-pointer sm:invisible sm:group-hover:visible\"\n title=\"{{ 'userProfile.editProperty' | transloco }}\"\n [attr.aria-label]=\"'userProfile.editProperty' | transloco\"\n (click)=\"onCancelEdit()\">\n <UndoIcon />\n </button>\n } @else {\n <button\n variant=\"ghost\"\n [iconOnly]=\"true\"\n class=\"visible mx-2 cursor-pointer sm:invisible sm:group-hover:visible\"\n title=\"{{ 'userProfile.editProperty' | transloco }}\"\n [attr.aria-label]=\"'userProfile.editProperty' | transloco\"\n (click)=\"onEdit('customData', key)\">\n <EditIcon />\n </button>\n <button\n variant=\"ghost\"\n [iconOnly]=\"true\"\n class=\"text-destructive visible cursor-pointer sm:invisible sm:group-hover:visible\"\n title=\"{{ 'userProfile.deleteProperty' | transloco }}\"\n [attr.aria-label]=\"'userProfile.deleteProperty' | transloco\"\n [disabled]=\"\n !!pending() || !hasStoredValue('customData', key)\n \"\n (click)=\"onDeleteData('customData', key)\">\n <TrashIcon />\n </button>\n }\n </div>\n </div>\n }\n </div>\n </div>\n }\n </div>\n} @else if (userProfileResource.isLoading()) {\n <span>Loading...</span>\n} @else if (userProfileResource.error()) {\n <p>Please contact an administrator to create your user profile.</p>\n}\n\n<!-- AVATAR UPLOAD -->\n<input\n #avatarInput\n class=\"hidden\"\n type=\"file\"\n accept=\"image/*\"\n (change)=\"uploadAvatar($event)\" />\n", dependencies: [{ kind: "ngmodule", type: FormsModule }, { kind: "directive", type: i1.NgSelectOption, selector: "option", inputs: ["ngValue", "value"] }, { kind: "directive", type: i1.ɵNgSelectMultipleOption, selector: "option", inputs: ["ngValue", "value"] }, { kind: "directive", type: i1.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1.SelectControlValueAccessor, selector: "select:not([multiple])[formControlName],select:not([multiple])[formControl],select:not([multiple])[ngModel]", inputs: ["compareWith"] }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }, { kind: "ngmodule", type: RouterModule }, { kind: "component", type: TrashIcon, selector: "trash-icon, TrashIcon", inputs: ["class"] }, { kind: "component", type: EditIcon, selector: "edit-icon, EditIcon, editicon", inputs: ["class"] }, { kind: "component", type: UndoIcon, selector: "UndoIcon, undoicon, undo-icon", inputs: ["class"] }, { kind: "component", type: SaveIcon, selector: "save-icon, SaveIcon", inputs: ["class"] }, { kind: "component", type: AvatarComponent, selector: "avatar, Avatar", inputs: ["class", "size"] }, { kind: "component", type: AvatarFallbackComponent, selector: "avatar-fallback, avatarfallback, AvatarFallback", inputs: ["class", "size"] }, { kind: "component", type: UserIcon, selector: "user-icon, UserIcon", inputs: ["class"] }, { kind: "component", type: AvatarImageComponent, selector: "avatar-image, AvatarImage, avatarimage", inputs: ["class", "src", "alt", "width", "height", "referrerPolicy", "crossOrigin"] }, { kind: "directive", type: ButtonComponent, selector: "button", inputs: ["class", "variant", "decoration", "scheme", "iconOnly", "size", "solid"] }, { kind: "component", type: ChangePasswordComponent, selector: "change-password, ChangePassword, changepassword", inputs: ["username", "alert", "redirectAfterSuccess", "redirectAfterCancel", "currentPassword", "newPassword", "confirmPassword"], outputs: ["success", "cancel", "currentPasswordChange", "newPasswordChange", "confirmPasswordChange"] }, { kind: "pipe", type: TranslocoPipe, name: "transloco" }] });
18505
18586
  }
18506
18587
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.29", ngImport: i0, type: UserProfileFormComponent, decorators: [{
18507
18588
  type: Component,
@@ -18519,7 +18600,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.29", ngImpo
18519
18600
  AvatarImageComponent,
18520
18601
  ButtonComponent,
18521
18602
  ChangePasswordComponent
18522
- ], providers: [provideTranslocoScope("user-profile", "login")], template: "@if (userProfileResource.hasValue()) {\n @let profile = userProfile();\n <div class=\"flex min-h-0 flex-1 flex-col gap-2 p-4\">\n <div class=\"mb-4 flex\">\n @if (allowProfilePhoto()) {\n <!-- AVATAR -->\n <Avatar\n class=\"size-14 cursor-pointer\"\n [class.ring-2]=\"!!fieldState('data.profilePhoto')\"\n [class.ring-destructive]=\"fieldState('data.profilePhoto') === 'error'\"\n [class.ring-green-600]=\"fieldState('data.profilePhoto') === 'success'\"\n (click)=\"avatarInput.click()\">\n @if (profile?.data?.profilePhoto) {\n <AvatarImage\n [src]=\"profile?.data?.profilePhoto!\"\n width=\"44\"\n height=\"44\"\n alt=\"avatar\" />\n }\n <AvatarFallback class=\"text-lg\">\n @if (initials()) {\n <span>{{ initials() }}</span>\n } @else {\n <UserIcon class=\"size-7\" />\n }\n </AvatarFallback>\n </Avatar>\n @if (profile?.data?.profilePhoto) {\n <button\n variant=\"ghost\"\n [iconOnly]=\"true\"\n class=\"text-destructive cursor-pointer\"\n title=\"{{ 'userProfile.deleteProperty' | transloco }}\"\n [attr.aria-label]=\"'userProfile.deleteProperty' | transloco\"\n [disabled]=\"!!pending()\"\n (click)=\"onDeleteData('data', 'profilePhoto')\">\n <TrashIcon />\n </button>\n }\n }\n <div class=\"grow\"></div>\n <div class=\"flex flex-col gap-4\">\n <!-- LANGUAGE -->\n <select\n class=\"hover:outline-primary focus:outline-primary border-foreground/10 bg-background hover:bg-muted focus:bg-muted h-8 rounded-md border px-2 hover:outline focus:outline\"\n [(ngModel)]=\"currentLanguage\"\n (change)=\"changeLanguage()\">\n @for (lang of AllLanguages; track lang.code) {\n <option [value]=\"lang.code\">\n {{ lang.label }}\n </option>\n }\n </select>\n <!-- CHANGE PASSWORD -->\n @if (allowChangePassword()) {\n <button\n [disabled]=\"changingPassword()\"\n (click)=\"changingPassword.set(true)\">\n {{ \"login.changePassword\" | transloco }}\n </button>\n }\n </div>\n </div>\n <!-- always rendered, so the room is reserved and the layout doesn't shift when a message appears -->\n <p\n class=\"min-h-5 text-sm\"\n role=\"status\"\n [attr.aria-live]=\"feedback()?.type === 'error' ? 'assertive' : 'polite'\"\n [class.text-destructive]=\"feedback()?.type === 'error'\"\n [class.text-green-600]=\"feedback()?.type === 'success'\">\n {{ feedback()?.message }}\n </p>\n @if (changingPassword()) {\n <ChangePassword\n [username]=\"username()\"\n [redirectAfterSuccess]=\"false\"\n [redirectAfterCancel]=\"false\"\n (cancel)=\"changingPassword.set(false)\"\n (success)=\"changingPassword.set(false)\" />\n } @else {\n <!-- -m-1 p-1: room for the outline of a field, which the scroll container would otherwise clip -->\n <div class=\"-m-1 min-h-0 flex-1 overflow-auto p-1\">\n <div>\n @for (key of formKeys(); track key) {\n <div>\n <p class=\"px-2\">{{ `userProfile.data.${key}` | transloco }}</p>\n <div\n class=\"group flex flex-row rounded-md px-2 py-0.5\"\n [class.ring-1]=\"!!fieldState('data.' + key)\"\n [class.ring-destructive]=\"fieldState('data.' + key) === 'error'\"\n [class.ring-green-600]=\"\n fieldState('data.' + key) === 'success'\n \">\n <div\n class=\"min-w-0 grow break-words whitespace-pre-line\"\n [class.text-muted-foreground]=\"!getDataValue('data', key)\">\n @if (propertyToEdit() === \"data.\" + key) {\n <textarea\n class=\"hover:outline-primary focus:outline-primary border-foreground/20 hover:bg-muted focus:bg-muted mt-2 w-full rounded-md border px-2 pt-1 hover:outline focus:outline\"\n id=\"user-profile-{{ key }}\"\n [(ngModel)]=\"value\"></textarea>\n } @else {\n {{\n getDataValue(\"data\", key) ||\n (\"userProfile.notDefined\" | transloco)\n }}\n }\n </div>\n @if (propertyToEdit() === \"data.\" + key) {\n <button\n variant=\"ghost\"\n [iconOnly]=\"true\"\n class=\"visible mx-2 cursor-pointer sm:invisible sm:group-hover:visible\"\n title=\"{{ 'userProfile.editProperty' | transloco }}\"\n [attr.aria-label]=\"'userProfile.editProperty' | transloco\"\n [disabled]=\"!!pending()\"\n (click)=\"onSaveData('data', key)\">\n <save-icon class=\"ml-2\" />\n </button>\n <button\n variant=\"ghost\"\n [iconOnly]=\"true\"\n class=\"visible cursor-pointer sm:invisible sm:group-hover:visible\"\n title=\"{{ 'userProfile.editProperty' | transloco }}\"\n [attr.aria-label]=\"'userProfile.editProperty' | transloco\"\n (click)=\"onCancelEdit()\">\n <UndoIcon />\n </button>\n } @else {\n <button\n variant=\"ghost\"\n [iconOnly]=\"true\"\n class=\"visible mx-2 cursor-pointer sm:invisible sm:group-hover:visible\"\n title=\"{{ 'userProfile.editProperty' | transloco }}\"\n [attr.aria-label]=\"'userProfile.editProperty' | transloco\"\n (click)=\"onEdit('data', key)\">\n <EditIcon />\n </button>\n <button\n variant=\"ghost\"\n [iconOnly]=\"true\"\n class=\"text-destructive visible cursor-pointer sm:invisible sm:group-hover:visible\"\n title=\"{{ 'userProfile.deleteProperty' | transloco }}\"\n [attr.aria-label]=\"'userProfile.deleteProperty' | transloco\"\n [disabled]=\"!!pending()\"\n (click)=\"onDeleteData('data', key)\">\n <TrashIcon />\n </button>\n }\n </div>\n </div>\n }\n @for (key of customData(); track key) {\n <div>\n <p class=\"px-2\">{{ key }}</p>\n <div\n class=\"group flex flex-row rounded-md px-2 py-0.5\"\n [class.ring-1]=\"!!fieldState('customData.' + key)\"\n [class.ring-destructive]=\"\n fieldState('customData.' + key) === 'error'\n \"\n [class.ring-green-600]=\"\n fieldState('customData.' + key) === 'success'\n \">\n <div\n class=\"min-w-0 grow break-words whitespace-pre-line\"\n [class.text-muted-foreground]=\"\n !getDataValue('customData', key)\n \">\n @if (propertyToEdit() === \"customData.\" + key) {\n <textarea\n class=\"hover:outline-primary focus:outline-primary border-foreground/20 hover:bg-muted focus:bg-muted mt-2 w-full rounded-md border px-2 pt-1 hover:outline focus:outline\"\n id=\"user-profile-{{ key }}\"\n [(ngModel)]=\"value\"></textarea>\n } @else {\n {{\n getDataValue(\"customData\", key) ||\n (\"userProfile.notDefined\" | transloco)\n }}\n }\n </div>\n @if (propertyToEdit() === \"customData.\" + key) {\n <button\n variant=\"ghost\"\n [iconOnly]=\"true\"\n class=\"visible mx-2 cursor-pointer sm:invisible sm:group-hover:visible\"\n title=\"{{ 'userProfile.editProperty' | transloco }}\"\n [attr.aria-label]=\"'userProfile.editProperty' | transloco\"\n [disabled]=\"!!pending()\"\n (click)=\"onSaveData('customData', key)\">\n <save-icon class=\"ml-2\" />\n </button>\n <button\n variant=\"ghost\"\n [iconOnly]=\"true\"\n class=\"visible cursor-pointer sm:invisible sm:group-hover:visible\"\n title=\"{{ 'userProfile.editProperty' | transloco }}\"\n [attr.aria-label]=\"'userProfile.editProperty' | transloco\"\n (click)=\"onCancelEdit()\">\n <UndoIcon />\n </button>\n } @else {\n <button\n variant=\"ghost\"\n [iconOnly]=\"true\"\n class=\"visible mx-2 cursor-pointer sm:invisible sm:group-hover:visible\"\n title=\"{{ 'userProfile.editProperty' | transloco }}\"\n [attr.aria-label]=\"'userProfile.editProperty' | transloco\"\n (click)=\"onEdit('customData', key)\">\n <EditIcon />\n </button>\n <button\n variant=\"ghost\"\n [iconOnly]=\"true\"\n class=\"text-destructive visible cursor-pointer sm:invisible sm:group-hover:visible\"\n title=\"{{ 'userProfile.deleteProperty' | transloco }}\"\n [attr.aria-label]=\"'userProfile.deleteProperty' | transloco\"\n [disabled]=\"!!pending()\"\n (click)=\"onDeleteData('customData', key)\">\n <TrashIcon />\n </button>\n }\n </div>\n </div>\n }\n </div>\n </div>\n }\n </div>\n} @else if (userProfileResource.isLoading()) {\n <span>Loading...</span>\n} @else if (userProfileResource.error()) {\n <p>Please contact an administrator to create your user profile.</p>\n}\n\n<!-- AVATAR UPLOAD -->\n<input\n #avatarInput\n class=\"hidden\"\n type=\"file\"\n accept=\"image/*\"\n (change)=\"uploadAvatar($event)\" />\n" }]
18603
+ ], providers: [provideTranslocoScope("user-profile", "login")], template: "@if (userProfileResource.hasValue()) {\n @let profile = userProfile();\n <div class=\"flex min-h-0 flex-1 flex-col gap-2 p-4\">\n <div class=\"mb-4 flex\">\n @if (allowProfilePhoto()) {\n <!-- AVATAR -->\n <Avatar\n class=\"size-14 cursor-pointer\"\n [class.ring-2]=\"!!fieldState('data.profilePhoto')\"\n [class.ring-destructive]=\"fieldState('data.profilePhoto') === 'error'\"\n [class.ring-green-600]=\"fieldState('data.profilePhoto') === 'success'\"\n (click)=\"avatarInput.click()\">\n @if (profile?.data?.profilePhoto) {\n <AvatarImage\n [src]=\"profile?.data?.profilePhoto!\"\n width=\"44\"\n height=\"44\"\n alt=\"avatar\" />\n }\n <AvatarFallback class=\"text-lg\">\n @if (initials()) {\n <span>{{ initials() }}</span>\n } @else {\n <UserIcon class=\"size-7\" />\n }\n </AvatarFallback>\n </Avatar>\n @if (profile?.data?.profilePhoto) {\n <button\n variant=\"ghost\"\n [iconOnly]=\"true\"\n class=\"text-destructive cursor-pointer\"\n title=\"{{ 'userProfile.deleteProperty' | transloco }}\"\n [attr.aria-label]=\"'userProfile.deleteProperty' | transloco\"\n [disabled]=\"!!pending()\"\n (click)=\"onDeleteData('data', 'profilePhoto')\">\n <TrashIcon />\n </button>\n }\n }\n <div class=\"grow\"></div>\n <div class=\"flex flex-col gap-4\">\n <!-- LANGUAGE -->\n <select\n class=\"hover:outline-primary focus:outline-primary border-foreground/10 bg-background hover:bg-muted focus:bg-muted h-8 rounded-md border px-2 hover:outline focus:outline\"\n [(ngModel)]=\"currentLanguage\"\n (change)=\"changeLanguage()\">\n @for (lang of AllLanguages; track lang.code) {\n <option [value]=\"lang.code\">\n {{ lang.label }}\n </option>\n }\n </select>\n <!-- CHANGE PASSWORD -->\n @if (allowChangePassword()) {\n <button\n [disabled]=\"changingPassword()\"\n (click)=\"changingPassword.set(true)\">\n {{ \"login.changePassword\" | transloco }}\n </button>\n }\n </div>\n </div>\n <!-- always rendered, so the room is reserved and the layout doesn't shift when a message appears -->\n <p\n class=\"min-h-5 text-sm\"\n role=\"status\"\n [attr.aria-live]=\"feedback()?.type === 'error' ? 'assertive' : 'polite'\"\n [class.text-destructive]=\"feedback()?.type === 'error'\"\n [class.text-green-600]=\"feedback()?.type === 'success'\">\n {{ feedback()?.message }}\n </p>\n @if (changingPassword()) {\n <ChangePassword\n [username]=\"username()\"\n [redirectAfterSuccess]=\"false\"\n [redirectAfterCancel]=\"false\"\n (cancel)=\"changingPassword.set(false)\"\n (success)=\"changingPassword.set(false)\" />\n } @else {\n <!-- -m-1 p-1: room for the outline of a field, which the scroll container would otherwise clip -->\n <div class=\"-m-1 min-h-0 flex-1 overflow-auto p-1\">\n <div>\n @for (key of formKeys(); track key) {\n <div>\n <p class=\"px-2\">{{ `userProfile.data.${key}` | transloco }}</p>\n <div\n class=\"group flex flex-row rounded-md px-2 py-0.5\"\n [class.ring-1]=\"!!fieldState('data.' + key)\"\n [class.ring-destructive]=\"fieldState('data.' + key) === 'error'\"\n [class.ring-green-600]=\"\n fieldState('data.' + key) === 'success'\n \">\n <div\n class=\"min-w-0 grow break-words whitespace-pre-line\"\n [class.text-muted-foreground]=\"!getDataValue('data', key)\">\n @if (propertyToEdit() === \"data.\" + key) {\n <textarea\n class=\"hover:outline-primary focus:outline-primary border-foreground/20 hover:bg-muted focus:bg-muted mt-2 w-full rounded-md border px-2 pt-1 hover:outline focus:outline\"\n id=\"user-profile-{{ key }}\"\n [(ngModel)]=\"value\"></textarea>\n } @else {\n {{\n getDataValue(\"data\", key) ||\n (\"userProfile.notDefined\" | transloco)\n }}\n }\n </div>\n @if (propertyToEdit() === \"data.\" + key) {\n <button\n variant=\"ghost\"\n [iconOnly]=\"true\"\n class=\"visible mx-2 cursor-pointer sm:invisible sm:group-hover:visible\"\n title=\"{{ 'userProfile.editProperty' | transloco }}\"\n [attr.aria-label]=\"'userProfile.editProperty' | transloco\"\n [disabled]=\"!!pending()\"\n (click)=\"onSaveData('data', key)\">\n <save-icon class=\"ml-2\" />\n </button>\n <button\n variant=\"ghost\"\n [iconOnly]=\"true\"\n class=\"visible cursor-pointer sm:invisible sm:group-hover:visible\"\n title=\"{{ 'userProfile.editProperty' | transloco }}\"\n [attr.aria-label]=\"'userProfile.editProperty' | transloco\"\n (click)=\"onCancelEdit()\">\n <UndoIcon />\n </button>\n } @else {\n <button\n variant=\"ghost\"\n [iconOnly]=\"true\"\n class=\"visible mx-2 cursor-pointer sm:invisible sm:group-hover:visible\"\n title=\"{{ 'userProfile.editProperty' | transloco }}\"\n [attr.aria-label]=\"'userProfile.editProperty' | transloco\"\n (click)=\"onEdit('data', key)\">\n <EditIcon />\n </button>\n <button\n variant=\"ghost\"\n [iconOnly]=\"true\"\n class=\"text-destructive visible cursor-pointer sm:invisible sm:group-hover:visible\"\n title=\"{{ 'userProfile.deleteProperty' | transloco }}\"\n [attr.aria-label]=\"'userProfile.deleteProperty' | transloco\"\n [disabled]=\"!!pending() || !hasStoredValue('data', key)\"\n (click)=\"onDeleteData('data', key)\">\n <TrashIcon />\n </button>\n }\n </div>\n </div>\n }\n @for (key of customData(); track key) {\n <div>\n <p class=\"px-2\">{{ key }}</p>\n <div\n class=\"group flex flex-row rounded-md px-2 py-0.5\"\n [class.ring-1]=\"!!fieldState('customData.' + key)\"\n [class.ring-destructive]=\"\n fieldState('customData.' + key) === 'error'\n \"\n [class.ring-green-600]=\"\n fieldState('customData.' + key) === 'success'\n \">\n <div\n class=\"min-w-0 grow break-words whitespace-pre-line\"\n [class.text-muted-foreground]=\"\n !getDataValue('customData', key)\n \">\n @if (propertyToEdit() === \"customData.\" + key) {\n <textarea\n class=\"hover:outline-primary focus:outline-primary border-foreground/20 hover:bg-muted focus:bg-muted mt-2 w-full rounded-md border px-2 pt-1 hover:outline focus:outline\"\n id=\"user-profile-{{ key }}\"\n [(ngModel)]=\"value\"></textarea>\n } @else {\n {{\n getDataValue(\"customData\", key) ||\n (\"userProfile.notDefined\" | transloco)\n }}\n }\n </div>\n @if (propertyToEdit() === \"customData.\" + key) {\n <button\n variant=\"ghost\"\n [iconOnly]=\"true\"\n class=\"visible mx-2 cursor-pointer sm:invisible sm:group-hover:visible\"\n title=\"{{ 'userProfile.editProperty' | transloco }}\"\n [attr.aria-label]=\"'userProfile.editProperty' | transloco\"\n [disabled]=\"!!pending()\"\n (click)=\"onSaveData('customData', key)\">\n <save-icon class=\"ml-2\" />\n </button>\n <button\n variant=\"ghost\"\n [iconOnly]=\"true\"\n class=\"visible cursor-pointer sm:invisible sm:group-hover:visible\"\n title=\"{{ 'userProfile.editProperty' | transloco }}\"\n [attr.aria-label]=\"'userProfile.editProperty' | transloco\"\n (click)=\"onCancelEdit()\">\n <UndoIcon />\n </button>\n } @else {\n <button\n variant=\"ghost\"\n [iconOnly]=\"true\"\n class=\"visible mx-2 cursor-pointer sm:invisible sm:group-hover:visible\"\n title=\"{{ 'userProfile.editProperty' | transloco }}\"\n [attr.aria-label]=\"'userProfile.editProperty' | transloco\"\n (click)=\"onEdit('customData', key)\">\n <EditIcon />\n </button>\n <button\n variant=\"ghost\"\n [iconOnly]=\"true\"\n class=\"text-destructive visible cursor-pointer sm:invisible sm:group-hover:visible\"\n title=\"{{ 'userProfile.deleteProperty' | transloco }}\"\n [attr.aria-label]=\"'userProfile.deleteProperty' | transloco\"\n [disabled]=\"\n !!pending() || !hasStoredValue('customData', key)\n \"\n (click)=\"onDeleteData('customData', key)\">\n <TrashIcon />\n </button>\n }\n </div>\n </div>\n }\n </div>\n </div>\n }\n </div>\n} @else if (userProfileResource.isLoading()) {\n <span>Loading...</span>\n} @else if (userProfileResource.error()) {\n <p>Please contact an administrator to create your user profile.</p>\n}\n\n<!-- AVATAR UPLOAD -->\n<input\n #avatarInput\n class=\"hidden\"\n type=\"file\"\n accept=\"image/*\"\n (change)=\"uploadAvatar($event)\" />\n" }]
18523
18604
  }], ctorParameters: () => [], propDecorators: { createInputElement: [{ type: i0.ViewChild, args: ["avatarInput", { isSignal: true }] }], value: [{ type: i0.Input, args: [{ isSignal: true, alias: "value", required: false }] }, { type: i0.Output, args: ["valueChange"] }], currentLanguage: [{ type: i0.Input, args: [{ isSignal: true, alias: "currentLanguage", required: false }] }, { type: i0.Output, args: ["currentLanguageChange"] }] } });
18524
18605
 
18525
18606
  class UserProfileDialog {
@@ -18796,5 +18877,5 @@ const queryNameResolver = () => {
18796
18877
  * Generated bundle index. Do not edit.
18797
18878
  */
18798
18879
 
18799
- export { AGGREGATIONS_NAMES, AGGREGATIONS_NAMES_PRESET_DEFAULT, APP_FEATURES, AdvancedFiltersComponent, AdvancedSearch, AdvancedSearchComponent, AggregationComponent, AggregationDateComponent, AggregationDateRangeDialogComponent, AggregationItemDirective, AggregationListComponent, AggregationPanelComponent, AggregationTreeComponent, AggregationsService, AggregationsStore, Alert, AlertDialog, AlertsComponent, AppService, AppStore, ApplicationService, ApplicationStore, ArticleEntities, ArticleExtracts, ArticleLabels, ArticleSimilarDocuments, AsideFiltersComponent, AuditFeedbackType, AuditService, AuthGuard, AuthPageComponent, AutocompleteService, BOOKMARKS_CONFIG, BOOKMARKS_OPTIONS, BackdropComponent, BackdropService, BookmarkButtonComponent, BookmarksComponent, COLLECTIONS_CONFIG, COLLECTIONS_OPTIONS, COMPONENTS_FOR_DOCUMENT_TYPE, ChangePasswordComponent, ChildMarkerDirective, CollectionsComponent, CollectionsDialog, DRAWER_COMPONENT, DRAWER_STACK_MAX_COUNT, DateComponent, DeleteCollectionDialog, DidYouMeanComponent, DocumentLocatorComponent, DrawerAdvancedFiltersComponent, DrawerComponent, DrawerNavbarComponent, DrawerPreviewComponent, DrawerService, DrawerStackComponent, DrawerStackService, DropdownInputComponent, DropdownListComponent, ErrorComponent, ExportDialog, ExportService, FILTERS_BREAKPOINT, FILTER_DATE_ALLOW_CUSTOM_RANGE, FeatureFlagsDialogComponent, FeedbackDialogComponent, FileSizePipe, FilterButtonComponent, FiltersBarComponent, HIGHLIGHTS, HighlightWordPipe, InfinityScrollDirective, InlineWorker, JsonMethodPluginService, KeyboardNavigatorDirective, LabelService, LabelsEditDialog, LoadingComponent, MetadataComponent, MissingTermsComponent, MoreButtonComponent, MoreComponent, MultiSelectLabelsComponent, MultiSelectionToolbarComponent, NON_SEARCHABLE_COLUMNS, NON_SEARCHABLE_DEFAULTS, NavbarTabsComponent, NavigationService, NoResultComponent, OpenArticleOnCtrlEnterDirective, OperatorPipe, OverflowItemDirective, OverflowManagerDirective, OverflowStopDirective, OverrideUserDialogComponent, PREVIEW_CONFIG, PagerComponent, PreviewNavigator, PreviewService, PrincipalService, PrincipalStore, QueryParamsStore, QueryService, RECENT_SEARCHES_CONFIG, RECENT_SEARCHES_OPTIONS, ROUTE_COMPONENTS, RecentSearchesComponent, ResetUserSettingsDialogComponent, SAVED_SEARCHES_CONFIG, SAVED_SEARCHES_OPTIONS, SavedSearchDialog, SavedSearchesComponent, SavedSearchesService, SearchFeedbackComponent, SearchInputFooter, SearchService, SelectArticleDirective, SelectArticleOnClickDirective, SelectionHistoryService, SelectionService, SelectionStore, ShowBookmarkDirective, SidebarNavComponent, SignInComponent, SignedOutComponent, SortSelectorComponent, SourceComponent, SourceIconPipe, SponsoredResultsComponent, SyslangPipe, THEMES, TextChunkService, ThemeProviderDirective, ThemeSelectorComponent, ThemeStore, ThemeToggleComponent, TranslocoDateImpurePipe, UserProfileDialog, UserProfileFormComponent, UserProfileService, UserSettingsStore, __resetSignInMutexForTests, applyThemeToNativeElement, auditInterceptorFn, authInterceptorFn, bodyInterceptorFn, bootstrapApp, buildQuery, debouncedSignal, errorInterceptorFn, getCurrentPath, getCurrentQueryName, getQueryNameFromRoute, injectRouteNavigation, isAuthRoute, isSignInInFlight, legacyFilterToFilter, processCssVars, queryNameResolver, signIn, themeColorNameToCssVariable, themeColorsToCssVariables, toastInterceptorFn, withAggregationsFeatures, withAlertsFeatures, withAppFeatures, withApplicationFeatures, withAssistantFeatures, withBasketsFeatures, withBookmarkFeatures, withBootstrapApp, withExtractsFeatures, withFetch, withMultiSelectionFeatures, withQueryParamsFeatures, withRecentSearchesFeatures, withSavedSearchesFeatures, withSelectionFeatures, withThemeBodyHook, withThemes, withThemesFeatures, withUserSettingsFeatures };
18880
+ export { AGGREGATIONS_NAMES, AGGREGATIONS_NAMES_PRESET_DEFAULT, APP_FEATURES, AdvancedFiltersComponent, AdvancedSearch, AdvancedSearchComponent, AggregationComponent, AggregationDateComponent, AggregationDateRangeDialogComponent, AggregationItemDirective, AggregationListComponent, AggregationPanelComponent, AggregationTreeComponent, AggregationsService, AggregationsStore, Alert, AlertDialog, AlertsComponent, AppService, AppStore, ApplicationService, ApplicationStore, ArticleEntities, ArticleExtracts, ArticleLabels, ArticleSimilarDocuments, AsideFiltersComponent, AuditFeedbackType, AuditService, AuthGuard, AuthPageComponent, AutocompleteService, BOOKMARKS_CONFIG, BOOKMARKS_OPTIONS, BackdropComponent, BackdropService, BookmarkButtonComponent, BookmarksComponent, COLLECTIONS_CONFIG, COLLECTIONS_OPTIONS, COMPONENTS_FOR_DOCUMENT_TYPE, ChangePasswordComponent, ChildMarkerDirective, CollectionsComponent, CollectionsDialog, DRAWER_COMPONENT, DRAWER_STACK_MAX_COUNT, DateComponent, DeleteCollectionDialog, DidYouMeanComponent, DocumentLocatorComponent, DrawerAdvancedFiltersComponent, DrawerComponent, DrawerNavbarComponent, DrawerPreviewComponent, DrawerService, DrawerStackComponent, DrawerStackService, DropdownInputComponent, DropdownListComponent, EntityNavigationService, ErrorComponent, ExportDialog, ExportService, FILTERS_BREAKPOINT, FILTER_DATE_ALLOW_CUSTOM_RANGE, FeatureFlagsDialogComponent, FeedbackDialogComponent, FileSizePipe, FilterButtonComponent, FiltersBarComponent, HIGHLIGHTS, HighlightWordPipe, InfinityScrollDirective, InlineWorker, JsonMethodPluginService, KeyboardNavigatorDirective, LabelService, LabelsEditDialog, LoadingComponent, MetadataComponent, MissingTermsComponent, MoreButtonComponent, MoreComponent, MultiSelectLabelsComponent, MultiSelectionToolbarComponent, NON_SEARCHABLE_COLUMNS, NON_SEARCHABLE_DEFAULTS, NavbarTabsComponent, NavigationService, NoResultComponent, OpenArticleOnCtrlEnterDirective, OperatorPipe, OverflowItemDirective, OverflowManagerDirective, OverflowStopDirective, OverrideUserDialogComponent, PREVIEW_CONFIG, PagerComponent, PreviewNavigator, PreviewService, PrincipalService, PrincipalStore, QueryParamsStore, QueryService, RECENT_SEARCHES_CONFIG, RECENT_SEARCHES_OPTIONS, ROUTE_COMPONENTS, RecentSearchesComponent, ResetUserSettingsDialogComponent, SAVED_SEARCHES_CONFIG, SAVED_SEARCHES_OPTIONS, SavedSearchDialog, SavedSearchesComponent, SavedSearchesService, SearchFeedbackComponent, SearchInputFooter, SearchService, SelectArticleDirective, SelectArticleOnClickDirective, SelectionHistoryService, SelectionService, SelectionStore, ShowBookmarkDirective, SidebarNavComponent, SignInComponent, SignedOutComponent, SortSelectorComponent, SourceComponent, SourceIconPipe, SponsoredResultsComponent, SyslangPipe, THEMES, TextChunkService, ThemeProviderDirective, ThemeSelectorComponent, ThemeStore, ThemeToggleComponent, TranslocoDateImpurePipe, UserProfileDialog, UserProfileFormComponent, UserProfileService, UserSettingsStore, __resetSignInMutexForTests, applyThemeToNativeElement, auditInterceptorFn, authInterceptorFn, bodyInterceptorFn, bootstrapApp, buildQuery, debouncedSignal, errorInterceptorFn, getCurrentPath, getCurrentQueryName, getQueryNameFromRoute, injectRouteNavigation, isAuthRoute, isSignInInFlight, legacyFilterToFilter, processCssVars, queryNameResolver, signIn, themeColorNameToCssVariable, themeColorsToCssVariables, toastInterceptorFn, withAggregationsFeatures, withAlertsFeatures, withAppFeatures, withApplicationFeatures, withAssistantFeatures, withBasketsFeatures, withBookmarkFeatures, withBootstrapApp, withExtractsFeatures, withFetch, withMultiSelectionFeatures, withQueryParamsFeatures, withRecentSearchesFeatures, withSavedSearchesFeatures, withSelectionFeatures, withThemeBodyHook, withThemes, withThemesFeatures, withUserSettingsFeatures };
18800
18881
  //# sourceMappingURL=sinequa-atomic-angular.mjs.map