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

Figure out a different way to do form field localizations #55

Closed
shawnbot opened this issue Jun 18, 2020 · 6 comments
Closed

Figure out a different way to do form field localizations #55

shawnbot opened this issue Jun 18, 2020 · 6 comments

Comments

@shawnbot
Copy link
Member

shawnbot commented Jun 18, 2020

We currently have a kind of funky way of overriding field label and description localizations. The trick is to set the string ID column in your spreadsheet to {key}_{property}, where {key} is the API key of the field you're targeting and {property} is the "part" of the field you're localizing: usually label or description, but sometimes content for HTML element components. There are some issues with this approach:

  1. This hack overrides the label, description (etc.) of the field as written on form.io, which can lead to some confusion if people start editing the fields on form.io and see no change in the rendered form. It's often much easier to edit the spreadsheet than the form definition itself, but there's no feedback on form.io to let people know that those labels, descriptions, or content will be overridden on sf.gov.

  2. Because formiojs doesn't support i18next's ability to take an array of keys and treat the ones later in the list as fallbacks (i18next.t(['confirm.yes', 'yes']) returns yes, but form.t(['confirm.yes', 'yes']) returns confirm.yes,yes because it stringifies the array!), I've had to monkey-patch formio's t() implementation to do the Right Thing. This is... not ideal, and actually kind of a dangerous.

I'm not sure what the right way is to do this, but namespaces might help. Either way, it would be good to get formiojs working with fallback key arrays so that we don't have to maintain this ugly patch.

Context might also be useful. Ambiguous strings like "feet" (used as a field suffix) would benefit from context when translating, and (if passed in all of the template calls 😬 ) even help us identify where certain strings are being used.

@shawnbot
Copy link
Member Author

After looking at this in practice, I think that just having a nested key structure should be enough. Rather than using a _ to separate the component key and the "part" ({key}_label, {key}_description, etc.), we can use a . and either nest the keys in the JSON representation:

{
  "en": {
    "firstName": {
      "label": "First name"
    }
  },
  "es": {
    "firstName": {
      "label": "Nombre"
    }
  }
}

...or just collapse them like so:

{
  "en": {
    "firstName.label": "First name"
  },
  "es": {
    "firstName.label": "Nombre"
  }
}

We could even nest the component keys another level, e.g. components.firstName.label, to make it super clear that these are component-specific. In an ideal world, template-specific strings would also have their own namespace, i.e. templates.{type}.{key}, so that you'd localize templates.wizardNav.submitButton.text instead of just the context-free word "Submit".

@shawnbot
Copy link
Member Author

Just for kicks, here is the output of egrep -rno0 'ctx\.t\((.+?)\)' src/templates as of 2426a24:

