Skip to content

Commit

Permalink
Localized component (#26)
Browse files Browse the repository at this point in the history
* POC

* e2e

* Tests

* tests

* Update changelog

* v1.0.16-rc1

* extend locale types

* Tests

* Update

* Refactor

* Update test description

* Add tests

* v1.0.16-rc2

* private _locale

* Refactor WIP

* Refactor

* v1.0.16-rc3

* `defaultLocale` is protected + Component locale supports undefined

* Export `Localized<>` utility type

* v1.0.16-rc4

---------

Co-authored-by: Claudio Benedetti <claudio.benedetti@mia-platform.eu>
  • Loading branch information
clabene and Claudio Benedetti committed Jan 28, 2024
1 parent dea9656 commit af59d30
Show file tree
Hide file tree
Showing 14 changed files with 623 additions and 14 deletions.
9 changes: 9 additions & 0 deletions .vscode/launch.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,15 @@
"runtimeArgs": ["jest", "--runInBand", "${relativeFile}"],
"console": "integratedTerminal",
"internalConsoleOptions": "neverOpen",
},
{
"type": "node",
"request": "launch",
"name": "e2e: current test file",
"runtimeExecutable": "yarn",
"runtimeArgs": ["wtr", "${relativeFile}", "--node-resolve", "--playwright", "--browsers", "firefox", "chromium"],
"console": "integratedTerminal",
"internalConsoleOptions": "neverOpen",
}
]
}
5 changes: 4 additions & 1 deletion .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,8 @@
"typescript.tsdk": ".yarn/sdks/typescript/lib",
"typescript.enablePromptUseWorkspaceTsdk": true,
"eslint.nodePath": ".yarn/sdks",
"prettier.prettierPath": ".yarn/sdks/prettier/index.js"
"prettier.prettierPath": ".yarn/sdks/prettier/index.js",
"editor.codeActionsOnSave": {
"source.fixAll.eslint": true
},
}
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## Unreleased

- `customLocale` property is available in `bk-base` component

## [1.0.15] - 2024-01-16

- `reroutingRules` property is available in `bk-http-base` component
Expand Down
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@micro-lc/back-kit-engine",
"version": "1.0.15",
"version": "1.0.16-rc4",
"description": "engine to sync react over webcomponents scaffolding + backoffice utils and types/interfaces",
"license": "SEE LICENSE IN LICENSE",
"author": "Mia Platform Core Team <core@mia-platform.eu>",
Expand Down Expand Up @@ -180,5 +180,6 @@
"open@8.4.0": {
"unplugged": true
}
}
},
"stableVersion": "1.0.15"
}
68 changes: 68 additions & 0 deletions src/base/__tests__/bk-base.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,4 +53,72 @@ describe('bk-base tests', () => {
el.remove()
expect(document.body.outerHTML).toEqual('<body><div><!----></div></body>')
})

describe('localized-component tests', () => {
it('should not localize', () => {
const base = new BkBase()
// @ts-expect-error access private field
expect(base.defaultLocale).toBeUndefined()
expect(base.customLocale).toBeUndefined()
expect(base.locale).toBeUndefined()
})

it('should localize', async () => {
const base = new BkBase()
base.customLocale = {
title: {en: 'Title', it: 'Titolo'},
subtitle: {en: 'Subtitle'}
}
expect(base.locale).toStrictEqual({title: 'Title', subtitle: 'Subtitle'})
})

it('should localize with default', () => {
const base = new BkBase()
// @ts-expect-error access private field
base.defaultLocale = {title: 'Title', subtitle: 'Subtitle'}
base.customLocale = {
title: {en: 'Title-custom', it: 'Titolo-custom'},
subtitle: {it: 'Sottotitolo-custom'}
}
expect(base.locale).toStrictEqual({title: 'Title-custom', subtitle: 'Subtitle'})
})

it('should initialize localize', () => {
const base = new BkBase()
// @ts-expect-error access private field
base.defaultLocale = {title: 'Title', subtitle: 'Subtitle'}
base.locale = {title: 'Title2'}
expect(base.locale).toStrictEqual({title: 'Title2'})
})

it('should localize (it)', async () => {
const {navigator} = window
Object.defineProperty(window, 'navigator', {writable: true, value: {language: 'it'}})

const base = new BkBase()
base.customLocale = {
title: {en: 'Title', it: 'Titolo'},
subtitle: {en: 'Subtitle'}
}
expect(base.locale).toStrictEqual({title: 'Titolo', subtitle: 'Subtitle'})

Object.defineProperty(window, 'navigator', {writable: true, value: navigator})
})

it('should localize with default (it)', () => {
const {navigator} = window
Object.defineProperty(window, 'navigator', {writable: true, value: {language: 'it'}})

const base = new BkBase()
// @ts-expect-error access private field
base.defaultLocale = {title: 'Title', subtitle: 'Subtitle'}
base.customLocale = {
title: {en: 'Title-custom'},
subtitle: {it: 'Sottotitolo-custom'}
}
expect(base.locale).toStrictEqual({title: 'Title-custom', subtitle: 'Sottotitolo-custom'})

Object.defineProperty(window, 'navigator', {writable: true, value: navigator})
})
})
})
105 changes: 105 additions & 0 deletions src/base/__tests__/localized-components.tets.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import {mergeLabels, solveLocale} from '../localized-components'

