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

Add plan JSON export #1357

Merged
merged 5 commits into from
Jul 10, 2024
Merged
Show file tree
Hide file tree
Changes from 4 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
109 changes: 108 additions & 1 deletion src/components/plan/PlanForm.svelte
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
<svelte:options immutable={true} />

<script lang="ts">
import CloseIcon from '@nasa-jpl/stellar/icons/close.svg?component';
import DownloadIcon from '@nasa-jpl/stellar/icons/download.svg?component';
import { PlanStatusMessages } from '../../enums/planStatusMessages';
import { SearchParameters } from '../../enums/searchParameters';
import { field } from '../../stores/form';
Expand All @@ -9,14 +11,16 @@
import { plans } from '../../stores/plans';
import { simulationDataset, simulationDatasetId } from '../../stores/simulation';
import { viewTogglePanel } from '../../stores/views';
import type { ActivityDirective, ActivityDirectiveId } from '../../types/activity';
import type { User, UserId } from '../../types/app';
import type { Plan, PlanCollaborator, PlanSlimmer } from '../../types/plan';
import type { Plan, PlanCollaborator, PlanSlimmer, PlanTransfer } from '../../types/plan';
import type { PlanSnapshot as PlanSnapshotType } from '../../types/plan-snapshot';
import type { PlanTagsInsertInput, Tag, TagsChangeEvent } from '../../types/tags';
import effects from '../../utilities/effects';
import { removeQueryParam, setQueryParam } from '../../utilities/generic';
import { permissionHandler } from '../../utilities/permissionHandler';
import { featurePermissions } from '../../utilities/permissions';
import { getPlanForTransfer } from '../../utilities/plan';
import { getShortISOForDate } from '../../utilities/time';
import { tooltip } from '../../utilities/tooltip';
import { required, unique } from '../../utilities/validators';
Expand All @@ -25,11 +29,13 @@
import Input from '../form/Input.svelte';
import CardList from '../ui/CardList.svelte';
import FilterToggleButton from '../ui/FilterToggleButton.svelte';
import ProgressRadial from '../ui/ProgressRadial.svelte';
import PlanCollaboratorInput from '../ui/Tags/PlanCollaboratorInput.svelte';
import TagsInput from '../ui/Tags/TagsInput.svelte';
import PlanSnapshot from './PlanSnapshot.svelte';

export let plan: Plan | null;
export let activityDirectivesMap: Record<ActivityDirectiveId, ActivityDirective> = {};
export let planTags: Tag[];
export let tags: Tag[] = [];
export let user: User | null;
Expand All @@ -41,13 +47,15 @@
let hasCreateSnapshotPermission: boolean = false;
let hasPlanUpdatePermission: boolean = false;
let hasPlanCollaboratorsUpdatePermission: boolean = false;
let planExportAbortController: AbortController | null = null;
let planNameField = field<string>('', [
required,
unique(
$plans.filter(p => p.id !== plan?.id).map(p => p.name),
'Plan name already exists',
),
]);
let planExportProgress: number | null = null;

$: permissionError = $planReadOnly ? PlanStatusMessages.READ_ONLY : 'You do not have permission to edit this plan.';
$: if (plan) {
Expand Down Expand Up @@ -133,12 +141,92 @@
effects.updatePlan(plan, { name: $planNameField.value }, user);
}
}

async function exportPlan() {
if (plan) {
if (planExportAbortController) {
planExportAbortController.abort();
}

planExportAbortController = new AbortController();

let qualifiedActivityDirectives: ActivityDirective[] = [];
planExportProgress = 0;

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,
);

totalProgress++;
planExportProgress = (totalProgress / numOfDirectives) * 100;

return {
...activityDirective,
arguments: effectiveArguments?.arguments ?? activityDirective.arguments,
};
}

totalProgress++;
planExportProgress = (totalProgress / numOfDirectives) * 100;

return activityDirective;
}),
);

