Files
shopdb-flask/docs/adr/ADR-010-frontend-plugin-hooks.md
cproudlock e005d1846a docs: wiki staleness sweep (Fable-orchestrated Opus audit)
Audited all 40 docs/ against the live codebase; fixed factual staleness in 23,
14 were clean. Highlights (all verified against code):
- equipment -> machines (ADR-011 rename) in INSTALL/DEPLOY-WINDOWS-IIS,
  PLUGIN-GUIDE, GE-ENFORCE, ROADMAP.
- Versions refreshed: contract 0.10.0 -> 0.13.0, product 0.5.0 -> 0.7.0, plus
  plugin example core_version pins.
- Bundled set corrected to the current 13 (PLUGINS.md 7 -> 13 rows; DEPLOY
  eleven -> thirteen).
- Per-plugin Alembic chain workflow (ADR-008) replacing stale core-chain steps
  in PLUGIN-QUICKSTART / BACKUP-RESTORE; deploy adds plugin upgrade-all.
- Frontend plugin staging (ADR-010) replacing 'no frontend plugin system yet'
  in PLUGIN-GUIDE; view/route paths repointed to plugins/<name>/frontend/.
- Corrected file paths (MapView.vue, manifest_schema.json), CLI (shelf-list),
  API gating (GET /api/plugins is optional-jwt), WJF 15 -> 16 stages, and
  retired Collector/PC-Types settings pages (ADR-012).
- ge-enforce proposal marked ACCEPTED/built.
2026-07-19 12:54:53 -04:00

16 KiB

ADR-010: Frontend plugin hook contract

  • Status: ACCEPTED
  • Date: 2026-07-11
  • Accepted: 2026-07-11
  • Deciders: cproudlock
  • Supersedes: none

Context

The backend plugin contract is settled (ADR-002 surface, ADR-006 collector, ADR-008 migrations, ADR-009 route gating). The one undefined piece before 1.0 is the frontend: how a plugin contributes UI without hand-editing core Vue files. CONTRACT-STABILITY.md names this the single biggest churn item ("no server-side hook for asset-detail panels, map markers, or search-result rendering; a plugin that needs custom UI still hand-edits the Vue frontend").

The measuringtools plugin (bundled 2026-07-11, whose construction is narrated in docs/PLUGIN-GUIDE.md) is the fresh evidence. Integrating it needed exactly two hand edits to core frontend files:

  1. frontend/src/api/index.js - an api-client block appended after warrantyApi (PLUGIN-GUIDE.md section 9). The scaffolder now emits a paste-in snippet, but it is still a hand edit to a shared, churn-heavy file.
  2. frontend/src/views/settings/settingsNav.js - a card entry in the hardcoded settingsGroups catalog (the "Measuring Tools" group, ~line 51), consumed by SettingsLayout (left rail) and SettingsIndex (landing overview).

Everything else integrated with zero core edits, because a mechanism already existed for it:

