@sinequa/atomic-angular 1.19.4 → 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';
@@ -6119,25 +6119,41 @@ class MissingTermsComponent {
6119
6119
  missingTerms = computed(() => {
6120
6120
  const query = this.queryParamsStore.getQuery();
6121
6121
  return this.article()
6122
- .termspresence?.filter(tp => tp.presence === 'missing')
6122
+ .termspresence?.filter(tp => tp.presence === "missing")
6123
6123
  .map(tp => {
6124
- const bracketMatch = tp.term.match(/^\[(.+)\]$/);
6125
- const cleanTerm = bracketMatch ? bracketMatch[1] : tp.term;
6126
- const escapedTerm = cleanTerm.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
6127
- const text = (query.text || '').replace(new RegExp(`\\b${escapedTerm}\\b`, 'gi'), '').replace(/\s+/g, ' ').trim();
6128
- return {
6129
- value: cleanTerm,
6130
- queryParams: { q: `${text} +[${cleanTerm}]` }
6131
- };
6124
+ const { value, q } = mustIncludeQuery(query.text || "", tp.term);
6125
+ return { value, queryParams: { q } };
6132
6126
  });
6133
6127
  }, ...(ngDevMode ? [{ debugName: "missingTerms" }] : []));
6134
6128
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.29", ngImport: i0, type: MissingTermsComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
6135
- 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" }] });
6136
6130
  }
6137
6131
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.29", ngImport: i0, type: MissingTermsComponent, decorators: [{
6138
6132
  type: Component,
6139
- 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"] }]
6140
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
+ }
6141
6157
 
6142
6158
  class CollectionsDialog {
6143
6159
  closed = output();
@@ -18260,17 +18276,71 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.29", ngImpo
18260
18276
 
18261
18277
  /** Whether a value is an object (an array excluded): a structure, which this form cannot edit. */
18262
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
+ }
18263
18292
  /**
18264
- * Normalizes `features.userProfile.customData`, as written in the custom JSON, into a flat list.
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
+ }
18330
+ /**
18331
+ * Normalizes `features.userProfile.customData` / `features.userProfile.privateCustomData`, as written in
18332
+ * the custom JSON, into a flat list of `/`-separated paths.
18265
18333
  *
18266
- * Two entry shapes are accepted and can be mixed:
18267
- * - `"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;
18268
18336
  * - `{ "nickname": "none" }` — a property and its default value. An object with several keys declares
18269
- * 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.
18270
18340
  *
18271
18341
  * The JSON is read at run time and nothing validates it, so an entry that is neither a string nor a
18272
- * plain object is dropped, with a warning, rather than thrown on. When a key is declared twice, the
18273
- * 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.
18274
18344
  */
