Skip to content

Commit 693ca41

Browse files
committed
feat: automate provisioning of available currency codes
closes #122
1 parent efdaca3 commit 693ca41

File tree

10 files changed

+128
-69
lines changed

10 files changed

+128
-69
lines changed

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,8 +57,9 @@
5757
"eslint-plugin-prefer-arrow-functions": "3.6.2",
5858
"eslint-plugin-prettier": "5.4.0",
5959
"eslint-plugin-vue": "10.1.0",
60+
"fast-xml-parser": "5.3.0",
6061
"html-minifier-terser": "7.2.0",
61-
"husky": "9.1.7",
62+
"husky": "^8.0.0",
6263
"prettier": "3.5.3",
6364
"sass": "1.89.0",
6465
"typescript": "5.8.3",

pnpm-lock.yaml

Lines changed: 16 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

scripts/update-currencies.mjs

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { XMLParser } from 'fast-xml-parser';
2+
import fs from 'fs/promises';
3+
4+
const target = '../src/composables/available-currency-codes/currencies.json';
5+
const source =
6+
'https://www.six-group.com/dam/download/financial-information/data-center/iso-currrency/lists/list-one.xml';
7+
8+
const response = await fetch(source).then((res) => res.text());
9+
const parsed = new XMLParser({ ignoreAttributes: false }).parse(response);
10+
const list = parsed['ISO_4217']['CcyTbl']['CcyNtry'];
11+
12+
const currencies = new Set(
13+
list
14+
.filter((v) => v.Ccy)
15+
.filter((v) => typeof v.CcyNm === 'string' || !('@_IsFund' in v.CcyNm)) // Filter out funds
16+
.filter((v) => !v.Ccy.startsWith('X')) // Filter out other funds and precious metals
17+
.filter((v) => !v.Ccy.match(/bond|market|unit/i)) // Filter out special purpose codes
18+
.map((v) => v.Ccy)
19+
);
20+
21+
// Remove special currency
22+
currencies.delete('XXX');
23+
24+
const sortedList = Array.from(currencies).sort();
25+
await fs.writeFile(target, JSON.stringify(sortedList) + '\n', 'utf-8');

src/app/components/base/context-menu/ContextMenu.vue

Lines changed: 34 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -3,22 +3,26 @@
33
<slot :toggle="toggle" />
44
</div>
55
<div ref="popper" :class="[$style.popper, { [$style.visible]: visible }]">
6-
<ul :class="listClasses">
7-
<slot v-if="$slots.options" name="options" />
8-
<template v-else-if="options">
9-
<ContextMenuButton
10-
v-for="option of options"
11-
:key="option.id"
12-
:testId="`${testId}-${option.id}`"
13-
:padIcon="hasOptionWithIcon"
14-
:text="option.label ?? option.id"
15-
:icon="option.icon"
16-
:muted="option.muted"
17-
:highlight="option.id === highlight"
18-
@click="select(option)"
19-
/>
20-
</template>
21-
</ul>
6+
<div :class="listClasses">
7+
<slot name="header" />
8+
9+
<ul :class="$style.options">
10+
<slot v-if="$slots.options" name="options" />
11+
<template v-else-if="options">
12+
<ContextMenuButton
13+
v-for="option of options"
14+
:key="option.id"
15+
:testId="`${testId}-${option.id}`"
16+
:padIcon="hasOptionWithIcon"
17+
:text="option.label ?? option.id"
18+
:icon="option.icon"
19+
:muted="option.muted"
20+
:highlight="option.id === highlight"
21+
@click="select(option)"
22+
/>
23+
</template>
24+
</ul>
25+
</div>
2226
</div>
2327
</template>
2428