describe('localized-components tests - solveLocale', () => {
const locale = {
title: {
en: 'title-en',
it: 'title-it',
es: 'title-es',
},
subtitle: {
subtitle: {
en: 'subtitle-en',
it: 'subtitle-it',
es: 'subtitle-es',
},
badge: {
en: 'badge-en',
it: 'badge-it',
es: 'badge-es',
},
}
}
const en = {
title: 'title-en',
subtitle: {
subtitle: 'subtitle-en',
badge: 'badge-en',
}
}
const ita = {
title: 'title-it',
subtitle: {
subtitle: 'subtitle-it',
badge: 'badge-it',
}
}
const es = {
title: 'title-es',
subtitle: {
subtitle: 'subtitle-es',
badge: 'badge-es',
}
}

it.each([
[locale, 'en', en],
[locale, 'it', ita],
[locale, 'es', es],
[locale, 'fr', en],
[locale, undefined, en],
])('should solve localized texts', (locale, lang, expected) => {
expect(solveLocale(locale, lang)).toStrictEqual(expected)
})
})

describe('localized-components tests - solveLocale edge cases', () => {
it('should solve localized texts', () => {
const locale = {name: {en: 'Name'}, badge: {id: 0, monthBefore: true, range: ['start', 'end'], text: {en: 'Text'}}}
const expected = {name: 'Name', badge: {id: 0, monthBefore: true, range: ['start', 'end'], text: 'Text'}}

// @ts-expect-error force unorthodox locale
expect(solveLocale(locale)).toStrictEqual(expected)
})

it('localized-components tests - solveLocale default language kicks in', () => {
const {navigator} = window
Object.defineProperty(window, 'navigator', {writable: true, value: {language: undefined}})
expect(solveLocale({name: {en: 'Name', it: 'Nome'}})).toStrictEqual({name: 'Name'})
Object.defineProperty(window, 'navigator', {writable: true, value: navigator})
})
})

describe('localized-components tests - mergeLables', () => {
it.each([
[
{title: 'Title', subtitle: {name: 'Subtitle-new'}},
{subtitle: {name: 'Subtitle'}},
{title: 'Title', subtitle: {name: 'Subtitle-new'}}
],
[
{subtitle: {name: 'Subtitle-new'}},
{title: 'Title', subtitle: {name: 'Subtitle'}},
{title: 'Title', subtitle: {name: 'Subtitle-new'}}
],
[
{title: 'Title', subtitle: {name: 'Subtitle-new'}},
{subtitle: 'Subtitle'},
{title: 'Title', subtitle: {name: 'Subtitle-new'}}
],
[
{title: 'Title', subtitle: {name: 'Subtitle-new'}},
{title: 'Title', subtitle: {badge: 'Badge'}},
{title: 'Title', subtitle: {name: 'Subtitle-new', badge: 'Badge'}}
],
[{title: 'Title', subtitle: 'Subtitle-new'}, {subtitle: 'Subtitle'}, {title: 'Title', subtitle: 'Subtitle-new'}],
[{title: 'Title'}, {subtitle: 'Subtitle'}, {title: 'Title', subtitle: 'Subtitle'}],
[{}, {title: 'Title'}, {title: 'Title'}],
[{title: 'Title'}, {}, {title: 'Title'}],
[{title: 'Title'}, undefined, {title: 'Title'}],
[undefined, {title: 'Title'}, {title: 'Title'}],
[undefined, undefined, undefined],
])('should merge labels', (labels, defaultLabels, expected) => {
expect(mergeLabels(labels, defaultLabels)).toStrictEqual(expected)
})
})
18 changes: 17 additions & 1 deletion src/base/bk-base/bk-base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import {Subscription,ReplaySubject} from 'rxjs'
import type {Observable} from 'rxjs'

import type {EventBus} from '../../events'
import {Localized} from '../../utils/i18n'
import {Labels, LocalizedComponent, mergeLabels, solveLocale} from '../localized-components'