src/templates/well/form.ejs:3:ctx.t([`${ctx.component.key}_description`, ctx.component.description])
src/templates/datagrid/html.ejs:11:ctx.t(col.label || col.title)
src/templates/datagrid/form.ejs:12:ctx.t(col.label || col.title)
src/templates/datagrid/form.ejs:20:ctx.t(ctx.component.addAnother || 'Add Another')
src/templates/datagrid/form.ejs:71:ctx.t(ctx.component.addAnother || 'Add Another')
src/templates/tree/partials/view.ejs:10:ctx.t(ctx.node.collapsed ? 'Expand' : 'Collapse')
src/templates/tree/partials/view.ejs:13:ctx.t('Add')
src/templates/tree/partials/view.ejs:14:ctx.t('Edit')
src/templates/tree/partials/view.ejs:15:ctx.t('Delete')
src/templates/tree/partials/view.ejs:17:ctx.t('Revert')
src/templates/tree/partials/edit.ejs:5:ctx.t('Save')
src/templates/tree/partials/edit.ejs:6:ctx.t('Cancel')
src/templates/radio/html.ejs:2:ctx.t(item.label)
src/templates/radio/form.ejs:4:ctx.t(ctx.component.label)
src/templates/radio/form.ejs:17:ctx.t(item.label)
src/templates/wizardNav/form.ejs:6:ctx.t('Back')
src/templates/wizardNav/form.ejs:14:ctx.t(ctx.currentPage === 0 ? 'Get started' : 'Next')
src/templates/wizardNav/form.ejs:22:ctx.t('Submit')
src/templates/wizardNav/form.ejs:29:ctx.t('Leave the form')
src/templates/panel/form.ejs:7:ctx.t(ctx.component.title)
src/templates/signature/form.ejs:17:ctx.t(ctx.component.footer)
src/templates/file/form.ejs:4:ctx.t('Gallery')
src/templates/file/form.ejs:5:ctx.t('Camera')
src/templates/file/form.ejs:11:ctx.t('Drop files to attach, or')
src/templates/file/form.ejs:13:ctx.t('Browse')
src/templates/file/form.ejs:22:ctx.t('Take Picture')
src/templates/file/form.ejs:23:ctx.t('Switch to file upload')
src/templates/file/form.ejs:29:ctx.t('No storage has been set for this field. File uploads are disabled until storage is set up.')
src/templates/file/form.ejs:32:ctx.t('File API & FileReader API not supported.')
src/templates/file/form.ejs:35:ctx.t("XHR2's FormData is not supported.")
src/templates/file/form.ejs:38:ctx.t("XHR2's upload progress isn't supported.")
src/templates/file/form.ejs:49:ctx.t('File name')
src/templates/file/form.ejs:52:ctx.t('Size')
src/templates/file/form.ejs:56:ctx.t('Type')
src/templates/file/form.ejs:137:ctx.t('There was an error uploading the file.')
src/templates/input/form.ejs:8:ctx.t([`${ctx.component.key}_prefix`, ctx.component.prefix])
src/templates/input/form.ejs:33:ctx.t([`${ctx.component.key}_suffix`, ctx.component.suffix])
src/templates/builderPlaceholder/form.ejs:8:ctx.t('Drag and Drop a form component')
src/templates/builderSidebarGroup/form.ejs:14:ctx.t(ctx.group.title)
src/templates/builderSidebarGroup/form.ejs:37:ctx.t(ctx.group.components[componentKey].title)
src/templates/multiValueTable/form.ejs:7:ctx.t('Add another')
src/templates/builderWizard/form.ejs:13:ctx.t('Create Page')
src/templates/builderWizard/form.ejs:14:ctx.t('Page')
src/templates/wizard/builder.ejs:1:ctx.t(ctx.component.title)
src/templates/wizard/form.ejs:15:ctx.t(panel.properties.displayTitle || panel.title)
src/templates/checkbox/form.ejs:16:ctx.t([`${ctx.component.key}_label`, ctx.input.label])
src/templates/checkbox/form.ejs:18:ctx.t([`${ctx.component.key}_description`, ctx.component.tooltip])
src/templates/pdf/form.ejs:9:ctx.t('Submit')
src/templates/label/form.ejs:3:ctx.t([`${ctx.component.key}_label`, ctx.component.label])
src/templates/editgrid/html.ejs:17:ctx.t(ctx.component.saveRow || 'Save')
src/templates/editgrid/html.ejs:19:ctx.t(ctx.component.removeRow || 'Cancel')
src/templates/editgrid/form.ejs:17:ctx.t(ctx.component.saveRow || 'Save')
src/templates/editgrid/form.ejs:19:ctx.t(ctx.component.removeRow || 'Cancel')
src/templates/editgrid/form.ejs:38:ctx.t(ctx.component.addAnother || 'Add Another')
src/templates/html/form.ejs:5:ctx.t([`${ctx.component.key}_content`, ctx.content])
src/templates/tab/flat.ejs:4:ctx.t(tab.label)
src/templates/tab/form.ejs:6:ctx.t(tab.label)
src/templates/field/align.ejs:25:ctx.t(ctx.component.description)
src/templates/field/form.ejs:6:ctx.t([`${ctx.component.key}_description`, ctx.component.description])
src/templates/field/form.ejs:14:ctx.t([`${ctx.component.key}_tooltip`, ctx.component.tooltip])
src/templates/pdfBuilderUpload/form.ejs:2:ctx.t('Upload a PDF File')
src/templates/pdfBuilderUpload/form.ejs:5:ctx.t('Drop pdf to start, or')
ctx.t('browse')
src/templates/selectOption/html.ejs:1:ctx.t(ctx.option.label)
src/templates/selectOption/form.ejs:7:ctx.t(ctx.option.label)
src/templates/resourceAdd/form.ejs:12:ctx.t(ctx.component.addResourceLabel || 'Add Resource')
src/templates/table/form.ejs:11:ctx.t(header)
src/templates/modaldialog/form.ejs:9:ctx.t('Close')
src/templates/address/form.ejs:2:ctx.t([`${ctx.component.key}_description`, ctx.component.description])
src/templates/fieldset/form.ejs:4:ctx.t(ctx.component.legend)
src/templates/day/form.ejs:6:ctx.t('Day')
src/templates/day/form.ejs:14:ctx.t('Month')
src/templates/day/form.ejs:22:ctx.t('Day')
src/templates/day/form.ejs:30:ctx.t('Year')
src/templates/survey/html.ejs:5:ctx.t(question.label)
src/templates/survey/html.ejs:9:ctx.t(item.label)
src/templates/survey/form.ejs:6:ctx.t(value.label)
src/templates/survey/form.ejs:13:ctx.t(question.label)
src/templates/wizardHeader/form.ejs:6:ctx.t(properties.backTitle || 'Back', { context: 'nav' })
src/templates/wizardHeader/form.ejs:23:ctx.t(panel.properties.displayTitle || panel.title, { context: 'nav' })
src/templates/webform/builder.ejs:1:ctx.t(ctx.component.title)
src/templates/builderEditForm/form.ejs:3:ctx.t(ctx.componentInfo.title)
ctx.t('Component')
src/templates/builderEditForm/form.ejs:7:ctx.t(ctx.componentInfo.documentation)
src/templates/builderEditForm/form.ejs:8:ctx.t('Help')
src/templates/builderEditForm/form.ejs:20:ctx.t('Save')
src/templates/builderEditForm/form.ejs:21:ctx.t('Cancel')
src/templates/builderEditForm/form.ejs:22:ctx.t('Remove')
src/templates/builderEditForm/form.ejs:30:ctx.t('Preview')
src/templates/builderEditForm/form.ejs:40:ctx.t(ctx.componentInfo.help)
src/templates/builderEditForm/form.ejs:44:ctx.t('Save')
src/templates/builderEditForm/form.ejs:45:ctx.t('Cancel')
src/templates/builderEditForm/form.ejs:46:ctx.t('Remove')

