[I18n] Add one-time binding to angularjs i18n (#23499)

* Add one-time binding to angularjs i18n

* Add watcher for values property

* Watch values field only if it is provided

* Fix ci
This commit is contained in:
Leanid Shutau 2018-10-10 15:46:53 +03:00 committed by GitHub
parent 4246530213
commit 14e4e1744c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 116 additions and 42 deletions

View file

@ -18,7 +18,7 @@ The following types are supported:
- ToggleSwitch
- LinkLabel and etc.
There is one more complex case, when we have to divide a single expression into different labels.
There is one more complex case, when we have to divide a single expression into different labels.
For example the message before translation looks like:
@ -221,8 +221,8 @@ For example:
```js
<button
aria-label="{{'kbn.management.editIndexPattern.removeAriaLabel' | i18n: {defaultMessage: 'Remove index pattern'} }}"
tooltip="{{'kbn.management.editIndexPattern.removeTooltip' | i18n: {defaultMessage: 'Remove index pattern'} }}"
aria-label="{{ ::'kbn.management.editIndexPattern.removeAriaLabel' | i18n: {defaultMessage: 'Remove index pattern'} }}"
tooltip="{{ ::'kbn.management.editIndexPattern.removeTooltip' | i18n: {defaultMessage: 'Remove index pattern'} }}"
>
</button>
```
@ -333,4 +333,3 @@ it('should render normally', async () => {
});
// ...
```

View file

@ -188,7 +188,7 @@ import { i18n } from '@kbn/i18n';
i18n.init(messages);
```
One common use-case is that of internationalizing a string constant. Here's an
One common use-case is that of internationalizing a string constant. Here's an
example of how we'd do that:
```js
@ -399,7 +399,7 @@ In order to translate attributes in Angular we should use `i18nFilter`:
```html
<input
type="text"
placeholder="{{'KIBANA-MANAGEMENT-OBJECTS-SEARCH_PLACEHOLDER' | i18n: {
placeholder="{{ ::'KIBANA-MANAGEMENT-OBJECTS-SEARCH_PLACEHOLDER' | i18n: {
defaultMessage: 'Search'
} }}"
>

View file

@ -0,0 +1,5 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`i18nDirective inserts correct translation html content with values 1`] = `"default-message word"`;
exports[`i18nDirective inserts correct translation html content with values 2`] = `"default-message anotherWord"`;

View file

