Skip to content

Commit

Permalink
Detangle recoil state and add error boundary for API errors
Browse files Browse the repository at this point in the history
Also add a home page that explains what this app does and how to get a
personal access token
  • Loading branch information
erfanio committed Jul 7, 2024
1 parent 87529d0 commit 3d18c8d
Show file tree
Hide file tree
Showing 26 changed files with 537 additions and 420 deletions.
38 changes: 22 additions & 16 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,34 +1,40 @@
# Up Bank Transaction Tagger
# Unofficial Up Bank Web App

Web app using [Up Bank API](https://developer.up.com.au/) to allow bulk tagging of
transactions. Everything happens in your browser, so a refresh will wipe any loaded
transactions.
Web app using [Up Bank API](https://developer.up.com.au/), currently primarily to
allow bulk tagging of transactions. Everything happens in your browser, so a
refresh will wipe any loaded transactions.

Access the live version on GitHub Pages! [erfanio.github.io/up-transaction-tagger](https://erfanio.github.io/up-transaction-tagger/)

![screenshot](./preview.png)

## FAQ

### What's an API key?
### What's an API or Perosnal Access Token?

Your API key uniquely identifies you with Up Bank. It allows anyone that knows your
API key to read your transactions and add/update tags and categories.
Up Bank provides an interface for third-party applications (such as this one) to
read your transactions and add/update categories or tags to them. This is called
an application programming interface (API) and your **personal access token** is
a unique identifier that allows applications to access your trasaction data
using the API.

Go to [api.up.com.au](http://api.up.com.au) to get your API key!
You can get your personal access token from [api.up.com.au](http://api.up.com.au).
You can revoke the previous token by generating a new one any time.

### I see "Covered from X" or "Forwarded to X" transactions... what gives?

Covers and fowards are transactions under the hood. The Up app hides these transactions
under pretty UI but they're still there.
Covers and fowards are transactions under the hood. The Up app hides these
transactions under pretty UI but they're still there.

These transactions show up in the API but they don't mark which transaction they're
covering. I have an [open GitHub issue](https://github.com/up-banking/api/issues/99)
with Up to add this feature.
These transactions show up in the API but they don't mark which transaction
they're covering. I have an
[open GitHub issue](https://github.com/up-banking/api/issues/99) with Up to add
this feature.

For now, this web app uses heuristics to find likely covers/forwards and displays them a similar
UI to the app.
For now, this web app uses heuristics to find likely covers/forwards and displays
them a similar UI to the app.

### Can I shift select?

YES! The web app supports selecting multiple transactions in a row if you hold the shift key.
YES! The web app supports selecting multiple transactions in a row if you hold the
shift key.
27 changes: 2 additions & 25 deletions public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -7,36 +7,13 @@
content="width=device-width, initial-scale=1, shrink-to-fit=no"
/>
<meta name="theme-color" content="#000000" />
<!--
manifest.json provides metadata used when your web app is added to the
homescreen on Android. See https://developers.google.com/web/fundamentals/engage-and-retain/web-app-manifest/
-->
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<link rel="shortcut icon" href="%PUBLIC_URL%/favicon.ico" />
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>React App</title>
<!-- <link rel="shortcut icon" href="%PUBLIC_URL%/favicon.ico" /> -->
<title>Unofficial Up Bank Web App</title>
</head>

<body>
<noscript> You need to enable JavaScript to run this app. </noscript>
<div id="root"></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
</body>
</html>
3 changes: 1 addition & 2 deletions src/Accounts.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import { accountsQuery } from './api_client';
import { accountsQuery } from './data/accounts';
import { useRecoilValue } from 'recoil';
import React, { useState } from 'react';
import Transactions from './Transactions';

import './Accounts.css';

const TRIANGLE_DOWN = '▾';
Expand Down
10 changes: 4 additions & 6 deletions src/ActionBar.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,15 @@
import {
selectedTransactionsState,
selectedTransactionsQuery,
} from './global_state';
} from './data/selectedTransactions';
import { tagsQuery, tagTransactions } from './data/tags';
import { accountsQuery } from './data/accounts';
import {
tagsQuery,
tagTransactions,
accountsQuery,
paginatedTransactionsState,
refreshTransactions,
} from './api_client';
} from './data/transactions';
import { useRecoilValue, useRecoilCallback, useRecoilState } from 'recoil';
import React, { useState } from 'react';

import './ActionBar.css';

function AddTag({ closePopup }: { closePopup: () => void }) {
Expand Down
9 changes: 9 additions & 0 deletions src/ApiKeyForm.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
.api-form label > input {
margin-left: 10px;
}

.api-form input[type='submit'] {
margin-top: 20px;
display: block;
background-color: #ff7a64;
}
33 changes: 33 additions & 0 deletions src/ApiKeyForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import React, { useState } from 'react';
import './ApiKeyForm.css';

type ApiKeyFormProps = {
onSubmit: (apiKey: string) => void;
};
export default function ApiKeyForm({ onSubmit }: ApiKeyFormProps) {
const [apiKey, setApiKey] = useState('');
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setApiKey(event.target.value);
};

const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
onSubmit(apiKey);
};

return (
<form onSubmit={handleSubmit} className="api-form">
<label>
Personal Access Token
<input
type="text"
name="api-key"
placeholder="up:yeah:aBcDeFgHiJkLmNoP12345"
value={apiKey}
onChange={handleChange}
/>
</label>
<input type="submit" value="Let's Get Started" />
</form>
);
}
8 changes: 8 additions & 0 deletions src/App.css
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,11 @@
.topbar {
display: flex;
}

.card.text p {
margin: 1em 0 1em 0;
}

.error pre {
text-wrap: auto;
}
150 changes: 102 additions & 48 deletions src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,70 +1,124 @@
import { apiKeyState } from './api_client';
import { useRecoilState } from 'recoil';
import React, { useState } from 'react';
import React, { Component } from 'react';
import Accounts from './Accounts';
import ActionBar from './ActionBar';
import Filters from './Filters';
import Search from './Search';

import ApiKeyForm from './ApiKeyForm';
import { apiKeyState } from './data/apiKey';
import { useRecoilState } from 'recoil';
import './App.css';

type ApiKeyFormProps = {
onSubmit: (apiKey: string) => void;
type DisplayErrorsProps = {
setApiKey: (apiKey: string) => void;
children: React.ReactElement;
};
function ApiKeyForm({ onSubmit }: ApiKeyFormProps) {
const [apiKey, setApiKey] = useState('');
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setApiKey(event.target.value);
};
type DisplayErrorsState = {
caughtError?: any;
};
class DisplayErrors extends Component<DisplayErrorsProps, DisplayErrorsState> {
state: DisplayErrorsState = { caughtError: undefined };

const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
onSubmit(apiKey);
};
static getDerivedStateFromError(error: any) {
return { caughtError: error };
}

return (
<form onSubmit={handleSubmit}>
<input
type="text"
name="api-key"
placeholder="API KEY"
value={apiKey}
onChange={handleChange}
/>
<input type="submit" />
</form>
);
}
onApiKeySubmit = (apiKey: string) => {
this.setState({ caughtError: undefined });
this.props.setApiKey(apiKey);
}

type WithApiKeyProps = {
children: React.ReactElement;
};
function WithApiKey({ children }: WithApiKeyProps) {
const [apiKey, setApiKey] = useRecoilState(apiKeyState);
render() {
if (this.state.caughtError) {
if (this.state.caughtError.status == '401') {
return (
<div className="card text error">
<h1>Auth Error</h1>
<p>
Uh oh, looks like your personal access token isn't working. Up API
returned this error.
</p>
<pre>{JSON.stringify(this.state.caughtError, null, 2)}</pre>
<h2>Update Your Personal Access Token</h2>
<p>
Maybe there was a typo, try setting your personal access token
again. If that still doesn't work, generate a new one from{' '}
<a href="https://api.up.com.au/getting_started" target="_blank">
api.up.com.au
</a>
.
</p>
<ApiKeyForm onSubmit={this.onApiKeySubmit} />
</div>
);
}

const handleSubmit = (newApiKey: string) => {
setApiKey(newApiKey);
};
return (
<div className="card text error">
<h1>Unknown Error</h1>
<p>Uh oh, something went wrong. Refresh this page to reset.</p>
<p>
Maybe to take screenshot of this error and create an issue on Github,{' '}
<a href="https://github.com/erfanio/up-transaction-tagger/issues" target="_blank">
github.com/erfanio/up-transaction-tagger/issues
</a>
</p>
<pre>{JSON.stringify(this.state.caughtError, null, 2)}</pre>
</div>
);
}

if (apiKey) {
return children;
return this.props.children;
}
return <ApiKeyForm onSubmit={handleSubmit} />;
}

export default function App() {
const [apiKey, setApiKey] = useRecoilState(apiKeyState);

return (
<div className="app">
<WithApiKey>
<React.Suspense fallback={<p>Loading accounts...</p>}>
<div className="topbar">
<Search />
<Filters />
<DisplayErrors setApiKey={setApiKey}>
{!apiKey ? (
<div className="card text">
<h1>Unofficial Up Bank Web App</h1>
<p>
The official app is amazing but it doesn't have every feature.
This unofficial app uses the Up Bank API to access your
transaction data and add some features that @erfanio wanted
to have :)
</p>
<h2>How Does It Work?</h2>
<p>
Up Bank offers a Application Programming Interface (API) which is a
convenient way to allow third-party apps to <b>only</b> access your
transaction data and do minor changes like adding tags and categories.
</p>
<p>This app is fully local, your data never leaves your browser.</p>
<h2>Get Your Personal Access Token</h2>
<p>
Personal access token authenticates this app with Up Bank, and
allows this app to list your accounts, transactions, and tags
and assign tags and categories to transactions.
</p>
<p>
You can generate a new personal access token by going to{' '}
<a href="https://api.up.com.au/getting_started" target="_blank">
api.up.com.au
</a>
. Once you have it, enter here to get started.
</p>
<ApiKeyForm onSubmit={setApiKey} />
</div>
<Accounts />
<ActionBar />
</React.Suspense>
</WithApiKey>
) : (
<React.Suspense fallback={<p>Loading accounts...</p>}>
<div className="topbar">
<Search />
<Filters />
</div>
<Accounts />
<ActionBar />
</React.Suspense>
)}
</DisplayErrors>
</div>
);
}
8 changes: 4 additions & 4 deletions src/Filters.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { accountsQuery, categoriesQuery } from './api_client';
import { filtersState, NOT_COVERED_ID, UNCATEGORIZED_ID } from './global_state';
import { accountsQuery } from './data/accounts';
import { categoriesQuery } from './data/categories';
import { filtersState, NOT_COVERED_ID, UNCATEGORIZED_ID } from './data/filters';
import { useRecoilState, useRecoilValue } from 'recoil';
import React, { useState } from 'react';
import { ReactComponent as ChevronRight } from './chevron-right.svg';
import { ReactComponent as ChevronRight } from './svg/chevron-right.svg';
import Overlay from './Overlay';

import './Filters.css';

const TRIANGLE_DOWN = '▾';
Expand Down
4 changes: 1 addition & 3 deletions src/Overlay.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import React, { useEffect } from 'react';
import { ReactComponent as CloseIcon } from './close.svg';

import { ReactComponent as CloseIcon } from './svg/close.svg';
import './Overlay.css';

type OverlayProps = {
Expand All @@ -10,7 +9,6 @@ type OverlayProps = {
};
export default function Overlay({ open, setOpen, children }: OverlayProps) {
useEffect(() => {
console.log('effect happened!!');
if (open) {
document.body.classList.add('overlay-open');
} else {
Expand Down
6 changes: 2 additions & 4 deletions src/Search.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
import { useRecoilState } from 'recoil';
import { searchState } from './global_state';
import { searchState } from './data/filters';
import React from 'react';

import { ReactComponent as SearchIcon } from './search.svg';

import { ReactComponent as SearchIcon } from './svg/search.svg';
import './Search.css';

export default function Search() {
Expand Down
Loading

0 comments on commit 3d18c8d

Please sign in to comment.