Merge branch 'main' into bugfix/67-sonar-frontend-skipped
This commit is contained in:
@@ -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',
|
||||
},
|
||||
}
|
||||
);
|
||||
Generated
+3497
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }}
|
||||
/>
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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'}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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]);
|
||||
|
||||
Reference in New Issue
Block a user