diff --git a/packages/desktop-client/src/components/tags/ManageTags.tsx b/packages/desktop-client/src/components/tags/ManageTags.tsx index 43811ec8be..2bb4010793 100644 --- a/packages/desktop-client/src/components/tags/ManageTags.tsx +++ b/packages/desktop-client/src/components/tags/ManageTags.tsx @@ -21,7 +21,7 @@ import { useSelected, } from '@desktop-client/hooks/useSelected'; import { useTags } from '@desktop-client/hooks/useTags'; -import { deleteAllTags, findTags } from '@desktop-client/queries/queriesSlice'; +import { deleteAllTags, findTags } from '@desktop-client/tags/tagsSlice'; import { useDispatch } from '@desktop-client/redux'; export function ManageTags() { diff --git a/packages/desktop-client/src/components/tags/TagCreationRow.tsx b/packages/desktop-client/src/components/tags/TagCreationRow.tsx index 120b0388b1..6864ba9e91 100644 --- a/packages/desktop-client/src/components/tags/TagCreationRow.tsx +++ b/packages/desktop-client/src/components/tags/TagCreationRow.tsx @@ -14,7 +14,7 @@ import { Stack } from '@actual-app/components/stack'; import { theme } from '@actual-app/components/theme'; import { View } from '@actual-app/components/view'; -import { type Tag } from 'loot-core/types/models'; +import { type TagEntity } from 'loot-core/types/models'; import { InputCell, @@ -23,11 +23,11 @@ import { } from '@desktop-client/components/table'; import { useProperFocus } from '@desktop-client/hooks/useProperFocus'; import { useTagCSS } from '@desktop-client/hooks/useTagCSS'; -import { createTag } from '@desktop-client/queries/queriesSlice'; +import { createTag } from '@desktop-client/tags/tagsSlice'; import { useDispatch } from '@desktop-client/redux'; type TagCreationRowProps = { - tags: Tag[]; + tags: TagEntity[]; onClose: () => void; }; diff --git a/packages/desktop-client/src/components/tags/TagEditor.tsx b/packages/desktop-client/src/components/tags/TagEditor.tsx index 2a4e3ef3c6..6567bca6bd 100644 --- a/packages/desktop-client/src/components/tags/TagEditor.tsx +++ b/packages/desktop-client/src/components/tags/TagEditor.tsx @@ -3,14 +3,14 @@ import { type RefObject } from 'react'; import { Button } from '@actual-app/components/button'; import { ColorPicker } from '@actual-app/components/color-picker'; -import { type Tag } from 'loot-core/types/models'; +import { type TagEntity } from 'loot-core/types/models'; import { useTagCSS } from '@desktop-client/hooks/useTagCSS'; -import { updateTag } from '@desktop-client/queries/queriesSlice'; +import { updateTag } from '@desktop-client/tags/tagsSlice'; import { useDispatch } from '@desktop-client/redux'; type TagEditorProps = { - tag: Tag; + tag: TagEntity; ref: RefObject; }; diff --git a/packages/desktop-client/src/components/tags/TagRow.tsx b/packages/desktop-client/src/components/tags/TagRow.tsx index 9e51000f79..57a8d90552 100644 --- a/packages/desktop-client/src/components/tags/TagRow.tsx +++ b/packages/desktop-client/src/components/tags/TagRow.tsx @@ -7,7 +7,7 @@ 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'; +import { type TagEntity } from 'loot-core/types/models'; import { TagEditor } from './TagEditor'; @@ -22,11 +22,11 @@ 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 { deleteTag, updateTag } from '@desktop-client/queries/queriesSlice'; +import { deleteTag, updateTag } from '@desktop-client/tags/tagsSlice'; import { useDispatch } from '@desktop-client/redux'; type TagRowProps = { - tag: Tag; + tag: TagEntity; hovered?: boolean; selected?: boolean; onHover: (id?: string) => void; diff --git a/packages/desktop-client/src/components/tags/TagsList.tsx b/packages/desktop-client/src/components/tags/TagsList.tsx index e41aaca879..0699fbd534 100644 --- a/packages/desktop-client/src/components/tags/TagsList.tsx +++ b/packages/desktop-client/src/components/tags/TagsList.tsx @@ -1,13 +1,13 @@ import React from 'react'; -import { type Tag } from 'loot-core/types/models'; +import { type TagEntity } from 'loot-core/types/models'; import { TagRow } from './TagRow'; import { Table, useTableNavigator } from '@desktop-client/components/table'; type TagsListProps = { - tags: Tag[]; + tags: TagEntity[]; selectedItems: Set; hoveredTag?: string; onHover: (id?: string) => void; diff --git a/packages/desktop-client/src/hooks/useTags.ts b/packages/desktop-client/src/hooks/useTags.ts index 85577bbf99..9b9e94eb4a 100644 --- a/packages/desktop-client/src/hooks/useTags.ts +++ b/packages/desktop-client/src/hooks/useTags.ts @@ -2,13 +2,13 @@ import { useEffect } from 'react'; import { useInitialMount } from './useInitialMount'; -import { getTags } from '@desktop-client/queries/queriesSlice'; +import { getTags } from '@desktop-client/tags/tagsSlice'; import { useDispatch, useSelector } from '@desktop-client/redux'; export function useTags() { const dispatch = useDispatch(); const isInitialMount = useInitialMount(); - const isTagsDirty = useSelector(state => state.queries.isTagsDirty); + const isTagsDirty = useSelector(state => state.tags.isTagsDirty); useEffect(() => { if (isInitialMount || isTagsDirty) { @@ -16,5 +16,5 @@ export function useTags() { } }, [dispatch, isInitialMount, isTagsDirty]); - return useSelector(state => state.queries.tags); + return useSelector(state => state.tags.tags); } diff --git a/packages/desktop-client/src/queries/queriesSlice.ts b/packages/desktop-client/src/queries/queriesSlice.ts index 56951c2e17..3268954eea 100644 --- a/packages/desktop-client/src/queries/queriesSlice.ts +++ b/packages/desktop-client/src/queries/queriesSlice.ts @@ -2,7 +2,7 @@ import { createSlice, type PayloadAction } from '@reduxjs/toolkit'; import { send } from 'loot-core/platform/client/fetch'; -import { type TransactionEntity, type Tag } from 'loot-core/types/models'; +import { type TransactionEntity } from 'loot-core/types/models'; import { markUpdatedAccounts } from '@desktop-client/accounts/accountsSlice'; import { resetApp } from '@desktop-client/app/appSlice'; @@ -15,20 +15,12 @@ type QueriesState = { newTransactions: Array; matchedTransactions: Array; lastTransaction: TransactionEntity | null; - tags: Tag[]; - isTagsLoading: boolean; - isTagsLoaded: boolean; - isTagsDirty: boolean; }; const initialState: QueriesState = { newTransactions: [], matchedTransactions: [], lastTransaction: null, - tags: [], - isTagsLoading: false, - isTagsLoaded: false, - isTagsDirty: false, }; type SetNewTransactionsPayload = { @@ -77,124 +69,12 @@ const queriesSlice = createSlice({ ) { state.lastTransaction = action.payload.transaction; }, - markTagsDirty(state) { - _markTagsDirty(state); - }, }, extraReducers: builder => { - // App - builder.addCase(resetApp, () => initialState); - - // Tags - - builder.addCase(createTag.fulfilled, _markTagsDirty); - builder.addCase(deleteTag.fulfilled, _markTagsDirty); - builder.addCase(deleteAllTags.fulfilled, _markTagsDirty); - builder.addCase(updateTag.fulfilled, _markTagsDirty); - - builder.addCase(reloadTags.fulfilled, (state, action) => { - _loadTags(state, action.payload); - }); - - builder.addCase(reloadTags.rejected, state => { - state.isTagsLoading = false; - }); - - builder.addCase(reloadTags.pending, state => { - state.isTagsLoading = true; - }); - - builder.addCase(getTags.fulfilled, (state, action) => { - _loadTags(state, action.payload); - }); - - builder.addCase(getTags.rejected, state => { - state.isTagsLoading = false; - }); - - builder.addCase(getTags.pending, state => { - state.isTagsLoading = true; - }); - - builder.addCase(findTags.fulfilled, (state, action) => { - _loadTags(state, action.payload); - }); - - builder.addCase(findTags.rejected, state => { - state.isTagsLoading = false; - }); - - builder.addCase(findTags.pending, state => { - state.isTagsLoading = true; - }); }, }); -export const getTags = createAppAsyncThunk( - `${sliceName}/getTags`, - async () => { - const tags: Tag[] = await send('tags-get'); - return tags; - }, - { - condition: (_, { getState }) => { - const { queries } = getState(); - return ( - !queries.isTagsLoading && (queries.isTagsDirty || !queries.isTagsLoaded) - ); - }, - }, -); - -export const reloadTags = createAppAsyncThunk( - `${sliceName}/reloadTags`, - async () => { - const tags: Tag[] = await send('tags-get'); - return tags; - }, -); - -export const createTag = createAppAsyncThunk( - `${sliceName}/createTag`, - async ({ tag, color, description }: Omit) => { - const id = await send('tags-create', { tag, color, description }); - return id; - }, -); - -export const deleteTag = createAppAsyncThunk( - `${sliceName}/deleteTag`, - async (tag: Tag) => { - const id = await send('tags-delete', tag); - return id; - }, -); - -export const deleteAllTags = createAppAsyncThunk( - `${sliceName}/deleteAllTags`, - async (ids: Array) => { - const id = await send('tags-delete-all', ids); - return id; - }, -); - -export const updateTag = createAppAsyncThunk( - `${sliceName}/updateTag`, - async (tag: Tag) => { - const id = await send('tags-update', tag); - return id; - }, -); - -export const findTags = createAppAsyncThunk( - `${sliceName}/findTags`, - async () => { - const tags: Tag[] = await send('tags-find'); - return tags; - }, -); - // Transaction actions type ImportPreviewTransactionsPayload = { @@ -297,28 +177,7 @@ export const actions = { ...queriesSlice.actions, importPreviewTransactions, importTransactions, - getTags, - createTag, - updateTag, - deleteTag, - deleteAllTags, - findTags, }; -export const { - setNewTransactions, - updateNewTransactions, - setLastTransaction, - markTagsDirty, -} = queriesSlice.actions; - -function _loadTags(state: QueriesState, tags: QueriesState['tags']) { - state.tags = tags; - state.isTagsLoading = false; - state.isTagsLoaded = true; - state.isTagsDirty = false; -} - -function _markTagsDirty(state: QueriesState) { - state.isTagsDirty = true; -} +export const { setNewTransactions, updateNewTransactions, setLastTransaction } = + queriesSlice.actions; diff --git a/packages/desktop-client/src/redux/mock.tsx b/packages/desktop-client/src/redux/mock.tsx index bc09170844..81a46573d6 100644 --- a/packages/desktop-client/src/redux/mock.tsx +++ b/packages/desktop-client/src/redux/mock.tsx @@ -41,6 +41,10 @@ import { name as queriesSliceName, reducer as queriesSliceReducer, } from '@desktop-client/queries/queriesSlice'; +import { + name as tagsSliceName, + reducer as tagsSliceReducer, +} from '@desktop-client/tags/tagsSlice'; import { name as usersSliceName, reducer as usersSliceReducer, @@ -56,6 +60,7 @@ const appReducer = combineReducers({ [payeesSliceName]: payeesSliceReducer, [prefsSliceName]: prefsSliceReducer, [queriesSliceName]: queriesSliceReducer, + [tagsSliceName]: tagsSliceReducer, [usersSliceName]: usersSliceReducer, }); diff --git a/packages/desktop-client/src/redux/store.ts b/packages/desktop-client/src/redux/store.ts index 72d8059f88..f22a155216 100644 --- a/packages/desktop-client/src/redux/store.ts +++ b/packages/desktop-client/src/redux/store.ts @@ -42,6 +42,10 @@ import { name as queriesSliceName, reducer as queriesSliceReducer, } from '@desktop-client/queries/queriesSlice'; +import { + name as tagsSliceName, + reducer as tagsSliceReducer, +} from '@desktop-client/tags/tagsSlice'; import { name as usersSliceName, reducer as usersSliceReducer, @@ -57,6 +61,7 @@ const rootReducer = combineReducers({ [payeesSliceName]: payeesSliceReducer, [prefsSliceName]: prefsSliceReducer, [queriesSliceName]: queriesSliceReducer, + [tagsSliceName]: tagsSliceReducer, [usersSliceName]: usersSliceReducer, }); diff --git a/packages/desktop-client/src/tags/tagsSlice.ts b/packages/desktop-client/src/tags/tagsSlice.ts new file mode 100644 index 0000000000..580ab49ac9 --- /dev/null +++ b/packages/desktop-client/src/tags/tagsSlice.ts @@ -0,0 +1,165 @@ +import { createSlice } from '@reduxjs/toolkit'; + +import { send } from 'loot-core/platform/client/fetch'; +import { TagEntity } from 'loot-core/types/models'; + +import { resetApp } from '@desktop-client/app/appSlice'; +import { createAppAsyncThunk } from '@desktop-client/redux'; + +const sliceName = 'tags'; + +type TagsState = { + tags: TagEntity[]; + isTagsLoading: boolean; + isTagsLoaded: boolean; + isTagsDirty: boolean; +}; + +const initialState: TagsState = { + tags: [], + isTagsLoading: false, + isTagsLoaded: false, + isTagsDirty: false, +}; + +const tagSlice = createSlice({ + name: sliceName, + initialState, + reducers: { + markTagsDirty(state) { + _markTagsDirty(state); + }, + }, + extraReducers: builder => { + builder.addCase(resetApp, () => initialState); + + builder.addCase(createTag.fulfilled, _markTagsDirty); + builder.addCase(deleteTag.fulfilled, _markTagsDirty); + builder.addCase(deleteAllTags.fulfilled, _markTagsDirty); + builder.addCase(updateTag.fulfilled, _markTagsDirty); + + builder.addCase(reloadTags.fulfilled, (state, action) => { + _loadTags(state, action.payload); + }); + + builder.addCase(reloadTags.rejected, state => { + state.isTagsLoading = false; + }); + + builder.addCase(reloadTags.pending, state => { + state.isTagsLoading = true; + }); + + builder.addCase(getTags.fulfilled, (state, action) => { + _loadTags(state, action.payload); + }); + + builder.addCase(getTags.rejected, state => { + state.isTagsLoading = false; + }); + + builder.addCase(getTags.pending, state => { + state.isTagsLoading = true; + }); + + builder.addCase(findTags.fulfilled, (state, action) => { + _loadTags(state, action.payload); + }); + + builder.addCase(findTags.rejected, state => { + state.isTagsLoading = false; + }); + + builder.addCase(findTags.pending, state => { + state.isTagsLoading = true; + }); + }, +}); + +export const getTags = createAppAsyncThunk( + `${sliceName}/getTags`, + async () => { + const tags: TagEntity[] = await send('tags-get'); + return tags; + }, + { + condition: (_, { getState }) => { + const { tags } = getState(); + return !tags.isTagsLoading && (tags.isTagsDirty || !tags.isTagsLoaded); + }, + }, +); + +export const reloadTags = createAppAsyncThunk( + `${sliceName}/reloadTags`, + async () => { + const tags: TagEntity[] = await send('tags-get'); + return tags; + }, +); + +export const createTag = createAppAsyncThunk( + `${sliceName}/createTag`, + async ({ tag, color, description }: Omit) => { + const id = await send('tags-create', { tag, color, description }); + return id; + }, +); + +export const deleteTag = createAppAsyncThunk( + `${sliceName}/deleteTag`, + async (tag: TagEntity) => { + const id = await send('tags-delete', tag); + return id; + }, +); + +export const deleteAllTags = createAppAsyncThunk( + `${sliceName}/deleteAllTags`, + async (ids: Array) => { + const id = await send('tags-delete-all', ids); + return id; + }, +); + +export const updateTag = createAppAsyncThunk( + `${sliceName}/updateTag`, + async (tag: TagEntity) => { + const id = await send('tags-update', tag); + return id; + }, +); + +export const findTags = createAppAsyncThunk( + `${sliceName}/findTags`, + async () => { + const tags: TagEntity[] = await send('tags-find'); + return tags; + }, +); + +export const { name, reducer, getInitialState } = tagSlice; + +export const actions = { + ...tagSlice.actions, + getTags, + reloadTags, + createTag, + deleteTag, + deleteAllTags, + updateTag, + findTags, +}; + +export const { markTagsDirty } = tagSlice.actions; + +function _loadTags(state: TagsState, tags: TagsState['tags']) { + state.tags = tags; + state.isTagsLoading = false; + state.isTagsLoaded = true; + state.isTagsDirty = false; +} + +function _markTagsDirty(state: TagsState) { + state.isTagsDirty = true; +} diff --git a/packages/loot-core/src/server/tags/app.ts b/packages/loot-core/src/server/tags/app.ts index 4c7a285517..92be65c701 100644 --- a/packages/loot-core/src/server/tags/app.ts +++ b/packages/loot-core/src/server/tags/app.ts @@ -1,4 +1,4 @@ -import { Tag } from '../../types/models'; +import { TagEntity } from '../../types/models'; import { createApp } from '../app'; import * as db from '../db'; import { mutator } from '../mutators'; @@ -22,7 +22,7 @@ app.method('tags-delete-all', mutator(deleteAllTags)); app.method('tags-update', mutator(undoable(updateTag))); app.method('tags-find', mutator(findTags)); -async function getTags(): Promise { +async function getTags(): Promise { return await db.getTags(); } @@ -30,7 +30,7 @@ async function createTag({ tag, color = null, description = null, -}: Omit): Promise { +}: Omit): Promise { const allTags = await db.getAllTags(); const { id: tagId = null } = allTags.find(t => t.tag === tag) || {}; @@ -54,12 +54,14 @@ async function createTag({ return { id, tag, color, description }; } -async function deleteTag(tag: Tag): Promise { +async function deleteTag(tag: TagEntity): Promise { await db.deleteTag(tag); return tag.id; } -async function deleteAllTags(ids: Array): Promise> { +async function deleteAllTags( + ids: Array, +): Promise> { await batchMessages(async () => { for (const id of ids) { await db.deleteTag({ id }); @@ -68,12 +70,12 @@ async function deleteAllTags(ids: Array): Promise> { return ids; } -async function updateTag(tag: Tag): Promise { +async function updateTag(tag: TagEntity): Promise { await db.updateTag(tag); return tag; } -async function findTags(): Promise { +async function findTags(): Promise { const taggedNotes = await db.findTags(); const tags = await getTags(); diff --git a/packages/loot-core/src/types/models/tags.ts b/packages/loot-core/src/types/models/tags.ts index c5699e7036..1f1f369e06 100644 --- a/packages/loot-core/src/types/models/tags.ts +++ b/packages/loot-core/src/types/models/tags.ts @@ -1,4 +1,4 @@ -export interface Tag { +export interface TagEntity { id: string; tag: string; color?: string | null;