diff --git a/e2e/test-data/examplePlans/ExamplePlan_Small1.json b/e2e/test-data/examplePlans/ExamplePlan_Small1.json index b41e51b141..cb74817938 100644 --- a/e2e/test-data/examplePlans/ExamplePlan_Small1.json +++ b/e2e/test-data/examplePlans/ExamplePlan_Small1.json @@ -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 } ] } diff --git a/e2e/test-data/examplePlans/ExamplePlan_Small3.json b/e2e/test-data/examplePlans/ExamplePlan_Small3.json index 2304cf708b..e6ea0e17f9 100644 --- a/e2e/test-data/examplePlans/ExamplePlan_Small3.json +++ b/e2e/test-data/examplePlans/ExamplePlan_Small3.json @@ -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 } ] } diff --git a/e2e/tests/functional/planning/plan.e2e.spec.js b/e2e/tests/functional/planning/plan.e2e.spec.js index 9ac9a216d3..61784bfa9a 100644 --- a/e2e/tests/functional/planning/plan.e2e.spec.js +++ b/e2e/tests/functional/planning/plan.e2e.spec.js @@ -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' + ); + }); }); diff --git a/e2e/tests/functional/planning/timelist.e2e.spec.js b/e2e/tests/functional/planning/timelist.e2e.spec.js index 48a565e010..b1f6ee0285 100644 --- a/e2e/tests/functional/planning/timelist.e2e.spec.js +++ b/e2e/tests/functional/planning/timelist.e2e.spec.js @@ -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' + ); + }); }); }); diff --git a/src/api/objects/ObjectAPI.js b/src/api/objects/ObjectAPI.js index 58fa73249e..a5ee4bc6e6 100644 --- a/src/api/objects/ObjectAPI.js +++ b/src/api/objects/ObjectAPI.js @@ -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 diff --git a/src/plugins/activityStates/activityStatesInterceptor.js b/src/plugins/activityStates/activityStatesInterceptor.js new file mode 100644 index 0000000000..0ac425b074 --- /dev/null +++ b/src/plugins/activityStates/activityStatesInterceptor.js @@ -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; diff --git a/src/plugins/activityStates/createActivityStatesIdentifier.js b/src/plugins/activityStates/createActivityStatesIdentifier.js new file mode 100644 index 0000000000..76ab1d2cb7 --- /dev/null +++ b/src/plugins/activityStates/createActivityStatesIdentifier.js @@ -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 + }; +} diff --git a/src/plugins/activityStates/pluginSpec.js b/src/plugins/activityStates/pluginSpec.js new file mode 100644 index 0000000000..009259eaf2 --- /dev/null +++ b/src/plugins/activityStates/pluginSpec.js @@ -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(); + }); + }); + }); +}); diff --git a/src/plugins/plan/components/ActivityTimeline.vue b/src/plugins/plan/components/ActivityTimeline.vue index de732552ab..66047170ab 100644 --- a/src/plugins/plan/components/ActivityTimeline.vue +++ b/src/plugins/plan/components/ActivityTimeline.vue @@ -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 + }); } } }; diff --git a/src/plugins/plan/components/PlanView.vue b/src/plugins/plan/components/PlanView.vue index f94ccdbf37..ad69caedf3 100644 --- a/src/plugins/plan/components/PlanView.vue +++ b/src/plugins/plan/components/PlanView.vue @@ -47,6 +47,7 @@ :width="group.width" :is-nested="options.isChildObject" :status="status" + @activity-selected="selectActivity" /> @@ -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 + ); } } }; diff --git a/src/plugins/plan/inspector/components/PlanActivitiesView.vue b/src/plugins/plan/inspector/components/PlanActivitiesView.vue index 9069dbb547..002023adc2 100644 --- a/src/plugins/plan/inspector/components/PlanActivitiesView.vue +++ b/src/plugins/plan/inspector/components/PlanActivitiesView.vue @@ -20,21 +20,35 @@ at runtime from the About dialog for additional information. --> - - - + + + diff --git a/src/plugins/plan/inspector/components/PlanActivityStatusView.vue b/src/plugins/plan/inspector/components/PlanActivityStatusView.vue new file mode 100644 index 0000000000..f2ccffee6c --- /dev/null +++ b/src/plugins/plan/inspector/components/PlanActivityStatusView.vue @@ -0,0 +1,127 @@ + + + + + + {{ heading }} + + Set Status + + + + {{ status.label }} + + + + + + + + + diff --git a/src/plugins/plan/inspector/components/PlanActivityView.vue b/src/plugins/plan/inspector/components/PlanActivityTimeView.vue similarity index 68% rename from src/plugins/plan/inspector/components/PlanActivityView.vue rename to src/plugins/plan/inspector/components/PlanActivityTimeView.vue index 9ed488a500..ab1d4d5aee 100644 --- a/src/plugins/plan/inspector/components/PlanActivityView.vue +++ b/src/plugins/plan/inspector/components/PlanActivityTimeView.vue @@ -21,23 +21,23 @@ --> - - - {{ heading }} + + + + {{ heading }} + + + + - - -