Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
625 changes: 625 additions & 0 deletions docs/plans/inline-sub-category-assignment.md

Large diffs are not rendered by default.

145 changes: 145 additions & 0 deletions public/js/inline_sub_category.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
/**
* Inline sub category assignment on the transaction list.
*
* Progressive enhancement: every uncategorized cell already contains a working
* form. This module hides its submit button and turns a change on the select
* into a fetch, so a whole page of transactions can be categorized without a
* reload. If this module fails to load or throws, the forms keep working.
*/

const SAVED_MARKER_DURATION = 2000;

const getLabels = () => {
const table = document.getElementById('transaction-list');

return {
saved: (table && table.dataset.savedLabel) || 'saved',
error: (table && table.dataset.errorLabel) || 'Could not save the sub category',
};
};

/**
* The selects of every row still waiting to be categorized, in document order.
*/
const remainingSelects = () => Array.from(
document.querySelectorAll('form.inline-sub-category select')
);

/**
* Removing the form removes the focused element, which would drop focus to
* <body> and break the tab chain. Hand focus to the next row still needing work.
*/
const moveFocusToNextRow = (select) => {
const selects = remainingSelects();
const next = selects[selects.indexOf(select) + 1] || selects[selects.indexOf(select) - 1];

if (next) {
next.focus();
}
};

const createMarker = (className, text) => {
const marker = document.createElement('span');
marker.className = className;
// textContent, never innerHTML: labels and category names are external input.
marker.textContent = text;
// The marker is truncated to keep the column width stable, so the full
// message has to stay reachable on hover.
marker.title = text;

return marker;
};

const showError = (form, select, message) => {
clearMarkers(form.parentNode);
select.disabled = false;
form.parentNode.appendChild(createMarker('sub-category-marker text-danger', `⚠ ${message}`));
};

const clearMarkers = (cell) => {
cell.querySelectorAll('.sub-category-marker').forEach((marker) => marker.remove());
};

/**
* Replaces the form with the plain category name, so the cell becomes
* indistinguishable from a row that was already categorized on page load.
*/
const settleRow = (form, select, subCategoryName, labels) => {
const cell = form.parentNode;
const row = form.closest('tr');

moveFocusToNextRow(select);

clearMarkers(cell);
form.remove();
cell.insertBefore(document.createTextNode(subCategoryName), cell.firstChild);

if (row) {
row.classList.remove('uncategorized-transaction');
}

const marker = createMarker('sub-category-marker text-success', `✓ ${labels.saved}`);
cell.appendChild(marker);
window.setTimeout(() => marker.remove(), SAVED_MARKER_DURATION);
};

const submit = async (form, select, labels) => {
const cell = form.parentNode;

// Serialized before disabling the select: FormData skips disabled controls,
// which would drop the subCategory field and make the request a no-op.
const body = new FormData(form);

clearMarkers(cell);
select.disabled = true;
cell.appendChild(createMarker('sub-category-marker text-muted', '⋯'));

let response;
let payload;

try {
response = await fetch(form.action, {
method: 'POST',
body,
headers: { Accept: 'application/json' },
});
payload = await response.json();
} catch (error) {
showError(form, select, labels.error);

return;
}

if (!response.ok) {
showError(form, select, (payload && payload.error) || labels.error);

return;
}

settleRow(form, select, payload.subCategory.name, labels);
};

const init = () => {
const labels = getLabels();

document.querySelectorAll('form.inline-sub-category').forEach((form) => {
const select = form.querySelector('select');
const button = form.querySelector('button');

// Only hidden once this module is known to run: a broken script must
// leave a usable form behind, which <noscript> would not do.
if (button) {
button.hidden = true;
}

select.addEventListener('change', () => {
if ('' === select.value) {
return;
}

submit(form, select, labels);
});
});
};

export default { init };
50 changes: 50 additions & 0 deletions public/styles/main.css
Original file line number Diff line number Diff line change
Expand Up @@ -257,3 +257,53 @@ form#filter-form.large-filter-form .col-form-label {
color: #3b3968;
font-weight: bold;
}

/* Inline sub category assignment on the transaction list */

tr.uncategorized-transaction > td:first-child {
border-left: 3px solid #ffc107;
}

/* The column keeps a constant width whatever the cell currently holds: a
select, a plain category name, or a save marker. Without this, settling a
row narrows the column and drags every other column sideways. */
#transaction-list th.sub-category-column,
#transaction-list td.sub-category-cell {
width: 24rem;
}

/* Every row is as tall as a row holding a select, so settling one down to
plain text does not make it shrink. The cell is border-box, so the figure
adds up the select, the cell padding (.75rem top and bottom) and the
collapsed row border. */
#main td.sub-category-cell {
position: relative;
height: calc(1.8125rem + 2px + 1.5rem + 1px);
}

#main .sub-category-cell form.inline-sub-category {
display: inline-block;
margin-bottom: 0;
}

/* Fixed width on purpose: an auto width select grows to fit the selected
option and would resize the column on every pick. */
#main .sub-category-cell select {
width: 13rem;
max-width: 13rem;
display: inline-block;
vertical-align: middle;
}

/* Truncated rather than wrapped: a long error message must not widen the
column or push the row taller. The full text stays in the title attribute. */
.sub-category-marker {
display: inline-block;
margin-left: 0.5rem;
max-width: 8rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
vertical-align: middle;
font-size: 0.875rem;
}
101 changes: 100 additions & 1 deletion src/Controller/TransactionController.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,13 @@
namespace App\Controller;

