diff --git a/RELEASE.md b/RELEASE.md index 0c8854947..88f7e2c79 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -1,58 +1,32 @@ -> v0.6.70 ~ "Telematics data retention" +> v0.6.71 ~ "Extensible tables and panels, configured order lifecycles and order presentation profiles" --- -## What's New -- **Telematics Data settings.** A new Fleet-Ops → Settings → Telematics Data page lets each organization decide how long telemetry is kept: device events, raw event payloads, positions, processed and quarantined delivery envelopes, and sync run diagnostics. Administrators set system-wide defaults from the admin console (Fleet-Ops Config → Telematics Data). A value of 0 keeps rows forever. -- **Scheduled retention sweep.** `fleetops:prune-telematics-data` runs every fifteen minutes and applies each organization's policy in bounded batches, so a large backlog is drained gradually without stalling ingestion. Defaults: events 30 days, raw payloads stripped after 7 days, positions 90 days, processed deliveries 24 hours, quarantined deliveries 7 days, sync runs 7 days. -- **Storage usage and on-demand cleanup.** The settings page shows rows, oldest row and estimated size per table for your organization, and "Run cleanup now" queues an immediate retention run. +## Highlights ---- -## Fixes -- Device events no longer store the raw provider unit twice. `payload` keeps the raw unit; `meta` holds only the normalized block (speed, heading, odometer, ignition, fuel level, timing). New event rows are roughly half the size. -- Telemetry-driven saves (device, event, position, vehicle, sensors) no longer write to the activity log on every poll. Organizations that want them can enable "Log telemetry activity" in Telematics Data settings. Manual edits are logged as before. -- Delivery inbox and sync run retention moved from the drain command into the retention sweep; `fleetops:drain-telematic-inbox` now only recovers deliveries and finishes interrupted runs. - ---- -## Upgrade notes -- New indexes on `device_events (company_uuid, created_at)`, `positions (company_uuid, created_at)` and `telematic_sync_runs (telematic_uuid, updated_at)`; on very large tables run `php artisan migrate` in a maintenance window. -- Retention is enabled by default. Existing rows older than the defaults are removed over successive runs after upgrading; set the values to 0 before upgrading if you need to keep history indefinitely. -- Deleting rows frees space inside the database files, not on disk. Run `OPTIMIZE TABLE` off-peak to return space to the operating system. -- Run `php artisan fleetbase:create-permissions` to register the `telematics-settings` permission and the Telematics Settings Manager policy and role. +- **Resource view registries.** Extensions can add the following through `fleet-ops::table:` and `fleet-ops::details:`: + - columns, row actions, bulk actions and toolbar buttons on every top-level Fleet-Ops table; + - header buttons and "…" menu items on its details panels and side panels. ---- -## Testing -- Backend tests cover the retention policy layering and clamping, the prune command (per-company policies, compaction, inbox tables via connections, orphans, batch caps, dry runs, filters, locking), the settings and storage usage endpoints, the index migrations, the on-demand job, and the meta and activity-log changes to ingestion. -- Ember tests cover the settings route guard, the settings controller, and the admin defaults component. -> v0.6.70 ~ "Order reporting and a cleaner default dashboard" - ---- -## What's New -- **Report on orders, their items and tracking.** The report builder can answer questions like "Which products sold the most this month?" and "What did orders total this month?". - - Orders expose their ID and Internal ID, and relationships for tracking (tracking number, region, latest status), Order Config, Customer Vendor, Created By, Purchase Rate › Service Quote and Payload Return. - - `Payload` exposes its items with name, SKU, price, dimensions, metadata and destination. Select an item column for one row per item, or group by one to summarise per product. - - Summary columns count orders distinctly, so they stay correct when item rows are selected. Distance, duration and transaction amount have totals and averages. - - `meta` has no fixed shape, so no column assumes a key in it. Read keys with a computed column, for example `CAST(JSON_UNQUOTE(JSON_EXTRACT(payload.entities.meta, '$.quantity')) AS DECIMAL(15,2))`. - - Column labels drop the table name ("Type", not "Order Type"). Distance and duration are labelled in meters and seconds, and money columns are marked "(minor units)". -- **A cleaner default dashboard.** Fleet-Ops widgets declare their place on the default dashboard: Radar first, then Active Orders and Drivers Online, a full-width Live Fleet map, and Revenue Trend, Top Drivers and Maintenance side by side. -- **Live Fleet widget cards match the live map.** Clicking a driver or vehicle on the widget map shows the same card as **Operations › Live Map**. + Built-in items carry stable ids, so registered items can be placed before or after them. +- **Configured order lifecycles.** An order config can define its own lifecycle in `meta.lifecycle`: `initial`, `completed`, `canceled` and `terminal` activities, `dispatch: false` and `strict_transitions`. The internal API starts orders at the initial activity, refuses to dispatch when dispatch is off, accepts only the flow's transitions when strict, and cancels to the configured activity. Configs without a lifecycle behave as before. + - **Lifecycle tab** in Order Configuration, to view and edit a config's lifecycle. + - **The board** shows a configured lifecycle's activities as its columns. + - **The activity flow editor** roots at the configured initial activity, and handles flows with cycles. +- **Order presentation profiles.** An extension can give its order type its own order form sections, extra and hidden fields, details view sections and hidden actions, registered in `fleet-ops:order-presentation`. **Edit details** opens the profiled form. Other order types are unchanged. +- **The Order Configuration manager lists every config**, including configs created while the console is open. +- **An order's activity timeline follows status changes made on the server.** +- **Fix: the Activity Flow crashed on a flow with a cycle.** +- **Fix: order details now load the assigned vehicle with the order.** +- **Fix: the order form failed when its custom fields couldn't load.** +- **Fix: registered details tabs.** Opening a driver, vehicle or trailer side panel removed the route from tabs other extensions had registered, which broke them in the full details view. Side panels also left out the tabs they could render. +- **Fix: the sensor details view showed the tabs registered for places.** --- -## Fixes -- Custom field values on fuel reports and service areas are saved. They were dropped because the save hooks read the snake_case payload root, while the console sends `fuelReport` and `serviceArea`. -- The report schema no longer references 13 columns and join keys that don't exist: driver name, email and phone and the driver's vehicle, the vehicle's driver, and the fuel report cost, odometer and date. Transaction summaries sum `transaction.amount`. -- Every report table, and the item join, leaves soft-deleted rows out on core-api v1.6.64 and later. - ---- -## Dependencies -- Order items, JSON totals and soft deletes in reports need `fleetbase/core-api` v1.6.64. The schema still registers on older versions. -- The report builder and the default dashboard order ship in `@fleetbase/ember-ui` v0.4.4. - ---- -## Testing -- Every declared report column and join key was checked against the database schema, and order reports were run against real storefront orders on MySQL in strict mode. -- Tests cover the report schema at 100% line coverage, the Live Fleet map cards and the default dashboard order. +## Upgrading +Needs fleetbase/ember-core v0.3.25 and fleetbase/ember-ui v0.4.5. --- ## Need help? - [GitHub Discussions](https://github.com/fleetbase/fleetbase/discussions) - [Discord](https://discord.gg/HnTqQ6zAVn) +--- diff --git a/addon/components/order-config-manager.js b/addon/components/order-config-manager.js index 9d4fd0b23..7f5aad343 100644 --- a/addon/components/order-config-manager.js +++ b/addon/components/order-config-manager.js @@ -14,6 +14,7 @@ import applyContextComponentArguments from '@fleetbase/ember-core/utils/apply-co import OrderConfigManagerDetailsComponent from './order-config-manager/details'; import OrderConfigManagerCustomFieldsComponent from './order-config-manager/custom-fields'; import OrderConfigManagerActivityFlowComponent from './order-config-manager/activity-flow'; +import OrderConfigManagerLifecycleComponent from './order-config-manager/lifecycle'; import OrderConfigManagerEntitiesComponent from './order-config-manager/entities'; import findActiveTab from '../utils/find-active-tab'; @@ -45,6 +46,7 @@ export default class OrderConfigManagerComponent extends Component { this.universe._createMenuItem(this.intl.t('order-config-manager.tabs.details'), null, { icon: 'circle-info', component: OrderConfigManagerDetailsComponent }), this.universe._createMenuItem(this.intl.t('order-config-manager.tabs.custom-fields'), null, { icon: 'rectangle-list', component: OrderConfigManagerCustomFieldsComponent }), this.universe._createMenuItem(this.intl.t('order-config-manager.tabs.activity-flow'), null, { icon: 'diagram-project', component: OrderConfigManagerActivityFlowComponent }), + this.universe._createMenuItem(this.intl.t('order-config-manager.tabs.lifecycle'), null, { icon: 'arrows-spin', component: OrderConfigManagerLifecycleComponent }), this.universe._createMenuItem(this.intl.t('order-config-manager.tabs.entities'), null, { icon: 'boxes-packing', component: OrderConfigManagerEntitiesComponent }), ]; @@ -106,7 +108,9 @@ export default class OrderConfigManagerComponent extends Component { */ @task *loadOrderConfigs(options = {}) { try { - const configs = yield this.store.findAll('order-config').then(Array.from); + // Always fetch: `findAll` otherwise resolves with whatever subset of configs the store + // already holds (e.g. from an order's embedded config) and only refreshes in the background. + const configs = yield this.store.findAll('order-config', { reload: true }).then(Array.from); let currentConfig; if (isArray(configs) && configs.length > 0) { currentConfig = configs.find((c) => c.id === this.args.orderConfig); diff --git a/addon/components/order-config-manager/activity-flow.js b/addon/components/order-config-manager/activity-flow.js index 88c444cca..3ed847ca8 100644 --- a/addon/components/order-config-manager/activity-flow.js +++ b/addon/components/order-config-manager/activity-flow.js @@ -34,7 +34,17 @@ export default class OrderConfigManagerActivityFlowComponent extends Component { * An array of activity codes that should not be modified. * @type {Array.} */ - @tracked immutableActivities = ['created', 'dispatched', 'started']; + /** + * Activities that cannot be edited or removed. Configs with their own lifecycle + * (`meta.lifecycle`) are not bound to the default dispatch activities. + */ + get immutableActivities() { + return this.lifecycle ? [] : ['created', 'dispatched', 'started']; + } + + get lifecycle() { + return this.config?.meta?.lifecycle ?? null; + } /** * The configuration data for the activity flow. @@ -180,6 +190,12 @@ export default class OrderConfigManagerActivityFlowComponent extends Component { this.clearGraph({ onAfter: () => { this.flow = {}; + // A configured lifecycle resets to its saved flow, never to the default dispatch flow. + if (this.lifecycle && this.config?.flow && Object.keys(this.config.flow).length) { + this.deserializeFlow(this.config.flow); + return; + } + const defaultActivities = this.getDefaultActivities(); this.addActivityToGraph(defaultActivities); }, @@ -361,7 +377,7 @@ export default class OrderConfigManagerActivityFlowComponent extends Component { } }); - const rootCode = getOrderConfigFlowRootCode(deserializedFlow); + const rootCode = getOrderConfigFlowRootCode(deserializedFlow, this.lifecycle?.initial); if (rootCode && deserializedFlow[rootCode]) { this.addDeserializedActivityToGraph(deserializedFlow[rootCode]); } @@ -439,6 +455,10 @@ export default class OrderConfigManagerActivityFlowComponent extends Component { * and creates links from the parent to each child. It then calls itself to add any children of the current child activities. * Finally, it repositions all child activities to maintain visual consistency. * + * A flow is a graph, not a tree: an activity may be reachable from several parents, and a + * configured lifecycle can loop back (e.g. active -> extended -> active). An activity that is + * already on the graph only gets a link from this parent; it is never placed or walked twice. + * * @param {Object} parentActivity - The parent activity to which child activities will be added. * @param {Array} [childActivities=[]] - An array of child activity objects to be added. Defaults to an empty array. */ @@ -447,6 +467,11 @@ export default class OrderConfigManagerActivityFlowComponent extends Component { return; } childActivities.forEach((childActivityObject) => { + if (childActivityObject.get('node')) { + this.addLinkToGraph(parentActivity, childActivityObject); + return; + } + const childPositionals = this.getActivityPositioning(childActivityObject, parentActivity); const childActivity = this.createActivityNode(childActivityObject, childPositionals); childActivity.set('parentId', parentActivity.get('id')); @@ -823,16 +848,26 @@ export default class OrderConfigManagerActivityFlowComponent extends Component { const addButton = this.createAddActivityButton(activity, { x: width }); let tools = []; - if (activity.get('code') === 'created' || activity.get('code') === 'dispatched') { - tools = []; - } + const code = activity.get('code'); + const canUpdate = this.abilities.can('fleet-ops update order-config'); - if (activity.get('code') === 'started' && this.abilities.can('fleet-ops update order-config')) { - tools = [addButton]; - } + if (this.lifecycle) { + // The configured initial activity is the root: it can gain children but not be removed. + if (canUpdate) { + tools = code === this.lifecycle.initial ? [addButton] : [removeButton, addButton]; + } + } else { + if (code === 'created' || code === 'dispatched') { + tools = []; + } + + if (code === 'started' && canUpdate) { + tools = [addButton]; + } - if (!this.immutableActivities.includes(activity.get('code')) && this.abilities.can('fleet-ops update order-config')) { - tools = [removeButton, addButton]; + if (!this.immutableActivities.includes(code) && canUpdate) { + tools = [removeButton, addButton]; + } } const activityNodeView = activityNode.findView(this.paper); diff --git a/addon/components/order-config-manager/lifecycle.hbs b/addon/components/order-config-manager/lifecycle.hbs new file mode 100644 index 000000000..8799540a5 --- /dev/null +++ b/addon/components/order-config-manager/lifecycle.hbs @@ -0,0 +1,250 @@ +
+ +