@@ -36,7 +40,9 @@ import { ClassNames } from '@utils/types.ts';
3640
import { computed, provide, ref, useCssModule, watch } from 'vue';
3741
3842
const emit = defineEmits<{
39-
(e: 'select', option: ContextMenuOption): void;
43+
select: [option: ContextMenuOption];
44+
open: [];
45+
close: [];
4046
}>();
4147
4248
const props = withDefaults(
@@ -99,6 +105,8 @@ const select = (option: ContextMenuOption): void => {
99105
100106
const toggle = () => requestAnimationFrame(() => (visible.value = !visible.value));
101107
108+
watch(visible, (value) => (value ? emit('open') : emit('close')));
109+
102110
provide<ContextMenuStore>(ContextMenuStoreKey, {
103111
close: () => requestAnimationFrame(() => (visible.value = false))
104112
});
@@ -129,17 +137,13 @@ provide<ContextMenuStore>(ContextMenuStoreKey, {
129137
}
130138
131139
.list {
132-
list-style: none outside none;
133140
display: flex;
134141
flex-direction: column;
135-
align-items: flex-end;
136142
-webkit-backdrop-filter: var(--context-menu-backdrop);
137143
backdrop-filter: var(--context-menu-backdrop);
138144
box-shadow: var(--context-menu-shadow);
139145
border-radius: var(--border-radius-m);
140146
padding: 6px 0;
141-
max-height: 130px;
142-
overflow: auto;
143147
visibility: hidden;
144148
opacity: 0;
145149
transition: all var(--transition-s);
@@ -164,4 +168,13 @@ provide<ContextMenuStore>(ContextMenuStoreKey, {
164168
transform: translateX(-6px);
165169
}
166170
}
171+
172+
.options {
173+
display: flex;
174+
flex-direction: column;
175+
align-items: flex-end;
176+
list-style: none outside none;
177+
max-height: 130px;
178+
overflow: auto;
179+
}
167180
</style>

src/app/components/base/select/Select.vue

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,15 @@
55
<ContextMenu
66
:testId="testId"
77
tooltipPosition="bottom"
8-
:options="options"
8+
:options="availableOptions"
99
:offset="[0, 4]"
1010
position="bottom-start"
11+
@open="focusInput"
1112
@select="modelValue = $event.id"
1213
>
14+
<template v-if="searchable" #header>
15+
<input ref="input" v-model.trim="searchQuery" placeholder="Search..." :class="$style.searchField" />
16+
</template>
1317
<template #default="{ toggle }">
1418
<button :id="fieldId" :data-testid="testId" :class="$style.btn" type="button" @click="toggle">
1519
{{ currentValue }}
@@ -23,20 +27,37 @@
2327
import { ContextMenuOption, ContextMenuOptionId } from '@components/base/context-menu/ContextMenu.types.ts';
2428
import ContextMenu from '@components/base/context-menu/ContextMenu.vue';
2529
import { uuid } from '@utils/uuid.ts';
26-
import { computed } from 'vue';
30+
import { computed, nextTick, ref, useTemplateRef, watch } from 'vue';
2731
2832
const modelValue = defineModel<ContextMenuOptionId>();
2933
3034
const props = defineProps<{
3135
label: string;
3236
options?: ContextMenuOption[];
3337
testId?: string;
38+
searchable?: boolean;
3439
}>();
3540
3641
const fieldId = uuid();
42+
const input = useTemplateRef('input');
43+
const searchQuery = ref('');
44+
45+
const availableOptions = computed(() => {
46+
const lowerSearch = searchQuery.value.toLowerCase();
47+
return props.options?.filter((option) => option.label?.toLowerCase().includes(lowerSearch));
48+
});
49+
3750
const currentValue = computed(
3851
() => props.options?.find((option) => option.id === modelValue.value)?.label ?? 'Select...'
3952
);
53+
54+
const focusInput = () => {
55+
nextTick(() => input.value?.focus());
56+
};
57+
58+
watch(modelValue, () => {
59+
searchQuery.value = '';
60+
});
4061
</script>
4162

4263
<style lang="scss" module>
@@ -76,4 +97,11 @@ const currentValue = computed(
7697
background: var(--input-field-focus-background);
7798
}
7899
}
100+
101+
.searchField {
102+
all: unset;
103+
font-size: var(--font-size-xs);
104+
padding: 3px 12px 8px;
105+
border-bottom: 2px solid var(--input-field-border);
106+
}
79107
</style>

src/app/pages/navigation/settings/SettingsDialog.vue

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@
1414
testId="change-currency"
1515
:label="t('navigation.settings.currency')"
1616
:options="currencies"
17-
@update:model-value="changeCurrency($event as AvailableCurrency)"
17+
searchable
18+
@update:model-value="changeCurrency($event)"
1819
/>
1920

2021
<Select
@@ -47,12 +48,12 @@ import CheckBox from '@components/base/check-box/CheckBox.vue';
4748
import { ContextMenuOption } from '@components/base/context-menu/ContextMenu.types.ts';
4849
import Dialog from '@components/base/dialog/Dialog.vue';
4950
import Select from '@components/base/select/Select.vue';
51+
import { useAvailableCurrencyCodes } from '@composables/available-currency-codes/useAvailableCurrencyCodes.ts';
5052
import { useMonthNames } from '@composables/useMonthNames.ts';
5153
import { AvailableLocale, availableLocales, initialLocale } from '@i18n/index.ts';
5254
import { RiCheckLine } from '@remixicon/vue';
5355
import { useSettingsStore } from '@store/settings';
5456
import { useDataStore } from '@store/state';
55-
import { availableCurrencies, AvailableCurrency } from '@store/state/types.ts';
5657
import { computed } from 'vue';
5758
import { useI18n } from 'vue-i18n';
5859
@@ -67,6 +68,7 @@ defineProps<{
6768
const { t, locale } = useI18n();
6869
const { setMonthOffset, setCarryOver, setAnimations, state: settings } = useSettingsStore();
6970
const { changeCurrency, changeLocale, state } = useDataStore();
71+
const currencyCodes = useAvailableCurrencyCodes(() => [state.currency]);
7072
const monthNames = useMonthNames();
7173
7274
const locales = computed<ContextMenuOption[]>(() => {
@@ -80,10 +82,10 @@ const locales = computed<ContextMenuOption[]>(() => {
8082
});
8183
8284
const currencies = computed<ContextMenuOption[]>(() =>
83-
availableCurrencies.map((value) => ({
85+
currencyCodes.value.map((value) => ({
8486
id: value,
8587
icon: state.currency === value ? RiCheckLine : undefined,
86-
label: `${formatNumber(locale.value, value, 'name')} (${formatNumber(locale.value, value)})`
88+
label: `${formatNumber(locale.value, value, 'name')} ${value} (${formatNumber(locale.value, value)})`
8789
}))
8890
);
8991
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
["AED","AFN","ALL","AMD","AOA","ARS","AUD","AWG","AZN","BAM","BBD","BDT","BGN","BHD","BIF","BMD","BND","BOB","BRL","BSD","BTN","BWP","BYN","BZD","CAD","CDF","CHF","CLP","CNY","COP","CRC","CUP","CVE","CZK","DJF","DKK","DOP","DZD","EGP","ERN","ETB","EUR","FJD","FKP","GBP","GEL","GHS","GIP","GMD","GNF","GTQ","GYD","HKD","HNL","HTG","HUF","IDR","ILS","INR","IQD","IRR","ISK","JMD","JOD","JPY","KES","KGS","KHR","KMF","KPW","KRW","KWD","KYD","KZT","LAK","LBP","LKR","LRD","LSL","LYD","MAD","MDL","MGA","MKD","MMK","MNT","MOP","MRU","MUR","MVR","MWK","MXN","MYR","MZN","NAD","NGN","NIO","NOK","NPR","NZD","OMR","PAB","PEN","PGK","PHP","PKR","PLN","PYG","QAR","RON","RSD","RUB","RWF","SAR","SBD","SCR","SDG","SEK","SGD","SHP","SLE","SOS","SRD","SSP","STN","SVC","SYP","SZL","THB","TJS","TMT","TND","TOP","TRY","TTD","TWD","TZS","UAH","UGX","USD","UYU","UYW","UZS","VED","VES","VND","VUV","WST","YER","ZAR","ZMW","ZWG"]
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import codes from './currencies.json' assert { type: 'json' };
2+
import { computed, MaybeRefOrGetter, toValue } from 'vue';
3+
4+
const availableCurrencies = new Set(codes);
5+
const supportedCurrenciesByBrowser = new Set(Intl.supportedValuesOf('currency'));
6+
const currencies = availableCurrencies.intersection(supportedCurrenciesByBrowser);
7+
8+
export const useAvailableCurrencyCodes = (include?: MaybeRefOrGetter<string[]>) =>
9+
computed(() => Array.from(currencies.union(new Set(toValue(include) ?? []))).sort());

src/store/state/index.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { migrateApplicationState } from './migrator';
2-
import { AvailableCurrency, Budget, BudgetGroup, BudgetYear, DataState, DataStates, DataStateV1 } from './types';
2+
import { Budget, BudgetGroup, BudgetYear, DataState, DataStates, DataStateV1 } from './types';
33
import { generateBudgetYearFromCurrent } from './utils/generators.ts';
44
import { useTime } from '@composables/useTime.ts';
55
import { AvailableLocale, changeLocale } from '@i18n/index';
@@ -22,7 +22,7 @@ interface StoredClipboardData {
2222
type StoreView = Omit<BudgetYear, 'year'> & {
2323
clipboard: StoredClipboardData | undefined;
2424
activeYear: number;
25-
currency: AvailableCurrency;
25+
currency: string;
2626
locale: AvailableLocale;
2727
years: BudgetYear[];
2828
overallBalance: number | undefined;
@@ -42,7 +42,7 @@ export const createDataStore = (storage?: Storage) => {
4242
};
4343

4444
watch(
45-
() => [state.locale, state.currency] as [AvailableLocale, AvailableCurrency],
45+
() => [state.locale, state.currency] as [AvailableLocale, string],
4646
([locale, currency]) => changeLocale(locale, { currency }),
4747
{ immediate: true }
4848
);
@@ -144,7 +144,7 @@ export const createDataStore = (storage?: Storage) => {
144144
state.locale = locale;
145145
},
146146

147-
changeCurrency: (currency: AvailableCurrency) => {
147+
changeCurrency: (currency: string) => {
148148
state.currency = currency;
149149
},
150150

src/store/state/types.ts

Lines changed: 1 addition & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -3,42 +3,6 @@ import { MigratableState } from 'yuppee';
33

44
export type BudgetValues = number[];
55

6-
export const availableCurrencies = [
7-
'USD',
8-
'EUR',
9-
'JPY',
10-
'GBP',
11-
'CZK',
12-
'AUD',
13-
'CAD',
14-
'CHF',
15-
'CNY',
16-
'DKK',
17-
'ILS',
18-
'SEK',
19-
'NZD',
20-
'MXN',
21-
'CLP',
22-
'UYU',
23-
'SGD',
24-
'HKD',
25-
'NOK',
26-
'KRW',
27-
'TRY',
28-
'INR',
29-
'RUB',
30-
'BRL',
31-
'ZAR',
32-
'HUF',
33-
'PLN',
34-
'CRC',
35-
'IDR',
36-
'RON',
37-
'MDL'
38-
] as const;
39-
40-
export type AvailableCurrency = (typeof availableCurrencies)[number];
41-
426
export interface Budget {
437
id: string;
448
name: string;
@@ -69,7 +33,7 @@ export interface DataStateV2 extends MigratableState<2> {
6933
export interface DataStateV3 extends MigratableState<3> {
7034
years: BudgetYear[];
7135
locale: AvailableLocale;
72-
currency: AvailableCurrency;
36+
currency: string;
7337
}
7438

7539
// Latest structure

0 commit comments

Comments
 (0)