From 250db8d7f9e9486690feede3e1a3dd0f3fc34744 Mon Sep 17 00:00:00 2001 From: Shefali Joshi Date: Thu, 14 Dec 2023 06:19:42 -0800 Subject: [PATCH] Allow specification of swimlanes via configuration (#7200) * Use specified group order for plans * Allow groupIds to be a function * Fix typo in if statement * Check that activities are present for a given group * Change refresh to emit the new model * Update domainobject on change * Revert changes for domainObject * Revert groupIds as functions. Check if groups are objects with names instead. * Add e2e test for plan swim lane order * Address review comments - improve if statement * Move function to right util helper * Fix path for imported code * Remove focused test * Change the name of the ordered group configuration --- e2e/helper/planningUtils.js | 44 +++++++++++++++ .../ExamplePlanWithOrderedLanes.json | 54 +++++++++++++++++++ .../functional/planning/plan.e2e.spec.js | 17 +++++- src/plugins/plan/components/PlanView.vue | 8 ++- src/plugins/plan/util.js | 53 ++++++++++++++---- src/plugins/timeline/TimelineViewLayout.vue | 5 +- src/plugins/timelist/TimelistComponent.vue | 7 ++- 7 files changed, 170 insertions(+), 18 deletions(-) create mode 100644 e2e/test-data/examplePlans/ExamplePlanWithOrderedLanes.json diff --git a/e2e/helper/planningUtils.js b/e2e/helper/planningUtils.js index 3c5513e218..ce725df63a 100644 --- a/e2e/helper/planningUtils.js +++ b/e2e/helper/planningUtils.js @@ -81,6 +81,30 @@ function activitiesWithinTimeBounds(start1, end1, start2, end2) { ); } +/** + * Asserts that the swim lanes / groups in the plan view matches the order of + * groups in the plan data. + * @param {import('@playwright/test').Page} page the page + * @param {object} plan The raw plan json to assert against + * @param {string} objectUrl The URL of the object to assert against (plan or gantt chart) + */ +export async function assertPlanOrderedSwimLanes(page, plan, objectUrl) { + // Switch to the plan view + await page.goto(`${objectUrl}?view=plan.view`); + const planGroups = await page + .locator('.c-plan__contents > div > .c-swimlane__lane-label .c-object-label__name') + .all(); + + const groups = plan.Groups; + + for (let i = 0; i < groups.length; i++) { + // Assert that the order of groups in the plan view matches the order of + // groups in the plan data + const groupName = await planGroups[i].innerText(); + expect(groupName).toEqual(groups[i].name); + } +} + /** * Navigate to the plan view, switch to fixed time mode, * and set the bounds to span all activities. @@ -110,3 +134,23 @@ export async function setDraftStatusForPlan(page, plan) { await window.openmct.status.set(planObject.uuid, 'draft'); }, plan); } + +export async function addPlanGetInterceptor(page) { + await page.waitForLoadState('load'); + await page.evaluate(async () => { + await window.openmct.objects.addGetInterceptor({ + appliesTo: (identifier, domainObject) => { + return domainObject && domainObject.type === 'plan'; + }, + invoke: (identifier, object) => { + if (object) { + object.sourceMap = { + orderedGroups: 'Groups' + }; + } + + return object; + } + }); + }); +} diff --git a/e2e/test-data/examplePlans/ExamplePlanWithOrderedLanes.json b/e2e/test-data/examplePlans/ExamplePlanWithOrderedLanes.json new file mode 100644 index 0000000000..e3f665395d --- /dev/null +++ b/e2e/test-data/examplePlans/ExamplePlanWithOrderedLanes.json @@ -0,0 +1,54 @@ +{ + "Groups": [ + { + "name": "Group 1" + }, + { + "name": "Group 2" + } + ], + "Group 2": [ + { + "name": "Past event 3", + "start": 1660493208000, + "end": 1660503981000, + "type": "Group 2", + "color": "orange", + "textColor": "white" + }, + { + "name": "Past event 4", + "start": 1660579608000, + "end": 1660624108000, + "type": "Group 2", + "color": "orange", + "textColor": "white" + }, + { + "name": "Past event 5", + "start": 1660666008000, + "end": 1660681529000, + "type": "Group 2", + "color": "orange", + "textColor": "white" + } + ], + "Group 1": [ + { + "name": "Past event 1", + "start": 1660320408000, + "end": 1660343797000, + "type": "Group 1", + "color": "orange", + "textColor": "white" + }, + { + "name": "Past event 2", + "start": 1660406808000, + "end": 1660429160000, + "type": "Group 1", + "color": "orange", + "textColor": "white" + } + ] +} diff --git a/e2e/tests/functional/planning/plan.e2e.spec.js b/e2e/tests/functional/planning/plan.e2e.spec.js index c4a65ced3c..9ac5b8c03d 100644 --- a/e2e/tests/functional/planning/plan.e2e.spec.js +++ b/e2e/tests/functional/planning/plan.e2e.spec.js @@ -21,8 +21,13 @@ *****************************************************************************/ const { test } = require('../../../pluginFixtures'); const { createPlanFromJSON } = require('../../../appActions'); +const { addPlanGetInterceptor } = require('../../../helper/planningUtils.js'); const testPlan1 = require('../../../test-data/examplePlans/ExamplePlan_Small1.json'); -const { assertPlanActivities } = require('../../../helper/planningUtils'); +const testPlanWithOrderedLanes = require('../../../test-data/examplePlans/ExamplePlanWithOrderedLanes.json'); +const { + assertPlanActivities, + assertPlanOrderedSwimLanes +} = require('../../../helper/planningUtils'); test.describe('Plan', () => { let plan; @@ -36,4 +41,14 @@ test.describe('Plan', () => { test('Displays all plan events', async ({ page }) => { await assertPlanActivities(page, testPlan1, plan.url); }); + + test('Displays plans with ordered swim lanes configuration', async ({ page }) => { + // Add configuration for swim lanes + await addPlanGetInterceptor(page); + // Create the plan + const planWithSwimLanes = await createPlanFromJSON(page, { + json: testPlanWithOrderedLanes + }); + await assertPlanOrderedSwimLanes(page, testPlanWithOrderedLanes, planWithSwimLanes.url); + }); }); diff --git a/src/plugins/plan/components/PlanView.vue b/src/plugins/plan/components/PlanView.vue index cb35a2c6a0..0500d4f96d 100644 --- a/src/plugins/plan/components/PlanView.vue +++ b/src/plugins/plan/components/PlanView.vue @@ -59,7 +59,7 @@ import SwimLane from '@/ui/components/swim-lane/SwimLane.vue'; import TimelineAxis from '../../../ui/components/TimeSystemAxis.vue'; import PlanViewConfiguration from '../PlanViewConfiguration'; -import { getContrastingColor, getValidatedData } from '../util'; +import { getContrastingColor, getValidatedData, getValidatedGroups } from '../util'; import ActivityTimeline from './ActivityTimeline.vue'; const PADDING = 1; @@ -416,7 +416,7 @@ export default { return currentRow || SWIMLANE_PADDING; }, generateActivities() { - const groupNames = Object.keys(this.planData); + const groupNames = getValidatedGroups(this.domainObject, this.planData); if (!groupNames.length) { return; @@ -430,6 +430,10 @@ export default { let currentRow = 0; const rawActivities = this.planData[groupName]; + if (rawActivities === undefined) { + return; + } + rawActivities.forEach((rawActivity) => { if (!this.isActivityInBounds(rawActivity)) { return; diff --git a/src/plugins/plan/util.js b/src/plugins/plan/util.js index 3914f40b8b..97aacc6507 100644 --- a/src/plugins/plan/util.js +++ b/src/plugins/plan/util.js @@ -22,17 +22,7 @@ export function getValidatedData(domainObject) { const sourceMap = domainObject.sourceMap; - const body = domainObject.selectFile?.body; - let json = {}; - if (typeof body === 'string') { - try { - json = JSON.parse(body); - } catch (e) { - return json; - } - } else if (body !== undefined) { - json = body; - } + const json = getObjectJson(domainObject); if ( sourceMap !== undefined && @@ -69,6 +59,47 @@ export function getValidatedData(domainObject) { } } +function getObjectJson(domainObject) { + const body = domainObject.selectFile?.body; + let json = {}; + if (typeof body === 'string') { + try { + json = JSON.parse(body); + } catch (e) { + return json; + } + } else if (body !== undefined) { + json = body; + } + + return json; +} + +export function getValidatedGroups(domainObject, planData) { + let orderedGroupNames; + const sourceMap = domainObject.sourceMap; + const json = getObjectJson(domainObject); + if (sourceMap?.orderedGroups) { + const groups = json[sourceMap.orderedGroups]; + if (groups.length && typeof groups[0] === 'object') { + //if groups is a list of objects, then get the name property from each group object. + const groupsWithNames = groups.filter( + (groupObj) => groupObj.name !== undefined && groupObj.name !== '' + ); + orderedGroupNames = groupsWithNames.map((groupObj) => groupObj.name); + } else { + // Otherwise, groups is likely a list of names, so use that. + orderedGroupNames = groups; + } + } + + if (orderedGroupNames === undefined) { + orderedGroupNames = Object.keys(planData); + } + + return orderedGroupNames; +} + export function getContrastingColor(hexColor) { function cutHex(h, start, end) { const hStr = h.charAt(0) === '#' ? h.substring(1, 7) : h; diff --git a/src/plugins/timeline/TimelineViewLayout.vue b/src/plugins/timeline/TimelineViewLayout.vue index 0c1d62a76f..6750b27f55 100644 --- a/src/plugins/timeline/TimelineViewLayout.vue +++ b/src/plugins/timeline/TimelineViewLayout.vue @@ -53,7 +53,7 @@ import _ from 'lodash'; import SwimLane from '@/ui/components/swim-lane/SwimLane.vue'; import TimelineAxis from '../../ui/components/TimeSystemAxis.vue'; -import { getValidatedData } from '../plan/util'; +import { getValidatedData, getValidatedGroups } from '../plan/util'; import TimelineObjectView from './TimelineObjectView.vue'; const unknownObjectType = { @@ -108,7 +108,8 @@ export default { let objectPath = [domainObject].concat(this.objectPath.slice()); let rowCount = 0; if (domainObject.type === 'plan') { - rowCount = Object.keys(getValidatedData(domainObject)).length; + const planData = getValidatedData(domainObject); + rowCount = getValidatedGroups(domainObject, planData).length; } else if (domainObject.type === 'gantt-chart') { rowCount = Object.keys(domainObject.configuration.swimlaneVisibility).length; } diff --git a/src/plugins/timelist/TimelistComponent.vue b/src/plugins/timelist/TimelistComponent.vue index 66a22be871..db6524789e 100644 --- a/src/plugins/timelist/TimelistComponent.vue +++ b/src/plugins/timelist/TimelistComponent.vue @@ -38,7 +38,7 @@ import { v4 as uuid } from 'uuid'; import { TIME_CONTEXT_EVENTS } from '../../api/time/constants'; import ListView from '../../ui/components/List/ListView.vue'; import { getPreciseDuration } from '../../utils/duration'; -import { getValidatedData } from '../plan/util'; +import { getValidatedData, getValidatedGroups } from '../plan/util'; import { SORT_ORDER_OPTIONS } from './constants'; const SCROLL_TIMEOUT = 10000; @@ -283,10 +283,13 @@ export default { this.planData = getValidatedData(domainObject); }, listActivities() { - let groups = Object.keys(this.planData); + let groups = getValidatedGroups(this.domainObject, this.planData); let activities = []; groups.forEach((key) => { + if (this.planData[key] === undefined) { + return; + } // Create new objects so Vue 3 can detect any changes activities = activities.concat(JSON.parse(JSON.stringify(this.planData[key]))); });