Skip to content

Commit ab54d61

Browse files
authored
Merge pull request #81 from shiftcode/#80-expose-svg-base-class
feat(svg): expose `SvgBaseDirective` to allow custom icon component implementations
2 parents b235d40 + db4a30d commit ab54d61

8 files changed

Lines changed: 288 additions & 54 deletions

File tree

apps/styleguide/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@shiftcode/styleguide",
3-
"version": "15.0.0",
3+
"version": "15.1.0-pr80.1",
44
"private": true,
55
"type": "module",
66
"scripts": {

lerna.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"$schema": "node_modules/lerna/schemas/lerna-schema.json",
33
"useNx": false,
44
"packages": ["libs/*", "apps/*"],
5-
"version": "15.0.0",
5+
"version": "15.1.0-pr80.1",
66
"command": {
77
"version": {
88
"allowBranch": "*",

libs/components/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@shiftcode/ngx-components",
3-
"version": "15.0.0",
3+
"version": "15.1.0-pr80.1",
44
"repository": "https://github.com/shiftcode/sc-ng-commons-public",
55
"license": "MIT",
66
"author": "shiftcode GmbH <team@shiftcode.ch>",
Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
import { HttpErrorResponse } from '@angular/common/http'
2+
import { Component, inject, signal } from '@angular/core'
3+
import { TestBed } from '@angular/core/testing'
4+
import { Logger } from '@shiftcode/logger'
5+
import { describe, expect, test, vi } from 'vitest'
6+
7+
import { SvgBaseDirective } from './svg-base.directive'
8+
import { SvgRegistry } from './svg-registry.service'
9+
10+
const MOCK_SVG_URL = '/assets/test.svg'
11+
12+
@Component({ selector: 'sc-test-svg-base', template: '', standalone: true })
13+
class TestSvgComponent extends SvgBaseDirective {
14+
readonly data = signal<{ url: string; attrs?: Record<string, string> }>({ url: MOCK_SVG_URL })
15+
protected readonly logger = inject(Logger)
16+
}
17+
18+
function createSvgElement(): SVGElement {
19+
const div = document.createElement('div')
20+
div.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" data-ts="${Date.now()}"><path d="M0 0"/></svg>`
21+
return div.querySelector('svg') as SVGElement
22+
}
23+
24+
function setup() {
25+
const mockLogger = {
26+
debug: vi.fn(),
27+
info: vi.fn(),
28+
warn: vi.fn(),
29+
error: vi.fn(),
30+
} as unknown as Logger
31+
32+
const mockRegistry = { getFromUrl: vi.fn<() => Promise<SVGElement>>() }
33+
34+
TestBed.configureTestingModule({
35+
providers: [
36+
{ provide: Logger, useValue: mockLogger },
37+
{ provide: SvgRegistry, useValue: mockRegistry },
38+
],
39+
})
40+
41+
return { mockLogger, mockRegistry }
42+
}
43+
44+
async function flushMicrotasks(count = 2): Promise<void> {
45+
for (let i = 0; i < count; i++) {
46+
await Promise.resolve()
47+
}
48+
}
49+
50+
async function renderComponent(url: string = MOCK_SVG_URL, attrs?: Record<string, string>) {
51+
const fixture = TestBed.createComponent(TestSvgComponent)
52+
53+
fixture.componentInstance.data.set({ url, attrs })
54+
TestBed.tick()
55+
await flushMicrotasks()
56+
57+
return {
58+
fixture,
59+
hostElement: fixture.nativeElement as HTMLElement,
60+
}
61+
}
62+
63+
describe('SvgBaseDirective', () => {
64+
test('fetches SVG from the registry and inserts it into the DOM', async () => {
65+
const { mockRegistry } = setup()
66+
const svg = createSvgElement()
67+
mockRegistry.getFromUrl.mockResolvedValue(svg)
68+
69+
const { hostElement } = await renderComponent()
70+
71+
expect(mockRegistry.getFromUrl).toHaveBeenCalledWith(MOCK_SVG_URL)
72+
expect(hostElement.contains(svg)).toBe(true)
73+
})
74+
75+
test('applies provided attrs to the SVG element', async () => {
76+
const { mockRegistry } = setup()
77+
const svg = createSvgElement()
78+
mockRegistry.getFromUrl.mockResolvedValue(svg)
79+
80+
const { hostElement } = await renderComponent(MOCK_SVG_URL, { 'data-test': 'true', class: 'my-icon' })
81+
82+
expect(svg.getAttribute('data-test')).toBe('true')
83+
expect(svg.getAttribute('class')).toBe('my-icon')
84+
expect(hostElement.contains(svg)).toBe(true)
85+
})
86+
87+
test('does not modify SVG attributes when no attrs are provided', async () => {
88+
const { mockRegistry } = setup()
89+
const svg = createSvgElement()
90+
const originalAttributes = Array.from(svg.attributes).map((a) => ({ name: a.name, value: a.value }))
91+
mockRegistry.getFromUrl.mockResolvedValue(svg)
92+
93+
const { hostElement } = await renderComponent()
94+
95+
expect(Array.from(svg.attributes).map((a) => ({ name: a.name, value: a.value }))).toEqual(originalAttributes)
96+
expect(hostElement.contains(svg)).toBe(true)
97+
})
98+
99+
test('allows to update the SVG when the url signal changes', async () => {
100+
const { mockRegistry } = setup()
101+
const svgFirst = createSvgElement()
102+
const svgSecond = createSvgElement()
103+
104+
mockRegistry.getFromUrl.mockResolvedValueOnce(svgFirst).mockResolvedValueOnce(svgSecond)
105+
106+
const { fixture, hostElement } = await renderComponent()
107+
108+
expect(hostElement.contains(svgFirst)).toBe(true)
109+
110+
fixture.componentInstance.data.set({ url: '/assets/other.svg' })
111+
TestBed.tick()
112+
await flushMicrotasks()
113+
114+
expect(hostElement.contains(svgSecond)).toBe(true)
115+
})
116+
117+
test('clears existing DOM content before inserting the new SVG on signal change', async () => {
118+
const { mockRegistry } = setup()
119+
const svgFirst = createSvgElement()
120+
const svgSecond = createSvgElement()
121+
mockRegistry.getFromUrl.mockResolvedValueOnce(svgFirst).mockResolvedValueOnce(svgSecond)
122+
123+
const { fixture, hostElement } = await renderComponent()
124+
125+
expect(hostElement.contains(svgFirst)).toBe(true)
126+
127+
fixture.componentInstance.data.set({ url: '/assets/other.svg' })
128+
TestBed.tick()
129+
await flushMicrotasks()
130+
131+
expect(hostElement.contains(svgFirst)).toBe(false)
132+
expect(hostElement.contains(svgSecond)).toBe(true)
133+
expect(hostElement.querySelectorAll('svg').length).toBe(1)
134+
})
135+
136+
test('does not insert SVG when the signal changes before the first request resolved', async () => {
137+
const { mockRegistry } = setup()
138+
139+
let resolveFirst!: (svg: SVGElement) => void
140+
const firstPromise = new Promise<SVGElement>((resolve) => (resolveFirst = resolve))
141+
142+
const firstSvg = createSvgElement()
143+
const secondSvg = createSvgElement()
144+
145+
mockRegistry.getFromUrl
146+
.mockReturnValueOnce(firstPromise) // first call returns a promise that we can control
147+
.mockResolvedValueOnce(secondSvg) // second call resolves immediately
148+
149+
const { fixture, hostElement } = await renderComponent()
150+
151+
expect(mockRegistry.getFromUrl).toHaveBeenCalledTimes(1)
152+
153+
// Change signal before firstPromise resolves
154+
fixture.componentInstance.data.set({ url: '/assets/other.svg' })
155+
TestBed.tick()
156+
await flushMicrotasks()
157+
158+
// At this point, the second svg should be rendered.
159+
expect(mockRegistry.getFromUrl).toHaveBeenCalledTimes(2)
160+
expect(hostElement.contains(secondSvg)).toBe(true)
161+
162+
// Now resolve the first promise; it should have no effect in the DOM anymore.
163+
resolveFirst(firstSvg)
164+
TestBed.tick()
165+
await flushMicrotasks()
166+
expect(hostElement.contains(secondSvg)).toBe(true)
167+
})
168+
169+
test('logs a debug info for network errors (HttpErrorResponse with status 0)', async () => {
170+
const { mockLogger, mockRegistry } = setup()
171+
172+
const networkError = new HttpErrorResponse({ status: 0, url: MOCK_SVG_URL })
173+
mockRegistry.getFromUrl.mockRejectedValue(networkError)
174+
175+
await renderComponent()
176+
177+
expect(mockLogger.debug).toHaveBeenCalledWith(expect.stringContaining(MOCK_SVG_URL), networkError)
178+
expect(mockLogger.error).not.toHaveBeenCalled()
179+
})
180+
181+
test('logs an error for HttpErrorResponse with a non-zero status', async () => {
182+
const { mockLogger, mockRegistry } = setup()
183+
184+
const notFoundErrorResponse = new HttpErrorResponse({ status: 404, url: MOCK_SVG_URL })
185+
mockRegistry.getFromUrl.mockRejectedValue(notFoundErrorResponse)
186+
187+
await renderComponent()
188+
189+
expect(mockLogger.error).toHaveBeenCalledWith(expect.stringContaining(MOCK_SVG_URL), notFoundErrorResponse)
190+
})
191+
192+
test('logs an error for non-HTTP errors', async () => {
193+
const { mockLogger, mockRegistry } = setup()
194+
195+
const genericError = new Error('Something went wrong')
196+
mockRegistry.getFromUrl.mockRejectedValue(genericError)
197+
198+
await renderComponent()
199+
200+
expect(mockLogger.error).toHaveBeenCalledWith(expect.stringContaining(MOCK_SVG_URL), genericError)
201+
})
202+
})
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { HttpErrorResponse } from '@angular/common/http'
2+
import { Directive, effect, ElementRef, inject, Renderer2, Signal } from '@angular/core'
3+
import { Logger } from '@shiftcode/logger'
4+
5+
import { SvgRegistry } from './svg-registry.service'
6+
7+
/**
8+
* Base class for components that display an SVG element inline.
9+
* The SVG content is directly inlined as a child of the component, so that CSS styles can easily be applied to it.
10+
*/
11+
@Directive()
12+
export abstract class SvgBaseDirective {
13+
protected readonly elRef = inject<ElementRef<HTMLElement>>(ElementRef)
14+
protected readonly renderer = inject(Renderer2)
15+
protected readonly svgRegistry = inject(SvgRegistry)
16+
17+
protected abstract readonly logger: Logger
18+
protected abstract readonly data: Signal<{ url: string; attrs?: Record<string, string> }>
19+
20+
constructor() {
21+
effect((onCleanup) => {
22+
const { url, attrs } = this.data()
23+
const abortController = new AbortController()
24+
this.getAndSet(url, attrs, abortController.signal)
25+
onCleanup(() => abortController.abort())
26+
})
27+
}
28+
29+
private getAndSet(url: string, attrs: Record<string, string> | undefined, abortSignal: AbortSignal) {
30+
// due to the caching in SvgRegistry we cannot simply abort the fetching of the svg.
31+
// but we ensure that we do not set the svg element if the abort signal has been triggered in the meantime.
32+
this.svgRegistry
33+
.getFromUrl(url)
34+
.then(this.getSvgModifierFn(attrs))
35+
.then(this.getSvgSetterFn(abortSignal))
36+
.catch((err: any) => {
37+
if (err instanceof HttpErrorResponse && err.status === 0) {
38+
// in case of no internet or a timeout log a warning, we can not do anything about that
39+
this.logger.debug(`Error retrieving icon for path ${url}, due to no network`, err)
40+
} else {
41+
this.logger.error(`Error retrieving icon for path ${url}`, err)
42+
}
43+
})
44+
}
45+
46+
private getSvgModifierFn(attrs?: Record<string, string>) {
47+
const attrsEntries = attrs ? Object.entries(attrs) : []
48+
if (attrsEntries.length === 0) {
49+
return (svg: SVGElement): SVGElement => svg
50+
}
51+
52+
return (svg: SVGElement): SVGElement => {
53+
for (const [key, val] of attrsEntries) {
54+
svg.setAttribute(key, val)
55+
}
56+
return svg
57+
}
58+
}
59+
60+
private getSvgSetterFn = (abortSignal: AbortSignal) => {
61+
return (svg: SVGElement) => {
62+
if (abortSignal.aborted) {
63+
this.logger.debug('Aborting setSvgElement due to abort signal')
64+
return
65+
}
66+
// Remove existing child nodes and add the new SVG element.
67+
this.elRef.nativeElement.innerHTML = ''
68+
this.renderer.appendChild(this.elRef.nativeElement, svg)
69+
}
70+
}
71+
}
Lines changed: 10 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,19 @@
1-
import { HttpErrorResponse } from '@angular/common/http'
2-
import { ChangeDetectionStrategy, Component, effect, ElementRef, inject, input, Renderer2 } from '@angular/core'
1+
import { ChangeDetectionStrategy, Component, computed, inject, input } from '@angular/core'
32
import { Logger } from '@shiftcode/logger'
43
import { LoggerService } from '@shiftcode/ngx-core'
54

6-
import { SvgRegistry } from './svg-registry.service'
5+
import { SvgBaseDirective } from './svg-base.directive'
76

87
/**
98
* Standalone SvgComponent to display svg inline.
10-
* (Initially copied from material MdIcon Directive but got rid of unused functionality and refactored to Promises)
119
*
1210
* - Specify the url input to load an SVG icon from a URL.
1311
* The SVG content is directly inlined as a child of the <sc-svg> component,
1412
* so that CSS styles can easily be applied to it.
15-
* The URL is loaded via an XMLHttpRequest, so it must be on the same domain as the page or its
13+
* The URL is loaded via Angular's {@link HttpClient}, it must be on the same domain as the page or its
1614
* server must be configured to allow cross-domain requests.
1715
* @example
18-
* <sc-svg url="assets/arrow.svg"></sc-svg>
16+
* <sc-svg url="assets/arrow.svg" />
1917
*/
2018
@Component({
2119
selector: 'sc-svg',
@@ -24,52 +22,14 @@ import { SvgRegistry } from './svg-registry.service'
2422
styleUrls: ['./svg.component.scss'],
2523
changeDetection: ChangeDetectionStrategy.OnPush,
2624
})
27-
export class SvgComponent {
25+
export class SvgComponent extends SvgBaseDirective {
2826
readonly url = input.required<string>()
2927

3028
readonly attrs = input<Record<string, string>>({})
3129

32-
protected readonly elRef: ElementRef<HTMLElement> = inject(ElementRef)
33-
protected readonly renderer = inject(Renderer2)
34-
protected readonly svgRegistry = inject(SvgRegistry)
35-
36-
private readonly logger: Logger = inject(LoggerService).getInstance('SvgComponent')
37-
38-
constructor() {
39-
effect(() => {
40-
this.loadAndSetSvg(this.url(), this.attrs())
41-
})
42-
}
43-
44-
private loadAndSetSvg(url: string, attrs: Record<string, string>) {
45-
if (!url.endsWith('.svg')) {
46-
this.logger.warn('svg url does not end with *.svg')
47-
}
48-
this.svgRegistry
49-
.getFromUrl(url)
50-
.then(this.modifySvgElement(attrs))
51-
.then(this.setSvgElement)
52-
.catch((err: any) => {
53-
if (err instanceof HttpErrorResponse && err.status === 0) {
54-
// in case of no internet or a timeout log a warning, we can not do anything about that
55-
this.logger.warn(`Error retrieving icon for path ${this.url()}, due to no network`, err)
56-
} else {
57-
this.logger.error(`Error retrieving icon for path ${this.url()}`, err)
58-
}
59-
})
60-
}
61-
62-
private modifySvgElement(attrs: Record<string, string>) {
63-
return (svg: SVGElement): SVGElement => {
64-
Object.keys(attrs).forEach((key) => svg.setAttribute(key, attrs[key]))
65-
return svg
66-
}
67-
}
68-
69-
private setSvgElement = (svg: SVGElement | null) => {
70-
const layoutElement = this.elRef.nativeElement
71-
// Remove existing child nodes and add the new SVG element.
72-
layoutElement.innerHTML = ''
73-
this.renderer.appendChild(layoutElement, svg)
74-
}
30+
protected readonly logger: Logger = inject(LoggerService).getInstance('SvgComponent')
31+
protected readonly data = computed(() => ({
32+
url: this.url(),
33+
attrs: this.attrs(),
34+
}))
7535
}

libs/components/src/public-api.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
// svg
22
export * from './lib/svg/svg.component'
3+
export * from './lib/svg/svg-base.directive'
34
export * from './lib/svg/svg-registry.service'
45

56
// svg-animate

libs/core/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@shiftcode/ngx-core",
3-
"version": "15.0.0",
3+
"version": "15.1.0-pr80.1",
44
"repository": "https://github.com/shiftcode/sc-ng-commons-public",
55
"license": "MIT",
66
"author": "shiftcode GmbH <team@shiftcode.ch>",

0 commit comments

Comments
 (0)