Skip to content

Commit

Permalink
Feature: Plan import (#1388)
Browse files Browse the repository at this point in the history
* add support for plan import

* update PlanTransfer schema to include sim args and tag colors

* export activity tags

* fix test

* derive end time from planJSON duration

* parse both DOY and YMD from plan file

* make tags optional

* import times as UTC

* sort directives by id on export

* another fix for UTC import parsing
  • Loading branch information
duranb authored Jul 25, 2024
1 parent ddb4180 commit 00a7aab
Show file tree
Hide file tree
Showing 10 changed files with 350 additions and 60 deletions.
68 changes: 44 additions & 24 deletions src/components/plan/PlanForm.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,10 @@
import { viewTogglePanel } from '../../stores/views';
import type { ActivityDirective, ActivityDirectiveId } from '../../types/activity';
import type { User, UserId } from '../../types/app';
import type { ArgumentsMap } from '../../types/parameter';
import type { Plan, PlanCollaborator, PlanSlimmer, PlanTransfer } from '../../types/plan';
import type { PlanSnapshot as PlanSnapshotType } from '../../types/plan-snapshot';
import type { Simulation } from '../../types/simulation';
import type { PlanTagsInsertInput, Tag, TagsChangeEvent } from '../../types/tags';
import effects from '../../utilities/effects';
import { removeQueryParam, setQueryParam } from '../../utilities/generic';
Expand Down Expand Up @@ -166,35 +168,54 @@
let totalProgress = 0;
const numOfDirectives = Object.values(activityDirectivesMap).length;
qualifiedActivityDirectives = await Promise.all(
Object.values(activityDirectivesMap).map(async activityDirective => {
if (plan) {
const effectiveArguments = await effects.getEffectiveActivityArguments(
plan?.model_id,
activityDirective.type,
activityDirective.arguments,
user,
planExportAbortController?.signal,
);
const simulation: Simulation | null = await effects.getPlanLatestSimulation(plan.id, user);
totalProgress++;
planExportProgress = (totalProgress / numOfDirectives) * 100;
return {
...activityDirective,
arguments: effectiveArguments?.arguments ?? activityDirective.arguments,
};
const simulationArguments: ArgumentsMap = simulation
? {
...simulation.template?.arguments,
...simulation.arguments,
}
: {};
qualifiedActivityDirectives = (
await Promise.all(
Object.values(activityDirectivesMap).map(async activityDirective => {
if (plan) {
const effectiveArguments = await effects.getEffectiveActivityArguments(
plan?.model_id,
activityDirective.type,
activityDirective.arguments,
user,
planExportAbortController?.signal,
);
totalProgress++;
planExportProgress = (totalProgress / numOfDirectives) * 100;
return {
...activityDirective,
arguments: effectiveArguments?.arguments ?? activityDirective.arguments,
};
}
totalProgress++;
planExportProgress = (totalProgress / numOfDirectives) * 100;
totalProgress++;
planExportProgress = (totalProgress / numOfDirectives) * 100;
return activityDirective;
}),
);
return activityDirective;
}),
)
).sort((directiveA, directiveB) => {
if (directiveA.id < directiveB.id) {
return -1;
}
if (directiveA.id > directiveB.id) {
return 1;
}
return 0;
});
if (planExportAbortController && !planExportAbortController.signal.aborted) {
const planExport: PlanTransfer = getPlanForTransfer(plan, qualifiedActivityDirectives);
const planExport: PlanTransfer = getPlanForTransfer(plan, qualifiedActivityDirectives, simulationArguments);
const a = document.createElement('a');
a.href = URL.createObjectURL(new Blob([JSON.stringify(planExport, null, 2)], { type: 'application/json' }));
Expand Down Expand Up @@ -226,7 +247,6 @@
<svelte:fragment slot="right">
<button
class="st-button icon export"
style={'visibility:hidden;'}
on:click|stopPropagation={onPlanExport}
use:tooltip={{ content: planExportProgress === null ? 'Export Plan JSON' : 'Cancel Plan Export' }}
>
Expand Down
158 changes: 138 additions & 20 deletions src/routes/plans/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import { page } from '$app/stores';
import PlanIcon from '@nasa-jpl/stellar/icons/plan.svg?component';
import type { ICellRendererParams, ValueGetterParams } from 'ag-grid-community';
import { flatten } from 'lodash-es';
import { onDestroy, onMount } from 'svelte';
import Nav from '../../components/app/Nav.svelte';
import PageTitle from '../../components/app/PageTitle.svelte';
Expand Down Expand Up @@ -34,17 +35,20 @@
import type { User } from '../../types/app';
import type { DataGridColumnDef, RowId } from '../../types/data-grid';
import type { ModelSlim } from '../../types/model';
import type { Plan, PlanSlim } from '../../types/plan';
import type { DeprecatedPlanTransfer, Plan, PlanSlim, PlanTransfer } from '../../types/plan';
import type { PlanTagsInsertInput, Tag, TagsChangeEvent } from '../../types/tags';
import { generateRandomPastelColor } from '../../utilities/color';
import effects from '../../utilities/effects';
import { removeQueryParam } from '../../utilities/generic';
import { permissionHandler } from '../../utilities/permissionHandler';
import { featurePermissions } from '../../utilities/permissions';
import { isDeprecatedPlanTransfer } from '../../utilities/plan';
import {
convertDoyToYmd,
convertUsToDurationString,
formatDate,
getDoyTime,
getDoyTimeFromInterval,
getShortISOForDate,
} from '../../utilities/time';
import { min, required, unique } from '../../utilities/validators';
Expand Down Expand Up @@ -220,6 +224,8 @@
'Plan name already exists',
),
]);
let planUploadFiles: FileList | undefined;
let fileInput: HTMLInputElement;
let simTemplateField = field<number | null>(null);
$: startTimeField = field<string>('', [required, $plugins.time.primary.validate]);
Expand Down Expand Up @@ -325,26 +331,46 @@
}
let startTime = getDoyTime(startTimeDate);
let endTime = getDoyTime(endTimeDate);
const newPlan = await effects.createPlan(
endTime,
$modelIdField.value,
$nameField.value,
startTime,
$simTemplateField.value,
user,
);
if (newPlan) {
// Associate new tags with plan
const newPlanTags: PlanTagsInsertInput[] = (planTags || []).map(({ id: tag_id }) => ({
plan_id: newPlan.id,
tag_id,
}));
newPlan.tags = planTags.map(tag => ({ tag }));
if (!$plans.find(({ id }) => newPlan.id === id)) {
plans.updateValue(storePlans => [...storePlans, newPlan]);
if (planUploadFiles && planUploadFiles.length) {
await effects.importPlan(
$nameField.value,
$modelIdField.value,
startTime,
endTime,
$simTemplateField.value,
planTags.map(({ id }) => id),
planUploadFiles,
user,
);
fileInput.value = '';
planUploadFiles = undefined;
$startTimeField.value = '';
$endTimeField.value = '';
$nameField.value = '';
} else {
const newPlan: PlanSlim | null = await effects.createPlan(
endTime,
$modelIdField.value,
$nameField.value,
startTime,
$simTemplateField.value,
user,
);
if (newPlan) {
// Associate new tags with plan
const newPlanTags: PlanTagsInsertInput[] = (planTags || []).map(({ id: tag_id }) => ({
plan_id: newPlan.id,
tag_id,
}));
newPlan.tags = planTags.map(tag => ({ tag }));
if (!$plans.find(({ id }) => newPlan.id === id)) {
plans.updateValue(storePlans => [...storePlans, newPlan]);
}
await effects.createPlanTags(newPlanTags, newPlan, user);
$startTimeField.value = '';
$endTimeField.value = '';
$nameField.value = '';
}
await effects.createPlanTags(newPlanTags, newPlan, user);
}
}
Expand Down Expand Up @@ -418,6 +444,82 @@
durationString = 'None';
}
}
async function onReaderLoad(event: ProgressEvent<FileReader>) {
if (event.target !== null && event.target.result !== null) {
const planJSON: PlanTransfer | DeprecatedPlanTransfer = JSON.parse(`${event.target.result}`) as
| PlanTransfer
| DeprecatedPlanTransfer;
nameField.validateAndSet(planJSON.name);
const importedPlanTags = (planJSON.tags ?? []).reduce(
(previousTags: { existingTags: Tag[]; newTags: Pick<Tag, 'color' | 'name'>[] }, importedPlanTag) => {
const {
tag: { color: importedPlanTagColor, name: importedPlanTagName },
} = importedPlanTag;
const existingTag = $tags.find(({ name }) => importedPlanTagName === name);
if (existingTag) {
return {
...previousTags,
existingTags: [...previousTags.existingTags, existingTag],
};
} else {
return {
...previousTags,
newTags: [
...previousTags.newTags,
{
color: importedPlanTagColor,
name: importedPlanTagName,
},
],
};
}
},
{
existingTags: [],
newTags: [],
},
);
const newTags: Tag[] = flatten(
await Promise.all(
importedPlanTags.newTags.map(async ({ color: tagColor, name: tagName }) => {
return (
(await effects.createTags([{ color: tagColor ?? generateRandomPastelColor(), name: tagName }], user)) ||
[]
);
}),
),
);
planTags = [...importedPlanTags.existingTags, ...newTags];
// remove the `+00:00` timezone before parsing
const startTime = `${convertDoyToYmd(planJSON.start_time.replace(/\+00:00/, ''))}`;
await startTimeField.validateAndSet(getDoyTime(new Date(startTime), true));
if (isDeprecatedPlanTransfer(planJSON)) {
await endTimeField.validateAndSet(
getDoyTime(new Date(`${convertDoyToYmd(planJSON.end_time.replace(/\+00:00/, ''))}`), true),
);
} else {
const { duration } = planJSON;
await endTimeField.validateAndSet(getDoyTimeFromInterval(startTime, duration));
}
updateDurationString();
}
}
function onPlanFileChange(event: Event) {
const files = (event.target as HTMLInputElement).files;
if (files !== null && files.length) {
const reader = new FileReader();
reader.onload = onReaderLoad;
reader.readAsText(files[0]);
}
}
</script>

