diff --git a/e2e-tests/data/external-dataset.csv b/e2e-tests/data/external-dataset.csv new file mode 100644 index 0000000000..4d968098a9 --- /dev/null +++ b/e2e-tests/data/external-dataset.csv @@ -0,0 +1,6 @@ +time_utc,TotalPower,BatteryStateOfCharge,Temperature +2024-245T00:01:00.0,0.0,143.15,0.0 +2024-245T00:02:00.0,384.999999940483,1.4,-12.0964867663028 +2024-245T00:03:00.0,384.999999399855,137.45,-12.0974993557598 +2024-245T00:04:00.0,385.000010807604,134.85,-12.0985125609155 +2024-245T00:05:00.0,381.80000002749,132.4,-12.0995253838464 diff --git a/e2e-tests/data/external-dataset.json b/e2e-tests/data/external-dataset.json new file mode 100644 index 0000000000..b0b1bf49e8 --- /dev/null +++ b/e2e-tests/data/external-dataset.json @@ -0,0 +1,51 @@ +{ + "datasetStart": "2024-245T14:00:00", + "profileSet": { + "/awake": { + "schema": { + "type": "string" + }, + "segments": [ + { + "duration": 3000000000, + "dynamics": "foo" + }, + { + "duration": 3000000000, + "dynamics": "bar" + } + ], + "type": "discrete" + }, + "/batteryEnergy": { + "schema": { + "items": { + "initial": { + "type": "real" + }, + "rate": { + "type": "real" + } + }, + "type": "struct" + }, + "segments": [ + { + "duration": 40000000, + "dynamics": { + "initial": 100, + "rate": -0.5 + } + }, + { + "duration": 30000000, + "dynamics": { + "initial": 35, + "rate": -0.1 + } + } + ], + "type": "real" + } + } +} \ No newline at end of file diff --git a/e2e-tests/fixtures/Plan.ts b/e2e-tests/fixtures/Plan.ts index 24f919060f..21bffc778f 100644 --- a/e2e-tests/fixtures/Plan.ts +++ b/e2e-tests/fixtures/Plan.ts @@ -94,6 +94,7 @@ export class Plan { } async addActivity(name: string = 'GrowBanana') { + await this.showPanel(PanelNames.TIMELINE_ITEMS); const currentNumOfActivitiesWithName = await this.panelActivityDirectivesTable.getByRole('row', { name }).count(); const activityListItem = this.page.locator(`.list-item :text-is("${name}")`); const activityRow = this.page @@ -253,6 +254,13 @@ export class Plan { await this.panelActivityForm.getByPlaceholder('Enter preset name').blur(); } + async fillExternalDatasetFileInput(importFilePath: string) { + const inputFile = this.page.locator('input[name="file"]'); + await inputFile.focus(); + await inputFile.setInputFiles(importFilePath); + await inputFile.evaluate(e => e.blur()); + } + async fillPlanName(name: string) { await this.planNameInput.fill(name); await this.planNameInput.evaluate(e => e.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' }))); @@ -554,6 +562,14 @@ export class Plan { this.schedulingSatisfiedActivity = page.locator('.scheduling-goal-analysis-activities-list > .satisfied-activity'); } + async uploadExternalDatasets(importFilePath: string) { + await this.panelActivityTypes.getByRole('button', { exact: true, name: 'Resources' }).click(); + await this.panelActivityTypes.getByRole('button', { exact: true, name: 'Upload Resources' }).click(); + await this.fillExternalDatasetFileInput(importFilePath); + await expect(this.panelActivityTypes.getByRole('button', { exact: true, name: 'Upload' })).toBeEnabled(); + await this.panelActivityTypes.getByRole('button', { exact: true, name: 'Upload' }).click(); + } + async waitForActivityCheckingStatus(status: Status) { await expect(this.page.locator(this.activityCheckingStatusSelector(status))).toBeAttached({ timeout: 10000 }); await expect(this.page.locator(this.activityCheckingStatusSelector(status))).toBeVisible(); diff --git a/e2e-tests/tests/plan-resources.test.ts b/e2e-tests/tests/plan-resources.test.ts new file mode 100644 index 0000000000..495df1e071 --- /dev/null +++ b/e2e-tests/tests/plan-resources.test.ts @@ -0,0 +1,58 @@ +import test, { expect, type BrowserContext, type Page } from '@playwright/test'; +import { Constraints } from '../fixtures/Constraints.js'; +import { Models } from '../fixtures/Models.js'; +import { Plan } from '../fixtures/Plan.js'; +import { Plans } from '../fixtures/Plans.js'; +import { SchedulingConditions } from '../fixtures/SchedulingConditions.js'; +import { SchedulingGoals } from '../fixtures/SchedulingGoals.js'; + +let constraints: Constraints; +let context: BrowserContext; +let models: Models; +let page: Page; +let plan: Plan; +let plans: Plans; +let schedulingConditions: SchedulingConditions; +let schedulingGoals: SchedulingGoals; + +test.beforeAll(async ({ baseURL, browser }) => { + context = await browser.newContext(); + page = await context.newPage(); + + models = new Models(page); + plans = new Plans(page, models); + constraints = new Constraints(page); + schedulingConditions = new SchedulingConditions(page); + schedulingGoals = new SchedulingGoals(page); + plan = new Plan(page, plans, constraints, schedulingGoals, schedulingConditions); + + await models.goto(); + await models.createModel(baseURL); + await plans.goto(); + await plans.createPlan(); + await plan.goto(); +}); + +test.afterAll(async () => { + await plans.goto(); + await plans.deletePlan(); + await models.goto(); + await models.deleteModel(); + await page.close(); + await context.close(); +}); + +test.describe.serial('Plan Resources', () => { + test('Uploading external plan dataset file - JSON', async () => { + await plan.uploadExternalDatasets('e2e-tests/data/external-dataset.json'); + await expect(plan.panelActivityTypes.getByText('/awake')).toBeVisible(); + await expect(plan.panelActivityTypes.getByText('/batteryEnergy')).toBeVisible(); + }); + + test('Uploading external plan dataset file - CSV', async () => { + await plan.uploadExternalDatasets('e2e-tests/data/external-dataset.csv'); + await expect(plan.panelActivityTypes.getByText('TotalPower')).toBeVisible(); + await expect(plan.panelActivityTypes.getByText('BatteryStateOfCharge')).toBeVisible(); + await expect(plan.panelActivityTypes.getByText('Temperature')).toBeVisible(); + }); +}); diff --git a/src/components/Collapse.svelte b/src/components/Collapse.svelte index b705754a3d..48ac6c4a34 100644 --- a/src/components/Collapse.svelte +++ b/src/components/Collapse.svelte @@ -40,7 +40,7 @@ class:static={!collapsible} class:expanded style:height={`${headerHeight}px`} - on:click={() => { + on:click|stopPropagation={() => { if (collapsible) { expanded = !expanded; dispatch('collapse', !expanded); diff --git a/src/components/ResourceList.svelte b/src/components/ResourceList.svelte index 1c8cafa4eb..c0f984a78a 100644 --- a/src/components/ResourceList.svelte +++ b/src/components/ResourceList.svelte @@ -1,23 +1,67 @@ + +
+ +
+ + diff --git a/src/components/TimelineItemList.svelte b/src/components/TimelineItemList.svelte index 0086b5433b..0e3314d6a3 100644 --- a/src/components/TimelineItemList.svelte +++ b/src/components/TimelineItemList.svelte @@ -2,6 +2,7 @@ @@ -33,7 +35,7 @@ - + diff --git a/src/components/scheduling/goals/SchedulingGoalForm.svelte b/src/components/scheduling/goals/SchedulingGoalForm.svelte index ee78e0176d..497550f249 100644 --- a/src/components/scheduling/goals/SchedulingGoalForm.svelte +++ b/src/components/scheduling/goals/SchedulingGoalForm.svelte @@ -4,8 +4,8 @@ import { goto } from '$app/navigation'; import { base } from '$app/paths'; import { createEventDispatcher } from 'svelte'; - import { SchedulingType } from '../../../constants/scheduling'; import { DefinitionType } from '../../../enums/association'; + import { SchedulingType } from '../../../enums/scheduling'; import { SearchParameters } from '../../../enums/searchParameters'; import { schedulingGoals } from '../../../stores/scheduling'; import type { User, UserId } from '../../../types/app'; diff --git a/src/constants/scheduling.ts b/src/enums/scheduling.ts similarity index 100% rename from src/constants/scheduling.ts rename to src/enums/scheduling.ts diff --git a/src/stores/simulation.ts b/src/stores/simulation.ts index 01a3a1af53..01e60b29a6 100644 --- a/src/stores/simulation.ts +++ b/src/stores/simulation.ts @@ -100,6 +100,15 @@ export const selectedSimulationEventId: Writable = writable(null) /* Derived. */ +export const allResourceTypes: Readable = derived( + [resourceTypes, externalResources], + ([$resourceTypes, $externalResources]) => { + return $resourceTypes + .map(({ name, schema }) => ({ name, schema })) + .concat($externalResources.map(({ name, schema }) => ({ name, schema }))); + }, +); + export const spansMap: Readable = derived(spans, $spans => keyBy($spans, 'span_id')); export const spanUtilityMaps: Readable = derived(spans, $spans => { diff --git a/src/types/scheduling.ts b/src/types/scheduling.ts index d52e8fb072..fbf883cf16 100644 --- a/src/types/scheduling.ts +++ b/src/types/scheduling.ts @@ -1,4 +1,4 @@ -import type { SchedulingType } from '../constants/scheduling'; +import type { SchedulingType } from '../enums/scheduling'; import type { PartialWith } from './app'; import type { SchedulingError } from './errors'; import type { BaseDefinition, BaseMetadata } from './metadata'; diff --git a/src/types/simulation.ts b/src/types/simulation.ts index 3180d65fb6..638aa5481b 100644 --- a/src/types/simulation.ts +++ b/src/types/simulation.ts @@ -7,6 +7,7 @@ import type { Subscription } from './subscribable'; export type PlanDataset = { dataset: { profiles: Profile[] }; + dataset_id: number; offset_from_plan_start: string; }; diff --git a/src/utilities/effects.ts b/src/utilities/effects.ts index d73c0b3e35..4d99804b0f 100644 --- a/src/utilities/effects.ts +++ b/src/utilities/effects.ts @@ -7,8 +7,8 @@ import { type ParameterDictionary as AmpcsParameterDictionary, } from '@nasa-jpl/aerie-ampcs'; import { get } from 'svelte/store'; -import { SchedulingType } from '../constants/scheduling'; import { DictionaryTypes } from '../enums/dictionaryTypes'; +import { SchedulingType } from '../enums/scheduling'; import { SearchParameters } from '../enums/searchParameters'; import { Status } from '../enums/status'; import { activityDirectivesDB, selectedActivityDirectiveId } from '../stores/activities'; @@ -213,7 +213,7 @@ import type { import type { Row, Timeline } from '../types/timeline'; import type { View, ViewDefinition, ViewInsertInput, ViewSlim, ViewUpdateInput } from '../types/view'; import { ActivityDeletionAction } from './activities'; -import { convertToQuery, getSearchParameterNumber, setQueryParam } from './generic'; +import { compare, convertToQuery, getSearchParameterNumber, setQueryParam } from './generic'; import gql, { convertToGQLArray } from './gql'; import { showConfirmModal, @@ -4230,12 +4230,26 @@ const effects = { const { plan_dataset: plan_datasets } = data; if (plan_datasets != null) { let resources: Resource[] = []; + + const profileMap: Set = new Set(); + plan_datasets.sort(({ dataset_id: datasetIdA }, { dataset_id: datasetIdB }) => { + return compare(datasetIdA, datasetIdB, false); + }); + for (const dataset of plan_datasets) { const { dataset: { profiles }, offset_from_plan_start, } = dataset; - const sampledResources: Resource[] = sampleProfiles(profiles, startTimeYmd, offset_from_plan_start); + const uniqueProfiles: Profile[] = profiles.filter(profile => { + if (!profileMap.has(profile.name)) { + profileMap.add(profile.name); + return true; + } + return false; + }); + + const sampledResources: Resource[] = sampleProfiles(uniqueProfiles, startTimeYmd, offset_from_plan_start); resources = [...resources, ...sampledResources]; } return { aborted: false, resources }; @@ -6405,6 +6419,39 @@ const effects = { } }, + async uploadExternalDataset( + plan: Plan, + files: FileList, + user: User | null, + simulationDatasetId?: number, + ): Promise { + try { + if (!gatewayPermissions.ADD_EXTERNAL_DATASET(user, plan)) { + throwPermissionError('add external datasets'); + } + + const file: File = files[0]; + + const body = new FormData(); + body.append('plan_id', `${plan.id}`); + body.append('simulation_dataset_id', `${simulationDatasetId}`); + body.append('external_dataset', file, file.name); + + const uploadedDatasetId = await reqGateway('/uploadDataset', 'POST', body, user, true); + + if (uploadedDatasetId != null) { + showSuccessToast('External Dataset Uploaded Successfully'); + return uploadedDatasetId; + } + + throw Error('External Dataset Upload Failed'); + } catch (e) { + catchError(e as Error); + showFailureToast('External Dataset Upload Failed'); + return null; + } + }, + async uploadFile(file: File, user: User | null): Promise { try { const body = new FormData(); diff --git a/src/utilities/gql.ts b/src/utilities/gql.ts index a5905915e1..795321f62f 100644 --- a/src/utilities/gql.ts +++ b/src/utilities/gql.ts @@ -5,6 +5,7 @@ export enum Queries { ACTIVITY_DIRECTIVE_VALIDATIONS = 'activity_directive_validations', ACTIVITY_PRESETS = 'activity_presets', ACTIVITY_TYPES = 'activity_type', + ADD_EXTERNAL_DATASET = 'addExternalDataset', ANCHOR_VALIDATION_STATUS = 'anchor_validation_status', APPLY_PRESET_TO_ACTIVITY = 'apply_preset_to_activity', BEGIN_MERGE = 'begin_merge', @@ -1802,6 +1803,7 @@ const gql = { type } } + dataset_id offset_from_plan_start } } diff --git a/src/utilities/permissions.ts b/src/utilities/permissions.ts index cf62f7b81c..0bfb68456a 100644 --- a/src/utilities/permissions.ts +++ b/src/utilities/permissions.ts @@ -24,7 +24,7 @@ import type { SchedulingGoalMetadata, } from '../types/scheduling'; import type { Parcel, UserSequence, Workspace } from '../types/sequencing'; -import type { Simulation, SimulationTemplate } from '../types/simulation'; +import type { PlanDataset, Simulation, SimulationTemplate } from '../types/simulation'; import type { Tag } from '../types/tags'; import type { View, ViewSlim } from '../types/view'; import gql, { Queries } from './gql'; @@ -1149,6 +1149,12 @@ const queryPermissions: Record b }; const gatewayPermissions = { + ADD_EXTERNAL_DATASET: (user: User | null, plan: PlanWithOwners): boolean => { + const queries = [getFunctionPermission(Queries.ADD_EXTERNAL_DATASET)]; + return ( + isUserAdmin(user) || (getPermission(queries, user) && (isPlanOwner(user, plan) || isPlanCollaborator(user, plan))) + ); + }, IMPORT_PLAN: (user: User | null) => { return ( isUserAdmin(user) || @@ -1302,6 +1308,7 @@ interface FeaturePermissions { expansionSequences: ExpansionSequenceCRUDPermission>; expansionSets: ExpansionSetsCRUDPermission>; externalEventType: CRUDPermission; + externalResources: PlanAssetCRUDPermission; externalSource: CRUDPermission; externalSourceType: CRUDPermission; model: CRUDPermission; @@ -1414,6 +1421,12 @@ const featurePermissions: FeaturePermissions = { canRead: user => queryPermissions.SUB_EXTERNAL_EVENT_TYPES(user), canUpdate: () => false, // no feature to update external event types }, + externalResources: { + canCreate: (user, plan) => gatewayPermissions.ADD_EXTERNAL_DATASET(user, plan), + canDelete: () => false, + canRead: () => true, + canUpdate: () => true, + }, externalSource: { canCreate: user => queryPermissions.CREATE_EXTERNAL_SOURCE(user), canDelete: (user, externalSources) => queryPermissions.DELETE_EXTERNAL_SOURCES(user, externalSources),