Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Copy & Paste of Activity Directives between Plans #1627

Open
wants to merge 1 commit into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 43 additions & 14 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 @@ -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;
Expand All @@ -67,6 +68,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',
Expand Down Expand Up @@ -166,14 +177,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 All @@ -195,7 +203,7 @@
pluralItemDisplayText="Activity Directives"
scrollToSelection={true}
singleItemDisplayText="Activity Directive"
{showCopyMenu}
showCopyMenu={true}
suppressDragLeaveHidesColumns={false}
{user}
{filterExpression}
Expand All @@ -214,9 +222,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 (permissionErrorText !== null) {
return permissionErrorText;
} else if (directivesInClipboard <= 0) {
return 'No activity directives in clipboard';
} else {
return null;
}
},
},
],
]}
on:click={pasteActivityDirectives}
>
{getPasteActivityDirectivesText(directivesInClipboard)}
</ContextMenuItem>
<ContextMenuSeparator></ContextMenuSeparator>
{/if}
{/await}
</svelte:fragment>
</BulkActionDataGrid>
60 changes: 42 additions & 18 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,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;

Expand Down Expand Up @@ -125,6 +127,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);
Expand Down Expand Up @@ -256,19 +270,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 +439,35 @@
>
Set Simulation End
</ContextMenuItem>
{#if canPasteActivityDirectives()}
{#await getActivityDirectivesClipboardCount() then directivesInClipboard}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there still a chance that this menu item won't show while it's loading the count? Is it always quick to pop in?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm sure there's still a chance if you're trying to copy/paste tens of thousands of activities

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah that makes sense. Can we instead save the count to a variable and instead of awaiting to show the menu option, just show the option with a 0 count and disabling it to be consistent with always showing options?

Copy link
Contributor Author

@ivydeliz ivydeliz Feb 25, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@duranb since we're using the system clipboard we first need to make sure the clipboard has activity directives and then we get how many there are. So keeping the count doesn't really help me because we still have to deserialize the clipboard to see if it's the right type of content.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But my concern here is that we're hiding menu items from the user while waiting for the clipboard to deserialize. It's probably not likely that it would take long to deserialize, but I just want to make sure that the UX is consistent.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

see my comment above, sorry we're having this chat in two different places where the same thing is happening 😄

<ContextMenuSeparator />
<ContextMenuItem
use={[
[
permissionHandler,
{
hasPermission: hasCreatePermission && 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
</ContextMenuItem>
{/if}
{/await}
{/if}
<ContextMenuSeparator />
{#if span}
Expand Down
124 changes: 62 additions & 62 deletions src/utilities/activities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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<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;
//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<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;
}
22 changes: 22 additions & 0 deletions src/utilities/clipboard.ts
Original file line number Diff line number Diff line change
@@ -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<string | void> {
try {
return await window.navigator.clipboard.readText();
} catch (e) {
console.error(e);
}
}
Loading
Loading