diff --git a/src/components/activity/ActivityDirectivesTable.svelte b/src/components/activity/ActivityDirectivesTable.svelte index 8008fe2ce4..25496a6a35 100644 --- a/src/components/activity/ActivityDirectivesTable.svelte +++ b/src/components/activity/ActivityDirectivesTable.svelte @@ -16,7 +16,13 @@ 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'; + import { createEventDispatcher, onDestroy, onMount } from 'svelte'; + import { + canPasteActivityDirectivesFromClipboard, + copyActivityDirectivesToClipboard, + pasteActivityDirectivesFromClipboard, + } from '../../utilities/activities'; + import { isMetaOrCtrlPressed } from '../../utilities/keyboardEvents'; export let activityDirectives: ActivityDirective[] = []; export let activityDirectiveErrorRollupsMap: Record | undefined = undefined; @@ -44,15 +50,22 @@ let activityErrorColumnDef: DataGridColumnDef | null = null; let activityDirectivesWithErrorCounts: ActivityDirectiveWithErrorCounts[] = []; let completeColumnDefs: ColDef[] = columnDefs; + let hasCreatePermission: boolean = false; let hasDeletePermission: boolean = false; let isDeletingDirective: boolean = false; + let showCopyMenu: boolean = true; $: hasDeletePermission = plan !== null ? featurePermissions.activityDirective.canDelete(user, plan) && !planReadOnly : false; + + $: hasCreatePermission = + plan !== null ? featurePermissions.activityDirective.canCreate(user, plan) && !planReadOnly : false; + $: activityDirectivesWithErrorCounts = activityDirectives.map(activityDirective => ({ ...activityDirective, errorCounts: activityDirectiveErrorRollupsMap?.[activityDirective.id]?.errorCounts, })); + $: { activityActionColumnDef = { cellClass: 'action-cell-container', @@ -117,6 +130,29 @@ completeColumnDefs = [activityErrorColumnDef, ...(columnDefs ?? []), activityActionColumnDef]; } + onMount(() => { + document.addEventListener(`keydown`, onKeyDown); + }); + + onDestroy(() => { + document.removeEventListener(`keydown`, onKeyDown); + }); + + function onKeyDown(event: KeyboardEvent) { + if (isMetaOrCtrlPressed(event)) { + if (event.key === 'c') { + const activities = getSelectedActivityDirectives(); + if (activities.length > 0 && plan !== null) { + copyActivityDirectivesToClipboard(plan, activities); + } + } else if (event.key === 'v') { + if (plan !== null) { + pasteActivityDirectivesFromClipboard(plan, user); + } + } + } + } + async function deleteActivityDirective({ id }: ActivityDirective) { if (!isDeletingDirective && plan !== null) { isDeletingDirective = true; @@ -138,6 +174,17 @@ return activityDirective.id; } + function getSelectedActivityDirectives(): ActivityDirective[] { + const directives: ActivityDirective[] = []; + bulkSelectedActivityDirectiveIds.forEach(id => { + const found = activityDirectives.find(item => item.id === id); + if (found !== null && found !== undefined) { + directives.push(found); + } + }); + return directives; + } + function scrollTimelineToActivityDirective() { const directiveId = bulkSelectedActivityDirectiveIds.length > 0 && bulkSelectedActivityDirectiveIds[0]; const directive = activityDirectives.find(item => item.id === directiveId) ?? null; @@ -145,6 +192,18 @@ dispatch('scrollTimelineToTime', directive.start_time_ms); } } + + function copyActivityDirectives({ detail: activities }: CustomEvent) { + plan !== null && copyActivityDirectivesToClipboard(plan, activities); + } + + function canPasteActivityDirectives(): boolean { + return plan !== null && hasCreatePermission && canPasteActivityDirectivesFromClipboard(plan); + } + + function pasteActivityDirectives() { + plan !== null && pasteActivityDirectivesFromClipboard(plan, user); + } Scroll to Activity {/if} + {#if canPasteActivityDirectives()} + Paste + {/if} diff --git a/src/components/timeline/TimelineContextMenu.svelte b/src/components/timeline/TimelineContextMenu.svelte index abe5de9608..15c5d25e5d 100644 --- a/src/components/timeline/TimelineContextMenu.svelte +++ b/src/components/timeline/TimelineContextMenu.svelte @@ -21,7 +21,13 @@ TimeRange, VerticalGuide, } from '../../types/timeline'; - import { getAllSpansForActivityDirective, getSpanRootParent } from '../../utilities/activities'; + import { + canPasteActivityDirectivesFromClipboard, + copyActivityDirectivesToClipboard, + getAllSpansForActivityDirective, + getSpanRootParent, + pasteActivityDirectivesFromClipboard, + } from '../../utilities/activities'; import effects from '../../utilities/effects'; import { getTarget } from '../../utilities/generic'; import { permissionHandler } from '../../utilities/permissionHandler'; @@ -31,6 +37,7 @@ import ContextMenuItem from '../context-menu/ContextMenuItem.svelte'; import ContextMenuSeparator from '../context-menu/ContextMenuSeparator.svelte'; import ContextSubMenuItem from '../context-menu/ContextSubMenuItem.svelte'; + import { featurePermissions } from '../../utilities/permissions'; export let activityDirectivesMap: ActivityDirectivesMap; export let contextMenu: MouseOver | null; @@ -112,6 +119,7 @@ $: activityDirectiveStartDate = activityDirective ? new Date(getUnixEpochTimeFromInterval(planStartTimeYmd, activityDirective.start_offset)) : null; + // Explicitly keep track of offsetX because Firefox ends up zeroing it out on the original `contextmenu` MouseEvent $: offsetX = contextMenu?.e.offsetX; @@ -241,6 +249,24 @@ export function isShown() { return contextMenuComponent.isShown(); } + + function copyActivityDirective(activity: ActivityDirective) { + plan !== null && copyActivityDirectivesToClipboard(plan, [activity]); + } + + function canPasteActivityDirectives(): boolean { + return ( + plan !== null && + featurePermissions.activityDirective.canCreate(user, plan) && + canPasteActivityDirectivesFromClipboard(plan) + ); + } + + function pasteActivityDirectivesAtTime(time: Date | false | null) { + if (plan !== null && time instanceof Date) { + pasteActivityDirectivesFromClipboard(plan, user, time.getTime()); + } + } @@ -311,6 +337,9 @@ Set Simulation End at Directive Start + activityDirective !== null && copyActivityDirective(activityDirective)}> + Copy Activity Directive + { if (activityDirective !== null) { @@ -329,7 +358,7 @@ ], ]} > - Delete Directive + Delete Activity Directive {:else if span} Jump to Activity Directive @@ -398,6 +427,15 @@ > Set Simulation End + {#if canPasteActivityDirectives()} + + + pasteActivityDirectivesAtTime(xScaleView && offsetX !== undefined && xScaleView.invert(offsetX))} + > + Paste at Time + + {/if} {/if} {#if span} diff --git a/src/components/ui/DataGrid/BulkActionDataGrid.svelte b/src/components/ui/DataGrid/BulkActionDataGrid.svelte index 13efd9996e..dbfd665edd 100644 --- a/src/components/ui/DataGrid/BulkActionDataGrid.svelte +++ b/src/components/ui/DataGrid/BulkActionDataGrid.svelte @@ -5,12 +5,14 @@ // eslint-disable-next-line interface $$Events extends ComponentEvents> { + bulkCopyItems: CustomEvent; bulkDeleteItems: CustomEvent; } + import { browser } from '$app/environment'; import type { ColDef, ColumnState, IRowNode, RedrawRowsParams } from 'ag-grid-community'; import { keyBy } from 'lodash-es'; - import { createEventDispatcher, onDestroy, type ComponentEvents } from 'svelte'; + import { type ComponentEvents, createEventDispatcher, onDestroy } from 'svelte'; import type { User } from '../../../types/app'; import type { Dispatcher } from '../../../types/component'; import type { RowId, TRowData } from '../../../types/data-grid'; @@ -35,6 +37,7 @@ export let selectedItemId: RowId | null = null; export let selectedItemIds: RowId[] = []; export let showContextMenu: boolean = true; + export let showCopyMenu: boolean = false; export let singleItemDisplayText: string = ''; export let suppressDragLeaveHidesColumns: boolean = true; export let suppressRowClickSelection: boolean = false; @@ -89,23 +92,33 @@ onDestroy(() => onBlur()); + function bulkCopyItems() { + const selectedRows = getRowDataFromSelectedItems(); + if (selectedRows.length) { + dispatch('bulkCopyItems', selectedRows); + } + } + function bulkDeleteItems() { if (deletePermission) { - const selectedItemIdsMap = keyBy(selectedItemIds); - const selectedRows: RowData[] = items.reduce((selectedRows: RowData[], row: RowData) => { - const id = getRowId(row); - if (selectedItemIdsMap[id] !== undefined) { - selectedRows.push(row); - } - return selectedRows; - }, []); - + const selectedRows = getRowDataFromSelectedItems(); if (selectedRows.length) { dispatch('bulkDeleteItems', selectedRows); } } } + function getRowDataFromSelectedItems(): RowData[] { + const selectedItemIdsMap = keyBy(selectedItemIds); + return items.reduce((selectedRows: RowData[], row: RowData) => { + const id = getRowId(row); + if (selectedItemIdsMap[id] !== undefined) { + selectedRows.push(row); + } + return selectedRows; + }, []); + } + function onBlur() { if (browser) { document.removeEventListener('keydown', onKeyDown); @@ -172,13 +185,20 @@ > {#if showContextMenu} - Bulk Actions Select All {isFiltered ? 'Visible ' : ''}{pluralItemDisplayText} + {#if selectedItemIds.length} + {#if showCopyMenu} + + Copy {selectedItemIds.length} + {selectedItemIds.length > 1 ? pluralItemDisplayText : singleItemDisplayText} + + {/if} + a.id)); + const clippedActivities = activities.map(activity => { + const anchorInSelection = activity.anchor_id !== null && copiedActivityIds.has(activity.anchor_id); + return { + anchor_id: anchorInSelection ? activity.anchor_id : null, + anchored_to_start: activity.anchored_to_start, + arguments: activity.arguments, + id: activity.id, + name: activity.name, + start_offset: activity.start_offset, + start_time_ms: activity.start_time_ms, + tags: activity.tags, + type: activity.type, + }; + }); + + const clipboard = { + activities: clippedActivities, + sourcePlanId: sourcePlan.id, + }; + + setClipboardContent(JSON.stringify(clipboard)); + showSuccessToast(`Copied ${activities.length} Activity Directive${activities.length ? '' : 's'}`); +} + +export function canPasteActivityDirectivesFromClipboard(destinationPlan: Plan | null): boolean { + if (destinationPlan === null) { + return false; + } + + const serializedClipboard = getClipboardContent(); + if (serializedClipboard === null) { + return false; + } + + const clipboard = JSON.parse(serializedClipboard); + //current scope, allows copy/paste in the same plan + if (clipboard.sourcePlanId !== destinationPlan.id) { + return false; + } + + return true; +} + +export function pasteActivityDirectivesFromClipboard( + destinationPlan: Plan, + user: User | null, + pasteStartingAtTime?: number, +) { + const serializedClipboard = getClipboardContent(); + if (serializedClipboard !== null) { + const clipboard = JSON.parse(serializedClipboard); + const activities: ActivityDirective[] = clipboard.activities; + + //transpose in time if we're given a time, otherwise it paste at the current time + if (pasteStartingAtTime !== undefined) { + const starts: number[] = []; + activities.forEach(a => { + if (a.start_time_ms !== null) { + starts.push(a.start_time_ms); + } + }); + const earliestStart = Math.min(...starts); + const diff = pasteStartingAtTime - earliestStart; + + activities.forEach(activity => { + if (activity.start_time_ms !== null) { + activity.start_time_ms += diff; + const startTimeDoy = getDoyTime(new Date(activity.start_time_ms)); + activity.start_offset = getIntervalFromDoyRange(destinationPlan.start_time_doy, startTimeDoy); + } + }); + } + + effects.cloneActivityDirectives(activities, destinationPlan, user).then(() => {}); //TODO select clones? + } +} diff --git a/src/utilities/clipboard.ts b/src/utilities/clipboard.ts new file mode 100644 index 0000000000..f8744e6354 --- /dev/null +++ b/src/utilities/clipboard.ts @@ -0,0 +1,7 @@ +export function setClipboardContent(content: string) { + sessionStorage.setItem(`aerie_clipboard`, content); +} + +export function getClipboardContent(): string | null { + return sessionStorage.getItem(`aerie_clipboard`); +} diff --git a/src/utilities/effects.ts b/src/utilities/effects.ts index 4ab9d6d76f..534964bfa6 100644 --- a/src/utilities/effects.ts +++ b/src/utilities/effects.ts @@ -473,6 +473,68 @@ const effects = { } }, + async cloneActivityDirectives( + activities: ActivityDirective[], + plan: Plan, + user: User | null, + ): Promise { + try { + if (plan === null) { + throw Error(`Plan is not defined`); + } + if (!queryPermissions.CREATE_ACTIVITY_DIRECTIVE(user, plan)) { + throwPermissionError('cloning activity directives into the plan'); + } + + const activityRemap: Record = {}; + const activityDirectivesInsertInput = activities.map( + ({ anchored_to_start, arguments: activityArguments, metadata, name, start_offset, type }) => { + const activityDirectiveInsertInput: ActivityDirectiveInsertInput = { + anchor_id: null, + anchored_to_start, + arguments: activityArguments, + metadata, + name, + plan_id: plan.id, + start_offset, + type, + }; + return activityDirectiveInsertInput; + }, + ); + + const response = await reqHasura<{ returning: ActivityDirectiveDB[] }>( + gql.CREATE_ACTIVITY_DIRECTIVES, + { activityDirectivesInsertInput }, + user, + ); + + // re-anchor activity directive clones + const { insert_activity_directive: createdActivities } = response; + if (createdActivities !== null) { + const { returning: clonedActivitiesReferences } = createdActivities; + clonedActivitiesReferences.forEach((directive, index) => { + const { id } = activities[index]; + activityRemap[id] = directive.id; + }); + + const anchorUpdates = activities + .filter(({ anchor_id: anchorId }) => anchorId !== null) + .map(({ anchor_id: anchorId, id }) => ({ + _set: { anchor_id: activityRemap[anchorId as number] }, + where: { id: { _eq: activityRemap[id] }, plan_id: { _eq: (plan as PlanSchema).id } }, + })); + + await reqHasura(gql.UPDATE_ACTIVITY_DIRECTIVES, { updates: anchorUpdates }, user); + showSuccessToast(`Pasted ${activities.length} Activity Directive${activities.length === 1 ? '' : 's'}`); + return clonedActivitiesReferences; + } + } catch (e) { + catchError('Activity Directive Paste Failed', e as Error); + showFailureToast('Activity Directive Paste Failed'); + } + }, + async createActivityDirective( argumentsMap: ArgumentsMap, start_time_doy: string, diff --git a/src/utilities/gql.ts b/src/utilities/gql.ts index 4e4e8c063d..464d779183 100644 --- a/src/utilities/gql.ts +++ b/src/utilities/gql.ts @@ -334,6 +334,17 @@ const gql = { } `, + CREATE_ACTIVITY_DIRECTIVES: `#graphql + mutation CreateActivityDirectives($activityDirectivesInsertInput: [activity_directive_insert_input!]!) { + insert_activity_directive(objects: $activityDirectivesInsertInput) { + returning { + id + type + } + } + } + `, + CREATE_ACTIVITY_DIRECTIVE_TAGS: `#graphql mutation CreateActivityDirectiveTags($tags: [activity_directive_tags_insert_input!]!) { ${Queries.INSERT_ACTIVITY_DIRECTIVE_TAGS}(objects: $tags, on_conflict: { @@ -3449,6 +3460,16 @@ const gql = { } `, + UPDATE_ACTIVITY_DIRECTIVES: `#graphql + mutation UpdateActivityDirective($updates: [activity_directive_updates!]!) { + update_activity_directive_many( + updates: $updates + ) { + affected_rows + } + } + `, + UPDATE_ACTIVITY_PRESET: `#graphql mutation UpdateActivityPreset($id: Int!, $activityPresetSetInput: activity_presets_set_input!) { ${Queries.UPDATE_ACTIVITY_PRESET}(