From 848dfbe27f58b829d992bff11761457fa93e2a78 Mon Sep 17 00:00:00 2001 From: iVy Deliz Date: Mon, 24 Feb 2025 20:17:53 -0700 Subject: [PATCH] Copy & Paste of Activity Directives between Plans (#1625) --- .../activity/ActivityDirectivesTable.svelte | 53 ++++++-- .../timeline/TimelineContextMenu.svelte | 56 +++++--- src/utilities/activities.ts | 124 +++++++++--------- src/utilities/clipboard.ts | 22 ++++ src/utilities/sessionStorageClipboard.ts | 7 - 5 files changed, 161 insertions(+), 101 deletions(-) create mode 100644 src/utilities/clipboard.ts delete mode 100644 src/utilities/sessionStorageClipboard.ts diff --git a/src/components/activity/ActivityDirectivesTable.svelte b/src/components/activity/ActivityDirectivesTable.svelte index 4f5e4ea906..94d6442867 100644 --- a/src/components/activity/ActivityDirectivesTable.svelte +++ b/src/components/activity/ActivityDirectivesTable.svelte @@ -10,7 +10,7 @@ import type { ActivityErrorCounts, ActivityErrorRollup } from '../../types/errors'; import type { Plan } from '../../types/plan'; import { - canPasteActivityDirectivesFromClipboard, + getActivityDirectivesClipboardCount, copyActivityDirectivesToClipboard, getActivityDirectivesToPaste, getPasteActivityDirectivesText, @@ -23,6 +23,7 @@ import BulkActionDataGrid from '../ui/DataGrid/BulkActionDataGrid.svelte'; import type DataGrid from '../ui/DataGrid/DataGrid.svelte'; import DataGridActions from '../ui/DataGrid/DataGridActions.svelte'; + import { permissionHandler } from '../../utilities/permissionHandler'; export let activityDirectives: ActivityDirective[] | null = null; export let activityDirectiveErrorRollupsMap: Record | undefined = undefined; @@ -54,7 +55,7 @@ let hasCreatePermission: boolean = false; let hasDeletePermission: boolean = false; let isDeletingDirective: boolean = false; - let showCopyMenu: boolean = true; + let permissionErrorText: string | null = null; $: hasDeletePermission = plan !== null ? featurePermissions.activityDirective.canDelete(user, plan) && !planReadOnly : false; @@ -67,6 +68,12 @@ errorCounts: activityDirectiveErrorRollupsMap?.[activityDirective.id]?.errorCounts, })); + $: permissionErrorText = planReadOnly + ? PlanStatusMessages.READ_ONLY + : !hasCreatePermission + ? 'You do not have permission create activity directives' + : null; + $: { activityActionColumnDef = { cellClass: 'action-cell-container', @@ -166,14 +173,11 @@ } } - function canPasteActivityDirectives(): boolean { - return plan !== null && hasCreatePermission && canPasteActivityDirectivesFromClipboard(plan); - } - - function pasteActivityDirectives() { - if (plan !== null && canPasteActivityDirectives()) { - const directives = getActivityDirectivesToPaste(plan); - if (directives !== undefined) { + async function pasteActivityDirectives() { + if (plan != null && hasCreatePermission) { + const directivesInClipboard = await getActivityDirectivesClipboardCount(); + if (directivesInClipboard > 0) { + const directives = await getActivityDirectivesToPaste(plan); dispatch(`createActivityDirectives`, directives); } } @@ -195,7 +199,7 @@ pluralItemDisplayText="Activity Directives" scrollToSelection={true} singleItemDisplayText="Activity Directive" - {showCopyMenu} + showCopyMenu={true} suppressDragLeaveHidesColumns={false} {user} {filterExpression} @@ -214,9 +218,30 @@ Scroll to Activity {/if} - {#if canPasteActivityDirectives()} - {getPasteActivityDirectivesText()} + {#await getActivityDirectivesClipboardCount() then directivesInClipboard} + 0, + permissionError: () => { + if (permissionErrorText !== null) { + return permissionErrorText; + } else if (directivesInClipboard <= 0) { + return 'No activity directives in clipboard'; + } else { + return null; + } + }, + }, + ], + ]} + on:click={pasteActivityDirectives} + > + {getPasteActivityDirectivesText(directivesInClipboard)} + - {/if} + {/await} diff --git a/src/components/timeline/TimelineContextMenu.svelte b/src/components/timeline/TimelineContextMenu.svelte index b4665296e6..93b3821e6d 100644 --- a/src/components/timeline/TimelineContextMenu.svelte +++ b/src/components/timeline/TimelineContextMenu.svelte @@ -22,7 +22,7 @@ VerticalGuide, } from '../../types/timeline'; import { - canPasteActivityDirectivesFromClipboard, + getActivityDirectivesClipboardCount, copyActivityDirectivesToClipboard, getAllSpansForActivityDirective, getSpanRootParent, @@ -78,10 +78,12 @@ let activityDirectiveSpans: Span[] | null = []; let activityDirectiveStartDate: Date | null = null; let contextMenuComponent: ContextMenu; + let hasActivityLayer: boolean = false; let span: Span | null; + let hasCreatePermission: boolean = false; let timelines: Timeline[] = []; - let hasActivityLayer: boolean = false; let mouseOverOrigin: MouseOverOrigin | undefined = undefined; + let permissionErrorText: string | null = null; let row: Row | undefined = undefined; let offsetX: number | undefined; @@ -125,6 +127,14 @@ // Explicitly keep track of offsetX because Firefox ends up zeroing it out on the original `contextmenu` MouseEvent $: offsetX = contextMenu?.e.offsetX; + $: hasCreatePermission = plan !== null && featurePermissions.activityDirective.canCreate(user, plan); + + $: permissionErrorText = $planReadOnly + ? PlanStatusMessages.READ_ONLY + : !hasCreatePermission + ? 'You do not have permission create activity directives' + : null; + function jumpToActivityDirective() { if (span !== null) { const rootSpan = getSpanRootParent(spansMap, span.span_id); @@ -256,19 +266,12 @@ 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); + async function pasteActivityDirectivesAtTime(time: Date | false | null) { + if (plan !== null && hasCreatePermission && time instanceof Date) { + const directivesInClipboard = await getActivityDirectivesClipboardCount(); + if (directivesInClipboard > 0) { + const directives = await getActivityDirectivesToPaste(plan, time.getTime()); + await effects.cloneActivityDirectives(directives, plan, user); } } } @@ -432,18 +435,35 @@ > Set Simulation End - {#if canPasteActivityDirectives()} + {#await getActivityDirectivesClipboardCount() then directivesInClipboard} 0, + permissionError: () => { + if (permissionErrorText !== null) { + return permissionErrorText; + } else if (directivesInClipboard <= 0) { + return 'No activity directives in clipboard'; + } else { + return null; + } + }, + }, + ], + ]} on:click={() => { if (xScaleView && offsetX !== undefined) { pasteActivityDirectivesAtTime(xScaleView.invert(offsetX)); } }} > - {getPasteActivityDirectivesText()} at Time + {getPasteActivityDirectivesText(directivesInClipboard)} at Time - {/if} + {/await} {/if} {#if span} diff --git a/src/utilities/activities.ts b/src/utilities/activities.ts index 70753f6f5d..94f8854abe 100644 --- a/src/utilities/activities.ts +++ b/src/utilities/activities.ts @@ -11,8 +11,9 @@ import { getIntervalInMs, getUnixEpochTime, } from './time'; -import { getSessionStorageClipboard, setSessionStorageClipboard } from './sessionStorageClipboard'; -import { showSuccessToast } from './toast'; +import { getClipboardContent, setClipboardContent } from './clipboard'; +import { showFailureToast, showSuccessToast } from './toast'; +import { pluralize } from './text'; /** * Updates activity metadata with a new key/value and removes any empty values. @@ -191,90 +192,89 @@ export function copyActivityDirectivesToClipboard(sourcePlan: Plan, activities: const clipboard = { activities: clippedActivities, - sourcePlanId: sourcePlan.id, + sourcePlan: sourcePlan.id, + type: `aerie_activity_directives`, }; - setSessionStorageClipboard(JSON.stringify(clipboard)); - showSuccessToast(`Copied ${activities.length} Activity Directive${activities.length === 1 ? '' : 's'}`); + const noun = `Activity Directive${activities.length === 1 ? '' : 's'}`; + setClipboardContent( + clipboard, + () => showSuccessToast(`Copied ${activities.length} ${noun}`), + () => showFailureToast(`Failed to copy ${activities.length} ${noun}`), + ); } -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 getPasteActivityDirectivesText(count: number): string { + if (count <= 0) { + return `Paste Activity Directives`; //generic text, disabled context menu + } else { + return `Paste ${count} Activity Directive${pluralize(count)}`; } } -export function canPasteActivityDirectivesFromClipboard(destinationPlan: Plan | null): boolean { - if (destinationPlan === null) { - return false; - } - +export async function getActivityDirectivesClipboardCount(): Promise { 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; + const clipboardContent = await getClipboardContent(); + if (clipboardContent !== undefined) { + const clipboard = JSON.parse(clipboardContent); + if (clipboard.type === 'aerie_activity_directives' && clipboard.activities !== undefined) { + return clipboard.activities.length; + } } } catch (e) { - console.error(e); - return false; + //throws error when we have some other generic item in our clipboard (not json). but just need to catch it. } - - return true; + return -1; } -export function getActivityDirectivesToPaste( +export async function getActivityDirectivesToPaste( destinationPlan: Plan, pasteStartingAtTime?: number, -): ActivityDirective[] | undefined { +): Promise { + let activities: ActivityDirective[] = []; try { - const serializedClipboard = getSessionStorageClipboard(); - if (serializedClipboard !== null) { + const serializedClipboard = await getClipboardContent(); + if (serializedClipboard !== undefined) { const clipboard = JSON.parse(serializedClipboard); - const activities: ActivityDirective[] = clipboard.activities; + activities = 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 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); + } + }); + + //bounded by plan start and plan end + const planStart = getUnixEpochTime(destinationPlan.start_time_doy); + const planEnd = getUnixEpochTime(destinationPlan.end_time_doy); + const earliestStart = Math.min(...starts); + if (earliestStart < planStart || earliestStart > planEnd) { + pasteStartingAtTime = planStart; //if out of bounds, paste starting at the start of the plan. + } - const planStart = getUnixEpochTime(destinationPlan.start_time_doy); - const earliestStart = Math.max(planStart, Math.min(...starts)); //bounded by plan start - const diff = pasteStartingAtTime - earliestStart; + //transpose in time if we're given a time or if it was out of bounds + let diff = 0; + if (typeof pasteStartingAtTime === 'number') { + 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); - } + 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); } + return activities; } diff --git a/src/utilities/clipboard.ts b/src/utilities/clipboard.ts new file mode 100644 index 0000000000..b9c68f257b --- /dev/null +++ b/src/utilities/clipboard.ts @@ -0,0 +1,22 @@ +export function setClipboardContent(content: object, success?: () => void, fail?: () => void) { + navigator.clipboard.writeText(JSON.stringify(content)).then( + () => { + if (success !== undefined) { + success(); //optional success callback + } + }, + () => { + if (fail !== undefined) { + fail(); //optional fail callback + } + }, + ); +} + +export async function getClipboardContent(): Promise { + try { + return await window.navigator.clipboard.readText(); + } catch (e) { + console.error(e); + } +} diff --git a/src/utilities/sessionStorageClipboard.ts b/src/utilities/sessionStorageClipboard.ts deleted file mode 100644 index 4c112419e2..0000000000 --- a/src/utilities/sessionStorageClipboard.ts +++ /dev/null @@ -1,7 +0,0 @@ -export function setSessionStorageClipboard(content: string) { - sessionStorage.setItem(`aerie_clipboard`, content); -} - -export function getSessionStorageClipboard(): string | null { - return sessionStorage.getItem(`aerie_clipboard`); -}