Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
44 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
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
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
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