@ -30,7 +30,7 @@ angular
describe('i18nDirective', () => {
let compile: angular.ICompileService;
let scope: angular.IRootScopeService;
let scope: angular.IRootScopeService & { word?: string };
beforeEach(angular.mock.module('app'));
beforeEach(
@ -38,6 +38,7 @@ describe('i18nDirective', () => {
($compile: angular.ICompileService, $rootScope: angular.IRootScopeService) => {
compile = $compile;
scope = $rootScope.$new();
scope.word = 'word';
}
)
);
@ -62,19 +63,23 @@ describe('i18nDirective', () => {
test('inserts correct translation html content with values', () => {
const id = 'id';
const defaultMessage = 'default-message {word}';
const compiledContent = 'default-message word';
const element = angular.element(
`<div
i18n-id="${id}"
i18n-default-message="${defaultMessage}"
i18n-values="{ word: 'word' }"
i18n-values="{ word }"
/>`
);
compile(element)(scope);
scope.$digest();
expect(element.html()).toEqual(compiledContent);
expect(element.html()).toMatchSnapshot();
scope.word = 'anotherWord';
scope.$digest();
expect(element.html()).toMatchSnapshot();
});
});

View file

@ -21,26 +21,37 @@ import { IDirective, IRootElementService, IScope } from 'angular';
import { I18nServiceType } from './provider';
export function i18nDirective(i18n: I18nServiceType): IDirective {
interface I18nScope extends IScope {
values?: Record<string, any>;
defaultMessage: string;
id: string;
}
export function i18nDirective(i18n: I18nServiceType): IDirective<I18nScope> {
return {
restrict: 'A',
scope: {
id: '@i18nId',
defaultMessage: '@i18nDefaultMessage',
values: '=i18nValues',
values: '<?i18nValues',
},
link($scope: IScope, $element: IRootElementService) {
$scope.$watchGroup(
['id', 'defaultMessage', 'values'],
([id, defaultMessage = '', values = {}]) => {
$element.html(
i18n(id, {
values,
defaultMessage,
})
);
}
);
link($scope, $element) {
if ($scope.values) {
$scope.$watchCollection('values', () => {
setHtmlContent($element, $scope, i18n);
});
} else {
setHtmlContent($element, $scope, i18n);
}
},
};
}
function setHtmlContent($element: IRootElementService, $scope: I18nScope, i18n: I18nServiceType) {
$element.html(
i18n($scope.id, {
values: $scope.values,
defaultMessage: $scope.defaultMessage,
})
);
}

View file

@ -5,7 +5,7 @@
data-test-subj="editIndexPattern"
class="kuiViewContent"
role="region"
aria-label="{{'kbn.management.editIndexPattern.detailsAria' | i18n: { defaultMessage: 'Index pattern details' } }}"
aria-label="{{::'kbn.management.editIndexPattern.detailsAria' | i18n: { defaultMessage: 'Index pattern details' } }}"
>
<!-- Header -->
<kbn-management-index-header
@ -103,9 +103,9 @@
<input
class="kuiSearchInput__input"
type="text"
aria-label="{{'kbn.management.editIndexPattern.fields.filterAria' | i18n: {defaultMessage: 'Filter'} }}"
aria-label="{{::'kbn.management.editIndexPattern.fields.filterAria' | i18n: {defaultMessage: 'Filter'} }}"
ng-model="fieldFilter"
placeholder="{{'kbn.management.editIndexPattern.fields.filterPlaceholder' | i18n: {defaultMessage: 'Filter'} }}"
placeholder="{{::'kbn.management.editIndexPattern.fields.filterPlaceholder' | i18n: {defaultMessage: 'Filter'} }}"
data-test-subj="indexPatternFieldFilter"
>
</div>

View file

@ -2,7 +2,7 @@
<a
data-test-subj="indexPatternFieldEditButton"
ng-href="{{ kbnUrl.getRouteHref(field, 'edit') }}"
aria-label="{{'kbn.management.editIndexPattern.editFieldButton' | i18n: { defaultMessage: 'Edit' } }}"
aria-label="{{::'kbn.management.editIndexPattern.editFieldButton' | i18n: { defaultMessage: 'Edit' } }}"
class="kuiButton kuiButton--basic kuiButton--small"
>
<span aria-hidden="true" class="kuiIcon fa-pencil"></span>
@ -12,7 +12,7 @@
ng-if="field.scripted"
ng-click="remove(field)"
class="kuiButton kuiButton--danger kuiButton--small"
aria-label="{{'kbn.management.editIndexPattern.deleteFieldButton' | i18n: { defaultMessage: 'Delete' } }}"
aria-label="{{::'kbn.management.editIndexPattern.deleteFieldButton' | i18n: { defaultMessage: 'Delete' } }}"
>
<span aria-hidden="true" class="kuiIcon fa-trash"></span>
</button>

View file

@ -18,8 +18,8 @@
<button
ng-if="setDefault"
ng-click="setDefault()"
aria-label="{{'kbn.management.editIndexPattern.setDefaultAria' | i18n: { defaultMessage: 'Set as default index' } }}"
tooltip="{{'kbn.management.editIndexPattern.setDefaultTooltip' | i18n: { defaultMessage: 'Set as default index' } }}"
aria-label="{{::'kbn.management.editIndexPattern.setDefaultAria' | i18n: { defaultMessage: 'Set as default index' } }}"
tooltip="{{::'kbn.management.editIndexPattern.setDefaultTooltip' | i18n: { defaultMessage: 'Set as default index' } }}"
class="kuiButton kuiButton--basic"
data-test-subj="setDefaultIndexPatternButton"
>
@ -32,8 +32,8 @@
<button
ng-if="refreshFields"
ng-click="refreshFields()"
aria-label="{{'kbn.management.editIndexPattern.refreshAria' | i18n: { defaultMessage: 'Reload field list' } }}"
tooltip="{{'kbn.management.editIndexPattern.refreshTooltip' | i18n: { defaultMessage: 'Refresh field list' } }}"
aria-label="{{::'kbn.management.editIndexPattern.refreshAria' | i18n: { defaultMessage: 'Reload field list' } }}"
tooltip="{{::'kbn.management.editIndexPattern.refreshTooltip' | i18n: { defaultMessage: 'Refresh field list' } }}"
class="kuiButton kuiButton--basic"
>
<span
@ -45,8 +45,8 @@
<button
ng-if="delete"
ng-click="delete()"
aria-label="{{'kbn.management.editIndexPattern.removeAria' | i18n: { defaultMessage: 'Remove index pattern' } }}"
tooltip="{{'kbn.management.editIndexPattern.removeTooltip' | i18n: { defaultMessage: 'Remove index pattern' } }}"
aria-label="{{::'kbn.management.editIndexPattern.removeAria' | i18n: { defaultMessage: 'Remove index pattern' } }}"
tooltip="{{::'kbn.management.editIndexPattern.removeTooltip' | i18n: { defaultMessage: 'Remove index pattern' } }}"
class="kuiButton kuiButton--danger"
data-test-subj="deleteIndexPatternButton"
>

View file

@ -1,5 +1,5 @@
<div class="euiPage">
<div class="col-md-2 sidebar-container" role="region" aria-label="{{'kbn.management.editIndexPatternAria' | i18n: { defaultMessage: 'Index patterns' } }}">
<div class="col-md-2 sidebar-container" role="region" aria-label="{{::'kbn.management.editIndexPatternAria' | i18n: { defaultMessage: 'Index patterns' } }}">
<div class="sidebar-list">
<div class="sidebar-item-title full-title">
<h5 data-test-subj="createIndexPatternParent">
@ -55,4 +55,4 @@
<div ng-transclude></div>
</div>
</div>
</div>
</div>

View file

@ -32,7 +32,7 @@
kbn-accessible-click
aria-expanded="{{!!showColorRange}}"
aria-controls="metricOptionsRanges"
aria-label="{{'metricVis.params.ranges.toggleOptionsAriaLabel' | i18n: { defaultMessage: 'Toggle range options' } }}"
aria-label="{{::'metricVis.params.ranges.toggleOptionsAriaLabel' | i18n: { defaultMessage: 'Toggle range options' } }}"
class="kuiSideBarCollapsibleTitle__label"
ng-click="showColorRange = !showColorRange"
>
@ -135,7 +135,7 @@
kbn-accessible-click
aria-expanded="{{!!showColorOptions}}"
aria-controls="metricOptionsColors"
aria-label="{{'metricVis.params.color.toggleOptionsAriaLabel' | i18n: { defaultMessage: 'Toggle color options' } }}"
aria-label="{{::'metricVis.params.color.toggleOptionsAriaLabel' | i18n: { defaultMessage: 'Toggle color options' } }}"
class="kuiSideBarCollapsibleTitle__label"
ng-click="showColorOptions = !showColorOptions"
>
@ -219,7 +219,7 @@
kbn-accessible-click
aria-expanded="{{!!showStyle}}"
aria-controls="metricOptionsStyle"
aria-label="{{'metricVis.params.style.toggleOptionsAriaLabel' | i18n: { defaultMessage: 'Toggle style options' } }}"
aria-label="{{::'metricVis.params.style.toggleOptionsAriaLabel' | i18n: { defaultMessage: 'Toggle style options' } }}"
class="kuiSideBarCollapsibleTitle__label"
ng-click="showStyle = !showStyle"
>

View file

@ -1 +1 @@
<p>{{ 'plugin_2.message-id' | i18n: { defaultMessage: 'Message text' } }}</p>
<p>{{ ::'plugin_2.message-id' | i18n: { defaultMessage: 'Message text' } }}</p>

View file

@ -26,6 +26,18 @@ Array [
]
`;
exports[`dev/i18n/extractors/html extracts default messages from HTML with one-time binding 1`] = `
Array [
Array [
"kbn.id",
Object {
"context": undefined,
"message": "Message text with {value}",
},
],
]
`;
exports[`dev/i18n/extractors/html throws on empty i18n-id 1`] = `"Empty \\"i18n-id\\" value in angular directive is not allowed."`;
exports[`dev/i18n/extractors/html throws on missing i18n-default-message attribute 1`] = `"Empty defaultMessage in angular directive is not allowed (\\"message-id\\")."`;

View file

@ -119,6 +119,34 @@ function trimCurlyBraces(string) {
return string.slice(2, -2).trim();
}
/**
* Removes parentheses from the start and the end of a string.
*
* Example: `('id' | i18n: { defaultMessage: 'Message' })`
* @param {string} string string to trim
*/
function trimParentheses(string) {
if (string.startsWith('(') && string.endsWith(')')) {
return string.slice(1, -1);
}
return string;
}
/**
* Removes one-time binding operator `::` from the start of a string.
*
* Example: `::'id' | i18n: { defaultMessage: 'Message' }`
* @param {string} string string to trim
*/
function trimOneTimeBindingOperator(string) {
if (string.startsWith('::')) {
return string.slice(2);
}
return string;
}
function* getFilterMessages(htmlContent) {
const expressions = (htmlContent.match(ANGULAR_EXPRESSION_REGEX) || [])
.filter(expression => expression.includes(I18N_FILTER_MARKER))
@ -126,7 +154,10 @@ function* getFilterMessages(htmlContent) {
for (const expression of expressions) {
const filterStart = expression.indexOf(I18N_FILTER_MARKER);
const idExpression = expression.slice(0, filterStart).trim();
const idExpression = trimParentheses(
trimOneTimeBindingOperator(expression.slice(0, filterStart).trim())
);
const filterObjectExpression = expression.slice(filterStart + I18N_FILTER_MARKER.length).trim();
if (!filterObjectExpression || !idExpression) {

View file

@ -43,6 +43,17 @@ describe('dev/i18n/extractors/html', () => {
expect(actual.sort()).toMatchSnapshot();
});
test('extracts default messages from HTML with one-time binding', () => {
const actual = Array.from(
extractHtmlMessages(`
<div>
{{::'kbn.id' | i18n: { defaultMessage: 'Message text with {value}', values: { value: 'value' } }}}
</div>
`)
);
expect(actual.sort()).toMatchSnapshot();
});
test('throws on empty i18n-id', () => {
const source = Buffer.from(`\
<p