Add filter option for category groups (#6834)
* Add filter by category groups * Add tests * Add release notes * [autofix.ci] apply automated fixes * Fix typecheck findings * Fix modal * Address nitpick comment (filterBy) * Fix e2e tests * Make group a subfield of category * Fix test by typing in autocomplete * Replace testId with a11y lookups * Apply new type import style rules * Apply feedback * Improve typing on array reduce, remove manual type coercion --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
@@ -62,26 +62,56 @@ test.describe('Transactions', () => {
|
||||
const autocomplete = page.getByTestId('autocomplete');
|
||||
await expect(autocomplete).toMatchThemeScreenshots();
|
||||
|
||||
// Ensure that autocomplete filters properly
|
||||
await page.keyboard.type('C');
|
||||
await expect(autocomplete).toMatchThemeScreenshots();
|
||||
|
||||
// Select the active item
|
||||
await page.getByTestId('Clothing-category-item').click();
|
||||
await filterTooltip.applyButton.click();
|
||||
|
||||
// Assert that there are only clothing transactions
|
||||
await expect(accountPage.getNthTransaction(0).category).toHaveText(
|
||||
'Clothing',
|
||||
);
|
||||
await expect(accountPage.getNthTransaction(1).category).toHaveText(
|
||||
'Clothing',
|
||||
);
|
||||
await expect(accountPage.getNthTransaction(2).category).toHaveText(
|
||||
'Clothing',
|
||||
);
|
||||
await expect(accountPage.getNthTransaction(3).category).toHaveText(
|
||||
'Clothing',
|
||||
);
|
||||
await expect(accountPage.getNthTransaction(4).category).toHaveText(
|
||||
'Clothing',
|
||||
);
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await expect(accountPage.getNthTransaction(i).category).toHaveText(
|
||||
'Clothing',
|
||||
);
|
||||
}
|
||||
await expect(page).toMatchThemeScreenshots();
|
||||
});
|
||||
|
||||
test('by category group', async () => {
|
||||
// Use Capital One Checking because it has transactions that aren't just Clothing
|
||||
accountPage = await navigation.goToAccountPage('Capital One Checking');
|
||||
|
||||
const filterTooltip = await accountPage.filterBy('Category');
|
||||
|
||||
await filterTooltip.locator
|
||||
.getByRole('button', { name: 'Category', exact: true })
|
||||
.click();
|
||||
await page
|
||||
.getByRole('button', { name: 'Category group', exact: true })
|
||||
.click();
|
||||
|
||||
await expect(filterTooltip.locator).toMatchThemeScreenshots();
|
||||
|
||||
// Type in the autocomplete box
|
||||
const autocomplete = page.getByTestId('autocomplete');
|
||||
await expect(autocomplete).toMatchThemeScreenshots();
|
||||
|
||||
// Ensure that autocomplete filters properly
|
||||
await page.keyboard.type('U');
|
||||
await expect(autocomplete).toMatchThemeScreenshots();
|
||||
|
||||
// Select the active item
|
||||
await page.getByTestId('Usual Expenses-category-group-item').click();
|
||||
await filterTooltip.applyButton.click();
|
||||
|
||||
// Assert that there are only transactions with categories in the Usual Expenses group
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await expect(accountPage.getNthTransaction(i).category).toHaveText(
|
||||
/^(Savings|Medical|Gift|General|Clothing|Entertainment|Restaurants|Food)$/,
|
||||
);
|
||||
}
|
||||
await expect(page).toMatchThemeScreenshots();
|
||||
});
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 9.3 KiB After Width: | Height: | Size: 9.1 KiB |
|
After Width: | Height: | Size: 114 KiB |
|
After Width: | Height: | Size: 115 KiB |
|
After Width: | Height: | Size: 114 KiB |
|
Before Width: | Height: | Size: 9.1 KiB After Width: | Height: | Size: 8.9 KiB |
|
Before Width: | Height: | Size: 9.1 KiB After Width: | Height: | Size: 8.9 KiB |
|
Before Width: | Height: | Size: 113 KiB After Width: | Height: | Size: 6.9 KiB |
|
Before Width: | Height: | Size: 115 KiB After Width: | Height: | Size: 6.9 KiB |
|
Before Width: | Height: | Size: 114 KiB After Width: | Height: | Size: 6.9 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 95 KiB |
|
After Width: | Height: | Size: 96 KiB |
|
After Width: | Height: | Size: 95 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
@@ -11,6 +11,7 @@ import { AccountMenuModal } from './modals/AccountMenuModal';
|
||||
import { BudgetAutomationsModal } from './modals/BudgetAutomationsModal';
|
||||
import { BudgetPageMenuModal } from './modals/BudgetPageMenuModal';
|
||||
import { CategoryAutocompleteModal } from './modals/CategoryAutocompleteModal';
|
||||
import { CategoryGroupAutocompleteModal } from './modals/CategoryGroupAutocompleteModal';
|
||||
import { CategoryGroupMenuModal } from './modals/CategoryGroupMenuModal';
|
||||
import { CategoryMenuModal } from './modals/CategoryMenuModal';
|
||||
import { CloseAccountModal } from './modals/CloseAccountModal';
|
||||
@@ -206,6 +207,11 @@ export function Modals() {
|
||||
case 'category-autocomplete':
|
||||
return <CategoryAutocompleteModal key={key} {...modal.options} />;
|
||||
|
||||
case 'category-group-autocomplete':
|
||||
return (
|
||||
<CategoryGroupAutocompleteModal key={key} {...modal.options} />
|
||||
);
|
||||
|
||||
case 'account-autocomplete':
|
||||
return <AccountAutocompleteModal key={key} {...modal.options} />;
|
||||
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
import React, { Fragment, useMemo } from 'react';
|
||||
import type {
|
||||
ComponentProps,
|
||||
ComponentPropsWithoutRef,
|
||||
CSSProperties,
|
||||
ReactElement,
|
||||
ReactNode,
|
||||
} from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useResponsive } from '@actual-app/components/hooks/useResponsive';
|
||||
import { styles } from '@actual-app/components/styles';
|
||||
import { TextOneLine } from '@actual-app/components/text-one-line';
|
||||
import { theme } from '@actual-app/components/theme';
|
||||
import { View } from '@actual-app/components/view';
|
||||
import { css, cx } from '@emotion/css';
|
||||
|
||||
import type { CategoryGroupEntity } from 'loot-core/types/models';
|
||||
|
||||
import { Autocomplete } from './Autocomplete';
|
||||
|
||||
import { useCategories } from '@desktop-client/hooks/useCategories';
|
||||
|
||||
type CategoryGroupAutocompleteItem = CategoryGroupEntity;
|
||||
|
||||
type CategoryGroupListProps = {
|
||||
items: CategoryGroupAutocompleteItem[];
|
||||
getItemProps?: (arg: {
|
||||
item: CategoryGroupAutocompleteItem;
|
||||
}) => Partial<ComponentProps<typeof View>>;
|
||||
highlightedIndex: number;
|
||||
embedded?: boolean;
|
||||
footer?: ReactNode;
|
||||
renderCategoryGroupItem?: (
|
||||
props: ComponentPropsWithoutRef<typeof CategoryGroupItem>,
|
||||
) => ReactElement<typeof CategoryGroupItem>;
|
||||
showHiddenItems?: boolean;
|
||||
};
|
||||
|
||||
function CategoryGroupList({
|
||||
items,
|
||||
getItemProps,
|
||||
highlightedIndex,
|
||||
embedded,
|
||||
footer,
|
||||
renderCategoryGroupItem = defaultRenderCategoryItem,
|
||||
showHiddenItems,
|
||||
}: CategoryGroupListProps) {
|
||||
const categoryGroups = useMemo(() => {
|
||||
return items.reduce<
|
||||
(CategoryGroupAutocompleteItem & { highlightedIndex: number })[]
|
||||
>((acc, item, index) => {
|
||||
const itemWithIndex = {
|
||||
...item,
|
||||
highlightedIndex: index,
|
||||
};
|
||||
|
||||
acc.push(itemWithIndex);
|
||||
|
||||
return acc;
|
||||
}, []);
|
||||
}, [items]);
|
||||
|
||||
return (
|
||||
<View>
|
||||
<View
|
||||
style={{
|
||||
overflowY: 'auto',
|
||||
willChange: 'transform',
|
||||
padding: '5px 0',
|
||||
...(!embedded && { maxHeight: 175 }),
|
||||
}}
|
||||
>
|
||||
{categoryGroups.map(item => (
|
||||
<Fragment key={item.id}>
|
||||
{renderCategoryGroupItem({
|
||||
...(getItemProps ? getItemProps({ item }) : {}),
|
||||
item,
|
||||
highlighted: highlightedIndex === item.highlightedIndex,
|
||||
embedded,
|
||||
style: {
|
||||
...(showHiddenItems &&
|
||||
item.hidden && {
|
||||
color: theme.pageTextSubdued,
|
||||
}),
|
||||
},
|
||||
})}
|
||||
</Fragment>
|
||||
))}
|
||||
</View>
|
||||
{footer}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
type CategoryGroupAutocompleteProps = ComponentProps<
|
||||
typeof Autocomplete<CategoryGroupAutocompleteItem>
|
||||
> & {
|
||||
categoryGroups?: Array<CategoryGroupEntity>;
|
||||
renderCategoryGroupItem?: (
|
||||
props: ComponentPropsWithoutRef<typeof CategoryGroupItem>,
|
||||
) => ReactElement<typeof CategoryGroupItem>;
|
||||
showHiddenCategories?: boolean;
|
||||
};
|
||||
|
||||
export function CategoryGroupAutocomplete({
|
||||
categoryGroups,
|
||||
embedded,
|
||||
closeOnBlur,
|
||||
renderCategoryGroupItem,
|
||||
showHiddenCategories,
|
||||
...props
|
||||
}: CategoryGroupAutocompleteProps) {
|
||||
const {
|
||||
data: { grouped: defaultCategoryGroups } = {
|
||||
grouped: [],
|
||||
},
|
||||
} = useCategories();
|
||||
|
||||
const categoryGroupSuggestions: CategoryGroupAutocompleteItem[] =
|
||||
useMemo(() => {
|
||||
const allSuggestions = categoryGroups || defaultCategoryGroups;
|
||||
|
||||
if (!showHiddenCategories) {
|
||||
return allSuggestions.filter(suggestion => !suggestion.hidden);
|
||||
}
|
||||
|
||||
return allSuggestions;
|
||||
}, [categoryGroups, defaultCategoryGroups, showHiddenCategories]);
|
||||
|
||||
return (
|
||||
<Autocomplete
|
||||
strict
|
||||
highlightFirst
|
||||
embedded={embedded}
|
||||
closeOnBlur={closeOnBlur}
|
||||
suggestions={categoryGroupSuggestions}
|
||||
renderItems={(items, getItemProps, highlightedIndex) => (
|
||||
<CategoryGroupList
|
||||
items={items}
|
||||
embedded={embedded}
|
||||
getItemProps={getItemProps}
|
||||
highlightedIndex={highlightedIndex}
|
||||
renderCategoryGroupItem={renderCategoryGroupItem}
|
||||
showHiddenItems={showHiddenCategories}
|
||||
/>
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
type CategoryGroupItemProps = {
|
||||
item: CategoryGroupAutocompleteItem;
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
highlighted?: boolean;
|
||||
embedded?: boolean;
|
||||
showBalances?: boolean;
|
||||
};
|
||||
|
||||
function CategoryGroupItem({
|
||||
item,
|
||||
className,
|
||||
style,
|
||||
highlighted,
|
||||
embedded,
|
||||
...props
|
||||
}: CategoryGroupItemProps) {
|
||||
const { t } = useTranslation();
|
||||
const { isNarrowWidth } = useResponsive();
|
||||
const narrowStyle = isNarrowWidth
|
||||
? {
|
||||
...styles.mobileMenuItem,
|
||||
borderRadius: 0,
|
||||
borderTop: `1px solid ${theme.pillBorder}`,
|
||||
}
|
||||
: {};
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
style={style}
|
||||
className={cx(
|
||||
className,
|
||||
css({
|
||||
backgroundColor: highlighted
|
||||
? theme.menuAutoCompleteBackgroundHover
|
||||
: 'transparent',
|
||||
color: highlighted
|
||||
? theme.menuAutoCompleteItemTextHover
|
||||
: theme.menuAutoCompleteItemText,
|
||||
padding: 4,
|
||||
paddingLeft: 20,
|
||||
borderRadius: embedded ? 4 : 0,
|
||||
border: 'none',
|
||||
font: 'inherit',
|
||||
...narrowStyle,
|
||||
}),
|
||||
)}
|
||||
data-testid={`${item.name}-category-group-item`}
|
||||
data-highlighted={highlighted || undefined}
|
||||
{...props}
|
||||
>
|
||||
<View style={{ flexDirection: 'row', justifyContent: 'space-between' }}>
|
||||
<TextOneLine>
|
||||
{item.name}
|
||||
{item.hidden ? ' ' + t('(hidden)') : ''}
|
||||
</TextOneLine>
|
||||
</View>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function defaultRenderCategoryItem(
|
||||
props: ComponentPropsWithoutRef<typeof CategoryGroupItem>,
|
||||
): ReactElement<typeof CategoryGroupItem> {
|
||||
return <CategoryGroupItem {...props} />;
|
||||
}
|
||||
@@ -82,8 +82,8 @@ type ConfigureFieldProps<T extends RuleConditionEntity> =
|
||||
};
|
||||
|
||||
function ConfigureField<T extends RuleConditionEntity>({
|
||||
field,
|
||||
initialSubfield = field,
|
||||
field: initialField,
|
||||
initialSubfield = initialField,
|
||||
op,
|
||||
value,
|
||||
dispatch,
|
||||
@@ -92,9 +92,11 @@ function ConfigureField<T extends RuleConditionEntity>({
|
||||
const { t } = useTranslation();
|
||||
const format = useFormat();
|
||||
const dateFormat = useDateFormat() || 'MM/dd/yyyy';
|
||||
const field = initialField === 'category_group' ? 'category' : initialField;
|
||||
const [subfield, setSubfield] = useState(initialSubfield);
|
||||
const inputRef = useRef<AmountInputRef>(null);
|
||||
const prevOp = useRef<T['op'] | null>(null);
|
||||
const prevSubfield = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (prevOp.current !== op && inputRef.current) {
|
||||
@@ -103,6 +105,13 @@ function ConfigureField<T extends RuleConditionEntity>({
|
||||
prevOp.current = op;
|
||||
}, [op]);
|
||||
|
||||
useEffect(() => {
|
||||
if (prevSubfield.current !== subfield && inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
}
|
||||
prevSubfield.current = subfield;
|
||||
}, [subfield]);
|
||||
|
||||
const type = FIELD_TYPES.get(field);
|
||||
let ops = getValidOps(field).filter(op => op !== 'isbetween');
|
||||
|
||||
@@ -131,25 +140,42 @@ function ConfigureField<T extends RuleConditionEntity>({
|
||||
const isPayeeIdOp = (op: T['op']) =>
|
||||
['is', 'is not', 'one of', 'not one of'].includes(op);
|
||||
|
||||
const subfieldSelectOptions = (
|
||||
field: 'amount' | 'date' | 'category',
|
||||
): Array<readonly [string, string]> => {
|
||||
switch (field) {
|
||||
case 'amount':
|
||||
return [
|
||||
['amount', t('Amount')],
|
||||
['amount-inflow', t('Amount (inflow)')],
|
||||
['amount-outflow', t('Amount (outflow)')],
|
||||
];
|
||||
|
||||
case 'date':
|
||||
return [
|
||||
['date', t('Date')],
|
||||
['month', t('Month')],
|
||||
['year', t('Year')],
|
||||
];
|
||||
|
||||
case 'category':
|
||||
return [
|
||||
['category', t('Category')],
|
||||
['category_group', t('Category group')],
|
||||
];
|
||||
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<FocusScope>
|
||||
<View style={{ marginBottom: 10 }}>
|
||||
<SpaceBetween style={{ alignItems: 'flex-start' }}>
|
||||
{field === 'amount' || field === 'date' ? (
|
||||
{field === 'amount' || field === 'date' || field === 'category' ? (
|
||||
<Select
|
||||
options={
|
||||
field === 'amount'
|
||||
? [
|
||||
['amount', t('Amount')],
|
||||
['amount-inflow', t('Amount (inflow)')],
|
||||
['amount-outflow', t('Amount (outflow)')],
|
||||
]
|
||||
: [
|
||||
['date', t('Date')],
|
||||
['month', t('Month')],
|
||||
['year', t('Year')],
|
||||
]
|
||||
}
|
||||
options={subfieldSelectOptions(field)}
|
||||
value={subfield}
|
||||
onChange={sub => {
|
||||
setSubfield(sub);
|
||||
@@ -235,6 +261,7 @@ function ConfigureField<T extends RuleConditionEntity>({
|
||||
e.preventDefault();
|
||||
|
||||
let submitValue = value;
|
||||
let storableField = field;
|
||||
|
||||
if (field === 'amount' && inputRef.current) {
|
||||
try {
|
||||
@@ -256,9 +283,13 @@ function ConfigureField<T extends RuleConditionEntity>({
|
||||
}
|
||||
}
|
||||
|
||||
if (field === 'category') {
|
||||
storableField = subfield;
|
||||
}
|
||||
|
||||
// @ts-expect-error - fix me
|
||||
onApply({
|
||||
field,
|
||||
field: storableField,
|
||||
op,
|
||||
value: submitValue,
|
||||
options: subfieldToOptions(field, subfield),
|
||||
@@ -269,7 +300,7 @@ function ConfigureField<T extends RuleConditionEntity>({
|
||||
<GenericInput
|
||||
ref={inputRef}
|
||||
// @ts-expect-error - fix me
|
||||
field={field === 'date' ? subfield : field}
|
||||
field={field === 'date' || field === 'category' ? subfield : field}
|
||||
// @ts-expect-error - fix me
|
||||
type={
|
||||
type === 'id' &&
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useResponsive } from '@actual-app/components/hooks/useResponsive';
|
||||
import { theme } from '@actual-app/components/theme';
|
||||
import { View } from '@actual-app/components/view';
|
||||
|
||||
import * as monthUtils from 'loot-core/shared/months';
|
||||
|
||||
import { CategoryGroupAutocomplete } from '@desktop-client/components/autocomplete/CategoryGroupAutocomplete';
|
||||
import {
|
||||
Modal,
|
||||
ModalCloseButton,
|
||||
ModalHeader,
|
||||
ModalTitle,
|
||||
} from '@desktop-client/components/common/Modal';
|
||||
import { SectionLabel } from '@desktop-client/components/forms';
|
||||
import { SheetNameProvider } from '@desktop-client/hooks/useSheetName';
|
||||
import type { Modal as ModalType } from '@desktop-client/modals/modalsSlice';
|
||||
|
||||
type CategoryGroupAutocompleteModalProps = Extract<
|
||||
ModalType,
|
||||
{ name: 'category-group-autocomplete' }
|
||||
>['options'];
|
||||
|
||||
export function CategoryGroupAutocompleteModal({
|
||||
title,
|
||||
month,
|
||||
onSelect,
|
||||
categoryGroups,
|
||||
showHiddenCategories,
|
||||
closeOnSelect,
|
||||
clearOnSelect,
|
||||
onClose,
|
||||
}: CategoryGroupAutocompleteModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const { isNarrowWidth } = useResponsive();
|
||||
|
||||
const defaultAutocompleteProps = {
|
||||
containerProps: { style: { height: isNarrowWidth ? '90vh' : 275 } },
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
name="category-group-autocomplete"
|
||||
noAnimation={!isNarrowWidth}
|
||||
onClose={onClose}
|
||||
containerProps={{
|
||||
style: {
|
||||
height: isNarrowWidth
|
||||
? 'calc(var(--visual-viewport-height) * 0.85)'
|
||||
: 275,
|
||||
backgroundColor: theme.menuAutoCompleteBackground,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{({ state: { close } }) => (
|
||||
<>
|
||||
{isNarrowWidth && (
|
||||
<ModalHeader
|
||||
title={
|
||||
<ModalTitle
|
||||
title={title || t('Category group')}
|
||||
getStyle={() => ({ color: theme.menuAutoCompleteText })}
|
||||
/>
|
||||
}
|
||||
rightContent={
|
||||
<ModalCloseButton
|
||||
onPress={close}
|
||||
style={{ color: theme.menuAutoCompleteText }}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<View>
|
||||
{!isNarrowWidth && (
|
||||
<SectionLabel
|
||||
title={t('Category group')}
|
||||
style={{
|
||||
alignSelf: 'center',
|
||||
color: theme.menuAutoCompleteText,
|
||||
marginBottom: 10,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<View style={{ flex: 1 }}>
|
||||
<SheetNameProvider
|
||||
name={month ? monthUtils.sheetForMonth(month) : ''}
|
||||
>
|
||||
<CategoryGroupAutocomplete
|
||||
focused
|
||||
embedded
|
||||
closeOnBlur={false}
|
||||
closeOnSelect={closeOnSelect}
|
||||
clearOnSelect={clearOnSelect}
|
||||
onClose={close}
|
||||
{...defaultAutocompleteProps}
|
||||
onSelect={onSelect}
|
||||
categoryGroups={categoryGroups}
|
||||
showHiddenCategories={showHiddenCategories}
|
||||
value={null}
|
||||
/>
|
||||
</SheetNameProvider>
|
||||
</View>
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -185,13 +185,16 @@ function CustomReportInner({ report: initialReport }: CustomReportInnerProps) {
|
||||
|
||||
// Complex category conditions are:
|
||||
// - conditions with multiple "category" fields
|
||||
// - conditions with "category" field that use "contains", "doesNotContain" or "matches" operations
|
||||
// - conditions with "category" field that use "contains", "doesNotContain", "matches", "hasTags" operations
|
||||
// - conditions with a "category_group" field
|
||||
const isComplexCategoryCondition =
|
||||
!!conditions.find(
|
||||
({ field, op }) =>
|
||||
field === 'category' &&
|
||||
['contains', 'doesNotContain', 'matches', 'hasTags'].includes(op),
|
||||
) || conditions.filter(({ field }) => field === 'category').length >= 2;
|
||||
) ||
|
||||
conditions.filter(({ field }) => field === 'category').length >= 2 ||
|
||||
conditions.filter(({ field }) => field === 'category_group').length >= 1;
|
||||
|
||||
const setSelectedCategories = (newCategories: CategoryEntity[]) => {
|
||||
const newCategoryIdSet = new Set(newCategories.map(({ id }) => id));
|
||||
|
||||
@@ -968,6 +968,7 @@ const conditionFields = [
|
||||
'imported_payee',
|
||||
'account',
|
||||
'category',
|
||||
'category_group',
|
||||
'date',
|
||||
'payee',
|
||||
'notes',
|
||||
|
||||
@@ -43,7 +43,12 @@ export function Value<T>({
|
||||
const format = useFormat();
|
||||
const dateFormat = useDateFormat() || 'MM/dd/yyyy';
|
||||
const payees = usePayees();
|
||||
const { data: { list: categories } = { list: [] } } = useCategories();
|
||||
const {
|
||||
data: { list: categories, grouped: categoryGroups } = {
|
||||
list: [],
|
||||
grouped: [],
|
||||
},
|
||||
} = useCategories();
|
||||
const accounts = useAccounts();
|
||||
const valueStyle = {
|
||||
color: theme.pageTextPositive,
|
||||
@@ -52,15 +57,30 @@ export function Value<T>({
|
||||
const ValueText = field === 'amount' ? FinancialText : Text;
|
||||
const locale = useLocale();
|
||||
|
||||
const data =
|
||||
dataProp ||
|
||||
(field === 'payee'
|
||||
? payees
|
||||
: field === 'category'
|
||||
? categories
|
||||
: field === 'account'
|
||||
? accounts
|
||||
: []);
|
||||
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);
|
||||
|
||||
@@ -77,6 +97,8 @@ export function Value<T>({
|
||||
} else {
|
||||
switch (field) {
|
||||
case 'amount':
|
||||
case 'amount-inflow':
|
||||
case 'amount-outflow':
|
||||
return format(value, 'financial');
|
||||
case 'date':
|
||||
if (value) {
|
||||
@@ -98,6 +120,7 @@ export function Value<T>({
|
||||
return value;
|
||||
case 'payee':
|
||||
case 'category':
|
||||
case 'category_group':
|
||||
case 'account':
|
||||
case 'rule':
|
||||
if (valueIsRaw) {
|
||||
|
||||
@@ -15,6 +15,7 @@ import { PercentInput } from './PercentInput';
|
||||
import { AccountAutocomplete } from '@desktop-client/components/autocomplete/AccountAutocomplete';
|
||||
import { Autocomplete } from '@desktop-client/components/autocomplete/Autocomplete';
|
||||
import { CategoryAutocomplete } from '@desktop-client/components/autocomplete/CategoryAutocomplete';
|
||||
import { CategoryGroupAutocomplete } from '@desktop-client/components/autocomplete/CategoryGroupAutocomplete';
|
||||
import { FilterAutocomplete } from '@desktop-client/components/autocomplete/FilterAutocomplete';
|
||||
import { PayeeAutocomplete } from '@desktop-client/components/autocomplete/PayeeAutocomplete';
|
||||
import { ReportAutocomplete } from '@desktop-client/components/autocomplete/ReportAutocomplete';
|
||||
@@ -34,7 +35,7 @@ type GenericInputProps = {
|
||||
| ((
|
||||
| {
|
||||
type: 'id';
|
||||
field: 'payee' | 'category';
|
||||
field: 'payee' | 'category' | 'category_group';
|
||||
}
|
||||
| {
|
||||
type: 'id';
|
||||
@@ -264,6 +265,44 @@ export const GenericInput = ({
|
||||
);
|
||||
break;
|
||||
|
||||
case 'category_group':
|
||||
content = (
|
||||
<CategoryGroupAutocomplete
|
||||
{...multiProps}
|
||||
categoryGroups={categoryGroups}
|
||||
openOnFocus={!isNarrowWidth}
|
||||
updateOnValueChange={isNarrowWidth}
|
||||
showHiddenCategories
|
||||
inputProps={{
|
||||
ref,
|
||||
...(showPlaceholder ? { placeholder: t('nothing') } : null),
|
||||
onClick: () => {
|
||||
if (!isNarrowWidth) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(
|
||||
pushModal({
|
||||
modal: {
|
||||
name: 'category-group-autocomplete',
|
||||
options: {
|
||||
onSelect: newValue => {
|
||||
if (props.multi === true) {
|
||||
props.onChange([...props.value, newValue]);
|
||||
return;
|
||||
}
|
||||
props.onChange(newValue);
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
|
||||
default:
|
||||
}
|
||||
break;
|
||||
|
||||
@@ -231,6 +231,19 @@ export type Modal =
|
||||
onClose?: () => void;
|
||||
};
|
||||
}
|
||||
| {
|
||||
name: 'category-group-autocomplete';
|
||||
options: {
|
||||
title?: string;
|
||||
categoryGroups?: CategoryGroupEntity[];
|
||||
onSelect: (categoryGroupId: string, categoryGroupName: string) => void;
|
||||
month?: string | undefined;
|
||||
showHiddenCategories?: boolean;
|
||||
closeOnSelect?: boolean;
|
||||
clearOnSelect?: boolean;
|
||||
onClose?: () => void;
|
||||
};
|
||||
}
|
||||
| {
|
||||
name: 'account-autocomplete';
|
||||
options: {
|
||||
|
||||