refactor: remove the duplicated blocks SonarQube found (#182)
Three of the four candidates were real. The fourth was my mistake in the issue. **The category tree adapter**, duplicated verbatim between `CategoryTreeSelect.tsx` and `FilterDrawer.tsx`. This one was mine: #139 moved the storefront filter to a `TreeSelect` and copied the admin's adapter rather than sharing it, with a comment saying the shape "matches the admin's CategoryTreeSelect so the two stay comparable" — an argument for one implementation that instead produced two. It now lives in `filters.ts` beside `buildCategoryTree`, which was already shared for exactly the same reason: one meaning, one implementation. `Categories.tsx` keeps its own. It builds a different shape for a real antd `Tree`, keyed rather than valued, with a title that is a React node carrying that screen's buttons. Genuinely different, and folding it in would mean a parameterised adapter that serves neither case clearly. **The `item_images` insert loop**, written separately by create and update and differing only in where the id came from and where the sort order started. Both are parameters now, which also means the `/uploads/` prefix is written once — #103 made that the value `uploadUrl` joins an origin onto, so it is a contract rather than a string. Extracting it turned up two things the inline versions hid. Create indexed `files[i]?.filename ?? ''`, so a missing element would have stored a path pointing at the uploads directory itself; iterating by entry removes the possibility rather than defending against it. And the helper's typed `itemId` surfaced that `req.params.id` is `string | undefined` under `noUncheckedIndexedAccess`, which the old inline `unknown[]` swallowed — now `Number()`, as the `setItemTags` call two lines above already did. **The optional-field guards**, eight identical lines opening both routes. The distinction worth preserving is that `undefined` means "not submitted", which update reads as "leave as-is", so an unparseable value has to be told apart from an absent one. That is what makes it more than a null check and worth stating once. **`TAG_COLORS` was not a duplication.** The issue listed four files on the strength of a grep that also matched `STATUS_TAG_COLORS` in `Admin.tsx` — a status-to-colour map for the inventory table, unrelated to the tag palette. What remains is one definition in `backend/src/utils.ts` and one mirror in `frontend/src/admin/Tags.tsx`, already carrying a comment pointing at the other, which is the same treatment `ALLOWED_IMAGE_TYPES` gets and is correct: there is no shared package, and creating one for a colour list would cost more than it saves. Verified beyond the type checker, since three of these are pure moves that compile either way: 278 unit and 254 integration tests, and the end-to-end specs covering both consumers of the shared adapter — the storefront drawer and the admin item form's category picker, including inline category creation. Closes #182
This commit is contained in:
@@ -5,21 +5,7 @@ import Button from 'antd/es/button';
|
||||
import Divider from 'antd/es/divider';
|
||||
import message from 'antd/es/message';
|
||||
import { Category, createCategory, fetchAdminCategories } from '../api';
|
||||
import { buildCategoryTree, CategoryNode } from '../filters';
|
||||
|
||||
interface CategoryTreeOption {
|
||||
value: number;
|
||||
title: string;
|
||||
children?: CategoryTreeOption[];
|
||||
}
|
||||
|
||||
function toTreeData(nodes: CategoryNode[]): CategoryTreeOption[] {
|
||||
return nodes.map((node) => ({
|
||||
value: node.id,
|
||||
title: node.name,
|
||||
children: node.children.length ? toTreeData(node.children) : undefined
|
||||
}));
|
||||
}
|
||||
import { buildCategoryTree, toCategoryTreeData } from '../filters';
|
||||
|
||||
type Props = Readonly<{
|
||||
// Supplied by antd's Form.Item. `id` has to be forwarded or the field loses
|
||||
@@ -40,7 +26,7 @@ export default function CategoryTreeSelect({ value, onChange, id, categories, on
|
||||
const [newCategoryName, setNewCategoryName] = useState('');
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
const treeData = useMemo(() => toTreeData(buildCategoryTree(categories)), [categories]);
|
||||
const treeData = useMemo(() => toCategoryTreeData(buildCategoryTree(categories)), [categories]);
|
||||
|
||||
// Tags can be invented from the item form, so categories should be too —
|
||||
// otherwise adding an item in a new category means abandoning a half-filled
|
||||
|
||||
@@ -9,7 +9,7 @@ import Empty from 'antd/es/empty';
|
||||
import Switch from 'antd/es/switch';
|
||||
import Grid from 'antd/es/grid';
|
||||
import type { Category, Tag as ItemTag } from '../api';
|
||||
import { ItemFilters, ItemStatus, STATUS_OPTIONS, buildCategoryTree, CategoryNode } from '../filters';
|
||||
import { ItemFilters, ItemStatus, STATUS_OPTIONS, buildCategoryTree, toCategoryTreeData } from '../filters';
|
||||
|
||||
// One drawer for the storefront and the admin, with the sections that differ
|
||||
// driven by props rather than by a second component that would drift (#169).
|
||||
@@ -37,23 +37,6 @@ type Props = Readonly<{
|
||||
showStatus?: boolean;
|
||||
}>;
|
||||
|
||||
// `value` rather than `key`: this fed an antd `Tree`, which identifies nodes by
|
||||
// key, and now feeds a `TreeSelect`, which selects and searches by value. The
|
||||
// shape matches the admin's CategoryTreeSelect so the two stay comparable.
|
||||
interface CategoryTreeOption {
|
||||
value: number;
|
||||
title: string;
|
||||
children?: CategoryTreeOption[];
|
||||
}
|
||||
|
||||
function toTreeData(nodes: CategoryNode[]): CategoryTreeOption[] {
|
||||
return nodes.map((node) => ({
|
||||
value: node.id,
|
||||
title: node.name,
|
||||
children: node.children.length ? toTreeData(node.children) : undefined
|
||||
}));
|
||||
}
|
||||
|
||||
const sectionHeading: React.CSSProperties = {
|
||||
margin: '0 0 8px',
|
||||
fontSize: 12,
|
||||
@@ -146,7 +129,7 @@ export default function FilterDrawer({
|
||||
// read off highlighting. The admin's CategoryTreeSelect is the same
|
||||
// control, so the two screens behave alike.
|
||||
<TreeSelect
|
||||
treeData={toTreeData(buildCategoryTree(categories))}
|
||||
treeData={toCategoryTreeData(buildCategoryTree(categories))}
|
||||
value={filters.categoryIds}
|
||||
onChange={selectCategories}
|
||||
multiple
|
||||
|
||||
@@ -219,6 +219,33 @@ export function buildCategoryTree(categories: Category[]): CategoryNode[] {
|
||||
return roots;
|
||||
}
|
||||
|
||||
/**
|
||||
* A category tree in the shape antd's `TreeSelect` reads.
|
||||
*
|
||||
* Beside `buildCategoryTree` because it has the same property: one meaning, so
|
||||
* one implementation. It lived in both `CategoryTreeSelect.tsx` and
|
||||
* `FilterDrawer.tsx` verbatim after #139 copied it rather than sharing it, and
|
||||
* two copies of a mapping is two places for a field to be renamed.
|
||||
*
|
||||
* `value` rather than `key`: a `TreeSelect` selects and searches by value,
|
||||
* where an antd `Tree` identifies nodes by key. `Categories.tsx` builds a third
|
||||
* shape for a real `Tree`, whose title is a React node carrying that screen's
|
||||
* own buttons — genuinely different, and deliberately not folded in here.
|
||||
*/
|
||||
export interface CategoryTreeOption {
|
||||
value: number;
|
||||
title: string;
|
||||
children?: CategoryTreeOption[];
|
||||
}
|
||||
|
||||
export function toCategoryTreeData(nodes: CategoryNode[]): CategoryTreeOption[] {
|
||||
return nodes.map((node) => ({
|
||||
value: node.id,
|
||||
title: node.name,
|
||||
children: node.children.length ? toCategoryTreeData(node.children) : undefined
|
||||
}));
|
||||
}
|
||||
|
||||
// "Furniture / Tables / Coffee Tables" — used on chips and in the admin form so
|
||||
// a leaf name like "Vintage" isn't ambiguous between branches.
|
||||
export function categoryPath(categories: Category[], id: number): string {
|
||||
|
||||
Reference in New Issue
Block a user