diff --git a/libs/html-tag/src/lib/html-tag/html-tag-config.spec.ts b/libs/html-tag/src/lib/html-tag/html-tag-config.spec.ts index de7aee93..fe9c6a56 100644 --- a/libs/html-tag/src/lib/html-tag/html-tag-config.spec.ts +++ b/libs/html-tag/src/lib/html-tag/html-tag-config.spec.ts @@ -22,7 +22,7 @@ describe('HtmlTagConfig', () => { }); describe('tag prop', () => { - const { testValid, testInvalid } = testValidProp('tag'); + const { testValid, testInvalid } = testProp('tag'); testValid(undefined); testValid(null); @@ -34,7 +34,7 @@ describe('HtmlTagConfig', () => { }); describe('namespace prop', () => { - const { testValid, testInvalid } = testValidProp('namespace'); + const { testValid, testInvalid } = testProp('namespace'); testValid(undefined); testValid(null); @@ -46,7 +46,7 @@ describe('HtmlTagConfig', () => { }); describe('attributes prop', () => { - const { testValid, testInvalid } = testValidProp('attributes'); + const { testValid, testInvalid } = testProp('attributes'); testValid(undefined); testValid(null); @@ -65,9 +65,33 @@ describe('HtmlTagConfig', () => { testInvalid({ attr1: [] }, 'object records of arrays'); testInvalid({ attr1: {} }, 'object records of objects'); }); + + describe('properties prop', () => { + const { testValid, testInvalid } = testProp('properties'); + + testValid(undefined); + testValid(null); + testValid( + { + attr1: 'value1', + attr2: 2, + attr3: true, + attr4: [], + attr5: {}, + attr6: () => {}, + }, + 'object records of unknown', + ); + testValid({}, 'empty objects'); + + testInvalid('value', 'strings'); + testInvalid(123, 'numbers'); + testInvalid(true, 'booleans'); + testInvalid([], 'arrays'); + }); }); -function testValidProp(prop: string) { +function testProp(prop: string) { const testValid = (val: unknown, name?: string) => it(`should allow ${name || val}`, () => { expect(() => diff --git a/libs/html-tag/src/lib/html-tag/html-tag-config.ts b/libs/html-tag/src/lib/html-tag/html-tag-config.ts index 4c41f6e1..12b43390 100644 --- a/libs/html-tag/src/lib/html-tag/html-tag-config.ts +++ b/libs/html-tag/src/lib/html-tag/html-tag-config.ts @@ -1,6 +1,6 @@ import { Injectable } from '@angular/core'; import { Option, OptionTypeFactory } from '@orchestrator/core'; -import { record, string } from 'io-ts'; +import { record, string, unknown } from 'io-ts'; @Injectable({ providedIn: 'root' }) export class HtmlTagConfig { @@ -11,7 +11,10 @@ export class HtmlTagConfig { namespace?: string; @OptionTypeFactory(() => record(string, string)) - attributes?: { [attr: string]: string }; + attributes?: Record; + + @OptionTypeFactory(() => record(string, unknown)) + properties?: Record; @Option() text?: string; diff --git a/libs/html-tag/src/lib/html-tag/html-tag.component.spec.ts b/libs/html-tag/src/lib/html-tag/html-tag.component.spec.ts index 24a51dec..b507dd22 100644 --- a/libs/html-tag/src/lib/html-tag/html-tag.component.spec.ts +++ b/libs/html-tag/src/lib/html-tag/html-tag.component.spec.ts @@ -73,6 +73,24 @@ describe('HtmlTagComponent', () => { expect(componentElem.children[0].name.toLowerCase()).toBe('div'); }); + it('should not re-render when config.tag is same', () => { + component.config = { tag: 'div' }; + + fixture.detectChanges(); + + const componentElem1 = getComponentElem(); + const divElem1 = componentElem1.children[0]; + + component.config = { tag: 'div' }; + + fixture.detectChanges(); + + const componentElem2 = getComponentElem(); + const divElem2 = componentElem2.children[0]; + + expect(divElem1).toBe(divElem2); + }); + it('should clear tag after config.tag is unset', () => { component.config = { tag: 'div' }; @@ -99,43 +117,132 @@ describe('HtmlTagComponent', () => { expect(divElem.nativeElement.namespaceURI).toBe('xxx'); }); - it('should render attributes from config.attributes', () => { - component.config = { - tag: 'div', - attributes: { attr1: 'value1', attr2: 'value2' }, - }; + describe('attributes', () => { + it('should render from config.attributes', () => { + component.config = { + tag: 'div', + attributes: { attr1: 'value1', attr2: 'value2' }, + }; - fixture.detectChanges(); + fixture.detectChanges(); - const componentElem = getComponentElem(); - const divElem = componentElem.children[0]; + const componentElem = getComponentElem(); + const divElem = componentElem.children[0]; + + expect(divElem.nativeElement.getAttribute('attr1')).toBe('value1'); + expect(divElem.nativeElement.getAttribute('attr2')).toBe('value2'); + }); + + it('should update when config.attributes changed', () => { + component.config = { + tag: 'div', + attributes: { attr1: 'value1', attr2: 'value2', attr3: 'value3' }, + }; + + fixture.detectChanges(); + + component.config = { + tag: 'div', + attributes: { attr1: 'value1', attr2: 'value22', attr4: 'value4' }, + }; + + fixture.detectChanges(); + + const componentElem = getComponentElem(); + const divElem = componentElem.children[0]; + + expect(divElem.nativeElement.getAttribute('attr1')).toBe('value1'); + expect(divElem.nativeElement.getAttribute('attr2')).toBe('value22'); + expect(divElem.nativeElement.getAttribute('attr3')).toBe(null); + expect(divElem.nativeElement.getAttribute('attr4')).toBe('value4'); + }); + + it('should clear when config.attributes is empty object', () => { + component.config = { + tag: 'div', + attributes: { attr1: 'value1', attr2: 'value2' }, + }; + + fixture.detectChanges(); + + component.config = { + tag: 'div', + attributes: {}, + }; - expect(divElem.nativeElement.getAttribute('attr1')).toBe('value1'); - expect(divElem.nativeElement.getAttribute('attr2')).toBe('value2'); + fixture.detectChanges(); + + const componentElem = getComponentElem(); + const divElem = componentElem.children[0]; + + expect(divElem.nativeElement.getAttribute('attr1')).toBe(null); + expect(divElem.nativeElement.getAttribute('attr2')).toBe(null); + }); }); - it('should update attributes when config.attributes changed', () => { - component.config = { - tag: 'div', - attributes: { attr1: 'value1', attr2: 'value2', attr3: 'value3' }, - }; + describe('properties', () => { + it('should render from config.properties', () => { + component.config = { + tag: 'div', + properties: { prop1: 'value1', prop2: true, prop3: 3 }, + }; - fixture.detectChanges(); + fixture.detectChanges(); - component.config = { - tag: 'div', - attributes: { attr1: 'value1', attr2: 'value22', attr4: 'value4' }, - }; + const componentElem = getComponentElem(); + const divElem = componentElem.children[0]; - fixture.detectChanges(); + expect(divElem.nativeElement.prop1).toBe('value1'); + expect(divElem.nativeElement.prop2).toBe(true); + expect(divElem.nativeElement.prop3).toBe(3); + }); - const componentElem = getComponentElem(); - const divElem = componentElem.children[0]; + it('should update when config.properties changed', () => { + component.config = { + tag: 'div', + properties: { prop1: 'value1', prop2: true, prop3: 3 }, + }; + + fixture.detectChanges(); + + component.config = { + tag: 'div', + properties: { prop1: 'value1', prop2: false, prop4: { prop4: true } }, + }; - expect(divElem.nativeElement.getAttribute('attr1')).toBe('value1'); - expect(divElem.nativeElement.getAttribute('attr2')).toBe('value22'); - expect(divElem.nativeElement.getAttribute('attr3')).toBe(null); - expect(divElem.nativeElement.getAttribute('attr4')).toBe('value4'); + fixture.detectChanges(); + + const componentElem = getComponentElem(); + const divElem = componentElem.children[0]; + + expect(divElem.nativeElement.prop1).toBe('value1'); + expect(divElem.nativeElement.prop2).toBe(false); + expect(divElem.nativeElement.prop3).toBe(undefined); + expect(divElem.nativeElement.prop4).toEqual({ prop4: true }); + }); + + it('should clear when config.properties is empty object', () => { + component.config = { + tag: 'div', + properties: { prop1: 'value1', prop2: true, prop3: 3 }, + }; + + fixture.detectChanges(); + + component.config = { + tag: 'div', + properties: {}, + }; + + fixture.detectChanges(); + + const componentElem = getComponentElem(); + const divElem = componentElem.children[0]; + + expect(divElem.nativeElement.prop1).toBe(undefined); + expect(divElem.nativeElement.prop2).toBe(undefined); + expect(divElem.nativeElement.prop3).toBe(undefined); + }); }); it('should render items inside of the tag from items', () => { diff --git a/libs/html-tag/src/lib/html-tag/html-tag.component.ts b/libs/html-tag/src/lib/html-tag/html-tag.component.ts index 34b6e8c6..99000ca8 100644 --- a/libs/html-tag/src/lib/html-tag/html-tag.component.ts +++ b/libs/html-tag/src/lib/html-tag/html-tag.component.ts @@ -2,6 +2,7 @@ import { ChangeDetectionStrategy, Component, Input, + KeyValueDiffer, KeyValueDiffers, OnChanges, OnDestroy, @@ -16,8 +17,16 @@ import { OrchestratorConfigItem, OrchestratorDynamicComponent, } from '@orchestrator/core'; +import { string } from 'fp-ts'; import { HtmlTagConfig } from './html-tag-config'; +interface TrackRecord { + differ: KeyValueDiffer; + getRecord(): Record | undefined; + set(name: string, value: T): void; + remove(name: string): void; +} + @Component({ selector: 'orc-html-tag', templateUrl: './html-tag.component.html', @@ -33,21 +42,38 @@ export class HtmlTagComponent /** @internal */ @ViewChild('tagContentAnchor', { static: true, read: ViewContainerRef }) - _tagContentVcr?: ViewContainerRef; + readonly _tagContentVcr?: ViewContainerRef; /** @internal */ @ViewChild('contentTpl', { static: true }) - _contentTpl?: TemplateRef; + readonly _contentTpl?: TemplateRef; + + private readonly objectDiffer = this.keyValDiffers.find({}); + + private readonly hostElement: unknown = this.vcr.element.nativeElement; + + private readonly trackRecordsMap = { + attributes: { + differ: this.objectDiffer.create(), + getRecord: () => this.config?.attributes, + set: (name, value) => this.setAttr(name, value), + remove: (name) => this.removeAttr(name), + } as TrackRecord, + properties: { + differ: this.objectDiffer.create(), + getRecord: () => this.config?.properties, + set: (name, value) => this.setProp(name, value), + remove: (name) => this.setProp(name, undefined), + } as TrackRecord, + }; - private attrsDiffer = this.keyValDiffers.find({}).create(); - private hostElement: unknown = this.vcr.element.nativeElement; private tagName?: string; private tagElement?: unknown; constructor( - private vcr: ViewContainerRef, - private renderer: Renderer2, - private keyValDiffers: KeyValueDiffers, + private readonly vcr: ViewContainerRef, + private readonly renderer: Renderer2, + private readonly keyValDiffers: KeyValueDiffers, ) {} ngOnChanges(changes: SimpleChanges): void { @@ -84,6 +110,7 @@ export class HtmlTagComponent } this.updateAttrs(); + this.updateProps(); this.updateContent(); } @@ -116,21 +143,29 @@ export class HtmlTagComponent } private updateAttrs() { - const changes = this.attrsDiffer.diff(this.config?.attributes ?? {}); + this.updateRecord(this.trackRecordsMap.attributes); + } + + private updateProps() { + this.updateRecord(this.trackRecordsMap.properties); + } + + private updateRecord(trackMap: TrackRecord) { + const changes = trackMap.differ.diff(trackMap.getRecord()); if (!changes || this.tagElement === undefined) { return; } changes.forEachAddedItem((change) => - this.setAttr(change.key, change.currentValue), + trackMap.set(change.key, change.currentValue), ); changes.forEachChangedItem((change) => - this.setAttr(change.key, change.currentValue), + trackMap.set(change.key, change.currentValue), ); - changes.forEachRemovedItem((change) => this.removeAttr(change.key)); + changes.forEachRemovedItem((change) => trackMap.remove(change.key)); } private setAttr(key: string, value?: string | null) { @@ -141,6 +176,10 @@ export class HtmlTagComponent this.renderer.removeAttribute(this.tagElement, key); } + private setProp(key: string, value: unknown) { + this.renderer.setProperty(this.tagElement, key, value); + } + private updateContent() { if (this.config?.text) { this.renderer.setProperty(