@mohamedatia/fly-design-system 1.9.3 → 2.3.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/fesm2022/mohamedatia-fly-design-system.mjs +843 -35
- package/fesm2022/mohamedatia-fly-design-system.mjs.map +1 -1
- package/package.json +1 -1
- package/scss/_theme-light.scss +89 -89
- package/types/mohamedatia-fly-design-system.d.ts +525 -75
- package/types/mohamedatia-fly-design-system.d.ts.map +1 -1
|
@@ -1,16 +1,63 @@
|
|
|
1
1
|
import * as i0 from '@angular/core';
|
|
2
|
-
import { InjectionToken, signal, computed, Injectable, inject, ErrorHandler, PLATFORM_ID, Pipe, DOCUMENT, ElementRef, input, output, HostListener, ViewChild, ChangeDetectionStrategy, Component, EventEmitter, DestroyRef, Output, Input, viewChild, effect, ViewEncapsulation, model } from '@angular/core';
|
|
2
|
+
import { InjectionToken, signal, computed, Injectable, inject, ErrorHandler, PLATFORM_ID, Pipe, DOCUMENT, ElementRef, input, output, HostListener, ViewChild, ChangeDetectionStrategy, Component, EventEmitter, DestroyRef, Output, Input, forwardRef, viewChild, effect, ViewEncapsulation, model } from '@angular/core';
|
|
3
|
+
import * as i1$1 from '@angular/common';
|
|
3
4
|
import { isPlatformBrowser, CommonModule } from '@angular/common';
|
|
4
5
|
import { Router } from '@angular/router';
|
|
5
|
-
import { of, ReplaySubject } from 'rxjs';
|
|
6
|
+
import { of, ReplaySubject, Subject } from 'rxjs';
|
|
6
7
|
import * as i1 from '@angular/forms';
|
|
7
|
-
import { FormsModule } from '@angular/forms';
|
|
8
|
+
import { FormsModule, NG_VALUE_ACCESSOR, NG_VALIDATORS } from '@angular/forms';
|
|
8
9
|
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
|
9
|
-
import { switchMap } from 'rxjs/operators';
|
|
10
|
+
import { switchMap, debounceTime, distinctUntilChanged, filter } from 'rxjs/operators';
|
|
10
11
|
import { HttpClient, HttpEventType } from '@angular/common/http';
|
|
11
12
|
import Cropper from 'cropperjs';
|
|
12
13
|
|
|
13
14
|
const WINDOW_DATA = new InjectionToken('WINDOW_DATA');
|
|
15
|
+
/**
|
|
16
|
+
* Per-window injection token carrying the active `LaunchContext` as a signal.
|
|
17
|
+
* Resolves to `null` outside the desktop shell (e.g. mock-host dev mode).
|
|
18
|
+
*
|
|
19
|
+
* Remotes typically consume it as:
|
|
20
|
+
* ```ts
|
|
21
|
+
* private readonly launchCtx = inject(LAUNCH_CONTEXT, { optional: true });
|
|
22
|
+
* constructor() {
|
|
23
|
+
* effect(() => {
|
|
24
|
+
* const ctx = this.launchCtx?.();
|
|
25
|
+
* if (ctx?.route) this.router.navigateByUrl(ctx.route);
|
|
26
|
+
* });
|
|
27
|
+
* }
|
|
28
|
+
* ```
|
|
29
|
+
*/
|
|
30
|
+
const LAUNCH_CONTEXT = new InjectionToken('LAUNCH_CONTEXT');
|
|
31
|
+
|
|
32
|
+
/** Discriminator string values — re-exported so hosts can build kind menus dynamically. */
|
|
33
|
+
const AUDIENCE_TERM_KINDS = [
|
|
34
|
+
'users',
|
|
35
|
+
'roles',
|
|
36
|
+
'ou',
|
|
37
|
+
'app-everyone',
|
|
38
|
+
'chart',
|
|
39
|
+
'preset',
|
|
40
|
+
];
|
|
41
|
+
/**
|
|
42
|
+
* Runtime values for {@link AudiencePresetKind}. Hosts iterate this when surfacing a
|
|
43
|
+
* preset picker; the type union mirrors this list 1:1.
|
|
44
|
+
*/
|
|
45
|
+
const AUDIENCE_PRESETS = [
|
|
46
|
+
'self',
|
|
47
|
+
'ou-managers',
|
|
48
|
+
'direct-reports',
|
|
49
|
+
];
|
|
50
|
+
/**
|
|
51
|
+
* Hard caps mirrored from `AudienceLimits.cs`. Hosts use these for client-side validation
|
|
52
|
+
* before submit — backend re-validates regardless. Keep these in sync with the C# constants.
|
|
53
|
+
*/
|
|
54
|
+
const AUDIENCE_LIMITS = {
|
|
55
|
+
maxTermsPerFilter: 64,
|
|
56
|
+
maxUserIdsPerTerm: 500,
|
|
57
|
+
maxRoleKeysPerTerm: 64,
|
|
58
|
+
maxOuIdsPerTerm: 32,
|
|
59
|
+
maxResolvedUsers: 10_000,
|
|
60
|
+
};
|
|
14
61
|
|
|
15
62
|
/**
|
|
16
63
|
* Shared AuthService for Business Apps.
|
|
@@ -768,10 +815,30 @@ class SharePanelComponent {
|
|
|
768
815
|
revokePermission;
|
|
769
816
|
/** When true, show “apply to children” (e.g. folders). */
|
|
770
817
|
showApplyToChildren = false;
|
|
818
|
+
/**
|
|
819
|
+
* When true, surfaces an "Add as deny" toggle in the add-permission row and renders
|
|
820
|
+
* existing deny grants with red styling. Hosts whose backend does not support deny
|
|
821
|
+
* grants (legacy callers) leave this false and the panel hides all deny affordances.
|
|
822
|
+
*/
|
|
823
|
+
supportsDeny = false;
|
|
771
824
|
/** Override level dropdown options (e.g. notes: View / Edit only). */
|
|
772
825
|
permissionLevels;
|
|
773
826
|
/** i18n key for wildcard app grant label */
|
|
774
827
|
everyoneLabelKey = 'files.share.everyone';
|
|
828
|
+
/**
|
|
829
|
+
* Optional apps-chart role-OU index for the tenant. When supplied, the panel:
|
|
830
|
+
* <list type="bullet">
|
|
831
|
+
* <item>Renders existing OU grants whose ouId matches a row as <c>RolePrincipal</c>
|
|
832
|
+
* chips ("FFO in Circles") instead of opaque OU labels.</item>
|
|
833
|
+
* <item>Surfaces an "Add role" picker that translates the picked role to its
|
|
834
|
+
* role-OU UUID and dispatches via {@link grantToOu}. Backend stores it as a
|
|
835
|
+
* plain OU grant; <see cref="PermissionResolver"/> resolves at access time via
|
|
836
|
+
* the user's effective OU set.</item>
|
|
837
|
+
* </list>
|
|
838
|
+
* Hosts that don't have access to the apps-chart (or don't want the role flow) omit
|
|
839
|
+
* this input — OU grants then render as generic OU principals.
|
|
840
|
+
*/
|
|
841
|
+
loadRoleOus;
|
|
775
842
|
close = new EventEmitter();
|
|
776
843
|
destroyRef = inject(DestroyRef);
|
|
777
844
|
i18n = inject(I18nService);
|
|
@@ -780,6 +847,20 @@ class SharePanelComponent {
|
|
|
780
847
|
searchQuery = '';
|
|
781
848
|
newLevel = '';
|
|
782
849
|
applyToChildren = false;
|
|
850
|
+
/** Apps-chart role-OU index, populated lazily when {@link loadRoleOus} is provided. */
|
|
851
|
+
roleOus = signal([], ...(ngDevMode ? [{ debugName: "roleOus" }] : /* istanbul ignore next */ []));
|
|
852
|
+
/** Map<ouId, role row> — derived from roleOus for O(1) lookup during rendering. */
|
|
853
|
+
roleOusByOuId = signal({}, ...(ngDevMode ? [{ debugName: "roleOusByOuId" }] : /* istanbul ignore next */ []));
|
|
854
|
+
/** Two-way bound to the role picker's filter input. */
|
|
855
|
+
roleSearchQuery = '';
|
|
856
|
+
/** True while the role picker section is expanded. */
|
|
857
|
+
showRolePicker = signal(false, ...(ngDevMode ? [{ debugName: "showRolePicker" }] : /* istanbul ignore next */ []));
|
|
858
|
+
/**
|
|
859
|
+
* Two-way bound to the "Add as deny" toggle. When true, the next user/OU pick is
|
|
860
|
+
* persisted as a deny grant. Reset to false after each successful grant so the next
|
|
861
|
+
* pick defaults back to allow.
|
|
862
|
+
*/
|
|
863
|
+
addAsDeny = false;
|
|
783
864
|
searchResults = signal([], ...(ngDevMode ? [{ debugName: "searchResults" }] : /* istanbul ignore next */ []));
|
|
784
865
|
ouTree = signal([], ...(ngDevMode ? [{ debugName: "ouTree" }] : /* istanbul ignore next */ []));
|
|
785
866
|
showOuPicker = signal(false, ...(ngDevMode ? [{ debugName: "showOuPicker" }] : /* istanbul ignore next */ []));
|
|
@@ -794,6 +875,23 @@ class SharePanelComponent {
|
|
|
794
875
|
const levels = this.levelOptions;
|
|
795
876
|
this.newLevel = levels[0]?.value ?? 'View';
|
|
796
877
|
this.refreshPermissions();
|
|
878
|
+
// Load the role-OU lookup table once. Failures degrade gracefully — OU grants then
|
|
879
|
+
// render as generic OU principals (the v1 behaviour) instead of Role chips.
|
|
880
|
+
this.loadRoleOus?.()
|
|
881
|
+
.pipe(takeUntilDestroyed(this.destroyRef))
|
|
882
|
+
.subscribe({
|
|
883
|
+
next: (rows) => {
|
|
884
|
+
this.roleOus.set(rows);
|
|
885
|
+
const byId = {};
|
|
886
|
+
for (const r of rows)
|
|
887
|
+
byId[r.ouId] = r;
|
|
888
|
+
this.roleOusByOuId.set(byId);
|
|
889
|
+
},
|
|
890
|
+
error: () => {
|
|
891
|
+
this.roleOus.set([]);
|
|
892
|
+
this.roleOusByOuId.set({});
|
|
893
|
+
},
|
|
894
|
+
});
|
|
797
895
|
this.loadOuLabelMap()
|
|
798
896
|
.pipe(takeUntilDestroyed(this.destroyRef))
|
|
799
897
|
.subscribe({
|
|
@@ -866,14 +964,55 @@ class SharePanelComponent {
|
|
|
866
964
|
onGrantToUser(user) {
|
|
867
965
|
this.searchResults.set([]);
|
|
868
966
|
this.searchQuery = '';
|
|
869
|
-
|
|
967
|
+
const isDeny = this.supportsDeny && this.addAsDeny;
|
|
968
|
+
this.grantToUser(user.id, this.newLevel, this.applyToChildren, isDeny)
|
|
870
969
|
.pipe(takeUntilDestroyed(this.destroyRef))
|
|
871
|
-
.subscribe({
|
|
970
|
+
.subscribe({
|
|
971
|
+
next: () => {
|
|
972
|
+
// Reset deny toggle after each successful grant so allow is the default for the
|
|
973
|
+
// next pick. Preserves the level dropdown which is genuinely sticky.
|
|
974
|
+
this.addAsDeny = false;
|
|
975
|
+
this.refreshPermissions();
|
|
976
|
+
},
|
|
977
|
+
});
|
|
872
978
|
}
|
|
873
979
|
onGrantToOu(ou) {
|
|
874
|
-
|
|
980
|
+
const isDeny = this.supportsDeny && this.addAsDeny;
|
|
981
|
+
this.grantToOu(ou.id, this.newLevel, this.applyToChildren, isDeny)
|
|
875
982
|
.pipe(takeUntilDestroyed(this.destroyRef))
|
|
876
|
-
.subscribe({
|
|
983
|
+
.subscribe({
|
|
984
|
+
next: () => {
|
|
985
|
+
this.addAsDeny = false;
|
|
986
|
+
this.refreshPermissions();
|
|
987
|
+
},
|
|
988
|
+
});
|
|
989
|
+
}
|
|
990
|
+
/**
|
|
991
|
+
* Add a role grant. Resolves to the role-OU UUID and dispatches via {@link grantToOu}
|
|
992
|
+
* so the backend stores it as a plain OU grant; PermissionResolver matches it at access
|
|
993
|
+
* time via the user's effective OU set (apps-chart computed-from-role membership).
|
|
994
|
+
*/
|
|
995
|
+
onGrantToRole(role) {
|
|
996
|
+
const isDeny = this.supportsDeny && this.addAsDeny;
|
|
997
|
+
this.grantToOu(role.ouId, this.newLevel, this.applyToChildren, isDeny)
|
|
998
|
+
.pipe(takeUntilDestroyed(this.destroyRef))
|
|
999
|
+
.subscribe({
|
|
1000
|
+
next: () => {
|
|
1001
|
+
this.addAsDeny = false;
|
|
1002
|
+
this.roleSearchQuery = '';
|
|
1003
|
+
this.refreshPermissions();
|
|
1004
|
+
},
|
|
1005
|
+
});
|
|
1006
|
+
}
|
|
1007
|
+
/** Filtered role list — empty query returns all roles, otherwise case-insensitive substring match. */
|
|
1008
|
+
filteredRoles() {
|
|
1009
|
+
const q = this.roleSearchQuery.trim().toLowerCase();
|
|
1010
|
+
const all = this.roleOus();
|
|
1011
|
+
if (!q)
|
|
1012
|
+
return all;
|
|
1013
|
+
return all.filter((r) => r.displayName.toLowerCase().includes(q)
|
|
1014
|
+
|| r.roleKey.toLowerCase().includes(q)
|
|
1015
|
+
|| r.appId.toLowerCase().includes(q));
|
|
877
1016
|
}
|
|
878
1017
|
onUpdateLevel(perm, level) {
|
|
879
1018
|
this.updatePermission(perm.id, level)
|
|
@@ -886,13 +1025,25 @@ class SharePanelComponent {
|
|
|
886
1025
|
.subscribe({ next: () => this.refreshPermissions() });
|
|
887
1026
|
}
|
|
888
1027
|
getPermIcon(perm) {
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
if (perm.
|
|
892
|
-
return 'pi pi-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
1028
|
+
// Deny rows take a distinct icon regardless of principal kind so the operator can
|
|
1029
|
+
// scan a long permission list and immediately spot revocations.
|
|
1030
|
+
if (perm.isDeny)
|
|
1031
|
+
return 'pi pi-ban';
|
|
1032
|
+
// Reverse-translate: an OuPrincipal whose ouId matches a known role-OU should render
|
|
1033
|
+
// as a role chip, even though the host stored it as an OU grant.
|
|
1034
|
+
if (perm.principal.kind === 'ou' && this.roleOusByOuId()[perm.principal.ouId]) {
|
|
1035
|
+
return 'pi pi-id-card';
|
|
1036
|
+
}
|
|
1037
|
+
return SharePanelComponent.principalIcon(perm.principal);
|
|
1038
|
+
}
|
|
1039
|
+
/** Maps a principal kind to its display icon. Static so hosts can reuse for menus. */
|
|
1040
|
+
static principalIcon(principal) {
|
|
1041
|
+
switch (principal.kind) {
|
|
1042
|
+
case 'user': return 'pi pi-user';
|
|
1043
|
+
case 'role': return 'pi pi-id-card';
|
|
1044
|
+
case 'ou': return 'pi pi-sitemap';
|
|
1045
|
+
case 'app-everyone': return 'pi pi-globe';
|
|
1046
|
+
}
|
|
896
1047
|
}
|
|
897
1048
|
/** Logical start padding for OU hierarchy (roots align with section padding). */
|
|
898
1049
|
ouIndentStartPx(ou) {
|
|
@@ -900,31 +1051,40 @@ class SharePanelComponent {
|
|
|
900
1051
|
return 12 + d * 18;
|
|
901
1052
|
}
|
|
902
1053
|
getPermLabel(perm) {
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
return
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
1054
|
+
const p = perm.principal;
|
|
1055
|
+
if (p.displayName?.trim())
|
|
1056
|
+
return p.displayName.trim();
|
|
1057
|
+
switch (p.kind) {
|
|
1058
|
+
case 'user':
|
|
1059
|
+
return `${p.userId.substring(0, 8)}…`;
|
|
1060
|
+
case 'role':
|
|
1061
|
+
// Hosts SHOULD pre-resolve role display names — this branch is the
|
|
1062
|
+
// worst-case fallback for an unresolved key.
|
|
1063
|
+
return `${p.appId}:${p.roleKey}`;
|
|
1064
|
+
case 'ou': {
|
|
1065
|
+
// Reverse-translate: if this OU id matches a known apps-chart role-OU, render
|
|
1066
|
+
// as the semantic role label instead of the opaque OU name.
|
|
1067
|
+
const role = this.roleOusByOuId()[p.ouId];
|
|
1068
|
+
if (role)
|
|
1069
|
+
return role.displayName;
|
|
1070
|
+
const fromDefault = this.ouLabelById()[p.ouId];
|
|
1071
|
+
if (fromDefault)
|
|
1072
|
+
return fromDefault;
|
|
1073
|
+
const ou = this.ouTree().find((o) => o.id === p.ouId);
|
|
1074
|
+
if (ou)
|
|
1075
|
+
return ou.displayName;
|
|
1076
|
+
return `${p.ouId.substring(0, 8)}…`;
|
|
1077
|
+
}
|
|
1078
|
+
case 'app-everyone':
|
|
1079
|
+
return p.appId === '*' ? this.i18n.t(this.everyoneLabelKey) : p.appId;
|
|
917
1080
|
}
|
|
918
|
-
if (perm.grantedToAppId === '*')
|
|
919
|
-
return this.i18n.t(this.everyoneLabelKey);
|
|
920
|
-
return perm.grantedToAppId ?? '';
|
|
921
1081
|
}
|
|
922
1082
|
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.5", ngImport: i0, type: SharePanelComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
923
|
-
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.5", type: SharePanelComponent, isStandalone: true, selector: "fly-share-panel", inputs: { targetName: "targetName", titleKey: "titleKey", loadPermissions: "loadPermissions", searchUsers: "searchUsers", loadOuTree: "loadOuTree", loadChartOptions: "loadChartOptions", loadOuLabelMap: "loadOuLabelMap", grantToUser: "grantToUser", grantToOu: "grantToOu", updatePermission: "updatePermission", revokePermission: "revokePermission", showApplyToChildren: "showApplyToChildren", permissionLevels: "permissionLevels", everyoneLabelKey: "everyoneLabelKey" }, outputs: { close: "close" }, ngImport: i0, template: "<div class=\"fac-overlay\" (click)=\"close.emit()\">\n <div class=\"fac-panel\" (click)=\"$event.stopPropagation()\">\n <div class=\"fac-header\">\n <h3>{{ titleKey | translate }}</h3>\n <span class=\"fac-target-name\">{{ targetName }}</span>\n <button type=\"button\" class=\"fac-close\" (click)=\"close.emit()\">\n <i class=\"pi pi-times\" aria-hidden=\"true\"></i>\n </button>\n </div>\n\n <div class=\"fac-add-section\">\n <div class=\"fac-search-row\">\n <input\n type=\"text\"\n class=\"fac-input\"\n [(ngModel)]=\"searchQuery\"\n (input)=\"onSearchInput()\"\n [placeholder]=\"'files.share.search_placeholder' | translate\"\n />\n <select class=\"fac-select\" [(ngModel)]=\"newLevel\">\n @for (opt of levelOptions; track opt.value) {\n <option [value]=\"opt.value\">{{ opt.labelKey | translate }}</option>\n }\n </select>\n </div>\n\n @if (searchResults().length > 0) {\n <div class=\"fac-search-results\">\n @for (result of searchResults(); track result.id) {\n <div class=\"fac-search-item\" (click)=\"onGrantToUser(result)\">\n <i class=\"pi pi-user\" aria-hidden=\"true\"></i>\n <span>{{ result.firstName }} {{ result.lastName }}</span>\n <span class=\"fac-email\">{{ result.email }}</span>\n </div>\n }\n </div>\n }\n\n @if (chartOptions().length > 0) {\n <div class=\"fac-chart-row\">\n <label for=\"fac-org-chart-select\" class=\"fac-section-label\">{{\n 'files.share.org_chart' | translate\n }}</label>\n <select\n id=\"fac-org-chart-select\"\n class=\"fac-select\"\n [ngModel]=\"selectedOrgChartId()\"\n (ngModelChange)=\"onOrgChartSelectChange($event)\">\n @for (opt of chartOptions(); track $index) {\n <option [ngValue]=\"opt.id\">{{ opt.name }}</option>\n }\n </select>\n </div>\n }\n\n @if (showOuPicker()) {\n <div class=\"fac-ou-section\">\n <div class=\"fac-section-label\">{{ 'files.share.share_with_ou' | translate }}</div>\n @for (ou of ouTree(); track ou.id) {\n <div\n class=\"fac-ou-item\"\n [style.padding-inline-start.px]=\"ouIndentStartPx(ou)\"\n (click)=\"onGrantToOu(ou)\"\n >\n <i class=\"pi pi-sitemap\" aria-hidden=\"true\"></i>\n <span>{{ ou.displayName }}</span>\n </div>\n }\n </div>\n }\n\n <div class=\"fac-toggle-row\">\n <button type=\"button\" class=\"fac-link\" (click)=\"showOuPicker.set(!showOuPicker())\">\n {{ showOuPicker() ? ('files.share.hide_ous' | translate) : ('files.share.show_ous' | translate) }}\n </button>\n @if (showApplyToChildren) {\n <label class=\"fac-checkbox-label\">\n <input type=\"checkbox\" [(ngModel)]=\"applyToChildren\" />\n {{ 'files.share.apply_children' | translate }}\n </label>\n }\n </div>\n </div>\n\n <div class=\"fac-perms-section\">\n <div class=\"fac-section-label\">{{ 'files.share.current_permissions' | translate }}</div>\n @if (loading()) {\n <div class=\"fac-loading\"><i class=\"pi pi-spin pi-spinner\" aria-hidden=\"true\"></i></div>\n } @else if (permissions().length === 0) {\n <div class=\"fac-empty\">{{ 'files.share.no_permissions' | translate }}</div>\n } @else {\n @for (perm of permissions(); track perm.id) {\n <div class=\"fac-perm-row\">\n <div class=\"fac-perm-info\">\n <i [class]=\"getPermIcon(perm)\" aria-hidden=\"true\"></i>\n <span class=\"fac-perm-name\">{{ getPermLabel(perm) }}</span>\n @if (perm.isInherited) {\n <span class=\"fac-badge inherited\">{{ 'files.share.inherited' | translate }}</span>\n }\n </div>\n <div class=\"fac-perm-actions\">\n <select\n class=\"fac-select sm\"\n [ngModel]=\"perm.level\"\n (ngModelChange)=\"onUpdateLevel(perm, $event)\"\n [disabled]=\"perm.isInherited === true\"\n >\n @for (opt of levelOptions; track opt.value) {\n <option [value]=\"opt.value\">{{ opt.labelKey | translate }}</option>\n }\n </select>\n <button\n type=\"button\"\n class=\"fac-icon-btn danger\"\n (click)=\"onRevokePermission(perm)\"\n [disabled]=\"perm.isInherited === true\"\n [title]=\"'files.share.revoke' | translate\"\n >\n <i class=\"pi pi-trash\" aria-hidden=\"true\"></i>\n </button>\n </div>\n </div>\n }\n }\n </div>\n </div>\n</div>\n", styles: [".fac-overlay{position:fixed;inset:0;background:#00000080;display:flex;align-items:center;justify-content:center;z-index:2000;-webkit-backdrop-filter:blur(4px);backdrop-filter:blur(4px)}.fac-panel{background:var(--surface-card, #1e1e1e);border:1px solid var(--surface-border);border-radius:12px;width:600px;max-width:min(600px,96vw);max-height:85vh;display:flex;flex-direction:column;overflow:hidden;box-shadow:0 16px 48px #0006}.fac-header{display:flex;align-items:center;gap:10px;padding:16px 20px;border-bottom:1px solid var(--surface-border)}.fac-header h3{margin:0;font-size:16px;font-weight:600}.fac-target-name{flex:1;font-size:13px;color:var(--text-color-secondary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.fac-close{background:none;border:none;color:var(--text-color-secondary);cursor:pointer;font-size:16px;padding:4px}.fac-close:hover{color:var(--text-color)}.fac-add-section{padding:16px 20px;border-bottom:1px solid var(--surface-border)}.fac-search-row{display:flex;gap:8px}.fac-chart-row{display:flex;flex-direction:column;gap:6px;margin-top:12px}.fac-chart-row .fac-select{width:100%}.fac-input{flex:1;background:#ffffff14;border:1px solid rgba(255,255,255,.15);border-radius:8px;padding:8px 12px;color:inherit;font-size:13px;outline:none}.fac-input:focus{border-color:var(--primary-color, #e8732a)}.fac-select{background:#ffffff14;border:1px solid rgba(255,255,255,.15);border-radius:8px;padding:8px 10px;color:inherit;font-size:13px;outline:none}.fac-select.sm{padding:4px 8px;font-size:12px}.fac-search-results{margin-top:8px;max-height:150px;overflow-y:auto;border:1px solid var(--surface-border);border-radius:8px}.fac-search-item{display:flex;align-items:center;gap:8px;padding:8px 12px;cursor:pointer;font-size:13px}.fac-search-item:hover{background:var(--surface-hover)}.fac-search-item i{color:var(--text-color-secondary)}.fac-email{color:var(--text-color-secondary);font-size:12px;margin-inline-start:auto}.fac-ou-section{margin-top:10px;max-height:220px;overflow-y:auto}.fac-ou-item{display:flex;align-items:center;gap:8px;padding:6px 12px;padding-inline-start:12px;cursor:pointer;font-size:13px;border-radius:6px}.fac-ou-item:hover{background:var(--surface-hover)}.fac-ou-item i{color:var(--text-color-secondary)}.fac-section-label{font-size:11px;font-weight:700;text-transform:uppercase;color:var(--text-color-secondary);letter-spacing:.5px;margin-bottom:8px}.fac-toggle-row{display:flex;align-items:center;justify-content:space-between;margin-top:10px}.fac-link{background:none;border:none;color:var(--primary-color, #e8732a);cursor:pointer;font-size:12px;padding:0}.fac-link:hover{text-decoration:underline}.fac-checkbox-label{display:flex;align-items:center;gap:6px;font-size:12px;color:var(--text-color-secondary);cursor:pointer}.fac-checkbox-label input{accent-color:var(--primary-color, #e8732a)}.fac-perms-section{flex:1;overflow-y:auto;padding:16px 20px}.fac-loading,.fac-empty{text-align:center;padding:20px;color:var(--text-color-secondary);font-size:13px}.fac-perm-row{display:flex;align-items:center;justify-content:space-between;padding:8px 0;border-bottom:1px solid rgba(255,255,255,.05);gap:8px}.fac-perm-info{display:flex;align-items:center;gap:8px;flex:1;min-width:0}.fac-perm-info i{color:var(--text-color-secondary);flex-shrink:0}.fac-perm-name{font-size:13px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.fac-badge{font-size:10px;padding:2px 6px;border-radius:8px;flex-shrink:0}.fac-badge.inherited{background:#ffffff1a;color:var(--text-color-secondary)}.fac-perm-actions{display:flex;align-items:center;gap:6px;flex-shrink:0}.fac-icon-btn{background:#ffffff0f;border:none;border-radius:6px;width:28px;height:28px;display:flex;align-items:center;justify-content:center;color:inherit;cursor:pointer;font-size:12px}.fac-icon-btn:hover{background:#ffffff24}.fac-icon-btn.danger:hover{background:#ff3b3033;color:#ff3b30}.fac-icon-btn:disabled{opacity:.4;cursor:default}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }, { 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.CheckboxControlValueAccessor, selector: "input[type=checkbox][formControlName],input[type=checkbox][formControl],input[type=checkbox][ngModel]" }, { 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: "pipe", type: TranslatePipe, name: "translate" }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
1083
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.5", type: SharePanelComponent, isStandalone: true, selector: "fly-share-panel", inputs: { targetName: "targetName", titleKey: "titleKey", loadPermissions: "loadPermissions", searchUsers: "searchUsers", loadOuTree: "loadOuTree", loadChartOptions: "loadChartOptions", loadOuLabelMap: "loadOuLabelMap", grantToUser: "grantToUser", grantToOu: "grantToOu", updatePermission: "updatePermission", revokePermission: "revokePermission", showApplyToChildren: "showApplyToChildren", supportsDeny: "supportsDeny", permissionLevels: "permissionLevels", everyoneLabelKey: "everyoneLabelKey", loadRoleOus: "loadRoleOus" }, outputs: { close: "close" }, ngImport: i0, template: "<div class=\"fac-overlay\" (click)=\"close.emit()\">\r\n <div class=\"fac-panel\" (click)=\"$event.stopPropagation()\">\r\n <div class=\"fac-header\">\r\n <h3>{{ titleKey | translate }}</h3>\r\n <span class=\"fac-target-name\">{{ targetName }}</span>\r\n <button\r\n type=\"button\"\r\n class=\"fac-close\"\r\n (click)=\"close.emit()\"\r\n [attr.aria-label]=\"'files.share.close' | translate\">\r\n <i class=\"pi pi-times\" aria-hidden=\"true\"></i>\r\n </button>\r\n </div>\r\n\r\n <div class=\"fac-add-section\">\r\n <div class=\"fac-search-row\">\r\n <input\r\n type=\"text\"\r\n class=\"fac-input\"\r\n [(ngModel)]=\"searchQuery\"\r\n (input)=\"onSearchInput()\"\r\n [placeholder]=\"'files.share.search_placeholder' | translate\"\r\n [attr.aria-label]=\"'files.share.search_users' | translate\"\r\n />\r\n <select class=\"fac-select\" [(ngModel)]=\"newLevel\">\r\n @for (opt of levelOptions; track opt.value) {\r\n <option [value]=\"opt.value\">{{ opt.labelKey | translate }}</option>\r\n }\r\n </select>\r\n </div>\r\n\r\n @if (searchResults().length > 0) {\r\n <div class=\"fac-search-results\">\r\n @for (result of searchResults(); track result.id) {\r\n <div class=\"fac-search-item\" (click)=\"onGrantToUser(result)\">\r\n <i class=\"pi pi-user\" aria-hidden=\"true\"></i>\r\n <span>{{ result.firstName }} {{ result.lastName }}</span>\r\n <span class=\"fac-email\">{{ result.email }}</span>\r\n </div>\r\n }\r\n </div>\r\n }\r\n\r\n @if (chartOptions().length > 0) {\r\n <div class=\"fac-chart-row\">\r\n <label for=\"fac-org-chart-select\" class=\"fac-section-label\">{{\r\n 'files.share.org_chart' | translate\r\n }}</label>\r\n <select\r\n id=\"fac-org-chart-select\"\r\n class=\"fac-select\"\r\n [ngModel]=\"selectedOrgChartId()\"\r\n (ngModelChange)=\"onOrgChartSelectChange($event)\">\r\n @for (opt of chartOptions(); track $index) {\r\n <option [ngValue]=\"opt.id\">{{ opt.name }}</option>\r\n }\r\n </select>\r\n </div>\r\n }\r\n\r\n @if (showOuPicker()) {\r\n <div class=\"fac-ou-section\">\r\n <div class=\"fac-section-label\">{{ 'files.share.share_with_ou' | translate }}</div>\r\n @for (ou of ouTree(); track ou.id) {\r\n <button\r\n type=\"button\"\r\n class=\"fac-ou-item\"\r\n [style.padding-inline-start.px]=\"ouIndentStartPx(ou)\"\r\n (click)=\"onGrantToOu(ou)\"\r\n [attr.aria-label]=\"('files.share.share_with_ou_named' | translate) + ' ' + ou.displayName\"\r\n >\r\n <i class=\"pi pi-sitemap\" aria-hidden=\"true\"></i>\r\n <span dir=\"auto\">{{ ou.displayName }}</span>\r\n </button>\r\n }\r\n </div>\r\n }\r\n\r\n @if (loadRoleOus && roleOus().length > 0) {\r\n <div class=\"fac-role-section\">\r\n <div class=\"fac-section-label\">{{ 'files.share.share_with_role' | translate }}</div>\r\n <input\r\n type=\"text\"\r\n class=\"fac-input\"\r\n [(ngModel)]=\"roleSearchQuery\"\r\n [placeholder]=\"'files.share.role_search_placeholder' | translate\"\r\n [attr.aria-label]=\"'files.share.search_roles' | translate\"\r\n [hidden]=\"!showRolePicker()\" />\r\n @if (showRolePicker()) {\r\n <div class=\"fac-role-list\">\r\n @for (role of filteredRoles(); track role.ouId) {\r\n <button\r\n type=\"button\"\r\n class=\"fac-role-item\"\r\n (click)=\"onGrantToRole(role)\"\r\n [attr.aria-label]=\"('files.share.share_with_role_named' | translate) + ' ' + role.displayName\">\r\n <i class=\"pi pi-id-card\" aria-hidden=\"true\"></i>\r\n <span dir=\"auto\">{{ role.displayName }}</span>\r\n <span class=\"fac-role-app\">{{ role.appId }}</span>\r\n </button>\r\n }\r\n @if (filteredRoles().length === 0) {\r\n <div class=\"fac-empty\">{{ 'files.share.no_roles_match' | translate }}</div>\r\n }\r\n </div>\r\n }\r\n </div>\r\n }\r\n\r\n <div class=\"fac-toggle-row\">\r\n <button type=\"button\" class=\"fac-link\" (click)=\"showOuPicker.set(!showOuPicker())\">\r\n {{ showOuPicker() ? ('files.share.hide_ous' | translate) : ('files.share.show_ous' | translate) }}\r\n </button>\r\n @if (loadRoleOus && roleOus().length > 0) {\r\n <button type=\"button\" class=\"fac-link\" (click)=\"showRolePicker.set(!showRolePicker())\">\r\n {{ showRolePicker() ? ('files.share.hide_roles' | translate) : ('files.share.show_roles' | translate) }}\r\n </button>\r\n }\r\n @if (showApplyToChildren) {\r\n <label class=\"fac-checkbox-label\">\r\n <input type=\"checkbox\" [(ngModel)]=\"applyToChildren\" />\r\n {{ 'files.share.apply_children' | translate }}\r\n </label>\r\n }\r\n @if (supportsDeny) {\r\n <label class=\"fac-checkbox-label fac-deny-toggle\"\r\n [title]=\"'files.share.deny_help' | translate\">\r\n <input type=\"checkbox\" [(ngModel)]=\"addAsDeny\" />\r\n <i class=\"pi pi-ban\" aria-hidden=\"true\"></i>\r\n {{ 'files.share.add_as_deny' | translate }}\r\n </label>\r\n }\r\n </div>\r\n </div>\r\n\r\n <div class=\"fac-perms-section\">\r\n <div class=\"fac-section-label\">{{ 'files.share.current_permissions' | translate }}</div>\r\n @if (loading()) {\r\n <div class=\"fac-loading\"><i class=\"pi pi-spin pi-spinner\" aria-hidden=\"true\"></i></div>\r\n } @else if (permissions().length === 0) {\r\n <div class=\"fac-empty\">{{ 'files.share.no_permissions' | translate }}</div>\r\n } @else {\r\n @for (perm of permissions(); track perm.id) {\r\n <div class=\"fac-perm-row\" [class.fac-perm-row-deny]=\"perm.isDeny\">\r\n <div class=\"fac-perm-info\">\r\n <i [class]=\"getPermIcon(perm)\" aria-hidden=\"true\"></i>\r\n <span class=\"fac-perm-name\" dir=\"auto\">{{ getPermLabel(perm) }}</span>\r\n @if (perm.isDeny) {\r\n <span class=\"fac-badge deny\">{{ 'files.share.denied' | translate }}</span>\r\n }\r\n @if (perm.isInherited) {\r\n <span class=\"fac-badge inherited\">{{ 'files.share.inherited' | translate }}</span>\r\n }\r\n </div>\r\n <div class=\"fac-perm-actions\">\r\n @if (!perm.isDeny) {\r\n <select\r\n class=\"fac-select sm\"\r\n [ngModel]=\"perm.level\"\r\n (ngModelChange)=\"onUpdateLevel(perm, $event)\"\r\n [disabled]=\"perm.isInherited === true\"\r\n >\r\n @for (opt of levelOptions; track opt.value) {\r\n <option [value]=\"opt.value\">{{ opt.labelKey | translate }}</option>\r\n }\r\n </select>\r\n }\r\n <button\r\n type=\"button\"\r\n class=\"fac-icon-btn danger\"\r\n (click)=\"onRevokePermission(perm)\"\r\n [disabled]=\"perm.isInherited === true\"\r\n [title]=\"'files.share.revoke' | translate\"\r\n >\r\n <i class=\"pi pi-trash\" aria-hidden=\"true\"></i>\r\n </button>\r\n </div>\r\n </div>\r\n }\r\n }\r\n </div>\r\n </div>\r\n</div>\r\n", styles: [".fac-overlay{position:fixed;inset:0;background:#00000080;display:flex;align-items:center;justify-content:center;z-index:2000;-webkit-backdrop-filter:blur(4px);backdrop-filter:blur(4px)}.fac-panel{background:var(--surface-card, #1e1e1e);border:1px solid var(--surface-border);border-radius:12px;width:600px;max-width:min(600px,96vw);max-height:85vh;display:flex;flex-direction:column;overflow:hidden;box-shadow:0 16px 48px #0006}.fac-header{display:flex;align-items:center;gap:10px;padding:16px 20px;border-bottom:1px solid var(--surface-border)}.fac-header h3{margin:0;font-size:16px;font-weight:600}.fac-target-name{flex:1;font-size:13px;color:var(--text-color-secondary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.fac-close{background:none;border:none;color:var(--text-color-secondary);cursor:pointer;font-size:16px;padding:4px}.fac-close:hover{color:var(--text-color)}.fac-add-section{padding:16px 20px;border-bottom:1px solid var(--surface-border)}.fac-search-row{display:flex;gap:8px}.fac-chart-row{display:flex;flex-direction:column;gap:6px;margin-top:12px}.fac-chart-row .fac-select{width:100%}.fac-input{flex:1;background:#ffffff14;border:1px solid rgba(255,255,255,.15);border-radius:8px;padding:8px 12px;color:inherit;font-size:13px;outline:none}.fac-input:focus{border-color:var(--primary-color, #e8732a)}.fac-select{background:#ffffff14;border:1px solid rgba(255,255,255,.15);border-radius:8px;padding:8px 10px;color:inherit;font-size:13px;outline:none}.fac-select.sm{padding:4px 8px;font-size:12px}.fac-search-results{margin-top:8px;max-height:150px;overflow-y:auto;border:1px solid var(--surface-border);border-radius:8px}.fac-search-item{display:flex;align-items:center;gap:8px;padding:8px 12px;cursor:pointer;font-size:13px}.fac-search-item:hover{background:var(--surface-hover)}.fac-search-item i{color:var(--text-color-secondary)}.fac-email{color:var(--text-color-secondary);font-size:12px;margin-inline-start:auto}.fac-ou-section{margin-top:10px;max-height:220px;overflow-y:auto}.fac-ou-item{display:flex;align-items:center;gap:8px;padding:6px 12px;padding-inline-start:12px;cursor:pointer;font-size:13px;border-radius:6px}.fac-ou-item:hover{background:var(--surface-hover)}.fac-ou-item i{color:var(--text-color-secondary)}.fac-section-label{font-size:11px;font-weight:700;text-transform:uppercase;color:var(--text-color-secondary);letter-spacing:.5px;margin-bottom:8px}.fac-toggle-row{display:flex;align-items:center;justify-content:space-between;margin-top:10px}.fac-link{background:none;border:none;color:var(--primary-color, #e8732a);cursor:pointer;font-size:12px;padding:0}.fac-link:hover{text-decoration:underline}.fac-checkbox-label{display:flex;align-items:center;gap:6px;font-size:12px;color:var(--text-color-secondary);cursor:pointer}.fac-checkbox-label input{accent-color:var(--primary-color, #e8732a)}.fac-perms-section{flex:1;overflow-y:auto;padding:16px 20px}.fac-loading,.fac-empty{text-align:center;padding:20px;color:var(--text-color-secondary);font-size:13px}.fac-perm-row{display:flex;align-items:center;justify-content:space-between;padding:8px 0;border-bottom:1px solid rgba(255,255,255,.05);gap:8px}.fac-perm-info{display:flex;align-items:center;gap:8px;flex:1;min-width:0}.fac-perm-info i{color:var(--text-color-secondary);flex-shrink:0}.fac-perm-name{font-size:13px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.fac-badge{font-size:10px;padding:2px 6px;border-radius:8px;flex-shrink:0}.fac-badge.inherited{background:#ffffff1a;color:var(--text-color-secondary)}.fac-badge.deny{background:#ff3b302e;color:#ff3b30;text-transform:uppercase;font-weight:600;letter-spacing:.5px}.fac-perm-row-deny{background:#ff3b300f;border-inline-start:3px solid #ff3b30;padding-inline-start:8px;border-radius:6px}.fac-perm-row-deny .fac-perm-info i{color:#ff3b30}.fac-deny-toggle{color:#ff6b6b}.fac-deny-toggle i{color:#ff6b6b;font-size:11px;margin-inline-end:2px}.fac-deny-toggle input{accent-color:#ff3b30}.fac-role-section{margin-top:10px;display:flex;flex-direction:column;gap:6px}.fac-role-list{margin-top:6px;max-height:220px;overflow-y:auto;border:1px solid var(--surface-border);border-radius:8px}.fac-role-item{display:flex;align-items:center;gap:8px;padding:8px 12px;cursor:pointer;font-size:13px}.fac-role-item:hover{background:var(--surface-hover)}.fac-role-item i{color:var(--text-color-secondary)}.fac-role-app{margin-inline-start:auto;font-size:11px;color:var(--text-color-secondary);text-transform:uppercase;letter-spacing:.5px}.fac-perm-actions{display:flex;align-items:center;gap:6px;flex-shrink:0}.fac-icon-btn{background:#ffffff0f;border:none;border-radius:6px;width:28px;height:28px;display:flex;align-items:center;justify-content:center;color:inherit;cursor:pointer;font-size:12px}.fac-icon-btn:hover{background:#ffffff24}.fac-icon-btn.danger:hover{background:#ff3b3033;color:#ff3b30}.fac-icon-btn:disabled{opacity:.4;cursor:default}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }, { 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.CheckboxControlValueAccessor, selector: "input[type=checkbox][formControlName],input[type=checkbox][formControl],input[type=checkbox][ngModel]" }, { 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: "pipe", type: TranslatePipe, name: "translate" }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
924
1084
|
}
|
|
925
1085
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.5", ngImport: i0, type: SharePanelComponent, decorators: [{
|
|
926
1086
|
type: Component,
|
|
927
|
-
args: [{ selector: 'fly-share-panel', standalone: true, imports: [CommonModule, FormsModule, TranslatePipe], changeDetection: ChangeDetectionStrategy.OnPush, template: "<div class=\"fac-overlay\" (click)=\"close.emit()\">\n <div class=\"fac-panel\" (click)=\"$event.stopPropagation()\">\n <div class=\"fac-header\">\n <h3>{{ titleKey | translate }}</h3>\n <span class=\"fac-target-name\">{{ targetName }}</span>\n <button type=\"button\" class=\"fac-close\" (click)=\"close.emit()\">\n <i class=\"pi pi-times\" aria-hidden=\"true\"></i>\n </button>\n </div>\n\n <div class=\"fac-add-section\">\n <div class=\"fac-search-row\">\n <input\n type=\"text\"\n class=\"fac-input\"\n [(ngModel)]=\"searchQuery\"\n (input)=\"onSearchInput()\"\n [placeholder]=\"'files.share.search_placeholder' | translate\"\n />\n <select class=\"fac-select\" [(ngModel)]=\"newLevel\">\n @for (opt of levelOptions; track opt.value) {\n <option [value]=\"opt.value\">{{ opt.labelKey | translate }}</option>\n }\n </select>\n </div>\n\n @if (searchResults().length > 0) {\n <div class=\"fac-search-results\">\n @for (result of searchResults(); track result.id) {\n <div class=\"fac-search-item\" (click)=\"onGrantToUser(result)\">\n <i class=\"pi pi-user\" aria-hidden=\"true\"></i>\n <span>{{ result.firstName }} {{ result.lastName }}</span>\n <span class=\"fac-email\">{{ result.email }}</span>\n </div>\n }\n </div>\n }\n\n @if (chartOptions().length > 0) {\n <div class=\"fac-chart-row\">\n <label for=\"fac-org-chart-select\" class=\"fac-section-label\">{{\n 'files.share.org_chart' | translate\n }}</label>\n <select\n id=\"fac-org-chart-select\"\n class=\"fac-select\"\n [ngModel]=\"selectedOrgChartId()\"\n (ngModelChange)=\"onOrgChartSelectChange($event)\">\n @for (opt of chartOptions(); track $index) {\n <option [ngValue]=\"opt.id\">{{ opt.name }}</option>\n }\n </select>\n </div>\n }\n\n @if (showOuPicker()) {\n <div class=\"fac-ou-section\">\n <div class=\"fac-section-label\">{{ 'files.share.share_with_ou' | translate }}</div>\n @for (ou of ouTree(); track ou.id) {\n <div\n class=\"fac-ou-item\"\n [style.padding-inline-start.px]=\"ouIndentStartPx(ou)\"\n (click)=\"onGrantToOu(ou)\"\n >\n <i class=\"pi pi-sitemap\" aria-hidden=\"true\"></i>\n <span>{{ ou.displayName }}</span>\n </div>\n }\n </div>\n }\n\n <div class=\"fac-toggle-row\">\n <button type=\"button\" class=\"fac-link\" (click)=\"showOuPicker.set(!showOuPicker())\">\n {{ showOuPicker() ? ('files.share.hide_ous' | translate) : ('files.share.show_ous' | translate) }}\n </button>\n @if (showApplyToChildren) {\n <label class=\"fac-checkbox-label\">\n <input type=\"checkbox\" [(ngModel)]=\"applyToChildren\" />\n {{ 'files.share.apply_children' | translate }}\n </label>\n }\n </div>\n </div>\n\n <div class=\"fac-perms-section\">\n <div class=\"fac-section-label\">{{ 'files.share.current_permissions' | translate }}</div>\n @if (loading()) {\n <div class=\"fac-loading\"><i class=\"pi pi-spin pi-spinner\" aria-hidden=\"true\"></i></div>\n } @else if (permissions().length === 0) {\n <div class=\"fac-empty\">{{ 'files.share.no_permissions' | translate }}</div>\n } @else {\n @for (perm of permissions(); track perm.id) {\n <div class=\"fac-perm-row\">\n <div class=\"fac-perm-info\">\n <i [class]=\"getPermIcon(perm)\" aria-hidden=\"true\"></i>\n <span class=\"fac-perm-name\">{{ getPermLabel(perm) }}</span>\n @if (perm.isInherited) {\n <span class=\"fac-badge inherited\">{{ 'files.share.inherited' | translate }}</span>\n }\n </div>\n <div class=\"fac-perm-actions\">\n <select\n class=\"fac-select sm\"\n [ngModel]=\"perm.level\"\n (ngModelChange)=\"onUpdateLevel(perm, $event)\"\n [disabled]=\"perm.isInherited === true\"\n >\n @for (opt of levelOptions; track opt.value) {\n <option [value]=\"opt.value\">{{ opt.labelKey | translate }}</option>\n }\n </select>\n <button\n type=\"button\"\n class=\"fac-icon-btn danger\"\n (click)=\"onRevokePermission(perm)\"\n [disabled]=\"perm.isInherited === true\"\n [title]=\"'files.share.revoke' | translate\"\n >\n <i class=\"pi pi-trash\" aria-hidden=\"true\"></i>\n </button>\n </div>\n </div>\n }\n }\n </div>\n </div>\n</div>\n", styles: [".fac-overlay{position:fixed;inset:0;background:#00000080;display:flex;align-items:center;justify-content:center;z-index:2000;-webkit-backdrop-filter:blur(4px);backdrop-filter:blur(4px)}.fac-panel{background:var(--surface-card, #1e1e1e);border:1px solid var(--surface-border);border-radius:12px;width:600px;max-width:min(600px,96vw);max-height:85vh;display:flex;flex-direction:column;overflow:hidden;box-shadow:0 16px 48px #0006}.fac-header{display:flex;align-items:center;gap:10px;padding:16px 20px;border-bottom:1px solid var(--surface-border)}.fac-header h3{margin:0;font-size:16px;font-weight:600}.fac-target-name{flex:1;font-size:13px;color:var(--text-color-secondary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.fac-close{background:none;border:none;color:var(--text-color-secondary);cursor:pointer;font-size:16px;padding:4px}.fac-close:hover{color:var(--text-color)}.fac-add-section{padding:16px 20px;border-bottom:1px solid var(--surface-border)}.fac-search-row{display:flex;gap:8px}.fac-chart-row{display:flex;flex-direction:column;gap:6px;margin-top:12px}.fac-chart-row .fac-select{width:100%}.fac-input{flex:1;background:#ffffff14;border:1px solid rgba(255,255,255,.15);border-radius:8px;padding:8px 12px;color:inherit;font-size:13px;outline:none}.fac-input:focus{border-color:var(--primary-color, #e8732a)}.fac-select{background:#ffffff14;border:1px solid rgba(255,255,255,.15);border-radius:8px;padding:8px 10px;color:inherit;font-size:13px;outline:none}.fac-select.sm{padding:4px 8px;font-size:12px}.fac-search-results{margin-top:8px;max-height:150px;overflow-y:auto;border:1px solid var(--surface-border);border-radius:8px}.fac-search-item{display:flex;align-items:center;gap:8px;padding:8px 12px;cursor:pointer;font-size:13px}.fac-search-item:hover{background:var(--surface-hover)}.fac-search-item i{color:var(--text-color-secondary)}.fac-email{color:var(--text-color-secondary);font-size:12px;margin-inline-start:auto}.fac-ou-section{margin-top:10px;max-height:220px;overflow-y:auto}.fac-ou-item{display:flex;align-items:center;gap:8px;padding:6px 12px;padding-inline-start:12px;cursor:pointer;font-size:13px;border-radius:6px}.fac-ou-item:hover{background:var(--surface-hover)}.fac-ou-item i{color:var(--text-color-secondary)}.fac-section-label{font-size:11px;font-weight:700;text-transform:uppercase;color:var(--text-color-secondary);letter-spacing:.5px;margin-bottom:8px}.fac-toggle-row{display:flex;align-items:center;justify-content:space-between;margin-top:10px}.fac-link{background:none;border:none;color:var(--primary-color, #e8732a);cursor:pointer;font-size:12px;padding:0}.fac-link:hover{text-decoration:underline}.fac-checkbox-label{display:flex;align-items:center;gap:6px;font-size:12px;color:var(--text-color-secondary);cursor:pointer}.fac-checkbox-label input{accent-color:var(--primary-color, #e8732a)}.fac-perms-section{flex:1;overflow-y:auto;padding:16px 20px}.fac-loading,.fac-empty{text-align:center;padding:20px;color:var(--text-color-secondary);font-size:13px}.fac-perm-row{display:flex;align-items:center;justify-content:space-between;padding:8px 0;border-bottom:1px solid rgba(255,255,255,.05);gap:8px}.fac-perm-info{display:flex;align-items:center;gap:8px;flex:1;min-width:0}.fac-perm-info i{color:var(--text-color-secondary);flex-shrink:0}.fac-perm-name{font-size:13px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.fac-badge{font-size:10px;padding:2px 6px;border-radius:8px;flex-shrink:0}.fac-badge.inherited{background:#ffffff1a;color:var(--text-color-secondary)}.fac-perm-actions{display:flex;align-items:center;gap:6px;flex-shrink:0}.fac-icon-btn{background:#ffffff0f;border:none;border-radius:6px;width:28px;height:28px;display:flex;align-items:center;justify-content:center;color:inherit;cursor:pointer;font-size:12px}.fac-icon-btn:hover{background:#ffffff24}.fac-icon-btn.danger:hover{background:#ff3b3033;color:#ff3b30}.fac-icon-btn:disabled{opacity:.4;cursor:default}\n"] }]
|
|
1087
|
+
args: [{ selector: 'fly-share-panel', standalone: true, imports: [CommonModule, FormsModule, TranslatePipe], changeDetection: ChangeDetectionStrategy.OnPush, template: "<div class=\"fac-overlay\" (click)=\"close.emit()\">\r\n <div class=\"fac-panel\" (click)=\"$event.stopPropagation()\">\r\n <div class=\"fac-header\">\r\n <h3>{{ titleKey | translate }}</h3>\r\n <span class=\"fac-target-name\">{{ targetName }}</span>\r\n <button\r\n type=\"button\"\r\n class=\"fac-close\"\r\n (click)=\"close.emit()\"\r\n [attr.aria-label]=\"'files.share.close' | translate\">\r\n <i class=\"pi pi-times\" aria-hidden=\"true\"></i>\r\n </button>\r\n </div>\r\n\r\n <div class=\"fac-add-section\">\r\n <div class=\"fac-search-row\">\r\n <input\r\n type=\"text\"\r\n class=\"fac-input\"\r\n [(ngModel)]=\"searchQuery\"\r\n (input)=\"onSearchInput()\"\r\n [placeholder]=\"'files.share.search_placeholder' | translate\"\r\n [attr.aria-label]=\"'files.share.search_users' | translate\"\r\n />\r\n <select class=\"fac-select\" [(ngModel)]=\"newLevel\">\r\n @for (opt of levelOptions; track opt.value) {\r\n <option [value]=\"opt.value\">{{ opt.labelKey | translate }}</option>\r\n }\r\n </select>\r\n </div>\r\n\r\n @if (searchResults().length > 0) {\r\n <div class=\"fac-search-results\">\r\n @for (result of searchResults(); track result.id) {\r\n <div class=\"fac-search-item\" (click)=\"onGrantToUser(result)\">\r\n <i class=\"pi pi-user\" aria-hidden=\"true\"></i>\r\n <span>{{ result.firstName }} {{ result.lastName }}</span>\r\n <span class=\"fac-email\">{{ result.email }}</span>\r\n </div>\r\n }\r\n </div>\r\n }\r\n\r\n @if (chartOptions().length > 0) {\r\n <div class=\"fac-chart-row\">\r\n <label for=\"fac-org-chart-select\" class=\"fac-section-label\">{{\r\n 'files.share.org_chart' | translate\r\n }}</label>\r\n <select\r\n id=\"fac-org-chart-select\"\r\n class=\"fac-select\"\r\n [ngModel]=\"selectedOrgChartId()\"\r\n (ngModelChange)=\"onOrgChartSelectChange($event)\">\r\n @for (opt of chartOptions(); track $index) {\r\n <option [ngValue]=\"opt.id\">{{ opt.name }}</option>\r\n }\r\n </select>\r\n </div>\r\n }\r\n\r\n @if (showOuPicker()) {\r\n <div class=\"fac-ou-section\">\r\n <div class=\"fac-section-label\">{{ 'files.share.share_with_ou' | translate }}</div>\r\n @for (ou of ouTree(); track ou.id) {\r\n <button\r\n type=\"button\"\r\n class=\"fac-ou-item\"\r\n [style.padding-inline-start.px]=\"ouIndentStartPx(ou)\"\r\n (click)=\"onGrantToOu(ou)\"\r\n [attr.aria-label]=\"('files.share.share_with_ou_named' | translate) + ' ' + ou.displayName\"\r\n >\r\n <i class=\"pi pi-sitemap\" aria-hidden=\"true\"></i>\r\n <span dir=\"auto\">{{ ou.displayName }}</span>\r\n </button>\r\n }\r\n </div>\r\n }\r\n\r\n @if (loadRoleOus && roleOus().length > 0) {\r\n <div class=\"fac-role-section\">\r\n <div class=\"fac-section-label\">{{ 'files.share.share_with_role' | translate }}</div>\r\n <input\r\n type=\"text\"\r\n class=\"fac-input\"\r\n [(ngModel)]=\"roleSearchQuery\"\r\n [placeholder]=\"'files.share.role_search_placeholder' | translate\"\r\n [attr.aria-label]=\"'files.share.search_roles' | translate\"\r\n [hidden]=\"!showRolePicker()\" />\r\n @if (showRolePicker()) {\r\n <div class=\"fac-role-list\">\r\n @for (role of filteredRoles(); track role.ouId) {\r\n <button\r\n type=\"button\"\r\n class=\"fac-role-item\"\r\n (click)=\"onGrantToRole(role)\"\r\n [attr.aria-label]=\"('files.share.share_with_role_named' | translate) + ' ' + role.displayName\">\r\n <i class=\"pi pi-id-card\" aria-hidden=\"true\"></i>\r\n <span dir=\"auto\">{{ role.displayName }}</span>\r\n <span class=\"fac-role-app\">{{ role.appId }}</span>\r\n </button>\r\n }\r\n @if (filteredRoles().length === 0) {\r\n <div class=\"fac-empty\">{{ 'files.share.no_roles_match' | translate }}</div>\r\n }\r\n </div>\r\n }\r\n </div>\r\n }\r\n\r\n <div class=\"fac-toggle-row\">\r\n <button type=\"button\" class=\"fac-link\" (click)=\"showOuPicker.set(!showOuPicker())\">\r\n {{ showOuPicker() ? ('files.share.hide_ous' | translate) : ('files.share.show_ous' | translate) }}\r\n </button>\r\n @if (loadRoleOus && roleOus().length > 0) {\r\n <button type=\"button\" class=\"fac-link\" (click)=\"showRolePicker.set(!showRolePicker())\">\r\n {{ showRolePicker() ? ('files.share.hide_roles' | translate) : ('files.share.show_roles' | translate) }}\r\n </button>\r\n }\r\n @if (showApplyToChildren) {\r\n <label class=\"fac-checkbox-label\">\r\n <input type=\"checkbox\" [(ngModel)]=\"applyToChildren\" />\r\n {{ 'files.share.apply_children' | translate }}\r\n </label>\r\n }\r\n @if (supportsDeny) {\r\n <label class=\"fac-checkbox-label fac-deny-toggle\"\r\n [title]=\"'files.share.deny_help' | translate\">\r\n <input type=\"checkbox\" [(ngModel)]=\"addAsDeny\" />\r\n <i class=\"pi pi-ban\" aria-hidden=\"true\"></i>\r\n {{ 'files.share.add_as_deny' | translate }}\r\n </label>\r\n }\r\n </div>\r\n </div>\r\n\r\n <div class=\"fac-perms-section\">\r\n <div class=\"fac-section-label\">{{ 'files.share.current_permissions' | translate }}</div>\r\n @if (loading()) {\r\n <div class=\"fac-loading\"><i class=\"pi pi-spin pi-spinner\" aria-hidden=\"true\"></i></div>\r\n } @else if (permissions().length === 0) {\r\n <div class=\"fac-empty\">{{ 'files.share.no_permissions' | translate }}</div>\r\n } @else {\r\n @for (perm of permissions(); track perm.id) {\r\n <div class=\"fac-perm-row\" [class.fac-perm-row-deny]=\"perm.isDeny\">\r\n <div class=\"fac-perm-info\">\r\n <i [class]=\"getPermIcon(perm)\" aria-hidden=\"true\"></i>\r\n <span class=\"fac-perm-name\" dir=\"auto\">{{ getPermLabel(perm) }}</span>\r\n @if (perm.isDeny) {\r\n <span class=\"fac-badge deny\">{{ 'files.share.denied' | translate }}</span>\r\n }\r\n @if (perm.isInherited) {\r\n <span class=\"fac-badge inherited\">{{ 'files.share.inherited' | translate }}</span>\r\n }\r\n </div>\r\n <div class=\"fac-perm-actions\">\r\n @if (!perm.isDeny) {\r\n <select\r\n class=\"fac-select sm\"\r\n [ngModel]=\"perm.level\"\r\n (ngModelChange)=\"onUpdateLevel(perm, $event)\"\r\n [disabled]=\"perm.isInherited === true\"\r\n >\r\n @for (opt of levelOptions; track opt.value) {\r\n <option [value]=\"opt.value\">{{ opt.labelKey | translate }}</option>\r\n }\r\n </select>\r\n }\r\n <button\r\n type=\"button\"\r\n class=\"fac-icon-btn danger\"\r\n (click)=\"onRevokePermission(perm)\"\r\n [disabled]=\"perm.isInherited === true\"\r\n [title]=\"'files.share.revoke' | translate\"\r\n >\r\n <i class=\"pi pi-trash\" aria-hidden=\"true\"></i>\r\n </button>\r\n </div>\r\n </div>\r\n }\r\n }\r\n </div>\r\n </div>\r\n</div>\r\n", styles: [".fac-overlay{position:fixed;inset:0;background:#00000080;display:flex;align-items:center;justify-content:center;z-index:2000;-webkit-backdrop-filter:blur(4px);backdrop-filter:blur(4px)}.fac-panel{background:var(--surface-card, #1e1e1e);border:1px solid var(--surface-border);border-radius:12px;width:600px;max-width:min(600px,96vw);max-height:85vh;display:flex;flex-direction:column;overflow:hidden;box-shadow:0 16px 48px #0006}.fac-header{display:flex;align-items:center;gap:10px;padding:16px 20px;border-bottom:1px solid var(--surface-border)}.fac-header h3{margin:0;font-size:16px;font-weight:600}.fac-target-name{flex:1;font-size:13px;color:var(--text-color-secondary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.fac-close{background:none;border:none;color:var(--text-color-secondary);cursor:pointer;font-size:16px;padding:4px}.fac-close:hover{color:var(--text-color)}.fac-add-section{padding:16px 20px;border-bottom:1px solid var(--surface-border)}.fac-search-row{display:flex;gap:8px}.fac-chart-row{display:flex;flex-direction:column;gap:6px;margin-top:12px}.fac-chart-row .fac-select{width:100%}.fac-input{flex:1;background:#ffffff14;border:1px solid rgba(255,255,255,.15);border-radius:8px;padding:8px 12px;color:inherit;font-size:13px;outline:none}.fac-input:focus{border-color:var(--primary-color, #e8732a)}.fac-select{background:#ffffff14;border:1px solid rgba(255,255,255,.15);border-radius:8px;padding:8px 10px;color:inherit;font-size:13px;outline:none}.fac-select.sm{padding:4px 8px;font-size:12px}.fac-search-results{margin-top:8px;max-height:150px;overflow-y:auto;border:1px solid var(--surface-border);border-radius:8px}.fac-search-item{display:flex;align-items:center;gap:8px;padding:8px 12px;cursor:pointer;font-size:13px}.fac-search-item:hover{background:var(--surface-hover)}.fac-search-item i{color:var(--text-color-secondary)}.fac-email{color:var(--text-color-secondary);font-size:12px;margin-inline-start:auto}.fac-ou-section{margin-top:10px;max-height:220px;overflow-y:auto}.fac-ou-item{display:flex;align-items:center;gap:8px;padding:6px 12px;padding-inline-start:12px;cursor:pointer;font-size:13px;border-radius:6px}.fac-ou-item:hover{background:var(--surface-hover)}.fac-ou-item i{color:var(--text-color-secondary)}.fac-section-label{font-size:11px;font-weight:700;text-transform:uppercase;color:var(--text-color-secondary);letter-spacing:.5px;margin-bottom:8px}.fac-toggle-row{display:flex;align-items:center;justify-content:space-between;margin-top:10px}.fac-link{background:none;border:none;color:var(--primary-color, #e8732a);cursor:pointer;font-size:12px;padding:0}.fac-link:hover{text-decoration:underline}.fac-checkbox-label{display:flex;align-items:center;gap:6px;font-size:12px;color:var(--text-color-secondary);cursor:pointer}.fac-checkbox-label input{accent-color:var(--primary-color, #e8732a)}.fac-perms-section{flex:1;overflow-y:auto;padding:16px 20px}.fac-loading,.fac-empty{text-align:center;padding:20px;color:var(--text-color-secondary);font-size:13px}.fac-perm-row{display:flex;align-items:center;justify-content:space-between;padding:8px 0;border-bottom:1px solid rgba(255,255,255,.05);gap:8px}.fac-perm-info{display:flex;align-items:center;gap:8px;flex:1;min-width:0}.fac-perm-info i{color:var(--text-color-secondary);flex-shrink:0}.fac-perm-name{font-size:13px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.fac-badge{font-size:10px;padding:2px 6px;border-radius:8px;flex-shrink:0}.fac-badge.inherited{background:#ffffff1a;color:var(--text-color-secondary)}.fac-badge.deny{background:#ff3b302e;color:#ff3b30;text-transform:uppercase;font-weight:600;letter-spacing:.5px}.fac-perm-row-deny{background:#ff3b300f;border-inline-start:3px solid #ff3b30;padding-inline-start:8px;border-radius:6px}.fac-perm-row-deny .fac-perm-info i{color:#ff3b30}.fac-deny-toggle{color:#ff6b6b}.fac-deny-toggle i{color:#ff6b6b;font-size:11px;margin-inline-end:2px}.fac-deny-toggle input{accent-color:#ff3b30}.fac-role-section{margin-top:10px;display:flex;flex-direction:column;gap:6px}.fac-role-list{margin-top:6px;max-height:220px;overflow-y:auto;border:1px solid var(--surface-border);border-radius:8px}.fac-role-item{display:flex;align-items:center;gap:8px;padding:8px 12px;cursor:pointer;font-size:13px}.fac-role-item:hover{background:var(--surface-hover)}.fac-role-item i{color:var(--text-color-secondary)}.fac-role-app{margin-inline-start:auto;font-size:11px;color:var(--text-color-secondary);text-transform:uppercase;letter-spacing:.5px}.fac-perm-actions{display:flex;align-items:center;gap:6px;flex-shrink:0}.fac-icon-btn{background:#ffffff0f;border:none;border-radius:6px;width:28px;height:28px;display:flex;align-items:center;justify-content:center;color:inherit;cursor:pointer;font-size:12px}.fac-icon-btn:hover{background:#ffffff24}.fac-icon-btn.danger:hover{background:#ff3b3033;color:#ff3b30}.fac-icon-btn:disabled{opacity:.4;cursor:default}\n"] }]
|
|
928
1088
|
}], propDecorators: { targetName: [{
|
|
929
1089
|
type: Input
|
|
930
1090
|
}], titleKey: [{
|
|
@@ -958,14 +1118,618 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.5", ngImpor
|
|
|
958
1118
|
args: [{ required: true }]
|
|
959
1119
|
}], showApplyToChildren: [{
|
|
960
1120
|
type: Input
|
|
1121
|
+
}], supportsDeny: [{
|
|
1122
|
+
type: Input
|
|
961
1123
|
}], permissionLevels: [{
|
|
962
1124
|
type: Input
|
|
963
1125
|
}], everyoneLabelKey: [{
|
|
964
1126
|
type: Input
|
|
1127
|
+
}], loadRoleOus: [{
|
|
1128
|
+
type: Input
|
|
965
1129
|
}], close: [{
|
|
966
1130
|
type: Output
|
|
967
1131
|
}] } });
|
|
968
1132
|
|
|
1133
|
+
/**
|
|
1134
|
+
* Generic builder for {@link AudienceFilter}. Accepts an initial value, emits both the
|
|
1135
|
+
* full filter and a validity flag on every change. Reuses the same callback shapes as
|
|
1136
|
+
* {@link SharePanelComponent} so a host already wiring the share panel can drop this in
|
|
1137
|
+
* with no extra plumbing.
|
|
1138
|
+
*
|
|
1139
|
+
* Compaction policy: when the operator picks the same kind multiple times, the component
|
|
1140
|
+
* merges into a single term where it's safe to do so:
|
|
1141
|
+
* <list type="bullet">
|
|
1142
|
+
* <item><b>users</b>: dedupes user ids into one {@link UsersTerm}.</item>
|
|
1143
|
+
* <item><b>roles</b>: per `appId`, dedupes role keys into one {@link RolesTerm}.</item>
|
|
1144
|
+
* <item><b>ou</b>: per `(chartId, includeDescendants)`, dedupes OU ids into one {@link OuTerm}.</item>
|
|
1145
|
+
* <item><b>app-everyone / chart / preset</b>: idempotent per kind+key.</item>
|
|
1146
|
+
* </list>
|
|
1147
|
+
* This keeps the term count well below {@link AUDIENCE_LIMITS.maxTermsPerFilter} for typical
|
|
1148
|
+
* use, and matches how the backend resolver would have unioned them anyway.
|
|
1149
|
+
*/
|
|
1150
|
+
class AudienceBuilderComponent {
|
|
1151
|
+
/** Initial audience filter. Two-way: changes emit via {@link audienceChange}. */
|
|
1152
|
+
value = { includes: [] };
|
|
1153
|
+
/** Hide the Excludes section entirely (e.g. for simple "send to" pickers). */
|
|
1154
|
+
showExcludes = true;
|
|
1155
|
+
/**
|
|
1156
|
+
* Which term kinds are selectable. Defaults to all six. Hosts that don't have a
|
|
1157
|
+
* concept of `chart` or `preset` should narrow this down to keep the UI focused.
|
|
1158
|
+
*/
|
|
1159
|
+
supportedKinds = [
|
|
1160
|
+
'users',
|
|
1161
|
+
'roles',
|
|
1162
|
+
'ou',
|
|
1163
|
+
'app-everyone',
|
|
1164
|
+
'chart',
|
|
1165
|
+
'preset',
|
|
1166
|
+
];
|
|
1167
|
+
/**
|
|
1168
|
+
* Anchor user id used when adding a `preset` term. Typically `auth.currentUser().id`.
|
|
1169
|
+
* If null and the operator picks a preset, the term is rejected with an inline error.
|
|
1170
|
+
*/
|
|
1171
|
+
selfUserId = null;
|
|
1172
|
+
/** App ids the host wants to expose as `app-everyone` options. */
|
|
1173
|
+
appEveryoneOptions = [];
|
|
1174
|
+
// ── Picker callbacks (same shapes as SharePanelComponent) ────────────────────
|
|
1175
|
+
searchUsers;
|
|
1176
|
+
loadOuTree;
|
|
1177
|
+
loadChartOptions;
|
|
1178
|
+
/**
|
|
1179
|
+
* Optional apps-chart role-OU index. When supplied, the picker exposes a "Roles" tab
|
|
1180
|
+
* surfacing role chips; without it, that tab is hidden even if `'roles'` is in
|
|
1181
|
+
* {@link supportedKinds}.
|
|
1182
|
+
*/
|
|
1183
|
+
loadRoleOus;
|
|
1184
|
+
// ── Outputs ──────────────────────────────────────────────────────────────────
|
|
1185
|
+
/** Fires on every successful add/remove with the current filter. */
|
|
1186
|
+
audienceChange = new EventEmitter();
|
|
1187
|
+
/**
|
|
1188
|
+
* True when {@link AudienceFilter.includes} is non-empty and the term count is within
|
|
1189
|
+
* {@link AUDIENCE_LIMITS.maxTermsPerFilter}. Hosts should disable submit when false.
|
|
1190
|
+
*/
|
|
1191
|
+
validityChange = new EventEmitter();
|
|
1192
|
+
// ── Internal state (signals; computed where derived) ─────────────────────────
|
|
1193
|
+
destroyRef = inject(DestroyRef);
|
|
1194
|
+
i18n = inject(I18nService);
|
|
1195
|
+
includes = signal([], ...(ngDevMode ? [{ debugName: "includes" }] : /* istanbul ignore next */ []));
|
|
1196
|
+
excludes = signal([], ...(ngDevMode ? [{ debugName: "excludes" }] : /* istanbul ignore next */ []));
|
|
1197
|
+
editTarget = signal('includes', ...(ngDevMode ? [{ debugName: "editTarget" }] : /* istanbul ignore next */ []));
|
|
1198
|
+
pickerOpen = signal(false, ...(ngDevMode ? [{ debugName: "pickerOpen" }] : /* istanbul ignore next */ []));
|
|
1199
|
+
activeKind = signal('users', ...(ngDevMode ? [{ debugName: "activeKind" }] : /* istanbul ignore next */ []));
|
|
1200
|
+
// User picker — signal-backed so OnPush + computed re-evaluates correctly. Keystrokes go
|
|
1201
|
+
// through a debounced Subject + switchMap so a slow "fo" result can never overwrite a
|
|
1202
|
+
// fresh "foobar" result (the previous setTimeout-based path had this race).
|
|
1203
|
+
userSearchQuery = signal('', ...(ngDevMode ? [{ debugName: "userSearchQuery" }] : /* istanbul ignore next */ []));
|
|
1204
|
+
userSearchResults = signal([], ...(ngDevMode ? [{ debugName: "userSearchResults" }] : /* istanbul ignore next */ []));
|
|
1205
|
+
userSearch$ = new Subject();
|
|
1206
|
+
// Role picker.
|
|
1207
|
+
roleOus = signal([], ...(ngDevMode ? [{ debugName: "roleOus" }] : /* istanbul ignore next */ []));
|
|
1208
|
+
roleSearchQuery = signal('', ...(ngDevMode ? [{ debugName: "roleSearchQuery" }] : /* istanbul ignore next */ []));
|
|
1209
|
+
/**
|
|
1210
|
+
* Filtered role list as a `computed` so the template doesn't re-call a method on every
|
|
1211
|
+
* change-detection cycle.
|
|
1212
|
+
*/
|
|
1213
|
+
filteredRoles = computed(() => {
|
|
1214
|
+
const q = this.roleSearchQuery().trim().toLowerCase();
|
|
1215
|
+
const all = this.roleOus();
|
|
1216
|
+
if (!q)
|
|
1217
|
+
return all;
|
|
1218
|
+
return all.filter((r) => r.displayName.toLowerCase().includes(q)
|
|
1219
|
+
|| r.roleKey.toLowerCase().includes(q)
|
|
1220
|
+
|| r.appId.toLowerCase().includes(q));
|
|
1221
|
+
}, ...(ngDevMode ? [{ debugName: "filteredRoles" }] : /* istanbul ignore next */ []));
|
|
1222
|
+
// OU picker.
|
|
1223
|
+
ouTree = signal([], ...(ngDevMode ? [{ debugName: "ouTree" }] : /* istanbul ignore next */ []));
|
|
1224
|
+
chartOptions = signal([], ...(ngDevMode ? [{ debugName: "chartOptions" }] : /* istanbul ignore next */ []));
|
|
1225
|
+
selectedChartId = signal(null, ...(ngDevMode ? [{ debugName: "selectedChartId" }] : /* istanbul ignore next */ []));
|
|
1226
|
+
ouIncludeDescendants = signal(false, ...(ngDevMode ? [{ debugName: "ouIncludeDescendants" }] : /* istanbul ignore next */ []));
|
|
1227
|
+
/**
|
|
1228
|
+
* Chart-term picker entries — filters out the null-id default chart since `ChartTerm`
|
|
1229
|
+
* requires a real Guid. Avoids the UX trap where clicking "Default Company" surfaces
|
|
1230
|
+
* an inline error.
|
|
1231
|
+
*/
|
|
1232
|
+
chartTermOptions = computed(() => this.chartOptions().filter((c) => c.id !== null), ...(ngDevMode ? [{ debugName: "chartTermOptions" }] : /* istanbul ignore next */ []));
|
|
1233
|
+
selectedChartId$ = new ReplaySubject(1);
|
|
1234
|
+
// Preset options exposed from the model so the template stays in sync with new presets.
|
|
1235
|
+
presetOptions = AUDIENCE_PRESETS;
|
|
1236
|
+
// Validation surface (inline errors).
|
|
1237
|
+
errorKey = signal(null, ...(ngDevMode ? [{ debugName: "errorKey" }] : /* istanbul ignore next */ []));
|
|
1238
|
+
/** True when the host disabled this control (template-driven `[disabled]` or reactive). */
|
|
1239
|
+
disabled = signal(false, ...(ngDevMode ? [{ debugName: "disabled" }] : /* istanbul ignore next */ []));
|
|
1240
|
+
// ── ControlValueAccessor + Validator wiring ──────────────────────────────────
|
|
1241
|
+
// These let hosts bind the component via `formControl` / `formControlName` /
|
|
1242
|
+
// `[(ngModel)]` instead of plumbing `[value]` + `(audienceChange)` manually. The
|
|
1243
|
+
// (audienceChange) / (validityChange) outputs still fire — Angular forms infrastructure
|
|
1244
|
+
// is purely additive and template-driven hosts continue to work unchanged.
|
|
1245
|
+
onChange = () => undefined;
|
|
1246
|
+
onTouched = () => undefined;
|
|
1247
|
+
onValidatorChange = () => undefined;
|
|
1248
|
+
// ── Lifecycle ────────────────────────────────────────────────────────────────
|
|
1249
|
+
ngOnInit() {
|
|
1250
|
+
// applyValue() seeds includes/excludes — defer the initial emit so we don't trigger
|
|
1251
|
+
// ExpressionChangedAfterItHasBeenChecked when a parent template binding observes
|
|
1252
|
+
// (audienceChange) / (validityChange) in the same change-detection cycle.
|
|
1253
|
+
this.applyValue(this.value, /* emitImmediate */ false);
|
|
1254
|
+
queueMicrotask(() => this.emitState());
|
|
1255
|
+
if (this.loadRoleOus) {
|
|
1256
|
+
this.loadRoleOus()
|
|
1257
|
+
.pipe(takeUntilDestroyed(this.destroyRef))
|
|
1258
|
+
.subscribe({
|
|
1259
|
+
next: (rows) => this.roleOus.set(rows),
|
|
1260
|
+
error: () => this.roleOus.set([]),
|
|
1261
|
+
});
|
|
1262
|
+
}
|
|
1263
|
+
this.selectedChartId$
|
|
1264
|
+
.pipe(switchMap((id) => this.loadOuTree(id)), takeUntilDestroyed(this.destroyRef))
|
|
1265
|
+
.subscribe({
|
|
1266
|
+
next: (tree) => this.ouTree.set(tree),
|
|
1267
|
+
error: () => this.ouTree.set([]),
|
|
1268
|
+
});
|
|
1269
|
+
// Debounced user search: 300ms idle → switchMap cancels any in-flight request when a
|
|
1270
|
+
// newer query arrives, killing the "stale result overwrites fresh result" race the old
|
|
1271
|
+
// setTimeout path had. `filter(q.length >= 2)` preserves the same UX gate.
|
|
1272
|
+
this.userSearch$
|
|
1273
|
+
.pipe(debounceTime(300), distinctUntilChanged(), filter((q) => q.length >= 2), switchMap((q) => this.searchUsers(q)), takeUntilDestroyed(this.destroyRef))
|
|
1274
|
+
.subscribe({
|
|
1275
|
+
next: (items) => this.userSearchResults.set(items),
|
|
1276
|
+
error: () => this.userSearchResults.set([]),
|
|
1277
|
+
});
|
|
1278
|
+
this.loadChartOptions()
|
|
1279
|
+
.pipe(takeUntilDestroyed(this.destroyRef))
|
|
1280
|
+
.subscribe({
|
|
1281
|
+
next: (opts) => {
|
|
1282
|
+
this.chartOptions.set(opts);
|
|
1283
|
+
const initial = opts[0]?.id ?? null;
|
|
1284
|
+
this.selectedChartId.set(initial);
|
|
1285
|
+
this.selectedChartId$.next(initial);
|
|
1286
|
+
},
|
|
1287
|
+
error: () => {
|
|
1288
|
+
this.chartOptions.set([]);
|
|
1289
|
+
this.selectedChartId.set(null);
|
|
1290
|
+
this.selectedChartId$.next(null);
|
|
1291
|
+
},
|
|
1292
|
+
});
|
|
1293
|
+
if (this.supportedKinds.length > 0 && !this.supportedKinds.includes(this.activeKind())) {
|
|
1294
|
+
this.activeKind.set(this.supportedKinds[0]);
|
|
1295
|
+
}
|
|
1296
|
+
}
|
|
1297
|
+
ngOnChanges(changes) {
|
|
1298
|
+
// Re-hydrate when the host swaps the filter wholesale (e.g. editing a different rule).
|
|
1299
|
+
// First-mount initialisation is handled by ngOnInit so we skip the initial assignment
|
|
1300
|
+
// here. Picker UI state is also reset so a stale search query / open picker from the
|
|
1301
|
+
// previous draft doesn't bleed into the new one.
|
|
1302
|
+
//
|
|
1303
|
+
// We do NOT emit (audienceChange) / onChange — the host already KNOWS the value it
|
|
1304
|
+
// pushed. Echoing back via the change callback can create a feedback loop with two-way
|
|
1305
|
+
// bindings and reactive forms. Validity, however, may genuinely change with the new
|
|
1306
|
+
// value — emit that signal explicitly via the form-validator hook + (validityChange).
|
|
1307
|
+
if (changes['value'] && !changes['value'].firstChange) {
|
|
1308
|
+
this.applyValue(this.value, /* emitImmediate */ false);
|
|
1309
|
+
this.closePicker();
|
|
1310
|
+
this.errorKey.set(null);
|
|
1311
|
+
this.emitValidityOnly();
|
|
1312
|
+
}
|
|
1313
|
+
}
|
|
1314
|
+
ngOnDestroy() {
|
|
1315
|
+
this.userSearch$.complete();
|
|
1316
|
+
}
|
|
1317
|
+
/**
|
|
1318
|
+
* Replaces the in-component state from an external value. Null-safe — if the host
|
|
1319
|
+
* passes <c>null</c> or <c>undefined</c> (e.g. while a parent signal is loading),
|
|
1320
|
+
* we treat it as an empty filter rather than crashing.
|
|
1321
|
+
*/
|
|
1322
|
+
applyValue(v, emitImmediate) {
|
|
1323
|
+
this.includes.set([...(v?.includes ?? [])]);
|
|
1324
|
+
this.excludes.set([...(v?.excludes ?? [])]);
|
|
1325
|
+
if (emitImmediate)
|
|
1326
|
+
this.emitState();
|
|
1327
|
+
}
|
|
1328
|
+
// ── Picker open/close + tabs ─────────────────────────────────────────────────
|
|
1329
|
+
openPicker(target) {
|
|
1330
|
+
if (this.disabled())
|
|
1331
|
+
return;
|
|
1332
|
+
this.editTarget.set(target);
|
|
1333
|
+
this.pickerOpen.set(true);
|
|
1334
|
+
this.errorKey.set(null);
|
|
1335
|
+
}
|
|
1336
|
+
closePicker() {
|
|
1337
|
+
this.pickerOpen.set(false);
|
|
1338
|
+
this.userSearchQuery.set('');
|
|
1339
|
+
this.userSearchResults.set([]);
|
|
1340
|
+
this.roleSearchQuery.set('');
|
|
1341
|
+
}
|
|
1342
|
+
selectKind(kind) {
|
|
1343
|
+
this.activeKind.set(kind);
|
|
1344
|
+
this.errorKey.set(null);
|
|
1345
|
+
}
|
|
1346
|
+
// ── User picker ──────────────────────────────────────────────────────────────
|
|
1347
|
+
onUserSearchInput() {
|
|
1348
|
+
const q = this.userSearchQuery().trim();
|
|
1349
|
+
if (q.length < 2) {
|
|
1350
|
+
this.userSearchResults.set([]);
|
|
1351
|
+
// Push the short query into the stream so distinctUntilChanged resets when the user
|
|
1352
|
+
// shortens and re-lengthens the query (otherwise "foo" → "f" → "foo" is a no-op).
|
|
1353
|
+
this.userSearch$.next(q);
|
|
1354
|
+
return;
|
|
1355
|
+
}
|
|
1356
|
+
this.userSearch$.next(q);
|
|
1357
|
+
}
|
|
1358
|
+
pickUser(user) {
|
|
1359
|
+
this.addToActiveBucket((existing) => this.upsertUsersTerm(existing, user.id));
|
|
1360
|
+
this.userSearchQuery.set('');
|
|
1361
|
+
this.userSearchResults.set([]);
|
|
1362
|
+
}
|
|
1363
|
+
// ── Role picker ──────────────────────────────────────────────────────────────
|
|
1364
|
+
pickRole(role) {
|
|
1365
|
+
this.addToActiveBucket((existing) => this.upsertRolesTerm(existing, role.appId, role.roleKey));
|
|
1366
|
+
this.roleSearchQuery.set('');
|
|
1367
|
+
}
|
|
1368
|
+
// ── OU picker ────────────────────────────────────────────────────────────────
|
|
1369
|
+
onChartChange(chartId) {
|
|
1370
|
+
this.selectedChartId.set(chartId);
|
|
1371
|
+
this.selectedChartId$.next(chartId);
|
|
1372
|
+
}
|
|
1373
|
+
pickOu(ou) {
|
|
1374
|
+
const chartId = this.selectedChartId();
|
|
1375
|
+
const includeDescendants = this.ouIncludeDescendants();
|
|
1376
|
+
this.addToActiveBucket((existing) => this.upsertOuTerm(existing, ou.id, chartId, includeDescendants));
|
|
1377
|
+
}
|
|
1378
|
+
ouIndentStartPx(ou) {
|
|
1379
|
+
const d = ou.depth ?? 0;
|
|
1380
|
+
return 12 + d * 18;
|
|
1381
|
+
}
|
|
1382
|
+
// ── App-everyone picker ──────────────────────────────────────────────────────
|
|
1383
|
+
pickAppEveryone(appId) {
|
|
1384
|
+
this.addToActiveBucket((existing) => this.upsertAppEveryoneTerm(existing, appId));
|
|
1385
|
+
}
|
|
1386
|
+
// ── Chart picker ─────────────────────────────────────────────────────────────
|
|
1387
|
+
pickChart(chartId) {
|
|
1388
|
+
if (!chartId) {
|
|
1389
|
+
this.errorKey.set('audience.error.chart_id_required');
|
|
1390
|
+
return;
|
|
1391
|
+
}
|
|
1392
|
+
this.addToActiveBucket((existing) => this.upsertChartTerm(existing, chartId));
|
|
1393
|
+
}
|
|
1394
|
+
// ── Preset picker ────────────────────────────────────────────────────────────
|
|
1395
|
+
pickPreset(preset) {
|
|
1396
|
+
if (!this.selfUserId) {
|
|
1397
|
+
this.errorKey.set('audience.error.self_user_required');
|
|
1398
|
+
return;
|
|
1399
|
+
}
|
|
1400
|
+
const anchor = this.selfUserId;
|
|
1401
|
+
this.addToActiveBucket((existing) => this.upsertPresetTerm(existing, preset, anchor));
|
|
1402
|
+
}
|
|
1403
|
+
// ── Term removal ─────────────────────────────────────────────────────────────
|
|
1404
|
+
removeTerm(target, index) {
|
|
1405
|
+
if (this.disabled())
|
|
1406
|
+
return;
|
|
1407
|
+
const list = target === 'includes' ? this.includes() : this.excludes();
|
|
1408
|
+
const next = list.filter((_, i) => i !== index);
|
|
1409
|
+
if (target === 'includes')
|
|
1410
|
+
this.includes.set(next);
|
|
1411
|
+
else
|
|
1412
|
+
this.excludes.set(next);
|
|
1413
|
+
this.emitState();
|
|
1414
|
+
}
|
|
1415
|
+
removeUserId(target, termIndex, userId) {
|
|
1416
|
+
this.editTermAt(target, termIndex, (term) => {
|
|
1417
|
+
if (term.kind !== 'users')
|
|
1418
|
+
return term;
|
|
1419
|
+
const next = term.userIds.filter((id) => id !== userId);
|
|
1420
|
+
return next.length === 0 ? null : { ...term, userIds: next };
|
|
1421
|
+
});
|
|
1422
|
+
}
|
|
1423
|
+
removeRoleKey(target, termIndex, roleKey) {
|
|
1424
|
+
this.editTermAt(target, termIndex, (term) => {
|
|
1425
|
+
if (term.kind !== 'roles')
|
|
1426
|
+
return term;
|
|
1427
|
+
const next = term.roleKeys.filter((k) => k !== roleKey);
|
|
1428
|
+
return next.length === 0 ? null : { ...term, roleKeys: next };
|
|
1429
|
+
});
|
|
1430
|
+
}
|
|
1431
|
+
removeOuId(target, termIndex, ouId) {
|
|
1432
|
+
this.editTermAt(target, termIndex, (term) => {
|
|
1433
|
+
if (term.kind !== 'ou')
|
|
1434
|
+
return term;
|
|
1435
|
+
const next = term.ouIds.filter((id) => id !== ouId);
|
|
1436
|
+
return next.length === 0 ? null : { ...term, ouIds: next };
|
|
1437
|
+
});
|
|
1438
|
+
}
|
|
1439
|
+
// ── Public read API ──────────────────────────────────────────────────────────
|
|
1440
|
+
/** Returns the current filter snapshot — useful for hosts that want to peek without subscribing. */
|
|
1441
|
+
snapshot() {
|
|
1442
|
+
return this.buildFilter();
|
|
1443
|
+
}
|
|
1444
|
+
// ── ControlValueAccessor implementation ──────────────────────────────────────
|
|
1445
|
+
/**
|
|
1446
|
+
* Called by the forms infrastructure to push a new value into the control. Mirrors the
|
|
1447
|
+
* `[value]` setter path — re-uses `applyValue` so template-driven and reactive callers
|
|
1448
|
+
* converge on the same hydration code.
|
|
1449
|
+
*/
|
|
1450
|
+
writeValue(value) {
|
|
1451
|
+
this.applyValue(value, /* emitImmediate */ false);
|
|
1452
|
+
}
|
|
1453
|
+
registerOnChange(fn) {
|
|
1454
|
+
this.onChange = fn;
|
|
1455
|
+
}
|
|
1456
|
+
registerOnTouched(fn) {
|
|
1457
|
+
this.onTouched = fn;
|
|
1458
|
+
}
|
|
1459
|
+
setDisabledState(isDisabled) {
|
|
1460
|
+
this.disabled.set(isDisabled);
|
|
1461
|
+
if (isDisabled)
|
|
1462
|
+
this.closePicker();
|
|
1463
|
+
}
|
|
1464
|
+
// ── Validator implementation ─────────────────────────────────────────────────
|
|
1465
|
+
/**
|
|
1466
|
+
* Aligns Angular's reactive-forms validity with the component's own `validityChange`
|
|
1467
|
+
* output. Returns `audienceRequired` when includes is empty (the canonical "zero
|
|
1468
|
+
* recipients" guard) and `audienceTooManyTerms` when over the per-filter cap.
|
|
1469
|
+
* <br>
|
|
1470
|
+
* The output-event path (<see cref="emitState"/>) reuses this method so there is ONE
|
|
1471
|
+
* definition of "valid" — the classic "two almost-identical validators drifted" bug
|
|
1472
|
+
* cannot recur here.
|
|
1473
|
+
*/
|
|
1474
|
+
validate(_) {
|
|
1475
|
+
return this.validateFilter(this.buildFilter());
|
|
1476
|
+
}
|
|
1477
|
+
validateFilter(candidate) {
|
|
1478
|
+
if (candidate.includes.length === 0)
|
|
1479
|
+
return { audienceRequired: true };
|
|
1480
|
+
const total = candidate.includes.length + (candidate.excludes?.length ?? 0);
|
|
1481
|
+
if (total > AUDIENCE_LIMITS.maxTermsPerFilter) {
|
|
1482
|
+
return { audienceTooManyTerms: { actual: total, max: AUDIENCE_LIMITS.maxTermsPerFilter } };
|
|
1483
|
+
}
|
|
1484
|
+
return null;
|
|
1485
|
+
}
|
|
1486
|
+
registerOnValidatorChange(fn) {
|
|
1487
|
+
this.onValidatorChange = fn;
|
|
1488
|
+
}
|
|
1489
|
+
// ── Term display helpers ─────────────────────────────────────────────────────
|
|
1490
|
+
ouLabel(ouId) {
|
|
1491
|
+
const match = this.ouTree().find((o) => o.id === ouId);
|
|
1492
|
+
return match?.displayName ?? `${ouId.substring(0, 8)}…`;
|
|
1493
|
+
}
|
|
1494
|
+
roleLabel(appId, roleKey) {
|
|
1495
|
+
const match = this.roleOus().find((r) => r.appId === appId && r.roleKey === roleKey);
|
|
1496
|
+
return match?.displayName ?? `${appId}:${roleKey}`;
|
|
1497
|
+
}
|
|
1498
|
+
appLabel(appId) {
|
|
1499
|
+
if (appId === '*')
|
|
1500
|
+
return this.i18n.t('audience.app_everyone_wildcard');
|
|
1501
|
+
const match = this.appEveryoneOptions.find((a) => a.appId === appId);
|
|
1502
|
+
return match?.displayName ?? appId;
|
|
1503
|
+
}
|
|
1504
|
+
chartLabel(chartId) {
|
|
1505
|
+
const match = this.chartOptions().find((c) => c.id === chartId);
|
|
1506
|
+
return match?.name ?? `${chartId.substring(0, 8)}…`;
|
|
1507
|
+
}
|
|
1508
|
+
/**
|
|
1509
|
+
* Locale key for a term kind. Replaces hyphens with underscores so the JSON file uses
|
|
1510
|
+
* one consistent naming convention (`audience.kind_app_everyone`, not the hyphen variant).
|
|
1511
|
+
*/
|
|
1512
|
+
kindLocaleKey(kind) {
|
|
1513
|
+
return `audience.kind_${kind.replace(/-/g, '_')}`;
|
|
1514
|
+
}
|
|
1515
|
+
/** Same convention as {@link kindLocaleKey} but for preset names. */
|
|
1516
|
+
presetLocaleKey(preset) {
|
|
1517
|
+
return `audience.preset_${preset.replace(/-/g, '_')}`;
|
|
1518
|
+
}
|
|
1519
|
+
/** Picon icon CSS class for a term kind tab — kept here so the template stays declarative. */
|
|
1520
|
+
kindIconClass(kind) {
|
|
1521
|
+
switch (kind) {
|
|
1522
|
+
case 'users': return 'pi pi-user';
|
|
1523
|
+
case 'roles': return 'pi pi-id-card';
|
|
1524
|
+
case 'ou': return 'pi pi-sitemap';
|
|
1525
|
+
case 'app-everyone': return 'pi pi-globe';
|
|
1526
|
+
case 'chart': return 'pi pi-share-alt';
|
|
1527
|
+
case 'preset': return 'pi pi-star';
|
|
1528
|
+
}
|
|
1529
|
+
}
|
|
1530
|
+
// ── Internal helpers — upsert + state emission ───────────────────────────────
|
|
1531
|
+
addToActiveBucket(upsert) {
|
|
1532
|
+
if (this.disabled())
|
|
1533
|
+
return;
|
|
1534
|
+
const target = this.editTarget();
|
|
1535
|
+
const current = target === 'includes' ? this.includes() : this.excludes();
|
|
1536
|
+
const next = upsert(current);
|
|
1537
|
+
if (this.totalTermCount(next, target) > AUDIENCE_LIMITS.maxTermsPerFilter) {
|
|
1538
|
+
this.errorKey.set('audience.error.too_many_terms');
|
|
1539
|
+
return;
|
|
1540
|
+
}
|
|
1541
|
+
if (target === 'includes')
|
|
1542
|
+
this.includes.set(next);
|
|
1543
|
+
else
|
|
1544
|
+
this.excludes.set(next);
|
|
1545
|
+
this.errorKey.set(null);
|
|
1546
|
+
this.emitState();
|
|
1547
|
+
}
|
|
1548
|
+
editTermAt(target, index, fn) {
|
|
1549
|
+
if (this.disabled())
|
|
1550
|
+
return;
|
|
1551
|
+
const list = target === 'includes' ? this.includes() : this.excludes();
|
|
1552
|
+
const term = list[index];
|
|
1553
|
+
if (!term)
|
|
1554
|
+
return;
|
|
1555
|
+
const replaced = fn(term);
|
|
1556
|
+
const next = replaced === null
|
|
1557
|
+
? list.filter((_, i) => i !== index)
|
|
1558
|
+
: list.map((t, i) => (i === index ? replaced : t));
|
|
1559
|
+
if (target === 'includes')
|
|
1560
|
+
this.includes.set(next);
|
|
1561
|
+
else
|
|
1562
|
+
this.excludes.set(next);
|
|
1563
|
+
this.emitState();
|
|
1564
|
+
}
|
|
1565
|
+
totalTermCount(replacement, target) {
|
|
1566
|
+
const inc = target === 'includes' ? replacement : this.includes();
|
|
1567
|
+
const exc = target === 'excludes' ? replacement : this.excludes();
|
|
1568
|
+
return inc.length + exc.length;
|
|
1569
|
+
}
|
|
1570
|
+
upsertUsersTerm(existing, userId) {
|
|
1571
|
+
const idx = existing.findIndex((t) => t.kind === 'users');
|
|
1572
|
+
if (idx < 0) {
|
|
1573
|
+
const term = { kind: 'users', userIds: [userId] };
|
|
1574
|
+
return [...existing, term];
|
|
1575
|
+
}
|
|
1576
|
+
const term = existing[idx];
|
|
1577
|
+
if (term.userIds.includes(userId))
|
|
1578
|
+
return existing; // idempotent
|
|
1579
|
+
if (term.userIds.length >= AUDIENCE_LIMITS.maxUserIdsPerTerm) {
|
|
1580
|
+
this.errorKey.set('audience.error.too_many_users_per_term');
|
|
1581
|
+
return existing;
|
|
1582
|
+
}
|
|
1583
|
+
return existing.map((t, i) => i === idx ? { ...term, userIds: [...term.userIds, userId] } : t);
|
|
1584
|
+
}
|
|
1585
|
+
upsertRolesTerm(existing, appId, roleKey) {
|
|
1586
|
+
const idx = existing.findIndex((t) => t.kind === 'roles' && t.appId === appId);
|
|
1587
|
+
if (idx < 0) {
|
|
1588
|
+
const term = { kind: 'roles', appId, roleKeys: [roleKey] };
|
|
1589
|
+
return [...existing, term];
|
|
1590
|
+
}
|
|
1591
|
+
const term = existing[idx];
|
|
1592
|
+
if (term.roleKeys.includes(roleKey))
|
|
1593
|
+
return existing;
|
|
1594
|
+
if (term.roleKeys.length >= AUDIENCE_LIMITS.maxRoleKeysPerTerm) {
|
|
1595
|
+
this.errorKey.set('audience.error.too_many_roles_per_term');
|
|
1596
|
+
return existing;
|
|
1597
|
+
}
|
|
1598
|
+
return existing.map((t, i) => i === idx ? { ...term, roleKeys: [...term.roleKeys, roleKey] } : t);
|
|
1599
|
+
}
|
|
1600
|
+
upsertOuTerm(existing, ouId, chartId, includeDescendants) {
|
|
1601
|
+
const idx = existing.findIndex((t) => t.kind === 'ou'
|
|
1602
|
+
&& t.chartId === chartId
|
|
1603
|
+
&& t.includeDescendants === includeDescendants);
|
|
1604
|
+
if (idx < 0) {
|
|
1605
|
+
const term = {
|
|
1606
|
+
kind: 'ou',
|
|
1607
|
+
ouIds: [ouId],
|
|
1608
|
+
chartId,
|
|
1609
|
+
includeDescendants,
|
|
1610
|
+
};
|
|
1611
|
+
return [...existing, term];
|
|
1612
|
+
}
|
|
1613
|
+
const term = existing[idx];
|
|
1614
|
+
if (term.ouIds.includes(ouId))
|
|
1615
|
+
return existing;
|
|
1616
|
+
if (term.ouIds.length >= AUDIENCE_LIMITS.maxOuIdsPerTerm) {
|
|
1617
|
+
this.errorKey.set('audience.error.too_many_ous_per_term');
|
|
1618
|
+
return existing;
|
|
1619
|
+
}
|
|
1620
|
+
return existing.map((t, i) => i === idx ? { ...term, ouIds: [...term.ouIds, ouId] } : t);
|
|
1621
|
+
}
|
|
1622
|
+
upsertAppEveryoneTerm(existing, appId) {
|
|
1623
|
+
const exists = existing.some((t) => t.kind === 'app-everyone' && t.appId === appId);
|
|
1624
|
+
if (exists)
|
|
1625
|
+
return existing;
|
|
1626
|
+
const term = { kind: 'app-everyone', appId };
|
|
1627
|
+
return [...existing, term];
|
|
1628
|
+
}
|
|
1629
|
+
upsertChartTerm(existing, chartId) {
|
|
1630
|
+
const exists = existing.some((t) => t.kind === 'chart' && t.chartId === chartId);
|
|
1631
|
+
if (exists)
|
|
1632
|
+
return existing;
|
|
1633
|
+
const term = { kind: 'chart', chartId };
|
|
1634
|
+
return [...existing, term];
|
|
1635
|
+
}
|
|
1636
|
+
upsertPresetTerm(existing, preset, anchorUserId) {
|
|
1637
|
+
const exists = existing.some((t) => t.kind === 'preset' && t.preset === preset && t.anchorUserId === anchorUserId);
|
|
1638
|
+
if (exists)
|
|
1639
|
+
return existing;
|
|
1640
|
+
const term = { kind: 'preset', preset, anchorUserId };
|
|
1641
|
+
return [...existing, term];
|
|
1642
|
+
}
|
|
1643
|
+
buildFilter() {
|
|
1644
|
+
const inc = this.includes();
|
|
1645
|
+
const exc = this.excludes();
|
|
1646
|
+
return {
|
|
1647
|
+
includes: inc,
|
|
1648
|
+
...(exc.length > 0 ? { excludes: exc } : {}),
|
|
1649
|
+
...(this.value?.options ? { options: this.value.options } : {}),
|
|
1650
|
+
};
|
|
1651
|
+
}
|
|
1652
|
+
emitState() {
|
|
1653
|
+
const filter = this.buildFilter();
|
|
1654
|
+
this.audienceChange.emit(filter);
|
|
1655
|
+
this.validityChange.emit(this.computeValidity(filter));
|
|
1656
|
+
// Forward to the forms infrastructure so reactive forms see the change.
|
|
1657
|
+
this.onChange(filter);
|
|
1658
|
+
this.onTouched();
|
|
1659
|
+
this.onValidatorChange();
|
|
1660
|
+
}
|
|
1661
|
+
/**
|
|
1662
|
+
* Emits ONLY the validity signal (component output + forms-validator hook). Used by
|
|
1663
|
+
* <see cref="ngOnChanges"/> when the host pushes a new value: the host already knows the
|
|
1664
|
+
* value it sent, so re-firing onChange / audienceChange is redundant and risks feedback
|
|
1665
|
+
* loops with two-way bindings; but the validity flag may genuinely have flipped and the
|
|
1666
|
+
* host needs that to enable / disable submit.
|
|
1667
|
+
*/
|
|
1668
|
+
emitValidityOnly() {
|
|
1669
|
+
const filter = this.buildFilter();
|
|
1670
|
+
this.validityChange.emit(this.computeValidity(filter));
|
|
1671
|
+
this.onValidatorChange();
|
|
1672
|
+
}
|
|
1673
|
+
computeValidity(candidate) {
|
|
1674
|
+
// Single-source: delegate to validateFilter so the output flag and the form-validator
|
|
1675
|
+
// result agree by construction.
|
|
1676
|
+
return this.validateFilter(candidate) === null;
|
|
1677
|
+
}
|
|
1678
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.5", ngImport: i0, type: AudienceBuilderComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
1679
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.5", type: AudienceBuilderComponent, isStandalone: true, selector: "fly-audience-builder", inputs: { value: "value", showExcludes: "showExcludes", supportedKinds: "supportedKinds", selfUserId: "selfUserId", appEveryoneOptions: "appEveryoneOptions", searchUsers: "searchUsers", loadOuTree: "loadOuTree", loadChartOptions: "loadChartOptions", loadRoleOus: "loadRoleOus" }, outputs: { audienceChange: "audienceChange", validityChange: "validityChange" }, providers: [
|
|
1680
|
+
{
|
|
1681
|
+
provide: NG_VALUE_ACCESSOR,
|
|
1682
|
+
useExisting: forwardRef(() => AudienceBuilderComponent),
|
|
1683
|
+
multi: true,
|
|
1684
|
+
},
|
|
1685
|
+
{
|
|
1686
|
+
provide: NG_VALIDATORS,
|
|
1687
|
+
useExisting: forwardRef(() => AudienceBuilderComponent),
|
|
1688
|
+
multi: true,
|
|
1689
|
+
},
|
|
1690
|
+
], usesOnChanges: true, ngImport: i0, template: "<div class=\"fab-root\">\r\n\r\n <!-- Includes section -->\r\n <div class=\"fab-bucket\">\r\n <div class=\"fab-bucket-header\">\r\n <div class=\"fab-section-label\" id=\"fab-includes-label\">{{ 'audience.includes' | translate }}</div>\r\n <button\r\n type=\"button\"\r\n class=\"fab-add-btn\"\r\n (click)=\"openPicker('includes')\"\r\n [attr.aria-expanded]=\"pickerOpen() && editTarget() === 'includes'\"\r\n [attr.aria-controls]=\"pickerOpen() && editTarget() === 'includes' ? 'fab-picker-region' : null\"\r\n >\r\n <i class=\"pi pi-plus\" aria-hidden=\"true\"></i>\r\n {{ 'audience.add_term' | translate }}\r\n </button>\r\n </div>\r\n\r\n @if (includes().length === 0) {\r\n <div class=\"fab-empty\">{{ 'audience.includes_empty' | translate }}</div>\r\n } @else {\r\n <ul class=\"fab-term-list\" aria-labelledby=\"fab-includes-label\">\r\n @for (term of includes(); track term) {\r\n <li class=\"fab-term\">\r\n <ng-container\r\n *ngTemplateOutlet=\"termCard; context: { $implicit: term, target: 'includes', index: $index }\">\r\n </ng-container>\r\n </li>\r\n }\r\n </ul>\r\n }\r\n </div>\r\n\r\n <!-- Excludes section -->\r\n @if (showExcludes) {\r\n <div class=\"fab-bucket fab-bucket-exclude\">\r\n <div class=\"fab-bucket-header\">\r\n <div class=\"fab-section-label\" id=\"fab-excludes-label\">{{ 'audience.excludes' | translate }}</div>\r\n <button\r\n type=\"button\"\r\n class=\"fab-add-btn\"\r\n (click)=\"openPicker('excludes')\"\r\n [attr.aria-expanded]=\"pickerOpen() && editTarget() === 'excludes'\"\r\n [attr.aria-controls]=\"pickerOpen() && editTarget() === 'excludes' ? 'fab-picker-region' : null\"\r\n >\r\n <i class=\"pi pi-plus\" aria-hidden=\"true\"></i>\r\n {{ 'audience.add_term' | translate }}\r\n </button>\r\n </div>\r\n\r\n @if (excludes().length === 0) {\r\n <div class=\"fab-empty\">{{ 'audience.excludes_empty' | translate }}</div>\r\n } @else {\r\n <ul class=\"fab-term-list\" aria-labelledby=\"fab-excludes-label\">\r\n @for (term of excludes(); track term) {\r\n <li class=\"fab-term\">\r\n <ng-container\r\n *ngTemplateOutlet=\"termCard; context: { $implicit: term, target: 'excludes', index: $index }\">\r\n </ng-container>\r\n </li>\r\n }\r\n </ul>\r\n }\r\n </div>\r\n }\r\n\r\n <!-- Inline picker (shared across includes/excludes; gated by pickerOpen) -->\r\n @if (pickerOpen()) {\r\n <div\r\n id=\"fab-picker-region\"\r\n class=\"fab-picker\"\r\n role=\"region\"\r\n [attr.aria-label]=\"(editTarget() === 'includes'\r\n ? ('audience.adding_to_includes' | translate)\r\n : ('audience.adding_to_excludes' | translate))\"\r\n >\r\n <div class=\"fab-picker-header\">\r\n <span class=\"fab-picker-target\">\r\n {{\r\n editTarget() === 'includes'\r\n ? ('audience.adding_to_includes' | translate)\r\n : ('audience.adding_to_excludes' | translate)\r\n }}\r\n </span>\r\n <button type=\"button\" class=\"fab-close\" (click)=\"closePicker()\" [attr.aria-label]=\"'audience.close' | translate\">\r\n <i class=\"pi pi-times\" aria-hidden=\"true\"></i>\r\n </button>\r\n </div>\r\n\r\n <!-- Kind tabs -->\r\n <div class=\"fab-kind-tabs\" role=\"tablist\">\r\n @for (kind of supportedKinds; track kind) {\r\n <button\r\n type=\"button\"\r\n role=\"tab\"\r\n class=\"fab-kind-tab\"\r\n [id]=\"'fab-tab-' + kind\"\r\n [class.active]=\"activeKind() === kind\"\r\n [attr.aria-selected]=\"activeKind() === kind\"\r\n [attr.aria-controls]=\"'fab-tabpanel-' + kind\"\r\n (click)=\"selectKind(kind)\"\r\n >\r\n <i [class]=\"kindIconClass(kind)\" aria-hidden=\"true\"></i>\r\n <span>{{ kindLocaleKey(kind) | translate }}</span>\r\n </button>\r\n }\r\n </div>\r\n\r\n @if (errorKey()) {\r\n <div class=\"fab-inline-error\" role=\"alert\">\r\n <i class=\"pi pi-exclamation-triangle\" aria-hidden=\"true\"></i>\r\n {{ errorKey()! | translate }}\r\n </div>\r\n }\r\n\r\n <!-- Per-kind picker bodies -->\r\n <div\r\n class=\"fab-picker-body\"\r\n role=\"tabpanel\"\r\n [id]=\"'fab-tabpanel-' + activeKind()\"\r\n [attr.aria-labelledby]=\"'fab-tab-' + activeKind()\"\r\n >\r\n @switch (activeKind()) {\r\n\r\n @case ('users') {\r\n <input\r\n type=\"text\"\r\n class=\"fab-input\"\r\n [ngModel]=\"userSearchQuery()\"\r\n (ngModelChange)=\"userSearchQuery.set($event); onUserSearchInput()\"\r\n [placeholder]=\"'audience.search_users_placeholder' | translate\"\r\n [attr.aria-label]=\"'audience.search_users_placeholder' | translate\"\r\n />\r\n @if (userSearchResults().length > 0) {\r\n <div class=\"fab-result-list\" role=\"list\">\r\n @for (u of userSearchResults(); track u.id) {\r\n <button type=\"button\" role=\"listitem\" class=\"fab-result-item\" (click)=\"pickUser(u)\">\r\n <i class=\"pi pi-user\" aria-hidden=\"true\"></i>\r\n <span>{{ u.firstName }} {{ u.lastName }}</span>\r\n <span class=\"fab-result-secondary\">{{ u.email }}</span>\r\n </button>\r\n }\r\n </div>\r\n } @else if (userSearchQuery().length >= 2) {\r\n <div class=\"fab-empty\">{{ 'audience.no_users_match' | translate }}</div>\r\n }\r\n }\r\n\r\n @case ('roles') {\r\n @if (loadRoleOus && roleOus().length > 0) {\r\n <input\r\n type=\"text\"\r\n class=\"fab-input\"\r\n [ngModel]=\"roleSearchQuery()\"\r\n (ngModelChange)=\"roleSearchQuery.set($event)\"\r\n [placeholder]=\"'audience.search_roles_placeholder' | translate\"\r\n [attr.aria-label]=\"'audience.search_roles_placeholder' | translate\"\r\n />\r\n <div class=\"fab-result-list\" role=\"list\">\r\n @for (role of filteredRoles(); track role.ouId) {\r\n <button type=\"button\" role=\"listitem\" class=\"fab-result-item\" (click)=\"pickRole(role)\">\r\n <i class=\"pi pi-id-card\" aria-hidden=\"true\"></i>\r\n <span>{{ role.displayName }}</span>\r\n <span class=\"fab-result-secondary\">{{ role.appId }}</span>\r\n </button>\r\n }\r\n @if (filteredRoles().length === 0) {\r\n <div class=\"fab-empty\">{{ 'audience.no_roles_match' | translate }}</div>\r\n }\r\n </div>\r\n } @else {\r\n <div class=\"fab-empty\">{{ 'audience.roles_unavailable' | translate }}</div>\r\n }\r\n }\r\n\r\n @case ('ou') {\r\n @if (chartOptions().length > 0) {\r\n <label for=\"fab-chart-select\" class=\"fab-mini-label\">\r\n {{ 'audience.org_chart' | translate }}\r\n </label>\r\n <select\r\n id=\"fab-chart-select\"\r\n class=\"fab-select\"\r\n [ngModel]=\"selectedChartId()\"\r\n (ngModelChange)=\"onChartChange($event)\"\r\n >\r\n @for (opt of chartOptions(); track $index) {\r\n <option [ngValue]=\"opt.id\">{{ opt.name }}</option>\r\n }\r\n </select>\r\n }\r\n <label class=\"fab-checkbox-label\">\r\n <input\r\n type=\"checkbox\"\r\n [ngModel]=\"ouIncludeDescendants()\"\r\n (ngModelChange)=\"ouIncludeDescendants.set($event)\"\r\n />\r\n {{ 'audience.include_descendants' | translate }}\r\n </label>\r\n @if (ouTree().length === 0) {\r\n <div class=\"fab-empty\">{{ 'audience.ou_tree_empty' | translate }}</div>\r\n } @else {\r\n <div class=\"fab-result-list fab-ou-tree\" role=\"list\">\r\n @for (ou of ouTree(); track ou.id) {\r\n <button\r\n type=\"button\"\r\n role=\"listitem\"\r\n class=\"fab-result-item\"\r\n [style.padding-inline-start.px]=\"ouIndentStartPx(ou)\"\r\n (click)=\"pickOu(ou)\"\r\n >\r\n <i class=\"pi pi-sitemap\" aria-hidden=\"true\"></i>\r\n <span>{{ ou.displayName }}</span>\r\n </button>\r\n }\r\n </div>\r\n }\r\n }\r\n\r\n @case ('app-everyone') {\r\n @if (appEveryoneOptions.length === 0) {\r\n <div class=\"fab-empty\">{{ 'audience.app_everyone_empty' | translate }}</div>\r\n } @else {\r\n <div class=\"fab-result-list\" role=\"list\">\r\n @for (app of appEveryoneOptions; track app.appId) {\r\n <button type=\"button\" role=\"listitem\" class=\"fab-result-item\" (click)=\"pickAppEveryone(app.appId)\">\r\n <i class=\"pi pi-globe\" aria-hidden=\"true\"></i>\r\n <span>{{ app.displayName }}</span>\r\n <span class=\"fab-result-secondary\">{{ app.appId }}</span>\r\n </button>\r\n }\r\n </div>\r\n }\r\n }\r\n\r\n @case ('chart') {\r\n @if (chartTermOptions().length === 0) {\r\n <div class=\"fab-empty\">{{ 'audience.chart_options_empty' | translate }}</div>\r\n } @else {\r\n <div class=\"fab-result-list\" role=\"list\">\r\n @for (opt of chartTermOptions(); track opt.id) {\r\n <button type=\"button\" role=\"listitem\" class=\"fab-result-item\" (click)=\"pickChart(opt.id)\">\r\n <i class=\"pi pi-share-alt\" aria-hidden=\"true\"></i>\r\n <span>{{ opt.name }}</span>\r\n </button>\r\n }\r\n </div>\r\n }\r\n }\r\n\r\n @case ('preset') {\r\n <div class=\"fab-result-list\" role=\"list\">\r\n @for (p of presetOptions; track p) {\r\n <button type=\"button\" role=\"listitem\" class=\"fab-result-item\" (click)=\"pickPreset(p)\">\r\n <i class=\"pi pi-star\" aria-hidden=\"true\"></i>\r\n <span>{{ presetLocaleKey(p) | translate }}</span>\r\n </button>\r\n }\r\n </div>\r\n }\r\n }\r\n </div>\r\n </div>\r\n }\r\n\r\n</div>\r\n\r\n<!-- Reusable term card template, parameterised by target bucket + index for removal callbacks -->\r\n<ng-template #termCard let-term let-target=\"target\" let-index=\"index\">\r\n <div class=\"fab-term-card\" [class.fab-term-exclude]=\"target === 'excludes'\">\r\n\r\n @switch (term.kind) {\r\n @case ('users') {\r\n <div class=\"fab-term-head\">\r\n <i class=\"pi pi-user\" aria-hidden=\"true\"></i>\r\n <span class=\"fab-term-title\">\r\n {{ 'audience.term_users' | translate: { count: term.userIds.length } }}\r\n </span>\r\n <button type=\"button\" class=\"fab-icon-btn danger\" (click)=\"removeTerm(target, index)\"\r\n [attr.aria-label]=\"'audience.remove_term' | translate\">\r\n <i class=\"pi pi-times\" aria-hidden=\"true\"></i>\r\n </button>\r\n </div>\r\n <div class=\"fab-term-chips\">\r\n @for (id of term.userIds; track id) {\r\n <span class=\"fab-chip\">\r\n <i class=\"pi pi-user\" aria-hidden=\"true\"></i>\r\n <span>{{ id.substring(0, 8) }}\u2026</span>\r\n <button type=\"button\" class=\"fab-chip-x\" (click)=\"removeUserId(target, index, id)\"\r\n [attr.aria-label]=\"'audience.remove_term' | translate\">\r\n <i class=\"pi pi-times\" aria-hidden=\"true\"></i>\r\n </button>\r\n </span>\r\n }\r\n </div>\r\n }\r\n\r\n @case ('roles') {\r\n <div class=\"fab-term-head\">\r\n <i class=\"pi pi-id-card\" aria-hidden=\"true\"></i>\r\n <span class=\"fab-term-title\">\r\n {{ 'audience.term_roles' | translate: { app: appLabel(term.appId), count: term.roleKeys.length } }}\r\n </span>\r\n <button type=\"button\" class=\"fab-icon-btn danger\" (click)=\"removeTerm(target, index)\"\r\n [attr.aria-label]=\"'audience.remove_term' | translate\">\r\n <i class=\"pi pi-times\" aria-hidden=\"true\"></i>\r\n </button>\r\n </div>\r\n <div class=\"fab-term-chips\">\r\n @for (k of term.roleKeys; track k) {\r\n <span class=\"fab-chip\">\r\n <i class=\"pi pi-id-card\" aria-hidden=\"true\"></i>\r\n <span>{{ roleLabel(term.appId, k) }}</span>\r\n <button type=\"button\" class=\"fab-chip-x\" (click)=\"removeRoleKey(target, index, k)\"\r\n [attr.aria-label]=\"'audience.remove_term' | translate\">\r\n <i class=\"pi pi-times\" aria-hidden=\"true\"></i>\r\n </button>\r\n </span>\r\n }\r\n </div>\r\n }\r\n\r\n @case ('ou') {\r\n <div class=\"fab-term-head\">\r\n <i class=\"pi pi-sitemap\" aria-hidden=\"true\"></i>\r\n <span class=\"fab-term-title\">\r\n {{ 'audience.term_ous' | translate: { count: term.ouIds.length } }}\r\n @if (term.includeDescendants) {\r\n <span class=\"fab-badge\">{{ 'audience.descendants_badge' | translate }}</span>\r\n }\r\n </span>\r\n <button type=\"button\" class=\"fab-icon-btn danger\" (click)=\"removeTerm(target, index)\"\r\n [attr.aria-label]=\"'audience.remove_term' | translate\">\r\n <i class=\"pi pi-times\" aria-hidden=\"true\"></i>\r\n </button>\r\n </div>\r\n <div class=\"fab-term-chips\">\r\n @for (id of term.ouIds; track id) {\r\n <span class=\"fab-chip\">\r\n <i class=\"pi pi-sitemap\" aria-hidden=\"true\"></i>\r\n <span>{{ ouLabel(id) }}</span>\r\n <button type=\"button\" class=\"fab-chip-x\" (click)=\"removeOuId(target, index, id)\"\r\n [attr.aria-label]=\"'audience.remove_term' | translate\">\r\n <i class=\"pi pi-times\" aria-hidden=\"true\"></i>\r\n </button>\r\n </span>\r\n }\r\n </div>\r\n }\r\n\r\n @case ('app-everyone') {\r\n <div class=\"fab-term-head\">\r\n <i class=\"pi pi-globe\" aria-hidden=\"true\"></i>\r\n <span class=\"fab-term-title\">\r\n {{ 'audience.term_app_everyone' | translate: { app: appLabel(term.appId) } }}\r\n </span>\r\n <button type=\"button\" class=\"fab-icon-btn danger\" (click)=\"removeTerm(target, index)\"\r\n [attr.aria-label]=\"'audience.remove_term' | translate\">\r\n <i class=\"pi pi-times\" aria-hidden=\"true\"></i>\r\n </button>\r\n </div>\r\n }\r\n\r\n @case ('chart') {\r\n <div class=\"fab-term-head\">\r\n <i class=\"pi pi-share-alt\" aria-hidden=\"true\"></i>\r\n <span class=\"fab-term-title\">\r\n {{ 'audience.term_chart' | translate: { chart: chartLabel(term.chartId) } }}\r\n </span>\r\n <button type=\"button\" class=\"fab-icon-btn danger\" (click)=\"removeTerm(target, index)\"\r\n [attr.aria-label]=\"'audience.remove_term' | translate\">\r\n <i class=\"pi pi-times\" aria-hidden=\"true\"></i>\r\n </button>\r\n </div>\r\n }\r\n\r\n @case ('preset') {\r\n <div class=\"fab-term-head\">\r\n <i class=\"pi pi-star\" aria-hidden=\"true\"></i>\r\n <span class=\"fab-term-title\">\r\n {{ presetLocaleKey(term.preset) | translate }}\r\n </span>\r\n <button type=\"button\" class=\"fab-icon-btn danger\" (click)=\"removeTerm(target, index)\"\r\n [attr.aria-label]=\"'audience.remove_term' | translate\">\r\n <i class=\"pi pi-times\" aria-hidden=\"true\"></i>\r\n </button>\r\n </div>\r\n }\r\n }\r\n </div>\r\n</ng-template>\r\n", styles: [".fab-root{display:flex;flex-direction:column;gap:14px;width:100%}.fab-bucket{background:var(--surface-card, #1e1e1e);border:1px solid var(--surface-border);border-radius:10px;padding:12px 14px}.fab-bucket.fab-bucket-exclude{border-inline-start:3px solid rgba(255,59,48,.55)}.fab-bucket-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:8px}.fab-section-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.5px;color:var(--text-color-secondary)}.fab-add-btn{display:inline-flex;align-items:center;gap:6px;background:var(--primary-color, #e8732a);color:#fff;border:none;border-radius:6px;padding:5px 10px;font-size:12px;cursor:pointer}.fab-add-btn:hover{filter:brightness(1.05)}.fab-add-btn i{font-size:11px}.fab-empty{text-align:center;padding:12px;color:var(--text-color-secondary);font-size:12px;font-style:italic}.fab-term-list{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:8px}.fab-term-card{background:#ffffff0a;border:1px solid rgba(255,255,255,.08);border-radius:8px;padding:8px 10px}.fab-term-card.fab-term-exclude{background:#ff3b300d;border-color:#ff3b302e}.fab-term-head{display:flex;align-items:center;gap:8px}.fab-term-head i:first-child{color:var(--text-color-secondary)}.fab-term-title{flex:1;font-size:13px;display:inline-flex;align-items:center;gap:6px}.fab-badge{font-size:10px;background:#ffffff1a;color:var(--text-color-secondary);padding:2px 6px;border-radius:8px;text-transform:uppercase;letter-spacing:.5px}.fab-term-chips{margin-top:6px;display:flex;flex-wrap:wrap;gap:4px}.fab-chip{display:inline-flex;align-items:center;gap:4px;background:#ffffff0f;border-radius:12px;padding:2px 4px 2px 8px;font-size:11px}.fab-chip i{font-size:10px;color:var(--text-color-secondary)}.fab-chip-x{background:none;border:none;color:var(--text-color-secondary);cursor:pointer;padding:2px 4px;border-radius:50%;display:inline-flex;align-items:center;justify-content:center}.fab-chip-x i{font-size:9px}.fab-chip-x:hover{background:#ff3b302e;color:#ff3b30}.fab-icon-btn{background:#ffffff0f;border:none;border-radius:6px;width:24px;height:24px;display:inline-flex;align-items:center;justify-content:center;color:inherit;cursor:pointer;font-size:11px}.fab-icon-btn:hover{background:#ffffff24}.fab-icon-btn.danger:hover{background:#ff3b3033;color:#ff3b30}.fab-picker{background:var(--surface-card, #1e1e1e);border:1px solid var(--surface-border);border-radius:10px;padding:12px 14px;display:flex;flex-direction:column;gap:10px;box-shadow:0 6px 18px #0000002e}.fab-picker-header{display:flex;align-items:center;justify-content:space-between}.fab-picker-target{font-size:12px;color:var(--text-color-secondary)}.fab-close{background:none;border:none;color:var(--text-color-secondary);cursor:pointer;font-size:14px;padding:4px}.fab-close:hover{color:var(--text-color)}.fab-kind-tabs{display:flex;flex-wrap:wrap;gap:4px;border-bottom:1px solid var(--surface-border);padding-bottom:6px}.fab-kind-tab{background:transparent;border:1px solid transparent;border-radius:6px;padding:6px 10px;font-size:12px;color:var(--text-color-secondary);cursor:pointer;display:inline-flex;align-items:center;gap:6px}.fab-kind-tab i{font-size:11px}.fab-kind-tab:hover{background:var(--surface-hover);color:var(--text-color)}.fab-kind-tab.active{background:#e8732a1f;border-color:var(--primary-color, #e8732a);color:var(--text-color)}.fab-picker-body{display:flex;flex-direction:column;gap:8px}.fab-input,.fab-select{background:#ffffff14;border:1px solid rgba(255,255,255,.15);border-radius:8px;padding:8px 12px;color:inherit;font-size:13px;outline:none}.fab-input:focus,.fab-select:focus{border-color:var(--primary-color, #e8732a)}.fab-mini-label{font-size:11px;color:var(--text-color-secondary)}.fab-result-list{max-height:200px;overflow-y:auto;border:1px solid var(--surface-border);border-radius:8px}.fab-result-list.fab-ou-tree{max-height:240px}button.fab-result-item{width:100%;background:transparent;border:none;color:inherit;font:inherit;text-align:start;appearance:none}.fab-result-item{display:flex;align-items:center;gap:8px;padding:6px 12px;cursor:pointer;font-size:13px}.fab-result-item:hover{background:var(--surface-hover)}.fab-result-item:focus-visible{outline:2px solid var(--primary-color, #e8732a);outline-offset:-2px}.fab-result-item i{color:var(--text-color-secondary)}.fab-result-secondary{color:var(--text-color-secondary);font-size:11px;margin-inline-start:auto;text-transform:uppercase;letter-spacing:.4px}.fab-checkbox-label{display:flex;align-items:center;gap:6px;font-size:12px;color:var(--text-color-secondary);cursor:pointer}.fab-checkbox-label input{accent-color:var(--primary-color, #e8732a)}.fab-inline-error{display:flex;align-items:center;gap:6px;background:#ff3b301a;color:#ff8a80;border-radius:6px;padding:6px 10px;font-size:12px}.fab-inline-error i{font-size:12px}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "directive", type: i1$1.NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }, { 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.CheckboxControlValueAccessor, selector: "input[type=checkbox][formControlName],input[type=checkbox][formControl],input[type=checkbox][ngModel]" }, { 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: "pipe", type: TranslatePipe, name: "translate" }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
1691
|
+
}
|
|
1692
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.5", ngImport: i0, type: AudienceBuilderComponent, decorators: [{
|
|
1693
|
+
type: Component,
|
|
1694
|
+
args: [{ selector: 'fly-audience-builder', standalone: true, imports: [CommonModule, FormsModule, TranslatePipe], changeDetection: ChangeDetectionStrategy.OnPush, providers: [
|
|
1695
|
+
{
|
|
1696
|
+
provide: NG_VALUE_ACCESSOR,
|
|
1697
|
+
useExisting: forwardRef(() => AudienceBuilderComponent),
|
|
1698
|
+
multi: true,
|
|
1699
|
+
},
|
|
1700
|
+
{
|
|
1701
|
+
provide: NG_VALIDATORS,
|
|
1702
|
+
useExisting: forwardRef(() => AudienceBuilderComponent),
|
|
1703
|
+
multi: true,
|
|
1704
|
+
},
|
|
1705
|
+
], template: "<div class=\"fab-root\">\r\n\r\n <!-- Includes section -->\r\n <div class=\"fab-bucket\">\r\n <div class=\"fab-bucket-header\">\r\n <div class=\"fab-section-label\" id=\"fab-includes-label\">{{ 'audience.includes' | translate }}</div>\r\n <button\r\n type=\"button\"\r\n class=\"fab-add-btn\"\r\n (click)=\"openPicker('includes')\"\r\n [attr.aria-expanded]=\"pickerOpen() && editTarget() === 'includes'\"\r\n [attr.aria-controls]=\"pickerOpen() && editTarget() === 'includes' ? 'fab-picker-region' : null\"\r\n >\r\n <i class=\"pi pi-plus\" aria-hidden=\"true\"></i>\r\n {{ 'audience.add_term' | translate }}\r\n </button>\r\n </div>\r\n\r\n @if (includes().length === 0) {\r\n <div class=\"fab-empty\">{{ 'audience.includes_empty' | translate }}</div>\r\n } @else {\r\n <ul class=\"fab-term-list\" aria-labelledby=\"fab-includes-label\">\r\n @for (term of includes(); track term) {\r\n <li class=\"fab-term\">\r\n <ng-container\r\n *ngTemplateOutlet=\"termCard; context: { $implicit: term, target: 'includes', index: $index }\">\r\n </ng-container>\r\n </li>\r\n }\r\n </ul>\r\n }\r\n </div>\r\n\r\n <!-- Excludes section -->\r\n @if (showExcludes) {\r\n <div class=\"fab-bucket fab-bucket-exclude\">\r\n <div class=\"fab-bucket-header\">\r\n <div class=\"fab-section-label\" id=\"fab-excludes-label\">{{ 'audience.excludes' | translate }}</div>\r\n <button\r\n type=\"button\"\r\n class=\"fab-add-btn\"\r\n (click)=\"openPicker('excludes')\"\r\n [attr.aria-expanded]=\"pickerOpen() && editTarget() === 'excludes'\"\r\n [attr.aria-controls]=\"pickerOpen() && editTarget() === 'excludes' ? 'fab-picker-region' : null\"\r\n >\r\n <i class=\"pi pi-plus\" aria-hidden=\"true\"></i>\r\n {{ 'audience.add_term' | translate }}\r\n </button>\r\n </div>\r\n\r\n @if (excludes().length === 0) {\r\n <div class=\"fab-empty\">{{ 'audience.excludes_empty' | translate }}</div>\r\n } @else {\r\n <ul class=\"fab-term-list\" aria-labelledby=\"fab-excludes-label\">\r\n @for (term of excludes(); track term) {\r\n <li class=\"fab-term\">\r\n <ng-container\r\n *ngTemplateOutlet=\"termCard; context: { $implicit: term, target: 'excludes', index: $index }\">\r\n </ng-container>\r\n </li>\r\n }\r\n </ul>\r\n }\r\n </div>\r\n }\r\n\r\n <!-- Inline picker (shared across includes/excludes; gated by pickerOpen) -->\r\n @if (pickerOpen()) {\r\n <div\r\n id=\"fab-picker-region\"\r\n class=\"fab-picker\"\r\n role=\"region\"\r\n [attr.aria-label]=\"(editTarget() === 'includes'\r\n ? ('audience.adding_to_includes' | translate)\r\n : ('audience.adding_to_excludes' | translate))\"\r\n >\r\n <div class=\"fab-picker-header\">\r\n <span class=\"fab-picker-target\">\r\n {{\r\n editTarget() === 'includes'\r\n ? ('audience.adding_to_includes' | translate)\r\n : ('audience.adding_to_excludes' | translate)\r\n }}\r\n </span>\r\n <button type=\"button\" class=\"fab-close\" (click)=\"closePicker()\" [attr.aria-label]=\"'audience.close' | translate\">\r\n <i class=\"pi pi-times\" aria-hidden=\"true\"></i>\r\n </button>\r\n </div>\r\n\r\n <!-- Kind tabs -->\r\n <div class=\"fab-kind-tabs\" role=\"tablist\">\r\n @for (kind of supportedKinds; track kind) {\r\n <button\r\n type=\"button\"\r\n role=\"tab\"\r\n class=\"fab-kind-tab\"\r\n [id]=\"'fab-tab-' + kind\"\r\n [class.active]=\"activeKind() === kind\"\r\n [attr.aria-selected]=\"activeKind() === kind\"\r\n [attr.aria-controls]=\"'fab-tabpanel-' + kind\"\r\n (click)=\"selectKind(kind)\"\r\n >\r\n <i [class]=\"kindIconClass(kind)\" aria-hidden=\"true\"></i>\r\n <span>{{ kindLocaleKey(kind) | translate }}</span>\r\n </button>\r\n }\r\n </div>\r\n\r\n @if (errorKey()) {\r\n <div class=\"fab-inline-error\" role=\"alert\">\r\n <i class=\"pi pi-exclamation-triangle\" aria-hidden=\"true\"></i>\r\n {{ errorKey()! | translate }}\r\n </div>\r\n }\r\n\r\n <!-- Per-kind picker bodies -->\r\n <div\r\n class=\"fab-picker-body\"\r\n role=\"tabpanel\"\r\n [id]=\"'fab-tabpanel-' + activeKind()\"\r\n [attr.aria-labelledby]=\"'fab-tab-' + activeKind()\"\r\n >\r\n @switch (activeKind()) {\r\n\r\n @case ('users') {\r\n <input\r\n type=\"text\"\r\n class=\"fab-input\"\r\n [ngModel]=\"userSearchQuery()\"\r\n (ngModelChange)=\"userSearchQuery.set($event); onUserSearchInput()\"\r\n [placeholder]=\"'audience.search_users_placeholder' | translate\"\r\n [attr.aria-label]=\"'audience.search_users_placeholder' | translate\"\r\n />\r\n @if (userSearchResults().length > 0) {\r\n <div class=\"fab-result-list\" role=\"list\">\r\n @for (u of userSearchResults(); track u.id) {\r\n <button type=\"button\" role=\"listitem\" class=\"fab-result-item\" (click)=\"pickUser(u)\">\r\n <i class=\"pi pi-user\" aria-hidden=\"true\"></i>\r\n <span>{{ u.firstName }} {{ u.lastName }}</span>\r\n <span class=\"fab-result-secondary\">{{ u.email }}</span>\r\n </button>\r\n }\r\n </div>\r\n } @else if (userSearchQuery().length >= 2) {\r\n <div class=\"fab-empty\">{{ 'audience.no_users_match' | translate }}</div>\r\n }\r\n }\r\n\r\n @case ('roles') {\r\n @if (loadRoleOus && roleOus().length > 0) {\r\n <input\r\n type=\"text\"\r\n class=\"fab-input\"\r\n [ngModel]=\"roleSearchQuery()\"\r\n (ngModelChange)=\"roleSearchQuery.set($event)\"\r\n [placeholder]=\"'audience.search_roles_placeholder' | translate\"\r\n [attr.aria-label]=\"'audience.search_roles_placeholder' | translate\"\r\n />\r\n <div class=\"fab-result-list\" role=\"list\">\r\n @for (role of filteredRoles(); track role.ouId) {\r\n <button type=\"button\" role=\"listitem\" class=\"fab-result-item\" (click)=\"pickRole(role)\">\r\n <i class=\"pi pi-id-card\" aria-hidden=\"true\"></i>\r\n <span>{{ role.displayName }}</span>\r\n <span class=\"fab-result-secondary\">{{ role.appId }}</span>\r\n </button>\r\n }\r\n @if (filteredRoles().length === 0) {\r\n <div class=\"fab-empty\">{{ 'audience.no_roles_match' | translate }}</div>\r\n }\r\n </div>\r\n } @else {\r\n <div class=\"fab-empty\">{{ 'audience.roles_unavailable' | translate }}</div>\r\n }\r\n }\r\n\r\n @case ('ou') {\r\n @if (chartOptions().length > 0) {\r\n <label for=\"fab-chart-select\" class=\"fab-mini-label\">\r\n {{ 'audience.org_chart' | translate }}\r\n </label>\r\n <select\r\n id=\"fab-chart-select\"\r\n class=\"fab-select\"\r\n [ngModel]=\"selectedChartId()\"\r\n (ngModelChange)=\"onChartChange($event)\"\r\n >\r\n @for (opt of chartOptions(); track $index) {\r\n <option [ngValue]=\"opt.id\">{{ opt.name }}</option>\r\n }\r\n </select>\r\n }\r\n <label class=\"fab-checkbox-label\">\r\n <input\r\n type=\"checkbox\"\r\n [ngModel]=\"ouIncludeDescendants()\"\r\n (ngModelChange)=\"ouIncludeDescendants.set($event)\"\r\n />\r\n {{ 'audience.include_descendants' | translate }}\r\n </label>\r\n @if (ouTree().length === 0) {\r\n <div class=\"fab-empty\">{{ 'audience.ou_tree_empty' | translate }}</div>\r\n } @else {\r\n <div class=\"fab-result-list fab-ou-tree\" role=\"list\">\r\n @for (ou of ouTree(); track ou.id) {\r\n <button\r\n type=\"button\"\r\n role=\"listitem\"\r\n class=\"fab-result-item\"\r\n [style.padding-inline-start.px]=\"ouIndentStartPx(ou)\"\r\n (click)=\"pickOu(ou)\"\r\n >\r\n <i class=\"pi pi-sitemap\" aria-hidden=\"true\"></i>\r\n <span>{{ ou.displayName }}</span>\r\n </button>\r\n }\r\n </div>\r\n }\r\n }\r\n\r\n @case ('app-everyone') {\r\n @if (appEveryoneOptions.length === 0) {\r\n <div class=\"fab-empty\">{{ 'audience.app_everyone_empty' | translate }}</div>\r\n } @else {\r\n <div class=\"fab-result-list\" role=\"list\">\r\n @for (app of appEveryoneOptions; track app.appId) {\r\n <button type=\"button\" role=\"listitem\" class=\"fab-result-item\" (click)=\"pickAppEveryone(app.appId)\">\r\n <i class=\"pi pi-globe\" aria-hidden=\"true\"></i>\r\n <span>{{ app.displayName }}</span>\r\n <span class=\"fab-result-secondary\">{{ app.appId }}</span>\r\n </button>\r\n }\r\n </div>\r\n }\r\n }\r\n\r\n @case ('chart') {\r\n @if (chartTermOptions().length === 0) {\r\n <div class=\"fab-empty\">{{ 'audience.chart_options_empty' | translate }}</div>\r\n } @else {\r\n <div class=\"fab-result-list\" role=\"list\">\r\n @for (opt of chartTermOptions(); track opt.id) {\r\n <button type=\"button\" role=\"listitem\" class=\"fab-result-item\" (click)=\"pickChart(opt.id)\">\r\n <i class=\"pi pi-share-alt\" aria-hidden=\"true\"></i>\r\n <span>{{ opt.name }}</span>\r\n </button>\r\n }\r\n </div>\r\n }\r\n }\r\n\r\n @case ('preset') {\r\n <div class=\"fab-result-list\" role=\"list\">\r\n @for (p of presetOptions; track p) {\r\n <button type=\"button\" role=\"listitem\" class=\"fab-result-item\" (click)=\"pickPreset(p)\">\r\n <i class=\"pi pi-star\" aria-hidden=\"true\"></i>\r\n <span>{{ presetLocaleKey(p) | translate }}</span>\r\n </button>\r\n }\r\n </div>\r\n }\r\n }\r\n </div>\r\n </div>\r\n }\r\n\r\n</div>\r\n\r\n<!-- Reusable term card template, parameterised by target bucket + index for removal callbacks -->\r\n<ng-template #termCard let-term let-target=\"target\" let-index=\"index\">\r\n <div class=\"fab-term-card\" [class.fab-term-exclude]=\"target === 'excludes'\">\r\n\r\n @switch (term.kind) {\r\n @case ('users') {\r\n <div class=\"fab-term-head\">\r\n <i class=\"pi pi-user\" aria-hidden=\"true\"></i>\r\n <span class=\"fab-term-title\">\r\n {{ 'audience.term_users' | translate: { count: term.userIds.length } }}\r\n </span>\r\n <button type=\"button\" class=\"fab-icon-btn danger\" (click)=\"removeTerm(target, index)\"\r\n [attr.aria-label]=\"'audience.remove_term' | translate\">\r\n <i class=\"pi pi-times\" aria-hidden=\"true\"></i>\r\n </button>\r\n </div>\r\n <div class=\"fab-term-chips\">\r\n @for (id of term.userIds; track id) {\r\n <span class=\"fab-chip\">\r\n <i class=\"pi pi-user\" aria-hidden=\"true\"></i>\r\n <span>{{ id.substring(0, 8) }}\u2026</span>\r\n <button type=\"button\" class=\"fab-chip-x\" (click)=\"removeUserId(target, index, id)\"\r\n [attr.aria-label]=\"'audience.remove_term' | translate\">\r\n <i class=\"pi pi-times\" aria-hidden=\"true\"></i>\r\n </button>\r\n </span>\r\n }\r\n </div>\r\n }\r\n\r\n @case ('roles') {\r\n <div class=\"fab-term-head\">\r\n <i class=\"pi pi-id-card\" aria-hidden=\"true\"></i>\r\n <span class=\"fab-term-title\">\r\n {{ 'audience.term_roles' | translate: { app: appLabel(term.appId), count: term.roleKeys.length } }}\r\n </span>\r\n <button type=\"button\" class=\"fab-icon-btn danger\" (click)=\"removeTerm(target, index)\"\r\n [attr.aria-label]=\"'audience.remove_term' | translate\">\r\n <i class=\"pi pi-times\" aria-hidden=\"true\"></i>\r\n </button>\r\n </div>\r\n <div class=\"fab-term-chips\">\r\n @for (k of term.roleKeys; track k) {\r\n <span class=\"fab-chip\">\r\n <i class=\"pi pi-id-card\" aria-hidden=\"true\"></i>\r\n <span>{{ roleLabel(term.appId, k) }}</span>\r\n <button type=\"button\" class=\"fab-chip-x\" (click)=\"removeRoleKey(target, index, k)\"\r\n [attr.aria-label]=\"'audience.remove_term' | translate\">\r\n <i class=\"pi pi-times\" aria-hidden=\"true\"></i>\r\n </button>\r\n </span>\r\n }\r\n </div>\r\n }\r\n\r\n @case ('ou') {\r\n <div class=\"fab-term-head\">\r\n <i class=\"pi pi-sitemap\" aria-hidden=\"true\"></i>\r\n <span class=\"fab-term-title\">\r\n {{ 'audience.term_ous' | translate: { count: term.ouIds.length } }}\r\n @if (term.includeDescendants) {\r\n <span class=\"fab-badge\">{{ 'audience.descendants_badge' | translate }}</span>\r\n }\r\n </span>\r\n <button type=\"button\" class=\"fab-icon-btn danger\" (click)=\"removeTerm(target, index)\"\r\n [attr.aria-label]=\"'audience.remove_term' | translate\">\r\n <i class=\"pi pi-times\" aria-hidden=\"true\"></i>\r\n </button>\r\n </div>\r\n <div class=\"fab-term-chips\">\r\n @for (id of term.ouIds; track id) {\r\n <span class=\"fab-chip\">\r\n <i class=\"pi pi-sitemap\" aria-hidden=\"true\"></i>\r\n <span>{{ ouLabel(id) }}</span>\r\n <button type=\"button\" class=\"fab-chip-x\" (click)=\"removeOuId(target, index, id)\"\r\n [attr.aria-label]=\"'audience.remove_term' | translate\">\r\n <i class=\"pi pi-times\" aria-hidden=\"true\"></i>\r\n </button>\r\n </span>\r\n }\r\n </div>\r\n }\r\n\r\n @case ('app-everyone') {\r\n <div class=\"fab-term-head\">\r\n <i class=\"pi pi-globe\" aria-hidden=\"true\"></i>\r\n <span class=\"fab-term-title\">\r\n {{ 'audience.term_app_everyone' | translate: { app: appLabel(term.appId) } }}\r\n </span>\r\n <button type=\"button\" class=\"fab-icon-btn danger\" (click)=\"removeTerm(target, index)\"\r\n [attr.aria-label]=\"'audience.remove_term' | translate\">\r\n <i class=\"pi pi-times\" aria-hidden=\"true\"></i>\r\n </button>\r\n </div>\r\n }\r\n\r\n @case ('chart') {\r\n <div class=\"fab-term-head\">\r\n <i class=\"pi pi-share-alt\" aria-hidden=\"true\"></i>\r\n <span class=\"fab-term-title\">\r\n {{ 'audience.term_chart' | translate: { chart: chartLabel(term.chartId) } }}\r\n </span>\r\n <button type=\"button\" class=\"fab-icon-btn danger\" (click)=\"removeTerm(target, index)\"\r\n [attr.aria-label]=\"'audience.remove_term' | translate\">\r\n <i class=\"pi pi-times\" aria-hidden=\"true\"></i>\r\n </button>\r\n </div>\r\n }\r\n\r\n @case ('preset') {\r\n <div class=\"fab-term-head\">\r\n <i class=\"pi pi-star\" aria-hidden=\"true\"></i>\r\n <span class=\"fab-term-title\">\r\n {{ presetLocaleKey(term.preset) | translate }}\r\n </span>\r\n <button type=\"button\" class=\"fab-icon-btn danger\" (click)=\"removeTerm(target, index)\"\r\n [attr.aria-label]=\"'audience.remove_term' | translate\">\r\n <i class=\"pi pi-times\" aria-hidden=\"true\"></i>\r\n </button>\r\n </div>\r\n }\r\n }\r\n </div>\r\n</ng-template>\r\n", styles: [".fab-root{display:flex;flex-direction:column;gap:14px;width:100%}.fab-bucket{background:var(--surface-card, #1e1e1e);border:1px solid var(--surface-border);border-radius:10px;padding:12px 14px}.fab-bucket.fab-bucket-exclude{border-inline-start:3px solid rgba(255,59,48,.55)}.fab-bucket-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:8px}.fab-section-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.5px;color:var(--text-color-secondary)}.fab-add-btn{display:inline-flex;align-items:center;gap:6px;background:var(--primary-color, #e8732a);color:#fff;border:none;border-radius:6px;padding:5px 10px;font-size:12px;cursor:pointer}.fab-add-btn:hover{filter:brightness(1.05)}.fab-add-btn i{font-size:11px}.fab-empty{text-align:center;padding:12px;color:var(--text-color-secondary);font-size:12px;font-style:italic}.fab-term-list{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:8px}.fab-term-card{background:#ffffff0a;border:1px solid rgba(255,255,255,.08);border-radius:8px;padding:8px 10px}.fab-term-card.fab-term-exclude{background:#ff3b300d;border-color:#ff3b302e}.fab-term-head{display:flex;align-items:center;gap:8px}.fab-term-head i:first-child{color:var(--text-color-secondary)}.fab-term-title{flex:1;font-size:13px;display:inline-flex;align-items:center;gap:6px}.fab-badge{font-size:10px;background:#ffffff1a;color:var(--text-color-secondary);padding:2px 6px;border-radius:8px;text-transform:uppercase;letter-spacing:.5px}.fab-term-chips{margin-top:6px;display:flex;flex-wrap:wrap;gap:4px}.fab-chip{display:inline-flex;align-items:center;gap:4px;background:#ffffff0f;border-radius:12px;padding:2px 4px 2px 8px;font-size:11px}.fab-chip i{font-size:10px;color:var(--text-color-secondary)}.fab-chip-x{background:none;border:none;color:var(--text-color-secondary);cursor:pointer;padding:2px 4px;border-radius:50%;display:inline-flex;align-items:center;justify-content:center}.fab-chip-x i{font-size:9px}.fab-chip-x:hover{background:#ff3b302e;color:#ff3b30}.fab-icon-btn{background:#ffffff0f;border:none;border-radius:6px;width:24px;height:24px;display:inline-flex;align-items:center;justify-content:center;color:inherit;cursor:pointer;font-size:11px}.fab-icon-btn:hover{background:#ffffff24}.fab-icon-btn.danger:hover{background:#ff3b3033;color:#ff3b30}.fab-picker{background:var(--surface-card, #1e1e1e);border:1px solid var(--surface-border);border-radius:10px;padding:12px 14px;display:flex;flex-direction:column;gap:10px;box-shadow:0 6px 18px #0000002e}.fab-picker-header{display:flex;align-items:center;justify-content:space-between}.fab-picker-target{font-size:12px;color:var(--text-color-secondary)}.fab-close{background:none;border:none;color:var(--text-color-secondary);cursor:pointer;font-size:14px;padding:4px}.fab-close:hover{color:var(--text-color)}.fab-kind-tabs{display:flex;flex-wrap:wrap;gap:4px;border-bottom:1px solid var(--surface-border);padding-bottom:6px}.fab-kind-tab{background:transparent;border:1px solid transparent;border-radius:6px;padding:6px 10px;font-size:12px;color:var(--text-color-secondary);cursor:pointer;display:inline-flex;align-items:center;gap:6px}.fab-kind-tab i{font-size:11px}.fab-kind-tab:hover{background:var(--surface-hover);color:var(--text-color)}.fab-kind-tab.active{background:#e8732a1f;border-color:var(--primary-color, #e8732a);color:var(--text-color)}.fab-picker-body{display:flex;flex-direction:column;gap:8px}.fab-input,.fab-select{background:#ffffff14;border:1px solid rgba(255,255,255,.15);border-radius:8px;padding:8px 12px;color:inherit;font-size:13px;outline:none}.fab-input:focus,.fab-select:focus{border-color:var(--primary-color, #e8732a)}.fab-mini-label{font-size:11px;color:var(--text-color-secondary)}.fab-result-list{max-height:200px;overflow-y:auto;border:1px solid var(--surface-border);border-radius:8px}.fab-result-list.fab-ou-tree{max-height:240px}button.fab-result-item{width:100%;background:transparent;border:none;color:inherit;font:inherit;text-align:start;appearance:none}.fab-result-item{display:flex;align-items:center;gap:8px;padding:6px 12px;cursor:pointer;font-size:13px}.fab-result-item:hover{background:var(--surface-hover)}.fab-result-item:focus-visible{outline:2px solid var(--primary-color, #e8732a);outline-offset:-2px}.fab-result-item i{color:var(--text-color-secondary)}.fab-result-secondary{color:var(--text-color-secondary);font-size:11px;margin-inline-start:auto;text-transform:uppercase;letter-spacing:.4px}.fab-checkbox-label{display:flex;align-items:center;gap:6px;font-size:12px;color:var(--text-color-secondary);cursor:pointer}.fab-checkbox-label input{accent-color:var(--primary-color, #e8732a)}.fab-inline-error{display:flex;align-items:center;gap:6px;background:#ff3b301a;color:#ff8a80;border-radius:6px;padding:6px 10px;font-size:12px}.fab-inline-error i{font-size:12px}\n"] }]
|
|
1706
|
+
}], propDecorators: { value: [{
|
|
1707
|
+
type: Input
|
|
1708
|
+
}], showExcludes: [{
|
|
1709
|
+
type: Input
|
|
1710
|
+
}], supportedKinds: [{
|
|
1711
|
+
type: Input
|
|
1712
|
+
}], selfUserId: [{
|
|
1713
|
+
type: Input
|
|
1714
|
+
}], appEveryoneOptions: [{
|
|
1715
|
+
type: Input
|
|
1716
|
+
}], searchUsers: [{
|
|
1717
|
+
type: Input,
|
|
1718
|
+
args: [{ required: true }]
|
|
1719
|
+
}], loadOuTree: [{
|
|
1720
|
+
type: Input,
|
|
1721
|
+
args: [{ required: true }]
|
|
1722
|
+
}], loadChartOptions: [{
|
|
1723
|
+
type: Input,
|
|
1724
|
+
args: [{ required: true }]
|
|
1725
|
+
}], loadRoleOus: [{
|
|
1726
|
+
type: Input
|
|
1727
|
+
}], audienceChange: [{
|
|
1728
|
+
type: Output
|
|
1729
|
+
}], validityChange: [{
|
|
1730
|
+
type: Output
|
|
1731
|
+
}] } });
|
|
1732
|
+
|
|
969
1733
|
/** Full-bleed loading overlay for window content (shell) or embedded hosts. */
|
|
970
1734
|
class FlyBlockUiComponent {
|
|
971
1735
|
/** When false, the overlay is not rendered (host may use @if instead). */
|
|
@@ -1627,12 +2391,56 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.5", ngImpor
|
|
|
1627
2391
|
* options resolve (no spurious null fetch). `loadOuLabelMap` supplies default-tree labels for OU rows.
|
|
1628
2392
|
* v1.8.0: `WindowInstance` optional `contentUiBlocked` / `contentUiBlockMessageKey`; `WindowManagerService`
|
|
1629
2393
|
* `beginContentUiBlock` / `endContentUiBlock` default no-ops; `FlyBlockUiComponent` for shell window overlay.
|
|
2394
|
+
* v1.10.0: SharePanelComponent — `supportsDeny` input + `isDeny?` on `SharePermissionEntry`. Surfaces an
|
|
2395
|
+
* "Add as deny" toggle in the add-permission row and renders existing deny grants with red
|
|
2396
|
+
* accent + ban icon. `grantToUser` / `grantToOu` callbacks gain a fourth `isDeny: boolean`
|
|
2397
|
+
* parameter (additive — existing 3-arg callers won't compile until updated). New locale keys
|
|
2398
|
+
* `files.share.add_as_deny`, `files.share.deny_help`, `files.share.denied` (en/ar/fr/ur).
|
|
2399
|
+
* Backed by Files Manager `FilePermission.IsDeny` and Notes `NoteShareLevel.Deny`.
|
|
2400
|
+
* v2.0.0: BREAKING — `SharePermissionEntry` adopts the `SharePrincipal` discriminated union
|
|
2401
|
+
* (`{ kind: 'user' | 'role' | 'ou' | 'app-everyone', ... }`) instead of the v1
|
|
2402
|
+
* `grantedToUserId` / `grantedToOuId` / `grantedToAppId` set-which-is-set fields.
|
|
2403
|
+
* Hosts must translate domain DTOs to/from the new shape (see
|
|
2404
|
+
* `file-access-control.component.ts` and `notes.component.ts` for migration patterns).
|
|
2405
|
+
* The `role` kind is reserved for the planned apps-chart role-as-principal flow;
|
|
2406
|
+
* hosts should not emit it until the backend's `PermissionResolver` enumerates a
|
|
2407
|
+
* user's full OU set (direct + computed-from-role memberships).
|
|
2408
|
+
* v2.1.0: AudienceBuilderComponent — generic <fly-audience-builder> form-control for composing
|
|
2409
|
+
* a `Fly.Shared.Core.Audience.AudienceFilter`. TypeScript model (`AudienceFilter`,
|
|
2410
|
+
* `AudienceTerm` discriminated union, `AUDIENCE_LIMITS`) mirrors the C# polymorphic
|
|
2411
|
+
* shape byte-for-byte (`kind` discriminator: users/roles/ou/app-everyone/chart/preset).
|
|
2412
|
+
* Reuses share-panel callback shapes (searchUsers, loadOuTree, loadChartOptions,
|
|
2413
|
+
* loadRoleOus). Distinct from share-panel: emits `(audienceChange)` + `(validityChange)`
|
|
2414
|
+
* instead of auto-persisting; hosts save on submit. New `audience.*` locale namespace.
|
|
2415
|
+
* v2.3.0: Deep-link launch contract — `LAUNCH_CONTEXT` injection token + `LaunchContext`
|
|
2416
|
+
* type for federated remotes to receive a route + params payload when the host
|
|
2417
|
+
* opens (or re-opens) their window via a `?app=&route=¶ms=` URL on the shell.
|
|
2418
|
+
* Token resolves to `Signal<LaunchContext | null>`; `null` when no host provider.
|
|
2419
|
+
* Backed by `Fly.Shared.Core.Apps.IDeepLinkBuilder` for outbound URL generation.
|
|
2420
|
+
* v2.2.0: AudienceBuilderComponent now implements `ControlValueAccessor` + `Validator` so
|
|
2421
|
+
* hosts can bind via `formControl` / `formControlName` / `[(ngModel)]`. Existing
|
|
2422
|
+
* `[value]` + `(audienceChange)` + `(validityChange)` API still works (forms wiring
|
|
2423
|
+
* is purely additive). `AUDIENCE_PRESETS` const exported for hosts that build their
|
|
2424
|
+
* own preset pickers. `OuTerm.chartId` and `OuTerm.includeDescendants` are now
|
|
2425
|
+
* REQUIRED (no longer optional) so wire round-trips are deterministic — emit explicit
|
|
2426
|
+
* `null` / `boolean` rather than `undefined`. New `AudienceErrorCodes` mirror added.
|
|
1630
2427
|
* See docs/ExternalAppsGuide/03-frontend-app.md.
|
|
1631
2428
|
*/
|
|
2429
|
+
/**
|
|
2430
|
+
* Stable error codes returned by audience-aware backend endpoints. Mirror of
|
|
2431
|
+
* `Fly.Shared.Core.Audience.AudienceErrorCodes`. Compare on these strings, not on HTTP
|
|
2432
|
+
* status, so localized messages can change without breaking integrations.
|
|
2433
|
+
*/
|
|
2434
|
+
const AUDIENCE_ERROR_CODES = {
|
|
2435
|
+
invalidFilter: 'INVALID_AUDIENCE_FILTER',
|
|
2436
|
+
audienceTooLarge: 'AUDIENCE_TOO_LARGE',
|
|
2437
|
+
resolverUnavailable: 'AUDIENCE_RESOLVER_UNAVAILABLE',
|
|
2438
|
+
invalidAudienceKind: 'INVALID_AUDIENCE_KIND',
|
|
2439
|
+
};
|
|
1632
2440
|
|
|
1633
2441
|
/**
|
|
1634
2442
|
* Generated bundle index. Do not edit.
|
|
1635
2443
|
*/
|
|
1636
2444
|
|
|
1637
|
-
export { AuthService, ContextMenuComponent, DEFAULT_FLY_THEME_MODE, DialogResult, FLY_THEME_MODE_IDS, FlyBlockUiComponent, FlyFileUploadComponent, FlyImageUploadComponent, FlyThemeService, I18nService, MessageBoxButtons, MessageBoxComponent, MessageBoxIcon, MessageBoxService, MockAuthService, RTL_LOCALE_SET, SHARE_PANEL_DEFAULT_FILE_LEVELS, SharePanelComponent, StandaloneWindowManagerService, TranslatePipe, WINDOW_DATA, WindowManagerService, isRtlLocale, normalizeFlyTheme };
|
|
2445
|
+
export { AUDIENCE_ERROR_CODES, AUDIENCE_LIMITS, AUDIENCE_PRESETS, AUDIENCE_TERM_KINDS, AudienceBuilderComponent, AuthService, ContextMenuComponent, DEFAULT_FLY_THEME_MODE, DialogResult, FLY_THEME_MODE_IDS, FlyBlockUiComponent, FlyFileUploadComponent, FlyImageUploadComponent, FlyThemeService, I18nService, LAUNCH_CONTEXT, MessageBoxButtons, MessageBoxComponent, MessageBoxIcon, MessageBoxService, MockAuthService, RTL_LOCALE_SET, SHARE_PANEL_DEFAULT_FILE_LEVELS, SharePanelComponent, StandaloneWindowManagerService, TranslatePipe, WINDOW_DATA, WindowManagerService, isRtlLocale, normalizeFlyTheme };
|
|
1638
2446
|
//# sourceMappingURL=mohamedatia-fly-design-system.mjs.map
|