From 876fed0c330e1ea46bb2df35faf556d497d1735c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sascha=20I=C3=9Fbr=C3=BCcker?= Date: Thu, 23 Jan 2025 16:09:39 +0200 Subject: [PATCH 1/4] feat: add mixin for handling partial i18n objects --- packages/component-base/src/i18n-mixin.d.ts | 23 +++++ packages/component-base/src/i18n-mixin.js | 83 +++++++++++++++++++ .../component-base/test/i18n-mixin.test.js | 83 +++++++++++++++++++ 3 files changed, 189 insertions(+) create mode 100644 packages/component-base/src/i18n-mixin.d.ts create mode 100644 packages/component-base/src/i18n-mixin.js create mode 100644 packages/component-base/test/i18n-mixin.test.js diff --git a/packages/component-base/src/i18n-mixin.d.ts b/packages/component-base/src/i18n-mixin.d.ts new file mode 100644 index 0000000000..4c3e7fc052 --- /dev/null +++ b/packages/component-base/src/i18n-mixin.d.ts @@ -0,0 +1,23 @@ +/** + * @license + * Copyright (c) 2025 - 2025 Vaadin Ltd. + * This program is available under Apache License Version 2.0, available at https://vaadin.com/license/ + */ +import type { Constructor } from '@open-wc/dedupe-mixin'; + +/** + * A mixin that allows to set partial I18N properties. + */ +export declare function I18nMixin, I>( + superclass: T, + defaultI18n: I, +): Constructor> & T; + +export declare class I18nMixinClass { + /** + * The object used to localize this component. To change the default + * localization, replace this with an object that provides all properties, or + * just the individual properties you want to change. + */ + i18n: Partial; +} diff --git a/packages/component-base/src/i18n-mixin.js b/packages/component-base/src/i18n-mixin.js new file mode 100644 index 0000000000..f314f4ed62 --- /dev/null +++ b/packages/component-base/src/i18n-mixin.js @@ -0,0 +1,83 @@ +/** + * @license + * Copyright (c) 2025 - 2025 Vaadin Ltd. + * This program is available under Apache License Version 2.0, available at https://vaadin.com/license/ + */ + +function deepMerge(target, ...sources) { + const isObject = (item) => item && typeof item === 'object' && !Array.isArray(item); + const merge = (target, source) => { + if (isObject(source) && isObject(target)) { + Object.keys(source).forEach((key) => { + if (isObject(source[key])) { + if (!target[key]) { + target[key] = {}; + } + + merge(target[key], source[key]); + } else if (source[key] !== undefined && source[key] !== null) { + target[key] = source[key]; + } + }); + } + }; + + sources.forEach((source) => { + merge(target, source); + }); + + return target; +} + +/** + * A mixin that allows to set partial I18N properties. + * + * @polymerMixin + */ +export const I18nMixin = (superClass, defaultI18n) => + class I18nMixinClass extends superClass { + static get properties() { + return { + /** @private */ + __effectiveI18n: { + type: Object, + sync: true, + }, + }; + } + + constructor() { + super(); + + this.i18n = deepMerge({}, defaultI18n); + } + + /** + * The object used to localize this component. To change the default + * localization, replace this with an object that provides all properties, or + * just the individual properties you want to change. + * + * Should be overridden by subclasses to provide a custom JSDoc with the + * default I18N properties. + * + * @returns {Object} + */ + get i18n() { + return this.__customI18n; + } + + /** + * The object used to localize this component. To change the default + * localization, replace this with an object that provides all properties, or + * just the individual properties you want to change. + * + * Should be overridden by subclasses to provide a custom JSDoc with the + * default I18N properties. + * + * @param {Object} value + */ + set i18n(value) { + this.__customI18n = value; + this.__effectiveI18n = deepMerge({}, defaultI18n, this.__customI18n); + } + }; diff --git a/packages/component-base/test/i18n-mixin.test.js b/packages/component-base/test/i18n-mixin.test.js new file mode 100644 index 0000000000..37c0fdf943 --- /dev/null +++ b/packages/component-base/test/i18n-mixin.test.js @@ -0,0 +1,83 @@ +import { expect } from '@vaadin/chai-plugins'; +import { nextRender } from '@vaadin/testing-helpers'; +import { PolymerElement } from '@polymer/polymer/polymer-element.js'; +import { LitElement } from 'lit'; +import { I18nMixin } from '../src/i18n-mixin.js'; +import { PolylitMixin } from '../src/polylit-mixin.js'; + +const DEFAULT_I18N = { + foo: 'Foo', + bar: { + baz: 'Baz', + }, +}; + +class I18nMixinPolymerElement extends I18nMixin(PolymerElement, DEFAULT_I18N) { + static get is() { + return 'i18n-mixin-polymer-element'; + } +} + +customElements.define(I18nMixinPolymerElement.is, I18nMixinPolymerElement); + +class I18nMixinLitElement extends I18nMixin(PolylitMixin(LitElement), DEFAULT_I18N) { + static get is() { + return 'i18n-mixin-lit-element'; + } +} + +customElements.define(I18nMixinLitElement.is, I18nMixinLitElement); + +const runTests = (baseClass) => { + let element; + + beforeEach(async () => { + element = document.createElement(baseClass.is); + document.body.appendChild(element); + await nextRender(); + }); + + it('should initialize with copy of defaults', () => { + expect(element.i18n).to.deep.equal(DEFAULT_I18N); + expect(element.i18n).to.not.equal(DEFAULT_I18N); + + expect(element.__effectiveI18n).to.deep.equal(DEFAULT_I18N); + expect(element.__effectiveI18n).to.not.equal(DEFAULT_I18N); + }); + + it('should return same reference that was set previously', () => { + const customI18n = { foo: 'Custom Foo' }; + element.i18n = customI18n; + expect(element.i18n).to.equal(customI18n); + }); + + it('should deep merge custom i18n with default i18n', () => { + element.i18n = {}; + expect(element.__effectiveI18n).to.deep.equal(DEFAULT_I18N); + + element.i18n = { foo: 'Custom Foo' }; + expect(element.__effectiveI18n).to.deep.equal({ foo: 'Custom Foo', bar: { baz: 'Baz' } }); + + element.i18n = { bar: { baz: 'Custom Baz' } }; + expect(element.__effectiveI18n).to.deep.equal({ foo: 'Foo', bar: { baz: 'Custom Baz' } }); + }); + + it('should ignore null and undefined values in custom i18n', () => { + element.i18n = { foo: null }; + expect(element.__effectiveI18n).to.deep.equal(DEFAULT_I18N); + + element.i18n = { bar: undefined }; + expect(element.__effectiveI18n).to.deep.equal(DEFAULT_I18N); + + element.i18n = { bar: { baz: null } }; + expect(element.__effectiveI18n).to.deep.equal(DEFAULT_I18N); + }); +}; + +describe('I18nMixin + Polymer', () => { + runTests(I18nMixinPolymerElement); +}); + +describe('I18nMixin + Lit', () => { + runTests(I18nMixinLitElement); +}); From 9922955373eaf4c7cd0221015e15a142a2230d36 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sascha=20I=C3=9Fbr=C3=BCcker?= Date: Fri, 24 Jan 2025 13:08:19 +0200 Subject: [PATCH 2/4] add TS support for deep partials --- packages/component-base/src/i18n-mixin.d.ts | 8 +++++++- .../i18n-mixin.types.ts | 20 +++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) create mode 100644 packages/component-base/test/typings/data-provider-controller/i18n-mixin.types.ts diff --git a/packages/component-base/src/i18n-mixin.d.ts b/packages/component-base/src/i18n-mixin.d.ts index 4c3e7fc052..f404195557 100644 --- a/packages/component-base/src/i18n-mixin.d.ts +++ b/packages/component-base/src/i18n-mixin.d.ts @@ -5,6 +5,12 @@ */ import type { Constructor } from '@open-wc/dedupe-mixin'; +type DeepPartial = T extends object + ? { + [P in keyof T]?: DeepPartial; + } + : T; + /** * A mixin that allows to set partial I18N properties. */ @@ -19,5 +25,5 @@ export declare class I18nMixinClass { * localization, replace this with an object that provides all properties, or * just the individual properties you want to change. */ - i18n: Partial; + i18n: DeepPartial; } diff --git a/packages/component-base/test/typings/data-provider-controller/i18n-mixin.types.ts b/packages/component-base/test/typings/data-provider-controller/i18n-mixin.types.ts new file mode 100644 index 0000000000..a121f3b451 --- /dev/null +++ b/packages/component-base/test/typings/data-provider-controller/i18n-mixin.types.ts @@ -0,0 +1,20 @@ +import { I18nMixin } from '../../../src/i18n-mixin'; + +const assertType = (actual: TExpected) => actual; + +const DEFAULT_I18N = { + foo: 'foo', + bar: { + baz: 'baz', + qux: 'qux', + }, +}; + +class TestElement extends I18nMixin(HTMLElement, DEFAULT_I18N) {} + +const element = new TestElement(); + +// Verify i18n property accepts deep partials +assertType<{ foo?: string }>(element.i18n); +assertType<{ bar?: object }>(element.i18n); +assertType<{ bar?: { baz?: string } }>(element.i18n); From 294cd7d6871fab9469957e90cfa71c8df81a70ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sascha=20I=C3=9Fbr=C3=BCcker?= Date: Mon, 27 Jan 2025 09:29:49 +0100 Subject: [PATCH 3/4] remove deep partial from I18N mixin --- packages/component-base/src/i18n-mixin.d.ts | 8 +------- .../typings/data-provider-controller/i18n-mixin.types.ts | 7 ++----- 2 files changed, 3 insertions(+), 12 deletions(-) diff --git a/packages/component-base/src/i18n-mixin.d.ts b/packages/component-base/src/i18n-mixin.d.ts index f404195557..d8377c2931 100644 --- a/packages/component-base/src/i18n-mixin.d.ts +++ b/packages/component-base/src/i18n-mixin.d.ts @@ -5,12 +5,6 @@ */ import type { Constructor } from '@open-wc/dedupe-mixin'; -type DeepPartial = T extends object - ? { - [P in keyof T]?: DeepPartial; - } - : T; - /** * A mixin that allows to set partial I18N properties. */ @@ -25,5 +19,5 @@ export declare class I18nMixinClass { * localization, replace this with an object that provides all properties, or * just the individual properties you want to change. */ - i18n: DeepPartial; + i18n: I; } diff --git a/packages/component-base/test/typings/data-provider-controller/i18n-mixin.types.ts b/packages/component-base/test/typings/data-provider-controller/i18n-mixin.types.ts index a121f3b451..bfc3d72c8b 100644 --- a/packages/component-base/test/typings/data-provider-controller/i18n-mixin.types.ts +++ b/packages/component-base/test/typings/data-provider-controller/i18n-mixin.types.ts @@ -1,4 +1,4 @@ -import { I18nMixin } from '../../../src/i18n-mixin'; +import { I18nMixin } from '../../../src/i18n-mixin.js'; const assertType = (actual: TExpected) => actual; @@ -14,7 +14,4 @@ class TestElement extends I18nMixin(HTMLElement, DEFAULT_I18N) {} const element = new TestElement(); -// Verify i18n property accepts deep partials -assertType<{ foo?: string }>(element.i18n); -assertType<{ bar?: object }>(element.i18n); -assertType<{ bar?: { baz?: string } }>(element.i18n); +assertType(element.i18n); From d74d2a31ee14bd351295ea1068cca1e5511b7468 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sascha=20I=C3=9Fbr=C3=BCcker?= Date: Mon, 27 Jan 2025 17:10:08 +0100 Subject: [PATCH 4/4] copy arrays on merge --- packages/component-base/src/i18n-mixin.js | 15 +++++++++------ packages/component-base/test/i18n-mixin.test.js | 11 ++++++++--- 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/packages/component-base/src/i18n-mixin.js b/packages/component-base/src/i18n-mixin.js index f314f4ed62..0df53cda49 100644 --- a/packages/component-base/src/i18n-mixin.js +++ b/packages/component-base/src/i18n-mixin.js @@ -5,18 +5,21 @@ */ function deepMerge(target, ...sources) { - const isObject = (item) => item && typeof item === 'object' && !Array.isArray(item); + const isArray = (item) => Array.isArray(item); + const isObject = (item) => item && typeof item === 'object' && !isArray(item); const merge = (target, source) => { if (isObject(source) && isObject(target)) { Object.keys(source).forEach((key) => { - if (isObject(source[key])) { + const sourceValue = source[key]; + if (isObject(sourceValue)) { if (!target[key]) { target[key] = {}; } - - merge(target[key], source[key]); - } else if (source[key] !== undefined && source[key] !== null) { - target[key] = source[key]; + merge(target[key], sourceValue); + } else if (isArray(sourceValue)) { + target[key] = [...sourceValue]; + } else if (sourceValue !== undefined && sourceValue !== null) { + target[key] = sourceValue; } }); } diff --git a/packages/component-base/test/i18n-mixin.test.js b/packages/component-base/test/i18n-mixin.test.js index 37c0fdf943..0109b81d65 100644 --- a/packages/component-base/test/i18n-mixin.test.js +++ b/packages/component-base/test/i18n-mixin.test.js @@ -10,6 +10,7 @@ const DEFAULT_I18N = { bar: { baz: 'Baz', }, + qux: ['q', 'u', 'x'], }; class I18nMixinPolymerElement extends I18nMixin(PolymerElement, DEFAULT_I18N) { @@ -37,12 +38,16 @@ const runTests = (baseClass) => { await nextRender(); }); - it('should initialize with copy of defaults', () => { + it('should initialize with deep copy of defaults', () => { expect(element.i18n).to.deep.equal(DEFAULT_I18N); expect(element.i18n).to.not.equal(DEFAULT_I18N); + expect(element.i18n.bar).to.not.equal(DEFAULT_I18N.bar); + expect(element.i18n.qux).to.not.equal(DEFAULT_I18N.qux); expect(element.__effectiveI18n).to.deep.equal(DEFAULT_I18N); expect(element.__effectiveI18n).to.not.equal(DEFAULT_I18N); + expect(element.__effectiveI18n.bar).to.not.equal(DEFAULT_I18N.bar); + expect(element.__effectiveI18n.qux).to.not.equal(DEFAULT_I18N.qux); }); it('should return same reference that was set previously', () => { @@ -56,10 +61,10 @@ const runTests = (baseClass) => { expect(element.__effectiveI18n).to.deep.equal(DEFAULT_I18N); element.i18n = { foo: 'Custom Foo' }; - expect(element.__effectiveI18n).to.deep.equal({ foo: 'Custom Foo', bar: { baz: 'Baz' } }); + expect(element.__effectiveI18n).to.deep.equal({ ...DEFAULT_I18N, foo: 'Custom Foo' }); element.i18n = { bar: { baz: 'Custom Baz' } }; - expect(element.__effectiveI18n).to.deep.equal({ foo: 'Foo', bar: { baz: 'Custom Baz' } }); + expect(element.__effectiveI18n).to.deep.equal({ ...DEFAULT_I18N, bar: { baz: 'Custom Baz' } }); }); it('should ignore null and undefined values in custom i18n', () => {