diff --git a/app/javascript/components/intl/HoudiniIntl.spec.tsx b/app/javascript/components/intl/HoudiniIntl.spec.tsx new file mode 100644 index 00000000..e52d999b --- /dev/null +++ b/app/javascript/components/intl/HoudiniIntl.spec.tsx @@ -0,0 +1,40 @@ +// License: LGPL-3.0-or-later +import { createHoudiniIntl, FormatMoneyOptions } from "./HoudiniIntl"; +import { Money } from "../../common/money"; +const NBSP = '\xa0'; + +let tests:Array<[Money, FormatMoneyOptions, string]>; + + +describe('formatMoney', () => { + describe('en', () => { + const intl = createHoudiniIntl({locale: 'en'}); + const oneDollar = Money.fromCents(100, 'usd'); + const oneThousandDollars = Money.fromCents(100000, 'usd'); + const oneThousandDollarsTenCents = Money.fromCents(100010, 'usd'); + const oneEuro = Money.fromCents(100, 'eur'); + const oneHundredYen = Money.fromCents(100, 'jpy'); + + tests = [ + [oneDollar, {}, "$1.00"], + [oneDollar, {currencyDisplay: 'code'}, `USD${NBSP}1.00`], + [oneDollar, {currencyDisplay: "name"}, "1.00 US dollars"], + [oneThousandDollars, {}, '$1,000.00'], + [oneThousandDollars, {currencyDisplay: 'code'}, `USD${NBSP}1,000.00`], + [oneThousandDollars, {currencyDisplay: "name"}, `1,000.00 US dollars`], + [oneThousandDollars, {minimumFractionDigits: 0}, "$1,000"], + [oneThousandDollarsTenCents, {minimumFractionDigits: 2}, "$1,000.10"], + [oneEuro, {}, "€1.00"], + [oneEuro, {currencyDisplay: 'code'}, `EUR${NBSP}1.00`], + [oneEuro, {currencyDisplay: "name"}, "1.00 euros"], + [oneHundredYen, {}, "¥100"], + [oneHundredYen, {currencyDisplay: 'code'}, `JPY${NBSP}100`], + [oneHundredYen, {currencyDisplay: "name"}, "100 Japanese yen"] + ]; + it.each(tests)('money representing %j with opts %j returns %s', (money, opts, expected ) => { + expect.assertions(1); + const output = intl.formatMoney(money, opts); + expect(output).toBe(expected); + }); + }); +}); diff --git a/app/javascript/components/intl/HoudiniIntl.tsx b/app/javascript/components/intl/HoudiniIntl.tsx new file mode 100644 index 00000000..e64e39e2 --- /dev/null +++ b/app/javascript/components/intl/HoudiniIntl.tsx @@ -0,0 +1,57 @@ +// License: LGPL-3.0-or-later + +import * as React from "react"; +import { useIntl, FormatNumberOptions, IntlShape, IntlProvider, createIntl} from "react-intl"; +import { Money } from "../../common/money"; + +export declare type FormatMoneyOptions = Omit; + +export declare type HoudiniIntlShape = IntlShape & {formatMoney:(amount:Money, opts?:FormatMoneyOptions) => string} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const HoudiniIntlContext = React.createContext(null as any); + +function rawFormatMoney(intl:IntlShape, amount:Money, opts?:FormatMoneyOptions) : string { + const formatter = intl.formatters.getNumberFormat(intl.locale, {...opts, + style: 'currency', + currency: amount.currency.toUpperCase(), + }); + + const adjustedAmount = amount.amount / Math.pow(10, formatter.resolvedOptions().maximumFractionDigits); + return formatter.format(adjustedAmount); +} + +export function useHoudiniIntl() : HoudiniIntlShape { + const context = React.useContext(HoudiniIntlContext); + return context; +} + +export function HoudiniIntlProvider(props:ConstructorParameters[0]) : JSX.Element { + return + + {props.children} + + ; +} + +// eslint-disable-next-line @typescript-eslint/ban-types +function InnerProvider({children}:React.PropsWithChildren<{}>) : JSX.Element { + const intl = useIntl(); + const formatMoney = React.useCallback((amount:Money, opts?:FormatMoneyOptions) => { + return rawFormatMoney(intl, amount, opts); + }, [intl]); + + const houdiniIntl = { ...intl, formatMoney}; + return + {children} + ; +} + + +export function createHoudiniIntl(...props:Parameters) : HoudiniIntlShape { + + const intl = createIntl(...props); + const formatMoney = (amount:Money, opts?:FormatMoneyOptions) => rawFormatMoney(intl, amount, opts); + + return {...intl, formatMoney}; +} \ No newline at end of file diff --git a/app/javascript/components/intl/index.ts b/app/javascript/components/intl/index.ts new file mode 100644 index 00000000..904f983d --- /dev/null +++ b/app/javascript/components/intl/index.ts @@ -0,0 +1,3 @@ +// License: LGPL-3.0-or-later +export {useHoudiniIntl, HoudiniIntlProvider} from './HoudiniIntl'; +export type {HoudiniIntlShape} from './HoudiniIntl'; \ No newline at end of file diff --git a/app/javascript/components/tests/intl/index.tsx b/app/javascript/components/tests/intl/index.tsx new file mode 100644 index 00000000..95ffd4c7 --- /dev/null +++ b/app/javascript/components/tests/intl/index.tsx @@ -0,0 +1,104 @@ +import React from 'react'; +import addons from '@storybook/addons'; +import omit from 'lodash/omit'; +import { HoudiniIntlProvider, HoudiniIntlShape} from '../../intl'; +export let _config:any = null; + +const EVENT_SET_CONFIG_ID = "intl/set_config"; +const EVENT_GET_LOCALE_ID = "intl/get_locale"; +const EVENT_SET_LOCALE_ID = "intl/set_locale"; +class WithIntl extends React.Component { + constructor (props:any) { + super(props); + + this.state = { + locale: props.intlConfig.defaultLocale || null + }; + + this.setLocale = this.setLocale.bind(this); + + const { channel } = this.props; + + // Listen for change of locale + channel.on(EVENT_SET_LOCALE_ID, this.setLocale); + + // Request the current locale + channel.emit(EVENT_GET_LOCALE_ID); + } + + setLocale (locale:string) { + this.setState({ + locale: locale + }); + } + + componentWillUnmount () { + this.props.channel.removeListener(EVENT_SET_LOCALE_ID, this.setLocale); + } + + render () { + // If the component is not initialized we don't want to render anything + if (!this.state.locale) { + return null; + } + + const { + children, + getMessages, + getFormats, + intlConfig + } = this.props; + + const { locale } = this.state; + const messages = getMessages(locale); + + const customProps:any = { + key: locale, + locale, + messages, + }; + // if getFormats is not defined, we don't want to specify the formats property + if(getFormats) { + customProps.formats = getFormats(locale); + } + + return ( + + {children} + + ); + } +} + + +export const setIntlConfig = (config:any) => { + _config = config; + + const channel = addons.getChannel(); + channel.emit(EVENT_SET_CONFIG_ID, { + locales: config.locales, + defaultLocale: config.defaultLocale + }); +}; + + + +export const withIntl = (story:any) => { + const channel = addons.getChannel(); + + const intlConfig = omit(_config, ['locales', 'getMessages', 'getFormats']); + + return ( + + {story()} + + ); +}; + + + +