@sinequa/atomic-angular 1.19.3 → 1.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,12 +2,12 @@ import * as i0 from '@angular/core';
2
2
  import { Injectable, inject, HostBinding, Component, Pipe, InjectionToken, computed, ChangeDetectorRef, DestroyRef, LOCALE_ID, Inject, Optional, input, output, signal, effect, assertInInjectionContext, runInInjectionContext, EnvironmentInjector, Injector, EventEmitter, Directive, viewChild, ElementRef, afterNextRender, untracked, linkedSignal, model, TemplateRef, HostListener, Renderer2, contentChildren, contentChild, booleanAttribute, resource, ChangeDetectionStrategy, ViewContainerRef, numberAttribute, viewChildren, afterRenderEffect, afterEveryRender } from '@angular/core';
3
3
  import { BehaviorSubject, Subscription, Subject, of, catchError, map, tap, EMPTY, throwError, from, filter as filter$1, shareReplay, switchMap, firstValueFrom, fromEvent, debounceTime } from 'rxjs';
4
4
  import { TranslocoService, TranslocoPipe, provideTranslocoScope } from '@jsverse/transloco';
5
- import { DropdownComponent, DropdownContentComponent, InputComponent, ButtonComponent, cn, FaIconComponent, EllipsisIcon, ChevronRightIcon, MenuComponent, MenuContentComponent, MenuItemComponent, LinkComponent, CardComponent, CardHeaderComponent, CardContentComponent, BadgeComponent, DialogComponent, DialogHeaderComponent, DialogTitleComponent, DialogContentComponent, DialogFooterComponent, ListItemComponent, SquareCheckIcon, SquareMinusIcon, SquareIcon, SwitchComponent, SelectOptionDirective, DialogService, XMarkIcon, InboxIcon, SparklesIcon, FileOutputIcon, TabsComponent, TabsListComponent, TabComponent, SidebarMenuComponent, SidebarMenuItemComponent, SidebarMenuButtonComponent, TooltipDirective, FrownIcon, ChevronLeftIcon, ChevronsLeftIcon, ChevronsRightIcon, ArrowUpAzIcon, ArrowDownZaIcon, ArrowUpRightFromSquareIcon, ToggleRightIcon, ToggleLeftIcon, Separator, SheetCloseDirective, ArrowLeftIcon, SheetService, DateRangePickerDirective, DatepickerDirective, FilterIcon, FilterXIcon, IconButtonComponent, HighlighterIcon, TagsIcon, SpinnerIcon, MagnifyingGlassIcon, LoadingCircleIcon, CircleCheckIcon, PopoverComponent, BellIcon, TrashIcon, BarsIcon, CardFooterComponent, InputGroupInput, InputGroupComponent, InputGroupAddonComponent, EyeSlashIcon, EyeIcon, BookmarkIcon, PopoverContentComponent, UserIcon, FolderIcon, DialogDescriptionComponent, VerticalDividerComponent, CommentIcon, ThumbsUpIcon, ThumbsDownIcon, DropdownDirective, SearchIcon, TriangleAlertIcon, BreakpointObserverService, ListFilterIcon, SheetComponent, SheetHeaderComponent, SheetTitleComponent, TrashCanIcon, CircleXIcon, InfoCircleIcon, HorizontalDividerComponent, HistoryIcon, StarIcon, FlagEnglishIcon, FlagFrenchIcon, FlagGermanIcon, EditIcon, UndoIcon, SaveIcon, AvatarComponent, AvatarFallbackComponent, AvatarImageComponent } from '@sinequa/ui';
5
+ import { DropdownComponent, DropdownContentComponent, InputComponent, ButtonComponent, cn, FaIconComponent, EllipsisIcon, ChevronRightIcon, MenuComponent, MenuContentComponent, MenuItemComponent, LinkComponent, CardComponent, CardHeaderComponent, CardContentComponent, BadgeComponent, DialogComponent, DialogHeaderComponent, DialogTitleComponent, DialogContentComponent, DialogFooterComponent, ListItemComponent, SquareCheckIcon, SquareMinusIcon, SquareIcon, SwitchComponent, SelectOptionDirective, DialogService, XMarkIcon, InboxIcon, SparklesIcon, FileOutputIcon, TabsComponent, TabsListComponent, TabComponent, SidebarMenuComponent, SidebarMenuItemComponent, SidebarMenuButtonComponent, TooltipDirective, FrownIcon, ChevronLeftIcon, ChevronsLeftIcon, ChevronsRightIcon, ArrowUpAzIcon, ArrowDownZaIcon, ArrowUpRightFromSquareIcon, ToggleRightIcon, ToggleLeftIcon, Separator, SheetCloseDirective, ArrowLeftIcon, SheetService, DateRangePickerDirective, DatepickerDirective, FilterIcon, FilterXIcon, IconButtonComponent, HighlighterIcon, TagsIcon, SpinnerIcon, MagnifyingGlassIcon, LoadingCircleIcon, CircleCheckIcon, PopoverComponent, BellIcon, TrashIcon, BarsIcon, CardFooterComponent, InputGroupInput, InputGroupComponent, InputGroupAddonComponent, EyeSlashIcon, EyeIcon, BookmarkIcon, PopoverContentComponent, UserIcon, FolderIcon, DialogDescriptionComponent, VerticalDividerComponent, CommentIcon, ThumbsUpIcon, ThumbsDownIcon, DropdownDirective, SearchIcon, TriangleAlertIcon, BreakpointObserverService, ListFilterIcon, SheetComponent, SheetHeaderComponent, SheetTitleComponent, TrashCanIcon, CircleXIcon, InfoCircleIcon, HorizontalDividerComponent, HistoryIcon, StarIcon, FlagEnglishIcon, FlagFrenchIcon, FlagGermanIcon, EditIcon, UndoIcon, SaveIcon, AvatarComponent, AvatarFallbackComponent, GlobeIcon, UserSecretIcon, AvatarImageComponent } from '@sinequa/ui';
6
6
  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, 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';
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, deleteUserProfilePrivateCustomData, deleteUserProfileProperty, patchUserProfilePrivateCustomData, 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
  }
