Skip to content

Commit

Permalink
Activity state display for plans in Gantt and Time list views (#7370)
Browse files Browse the repository at this point in the history
* 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>
  • Loading branch information
3 people committed Jan 28, 2024
1 parent 60e1eeb commit dc5a323
Show file tree
Hide file tree
Showing 20 changed files with 771 additions and 161 deletions.
15 changes: 10 additions & 5 deletions e2e/test-data/examplePlans/ExamplePlan_Small1.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,39 +6,44 @@
"end": 1660343797000,
"type": "Group 1",
"color": "orange",
"textColor": "white"
"textColor": "white",
"id": 1
},
{
"name": "Past event 2",
"start": 1660406808000,
"end": 1660429160000,
"type": "Group 1",
"color": "orange",
"textColor": "white"
"textColor": "white",
"id": 2
},
{
"name": "Past event 3",
"start": 1660493208000,
"end": 1660503981000,
"type": "Group 1",
"color": "orange",
"textColor": "white"
"textColor": "white",
"id": 3
},
{
"name": "Past event 4",
"start": 1660579608000,
"end": 1660624108000,
"type": "Group 1",
"color": "orange",
"textColor": "white"
"textColor": "white",
"id": 4
},
{
"name": "Past event 5",
"start": 1660666008000,
"end": 1660681529000,
"type": "Group 1",
"color": "orange",
"textColor": "white"
"textColor": "white",
"id": 5
}
]
}
12 changes: 8 additions & 4 deletions e2e/test-data/examplePlans/ExamplePlan_Small3.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,17 @@
"end": 1660343797000,
"type": "Group 1",
"color": "orange",
"textColor": "white"
"textColor": "white",
"id": 1
},
{
"name": "Time until supper",
"start": 1650320402000,
"end": 1650420410000,
"type": "Group 2",
"color": "blue",
"textColor": "white"
"textColor": "white",
"id": 2
}
],
"Group 2": [
Expand All @@ -24,15 +26,17 @@
"end": 1650320102001,
"type": "Group 2",
"color": "green",
"textColor": "white"
"textColor": "white",
"id": 3
},
{
"name": "Time since last accident",
"start": 1650320102002,
"end": 1650320102002,
"type": "Group 1",
"color": "yellow",
"textColor": "white"
"textColor": "white",
"id": 4
}
]
}
45 changes: 44 additions & 1 deletion e2e/tests/functional/planning/plan.e2e.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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'
);
});
});
77 changes: 26 additions & 51 deletions e2e/tests/functional/planning/timelist.e2e.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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(
Expand All @@ -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 () => {
Expand All @@ -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'
);
});
});
});

Expand Down
8 changes: 7 additions & 1 deletion src/api/objects/ObjectAPI.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
68 changes: 68 additions & 0 deletions src/plugins/activityStates/activityStatesInterceptor.js
Original file line number Diff line number Diff line change
@@ -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;
30 changes: 30 additions & 0 deletions src/plugins/activityStates/createActivityStatesIdentifier.js
Original file line number Diff line number Diff line change
@@ -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
};
}
Loading

0 comments on commit dc5a323

Please sign in to comment.