From fa475e2887083e99485727255996ad938d15dbcc Mon Sep 17 00:00:00 2001 From: Clint Andrew Hall Date: Fri, 4 Jan 2019 19:53:36 -0600 Subject: [PATCH] [Canvas][i18n] Elements (#27904) * [Canvas][i18n] Elements * Addressing feedback; using global i18n * Fixing unit test to reflect globals * Making i18n more flexible * Switching to a Provider strategy for i18n --- .../canvas_plugin_src/elements/register.js | 10 +- .../strings/apply_strings.ts | 39 +++ .../strings/element_strings.test.ts | 43 ++++ .../strings/element_strings.ts | 236 ++++++++++++++++++ .../strings/i18n_provider.ts | 36 +++ .../canvas/canvas_plugin_src/strings/index.ts | 9 + x-pack/plugins/canvas/public/lib/element.ts | 4 +- x-pack/plugins/canvas/scripts/_helpers.js | 6 + x-pack/plugins/canvas/scripts/jest.js | 7 + x-pack/plugins/canvas/types/global.d.ts | 13 + 10 files changed, 400 insertions(+), 3 deletions(-) create mode 100644 x-pack/plugins/canvas/canvas_plugin_src/strings/apply_strings.ts create mode 100644 x-pack/plugins/canvas/canvas_plugin_src/strings/element_strings.test.ts create mode 100644 x-pack/plugins/canvas/canvas_plugin_src/strings/element_strings.ts create mode 100644 x-pack/plugins/canvas/canvas_plugin_src/strings/i18n_provider.ts create mode 100644 x-pack/plugins/canvas/canvas_plugin_src/strings/index.ts create mode 100644 x-pack/plugins/canvas/scripts/jest.js create mode 100644 x-pack/plugins/canvas/types/global.d.ts diff --git a/x-pack/plugins/canvas/canvas_plugin_src/elements/register.js b/x-pack/plugins/canvas/canvas_plugin_src/elements/register.js index cee9760d2d4c..74086dda6f2b 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/elements/register.js +++ b/x-pack/plugins/canvas/canvas_plugin_src/elements/register.js @@ -5,6 +5,14 @@ */ import 'babel-polyfill'; + +import { applyElementStrings, i18nProvider } from '../strings'; import { elementSpecs } from './index'; -elementSpecs.forEach(canvas.register); +const { i18n, register } = canvas; + +// i18n is only available from Kibana when specs are registered. Init the Canvas i18n Provider with that instance. +i18nProvider.init(i18n); + +// Apply localized strings to the Element specs, then register them. +applyElementStrings(elementSpecs).forEach(register); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/strings/apply_strings.ts b/x-pack/plugins/canvas/canvas_plugin_src/strings/apply_strings.ts new file mode 100644 index 000000000000..809a0c4f704c --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/strings/apply_strings.ts @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { ElementFactory } from '../elements/types'; +import { getElementStrings } from './index'; + +/** + * This function takes a set of Canvas Element specification factories, runs them, + * replaces relevant strings (if available) and returns a new factory. We do this + * so the specifications themselves have no dependency on i18n, for clarity for both + * our and external plugin developers. + */ +export const applyElementStrings = (elements: ElementFactory[]) => { + const elementStrings = getElementStrings(); + + return elements.map(spec => { + const result = spec(); + const { name } = result; + const strings = elementStrings[name]; + + // If we have registered strings for this spec, we should replace any that are available. + if (strings) { + const { displayName, help } = strings; + // If the function has a registered help string, replace it on the spec. + if (help) { + result.help = help; + } + + if (displayName) { + result.displayName = displayName; + } + } + + return () => result; + }); +}; diff --git a/x-pack/plugins/canvas/canvas_plugin_src/strings/element_strings.test.ts b/x-pack/plugins/canvas/canvas_plugin_src/strings/element_strings.test.ts new file mode 100644 index 000000000000..84a248fba436 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/strings/element_strings.test.ts @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18nProvider } from './i18n_provider'; +i18nProvider.init(); + +import { getElementStrings } from '.'; +import { elementSpecs } from '../elements'; + +beforeAll(() => { + i18nProvider.init(); +}); + +describe('ElementStrings', () => { + const elementStrings = getElementStrings(); + const elementNames = elementSpecs.map(spec => spec().name); + const stringKeys = Object.keys(elementStrings); + + test('All element names should exist in the strings definition', () => { + elementNames.forEach(name => expect(stringKeys).toContain(name)); + }); + + test('All string definitions should correspond to an existing element', () => { + stringKeys.forEach(key => expect(elementNames).toContain(key)); + }); + + const strings = Object.values(elementStrings); + + test('All elements should have a displayName string defined', () => { + strings.forEach(value => { + expect(value).toHaveProperty('displayName'); + }); + }); + + test('All elements should have a help string defined', () => { + strings.forEach(value => { + expect(value).toHaveProperty('help'); + }); + }); +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/strings/element_strings.ts b/x-pack/plugins/canvas/canvas_plugin_src/strings/element_strings.ts new file mode 100644 index 000000000000..5fe6237eeb3a --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/strings/element_strings.ts @@ -0,0 +1,236 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18nProvider } from './i18n_provider'; + +interface ElementStrings { + displayName: string; + help: string; +} + +interface ElementStringDict { + [elementName: string]: ElementStrings; +} + +/** + * This function will return a dictionary of strings, organized by Canvas + * Element specification. This function requires that `i18nProvider` be + * properly initialized. + */ +export const getElementStrings = (): ElementStringDict => { + const i18n = i18nProvider.getInstance(); + + return { + areaChart: { + displayName: i18n.translate('xpack.canvas.elements.areaChartDisplayName', { + defaultMessage: 'Area chart', + }), + help: i18n.translate('xpack.canvas.elements.areaChartHelpText', { + defaultMessage: 'A line chart with a filled body', + }), + }, + bubbleChart: { + displayName: i18n.translate('xpack.canvas.elements.bubbleChartDisplayName', { + defaultMessage: 'Bubble chart', + }), + help: i18n.translate('xpack.canvas.elements.bubbleChartHelpText', { + defaultMessage: 'A customizable bubble chart', + }), + }, + debug: { + displayName: i18n.translate('xpack.canvas.elements.debugDisplayName', { + defaultMessage: 'Debug', + }), + help: i18n.translate('xpack.canvas.elements.debugHelpText', { + defaultMessage: 'Just dumps the configuration of the element', + }), + }, + donut: { + displayName: i18n.translate('xpack.canvas.elements.donutChartDisplayName', { + defaultMessage: 'Donut chart', + }), + help: i18n.translate('xpack.canvas.elements.donutChartHelpText', { + defaultMessage: 'A customizable donut chart', + }), + }, + dropdown_filter: { + displayName: i18n.translate('xpack.canvas.elements.dropdownFilterDisplayName', { + defaultMessage: 'Dropdown Filter', + }), + help: i18n.translate('xpack.canvas.elements.dropdownFilterHelpText', { + defaultMessage: 'A dropdown from which you can select values for an "exactly" filter', + }), + }, + horizontalBarChart: { + displayName: i18n.translate('xpack.canvas.elements.horizontalBarChartDisplayName', { + defaultMessage: 'Horizontal Bar chart', + }), + help: i18n.translate('xpack.canvas.elements.horizontalBarChartHelpText', { + defaultMessage: 'A customizable horizontal bar chart', + }), + }, + horizontalProgressBar: { + displayName: i18n.translate('xpack.canvas.elements.horizontalProgressBarDisplayName', { + defaultMessage: 'Horizontal Progress Bar', + }), + help: i18n.translate('xpack.canvas.elements.horizontalProgressBarHelpText', { + defaultMessage: 'Displays progress as a portion of a horizontal bar', + }), + }, + horizontalProgressPill: { + displayName: i18n.translate('xpack.canvas.elements.horizontalProgressPillDisplayName', { + defaultMessage: 'Horizontal Progress Pill', + }), + help: i18n.translate('xpack.canvas.elements.horizontalProgressPillHelpText', { + defaultMessage: 'Displays progress as a portion of a horizontal pill', + }), + }, + image: { + displayName: i18n.translate('xpack.canvas.elements.imageDisplayName', { + defaultMessage: 'Image', + }), + help: i18n.translate('xpack.canvas.elements.imageHelpText', { + defaultMessage: 'A static image', + }), + }, + lineChart: { + displayName: i18n.translate('xpack.canvas.elements.lineChartDisplayName', { + defaultMessage: 'Line chart', + }), + help: i18n.translate('xpack.canvas.elements.lineChartHelpText', { + defaultMessage: 'A customizable line chart', + }), + }, + markdown: { + displayName: i18n.translate('xpack.canvas.elements.markdownDisplayName', { + defaultMessage: 'Markdown', + }), + help: i18n.translate('xpack.canvas.elements.markdownHelpText', { + defaultMessage: 'Markup from Markdown', + }), + }, + metric: { + displayName: i18n.translate('xpack.canvas.elements.metricDisplayName', { + defaultMessage: 'Metric', + }), + help: i18n.translate('xpack.canvas.elements.metricHelpText', { + defaultMessage: 'A number with a label', + }), + }, + pie: { + displayName: i18n.translate('xpack.canvas.elements.pieDisplayName', { + defaultMessage: 'Pie chart', + }), + help: i18n.translate('xpack.canvas.elements.pieHelpText', { + defaultMessage: 'Pie chart', + }), + }, + plot: { + displayName: i18n.translate('xpack.canvas.elements.plotDisplayName', { + defaultMessage: 'Coordinate plot', + }), + help: i18n.translate('xpack.canvas.elements.plotHelpText', { + defaultMessage: 'Mixed line, bar or dot charts', + }), + }, + progressGauge: { + displayName: i18n.translate('xpack.canvas.elements.progressGaugeDisplayName', { + defaultMessage: 'Progress Gauge', + }), + help: i18n.translate('xpack.canvas.elements.progressGaugeHelpText', { + defaultMessage: 'Displays progress as a portion of a gauge', + }), + }, + progressSemicircle: { + displayName: i18n.translate('xpack.canvas.elements.progressSemicircleDisplayName', { + defaultMessage: 'Progress Semicircle', + }), + help: i18n.translate('xpack.canvas.elements.progressSemicircleHelpText', { + defaultMessage: 'Displays progress as a portion of a semicircle', + }), + }, + progressWheel: { + displayName: i18n.translate('xpack.canvas.elements.progressWheelDisplayName', { + defaultMessage: 'Progress Wheel', + }), + help: i18n.translate('xpack.canvas.elements.progressWheelHelpText', { + defaultMessage: 'Displays progress as a portion of a wheel', + }), + }, + repeatImage: { + displayName: i18n.translate('xpack.canvas.elements.repeatImageDisplayName', { + defaultMessage: 'Image repeat', + }), + help: i18n.translate('xpack.canvas.elements.repeatImageHelpText', { + defaultMessage: 'Repeats an image N times', + }), + }, + revealImage: { + displayName: i18n.translate('xpack.canvas.elements.revealImageDisplayName', { + defaultMessage: 'Image reveal', + }), + help: i18n.translate('xpack.canvas.elements.revealImageHelpText', { + defaultMessage: 'Reveals a percentage of an image', + }), + }, + shape: { + displayName: i18n.translate('xpack.canvas.elements.shapeDisplayName', { + defaultMessage: 'Shape', + }), + help: i18n.translate('xpack.canvas.elements.shapeHelpText', { + defaultMessage: 'A customizable shape', + }), + }, + table: { + displayName: i18n.translate('xpack.canvas.elements.tableDisplayName', { + defaultMessage: 'Data table', + }), + help: i18n.translate('xpack.canvas.elements.tableHelpText', { + defaultMessage: 'A scrollable grid for displaying data in a tabular format', + }), + }, + tiltedPie: { + displayName: i18n.translate('xpack.canvas.elements.tiltedPieDisplayName', { + defaultMessage: 'Tilted pie chart', + }), + help: i18n.translate('xpack.canvas.elements.tiltedPieHelpText', { + defaultMessage: 'A customizable tilted pie chart', + }), + }, + time_filter: { + displayName: i18n.translate('xpack.canvas.elements.timeFilterDisplayName', { + defaultMessage: 'Time filter', + }), + help: i18n.translate('xpack.canvas.elements.timeFilterHelpText', { + defaultMessage: 'Set a time window', + }), + }, + verticalBarChart: { + displayName: i18n.translate('xpack.canvas.elements.verticalBarChartDisplayName', { + defaultMessage: 'Vertical bar chart', + }), + help: i18n.translate('xpack.canvas.elements.verticalBarChartHelpText', { + defaultMessage: 'A customizable vertical bar chart', + }), + }, + verticalProgressBar: { + displayName: i18n.translate('xpack.canvas.elements.verticalProgressBarDisplayName', { + defaultMessage: 'Vertical Progress Bar', + }), + help: i18n.translate('xpack.canvas.elements.verticalProgressBarHelpText', { + defaultMessage: 'Displays progress as a portion of a vertical bar', + }), + }, + verticalProgressPill: { + displayName: i18n.translate('xpack.canvas.elements.verticalProgressPillDisplayName', { + defaultMessage: 'Vertical Progress Pill', + }), + help: i18n.translate('xpack.canvas.elements.verticalProgressPillHelpText', { + defaultMessage: 'Displays progress as a portion of a vertical pill', + }), + }, + }; +}; diff --git a/x-pack/plugins/canvas/canvas_plugin_src/strings/i18n_provider.ts b/x-pack/plugins/canvas/canvas_plugin_src/strings/i18n_provider.ts new file mode 100644 index 000000000000..e37b5a06520e --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/strings/i18n_provider.ts @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n as i18nCore } from '@kbn/i18n'; + +let i18nCoreInstance: typeof i18nCore | null = null; + +/** + * @kbn/i18n is provided as a global module, but there's a difference between the version provided by Kibana, which is properly + * initialized, and the one imported directly from the global module. We need the former, as the latter, for example, won't + * be set to the proper locale, (set in kibana.yml or the command line). + * + * As a result, we need to initialize our own provider before using i18n in Canvas code. This simple singleton is here for that + * purpose. + */ +export const i18nProvider = { + // For simplicity in cases like testing, you can just init this Provider without parameters... but you won't have the + // Kibana-initialized i18n runtime. + init: (i18n: typeof i18nCore = i18nCore): typeof i18nCore => { + if (i18nCoreInstance === null) { + i18nCoreInstance = i18n; + } + return i18nCoreInstance; + }, + getInstance: (): typeof i18nCore => { + if (i18nCoreInstance === null) { + throw new Error( + 'i18nProvider not initialized; you must first call `init` with an instance of `@kbn/i18n`' + ); + } + return i18nCoreInstance; + }, +}; diff --git a/x-pack/plugins/canvas/canvas_plugin_src/strings/index.ts b/x-pack/plugins/canvas/canvas_plugin_src/strings/index.ts new file mode 100644 index 000000000000..8402eced2769 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/strings/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export * from './apply_strings'; +export * from './element_strings'; +export * from './i18n_provider'; diff --git a/x-pack/plugins/canvas/public/lib/element.ts b/x-pack/plugins/canvas/public/lib/element.ts index 289b7aac90c2..e7cc07609025 100644 --- a/x-pack/plugins/canvas/public/lib/element.ts +++ b/x-pack/plugins/canvas/public/lib/element.ts @@ -25,11 +25,11 @@ export class Element { public height?: number; constructor(config: ElementSpec) { - const { name, image, displayName, expression, filter, width, height } = config; + const { name, image, displayName, expression, filter, help, width, height } = config; this.name = name; this.displayName = displayName || name; this.image = image || defaultHeader; - this.help = config.help || ''; + this.help = help || ''; if (!config.expression) { throw new Error('Element types must have a default expression'); diff --git a/x-pack/plugins/canvas/scripts/_helpers.js b/x-pack/plugins/canvas/scripts/_helpers.js index 687faad14e0a..0f4b07d98830 100644 --- a/x-pack/plugins/canvas/scripts/_helpers.js +++ b/x-pack/plugins/canvas/scripts/_helpers.js @@ -17,3 +17,9 @@ exports.runKibanaScript = function(name, args = []) { process.argv.splice(2, 0, ...args); require('../../../../scripts/' + name); // eslint-disable-line import/no-dynamic-require }; + +exports.runXPackScript = function(name, args = []) { + process.chdir(resolve(__dirname, '../../..')); + process.argv.splice(2, 0, ...args); + require('../../../scripts/' + name); // eslint-disable-line import/no-dynamic-require +}; diff --git a/x-pack/plugins/canvas/scripts/jest.js b/x-pack/plugins/canvas/scripts/jest.js new file mode 100644 index 000000000000..869402a1e472 --- /dev/null +++ b/x-pack/plugins/canvas/scripts/jest.js @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +require('./_helpers').runXPackScript('jest', ['plugins/canvas']); diff --git a/x-pack/plugins/canvas/types/global.d.ts b/x-pack/plugins/canvas/types/global.d.ts new file mode 100644 index 000000000000..d47cfdb09cb7 --- /dev/null +++ b/x-pack/plugins/canvas/types/global.d.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n as I18N } from '@kbn/i18n'; + +declare global { + const canvas: { + i18n: typeof I18N; + }; +}