@@ -6114,25 +6119,41 @@ class MissingTermsComponent {
6114
6119
  missingTerms = computed(() => {
6115
6120
  const query = this.queryParamsStore.getQuery();
6116
6121
  return this.article()
6117
- .termspresence?.filter(tp => tp.presence === 'missing')
6122
+ .termspresence?.filter(tp => tp.presence === "missing")
6118
6123
  .map(tp => {
6119
- const bracketMatch = tp.term.match(/^\[(.+)\]$/);
6120
- const cleanTerm = bracketMatch ? bracketMatch[1] : tp.term;
6121
- const escapedTerm = cleanTerm.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
6122
- const text = (query.text || '').replace(new RegExp(`\\b${escapedTerm}\\b`, 'gi'), '').replace(/\s+/g, ' ').trim();
6123
- return {
6124
- value: cleanTerm,
6125
- queryParams: { q: `${text} +[${cleanTerm}]` }
6126
- };
6124
+ const { value, q } = mustIncludeQuery(query.text || "", tp.term);
6125
+ return { value, queryParams: { q } };
6127
6126
  });
6128
6127
  }, ...(ngDevMode ? [{ debugName: "missingTerms" }] : []));
6129
6128
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.29", ngImport: i0, type: MissingTermsComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
6130
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "20.3.29", type: MissingTermsComponent, isStandalone: true, selector: "missing-terms, MissingTerms, missingterms", inputs: { article: { classPropertyName: "article", publicName: "article", isSignal: true, isRequired: true, transformFunction: null } }, providers: [provideTranslocoScope('article')], ngImport: i0, template: "@if ((missingTerms() || []).length > 0) {\n <span class=\"terms-label\">{{ 'article.missingTerms' | transloco }}</span>\n <span class=\"terms\">\n @for (term of missingTerms(); track $index) {\n <span class=\"term ms-1\">{{ term.value }}</span>\n }\n </span>\n <span class=\"mx-1\">|</span>\n <span class=\"terms-label ms-1\">{{ 'article.mustInclude' | transloco }}</span>\n <span class=\"terms\">\n @for (term of missingTerms(); track $index) {\n <a role=\"button\" class=\"ms-1\" routerLink=\"search\" [queryParams]=\"term.queryParams\" (click)=\"$event.stopPropagation()\">{{ term.value }}</a>\n }\n </span>\n}\n", styles: [".term{text-decoration-line:line-through}\n"], dependencies: [{ kind: "directive", type: RouterLink, selector: "[routerLink]", inputs: ["target", "queryParams", "fragment", "queryParamsHandling", "state", "info", "relativeTo", "preserveFragment", "skipLocationChange", "replaceUrl", "routerLink"] }, { kind: "pipe", type: TranslocoPipe, name: "transloco" }] });
6129
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "20.3.29", type: MissingTermsComponent, isStandalone: true, selector: "missing-terms, MissingTerms, missingterms", inputs: { article: { classPropertyName: "article", publicName: "article", isSignal: true, isRequired: true, transformFunction: null } }, providers: [provideTranslocoScope("article")], ngImport: i0, template: "@if ((missingTerms() || []).length > 0) {\n <span class=\"terms-label\">{{ 'article.missingTerms' | transloco }}</span>\n <span class=\"terms\">\n @for (term of missingTerms(); track $index) {\n <span class=\"term ms-1\">{{ term.value }}</span>\n }\n </span>\n <span class=\"mx-1\">|</span>\n <span class=\"terms-label ms-1\">{{ 'article.mustInclude' | transloco }}</span>\n <span class=\"terms\">\n @for (term of missingTerms(); track $index) {\n <a role=\"button\" class=\"ms-1\" routerLink=\"search\" [queryParams]=\"term.queryParams\" (click)=\"$event.stopPropagation()\">{{ term.value }}</a>\n }\n </span>\n}\n", styles: [".term{text-decoration-line:line-through}\n"], dependencies: [{ kind: "directive", type: RouterLink, selector: "[routerLink]", inputs: ["target", "queryParams", "fragment", "queryParamsHandling", "state", "info", "relativeTo", "preserveFragment", "skipLocationChange", "replaceUrl", "routerLink"] }, { kind: "pipe", type: TranslocoPipe, name: "transloco" }] });
6131
6130
  }
6132
6131
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.29", ngImport: i0, type: MissingTermsComponent, decorators: [{
6133
6132
  type: Component,
6134
- args: [{ selector: 'missing-terms, MissingTerms, missingterms', standalone: true, imports: [TranslocoPipe, RouterLink], providers: [provideTranslocoScope('article')], template: "@if ((missingTerms() || []).length > 0) {\n <span class=\"terms-label\">{{ 'article.missingTerms' | transloco }}</span>\n <span class=\"terms\">\n @for (term of missingTerms(); track $index) {\n <span class=\"term ms-1\">{{ term.value }}</span>\n }\n </span>\n <span class=\"mx-1\">|</span>\n <span class=\"terms-label ms-1\">{{ 'article.mustInclude' | transloco }}</span>\n <span class=\"terms\">\n @for (term of missingTerms(); track $index) {\n <a role=\"button\" class=\"ms-1\" routerLink=\"search\" [queryParams]=\"term.queryParams\" (click)=\"$event.stopPropagation()\">{{ term.value }}</a>\n }\n </span>\n}\n", styles: [".term{text-decoration-line:line-through}\n"] }]
6133
+ args: [{ selector: "missing-terms, MissingTerms, missingterms", standalone: true, imports: [TranslocoPipe, RouterLink], providers: [provideTranslocoScope("article")], template: "@if ((missingTerms() || []).length > 0) {\n <span class=\"terms-label\">{{ 'article.missingTerms' | transloco }}</span>\n <span class=\"terms\">\n @for (term of missingTerms(); track $index) {\n <span class=\"term ms-1\">{{ term.value }}</span>\n }\n </span>\n <span class=\"mx-1\">|</span>\n <span class=\"terms-label ms-1\">{{ 'article.mustInclude' | transloco }}</span>\n <span class=\"terms\">\n @for (term of missingTerms(); track $index) {\n <a role=\"button\" class=\"ms-1\" routerLink=\"search\" [queryParams]=\"term.queryParams\" (click)=\"$event.stopPropagation()\">{{ term.value }}</a>\n }\n </span>\n}\n", styles: [".term{text-decoration-line:line-through}\n"] }]
6135
6134
  }], propDecorators: { article: [{ type: i0.Input, args: [{ isSignal: true, alias: "article", required: true }] }] } });
