From 5f11c343466ddc5dd9c653da60b027522a0766fe Mon Sep 17 00:00:00 2001 From: Shiv Thakker Date: Mon, 28 Sep 2026 17:35:34 +0800 Subject: [PATCH 1/4] Support configured order lifecycles and order presentation profiles Opt-in via OrderConfig meta; orders whose config does not opt in keep the default dispatch lifecycle and presentation. Backend (meta.lifecycle): - OrderConfig helpers: initial/completed/canceled/terminal codes, allowsDispatch, strict transitions, canTransition over the flow graph - createRecord starts at the configured initial activity and never dispatches when dispatch is disabled - Tracking number initial status comes from the owner when provided - Order::cancel uses the config's canceled activity - updateActivity validates targets against the configured graph when strict_transitions is set; dispatch endpoints refuse non-dispatch configs Frontend: - order-presentation service: extensions register profiles (by meta.presentation_profile) that compose the order form/details, hide fields/actions and route edits to the profiled form - Kanban derives columns/labels from a configured lifecycle instead of injecting default dispatch columns - Activity flow editor roots at the configured initial activity Tests: OrderConfigLifecycleTest; existing suite unchanged (ReportSchemaContractsTest fails on main as well). Co-Authored-By: Claude Opus 5.5 --- .../order-config-manager/activity-flow.js | 46 +++++-- addon/components/order/details.hbs | 29 ++++ addon/components/order/details.js | 26 ++++ addon/components/order/form.hbs | 12 ++ addon/components/order/form.js | 14 ++ addon/components/order/form/details.hbs | 34 ++++- addon/components/order/form/details.js | 46 +++++++ addon/components/order/form/route.hbs | 6 +- addon/components/order/form/route.js | 11 ++ addon/components/order/kanban.js | 72 ++++++++++ addon/controllers/operations/orders/index.js | 7 +- .../operations/orders/index/details.js | 29 +++- addon/services/order-actions.js | 6 + addon/services/order-presentation.js | 125 +++++++++++++++++ addon/utils/normalize-order-config-flow.js | 7 +- app/services/order-presentation.js | 1 + .../Internal/v1/OrderController.php | 38 +++++- server/src/Models/Order.php | 38 +++++- server/src/Models/OrderConfig.php | 129 ++++++++++++++++++ server/src/Models/TrackingNumber.php | 12 +- server/tests/OrderConfigLifecycleTest.php | 120 ++++++++++++++++ 21 files changed, 773 insertions(+), 35 deletions(-) create mode 100644 addon/services/order-presentation.js create mode 100644 app/services/order-presentation.js create mode 100644 server/tests/OrderConfigLifecycleTest.php diff --git a/addon/components/order-config-manager/activity-flow.js b/addon/components/order-config-manager/activity-flow.js index 88c444cca..814a0fb79 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]); } @@ -823,16 +839,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 (!this.immutableActivities.includes(activity.get('code')) && this.abilities.can('fleet-ops update order-config')) { - tools = [removeButton, addButton]; + if (code === 'started' && canUpdate) { + tools = [addButton]; + } + + if (!this.immutableActivities.includes(code) && canUpdate) { + tools = [removeButton, addButton]; + } } const activityNodeView = activityNode.findView(this.paper); 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/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..c74403998 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); 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)}}