Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
63 commits
Select commit Hold shift + click to select a range
14d80d1
Add inspection maintenance platform foundation
roncodes Jun 17, 2026
c06c470
Use shared inspection data models
roncodes Jun 17, 2026
ccc1afc
Add public inspection runner
roncodes Jun 17, 2026
1fd8cf0
Add the driver-facing inspections API, and cover the inspection platform
roncodes Sep 9, 2026
5e98475
console: stop the inspection forms writing to the model during render…
roncodes Sep 10, 2026
73bfed6
Model an inspection form as groups of typed fields
roncodes Sep 10, 2026
94073a8
Answer, export and migrate inspections built from fields
roncodes Sep 10, 2026
a29b250
Cover the second cut: the form, the answers, the files
roncodes Sep 10, 2026
b5efd42
Build the form, answer it, and show what came back
roncodes Sep 10, 2026
f8c569f
Take the console's file references and its cleared answers
roncodes Sep 10, 2026
1addf1f
Do not reach for a destroyed component, or a form's console id
roncodes Sep 10, 2026
7fac84c
Do not ask for a translation of a severity nobody wrote
roncodes Sep 10, 2026
df591b2
Say datetime where the app says datetime
roncodes Sep 10, 2026
fe6e5f9
Pin the whole body the app sends
roncodes Sep 10, 2026
8d76d62
Guard the two remaining deferred loads
roncodes Sep 10, 2026
f63667c
Let the public link run a form built from fields
roncodes Sep 10, 2026
3e42dbd
Do not let the builder delete a field it never owned
roncodes Sep 10, 2026
0f76aa7
Guard the other severity label the same way
roncodes Sep 10, 2026
4599c82
console: fix the inspection form's settings, labels, selects and fiel…
roncodes Sep 10, 2026
cc7fc51
console: keep the builder's draft alive, and stop the group inputs lo…
roncodes Sep 10, 2026
85b1464
console: let the field editor save, and stack its two toggles
roncodes Sep 10, 2026
8a7638c
console: let a field's name read, with its actions below
roncodes Sep 10, 2026
f8370d4
console: retire frequency, and show option labels rather than values
roncodes Sep 10, 2026
8f84da0
Make an inspection read as a checklist, and keep its links
roncodes Sep 10, 2026
6cb9132
Let a link be revoked, and put the record details back in a panel
roncodes Sep 10, 2026
8253510
Point the public inspection page at the routes that exist
roncodes Sep 10, 2026
71386b5
Build the inspection sheet as concept 1c, "Promotion"
roncodes Sep 10, 2026
9fa0d8b
Add the three exports the sheet was already importing
roncodes Sep 10, 2026
a64a66e
Span a promoted field in place, and stop restyling the console's inputs
roncodes Sep 10, 2026
f20d63f
Open a failed check's detail in a flyout, so no answer moves the sheet
roncodes Sep 11, 2026
5026ecf
Finish the failure style, refresh link lists live, use the attribution
roncodes Sep 11, 2026
fba594b
Let a public link take photos, and close four gaps around it
roncodes Sep 11, 2026
bd77410
Name uploaded link files by their detected type, not the device's name
roncodes Sep 11, 2026
3313a50
Inspection links for anyone, behind a PIN; record who submitted
roncodes Sep 11, 2026
b5ca443
Fix the public PIN screen and delivery choice; send the link with the…
roncodes Sep 11, 2026
4c00a1a
Drop the horizontal padding around the form's public links
roncodes Sep 11, 2026
787ddfc
fix(map): invoke leaflet-tile-url instead of passing it by name
roncodes Sep 11, 2026
e124fd8
chore: open release v0.6.66
roncodes Sep 11, 2026
405b59a
Merge pull request #320 from fleetbase/fix/leaflet-tile-url-invocation
roncodes Sep 11, 2026
24f67fc
Keep inspection answers through validation; answer public routes in JSON
roncodes Sep 11, 2026
9b2c6e0
Accept a console submission sent with null counts; standard spinner m…
roncodes Sep 11, 2026
7984789
Fix CI: lint errors, an encrypter in the test harness, and a stale test
roncodes Sep 11, 2026
5eb1f88
Fix the route registration test: chain onto routes, replay resource r…
roncodes Sep 11, 2026
9a69b38
Cover the inspection link PIN, delivery, upload and JSON responses
roncodes Sep 11, 2026
0d11583
Answer every inspection route in JSON, not with a redirect
roncodes Sep 11, 2026
2e130a7
Give the issue a failed inspection raises a location
roncodes Sep 12, 2026
db671b6
Let the field tests store the issue location a failure raises
roncodes Sep 12, 2026
70b7589
Make the inspection record read cleanly
roncodes Sep 14, 2026
09d5ceb
Read a pass and a fail as the same card, in the form's columns
roncodes Sep 14, 2026
d3e2c87
Lift the dark-mode pass card a level
roncodes Sep 14, 2026
82f1808
Ask before an inspection raises anything, and link what it raised
roncodes Sep 14, 2026
fc4d3e9
Stop offering inspection actions that have already been taken
roncodes Sep 14, 2026
0e04f2c
Name inspection answers for what they are, not how they are stored
roncodes Sep 14, 2026
a138c70
Make a field's id mean the same thing everywhere
roncodes Sep 14, 2026
00ab48a
Hand back an item result's photos, not the references to them
roncodes Sep 14, 2026
e17ecea
Report a refusal against the body it was sent under
roncodes Sep 14, 2026
0f3305e
Give a vehicle its inspection history, and stop drawing an empty rule
roncodes Sep 14, 2026
9f2ffe9
Style the one inspection status that had no badge
roncodes Sep 14, 2026
b425b61
Make the inspection history readable, and link the records it names
roncodes Sep 14, 2026
7794fea
Render the vehicle tabs' status badges, and show their spinners
roncodes Sep 14, 2026
face0a6
Use the pills for linked records, and make the form link look like one
roncodes Sep 14, 2026
87742d3
Show a form what has been filed against it
roncodes Sep 14, 2026
60a9cff
Merge pull request #319 from fleetbase/feature/inspections-driver-api
roncodes Sep 14, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 11 additions & 6 deletions RELEASE.md
Original file line number Diff line number Diff line change
@@ -1,15 +1,20 @@
> v0.6.65 ~ "First-class trailer operations"
> v0.6.66 ~ "Inspections"

