Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
6 changes: 5 additions & 1 deletion addon/components/order-config-manager.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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 }),
];

Expand Down Expand Up @@ -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);
Expand Down
175 changes: 175 additions & 0 deletions addon/components/order-config-manager/lifecycle.hbs
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
<div class="px-6 py-2 space-y-4">
<ContentPanel @title={{t "order-config-manager.lifecycle.title"}} @open={{true}} @wrapperClass="bordered-classic">
<p class="text-sm text-gray-600 dark:text-gray-300 mb-4">{{t "order-config-manager.lifecycle.description"}}</p>

<div class="mb-4">
<Toggle
@label={{t "order-config-manager.lifecycle.enable"}}
@helpText={{t "order-config-manager.lifecycle.enable-help"}}
@isToggled={{this.enabled}}
@disabled={{or (not this.canEdit) this.save.isRunning}}
@onToggle={{this.toggleEnabled}}
/>
</div>

{{#if this.enabled}}
<div class="grid grid-cols-1 lg:grid-cols-2 gap-x-6 gap-y-2">
<InputGroup @name={{t "order-config-manager.lifecycle.initial"}} @required={{true}} @helpText={{t "order-config-manager.lifecycle.initial-help"}}>
<div class="fleetbase-model-select fleetbase-power-select ember-model-select">
<PowerSelect
@options={{this.activities}}
@selected={{this.selectedInitial}}
@onChange={{fn this.setActivity "initial"}}
@searchEnabled={{true}}
@searchField="label"
@placeholder={{t "order-config-manager.lifecycle.select-activity"}}
@triggerClass="form-select form-input"
@disabled={{not this.canEdit}}
as |activity|
>
<span>{{activity.label}}</span>
<span class="ml-2 text-xs text-gray-500">{{activity.code}}</span>
</PowerSelect>
</div>
</InputGroup>

<InputGroup @name={{t "order-config-manager.lifecycle.terminal"}} @helpText={{t "order-config-manager.lifecycle.terminal-help"}}>
<div class="fleetbase-model-select fleetbase-power-select ember-model-select">
<PowerSelectMultiple
@options={{this.activities}}
@selected={{this.selectedTerminal}}
@onChange={{this.setTerminal}}
@searchEnabled={{true}}
@searchField="label"
@placeholder={{t "order-config-manager.lifecycle.select-activities"}}
@triggerClass="form-select form-input"
@disabled={{not this.canEdit}}
as |activity|
>
{{activity.label}}
</PowerSelectMultiple>
</div>
</InputGroup>

<InputGroup @name={{t "order-config-manager.lifecycle.completed"}} @helpText={{t "order-config-manager.lifecycle.completed-help"}}>
<div class="fleetbase-model-select fleetbase-power-select ember-model-select">
<PowerSelect
@options={{this.activities}}
@selected={{this.selectedCompleted}}
@onChange={{fn this.setActivity "completed"}}
@searchEnabled={{true}}
@searchField="label"
@allowClear={{true}}
@placeholder={{t "order-config-manager.lifecycle.select-activity"}}
@triggerClass="form-select form-input"
@disabled={{not this.canEdit}}
as |activity|
>
<span>{{activity.label}}</span>
<span class="ml-2 text-xs text-gray-500">{{activity.code}}</span>
</PowerSelect>
</div>
</InputGroup>

<InputGroup @name={{t "order-config-manager.lifecycle.canceled"}} @helpText={{t "order-config-manager.lifecycle.canceled-help"}}>
<div class="fleetbase-model-select fleetbase-power-select ember-model-select">
<PowerSelect
@options={{this.activities}}
@selected={{this.selectedCanceled}}
@onChange={{fn this.setActivity "canceled"}}
@searchEnabled={{true}}
@searchField="label"
@allowClear={{true}}
@placeholder={{t "order-config-manager.lifecycle.select-activity"}}
@triggerClass="form-select form-input"
@disabled={{not this.canEdit}}
as |activity|
>
<span>{{activity.label}}</span>
<span class="ml-2 text-xs text-gray-500">{{activity.code}}</span>
</PowerSelect>
</div>
</InputGroup>

<div class="py-2">
<Toggle
@label={{t "order-config-manager.lifecycle.dispatch"}}
@helpText={{t "order-config-manager.lifecycle.dispatch-help"}}
@isToggled={{this.lifecycle.dispatch}}
@disabled={{not this.canEdit}}
@onToggle={{fn this.setFlag "dispatch"}}
/>
</div>
<div class="py-2">
<Toggle
@label={{t "order-config-manager.lifecycle.strict-transitions"}}
@helpText={{t "order-config-manager.lifecycle.strict-transitions-help"}}
@isToggled={{this.lifecycle.strict_transitions}}
@disabled={{not this.canEdit}}
@onToggle={{fn this.setFlag "strict_transitions"}}
/>
</div>
</div>

{{#if this.problems.length}}
<div class="mt-2 rounded-md border border-red-300 dark:border-red-700 px-3 py-2 text-sm">
<div class="font-semibold text-red-700 dark:text-red-400 mb-1">{{t "order-config-manager.lifecycle.problems-title"}}</div>
<ul class="list-disc pl-5 text-red-700 dark:text-red-400">
{{#each this.problems as |problem|}}<li>{{problem}}</li>{{/each}}
</ul>
</div>
{{/if}}
{{else}}
<p class="text-sm text-gray-600 dark:text-gray-300">{{t "order-config-manager.lifecycle.default-lifecycle"}}</p>
{{/if}}
</ContentPanel>

{{#if this.saved}}
<ContentPanel @title={{t "order-config-manager.lifecycle.summary"}} @open={{true}} @wrapperClass="bordered-classic">
<dl class="grid grid-cols-1 md:grid-cols-2 gap-x-6 gap-y-1 text-sm">
<div class="flex"><dt class="w-40 shrink-0 text-gray-500">{{t "order-config-manager.lifecycle.summary-initial"}}</dt><dd>{{or
(get (this.option this.saved.initial) "label")
(t "order-config-manager.lifecycle.none")
}}</dd></div>
<div class="flex"><dt class="w-40 shrink-0 text-gray-500">{{t "order-config-manager.lifecycle.summary-completed"}}</dt><dd>{{or
(get (this.option this.saved.completed) "label")
(t "order-config-manager.lifecycle.none")
}}</dd></div>
<div class="flex"><dt class="w-40 shrink-0 text-gray-500">{{t "order-config-manager.lifecycle.summary-canceled"}}</dt><dd>{{or
(get (this.option this.saved.canceled) "label")
(t "order-config-manager.lifecycle.none")
}}</dd></div>
<div class="flex"><dt class="w-40 shrink-0 text-gray-500">{{t "order-config-manager.lifecycle.summary-terminal"}}</dt><dd>{{#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}}</dd></div>
<div class="flex"><dt class="w-40 shrink-0 text-gray-500">{{t "order-config-manager.lifecycle.summary-dispatch"}}</dt><dd>{{if
this.saved.dispatch
(t "order-config-manager.lifecycle.yes")
(t "order-config-manager.lifecycle.no")
}}</dd></div>
<div class="flex"><dt class="w-40 shrink-0 text-gray-500">{{t "order-config-manager.lifecycle.summary-strict"}}</dt><dd>{{if
this.saved.strict_transitions
(t "order-config-manager.lifecycle.yes")
(t "order-config-manager.lifecycle.no")
}}</dd></div>
</dl>
</ContentPanel>
{{/if}}

{{#if this.canEdit}}
<ContentPanel @title={{t "order-config-manager.details.controls"}} @open={{true}}>
<div class="flex flex-row items-center py-2 space-x-4">
<Button
@type="primary"
@icon="save"
@text={{t "common.save-changes"}}
@onClick={{perform this.save}}
@isLoading={{this.save.isRunning}}
@disabled={{not this.canSave}}
@permission="fleet-ops update order-config"
/>
</div>
</ContentPanel>
{{/if}}
</div>
195 changes: 195 additions & 0 deletions addon/components/order-config-manager/lifecycle.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
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 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 };

/**
* 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.
*
* @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;

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() {
const flow = normalizeOrderConfigFlow(this.config?.flow ?? {});
return Object.values(flow)
.filter((activity) => activity && typeof activity === 'object')
.map((activity) => ({ code: activity.code ?? activity.key, label: activity.status || activity.code || activity.key }))
.filter((activity) => activity.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);
}

/** 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 (!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);
}

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);
yield this.config.save();
this.saved = this.normalize(this.lifecycle);
this.lifecycle = this.normalize(this.lifecycle);
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.notifications.serverError(error);
}
}
}
Loading
Loading