Skip to content

Commit 08e06cb

Browse files
authored
Feature: Upload external resources (#1492)
* 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
1 parent 7ec44aa commit 08e06cb

File tree

16 files changed

+369
-14
lines changed

16 files changed

+369
-14
lines changed
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
time_utc,TotalPower,BatteryStateOfCharge,Temperature
2+
2024-245T00:01:00.0,0.0,143.15,0.0
3+
2024-245T00:02:00.0,384.999999940483,1.4,-12.0964867663028
4+
2024-245T00:03:00.0,384.999999399855,137.45,-12.0974993557598
5+
2024-245T00:04:00.0,385.000010807604,134.85,-12.0985125609155
6+
2024-245T00:05:00.0,381.80000002749,132.4,-12.0995253838464
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
{
2+
"datasetStart": "2024-245T14:00:00",
3+
"profileSet": {
4+
"/awake": {
5+
"schema": {
6+
"type": "string"
7+
},
8+
"segments": [
9+
{
10+
"duration": 3000000000,
11+
"dynamics": "foo"
12+
},
13+
{
14+
"duration": 3000000000,
15+
"dynamics": "bar"
16+
}
17+
],
18+
"type": "discrete"
19+
},
20+
"/batteryEnergy": {
21+
"schema": {
22+
"items": {
23+
"initial": {
24+
"type": "real"
25+
},
26+
"rate": {
27+
"type": "real"
28+
}
29+
},
30+
"type": "struct"
31+
},
32+
"segments": [
33+
{
34+
"duration": 40000000,
35+
"dynamics": {
36+
"initial": 100,
37+
"rate": -0.5
38+
}
39+
},
40+
{
41+
"duration": 30000000,
42+
"dynamics": {
43+
"initial": 35,
44+
"rate": -0.1
45+
}
46+
}
47+
],
48+
"type": "real"
49+
}
50+
}
51+
}

e2e-tests/fixtures/Plan.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ export class Plan {
9494
}
9595

9696
async addActivity(name: string = 'GrowBanana') {
97+
await this.showPanel(PanelNames.TIMELINE_ITEMS);
9798
const currentNumOfActivitiesWithName = await this.panelActivityDirectivesTable.getByRole('row', { name }).count();
9899
const activityListItem = this.page.locator(`.list-item :text-is("${name}")`);
99100
const activityRow = this.page
@@ -253,6 +254,13 @@ export class Plan {
253254
await this.panelActivityForm.getByPlaceholder('Enter preset name').blur();
254255
}
255256

257+
async fillExternalDatasetFileInput(importFilePath: string) {
258+
const inputFile = this.page.locator('input[name="file"]');
259+
await inputFile.focus();
260+
await inputFile.setInputFiles(importFilePath);
261+
await inputFile.evaluate(e => e.blur());
262+
}
263+
256264
async fillPlanName(name: string) {
257265
await this.planNameInput.fill(name);
258266
await this.planNameInput.evaluate(e => e.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' })));
@@ -554,6 +562,14 @@ export class Plan {
554562
this.schedulingSatisfiedActivity = page.locator('.scheduling-goal-analysis-activities-list > .satisfied-activity');
555563
}
556564

565+
async uploadExternalDatasets(importFilePath: string) {
566+
await this.panelActivityTypes.getByRole('button', { exact: true, name: 'Resources' }).click();
567+
await this.panelActivityTypes.getByRole('button', { exact: true, name: 'Upload Resources' }).click();
568+
await this.fillExternalDatasetFileInput(importFilePath);
569+
await expect(this.panelActivityTypes.getByRole('button', { exact: true, name: 'Upload' })).toBeEnabled();
570+
await this.panelActivityTypes.getByRole('button', { exact: true, name: 'Upload' }).click();
571+
}
572+
557573
async waitForActivityCheckingStatus(status: Status) {
558574
await expect(this.page.locator(this.activityCheckingStatusSelector(status))).toBeAttached({ timeout: 10000 });
559575
await expect(this.page.locator(this.activityCheckingStatusSelector(status))).toBeVisible();
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import test, { expect, type BrowserContext, type Page } from '@playwright/test';
2+
import { Constraints } from '../fixtures/Constraints.js';
3+
import { Models } from '../fixtures/Models.js';
4+
import { Plan } from '../fixtures/Plan.js';
5+
import { Plans } from '../fixtures/Plans.js';
6+
import { SchedulingConditions } from '../fixtures/SchedulingConditions.js';
7+
import { SchedulingGoals } from '../fixtures/SchedulingGoals.js';
8+
9+
let constraints: Constraints;
10+
let context: BrowserContext;
11+
let models: Models;
12+
let page: Page;
13+
let plan: Plan;
14+
let plans: Plans;
15+
let schedulingConditions: SchedulingConditions;
16+
let schedulingGoals: SchedulingGoals;
17+
18+
test.beforeAll(async ({ baseURL, browser }) => {
19+
context = await browser.newContext();
20+
page = await context.newPage();
21+
22+
models = new Models(page);
23+
plans = new Plans(page, models);
24+
constraints = new Constraints(page);
25+
schedulingConditions = new SchedulingConditions(page);
26+
schedulingGoals = new SchedulingGoals(page);
27+
plan = new Plan(page, plans, constraints, schedulingGoals, schedulingConditions);
28+
29+
await models.goto();
30+
await models.createModel(baseURL);
31+
await plans.goto();
32+
await plans.createPlan();
33+
await plan.goto();
34+
});
35+
36+
test.afterAll(async () => {
37+
await plans.goto();
38+
await plans.deletePlan();
39+
await models.goto();
40+
await models.deleteModel();
41+
await page.close();
42+
await context.close();
43+
});
44+
45+
test.describe.serial('Plan Resources', () => {
46+
test('Uploading external plan dataset file - JSON', async () => {
47+
await plan.uploadExternalDatasets('e2e-tests/data/external-dataset.json');
48+
await expect(plan.panelActivityTypes.getByText('/awake')).toBeVisible();
49+
await expect(plan.panelActivityTypes.getByText('/batteryEnergy')).toBeVisible();
50+
});
51+
52+
test('Uploading external plan dataset file - CSV', async () => {
53+
await plan.uploadExternalDatasets('e2e-tests/data/external-dataset.csv');
54+
await expect(plan.panelActivityTypes.getByText('TotalPower')).toBeVisible();
55+
await expect(plan.panelActivityTypes.getByText('BatteryStateOfCharge')).toBeVisible();
56+
await expect(plan.panelActivityTypes.getByText('Temperature')).toBeVisible();
57+
});
58+
});

src/components/Collapse.svelte

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@
4040
class:static={!collapsible}
4141
class:expanded
4242
style:height={`${headerHeight}px`}
43-
on:click={() => {
43+
on:click|stopPropagation={() => {
4444
if (collapsible) {
4545
expanded = !expanded;
4646
dispatch('collapse', !expanded);

src/components/ResourceList.svelte

Lines changed: 149 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,67 @@
11
<svelte:options immutable={true} />
22

33
<script lang="ts">
4-
import { resourceTypes } from '../stores/simulation';
4+
import CloseIcon from '@nasa-jpl/stellar/icons/close.svg?component';
5+
import UploadIcon from '@nasa-jpl/stellar/icons/upload.svg?component';
6+
import { plan } from '../stores/plan';
7+
import { allResourceTypes, simulationDatasetId } from '../stores/simulation';
8+
import type { User } from '../types/app';
59
import type { ResourceType } from '../types/simulation';
610
import type { TimelineItemType } from '../types/timeline';
11+
import effects from '../utilities/effects';
12+
import { permissionHandler } from '../utilities/permissionHandler';
13+
import { featurePermissions } from '../utilities/permissions';
14+
import { tooltip } from '../utilities/tooltip';
715
import ResourceListPrefix from './ResourceListPrefix.svelte';
816
import TimelineItemList from './TimelineItemList.svelte';
17+
import Input from './form/Input.svelte';
18+
19+
export let user: User | null;
20+
21+
const uploadPermissionError: string = `You do not have permission to upload resources.`;
922
1023
let resourceDataTypes: string[] = [];
24+
let hasUploadPermission: boolean = false;
25+
let isUploadVisible: boolean = false;
26+
let useSelectedSimulation: boolean = false;
27+
let uploadFiles: FileList | undefined;
28+
let uploadFileInput: HTMLInputElement;
1129
12-
$: resourceDataTypes = [...new Set($resourceTypes.map(t => t.schema.type))];
30+
$: resourceDataTypes = [...new Set($allResourceTypes.map(t => t.schema.type))];
31+
$: if (user !== null && $plan !== null) {
32+
hasUploadPermission = featurePermissions.externalResources.canCreate(user, $plan);
33+
}
1334
1435
function getFilterValueFromItem(item: TimelineItemType) {
1536
return (item as ResourceType).schema.type;
1637
}
38+
39+
function onShowUpload() {
40+
isUploadVisible = true;
41+
}
42+
43+
function onHideUpload() {
44+
isUploadVisible = false;
45+
}
46+
47+
async function onUpload() {
48+
if (uploadFiles !== undefined) {
49+
if ($plan && uploadFiles?.length) {
50+
await effects.uploadExternalDataset(
51+
$plan,
52+
uploadFiles,
53+
user,
54+
useSelectedSimulation ? $simulationDatasetId : undefined,
55+
);
56+
}
57+
uploadFileInput.value = '';
58+
uploadFiles = undefined;
59+
}
60+
}
1761
</script>
1862

1963
<TimelineItemList
20-
items={$resourceTypes}
64+
items={$allResourceTypes}
2165
chartType="line"
2266
typeName="resource"
2367
typeNamePlural="Resources"
@@ -26,5 +70,107 @@
2670
{getFilterValueFromItem}
2771
let:prop={item}
2872
>
73+
<div slot="header" class="upload-container" hidden={!isUploadVisible}>
74+
<button class="close-upload" type="button" on:click={onHideUpload}>
75+
<CloseIcon />
76+
</button>
77+
<Input layout="stacked">
78+
<label class="st-typography-body" for="file">Resource File</label>
79+
<input
80+
class="w-100"
81+
name="file"
82+
type="file"
83+
accept="application/json,.csv,.txt"
84+
bind:files={uploadFiles}
85+
bind:this={uploadFileInput}
86+
use:permissionHandler={{
87+
hasPermission: hasUploadPermission,
88+
permissionError: uploadPermissionError,
89+
}}
90+
/>
91+
</Input>
92+
<div class="use-simulation">
93+
<label class="st-typography-body timeline-item-list-filter-option-label" for="simulation-association">
94+
Use selected simulation
95+
</label>
96+
<input
97+
bind:checked={useSelectedSimulation}
98+
class="simulation-checkbox"
99+
type="checkbox"
100+
name="simulation-association"
101+
/>
102+
</div>
103+
<div class="upload-button-container">
104+
<button
105+
class="st-button secondary"
106+
disabled={!uploadFiles?.length}
107+
on:click={onUpload}
108+
use:permissionHandler={{
109+
hasPermission: hasUploadPermission,
110+
permissionError: uploadPermissionError,
111+
}}
112+
>
113+
Upload
114+
</button>
115+
</div>
116+
</div>
117+
<div slot="button">
118+
<button
119+
class="st-button secondary"
120+
on:click={onShowUpload}
121+
use:permissionHandler={{
122+
hasPermission: hasUploadPermission,
123+
permissionError: uploadPermissionError,
124+
}}
125+
use:tooltip={{ content: 'Upload Resources' }}
126+
>
127+
<UploadIcon />
128+
</button>
129+
</div>
29130
<ResourceListPrefix {item} />
30131
</TimelineItemList>
132+
133+
<style>
134+
.upload-container {
135+
background: var(--st-gray-15);
136+
border-radius: 5px;
137+
margin: 5px;
138+
padding: 8px 11px 8px;
139+
position: relative;
140+
}
141+
142+
.upload-container[hidden] {
143+
display: none;
144+
}
145+
146+
.upload-container {
147+
display: grid;
148+
row-gap: 8px;
149+
}
150+
151+
.upload-container .use-simulation {
152+
column-gap: 8px;
153+
display: grid;
154+
grid-template-columns: max-content auto;
155+
justify-content: space-between;
156+
justify-self: left;
157+
margin: 0;
158+
width: 100%;
159+
}
160+
161+
.upload-container :global(.upload-button-container) {
162+
display: flex;
163+
flex-flow: row-reverse;
164+
}
165+
166+
.upload-container :global(.close-upload) {
167+
background: none;
168+
border: 0;
169+
cursor: pointer;
170+
height: 1.3rem;
171+
padding: 0;
172+
position: absolute;
173+
right: 3px;
174+
top: 3px;
175+
}
176+
</style>

src/components/TimelineItemList.svelte

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
<script lang="ts">
44
import ChevronDownIcon from '@nasa-jpl/stellar/icons/chevron_down.svg?component';
5+
56
import GripVerticalIcon from 'bootstrap-icons/icons/grip-vertical.svg?component';
67
import { capitalize } from 'lodash-es';
78
import PlusCircledIcon from '../assets/plus-circled.svg?component';
@@ -145,7 +146,7 @@
145146
autocomplete="off"
146147
placeholder="Filter {typeName} types"
147148
/>
148-
<div style="position: relative">
149+
<div class="filter-buttons">
149150
<button
150151
class="st-button secondary menu-button"
151152
style="position: relative; z-index: 1"
@@ -186,9 +187,11 @@
186187
</div>
187188
</Menu>
188189
</div>
190+
<slot name="button" />
189191
</div>
190192

191193
<div class="controls">
194+
<slot name="header" />
192195
<div class="controls-header st-typography-medium">
193196
<div>{typeNamePlural} ({filteredItems.length})</div>
194197
<div>
@@ -394,9 +397,10 @@
394397
flex: 1;
395398
}
396399
397-
.controls-header .st-button {
400+
.filter-buttons {
401+
display: flex;
398402
gap: 4px;
399-
height: 20px;
403+
position: relative;
400404
}
401405
402406
.list-items {

0 commit comments

Comments
 (0)