---
## What's New
- **Trailers become a first-class Fleet-Ops resource.** Create and manage trailers independently, attach them to vehicles, connect equipment and telematics devices, and review operational, maintenance, location, and towing details from dedicated Trailer screens.
- **Vehicle and equipment relationships are easier to understand.** Vehicle details expose attached trailers and equipment, while Trailer details show the current towing vehicle and installed equipment.
- **Trailer APIs support complete integrations.** Internal and public endpoints cover trailer lifecycle, filtering, towing connections, equipment, devices, telemetry, imports, and exports.
- **Inspection forms are built from typed fields.** A form is groups of fields laid out on a grid: pass/fail checks with severity and on-fail settings, text, numbers, selections, dates, photo uploads and signatures. Forms are built and published from a form builder in the console.
- **One inspection sheet everywhere.** The same sheet is used to fill in an inspection in the console, to read one back, and on a public link. A failed check opens its severity, unsafe flag, comment and photos in place, and a defects tray summarises what needs attention. It works on phones and tablets.
- **Drivers file inspections through the API.** The `v1` inspection endpoints list published forms, file an inspection against one, and read back submissions and a vehicle's history, for the Navigator app and other integrations.
- **Inspection links for anyone in the organisation.** A link can be assigned to any user, protected by a six-digit PIN, and emailed or texted to them. A failed inspection can raise an issue and open a work order, and every submission records who filed it.

---
## Fixes
- Place, zone and service area details, and the place and point map modals, no longer fail to render with "A resolved helper cannot be passed as a named argument".

