Skip to content

Commit

Permalink
#764 Add option to format numbers as currency in tables
Browse files Browse the repository at this point in the history
  • Loading branch information
Polleps committed Jan 23, 2024
1 parent 1abdc43 commit 906f96f
Show file tree
Hide file tree
Showing 10 changed files with 229 additions and 56 deletions.
53 changes: 53 additions & 0 deletions browser/data-browser/src/chunks/CurrencyPicker/CurrencyPicker.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { FC, useEffect } from 'react';
import { getSupportedCurrencyList } from './currencies';
import { BasicSelect } from '../../components/forms/BasicSelect';
import { Resource, dataBrowser, useString } from '@tomic/react';

interface CurrencyPickerProps {
resource: Resource;
}

const supportedCurrencies = getSupportedCurrencyList();

const getSymbol = (code: string) => {
return new Intl.NumberFormat('default', {
style: 'currency',
currency: code,
currencyDisplay: 'narrowSymbol',
})
.formatToParts(0)
.find(part => part.type === 'currency')?.value;
};

const CurrencyPicker: FC<CurrencyPickerProps> = ({ resource }) => {
const [currency, setCurrency] = useString(
resource,
dataBrowser.properties.currency,
);

const handleChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
setCurrency(e.target.value);
};

useEffect(() => {
if (currency === undefined) {
setCurrency('EUR');
}
}, []);

return (
<BasicSelect defaultValue={currency ?? 'EUR'} onChange={handleChange}>
{supportedCurrencies.map(c => (
<option
key={c.code}
value={c.code}
label={`${c.code} ${c.name ?? ''} (${getSymbol(c.code)})`}
>
{c.code}
</option>
))}
</BasicSelect>
);
};