6135
+ /**
6136
+ * The backend returns each term with the syntax it has in the query: `[exit code]`, `"exit code"`, `(exit code)` or `/exit code/`.
6137
+ * Removes that syntax (and any +/- operator) to get the bare value, which is what goes inside `+[...]`.
6138
+ */
6139
+ const DELIMITED_TERM = /^[+-]?(?:\[(.+)\]|"(.+)"|\((.+)\)|\/(.+)\/)$/;
6140
+ /**
6141
+ * Builds the query text which makes `term` mandatory: the term is removed from the current text, then added as a `+[term]` concept.
6142
+ */
6143
+ function mustIncludeQuery(queryText, term) {
6144
+ const delimited = term.match(DELIMITED_TERM);
6145
+ const value = delimited ? (delimited[1] ?? delimited[2] ?? delimited[3] ?? delimited[4]) : term;
6146
+ // Escape special regex characters to avoid regex injection (e.g. brackets becoming character classes)
6147
+ const escapedTerm = value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
6148
+ // 1. The term is a whole pattern of the query (e.g. "exit code" or +[exit code]): remove it with its delimiters.
6149
+ // A word boundary cannot be used here, as the delimiters are not word characters.
6150
+ const wholePattern = new RegExp(`(^|\\s)[+-]?(?:\\[${escapedTerm}\\]|"${escapedTerm}"|\\(${escapedTerm}\\)|\\/${escapedTerm}\\/)(?=\\s|$)`, "gi");
6151
+ const withoutPattern = queryText.replace(wholePattern, "$1");
6152
+ // 2. Otherwise, the term is made of plain words of the query: remove them.
6153
+ const remaining = withoutPattern !== queryText ? withoutPattern : queryText.replace(new RegExp(`\\b${escapedTerm}\\b`, "gi"), "");
6154
+ const text = remaining.replace(/\s+/g, " ").trim();
6155
+ return { value, q: `${text} +[${value}]` };
6156
+ }
6136
6157
 
