Skip to content

Commit

Permalink
Feature: Upload external resources (#1492)
Browse files Browse the repository at this point in the history
* fix collapse clickthrough

* move scheduling enum file to enums directory

* Add ability to upload external dataset to plan

* add ability to associate simulation dataset to external datasets

* styling

* add e2e tests for dataset uploading

* update design

* add toasts for external dataset upload

* cleanup

* ensure unique resource name

* add styling

* fix unique profile names

* fix styling
fix unique list of types

* fix test
  • Loading branch information
duranb authored Oct 23, 2024
1 parent 7ec44aa commit 08e06cb
Show file tree
Hide file tree
Showing 16 changed files with 369 additions and 14 deletions.
6 changes: 6 additions & 0 deletions e2e-tests/data/external-dataset.csv
Original file line number Diff line number Diff line change
@@ -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
51 changes: 51 additions & 0 deletions e2e-tests/data/external-dataset.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
}
16 changes: 16 additions & 0 deletions e2e-tests/fixtures/Plan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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' })));
Expand Down Expand Up @@ -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();
Expand Down
58 changes: 58 additions & 0 deletions e2e-tests/tests/plan-resources.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
2 changes: 1 addition & 1 deletion src/components/Collapse.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
152 changes: 149 additions & 3 deletions src/components/ResourceList.svelte
Original file line number Diff line number Diff line change
@@ -1,23 +1,67 @@
<svelte:options immutable={true} />

<script lang="ts">
import { resourceTypes } from '../stores/simulation';
import CloseIcon from '@nasa-jpl/stellar/icons/close.svg?component';
import UploadIcon from '@nasa-jpl/stellar/icons/upload.svg?component';
import { plan } from '../stores/plan';
import { allResourceTypes, simulationDatasetId } from '../stores/simulation';
import type { User } from '../types/app';
import type { ResourceType } from '../types/simulation';
import type { TimelineItemType } from '../types/timeline';
import effects from '../utilities/effects';
import { permissionHandler } from '../utilities/permissionHandler';
import { featurePermissions } from '../utilities/permissions';
import { tooltip } from '../utilities/tooltip';
import ResourceListPrefix from './ResourceListPrefix.svelte';
import TimelineItemList from './TimelineItemList.svelte';
import Input from './form/Input.svelte';
export let user: User | null;
const uploadPermissionError: string = `You do not have permission to upload resources.`;
let resourceDataTypes: string[] = [];
let hasUploadPermission: boolean = false;
let isUploadVisible: boolean = false;
let useSelectedSimulation: boolean = false;
let uploadFiles: FileList | undefined;
let uploadFileInput: HTMLInputElement;
$: resourceDataTypes = [...new Set($resourceTypes.map(t => t.schema.type))];
$: resourceDataTypes = [...new Set($allResourceTypes.map(t => t.schema.type))];
$: if (user !== null && $plan !== null) {
hasUploadPermission = featurePermissions.externalResources.canCreate(user, $plan);
}
function getFilterValueFromItem(item: TimelineItemType) {
return (item as ResourceType).schema.type;
}
function onShowUpload() {
isUploadVisible = true;
}
function onHideUpload() {
isUploadVisible = false;
}
async function onUpload() {
if (uploadFiles !== undefined) {
if ($plan && uploadFiles?.length) {
await effects.uploadExternalDataset(
$plan,
uploadFiles,
user,
useSelectedSimulation ? $simulationDatasetId : undefined,
);
}
uploadFileInput.value = '';
uploadFiles = undefined;
}
}
</script>

<TimelineItemList
items={$resourceTypes}
items={$allResourceTypes}
chartType="line"
typeName="resource"
typeNamePlural="Resources"
Expand All @@ -26,5 +70,107 @@
{getFilterValueFromItem}
let:prop={item}
>
<div slot="header" class="upload-container" hidden={!isUploadVisible}>
<button class="close-upload" type="button" on:click={onHideUpload}>
<CloseIcon />
</button>
<Input layout="stacked">
<label class="st-typography-body" for="file">Resource File</label>
<input
class="w-100"
name="file"
type="file"
accept="application/json,.csv,.txt"
bind:files={uploadFiles}
bind:this={uploadFileInput}
use:permissionHandler={{
hasPermission: hasUploadPermission,
permissionError: uploadPermissionError,
}}
/>
</Input>
<div class="use-simulation">
<label class="st-typography-body timeline-item-list-filter-option-label" for="simulation-association">
Use selected simulation
</label>
<input
bind:checked={useSelectedSimulation}
class="simulation-checkbox"
type="checkbox"
name="simulation-association"
/>
</div>
<div class="upload-button-container">
<button
class="st-button secondary"
disabled={!uploadFiles?.length}
on:click={onUpload}
use:permissionHandler={{
hasPermission: hasUploadPermission,
permissionError: uploadPermissionError,
}}
>
Upload
</button>
</div>
</div>
<div slot="button">
<button
class="st-button secondary"
on:click={onShowUpload}
use:permissionHandler={{
hasPermission: hasUploadPermission,
permissionError: uploadPermissionError,
}}
use:tooltip={{ content: 'Upload Resources' }}
>
<UploadIcon />
</button>
</div>
<ResourceListPrefix {item} />
</TimelineItemList>

<style>
.upload-container {
background: var(--st-gray-15);
border-radius: 5px;
margin: 5px;
padding: 8px 11px 8px;
position: relative;
}
.upload-container[hidden] {
display: none;
}
.upload-container {
display: grid;
row-gap: 8px;
}
.upload-container .use-simulation {
column-gap: 8px;
display: grid;
grid-template-columns: max-content auto;
justify-content: space-between;
justify-self: left;
margin: 0;
width: 100%;
}
.upload-container :global(.upload-button-container) {
display: flex;
flex-flow: row-reverse;
}
.upload-container :global(.close-upload) {
background: none;
border: 0;
cursor: pointer;
height: 1.3rem;
padding: 0;
position: absolute;
right: 3px;
top: 3px;
}
</style>
10 changes: 7 additions & 3 deletions src/components/TimelineItemList.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

<script lang="ts">
import ChevronDownIcon from '@nasa-jpl/stellar/icons/chevron_down.svg?component';
import GripVerticalIcon from 'bootstrap-icons/icons/grip-vertical.svg?component';
import { capitalize } from 'lodash-es';
import PlusCircledIcon from '../assets/plus-circled.svg?component';
Expand Down Expand Up @@ -145,7 +146,7 @@
autocomplete="off"
placeholder="Filter {typeName} types"
/>
<div style="position: relative">
<div class="filter-buttons">
<button
class="st-button secondary menu-button"
style="position: relative; z-index: 1"
Expand Down Expand Up @@ -186,9 +187,11 @@
</div>
</Menu>
</div>
<slot name="button" />
</div>

<div class="controls">
<slot name="header" />
<div class="controls-header st-typography-medium">
<div>{typeNamePlural} ({filteredItems.length})</div>
<div>
Expand Down Expand Up @@ -394,9 +397,10 @@
flex: 1;
}
.controls-header .st-button {
.filter-buttons {
display: flex;
gap: 4px;
height: 20px;
position: relative;
}
.list-items {
Expand Down
Loading

0 comments on commit 08e06cb

Please sign in to comment.