Skip to content

Commit

Permalink
Rename plan (#1305)
Browse files Browse the repository at this point in the history
* Ability to update plan name 
* Refactor to ensure plan name updates are properly reflected in the UI
  • Loading branch information
AaronPlave authored Jun 4, 2024
1 parent b8ea05c commit abcfb6b
Show file tree
Hide file tree
Showing 10 changed files with 184 additions and 25 deletions.
14 changes: 14 additions & 0 deletions e2e-tests/fixtures/Plan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ export class Plan {
planCollaboratorInput: Locator;
planCollaboratorInputContainer: Locator;
planCollaboratorLoadingInput: Locator;
planNameInput: Locator;
planTitle: Locator;
reSimulateButton: Locator;
roleSelector: Locator;
Expand Down Expand Up @@ -185,6 +186,13 @@ export class Plan {
await this.panelActivityForm.getByPlaceholder('Enter preset name').blur();
}

async fillPlanName(name: string) {
await this.planNameInput.fill(name);
await this.planNameInput.evaluate(e => e.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' })));
await this.planNameInput.evaluate(e => e.dispatchEvent(new Event('change')));
await this.planNameInput.blur();
}

async fillSimulationTemplateName(templateName: string) {
await this.panelSimulation.getByRole('button', { name: 'Set Template' }).click();
await this.panelSimulation.locator('.dropdown-header').waitFor({ state: 'attached' });
Expand Down Expand Up @@ -240,6 +248,11 @@ export class Plan {
await this.page.locator(this.schedulingGoalListItemSelector(goalName)).waitFor({ state: 'detached' });
}

async renamePlan(name: string) {
await this.fillPlanName(name);
await this.waitForToast('Plan Updated Successfully');
}

async runAnalysis() {
await this.analyzeButton.click();
await this.waitForSchedulingStatus(Status.Incomplete);
Expand Down Expand Up @@ -415,6 +428,7 @@ export class Plan {
this.planTitle = page.locator(`.plan-title:has-text("${this.planName}")`);
this.planCollaboratorInputContainer = this.panelPlanMetadata.locator('.input:has-text("Collaborators")');
this.planCollaboratorInput = this.planCollaboratorInputContainer.getByPlaceholder('Search collaborators or plans');
this.planNameInput = page.locator('input[name="plan-name"]');
this.planCollaboratorLoadingInput = this.planCollaboratorInputContainer.getByPlaceholder('Loading...');
this.roleSelector = page.locator(`.nav select`);
this.reSimulateButton = page.locator('.header-actions button:has-text("Re-Run")');
Expand Down
22 changes: 19 additions & 3 deletions e2e-tests/tests/plan-metadata.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,9 @@ test.beforeAll(async ({ browser, baseURL }) => {
userB = new User(page, 'userB');
models = new Models(page);
plans = new Plans(page, models);
constraints = new Constraints(page, models);
schedulingConditions = new SchedulingConditions(page, models);
schedulingGoals = new SchedulingGoals(page, models);
constraints = new Constraints(page);
schedulingConditions = new SchedulingConditions(page);
schedulingGoals = new SchedulingGoals(page);
planA = new Plan(page, plans, constraints, schedulingGoals, schedulingConditions, plans.createPlanName());
planB = new Plan(page, plans, constraints, schedulingGoals, schedulingConditions, plans.createPlanName());

Expand All @@ -44,6 +44,7 @@ test.beforeAll(async ({ browser, baseURL }) => {
await userA.login(baseURL);
await plans.goto();
const planAId = await plans.createPlan(planA.planName);
await page.waitForTimeout(500); // Give plan page a moment to reset the input fields after plan creation success
await plans.createPlan(planB.planName);
await planA.goto(planAId);
});
Expand All @@ -62,6 +63,21 @@ test.afterAll(async ({ baseURL }) => {
});

test.describe.serial('Plan Metadata', () => {
test('Plan should be re-nameable', async () => {
await planA.showPanel(PanelNames.PLAN_METADATA);
await planA.renamePlan(planA.planName + '_renamed');

// Give the input form a moment to react before immediately performing another rename
await page.waitForTimeout(250);
await planA.renamePlan(planA.planName);
});

test('Plan name uniqueness validation enforced', async () => {
await planA.showPanel(PanelNames.PLAN_METADATA);
await planA.fillPlanName(planB.planName);
await expect(page.locator('.error:has-text("Plan name already exists")')).toBeDefined();
});

test('Plan owner should be userA', async () => {
await planA.showPanel(PanelNames.PLAN_METADATA);
await expect(planA.panelPlanMetadata.locator('input[name="owner"]')).toHaveValue(userA.username);
Expand Down
51 changes: 46 additions & 5 deletions src/components/plan/PlanForm.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@
<script lang="ts">
import { PlanStatusMessages } from '../../enums/planStatusMessages';
import { SearchParameters } from '../../enums/searchParameters';
import { planReadOnly, planReadOnlySnapshot } from '../../stores/plan';
import { field } from '../../stores/form';
import { planMetadata, planReadOnly, planReadOnlySnapshot } from '../../stores/plan';
import { planSnapshotId, planSnapshotsWithSimulations } from '../../stores/planSnapshots';
import { plans } from '../../stores/plans';
import { simulationDataset, simulationDatasetId } from '../../stores/simulation';
import { viewTogglePanel } from '../../stores/views';
import type { User, UserId } from '../../types/app';
Expand All @@ -17,7 +19,9 @@
import { featurePermissions } from '../../utilities/permissions';
import { getShortISOForDate } from '../../utilities/time';
import { tooltip } from '../../utilities/tooltip';
import { required, unique } from '../../utilities/validators';
import Collapse from '../Collapse.svelte';
import Field from '../form/Field.svelte';
import Input from '../form/Input.svelte';
import CardList from '../ui/CardList.svelte';
import FilterToggleButton from '../ui/FilterToggleButton.svelte';
Expand Down Expand Up @@ -61,6 +65,14 @@
filteredPlanSnapshots = $planSnapshotsWithSimulations;
}
$: planNameField = field<string>(plan?.name ?? '', [
required,
unique(
$plans.filter(p => p.id !== plan?.id).map(p => p.name),
'Plan name already exists',
),
]);
async function onTagsInputChange(event: TagsChangeEvent) {
const {
detail: { tag, type },
Expand Down Expand Up @@ -102,16 +114,37 @@
function onToggleFilter() {
isFilteredBySimulation = !isFilteredBySimulation;
}
async function onPlanNameChange() {
if (plan && $planNameField.dirtyAndValid && $planNameField.value) {
// Optimistically update plan metadata
planMetadata.updateValue(pm => (pm ? { ...pm, name: $planNameField.value } : null));
effects.updatePlan(plan, { name: $planNameField.value }, user);
}
}
</script>

<div class="plan-form">
{#if plan}
<fieldset>
<Collapse title="Details">
<Input layout="inline">
<label use:tooltip={{ content: 'Name', placement: 'top' }} for="name">Plan Name</label>
<input class="st-input w-100" disabled name="name" value={plan.name} />
</Input>
<div class="plan-form-field">
<Field field={planNameField} on:change={onPlanNameChange}>
<Input layout="inline">
<label use:tooltip={{ content: 'Name', placement: 'top' }} for="plan-name">Plan Name</label>
<input
autocomplete="off"
class="st-input w-100"
name="plan-name"
placeholder="Enter a plan name"
use:permissionHandler={{
hasPermission: hasPlanUpdatePermission,
permissionError,
}}
/>
</Input>
</Field>
</div>
<Input layout="inline">
<label use:tooltip={{ content: 'ID', placement: 'top' }} for="id">Plan ID</label>
<input class="st-input w-100" disabled name="id" value={plan.id} />
Expand Down Expand Up @@ -278,4 +311,12 @@
column-gap: 4px;
display: flex;
}
.plan-form-field :global(fieldset) {
padding: 0;
}
.plan-form-field :global(fieldset .error *) {
padding-left: calc(40% + 8px);
}
</style>
25 changes: 15 additions & 10 deletions src/routes/plans/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
import { SearchParameters } from '../../enums/searchParameters';
import { field } from '../../stores/form';
import { createPlanError, creatingPlan, resetPlanStores } from '../../stores/plan';
import { plans } from '../../stores/plans';
import { simulationTemplates } from '../../stores/simulation';
import { tags } from '../../stores/tags';
import type { User } from '../../types/app';
Expand All @@ -35,7 +36,7 @@
import { permissionHandler } from '../../utilities/permissionHandler';
import { featurePermissions } from '../../utilities/permissions';
import { convertUsToDurationString, getDoyTime, getShortISOForDate, getUnixEpochTime } from '../../utilities/time';
import { min, required, timestamp } from '../../utilities/validators';
import { min, required, timestamp, unique } from '../../utilities/validators';
import type { PageData } from './$types';
export let data: PageData;
Expand Down Expand Up @@ -167,16 +168,20 @@
let models: ModelSlim[];
let nameInputField: HTMLInputElement;
let planTags: Tag[] = [];
let plans: PlanSlim[];
let user: User | null = null;
let endTimeDoyField = field<string>('', [required, timestamp]);
let modelIdField = field<number>(-1, [min(1, 'Field is required')]);
let nameField = field<string>('', [required]);
$: nameField = field<string>('', [
required,
unique(
$plans.map(plan => plan.name),
'Plan name already exists',
),
]);
let simTemplateField = field<number | null>(null);
let startTimeDoyField = field<string>('', [required, timestamp]);
$: plans = data.plans;
$: models = data.models;
$: {
user = data.user;
Expand Down Expand Up @@ -221,7 +226,7 @@
$modelIdField.dirtyAndValid &&
$nameField.dirtyAndValid &&
$startTimeDoyField.dirtyAndValid;
$: filteredPlans = plans.filter(plan => {
$: filteredPlans = $plans.filter(plan => {
const filterTextLowerCase = filterText.toLowerCase();
return (
plan.end_time_doy.includes(filterTextLowerCase) ||
Expand Down Expand Up @@ -267,15 +272,15 @@
}));
await effects.createPlanTags(newPlanTags, newPlan, user);
newPlan.tags = planTags.map(tag => ({ tag }));
plans = [...plans, newPlan];
plans.updateValue(storePlans => [...storePlans, newPlan]);
}
}
async function deletePlan(plan: PlanSlim): Promise<void> {
const success = await effects.deletePlan(plan, user);
if (success) {
plans = plans.filter(p => plan.id !== p.id);
plans.updateValue(storePlans => storePlans.filter(p => plan.id !== p.id));
}
}
Expand All @@ -294,7 +299,7 @@
}
}
function deletePlanContext(event: CustomEvent<RowId[]>) {
function deletePlanContext(event: CustomEvent<RowId[]>, plans: PlanSlim[]) {
const id = event.detail[0] as number;
const plan = plans.find(t => t.id === id);
if (plan) {
Expand Down Expand Up @@ -500,14 +505,14 @@
</svelte:fragment>

<svelte:fragment slot="body">
{#if filteredPlans.length}
{#if filteredPlans && filteredPlans.length}
<SingleActionDataGrid
{columnDefs}
hasDeletePermission={featurePermissions.plan.canDelete}
itemDisplayText="Plan"
items={filteredPlans}
{user}
on:deleteItem={deletePlanContext}
on:deleteItem={event => deletePlanContext(event, filteredPlans)}
on:rowClicked={({ detail }) => showPlan(detail.data)}
/>
{:else}
Expand Down
10 changes: 6 additions & 4 deletions src/routes/plans/[id]/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -340,7 +340,7 @@
}
$: if (
$plan &&
$initialPlan &&
$simulationDataset !== null &&
(getSimulationStatus($simulationDataset) === Status.Complete ||
getSimulationStatus($simulationDataset) === Status.Complete)
Expand All @@ -351,7 +351,7 @@
effects
.getSpans(
datasetId,
$simulationDataset.simulation_start_time ?? $plan.start_time,
$simulationDataset.simulation_start_time ?? $initialPlan.start_time,
data.user,
simulationDataAbortController.signal,
)
Expand Down Expand Up @@ -545,7 +545,7 @@

<svelte:window on:keydown={onKeydown} bind:innerWidth={windowWidth} />

<PageTitle subTitle={data.initialPlan.name} title="Plans" />
<PageTitle subTitle={$plan?.name} title="Plans" />
<CssGrid
class="plan-container"
rows={$planSnapshot
Expand All @@ -554,7 +554,9 @@
>
<Nav user={data.user}>
<div class="title" slot="title">
<PlanMenu plan={data.initialPlan} user={data.user} />
{#if $plan}
<PlanMenu plan={$plan} user={data.user} />
{/if}

{#if $planReadOnlyMergeRequest || data.initialPlan.parent_plan?.is_locked}
<button
Expand Down
16 changes: 16 additions & 0 deletions src/stores/plans.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import type { PlanSlim } from '../types/plan';
import gql from '../utilities/gql';
import { getDoyTime, getDoyTimeFromInterval } from '../utilities/time';
import { gqlSubscribable } from './subscribable';

/* Subscriptions. */

export const plans = gqlSubscribable<PlanSlim[]>(gql.SUB_PLANS, {}, [], null, plans => {
return (plans as PlanSlim[]).map(plan => {
return {
...plan,
end_time_doy: getDoyTimeFromInterval(plan.start_time, plan.duration),
start_time_doy: getDoyTime(new Date(plan.start_time)),
};
});
});
23 changes: 23 additions & 0 deletions src/utilities/effects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ import type {
PlanMergeNonConflictingActivity,
PlanMergeRequestSchema,
PlanMergeResolution,
PlanMetadata,
PlanSchema,
PlanSlim,
} from '../types/plan';
Expand Down Expand Up @@ -4671,6 +4672,28 @@ const effects = {
}
},

async updatePlan(plan: Plan, planMetadata: Partial<PlanMetadata>, user: User | null): Promise<void> {
try {
if (!queryPermissions.UPDATE_PLAN(user, plan)) {
throwPermissionError('update plan');
}

const data = await reqHasura(gql.UPDATE_PLAN, { plan: planMetadata, plan_id: plan.id }, user);
const { updatePlan } = data;

if (updatePlan.id != null) {
showSuccessToast('Plan Updated Successfully');
return;
} else {
throw Error(`Unable to update plan with ID: "${plan.id}"`);
}
} catch (e) {
catchError('Plan Update Failed', e as Error);
showFailureToast('Plan Update Failed');
return;
}
},

async updatePlanSnapshot(id: number, snapshot: Partial<PlanSnapshot>, user: User | null): Promise<void> {
try {
if (!queryPermissions.UPDATE_PLAN_SNAPSHOT(user)) {
Expand Down
Loading

0 comments on commit abcfb6b

Please sign in to comment.