Fix tags not syncing properly (#5387)

* start

* migration

* don't show deleted tags in list

* tag updates (#5389)

* upsert tag to ensure uniqueness

and insert default-tag in migration

* remove ability to change default tag color

---------

Co-authored-by: pogman-code <adrian.maurin@gmail.com>

* tags fixes (#5391)

* better tag validation

* fix typecheck issues

* update note

---------

Co-authored-by: pogman-code <adrian.maurin@gmail.com>
This commit is contained in:
youngcw
2025-07-25 14:32:52 -07:00
committed by GitHub
parent 1861060bda
commit a15ff85c20
11 changed files with 111 additions and 121 deletions

View File

@@ -5,6 +5,7 @@ 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 { Text } from '@actual-app/components/text';
import { theme } from '@actual-app/components/theme';
import { View } from '@actual-app/components/view';
@@ -31,30 +32,15 @@ export function ManageTags() {
const [create, setCreate] = useState(false);
const tags = useTags();
const defaultTag = useMemo(
() => ({
id: '*',
tag: '*',
color: theme.noteTagDefault,
description: t('Default tag color'),
...tags.find(tag => tag.tag === '*'),
}),
[t, tags],
);
const filteredTags = useMemo(() => {
return filter === ''
? [defaultTag, ...tags.filter(tag => tag.tag !== '*')]
? tags
: tags.filter(tag =>
getNormalisedString(tag.tag).includes(getNormalisedString(filter)),
);
}, [defaultTag, filter, tags]);
}, [filter, tags]);
const selectedInst = useSelected(
'manage-tags',
filteredTags.filter(tag => tag.tag !== '*'),
[],
);
const selectedInst = useSelected('manage-tags', filteredTags, []);
const onDeleteSelected = useCallback(async () => {
dispatch(deleteAllTags([...selectedInst.items]));
@@ -113,12 +99,25 @@ export function ManageTags() {
{create && (
<TagCreationRow onClose={() => setCreate(false)} tags={tags} />
)}
<TagsList
tags={filteredTags}
selectedItems={selectedInst.items}
hoveredTag={hoveredTag}
onHover={id => setHoveredTag(id ?? undefined)}
/>
{tags.length ? (
<TagsList
tags={filteredTags}
selectedItems={selectedInst.items}
hoveredTag={hoveredTag}
onHover={id => setHoveredTag(id ?? undefined)}
/>
) : (
<View
style={{
background: theme.tableBackground,
fontStyle: 'italic',
}}
>
<Text style={{ margin: 'auto', padding: '20px' }}>
<Trans>No Tags</Trans>
</Text>
</View>
)}
</View>
<View
style={{

View File

@@ -31,6 +31,8 @@ type TagCreationRowProps = {
onClose: () => void;
};
const isTagValid = (tag: string) => tag.match(/^([^#\s]+)$/);
export const TagCreationRow = ({ onClose, tags }: TagCreationRowProps) => {
const { t } = useTranslation();
const dispatch = useDispatch();
@@ -64,7 +66,7 @@ export const TagCreationRow = ({ onClose, tags }: TagCreationRowProps) => {
};
const onAddTag = () => {
if (!tag.trim() || !color.trim() || tagNames.includes(tag)) {
if (!isTagValid(tag) || !color.trim() || tagNames.includes(tag)) {
return;
}
@@ -187,7 +189,7 @@ export const TagCreationRow = ({ onClose, tags }: TagCreationRowProps) => {
style={{ padding: '4px 10px' }}
onPress={onAddTag}
data-testid="add-button"
isDisabled={!tag || tagNames.includes(tag)}
isDisabled={!isTagValid(tag) || tagNames.includes(tag)}
ref={addButtonRef}
>
<Trans>Add</Trans>

View File

@@ -1,12 +1,11 @@
import { type RefObject } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@actual-app/components/button';
import { ColorPicker } from '@actual-app/components/color-picker';
import { type Tag } from 'loot-core/types/models';
import { createTag, updateTag } from '@desktop-client/queries/queriesSlice';
import { updateTag } from '@desktop-client/queries/queriesSlice';
import { useDispatch } from '@desktop-client/redux';
import { useTagCSS } from '@desktop-client/style/tags';
@@ -16,25 +15,16 @@ type TagEditorProps = {
};
export const TagEditor = ({ tag, ref }: TagEditorProps) => {
const { t } = useTranslation();
const dispatch = useDispatch();
const getTagCSS = useTagCSS();
const formattedTag = <>#{tag.tag === '*' ? t('Default') : tag.tag}</>;
const formattedTag = <>#{tag.tag}</>;
return (
<ColorPicker
value={tag.color}
value={tag.color ?? undefined}
onChange={color => {
dispatch(
tag.id !== '*'
? updateTag({ ...tag, color: color.toString('hex') })
: createTag({
tag: tag.tag,
color: color.toString('hex'),
description: tag.description,
}),
);
dispatch(updateTag({ ...tag, color: color.toString('hex') }));
}}
>
<Button variant="bare" className={getTagCSS(tag.tag)} ref={ref}>

View File

@@ -1,9 +1,7 @@
import React, { memo, useRef } from 'react';
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';
@@ -24,11 +22,7 @@ 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 {
createTag,
deleteTag,
updateTag,
} from '@desktop-client/queries/queriesSlice';
import { deleteTag, updateTag } from '@desktop-client/queries/queriesSlice';
import { useDispatch } from '@desktop-client/redux';
type TagRowProps = {
@@ -58,15 +52,7 @@ export const TagRow = memo(
const navigate = useNavigate();
const onUpdate = (description: string) => {
dispatch(
tag.id !== '*'
? updateTag({ ...tag, description })
: createTag({
tag: tag.tag,
color: tag.color,
description,
}),
);
dispatch(updateTag({ ...tag, description }));
};
const onShowActivity = () => {
@@ -116,7 +102,7 @@ export const TagRow = memo(
items={[
{
name: 'delete',
text: tag.tag !== '*' ? t('Delete') : t('Reset'),
text: t('Delete'),
},
]}
onMenuSelect={name => {
@@ -131,36 +117,19 @@ export const TagRow = memo(
}}
/>
</Popover>
{tag.tag !== '*' ? (
<SelectCell
exposed={hovered || selected || focusedField === 'select'}
focused={focusedField === 'select'}
onSelect={e => {
dispatchSelected({
type: 'select',
id: tag.id,
isRangeSelect: e.shiftKey,
});
}}
selected={selected}
/>
) : (
<Cell width={20} plain>
<Button
variant="bare"
type="button"
style={{
borderWidth: 0,
backgroundColor: 'transparent',
marginLeft: 'auto',
}}
onPress={() => dispatch(deleteTag(tag))}
ref={resetButtonRef}
>
<SvgRefreshArrow width={13} height={13} />
</Button>
</Cell>
)}
<SelectCell
exposed={hovered || selected || focusedField === 'select'}
focused={focusedField === 'select'}
onSelect={e => {
dispatchSelected({
type: 'select',
id: tag.id,
isRangeSelect: e.shiftKey,
});
}}
selected={selected}
/>
<Cell width={250} plain style={{ padding: '5px', display: 'block' }}>
<TagEditor tag={tag} ref={colorButtonRef} />
@@ -184,28 +153,27 @@ 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>
)}
<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

@@ -24,7 +24,7 @@ export function useTags() {
return tags;
}
function getTagCSSColors(theme: Theme, color?: string) {
function getTagCSSColors(theme: Theme, color?: string | null) {
if (theme === 'light') {
return [
color ? `${color} !important` : themeStyle.noteTagText,
@@ -55,9 +55,7 @@ export function useTagCSS() {
const [color, backgroundColor, backgroundColorHovered] = getTagCSSColors(
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),
options.color ?? tags.find(t => t.tag === tag)?.color,
);
return css({

View File

@@ -0,0 +1,5 @@
BEGIN TRANSACTION;
ALTER TABLE tags ADD COLUMN tombstone integer DEFAULT 0;
COMMIT;

View File

@@ -806,6 +806,15 @@ function toSqlQueryParameters(params: unknown[]) {
}
export function getTags() {
return all<DbTag>(`
SELECT id, tag, color, description
FROM tags
WHERE tombstone = 0
ORDER BY tag
`);
}
export function getAllTags() {
return all<DbTag>(`
SELECT id, tag, color, description
FROM tags
@@ -818,9 +827,7 @@ export function insertTag(tag): Promise<DbTag['id']> {
}
export async function deleteTag(tag) {
return transaction(() => {
runQuery(`DELETE FROM tags WHERE id = ?`, [tag.id]);
});
return delete_('tags', tag.id);
}
export function updateTag(tag) {

View File

@@ -328,6 +328,7 @@ export type DbViewSchedule = {
export type DbTag = {
id: string;
tag: string;
color: string;
color?: string | null;
description?: string | null;
tombstone: 1 | 0;
};

View File

@@ -28,12 +28,26 @@ async function getTags(): Promise<Tag[]> {
async function createTag({
tag,
color,
color = null,
description = null,
}: Omit<Tag, 'id'>): Promise<Tag> {
const allTags = await db.getAllTags();
const { id: tagId = null } = allTags.find(t => t.tag === tag) || {};
if (tagId) {
await db.updateTag({
id: tagId,
tag,
color,
description,
tombstone: 0,
});
return { id: tagId, tag, color, description };
}
const id = await db.insertTag({
tag: tag.trim(),
color: color.trim(),
color: color ? color.trim() : null,
description,
});
@@ -66,7 +80,7 @@ async function findTags(): Promise<Tag[]> {
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: '' }));
tags.push(await createTag({ tag }));
}
}
}

View File

@@ -1,6 +1,6 @@
export interface Tag {
id: string;
tag: string;
color: string;
color?: string | null;
description?: string | null;
}

View File

@@ -0,0 +1,6 @@
---
category: Bugfix
authors: [youngcw, pogman-code]
---
Fix tags not syncing properly