Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature: Upload external resources #1492

Merged
merged 14 commits into from
Oct 23, 2024
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
AaronPlave marked this conversation as resolved.
Show resolved Hide resolved
AaronPlave marked this conversation as resolved.
Show resolved Hide resolved
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 {
AaronPlave marked this conversation as resolved.
Show resolved Hide resolved
display: flex;
gap: 4px;
height: 20px;
position: relative;
}

.list-items {
Expand Down
Loading
Loading