export default CurrencyPicker;
11 changes: 11 additions & 0 deletions browser/data-browser/src/chunks/CurrencyPicker/currencies.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
// Data taken from https://www.six-group.com/dam/download/financial-information/data-center/iso-currrency/lists/list-one.xml
const currencyNames = JSON.parse(
`{"AFN":"Afghani","EUR":"Euro","ALL":"Lek","DZD":"Algerian Dinar","USD":"US Dollar","AOA":"Kwanza","XCD":"East Caribbean Dollar","ARS":"Argentine Peso","AMD":"Armenian Dram","AWG":"Aruban Florin","AUD":"Australian Dollar","AZN":"Azerbaijan Manat","BSD":"Bahamian Dollar","BHD":"Bahraini Dinar","BDT":"Taka","BBD":"Barbados Dollar","BYN":"Belarusian Ruble","BZD":"Belize Dollar","XOF":"CFA Franc BCEAO","BMD":"Bermudian Dollar","INR":"Indian Rupee","BTN":"Ngultrum","BOB":"Boliviano","BOV":"Mvdol","BAM":"Convertible Mark","BWP":"Pula","NOK":"Norwegian Krone","BRL":"Brazilian Real","BND":"Brunei Dollar","BGN":"Bulgarian Lev","BIF":"Burundi Franc","CVE":"Cabo Verde Escudo","KHR":"Riel","XAF":"CFA Franc BEAC","CAD":"Canadian Dollar","KYD":"Cayman Islands Dollar","CLP":"Chilean Peso","CLF":"Unidad de Fomento","CNY":"Yuan Renminbi","COP":"Colombian Peso","COU":"Unidad de Valor Real","KMF":"Comorian Franc ","CDF":"Congolese Franc","NZD":"New Zealand Dollar","CRC":"Costa Rican Colon","CUP":"Cuban Peso","CUC":"Peso Convertible","ANG":"Netherlands Antillean Guilder","CZK":"Czech Koruna","DKK":"Danish Krone","DJF":"Djibouti Franc","DOP":"Dominican Peso","EGP":"Egyptian Pound","SVC":"El Salvador Colon","ERN":"Nakfa","SZL":"Lilangeni","ETB":"Ethiopian Birr","FKP":"Falkland Islands Pound","FJD":"Fiji Dollar","XPF":"CFP Franc","GMD":"Dalasi","GEL":"Lari","GHS":"Ghana Cedi","GIP":"Gibraltar Pound","GTQ":"Quetzal","GBP":"Pound Sterling","GNF":"Guinean Franc","GYD":"Guyana Dollar","HTG":"Gourde","HNL":"Lempira","HKD":"Hong Kong Dollar","HUF":"Forint","ISK":"Iceland Krona","IDR":"Rupiah","XDR":"SDR (Special Drawing Right)","IRR":"Iranian Rial","IQD":"Iraqi Dinar","ILS":"New Israeli Sheqel","JMD":"Jamaican Dollar","JPY":"Yen","JOD":"Jordanian Dinar","KZT":"Tenge","KES":"Kenyan Shilling","KPW":"North Korean Won","KRW":"Won","KWD":"Kuwaiti Dinar","KGS":"Som","LAK":"Lao Kip","LBP":"Lebanese Pound","LSL":"Loti","ZAR":"Rand","LRD":"Liberian Dollar","LYD":"Libyan Dinar","CHF":"Swiss Franc","MOP":"Pataca","MKD":"Denar","MGA":"Malagasy Ariary","MWK":"Malawi Kwacha","MYR":"Malaysian Ringgit","MVR":"Rufiyaa","MRU":"Ouguiya","MUR":"Mauritius Rupee","XUA":"ADB Unit of Account","MXN":"Mexican Peso","MXV":"Mexican Unidad de Inversion (UDI)","MDL":"Moldovan Leu","MNT":"Tugrik","MAD":"Moroccan Dirham","MZN":"Mozambique Metical","MMK":"Kyat","NAD":"Namibia Dollar","NPR":"Nepalese Rupee","NIO":"Cordoba Oro","NGN":"Naira","OMR":"Rial Omani","PKR":"Pakistan Rupee","PAB":"Balboa","PGK":"Kina","PYG":"Guarani","PEN":"Sol","PHP":"Philippine Peso","PLN":"Zloty","QAR":"Qatari Rial","RON":"Romanian Leu","RUB":"Russian Ruble","RWF":"Rwanda Franc","SHP":"Saint Helena Pound","WST":"Tala","STN":"Dobra","SAR":"Saudi Riyal","RSD":"Serbian Dinar","SCR":"Seychelles Rupee","SLE":"Leone","SGD":"Singapore Dollar","XSU":"Sucre","SBD":"Solomon Islands Dollar","SOS":"Somali Shilling","SSP":"South Sudanese Pound","LKR":"Sri Lanka Rupee","SDG":"Sudanese Pound","SRD":"Surinam Dollar","SEK":"Swedish Krona","CHE":"WIR Euro","CHW":"WIR Franc","SYP":"Syrian Pound","TWD":"New Taiwan Dollar","TJS":"Somoni","TZS":"Tanzanian Shilling","THB":"Baht","TOP":"Pa’anga","TTD":"Trinidad and Tobago Dollar","TND":"Tunisian Dinar","TRY":"Turkish Lira","TMT":"Turkmenistan New Manat","UGX":"Uganda Shilling","UAH":"Hryvnia","AED":"UAE Dirham","USN":"US Dollar (Next day)","UYU":"Peso Uruguayo","UYI":"Uruguay Peso en Unidades Indexadas (UI)","UYW":"Unidad Previsional","UZS":"Uzbekistan Sum","VUV":"Vatu","VES":"Bolívar Soberano","VED":"Bolívar Soberano","VND":"Dong","YER":"Yemeni Rial","ZMW":"Zambian Kwacha","ZWL":"Zimbabwe Dollar","XBA":"Bond Markets Unit European Composite Unit (EURCO)","XBB":"Bond Markets Unit European Monetary Unit (E.M.U.-6)","XBC":"Bond Markets Unit European Unit of Account 9 (E.U.A.-9)","XBD":"Bond Markets Unit European Unit of Account 17 (E.U.A.-17)","XTS":"Codes specifically reserved for testing purposes","XXX":"The codes assigned for transactions where no currency is involved","XAU":"Gold","XPD":"Palladium","XPT":"Platinum","XAG":"Silver"}`,
);