6137
6158
  class CollectionsDialog {
6138
6159
  closed = output();
@@ -18255,17 +18276,71 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.29", ngImpo
18255
18276
 
18256
18277
  /** Whether a value is an object (an array excluded): a structure, which this form cannot edit. */
18257
18278
  const isPlainObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
18279
+ /** Segments of a `/`-separated property path. */
18280
+ const toPath = (key) => key.split("/");
18281
+ /** The value stored at a `/`-separated path of `source`, or `undefined` when a node on the way is missing or not an object. */
18282
+ function getAtPath(source, key) {
18283
+ let node = source;
18284
+ for (const segment of toPath(key)) {
18285
+ if (!isPlainObject(node)) {
18286
+ return undefined;
18287
+ }
18288
+ node = node[segment];
18289
+ }
18290
+ return node;
18291
+ }
18292
+ /**
18293
+ * A copy of `source` holding `value` at a `/`-separated path (`undefined` removes it), siblings untouched.
18294
+ * The nodes on the way are created when missing, or replaced when they are not objects.
18295
+ */
18296
+ function setAtPath(source, key, value) {
18297
+ const [head, ...rest] = toPath(key);
18298
+ const copy = { ...(source ?? {}) };
18299
+ if (rest.length) {
18300
+ const child = copy[head];
18301
+ copy[head] = setAtPath(isPlainObject(child) ? child : undefined, rest.join("/"), value);
18302
+ }
18303
+ else {
18304
+ copy[head] = value;
18305
+ }
18306
+ return copy;
18307
+ }
18308
+ /**
18309
+ * Where to write `value` for the property at `key` so that the server accepts it.
18310
+ *
18311
+ * The server does not create the nodes on the way: a PATCH on `obj/deep/leaf` answers 404 while `obj` or
18312
+ * `obj/deep` does not exist in the profile. The write is therefore moved up to the first missing node (an
18313
+ * absent one, or one that is not an object), and carries the rest of the path as nested objects: with an empty
18314
+ * `source`, `obj/deep/leaf = "x"` is written as `obj = { deep: { leaf: "x" } }`. When every node on the way
18315
+ * exists, the target is `key` itself.
18316
+ */
18317
+ function writeTarget(source, key, value) {
18318
+ const path = toPath(key);
18319
+ let node = source;
18320
+ for (let depth = 0; depth < path.length - 1; depth++) {
18321
+ const child = isPlainObject(node) ? node[path[depth]] : undefined;
18322
+ if (!isPlainObject(child)) {
18323
+ const nested = path.slice(depth + 1).reduceRight((inner, segment) => ({ [segment]: inner }), value);
18324
+ return { key: path.slice(0, depth + 1).join("/"), value: nested };
18325
+ }
18326
+ node = child;
18327
+ }
18328
+ return { key, value };
18329
+ }
18258
18330
  /**
18259
- * Normalizes `features.userProfile.customData`, as written in the custom JSON, into a flat list.
18331
+ * Normalizes `features.userProfile.customData` / `features.userProfile.privateCustomData`, as written in
18332
+ * the custom JSON, into a flat list of `/`-separated paths.
18260
18333
  *
18261
- * Two entry shapes are accepted and can be mixed:
18262
- * - `"nickname"` — a property without default;
18334
+ * Entry shapes accepted, which can be mixed:
18335
+ * - `"nickname"` — a property without default; `"agent/favorites"` is a nested one;
18263
18336
  * - `{ "nickname": "none" }` — a property and its default value. An object with several keys declares
18264
- * one property per key.
18337
+ * one property per key;
18338
+ * - `{ "obj": { "property1": "val2" } }` — a hierarchy: the property `obj/property1`, default `val2`, at
18339
+ * any depth.
18265
18340
  *
18266
18341
  * The JSON is read at run time and nothing validates it, so an entry that is neither a string nor a
18267
- * plain object is dropped, with a warning, rather than thrown on. When a key is declared twice, the
18268
- * first declaration wins.
18342
+ * plain object, or a path with an empty segment, is dropped, with a warning, rather than thrown on. When
18343
+ * a path is declared twice, the first declaration wins.
18269
18344
  */
18270
18345
  function normalizeCustomData(entries) {
18271
18346
  const properties = new Map();
@@ -18273,14 +18348,26 @@ function normalizeCustomData(entries) {
18273
18348
  if (!key || properties.has(key)) {
18274
18349
  return;
18275
18350
  }
18351
+ if (toPath(key).some(segment => !segment)) {
18352
+ console.warn("[user-profile] customData: path ignored, it has an empty segment.", key);
18353
+ return;
18354
+ }
18276
18355
  properties.set(key, value === undefined || value === null ? { key } : { key, defaultValue: value });
18277
18356
  };
18357
+ const addTree = (prefix, value) => {
18358
+ if (isPlainObject(value) && Object.keys(value).length) {
18359
+ Object.entries(value).forEach(([key, child]) => addTree(prefix ? `${prefix}/${key}` : key, child));
18360
+ }
18361
+ else {
18362
+ add(prefix, isPlainObject(value) ? undefined : value);
18363
+ }
18364
+ };
18278
18365
  for (const entry of Array.isArray(entries) ? entries : []) {
18279
18366
  if (typeof entry === "string") {
18280
18367
  add(entry);
18281
18368
  }
18282
18369
  else if (isPlainObject(entry)) {
18283
- Object.entries(entry).forEach(([key, value]) => add(key, value));
18370
+ addTree("", entry);
18284
18371
  }
18285
18372
  else {
18286
18373
  console.warn("[user-profile] customData: entry ignored, expected a string or an object.", entry);
@@ -18289,6 +18376,101 @@ function normalizeCustomData(entries) {
18289
18376
  return [...properties.values()];
18290
18377
  }
18291
18378
 
18379
+ /** Whether a value is a structure (an array or an object) rather than a scalar. */
18380
+ const isStructured = (value) => Array.isArray(value) || isPlainObject(value);
18381
+ /**
18382
+ * Read-only view of any JSON value, to show a profile property whose content is not known in advance.
18383
+ *
18384
+ * - a scalar is shown as text;
18385
+ * - an array of scalars is a row of chips;
18386
+ * - any other array, and an object, is a nested list: each item (or key) is shown with this same
18387
+ * component, so values, objects and arrays can be mixed at any depth.
18388
+ *
18389
+ * It renders nothing interactive, so the profile form keeps the edition to itself.
18390
+ */
18391
+ class JsonValueView {
18392
+ value = input(...(ngDevMode ? [undefined, { debugName: "value" }] : []));
18393
+ isArray = computed(() => Array.isArray(this.value()), ...(ngDevMode ? [{ debugName: "isArray" }] : []));
18394
+ /** The items of a structure (a label only for an object key), or `undefined` for a scalar. */
18395
+ entries = computed(() => {
18396
+ const value = this.value();
18397
+ if (Array.isArray(value)) {
18398
+ return value.map(item => ({ label: undefined, value: item }));
18399
+ }
18400
+ if (isPlainObject(value)) {
18401
+ return Object.entries(value).map(([label, item]) => ({ label: label, value: item }));
18402
+ }
18403
+ return undefined;
18404
+ }, ...(ngDevMode ? [{ debugName: "entries" }] : []));
18405
+ scalarArray = computed(() => this.isArray() && (this.entries() ?? []).length > 0 && (this.entries() ?? []).every(item => !isStructured(item.value)), ...(ngDevMode ? [{ debugName: "scalarArray" }] : []));
18406
+ text(value) {
18407
+ return value === null || value === undefined ? "∅" : String(value);
18408
+ }
18409
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.29", ngImport: i0, type: JsonValueView, deps: [], target: i0.ɵɵFactoryTarget.Component });
18410
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "20.3.29", type: JsonValueView, isStandalone: true, selector: "json-value-view", inputs: { value: { classPropertyName: "value", publicName: "value", isSignal: true, isRequired: false, transformFunction: null } }, host: { classAttribute: "block min-w-0" }, ngImport: i0, template: `
18411
+ @if (entries(); as list) {
18412
+ @if (scalarArray()) {
18413
+ <ul class="flex flex-wrap gap-1">
18414
+ @for (item of list; track $index) {
18415
+ <li class="bg-muted rounded-full px-2 py-0.5 text-sm break-words">{{ text(item.value) }}</li>
18416
+ }
18417
+ </ul>
18418
+ } @else if (list.length) {
18419
+ <ul class="border-foreground/10 flex flex-col gap-1 border-l-2 pl-2">
18420
+ @for (item of list; track $index) {
18421
+ <li class="flex min-w-0 flex-col">
18422
+ @if (item.label !== undefined) {
18423
+ <span class="text-muted-foreground text-xs">{{ item.label }}</span>
18424
+ }
18425
+ <json-value-view [value]="item.value" />
18426
+ </li>
18427
+ }
18428
+ </ul>
18429
+ } @else {
18430
+ <span class="text-muted-foreground">{{ isArray() ? "[ ]" : "{ }" }}</span>
18431
+ }
18432
+ } @else {
18433
+ <span class="break-words whitespace-pre-line">{{ text(value()) }}</span>
18434
+ }
18435
+ `, isInline: true, dependencies: [{ kind: "component", type: JsonValueView, selector: "json-value-view", inputs: ["value"] }] });
18436
+ }
18437
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.29", ngImport: i0, type: JsonValueView, decorators: [{
18438
+ type: Component,
18439
+ args: [{
18440
+ selector: "json-value-view",
18441
+ standalone: true,
18442
+ host: { class: "block min-w-0" },
18443
+ template: `
18444
+ @if (entries(); as list) {
18445
+ @if (scalarArray()) {
18446
+ <ul class="flex flex-wrap gap-1">
18447
+ @for (item of list; track $index) {
18448
+ <li class="bg-muted rounded-full px-2 py-0.5 text-sm break-words">{{ text(item.value) }}</li>
18449
+ }
18450
+ </ul>
18451
+ } @else if (list.length) {
18452
+ <ul class="border-foreground/10 flex flex-col gap-1 border-l-2 pl-2">
18453
+ @for (item of list; track $index) {
18454
+ <li class="flex min-w-0 flex-col">
18455
+ @if (item.label !== undefined) {
18456
+ <span class="text-muted-foreground text-xs">{{ item.label }}</span>
18457
+ }
18458
+ <json-value-view [value]="item.value" />
18459
+ </li>
18460
+ }
18461
+ </ul>
18462
+ } @else {
18463
+ <span class="text-muted-foreground">{{ isArray() ? "[ ]" : "{ }" }}</span>
18464
+ }
18465
+ } @else {
18466
+ <span class="break-words whitespace-pre-line">{{ text(value()) }}</span>
18467
+ }
18468
+ `
18469
+ }]
18470
+ }], propDecorators: { value: [{ type: i0.Input, args: [{ isSignal: true, alias: "value", required: false }] }] } });
18471
+
18472
+ /** Marks a JSON editor content that does not parse. */
18473
+ const INVALID = Symbol("invalid-json");
18292
18474
  const SUPPORTED_LANGUAGES = ["en", "fr", "de"];
