From 2bca0b9bb7aa4c0062f281d2baa73f67140ada66 Mon Sep 17 00:00:00 2001 From: Manuel Miranda Date: Wed, 11 Oct 2023 16:22:09 +0800 Subject: [PATCH] Onboarding v2 (#271) --- package.json | 1 + .../accounts/__snapshots__/page.test.tsx.snap | 5 +- .../app/dashboard/accounts/page.test.tsx | 29 +- src/__tests__/components/Table.test.tsx | 4 + .../__snapshots__/Table.test.tsx.snap | 1 + .../forms/account/AccountForm.test.tsx | 40 ++- .../forms/commodity/Commodityform.test.tsx | 86 +++++ .../__snapshots__/Commodityform.test.tsx.snap | 127 +++++++ .../transaction/TransactionForm.test.tsx | 2 +- .../onboarding/CustomTooltip.test.tsx | 19 + .../components/onboarding/Onboarding.test.tsx | 194 ++++++++++ .../__snapshots__/CustomTooltip.test.tsx.snap | 11 + .../pages/account/TransactionsTable.test.tsx | 2 + .../pages/accounts/AccountsTable.test.tsx | 7 + .../__snapshots__/AccountsTable.test.tsx.snap | 14 +- .../LatestTransactions.test.tsx.snap | 6 + .../investments/InvestmentsTable.test.tsx | 1 + .../selectors/AccountSelector.test.tsx | 75 +++- src/__tests__/lib/queries/getAccounts.test.ts | 4 +- src/app/dashboard/accounts/page.tsx | 8 +- src/book/entities/Account.ts | 12 + src/components/Table.tsx | 8 +- src/components/forms/account/AccountForm.tsx | 26 +- .../forms/commodity/CommodityForm.tsx | 78 ++++ src/components/onboarding/CustomTooltip.tsx | 16 + src/components/onboarding/Onboarding.tsx | 340 ++++++++++++++++++ .../pages/account/TransactionsTable.tsx | 1 + .../pages/accounts/AccountsTable.tsx | 49 ++- .../pages/accounts/LatestTransactions.tsx | 3 + .../pages/investments/InvestmentsTable.tsx | 1 + src/components/selectors/AccountSelector.tsx | 18 +- src/css/globals.css | 18 +- src/lib/queries/getAccounts.ts | 2 +- src/lib/queries/getEarliestDate.ts | 2 +- yarn.lock | 120 ++++++- 35 files changed, 1262 insertions(+), 68 deletions(-) create mode 100644 src/__tests__/components/forms/commodity/Commodityform.test.tsx create mode 100644 src/__tests__/components/forms/commodity/__snapshots__/Commodityform.test.tsx.snap create mode 100644 src/__tests__/components/onboarding/CustomTooltip.test.tsx create mode 100644 src/__tests__/components/onboarding/Onboarding.test.tsx create mode 100644 src/__tests__/components/onboarding/__snapshots__/CustomTooltip.test.tsx.snap create mode 100644 src/components/forms/commodity/CommodityForm.tsx create mode 100644 src/components/onboarding/CustomTooltip.tsx create mode 100644 src/components/onboarding/Onboarding.tsx diff --git a/package.json b/package.json index 9227c6de..b0ff5177 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "react-hook-form": "^7.47.0", "react-hotkeys-hook": "^4.4.1", "react-icons": "^4.11.0", + "react-joyride": "^2.6.0", "react-modal": "^3.16.1", "react-select": "^5.7.7", "react-tailwindcss-datepicker": "^1.6.6", diff --git a/src/__tests__/app/dashboard/accounts/__snapshots__/page.test.tsx.snap b/src/__tests__/app/dashboard/accounts/__snapshots__/page.test.tsx.snap index 03a7156e..1adab0c7 100644 --- a/src/__tests__/app/dashboard/accounts/__snapshots__/page.test.tsx.snap +++ b/src/__tests__/app/dashboard/accounts/__snapshots__/page.test.tsx.snap @@ -1,7 +1,10 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`AccountsPage passes empty data to components when no data ready 1`] = ` +exports[`AccountsPage shows onboarding when no data 1`] = `
+
diff --git a/src/__tests__/app/dashboard/accounts/page.test.tsx b/src/__tests__/app/dashboard/accounts/page.test.tsx index 875a9178..324cd3f2 100644 --- a/src/__tests__/app/dashboard/accounts/page.test.tsx +++ b/src/__tests__/app/dashboard/accounts/page.test.tsx @@ -10,6 +10,7 @@ import type { Account } from '@/book/entities'; import AccountsPage from '@/app/dashboard/accounts/page'; import AddAccountButton from '@/components/buttons/AddAccountButton'; import DateRangeInput from '@/components/DateRangeInput'; +import Onboarding from '@/components/onboarding/Onboarding'; import { AccountsTable, NetWorthPie, @@ -52,6 +53,10 @@ jest.mock('@/components/pages/accounts/LatestTransactions', () => jest.fn( () =>
, )); +jest.mock('@/components/onboarding/Onboarding', () => jest.fn( + () =>
, +)); + describe('AccountsPage', () => { beforeEach(() => { jest.spyOn(DateTime, 'now').mockReturnValue(DateTime.fromISO('2023-01-02')); @@ -70,7 +75,7 @@ describe('AccountsPage', () => { await screen.findByText('Loading...'); }); - it('passes empty data to components when no data ready', async () => { + it('shows onboarding when no data', async () => { const date = DateTime.fromISO('2022-01-01'); jest.spyOn(apiHook, 'useStartDate').mockReturnValueOnce({ data: date } as SWRResponse); const { container } = render(); @@ -78,10 +83,19 @@ describe('AccountsPage', () => { await screen.findByTestId('AddAccountButton'); expect(AddAccountButton).toHaveBeenLastCalledWith({}, {}); + await screen.findByTestId('Onboarding'); + expect(Onboarding).toHaveBeenLastCalledWith( + { + show: true, + }, + {}, + ); + await screen.findByTestId('AccountsTable'); expect(AccountsTable).toHaveBeenLastCalledWith( { selectedDate: DateTime.fromISO('2023-01-02'), + isExpanded: true, }, {}, ); @@ -152,7 +166,7 @@ describe('AccountsPage', () => { type: 'ROOT', childrenIds: ['a3', 'a5'], } as Account, - expense: { + type_expense: { guid: 'a3', name: 'Expenses', commodity: { @@ -170,7 +184,7 @@ describe('AccountsPage', () => { type: 'EXPENSE', childrenIds: [] as string[], } as Account, - income: { + type_income: { guid: 'a5', name: 'Income', commodity: { @@ -196,9 +210,18 @@ describe('AccountsPage', () => { render(); + await screen.findByTestId('Onboarding'); + expect(Onboarding).toHaveBeenLastCalledWith( + { + show: false, + }, + {}, + ); + await screen.findByTestId('AccountsTable'); expect(AccountsTable).toBeCalledTimes(1); expect(AccountsTable).toHaveBeenLastCalledWith({ + isExpanded: false, selectedDate: DateTime.now(), }, {}); expect(NetWorthPie).toHaveBeenLastCalledWith({ diff --git a/src/__tests__/components/Table.test.tsx b/src/__tests__/components/Table.test.tsx index 2680554a..ee8f15c6 100644 --- a/src/__tests__/components/Table.test.tsx +++ b/src/__tests__/components/Table.test.tsx @@ -41,6 +41,7 @@ describe('Table', () => { it('renders headers with empty data', () => { const { container } = render( + id="table" columns={columns} data={[]} />, @@ -54,6 +55,7 @@ describe('Table', () => { it('displays data', () => { render( + id="table" columns={columns} data={[ { @@ -82,6 +84,7 @@ describe('Table', () => { it('can sort', async () => { render( + id="table" columns={columns} data={[ { @@ -113,6 +116,7 @@ describe('Table', () => { it('can provide default sort', async () => { render( + id="table" columns={columns} data={[ { diff --git a/src/__tests__/components/__snapshots__/Table.test.tsx.snap b/src/__tests__/components/__snapshots__/Table.test.tsx.snap index 4f890d1c..bbada918 100644 --- a/src/__tests__/components/__snapshots__/Table.test.tsx.snap +++ b/src/__tests__/components/__snapshots__/Table.test.tsx.snap @@ -7,6 +7,7 @@ exports[`Table renders headers with empty data 1`] = ` > { expect(container).toMatchSnapshot(); }); + it('renders with defaults as expected', async () => { + render( + {}} + />, + ); + + await waitFor(() => expect(screen.getByLabelText('Name')).toHaveValue('Test account')); + screen.getByText('Assets'); + screen.getByText('BANK'); + screen.getByText('EUR'); + }); + it('button is disabled when form not valid', async () => { render( { description: null, parentId: assetAccount.guid, path: 'Assets:TestAccount', + placeholder: false, + hidden: false, }); expect(mockSave).toHaveBeenCalledTimes(1); expect(swr.mutate).toBeCalledTimes(1); - expect(swr.mutate).toHaveBeenNthCalledWith( - 1, - '/api/accounts', - expect.any(Function), - { revalidate: false }, - ); - - expect( - await (swr.mutate as jest.Mock).mock.calls[0][1]({ - [assetAccount.guid]: Account, - }), - ).toEqual({ - [assetAccount.guid]: await Account.findOneByOrFail({ guid: assetAccount.guid }), - [account.guid]: account, - }); + expect(swr.mutate).toHaveBeenNthCalledWith(1, '/api/accounts'); }); it('creates bank account with opening balance', async () => { @@ -246,6 +254,8 @@ describe('AccountForm', () => { description: null, parentId: assetAccount.guid, path: 'Assets:TestAccount', + placeholder: false, + hidden: false, }); const txs = await Transaction.find(); diff --git a/src/__tests__/components/forms/commodity/Commodityform.test.tsx b/src/__tests__/components/forms/commodity/Commodityform.test.tsx new file mode 100644 index 00000000..518b15b6 --- /dev/null +++ b/src/__tests__/components/forms/commodity/Commodityform.test.tsx @@ -0,0 +1,86 @@ +import React from 'react'; +import { + render, + screen, +} from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { DataSource } from 'typeorm'; + +import { + Account, + Commodity, + Price, + Split, + Transaction, +} from '@/book/entities'; +import CommodityForm from '@/components/forms/commodity/CommodityForm'; + +jest.mock('swr'); + +describe('CommodityForm', () => { + let datasource: DataSource; + + beforeEach(async () => { + datasource = new DataSource({ + type: 'sqljs', + dropSchema: true, + entities: [Account, Commodity, Price, Split, Transaction], + synchronize: true, + logging: false, + }); + await datasource.initialize(); + }); + + afterEach(async () => { + jest.resetAllMocks(); + await datasource.destroy(); + }); + + it('renders as expected', async () => { + const { container } = render( + {}} + />, + ); + + screen.getByRole('combobox', { name: 'mnemonicInput' }); + expect(container).toMatchSnapshot(); + }); + + it('button is disabled when form not valid', async () => { + render( + {}} + />, + ); + + const button = await screen.findByText('Save'); + expect(button).toBeDisabled(); + }); + + it('creates commodity with expected params, mutates and saves', async () => { + const user = userEvent.setup(); + const mockSave = jest.fn(); + + render( + , + ); + + await user.click(screen.getByRole('combobox', { name: 'mnemonicInput' })); + await user.click(screen.getByText('EUR')); + + expect(screen.getByText('Save')).not.toBeDisabled(); + await user.click(screen.getByText('Save')); + + const currency = await Commodity.findOneByOrFail({ mnemonic: 'EUR' }); + expect(currency).toEqual({ + guid: expect.any(String), + cusip: null, + mnemonic: 'EUR', + namespace: 'CURRENCY', + }); + expect(mockSave).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/__tests__/components/forms/commodity/__snapshots__/Commodityform.test.tsx.snap b/src/__tests__/components/forms/commodity/__snapshots__/Commodityform.test.tsx.snap new file mode 100644 index 00000000..6fd540ae --- /dev/null +++ b/src/__tests__/components/forms/commodity/__snapshots__/Commodityform.test.tsx.snap @@ -0,0 +1,127 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`CommodityForm renders as expected 1`] = ` +
+
+
+
+ + +
+ + + +
+
+ Choose your currency +
+
+ +
+
+
+
+
+ +
+

+

+ +
+ +
+ +
+`; diff --git a/src/__tests__/components/forms/transaction/TransactionForm.test.tsx b/src/__tests__/components/forms/transaction/TransactionForm.test.tsx index cab732e4..8da94ccd 100644 --- a/src/__tests__/components/forms/transaction/TransactionForm.test.tsx +++ b/src/__tests__/components/forms/transaction/TransactionForm.test.tsx @@ -514,7 +514,7 @@ describe('TransactionForm', () => { }); expect(tx.guid.length).toEqual(31); expect(mockSave).toHaveBeenCalledTimes(1); - }); + }, 10000); it('creates transaction with split not being main currency', async () => { const user = userEvent.setup(); diff --git a/src/__tests__/components/onboarding/CustomTooltip.test.tsx b/src/__tests__/components/onboarding/CustomTooltip.test.tsx new file mode 100644 index 00000000..e4d6096e --- /dev/null +++ b/src/__tests__/components/onboarding/CustomTooltip.test.tsx @@ -0,0 +1,19 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; + +import CustomTooltip from '@/components/onboarding/CustomTooltip'; + +describe('CustomTooltip', () => { + it('renders as expected', () => { + const { container } = render( + , + ); + + screen.getByText('Hello'); + expect(container).toMatchSnapshot(); + }); +}); diff --git a/src/__tests__/components/onboarding/Onboarding.test.tsx b/src/__tests__/components/onboarding/Onboarding.test.tsx new file mode 100644 index 00000000..598b7f40 --- /dev/null +++ b/src/__tests__/components/onboarding/Onboarding.test.tsx @@ -0,0 +1,194 @@ +import React from 'react'; +import { screen, render } from '@testing-library/react'; +import { DataSource } from 'typeorm'; +import type { SWRResponse } from 'swr'; +import { DateTime } from 'luxon'; +import userEvent from '@testing-library/user-event'; + +import Onboarding from '@/components/onboarding/Onboarding'; +import { + Account, + Commodity, + Price, + Split, + Transaction, +} from '@/book/entities'; +import * as API from '@/hooks/api'; + +jest.mock('@/hooks/api', () => ({ + __esModule: true, + ...jest.requireActual('@/hooks/api'), +})); + +describe('Onboarding', () => { + let datasource: DataSource; + + beforeEach(async () => { + datasource = new DataSource({ + type: 'sqljs', + dropSchema: true, + entities: [Account, Commodity, Price, Split, Transaction], + synchronize: true, + logging: false, + }); + await datasource.initialize(); + + await Account.create({ + guid: 'root_account_guid', + name: 'Root', + type: 'ROOT', + }).save(); + + jest.spyOn(API, 'useAccounts').mockReturnValue({ data: undefined } as SWRResponse); + }); + + // This test is huge but doing it like this because the onboarding + // steps are linked one after the other + it('full onboarding', async () => { + const user = userEvent.setup(); + render( +
+ + + +
, + ); + + // STEP 1 + // Select main currency and create initial accounts + await screen.findByText('Welcome!', { exact: false }); + + await user.click(screen.getByRole('combobox', { name: 'mnemonicInput' })); + await user.click(screen.getByText('EUR')); + + await user.click(screen.getByText('Save')); + + const eur = await Commodity.findOneByOrFail({ mnemonic: 'EUR' }); + + const accounts = await Account.find(); + expect(accounts).toEqual([ + expect.objectContaining({ + name: 'Root', + }), + expect.objectContaining({ + fk_commodity: eur, + parentId: 'root_account_guid', + placeholder: true, + type: 'ASSET', + name: 'Assets', + }), + expect.objectContaining({ + fk_commodity: eur, + parentId: 'root_account_guid', + placeholder: true, + type: 'EXPENSE', + name: 'Expenses', + }), + expect.objectContaining({ + fk_commodity: eur, + parentId: 'root_account_guid', + placeholder: true, + type: 'LIABILITY', + name: 'Liabilities', + }), + expect.objectContaining({ + fk_commodity: eur, + parentId: 'root_account_guid', + placeholder: true, + type: 'INCOME', + name: 'Income', + }), + expect.objectContaining({ + fk_commodity: eur, + parentId: 'root_account_guid', + placeholder: true, + type: 'EQUITY', + name: 'Equity', + }), + expect.objectContaining({ + fk_commodity: eur, + parentId: accounts[5].guid, + placeholder: false, + type: 'EQUITY', + name: 'Opening balances - EUR', + }), + ]); + + // STEP 2 + // Adds a bank account, all data is prefilled except the opening balance + await screen.findByText('Let\'s add your first', { exact: false }); + await screen.findByText('Assets'); + await user.type(screen.getByLabelText('Opening balance'), '1000'); + + expect(screen.getByText('Save')).toBeEnabled(); + await user.click(screen.getByText('Save')); + const bankAccount = await Account.findOneByOrFail({ name: 'My bank account' }); + expect(bankAccount).toEqual(expect.objectContaining({ + fk_commodity: eur, + placeholder: false, + type: 'BANK', + name: 'My bank account', + parentId: accounts[1].guid, + })); + + // STEP 3 + // Show accounts tree + await screen.findByText('This represents your accounts tree', { exact: false }); + await user.click(screen.getByText('Next')); + + // STEP 4 + // Adds an expense account + await screen.findByText('account to track your expenses', { exact: false }); + await screen.findByText('Expenses'); + + expect(screen.getByText('Save')).toBeEnabled(); + await user.click(screen.getByText('Save')); + const expensesAccount = await Account.findOneByOrFail({ name: 'Groceries' }); + expect(expensesAccount).toEqual(expect.objectContaining({ + fk_commodity: eur, + placeholder: false, + type: 'EXPENSE', + name: 'Groceries', + parentId: accounts[2].guid, + })); + + // STEP 5 + // Adds a transaction between bank account and groceries account + await screen.findByText('add the first transaction', { exact: false }); + await user.type(screen.getByLabelText('Date'), DateTime.now().toISODate() as string); + + expect(screen.getByText('Save')).toBeEnabled(); + await user.click(screen.getByText('Save')); + const tx = await Transaction.findOneOrFail( + { + where: { description: 'Grocery shopping' }, + relations: { splits: true }, + }, + ); + expect(tx).toEqual(expect.objectContaining({ + description: 'Grocery shopping', + fk_currency: eur, + splits: [ + expect.objectContaining({ + accountId: bankAccount.guid, + quantityNum: -30, + quantityDenom: 1, + valueNum: -30, + valueDenom: 1, + }), + expect.objectContaining({ + accountId: expensesAccount.guid, + quantityNum: 30, + quantityDenom: 1, + valueNum: 30, + valueDenom: 1, + }), + ], + })); + + // STEP 6 + // Shows final disclaimer + await screen.findByText('Good job!', { exact: false }); + await user.click(screen.getByText('Agreed!')); + }, 10000); +}); diff --git a/src/__tests__/components/onboarding/__snapshots__/CustomTooltip.test.tsx.snap b/src/__tests__/components/onboarding/__snapshots__/CustomTooltip.test.tsx.snap new file mode 100644 index 00000000..8f3c52e2 --- /dev/null +++ b/src/__tests__/components/onboarding/__snapshots__/CustomTooltip.test.tsx.snap @@ -0,0 +1,11 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`CustomTooltip renders as expected 1`] = ` +
+
+ Hello +
+
+`; diff --git a/src/__tests__/components/pages/account/TransactionsTable.test.tsx b/src/__tests__/components/pages/account/TransactionsTable.test.tsx index 3959cc4e..c3003ff9 100644 --- a/src/__tests__/components/pages/account/TransactionsTable.test.tsx +++ b/src/__tests__/components/pages/account/TransactionsTable.test.tsx @@ -129,6 +129,7 @@ describe('TransactionsTable', () => { await screen.findByTestId('Table'); expect(Table).toHaveBeenLastCalledWith( { + id: 'transactions-table', columns: [ { header: 'Date', @@ -180,6 +181,7 @@ describe('TransactionsTable', () => { await screen.findByTestId('Table'); expect(Table).toHaveBeenLastCalledWith({ + id: 'transactions-table', columns: [ { header: 'Date', diff --git a/src/__tests__/components/pages/accounts/AccountsTable.test.tsx b/src/__tests__/components/pages/accounts/AccountsTable.test.tsx index 4bf42881..b1867ba6 100644 --- a/src/__tests__/components/pages/accounts/AccountsTable.test.tsx +++ b/src/__tests__/components/pages/accounts/AccountsTable.test.tsx @@ -34,6 +34,7 @@ describe('AccountsTable', () => { await screen.findByTestId('Table'); expect(Table).toHaveBeenLastCalledWith( { + id: 'accounts-table', columns: [ { header: '', @@ -54,6 +55,7 @@ describe('AccountsTable', () => { desc: true, id: 'total', }, + isExpanded: false, showHeader: false, showPagination: false, tdClassName: 'p-2', @@ -83,6 +85,7 @@ describe('AccountsTable', () => { }, type: 'ASSET', childrenIds: [] as string[], + placeholder: true, }, a2: { guid: 'a2', @@ -115,6 +118,7 @@ describe('AccountsTable', () => { expect(Table).toBeCalledTimes(1); expect(Table).toHaveBeenLastCalledWith( { + id: 'accounts-table', columns: [ { header: '', @@ -141,6 +145,7 @@ describe('AccountsTable', () => { mnemonic: 'EUR', }, childrenIds: [], + placeholder: true, }, leaves: [], total: expect.any(Money), @@ -163,6 +168,7 @@ describe('AccountsTable', () => { desc: true, id: 'total', }, + isExpanded: false, showHeader: false, showPagination: false, tdClassName: 'p-2', @@ -258,6 +264,7 @@ describe('AccountsTable', () => { name: 'Assets', type: 'ASSET', childrenIds: ['a1'], + placeholder: true, } as Account, total: new Money(100, 'EUR'), monthlyTotals: {}, diff --git a/src/__tests__/components/pages/accounts/__snapshots__/AccountsTable.test.tsx.snap b/src/__tests__/components/pages/accounts/__snapshots__/AccountsTable.test.tsx.snap index 2ad5fb38..32a22d42 100644 --- a/src/__tests__/components/pages/accounts/__snapshots__/AccountsTable.test.tsx.snap +++ b/src/__tests__/components/pages/accounts/__snapshots__/AccountsTable.test.tsx.snap @@ -32,7 +32,8 @@ exports[`AccountsTable renders Name column as expected when expandable and expan Assets @@ -65,7 +66,8 @@ exports[`AccountsTable renders Name column as expected when expandable and not e Assets @@ -98,12 +100,12 @@ exports[`AccountsTable renders Name column as expected when not expandable 1`] = /> - Assets - + `; diff --git a/src/__tests__/components/pages/accounts/__snapshots__/LatestTransactions.test.tsx.snap b/src/__tests__/components/pages/accounts/__snapshots__/LatestTransactions.test.tsx.snap index ef2c6cde..3424c6bf 100644 --- a/src/__tests__/components/pages/accounts/__snapshots__/LatestTransactions.test.tsx.snap +++ b/src/__tests__/components/pages/accounts/__snapshots__/LatestTransactions.test.tsx.snap @@ -3,6 +3,9 @@ exports[`LatestTransactions renders as expected with txs 1`] = `
+

+ Latest transactions +

@@ -148,6 +151,9 @@ exports[`LatestTransactions renders as expected with txs 1`] = ` exports[`LatestTransactions renders with empty txs 1`] = `
+

+ Latest transactions +

0
diff --git a/src/__tests__/components/pages/investments/InvestmentsTable.test.tsx b/src/__tests__/components/pages/investments/InvestmentsTable.test.tsx index 16baf851..b0184856 100644 --- a/src/__tests__/components/pages/investments/InvestmentsTable.test.tsx +++ b/src/__tests__/components/pages/investments/InvestmentsTable.test.tsx @@ -26,6 +26,7 @@ describe('InvestmentsTable', () => { await screen.findByTestId('Table'); expect(Table).toHaveBeenLastCalledWith( { + id: 'investments-table', columns: [ { accessorFn: expect.any(Function), diff --git a/src/__tests__/components/selectors/AccountSelector.test.tsx b/src/__tests__/components/selectors/AccountSelector.test.tsx index 45a54611..210e0b25 100644 --- a/src/__tests__/components/selectors/AccountSelector.test.tsx +++ b/src/__tests__/components/selectors/AccountSelector.test.tsx @@ -18,7 +18,7 @@ jest.mock('@/hooks/api', () => ({ describe('AccountSelector', () => { beforeEach(() => { - jest.spyOn(apiHook, 'useAccounts').mockReturnValue({ data: {} } as SWRResponse); + jest.spyOn(apiHook, 'useAccounts').mockReturnValue({ data: undefined } as SWRResponse); }); afterEach(() => { @@ -126,6 +126,37 @@ describe('AccountSelector', () => { ); }); + it('removes type_ duplicates', async () => { + const options = [ + { + guid: 'guid1', + path: 'path1', + type: 'TYPE1', + commodity: { + mnemonic: 'USD', + } as Commodity, + } as Account, + ]; + jest.spyOn(apiHook, 'useAccounts').mockReturnValue( + { + data: { + guid1: options[0], + type_type1: options[0], + }, + } as SWRResponse, + ); + + render(); + + await screen.findByTestId('Selector'); + expect(Selector).toHaveBeenCalledWith( + expect.objectContaining({ + options, + }), + {}, + ); + }); + it('filters accounts', async () => { const options = [ { @@ -246,4 +277,46 @@ describe('AccountSelector', () => { {}, ); }); + + it('filters placeholders', async () => { + const options = [ + { + guid: 'guid1', + path: 'path1', + type: 'ASSET', + commodity: { + mnemonic: 'USD', + } as Commodity, + } as Account, + { + guid: 'guid2', + path: 'path2', + type: 'ASSET', + commodity: { + mnemonic: 'USD', + } as Commodity, + placeholder: true, + } as Account, + ]; + jest.spyOn(apiHook, 'useAccounts').mockReturnValue( + { + data: { + guid1: options[0], + guid2: options[1], + }, + } as SWRResponse, + ); + + render( + , + ); + + await screen.findByTestId('Selector'); + expect(Selector).toHaveBeenCalledWith( + expect.objectContaining({ + options: [options[0]], + }), + {}, + ); + }); }); diff --git a/src/__tests__/lib/queries/getAccounts.test.ts b/src/__tests__/lib/queries/getAccounts.test.ts index fa49a0f4..b7cf0ce4 100644 --- a/src/__tests__/lib/queries/getAccounts.test.ts +++ b/src/__tests__/lib/queries/getAccounts.test.ts @@ -59,8 +59,8 @@ describe('getAccounts', () => { const accounts = await getAccounts(); expect(accounts.root.guid).toEqual('a'); - expect(accounts.asset.guid).toEqual('abcdef'); - expect(accounts.expense.guid).toEqual('ghijk'); + expect(accounts.type_asset.guid).toEqual('abcdef'); + expect(accounts.type_expense.guid).toEqual('ghijk'); expect(accounts.abcdef.guid).toEqual('abcdef'); expect(accounts.ghijk.guid).toEqual('ghijk'); }); diff --git a/src/app/dashboard/accounts/page.tsx b/src/app/dashboard/accounts/page.tsx index fd0c5c9d..9d921489 100644 --- a/src/app/dashboard/accounts/page.tsx +++ b/src/app/dashboard/accounts/page.tsx @@ -12,6 +12,7 @@ import { LatestTransactions, } from '@/components/pages/accounts'; import DateRangeInput from '@/components/DateRangeInput'; +import Onboarding from '@/components/onboarding/Onboarding'; import * as API from '@/hooks/api'; export default function AccountsPage(): JSX.Element { @@ -32,9 +33,11 @@ export default function AccountsPage(): JSX.Element { } accounts = accounts || { root: { childrenIds: [] } }; + const showOnboarding = Object.keys(accounts).length === 1; return ( <> +
Your finances @@ -64,6 +67,7 @@ export default function AccountsPage(): JSX.Element {
@@ -81,14 +85,14 @@ export default function AccountsPage(): JSX.Element {
accounts?.[guid])} + accounts={accounts?.type_income?.childrenIds.map((guid: string) => accounts?.[guid])} title="Income" selectedDate={selectedDate} />
accounts?.[guid])} + accounts={accounts?.type_expense?.childrenIds.map((guid: string) => accounts?.[guid])} title="Expenses" selectedDate={selectedDate} /> diff --git a/src/book/entities/Account.ts b/src/book/entities/Account.ts index adf04af4..e3ee0ba0 100644 --- a/src/book/entities/Account.ts +++ b/src/book/entities/Account.ts @@ -124,6 +124,18 @@ export default class Account extends BaseEntity { @v.IsOptional() @v.Length(4, 2048) description?: string; + + @Column({ + default: false, + }) + hidden!: boolean; + + @Column({ + default: false, + }) + // Placeholders are hierarchical accounts that don't containg + // transactions, they are just parents of other accounts + placeholder!: boolean; } // https://github.com/typeorm/typeorm/issues/4714 diff --git a/src/components/Table.tsx b/src/components/Table.tsx index 60eb83b6..b9b2ee2d 100644 --- a/src/components/Table.tsx +++ b/src/components/Table.tsx @@ -9,6 +9,7 @@ import { getPaginationRowModel, getExpandedRowModel, ColumnSort, + ExpandedState, } from '@tanstack/react-table'; import classNames from 'classnames'; import { FaSortDown, FaSortUp } from 'react-icons/fa'; @@ -16,6 +17,7 @@ import { FaSortDown, FaSortUp } from 'react-icons/fa'; import Pagination from '@/components/Pagination'; export type TableProps = { + id: string, columns: ColumnDef[], data: T[], initialSort?: ColumnSort, @@ -24,10 +26,12 @@ export type TableProps = { showPagination?: boolean, tdClassName?: string, getSubRows?: (originalRow: T, index: number) => T[] | undefined, + isExpanded?: boolean, }; export default function Table( { + id, columns, data, initialSort, @@ -36,6 +40,7 @@ export default function Table( showPagination = true, tdClassName = 'px-6 py-4', getSubRows, + isExpanded = false, }: TableProps, ): JSX.Element { const tableConfig: TableOptions = { @@ -51,6 +56,7 @@ export default function Table( pageSize, }, sorting: (initialSort && [initialSort]) || undefined, + expanded: isExpanded as ExpandedState, }, }; @@ -63,7 +69,7 @@ export default function Table( return ( <>
-
+
{ showHeader && ( diff --git a/src/components/forms/account/AccountForm.tsx b/src/components/forms/account/AccountForm.tsx index 0c3d9ced..0efd242f 100644 --- a/src/components/forms/account/AccountForm.tsx +++ b/src/components/forms/account/AccountForm.tsx @@ -25,6 +25,7 @@ const resolver = classValidatorResolver(Account, { validator: { stopAtFirstError export type AccountFormProps = { onSave: Function, + defaultValues?: FormValues, }; export type FormValues = { @@ -42,8 +43,9 @@ export type SplitFieldData = { exchangeRate?: number, }; -export default function AccountForm({ onSave }: AccountFormProps): JSX.Element { +export default function AccountForm({ defaultValues, onSave }: AccountFormProps): JSX.Element { const form = useForm({ + defaultValues, mode: 'onChange', resolver, }); @@ -81,8 +83,10 @@ export default function AccountForm({ onSave }: AccountFormProps): JSX.Element { showRoot isClearable={false} ignoreAccounts={['STOCK', 'MUTUAL']} + ignorePlaceholders={false} placeholder="" onChange={field.onChange} + defaultValue={defaultValues?.parent} />

{fieldState.error?.message}

@@ -103,6 +107,7 @@ export default function AccountForm({ onSave }: AccountFormProps): JSX.Element { disabled={!parent} ignoreTypes={ignoreTypes} onChange={field.onChange} + defaultValue={(defaultValues?.type && { type: defaultValues.type }) || undefined} />

{fieldState.error?.message}

@@ -182,6 +187,7 @@ export default function AccountForm({ onSave }: AccountFormProps): JSX.Element { id="commodityInput" placeholder="" onChange={field.onChange} + defaultValue={defaultValues?.fk_commodity} />

{fieldState.error?.message}

@@ -201,21 +207,7 @@ export default function AccountForm({ onSave }: AccountFormProps): JSX.Element { async function onSubmit(data: FormValues, onSave: Function) { const account = await Account.create({ ...data }).save(); - mutate( - '/api/accounts', - async (accounts: AccountsMap) => { - const [child, parent] = await Promise.all([ - Account.findOneByOrFail({ guid: account.guid }), - Account.findOneByOrFail({ guid: account.parent.guid }), - ]); - return { - ...accounts, - [child.guid]: child, - [parent.guid]: parent, - }; - }, - { revalidate: false }, - ); + mutate('/api/accounts'); if (data.balance) { let equityAccount = await Account.findOneBy({ @@ -271,5 +263,5 @@ async function onSubmit(data: FormValues, onSave: Function) { // Opening balances affect net worth mutate('/api/monthly-totals', undefined); } - onSave(); + onSave(account); } diff --git a/src/components/forms/commodity/CommodityForm.tsx b/src/components/forms/commodity/CommodityForm.tsx new file mode 100644 index 00000000..3aa48760 --- /dev/null +++ b/src/components/forms/commodity/CommodityForm.tsx @@ -0,0 +1,78 @@ +import React from 'react'; +import { useForm, Controller } from 'react-hook-form'; +import { classValidatorResolver } from '@hookform/resolvers/class-validator'; +import { SingleValue } from 'react-select'; + +import { Commodity } from '@/book/entities'; +import Selector from '@/components/selectors/Selector'; + +const resolver = classValidatorResolver(Commodity, { validator: { stopAtFirstError: true } }); + +export type FormValues = { + namespace: string, + mnemonic: string, +}; + +export type CommodityFormProps = { + onSave: Function, +}; + +const BASE_CURRENCIES = [ + { label: 'EUR' }, + { label: 'USD' }, + { label: 'SGD' }, + { label: 'GBP' }, +]; + +export default function CommodityForm({ onSave }: CommodityFormProps): JSX.Element { + const form = useForm({ + mode: 'onChange', + resolver, + }); + + return ( +
onSubmit(data, onSave))}> +
+ ( + <> + + id="mnemonicInput" + labelAttribute="label" + options={BASE_CURRENCIES} + onChange={(newValue: SingleValue<{ label: string }> | null) => { + field.onChange(newValue?.label); + }} + placeholder="Choose your currency" + isClearable={false} + /> +

{fieldState.error?.message}

+ + )} + /> +
+ + + +
+ +
+ + ); +} + +async function onSubmit(data: FormValues, onSave: Function) { + const mainCommodity = await Commodity.create({ ...data }).save(); + await onSave(mainCommodity); +} diff --git a/src/components/onboarding/CustomTooltip.tsx b/src/components/onboarding/CustomTooltip.tsx new file mode 100644 index 00000000..4ede19ec --- /dev/null +++ b/src/components/onboarding/CustomTooltip.tsx @@ -0,0 +1,16 @@ +import React from 'react'; +import { TooltipRenderProps } from 'react-joyride'; + +export default function CustomTooltip({ + step, + tooltipProps, +}: TooltipRenderProps): JSX.Element { + return ( +
+ {step.content} +
+ ); +} diff --git a/src/components/onboarding/Onboarding.tsx b/src/components/onboarding/Onboarding.tsx new file mode 100644 index 00000000..00c359a0 --- /dev/null +++ b/src/components/onboarding/Onboarding.tsx @@ -0,0 +1,340 @@ +import React from 'react'; +import Joyride from 'react-joyride'; +import Image from 'next/image'; +import { mutate } from 'swr'; + +import { useMainCurrency } from '@/hooks/api'; +import { Account, Commodity, Split } from '@/book/entities'; +import AccountForm from '@/components/forms/account/AccountForm'; +import CommodityForm from '@/components/forms/commodity/CommodityForm'; +import CustomTooltip from '@/components/onboarding/CustomTooltip'; +import maffinLogo from '@/assets/images/maffin_logo_sm.png'; +import { DataSourceContext } from '@/hooks'; +import TransactionForm from '@/components/forms/transaction/TransactionForm'; + +type OnboardingProps = { + show?: boolean; +}; + +export default function Onboarding({ + show = false, +}: OnboardingProps): JSX.Element { + const { save } = React.useContext(DataSourceContext); + const [run] = React.useState(show); + const [stepIndex, setStepIndex] = React.useState(0); + const [accounts, setAccounts] = React.useState<{ [key: string]: Account }>({}); + + return ( + + +

+ Welcome! I'm Maffin and I will help you navigate through the first steps + on your journey to unify all your financial life in here. +

+
+ +
+

+ Before everything, you need to decide which currency + is going to be your main one. This is the currency that will be used to show + reports and calculate other things like net worth. +

+

+ The main currency cannot be changed later so make sure you + choose the right one for you! +

+
+ { + mutate( + '/api/main-currency', + commodity, + ); + await createInitialAccounts(setAccounts); + save(); + setStepIndex(1); + }} + /> + + ), + placement: 'center', + target: '#add-account', + disableBeacon: true, + }, + { + content: ( +
+ + Let's add your first + {' '} + + Bank + + {' '} + bank account. Set the + {' '} + opening balance + {' '} + to be the amount of today. If you want to start tracking from a previous date, + set it to the amount you had in the bank at that time (you can change this later). + + { + setAccounts({ + ...accounts, + bank: account, + }); + setStepIndex(2); + }} + defaultValues={{ + name: 'My bank account', + parent: accounts.type_asset as Account, + type: 'BANK', + fk_commodity: useMainCurrency().data as Commodity, + balance: 0, + }} + /> +
+ ), + placement: 'center', + target: '#add-account', + }, + { + content: ( +
+

+ This represents your accounts tree. Once you add more accounts, this + widget becomes very useful to navigate through all your accounts. +

+

+ You can see that your bank account is now there with the opening + balance you added and that your "Assets" parent account is + displaying that amount too. +

+

+ This is because it accumulates the total of the sub accounts. +

+
+ +
+
+ ), + target: '#accounts-table', + }, + { + content: ( +
+ + Let' now add an + {' '} + + Expense + + {' '} + account to track your expenses, say for example + Groceries. Note that we let you create as many accounts as you want. + Each account is like a category and allows you to visualise and report accordingly. + Some examples can be things like "Rent", "Electricity", etc. + + { + setAccounts({ + ...accounts, + expense: account, + }); + setStepIndex(4); + }} + defaultValues={{ + name: 'Groceries', + parent: accounts.type_expense as Account, + type: 'EXPENSE', + fk_commodity: useMainCurrency().data as Commodity, + balance: 0, + }} + /> +
+ ), + placement: 'center', + target: '#add-account', + }, + { + content: ( +
+ + Now that you have + {' '} + + Bank + + {' '} + and + {' '} + + Expense + + {' '} + accounts let's add the first transaction to + record a "Groceries" expense. + + { + save(); + setStepIndex(5); + }} + defaultValues={{ + date: '', + description: 'Grocery shopping', + splits: [ + Split.create({ + fk_account: accounts.bank as Account, + quantityNum: -30, + quantityDenom: 1, + valueNum: -30, + valueDenom: 1, + }), + Split.create({ + fk_account: accounts.expense as Account, + quantityNum: 30, + quantityDenom: 1, + valueNum: 30, + valueDenom: 1, + }), + ], + fk_currency: useMainCurrency().data as Commodity, + }} + /> +
+ ), + placement: 'center', + target: '#add-account', + }, + { + content: ( +
+ +

+ Good job! From here onwards you just need to keep adding + transactions and accounts to reflect your financial life as you need. +

+

+ Every time you do changes, they are auto saved and uploaded to + your Google Drive which is where the data lives. +

+
+ +
+

+ You own your data which means you have to be careful. Do not + delete the maffin.io folder from your Google drive! +

+
+
+ +
+
+ ), + placement: 'center', + target: '#add-account', + }, + ]} + tooltipComponent={CustomTooltip} + styles={{ + options: { + arrowColor: '#3a444e', + overlayColor: 'rgba(255, 255, 255, .3)', + }, + }} + /> + ); +} + +async function createInitialAccounts(setAccounts: Function) { + const mainCommodity = await Commodity.findOneByOrFail({ namespace: 'CURRENCY' }); + const root = await Account.findOneByOrFail({ + type: 'ROOT', + }); + + // Preload needed accounts for tutorial + setAccounts({ + type_asset: await Account.create({ + name: 'Assets', + type: 'ASSET', + description: 'Asset accounts are used for tracking things that are of value and can be used or sold to pay debts.', + placeholder: true, + fk_commodity: mainCommodity, + parent: root, + }).save(), + type_expense: await Account.create({ + name: 'Expenses', + type: 'EXPENSE', + description: 'Any expense such as food, clothing, taxes, etc.', + placeholder: true, + fk_commodity: mainCommodity, + parent: root, + children: [], + }).save(), + }); + + const liabilitiesAccount = Account.create({ + name: 'Liabilities', + type: 'LIABILITY', + description: 'Liability accounts are used for tracking debts or financial obligations.', + placeholder: true, + fk_commodity: mainCommodity, + parent: root, + }); + + const incomeAccount = Account.create({ + name: 'Income', + type: 'INCOME', + description: 'Any income received from sources such as salary, interest, dividends, etc.', + placeholder: true, + fk_commodity: mainCommodity, + parent: root, + }); + + const equityAccount = Account.create({ + name: 'Equity', + type: 'EQUITY', + description: 'Equity accounts are used to store the opening balances when you create new accounts', + placeholder: true, + fk_commodity: mainCommodity, + parent: root, + }); + + await Account.insert([liabilitiesAccount, incomeAccount, equityAccount]); + + await Account.create({ + name: `Opening balances - ${mainCommodity.mnemonic}`, + type: 'EQUITY', + description: `Opening balances for ${mainCommodity.mnemonic} accounts`, + placeholder: false, + fk_commodity: mainCommodity, + parent: await Account.findOneByOrFail({ type: 'EQUITY' }), + }).save(); + + mutate('/api/accounts'); +} diff --git a/src/components/pages/account/TransactionsTable.tsx b/src/components/pages/account/TransactionsTable.tsx index 7e3d7b14..9169c238 100644 --- a/src/components/pages/account/TransactionsTable.tsx +++ b/src/components/pages/account/TransactionsTable.tsx @@ -36,6 +36,7 @@ export default function TransactionsTable({ return ( + id="transactions-table" columns={columns} data={splits} /> diff --git a/src/components/pages/accounts/AccountsTable.tsx b/src/components/pages/accounts/AccountsTable.tsx index c5544d71..7fbaab0d 100644 --- a/src/components/pages/accounts/AccountsTable.tsx +++ b/src/components/pages/accounts/AccountsTable.tsx @@ -20,6 +20,7 @@ import * as API from '@/hooks/api'; export type AccountsTableProps = { selectedDate?: DateTime, + isExpanded?: boolean, }; type AccountsTableRow = { @@ -31,6 +32,7 @@ type AccountsTableRow = { export default function AccountsTable( { selectedDate = DateTime.now(), + isExpanded = false, }: AccountsTableProps, ): JSX.Element { let { data: accounts } = API.useAccounts(); @@ -42,6 +44,7 @@ export default function AccountsTable( return ( + id="accounts-table" columns={columns} data={tree.leaves} initialSort={{ id: 'total', desc: true }} @@ -49,6 +52,7 @@ export default function AccountsTable( showPagination={false} tdClassName="p-2" getSubRows={row => row.leaves} + isExpanded={isExpanded} /> ); } @@ -109,18 +113,39 @@ const columns: ColumnDef[] = [ )} - - {row.original.account.name} - + { + ( + row.original.account.placeholder + && ( + + {row.original.account.name} + + ) + ) || ( + + {row.original.account.name} + + ) + } { row.original.account.description diff --git a/src/components/pages/accounts/LatestTransactions.tsx b/src/components/pages/accounts/LatestTransactions.tsx index 699cb06c..00e7238b 100644 --- a/src/components/pages/accounts/LatestTransactions.tsx +++ b/src/components/pages/accounts/LatestTransactions.tsx @@ -18,6 +18,9 @@ export default function LatestTransactions(): JSX.Element { return (
+

+ Latest transactions +

{ ( txs.length diff --git a/src/components/pages/investments/InvestmentsTable.tsx b/src/components/pages/investments/InvestmentsTable.tsx index ffab203e..70900864 100644 --- a/src/components/pages/investments/InvestmentsTable.tsx +++ b/src/components/pages/investments/InvestmentsTable.tsx @@ -24,6 +24,7 @@ export default function InvestmentsTable(): JSX.Element { return ( + id="investments-table" columns={columns} data={investments} initialSort={{ id: 'unrealizedProfit', desc: true }} diff --git a/src/components/selectors/AccountSelector.tsx b/src/components/selectors/AccountSelector.tsx index 7d1c5ff7..f7bfba3d 100644 --- a/src/components/selectors/AccountSelector.tsx +++ b/src/components/selectors/AccountSelector.tsx @@ -7,6 +7,7 @@ import { Account } from '@/book/entities'; export type AccountSelectorProps = { placeholder?: string, ignoreAccounts?: string[], + ignorePlaceholders?: boolean, defaultValue?: Account, id?: string, showRoot?: boolean, @@ -20,6 +21,7 @@ export default function AccountSelector( { placeholder, ignoreAccounts = [], + ignorePlaceholders = true, defaultValue, id = 'accountSelector', showRoot = false, @@ -32,12 +34,24 @@ export default function AccountSelector( let { data: accounts } = useAccounts(); accounts = accounts || {}; - let options = Object.values(accounts); - options = options.filter(account => !(ignoreAccounts).includes(account.type)); + let options: Account[] = []; + + // Filter out duplicates that can be accessed via `type_` + Object.entries(accounts).forEach(([key, account]) => { + if (!key.startsWith('type_')) { + options.push(account); + } + }); + + options = options.filter(account => account && !(ignoreAccounts).includes(account.type)); if (!showRoot) { options = options.filter(account => account.type !== 'ROOT'); } + if (ignorePlaceholders) { + options = options.filter(account => !account.placeholder); + } + return ( id={id} diff --git a/src/css/globals.css b/src/css/globals.css index b4c30117..36227e08 100644 --- a/src/css/globals.css +++ b/src/css/globals.css @@ -49,7 +49,23 @@ } .badge { - @apply bg-slate-500/20 text-slate-300 inline-block my-1 px-1 py-1 text-xs text-center rounded-md + @apply bg-slate-500/20 text-slate-300 inline-block py-0.5 px-1 text-xs text-center rounded-md + } + + .badge.success { + @apply bg-green-500/20 text-green-300 + } + + .badge.warning { + @apply bg-orange-500/20 text-orange-300 + } + + .badge.danger { + @apply bg-red-500/20 text-red-300 + } + + .badge.info { + @apply bg-cyan-500/20 text-cyan-300 } .invalid-feedback { diff --git a/src/lib/queries/getAccounts.ts b/src/lib/queries/getAccounts.ts index 6edd2c1a..260789b6 100644 --- a/src/lib/queries/getAccounts.ts +++ b/src/lib/queries/getAccounts.ts @@ -25,7 +25,7 @@ export default async function getAccounts(): Promise { accounts.forEach(account => { if (account.parentId === accountsMap.root.guid) { - accountsMap[account.type.toLowerCase()] = account; + accountsMap[`type_${account.type.toLowerCase()}`] = account; } }); diff --git a/src/lib/queries/getEarliestDate.ts b/src/lib/queries/getEarliestDate.ts index 8728b3fc..9284098a 100644 --- a/src/lib/queries/getEarliestDate.ts +++ b/src/lib/queries/getEarliestDate.ts @@ -8,6 +8,6 @@ import { Transaction } from '@/book/entities'; export default async function getEarliestDate(): Promise { const dates = await Transaction.query('SELECT MIN(post_date) as date FROM transactions;'); return ( - dates.length && DateTime.fromSQL(dates[0].date) + dates.length && dates[0].date && DateTime.fromSQL(dates[0].date) ) || DateTime.now().startOf('year'); } diff --git a/yarn.lock b/yarn.lock index a86b077a..84d5fe03 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3122,6 +3122,31 @@ resolved "https://registry.yarnpkg.com/@fontsource/inter/-/inter-5.0.8.tgz#61b50cb0eb72b14ae1938d47c4a9a91546d2a50c" integrity sha512-28knWH1BfOiRalfLs90U4sge5mpQ8ZH6FS0PTT+IZMKrZ7wNHDHRuKa1kQJg+uHcc6axBppnxll+HXM4c7zo/Q== +"@gilbarbara/deep-equal@^0.1.1": + version "0.1.2" + resolved "https://registry.yarnpkg.com/@gilbarbara/deep-equal/-/deep-equal-0.1.2.tgz#1a106721368dba5e7e9fb7e9a3a6f9efbd8df36d" + integrity sha512-jk+qzItoEb0D0xSSmrKDDzf9sheQj/BAPxlgNxgmOaA3mxpUa6ndJLYGZKsJnIVEQSD8zcTbyILz7I0HcnBCRA== + +"@gilbarbara/deep-equal@^0.2.0": + version "0.2.0" + resolved "https://registry.yarnpkg.com/@gilbarbara/deep-equal/-/deep-equal-0.2.0.tgz#32f4fb81e43b245cf027da710f5aa0b72fe00853" + integrity sha512-dkjEAjjsoPUthQHYENjmgd453IBWLNGqFPolcmbbyKrHrGWj3AayQz7CYGN45OljDOTaFSmyb0sWgDtzpaxWjw== + +"@gilbarbara/helpers@^0.8.6": + version "0.8.7" + resolved "https://registry.yarnpkg.com/@gilbarbara/helpers/-/helpers-0.8.7.tgz#0db2b594ba095ec22ec3ffb7ef7585715d5a65c2" + integrity sha512-DL3btZpWnS3ZMkGdQ9sVQgVj/WlabUFbRoP6sg2iOjEFImq+QDqFgEDZn4Uf8LF3thGuNgj9EtsWlNCbvJYTqg== + dependencies: + "@gilbarbara/types" "^0.2.2" + is-lite "^0.9.3" + +"@gilbarbara/types@^0.2.2": + version "0.2.2" + resolved "https://registry.yarnpkg.com/@gilbarbara/types/-/types-0.2.2.tgz#397d66e5e4b1c44b65093b61e1e2bc0518b7d498" + integrity sha512-QuQDBRRcm1Q8AbSac2W1YElurOhprj3Iko/o+P1fJxUWS4rOGKMVli98OXS7uo4z+cKAif6a+L9bcZFSyauQpQ== + dependencies: + type-fest "^4.1.0" + "@hookform/resolvers@^3.3.1": version "3.3.1" resolved "https://registry.yarnpkg.com/@hookform/resolvers/-/resolvers-3.3.1.tgz#b7cbfe767434f52cba6b99b0a9a0b73eb8895188" @@ -5152,6 +5177,11 @@ dedent@^1.0.0: resolved "https://registry.yarnpkg.com/dedent/-/dedent-1.5.1.tgz#4f3fc94c8b711e9bb2800d185cd6ad20f2a90aff" integrity sha512-+LxW+KLWxu3HW3M2w2ympwtqPrqYRzU8fqi6Fhd18fBALe15blJPI/I4+UHveMVG6lJqB4JNd4UG0S5cnVHwIg== +deep-diff@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/deep-diff/-/deep-diff-1.0.2.tgz#afd3d1f749115be965e89c63edc7abb1506b9c26" + integrity sha512-aWS3UIVH+NPGCD1kki+DCU9Dua032iSsO43LqQpcs4R3+dVv7tX0qBGjiVHJHjplsoUM2XRO/KB92glqc68awg== + deep-equal@^2.0.5: version "2.2.1" resolved "https://registry.yarnpkg.com/deep-equal/-/deep-equal-2.2.1.tgz#c72ab22f3a7d3503a4ca87dde976fe9978816739" @@ -5186,7 +5216,7 @@ deep-is@^0.1.3, deep-is@~0.1.3: resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.4.tgz#a6f2dce612fadd2ef1f519b73551f17e85199831" integrity sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ== -deepmerge@^4.2.2: +deepmerge@^4.2.2, deepmerge@^4.3.1: version "4.3.1" resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-4.3.1.tgz#44b5f2147cd3b00d4b56137685966f26fd25dd4a" integrity sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A== @@ -5980,7 +6010,7 @@ execa@^7.1.1: signal-exit "^3.0.7" strip-final-newline "^3.0.0" -exenv@^1.2.0: +exenv@^1.2.0, exenv@^1.2.2: version "1.2.2" resolved "https://registry.yarnpkg.com/exenv/-/exenv-1.2.2.tgz#2ae78e85d9894158670b03d47bec1f03bd91bb9d" integrity sha512-Z+ktTxTwv9ILfgKCk32OX3n/doe+OcLTRtqK9pcL+JsP3J1/VW8Uvl4ZjLlKqeW4rzK4oesDOGMEMRIZqtP4Iw== @@ -6730,6 +6760,16 @@ is-inside-container@^1.0.0: dependencies: is-docker "^3.0.0" +is-lite@^0.8.2: + version "0.8.2" + resolved "https://registry.yarnpkg.com/is-lite/-/is-lite-0.8.2.tgz#26ab98b32aae8cc8b226593b9a641d2bf4bd3b6a" + integrity sha512-JZfH47qTsslwaAsqbMI3Q6HNNjUuq6Cmzzww50TdP5Esb6e1y2sK2UAaZZuzfAzpoI2AkxoPQapZdlDuP6Vlsw== + +is-lite@^0.9.2, is-lite@^0.9.3: + version "0.9.3" + resolved "https://registry.yarnpkg.com/is-lite/-/is-lite-0.9.3.tgz#b59cbc7b12e164bc68f263fd32b3d37150cc93bf" + integrity sha512-lbyynwsRRUMh1fHEinXkde/thdjj8OpW/okyGAVgmW4r/FkCEP966oSEg0B8ON5+mm73MJjFXB4ZViuaAldw4g== + is-map@^2.0.1, is-map@^2.0.2: version "2.0.2" resolved "https://registry.yarnpkg.com/is-map/-/is-map-2.0.2.tgz#00922db8c9bf73e81b7a335827bc2a43f2b91127" @@ -8182,6 +8222,11 @@ pkg-dir@^4.2.0: dependencies: find-up "^4.0.0" +popper.js@^1.16.0: + version "1.16.1" + resolved "https://registry.yarnpkg.com/popper.js/-/popper.js-1.16.1.tgz#2a223cb3dc7b6213d740e40372be40de43e65b1b" + integrity sha512-Wb4p1J4zyFTbM+u6WuO4XstYx4Ky9Cewe4DWrel7B0w6VVICvPwdOpotjzcf6eD8TsckVnIMNONQyPIUFOUbCQ== + postcss-import@^15.1.0: version "15.1.0" resolved "https://registry.yarnpkg.com/postcss-import/-/postcss-import-15.1.0.tgz#41c64ed8cc0e23735a9698b3249ffdbf704adc70" @@ -8418,6 +8463,19 @@ react-dom@^18.2.0: loose-envify "^1.1.0" scheduler "^0.23.0" +react-floater@^0.7.6: + version "0.7.6" + resolved "https://registry.yarnpkg.com/react-floater/-/react-floater-0.7.6.tgz#a98ee90e3d51200c6e6a564ff33496f3c0d7cfee" + integrity sha512-tt/15k/HpaShbtvWCwsQYLR+ebfUuYbl+oAUJ3DcEDkgYKeUcSkDey2PdAIERdVwzdFZANz47HbwoET2/Rduxg== + dependencies: + deepmerge "^4.2.2" + exenv "^1.2.2" + is-lite "^0.8.2" + popper.js "^1.16.0" + prop-types "^15.8.1" + react-proptype-conditional-require "^1.0.4" + tree-changes "^0.9.1" + react-hook-form@^7.47.0: version "7.47.0" resolved "https://registry.yarnpkg.com/react-hook-form/-/react-hook-form-7.47.0.tgz#a42f07266bd297ddf1f914f08f4b5f9783262f31" @@ -8433,6 +8491,11 @@ react-icons@^4.11.0: resolved "https://registry.yarnpkg.com/react-icons/-/react-icons-4.11.0.tgz#4b0e31c9bfc919608095cc429c4f1846f4d66c65" integrity sha512-V+4khzYcE5EBk/BvcuYRq6V/osf11ODUM2J8hg2FDSswRrGvqiYUYPRy4OdrWaQOBj4NcpJfmHZLNaD+VH0TyA== +react-innertext@^1.1.5: + version "1.1.5" + resolved "https://registry.yarnpkg.com/react-innertext/-/react-innertext-1.1.5.tgz#8147ac54db3f7067d95f49e2d2c05a720d27d8d0" + integrity sha512-PWAqdqhxhHIv80dT9znP2KvS+hfkbRovFp4zFYHFFlOoQLRiawIic81gKb3U1wEyJZgMwgs3JoLtwryASRWP3Q== + react-is@^16.13.1, react-is@^16.7.0: version "16.13.1" resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4" @@ -8448,6 +8511,23 @@ react-is@^18.0.0: resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.2.0.tgz#199431eeaaa2e09f86427efbb4f1473edb47609b" integrity sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w== +react-joyride@^2.6.0: + version "2.6.0" + resolved "https://registry.yarnpkg.com/react-joyride/-/react-joyride-2.6.0.tgz#948855088ff6989ffeeff55279ddcd2909b92226" + integrity sha512-en+IEPEq3AGJ2lO8WQE5iTzrwPfg39tlPf+jE8M0ZkDIy6SomckoRp2EQCZTehC8FkjP8FOXqaJuSKOKjwW2Cg== + dependencies: + "@gilbarbara/deep-equal" "^0.2.0" + "@gilbarbara/helpers" "^0.8.6" + deep-diff "^1.0.2" + deepmerge "^4.3.1" + is-lite "^0.9.3" + react-floater "^0.7.6" + react-innertext "^1.1.5" + react-is "^16.13.1" + scroll "^3.0.1" + scrollparent "^2.1.0" + tree-changes "^0.10.0" + react-lifecycles-compat@^3.0.0: version "3.0.4" resolved "https://registry.yarnpkg.com/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz#4f1a273afdfc8f3488a8c516bfda78f872352362" @@ -8477,6 +8557,11 @@ react-native-url-polyfill@^1.3.0: dependencies: whatwg-url-without-unicode "8.0.0-3" +react-proptype-conditional-require@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/react-proptype-conditional-require/-/react-proptype-conditional-require-1.0.4.tgz#69c2d5741e6df5e08f230f36bbc2944ee1222555" + integrity sha512-nopsRn7KnGgazBe2c3H2+Kf+Csp6PGDRLiBkYEDMKY8o/EIgft/WnIm/OnAKTawZiLnJXHAqhpFBddvs6NiXlw== + react-select@^5.7.7: version "5.7.7" resolved "https://registry.yarnpkg.com/react-select/-/react-select-5.7.7.tgz#dbade9dbf711ef2a181970c10f8ab319ac37fbd0" @@ -8760,6 +8845,16 @@ scheduler@^0.23.0: dependencies: loose-envify "^1.1.0" +scroll@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/scroll/-/scroll-3.0.1.tgz#d5afb59fb3592ee3df31c89743e78b39e4cd8a26" + integrity sha512-pz7y517OVls1maEzlirKO5nPYle9AXsFzTMNJrRGmT951mzpIBy7sNHOg5o/0MQd/NqliCiWnAi0kZneMPFLcg== + +scrollparent@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/scrollparent/-/scrollparent-2.1.0.tgz#6cae915c953835886a6ba0d77fdc2bb1ed09076d" + integrity sha512-bnnvJL28/Rtz/kz2+4wpBjHzWoEzXhVg/TE8BeVGJHUqE8THNIRnDxDWMktwM+qahvlRdvlLdsQfYe+cuqfZeA== + semver@^6.0.0, semver@^6.1.1, semver@^6.1.2, semver@^6.3.0: version "6.3.0" resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.0.tgz#ee0a64c8af5e8ceea67687b133761e1becbd1d3d" @@ -9342,6 +9437,22 @@ tr46@~0.0.3: resolved "https://registry.yarnpkg.com/tr46/-/tr46-0.0.3.tgz#8184fd347dac9cdc185992f3a6622e14b9d9ab6a" integrity sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw== +tree-changes@^0.10.0: + version "0.10.0" + resolved "https://registry.yarnpkg.com/tree-changes/-/tree-changes-0.10.0.tgz#945b53e76a224dca9d4deb56d05b572c5815054c" + integrity sha512-Hu1ElozbPrc8/zvDfazlnbOQxepXVpy0IRrNrZkUB1aDyyJ+yColKKzGmvO8KE5AH8xvW6z9aChFQfDJGlDdKA== + dependencies: + "@gilbarbara/deep-equal" "^0.1.1" + is-lite "^0.9.2" + +tree-changes@^0.9.1: + version "0.9.3" + resolved "https://registry.yarnpkg.com/tree-changes/-/tree-changes-0.9.3.tgz#89433ab3b4250c2910d386be1f83912b7144efcc" + integrity sha512-vvvS+O6kEeGRzMglTKbc19ltLWNtmNt1cpBoSYLj/iEcPVvpJasemKOlxBrmZaCtDJoF+4bwv3m01UKYi8mukQ== + dependencies: + "@gilbarbara/deep-equal" "^0.1.1" + is-lite "^0.8.2" + triple-beam@^1.3.0: version "1.3.0" resolved "https://registry.yarnpkg.com/triple-beam/-/triple-beam-1.3.0.tgz#a595214c7298db8339eeeee083e4d10bd8cb8dd9" @@ -9420,6 +9531,11 @@ type-fest@^0.21.3: resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.21.3.tgz#d260a24b0198436e133fa26a524a6d65fa3b2e37" integrity sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w== +type-fest@^4.1.0: + version "4.4.0" + resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-4.4.0.tgz#061cd10ff55664bb7174218cdf78c28c48f71c69" + integrity sha512-HT3RRs7sTfY22KuPQJkD/XjbTbxgP2Je5HPt6H6JEGvcjHd5Lqru75EbrP3tb4FYjNJ+DjLp+MNQTFQU0mhXNw== + typed-array-buffer@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/typed-array-buffer/-/typed-array-buffer-1.0.0.tgz#18de3e7ed7974b0a729d3feecb94338d1472cd60"