@c8y/ngx-components 1024.5.1 → 1024.9.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/{c8y-ngx-components-asset-property-grid.component-DhfJagxU.mjs → c8y-ngx-components-asset-property-grid.component-Ddva2QJW.mjs} +35 -19
- package/fesm2022/c8y-ngx-components-asset-property-grid.component-Ddva2QJW.mjs.map +1 -0
- package/fesm2022/c8y-ngx-components-context-dashboard.mjs +28 -14
- package/fesm2022/c8y-ngx-components-context-dashboard.mjs.map +1 -1
- package/fesm2022/c8y-ngx-components-datapoints-export-selector.mjs +2 -2
- package/fesm2022/c8y-ngx-components-datapoints-export-selector.mjs.map +1 -1
- package/fesm2022/c8y-ngx-components-icon-selector.mjs +1538 -59
- package/fesm2022/c8y-ngx-components-icon-selector.mjs.map +1 -1
- package/fesm2022/c8y-ngx-components-platform-configuration.mjs +2 -2
- package/fesm2022/c8y-ngx-components-platform-configuration.mjs.map +1 -1
- package/fesm2022/c8y-ngx-components-remote-access-data.mjs +9 -2
- package/fesm2022/c8y-ngx-components-remote-access-data.mjs.map +1 -1
- package/fesm2022/c8y-ngx-components-remote-access-terminal-viewer.mjs +6 -1
- package/fesm2022/c8y-ngx-components-remote-access-terminal-viewer.mjs.map +1 -1
- package/fesm2022/c8y-ngx-components-reports.mjs +3 -5
- package/fesm2022/c8y-ngx-components-reports.mjs.map +1 -1
- package/fesm2022/c8y-ngx-components-repository-configuration.mjs +94 -49
- package/fesm2022/c8y-ngx-components-repository-configuration.mjs.map +1 -1
- package/fesm2022/c8y-ngx-components.mjs +1 -1
- package/locales/de.po +266 -3
- package/locales/es.po +266 -3
- package/locales/fr.po +266 -3
- package/locales/ja_JP.po +265 -3
- package/locales/ko.po +266 -3
- package/locales/locales.pot +294 -7
- package/locales/nl.po +266 -3
- package/locales/pl.po +266 -3
- package/locales/pt_BR.po +266 -3
- package/locales/zh_CN.po +266 -3
- package/locales/zh_TW.po +266 -3
- package/package.json +1 -1
- package/types/c8y-ngx-components-context-dashboard.d.ts +7 -3
- package/types/c8y-ngx-components-context-dashboard.d.ts.map +1 -1
- package/types/c8y-ngx-components-icon-selector.d.ts +286 -32
- package/types/c8y-ngx-components-icon-selector.d.ts.map +1 -1
- package/types/c8y-ngx-components-platform-configuration.d.ts +2 -2
- package/types/c8y-ngx-components-remote-access-data.d.ts +8 -1
- package/types/c8y-ngx-components-remote-access-data.d.ts.map +1 -1
- package/types/c8y-ngx-components-remote-access-terminal-viewer.d.ts.map +1 -1
- package/types/c8y-ngx-components-reports.d.ts.map +1 -1
- package/types/c8y-ngx-components-repository-configuration.d.ts +20 -10
- package/types/c8y-ngx-components-repository-configuration.d.ts.map +1 -1
- package/fesm2022/c8y-ngx-components-asset-property-grid.component-DhfJagxU.mjs.map +0 -1
|
@@ -1,17 +1,36 @@
|
|
|
1
1
|
import * as i0 from '@angular/core';
|
|
2
|
-
import { Pipe, EventEmitter, Input, Output,
|
|
2
|
+
import { Pipe, inject, Injectable, signal, computed, viewChild, effect, Component, ChangeDetectionStrategy, EventEmitter, ElementRef, Input, Output, ChangeDetectorRef, forwardRef, NgModule } from '@angular/core';
|
|
3
3
|
import * as i1 from '@angular/forms';
|
|
4
|
-
import { FormsModule, NG_VALUE_ACCESSOR } from '@angular/forms';
|
|
5
|
-
import
|
|
6
|
-
import
|
|
7
|
-
import {
|
|
4
|
+
import { FormBuilder, Validators, FormsModule, ReactiveFormsModule, NG_VALUE_ACCESSOR } from '@angular/forms';
|
|
5
|
+
import { AppStateService, ZipService, ModalComponent, DropAreaComponent, IconDirective, FormGroupComponent, MessagesComponent, MessageDirective, C8yTranslateDirective, TypeaheadComponent, ListItemComponent, LoadingComponent, C8yTranslatePipe, AlertService, Permissions, ModalService, EmptyStateComponent, SelectComponent, GainsightService, DropAreaModule } from '@c8y/ngx-components';
|
|
6
|
+
import * as i2 from 'ngx-bootstrap/tooltip';
|
|
7
|
+
import { TooltipModule, TooltipDirective } from 'ngx-bootstrap/tooltip';
|
|
8
8
|
import { gettext } from '@c8y/ngx-components/gettext';
|
|
9
9
|
import * as i1$1 from 'ngx-bootstrap/modal';
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
10
|
+
import { BsModalRef, BsModalService } from 'ngx-bootstrap/modal';
|
|
11
|
+
import { toSignal, takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
|
12
|
+
import { defer, share, ReplaySubject, firstValueFrom, BehaviorSubject, from, combineLatest, merge, of } from 'rxjs';
|
|
13
|
+
import { shareReplay, map, startWith, tap, debounceTime, switchMap } from 'rxjs/operators';
|
|
14
|
+
import { TranslateService } from '@ngx-translate/core';
|
|
15
|
+
import { AsyncPipe } from '@angular/common';
|
|
16
|
+
import { ApplicationAvailability, ApplicationType, ApplicationService, FetchClient } from '@c8y/client';
|
|
17
|
+
import { ICON_OPTIONS_CONTEXT_PATH, getIconSet, getIconFileName, ICON_OPTIONS_FILE, getIconManifestPath, convertRelativePathToAbsolutePath, applyCustomIconStyles } from '@c8y/bootstrap';
|
|
18
|
+
import * as DOMPurifyNS from 'dompurify';
|
|
19
|
+
import { DomSanitizer } from '@angular/platform-browser';
|
|
20
|
+
import { PRODUCT_EXPERIENCE_ICON_SELECTOR as PRODUCT_EXPERIENCE_ICON_SELECTOR$1 } from '@c8y/ngx-components/icon-selector/model';
|
|
21
|
+
|
|
22
|
+
const PRODUCT_EXPERIENCE_ICON_SELECTOR = {
|
|
23
|
+
EVENTS: {
|
|
24
|
+
ICON_SELECTOR: 'iconSelector'
|
|
25
|
+
},
|
|
26
|
+
COMPONENTS: {
|
|
27
|
+
ICON_SELECTOR_WRAPPER_COMPONENT: 'icon-selector-wrapper-component'
|
|
28
|
+
},
|
|
29
|
+
ACTIONS: {
|
|
30
|
+
OPEN_ICON_SELECTOR: 'openIconSelector',
|
|
31
|
+
ICON_SELECTED: 'iconSelected'
|
|
32
|
+
}
|
|
33
|
+
};
|
|
15
34
|
|
|
16
35
|
class IconNamePipe {
|
|
17
36
|
transform(icon) {
|
|
@@ -28,42 +47,1378 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.17", ngImpo
|
|
|
28
47
|
args: [{ name: 'iconName' }]
|
|
29
48
|
}] });
|
|
30
49
|
|
|
50
|
+
const MANIFEST = {
|
|
51
|
+
name: ICON_OPTIONS_CONTEXT_PATH,
|
|
52
|
+
contextPath: ICON_OPTIONS_CONTEXT_PATH,
|
|
53
|
+
key: `${ICON_OPTIONS_CONTEXT_PATH}-key`,
|
|
54
|
+
description: 'Hosted app storing custom icon definitions.',
|
|
55
|
+
icon: {
|
|
56
|
+
class: 'c8y-icon-circle-star'
|
|
57
|
+
},
|
|
58
|
+
noAppSwitcher: true,
|
|
59
|
+
type: ApplicationType.HOSTED,
|
|
60
|
+
availability: ApplicationAvailability.SHARED
|
|
61
|
+
};
|
|
62
|
+
class CustomIconStorageService {
|
|
63
|
+
constructor() {
|
|
64
|
+
this.applicationService = inject(ApplicationService);
|
|
65
|
+
this.appState = inject(AppStateService);
|
|
66
|
+
this.fetch = inject(FetchClient);
|
|
67
|
+
this.zip = inject(ZipService);
|
|
68
|
+
}
|
|
69
|
+
/** Fetches the current icon set from the tenant's hosted app. Returns empty set if none exists.
|
|
70
|
+
* An optional fetchFn can be supplied to use global fetch (e.g. from bootstrap before Angular starts)
|
|
71
|
+
* instead of the injected FetchClient. */
|
|
72
|
+
async getIconSet() {
|
|
73
|
+
return getIconSet(this.fetch.fetch.bind(this.fetch));
|
|
74
|
+
}
|
|
75
|
+
/** Persists the full icon set to the tenant-owned public-icon-options hosted app.
|
|
76
|
+
* icons.json contains metadata-only icons array + an iconCss map (name → CSS rule).
|
|
77
|
+
* CSS is built once per icon at save time and stored per-entry, so incremental saves
|
|
78
|
+
* only update the changed entry without re-encoding every icon's SVG.
|
|
79
|
+
*
|
|
80
|
+
* `filesToRemove`: SVG file names of deleted icons. The platform has no delete-file
|
|
81
|
+
* API (`updateFiles` only adds or replaces), so when files must go the active binary
|
|
82
|
+
* is rebuilt without them. The manifest is always generated client-side and never
|
|
83
|
+
* read back from the archive — the file-update endpoint repackages asynchronously,
|
|
84
|
+
* so an archive read right after a write can return stale content.
|
|
85
|
+
*
|
|
86
|
+
* @returns the `lastModified` timestamp written to the manifest. */
|
|
87
|
+
async saveIconSet(icons, filesToRemove = []) {
|
|
88
|
+
const app = await this.ensureApp();
|
|
89
|
+
const metadata = icons.map(({ name, family, category, isOverride, overriddenIconName, hasSecondary }) => ({
|
|
90
|
+
name,
|
|
91
|
+
family,
|
|
92
|
+
category,
|
|
93
|
+
isOverride,
|
|
94
|
+
hasSecondary,
|
|
95
|
+
...(overriddenIconName ? { overriddenIconName } : {})
|
|
96
|
+
}));
|
|
97
|
+
const filesOfIcons = icons
|
|
98
|
+
.map(icon => {
|
|
99
|
+
const filesOfIcon = [];
|
|
100
|
+
if (icon.svgContentPrimary) {
|
|
101
|
+
const primaryFileName = getIconFileName(icon);
|
|
102
|
+
const primaryBlob = new File([icon.svgContentPrimary], primaryFileName, {
|
|
103
|
+
type: 'image/svg+xml'
|
|
104
|
+
});
|
|
105
|
+
filesOfIcon.push({ path: primaryFileName, contents: primaryBlob });
|
|
106
|
+
}
|
|
107
|
+
if (icon.hasSecondary && icon.svgContentSecondary) {
|
|
108
|
+
const secondaryFileName = getIconFileName(icon, true);
|
|
109
|
+
const secondaryBlob = new File([icon.svgContentSecondary], secondaryFileName, {
|
|
110
|
+
type: 'image/svg+xml'
|
|
111
|
+
});
|
|
112
|
+
filesOfIcon.push({ path: secondaryFileName, contents: secondaryBlob });
|
|
113
|
+
}
|
|
114
|
+
return filesOfIcon;
|
|
115
|
+
})
|
|
116
|
+
.flat();
|
|
117
|
+
const lastModified = new Date().toISOString();
|
|
118
|
+
const files = [
|
|
119
|
+
...filesOfIcons,
|
|
120
|
+
{
|
|
121
|
+
path: ICON_OPTIONS_FILE,
|
|
122
|
+
contents: new File([JSON.stringify({ icons: metadata, lastModified })], ICON_OPTIONS_FILE, {
|
|
123
|
+
type: 'application/json'
|
|
124
|
+
})
|
|
125
|
+
}
|
|
126
|
+
];
|
|
127
|
+
if (filesToRemove.length && app.activeVersionId) {
|
|
128
|
+
// No propagation wait here: nothing reads the manifest after a removal —
|
|
129
|
+
// CSS and grid state come from the in-memory set, and the next rebuild
|
|
130
|
+
// fetches kept files individually, which is safe on any served version.
|
|
131
|
+
await this.rebuildBinary(app, icons, files);
|
|
132
|
+
}
|
|
133
|
+
else {
|
|
134
|
+
await this.applicationService.binary(app).updateFiles(files);
|
|
135
|
+
// Added/changed SVGs must be fetchable once the grid renders them.
|
|
136
|
+
await this.waitForManifestPropagation(lastModified);
|
|
137
|
+
}
|
|
138
|
+
return lastModified;
|
|
139
|
+
}
|
|
140
|
+
async waitForManifestPropagation(lastModified) {
|
|
141
|
+
const pollAttempts = 10;
|
|
142
|
+
const pollDelayStep = 2_500;
|
|
143
|
+
const pollDelayMax = 10_000;
|
|
144
|
+
for (let i = 0; i < pollAttempts; i++) {
|
|
145
|
+
if (i > 0) {
|
|
146
|
+
// Incremental backoff before polling to give the server time to process the updated files
|
|
147
|
+
await new Promise(resolve => setTimeout(resolve, Math.min(i * pollDelayStep, pollDelayMax)));
|
|
148
|
+
}
|
|
149
|
+
const manifestPath = getIconManifestPath();
|
|
150
|
+
const response = await this.fetch.fetch(manifestPath, { cache: 'no-cache' });
|
|
151
|
+
if (response.status !== 200) {
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
const json = await response.json();
|
|
155
|
+
if (json.lastModified === lastModified) {
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
// Deliberately non-throwing: the save itself succeeded, only the propagation check timed out.
|
|
160
|
+
console.warn('Custom icons saved, but the updated manifest was not observed within the polling window.');
|
|
161
|
+
}
|
|
162
|
+
/** Rebuilds the app binary from scratch: the static cumulocity.json, the given
|
|
163
|
+
* upserts (fresh manifest + SVGs with in-memory content) and the remaining icons'
|
|
164
|
+
* SVGs fetched individually from the hosted app. The old archive is deliberately
|
|
165
|
+
* never downloaded — the file-update endpoint repackages asynchronously, so a
|
|
166
|
+
* read right after a write can return stale content. Per-file fetches are safe:
|
|
167
|
+
* kept files are unchanged, so any served version returns identical content. */
|
|
168
|
+
async rebuildBinary(app, icons, upserts) {
|
|
169
|
+
const files = [
|
|
170
|
+
{
|
|
171
|
+
fileName: 'cumulocity.json',
|
|
172
|
+
blob: new Blob([JSON.stringify(MANIFEST)], { type: 'application/json' })
|
|
173
|
+
},
|
|
174
|
+
...upserts.map(u => ({ fileName: u.path, blob: u.contents }))
|
|
175
|
+
];
|
|
176
|
+
const included = new Set(files.map(f => f.fileName));
|
|
177
|
+
const toFetch = icons
|
|
178
|
+
.flatMap(icon => [
|
|
179
|
+
getIconFileName(icon),
|
|
180
|
+
...(icon.hasSecondary ? [getIconFileName(icon, true)] : [])
|
|
181
|
+
])
|
|
182
|
+
.filter(name => !included.has(name));
|
|
183
|
+
const fetched = await Promise.all(toFetch.map(async (name) => {
|
|
184
|
+
const response = await this.fetch.fetch(convertRelativePathToAbsolutePath(name), {
|
|
185
|
+
cache: 'no-cache'
|
|
186
|
+
});
|
|
187
|
+
if (response.status !== 200) {
|
|
188
|
+
console.warn(`[c8y] Could not preserve ${name} while rebuilding the icon app (HTTP ${response.status}).`);
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
return { fileName: name, blob: await response.blob() };
|
|
192
|
+
}));
|
|
193
|
+
files.push(...fetched.filter(f => f !== null));
|
|
194
|
+
const zipBlob = await this.zip.createZip(files);
|
|
195
|
+
const file = new File([zipBlob], `${ICON_OPTIONS_CONTEXT_PATH}.zip`);
|
|
196
|
+
const binaryService = this.applicationService.binary(app);
|
|
197
|
+
const { data: binary } = await binaryService.upload(file);
|
|
198
|
+
await this.applicationService.update({ id: app.id, activeVersionId: `${binary.id}` });
|
|
199
|
+
await binaryService.delete(app.activeVersionId);
|
|
200
|
+
}
|
|
201
|
+
async ensureApp() {
|
|
202
|
+
const existing = await this.findOwnedApp();
|
|
203
|
+
if (existing) {
|
|
204
|
+
return existing;
|
|
205
|
+
}
|
|
206
|
+
const { data: created } = await this.applicationService.create(MANIFEST);
|
|
207
|
+
const zipBlob = await this.zip.createZip([
|
|
208
|
+
{
|
|
209
|
+
fileName: 'cumulocity.json',
|
|
210
|
+
blob: new Blob([JSON.stringify(MANIFEST)], { type: 'application/json' })
|
|
211
|
+
}
|
|
212
|
+
]);
|
|
213
|
+
const file = new File([zipBlob], `${ICON_OPTIONS_CONTEXT_PATH}.zip`);
|
|
214
|
+
const { data: binary } = await this.applicationService.binary(created).upload(file);
|
|
215
|
+
const { data: updated } = await this.applicationService.update({
|
|
216
|
+
id: created.id,
|
|
217
|
+
activeVersionId: `${binary.id}`
|
|
218
|
+
});
|
|
219
|
+
return updated;
|
|
220
|
+
}
|
|
221
|
+
async findOwnedApp() {
|
|
222
|
+
try {
|
|
223
|
+
const currentTenantName = this.appState.currentTenant.value?.name;
|
|
224
|
+
const { data: apps } = await this.applicationService.list({
|
|
225
|
+
name: ICON_OPTIONS_CONTEXT_PATH,
|
|
226
|
+
pageSize: 1,
|
|
227
|
+
owner: currentTenantName,
|
|
228
|
+
type: ApplicationType.HOSTED
|
|
229
|
+
});
|
|
230
|
+
return apps[0];
|
|
231
|
+
}
|
|
232
|
+
catch (e) {
|
|
233
|
+
console.error('[c8y] Failed to list applications when looking for icon options app:', e);
|
|
234
|
+
throw e;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: CustomIconStorageService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
|
|
238
|
+
static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: CustomIconStorageService, providedIn: 'root' }); }
|
|
239
|
+
}
|
|
240
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: CustomIconStorageService, decorators: [{
|
|
241
|
+
type: Injectable,
|
|
242
|
+
args: [{ providedIn: 'root' }]
|
|
243
|
+
}] });
|
|
244
|
+
|
|
245
|
+
// ESM build exports `default`; CJS/Jest wraps the instance at module.exports directly.
|
|
246
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
247
|
+
const DOMPurify = (DOMPurifyNS.default ?? DOMPurifyNS);
|
|
248
|
+
const DANGEROUS_ELEMENTS = ['script', 'iframe', 'object', 'embed', 'foreignObject', 'style'];
|
|
249
|
+
/** Matches any event-handler attribute (on* in any case), covering all SVG event attributes. */
|
|
250
|
+
const DANGEROUS_ATTRIBUTE_PREFIX = /^on/i;
|
|
251
|
+
/** Matches a CSS url() reference that does not point to an internal #id fragment. */
|
|
252
|
+
const EXTERNAL_CSS_URL = /url\s*\(\s*['"]?\s*(?!#)/i;
|
|
253
|
+
/** DOMPurify config for SVG-only sanitization. */
|
|
254
|
+
const SVG_PURIFY_CONFIG = {
|
|
255
|
+
USE_PROFILES: { svg: true, svgFilters: true },
|
|
256
|
+
FORBID_TAGS: DANGEROUS_ELEMENTS,
|
|
257
|
+
RETURN_DOM: false,
|
|
258
|
+
RETURN_DOM_FRAGMENT: false
|
|
259
|
+
};
|
|
260
|
+
// DOMPurify's SVG profile keeps href/xlink:href (e.g. on <image>) and style attributes.
|
|
261
|
+
// Strip external references it would otherwise let through; #id fragments stay valid.
|
|
262
|
+
DOMPurify.addHook('afterSanitizeAttributes', node => {
|
|
263
|
+
for (const attrName of ['href', 'xlink:href']) {
|
|
264
|
+
const value = node.getAttribute(attrName);
|
|
265
|
+
if (value && !value.startsWith('#')) {
|
|
266
|
+
node.removeAttribute(attrName);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
const style = node.getAttribute('style');
|
|
270
|
+
if (style && EXTERNAL_CSS_URL.test(style)) {
|
|
271
|
+
node.removeAttribute('style');
|
|
272
|
+
}
|
|
273
|
+
});
|
|
274
|
+
class SvgSanitizerService {
|
|
275
|
+
/** Validates SVG content without modifying it. Returns errors (blocking) and warnings (advisory). */
|
|
276
|
+
validate(svgContent) {
|
|
277
|
+
const errors = [];
|
|
278
|
+
const warnings = [];
|
|
279
|
+
if (!svgContent?.trim()) {
|
|
280
|
+
errors.push('SVG content is empty');
|
|
281
|
+
return { valid: false, errors, warnings };
|
|
282
|
+
}
|
|
283
|
+
let doc;
|
|
284
|
+
try {
|
|
285
|
+
doc = new DOMParser().parseFromString(svgContent, 'image/svg+xml');
|
|
286
|
+
}
|
|
287
|
+
catch {
|
|
288
|
+
errors.push('Failed to parse SVG as XML');
|
|
289
|
+
return { valid: false, errors, warnings };
|
|
290
|
+
}
|
|
291
|
+
const parserError = doc.querySelector('parsererror');
|
|
292
|
+
if (parserError) {
|
|
293
|
+
errors.push(`Malformed SVG: ${parserError.textContent?.substring(0, 200)}`);
|
|
294
|
+
return { valid: false, errors, warnings };
|
|
295
|
+
}
|
|
296
|
+
const svg = doc.documentElement;
|
|
297
|
+
if (svg.tagName !== 'svg') {
|
|
298
|
+
errors.push('Root element must be <svg>');
|
|
299
|
+
return { valid: false, errors, warnings };
|
|
300
|
+
}
|
|
301
|
+
if (!svg.getAttribute('viewBox')) {
|
|
302
|
+
warnings.push('Missing viewBox attribute — icon may not scale correctly');
|
|
303
|
+
}
|
|
304
|
+
for (const tag of DANGEROUS_ELEMENTS) {
|
|
305
|
+
if (svg.querySelector(tag)) {
|
|
306
|
+
errors.push(`Forbidden element <${tag}> detected`);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
// Include root <svg> itself — querySelectorAll('*') returns descendants only
|
|
310
|
+
const allElements = [svg, ...Array.from(svg.querySelectorAll('*'))];
|
|
311
|
+
for (const el of allElements) {
|
|
312
|
+
for (const attr of Array.from(el.attributes)) {
|
|
313
|
+
if (DANGEROUS_ATTRIBUTE_PREFIX.test(attr.name)) {
|
|
314
|
+
errors.push(`Forbidden event handler "${attr.name}" on <${el.tagName}>`);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
const href = el.getAttribute('xlink:href') || el.getAttribute('href');
|
|
318
|
+
if (href && !href.startsWith('#')) {
|
|
319
|
+
errors.push(`External reference detected: "${href}" — only internal #id references are allowed`);
|
|
320
|
+
}
|
|
321
|
+
const style = el.getAttribute('style');
|
|
322
|
+
if (style && EXTERNAL_CSS_URL.test(style)) {
|
|
323
|
+
errors.push(`External url() reference in style attribute on <${el.tagName}> — only internal #id references are allowed`);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
const fillAttrs = svg.querySelectorAll('[fill]');
|
|
327
|
+
const hasHardcodedColor = Array.from(fillAttrs).some(el => {
|
|
328
|
+
const fill = el.getAttribute('fill');
|
|
329
|
+
return fill && fill !== 'none' && fill !== 'currentColor' && !fill.startsWith('url(');
|
|
330
|
+
});
|
|
331
|
+
if (hasHardcodedColor) {
|
|
332
|
+
warnings.push('Hardcoded fill colors detected — use "currentColor" for theme-adaptive icons');
|
|
333
|
+
}
|
|
334
|
+
return { valid: errors.length === 0, errors, warnings };
|
|
335
|
+
}
|
|
336
|
+
/** Removes dangerous elements and attributes from SVG using DOMPurify.
|
|
337
|
+
* Output is safe for use with DomSanitizer.bypassSecurityTrustHtml(). */
|
|
338
|
+
sanitize(svgContent) {
|
|
339
|
+
return DOMPurify.sanitize(svgContent, SVG_PURIFY_CONFIG);
|
|
340
|
+
}
|
|
341
|
+
/** Replaces hardcoded fill/stroke colors with `currentColor` so icons adapt to the theme. */
|
|
342
|
+
normalizeColors(svgContent) {
|
|
343
|
+
const doc = new DOMParser().parseFromString(svgContent, 'image/svg+xml');
|
|
344
|
+
if (doc.querySelector('parsererror')) {
|
|
345
|
+
// Return as-is if malformed — sanitize() already validated structure
|
|
346
|
+
return svgContent;
|
|
347
|
+
}
|
|
348
|
+
const svg = doc.documentElement;
|
|
349
|
+
this.normalizeColorsOnNode(svg);
|
|
350
|
+
return new XMLSerializer().serializeToString(svg);
|
|
351
|
+
}
|
|
352
|
+
/** Percent-encodes a sanitized SVG string for use in a CSS `url()` data URI. */
|
|
353
|
+
toDataUri(sanitizedSvg) {
|
|
354
|
+
return `data:image/svg+xml,${encodeURIComponent(sanitizedSvg.trim().replace(/\s+/g, ' '))}`;
|
|
355
|
+
}
|
|
356
|
+
normalizeColorsOnNode(node) {
|
|
357
|
+
for (const attr of ['fill', 'stroke']) {
|
|
358
|
+
const value = node.getAttribute(attr);
|
|
359
|
+
if (value && value !== 'none' && value !== 'currentColor' && !value.startsWith('url(')) {
|
|
360
|
+
node.setAttribute(attr, 'currentColor');
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
for (const child of Array.from(node.children)) {
|
|
364
|
+
this.normalizeColorsOnNode(child);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: SvgSanitizerService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
|
|
368
|
+
static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: SvgSanitizerService, providedIn: 'root' }); }
|
|
369
|
+
}
|
|
370
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: SvgSanitizerService, decorators: [{
|
|
371
|
+
type: Injectable,
|
|
372
|
+
args: [{ providedIn: 'root' }]
|
|
373
|
+
}] });
|
|
374
|
+
|
|
375
|
+
const CUSTOM_CATEGORY_LABEL = gettext('Custom`icons-category`');
|
|
376
|
+
class CustomIconRegistryService {
|
|
377
|
+
constructor() {
|
|
378
|
+
/** Reactive signal holding the current set of custom icons. Updates on load, save, delete, or import. */
|
|
379
|
+
this.icons = signal([], ...(ngDevMode ? [{ debugName: "icons" }] : /* istanbul ignore next */ []));
|
|
380
|
+
/** True after a successful load — distinguishes "empty icons" from "never loaded or failed". */
|
|
381
|
+
this.loaded = signal(false, ...(ngDevMode ? [{ debugName: "loaded" }] : /* istanbul ignore next */ []));
|
|
382
|
+
/** Shared load stream: concurrent subscribers join the in-flight request, and the reset
|
|
383
|
+
* flags start a fresh request once the previous one completed or errored. */
|
|
384
|
+
this.load$ = defer(() => this._doLoad()).pipe(share({
|
|
385
|
+
connector: () => new ReplaySubject(1),
|
|
386
|
+
resetOnComplete: true,
|
|
387
|
+
resetOnError: true,
|
|
388
|
+
resetOnRefCountZero: true
|
|
389
|
+
}));
|
|
390
|
+
this.storage = inject(CustomIconStorageService);
|
|
391
|
+
this.zip = inject(ZipService);
|
|
392
|
+
this.svgSanitizer = inject(SvgSanitizerService);
|
|
393
|
+
this.fetch = inject(FetchClient);
|
|
394
|
+
}
|
|
395
|
+
/** Loads custom icons from storage and injects their CSS into the document.
|
|
396
|
+
* Concurrent calls share the same in-flight request — no duplicate fetches. */
|
|
397
|
+
load() {
|
|
398
|
+
return firstValueFrom(this.load$);
|
|
399
|
+
}
|
|
400
|
+
/** Saves or updates a custom icon (upsert by name + family) and refreshes CSS immediately.
|
|
401
|
+
* Matching on name alone would let e.g. a 'c8y' family entry replace a 'dlt' entry of the
|
|
402
|
+
* same name, silently dropping the other family's CSS rule. */
|
|
403
|
+
async saveIcon(def) {
|
|
404
|
+
const current = this.icons();
|
|
405
|
+
const existing = current.findIndex(i => i.name === def.name && i.family === def.family);
|
|
406
|
+
const updated = existing >= 0 ? current.map((i, idx) => (idx === existing ? def : i)) : [...current, def];
|
|
407
|
+
this.lastModified = await this.storage.saveIconSet(updated);
|
|
408
|
+
this.applyStyles(updated);
|
|
409
|
+
this.icons.set(updated);
|
|
410
|
+
}
|
|
411
|
+
/** Removes a custom icon by name + family, deletes its SVG files from the
|
|
412
|
+
* hosted app and refreshes CSS immediately. */
|
|
413
|
+
async deleteIcon(name, family) {
|
|
414
|
+
const current = this.icons();
|
|
415
|
+
const removed = current.find(i => i.name === name && i.family === family);
|
|
416
|
+
const updated = current.filter(i => !(i.name === name && i.family === family));
|
|
417
|
+
const filesToRemove = removed
|
|
418
|
+
? [
|
|
419
|
+
getIconFileName(removed),
|
|
420
|
+
...(removed.hasSecondary ? [getIconFileName(removed, true)] : [])
|
|
421
|
+
]
|
|
422
|
+
: [];
|
|
423
|
+
// The stored lastModified is deliberately NOT advanced: kept files are unchanged,
|
|
424
|
+
// and keeping their cache-busted URLs identical lets the browser cache serve them
|
|
425
|
+
// instead of refetching while the rebuilt app version is still propagating.
|
|
426
|
+
await this.storage.saveIconSet(updated, filesToRemove);
|
|
427
|
+
this.applyStyles(updated);
|
|
428
|
+
this.icons.set(updated);
|
|
429
|
+
}
|
|
430
|
+
/** Convert custom icons into DefaultIconDefinition[] grouped by category for the selector.
|
|
431
|
+
* Override icons are excluded — they only replace built-in CSS rendering, not add new grid entries. */
|
|
432
|
+
toIconDefinitions() {
|
|
433
|
+
const byCategory = new Map();
|
|
434
|
+
for (const icon of this.icons()) {
|
|
435
|
+
if (icon.isOverride)
|
|
436
|
+
continue;
|
|
437
|
+
const category = icon.category || CUSTOM_CATEGORY_LABEL;
|
|
438
|
+
if (!byCategory.has(category)) {
|
|
439
|
+
byCategory.set(category, []);
|
|
440
|
+
}
|
|
441
|
+
byCategory.get(category).push([icon.name]);
|
|
442
|
+
}
|
|
443
|
+
return Array.from(byCategory.entries()).map(([label, icons]) => ({ label, icons }));
|
|
444
|
+
}
|
|
445
|
+
/** Exports all custom icons as a ZIP containing icons.json and individual SVG files.
|
|
446
|
+
* Note: SVG content is only available in memory immediately after adding/editing icons.
|
|
447
|
+
* After page reload, icons loaded from storage have no svgContent in memory — those
|
|
448
|
+
* icons will be omitted from the SVG files in the export. */
|
|
449
|
+
async exportAsZip() {
|
|
450
|
+
const icons = this.icons();
|
|
451
|
+
const manifest = icons.map(({ name, category, family, isOverride, overriddenIconName, hasSecondary }) => ({
|
|
452
|
+
name,
|
|
453
|
+
category,
|
|
454
|
+
family,
|
|
455
|
+
isOverride,
|
|
456
|
+
overriddenIconName,
|
|
457
|
+
hasSecondary
|
|
458
|
+
}));
|
|
459
|
+
const iconPromises = new Array();
|
|
460
|
+
for (const icon of manifest) {
|
|
461
|
+
const svgsOfIconPromises = new Array();
|
|
462
|
+
const primary = this.downloadSVG(icon);
|
|
463
|
+
svgsOfIconPromises.push(primary);
|
|
464
|
+
if (icon.hasSecondary) {
|
|
465
|
+
const secondary = this.downloadSVG(icon, true);
|
|
466
|
+
svgsOfIconPromises.push(secondary);
|
|
467
|
+
}
|
|
468
|
+
iconPromises.push(Promise.all(svgsOfIconPromises)
|
|
469
|
+
.then(filesOfIcon => {
|
|
470
|
+
return { icon, filesOfIcon };
|
|
471
|
+
})
|
|
472
|
+
.catch(() => {
|
|
473
|
+
return { icon, filesOfIcon: [] }; // Skip icons whose SVGs can't be loaded
|
|
474
|
+
}));
|
|
475
|
+
}
|
|
476
|
+
const iconFiles = await Promise.all(iconPromises);
|
|
477
|
+
const iconsWithFiles = iconFiles.filter(i => i.filesOfIcon.length > 0);
|
|
478
|
+
const filteredManifest = iconsWithFiles.map(i => i.icon);
|
|
479
|
+
const files = [
|
|
480
|
+
{ fileName: 'icons.json', blob: new Blob([JSON.stringify(filteredManifest, null, 2)]) },
|
|
481
|
+
...iconsWithFiles.flatMap(i => i.filesOfIcon)
|
|
482
|
+
];
|
|
483
|
+
return this.zip.createZip(files);
|
|
484
|
+
}
|
|
485
|
+
/** Returns a copy of the icon with its SVG content loaded from the hosted app when
|
|
486
|
+
* not in memory — icons loaded from the manifest after a reload are metadata-only.
|
|
487
|
+
* A missing file is non-fatal: the field stays empty and the edit modal falls back
|
|
488
|
+
* to requiring a re-upload. */
|
|
489
|
+
async withSvgContent(icon) {
|
|
490
|
+
const result = { ...icon };
|
|
491
|
+
try {
|
|
492
|
+
if (!result.svgContentPrimary) {
|
|
493
|
+
const { blob } = await this.downloadSVG(icon);
|
|
494
|
+
result.svgContentPrimary = await blob.text();
|
|
495
|
+
}
|
|
496
|
+
if (icon.hasSecondary && !result.svgContentSecondary) {
|
|
497
|
+
const { blob } = await this.downloadSVG(icon, true);
|
|
498
|
+
result.svgContentSecondary = await blob.text();
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
catch (e) {
|
|
502
|
+
console.error('[c8y] Failed to load SVG content of custom icon:', e);
|
|
503
|
+
}
|
|
504
|
+
return result;
|
|
505
|
+
}
|
|
506
|
+
async downloadSVG(icon, isSecondary = false) {
|
|
507
|
+
const fileName = getIconFileName(icon, isSecondary);
|
|
508
|
+
const absolutePath = convertRelativePathToAbsolutePath(fileName);
|
|
509
|
+
const response = await this.fetch.fetch(absolutePath, { cache: 'no-cache' });
|
|
510
|
+
if (response.status !== 200) {
|
|
511
|
+
throw new Error(`Failed to fetch SVG: ${response.status}`);
|
|
512
|
+
}
|
|
513
|
+
const blob = await response.blob();
|
|
514
|
+
return { fileName, blob };
|
|
515
|
+
}
|
|
516
|
+
/** Imports icons from a ZIP export. Icons whose names already exist are skipped. */
|
|
517
|
+
async importFromZip(zipFile) {
|
|
518
|
+
const entries = await firstValueFrom(this.zip.getEntries(zipFile));
|
|
519
|
+
const manifestEntry = entries.find(e => e.filename === ICON_OPTIONS_FILE);
|
|
520
|
+
if (!manifestEntry) {
|
|
521
|
+
throw new Error(`Invalid export ZIP: missing ${ICON_OPTIONS_FILE}`);
|
|
522
|
+
}
|
|
523
|
+
const manifestBlob = await firstValueFrom(this.zip.getData(manifestEntry));
|
|
524
|
+
const manifest = JSON.parse(await manifestBlob.text());
|
|
525
|
+
const existing = new Set(this.icons().map(i => i.name));
|
|
526
|
+
const skipped = [];
|
|
527
|
+
const toAdd = [];
|
|
528
|
+
const VALID_NAME = /^[a-z0-9][a-z0-9-]*$/;
|
|
529
|
+
const VALID_FAMILIES = new Set(['dlt', 'c8y']);
|
|
530
|
+
for (const meta of manifest) {
|
|
531
|
+
if (!meta.name || !VALID_NAME.test(meta.name) || !VALID_FAMILIES.has(meta.family)) {
|
|
532
|
+
skipped.push(meta.name ?? '(invalid)');
|
|
533
|
+
continue;
|
|
534
|
+
}
|
|
535
|
+
if (existing.has(meta.name)) {
|
|
536
|
+
skipped.push(meta.name);
|
|
537
|
+
continue;
|
|
538
|
+
}
|
|
539
|
+
const primaryFileName = getIconFileName(meta);
|
|
540
|
+
const svgEntry = entries.find(e => e.filename === primaryFileName);
|
|
541
|
+
if (!svgEntry) {
|
|
542
|
+
skipped.push(meta.name);
|
|
543
|
+
continue;
|
|
544
|
+
}
|
|
545
|
+
const svgBlob = await firstValueFrom(this.zip.getData(svgEntry));
|
|
546
|
+
const svgContent = this.svgSanitizer.sanitize(await svgBlob.text());
|
|
547
|
+
let svgContentSecondary;
|
|
548
|
+
if (meta.hasSecondary) {
|
|
549
|
+
const secondaryFileName = getIconFileName(meta, true);
|
|
550
|
+
const secondaryEntry = entries.find(e => e.filename === secondaryFileName);
|
|
551
|
+
if (secondaryEntry) {
|
|
552
|
+
const secBlob = await firstValueFrom(this.zip.getData(secondaryEntry));
|
|
553
|
+
svgContentSecondary = this.svgSanitizer.sanitize(await secBlob.text());
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
toAdd.push({
|
|
557
|
+
...meta,
|
|
558
|
+
svgContentPrimary: svgContent,
|
|
559
|
+
svgContentSecondary,
|
|
560
|
+
hasSecondary: !!svgContentSecondary
|
|
561
|
+
});
|
|
562
|
+
}
|
|
563
|
+
if (toAdd.length) {
|
|
564
|
+
const updated = [...this.icons(), ...toAdd];
|
|
565
|
+
this.lastModified = await this.storage.saveIconSet(updated);
|
|
566
|
+
this.applyStyles(updated);
|
|
567
|
+
this.icons.set(updated);
|
|
568
|
+
}
|
|
569
|
+
return { imported: toAdd.length, skipped };
|
|
570
|
+
}
|
|
571
|
+
/** Injects CSS built from the in-memory icon set. The manifest is deliberately NOT
|
|
572
|
+
* re-fetched — right after a save the hosted app can still serve the previous
|
|
573
|
+
* version, which would re-inject rules for icons that were just removed. The stored
|
|
574
|
+
* lastModified keeps unchanged icons' cache-busted URLs stable, so the browser cache
|
|
575
|
+
* serves them instead of refetching while a new app version is still propagating. */
|
|
576
|
+
applyStyles(icons) {
|
|
577
|
+
applyCustomIconStyles({ icons, lastModified: this.lastModified });
|
|
578
|
+
}
|
|
579
|
+
async _doLoad() {
|
|
580
|
+
const set = await this.storage.getIconSet();
|
|
581
|
+
this.lastModified = set.lastModified;
|
|
582
|
+
this.icons.set(set.icons);
|
|
583
|
+
this.loaded.set(true);
|
|
584
|
+
}
|
|
585
|
+
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: CustomIconRegistryService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
|
|
586
|
+
static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: CustomIconRegistryService, providedIn: 'root' }); }
|
|
587
|
+
}
|
|
588
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: CustomIconRegistryService, decorators: [{
|
|
589
|
+
type: Injectable,
|
|
590
|
+
args: [{ providedIn: 'root' }]
|
|
591
|
+
}] });
|
|
592
|
+
|
|
593
|
+
// OnPush is intentionally omitted: c8y-form-group's debounced updateErrors fires via setTimeout,
|
|
594
|
+
// which does not mark an OnPush component dirty. Without Default CD, validation messages appear
|
|
595
|
+
// one cycle late (red border shows but text stays hidden until next interaction).
|
|
596
|
+
class AddCustomIconModalComponent {
|
|
597
|
+
/** Modal mode: 'add' (new icon), 'edit' (existing icon), 'override' (replace built-in).
|
|
598
|
+
* Plain property facade over a signal — BsModalService's `initialState` assigns it as a
|
|
599
|
+
* property, while `isEditMode`/`isOverrideMode` derive from the backing signal. */
|
|
600
|
+
get mode() {
|
|
601
|
+
return this._mode();
|
|
602
|
+
}
|
|
603
|
+
set mode(value) {
|
|
604
|
+
this._mode.set(value);
|
|
605
|
+
}
|
|
606
|
+
constructor() {
|
|
607
|
+
this._mode = signal('add', ...(ngDevMode ? [{ debugName: "_mode" }] : /* istanbul ignore next */ []));
|
|
608
|
+
/** Icon being edited/overridden. Null for 'add' mode. */
|
|
609
|
+
this.editingIcon = null;
|
|
610
|
+
/** Available category labels for the dropdown. */
|
|
611
|
+
this.allCategories = [];
|
|
612
|
+
/** Built-in + existing custom icon names — used for collision detection. */
|
|
613
|
+
this.reservedIconNames = new Set();
|
|
614
|
+
this.previewSizes = [20, 32, 48];
|
|
615
|
+
this.isReady = signal(false, ...(ngDevMode ? [{ debugName: "isReady" }] : /* istanbul ignore next */ []));
|
|
616
|
+
this.isEditMode = computed(() => this._mode() === 'edit', ...(ngDevMode ? [{ debugName: "isEditMode" }] : /* istanbul ignore next */ []));
|
|
617
|
+
this.isOverrideMode = computed(() => this._mode() === 'override', ...(ngDevMode ? [{ debugName: "isOverrideMode" }] : /* istanbul ignore next */ []));
|
|
618
|
+
this.selectedFamily = signal('dlt', ...(ngDevMode ? [{ debugName: "selectedFamily" }] : /* istanbul ignore next */ []));
|
|
619
|
+
this.primarySvg = signal(null, ...(ngDevMode ? [{ debugName: "primarySvg" }] : /* istanbul ignore next */ []));
|
|
620
|
+
this.primaryValidation = signal(null, ...(ngDevMode ? [{ debugName: "primaryValidation" }] : /* istanbul ignore next */ []));
|
|
621
|
+
this.lightSvg = signal(null, ...(ngDevMode ? [{ debugName: "lightSvg" }] : /* istanbul ignore next */ []));
|
|
622
|
+
this.lightValidation = signal(null, ...(ngDevMode ? [{ debugName: "lightValidation" }] : /* istanbul ignore next */ []));
|
|
623
|
+
this.showCompositePreview = computed(() => this.selectedFamily() === 'c8y' && !!this.primarySvg() && !!this.lightSvg(), ...(ngDevMode ? [{ debugName: "showCompositePreview" }] : /* istanbul ignore next */ []));
|
|
624
|
+
this.modalTitle = computed(() => this.isOverrideMode()
|
|
625
|
+
? gettext('Replace icon')
|
|
626
|
+
: this.isEditMode()
|
|
627
|
+
? gettext('Edit icon')
|
|
628
|
+
: gettext('Add icon'), ...(ngDevMode ? [{ debugName: "modalTitle" }] : /* istanbul ignore next */ []));
|
|
629
|
+
this.okLabel = computed(() => this.isOverrideMode()
|
|
630
|
+
? gettext('Replace')
|
|
631
|
+
: this.isEditMode()
|
|
632
|
+
? gettext('Save changes')
|
|
633
|
+
: gettext('Add icon'), ...(ngDevMode ? [{ debugName: "okLabel" }] : /* istanbul ignore next */ []));
|
|
634
|
+
this.isValid = computed(() => {
|
|
635
|
+
if (!this.primarySvg())
|
|
636
|
+
return false;
|
|
637
|
+
const primary = this.primaryValidation();
|
|
638
|
+
if (!primary?.valid)
|
|
639
|
+
return false;
|
|
640
|
+
if (this.selectedFamily() === 'c8y')
|
|
641
|
+
return !!this.lightSvg() && !!this.lightValidation()?.valid;
|
|
642
|
+
return true;
|
|
643
|
+
}, ...(ngDevMode ? [{ debugName: "isValid" }] : /* istanbul ignore next */ []));
|
|
644
|
+
/** Set on the first save attempt — validation messages only appear from then on,
|
|
645
|
+
* so an untouched form doesn't open covered in errors. */
|
|
646
|
+
this.saveAttempted = signal(false, ...(ngDevMode ? [{ debugName: "saveAttempted" }] : /* istanbul ignore next */ []));
|
|
647
|
+
this.hasCategoryError = computed(() => this.saveAttempted() && !this.categorySearch(), ...(ngDevMode ? [{ debugName: "hasCategoryError" }] : /* istanbul ignore next */ []));
|
|
648
|
+
this.categoryErrors = computed(() => this.hasCategoryError() ? { required: true } : {}, ...(ngDevMode ? [{ debugName: "categoryErrors" }] : /* istanbul ignore next */ []));
|
|
649
|
+
this.primarySvgErrors = computed(() => this.saveAttempted() && !this.primarySvg() ? { required: true } : {}, ...(ngDevMode ? [{ debugName: "primarySvgErrors" }] : /* istanbul ignore next */ []));
|
|
650
|
+
this.lightSvgErrors = computed(() => this.saveAttempted() && this.selectedFamily() === 'c8y' && !this.lightSvg()
|
|
651
|
+
? { required: true }
|
|
652
|
+
: {}, ...(ngDevMode ? [{ debugName: "lightSvgErrors" }] : /* istanbul ignore next */ []));
|
|
653
|
+
/** Boolean error state for the SVG upload form groups — drives `c8y-form-group [hasError]`
|
|
654
|
+
* so the label and messages pick up the validation styling (the drop area is not a form control). */
|
|
655
|
+
this.hasPrimarySvgError = computed(() => this.saveAttempted() && !this.primarySvg(), ...(ngDevMode ? [{ debugName: "hasPrimarySvgError" }] : /* istanbul ignore next */ []));
|
|
656
|
+
this.hasLightSvgError = computed(() => this.saveAttempted() && this.selectedFamily() === 'c8y' && !this.lightSvg(), ...(ngDevMode ? [{ debugName: "hasLightSvgError" }] : /* istanbul ignore next */ []));
|
|
657
|
+
this.categorySearch = signal('', ...(ngDevMode ? [{ debugName: "categorySearch" }] : /* istanbul ignore next */ []));
|
|
658
|
+
this.baseCategories = computed(() => this.allCategories.length ? this.allCategories : ['Custom'], ...(ngDevMode ? [{ debugName: "baseCategories" }] : /* istanbul ignore next */ []));
|
|
659
|
+
/** Categories matching the current search text — drives the typeahead dropdown list. */
|
|
660
|
+
this.filteredCategories = computed(() => this.baseCategories().filter(c => this.isCategoryMatch(c)), ...(ngDevMode ? [{ debugName: "filteredCategories" }] : /* istanbul ignore next */ []));
|
|
661
|
+
this.hasNoMatch = computed(() => !!this.categorySearch() && this.filteredCategories().length === 0, ...(ngDevMode ? [{ debugName: "hasNoMatch" }] : /* istanbul ignore next */ []));
|
|
662
|
+
/** True when the typed text is non-empty and doesn't exactly match an existing category. */
|
|
663
|
+
this.showAddNew = computed(() => {
|
|
664
|
+
const search = this.categorySearch();
|
|
665
|
+
if (!search)
|
|
666
|
+
return false;
|
|
667
|
+
const searchLower = search.toLowerCase();
|
|
668
|
+
return !this.baseCategories().some(c => c.replace(/`[^`]*`$/, '').toLowerCase() === searchLower);
|
|
669
|
+
}, ...(ngDevMode ? [{ debugName: "showAddNew" }] : /* istanbul ignore next */ []));
|
|
670
|
+
this.categoryTypeahead = viewChild('categoryTypeahead', ...(ngDevMode ? [{ debugName: "categoryTypeahead" }] : /* istanbul ignore next */ []));
|
|
671
|
+
this.nameMessages = [
|
|
672
|
+
{ name: 'required', text: gettext('Icon name is required.') },
|
|
673
|
+
{ name: 'pattern', text: gettext('Only lowercase letters, numbers and hyphens are allowed.') },
|
|
674
|
+
{
|
|
675
|
+
name: 'c8yPrefix',
|
|
676
|
+
text: gettext('DLT (single color) icons must not start with "c8y-". Use the C8Y (duocolor) type for c8y- prefixed icons.')
|
|
677
|
+
},
|
|
678
|
+
{
|
|
679
|
+
name: 'nameCollision',
|
|
680
|
+
text: gettext('This name conflicts with a built-in icon. Use the Override option instead, or choose a different name.')
|
|
681
|
+
}
|
|
682
|
+
];
|
|
683
|
+
this.primaryPreviewHtml = this.buildPreviewComputed(this.primarySvg);
|
|
684
|
+
this.lightPreviewHtml = this.buildPreviewComputed(this.lightSvg);
|
|
685
|
+
this.form = inject(FormBuilder).group({
|
|
686
|
+
name: [
|
|
687
|
+
'',
|
|
688
|
+
[
|
|
689
|
+
Validators.required,
|
|
690
|
+
Validators.pattern(/^[a-z0-9][a-z0-9-]*$/),
|
|
691
|
+
this.c8yPrefixValidator(),
|
|
692
|
+
this.nameCollisionValidator()
|
|
693
|
+
]
|
|
694
|
+
],
|
|
695
|
+
family: ['dlt'],
|
|
696
|
+
category: ['', Validators.required]
|
|
697
|
+
});
|
|
698
|
+
this.bsModal = inject(BsModalRef);
|
|
699
|
+
this.svgSanitizer = inject(SvgSanitizerService);
|
|
700
|
+
this.domSanitizer = inject(DomSanitizer);
|
|
701
|
+
this.translateService = inject(TranslateService);
|
|
702
|
+
/** Modal body — only rendered once `isReady` is true. */
|
|
703
|
+
this.modalBody = viewChild('modalBody', ...(ngDevMode ? [{ debugName: "modalBody" }] : /* istanbul ignore next */ []));
|
|
704
|
+
this.result = new Promise(res => {
|
|
705
|
+
this.resolve = res;
|
|
706
|
+
});
|
|
707
|
+
// Focus the first interactive field as soon as the body renders — keyboard/
|
|
708
|
+
// screen-reader users don't need to re-navigate from the modal title.
|
|
709
|
+
effect(() => {
|
|
710
|
+
const body = this.modalBody();
|
|
711
|
+
if (!body)
|
|
712
|
+
return;
|
|
713
|
+
body.nativeElement
|
|
714
|
+
.querySelector('input:not([disabled]), button:not([disabled]), select:not([disabled])')
|
|
715
|
+
?.focus();
|
|
716
|
+
});
|
|
717
|
+
}
|
|
718
|
+
ngOnInit() {
|
|
719
|
+
// Deferred so Bootstrap animates the modal at its full (spinner) size first.
|
|
720
|
+
setTimeout(() => this.isReady.set(true));
|
|
721
|
+
// Disable via the forms API — an [attr.disabled] binding gets stripped when
|
|
722
|
+
// reactive forms syncs the enabled control state on init.
|
|
723
|
+
if (this.isEditMode()) {
|
|
724
|
+
this.form.get('name')?.disable();
|
|
725
|
+
}
|
|
726
|
+
if (this.editingIcon) {
|
|
727
|
+
this.selectedFamily.set(this.editingIcon.family);
|
|
728
|
+
const category = this.editingIcon.category || 'Custom';
|
|
729
|
+
this.categorySearch.set(category.replace(/`[^`]*`$/, ''));
|
|
730
|
+
this.form.patchValue({
|
|
731
|
+
name: this.editingIcon.name,
|
|
732
|
+
family: this.editingIcon.family,
|
|
733
|
+
category
|
|
734
|
+
});
|
|
735
|
+
setTimeout(() => {
|
|
736
|
+
const th = this.categoryTypeahead();
|
|
737
|
+
if (th?.searchControl) {
|
|
738
|
+
th.searchControl.nativeElement.value = category.replace(/`[^`]*`$/, '');
|
|
739
|
+
}
|
|
740
|
+
});
|
|
741
|
+
if (this.editingIcon.svgContentPrimary) {
|
|
742
|
+
this.primarySvg.set(this.editingIcon.svgContentPrimary);
|
|
743
|
+
this.primaryValidation.set(this.svgSanitizer.validate(this.editingIcon.svgContentPrimary));
|
|
744
|
+
}
|
|
745
|
+
if (this.editingIcon.svgContentSecondary) {
|
|
746
|
+
this.lightSvg.set(this.editingIcon.svgContentSecondary);
|
|
747
|
+
this.lightValidation.set(this.svgSanitizer.validate(this.editingIcon.svgContentSecondary));
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
onFamilyChange(family) {
|
|
752
|
+
this.selectedFamily.set(family);
|
|
753
|
+
this.form.patchValue({ family });
|
|
754
|
+
// Re-run name validators since c8yPrefixValidator depends on selectedFamily
|
|
755
|
+
this.form.get('name')?.updateValueAndValidity();
|
|
756
|
+
if (family === 'dlt') {
|
|
757
|
+
this.lightSvg.set(null);
|
|
758
|
+
this.lightValidation.set(null);
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
async onPrimaryDropped(files) {
|
|
762
|
+
if (!files.length)
|
|
763
|
+
return;
|
|
764
|
+
const raw = await files[0].readAsText();
|
|
765
|
+
const { content, validation } = this.processUpload(raw);
|
|
766
|
+
this.primarySvg.set(content);
|
|
767
|
+
this.primaryValidation.set(validation);
|
|
768
|
+
if (!this.form.getRawValue().name) {
|
|
769
|
+
const nameFromFile = files[0].file.name
|
|
770
|
+
.replace(/\.svg$/i, '')
|
|
771
|
+
.toLowerCase()
|
|
772
|
+
.replace(/[^a-z0-9-]/g, '-')
|
|
773
|
+
.replace(/-+/g, '-')
|
|
774
|
+
.replace(/^-|-$/g, '');
|
|
775
|
+
this.form.patchValue({ name: nameFromFile });
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
async onLightDropped(files) {
|
|
779
|
+
if (!files.length)
|
|
780
|
+
return;
|
|
781
|
+
const raw = await files[0].readAsText();
|
|
782
|
+
const { content, validation } = this.processUpload(raw);
|
|
783
|
+
this.lightSvg.set(content);
|
|
784
|
+
this.lightValidation.set(validation);
|
|
785
|
+
}
|
|
786
|
+
clearPrimary() {
|
|
787
|
+
this.primarySvg.set(null);
|
|
788
|
+
this.primaryValidation.set(null);
|
|
789
|
+
}
|
|
790
|
+
clearLight() {
|
|
791
|
+
this.lightSvg.set(null);
|
|
792
|
+
this.lightValidation.set(null);
|
|
793
|
+
}
|
|
794
|
+
/** Called on every keystroke in the typeahead — filters categories and sets the form value. */
|
|
795
|
+
onCategorySearch(text) {
|
|
796
|
+
this.categorySearch.set(text);
|
|
797
|
+
this.form.patchValue({ category: text });
|
|
798
|
+
this.form.get('category')?.markAsTouched();
|
|
799
|
+
}
|
|
800
|
+
clearCategory() {
|
|
801
|
+
this.categorySearch.set('');
|
|
802
|
+
this.form.patchValue({ category: '' });
|
|
803
|
+
this.form.get('category')?.markAsTouched();
|
|
804
|
+
const th = this.categoryTypeahead();
|
|
805
|
+
th?.reset();
|
|
806
|
+
}
|
|
807
|
+
/** Selects an existing category or confirms a new one from the typeahead dropdown. */
|
|
808
|
+
selectCategory(name) {
|
|
809
|
+
const trimmed = name.trim();
|
|
810
|
+
if (!trimmed)
|
|
811
|
+
return;
|
|
812
|
+
const displayName = trimmed.replace(/`[^`]*`$/, '');
|
|
813
|
+
this.categorySearch.set(displayName); // store display name — filter uses this for comparison
|
|
814
|
+
this.form.patchValue({ category: trimmed }); // store raw value (preserves gettext key for built-ins)
|
|
815
|
+
this.form.get('category')?.markAsTouched();
|
|
816
|
+
const th = this.categoryTypeahead();
|
|
817
|
+
if (th?.searchControl) {
|
|
818
|
+
th.searchControl.nativeElement.value = displayName;
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
save() {
|
|
822
|
+
// Save stays enabled while invalid — the first attempt surfaces the validation
|
|
823
|
+
// messages instead of leaving the user guessing why a disabled button won't work.
|
|
824
|
+
if (!this.isValid() || (this.form.invalid && !this.isOverrideMode())) {
|
|
825
|
+
this.saveAttempted.set(true);
|
|
826
|
+
this.form.markAllAsTouched();
|
|
827
|
+
return;
|
|
828
|
+
}
|
|
829
|
+
// getRawValue(): the name control is disabled in edit mode and would be
|
|
830
|
+
// missing from form.value
|
|
831
|
+
let iconName = this.form.getRawValue().name ?? '';
|
|
832
|
+
// c8y family icons require the 'c8y-' prefix so the IconDirective routes
|
|
833
|
+
// them to the duocolor CSS pseudo-element rendering path (::before + ::after)
|
|
834
|
+
if (this.selectedFamily() === 'c8y' && !iconName.startsWith('c8y-')) {
|
|
835
|
+
iconName = `c8y-${iconName}`;
|
|
836
|
+
}
|
|
837
|
+
// Safe: isValid() guard above ensures primarySvg() is non-null. The signals already
|
|
838
|
+
// hold sanitized + color-normalized content — onPrimaryDropped()/onLightDropped()
|
|
839
|
+
// process uploads, and editingIcon content was processed when originally saved.
|
|
840
|
+
const result = {
|
|
841
|
+
name: iconName,
|
|
842
|
+
family: this.selectedFamily(),
|
|
843
|
+
// Overrides replace a built-in icon and belong to no collection
|
|
844
|
+
category: this.isOverrideMode() ? '' : (this.form.value.category ?? 'Custom'),
|
|
845
|
+
svgContentPrimary: this.primarySvg(),
|
|
846
|
+
isOverride: this.isOverrideMode(),
|
|
847
|
+
overriddenIconName: this.editingIcon?.overriddenIconName
|
|
848
|
+
};
|
|
849
|
+
if (this.selectedFamily() === 'c8y' && this.lightSvg()) {
|
|
850
|
+
result.svgContentSecondary = this.lightSvg();
|
|
851
|
+
result.hasSecondary = true;
|
|
852
|
+
}
|
|
853
|
+
this.resolve(result);
|
|
854
|
+
this.bsModal.hide();
|
|
855
|
+
}
|
|
856
|
+
cancel() {
|
|
857
|
+
this.resolve(null);
|
|
858
|
+
this.bsModal.hide();
|
|
859
|
+
}
|
|
860
|
+
/** Overrides c8y-modal's default hide-on-OK — save() decides whether the modal
|
|
861
|
+
* closes (valid) or stays open showing the validation messages. */
|
|
862
|
+
keepOpenOnOk() { }
|
|
863
|
+
/** Sanitizes an uploaded SVG and validates the result. Issues found in the raw
|
|
864
|
+
* upload but gone after sanitizing were removed content — surfaced as warnings
|
|
865
|
+
* so the user knows the stored SVG differs from the uploaded file. */
|
|
866
|
+
processUpload(raw) {
|
|
867
|
+
const rawValidation = this.svgSanitizer.validate(raw);
|
|
868
|
+
const content = this.svgSanitizer.normalizeColors(this.svgSanitizer.sanitize(raw));
|
|
869
|
+
const validation = this.svgSanitizer.validate(content);
|
|
870
|
+
const removed = rawValidation.errors.filter(e => !validation.errors.includes(e));
|
|
871
|
+
if (removed.length) {
|
|
872
|
+
validation.warnings = [
|
|
873
|
+
this.translateService.instant(gettext('Unsafe content was removed from the uploaded SVG:')),
|
|
874
|
+
...removed,
|
|
875
|
+
...validation.warnings
|
|
876
|
+
];
|
|
877
|
+
}
|
|
878
|
+
return { content, validation };
|
|
879
|
+
}
|
|
880
|
+
/** Returns true when the category label matches the current search text. */
|
|
881
|
+
isCategoryMatch(cat) {
|
|
882
|
+
const search = this.categorySearch().toLowerCase();
|
|
883
|
+
if (!search)
|
|
884
|
+
return true;
|
|
885
|
+
return cat
|
|
886
|
+
.replace(/`[^`]*`$/, '')
|
|
887
|
+
.toLowerCase()
|
|
888
|
+
.includes(search);
|
|
889
|
+
}
|
|
890
|
+
buildPreviewComputed(svgSignal) {
|
|
891
|
+
return computed(() => {
|
|
892
|
+
const svg = svgSignal();
|
|
893
|
+
if (!svg)
|
|
894
|
+
return null;
|
|
895
|
+
let sanitized = this.svgSanitizer.sanitize(svg);
|
|
896
|
+
sanitized = sanitized.replace(/<svg/i, '<svg width="100%" height="100%" style="display:block"');
|
|
897
|
+
// Safe: sanitize() already removed all dangerous elements, event handlers, and
|
|
898
|
+
// external references. The only modification after sanitize() is adding safe sizing attributes.
|
|
899
|
+
return this.domSanitizer.bypassSecurityTrustHtml(sanitized);
|
|
900
|
+
});
|
|
901
|
+
}
|
|
902
|
+
c8yPrefixValidator() {
|
|
903
|
+
return (control) => {
|
|
904
|
+
if (typeof control.value === 'string' &&
|
|
905
|
+
control.value.startsWith('c8y-') &&
|
|
906
|
+
this.selectedFamily() === 'dlt') {
|
|
907
|
+
return { c8yPrefix: {} };
|
|
908
|
+
}
|
|
909
|
+
return null;
|
|
910
|
+
};
|
|
911
|
+
}
|
|
912
|
+
nameCollisionValidator() {
|
|
913
|
+
return (control) => {
|
|
914
|
+
const name = typeof control.value === 'string' ? control.value : '';
|
|
915
|
+
if (!name) {
|
|
916
|
+
return null; // other validators will catch empty name
|
|
917
|
+
}
|
|
918
|
+
const nonPrefixedNewName = name.replace(/^c8y-/, '');
|
|
919
|
+
const hasConflict = Array.from(this.reservedIconNames).some(iconName => {
|
|
920
|
+
const nonPrefixedIconName = iconName.replace(/^c8y-/, '');
|
|
921
|
+
return (iconName === name ||
|
|
922
|
+
iconName === nonPrefixedNewName ||
|
|
923
|
+
nonPrefixedIconName === name ||
|
|
924
|
+
nonPrefixedIconName === nonPrefixedNewName);
|
|
925
|
+
});
|
|
926
|
+
if (hasConflict) {
|
|
927
|
+
return { nameCollision: {} };
|
|
928
|
+
}
|
|
929
|
+
return null;
|
|
930
|
+
};
|
|
931
|
+
}
|
|
932
|
+
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: AddCustomIconModalComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
|
|
933
|
+
static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.17", type: AddCustomIconModalComponent, isStandalone: true, selector: "c8y-add-custom-icon-modal", viewQueries: [{ propertyName: "categoryTypeahead", first: true, predicate: ["categoryTypeahead"], descendants: true, isSignal: true }, { propertyName: "modalBody", first: true, predicate: ["modalBody"], descendants: true, isSignal: true }], ngImport: i0, template: "<c8y-modal\n [title]=\"modalTitle() | translate\"\n [headerClasses]=\"'dialog-header'\"\n (onDismiss)=\"cancel()\"\n (onClose)=\"save()\"\n [close]=\"keepOpenOnOk\"\n [labels]=\"{ cancel: 'Cancel', ok: okLabel() | translate }\"\n>\n <ng-container c8y-modal-title>\n <span\n [c8yIcon]=\"isOverrideMode() ? 'replace' : isEditMode() ? 'pencil' : 'plus-circle-o'\"\n aria-hidden=\"true\"\n ></span>\n </ng-container>\n\n @if (!isReady()) {\n <!-- min-height matches the approximate form height so Bootstrap animates\n the modal at its full size before the form is rendered. -->\n <div\n class=\"p-32 d-flex a-i-center j-c-center\"\n style=\"min-height: 380px\"\n [attr.aria-label]=\"'Loading\u2026' | translate\"\n role=\"status\"\n >\n <c8y-loading></c8y-loading>\n </div>\n }\n\n @if (isReady()) {\n <div\n class=\"p-t-16 p-b-24 p-l-32 p-r-32\"\n #modalBody\n >\n @if (isOverrideMode()) {\n <p class=\"text-center text-16 text-bold text-balance m-b-16\">\n {{ 'Upload a replacement SVG for {{iconName}}' | translate: { iconName:\n editingIcon?.overriddenIconName } }}\n </p>\n }\n\n <!-- Name, collection and icon type (hidden in override mode) -->\n @if (!isOverrideMode()) {\n @if (!isEditMode()) {\n <p\n class=\"text-muted m-b-16\"\n translate\n >\n Upload a custom SVG icon and assign it to a collection to keep your icons organized.\n </p>\n }\n <div\n class=\"m-b-16\"\n [formGroup]=\"form\"\n >\n <c8y-form-group class=\"p-b-16\">\n <label\n for=\"iconName\"\n translate\n >Icon name</label\n >\n <input\n class=\"form-control\"\n id=\"iconName\"\n aria-required=\"true\"\n aria-describedby=\"iconName-messages\"\n type=\"text\"\n [placeholder]=\"'e.g. {{ example }}' | translate: { example: 'acme-logo' }\"\n formControlName=\"name\"\n />\n <c8y-messages\n id=\"iconName-messages\"\n role=\"status\"\n [helpMessage]=\"'Lowercase letters, numbers and hyphens only' | translate\"\n >\n @for (message of nameMessages; track message.name) {\n <c8y-message\n [name]=\"message.name\"\n [text]=\"message.text | translate\"\n ></c8y-message>\n }\n </c8y-messages>\n </c8y-form-group>\n\n <c8y-form-group [hasError]=\"hasCategoryError()\">\n <label\n for=\"iconCategory\"\n translate\n >Collection</label\n >\n <div class=\"p-relative\">\n <c8y-typeahead\n [attr.aria-label]=\"'Icon collection' | translate\"\n id=\"iconCategory\"\n #categoryTypeahead\n [allowFreeEntries]=\"true\"\n [hideNew]=\"true\"\n [placeholder]=\"'Select or enter a collection name' | translate\"\n (onSearch)=\"onCategorySearch($event)\"\n >\n @for (cat of filteredCategories(); track cat) {\n <c8y-li\n class=\"p-l-8 p-r-8 c8y-list__item--link\"\n (click)=\"selectCategory(cat)\"\n [active]=\"form.value.category === cat\"\n >{{ cat | translate }}</c8y-li\n >\n }\n @if (showAddNew()) {\n <c8y-li class=\"bg-level-2 p-8\">\n @if (hasNoMatch()) {\n <span\n class=\"m-r-8\"\n translate\n >No existing collection found.</span\n >\n }\n <button\n class=\"btn btn-default btn-xs\"\n type=\"button\"\n (click)=\"selectCategory(categorySearch())\"\n >\n {{ 'Add new collection`collection`' | translate }}\n </button>\n </c8y-li>\n }\n </c8y-typeahead>\n @if (categorySearch()) {\n <button\n class=\"btn btn-dot p-absolute\"\n style=\"right: 36px; z-index: 10\"\n [attr.aria-label]=\"'Clear collection' | translate\"\n type=\"button\"\n (click)=\"clearCategory()\"\n >\n <i\n [c8yIcon]=\"'times'\"\n aria-hidden=\"true\"\n ></i>\n </button>\n }\n </div>\n <c8y-messages\n role=\"alert\"\n [show]=\"categoryErrors()\"\n >\n <c8y-message\n [name]=\"'required'\"\n [text]=\"'Collection is required.' | translate\"\n ></c8y-message>\n </c8y-messages>\n </c8y-form-group>\n\n @if (!isEditMode()) {\n <fieldset class=\"c8y-fieldset m-b-24\">\n <legend translate>Icon type</legend>\n <div class=\"d-flex gap-16 p-b-16\">\n <label class=\"c8y-radio a-i-start\">\n <input\n name=\"iconFamily\"\n type=\"radio\"\n [checked]=\"selectedFamily() === 'dlt'\"\n (change)=\"onFamilyChange('dlt')\"\n />\n <span></span>\n <span\n >{{ 'Single color' | translate }}<br />\n <small\n class=\"text-muted d-block\"\n translate\n >\n Single SVG, inherits text color via currentColor.\n </small>\n </span>\n </label>\n\n <label class=\"c8y-radio a-i-start\">\n <input\n name=\"iconFamily\"\n type=\"radio\"\n [checked]=\"selectedFamily() === 'c8y'\"\n (change)=\"onFamilyChange('c8y')\"\n />\n <span></span>\n <span>\n {{ 'Duocolor' | translate }}<br />\n <small\n class=\"text-muted d-block\"\n translate\n >\n Two SVGs required: a dark layer and a light accent layer.\n </small>\n </span>\n </label>\n </div>\n </fieldset>\n }\n </div>\n }\n\n <!-- SVG uploads -->\n <div\n class=\"m-b-16\"\n [class.row]=\"selectedFamily() === 'c8y'\"\n >\n @if (selectedFamily() === 'dlt') {\n <c8y-form-group [hasError]=\"hasPrimarySvgError()\">\n <label translate>Icon SVG</label>\n @if (!primarySvg()) {\n <c8y-drop-area\n class=\"drop-area-sm\"\n [icon]=\"'upload'\"\n [title]=\"'Upload SVG icon' | translate\"\n [attr.aria-label]=\"'Upload SVG icon' | translate\"\n [accept]=\"'.svg'\"\n [maxAllowedFiles]=\"1\"\n [maxFileSizeInMegaBytes]=\"5\"\n [message]=\"'Drop SVG file here or click to browse' | translate\"\n (dropped)=\"onPrimaryDropped($event)\"\n ></c8y-drop-area>\n <c8y-messages\n class=\"m-t-8\"\n role=\"status\"\n [show]=\"primarySvgErrors()\"\n [helpMessage]=\"\n hasPrimarySvgError()\n ? ''\n : ('Square viewBox (e.g. 24\u00D724) \u00B7 currentColor fills \u00B7 no external refs or scripts'\n | translate)\n \"\n >\n <c8y-message\n [name]=\"'required'\"\n [text]=\"'Upload an SVG file.' | translate\"\n ></c8y-message>\n @if (primaryValidation(); as pv) {\n @for (error of pv.errors; track error) {\n <div class=\"d-block text-danger\">{{ error }}</div>\n }\n @for (warning of pv.warnings; track warning) {\n <div class=\"d-block text-warning\">{{ warning }}</div>\n }\n }\n </c8y-messages>\n } @else {\n <fieldset class=\"c8y-fieldset m-b-8\">\n <legend class=\"d-flex a-i-center\">\n <span translate>Preview</span>\n <button\n class=\"btn btn-dot btn-dot--danger m-l-8\"\n [attr.aria-label]=\"'Remove SVG preview' | translate\"\n [tooltip]=\"'Remove SVG preview' | translate\"\n placement=\"top\"\n container=\"body\"\n [delay]=\"500\"\n (click)=\"clearPrimary()\"\n >\n <i\n [c8yIcon]=\"'minus-circle'\"\n aria-hidden=\"true\"\n ></i>\n </button>\n </legend>\n <div class=\"row d-flex a-i-center gap-16 p-b-16\">\n <div\n class=\"col-sm-6 d-flex a-i-end gap-8 j-c-around p-16 c8y-light-theme\"\n style=\"\n background: var(--c8y-root-component-background-default);\n border: 1px solid var(--c8y-root-component-border-color);\n border-radius: var(--c8y-component-border-radius-base, 4px);\n \"\n >\n @for (size of previewSizes; track size) {\n <div class=\"d-flex d-col a-i-center\">\n <div\n class=\"d-flex a-i-center j-c-center text-default\"\n [style.width.px]=\"size\"\n [style.height.px]=\"size\"\n aria-hidden=\"true\"\n [innerHTML]=\"primaryPreviewHtml()\"\n ></div>\n <small class=\"text-muted\">{{ size }}</small>\n </div>\n }\n </div>\n <div\n class=\"col-sm-6 d-flex a-i-end gap-16 j-c-around p-16 c8y-dark-theme\"\n style=\"\n background: var(--c8y-root-component-background-default);\n border: 1px solid var(--c8y-root-component-border-color);\n border-radius: var(--c8y-component-border-radius-base, 4px);\n \"\n >\n @for (size of previewSizes; track size) {\n <div class=\"d-flex d-col a-i-center\">\n <div\n class=\"d-flex a-i-center j-c-center text-default\"\n [style.width.px]=\"size\"\n [style.height.px]=\"size\"\n aria-hidden=\"true\"\n [innerHTML]=\"primaryPreviewHtml()\"\n ></div>\n <small class=\"text-muted\">{{ size }}</small>\n </div>\n }\n </div>\n </div>\n @if (primaryValidation(); as pv) {\n @if (pv.errors.length || pv.warnings.length) {\n <small\n class=\"form-control-feedback-message\"\n role=\"status\"\n >\n @for (error of pv.errors; track error) {\n <div class=\"d-block text-danger\">{{ error }}</div>\n }\n @for (warning of pv.warnings; track warning) {\n <div class=\"d-block text-warning\">{{ warning }}</div>\n }\n </small>\n }\n }\n </fieldset>\n }\n </c8y-form-group>\n } @else {\n <!-- C8Y duocolor: dark + light uploads side by side -->\n <div class=\"row d-flex-md gap-16\">\n <div class=\"col-sm-6\">\n <c8y-form-group [hasError]=\"hasPrimarySvgError()\">\n <label translate>Dark layer SVG</label>\n @if (!primarySvg()) {\n <c8y-drop-area\n class=\"drop-area-sm\"\n [icon]=\"'upload'\"\n [attr.aria-label]=\"'Upload dark layer SVG' | translate\"\n [accept]=\"'.svg'\"\n [maxAllowedFiles]=\"1\"\n [maxFileSizeInMegaBytes]=\"5\"\n [message]=\"'Drop dark layer SVG' | translate\"\n (dropped)=\"onPrimaryDropped($event)\"\n ></c8y-drop-area>\n <c8y-messages\n class=\"m-t-8\"\n role=\"status\"\n [show]=\"primarySvgErrors()\"\n [helpMessage]=\"\n hasPrimarySvgError()\n ? ''\n : ('Square viewBox \u00B7 currentColor fills \u00B7 main icon shape' | translate)\n \"\n >\n <c8y-message\n [name]=\"'required'\"\n [text]=\"'Upload an SVG file.' | translate\"\n ></c8y-message>\n @if (primaryValidation(); as pv) {\n @for (error of pv.errors; track error) {\n <div class=\"d-block text-danger\">{{ error }}</div>\n }\n @for (warning of pv.warnings; track warning) {\n <div class=\"d-block text-warning\">{{ warning }}</div>\n }\n }\n </c8y-messages>\n } @else {\n <div class=\"d-flex a-i-center gap-8 p-8 separator m-b-4\">\n <div\n class=\"d-flex a-i-center j-c-center\"\n [style.width.px]=\"32\"\n [style.height.px]=\"32\"\n aria-hidden=\"true\"\n [innerHTML]=\"primaryPreviewHtml()\"\n ></div>\n <span\n class=\"text-muted text-12 flex-grow\"\n translate\n >Dark layer</span\n >\n <button\n class=\"btn btn-dot btn-dot--danger\"\n [attr.aria-label]=\"'Remove dark layer SVG' | translate\"\n [tooltip]=\"'Remove dark layer SVG' | translate\"\n placement=\"top\"\n container=\"body\"\n [delay]=\"500\"\n (click)=\"clearPrimary()\"\n >\n <i\n [c8yIcon]=\"'minus-circle'\"\n aria-hidden=\"true\"\n ></i>\n </button>\n </div>\n @if (primaryValidation(); as pv) {\n @if (pv.errors.length || pv.warnings.length) {\n <small\n class=\"form-control-feedback-message\"\n role=\"status\"\n >\n @for (error of pv.errors; track error) {\n <div class=\"d-block text-danger\">{{ error }}</div>\n }\n @for (warning of pv.warnings; track warning) {\n <div class=\"d-block text-warning\">{{ warning }}</div>\n }\n </small>\n }\n }\n }\n </c8y-form-group>\n </div>\n <div class=\"col-sm-6\">\n <c8y-form-group [hasError]=\"hasLightSvgError()\">\n <label translate>Light layer SVG</label>\n @if (!lightSvg()) {\n <c8y-drop-area\n class=\"drop-area-sm\"\n [icon]=\"'upload'\"\n [attr.aria-label]=\"'Upload light layer SVG' | translate\"\n [accept]=\"'.svg'\"\n [maxAllowedFiles]=\"1\"\n [maxFileSizeInMegaBytes]=\"5\"\n [message]=\"'Drop light layer SVG' | translate\"\n (dropped)=\"onLightDropped($event)\"\n ></c8y-drop-area>\n <c8y-messages\n class=\"m-t-8\"\n role=\"status\"\n [show]=\"lightSvgErrors()\"\n [helpMessage]=\"\n hasLightSvgError()\n ? ''\n : ('Same viewBox as dark layer \u00B7 accent shape for the light tint'\n | translate)\n \"\n >\n <c8y-message\n [name]=\"'required'\"\n [text]=\"'Upload an SVG file.' | translate\"\n ></c8y-message>\n @if (lightValidation(); as lv) {\n @for (error of lv.errors; track error) {\n <div class=\"d-block text-danger\">{{ error }}</div>\n }\n @for (warning of lv.warnings; track warning) {\n <div class=\"d-block text-warning\">{{ warning }}</div>\n }\n }\n </c8y-messages>\n } @else {\n <div class=\"d-flex a-i-center gap-8 p-8 separator m-b-4\">\n <div\n class=\"d-flex a-i-center j-c-center\"\n [style.width.px]=\"32\"\n [style.height.px]=\"32\"\n aria-hidden=\"true\"\n [innerHTML]=\"lightPreviewHtml()\"\n ></div>\n <span\n class=\"text-muted text-12 flex-grow\"\n translate\n >Light layer</span\n >\n <button\n class=\"btn btn-dot btn-dot--danger\"\n [attr.aria-label]=\"'Remove light layer SVG' | translate\"\n [tooltip]=\"'Remove light layer SVG' | translate\"\n placement=\"top\"\n container=\"body\"\n [delay]=\"500\"\n (click)=\"clearLight()\"\n >\n <i\n [c8yIcon]=\"'minus-circle'\"\n aria-hidden=\"true\"\n ></i>\n </button>\n </div>\n @if (lightValidation(); as lv) {\n @if (lv.errors.length || lv.warnings.length) {\n <small\n class=\"form-control-feedback-message\"\n role=\"status\"\n >\n @for (error of lv.errors; track error) {\n <div class=\"d-block text-danger\">{{ error }}</div>\n }\n @for (warning of lv.warnings; track warning) {\n <div class=\"d-block text-warning\">{{ warning }}</div>\n }\n </small>\n }\n }\n }\n </c8y-form-group>\n </div>\n </div>\n\n @if (showCompositePreview()) {\n <fieldset\n class=\"c8y-fieldset m-t-16\"\n aria-hidden=\"true\"\n >\n <legend translate>Composited preview</legend>\n <div class=\"row d-flex-md gap-16 p-b-16\">\n <!-- Light background -->\n <div\n class=\"col-sm-6 d-flex a-i-end gap-8 j-c-around p-16 c8y-light-theme\"\n style=\"\n background: var(--c8y-root-component-background-default);\n border: 1px solid var(--c8y-root-component-border-color);\n border-radius: var(--c8y-component-border-radius-base, 4px);\n \"\n >\n @for (size of previewSizes; track size) {\n <div class=\"d-flex d-col a-i-center\">\n <div\n class=\"p-relative\"\n [style.width.px]=\"size\"\n [style.height.px]=\"size\"\n >\n <div\n class=\"d-flex a-i-center j-c-center fit-w fit-h\"\n style=\"color: var(--c8y-root-component-icon-dark-color-dark)\"\n [innerHTML]=\"primaryPreviewHtml()\"\n ></div>\n <div\n class=\"d-flex a-i-center j-c-center fit-w fit-h p-absolute\"\n style=\"\n top: 0;\n left: 0;\n color: var(--c8y-root-component-icon-dark-color-light);\n \"\n [innerHTML]=\"lightPreviewHtml()\"\n ></div>\n </div>\n <small class=\"text-muted\">{{ size }}</small>\n </div>\n }\n </div>\n <!-- Dark background -->\n <div\n class=\"col-sm-6 d-flex a-i-end gap-8 j-c-around p-16 c8y-dark-theme\"\n style=\"\n background: var(--c8y-root-component-background-default);\n border-radius: var(--c8y-component-border-radius-base, 4px);\n \"\n >\n @for (size of previewSizes; track size) {\n <div class=\"d-flex d-col a-i-center\">\n <div\n class=\"p-relative\"\n [style.width.px]=\"size\"\n [style.height.px]=\"size\"\n >\n <div\n class=\"d-flex a-i-center j-c-center fit-w fit-h\"\n style=\"color: var(--c8y-root-component-icon-dark-color-dark)\"\n [innerHTML]=\"primaryPreviewHtml()\"\n ></div>\n <div\n class=\"d-flex a-i-center j-c-center fit-w fit-h p-absolute\"\n style=\"\n top: 0;\n left: 0;\n color: var(\n --c8y-component-icon-dark-color-light,\n var(--c8y-root-component-icon-dark-color-light)\n );\n \"\n [innerHTML]=\"lightPreviewHtml()\"\n ></div>\n </div>\n <small class=\"text-muted\">{{ size }}</small>\n </div>\n }\n </div>\n </div>\n </fieldset>\n }\n }\n </div>\n </div>\n }\n</c8y-modal>\n", dependencies: [{ kind: "component", type: ModalComponent, selector: "c8y-modal", inputs: ["disabled", "close", "dismiss", "title", "body", "customFooter", "headerClasses", "labels"], outputs: ["onDismiss", "onClose"] }, { kind: "component", type: DropAreaComponent, selector: "c8y-drop-area", inputs: ["formControl", "title", "message", "icon", "loadingMessage", "forceHideList", "alwaysShow", "clickToOpen", "loading", "progress", "maxAllowedFiles", "files", "maxFileSizeInMegaBytes", "accept"], outputs: ["dropped"] }, { kind: "directive", type: IconDirective, selector: "[c8yIcon]", inputs: ["c8yIcon"] }, { kind: "component", type: FormGroupComponent, selector: "c8y-form-group", inputs: ["hasError", "hasWarning", "hasSuccess", "novalidation", "status"] }, { kind: "component", type: MessagesComponent, selector: "c8y-messages", inputs: ["show", "defaults", "helpMessage", "additionalMessages"] }, { kind: "directive", type: MessageDirective, selector: "c8y-message", inputs: ["name", "text"] }, { kind: "directive", type: C8yTranslateDirective, selector: "[translate],[ngx-translate]" }, { kind: "ngmodule", type: FormsModule }, { 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.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.NgControlStatusGroup, selector: "[formGroupName],[formArrayName],[ngModelGroup],[formGroup],[formArray],form:not([ngNoForm]),[ngForm]" }, { kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.FormGroupDirective, selector: "[formGroup]", inputs: ["formGroup"], outputs: ["ngSubmit"], exportAs: ["ngForm"] }, { kind: "directive", type: i1.FormControlName, selector: "[formControlName]", inputs: ["formControlName", "disabled", "ngModel"], outputs: ["ngModelChange"] }, { kind: "component", type: TypeaheadComponent, selector: "c8y-typeahead", inputs: ["required", "maxlength", "disabled", "allowFreeEntries", "placeholder", "displayProperty", "icon", "name", "autoClose", "hideNew", "container", "selected", "title", "highlightFirstItem"], outputs: ["onSearch", "onIconClick"] }, { kind: "component", type: ListItemComponent, selector: "c8y-list-item, c8y-li", inputs: ["active", "highlighted", "emptyActions", "dense", "collapsed", "selectable"], outputs: ["collapsedChange"] }, { kind: "component", type: LoadingComponent, selector: "c8y-loading", inputs: ["layout", "progress", "progressLabel", "message"] }, { kind: "ngmodule", type: TooltipModule }, { kind: "directive", type: i2.TooltipDirective, selector: "[tooltip], [tooltipHtml]", inputs: ["adaptivePosition", "tooltip", "placement", "triggers", "container", "containerClass", "boundariesElement", "isOpen", "isDisabled", "delay", "tooltipHtml", "tooltipPlacement", "tooltipIsOpen", "tooltipEnable", "tooltipAppendToBody", "tooltipAnimation", "tooltipClass", "tooltipContext", "tooltipPopupDelay", "tooltipFadeDuration", "tooltipTrigger"], outputs: ["tooltipChange", "onShown", "onHidden", "tooltipStateChanged"], exportAs: ["bs-tooltip"] }, { kind: "pipe", type: C8yTranslatePipe, name: "translate" }] }); }
|
|
934
|
+
}
|
|
935
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: AddCustomIconModalComponent, decorators: [{
|
|
936
|
+
type: Component,
|
|
937
|
+
args: [{ selector: 'c8y-add-custom-icon-modal', imports: [
|
|
938
|
+
ModalComponent,
|
|
939
|
+
DropAreaComponent,
|
|
940
|
+
IconDirective,
|
|
941
|
+
FormGroupComponent,
|
|
942
|
+
MessagesComponent,
|
|
943
|
+
MessageDirective,
|
|
944
|
+
C8yTranslateDirective,
|
|
945
|
+
C8yTranslatePipe,
|
|
946
|
+
FormsModule,
|
|
947
|
+
ReactiveFormsModule,
|
|
948
|
+
TypeaheadComponent,
|
|
949
|
+
ListItemComponent,
|
|
950
|
+
LoadingComponent,
|
|
951
|
+
TooltipModule
|
|
952
|
+
], template: "<c8y-modal\n [title]=\"modalTitle() | translate\"\n [headerClasses]=\"'dialog-header'\"\n (onDismiss)=\"cancel()\"\n (onClose)=\"save()\"\n [close]=\"keepOpenOnOk\"\n [labels]=\"{ cancel: 'Cancel', ok: okLabel() | translate }\"\n>\n <ng-container c8y-modal-title>\n <span\n [c8yIcon]=\"isOverrideMode() ? 'replace' : isEditMode() ? 'pencil' : 'plus-circle-o'\"\n aria-hidden=\"true\"\n ></span>\n </ng-container>\n\n @if (!isReady()) {\n <!-- min-height matches the approximate form height so Bootstrap animates\n the modal at its full size before the form is rendered. -->\n <div\n class=\"p-32 d-flex a-i-center j-c-center\"\n style=\"min-height: 380px\"\n [attr.aria-label]=\"'Loading\u2026' | translate\"\n role=\"status\"\n >\n <c8y-loading></c8y-loading>\n </div>\n }\n\n @if (isReady()) {\n <div\n class=\"p-t-16 p-b-24 p-l-32 p-r-32\"\n #modalBody\n >\n @if (isOverrideMode()) {\n <p class=\"text-center text-16 text-bold text-balance m-b-16\">\n {{ 'Upload a replacement SVG for {{iconName}}' | translate: { iconName:\n editingIcon?.overriddenIconName } }}\n </p>\n }\n\n <!-- Name, collection and icon type (hidden in override mode) -->\n @if (!isOverrideMode()) {\n @if (!isEditMode()) {\n <p\n class=\"text-muted m-b-16\"\n translate\n >\n Upload a custom SVG icon and assign it to a collection to keep your icons organized.\n </p>\n }\n <div\n class=\"m-b-16\"\n [formGroup]=\"form\"\n >\n <c8y-form-group class=\"p-b-16\">\n <label\n for=\"iconName\"\n translate\n >Icon name</label\n >\n <input\n class=\"form-control\"\n id=\"iconName\"\n aria-required=\"true\"\n aria-describedby=\"iconName-messages\"\n type=\"text\"\n [placeholder]=\"'e.g. {{ example }}' | translate: { example: 'acme-logo' }\"\n formControlName=\"name\"\n />\n <c8y-messages\n id=\"iconName-messages\"\n role=\"status\"\n [helpMessage]=\"'Lowercase letters, numbers and hyphens only' | translate\"\n >\n @for (message of nameMessages; track message.name) {\n <c8y-message\n [name]=\"message.name\"\n [text]=\"message.text | translate\"\n ></c8y-message>\n }\n </c8y-messages>\n </c8y-form-group>\n\n <c8y-form-group [hasError]=\"hasCategoryError()\">\n <label\n for=\"iconCategory\"\n translate\n >Collection</label\n >\n <div class=\"p-relative\">\n <c8y-typeahead\n [attr.aria-label]=\"'Icon collection' | translate\"\n id=\"iconCategory\"\n #categoryTypeahead\n [allowFreeEntries]=\"true\"\n [hideNew]=\"true\"\n [placeholder]=\"'Select or enter a collection name' | translate\"\n (onSearch)=\"onCategorySearch($event)\"\n >\n @for (cat of filteredCategories(); track cat) {\n <c8y-li\n class=\"p-l-8 p-r-8 c8y-list__item--link\"\n (click)=\"selectCategory(cat)\"\n [active]=\"form.value.category === cat\"\n >{{ cat | translate }}</c8y-li\n >\n }\n @if (showAddNew()) {\n <c8y-li class=\"bg-level-2 p-8\">\n @if (hasNoMatch()) {\n <span\n class=\"m-r-8\"\n translate\n >No existing collection found.</span\n >\n }\n <button\n class=\"btn btn-default btn-xs\"\n type=\"button\"\n (click)=\"selectCategory(categorySearch())\"\n >\n {{ 'Add new collection`collection`' | translate }}\n </button>\n </c8y-li>\n }\n </c8y-typeahead>\n @if (categorySearch()) {\n <button\n class=\"btn btn-dot p-absolute\"\n style=\"right: 36px; z-index: 10\"\n [attr.aria-label]=\"'Clear collection' | translate\"\n type=\"button\"\n (click)=\"clearCategory()\"\n >\n <i\n [c8yIcon]=\"'times'\"\n aria-hidden=\"true\"\n ></i>\n </button>\n }\n </div>\n <c8y-messages\n role=\"alert\"\n [show]=\"categoryErrors()\"\n >\n <c8y-message\n [name]=\"'required'\"\n [text]=\"'Collection is required.' | translate\"\n ></c8y-message>\n </c8y-messages>\n </c8y-form-group>\n\n @if (!isEditMode()) {\n <fieldset class=\"c8y-fieldset m-b-24\">\n <legend translate>Icon type</legend>\n <div class=\"d-flex gap-16 p-b-16\">\n <label class=\"c8y-radio a-i-start\">\n <input\n name=\"iconFamily\"\n type=\"radio\"\n [checked]=\"selectedFamily() === 'dlt'\"\n (change)=\"onFamilyChange('dlt')\"\n />\n <span></span>\n <span\n >{{ 'Single color' | translate }}<br />\n <small\n class=\"text-muted d-block\"\n translate\n >\n Single SVG, inherits text color via currentColor.\n </small>\n </span>\n </label>\n\n <label class=\"c8y-radio a-i-start\">\n <input\n name=\"iconFamily\"\n type=\"radio\"\n [checked]=\"selectedFamily() === 'c8y'\"\n (change)=\"onFamilyChange('c8y')\"\n />\n <span></span>\n <span>\n {{ 'Duocolor' | translate }}<br />\n <small\n class=\"text-muted d-block\"\n translate\n >\n Two SVGs required: a dark layer and a light accent layer.\n </small>\n </span>\n </label>\n </div>\n </fieldset>\n }\n </div>\n }\n\n <!-- SVG uploads -->\n <div\n class=\"m-b-16\"\n [class.row]=\"selectedFamily() === 'c8y'\"\n >\n @if (selectedFamily() === 'dlt') {\n <c8y-form-group [hasError]=\"hasPrimarySvgError()\">\n <label translate>Icon SVG</label>\n @if (!primarySvg()) {\n <c8y-drop-area\n class=\"drop-area-sm\"\n [icon]=\"'upload'\"\n [title]=\"'Upload SVG icon' | translate\"\n [attr.aria-label]=\"'Upload SVG icon' | translate\"\n [accept]=\"'.svg'\"\n [maxAllowedFiles]=\"1\"\n [maxFileSizeInMegaBytes]=\"5\"\n [message]=\"'Drop SVG file here or click to browse' | translate\"\n (dropped)=\"onPrimaryDropped($event)\"\n ></c8y-drop-area>\n <c8y-messages\n class=\"m-t-8\"\n role=\"status\"\n [show]=\"primarySvgErrors()\"\n [helpMessage]=\"\n hasPrimarySvgError()\n ? ''\n : ('Square viewBox (e.g. 24\u00D724) \u00B7 currentColor fills \u00B7 no external refs or scripts'\n | translate)\n \"\n >\n <c8y-message\n [name]=\"'required'\"\n [text]=\"'Upload an SVG file.' | translate\"\n ></c8y-message>\n @if (primaryValidation(); as pv) {\n @for (error of pv.errors; track error) {\n <div class=\"d-block text-danger\">{{ error }}</div>\n }\n @for (warning of pv.warnings; track warning) {\n <div class=\"d-block text-warning\">{{ warning }}</div>\n }\n }\n </c8y-messages>\n } @else {\n <fieldset class=\"c8y-fieldset m-b-8\">\n <legend class=\"d-flex a-i-center\">\n <span translate>Preview</span>\n <button\n class=\"btn btn-dot btn-dot--danger m-l-8\"\n [attr.aria-label]=\"'Remove SVG preview' | translate\"\n [tooltip]=\"'Remove SVG preview' | translate\"\n placement=\"top\"\n container=\"body\"\n [delay]=\"500\"\n (click)=\"clearPrimary()\"\n >\n <i\n [c8yIcon]=\"'minus-circle'\"\n aria-hidden=\"true\"\n ></i>\n </button>\n </legend>\n <div class=\"row d-flex a-i-center gap-16 p-b-16\">\n <div\n class=\"col-sm-6 d-flex a-i-end gap-8 j-c-around p-16 c8y-light-theme\"\n style=\"\n background: var(--c8y-root-component-background-default);\n border: 1px solid var(--c8y-root-component-border-color);\n border-radius: var(--c8y-component-border-radius-base, 4px);\n \"\n >\n @for (size of previewSizes; track size) {\n <div class=\"d-flex d-col a-i-center\">\n <div\n class=\"d-flex a-i-center j-c-center text-default\"\n [style.width.px]=\"size\"\n [style.height.px]=\"size\"\n aria-hidden=\"true\"\n [innerHTML]=\"primaryPreviewHtml()\"\n ></div>\n <small class=\"text-muted\">{{ size }}</small>\n </div>\n }\n </div>\n <div\n class=\"col-sm-6 d-flex a-i-end gap-16 j-c-around p-16 c8y-dark-theme\"\n style=\"\n background: var(--c8y-root-component-background-default);\n border: 1px solid var(--c8y-root-component-border-color);\n border-radius: var(--c8y-component-border-radius-base, 4px);\n \"\n >\n @for (size of previewSizes; track size) {\n <div class=\"d-flex d-col a-i-center\">\n <div\n class=\"d-flex a-i-center j-c-center text-default\"\n [style.width.px]=\"size\"\n [style.height.px]=\"size\"\n aria-hidden=\"true\"\n [innerHTML]=\"primaryPreviewHtml()\"\n ></div>\n <small class=\"text-muted\">{{ size }}</small>\n </div>\n }\n </div>\n </div>\n @if (primaryValidation(); as pv) {\n @if (pv.errors.length || pv.warnings.length) {\n <small\n class=\"form-control-feedback-message\"\n role=\"status\"\n >\n @for (error of pv.errors; track error) {\n <div class=\"d-block text-danger\">{{ error }}</div>\n }\n @for (warning of pv.warnings; track warning) {\n <div class=\"d-block text-warning\">{{ warning }}</div>\n }\n </small>\n }\n }\n </fieldset>\n }\n </c8y-form-group>\n } @else {\n <!-- C8Y duocolor: dark + light uploads side by side -->\n <div class=\"row d-flex-md gap-16\">\n <div class=\"col-sm-6\">\n <c8y-form-group [hasError]=\"hasPrimarySvgError()\">\n <label translate>Dark layer SVG</label>\n @if (!primarySvg()) {\n <c8y-drop-area\n class=\"drop-area-sm\"\n [icon]=\"'upload'\"\n [attr.aria-label]=\"'Upload dark layer SVG' | translate\"\n [accept]=\"'.svg'\"\n [maxAllowedFiles]=\"1\"\n [maxFileSizeInMegaBytes]=\"5\"\n [message]=\"'Drop dark layer SVG' | translate\"\n (dropped)=\"onPrimaryDropped($event)\"\n ></c8y-drop-area>\n <c8y-messages\n class=\"m-t-8\"\n role=\"status\"\n [show]=\"primarySvgErrors()\"\n [helpMessage]=\"\n hasPrimarySvgError()\n ? ''\n : ('Square viewBox \u00B7 currentColor fills \u00B7 main icon shape' | translate)\n \"\n >\n <c8y-message\n [name]=\"'required'\"\n [text]=\"'Upload an SVG file.' | translate\"\n ></c8y-message>\n @if (primaryValidation(); as pv) {\n @for (error of pv.errors; track error) {\n <div class=\"d-block text-danger\">{{ error }}</div>\n }\n @for (warning of pv.warnings; track warning) {\n <div class=\"d-block text-warning\">{{ warning }}</div>\n }\n }\n </c8y-messages>\n } @else {\n <div class=\"d-flex a-i-center gap-8 p-8 separator m-b-4\">\n <div\n class=\"d-flex a-i-center j-c-center\"\n [style.width.px]=\"32\"\n [style.height.px]=\"32\"\n aria-hidden=\"true\"\n [innerHTML]=\"primaryPreviewHtml()\"\n ></div>\n <span\n class=\"text-muted text-12 flex-grow\"\n translate\n >Dark layer</span\n >\n <button\n class=\"btn btn-dot btn-dot--danger\"\n [attr.aria-label]=\"'Remove dark layer SVG' | translate\"\n [tooltip]=\"'Remove dark layer SVG' | translate\"\n placement=\"top\"\n container=\"body\"\n [delay]=\"500\"\n (click)=\"clearPrimary()\"\n >\n <i\n [c8yIcon]=\"'minus-circle'\"\n aria-hidden=\"true\"\n ></i>\n </button>\n </div>\n @if (primaryValidation(); as pv) {\n @if (pv.errors.length || pv.warnings.length) {\n <small\n class=\"form-control-feedback-message\"\n role=\"status\"\n >\n @for (error of pv.errors; track error) {\n <div class=\"d-block text-danger\">{{ error }}</div>\n }\n @for (warning of pv.warnings; track warning) {\n <div class=\"d-block text-warning\">{{ warning }}</div>\n }\n </small>\n }\n }\n }\n </c8y-form-group>\n </div>\n <div class=\"col-sm-6\">\n <c8y-form-group [hasError]=\"hasLightSvgError()\">\n <label translate>Light layer SVG</label>\n @if (!lightSvg()) {\n <c8y-drop-area\n class=\"drop-area-sm\"\n [icon]=\"'upload'\"\n [attr.aria-label]=\"'Upload light layer SVG' | translate\"\n [accept]=\"'.svg'\"\n [maxAllowedFiles]=\"1\"\n [maxFileSizeInMegaBytes]=\"5\"\n [message]=\"'Drop light layer SVG' | translate\"\n (dropped)=\"onLightDropped($event)\"\n ></c8y-drop-area>\n <c8y-messages\n class=\"m-t-8\"\n role=\"status\"\n [show]=\"lightSvgErrors()\"\n [helpMessage]=\"\n hasLightSvgError()\n ? ''\n : ('Same viewBox as dark layer \u00B7 accent shape for the light tint'\n | translate)\n \"\n >\n <c8y-message\n [name]=\"'required'\"\n [text]=\"'Upload an SVG file.' | translate\"\n ></c8y-message>\n @if (lightValidation(); as lv) {\n @for (error of lv.errors; track error) {\n <div class=\"d-block text-danger\">{{ error }}</div>\n }\n @for (warning of lv.warnings; track warning) {\n <div class=\"d-block text-warning\">{{ warning }}</div>\n }\n }\n </c8y-messages>\n } @else {\n <div class=\"d-flex a-i-center gap-8 p-8 separator m-b-4\">\n <div\n class=\"d-flex a-i-center j-c-center\"\n [style.width.px]=\"32\"\n [style.height.px]=\"32\"\n aria-hidden=\"true\"\n [innerHTML]=\"lightPreviewHtml()\"\n ></div>\n <span\n class=\"text-muted text-12 flex-grow\"\n translate\n >Light layer</span\n >\n <button\n class=\"btn btn-dot btn-dot--danger\"\n [attr.aria-label]=\"'Remove light layer SVG' | translate\"\n [tooltip]=\"'Remove light layer SVG' | translate\"\n placement=\"top\"\n container=\"body\"\n [delay]=\"500\"\n (click)=\"clearLight()\"\n >\n <i\n [c8yIcon]=\"'minus-circle'\"\n aria-hidden=\"true\"\n ></i>\n </button>\n </div>\n @if (lightValidation(); as lv) {\n @if (lv.errors.length || lv.warnings.length) {\n <small\n class=\"form-control-feedback-message\"\n role=\"status\"\n >\n @for (error of lv.errors; track error) {\n <div class=\"d-block text-danger\">{{ error }}</div>\n }\n @for (warning of lv.warnings; track warning) {\n <div class=\"d-block text-warning\">{{ warning }}</div>\n }\n </small>\n }\n }\n }\n </c8y-form-group>\n </div>\n </div>\n\n @if (showCompositePreview()) {\n <fieldset\n class=\"c8y-fieldset m-t-16\"\n aria-hidden=\"true\"\n >\n <legend translate>Composited preview</legend>\n <div class=\"row d-flex-md gap-16 p-b-16\">\n <!-- Light background -->\n <div\n class=\"col-sm-6 d-flex a-i-end gap-8 j-c-around p-16 c8y-light-theme\"\n style=\"\n background: var(--c8y-root-component-background-default);\n border: 1px solid var(--c8y-root-component-border-color);\n border-radius: var(--c8y-component-border-radius-base, 4px);\n \"\n >\n @for (size of previewSizes; track size) {\n <div class=\"d-flex d-col a-i-center\">\n <div\n class=\"p-relative\"\n [style.width.px]=\"size\"\n [style.height.px]=\"size\"\n >\n <div\n class=\"d-flex a-i-center j-c-center fit-w fit-h\"\n style=\"color: var(--c8y-root-component-icon-dark-color-dark)\"\n [innerHTML]=\"primaryPreviewHtml()\"\n ></div>\n <div\n class=\"d-flex a-i-center j-c-center fit-w fit-h p-absolute\"\n style=\"\n top: 0;\n left: 0;\n color: var(--c8y-root-component-icon-dark-color-light);\n \"\n [innerHTML]=\"lightPreviewHtml()\"\n ></div>\n </div>\n <small class=\"text-muted\">{{ size }}</small>\n </div>\n }\n </div>\n <!-- Dark background -->\n <div\n class=\"col-sm-6 d-flex a-i-end gap-8 j-c-around p-16 c8y-dark-theme\"\n style=\"\n background: var(--c8y-root-component-background-default);\n border-radius: var(--c8y-component-border-radius-base, 4px);\n \"\n >\n @for (size of previewSizes; track size) {\n <div class=\"d-flex d-col a-i-center\">\n <div\n class=\"p-relative\"\n [style.width.px]=\"size\"\n [style.height.px]=\"size\"\n >\n <div\n class=\"d-flex a-i-center j-c-center fit-w fit-h\"\n style=\"color: var(--c8y-root-component-icon-dark-color-dark)\"\n [innerHTML]=\"primaryPreviewHtml()\"\n ></div>\n <div\n class=\"d-flex a-i-center j-c-center fit-w fit-h p-absolute\"\n style=\"\n top: 0;\n left: 0;\n color: var(\n --c8y-component-icon-dark-color-light,\n var(--c8y-root-component-icon-dark-color-light)\n );\n \"\n [innerHTML]=\"lightPreviewHtml()\"\n ></div>\n </div>\n <small class=\"text-muted\">{{ size }}</small>\n </div>\n }\n </div>\n </div>\n </fieldset>\n }\n }\n </div>\n </div>\n }\n</c8y-modal>\n" }]
|
|
953
|
+
}], ctorParameters: () => [], propDecorators: { categoryTypeahead: [{ type: i0.ViewChild, args: ['categoryTypeahead', { isSignal: true }] }], modalBody: [{ type: i0.ViewChild, args: ['modalBody', { isSignal: true }] }] } });
|
|
954
|
+
|
|
955
|
+
class AddCustomIconModalService {
|
|
956
|
+
constructor() {
|
|
957
|
+
this.modal = inject(BsModalService);
|
|
958
|
+
}
|
|
959
|
+
/**
|
|
960
|
+
* Opens the add/edit/override custom icon modal.
|
|
961
|
+
* @returns Resolves with the saved icon definition, or `null` if the user cancelled.
|
|
962
|
+
* The `mode` option controls the modal title and which fields are editable.
|
|
963
|
+
*/
|
|
964
|
+
open(options = {}) {
|
|
965
|
+
const initialState = {
|
|
966
|
+
mode: options.mode ?? 'add',
|
|
967
|
+
editingIcon: options.editingIcon ?? null,
|
|
968
|
+
allCategories: options.allCategories ?? [],
|
|
969
|
+
reservedIconNames: options.reservedIconNames ?? new Set()
|
|
970
|
+
};
|
|
971
|
+
const ref = this.modal.show(AddCustomIconModalComponent, {
|
|
972
|
+
initialState,
|
|
973
|
+
ignoreBackdropClick: true,
|
|
974
|
+
keyboard: false,
|
|
975
|
+
class: 'modal-md'
|
|
976
|
+
});
|
|
977
|
+
return ref.content.result;
|
|
978
|
+
}
|
|
979
|
+
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: AddCustomIconModalService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
|
|
980
|
+
static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: AddCustomIconModalService, providedIn: 'root' }); }
|
|
981
|
+
}
|
|
982
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: AddCustomIconModalService, decorators: [{
|
|
983
|
+
type: Injectable,
|
|
984
|
+
args: [{ providedIn: 'root' }]
|
|
985
|
+
}] });
|
|
986
|
+
|
|
987
|
+
class ImportCustomIconsModalComponent {
|
|
988
|
+
constructor() {
|
|
989
|
+
/** Current state of the import workflow: idle → previewing → importing. */
|
|
990
|
+
this.state = signal('idle', ...(ngDevMode ? [{ debugName: "state" }] : /* istanbul ignore next */ []));
|
|
991
|
+
/** Error message if ZIP parsing fails; null when clean. */
|
|
992
|
+
this.dropError = signal(null, ...(ngDevMode ? [{ debugName: "dropError" }] : /* istanbul ignore next */ []));
|
|
993
|
+
/** Icons ready to import (not skipped) from the current ZIP, with SVG previews. */
|
|
994
|
+
this.toImportIcons = signal([], ...(ngDevMode ? [{ debugName: "toImportIcons" }] : /* istanbul ignore next */ []));
|
|
995
|
+
this.familyLabels = {
|
|
996
|
+
dlt: gettext('Single color'),
|
|
997
|
+
c8y: gettext('Duocolor')
|
|
998
|
+
};
|
|
999
|
+
this.skipReasonLabels = {
|
|
1000
|
+
exists: gettext('Already exists'),
|
|
1001
|
+
missingSvg: gettext('Missing SVG file')
|
|
1002
|
+
};
|
|
1003
|
+
/** Icons skipped during preview, with the reason. */
|
|
1004
|
+
this.skippedIcons = signal([], ...(ngDevMode ? [{ debugName: "skippedIcons" }] : /* istanbul ignore next */ []));
|
|
1005
|
+
/** True when every skipped icon was skipped because it already exists in the tenant. */
|
|
1006
|
+
this.allSkippedExist = computed(() => this.skippedIcons().length > 0 && this.skippedIcons().every(s => s.reason === 'exists'), ...(ngDevMode ? [{ debugName: "allSkippedExist" }] : /* istanbul ignore next */ []));
|
|
1007
|
+
this.pendingFile = null;
|
|
1008
|
+
this.bsModal = inject(BsModalRef);
|
|
1009
|
+
this.registry = inject(CustomIconRegistryService);
|
|
1010
|
+
this.zip = inject(ZipService);
|
|
1011
|
+
this.alerts = inject(AlertService);
|
|
1012
|
+
this.translate = inject(TranslateService);
|
|
1013
|
+
this.svgSanitizer = inject(SvgSanitizerService);
|
|
1014
|
+
this.domSanitizer = inject(DomSanitizer);
|
|
1015
|
+
this.result = new Promise(res => {
|
|
1016
|
+
this.resolve = res;
|
|
1017
|
+
});
|
|
1018
|
+
}
|
|
1019
|
+
async onZipDropped(files) {
|
|
1020
|
+
if (!files.length)
|
|
1021
|
+
return;
|
|
1022
|
+
this.dropError.set(null);
|
|
1023
|
+
this.pendingFile = files[0].file;
|
|
1024
|
+
try {
|
|
1025
|
+
const preview = await this.previewImport(this.pendingFile);
|
|
1026
|
+
this.toImportIcons.set(preview.toImport);
|
|
1027
|
+
this.skippedIcons.set(preview.skipped);
|
|
1028
|
+
this.state.set('previewing');
|
|
1029
|
+
}
|
|
1030
|
+
catch (e) {
|
|
1031
|
+
this.dropError.set((e instanceof Error ? e.message : null) ??
|
|
1032
|
+
this.translate.instant(gettext('Failed to read ZIP file.')));
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
async confirmImport() {
|
|
1036
|
+
if (!this.pendingFile)
|
|
1037
|
+
return;
|
|
1038
|
+
this.state.set('importing');
|
|
1039
|
+
try {
|
|
1040
|
+
const result = await this.registry.importFromZip(this.pendingFile);
|
|
1041
|
+
const msg = this.translate.instant(gettext('{{count}} icon(s) imported.'), {
|
|
1042
|
+
count: result.imported
|
|
1043
|
+
});
|
|
1044
|
+
this.alerts.success(msg);
|
|
1045
|
+
this.resolve(result.imported > 0);
|
|
1046
|
+
this.bsModal.hide();
|
|
1047
|
+
}
|
|
1048
|
+
catch (e) {
|
|
1049
|
+
this.alerts.danger((e instanceof Error ? e.message : null) ?? gettext('Import failed.'));
|
|
1050
|
+
this.state.set('previewing');
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
close() {
|
|
1054
|
+
if (this.state() === 'importing')
|
|
1055
|
+
return;
|
|
1056
|
+
this.resolve(false);
|
|
1057
|
+
this.bsModal.hide();
|
|
1058
|
+
}
|
|
1059
|
+
/** Overrides c8y-modal's default hide-on-click — the modal must stay open and show
|
|
1060
|
+
* the importing spinner until confirmImport()/close() hide it themselves. */
|
|
1061
|
+
keepOpen() { }
|
|
1062
|
+
async previewImport(file) {
|
|
1063
|
+
const entries = await firstValueFrom(this.zip.getEntries(file));
|
|
1064
|
+
const manifestEntry = entries.find(e => e.filename === 'icons.json');
|
|
1065
|
+
if (!manifestEntry) {
|
|
1066
|
+
throw new Error(this.translate.instant(gettext('Invalid ZIP: missing icons.json')));
|
|
1067
|
+
}
|
|
1068
|
+
const blob = await firstValueFrom(this.zip.getData(manifestEntry));
|
|
1069
|
+
const manifest = JSON.parse(await blob.text());
|
|
1070
|
+
const existing = new Set(this.registry.icons().map(i => i.name));
|
|
1071
|
+
const entryNames = new Set(entries.map(e => e.filename));
|
|
1072
|
+
const skipped = [];
|
|
1073
|
+
const toImport = [];
|
|
1074
|
+
const readSvg = async (fileName) => {
|
|
1075
|
+
const entry = entries.find(e => e.filename === fileName);
|
|
1076
|
+
if (!entry)
|
|
1077
|
+
return null;
|
|
1078
|
+
const svgBlob = await firstValueFrom(this.zip.getData(entry));
|
|
1079
|
+
return this.buildPreviewHtml(await svgBlob.text());
|
|
1080
|
+
};
|
|
1081
|
+
for (const meta of manifest) {
|
|
1082
|
+
const primaryFileName = getIconFileName(meta);
|
|
1083
|
+
const secondaryFileName = meta.hasSecondary && getIconFileName(meta, true);
|
|
1084
|
+
const preview = {
|
|
1085
|
+
name: meta.name,
|
|
1086
|
+
category: meta.category,
|
|
1087
|
+
family: meta.family,
|
|
1088
|
+
isOverride: meta.isOverride,
|
|
1089
|
+
previewHtml: await readSvg(primaryFileName),
|
|
1090
|
+
previewHtmlSecondary: secondaryFileName ? await readSvg(secondaryFileName) : null
|
|
1091
|
+
};
|
|
1092
|
+
const missingSvg = !entryNames.has(primaryFileName) ||
|
|
1093
|
+
(secondaryFileName && !entryNames.has(secondaryFileName));
|
|
1094
|
+
if (existing.has(meta.name)) {
|
|
1095
|
+
skipped.push({ ...preview, reason: 'exists' });
|
|
1096
|
+
}
|
|
1097
|
+
else if (missingSvg) {
|
|
1098
|
+
skipped.push({ ...preview, reason: 'missingSvg' });
|
|
1099
|
+
}
|
|
1100
|
+
else {
|
|
1101
|
+
toImport.push(preview);
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
1104
|
+
return { toImport, skipped };
|
|
1105
|
+
}
|
|
1106
|
+
/** Sanitized preview of an SVG from the ZIP.
|
|
1107
|
+
* Safe for [innerHTML]: the string is DOMPurify-sanitized right before the trust call
|
|
1108
|
+
* and only safe sizing attributes are added afterwards. */
|
|
1109
|
+
buildPreviewHtml(rawSvg) {
|
|
1110
|
+
const sanitized = this.svgSanitizer.sanitize(rawSvg);
|
|
1111
|
+
if (!sanitized) {
|
|
1112
|
+
return null;
|
|
1113
|
+
}
|
|
1114
|
+
return this.domSanitizer.bypassSecurityTrustHtml(sanitized.replace(/<svg/i, '<svg width="100%" height="100%" style="display:block"'));
|
|
1115
|
+
}
|
|
1116
|
+
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: ImportCustomIconsModalComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
|
|
1117
|
+
static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.17", type: ImportCustomIconsModalComponent, isStandalone: true, selector: "c8y-import-custom-icons-modal", ngImport: i0, template: "<c8y-modal\n [title]=\"'Import custom icons' | translate\"\n [headerClasses]=\"'dialog-header'\"\n (onDismiss)=\"close()\"\n (onClose)=\"confirmImport()\"\n [close]=\"keepOpen\"\n [dismiss]=\"keepOpen\"\n [disabled]=\"state() !== 'previewing' || toImportIcons().length === 0\"\n [labels]=\"{\n cancel: 'Cancel',\n ok: 'Import' | translate\n }\"\n>\n <ng-container c8y-modal-title>\n <span\n [c8yIcon]=\"'data-import'\"\n aria-hidden=\"true\"\n ></span>\n </ng-container>\n\n <div class=\"p-24\">\n @if (state() === 'idle') {\n <p\n class=\"text-16 text-center text-balance m-b-16\"\n translate\n >\n Upload a ZIP file previously exported from the icon selector to import custom icons into\n this tenant.\n </p>\n <div class=\"d-flex j-c-center\">\n <c8y-drop-area\n [icon]=\"'upload'\"\n style=\"width: 390px\"\n [title]=\"'Upload icon ZIP' | translate\"\n accept=\".zip\"\n [maxAllowedFiles]=\"1\"\n [message]=\"'Drop your .zip export file here or click to browse' | translate\"\n (dropped)=\"onZipDropped($event)\"\n ></c8y-drop-area>\n @if (dropError()) {\n <div\n class=\"alert alert-danger m-t-8\"\n role=\"alert\"\n >\n {{ dropError() }}\n </div>\n }\n </div>\n }\n\n @if (state() === 'previewing') {\n @if (toImportIcons().length) {\n <p\n class=\"m-b-8 text-bold\"\n translate\n >\n The following icons will be imported:\n </p>\n <ul class=\"list-unstyled m-b-16 separator-top\">\n @for (icon of toImportIcons(); track icon.name) {\n <li class=\"d-flex a-i-center gap-8 p-t-8 p-b-8 separator-bottom\">\n <span\n class=\"p-relative flex-no-shrink\"\n [style.width.px]=\"24\"\n [style.height.px]=\"24\"\n aria-hidden=\"true\"\n >\n <span\n class=\"d-flex a-i-center j-c-center fit-w fit-h\"\n [class.text-default]=\"!icon.previewHtmlSecondary\"\n [style.color]=\"\n icon.previewHtmlSecondary\n ? 'var(--c8y-root-component-icon-dark-color-dark)'\n : null\n \"\n [innerHTML]=\"icon.previewHtml\"\n ></span>\n @if (icon.previewHtmlSecondary) {\n <span\n class=\"d-flex a-i-center j-c-center fit-w fit-h p-absolute\"\n style=\"top: 0; left: 0; color: var(--c8y-root-component-icon-dark-color-light)\"\n [innerHTML]=\"icon.previewHtmlSecondary\"\n ></span>\n }\n </span>\n <span class=\"flex-grow min-width-0 text-truncate\">\n {{ icon.name }}\n @if (icon.category && !icon.isOverride) {\n <small class=\"text-muted m-l-4\">{{ icon.category | translate }}</small>\n }\n </span>\n <span class=\"tag tag--info flex-no-shrink\">\n {{ familyLabels[icon.family] | translate }}\n </span>\n @if (icon.isOverride) {\n <span\n class=\"tag tag--warning flex-no-shrink\"\n translate\n >Override</span\n >\n }\n </li>\n }\n </ul>\n } @else if (allSkippedExist()) {\n <div\n class=\"alert alert-warning\"\n role=\"status\"\n translate\n >\n All icons in this file already exist in this tenant.\n </div>\n } @else {\n <div\n class=\"alert alert-warning\"\n role=\"status\"\n translate\n >\n No icons to import.\n </div>\n }\n @if (skippedIcons().length && !(allSkippedExist() && toImportIcons().length === 0)) {\n <p\n class=\"m-b-8 text-bold\"\n translate\n >\n The following icons will be skipped:\n </p>\n <ul class=\"list-unstyled m-b-16 separator-top\">\n @for (icon of skippedIcons(); track icon.name) {\n <li class=\"d-flex a-i-center gap-8 p-t-8 p-b-8 separator-bottom\">\n <span\n class=\"p-relative flex-no-shrink\"\n [style.width.px]=\"24\"\n [style.height.px]=\"24\"\n aria-hidden=\"true\"\n >\n <span\n class=\"d-flex a-i-center j-c-center fit-w fit-h\"\n [class.text-default]=\"!icon.previewHtmlSecondary\"\n [style.color]=\"\n icon.previewHtmlSecondary\n ? 'var(--c8y-root-component-icon-dark-color-dark)'\n : null\n \"\n [innerHTML]=\"icon.previewHtml\"\n ></span>\n @if (icon.previewHtmlSecondary) {\n <span\n class=\"d-flex a-i-center j-c-center fit-w fit-h p-absolute\"\n style=\"top: 0; left: 0; color: var(--c8y-root-component-icon-dark-color-light)\"\n [innerHTML]=\"icon.previewHtmlSecondary\"\n ></span>\n }\n </span>\n <span class=\"flex-grow min-width-0 text-truncate\">\n {{ icon.name }}\n @if (icon.category && !icon.isOverride) {\n <small class=\"text-muted m-l-4\">{{ icon.category | translate }}</small>\n }\n </span>\n <span class=\"tag tag--warning flex-no-shrink\">\n {{ skipReasonLabels[icon.reason] | translate }}\n </span>\n </li>\n }\n </ul>\n }\n }\n\n @if (state() === 'importing') {\n <c8y-loading\n role=\"status\"\n [message]=\"'Importing icons\u2026' | translate\"\n ></c8y-loading>\n }\n </div>\n</c8y-modal>\n", dependencies: [{ kind: "directive", type: C8yTranslateDirective, selector: "[translate],[ngx-translate]" }, { kind: "component", type: DropAreaComponent, selector: "c8y-drop-area", inputs: ["formControl", "title", "message", "icon", "loadingMessage", "forceHideList", "alwaysShow", "clickToOpen", "loading", "progress", "maxAllowedFiles", "files", "maxFileSizeInMegaBytes", "accept"], outputs: ["dropped"] }, { kind: "component", type: LoadingComponent, selector: "c8y-loading", inputs: ["layout", "progress", "progressLabel", "message"] }, { kind: "component", type: ModalComponent, selector: "c8y-modal", inputs: ["disabled", "close", "dismiss", "title", "body", "customFooter", "headerClasses", "labels"], outputs: ["onDismiss", "onClose"] }, { kind: "directive", type: IconDirective, selector: "[c8yIcon]", inputs: ["c8yIcon"] }, { kind: "pipe", type: C8yTranslatePipe, name: "translate" }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); }
|
|
1118
|
+
}
|
|
1119
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: ImportCustomIconsModalComponent, decorators: [{
|
|
1120
|
+
type: Component,
|
|
1121
|
+
args: [{ selector: 'c8y-import-custom-icons-modal', changeDetection: ChangeDetectionStrategy.OnPush, imports: [
|
|
1122
|
+
C8yTranslatePipe,
|
|
1123
|
+
C8yTranslateDirective,
|
|
1124
|
+
DropAreaComponent,
|
|
1125
|
+
LoadingComponent,
|
|
1126
|
+
ModalComponent,
|
|
1127
|
+
IconDirective
|
|
1128
|
+
], template: "<c8y-modal\n [title]=\"'Import custom icons' | translate\"\n [headerClasses]=\"'dialog-header'\"\n (onDismiss)=\"close()\"\n (onClose)=\"confirmImport()\"\n [close]=\"keepOpen\"\n [dismiss]=\"keepOpen\"\n [disabled]=\"state() !== 'previewing' || toImportIcons().length === 0\"\n [labels]=\"{\n cancel: 'Cancel',\n ok: 'Import' | translate\n }\"\n>\n <ng-container c8y-modal-title>\n <span\n [c8yIcon]=\"'data-import'\"\n aria-hidden=\"true\"\n ></span>\n </ng-container>\n\n <div class=\"p-24\">\n @if (state() === 'idle') {\n <p\n class=\"text-16 text-center text-balance m-b-16\"\n translate\n >\n Upload a ZIP file previously exported from the icon selector to import custom icons into\n this tenant.\n </p>\n <div class=\"d-flex j-c-center\">\n <c8y-drop-area\n [icon]=\"'upload'\"\n style=\"width: 390px\"\n [title]=\"'Upload icon ZIP' | translate\"\n accept=\".zip\"\n [maxAllowedFiles]=\"1\"\n [message]=\"'Drop your .zip export file here or click to browse' | translate\"\n (dropped)=\"onZipDropped($event)\"\n ></c8y-drop-area>\n @if (dropError()) {\n <div\n class=\"alert alert-danger m-t-8\"\n role=\"alert\"\n >\n {{ dropError() }}\n </div>\n }\n </div>\n }\n\n @if (state() === 'previewing') {\n @if (toImportIcons().length) {\n <p\n class=\"m-b-8 text-bold\"\n translate\n >\n The following icons will be imported:\n </p>\n <ul class=\"list-unstyled m-b-16 separator-top\">\n @for (icon of toImportIcons(); track icon.name) {\n <li class=\"d-flex a-i-center gap-8 p-t-8 p-b-8 separator-bottom\">\n <span\n class=\"p-relative flex-no-shrink\"\n [style.width.px]=\"24\"\n [style.height.px]=\"24\"\n aria-hidden=\"true\"\n >\n <span\n class=\"d-flex a-i-center j-c-center fit-w fit-h\"\n [class.text-default]=\"!icon.previewHtmlSecondary\"\n [style.color]=\"\n icon.previewHtmlSecondary\n ? 'var(--c8y-root-component-icon-dark-color-dark)'\n : null\n \"\n [innerHTML]=\"icon.previewHtml\"\n ></span>\n @if (icon.previewHtmlSecondary) {\n <span\n class=\"d-flex a-i-center j-c-center fit-w fit-h p-absolute\"\n style=\"top: 0; left: 0; color: var(--c8y-root-component-icon-dark-color-light)\"\n [innerHTML]=\"icon.previewHtmlSecondary\"\n ></span>\n }\n </span>\n <span class=\"flex-grow min-width-0 text-truncate\">\n {{ icon.name }}\n @if (icon.category && !icon.isOverride) {\n <small class=\"text-muted m-l-4\">{{ icon.category | translate }}</small>\n }\n </span>\n <span class=\"tag tag--info flex-no-shrink\">\n {{ familyLabels[icon.family] | translate }}\n </span>\n @if (icon.isOverride) {\n <span\n class=\"tag tag--warning flex-no-shrink\"\n translate\n >Override</span\n >\n }\n </li>\n }\n </ul>\n } @else if (allSkippedExist()) {\n <div\n class=\"alert alert-warning\"\n role=\"status\"\n translate\n >\n All icons in this file already exist in this tenant.\n </div>\n } @else {\n <div\n class=\"alert alert-warning\"\n role=\"status\"\n translate\n >\n No icons to import.\n </div>\n }\n @if (skippedIcons().length && !(allSkippedExist() && toImportIcons().length === 0)) {\n <p\n class=\"m-b-8 text-bold\"\n translate\n >\n The following icons will be skipped:\n </p>\n <ul class=\"list-unstyled m-b-16 separator-top\">\n @for (icon of skippedIcons(); track icon.name) {\n <li class=\"d-flex a-i-center gap-8 p-t-8 p-b-8 separator-bottom\">\n <span\n class=\"p-relative flex-no-shrink\"\n [style.width.px]=\"24\"\n [style.height.px]=\"24\"\n aria-hidden=\"true\"\n >\n <span\n class=\"d-flex a-i-center j-c-center fit-w fit-h\"\n [class.text-default]=\"!icon.previewHtmlSecondary\"\n [style.color]=\"\n icon.previewHtmlSecondary\n ? 'var(--c8y-root-component-icon-dark-color-dark)'\n : null\n \"\n [innerHTML]=\"icon.previewHtml\"\n ></span>\n @if (icon.previewHtmlSecondary) {\n <span\n class=\"d-flex a-i-center j-c-center fit-w fit-h p-absolute\"\n style=\"top: 0; left: 0; color: var(--c8y-root-component-icon-dark-color-light)\"\n [innerHTML]=\"icon.previewHtmlSecondary\"\n ></span>\n }\n </span>\n <span class=\"flex-grow min-width-0 text-truncate\">\n {{ icon.name }}\n @if (icon.category && !icon.isOverride) {\n <small class=\"text-muted m-l-4\">{{ icon.category | translate }}</small>\n }\n </span>\n <span class=\"tag tag--warning flex-no-shrink\">\n {{ skipReasonLabels[icon.reason] | translate }}\n </span>\n </li>\n }\n </ul>\n }\n }\n\n @if (state() === 'importing') {\n <c8y-loading\n role=\"status\"\n [message]=\"'Importing icons\u2026' | translate\"\n ></c8y-loading>\n }\n </div>\n</c8y-modal>\n" }]
|
|
1129
|
+
}] });
|
|
1130
|
+
|
|
1131
|
+
class ImportCustomIconsModalService {
|
|
1132
|
+
constructor() {
|
|
1133
|
+
this.modal = inject(BsModalService);
|
|
1134
|
+
}
|
|
1135
|
+
/**
|
|
1136
|
+
* Opens the import custom icons modal.
|
|
1137
|
+
* @returns Resolves to `true` when at least one icon was imported, `false` when the user
|
|
1138
|
+
* cancelled or nothing was imported.
|
|
1139
|
+
*/
|
|
1140
|
+
open() {
|
|
1141
|
+
const ref = this.modal.show(ImportCustomIconsModalComponent, {
|
|
1142
|
+
ignoreBackdropClick: true,
|
|
1143
|
+
keyboard: false,
|
|
1144
|
+
class: 'modal-md'
|
|
1145
|
+
});
|
|
1146
|
+
return ref.content.result;
|
|
1147
|
+
}
|
|
1148
|
+
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: ImportCustomIconsModalService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
|
|
1149
|
+
static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: ImportCustomIconsModalService, providedIn: 'root' }); }
|
|
1150
|
+
}
|
|
1151
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: ImportCustomIconsModalService, decorators: [{
|
|
1152
|
+
type: Injectable,
|
|
1153
|
+
args: [{ providedIn: 'root' }]
|
|
1154
|
+
}] });
|
|
1155
|
+
|
|
31
1156
|
const allIconCategory = gettext('All`icons-category`');
|
|
1157
|
+
function errorMessage(e, fallback) {
|
|
1158
|
+
return (e instanceof Error ? e.message : null) ?? fallback;
|
|
1159
|
+
}
|
|
32
1160
|
class IconSelectorComponent {
|
|
33
1161
|
constructor() {
|
|
1162
|
+
// Exposed so templates can use gettext() for i18n string extraction inside @let expressions
|
|
1163
|
+
this.gettext = gettext;
|
|
1164
|
+
/** Category labels to hide from the grid. Values are translated category strings (e.g. `gettext('Custom\`icons-category\`')`). */
|
|
34
1165
|
this.iconCategoriesToExclude = [];
|
|
1166
|
+
/** When true, displays the icon's CSS class name as a label beneath each tile. */
|
|
35
1167
|
this.showIconClass = true;
|
|
36
1168
|
this.onSelect = new EventEmitter();
|
|
1169
|
+
this.allIconCategory = allIconCategory;
|
|
37
1170
|
this.searchTerm$ = new BehaviorSubject('');
|
|
38
1171
|
this.selectedIconCategory$ = new BehaviorSubject(allIconCategory);
|
|
39
|
-
this.
|
|
1172
|
+
this.isAdmin = signal(false, ...(ngDevMode ? [{ debugName: "isAdmin" }] : /* istanbul ignore next */ []));
|
|
1173
|
+
this.customIconNames = signal(new Set(), ...(ngDevMode ? [{ debugName: "customIconNames" }] : /* istanbul ignore next */ []));
|
|
1174
|
+
this.overriddenIconNames = signal(new Set(), ...(ngDevMode ? [{ debugName: "overriddenIconNames" }] : /* istanbul ignore next */ []));
|
|
1175
|
+
this.customCategoryLabels = signal(new Set(), ...(ngDevMode ? [{ debugName: "customCategoryLabels" }] : /* istanbul ignore next */ []));
|
|
1176
|
+
this.iconsLoading = signal(true, ...(ngDevMode ? [{ debugName: "iconsLoading" }] : /* istanbul ignore next */ []));
|
|
1177
|
+
this.isSaving = signal(false, ...(ngDevMode ? [{ debugName: "isSaving" }] : /* istanbul ignore next */ []));
|
|
1178
|
+
/** Categories rendered so far; updated in chunks to keep frames short. */
|
|
1179
|
+
this.visibleDefinitions = signal([], ...(ngDevMode ? [{ debugName: "visibleDefinitions" }] : /* istanbul ignore next */ []));
|
|
1180
|
+
/** Icons matching the current filter — drives the screen-reader result announcement. */
|
|
1181
|
+
this.filteredIconCount = signal(0, ...(ngDevMode ? [{ debugName: "filteredIconCount" }] : /* istanbul ignore next */ []));
|
|
1182
|
+
this.hasCustomIcons = computed(() => this.registry.icons().length > 0, ...(ngDevMode ? [{ debugName: "hasCustomIcons" }] : /* istanbul ignore next */ []));
|
|
1183
|
+
/** Incremented on each new renderProgressively call to cancel in-flight chunks.
|
|
1184
|
+
* Also incremented in ngOnDestroy so any pending setTimeout callbacks self-terminate. */
|
|
1185
|
+
this.progressiveRenderToken = 0;
|
|
1186
|
+
this.scrollToIconTimer = null;
|
|
1187
|
+
// ReplaySubject(1): no initial emission (unlike BehaviorSubject) so combineLatest waits
|
|
1188
|
+
// for ngOnInit's explicit .next() before emitting — prevents two-phase loading.
|
|
1189
|
+
// Replay ensures late subscribers (template may subscribe after ngOnInit fires) still get
|
|
1190
|
+
// the value — fixes blank selector for non-admin users.
|
|
1191
|
+
this.customIconsRefresh$ = new ReplaySubject(1);
|
|
1192
|
+
this.el = inject(ElementRef);
|
|
1193
|
+
this.permissions = inject(Permissions);
|
|
1194
|
+
this.registry = inject(CustomIconRegistryService);
|
|
1195
|
+
this.addModal = inject(AddCustomIconModalService);
|
|
1196
|
+
this.importModal = inject(ImportCustomIconsModalService);
|
|
1197
|
+
this.modalService = inject(ModalService);
|
|
1198
|
+
this.alerts = inject(AlertService);
|
|
1199
|
+
this.translate = inject(TranslateService);
|
|
1200
|
+
try {
|
|
1201
|
+
this.isAdmin.set(this.permissions.hasRole(Permissions.ROLE_APPLICATION_MANAGEMENT_ADMIN));
|
|
1202
|
+
}
|
|
1203
|
+
catch {
|
|
1204
|
+
// not logged in yet
|
|
1205
|
+
}
|
|
1206
|
+
const builtInIcons$ = defer(() => from(this.loadBuiltInDefinitions())).pipe(shareReplay({ refCount: true, bufferSize: 1 }));
|
|
1207
|
+
const customIcons$ = this.customIconsRefresh$.pipe(map(() => this.registry.toIconDefinitions()),
|
|
1208
|
+
// Emit an empty list immediately so combineLatest doesn't wait for the
|
|
1209
|
+
// registry network fetch before showing built-in icons.
|
|
1210
|
+
startWith([]));
|
|
1211
|
+
this.icons$ = combineLatest([builtInIcons$, customIcons$]).pipe(
|
|
1212
|
+
// Clear the loading spinner on the first emission (built-in icons ready)
|
|
1213
|
+
// rather than waiting until registry.load() also completes.
|
|
1214
|
+
tap(() => this.iconsLoading.set(false)), map(([builtIn, custom]) => {
|
|
1215
|
+
// Merge definitions with the same label (custom icons assigned to an existing category
|
|
1216
|
+
// must appear inside that category, not create a duplicate group)
|
|
1217
|
+
const merged = new Map();
|
|
1218
|
+
for (const def of [...custom, ...builtIn]) {
|
|
1219
|
+
const existing = merged.get(def.label);
|
|
1220
|
+
const mutableIcons = def.icons.map(i => [...i]);
|
|
1221
|
+
if (existing) {
|
|
1222
|
+
existing.push(...mutableIcons);
|
|
1223
|
+
}
|
|
1224
|
+
else {
|
|
1225
|
+
merged.set(def.label, mutableIcons);
|
|
1226
|
+
}
|
|
1227
|
+
}
|
|
1228
|
+
return Array.from(merged.entries())
|
|
1229
|
+
.map(([label, icons]) => ({ label, icons }))
|
|
1230
|
+
.filter(c => !this.iconCategoriesToExclude.includes(c.label))
|
|
1231
|
+
.sort((a, b) => a.label.localeCompare(b.label));
|
|
1232
|
+
}), shareReplay({ refCount: true, bufferSize: 1 }));
|
|
40
1233
|
this.filteredIcons$ = combineLatest([
|
|
41
1234
|
this.icons$,
|
|
42
1235
|
this.searchTerm$.pipe(debounceTime(500), startWith(this.searchTerm$.value)),
|
|
43
1236
|
this.selectedIconCategory$
|
|
44
1237
|
]).pipe(map(([icons, searchTerm, category]) => this.filterIconsByCategoryAndSearchTerm(icons, category, searchTerm)));
|
|
45
|
-
this.availableIconCategories$ = this.icons$.pipe(map(icons => [allIconCategory, ...icons.map(
|
|
1238
|
+
this.availableIconCategories$ = this.icons$.pipe(map(icons => [allIconCategory, ...icons.map(c => c.label)]));
|
|
1239
|
+
this.availableIconCategoryItems$ = this.availableIconCategories$.pipe(switchMap(cats => merge(of(null), this.translate.onLangChange).pipe(map(() => cats.map(c => ({ label: this.translate.instant(c), value: c }))))), startWith([{ label: this.translate.instant(allIconCategory), value: allIconCategory }]));
|
|
1240
|
+
this.allCategories = toSignal(this.icons$.pipe(map(icons => icons.map(c => c.label))), {
|
|
1241
|
+
initialValue: []
|
|
1242
|
+
});
|
|
1243
|
+
this.allIconNames = toSignal(this.icons$.pipe(map(defs => new Set(defs.flatMap(cat => cat.icons.map(i => i[0]))))), { initialValue: new Set() });
|
|
1244
|
+
// Drive progressive rendering — each new filteredIcons$ emission (initial load,
|
|
1245
|
+
// search, category filter) triggers chunked DOM creation so the first categories
|
|
1246
|
+
// appear immediately without blocking the main thread for 1400+ nodes.
|
|
1247
|
+
this.filteredIcons$.pipe(takeUntilDestroyed()).subscribe(defs => {
|
|
1248
|
+
this.filteredIconCount.set(defs.reduce((total, def) => total + def.icons.length, 0));
|
|
1249
|
+
this.renderProgressively(defs);
|
|
1250
|
+
});
|
|
46
1251
|
}
|
|
47
|
-
async
|
|
48
|
-
|
|
49
|
-
|
|
1252
|
+
async ngOnInit() {
|
|
1253
|
+
// Only fetch on first open or after a failed load. Skipping on subsequent opens avoids
|
|
1254
|
+
// racing against the platform propagating a new activeVersionId after a save.
|
|
1255
|
+
// registry.loaded() distinguishes "never loaded / error" from "loaded but empty".
|
|
1256
|
+
try {
|
|
1257
|
+
if (!this.registry.loaded()) {
|
|
1258
|
+
await this.registry.load();
|
|
1259
|
+
}
|
|
1260
|
+
this.syncCustomIconNames();
|
|
1261
|
+
}
|
|
1262
|
+
catch (e) {
|
|
1263
|
+
this.alerts.danger(errorMessage(e, gettext('Failed to load custom icons.')));
|
|
1264
|
+
}
|
|
1265
|
+
this.customIconsRefresh$.next();
|
|
1266
|
+
// iconsLoading is now cleared reactively by tap() in the icons$ pipeline on first emission.
|
|
50
1267
|
}
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
1268
|
+
async onResetOverrideClicked(iconName) {
|
|
1269
|
+
try {
|
|
1270
|
+
await this.modalService.confirm(gettext('Reset icon'), gettext('This will remove your custom override and restore the original icon.'), 'warning');
|
|
54
1271
|
}
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
1272
|
+
catch {
|
|
1273
|
+
return;
|
|
1274
|
+
}
|
|
1275
|
+
const override = this.registry
|
|
1276
|
+
.icons()
|
|
1277
|
+
.find(i => i.isOverride && i.overriddenIconName === iconName);
|
|
1278
|
+
if (!override)
|
|
1279
|
+
return;
|
|
1280
|
+
this.isSaving.set(true);
|
|
1281
|
+
try {
|
|
1282
|
+
await this.registry.deleteIcon(override.name, override.family);
|
|
1283
|
+
this.syncCustomIconNames();
|
|
1284
|
+
this.customIconsRefresh$.next();
|
|
1285
|
+
}
|
|
1286
|
+
catch (e) {
|
|
1287
|
+
this.alerts.danger(errorMessage(e, gettext('Failed to reset icon.')));
|
|
1288
|
+
}
|
|
1289
|
+
finally {
|
|
1290
|
+
this.isSaving.set(false);
|
|
1291
|
+
}
|
|
1292
|
+
}
|
|
1293
|
+
async onAddIconClicked() {
|
|
1294
|
+
const result = await this.addModal.open({
|
|
1295
|
+
mode: 'add',
|
|
1296
|
+
allCategories: this.allCategories(),
|
|
1297
|
+
reservedIconNames: new Set([...this.allIconNames(), ...this.customIconNames()])
|
|
1298
|
+
});
|
|
1299
|
+
if (!result)
|
|
1300
|
+
return;
|
|
1301
|
+
this.isSaving.set(true);
|
|
1302
|
+
try {
|
|
1303
|
+
await this.registry.saveIcon(result);
|
|
1304
|
+
this.syncCustomIconNames();
|
|
1305
|
+
this.customIconsRefresh$.next();
|
|
1306
|
+
}
|
|
1307
|
+
catch (e) {
|
|
1308
|
+
this.alerts.danger(errorMessage(e, gettext('Failed to save icon.')));
|
|
1309
|
+
return;
|
|
1310
|
+
}
|
|
1311
|
+
finally {
|
|
1312
|
+
this.isSaving.set(false);
|
|
1313
|
+
}
|
|
1314
|
+
this.scrollToIcon(result.name);
|
|
1315
|
+
}
|
|
1316
|
+
async onEditIconClicked(iconName) {
|
|
1317
|
+
const icon = this.registry.icons().find(i => i.name === iconName);
|
|
1318
|
+
if (!icon)
|
|
1319
|
+
return;
|
|
1320
|
+
// After a reload only manifest metadata is in memory — fetch the SVGs so the
|
|
1321
|
+
// edit modal shows the current content instead of empty upload fields.
|
|
1322
|
+
const editingIcon = await this.registry.withSvgContent(icon);
|
|
1323
|
+
const result = await this.addModal.open({
|
|
1324
|
+
mode: 'edit',
|
|
1325
|
+
editingIcon,
|
|
1326
|
+
allCategories: this.allCategories()
|
|
1327
|
+
});
|
|
1328
|
+
if (!result)
|
|
1329
|
+
return;
|
|
1330
|
+
this.isSaving.set(true);
|
|
1331
|
+
try {
|
|
1332
|
+
await this.registry.saveIcon(result);
|
|
1333
|
+
this.syncCustomIconNames();
|
|
1334
|
+
this.customIconsRefresh$.next();
|
|
1335
|
+
}
|
|
1336
|
+
catch (e) {
|
|
1337
|
+
this.alerts.danger(errorMessage(e, gettext('Failed to save icon.')));
|
|
1338
|
+
return;
|
|
1339
|
+
}
|
|
1340
|
+
finally {
|
|
1341
|
+
this.isSaving.set(false);
|
|
1342
|
+
}
|
|
1343
|
+
this.scrollToIcon(result.name);
|
|
1344
|
+
}
|
|
1345
|
+
async onDeleteIconClicked(iconName) {
|
|
1346
|
+
const icon = this.registry.icons().find(i => i.name === iconName);
|
|
1347
|
+
if (!icon)
|
|
1348
|
+
return;
|
|
1349
|
+
try {
|
|
1350
|
+
await this.modalService.confirm(gettext('Delete icon'), gettext('Are you sure you want to delete this icon? This cannot be undone.'), 'danger', { ok: gettext('Delete') });
|
|
1351
|
+
}
|
|
1352
|
+
catch {
|
|
1353
|
+
return;
|
|
1354
|
+
}
|
|
1355
|
+
this.isSaving.set(true);
|
|
1356
|
+
try {
|
|
1357
|
+
await this.registry.deleteIcon(icon.name, icon.family);
|
|
1358
|
+
this.syncCustomIconNames();
|
|
1359
|
+
this.customIconsRefresh$.next();
|
|
1360
|
+
}
|
|
1361
|
+
catch (e) {
|
|
1362
|
+
this.alerts.danger(errorMessage(e, gettext('Failed to delete icon.')));
|
|
1363
|
+
}
|
|
1364
|
+
finally {
|
|
1365
|
+
this.isSaving.set(false);
|
|
1366
|
+
}
|
|
1367
|
+
}
|
|
1368
|
+
async onOverrideIconClicked(icon) {
|
|
1369
|
+
const iconName = icon[0];
|
|
1370
|
+
const overrideDef = {
|
|
1371
|
+
name: iconName.replace(/^c8y-/, ''),
|
|
1372
|
+
family: iconName.startsWith('c8y-') ? 'c8y' : 'dlt',
|
|
1373
|
+
category: '',
|
|
1374
|
+
svgContentPrimary: '',
|
|
1375
|
+
isOverride: true,
|
|
1376
|
+
overriddenIconName: iconName
|
|
1377
|
+
};
|
|
1378
|
+
const result = await this.addModal.open({
|
|
1379
|
+
mode: 'override',
|
|
1380
|
+
editingIcon: overrideDef,
|
|
1381
|
+
allCategories: this.allCategories()
|
|
1382
|
+
});
|
|
1383
|
+
if (!result)
|
|
1384
|
+
return;
|
|
1385
|
+
this.isSaving.set(true);
|
|
1386
|
+
try {
|
|
1387
|
+
await this.registry.saveIcon(result);
|
|
1388
|
+
this.syncCustomIconNames();
|
|
1389
|
+
this.customIconsRefresh$.next();
|
|
1390
|
+
}
|
|
1391
|
+
catch (e) {
|
|
1392
|
+
this.alerts.danger(errorMessage(e, gettext('Failed to save icon override.')));
|
|
1393
|
+
return;
|
|
1394
|
+
}
|
|
1395
|
+
finally {
|
|
1396
|
+
this.isSaving.set(false);
|
|
1397
|
+
}
|
|
1398
|
+
// Scroll to the original icon position (override keeps it in the built-in grid slot)
|
|
1399
|
+
this.scrollToIcon(result.overriddenIconName ?? result.name);
|
|
1400
|
+
}
|
|
1401
|
+
async onExportClicked() {
|
|
1402
|
+
try {
|
|
1403
|
+
const blob = await this.registry.exportAsZip();
|
|
1404
|
+
this.triggerDownload(blob, 'custom-icons.zip');
|
|
1405
|
+
}
|
|
1406
|
+
catch (e) {
|
|
1407
|
+
this.alerts.danger(errorMessage(e, gettext('Export failed.')));
|
|
1408
|
+
}
|
|
1409
|
+
}
|
|
1410
|
+
async onImportClicked() {
|
|
1411
|
+
const didImport = await this.importModal.open();
|
|
1412
|
+
if (didImport) {
|
|
1413
|
+
this.isSaving.set(true);
|
|
1414
|
+
try {
|
|
1415
|
+
this.syncCustomIconNames();
|
|
1416
|
+
this.customIconsRefresh$.next();
|
|
1417
|
+
}
|
|
1418
|
+
finally {
|
|
1419
|
+
this.isSaving.set(false);
|
|
63
1420
|
}
|
|
64
|
-
return matchingCategories;
|
|
65
1421
|
}
|
|
66
|
-
return iconCategories;
|
|
67
1422
|
}
|
|
68
1423
|
onSearchChange(searchTerm) {
|
|
69
1424
|
this.searchTerm$.next(searchTerm);
|
|
@@ -75,21 +1430,133 @@ class IconSelectorComponent {
|
|
|
75
1430
|
this.selectedIcon = icon[0];
|
|
76
1431
|
this.onSelect.emit(icon[0]);
|
|
77
1432
|
}
|
|
1433
|
+
ngOnDestroy() {
|
|
1434
|
+
this.customIconsRefresh$.complete();
|
|
1435
|
+
++this.progressiveRenderToken;
|
|
1436
|
+
if (this.scrollToIconTimer !== null)
|
|
1437
|
+
clearTimeout(this.scrollToIconTimer);
|
|
1438
|
+
}
|
|
1439
|
+
/**
|
|
1440
|
+
* Renders `definitions` in chunks of 3 categories per `setTimeout(0)` frame.
|
|
1441
|
+
* This keeps individual frames short so the browser can paint after each chunk
|
|
1442
|
+
* and Angular's change-detection cycles remain cheap (enabling fast modal opens).
|
|
1443
|
+
* A token guards against stale chunks from a superseded render (e.g. rapid search).
|
|
1444
|
+
*/
|
|
1445
|
+
renderProgressively(definitions, onComplete) {
|
|
1446
|
+
const token = ++this.progressiveRenderToken;
|
|
1447
|
+
if (!definitions.length) {
|
|
1448
|
+
this.visibleDefinitions.set([]);
|
|
1449
|
+
onComplete?.();
|
|
1450
|
+
return;
|
|
1451
|
+
}
|
|
1452
|
+
const CHUNK = 3;
|
|
1453
|
+
// Show first chunk immediately — no empty-state flash.
|
|
1454
|
+
this.visibleDefinitions.set(definitions.slice(0, CHUNK));
|
|
1455
|
+
let i = CHUNK;
|
|
1456
|
+
const render = () => {
|
|
1457
|
+
if (token !== this.progressiveRenderToken)
|
|
1458
|
+
return; // superseded
|
|
1459
|
+
const chunk = definitions.slice(i, i + CHUNK);
|
|
1460
|
+
if (!chunk.length) {
|
|
1461
|
+
onComplete?.();
|
|
1462
|
+
return;
|
|
1463
|
+
}
|
|
1464
|
+
this.visibleDefinitions.update(prev => [...prev, ...chunk]);
|
|
1465
|
+
i += CHUNK;
|
|
1466
|
+
if (i < definitions.length) {
|
|
1467
|
+
setTimeout(render, 0);
|
|
1468
|
+
}
|
|
1469
|
+
else {
|
|
1470
|
+
onComplete?.();
|
|
1471
|
+
}
|
|
1472
|
+
};
|
|
1473
|
+
if (i < definitions.length)
|
|
1474
|
+
setTimeout(render, 0);
|
|
1475
|
+
else
|
|
1476
|
+
onComplete?.();
|
|
1477
|
+
}
|
|
1478
|
+
filterIconsByCategoryAndSearchTerm(iconCategories, selectedCategory, searchTerm) {
|
|
1479
|
+
if (selectedCategory !== allIconCategory) {
|
|
1480
|
+
iconCategories = iconCategories.filter(category => category.label === selectedCategory);
|
|
1481
|
+
}
|
|
1482
|
+
if (!searchTerm)
|
|
1483
|
+
return iconCategories;
|
|
1484
|
+
const lowerCaseSearchTerm = searchTerm.toLowerCase();
|
|
1485
|
+
const matchingCategories = [];
|
|
1486
|
+
for (const category of iconCategories) {
|
|
1487
|
+
const matchingIcons = category.icons.filter(iconClasses => iconClasses.some(iconClass => iconClass.includes(lowerCaseSearchTerm)));
|
|
1488
|
+
if (matchingIcons.length) {
|
|
1489
|
+
matchingCategories.push({ label: category.label, icons: matchingIcons });
|
|
1490
|
+
}
|
|
1491
|
+
}
|
|
1492
|
+
return matchingCategories;
|
|
1493
|
+
}
|
|
1494
|
+
isCustomIcon(iconName) {
|
|
1495
|
+
return this.customIconNames().has(iconName);
|
|
1496
|
+
}
|
|
1497
|
+
isIconOverridden(iconName) {
|
|
1498
|
+
return this.overriddenIconNames().has(iconName);
|
|
1499
|
+
}
|
|
1500
|
+
async loadBuiltInDefinitions() {
|
|
1501
|
+
const { allIcons } = await import('@c8y/ngx-components/icon-selector/icons');
|
|
1502
|
+
return allIcons;
|
|
1503
|
+
}
|
|
1504
|
+
syncCustomIconNames() {
|
|
1505
|
+
const icons = this.registry.icons();
|
|
1506
|
+
const nonOverrides = icons.filter(i => !i.isOverride);
|
|
1507
|
+
this.customIconNames.set(new Set(nonOverrides.map(i => i.name)));
|
|
1508
|
+
this.customCategoryLabels.set(new Set(nonOverrides.map(i => i.category).filter(Boolean)));
|
|
1509
|
+
this.overriddenIconNames.set(new Set(icons
|
|
1510
|
+
.filter(i => i.isOverride)
|
|
1511
|
+
.map(i => i.overriddenIconName)
|
|
1512
|
+
.filter(Boolean)));
|
|
1513
|
+
}
|
|
1514
|
+
scrollToIcon(iconName) {
|
|
1515
|
+
this.selectedIcon = iconName;
|
|
1516
|
+
this.onSelect.emit(iconName);
|
|
1517
|
+
this.scheduleScrollToSelected();
|
|
1518
|
+
}
|
|
1519
|
+
/** Scrolls the grid to whichever icon currently has [aria-current="true"].
|
|
1520
|
+
* Called after a user selects or saves an icon. */
|
|
1521
|
+
scheduleScrollToSelected(delay = 50) {
|
|
1522
|
+
if (this.scrollToIconTimer !== null)
|
|
1523
|
+
clearTimeout(this.scrollToIconTimer);
|
|
1524
|
+
this.scrollToIconTimer = setTimeout(() => {
|
|
1525
|
+
this.scrollToIconTimer = null;
|
|
1526
|
+
try {
|
|
1527
|
+
const btn = this.el.nativeElement.querySelector('[aria-current="true"]');
|
|
1528
|
+
btn?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
|
1529
|
+
}
|
|
1530
|
+
catch {
|
|
1531
|
+
// non-critical — scroll is best-effort
|
|
1532
|
+
}
|
|
1533
|
+
}, delay);
|
|
1534
|
+
}
|
|
1535
|
+
triggerDownload(blob, filename) {
|
|
1536
|
+
const url = URL.createObjectURL(blob);
|
|
1537
|
+
const a = document.createElement('a');
|
|
1538
|
+
a.href = url;
|
|
1539
|
+
a.download = filename;
|
|
1540
|
+
a.click();
|
|
1541
|
+
URL.revokeObjectURL(url);
|
|
1542
|
+
}
|
|
78
1543
|
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: IconSelectorComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
|
|
79
|
-
static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.17", type: IconSelectorComponent, isStandalone: true, selector: "c8y-icon-selector", inputs: { iconCategoriesToExclude: "iconCategoriesToExclude", showIconClass: "showIconClass", selectedIcon: "selectedIcon" }, outputs: { onSelect: "onSelect" }, ngImport: i0, template: "<div class=\"p-l-24 p-r-24 p-t-8 p-b-8 separator-bottom\">\n <div class=\"row d-flex-sm\">\n <div class=\"col-sm-6 m-b-8\">\n <div\n class=\"input-group-search input-group\"\n style=\"width: auto\"\n >\n <input\n class=\"form-control\"\n id=\"filter-icons\"\n placeholder=\"{{ 'Type to filter icons\u2026' | translate }}\"\n type=\"search\"\n [ngModel]=\"searchTerm$ | async\"\n (ngModelChange)=\"onSearchChange($event)\"\n />\n @if (searchTerm$ | async) {\n <span\n class=\"input-group-addon pointer\"\n (click)=\"onSearchChange('')\"\n >\n <i c8yIcon=\"times\"></i>\n </span>\n } @else {\n <span class=\"input-group-addon\">\n <i c8yIcon=\"search\"></i>\n </span>\n }\n </div>\n </div>\n <div class=\"col-sm-6 m-b-8 text-right\">\n <div class=\"d-inline-flex a-i-center text-left\">\n <label\n class=\"m-b-0 m-r-8 flex-no-shrink\"\n translate\n >\n Filter by type\n </label>\n <div class=\"c8y-select-wrapper\">\n <select\n class=\"form-control\"\n id=\"exampleSelect\"\n [ngModel]=\"selectedIconCategory$ | async\"\n (ngModelChange)=\"onCategoryFilterChanged($event)\"\n >\n @for (category of availableIconCategories$ | async; track category) {\n <option [ngValue]=\"category\">\n {{ category | translate }}\n </option>\n }\n </select>\n <span></span>\n </div>\n </div>\n </div>\n </div>\n</div>\n<div class=\"modal-inner-scroll\">\n <div\n class=\"modal-body\"\n style=\"height: calc(100vh - 293px)\"\n >\n <div class=\"dtm-icon-grid\">\n @for (iconDefinition of filteredIcons$ | async; track iconDefinition) {\n <div class=\"d-contents\">\n <div class=\"legend form-block center grid__col--fullspan\">\n {{ iconDefinition.label | translate }}\n </div>\n\n @for (icon of iconDefinition.icons; track icon) {\n <div class=\"d-contents\">\n <div\n class=\"dtm-icon-grid__item\"\n [ngClass]=\"{\n 'dtm-icon-grid__item--selected': selectedIcon && icon[0] === selectedIcon\n }\"\n >\n <button\n class=\"dtm-icon-grid__btn\"\n [title]=\"icon[0] | iconName\"\n (click)=\"onIconClicked(icon)\"\n >\n <i\n class=\"icon-40 c8y-icon-duocolor\"\n [c8yIcon]=\"icon[0]\"\n ></i>\n @if (showIconClass) {\n <small class=\"d-block text-center text-break-word\">\n {{ icon[0] | iconName }}\n </small>\n }\n </button>\n </div>\n </div>\n }\n </div>\n } @empty {\n <c8y-ui-empty-state\n icon=\"search\"\n style=\"grid-column: 1 / 3\"\n [title]=\"'No icons found' | translate\"\n [subtitle]=\"\n 'Try adjusting your search or filter to find what you\\'re looking for.' | translate\n \"\n [horizontal]=\"true\"\n ></c8y-ui-empty-state>\n }\n </div>\n </div>\n</div>\n", dependencies: [{ kind: "ngmodule", type: FormsModule }, { kind: "directive", type: i1.NgSelectOption, selector: "option", inputs: ["ngValue", "value"] }, { kind: "directive", type: i1.ɵNgSelectMultipleOption, selector: "option", inputs: ["ngValue", "value"] }, { kind: "directive", type: i1.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1.SelectControlValueAccessor, selector: "select:not([multiple])[formControlName],select:not([multiple])[formControl],select:not([multiple])[ngModel]", inputs: ["compareWith"] }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }, { kind: "directive", type: IconDirective, selector: "[c8yIcon]", inputs: ["c8yIcon"] }, { kind: "directive", type: C8yTranslateDirective, selector: "[translate],[ngx-translate]" }, { kind: "directive", type: NgClass, selector: "[ngClass]", inputs: ["class", "ngClass"] }, { kind: "component", type: EmptyStateComponent, selector: "c8y-ui-empty-state", inputs: ["icon", "title", "subtitle", "horizontal"] }, { kind: "pipe", type: C8yTranslatePipe, name: "translate" }, { kind: "pipe", type: AsyncPipe, name: "async" }, { kind: "pipe", type: IconNamePipe, name: "iconName" }] }); }
|
|
1544
|
+
static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.17", type: IconSelectorComponent, isStandalone: true, selector: "c8y-icon-selector", inputs: { iconCategoriesToExclude: "iconCategoriesToExclude", showIconClass: "showIconClass", selectedIcon: "selectedIcon" }, outputs: { onSelect: "onSelect" }, ngImport: i0, template: "<div\n class=\"p-l-24 p-r-24 p-t-8 p-b-8 separator-bottom sticky-top\"\n style=\"background: var(--c8y-root-component-background-default); z-index: 2\"\n>\n <div class=\"row d-flex-sm\">\n <div class=\"col-sm-5 m-b-8\">\n <div\n class=\"input-group-search input-group\"\n style=\"width: auto\"\n >\n <label\n class=\"sr-only\"\n for=\"filter-icons\"\n translate\n >Filter icons</label\n >\n <input\n class=\"form-control\"\n id=\"filter-icons\"\n placeholder=\"{{ 'Type to filter icons\u2026' | translate }}\"\n type=\"search\"\n [ngModel]=\"searchTerm$ | async\"\n (ngModelChange)=\"onSearchChange($event)\"\n />\n @if (searchTerm$ | async) {\n <button\n class=\"input-group-addon\"\n [attr.aria-label]=\"'Clear search' | translate\"\n type=\"button\"\n (click)=\"onSearchChange('')\"\n >\n <i\n c8yIcon=\"times\"\n aria-hidden=\"true\"\n ></i>\n </button>\n } @else {\n <span\n class=\"input-group-addon\"\n aria-hidden=\"true\"\n >\n <i\n c8yIcon=\"search\"\n aria-hidden=\"true\"\n ></i>\n </span>\n }\n </div>\n </div>\n <div class=\"col-sm-7 m-b-8\">\n <div class=\"d-flex a-i-center j-c-end gap-16 text-left\">\n <div class=\"d-flex a-i-center flex-grow\">\n <label\n class=\"m-b-0 m-r-8 flex-no-shrink\"\n id=\"icon-category-filter-label\"\n translate\n >Collection</label\n >\n <c8y-select\n class=\"flex-grow\"\n [attr.aria-labelledby]=\"'icon-category-filter-label'\"\n [items]=\"availableIconCategoryItems$ | async\"\n [selected]=\"selectedIconCategory$ | async\"\n [canDeselect]=\"true\"\n (onSelect)=\"onCategoryFilterChanged($any($event).value)\"\n (onDeselect)=\"onCategoryFilterChanged(allIconCategory)\"\n ></c8y-select>\n </div>\n\n @if (isAdmin()) {\n <button\n class=\"btn btn-clean p-l-8 p-r-8 flex-no-shrink\"\n [title]=\"'Add custom icon' | translate\"\n [attr.aria-label]=\"'Add custom icon' | translate\"\n type=\"button\"\n (click)=\"onAddIconClicked()\"\n >\n <i\n class=\"m-r-4\"\n c8yIcon=\"plus-circle\"\n aria-hidden=\"true\"\n ></i>\n <span translate>Add icon</span>\n </button>\n <button\n class=\"btn btn-clean flex-no-shrink\"\n [attr.aria-label]=\"'Import icons from ZIP' | translate\"\n [tooltip]=\"'Import icons from ZIP' | translate\"\n placement=\"top\"\n container=\"body\"\n type=\"button\"\n [delay]=\"500\"\n [adaptivePosition]=\"false\"\n (click)=\"onImportClicked()\"\n >\n <i\n class=\"m-r-4\"\n c8yIcon=\"data-import\"\n aria-hidden=\"true\"\n ></i>\n <span>{{ 'Import`verb, imports icons from a ZIP file`' | translate }}</span>\n </button>\n @if (hasCustomIcons()) {\n <button\n class=\"btn btn-clean flex-no-shrink\"\n [attr.aria-label]=\"'Export custom icons as ZIP' | translate\"\n [tooltip]=\"'Export custom icons as ZIP' | translate\"\n placement=\"top\"\n container=\"body\"\n type=\"button\"\n [delay]=\"500\"\n [adaptivePosition]=\"false\"\n (click)=\"onExportClicked()\"\n >\n <i\n class=\"m-r-4\"\n c8yIcon=\"data-export\"\n aria-hidden=\"true\"\n ></i>\n <span>{{ 'Export`verb, exports custom icons as a ZIP file`' | translate }}</span>\n </button>\n }\n }\n </div>\n </div>\n </div>\n\n @if (isSaving()) {\n <div\n class=\"p-absolute m-t-4 p-b-4 overflow-hidden\"\n style=\"left: 0; right: 0\"\n [attr.aria-label]=\"'Saving\u2026' | translate\"\n role=\"status\"\n >\n <c8y-loading layout=\"page\"></c8y-loading>\n </div>\n }\n</div>\n\n<div class=\"p-16\">\n @if (iconsLoading()) {\n <div\n class=\"d-flex a-i-center j-c-center p-32\"\n [attr.aria-label]=\"'Loading icons\u2026' | translate\"\n role=\"status\"\n >\n <c8y-loading></c8y-loading>\n </div>\n } @else {\n <p\n class=\"sr-only\"\n role=\"status\"\n >\n @if (filteredIconCount() === 0) {\n {{ 'No icons found' | translate }}\n } @else {\n {{ '{{count}} icon(s) found' | translate: { count: filteredIconCount() } }}\n }\n </p>\n <section\n class=\"dtm-icon-grid\"\n [attr.aria-label]=\"'Icon grid' | translate\"\n [attr.aria-busy]=\"visibleDefinitions().length === 0 ? 'true' : null\"\n role=\"region\"\n >\n @for (iconDefinition of visibleDefinitions(); track iconDefinition.label) {\n <div\n class=\"d-contents\"\n [attr.aria-label]=\"iconDefinition.label | translate\"\n role=\"group\"\n >\n <div class=\"legend form-block center grid__col--fullspan d-flex a-i-center gap-8\">\n {{ iconDefinition.label | translate }}\n @if (customCategoryLabels().has(iconDefinition.label)) {\n <span class=\"tag tag--info m-l-4 m-t-0 m-b-0\">{{\n 'Custom`badge indicating a user-added icon category`' | translate\n }}</span>\n }\n </div>\n\n @for (icon of iconDefinition.icons; track icon[0]) {\n @let isCustom = isCustomIcon(icon[0]);\n @let isOverridden = isIconOverridden(icon[0]);\n @let overriddenLabel = gettext('{{name}} (overridden)');\n @let deleteHint = 'Select a different icon before deleting this one.' | translate;\n <div class=\"d-contents\">\n <div\n class=\"dtm-icon-grid__item\"\n [class.dtm-icon-grid__item--selected]=\"selectedIcon && icon[0] === selectedIcon\"\n [class.dtm-icon-grid__item--overridden]=\"isOverridden\"\n >\n <button\n class=\"dtm-icon-grid__btn\"\n [title]=\"icon[0] | iconName\"\n [attr.aria-label]=\"\n isOverridden\n ? (overriddenLabel | translate: { name: icon[0] | iconName })\n : (icon[0] | iconName)\n \"\n [attr.aria-current]=\"selectedIcon && icon[0] === selectedIcon ? 'true' : null\"\n (click)=\"onIconClicked(icon)\"\n >\n <i\n class=\"icon-40 c8y-icon-duocolor\"\n [c8yIcon]=\"icon[0]\"\n aria-hidden=\"true\"\n ></i>\n @if (showIconClass) {\n <small\n class=\"d-block text-center text-break-word\"\n aria-hidden=\"true\"\n >\n {{ icon[0] | iconName }}\n </small>\n }\n </button>\n\n @if (isOverridden) {\n <span\n class=\"dtm-icon-grid__override-marker\"\n [tooltip]=\"'Overridden icon' | translate\"\n placement=\"top\"\n container=\"body\"\n aria-hidden=\"true\"\n [delay]=\"500\"\n >\n <i c8yIcon=\"replace\"></i>\n </span>\n }\n\n @if (isAdmin()) {\n <div\n class=\"dtm-icon-grid__actions\"\n [attr.aria-label]=\"'{{name}} actions' | translate: { name: icon[0] | iconName }\"\n role=\"group\"\n >\n @if (isCustom) {\n <button\n class=\"btn btn-dot\"\n [attr.aria-label]=\"\n 'Edit {{name}}' | translate: { name: icon[0] | iconName }\n \"\n [tooltip]=\"'Edit {{name}}' | translate: { name: icon[0] | iconName }\"\n placement=\"top\"\n container=\"body\"\n type=\"button\"\n [delay]=\"500\"\n (click)=\"$event.stopPropagation(); onEditIconClicked(icon[0])\"\n >\n <i\n c8yIcon=\"pencil\"\n aria-hidden=\"true\"\n ></i>\n </button>\n <span\n [tooltip]=\"icon[0] === selectedIcon ? deleteHint : null\"\n placement=\"top\"\n container=\"body\"\n [delay]=\"500\"\n >\n <button\n class=\"btn btn-dot btn-dot--danger m-0\"\n [class.disabled]=\"icon[0] === selectedIcon\"\n [attr.aria-label]=\"\n 'Delete {{name}}' | translate: { name: icon[0] | iconName }\n \"\n [tooltip]=\"'Delete {{name}}' | translate: { name: icon[0] | iconName }\"\n placement=\"top\"\n container=\"body\"\n [attr.aria-disabled]=\"icon[0] === selectedIcon ? 'true' : null\"\n [attr.aria-describedby]=\"\n icon[0] === selectedIcon ? 'delete-hint-' + icon[0] : null\n \"\n type=\"button\"\n [delay]=\"500\"\n (click)=\"\n $event.stopPropagation();\n icon[0] === selectedIcon || onDeleteIconClicked(icon[0])\n \"\n >\n <i\n c8yIcon=\"minus-circle\"\n aria-hidden=\"true\"\n ></i>\n </button>\n @if (icon[0] === selectedIcon) {\n <span\n class=\"sr-only\"\n [id]=\"'delete-hint-' + icon[0]\"\n >{{ deleteHint }}</span\n >\n }\n </span>\n } @else {\n @let overrideKey =\n isOverridden\n ? gettext('Replace override for {{name}}')\n : gettext('Override {{name}}');\n <button\n class=\"btn btn-dot\"\n [attr.aria-label]=\"overrideKey | translate: { name: icon[0] | iconName }\"\n [tooltip]=\"overrideKey | translate: { name: icon[0] | iconName }\"\n placement=\"top\"\n container=\"body\"\n type=\"button\"\n [delay]=\"500\"\n (click)=\"$event.stopPropagation(); onOverrideIconClicked(icon)\"\n >\n <i\n c8yIcon=\"replace\"\n aria-hidden=\"true\"\n ></i>\n </button>\n @if (isOverridden) {\n <button\n class=\"btn btn-dot btn-dot--danger\"\n [attr.aria-label]=\"\n 'Reset override for {{name}}' | translate: { name: icon[0] | iconName }\n \"\n [tooltip]=\"\n 'Reset override for {{name}}' | translate: { name: icon[0] | iconName }\n \"\n placement=\"top\"\n container=\"body\"\n type=\"button\"\n [delay]=\"500\"\n (click)=\"$event.stopPropagation(); onResetOverrideClicked(icon[0])\"\n >\n <i\n c8yIcon=\"undo\"\n aria-hidden=\"true\"\n ></i>\n </button>\n }\n }\n </div>\n }\n </div>\n </div>\n }\n </div>\n } @empty {\n <c8y-ui-empty-state\n class=\"grid__col--fullspan\"\n icon=\"search\"\n [title]=\"'No icons found' | translate\"\n [subtitle]=\"\n 'Try adjusting your search or filter to find what you\\'re looking for.' | translate\n \"\n [horizontal]=\"true\"\n ></c8y-ui-empty-state>\n }\n </section>\n }\n <!-- end @else iconsLoading -->\n</div>\n", dependencies: [{ kind: "ngmodule", type: FormsModule }, { 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.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: "directive", type: IconDirective, selector: "[c8yIcon]", inputs: ["c8yIcon"] }, { kind: "directive", type: C8yTranslateDirective, selector: "[translate],[ngx-translate]" }, { kind: "component", type: EmptyStateComponent, selector: "c8y-ui-empty-state", inputs: ["icon", "title", "subtitle", "horizontal"] }, { kind: "component", type: LoadingComponent, selector: "c8y-loading", inputs: ["layout", "progress", "progressLabel", "message"] }, { kind: "component", type: SelectComponent, selector: "c8y-select", inputs: ["placeholder", "items", "selected", "container", "multi", "canSelectWithSpace", "disabled", "autoClose", "insideClick", "required", "canDeselect", "name", "icon", "filterItems"], outputs: ["onSelect", "onDeselect", "onIconClick"] }, { kind: "ngmodule", type: TooltipModule }, { kind: "directive", type: i2.TooltipDirective, selector: "[tooltip], [tooltipHtml]", inputs: ["adaptivePosition", "tooltip", "placement", "triggers", "container", "containerClass", "boundariesElement", "isOpen", "isDisabled", "delay", "tooltipHtml", "tooltipPlacement", "tooltipIsOpen", "tooltipEnable", "tooltipAppendToBody", "tooltipAnimation", "tooltipClass", "tooltipContext", "tooltipPopupDelay", "tooltipFadeDuration", "tooltipTrigger"], outputs: ["tooltipChange", "onShown", "onHidden", "tooltipStateChanged"], exportAs: ["bs-tooltip"] }, { kind: "pipe", type: C8yTranslatePipe, name: "translate" }, { kind: "pipe", type: AsyncPipe, name: "async" }, { kind: "pipe", type: IconNamePipe, name: "iconName" }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); }
|
|
80
1545
|
}
|
|
81
1546
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: IconSelectorComponent, decorators: [{
|
|
82
1547
|
type: Component,
|
|
83
|
-
args: [{ selector: 'c8y-icon-selector', imports: [
|
|
1548
|
+
args: [{ selector: 'c8y-icon-selector', changeDetection: ChangeDetectionStrategy.OnPush, imports: [
|
|
84
1549
|
FormsModule,
|
|
85
1550
|
IconDirective,
|
|
86
1551
|
C8yTranslateDirective,
|
|
87
1552
|
C8yTranslatePipe,
|
|
88
1553
|
AsyncPipe,
|
|
89
|
-
NgClass,
|
|
90
1554
|
IconNamePipe,
|
|
91
|
-
EmptyStateComponent
|
|
92
|
-
|
|
1555
|
+
EmptyStateComponent,
|
|
1556
|
+
LoadingComponent,
|
|
1557
|
+
SelectComponent,
|
|
1558
|
+
TooltipModule
|
|
1559
|
+
], template: "<div\n class=\"p-l-24 p-r-24 p-t-8 p-b-8 separator-bottom sticky-top\"\n style=\"background: var(--c8y-root-component-background-default); z-index: 2\"\n>\n <div class=\"row d-flex-sm\">\n <div class=\"col-sm-5 m-b-8\">\n <div\n class=\"input-group-search input-group\"\n style=\"width: auto\"\n >\n <label\n class=\"sr-only\"\n for=\"filter-icons\"\n translate\n >Filter icons</label\n >\n <input\n class=\"form-control\"\n id=\"filter-icons\"\n placeholder=\"{{ 'Type to filter icons\u2026' | translate }}\"\n type=\"search\"\n [ngModel]=\"searchTerm$ | async\"\n (ngModelChange)=\"onSearchChange($event)\"\n />\n @if (searchTerm$ | async) {\n <button\n class=\"input-group-addon\"\n [attr.aria-label]=\"'Clear search' | translate\"\n type=\"button\"\n (click)=\"onSearchChange('')\"\n >\n <i\n c8yIcon=\"times\"\n aria-hidden=\"true\"\n ></i>\n </button>\n } @else {\n <span\n class=\"input-group-addon\"\n aria-hidden=\"true\"\n >\n <i\n c8yIcon=\"search\"\n aria-hidden=\"true\"\n ></i>\n </span>\n }\n </div>\n </div>\n <div class=\"col-sm-7 m-b-8\">\n <div class=\"d-flex a-i-center j-c-end gap-16 text-left\">\n <div class=\"d-flex a-i-center flex-grow\">\n <label\n class=\"m-b-0 m-r-8 flex-no-shrink\"\n id=\"icon-category-filter-label\"\n translate\n >Collection</label\n >\n <c8y-select\n class=\"flex-grow\"\n [attr.aria-labelledby]=\"'icon-category-filter-label'\"\n [items]=\"availableIconCategoryItems$ | async\"\n [selected]=\"selectedIconCategory$ | async\"\n [canDeselect]=\"true\"\n (onSelect)=\"onCategoryFilterChanged($any($event).value)\"\n (onDeselect)=\"onCategoryFilterChanged(allIconCategory)\"\n ></c8y-select>\n </div>\n\n @if (isAdmin()) {\n <button\n class=\"btn btn-clean p-l-8 p-r-8 flex-no-shrink\"\n [title]=\"'Add custom icon' | translate\"\n [attr.aria-label]=\"'Add custom icon' | translate\"\n type=\"button\"\n (click)=\"onAddIconClicked()\"\n >\n <i\n class=\"m-r-4\"\n c8yIcon=\"plus-circle\"\n aria-hidden=\"true\"\n ></i>\n <span translate>Add icon</span>\n </button>\n <button\n class=\"btn btn-clean flex-no-shrink\"\n [attr.aria-label]=\"'Import icons from ZIP' | translate\"\n [tooltip]=\"'Import icons from ZIP' | translate\"\n placement=\"top\"\n container=\"body\"\n type=\"button\"\n [delay]=\"500\"\n [adaptivePosition]=\"false\"\n (click)=\"onImportClicked()\"\n >\n <i\n class=\"m-r-4\"\n c8yIcon=\"data-import\"\n aria-hidden=\"true\"\n ></i>\n <span>{{ 'Import`verb, imports icons from a ZIP file`' | translate }}</span>\n </button>\n @if (hasCustomIcons()) {\n <button\n class=\"btn btn-clean flex-no-shrink\"\n [attr.aria-label]=\"'Export custom icons as ZIP' | translate\"\n [tooltip]=\"'Export custom icons as ZIP' | translate\"\n placement=\"top\"\n container=\"body\"\n type=\"button\"\n [delay]=\"500\"\n [adaptivePosition]=\"false\"\n (click)=\"onExportClicked()\"\n >\n <i\n class=\"m-r-4\"\n c8yIcon=\"data-export\"\n aria-hidden=\"true\"\n ></i>\n <span>{{ 'Export`verb, exports custom icons as a ZIP file`' | translate }}</span>\n </button>\n }\n }\n </div>\n </div>\n </div>\n\n @if (isSaving()) {\n <div\n class=\"p-absolute m-t-4 p-b-4 overflow-hidden\"\n style=\"left: 0; right: 0\"\n [attr.aria-label]=\"'Saving\u2026' | translate\"\n role=\"status\"\n >\n <c8y-loading layout=\"page\"></c8y-loading>\n </div>\n }\n</div>\n\n<div class=\"p-16\">\n @if (iconsLoading()) {\n <div\n class=\"d-flex a-i-center j-c-center p-32\"\n [attr.aria-label]=\"'Loading icons\u2026' | translate\"\n role=\"status\"\n >\n <c8y-loading></c8y-loading>\n </div>\n } @else {\n <p\n class=\"sr-only\"\n role=\"status\"\n >\n @if (filteredIconCount() === 0) {\n {{ 'No icons found' | translate }}\n } @else {\n {{ '{{count}} icon(s) found' | translate: { count: filteredIconCount() } }}\n }\n </p>\n <section\n class=\"dtm-icon-grid\"\n [attr.aria-label]=\"'Icon grid' | translate\"\n [attr.aria-busy]=\"visibleDefinitions().length === 0 ? 'true' : null\"\n role=\"region\"\n >\n @for (iconDefinition of visibleDefinitions(); track iconDefinition.label) {\n <div\n class=\"d-contents\"\n [attr.aria-label]=\"iconDefinition.label | translate\"\n role=\"group\"\n >\n <div class=\"legend form-block center grid__col--fullspan d-flex a-i-center gap-8\">\n {{ iconDefinition.label | translate }}\n @if (customCategoryLabels().has(iconDefinition.label)) {\n <span class=\"tag tag--info m-l-4 m-t-0 m-b-0\">{{\n 'Custom`badge indicating a user-added icon category`' | translate\n }}</span>\n }\n </div>\n\n @for (icon of iconDefinition.icons; track icon[0]) {\n @let isCustom = isCustomIcon(icon[0]);\n @let isOverridden = isIconOverridden(icon[0]);\n @let overriddenLabel = gettext('{{name}} (overridden)');\n @let deleteHint = 'Select a different icon before deleting this one.' | translate;\n <div class=\"d-contents\">\n <div\n class=\"dtm-icon-grid__item\"\n [class.dtm-icon-grid__item--selected]=\"selectedIcon && icon[0] === selectedIcon\"\n [class.dtm-icon-grid__item--overridden]=\"isOverridden\"\n >\n <button\n class=\"dtm-icon-grid__btn\"\n [title]=\"icon[0] | iconName\"\n [attr.aria-label]=\"\n isOverridden\n ? (overriddenLabel | translate: { name: icon[0] | iconName })\n : (icon[0] | iconName)\n \"\n [attr.aria-current]=\"selectedIcon && icon[0] === selectedIcon ? 'true' : null\"\n (click)=\"onIconClicked(icon)\"\n >\n <i\n class=\"icon-40 c8y-icon-duocolor\"\n [c8yIcon]=\"icon[0]\"\n aria-hidden=\"true\"\n ></i>\n @if (showIconClass) {\n <small\n class=\"d-block text-center text-break-word\"\n aria-hidden=\"true\"\n >\n {{ icon[0] | iconName }}\n </small>\n }\n </button>\n\n @if (isOverridden) {\n <span\n class=\"dtm-icon-grid__override-marker\"\n [tooltip]=\"'Overridden icon' | translate\"\n placement=\"top\"\n container=\"body\"\n aria-hidden=\"true\"\n [delay]=\"500\"\n >\n <i c8yIcon=\"replace\"></i>\n </span>\n }\n\n @if (isAdmin()) {\n <div\n class=\"dtm-icon-grid__actions\"\n [attr.aria-label]=\"'{{name}} actions' | translate: { name: icon[0] | iconName }\"\n role=\"group\"\n >\n @if (isCustom) {\n <button\n class=\"btn btn-dot\"\n [attr.aria-label]=\"\n 'Edit {{name}}' | translate: { name: icon[0] | iconName }\n \"\n [tooltip]=\"'Edit {{name}}' | translate: { name: icon[0] | iconName }\"\n placement=\"top\"\n container=\"body\"\n type=\"button\"\n [delay]=\"500\"\n (click)=\"$event.stopPropagation(); onEditIconClicked(icon[0])\"\n >\n <i\n c8yIcon=\"pencil\"\n aria-hidden=\"true\"\n ></i>\n </button>\n <span\n [tooltip]=\"icon[0] === selectedIcon ? deleteHint : null\"\n placement=\"top\"\n container=\"body\"\n [delay]=\"500\"\n >\n <button\n class=\"btn btn-dot btn-dot--danger m-0\"\n [class.disabled]=\"icon[0] === selectedIcon\"\n [attr.aria-label]=\"\n 'Delete {{name}}' | translate: { name: icon[0] | iconName }\n \"\n [tooltip]=\"'Delete {{name}}' | translate: { name: icon[0] | iconName }\"\n placement=\"top\"\n container=\"body\"\n [attr.aria-disabled]=\"icon[0] === selectedIcon ? 'true' : null\"\n [attr.aria-describedby]=\"\n icon[0] === selectedIcon ? 'delete-hint-' + icon[0] : null\n \"\n type=\"button\"\n [delay]=\"500\"\n (click)=\"\n $event.stopPropagation();\n icon[0] === selectedIcon || onDeleteIconClicked(icon[0])\n \"\n >\n <i\n c8yIcon=\"minus-circle\"\n aria-hidden=\"true\"\n ></i>\n </button>\n @if (icon[0] === selectedIcon) {\n <span\n class=\"sr-only\"\n [id]=\"'delete-hint-' + icon[0]\"\n >{{ deleteHint }}</span\n >\n }\n </span>\n } @else {\n @let overrideKey =\n isOverridden\n ? gettext('Replace override for {{name}}')\n : gettext('Override {{name}}');\n <button\n class=\"btn btn-dot\"\n [attr.aria-label]=\"overrideKey | translate: { name: icon[0] | iconName }\"\n [tooltip]=\"overrideKey | translate: { name: icon[0] | iconName }\"\n placement=\"top\"\n container=\"body\"\n type=\"button\"\n [delay]=\"500\"\n (click)=\"$event.stopPropagation(); onOverrideIconClicked(icon)\"\n >\n <i\n c8yIcon=\"replace\"\n aria-hidden=\"true\"\n ></i>\n </button>\n @if (isOverridden) {\n <button\n class=\"btn btn-dot btn-dot--danger\"\n [attr.aria-label]=\"\n 'Reset override for {{name}}' | translate: { name: icon[0] | iconName }\n \"\n [tooltip]=\"\n 'Reset override for {{name}}' | translate: { name: icon[0] | iconName }\n \"\n placement=\"top\"\n container=\"body\"\n type=\"button\"\n [delay]=\"500\"\n (click)=\"$event.stopPropagation(); onResetOverrideClicked(icon[0])\"\n >\n <i\n c8yIcon=\"undo\"\n aria-hidden=\"true\"\n ></i>\n </button>\n }\n }\n </div>\n }\n </div>\n </div>\n }\n </div>\n } @empty {\n <c8y-ui-empty-state\n class=\"grid__col--fullspan\"\n icon=\"search\"\n [title]=\"'No icons found' | translate\"\n [subtitle]=\"\n 'Try adjusting your search or filter to find what you\\'re looking for.' | translate\n \"\n [horizontal]=\"true\"\n ></c8y-ui-empty-state>\n }\n </section>\n }\n <!-- end @else iconsLoading -->\n</div>\n" }]
|
|
93
1560
|
}], ctorParameters: () => [], propDecorators: { iconCategoriesToExclude: [{
|
|
94
1561
|
type: Input
|
|
95
1562
|
}], showIconClass: [{
|
|
@@ -101,9 +1568,9 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.17", ngImpo
|
|
|
101
1568
|
}] } });
|
|
102
1569
|
|
|
103
1570
|
class IconSelectorModalComponent {
|
|
104
|
-
constructor(
|
|
105
|
-
this.bsModal = bsModal;
|
|
1571
|
+
constructor() {
|
|
106
1572
|
this.title = gettext('Select icon');
|
|
1573
|
+
this.selectionExplicitlySet = false;
|
|
107
1574
|
this.saveButtonLabel = gettext('Select');
|
|
108
1575
|
this.iconCategoriesToExclude = [];
|
|
109
1576
|
this.showIconClass = true;
|
|
@@ -111,30 +1578,40 @@ class IconSelectorModalComponent {
|
|
|
111
1578
|
this.save = resolve;
|
|
112
1579
|
this.cancel = reject;
|
|
113
1580
|
});
|
|
1581
|
+
this.bsModal = inject(BsModalRef);
|
|
1582
|
+
this.cdr = inject(ChangeDetectorRef);
|
|
114
1583
|
}
|
|
115
1584
|
close() {
|
|
116
1585
|
this.bsModal.hide();
|
|
117
1586
|
this.cancel();
|
|
118
1587
|
}
|
|
119
1588
|
saveChanges() {
|
|
1589
|
+
if (!this.selectionExplicitlySet)
|
|
1590
|
+
return;
|
|
120
1591
|
this.bsModal.hide();
|
|
121
1592
|
this.save(this.currentSelection);
|
|
122
1593
|
}
|
|
123
1594
|
selectionChange(newSelection) {
|
|
124
1595
|
this.currentSelection = newSelection;
|
|
1596
|
+
this.selectionExplicitlySet = true;
|
|
1597
|
+
this.cdr.markForCheck();
|
|
125
1598
|
}
|
|
126
|
-
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: IconSelectorModalComponent, deps: [
|
|
127
|
-
static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "21.2.17", type: IconSelectorModalComponent, isStandalone: true, selector: "c8y-icon-selector-modal", ngImport: i0, template: "<
|
|
1599
|
+
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: IconSelectorModalComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
|
|
1600
|
+
static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "21.2.17", type: IconSelectorModalComponent, isStandalone: true, selector: "c8y-icon-selector-modal", ngImport: i0, template: "<c8y-modal\n [title]=\"title | translate\"\n (onDismiss)=\"close()\"\n (onClose)=\"saveChanges()\"\n headerClasses=\"text-center\"\n [disabled]=\"!selectionExplicitlySet\"\n [labels]=\"{ cancel: 'Cancel', ok: saveButtonLabel | translate }\"\n>\n <c8y-icon-selector\n [selectedIcon]=\"currentSelection\"\n [iconCategoriesToExclude]=\"iconCategoriesToExclude\"\n [showIconClass]=\"showIconClass\"\n (onSelect)=\"selectionChange($event)\"\n ></c8y-icon-selector>\n</c8y-modal>\n", dependencies: [{ kind: "component", type: IconSelectorComponent, selector: "c8y-icon-selector", inputs: ["iconCategoriesToExclude", "showIconClass", "selectedIcon"], outputs: ["onSelect"] }, { kind: "component", type: ModalComponent, selector: "c8y-modal", inputs: ["disabled", "close", "dismiss", "title", "body", "customFooter", "headerClasses", "labels"], outputs: ["onDismiss", "onClose"] }, { kind: "pipe", type: C8yTranslatePipe, name: "translate" }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); }
|
|
128
1601
|
}
|
|
129
1602
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: IconSelectorModalComponent, decorators: [{
|
|
130
1603
|
type: Component,
|
|
131
|
-
args: [{ selector: 'c8y-icon-selector-modal', imports: [IconSelectorComponent, C8yTranslatePipe], template: "<
|
|
132
|
-
}]
|
|
1604
|
+
args: [{ selector: 'c8y-icon-selector-modal', changeDetection: ChangeDetectionStrategy.OnPush, imports: [IconSelectorComponent, C8yTranslatePipe, ModalComponent], template: "<c8y-modal\n [title]=\"title | translate\"\n (onDismiss)=\"close()\"\n (onClose)=\"saveChanges()\"\n headerClasses=\"text-center\"\n [disabled]=\"!selectionExplicitlySet\"\n [labels]=\"{ cancel: 'Cancel', ok: saveButtonLabel | translate }\"\n>\n <c8y-icon-selector\n [selectedIcon]=\"currentSelection\"\n [iconCategoriesToExclude]=\"iconCategoriesToExclude\"\n [showIconClass]=\"showIconClass\"\n (onSelect)=\"selectionChange($event)\"\n ></c8y-icon-selector>\n</c8y-modal>\n" }]
|
|
1605
|
+
}] });
|
|
133
1606
|
|
|
134
1607
|
class IconSelectorService {
|
|
135
1608
|
constructor(modal) {
|
|
136
1609
|
this.modal = modal;
|
|
137
1610
|
}
|
|
1611
|
+
/**
|
|
1612
|
+
* Opens the icon selector modal. Resolves with the selected icon CSS class string.
|
|
1613
|
+
* Rejects when the user dismisses without selecting.
|
|
1614
|
+
*/
|
|
138
1615
|
selectIcon(initialState = {}) {
|
|
139
1616
|
const modal = this.modal.show(IconSelectorModalComponent, {
|
|
140
1617
|
ignoreBackdropClick: true,
|
|
@@ -170,16 +1647,19 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.17", ngImpo
|
|
|
170
1647
|
</c8y-icon-selector-wrapper>
|
|
171
1648
|
* ```
|
|
172
1649
|
*/
|
|
1650
|
+
// OnPush is required here: ControlValueAccessor's writeValue() is called by Angular forms outside
|
|
1651
|
+
// the component's CD cycle, so markForCheck() must be used to schedule a check when the value changes.
|
|
173
1652
|
class IconSelectorWrapperComponent {
|
|
174
|
-
constructor(
|
|
175
|
-
this.iconSelector = iconSelector;
|
|
176
|
-
this.gainsightService = gainsightService;
|
|
1653
|
+
constructor() {
|
|
177
1654
|
this.canRemoveIcon = false;
|
|
178
1655
|
/**
|
|
179
1656
|
* The displayed icon size, the value has to be multiple of 8.
|
|
180
1657
|
*/
|
|
181
1658
|
this.iconSize = 32;
|
|
182
1659
|
this.onSelect = new EventEmitter();
|
|
1660
|
+
this.iconSelector = inject(IconSelectorService);
|
|
1661
|
+
this.gainsightService = inject(GainsightService);
|
|
1662
|
+
this.cdr = inject(ChangeDetectorRef);
|
|
183
1663
|
/**
|
|
184
1664
|
* @ignore
|
|
185
1665
|
*/
|
|
@@ -192,6 +1672,7 @@ class IconSelectorWrapperComponent {
|
|
|
192
1672
|
*/
|
|
193
1673
|
writeValue(icon) {
|
|
194
1674
|
this.selectedIcon = icon;
|
|
1675
|
+
this.cdr.markForCheck();
|
|
195
1676
|
}
|
|
196
1677
|
/**
|
|
197
1678
|
* @ignore
|
|
@@ -212,44 +1693,45 @@ class IconSelectorWrapperComponent {
|
|
|
212
1693
|
}
|
|
213
1694
|
async openIconSelector() {
|
|
214
1695
|
try {
|
|
215
|
-
this.gainsightService.triggerEvent(PRODUCT_EXPERIENCE_ICON_SELECTOR.EVENTS.ICON_SELECTOR, {
|
|
216
|
-
component: PRODUCT_EXPERIENCE_ICON_SELECTOR.COMPONENTS.ICON_SELECTOR_WRAPPER_COMPONENT,
|
|
217
|
-
action: PRODUCT_EXPERIENCE_ICON_SELECTOR.ACTIONS.OPEN_ICON_SELECTOR
|
|
1696
|
+
this.gainsightService.triggerEvent(PRODUCT_EXPERIENCE_ICON_SELECTOR$1.EVENTS.ICON_SELECTOR, {
|
|
1697
|
+
component: PRODUCT_EXPERIENCE_ICON_SELECTOR$1.COMPONENTS.ICON_SELECTOR_WRAPPER_COMPONENT,
|
|
1698
|
+
action: PRODUCT_EXPERIENCE_ICON_SELECTOR$1.ACTIONS.OPEN_ICON_SELECTOR
|
|
218
1699
|
});
|
|
219
1700
|
const icon = await this.iconSelector.selectIcon({
|
|
220
1701
|
currentSelection: this.selectedIcon
|
|
221
1702
|
});
|
|
222
1703
|
this.onChange(icon);
|
|
223
1704
|
this.selectedIcon = icon;
|
|
1705
|
+
this.cdr.markForCheck();
|
|
224
1706
|
this.onSelect.emit(icon);
|
|
225
|
-
this.gainsightService.triggerEvent(PRODUCT_EXPERIENCE_ICON_SELECTOR.EVENTS.ICON_SELECTOR, {
|
|
226
|
-
component: PRODUCT_EXPERIENCE_ICON_SELECTOR.COMPONENTS.ICON_SELECTOR_WRAPPER_COMPONENT,
|
|
227
|
-
action: PRODUCT_EXPERIENCE_ICON_SELECTOR.ACTIONS.ICON_SELECTED
|
|
1707
|
+
this.gainsightService.triggerEvent(PRODUCT_EXPERIENCE_ICON_SELECTOR$1.EVENTS.ICON_SELECTOR, {
|
|
1708
|
+
component: PRODUCT_EXPERIENCE_ICON_SELECTOR$1.COMPONENTS.ICON_SELECTOR_WRAPPER_COMPONENT,
|
|
1709
|
+
action: PRODUCT_EXPERIENCE_ICON_SELECTOR$1.ACTIONS.ICON_SELECTED
|
|
228
1710
|
});
|
|
229
1711
|
}
|
|
230
1712
|
catch {
|
|
231
1713
|
// nothing to do
|
|
232
1714
|
}
|
|
233
1715
|
}
|
|
234
|
-
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: IconSelectorWrapperComponent, deps: [
|
|
1716
|
+
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: IconSelectorWrapperComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
|
|
235
1717
|
static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.17", type: IconSelectorWrapperComponent, isStandalone: true, selector: "c8y-icon-selector-wrapper", inputs: { canRemoveIcon: "canRemoveIcon", selectedIcon: "selectedIcon", iconSize: "iconSize" }, outputs: { onSelect: "onSelect" }, providers: [
|
|
236
1718
|
{
|
|
237
1719
|
provide: NG_VALUE_ACCESSOR,
|
|
238
1720
|
useExisting: forwardRef(() => IconSelectorWrapperComponent),
|
|
239
1721
|
multi: true
|
|
240
1722
|
}
|
|
241
|
-
], ngImport: i0, template: "<div class=\"d-flex a-i-center j-c-center p-relative\">\n @if (selectedIcon) {\n <div class=\"icon-{{ iconSize }} text-center fit-w fit-h\">\n <i\n class=\"c8y-icon-duocolor\"\n [c8yIcon]=\"selectedIcon\"\n ></i>\n </div>\n }\n\n @if (!selectedIcon) {\n <div\n class=\"icon-{{ iconSize }} text-muted text-center a-s-stretch fit-w\"\n style=\"border: 2px dashed var(--c8y-root-component-border-color)\"\n >\n <span class=\"d-flex a-i-center j-c-center text-12 fit-h\">\n <em>{{ 'Auto' | translate }}</em>\n </span>\n </div>\n }\n <div class=\"showOnHover d-flex j-c-center a-i-center p-absolute fit-h fit-w\">\n @if (!selectedIcon) {\n <button\n class=\"btn btn-clean btn-icon btn-sm m-0\"\n title=\"{{ 'Select icon' | translate }}\"\n [attr.aria-label]=\"'Select icon' | translate\"\n type=\"button\"\n (click)=\"openIconSelector()\"\n >\n {{ 'Select' | translate }}\n </button>\n }\n @if (selectedIcon) {\n <button\n class=\"btn-dot btn-icon m-0 fit-h\"\n [attr.aria-label]=\"'Change icon' | translate\"\n tooltip=\"{{ 'Change icon' | translate }}\"\n placement=\"top\"\n container=\"body\"\n type=\"button\"\n [delay]=\"500\"\n (click)=\"openIconSelector()\"\n >\n <i
|
|
1723
|
+
], ngImport: i0, template: "<div class=\"d-flex a-i-center j-c-center p-relative\">\n @if (selectedIcon) {\n <div class=\"icon-{{ iconSize }} text-center fit-w fit-h\">\n <i\n class=\"c8y-icon-duocolor\"\n [c8yIcon]=\"selectedIcon\"\n aria-hidden=\"true\"\n ></i>\n </div>\n }\n\n @if (!selectedIcon) {\n <div\n class=\"icon-{{ iconSize }} text-muted text-center a-s-stretch fit-w\"\n style=\"border: 2px dashed var(--c8y-root-component-border-color)\"\n >\n <span class=\"d-flex a-i-center j-c-center text-12 fit-h\">\n <em>{{ 'Auto`icon-selector: automatic / default icon`' | translate }}</em>\n </span>\n </div>\n }\n <div class=\"showOnHover d-flex j-c-center a-i-center p-absolute fit-h fit-w\">\n @if (!selectedIcon) {\n <button\n class=\"btn btn-clean btn-icon btn-sm m-0\"\n title=\"{{ 'Select icon' | translate }}\"\n [attr.aria-label]=\"'Select icon' | translate\"\n type=\"button\"\n (click)=\"openIconSelector()\"\n >\n {{ 'Select' | translate }}\n </button>\n }\n @if (selectedIcon) {\n <button\n class=\"btn-dot btn-icon m-0 fit-h\"\n [attr.aria-label]=\"'Change icon' | translate\"\n tooltip=\"{{ 'Change icon' | translate }}\"\n placement=\"top\"\n container=\"body\"\n type=\"button\"\n [delay]=\"500\"\n (click)=\"openIconSelector()\"\n >\n <i\n [c8yIcon]=\"'replace'\"\n aria-hidden=\"true\"\n ></i>\n </button>\n }\n @if (selectedIcon && canRemoveIcon) {\n <button\n class=\"btn-dot btn-dot--danger btn-icon fit-h m-0\"\n [attr.aria-label]=\"'Remove icon and use default icon' | translate\"\n tooltip=\"{{ 'Remove icon and use default icon' | translate }}\"\n placement=\"top\"\n container=\"body\"\n type=\"button\"\n [delay]=\"500\"\n (click)=\"removeIcon()\"\n >\n <i\n [c8yIcon]=\"'trash'\"\n aria-hidden=\"true\"\n ></i>\n </button>\n }\n </div>\n</div>\n", dependencies: [{ kind: "directive", type: IconDirective, selector: "[c8yIcon]", inputs: ["c8yIcon"] }, { kind: "directive", type: TooltipDirective, selector: "[tooltip], [tooltipHtml]", inputs: ["adaptivePosition", "tooltip", "placement", "triggers", "container", "containerClass", "boundariesElement", "isOpen", "isDisabled", "delay", "tooltipHtml", "tooltipPlacement", "tooltipIsOpen", "tooltipEnable", "tooltipAppendToBody", "tooltipAnimation", "tooltipClass", "tooltipContext", "tooltipPopupDelay", "tooltipFadeDuration", "tooltipTrigger"], outputs: ["tooltipChange", "onShown", "onHidden", "tooltipStateChanged"], exportAs: ["bs-tooltip"] }, { kind: "pipe", type: C8yTranslatePipe, name: "translate" }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); }
|
|
242
1724
|
}
|
|
243
1725
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: IconSelectorWrapperComponent, decorators: [{
|
|
244
1726
|
type: Component,
|
|
245
|
-
args: [{ selector: 'c8y-icon-selector-wrapper', providers: [
|
|
1727
|
+
args: [{ selector: 'c8y-icon-selector-wrapper', changeDetection: ChangeDetectionStrategy.OnPush, providers: [
|
|
246
1728
|
{
|
|
247
1729
|
provide: NG_VALUE_ACCESSOR,
|
|
248
1730
|
useExisting: forwardRef(() => IconSelectorWrapperComponent),
|
|
249
1731
|
multi: true
|
|
250
1732
|
}
|
|
251
|
-
], imports: [
|
|
252
|
-
}],
|
|
1733
|
+
], imports: [IconDirective, TooltipDirective, C8yTranslatePipe], template: "<div class=\"d-flex a-i-center j-c-center p-relative\">\n @if (selectedIcon) {\n <div class=\"icon-{{ iconSize }} text-center fit-w fit-h\">\n <i\n class=\"c8y-icon-duocolor\"\n [c8yIcon]=\"selectedIcon\"\n aria-hidden=\"true\"\n ></i>\n </div>\n }\n\n @if (!selectedIcon) {\n <div\n class=\"icon-{{ iconSize }} text-muted text-center a-s-stretch fit-w\"\n style=\"border: 2px dashed var(--c8y-root-component-border-color)\"\n >\n <span class=\"d-flex a-i-center j-c-center text-12 fit-h\">\n <em>{{ 'Auto`icon-selector: automatic / default icon`' | translate }}</em>\n </span>\n </div>\n }\n <div class=\"showOnHover d-flex j-c-center a-i-center p-absolute fit-h fit-w\">\n @if (!selectedIcon) {\n <button\n class=\"btn btn-clean btn-icon btn-sm m-0\"\n title=\"{{ 'Select icon' | translate }}\"\n [attr.aria-label]=\"'Select icon' | translate\"\n type=\"button\"\n (click)=\"openIconSelector()\"\n >\n {{ 'Select' | translate }}\n </button>\n }\n @if (selectedIcon) {\n <button\n class=\"btn-dot btn-icon m-0 fit-h\"\n [attr.aria-label]=\"'Change icon' | translate\"\n tooltip=\"{{ 'Change icon' | translate }}\"\n placement=\"top\"\n container=\"body\"\n type=\"button\"\n [delay]=\"500\"\n (click)=\"openIconSelector()\"\n >\n <i\n [c8yIcon]=\"'replace'\"\n aria-hidden=\"true\"\n ></i>\n </button>\n }\n @if (selectedIcon && canRemoveIcon) {\n <button\n class=\"btn-dot btn-dot--danger btn-icon fit-h m-0\"\n [attr.aria-label]=\"'Remove icon and use default icon' | translate\"\n tooltip=\"{{ 'Remove icon and use default icon' | translate }}\"\n placement=\"top\"\n container=\"body\"\n type=\"button\"\n [delay]=\"500\"\n (click)=\"removeIcon()\"\n >\n <i\n [c8yIcon]=\"'trash'\"\n aria-hidden=\"true\"\n ></i>\n </button>\n }\n </div>\n</div>\n" }]
|
|
1734
|
+
}], propDecorators: { canRemoveIcon: [{
|
|
253
1735
|
type: Input
|
|
254
1736
|
}], selectedIcon: [{
|
|
255
1737
|
type: Input
|
|
@@ -261,8 +1743,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.17", ngImpo
|
|
|
261
1743
|
|
|
262
1744
|
class IconSelectorModule {
|
|
263
1745
|
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: IconSelectorModule, deps: [], target: i0.ɵɵFactoryTarget.NgModule }); }
|
|
264
|
-
static { this.ɵmod = i0.ɵɵngDeclareNgModule({ minVersion: "14.0.0", version: "21.2.17", ngImport: i0, type: IconSelectorModule, imports: [
|
|
265
|
-
FormsModule,
|
|
1746
|
+
static { this.ɵmod = i0.ɵɵngDeclareNgModule({ minVersion: "14.0.0", version: "21.2.17", ngImport: i0, type: IconSelectorModule, imports: [FormsModule,
|
|
266
1747
|
DropAreaModule,
|
|
267
1748
|
TooltipModule,
|
|
268
1749
|
IconSelectorComponent,
|
|
@@ -272,8 +1753,7 @@ class IconSelectorModule {
|
|
|
272
1753
|
IconSelectorModalComponent,
|
|
273
1754
|
IconNamePipe,
|
|
274
1755
|
IconSelectorWrapperComponent] }); }
|
|
275
|
-
static { this.ɵinj = i0.ɵɵngDeclareInjector({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: IconSelectorModule, imports: [
|
|
276
|
-
FormsModule,
|
|
1756
|
+
static { this.ɵinj = i0.ɵɵngDeclareInjector({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: IconSelectorModule, imports: [FormsModule,
|
|
277
1757
|
DropAreaModule,
|
|
278
1758
|
TooltipModule,
|
|
279
1759
|
IconSelectorComponent,
|
|
@@ -283,7 +1763,6 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.17", ngImpo
|
|
|
283
1763
|
type: NgModule,
|
|
284
1764
|
args: [{
|
|
285
1765
|
imports: [
|
|
286
|
-
CommonModule,
|
|
287
1766
|
FormsModule,
|
|
288
1767
|
DropAreaModule,
|
|
289
1768
|
TooltipModule,
|
|
@@ -305,5 +1784,5 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.17", ngImpo
|
|
|
305
1784
|
* Generated bundle index. Do not edit.
|
|
306
1785
|
*/
|
|
307
1786
|
|
|
308
|
-
export { IconNamePipe, IconSelectorComponent, IconSelectorModalComponent, IconSelectorModule, IconSelectorService, IconSelectorWrapperComponent };
|
|
1787
|
+
export { AddCustomIconModalService, CustomIconRegistryService, CustomIconStorageService, IconNamePipe, IconSelectorComponent, IconSelectorModalComponent, IconSelectorModule, IconSelectorService, IconSelectorWrapperComponent, ImportCustomIconsModalService, PRODUCT_EXPERIENCE_ICON_SELECTOR, SvgSanitizerService };
|
|
309
1788
|
//# sourceMappingURL=c8y-ngx-components-icon-selector.mjs.map
|