Capability Existing mechanism
Views / routes Auto-discovery: frontend/src/router/index.js globs router/routes/*.js via import.meta.glob; a route starting settings/ auto-nests under the settings shell.
Disabled-plugin gating meta: { plugin: '<name>' } per ADR-009; router guard redirects when the backend plugin is off.
Sidebar / dashboard / reports Declarative backend hooks rendered by core: get_navigation_items, get_dashboard_widgets, get_reports on BasePlugin (shopdb/plugins/base.py), merged by shopdb/core/api/dashboard.py and shopdb/core/api/reports.py.
Map placement Data-driven off asset types + resolved positions (ADR-001); a typed, positioned asset appears on the map with its type color and no plugin-side map code.

Capabilities a plugin cannot have at all today - no hook exists, and adding one would mean forking a core view:

  1. Asset-detail extension panels. A warranty-coverage section on a PC or printer detail page, a calibration-status card on a measuring tool. Today WarrantyPanel.vue is composed in by hand-editing each detail view.
  2. Map marker / overlay contributions. A calibration-due badge on the shop-floor map (frontend/src/views/MapView.vue). The map draws type colors but has no plugin decoration path.
  3. Search-result rendering / routing for plugin asset types. Global search returns assets, but core hardcodes how each type renders and where its detail link points; a plugin asset type has no way to declare its icon or route.

There is also an unsolved distribution wrinkle from ADR-003: external plugins symlink into plugins/<name>/ backend-only, but any frontend file they carry must be physically copied into frontend/src/, because Vite compiles the tree at build time and cannot reach outside it.

Options considered

A. Runtime dynamic component registration

Plugins ship real Vue components that core loads and mounts at runtime into named extension points (an iconMap registration, a panel registry, a marker renderer registry). This is the richest model and the one ADR-009's "Future direction" sketched.

  • Pro: a plugin can render anything; no core generic-renderer ceiling.
  • Con: requires runtime module loading of plugin-authored code (dynamic import of built chunks), a versioned shared-component surface that becomes an accidental contract the moment it leaks, and it does nothing about the build-time-only reach of Vite for external plugins. It is the most code for the least near-term payoff. ADR-009 already priced this and deferred it.

B. Declarative data-only hooks rendered by core generic components

Plugins return plain dicts from new BasePlugin hooks; a generic core component renders them. This is the exact precedent already proven three times: get_navigation_items, get_dashboard_widgets, and get_reports (0.6.0) all return dicts that a core consumer endpoint merges and a core Vue component renders. No plugin ships frontend code.

  • Pro: additive, minor-bump changes under ADR-002; identical access pattern to the existing consumers (skip disabled, fail-loud in dev, isolate in prod); works untouched for external symlink-only plugins, because the data crosses the wire and core owns the renderer.
  • Con: bounded to what a generic renderer can draw. A panel that needs a bespoke chart or a custom-interaction map overlay does not fit.

C. Build-time file-convention discovery

Extend the import.meta.glob precedent from ./routes/*.js to a plugin-owned frontend tree (plugins/*/frontend/), so a plugin's real components (full list/detail/form views, its api-client module) live with the plugin and the build picks them up by convention. This is ADR-009 "Future direction" steps 1-2.

  • Pro: true self-containment for genuine components; the natural home for the api-client block (friction 1) and full views.
  • Con: significant build-system work (per-plugin Vite entry discovery, code-splitting, dev-server HMR across the tree) and it does not by itself solve the external-plugin wrinkle - a symlinked out-of-tree frontend/ is still outside Vite's compiled root. Only pays off once out-of-tree plugins with frontends are a real requirement.

Hybrid

The evidence splits cleanly. The five friction points fall into two buckets: surfaces where a generic renderer fed by plugin data is sufficient (2, 3, 4, 5), and surfaces where a real component is genuinely unavoidable (full views, and the api-client module in friction 1). Option B fits the first bucket exactly and is cheap and additive. Option C is the right long-term answer for the second but is expensive and, per ADR-009, gated on external-plugin demand. Option A buys nothing B does not, at the highest cost. The decision is B now, C deferred, A not pursued.

Decision

DECISION: adopt a hybrid. Add data-only declarative hooks (Option B) for the four presentation surfaces a generic core renderer can serve, and keep file-convention glob discovery (Option C) as the deferred mechanism for the residual cases where a real component is unavoidable. Do not pursue runtime component registration (Option A).

New hooks (Option B, data-only, additive)

Each is a new optional BasePlugin method returning a list of dicts, merged by a core consumer endpoint using the same pattern as get_reports (inject plugin name, skip disabled, re-raise in dev/test, log-and-isolate in prod), and rendered by a generic core component. Icon values are string keys mapped to Lucide components core-side, exactly like get_navigation_items.

get_settings_cards - resolves friction point 2 (settingsNav.js hand edit).

{
  'group':       'Measuring Tools',           # rail group title (created if new)
  'to':          '/settings/measuringtooltypes',
  'icon':        'ruler',                      # string key, mapped core-side
  'title':       'Measuring Tool Types',
  'description': 'Manage measuring-tool subtypes + map colors',
  'position':    22,                           # order within the group
}

