Files
actual/packages/desktop-client/src/components/rules/Value.tsx
7f4a0e7d6b [AI] Move i18n usage outside of loot-core (#8027)
* [AI] Move i18n usage outside of loot-core

loot-core should be platform-agnostic and free of i18n concerns. This
moves all user-facing translated string helpers (rule/schedule labels,
error formatters) into desktop-client's #util/{rule,schedule,error}
modules, while keeping the underlying logic in loot-core.

- Headless @actual-app/api error formatters in loot-core now return
  plain English strings (the API has no i18n runtime).
- The persisted "Unknown" institution name and the platform storage
  alert use plain strings.
- Removed the unused i18next dependency from loot-core.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* [AI] Address review feedback on i18n-out-of-loot-core PR

- Wrap switch default arms in blocks to satisfy noSwitchDeclarations
  (desktop-client and loot-core getDownloadError)
- getSecretsError: return a localized generic message instead of leaking
  the raw backend error token
- getRecurringDescription: always separate the weekend annotation so it no
  longer renders as "Monday(after weekend)"; add test coverage
- IndexedDB quota error: surface a typed 'indexeddb-quota-error' event from
  loot-core and present the localized alert in desktop-client instead of a
  hard-coded English string
- Persist a null bank name (instead of English "Unknown") when the provider
  reports no institution, and render a localized fallback in the bank sync UI

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 22:10:33 +00:00

215 lines
5.6 KiB
TypeScript

// @ts-strict-ignore
import React, { useState } from 'react';
import type { CSSProperties } from 'react';
import { useTranslation } from 'react-i18next';
import { Text } from '@actual-app/components/text';
import { theme } from '@actual-app/components/theme';
import { getMonthYearFormat } from '@actual-app/core/shared/months';
import { format as formatDate, parseISO } from 'date-fns';
import { Link } from '#components/common/Link';
import { FinancialText } from '#components/FinancialText';
import { useAccounts } from '#hooks/useAccounts';
import { useCategories } from '#hooks/useCategories';
import { useDateFormat } from '#hooks/useDateFormat';
import { useFormat } from '#hooks/useFormat';
import { useLocale } from '#hooks/useLocale';
import { usePayees } from '#hooks/usePayees';
import { getRecurringDescription } from '#util/schedule';
type ValueProps<T> = {
value: T;
field: unknown;
valueIsRaw?: boolean;
inline?: boolean;
data?: unknown;
describe?: (item: T) => string;
style?: CSSProperties;
};
export function Value<T>({
value,
field,
valueIsRaw,
inline = false,
data: dataProp,
// @ts-expect-error fix this later
describe = x => x.name,
style,
}: ValueProps<T>) {
const { t } = useTranslation();
const format = useFormat();
const dateFormat = useDateFormat() || 'MM/dd/yyyy';
const { data: payees } = usePayees();
const {
data: { list: categories, grouped: categoryGroups } = {
list: [],
grouped: [],
},
} = useCategories();
const { data: accounts = [] } = useAccounts();
const valueStyle = {
color: theme.pageTextPositive,
...style,
};
const ValueText = field === 'amount' ? FinancialText : Text;
const locale = useLocale();
function getData() {
if (dataProp) {
return dataProp;
}
switch (field) {
case 'payee':
return payees;
case 'category':
return categories;
case 'category_group':
return categoryGroups;
case 'account':
return accounts;
default:
return [];
}
}
const data = getData();
const [expanded, setExpanded] = useState(false);
function onExpand(e) {
e.preventDefault();
setExpanded(true);
}
function formatValue(value) {
if (value == null || value === '') {
return t('(nothing)');
} else if (typeof value === 'boolean') {
return value ? 'true' : 'false';
} else {
switch (field) {
case 'amount':
case 'amount-inflow':
case 'amount-outflow':
return format(value, 'financial');
case 'date':
if (value) {
if (value.frequency) {
return getRecurringDescription(value, dateFormat, locale);
}
return formatDate(parseISO(value), dateFormat);
}
return null;
case 'month':
return value
? formatDate(parseISO(value), getMonthYearFormat(dateFormat))
: null;
case 'year':
return value ? formatDate(parseISO(value), 'yyyy') : null;
case 'notes':
case 'imported_payee':
case 'payee_name':
return value;
case 'payee':
case 'category':
case 'category_group':
case 'account':
case 'rule':
if (valueIsRaw) {
return value;
}
if (data && Array.isArray(data)) {
const item = data.find(item => item.id === value);
if (item) {
return describe(item);
} else {
return t('(deleted)');
}
}
return '…';
default:
throw new Error(`Unknown field ${String(field)}`);
}
}
}
if (Array.isArray(value)) {
if (value.length === 0) {
return <ValueText style={valueStyle}>(empty)</ValueText>;
} else if (value.length === 1) {
return (
<Text>
[<ValueText style={valueStyle}>{formatValue(value[0])}</ValueText>]
</Text>
);
}
let displayed: unknown[] = value;
if (!expanded && value.length > 4) {
displayed = value.slice(0, 3);
}
const numHidden = value.length - displayed.length;
return (
<Text style={{ color: theme.tableText }}>
[
{displayed.map((v, i) => {
const text = (
<ValueText style={valueStyle}>{formatValue(v)}</ValueText>
);
let spacing;
if (inline) {
spacing = i !== 0 ? ' ' : '';
} else {
spacing = (
<>
{i === 0 && <br />}
&nbsp;&nbsp;
</>
);
}
return (
<Text key={i}>
{spacing}
{text}
{i === value.length - 1 ? '' : ','}
{!inline && <br />}
</Text>
);
})}
{numHidden > 0 && (
<Text style={valueStyle}>
&nbsp;&nbsp;
<Link variant="text" onClick={onExpand} style={valueStyle}>
{t('{{num}} more items...', { num: numHidden })}
</Link>
{!inline && <br />}
</Text>
)}
]
</Text>
);
// @ts-expect-error Fix typechecker here
} else if (value && value.num1 != null && value.num2 != null) {
// An "in between" type
// @ts-expect-error Fix typechecker here
const { num1, num2 } = value;
return (
<Text>
<ValueText style={valueStyle}>{formatValue(num1)}</ValueText> {t('and')}{' '}
<ValueText style={valueStyle}>{formatValue(num2)}</ValueText>
</Text>
);
} else {
return <ValueText style={valueStyle}>{formatValue(value)}</ValueText>;
}
}