---
## Testing
- Added frontend, backend, API-contract, attachment, telematics, spatial-data, permission, and serialization coverage for first-class trailers.
- Expanded the Fleetbase Postman collection and documentation alongside the Fleet-Ops implementation.
- Model and controller contract tests cover inspection forms, submissions, links and the PIN lockout.
- The Fleetbase Postman collection documents the `v1` inspection endpoints.

---
## Need help?
Expand Down
103 changes: 103 additions & 0 deletions addon/components/inspection-field/form.hbs
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
<div class="inspection-field-form space-y-3" ...attributes>
<InputGroup @name={{t "inspection.field.label"}}>
<input type="text" value={{this.field.label}} class="w-full form-input" placeholder={{t "inspection.field.label-placeholder"}} disabled={{this.isDisabled}} {{on "input" this.setLabel}} />
</InputGroup>

<InputGroup @name={{t "inspection.field.name"}} @helpText={{t "inspection.field.name-help"}}>
<input type="text" value={{this.field.name}} class="w-full form-input font-mono" placeholder={{t "inspection.field.name-placeholder"}} disabled={{this.isDisabled}} {{on "input" this.setName}} />
</InputGroup>

<InputGroup @name={{t "inspection.field.type"}}>
<select class="form-select w-full" disabled={{this.isDisabled}} {{on "change" this.setType}}>
{{#each this.fieldTypes as |fieldType|}}
<option value={{fieldType}} selected={{eq this.field.type fieldType}}>
{{t (concat "inspection.field-type." fieldType)}}
</option>
{{/each}}
</select>
</InputGroup>

<InputGroup @name={{t "inspection.field.description"}}>
<textarea class="w-full form-input" rows="2" placeholder={{t "inspection.field.description-placeholder"}} disabled={{this.isDisabled}} {{on "input" this.setDescription}}>{{this.field.description}}</textarea>
</InputGroup>

<InputGroup @name={{t "inspection.field.help-text"}}>
<input type="text" value={{this.field.help_text}} class="w-full form-input" placeholder={{t "inspection.field.help-text-placeholder"}} disabled={{this.isDisabled}} {{on "input" this.setHelpText}} />
</InputGroup>

<div class="flex flex-col space-y-3">
<Toggle @isToggled={{this.field.required}} @onToggle={{this.setRequired}} @label={{t "inspection.field.required"}} @disabled={{this.isDisabled}} />
<Toggle @isToggled={{this.field.editable}} @onToggle={{this.setEditable}} @label={{t "inspection.field.editable"}} @disabled={{this.isDisabled}} />
</div>

{{#if this.hasOptions}}
<InputGroup @name={{t "inspection.field.options"}}>
<div class="space-y-2">
{{#each this.options as |option index|}}
<div class="flex flex-row items-center space-x-2">
<input type="text" value={{option}} class="w-full form-input" disabled={{this.isDisabled}} {{on "input" (fn this.updateOption index)}} />
<Button @type="danger" @icon="trash" @size="xs" @disabled={{this.isDisabled}} @onClick={{fn this.removeOption index}} />
</div>
{{else}}
<div class="text-xs text-gray-500">{{t "inspection.field.no-options"}}</div>
{{/each}}
<div class="flex flex-row items-center space-x-2">
<input type="text" value={{this.newOption}} class="w-full form-input" placeholder={{t "inspection.field.option-placeholder"}} disabled={{this.isDisabled}} {{on "input" this.setNewOption}} />
<Button @icon="plus" @size="xs" @text={{t "inspection.field.add-option"}} @disabled={{this.isDisabled}} @onClick={{this.addOption}} />
</div>
</div>
</InputGroup>
{{/if}}

{{#if this.isNumber}}
<InputGroup @name={{t "inspection.field.unit"}} @helpText={{t "inspection.field.unit-help"}}>
<input type="text" value={{this.meta.unit}} class="w-full form-input" placeholder={{t "inspection.field.unit-placeholder"}} disabled={{this.isDisabled}} {{on "input" this.setUnit}} />
</InputGroup>
<Toggle @isToggled={{this.isOdometer}} @onToggle={{this.toggleOdometerRole}} @label={{t "inspection.field.odometer-role"}} @helpText={{t "inspection.field.odometer-role-help"}} @disabled={{this.isDisabled}} />
{{/if}}

{{#if this.isPassFail}}
<div class="rounded-lg border border-gray-200 dark:border-gray-700 p-3 space-y-3">
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500">{{t "inspection.field.on-fail"}}</div>
<div class="text-xs text-gray-500">{{t "inspection.field.on-fail-help"}}</div>

<InputGroup @name={{t "inspection.field.default-severity"}}>
<div class="fleetbase-model-select fleetbase-power-select ember-model-select">
<PowerSelect
@options={{this.severityOptions}}
@selected={{this.meta.severity}}
@onChange={{this.setSeverity}}
@triggerClass="form-select form-input"
@renderInPlace={{true}}
@disabled={{this.isDisabled}}
as |severity|
>
{{t (concat "inspection.severity." severity)}}
</PowerSelect>
</div>
</InputGroup>

<Toggle @isToggled={{this.meta.require_photo_on_fail}} @onToggle={{this.setRequirePhotoOnFail}} @label={{t "inspection.field.require-photo-on-fail"}} @disabled={{this.isDisabled}} />
<Toggle @isToggled={{this.meta.require_comment_on_fail}} @onToggle={{this.setRequireCommentOnFail}} @label={{t "inspection.field.require-comment-on-fail"}} @disabled={{this.isDisabled}} />
<Toggle @isToggled={{this.meta.unsafe_on_fail}} @onToggle={{this.setUnsafeOnFail}} @label={{t "inspection.field.unsafe-on-fail"}} @helpText={{t "inspection.field.unsafe-on-fail-help"}} @disabled={{this.isDisabled}} />

<InputGroup @name={{t "inspection.field.instructions"}}>
<textarea class="w-full form-input" rows="3" placeholder={{t "inspection.field.instructions-placeholder"}} disabled={{this.isDisabled}} {{on "input" this.setInstructions}}>{{this.meta.instructions}}</textarea>
</InputGroup>
</div>
{{/if}}

<InputGroup @name={{t "inspection.field.column-span"}}>
<div class="flex flex-row items-center space-x-2">
{{#each this.colSpanOptions as |size|}}
<Button
@type={{if (eq this.meta.colSpan size) "primary" "default"}}
@size="xs"
@text={{size}}
@disabled={{this.isDisabled}}
@onClick={{fn this.setColSpan size}}
/>
{{/each}}
</div>
</InputGroup>
</div>
211 changes: 211 additions & 0 deletions addon/components/inspection-field/form.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
import { INSPECTION_FIELD_TYPES, INSPECTION_SEVERITIES, componentForFieldType, isOptionFieldType } from '../../utils/inspection-field-types';

/**
* The editor for one inspection field.
*
* It is the platform's custom-field editor plus what an inspection needs: the
* eleven types an inspection form may be built from, and — only for
* `pass-fail` — the *On fail* rules the driver app enforces and the server
* re-checks (`InspectionSubmitter::normalizeValue()`).
*
* The field is a plain object owned by the builder's draft, not an Ember Data
* record: a form is laid out before the form record exists and the whole
* structure is written on the first save. Nothing here mutates `@field` — each
* change builds a new object and hands it to `@onChange`, so no write ever
* happens during render.
*/
export default class InspectionFieldFormComponent extends Component {
@tracked newOption = '';

fieldTypes = INSPECTION_FIELD_TYPES;
severityOptions = INSPECTION_SEVERITIES;
colSpanOptions = [1, 2, 3];

/**
* The field being edited, held locally so an edit re-renders.
*
* Invoked directly by the builder the field arrives as `@field`; rendered
* inside the resource context panel it arrives on the overlay
* definition's shared `state`, which is a plain object — mutating it
* would never re-render, so the component owns a tracked copy and writes
* through on every change. That shared handle is what the builder reads
* back when the author saves.
*/
@tracked localField = this.args.field ?? this.args.overlay?.state?.field ?? {};

get field() {
return this.localField;
}

get isDisabled() {
return this.args.disabled ?? this.args.overlay?.disabled ?? false;
}

get meta() {
const meta = this.field.meta;
return meta && typeof meta === 'object' ? meta : {};
}

get isPassFail() {
return this.field.type === 'pass-fail';
}

get isNumber() {
return this.field.type === 'number';
}

get hasOptions() {
return isOptionFieldType(this.field.type);
}

get options() {
return Array.isArray(this.field.options) ? this.field.options : [];
}

get isOdometer() {
return this.meta.role === 'odometer';
}

/** Every change to the field goes through here, and only from an action. */
change(attributes) {
const next = { ...this.field, ...attributes };
this.localField = next;

if (this.args.overlay?.state) {
this.args.overlay.state.field = next;
}

if (typeof this.args.onChange === 'function') {
this.args.onChange(next);
}
}

changeMeta(attributes) {
this.change({ meta: { ...this.meta, ...attributes } });
}

/**
* The name a label derives to. The label names the field; this machine
* name follows it until the author types one of their own.
*
* Not `dasherize`: it only rewrites spaces and underscores, so a label
* like "Sidewall condition, offside rear" kept its comma and produced
* `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 —
* so anything that is not a letter or a digit becomes a separator, and
* runs of separators collapse.
*/
slugify(value) {
return String(value ?? '')
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
}

@action setLabel(event) {
const label = event.target.value;
const derived = this.slugify(this.field.label);
const current = (this.field.name ?? '').trim();

// The name follows the label until an author types their own.
const follows = current === '' || current === derived;

this.change({
label,
name: follows ? this.slugify(label) : current,
});
}

@action setName(event) {
this.change({ name: this.slugify(event.target.value) });
}

@action setDescription(event) {
this.change({ description: event.target.value });
}

@action setHelpText(event) {
this.change({ help_text: event.target.value });
}

@action setType(event) {
const type = event.target.value;
const attributes = { type, component: componentForFieldType(type) };

// A field that has just become pass-fail needs the defaults its rules
// are read from; one that has stopped being pass-fail keeps its meta,
// because the author may be switching back.
if (type === 'pass-fail' && this.meta.severity === undefined) {
attributes.meta = { ...this.meta, severity: 'medium', require_photo_on_fail: false, require_comment_on_fail: false, unsafe_on_fail: false };
}

this.change(attributes);
}

@action setRequired(required) {
this.change({ required: Boolean(required) });
}

@action setEditable(editable) {
this.change({ editable: Boolean(editable) });
}

@action setColSpan(colSpan) {
this.changeMeta({ colSpan });
}

@action setUnit(event) {
this.changeMeta({ unit: event.target.value });
}

@action toggleOdometerRole(isOdometer) {
this.changeMeta({ role: isOdometer ? 'odometer' : null });
}

@action setSeverity(severity) {
this.changeMeta({ severity });
}

@action setRequirePhotoOnFail(value) {
this.changeMeta({ require_photo_on_fail: Boolean(value) });
}

@action setRequireCommentOnFail(value) {
this.changeMeta({ require_comment_on_fail: Boolean(value) });
}

@action setUnsafeOnFail(value) {
this.changeMeta({ unsafe_on_fail: Boolean(value) });
}

@action setInstructions(event) {
this.changeMeta({ instructions: event.target.value });
}

@action setNewOption(event) {
this.newOption = event.target.value;
}

@action addOption() {
const option = this.newOption.trim();
if (option === '') {
return;
}

this.newOption = '';
this.change({ options: [...this.options, option] });
}

@action updateOption(index, event) {
const value = event.target.value;
this.change({ options: this.options.map((option, optionIndex) => (optionIndex === index ? value : option)) });
}

@action removeOption(index) {
this.change({ options: this.options.filter((_, optionIndex) => optionIndex !== index) });
}
}
Loading
Loading