18275
18345
  function normalizeCustomData(entries) {
18276
18346
  const properties = new Map();
@@ -18278,14 +18348,26 @@ function normalizeCustomData(entries) {
18278
18348
  if (!key || properties.has(key)) {
18279
18349
  return;
18280
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
+ }
18281
18355
  properties.set(key, value === undefined || value === null ? { key } : { key, defaultValue: value });
18282
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
+ };
18283
18365
  for (const entry of Array.isArray(entries) ? entries : []) {
18284
18366
  if (typeof entry === "string") {
18285
18367
  add(entry);
18286
18368
  }
18287
18369
  else if (isPlainObject(entry)) {
18288
- Object.entries(entry).forEach(([key, value]) => add(key, value));
18370
+ addTree("", entry);
18289
18371
  }
18290
18372
  else {
18291
18373
  console.warn("[user-profile] customData: entry ignored, expected a string or an object.", entry);
@@ -18294,6 +18376,101 @@ function normalizeCustomData(entries) {
18294
18376
  return [...properties.values()];
18295
18377
  }
18296
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");
18297
18474
  const SUPPORTED_LANGUAGES = ["en", "fr", "de"];
18298
18475
  class UserProfileFormComponent {
18299
18476
  principalStore = inject(PrincipalStore);
@@ -18325,16 +18502,36 @@ class UserProfileFormComponent {
18325
18502
  ];
18326
18503
  changingPassword = signal(false, ...(ngDevMode ? [{ debugName: "changingPassword" }] : []));
18327
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" }] : []));
18328
18506
  /**
18329
- * The editable custom properties. A property that already holds an object in the profile (hierarchical
18330
- * `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}).
18331
18509
  */
18332
- customData = computed(() => {
18333
- const stored = this.userProfile()?.customData;
18334
- return this.customDataConfig()
18335
- .map(({ key }) => key)
18336
- .filter(key => !this.keys().some(k => k.toLowerCase() === key.toLowerCase()) && !isPlainObject(stored?.[key]));
18337
- }, ...(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" }] : []));
18338
18535
  allowChangePassword = computed(() => {
18339
18536
  const { useCredentials } = globalConfig;
18340
18537
  const { allowChangePassword = false } = this.appStore.general()?.features || {};
@@ -18384,9 +18581,10 @@ class UserProfileFormComponent {
18384
18581
  * @param propertyName property name
18385
18582
  */
18386
18583
  onEdit(category, propertyName) {
18387
- const value = this.getDataValue(category, propertyName);
18584
+ const raw = this.getRawValue(category, propertyName);
18388
18585
  this.feedback.set(undefined);
18389
- 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());
18390
18588
  this.propertyToEdit.set(`${category}.${propertyName}`);
18391
18589
  }
18392
18590
  /** Name of the property as the user sees it: the translated label for `data`, the raw key otherwise. */
@@ -18411,6 +18609,7 @@ class UserProfileFormComponent {
18411
18609
  /** Leaves the editor without saving. */
18412
18610
  onCancelEdit() {
18413
18611
  this.feedback.set(undefined);
18612
+ this.editingJson.set(false);
18414
18613
  this.propertyToEdit.set(undefined);
18415
18614
  }
18416
18615
  /**
@@ -18422,7 +18621,7 @@ class UserProfileFormComponent {
18422
18621
  * @param propertyName property name
18423
18622
  */
18424
18623
  hasStoredValue(category, propertyName) {
18425
- const stored = this.userProfile()?.[category]?.[propertyName];
18624
+ const stored = this.storedValue(category, propertyName);
18426
18625
  return stored !== undefined && stored !== null;
18427
18626
  }
18428
18627
  /**
@@ -18443,7 +18642,10 @@ class UserProfileFormComponent {
18443
18642
  this.feedback.set(undefined);
18444
18643
  this.pending.set(key);
18445
18644
  try {
18446
- 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);
18447
18649
  if (response) {
18448
18650
  info("Deleted user profile property", response);
18449
18651
  this.forgetProperty(category, propertyName);
@@ -18473,8 +18675,12 @@ class UserProfileFormComponent {
18473
18675
  /** Takes a deleted property out of the stored profile; a custom property with a default shows that default again. */
18474
18676
  forgetProperty(category, propertyName) {
18475
18677
  const userProfile = JSON.parse(JSON.stringify(this.userProfile()));
18476
- if (userProfile[category]) {
18477
- userProfile[category][propertyName] = undefined;
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);
18478
18684
  }
18479
18685
  this.userProfileResource.set(userProfile);
18480
18686
  }
@@ -18489,17 +18695,30 @@ class UserProfileFormComponent {
18489
18695
  return;
18490
18696
  const key = `${category}.${propertyName}`;
18491
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
+ }
18492
18703
  this.pending.set(key);
18493
18704
  // cloning the user profile to avoid changing the signal value before confirming the update
18494
18705
  const userProfile = JSON.parse(JSON.stringify(this.userProfile()));
18495
- if (!userProfile[category])
18496
- userProfile[category] = {};
18497
- const written = this.value()?.trim() || "";
18498
- userProfile[category][propertyName] = written;
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);
18499
18714
  try {
18500
- const response = await patchUserProfile(userProfile, category, propertyName);
18501
- this.foldSavedValue(category, propertyName, response, written);
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);
18502
18720
  this.value.set(undefined);
18721
+ this.editingJson.set(false);
18503
18722
  this.propertyToEdit.set(undefined);
18504
18723
  this.feedback.set({ key, type: "success", message: this.message("saved", category, propertyName) });
18505
18724
  }
@@ -18519,7 +18738,7 @@ class UserProfileFormComponent {
18519
18738
  */
18520
18739
  foldSavedValue(category, propertyName, response, written) {
18521
18740
  const current = this.userProfile();
18522
- if (isPlainObject(response) && ("data" in response || "customData" in response)) {
18741
+ if (category !== "privateCustomData" && !isStructured(written) && isPlainObject(response) && ("data" in response || "customData" in response)) {
18523
18742
  const profile = response;
18524
18743
  this.userProfileResource.set({
18525
18744
  ...current,
@@ -18533,24 +18752,64 @@ class UserProfileFormComponent {
18533
18752
  const value = response === undefined || response === null ? written : response;
18534
18753
  this.userProfileResource.set({
18535
18754
  ...current,
18536
- [category]: { ...(current?.[category] || {}), [propertyName]: value }
18755
+ [category]: category === "data" ? { ...(current?.data || {}), [propertyName]: value } : setAtPath(current?.[category], propertyName, value)
18537
18756
  });
18538
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
+ }
18539
18779
  /**
18540
- * Get the value of a data key
18780
+ * Get the value of a data key, when it is a scalar
18541
18781
  *
18542
18782
  * @param category data or customData
18543
18783
  * @param key property key
18544
- * @returns the value
18784
+ * @returns the value, or `undefined` when there is none or when it is a structure
18545
18785
  */
18546
18786
  getDataValue(category, key) {
18547
- const data = this.userProfile()?.[category];
18548
- const value = data?.[key];
18549
- if (value === undefined && category === "customData") {
18550
- const defaultValue = this.customDataConfig().find(property => property.key === key)?.defaultValue;
18551
- 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
+ }
18552
18803
  }
18553
- 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;
18554
18813
  }
18555
18814
  /**
18556
18815
  * TODO: how do we save the picture?
@@ -18582,7 +18841,7 @@ class UserProfileFormComponent {
18582
18841
  }
18583
18842
  }
18584
18843
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.29", ngImport: i0, type: UserProfileFormComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
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" }] });
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" }] });
18586
18845
  }
18587
18846
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.29", ngImport: i0, type: UserProfileFormComponent, decorators: [{
18588
18847
  type: Component,
@@ -18597,10 +18856,13 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.29", ngImpo
18597
18856
  AvatarComponent,
18598
18857
  AvatarFallbackComponent,
18599
18858
  UserIcon,
18859
+ GlobeIcon,
18860
+ UserSecretIcon,
18861
+ JsonValueView,
18600
18862
  AvatarImageComponent,
18601
18863
  ButtonComponent,
18602
18864
  ChangePasswordComponent
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" }]
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" }]
18604
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"] }] } });
18605
18867
 
18606
18868
  class UserProfileDialog {
@@ -18613,7 +18875,7 @@ class UserProfileDialog {
18613
18875
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.29", ngImport: i0, type: UserProfileDialog, deps: [], target: i0.ɵɵFactoryTarget.Component });
18614
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: `
18615
18877
  <div dialog #dialog="dialog">
18616
- <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">
18617
18879
  <DialogHeader>
18618
18880
  <DialogTitle>{{ 'userProfile.title' | transloco }}</DialogTitle>
18619
18881
  </DialogHeader>
@@ -18630,18 +18892,11 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.29", ngImpo
18630
18892
  args: [{
18631
18893
  selector: "user-profile-dialog, userprofiledialog, UserProfileDialog",
18632
18894
  standalone: true,
18633
- imports: [
18634
- DialogComponent,
18635
- DialogHeaderComponent,
18636
- DialogContentComponent,
18637
- UserProfileFormComponent,
18638
- DialogTitleComponent,
18639
- TranslocoPipe
18640
- ],
18895
+ imports: [DialogComponent, DialogHeaderComponent, DialogContentComponent, UserProfileFormComponent, DialogTitleComponent, TranslocoPipe],
18641
18896
  providers: [provideTranslocoScope("user-profile")],
18642
18897
  template: `
18643
18898
  <div dialog #dialog="dialog">
18644
- <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">
18645
18900
  <DialogHeader>
18646
18901
  <DialogTitle>{{ 'userProfile.title' | transloco }}</DialogTitle>
18647
18902
  </DialogHeader>