From 1b1a8b163b65e271562b528ccfa57b75b3cd046b Mon Sep 17 00:00:00 2001 From: duranb Date: Thu, 17 Sep 2026 15:02:02 -0700 Subject: [PATCH 01/10] update plan and model types to match db changes --- e2e-tests/fixtures/global.setup.jar.ts | 4 ++-- e2e-tests/tests/plan-metadata.test.ts | 4 ++-- e2e-tests/utilities/api.ts | 6 +++--- src/components/plan/PlanMergeReview.test.ts | 4 +++- src/types/model.ts | 8 ++++--- src/types/plan.ts | 1 + src/utilities/activities.test.ts | 4 +++- src/utilities/effects.ts | 10 ++++----- src/utilities/gql.ts | 23 +++++++++++++++------ src/utilities/plan.test.ts | 11 +++++++--- 10 files changed, 49 insertions(+), 26 deletions(-) diff --git a/e2e-tests/fixtures/global.setup.jar.ts b/e2e-tests/fixtures/global.setup.jar.ts index 9654f3e43e..d33142c8f3 100644 --- a/e2e-tests/fixtures/global.setup.jar.ts +++ b/e2e-tests/fixtures/global.setup.jar.ts @@ -31,10 +31,10 @@ setup.skip(jarDataExists, 'cached JAR data exists'); setup('upload test JAR and save shared test data', async () => { const api = new AerieApi(); await api.login('test', 'test'); - const jarId = await api.uploadFile('e2e-tests/data/banananation-develop.jar'); + const definitionFileId = await api.uploadFile('e2e-tests/data/banananation-develop.jar'); const sharedData: SharedTestData = { - jarId, + definitionFileId, }; // Ensure the directory exists diff --git a/e2e-tests/tests/plan-metadata.test.ts b/e2e-tests/tests/plan-metadata.test.ts index 550b461b52..abadc1285e 100644 --- a/e2e-tests/tests/plan-metadata.test.ts +++ b/e2e-tests/tests/plan-metadata.test.ts @@ -45,12 +45,12 @@ test.beforeAll(async ({ browser }) => { await apiA.login('userA', 'test'); // Use pre-uploaded JAR from global setup - const { jarId } = getSharedTestData(); + const { definitionFileId } = getSharedTestData(); // Create model via API (much faster and more reliable than UI) const modelName = uniqueNamesGenerator({ dictionaries: [adjectives, colors, animals] }); const model = await apiA.createModel({ - jar_id: jarId, + definition_file_id: definitionFileId, mission: 'test', name: modelName, version: '1.0.0', diff --git a/e2e-tests/utilities/api.ts b/e2e-tests/utilities/api.ts index 08713501a0..87b654501b 100644 --- a/e2e-tests/utilities/api.ts +++ b/e2e-tests/utilities/api.ts @@ -45,7 +45,7 @@ export interface ApiUser { * Shared test data written during global setup and read by tests. */ export interface SharedTestData { - jarId: number; + definitionFileId: number; } /** @@ -616,12 +616,12 @@ export async function setupTest(browser: Browser, options: SetupOptions = {}): P await api.login(user, 'test'); // Use pre-uploaded JAR from global setup - const { jarId } = getSharedTestData(); + const { definitionFileId } = getSharedTestData(); // Create model via API const modelName = options.modelName ?? uniqueNamesGenerator({ dictionaries: [adjectives, colors, animals] }); const model = await api.createModel({ - jar_id: jarId, + definition_file_id: definitionFileId, mission: 'test', name: modelName, version: '1.0.0', diff --git a/src/components/plan/PlanMergeReview.test.ts b/src/components/plan/PlanMergeReview.test.ts index 533ee89d26..0707573792 100644 --- a/src/components/plan/PlanMergeReview.test.ts +++ b/src/components/plan/PlanMergeReview.test.ts @@ -100,14 +100,16 @@ const mockInitialPlan: Plan = { end_time_doy: '2023-054T00:00:00', id: 1, is_locked: true, + is_read_only: false, model: { activity_types: [], constraint_specification: [], created_at: '2023-02-16T00:00:00', default_view_id: 0, + definition_file_id: 1, derivation_group_specification: [], id: 1, - jar_id: 1, + is_executable: true, mission: '', name: 'Demo Model', owner: 'spacecaptain', diff --git a/src/types/model.ts b/src/types/model.ts index d660b81216..aea7517223 100644 --- a/src/types/model.ts +++ b/src/types/model.ts @@ -7,7 +7,7 @@ import type { View, ViewSlim } from './view'; export type Model = ModelSchema; -export type ModelInsertInput = Pick; +export type ModelInsertInput = Pick; export type ModelSetInput = Pick; export type ModelStatus = 'extracting' | 'complete' | 'error' | 'none'; @@ -39,10 +39,11 @@ export type ModelSchema = { constraint_specification: ConstraintModelSpecification[]; created_at: string; default_view_id: number | null; + definition_file_id: number; derivation_group_specification: ModelDerivationGroup[]; description?: string; id: number; - jar_id: number; + is_executable: boolean; mission: string; name: string; owner: UserId; @@ -63,8 +64,9 @@ export type ModelSlim = Pick< | 'activity_types' | 'created_at' | 'description' + | 'definition_file_id' | 'id' - | 'jar_id' + | 'is_executable' | 'name' | 'owner' | 'plans' diff --git a/src/types/plan.ts b/src/types/plan.ts index b1db8502c4..bcfa664979 100644 --- a/src/types/plan.ts +++ b/src/types/plan.ts @@ -107,6 +107,7 @@ export type PlanSchema = { duration: string; id: number; is_locked: boolean; + is_read_only: boolean; model: Model | null; model_id: number | null; name: string; diff --git a/src/utilities/activities.test.ts b/src/utilities/activities.test.ts index 35625570f7..f2dc0f36cd 100644 --- a/src/utilities/activities.test.ts +++ b/src/utilities/activities.test.ts @@ -298,14 +298,16 @@ function getTestPlan(): Plan { end_time_doy: '2006-T194:00:00', id: 1, is_locked: false, + is_read_only: false, model: { activity_types: [], constraint_specification: [], created_at: '2006-07-11T00:00:00', default_view_id: 0, + definition_file_id: 123, derivation_group_specification: [], id: 1, - jar_id: 123, + is_executable: true, mission: 'Test', name: 'Test Model', owner: 'test', diff --git a/src/utilities/effects.ts b/src/utilities/effects.ts index 515aaf99e0..6f45b92dd4 100644 --- a/src/utilities/effects.ts +++ b/src/utilities/effects.ts @@ -1518,14 +1518,14 @@ const effects = { creatingModelStore.set(true); const file: File = files[0]; - const jarId = await effects.uploadFile(file, user); + const definitionFileId = await effects.uploadFile(file, user); showSuccessToast('Model Uploaded Successfully. Processing model...'); logMessage('log', `Uploaded model file "${name}" (v${version}).`); - if (jarId !== null) { + if (definitionFileId !== null) { const modelInsertInput: ModelInsertInput = { + definition_file_id: definitionFileId, description, - jar_id: jarId, mission: '', name, version, @@ -3268,8 +3268,8 @@ const effects = { ); if (confirm) { - const { id, jar_id } = model; - await effects.deleteFile(jar_id, user); + const { id, definition_file_id } = model; + await effects.deleteFile(definition_file_id, user); const data = await reqHasura<{ id: number }>(gql.DELETE_MODEL, { id }, user); if (data.deleteModel != null) { showSuccessToast('Model Deleted Successfully'); diff --git a/src/utilities/gql.ts b/src/utilities/gql.ts index 7b27eac7f8..3f838a8a96 100644 --- a/src/utilities/gql.ts +++ b/src/utilities/gql.ts @@ -359,6 +359,7 @@ const gql = { } duration id + is_read_only owner revision start_time @@ -1279,7 +1280,8 @@ const gql = { created_at description id - jar_id + is_executable + definition_file_id name plans { id @@ -1360,9 +1362,11 @@ const gql = { duration id is_locked + is_read_only model: mission_model { + definition_file_id id - jar_id + is_executable name owner parameters { @@ -1446,9 +1450,10 @@ const gql = { query GetPlansAndModels { models: ${Queries.MISSION_MODELS}(order_by: { id: desc }) { created_at + definition_file_id description id - jar_id + is_executable name owner plans { @@ -1491,6 +1496,7 @@ const gql = { created_at duration id + is_read_only model_id name owner @@ -2510,8 +2516,9 @@ const gql = { } created_at default_view_id + definition_file_id description - jar_id + is_executable id mission name @@ -2585,9 +2592,10 @@ const gql = { parameters } created_at + definition_file_id description id - jar_id + is_executable name plans { id @@ -2684,6 +2692,7 @@ const gql = { created_at duration id + is_read_only model_id name owner @@ -2929,11 +2938,13 @@ const gql = { subscription SubPlanMetadata($planId: Int!) { plan_metadata: ${Queries.PLAN}(id: $planId) { id + is_read_only start_time duration model: mission_model { + definition_file_id id - jar_id + is_executable name owner parameters { diff --git a/src/utilities/plan.test.ts b/src/utilities/plan.test.ts index f82ad7e233..38917ace6f 100644 --- a/src/utilities/plan.test.ts +++ b/src/utilities/plan.test.ts @@ -40,14 +40,16 @@ describe('Plan utility', () => { end_time_doy: '2025-001T00:00:00', id: 1, is_locked: false, + is_read_only: false, model: { activity_types: [], constraint_specification: [], created_at: '2024-01-01T00:00:00', default_view_id: 0, + definition_file_id: 123, derivation_group_specification: [], id: 1, - jar_id: 123, + is_executable: true, mission: 'Test', name: 'Test Model', owner: 'test', @@ -235,9 +237,10 @@ describe('Plan utility', () => { constraint_specification: [], created_at: '2024-01-01T00:00:00', default_view_id: 0, + definition_file_id: 123, derivation_group_specification: [], id: 1, - jar_id: 123, + is_executable: true, mission: 'Test', name: 'Test Model', owner: 'test', @@ -342,14 +345,16 @@ describe('Plan utility', () => { end_time_doy: '2025-001T00:00:00', id: 1, is_locked: false, + is_read_only: false, model: { activity_types: [], constraint_specification: [], created_at: '2024-01-01T00:00:00', default_view_id: 0, + definition_file_id: 123, derivation_group_specification: [], id: 1, - jar_id: 123, + is_executable: true, mission: 'Test', name: 'Test Model', owner: 'test', From 45521613666dcdc91d556ebcc4a330f03fd26351 Mon Sep 17 00:00:00 2001 From: duranb Date: Fri, 18 Sep 2026 01:22:24 -0700 Subject: [PATCH 02/10] Add executable model filtering and read-only plan support - Add "Executable" column to plans table showing model execution status - Update Model ID, Model Name, and Model Version columns to show "-" or "N/A" for non-executable models - Add custom comparators for executable status and model fields - Disable model, start time, end time fields when importing readonly plans --- src/components/model/Models.svelte | 4 + src/components/plan/PlanTimeBounds.svelte | 9 +- src/routes/plans/+page.svelte | 353 ++++++++++++++++------ src/types/plan.ts | 26 +- src/utilities/effects.ts | 29 +- src/utilities/generic.test.ts | 57 ++++ src/utilities/generic.ts | 29 ++ src/utilities/plan.test.ts | 1 + 8 files changed, 400 insertions(+), 108 deletions(-) diff --git a/src/components/model/Models.svelte b/src/components/model/Models.svelte index bea34cab55..ef8fc4ec6a 100644 --- a/src/components/model/Models.svelte +++ b/src/components/model/Models.svelte @@ -44,6 +44,8 @@ const createPlanPermissionError: string = 'You do not have permission to create a plan'; const extractionPermissionError: string = 'You do not have permission to re-trigger a model extraction'; + const showNonExecutableModels: boolean = false; + const modelsLoading = models.loading; const baseColumnDefs: DataGridColumnDef[] = [ @@ -495,6 +497,8 @@ hasDeletePermission={hasDeleteModelPermission} itemDisplayText="Model" items={$models} + isExternalFilterPresent={() => !showNonExecutableModels} + doesExternalFilterPass={node => !!node.data?.is_executable} showLoadingSkeleton loading={$modelsLoading} {user} diff --git a/src/components/plan/PlanTimeBounds.svelte b/src/components/plan/PlanTimeBounds.svelte index eed0085253..2165ac2f01 100644 --- a/src/components/plan/PlanTimeBounds.svelte +++ b/src/components/plan/PlanTimeBounds.svelte @@ -16,11 +16,13 @@ export let user: User | null = null; export let hasUpdatePermission: boolean = false; export let permissionError: string = ''; + export let isReadOnly: boolean = false; let startTimeString: string = ''; let endTimeYmd: string | null = null; let endTimeString: string = ''; let durationString: string = 'None'; + let changeButtonTooltip: string = 'Change plan time range'; // Display values are read-only but selectable (the inputs are `readonly`, not `disabled`) so the // text remains copyable. Editing goes through ChangePlanBoundsModal, which edits both at once. @@ -28,6 +30,7 @@ $: endTimeYmd = convertDoyToYmd(plan.end_time_doy); $: endTimeString = endTimeYmd ? formatDate(new Date(endTimeYmd), $plugins.time.primary.format) : plan.end_time_doy; $: durationString = convertUsToDurationString(getIntervalInMs(plan.duration) * 1000) || 'None'; + $: changeButtonTooltip = isReadOnly ? 'Plan is read-only' : 'Change plan time range'; function openChangePlanBoundsModal() { showChangePlanBoundsModal(plan, user); @@ -42,14 +45,16 @@
diff --git a/src/routes/plans/+page.svelte b/src/routes/plans/+page.svelte index 036604d087..1c691f42b0 100644 --- a/src/routes/plans/+page.svelte +++ b/src/routes/plans/+page.svelte @@ -25,6 +25,7 @@ import IconCellRenderer from '../../components/ui/IconCellRenderer.svelte'; import Panel from '../../components/ui/Panel.svelte'; import SectionTitle from '../../components/ui/SectionTitle.svelte'; + import TagChip from '../../components/ui/Tags/Tag.svelte'; import TagsInput from '../../components/ui/Tags/TagsInput.svelte'; import { InvalidDate } from '../../constants/time'; import { SearchParameters } from '../../enums/searchParameters'; @@ -37,12 +38,13 @@ import { tags } from '../../stores/tags'; import { getUserStore } from '../../stores/user'; import type { DataGridColumnDef, RowId } from '../../types/data-grid'; + import type { FieldStore } from '../../types/form'; import type { ModelSlim } from '../../types/model'; import type { DeprecatedPlanTransfer, Plan, PlanSlim, PlanTransfer } from '../../types/plan'; import type { PlanTagsInsertInput, Tag, TagsChangeEvent } from '../../types/tags'; import { generateRandomPastelColor } from '../../utilities/color'; import effects from '../../utilities/effects'; - import { parseJSONStream } from '../../utilities/generic'; + import { compareWithRankings, parseJSONStream } from '../../utilities/generic'; import { permissionHandler } from '../../utilities/permissionHandler'; import { featurePermissions } from '../../utilities/permissions'; import { computeDurationString, exportPlan, isDeprecatedPlanTransfer } from '../../utilities/plan'; @@ -83,15 +85,6 @@ width: 75, }, { field: 'name', filter: 'text', headerName: 'Name', resizable: true, sortable: true }, - { - field: 'model_id', - filter: 'number', - headerName: 'Model ID', - resizable: true, - sortable: true, - suppressAutoSize: true, - width: 130, - }, { field: 'start_time', filter: 'text', @@ -196,6 +189,7 @@ let durationString: string = 'None'; let filterText: string = ''; let isPlanImportMode: boolean = false; + let isPlanUploadReadOnly: boolean = false; let orderedModels: ModelSlim[] = []; let nameInputField: InputStellar; let planExporting: boolean = false; @@ -204,6 +198,8 @@ let selectedPlan: PlanSlim | undefined; let selectedPlanId: number | null = null; let selectedPlanModelName: string | null = null; + let startTimeField: FieldStore; + let endTimeField: FieldStore; let modelIdField = field(-1, [min(1, 'Field is required')]); let nameField = field('', [ required, @@ -249,11 +245,134 @@ } return 0; }); + $: { canCreate = $user ? featurePermissions.plan.canCreate($user) : false; columnDefs = [ - ...baseColumnDefs.slice(0, 3), + ...baseColumnDefs.slice(0, 2), { + autoHeight: true, + cellRenderer: (params: ICellRendererParams): HTMLDivElement | void => { + if (params.value) { + const executableDiv = document.createElement('div'); + executableDiv.className = 'tags-cell'; + new TagChip({ + props: { + removable: false, + tag: { + color: params.value === 'Executable' ? '#d9fffa' : '#eef2f8', + name: params.value, + }, + }, + target: executableDiv, + }); + return executableDiv; + } + }, + comparator: ( + valueA: number | string | null | undefined, + valueB: number | string | null | undefined, + _nodeA, + _nodeB, + isDescending: boolean, + ) => { + return compareWithRankings( + valueA, + valueB, + isDescending + ? { + '': 0, + string: 1, + } + : { + '': 1, + string: 0, + }, + ); + }, + field: 'is_executable', + filter: 'text', + headerName: 'Executable', + resizable: true, + sortable: true, + valueGetter: (params: ValueGetterParams) => { + if (params.data?.model_id !== undefined) { + const associatedModel = $models.find(model => model.id === params.data?.model_id); + if (associatedModel) { + return associatedModel.is_executable ? 'Executable' : 'Non Executable'; + } + } + return ''; + }, + width: 160, + }, + { + comparator: ( + valueA: number | string | null | undefined, + valueB: number | string | null | undefined, + _nodeA, + _nodeB, + isDescending: boolean, + ) => { + return compareWithRankings( + valueA, + valueB, + isDescending + ? { + '': 0, + '-': 1, + number: 2, + } + : { + '': 2, + '-': 1, + number: 0, + }, + ); + }, + field: 'model_id', + filter: 'number', + headerName: 'Model ID', + resizable: true, + sortable: true, + suppressAutoSize: true, + valueGetter: (params: ValueGetterParams) => { + let value: string | number = ''; + if (params.data?.model_id !== undefined) { + const associatedModel = $models.find(model => model.id === params.data?.model_id); + if (associatedModel) { + value = associatedModel.is_executable ? associatedModel.id : '-'; + } + } + + return value; + }, + width: 130, + }, + { + comparator: ( + valueA: number | string | null | undefined, + valueB: number | string | null | undefined, + _nodeA, + _nodeB, + isDescending: boolean, + ) => { + return compareWithRankings( + valueA, + valueB, + isDescending + ? { + '': 0, + 'N/A': 1, + string: 2, + } + : { + '': 2, + 'N/A': 1, + string: 0, + }, + ); + }, field: 'model_name', filter: 'text', headerName: 'Model Name', @@ -261,12 +380,39 @@ sortable: true, valueGetter: (params: ValueGetterParams) => { if (params.data?.model_id !== undefined) { - return $models.find(model => model.id === params.data?.model_id)?.name; + const associatedModel = $models.find(model => model.id === params.data?.model_id); + if (associatedModel) { + return associatedModel.is_executable ? associatedModel.name : 'N/A'; + } } + return ''; }, width: 150, }, { + comparator: ( + valueA: number | string | null | undefined, + valueB: number | string | null | undefined, + _nodeA, + _nodeB, + isDescending: boolean, + ) => { + return compareWithRankings( + valueA, + valueB, + isDescending + ? { + '': 0, + '-': 1, + string: 2, + } + : { + '': 2, + '-': 1, + string: 0, + }, + ); + }, field: 'model_version', filter: 'text', headerName: 'Model Version', @@ -274,12 +420,20 @@ sortable: true, valueGetter: (params: ValueGetterParams) => { if (params.data?.model_id !== undefined) { - return $models.find(model => model.id === params.data?.model_id)?.version; + const associatedModel = $models.find(model => model.id === params.data?.model_id); + if (associatedModel) { + if (associatedModel.is_executable) { + return associatedModel.version; + } else { + return '-'; + } + } } + return ''; }, width: 150, }, - ...baseColumnDefs.slice(3), + ...baseColumnDefs.slice(2), { cellClass: 'action-cell-container', cellRenderer: (params: PlanCellRendererParams) => { @@ -497,6 +651,7 @@ function hideImportPlan() { isPlanImportMode = false; + isPlanUploadReadOnly = false; planUploadFileInput.value = ''; planUploadFiles = undefined; planUploadFilesError = null; @@ -597,6 +752,13 @@ const { duration } = planJSON; await endTimeField.validateAndSet(getDoyTimeFromInterval(startTime, duration)); + + // if the plan has a model, it means it's a read only plan + if (planJSON.model) { + isPlanUploadReadOnly = true; + } else { + isPlanUploadReadOnly = false; + } } updateDurationString(); @@ -693,34 +855,38 @@
-
-
- -
-
- + +
-
+ {/if}
@@ -732,6 +898,7 @@ user={$user} hasUpdatePermission={canUpdatePlan} permissionError="You do not have permission to edit this plan." + isReadOnly={selectedPlan.is_read_only} /> @@ -748,6 +915,10 @@ {:else} + {@const canModify = canCreate && !isPlanUploadReadOnly} + {@const canModifyTooltip = isPlanUploadReadOnly + ? 'You cannot change time bounds for a plan that is read only.' + : permissionError}
@@ -794,51 +965,55 @@ -
- Model provided by read-only plan
+ {:else} +
- - - - - {#if orderedModels.length === 0} -
No models available
- {:else} - {#each orderedModels as model (model.id)} - - {model.name} -
(Version: {model.version})
-
- {/each} - {/if} -
- - -
+ + + + + {#if orderedModels.length === 0} +
No models available
+ {:else} + {#each orderedModels as model (model.id)} + + {model.name} +
(Version: {model.version})
+
+ {/each} + {/if} +
+ + + + {/if}
{#if selectedModel} @@ -869,7 +1044,7 @@
& { +type ModelDeclaration = Model; +type RunResults = [{ id: number; simulation_datasets: [{ id: number; plan_revision: number }] }]; +type PlanTransferBase = Pick & { activities: Pick< ActivityDirective, - | 'anchor_id' - | 'anchored_to_start' - | 'arguments' - | 'id' - | 'metadata' - | 'name' - | 'start_offset' - | ('type' & { tags: { tag: Pick }[] }) + 'anchor_id' | 'anchored_to_start' | 'arguments' | 'id' | 'metadata' | 'name' | 'start_offset' | 'type' >[]; simulation_arguments: ArgumentsMap; tags?: { tag: Pick }[]; version?: string; }; +export type PlanTransfer = + | (PlanTransferBase & { + model?: never; + model_id: number | null; + }) + | (PlanTransferBase & { + model: ModelDeclaration; + model_id?: never; + results?: RunResults; + }); + export type DeprecatedPlanTransfer = Omit & { end_time: string; sim_id: number; @@ -163,6 +169,7 @@ export type PlanMetadata = Pick< | 'model' | 'start_time' | 'duration' + | 'is_read_only' >; export type PlanSlim = Pick< @@ -172,6 +179,7 @@ export type PlanSlim = Pick< | 'duration' | 'end_time_doy' | 'id' + | 'is_read_only' | 'model_id' | 'name' | 'owner' diff --git a/src/utilities/effects.ts b/src/utilities/effects.ts index 6f45b92dd4..70ec890e99 100644 --- a/src/utilities/effects.ts +++ b/src/utilities/effects.ts @@ -1647,8 +1647,18 @@ const effects = { ); const { createPlan } = data; if (createPlan != null) { - const { collaborators, created_at, duration, id, owner, revision, start_time, updated_at, updated_by } = - createPlan; + const { + collaborators, + created_at, + duration, + id, + is_read_only, + owner, + revision, + start_time, + updated_at, + updated_by, + } = createPlan; if (!(await effects.initialSimulationUpdate(id, simulationTemplateId, startTimeDoy, endTimeDoy, user))) { throw Error('Failed to update simulation.'); @@ -1660,6 +1670,7 @@ const effects = { duration, end_time_doy: endTimeDoy, id, + is_read_only, model_id: modelId, name, owner, @@ -5606,7 +5617,7 @@ const effects = { async importPlan( name: string, - modelId: number, + modelId: number | null, startTime: string, endTime: string, simulationTemplateId: number | null, @@ -5629,11 +5640,13 @@ const effects = { const body = new FormData(); body.append('name', `${name}`); - body.append('model_id', `${modelId}`); - body.append('start_time', `${startTime}`); - body.append('duration', `${duration}`); - if (simulationTemplateId !== null) { - body.append('simulation_template_id', `${simulationTemplateId}`); + if (modelId !== null) { + body.append('model_id', `${modelId}`); + body.append('start_time', `${startTime}`); + body.append('duration', `${duration}`); + if (simulationTemplateId !== null) { + body.append('simulation_template_id', `${simulationTemplateId}`); + } } body.append('tags', JSON.stringify(tagIds)); body.append('plan_file', file, file.name); diff --git a/src/utilities/generic.test.ts b/src/utilities/generic.test.ts index 4be4ab96d5..af77d609f1 100644 --- a/src/utilities/generic.test.ts +++ b/src/utilities/generic.test.ts @@ -8,6 +8,7 @@ import { attemptStringConversion, clamp, classNames, + compareWithRankings, extractQuotes, filterEmpty, filterNullish, @@ -179,4 +180,60 @@ describe('Generic utility function tests', () => { }); }); }); + + describe('compareWithRankings', () => { + test('sorts an array according to explicit rankings', () => { + const rankings = { completed: 3, draft: 1, 'in progress': 2 }; + const values = ['completed', 'draft', 'in progress']; + + values.sort((valueA, valueB) => compareWithRankings(valueA, valueB, rankings)); + + expect(values).toEqual(['draft', 'in progress', 'completed']); + }); + + test('uses a value ranking before a type ranking', () => { + const rankings = { '42': 3, number: 1 }; + const values = [42, 7]; + + values.sort((valueA, valueB) => compareWithRankings(valueA, valueB, rankings)); + + expect(values).toEqual([7, 42]); + }); + + test('uses type rankings when no value-specific ranking exists', () => { + const rankings = { number: 1, string: 2 }; + const values = ['10', 10]; + + values.sort((valueA, valueB) => compareWithRankings(valueA, valueB, rankings)); + + expect(values).toEqual([10, '10']); + }); + + test('compares numbers numerically when they have the same rank', () => { + const rankings = { number: 1 }; + const values = [10, 2, 100]; + + values.sort((valueA, valueB) => compareWithRankings(valueA, valueB, rankings)); + + expect(values).toEqual([2, 10, 100]); + }); + + test('compares non-numbers using numeric-aware locale ordering when they have the same rank', () => { + const rankings = { string: 1 }; + const values = ['item10', 'item2', 'item1']; + + values.sort((valueA, valueB) => compareWithRankings(valueA, valueB, rankings)); + + expect(values).toEqual(['item1', 'item2', 'item10']); + }); + + test('uses the default rank for null and unranked values', () => { + const rankings = { ready: 1 }; + const values = ['ready', null, 'unknown']; + + values.sort((valueA, valueB) => compareWithRankings(valueA, valueB, rankings)); + + expect(values).toEqual([null, 'unknown', 'ready']); + }); + }); }); diff --git a/src/utilities/generic.ts b/src/utilities/generic.ts index 4b078ff38b..b0647d2e99 100644 --- a/src/utilities/generic.ts +++ b/src/utilities/generic.ts @@ -64,6 +64,35 @@ export function stringCompare(a: string, b: string): number { return a.localeCompare(b); } +function getSortRank(value: number | string | null | undefined, rankMappings: Record): number { + let rank = rankMappings[String(value)]; + + if (rank === undefined) { + rank = rankMappings[typeof value]; + } + + return rank !== undefined ? rank : 0; +} + +export function compareWithRankings( + valueA: number | string | null | undefined, + valueB: number | string | null | undefined, + rankMappings: Record, +): number { + const priorityA = getSortRank(valueA, rankMappings); + const priorityB = getSortRank(valueB, rankMappings); + + // Keep empty and placeholder values at the bottom in either direction. + if (priorityA !== priorityB) { + return priorityA - priorityB; + } + + if (typeof valueA === 'number' && typeof valueB === 'number') { + return valueA - valueB; + } + return String(valueA).localeCompare(String(valueB), undefined, { numeric: true }); +} + /** * Clamp a number between min and max. */ diff --git a/src/utilities/plan.test.ts b/src/utilities/plan.test.ts index 38917ace6f..9870f19365 100644 --- a/src/utilities/plan.test.ts +++ b/src/utilities/plan.test.ts @@ -232,6 +232,7 @@ describe('Plan utility', () => { end_time_doy: '2025-001T00:00:00', id: 1, is_locked: false, + is_read_only: false, model: { activity_types: [], constraint_specification: [], From 24e810c0afa2a1227fbeb8c56c587ed5f6ecff33 Mon Sep 17 00:00:00 2001 From: duranb Date: Mon, 21 Sep 2026 12:24:18 -0700 Subject: [PATCH 03/10] Better visual distinction of "readonly" plan --- src/components/menus/PlanMenu.svelte | 158 +++++++++++++++------------ src/components/ui/PlanName.svelte | 16 +++ src/components/ui/PlayOffIcon.svelte | 21 ++++ src/routes/plans/+page.svelte | 139 ++++++++--------------- src/utilities/generic.test.ts | 35 +++--- src/utilities/generic.ts | 6 +- 6 files changed, 189 insertions(+), 186 deletions(-) create mode 100644 src/components/ui/PlanName.svelte create mode 100644 src/components/ui/PlayOffIcon.svelte diff --git a/src/components/menus/PlanMenu.svelte b/src/components/menus/PlanMenu.svelte index 73ea3cea67..86311b43e5 100644 --- a/src/components/menus/PlanMenu.svelte +++ b/src/components/menus/PlanMenu.svelte @@ -7,7 +7,7 @@ import { ChevronDown } from 'lucide-svelte'; import { PlanStatusMessages } from '../../enums/planStatusMessages'; import { activityDirectivesMap } from '../../stores/activities'; - import { planReadOnly } from '../../stores/plan'; + import { planIsLocked } from '../../stores/plan'; import { initialPlanSnapshotsLoading } from '../../stores/planSnapshots'; import { viewTogglePanel } from '../../stores/views'; import type { User } from '../../types/app'; @@ -19,6 +19,7 @@ import { exportPlan } from '../../utilities/plan'; import Menu from '../menus/Menu.svelte'; import MenuItem from '../menus/MenuItem.svelte'; + import PlanName from '../ui/PlanName.svelte'; import MenuDivider from './MenuDivider.svelte'; export let plan: Plan; @@ -39,11 +40,11 @@ model_id: plan.model_id, }, plan.model, - ) && !$planReadOnly + ) && !$planIsLocked : false; $: hasCreatePlanBranchPermission = - featurePermissions.planBranch.canCreateBranch(user, plan, plan.model) && !$planReadOnly; - $: hasCreateSnapshotPermission = featurePermissions.planSnapshot.canCreate(user, plan, plan.model) && !$planReadOnly; + featurePermissions.planBranch.canCreateBranch(user, plan, plan.model) && !$planIsLocked; + $: hasCreateSnapshotPermission = featurePermissions.planSnapshot.canCreate(user, plan, plan.model) && !$planIsLocked; function createMergePlanBranchRequest() { effects.createPlanBranchRequest(plan, 'merge', user); @@ -91,86 +92,103 @@ {/if} -
planMenu.toggle()}> -
{plan.name}
- - -
Create branch
-
- -
View merge requests
-
- {#if plan.parent_plan !== null} - + {#if plan.is_read_only} +
planMenu.toggle()}> +
+ +
+ + + {#if !planExporting} + Export plan as .json + {:else} + Exporting... + {/if} + + +
+ {:else} +
planMenu.toggle()}> +
{plan.name}
+ -
Create merge request
+
Create branch
- goto(`${base}/plans/${plan?.parent_plan?.id}`)}> -
Open parent plan
+ +
View merge requests
- {/if} - - -
Take Snapshot
-
- -
View Snapshot History
-
- - - {#if !planExporting} - Export plan as .json - {:else} - Exporting... + {#if plan.parent_plan !== null} + + +
Create merge request
+
+ goto(`${base}/plans/${plan?.parent_plan?.id}`)}> +
Open parent plan
+
{/if} -
-
-
- {#if plan.child_plans.length > 0} -
- {plan.child_plans.length} branch{plan.child_plans.length > 1 ? 'es' : ''} + + +
Take Snapshot
+
+ +
View Snapshot History
+
+ + + {#if !planExporting} + Export plan as .json + {:else} + Exporting... + {/if} + +
+ {#if plan.child_plans.length > 0} +
+ {plan.child_plans.length} branch{plan.child_plans.length > 1 ? 'es' : ''} +
+ {/if} {/if} diff --git a/src/components/ui/PlanName.svelte b/src/components/ui/PlanName.svelte new file mode 100644 index 0000000000..aa0b791f5a --- /dev/null +++ b/src/components/ui/PlanName.svelte @@ -0,0 +1,16 @@ + + + + +
+ {name} + {#if isReadOnly} + + {/if} +
diff --git a/src/components/ui/PlayOffIcon.svelte b/src/components/ui/PlayOffIcon.svelte new file mode 100644 index 0000000000..3b03b7ee51 --- /dev/null +++ b/src/components/ui/PlayOffIcon.svelte @@ -0,0 +1,21 @@ + + + + + + + + + diff --git a/src/routes/plans/+page.svelte b/src/routes/plans/+page.svelte index 1c691f42b0..ac401c46bc 100644 --- a/src/routes/plans/+page.svelte +++ b/src/routes/plans/+page.svelte @@ -24,8 +24,8 @@ import SingleActionDataGrid from '../../components/ui/DataGrid/SingleActionDataGrid.svelte'; import IconCellRenderer from '../../components/ui/IconCellRenderer.svelte'; import Panel from '../../components/ui/Panel.svelte'; + import PlanName from '../../components/ui/PlanName.svelte'; import SectionTitle from '../../components/ui/SectionTitle.svelte'; - import TagChip from '../../components/ui/Tags/Tag.svelte'; import TagsInput from '../../components/ui/Tags/TagsInput.svelte'; import { InvalidDate } from '../../constants/time'; import { SearchParameters } from '../../enums/searchParameters'; @@ -84,7 +84,22 @@ suppressSizeToFit: true, width: 75, }, - { field: 'name', filter: 'text', headerName: 'Name', resizable: true, sortable: true }, + { + cellRenderer: (params: ICellRendererParams) => { + const div = document.createElement('div'); + new PlanName({ + props: { name: params.data?.name || '', isReadOnly: params.data?.is_read_only || false }, + target: div, + }); + return div; + }, + field: 'name', + filter: 'text', + headerName: 'Name', + resizable: true, + sortable: true, + width: 150, + }, { field: 'start_time', filter: 'text', @@ -251,24 +266,6 @@ columnDefs = [ ...baseColumnDefs.slice(0, 2), { - autoHeight: true, - cellRenderer: (params: ICellRendererParams): HTMLDivElement | void => { - if (params.value) { - const executableDiv = document.createElement('div'); - executableDiv.className = 'tags-cell'; - new TagChip({ - props: { - removable: false, - tag: { - color: params.value === 'Executable' ? '#d9fffa' : '#eef2f8', - name: params.value, - }, - }, - target: executableDiv, - }); - return executableDiv; - } - }, comparator: ( valueA: number | string | null | undefined, valueB: number | string | null | undefined, @@ -276,35 +273,26 @@ _nodeB, isDescending: boolean, ) => { - return compareWithRankings( - valueA, - valueB, - isDescending - ? { - '': 0, - string: 1, - } - : { - '': 1, - string: 0, - }, - ); + return compareWithRankings(valueA, valueB, isDescending ? ['', '-', 'number'] : ['number', '-', '']); }, - field: 'is_executable', - filter: 'text', - headerName: 'Executable', + field: 'model_id', + filter: 'number', + headerName: 'Model ID', resizable: true, sortable: true, + suppressAutoSize: true, valueGetter: (params: ValueGetterParams) => { + let value: string | number = ''; if (params.data?.model_id !== undefined) { const associatedModel = $models.find(model => model.id === params.data?.model_id); if (associatedModel) { - return associatedModel.is_executable ? 'Executable' : 'Non Executable'; + value = associatedModel.is_executable ? associatedModel.id : '-'; } } - return ''; + + return value; }, - width: 160, + width: 130, }, { comparator: ( @@ -314,40 +302,23 @@ _nodeB, isDescending: boolean, ) => { - return compareWithRankings( - valueA, - valueB, - isDescending - ? { - '': 0, - '-': 1, - number: 2, - } - : { - '': 2, - '-': 1, - number: 0, - }, - ); + return compareWithRankings(valueA, valueB, isDescending ? ['', 'N/A', 'string'] : ['string', 'N/A', '']); }, - field: 'model_id', - filter: 'number', - headerName: 'Model ID', + field: 'model_name', + filter: 'text', + headerName: 'Model Name', resizable: true, sortable: true, - suppressAutoSize: true, valueGetter: (params: ValueGetterParams) => { - let value: string | number = ''; if (params.data?.model_id !== undefined) { const associatedModel = $models.find(model => model.id === params.data?.model_id); if (associatedModel) { - value = associatedModel.is_executable ? associatedModel.id : '-'; + return associatedModel.is_executable ? associatedModel.name : 'N/A'; } } - - return value; + return ''; }, - width: 130, + width: 200, }, { comparator: ( @@ -357,21 +328,7 @@ _nodeB, isDescending: boolean, ) => { - return compareWithRankings( - valueA, - valueB, - isDescending - ? { - '': 0, - 'N/A': 1, - string: 2, - } - : { - '': 2, - 'N/A': 1, - string: 0, - }, - ); + return compareWithRankings(valueA, valueB, isDescending ? ['', 'N/A', 'string'] : ['string', 'N/A', '']); }, field: 'model_name', filter: 'text', @@ -397,21 +354,7 @@ _nodeB, isDescending: boolean, ) => { - return compareWithRankings( - valueA, - valueB, - isDescending - ? { - '': 0, - '-': 1, - string: 2, - } - : { - '': 2, - '-': 1, - string: 0, - }, - ); + return compareWithRankings(valueA, valueB, isDescending ? ['', '-', 'string'] : ['string', '-', '']); }, field: 'model_version', filter: 'text', @@ -434,6 +377,16 @@ width: 150, }, ...baseColumnDefs.slice(2), + { + autoHeight: true, + field: 'is_read_only', + filter: 'agTextColumnFilter', + cellDataType: 'boolean', + headerName: 'Read Only', + resizable: true, + sortable: true, + width: 260, + }, { cellClass: 'action-cell-container', cellRenderer: (params: PlanCellRendererParams) => { diff --git a/src/utilities/generic.test.ts b/src/utilities/generic.test.ts index af77d609f1..4459a9b83b 100644 --- a/src/utilities/generic.test.ts +++ b/src/utilities/generic.test.ts @@ -183,34 +183,34 @@ describe('Generic utility function tests', () => { describe('compareWithRankings', () => { test('sorts an array according to explicit rankings', () => { - const rankings = { completed: 3, draft: 1, 'in progress': 2 }; - const values = ['completed', 'draft', 'in progress']; + const rankings = ['draft', 'in progress', 'completed', '-', '']; + const values = ['completed', '', '-', 'draft', 'in progress']; values.sort((valueA, valueB) => compareWithRankings(valueA, valueB, rankings)); - expect(values).toEqual(['draft', 'in progress', 'completed']); + expect(values).toEqual(['draft', 'in progress', 'completed', '-', '']); }); - test('uses a value ranking before a type ranking', () => { - const rankings = { '42': 3, number: 1 }; - const values = [42, 7]; + test('uses the ranking for a value when one exists', () => { + const rankings = ['42', '7']; + const values = [7, 42]; values.sort((valueA, valueB) => compareWithRankings(valueA, valueB, rankings)); - expect(values).toEqual([7, 42]); + expect(values).toEqual([42, 7]); }); - test('uses type rankings when no value-specific ranking exists', () => { - const rankings = { number: 1, string: 2 }; - const values = ['10', 10]; + test('uses the default rank for values that are not explicitly ranked', () => { + const rankings = ['unknown', 'ready']; + const values = ['ready', null, 'unknown']; values.sort((valueA, valueB) => compareWithRankings(valueA, valueB, rankings)); - expect(values).toEqual([10, '10']); + expect(values).toEqual([null, 'unknown', 'ready']); }); test('compares numbers numerically when they have the same rank', () => { - const rankings = { number: 1 }; + const rankings: string[] = []; const values = [10, 2, 100]; values.sort((valueA, valueB) => compareWithRankings(valueA, valueB, rankings)); @@ -219,21 +219,12 @@ describe('Generic utility function tests', () => { }); test('compares non-numbers using numeric-aware locale ordering when they have the same rank', () => { - const rankings = { string: 1 }; + const rankings: string[] = []; const values = ['item10', 'item2', 'item1']; values.sort((valueA, valueB) => compareWithRankings(valueA, valueB, rankings)); expect(values).toEqual(['item1', 'item2', 'item10']); }); - - test('uses the default rank for null and unranked values', () => { - const rankings = { ready: 1 }; - const values = ['ready', null, 'unknown']; - - values.sort((valueA, valueB) => compareWithRankings(valueA, valueB, rankings)); - - expect(values).toEqual([null, 'unknown', 'ready']); - }); }); }); diff --git a/src/utilities/generic.ts b/src/utilities/generic.ts index b0647d2e99..90071318e4 100644 --- a/src/utilities/generic.ts +++ b/src/utilities/generic.ts @@ -77,8 +77,12 @@ function getSortRank(value: number | string | null | undefined, rankMappings: Re export function compareWithRankings( valueA: number | string | null | undefined, valueB: number | string | null | undefined, - rankMappings: Record, + valueRankings: string[], ): number { + const rankMappings: Record = {}; + valueRankings.forEach((value, index) => { + rankMappings[value] = index; + }); const priorityA = getSortRank(valueA, rankMappings); const priorityB = getSortRank(valueB, rankMappings); From 27cb11a7585c5a5355545f8d455fc6ef1bdd58d4 Mon Sep 17 00:00:00 2001 From: duranb Date: Mon, 21 Sep 2026 12:24:30 -0700 Subject: [PATCH 04/10] Rename planReadOnly to planIsLocked throughout codebase --- .../activity/ActivityDirectiveForm.svelte | 8 ++--- .../ActivityDirectivesTablePanel.svelte | 4 +-- .../activity/ActivityFormPanel.svelte | 6 ++-- .../activity/ActivityPresetInput.svelte | 12 +++---- .../constraints/ConstraintsPanel.svelte | 18 +++++----- .../modals/ManagePlanConstraintsModal.svelte | 6 ++-- ...ManagePlanSchedulingConditionsModal.svelte | 6 ++-- .../ManagePlanSchedulingGoalsModal.svelte | 6 ++-- .../modals/PlanMergeRequestsModal.svelte | 6 ++-- src/components/plan/PlanForm.svelte | 18 +++++----- .../SchedulingConditionsPanel.svelte | 10 +++--- .../scheduling/SchedulingGoalsPanel.svelte | 18 +++++----- .../SimulationHistoryDataset.svelte | 4 +-- .../simulation/SimulationPanel.svelte | 16 ++++----- .../simulation/SimulationTemplateInput.svelte | 12 +++---- .../timeline/TimelineContextMenu.svelte | 34 +++++++++---------- src/components/timeline/TimelinePanel.svelte | 6 ++-- .../timeline/TimelineViewControls.svelte | 6 ++-- src/routes/plans/[id]/+page.svelte | 31 +++++++++-------- src/stores/plan.ts | 13 ++++++- 20 files changed, 127 insertions(+), 113 deletions(-) diff --git a/src/components/activity/ActivityDirectiveForm.svelte b/src/components/activity/ActivityDirectiveForm.svelte index 64c332ab2b..c1c25d7f47 100644 --- a/src/components/activity/ActivityDirectiveForm.svelte +++ b/src/components/activity/ActivityDirectiveForm.svelte @@ -11,7 +11,7 @@ import { activityArgumentDefaultsMap } from '../../stores/activities'; import { activityErrorRollupsMap, activityValidationErrors } from '../../stores/console'; import { field } from '../../stores/form'; - import { plan, planReadOnly } from '../../stores/plan'; + import { plan, planIsLocked } from '../../stores/plan'; import { plugins } from '../../stores/plugins'; import type { ActivityDirective, @@ -92,9 +92,9 @@ $: if (user !== null && $plan !== null) { hasUpdatePermission = - featurePermissions.activityDirective.canUpdate(user, $plan, activityDirective) && !$planReadOnly; + featurePermissions.activityDirective.canUpdate(user, $plan, activityDirective) && !$planIsLocked; } - $: updatePermissionError = $planReadOnly + $: updatePermissionError = $planIsLocked ? PlanStatusMessages.READ_ONLY : 'You do not have permission to update this activity'; $: highlightKeysMap = keyByBoolean(highlightKeys); @@ -597,7 +597,7 @@ anchorId={revision ? revision.anchor_id : activityDirective.anchor_id} disabled={!editable} {highlightKeysMap} - planReadOnly={$planReadOnly} + planReadOnly={$planIsLocked} isAnchoredToStart={revision ? revision.anchored_to_start : activityDirective.anchored_to_start} startOffset={revision ? revision.start_offset : activityDirective.start_offset} on:updateAnchor={updateAnchor} diff --git a/src/components/activity/ActivityDirectivesTablePanel.svelte b/src/components/activity/ActivityDirectivesTablePanel.svelte index 2fc3d41de8..a379e918af 100644 --- a/src/components/activity/ActivityDirectivesTablePanel.svelte +++ b/src/components/activity/ActivityDirectivesTablePanel.svelte @@ -15,7 +15,7 @@ import { InvalidDate } from '../../constants/time'; import { activityDirectivesMap, selectActivity, selectedActivityDirectiveId } from '../../stores/activities'; import { activityErrorRollupsMap } from '../../stores/console'; - import { maxTimeRange, plan, planModelActivityTypes, planReadOnly, viewTimeRange } from '../../stores/plan'; + import { maxTimeRange, plan, planIsLocked, planModelActivityTypes, viewTimeRange } from '../../stores/plan'; import { plugins } from '../../stores/plugins'; import { spansMap, spanUtilityMaps } from '../../stores/simulation'; import { view, viewTogglePanel, viewUpdateActivityDirectivesTable } from '../../stores/views'; @@ -468,7 +468,7 @@ plan={$plan} spansMap={$spansMap} spanUtilityMaps={$spanUtilityMaps} - planReadOnly={$planReadOnly} + planReadOnly={$planIsLocked} {user} on:columnMoved={onColumnMoved} on:columnPinned={onColumnPinned} diff --git a/src/components/activity/ActivityFormPanel.svelte b/src/components/activity/ActivityFormPanel.svelte index f5078f4954..2b84db6aad 100644 --- a/src/components/activity/ActivityFormPanel.svelte +++ b/src/components/activity/ActivityFormPanel.svelte @@ -18,9 +18,9 @@ import { activityEditingLocked, plan, + planIsLocked, planModelActivityTypes, planModelId, - planReadOnly, setActivityEditingLocked, } from '../../stores/plan'; import { selectedSpan, simulationDatasetId, spanUtilityMaps, spansMap } from '../../stores/simulation'; @@ -52,12 +52,12 @@ let previewRevision: ActivityDirectiveRevision | undefined; let selectedParameterName: string | null = null; - $: deletePermissionError = $planReadOnly + $: deletePermissionError = $planIsLocked ? PlanStatusMessages.READ_ONLY : 'You do not have permission to delete this activity'; $: if (user !== null && $plan !== null && $selectedActivityDirective !== null) { hasDeletePermission = - featurePermissions.activityDirective.canDelete(user, $plan, $selectedActivityDirective) && !$planReadOnly; + featurePermissions.activityDirective.canDelete(user, $plan, $selectedActivityDirective) && !$planIsLocked; } // Auto close the changelog and clear revision preview state whenever the selected activity changes diff --git a/src/components/activity/ActivityPresetInput.svelte b/src/components/activity/ActivityPresetInput.svelte index fe92f00df4..4b8865e692 100644 --- a/src/components/activity/ActivityPresetInput.svelte +++ b/src/components/activity/ActivityPresetInput.svelte @@ -2,7 +2,7 @@