mirror of
https://github.com/actualbudget/actual.git
synced 2026-03-11 20:44:32 -05:00
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:
@@ -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={{
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
BEGIN TRANSACTION;
|
||||
|
||||
ALTER TABLE tags ADD COLUMN tombstone integer DEFAULT 0;
|
||||
|
||||
COMMIT;
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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 }));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export interface Tag {
|
||||
id: string;
|
||||
tag: string;
|
||||
color: string;
|
||||
color?: string | null;
|
||||
description?: string | null;
|
||||
}
|
||||
|
||||
6
upcoming-release-notes/5387.md
Normal file
6
upcoming-release-notes/5387.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Bugfix
|
||||
authors: [youngcw, pogman-code]
|
||||
---
|
||||
|
||||
Fix tags not syncing properly
|
||||
Reference in New Issue
Block a user