diff --git a/src/components/activity/ActivityDirectivesTable.svelte b/src/components/activity/ActivityDirectivesTable.svelte index 8008fe2ce4..23436e2341 100644 --- a/src/components/activity/ActivityDirectivesTable.svelte +++ b/src/components/activity/ActivityDirectivesTable.svelte @@ -17,6 +17,12 @@ import ContextMenuItem from '../context-menu/ContextMenuItem.svelte'; import ContextMenuSeparator from '../context-menu/ContextMenuSeparator.svelte'; import { createEventDispatcher } from 'svelte'; + import { + canPasteActivityDirectivesFromClipboard, + copyActivityDirectivesToClipboard, + getPasteActivityDirectivesText, + getActivityDirectivesToPaste, + } from '../../utilities/activities'; export let activityDirectives: ActivityDirective[] = []; export let activityDirectiveErrorRollupsMap: Record | undefined = undefined; @@ -31,6 +37,7 @@ export let filterExpression: string = ''; const dispatch = createEventDispatcher<{ + createActivityDirectives: ActivityDirective[]; scrollTimelineToTime: number; }>(); @@ -44,15 +51,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', @@ -145,6 +159,25 @@ dispatch('scrollTimelineToTime', directive.start_time_ms); } } + + function copyActivityDirectives({ detail: activities }: CustomEvent) { + if (plan !== null) { + copyActivityDirectivesToClipboard(plan, activities); + } + } + + function canPasteActivityDirectives(): boolean { + return plan !== null && hasCreatePermission && canPasteActivityDirectivesFromClipboard(plan); + } + + function pasteActivityDirectives() { + if (plan !== null && canPasteActivityDirectives()) { + const directives = getActivityDirectivesToPaste(plan); + if (directives !== undefined) { + dispatch(`createActivityDirectives`, directives); + } + } + } Scroll to Activity {/if} + {#if canPasteActivityDirectives()} + {getPasteActivityDirectivesText()} + + {/if} diff --git a/src/components/activity/ActivityDirectivesTablePanel.svelte b/src/components/activity/ActivityDirectivesTablePanel.svelte index a430ff440a..d3e41471bb 100644 --- a/src/components/activity/ActivityDirectivesTablePanel.svelte +++ b/src/components/activity/ActivityDirectivesTablePanel.svelte @@ -32,6 +32,7 @@ import ActivityTableMenu from './ActivityTableMenu.svelte'; import { get } from 'svelte/store'; import { getTimeRangeAroundTime } from '../../utilities/timeline'; + import effects from '../../utilities/effects'; export let gridSection: ViewGridSection; export let user: User | null; @@ -246,6 +247,13 @@ dataGrid?.sizeColumnsToFit(); } + function createActivityDirectives({ detail }: CustomEvent) { + const p = get(plan); + if (p !== null) { + effects.cloneActivityDirectives(detail, p, user); + } + } + function onGridSizeChanged() { if (activityDirectivesTable?.autoSizeColumns === 'fill') { autoSizeSpace(); @@ -426,6 +434,7 @@ on:columnPinned={onColumnPinned} on:columnResized={onColumnResized} on:columnVisible={onColumnVisible} + on:createActivityDirectives={createActivityDirectives} on:gridSizeChanged={onGridSizeChangedDebounced} on:rowDoubleClicked={onRowDoubleClicked} on:selectionChanged={onSelectionChanged} diff --git a/src/components/timeline/TimelineContextMenu.svelte b/src/components/timeline/TimelineContextMenu.svelte index abe5de9608..b4665296e6 100644 --- a/src/components/timeline/TimelineContextMenu.svelte +++ b/src/components/timeline/TimelineContextMenu.svelte @@ -21,7 +21,14 @@ TimeRange, VerticalGuide, } from '../../types/timeline'; - import { getAllSpansForActivityDirective, getSpanRootParent } from '../../utilities/activities'; + import { + canPasteActivityDirectivesFromClipboard, + copyActivityDirectivesToClipboard, + getAllSpansForActivityDirective, + getSpanRootParent, + getPasteActivityDirectivesText, + getActivityDirectivesToPaste, + } from '../../utilities/activities'; import effects from '../../utilities/effects'; import { getTarget } from '../../utilities/generic'; import { permissionHandler } from '../../utilities/permissionHandler'; @@ -31,6 +38,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; @@ -49,6 +57,7 @@ const dispatch = createEventDispatcher<{ collapseDiscreteTree: Row; + createActivityDirectives: ActivityDirective[]; deleteActivityDirective: number; deleteRow: Row; duplicateRow: Row; @@ -57,6 +66,7 @@ jumpToActivityDirective: number; jumpToSpan: number; moveRow: { direction: 'up' | 'down'; row: Row }; + pasteActivityDirectivesAtTime: Date | null; toggleActivityComposition: { composition: ActivityOptions['composition']; row: Row }; updateVerticalGuides: VerticalGuide[]; viewTimeRangeChanged: TimeRange; @@ -241,6 +251,27 @@ 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 && featurePermissions.activityDirective.canCreate(user, plan) && time instanceof Date) { + const directives = getActivityDirectivesToPaste(plan, time.getTime()); + if (directives !== undefined) { + effects.cloneActivityDirectives(directives, plan, user); + } + } + } @@ -311,6 +342,9 @@ Set Simulation End at Directive Start + activityDirective !== null && copyActivityDirective(activityDirective)}> + Copy Activity Directive + { if (activityDirective !== null) { @@ -329,7 +363,7 @@ ], ]} > - Delete Directive + Delete Activity Directive {:else if span} Jump to Activity Directive @@ -398,6 +432,18 @@ > Set Simulation End + {#if canPasteActivityDirectives()} + + { + if (xScaleView && offsetX !== undefined) { + pasteActivityDirectivesAtTime(xScaleView.invert(offsetX)); + } + }} + > + {getPasteActivityDirectivesText()} 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.anchor_id !== null && !anchorInSelection ? '0' : activity.start_offset, + start_time_ms: activity.start_time_ms, + tags: activity.tags, + type: activity.type, + }; + }); + + const clipboard = { + activities: clippedActivities, + sourcePlanId: sourcePlan.id, + }; + + setSessionStorageClipboard(JSON.stringify(clipboard)); + showSuccessToast(`Copied ${activities.length} Activity Directive${activities.length === 1 ? '' : 's'}`); +} + +export function getPasteActivityDirectivesText(): string | undefined { + try { + const serializedClipboard = getSessionStorageClipboard(); + if (serializedClipboard !== null) { + const clipboard = JSON.parse(serializedClipboard); + if (Array.isArray(clipboard.activities)) { + const n = clipboard.activities.length; + return `Paste ${n} Activity Directive${n === 1 ? '' : 's'}`; + } + } + } catch (e) { + console.error('e'); + } +} + +export function canPasteActivityDirectivesFromClipboard(destinationPlan: Plan | null): boolean { + if (destinationPlan === null) { + return false; + } + + try { + const serializedClipboard = getSessionStorageClipboard(); + 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; + } + } catch (e) { + console.error(e); + return false; + } + + return true; +} + +export function getActivityDirectivesToPaste( + destinationPlan: Plan, + pasteStartingAtTime?: number, +): ActivityDirective[] | undefined { + try { + const serializedClipboard = getSessionStorageClipboard(); + 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 (typeof pasteStartingAtTime === 'number') { + const starts: number[] = []; + activities.forEach(a => { + //unachored activities are the ones we're trying to place relative to each other in time, anchored will be calculated from offset + if (a.anchor_id === null && a.start_time_ms !== null) { + starts.push(a.start_time_ms); + } + }); + + const planStart = getUnixEpochTime(destinationPlan.start_time_doy); + const earliestStart = Math.max(planStart, Math.min(...starts)); //bounded by plan start + const diff = pasteStartingAtTime - earliestStart; + + activities.forEach(activity => { + if (activity.start_time_ms !== null) { + //anchored activities don't need offset to be updated + if (activity.anchor_id === null) { + activity.start_time_ms += diff; + const startTimeDoy = getDoyTime(new Date(activity.start_time_ms)); + activity.start_offset = getIntervalFromDoyRange(destinationPlan.start_time_doy, startTimeDoy); + } + } + }); + } + return activities; + } + } catch (e) { + console.error(e); + } +} diff --git a/src/utilities/effects.ts b/src/utilities/effects.ts index 60d5b83666..7db249c32e 100644 --- a/src/utilities/effects.ts +++ b/src/utilities/effects.ts @@ -474,6 +474,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('clone 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 391e3617d3..cb5e1f7e71 100644 --- a/src/utilities/gql.ts +++ b/src/utilities/gql.ts @@ -105,6 +105,7 @@ export enum Queries { GET_SEQUENCE_SEQ_JSON = 'getSequenceSeqJson', GET_USER_SEQUENCE_SEQ_JSON = 'getUserSequenceSeqJson', INSERT_ACTIVITY_DIRECTIVE = 'insert_activity_directive_one', + INSERT_ACTIVITY_DIRECTIVES = 'insert_activity_directive', INSERT_ACTIVITY_DIRECTIVE_TAGS = 'insert_activity_directive_tags', INSERT_ACTIVITY_PRESET = 'insert_activity_presets_one', INSERT_CHANNEL_DICTIONARY = 'insert_channel_dictionary_one', @@ -198,6 +199,7 @@ export enum Queries { TAGS = 'tags', TOPIC = 'topic', UPDATE_ACTIVITY_DIRECTIVE = 'update_activity_directive_by_pk', + UPDATE_ACTIVITY_DIRECTIVES = 'update_activity_directive_many', UPDATE_ACTIVITY_PRESET = 'update_activity_presets_by_pk', UPDATE_CONSTRAINT_METADATA = 'update_constraint_metadata_by_pk', UPDATE_CONSTRAINT_SPECIFICATION = 'update_constraint_specification_by_pk', @@ -335,6 +337,17 @@ const gql = { } `, + CREATE_ACTIVITY_DIRECTIVES: `#graphql + mutation CreateActivityDirectives($activityDirectivesInsertInput: [activity_directive_insert_input!]!) { + ${Queries.INSERT_ACTIVITY_DIRECTIVES}(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: { @@ -3470,6 +3483,16 @@ const gql = { } `, + UPDATE_ACTIVITY_DIRECTIVES: `#graphql + mutation UpdateActivityDirective($updates: [activity_directive_updates!]!) { + ${Queries.UPDATE_ACTIVITY_DIRECTIVES}( + updates: $updates + ) { + affected_rows + } + } + `, + UPDATE_ACTIVITY_PRESET: `#graphql mutation UpdateActivityPreset($id: Int!, $activityPresetSetInput: activity_presets_set_input!) { ${Queries.UPDATE_ACTIVITY_PRESET}( diff --git a/src/utilities/permissions.ts b/src/utilities/permissions.ts index 1fd1e98c6e..6b513311ba 100644 --- a/src/utilities/permissions.ts +++ b/src/utilities/permissions.ts @@ -319,6 +319,12 @@ const queryPermissions: Record b isUserAdmin(user) || (getPermission(queries, user) && (isPlanOwner(user, plan) || isPlanCollaborator(user, plan))) ); }, + CREATE_ACTIVITY_DIRECTIVES: (user: User | null, plan: PlanWithOwners): boolean => { + const queries = [Queries.INSERT_ACTIVITY_DIRECTIVES]; + return ( + isUserAdmin(user) || (getPermission(queries, user) && (isPlanOwner(user, plan) || isPlanCollaborator(user, plan))) + ); + }, CREATE_ACTIVITY_DIRECTIVE_TAGS: (user: User | null): boolean => { return isUserAdmin(user) || getPermission([Queries.INSERT_ACTIVITY_DIRECTIVE_TAGS], user); }, @@ -933,6 +939,13 @@ const queryPermissions: Record b (isPlanOwner(user, plan) || isPlanCollaborator(user, plan))) ); }, + UPDATE_ACTIVITY_DIRECTIVES: (user: User | null, plan: PlanWithOwners): boolean => { + return ( + isUserAdmin(user) || + (getPermission([Queries.UPDATE_ACTIVITY_DIRECTIVES], user) && + (isPlanOwner(user, plan) || isPlanCollaborator(user, plan))) + ); + }, UPDATE_ACTIVITY_PRESET: (user: User | null, preset: AssetWithOwner): boolean => { return isUserAdmin(user) || (getPermission([Queries.UPDATE_ACTIVITY_PRESET], user) && isUserOwner(user, preset)); }, diff --git a/src/utilities/sessionStorageClipboard.ts b/src/utilities/sessionStorageClipboard.ts new file mode 100644 index 0000000000..4c112419e2 --- /dev/null +++ b/src/utilities/sessionStorageClipboard.ts @@ -0,0 +1,7 @@ +export function setSessionStorageClipboard(content: string) { + sessionStorage.setItem(`aerie_clipboard`, content); +} + +export function getSessionStorageClipboard(): string | null { + return sessionStorage.getItem(`aerie_clipboard`); +}