From e15d9b9f7c2dbed0e8d56ff1bbf002ead4bcf3cc Mon Sep 17 00:00:00 2001 From: bduran Date: Tue, 30 Jul 2024 14:10:33 -0700 Subject: [PATCH 01/25] add export plan logic to plans table add export plan logic to plan nav menu add import validation error handling --- src/components/menus/PlanMenu.svelte | 18 +++ src/components/plan/PlanForm.svelte | 74 ++------- src/routes/plans/+page.svelte | 233 ++++++++++++++++++--------- src/tests/mocks/user/mockUser.ts | 105 ++++++++++++ src/utilities/effects.test.ts | 118 +------------- src/utilities/effects.ts | 93 +++++++++++ src/utilities/generic.ts | 14 ++ src/utilities/plan.test.ts | 180 ++++++++++++++++++++- src/utilities/plan.ts | 75 +++++++-- 9 files changed, 647 insertions(+), 263 deletions(-) create mode 100644 src/tests/mocks/user/mockUser.ts diff --git a/src/components/menus/PlanMenu.svelte b/src/components/menus/PlanMenu.svelte index 49bcb25082..ec49568d91 100644 --- a/src/components/menus/PlanMenu.svelte +++ b/src/components/menus/PlanMenu.svelte @@ -11,9 +11,11 @@ import type { User } from '../../types/app'; import type { Plan } from '../../types/plan'; import effects from '../../utilities/effects'; + import { downloadJSON } from '../../utilities/generic'; import { showPlanBranchesModal, showPlanMergeRequestsModal } from '../../utilities/modal'; import { permissionHandler } from '../../utilities/permissionHandler'; import { featurePermissions } from '../../utilities/permissions'; + import { getPlanForTransfer } from '../../utilities/plan'; import Menu from '../menus/Menu.svelte'; import MenuItem from '../menus/MenuItem.svelte'; import MenuDivider from './MenuDivider.svelte'; @@ -53,6 +55,11 @@ effects.createPlanSnapshot(plan, user); } + async function exportPlan() { + const planTransfer = await getPlanForTransfer(plan, user); + downloadJSON(planTransfer, planTransfer.name); + } + function viewSnapshotHistory() { viewTogglePanel({ state: true, type: 'right', update: { rightComponentTop: 'PlanMetadataPanel' } }); } @@ -138,6 +145,10 @@
View Snapshot History
+ +
+ +
{#if plan.child_plans.length > 0} @@ -193,4 +204,11 @@ cursor: pointer; user-select: none; } + + .export-button-container { + align-items: center; + display: flex; + justify-content: center; + padding: var(--aerie-menu-item-padding, 8px); + } diff --git a/src/components/plan/PlanForm.svelte b/src/components/plan/PlanForm.svelte index b5e39fa4e3..09d212720a 100644 --- a/src/components/plan/PlanForm.svelte +++ b/src/components/plan/PlanForm.svelte @@ -14,13 +14,11 @@ 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 { Plan, PlanCollaborator, PlanSlimmer } 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'; + import { downloadJSON, removeQueryParam, setQueryParam } from '../../utilities/generic'; import { permissionHandler } from '../../utilities/permissionHandler'; import { featurePermissions } from '../../utilities/permissions'; import { getPlanForTransfer } from '../../utilities/plan'; @@ -161,66 +159,20 @@ } planExportAbortController = new AbortController(); - - let qualifiedActivityDirectives: ActivityDirective[] = []; planExportProgress = 0; - let totalProgress = 0; - const numOfDirectives = Object.values(activityDirectivesMap).length; - - const simulation: Simulation | null = await effects.getPlanLatestSimulation(plan.id, user); - - 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; - - 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, simulationArguments); - - 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(); + const planExport = await getPlanForTransfer( + plan, + user, + (progress: number) => { + planExportProgress = progress; + }, + Object.values(activityDirectivesMap), + planExportAbortController.signal, + ); + + downloadJSON(planExport, planExport.name); } planExportProgress = null; } diff --git a/src/routes/plans/+page.svelte b/src/routes/plans/+page.svelte index e8229ddf69..62f89e8360 100644 --- a/src/routes/plans/+page.svelte +++ b/src/routes/plans/+page.svelte @@ -5,6 +5,7 @@ import { base } from '$app/paths'; import { page } from '$app/stores'; import PlanIcon from '@nasa-jpl/stellar/icons/plan.svg?component'; + import UploadIcon from '@nasa-jpl/stellar/icons/upload.svg?component'; import type { ICellRendererParams, ValueGetterParams } from 'ag-grid-community'; import { flatten } from 'lodash-es'; import { onDestroy, onMount } from 'svelte'; @@ -39,10 +40,10 @@ import type { PlanTagsInsertInput, Tag, TagsChangeEvent } from '../../types/tags'; import { generateRandomPastelColor } from '../../utilities/color'; import effects from '../../utilities/effects'; - import { removeQueryParam } from '../../utilities/generic'; + import { downloadJSON, removeQueryParam } from '../../utilities/generic'; import { permissionHandler } from '../../utilities/permissionHandler'; import { featurePermissions } from '../../utilities/permissions'; - import { isDeprecatedPlanTransfer } from '../../utilities/plan'; + import { getPlanForTransfer, isDeprecatedPlanTransfer } from '../../utilities/plan'; import { convertDoyToYmd, convertUsToDurationString, @@ -58,6 +59,7 @@ type CellRendererParams = { deletePlan: (plan: Plan) => void; + exportPlan: (plan: Plan) => void; }; type PlanCellRendererParams = ICellRendererParams & CellRendererParams; @@ -210,6 +212,7 @@ let columnDefs: DataGridColumnDef[] = baseColumnDefs; let durationString: string = 'None'; let filterText: string = ''; + let isPlanImportMode: boolean = false; let orderedModels: ModelSlim[] = []; let nameInputField: HTMLInputElement; let planTags: Tag[] = []; @@ -225,7 +228,8 @@ ), ]); let planUploadFiles: FileList | undefined; - let fileInput: HTMLInputElement; + let planUploadFilesError: string | null = null; + let planUploadFileInput: HTMLInputElement; let simTemplateField = field(null); $: startTimeField = field('', [required, $plugins.time.primary.validate]); @@ -268,6 +272,11 @@ content: 'Delete Plan', placement: 'bottom', }, + downloadCallback: params.exportPlan, + downloadTooltip: { + content: 'Export Plan', + placement: 'bottom', + }, hasDeletePermission: params.data && user ? featurePermissions.plan.canDelete(user, params.data) : false, rowData: params.data, }, @@ -278,6 +287,7 @@ }, cellRendererParams: { deletePlan, + exportPlan, } as CellRendererParams, field: 'actions', headerName: '', @@ -285,7 +295,7 @@ sortable: false, suppressAutoSize: true, suppressSizeToFit: true, - width: 25, + width: 50, }, ]; } @@ -342,7 +352,7 @@ planUploadFiles, user, ); - fileInput.value = ''; + planUploadFileInput.value = ''; planUploadFiles = undefined; $startTimeField.value = ''; $endTimeField.value = ''; @@ -382,6 +392,14 @@ } } + async function exportPlan(plan: PlanSlim): Promise { + const planTransfer = await getPlanForTransfer(plan, user); + + if (planTransfer) { + downloadJSON(planTransfer, plan.name); + } + } + async function onTagsInputChange(event: TagsChangeEvent) { const { detail: { tag, type }, @@ -409,6 +427,17 @@ goto(`${base}/plans/${plan.id}`); } + function hideImportPlan() { + isPlanImportMode = false; + planUploadFileInput.value = ''; + planUploadFiles = undefined; + planUploadFilesError = null; + } + + function showImportPlan() { + isPlanImportMode = true; + } + async function onStartTimeChanged() { if ($startTimeField.value && $startTimeField.valid && $endTimeField.value === '') { // Set end time as start time plus a day by default @@ -445,70 +474,75 @@ } } async function onReaderLoad(event: ProgressEvent) { + planUploadFilesError = null; 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[] }, 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: [], - }, - ); + try { + 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[] }, 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)) || - [] - ); - }), - ), - ); + 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]; + 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)); + // 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; + 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)); - } + await endTimeField.validateAndSet(getDoyTimeFromInterval(startTime, duration)); + } - updateDurationString(); + updateDurationString(); + } catch (e) { + planUploadFilesError = (e as Error).message; + } } } @@ -533,28 +567,15 @@ New Plan +
-
- - -
- + {#if planUploadFiles}{/if} + + {#if planUploadFilesError} +
{planUploadFilesError}
+ {/if} + + {/if} +
+
+ +
@@ -733,4 +793,21 @@ .model-status { padding: 5px 16px 0; } + + .import-button { + column-gap: 4px; + } + + .import-input-container { + column-gap: 0.5rem; + display: grid; + grid-template-columns: auto min-content; + } + + .error { + color: var(--st-red); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } diff --git a/src/tests/mocks/user/mockUser.ts b/src/tests/mocks/user/mockUser.ts new file mode 100644 index 0000000000..9b473010a7 --- /dev/null +++ b/src/tests/mocks/user/mockUser.ts @@ -0,0 +1,105 @@ +import type { User } from '../../../types/app'; + +export const mockUser: User = { + activeRole: 'aerie_admin', + allowedRoles: ['aerie_admin'], + defaultRole: 'aerie_admin', + id: 'test', + permissibleQueries: { + activity_presets: true, + apply_preset_to_activity: true, + begin_merge: true, + cancel_merge: true, + commit_merge: true, + constraint: true, + constraintViolations: true, + createExpansionSet: true, + create_merge_request: true, + delete_activity_by_pk_delete_subtree_bulk: true, + delete_activity_by_pk_reanchor_plan_start_bulk: true, + delete_activity_by_pk_reanchor_to_anchor_bulk: true, + delete_activity_directive: true, + delete_activity_directive_tags: true, + delete_activity_presets_by_pk: true, + delete_command_dictionary_by_pk: true, + delete_constraint_by_pk: true, + delete_constraint_tags: true, + delete_expansion_rule_by_pk: true, + delete_expansion_rule_tags: true, + delete_expansion_set_by_pk: true, + delete_mission_model_by_pk: true, + delete_plan_by_pk: true, + delete_plan_tags: true, + delete_preset_to_directive_by_pk: true, + delete_scheduling_condition_by_pk: true, + delete_scheduling_goal_by_pk: true, + delete_scheduling_goal_tags: true, + delete_scheduling_specification: true, + delete_scheduling_specification_goals_by_pk: true, + delete_sequence_by_pk: true, + delete_sequence_to_simulated_activity_by_pk: true, + delete_simulation_template_by_pk: true, + delete_tags_by_pk: true, + delete_user_sequence_by_pk: true, + delete_view: true, + delete_view_by_pk: true, + deny_merge: true, + duplicate_plan: true, + expandAllActivities: true, + expansion_rule: true, + expansion_run: true, + expansion_set: true, + insert_activity_directive_one: true, + insert_activity_directive_tags: true, + insert_activity_presets_one: true, + insert_constraint_one: true, + insert_constraint_tags: true, + insert_expansion_rule_one: true, + insert_expansion_rule_tags: true, + insert_mission_model_one: true, + insert_plan_one: true, + insert_plan_tags: true, + insert_scheduling_condition_one: true, + insert_scheduling_goal_one: true, + insert_scheduling_goal_tags: true, + insert_scheduling_specification_conditions_one: true, + insert_scheduling_specification_goals_one: true, + insert_scheduling_specification_one: true, + insert_sequence_one: true, + insert_sequence_to_simulated_activity_one: true, + insert_simulation_template_one: true, + insert_tags: true, + insert_user_sequence_one: true, + insert_view_one: true, + mission_model: true, + plan_by_pk: true, + set_resolution: true, + set_resolution_bulk: true, + simulate: true, + simulation: true, + simulation_template: true, + tag: true, + update_activity_directive_by_pk: true, + update_activity_presets_by_pk: true, + update_constraint_by_pk: true, + update_expansion_rule_by_pk: true, + update_plan_by_pk: true, + update_scheduling_condition_by_pk: true, + update_scheduling_goal_by_pk: true, + update_scheduling_specification_by_pk: true, + update_scheduling_specification_conditions_by_pk: true, + update_scheduling_specification_goals_by_pk: true, + update_simulation: true, + update_simulation_by_pk: true, + update_simulation_template_by_pk: true, + update_tags_by_pk: true, + update_user_sequence_by_pk: true, + update_view_by_pk: true, + uploadDictionary: true, + user_sequence: true, + view: true, + withdraw_merge_request: true, + }, + rolePermissions: {}, + token: '', +}; diff --git a/src/utilities/effects.test.ts b/src/utilities/effects.test.ts index c9c5068f06..db8682e4c5 100644 --- a/src/utilities/effects.test.ts +++ b/src/utilities/effects.test.ts @@ -1,6 +1,6 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; import * as Errors from '../stores/errors'; -import type { User } from '../types/app'; +import { mockUser } from '../tests/mocks/user/mockUser'; import type { Model } from '../types/model'; import type { ArgumentsMap, ParametersMap } from '../types/parameter'; import type { Plan } from '../types/plan'; @@ -18,110 +18,6 @@ vi.mock('./toast', () => ({ const catchErrorSpy = vi.fn(); -const user: User = { - activeRole: 'aerie_admin', - allowedRoles: ['aerie_admin'], - defaultRole: 'aerie_admin', - id: 'test', - permissibleQueries: { - activity_presets: true, - apply_preset_to_activity: true, - begin_merge: true, - cancel_merge: true, - commit_merge: true, - constraint: true, - constraintViolations: true, - createExpansionSet: true, - create_merge_request: true, - delete_activity_by_pk_delete_subtree_bulk: true, - delete_activity_by_pk_reanchor_plan_start_bulk: true, - delete_activity_by_pk_reanchor_to_anchor_bulk: true, - delete_activity_directive: true, - delete_activity_directive_tags: true, - delete_activity_presets_by_pk: true, - delete_command_dictionary_by_pk: true, - delete_constraint_by_pk: true, - delete_constraint_tags: true, - delete_expansion_rule_by_pk: true, - delete_expansion_rule_tags: true, - delete_expansion_set_by_pk: true, - delete_mission_model_by_pk: true, - delete_plan_by_pk: true, - delete_plan_tags: true, - delete_preset_to_directive_by_pk: true, - delete_scheduling_condition_by_pk: true, - delete_scheduling_goal_by_pk: true, - delete_scheduling_goal_tags: true, - delete_scheduling_specification: true, - delete_scheduling_specification_goals_by_pk: true, - delete_sequence_by_pk: true, - delete_sequence_to_simulated_activity_by_pk: true, - delete_simulation_template_by_pk: true, - delete_tags_by_pk: true, - delete_user_sequence_by_pk: true, - delete_view: true, - delete_view_by_pk: true, - deny_merge: true, - duplicate_plan: true, - expandAllActivities: true, - expansion_rule: true, - expansion_run: true, - expansion_set: true, - insert_activity_directive_one: true, - insert_activity_directive_tags: true, - insert_activity_presets_one: true, - insert_constraint_one: true, - insert_constraint_tags: true, - insert_expansion_rule_one: true, - insert_expansion_rule_tags: true, - insert_mission_model_one: true, - insert_plan_one: true, - insert_plan_tags: true, - insert_scheduling_condition_one: true, - insert_scheduling_goal_one: true, - insert_scheduling_goal_tags: true, - insert_scheduling_specification_conditions_one: true, - insert_scheduling_specification_goals_one: true, - insert_scheduling_specification_one: true, - insert_sequence_one: true, - insert_sequence_to_simulated_activity_one: true, - insert_simulation_template_one: true, - insert_tags: true, - insert_user_sequence_one: true, - insert_view_one: true, - mission_model: true, - plan_by_pk: true, - set_resolution: true, - set_resolution_bulk: true, - simulate: true, - simulation: true, - simulation_template: true, - tag: true, - update_activity_directive_by_pk: true, - update_activity_presets_by_pk: true, - update_constraint_by_pk: true, - update_expansion_rule_by_pk: true, - update_plan_by_pk: true, - update_scheduling_condition_by_pk: true, - update_scheduling_goal_by_pk: true, - update_scheduling_specification_by_pk: true, - update_scheduling_specification_conditions_by_pk: true, - update_scheduling_specification_goals_by_pk: true, - update_simulation: true, - update_simulation_by_pk: true, - update_simulation_template_by_pk: true, - update_tags_by_pk: true, - update_user_sequence_by_pk: true, - update_view_by_pk: true, - uploadDictionary: true, - user_sequence: true, - view: true, - withdraw_merge_request: true, - }, - rolePermissions: {}, - token: '', -}; - describe('Handle modal and requests in effects', () => { beforeAll(() => { vi.mock('../stores/plan', () => ({ @@ -167,7 +63,7 @@ describe('Handle modal and requests in effects', () => { owner: 'test', } as Plan, 4, - user, + mockUser, ); expect(catchErrorSpy).toHaveBeenCalledWith( @@ -206,7 +102,7 @@ describe('Handle modal and requests in effects', () => { owner: 'test', } as Plan, 3, - user, + mockUser, ); expect(catchErrorSpy).toHaveBeenCalledWith( @@ -230,7 +126,7 @@ describe('Handle modal and requests in effects', () => { id: 1, owner: 'test', } as Plan, - user, + mockUser, ); expect(catchErrorSpy).toHaveBeenCalledWith( @@ -258,7 +154,7 @@ describe('Handle modal and requests in effects', () => { id: 1, owner: 'test', } as Plan, - user, + mockUser, ); expect(catchErrorSpy).toHaveBeenCalledWith( @@ -276,7 +172,7 @@ describe('Handle modal and requests in effects', () => { vi.spyOn(Errors, 'catchError').mockImplementationOnce(catchErrorSpy); - await effects.createActivityDirectiveTags([], user); + await effects.createActivityDirectiveTags([], mockUser); expect(catchErrorSpy).toHaveBeenCalledWith( 'Create Activity Directive Tags Failed', @@ -301,7 +197,7 @@ describe('Handle modal and requests in effects', () => { tag_id: 1, }, ], - user, + mockUser, ); expect(catchErrorSpy).toHaveBeenCalledOnce(); diff --git a/src/utilities/effects.ts b/src/utilities/effects.ts index 25c9dc7f5e..a4b01bd275 100644 --- a/src/utilities/effects.ts +++ b/src/utilities/effects.ts @@ -2721,6 +2721,23 @@ const effects = { } }, + async getActivitiesForPlan(planId: number, user: User | null): Promise { + try { + const query = convertToQuery(gql.SUB_ACTIVITY_DIRECTIVES); + const data = await reqHasura(query, { planId }, user); + + const { activity_directives } = data; + if (activity_directives != null) { + return activity_directives; + } else { + throw Error('Unable to retrieve activities for plan'); + } + } catch (e) { + catchError(e as Error); + return []; + } + }, + async getActivityDirectiveChangelog( planId: number, activityId: number, @@ -3322,6 +3339,82 @@ const effects = { } }, + async getQualifiedPlanParts( + planId: number, + user: User | null, + progressCallback: (progress: number) => void, + signal?: AbortSignal, + ): Promise<{ + activities: ActivityDirectiveDB[]; + plan: Plan; + simulationArguments: ArgumentsMap; + } | null> { + try { + const plan = await effects.getPlan(planId, user); + + if (plan) { + const simulation: Simulation | null = await effects.getPlanLatestSimulation(plan.id, user); + const simulationArguments: ArgumentsMap = simulation + ? { + ...simulation.template?.arguments, + ...simulation.arguments, + } + : {}; + + const activities: ActivityDirectiveDB[] = (await effects.getActivitiesForPlan(plan.id, user)) ?? []; + + let totalProgress = 0; + const numOfDirectives = activities.length; + + const qualifiedActivityDirectives = ( + await Promise.all( + activities.map(async activityDirective => { + if (plan) { + const effectiveArguments = await effects.getEffectiveActivityArguments( + plan?.model_id, + activityDirective.type, + activityDirective.arguments, + user, + signal, + ); + + totalProgress++; + progressCallback((totalProgress / numOfDirectives) * 100); + + return { + ...activityDirective, + arguments: effectiveArguments?.arguments ?? activityDirective.arguments, + }; + } + + totalProgress++; + progressCallback((totalProgress / numOfDirectives) * 100); + + return activityDirective; + }), + ) + ).sort((directiveA, directiveB) => { + if (directiveA.id < directiveB.id) { + return -1; + } + if (directiveA.id > directiveB.id) { + return 1; + } + return 0; + }); + + return { + activities: qualifiedActivityDirectives, + plan, + simulationArguments, + }; + } + } catch (e) { + catchError(e as Error); + } + return null; + }, + getResource( datasetId: number, name: string, diff --git a/src/utilities/generic.ts b/src/utilities/generic.ts index 19ec370f6a..01edb6a01b 100644 --- a/src/utilities/generic.ts +++ b/src/utilities/generic.ts @@ -348,3 +348,17 @@ export function addPageFocusListener(onChange: (string: 'out' | 'in') => void): document.removeEventListener('visibilitychange', handleChange); }; } + +export function downloadJSON(json: object, filename: string) { + downloadBlob( + new Blob([JSON.stringify(json, null, 2)], { type: 'application/json' }), + /\.json$/.test(filename) ? filename : `${filename}.json`, + ); +} + +export function downloadBlob(blob: Blob, filename: string) { + const a = document.createElement('a'); + a.href = URL.createObjectURL(blob); + a.download = filename; + a.click(); +} diff --git a/src/utilities/plan.test.ts b/src/utilities/plan.test.ts index 328b188576..b88e687b2a 100644 --- a/src/utilities/plan.test.ts +++ b/src/utilities/plan.test.ts @@ -1,12 +1,34 @@ import { describe } from 'node:test'; -import { expect, it } from 'vitest'; +import { expect, it, vi } from 'vitest'; +import { mockUser } from '../tests/mocks/user/mockUser'; +import type { ActivityDirective } from '../types/activity'; +import type { Simulation } from '../types/simulation'; +import effects from './effects'; import { getPlanForTransfer } from './plan'; +vi.mock('./effects', () => ({ + default: { + getActivitiesForPlan: vi.fn(), + getEffectiveActivityArguments: vi.fn(), + getPlanLatestSimulation: vi.fn(), + }, +})); + +const getPlanLatestSimulationSpy = vi.spyOn(effects, 'getPlanLatestSimulation'); +const getActivitiesForPlanSpy = vi.spyOn(effects, 'getActivitiesForPlan'); +const getEffectiveActivityArgumentsSpy = vi.spyOn(effects, 'getEffectiveActivityArguments'); + describe('Plan utility', () => { describe('getPlanForTransfer', () => { - it('Should return a formatted plan object for downloading', () => { + it('Should return a formatted plan object for downloading', async () => { + getPlanLatestSimulationSpy.mockResolvedValueOnce({ + arguments: { + test: 1, + }, + } as unknown as Simulation); + expect( - getPlanForTransfer( + await getPlanForTransfer( { child_plans: [], collaborators: [], @@ -67,6 +89,8 @@ describe('Plan utility', () => { updated_at: '2024-01-01T00:00:00', updated_by: 'test', }, + mockUser, + () => {}, [ { anchor_id: null, @@ -99,9 +123,157 @@ describe('Plan utility', () => { 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', + tags: [ + { + tag: { + color: '#ff0000', + name: 'test tag', + }, + }, + ], + type: 'TestActivity', + }, + ], + duration: '1y', + id: 1, + model_id: 1, + name: 'Foo plan', + simulation_arguments: { + test: 1, + }, + start_time: '2024-01-01T00:00:00+00:00', + tags: [ + { + tag: { + color: '#fff', + name: 'test tag', + }, + }, + ], + }); + }); + + it('Should download all activities for a plan and return a formatted plan object for downloading', async () => { + getPlanLatestSimulationSpy.mockResolvedValueOnce({ + arguments: { + test: 1, + }, + } as unknown as Simulation); + getActivitiesForPlanSpy.mockResolvedValueOnce([ + { + anchor_id: null, + anchored_to_start: true, + arguments: {}, + 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: [ + { + tag: { + color: '#ff0000', + created_at: '', + id: 1, + name: 'test tag', + owner: 'test', + }, + }, + ], + type: 'TestActivity', + }, + ] as ActivityDirective[]); + getEffectiveActivityArgumentsSpy.mockResolvedValueOnce({ + arguments: { + numOfTests: 1, + }, + errors: {}, + success: true, + }); + + expect( + await getPlanForTransfer( { - test: 1, + 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', }, + mockUser, + () => {}, ), ).toEqual({ activities: [ diff --git a/src/utilities/plan.ts b/src/utilities/plan.ts index 8535c0801e..c6f401daa1 100644 --- a/src/utilities/plan.ts +++ b/src/utilities/plan.ts @@ -1,15 +1,72 @@ -import type { ActivityDirective } from '../types/activity'; +import type { ActivityDirective, ActivityDirectiveDB } from '../types/activity'; +import type { User } from '../types/app'; import type { ArgumentsMap } from '../types/parameter'; -import type { DeprecatedPlanTransfer, Plan, PlanTransfer } from '../types/plan'; +import type { DeprecatedPlanTransfer, Plan, PlanSlim, PlanTransfer } from '../types/plan'; +import type { Simulation } from '../types/simulation'; +import effects from './effects'; import { convertDoyToYmd } from './time'; -export function getPlanForTransfer( - plan: Plan, - activities: ActivityDirective[], - simulationArguments: ArgumentsMap, -): PlanTransfer { +export async function getPlanForTransfer( + plan: Plan | PlanSlim, + user: User | null, + progressCallback?: (progress: number) => void, + activities?: ActivityDirective[], + signal?: AbortSignal, +): Promise { + const simulation: Simulation | null = await effects.getPlanLatestSimulation(plan.id, user); + const qualifiedSimulationArguments: ArgumentsMap = simulation + ? { + ...simulation.template?.arguments, + ...simulation.arguments, + } + : {}; + let activitiesToQualify: ActivityDirectiveDB[] = activities ?? []; + if (activities === undefined) { + activitiesToQualify = (await effects.getActivitiesForPlan(plan.id, user)) ?? []; + } + + let totalProgress = 0; + const numOfDirectives = activitiesToQualify.length; + + const qualifiedActivityDirectives = ( + await Promise.all( + activitiesToQualify.map(async activityDirective => { + if (plan) { + const effectiveArguments = await effects.getEffectiveActivityArguments( + plan?.model_id, + activityDirective.type, + activityDirective.arguments, + user, + signal, + ); + + totalProgress++; + progressCallback?.((totalProgress / numOfDirectives) * 100); + + return { + ...activityDirective, + arguments: effectiveArguments?.arguments ?? activityDirective.arguments, + }; + } + + totalProgress++; + progressCallback?.((totalProgress / numOfDirectives) * 100); + + return activityDirective; + }), + ) + ).sort((directiveA, directiveB) => { + if (directiveA.id < directiveB.id) { + return -1; + } + if (directiveA.id > directiveB.id) { + return 1; + } + return 0; + }); + return { - activities: activities.map( + activities: qualifiedActivityDirectives.map( ({ anchor_id, anchored_to_start, @@ -36,7 +93,7 @@ export function getPlanForTransfer( id: plan.id, model_id: plan.model_id, name: plan.name, - simulation_arguments: simulationArguments, + simulation_arguments: qualifiedSimulationArguments, start_time: (convertDoyToYmd(plan.start_time_doy) as string).replace('Z', '+00:00'), tags: plan.tags.map(({ tag: { color, name } }) => ({ tag: { color, name } })), }; From ddc0e783a65b3d536955f686e51bb7fe862d795d Mon Sep 17 00:00:00 2001 From: bduran Date: Tue, 30 Jul 2024 14:24:03 -0700 Subject: [PATCH 02/25] add `version` to plan transfer --- src/types/plan.ts | 1 + src/utilities/plan.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/src/types/plan.ts b/src/types/plan.ts index f760835cdb..a77f573168 100644 --- a/src/types/plan.ts +++ b/src/types/plan.ts @@ -107,6 +107,7 @@ export type PlanTransfer = Pick[]; simulation_arguments: ArgumentsMap; tags?: { tag: Pick }[]; + version?: string; }; export type DeprecatedPlanTransfer = Omit & { diff --git a/src/utilities/plan.ts b/src/utilities/plan.ts index c6f401daa1..7db99a73ee 100644 --- a/src/utilities/plan.ts +++ b/src/utilities/plan.ts @@ -96,6 +96,7 @@ export async function getPlanForTransfer( simulation_arguments: qualifiedSimulationArguments, start_time: (convertDoyToYmd(plan.start_time_doy) as string).replace('Z', '+00:00'), tags: plan.tags.map(({ tag: { color, name } }) => ({ tag: { color, name } })), + version: '2', }; } From 5e3a6fc53da6692e7e04075d74c4fb63a989e2ff Mon Sep 17 00:00:00 2001 From: bduran Date: Tue, 30 Jul 2024 15:59:31 -0700 Subject: [PATCH 03/25] add json stream parsing --- package-lock.json | 6 ++++ package.json | 1 + src/routes/plans/+page.svelte | 13 +++++--- src/utilities/generic.test.ts | 29 +++++++++++++++++- src/utilities/generic.ts | 56 +++++++++++++++++++++++++++++++---- 5 files changed, 95 insertions(+), 10 deletions(-) diff --git a/package-lock.json b/package-lock.json index e3ab212284..3ad43a524e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@nasa-jpl/aerie-ampcs": "^1.0.5", "@nasa-jpl/seq-json-schema": "^1.0.20", "@nasa-jpl/stellar": "^1.1.18", + "@streamparser/json": "^0.0.17", "@sveltejs/adapter-node": "5.0.1", "@sveltejs/kit": "^2.5.4", "ag-grid-community": "31.2.0", @@ -1503,6 +1504,11 @@ "integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==", "dev": true }, + "node_modules/@streamparser/json": { + "version": "0.0.17", + "resolved": "https://registry.npmjs.org/@streamparser/json/-/json-0.0.17.tgz", + "integrity": "sha512-mW54K6CTVJVLwXRB6kSS1xGWPmtTuXAStWnlvtesmcySgtop+eFPWOywBFPpJO4UD173epYsPSP6HSW8kuqN8w==" + }, "node_modules/@sveltejs/adapter-node": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/@sveltejs/adapter-node/-/adapter-node-5.0.1.tgz", diff --git a/package.json b/package.json index 406a2ffa8c..c5377da7de 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "@nasa-jpl/aerie-ampcs": "^1.0.5", "@nasa-jpl/seq-json-schema": "^1.0.20", "@nasa-jpl/stellar": "^1.1.18", + "@streamparser/json": "^0.0.17", "@sveltejs/adapter-node": "5.0.1", "@sveltejs/kit": "^2.5.4", "ag-grid-community": "31.2.0", diff --git a/src/routes/plans/+page.svelte b/src/routes/plans/+page.svelte index 62f89e8360..5db4a0390b 100644 --- a/src/routes/plans/+page.svelte +++ b/src/routes/plans/+page.svelte @@ -40,7 +40,7 @@ import type { PlanTagsInsertInput, Tag, TagsChangeEvent } from '../../types/tags'; import { generateRandomPastelColor } from '../../utilities/color'; import effects from '../../utilities/effects'; - import { downloadJSON, removeQueryParam } from '../../utilities/generic'; + import { downloadJSON, parseJSON, removeQueryParam } from '../../utilities/generic'; import { permissionHandler } from '../../utilities/permissionHandler'; import { featurePermissions } from '../../utilities/permissions'; import { getPlanForTransfer, isDeprecatedPlanTransfer } from '../../utilities/plan'; @@ -473,13 +473,18 @@ durationString = 'None'; } } + async function onReaderLoad(event: ProgressEvent) { planUploadFilesError = null; if (event.target !== null && event.target.result !== null) { try { - const planJSON: PlanTransfer | DeprecatedPlanTransfer = JSON.parse(`${event.target.result}`) as - | PlanTransfer - | DeprecatedPlanTransfer; + let planJSON: PlanTransfer | DeprecatedPlanTransfer; + try { + planJSON = await parseJSON(`${event.target.result}`); + } catch (e) { + throw new Error('Plan file is not valid JSON'); + } + nameField.validateAndSet(planJSON.name); const importedPlanTags = (planJSON.tags ?? []).reduce( (previousTags: { existingTags: Tag[]; newTags: Pick[] }, importedPlanTag) => { diff --git a/src/utilities/generic.test.ts b/src/utilities/generic.test.ts index fe07bcbb86..433c33a723 100644 --- a/src/utilities/generic.test.ts +++ b/src/utilities/generic.test.ts @@ -1,6 +1,14 @@ import { afterAll, describe, expect, test, vi } from 'vitest'; import { SearchParameters } from '../enums/searchParameters'; -import { attemptStringConversion, clamp, classNames, filterEmpty, getSearchParameterNumber, isMacOs } from './generic'; +import { + attemptStringConversion, + clamp, + classNames, + filterEmpty, + getSearchParameterNumber, + isMacOs, + parseJSON, +} from './generic'; const mockNavigator = { platform: 'MacIntel', @@ -145,3 +153,22 @@ describe('getSearchParameterNumber', () => { ); }); }); + +describe('parseJSON', () => { + test.each([ + { + testCase: + '{"activities":[{"anchor_id":201,"anchored_to_start":true,"arguments":{"peelDirection":"fromTip"},"id":199,"metadata":{},"name":"PeelBanana","start_offset":"1 day 04:03:00.645","tags":[{"tag":{"color":"#c1def7","name":"bar"}},{"tag":{"color":"#ff0000","name":"flubber"}}],"type":"PeelBanana"},{"anchor_id":null,"anchored_to_start":true,"arguments":{"growingDuration":3600000000,"quantity":1},"id":200,"metadata":{},"name":"GrowBanana","start_offset":"00:29:51.705","tags":[{"tag":{"color":"#e0c3d4","name":"baz"}}],"type":"GrowBanana"},{"anchor_id":200,"anchored_to_start":true,"arguments":{"quantity":10},"id":201,"metadata":{},"name":"PickBanana","start_offset":"01:07:10.107","tags":[],"type":"PickBanana"},{"anchor_id":null,"anchored_to_start":true,"arguments":{"label":"unlabeled"},"id":202,"metadata":{},"name":"parent","start_offset":"00:00:00","tags":[],"type":"parent"},{"anchor_id":200,"anchored_to_start":false,"arguments":{"quantity":10},"id":203,"metadata":{},"name":"PickBanana","start_offset":"01:16:18.801","tags":[],"type":"PickBanana"},{"anchor_id":null,"anchored_to_start":true,"arguments":{"counter":0},"id":204,"metadata":{},"name":"child","start_offset":"00:00:00","tags":[],"type":"child"}],"duration":"24:00:00","id":61,"model_id":1,"name":"Banana Plan 39","simulation_arguments":{"initialProducer":"Chiquita3","initialPlantCount":203},"start_time":"2024-07-01T00:00:00.000+00:00","tags":[{"tag":{"color":"#ff0000","name":"foo"}},{"tag":{"color":"#c1def7","name":"bar"}}]}', + }, + { testCase: '{"a": 1, "b": "foo", "c": "bar"}' }, + ])('Should successfully parse strings into JSON objects', async ({ testCase }) => { + expect(await parseJSON(testCase)).toBeTypeOf('object'); + }); + + test.each([{ testCase: 'a: 1, "b": "foo", "c": "bar"' }])( + 'Should throw an error when parsing invalid JSON strings', + async ({ testCase }) => { + await expect(parseJSON(testCase)).rejects.toThrowError(/Unexpected/); + }, + ); +}); diff --git a/src/utilities/generic.ts b/src/utilities/generic.ts index 01edb6a01b..c2a2ec67c9 100644 --- a/src/utilities/generic.ts +++ b/src/utilities/generic.ts @@ -1,4 +1,5 @@ import { browser } from '$app/environment'; +import JSONParser from '@streamparser/json/jsonparser.js'; import type { SearchParameters } from '../enums/searchParameters'; /** @@ -349,6 +350,23 @@ export function addPageFocusListener(onChange: (string: 'out' | 'in') => void): }; } +/** + * Utility function for downloading the contents of a Blob to client storage + * @param blob + * @param filename - file extension should be provided + */ +export function downloadBlob(blob: Blob, filename: string) { + const a = document.createElement('a'); + a.href = URL.createObjectURL(blob); + a.download = filename; + a.click(); +} + +/** + * Utility function for downloading a valid JSON to client storage + * @param json + * @param filename - file extension doesn't need to be provided + */ export function downloadJSON(json: object, filename: string) { downloadBlob( new Blob([JSON.stringify(json, null, 2)], { type: 'application/json' }), @@ -356,9 +374,37 @@ export function downloadJSON(json: object, filename: string) { ); } -export function downloadBlob(blob: Blob, filename: string) { - const a = document.createElement('a'); - a.href = URL.createObjectURL(blob); - a.download = filename; - a.click(); +/** + * Utility function for parsing a long string into a JSON object + * This function is more for very long strings that need to be broken up into chunks in order to + * fully parse it into a JSON object without running out of memory + * @param jsonString + * @returns R + */ +export async function parseJSON(jsonString: string): Promise { + return new Promise((resolve, reject) => { + const jsonParser = new JSONParser({ paths: ['$.*'], stringBufferSize: undefined }); + let finalJSON: R; + jsonParser.onToken = ({ value }) => { + if (finalJSON === undefined) { + if (value === '[') { + finalJSON = [] as R; + } else if (value === '{') { + finalJSON = {} as R; + } + } + }; + jsonParser.onValue = ({ parent }) => { + finalJSON = parent as R; + }; + jsonParser.onEnd = () => { + resolve(finalJSON as R); + }; + + try { + jsonParser.write(jsonString); + } catch (e) { + reject(e); + } + }); } From b0838577135ab4bf7a4bac59022c2ea25b19bb60 Mon Sep 17 00:00:00 2001 From: bduran Date: Thu, 1 Aug 2024 16:50:27 -0700 Subject: [PATCH 04/25] add import/export to plans page change plans page to select plan before navigating --- src/assets/export.svg | 5 + src/assets/import.svg | 5 + .../ui/DataGrid/DataGridActions.svelte | 8 +- src/routes/plans/+page.svelte | 561 +++++++++++------- src/utilities/plan.test.ts | 2 + 5 files changed, 368 insertions(+), 213 deletions(-) create mode 100644 src/assets/export.svg create mode 100644 src/assets/import.svg diff --git a/src/assets/export.svg b/src/assets/export.svg new file mode 100644 index 0000000000..999cc55e78 --- /dev/null +++ b/src/assets/export.svg @@ -0,0 +1,5 @@ + + + diff --git a/src/assets/import.svg b/src/assets/import.svg new file mode 100644 index 0000000000..628093ca18 --- /dev/null +++ b/src/assets/import.svg @@ -0,0 +1,5 @@ + + + diff --git a/src/components/ui/DataGrid/DataGridActions.svelte b/src/components/ui/DataGrid/DataGridActions.svelte index a6c8990f13..807bf6a1cd 100644 --- a/src/components/ui/DataGrid/DataGridActions.svelte +++ b/src/components/ui/DataGrid/DataGridActions.svelte @@ -8,6 +8,7 @@ import PenIcon from '@nasa-jpl/stellar/icons/pen.svg?component'; import TrashIcon from '@nasa-jpl/stellar/icons/trash.svg?component'; import type { Placement } from 'tippy.js'; + import ExportIcon from '../../../assets/export.svg?component'; import type { TRowData } from '../../../types/data-grid'; import { tooltip } from '../../../utilities/tooltip'; @@ -26,6 +27,7 @@ export let hasDeletePermissionError: string | undefined = undefined; export let hasEditPermission: boolean = true; export let hasEditPermissionError: string | undefined = undefined; + export let useExportIcon: boolean | undefined = undefined; export let viewTooltip: Tooltip | undefined = undefined; export let editCallback: ((data: RowData) => void) | undefined = undefined; @@ -57,7 +59,11 @@ }} use:tooltip={downloadTooltip} > - + {#if useExportIcon} + + {:else} + + {/if} {/if} {#if editCallback} diff --git a/src/routes/plans/+page.svelte b/src/routes/plans/+page.svelte index 5db4a0390b..0b08c70282 100644 --- a/src/routes/plans/+page.svelte +++ b/src/routes/plans/+page.svelte @@ -7,8 +7,10 @@ import PlanIcon from '@nasa-jpl/stellar/icons/plan.svg?component'; import UploadIcon from '@nasa-jpl/stellar/icons/upload.svg?component'; import type { ICellRendererParams, ValueGetterParams } from 'ag-grid-community'; + import XIcon from 'bootstrap-icons/icons/x.svg?component'; import { flatten } from 'lodash-es'; import { onDestroy, onMount } from 'svelte'; + import ImportIcon from '../../assets/import.svg?component'; import Nav from '../../components/app/Nav.svelte'; import PageTitle from '../../components/app/PageTitle.svelte'; import DatePickerField from '../../components/form/DatePickerField.svelte'; @@ -46,12 +48,14 @@ import { getPlanForTransfer, isDeprecatedPlanTransfer } from '../../utilities/plan'; import { convertDoyToYmd, + convertDurationStringToUs, convertUsToDurationString, formatDate, getDoyTime, getDoyTimeFromInterval, getShortISOForDate, } from '../../utilities/time'; + import { tooltip } from '../../utilities/tooltip'; import { min, required, unique } from '../../utilities/validators'; import type { PageData } from './$types'; @@ -86,32 +90,6 @@ suppressAutoSize: true, width: 130, }, - { - field: 'model_name', - filter: 'text', - headerName: 'Model Name', - resizable: true, - sortable: true, - valueGetter: (params: ValueGetterParams) => { - if (params.data?.model_id !== undefined) { - return data.models.find(model => model.id === params.data?.model_id)?.name; - } - }, - width: 150, - }, - { - field: 'model_version', - filter: 'text', - headerName: 'Model Version', - resizable: true, - sortable: true, - valueGetter: (params: ValueGetterParams) => { - if (params.data?.model_id !== undefined) { - return data.models.find(model => model.id === params.data?.model_id)?.version; - } - }, - width: 150, - }, { field: 'start_time', filter: 'text', @@ -210,6 +188,7 @@ let canCreate: boolean = false; let columnDefs: DataGridColumnDef[] = baseColumnDefs; + let createPlanButtonText: string = 'Create'; let durationString: string = 'None'; let filterText: string = ''; let isPlanImportMode: boolean = false; @@ -217,6 +196,11 @@ let nameInputField: HTMLInputElement; let planTags: Tag[] = []; let selectedModel: ModelSlim | undefined; + let selectedPlan: PlanSlim | undefined; + let selectedPlanId: number | null = null; + let selectedPlanModelName: string | null = null; + let selectedPlanStartTime: string | null = null; + let selectedPlanEndTime: string | null = null; let user: User | null = null; let modelIdField = field(-1, [min(1, 'Field is required')]); @@ -243,6 +227,19 @@ 'Plan name already exists', ), ]); + + selectedPlan = $plans.find(({ id }) => id === selectedPlanId); + if (selectedPlan) { + const parsedPlanStartTime = $plugins.time.primary.parse(selectedPlan?.start_time_doy); + const parsedPlanEndTime = $plugins.time.primary.parse(selectedPlan?.end_time_doy); + if (parsedPlanStartTime) { + selectedPlanStartTime = formatDate(parsedPlanStartTime, $plugins.time.primary.format); + } + if (parsedPlanEndTime) { + selectedPlanEndTime = formatDate(parsedPlanEndTime, $plugins.time.primary.format); + } + selectedPlanModelName = $models.find(model => model.id === selectedPlan?.model_id)?.name ?? null; + } } $: models.updateValue(() => data.models); // sort in descending ID order @@ -259,7 +256,34 @@ user = data.user; canCreate = user ? featurePermissions.plan.canCreate(user) : false; columnDefs = [ - ...baseColumnDefs, + ...baseColumnDefs.slice(0, 3), + { + field: 'model_name', + filter: 'text', + headerName: 'Model Name', + resizable: true, + sortable: true, + valueGetter: (params: ValueGetterParams) => { + if (params.data?.model_id !== undefined) { + return $models.find(model => model.id === params.data?.model_id)?.name; + } + }, + width: 150, + }, + { + field: 'model_version', + filter: 'text', + headerName: 'Model Version', + resizable: true, + sortable: true, + valueGetter: (params: ValueGetterParams) => { + if (params.data?.model_id !== undefined) { + return $models.find(model => model.id === params.data?.model_id)?.version; + } + }, + width: 150, + }, + ...baseColumnDefs.slice(3), { cellClass: 'action-cell-container', cellRenderer: (params: PlanCellRendererParams) => { @@ -277,6 +301,7 @@ content: 'Export Plan', placement: 'bottom', }, + useExportIcon: true, hasDeletePermission: params.data && user ? featurePermissions.plan.canDelete(user, params.data) : false, rowData: params.data, }, @@ -304,6 +329,11 @@ $modelIdField.dirtyAndValid && $nameField.dirtyAndValid && $startTimeField.dirtyAndValid; + $: if ($creatingPlan) { + createPlanButtonText = planUploadFiles ? 'Creating from .json...' : 'Creating...'; + } else { + createPlanButtonText = planUploadFiles ? 'Create from .json' : 'Create'; + } $: filteredPlans = $plans.filter(plan => { const filterTextLowerCase = filterText.toLowerCase(); return ( @@ -392,6 +422,10 @@ } } + function deselectPlan() { + selectPlan(null); + } + async function exportPlan(plan: PlanSlim): Promise { const planTransfer = await getPlanForTransfer(plan, user); @@ -400,6 +434,16 @@ } } + async function exportSelectedPlan(): Promise { + if (selectedPlan) { + const planTransfer = await getPlanForTransfer(selectedPlan, user); + + if (planTransfer) { + downloadJSON(planTransfer, selectedPlan.name); + } + } + } + async function onTagsInputChange(event: TagsChangeEvent) { const { detail: { tag, type }, @@ -423,8 +467,10 @@ } } - function showPlan(plan: Pick) { - goto(`${base}/plans/${plan.id}`); + function showPlan() { + if (selectedPlanId !== null) { + goto(`${base}/plans/${selectedPlanId}`); + } } function hideImportPlan() { @@ -554,11 +600,20 @@ 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]); + const file = files[0]; + if (/\.json$/.test(file.name)) { + const reader = new FileReader(); + reader.onload = onReaderLoad; + reader.readAsText(file); + } else { + planUploadFilesError = 'Plan file is not a .json file'; + } } } + + function selectPlan(planId: number | null) { + selectedPlanId = planId; + } @@ -571,196 +626,267 @@ - New Plan - + {#if selectedPlan} + Selected plan +
+ + +
+ {:else} + New Plan + + {/if}
-
- - - - - - - {#if selectedModel} -
- -
- {/if} - - - - - - -
- -
-
- -
- + {#if selectedPlan} +
- - +
- - - - + {#if planUploadFiles} + {/if} + + {#if planUploadFilesError} +
{planUploadFilesError}
+ {/if} + + {/if} + + + + - + +
+ {#if selectedModel} +
+ +
+ {/if} + + + + + -
- - -
+
+ +
+
+ +
- {#if isPlanImportMode}
- -
- - {#if planUploadFiles}{/if} -
- {#if planUploadFilesError} -
{planUploadFilesError}
- {/if} + +
- {/if} -
- -
-
- -
- + + + + + +
+ + +
+ +
+ +
+ + {/if}
@@ -783,8 +909,9 @@ itemDisplayText="Plan" items={filteredPlans} {user} + selectedItemId={selectedPlanId ?? null} on:deleteItem={event => deletePlanContext(event, filteredPlans)} - on:rowClicked={({ detail }) => showPlan(detail.data)} + on:rowClicked={({ detail }) => selectPlan(detail.data.id)} /> {:else} No Plans Found @@ -799,7 +926,7 @@ padding: 5px 16px 0; } - .import-button { + .transfer-button { column-gap: 4px; } @@ -815,4 +942,14 @@ text-overflow: ellipsis; white-space: nowrap; } + + .selected-plan-buttons { + column-gap: 0.25rem; + display: grid; + grid-template-columns: repeat(2, min-content); + } + + .plan-metadata-item-label { + white-space: nowrap; + } diff --git a/src/utilities/plan.test.ts b/src/utilities/plan.test.ts index b88e687b2a..e6366794ca 100644 --- a/src/utilities/plan.test.ts +++ b/src/utilities/plan.test.ts @@ -163,6 +163,7 @@ describe('Plan utility', () => { }, }, ], + version: '2', }); }); @@ -314,6 +315,7 @@ describe('Plan utility', () => { }, }, ], + version: '2', }); }); }); From 4f622110540e9a6bcaa95ede558aef4e8f9389dc Mon Sep 17 00:00:00 2001 From: bduran Date: Thu, 1 Aug 2024 19:32:04 -0700 Subject: [PATCH 05/25] fix more flaky tests --- .vscode/tasks.json | 17 +++-------------- e2e-tests/fixtures/Constraints.ts | 5 +++++ e2e-tests/fixtures/SchedulingConditions.ts | 5 +++++ e2e-tests/fixtures/SchedulingGoals.ts | 5 +++++ playwright.config.ts | 1 + 5 files changed, 19 insertions(+), 14 deletions(-) diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 98b9e1d288..e8a3c3cdeb 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -117,6 +117,7 @@ { "label": "e2e Debug", "type": "shell", + "dependsOn": ["Aerie UI"], "command": "nvm use && npm run test:e2e:debug", "detail": "Task to run the e2e tests in debug mode." }, @@ -131,28 +132,16 @@ { "label": "e2e Tests", "type": "shell", - "dependsOn": ["Build UI"], + "dependsOn": ["Aerie UI"], "command": "nvm use && npm run test:e2e", "detail": "Task to run e2e tests" }, - { - "label": "e2e Rerun", - "type": "shell", - "command": "nvm use && npm run test:e2e", - "detail": "Task to rerun e2e tests without rebuilding the UI." - }, { "label": "e2e Tests - With UI", "type": "shell", - "dependsOn": ["Build UI"], + "dependsOn": ["Aerie UI"], "command": "nvm use && npm run test:e2e:with-ui", "detail": "Task to run e2e tests with Playwright UI." - }, - { - "label": "e2e Tests - With UI Rerun", - "type": "shell", - "command": "nvm use && npm run test:e2e:with-ui", - "detail": "Task to run e2e tests with Playwright UI without rebuilding the UI." } ] } diff --git a/e2e-tests/fixtures/Constraints.ts b/e2e-tests/fixtures/Constraints.ts index ad51549a79..286b49bee0 100644 --- a/e2e-tests/fixtures/Constraints.ts +++ b/e2e-tests/fixtures/Constraints.ts @@ -26,6 +26,11 @@ export class Constraints { async createConstraint(baseURL: string | undefined) { await expect(this.saveButton).toBeDisabled(); + + // TODO: Potentially fix this in component. The loading of monaco causes the page fields to reset + // so we need to wait until the page is fully loaded + await this.page.getByText('Loading Editor...').waitFor({ state: 'detached' }); + await this.fillConstraintName(); await this.fillConstraintDescription(); await this.fillConstraintDefinition(); diff --git a/e2e-tests/fixtures/SchedulingConditions.ts b/e2e-tests/fixtures/SchedulingConditions.ts index 2ec3607647..dcde2b6842 100644 --- a/e2e-tests/fixtures/SchedulingConditions.ts +++ b/e2e-tests/fixtures/SchedulingConditions.ts @@ -27,6 +27,11 @@ export class SchedulingConditions { async createSchedulingCondition(baseURL: string | undefined) { await expect(this.saveButton).toBeDisabled(); + + // TODO: Potentially fix this in component. The loading of monaco causes the page fields to reset + // so we need to wait until the page is fully loaded + await this.page.getByText('Loading Editor...').waitFor({ state: 'detached' }); + await this.fillConditionName(); await this.fillConditionDescription(); await this.fillConditionDefinition(); diff --git a/e2e-tests/fixtures/SchedulingGoals.ts b/e2e-tests/fixtures/SchedulingGoals.ts index 57864bf1ef..9dfe5c15a5 100644 --- a/e2e-tests/fixtures/SchedulingGoals.ts +++ b/e2e-tests/fixtures/SchedulingGoals.ts @@ -24,6 +24,11 @@ export class SchedulingGoals { async createSchedulingGoal(baseURL: string | undefined, goalName: string) { await expect(this.saveButton).toBeDisabled(); + + // TODO: Potentially fix this in component. The loading of monaco causes the page fields to reset + // so we need to wait until the page is fully loaded + await this.page.getByText('Loading Editor...').waitFor({ state: 'detached' }); + await this.fillGoalName(goalName); await this.fillGoalDescription(); await this.fillGoalDefinition(); diff --git a/playwright.config.ts b/playwright.config.ts index 6c68556930..3fa7f7125e 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -49,6 +49,7 @@ const config: PlaywrightTestConfig = { webServer: { command: 'npm run preview', port: 3000, + reuseExistingServer: !process.env.CI, }, }; From 189b99ad10219d950d6f42ba2ed840bf3ff42808 Mon Sep 17 00:00:00 2001 From: bduran Date: Thu, 1 Aug 2024 19:32:21 -0700 Subject: [PATCH 06/25] fix incorrect icons used --- src/routes/plans/+page.svelte | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/routes/plans/+page.svelte b/src/routes/plans/+page.svelte index 0b08c70282..4bfc208c2d 100644 --- a/src/routes/plans/+page.svelte +++ b/src/routes/plans/+page.svelte @@ -5,11 +5,11 @@ import { base } from '$app/paths'; import { page } from '$app/stores'; import PlanIcon from '@nasa-jpl/stellar/icons/plan.svg?component'; - import UploadIcon from '@nasa-jpl/stellar/icons/upload.svg?component'; import type { ICellRendererParams, ValueGetterParams } from 'ag-grid-community'; import XIcon from 'bootstrap-icons/icons/x.svg?component'; import { flatten } from 'lodash-es'; import { onDestroy, onMount } from 'svelte'; + import ExportIcon from '../../assets/export.svg?component'; import ImportIcon from '../../assets/import.svg?component'; import Nav from '../../components/app/Nav.svelte'; import PageTitle from '../../components/app/PageTitle.svelte'; @@ -630,7 +630,7 @@ Selected plan
{/if} From 2a3c5a5f1cbe838c3abf448c0ba84aacafa17aad Mon Sep 17 00:00:00 2001 From: bduran Date: Thu, 1 Aug 2024 19:32:37 -0700 Subject: [PATCH 07/25] add basic e2e test for import --- e2e-tests/data/banana-plan-export.json | 11 ++++++++++ e2e-tests/fixtures/Plans.ts | 29 ++++++++++++++++++++++++++ e2e-tests/tests/plans.test.ts | 5 +++++ 3 files changed, 45 insertions(+) create mode 100644 e2e-tests/data/banana-plan-export.json diff --git a/e2e-tests/data/banana-plan-export.json b/e2e-tests/data/banana-plan-export.json new file mode 100644 index 0000000000..d3314a7818 --- /dev/null +++ b/e2e-tests/data/banana-plan-export.json @@ -0,0 +1,11 @@ +{ + "activities": [], + "duration": "24:00:00", + "id": 59, + "model_id": 1, + "name": "Banana Plan", + "simulation_arguments": {}, + "start_time": "2024-08-02T00:00:00+00:00", + "tags": [], + "version": "2" +} \ No newline at end of file diff --git a/e2e-tests/fixtures/Plans.ts b/e2e-tests/fixtures/Plans.ts index 211a596ad4..8fb3dc5eb9 100644 --- a/e2e-tests/fixtures/Plans.ts +++ b/e2e-tests/fixtures/Plans.ts @@ -11,7 +11,10 @@ export class Plans { createButton: Locator; durationDisplay: Locator; endTime: string = '2022-006T00:00:00'; + importButton: Locator; + importFilePath: string = 'e2e-tests/data/banana-plan-export.json'; inputEndTime: Locator; + inputFile: Locator; inputModel: Locator; inputModelSelector: string = 'select[name="model"]'; inputName: Locator; @@ -86,6 +89,12 @@ export class Plans { await this.inputEndTime.evaluate(e => e.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' }))); } + async fillInputFile(importFilePath: string = this.importFilePath) { + await this.inputFile.focus(); + await this.inputFile.setInputFiles(importFilePath); + await this.inputFile.evaluate(e => e.blur()); + } + async fillInputName(planName = this.planName) { await this.inputName.focus(); await this.inputName.fill(planName); @@ -131,6 +140,24 @@ export class Plans { await this.page.waitForTimeout(250); } + async importPlan(planName = this.planName) { + await expect(this.tableRow(planName)).not.toBeVisible(); + await this.importButton.click(); + await this.selectInputModel(); + await this.fillInputFile(); + await this.fillInputName(planName); + await this.createButton.waitFor({ state: 'attached' }); + await this.createButton.waitFor({ state: 'visible' }); + await this.createButton.isEnabled({ timeout: 500 }); + await this.createButton.click(); + await this.filterTable(planName); + await this.tableRow(planName).waitFor({ state: 'attached' }); + await this.tableRow(planName).waitFor({ state: 'visible' }); + const planId = await this.getPlanId(planName); + this.planId = planId; + return planId; + } + async selectInputModel() { const value = await getOptionValueFromText(this.page, this.inputModelSelector, this.models.modelName); await this.inputModel.focus(); @@ -148,7 +175,9 @@ export class Plans { this.confirmModalDeleteButton = this.confirmModal.getByRole('button', { name: 'Delete' }); this.createButton = page.getByRole('button', { name: 'Create' }); this.durationDisplay = page.locator('input[name="duration"]'); + this.importButton = page.getByRole('button', { name: 'Import' }); this.inputEndTime = page.locator('input[name="end-time"]'); + this.inputFile = page.locator('input[name="file"]'); this.inputModel = page.locator(this.inputModelSelector); this.inputName = page.locator('input[name="name"]'); this.inputStartTime = page.locator('input[name="start-time"]'); diff --git a/e2e-tests/tests/plans.test.ts b/e2e-tests/tests/plans.test.ts index c34fb033e4..06c13d1990 100644 --- a/e2e-tests/tests/plans.test.ts +++ b/e2e-tests/tests/plans.test.ts @@ -125,4 +125,9 @@ test.describe.serial('Plans', () => { test('Delete plan', async () => { await plans.deletePlan(); }); + + test('Import plan', async () => { + await plans.importPlan(); + await plans.deletePlan(); + }); }); From 77fa55d1055a3cac20c8091951a82a7b306a325d Mon Sep 17 00:00:00 2001 From: bduran Date: Thu, 1 Aug 2024 20:08:04 -0700 Subject: [PATCH 08/25] fix duration parsing --- src/routes/plans/+page.svelte | 26 +++++++++++++++----------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/src/routes/plans/+page.svelte b/src/routes/plans/+page.svelte index 4bfc208c2d..b55f4dc766 100644 --- a/src/routes/plans/+page.svelte +++ b/src/routes/plans/+page.svelte @@ -48,11 +48,11 @@ import { getPlanForTransfer, isDeprecatedPlanTransfer } from '../../utilities/plan'; import { convertDoyToYmd, - convertDurationStringToUs, convertUsToDurationString, formatDate, getDoyTime, getDoyTimeFromInterval, + getIntervalInMs, getShortISOForDate, } from '../../utilities/time'; import { tooltip } from '../../utilities/tooltip'; @@ -230,13 +230,17 @@ selectedPlan = $plans.find(({ id }) => id === selectedPlanId); if (selectedPlan) { - const parsedPlanStartTime = $plugins.time.primary.parse(selectedPlan?.start_time_doy); - const parsedPlanEndTime = $plugins.time.primary.parse(selectedPlan?.end_time_doy); - if (parsedPlanStartTime) { - selectedPlanStartTime = formatDate(parsedPlanStartTime, $plugins.time.primary.format); - } - if (parsedPlanEndTime) { - selectedPlanEndTime = formatDate(parsedPlanEndTime, $plugins.time.primary.format); + try { + const parsedPlanStartTime = $plugins.time.primary.parse(selectedPlan?.start_time_doy); + const parsedPlanEndTime = $plugins.time.primary.parse(selectedPlan?.end_time_doy); + if (parsedPlanStartTime) { + selectedPlanStartTime = formatDate(parsedPlanStartTime, $plugins.time.primary.format); + } + if (parsedPlanEndTime) { + selectedPlanEndTime = formatDate(parsedPlanEndTime, $plugins.time.primary.format); + } + } catch (e) { + console.log(e); } selectedPlanModelName = $models.find(model => model.id === selectedPlan?.model_id)?.name ?? null; } @@ -672,13 +676,13 @@ @@ -688,7 +692,7 @@ disabled class="st-input w-100" name="duration" - value={convertUsToDurationString(convertDurationStringToUs(selectedPlan.duration))} + value={convertUsToDurationString(getIntervalInMs(selectedPlan.duration) * 1000)} /> From f4091acb8c76c5bee2b753388d8dd97f30a78f29 Mon Sep 17 00:00:00 2001 From: bduran Date: Thu, 1 Aug 2024 20:08:56 -0700 Subject: [PATCH 09/25] fix lint --- e2e-tests/data/banana-plan-export.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/e2e-tests/data/banana-plan-export.json b/e2e-tests/data/banana-plan-export.json index d3314a7818..6973d1a811 100644 --- a/e2e-tests/data/banana-plan-export.json +++ b/e2e-tests/data/banana-plan-export.json @@ -8,4 +8,4 @@ "start_time": "2024-08-02T00:00:00+00:00", "tags": [], "version": "2" -} \ No newline at end of file +} From 518eb7424497b673892521c2585a41077eaa0921 Mon Sep 17 00:00:00 2001 From: bduran Date: Fri, 2 Aug 2024 15:13:20 -0700 Subject: [PATCH 10/25] make plan import input field more strict --- src/routes/plans/+page.svelte | 135 +++++++-------- src/utilities/generic.test.ts | 314 +++++++++++++++++----------------- src/utilities/generic.ts | 29 +++- 3 files changed, 249 insertions(+), 229 deletions(-) diff --git a/src/routes/plans/+page.svelte b/src/routes/plans/+page.svelte index b55f4dc766..b4de0eff12 100644 --- a/src/routes/plans/+page.svelte +++ b/src/routes/plans/+page.svelte @@ -42,7 +42,7 @@ import type { PlanTagsInsertInput, Tag, TagsChangeEvent } from '../../types/tags'; import { generateRandomPastelColor } from '../../utilities/color'; import effects from '../../utilities/effects'; - import { downloadJSON, parseJSON, removeQueryParam } from '../../utilities/generic'; + import { downloadJSON, parseJSONStream, removeQueryParam } from '../../utilities/generic'; import { permissionHandler } from '../../utilities/permissionHandler'; import { featurePermissions } from '../../utilities/permissions'; import { getPlanForTransfer, isDeprecatedPlanTransfer } from '../../utilities/plan'; @@ -524,80 +524,78 @@ } } - async function onReaderLoad(event: ProgressEvent) { + async function parsePlanFileStream(stream: ReadableStream) { planUploadFilesError = null; - if (event.target !== null && event.target.result !== null) { + try { + let planJSON: PlanTransfer | DeprecatedPlanTransfer; try { - let planJSON: PlanTransfer | DeprecatedPlanTransfer; - try { - planJSON = await parseJSON(`${event.target.result}`); - } catch (e) { - throw new Error('Plan file is not valid JSON'); - } - - nameField.validateAndSet(planJSON.name); - const importedPlanTags = (planJSON.tags ?? []).reduce( - (previousTags: { existingTags: Tag[]; newTags: Pick[] }, 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: [], - }, - ); + planJSON = await parseJSONStream(stream); + } catch (e) { + throw new Error('Plan file is not valid JSON'); + } - 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)) || - [] - ); - }), - ), - ); + nameField.validateAndSet(planJSON.name); + const importedPlanTags = (planJSON.tags ?? []).reduce( + (previousTags: { existingTags: Tag[]; newTags: Pick[] }, 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: [], + }, + ); - planTags = [...importedPlanTags.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)) || + [] + ); + }), + ), + ); - // remove the `+00:00` timezone before parsing - const startTime = `${convertDoyToYmd(planJSON.start_time.replace(/\+00:00/, ''))}`; - await startTimeField.validateAndSet(getDoyTime(new Date(startTime), true)); + planTags = [...importedPlanTags.existingTags, ...newTags]; - if (isDeprecatedPlanTransfer(planJSON)) { - await endTimeField.validateAndSet( - getDoyTime(new Date(`${convertDoyToYmd(planJSON.end_time.replace(/\+00:00/, ''))}`), true), - ); - } else { - const { duration } = planJSON; + // remove the `+00:00` timezone before parsing + const startTime = `${convertDoyToYmd(planJSON.start_time.replace(/\+00:00/, ''))}`; + await startTimeField.validateAndSet(getDoyTime(new Date(startTime), true)); - await endTimeField.validateAndSet(getDoyTimeFromInterval(startTime, duration)); - } + if (isDeprecatedPlanTransfer(planJSON)) { + await endTimeField.validateAndSet( + getDoyTime(new Date(`${convertDoyToYmd(planJSON.end_time.replace(/\+00:00/, ''))}`), true), + ); + } else { + const { duration } = planJSON; - updateDurationString(); - } catch (e) { - planUploadFilesError = (e as Error).message; + await endTimeField.validateAndSet(getDoyTimeFromInterval(startTime, duration)); } + + updateDurationString(); + } catch (e) { + planUploadFilesError = (e as Error).message; } } @@ -606,9 +604,7 @@ if (files !== null && files.length) { const file = files[0]; if (/\.json$/.test(file.name)) { - const reader = new FileReader(); - reader.onload = onReaderLoad; - reader.readAsText(file); + parsePlanFileStream(file.stream()); } else { planUploadFilesError = 'Plan file is not a .json file'; } @@ -730,6 +726,7 @@ class="w-100" name="file" type="file" + accept="application/json" bind:files={planUploadFiles} bind:this={planUploadFileInput} use:permissionHandler={{ diff --git a/src/utilities/generic.test.ts b/src/utilities/generic.test.ts index 433c33a723..69d36493e1 100644 --- a/src/utilities/generic.test.ts +++ b/src/utilities/generic.test.ts @@ -7,7 +7,7 @@ import { filterEmpty, getSearchParameterNumber, isMacOs, - parseJSON, + parseJSONStream, } from './generic'; const mockNavigator = { @@ -16,159 +16,163 @@ const mockNavigator = { vi.stubGlobal('navigator', mockNavigator); -afterAll(() => { - vi.restoreAllMocks(); -}); - -describe('clamp', () => { - test('Should clamp a number already in the correct range to the number itself', () => { - const clampedNumber = clamp(10, 0, 20); - expect(clampedNumber).toEqual(10); - }); - - test('Should clamp a number smaller than the correct range to the lower bound in the range', () => { - const clampedNumber = clamp(5, 10, 20); - expect(clampedNumber).toEqual(10); - }); - - test('Should clamp a number larger than the correct range to the upper bound in the range', () => { - const clampedNumber = clamp(25, 10, 20); - expect(clampedNumber).toEqual(20); - }); -}); - -describe('classNames', () => { - test('Should generate the correct complete class string given an object of conditionals', () => { - expect( - classNames('foo', { - bar: true, - baz: false, - }), - ).toEqual('foo bar'); - - expect( - classNames('foo', { - bar: true, - baz: true, - }), - ).toEqual('foo bar baz'); - - expect( - classNames('foo', { - bar: false, - baz: false, - }), - ).toEqual('foo'); - }); -}); - -describe('filterEmpty', () => { - test('Should correctly determine if something is not null or undefined', () => { - expect(filterEmpty(0)).toEqual(true); - expect(filterEmpty(false)).toEqual(true); - expect(filterEmpty(null)).toEqual(false); - expect(filterEmpty(undefined)).toEqual(false); - }); - - test('Should correctly filter out null and undefined entries in arrays', () => { - expect([0, 1, 2, null, 4, undefined, 5].filter(filterEmpty)).toStrictEqual([0, 1, 2, 4, 5]); - expect(['false', false, { foo: 1 }, null, undefined].filter(filterEmpty)).toStrictEqual([ - 'false', - false, - { foo: 1 }, - ]); - }); -}); - -describe('attemptStringConversion', () => { - test('Should convert strings to strings', () => { - expect(attemptStringConversion('')).toEqual(''); - expect(attemptStringConversion('Foo')).toEqual('Foo'); - }); - test('Should convert numbers to strings', () => { - expect(attemptStringConversion(1.0101)).toEqual('1.0101'); - }); - test('Should convert arrays to strings', () => { - expect(attemptStringConversion([1.0101, 'Foo'])).toEqual('1.0101,Foo'); - }); - test('Should convert booleans to strings', () => { - expect(attemptStringConversion(true)).toEqual('true'); - expect(attemptStringConversion(false)).toEqual('false'); - }); - test('Should return null when attempting to convert non-stringable values', () => { - expect(attemptStringConversion(null)).toEqual(null); - expect(attemptStringConversion(undefined)).toEqual(null); +describe('Generic utility function tests', () => { + afterAll(() => { + vi.restoreAllMocks(); + }); + + describe('clamp', () => { + test('Should clamp a number already in the correct range to the number itself', () => { + const clampedNumber = clamp(10, 0, 20); + expect(clampedNumber).toEqual(10); + }); + + test('Should clamp a number smaller than the correct range to the lower bound in the range', () => { + const clampedNumber = clamp(5, 10, 20); + expect(clampedNumber).toEqual(10); + }); + + test('Should clamp a number larger than the correct range to the upper bound in the range', () => { + const clampedNumber = clamp(25, 10, 20); + expect(clampedNumber).toEqual(20); + }); + }); + + describe('classNames', () => { + test('Should generate the correct complete class string given an object of conditionals', () => { + expect( + classNames('foo', { + bar: true, + baz: false, + }), + ).toEqual('foo bar'); + + expect( + classNames('foo', { + bar: true, + baz: true, + }), + ).toEqual('foo bar baz'); + + expect( + classNames('foo', { + bar: false, + baz: false, + }), + ).toEqual('foo'); + }); + }); + + describe('filterEmpty', () => { + test('Should correctly determine if something is not null or undefined', () => { + expect(filterEmpty(0)).toEqual(true); + expect(filterEmpty(false)).toEqual(true); + expect(filterEmpty(null)).toEqual(false); + expect(filterEmpty(undefined)).toEqual(false); + }); + + test('Should correctly filter out null and undefined entries in arrays', () => { + expect([0, 1, 2, null, 4, undefined, 5].filter(filterEmpty)).toStrictEqual([0, 1, 2, 4, 5]); + expect(['false', false, { foo: 1 }, null, undefined].filter(filterEmpty)).toStrictEqual([ + 'false', + false, + { foo: 1 }, + ]); + }); + }); + + describe('attemptStringConversion', () => { + test('Should convert strings to strings', () => { + expect(attemptStringConversion('')).toEqual(''); + expect(attemptStringConversion('Foo')).toEqual('Foo'); + }); + test('Should convert numbers to strings', () => { + expect(attemptStringConversion(1.0101)).toEqual('1.0101'); + }); + test('Should convert arrays to strings', () => { + expect(attemptStringConversion([1.0101, 'Foo'])).toEqual('1.0101,Foo'); + }); + test('Should convert booleans to strings', () => { + expect(attemptStringConversion(true)).toEqual('true'); + expect(attemptStringConversion(false)).toEqual('false'); + }); + test('Should return null when attempting to convert non-stringable values', () => { + expect(attemptStringConversion(null)).toEqual(null); + expect(attemptStringConversion(undefined)).toEqual(null); + }); + }); + + describe('isMacOs', () => { + test('Should return true for Mac browsers', () => { + expect(isMacOs()).toEqual(true); + + mockNavigator.platform = 'MacPPC'; + expect(isMacOs()).toEqual(true); + + mockNavigator.platform = 'Mac68K'; + expect(isMacOs()).toEqual(true); + }); + + test('Should return false for Windows browsers', () => { + mockNavigator.platform = 'Win32'; + expect(isMacOs()).toEqual(false); + + mockNavigator.platform = 'Windows'; + expect(isMacOs()).toEqual(false); + }); + + test('Should return false for Linux browsers', () => { + mockNavigator.platform = 'Linux i686'; + expect(isMacOs()).toEqual(false); + + mockNavigator.platform = 'Linux x86_64'; + expect(isMacOs()).toEqual(false); + }); + }); + + describe('getSearchParameterNumber', () => { + test.each( + Object.keys(SearchParameters).map(key => ({ + key, + parameter: SearchParameters[key as keyof typeof SearchParameters], + })), + )('Should correctly parse out the $key specified in the URL search query parameter', ({ parameter }) => { + const random = Math.random(); + expect( + getSearchParameterNumber(parameter as SearchParameters, new URLSearchParams(`?${parameter}=${random}`)), + ).toBe(random); + }); + + test.each( + Object.keys(SearchParameters).map(key => ({ + key, + parameter: SearchParameters[key as keyof typeof SearchParameters], + })), + )('Should ignore non number values for the $key specified in the URL search query parameter', ({ parameter }) => { + expect(getSearchParameterNumber(parameter as SearchParameters, new URLSearchParams(`?${parameter}=foo`))).toBe( + null, + ); + }); + }); + + describe('parseJSONStream', () => { + test('Should be able to parse a really long JSON string', async () => { + const { readable, writable } = new TransformStream(); + + const writer = writable.getWriter(); + await writer.ready; + writer.write('{"activities":['); + const numOfActivities = 10000; + for (let i = 0; i < numOfActivities; i++) { + writer.write(JSON.stringify({ arguments: { metadata: {}, name: 'PeelBanana', peelDirection: 'fromTip' } })); + if (i < numOfActivities - 1) { + writer.write(','); + } + } + writer.write(']}'); + writer.close(); + + expect(await parseJSONStream(readable as unknown as ReadableStream)).toBeTypeOf('object'); + }); }); }); - -describe('isMacOs', () => { - test('Should return true for Mac browsers', () => { - expect(isMacOs()).toEqual(true); - - mockNavigator.platform = 'MacPPC'; - expect(isMacOs()).toEqual(true); - - mockNavigator.platform = 'Mac68K'; - expect(isMacOs()).toEqual(true); - }); - - test('Should return false for Windows browsers', () => { - mockNavigator.platform = 'Win32'; - expect(isMacOs()).toEqual(false); - - mockNavigator.platform = 'Windows'; - expect(isMacOs()).toEqual(false); - }); - - test('Should return false for Linux browsers', () => { - mockNavigator.platform = 'Linux i686'; - expect(isMacOs()).toEqual(false); - - mockNavigator.platform = 'Linux x86_64'; - expect(isMacOs()).toEqual(false); - }); -}); - -describe('getSearchParameterNumber', () => { - test.each( - Object.keys(SearchParameters).map(key => ({ - key, - parameter: SearchParameters[key as keyof typeof SearchParameters], - })), - )('Should correctly parse out the $key specified in the URL search query parameter', ({ parameter }) => { - const random = Math.random(); - expect( - getSearchParameterNumber(parameter as SearchParameters, new URLSearchParams(`?${parameter}=${random}`)), - ).toBe(random); - }); - - test.each( - Object.keys(SearchParameters).map(key => ({ - key, - parameter: SearchParameters[key as keyof typeof SearchParameters], - })), - )('Should ignore non number values for the $key specified in the URL search query parameter', ({ parameter }) => { - expect(getSearchParameterNumber(parameter as SearchParameters, new URLSearchParams(`?${parameter}=foo`))).toBe( - null, - ); - }); -}); - -describe('parseJSON', () => { - test.each([ - { - testCase: - '{"activities":[{"anchor_id":201,"anchored_to_start":true,"arguments":{"peelDirection":"fromTip"},"id":199,"metadata":{},"name":"PeelBanana","start_offset":"1 day 04:03:00.645","tags":[{"tag":{"color":"#c1def7","name":"bar"}},{"tag":{"color":"#ff0000","name":"flubber"}}],"type":"PeelBanana"},{"anchor_id":null,"anchored_to_start":true,"arguments":{"growingDuration":3600000000,"quantity":1},"id":200,"metadata":{},"name":"GrowBanana","start_offset":"00:29:51.705","tags":[{"tag":{"color":"#e0c3d4","name":"baz"}}],"type":"GrowBanana"},{"anchor_id":200,"anchored_to_start":true,"arguments":{"quantity":10},"id":201,"metadata":{},"name":"PickBanana","start_offset":"01:07:10.107","tags":[],"type":"PickBanana"},{"anchor_id":null,"anchored_to_start":true,"arguments":{"label":"unlabeled"},"id":202,"metadata":{},"name":"parent","start_offset":"00:00:00","tags":[],"type":"parent"},{"anchor_id":200,"anchored_to_start":false,"arguments":{"quantity":10},"id":203,"metadata":{},"name":"PickBanana","start_offset":"01:16:18.801","tags":[],"type":"PickBanana"},{"anchor_id":null,"anchored_to_start":true,"arguments":{"counter":0},"id":204,"metadata":{},"name":"child","start_offset":"00:00:00","tags":[],"type":"child"}],"duration":"24:00:00","id":61,"model_id":1,"name":"Banana Plan 39","simulation_arguments":{"initialProducer":"Chiquita3","initialPlantCount":203},"start_time":"2024-07-01T00:00:00.000+00:00","tags":[{"tag":{"color":"#ff0000","name":"foo"}},{"tag":{"color":"#c1def7","name":"bar"}}]}', - }, - { testCase: '{"a": 1, "b": "foo", "c": "bar"}' }, - ])('Should successfully parse strings into JSON objects', async ({ testCase }) => { - expect(await parseJSON(testCase)).toBeTypeOf('object'); - }); - - test.each([{ testCase: 'a: 1, "b": "foo", "c": "bar"' }])( - 'Should throw an error when parsing invalid JSON strings', - async ({ testCase }) => { - await expect(parseJSON(testCase)).rejects.toThrowError(/Unexpected/); - }, - ); -}); diff --git a/src/utilities/generic.ts b/src/utilities/generic.ts index c2a2ec67c9..841311e30d 100644 --- a/src/utilities/generic.ts +++ b/src/utilities/generic.ts @@ -374,15 +374,32 @@ export function downloadJSON(json: object, filename: string) { ); } +async function* streamAsyncIterable(stream: ReadableStream) { + const reader = stream.getReader(); + try { + while (true) { + // eslint-disable-next-line no-await-in-loop + const { done, value } = await reader.read(); + if (done) { + return; + } + yield value; + } + } finally { + reader.releaseLock(); + } +} + /** - * Utility function for parsing a long string into a JSON object + * Utility function for parsing a large JSON string into a JSON object * This function is more for very long strings that need to be broken up into chunks in order to * fully parse it into a JSON object without running out of memory - * @param jsonString + * @param jsonStream * @returns R */ -export async function parseJSON(jsonString: string): Promise { - return new Promise((resolve, reject) => { +export async function parseJSONStream(jsonStream: ReadableStream): Promise { + // eslint-disable-next-line no-async-promise-executor + return new Promise(async (resolve, reject) => { const jsonParser = new JSONParser({ paths: ['$.*'], stringBufferSize: undefined }); let finalJSON: R; jsonParser.onToken = ({ value }) => { @@ -402,7 +419,9 @@ export async function parseJSON(jsonString: string): Promise { }; try { - jsonParser.write(jsonString); + for await (const result of streamAsyncIterable(jsonStream)) { + jsonParser.write(result); + } } catch (e) { reject(e); } From 6703c6edd175ca1d798283b2d62e3b9a306a0ddd Mon Sep 17 00:00:00 2001 From: bduran Date: Tue, 6 Aug 2024 10:34:19 -0700 Subject: [PATCH 11/25] add download progress to all places that export plans --- src/components/plan/PlanForm.svelte | 10 ++- .../ui/DataGrid/DataGridActions.svelte | 80 ++++++++++++++++--- src/routes/plans/+page.svelte | 70 +++++++++++++--- src/utilities/generic.test.ts | 2 +- 4 files changed, 134 insertions(+), 28 deletions(-) diff --git a/src/components/plan/PlanForm.svelte b/src/components/plan/PlanForm.svelte index 09d212720a..bca20f5c79 100644 --- a/src/components/plan/PlanForm.svelte +++ b/src/components/plan/PlanForm.svelte @@ -2,7 +2,7 @@ {#if viewCallback} @@ -50,19 +85,16 @@ {/if} {#if downloadCallback} - {/if} @@ -101,3 +133,25 @@ {/if} + + diff --git a/src/routes/plans/+page.svelte b/src/routes/plans/+page.svelte index b4de0eff12..e560af8194 100644 --- a/src/routes/plans/+page.svelte +++ b/src/routes/plans/+page.svelte @@ -4,6 +4,7 @@ import { goto } from '$app/navigation'; import { base } from '$app/paths'; import { page } from '$app/stores'; + import CloseIcon from '@nasa-jpl/stellar/icons/close.svg?component'; import PlanIcon from '@nasa-jpl/stellar/icons/plan.svg?component'; import type { ICellRendererParams, ValueGetterParams } from 'ag-grid-community'; import XIcon from 'bootstrap-icons/icons/x.svg?component'; @@ -24,6 +25,7 @@ import SingleActionDataGrid from '../../components/ui/DataGrid/SingleActionDataGrid.svelte'; import IconCellRenderer from '../../components/ui/IconCellRenderer.svelte'; import Panel from '../../components/ui/Panel.svelte'; + import ProgressRadial from '../../components/ui/ProgressRadial.svelte'; import SectionTitle from '../../components/ui/SectionTitle.svelte'; import TagsInput from '../../components/ui/Tags/TagsInput.svelte'; import { InvalidDate } from '../../constants/time'; @@ -63,7 +65,7 @@ type CellRendererParams = { deletePlan: (plan: Plan) => void; - exportPlan: (plan: Plan) => void; + exportPlan: (plan: Plan, progressCallback?: (progress: number) => void, signal?: AbortSignal) => void; }; type PlanCellRendererParams = ICellRendererParams & CellRendererParams; @@ -195,6 +197,8 @@ let orderedModels: ModelSlim[] = []; let nameInputField: HTMLInputElement; let planTags: Tag[] = []; + let planExportAbortController: AbortController | null = null; + let planExportProgress: number | null = null; let selectedModel: ModelSlim | undefined; let selectedPlan: PlanSlim | undefined; let selectedPlanId: number | null = null; @@ -430,24 +434,46 @@ selectPlan(null); } - async function exportPlan(plan: PlanSlim): Promise { - const planTransfer = await getPlanForTransfer(plan, user); + async function exportPlan( + plan: PlanSlim, + progressCallback: (progress: number) => void, + signal: AbortSignal, + ): Promise { + const planTransfer = await getPlanForTransfer(plan, user, progressCallback, undefined, signal); - if (planTransfer) { + if (planTransfer && !signal.aborted) { downloadJSON(planTransfer, plan.name); } } - async function exportSelectedPlan(): Promise { + async function exportSelectedPlan() { if (selectedPlan) { - const planTransfer = await getPlanForTransfer(selectedPlan, user); + if (planExportAbortController) { + planExportAbortController.abort(); + } + + planExportProgress = 0; + planExportAbortController = new AbortController(); - if (planTransfer) { - downloadJSON(planTransfer, selectedPlan.name); + if (planExportAbortController && !planExportAbortController.signal.aborted) { + await exportPlan( + selectedPlan, + (progress: number) => { + planExportProgress = progress; + }, + planExportAbortController.signal, + ); } + planExportProgress = null; } } + function cancelSelectedPlanExport() { + planExportAbortController?.abort(); + planExportAbortController = null; + planExportProgress = null; + } + async function onTagsInputChange(event: TagsChangeEvent) { const { detail: { tag, type }, @@ -629,8 +655,16 @@ {#if selectedPlan} Selected plan
- {/if} @@ -987,6 +991,10 @@ grid-template-columns: auto min-content; } + .clear-export { + height: 1.3rem; + } + .error { color: var(--st-red); overflow: hidden; From 346264f0234f10d6ea3479734811025f33644e26 Mon Sep 17 00:00:00 2001 From: bduran Date: Tue, 6 Aug 2024 12:31:11 -0700 Subject: [PATCH 14/25] fix test --- src/utilities/plan.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/utilities/plan.ts b/src/utilities/plan.ts index fe06efd46c..3bbc931b5d 100644 --- a/src/utilities/plan.ts +++ b/src/utilities/plan.ts @@ -43,7 +43,7 @@ export async function getPlanForTransfer( progressCallback?.(0); const qualifiedActivityDirectiveChunks: ActivityDirectiveDB[][] = []; - for (let i = 0; i < chunkedActivities.length - 1; i++) { + for (let i = 0; i < chunkedActivities.length; i++) { if (!signal?.aborted) { const activitiesToQualifyChunk: ActivityDirectiveDB[] = chunkedActivities[i]; qualifiedActivityDirectiveChunks[i] = await Promise.all( From 6228af1f2db0b8f694013b61b646cb9f9c6e05c5 Mon Sep 17 00:00:00 2001 From: bduran Date: Tue, 6 Aug 2024 12:39:12 -0700 Subject: [PATCH 15/25] update testing docs to reflect playwright config dev change --- docs/TESTING.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/TESTING.md b/docs/TESTING.md index bb357b90f8..17211ebf02 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -4,12 +4,14 @@ This document describes the testing development workflow. End-to-end tests are r ## End-to-end -All end-to-end tests assume a production build of the project is available: +All end-to-end tests assume a production build of the project is available if run from CI: ```sh npm run build ``` +If you are running the tests locally, then the above step is not needed. Playwright will be using your local dev server rather than starting up its own node server that uses the `/build` directory. + All end-to-end tests also assume all Aerie services are running and available on `localhost`. See the example [docker-compose-test.yml](../docker-compose-test.yml) for an example of how to run the complete Aerie system. Notice we disable authentication for simplicity when running our end-to-end tests. You can reference the [Aerie deployment documentation](https://github.com/NASA-AMMOS/aerie/tree/develop/deployment) for more detailed deployment information. To execute end-to-end tests normally (i.e. not in debug mode), use the following command: From 7c7e613ce03a5972a10ea57a3163b66a7d99a531 Mon Sep 17 00:00:00 2001 From: bduran Date: Tue, 6 Aug 2024 13:49:29 -0700 Subject: [PATCH 16/25] add progress to plan menu export make a little more DRY --- src/components/menus/PlanMenu.svelte | 45 +++++++- src/components/plan/PlanForm.svelte | 24 +--- .../ui/DataGrid/DataGridActions.svelte | 4 +- src/routes/plans/+page.svelte | 22 ++-- src/utilities/plan.test.ts | 109 +++++++++++++++++- src/utilities/plan.ts | 20 +++- 6 files changed, 181 insertions(+), 43 deletions(-) diff --git a/src/components/menus/PlanMenu.svelte b/src/components/menus/PlanMenu.svelte index ec49568d91..81e6eb7a06 100644 --- a/src/components/menus/PlanMenu.svelte +++ b/src/components/menus/PlanMenu.svelte @@ -5,19 +5,20 @@ import { base } from '$app/paths'; import BranchIcon from '@nasa-jpl/stellar/icons/branch.svg?component'; import ChevronDownIcon from '@nasa-jpl/stellar/icons/chevron_down.svg?component'; + import CloseIcon from '@nasa-jpl/stellar/icons/close.svg?component'; import { PlanStatusMessages } from '../../enums/planStatusMessages'; import { planReadOnly } from '../../stores/plan'; import { viewTogglePanel } from '../../stores/views'; import type { User } from '../../types/app'; import type { Plan } from '../../types/plan'; import effects from '../../utilities/effects'; - import { downloadJSON } from '../../utilities/generic'; import { showPlanBranchesModal, showPlanMergeRequestsModal } from '../../utilities/modal'; import { permissionHandler } from '../../utilities/permissionHandler'; import { featurePermissions } from '../../utilities/permissions'; - import { getPlanForTransfer } from '../../utilities/plan'; + import { exportPlan } from '../../utilities/plan'; import Menu from '../menus/Menu.svelte'; import MenuItem from '../menus/MenuItem.svelte'; + import ProgressRadial from '../ui/ProgressRadial.svelte'; import MenuDivider from './MenuDivider.svelte'; export let plan: Plan; @@ -27,6 +28,8 @@ let hasCreatePlanBranchPermission: boolean = false; let hasCreateSnapshotPermission: boolean = false; let planMenu: Menu; + let planExportAbortController: AbortController | null = null; + let planExportProgress: number | null = null; $: hasCreateMergeRequestPermission = plan.parent_plan ? featurePermissions.planBranch.canCreateRequest( @@ -55,9 +58,32 @@ effects.createPlanSnapshot(plan, user); } - async function exportPlan() { - const planTransfer = await getPlanForTransfer(plan, user); - downloadJSON(planTransfer, planTransfer.name); + async function onExportPlan() { + if (planExportAbortController) { + planExportAbortController.abort(); + } + + planExportProgress = 0; + planExportAbortController = new AbortController(); + + if (planExportAbortController && !planExportAbortController.signal.aborted) { + await exportPlan( + plan, + user, + (progress: number) => { + planExportProgress = progress; + }, + undefined, + planExportAbortController.signal, + ); + } + planExportProgress = null; + } + + function onCancelExportPlan() { + planExportAbortController?.abort(); + planExportAbortController = null; + planExportProgress = null; } function viewSnapshotHistory() { @@ -147,7 +173,14 @@
- +
diff --git a/src/components/plan/PlanForm.svelte b/src/components/plan/PlanForm.svelte index bca20f5c79..05b406c875 100644 --- a/src/components/plan/PlanForm.svelte +++ b/src/components/plan/PlanForm.svelte @@ -18,10 +18,10 @@ import type { PlanSnapshot as PlanSnapshotType } from '../../types/plan-snapshot'; import type { PlanTagsInsertInput, Tag, TagsChangeEvent } from '../../types/tags'; import effects from '../../utilities/effects'; - import { downloadJSON, removeQueryParam, setQueryParam } from '../../utilities/generic'; + import { removeQueryParam, setQueryParam } from '../../utilities/generic'; import { permissionHandler } from '../../utilities/permissionHandler'; import { featurePermissions } from '../../utilities/permissions'; - import { getPlanForTransfer } from '../../utilities/plan'; + import { exportPlan } from '../../utilities/plan'; import { convertDoyToYmd, formatDate, getShortISOForDate } from '../../utilities/time'; import { tooltip } from '../../utilities/tooltip'; import { required, unique } from '../../utilities/validators'; @@ -152,7 +152,7 @@ } } - async function exportPlan() { + async function onExportPlan() { if (plan) { if (planExportAbortController) { planExportAbortController.abort(); @@ -161,7 +161,7 @@ planExportAbortController = new AbortController(); if (planExportAbortController && !planExportAbortController.signal.aborted) { - const planExport = await getPlanForTransfer( + await exportPlan( plan, user, (progress: number) => { @@ -170,28 +170,16 @@ Object.values(activityDirectivesMap), planExportAbortController.signal, ); - - if (planExport) { - downloadJSON(planExport, planExport.name); - } } planExportProgress = null; } } - function cancelPlanExport() { + function onCancelExportPlan() { planExportAbortController?.abort(); planExportAbortController = null; planExportProgress = null; } - - function onPlanExport() { - if (planExportProgress === null) { - exportPlan(); - } else { - cancelPlanExport(); - } - }
@@ -201,7 +189,7 @@ -
+ + {#if planExportProgress === null} + Export plan as .json + {:else} +
+ Cancel plan export + +
+ {/if} +
{#if plan.child_plans.length > 0} @@ -238,10 +237,9 @@ user-select: none; } - .export-button-container { + .cancel-plan-export { align-items: center; + column-gap: 0.25rem; display: flex; - justify-content: center; - padding: var(--aerie-menu-item-padding, 8px); } diff --git a/src/components/plan/PlanForm.svelte b/src/components/plan/PlanForm.svelte index 05b406c875..5d518ac10e 100644 --- a/src/components/plan/PlanForm.svelte +++ b/src/components/plan/PlanForm.svelte @@ -187,18 +187,27 @@
- + {:else} + + + {/if}
@@ -402,15 +411,23 @@ .export { border-radius: 50%; + height: 28px; position: relative; + width: 28px; } - .export .cancel { - display: none; + + .cancel-button { + background: none; + border: 0; + border-radius: 50%; + position: relative; + width: 28px; } - .export:hover .cancel { + .cancel-button .cancel { align-items: center; - display: flex; + cursor: pointer; + display: none; height: 100%; justify-content: center; left: 0; @@ -418,4 +435,12 @@ top: 0; width: 100%; } + + .cancel-button .cancel :global(svg) { + width: 10px; + } + + .cancel-button:hover .cancel { + display: flex; + } diff --git a/src/components/ui/DataGrid/DataGridActions.svelte b/src/components/ui/DataGrid/DataGridActions.svelte index b2b4b41d46..1492eaed85 100644 --- a/src/components/ui/DataGrid/DataGridActions.svelte +++ b/src/components/ui/DataGrid/DataGridActions.svelte @@ -63,11 +63,14 @@ } } + function onCancelDownload() { + downloadAbortController?.abort(); + downloadAbortController = null; + downloadProgress = null; + } + function progressCallback(progress: number) { downloadProgress = progress; - if (downloadProgress === 100) { - downloadProgress = null; - } } @@ -85,18 +88,24 @@ {/if} {#if downloadCallback} - + {:else} + + + {/if} {/if} {#if editCallback}
- +
{:else}
From 18b9838894132f8c2bd22f1ed25d51733e76dea5 Mon Sep 17 00:00:00 2001 From: bduran Date: Wed, 7 Aug 2024 13:36:07 -0700 Subject: [PATCH 21/25] add background to plan download progress radial keep download progress shown while downloading from table --- src/components/menus/PlanMenu.svelte | 3 +- src/components/plan/PlanForm.svelte | 3 +- .../ui/DataGrid/DataGridActions.svelte | 40 ++++++++++++++----- src/components/ui/ProgressRadial.svelte | 2 +- src/css/ag-grid-stellar.css | 4 ++ src/routes/plans/+page.svelte | 3 +- 6 files changed, 42 insertions(+), 13 deletions(-) diff --git a/src/components/menus/PlanMenu.svelte b/src/components/menus/PlanMenu.svelte index 632ad081d8..133dc839ab 100644 --- a/src/components/menus/PlanMenu.svelte +++ b/src/components/menus/PlanMenu.svelte @@ -182,7 +182,7 @@ {:else}
Cancel plan export - +
{/if} @@ -243,6 +243,7 @@ } .cancel-plan-export { + --progress-radial-background: var(--st-gray-20); align-items: center; column-gap: 0.25rem; display: flex; diff --git a/src/components/plan/PlanForm.svelte b/src/components/plan/PlanForm.svelte index 5d518ac10e..b6566955d3 100644 --- a/src/components/plan/PlanForm.svelte +++ b/src/components/plan/PlanForm.svelte @@ -196,7 +196,7 @@ placement: 'top', }} > - +
{:else} @@ -417,6 +417,7 @@ } .cancel-button { + --progress-radial-background: var(--st-gray-20); background: none; border: 0; border-radius: 50%; diff --git a/src/components/ui/DataGrid/DataGridActions.svelte b/src/components/ui/DataGrid/DataGridActions.svelte index 1492eaed85..f1c908bd6e 100644 --- a/src/components/ui/DataGrid/DataGridActions.svelte +++ b/src/components/ui/DataGrid/DataGridActions.svelte @@ -22,14 +22,18 @@ }; export let rowData: RowData | undefined; + export let editButtonClass: string | undefined = undefined; export let editTooltip: Tooltip | undefined = undefined; + export let deleteButtonClass: string | undefined = undefined; export let deleteTooltip: Tooltip | undefined = undefined; + export let downloadButtonClass: string | undefined = undefined; export let downloadTooltip: Tooltip | undefined = undefined; export let hasDeletePermission: boolean = true; export let hasDeletePermissionError: string | undefined = undefined; export let hasEditPermission: boolean = true; export let hasEditPermissionError: string | undefined = undefined; export let useExportIcon: boolean | undefined = undefined; + export let viewButtonClass: string | undefined = undefined; export let viewTooltip: Tooltip | undefined = undefined; export let editCallback: ((data: RowData) => void) | undefined = undefined; @@ -76,7 +80,9 @@ {#if viewCallback} {:else} {/if} {/if} {#if editCallback} {/if} @@ -1006,6 +1006,7 @@ } .cancel-button { + --progress-radial-background: var(--st-gray-20); background: none; border: 0; position: relative; From 3a881b501d8db6b3ffe94dbc07242de928b99713 Mon Sep 17 00:00:00 2001 From: bduran Date: Wed, 7 Aug 2024 14:24:13 -0700 Subject: [PATCH 22/25] set plan creation state for plan import --- src/utilities/effects.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/utilities/effects.ts b/src/utilities/effects.ts index a4b01bd275..f952ac083d 100644 --- a/src/utilities/effects.ts +++ b/src/utilities/effects.ts @@ -3925,6 +3925,9 @@ const effects = { if (!gatewayPermissions.IMPORT_PLAN(user)) { throwPermissionError('import a plan'); } + + creatingPlan.set(true); + const file: File = files[0]; const duration = getIntervalFromDoyRange(startTime, endTime); @@ -3942,6 +3945,7 @@ const effects = { const createdPlan = await reqGateway('/importPlan', 'POST', body, user, true); + creatingPlan.set(false); if (createdPlan != null) { return createdPlan; } @@ -3949,6 +3953,7 @@ const effects = { return null; } catch (e) { catchError(e as Error); + creatingPlan.set(false); return null; } }, From 80f22629c367f1662ef9269f2aa44424fa0e52ab Mon Sep 17 00:00:00 2001 From: bduran Date: Wed, 7 Aug 2024 14:32:12 -0700 Subject: [PATCH 23/25] disable plan creation button during creation --- src/routes/plans/+page.svelte | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/routes/plans/+page.svelte b/src/routes/plans/+page.svelte index 1b0b71af45..43e4890191 100644 --- a/src/routes/plans/+page.svelte +++ b/src/routes/plans/+page.svelte @@ -343,7 +343,8 @@ $endTimeField.dirtyAndValid && $modelIdField.dirtyAndValid && $nameField.dirtyAndValid && - $startTimeField.dirtyAndValid; + $startTimeField.dirtyAndValid && + !$creatingPlan; $: if ($creatingPlan) { createPlanButtonText = planUploadFiles ? 'Creating from .json...' : 'Creating...'; } else { From 708fa64404cf904fbf2c06cddeecb548c50305e2 Mon Sep 17 00:00:00 2001 From: bduran Date: Wed, 7 Aug 2024 14:37:58 -0700 Subject: [PATCH 24/25] properly reset plan form fields on plan creation --- src/routes/plans/+page.svelte | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/routes/plans/+page.svelte b/src/routes/plans/+page.svelte index 43e4890191..01c2897e9b 100644 --- a/src/routes/plans/+page.svelte +++ b/src/routes/plans/+page.svelte @@ -400,9 +400,9 @@ ); planUploadFileInput.value = ''; planUploadFiles = undefined; - $startTimeField.value = ''; - $endTimeField.value = ''; - $nameField.value = ''; + startTimeField.reset(''); + endTimeField.reset(''); + nameField.reset(''); } else { const newPlan: PlanSlim | null = await effects.createPlan( endTime, @@ -423,9 +423,9 @@ plans.updateValue(storePlans => [...storePlans, newPlan]); } await effects.createPlanTags(newPlanTags, newPlan, user); - $startTimeField.value = ''; - $endTimeField.value = ''; - $nameField.value = ''; + startTimeField.reset(''); + endTimeField.reset(''); + nameField.reset(''); } } } From c0e1501acd30fda7bb8a4baae9bf02f25c9dd59f Mon Sep 17 00:00:00 2001 From: bduran Date: Wed, 7 Aug 2024 14:52:21 -0700 Subject: [PATCH 25/25] hide plan import form with CSS instead --- src/routes/plans/+page.svelte | 54 ++++++++++++++++++----------------- 1 file changed, 28 insertions(+), 26 deletions(-) diff --git a/src/routes/plans/+page.svelte b/src/routes/plans/+page.svelte index 01c2897e9b..05b3d3cca8 100644 --- a/src/routes/plans/+page.svelte +++ b/src/routes/plans/+page.svelte @@ -779,32 +779,30 @@ - {#if isPlanImportMode} -
- - -
- -
- {#if planUploadFilesError} -
{planUploadFilesError}
- {/if} -
- {/if} + @@ -995,6 +993,10 @@ position: relative; } + .plan-import-container[hidden] { + display: none; + } + .transfer-button-container { display: grid; grid-template-columns: auto auto;