Add button to import existing tag from transactions notes (#5368)

* Add button to import existing tag from transactions notes

* fix ##non-tag matching

* 'find' tags instead of 'import' to avoid confusion

* add link to show transaction that have given tag

* use same style as PayeeTableRow's button
This commit is contained in:
POGMAN
2025-07-23 18:57:43 +02:00
committed by GitHub
parent f5a6700b21
commit 45610bae81
8 changed files with 113 additions and 4 deletions

View File

@@ -56,6 +56,8 @@ function RuleButton({ ruleCount, focused, onEdit, onClick }: RuleButtonProps) {
border: '1px solid ' + theme.noticeBackground,
color: theme.noticeTextDark,
fontSize: 12,
cursor: 'pointer',
':hover': { backgroundColor: theme.noticeBackgroundLight },
}}
onEdit={onEdit}
onSelect={onClick}

View File

@@ -3,6 +3,7 @@ import { Trans, useTranslation } from 'react-i18next';
import { Button } from '@actual-app/components/button';
import { SvgAdd } from '@actual-app/components/icons/v1';
import { SvgSearchAlternate } from '@actual-app/components/icons/v2';
import { Stack } from '@actual-app/components/stack';
import { theme } from '@actual-app/components/theme';
import { View } from '@actual-app/components/view';
@@ -18,7 +19,7 @@ import {
SelectedProvider,
useSelected,
} from '@desktop-client/hooks/useSelected';
import { deleteAllTags } from '@desktop-client/queries/queriesSlice';
import { deleteAllTags, findTags } from '@desktop-client/queries/queriesSlice';
import { useDispatch } from '@desktop-client/redux';
import { useTags } from '@desktop-client/style/tags';
@@ -92,6 +93,14 @@ export function ManageTags() {
<SvgAdd width={10} height={10} style={{ marginRight: 3 }} />
<Trans>Add New</Trans>
</Button>
<Button variant="bare" onPress={() => dispatch(findTags())}>
<SvgSearchAlternate
width={10}
height={10}
style={{ marginRight: 3 }}
/>
<Trans>Find Existing Tags</Trans>
</Button>
<View style={{ flex: 1 }} />
<Search
placeholder={t('Filter tags...')}

View File

@@ -1,10 +1,12 @@
import React, { memo, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { Trans, useTranslation } from 'react-i18next';
import { Button } from '@actual-app/components/button';
import { SvgArrowThinRight } from '@actual-app/components/icons/v1';
import { SvgRefreshArrow } from '@actual-app/components/icons/v2';
import { Menu } from '@actual-app/components/menu';
import { Popover } from '@actual-app/components/popover';
import { Text } from '@actual-app/components/text';
import { theme } from '@actual-app/components/theme';
import { type Tag } from 'loot-core/types/models';
@@ -16,8 +18,10 @@ import {
Row,
Cell,
InputCell,
CellButton,
} from '@desktop-client/components/table';
import { useContextMenu } from '@desktop-client/hooks/useContextMenu';
import { useNavigate } from '@desktop-client/hooks/useNavigate';
import { useProperFocus } from '@desktop-client/hooks/useProperFocus';
import { useSelectedDispatch } from '@desktop-client/hooks/useSelected';
import {
@@ -51,6 +55,7 @@ export const TagRow = memo(
const triggerRef = useRef(null);
const { setMenuOpen, menuOpen, handleContextMenu, position } =
useContextMenu();
const navigate = useNavigate();
const onUpdate = (description: string) => {
dispatch(
@@ -64,6 +69,23 @@ export const TagRow = memo(
);
};
const onShowActivity = () => {
const filterConditions = [
{
field: 'notes',
op: 'hasTags',
value: `#${tag.tag}`,
type: 'string',
},
];
navigate('/accounts', {
state: {
goBack: true,
filterConditions,
},
});
};
return (
<Row
ref={triggerRef}
@@ -162,6 +184,28 @@ export const TagRow = memo(
placeholder: t('No description'),
}}
/>
{tag.tag !== '*' && (
<Cell width="auto" style={{ padding: '0 10px' }} plain>
<CellButton
style={{
borderRadius: 4,
padding: '3px 6px',
backgroundColor: theme.noticeBackground,
border: '1px solid ' + theme.noticeBackground,
color: theme.noticeTextDark,
fontSize: 12,
cursor: 'pointer',
':hover': { backgroundColor: theme.noticeBackgroundLight },
}}
onSelect={onShowActivity}
>
<Text style={{ paddingRight: 5 }}>
<Trans>View Transactions</Trans>
</Text>
<SvgArrowThinRight style={{ width: 8, height: 8 }} />
</CellButton>
</Cell>
)}
</Row>
);
},

View File

@@ -189,6 +189,10 @@ const queriesSlice = createSlice({
const tagIdx = state.tags.findIndex(tag => tag.id === action.payload.id);
state.tags[tagIdx] = action.payload;
});
builder.addCase(findTags.fulfilled, (state, action) => {
state.tags = action.payload;
});
},
});
@@ -485,6 +489,14 @@ export const updateTag = createAppAsyncThunk(
},
);
export const findTags = createAppAsyncThunk(
`${sliceName}/findTags`,
async () => {
const id = await send('tags-find');
return id;
},
);
// Budget actions
type ApplyBudgetActionPayload =

View File

@@ -56,8 +56,8 @@ export function useTagCSS() {
theme,
// fallback strategy: options color > tag color > default color > theme color (undefined)
options.color ??
tags.find(t => t.tag === tag)?.color ??
tags.find(t => t.tag === '*')?.color,
(tags.find(t => t.tag === tag)?.color ||
tags.find(t => t.tag === '*')?.color),
);
return css({

View File

@@ -826,3 +826,14 @@ export async function deleteTag(tag) {
export function updateTag(tag) {
return update('tags', tag);
}
export function findTags() {
return all<{ notes: string }>(
`
SELECT notes
FROM transactions
WHERE notes LIKE ?
`,
['%#%'],
);
}

View File

@@ -11,6 +11,7 @@ export type TagsHandlers = {
'tags-delete': typeof deleteTag;
'tags-delete-all': typeof deleteAllTags;
'tags-update': typeof updateTag;
'tags-find': typeof findTags;
};
export const app = createApp<TagsHandlers>();
@@ -19,6 +20,7 @@ app.method('tags-create', mutator(undoable(createTag)));
app.method('tags-delete', mutator(undoable(deleteTag)));
app.method('tags-delete-all', mutator(deleteAllTags));
app.method('tags-update', mutator(undoable(updateTag)));
app.method('tags-find', mutator(findTags));
async function getTags(): Promise<Tag[]> {
return await db.getTags();
@@ -56,3 +58,26 @@ async function updateTag(tag: Tag): Promise<Tag> {
await db.updateTag(tag);
return tag;
}
async function findTags(): Promise<Tag[]> {
const taggedNotes = await db.findTags();
const tags = await getTags();
for (const { notes } of taggedNotes) {
for (const [_, tag] of notes.matchAll(/(?<!#)#([^#\s]+)/g)) {
if (!tags.find(t => t.tag === tag)) {
tags.push(await createTag({ tag, color: '' }));
}
}
}
return tags.sort(function (a, b) {
if (a.tag < b.tag) {
return -1;
}
if (a.tag > b.tag) {
return 1;
}
return 0;
});
}