Activity state display for plans in Gantt and Time list views (#7370)

* Add activity states domain object and interceptor to auto create one

* Add activity state inspector option

* Only save status if we have a unique ids for activities

* Include the id in the activity properties

* Don't show activity state section in the inspector if multiple activities are selected

* Display activity properties when an activity row is selected in the timelist

* Use activity id as key if it is available

* Ensure the correct option is selected for activity states

* Add status label

* Refactor activity selection. Display activity properties

* Remove activity states plugin. Move the activity states interceptor to the plan plugin.

* Change activity states interceptor parameters to options

* Rename constants

* Fix activity states test

* Add e2e test for activity states feature.

* Address review comments. Rename variables, documentation.

* No shallow copy

* Suppress lint warning for conditionals

* Remove check for abort controller

* Move classes to components

* number primitive

* Closes #7369
- WIP tweaks to simplify the Inspector view.

* Ensure 'notStarted' is the default state for activities

* Remove extra quotes

* Closes #7369
- Mod to `s-selected` styling to allow selection visiblity on Time List rows.

* Use generated key for vue

* Fix e2e tests

* Fix timelist test

---------

Co-authored-by: Charles Hacskaylo <charlesh88@gmail.com>
Co-authored-by: John Hill <john.c.hill@nasa.gov>
This commit is contained in:
Shefali Joshi 2024-01-28 15:46:10 -08:00 committed by GitHub
parent 60e1eeba8e
commit dc5a3236b3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 771 additions and 161 deletions

View File

@ -6,7 +6,8 @@
"end": 1660343797000,
"type": "Group 1",
"color": "orange",
"textColor": "white"
"textColor": "white",
"id": 1
},
{
"name": "Past event 2",
@ -14,7 +15,8 @@
"end": 1660429160000,
"type": "Group 1",
"color": "orange",
"textColor": "white"
"textColor": "white",
"id": 2
},
{
"name": "Past event 3",
@ -22,7 +24,8 @@
"end": 1660503981000,
"type": "Group 1",
"color": "orange",
"textColor": "white"
"textColor": "white",
"id": 3
},
{
"name": "Past event 4",
@ -30,7 +33,8 @@
"end": 1660624108000,
"type": "Group 1",
"color": "orange",
"textColor": "white"
"textColor": "white",
"id": 4
},
{
"name": "Past event 5",
@ -38,7 +42,8 @@
"end": 1660681529000,
"type": "Group 1",
"color": "orange",
"textColor": "white"
"textColor": "white",
"id": 5
}
]
}

View File

@ -6,7 +6,8 @@
"end": 1660343797000,
"type": "Group 1",
"color": "orange",
"textColor": "white"
"textColor": "white",
"id": 1
},
{
"name": "Time until supper",
@ -14,7 +15,8 @@
"end": 1650420410000,
"type": "Group 2",
"color": "blue",
"textColor": "white"
"textColor": "white",
"id": 2
}
],
"Group 2": [
@ -24,7 +26,8 @@
"end": 1650320102001,
"type": "Group 2",
"color": "green",
"textColor": "white"
"textColor": "white",
"id": 3
},
{
"name": "Time since last accident",
@ -32,7 +35,8 @@
"end": 1650320102002,
"type": "Group 1",
"color": "yellow",
"textColor": "white"
"textColor": "white",
"id": 4
}
]
}

View File

@ -27,7 +27,7 @@ import {
assertPlanActivities,
assertPlanOrderedSwimLanes
} from '../../../helper/planningUtils.js';
import { test } from '../../../pluginFixtures.js';
import { expect, test } from '../../../pluginFixtures.js';
const testPlan1 = JSON.parse(
fs.readFileSync(
@ -63,4 +63,47 @@ test.describe('Plan', () => {
});
await assertPlanOrderedSwimLanes(page, testPlanWithOrderedLanes, planWithSwimLanes.url);
});
test('Allows setting the state of an activity when selected.', async ({ page }) => {
const groups = Object.keys(testPlan1);
const firstGroupKey = groups[0];
const firstGroupItems = testPlan1[firstGroupKey];
const firstActivity = firstGroupItems[0];
const lastActivity = firstGroupItems[firstGroupItems.length - 1];
const startBound = firstActivity.start;
// Set the endBound to the end time of the current activity
let endBound = lastActivity.end;
// eslint-disable-next-line playwright/no-conditional-in-test
if (endBound === startBound) {
// Prevent oddities with setting start and end bound equal
// via URL params
endBound += 1;
}
// Switch to fixed time mode with all plan events within the bounds
await page.goto(
`${plan.url}?tc.mode=fixed&tc.startBound=${startBound}&tc.endBound=${endBound}&tc.timeSystem=utc&view=plan.view`
);
// select the first activity in the list
await page.getByText('Past event 1').click();
// Find the activity state section in the inspector
await page.getByRole('tab', { name: 'Activity' }).click();
// Check that activity state dropdown selection shows the `set status` option by default
await expect(page.getByLabel('Activity Status').locator("[aria-selected='true']")).toHaveText(
'Not started'
);
// Change the selection of the activity status
await page.getByRole('combobox').selectOption({ label: 'Aborted' });
// select a different activity and back to the previous one
await page.getByText('Past event 2').click();
await page.getByText('Past event 1').click();
// Check that activity state dropdown selection shows the previously selected option by default
await expect(page.getByLabel('Activity Status').locator("[aria-selected='true']")).toHaveText(
'Aborted'
);
});
});

