From b99e9229b92ff9da1be7e37a9f5c2bf7ecbad7d4 Mon Sep 17 00:00:00 2001 From: Ivy Deliz Date: Tue, 12 Nov 2024 13:02:25 -0700 Subject: [PATCH] Add "scroll to activity" to activity directive table context menu (#1433) (#1538) Add "Scroll to Activity" to activity directive table context menu (#1433) --- .../activity/ActivityDirectivesTable.svelte | 26 +++++++++- .../ActivityDirectivesTablePanel.svelte | 15 +++++- .../timeline/TimelineViewControls.svelte | 19 ++----- .../ui/DataGrid/BulkActionDataGrid.svelte | 4 +- src/utilities/timeline.test.ts | 52 ++++++++++++++++++- src/utilities/timeline.ts | 24 +++++++++ 6 files changed, 122 insertions(+), 18 deletions(-) diff --git a/src/components/activity/ActivityDirectivesTable.svelte b/src/components/activity/ActivityDirectivesTable.svelte index 0e9647c9b4..8008fe2ce4 100644 --- a/src/components/activity/ActivityDirectivesTable.svelte +++ b/src/components/activity/ActivityDirectivesTable.svelte @@ -14,6 +14,9 @@ import BulkActionDataGrid from '../ui/DataGrid/BulkActionDataGrid.svelte'; import type DataGrid from '../ui/DataGrid/DataGrid.svelte'; import DataGridActions from '../ui/DataGrid/DataGridActions.svelte'; + import ContextMenuItem from '../context-menu/ContextMenuItem.svelte'; + import ContextMenuSeparator from '../context-menu/ContextMenuSeparator.svelte'; + import { createEventDispatcher } from 'svelte'; export let activityDirectives: ActivityDirective[] = []; export let activityDirectiveErrorRollupsMap: Record | undefined = undefined; @@ -22,10 +25,15 @@ export let dataGrid: DataGrid | undefined = undefined; export let plan: Plan | null; export let selectedActivityDirectiveId: ActivityDirectiveId | null = null; + export let bulkSelectedActivityDirectiveIds: ActivityDirectiveId[] = []; export let planReadOnly: boolean = false; export let user: User | null; export let filterExpression: string = ''; + const dispatch = createEventDispatcher<{ + scrollTimelineToTime: number; + }>(); + type ActivityDirectiveWithErrorCounts = ActivityDirective & { errorCounts?: ActivityErrorCounts }; type CellRendererParams = { deleteActivityDirective: (activity: ActivityDirective) => void; @@ -129,11 +137,20 @@ function getRowId(activityDirective: ActivityDirective): ActivityDirectiveId { return activityDirective.id; } + + function scrollTimelineToActivityDirective() { + const directiveId = bulkSelectedActivityDirectiveIds.length > 0 && bulkSelectedActivityDirectiveIds[0]; + const directive = activityDirectives.find(item => item.id === directiveId) ?? null; + if (directive?.start_time_ms !== undefined && directive?.start_time_ms !== null) { + dispatch('scrollTimelineToTime', directive.start_time_ms); + } + } +> + + {#if bulkSelectedActivityDirectiveIds.length === 1} + Scroll to Activity + + {/if} + + diff --git a/src/components/activity/ActivityDirectivesTablePanel.svelte b/src/components/activity/ActivityDirectivesTablePanel.svelte index 9dfc2087a3..a430ff440a 100644 --- a/src/components/activity/ActivityDirectivesTablePanel.svelte +++ b/src/components/activity/ActivityDirectivesTablePanel.svelte @@ -14,7 +14,7 @@ import { InvalidDate } from '../../constants/time'; import { activityDirectivesMap, selectActivity, selectedActivityDirectiveId } from '../../stores/activities'; import { activityErrorRollupsMap } from '../../stores/errors'; - import { plan, planReadOnly } from '../../stores/plan'; + import { maxTimeRange, plan, planReadOnly, viewTimeRange } from '../../stores/plan'; import { plugins } from '../../stores/plugins'; import { view, viewTogglePanel, viewUpdateActivityDirectivesTable } from '../../stores/views'; import type { ActivityDirective } from '../../types/activity'; @@ -30,6 +30,8 @@ import Panel from '../ui/Panel.svelte'; import ActivityDirectivesTable from './ActivityDirectivesTable.svelte'; import ActivityTableMenu from './ActivityTableMenu.svelte'; + import { get } from 'svelte/store'; + import { getTimeRangeAroundTime } from '../../utilities/timeline'; export let gridSection: ViewGridSection; export let user: User | null; @@ -364,6 +366,16 @@ viewUpdateActivityDirectivesTable({ autoSizeColumns: 'off' }); } } + + function scrollTimelineToTime({ detail }: CustomEvent) { + const currentTimeRange = get(viewTimeRange); + const centeredTimeRange = getTimeRangeAroundTime( + detail, + currentTimeRange.end - currentTimeRange.start, + get(maxTimeRange), + ); + viewTimeRange.set(centeredTimeRange); + } @@ -417,6 +429,7 @@ on:gridSizeChanged={onGridSizeChangedDebounced} on:rowDoubleClicked={onRowDoubleClicked} on:selectionChanged={onSelectionChanged} + on:scrollTimelineToTime={scrollTimelineToTime} /> diff --git a/src/components/timeline/TimelineViewControls.svelte b/src/components/timeline/TimelineViewControls.svelte index 903ff1fb15..65dd3c4a43 100644 --- a/src/components/timeline/TimelineViewControls.svelte +++ b/src/components/timeline/TimelineViewControls.svelte @@ -27,13 +27,8 @@ } from '../../stores/simulation'; import { timelineInteractionMode, timelineLockStatus, viewIsModified } from '../../stores/views'; import type { TimeRange } from '../../types/timeline'; - import { - getActivityDirectiveStartTimeMs, - getDoyTimeFromInterval, - getIntervalInMs, - getUnixEpochTime, - } from '../../utilities/time'; - import { TimelineLockStatus } from '../../utilities/timeline'; + import { getActivityDirectiveStartTimeMs, getDoyTimeFromInterval, getUnixEpochTime } from '../../utilities/time'; + import { getTimeRangeAroundTime, TimelineLockStatus } from '../../utilities/timeline'; import { showFailureToast, showSuccessToast } from '../../utilities/toast'; import { tooltip } from '../../utilities/tooltip'; import Input from '../form/Input.svelte'; @@ -217,13 +212,9 @@ function scrollToSelection() { const time = getSelectionTime(); if (!isNaN(time) && (time < viewTimeRange.start || time > viewTimeRange.end)) { - const midSpan = time + getIntervalInMs($selectedSpan?.duration) / 2; - const start = Math.max(maxTimeRange.start, midSpan - viewDuration / 2); - const end = Math.min(maxTimeRange.end, midSpan + viewDuration / 2); - dispatch('viewTimeRangeChanged', { - end, - start, - }); + const currentTimeRangeSpan = viewTimeRange.end - viewTimeRange.start; + const centeredTimeRange = getTimeRangeAroundTime(time, currentTimeRangeSpan, maxTimeRange); + dispatch('viewTimeRangeChanged', centeredTimeRange); } } diff --git a/src/components/ui/DataGrid/BulkActionDataGrid.svelte b/src/components/ui/DataGrid/BulkActionDataGrid.svelte index 1c46d4fa69..13efd9996e 100644 --- a/src/components/ui/DataGrid/BulkActionDataGrid.svelte +++ b/src/components/ui/DataGrid/BulkActionDataGrid.svelte @@ -33,6 +33,7 @@ export let pluralItemDisplayText: string = ''; export let scrollToSelection: boolean = false; export let selectedItemId: RowId | null = null; + export let selectedItemIds: RowId[] = []; export let showContextMenu: boolean = true; export let singleItemDisplayText: string = ''; export let suppressDragLeaveHidesColumns: boolean = true; @@ -48,7 +49,6 @@ let isFiltered: boolean = false; let deletePermission: boolean = true; - let selectedItemIds: RowId[] = []; $: if (typeof hasDeletePermission === 'function' && user) { if (selectedItemIds.length > 0) { @@ -172,6 +172,8 @@ > {#if showContextMenu} + + Bulk Actions Select All {isFiltered ? 'Visible ' : ''}{pluralItemDisplayText} diff --git a/src/utilities/timeline.test.ts b/src/utilities/timeline.test.ts index c9ca5ae55a..61852b39d4 100644 --- a/src/utilities/timeline.test.ts +++ b/src/utilities/timeline.test.ts @@ -1,5 +1,5 @@ import { keyBy } from 'lodash-es'; -import { expect, test } from 'vitest'; +import { describe, expect, test } from 'vitest'; import { ViewDiscreteLayerColorPresets, ViewLineLayerColorPresets, @@ -25,6 +25,7 @@ import { externalEventInView, filterResourcesByLayer, generateDiscreteTreeUtil, + getTimeRangeAroundTime, getUniqueColorForActivityLayer, getUniqueColorForLineLayer, getUniqueColorSchemeForXRangeLayer, @@ -36,6 +37,7 @@ import { paginateNodes, spanInView, } from './timeline'; +import { convertUTCToMs } from './time'; const testSpans: Span[] = [ generateSpan({ @@ -1185,3 +1187,51 @@ test('getUniqueColorSchemeForXRangeLayer', () => { const existingScheme = (row2.layers[0] as XRangeLayer).colorScheme; expect(getUniqueColorSchemeForXRangeLayer(row2)).not.toBe(existingScheme); }); + +describe('getTimeRangeAroundTime', () => { + const hourInMs = 3600000; + const TEST_TIME = convertUTCToMs(`2024-10-14T16:06:00Z`); + + test('Should return TimeRange centered on time with +/- 1 day, unbounded', () => { + const timeRange = getTimeRangeAroundTime(TEST_TIME, 48 * hourInMs); + expect(timeRange).toStrictEqual({ + end: convertUTCToMs(`2024-10-15T16:06:00Z`), //1 day after TEST_TIME + start: convertUTCToMs(`2024-10-13T16:06:00Z`), //1 day before TEST_TIME + }); + expect(timeRange.end - timeRange.start).toBe(48 * hourInMs); + }); + + test('Should return TimeRange centered on time with +/- 1 hour, unbounded', () => { + const timeRange = getTimeRangeAroundTime(TEST_TIME, 2 * hourInMs); + expect(timeRange).toStrictEqual({ + end: convertUTCToMs(`2024-10-14T17:06:00Z`), //1 hour after TEST_TIME + start: convertUTCToMs(`2024-10-14T15:06:00Z`), //1 hour before TEST_TIME + }); + expect(timeRange.end - timeRange.start).toBe(2 * hourInMs); + }); + + test('Should return TimeRange with 48 hour span with time in it, bounded by the start', () => { + const timeRange = getTimeRangeAroundTime(TEST_TIME, 48 * hourInMs, { + end: convertUTCToMs(`2024-10-20T00:00:00Z`), + start: convertUTCToMs(`2024-10-14T00:00:00Z`), + }); + + expect(timeRange).toStrictEqual({ + end: convertUTCToMs(`2024-10-16T00:00:00Z`), //bounded start + 48 hours + start: convertUTCToMs(`2024-10-14T00:00:00Z`), //bounded start + }); + expect(timeRange.end - timeRange.start).toBe(48 * hourInMs); + }); + + test('Should return TimeRange with 48 hour span with time in it, bounded by the end', () => { + const timeRange = getTimeRangeAroundTime(TEST_TIME, 48 * hourInMs, { + end: convertUTCToMs(`2024-10-14T11:59:59Z`), + start: convertUTCToMs(`2024-10-10T00:00:00Z`), + }); + expect(timeRange).toStrictEqual({ + end: convertUTCToMs(`2024-10-14T11:59:59Z`), //bounded end + start: convertUTCToMs(`2024-10-12T11:59:59Z`), //bounded end - 48 hours + }); + expect(timeRange.end - timeRange.start).toBe(48 * hourInMs); + }); +}); diff --git a/src/utilities/timeline.ts b/src/utilities/timeline.ts index d46d5d2e1e..f1e18201f7 100644 --- a/src/utilities/timeline.ts +++ b/src/utilities/timeline.ts @@ -428,6 +428,30 @@ export function getUniqueColorForLineLayer(row?: Row): string { return color; } +export function getTimeRangeAroundTime(time: number, timeRangeSpan: number, maxTimeRange?: TimeRange): TimeRange { + const padding = timeRangeSpan / 2; + let start = time - padding; + let end = time + padding; + + // optional maxTimeRange for bounding the results bounds + if (maxTimeRange !== undefined && maxTimeRange !== null) { + //span is larger than the max time range, well it can't get larger than that + if (timeRangeSpan >= maxTimeRange.end - maxTimeRange.start) { + return maxTimeRange; + } + + //bound the start or end of the TimeRange, but keep the timeRangeSpan the same + if (time - padding < maxTimeRange.start) { + start = maxTimeRange.start; + end = maxTimeRange.start + timeRangeSpan; + } else if (time + padding > maxTimeRange.end) { + start = maxTimeRange.end - timeRangeSpan; + end = maxTimeRange.end; + } + } + return { end, start }; +} + /** * Returns a new vertical guide */