Skip to content

Commit

Permalink
Copy & Paste of Activity Directives between Plans (#1625)
Browse files Browse the repository at this point in the history
  • Loading branch information
ivydeliz committed Feb 13, 2025
1 parent 282301a commit 3796dac
Show file tree
Hide file tree
Showing 5 changed files with 148 additions and 98 deletions.
43 changes: 31 additions & 12 deletions src/components/activity/ActivityDirectivesTable.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
import type { ActivityErrorCounts, ActivityErrorRollup } from '../../types/errors';
import type { Plan } from '../../types/plan';
import {
canPasteActivityDirectivesFromClipboard,
getActivityDirectivesClipboardCount,
copyActivityDirectivesToClipboard,
getActivityDirectivesToPaste,
getPasteActivityDirectivesText,
Expand All @@ -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<ActivityDirectiveId, ActivityErrorRollup> | undefined = undefined;
Expand Down Expand Up @@ -166,14 +167,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);
}
}
Expand Down Expand Up @@ -214,9 +212,30 @@
<ContextMenuItem on:click={scrollTimelineToActivityDirective}>Scroll to Activity</ContextMenuItem>
<ContextMenuSeparator></ContextMenuSeparator>
{/if}
{#if canPasteActivityDirectives()}
<ContextMenuItem on:click={pasteActivityDirectives}>{getPasteActivityDirectivesText()}</ContextMenuItem>
{#await getActivityDirectivesClipboardCount() then directivesInClipboard}
<ContextMenuItem
use={[
[
permissionHandler,
{
hasPermission: hasCreatePermission && directivesInClipboard > 0,
permissionError: () => {
if (planReadOnly) {
return PlanStatusMessages.READ_ONLY;
} else if (!hasCreatePermission) {
return 'You do not have permission create activity directives';
} else if (directivesInClipboard <= 0) {
return 'No activity directives in clipboard';
}
},
},
],
]}
on:click={pasteActivityDirectives}
>
{getPasteActivityDirectivesText(directivesInClipboard)}
</ContextMenuItem>
<ContextMenuSeparator></ContextMenuSeparator>
{/if}
{/await}
</svelte:fragment>
</BulkActionDataGrid>
47 changes: 30 additions & 17 deletions src/components/timeline/TimelineContextMenu.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
VerticalGuide,
} from '../../types/timeline';
import {
canPasteActivityDirectivesFromClipboard,
getActivityDirectivesClipboardCount,
copyActivityDirectivesToClipboard,
getAllSpansForActivityDirective,
getSpanRootParent,
Expand Down Expand Up @@ -78,6 +78,7 @@
let activityDirectiveSpans: Span[] | null = [];
let activityDirectiveStartDate: Date | null = null;
let contextMenuComponent: ContextMenu;
let hasCreatePermission: boolean = false;
let span: Span | null;
let timelines: Timeline[] = [];
let hasActivityLayer: boolean = false;
Expand Down Expand Up @@ -125,6 +126,8 @@
// 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);
function jumpToActivityDirective() {
if (span !== null) {
const rootSpan = getSpanRootParent(spansMap, span.span_id);
Expand Down Expand Up @@ -256,19 +259,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);
}
}
}
Expand Down Expand Up @@ -432,18 +428,35 @@
>
Set Simulation End
</ContextMenuItem>
{#if canPasteActivityDirectives()}
{#await getActivityDirectivesClipboardCount() then directivesInClipboard}
<ContextMenuSeparator />
<ContextMenuItem
use={[
[
permissionHandler,
{
hasPermission: hasCreatePermission && directivesInClipboard > 0,
permissionError: () => {
if ($planReadOnly) {
return PlanStatusMessages.READ_ONLY;
} else if (!hasCreatePermission) {
return 'You do not have permission create activity directives';
} else if (directivesInClipboard <= 0) {
return 'No activity directives in clipboard';
}
},
},
],
]}
on:click={() => {
if (xScaleView && offsetX !== undefined) {
pasteActivityDirectivesAtTime(xScaleView.invert(offsetX));
}
}}
>
{getPasteActivityDirectivesText()} at Time
{getPasteActivityDirectivesText(directivesInClipboard)} at Time
</ContextMenuItem>
{/if}
{/await}
{/if}
<ContextMenuSeparator />
{#if span}
Expand Down
123 changes: 61 additions & 62 deletions src/utilities/activities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ import {
getIntervalInMs,
getUnixEpochTime,
} from './time';
import { getSessionStorageClipboard, setSessionStorageClipboard } from './sessionStorageClipboard';
import { showSuccessToast } from './toast';
import { getClipboardContent, setClipboardContent } from './clipboard';
import { showFailureToast, showSuccessToast } from './toast';

/**
* Updates activity metadata with a new key/value and removes any empty values.
Expand Down Expand Up @@ -191,90 +191,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${count === 1 ? '' : 's'}`;
}
}

export function canPasteActivityDirectivesFromClipboard(destinationPlan: Plan | null): boolean {
if (destinationPlan === null) {
return false;
}

export async function getActivityDirectivesClipboardCount(): Promise<number> {
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;
//eh silent
}

return true;
return -1;
}

export function getActivityDirectivesToPaste(
export async function getActivityDirectivesToPaste(
destinationPlan: Plan,
pasteStartingAtTime?: number,
): ActivityDirective[] | undefined {
): Promise<ActivityDirective[]> {
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;
}
26 changes: 26 additions & 0 deletions src/utilities/clipboard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
export function setClipboardContent(content: object, success?: () => void, fail?: () => void) {
navigator.permissions.query({ name: 'clipboard-write' as PermissionName }).then(result => {
if (result.state === 'granted' || result.state === 'prompt') {
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<string | undefined> {
try {
return await window.navigator.clipboard.readText();
} catch (e) {
console.error(e);
}
}
7 changes: 0 additions & 7 deletions src/utilities/sessionStorageClipboard.ts

This file was deleted.

0 comments on commit 3796dac

Please sign in to comment.