diff --git a/e2e/tests/functional/planning/timelist.e2e.spec.js b/e2e/tests/functional/planning/timelist.e2e.spec.js new file mode 100644 index 0000000000..54f65019f6 --- /dev/null +++ b/e2e/tests/functional/planning/timelist.e2e.spec.js @@ -0,0 +1,122 @@ +/***************************************************************************** + * 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. + *****************************************************************************/ + +const { test, expect } = require('../../../pluginFixtures'); +const { createDomainObjectWithDefaults, createPlanFromJSON } = require('../../../appActions'); + +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 ({ + page + }) => { + // Goto baseURL + await page.goto('./', { waitUntil: 'domcontentloaded' }); + + const timelist = await test.step('Create a Time List', async () => { + const createdTimeList = await createDomainObjectWithDefaults(page, { type: 'Time List' }); + const objectName = await page.locator('.l-browse-bar__object-name').innerText(); + expect(objectName).toBe(createdTimeList.name); + + return createdTimeList; + }); + + await test.step('Create a Plan and add it to the timelist', async () => { + const createdPlan = await createPlanFromJSON(page, { + name: 'Test Plan', + json: testPlan + }); + + await page.goto(timelist.url); + // Expand the tree to show the plan + await page.click("button[title='Show selected item in tree']"); + await page.dragAndDrop(`role=treeitem[name=/${createdPlan.name}/]`, '.c-object-view'); + await page.click("button[title='Save']"); + await page.click("li[title='Save and Finish Editing']"); + const startBound = testPlan.TEST_GROUP[0].start; + const endBound = testPlan.TEST_GROUP[testPlan.TEST_GROUP.length - 1].end; + + // Switch to fixed time mode with all plan events within the bounds + await page.goto( + `${timelist.url}?tc.mode=fixed&tc.startBound=${startBound}&tc.endBound=${endBound}&tc.timeSystem=utc&view=timelist.view` + ); + + // Verify all events are displayed + const eventCount = await page.locator('.js-list-item').count(); + expect(eventCount).toEqual(testPlan.TEST_GROUP.length); + }); + + await test.step('Does not show milliseconds in times', async () => { + // Get the first activity + const row = await page.locator('.js-list-item').first(); + // Verify that none fo the times have milliseconds displayed. + // Example: 2024-11-17T16:00:00Z is correct and 2024-11-17T16:00:00.000Z is wrong + + await expect(row.locator('.--start')).not.toContainText('.'); + await expect(row.locator('.--end')).not.toContainText('.'); + await expect(row.locator('.--duration')).not.toContainText('.'); + }); + }); +}); diff --git a/src/plugins/timelist/Timelist.vue b/src/plugins/timelist/Timelist.vue index 54c392571c..328f46a456 100644 --- a/src/plugins/timelist/Timelist.vue +++ b/src/plugins/timelist/Timelist.vue @@ -41,8 +41,10 @@ import moment from 'moment'; import { v4 as uuid } from 'uuid'; const SCROLL_TIMEOUT = 10000; -const ROW_HEIGHT = 30; -const TIME_FORMAT = 'YYYY-MM-DD HH:mm:ss:SSS'; +const TIME_FORMAT = 'YYYY-MM-DD HH:mm:ss'; +const CURRENT_CSS_SUFFIX = '--is-current'; +const PAST_CSS_SUFFIX = '--is-past'; +const FUTURE_CSS_SUFFIX = '--is-future'; const headerItems = [ { defaultDirection: true, @@ -79,9 +81,9 @@ const headerItems = [ format: function (value) { let result; if (value < 0) { - result = `-${getPreciseDuration(Math.abs(value))}`; + result = `+${getPreciseDuration(Math.abs(value), true)}`; } else if (value > 0) { - result = `+${getPreciseDuration(value)}`; + result = `-${getPreciseDuration(value, true)}`; } else { result = 'Now'; } @@ -360,11 +362,12 @@ export default { groups.forEach((key) => { activities = activities.concat(this.planData[key]); }); - activities = activities.filter(this.filterActivities); + // filter activities first, then sort by start time + activities = activities.filter(this.filterActivities).sort(this.sortByStartTime); activities = this.applyStyles(activities); - this.setScrollTop(); - // sort by start time - this.planActivities = activities.sort(this.sortByStartTime); + this.planActivities = activities; + //We need to wait for the next tick since we need the height of the row from the DOM + this.$nextTick(this.setScrollTop); }, updateTimeStampAndListActivities(time) { this.timestamp = time; @@ -410,30 +413,41 @@ export default { }, applyStyles(activities) { let firstCurrentActivityIndex = -1; + let activityClosestToNowIndex = -1; let currentActivitiesCount = 0; const styledActivities = activities.map((activity, index) => { if (this.timestamp >= activity.start && this.timestamp <= activity.end) { - activity.cssClass = '--is-current'; + activity.cssClass = CURRENT_CSS_SUFFIX; if (firstCurrentActivityIndex < 0) { firstCurrentActivityIndex = index; } currentActivitiesCount = currentActivitiesCount + 1; } else if (this.timestamp < activity.start) { - activity.cssClass = '--is-future'; + activity.cssClass = FUTURE_CSS_SUFFIX; + //the index of the first activity that's greater than the current timestamp + if (activityClosestToNowIndex < 0) { + activityClosestToNowIndex = index; + } } else { - activity.cssClass = '--is-past'; + activity.cssClass = PAST_CSS_SUFFIX; } if (!activity.key) { activity.key = uuid(); } - activity.duration = activity.start - this.timestamp; + if (activity.start < this.timestamp) { + //if the activity start time has passed, display the time to the end of the activity + activity.duration = activity.end - this.timestamp; + } else { + activity.duration = activity.start - this.timestamp; + } return activity; }); + this.activityClosestToNowIndex = activityClosestToNowIndex; this.firstCurrentActivityIndex = firstCurrentActivityIndex; this.currentActivitiesCount = currentActivitiesCount; @@ -451,13 +465,22 @@ export default { } this.firstCurrentActivityIndex = -1; + this.activityClosestToNowIndex = -1; this.currentActivitiesCount = 0; this.$el.parentElement?.scrollTo({ top: 0 }); this.autoScrolled = false; }, setScrollTop() { - //scroll to somewhere mid-way of the current activities - if (this.firstCurrentActivityIndex > -1) { + //The view isn't ready yet + if (!this.$el.parentElement) { + return; + } + + const row = this.$el.querySelector('.js-list-item'); + if (row && this.firstCurrentActivityIndex > -1) { + // scroll to somewhere mid-way of the current activities + const ROW_HEIGHT = row.getBoundingClientRect().height; + if (this.canAutoScroll() === false) { return; } @@ -469,7 +492,22 @@ export default { behavior: 'smooth' }); this.autoScrolled = false; + } else if (row && this.activityClosestToNowIndex > -1) { + // scroll to somewhere close to 'now' + + const ROW_HEIGHT = row.getBoundingClientRect().height; + + if (this.canAutoScroll() === false) { + return; + } + + this.$el.parentElement.scrollTo({ + top: ROW_HEIGHT * (this.activityClosestToNowIndex - 1), + behavior: 'smooth' + }); + this.autoScrolled = false; } else { + // scroll to the top this.resetScroll(); } }, diff --git a/src/plugins/timelist/pluginSpec.js b/src/plugins/timelist/pluginSpec.js index c4633e32f7..e159456641 100644 --- a/src/plugins/timelist/pluginSpec.js +++ b/src/plugins/timelist/pluginSpec.js @@ -219,10 +219,10 @@ describe('the plugin', function () { 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua' ); expect(itemValues[0].innerHTML.trim()).toEqual( - `${moment(twoHoursPast).format('YYYY-MM-DD HH:mm:ss:SSS')}Z` + `${moment(twoHoursPast).format('YYYY-MM-DD HH:mm:ss')}Z` ); expect(itemValues[1].innerHTML.trim()).toEqual( - `${moment(oneHourPast).format('YYYY-MM-DD HH:mm:ss:SSS')}Z` + `${moment(oneHourPast).format('YYYY-MM-DD HH:mm:ss')}Z` ); done(); diff --git a/src/utils/duration.js b/src/utils/duration.js index d7e8fa62f1..5aeafc0ff2 100644 --- a/src/utils/duration.js +++ b/src/utils/duration.js @@ -63,14 +63,16 @@ export function millisecondsToDHMS(numericDuration) { return `${dhms ? '+' : ''} ${dhms}`; } -export function getPreciseDuration(value) { +export function getPreciseDuration(value, excludeMilliSeconds) { const ms = value || 0; - - return [ + const duration = [ toDoubleDigits(Math.floor(normalizeAge(ms / ONE_DAY))), toDoubleDigits(Math.floor(normalizeAge((ms % ONE_DAY) / ONE_HOUR))), toDoubleDigits(Math.floor(normalizeAge((ms % ONE_HOUR) / ONE_MINUTE))), - toDoubleDigits(Math.floor(normalizeAge((ms % ONE_MINUTE) / ONE_SECOND))), - toTripleDigits(Math.floor(normalizeAge(ms % ONE_SECOND))) - ].join(':'); + toDoubleDigits(Math.floor(normalizeAge((ms % ONE_MINUTE) / ONE_SECOND))) + ]; + if (!excludeMilliSeconds) { + duration.push(toTripleDigits(Math.floor(normalizeAge(ms % ONE_SECOND)))); + } + return duration.join(':'); }