Skip to content
Draft
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
12 changes: 7 additions & 5 deletions src/components/AddressForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,14 +29,16 @@ type CountryZipRegex = {
samples?: string;
};

const countryZipRegexData: Partial<Record<string, CountryZipRegex>> = COMMON_CONST.COUNTRY_ZIP_REGEX_DATA;

type AddressFormID = typeof ONYXKEYS.FORMS.HOME_ADDRESS_FORM | typeof ONYXKEYS.FORMS.WORKSPACE_OFFICE_LOCATION_FORM;

type AddressFormProps<TFormID extends AddressFormID> = {
/** Address city field */
city?: string;

/** Address country field */
country?: Country | '';
/** Raw default or saved country. User selections and submitted values retain the finite form model. */
country?: string;

/** Address state field */
state?: string;
Expand Down Expand Up @@ -98,7 +100,7 @@ type AddressFormProps<TFormID extends AddressFormID> = {
/** The values the address inputs start from and report changes to, shared by AddressForm with its AddressForm.Fields */
type AddressFieldsSettings = {
city: string;
country: Country | '';
country: string;
state: string;
street1: string;
street2: string;
Expand Down Expand Up @@ -126,7 +128,7 @@ function AddressFormFields() {
const {translate} = useLocalize();
const {city, country, state, street1, street2, zip, onAddressChanged, shouldSaveDraft, shouldHideCountrySelector} = useContext(AddressFieldsContext);

const zipSampleFormat = (country && (COMMON_CONST.COUNTRY_ZIP_REGEX_DATA[country] as CountryZipRegex)?.samples) ?? '';
const zipSampleFormat = (country && countryZipRegexData[country]?.samples) ?? '';

const zipFormat = translate('common.zipCodeExampleFormat', zipSampleFormat);

Expand Down Expand Up @@ -313,7 +315,7 @@ function AddressForm<TFormID extends AddressFormID>({
}

// If no country is selected, default value is an empty string and there's no related regex data so we default to an empty object
const countryRegexDetails = (values.country ? COMMON_CONST.COUNTRY_ZIP_REGEX_DATA?.[values.country] : {}) as CountryZipRegex;
const countryRegexDetails = values.country ? countryZipRegexData?.[values.country] : {};

// The postal code system might not exist for a country, so no regex either for them.
const countrySpecificZipRegex = countryRegexDetails?.regex;
Expand Down
9 changes: 5 additions & 4 deletions src/components/CountrySelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@ import useGeographicalStateAndCountryFromRoute from '@hooks/useGeographicalState
import useLocalize from '@hooks/useLocalize';
import useThemeStyles from '@hooks/useThemeStyles';

import {translateCountry} from '@libs/Localize';
import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute';
import Navigation from '@libs/Navigation/Navigation';

import CONST from '@src/CONST';
import type {Country} from '@src/CONST';
import {DYNAMIC_ROUTES} from '@src/ROUTES';

import type {ComponentRef, ForwardedRef} from 'react';
Expand All @@ -24,7 +24,8 @@ type CountrySelectorProps = {
/** Callback called when the country changes. */
onInputChange?: (value?: string) => void;

value?: Country | '';
/** Persisted defaults may contain country names or unknown strings before a finite route selection. */
value?: string;

/** inputID used by the Form component */
// eslint-disable-next-line react/no-unused-prop-types
Expand All @@ -38,10 +39,10 @@ type CountrySelectorProps = {

function CountrySelector({errorText = '', value: countryCode, onInputChange = () => {}, onBlur, ref}: CountrySelectorProps) {
const styles = useThemeStyles();
const {translate} = useLocalize();
const {translate, preferredLocale} = useLocalize();
const {country: countryFromUrl} = useGeographicalStateAndCountryFromRoute();

const title = countryCode ? translate(`allCountries.${countryCode}`) : '';
const title = countryCode ? translateCountry(preferredLocale, countryCode) : '';
const countryTitleDescStyle = title.length === 0 ? styles.textNormal : null;

const didOpenCountrySelector = useRef(false);
Expand Down
6 changes: 3 additions & 3 deletions src/components/Search/SearchChartView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import type {ChartView, GroupedItem, SearchGroupBy, SearchQueryJSON} from './typ

import {buildChartSeries, isChartAverage} from './buildChartSeries';
import {buildChartDrillDownQuery} from './chartDrillDown';
import CHART_GROUP_BY_CONFIG from './chartGroupByConfig';
import CHART_GROUP_BY_CONFIG, {getChartFilterQuery} from './chartGroupByConfig';
import getInProgressBucketLabel from './getInProgressBucketLabel';
import isPeriodAfterToday, {isEmptyPeriodAfterToday} from './isPeriodAfterToday';
import applyRunningTotal from './runningTotal';
Expand Down Expand Up @@ -95,7 +95,7 @@ function SearchChartView({
const {getCurrencySymbol, getCurrencyDecimals} = useCurrencyListActions();
const {currentSearchKey} = useSearchQueryContext();

const {getLabel, getShortLabel, getLineChartLabel, getFilterQuery, getDateRange, axisLabelSteps} = CHART_GROUP_BY_CONFIG[groupBy];
const {getLabel, getShortLabel, getLineChartLabel, getDateRange, axisLabelSteps} = CHART_GROUP_BY_CONFIG[groupBy];

const today = format(new Date(), CONST.DATE.FNS_FORMAT_STRING);
const dateFilterRange = queryJSON ? getDateFilterRange(queryJSON) : {};
Expand Down Expand Up @@ -133,7 +133,7 @@ function SearchChartView({
return;
}

const query = buildChartDrillDownQuery(queryJSON, getFilterQuery(item));
const query = buildChartDrillDownQuery(queryJSON, getChartFilterQuery(item, groupBy));

if (!query) {
return;
Expand Down
101 changes: 57 additions & 44 deletions src/components/Search/chartGroupByConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,24 +2,12 @@ import DateUtils from '@libs/DateUtils';

import CONST from '@src/CONST';

import type {
TransactionCardGroupListItemType,
TransactionCategoryGroupListItemType,
TransactionMemberGroupListItemType,
TransactionMerchantGroupListItemType,
TransactionMonthGroupListItemType,
TransactionQuarterGroupListItemType,
TransactionTagGroupListItemType,
TransactionWeekGroupListItemType,
TransactionWithdrawalIDGroupListItemType,
TransactionYearGroupListItemType,
} from './SearchList/ListItem/types';
import type {GroupedItem, SearchGroupBy} from './types';

/** The dates a time bucket covers */
type ChartBucketRange = {start: string; end: string};

type ChartGroupByConfig = {
type ChartGroupByConfig<TFilterItem extends GroupedItem = GroupedItem> = {
/** Name of the icon rendered next to the chart title */
titleIconName: 'Users' | 'CreditCard' | 'Send' | 'Folder' | 'Basket' | 'Tag' | 'Calendar';

Expand All @@ -36,47 +24,56 @@ type ChartGroupByConfig = {
axisLabelSteps?: readonly number[];

/** Builds the query fragment appended to the current query to drill into a group's transactions */
getFilterQuery: (item: GroupedItem) => string;
getFilterQuery: (item: TFilterItem) => string;

/** Returns the dates a time-based group covers, as yyyy-MM-dd strings */
getDateRange?: (item: GroupedItem) => ChartBucketRange | undefined;
};

type ChartFilterItem<TGroupBy extends SearchGroupBy> = TGroupBy extends typeof CONST.SEARCH.GROUP_BY.WEEK
? Extract<GroupedItem, {week: string}>
: TGroupBy extends typeof CONST.SEARCH.GROUP_BY.MONTH
? Extract<GroupedItem, {month: number; year: number}>
: TGroupBy extends typeof CONST.SEARCH.GROUP_BY.QUARTER
? Extract<GroupedItem, {quarter: number; year: number}>
: TGroupBy extends typeof CONST.SEARCH.GROUP_BY.YEAR
? Extract<GroupedItem, {year: number}>
: GroupedItem;

/**
* Chart-specific configuration for each groupBy type - defines how to extract label and build filter query
* for displaying grouped transaction data in charts.
*/
const CHART_GROUP_BY_CONFIG: Record<SearchGroupBy, ChartGroupByConfig> = {
const CHART_GROUP_BY_CONFIG: {[TGroupBy in SearchGroupBy]: ChartGroupByConfig<ChartFilterItem<TGroupBy>>} = {
[CONST.SEARCH.GROUP_BY.FROM]: {
titleIconName: 'Users',
getLabel: (item: GroupedItem) => (item as TransactionMemberGroupListItemType).formattedFrom ?? '',
getFilterQuery: (item: GroupedItem) => `from:${(item as TransactionMemberGroupListItemType).accountID}`,
getLabel: (item: GroupedItem) => ('formattedFrom' in item ? item.formattedFrom : undefined) ?? '',
getFilterQuery: (item: GroupedItem) => `from:${'accountID' in item ? item.accountID : undefined}`,
},
[CONST.SEARCH.GROUP_BY.CARD]: {
titleIconName: 'CreditCard',
getLabel: (item: GroupedItem) => (item as TransactionCardGroupListItemType).formattedCardName ?? '',
getFilterQuery: (item: GroupedItem) => `cardID:${(item as TransactionCardGroupListItemType).cardID}`,
getLabel: (item: GroupedItem) => ('formattedCardName' in item ? item.formattedCardName : undefined) ?? '',
getFilterQuery: (item: GroupedItem) => `cardID:${'cardID' in item ? item.cardID : undefined}`,
},
[CONST.SEARCH.GROUP_BY.WITHDRAWAL_ID]: {
titleIconName: 'Send',
// eslint-disable-next-line rulesdir/no-default-id-values -- formattedWithdrawalID is a display label, not an Onyx ID
getLabel: (item: GroupedItem) => (item as TransactionWithdrawalIDGroupListItemType).formattedWithdrawalID ?? '',
getFilterQuery: (item: GroupedItem) => `withdrawalID:${(item as TransactionWithdrawalIDGroupListItemType).entryID}`,
getLabel: (item: GroupedItem) => ('formattedWithdrawalID' in item ? item.formattedWithdrawalID : undefined) ?? '',
getFilterQuery: (item: GroupedItem) => `withdrawalID:${'entryID' in item ? item.entryID : undefined}`,
},
[CONST.SEARCH.GROUP_BY.CATEGORY]: {
titleIconName: 'Folder',
getLabel: (item: GroupedItem) => (item as TransactionCategoryGroupListItemType).formattedCategory ?? '',
getFilterQuery: (item: GroupedItem) => `category:"${(item as TransactionCategoryGroupListItemType).category}"`,
getLabel: (item: GroupedItem) => ('formattedCategory' in item ? item.formattedCategory : undefined) ?? '',
getFilterQuery: (item: GroupedItem) => `category:"${'category' in item ? item.category : undefined}"`,
},
[CONST.SEARCH.GROUP_BY.MERCHANT]: {
titleIconName: 'Basket',
getLabel: (item: GroupedItem) => (item as TransactionMerchantGroupListItemType).formattedMerchant ?? '',
getFilterQuery: (item: GroupedItem) => `merchant="${(item as TransactionMerchantGroupListItemType).merchant}"`,
getLabel: (item: GroupedItem) => ('formattedMerchant' in item ? item.formattedMerchant : undefined) ?? '',
getFilterQuery: (item: GroupedItem) => `merchant="${'merchant' in item ? item.merchant : undefined}"`,
},
[CONST.SEARCH.GROUP_BY.TAG]: {
titleIconName: 'Tag',
getLabel: (item: GroupedItem) => (item as TransactionTagGroupListItemType).formattedTag ?? '',
getFilterQuery: (item: GroupedItem) => `tag:"${(item as TransactionTagGroupListItemType).tag}"`,
getLabel: (item: GroupedItem) => ('formattedTag' in item ? item.formattedTag : undefined) ?? '',
getFilterQuery: (item: GroupedItem) => `tag:"${'tag' in item ? item.tag : undefined}"`,
},
[CONST.SEARCH.GROUP_BY.DAY]: {
titleIconName: 'Calendar',
Expand All @@ -89,48 +86,44 @@ const CHART_GROUP_BY_CONFIG: Record<SearchGroupBy, ChartGroupByConfig> = {
},
[CONST.SEARCH.GROUP_BY.MONTH]: {
titleIconName: 'Calendar',
getLabel: (item: GroupedItem) => (item as TransactionMonthGroupListItemType).formattedMonth ?? '',
getLabel: (item: GroupedItem) => ('formattedMonth' in item ? item.formattedMonth : undefined) ?? '',
getShortLabel: (item: GroupedItem) => (item.groupedBy === CONST.SEARCH.GROUP_BY.MONTH ? item.shortFormattedMonth : undefined),
axisLabelSteps: [1, 2, 3, 6],
getFilterQuery: (item: GroupedItem) => {
const monthItem = item as TransactionMonthGroupListItemType;
const {start, end} = DateUtils.getMonthDateRange(monthItem.year, monthItem.month);
getFilterQuery: (item: Extract<GroupedItem, {month: number; year: number}>) => {
const {start, end} = DateUtils.getMonthDateRange(item.year, item.month);
return `date>=${start} date<=${end}`;
},
getDateRange: (item: GroupedItem) => (item.groupedBy === CONST.SEARCH.GROUP_BY.MONTH ? DateUtils.getMonthDateRange(item.year, item.month) : undefined),
},
[CONST.SEARCH.GROUP_BY.WEEK]: {
titleIconName: 'Calendar',
getLabel: (item: GroupedItem) => (item as TransactionWeekGroupListItemType).formattedWeek ?? '',
getLabel: (item: GroupedItem) => ('formattedWeek' in item ? item.formattedWeek : undefined) ?? '',
getShortLabel: (item: GroupedItem) => (item.groupedBy === CONST.SEARCH.GROUP_BY.WEEK ? item.shortFormattedWeek : undefined),
getLineChartLabel: (item: GroupedItem) => (item.groupedBy === CONST.SEARCH.GROUP_BY.WEEK ? item.lineChartFormattedWeek : undefined),
axisLabelSteps: [1, 2, 4],
getFilterQuery: (item: GroupedItem) => {
const weekItem = item as TransactionWeekGroupListItemType;
const {start, end} = DateUtils.getWeekDateRange(weekItem.week);
getFilterQuery: (item: Extract<GroupedItem, {week: string}>) => {
const {start, end} = DateUtils.getWeekDateRange(item.week);
return `date>=${start} date<=${end}`;
},
getDateRange: (item: GroupedItem) => (item.groupedBy === CONST.SEARCH.GROUP_BY.WEEK ? DateUtils.getWeekDateRange(item.week) : undefined),
},
[CONST.SEARCH.GROUP_BY.YEAR]: {
titleIconName: 'Calendar',
getLabel: (item: GroupedItem) => (item as TransactionYearGroupListItemType).formattedYear ?? '',
getLabel: (item: GroupedItem) => ('formattedYear' in item ? item.formattedYear : undefined) ?? '',
axisLabelSteps: [1, 2, 5],
getFilterQuery: (item: GroupedItem) => {
const yearItem = item as TransactionYearGroupListItemType;
const {start, end} = DateUtils.getYearDateRange(yearItem.year);
getFilterQuery: (item: Extract<GroupedItem, {year: number}>) => {
const {start, end} = DateUtils.getYearDateRange(item.year);
return `date>=${start} date<=${end}`;
},
getDateRange: (item: GroupedItem) => (item.groupedBy === CONST.SEARCH.GROUP_BY.YEAR ? DateUtils.getYearDateRange(item.year) : undefined),
},
[CONST.SEARCH.GROUP_BY.QUARTER]: {
titleIconName: 'Calendar',
getLabel: (item: GroupedItem) => (item as TransactionQuarterGroupListItemType).formattedQuarter ?? '',
getLabel: (item: GroupedItem) => ('formattedQuarter' in item ? item.formattedQuarter : undefined) ?? '',
getShortLabel: (item: GroupedItem) => (item.groupedBy === CONST.SEARCH.GROUP_BY.QUARTER ? item.shortFormattedQuarter : undefined),
axisLabelSteps: [1, 2, 4],
getFilterQuery: (item: GroupedItem) => {
const quarterItem = item as TransactionQuarterGroupListItemType;
const {start, end} = DateUtils.getQuarterDateRange(quarterItem.year, quarterItem.quarter);
getFilterQuery: (item: Extract<GroupedItem, {quarter: number; year: number}>) => {
const {start, end} = DateUtils.getQuarterDateRange(item.year, item.quarter);
return `date>=${start} date<=${end}`;
},
getDateRange: (item: GroupedItem) => (item.groupedBy === CONST.SEARCH.GROUP_BY.QUARTER ? DateUtils.getQuarterDateRange(item.year, item.quarter) : undefined),
Expand All @@ -142,5 +135,25 @@ const CHART_GROUP_BY_CONFIG: Record<SearchGroupBy, ChartGroupByConfig> = {
},
};

/**
* Dispatches on the chart's grouping, because a row's own `groupedBy` can differ: expense report rows keep theirs in any chart.
* A time chart filters only its own rows, so any other row has no filter.
*/
function getChartFilterQuery(item: GroupedItem, groupBy: SearchGroupBy): string {
switch (groupBy) {
case CONST.SEARCH.GROUP_BY.WEEK:
return item.groupedBy === CONST.SEARCH.GROUP_BY.WEEK ? CHART_GROUP_BY_CONFIG[CONST.SEARCH.GROUP_BY.WEEK].getFilterQuery(item) : '';
case CONST.SEARCH.GROUP_BY.MONTH:
return item.groupedBy === CONST.SEARCH.GROUP_BY.MONTH ? CHART_GROUP_BY_CONFIG[CONST.SEARCH.GROUP_BY.MONTH].getFilterQuery(item) : '';
case CONST.SEARCH.GROUP_BY.QUARTER:
return item.groupedBy === CONST.SEARCH.GROUP_BY.QUARTER ? CHART_GROUP_BY_CONFIG[CONST.SEARCH.GROUP_BY.QUARTER].getFilterQuery(item) : '';
case CONST.SEARCH.GROUP_BY.YEAR:
return item.groupedBy === CONST.SEARCH.GROUP_BY.YEAR ? CHART_GROUP_BY_CONFIG[CONST.SEARCH.GROUP_BY.YEAR].getFilterQuery(item) : '';
default:
return CHART_GROUP_BY_CONFIG[groupBy].getFilterQuery(item);
}
}

export default CHART_GROUP_BY_CONFIG;
export {getChartFilterQuery};
export type {ChartBucketRange};
8 changes: 6 additions & 2 deletions src/languages/IntlStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -278,12 +278,16 @@ class IntlStore {
});
}

public static get<TPath extends TranslationPaths>(key: TPath, locale?: Locale) {
public static get<TPath extends TranslationPaths>(key: TPath, locale?: Locale): FlatTranslationsObject[TPath] | null;

public static get(key: TranslationPaths | `allCountries.${string}`, locale?: Locale): FlatTranslationsObject[TranslationPaths] | null;

public static get(key: TranslationPaths | `allCountries.${string}`, locale?: Locale): FlatTranslationsObject[TranslationPaths] | null {
const localeToUse = locale && this.cache.has(locale) ? locale : this.currentLocale;
if (!localeToUse) {
return null;
}
const translations = this.cache.get(localeToUse);
const translations: (FlatTranslationsObject & Partial<Record<`allCountries.${string}`, string>>) | undefined = this.cache.get(localeToUse);
return translations?.[key] ?? null;
}
}
Expand Down
23 changes: 18 additions & 5 deletions src/libs/CountryUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,21 @@ import type {Address} from '@src/types/onyx/PrivatePersonalDetails';

import {CONST as COMMON_CONST} from 'expensify-common';

import {getObjectKeys} from './ObjectUtils';

type AddressType = PersonalDetailsForm | Address | undefined;

/**
* Normalizes the address containing a country field by converting country names to country codes.
* Handles the case where old data has "United States" instead of "US".
*/
function normalizeCountryCode(data: PersonalDetailsForm): PersonalDetailsForm;
function normalizeCountryCode(data: Address): Address;
function normalizeCountryCode(data: undefined): undefined;
function normalizeCountryCode(data: PersonalDetailsForm | undefined): PersonalDetailsForm | undefined;
function normalizeCountryCode(data: Address | undefined): Address | undefined;
function normalizeCountryCode(data: PersonalDetailsForm | Address): PersonalDetailsForm | Address;
function normalizeCountryCode(data: AddressType): AddressType;
function normalizeCountryCode(data: AddressType): AddressType {
if (!data?.country) {
return data;
Expand All @@ -28,14 +37,18 @@ function normalizeCountryCode(data: AddressType): AddressType {
};
}

function getCountryCode(countryValue: string | undefined): Country | undefined {
for (const [code, name] of Object.entries(CONST.ALL_COUNTRIES)) {
if (name === countryValue) {
return code as Country;
function getCountryCode(countryValue: Country | ''): Country | '';
function getCountryCode(countryValue: Country | '' | undefined): Country | '' | undefined;
function getCountryCode(countryValue: string): string;
function getCountryCode(countryValue: string | undefined): string | undefined;
function getCountryCode(countryValue: string | undefined): string | undefined {
for (const code of getObjectKeys(CONST.ALL_COUNTRIES)) {
if (CONST.ALL_COUNTRIES[code] === countryValue) {
return code;
}
}

return countryValue as Country | undefined;
return countryValue;
}

// StateSelector keys on 2-letter codes, but stored addresses may use full state names (e.g. "California").
Expand Down
Loading
Loading