Create/edit place form with a map picker (task 94) #72

Merged
rob merged 3 commits from feat/place-form into main 2026-08-05 20:01:57 +00:00
Owner

Closes task 94. Took ADR-0064 (0063 is claimed by PR #71, which is untouched by this branch).

PlaceForm (src/PlaceMark.WebUI/Places/) creates or amends a place — name, description, latitude/longitude — validated against CreatePlaceRequest/UpdatePlaceRequest's own annotations (ADR-0025), not a restated copy.

One pair of fields for both input paths. Typing writes into _latitude/_longitude directly; picking on the embedded map (LeafletMap.OnPick) writes into the same two fields. There is no second "picked" representation to fall out of step with a typed one, so a round trip through either path cannot drift. LeafletMap.Centre is computed once on init and never re-derived from the coordinate fields, so typing moves the marker without panning the map.

Interop extension (ADR-0064). LeafletMap gains EnablePicking/OnPick: a map click or a marker drag now calls back into .NET through one new [JSInvokable] method. Off by default — the home map and group overview are unaffected. Full reasoning and alternatives in the ADR.

409 handling. Place.Version round-trips into UpdatePlaceRequest.Version (ADR-0048). A stale version is told apart from a validation failure by status, and rendered with prose naming what happened: someone else changed the place first, and it's their change a silent overwrite would have destroyed — not the caller's edit, which was not saved rather than lost.

Group handling. Create mode offers a group selector filtered to the caller's Owner/Editor groups only (courtesy, not authority — the API still decides). Edit mode offers no group control at all: UpdatePlaceRequest carries no group_id, so it structurally cannot change here. Moving a place is task 71's own endpoint.

Navigation. No navigation on success, following GroupForm's own precedent (task 69) rather than the groomed criterion's "group detail page" — written before the map became the home page and before ADR-0062 replaced a place detail page with a panel. Raises OnSaved; the host decides what happens next.

What bUnit cannot show (task 156, no browser here): that a real click or drag actually reaches OnMapPicked, that dragging feels responsive, or anything about how the picker renders. The round-trip tests prove the .NET-side channel is exact at full double precision by invoking OnMapPicked directly, not by driving a real pointer.

Full CI sequence (restore, build, test, dotnet format --verify-no-changes) run locally against the pinned SDK — 1187 tests pass.

Closes task 94. Took ADR-0064 (0063 is claimed by PR #71, which is untouched by this branch). **`PlaceForm`** (`src/PlaceMark.WebUI/Places/`) creates or amends a place — name, description, latitude/longitude — validated against `CreatePlaceRequest`/`UpdatePlaceRequest`'s own annotations (ADR-0025), not a restated copy. **One pair of fields for both input paths.** Typing writes into `_latitude`/`_longitude` directly; picking on the embedded map (`LeafletMap.OnPick`) writes into the same two fields. There is no second "picked" representation to fall out of step with a typed one, so a round trip through either path cannot drift. `LeafletMap.Centre` is computed once on init and never re-derived from the coordinate fields, so typing moves the marker without panning the map. **Interop extension (ADR-0064).** `LeafletMap` gains `EnablePicking`/`OnPick`: a map click or a marker drag now calls back into .NET through one new `[JSInvokable]` method. Off by default — the home map and group overview are unaffected. Full reasoning and alternatives in the ADR. **409 handling.** `Place.Version` round-trips into `UpdatePlaceRequest.Version` (ADR-0048). A stale version is told apart from a validation failure by status, and rendered with prose naming what happened: someone else changed the place first, and it's their change a silent overwrite would have destroyed — not the caller's edit, which was not saved rather than lost. **Group handling.** Create mode offers a group selector filtered to the caller's Owner/Editor groups only (courtesy, not authority — the API still decides). Edit mode offers no group control at all: `UpdatePlaceRequest` carries no `group_id`, so it structurally cannot change here. Moving a place is task 71's own endpoint. **Navigation.** No navigation on success, following `GroupForm`'s own precedent (task 69) rather than the groomed criterion's "group detail page" — written before the map became the home page and before ADR-0062 replaced a place detail page with a panel. Raises `OnSaved`; the host decides what happens next. **What bUnit cannot show** (task 156, no browser here): that a real click or drag actually reaches `OnMapPicked`, that dragging feels responsive, or anything about how the picker renders. The round-trip tests prove the .NET-side channel is exact at full double precision by invoking `OnMapPicked` directly, not by driving a real pointer. Full CI sequence (restore, build, test, `dotnet format --verify-no-changes`) run locally against the pinned SDK — 1187 tests pass.
Add the create/edit place form with a map picker
All checks were successful
CI / build (pull_request) Successful in 2m35s
75cc56b675
rob left a comment

Verdict: changes needed

Stale Version on a second consecutive edit. PlaceForm reads Place.Version live off the [Parameter] in SubmitAsync, but nothing in the component ever updates it after a successful save — SaveAsync invokes OnSaved and stops. Confirmed empirically (added and ran a probe test, since the PR's own suite doesn't cover this): two edits submitted in a row against the same rendered instance, with no host-supplied refresh of Place between them, both send "version":42 — even though the first response carried Version: 43. The doc comment's "round-tripped, never invented" claim only holds for the first submit; every submit after that silently depends on the host swapping in a fresh Place parameter, which is neither enforced nor documented as a requirement, and the PR's own remarks explicitly leave "what happens next" (including "closing a panel" vs. keeping it open) to the host. This is exactly the confusing-409-on-a-second-edit scenario the ticket calls out. Fix: track the current version in a field seeded from Place.Version and updated from saved.Version in SaveAsync's success branch, and use that instead of Place.Version in SubmitAsync; add a test that submits twice in a row.

Clearing a coordinate field silently commits it to 0. Emptying #place-form-latitude (or longitude) sets _latitude to 0, not to an error state — standard BindConverter behaviour for a non-nullable numeric @bind, but this is the first numeric-bound field in the codebase, so nothing established how to handle it. 0 is in range, so RangeAttribute never flags it, and the UI gives no indication the field changed — a caller who clears a field meaning to retype it, then submits without noticing, silently saves a wrong location. Worth a deliberate decision (nullable intermediate field with its own required-message, or otherwise visibly flag empty) rather than leaving the framework default as the behaviour.

Everything else checked out:

  • Round-trip/no-drift: verified with high-precision, negative, zero and boundary (±90/±180) values through both the type and pick paths repeatedly — no rounding or drift. An update loop isn't merely guarded against, it's structurally impossible: HandlePick never dispatches a DOM event or calls back into JS, and setMarkers never synthesises a click/dragend, so there's no feedback path to loop through.
  • group_id is unchangeable on edit by construction, not just a hidden control — UpdatePlaceRequest carries no GroupId member at all.
  • Validation runs the contracts' own annotations (ContractValidation.Validate against CreatePlaceRequest/UpdatePlaceRequest), confirmed rather than assumed.
  • Owner/Editor-only gating confirmed, including fail-closed when CallerRole is unset.
  • The 409 message matches the required wording and is correctly told apart from a 400.
  • OnMapPicked: as narrow as ADR-0064 claims. A DotNetObjectReference is only ever created and handed to map.js when EnablePicking is true, so a non-picking LeafletMap (home map, group overview) has no callable reference to reach at all. The method takes unvalidated primitives, but that's fine — the worst an unexpected caller could do is set a coordinate, which the visible fields already allow, still subject to the same downstream validation.
  • Build, the full suite (1187 tests, matching the PR body's own count) and dotnet format --verify-no-changes all pass clean under the pinned SDK (10.0.100).

What bUnit cannot show, as the PR and ADR-0064 already say: a real click or drag reaching OnMapPicked, drag responsiveness, or anything about how the picker actually renders. Nothing here adds to that.

Verdict: changes needed **Stale `Version` on a second consecutive edit.** `PlaceForm` reads `Place.Version` live off the `[Parameter]` in `SubmitAsync`, but nothing in the component ever updates it after a successful save — `SaveAsync` invokes `OnSaved` and stops. Confirmed empirically (added and ran a probe test, since the PR's own suite doesn't cover this): two edits submitted in a row against the same rendered instance, with no host-supplied refresh of `Place` between them, both send `"version":42` — even though the first response carried `Version: 43`. The doc comment's "round-tripped, never invented" claim only holds for the first submit; every submit after that silently depends on the host swapping in a fresh `Place` parameter, which is neither enforced nor documented as a requirement, and the PR's own remarks explicitly leave "what happens next" (including "closing a panel" vs. keeping it open) to the host. This is exactly the confusing-409-on-a-second-edit scenario the ticket calls out. Fix: track the current version in a field seeded from `Place.Version` and updated from `saved.Version` in `SaveAsync`'s success branch, and use that instead of `Place.Version` in `SubmitAsync`; add a test that submits twice in a row. **Clearing a coordinate field silently commits it to `0`.** Emptying `#place-form-latitude` (or longitude) sets `_latitude` to `0`, not to an error state — standard `BindConverter` behaviour for a non-nullable numeric `@bind`, but this is the first numeric-bound field in the codebase, so nothing established how to handle it. `0` is in range, so `RangeAttribute` never flags it, and the UI gives no indication the field changed — a caller who clears a field meaning to retype it, then submits without noticing, silently saves a wrong location. Worth a deliberate decision (nullable intermediate field with its own required-message, or otherwise visibly flag empty) rather than leaving the framework default as the behaviour. Everything else checked out: - Round-trip/no-drift: verified with high-precision, negative, zero and boundary (±90/±180) values through both the type and pick paths repeatedly — no rounding or drift. An update loop isn't merely guarded against, it's structurally impossible: `HandlePick` never dispatches a DOM event or calls back into JS, and `setMarkers` never synthesises a click/dragend, so there's no feedback path to loop through. - `group_id` is unchangeable on edit by construction, not just a hidden control — `UpdatePlaceRequest` carries no `GroupId` member at all. - Validation runs the contracts' own annotations (`ContractValidation.Validate` against `CreatePlaceRequest`/`UpdatePlaceRequest`), confirmed rather than assumed. - Owner/Editor-only gating confirmed, including fail-closed when `CallerRole` is unset. - The 409 message matches the required wording and is correctly told apart from a 400. - `OnMapPicked`: as narrow as ADR-0064 claims. A `DotNetObjectReference` is only ever created and handed to `map.js` when `EnablePicking` is true, so a non-picking `LeafletMap` (home map, group overview) has no callable reference to reach at all. The method takes unvalidated primitives, but that's fine — the worst an unexpected caller could do is set a coordinate, which the visible fields already allow, still subject to the same downstream validation. - Build, the full suite (1187 tests, matching the PR body's own count) and `dotnet format --verify-no-changes` all pass clean under the pinned SDK (10.0.100). What bUnit cannot show, as the PR and ADR-0064 already say: a real click or drag reaching `OnMapPicked`, drag responsiveness, or anything about how the picker actually renders. Nothing here adds to that.
# Conflicts:
#	docs/adr/README.md
Fix stale version and empty-coordinate bugs in the place form
All checks were successful
CI / build (pull_request) Successful in 2m43s
29e3ad8a58
Author
Owner

Both findings fixed, each with a regression test confirmed failing against the pre-fix code before the fix landed:

  • Stale version: PlaceForm now tracks its own _version field (seeded from Place.Version once, updated from the API's response after every successful save) instead of reading Place.Version live at submit time. PlaceForm_EditedTwiceInARowWithNoInterveningReload_SendsTheVersionTheFirstSaveActuallyProduced.
  • Empty coordinate → 0: backing fields are now double?, not double. A cleared field parses to null (not the type's default), checked explicitly before either request type is built and reported as a required-field message, same shape as a blank name. PlaceForm_ClearingACoordinateField_DoesNotPlotOrCommitTheEquator / PlaceForm_SubmittedWithAClearedCoordinateField_ShowsARequiredMessageAndDoesNotCallTheApi.

Also merged main (PR #71) — the form now uses the shared ErrorNotification component rather than its own paragraph, matching GroupForm's updated shape.

Full CI sequence (build, test across the whole solution, dotnet format --verify-no-changes) run locally against the pinned SDK — 1207 tests pass.

Both findings fixed, each with a regression test confirmed failing against the pre-fix code before the fix landed: - **Stale version**: `PlaceForm` now tracks its own `_version` field (seeded from `Place.Version` once, updated from the API's response after every successful save) instead of reading `Place.Version` live at submit time. `PlaceForm_EditedTwiceInARowWithNoInterveningReload_SendsTheVersionTheFirstSaveActuallyProduced`. - **Empty coordinate → 0**: backing fields are now `double?`, not `double`. A cleared field parses to `null` (not the type's default), checked explicitly before either request type is built and reported as a required-field message, same shape as a blank name. `PlaceForm_ClearingACoordinateField_DoesNotPlotOrCommitTheEquator` / `PlaceForm_SubmittedWithAClearedCoordinateField_ShowsARequiredMessageAndDoesNotCallTheApi`. Also merged `main` (PR #71) — the form now uses the shared `ErrorNotification` component rather than its own paragraph, matching `GroupForm`'s updated shape. Full CI sequence (build, test across the whole solution, `dotnet format --verify-no-changes`) run locally against the pinned SDK — 1207 tests pass.
rob left a comment

Verdict: mergeable

Re-reviewed at 29e3ad8. Both prior findings are fixed and I verified the fixes myself rather than taking the summary on trust:

  • _version tracking. Confirmed: two consecutive edits with no intervening reload now send 42 then 43, and both succeed. I reverted PlaceForm.razor/.razor.cs alone to the pre-fix commit and re-ran PlaceForm_EditedTwiceInARowWithNoInterveningReload_SendsTheVersionTheFirstSaveActuallyProduced and the two nullable-coordinate tests against it — all three genuinely fail (stale version, silent 0, API called with a blank field) on the old code and pass on the new. The author's claim checks out.

  • The swap edge case. Confirmed by rendering PlaceForm against one place, then re-rendering the same instance with a different Place (different id, name, coordinates and version — no @key), then submitting: the request goes to the new place's route (Place.Id is read live) but carries the old place's name, coordinates and _versionOnInitialized seeds every field, _version included, exactly once, and nothing reacts to Place changing under it. This isn't a fresh regression — _name/_latitude/_longitude already had this property before this fix — but it's worth naming precisely because the fix changes _version's piece of it: previously a swap would have sent the new place's real version alongside the old place's stale content, which a matching version could let through as a silent overwrite of the wrong place with the wrong data; now _version is stale too, so the mismatch almost certainly 409s instead — a safer failure mode, landing on the wrong side of "fail loud" by accident rather than by anyone deciding so. Nothing hosts PlaceForm yet, so there's no way to confirm a future host won't reuse the instance across a selection change without re-keying it. Worth a line in the remarks stating the @key="Place?.Id" requirement explicitly (the same way every other deliberate constraint here is written down), so whoever writes the first host reads it before discovering it.

  • Nullable coordinates. 0, 0.0 and -0 all parse as real values, plot a marker, show no field error, and submit correctly (-0 serialises as -0 in the JSON body, which is correct IEEE-754 behaviour, not a bug). An empty field is refused with a required message and never reaches the API. Matches the brief exactly.

  • The main merge. PlaceForm now renders one ErrorNotification for its general/conflict error, matching GroupForm's shape exactly — its own .form-error paragraph is gone, and the remaining .form-error markup is only the pre-form Owner/Editor gating message, the same non-dismissible carve-out ADR-0063 documents for GroupForm's rename gate. No duplicate surface. Full suite (1207, matching the PR's own count), build and dotnet format --verify-no-changes all pass clean under the pinned SDK; no conflict markers or other stray changes anywhere in the merge.

Verdict: mergeable Re-reviewed at `29e3ad8`. Both prior findings are fixed and I verified the fixes myself rather than taking the summary on trust: - **`_version` tracking.** Confirmed: two consecutive edits with no intervening reload now send `42` then `43`, and both succeed. I reverted `PlaceForm.razor`/`.razor.cs` alone to the pre-fix commit and re-ran `PlaceForm_EditedTwiceInARowWithNoInterveningReload_SendsTheVersionTheFirstSaveActuallyProduced` and the two nullable-coordinate tests against it — all three genuinely fail (stale version, silent `0`, API called with a blank field) on the old code and pass on the new. The author's claim checks out. - **The swap edge case.** Confirmed by rendering `PlaceForm` against one place, then re-rendering the *same instance* with a different `Place` (different id, name, coordinates and version — no `@key`), then submitting: the request goes to the new place's route (`Place.Id` is read live) but carries the *old* place's name, coordinates and `_version` — `OnInitialized` seeds every field, `_version` included, exactly once, and nothing reacts to `Place` changing under it. This isn't a fresh regression — `_name`/`_latitude`/`_longitude` already had this property before this fix — but it's worth naming precisely because the fix changes `_version`'s piece of it: previously a swap would have sent the *new* place's real version alongside the *old* place's stale content, which a matching version could let through as a silent overwrite of the wrong place with the wrong data; now `_version` is stale too, so the mismatch almost certainly 409s instead — a safer failure mode, landing on the wrong side of "fail loud" by accident rather than by anyone deciding so. Nothing hosts `PlaceForm` yet, so there's no way to confirm a future host won't reuse the instance across a selection change without re-keying it. Worth a line in the remarks stating the `@key="Place?.Id"` requirement explicitly (the same way every other deliberate constraint here is written down), so whoever writes the first host reads it before discovering it. - **Nullable coordinates.** `0`, `0.0` and `-0` all parse as real values, plot a marker, show no field error, and submit correctly (`-0` serialises as `-0` in the JSON body, which is correct IEEE-754 behaviour, not a bug). An empty field is refused with a required message and never reaches the API. Matches the brief exactly. - **The `main` merge.** `PlaceForm` now renders one `ErrorNotification` for its general/conflict error, matching `GroupForm`'s shape exactly — its own `.form-error` paragraph is gone, and the remaining `.form-error` markup is only the pre-form Owner/Editor gating message, the same non-dismissible carve-out ADR-0063 documents for `GroupForm`'s rename gate. No duplicate surface. Full suite (1207, matching the PR's own count), build and `dotnet format --verify-no-changes` all pass clean under the pinned SDK; no conflict markers or other stray changes anywhere in the merge.
rob merged commit b0bb6f9f93 into main 2026-08-05 20:01:57 +00:00
rob deleted branch feat/place-form 2026-08-05 20:01:58 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
rob/PlaceMark!72
No description provided.