Skip to content

release: Fleet-Ops v0.6.66 - #321

Open
roncodes wants to merge 67 commits into
mainfrom
release/v0.6.66
Open

roncodes wants to merge 67 commits into
mainfrom
release/v0.6.66

Conversation

@roncodes

Copy link
Copy Markdown
Member

Summary

  • open the Fleet-Ops v0.6.66 release branch
  • synchronize the extension, frontend package, and API package versions
  • prepare release notes for inspections and the map tile fix

Planned release work

  • inspection forms built from typed field groups, with a form builder
  • one inspection sheet for the console, the read-only record and public links, usable on phones and tablets
  • the driver-facing v1 inspections API
  • inspection links assignable to any user, protected by a PIN, and sent by email or SMS
  • a fix for place, zone and service area details failing to render on the map tile layer
  • coordinated Postman collection and CI seed updates

#319 and #320 target this release branch for review before the release is merged to main. Companion PRs: fleetbase/fleetops-data#77, fleetbase/fleetbase#652 and fleetbase/postman#60.

Verification

  • validated the modified JSON manifests parse successfully
  • checked the release metadata diff for whitespace errors

roncodes and others added 30 commits September 9, 2026 19:03
The inspection platform (#267) shipped a public, tokenised link and the
console's internal CRUD, and nothing a driver's app could authenticate
against: a driver who inspects the same truck every morning had no way to
file a DVIR except a link minted for them each time.

This adds the consumable surface under v1:

  GET  /v1/inspection-forms              published forms; vehicle=, type=
  GET  /v1/inspection-forms/{id}         one published form
  POST /v1/inspections                   file against a form
  GET  /v1/inspections                   driver=, vehicle=, type=, result=, status=
  GET  /v1/inspections/{id}
  GET  /v1/vehicles/{id}/inspections

Every lookup is scoped to the API key's company, and an unpublished form
answers 404 rather than 403: to a driver a form that cannot be filled in
does not exist.

The submit body is exactly what the public link accepts, plus the form,
driver and vehicle the link already knew. Both doors now go through one
InspectionSubmitter, so they cannot drift on what a submission is. The app
queues a submit while offline and replays it; a replay carrying the same
Idempotency-Key answers with the submission the first attempt filed rather
than filing twice. The platform has no idempotency middleware, so the key is
kept on the submission's meta and looked up there.

Photos are validated as base64 or a URL by a small Rule rather than a regex:
a DVIR photo is easily a megabyte of base64, and a backtracking pattern over
that only fails on a real handset.

Coverage: the platform arrived with no tests, and the gate is 100% of
server/src. The models, the public and internal controllers, the resources,
the hub and report schema changes and the new API are covered against an
in-memory SQLite database, as the trailer lifecycle tests are. Four tests
on main asserted the old contract and are updated to the new one, kept
exact: the maintenance hub leads its KPIs with failed inspections and adds
an Inspections section and doc; a hub with no published form is told to
create one; the report schema registers work orders, maintenance history
and inspections; a closed work order records its maintenance as `completed`.

The migration is re-stamped to today, content unchanged.
…, and translate the sidebar

Both form components assigned to @resource in their constructors, which Glimmer
refuses once the template has read the attribute in the same render pass
("You attempted to update items ... already used in the same computation").
The model's items / item_results are now the single source of truth, read
through getters and replaced only from actions. Text fields update from the
DOM event rather than passing the stale rendered value, and every control
honours cannot-write. The sidebar items are translation keys, added to all
nine locales.
The first cut stored a form as a JSON list of pass/fail items. That is a
checklist. Fleetio parity — and the fliit module that already has it —
wants groups of typed fields, built from the platform's own custom-field
system, so an inspection is a form filled in rather than a list ticked.

A form's groups are platform categories owned by the form; its fields are
custom fields whose subject is the form, filed under
`fleetops_inspection_form` so a field the console's generic panel adds to
the form record is not mistaken for one a driver answers. Both come back
as `grouped_fields`, which is what the app codes against.

A submission answers through custom-field values. `inspection_item_results`
stays: issues, work orders and the history are built from it, so every
pass-fail value is mirrored into a result row keyed the way the app keys
it. "Not applicable" is carried through as its own status rather than
flattened into a pass. Photos and signatures arrive as base64 — the app is
offline-first and cannot upload first — and are stored as platform files,
leaving `file:<uuid>` as the value, which is the platform's convention.

The submission resource answers `custom_field_values` with each field's
identity beside its answer and every file reference resolved, plus the
`files` the inspection carries. The rules a field insists on when it fails
are checked before any photo is stored, so a refusal leaves nothing behind.

The tests' in-memory schema gains `categories`, the real `custom_fields`
columns and the file columns the projection reads.
The console writes a form's structure by posting the whole draft — the
builder lays a form out before the record exists, so the structure has to
arrive with the save that creates it. `InspectionFormController` takes it
under `inspection_form.field_groups` (and fliit's `draft`, so a form
authored there still saves) and prunes what the post no longer lists.

`InspectionSubmissionController` accepts `custom_field_values` on the same
terms as the driver API, so the console and the app write the same rows,
and still accepts `item_results` for a legacy checklist.

Two bugs found on the way. `Request::array()` is a core-api macro taking
exactly one argument, so every `$request->array($a, $request->array($b))`
in this controller silently discarded its fallback and the flat spelling
never worked; both reads now go through a helper that tries each key. And
a field written straight into the table by a seed carries no `component`,
so the resource names one from the type.

`InspectionExport` gives compliance the spreadsheet it asks for: one row
per submission, defects named, follow-up and out-of-service called out,
behind the usual `export` route. The migration folds a first cut's `items`
into a "Checklist" group of pass-fail fields, skipping any form already
built with fields so it can be run again safely.
Every new line, in the harness's own style: an in-memory database with the
platform's `categories`, `custom_fields` and `custom_field_values` beside
the inspection tables, and a real local filesystem so the file store can be
watched turning a driver's base64 into a platform file and the resource
resolving it back.

What is pinned here is the contract the app codes against: `grouped_fields`
with a field's id, type, options and meta; `custom_field_values` accepted
by uuid or by name; a pass-fail answer read whatever shape it arrives in,
"not applicable" kept as its own status; the comment and photo a field
insists on refusing the whole set before any file is written; a meter and a
boolean answered back as a number and a boolean rather than the strings the
value column stores.

One behaviour changed to match: a number value now leaves the resource as a
number. The `custom_field_values` guard in the submission resource went —
`withCustomFields()` has always loaded that relation, so it was dead.
The console side of the second cut. A form is built in `inspection-form/builder`:
field groups with a grid size, fields added, edited, reordered and deleted
through a FleetOps field editor that offers the inspection type list and,
for `pass-fail`, the On fail section the driver app reads — default severity,
whether a photo or a comment is required, whether the defect parks the truck,
and the instructions the driver sees.

A field is answered through `inspection-field/input`. `pass-fail`, `signature`
and the inspection flavour of `file-upload` are FleetOps' own, as are
`textarea`, `number` and `boolean` — the platform's type map has no component
for those three. Everything else is handed to the platform's `custom-field/input`.
The record gets Overview, Photos and Audit tabs; Photos is `ModelMultiFileUpload`
against `inspection_photo`, Audit the platform's `ActivityLog`.

The builder holds the structure as a draft of plain objects, so a form can be
laid out before the record exists and the whole thing is posted with the save.
It has to be posted separately: `inspection-form` and `inspection-submission`
live in `@fleetbase/fleetops-data` and declare no attribute for the structure
or the answers, so Ember Data drops both in both directions. Until that package
gains them, the structure and the answers ride their own request to the
internal endpoint, which is also the only spelling the server reads.

Glimmer discipline throughout: nothing writes to `@resource` during render,
text inputs update from the DOM event, and every control honours `cannot-write`.
Two things the console does that the driver app does not.

It uploads a photo the moment it is picked and keeps the reference the
upload answered with, which names the file by its public id — `file:file_…`,
not `file:<uuid>`. That reference was passed through untouched, so nothing
downstream could resolve it: the file was never claimed by the submission and
the resource handed the raw string back. A `file:` reference that does not
name a uuid is now looked up and rewritten to the one it does.

And it can clear a field. `custom_field_values.value` is a NOT NULL column,
so an answer that is nothing is not an answer — it deletes the row instead of
writing a null the column will not take.
Three components kick a load off in the next runloop; if the panel closes
first the task is performed on something already torn down. And the form the
answering screen assigns writes its own uuid into the column, never the id
the console addresses it by.
A severity that is not one of the four the field editor offers has no key,
and asking for one puts "Missing translation" on the record. Fall back to
the value itself.
`valueTypeFor` in the app's useInspections sends `datetime` for a
date-time field and `date` for a date; the console said `date` for both.
The server takes either, but the two clients should file the same row.
buildSubmission in the app's useInspections.ts emits both bodies at once,
every pass-fail answer carrying not_applicable and unsafe, photos and the
signature as bare base64. That exact shape now goes through the submitter in
a test, so a change here that the app would not survive fails here first.
Same as the others: the details panels kick their load off in the next
runloop, and closing the panel first would perform a task on a component
already torn down.
The tokenised link built its checklist from `items`, which a form built in
the new builder does not have — so the link showed an empty form and would
not submit. It now falls back to the pass/fail fields of `grouped_fields`,
keyed the way the server keys a derived result, so a link submission and an
app submission name the same item. The other field types are still not
offered there: a public link is a checklist for a contractor, not the app.
`InspectionFormSync` read every custom field subjected to the form, not
just the ones filed under the inspection kind. The builder posts the whole
form and prunes what the post no longer lists, so a custom field the
console's generic panel had added to the form *record* was deleted by the
next save of the structure. Both the read and the prune are now scoped.
The form's own details view asked for a translation of whatever severity a
field carries, which for a field converted from a hand-written first-cut
item can be anything. Same fallback as the record.
…d editor

Six things from the first real run of the builder:

- Settings rendered as a raw metadata editor, collapsed. They are three
  known switches — two the server reads when a submission has failures, one
  the driver app reads before it will submit — so they are checkboxes in an
  unlabelled input group, and the panel opens.
- Type and status showed smart-humanize output ("Dvir"). They now come from
  labelled option lists in fleet-ops-options, so DVIR reads as DVIR.
- Six inspection types were missing: pre-operational, post-operational,
  safety inspection, maintenance inspection, damage assessment and annual
  inspection.
- Every PowerSelect is wrapped in the fleetbase-model-select /
  fleetbase-power-select / ember-model-select div so it renders as the rest
  of the console does.
- The field editor opens as a right-side overlay over the form panel rather
  than a modal, sized xs so the form stays visible beside it. It holds the
  field in tracked state and writes through to the overlay's shared handle,
  which is what the builder reads back on save.
- Frequency is dropped from the form. The column and the API field stay, but
  nothing schedules an inspection from it, so the dropdown only asked the
  author a question the product does not act on.
…sing focus

Three from the second run:

- Collapsing the Form Builder panel emptied the form. ContentPanel unrenders
  its body when closed, so the builder component was destroyed and the draft
  went with it; reopening rebuilt it from nothing. The draft now lives on the
  controller, which the collapse does not touch, and the builder renders
  @groups and reports changes through @onchange. The load is skipped when the
  controller already holds a structure, so reopening cannot overwrite unsaved
  edits with server state either.
- Typing in a group's name or description lost focus after each keystroke.
  The iteration was unkeyed, so replacing the edited group object changed its
  identity and Glimmer rebuilt the input. Both loops are keyed on uuid, which
  every group and field is guaranteed. The two inputs also take their value
  once on insert rather than re-binding it, so a mid-word edit cannot move the
  caret to the end.
- The settings checkbox description sat tight against its label; it now has
  mt-1.
The panel's Save was disabled for good. Without pojoResource the header
falls through to `cannot-write @resource`, and the overlay carries a plain
object rather than an Ember Data record, so the permission check had nothing
to judge and denies by default. The field is a POJO, which is exactly what
pojoResource is for.

Required and editable now stack with spacing rather than sitting in a row.
Sharing one row with four buttons truncated every label to a few characters
in a two- or three-column group — unreadable, and impossible to keep track of
while building a form. The name takes its own line, with the required mark
and the type pill beside it and the actions right-aligned underneath. The
pill no longer truncates; the label is the only thing that gives way.
Five things reported while testing inspection forms in the console.

Publish stayed in the header after a form was published, so the only
thing pressing it could do was report that the form was already
published. It now appears only while the form can be published, and as
`type="success"`. Generate Link moves the other way: it needs a
published form, so it appears at the same moment Publish leaves.

A field's machine name came from `dasherize`, which rewrites spaces and
underscores and leaves everything else alone — "Sidewall condition,
offside rear" became `sidewall-condition,-offside-rear`. The name is an
identifier: it travels as an item result's `item_key` and is what a
report groups on. It is now a real slug, with anything that is not a
letter or a digit acting as a separator.

`frequency` is gone. It was inherited metadata: a column, an attribute
and a filter that nothing scheduled an inspection from. It is removed
from the v1 resource, the model's fillable and filter params, the
report schema, the index column and query param, the details panel and
the create defaults. The database column is deliberately left in place
— the create migration has already run on live instances, and a dead
nullable column is cheaper than editing a migration mid-test. A later
migration can drop it.

Type rendered through `smart-humanize`, so a form of type `dvir` read
"Dvir". Option lists already carry the labels, so this adds a table cell
that reads them: `table/cell/fleet-ops-option` takes the list name from
the column's `optionsKey` and falls back to humanizing anything with no
matching option, so a retired value is still legible. The index Type
column and the details panel's type and status now use it.

The Created column's own bug is in fleetops-data, where the model hands
out a raw `Date` — fleetbase/fleetops-data#77 fixes that.
Three things reported while testing inspections in the console.

**The sheet.** An inspection form rendered as a bag of inputs: groups
were collapsible content panels, fields sat in a CSS grid, some had a
border and some did not, and failing a check grew that grid cell — which
stretched the row and left its neighbour floating in white space. It was
not a form anyone would want to work down.

It is now one sheet. Every field is a row of the same shape: what is
being checked on the left, the control on the right, a full-width
control under its label when it needs one. A failure opens its defect
block below its own row, so answering one line can never change the
shape of another. Groups are plain sections that are always open — a
panel that shuts invites an inspector not to read a line they have to
read — with the one number that matters in the header: what failed, or
what is still owed. A running total sits at the foot, and an unsafe-to-
operate answer is called out there. A failed row is marked down its own
edge so a long sheet can be scanned.

Pass / fail / not-applicable is one segmented control rather than three
loose buttons. Toggles get their own row like everything else. Number
fields have a placeholder and show their unit. All eleven field types
are rendered here now rather than five of them being handed to the
platform's `custom-field/input`, which brought its own label chrome and
made the sheet read as two interleaved forms.

One `inspection-sheet` component renders it everywhere — the console's
submission form, the read-only record, and the public link — so the
three cannot drift. The seeding and the `custom_field_values` payload
moved to `utils/inspection-answers` for the same reason.

Two modifiers replace `value={{...}}` on inputs that re-render on every
keystroke: `sync-value` writes a value in only while the field is not
being typed in, which is the focus bug from the form builder in another
guise, and `when-changed` runs something on change but not on insert.

**The public link opened a blank page.** The generated path was
`/inspection?…`, which matches the *authenticated* `console/:slug`
route. The public one is `/~/:slug`, a sibling of `console`, outside its
chrome and its auth gate — the same route ledger's invoice links use.
The path now carries the `~/`, and the page itself was rebuilt on the
shared sheet instead of the pass-fail-only checklist it had. Uploads are
off there: the file endpoint needs a session and a link does not have
one, so the row says where a photo can be added rather than offering a
button that would fail.

**A generated link existed only as a toast.** It went to the clipboard
and, once that was overwritten, there was no way to find out what had
been handed out, to whom, or whether it still worked. Links are now
listed — under the form's details and below the generate form — with the
URL to copy again, the vehicle and driver it was for, when it was made,
when it was last opened, whether it is active, expired, used or revoked,
and a way to revoke one.

That needs the link itself, which was stored only as a sha256. A new
migration adds an encrypted `token` column beside the hash; `token_hash`
is untouched and remains the unique index every public request resolves
through. A link is a capability URL — one published form, once, for one
vehicle, until it expires — not a credential, and being re-readable is
how share links behave. Links minted before this are listed without a
URL rather than pretending to have one.
Revoke always 404'd. The link resource sent `id` as `$this->id`, which
on a Fleetbase model is the table's auto-increment column, while the
revoke endpoint resolves a link by uuid or public id. So the list could
show links and never act on one. It sends the public id now, and the
lookup accepts a numeric id too so an already-open console still works.

The links list was also stacking a gray-900 fill inside a gray-800
panel, which reads as a hole punched in the page. In dark the sunken
surface is now the same gray-800, and separation comes from the border
under a section header and a shadow on the card. The rows are tighter:
they sit inside a panel that already pads.

Putting the record's own details into a bare section went too far. Only
the form's field groups needed to stop being collapsible panels — the
details are a resource form like every other one in the console, so
they are back in their content panel exactly as they were, keeping only
`sync-value` on the two number inputs so the caret stays put mid-edit.

The sheet itself now has an inset, so its section borders no longer land
on the overlay panel's own edge and read as a double rule.

How the field groups themselves should be laid out — whether the
builder's grid survives — is still open, and nothing here presumes an
answer.
Two faults, both between the page and its data.

The page asked for `fleet-ops/public/inspections/forms/{id}` and got a
404 whose body was Laravel's "There is nothing to see here" — no route
matched. FleetOps mounts its API at the application root:
`fleetops.api.routing.prefix` is null, which is why its consumable
routes are `/v1/...` and its internal ones `/int/v1/...` rather than
sitting under an engine name the way ledger's do. The public routes
follow it, so the namespace is `public`. Verified against a running
instance: the endpoint now answers 200 with the form, its four groups
and the link's driver, vehicle and expiry.

Behind that sat a second fault that would have shown an empty sheet on
a page that finally loaded. A form's structure arrives in two shapes:
`field_groups`, whose fields come in a sibling `fields` array joined on
`category_uuid`, and `grouped_fields`, which nests them. The normalizer
preferred `field_groups` whenever it was present — but a public payload
omits `category_uuid`, so the join matched nothing and every group came
back empty. It now takes whichever shape actually produced fields, and
falls back to `field_groups` when neither did, so a form whose groups
are laid out but still empty keeps them in the builder.

Checked against the live payload for all four shapes: public, internal,
groups-without-fields, and nothing at all.
The author's `grid_size` survives — for the fields that stay compact.
A field that needs room leaves the grid entirely and becomes a
full-width band under it: a pass-fail that has failed and now owes a
severity, a comment and photos, or a note, upload or signature that
never fitted a column. That is what stops one answer changing the shape
of another, and it needed no compromise on the authoring intent: after
promotion there is no neighbouring cell left to stretch.

A group header now carries one dot per field in answer order — filled
for a pass, red for a failure, grey for not-applicable, hollow for
unanswered, amber for a required answer still missing or a failure
still owing its comment or photo. The right of the header says what is
outstanding, or how the group is laid out when nothing is. A dot is
neutral rather than green for an answered field that is not a check: a
toggle that is off has not passed anything.

Pass, fail and not-applicable are one segmented control, and so is
severity, whose chosen grade fills red — it is a grade of failure, not
a neutral pick. Unsafe to operate is a bordered pill, because it is the
gravest thing on the sheet. Photos are 60x44 slots with a dashed slot
inviting the next one, and a single upload or signature shows the same
slot beside its label rather than a bare filename.

The foot is four tallies and, when something is wrong, a banner that
names the field instead of counting it — the unsafe failure with its
severity, and the first required answer still missing — each with a
jump that scrolls to that field and holds it for a moment. Jumping is
by id rather than a held element, because a promoted field moves
between the grid and its band as the answer changes.

Layout is measured against the sheet, not the window: it renders in a
600px overlay panel on a wide screen and full width on a phone, so a
viewport media query would collapse the columns in exactly the wrong
places. Container queries drop 3 and 4 columns to 2 below 660px and to
one below 420px.

The same sheet renders in all three places it is read: the console's
submission form, the read-only record, and the public link. The link
page's own details and sign-off sections were rebuilt in the same
language.

Checked by rendering the real LGV Daily Pre-Trip payload from a running
instance through the templates' own markup and stylesheet, in both
themes, against the concept.
`fieldMarker`, `isPromoted` and `ROOMY_FIELD_TYPES` were never written
to `utils/inspection-answers`. A scripted edit that was meant to insert
them matched nothing and reported nothing, so the group component and
`summarize` both called a function that did not exist. Every sheet threw
on render: the public link, and the console submission form the moment a
form was chosen.

They exist now, and three checks were run against the real module rather
than a restatement of its rules — which is what let this through, since
the render harness reimplemented the logic instead of importing it:

- every export the sheet imports resolves, exercised end to end on a live
  LGV Daily Pre-Trip payload;
- every `this.x` in all fourteen inspection templates resolves to a
  member of its component;
- every translation key the inspection code names exists.
Seven things reported against the sheet.

Promotion moved a field to the end of its group, so failing a check
re-sorted the group and the fields after it jumped up past it. A field
that needs room now stays exactly where it was authored and spans the
full width of the grid instead. It is still alone on its row, so it
still cannot stretch a neighbour, and nothing moves.

A failure's own comment box, photo slots and segmented controls kept the
neutral border while everything around them turned red. They take the
failure's edge colour now, so the block reads as one thing.

The sheet was forcing a height and a font size onto every `.form-input`,
`.form-select` and power-select trigger, which made an input on this
screen shorter and tighter than the same input anywhere else in
Fleetbase. That is gone; controls keep the console's own sizing, and the
segmented controls match a real control's height rather than setting
their own.

Groups sit further apart, and the group block has padding on all four
sides — the last group used to touch the divider above the tallies, and
the public link's details and sign-off panels had no bottom padding at
all.

Every field is stacked now, label above control, whatever the column
count. A one-column group used to put the label beside the control,
which read as two different forms interleaved.

The "2 col · 1 promoted" line is gone. It described the layout to
someone who can see the layout. The header keeps only what is
outstanding, and only when something is.

Checked by rendering the real LGV Daily Pre-Trip payload through the
sheet's markup and stylesheet in both themes, and by re-running the
three checks: every template reference, every local import and every
translation key resolves.
A failed pass-fail used to widen into a full-width band in place. That
still re-flowed the group every time a check failed. The failure now
keeps its cell, and its severity, unsafe flag, comment and photos open in
a flyout anchored to it. The sheet's layout depends on the form alone;
answering a field can never change it.

Notes, uploads and signatures keep their full-width band, because the
form decides their size. The read-only record keeps each defect inline,
where it is the most important thing on the page.

How the flyout behaves:

- Choosing Fail opens it, with no second click, and focuses the comment.
- A caret points at the Fail button, and the field is ringed with an
  outline, which takes no space, so opening it moves nothing either.
- It closes on Done, its close button, Escape, or a press outside it and
  its field — never on blur, because focus leaves for the native photo
  picker. Escape and Done return focus to the Fail button.
- Closing is always safe: every answer is saved as it is typed. So Done
  still closes when a required comment or photo is missing, and the chip
  it leaves says what is owed in amber instead of trapping the inspector.
- Only one is open at a time. Switching an accidental Fail back to Pass
  keeps the comment and photos.
- On a sheet narrower than 520px — the public link on a phone — the same
  content comes up as a bottom sheet with a backdrop, mounted in the root
  wormhole so it is pinned to the screen rather than to the sheet.

Placement is done here rather than with the platform's Floating, which
positions once and never follows, and FleetOps does not declare
floating-ui. The flyout lives in a layer inside the sheet, so it scrolls
with its field for free and needs no scroll listener. It goes below the
field unless only the space above can hold it: whatever hangs below can
always be scrolled to, but a panel pushed above the start of the sheet
could not be reached at all.

It is revealed once, by the least scroll that brings it fully into view
with a margin, or its title if it is taller than the view. The reveal is
worked out from layout after the opening animation finishes: measured
mid-animation it was clamped against a briefly shorter scroll area and
stopped exactly the animated 4px short. An invisible 8px spacer below
the flyout gives that margin room at the very end of the page, since an
absolutely placed box extends the scroll area but its margin does not.

A defects tray at the foot of the sheet lists every failure with its
severity, field and evidence, and each row reopens that flyout. It
replaces the separate unsafe banner.

Verified by loading the shipped modifier and stylesheet into a real
browser and checking placement, focus, scrolling with the field,
dismissal, teardown, both sides of the flip rule, the reveal and its
margin, a flyout taller than the view, and the end of the page; and by
re-running the template-reference, local-import and translation sweeps.
fix(map): invoke leaflet-tile-url instead of passing it by name
@codecov

codecov Bot commented Sep 11, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 100.00%. Comparing base (2980fb3) to head (60a9cff).

Additional details and impacted files
@@              Coverage Diff              @@
##                main      #321     +/-   ##
=============================================
  Coverage     100.00%   100.00%             
- Complexity     10274     10774    +500     
=============================================
  Files            545       565     +20     
  Lines          39684     41545   +1861     
=============================================
+ Hits           39684     41545   +1861     
Flag Coverage Δ
backend 100.00% <100.00%> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

A public link submission came back as a 302. Two faults:

The submitter's rules had no rule for an answer's value. validate()
returns only ruled keys, and once nested rules exist an array's other keys
are dropped, so every custom_field_values answer reached the submitter
empty. A failed check that carried its photo was refused as having none.
The v1 driver endpoint uses the same rules and lost answers the same way.
value and custom_field_uuid now have rules, and a test runs the rules
through Laravel's validator.

That refusal reached the page as a redirect: the fetch service sends
Accept: */*, and Laravel answers a failed validation on a non-JSON request
by redirecting back. The public routes now run behind ForceJsonResponse,
and the page asks for JSON as well.
…arkup

Filing an inspection from the console failed: "Column 'total_items'
cannot be null". The console's model serialises every attribute, and an
explicit null in an insert overrides the column default instead of falling
back to it. A saving hook now restores the defaults of the NOT NULL
columns (type, status, total_items, failed_items) when they arrive null.
The counts are worked out from the answers straight after, as before.

The inspection loading states use the standard markup: a centred
container, and a Spinner laid out as a row with its message.
Ember CI: stylelint wanted single-quoted font names and range media
queries, and a duplicate .inspection-slot--add block merged into the
first; prettier reflowed two sidebar menu items and one option string.

PHP CI: generating a link now writes the PIN through the encrypted cast,
and the inspection test harnesses had no encrypter bound, so link tests
errored with "Target class [encrypter] does not exist". Both harnesses
bind a reversible stand-in with the encrypt/decrypt methods Eloquent's
cast calls.

The public link test still submitted the first cut's flat item_results,
which a link has refused since the file-reference hardening; the refusal
aborts with an empty message, so it read as an unexplained error. It now
checks that refusal, then files through the form's fields, and checks the
typed name and PIN flag the submission records.

The submitter rules test checked the rules through Laravel's validator,
which this package does not install; it checks each answer key has a rule.

php-cs-fixer ordered imports and aligned the new rules.
…outes

The recorder's verb methods returned nothing, so the route file's
->middleware('throttle:20,1,inspection-upload') on the public upload route
failed with "Call to a member function middleware() on null" and both tests
errored. They now return a recorded route that takes middleware, and any
other chained call is accepted.

fleetbaseRoutes() dropped the callback where resources declare their extra
routes, so none of those were recorded. It now replays it inside the
resource's prefix, the way the platform macro does.

The test also checks the public inspection routes, the public group's JSON
and rate-limit middleware, the upload limit, and the link send-pin route.
The coverage gate failed on the new link code: the PIN sender, its email,
the JSON middleware, link assignment and send-pin, the public PIN checks
and the photo upload had no tests. New tests cover each of them:

- who a link's PIN goes to, why it cannot be sent, masking, the SMS text
  and the link's address
- email and SMS delivery, the organisation's sender ID, a refused text and
  failures in transit, which are reported and not thrown
- assigning a link, the PIN fields the console sees, refusals before
  anything is minted, an outsider refused, and sending again
- listing and revoking links, by public or numeric id, with their states
- a missing, wrong or right PIN, by header or field, and the lockout
- a photo stored under the type its bytes show, a disk that refuses it,
  and the per-link cap
- a single-use link taken by a concurrent submit, and a reusable one
- the public routes answering in JSON

Two changes to the code under test: the PIN sender resolves SmsService
from the container, so a provider can be swapped; and the upload no longer
re-saves the file's company, which File::create already sets from the
link, so that branch could never run.

The harness gains the helpers core would provide (report, event, the
humanize macro), fakes for mail, SMS and the exception handler, and a
container that answers environment() for Utils::consoleUrl().
The Postman contract's Submit an Inspection got a 302 from POST
/v1/inspections, which the runner followed to a port nothing listens on and
reported as ECONNREFUSED. A refused $request->validate() redirects any
request that did not ask for JSON, and the collection does not send
Accept: application/json.

The v1 inspection-form and inspection routes, a vehicle's inspection
history, and the console's inspection-form and submission routes now run
behind ForceJsonResponse, as the public link routes already do, so a
refusal is a 422 with its reasons. The route test checks each of them.
Filing an inspection with a failed item answered 500: issues.location is a
spatial column with no default, and createIssueFromFailures() left it out,
so MySQL refused the insert (1364, "field location does not have a default
value"). Found by the PHP SDK's live contract run in
fleetbase/fleetbase-php#15 against this branch.

The issue now takes where the failure was reported: the submission's own
coordinates, else the vehicle's last known position, else the driver's, and
an empty point when nothing is known. Read directly rather than through
Utils::getPointFromMixed(), which throws when it cannot resolve a point,
and every one of those sources is routinely empty.

No test caught it because the harnesses stubbed ST_GeomFromText to hand
back the WKT it was given, so a stored point could not be read again. The
stub now answers as MySQL does, a 4-byte SRID and the geometry's WKB, and
the tests cover an issue taking the submission's coordinates, the vehicle's,
and the empty point.
The submitter tests file a failed inspection, which now raises an issue
carrying a location, and this harness had no ST_GeomFromText: the insert
failed with "no such function". It stubs the same MySQL-shaped point the
model and controller harnesses do.
Five things the record got wrong, from a review of a filed submission.

Badges: ember-ui styles a badge by its status name, and `danger` is not one
of them, so every chip using it — a failed result, an unsafe defect, a
critical severity — rendered grey. They use statuses ember-ui knows, and
the engine adds the three it has no entry for: passed, submitted, unsafe.

Read-back answers kept no shape: a cell was a bare label with its value
loose beneath, while a note or upload drew a bordered card, so one row read
as a card and the next as stray text. A read-only cell now takes the same
card, sized to its own answer, and a failed one keeps the defect's red. An
empty answer says "Unanswered" rather than showing a dash beside a band
that says it in words.

The header carried five buttons and pushed the title out of the overlay.
Editing keeps its button; create issue, create work order, resolve and
delete moved behind the ellipsis, as the vehicle panel does.

Item results restated the pass/fail answers the sheet already shows, so it
is kept only for a submission filed before typed fields, which has nothing
else to read.

The title names the form that was filled in — "{form} Submission" — rather
than repeating the record's id, which the overview already shows.
A failed check rendered as a band, and a band spans the grid, so one answer
ran the full width of the record while its neighbours sat in the columns the
form was built with. A pass, meanwhile, was a plain grey card with a chip,
which read as a different kind of thing entirely.

Pass, fail and N/A are now one card shape, coloured by the answer: green for
a pass, red for a fail, neutral for N/A, with the state's chip beside the
label. A fail keeps everything it carried — severity, the unsafe flag, the
comment and the photos — stacked beneath the head, inside the column the
form gave it. Only a note, an upload or a signature still spans, which is
the room those actually need.

Pass tokens join the fail set in both themes, and the chip takes the
answer's colour rather than always being red.
The green a passed check sat on was darker than the red of the fail beside
it — #17231c against #241b1e — so a pass read as a near-black card rather
than a green one. The fill moves up a step to #1c2e22 and the border with
it, #1f3d2b to #2f5a3c, which keeps the same relationship to the fail card's
edge. Light mode is untouched.
Create Issue, Create Work Order and Resolve fired on click: the first
anyone knew of a new issue was a toast and an id. Each now opens a
preview naming the failed checks it will be built from, worst severity
first, how many are unsafe to operate, and when the work order falls
due — and refuses to submit when nothing failed, which the server would
otherwise only tell you after the request.

The follow-up panel showed the bare uuid of whatever was created. It
now shows the title, status and priority, and opens the issue or work
order when clicked.
A submission raises one issue and one work order — the server has always
enforced that, handing back what exists rather than making a second one.
The console did not know, so Create Issue stayed on the menu and every
further click reported success while creating nothing. Both are now
offered only while there is something to raise, and Resolve only while
the inspection is unresolved; what has been raised is linked from the
Follow Up panel instead.

Submit had the same shape in the table's row menu: on an already-filed
inspection it recounted totals, changed nothing and still said
"Inspection submitted", as often as you clicked it. It now asks first,
and appears only on drafts.

The form a submission was filled in from was a dead name; it opens the
form.
`custom_field_values` describes the table the answers happen to live in.
An integrator reading a DVIR should not have to know that: a form has
`fields`, so a submission has `answers` to them, and each answer names
its `field` rather than its `custom_field`.

`value_type` goes with it. It says how a value is stored, which is the
platform's business — the console still gets it, and `type` (the field's
own type) is what tells a consumer how to read the value.

Nothing breaks. `custom_field_values` is still accepted on submit and
still answered on the internal namespace, so the console and older app
builds carry on; error paths follow whichever spelling was sent. A field
can now be named by public id as well as by uuid or name, which is what
core-api#254 gives it — until that lands the public id falls back to the
uuid, which is what it already was.
The rename left the form and the submission disagreeing. A form handed
out a field's uuid as its id; a submission's answers named the field by
its public id. Once custom fields have one, a consumer would read a form,
submit against the id it gave them, read the submission back and find a
different value for the same field.

The form now answers the way its groups already did — the public id
outside, the uuid on the console's own namespace — so the id a driver
reads, the id a submit names a field by, and the id that comes back on
the answers are one id.

An item result carried `meta.custom_field_uuid`, which is the link back
to the field it mirrors and the console's business; outside, `item_key`
already names the field, so the uuid no longer goes out with it.
An answer's photos come back as something fetchable; the item results
derived from those same answers handed back the raw `file:<uuid>`
strings they are stored as — the last place the public body still showed
an internal identifier.

A flat `item_results` submit stores no file, and project() hands a value
it cannot resolve straight back, so those bodies are unchanged.
applyCustomFieldValues() gained a parameter naming the key the answers
arrived under, so a 422 points at the path the caller actually sent. It
defaulted to the new spelling, which was wrong: every caller that does
not pass it — the public link, the console, and the tests — is by
definition one that sent the old one, and each was suddenly told its
answers were refused at `answers.N.field`. The default is the old
spelling; only a submit that received `answers` asks for the new one.

The rule strings are asserted verbatim in one place, and gained the
third body.
Two things from reviewing the console.

The row menu's separator sat above four conditional actions and was not
conditional itself. On a resolved inspection that had already raised its
issue and work order, all four hid and the rule drew anyway — directly
beneath the one above Delete, as a double line with nothing between
them. It now appears only when something below it does.

A vehicle's overlay had no way to see what had been inspected on it: the
submissions existed, filed against that vehicle, reachable only from the
Inspections screen. It has an Inspections tab now, built like the
trailer's work orders tab — the record id, the form, the result and
status, how many checks failed, when it was filed, and a way in.

Filing is a driver's job, through the app or a public link, so nothing
is created from here; the tab is a history.

It queries `vehicle_uuid` rather than `vehicle`: the latter is neither a
column nor searchable on this model, so it is dropped without a word.
`needs_review` is offered in the status field and counted by the hub, so
a supervisor can set it — but neither ember-ui nor this engine had a
class for it, and a badge without one renders as an unstyled chip. The
vehicle's new Inspections tab shows status, which is what surfaced it;
the record panel and the index table had the same gap.

Amber, following the warning family ember-ui uses for "pending".
Three things from the screenshot review.

The vehicle's Inspections tab put six columns in a 580px overlay. The
form name wrapped onto three lines, the submitted date sat off the edge
behind a scrollbar, and the id — uppercased, in full — dominated a row
you could not otherwise read. It is one block per inspection now: the
form it was filled in from, what it came to, when, how many checks
failed, and the id small and out of the way. A draft has no result yet,
so that badge is no longer drawn as an empty coloured dot.

The link to the form had no styling at all — no CSS rule was ever
written for it — so it read as plain text with an oversized icon beside
it. It is a link now: blue, the icon small and spaced, the whole thing
one target.

And the records a submission names were dead text. `Resource::DetailLink`
renders a linked resource as its identity — photo, name, the line that
identifies it — and opens it; `Vehicle::DetailLink` and
`Driver::DetailLink` say what a vehicle and a driver are. They open the
context panel beside what you were reading, as the console does
elsewhere, and `@mode="route"` asks for the full transition instead.

Only the inspection submission uses them so far. Vehicle, driver and
work-order details all name records the same dead way and are the
obvious next callers.
Three of the vehicle's tabs were rendering a `<StatusBadge>` that does
not exist — not in this engine, not in ember-ui. The cell drew nothing
at all, so a work order's and a schedule's status were simply absent.
`<Badge>` is what every other table uses. In block form: self-closing
renders an empty chip when a record carries no status, which is the same
thing the inspection tab was caught doing.

Two of those tabs guarded their spinner on the results array rather than
the task that fills it. An array has no `isRunning`, so the guard was
always false: the spinner never appeared and the empty state showed
while the query was still running, telling you there was nothing here
before anything had been fetched.

Swept the engine for both: no `<StatusBadge>` usages remain, and all
nine spinner guards across the details tabs now name a real task.
The pills already do this job — photo, name, the line that identifies a
record, and a click — so a second family for the same thing was the
wrong call. `Resource::DetailLink` and its vehicle and driver wrappers
are gone; the submission's vehicle and driver are pills, opening the
context panel beside what you were reading.

Two bugs in `vehicle/pill` had to go first. `@this.resource=` is not an
argument Pill reads, so Pill received no resource at all: its name
fallback, its online dot and the record handed to `@onClick` were empty
— which this change depends on. The tooltip's first line invoked a path
with an argument, rendering nothing.

Pills carried no styling of their own and gave no sign they could be
clicked. They now respond on hover, and only when given something to do.

The form link was never blue: ember-ui styles `.field-info-container >
.field-value`, two classes to the one on `.inspection-record__link`, so
the panel's body colour won on specificity alone. Scoped to match rather
than forced with !important.
The vehicle overlay answers what happened to one truck; a form had no
way to answer what it had collected. Its panel now has a Submissions
tab beside the overview, listing what was filed against it, newest
first, and opening any of them.

Every row shares the form, so naming it on each would be dead weight:
the vehicle leads instead, with the driver, the time and any failure
count beside it. Same blocks as the vehicle's history — it is the same
object seen from the other side — so no new styling.

The tab list moved onto the controller. It was declared inline in the
template, which was fine for one tab and not for two.

It filters on `inspection_form_uuid`, which is fillable and so binds the
query; `inspection_form` alone would have been dropped in silence and
listed every submission in the company.
Add the driver-facing inspections API, and cover the inspection platform (#267 rebased)
The telematics device Type filter listed every option as "[object
Object]". It hands filter/multi-option a list of {label, value} objects
from fleetOpsOptions and never says which key to show, and multi-option
has no default — unlike filter/select, it prints the whole option. It
also sends the whole object as the filter value, so even had the options
read correctly, choosing one would not have filtered anything.

Four more columns had the same omission, each fed by the same kind of
option list: device Status beside Type (the shared device columns, so
the Devices index as well as the telematic Devices tab), sensor Type and
Status on a device's Sensors tab, and telematic Status on the
Telematics index. The Sensors screens elsewhere already named the keys.
A failed inspection raises an issue and a work order and names both, so
the inspection's own panel already links to what it raised. The other
direction did not exist: from the issue or the repair there was no way
to see the inspection that caused it, though the submission records the
link in `issue_uuid` and `work_order_uuid`.

The issue gains a Linked Inspections card beside its linked resources,
and the work order a field beside its linked schedule. Both appear only
when there is something to show, since most issues and work orders were
never raised from an inspection. They are looked up by those columns
rather than read from the record's meta, so an inspection attached by
hand shows up as well as one that raised it.

The inspection is shown as a pill, joining the family the console
already uses for linked records. It is built like the order pill — the
other resource with no photo — so it takes an icon where the others take
an image.

A failed lookup is logged rather than surfaced: it is supplementary, and
should not put an error in front of someone reading the issue itself.
…ptions

Label the device, sensor and telematic filter options
Show an issue or work order the inspection that raised it
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