Skip to content

refactor: extract record GIS feature hydration into a shared useRecordFeatures module - #2212

Draft
jayenashar wants to merge 3 commits into
mainfrom
feat/shared-record-features
Draft

refactor: extract record GIS feature hydration into a shared useRecordFeatures module#2212
jayenashar wants to merge 3 commits into
mainfrom
feat/shared-record-features

Conversation

@jayenashar

@jayenashar jayenashar commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

refactor: extract record GIS feature hydration into a shared useRecordFeatures module

Ticket

n/a

Description

Record GIS geometry extraction lived inline inside OverviewMap.tsx, so nothing else in the app could hydrate GeoJSON features from records. This moves it into its own module with tests, and OverviewMap consumes it. Behaviour is unchanged: the map renders exactly as before.

The map also had no e2e coverage, because TabPanel renders a tab's children only while that tab is selected and no spec ever opened it. This adds that coverage.

Proposed Changes

  • New app/src/gui/components/notebook/recordFeatures.ts: getGISFields, extractFeaturesFromRecord, and the useRecordFeatures React Query hook, which returns the query state plus the memoized dataEngine and gisFields so consumers share them.
  • New app/src/gui/components/notebook/recordFeatures.test.tsx: 8 tests.
  • Refactored app/src/gui/components/notebook/OverviewMap.tsx: consumes the hook. Rendering, styling, tap handling and popover code are untouched; props and mount contract are unchanged.
  • New e2e/test/specs/app/notebook-map.e2e.ts plus harness: api/notebooks/e2e-minimal.json gains an optional TakePoint field so the seeded notebook has geometry, and e2e/test/helpers/geolocation.ts stubs navigator.geolocation.getCurrentPosition (headless Chrome has no location provider, and this suite pins WebDriver Classic so browser.emulate is unavailable).

Three decisions in the new module are worth flagging for review:

  1. Extraction is notebook-wide, not per-form. A record's AVPs can hold any field it was ever saved with, and the designer's sectionMovedToForm / fieldMovedToSection can reparent a spatial field long after capture, leaving the AVP intact but unreachable from the record's own form. Scoping to the record's own form silently drops that geometry.
  2. A missing revision skips only its own record. getRevision sits inside the per-record guard, so one DocumentNotFoundError no longer rejects the batch and blanks the whole map. Any other revision-read failure is still rethrown, so a systemic fault surfaces as an error rather than as a misleadingly empty map.
  3. The hook returns four named props rather than spreading the query result. In React Query v5 the result is a proxy whose get trap marks props tracked, so spreading re-renders OverviewMap on isFetching / dataUpdatedAt / failureCount, none of which it reads, and touches the own enumerable promise key.

The React Query key prefix changes from overview-map-features to record-features. Nothing invalidates the old key, so the rename is inert.

How to Test

  • pnpm --filter @faims3/app compile, pnpm --filter @faims3/e2e typecheck, pnpm run format:check: clean
  • pnpm --filter @faims3/app test: 25 files / 120 tests pass, including the 8 new ones
  • CI's Headless Chromium suite runs the new app/notebook-map.e2e.ts
  • Manually: open a notebook with a Map or TakePoint field and records with geometry, then open the Map tab. Records plot coloured by form type, and tapping a pin opens the popover with HRID / form label / created-by and the View record button. A notebook with no GIS fields still shows "No GIS fields found in this project's form definition", and one with no geometry still shows "No records with location data found".

Additional Information

The e2e spec's load-bearing assertion is the absence of the "No records with location data found" empty state, which renders precisely when hydration yields zero features, so it fails if geometry stops being extracted. It waits for a terminal state before asserting, so a regression reports its cause instead of timing out. No basemap API key is needed, since OpenLayers builds its viewport whether or not a tile loads.

Checklist

  • I have confirmed all commits have been signed.
  • I have added JSDoc style comments to any new functions or classes.
  • I have added tests for new code if appropriate
  • Relevant documentation such as READMEs, guides, and class comments are updated.

🤖 Generated with Claude Code

@github-actions

github-actions Bot commented Jul 26, 2026

Copy link
Copy Markdown

E2E ✅ passed

Result ✅ Suite passed
Artifacts Download e2e-artifacts-30256668700.zip
Run View workflow run

After downloading and extracting the .zip file, open artifacts/index.html (failures listed first).

Updated for commit 091cccf

jayenashar added a commit that referenced this pull request Jul 26, 2026
…ord failures

Addresses the multi-model review findings on PR #2212.

Geometry extraction goes back to the notebook-wide field list, reverting
the per-form GIS field scoping. Per-form scoping silently dropped
geometry: a record's AVPs can hold any field it was ever saved with, and
the designer can move a spatial field to another form (or drop it from
every view) long after capture, leaving the AVP intact but unreachable
from the record's own form. This also removes a getFieldNamesForViewset
call that threw during render on a viewset with a dangling view
reference, and closes a query-key gap where the key tracked only the flat
field list while extraction read the per-form map. The cost is giving up
the skipped revision read for records of non-spatial forms; correctness
outranks that, and the old code already skipped absent AVPs, so the
saving was one getRevision per such record.

