Project settings: tabbed & grouped marker types — implementation plan¶
Source: user request (not from docs/features/future/backlog.md — the
only grouping line there, "group the entries by type" under "Map Edit View
Sidebar", is about the map-edit sidebar's POI list, already shipped per
map-edit-sidebar-poi-list.md, and
is a different screen from this one).
Today, project.tsx's Settings tab
renders three stacked sections — "Point of interest types", "Line types",
"Area types" — each a flat ItemGroup list, separated by <Separator />.
Each type (PoiTypeDefinition/LineTypeDefinition/AreaTypeDefinition in
app/lib/poi.ts/line.ts/area.ts) already has an optional group: string
field, set via a free-text Input in PoiTypeForm/LineTypeForm/
AreaTypeForm, but nothing currently reads it for display.
This plan replaces the three stacked sections with a nested Point/Line/Area
tab bar, groups each tab's list by group, and upgrades the group form
field from free text to the existing Combobox component (suggests this
project's existing groups for that kind, still accepts a new typed value).
Three parts, sequenced so each leaves project.tsx type-checking and
shippable on its own:
- Structural tab bar (pure reshuffle, no behavior change).
- Grouped headings within each tab.
groupfield becomes aComboboxin the three add/edit forms.
Cross-cutting decisions¶
| Decision | Choice | Why |
|---|---|---|
| Tab state | Uncontrolled, via <Tabs defaultValue="point"> (no new useState) |
Matches the existing outer <Tabs defaultValue="maps"> at line 787 — no other tab on this page needs to be read or set from outside itself |
| Ungrouped heading | A literal "Ungrouped" heading, always rendered last | Confirmed with user |
| Group heading order | Alphabetical (case-insensitive), "Ungrouped" always last regardless of where it'd sort alphabetically | Confirmed with user |
| Grouping helper | One shared generic helper (e.g. groupByField), used by all three lists |
The three lists (poiTypes, lineTypes, areaTypes) group on the exact same shape ({ group?: string }) — one helper avoids three copies of the same sort/bucket logic |
| Combobox suggestions scope | Distinct group values from that project's types of the same kind only (POI groups don't leak into the Line form's suggestions) |
Confirmed in refined task; a "Road types" group on lines has nothing to do with a "Settlements" group on POIs |
| Combobox value plumbing | Local useState<string> for the group value/input text, submitted via the existing hidden-input pattern (<input type="hidden" name="group" value={group} />), mirroring how color/icon are already threaded from PoiTypeForm's local state into the form post |
No new server/action code needed; the action already reads formData.get("group") as free text today, so any string arriving via that hidden input works unchanged |
| No data/server changes | app/lib/poi.ts, line.ts, area.ts, projects.server.ts, and the action's group handling (lines ~150, ~206, ~260) are untouched |
group is already a stored optional string with no validation; this is a display + input-widget change only |
Out of scope¶
- Reordering groups manually, renaming a group across all its types in one action, or deleting an empty group.
- Any change to
app/components/feature-control.tsx(the map-edit sidebar's own grouped checkbox list) — used only as a reference pattern. - Grouping/tabs beyond Point/Line/Area (e.g. nested sub-groups).
Part 1: Point/Line/Area tab bar (structural only)¶
Goal¶
The Settings tab shows a nested tab bar (Point, Line, Area) in place of the three stacked, separator-divided sections. No grouping or form changes yet — each tab's content is exactly today's existing list markup, moved as-is.
Scope¶
In: wrapping the existing "Point of interest types" / "Line types" / "Area
types" blocks (currently ~project.tsx
lines 960–1251) each in its own TabsContent, under one new nested Tabs/
TabsList/TabsTrigger bar; removing the two <Separator />s that
currently divide them.
Out: grouping by group (Part 2); any change to the add/edit forms (Part
3); the outer Maps/Settings tab bar (unchanged).
Tasks¶
T1 — Nest a nested tab bar around the three type sections¶
Goal: Selecting Point, Line, or Area shows only that kind's existing list/add-button/inline-form block; nothing else changes.
Acceptance criteria:
- A
<Tabs defaultValue="point">with a<TabsList>containing three<TabsTrigger>s ("Point", "Line", "Area") renders where the three<Separator />-divided<div>blocks currently sit inside the outer SettingsTabsContent. - Each existing block (heading + Add-type button +
ItemGroup+ inline add form) moves, unmodified, into its own<TabsContent value="point" | "line" | "area">— same JSX, same conditions (isAddingType/editingTypeSlugetc.), just re-parented. - The two
<Separator />elements that previously divided the three sections are removed (a tab bar replaces the need for a divider). - No new
useState, fetcher, or server code is added in this task. pnpm typecheckpasses.- Manual check:
pnpm dev, open a project's Settings tab, confirm all three tabs show their previously-existing content unchanged, and that add/edit/delete on each still work exactly as before.
Part 2: Grouped headings within each tab¶
Depends on Part 1 (grouping renders inside the new TabsContents, so Part
1's structure must exist first).
Goal¶
Within each of the three tabs, types sharing a group value render under
a heading for that group; types with no group render under a trailing
"Ungrouped" heading. Alphabetical group order.
Scope¶
In: a shared grouping helper; heading markup inserted into each of the
three ItemGroup lists; sorting groups alphabetically with "Ungrouped"
forced last.
Out: any change to how an individual Item row renders, how add/edit
forms behave (Part 3), or how usage counts (countPoiTypeUsage etc.) are
computed.
Data model¶
// app/routes/project.tsx — local helper, not exported
function groupByField<T extends { group?: string }>(
items: T[]
): { group: string; items: T[] }[]
- Buckets
itemsbygroup, one bucket per distinct value plus one bucket for items with nogroup. - Returns buckets sorted alphabetically (case-insensitive) by
group, with the no-groupbucket last, itsgroupset to the literal string"Ungrouped".
Tasks¶
T2 — groupByField helper¶
Goal: One helper produces alphabetically-sorted, "Ungrouped"-last buckets for any of the three type arrays.
Acceptance criteria:
groupByFieldis defined once inproject.tsx(near the other local helpers likecountPoiTypeUsage) with the signature above.- Alphabetical comparison is case-insensitive; the
"Ungrouped"bucket is always last regardless of its position in a case-insensitive sort. - An input with no
group-less items produces no"Ungrouped"bucket (empty buckets aren't rendered). pnpm typecheckpasses.
T3 — Render group headings in all three tabs¶
Goal: Each tab's ItemGroup list is broken into per-group sections
with a heading, using groupByField.
Acceptance criteria:
- The Point tab maps
groupByField(project.poiTypes)and renders a heading (e.g.<h3>) per bucket'sgroupname above that bucket's existingItemrows; same for Line (project.lineTypes) and Area (project.areaTypes). - The existing per-row edit-in-place behavior (
editingTypeSlugetc. swapping a row for its form) continues to work unchanged inside a group's bucket. - The inline "add type" form (
isAddingTypeetc.) keeps its current position (end of the list) and is not itself grouped. - A project with no types set for any of its entries (all
groupundefined) shows a single "Ungrouped" heading, not a headingless flat list — consistent, predictable structure over a special-cased empty state. pnpm typecheckpasses.- Manual check:
pnpm dev, set agroupon a couple of POI types via Edit, confirm they render under a shared heading, and ungrouped ones fall under a trailing "Ungrouped" heading; repeat for Line and Area.
Part 3: group field becomes a Combobox¶
Independent of Parts 1–2 (touches the add/edit forms, not the list display), but sequenced last since it's the most novel piece of UI.
Goal¶
In PoiTypeForm, LineTypeForm, and AreaTypeForm, the group field is
a Combobox (from app/components/ui/combobox.tsx) offering that
project's existing group names for the matching kind as suggestions, while
still accepting a freshly typed new group name.
Scope¶
In: replacing the <Input name="group" .../> in all three forms with a
Combobox-based field, backed by a hidden input name="group" carrying
whatever value/typed text is current, sourced from a per-kind distinct-
groups list computed from project.poiTypes / lineTypes / areaTypes.
Out: any change to the action's group handling (already accepts any
string), or to groupByField from Part 2 (a separate, simpler distinct-
values list is computed here, per kind).
Data model¶
// app/routes/project.tsx — local helper, not exported
function distinctGroups(items: { group?: string }[]): string[]
- Returns the distinct, alphabetically-sorted (case-insensitive)
groupvalues present initems, excludingundefined/empty.
Tasks¶
T4 — distinctGroups helper¶
Goal: Each form can be given the right suggestion list for its kind.
Acceptance criteria:
distinctGroupsis defined once inproject.tsxwith the signature above.- Called as
distinctGroups(project.poiTypes)/distinctGroups(project.lineTypes)/distinctGroups(project.areaTypes)and passed intoPoiTypeForm/LineTypeForm/AreaTypeFormas a newgroupOptions: string[]prop. pnpm typecheckpasses.
T5 — Swap the group Input for a Combobox in all three forms¶
Goal: The group field in each form lets a user pick an existing group
for that kind or type a brand-new one, and the chosen/typed value still
submits as a plain group string field.
Acceptance criteria:
PoiTypeForm,LineTypeForm, andAreaTypeFormeach gain agroupOptions: string[]prop (from T4) and replace theirgroup<Input>with aComboboxbuilt fromCombobox/ComboboxInput/ComboboxContent/ComboboxList/ComboboxItem/ComboboxEmpty(app/components/ui/combobox.tsx), listinggroupOptionsas items.- The combobox is initialized from
defaultValues?.groupwhen editing an existing type (same as the currentInput'sdefaultValue). - Typing a value not present in
groupOptionsand submitting the form still saves that typed value as the type'sgroup(same free-text behavior as today) — the combobox constrains suggestions, not input. - Clearing the field and submitting still saves an
undefined/nogroup(matching today's "blank clears the field" behavior from thegroup || undefinedhandling already in the action). - A hidden
<input type="hidden" name="group" value={...} />continues to be what the form actually submits, so no action/server code changes. pnpm typecheckpasses.- Manual check:
pnpm dev, open the Add-type form for each kind, confirm existing groups (for that kind only) show as suggestions, typing a new group name and saving works, and editing a type pre-fills its current group.