<PageTitle title="Plans" />
Expand All @@ -437,6 +539,22 @@
<form on:submit|preventDefault={createPlan}>
<AlertError class="m-2" error={$createPlanError} />

<fieldset>
<label for="file">Import Plan JSON File</label>
<input
class="w-100"
name="file"
type="file"
bind:files={planUploadFiles}
bind:this={fileInput}
use:permissionHandler={{
hasPermission: canCreate,
permissionError,
}}
on:change={onPlanFileChange}
/>
</fieldset>

<Field field={modelIdField}>
<label for="model" slot="label">Models</label>
<select
Expand Down
18 changes: 15 additions & 3 deletions src/types/plan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { ActivityDirective } from './activity';
import type { UserId } from './app';
import type { ConstraintPlanSpec } from './constraint';
import type { Model } from './model';
import type { ArgumentsMap } from './parameter';
import type { SchedulingPlanSpecification } from './scheduling';
import type { Tag } from './tags';

Expand Down Expand Up @@ -92,14 +93,25 @@ export type PlanSchema = {
updated_by: UserId;
};

export type PlanTransfer = Pick<PlanSchema, 'id' | 'model_id' | 'name' | 'start_time'> & {
export type PlanTransfer = Pick<PlanSchema, 'id' | 'duration' | 'model_id' | 'name' | 'start_time'> & {
activities: Pick<
ActivityDirective,
'anchor_id' | 'anchored_to_start' | 'arguments' | 'id' | 'metadata' | 'name' | 'start_offset' | 'type'
| 'anchor_id'
| 'anchored_to_start'
| 'arguments'
| 'id'
| 'metadata'
| 'name'
| 'start_offset'
| ('type' & { tags: { tag: Pick<Tag, 'color' | 'name'> }[] })
>[];
simulation_arguments: ArgumentsMap;
tags?: { tag: Pick<Tag, 'color' | 'name'> }[];
};

export type DeprecatedPlanTransfer = Omit<PlanTransfer, 'duration' | 'simulation_arguments'> & {
end_time: string;
sim_id: number;
tags: { tag: Pick<Tag, 'id' | 'name'> }[];
};

export type PlanMetadata = Pick<
Expand Down
Loading

0 comments on commit 00a7aab

Please sign in to comment.