cenko.tech

Editing an expense without deleting it first

Daybook, a small browser budget tracker, could add and delete expenses but not change them. I added an edit mode that reuses the add form.

Daybook is a small budget tracker that runs in the browser. You set a monthly or weekly budget, log expenses with a description, amount, date and category, and export a month to Excel. Everything is stored in localStorage on your device, and the code is plain HTML, CSS and JavaScript with no framework.

The problem#

You could add an expense and you could delete one, but you couldn’t change one. A typo in the description, a wrong amount, or an expense logged on the wrong day meant deleting it and typing the whole thing in again.

I wrote this up as issue #1. It included the proposed behaviour, a list of acceptance criteria, and links to the three places in app.js that would need to change.

Reusing the add form#

I didn’t add a second form or a modal. The Add an expense form already had the fields, the validation and the error message, so editing reuses it.

Each row in Recent expenses now has a pencil button next to the delete button, with the accessible label “Edit <name>”. Clicking it:

  • fills the form with that expense’s description, amount, date and category,
  • changes the heading to “Edit expense” and the submit button to Save changes,
  • shows a Cancel button and highlights the row being edited,
  • moves focus to the description field.

The app tracks edit mode with a single variable, editingId. When it is null, the form adds an expense. When it holds an id, the form saves changes to that expense.

Saving in place#

The submit handler validates the input before it checks the mode, so edits follow the same rules as new expenses: a description of 1–80 characters, a positive amount and a valid date. The edit branch then replaces the expense in the array:

const previous = state.expenses[index];
state.expenses[index] = { ...previous, name, amount: Math.round(amount * 100) / 100, category, date };
if (!saveState()) {
  state.expenses[index] = previous;
  return;
}

The spread keeps the expense’s id and createdAt, so nothing else that refers to it changes. The stored format doesn’t change either.

saveState() returns false when localStorage refuses the write, for example when it is full. The app already handled this when adding or deleting an expense: it showed a toast and put the in-memory state back. The edit branch does the same thing, so a failed save doesn’t leave the screen showing data that was never stored.

The edge cases#

Most of the work went into the cases around the edit:

  • The date moves to another month. The list only shows the selected month, so the row disappears after saving. Without an explanation, that looks like the expense was lost. In that case the toast says “Expense updated and moved to <Month Year>” instead of “Expense updated”.
  • Leaving edit mode. Cancel, Escape, changing the selected month, and deleting the expense being edited all reset the form to add mode. Otherwise, after switching months, Save changes would quietly overwrite an expense you could no longer see.
  • The expense is gone. If the expense no longer exists when you press save, the form resets and shows “That expense no longer exists.” It doesn’t create a new expense.

Where focus goes#

Clicking Edit moves focus to the form. When you save or cancel, focus should go back to where you came from, which is that row’s Edit button.

The catch is that render() rebuilds every row with replaceChildren(), so the button you clicked no longer exists. The code looks up the new button by id after rendering:

function editButtonFor(id) {
  return [...expenseRows.querySelectorAll("[data-edit-id]")].find((button) => button.dataset.editId === id);
}

(editButtonFor(editedId) || document.querySelector("#expense-name")).focus();

If the row is no longer shown, for example because the expense moved to another month, focus falls back to the description field. Comparing dataset values also avoids building a CSS selector from the id, so the id never needs escaping.

Testing#

I tested the real index.html and app.js in jsdom with 26 checks, and they all pass. They cover rendering and label escaping, filling the form, saving in place, invalid input, a simulated storage failure that rolls back, Cancel and Escape, deleting the expense being edited, moving an expense to another month, changing the month mid-edit, adding expenses still working, and where focus ends up.

What I didn’t test: the layout in a real browser, at either desktop or mobile width. The pull request said so, and the maintainer merged it the same day.

A follow-up from another contributor#

Shortly afterwards, another pull request made Daybook sync its data across open tabs by listening for the storage event. It reuses stopEditing(), so if the expense you’re editing is deleted in another tab, this tab leaves edit mode and tells you so.

cd ~/blog