export function getSupportedCurrencyList() {
return Intl.supportedValuesOf('currency').map(code => ({
code,
name: currencyNames[code] as string,
}));
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/**
* Function to map currency codes to names using this list: https://www.six-group.com/dam/download/financial-information/data-center/iso-currrency/lists/list-one.xml
* Used to update the string in currencies.ts.
* Only works in the browser.
*
* To use, move the file out of the chunks folder
* @param xmlStr XML String with ISO 4217 data
*/
export const processCurrencyFile = (xmlStr: string): string => {
const parser = new DOMParser();
const xmlDoc = parser.parseFromString(xmlStr, 'text/xml');
const currencyNodes = xmlDoc.getElementsByTagName('CcyNtry');
const currencyMap = {};

for (let i = 0; i < currencyNodes.length; i++) {
const currencyNode = currencyNodes[i];
const code = currencyNode.getElementsByTagName('Ccy')[0]?.textContent;

if (!code) {
continue;
}

const currencyName =
currencyNode.getElementsByTagName('CcyNm')[0]?.textContent;
currencyMap[code] = currencyName;
}

return JSON.stringify(currencyMap);
};
46 changes: 8 additions & 38 deletions browser/data-browser/src/components/forms/AtomicSelectInput.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { Resource, useValue } from '@tomic/react';

import { InputWrapper } from './InputStyles';
import { styled } from 'styled-components';
import { BasicSelect } from './BasicSelect';

interface AtomicSelectInputProps {
resource: Resource;
Expand Down Expand Up @@ -33,41 +32,12 @@ export function AtomicSelectInput({
};

return (
<StyledInputWrapper>
<SelectWrapper disabled={!!props.disabled}>
<Select {...props} onChange={handleChange} value={value as string}>
{options.map(option => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</Select>
</SelectWrapper>
</StyledInputWrapper>
<BasicSelect {...props} onChange={handleChange} value={value as string}>
{options.map(option => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</BasicSelect>
);
}

const StyledInputWrapper = styled(InputWrapper)`
min-width: 15ch;
`;

const SelectWrapper = styled.span<{ disabled: boolean }>`
width: 100%;
padding-inline: 0.2rem;
background-color: ${p =>
p.disabled ? p.theme.colors.bg1 : p.theme.colors.bg};
`;

const Select = styled.select`
cursor: pointer;
width: 100%;
border: none;
outline: none;
height: 2rem;
background-color: transparent;
color: ${p => p.theme.colors.text};
&:disabled {
color: ${props => props.theme.colors.textLight};
background-color: transparent;
}
`;
43 changes: 43 additions & 0 deletions browser/data-browser/src/components/forms/BasicSelect.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { styled } from 'styled-components';
import { InputWrapper } from './InputStyles';
import { FC, PropsWithChildren } from 'react';

type Props = React.SelectHTMLAttributes<HTMLSelectElement>;

export const BasicSelect: FC<PropsWithChildren<Props>> = ({
children,
...props
}) => {
return (
<StyledInputWrapper>
<SelectWrapper disabled={!!props.disabled}>
<Select {...props}>{children}</Select>
</SelectWrapper>
</StyledInputWrapper>
);
};

const StyledInputWrapper = styled(InputWrapper)`
min-width: 15ch;
`;

const SelectWrapper = styled.span<{ disabled: boolean }>`
width: 100%;
padding-inline: 0.2rem;
background-color: ${p =>
p.disabled ? p.theme.colors.bg1 : p.theme.colors.bg};
`;

const Select = styled.select`
cursor: pointer;
width: 100%;
border: none;
outline: none;
height: 2rem;
background-color: transparent;
color: ${p => p.theme.colors.text};
&:disabled {
color: ${props => props.theme.colors.textLight};
background-color: transparent;
}
`;
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {
JSONValue,
dataBrowser,
urls,
useNumber,
useResource,
Expand Down Expand Up @@ -50,12 +51,18 @@ function FloatCellDisplay({
urls.properties.constraints.decimalPlaces,
);

const [currency] = useString(
propertyResource,
dataBrowser.properties.currency,
);

const isPercentage = numberFormatting === numberFormats.percentage;

const formattedValue = formatNumber(
value as number | undefined,
decimalPlaces,
numberFormatting,
currency,
);

return (
Expand Down
Original file line number Diff line number Diff line change
@@ -1,40 +1,59 @@
import { urls, useNumber, useStore, useString } from '@tomic/react';
import { useEffect } from 'react';
import {
core,
dataBrowser,
urls,
useNumber,
useStore,
useString,
} from '@tomic/react';
import { Suspense, lazy, useEffect } from 'react';
import { RadioGroup, RadioInput } from '../../../components/forms/RadioInput';
import { FormGroupHeading } from './FormGroupHeading';
import { DecimalPlacesInput } from './Inputs/DecimalPlacesInput';
import { TableRangeInput } from './Inputs/TableRangeInput';
import { PropertyCategoryFormProps } from './PropertyCategoryFormProps';

const { numberFormats } = urls.instances;
const CurrencyPicker = lazy(
() => import('../../../chunks/CurrencyPicker/CurrencyPicker'),
);

export const NumberPropertyForm = ({
resource,
}: PropertyCategoryFormProps): JSX.Element => {
const store = useStore();
const [numberFormatting, setNumberFormatting] = useString(
resource,
urls.properties.constraints.numberFormatting,
dataBrowser.properties.numberFormatting,
);

const [decimalPlaces] = useNumber(
resource,
urls.properties.constraints.decimalPlaces,
dataBrowser.properties.decimalPlaces,
);

const handleNumberFormatChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const [_, setDataType] = useString(resource, core.properties.datatype);

const handleNumberFormatChange = async (
e: React.ChangeEvent<HTMLInputElement>,
) => {
setNumberFormatting(e.target.value);

if (e.target.value === numberFormats.currency) {
await resource.addClasses(store, dataBrowser.classes.currencyProperty);
await setDataType(urls.datatypes.float);
} else {
await resource.removeClasses(store, dataBrowser.classes.currencyProperty);
resource.removePropVal(dataBrowser.properties.currency);
}
};

useEffect(() => {
resource.addClasses(
store,
urls.classes.constraintProperties.formattedNumber,
);
resource.addClasses(store, dataBrowser.classes.formattedNumber);

// If decimal places is not set yet we assume it is a new property and should default to float.
if (decimalPlaces === undefined) {
resource.set(urls.properties.datatype, urls.datatypes.float, store);
resource.set(core.properties.datatype, urls.datatypes.float, store);
}

if (numberFormatting === undefined) {
Expand All @@ -43,7 +62,7 @@ export const NumberPropertyForm = ({
}, []);

return (
<>
<Suspense fallback={<div>loading...</div>}>
<FormGroupHeading>Number Format</FormGroupHeading>
<RadioGroup>
<RadioInput
Expand All @@ -62,15 +81,27 @@ export const NumberPropertyForm = ({
>
Percentage
</RadioInput>
<RadioInput
name='number-format'
value={numberFormats.currency}
checked={numberFormatting === numberFormats.currency}
onChange={handleNumberFormatChange}
>
Currency
</RadioInput>
</RadioGroup>
<DecimalPlacesInput resource={resource} />
{resource.hasClasses(dataBrowser.classes.currencyProperty) ? (
<CurrencyPicker resource={resource} />
) : (
<DecimalPlacesInput resource={resource} />
)}
<FormGroupHeading>Range</FormGroupHeading>
<TableRangeInput
resource={resource}
minProp={urls.properties.constraints.min}
maxProp={urls.properties.constraints.max}
constraintClass={urls.classes.constraintProperties.rangeProperty}
minProp={dataBrowser.properties.min}
maxProp={dataBrowser.properties.max}
constraintClass={dataBrowser.classes.rangeProperty}
/>
</>
</Suspense>
);
};
19 changes: 18 additions & 1 deletion browser/data-browser/src/views/TablePage/helpers/formatNumber.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ import { urls } from '@tomic/react';
export function formatNumber(
value: number | undefined,
fractionDigits: number | undefined,
formatting?: string,
formatting: string | undefined,
currency?: string,
): string {
if (value === undefined) {
return '';
Expand All @@ -21,6 +22,22 @@ export function formatNumber(
return formatter.format(value / 100);
}

if (formatting === urls.instances.numberFormats.currency) {
try {
const formatter = new Intl.NumberFormat('default', {
style: 'currency',
currency,
currencyDisplay: 'narrowSymbol',
});

return formatter.format(value);
} catch (e) {
console.error(e);

return value.toString();
}
}

const formatter = new Intl.NumberFormat('default', {
style: 'decimal',
minimumFractionDigits: fixedFractionDigits,
Expand Down
Loading

0 comments on commit 906f96f

Please sign in to comment.