@c8y/ngx-components 1024.2.4 → 1024.8.3

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.
Files changed (71) hide show
  1. package/fesm2022/{c8y-ngx-components-asset-property-grid.component-DhfJagxU.mjs → c8y-ngx-components-asset-property-grid.component-Ddva2QJW.mjs} +35 -19
  2. package/fesm2022/c8y-ngx-components-asset-property-grid.component-Ddva2QJW.mjs.map +1 -0
  3. package/fesm2022/c8y-ngx-components-bookmarks.mjs +33 -23
  4. package/fesm2022/c8y-ngx-components-bookmarks.mjs.map +1 -1
  5. package/fesm2022/c8y-ngx-components-context-dashboard.mjs +30 -16
  6. package/fesm2022/c8y-ngx-components-context-dashboard.mjs.map +1 -1
  7. package/fesm2022/c8y-ngx-components-datapoint-explorer-view.mjs +3 -2
  8. package/fesm2022/c8y-ngx-components-datapoint-explorer-view.mjs.map +1 -1
  9. package/fesm2022/c8y-ngx-components-datapoints-export-selector.mjs +2 -2
  10. package/fesm2022/c8y-ngx-components-datapoints-export-selector.mjs.map +1 -1
  11. package/fesm2022/c8y-ngx-components-device-profile.mjs +27 -9
  12. package/fesm2022/c8y-ngx-components-device-profile.mjs.map +1 -1
  13. package/fesm2022/c8y-ngx-components-icon-selector.mjs +1538 -59
  14. package/fesm2022/c8y-ngx-components-icon-selector.mjs.map +1 -1
  15. package/fesm2022/c8y-ngx-components-protocol-opcua.mjs +9 -11
  16. package/fesm2022/c8y-ngx-components-protocol-opcua.mjs.map +1 -1
  17. package/fesm2022/c8y-ngx-components-remote-access-data.mjs +52 -2
  18. package/fesm2022/c8y-ngx-components-remote-access-data.mjs.map +1 -1
  19. package/fesm2022/c8y-ngx-components-remote-access-ssh-remote-access-ssh-endpoint-modal.mjs +17 -3
  20. package/fesm2022/c8y-ngx-components-remote-access-ssh-remote-access-ssh-endpoint-modal.mjs.map +1 -1
  21. package/fesm2022/c8y-ngx-components-remote-access-terminal-viewer.mjs +184 -41
  22. package/fesm2022/c8y-ngx-components-remote-access-terminal-viewer.mjs.map +1 -1
  23. package/fesm2022/c8y-ngx-components-repository-configuration.mjs +94 -49
  24. package/fesm2022/c8y-ngx-components-repository-configuration.mjs.map +1 -1
  25. package/fesm2022/c8y-ngx-components-repository-firmware.mjs +5 -4
  26. package/fesm2022/c8y-ngx-components-repository-firmware.mjs.map +1 -1
  27. package/fesm2022/c8y-ngx-components-repository-software.mjs +10 -4
  28. package/fesm2022/c8y-ngx-components-repository-software.mjs.map +1 -1
  29. package/fesm2022/c8y-ngx-components-trusted-certificates.mjs +5 -4
  30. package/fesm2022/c8y-ngx-components-trusted-certificates.mjs.map +1 -1
  31. package/fesm2022/c8y-ngx-components-widgets-implementations-asset-table.mjs +6 -5
  32. package/fesm2022/c8y-ngx-components-widgets-implementations-asset-table.mjs.map +1 -1
  33. package/fesm2022/c8y-ngx-components.mjs +10 -5
  34. package/fesm2022/c8y-ngx-components.mjs.map +1 -1
  35. package/locales/de.po +327 -8
  36. package/locales/es.po +327 -8
  37. package/locales/fr.po +327 -8
  38. package/locales/ja_JP.po +325 -9
  39. package/locales/ko.po +327 -8
  40. package/locales/locales.pot +326 -7
  41. package/locales/nl.po +327 -8
  42. package/locales/pl.po +327 -8
  43. package/locales/pt_BR.po +327 -8
  44. package/locales/zh_CN.po +327 -8
  45. package/locales/zh_TW.po +327 -8
  46. package/package.json +1 -1
  47. package/types/c8y-ngx-components-bookmarks.d.ts +7 -7
  48. package/types/c8y-ngx-components-bookmarks.d.ts.map +1 -1
  49. package/types/c8y-ngx-components-context-dashboard.d.ts +7 -3
  50. package/types/c8y-ngx-components-context-dashboard.d.ts.map +1 -1
  51. package/types/c8y-ngx-components-device-profile.d.ts +5 -2
  52. package/types/c8y-ngx-components-device-profile.d.ts.map +1 -1
  53. package/types/c8y-ngx-components-icon-selector.d.ts +286 -32
  54. package/types/c8y-ngx-components-icon-selector.d.ts.map +1 -1
  55. package/types/c8y-ngx-components-protocol-opcua.d.ts.map +1 -1
  56. package/types/c8y-ngx-components-remote-access-data.d.ts +27 -1
  57. package/types/c8y-ngx-components-remote-access-data.d.ts.map +1 -1
  58. package/types/c8y-ngx-components-remote-access-ssh-remote-access-ssh-endpoint-modal.d.ts +10 -3
  59. package/types/c8y-ngx-components-remote-access-ssh-remote-access-ssh-endpoint-modal.d.ts.map +1 -1
  60. package/types/c8y-ngx-components-remote-access-terminal-viewer.d.ts +20 -2
  61. package/types/c8y-ngx-components-remote-access-terminal-viewer.d.ts.map +1 -1
  62. package/types/c8y-ngx-components-repository-configuration.d.ts +20 -10
  63. package/types/c8y-ngx-components-repository-configuration.d.ts.map +1 -1
  64. package/types/c8y-ngx-components-repository-firmware.d.ts.map +1 -1
  65. package/types/c8y-ngx-components-repository-software.d.ts +2 -0
  66. package/types/c8y-ngx-components-repository-software.d.ts.map +1 -1
  67. package/types/c8y-ngx-components-trusted-certificates.d.ts.map +1 -1
  68. package/types/c8y-ngx-components-widgets-implementations-asset-table.d.ts.map +1 -1
  69. package/types/c8y-ngx-components.d.ts +5 -1
  70. package/types/c8y-ngx-components.d.ts.map +1 -1
  71. 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, Component, Injectable, forwardRef, NgModule } from '@angular/core';
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 * as i2 from '@c8y/ngx-components';
6
- import { IconDirective, C8yTranslateDirective, EmptyStateComponent, C8yTranslatePipe, CommonModule, DropAreaModule } from '@c8y/ngx-components';
7
- import { TooltipDirective, TooltipModule } from 'ngx-bootstrap/tooltip';
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 { BehaviorSubject, defer, from, combineLatest } from 'rxjs';
11
- import { map, shareReplay, debounceTime, startWith } from 'rxjs/operators';
12
- import { clone } from 'lodash-es';
13
- import { NgClass, AsyncPipe, NgIf } from '@angular/common';
14
- import { PRODUCT_EXPERIENCE_ICON_SELECTOR } from '@c8y/ngx-components/icon-selector/model';
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.icons$ = defer(() => from(this.loadIconDefinitions())).pipe(map(icons => icons.filter(tmp => !this.iconCategoriesToExclude.includes(tmp.label))), shareReplay({ refCount: true, bufferSize: 1 }));
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(tmp => tmp.label)]));
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 loadIconDefinitions() {
48
- const { allIcons } = await import('@c8y/ngx-components/icon-selector/icons');
49
- return allIcons;
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
- filterIconsByCategoryAndSearchTerm(iconCategories, selectedCategory, searchTerm) {
52
- if (selectedCategory !== allIconCategory) {
53
- iconCategories = iconCategories.filter(category => category.label === selectedCategory);
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
- if (searchTerm) {
56
- const lowerCaseSearchTerm = searchTerm.toLowerCase();
57
- const matchingCategories = new Array();
58
- for (const category of iconCategories) {
59
- const matchingIcons = category.icons.filter(iconClasses => iconClasses.some(iconClass => iconClass.includes(lowerCaseSearchTerm)));
60
- if (matchingIcons.length) {
61
- matchingCategories.push({ ...clone(category), icons: matchingIcons });
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
- ], 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" }]
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(bsModal) {
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: [{ token: i1$1.BsModalRef }], target: i0.ɵɵFactoryTarget.Component }); }
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: "<div class=\"modal-header\">\n <div id=\"modal-title\" class=\"modal-title h4\">{{ title | translate }}</div>\n</div>\n<div id=\"modal-body\">\n <c8y-icon-selector\n (onSelect)=\"selectionChange($event)\"\n [selectedIcon]=\"currentSelection\"\n [iconCategoriesToExclude]=\"iconCategoriesToExclude\"\n [showIconClass]=\"showIconClass\"\n ></c8y-icon-selector>\n</div>\n<div class=\"modal-footer separator text-center\">\n <button class=\"btn btn-default\" (click)=\"close()\">\n {{ 'Cancel' | translate }}\n </button>\n <button class=\"btn btn-primary\" [disabled]=\"!currentSelection\" (click)=\"saveChanges()\">\n {{ saveButtonLabel | translate }}\n </button>\n</div>\n", dependencies: [{ kind: "component", type: IconSelectorComponent, selector: "c8y-icon-selector", inputs: ["iconCategoriesToExclude", "showIconClass", "selectedIcon"], outputs: ["onSelect"] }, { kind: "pipe", type: C8yTranslatePipe, name: "translate" }] }); }
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: "<div class=\"modal-header\">\n <div id=\"modal-title\" class=\"modal-title h4\">{{ title | translate }}</div>\n</div>\n<div id=\"modal-body\">\n <c8y-icon-selector\n (onSelect)=\"selectionChange($event)\"\n [selectedIcon]=\"currentSelection\"\n [iconCategoriesToExclude]=\"iconCategoriesToExclude\"\n [showIconClass]=\"showIconClass\"\n ></c8y-icon-selector>\n</div>\n<div class=\"modal-footer separator text-center\">\n <button class=\"btn btn-default\" (click)=\"close()\">\n {{ 'Cancel' | translate }}\n </button>\n <button class=\"btn btn-primary\" [disabled]=\"!currentSelection\" (click)=\"saveChanges()\">\n {{ saveButtonLabel | translate }}\n </button>\n</div>\n" }]
132
- }], ctorParameters: () => [{ type: i1$1.BsModalRef }] });
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(iconSelector, gainsightService) {
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: [{ token: IconSelectorService }, { token: i2.GainsightService }], target: i0.ɵɵFactoryTarget.Component }); }
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 [c8yIcon]=\"'replace'\"></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 [c8yIcon]=\"'minus-circle'\"></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" }] }); }
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: [NgIf, 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 ></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 [c8yIcon]=\"'replace'\"></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 [c8yIcon]=\"'minus-circle'\"></i>\n </button>\n }\n </div>\n</div>\n" }]
252
- }], ctorParameters: () => [{ type: IconSelectorService }, { type: i2.GainsightService }], propDecorators: { canRemoveIcon: [{
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: [CommonModule,
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: [CommonModule,
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