if (!planExportAbortController.signal.aborted) {
AaronPlave marked this conversation as resolved.
Show resolved Hide resolved
const planExport: PlanTransfer = getPlanForTransfer(plan, qualifiedActivityDirectives);

const a = document.createElement('a');
a.href = URL.createObjectURL(new Blob([JSON.stringify(planExport, null, 2)], { type: 'application/json' }));
a.download = planExport.name;
a.click();
}
planExportProgress = null;
}
}

function cancelPlanExport() {
planExportAbortController?.abort();
planExportAbortController = null;
}

function onPlanExport() {
if (planExportProgress === null) {
exportPlan();
} else {
cancelPlanExport();
}
}
</script>

<div class="plan-form">
{#if plan}
<fieldset>
<Collapse title="Details">
<svelte:fragment slot="right">
<button
class="st-button icon export"
on:click|stopPropagation={onPlanExport}
use:tooltip={{ content: planExportProgress === null ? 'Export Plan JSON' : 'Cancel Plan Export' }}
>
{#if planExportProgress !== null}
<ProgressRadial progress={planExportProgress} useBackground={false} />
<div class="cancel"><CloseIcon /></div>
{:else}
<DownloadIcon />
AaronPlave marked this conversation as resolved.
Show resolved Hide resolved
{/if}
</button>
</svelte:fragment>
<div class="plan-form-field">
<Field field={planNameField} on:change={onPlanNameChange}>
<Input layout="inline">
Expand Down Expand Up @@ -332,4 +420,23 @@
.plan-form-field :global(fieldset .error *) {
padding-left: calc(40% + 8px);
}

.export {
border-radius: 50%;
position: relative;
}
.export .cancel {
display: none;
}

.export:hover .cancel {
align-items: center;
display: flex;
height: 100%;
justify-content: center;
left: 0;
position: absolute;
top: 0;
width: 100%;
}
</style>
2 changes: 1 addition & 1 deletion src/components/plan/PlanMergeReview.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ const mockInitialPlan: Plan = {
parent_plan: null,
revision: 3,
scheduling_specification: { id: 1 },
simulations: [{ simulation_datasets: [{ id: 1, plan_revision: 3 }] }],
simulations: [{ id: 2, simulation_datasets: [{ id: 1, plan_revision: 3 }] }],
start_time: '2023-02-16T00:00:00',
start_time_doy: '2023-047T00:00:00',
tags: [],
Expand Down
2 changes: 2 additions & 0 deletions src/components/plan/PlanMetadataPanel.svelte
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<svelte:options immutable={true} />

<script lang="ts">
import { activityDirectivesMap } from '../../stores/activities';
import { plan, planTags } from '../../stores/plan';
import { gqlSubscribable } from '../../stores/subscribable';
import { tags } from '../../stores/tags';
Expand Down Expand Up @@ -30,6 +31,7 @@
</svelte:fragment>
<svelte:fragment slot="body">
<PlanForm
activityDirectivesMap={$activityDirectivesMap}
plan={$plan}
planTags={$planTags}
tags={$tags}
Expand Down
12 changes: 11 additions & 1 deletion src/types/plan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,13 +85,23 @@ export type PlanSchema = {
parent_plan: Pick<PlanSchema, 'id' | 'name' | 'owner' | 'collaborators' | 'is_locked'> | null;
revision: number;
scheduling_specification: Pick<SchedulingPlanSpecification, 'id'> | null;
simulations: [{ simulation_datasets: [{ id: number; plan_revision: number }] }];
simulations: [{ id: number; simulation_datasets: [{ id: number; plan_revision: number }] }];
start_time: string;
tags: { tag: Tag }[];
updated_at: string;
updated_by: UserId;
};

export type PlanTransfer = Pick<PlanSchema, 'id' | 'model_id' | 'name' | 'start_time'> & {
activities: Pick<
ActivityDirective,
'anchor_id' | 'anchored_to_start' | 'arguments' | 'id' | 'metadata' | 'name' | 'start_offset' | 'type'
>[];
end_time: string;
sim_id: number;
tags: { tag: Pick<Tag, 'id' | 'name'> }[];
};

export type PlanMetadata = Pick<
PlanSchema,
'id' | 'updated_at' | 'updated_by' | 'name' | 'owner' | 'created_at' | 'collaborators' | 'model'
Expand Down
2 changes: 2 additions & 0 deletions src/utilities/effects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2830,6 +2830,7 @@ const effects = {
activityTypeName: string,
argumentsMap: ArgumentsMap,
user: User | null,
signal?: AbortSignal,
): Promise<EffectiveArguments | null> {
try {
const data = await reqHasura<EffectiveArguments>(
Expand All @@ -2840,6 +2841,7 @@ const effects = {
modelId,
},
user,
signal,
);
const { effectiveActivityArguments } = data;
return effectiveActivityArguments;
Expand Down
1 change: 1 addition & 0 deletions src/utilities/gql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1419,6 +1419,7 @@ const gql = {
id
}
simulations(order_by: { id: desc }, limit: 1) {
id
simulation_datasets(order_by: { id: desc }) {
id
plan_revision
Expand Down
125 changes: 125 additions & 0 deletions src/utilities/plan.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import { describe } from 'node:test';
import { expect, it } from 'vitest';
import { getPlanForTransfer } from './plan';

describe('Plan utility', () => {
describe('getPlanForTransfer', () => {
it('Should return a formatted plan object for downloading', () => {
expect(
getPlanForTransfer(
{
child_plans: [],
collaborators: [],
constraint_specification: [],
created_at: '2024-01-01T00:00:00',
duration: '1y',
end_time_doy: '2025-001T00:00:00',
id: 1,
is_locked: false,
model: {
constraint_specification: [],
created_at: '2024-01-01T00:00:00',
id: 1,
jar_id: 123,
mission: 'Test',
name: 'Test Model',
owner: 'test',
parameters: { parameters: {} },
plans: [],
refresh_activity_type_logs: [],
refresh_model_parameter_logs: [],
refresh_resource_type_logs: [],
scheduling_specification_conditions: [],
scheduling_specification_goals: [],
version: '1.0.0',
view: null,
},
model_id: 1,
name: 'Foo plan',
owner: 'test',
parent_plan: null,
revision: 1,
scheduling_specification: null,
simulations: [
{
id: 3,
simulation_datasets: [
{
id: 1,
plan_revision: 1,
},
],
},
],
start_time: '2024-01-01T00:00:00+00:00',
start_time_doy: '2024-001T00:00:00',
tags: [
{
tag: {
color: '#fff',
created_at: '2024-01-01T00:00:00',
id: 0,
name: 'test tag',
owner: 'test',
},
},
],
updated_at: '2024-01-01T00:00:00',
updated_by: 'test',
},
[
{
anchor_id: null,
anchored_to_start: true,
arguments: {
numOfTests: 1,
},
created_at: '2024-01-01T00:00:00',
created_by: 'test',
id: 0,
last_modified_arguments_at: '2024-01-01T00:00:00',
last_modified_at: '2024-01-01T00:00:00',
metadata: {},
name: 'Test Activity',
plan_id: 1,
source_scheduling_goal_id: null,
start_offset: '0:00:00',
start_time_ms: 0,
tags: [],
type: 'TestActivity',
},
],
),
).toEqual({
activities: [
{
anchor_id: null,
anchored_to_start: true,
arguments: {
numOfTests: 1,
},
id: 0,
metadata: {},
name: 'Test Activity',
start_offset: '0:00:00',
type: 'TestActivity',
},
],
end_time: '2025-01-01T00:00:00+00:00',
id: 1,
model_id: 1,
name: 'Foo plan',
sim_id: 3,
start_time: '2024-01-01T00:00:00+00:00',
tags: [
{
tag: {
id: 0,
name: 'test tag',
},
},
],
});
});
});
});
Loading
Loading