diff --git a/.cspell.json b/.cspell.json index bc37d13deba..5ae5a0df545 100644 --- a/.cspell.json +++ b/.cspell.json @@ -495,7 +495,8 @@ "Andale", "unnnormalized", "checksnapshots", - "specced" + "specced", + "countup" ], "dictionaries": ["npm", "softwareTerms", "node", "html", "css", "bash", "en_US"], "ignorePaths": [ diff --git a/e2e/tests/functional/planning/timelist.e2e.spec.js b/e2e/tests/functional/planning/timelist.e2e.spec.js index b1f6ee02850..4f53d5d8c55 100644 --- a/e2e/tests/functional/planning/timelist.e2e.spec.js +++ b/e2e/tests/functional/planning/timelist.e2e.spec.js @@ -43,7 +43,7 @@ const TIME_TO_FROM_COLUMN = 2; // eslint-disable-next-line no-unused-vars const ACTIVITY_COLUMN = 3; const HEADER_ROW = 0; -const NUM_COLUMNS = 4; +const NUM_COLUMNS = 5; test.describe('Time List', () => { 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 ({ @@ -109,6 +109,70 @@ test.describe('Time List', () => { }); }); +test("View a timelist in expanded view, verify all the activities are displayed and selecting an activity shows it's properties", 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 () => { + await createPlanFromJSON(page, { + name: 'Test Plan', + json: examplePlanSmall1, + parent: timelist.uuid + }); + + // Ensure that all activities are shown in the expanded view + 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( + `${timelist.url}?tc.mode=fixed&tc.startBound=${startBound}&tc.endBound=${endBound}&tc.timeSystem=utc&view=timelist.view` + ); + + // Change the object to edit mode + await page.getByRole('button', { name: 'Edit Object' }).click(); + + // Find the display properties section in the inspector + await page.getByRole('tab', { name: 'View Properties' }).click(); + // Switch to expanded view and save the setting + await page.getByLabel('Display Style').selectOption({ label: 'Expanded' }); + + // Click on the "Save" button + await page.getByRole('button', { name: 'Save' }).click(); + await page.getByRole('listitem', { name: 'Save and Finish Editing' }).click(); + + // Verify all events are displayed + const eventCount = await page.getByRole('row').count(); + await expect(eventCount).toEqual(firstGroupItems.length); + }); + + 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' + ); + }); +}); + /** * The regular expression used to parse the countdown string. * Some examples of valid Countdown strings: diff --git a/src/plugins/plan/inspector/components/PlanActivitiesView.vue b/src/plugins/plan/inspector/components/PlanActivitiesView.vue index 002023adc24..22c473da0e3 100644 --- a/src/plugins/plan/inspector/components/PlanActivitiesView.vue +++ b/src/plugins/plan/inspector/components/PlanActivitiesView.vue @@ -129,7 +129,7 @@ export default { this.selectedActivities = []; selection.forEach((selectionItem) => { if (selectionItem[0].context.type === 'activity') { - const activity = selectionItem[0].context.activity; + const activity = { ...selectionItem[0].context.activity }; if (activity) { activity.key = activity.id ?? activity.name; this.selectedActivities.push(activity); diff --git a/src/plugins/plan/inspector/components/PlanActivityStatusView.vue b/src/plugins/plan/inspector/components/PlanActivityStatusView.vue index f2ccffee6c1..2fe7188da4f 100644 --- a/src/plugins/plan/inspector/components/PlanActivityStatusView.vue +++ b/src/plugins/plan/inspector/components/PlanActivityStatusView.vue @@ -67,8 +67,8 @@ const activityStates = [ label: 'Aborted' }, { - key: 'cancelled', - label: 'Cancelled' + key: 'skipped', + label: 'Skipped' } ]; diff --git a/src/plugins/timelist/ExpandedViewItem.vue b/src/plugins/timelist/ExpandedViewItem.vue new file mode 100644 index 00000000000..22eeaed04f0 --- /dev/null +++ b/src/plugins/timelist/ExpandedViewItem.vue @@ -0,0 +1,281 @@ + + + + diff --git a/src/plugins/timelist/TimelistComponent.vue b/src/plugins/timelist/TimelistComponent.vue index d86f5413a75..5b1f2b4d21f 100644 --- a/src/plugins/timelist/TimelistComponent.vue +++ b/src/plugins/timelist/TimelistComponent.vue @@ -21,14 +21,55 @@ --> @@ -37,27 +78,36 @@ import _ from 'lodash'; import { v4 as uuid } from 'uuid'; import { TIME_CONTEXT_EVENTS } from '../../api/time/constants.js'; -import ListView from '../../ui/components/List/ListView.vue'; +import ListHeader from '../../ui/components/List/ListHeader.vue'; +import ListItem from '../../ui/components/List/ListItem.vue'; import { getPreciseDuration } from '../../utils/duration.js'; import { getFilteredValues, getValidatedData, getValidatedGroups } from '../plan/util.js'; import { SORT_ORDER_OPTIONS } from './constants.js'; +import ExpandedViewItem from './ExpandedViewItem.vue'; const SCROLL_TIMEOUT = 10000; const TIME_FORMAT = 'YYYY-MM-DD HH:mm:ss'; +const SAME_DAY_PRECISION_SECONDS = '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, isSortable: true, property: 'start', name: 'Start Time', - format: function (value, object, key, openmct) { + format: function (value, object, key, openmct, options = {}) { const timeFormat = openmct.time.timeSystem().timeFormat; const timeFormatter = openmct.telemetry.getValueFormatter({ format: timeFormat }).formatter; - return timeFormatter.format(value, TIME_FORMAT); + if (options.skipDateForToday) { + return timeFormatter.format(value, SAME_DAY_PRECISION_SECONDS); + } else { + return timeFormatter.format(value, TIME_FORMAT); + } } }, { @@ -65,25 +115,34 @@ const headerItems = [ isSortable: true, property: 'end', name: 'End Time', - format: function (value, object, key, openmct) { + format: function (value, object, key, openmct, options = {}) { const timeFormat = openmct.time.timeSystem().timeFormat; const timeFormatter = openmct.telemetry.getValueFormatter({ format: timeFormat }).formatter; - return timeFormatter.format(value, TIME_FORMAT); + if (options.skipDateForToday) { + return timeFormatter.format(value, SAME_DAY_PRECISION_SECONDS); + } else { + return timeFormatter.format(value, TIME_FORMAT); + } } }, { defaultDirection: false, - property: 'duration', + property: 'countdown', name: 'Time To/From', - format: function (value) { + format: function (value, object, key, openmct, options = {}) { let result; if (value < 0) { - result = `+${getPreciseDuration(Math.abs(value), { + const prefix = options.skipPrefix ? '' : '+'; + result = `${prefix}${getPreciseDuration(Math.abs(value), { excludeMilliSeconds: true, useDayFormat: true })}`; } else if (value > 0) { - result = `-${getPreciseDuration(value, { excludeMilliSeconds: true, useDayFormat: true })}`; + const prefix = options.skipPrefix ? '' : '+'; + result = `${prefix}${getPreciseDuration(value, { + excludeMilliSeconds: true, + useDayFormat: true + })}`; } else { result = 'Now'; } @@ -91,6 +150,14 @@ const headerItems = [ return result; } }, + { + defaultDirection: false, + property: 'duration', + name: 'Duration', + format: function (value, object, key, openmct) { + return `${getPreciseDuration(value, { excludeMilliSeconds: true, useDayFormat: true })}`; + } + }, { defaultDirection: true, property: 'name', @@ -105,7 +172,9 @@ const defaultSort = { export default { components: { - ListView + ExpandedViewItem, + ListHeader, + ListItem }, inject: ['openmct', 'domainObject', 'path', 'composition'], data() { @@ -114,18 +183,42 @@ export default { viewBounds: undefined, height: 0, planActivities: [], + groups: [], headerItems: headerItems, - defaultSort: defaultSort + defaultSort: defaultSort, + isExpanded: false, + persistedActivityStates: {}, + sortedItems: [] }; }, - mounted() { - this.isEditing = this.openmct.editor.isEditing(); + computed: { + listTypeClass() { + if (this.isExpanded) { + return 'c-timelist c-timelist--large'; + } + return 'c-timelist'; + }, + itemProperties() { + return this.headerItems.map((headerItem) => { + return { + key: headerItem.property, + format: headerItem.format + }; + }); + } + }, + created() { this.updateTimestamp = _.throttle(this.updateTimestamp, 1000); + this.deferAutoScroll = _.debounce(this.deferAutoScroll, 500); this.setTimeContext(); this.timestamp = this.timeContext.now(); + }, + mounted() { + this.isEditing = this.openmct.editor.isEditing(); this.getPlanDataAndSetConfig(this.domainObject); + this.getActivityStates(); this.unlisten = this.openmct.objects.observe( this.domainObject, @@ -145,7 +238,6 @@ export default { this.openmct.editor.on('isEditing', this.setEditState); - this.deferAutoScroll = _.debounce(this.deferAutoScroll, 500); this.$el.parentElement.addEventListener('scroll', this.deferAutoScroll, true); if (this.composition) { @@ -165,6 +257,14 @@ export default { this.unlistenConfig(); } + if (this.stopObservingPlan) { + this.stopObservingPlan(); + } + + if (this.stopObservingActivityStatesObject) { + this.stopObservingActivityStatesObject(); + } + if (this.removeStatusListener) { this.removeStatusListener(); } @@ -204,6 +304,18 @@ export default { sourceMap: this.domainObject.sourceMap }); }, + async getActivityStates() { + const activityStatesObject = await this.openmct.objects.get('activity-states'); + this.setActivityStates(activityStatesObject); + this.stopObservingActivityStatesObject = this.openmct.objects.observe( + activityStatesObject, + '*', + this.setActivityStates + ); + }, + setActivityStates(activityStatesObject) { + this.persistedActivityStates = activityStatesObject.activities; + }, getPlanDataAndSetConfig(mutatedObject) { this.getPlanData(mutatedObject); this.setViewFromConfig(mutatedObject.configuration); @@ -215,6 +327,7 @@ export default { this.hideAll = false; } else { this.setSort(); + this.isExpanded = configuration.isExpanded; } this.listActivities(); }, @@ -232,7 +345,6 @@ export default { }, addItem(domainObject) { this.planObjects = [domainObject]; - this.resetPlanData(); if (domainObject.type === 'plan') { this.getPlanDataAndSetConfig({ ...this.domainObject, @@ -240,15 +352,28 @@ export default { sourceMap: domainObject.sourceMap }); } + //listen for changes to the plan + if (this.stopObservingPlan) { + this.stopObservingPlan(); + } + this.stopObservingPlan = this.openmct.objects.observe( + this.planObjects[0], + '*', + this.handlePlanChange + ); }, - addToComposition(telemetryObject) { + handlePlanChange(planObject) { + this.getPlanData(planObject); + this.listActivities(); + }, + addToComposition(planObject) { if (this.planObjects.length > 0) { - this.confirmReplacePlan(telemetryObject); + this.confirmReplacePlan(planObject); } else { - this.addItem(telemetryObject); + this.addItem(planObject); } }, - confirmReplacePlan(telemetryObject) { + confirmReplacePlan(planObject) { const dialog = this.openmct.overlays.dialog({ iconClass: 'alert', message: 'This action will replace the current plan. Do you want to continue?', @@ -259,22 +384,22 @@ export default { callback: () => { const oldTelemetryObject = this.planObjects[0]; this.removeFromComposition(oldTelemetryObject); - this.addItem(telemetryObject); + this.addItem(planObject); dialog.dismiss(); } }, { label: 'Cancel', callback: () => { - this.removeFromComposition(telemetryObject); + this.removeFromComposition(planObject); dialog.dismiss(); } } ] }); }, - removeFromComposition(telemetryObject) { - this.composition.remove(telemetryObject); + removeFromComposition(planObject) { + this.composition.remove(planObject); }, removeItem() { this.planObjects = []; @@ -282,25 +407,28 @@ export default { }, resetPlanData() { this.planData = {}; + this.groups = []; + this.planActivities = []; + this.sortedItems = []; }, getPlanData(domainObject) { + this.resetPlanData(); this.planData = getValidatedData(domainObject); - }, - listActivities() { - let groups = getValidatedGroups(this.domainObject, this.planData); - let activities = []; - - groups.forEach((key) => { + this.groups = getValidatedGroups(this.domainObject, this.planData); + this.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]))); + this.planActivities.push(...this.planData[key]); }); - // filter activities first, then sort by start time - activities = activities.filter(this.filterActivities).sort(this.sortByStartTime); - activities = this.applyStyles(activities); - this.planActivities = [...activities]; + }, + + listActivities() { + // filter activities first, then sort + const filteredItems = this.planActivities.filter(this.filterActivities); + const sortedItems = this.sortItems(filteredItems); + this.sortedItems = this.applyStyles(sortedItems); //We need to wait for the next tick since we need the height of the row from the DOM this.$nextTick(this.setScrollTop); }, @@ -405,11 +533,13 @@ export default { activity.key = uuid(); } + activity.duration = activity.end - activity.start; + 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; + activity.countdown = activity.end - this.timestamp; } else { - activity.duration = activity.start - this.timestamp; + activity.countdown = activity.start - this.timestamp; } return activity; @@ -452,7 +582,7 @@ export default { }, setScrollTop() { //The view isn't ready yet - if (!this.$el.parentElement) { + if (!this.$el.parentElement || this.isExpanded) { return; } @@ -530,11 +660,12 @@ export default { defaultDirection: direction }; }, - sortByStartTime(a, b) { - const numA = parseInt(a.start, 10); - const numB = parseInt(b.start, 10); - - return numA - numB; + sortItems(activities) { + let sortedItems = _.sortBy(activities, this.defaultSort.property); + if (!this.defaultSort.defaultDirection) { + sortedItems = sortedItems.reverse(); + } + return sortedItems; }, setStatus(status) { this.status = status; @@ -543,6 +674,17 @@ export default { this.isEditing = isEditing; this.setViewFromConfig(this.domainObject.configuration); }, + sort(data) { + const property = data.property; + const direction = data.direction; + + if (this.defaultSort.property === property) { + this.defaultSort.defaultDirection = !this.defaultSort.defaultDirection; + } else { + this.defaultSort.property = property; + this.defaultSort.defaultDirection = direction; + } + }, setSelectionForActivity(activity, element) { const multiSelect = false; diff --git a/src/plugins/timelist/constants.js b/src/plugins/timelist/constants.js index 0849e34bcae..4146d130e76 100644 --- a/src/plugins/timelist/constants.js +++ b/src/plugins/timelist/constants.js @@ -22,3 +22,7 @@ export const SORT_ORDER_OPTIONS = [ ]; export const TIMELIST_TYPE = 'timelist'; + +export const CURRENT_CSS_SUFFIX = '--is-current'; +export const PAST_CSS_SUFFIX = '--is-past'; +export const FUTURE_CSS_SUFFIX = '--is-future'; diff --git a/src/plugins/timelist/inspector/EventProperties.vue b/src/plugins/timelist/inspector/EventProperties.vue index 2a3d2d18e31..2865dcef777 100644 --- a/src/plugins/timelist/inspector/EventProperties.vue +++ b/src/plugins/timelist/inspector/EventProperties.vue @@ -22,8 +22,8 @@ diff --git a/src/plugins/timelist/inspector/FilteringComponent.vue b/src/plugins/timelist/inspector/FilteringComponent.vue index bf8c34a2a31..e5be1bd8a25 100644 --- a/src/plugins/timelist/inspector/FilteringComponent.vue +++ b/src/plugins/timelist/inspector/FilteringComponent.vue @@ -41,7 +41,7 @@ >
-