export type Listener = (eventBus: EventBus, kickoff: Observable<0>) => Subscription
export type Bootstrapper = (eventBus: EventBus) => void
Expand Down Expand Up @@ -35,7 +37,7 @@ function bootstrap<T extends BkBase> (
* @superclass
* @description BackOffice library base superclass for Lit-based webcomponents
*/
export class BkBase extends LitElement {
export class BkBase<L extends Labels = Labels> extends LitElement implements LocalizedComponent<L> {
/**
* @description a window that might support sandboxed logic/methods
*/
Expand Down Expand Up @@ -79,6 +81,11 @@ export class BkBase extends LitElement {
this._eventBus = e
}

@property({attribute: false})
set customLocale(l: Localized<L>) {
this._locale = mergeLabels(solveLocale(l), this.defaultLocale)
}

private _currentBusSubscriptions: Subscription[] = []

private _eventBus?: EventBus
Expand Down Expand Up @@ -113,6 +120,15 @@ export class BkBase extends LitElement {
this._subscription = s
}

protected defaultLocale?: L | undefined
protected _locale?: L
set locale (l: L | undefined) {
this._locale = l
}
get locale (): L | undefined {
return this._locale ?? this.defaultLocale
}

constructor (
listeners?: Listener | Listener[],
bootstrap?: Bootstrapper | Bootstrapper[]
Expand Down
5 changes: 3 additions & 2 deletions src/base/bk-component/bk-component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {BkBase} from '../bk-base'
import type {
Bootstrapper, Listener
} from '../bk-base'
import {Labels} from '../localized-components'
import {
adoptStylesheet,
adoptStylesOnShadowRoot,
Expand All @@ -26,8 +27,8 @@ import {
* @description BackOffice library react-rendering component superclass
* for Lit-based webcomponents. Extends `BkBase` and its properties
*/
export class BkComponent<P = {children?: React.ReactNode}>
extends BkBase implements LitCreatable<P>, StyledComponent {
export class BkComponent<P = {children?: React.ReactNode}, L extends Labels = Labels>
extends BkBase<L> implements LitCreatable<P>, StyledComponent {
protected dynamicStyleSheet?: string
_adoptedStyleSheets: CSSResultOrNative[] = []

Expand Down
3 changes: 2 additions & 1 deletion src/base/bk-http-base/bk-http-base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,15 @@ import {
import type {HttpClientInstance, RerouteRule} from '../../utils'
import {createFetchHttpClient} from '../../utils'
import {BkBase} from '../bk-base'
import {Labels} from '../localized-components'

/**
* @superclass
* @description Extends `BkBase` by adding an instance of a basic
* http client which wraps browser's `fetch` API. It provides an axios-like
* API on fetch GET and POST method
*/
export class BkHttpBase extends BkBase {
export class BkHttpBase<L extends Labels = Labels> extends BkBase<L> {
_basePath?: string

_headers?: HeadersInit
Expand Down
5 changes: 3 additions & 2 deletions src/base/bk-http-component/bk-http-component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import type {
Bootstrapper, Listener
} from '../bk-base'
import {BkHttpBase} from '../bk-http-base'
import {Labels} from '../localized-components'
import {
adoptStylesheet,
adoptStylesOnShadowRoot,
Expand All @@ -26,8 +27,8 @@ import {
* @description embeds an http client instance in a webcomponent
* which renders a React component
*/
export class BkHttpComponent<P = Record<string, never>>
extends BkHttpBase implements LitCreatable<P>, StyledComponent {
export class BkHttpComponent<P = Record<string, never>, L extends Labels = Labels>
extends BkHttpBase<L> implements LitCreatable<P>, StyledComponent {
protected dynamicStyleSheet?: string
_adoptedStyleSheets: CSSResultOrNative[] = []

Expand Down
58 changes: 58 additions & 0 deletions src/base/localized-components.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import {type LocalizedText, DEFAULT_LANGUAGE, getLocalizedText, Localized} from '../utils/i18n'

export type Labels = {
[key: string]: string | undefined | Labels
}

export interface LocalizedComponent<L extends Labels = Labels> {
customLocale?: Localized<L>
locale?: L
}

const unique = <T>(l: T[]) => [...new Set(l)]

function isValidObject (input: unknown): input is Record<string, unknown> {
return Boolean(input) && typeof input === 'object' && !Array.isArray(input)
}

function isLocaliedText (input: unknown): input is LocalizedText {
return isValidObject(input)
&& Object
.entries(input)
.every(([key, value]) =>
typeof key === 'string' && key.length === 2 && typeof value === 'string')
}

function merge<T = unknown> (values: T, defaultValues: T): T {
if (typeof values === 'undefined') {
return defaultValues
}

if (!isValidObject(values)) {
return values
}

if (!isValidObject(defaultValues)) {
return values
}

return unique(Object.keys(defaultValues).concat(Object.keys(values)))
.reduce((acc, key) => ({...acc, [key]: merge(values?.[key], defaultValues?.[key])}), {}) as T
}

export function solveLocale<L extends Labels> (
locale: Localized<L>,
lang: string = navigator.language || DEFAULT_LANGUAGE
): L {
if (!isValidObject(locale)) {
return locale
}
return Object.entries(locale).reduce((acc, [key, entry]) => {
acc[key as keyof L] = (isLocaliedText(entry) ? getLocalizedText(entry, lang) : solveLocale(entry, lang)) as L[keyof L]
return acc
}, {} as L)
}

export function mergeLabels <L extends Labels> (labels?: L, defaultLabels?: L): L | undefined {
return merge(labels, defaultLabels)
}
2 changes: 1 addition & 1 deletion src/utils/__tests__/i18n.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ describe('i18n tests', () => {
['str', undefined, 'str'],
['str', 'en', 'str'],
['str', '123456789', 'str'],
[{}.toString(), undefined, {}],
[undefined, undefined, {}],
['str', 'en', {
en: 'str', it: 'abc'
}],
Expand Down
Loading

0 comments on commit af59d30

Please sign in to comment.