@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.
- package/features/user-profile/i18n/de.json +5 -0
- package/features/user-profile/i18n/en.json +5 -0
- package/features/user-profile/i18n/fr.json +5 -0
- package/fesm2022/sinequa-atomic-angular.mjs +317 -62
- package/fesm2022/sinequa-atomic-angular.mjs.map +1 -1
- package/index.d.ts +315 -224
- package/package.json +2 -2
|
@@ -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 ===
|
|
6122
|
+
.termspresence?.filter(tp => tp.presence === "missing")
|
|
6123
6123
|
.map(tp => {
|
|
6124
|
-
const
|
|
6125
|
-
|
|
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(
|
|
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:
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
|
18330
|
-
*
|
|
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
|
-
|
|
18334
|
-
|
|
18335
|
-
|
|
18336
|
-
|
|
18337
|
-
|
|
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
|
|
18584
|
+
const raw = this.getRawValue(category, propertyName);
|
|
18388
18585
|
this.feedback.set(undefined);
|
|
18389
|
-
this.
|
|
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.
|
|
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
|
-
|
|
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 (
|
|
18477
|
-
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);
|
|
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 (
|
|
18496
|
-
userProfile
|
|
18497
|
-
|
|
18498
|
-
|
|
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
|
-
|
|
18501
|
-
|
|
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?.
|
|
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
|
|
18548
|
-
|
|
18549
|
-
|
|
18550
|
-
|
|
18551
|
-
|
|
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
|
-
|
|
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>
|