Consumer: a new GET /api/settings/cards merges enabled plugins' cards into the core settingsGroups catalog; SettingsLayout and SettingsIndex read the merged catalog instead of the hardcoded JS array. The catalog's own core groups stay in settingsNav.js; plugin groups are appended.

get_asset_panels - resolves friction point 3 (asset-detail panels).

{
  'id':         'calibration',
  'title':      'Calibration',
  'assettypes': ['measuring_tool'],           # detail pages it appears on; ['*'] = all
  'endpoint':   '/api/measuringtools/{assetid}/calibration-panel',
  'render':     'keyvalue',                    # 'keyvalue' | 'table' | 'badge'
  'position':   20,
}

Consumer: GET /api/assets/{assetid}/panels returns the panels whose assettypes match that asset's type; a generic AssetPanel component on the detail page fetches each endpoint and renders it in the declared style. This covers the warranty-coverage and calibration-status cases. A panel that needs bespoke UI (a chart) is out of scope for the data-only hook and falls to the deferred component mechanism below - stated honestly, not hidden.

get_map_overlays - resolves friction point 4 (map marker/overlay badges).

{
  'id':       'calibration-due',
  'label':    'Calibration due',              # legend label
  'endpoint': '/api/measuringtools/map-overlay', # -> [{assetid, color, label}]
  'style':    'badge',                        # 'badge' | 'ring'
  'legend':   True,
}

Consumer: MapView.vue fetches enabled plugins' overlay endpoints and decorates the already-placed markers; legend entries append to the existing legend. The map stays data-driven; plugins add decoration data, not map code.

get_asset_presentation - resolves friction point 5 (search rendering / routing) and, as a bonus, removes the AppLayout.vue iconMap hand edit called out in PLUGIN-GUIDE.md section 9.

{
  'assettype': 'measuring_tool',              # AssetType.assettype key the plugin owns
  'icon':      'ruler',
  'label':     'Measuring Tool',
  'route':     '/measuringtools/{assetid}',   # detail-route pattern
}

Consumer: GET /api/assets/presentation returns the type-to-presentation map; global-search result rows and any asset cross-link use it to pick the icon and build the detail link, so core never hardcodes a plugin's route or icon.

Friction map

Friction point Mechanism Hook / change
1. api-client block in api/index.js C (deferred) plugin-owned frontend/ tree, glob-discovered; scaffolder snippet is the near-term mitigation
2. settingsNav.js card entry B (now) get_settings_cards
3. asset-detail panels B (now) get_asset_panels (bespoke panels -> C, deferred)
4. map markers / overlays B (now) get_map_overlays (bespoke overlays -> C, deferred)
5. search-result rendering / routing B (now) get_asset_presentation
views (full list/detail/form) C (deferred) already glob-discovered under core routes/*.js; long-term move to plugins/*/frontend/ per ADR-009

Deferred: file-convention frontend tree (Option C)

Extending import.meta.glob from ./routes/*.js to plugins/*/frontend/ (so views and the api-client module live with the plugin) is ADR-009 "Future direction" steps 1-2. It is deferred for the same reasons ADR-009 gave: it is build-system-heavy and the payoff only lands once out-of-tree plugins with their own frontends are a real requirement. Until then, friction 1 stays mitigated by the scaffolder snippet, and full views keep shipping in the core bundle and gated by ADR-009 meta.plugin.

This is also the honest limit on the external-plugin wrinkle. The four data-only hooks need zero frontend files from a plugin, so an external symlink-only (backend) plugin gets settings cards, detail panels, map overlays, and search presentation with no copy-into-frontend/src/ step at all. The wrinkle survives only for the residual component-backed cases, which is exactly the deferred Option C work; pushing external-repo frontend distribution to that later ADR matches ADR-003's posture of deferring out-of-tree packaging until two sites run their own plugins.

Contract-version impact (ADR-002)

