From f4108d4a76582dac7b1f8cd240e91f226e7b1ad5 Mon Sep 17 00:00:00 2001 From: javieri-empathy <68222542+javieri-empathy@users.noreply.github.com> Date: Tue, 13 Sep 2022 10:57:21 +0200 Subject: [PATCH] feat: add `uiLang` parameter (#714) EX-6855 --- .../src/x-installer/api/api.types.ts | 16 ++- .../x-installer/__tests__/x-installer.spec.ts | 134 ++++++++++++------ .../src/x-installer/x-installer/types.ts | 47 +++--- .../x-installer/x-installer/x-installer.ts | 68 +++++---- .../snippet-config-extra-params.spec.ts | 4 +- .../snippet-config-extra-params.vue | 2 +- .../web-archetype-integration-guide.md | 4 +- packages/x-utils/src/types/index.ts | 1 + packages/x-utils/src/types/object.types.ts | 12 ++ 9 files changed, 194 insertions(+), 94 deletions(-) create mode 100644 packages/x-utils/src/types/object.types.ts diff --git a/packages/x-components/src/x-installer/api/api.types.ts b/packages/x-components/src/x-installer/api/api.types.ts index 1c27b6f97c..aeae092905 100644 --- a/packages/x-components/src/x-installer/api/api.types.ts +++ b/packages/x-components/src/x-installer/api/api.types.ts @@ -1,3 +1,4 @@ +import { RequiredProperties } from '@empathyco/x-utils'; import { XBus } from '../../plugins/x-bus.types'; import { DocumentDirection } from '../../plugins/x-plugin.types'; import { XEvent, XEventPayload } from '../../wiring/events.types'; @@ -94,13 +95,13 @@ export interface SnippetConfig { /** Customer instance. */ instance: string; /** Backend services environment. */ - env?: 'live' | 'staging'; + env?: 'staging'; /** Execution scope (desktop, mobile, app, ...). */ scope: string; - /** Language to display. */ + /** Language for the API request, and default value for {@link SnippetConfig.uiLang}. */ lang: string; - /** Language to send to backend services. */ - searchLang?: string; + /** Language to use for the messages. Defaults to {@link SnippetConfig.lang}. */ + uiLang?: string; /** User GDPR consent. */ consent?: boolean; /** Document direction. */ @@ -121,6 +122,13 @@ export interface SnippetConfig { [extra: string]: unknown; } +/** + * A normalised version of the snippet config. + * + * @public + */ +export type NormalisedSnippetConfig = RequiredProperties; + /** * Information to render a query preview with. * diff --git a/packages/x-components/src/x-installer/x-installer/__tests__/x-installer.spec.ts b/packages/x-components/src/x-installer/x-installer/__tests__/x-installer.spec.ts index 7071618f54..d69d5f7031 100644 --- a/packages/x-components/src/x-installer/x-installer/__tests__/x-installer.spec.ts +++ b/packages/x-components/src/x-installer/x-installer/__tests__/x-installer.spec.ts @@ -8,6 +8,7 @@ import { XComponentsAdapterDummy } from '../../../__tests__/adapter.dummy'; import { AnyXModule } from '../../../x-modules/x-modules.types'; import { InitWrapper, InstallXOptions } from '../types'; import { XInstaller } from '../x-installer'; +import { SnippetConfig } from '../../api/index'; describe('testing `XInstaller` utility', () => { const adapter = XComponentsAdapterDummy; @@ -25,11 +26,30 @@ describe('testing `XInstaller` utility', () => { mounted: jest.fn() }; - const snippetConfig = { + const getMinimumSnippetConfig = (): SnippetConfig => ({ instance: 'test', lang: 'test', scope: 'test' - }; + }); + + /** + * Creates a Vue component injecting the snippet config. + * + * @param snippetProperty + * @returns A Vue component with the injected snippet config. + * + * @internal + */ + function createSnippetConfigComponent( + snippetProperty: keyof SnippetConfig = 'instance' + ): VueConstructor { + return Vue.extend({ + inject: ['snippetConfig'], + render(h) { + return h('h1', [(this as any).snippetConfig[snippetProperty]]); + } + }); + } beforeEach(() => { delete window.initX; @@ -45,7 +65,7 @@ describe('testing `XInstaller` utility', () => { __PRIVATE__xModules, initialXModules: [initialXModule], vue: createLocalVue() - }).init(snippetConfig); + }).init(getMinimumSnippetConfig()); const params = xPluginMock.install.mock.calls[0][1]; expect(xPluginMock.install).toHaveBeenCalledTimes(1); @@ -59,21 +79,23 @@ describe('testing `XInstaller` utility', () => { it('creates the public API in global scope by default', () => { delete window.InterfaceX; - new XInstaller({ adapter, plugin, vue: createLocalVue() }).init(snippetConfig); + new XInstaller({ adapter, plugin, vue: createLocalVue() }).init(getMinimumSnippetConfig()); expect(window.InterfaceX).toBeDefined(); delete window.InterfaceX; }); it('does not create the public API passing the api parameter to false', () => { - new XInstaller({ adapter, plugin, api: false, vue: createLocalVue() }).init(snippetConfig); + new XInstaller({ adapter, plugin, api: false, vue: createLocalVue() }).init( + getMinimumSnippetConfig() + ); expect(window.InterfaceX).not.toBeDefined(); }); it('installs the XPlugin using the passed vue', () => { const localVue = createLocalVue(); - new XInstaller({ adapter, plugin, vue: localVue }).init(snippetConfig); + new XInstaller({ adapter, plugin, vue: localVue }).init(getMinimumSnippetConfig()); const vueParam = xPluginMock.install.mock.calls[0][0]; expect(xPluginMock.install).toHaveBeenCalledTimes(1); @@ -82,7 +104,7 @@ describe('testing `XInstaller` utility', () => { it('creates a Vue application using the component passed in the app option', async () => { await new XInstaller({ adapter, plugin, app: component, vue: createLocalVue() }).init( - snippetConfig + getMinimumSnippetConfig() ); // eslint-disable-next-line @typescript-eslint/unbound-method @@ -102,7 +124,7 @@ describe('testing `XInstaller` utility', () => { vue, vueOptions, app: component - }).init(snippetConfig); + }).init(getMinimumSnippetConfig()); expect(app).toHaveProperty('testMethod'); expect(app).toHaveProperty('$router'); @@ -128,40 +150,40 @@ describe('testing `XInstaller` utility', () => { }; }, app: component - }).init(snippetConfig); + }).init(getMinimumSnippetConfig()); expect(app).toHaveProperty('$router'); expect(app).toHaveProperty('bus'); - expect(app).toHaveProperty('snippet', snippetConfig); + expect(app).toHaveProperty('snippet', { ...getMinimumSnippetConfig(), uiLang: 'test' }); }); it('initializes the app with the provided snippet config', async () => { const vue = createLocalVue(); - const { app } = await new XInstaller({ + const { app, api } = await new XInstaller({ adapter, vue, - app: injectSnippetConfigComponent() - }).init(snippetConfig); + app: createSnippetConfigComponent() + }).init(getMinimumSnippetConfig()); - expect(app?.$el).toHaveTextContent(snippetConfig.instance); + expect(app?.$el).toHaveTextContent(getMinimumSnippetConfig().instance); - snippetConfig.instance = 'test-2'; + api?.setSnippetConfig({ instance: 'test-2' }); await vue.nextTick(); expect(app?.$el).toHaveTextContent('test-2'); }); it('initializes the app when window.initX has the snippet config object', async () => { const vue = createLocalVue(); - window.initX = snippetConfig; - const { app } = (await new XInstaller({ + window.initX = getMinimumSnippetConfig(); + const { app, api } = (await new XInstaller({ adapter, vue, - app: injectSnippetConfigComponent() + app: createSnippetConfigComponent() }).init()) as InitWrapper; - expect(app?.$el).toHaveTextContent(snippetConfig.instance); + expect(app?.$el).toHaveTextContent(getMinimumSnippetConfig().instance); - snippetConfig.instance = 'test-2'; + api?.setSnippetConfig({ instance: 'test-2' }); await vue.nextTick(); expect(app?.$el).toHaveTextContent('test-2'); }); @@ -169,16 +191,16 @@ describe('testing `XInstaller` utility', () => { // eslint-disable-next-line max-len it('initializes the app when window.initX is a function retrieving the snippet config', async () => { const vue = createLocalVue(); - window.initX = () => snippetConfig; - const { app } = (await new XInstaller({ + window.initX = () => getMinimumSnippetConfig(); + const { app, api } = (await new XInstaller({ adapter, vue, - app: injectSnippetConfigComponent() + app: createSnippetConfigComponent() }).init()) as InitWrapper; - expect(app?.$el).toHaveTextContent(snippetConfig.instance); + expect(app?.$el).toHaveTextContent(getMinimumSnippetConfig().instance); - snippetConfig.instance = 'test-2'; + api?.setSnippetConfig({ instance: 'test-2' }); await vue.nextTick(); expect(app?.$el).toHaveTextContent('test-2'); }); @@ -187,22 +209,50 @@ describe('testing `XInstaller` utility', () => { it('does not initialize XComponents when no snippet config is passed and no window.initX is not defined', async () => { expect(await new XInstaller({ adapter, plugin, vue: createLocalVue() }).init()).toBeUndefined(); }); -}); -/** - * Creates a Vue component injecting the snippet config. - * - * @returns A Vue component with the injected snippet config. - * - * @internal - */ -function injectSnippetConfigComponent(): VueConstructor { - return Vue.extend({ - inject: ['snippetConfig'], - render(h) { - // Vue does not provide type safety for inject - const instance = (this as any).snippetConfig.instance; - return h('h1', [instance]); - } + describe('`lang` & `uiLang`', () => { + it('provides a `uiLang` by default', async () => { + const { app } = await new XInstaller({ + adapter, + plugin, + vue: createLocalVue(), + app: createSnippetConfigComponent('uiLang') + }).init({ ...getMinimumSnippetConfig(), lang: 'en' }); + + expect(app?.$el).toHaveTextContent('en'); + }); + + it('respects user `uiLang` value', async () => { + const { app } = await new XInstaller({ + adapter, + plugin, + vue: createLocalVue(), + app: createSnippetConfigComponent('uiLang') + }).init({ ...getMinimumSnippetConfig(), lang: 'en', uiLang: 'it' }); + expect(app?.$el).toHaveTextContent('it'); + }); + + it('updates `uiLang` when `lang` is changed', async () => { + const vue = createLocalVue(); + const { app, api } = await new XInstaller({ + adapter, + plugin, + vue, + app: createSnippetConfigComponent('uiLang') + }).init({ ...getMinimumSnippetConfig(), lang: 'en', uiLang: 'it' }); + expect(app?.$el).toHaveTextContent('it'); + + api!.setSnippetConfig({ lang: 'es' }); + await vue.nextTick(); + expect(app?.$el).toHaveTextContent('es'); + + api!.setSnippetConfig({ uiLang: 'en' }); + await vue.nextTick(); + expect(app?.$el).toHaveTextContent('en'); + + api!.setSnippetConfig({ lang: 'fr', uiLang: 'it' }); + await vue.nextTick(); + expect(app?.$el).toHaveTextContent('it'); + }); }); -} +}); diff --git a/packages/x-components/src/x-installer/x-installer/types.ts b/packages/x-components/src/x-installer/x-installer/types.ts index f5cff5b103..5dc1753796 100644 --- a/packages/x-components/src/x-installer/x-installer/types.ts +++ b/packages/x-components/src/x-installer/x-installer/types.ts @@ -1,7 +1,7 @@ import { ComponentOptions, PluginObject, VueConstructor } from 'vue'; import { XBus } from '../../plugins/x-bus.types'; import { XPluginOptions } from '../../plugins/x-plugin.types'; -import { SnippetConfig, XAPI } from '../api/api.types'; +import { NormalisedSnippetConfig, XAPI } from '../api/api.types'; /** * Interface for the parameter of the constructor of {@link XInstaller} function. It is an extended @@ -10,25 +10,37 @@ import { SnippetConfig, XAPI } from '../api/api.types'; * @public */ export interface InstallXOptions extends XPluginOptions { - /** The Vue component used as root of the application. If is not passed no Vue Application is - * initialized, only plugin installed. */ + /** + * The Vue component used as root of the application. If it is not passed, no Vue Application is + * initialized, only plugin installed. + */ app?: VueConstructor | ComponentOptions; - /** The API to expose globally. If is not passed the default {@link BaseXAPI} will be used. If - * a `false` value is passed then the API is not created.*/ + /** + * The API to expose globally. If is not passed the default {@link BaseXAPI} will be used. If + * a `false` value is passed then the API is not created. + */ api?: API | false; - /** The {@link XBus} used in the {@link XPlugin}. If not passed an instance of {@link BaseXBus} - * will be used.*/ + /** + * The {@link XBus} used in the {@link XPlugin}. If not passed an instance of {@link BaseXBus} + * will be used. + */ bus?: XBus; - /** An Element | string to indicate the HTML element that will contain the Vue + /** + * An Element | string to indicate the HTML element that will contain the Vue * application. If string selector is passed and the element doesn't exits, the - * {@link XInstaller} will create it. */ + * {@link XInstaller} will create it. + */ domElement?: Element | string; - /** The XPlugin which will be installed. If not passed, an instance of {@link XPlugin} will be - * installed.*/ + /** + * The XPlugin which will be installed. If not passed, an instance of {@link XPlugin} will be + * installed. + */ plugin?: PluginObject; - /** The Vue instance used to install the plugin and to create the Application. If not + /** + * The Vue instance used to install the plugin and to create the Application. If not * passed the default Vue instance is used. This can be useful to use the `localVue` - * in the unit tests.*/ + * in the unit tests. + */ vue?: VueConstructor; /** * This object can contain any option to pass to Vue instance at the moment of creating the App @@ -45,6 +57,7 @@ export interface InstallXOptions extends XPluginOptions * ``` */ vueOptions?: VueConstructorPartialArgument; + /** * Adds the option to install more Vue plugins, giving access to the {@link SnippetConfig} and * the {@link XBus}. @@ -68,9 +81,11 @@ export interface ExtraPluginsOptions { vue: VueConstructor; /** The events bus instance used to communicate different part of the x-components. */ bus: XBus; - /** Configuration coming from the client website with options like the lang, or the active - * currency. */ - snippet: SnippetConfig; + /** + * Configuration coming from the client website with options like the lang, or the active + * currency. + */ + snippet: NormalisedSnippetConfig; } /** diff --git a/packages/x-components/src/x-installer/x-installer/x-installer.ts b/packages/x-components/src/x-installer/x-installer/x-installer.ts index 710a6bcb50..5eb0494850 100644 --- a/packages/x-components/src/x-installer/x-installer/x-installer.ts +++ b/packages/x-components/src/x-installer/x-installer/x-installer.ts @@ -4,7 +4,7 @@ import { BaseXBus } from '../../plugins/x-bus'; import { XBus } from '../../plugins/x-bus.types'; import { XPlugin } from '../../plugins/x-plugin'; import { XPluginOptions } from '../../plugins/x-plugin.types'; -import { SnippetConfig, XAPI } from '../api/api.types'; +import { NormalisedSnippetConfig, SnippetConfig, XAPI } from '../api/api.types'; import { BaseXAPI } from '../api/base-api'; import { InitWrapper, InstallXOptions, VueConstructorPartialArgument } from './types'; @@ -52,7 +52,7 @@ declare global { * ``` * * 2.3 When the script of the project build is loaded it searches for a global `initX` - * variable that the customer must have in their web site. This variable can be a + * variable that the customer must have in their website. This variable can be a * function that returns the {@link SnippetConfig} or an object that contains the * {@link SnippetConfig} itself: * @@ -63,7 +63,7 @@ declare global { * env, * scope, * lang, - * searchLang, + * uiLang, * currency, * consent, * documentDirection @@ -77,7 +77,7 @@ declare global { * env, * scope, * lang, - * searchLang, + * uiLang, * currency, * consent, * documentDirection @@ -94,7 +94,7 @@ export class XInstaller { * * @internal */ - protected snippetConfig!: SnippetConfig; + protected snippetConfig?: NormalisedSnippetConfig; /** * Receives the {@link InstallXOptions} and merges it with the default fallback options. Also @@ -160,11 +160,12 @@ export class XInstaller { init(): Promise; async init(snippetConfig = this.retrieveSnippetConfig()): Promise { if (snippetConfig) { + this.snippetConfig = this.normaliseSnippetConfig(snippetConfig); const bus = this.createBus(); const pluginOptions = this.getPluginOptions(); const plugin = this.installPlugin(pluginOptions, bus); - const extraPlugins = await this.installExtraPlugins(snippetConfig, bus); - const app = this.createApp(extraPlugins, snippetConfig); + const extraPlugins = await this.installExtraPlugins(bus); + const app = this.createApp(extraPlugins); this.api?.setBus(bus); return { @@ -247,17 +248,15 @@ export class XInstaller { /** * Install more plugins to Vue defined by the user. * - * @param snippet - The snippet configuration. * @param bus - The events bus used in the application. * @returns The arguments from the plugins installation to be used in Vue's constructor. * @internal */ - protected installExtraPlugins( - snippet: SnippetConfig, - bus: XBus - ): Promise { + protected installExtraPlugins(bus: XBus): Promise { const vue = this.getVue(); - return Promise.resolve(this.options.installExtraPlugins?.({ vue, snippet, bus })); + return Promise.resolve( + this.options.installExtraPlugins?.({ vue, snippet: this.snippetConfig!, bus }) + ); } /** @@ -265,25 +264,18 @@ export class XInstaller { * application is created using that app. * * @param extraPlugins - Vue plugins initialisation data. - * @param snippetConfig - Configuration from the client snippet. * @returns The Created Vue application or undefined if not created. * * @internal */ - protected createApp( - extraPlugins: VueConstructorPartialArgument, - snippetConfig: SnippetConfig - ): Vue | undefined { + protected createApp(extraPlugins: VueConstructorPartialArgument): Vue | undefined { if (this.options.app !== undefined) { const vue = this.getVue(); - this.snippetConfig = vue.observable(snippetConfig); return new vue({ ...extraPlugins, ...this.options.vueOptions, - provide() { - return { - snippetConfig - }; + provide: { + snippetConfig: (this.snippetConfig = vue.observable(this.snippetConfig)) }, store: this.options.store, el: this.getMountingTarget(this.options.domElement), @@ -292,6 +284,25 @@ export class XInstaller { } } + protected normaliseSnippetConfig(snippetConfig: SnippetConfig): NormalisedSnippetConfig; + protected normaliseSnippetConfig(snippetConfig: Partial): Partial; + /** + * Transforms the snippet configuration. + * - If `lang` is provided and `uiLang` is not, it sets `uiLang=lang`. + * + * @param snippetConfig - The snippet config to normalise. + * @returns The normalised version of the given snippet config. + * @internal + */ + protected normaliseSnippetConfig( + snippetConfig: SnippetConfig | Partial + ): NormalisedSnippetConfig | Partial { + if (snippetConfig.lang) { + snippetConfig.uiLang ??= snippetConfig.lang; + } + return snippetConfig; + } + /** * It returns the HTML element to mount the Vue Application. If the `domElement` parameter in the * {@link InstallXOptions} is an Element or a string, then it is used. If it is @@ -321,13 +332,16 @@ export class XInstaller { /** * It updates all the provided properties from the current snippet config. * - * @param snippetConfig - All the properties to be updated in the {@link SnippetConfig}. + * @param newSnippetConfig - All the properties to be updated in the {@link SnippetConfig}. * * @internal */ - protected updateSnippetConfig(snippetConfig: Partial): void { - forEach(snippetConfig, (name, value) => { - this.getVue().set(this.snippetConfig, name, value); + protected updateSnippetConfig(newSnippetConfig: Partial): void { + if (!this.snippetConfig) { + return; + } + forEach(this.normaliseSnippetConfig(newSnippetConfig), (name, value) => { + this.getVue().set(this.snippetConfig!, name, value); }); } } diff --git a/packages/x-components/src/x-modules/extra-params/components/__tests__/snippet-config-extra-params.spec.ts b/packages/x-components/src/x-modules/extra-params/components/__tests__/snippet-config-extra-params.spec.ts index 8c15ef8920..bfd810d6b6 100644 --- a/packages/x-components/src/x-modules/extra-params/components/__tests__/snippet-config-extra-params.spec.ts +++ b/packages/x-components/src/x-modules/extra-params/components/__tests__/snippet-config-extra-params.spec.ts @@ -101,7 +101,7 @@ describe('testing snippet config extra params component', () => { }); // eslint-disable-next-line max-len - it('not emits the ExtraParamsProvided event when any no extra params in the snippet config changes', async () => { + it('does not emit ExtraParamsProvided when any no extra params in the snippet config changes', async () => { const { wrapper, setSnippetConfig } = renderSnippetConfigExtraParams(); const extraParamsProvidedCallback = jest.fn(); @@ -114,7 +114,7 @@ describe('testing snippet config extra params component', () => { }) ); - await setSnippetConfig({ searchLang: 'es' }); + await setSnippetConfig({ uiLang: 'es' }); expect(extraParamsProvidedCallback).toHaveBeenCalledTimes(1); diff --git a/packages/x-components/src/x-modules/extra-params/components/snippet-config-extra-params.vue b/packages/x-components/src/x-modules/extra-params/components/snippet-config-extra-params.vue index 5d52882fbe..8a9e05c6d8 100644 --- a/packages/x-components/src/x-modules/extra-params/components/snippet-config-extra-params.vue +++ b/packages/x-components/src/x-modules/extra-params/components/snippet-config-extra-params.vue @@ -58,7 +58,7 @@ default: (): Array => [ 'callbacks', 'productId', - 'searchLang', + 'uiLang', 'consent', 'documentDirection', 'currency', diff --git a/packages/x-components/static-docs/build-search-ui/web-archetype-integration-guide.md b/packages/x-components/static-docs/build-search-ui/web-archetype-integration-guide.md index b1368850fb..b6dc008c8c 100644 --- a/packages/x-components/static-docs/build-search-ui/web-archetype-integration-guide.md +++ b/packages/x-components/static-docs/build-search-ui/web-archetype-integration-guide.md @@ -227,8 +227,8 @@ following configuration parameters: | `instance` | `string` | _Required._ ID of the API client instance. It's provided by Empathy. | | `env` | `staging` | _Optional_. API environment to use. Note that if you **do not** declare this parameter, you'll use the production API. Instead, use `env: 'staging'` to point to the staging API. | | `scope` | `string` | _Optional_. Context where the search interface is executed, i.e. `mobile`, `mobile-app`, `tablet`, `desktop`. | -| `lang` | `string` | _Required._ Language to use. By default, it's used for both the frontend and the API requests. | -| `searchLang` | `string` | _Optional_. Language to use for the API requests **only**. | +| `lang` | `string` | _Required._ Language to use. By default, it's used for both the interface messages and the API requests. | +| `uiLang` | `string` | _Optional_. Language to use for the interface messages **only**. | | `consent` | `boolean` | _Required._ Determines whether the shopper has accepted the use of cookies so that the `sessionId` is sent to the Empathy's Search and Tagging APIs or not. | | `documentDirection` | `'ltr'` | `'rtl'` | _Optional_. Writing direction script that the X Components should, i.e. left-to-right or right-to-left. | | `currency` | `string` | _Required._ Currency identifier to configure how prices are displayed. | diff --git a/packages/x-utils/src/types/index.ts b/packages/x-utils/src/types/index.ts index 342e70b8d0..3054ce5cc6 100644 --- a/packages/x-utils/src/types/index.ts +++ b/packages/x-utils/src/types/index.ts @@ -1,2 +1,3 @@ export * from './paths.types'; export * from './utils.types'; +export * from './object.types'; diff --git a/packages/x-utils/src/types/object.types.ts b/packages/x-utils/src/types/object.types.ts new file mode 100644 index 0000000000..ce6a89a039 --- /dev/null +++ b/packages/x-utils/src/types/object.types.ts @@ -0,0 +1,12 @@ +/** + * Makes required the specified properties of the given type. + * + * @example + * ```ts + * type Required = RequiredProperties<{ a?: string; b?: number; }, 'a'>; + * // { a: string; b?: number; } + * ``` + * @public + */ +export type RequiredProperties = Something & + Required>;