View File

@ -30,6 +30,11 @@ const examplePlanSmall3 = JSON.parse(
new URL('../../../test-data/examplePlans/ExamplePlan_Small3.json', import.meta.url)
)
);
const examplePlanSmall1 = JSON.parse(
fs.readFileSync(
new URL('../../../test-data/examplePlans/ExamplePlan_Small1.json', import.meta.url)
)
);
// eslint-disable-next-line no-unused-vars
const START_TIME_COLUMN = 0;
// eslint-disable-next-line no-unused-vars
@ -40,53 +45,8 @@ const ACTIVITY_COLUMN = 3;
const HEADER_ROW = 0;
const NUM_COLUMNS = 4;
const testPlan = {
TEST_GROUP: [
{
name: 'Past event 1',
start: 1660320408000,
end: 1660343797000,
type: 'TEST-GROUP',
color: 'orange',
textColor: 'white'
},
{
name: 'Past event 2',
start: 1660406808000,
end: 1660429160000,
type: 'TEST-GROUP',
color: 'orange',
textColor: 'white'
},
{
name: 'Past event 3',
start: 1660493208000,
end: 1660503981000,
type: 'TEST-GROUP',
color: 'orange',
textColor: 'white'
},
{
name: 'Past event 4',
start: 1660579608000,
end: 1660624108000,
type: 'TEST-GROUP',
color: 'orange',
textColor: 'white'
},
{
name: 'Past event 5',
start: 1660666008000,
end: 1660681529000,
type: 'TEST-GROUP',
color: 'orange',
textColor: 'white'
}
]
};
test.describe('Time List', () => {
test('Create a Time List, add a single Plan to it and verify all the activities are displayed with no milliseconds', async ({
test("Create a Time List, add a single Plan to it, verify all the activities are displayed with no milliseconds and selecting an activity shows it's properties", async ({
page
}) => {
// Goto baseURL
@ -103,12 +63,16 @@ test.describe('Time List', () => {
await test.step('Create a Plan and add it to the timelist', async () => {
await createPlanFromJSON(page, {
name: 'Test Plan',
json: testPlan,
json: examplePlanSmall1,
parent: timelist.uuid
});
const startBound = testPlan.TEST_GROUP[0].start;
const endBound = testPlan.TEST_GROUP[testPlan.TEST_GROUP.length - 1].end;
const groups = Object.keys(examplePlanSmall1);
const firstGroupKey = groups[0];
const firstGroupItems = examplePlanSmall1[firstGroupKey];
const firstActivity = firstGroupItems[0];
const lastActivity = firstGroupItems[firstGroupItems.length - 1];
const startBound = firstActivity.start;
const endBound = lastActivity.end;
// Switch to fixed time mode with all plan events within the bounds
await page.goto(
@ -118,7 +82,7 @@ test.describe('Time List', () => {
// Verify all events are displayed
const eventCount = await page.getByRole('row').count();
// subtracting one for the header
await expect(eventCount - 1).toEqual(testPlan.TEST_GROUP.length);
await expect(eventCount - 1).toEqual(firstGroupItems.length);
});
await test.step('Does not show milliseconds in times', async () => {
@ -131,6 +95,17 @@ test.describe('Time List', () => {
await expect(row.locator('.--end')).not.toContainText('.');
await expect(row.locator('.--duration')).not.toContainText('.');
});
await test.step('Shows activity properties when a row is selected', async () => {
await page.getByRole('row').nth(2).click();
// Find the activity state section in the inspector
await page.getByRole('tab', { name: 'Activity' }).click();
// Check that activity state label is displayed in the inspector.
await expect(page.getByLabel('Activity Status').locator("[aria-selected='true']")).toHaveText(
'Not started'
);
});
});
});

View File

@ -99,7 +99,13 @@ export default class ObjectAPI {
this.cache = {};
this.interceptorRegistry = new InterceptorRegistry();
this.SYNCHRONIZED_OBJECT_TYPES = ['notebook', 'restricted-notebook', 'plan', 'annotation'];
this.SYNCHRONIZED_OBJECT_TYPES = [
'notebook',
'restricted-notebook',
'plan',
'annotation',
'activity-states'
];
this.errors = {
Conflict: ConflictError

View File

@ -0,0 +1,68 @@
/*****************************************************************************
* Open MCT, Copyright (c) 2014-2024, United States Government
* as represented by the Administrator of the National Aeronautics and Space
* Administration. All rights reserved.
*
* Open MCT is licensed under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*
* Open MCT includes source code licensed under additional open source
* licenses. See the Open Source Licenses file (LICENSES.md) included with
* this source code distribution or the Licensing information page available
* at runtime from the About dialog for additional information.
*****************************************************************************/
import { ACTIVITY_STATES_KEY } from './createActivityStatesIdentifier.js';
/**
* @typedef {object} ActivityStatesInterceptorOptions
* @property {import('../../api/objects/ObjectAPI').Identifier} identifier the {namespace, key} to use for the activity states object.
* @property {string} name The name of the activity states model.
* @property {number} priority the priority of the interceptor. By default, it is low.
*/
/**
* Creates an activity states object in the persistence store. This is used to save plan activity states.
* This will only get invoked when an attempt is made to save the state for an activity and no activity states object exists in the store.
* @param {import('../../../openmct').OpenMCT} openmct
* @param {ActivityStatesInterceptorOptions} options
* @returns {object}
*/
const ACTIVITY_STATES_TYPE = 'activity-states';
function activityStatesInterceptor(openmct, options) {
const { identifier, name, priority = openmct.priority.LOW } = options;
const activityStatesModel = {
identifier,
name,
type: ACTIVITY_STATES_TYPE,
activities: {},
location: null
};
return {
appliesTo: (identifierObject) => {
return identifierObject.key === ACTIVITY_STATES_KEY;
},
invoke: (identifierObject, object) => {
if (!object || openmct.objects.isMissing(object)) {
openmct.objects.save(activityStatesModel);
return activityStatesModel;
}
return object;
},
priority
};
}
export default activityStatesInterceptor;

View File

@ -0,0 +1,30 @@
/*****************************************************************************
* Open MCT, Copyright (c) 2014-2024, United States Government
* as represented by the Administrator of the National Aeronautics and Space
* Administration. All rights reserved.
*
* Open MCT is licensed under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*
* Open MCT includes source code licensed under additional open source
* licenses. See the Open Source Licenses file (LICENSES.md) included with
* this source code distribution or the Licensing information page available
* at runtime from the About dialog for additional information.
*****************************************************************************/
export const ACTIVITY_STATES_KEY = 'activity-states';
export function createActivityStatesIdentifier(namespace = '') {
return {
key: ACTIVITY_STATES_KEY,
namespace
};
}

View File

@ -0,0 +1,89 @@
/*****************************************************************************
* Open MCT, Copyright (c) 2014-2024, United States Government
* as represented by the Administrator of the National Aeronautics and Space
* Administration. All rights reserved.
*
* Open MCT is licensed under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*
* Open MCT includes source code licensed under additional open source
* licenses. See the Open Source Licenses file (LICENSES.md) included with
* this source code distribution or the Licensing information page available
* at runtime from the About dialog for additional information.
*****************************************************************************/
import { createOpenMct, resetApplicationState } from 'utils/testing';
import {
ACTIVITY_STATES_KEY,
createActivityStatesIdentifier
} from './createActivityStatesIdentifier.js';
const MISSING_NAME = `Missing: ${ACTIVITY_STATES_KEY}`;
const DEFAULT_NAME = 'Activity States';
const activityStatesIdentifier = createActivityStatesIdentifier();
describe('the plugin', () => {
let openmct;
let missingObj = {
identifier: activityStatesIdentifier,
type: 'unknown',
name: MISSING_NAME
};
describe('with no arguments passed in', () => {
beforeEach((done) => {
openmct = createOpenMct();
openmct.install(openmct.plugins.PlanLayout());
openmct.on('start', done);
openmct.startHeadless();
});
afterEach(() => {
return resetApplicationState(openmct);
});
it('when installed, adds "Activity States"', async () => {
const activityStatesObject = await openmct.objects.get(activityStatesIdentifier);
expect(activityStatesObject.name).toBe(DEFAULT_NAME);
expect(activityStatesObject).toBeDefined();
});
describe('adds an interceptor that returns a "Activity States" model for', () => {
let activityStatesObject;
let mockNotFoundProvider;
let activeProvider;
beforeEach(async () => {
mockNotFoundProvider = {
get: () => Promise.reject(new Error('Not found')),
create: () => Promise.resolve(missingObj),
update: () => Promise.resolve(missingObj)
};
activeProvider = mockNotFoundProvider;
spyOn(openmct.objects, 'getProvider').and.returnValue(activeProvider);
activityStatesObject = await openmct.objects.get(activityStatesIdentifier);
});
it('missing objects', () => {
let idsMatch = openmct.objects.areIdsEqual(
activityStatesObject.identifier,
activityStatesIdentifier
);
expect(activityStatesObject).toBeDefined();
expect(idsMatch).toBeTrue();
});
});
});
});

View File

@ -135,6 +135,7 @@ export default {
default: 22
}
},
emits: ['activity-selected'],
data() {
return {
lineHeight: 10
@ -142,30 +143,11 @@ export default {
},
methods: {
setSelectionForActivity(activity, event) {
const element = event.currentTarget;
const multiSelect = event.metaKey;
event.stopPropagation();
this.openmct.selection.select(
[
{
element: element,
context: {
type: 'activity',
activity: activity
}
},
{
element: this.openmct.layout.$refs.browseObject.$el,
context: {
item: this.domainObject,
supportsMultiSelect: true
}
}
],
multiSelect
);
this.$emit('activity-selected', {
event,
selection: activity.selection
});
}
}
};

View File

@ -47,6 +47,7 @@
:width="group.width"
:is-nested="options.isChildObject"
:status="status"
@activity-selected="selectActivity"
/>
</div>
</div>
@ -134,7 +135,7 @@ export default {
this.swimlaneVisibility = this.configuration.swimlaneVisibility;
this.clipActivityNames = this.configuration.clipActivityNames;
if (this.domainObject.type === 'plan') {
this.planData = getValidatedData(this.domainObject);
this.setPlanData(this.domainObject);
}
const canvas = document.createElement('canvas');
@ -177,6 +178,9 @@ export default {
this.planViewConfiguration.destroy();
},
methods: {
setPlanData(domainObject) {
this.planData = getValidatedData(domainObject);
},
activityNameFitsRect(activityName, rectWidth) {
return this.getTextWidth(activityName) + TEXT_LEFT_PADDING < rectWidth;
},
@ -215,9 +219,7 @@ export default {
callback: () => {
this.removeFromComposition(this.planObject);
this.planObject = domainObject;
this.planData = getValidatedData(domainObject);
this.setStatus(this.openmct.status.get(domainObject.identifier));
this.setScaleAndGenerateActivities();
this.handleSelectFileChange();
dialog.dismiss();
}
},
@ -237,9 +239,7 @@ export default {
} else {
this.planObject = domainObject;
this.swimlaneVisibility = this.configuration.swimlaneVisibility;
this.planData = getValidatedData(domainObject);
this.setStatus(this.openmct.status.get(domainObject.identifier));
this.setScaleAndGenerateActivities();
this.handleSelectFileChange(domainObject);
}
},
handleConfigurationChange(newConfiguration) {
@ -259,8 +259,10 @@ export default {
this.setScaleAndGenerateActivities();
},
handleSelectFileChange() {
this.planData = getValidatedData(this.domainObject);
handleSelectFileChange(domainObject) {
const planDomainObject = domainObject || this.domainObject;
this.setPlanData(planDomainObject);
this.setStatus(this.openmct.status.get(planDomainObject.identifier));
this.setScaleAndGenerateActivities();
},
removeFromComposition(domainObject) {
@ -434,7 +436,7 @@ export default {
return;
}
rawActivities.forEach((rawActivity) => {
rawActivities.forEach((rawActivity, index) => {
if (!this.isActivityInBounds(rawActivity)) {
return;
}
@ -481,13 +483,10 @@ export default {
const activity = {
color: color,
textColor: textColor,
name: rawActivity.name,
exceeds: {
start: this.xScale(this.viewBounds.start) > this.xScale(rawActivity.start),
end: this.xScale(this.viewBounds.end) < this.xScale(rawActivity.end)
},
start: rawActivity.start,
end: rawActivity.end,
row: currentRow,
textLines: textLines,
textStart: textStart,
@ -496,7 +495,11 @@ export default {
rectStart: rectX1,
rectEnd: showTextInsideRect ? rectX2 : textStart + textWidth,
rectWidth: rectWidth,
clipPathId: this.getClipPathId(groupName, rawActivity, currentRow)
clipPathId: this.getClipPathId(groupName, rawActivity, currentRow),
selection: {
groupName,
index
}
};
activitiesByRow[currentRow].push(activity);
});
@ -573,6 +576,31 @@ export default {
const activityName = activity.name.toLowerCase().replace(/ /g, '-');
return `${groupName}-${activityName}-${activity.start}-${activity.end}-${row}`;
},
selectActivity({ event, selection }) {
const element = event.currentTarget;
const multiSelect = event.metaKey;
const { groupName, index } = selection;
const rawActivity = this.planData[groupName][index];
this.openmct.selection.select(
[
{
element: element,
context: {
type: 'activity',
activity: rawActivity
}
},
{
element: this.openmct.layout.$refs.browseObject.$el,
context: {
item: this.domainObject,
supportsMultiSelect: true
}
}
],
multiSelect
);
}
}
};

View File

@ -20,21 +20,35 @@
at runtime from the About dialog for additional information.
-->
<template>
<div class="c-inspector__properties c-inspect-properties">
<plan-activity-view
v-for="activity in activities"
:key="activity.id"
:activity="activity"
:heading="heading"
/>
</div>
<plan-activity-time-view
v-for="activity in activities"
:key="activity.key"
:activity="activity"
:heading="heading"
/>
<plan-activity-properties-view
v-for="activity in activities"
:key="activity.key"
heading="Properties"
:activity="activity"
/>
<plan-activity-status-view
v-if="canPersistState"
:key="activities[0].key"
:activity="activities[0]"
:execution-state="activityExecutionState"
heading="Activity Status"
@update-activity-state="persistActivityState"
/>
</template>
<script>
import { getPreciseDuration } from 'utils/duration';
import { v4 as uuid } from 'uuid';
import PlanActivityView from './PlanActivityView.vue';
import { getDisplayProperties } from '../../util.js';
import PlanActivityPropertiesView from './PlanActivityPropertiesView.vue';
import PlanActivityStatusView from './PlanActivityStatusView.vue';
import PlanActivityTimeView from './PlanActivityTimeView.vue';
const propertyLabels = {
start: 'Start DateTime',
@ -44,23 +58,34 @@ const propertyLabels = {
latestEnd: 'Latest End',
gap: 'Gap',
overlap: 'Overlap',
totalTime: 'Total Time'
totalTime: 'Total Time',
description: 'Description'
};
export default {
components: {
PlanActivityView
PlanActivityTimeView,
PlanActivityPropertiesView,
PlanActivityStatusView
},
inject: ['openmct', 'selection'],
data() {
return {
name: '',
activities: [],
selectedActivities: [],
activityExecutionState: undefined,
heading: ''
};
},
computed: {
canPersistState() {
return this.selectedActivities.length === 1 && this.activities?.[0]?.id;
}
},
mounted() {
this.setFormatters();
this.getPlanData(this.selection);
this.getActivityStates();
this.getActivities();
this.openmct.selection.on('change', this.updateSelection);
this.openmct.time.on('timeSystem', this.setFormatters);
@ -68,8 +93,28 @@ export default {
beforeUnmount() {
this.openmct.selection.off('change', this.updateSelection);
this.openmct.time.off('timeSystem', this.setFormatters);
if (this.stopObservingActivityStatesObject) {
this.stopObservingActivityStatesObject();
}
},
methods: {
async getActivityStates() {
this.activityStatesObject = await this.openmct.objects.get('activity-states');
this.setActivityStates(this.activityStatesObject);
this.stopObservingActivityStatesObject = this.openmct.objects.observe(
this.activityStatesObject,
'*',
this.setActivityStates
);
},
setActivityStates(newActivitiesStateObject) {
if (this.activities.length) {
const id = this.activities[0].id;
this.activityExecutionState = newActivitiesStateObject.activities[id];
} else {
this.activityExecutionState = undefined;
}
},
setFormatters() {
let timeSystem = this.openmct.time.timeSystem();
this.timeFormatter = this.openmct.telemetry.getValueFormatter({
@ -86,6 +131,7 @@ export default {
if (selectionItem[0].context.type === 'activity') {
const activity = selectionItem[0].context.activity;
if (activity) {
activity.key = activity.id ?? activity.name;
this.selectedActivities.push(activity);
}
}
@ -104,20 +150,37 @@ export default {
this.activities.splice(0);
this.selectedActivities.forEach((selectedActivity, index) => {
const activity = {
id: uuid(),
start: {
label: propertyLabels.start,
value: this.formatTime(selectedActivity.start)
},
end: {
label: propertyLabels.end,
value: this.formatTime(selectedActivity.end)
},
duration: {
label: propertyLabels.duration,
value: this.formatDuration(selectedActivity.end - selectedActivity.start)
id: selectedActivity.id,
key: selectedActivity.key,
timeProperties: {
start: {
label: propertyLabels.start,
value: this.formatTime(selectedActivity.start)
},
end: {
label: propertyLabels.end,
value: this.formatTime(selectedActivity.end)
},
duration: {
label: propertyLabels.duration,
value: this.formatDuration(selectedActivity.end - selectedActivity.start)
}
}
};
activity.metadata = {};
if (selectedActivity.description) {
activity.metadata.description = {
label: propertyLabels.description,
value: selectedActivity.description
};
}
const displayProperties = getDisplayProperties(selectedActivity);
activity.metadata = {
...activity.metadata,
...displayProperties
};
this.activities[index] = activity;
});
},
@ -141,6 +204,8 @@ export default {
let latestEnd;
let gap;
let overlap;
let id;
let key;
//Sort by start time
let selectedActivities = this.selectedActivities.sort(this.sortFn);
@ -159,6 +224,8 @@ export default {
earliestStart = Math.min(earliestStart, selectedActivity.start);
latestEnd = Math.max(latestEnd, selectedActivity.end);
} else {
id = selectedActivity.id;
key = selectedActivity.id ?? selectedActivity.name;
earliestStart = selectedActivity.start;
latestEnd = selectedActivity.end;
}
@ -166,30 +233,33 @@ export default {
let totalTime = latestEnd - earliestStart;
const activity = {
id: uuid(),
earliestStart: {
label: propertyLabels.earliestStart,
value: this.formatTime(earliestStart)
},
latestEnd: {
label: propertyLabels.latestEnd,
value: this.formatTime(latestEnd)
id,
key,
timeProperties: {
earliestStart: {
label: propertyLabels.earliestStart,
value: this.formatTime(earliestStart)
},
latestEnd: {
label: propertyLabels.latestEnd,
value: this.formatTime(latestEnd)
}
}
};
if (gap) {
activity.gap = {
activity.timeProperties.gap = {
label: propertyLabels.gap,
value: this.formatDuration(gap)
};
} else if (overlap) {
activity.overlap = {
activity.timeProperties.overlap = {
label: propertyLabels.overlap,
value: this.formatDuration(overlap)
};
}
activity.totalTime = {
activity.timeProperties.totalTime = {
label: propertyLabels.totalTime,
value: this.formatDuration(totalTime)
};
@ -201,6 +271,11 @@ export default {
},
formatTime(time) {
return this.timeFormatter.format(time);
},
persistActivityState(data) {
const { key, executionState } = data;
const activitiesPath = `activities.${key}`;
this.openmct.objects.mutate(this.activityStatesObject, activitiesPath, executionState);
}
}
};

View File

@ -0,0 +1,81 @@
<!--
Open MCT, Copyright (c) 2014-2023, United States Government
as represented by the Administrator of the National Aeronautics and Space
Administration. All rights reserved.
Open MCT is licensed under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0.
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
License for the specific language governing permissions and limitations
under the License.
Open MCT includes source code licensed under additional open source
licenses. See the Open Source Licenses file (LICENSES.md) included with
this source code distribution or the Licensing information page available
at runtime from the About dialog for additional information.
-->
<template>
<div class="c-inspector__properties c-inspect-properties">
<div v-if="properties.length" class="u-contents">
<div class="c-inspect-properties__header">{{ heading }}</div>
<ul v-for="property in properties" :key="property.id" class="c-inspect-properties__section">
<activity-property :label="property.label" :value="property.value" />
</ul>
</div>
</div>
</template>
<script>
import ActivityProperty from './ActivityProperty.vue';
export default {
components: {
ActivityProperty
},
props: {
activity: {
type: Object,
required: true
},
heading: {
type: String,
required: true
}
},
data() {
return {
properties: []
};
},
mounted() {
this.setProperties();
},
methods: {
setProperties() {
if (!this.activity.metadata) {
return;
}
Object.keys(this.activity.metadata).forEach((key) => {
if (this.activity.metadata[key].label) {
const label = this.activity.metadata[key].label;
const value = String(this.activity.metadata[key].value);
const id = this.activity.id;
this.properties[this.properties.length] = {
id,
label,
value
};
}
});
}
}
};
</script>

View File

@ -0,0 +1,127 @@
<!--
Open MCT, Copyright (c) 2014-2023, United States Government
as represented by the Administrator of the National Aeronautics and Space
Administration. All rights reserved.
Open MCT is licensed under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0.
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
License for the specific language governing permissions and limitations
under the License.
Open MCT includes source code licensed under additional open source
licenses. See the Open Source Licenses file (LICENSES.md) included with
this source code distribution or the Licensing information page available
at runtime from the About dialog for additional information.
-->
<template>
<div class="c-inspector__properties c-inspect-properties">
<div class="u-contents">
<div class="c-inspect-properties__header">{{ heading }}</div>
<div class="c-inspect-properties__row">
<div class="c-inspect-properties__label" title="Set Status">Set Status</div>
<div class="c-inspect-properties__value" aria-label="Activity Status Label">
<select
v-model="currentStatusKey"
name="setActivityStatus"
aria-label="Activity Status"
@change="changeActivityStatus"
>
<option
v-for="status in activityStates"
:key="status.key"
:value="status.key"
:aria-selected="currentStatusKey === status.key"
>
{{ status.label }}
</option>
</select>
</div>
</div>
</div>
</div>
</template>
<script>
const activityStates = [
{
key: 'notStarted',
label: 'Not started'
},
{
key: 'in-progress',
label: 'In progress'
},
{
key: 'completed',
label: 'Completed'
},
{
key: 'aborted',
label: 'Aborted'
},
{
key: 'cancelled',
label: 'Cancelled'
}
];
export default {
props: {
activity: {
type: Object,
required: true
},
executionState: {
type: String,
default() {
return '';
}
},
heading: {
type: String,
required: true
}
},
emits: ['updateActivityState'],
data() {
return {
activityStates: activityStates,
currentStatusKey: activityStates[0].key
};
},
watch: {
executionState() {
this.setActivityStatus();
}
},
mounted() {
this.setActivityStatus();
},
methods: {
setActivityStatus() {
let statusKeyIndex = activityStates.findIndex((state) => state.key === this.executionState);
if (statusKeyIndex < 0) {
statusKeyIndex = 0;
}
this.currentStatusKey = this.activityStates[statusKeyIndex].key;
},
changeActivityStatus() {
if (this.currentStatusKey === '') {
return;
}
this.activity.executionState = this.currentStatusKey;
this.$emit('updateActivityState', {
key: this.activity.id,
executionState: this.currentStatusKey
});
}
}
};
</script>

View File

@ -21,23 +21,23 @@
-->
<template>
<div v-if="timeProperties.length" class="u-contents">
<div class="c-inspect-properties__header">
{{ heading }}
<div class="c-inspector__properties c-inspect-properties">
<div v-if="timeProperties.length" class="u-contents">
<div class="c-inspect-properties__header">
{{ heading }}
</div>
<ul
v-for="timeProperty in timeProperties"
:key="timeProperty.id"
class="c-inspect-properties__section"
>
<activity-property :label="timeProperty.label" :value="timeProperty.value" />
</ul>
</div>
<ul
v-for="timeProperty in timeProperties"
:key="timeProperty.id"
class="c-inspect-properties__section"
>
<activity-property :label="timeProperty.label" :value="timeProperty.value" />
</ul>
</div>
</template>
<script>
import { v4 as uuid } from 'uuid';
import ActivityProperty from './ActivityProperty.vue';
export default {
@ -64,13 +64,14 @@ export default {
},
methods: {
setProperties() {
Object.keys(this.activity).forEach((key) => {
if (this.activity[key].label) {
const label = this.activity[key].label;
const value = String(this.activity[key].value);
Object.keys(this.activity.timeProperties).forEach((key) => {
if (this.activity.timeProperties[key].label) {
const label = this.activity.timeProperties[key].label;
const value = String(this.activity.timeProperties[key].value);
const id = this.activity.id;
this.timeProperties[this.timeProperties.length] = {
id: uuid(),
id,
label,
value
};

View File

@ -20,12 +20,28 @@
* at runtime from the About dialog for additional information.
*****************************************************************************/
import activityStatesInterceptor from '../activityStates/activityStatesInterceptor.js';
import { createActivityStatesIdentifier } from '../activityStates/createActivityStatesIdentifier.js';
import ganttChartCompositionPolicy from './GanttChartCompositionPolicy.js';
import ActivityInspectorViewProvider from './inspector/ActivityInspectorViewProvider.js';
import GanttChartInspectorViewProvider from './inspector/GanttChartInspectorViewProvider.js';
import { DEFAULT_CONFIGURATION } from './PlanViewConfiguration.js';
import PlanViewProvider from './PlanViewProvider.js';
const ACTIVITY_STATES_DEFAULT_NAME = 'Activity States';
/**
* @typedef {object} PlanOptions
* @property {boolean} creatable true/false to allow creation of a plan via the Create menu.
* @property {string} name The name of the activity states model.
* @property {string} namespace the namespace to use for the activity states object.
* @property {Number} priority the priority of the interceptor. By default, it is low.
*/
/**
*
* @param {PlanOptions} options
* @returns {*} (any)
*/
export default function (options = {}) {
return function install(openmct) {
openmct.types.addType('plan', {
@ -70,5 +86,13 @@ export default function (options = {}) {
openmct.inspectorViews.addProvider(new ActivityInspectorViewProvider(openmct));
openmct.inspectorViews.addProvider(new GanttChartInspectorViewProvider(openmct));
openmct.composition.addPolicy(ganttChartCompositionPolicy(openmct));
//add activity states get interceptor
const { name = ACTIVITY_STATES_DEFAULT_NAME, namespace = '', priority } = options;
const identifier = createActivityStatesIdentifier(namespace);
openmct.objects.addGetInterceptor(
activityStatesInterceptor(openmct, { identifier, name, priority })
);
};
}

View File

@ -20,6 +20,18 @@
* at runtime from the About dialog for additional information.
*****************************************************************************/
/**
* The SourceMap allows mapping specific implementations of plan domain objects to those expected by Open MCT.
* @typedef {object} SourceMapOption
* @property {string} orderedGroups the property of the plan that lists groups/swim lanes specifying what order they will be displayed in Open MCT.
* @property {string} activities the property of the plan that has the list of activities to be displayed.
* @property {string} groupId the property of the activity that maps to the group/swim lane it should be displayed in.
* @property {string} start The start time property of the activity
* @property {string} end The end time property of the activity
* @property {string} id The unique id of the activity. This is required to allow setting activity states
* @property {object} displayProperties a list of key: value pairs that specifies which properties of the activity should be displayed when it is selected. Ex. {'location': 'Location', 'metadata.length_in_meters', 'Length (meters)'}
*/
import _ from 'lodash';
export function getValidatedData(domainObject) {
const sourceMap = domainObject.sourceMap;
@ -56,6 +68,14 @@ export function getValidatedData(domainObject) {
});
}
if (sourceMap.id) {
groupActivity.id = activity[sourceMap.id];
}
if (sourceMap.displayProperties) {
groupActivity.displayProperties = sourceMap.displayProperties;
}
if (!mappedJson[groupIdKey]) {
mappedJson[groupIdKey] = [];
}
@ -110,6 +130,26 @@ export function getValidatedGroups(domainObject, planData) {
return orderedGroupNames;
}
export function getDisplayProperties(activity) {
let displayProperties = {};
function extractProperties(properties, useKeyAsLabel = false) {
Object.keys(properties).forEach((key) => {
const label = useKeyAsLabel ? key : properties[key];
const value = _.get(activity, key);
if (value) {
displayProperties[key] = { label, value };
}
});
}
if (activity?.displayProperties) {
extractProperties(activity.displayProperties);
} else if (activity?.properties) {
extractProperties(activity.properties, true);
}
return displayProperties;
}
export function getFilteredValues(activity) {
let values = [];
if (Array.isArray(activity.filterMetadataValues)) {

View File

@ -123,15 +123,6 @@
.is-editing .l-layout__frame & {
pointer-events: none;
}
&.is-selected {
background-color: $colorSelectedBg !important;
color: $colorSelectedFg !important;
td {
background: none !important;
color: inherit !important;
}
}
}
td {
@ -186,6 +177,18 @@ td {
@include isLimit();
}
.c-table tr {
&[s-selected],
&.is-selected {
background-color: $colorSelectedBg !important;
color: $colorSelectedFg !important;
td {
background: none !important;
color: inherit !important;
}
}
}
/******************************* SPECIFIC CASE WRAPPERS */
.is-editing {
.c-telemetry-table__headers__labels {

View File

@ -27,6 +27,7 @@
:header-items="headerItems"
:default-sort="defaultSort"
class="sticky"
@item-selection-changed="setSelectionForActivity"
/>
</div>
</template>
@ -541,6 +542,29 @@ export default {
setEditState(isEditing) {
this.isEditing = isEditing;
this.setViewFromConfig(this.domainObject.configuration);
},
setSelectionForActivity(activity, element) {
const multiSelect = false;
this.openmct.selection.select(
[
{
element: element,
context: {
type: 'activity',
activity: activity
}
},
{
element: this.openmct.layout.$refs.browseObject.$el,
context: {
item: this.domainObject,
supportsMultiSelect: false
}
}
],
multiSelect
);
}
}
};

View File

@ -28,7 +28,7 @@ import TimelistPropertiesView from './TimelistPropertiesView.vue';
export default function TimeListInspectorViewProvider(openmct) {
return {
key: 'timelist-inspector',
name: 'Timelist Inspector View',
name: 'Config',
canView: function (selection) {
if (selection.length === 0 || selection[0].length === 0) {
return false;

View File

@ -43,6 +43,7 @@
:key="item.key"
:item="item"
:item-properties="itemProperties"
@click.stop="itemSelected(item, $event)"
/>
</tbody>
</table>
@ -86,6 +87,7 @@ export default {
}
}
},
emits: ['item-selection-changed'],
data() {
let sortBy = this.defaultSort.property;
let ascending = this.defaultSort.defaultDirection;
@ -156,6 +158,9 @@ export default {
})
);
}
},
itemSelected(item, event) {
this.$emit('item-selection-changed', item, event.currentTarget);
}
}
};