@pl4yzonellc/valar-ui 1.0.1
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.
|
@@ -0,0 +1,751 @@
|
|
|
1
|
+
import '@angular/localize/init';
|
|
2
|
+
import * as i0 from '@angular/core';
|
|
3
|
+
import { InjectionToken, Inject, Injectable, EventEmitter, Output, Input, Component, NgModule } from '@angular/core';
|
|
4
|
+
import * as i1$1 from '@angular/common';
|
|
5
|
+
import { CommonModule } from '@angular/common';
|
|
6
|
+
import * as i2 from '@angular/forms';
|
|
7
|
+
import { FormsModule } from '@angular/forms';
|
|
8
|
+
import * as i1 from '@angular/common/http';
|
|
9
|
+
import { provideHttpClient } from '@angular/common/http';
|
|
10
|
+
import { map, Subject, takeUntil, finalize, of, delay, throwError } from 'rxjs';
|
|
11
|
+
|
|
12
|
+
/** Injection token for the library config */
|
|
13
|
+
const FEATURE_FLAGS_CONFIG = new InjectionToken('FEATURE_FLAGS_CONFIG');
|
|
14
|
+
/** Reserved keys that are NOT flag groups */
|
|
15
|
+
const RESERVED_KEYS = new Set([
|
|
16
|
+
'_id', 'tenantId', 'createdAt', 'updatedAt', '__v', 'id',
|
|
17
|
+
]);
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* HTTP service that talks to the Valar feature-flags backend.
|
|
21
|
+
* Base URL is injected via FEATURE_FLAGS_CONFIG so the consuming
|
|
22
|
+
* app controls where requests go.
|
|
23
|
+
*/
|
|
24
|
+
class FeatureFlagsApiService {
|
|
25
|
+
constructor(http, config) {
|
|
26
|
+
this.http = http;
|
|
27
|
+
const version = config.apiVersion ?? 'v1';
|
|
28
|
+
this.baseUrl = `${config.apiBaseUrl}/${version}/feature-flags`;
|
|
29
|
+
}
|
|
30
|
+
/** GET /v1/feature-flags/:tenantId */
|
|
31
|
+
getByTenantId(tenantId) {
|
|
32
|
+
return this.http.get(`${this.baseUrl}/${encodeURIComponent(tenantId)}`).pipe(map(doc => doc ?? null));
|
|
33
|
+
}
|
|
34
|
+
/** PUT /v1/feature-flags (merge-upsert) */
|
|
35
|
+
upsert(payload) {
|
|
36
|
+
return this.http.put(this.baseUrl, payload);
|
|
37
|
+
}
|
|
38
|
+
/** DELETE /v1/feature-flags/:tenantId/:group/:key */
|
|
39
|
+
deleteFlag(tenantId, group, key) {
|
|
40
|
+
const url = [
|
|
41
|
+
this.baseUrl,
|
|
42
|
+
encodeURIComponent(tenantId),
|
|
43
|
+
encodeURIComponent(group),
|
|
44
|
+
encodeURIComponent(key),
|
|
45
|
+
].join('/');
|
|
46
|
+
return this.http.delete(url);
|
|
47
|
+
}
|
|
48
|
+
/** DELETE /v1/feature-flags/:tenantId/:group */
|
|
49
|
+
deleteGroup(tenantId, group) {
|
|
50
|
+
const url = [
|
|
51
|
+
this.baseUrl,
|
|
52
|
+
encodeURIComponent(tenantId),
|
|
53
|
+
encodeURIComponent(group),
|
|
54
|
+
].join('/');
|
|
55
|
+
return this.http.delete(url);
|
|
56
|
+
}
|
|
57
|
+
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.0.5", ngImport: i0, type: FeatureFlagsApiService, deps: [{ token: i1.HttpClient }, { token: FEATURE_FLAGS_CONFIG }], target: i0.ɵɵFactoryTarget.Injectable }); }
|
|
58
|
+
static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.0.5", ngImport: i0, type: FeatureFlagsApiService }); }
|
|
59
|
+
}
|
|
60
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.5", ngImport: i0, type: FeatureFlagsApiService, decorators: [{
|
|
61
|
+
type: Injectable
|
|
62
|
+
}], ctorParameters: () => [{ type: i1.HttpClient }, { type: undefined, decorators: [{
|
|
63
|
+
type: Inject,
|
|
64
|
+
args: [FEATURE_FLAGS_CONFIG]
|
|
65
|
+
}] }] });
|
|
66
|
+
|
|
67
|
+
class FlagGroupCardComponent {
|
|
68
|
+
constructor() {
|
|
69
|
+
this.groupName = '';
|
|
70
|
+
this.flags = [];
|
|
71
|
+
this.dirtyKeys = new Set();
|
|
72
|
+
this.isAddingFlag = false;
|
|
73
|
+
this.newFlagKey = '';
|
|
74
|
+
this.newFlagValue = '';
|
|
75
|
+
this.newFlagType = 'string';
|
|
76
|
+
this.flagChanged = new EventEmitter();
|
|
77
|
+
this.revertFlag = new EventEmitter();
|
|
78
|
+
this.addFlagRequest = new EventEmitter();
|
|
79
|
+
this.cancelAddFlag = new EventEmitter();
|
|
80
|
+
this.confirmAddFlag = new EventEmitter();
|
|
81
|
+
this.newFlagKeyChange = new EventEmitter();
|
|
82
|
+
this.newFlagValueChange = new EventEmitter();
|
|
83
|
+
this.newFlagTypeChange = new EventEmitter();
|
|
84
|
+
this.deleteFlagRequest = new EventEmitter();
|
|
85
|
+
this.deleteGroupRequest = new EventEmitter();
|
|
86
|
+
this.collapsed = false;
|
|
87
|
+
this.confirmDeleteGroup = false;
|
|
88
|
+
this.confirmDeleteKey = null;
|
|
89
|
+
/** Which flag key is currently in inline-edit mode */
|
|
90
|
+
this.editingKey = null;
|
|
91
|
+
this.editValue = '';
|
|
92
|
+
this.editType = 'string';
|
|
93
|
+
}
|
|
94
|
+
isDirty(key) {
|
|
95
|
+
return this.dirtyKeys.has(key);
|
|
96
|
+
}
|
|
97
|
+
/** Enter inline-edit mode for a flag */
|
|
98
|
+
startEdit(flag) {
|
|
99
|
+
this.editingKey = flag.key;
|
|
100
|
+
this.editType = this.detectType(flag.value);
|
|
101
|
+
this.editValue = flag.value === null ? '' : String(flag.value);
|
|
102
|
+
}
|
|
103
|
+
/** Commit the inline edit — emits change to parent, stays in view mode */
|
|
104
|
+
commitEdit(flag) {
|
|
105
|
+
const newValue = this.coerceValue(this.editValue, this.editType);
|
|
106
|
+
this.flagChanged.emit({ key: flag.key, oldValue: flag.value, newValue });
|
|
107
|
+
this.editingKey = null;
|
|
108
|
+
}
|
|
109
|
+
/** Cancel inline edit without staging anything */
|
|
110
|
+
cancelEdit() {
|
|
111
|
+
this.editingKey = null;
|
|
112
|
+
}
|
|
113
|
+
displayValue(value) {
|
|
114
|
+
if (value === null)
|
|
115
|
+
return 'null';
|
|
116
|
+
if (typeof value === 'boolean')
|
|
117
|
+
return value ? 'true' : 'false';
|
|
118
|
+
return String(value);
|
|
119
|
+
}
|
|
120
|
+
typeBadgeClass(value) {
|
|
121
|
+
if (typeof value === 'boolean')
|
|
122
|
+
return 'bg-info text-dark';
|
|
123
|
+
if (typeof value === 'number')
|
|
124
|
+
return 'bg-warning text-dark';
|
|
125
|
+
if (value === null)
|
|
126
|
+
return 'bg-secondary';
|
|
127
|
+
return 'bg-primary';
|
|
128
|
+
}
|
|
129
|
+
typeLabel(value) {
|
|
130
|
+
if (value === null)
|
|
131
|
+
return 'null';
|
|
132
|
+
return typeof value;
|
|
133
|
+
}
|
|
134
|
+
isBooleanFlag(value) {
|
|
135
|
+
return typeof value === 'boolean';
|
|
136
|
+
}
|
|
137
|
+
trackFlag(_index, flag) {
|
|
138
|
+
return flag.key;
|
|
139
|
+
}
|
|
140
|
+
onDeleteFlag(key) {
|
|
141
|
+
if (this.confirmDeleteKey === key) {
|
|
142
|
+
this.deleteFlagRequest.emit(key);
|
|
143
|
+
this.confirmDeleteKey = null;
|
|
144
|
+
}
|
|
145
|
+
else {
|
|
146
|
+
this.confirmDeleteKey = key;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
onDeleteGroup() {
|
|
150
|
+
if (this.confirmDeleteGroup) {
|
|
151
|
+
this.deleteGroupRequest.emit();
|
|
152
|
+
this.confirmDeleteGroup = false;
|
|
153
|
+
}
|
|
154
|
+
else {
|
|
155
|
+
this.confirmDeleteGroup = true;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
detectType(value) {
|
|
159
|
+
if (typeof value === 'boolean')
|
|
160
|
+
return 'boolean';
|
|
161
|
+
if (typeof value === 'number')
|
|
162
|
+
return 'number';
|
|
163
|
+
return 'string';
|
|
164
|
+
}
|
|
165
|
+
coerceValue(raw, type) {
|
|
166
|
+
const trimmed = raw.trim();
|
|
167
|
+
if (trimmed === '' || trimmed.toLowerCase() === 'null')
|
|
168
|
+
return null;
|
|
169
|
+
switch (type) {
|
|
170
|
+
case 'boolean':
|
|
171
|
+
return trimmed.toLowerCase() === 'true';
|
|
172
|
+
case 'number': {
|
|
173
|
+
const n = Number(trimmed);
|
|
174
|
+
return isNaN(n) ? trimmed : n;
|
|
175
|
+
}
|
|
176
|
+
default:
|
|
177
|
+
return trimmed;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.0.5", ngImport: i0, type: FlagGroupCardComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
|
|
181
|
+
static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "21.0.5", type: FlagGroupCardComponent, isStandalone: false, selector: "valar-flag-group-card", inputs: { groupName: "groupName", flags: "flags", dirtyKeys: "dirtyKeys", isAddingFlag: "isAddingFlag", newFlagKey: "newFlagKey", newFlagValue: "newFlagValue", newFlagType: "newFlagType" }, outputs: { flagChanged: "flagChanged", revertFlag: "revertFlag", addFlagRequest: "addFlagRequest", cancelAddFlag: "cancelAddFlag", confirmAddFlag: "confirmAddFlag", newFlagKeyChange: "newFlagKeyChange", newFlagValueChange: "newFlagValueChange", newFlagTypeChange: "newFlagTypeChange", deleteFlagRequest: "deleteFlagRequest", deleteGroupRequest: "deleteGroupRequest" }, ngImport: i0, template: "<div class=\"card shadow-sm\">\n <!-- Card Header -->\n <div class=\"card-header d-flex justify-content-between align-items-center\">\n <div class=\"d-flex align-items-center\">\n <button\n class=\"btn btn-sm btn-link text-decoration-none p-0 me-2\"\n (click)=\"collapsed = !collapsed\"\n [attr.aria-expanded]=\"!collapsed\"\n [attr.aria-controls]=\"'group-' + groupName\"\n aria-label=\"Toggle group\">\n <i class=\"bi\" [ngClass]=\"collapsed ? 'bi-chevron-right' : 'bi-chevron-down'\" aria-hidden=\"true\"></i>\n </button>\n <h6 class=\"mb-0 fw-bold\">\n <i class=\"bi bi-folder2 me-1 text-primary\" aria-hidden=\"true\"></i>\n {{ groupName }}\n </h6>\n <span class=\"badge bg-secondary ms-2\">{{ flags.length }}</span>\n <span *ngIf=\"dirtyKeys.size > 0\" class=\"badge bg-warning text-dark ms-1\" title=\"Unsaved changes\">\n {{ dirtyKeys.size }} edited\n </span>\n </div>\n <div class=\"btn-group btn-group-sm\">\n <button\n class=\"btn btn-outline-primary btn-sm\"\n (click)=\"addFlagRequest.emit()\"\n title=\"Add flag to this group\"\n aria-label=\"Add flag\">\n <i class=\"bi bi-plus\" aria-hidden=\"true\"></i>\n </button>\n <button\n class=\"btn btn-sm\"\n [ngClass]=\"confirmDeleteGroup ? 'btn-danger' : 'btn-outline-danger'\"\n (click)=\"onDeleteGroup()\"\n [title]=\"confirmDeleteGroup ? 'Click again to confirm' : 'Delete entire group'\"\n [attr.aria-label]=\"confirmDeleteGroup ? 'Confirm delete group' : 'Delete group'\">\n <i class=\"bi\" [ngClass]=\"confirmDeleteGroup ? 'bi-exclamation-triangle' : 'bi-trash'\" aria-hidden=\"true\"></i>\n <span *ngIf=\"confirmDeleteGroup\" class=\"ms-1\">Confirm?</span>\n </button>\n </div>\n </div>\n\n <!-- Card Body -->\n <div [id]=\"'group-' + groupName\" *ngIf=\"!collapsed\">\n <div class=\"table-responsive\">\n <table class=\"table table-hover table-sm mb-0\" [attr.aria-label]=\"'Flags in ' + groupName\">\n <thead class=\"table-light\">\n <tr>\n <th scope=\"col\" style=\"width: 28%\">Key</th>\n <th scope=\"col\" style=\"width: 12%\">Type</th>\n <th scope=\"col\" style=\"width: 38%\">Value</th>\n <th scope=\"col\" style=\"width: 22%\" class=\"text-end\">Actions</th>\n </tr>\n </thead>\n <tbody>\n <tr *ngFor=\"let flag of flags; trackBy: trackFlag\"\n [class.row-dirty]=\"isDirty(flag.key)\">\n <!-- KEY -->\n <td class=\"align-middle\">\n <code>{{ flag.key }}</code>\n <i *ngIf=\"isDirty(flag.key)\" class=\"bi bi-pencil-fill text-warning ms-1 small\"\n title=\"Edited\" aria-hidden=\"true\"></i>\n </td>\n\n <!-- TYPE -->\n <td class=\"align-middle\">\n <span class=\"badge\" [ngClass]=\"typeBadgeClass(flag.value)\">\n {{ typeLabel(flag.value) }}\n </span>\n </td>\n\n <!-- VALUE (view or inline-edit) -->\n <td class=\"align-middle\">\n <ng-container *ngIf=\"editingKey !== flag.key; else editMode\">\n <!-- Boolean as ON/OFF badge -->\n <span *ngIf=\"isBooleanFlag(flag.value); else plainValue\">\n <span class=\"badge\" [ngClass]=\"flag.value ? 'bg-success' : 'bg-danger'\">\n {{ flag.value ? 'ON' : 'OFF' }}\n </span>\n </span>\n <ng-template #plainValue>\n <span class=\"text-break\">{{ displayValue(flag.value) }}</span>\n </ng-template>\n </ng-container>\n\n <!-- Inline edit mode -->\n <ng-template #editMode>\n <!-- Boolean: simple toggle, no type dropdown -->\n <ng-container *ngIf=\"editType === 'boolean'; else nonBooleanEdit\">\n <select\n class=\"form-select form-select-sm\"\n style=\"max-width: 120px\"\n [(ngModel)]=\"editValue\"\n aria-label=\"Flag value\">\n <option value=\"true\">true</option>\n <option value=\"false\">false</option>\n </select>\n </ng-container>\n <!-- Non-boolean: type dropdown + value input -->\n <ng-template #nonBooleanEdit>\n <div class=\"input-group input-group-sm\">\n <select\n class=\"form-select\"\n style=\"max-width: 100px\"\n [(ngModel)]=\"editType\"\n aria-label=\"Flag type\">\n <option value=\"string\">string</option>\n <option value=\"number\">number</option>\n </select>\n <input\n type=\"text\"\n class=\"form-control\"\n [(ngModel)]=\"editValue\"\n (keyup.enter)=\"commitEdit(flag)\"\n aria-label=\"Flag value\" />\n </div>\n </ng-template>\n </ng-template>\n </td>\n\n <!-- ACTIONS -->\n <td class=\"align-middle text-end\">\n <ng-container *ngIf=\"editingKey !== flag.key; else editActions\">\n <button\n class=\"btn btn-sm btn-outline-secondary me-1\"\n (click)=\"startEdit(flag)\"\n title=\"Edit flag\"\n aria-label=\"Edit flag\">\n <i class=\"bi bi-pencil\" aria-hidden=\"true\"></i>\n </button>\n <button *ngIf=\"isDirty(flag.key)\"\n class=\"btn btn-sm btn-outline-warning me-1\"\n (click)=\"revertFlag.emit(flag.key)\"\n title=\"Revert change\"\n aria-label=\"Revert change\">\n <i class=\"bi bi-arrow-counterclockwise\" aria-hidden=\"true\"></i>\n </button>\n <button\n class=\"btn btn-sm\"\n [ngClass]=\"confirmDeleteKey === flag.key ? 'btn-danger' : 'btn-outline-danger'\"\n (click)=\"onDeleteFlag(flag.key)\"\n [title]=\"confirmDeleteKey === flag.key ? 'Click again to confirm' : 'Delete flag'\"\n [attr.aria-label]=\"confirmDeleteKey === flag.key ? 'Confirm delete' : 'Delete flag'\">\n <i class=\"bi\" [ngClass]=\"confirmDeleteKey === flag.key ? 'bi-exclamation-triangle' : 'bi-trash'\" aria-hidden=\"true\"></i>\n </button>\n </ng-container>\n <ng-template #editActions>\n <button\n class=\"btn btn-sm btn-success me-1\"\n (click)=\"commitEdit(flag)\"\n title=\"Stage change\"\n aria-label=\"Stage change\">\n <i class=\"bi bi-check-lg\" aria-hidden=\"true\"></i>\n </button>\n <button\n class=\"btn btn-sm btn-outline-secondary\"\n (click)=\"cancelEdit()\"\n title=\"Cancel\"\n aria-label=\"Cancel edit\">\n <i class=\"bi bi-x-lg\" aria-hidden=\"true\"></i>\n </button>\n </ng-template>\n </td>\n </tr>\n\n <!-- Empty row -->\n <tr *ngIf=\"flags.length === 0 && !isAddingFlag\">\n <td colspan=\"4\" class=\"text-center text-muted py-3\">\n No flags in this group.\n </td>\n </tr>\n </tbody>\n </table>\n </div>\n\n <!-- Add Flag Form -->\n <div *ngIf=\"isAddingFlag\" class=\"card-footer bg-light\">\n <h6 class=\"text-success mb-2\">\n <i class=\"bi bi-plus-circle me-1\" aria-hidden=\"true\"></i>Add Flag to \"{{ groupName }}\"\n </h6>\n <div class=\"row g-2 align-items-end\">\n <div class=\"col-md-3\">\n <label class=\"form-label form-label-sm\">Key</label>\n <input\n type=\"text\"\n class=\"form-control form-control-sm\"\n placeholder=\"flagKey\"\n [ngModel]=\"newFlagKey\"\n (ngModelChange)=\"newFlagKeyChange.emit($event)\"\n (keyup.enter)=\"confirmAddFlag.emit()\"\n aria-label=\"New flag key\" />\n </div>\n <div class=\"col-md-2\">\n <label class=\"form-label form-label-sm\">Type</label>\n <select\n class=\"form-select form-select-sm\"\n [ngModel]=\"newFlagType\"\n (ngModelChange)=\"newFlagTypeChange.emit($event)\"\n aria-label=\"New flag type\">\n <option value=\"string\">string</option>\n <option value=\"boolean\">boolean</option>\n <option value=\"number\">number</option>\n </select>\n </div>\n <div class=\"col-md-3\">\n <label class=\"form-label form-label-sm\">Value</label>\n <ng-container *ngIf=\"newFlagType === 'boolean'; else newTextInput\">\n <select\n class=\"form-select form-select-sm\"\n [ngModel]=\"newFlagValue\"\n (ngModelChange)=\"newFlagValueChange.emit($event)\"\n aria-label=\"New flag value\">\n <option value=\"true\">true</option>\n <option value=\"false\">false</option>\n </select>\n </ng-container>\n <ng-template #newTextInput>\n <input\n type=\"text\"\n class=\"form-control form-control-sm\"\n placeholder=\"value\"\n [ngModel]=\"newFlagValue\"\n (ngModelChange)=\"newFlagValueChange.emit($event)\"\n (keyup.enter)=\"confirmAddFlag.emit()\"\n aria-label=\"New flag value\" />\n </ng-template>\n </div>\n <div class=\"col-md-4\">\n <button class=\"btn btn-success btn-sm me-1\" (click)=\"confirmAddFlag.emit()\" [disabled]=\"!newFlagKey.trim()\">\n <i class=\"bi bi-check-lg me-1\" aria-hidden=\"true\"></i>Stage\n </button>\n <button class=\"btn btn-outline-secondary btn-sm\" (click)=\"cancelAddFlag.emit()\">\n Cancel\n </button>\n </div>\n </div>\n </div>\n </div>\n</div>\n", styles: [":host{display:block}.card-header{background-color:#f8f9fa}table code{font-size:.85em;color:#6f42c1}.badge{font-size:.75em}.row-dirty{background-color:#fff8e1!important;border-left:3px solid #ffc107}.row-dirty:hover{background-color:#fff3cd!important}\n"], dependencies: [{ kind: "directive", type: i1$1.NgClass, selector: "[ngClass]", inputs: ["class", "ngClass"] }, { kind: "directive", type: i1$1.NgForOf, selector: "[ngFor][ngForOf]", inputs: ["ngForOf", "ngForTrackBy", "ngForTemplate"] }, { kind: "directive", type: i1$1.NgIf, selector: "[ngIf]", inputs: ["ngIf", "ngIfThen", "ngIfElse"] }, { kind: "directive", type: i2.NgSelectOption, selector: "option", inputs: ["ngValue", "value"] }, { kind: "directive", type: i2.ɵNgSelectMultipleOption, selector: "option", inputs: ["ngValue", "value"] }, { kind: "directive", type: i2.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: i2.SelectControlValueAccessor, selector: "select:not([multiple])[formControlName],select:not([multiple])[formControl],select:not([multiple])[ngModel]", inputs: ["compareWith"] }, { kind: "directive", type: i2.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i2.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }] }); }
|
|
182
|
+
}
|
|
183
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.5", ngImport: i0, type: FlagGroupCardComponent, decorators: [{
|
|
184
|
+
type: Component,
|
|
185
|
+
args: [{ selector: 'valar-flag-group-card', standalone: false, template: "<div class=\"card shadow-sm\">\n <!-- Card Header -->\n <div class=\"card-header d-flex justify-content-between align-items-center\">\n <div class=\"d-flex align-items-center\">\n <button\n class=\"btn btn-sm btn-link text-decoration-none p-0 me-2\"\n (click)=\"collapsed = !collapsed\"\n [attr.aria-expanded]=\"!collapsed\"\n [attr.aria-controls]=\"'group-' + groupName\"\n aria-label=\"Toggle group\">\n <i class=\"bi\" [ngClass]=\"collapsed ? 'bi-chevron-right' : 'bi-chevron-down'\" aria-hidden=\"true\"></i>\n </button>\n <h6 class=\"mb-0 fw-bold\">\n <i class=\"bi bi-folder2 me-1 text-primary\" aria-hidden=\"true\"></i>\n {{ groupName }}\n </h6>\n <span class=\"badge bg-secondary ms-2\">{{ flags.length }}</span>\n <span *ngIf=\"dirtyKeys.size > 0\" class=\"badge bg-warning text-dark ms-1\" title=\"Unsaved changes\">\n {{ dirtyKeys.size }} edited\n </span>\n </div>\n <div class=\"btn-group btn-group-sm\">\n <button\n class=\"btn btn-outline-primary btn-sm\"\n (click)=\"addFlagRequest.emit()\"\n title=\"Add flag to this group\"\n aria-label=\"Add flag\">\n <i class=\"bi bi-plus\" aria-hidden=\"true\"></i>\n </button>\n <button\n class=\"btn btn-sm\"\n [ngClass]=\"confirmDeleteGroup ? 'btn-danger' : 'btn-outline-danger'\"\n (click)=\"onDeleteGroup()\"\n [title]=\"confirmDeleteGroup ? 'Click again to confirm' : 'Delete entire group'\"\n [attr.aria-label]=\"confirmDeleteGroup ? 'Confirm delete group' : 'Delete group'\">\n <i class=\"bi\" [ngClass]=\"confirmDeleteGroup ? 'bi-exclamation-triangle' : 'bi-trash'\" aria-hidden=\"true\"></i>\n <span *ngIf=\"confirmDeleteGroup\" class=\"ms-1\">Confirm?</span>\n </button>\n </div>\n </div>\n\n <!-- Card Body -->\n <div [id]=\"'group-' + groupName\" *ngIf=\"!collapsed\">\n <div class=\"table-responsive\">\n <table class=\"table table-hover table-sm mb-0\" [attr.aria-label]=\"'Flags in ' + groupName\">\n <thead class=\"table-light\">\n <tr>\n <th scope=\"col\" style=\"width: 28%\">Key</th>\n <th scope=\"col\" style=\"width: 12%\">Type</th>\n <th scope=\"col\" style=\"width: 38%\">Value</th>\n <th scope=\"col\" style=\"width: 22%\" class=\"text-end\">Actions</th>\n </tr>\n </thead>\n <tbody>\n <tr *ngFor=\"let flag of flags; trackBy: trackFlag\"\n [class.row-dirty]=\"isDirty(flag.key)\">\n <!-- KEY -->\n <td class=\"align-middle\">\n <code>{{ flag.key }}</code>\n <i *ngIf=\"isDirty(flag.key)\" class=\"bi bi-pencil-fill text-warning ms-1 small\"\n title=\"Edited\" aria-hidden=\"true\"></i>\n </td>\n\n <!-- TYPE -->\n <td class=\"align-middle\">\n <span class=\"badge\" [ngClass]=\"typeBadgeClass(flag.value)\">\n {{ typeLabel(flag.value) }}\n </span>\n </td>\n\n <!-- VALUE (view or inline-edit) -->\n <td class=\"align-middle\">\n <ng-container *ngIf=\"editingKey !== flag.key; else editMode\">\n <!-- Boolean as ON/OFF badge -->\n <span *ngIf=\"isBooleanFlag(flag.value); else plainValue\">\n <span class=\"badge\" [ngClass]=\"flag.value ? 'bg-success' : 'bg-danger'\">\n {{ flag.value ? 'ON' : 'OFF' }}\n </span>\n </span>\n <ng-template #plainValue>\n <span class=\"text-break\">{{ displayValue(flag.value) }}</span>\n </ng-template>\n </ng-container>\n\n <!-- Inline edit mode -->\n <ng-template #editMode>\n <!-- Boolean: simple toggle, no type dropdown -->\n <ng-container *ngIf=\"editType === 'boolean'; else nonBooleanEdit\">\n <select\n class=\"form-select form-select-sm\"\n style=\"max-width: 120px\"\n [(ngModel)]=\"editValue\"\n aria-label=\"Flag value\">\n <option value=\"true\">true</option>\n <option value=\"false\">false</option>\n </select>\n </ng-container>\n <!-- Non-boolean: type dropdown + value input -->\n <ng-template #nonBooleanEdit>\n <div class=\"input-group input-group-sm\">\n <select\n class=\"form-select\"\n style=\"max-width: 100px\"\n [(ngModel)]=\"editType\"\n aria-label=\"Flag type\">\n <option value=\"string\">string</option>\n <option value=\"number\">number</option>\n </select>\n <input\n type=\"text\"\n class=\"form-control\"\n [(ngModel)]=\"editValue\"\n (keyup.enter)=\"commitEdit(flag)\"\n aria-label=\"Flag value\" />\n </div>\n </ng-template>\n </ng-template>\n </td>\n\n <!-- ACTIONS -->\n <td class=\"align-middle text-end\">\n <ng-container *ngIf=\"editingKey !== flag.key; else editActions\">\n <button\n class=\"btn btn-sm btn-outline-secondary me-1\"\n (click)=\"startEdit(flag)\"\n title=\"Edit flag\"\n aria-label=\"Edit flag\">\n <i class=\"bi bi-pencil\" aria-hidden=\"true\"></i>\n </button>\n <button *ngIf=\"isDirty(flag.key)\"\n class=\"btn btn-sm btn-outline-warning me-1\"\n (click)=\"revertFlag.emit(flag.key)\"\n title=\"Revert change\"\n aria-label=\"Revert change\">\n <i class=\"bi bi-arrow-counterclockwise\" aria-hidden=\"true\"></i>\n </button>\n <button\n class=\"btn btn-sm\"\n [ngClass]=\"confirmDeleteKey === flag.key ? 'btn-danger' : 'btn-outline-danger'\"\n (click)=\"onDeleteFlag(flag.key)\"\n [title]=\"confirmDeleteKey === flag.key ? 'Click again to confirm' : 'Delete flag'\"\n [attr.aria-label]=\"confirmDeleteKey === flag.key ? 'Confirm delete' : 'Delete flag'\">\n <i class=\"bi\" [ngClass]=\"confirmDeleteKey === flag.key ? 'bi-exclamation-triangle' : 'bi-trash'\" aria-hidden=\"true\"></i>\n </button>\n </ng-container>\n <ng-template #editActions>\n <button\n class=\"btn btn-sm btn-success me-1\"\n (click)=\"commitEdit(flag)\"\n title=\"Stage change\"\n aria-label=\"Stage change\">\n <i class=\"bi bi-check-lg\" aria-hidden=\"true\"></i>\n </button>\n <button\n class=\"btn btn-sm btn-outline-secondary\"\n (click)=\"cancelEdit()\"\n title=\"Cancel\"\n aria-label=\"Cancel edit\">\n <i class=\"bi bi-x-lg\" aria-hidden=\"true\"></i>\n </button>\n </ng-template>\n </td>\n </tr>\n\n <!-- Empty row -->\n <tr *ngIf=\"flags.length === 0 && !isAddingFlag\">\n <td colspan=\"4\" class=\"text-center text-muted py-3\">\n No flags in this group.\n </td>\n </tr>\n </tbody>\n </table>\n </div>\n\n <!-- Add Flag Form -->\n <div *ngIf=\"isAddingFlag\" class=\"card-footer bg-light\">\n <h6 class=\"text-success mb-2\">\n <i class=\"bi bi-plus-circle me-1\" aria-hidden=\"true\"></i>Add Flag to \"{{ groupName }}\"\n </h6>\n <div class=\"row g-2 align-items-end\">\n <div class=\"col-md-3\">\n <label class=\"form-label form-label-sm\">Key</label>\n <input\n type=\"text\"\n class=\"form-control form-control-sm\"\n placeholder=\"flagKey\"\n [ngModel]=\"newFlagKey\"\n (ngModelChange)=\"newFlagKeyChange.emit($event)\"\n (keyup.enter)=\"confirmAddFlag.emit()\"\n aria-label=\"New flag key\" />\n </div>\n <div class=\"col-md-2\">\n <label class=\"form-label form-label-sm\">Type</label>\n <select\n class=\"form-select form-select-sm\"\n [ngModel]=\"newFlagType\"\n (ngModelChange)=\"newFlagTypeChange.emit($event)\"\n aria-label=\"New flag type\">\n <option value=\"string\">string</option>\n <option value=\"boolean\">boolean</option>\n <option value=\"number\">number</option>\n </select>\n </div>\n <div class=\"col-md-3\">\n <label class=\"form-label form-label-sm\">Value</label>\n <ng-container *ngIf=\"newFlagType === 'boolean'; else newTextInput\">\n <select\n class=\"form-select form-select-sm\"\n [ngModel]=\"newFlagValue\"\n (ngModelChange)=\"newFlagValueChange.emit($event)\"\n aria-label=\"New flag value\">\n <option value=\"true\">true</option>\n <option value=\"false\">false</option>\n </select>\n </ng-container>\n <ng-template #newTextInput>\n <input\n type=\"text\"\n class=\"form-control form-control-sm\"\n placeholder=\"value\"\n [ngModel]=\"newFlagValue\"\n (ngModelChange)=\"newFlagValueChange.emit($event)\"\n (keyup.enter)=\"confirmAddFlag.emit()\"\n aria-label=\"New flag value\" />\n </ng-template>\n </div>\n <div class=\"col-md-4\">\n <button class=\"btn btn-success btn-sm me-1\" (click)=\"confirmAddFlag.emit()\" [disabled]=\"!newFlagKey.trim()\">\n <i class=\"bi bi-check-lg me-1\" aria-hidden=\"true\"></i>Stage\n </button>\n <button class=\"btn btn-outline-secondary btn-sm\" (click)=\"cancelAddFlag.emit()\">\n Cancel\n </button>\n </div>\n </div>\n </div>\n </div>\n</div>\n", styles: [":host{display:block}.card-header{background-color:#f8f9fa}table code{font-size:.85em;color:#6f42c1}.badge{font-size:.75em}.row-dirty{background-color:#fff8e1!important;border-left:3px solid #ffc107}.row-dirty:hover{background-color:#fff3cd!important}\n"] }]
|
|
186
|
+
}], propDecorators: { groupName: [{
|
|
187
|
+
type: Input
|
|
188
|
+
}], flags: [{
|
|
189
|
+
type: Input
|
|
190
|
+
}], dirtyKeys: [{
|
|
191
|
+
type: Input
|
|
192
|
+
}], isAddingFlag: [{
|
|
193
|
+
type: Input
|
|
194
|
+
}], newFlagKey: [{
|
|
195
|
+
type: Input
|
|
196
|
+
}], newFlagValue: [{
|
|
197
|
+
type: Input
|
|
198
|
+
}], newFlagType: [{
|
|
199
|
+
type: Input
|
|
200
|
+
}], flagChanged: [{
|
|
201
|
+
type: Output
|
|
202
|
+
}], revertFlag: [{
|
|
203
|
+
type: Output
|
|
204
|
+
}], addFlagRequest: [{
|
|
205
|
+
type: Output
|
|
206
|
+
}], cancelAddFlag: [{
|
|
207
|
+
type: Output
|
|
208
|
+
}], confirmAddFlag: [{
|
|
209
|
+
type: Output
|
|
210
|
+
}], newFlagKeyChange: [{
|
|
211
|
+
type: Output
|
|
212
|
+
}], newFlagValueChange: [{
|
|
213
|
+
type: Output
|
|
214
|
+
}], newFlagTypeChange: [{
|
|
215
|
+
type: Output
|
|
216
|
+
}], deleteFlagRequest: [{
|
|
217
|
+
type: Output
|
|
218
|
+
}], deleteGroupRequest: [{
|
|
219
|
+
type: Output
|
|
220
|
+
}] } });
|
|
221
|
+
|
|
222
|
+
class ChangesSummaryModalComponent {
|
|
223
|
+
constructor() {
|
|
224
|
+
this.changes = [];
|
|
225
|
+
this.tenantId = '';
|
|
226
|
+
this.confirm = new EventEmitter();
|
|
227
|
+
this.dismiss = new EventEmitter();
|
|
228
|
+
}
|
|
229
|
+
displayValue(value) {
|
|
230
|
+
if (value === null)
|
|
231
|
+
return 'null';
|
|
232
|
+
if (typeof value === 'boolean')
|
|
233
|
+
return value ? 'true' : 'false';
|
|
234
|
+
return String(value);
|
|
235
|
+
}
|
|
236
|
+
typeLabel(value) {
|
|
237
|
+
if (value === null)
|
|
238
|
+
return 'null';
|
|
239
|
+
return typeof value;
|
|
240
|
+
}
|
|
241
|
+
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.0.5", ngImport: i0, type: ChangesSummaryModalComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
|
|
242
|
+
static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "21.0.5", type: ChangesSummaryModalComponent, isStandalone: false, selector: "valar-changes-summary-modal", inputs: { changes: "changes", tenantId: "tenantId" }, outputs: { confirm: "confirm", dismiss: "dismiss" }, ngImport: i0, template: "<!-- Backdrop -->\n<div class=\"modal-backdrop fade show\" (click)=\"dismiss.emit()\"></div>\n\n<!-- Modal -->\n<div class=\"modal fade show d-block\" tabindex=\"-1\" role=\"dialog\" aria-labelledby=\"changesSummaryTitle\" aria-modal=\"true\">\n <div class=\"modal-dialog modal-lg modal-dialog-centered modal-dialog-scrollable\">\n <div class=\"modal-content\">\n <!-- Header -->\n <div class=\"modal-header bg-primary text-white\">\n <h5 class=\"modal-title\" id=\"changesSummaryTitle\">\n <i class=\"bi bi-clipboard-check me-2\" aria-hidden=\"true\"></i>\n Review Changes\n </h5>\n <button type=\"button\" class=\"btn-close btn-close-white\" aria-label=\"Close\" (click)=\"dismiss.emit()\"></button>\n </div>\n\n <!-- Body -->\n <div class=\"modal-body\">\n <p class=\"text-muted mb-3\">\n You're about to update <strong>{{ changes.length }}</strong>\n flag{{ changes.length !== 1 ? 's' : '' }} for tenant\n <code>{{ tenantId }}</code>.\n </p>\n\n <div class=\"table-responsive\">\n <table class=\"table table-sm table-bordered mb-0\" aria-label=\"Pending changes summary\">\n <thead class=\"table-light\">\n <tr>\n <th scope=\"col\">Group</th>\n <th scope=\"col\">Key</th>\n <th scope=\"col\">Old Value</th>\n <th scope=\"col\" class=\"text-center\" style=\"width: 40px\">\n <i class=\"bi bi-arrow-right\" aria-hidden=\"true\"></i>\n </th>\n <th scope=\"col\">New Value</th>\n </tr>\n </thead>\n <tbody>\n <tr *ngFor=\"let change of changes\">\n <td>\n <i class=\"bi bi-folder2 me-1 text-primary\" aria-hidden=\"true\"></i>\n {{ change.group }}\n </td>\n <td><code>{{ change.key }}</code></td>\n <td>\n <span class=\"text-danger text-decoration-line-through\">\n {{ displayValue(change.oldValue) }}\n </span>\n <span class=\"badge bg-secondary ms-1\">{{ typeLabel(change.oldValue) }}</span>\n <span *ngIf=\"change.isNew\" class=\"badge bg-success ms-1\">NEW</span>\n </td>\n <td class=\"text-center text-muted\">\n <i class=\"bi bi-arrow-right\" aria-hidden=\"true\"></i>\n </td>\n <td>\n <span class=\"fw-semibold text-success\">\n {{ displayValue(change.newValue) }}\n </span>\n <span class=\"badge bg-secondary ms-1\">{{ typeLabel(change.newValue) }}</span>\n </td>\n </tr>\n </tbody>\n </table>\n </div>\n </div>\n\n <!-- Footer -->\n <div class=\"modal-footer\">\n <button type=\"button\" class=\"btn btn-outline-secondary\" (click)=\"dismiss.emit()\">\n <i class=\"bi bi-x-lg me-1\" aria-hidden=\"true\"></i>Cancel\n </button>\n <button type=\"button\" class=\"btn btn-primary\" (click)=\"confirm.emit()\">\n <i class=\"bi bi-check-lg me-1\" aria-hidden=\"true\"></i>Looks Good\n </button>\n </div>\n </div>\n </div>\n</div>\n", styles: [":host{display:block}.modal-backdrop{z-index:1040}.modal{z-index:1050}.text-decoration-line-through{text-decoration:line-through}\n"], dependencies: [{ kind: "directive", type: i1$1.NgForOf, selector: "[ngFor][ngForOf]", inputs: ["ngForOf", "ngForTrackBy", "ngForTemplate"] }, { kind: "directive", type: i1$1.NgIf, selector: "[ngIf]", inputs: ["ngIf", "ngIfThen", "ngIfElse"] }] }); }
|
|
243
|
+
}
|
|
244
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.5", ngImport: i0, type: ChangesSummaryModalComponent, decorators: [{
|
|
245
|
+
type: Component,
|
|
246
|
+
args: [{ selector: 'valar-changes-summary-modal', standalone: false, template: "<!-- Backdrop -->\n<div class=\"modal-backdrop fade show\" (click)=\"dismiss.emit()\"></div>\n\n<!-- Modal -->\n<div class=\"modal fade show d-block\" tabindex=\"-1\" role=\"dialog\" aria-labelledby=\"changesSummaryTitle\" aria-modal=\"true\">\n <div class=\"modal-dialog modal-lg modal-dialog-centered modal-dialog-scrollable\">\n <div class=\"modal-content\">\n <!-- Header -->\n <div class=\"modal-header bg-primary text-white\">\n <h5 class=\"modal-title\" id=\"changesSummaryTitle\">\n <i class=\"bi bi-clipboard-check me-2\" aria-hidden=\"true\"></i>\n Review Changes\n </h5>\n <button type=\"button\" class=\"btn-close btn-close-white\" aria-label=\"Close\" (click)=\"dismiss.emit()\"></button>\n </div>\n\n <!-- Body -->\n <div class=\"modal-body\">\n <p class=\"text-muted mb-3\">\n You're about to update <strong>{{ changes.length }}</strong>\n flag{{ changes.length !== 1 ? 's' : '' }} for tenant\n <code>{{ tenantId }}</code>.\n </p>\n\n <div class=\"table-responsive\">\n <table class=\"table table-sm table-bordered mb-0\" aria-label=\"Pending changes summary\">\n <thead class=\"table-light\">\n <tr>\n <th scope=\"col\">Group</th>\n <th scope=\"col\">Key</th>\n <th scope=\"col\">Old Value</th>\n <th scope=\"col\" class=\"text-center\" style=\"width: 40px\">\n <i class=\"bi bi-arrow-right\" aria-hidden=\"true\"></i>\n </th>\n <th scope=\"col\">New Value</th>\n </tr>\n </thead>\n <tbody>\n <tr *ngFor=\"let change of changes\">\n <td>\n <i class=\"bi bi-folder2 me-1 text-primary\" aria-hidden=\"true\"></i>\n {{ change.group }}\n </td>\n <td><code>{{ change.key }}</code></td>\n <td>\n <span class=\"text-danger text-decoration-line-through\">\n {{ displayValue(change.oldValue) }}\n </span>\n <span class=\"badge bg-secondary ms-1\">{{ typeLabel(change.oldValue) }}</span>\n <span *ngIf=\"change.isNew\" class=\"badge bg-success ms-1\">NEW</span>\n </td>\n <td class=\"text-center text-muted\">\n <i class=\"bi bi-arrow-right\" aria-hidden=\"true\"></i>\n </td>\n <td>\n <span class=\"fw-semibold text-success\">\n {{ displayValue(change.newValue) }}\n </span>\n <span class=\"badge bg-secondary ms-1\">{{ typeLabel(change.newValue) }}</span>\n </td>\n </tr>\n </tbody>\n </table>\n </div>\n </div>\n\n <!-- Footer -->\n <div class=\"modal-footer\">\n <button type=\"button\" class=\"btn btn-outline-secondary\" (click)=\"dismiss.emit()\">\n <i class=\"bi bi-x-lg me-1\" aria-hidden=\"true\"></i>Cancel\n </button>\n <button type=\"button\" class=\"btn btn-primary\" (click)=\"confirm.emit()\">\n <i class=\"bi bi-check-lg me-1\" aria-hidden=\"true\"></i>Looks Good\n </button>\n </div>\n </div>\n </div>\n</div>\n", styles: [":host{display:block}.modal-backdrop{z-index:1040}.modal{z-index:1050}.text-decoration-line-through{text-decoration:line-through}\n"] }]
|
|
247
|
+
}], propDecorators: { changes: [{
|
|
248
|
+
type: Input
|
|
249
|
+
}], tenantId: [{
|
|
250
|
+
type: Input
|
|
251
|
+
}], confirm: [{
|
|
252
|
+
type: Output
|
|
253
|
+
}], dismiss: [{
|
|
254
|
+
type: Output
|
|
255
|
+
}] } });
|
|
256
|
+
|
|
257
|
+
class FeatureFlagsManagerComponent {
|
|
258
|
+
constructor(api) {
|
|
259
|
+
this.api = api;
|
|
260
|
+
this.tenantId = '';
|
|
261
|
+
this.groups = [];
|
|
262
|
+
this.loading = false;
|
|
263
|
+
this.error = null;
|
|
264
|
+
this.successMsg = null;
|
|
265
|
+
this.loaded = false;
|
|
266
|
+
// Add-group form
|
|
267
|
+
this.showAddGroup = false;
|
|
268
|
+
this.newGroupName = '';
|
|
269
|
+
// Add-flag form (per group)
|
|
270
|
+
this.addingFlagToGroup = null;
|
|
271
|
+
this.newFlagKey = '';
|
|
272
|
+
this.newFlagValue = '';
|
|
273
|
+
this.newFlagType = 'string';
|
|
274
|
+
// ── Batch-edit state ──────────────────────────────────────────────
|
|
275
|
+
/**
|
|
276
|
+
* Pending changes keyed by "group.key".
|
|
277
|
+
* Stores old/new values so we can display a diff summary.
|
|
278
|
+
*/
|
|
279
|
+
this.pendingChanges = new Map();
|
|
280
|
+
this.showSummaryModal = false;
|
|
281
|
+
this.destroy$ = new Subject();
|
|
282
|
+
}
|
|
283
|
+
ngOnDestroy() {
|
|
284
|
+
this.destroy$.next();
|
|
285
|
+
this.destroy$.complete();
|
|
286
|
+
}
|
|
287
|
+
// ── Computed helpers ──────────────────────────────────────────────
|
|
288
|
+
get hasPendingChanges() {
|
|
289
|
+
return this.pendingChanges.size > 0;
|
|
290
|
+
}
|
|
291
|
+
get pendingChangeCount() {
|
|
292
|
+
return this.pendingChanges.size;
|
|
293
|
+
}
|
|
294
|
+
get pendingChangesList() {
|
|
295
|
+
return Array.from(this.pendingChanges.values());
|
|
296
|
+
}
|
|
297
|
+
/** Returns the set of dirty keys for a given group */
|
|
298
|
+
dirtyKeysForGroup(groupName) {
|
|
299
|
+
const keys = new Set();
|
|
300
|
+
for (const [compositeKey] of this.pendingChanges) {
|
|
301
|
+
const [g, k] = this.splitCompositeKey(compositeKey);
|
|
302
|
+
if (g === groupName)
|
|
303
|
+
keys.add(k);
|
|
304
|
+
}
|
|
305
|
+
return keys;
|
|
306
|
+
}
|
|
307
|
+
// ── Load ──────────────────────────────────────────────────────────
|
|
308
|
+
loadFlags() {
|
|
309
|
+
const id = this.tenantId.trim();
|
|
310
|
+
if (!id) {
|
|
311
|
+
this.error = 'Please enter a Tenant ID.';
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
this.clearMessages();
|
|
315
|
+
this.pendingChanges.clear();
|
|
316
|
+
this.loading = true;
|
|
317
|
+
this.api.getByTenantId(id)
|
|
318
|
+
.pipe(takeUntil(this.destroy$), finalize(() => this.loading = false))
|
|
319
|
+
.subscribe({
|
|
320
|
+
next: (doc) => {
|
|
321
|
+
this.parseDocument(doc);
|
|
322
|
+
this.loaded = true;
|
|
323
|
+
if (!doc || this.groups.length === 0) {
|
|
324
|
+
this.successMsg = 'No flags found for this tenant yet. Start adding some!';
|
|
325
|
+
}
|
|
326
|
+
},
|
|
327
|
+
error: (err) => {
|
|
328
|
+
this.error = err.error?.message ?? 'Failed to load flags.';
|
|
329
|
+
},
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
// ── Stage / unstage edits ─────────────────────────────────────────
|
|
333
|
+
/** Called when a flag value is changed inline */
|
|
334
|
+
onFlagChanged(groupName, event) {
|
|
335
|
+
const compositeKey = this.makeCompositeKey(groupName, event.key);
|
|
336
|
+
// If there's already a pending change, preserve the ORIGINAL old value
|
|
337
|
+
const existing = this.pendingChanges.get(compositeKey);
|
|
338
|
+
const originalOld = existing ? existing.oldValue : event.oldValue;
|
|
339
|
+
// If user reverted back to original value, unstage it
|
|
340
|
+
if (this.valuesEqual(originalOld, event.newValue)) {
|
|
341
|
+
this.pendingChanges.delete(compositeKey);
|
|
342
|
+
}
|
|
343
|
+
else {
|
|
344
|
+
this.pendingChanges.set(compositeKey, {
|
|
345
|
+
group: groupName,
|
|
346
|
+
key: event.key,
|
|
347
|
+
oldValue: originalOld,
|
|
348
|
+
newValue: event.newValue,
|
|
349
|
+
isNew: existing?.isNew ?? false,
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
/** Revert a single flag's pending change */
|
|
354
|
+
revertFlag(groupName, key) {
|
|
355
|
+
const compositeKey = this.makeCompositeKey(groupName, key);
|
|
356
|
+
this.pendingChanges.delete(compositeKey);
|
|
357
|
+
}
|
|
358
|
+
/** Discard all pending changes */
|
|
359
|
+
discardAll() {
|
|
360
|
+
this.pendingChanges.clear();
|
|
361
|
+
}
|
|
362
|
+
// ── Submit (batch upsert) ─────────────────────────────────────────
|
|
363
|
+
openSummaryModal() {
|
|
364
|
+
this.showSummaryModal = true;
|
|
365
|
+
}
|
|
366
|
+
closeSummaryModal() {
|
|
367
|
+
this.showSummaryModal = false;
|
|
368
|
+
}
|
|
369
|
+
/** Confirm and fire the API call with all pending changes */
|
|
370
|
+
submitAll() {
|
|
371
|
+
this.showSummaryModal = false;
|
|
372
|
+
this.clearMessages();
|
|
373
|
+
this.loading = true;
|
|
374
|
+
// Build the upsert payload: { tenantId, group1: { k1: v1 }, group2: { k2: v2 } }
|
|
375
|
+
const payload = { tenantId: this.tenantId };
|
|
376
|
+
for (const change of this.pendingChanges.values()) {
|
|
377
|
+
if (!payload[change.group]) {
|
|
378
|
+
payload[change.group] = {};
|
|
379
|
+
}
|
|
380
|
+
payload[change.group][change.key] = change.newValue;
|
|
381
|
+
}
|
|
382
|
+
this.api.upsert(payload)
|
|
383
|
+
.pipe(takeUntil(this.destroy$), finalize(() => this.loading = false))
|
|
384
|
+
.subscribe({
|
|
385
|
+
next: (doc) => {
|
|
386
|
+
const count = this.pendingChanges.size;
|
|
387
|
+
this.pendingChanges.clear();
|
|
388
|
+
this.parseDocument(doc);
|
|
389
|
+
this.successMsg = `${count} flag${count !== 1 ? 's' : ''} updated successfully!`;
|
|
390
|
+
},
|
|
391
|
+
error: (err) => {
|
|
392
|
+
this.error = err.error?.message ?? 'Failed to save changes.';
|
|
393
|
+
},
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
// ── Add group / add flag (staged as pending) ─────────────────────
|
|
397
|
+
addGroup() {
|
|
398
|
+
const name = this.newGroupName.trim();
|
|
399
|
+
if (!name)
|
|
400
|
+
return;
|
|
401
|
+
this.showAddGroup = false;
|
|
402
|
+
this.addingFlagToGroup = name;
|
|
403
|
+
this.newGroupName = '';
|
|
404
|
+
}
|
|
405
|
+
/** Stage a new flag as a pending change */
|
|
406
|
+
confirmAddFlag(groupName) {
|
|
407
|
+
const key = this.newFlagKey.trim();
|
|
408
|
+
if (!key)
|
|
409
|
+
return;
|
|
410
|
+
const value = this.coerceValue(this.newFlagValue, this.newFlagType);
|
|
411
|
+
const compositeKey = this.makeCompositeKey(groupName, key);
|
|
412
|
+
this.pendingChanges.set(compositeKey, {
|
|
413
|
+
group: groupName,
|
|
414
|
+
key,
|
|
415
|
+
oldValue: null,
|
|
416
|
+
newValue: value,
|
|
417
|
+
isNew: true,
|
|
418
|
+
});
|
|
419
|
+
this.addingFlagToGroup = null;
|
|
420
|
+
this.newFlagKey = '';
|
|
421
|
+
this.newFlagValue = '';
|
|
422
|
+
this.newFlagType = 'string';
|
|
423
|
+
}
|
|
424
|
+
cancelAddFlag() {
|
|
425
|
+
this.addingFlagToGroup = null;
|
|
426
|
+
this.newFlagKey = '';
|
|
427
|
+
this.newFlagValue = '';
|
|
428
|
+
}
|
|
429
|
+
// ── Deletes (immediate, not batched) ──────────────────────────────
|
|
430
|
+
deleteFlag(groupName, flagKey) {
|
|
431
|
+
this.clearMessages();
|
|
432
|
+
this.loading = true;
|
|
433
|
+
// Also clear any pending change for this flag
|
|
434
|
+
this.pendingChanges.delete(this.makeCompositeKey(groupName, flagKey));
|
|
435
|
+
this.api.deleteFlag(this.tenantId, groupName, flagKey)
|
|
436
|
+
.pipe(takeUntil(this.destroy$), finalize(() => this.loading = false))
|
|
437
|
+
.subscribe({
|
|
438
|
+
next: (doc) => {
|
|
439
|
+
this.parseDocument(doc);
|
|
440
|
+
this.successMsg = `Flag "${groupName}.${flagKey}" deleted.`;
|
|
441
|
+
},
|
|
442
|
+
error: (err) => this.error = err.error?.message ?? 'Failed to delete flag.',
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
deleteGroup(groupName) {
|
|
446
|
+
this.clearMessages();
|
|
447
|
+
this.loading = true;
|
|
448
|
+
// Clear any pending changes for this group
|
|
449
|
+
for (const [key] of this.pendingChanges) {
|
|
450
|
+
if (key.startsWith(groupName + '::')) {
|
|
451
|
+
this.pendingChanges.delete(key);
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
this.api.deleteGroup(this.tenantId, groupName)
|
|
455
|
+
.pipe(takeUntil(this.destroy$), finalize(() => this.loading = false))
|
|
456
|
+
.subscribe({
|
|
457
|
+
next: (doc) => {
|
|
458
|
+
this.parseDocument(doc);
|
|
459
|
+
this.successMsg = `Group "${groupName}" deleted.`;
|
|
460
|
+
},
|
|
461
|
+
error: (err) => this.error = err.error?.message ?? 'Failed to delete group.',
|
|
462
|
+
});
|
|
463
|
+
}
|
|
464
|
+
// ── Helpers ───────────────────────────────────────────────────────
|
|
465
|
+
trackGroup(_index, group) {
|
|
466
|
+
return group.name;
|
|
467
|
+
}
|
|
468
|
+
parseDocument(doc) {
|
|
469
|
+
this.groups = [];
|
|
470
|
+
if (!doc)
|
|
471
|
+
return;
|
|
472
|
+
for (const [key, value] of Object.entries(doc)) {
|
|
473
|
+
if (RESERVED_KEYS.has(key))
|
|
474
|
+
continue;
|
|
475
|
+
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
|
476
|
+
const flags = Object.entries(value).map(([k, v]) => ({ key: k, value: v }));
|
|
477
|
+
this.groups.push({ name: key, flags });
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
this.groups.sort((a, b) => a.name.localeCompare(b.name));
|
|
481
|
+
}
|
|
482
|
+
coerceValue(raw, type) {
|
|
483
|
+
const trimmed = raw.trim();
|
|
484
|
+
if (trimmed === '' || trimmed.toLowerCase() === 'null')
|
|
485
|
+
return null;
|
|
486
|
+
switch (type) {
|
|
487
|
+
case 'boolean':
|
|
488
|
+
return trimmed.toLowerCase() === 'true';
|
|
489
|
+
case 'number': {
|
|
490
|
+
const n = Number(trimmed);
|
|
491
|
+
return isNaN(n) ? trimmed : n;
|
|
492
|
+
}
|
|
493
|
+
default:
|
|
494
|
+
return trimmed;
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
clearMessages() {
|
|
498
|
+
this.error = null;
|
|
499
|
+
this.successMsg = null;
|
|
500
|
+
}
|
|
501
|
+
makeCompositeKey(group, key) {
|
|
502
|
+
return `${group}::${key}`;
|
|
503
|
+
}
|
|
504
|
+
splitCompositeKey(composite) {
|
|
505
|
+
const idx = composite.indexOf('::');
|
|
506
|
+
return [composite.substring(0, idx), composite.substring(idx + 2)];
|
|
507
|
+
}
|
|
508
|
+
valuesEqual(a, b) {
|
|
509
|
+
return a === b;
|
|
510
|
+
}
|
|
511
|
+
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.0.5", ngImport: i0, type: FeatureFlagsManagerComponent, deps: [{ token: FeatureFlagsApiService }], target: i0.ɵɵFactoryTarget.Component }); }
|
|
512
|
+
static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "21.0.5", type: FeatureFlagsManagerComponent, isStandalone: false, selector: "valar-feature-flags-manager", ngImport: i0, template: "<div class=\"container-fluid py-4 pb-5\">\n <!-- Header -->\n <div class=\"d-flex align-items-center mb-4\">\n <h2 class=\"mb-0\">\n <i class=\"bi bi-toggles2 me-2\" aria-hidden=\"true\"></i>Feature Flags\n </h2>\n </div>\n\n <!-- Alerts -->\n <div *ngIf=\"error\" class=\"alert alert-danger alert-dismissible fade show\" role=\"alert\">\n <i class=\"bi bi-exclamation-triangle-fill me-2\" aria-hidden=\"true\"></i>{{ error }}\n <button type=\"button\" class=\"btn-close\" aria-label=\"Close\" (click)=\"error = null\"></button>\n </div>\n <div *ngIf=\"successMsg\" class=\"alert alert-success alert-dismissible fade show\" role=\"alert\">\n <i class=\"bi bi-check-circle-fill me-2\" aria-hidden=\"true\"></i>{{ successMsg }}\n <button type=\"button\" class=\"btn-close\" aria-label=\"Close\" (click)=\"successMsg = null\"></button>\n </div>\n\n <!-- Tenant ID Input -->\n <div class=\"card shadow-sm mb-4\">\n <div class=\"card-body\">\n <label for=\"tenantIdInput\" class=\"form-label fw-semibold\">Tenant ID</label>\n <div class=\"input-group\">\n <span class=\"input-group-text\" id=\"tenant-addon\">\n <i class=\"bi bi-building\" aria-hidden=\"true\"></i>\n </span>\n <input\n id=\"tenantIdInput\"\n type=\"text\"\n class=\"form-control\"\n placeholder=\"e.g. tenant_acme\"\n [(ngModel)]=\"tenantId\"\n (keyup.enter)=\"loadFlags()\"\n [attr.aria-describedby]=\"'tenant-addon'\"\n aria-label=\"Tenant ID\" />\n <button\n class=\"btn btn-primary\"\n [disabled]=\"loading || !tenantId.trim()\"\n (click)=\"loadFlags()\">\n <span *ngIf=\"loading\" class=\"spinner-border spinner-border-sm me-1\"\n role=\"status\" aria-hidden=\"true\"></span>\n {{ loading ? 'Loading\u2026' : 'Load Flags' }}\n </button>\n </div>\n <div class=\"form-text\">Enter a tenant ID and press Enter or click Load Flags.</div>\n </div>\n </div>\n\n <!-- Content area (only after load) -->\n <div *ngIf=\"loaded\">\n <!-- Toolbar -->\n <div class=\"d-flex justify-content-between align-items-center mb-3\">\n <span class=\"text-muted\">\n {{ groups.length }} group{{ groups.length !== 1 ? 's' : '' }} found\n </span>\n <button class=\"btn btn-success btn-sm\" (click)=\"showAddGroup = true\" [disabled]=\"showAddGroup\">\n <i class=\"bi bi-plus-circle me-1\" aria-hidden=\"true\"></i>Add Group\n </button>\n </div>\n\n <!-- Add Group Form -->\n <div *ngIf=\"showAddGroup\" class=\"card border-success mb-3\">\n <div class=\"card-body\">\n <h6 class=\"card-title text-success\">\n <i class=\"bi bi-folder-plus me-1\" aria-hidden=\"true\"></i>New Group\n </h6>\n <div class=\"input-group input-group-sm\">\n <input\n type=\"text\"\n class=\"form-control\"\n placeholder=\"Group name (e.g. uiOptions)\"\n [(ngModel)]=\"newGroupName\"\n (keyup.enter)=\"addGroup()\"\n aria-label=\"New group name\" />\n <button class=\"btn btn-success\" (click)=\"addGroup()\" [disabled]=\"!newGroupName.trim()\">\n Next\n </button>\n <button class=\"btn btn-outline-secondary\" (click)=\"showAddGroup = false; newGroupName = ''\">\n Cancel\n </button>\n </div>\n <div class=\"form-text\">Letters, numbers, _ and - only. Must start with a letter.</div>\n </div>\n </div>\n\n <!-- Flag Groups -->\n <div *ngFor=\"let group of groups; trackBy: trackGroup\" class=\"mb-3\">\n <valar-flag-group-card\n [groupName]=\"group.name\"\n [flags]=\"group.flags\"\n [dirtyKeys]=\"dirtyKeysForGroup(group.name)\"\n [isAddingFlag]=\"addingFlagToGroup === group.name\"\n [newFlagKey]=\"newFlagKey\"\n [newFlagValue]=\"newFlagValue\"\n [newFlagType]=\"newFlagType\"\n (flagChanged)=\"onFlagChanged(group.name, $event)\"\n (revertFlag)=\"revertFlag(group.name, $event)\"\n (addFlagRequest)=\"addingFlagToGroup = group.name\"\n (cancelAddFlag)=\"cancelAddFlag()\"\n (confirmAddFlag)=\"confirmAddFlag(group.name)\"\n (newFlagKeyChange)=\"newFlagKey = $event\"\n (newFlagValueChange)=\"newFlagValue = $event\"\n (newFlagTypeChange)=\"newFlagType = $event\"\n (deleteFlagRequest)=\"deleteFlag(group.name, $event)\"\n (deleteGroupRequest)=\"deleteGroup(group.name)\">\n </valar-flag-group-card>\n </div>\n\n <!-- Empty state -->\n <div *ngIf=\"groups.length === 0 && !showAddGroup\" class=\"text-center text-muted py-5\">\n <i class=\"bi bi-inbox display-1\" aria-hidden=\"true\"></i>\n <p class=\"mt-3\">No feature flag groups yet. Click <strong>Add Group</strong> to get started.</p>\n </div>\n </div>\n</div>\n\n<!-- Sticky bottom action bar (visible when there are pending changes) -->\n<div *ngIf=\"hasPendingChanges\" class=\"action-bar\">\n <div class=\"container-fluid d-flex justify-content-between align-items-center\">\n <span class=\"text-white\">\n <i class=\"bi bi-pencil-square me-1\" aria-hidden=\"true\"></i>\n <strong>{{ pendingChangeCount }}</strong>\n unsaved change{{ pendingChangeCount !== 1 ? 's' : '' }}\n </span>\n <div>\n <button class=\"btn btn-outline-light btn-sm me-2\" (click)=\"discardAll()\">\n <i class=\"bi bi-x-lg me-1\" aria-hidden=\"true\"></i>Discard All\n </button>\n <button class=\"btn btn-warning btn-sm fw-semibold\" (click)=\"openSummaryModal()\">\n <i class=\"bi bi-send me-1\" aria-hidden=\"true\"></i>Review & Submit\n </button>\n </div>\n </div>\n</div>\n\n<!-- Changes Summary Modal -->\n<valar-changes-summary-modal\n *ngIf=\"showSummaryModal\"\n [changes]=\"pendingChangesList\"\n [tenantId]=\"tenantId\"\n (confirm)=\"submitAll()\"\n (dismiss)=\"closeSummaryModal()\">\n</valar-changes-summary-modal>\n", styles: [":host{display:block}.bi{vertical-align:-.125em}.action-bar{position:fixed;bottom:0;left:0;right:0;z-index:1030;background:linear-gradient(135deg,#343a40,#495057);padding:.75rem 1rem;box-shadow:0 -4px 12px #00000040;animation:slideUp .25s ease-out}@keyframes slideUp{0%{transform:translateY(100%);opacity:0}to{transform:translateY(0);opacity:1}}\n"], dependencies: [{ kind: "directive", type: i1$1.NgForOf, selector: "[ngFor][ngForOf]", inputs: ["ngForOf", "ngForTrackBy", "ngForTemplate"] }, { kind: "directive", type: i1$1.NgIf, selector: "[ngIf]", inputs: ["ngIf", "ngIfThen", "ngIfElse"] }, { kind: "directive", type: i2.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: i2.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i2.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }, { kind: "component", type: FlagGroupCardComponent, selector: "valar-flag-group-card", inputs: ["groupName", "flags", "dirtyKeys", "isAddingFlag", "newFlagKey", "newFlagValue", "newFlagType"], outputs: ["flagChanged", "revertFlag", "addFlagRequest", "cancelAddFlag", "confirmAddFlag", "newFlagKeyChange", "newFlagValueChange", "newFlagTypeChange", "deleteFlagRequest", "deleteGroupRequest"] }, { kind: "component", type: ChangesSummaryModalComponent, selector: "valar-changes-summary-modal", inputs: ["changes", "tenantId"], outputs: ["confirm", "dismiss"] }] }); }
|
|
513
|
+
}
|
|
514
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.5", ngImport: i0, type: FeatureFlagsManagerComponent, decorators: [{
|
|
515
|
+
type: Component,
|
|
516
|
+
args: [{ selector: 'valar-feature-flags-manager', standalone: false, template: "<div class=\"container-fluid py-4 pb-5\">\n <!-- Header -->\n <div class=\"d-flex align-items-center mb-4\">\n <h2 class=\"mb-0\">\n <i class=\"bi bi-toggles2 me-2\" aria-hidden=\"true\"></i>Feature Flags\n </h2>\n </div>\n\n <!-- Alerts -->\n <div *ngIf=\"error\" class=\"alert alert-danger alert-dismissible fade show\" role=\"alert\">\n <i class=\"bi bi-exclamation-triangle-fill me-2\" aria-hidden=\"true\"></i>{{ error }}\n <button type=\"button\" class=\"btn-close\" aria-label=\"Close\" (click)=\"error = null\"></button>\n </div>\n <div *ngIf=\"successMsg\" class=\"alert alert-success alert-dismissible fade show\" role=\"alert\">\n <i class=\"bi bi-check-circle-fill me-2\" aria-hidden=\"true\"></i>{{ successMsg }}\n <button type=\"button\" class=\"btn-close\" aria-label=\"Close\" (click)=\"successMsg = null\"></button>\n </div>\n\n <!-- Tenant ID Input -->\n <div class=\"card shadow-sm mb-4\">\n <div class=\"card-body\">\n <label for=\"tenantIdInput\" class=\"form-label fw-semibold\">Tenant ID</label>\n <div class=\"input-group\">\n <span class=\"input-group-text\" id=\"tenant-addon\">\n <i class=\"bi bi-building\" aria-hidden=\"true\"></i>\n </span>\n <input\n id=\"tenantIdInput\"\n type=\"text\"\n class=\"form-control\"\n placeholder=\"e.g. tenant_acme\"\n [(ngModel)]=\"tenantId\"\n (keyup.enter)=\"loadFlags()\"\n [attr.aria-describedby]=\"'tenant-addon'\"\n aria-label=\"Tenant ID\" />\n <button\n class=\"btn btn-primary\"\n [disabled]=\"loading || !tenantId.trim()\"\n (click)=\"loadFlags()\">\n <span *ngIf=\"loading\" class=\"spinner-border spinner-border-sm me-1\"\n role=\"status\" aria-hidden=\"true\"></span>\n {{ loading ? 'Loading\u2026' : 'Load Flags' }}\n </button>\n </div>\n <div class=\"form-text\">Enter a tenant ID and press Enter or click Load Flags.</div>\n </div>\n </div>\n\n <!-- Content area (only after load) -->\n <div *ngIf=\"loaded\">\n <!-- Toolbar -->\n <div class=\"d-flex justify-content-between align-items-center mb-3\">\n <span class=\"text-muted\">\n {{ groups.length }} group{{ groups.length !== 1 ? 's' : '' }} found\n </span>\n <button class=\"btn btn-success btn-sm\" (click)=\"showAddGroup = true\" [disabled]=\"showAddGroup\">\n <i class=\"bi bi-plus-circle me-1\" aria-hidden=\"true\"></i>Add Group\n </button>\n </div>\n\n <!-- Add Group Form -->\n <div *ngIf=\"showAddGroup\" class=\"card border-success mb-3\">\n <div class=\"card-body\">\n <h6 class=\"card-title text-success\">\n <i class=\"bi bi-folder-plus me-1\" aria-hidden=\"true\"></i>New Group\n </h6>\n <div class=\"input-group input-group-sm\">\n <input\n type=\"text\"\n class=\"form-control\"\n placeholder=\"Group name (e.g. uiOptions)\"\n [(ngModel)]=\"newGroupName\"\n (keyup.enter)=\"addGroup()\"\n aria-label=\"New group name\" />\n <button class=\"btn btn-success\" (click)=\"addGroup()\" [disabled]=\"!newGroupName.trim()\">\n Next\n </button>\n <button class=\"btn btn-outline-secondary\" (click)=\"showAddGroup = false; newGroupName = ''\">\n Cancel\n </button>\n </div>\n <div class=\"form-text\">Letters, numbers, _ and - only. Must start with a letter.</div>\n </div>\n </div>\n\n <!-- Flag Groups -->\n <div *ngFor=\"let group of groups; trackBy: trackGroup\" class=\"mb-3\">\n <valar-flag-group-card\n [groupName]=\"group.name\"\n [flags]=\"group.flags\"\n [dirtyKeys]=\"dirtyKeysForGroup(group.name)\"\n [isAddingFlag]=\"addingFlagToGroup === group.name\"\n [newFlagKey]=\"newFlagKey\"\n [newFlagValue]=\"newFlagValue\"\n [newFlagType]=\"newFlagType\"\n (flagChanged)=\"onFlagChanged(group.name, $event)\"\n (revertFlag)=\"revertFlag(group.name, $event)\"\n (addFlagRequest)=\"addingFlagToGroup = group.name\"\n (cancelAddFlag)=\"cancelAddFlag()\"\n (confirmAddFlag)=\"confirmAddFlag(group.name)\"\n (newFlagKeyChange)=\"newFlagKey = $event\"\n (newFlagValueChange)=\"newFlagValue = $event\"\n (newFlagTypeChange)=\"newFlagType = $event\"\n (deleteFlagRequest)=\"deleteFlag(group.name, $event)\"\n (deleteGroupRequest)=\"deleteGroup(group.name)\">\n </valar-flag-group-card>\n </div>\n\n <!-- Empty state -->\n <div *ngIf=\"groups.length === 0 && !showAddGroup\" class=\"text-center text-muted py-5\">\n <i class=\"bi bi-inbox display-1\" aria-hidden=\"true\"></i>\n <p class=\"mt-3\">No feature flag groups yet. Click <strong>Add Group</strong> to get started.</p>\n </div>\n </div>\n</div>\n\n<!-- Sticky bottom action bar (visible when there are pending changes) -->\n<div *ngIf=\"hasPendingChanges\" class=\"action-bar\">\n <div class=\"container-fluid d-flex justify-content-between align-items-center\">\n <span class=\"text-white\">\n <i class=\"bi bi-pencil-square me-1\" aria-hidden=\"true\"></i>\n <strong>{{ pendingChangeCount }}</strong>\n unsaved change{{ pendingChangeCount !== 1 ? 's' : '' }}\n </span>\n <div>\n <button class=\"btn btn-outline-light btn-sm me-2\" (click)=\"discardAll()\">\n <i class=\"bi bi-x-lg me-1\" aria-hidden=\"true\"></i>Discard All\n </button>\n <button class=\"btn btn-warning btn-sm fw-semibold\" (click)=\"openSummaryModal()\">\n <i class=\"bi bi-send me-1\" aria-hidden=\"true\"></i>Review & Submit\n </button>\n </div>\n </div>\n</div>\n\n<!-- Changes Summary Modal -->\n<valar-changes-summary-modal\n *ngIf=\"showSummaryModal\"\n [changes]=\"pendingChangesList\"\n [tenantId]=\"tenantId\"\n (confirm)=\"submitAll()\"\n (dismiss)=\"closeSummaryModal()\">\n</valar-changes-summary-modal>\n", styles: [":host{display:block}.bi{vertical-align:-.125em}.action-bar{position:fixed;bottom:0;left:0;right:0;z-index:1030;background:linear-gradient(135deg,#343a40,#495057);padding:.75rem 1rem;box-shadow:0 -4px 12px #00000040;animation:slideUp .25s ease-out}@keyframes slideUp{0%{transform:translateY(100%);opacity:0}to{transform:translateY(0);opacity:1}}\n"] }]
|
|
517
|
+
}], ctorParameters: () => [{ type: FeatureFlagsApiService }] });
|
|
518
|
+
|
|
519
|
+
/**
|
|
520
|
+
* In-memory mock of FeatureFlagsApiService.
|
|
521
|
+
* Ships with 2 tenants pre-loaded so you can test the UI
|
|
522
|
+
* without spinning up the backend.
|
|
523
|
+
*
|
|
524
|
+
* Use via `FeatureFlagsModule.forTesting()`.
|
|
525
|
+
*/
|
|
526
|
+
class MockFeatureFlagsApiService {
|
|
527
|
+
constructor() {
|
|
528
|
+
this.store = new Map();
|
|
529
|
+
this.seedData();
|
|
530
|
+
}
|
|
531
|
+
// ────────────────────────── Public API (same shape as real service) ──
|
|
532
|
+
getByTenantId(tenantId) {
|
|
533
|
+
const doc = this.store.get(tenantId) ?? null;
|
|
534
|
+
return of(this.clone(doc)).pipe(delay(250));
|
|
535
|
+
}
|
|
536
|
+
upsert(payload) {
|
|
537
|
+
const { tenantId, ...groups } = payload;
|
|
538
|
+
if (!tenantId?.trim()) {
|
|
539
|
+
return throwError(() => ({ error: { message: 'tenantId is required' } }));
|
|
540
|
+
}
|
|
541
|
+
let doc = this.store.get(tenantId);
|
|
542
|
+
if (!doc) {
|
|
543
|
+
doc = this.newDoc(tenantId);
|
|
544
|
+
this.store.set(tenantId, doc);
|
|
545
|
+
}
|
|
546
|
+
// Merge groups via dot-path (mirrors the backend's $set behavior)
|
|
547
|
+
for (const [groupName, groupFlags] of Object.entries(groups)) {
|
|
548
|
+
if (RESERVED_KEYS.has(groupName))
|
|
549
|
+
continue;
|
|
550
|
+
const existing = doc[groupName] ?? {};
|
|
551
|
+
doc[groupName] = { ...existing, ...groupFlags };
|
|
552
|
+
}
|
|
553
|
+
doc.updatedAt = new Date().toISOString();
|
|
554
|
+
return of(this.clone(doc)).pipe(delay(200));
|
|
555
|
+
}
|
|
556
|
+
deleteFlag(tenantId, group, key) {
|
|
557
|
+
const doc = this.store.get(tenantId);
|
|
558
|
+
if (!doc) {
|
|
559
|
+
return throwError(() => ({
|
|
560
|
+
error: { message: `No feature flags found for tenantId='${tenantId}'` },
|
|
561
|
+
}));
|
|
562
|
+
}
|
|
563
|
+
const g = doc[group];
|
|
564
|
+
if (g && key in g) {
|
|
565
|
+
delete g[key];
|
|
566
|
+
}
|
|
567
|
+
doc.updatedAt = new Date().toISOString();
|
|
568
|
+
return of(this.clone(doc)).pipe(delay(200));
|
|
569
|
+
}
|
|
570
|
+
deleteGroup(tenantId, group) {
|
|
571
|
+
const doc = this.store.get(tenantId);
|
|
572
|
+
if (!doc) {
|
|
573
|
+
return throwError(() => ({
|
|
574
|
+
error: { message: `No feature flags found for tenantId='${tenantId}'` },
|
|
575
|
+
}));
|
|
576
|
+
}
|
|
577
|
+
delete doc[group];
|
|
578
|
+
doc.updatedAt = new Date().toISOString();
|
|
579
|
+
return of(this.clone(doc)).pipe(delay(200));
|
|
580
|
+
}
|
|
581
|
+
// ────────────────────────── Seed data ────────────────────────────────
|
|
582
|
+
seedData() {
|
|
583
|
+
this.store.set('tenant_acme', this.buildAcmeTenant());
|
|
584
|
+
this.store.set('tenant_globex', this.buildGlobexTenant());
|
|
585
|
+
}
|
|
586
|
+
buildAcmeTenant() {
|
|
587
|
+
return {
|
|
588
|
+
_id: '6650a1b2c3d4e5f6a7b8c9d0',
|
|
589
|
+
tenantId: 'tenant_acme',
|
|
590
|
+
createdAt: '2025-11-01T08:00:00.000Z',
|
|
591
|
+
updatedAt: '2026-02-15T14:30:00.000Z',
|
|
592
|
+
uiOptions: {
|
|
593
|
+
darkMode: true,
|
|
594
|
+
sidebarCollapsed: false,
|
|
595
|
+
maxItemsPerPage: 25,
|
|
596
|
+
accentColor: '#4f46e5',
|
|
597
|
+
showBetaBanner: true,
|
|
598
|
+
},
|
|
599
|
+
checkout: {
|
|
600
|
+
newCheckoutFlow: true,
|
|
601
|
+
enableGiftCards: false,
|
|
602
|
+
maxCartItems: 50,
|
|
603
|
+
taxCalculation: 'inclusive',
|
|
604
|
+
},
|
|
605
|
+
notifications: {
|
|
606
|
+
emailEnabled: true,
|
|
607
|
+
smsEnabled: false,
|
|
608
|
+
pushEnabled: true,
|
|
609
|
+
dailyDigest: true,
|
|
610
|
+
marketingOptIn: false,
|
|
611
|
+
},
|
|
612
|
+
search: {
|
|
613
|
+
enableFuzzySearch: true,
|
|
614
|
+
maxResults: 100,
|
|
615
|
+
highlightMatches: true,
|
|
616
|
+
searchDelay: 300,
|
|
617
|
+
},
|
|
618
|
+
};
|
|
619
|
+
}
|
|
620
|
+
buildGlobexTenant() {
|
|
621
|
+
return {
|
|
622
|
+
_id: '6650b2c3d4e5f6a7b8c9d0e1',
|
|
623
|
+
tenantId: 'tenant_globex',
|
|
624
|
+
createdAt: '2025-12-10T10:00:00.000Z',
|
|
625
|
+
updatedAt: '2026-02-16T09:15:00.000Z',
|
|
626
|
+
uiOptions: {
|
|
627
|
+
darkMode: false,
|
|
628
|
+
sidebarCollapsed: true,
|
|
629
|
+
maxItemsPerPage: 10,
|
|
630
|
+
accentColor: '#059669',
|
|
631
|
+
showBetaBanner: false,
|
|
632
|
+
},
|
|
633
|
+
billing: {
|
|
634
|
+
enableAutoPay: true,
|
|
635
|
+
gracePeriodDays: 7,
|
|
636
|
+
currency: 'USD',
|
|
637
|
+
showInvoiceBreakdown: true,
|
|
638
|
+
lateFeePercent: 1.5,
|
|
639
|
+
},
|
|
640
|
+
featureAccess: {
|
|
641
|
+
analyticsV2: true,
|
|
642
|
+
exportToCsv: true,
|
|
643
|
+
bulkOperations: false,
|
|
644
|
+
advancedReporting: true,
|
|
645
|
+
apiRateLimit: 1000,
|
|
646
|
+
},
|
|
647
|
+
integrations: {
|
|
648
|
+
slackEnabled: true,
|
|
649
|
+
slackWebhookConfigured: true,
|
|
650
|
+
jiraEnabled: false,
|
|
651
|
+
githubEnabled: true,
|
|
652
|
+
webhookRetries: 3,
|
|
653
|
+
},
|
|
654
|
+
};
|
|
655
|
+
}
|
|
656
|
+
// ────────────────────────── Helpers ──────────────────────────────────
|
|
657
|
+
newDoc(tenantId) {
|
|
658
|
+
const now = new Date().toISOString();
|
|
659
|
+
return { tenantId, createdAt: now, updatedAt: now };
|
|
660
|
+
}
|
|
661
|
+
/** Deep-clone to avoid leaking mutable references */
|
|
662
|
+
clone(obj) {
|
|
663
|
+
return JSON.parse(JSON.stringify(obj));
|
|
664
|
+
}
|
|
665
|
+
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.0.5", ngImport: i0, type: MockFeatureFlagsApiService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
|
|
666
|
+
static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.0.5", ngImport: i0, type: MockFeatureFlagsApiService }); }
|
|
667
|
+
}
|
|
668
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.5", ngImport: i0, type: MockFeatureFlagsApiService, decorators: [{
|
|
669
|
+
type: Injectable
|
|
670
|
+
}], ctorParameters: () => [] });
|
|
671
|
+
|
|
672
|
+
class FeatureFlagsModule {
|
|
673
|
+
/**
|
|
674
|
+
* Call forRoot() in the consuming app to provide
|
|
675
|
+
* the API configuration.
|
|
676
|
+
*
|
|
677
|
+
* @example
|
|
678
|
+
* FeatureFlagsModule.forRoot({
|
|
679
|
+
* apiBaseUrl: 'http://localhost:3000',
|
|
680
|
+
* apiVersion: 'v1',
|
|
681
|
+
* })
|
|
682
|
+
*/
|
|
683
|
+
static forRoot(config) {
|
|
684
|
+
return {
|
|
685
|
+
ngModule: FeatureFlagsModule,
|
|
686
|
+
providers: [
|
|
687
|
+
{ provide: FEATURE_FLAGS_CONFIG, useValue: config },
|
|
688
|
+
FeatureFlagsApiService,
|
|
689
|
+
provideHttpClient(),
|
|
690
|
+
],
|
|
691
|
+
};
|
|
692
|
+
}
|
|
693
|
+
/**
|
|
694
|
+
* Call forTesting() in the consuming app to use
|
|
695
|
+
* in-memory mock data (no backend needed).
|
|
696
|
+
*
|
|
697
|
+
* Ships with 2 pre-loaded tenants:
|
|
698
|
+
* - "tenant_acme" (Acme Corp)
|
|
699
|
+
* - "tenant_globex" (Globex Inc)
|
|
700
|
+
*
|
|
701
|
+
* @example
|
|
702
|
+
* FeatureFlagsModule.forTesting()
|
|
703
|
+
*/
|
|
704
|
+
static forTesting() {
|
|
705
|
+
return {
|
|
706
|
+
ngModule: FeatureFlagsModule,
|
|
707
|
+
providers: [
|
|
708
|
+
{ provide: FeatureFlagsApiService, useClass: MockFeatureFlagsApiService },
|
|
709
|
+
],
|
|
710
|
+
};
|
|
711
|
+
}
|
|
712
|
+
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.0.5", ngImport: i0, type: FeatureFlagsModule, deps: [], target: i0.ɵɵFactoryTarget.NgModule }); }
|
|
713
|
+
static { this.ɵmod = i0.ɵɵngDeclareNgModule({ minVersion: "14.0.0", version: "21.0.5", ngImport: i0, type: FeatureFlagsModule, declarations: [FeatureFlagsManagerComponent,
|
|
714
|
+
FlagGroupCardComponent,
|
|
715
|
+
ChangesSummaryModalComponent], imports: [CommonModule,
|
|
716
|
+
FormsModule], exports: [FeatureFlagsManagerComponent,
|
|
717
|
+
FlagGroupCardComponent,
|
|
718
|
+
ChangesSummaryModalComponent] }); }
|
|
719
|
+
static { this.ɵinj = i0.ɵɵngDeclareInjector({ minVersion: "12.0.0", version: "21.0.5", ngImport: i0, type: FeatureFlagsModule, imports: [CommonModule,
|
|
720
|
+
FormsModule] }); }
|
|
721
|
+
}
|
|
722
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.5", ngImport: i0, type: FeatureFlagsModule, decorators: [{
|
|
723
|
+
type: NgModule,
|
|
724
|
+
args: [{
|
|
725
|
+
declarations: [
|
|
726
|
+
FeatureFlagsManagerComponent,
|
|
727
|
+
FlagGroupCardComponent,
|
|
728
|
+
ChangesSummaryModalComponent,
|
|
729
|
+
],
|
|
730
|
+
imports: [
|
|
731
|
+
CommonModule,
|
|
732
|
+
FormsModule,
|
|
733
|
+
],
|
|
734
|
+
exports: [
|
|
735
|
+
FeatureFlagsManagerComponent,
|
|
736
|
+
FlagGroupCardComponent,
|
|
737
|
+
ChangesSummaryModalComponent,
|
|
738
|
+
],
|
|
739
|
+
}]
|
|
740
|
+
}] });
|
|
741
|
+
|
|
742
|
+
/*
|
|
743
|
+
* Public API Surface of valar-ui
|
|
744
|
+
*/
|
|
745
|
+
|
|
746
|
+
/**
|
|
747
|
+
* Generated bundle index. Do not edit.
|
|
748
|
+
*/
|
|
749
|
+
|
|
750
|
+
export { ChangesSummaryModalComponent, FEATURE_FLAGS_CONFIG, FeatureFlagsApiService, FeatureFlagsManagerComponent, FeatureFlagsModule, FlagGroupCardComponent, MockFeatureFlagsApiService, RESERVED_KEYS };
|
|
751
|
+
//# sourceMappingURL=pl4yzonellc-valar-ui.mjs.map
|