{{t "order-config-manager.lifecycle.description"}}

+ +
+ +
+ + {{#if this.enabled}} +
+
+
{{t "order-config-manager.lifecycle.activities"}}
+ {{#if (and this.canEdit this.hasActivities)}} + + {{/if}} +
+

{{t "order-config-manager.lifecycle.activities-help"}}

+ + {{#if this.hasActivities}} +
+ {{#each this.activityRows as |activity|}} +
+ + + + +
{{activity.code}}{{#if activity.isInitial}} ({{t "order-config-manager.lifecycle.initial-short"}}){{/if}}
+
+ + + + +
+ + {{next.label}} + +
+
+
+ + {{#if this.canEdit}} +
+
+ {{/each}} +
+ {{else}} +
{{t "order-config-manager.lifecycle.no-activities"}}
+ {{/if}} + + {{#if this.canEdit}} +
+
{{t "order-config-manager.lifecycle.add-activity"}}
+
+ + + + + + + + + +
+ +
+
+ {{#if this.newActivityCodeTaken}} +
{{t "order-config-manager.lifecycle.problem-duplicate-code" code=this.newActivityCode}}
+ {{/if}} +
+ {{/if}} +
+ +
+ +
+ + {{activity.label}} + {{activity.code}} + +
+
+ + +
+ + {{activity.label}} + +
+
+ + +
+ + {{activity.label}} + {{activity.code}} + +
+
+ + +
+ + {{activity.label}} + {{activity.code}} + +
+
+ +
+ +
+ + +
+ + {{#if this.problems.length}} +
+
{{t "order-config-manager.lifecycle.problems-title"}}
+
    + {{#each this.problems as |problem|}}
  • {{problem}}
  • {{/each}} +
+
+ {{/if}} + {{else}} +

{{t "order-config-manager.lifecycle.default-lifecycle"}}

+ {{/if}} + + {{#if this.canEdit}} +
+
+ {{/if}} +
+ + {{#if this.saved}} + +
+
{{t "order-config-manager.lifecycle.summary-initial"}}
{{or + (get (this.option this.saved.initial) "label") + (t "order-config-manager.lifecycle.none") + }}
+
{{t "order-config-manager.lifecycle.summary-completed"}}
{{or + (get (this.option this.saved.completed) "label") + (t "order-config-manager.lifecycle.none") + }}
+
{{t "order-config-manager.lifecycle.summary-canceled"}}
{{or + (get (this.option this.saved.canceled) "label") + (t "order-config-manager.lifecycle.none") + }}
+
{{t "order-config-manager.lifecycle.summary-terminal"}}
{{#if this.saved.terminal.length}}{{#each + this.saved.terminal + as |code index| + }}{{if index ", "}}{{get (this.option code) "label"}}{{/each}}{{else}}{{t "order-config-manager.lifecycle.none"}}{{/if}}
+
{{t "order-config-manager.lifecycle.summary-dispatch"}}
{{if + this.saved.dispatch + (t "order-config-manager.lifecycle.yes") + (t "order-config-manager.lifecycle.no") + }}
+
{{t "order-config-manager.lifecycle.summary-strict"}}
{{if + this.saved.strict_transitions + (t "order-config-manager.lifecycle.yes") + (t "order-config-manager.lifecycle.no") + }}
+
+
+ {{/if}} + +
\ No newline at end of file diff --git a/addon/components/order-config-manager/lifecycle.js b/addon/components/order-config-manager/lifecycle.js new file mode 100644 index 000000000..6712e5899 --- /dev/null +++ b/addon/components/order-config-manager/lifecycle.js @@ -0,0 +1,358 @@ +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { inject as service } from '@ember/service'; +import { action } from '@ember/object'; +import { isArray } from '@ember/array'; +import { task } from 'ember-concurrency'; +import { underscore } from '@ember/string'; +import generateUUID from '@fleetbase/ember-core/utils/generate-uuid'; +import normalizeOrderConfigFlow, { getOrderConfigFlowRootCode } from '../../utils/normalize-order-config-flow'; + +/** Keys of `meta.lifecycle` understood by the server (see OrderConfig::lifecycle()). */ +const DEFAULTS = { initial: null, completed: null, canceled: null, terminal: [], dispatch: true, strict_transitions: false }; + +const NEW_ACTIVITY = { status: '', code: '', details: '', color: '#1f2937' }; + +/** + * Manages an order config's configured lifecycle (`meta.lifecycle`): the activity new orders start + * at, the activities that complete, cancel and end an order, whether orders may be dispatched and + * whether transitions must follow the flow. Without a configured lifecycle the default dispatch + * lifecycle applies. + * + * The activities themselves (the config's `flow`) can be defined here too, so a lifecycle can be + * built from scratch without the graph editor: add or remove activities, set their labels and the + * activities each one can move to. Flow and lifecycle are saved together. + * + * @extends Component + */ +export default class OrderConfigManagerLifecycleComponent extends Component { + @service modalsManager; + @service notifications; + @service abilities; + @service intl; + + /** The config being managed. */ + @tracked config; + + /** Working copy of `meta.lifecycle`, or null when the config has none. */ + @tracked lifecycle = null; + + /** Last saved lifecycle, to tell whether there is anything to save. */ + @tracked saved = null; + + /** Working copy of the config's flow, keyed by activity code, with next activities as codes. */ + @tracked flow = {}; + + /** Flow as last saved, serialized, to tell whether there is anything to save. */ + @tracked savedFlow = '{}'; + + /** The activity being added. */ + @tracked newActivity = { ...NEW_ACTIVITY }; + + constructor(owner, { config, configManagerContext }) { + super(...arguments); + this.changeConfig(config); + + configManagerContext.on('onConfigChanged', (newConfig) => { + this.changeConfig(newConfig); + }); + } + + get canEdit() { + return this.abilities.can('fleet-ops update order-config') && !this.config?.core_service; + } + + get enabled() { + return this.lifecycle !== null; + } + + /** Activities of the flow as select options. */ + get activities() { + return Object.values(this.flow).map((activity) => ({ code: activity.code, label: activity.status || activity.code })); + } + + /** Activities with their next activities resolved, for the editor. */ + get activityRows() { + return Object.values(this.flow).map((activity) => ({ + ...activity, + next: (activity.activities ?? []).map((code) => this.option(code)).filter(Boolean), + isInitial: this.lifecycle?.initial === activity.code, + })); + } + + get hasActivities() { + return Object.keys(this.flow).length > 0; + } + + get newActivityCode() { + return underscore((this.newActivity.code || this.newActivity.status || '').trim()); + } + + get canAddActivity() { + const code = this.newActivityCode; + return this.canEdit && this.newActivity.status.trim() !== '' && code !== '' && !this.flow[code]; + } + + get newActivityCodeTaken() { + const code = this.newActivityCode; + return code !== '' && Boolean(this.flow[code]); + } + + get selectedInitial() { + return this.option(this.lifecycle?.initial); + } + + get selectedCompleted() { + return this.option(this.lifecycle?.completed); + } + + get selectedCanceled() { + return this.option(this.lifecycle?.canceled); + } + + get selectedTerminal() { + return (this.lifecycle?.terminal ?? []).map((code) => this.option(code)).filter(Boolean); + } + + get isDirty() { + return JSON.stringify(this.lifecycle) !== JSON.stringify(this.saved) || this.serializeFlow() !== this.savedFlow; + } + + /** Problems that make the lifecycle unusable server-side. */ + get problems() { + if (!this.lifecycle) { + return []; + } + + const problems = []; + const codes = this.activities.map((activity) => activity.code); + if (codes.length === 0) { + problems.push(this.intl.t('order-config-manager.lifecycle.problem-no-activities')); + } + if (!this.lifecycle.initial) { + problems.push(this.intl.t('order-config-manager.lifecycle.problem-initial-required')); + } + + const referenced = [this.lifecycle.initial, this.lifecycle.completed, this.lifecycle.canceled, ...(this.lifecycle.terminal ?? [])].filter(Boolean); + for (const code of new Set(referenced)) { + if (!codes.includes(code)) { + problems.push(this.intl.t('order-config-manager.lifecycle.problem-missing-activity', { code })); + } + } + + return problems; + } + + get canSave() { + return this.canEdit && this.isDirty && this.problems.length === 0; + } + + /** Arrow function so templates can call it as a helper: (this.option code). */ + option = (code) => { + return code ? (this.activities.find((activity) => activity.code === code) ?? { code, label: code }) : null; + }; + + changeConfig(config) { + this.config = config; + this.saved = this.normalize(config?.meta?.lifecycle); + this.lifecycle = this.normalize(config?.meta?.lifecycle); + this.flow = this.readFlow(config?.flow); + this.savedFlow = this.serializeFlow(); + this.newActivity = { ...NEW_ACTIVITY }; + } + + /** The stored flow as plain activity objects with next activities as codes. */ + readFlow(flow) { + const normalized = normalizeOrderConfigFlow(flow ?? {}); + const result = {}; + Object.values(normalized).forEach((activity, index) => { + if (!activity || typeof activity !== 'object') { + return; + } + const code = activity.code ?? activity.key; + if (!code) { + return; + } + result[code] = { + ...activity, + code, + key: activity.key ?? code, + sequence: activity.sequence ?? index, + activities: (isArray(activity.activities) ? activity.activities : []).map((next) => (typeof next === 'string' ? next : (next?.code ?? next?.key))).filter(Boolean), + }; + }); + return result; + } + + serializeFlow() { + const serialized = {}; + Object.values(this.flow).forEach((activity) => { + // Graph-only state never belongs in the stored flow. + // eslint-disable-next-line no-unused-vars + const { node, id, parentId, _internalModel, ...stored } = activity; + serialized[activity.code] = { ...stored, activities: [...(activity.activities ?? [])] }; + }); + return JSON.stringify(serialized); + } + + @action setNewActivityField(field, event) { + this.newActivity = { ...this.newActivity, [field]: event.target.value }; + } + + @action addActivity() { + if (!this.canAddActivity) { + return; + } + + const code = this.newActivityCode; + const activity = { + key: code, + code, + status: this.newActivity.status.trim(), + details: this.newActivity.details.trim(), + color: this.newActivity.color || '#1f2937', + sequence: Object.keys(this.flow).length, + activities: [], + events: [], + logic: [], + actions: [], + entities: [], + complete: false, + require_pod: false, + pod_method: 'scan', + options: {}, + internalId: generateUUID(), + }; + + this.flow = { ...this.flow, [code]: activity }; + if (this.lifecycle && !this.lifecycle.initial) { + this.lifecycle = { ...this.lifecycle, initial: code }; + } + this.newActivity = { ...NEW_ACTIVITY }; + } + + @action removeActivity(code) { + const flow = { ...this.flow }; + delete flow[code]; + Object.keys(flow).forEach((key) => { + flow[key] = { ...flow[key], activities: flow[key].activities.filter((next) => next !== code) }; + }); + this.flow = flow; + + if (this.lifecycle) { + const without = (value) => (value === code ? null : value); + this.lifecycle = { + ...this.lifecycle, + initial: without(this.lifecycle.initial), + completed: without(this.lifecycle.completed), + canceled: without(this.lifecycle.canceled), + terminal: this.lifecycle.terminal.filter((item) => item !== code), + }; + } + } + + @action setActivityField(code, field, event) { + this.flow = { ...this.flow, [code]: { ...this.flow[code], [field]: event.target.value } }; + } + + @action setNextActivities(code, selected) { + this.flow = { ...this.flow, [code]: { ...this.flow[code], activities: (selected ?? []).map((activity) => activity.code) } }; + } + + /** Drops the default dispatch activities so a lifecycle can be designed from nothing. */ + @action startBlankFlow() { + this.modalsManager.confirm({ + title: this.intl.t('order-config-manager.lifecycle.blank-flow-title'), + body: this.intl.t('order-config-manager.lifecycle.blank-flow-body'), + acceptButtonText: this.intl.t('order-config-manager.lifecycle.blank-flow-confirm'), + confirm: (modal) => { + this.flow = {}; + if (this.lifecycle) { + this.lifecycle = { ...this.lifecycle, initial: null, completed: null, canceled: null, terminal: [] }; + } + modal.done(); + }, + }); + } + + normalize(lifecycle) { + if (!lifecycle || typeof lifecycle !== 'object' || Object.keys(lifecycle).length === 0) { + return null; + } + + return { + ...DEFAULTS, + ...lifecycle, + terminal: isArray(lifecycle.terminal) ? [...lifecycle.terminal] : [], + dispatch: !['false', '0', 0, false].includes(lifecycle.dispatch), + strict_transitions: Boolean(lifecycle.strict_transitions), + }; + } + + @action toggleEnabled(enabled) { + if (enabled) { + const flow = normalizeOrderConfigFlow(this.config?.flow ?? {}); + this.lifecycle = this.saved ? { ...this.saved, terminal: [...this.saved.terminal] } : { ...DEFAULTS, terminal: [], initial: getOrderConfigFlowRootCode(flow) ?? null }; + return; + } + + if (this.saved) { + this.modalsManager.confirm({ + title: this.intl.t('order-config-manager.lifecycle.remove-title'), + body: this.intl.t('order-config-manager.lifecycle.remove-body'), + acceptButtonText: this.intl.t('order-config-manager.lifecycle.remove-confirm'), + confirm: async (modal) => { + modal.startLoading(); + this.lifecycle = null; + await this.save.perform(); + modal.done(); + }, + decline: (modal) => { + modal.done(); + }, + }); + return; + } + + this.lifecycle = null; + } + + @action setActivity(key, activity) { + this.lifecycle = { ...this.lifecycle, [key]: activity?.code ?? null }; + } + + @action setTerminal(activities) { + this.lifecycle = { ...this.lifecycle, terminal: (activities ?? []).map((activity) => activity.code) }; + } + + @action setFlag(key, value) { + this.lifecycle = { ...this.lifecycle, [key]: Boolean(value) }; + } + + @task *save() { + const meta = { ...(this.config.meta ?? {}) }; + if (this.lifecycle) { + meta.lifecycle = { ...this.lifecycle }; + } else { + delete meta.lifecycle; + } + + try { + this.config.set('meta', meta); + this.config.set('flow', JSON.parse(this.serializeFlow())); + yield this.config.save(); + this.saved = this.normalize(this.lifecycle); + this.lifecycle = this.normalize(this.lifecycle); + this.savedFlow = this.serializeFlow(); + this.notifications.success(this.intl.t('order-config-manager.lifecycle.saved', { orderConfigName: this.config.name })); + if (typeof this.args.onConfigUpdated === 'function') { + this.args.onConfigUpdated(this.config); + } + } catch (error) { + this.config.rollbackAttributes(); + this.lifecycle = this.normalize(this.config.meta?.lifecycle); + this.flow = this.readFlow(this.config.flow); + this.notifications.serverError(error); + } + } +} diff --git a/addon/components/order/details.hbs b/addon/components/order/details.hbs index 1efdb1cbb..9e2ca09e4 100644 --- a/addon/components/order/details.hbs +++ b/addon/components/order/details.hbs @@ -44,6 +44,35 @@ ) ) }} + {{else if this.profile}} + {{#each this.profileSections key="key" as |section|}} + {{#if section.native}} + {{#let + (component + section.componentName + resource=@resource + onChange=section.onChange + isLoading=@isLoading + isLoadingActivity=@isLoadingActivity + reloadToken=this.effectiveProofReloadToken + ) + as |NativeSection| + }} + + {{/let}} + {{else}} + + + + {{/if}} + {{/each}} {{else}} diff --git a/addon/components/order/details.js b/addon/components/order/details.js index 4e6976747..279bc7837 100644 --- a/addon/components/order/details.js +++ b/addon/components/order/details.js @@ -1,14 +1,40 @@ import Component from '@glimmer/component'; import { action } from '@ember/object'; import { tracked } from '@glimmer/tracking'; +import { inject as service } from '@ember/service'; export default class OrderDetailsComponent extends Component { + @service orderPresentation; @tracked proofReloadToken = 0; get effectiveProofReloadToken() { return `${this.args.proofReloadToken ?? 0}:${this.proofReloadToken}`; } + /** The extension presentation profile applying to this order's config, if any. */ + get profile() { + return this.orderPresentation.profileFor(this.args.resource); + } + + /** Profile sections with the change handler each native section expects. */ + get profileSections() { + const handlers = { + activity: this.handleActivityChanged, + detail: this.args.onDetailsChanged, + 'custom-fields': this.args.onCustomFieldsChanged, + notes: this.args.onNotesChanged, + route: this.args.onRouteChanged, + payload: this.args.onPayloadChanged, + documents: this.args.onDocumentsChanged, + comments: this.args.onCommentsChanged, + metadata: this.args.onMetadataChanged, + }; + + return this.orderPresentation.renderableSectionsFor(this.profile, 'details').map((section) => { + return section.native ? { ...section, onChange: handlers[section.name] } : section; + }); + } + @action handleActivityChanged(optionsOrActivity) { if (typeof this.args.onActivityChanged === 'function') { this.args.onActivityChanged(optionsOrActivity?.activityCreated ?? optionsOrActivity); diff --git a/addon/components/order/details/activity.hbs b/addon/components/order/details/activity.hbs index 7c92c96d7..d5cabef07 100644 --- a/addon/components/order/details/activity.hbs +++ b/addon/components/order/details/activity.hbs @@ -5,9 +5,11 @@ @isLoading={{or @isLoadingActivity this.loadActivity.isRunning @isLoading}} @wrapperClass="bordered-top" > - {{#if (eq this.layout "timeline")}} - - {{else}} - - {{/if}} - +
+ {{#if (eq this.layout "timeline")}} + + {{else}} + + {{/if}} +
+ \ No newline at end of file diff --git a/addon/components/order/details/activity.js b/addon/components/order/details/activity.js index 418d190d6..9ed0f9418 100644 --- a/addon/components/order/details/activity.js +++ b/addon/components/order/details/activity.js @@ -1,6 +1,7 @@ import Component from '@glimmer/component'; import { tracked } from '@glimmer/tracking'; import { inject as service } from '@ember/service'; +import { action } from '@ember/object'; import { task } from 'ember-concurrency'; export default class OrderDetailsActivityComponent extends Component { @@ -64,6 +65,14 @@ export default class OrderDetailsActivityComponent extends Component { ]; } + /** + * A status change recorded outside this panel (a configured lifecycle advancing on save, an + * extension action, a realtime update) means new tracking statuses exist server-side. + */ + @action onStatusChanged() { + this.loadActivity.perform(); + } + @task *loadActivity() { if (!this.args.resource) { return; diff --git a/addon/components/order/form.hbs b/addon/components/order/form.hbs index 54f41171c..8fc2a1e56 100644 --- a/addon/components/order/form.hbs +++ b/addon/components/order/form.hbs @@ -16,6 +16,18 @@ RegistryYieldEnd=(component "registry-yield" registry="fleet-ops:component:order:form:end" order=@resource) ) }} + {{else if this.profile}} + {{#each this.profileSections key="key" as |section|}} + {{#if section.native}} + {{#let (component section.componentName resource=@resource hiddenFields=this.hiddenFields) as |NativeSection|}} + + {{/let}} + {{else}} + + + + {{/if}} + {{/each}} {{else}} diff --git a/addon/components/order/form.js b/addon/components/order/form.js index 3a3437f22..c2fbd0502 100644 --- a/addon/components/order/form.js +++ b/addon/components/order/form.js @@ -11,6 +11,7 @@ export default class OrderFormComponent extends Component { @service customFieldsRegistry; @service mapManager; @service currentUser; + @service orderPresentation; @tracked customFields; constructor() { @@ -18,6 +19,19 @@ export default class OrderFormComponent extends Component { this.orderConfigActions.loadAll.perform(); } + /** The extension presentation profile applying to this order's config, if any. */ + get profile() { + return this.orderPresentation.profileFor(this.args.resource); + } + + get profileSections() { + return this.orderPresentation.renderableSectionsFor(this.profile, 'form'); + } + + get hiddenFields() { + return this.orderPresentation.hiddenFieldsFor(this.args.resource); + } + @action selectFacilitator(model) { this.args.resource.set('facilitator', model); this.args.resource.set('driver', null); @@ -32,7 +46,13 @@ export default class OrderFormComponent extends Component { }); this.args.resource.payload.set('type', orderConfig.key); - this.customFields = yield this.customFieldsRegistry.loadSubjectCustomFields.perform(orderConfig); + // loadSubjectCustomFields rejects when the custom fields cannot be loaded (ember-core 0.3.25). + try { + this.customFields = yield this.customFieldsRegistry.loadSubjectCustomFields.perform(orderConfig); + } catch (err) { + this.customFields = undefined; + debug('Unable to load order custom fields: ' + err.message); + } } @task *selectDriver(driver) { diff --git a/addon/components/order/form/details.hbs b/addon/components/order/form/details.hbs index 32095e9c4..fec97dabe 100644 --- a/addon/components/order/form/details.hbs +++ b/addon/components/order/form/details.hbs @@ -9,7 +9,7 @@ @onChange={{perform this.selectOrderConfig}} @placeholder={{t "order.fields.order-type-placeholder"}} @triggerClass="form-select form-input" - @disabled={{cannot-write @resource}} + @disabled={{or (cannot-write @resource) this.orderTypeLocked}} as |orderConfig| > @@ -17,8 +17,19 @@ + {{#if this.profileDetailFields}} +
+ + + +
+ {{/if}} + + {{#if this.show.internalId}} + {{/if}} + {{#if this.show.schedule}} + {{/if}} + {{#if this.show.customer}} + {{/if}} + {{#if this.show.facilitator}} + {{/if}} - {{#if (and @resource.facilitator.isIntegratedVendor @resource.facilitator.service_types.length)}} + {{#if (and this.show.serviceType @resource.facilitator.isIntegratedVendor @resource.facilitator.service_types.length)}}