Merge branch 'main' into bugfix/67-sonar-frontend-skipped
SonarQube Analysis / sonarqube (pull_request) Successful in 3m51s
Tests / lint (pull_request) Successful in 1m55s
Tests / backend-unit (pull_request) Successful in 36s
Tests / frontend-e2e (pull_request) Failing after 8m42s

This commit is contained in:
2026-08-19 14:45:52 -05:00
21 changed files with 5488 additions and 59 deletions
+87
View File
@@ -0,0 +1,87 @@
import js from '@eslint/js';
import tseslint from 'typescript-eslint';
import reactHooks from 'eslint-plugin-react-hooks';
import jsxA11y from 'eslint-plugin-jsx-a11y';
import sonarjs from 'eslint-plugin-sonarjs';
import globals from 'globals';
// Named `.mjs` because this package is CommonJS — `eslint.config.js` would be
// parsed as CJS and the imports above would fail.
//
// Policy: every preset is downgraded to advisory, and the rules that actually
// fail the build are listed once at the bottom. That way the CI gate is
// readable in one place rather than inferred from four presets' defaults.
// The reasoning behind the split, and the measurements it rests on, are in
// docs/superpowers/specs/2026-08-19-eslint-design.md.
/**
* Rewrites a preset's enabled rules to `warn`, preserving each rule's options.
* Rules the preset explicitly turned off stay off — a preset that disables a
* rule means it, and flipping those to `warn` turns the whole of SonarJS's
* opt-in catalogue (file headers, naming conventions) into daily noise.
*/
const advisory = (config) => ({
...config,
rules: Object.fromEntries(
Object.entries(config.rules ?? {}).map(([rule, level]) => {
const severity = Array.isArray(level) ? level[0] : level;
if (severity === 'off' || severity === 0) return [rule, level];
return [rule, Array.isArray(level) ? ['warn', ...level.slice(1)] : 'warn'];
})
),
});
export default tseslint.config(
{ ignores: ['dist/**', 'playwright-report/**', 'test-results/**', 'eslint.config.mjs'] },
...[
js.configs.recommended,
...tseslint.configs.recommended,
sonarjs.configs.recommended,
// v7 of this plugin ships the React Compiler rule set alongside the two
// classic rules. This is React 18 with no compiler in the build, so those
// extra rules advise against a stricter model than the code was written
// for — worth seeing (`purity` catches a real `Date.now()` in render), not
// worth failing a build over.
reactHooks.configs.flat['recommended-latest'],
{ rules: jsxA11y.flatConfigs.recommended.rules },
].map(advisory),
{ plugins: { 'jsx-a11y': jsxA11y } },
{
// `tests/` and the Playwright specs are deliberately out of scope for now:
// tsconfig.json only includes `src`, so type-aware linting has no program
// for them, and widening it is a separate change with its own count.
files: ['src/**/*.{ts,tsx}'],
languageOptions: {
globals: globals.browser,
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
},
rules: {
// Hooks called conditionally break React outright.
'react-hooks/rules-of-hooks': 'error',
// The stale-closure rule. PR #11 fixed a cart badge that did not update
// after account creation, which is precisely what this catches.
'react-hooks/exhaustive-deps': 'error',
// An unawaited promise fails silently — the same class of defect as the
// unwrapped async routes in #59, and what the project's Playwright notes
// already warn about for missing `await`.
'@typescript-eslint/no-floating-promises': 'error',
// `attributes: false` because `onClick={async () => ...}` is idiomatic
// React and safe when the handler catches its own errors. Left at the
// default this rule flags every antd button in the admin screens — 25 of
// its 28 hits here — and a rule that is 89% noise gets switched off.
'@typescript-eslint/no-misused-promises': [
'error',
{ checksVoidReturn: { attributes: false } },
],
// A storefront image with no alt text is unusable in a screen reader, and
// the fix is one attribute.
'jsx-a11y/alt-text': 'error',
},
}
);
+3497
View File
File diff suppressed because it is too large Load Diff
+8
View File
@@ -5,6 +5,7 @@
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"lint": "eslint src",
"test:e2e": "playwright test"
},
"dependencies": {
@@ -18,13 +19,20 @@
"remark-gfm": "^4.0.0"
},
"devDependencies": {
"@eslint/js": "^9.39.5",
"@playwright/test": "^1.47.0",
"@types/pg": "^8.23.1",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"eslint": "^9.39.5",
"eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-sonarjs": "^4.2.0",
"globals": "^17.11.0",
"pg": "^8.23.0",
"typescript": "^5.5.4",
"typescript-eslint": "^8.67.0",
"vite": "^5.4.0"
}
}
+3 -3
View File
@@ -92,7 +92,7 @@ export default function App() {
return;
}
setLoading(true);
const timer = setTimeout(load, FILTER_DEBOUNCE_MS);
const timer = setTimeout(() => void load(), FILTER_DEBOUNCE_MS);
return () => clearTimeout(timer);
}, [load, awaitingAuth, needsFavoritesAuth]);
@@ -103,7 +103,7 @@ export default function App() {
// Adding to cart flips an item to reserved, and the filter options' price
// bounds shift as inventory changes.
const reload = useCallback(() => {
load();
void load();
fetchFilterOptions().then(setOptions).catch(() => undefined);
}, [load]);
@@ -171,7 +171,7 @@ export default function App() {
showIcon
message="Couldn't load items"
description="The server didn't return the catalogue. This is usually temporary."
action={<Button size="small" onClick={() => { setLoading(true); load(); }}>Retry</Button>}
action={<Button size="small" onClick={() => { setLoading(true); void load(); }}>Retry</Button>}
/>
) : needsFavoritesAuth ? (
<Empty description="Sign in to see the items you have favorited">
+26 -18
View File
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import {
Layout, Table, Button, Form, Input, InputNumber, Upload, Modal,
Space, Tag, Typography, Switch, message, Image as AntImage, theme, Tabs,
@@ -43,24 +43,32 @@ function Inventory() {
// arrive out of order and an older one can repaint stale rows over a newer
// result. Only the most recently issued request is allowed to set state.
const latestRequest = useRef(0);
const load = (active: ItemFilters = filters) => {
// useCallback rather than a plain function so the effect below can depend on
// it honestly: a function rebuilt every render would either loop forever in
// the dependency array or have to be suppressed out of it.
const load = useCallback((active: ItemFilters = filters) => {
const seq = ++latestRequest.current;
return fetchAdminItems(active).then(rows => {
if (seq === latestRequest.current) setItems(rows);
});
};
return fetchAdminItems(active)
.then(rows => {
if (seq === latestRequest.current) setItems(rows);
})
// Without this the table simply keeps showing whatever it had, so a
// failed refetch after a save looks identical to a save that did not
// change anything.
.catch(() => message.error('Could not load items'));
}, [filters]);
// The item form needs the current category tree and tag list; both change
// from the sibling tabs, so they're refetched whenever the modal opens.
const loadOptions = () => Promise.all([
const loadOptions = useCallback(() => Promise.all([
fetchAdminCategories().then(setCategories),
fetchAdminTags().then(setTags)
]);
]).catch(() => message.error('Could not load categories and tags')), []);
// Refetch whenever the filters change — filtering is server-side so the
// result stays correct regardless of how many items exist.
useEffect(() => { load(filters); }, [filters]);
useEffect(() => { loadOptions(); }, []);
useEffect(() => { void load(filters); }, [load, filters]);
useEffect(() => { void loadOptions(); }, [loadOptions]);
function applyFilters(next: ItemFilters) { setFilters(next); }
function clearFilters() { setFilters(EMPTY_FILTERS); }
@@ -70,7 +78,7 @@ function Inventory() {
form.resetFields();
setFileList([]);
setDescription('');
loadOptions();
void loadOptions();
setModalOpen(true);
}
@@ -84,7 +92,7 @@ function Inventory() {
});
setFileList([]);
setDescription(item.description || '');
loadOptions();
void loadOptions();
setModalOpen(true);
}
@@ -114,8 +122,8 @@ function Inventory() {
message.success(editingItem ? 'Item updated' : 'Item added');
setModalOpen(false);
load();
loadOptions();
void load();
void loadOptions();
}
// Status changes silently did nothing on failure — the row simply stayed put
@@ -127,7 +135,7 @@ function Inventory() {
message.error(`Couldn't ${label}${(err as Error).message}`);
return;
}
load();
void load();
}
async function handleDelete(id: number) {
@@ -138,7 +146,7 @@ function Inventory() {
return;
}
message.success('Item deleted');
load();
void load();
}
async function handleDeleteImage(itemId: number, imageId: number) {
@@ -149,7 +157,7 @@ function Inventory() {
return;
}
message.success('Image removed');
load();
void load();
setEditingItem(prev => prev && prev.id === itemId
? { ...prev, images: prev.images.filter(img => img.id !== imageId) }
: prev);
@@ -162,7 +170,7 @@ function Inventory() {
render: (images: Item['images']) =>
images[0] ? (
<span style={{ position: 'relative', display: 'inline-block' }}>
<img src={images[0].image_path} style={{ width: 60 }} />
<img src={images[0].image_path} alt="" style={{ width: 60 }} />
{images.length > 1 && (
<Tag style={{ position: 'absolute', bottom: -4, right: -8, fontSize: 10 }}>+{images.length - 1}</Tag>
)}
+11 -5
View File
@@ -74,10 +74,11 @@ export default function Categories() {
expansionInitialized.current = true;
}
})
.catch(() => message.error('Could not load categories'))
.finally(() => setLoading(false));
}
useEffect(() => { load(); }, []);
useEffect(() => { void load(); }, []);
function openNew(parent: number | null) {
setEditing(null);
@@ -110,7 +111,7 @@ export default function Categories() {
// Reveal where the category just landed instead of filing it out of sight.
if (parentId !== null) expand(parentId);
setModalOpen(false);
load();
void load();
} catch (err) {
message.error((err as Error).message);
}
@@ -137,7 +138,7 @@ export default function Categories() {
`Deleted ${result.deleted_categories} categor${result.deleted_categories === 1 ? 'y' : 'ies'}, ` +
`uncategorized ${result.uncategorized_items} item${result.uncategorized_items === 1 ? '' : 's'}`
);
load();
void load();
}
});
}
@@ -145,7 +146,7 @@ export default function Categories() {
// Dragging a node onto another reparents it. The server rejects a move that
// would put a node beneath its own descendant, so a refused drop just
// reloads the unchanged tree.
const handleDrop: TreeProps['onDrop'] = async (info) => {
const dropCategory = async (info: Parameters<NonNullable<TreeProps['onDrop']>>[0]) => {
const dragId = Number(info.dragNode.key);
const dropId = Number(info.node.key);
const dropToGap = !info.dropToGap ? dropId : findNode(tree, dropId)?.parent_id ?? null;
@@ -157,9 +158,14 @@ export default function Categories() {
} catch (err) {
message.error((err as Error).message);
}
load();
void load();
};
// Kept separate from dropCategory so the handler antd receives returns void,
// as its type says. An async function here would hand Tree a promise it never
// awaits.
const handleDrop: TreeProps['onDrop'] = (info) => void dropCategory(info);
function toTreeData(nodes: CategoryNode[]): DataNode[] {
return nodes.map((node) => ({
key: node.id,
+10 -6
View File
@@ -22,10 +22,14 @@ export default function Customers() {
const [togglingId, setTogglingId] = useState<number | null>(null);
function load() {
return fetchCustomers().then(rows => { setCustomers(rows); setLoading(false); });
return fetchCustomers()
.then(rows => { setCustomers(rows); setLoading(false); })
// Without this a failed load leaves the spinner up forever, with no
// indication that anything went wrong.
.catch(() => { setLoading(false); message.error('Could not load customers'); });
}
useEffect(() => { load(); }, []);
useEffect(() => { void load(); }, []);
async function openDetail(id: number) {
setDrawerOpen(true);
@@ -83,7 +87,7 @@ export default function Customers() {
setTogglingId(null);
}
message.success(disabling ? 'Account disabled' : 'Account re-enabled');
load();
void load();
}
});
}
@@ -103,7 +107,7 @@ export default function Customers() {
// Refresh both the popup and the row count behind it, so the count can't
// disagree with the list it opened from.
setReserved(await fetchReservedItems(reservedFor.id));
load();
void load();
}
const columns: ColumnsType<CustomerSummary> = [
@@ -151,7 +155,7 @@ export default function Customers() {
// The whole row opens the customer drawer, so without this the
// click reaches both handlers and the drawer opens behind the
// reserved-items dialog.
onClick={(event) => { event.stopPropagation(); openReserved(customer); }}
onClick={(event) => { event.stopPropagation(); void openReserved(customer); }}
>
{count} item{Number(count) === 1 ? '' : 's'}
</Button>
@@ -208,7 +212,7 @@ export default function Customers() {
loading={loading}
dataSource={customers}
columns={columns}
onRow={row => ({ onClick: () => openDetail(row.id), style: { cursor: 'pointer' } })}
onRow={row => ({ onClick: () => void openDetail(row.id), style: { cursor: 'pointer' } })}
pagination={{ pageSize: 10 }}
/>
+7 -4
View File
@@ -9,10 +9,13 @@ export default function Settings() {
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchAdminSettings().then(s => {
form.setFieldsValue({ cartExpiryHours: s.cartExpiryHours });
setLoading(false);
});
fetchAdminSettings()
.then(s => {
form.setFieldsValue({ cartExpiryHours: s.cartExpiryHours });
})
// A rejection here used to leave the form spinning indefinitely.
.catch(() => message.error('Could not load settings'))
.finally(() => setLoading(false));
}, [form]);
async function handleSave() {
+4 -3
View File
@@ -32,10 +32,11 @@ export default function Tags() {
setLoading(true);
return fetchAdminTags()
.then(setTags)
.catch(() => message.error('Could not load tags'))
.finally(() => setLoading(false));
}
useEffect(() => { load(); }, []);
useEffect(() => { void load(); }, []);
function openNew() {
setEditing(null);
@@ -68,7 +69,7 @@ export default function Tags() {
message.success('Tag added');
}
setModalOpen(false);
load();
void load();
} catch (err) {
message.error((err as Error).message);
}
@@ -83,7 +84,7 @@ export default function Tags() {
onOk: async () => {
await deleteTag(tag.id);
message.success('Tag deleted');
load();
void load();
}
});
}
+10 -5
View File
@@ -45,14 +45,16 @@ export default function Cart() {
function loadAll() {
setLoading(true);
Promise.all([fetchCart(), fetchAddresses(), fetchConfig()]).then(([cartData, addrs, cfg]) => {
void Promise.all([fetchCart(), fetchAddresses(), fetchConfig()]).then(([cartData, addrs, cfg]) => {
setItems(cartData.items);
setAddresses(addrs);
const def = addrs.find(a => a.is_default);
setSelectedAddressId(def ? def.id : (addrs[0]?.id ?? null));
setConfig(cfg);
setLoading(false);
});
})
// Anything here failing left the cart on a spinner with no explanation.
.catch(() => message.error('Could not load your cart'))
.finally(() => setLoading(false));
}
useEffect(() => { if (customer) loadAll(); }, [customer]);
@@ -129,7 +131,10 @@ export default function Cart() {
message.error('Checkout error, please try again.');
}
}).render('#paypal-cart-buttons');
}, [paypalReady, selectedAddressId, items.length]);
// refreshCartContext is a useCallback with an empty dependency list, so
// naming it here cannot re-run this effect and re-render the PayPal
// buttons — it just makes the dependency honest.
}, [paypalReady, selectedAddressId, items.length, refreshCartContext]);
if (authLoading || loading) return <Spin style={{ margin: 48 }} />;
@@ -154,7 +159,7 @@ export default function Cart() {
renderItem={item => (
<List.Item actions={[<Button danger size="small" onClick={() => handleRemove(item.item_id)}>Remove</Button>]}>
<List.Item.Meta
avatar={item.images[0] && <img src={item.images[0].image_path} style={{ width: 60, height: 60, objectFit: 'cover' }} />}
avatar={item.images[0] && <img src={item.images[0].image_path} alt="" style={{ width: 60, height: 60, objectFit: 'cover' }} />}
title={item.name}
description={
<Text type={new Date(item.expires_at).getTime() - Date.now() < 60 * 60 * 1000 ? 'danger' : 'secondary'}>
+4 -4
View File
@@ -56,7 +56,7 @@ export default function ItemCard({ item, onChanged }: Props) {
setAuthModalOpen(true);
return;
}
doAddToCart();
void doAddToCart();
}
// Asked only once a customer has actually favorited something, so the reason
@@ -110,7 +110,7 @@ export default function ItemCard({ item, onChanged }: Props) {
setAuthModalOpen(true);
return;
}
doToggleFavorite();
void doToggleFavorite();
}
const hasMultiple = item.images.length > 1;
@@ -183,8 +183,8 @@ export default function ItemCard({ item, onChanged }: Props) {
onClose={() => setAuthModalOpen(false)}
onSuccess={() => {
setAuthModalOpen(false);
if (pendingAction === 'favorite') doToggleFavorite();
else doAddToCart();
if (pendingAction === 'favorite') void doToggleFavorite();
else void doAddToCart();
}}
/>
</Card>
+1 -1
View File
@@ -19,7 +19,7 @@ export default function Account({ onClose }: Props) {
const navigate = useNavigate();
useEffect(() => {
if (customer) fetchMyOrders().then(setOrders);
if (customer) void fetchMyOrders().then(setOrders).catch(() => message.error('Could not load your orders'));
}, [customer]);
useEffect(() => {
@@ -25,7 +25,12 @@ export function CustomerAuthProvider({ children }: { children: React.ReactNode }
const refresh = useCallback(() => {
setLoading(true);
fetchMe().then(c => { setCustomer(c); setLoading(false); });
// A rejection here previously left `loading` true forever, which renders
// as a permanent spinner rather than a signed-out page.
void fetchMe()
.then(c => setCustomer(c))
.catch(() => setCustomer(null))
.finally(() => setLoading(false));
}, []);
useEffect(() => { refresh(); }, [refresh]);