@shawnbot
Copy link
Member Author

We also have the ability to add arbitrary custom properties to any component (field):

image

These are simple key/value pairs, and it looks like the values are treated as strings in the UI, but this might be the place where we can either associate its label, description, etc. with localized string IDs; or we could even store the localizations right in there.

One way this could work is that each of the field definitions in our "form library" would have a default set of custom properties attached whenever you pull it into your form in form.io:

  • en.label would be the English label text, es.label would be the Spanish translation, and so on
  • en.description would be the English description/help text, and so on
  • i18n.namespace could be the prefix/namespace for all of the associated string IDs in Phrase—the unique key that we use to associate the field with its translations

So our common "Name" field could come with everything baked into it as custom properties:

{
  "customProperties": {
    "es.label": "Nombre",
    "zh.label": "姓名",
    "tl.label": "Pangalan",
    "i18n.namespace": "fullName"
  }
}

We would need to support this at the template level by looking up localized strings in the following order (e.g. for the input label):

  • The component's customProperties.${language}.label
  • The component's actual label (by default, the English translation)
    🕵️ Under the hood, this string would live in Phrase under ${customProperties.i18n.namespace}.label

In order for this to be clearer when editing on form.io, we could run custom JS on the portal that:

  1. Passes these strings to i18next using the same mechanism we do in the theme
  2. Adds a language switcher at the top of the form renderer so that you can test it more easily
  3. Adds a link in the component editing UI to the Phrase project — maybe even directly to that string!

@shawnbot
Copy link
Member Author

For now, I'm holding off on storing custom properties in each component instance and instead linking them together with unique string ids that are expected to match up between an API that generates JSON (for uploading to Phrase) and the template t() calls. All of this is happening in #69, since the in-context editor is the easiest way to see whether it's working.

@shawnbot
Copy link
Member Author

shawnbot commented Oct 7, 2020

This is basically done in #106, which introduced the ctx.tk('property') helper:

formio-sfds/src/patch.js

Lines 27 to 35 in 54d009e

tk (field, defaultValue = '') {
const { component = {} } = this
const { type, key = type } = component
return key ? this.t([
`${key}.${field}`,
`${key}_${field}`,
component[field] || ''
]) : defaultValue
},

...which means that in our templates we can use {{ ctx.tk('label') }} to output a localizable/localized string for the component's label with the string id {key}.label. Now I just have to test the round-trip workflow of translating these strings in Phrase's in-context editor to make sure that it works!

@shawnbot
Copy link
Member Author

There are now lots of different ways to do this:

  1. Nix blue background, improve vertical form spacing #106 introduced the tk() template helper, described above.
  2. Add "embedded" translation support #146 added "embedded" translation support, which looks for custom component properties in the form {lang}:{path}, and promotes those to i18next strings named {key}.{path} for each language.
  3. Translations can still be passed in the i18n render option, which we're going to explore as a way of inlining translations in Explore more options for translation embedding #163

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

No branches or pull requests

1 participant