Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 39 additions & 40 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@
"yaml": "^2.1.1"
},
"dependencies": {
"@formatjs/intl": "^4.1.8",
"@fortawesome/fontawesome-svg-core": "^7.1.0",
"@roundingwell/care-ops-auth": "workspace:*",
"@roundingwell/care-ops-config": "workspace:*",
Expand All @@ -131,7 +132,6 @@
"backbone.store": "^1.1.1",
"dayjs": "^1.10.6",
"handlebars": "^4.7.9",
"handlebars-intl": "^1.1.2",
"jquery": "^4.0.0",
"libphonenumber-js": "^1.10.38",
"marionette.toolkit": "^6.2.0",
Expand Down
166 changes: 166 additions & 0 deletions src/js/base/helpers.cy.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import { View } from 'marionette';

import hbs from 'handlebars-inline-precompile';
import Handlebars from 'handlebars/dist/cjs/handlebars';

import { testTs } from 'helpers/test-timestamp';
import formatDate from 'helpers/format-date';
import { renderTemplate } from 'js/i18n';
import { registerWith } from 'js/i18n/intl';

context('Handlebars helpers', function() {
specify('Match text formatting', function() {
Expand Down Expand Up @@ -88,6 +91,169 @@ context('Handlebars helpers', function() {
.should('contain', 'No Date Available');
});

specify('Intl formatting', function() {
const testDate = '2026-05-05T12:00:00Z';
const IntlView = View.extend({
template: hbs`
{{#intl formats=formats}}
<div class="test-date-format">{{formatDate testDate "short"}}</div>
{{/intl}}
<div class="test-date-options">{{formatDate testDate year="numeric" month="short" day="numeric" timeZone="UTC"}}</div>
<div class="test-intlname">{{formatMessage intlName="patients.shared.components.durationComponent.mins" min=4}}</div>
<div class="test-function">{{formatMessage functionMessage name="Patient"}}</div>
<div class="test-message">{{formatMessage (intlGet "patients.shared.components.durationComponent.mins") min=2}}</div>
<div class="test-select">{{formatMessage (intlGet "patients.shared.components.dateFilterComponent.dateTypes") type="created_at"}}</div>
<div class="test-number-select">{{formatMessage (intlGet "patients.shared.listViews.countView.maximumListCount") maximumCount=50 totalInDb=1000 isFlowList=false}}</div>
<div class="test-html">{{formatHTMLMessage "<strong>{ name }</strong>" name=name}}</div>
<div class="test-html-safe">{{formatHTMLMessage "{ name }" name=(matchText "Patient Name" "Patient")}}</div>
<div class="test-escaped">{{formatHTMLMessage "{ name }" name=name}}</div>
`,
templateContext() {
return {
testDate,
formats: {
date: {
short: {
year: 'numeric',
month: '2-digit',
day: '2-digit',
timeZone: 'UTC',
},
},
},
functionMessage({ name }) {
return `Function ${ name }`;
},
name: '<script>alert("bad")</script>',
};
},
});

cy
.mount(() => {
return new IntlView();
})
.as('root');

cy
.get('@root')
.find('.test-date-format')
.should('contain', '05/05/2026');

cy
.get('@root')
.find('.test-date-options')
.should('contain', 'May 5, 2026');

cy
.get('@root')
.find('.test-intlname')
.should('contain', '4 mins');

cy
.get('@root')
.find('.test-function')
.should('contain', 'Function Patient');

cy
.get('@root')
.find('.test-message')
.should('contain', '2 mins');

cy
.get('@root')
.find('.test-select')
.should('contain', 'Added');

cy
.get('@root')
.find('.test-number-select')
.should('contain', 'Showing 50 of 1,000 Actions.');

cy
.get('@root')
.find('.test-html')
.should('contain', '<script>alert("bad")</script>')
.and('not.contain', '{ name }')
.find('strong')
.should('exist');

cy
.get('@root')
.find('.test-html-safe')
.find('strong')
.should('contain', 'Patient');

cy
.get('@root')
.find('.test-escaped script')
.should('not.exist');
});

specify('Intl formatting defaults', function() {
const localHandlebars = Handlebars.create();
registerWith(localHandlebars);

const template = localHandlebars.compile(`
{{#intl locales="en-US"}}
<div class="test-block">{{formatMessage "{ value }" value=false}}</div>
{{/intl}}
<div class="test-default-message">{{formatMessage "{ name }" name="Patient"}}</div>
<div class="test-default-date">{{formatDate "2026-05-05T12:00:00Z" year="numeric" month="2-digit" day="2-digit" timeZone="UTC"}}</div>
<div class="test-default-html">{{formatHTMLMessage "{ name }" name=safeName count=count}}</div>
`);
const html = template({
count: 0,
safeName: new localHandlebars.SafeString('<strong>Patient</strong>'),
});

expect(html).to.contain('<div class="test-block"></div>');
expect(html).to.contain('<div class="test-default-message">Patient</div>');
expect(html).to.contain('<div class="test-default-date">05/05/2026</div>');
expect(html).to.contain('<div class="test-default-html"><strong>Patient</strong></div>');
});

specify('Intl formatting rejects invalid input', function() {
const localHandlebars = Handlebars.create();
registerWith(localHandlebars);

const BadIntlTemplate = hbs`{{intl}}`;
const BadLocalIntlGetTemplate = localHandlebars.compile('{{intlGet "not.found"}}');
const BadIntlGetTemplate = hbs`{{intlGet "not.found"}}`;
const BadDateTemplate = hbs`{{formatDate null}}`;
const BadDateValueTemplate = hbs`{{formatDate "not-a-date"}}`;
const BadMessageMissingTemplate = hbs`{{formatMessage}}`;
const BadMessageTemplate = hbs`{{formatMessage message}}`;

expect(() => {
renderTemplate(BadIntlTemplate);
}).to.throw('{{#intl}} must be invoked as a block helper');

expect(() => {
BadLocalIntlGetTemplate();
}).to.throw('Could not find Intl object: not.found');

expect(() => {
renderTemplate(BadIntlGetTemplate);
}).to.throw('Could not find Intl object: not.found');

expect(() => {
renderTemplate(BadDateTemplate);
}).to.throw('A date or timestamp must be provided to {{formatDate}}');

expect(() => {
renderTemplate(BadDateValueTemplate);
}).to.throw('A date or timestamp must be provided to {{formatDate}}');

expect(() => {
renderTemplate(BadMessageMissingTemplate);
}).to.throw('{{formatMessage}} must be provided a message or intlName');

expect(() => {
renderTemplate(BadMessageTemplate, { message: { text: 'Bad Message' } });
}).to.throw('{{formatMessage}} must be provided a message or intlName');
});

specify('Phone number formatting', function() {
const PhoneView = View.extend({
template: hbs`
Expand Down
6 changes: 3 additions & 3 deletions src/js/i18n/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@

## formatjs

For the provider app we are using Yahoo's [formatjs](https://formatjs.io/) through the
[handlebars-intl](https://formatjs.io/handlebars/) lib. Our translation will be key based
and found in the directory [`/assets/js/i18n`](https://github.com/RoundingWell/app-frontend/tree/develop/src/js/i18n).
For the provider app we use [FormatJS](https://formatjs.io/) directly through a small
Handlebars adapter in `src/js/i18n/intl.js`. Our translation will be key based and found
in the directory [`/assets/js/i18n`](https://github.com/RoundingWell/app-frontend/tree/develop/src/js/i18n).

The key naming strategy currently under consideration is:
`view.directory.fileViews.viewName.stringContextName` where the first part is the
Expand Down
Loading
Loading