18293
18475
  class UserProfileFormComponent {
18294
18476
  principalStore = inject(PrincipalStore);
@@ -18320,16 +18502,36 @@ class UserProfileFormComponent {
18320
18502
  ];
18321
18503
  changingPassword = signal(false, ...(ngDevMode ? [{ debugName: "changingPassword" }] : []));
18322
18504
  customDataConfig = computed(() => normalizeCustomData(this.appStore.general()?.features?.userProfile?.customData), ...(ngDevMode ? [{ debugName: "customDataConfig" }] : []));
18505
+ privateCustomDataConfig = computed(() => normalizeCustomData(this.appStore.general()?.features?.userProfile?.privateCustomData), ...(ngDevMode ? [{ debugName: "privateCustomDataConfig" }] : []));
18323
18506
  /**
18324
- * The editable custom properties. A property that already holds an object in the profile (hierarchical
18325
- * `customData`) is left out: this form edits scalars only and would overwrite the structure.
18507
+ * The custom properties of the form. A property may hold any JSON value, a structure included: it is
18508
+ * shown structured and edited as JSON (see {@link editingJson}).
18326
18509
  */
18327
- customData = computed(() => {
18328
- const stored = this.userProfile()?.customData;
18329
- return this.customDataConfig()
18330
- .map(({ key }) => key)
18331
- .filter(key => !this.keys().some(k => k.toLowerCase() === key.toLowerCase()) && !isPlainObject(stored?.[key]));
18332
- }, ...(ngDevMode ? [{ debugName: "customData" }] : []));
18510
+ customData = computed(() => this.customDataConfig()
18511
+ .map(({ key }) => key)
18512
+ .filter(key => !this.keys().some(k => k.toLowerCase() === key.toLowerCase())), ...(ngDevMode ? [{ debugName: "customData" }] : []));
18513
+ /** The private properties of the form; they only ever go through the private endpoints. */
18514
+ privateCustomData = computed(() => this.privateCustomDataConfig().map(({ key }) => key), ...(ngDevMode ? [{ debugName: "privateCustomData" }] : []));
18515
+ /** Whether the editor holds JSON (the property is an array or an object) rather than a single text. */
18516
+ editingJson = signal(false, ...(ngDevMode ? [{ debugName: "editingJson" }] : []));
18517
+ /** Whether the JSON being edited does not parse: the save is then not offered. */
18518
+ invalidJson = computed(() => {
18519
+ if (!this.editingJson())
18520
+ return false;
18521
+ try {
18522
+ JSON.parse(this.value() ?? "");
18523
+ return false;
18524
+ }
18525
+ catch {
18526
+ return true;
18527
+ }
18528
+ }, ...(ngDevMode ? [{ debugName: "invalidJson" }] : []));
18529
+ /** Every field of the form, in display order. A key declared both public and private gives two fields. */
18530
+ fields = computed(() => [
18531
+ ...this.formKeys().map((key) => ({ category: "data", key })),
18532
+ ...this.customData().map((key) => ({ category: "customData", key })),
18533
+ ...this.privateCustomData().map((key) => ({ category: "privateCustomData", key }))
18534
+ ], ...(ngDevMode ? [{ debugName: "fields" }] : []));
18333
18535
  allowChangePassword = computed(() => {
18334
18536
  const { useCredentials } = globalConfig;
18335
18537
  const { allowChangePassword = false } = this.appStore.general()?.features || {};
@@ -18379,9 +18581,10 @@ class UserProfileFormComponent {
18379
18581
  * @param propertyName property name
18380
18582
  */
18381
18583
  onEdit(category, propertyName) {
18382
- const value = this.getDataValue(category, propertyName);
18584
+ const raw = this.getRawValue(category, propertyName);
18383
18585
  this.feedback.set(undefined);
18384
- this.value.set(value?.trim() || "");
18586
+ this.editingJson.set(isStructured(raw));
18587
+ this.value.set(isStructured(raw) ? JSON.stringify(raw, null, 2) : raw === undefined || raw === null ? "" : String(raw).trim());
18385
18588
  this.propertyToEdit.set(`${category}.${propertyName}`);
18386
18589
  }
18387
18590
  /** Name of the property as the user sees it: the translated label for `data`, the raw key otherwise. */
@@ -18406,8 +18609,21 @@ class UserProfileFormComponent {
18406
18609
  /** Leaves the editor without saving. */
18407
18610
  onCancelEdit() {
18408
18611
  this.feedback.set(undefined);
18612
+ this.editingJson.set(false);
18409
18613
  this.propertyToEdit.set(undefined);
18410
18614
  }
18615
+ /**
18616
+ * Whether the profile holds a value for a property. Only then is there something to delete: a property
18617
+ * that shows its configured default, or that is simply not set, is absent from the profile and the
18618
+ * server answers 404 to a delete on it (ES-34462).
18619
+ *
18620
+ * @param category data or customData
18621
+ * @param propertyName property name
18622
+ */
18623
+ hasStoredValue(category, propertyName) {
18624
+ const stored = this.storedValue(category, propertyName);
18625
+ return stored !== undefined && stored !== null;
18626
+ }
18411
18627
  /**
18412
18628
  * On data property deletion, for now we make the property an empty string
18413
18629
  *
@@ -18417,16 +18633,22 @@ class UserProfileFormComponent {
18417
18633
  async onDeleteData(category, propertyName) {
18418
18634
  if (this.pending())
18419
18635
  return;
18636
+ // Nothing stored, nothing to delete: not a failure, and no request to send.
18637
+ if (!this.hasStoredValue(category, propertyName)) {
18638
+ info("User profile property not stored, nothing to delete", { category, propertyName });
18639
+ return;
18640
+ }
18420
18641
  const key = `${category}.${propertyName}`;
18421
18642
  this.feedback.set(undefined);
18422
18643
  this.pending.set(key);
18423
18644
  try {
18424
- const response = await deleteUserProfileProperty(this.userProfile(), category, propertyName);
18645
+ // A private property is only ever deleted through the private endpoint, never the public one.
18646
+ const response = category === "privateCustomData"
18647
+ ? await deleteUserProfilePrivateCustomData(propertyName)
18648
+ : await deleteUserProfileProperty(this.userProfile(), category, propertyName);
18425
18649
  if (response) {
18426
18650
  info("Deleted user profile property", response);
18427
- const userProfile = JSON.parse(JSON.stringify(this.userProfile()));
18428
- userProfile[category][propertyName] = undefined;
18429
- this.userProfileResource.set(userProfile);
18651
+ this.forgetProperty(category, propertyName);
18430
18652
  this.feedback.set({ key, type: "success", message: this.message("deleted", category, propertyName) });
18431
18653
  }
18432
18654
  else {
@@ -18434,13 +18656,34 @@ class UserProfileFormComponent {
18434
18656
  this.feedback.set({ key, type: "error", message: this.message("deleteFailed", category, propertyName) });
18435
18657
  }
18436
18658
  }
18437
- catch {
18438
- this.feedback.set({ key, type: "error", message: this.message("deleteFailed", category, propertyName) });
18659
+ catch (err) {
18660
+ if (err?.status === 404) {
18661
+ // The property is not in the profile (anymore), e.g. removed from another tab: the state asked for is
18662
+ // already reached, so it is not an error to show.
18663
+ info("User profile property already absent", { category, propertyName });
18664
+ this.forgetProperty(category, propertyName);
18665
+ this.feedback.set({ key, type: "success", message: this.message("deleted", category, propertyName) });
18666
+ }
18667
+ else {
18668
+ this.feedback.set({ key, type: "error", message: this.message("deleteFailed", category, propertyName) });
18669
+ }
18439
18670
  }
18440
18671
  finally {
18441
18672
  this.pending.set(undefined);
18442
18673
  }
18443
18674
  }
18675
+ /** Takes a deleted property out of the stored profile; a custom property with a default shows that default again. */
18676
+ forgetProperty(category, propertyName) {
18677
+ const userProfile = JSON.parse(JSON.stringify(this.userProfile()));
18678
+ if (category === "data") {
18679
+ if (userProfile.data)
18680
+ userProfile.data[propertyName] = undefined;
18681
+ }
18682
+ else if (userProfile[category]) {
18683
+ userProfile[category] = setAtPath(userProfile[category], propertyName, undefined);
18684
+ }
18685
+ this.userProfileResource.set(userProfile);
18686
+ }
18444
18687
  /**
18445
18688
  * Saving a data property
18446
18689
  *
@@ -18452,24 +18695,30 @@ class UserProfileFormComponent {
18452
18695
  return;
18453
18696
  const key = `${category}.${propertyName}`;
18454
18697
  this.feedback.set(undefined);
18698
+ const written = this.valueToWrite(category, propertyName);
18699
+ if (written === INVALID) {
18700
+ this.feedback.set({ key, type: "error", message: this.transloco.translate("userProfile.invalidJson") });
18701
+ return;
18702
+ }
18455
18703
  this.pending.set(key);
18456
18704
  // cloning the user profile to avoid changing the signal value before confirming the update
18457
18705
  const userProfile = JSON.parse(JSON.stringify(this.userProfile()));
18458
- if (!userProfile[category])
18459
- userProfile[category] = {};
18460
- userProfile[category][propertyName] = this.value()?.trim() || "";
18706
+ if (category === "data") {
18707
+ userProfile.data = { ...(userProfile.data ?? {}), [propertyName]: written };
18708
+ }
18709
+ else {
18710
+ userProfile[category] = setAtPath(userProfile[category], propertyName, written);
18711
+ }
18712
+ // The server does not create the nodes on the way (404): write at the first missing one, with the rest nested.
18713
+ const target = category === "data" ? { key: propertyName, value: written } : writeTarget(this.userProfile()?.[category], propertyName, written);
18461
18714
  try {
18462
- const response = await patchUserProfile(userProfile, category, propertyName);
18463
- if (response) {
18464
- const newUserProfile = this.userProfile();
18465
- this.userProfileResource.set({
18466
- ...newUserProfile,
18467
- ...response,
18468
- data: { ...(newUserProfile?.data || {}), ...(response.data || {}) },
18469
- customData: { ...(newUserProfile?.customData || {}), ...(response.customData || {}) }
18470
- });
18471
- }
18715
+ // A private property is only ever written through the private endpoint, never the public one.
18716
+ const response = category === "privateCustomData"
18717
+ ? await patchUserProfilePrivateCustomData(target.key, target.value)
18718
+ : await patchUserProfile(userProfile, category, target.key);
18719
+ this.foldSavedValue(category, target.key, response, target.value);
18472
18720
  this.value.set(undefined);
18721
+ this.editingJson.set(false);
18473
18722
  this.propertyToEdit.set(undefined);
18474
18723
  this.feedback.set({ key, type: "success", message: this.message("saved", category, propertyName) });
18475
18724
  }
@@ -18481,20 +18730,86 @@ class UserProfileFormComponent {
18481
18730
  }
18482
18731
  }
18483
18732
  /**
18484
- * Get the value of a data key
18733
+ * Folds the answer of a PATCH back into the stored profile, so the form shows what was just saved.
18734
+ *
18735
+ * The answer depends on the server: before ES-34373 it is the whole updated profile (`{ data, customData, … }`),
18736
+ * after it is the written value alone (`"fav-1AAA"`). Reading the latter as a profile used to leave the stored
18737
+ * profile unchanged, and the form kept showing the old value (or the default) until the page was reloaded.
18738
+ */
18739
+ foldSavedValue(category, propertyName, response, written) {
18740
+ const current = this.userProfile();
18741
+ if (category !== "privateCustomData" && !isStructured(written) && isPlainObject(response) && ("data" in response || "customData" in response)) {
18742
+ const profile = response;
18743
+ this.userProfileResource.set({
18744
+ ...current,
18745
+ ...profile,
18746
+ data: { ...(current?.data || {}), ...(profile.data || {}) },
18747
+ customData: { ...(current?.customData || {}), ...(profile.customData || {}) }
18748
+ });
18749
+ return;
18750
+ }
18751
+ // The value alone: what the server read back, or what was sent when it answers with nothing usable.
18752
+ const value = response === undefined || response === null ? written : response;
18753
+ this.userProfileResource.set({
18754
+ ...current,
18755
+ [category]: category === "data" ? { ...(current?.data || {}), [propertyName]: value } : setAtPath(current?.[category], propertyName, value)
18756
+ });
18757
+ }
18758
+ /** The value the profile holds for a property, whichever category it lives in; `propertyName` is a path for custom data. */
18759
+ storedValue(category, propertyName) {
18760
+ const source = this.userProfile()?.[category];
18761
+ return category === "data" ? source?.[propertyName] : getAtPath(source, propertyName);
18762
+ }
18763
+ /**
18764
+ * The value of a property as the profile holds it, of any JSON type; while it holds none, the default the
18765
+ * administrator configured for a custom property.
18766
+ */
18767
+ getRawValue(category, key) {
18768
+ const value = this.storedValue(category, key);
18769
+ if (value === undefined && category !== "data") {
18770
+ const config = category === "customData" ? this.customDataConfig() : this.privateCustomDataConfig();
18771
+ return config.find(property => property.key === key)?.defaultValue;
18772
+ }
18773
+ return value;
18774
+ }
18775
+ /** Whether the value of a property is a structure (an array or an object), shown with {@link JsonValueView}. */
18776
+ isStructuredValue(category, key) {
18777
+ return isStructured(this.getRawValue(category, key));
18778
+ }
18779
+ /**
18780
+ * Get the value of a data key, when it is a scalar
18485
18781
  *
18486
18782
  * @param category data or customData
18487
18783
  * @param key property key
18488
- * @returns the value
18784
+ * @returns the value, or `undefined` when there is none or when it is a structure
18489
18785
  */
18490
18786
  getDataValue(category, key) {
18491
- const data = this.userProfile()?.[category];
18492
- const value = data?.[key];
18493
- if (value === undefined && category === "customData") {
18494
- const defaultValue = this.customDataConfig().find(property => property.key === key)?.defaultValue;
18495
- return defaultValue === undefined || typeof defaultValue === "object" ? undefined : String(defaultValue);
18787
+ const value = this.getRawValue(category, key);
18788
+ return value === undefined || isStructured(value) ? undefined : value;
18789
+ }
18790
+ /**
18791
+ * What the editor holds, as the value to send: the parsed JSON for a structure (or `INVALID`), the text
18792
+ * otherwise. A property stored as a number or a boolean stays one when the text still reads as such, so that
18793
+ * saving does not silently turn it into a string.
18794
+ */
18795
+ valueToWrite(category, propertyName) {
18796
+ if (this.editingJson()) {
18797
+ try {
18798
+ return JSON.parse(this.value() ?? "");
18799
+ }
18800
+ catch {
18801
+ return INVALID;
18802
+ }
18496
18803
  }
18497
- return value;
18804
+ const text = this.value()?.trim() || "";
18805
+ if (category === "data")
18806
+ return text;
18807
+ const stored = this.storedValue(category, propertyName);
18808
+ if (typeof stored === "number" && text !== "" && Number.isFinite(Number(text)))
18809
+ return Number(text);
18810
+ if (typeof stored === "boolean" && (text === "true" || text === "false"))
18811
+ return text === "true";
18812
+ return text;
18498
18813
  }
18499
18814
  /**
18500
18815
  * TODO: how do we save the picture?
@@ -18526,7 +18841,7 @@ class UserProfileFormComponent {
18526
18841
  }
18527
18842
  }
18528
18843
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.29", ngImport: i0, type: UserProfileFormComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
18529
- 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" }] });
18844
+ 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 <!-- two columns from the sm breakpoint; a structured value or the JSON editor takes the full width -->\n <div\n class=\"grid grid-cols-1 items-start gap-x-6 gap-y-2 sm:grid-cols-2\">\n @for (field of fields(); track field.category + \".\" + field.key) {\n @let id = field.category + \".\" + field.key;\n @let isPrivate = field.category === \"privateCustomData\";\n <div\n [class]=\"\n isStructuredValue(field.category, field.key) ||\n (propertyToEdit() === id && editingJson())\n ? 'min-w-0 sm:col-span-2'\n : 'min-w-0'\n \">\n <p class=\"flex items-center gap-2 px-2\">\n <span>\n @if (field.category === \"data\") {\n {{ `userProfile.data.${field.key}` | transloco }}\n } @else {\n {{ field.key.split(\"/\").join(\" \u203A \") }}\n }\n </span>\n <!-- PRIVATE / PUBLIC: icon AND text, so the information is not carried by the icon or a color alone -->\n <span\n class=\"text-muted-foreground inline-flex items-center gap-1 text-xs\"\n [attr.data-visibility]=\"isPrivate ? 'private' : 'public'\"\n [title]=\"\n (isPrivate\n ? 'userProfile.privateHint'\n : 'userProfile.publicHint'\n ) | transloco\n \">\n @if (isPrivate) {\n <user-secret-icon class=\"size-3.5 shrink-0\" />\n } @else {\n <globe-icon class=\"size-3.5 shrink-0\" />\n }\n <span>{{\n (isPrivate ? \"userProfile.private\" : \"userProfile.public\")\n | transloco\n }}</span>\n <span class=\"sr-only\">\n {{\n (isPrivate\n ? \"userProfile.privateHint\"\n : \"userProfile.publicHint\"\n ) | transloco\n }}\n </span>\n </span>\n </p>\n <div\n class=\"group flex flex-row rounded-md px-2 py-0.5\"\n [class.ring-1]=\"!!fieldState(id)\"\n [class.ring-destructive]=\"fieldState(id) === 'error'\"\n [class.ring-green-600]=\"fieldState(id) === 'success'\">\n <div\n class=\"min-w-0 grow break-words whitespace-pre-line\"\n [class.text-muted-foreground]=\"\n getRawValue(field.category, field.key) == null\n \">\n @if (propertyToEdit() === id) {\n @if (editingJson()) {\n <!-- an array or an object is edited as JSON, on several lines -->\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 font-mono text-sm hover:outline focus:outline\"\n rows=\"8\"\n [attr.aria-invalid]=\"invalidJson() || null\"\n [attr.aria-describedby]=\"\n invalidJson() ? 'user-profile-json-error' : null\n \"\n id=\"user-profile-{{ field.category }}-{{ field.key }}\"\n [(ngModel)]=\"value\"></textarea>\n } @else {\n <input\n type=\"text\"\n class=\"hover:outline-primary focus:outline-primary border-foreground/20 hover:bg-muted focus:bg-muted mt-2 h-8 w-full rounded-md border px-2 hover:outline focus:outline\"\n id=\"user-profile-{{ field.category }}-{{ field.key }}\"\n [(ngModel)]=\"value\" />\n }\n @if (invalidJson()) {\n <p\n id=\"user-profile-json-error\"\n class=\"text-destructive text-sm\">\n {{ \"userProfile.invalidJson\" | transloco }}\n </p>\n }\n } @else if (isStructuredValue(field.category, field.key)) {\n <json-value-view\n [value]=\"getRawValue(field.category, field.key)\" />\n } @else {\n {{\n getDataValue(field.category, field.key) ??\n (\"userProfile.notDefined\" | transloco)\n }}\n }\n </div>\n @if (propertyToEdit() === id) {\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() || invalidJson()\"\n (click)=\"onSaveData(field.category, field.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(field.category, field.key)\">\n <EditIcon />\n </button>\n <!-- nothing stored, nothing to delete: the button is not offered rather than shown disabled -->\n @if (hasStoredValue(field.category, field.key)) {\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]=\"\n 'userProfile.deleteProperty' | transloco\n \"\n [disabled]=\"!!pending()\"\n (click)=\"onDeleteData(field.category, field.key)\">\n <TrashIcon />\n </button>\n }\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: GlobeIcon, selector: "globe-icon, GlobeIcon, globeicon", inputs: ["class"] }, { kind: "component", type: UserSecretIcon, selector: "user-secret-icon, UserSecretIcon", inputs: ["class"] }, { kind: "component", type: JsonValueView, selector: "json-value-view", inputs: ["value"] }, { 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" }] });
18530
18845
  }
18531
18846
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.29", ngImport: i0, type: UserProfileFormComponent, decorators: [{
18532
18847
  type: Component,
@@ -18541,10 +18856,13 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.29", ngImpo
18541
18856
  AvatarComponent,
18542
18857
  AvatarFallbackComponent,
18543
18858
  UserIcon,
18859
+ GlobeIcon,
18860
+ UserSecretIcon,
18861
+ JsonValueView,
18544
18862
  AvatarImageComponent,
18545
18863
  ButtonComponent,
18546
18864
  ChangePasswordComponent
18547
- ], 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" }]
18865
+ ], 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 <!-- two columns from the sm breakpoint; a structured value or the JSON editor takes the full width -->\n <div\n class=\"grid grid-cols-1 items-start gap-x-6 gap-y-2 sm:grid-cols-2\">\n @for (field of fields(); track field.category + \".\" + field.key) {\n @let id = field.category + \".\" + field.key;\n @let isPrivate = field.category === \"privateCustomData\";\n <div\n [class]=\"\n isStructuredValue(field.category, field.key) ||\n (propertyToEdit() === id && editingJson())\n ? 'min-w-0 sm:col-span-2'\n : 'min-w-0'\n \">\n <p class=\"flex items-center gap-2 px-2\">\n <span>\n @if (field.category === \"data\") {\n {{ `userProfile.data.${field.key}` | transloco }}\n } @else {\n {{ field.key.split(\"/\").join(\" \u203A \") }}\n }\n </span>\n <!-- PRIVATE / PUBLIC: icon AND text, so the information is not carried by the icon or a color alone -->\n <span\n class=\"text-muted-foreground inline-flex items-center gap-1 text-xs\"\n [attr.data-visibility]=\"isPrivate ? 'private' : 'public'\"\n [title]=\"\n (isPrivate\n ? 'userProfile.privateHint'\n : 'userProfile.publicHint'\n ) | transloco\n \">\n @if (isPrivate) {\n <user-secret-icon class=\"size-3.5 shrink-0\" />\n } @else {\n <globe-icon class=\"size-3.5 shrink-0\" />\n }\n <span>{{\n (isPrivate ? \"userProfile.private\" : \"userProfile.public\")\n | transloco\n }}</span>\n <span class=\"sr-only\">\n {{\n (isPrivate\n ? \"userProfile.privateHint\"\n : \"userProfile.publicHint\"\n ) | transloco\n }}\n </span>\n </span>\n </p>\n <div\n class=\"group flex flex-row rounded-md px-2 py-0.5\"\n [class.ring-1]=\"!!fieldState(id)\"\n [class.ring-destructive]=\"fieldState(id) === 'error'\"\n [class.ring-green-600]=\"fieldState(id) === 'success'\">\n <div\n class=\"min-w-0 grow break-words whitespace-pre-line\"\n [class.text-muted-foreground]=\"\n getRawValue(field.category, field.key) == null\n \">\n @if (propertyToEdit() === id) {\n @if (editingJson()) {\n <!-- an array or an object is edited as JSON, on several lines -->\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 font-mono text-sm hover:outline focus:outline\"\n rows=\"8\"\n [attr.aria-invalid]=\"invalidJson() || null\"\n [attr.aria-describedby]=\"\n invalidJson() ? 'user-profile-json-error' : null\n \"\n id=\"user-profile-{{ field.category }}-{{ field.key }}\"\n [(ngModel)]=\"value\"></textarea>\n } @else {\n <input\n type=\"text\"\n class=\"hover:outline-primary focus:outline-primary border-foreground/20 hover:bg-muted focus:bg-muted mt-2 h-8 w-full rounded-md border px-2 hover:outline focus:outline\"\n id=\"user-profile-{{ field.category }}-{{ field.key }}\"\n [(ngModel)]=\"value\" />\n }\n @if (invalidJson()) {\n <p\n id=\"user-profile-json-error\"\n class=\"text-destructive text-sm\">\n {{ \"userProfile.invalidJson\" | transloco }}\n </p>\n }\n } @else if (isStructuredValue(field.category, field.key)) {\n <json-value-view\n [value]=\"getRawValue(field.category, field.key)\" />\n } @else {\n {{\n getDataValue(field.category, field.key) ??\n (\"userProfile.notDefined\" | transloco)\n }}\n }\n </div>\n @if (propertyToEdit() === id) {\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() || invalidJson()\"\n (click)=\"onSaveData(field.category, field.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(field.category, field.key)\">\n <EditIcon />\n </button>\n <!-- nothing stored, nothing to delete: the button is not offered rather than shown disabled -->\n @if (hasStoredValue(field.category, field.key)) {\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]=\"\n 'userProfile.deleteProperty' | transloco\n \"\n [disabled]=\"!!pending()\"\n (click)=\"onDeleteData(field.category, field.key)\">\n <TrashIcon />\n </button>\n }\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" }]
18548
18866
  }], 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"] }] } });
18549
18867
 
18550
18868
  class UserProfileDialog {
@@ -18557,7 +18875,7 @@ class UserProfileDialog {
18557
18875
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.29", ngImport: i0, type: UserProfileDialog, deps: [], target: i0.ɵɵFactoryTarget.Component });
18558
18876
  static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "20.3.29", type: UserProfileDialog, isStandalone: true, selector: "user-profile-dialog, userprofiledialog, UserProfileDialog", providers: [provideTranslocoScope("user-profile")], viewQueries: [{ propertyName: "dialog", first: true, predicate: DialogComponent, descendants: true, isSignal: true }], ngImport: i0, template: `
18559
18877
  <div dialog #dialog="dialog">
18560
- <DialogContent class="flex flex-col max-h-11/12 overflow-hidden">
18878
+ <DialogContent class="flex flex-col max-h-11/12 overflow-hidden sm:max-w-3xl">
18561
18879
  <DialogHeader>
18562
18880
  <DialogTitle>{{ 'userProfile.title' | transloco }}</DialogTitle>
18563
18881
  </DialogHeader>
@@ -18574,18 +18892,11 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.29", ngImpo
18574
18892
  args: [{
18575
18893
  selector: "user-profile-dialog, userprofiledialog, UserProfileDialog",
18576
18894
  standalone: true,
18577
- imports: [
18578
- DialogComponent,
18579
- DialogHeaderComponent,
18580
- DialogContentComponent,
18581
- UserProfileFormComponent,
18582
- DialogTitleComponent,
18583
- TranslocoPipe
18584
- ],
18895
+ imports: [DialogComponent, DialogHeaderComponent, DialogContentComponent, UserProfileFormComponent, DialogTitleComponent, TranslocoPipe],
18585
18896
  providers: [provideTranslocoScope("user-profile")],
18586
18897
  template: `
18587
18898
  <div dialog #dialog="dialog">
18588
- <DialogContent class="flex flex-col max-h-11/12 overflow-hidden">
18899
+ <DialogContent class="flex flex-col max-h-11/12 overflow-hidden sm:max-w-3xl">
18589
18900
  <DialogHeader>
18590
18901
  <DialogTitle>{{ 'userProfile.title' | transloco }}</DialogTitle>
18591
18902
  </DialogHeader>