Each of the four hooks is a new optional BasePlugin method - an additive change, so a minor bump per ADR-002, the same classification get_reports took at 0.6.0. Landing all four together is a single minor bump (proposed 0.7.0); landing them incrementally is one minor bump each. Adding the core consumer endpoints and the generic renderer components is core-internal and does not itself move __contract_version__. The deferred Option C introduces a separate, versioned frontend contract (ADR-009 step 4), tracked apart from the backend __contract_version__; it is not part of this proposal's bumps.

Consequences

Positive

  • Closes the CONTRACT-STABILITY.md "single biggest gap" for the four surfaces a generic renderer can serve, using the already-proven declarative pattern - low risk, low cost, additive-only.
  • External symlink-only plugins get four presentation surfaces with no copy-into-core step, shrinking (not yet eliminating) the ADR-003 wrinkle.
  • Removes three of the two-plus hand edits the exemplar needed (settingsNav card, plus the AppLayout iconMap edit from section 9), moving them to data.
  • Docs-drift guard forces documentation: tests/test_docs_contract.py fails if any new public BasePlugin hook is missing from docs/PLUGIN-HOOKS.md, so the hooks cannot ship undocumented.

Negative

  • Four more hooks and four more core consumer endpoints to maintain, each with the skip-disabled / fail-loud-in-dev / isolate-in-prod discipline.
  • The generic renderers set a ceiling: bespoke panels and overlays still have no home until Option C lands, so the contract is honest-but-partial, not total.
  • One extra request per surface on the pages that use it (a detail page fetches its panels; the map fetches overlays), on top of ADR-009's enabled-list fetch.

Neutral

  • Bundled plugins keep their current in-core views; migrating them to the hooks is opt-in and incremental (see Adoption plan), not a flag-day rewrite.
  • meta.plugin route gating (ADR-009) is unchanged and still gates the views; these hooks add presentation, not routing.
  • Contract tests (tests/test_plugin_contract.py) already assert every public hook is exercised; the new hooks slot into that harness.

Adoption plan

Prove the contract on bundled plugins before declaring it settled for sister sites.

  1. get_asset_panels first, via warranty. Warranty is asset-general and already composes WarrantyPanel.vue onto multiple detail pages by hand, with a clean per-asset endpoint behind it. Migrating it to a declarative get_asset_panels entry rendered by the generic AssetPanel is the lowest-risk proof and immediately removes hand edits from every detail view that shows warranty. This is the recommended first migration.
  2. get_settings_cards, get_map_overlays, get_asset_presentation via measuringtools. The guide exemplar already needs a settings card, a calibration-due map badge, and search routing for its measuring_tool type, so it exercises all three at once and its PLUGIN-GUIDE.md walkthrough becomes the reference for the new hooks.
  3. Only after both plugins run on the hooks in a real build: bump __contract_version__ to 0.7.0, document the hooks in docs/PLUGIN-HOOKS.md, and mark this ADR ACCEPTED.

Open questions

  • Should get_asset_panels render styles stay a small closed set (keyvalue / table / badge), or grow? A closed set keeps the renderer generic; growth pressure is the signal that a case actually needs Option C.
  • Should the four consumer endpoints collapse into one bundled GET /api/plugins/frontend-contributions call to save round-trips, or stay separate per surface for cache locality? Defer until the per-surface request cost is measured.
  • When Option C lands, does the api-client module move under plugins/*/frontend/ or get replaced entirely by a generated client from the backend blueprint? Out of scope here; belongs to the ADR-009 frontend-contract follow-up.

References

  • ADR-001 (asset model as the map/search data source)
  • ADR-002 (bump classification for the new hooks)
  • ADR-003 (external-plugin distribution posture)
  • ADR-009 (route gating; "Future direction" is the deferred Option C)
  • docs/PLUGIN-GUIDE.md sections 9-10 (the measuringtools frontend hand edits)
  • docs/CONTRACT-STABILITY.md (the expected-churn line this ADR answers)
  • shopdb/plugins/base.py (existing declarative hooks this pattern extends)
  • shopdb/core/api/dashboard.py, shopdb/core/api/reports.py (consumer precedent)