Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add macro options to configure CharacterCount translations #2895

Merged
merged 15 commits into from
Oct 17, 2022

Conversation

romaricpascal
Copy link
Member

@romaricpascal romaricpascal commented Oct 3, 2022

Lets the CharacterCount macro receive options to set its client-side translations.

To avoid cluttering the options, the way I18n handles pluralisation has been reworked to use an object containing a plural-type to message mapping rather than individual suffixed keys, like proposed by @36degrees below.

As the commit history shows, this involved:

  • updating the I18n class to use . based suffixes rather than camelCased ones
  • checking that keys in the config maps were flattened on more than one level deep (which they were, cheers @querkmachine 🙌🏻 )
  • updating the character count, its test and docs to match the new format
  • adding a helper macro to generate the internationalisation attributes, to reduce boilerplate inside the templates themselves. This led to creating a separate callMacro test helper for getting the output of a macro (as we're not just rendering components).
  • passing options from the macro to the HTML

Closes #2701, with the heavy lifting of getting the translations from the data attributes and using them done in #2887

It's worth noting as well that the new option names cause some wrapping in the docs, for which I've opened alphagov/govuk-design-system#2369.

@govuk-design-system-ci govuk-design-system-ci temporarily deployed to govuk-frontend-pr-2895 October 3, 2022 16:00 Inactive
@govuk-design-system-ci govuk-design-system-ci temporarily deployed to govuk-frontend-pr-2895 October 3, 2022 16:10 Inactive
@romaricpascal romaricpascal requested a review from a team October 3, 2022 16:30
@romaricpascal romaricpascal marked this pull request as ready for review October 3, 2022 16:30
Copy link
Contributor

@36degrees 36degrees left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we need to extend this approach to also allow one, two, few and many plural forms…

At the minute this only supports localising into languages that only use the one or other plural forms – based on our current implementation, it'd be fine for languages that use the Chinese, French or German pluralisation systems but not Arabic, Irish, Russian, Scottish or Welsh (!).

That's a lot of options we'll need to add 😱


It might be a bit late to suggest this, but I'm also wondering if we should consider namespacing the different forms together in both the Nunjucks and the config object so that you can do something like e.g.:

{{ govukCharacterCount({
  "id": "with-translations",
  "name": "with-translations",
  "maxlength": 10,
  "label": {
    "text": "Full address"
  },
  "charactersUnderLimitHtml": {
    "one": "%{count} character to go",
    "other": "%{count} characters to go"
  },
  "charactersOverLimitHtml": {
    "one": "%{count} character too many",
    "other": "%{count} characters too many"
  },
  "charactersAtLimitHtml": "Zero characters to go"
}) }}

or:

new CharacterCount($element, {
  i18n: {
    charactersUnderLimit: {
      one: '%{count} character to go',
      other: '%{count} characters to go'
    },
    charactersOverLimit: {
      one: '%{count} character too many',
      other: '%{count} characters too many'
    },
    charactersAtLimit: 'Zero characters to go'
  }
}).init()

That'd make the data attributes e.g. data-i18n.characters-under-limit.one.

It does introduce some inconsistency with the charactersAtLimit option not taking nested config, but I think it's rational inconsistency as it makes sense when you think about it…

Thoughts?

@romaricpascal
Copy link
Member Author

romaricpascal commented Oct 10, 2022

Oh dear, that was quite the oversight on my part, sorry for missing the other translation forms 😬 It will get repetitive indeed if we add one option for each plural form of each (it's already quite a bit) and using an object as you describe provides a much lighter alternative. Looks like it's a case of:

  • Nunjucks side: looping over the object and injecting one attribute per key. Wondering if it's not worth an pluralisedI18nAttribute macro to not burden the template with too much repetitive stuff.
  • JS side:
    • Checking that the nested object in the config get flattened on multiple levels (and adding the feature if not)
    • Update the I18n class to look for . separated plural forms rather than camelCased suffix
    • Update the CharacterCount to match the new format, including
      • the default config
      • the associated tests
      • the constructor's JSDoc – with a PluralisedTranslation type for the translations

@romaricpascal
Copy link
Member Author

The JS updates could be optional, as Nunjucks could be made to output attributes in our current format. But for consistency, best to have both be set up through objects/.-separated keys.

@romaricpascal
Copy link
Member Author

@querkmachine I'd welcome a quick review of that plan as I may have missed a part in how the internationalisation works 😊

@romaricpascal romaricpascal force-pushed the character-count-i18n-macro branch from 1dc02d6 to 18f4b34 Compare October 10, 2022 14:41
@govuk-design-system-ci govuk-design-system-ci temporarily deployed to govuk-frontend-pr-2895 October 10, 2022 14:41 Inactive
@romaricpascal romaricpascal force-pushed the character-count-i18n-macro branch from 18f4b34 to b328723 Compare October 10, 2022 14:55
@govuk-design-system-ci govuk-design-system-ci temporarily deployed to govuk-frontend-pr-2895 October 10, 2022 14:55 Inactive
@romaricpascal
Copy link
Member Author

@36degrees Second take on the matter, following the approach you suggested.

In terms of technical changes, the major thing beside the implementation of the feature itself are:

  • the introduction of a helper macro to render pluralised i18n attributes, as that was a good bit of boilerplate if we'd put it straight in the templates. I'm not attached to its naming, location or arguments (went with positional as it feels more performant than creating an object to just pass 2 options), so happy to discuss. Thinking it may be worth having a non-pluralised helper as well, and maybe move the {% if %} bits inside the helpers as well, but we can do that in a later PR.
  • the extraction of a callMacro helper to test that helper macro.

The documentation of the macro options hasn't been carried over yet from the first take, as I'd like to discuss the approach for it. From what I gathered, we currently have a system where we can document options as an object, then list the object's properties which will get extracted in the docs, like classes for the formGroup and countMessage options.

Screenshot of CharacterCount documentation showing nested options documentation for the formGroup and countMessage option

For internationalisation options, this will get very repetitive as it's the same set of options for all the options. I was thinking this is likely something that'll get described in the general documentation about configuration and internationalisation. If that's the case, it'll be lighter to just have a link pointing there rather than repeating 4 times the same list of 6 options.

@claireashworth how does this approach sound in terms of content organisation? Would it be a problem that, unlike the other options, we'd send users off the page to let them know that for charactersOver/UnderLimit and wordsOver/UnderLimit` they can pass 6 possible options, depending on their language needs?

@36degrees maybe there's another way I'm not aware to reduce duplication while documenting them on that same page, though?

@querkmachine
Copy link
Member

@romaricpascal Only thing I was going to raise was to make sure the plural suffixes are the same format as the spec we're mimicking (zero, one, two, etc.) but it seems like you've already done that!

This'll let us use Objects to pass pluralised translations rather than one key per plural type
@romaricpascal romaricpascal force-pushed the character-count-i18n-macro branch from e2a60b7 to 0034021 Compare October 11, 2022 15:14
@govuk-design-system-ci govuk-design-system-ci temporarily deployed to govuk-frontend-pr-2895 October 11, 2022 15:14 Inactive
Base automatically changed from character-count-i18n to main October 11, 2022 15:30
src/govuk/components/character-count/character-count.mjs Outdated Show resolved Hide resolved
src/govuk/macros/i18n.njk Outdated Show resolved Hide resolved
@@ -0,0 +1,13 @@
{#
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Out of interest, is this following any sort of conventions for 'doc blocks' for Nunjucks macros, or is it something we've 'made up'?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not aware of anything for Nunjucks doc blocks, nor found anything with a quick search, so went with following JSDocs conventions.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just a thought – are we happy with this macro being part of the 'public API' for GOV.UK Frontend? It'd mean that if we made changes to it we'd need to treat them as breaking.

If not, we should try to be explicit about it being private. I don't believe Nunjucks has a concept of public/private for macros but we could for example rename it or put it in a folder called _private or something.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's a good shout, I'll prefix it with _ and tag it @private in the doc which should make it clear that it's a function that we reserve to ourselves. I think I'd be keen to open it at the time we open the I18n API, as that'll be helpful for devs making their own macro to output attributes consistent with our API.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So... tough luck, Nunjucks is not happy with importing _ prefixed methods and complains with names starting with an underscore cannot be imported 😢 Tagging with @private in the doclet may be a bit light, but at least that we can do.

I'm a bit confused about what makes and doesn't make our public API though. Shouldn't anything that's not documented on either the design system site or frontend one be deemed private/"use as your own risk we may break it" anyway?

import { callMacro } from '../../../lib/jest-helpers'

describe('i18n.njk', () => {
describe('govukPluralisedI18nAttributes', () => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❤️ these tests

// Note the starting space so we ensure it doesn't stick to possible other previous attributes
expect(attributes).toEqual(' data-i18n.translation-key.other="You have %{count} characters remaining."')
})
it('renders multiple plural types', () => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is really picky, and definitely a personal preference thing, but I find it considerably easier to read blocks of tests if there's an empty new line between each test.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, that's a fair point,I think. I tend to write things quite compact.

On a side note, that could be something we lint for with ESLint's padding-line-between-statement, to enforce a blank line after multiline-expression (and I'd say multiline anything, to be honest).

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep, I've had a similar conversation with Colin about this on Slack.

Aware ideally this sort of thing wouldn't be coming up in code review – lots of stuff we can improve / automate here.

src/govuk/common.unit.test.mjs Show resolved Hide resolved
src/govuk/i18n.mjs Outdated Show resolved Hide resolved
@govuk-design-system-ci govuk-design-system-ci temporarily deployed to govuk-frontend-pr-2895 October 12, 2022 09:42 Inactive
@romaricpascal
Copy link
Member Author

☝🏻 Removed two async in tests that weren't awaiting anything.

*
* @param {string} macroName - The name of the macro
* @param {string} macroPath - The path to the file containing the macro *from the root of the project*
* @param {Array} options - The parameters that will be passed to the macro. They'll be `JSON.stringify`ed and joined with a `,`
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just a JSDoc suggestion, can we start explaining what it's an array of?

Strings, numbers, objects?

Slightly better?

Suggested change
* @param {Array} options - The parameters that will be passed to the macro. They'll be `JSON.stringify`ed and joined with a `,`
* @param {object[]} options - The parameters that will be passed to the macro. They'll be `JSON.stringify`ed and joined with a `,`

A lot better?

Suggested change
* @param {Array} options - The parameters that will be passed to the macro. They'll be `JSON.stringify`ed and joined with a `,`
* @param {{ key1: string; key2: number }[]} options - The parameters that will be passed to the macro. They'll be `JSON.stringify`ed and joined with a `,`

Not exactly sure, but flat structure?
Unless there are some unknown nested objects, can show a flat key/value object too

Suggested change
* @param {Array} options - The parameters that will be passed to the macro. They'll be `JSON.stringify`ed and joined with a `,`
* @param {Object<string, string | boolean | number>[]} options - The parameters that will be passed to the macro. They'll be `JSON.stringify`ed and joined with a `,`

Really not sure, but always string keys
Might have to check this with the TypeScript compiler (might want unknown or any) but in JSDoc you can say we've always got string keys but any value

Suggested change
* @param {Array} options - The parameters that will be passed to the macro. They'll be `JSON.stringify`ed and joined with a `,`
* @param {Object<string, *>[]} options - The parameters that will be passed to the macro. They'll be `JSON.stringify`ed and joined with a `,`

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That one is really an Array<any>. Its content will depend on the shape of the macro params that's under test.

const macroParams = options.map(param => JSON.stringify(param, null, 2)).join(',')

Makes me think the naming is not necessarily ideal, as options is usually an object, so I'll swap for params which should clarify :)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry, there's a [] after my Object<> examples, "array of objects" shorthand

Yeah it does looks like it's usually (always?) an object[] or undefined

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It could be an array of anything, not just object as a macro may accept number, booleans, arrays, objects as any of its parameters.

@@ -5,6 +5,42 @@ import '../../vendor/polyfills/Element/prototype/classList.mjs'
import { closestAttributeValue, extractConfigByNamespace, mergeConfigs, normaliseDataset } from '../../common.mjs'
import { I18n } from '../../i18n.mjs'

/**
* @typedef {object} CharacterCountTranslations
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

/**
* @type {CharacterCountTranslations}
*/
var DEFAULT_TRANSLATIONS = {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No comment on the word "translations", but similar to HEROKU_APP, GITHUB_TOKEN, PERCY_TOKEN, KEY_SPACE etc I'd put the type/modifier after the thing (if that makes sense 😆)

Suggested change
var DEFAULT_TRANSLATIONS = {
var TRANSLATIONS_DEFAULT = {

{% from "../textarea/macro.njk" import govukTextarea %}
{% from "../hint/macro.njk" import govukHint %}

<div class="govuk-character-count" data-module="govuk-character-count"
{%- if params.maxlength %} data-maxlength="{{ params.maxlength }}"{% endif %}
{%- if params.threshold %} data-threshold="{{ params.threshold }}"{% endif %}
{%- if params.maxwords %} data-maxwords="{{ params.maxwords }}"{% endif %}>
{%- if params.maxwords %} data-maxwords="{{ params.maxwords }}"{% endif %}
{%- if params.charactersUnderLimitHtml %}{{govukPluralisedI18nAttributes('characters-under-limit', params.charactersUnderLimitHtml)}}{% endif %}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Super quick not-even-tried-it example, but how about a function that does all i18n attributes?

{% set i18n = params.i18n %}
{%- if i18n %}{{ govukI18nAttributes([
  ['characters-under-limit', i18n.charactersUnderLimitHtml],
  ['characters-at-limit', i18n.charactersAtLimitHtml],
  ['characters-over-limit', i18n.charactersOverLimitHtml],
  ['words-under-limit', i18n.wordsUnderLimitHtml],
  ['words-at-limit', i18n.wordsAtLimitHtml],
  ['words-over-limit', i18n.wordsOverLimitHtml]
])}}{% endif %}

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The idea is appealing, and we don't really need to introduce a separate i18n namespace for them, we can just pick the values from params directly as we do have to read them manually (unless someone knows of some black magic to go kebab-case to cameCase or the other way around with Nunjucks without adding a filter).

There's a little trickiness to it as translations can be strings or object, depending of if they're pluralised or not. Using the current helper, the template makes it clear which attribute expects which, so we'd need to devise an API that lets us do the same, maybe:

{{ govukI18nAttributes({
  'characters-under-limit': {plurals: {params.charactersUnderLimitHtml}},
  'characters-over-limit': {plurals: {params.charactersOverLimitHtml}},
  'characters-at-limit': {string: params.charactersAtLimitHtml},
  'words-under-limit': {plurals: {params.wordsUnderLimitHtml}},
  'words-over-limit': {plurals: {params.wordsOverLimitHtml}},
  'words-at-limit': {string: params.wordsAtLimitHtml}
 })}}

That said, I think there's ground for a codebase wise look at some abstraction around data attributes, like a govukDataAttributes helper on a similar model, as setting them is always the same thing:

  • check if the value is truthy (or not undefined in a few cases)
  • set data-{{name}}={{value}} (sometimes escaped)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added an issue to track: #2917 😄

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@romaricpascal Yeah, look less at the "how" and more of a nice neat way to generate attributes

We already have this syntax elsewhere for .attributes:

{%- for attribute, value in message.attributes %} {{attribute}}="{{value}}"{% endfor %}

Then other "ad-hoc" data attributes in places:

{%- if params.disableAutoFocus !== undefined %} data-disable-auto-focus="{{ params.disableAutoFocus }}"{% endif %}

If each template had a map (or object) of all its available attributes, defaults, parameter overrides etc, you could write both regular and data attributes into the template in a nice unit-testable way

This certainly isn't a blocker, just an opportunity to makes the template look neater

@romaricpascal romaricpascal force-pushed the character-count-i18n-macro branch from 0b1089a to 4832d14 Compare October 13, 2022 12:12
@govuk-design-system-ci govuk-design-system-ci temporarily deployed to govuk-frontend-pr-2895 October 13, 2022 12:12 Inactive
This part of the execution flow is not currently tested as `Intl.PluralRules` exists in the test environment, so we never follow the 'else' part of the control flow.

Splitting it out allows us to unit test it and ensure that it works as expected.

Add error handling for unsupported locales.
This allows us to test the entire fallback logic together, passing a locale and asserting that we have the same behaviour as Intl.PluralRules, rather than testing the individual functions in i18n.PluralRules.
Improve test coverage of the I18n class
@govuk-design-system-ci govuk-design-system-ci temporarily deployed to govuk-frontend-pr-2895 October 14, 2022 16:08 Inactive
@romaricpascal romaricpascal merged commit 023c3bf into main Oct 17, 2022
@romaricpascal romaricpascal deleted the character-count-i18n-macro branch October 17, 2022 09:01
@romaricpascal romaricpascal mentioned this pull request Nov 10, 2022
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Add ability to pass translation strings into character count component via data-attributes
5 participants