mirror of
https://github.com/actualbudget/actual.git
synced 2026-05-11 01:18:59 -05:00
Compare commits
11 Commits
MatissJani
...
claude/hid
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
428dea9823 | ||
|
|
984702dd6c | ||
|
|
2fd88c2d71 | ||
|
|
fdf1196f8a | ||
|
|
89dfd7b6d8 | ||
|
|
e5997f97a8 | ||
|
|
07b9dcacad | ||
|
|
2a3f943660 | ||
|
|
b9ab3e7bc6 | ||
|
|
4f40defe9e | ||
|
|
7fe4a2f573 |
17
.github/workflows/vrt-update-generate.yml
vendored
17
.github/workflows/vrt-update-generate.yml
vendored
@@ -82,16 +82,17 @@ jobs:
|
||||
with:
|
||||
download-translations: 'false'
|
||||
- name: Build browser bundle
|
||||
# REACT_APP_NETLIFY=true keeps the "Create test file" button in the
|
||||
# production bundle — every VRT test's beforeEach relies on it via
|
||||
# ConfigurationPage.createTestFile().
|
||||
# REACT_APP_NETLIFY=true flips isNonProductionEnvironment() on in the
|
||||
# bundle so the "Create test file" button (used by every e2e beforeEach
|
||||
# via ConfigurationPage.createTestFile()) is still rendered in a
|
||||
# production build. Without it, e2e tests would time out waiting for
|
||||
# a button that was tree-shaken out.
|
||||
# --skip-translations keeps VRT screenshots deterministic by rendering
|
||||
# source-code English instead of upstream Weblate en.json (which can
|
||||
# drift between snapshot capture and test runs).
|
||||
env:
|
||||
REACT_APP_NETLIFY: 'true'
|
||||
run: |
|
||||
yarn workspace plugins-service build
|
||||
yarn workspace @actual-app/crdt build
|
||||
yarn workspace @actual-app/core build:browser
|
||||
yarn workspace @actual-app/web build:browser
|
||||
run: yarn build:browser --skip-translations
|
||||
- name: Upload build artifact
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
|
||||
@@ -204,8 +204,8 @@ export function getAccountBalance(id: APIAccountEntity['id'], cutoff?: Date) {
|
||||
return send('api/account-balance', { id, cutoff });
|
||||
}
|
||||
|
||||
export function getCategoryGroups() {
|
||||
return send('api/category-groups-get');
|
||||
export function getCategoryGroups(options: { hidden?: boolean } = {}) {
|
||||
return send('api/category-groups-get', options);
|
||||
}
|
||||
|
||||
export function createCategoryGroup(group: Omit<APICategoryGroupEntity, 'id'>) {
|
||||
@@ -226,8 +226,8 @@ export function deleteCategoryGroup(
|
||||
return send('api/category-group-delete', { id, transferCategoryId });
|
||||
}
|
||||
|
||||
export function getCategories() {
|
||||
return send('api/categories-get', { grouped: false });
|
||||
export function getCategories(options: { hidden?: boolean } = {}) {
|
||||
return send('api/categories-get', { grouped: false, ...options });
|
||||
}
|
||||
|
||||
export function createCategory(category: Omit<APICategoryEntity, 'id'>) {
|
||||
|
||||
131
packages/cli/src/commands/categories.test.ts
Normal file
131
packages/cli/src/commands/categories.test.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import * as api from '@actual-app/api';
|
||||
import { Command } from 'commander';
|
||||
|
||||
import { printOutput } from '#output';
|
||||
|
||||
import { registerCategoriesCommand } from './categories';
|
||||
import { registerCategoryGroupsCommand } from './category-groups';
|
||||
|
||||
vi.mock('@actual-app/api', () => ({
|
||||
getCategories: vi.fn().mockResolvedValue([]),
|
||||
createCategory: vi.fn().mockResolvedValue('new-id'),
|
||||
updateCategory: vi.fn().mockResolvedValue(undefined),
|
||||
deleteCategory: vi.fn().mockResolvedValue(undefined),
|
||||
getCategoryGroups: vi.fn().mockResolvedValue([]),
|
||||
createCategoryGroup: vi.fn().mockResolvedValue('new-group-id'),
|
||||
updateCategoryGroup: vi.fn().mockResolvedValue(undefined),
|
||||
deleteCategoryGroup: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock('#connection', () => ({
|
||||
withConnection: vi.fn((_opts, fn) => fn()),
|
||||
}));
|
||||
|
||||
vi.mock('#output', () => ({
|
||||
printOutput: vi.fn(),
|
||||
}));
|
||||
|
||||
function createProgram(): Command {
|
||||
const program = new Command();
|
||||
program.option('--format <format>');
|
||||
program.option('--server-url <url>');
|
||||
program.option('--password <pw>');
|
||||
program.option('--session-token <token>');
|
||||
program.option('--sync-id <id>');
|
||||
program.option('--data-dir <dir>');
|
||||
program.option('--verbose');
|
||||
program.exitOverride();
|
||||
registerCategoriesCommand(program);
|
||||
registerCategoryGroupsCommand(program);
|
||||
return program;
|
||||
}
|
||||
|
||||
async function run(args: string[]) {
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', ...args]);
|
||||
}
|
||||
|
||||
describe('categories commands', () => {
|
||||
let stderrSpy: ReturnType<typeof vi.spyOn>;
|
||||
let stdoutSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
stderrSpy = vi
|
||||
.spyOn(process.stderr, 'write')
|
||||
.mockImplementation(() => true);
|
||||
stdoutSpy = vi
|
||||
.spyOn(process.stdout, 'write')
|
||||
.mockImplementation(() => true);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
stderrSpy.mockRestore();
|
||||
stdoutSpy.mockRestore();
|
||||
});
|
||||
|
||||
describe('categories list', () => {
|
||||
it('asks the API to exclude hidden categories by default', async () => {
|
||||
await run(['categories', 'list']);
|
||||
|
||||
expect(api.getCategories).toHaveBeenCalledWith({ hidden: false });
|
||||
});
|
||||
|
||||
it('asks the API for all categories when --include-hidden is passed', async () => {
|
||||
await run(['categories', 'list', '--include-hidden']);
|
||||
|
||||
expect(api.getCategories).toHaveBeenCalledWith({});
|
||||
});
|
||||
|
||||
it('prints whatever the API returns', async () => {
|
||||
const visible = {
|
||||
id: '1',
|
||||
name: 'Visible',
|
||||
group_id: 'g1',
|
||||
hidden: false,
|
||||
};
|
||||
vi.mocked(api.getCategories).mockResolvedValue([visible]);
|
||||
|
||||
await run(['categories', 'list']);
|
||||
|
||||
expect(printOutput).toHaveBeenCalledWith([visible], undefined);
|
||||
});
|
||||
|
||||
it('passes format option to printOutput', async () => {
|
||||
vi.mocked(api.getCategories).mockResolvedValue([]);
|
||||
|
||||
await run(['--format', 'csv', 'categories', 'list']);
|
||||
|
||||
expect(printOutput).toHaveBeenCalledWith([], 'csv');
|
||||
});
|
||||
});
|
||||
|
||||
describe('category-groups list', () => {
|
||||
it('asks the API to exclude hidden groups by default', async () => {
|
||||
await run(['category-groups', 'list']);
|
||||
|
||||
expect(api.getCategoryGroups).toHaveBeenCalledWith({ hidden: false });
|
||||
});
|
||||
|
||||
it('asks the API for all groups when --include-hidden is passed', async () => {
|
||||
await run(['category-groups', 'list', '--include-hidden']);
|
||||
|
||||
expect(api.getCategoryGroups).toHaveBeenCalledWith({});
|
||||
});
|
||||
|
||||
it('prints whatever the API returns', async () => {
|
||||
const group = {
|
||||
id: 'g1',
|
||||
name: 'Group',
|
||||
is_income: false,
|
||||
hidden: false,
|
||||
categories: [{ id: 'c1', name: 'Cat', group_id: 'g1', hidden: false }],
|
||||
};
|
||||
vi.mocked(api.getCategoryGroups).mockResolvedValue([group]);
|
||||
|
||||
await run(['category-groups', 'list']);
|
||||
|
||||
expect(printOutput).toHaveBeenCalledWith([group], undefined);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -12,13 +12,16 @@ export function registerCategoriesCommand(program: Command) {
|
||||
|
||||
categories
|
||||
.command('list')
|
||||
.description('List all categories')
|
||||
.action(async () => {
|
||||
.description('List categories (excludes hidden by default)')
|
||||
.option('--include-hidden', 'Include hidden categories', false)
|
||||
.action(async cmdOpts => {
|
||||
const opts = program.opts();
|
||||
await withConnection(
|
||||
opts,
|
||||
async () => {
|
||||
const result = await api.getCategories();
|
||||
const result = await api.getCategories(
|
||||
cmdOpts.includeHidden ? {} : { hidden: false },
|
||||
);
|
||||
printOutput(result, opts.format);
|
||||
},
|
||||
{ mutates: false },
|
||||
|
||||
@@ -12,13 +12,16 @@ export function registerCategoryGroupsCommand(program: Command) {
|
||||
|
||||
groups
|
||||
.command('list')
|
||||
.description('List all category groups')
|
||||
.action(async () => {
|
||||
.description('List category groups (excludes hidden by default)')
|
||||
.option('--include-hidden', 'Include hidden groups and categories', false)
|
||||
.action(async cmdOpts => {
|
||||
const opts = program.opts();
|
||||
await withConnection(
|
||||
opts,
|
||||
async () => {
|
||||
const result = await api.getCategoryGroups();
|
||||
const result = await api.getCategoryGroups(
|
||||
cmdOpts.includeHidden ? {} : { hidden: false },
|
||||
);
|
||||
printOutput(result, opts.format);
|
||||
},
|
||||
{ mutates: false },
|
||||
|
||||
@@ -190,9 +190,11 @@ export const FocusableAmountInput = memo(function FocusableAmountInput({
|
||||
buttonProps,
|
||||
onFocus,
|
||||
onBlur,
|
||||
onChangeValue,
|
||||
...props
|
||||
}: FocusableAmountInputProps) {
|
||||
const [isNegative, setIsNegative] = useState(true);
|
||||
const [liveValue, setLiveValue] = useState(Math.abs(value));
|
||||
|
||||
const maybeApplyNegative = (amount: number, negative: boolean) => {
|
||||
const absValue = Math.abs(amount);
|
||||
@@ -203,6 +205,15 @@ export const FocusableAmountInput = memo(function FocusableAmountInput({
|
||||
props.onUpdateAmount?.(maybeApplyNegative(amount, negative));
|
||||
};
|
||||
|
||||
const handleChangeValue = (text: string) => {
|
||||
setLiveValue(currencyToAmount(text) || 0);
|
||||
onChangeValue?.(text);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setLiveValue(Math.abs(value));
|
||||
}, [value]);
|
||||
|
||||
useEffect(() => {
|
||||
if (sign) {
|
||||
setIsNegative(sign === '-');
|
||||
@@ -227,10 +238,11 @@ export const FocusableAmountInput = memo(function FocusableAmountInput({
|
||||
value={value}
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
onChangeValue={handleChangeValue}
|
||||
onUpdateAmount={amount => onUpdateAmount(amount, isNegative)}
|
||||
focused={focused && !disabled}
|
||||
style={{
|
||||
...makeAmountFullStyle(value, {
|
||||
...makeAmountFullStyle(maybeApplyNegative(liveValue, isNegative), {
|
||||
zeroColor: isNegative ? theme.numberNegative : theme.numberNeutral,
|
||||
positiveColor: theme.numberPositive,
|
||||
negativeColor: theme.numberNegative,
|
||||
|
||||
@@ -445,9 +445,13 @@ let accounts = await getAccounts();
|
||||
|
||||
#### `getCategories`
|
||||
|
||||
<Method name="getCategories" args={[]} returns="Promise<Category[]>" />
|
||||
<Method name="getCategories" args={[{ name: 'options = {}', type: 'object?' }]} returns="Promise<Category[]>" />
|
||||
|
||||
Get all categories.
|
||||
Get categories. By default, returns every category.
|
||||
|
||||
The `options` object supports:
|
||||
|
||||
- `hidden`: filter by hidden status. Pass `false` to return only visible categories, or `true` to return only hidden ones. Omit to return both.
|
||||
|
||||
#### `createCategory`
|
||||
|
||||
@@ -500,9 +504,13 @@ There should only ever be one income category group,
|
||||
|
||||
#### `getCategoryGroups`
|
||||
|
||||
<Method name="getCategoryGroups" args={[]} returns="Promise<CategoryGroup[]>" />
|
||||
<Method name="getCategoryGroups" args={[{ name: 'options = {}', type: 'object?' }]} returns="Promise<CategoryGroup[]>" />
|
||||
|
||||
Get all category groups.
|
||||
Get category groups. By default, returns every group with all of its categories nested under it.
|
||||
|
||||
The `options` object supports:
|
||||
|
||||
- `hidden`: filter by hidden status, applied to both groups and their nested categories. Pass `false` to return only visible groups and categories, or `true` to return only hidden ones. Omit to return both.
|
||||
|
||||
#### `createCategoryGroup`
|
||||
|
||||
|
||||
@@ -630,17 +630,20 @@ handlers['api/account-balance'] = withMutation(async function ({
|
||||
|
||||
handlers['api/categories-get'] = async function ({
|
||||
grouped,
|
||||
}: { grouped? } = {}) {
|
||||
hidden,
|
||||
}: { grouped?: boolean; hidden?: boolean } = {}) {
|
||||
checkFileOpen();
|
||||
const result = await handlers['get-categories']();
|
||||
const result = await handlers['get-categories']({ hidden });
|
||||
return grouped
|
||||
? result.grouped.map(group => categoryGroupModel.toExternal(group))
|
||||
: result.list.map(category => categoryModel.toExternal(category));
|
||||
};
|
||||
|
||||
handlers['api/category-groups-get'] = async function () {
|
||||
handlers['api/category-groups-get'] = async function ({
|
||||
hidden,
|
||||
}: { hidden?: boolean } = {}) {
|
||||
checkFileOpen();
|
||||
const groups = await handlers['get-category-groups']();
|
||||
const groups = await handlers['get-category-groups']({ hidden });
|
||||
return groups.map(group => categoryGroupModel.toExternal(group));
|
||||
};
|
||||
|
||||
|
||||
@@ -170,11 +170,23 @@ app.method(
|
||||
app.method('budget/render-note-templates', goalNoteActions.unparse);
|
||||
|
||||
// Server must return AQL entities not the raw DB data
|
||||
async function getCategories() {
|
||||
const categoryGroups = await getCategoryGroups();
|
||||
async function getCategories({ hidden }: { hidden?: boolean } = {}) {
|
||||
const categoryGroups = await getCategoryGroups({ hidden });
|
||||
let list: CategoryEntity[];
|
||||
if (hidden === true) {
|
||||
// A hidden category can live in a visible group, so when the caller
|
||||
// explicitly asks for hidden categories the flat list must look beyond
|
||||
// the (already hidden-filtered) groups returned above.
|
||||
const { data }: { data: CategoryEntity[] } = await aqlQuery(
|
||||
q('categories').filter({ hidden: true }).select('*'),
|
||||
);
|
||||
list = data;
|
||||
} else {
|
||||
list = categoryGroups.flatMap(g => g.categories ?? []);
|
||||
}
|
||||
return {
|
||||
grouped: categoryGroups,
|
||||
list: categoryGroups.flatMap(g => g.categories ?? []),
|
||||
list,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -388,10 +400,18 @@ async function deleteCategory({
|
||||
}
|
||||
|
||||
// Server must return AQL entities not the raw DB data
|
||||
async function getCategoryGroups() {
|
||||
async function getCategoryGroups({ hidden }: { hidden?: boolean } = {}) {
|
||||
const baseQuery = q('category_groups').select('*');
|
||||
const query = hidden === undefined ? baseQuery : baseQuery.filter({ hidden });
|
||||
const { data: categoryGroups }: { data: CategoryGroupEntity[] } =
|
||||
await aqlQuery(q('category_groups').select('*'));
|
||||
return categoryGroups;
|
||||
await aqlQuery(query);
|
||||
if (hidden === undefined) {
|
||||
return categoryGroups;
|
||||
}
|
||||
return categoryGroups.map(g => ({
|
||||
...g,
|
||||
categories: g.categories?.filter(c => Boolean(c.hidden) === hidden),
|
||||
}));
|
||||
}
|
||||
|
||||
async function createCategoryGroup({
|
||||
|
||||
@@ -164,9 +164,12 @@ export type ApiHandlers = {
|
||||
|
||||
'api/categories-get': (arg: {
|
||||
grouped?: boolean;
|
||||
hidden?: boolean;
|
||||
}) => Promise<Array<APICategoryGroupEntity | APICategoryEntity>>;
|
||||
|
||||
'api/category-groups-get': () => Promise<APICategoryGroupEntity[]>;
|
||||
'api/category-groups-get': (arg?: {
|
||||
hidden?: boolean;
|
||||
}) => Promise<APICategoryGroupEntity[]>;
|
||||
|
||||
'api/category-group-create': (arg: {
|
||||
group: Omit<APICategoryGroupEntity, 'id'>;
|
||||
|
||||
6
upcoming-release-notes/7774.md
Normal file
6
upcoming-release-notes/7774.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Bugfix
|
||||
authors: [MatissJanis]
|
||||
---
|
||||
|
||||
Mobile: add live value tracking for user input in mobile transactions.
|
||||
6
upcoming-release-notes/7781.md
Normal file
6
upcoming-release-notes/7781.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Maintenance
|
||||
authors: [MatissJanis]
|
||||
---
|
||||
|
||||
Fix update-vrt workflow
|
||||
6
upcoming-release-notes/7786.md
Normal file
6
upcoming-release-notes/7786.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Enhancements
|
||||
authors: [Matissjanis]
|
||||
---
|
||||
|
||||
CLI: hide hidden categories by default in list commands.
|
||||
Reference in New Issue
Block a user