A revision that is missing (DocumentNotFoundError) now skips just its
record instead of rejecting the whole query. Any other revision-read
failure is rethrown, so a systemic fault still surfaces as an error
rather than as an empty map.

RecordFeatureProps drops the unused `conflicts` property (no reader in
either repo) while conflicts stays in the records signature, so a
conflicting head syncing in still refreshes the map.

The hook stops spreading the React Query result. Spreading touches every
key on v5's tracked-props proxy, including `promise`, which rejects the
observer's internal thenable when prefetch-in-render is off, and
re-renders on unrelated state such as isFetching and dataUpdatedAt. It
now returns only data/isLoading/isError/error alongside dataEngine and
gisFields.

Tests go from 3 to 8: a regression guard for the dropped-geometry case
that fails against the per-form implementation, the FeatureCollection
branch, malformed AVP values, missing-vs-systemic read failures, and the
disabled-query path.

Co-Authored-By: Claude <noreply@anthropic.com>
Signed-off-by: Jayen <jayen@jayenashar.org>
@jayenashar
jayenashar marked this pull request as draft July 27, 2026 02:24
jayenashar and others added 3 commits July 27, 2026 20:03
…cordFeatures module

Record geometry extraction lived inline in OverviewMap, so nothing else
could hydrate features from records. It is generally useful, so it moves
into app/src/gui/components/notebook/recordFeatures.ts (with unit tests)
exposing getGISFields, getGISFieldsByForm, extractFeaturesFromRecord and
the useRecordFeatures React Query hook. OverviewMap now consumes the
shared module and drops the inline copy.

One behaviour difference against the old inline code: each record's
geometry is hydrated through its OWN form's GIS fields (via
getGISFieldsByForm) rather than scanning every GIS field in the notebook.
That is identical for any record captured through its form, and it skips
database reads entirely for records of non-spatial forms. Feature
properties additionally carry `conflicts`, and the query key now tracks
it.

Co-Authored-By: Claude <noreply@anthropic.com>
Signed-off-by: Jayen <jayen@jayenashar.org>
…ord failures

Addresses the multi-model review findings on PR #2212.

Geometry extraction goes back to the notebook-wide field list, reverting
the per-form GIS field scoping. Per-form scoping silently dropped
geometry: a record's AVPs can hold any field it was ever saved with, and
the designer can move a spatial field to another form (or drop it from
every view) long after capture, leaving the AVP intact but unreachable
from the record's own form. This also removes a getFieldNamesForViewset
call that threw during render on a viewset with a dangling view
reference, and closes a query-key gap where the key tracked only the flat
field list while extraction read the per-form map. The cost is giving up
the skipped revision read for records of non-spatial forms; correctness
outranks that, and the old code already skipped absent AVPs, so the
saving was one getRevision per such record.

A revision that is missing (DocumentNotFoundError) now skips just its
record instead of rejecting the whole query. Any other revision-read
failure is rethrown, so a systemic fault still surfaces as an error
rather than as an empty map.

RecordFeatureProps drops the unused `conflicts` property (no reader in
either repo) while conflicts stays in the records signature, so a
conflicting head syncing in still refreshes the map.

The hook stops spreading the React Query result. Spreading touches every
key on v5's tracked-props proxy, including `promise`, which rejects the
observer's internal thenable when prefetch-in-render is off, and
re-renders on unrelated state such as isFetching and dataUpdatedAt. It
now returns only data/isLoading/isError/error alongside dataEngine and
gisFields.

Tests go from 3 to 8: a regression guard for the dropped-geometry case
that fails against the per-form implementation, the FeatureCollection
branch, malformed AVP values, missing-vs-systemic read failures, and the
disabled-query path.

Co-Authored-By: Claude <noreply@anthropic.com>
Signed-off-by: Jayen <jayen@jayenashar.org>
Nothing exercised the notebook overview map before. The notebook page
renders a tab's children only while that tab is selected, so the whole
record-geometry hydration path went untested.

The seeded e2e-minimal notebook gains an optional TakePoint field, plus a
geolocation stub helper: headless Chrome has no location provider, and
this suite runs WebDriver Classic so BiDi's browser.emulate is
unavailable. Capacitor's web plugin reads navigator.geolocation at call
time, so shadowing the method is enough. Page-object helpers capture a
point and open the map tab, and the new spec creates a record with a
point then asserts the map plots it.

The spec waits for a terminal state (map mounted, or an empty/error state
rendered) before asserting, so a hydration regression reports the actual
cause instead of timing out. It does not depend on basemap tiles, which
need an API key CI has no secret for; OpenLayers builds its viewport
regardless.

Co-Authored-By: Claude <noreply@anthropic.com>
Signed-off-by: Jayen <jayen@jayenashar.org>
@jayenashar
jayenashar force-pushed the feat/shared-record-features branch from 4811246 to 08a2cbb Compare July 27, 2026 10:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant