diff --git a/src/components/activity/ActivityDirectivesTable.svelte b/src/components/activity/ActivityDirectivesTable.svelte index 4f5e4ea906..5dbfb023d0 100644 --- a/src/components/activity/ActivityDirectivesTable.svelte +++ b/src/components/activity/ActivityDirectivesTable.svelte @@ -9,12 +9,7 @@ import type { DataGridColumnDef } from '../../types/data-grid'; import type { ActivityErrorCounts, ActivityErrorRollup } from '../../types/errors'; import type { Plan } from '../../types/plan'; - import { - canPasteActivityDirectivesFromClipboard, - copyActivityDirectivesToClipboard, - getActivityDirectivesToPaste, - getPasteActivityDirectivesText, - } from '../../utilities/activities'; + import { copyActivityDirectivesToClipboard } from '../../utilities/activities'; import effects from '../../utilities/effects'; import { featurePermissions } from '../../utilities/permissions'; import ContextMenuItem from '../context-menu/ContextMenuItem.svelte'; @@ -23,6 +18,7 @@ import BulkActionDataGrid from '../ui/DataGrid/BulkActionDataGrid.svelte'; import type DataGrid from '../ui/DataGrid/DataGrid.svelte'; import DataGridActions from '../ui/DataGrid/DataGridActions.svelte'; + import PasteActivitiesContextMenu from './PasteActivitiesContextMenu.svelte'; export let activityDirectives: ActivityDirective[] | null = null; export let activityDirectiveErrorRollupsMap: Record | undefined = undefined; @@ -54,7 +50,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 +63,16 @@ errorCounts: activityDirectiveErrorRollupsMap?.[activityDirective.id]?.errorCounts, })); + $: { + if (planReadOnly) { + permissionErrorText = PlanStatusMessages.READ_ONLY; + } else if (!hasCreatePermission) { + permissionErrorText = 'You do not have permission create activity directives'; + } else { + permissionErrorText = null; + } + } + $: { activityActionColumnDef = { cellClass: 'action-cell-container', @@ -166,17 +172,8 @@ } } - 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); - } - } + function createActivityDirectives({ detail }: CustomEvent) { + dispatch('createActivityDirectives', detail); } @@ -195,7 +192,7 @@ pluralItemDisplayText="Activity Directives" scrollToSelection={true} singleItemDisplayText="Activity Directive" - {showCopyMenu} + showCopyMenu={true} suppressDragLeaveHidesColumns={false} {user} {filterExpression} @@ -214,9 +211,12 @@ Scroll to Activity {/if} - {#if canPasteActivityDirectives()} - {getPasteActivityDirectivesText()} - - {/if} + + diff --git a/src/components/activity/PasteActivitiesContextMenu.svelte b/src/components/activity/PasteActivitiesContextMenu.svelte new file mode 100644 index 0000000000..7cb9460512 --- /dev/null +++ b/src/components/activity/PasteActivitiesContextMenu.svelte @@ -0,0 +1,57 @@ + + + + +{#await getActivityDirectivesClipboardCount() then directivesInClipboard} + 0, + permissionError: () => { + if (planPermissionErrorText !== null) { + return planPermissionErrorText; + } else if (directivesInClipboard && directivesInClipboard <= 0) { + return 'No activity directives in clipboard'; + } else { + return null; + } + }, + }, + ], + ]} + on:click={() => pasteActivityDirectives(atTime)} + > + {getPasteActivityDirectivesText(directivesInClipboard)} + {atTime === undefined ? `` : `At Time`} + +{/await} diff --git a/src/components/timeline/TimelineContextMenu.svelte b/src/components/timeline/TimelineContextMenu.svelte index b4665296e6..1ffc7ced48 100644 --- a/src/components/timeline/TimelineContextMenu.svelte +++ b/src/components/timeline/TimelineContextMenu.svelte @@ -22,12 +22,9 @@ VerticalGuide, } from '../../types/timeline'; import { - canPasteActivityDirectivesFromClipboard, copyActivityDirectivesToClipboard, getAllSpansForActivityDirective, getSpanRootParent, - getPasteActivityDirectivesText, - getActivityDirectivesToPaste, } from '../../utilities/activities'; import effects from '../../utilities/effects'; import { getTarget } from '../../utilities/generic'; @@ -39,6 +36,7 @@ import ContextMenuSeparator from '../context-menu/ContextMenuSeparator.svelte'; import ContextSubMenuItem from '../context-menu/ContextSubMenuItem.svelte'; import { featurePermissions } from '../../utilities/permissions'; + import PasteActivitiesContextMenu from '../activity/PasteActivitiesContextMenu.svelte'; export let activityDirectivesMap: ActivityDirectivesMap; export let contextMenu: MouseOver | null; @@ -78,10 +76,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 +125,18 @@ // 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); + + $: { + if ($planReadOnly) { + permissionErrorText = PlanStatusMessages.READ_ONLY; + } else if (!hasCreatePermission) { + permissionErrorText = 'You do not have permission create activity directives'; + } else { + permissionErrorText = null; + } + } + function jumpToActivityDirective() { if (span !== null) { const rootSpan = getSpanRootParent(spansMap, span.span_id); @@ -256,20 +268,15 @@ plan !== null && copyActivityDirectivesToClipboard(plan, [activity]); } - function canPasteActivityDirectives(): boolean { - return ( - plan !== null && - featurePermissions.activityDirective.canCreate(user, plan) && - canPasteActivityDirectivesFromClipboard(plan) - ); + function getDateUnderMouse(): Date | undefined { + if (xScaleView && offsetX !== undefined) { + return xScaleView.invert(offsetX); + } } - 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 createActivityDirectives({ detail }: CustomEvent) { + if (plan !== null && hasCreatePermission) { + await effects.cloneActivityDirectives(detail, plan, user); } } @@ -432,18 +439,14 @@ > Set Simulation End - {#if canPasteActivityDirectives()} - - { - if (xScaleView && offsetX !== undefined) { - pasteActivityDirectivesAtTime(xScaleView.invert(offsetX)); - } - }} - > - {getPasteActivityDirectivesText()} at Time - - {/if} + + {/if} {#if span} diff --git a/src/utilities/activities.ts b/src/utilities/activities.ts index 70753f6f5d..02928d758f 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,87 @@ 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); + } + }); - const planStart = getUnixEpochTime(destinationPlan.start_time_doy); - const earliestStart = Math.max(planStart, Math.min(...starts)); //bounded by plan start - const diff = pasteStartingAtTime - earliestStart; + //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. + } - 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); - } - } - }); + //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; } - return activities; + + 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); + } + } + }); } } 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`); -}