use App\Entity\Transaction;
use App\Entity\TransactionType as TransactionTypeEnum;
use App\Exception\InvalidSubCategoryAssignmentException;
use App\FilterForm\TransactionFilterType;
use App\Form\TransactionType;
use App\Repository\SubCategoryRepository;
use App\Services\TransactionDiffChecker;
use App\Services\TransactionSubCategoryAssigner;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\Persistence\ManagerRegistry;
use Elasticsearch\Common\Exceptions\NoNodesAvailableException;
Expand All @@ -15,6 +19,7 @@
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Form\FormError;
use Symfony\Component\Form\FormFactoryInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\Session\Session;
Expand All @@ -30,13 +35,16 @@ public function index(
FormFactoryInterface $formFactory,
EntityManagerInterface $entityManager,
FilterBuilderUpdaterInterface $filterBuilderUpdater,
SubCategoryRepository $subCategoryRepository,
): Response {
$hasFilters = false;
$filterForm = $formFactory->create(TransactionFilterType::class);

$queryBuilder = $entityManager->createQueryBuilder()
->select('transaction')
->select('transaction', 'account', 'subCategory')
->from(Transaction::class, 'transaction')
->leftJoin('transaction.account', 'account')
->leftJoin('transaction.subCategory', 'subCategory')
->orderBy('transaction.createdAt', 'desc')
;

Expand All @@ -59,10 +67,16 @@ public function index(
$pagerfanta->setCurrentPage($request->query->getInt('page'));
}

$subCategories = [
TransactionTypeEnum::EXPENSES => $subCategoryRepository->findByTransactionTypeGroupedByTopCategory(TransactionTypeEnum::EXPENSES),
TransactionTypeEnum::REVENUES => $subCategoryRepository->findByTransactionTypeGroupedByTopCategory(TransactionTypeEnum::REVENUES),
];

return $this->render('transaction/index.html.twig', [
'pager' => $pagerfanta,
'filter_form' => $filterForm->createView(),
'has_filters' => $hasFilters,
'sub_categories' => $subCategories,
]);
}

Expand Down Expand Up @@ -123,6 +137,91 @@ public function edit(
]);
}

/**
* Assigns a sub category to a transaction from the transaction list.
*
* Set-only: an empty or unknown sub category is rejected, never treated as a
* request to clear the category.
*/
#[Route('/{id}/sub-category', name: 'transaction_set_sub_category', methods: ['PATCH'])]
public function setSubCategory(
Request $request,
Transaction $transaction,
TransactionSubCategoryAssigner $assigner,
SubCategoryRepository $subCategoryRepository,
TranslatorInterface $translator,
): Response {
$wantsJson = 'json' === $request->getPreferredFormat(null);

if (!$this->isCsrfTokenValid('set-sub-category'.$transaction->getId(), $request->request->getString('_token'))) {
return $this->subCategoryError(
$request,
$wantsJson,
$translator->trans('Invalid security token, please reload the page'),
Response::HTTP_FORBIDDEN
);
}

$subCategoryId = $request->request->getString('subCategory');
$subCategory = '' === $subCategoryId ? null : $subCategoryRepository->find($subCategoryId);

if (null === $subCategory) {
return $this->subCategoryError(
$request,
$wantsJson,
$translator->trans('A sub category is required'),
Response::HTTP_UNPROCESSABLE_ENTITY
);
}

try {
$assigner->assign($transaction, $subCategory);
} catch (InvalidSubCategoryAssignmentException $e) {
return $this->subCategoryError(
$request,
$wantsJson,
$translator->trans($e->getMessage(), [], 'validators'),
Response::HTTP_UNPROCESSABLE_ENTITY
);
}

if ($wantsJson) {
return new JsonResponse([
'id' => $transaction->getId(),
'subCategory' => [
'id' => $subCategory->getId(),
'name' => $subCategory->getName(),
],
]);
}

return $this->redirectToTransactionList($request);
}

private function subCategoryError(Request $request, bool $wantsJson, string $message, int $status): Response
{
if ($wantsJson) {
return new JsonResponse(['error' => $message], $status);
}

$this->addFlash('error', $message);

return $this->redirectToTransactionList($request);
}

/**
* Sends the no-javascript fallback back to the list it came from, keeping the
* active filters and page number.
*/
private function redirectToTransactionList(Request $request): Response
{
$queryString = parse_url((string) $request->headers->get('referer'), PHP_URL_QUERY);

return $this->redirect(
$this->generateUrl('transaction_index').(is_string($queryString) && '' !== $queryString ? '?'.$queryString : '')
);
}

#[Route('/{id}', name: 'transaction_delete', methods: ['DELETE'])]
public function delete(
Request $request,
Expand Down
7 changes: 7 additions & 0 deletions src/Exception/InvalidSubCategoryAssignmentException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<?php

namespace App\Exception;

class InvalidSubCategoryAssignmentException extends \Exception
{
}
17 changes: 17 additions & 0 deletions src/Repository/SubCategoryRepository.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ public function __construct(ManagerRegistry $registry)
public function findByTransactionType(string $value)
{
return $this->createQueryBuilder('s')
->addSelect('t')
->innerJoin('s.topCategory', 't', 'WITH', 't.transactionType = ?1')
->orderBy('s.name', 'ASC')
->setParameter(1, $value)
Expand All @@ -35,6 +36,22 @@ public function findByTransactionType(string $value)
;
}

/**
* @return array<string, SubCategory[]>
*/
public function findByTransactionTypeGroupedByTopCategory(string $value): array
{
$grouped = [];

foreach ($this->findByTransactionType($value) as $subCategory) {
$grouped[$subCategory->getTopCategory()->getName()][] = $subCategory;
}

ksort($grouped);

return $grouped;
}

/**
* @return SubCategory[] Returns an array of SubCategory objects
*/
Expand Down
Loading
Loading