mirror of
https://github.com/actualbudget/actual.git
synced 2026-03-09 11:42:54 -05:00
Compare commits
9 Commits
coderabbit
...
allow-chil
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8c8ee889e6 | ||
|
|
82cbb9bffe | ||
|
|
d53b0499cf | ||
|
|
c512bed718 | ||
|
|
b6b19fe52c | ||
|
|
d108b0f3c5 | ||
|
|
c2602bd11a | ||
|
|
e04d6e5716 | ||
|
|
3fc4d1f85e |
@@ -16,9 +16,9 @@ import {
|
||||
import { resetApp } from '@desktop-client/app/appSlice';
|
||||
import { addNotification } from '@desktop-client/notifications/notificationsSlice';
|
||||
import { markPayeesDirty } from '@desktop-client/payees/payeesSlice';
|
||||
import { setNewTransactions } from '@desktop-client/queries/queriesSlice';
|
||||
import { createAppAsyncThunk } from '@desktop-client/redux';
|
||||
import { type AppDispatch } from '@desktop-client/redux/store';
|
||||
import { setNewTransactions } from '@desktop-client/transactions/transactionsSlice';
|
||||
|
||||
const sliceName = 'account';
|
||||
|
||||
@@ -520,6 +520,97 @@ export const moveAccount = createAppAsyncThunk(
|
||||
},
|
||||
);
|
||||
|
||||
type ImportPreviewTransactionsPayload = {
|
||||
accountId: string;
|
||||
transactions: TransactionEntity[];
|
||||
};
|
||||
|
||||
export const importPreviewTransactions = createAppAsyncThunk(
|
||||
`${sliceName}/importPreviewTransactions`,
|
||||
async (
|
||||
{ accountId, transactions }: ImportPreviewTransactionsPayload,
|
||||
{ dispatch },
|
||||
) => {
|
||||
const { errors = [], updatedPreview } = await send('transactions-import', {
|
||||
accountId,
|
||||
transactions,
|
||||
isPreview: true,
|
||||
});
|
||||
|
||||
errors.forEach(error => {
|
||||
dispatch(
|
||||
addNotification({
|
||||
notification: {
|
||||
type: 'error',
|
||||
message: error.message,
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
return updatedPreview;
|
||||
},
|
||||
);
|
||||
|
||||
type ImportTransactionsPayload = {
|
||||
accountId: string;
|
||||
transactions: TransactionEntity[];
|
||||
reconcile: boolean;
|
||||
};
|
||||
|
||||
export const importTransactions = createAppAsyncThunk(
|
||||
`${sliceName}/importTransactions`,
|
||||
async (
|
||||
{ accountId, transactions, reconcile }: ImportTransactionsPayload,
|
||||
{ dispatch },
|
||||
) => {
|
||||
if (!reconcile) {
|
||||
await send('api/transactions-add', {
|
||||
accountId,
|
||||
transactions,
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
const {
|
||||
errors = [],
|
||||
added,
|
||||
updated,
|
||||
} = await send('transactions-import', {
|
||||
accountId,
|
||||
transactions,
|
||||
isPreview: false,
|
||||
});
|
||||
|
||||
errors.forEach(error => {
|
||||
dispatch(
|
||||
addNotification({
|
||||
notification: {
|
||||
type: 'error',
|
||||
message: error.message,
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
dispatch(
|
||||
setNewTransactions({
|
||||
newTransactions: added,
|
||||
matchedTransactions: updated,
|
||||
}),
|
||||
);
|
||||
|
||||
dispatch(
|
||||
markUpdatedAccounts({
|
||||
ids: added.length > 0 ? [accountId] : [],
|
||||
}),
|
||||
);
|
||||
|
||||
return added.length > 0 || updated.length > 0;
|
||||
},
|
||||
);
|
||||
|
||||
export const getAccountsById = memoizeOne(
|
||||
(accounts: AccountEntity[] | null | undefined) => groupById(accounts),
|
||||
);
|
||||
|
||||
@@ -85,9 +85,9 @@ import {
|
||||
pagedQuery,
|
||||
type PagedQuery,
|
||||
} from '@desktop-client/queries/pagedQuery';
|
||||
import { updateNewTransactions } from '@desktop-client/queries/queriesSlice';
|
||||
import { useSelector, useDispatch } from '@desktop-client/redux';
|
||||
import { type AppDispatch } from '@desktop-client/redux/store';
|
||||
import { updateNewTransactions } from '@desktop-client/transactions/transactionsSlice';
|
||||
|
||||
type ConditionEntity = Partial<RuleConditionEntity> | TransactionFilterEntity;
|
||||
|
||||
@@ -1932,9 +1932,11 @@ export function Account() {
|
||||
const location = useLocation();
|
||||
|
||||
const { grouped: categoryGroups } = useCategories();
|
||||
const newTransactions = useSelector(state => state.queries.newTransactions);
|
||||
const newTransactions = useSelector(
|
||||
state => state.transactions.newTransactions,
|
||||
);
|
||||
const matchedTransactions = useSelector(
|
||||
state => state.queries.matchedTransactions,
|
||||
state => state.transactions.matchedTransactions,
|
||||
);
|
||||
const accounts = useAccounts();
|
||||
const payees = usePayees();
|
||||
|
||||
@@ -79,8 +79,8 @@ import {
|
||||
} from '@desktop-client/hooks/useSingleActiveEditForm';
|
||||
import { pushModal } from '@desktop-client/modals/modalsSlice';
|
||||
import { aqlQuery } from '@desktop-client/queries/aqlQuery';
|
||||
import { setLastTransaction } from '@desktop-client/queries/queriesSlice';
|
||||
import { useSelector, useDispatch } from '@desktop-client/redux';
|
||||
import { setLastTransaction } from '@desktop-client/transactions/transactionsSlice';
|
||||
|
||||
function getFieldName(transactionId, field) {
|
||||
return `${field}-${transactionId}`;
|
||||
|
||||
@@ -95,7 +95,9 @@ export function TransactionListItem({
|
||||
const transferAccount = useAccount(payee?.transfer_acct || '');
|
||||
const isPreview = isPreviewId(transaction?.id || '');
|
||||
|
||||
const newTransactions = useSelector(state => state.queries.newTransactions);
|
||||
const newTransactions = useSelector(
|
||||
state => state.transactions.newTransactions,
|
||||
);
|
||||
|
||||
const { longPressProps } = useLongPress({
|
||||
accessibilityDescription: 'Long press to select multiple transactions',
|
||||
|
||||
@@ -27,6 +27,10 @@ import {
|
||||
stripCsvImportTransaction,
|
||||
} from './utils';
|
||||
|
||||
import {
|
||||
importPreviewTransactions,
|
||||
importTransactions,
|
||||
} from '@desktop-client/accounts/accountsSlice';
|
||||
import {
|
||||
Modal,
|
||||
ModalCloseButton,
|
||||
@@ -41,10 +45,6 @@ import { useCategories } from '@desktop-client/hooks/useCategories';
|
||||
import { useDateFormat } from '@desktop-client/hooks/useDateFormat';
|
||||
import { useSyncedPrefs } from '@desktop-client/hooks/useSyncedPrefs';
|
||||
import { reloadPayees } from '@desktop-client/payees/payeesSlice';
|
||||
import {
|
||||
importPreviewTransactions,
|
||||
importTransactions,
|
||||
} from '@desktop-client/queries/queriesSlice';
|
||||
import { useDispatch } from '@desktop-client/redux';
|
||||
|
||||
function getFileType(filepath) {
|
||||
|
||||
@@ -21,8 +21,8 @@ import {
|
||||
useSelected,
|
||||
} from '@desktop-client/hooks/useSelected';
|
||||
import { useTags } from '@desktop-client/hooks/useTags';
|
||||
import { deleteAllTags, findTags } from '@desktop-client/queries/queriesSlice';
|
||||
import { useDispatch } from '@desktop-client/redux';
|
||||
import { deleteAllTags, findTags } from '@desktop-client/tags/tagsSlice';
|
||||
|
||||
export function ManageTags() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -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 { useDispatch } from '@desktop-client/redux';
|
||||
import { createTag } from '@desktop-client/tags/tagsSlice';
|
||||
|
||||
type TagCreationRowProps = {
|
||||
tags: Tag[];
|
||||
tags: TagEntity[];
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
|
||||
@@ -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 { useDispatch } from '@desktop-client/redux';
|
||||
import { updateTag } from '@desktop-client/tags/tagsSlice';
|
||||
|
||||
type TagEditorProps = {
|
||||
tag: Tag;
|
||||
tag: TagEntity;
|
||||
ref: RefObject<HTMLButtonElement | null>;
|
||||
};
|
||||
|
||||
|
||||
@@ -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 { useDispatch } from '@desktop-client/redux';
|
||||
import { deleteTag, updateTag } from '@desktop-client/tags/tagsSlice';
|
||||
|
||||
type TagRowProps = {
|
||||
tag: Tag;
|
||||
tag: TagEntity;
|
||||
hovered?: boolean;
|
||||
selected?: boolean;
|
||||
onHover: (id?: string) => void;
|
||||
|
||||
@@ -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<string>;
|
||||
hoveredTag?: string;
|
||||
onHover: (id?: string) => void;
|
||||
|
||||
@@ -2,13 +2,13 @@ import { useEffect } from 'react';
|
||||
|
||||
import { useInitialMount } from './useInitialMount';
|
||||
|
||||
import { getTags } from '@desktop-client/queries/queriesSlice';
|
||||
import { useDispatch, useSelector } from '@desktop-client/redux';
|
||||
import { getTags } from '@desktop-client/tags/tagsSlice';
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -31,8 +31,9 @@ import * as notificationsSlice from './notifications/notificationsSlice';
|
||||
import * as payeesSlice from './payees/payeesSlice';
|
||||
import * as prefsSlice from './prefs/prefsSlice';
|
||||
import { aqlQuery } from './queries/aqlQuery';
|
||||
import * as queriesSlice from './queries/queriesSlice';
|
||||
import { store } from './redux/store';
|
||||
import * as tagsSlice from './tags/tagsSlice';
|
||||
import * as transactionsSlice from './transactions/transactionsSlice';
|
||||
import { redo, undo } from './undo';
|
||||
import * as usersSlice from './users/usersSlice';
|
||||
|
||||
@@ -46,7 +47,8 @@ const boundActions = bindActionCreators(
|
||||
...notificationsSlice.actions,
|
||||
...payeesSlice.actions,
|
||||
...prefsSlice.actions,
|
||||
...queriesSlice.actions,
|
||||
...transactionsSlice.actions,
|
||||
...tagsSlice.actions,
|
||||
...usersSlice.actions,
|
||||
},
|
||||
store.dispatch,
|
||||
|
||||
@@ -1,324 +0,0 @@
|
||||
// @ts-strict-ignore
|
||||
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 { markUpdatedAccounts } from '@desktop-client/accounts/accountsSlice';
|
||||
import { resetApp } from '@desktop-client/app/appSlice';
|
||||
import { addNotification } from '@desktop-client/notifications/notificationsSlice';
|
||||
import { createAppAsyncThunk } from '@desktop-client/redux';
|
||||
|
||||
const sliceName = 'queries';
|
||||
|
||||
type QueriesState = {
|
||||
newTransactions: Array<TransactionEntity['id']>;
|
||||
matchedTransactions: Array<TransactionEntity['id']>;
|
||||
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 = {
|
||||
newTransactions: Array<TransactionEntity['id']>;
|
||||
matchedTransactions: Array<TransactionEntity['id']>;
|
||||
};
|
||||
|
||||
type UpdateNewTransactionsPayload = {
|
||||
id: TransactionEntity['id'];
|
||||
};
|
||||
|
||||
type SetLastTransactionPayload = {
|
||||
transaction: TransactionEntity;
|
||||
};
|
||||
|
||||
const queriesSlice = createSlice({
|
||||
name: sliceName,
|
||||
initialState,
|
||||
reducers: {
|
||||
setNewTransactions(
|
||||
state,
|
||||
action: PayloadAction<SetNewTransactionsPayload>,
|
||||
) {
|
||||
state.newTransactions = action.payload.newTransactions
|
||||
? [...state.newTransactions, ...action.payload.newTransactions]
|
||||
: state.newTransactions;
|
||||
|
||||
state.matchedTransactions = action.payload.matchedTransactions
|
||||
? [...state.matchedTransactions, ...action.payload.matchedTransactions]
|
||||
: state.matchedTransactions;
|
||||
},
|
||||
updateNewTransactions(
|
||||
state,
|
||||
action: PayloadAction<UpdateNewTransactionsPayload>,
|
||||
) {
|
||||
state.newTransactions = state.newTransactions.filter(
|
||||
id => id !== action.payload.id,
|
||||
);
|
||||
state.matchedTransactions = state.matchedTransactions.filter(
|
||||
id => id !== action.payload.id,
|
||||
);
|
||||
},
|
||||
setLastTransaction(
|
||||
state,
|
||||
action: PayloadAction<SetLastTransactionPayload>,
|
||||
) {
|
||||
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<Tag, 'id'>) => {
|
||||
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<Tag['id']>) => {
|
||||
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 = {
|
||||
accountId: string;
|
||||
transactions: TransactionEntity[];
|
||||
};
|
||||
|
||||
export const importPreviewTransactions = createAppAsyncThunk(
|
||||
`${sliceName}/importPreviewTransactions`,
|
||||
async (
|
||||
{ accountId, transactions }: ImportPreviewTransactionsPayload,
|
||||
{ dispatch },
|
||||
) => {
|
||||
const { errors = [], updatedPreview } = await send('transactions-import', {
|
||||
accountId,
|
||||
transactions,
|
||||
isPreview: true,
|
||||
});
|
||||
|
||||
errors.forEach(error => {
|
||||
dispatch(
|
||||
addNotification({
|
||||
notification: {
|
||||
type: 'error',
|
||||
message: error.message,
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
return updatedPreview;
|
||||
},
|
||||
);
|
||||
|
||||
type ImportTransactionsPayload = {
|
||||
accountId: string;
|
||||
transactions: TransactionEntity[];
|
||||
reconcile: boolean;
|
||||
};
|
||||
|
||||
export const importTransactions = createAppAsyncThunk(
|
||||
`${sliceName}/importTransactions`,
|
||||
async (
|
||||
{ accountId, transactions, reconcile }: ImportTransactionsPayload,
|
||||
{ dispatch },
|
||||
) => {
|
||||
if (!reconcile) {
|
||||
await send('api/transactions-add', {
|
||||
accountId,
|
||||
transactions,
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
const {
|
||||
errors = [],
|
||||
added,
|
||||
updated,
|
||||
} = await send('transactions-import', {
|
||||
accountId,
|
||||
transactions,
|
||||
isPreview: false,
|
||||
});
|
||||
|
||||
errors.forEach(error => {
|
||||
dispatch(
|
||||
addNotification({
|
||||
notification: {
|
||||
type: 'error',
|
||||
message: error.message,
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
const { setNewTransactions } = queriesSlice.actions;
|
||||
|
||||
dispatch(
|
||||
setNewTransactions({
|
||||
newTransactions: added,
|
||||
matchedTransactions: updated,
|
||||
}),
|
||||
);
|
||||
|
||||
dispatch(
|
||||
markUpdatedAccounts({
|
||||
ids: added.length > 0 ? [accountId] : [],
|
||||
}),
|
||||
);
|
||||
|
||||
return added.length > 0 || updated.length > 0;
|
||||
},
|
||||
);
|
||||
|
||||
// Slice exports
|
||||
|
||||
export const { name, reducer, getInitialState } = queriesSlice;
|
||||
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;
|
||||
}
|
||||
@@ -38,9 +38,13 @@ import {
|
||||
reducer as prefsSliceReducer,
|
||||
} from '@desktop-client/prefs/prefsSlice';
|
||||
import {
|
||||
name as queriesSliceName,
|
||||
reducer as queriesSliceReducer,
|
||||
} from '@desktop-client/queries/queriesSlice';
|
||||
name as tagsSliceName,
|
||||
reducer as tagsSliceReducer,
|
||||
} from '@desktop-client/tags/tagsSlice';
|
||||
import {
|
||||
name as transactionsSliceName,
|
||||
reducer as transactionsSliceReducer,
|
||||
} from '@desktop-client/transactions/transactionsSlice';
|
||||
import {
|
||||
name as usersSliceName,
|
||||
reducer as usersSliceReducer,
|
||||
@@ -55,7 +59,8 @@ const appReducer = combineReducers({
|
||||
[notificationsSliceName]: notificationsSliceReducer,
|
||||
[payeesSliceName]: payeesSliceReducer,
|
||||
[prefsSliceName]: prefsSliceReducer,
|
||||
[queriesSliceName]: queriesSliceReducer,
|
||||
[transactionsSliceName]: transactionsSliceReducer,
|
||||
[tagsSliceName]: tagsSliceReducer,
|
||||
[usersSliceName]: usersSliceReducer,
|
||||
});
|
||||
|
||||
|
||||
@@ -39,9 +39,13 @@ import {
|
||||
reducer as prefsSliceReducer,
|
||||
} from '@desktop-client/prefs/prefsSlice';
|
||||
import {
|
||||
name as queriesSliceName,
|
||||
reducer as queriesSliceReducer,
|
||||
} from '@desktop-client/queries/queriesSlice';
|
||||
name as tagsSliceName,
|
||||
reducer as tagsSliceReducer,
|
||||
} from '@desktop-client/tags/tagsSlice';
|
||||
import {
|
||||
name as transactionsSliceName,
|
||||
reducer as transactionsSliceReducer,
|
||||
} from '@desktop-client/transactions/transactionsSlice';
|
||||
import {
|
||||
name as usersSliceName,
|
||||
reducer as usersSliceReducer,
|
||||
@@ -56,7 +60,8 @@ const rootReducer = combineReducers({
|
||||
[notificationsSliceName]: notificationsSliceReducer,
|
||||
[payeesSliceName]: payeesSliceReducer,
|
||||
[prefsSliceName]: prefsSliceReducer,
|
||||
[queriesSliceName]: queriesSliceReducer,
|
||||
[transactionsSliceName]: transactionsSliceReducer,
|
||||
[tagsSliceName]: tagsSliceReducer,
|
||||
[usersSliceName]: usersSliceReducer,
|
||||
});
|
||||
|
||||
|
||||
165
packages/desktop-client/src/tags/tagsSlice.ts
Normal file
165
packages/desktop-client/src/tags/tagsSlice.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
import { createSlice } from '@reduxjs/toolkit';
|
||||
|
||||
import { send } from 'loot-core/platform/client/fetch';
|
||||
import { type 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<TagEntity, 'id'>) => {
|
||||
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<TagEntity['id']>) => {
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { createSlice, type PayloadAction } from '@reduxjs/toolkit';
|
||||
|
||||
import { type TransactionEntity } from 'loot-core/types/models';
|
||||
|
||||
import { resetApp } from '@desktop-client/app/appSlice';
|
||||
|
||||
const sliceName = 'transactions';
|
||||
|
||||
type TransactionsState = {
|
||||
newTransactions: Array<TransactionEntity['id']>;
|
||||
matchedTransactions: Array<TransactionEntity['id']>;
|
||||
lastTransaction: TransactionEntity | null;
|
||||
};
|
||||
|
||||
const initialState: TransactionsState = {
|
||||
newTransactions: [],
|
||||
matchedTransactions: [],
|
||||
lastTransaction: null,
|
||||
};
|
||||
|
||||
type SetNewTransactionsPayload = {
|
||||
newTransactions: Array<TransactionEntity['id']>;
|
||||
matchedTransactions: Array<TransactionEntity['id']>;
|
||||
};
|
||||
|
||||
type UpdateNewTransactionsPayload = {
|
||||
id: TransactionEntity['id'];
|
||||
};
|
||||
|
||||
type SetLastTransactionPayload = {
|
||||
transaction: TransactionEntity;
|
||||
};
|
||||
|
||||
const transactionsSlice = createSlice({
|
||||
name: sliceName,
|
||||
initialState,
|
||||
reducers: {
|
||||
setNewTransactions(
|
||||
state,
|
||||
action: PayloadAction<SetNewTransactionsPayload>,
|
||||
) {
|
||||
state.newTransactions = action.payload.newTransactions
|
||||
? [...state.newTransactions, ...action.payload.newTransactions]
|
||||
: state.newTransactions;
|
||||
|
||||
state.matchedTransactions = action.payload.matchedTransactions
|
||||
? [...state.matchedTransactions, ...action.payload.matchedTransactions]
|
||||
: state.matchedTransactions;
|
||||
},
|
||||
updateNewTransactions(
|
||||
state,
|
||||
action: PayloadAction<UpdateNewTransactionsPayload>,
|
||||
) {
|
||||
state.newTransactions = state.newTransactions.filter(
|
||||
id => id !== action.payload.id,
|
||||
);
|
||||
state.matchedTransactions = state.matchedTransactions.filter(
|
||||
id => id !== action.payload.id,
|
||||
);
|
||||
},
|
||||
setLastTransaction(
|
||||
state,
|
||||
action: PayloadAction<SetLastTransactionPayload>,
|
||||
) {
|
||||
state.lastTransaction = action.payload.transaction;
|
||||
},
|
||||
},
|
||||
extraReducers: builder => {
|
||||
builder.addCase(resetApp, () => initialState);
|
||||
},
|
||||
});
|
||||
|
||||
export const { name, reducer, getInitialState } = transactionsSlice;
|
||||
export const actions = {
|
||||
...transactionsSlice.actions,
|
||||
};
|
||||
|
||||
export const { setNewTransactions, updateNewTransactions, setLastTransaction } =
|
||||
transactionsSlice.actions;
|
||||
@@ -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<Tag[]> {
|
||||
async function getTags(): Promise<TagEntity[]> {
|
||||
return await db.getTags();
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ async function createTag({
|
||||
tag,
|
||||
color = null,
|
||||
description = null,
|
||||
}: Omit<Tag, 'id'>): Promise<Tag> {
|
||||
}: Omit<TagEntity, 'id'>): Promise<TagEntity> {
|
||||
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<Tag['id']> {
|
||||
async function deleteTag(tag: TagEntity): Promise<TagEntity['id']> {
|
||||
await db.deleteTag(tag);
|
||||
return tag.id;
|
||||
}
|
||||
|
||||
async function deleteAllTags(ids: Array<Tag['id']>): Promise<Array<Tag['id']>> {
|
||||
async function deleteAllTags(
|
||||
ids: Array<TagEntity['id']>,
|
||||
): Promise<Array<TagEntity['id']>> {
|
||||
await batchMessages(async () => {
|
||||
for (const id of ids) {
|
||||
await db.deleteTag({ id });
|
||||
@@ -68,12 +70,12 @@ async function deleteAllTags(ids: Array<Tag['id']>): Promise<Array<Tag['id']>> {
|
||||
return ids;
|
||||
}
|
||||
|
||||
async function updateTag(tag: Tag): Promise<Tag> {
|
||||
async function updateTag(tag: TagEntity): Promise<TagEntity> {
|
||||
await db.updateTag(tag);
|
||||
return tag;
|
||||
}
|
||||
|
||||
async function findTags(): Promise<Tag[]> {
|
||||
async function findTags(): Promise<TagEntity[]> {
|
||||
const taggedNotes = await db.findTags();
|
||||
|
||||
const tags = await getTags();
|
||||
|
||||
@@ -5,10 +5,9 @@ export function validForTransfer(
|
||||
toTransaction: TransactionEntity,
|
||||
) {
|
||||
if (
|
||||
// no subtransactions
|
||||
// not already a transfer
|
||||
[fromTransaction, toTransaction].every(tran => {
|
||||
return tran.transfer_id == null && tran.is_child === false;
|
||||
return tran.transfer_id == null;
|
||||
}) &&
|
||||
fromTransaction.account !== toTransaction.account && // belong to different accounts
|
||||
fromTransaction.amount + toTransaction.amount === 0 // amount must zero each other out
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export interface Tag {
|
||||
export interface TagEntity {
|
||||
id: string;
|
||||
tag: string;
|
||||
color?: string | null;
|
||||
|
||||
6
upcoming-release-notes/5597.md
Normal file
6
upcoming-release-notes/5597.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Maintenance
|
||||
authors: [joel-jeremy]
|
||||
---
|
||||
|
||||
[Redux] Move tags states from queriesSlice to tagsSlice
|
||||
6
upcoming-release-notes/5603.md
Normal file
6
upcoming-release-notes/5603.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Enhancements
|
||||
authors: [joel-jeremy]
|
||||
---
|
||||
|
||||
Enable Make Transfer menu for child transactions
|
||||
Reference in New Issue
Block a user