From 87319d9d468d9e9f1130f331a954ae0c38ecfb80 Mon Sep 17 00:00:00 2001 From: Bryan Date: Wed, 28 Feb 2024 16:19:11 -0800 Subject: [PATCH] Shared Constraints MVP (#1118) * split constraints into metadata and definitions * add reference model selection to constraint editor * add radio button component * update ag-grid to take advantage of cell type feature --- e2e-tests/fixtures/Constraints.ts | 12 - e2e-tests/fixtures/Plan.ts | 19 +- e2e-tests/tests/constraints.test.ts | 1 + package-lock.json | 14 +- package.json | 2 +- .../ActivityDirectivesTablePanel.svelte | 5 +- .../activity/ActivitySpansTablePanel.svelte | 6 +- src/components/app/Nav.svelte | 3 +- .../console/views/ActivityErrors.svelte | 1 - .../constraints/ConstraintEditor.svelte | 54 +- .../constraints/ConstraintForm.svelte | 654 ++++++++++++------ .../constraints/ConstraintListItem.svelte | 135 ++-- src/components/constraints/Constraints.svelte | 261 ++----- .../constraints/ConstraintsPanel.svelte | 122 +++- .../modals/ManagePlanConstraintsModal.svelte | 337 +++++++++ src/components/plan/PlanMergeReview.test.ts | 2 + .../ui/DataGrid/BulkActionDataGrid.svelte | 3 + src/components/ui/DataGrid/DataGrid.svelte | 16 + src/components/ui/DataGrid/DataGrid.test.ts | 24 +- .../ui/DataGrid/DataGridActions.svelte | 6 +- .../ui/DataGrid/SingleActionDataGrid.svelte | 3 + .../ui/RadioButtons/RadioButton.svelte | 60 ++ .../ui/RadioButtons/RadioButtons.svelte | 91 +++ src/components/ui/SearchableDropdown.svelte | 5 +- src/components/view/ViewsTable.svelte | 9 +- src/enums/searchParameters.ts | 2 + src/routes/constraints/+page.svelte | 7 +- src/routes/constraints/+page.ts | 10 - src/routes/constraints/edit/[id]/+page.svelte | 96 ++- src/routes/constraints/edit/[id]/+page.ts | 11 - src/routes/constraints/new/+page.svelte | 23 +- src/routes/constraints/new/+page.ts | 12 - src/routes/plans/[id]/+page.svelte | 2 +- src/routes/tags/+page.svelte | 4 +- src/stores/constraints.ts | 96 ++- src/stores/subscribable.ts | 20 + src/stores/tags.ts | 1 - src/types/constraint.ts | 78 ++- src/types/model.ts | 2 + src/types/plan.ts | 2 + src/types/radio-buttons.ts | 10 + src/types/tags.ts | 10 + src/utilities/effects.ts | 356 ++++++---- src/utilities/generic.ts | 12 +- src/utilities/gql.ts | 294 ++++++-- src/utilities/modal.ts | 41 +- src/utilities/permissions.ts | 118 +++- src/workers/customTS.worker.ts | 3 +- 48 files changed, 2161 insertions(+), 894 deletions(-) create mode 100644 src/components/modals/ManagePlanConstraintsModal.svelte create mode 100644 src/components/ui/RadioButtons/RadioButton.svelte create mode 100644 src/components/ui/RadioButtons/RadioButtons.svelte create mode 100644 src/types/radio-buttons.ts diff --git a/e2e-tests/fixtures/Constraints.ts b/e2e-tests/fixtures/Constraints.ts index 1185ceda04..c5ca9953dd 100644 --- a/e2e-tests/fixtures/Constraints.ts +++ b/e2e-tests/fixtures/Constraints.ts @@ -1,6 +1,5 @@ import { expect, type Locator, type Page } from '@playwright/test'; import { adjectives, animals, colors, uniqueNamesGenerator } from 'unique-names-generator'; -import { getOptionValueFromText } from '../utilities/selectors.js'; import { Models } from './Models.js'; export class Constraints { @@ -26,14 +25,12 @@ export class Constraints { async createConstraint(baseURL: string | undefined) { await expect(this.saveButton).toBeDisabled(); - await this.selectModel(); await this.fillConstraintName(); await this.fillConstraintDescription(); await this.fillConstraintDefinition(); await expect(this.saveButton).not.toBeDisabled(); await this.saveButton.click(); await this.page.waitForURL(`${baseURL}/constraints/edit/*`); - await expect(this.saveButton).not.toBeDisabled(); await expect(this.closeButton).not.toBeDisabled(); await this.closeButton.click(); await this.page.waitForURL(`${baseURL}/constraints`); @@ -43,7 +40,6 @@ export class Constraints { await this.goto(); await expect(this.tableRow).toBeVisible(); await expect(this.tableRowDeleteButton).not.toBeVisible(); - await this.tableRow.hover(); await this.tableRowDeleteButton.waitFor({ state: 'attached' }); await this.tableRowDeleteButton.waitFor({ state: 'visible' }); @@ -85,14 +81,6 @@ export class Constraints { await this.page.waitForTimeout(250); } - async selectModel() { - await this.page.waitForSelector(`option:has-text("${this.models.modelName}")`, { state: 'attached' }); - const value = await getOptionValueFromText(this.page, this.inputConstraintModelSelector, this.models.modelName); - await this.inputConstraintModel.focus(); - await this.inputConstraintModel.selectOption(value); - await this.inputConstraintModel.evaluate(e => e.blur()); - } - updatePage(page: Page): void { this.closeButton = page.locator(`button:has-text("Close")`); this.confirmModal = page.locator(`.modal:has-text("Delete Constraint")`); diff --git a/e2e-tests/fixtures/Plan.ts b/e2e-tests/fixtures/Plan.ts index 221a267cf1..5709971c78 100644 --- a/e2e-tests/fixtures/Plan.ts +++ b/e2e-tests/fixtures/Plan.ts @@ -12,6 +12,7 @@ export class Plan { analyzeButton: Locator; appError: Locator; constraintListItemSelector: string; + constraintManageButton: Locator; constraintNewButton: Locator; gridMenu: Locator; gridMenuButton: Locator; @@ -74,12 +75,18 @@ export class Plan { } async createConstraint(baseURL: string | undefined) { - const [newConstraintPage] = await Promise.all([this.page.waitForEvent('popup'), this.constraintNewButton.click()]); + await this.constraintManageButton.click(); + const [newConstraintPage] = await Promise.all([ + this.page.waitForEvent('popup'), + await this.constraintNewButton.click(), + ]); this.constraints.updatePage(newConstraintPage); - await newConstraintPage.waitForURL(`${baseURL}/constraints/new`); + await newConstraintPage.waitForURL(`${baseURL}/constraints/new?modelId=*`); await this.constraints.createConstraint(baseURL); await newConstraintPage.close(); this.constraints.updatePage(this.page); + await this.page.getByRole('row', { name: this.constraints.constraintName }).getByRole('checkbox').click(); + await this.page.getByRole('button', { name: 'Update' }).click(); await this.page.waitForSelector(this.constraintListItemSelector, { state: 'visible', strict: true }); } @@ -148,6 +155,13 @@ export class Plan { await this.page.waitForTimeout(250); } + async removeConstraint() { + await this.constraintManageButton.click(); + await this.page.getByRole('row', { name: this.constraints.constraintName }).getByRole('checkbox').click(); + await this.page.getByRole('button', { name: 'Update' }).click(); + await this.page.locator(this.constraintListItemSelector).waitFor({ state: 'detached' }); + } + async runAnalysis() { await this.analyzeButton.click(); await this.page.waitForSelector(this.schedulingStatusSelector('Incomplete'), { state: 'attached', strict: true }); @@ -287,6 +301,7 @@ export class Plan { this.activitiesTableFirstRow = page .locator(`div.ag-theme-stellar.table .ag-center-cols-container > .ag-row`) .nth(0); + this.constraintManageButton = page.locator(`button[name="manage-constraints"]`); this.constraintNewButton = page.locator(`button[name="new-constraint"]`); this.gridMenu = page.locator('.grid-menu > .menu > .menu-slot'); this.gridMenuButton = page.locator('.grid-menu'); diff --git a/e2e-tests/tests/constraints.test.ts b/e2e-tests/tests/constraints.test.ts index 166e661978..5b2c242a08 100644 --- a/e2e-tests/tests/constraints.test.ts +++ b/e2e-tests/tests/constraints.test.ts @@ -52,6 +52,7 @@ test.describe.serial('Constraints', () => { }); test('Delete constraint', async () => { + await plan.removeConstraint(); await constraints.deleteConstraint(); }); }); diff --git a/package-lock.json b/package-lock.json index 4785131393..695d164764 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,7 +14,7 @@ "@nasa-jpl/stellar": "^1.1.18", "@sveltejs/adapter-node": "1.2.4", "@sveltejs/kit": "1.20.5", - "ag-grid-community": "29.3.3", + "ag-grid-community": "30.2.0", "ajv": "^8.12.0", "bootstrap": "^5.3.0", "bootstrap-icons": "^1.11.0", @@ -1401,9 +1401,9 @@ } }, "node_modules/ag-grid-community": { - "version": "29.3.3", - "resolved": "https://registry.npmjs.org/ag-grid-community/-/ag-grid-community-29.3.3.tgz", - "integrity": "sha512-5XHG2NtXfUFroST/IvWyIYzM7GnbAM1mX7YsKvUHRWk0iMY1kAMJMk6AOoNKe1BBj7jg+Wgbig123T4X7bNZPw==" + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/ag-grid-community/-/ag-grid-community-30.2.0.tgz", + "integrity": "sha512-Gd6GXmtzEQSCDloBdRxxCDqnjTBRAOf/zzlaxxyyVBJgc+cePuNgGdplRUhT/rwIiDwvyuoynvxelVE/iYdXsA==" }, "node_modules/agent-base": { "version": "6.0.2", @@ -7841,9 +7841,9 @@ "dev": true }, "ag-grid-community": { - "version": "29.3.3", - "resolved": "https://registry.npmjs.org/ag-grid-community/-/ag-grid-community-29.3.3.tgz", - "integrity": "sha512-5XHG2NtXfUFroST/IvWyIYzM7GnbAM1mX7YsKvUHRWk0iMY1kAMJMk6AOoNKe1BBj7jg+Wgbig123T4X7bNZPw==" + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/ag-grid-community/-/ag-grid-community-30.2.0.tgz", + "integrity": "sha512-Gd6GXmtzEQSCDloBdRxxCDqnjTBRAOf/zzlaxxyyVBJgc+cePuNgGdplRUhT/rwIiDwvyuoynvxelVE/iYdXsA==" }, "agent-base": { "version": "6.0.2", diff --git a/package.json b/package.json index 83a91b847c..1bee1ef26d 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,7 @@ "@nasa-jpl/stellar": "^1.1.18", "@sveltejs/adapter-node": "1.2.4", "@sveltejs/kit": "1.20.5", - "ag-grid-community": "29.3.3", + "ag-grid-community": "30.2.0", "ajv": "^8.12.0", "bootstrap": "^5.3.0", "bootstrap-icons": "^1.11.0", diff --git a/src/components/activity/ActivityDirectivesTablePanel.svelte b/src/components/activity/ActivityDirectivesTablePanel.svelte index 0d5d306bc8..9148bdef39 100644 --- a/src/components/activity/ActivityDirectivesTablePanel.svelte +++ b/src/components/activity/ActivityDirectivesTablePanel.svelte @@ -32,9 +32,7 @@ export let user: User | null; type ActivityDirectiveColumns = keyof ActivityDirective | 'derived_start_time'; - interface ActivityDirectiveColDef extends ColDef { - field: ActivityDirectiveColumns; - } + type ActivityDirectiveColDef = ColDef; let activityDirectivesTable: ViewTable | undefined; let autoSizeColumns: AutoSizeColumns | undefined; @@ -103,7 +101,6 @@ sortable: true, }, derived_start_time: { - field: 'derived_start_time', filter: 'text', headerName: 'Absolute Start Time (UTC)', hide: true, diff --git a/src/components/activity/ActivitySpansTablePanel.svelte b/src/components/activity/ActivitySpansTablePanel.svelte index aec0241860..a27c3e20e0 100644 --- a/src/components/activity/ActivitySpansTablePanel.svelte +++ b/src/components/activity/ActivitySpansTablePanel.svelte @@ -22,9 +22,7 @@ export let gridSection: ViewGridSection; type SpanColumns = keyof Span | 'derived_start_time' | 'derived_end_time'; - interface SpanColDef extends ColDef { - field: SpanColumns; - } + type SpanColDef = ColDef; let activitySpansTable: ViewTable | undefined; let autoSizeColumns: AutoSizeColumns | undefined; @@ -48,7 +46,6 @@ sortable: true, }, derived_start_time: { - field: 'derived_start_time', filter: 'text', headerName: 'Absolute Start Time (UTC)', hide: true, @@ -62,7 +59,6 @@ }, }, derived_end_time: { - field: 'derived_end_time', filter: 'text', headerName: 'Absolute End Time (UTC)', hide: true, diff --git a/src/components/app/Nav.svelte b/src/components/app/Nav.svelte index 7f154641c4..01ff1465ab 100644 --- a/src/components/app/Nav.svelte +++ b/src/components/app/Nav.svelte @@ -1,5 +1,4 @@ diff --git a/src/components/console/views/ActivityErrors.svelte b/src/components/console/views/ActivityErrors.svelte index 36cc0ed3d4..801e8542f9 100644 --- a/src/components/console/views/ActivityErrors.svelte +++ b/src/components/console/views/ActivityErrors.svelte @@ -70,7 +70,6 @@ width: 60, }, { - field: 'fields', filter: 'number', headerName: '# fields', resizable: true, diff --git a/src/components/constraints/ConstraintEditor.svelte b/src/components/constraints/ConstraintEditor.svelte index 69ea6e9841..a3e6a140e8 100644 --- a/src/components/constraints/ConstraintEditor.svelte +++ b/src/components/constraints/ConstraintEditor.svelte @@ -1,27 +1,39 @@ {title} + @@ -56,3 +85,16 @@ /> + + diff --git a/src/components/constraints/ConstraintForm.svelte b/src/components/constraints/ConstraintForm.svelte index 11a1651a98..16acd07383 100644 --- a/src/components/constraints/ConstraintForm.svelte +++ b/src/components/constraints/ConstraintForm.svelte @@ -3,14 +3,21 @@ - + async function saveConstraintDefinitionRevisionTags() { + if (constraintMetadataId !== null && initialConstraintRevision !== null) { + // Associate new tags with constraint definition version + const tagsToUpdate: ConstraintDefinitionTagsInsertInput[] = constraintDefinitionTags.map(({ id: tag_id }) => ({ + constraint_id: constraintMetadataId as number, + constraint_revision: initialConstraintRevision as number, + tag_id, + })); + + // Disassociate old tags from constraint + const tagIdsToDelete = initialConstraintDefinitionTags + .filter(tag => !constraintDefinitionTags.find(t => tag.id === t.id)) + .map(tag => tag.id); + await effects.updateConstraintDefinitionTags( + constraintMetadataId, + initialConstraintRevision, + tagsToUpdate, + tagIdsToDelete, + user, + ); + } + } + + function revertConstraint() { + constraintDefintionAuthor = initialConstraintDefinitionAuthor ?? user?.id ?? null; + constraintDefinitionCode = initialConstraintDefinitionCode; + constraintDefinitionTags = initialConstraintDefinitionTags; + constraintDescription = initialConstraintDescription; + constraintMetadataId = initialConstraintId; + constraintMetadataTags = initialConstraintMetadataTags; + constraintName = initialConstraintName; + constraintOwner = initialConstraintOwner ?? user?.id ?? null; + constraintPublic = initialConstraintPublic; + } + @@ -242,11 +385,14 @@ + {#if mode === 'edit' && saveButtonEnabled} + + {/if} + + + + window.open( + `${base}/constraints/edit/${constraint.id}${ + constraintPlanSpec.constraint_revision !== null + ? `?${SearchParameters.REVISION}=${constraintPlanSpec.constraint_revision}&${SearchParameters.MODEL_ID}=${modelId}` + : '' + }`, + '_blank', + )} + use={[ + [ + permissionHandler, + { + hasPermission: hasReadPermission, + permissionError: 'You do not have permission to edit this constraint', + }, + ], + ]} + > + View Constraint + + +
{#if constraint.description} @@ -112,39 +196,6 @@
{/if} - - - Actions - window.open(`${base}/constraints/edit/${constraint.id}`, '_blank')} - use={[ - [ - permissionHandler, - { - hasPermission: hasEditPermission, - permissionError: 'You do not have permission to edit this constraint', - }, - ], - ]} - > - Edit Constraint - - Modify - plan && effects.deleteConstraint(constraint, plan, user)} - use={[ - [ - permissionHandler, - { - hasPermission: hasDeletePermission, - permissionError: 'You do not have permission to delete this constraint', - }, - ], - ]} - > - Delete Constraint - - diff --git a/src/components/constraints/Constraints.svelte b/src/components/constraints/Constraints.svelte index 44f7c8b384..8c90f81354 100644 --- a/src/components/constraints/Constraints.svelte +++ b/src/components/constraints/Constraints.svelte @@ -3,13 +3,12 @@ + + + Manage Constraints + +
+
+
Constraints
+ + + + +
+
+
+ {#if filteredConstraints.length} + + {:else} +
No Constraints Found
+ {/if} +
+
+
+ + + + +
+ + diff --git a/src/components/plan/PlanMergeReview.test.ts b/src/components/plan/PlanMergeReview.test.ts index 021e476e00..4d714469d8 100644 --- a/src/components/plan/PlanMergeReview.test.ts +++ b/src/components/plan/PlanMergeReview.test.ts @@ -48,12 +48,14 @@ const mockMergeRequest: PlanMergeRequestSchema = { const mockInitialPlan: Plan = { child_plans: [{ id: 2, name: 'Branch 1' }], collaborators: [{ collaborator: 'tester 2' }], + constraint_specification: [], created_at: '2023-02-16T00:00:00', duration: '168:00:00', end_time_doy: '2023-054T00:00:00', id: 1, is_locked: true, model: { + constraint_specification: [], created_at: '2023-02-16T00:00:00', id: 1, jar_id: 1, diff --git a/src/components/ui/DataGrid/BulkActionDataGrid.svelte b/src/components/ui/DataGrid/BulkActionDataGrid.svelte index 7db0c73ba2..25161bced1 100644 --- a/src/components/ui/DataGrid/BulkActionDataGrid.svelte +++ b/src/components/ui/DataGrid/BulkActionDataGrid.svelte @@ -135,6 +135,9 @@ {suppressRowClickSelection} {filterExpression} on:blur={onBlur} + on:cellEditingStarted + on:cellEditingStopped + on:cellValueChanged on:cellMouseOver on:columnMoved on:columnPinned diff --git a/src/components/ui/DataGrid/DataGrid.svelte b/src/components/ui/DataGrid/DataGrid.svelte index e3cde337a2..20f05b732b 100644 --- a/src/components/ui/DataGrid/DataGrid.svelte +++ b/src/components/ui/DataGrid/DataGrid.svelte @@ -6,7 +6,10 @@ // eslint-disable-next-line interface $$Events extends ComponentEvents { cellContextMenu: CustomEvent>; + cellEditingStarted: CustomEvent>; + cellEditingStopped: CustomEvent>; cellMouseOver: CustomEvent>; + cellValueChanged: CustomEvent>; columnMoved: CustomEvent>; columnPinned: CustomEvent>; columnResized: CustomEvent>; @@ -24,7 +27,10 @@ import { Grid, type CellContextMenuEvent, + type CellEditingStartedEvent, + type CellEditingStoppedEvent, type CellMouseOverEvent, + type CellValueChangedEvent, type ColDef, type Column, type ColumnMovedEvent, @@ -258,9 +264,19 @@ This has been seen to result in unintended and often glitchy behavior, which oft isRowSelectable, maintainColumnOrder, onCellContextMenu, + onCellEditingStarted(event: CellEditingStartedEvent) { + dispatch('cellEditingStarted', event); + }, + onCellEditingStopped(event: CellEditingStoppedEvent) { + dispatch('cellEditingStopped', event); + }, onCellMouseOver(event: CellMouseOverEvent) { dispatch('cellMouseOver', event); }, + onCellValueChanged(event: CellValueChangedEvent) { + console.log('event :>> ', event); + dispatch('cellValueChanged', event); + }, onColumnMoved(event: ColumnMovedEvent) { dispatch('columnMoved', event); onColumnStateChangeDebounced(); diff --git a/src/components/ui/DataGrid/DataGrid.test.ts b/src/components/ui/DataGrid/DataGrid.test.ts index c746604ee7..f82bcdd566 100644 --- a/src/components/ui/DataGrid/DataGrid.test.ts +++ b/src/components/ui/DataGrid/DataGrid.test.ts @@ -30,7 +30,7 @@ describe('DataGrid Component', () => { rowData: testRowData, }); - expect(container.querySelectorAll('.ag-center-cols-clipper .ag-row')).toHaveLength(numOfRows); + expect(container.querySelectorAll('.ag-center-cols-container .ag-row')).toHaveLength(numOfRows); }); it('Should highlight the correctly selected rows on initialization', async () => { @@ -46,7 +46,7 @@ describe('DataGrid Component', () => { selectedRowIds: [1, 2, 3], }); - expect(container.querySelectorAll('.ag-center-cols-clipper .ag-row.ag-row-selected')).toHaveLength(3); + expect(container.querySelectorAll('.ag-center-cols-container .ag-row.ag-row-selected')).toHaveLength(3); }); it('Should highlight the correctly selected rows through user interaction', async () => { @@ -61,15 +61,15 @@ describe('DataGrid Component', () => { rowSelection: 'multiple', }); - expect(container.querySelectorAll('.ag-center-cols-clipper .ag-row.ag-row-selected')).toHaveLength(0); + expect(container.querySelectorAll('.ag-center-cols-container .ag-row.ag-row-selected')).toHaveLength(0); - await fireEvent.click(container.querySelectorAll('.ag-center-cols-clipper .ag-row')[0]); + await fireEvent.click(container.querySelectorAll('.ag-center-cols-container .ag-row')[0]); - expect(container.querySelectorAll('.ag-center-cols-clipper .ag-row.ag-row-selected')).toHaveLength(1); + expect(container.querySelectorAll('.ag-center-cols-container .ag-row.ag-row-selected')).toHaveLength(1); - await fireEvent.click(container.querySelectorAll('.ag-center-cols-clipper .ag-row')[2], { shiftKey: true }); + await fireEvent.click(container.querySelectorAll('.ag-center-cols-container .ag-row')[2], { shiftKey: true }); - expect(container.querySelectorAll('.ag-center-cols-clipper .ag-row.ag-row-selected')).toHaveLength(3); + expect(container.querySelectorAll('.ag-center-cols-container .ag-row.ag-row-selected')).toHaveLength(3); }); it('Should indicate that the row that was selected last is indicated as the current selected row', async () => { @@ -84,21 +84,21 @@ describe('DataGrid Component', () => { rowSelection: 'multiple', }); - expect(container.querySelectorAll('.ag-center-cols-clipper .ag-row.ag-row-selected')).toHaveLength(0); + expect(container.querySelectorAll('.ag-center-cols-container .ag-row.ag-row-selected')).toHaveLength(0); - await fireEvent.click(container.querySelectorAll('.ag-center-cols-clipper .ag-row')[2]); - await fireEvent.click(container.querySelectorAll('.ag-center-cols-clipper .ag-row')[0], { + await fireEvent.click(container.querySelectorAll('.ag-center-cols-container .ag-row')[2]); + await fireEvent.click(container.querySelectorAll('.ag-center-cols-container .ag-row')[0], { bubbles: true, shiftKey: true, }); - expect(container.querySelectorAll('.ag-center-cols-clipper .ag-row.ag-row-selected')).toHaveLength(3); + expect(container.querySelectorAll('.ag-center-cols-container .ag-row.ag-row-selected')).toHaveLength(3); // need to wait for the component to fully update await new Promise(resolve => setTimeout(resolve, 0)); expect( - container.querySelector('.ag-center-cols-clipper .ag-row.ag-row-selected.ag-current-row-selected'), + container.querySelector('.ag-center-cols-container .ag-row.ag-row-selected.ag-current-row-selected'), ).not.toBeNull(); }); }); diff --git a/src/components/ui/DataGrid/DataGridActions.svelte b/src/components/ui/DataGrid/DataGridActions.svelte index 2d2c865c31..2abe1ef661 100644 --- a/src/components/ui/DataGrid/DataGridActions.svelte +++ b/src/components/ui/DataGrid/DataGridActions.svelte @@ -25,7 +25,9 @@ export let deleteTooltip: Tooltip | undefined = undefined; export let downloadTooltip: Tooltip | undefined = undefined; export let hasDeletePermission: boolean = true; + export let hasDeletePermissionError: string | undefined = undefined; export let hasEditPermission: boolean = true; + export let hasEditPermissionError: string | undefined = undefined; export let planReadOnly: boolean = false; export let viewTooltip: Tooltip | undefined = undefined; @@ -74,7 +76,7 @@ hasPermission: hasEditPermission, permissionError: planReadOnly ? PlanStatusMessages.READ_ONLY - : `You do not have permission to ${editTooltip?.content ?? 'edit'}.`, + : hasEditPermissionError || `You do not have permission to ${editTooltip?.content ?? 'edit'}.`, }} > @@ -93,7 +95,7 @@ hasPermission: hasDeletePermission, permissionError: planReadOnly ? PlanStatusMessages.READ_ONLY - : `You do not have permission to ${deleteTooltip?.content ?? 'delete'}.`, + : hasDeletePermissionError || `You do not have permission to ${deleteTooltip?.content ?? 'delete'}.`, }} > diff --git a/src/components/ui/DataGrid/SingleActionDataGrid.svelte b/src/components/ui/DataGrid/SingleActionDataGrid.svelte index ba6ab8c6c5..f8dd18eaa7 100644 --- a/src/components/ui/DataGrid/SingleActionDataGrid.svelte +++ b/src/components/ui/DataGrid/SingleActionDataGrid.svelte @@ -116,6 +116,9 @@ rowSelection="single" {scrollToSelection} on:blur={onBlur} + on:cellEditingStarted + on:cellEditingStopped + on:cellValueChanged on:cellMouseOver on:columnMoved on:columnPinned diff --git a/src/components/ui/RadioButtons/RadioButton.svelte b/src/components/ui/RadioButtons/RadioButton.svelte new file mode 100644 index 0000000000..cf2063010c --- /dev/null +++ b/src/components/ui/RadioButtons/RadioButton.svelte @@ -0,0 +1,60 @@ + + + + + + + diff --git a/src/components/ui/RadioButtons/RadioButtons.svelte b/src/components/ui/RadioButtons/RadioButtons.svelte new file mode 100644 index 0000000000..bff0e730e4 --- /dev/null +++ b/src/components/ui/RadioButtons/RadioButtons.svelte @@ -0,0 +1,91 @@ + + + + + + +
+ +
+ + diff --git a/src/components/ui/SearchableDropdown.svelte b/src/components/ui/SearchableDropdown.svelte index 1913e57fcc..23eee6cc12 100644 --- a/src/components/ui/SearchableDropdown.svelte +++ b/src/components/ui/SearchableDropdown.svelte @@ -11,6 +11,7 @@ import SearchIcon from '@nasa-jpl/stellar/icons/search.svg?component'; import SettingsIcon from '@nasa-jpl/stellar/icons/settings.svg?component'; import { SvelteComponent, createEventDispatcher, type ComponentEvents } from 'svelte'; + import { PlanStatusMessages } from '../../enums/planStatusMessages'; import type { DropdownOption, DropdownOptions, SelectedDropdownOptionValue } from '../../types/dropdown'; import { getTarget } from '../../utilities/generic'; import { permissionHandler } from '../../utilities/permissionHandler'; @@ -19,7 +20,6 @@ import Menu from '../menus/Menu.svelte'; import MenuHeader from '../menus/MenuHeader.svelte'; import MenuItem from '../menus/MenuItem.svelte'; - import { PlanStatusMessages } from '../../enums/planStatusMessages'; interface PlaceholderOption extends Omit { value: null; @@ -32,6 +32,7 @@ export let hasUpdatePermission: boolean = true; export let options: DropdownOptions = []; export let maxListHeight: string = '300px'; + export let name: string | undefined = undefined; export let updatePermissionError: string = 'You do not have permission to update this'; export let placeholder: string = ''; export let planReadOnly: boolean = false; @@ -107,6 +108,7 @@ class="selected-display st-input w-100" class:error class:disabled + {name} on:click|stopPropagation={openMenu} role="textbox" aria-label={selectedOption?.display ?? placeholder} @@ -203,6 +205,7 @@ } .selected-display-value { + cursor: pointer; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; diff --git a/src/components/view/ViewsTable.svelte b/src/components/view/ViewsTable.svelte index 00d9ccadd5..28d0fb80a1 100644 --- a/src/components/view/ViewsTable.svelte +++ b/src/components/view/ViewsTable.svelte @@ -41,10 +41,13 @@ resizable: true, sortable: true, valueFormatter: ({ value: updatedAt }: ValueFormatterParams) => { - const updatedAtDate = new Date(updatedAt); - updatedAtDate.setMilliseconds(0); + if (updatedAt) { + const updatedAtDate = new Date(updatedAt); + updatedAtDate.setMilliseconds(0); - return updatedAtDate.toISOString().replace(/.\d+Z$/g, 'Z'); + return updatedAtDate.toISOString().replace(/.\d+Z$/g, 'Z'); + } + return ''; }, }, ]; diff --git a/src/enums/searchParameters.ts b/src/enums/searchParameters.ts index 5249e31767..cf9dedbd09 100644 --- a/src/enums/searchParameters.ts +++ b/src/enums/searchParameters.ts @@ -1,6 +1,7 @@ export enum SearchParameters { ACTIVITY_ID = 'activityId', MODEL_ID = 'modelId', + CONSTRAINT_ID = 'constraintId', REASON = 'reason', SIMULATION_DATASET_ID = 'simulationDatasetId', SNAPSHOT_ID = 'snapshotId', @@ -9,4 +10,5 @@ export enum SearchParameters { VIEW_ID = 'viewId', START_TIME = 'startTime', END_TIME = 'endTime', + REVISION = 'revision', } diff --git a/src/routes/constraints/+page.svelte b/src/routes/constraints/+page.svelte index 9dc7e4acb8..cd74c7238b 100644 --- a/src/routes/constraints/+page.svelte +++ b/src/routes/constraints/+page.svelte @@ -10,9 +10,4 @@ - + diff --git a/src/routes/constraints/+page.ts b/src/routes/constraints/+page.ts index 07753ff38f..ee8329053d 100644 --- a/src/routes/constraints/+page.ts +++ b/src/routes/constraints/+page.ts @@ -1,19 +1,9 @@ -import effects from '../../utilities/effects'; import type { PageLoad } from './$types'; export const load: PageLoad = async ({ parent }) => { const { user } = await parent(); - const { - modelMap: initialModelMap, - planMap: initialPlanMap, - plans: initialPlans, - } = await effects.getPlansAndModelsForConstraints(user); - return { - initialModelMap, - initialPlanMap, - initialPlans, user, }; }; diff --git a/src/routes/constraints/edit/[id]/+page.svelte b/src/routes/constraints/edit/[id]/+page.svelte index 692f365df7..0183d124f0 100644 --- a/src/routes/constraints/edit/[id]/+page.svelte +++ b/src/routes/constraints/edit/[id]/+page.svelte @@ -1,26 +1,98 @@ tag)} - initialModelMap={data.initialModelMap} - initialModels={data.initialModels} - initialPlanMap={data.initialPlanMap} - initialPlans={data.initialPlans} - initialTags={$tags} + initialConstraintDefinitionAuthor={constraintDefinitionAuthor} + initialConstraintDefinitionCode={constraintDefinitionCode} + initialConstraintDescription={constraintDescription} + initialConstraintId={constraintId} + initialConstraintName={constraintName} + initialConstraintPublic={constraintPublic} + initialConstraintDefinitionTags={constraintDefinitionTags} + initialConstraintMetadataTags={constraintMetadataTags} + initialConstraintOwner={constraintOwner} + initialConstraintRevision={constraintRevision} + initialReferenceModelId={referenceModelId} + {constraintRevisions} + tags={$tags} mode="edit" user={data.user} + on:selectRevision={onRevisionSelect} + on:selectReferenceModel={onModelSelect} /> diff --git a/src/routes/constraints/edit/[id]/+page.ts b/src/routes/constraints/edit/[id]/+page.ts index e46726cc17..e1be28c0ea 100644 --- a/src/routes/constraints/edit/[id]/+page.ts +++ b/src/routes/constraints/edit/[id]/+page.ts @@ -14,20 +14,9 @@ export const load: PageLoad = async ({ parent, params }) => { if (constraintId !== null) { const initialConstraint = await effects.getConstraint(constraintId, user); - const { - modelMap: initialModelMap, - models: initialModels, - planMap: initialPlanMap, - plans: initialPlans, - } = await effects.getPlansAndModelsForConstraints(user); - if (initialConstraint !== null) { return { initialConstraint, - initialModelMap, - initialModels, - initialPlanMap, - initialPlans, user, }; } diff --git a/src/routes/constraints/new/+page.svelte b/src/routes/constraints/new/+page.svelte index a47ecafcea..b7508ba56d 100644 --- a/src/routes/constraints/new/+page.svelte +++ b/src/routes/constraints/new/+page.svelte @@ -1,19 +1,24 @@ - + diff --git a/src/routes/constraints/new/+page.ts b/src/routes/constraints/new/+page.ts index 9e083774f4..ee8329053d 100644 --- a/src/routes/constraints/new/+page.ts +++ b/src/routes/constraints/new/+page.ts @@ -1,21 +1,9 @@ -import effects from '../../../utilities/effects'; import type { PageLoad } from './$types'; export const load: PageLoad = async ({ parent }) => { const { user } = await parent(); - const { - modelMap: initialModelMap, - models: initialModels, - planMap: initialPlanMap, - plans: initialPlans, - } = await effects.getPlansAndModelsForConstraints(user); - return { - initialModelMap, - initialModels, - initialPlanMap, - initialPlans, user, }; }; diff --git a/src/routes/plans/[id]/+page.svelte b/src/routes/plans/[id]/+page.svelte index 9ff2a6fc48..bd70b648e5 100644 --- a/src/routes/plans/[id]/+page.svelte +++ b/src/routes/plans/[id]/+page.svelte @@ -217,7 +217,7 @@ $: hasUpdateViewPermission = $view !== null ? featurePermissions.view.canUpdate(data.user, $view) : false; $: if ($plan) { hasCheckConstraintsPermission = - featurePermissions.constraints.canCheck(data.user, $plan, $plan.model) && !$planReadOnly; + featurePermissions.constraintPlanSpec.canCheck(data.user, $plan, $plan.model) && !$planReadOnly; hasExpandPermission = featurePermissions.expansionSequences.canExpand(data.user, $plan, $plan.model) && !$planReadOnly; hasScheduleAnalysisPermission = diff --git a/src/routes/tags/+page.svelte b/src/routes/tags/+page.svelte index bac95caaaf..2e6bbf9959 100644 --- a/src/routes/tags/+page.svelte +++ b/src/routes/tags/+page.svelte @@ -235,12 +235,12 @@ async function deleteTag(tag: Tag): Promise { const { confirm } = await showConfirmModal( 'Delete', - `Are you sure you want to delete "${tag.name}"? All occurences of this tag will be removed from Plans, Activity Directives, Constraints, Scheduling Goals, and Expansion Rules.`, + `Are you sure you want to delete "${tag.name}"? All occurrences of this tag will be removed from Plans, Activity Directives, Constraints, Scheduling Goals, and Expansion Rules.`, 'Delete Tag', ); if (confirm) { // TODO how should we handle partial success? - const constraintTagDeletionSuccess = await effects.deleteConstraintTags([tag.id], user); + const constraintTagDeletionSuccess = await effects.deleteConstraintMetadataTags([tag.id], user); const expansionRuleTagDeletionSuccess = await effects.deleteExpansionRuleTags([tag.id], user); const tagDeletionSuccess = await effects.deleteTag(tag, user); diff --git a/src/stores/constraints.ts b/src/stores/constraints.ts index 4614f83072..ad81b60b32 100644 --- a/src/stores/constraints.ts +++ b/src/stores/constraints.ts @@ -1,50 +1,82 @@ import { keyBy } from 'lodash-es'; import { derived, get, writable, type Readable, type Writable } from 'svelte/store'; import { Status } from '../enums/status'; -import type { Constraint, ConstraintResponse, ConstraintResultWithName } from '../types/constraint'; +import type { + ConstraintDefinition, + ConstraintMetadata, + ConstraintPlanSpec, + ConstraintResponse, + ConstraintResultWithName, +} from '../types/constraint'; import gql from '../utilities/gql'; -import { modelId, planId, planStartTimeMs } from './plan'; +import { planId, planStartTimeMs } from './plan'; import { gqlSubscribable } from './subscribable'; +/* Writeable. */ + +export const constraintMetadataId: Writable = writable(-1); + +export const constraintsViolationStatus: Writable = writable(null); + +export const constraintVisibilityMapWritable: Writable> = writable({}); + +export const checkConstraintsStatus: Writable = writable(null); + +export const rawConstraintResponses: Writable = writable([]); + +export const constraintsColumns: Writable = writable('1fr 3px 2fr'); +export const constraintsFormColumns: Writable = writable('1fr 3px 2fr'); + /* Subscriptions. */ -export const constraints = gqlSubscribable(gql.SUB_CONSTRAINTS, { modelId, planId }, [], null); +export const constraints = gqlSubscribable(gql.SUB_CONSTRAINTS, {}, [], null); -export const constraintsAll = gqlSubscribable(gql.SUB_CONSTRAINTS_ALL, {}, [], null); +export const constraintPlanSpecs = gqlSubscribable( + gql.SUB_CONSTRAINT_PLAN_SPECIFICATIONS, + { planId }, + [], + null, +); -export const constraintsMap: Readable> = derived([constraints], ([$constraints]) => +export const constraintMetadata = gqlSubscribable( + gql.SUB_CONSTRAINT, + { id: constraintMetadataId }, + null, + null, +); + +/* Derived. */ +export const constraintsMap: Readable> = derived([constraints], ([$constraints]) => keyBy($constraints, 'id'), ); -export const constraintVisibilityMapWritable: Writable> = writable({}); +export const allowedConstraintSpecs: Readable = derived( + [constraintPlanSpecs], + ([$constraintPlanSpecs]) => + $constraintPlanSpecs.filter(({ constraint_metadata: constraintMetadata }) => constraintMetadata !== null), +); + +export const allowedConstraintPlanSpecMap: Readable> = derived( + [allowedConstraintSpecs], + ([$allowedConstraintSpecs]) => keyBy($allowedConstraintSpecs, 'constraint_id'), +); -export const constraintVisibilityMap: Readable> = derived( - [constraintsMap, constraintVisibilityMapWritable], - ([$constraintsMap, $constraintVisibilityMapWritable]) => { - return Object.values($constraintsMap).reduce((map: Record, constraint) => { - if (constraint.id in $constraintVisibilityMapWritable) { - map[constraint.id] = $constraintVisibilityMapWritable[constraint.id]; +export const constraintVisibilityMap: Readable> = derived( + [allowedConstraintPlanSpecMap, constraintVisibilityMapWritable], + ([$allowedConstraintPlanSpecMap, $constraintVisibilityMapWritable]) => { + return Object.values($allowedConstraintPlanSpecMap).reduce((map: Record, constraint) => { + if (constraint.constraint_id in $constraintVisibilityMapWritable) { + map[constraint.constraint_id] = $constraintVisibilityMapWritable[constraint.constraint_id]; } else { - map[constraint.id] = true; + map[constraint.constraint_id] = true; } return map; }, {}); }, ); -export const checkConstraintsStatus: Writable = writable(null); -export const constraintsViolationStatus: Writable = writable(null); - -export const rawConstraintResponses: Writable = writable([]); - -export const constraintsColumns: Writable = writable('2fr 3px 1fr'); -export const constraintsFormColumns: Writable = writable('1fr 3px 2fr'); - -/* Derived. */ - -export const constraintResponseMap: Readable> = derived( - [rawConstraintResponses, planStartTimeMs], - ([$constraintResponses, $planStartTimeMs]) => +export const constraintResponseMap: Readable> = + derived([rawConstraintResponses, planStartTimeMs], ([$constraintResponses, $planStartTimeMs]) => keyBy( $constraintResponses.map(response => ({ ...response, @@ -62,13 +94,13 @@ export const constraintResponseMap: Readable = derived( - [constraints, constraintResponseMap], - ([$constraints, $constraintResponseMap]) => { - return $constraints.reduce((count, prev) => { - if (!(prev.id in $constraintResponseMap)) { + [allowedConstraintSpecs, constraintResponseMap], + ([$allowedConstraintSpecs, $constraintResponseMap]) => { + return $allowedConstraintSpecs.reduce((count, prev) => { + if (!(prev.constraint_id in $constraintResponseMap)) { count++; } return count; @@ -106,7 +138,7 @@ export const visibleConstraintResults: Readable = de /* Helper Functions. */ -export function setConstraintVisibility(constraintId: Constraint['id'], visible: boolean) { +export function setConstraintVisibility(constraintId: ConstraintDefinition['constraint_id'], visible: boolean) { constraintVisibilityMapWritable.set({ ...get(constraintVisibilityMapWritable), [constraintId]: visible }); } diff --git a/src/stores/subscribable.ts b/src/stores/subscribable.ts index 37c3242f91..0bd4b68040 100644 --- a/src/stores/subscribable.ts +++ b/src/stores/subscribable.ts @@ -107,6 +107,25 @@ export function gqlSubscribable( return ''; } + /** + * Helper that parses a user cookie to get a token. + * @todo We should migrate away from doing this and just pass the + * user to the subscription during initialization. + */ + function getRoleFromCookie(): string { + if (browser && document?.cookie) { + const cookies = document.cookie.split(/\s*;\s*/); + const roleCookie = cookies.find(entry => entry.startsWith('activeRole=')); + if (roleCookie) { + return roleCookie.split('activeRole=')[1]; + } else { + console.log(`No 'role' cookie found`); + } + } + + return ''; + } + function resubscribe() { subscribers.forEach(subscriber => { subscriber.unsubscribe(); @@ -150,6 +169,7 @@ export function gqlSubscribable( connectionParams: { headers: { Authorization: `Bearer ${token}`, + 'x-hasura-role': getRoleFromCookie(), }, }, url: env.PUBLIC_HASURA_WEB_SOCKET_URL, diff --git a/src/stores/tags.ts b/src/stores/tags.ts index d40c9d5d71..0b14f793f6 100644 --- a/src/stores/tags.ts +++ b/src/stores/tags.ts @@ -7,7 +7,6 @@ import { gqlSubscribable } from './subscribable'; /* Writeable. */ export const createTagError: Writable = writable(null); - /* Subscriptions. */ export const tags = gqlSubscribable(gql.SUB_TAGS, {}, [], null); diff --git a/src/types/constraint.ts b/src/types/constraint.ts index 2f5831fb45..3e01d27e8a 100644 --- a/src/types/constraint.ts +++ b/src/types/constraint.ts @@ -1,25 +1,83 @@ -import type { UserId } from './app'; -import type { Tag } from './tags'; +import type { PartialWith, UserId } from './app'; +import type { Model } from './model'; +import type { Plan } from './plan'; +import type { ConstraintTagsInsertInput, Tag } from './tags'; import type { TimeRange } from './timeline'; -export type Constraint = { +export type ConstraintDefinition = { + author: UserId; + constraint_id: number; created_at: string; definition: string; - description: string; + metadata: ConstraintMetadata; + // models_using: Model[]; + // plans_using: Plan[]; + revision: number; + tags: { tag: Tag }[]; +}; + +export type ConstraintMetadataVersionDefinition = Pick< + ConstraintDefinition, + 'author' | 'definition' | 'revision' | 'tags' +>; + +export type ConstraintMetadata = { + created_at: string; + description?: string; id: number; - model_id: number | null; + models_using: Pick[]; name: string; owner: UserId; - plan_id: number | null; + plans_using: Pick[]; + public: boolean; tags: { tag: Tag }[]; updated_at: string; updated_by: UserId; + versions: ConstraintMetadataVersionDefinition[]; +}; + +export type ConstraintMetadataSlim = Omit; + +export type ConstraintModelSpec = { + constraint_id: number; + constraint_metadata: ConstraintMetadata | null; + constraint_revision: number; + model_id: number; + // constraint_definition: ConstraintDefinition; + // model: Model; +}; + +export type ConstraintPlanSpec = { + constraint_id: number; + constraint_metadata: Pick | null; + constraint_revision: number | null; + enabled: boolean; + plan_id: number; + // constraint_definition: ConstraintDefinition; + // plan: Plan; +}; + +export type ConstraintPlanSpecInsertInput = Omit; + +export type ConstraintDefinitionInsertInput = Pick & { + tags: { + data: ConstraintTagsInsertInput[]; + }; }; export type ConstraintInsertInput = Omit< - Constraint, + ConstraintMetadataSlim, 'id' | 'created_at' | 'updated_at' | 'owner' | 'updated_by' | 'tags' ->; +> & { + tags: { + data: ConstraintTagsInsertInput[]; + }; + versions: { + data: Omit[]; + }; +}; + +export type ConstraintMetadataSetInput = PartialWith; export type ConstraintType = 'model' | 'plan'; @@ -37,8 +95,8 @@ export type ConstraintResult = { export type ConstraintResultWithName = ConstraintResult & { constraintName: string }; export type ConstraintResponse = { - constraintId: Constraint['id']; - constraintName: Constraint['name']; + constraintId: ConstraintMetadata['id']; + constraintName: ConstraintMetadata['name']; errors: UserCodeError[]; results: ConstraintResult; success: boolean; diff --git a/src/types/model.ts b/src/types/model.ts index 75efb5cc73..6a58c09bd2 100644 --- a/src/types/model.ts +++ b/src/types/model.ts @@ -1,4 +1,5 @@ import type { UserId } from './app'; +import type { ConstraintModelSpec } from './constraint'; import type { ParametersMap } from './parameter'; export type Model = ModelSchema; @@ -6,6 +7,7 @@ export type Model = ModelSchema; export type ModelInsertInput = Pick; export type ModelSchema = { + constraint_specification: ConstraintModelSpec[]; created_at: string; description?: string; id: number; diff --git a/src/types/plan.ts b/src/types/plan.ts index 0aa4e3b723..34c1e194fc 100644 --- a/src/types/plan.ts +++ b/src/types/plan.ts @@ -1,5 +1,6 @@ import type { ActivityDirective } from './activity'; import type { UserId } from './app'; +import type { ConstraintPlanSpec } from './constraint'; import type { Model } from './model'; import type { SchedulingSpec } from './scheduling'; import type { Tag } from './tags'; @@ -72,6 +73,7 @@ export type PlanMergeResolution = 'none' | 'source' | 'target'; export type PlanSchema = { child_plans: Pick[]; collaborators: PlanCollaborator[]; + constraint_specification: ConstraintPlanSpec[]; created_at: string; duration: string; id: number; diff --git a/src/types/radio-buttons.ts b/src/types/radio-buttons.ts new file mode 100644 index 0000000000..370901d105 --- /dev/null +++ b/src/types/radio-buttons.ts @@ -0,0 +1,10 @@ +import type { Writable } from 'svelte/store'; + +export type RadioButtonId = number | string | Record; + +export interface RadioButtonContext { + registerRadioButton: (radioButtonId: RadioButtonId) => void; + selectRadioButton: (radioButtonId: RadioButtonId) => void; + selectedRadioButton: Writable; + unregisterRadioButton: (radioButtonId: RadioButtonId) => void; +} diff --git a/src/types/tags.ts b/src/types/tags.ts index 2d48f4860e..1f6284a452 100644 --- a/src/types/tags.ts +++ b/src/types/tags.ts @@ -7,7 +7,17 @@ export type ActivityDirectiveTagsInsertInput = { }; export type ConstraintTagsInsertInput = { + tag_id: number; +}; + +export type ConstraintMetadataTagsInsertInput = { + constraint_id: number; + tag_id: number; +}; + +export type ConstraintDefinitionTagsInsertInput = { constraint_id: number; + constraint_revision: number; tag_id: number; }; diff --git a/src/utilities/effects.ts b/src/utilities/effects.ts index 4b785b099b..cb173f1be9 100644 --- a/src/utilities/effects.ts +++ b/src/utilities/effects.ts @@ -40,7 +40,17 @@ import type { import type { ActivityMetadata } from '../types/activity-metadata'; import type { BaseUser, User, UserId } from '../types/app'; import type { ReqAuthResponse, ReqSessionResponse } from '../types/auth'; -import type { Constraint, ConstraintInsertInput, ConstraintResponse, ConstraintResult } from '../types/constraint'; +import type { + ConstraintDefinition, + ConstraintDefinitionInsertInput, + ConstraintInsertInput, + ConstraintMetadata, + ConstraintMetadataSetInput, + ConstraintPlanSpec, + ConstraintPlanSpecInsertInput, + ConstraintResponse, + ConstraintResult, +} from '../types/constraint'; import type { ExpansionRule, ExpansionRuleInsertInput, @@ -121,6 +131,8 @@ import type { } from '../types/simulation'; import type { ActivityDirectiveTagsInsertInput, + ConstraintDefinitionTagsInsertInput, + ConstraintMetadataTagsInsertInput, ConstraintTagsInsertInput, ExpansionRuleTagsInsertInput, PlanSnapshotTagsInsertInput, @@ -142,6 +154,7 @@ import { showCreateViewModal, showDeleteActivitiesModal, showEditViewModal, + showManagePlanConstraintsModal, showPlanBranchRequestModal, showRestorePlanSnapshotModal, showUploadViewModal, @@ -272,7 +285,7 @@ const effects = { async cancelSimulation(simulationDatasetId: number, user: User | null): Promise { try { - if (!queryPermissions.UPDATE_SIMULATION_DATASET(user)) { + if (!queryPermissions.CANCEL_PENDING_SIMULATION(user)) { throwPermissionError('update a simulation dataset'); } const { confirm } = await showConfirmModal( @@ -501,42 +514,45 @@ const effects = { }, async createConstraint( - definition: string, - model: ModelSlim | null, name: string, - plan: PlanSlim | null, + isPublic: boolean, + metadataTags: ConstraintTagsInsertInput[], + definition: string, + definitionTags: ConstraintTagsInsertInput[], user: User | null, - description: string, - plans: PlanSlim[], + description?: string, ): Promise { try { - let hasPermission = false; - if (model) { - hasPermission = model.plans.reduce((previousValue, { id }) => { - const plan = plans.find(({ id: planId }) => planId === id); - if (plan) { - return previousValue || queryPermissions.CREATE_CONSTRAINT(user, plan); - } - return previousValue; - }, true); - } else if (plan) { - hasPermission = queryPermissions.CREATE_CONSTRAINT(user, plan); - } - if (!hasPermission) { + if (!queryPermissions.CREATE_CONSTRAINT(user)) { throwPermissionError('create a constraint'); } const constraintInsertInput: ConstraintInsertInput = { - definition, - description, - model_id: plan !== null ? null : model?.id ?? null, + ...(description ? { description } : {}), name, - plan_id: plan?.id ?? null, + public: isPublic, + tags: { + data: metadataTags, + }, + versions: { + data: [ + { + definition, + tags: { + data: definitionTags, + }, + }, + ], + }, }; - const data = await reqHasura(gql.CREATE_CONSTRAINT, { constraint: constraintInsertInput }, user); - const { createConstraint } = data; - if (createConstraint != null) { - const { id } = createConstraint; + const data = await reqHasura( + gql.CREATE_CONSTRAINT, + { constraint: constraintInsertInput }, + user, + ); + const { constraint } = data; + if (constraint != null) { + const { id } = constraint; showSuccessToast('Constraint Created Successfully'); return id; @@ -550,27 +566,39 @@ const effects = { } }, - async createConstraintTags(tags: ConstraintTagsInsertInput[], user: User | null): Promise { + async createConstraintDefinition( + constraintId: number, + definition: string, + definitionTags: ConstraintTagsInsertInput[], + user: User | null, + ): Promise | null> { try { - if (!queryPermissions.CREATE_CONSTRAINT_TAGS(user)) { - throwPermissionError('create constraint tags'); + if (!queryPermissions.CREATE_CONSTRAINT_DEFINITION(user)) { + throwPermissionError('create a constraint'); } - const data = await reqHasura<{ affected_rows: number }>(gql.CREATE_CONSTRAINT_TAGS, { tags }, user); - const { insert_constraint_tags } = data; - if (insert_constraint_tags != null) { - const { affected_rows } = insert_constraint_tags; - - if (affected_rows !== tags.length) { - throw Error('Some constraint tags were not successfully created'); - } - return affected_rows; + const constraintDefinitionInsertInput: ConstraintDefinitionInsertInput = { + constraint_id: constraintId, + definition, + tags: { + data: definitionTags, + }, + }; + const data = await reqHasura( + gql.CREATE_CONSTRAINT_DEFINITION, + { constraintDefinition: constraintDefinitionInsertInput }, + user, + ); + const { constraintDefinition } = data; + if (constraintDefinition != null) { + showSuccessToast('New Constraint Revision Created Successfully'); + return constraintDefinition; } else { - throw Error('Unable to create constraint tags'); + throw Error(`Unable to create constraint definition for constraint "${constraintId}"`); } } catch (e) { - catchError('Create Constraint Tags Failed', e as Error); - showFailureToast('Create Constraint Tags Failed'); + catchError('Constraint Creation Failed', e as Error); + showFailureToast('Constraint Creation Failed'); return null; } }, @@ -1657,9 +1685,9 @@ const effects = { } }, - async deleteConstraint(constraint: Constraint, plan: PlanSlim, user: User | null): Promise { + async deleteConstraint(constraint: ConstraintMetadata, user: User | null): Promise { try { - if (!queryPermissions.DELETE_CONSTRAINT(user, plan)) { + if (!queryPermissions.DELETE_CONSTRAINT_METADATA(user, constraint)) { throwPermissionError('delete this constraint'); } @@ -1670,8 +1698,8 @@ const effects = { ); if (confirm) { - const data = await reqHasura<{ id: number }>(gql.DELETE_CONSTRAINT, { id: constraint.id }, user); - if (data.deleteConstraint != null) { + const data = await reqHasura<{ id: number }>(gql.DELETE_CONSTRAINT_METADATA, { id: constraint.id }, user); + if (data.deleteConstraintMetadata != null) { showSuccessToast('Constraint Deleted Successfully'); return true; } else { @@ -1686,13 +1714,13 @@ const effects = { return false; }, - async deleteConstraintTags(ids: Tag['id'][], user: User | null): Promise { + async deleteConstraintMetadataTags(ids: Tag['id'][], user: User | null): Promise { try { - if (!queryPermissions.DELETE_CONSTRAINT_TAGS(user)) { + if (!queryPermissions.DELETE_CONSTRAINT_METADATA_TAGS(user)) { throwPermissionError('delete constraint tags'); } - const data = await reqHasura<{ affected_rows: number }>(gql.DELETE_CONSTRAINT_TAGS, { ids }, user); + const data = await reqHasura<{ affected_rows: number }>(gql.DELETE_CONSTRAINT_METADATA_TAGS, { ids }, user); if (data.delete_constraint_tags != null) { if (data.delete_constraint_tags.affected_rows !== ids.length) { throw Error('Some constraint tags were not successfully deleted'); @@ -1708,6 +1736,32 @@ const effects = { } }, + async deleteConstraintPlanSpecifications(plan: Plan, constraintIds: number[], user: User | null): Promise { + try { + if (!queryPermissions.DELETE_CONSTRAINT_PLAN_SPECIFICATIONS(user, plan)) { + throwPermissionError('delete constraint plan specifications'); + } + + const data = await reqHasura<{ affected_rows: number }>( + gql.DELETE_CONSTRAINT_PLAN_SPECIFICATIONS, + { constraintIds, planId: plan.id }, + user, + ); + if (data.delete_constraint_specification != null) { + if (data.delete_constraint_specification.affected_rows !== constraintIds.length) { + throw Error('Some constraint plan specifications were not successfully deleted'); + } + return true; + } else { + throw Error('Unable to delete constraint plan specifications'); + } + } catch (e) { + catchError('Delete Constraint Plan Specifications Failed', e as Error); + showFailureToast('Delete Constraint Plan Specifications Failed'); + return false; + } + }, + async deleteExpansionRule(rule: ExpansionRule, user: User | null): Promise { try { if (!queryPermissions.DELETE_EXPANSION_RULE(user, rule)) { @@ -2131,11 +2185,10 @@ const effects = { async deleteTag(tag: Tag, user: User | null): Promise { try { - if (!queryPermissions.DELETE_TAGS(user, tag)) { + if (!queryPermissions.DELETE_TAG(user, tag)) { throwPermissionError('delete tags'); } - await reqHasura<{ id: number }>(gql.DELETE_TAG, { id: tag.id }, user); showSuccessToast('Tag Deleted Successfully'); return true; } catch (e) { @@ -2420,9 +2473,9 @@ const effects = { } }, - async getConstraint(id: number, user: User | null): Promise { + async getConstraint(id: number, user: User | null): Promise { try { - const data = await reqHasura(gql.GET_CONSTRAINT, { id }, user); + const data = await reqHasura(convertToQuery(gql.SUB_CONSTRAINT), { id }, user); const { constraint } = data; return constraint; } catch (e) { @@ -2796,37 +2849,6 @@ const effects = { } }, - async getPlansAndModelsForConstraints(user: User | null): Promise<{ - modelMap: Record; - models: ModelSlim[]; - planMap: Record; - plans: PlanSlim[]; - }> { - try { - const { models, plans } = await effects.getPlansAndModels(user); - const planMap: Record = plans.reduce((prevMap: Record, plan: PlanSlim) => { - return { - ...prevMap, - [plan.id]: plan, - }; - }, {}); - const modelMap: Record = models.reduce( - (prevMap: Record, model: ModelSlim) => { - return { - ...prevMap, - [model.id]: model, - }; - }, - {}, - ); - - return { modelMap, models, planMap, plans }; - } catch (e) { - catchError(e as Error); - return { modelMap: {}, models: [], planMap: {}, plans: [] }; - } - }, - async getPlansAndModelsForScheduling(user: User | null): Promise<{ models: ModelSlim[]; plans: PlanSchedulingSpec[]; @@ -3121,14 +3143,10 @@ const effects = { } }, - async getTsFilesConstraints(model_id: number, plan_id: number | null, user: User | null): Promise { + async getTsFilesConstraints(model_id: number, user: User | null): Promise { if (model_id !== null && model_id !== undefined) { try { - const data = await reqHasura( - gql.GET_TYPESCRIPT_CONSTRAINTS, - { model_id, plan_id }, - user, - ); + const data = await reqHasura(gql.GET_TYPESCRIPT_CONSTRAINTS, { model_id }, user); const { dslTypeScriptResponse } = data; if (dslTypeScriptResponse != null) { const { reason, status, typescriptFiles } = dslTypeScriptResponse; @@ -3421,6 +3439,15 @@ const effects = { } }, + async managePlanConstraints(user: User | null): Promise { + try { + await showManagePlanConstraintsModal(user); + } catch (e) { + catchError('Constraint Unable To Be Applied To Plan', e as Error); + showFailureToast('Constraint Application Failed'); + } + }, + async planMergeBegin( merge_request_id: number, sourcePlan: PlanForMerging, @@ -3897,49 +3924,128 @@ const effects = { } }, - async updateConstraint( - id: number, - definition: string, - model: ModelSlim | null, - name: string, - plan: PlanSlim | null, + async updateConstraintDefinitionTags( + constraintId: number, + constraintRevision: number, + tags: ConstraintDefinitionTagsInsertInput[], + tagIdsToDelete: number[], user: User | null, - plans: PlanSlim[], - description?: string, - ): Promise { + ): Promise { try { - let hasPermission = false; - if (model) { - hasPermission = model.plans.reduce((previousValue, { id }) => { - const plan = plans.find(({ id: planId }) => planId === id); - if (plan) { - return previousValue || queryPermissions.UPDATE_CONSTRAINT(user, plan); - } - return previousValue; - }, true); - } else if (plan) { - hasPermission = queryPermissions.UPDATE_CONSTRAINT(user, plan); + if (!queryPermissions.UPDATE_CONSTRAINT_DEFINITION_TAGS(user)) { + throwPermissionError('create constraint definition tags'); } - if (!hasPermission) { + + const data = await reqHasura<{ affected_rows: number }>( + gql.UPDATE_CONSTRAINT_DEFINITION_TAGS, + { constraintId, constraintRevision, tagIdsToDelete, tags }, + user, + ); + const { deleteConstraintDefinitionTags, insertConstraintDefinitionTags } = data; + if (insertConstraintDefinitionTags != null && deleteConstraintDefinitionTags != null) { + const { affected_rows } = insertConstraintDefinitionTags; + + showSuccessToast('Constraint Updated Successfully'); + + return affected_rows; + } else { + throw Error('Unable to create constraint definition tags'); + } + } catch (e) { + catchError('Create Constraint Definition Tags Failed', e as Error); + showFailureToast('Create Constraint Definition Tags Failed'); + return null; + } + }, + + async updateConstraintMetadata( + id: number, + constraintMetadata: ConstraintMetadataSetInput, + tags: ConstraintMetadataTagsInsertInput[], + tagIdsToDelete: number[], + currentConstraintOwner: UserId, + user: User | null, + ): Promise { + try { + if (!queryPermissions.UPDATE_CONSTRAINT_METADATA(user, { owner: currentConstraintOwner })) { throwPermissionError('update this constraint'); } - const constraint: Partial = { - definition, - model_id: plan !== null ? null : model?.id, - name, - plan_id: plan?.id ?? null, - ...(description && { description }), - }; - const data = await reqHasura(gql.UPDATE_CONSTRAINT, { constraint, id }, user); - if (data.updateConstraint != null) { - showSuccessToast('Constraint Updated Successfully'); + const data = await reqHasura( + gql.UPDATE_CONSTRAINT_METADATA, + { constraintMetadata, id, tagIdsToDelete, tags }, + user, + ); + if ( + data.updateConstraintMetadata == null || + data.insertConstraintTags == null || + data.deleteConstraintTags == null + ) { + throw Error(`Unable to update constraint metadata with ID: "${id}"`); + } + + showSuccessToast('Constraint Updated Successfully'); + return true; + } catch (e) { + catchError('Constraint Metadata Update Failed', e as Error); + showFailureToast('Constraint Metadata Update Failed'); + return false; + } + }, + + async updateConstraintPlanSpecification( + plan: Plan, + constraintPlanSpecification: Omit, + user: User | null, + ) { + try { + if (!queryPermissions.UPDATE_CONSTRAINT_PLAN_SPECIFICATIONS(user, plan)) { + throwPermissionError('update this constraint plan specification'); + } + const { enabled, constraint_id: constraintId, constraint_revision: revision } = constraintPlanSpecification; + + const { updateConstraintPlanSpecification } = await reqHasura( + gql.UPDATE_CONSTRAINT_PLAN_SPECIFICATION, + { enabled, id: constraintId, planId: plan.id, revision }, + user, + ); + + if (updateConstraintPlanSpecification !== null) { + showSuccessToast(`Constraints Updated Successfully`); + } else { + throw Error('Unable to update the constraints for the plan'); + } + } catch (e) { + catchError('Constraint Plan Specification Update Failed', e as Error); + showFailureToast('Constraint Plan Specification Update Failed'); + } + }, + + async updateConstraintPlanSpecifications( + plan: Plan, + constraintSpecsToUpdate: ConstraintPlanSpecInsertInput[], + constraintSpecIdsToDelete: number[], + user: User | null, + ) { + try { + if (!queryPermissions.UPDATE_CONSTRAINT_PLAN_SPECIFICATIONS(user, plan)) { + throwPermissionError('update this constraint plan specification'); + } + + const { deleteConstraintPlanSpecifications, updateConstraintPlanSpecifications } = await reqHasura( + gql.UPDATE_CONSTRAINT_PLAN_SPECIFICATIONS, + { constraintSpecIdsToDelete, constraintSpecsToUpdate, planId: plan.id }, + user, + ); + + if (updateConstraintPlanSpecifications !== null || deleteConstraintPlanSpecifications !== null) { + showSuccessToast(`Constraints Updated Successfully`); } else { - throw Error(`Unable to update constraint with ID: "${id}"`); + throw Error('Unable to update the constraints for the plan'); } } catch (e) { - catchError('Constraint Update Failed', e as Error); - showFailureToast('Constraint Update Failed'); + catchError('Constraint Plan Specification Update Failed', e as Error); + showFailureToast('Constraint Plan Specification Update Failed'); } }, diff --git a/src/utilities/generic.ts b/src/utilities/generic.ts index 0da48e8336..e22f38c32b 100644 --- a/src/utilities/generic.ts +++ b/src/utilities/generic.ts @@ -232,12 +232,20 @@ export function removeQueryParam(key: SearchParameters, mode: 'PUSH' | 'REPLACE' * Changes the current URL to include a query parameter given by [key]=[value]. * @note Only runs in the browser (not server-side). */ -export function setQueryParam(key: SearchParameters, value: string, mode: 'PUSH' | 'REPLACE' = 'REPLACE'): void { +export function setQueryParam( + key: SearchParameters, + value?: string | null, + mode: 'PUSH' | 'REPLACE' = 'REPLACE', +): void { const { history, location } = window; const { hash, host, pathname, protocol, search } = location; const urlSearchParams = new URLSearchParams(search); - urlSearchParams.set(key, value); + if (value !== '' && value != null) { + urlSearchParams.set(key, value); + } else { + urlSearchParams.delete(key); + } const params = urlSearchParams.toString(); const path = `${protocol}//${host}${pathname}?${params}${hash}`; diff --git a/src/utilities/gql.ts b/src/utilities/gql.ts index 65868c2f7d..47193329cf 100644 --- a/src/utilities/gql.ts +++ b/src/utilities/gql.ts @@ -30,7 +30,7 @@ const gql = { success constraintId constraintName - type + constraintRevision results { resourceIds gaps { @@ -121,23 +121,46 @@ const gql = { `, CREATE_CONSTRAINT: `#graphql - mutation CreateConstraint($constraint: constraint_insert_input!) { - createConstraint: insert_constraint_one(object: $constraint) { + mutation CreateConstraint($constraint: constraint_metadata_insert_input!) { + constraint: insert_constraint_metadata_one(object: $constraint) { id + name + description + owner + public + tags { + tag_id + } + versions { + revision + definition + tags { + tag_id + } + } } } `, - CREATE_CONSTRAINT_TAGS: `#graphql - mutation CreateConstraintTags($tags: [constraint_tags_insert_input!]!) { - insert_constraint_tags(objects: $tags, on_conflict: { - constraint: constraint_tags_pkey, - update_columns: [] - }) { - affected_rows + CREATE_CONSTRAINT_DEFINITION: `#graphql + mutation insertConstraintDefinition($constraintDefinition: constraint_definition_insert_input!) { + constraintDefinition: insert_constraint_definition_one(object: $constraintDefinition) { + constraint_id + definition + revision } } -`, + `, + + CREATE_CONSTRAINT_MODEL_SPECIFICATION: `#graphql + mutation CreateConstraintModelSpecification($constraintModelSpecification: constraint_model_specification_insert_input!) { + constraintModelSpecification: insert_constraint_model_specification_one(object: $constraintModelSpecification) { + constraint_id + constraint_revision + model_id + } + } + `, CREATE_EXPANSION_RULE: `#graphql mutation CreateExpansionRule($rule: expansion_rule_insert_input!) { @@ -440,21 +463,51 @@ const gql = { } `, - DELETE_CONSTRAINT: `#graphql + DELETE_CONSTRAINT_METADATA: `#graphql mutation DeleteConstraint($id: Int!) { - deleteConstraint: delete_constraint_by_pk(id: $id) { + deleteConstraintMetadata: delete_constraint_metadata_by_pk(id: $id) { id } } `, - DELETE_CONSTRAINT_TAGS: `#graphql - mutation DeleteConstraintTags($ids: [Int!]!) { - delete_constraint_tags(where: { tag_id: { _in: $ids } }) { + DELETE_CONSTRAINT_METADATA_TAGS: `#graphql + mutation DeleteConstraintMetadataTags($ids: [Int!]!) { + delete_constraint_tags(where: { tag_id: { _in: $ids } }) { affected_rows } } -`, + `, + + DELETE_CONSTRAINT_MODEL_SPECIFICATIONS: `#graphql + mutation DeleteConstraintModelSpecification($constraintIds: [Int!]!, $modelId: Int!) { + delete_constraint_model_specification( + where: { + constraint_id: { _in: $constraintIds }, + _and: { + model_id: { _eq: $modelId }, + } + } + ) { + affected_rows + } + } + `, + + DELETE_CONSTRAINT_PLAN_SPECIFICATIONS: `#graphql + mutation DeleteConstraintPlanSpecification($constraintIds: [Int!]!, $planId: Int!) { + delete_constraint_specification( + where: { + constraint_id: { _in: $constraintIds }, + _and: { + plan_id: { _eq: $planId }, + } + } + ) { + affected_rows + } + } + `, DELETE_EXPANSION_RULE: `#graphql mutation DeleteExpansionRule($id: Int!) { @@ -671,30 +724,6 @@ const gql = { } `, - GET_CONSTRAINT: `#graphql - query GetConstraint($id: Int!) { - constraint: constraint_by_pk(id: $id) { - created_at - definition - description - id - model_id - name - owner - plan_id - updated_at - updated_by - tags { - tag { - color - id - name - } - } - } - } - `, - GET_EFFECTIVE_ACTIVITY_ARGUMENTS: `#graphql query GetEffectiveActivityArguments($modelId: ID!, $activityTypeName: String!, $arguments: ActivityArguments!) { effectiveActivityArguments: getActivityEffectiveArguments( @@ -1238,8 +1267,8 @@ const gql = { `, GET_TYPESCRIPT_CONSTRAINTS: `#graphql - query GetTypeScriptConstraints($model_id: ID!, $plan_id: Int) { - dslTypeScriptResponse: constraintsDslTypescript(missionModelId: $model_id, planId: $plan_id) { + query GetTypeScriptConstraints($model_id: ID!) { + dslTypeScriptResponse: constraintsDslTypescript(missionModelId: $model_id) { reason status typescriptFiles { @@ -1568,24 +1597,21 @@ const gql = { } `, - SUB_CONSTRAINTS: `#graphql - subscription SubConstraints($modelId: Int!, $planId: Int!) { - constraints: constraint(where: { - _or: [ - { model_id: { _eq: $modelId } }, - { plan_id: { _eq: $planId } } - ] - }, order_by: { name: asc }) { + SUB_CONSTRAINT: `#graphql + subscription SubConstraint($id: Int!) { + constraint: constraint_metadata_by_pk(id: $id) { created_at - definition description id - model_id name + models_using { + model_id + } owner - plan_id - updated_at - updated_by + plans_using { + plan_id + } + public tags { tag { color @@ -1593,23 +1619,62 @@ const gql = { name } } + updated_at + updated_by + versions { + author + definition + revision + tags { + tag { + color + id + name + } + } + } } } `, - SUB_CONSTRAINTS_ALL: `#graphql - subscription SubConstraintsAll { - constraints: constraint(order_by: { name: asc }) { + SUB_CONSTRAINTS: `#graphql + subscription SubConstraints { + constraints: constraint_metadata(order_by: { name: asc }) { created_at - definition description id - model_id name + models_using { + model_id + } owner - plan_id + plans_using { + plan_id + } + public + tags { + tag { + color + id + name + } + } updated_at updated_by + versions { + author + definition + revision + } + } + } + `, + + SUB_CONSTRAINT_DEFINITION: `#graphql + subscription SubConstraintDefinition($id: Int!, $revision: Int!) { + constraintDefinition: constraint_definition_by_pk(constraint_id: $id, revision: $revision) { + definition + revision tags { tag { color @@ -1621,6 +1686,28 @@ const gql = { } `, + SUB_CONSTRAINT_PLAN_SPECIFICATIONS: `#graphql + subscription SubConstraintPlanSpecifications($planId: Int!) { + constraintPlanSpecs: constraint_specification( + where: {plan_id: {_eq: $planId}}, + order_by: { constraint_id: desc } + ) { + constraint_id + constraint_revision + enabled + constraint_metadata { + name + owner + public + versions { + revision + } + } + plan_id + } + } + `, + SUB_EXPANSION_RULES: `#graphql subscription SubExpansionRules { expansionRules: expansion_rule(order_by: { id: desc }) { @@ -2234,13 +2321,86 @@ const gql = { } `, - UPDATE_CONSTRAINT: `#graphql - mutation UpdateConstraint($id: Int!, $constraint: constraint_set_input!) { - updateConstraint: update_constraint_by_pk( - pk_columns: { id: $id }, _set: $constraint + UPDATE_CONSTRAINT_DEFINITION_TAGS: `#graphql + mutation UpdateConstraintTags($constraintId: Int!, $constraintRevision: Int!, $tags: [constraint_definition_tags_insert_input!]!, $tagIdsToDelete: [Int!]!) { + insertConstraintDefinitionTags: insert_constraint_definition_tags(objects: $tags, on_conflict: { + constraint: constraint_definition_tags_pkey, + update_columns: [] + }) { + affected_rows + } + deleteConstraintDefinitionTags: delete_constraint_definition_tags( + where: { + tag_id: { _in: $tagIdsToDelete }, + _and: { + constraint_id: { _eq: $constraintId }, + constraint_revision: { _eq: $constraintRevision } + } + } + ) { + affected_rows + } + } + `, + + UPDATE_CONSTRAINT_METADATA: `#graphql + mutation UpdateConstraintMetadata($id: Int!, $constraintMetadata: constraint_metadata_set_input!, $tags: [constraint_tags_insert_input!]!, $tagIdsToDelete: [Int!]!) { + updateConstraintMetadata: update_constraint_metadata_by_pk( + pk_columns: { id: $id }, _set: $constraintMetadata ) { id } + insertConstraintTags: insert_constraint_tags(objects: $tags, on_conflict: { + constraint: constraint_tags_pkey, + update_columns: [] + }) { + affected_rows + } + deleteConstraintTags: delete_constraint_tags(where: { tag_id: { _in: $tagIdsToDelete } }) { + affected_rows + } + } + `, + + UPDATE_CONSTRAINT_PLAN_SPECIFICATION: `#graphql + mutation UpdateConstraintPlanSpecification($id: Int!, $revision: Int!, $enabled: Boolean!, $planId: Int!) { + updateConstraintPlanSpecification: update_constraint_specification_by_pk( + pk_columns: { constraint_id: $id, plan_id: $planId }, + _set: { + constraint_revision: $revision, + enabled: $enabled + } + ) { + constraint_revision + enabled + } + } + `, + + UPDATE_CONSTRAINT_PLAN_SPECIFICATIONS: `#graphql + mutation UpdateConstraintPlanSpecifications($constraintSpecsToUpdate: [constraint_specification_insert_input!]!, $constraintSpecIdsToDelete: [Int!]! = [], $planId: Int!) { + updateConstraintPlanSpecifications: insert_constraint_specification( + objects: $constraintSpecsToUpdate, + on_conflict: { + constraint: constraint_specification_pkey, + update_columns: [constraint_revision, enabled] + }, + ) { + returning { + constraint_revision + enabled + } + } + deleteConstraintPlanSpecifications: delete_constraint_specification( + where: { + constraint_id: { _in: $constraintSpecIdsToDelete }, + _and: { + plan_id: { _eq: $planId }, + } + } + ) { + affected_rows + } } `, @@ -2419,7 +2579,7 @@ const gql = { } } `, -}; +} as const; export function convertToGQLArray(array: string[] | number[]) { return `{${array.join(',')}}`; diff --git a/src/utilities/modal.ts b/src/utilities/modal.ts index d296b2c360..310ed0a088 100644 --- a/src/utilities/modal.ts +++ b/src/utilities/modal.ts @@ -7,9 +7,10 @@ import CreateViewModal from '../components/modals/CreateViewModal.svelte'; import DeleteActivitiesModal from '../components/modals/DeleteActivitiesModal.svelte'; import EditViewModal from '../components/modals/EditViewModal.svelte'; import ExpansionSequenceModal from '../components/modals/ExpansionSequenceModal.svelte'; +import ManagePlanConstraintsModal from '../components/modals/ManagePlanConstraintsModal.svelte'; import MergeReviewEndedModal from '../components/modals/MergeReviewEndedModal.svelte'; -import PlanBranchesModal from '../components/modals/PlanBranchesModal.svelte'; import PlanBranchRequestModal from '../components/modals/PlanBranchRequestModal.svelte'; +import PlanBranchesModal from '../components/modals/PlanBranchesModal.svelte'; import PlanLockedModal from '../components/modals/PlanLockedModal.svelte'; import PlanMergeRequestsModal from '../components/modals/PlanMergeRequestsModal.svelte'; import RestorePlanSnapshotModal from '../components/modals/RestorePlanSnapshotModal.svelte'; @@ -138,6 +139,44 @@ export async function showConfirmModal( }); } +/** + * Shows an ManagePlanConstraintsModal component with the supplied arguments. + */ +export async function showManagePlanConstraintsModal(user: User | null): Promise { + return new Promise(resolve => { + if (browser) { + const target: ModalElement | null = document.querySelector('#svelte-modal'); + + if (target) { + const managePlanConstraintsModal = new ManagePlanConstraintsModal({ + props: { user }, + target, + }); + target.resolve = resolve; + + managePlanConstraintsModal.$on('close', () => { + target.replaceChildren(); + target.resolve = null; + target.removeAttribute('data-dismissible'); + managePlanConstraintsModal.$destroy(); + }); + + managePlanConstraintsModal.$on( + 'add', + (e: CustomEvent<{ constraindId: number; constraintRevision: number }[]>) => { + target.replaceChildren(); + target.resolve = null; + resolve({ confirm: true, value: e.detail }); + managePlanConstraintsModal.$destroy(); + }, + ); + } + } else { + resolve({ confirm: false }); + } + }); +} + /** * Shows a PlanLockedModal component with the supplied arguments. */ diff --git a/src/utilities/permissions.ts b/src/utilities/permissions.ts index 621fd52376..30a2ab01d4 100644 --- a/src/utilities/permissions.ts +++ b/src/utilities/permissions.ts @@ -2,7 +2,7 @@ import { base } from '$app/paths'; import type { ActivityDirective, ActivityPreset } from '../types/activity'; import type { User, UserRole } from '../types/app'; import type { ReqAuthResponse } from '../types/auth'; -import type { Constraint } from '../types/constraint'; +import type { ConstraintMetadata } from '../types/constraint'; import type { ExpansionRule, ExpansionSequence, ExpansionSet } from '../types/expansion'; import type { Model } from '../types/model'; import type { @@ -292,6 +292,9 @@ const queryPermissions = { isUserAdmin(user) || (getPermission(queries, user) && getRolePlanPermission(queries, user, plan, model, preset)) ); }, + CANCEL_PENDING_SIMULATION: (user: User | null): boolean => { + return isUserAdmin(user) || getPermission(['update_simulation_dataset_by_pk'], user); + }, CHECK_CONSTRAINTS: (user: User | null, plan: PlanWithOwners, model: ModelWithOwner): boolean => { const queries = ['constraintViolations']; return isUserAdmin(user) || (getPermission(queries, user) && getRolePlanPermission(queries, user, plan, model)); @@ -311,15 +314,27 @@ const queryPermissions = { CREATE_COMMAND_DICTIONARY: (user: User | null): boolean => { return isUserAdmin(user) || getPermission(['uploadDictionary'], user); }, - CREATE_CONSTRAINT: (user: User | null, plan: PlanWithOwners): boolean => { + CREATE_CONSTRAINT: (user: User | null): boolean => { + return isUserAdmin(user) || getPermission(['insert_constraint_metadata_one'], user); + }, + CREATE_CONSTRAINT_DEFINITION: (user: User | null): boolean => { + return isUserAdmin(user) || getPermission(['insert_constraint_definition_one'], user); + }, + CREATE_CONSTRAINT_MODEL_SPECIFICATION: (user: User | null): boolean => { + return isUserAdmin(user) || getPermission(['insert_constraint_model_specification_one'], user); + }, + CREATE_CONSTRAINT_PLAN_SPECIFICATION: ( + user: User | null, + constraint: ConstraintMetadata, + plan: PlanWithOwners, + ): boolean => { return ( isUserAdmin(user) || - (getPermission(['insert_constraint_one'], user) && (isPlanOwner(user, plan) || isPlanCollaborator(user, plan))) + (getPermission(['insert_constraint_specification_one'], user) && + (constraint.public || isUserOwner(user, constraint)) && + (isPlanOwner(user, plan) || isPlanCollaborator(user, plan))) ); }, - CREATE_CONSTRAINT_TAGS: (user: User | null): boolean => { - return isUserAdmin(user) || getPermission(['insert_constraint_tags'], user); - }, CREATE_EXPANSION_RULE: (user: User | null): boolean => { return isUserAdmin(user) || getPermission(['insert_expansion_rule_one'], user); }, @@ -390,6 +405,9 @@ const queryPermissions = { CREATE_SIMULATION_TEMPLATE: (user: User | null): boolean => { return isUserAdmin(user) || getPermission(['insert_simulation_template_one'], user); }, + CREATE_TAG: (user: User | null): boolean => { + return isUserAdmin(user) || getPermission(['insert_tags_one'], user); + }, CREATE_TAGS: (user: User | null): boolean => { return isUserAdmin(user) || getPermission(['insert_tags'], user); }, @@ -435,15 +453,25 @@ const queryPermissions = { DELETE_COMMAND_DICTIONARY: (user: User | null): boolean => { return isUserAdmin(user) || getPermission(['delete_command_dictionary_by_pk'], user); }, - DELETE_CONSTRAINT: (user: User | null, plan: PlanWithOwners): boolean => { + DELETE_CONSTRAINT_METADATA: (user: User | null, constraintMetadata: AssetWithOwner): boolean => { return ( isUserAdmin(user) || - (getPermission(['delete_constraint_by_pk'], user) && (isPlanOwner(user, plan) || isPlanCollaborator(user, plan))) + (getPermission(['delete_constraint_metadata_by_pk'], user) && isUserOwner(user, constraintMetadata)) ); }, - DELETE_CONSTRAINT_TAGS: (user: User | null): boolean => { + DELETE_CONSTRAINT_METADATA_TAGS: (user: User | null): boolean => { return isUserAdmin(user) || getPermission(['delete_constraint_tags'], user); }, + DELETE_CONSTRAINT_MODEL_SPECIFICATION: (user: User | null): boolean => { + return isUserAdmin(user) || getPermission(['delete_constraint_model_specification'], user); + }, + DELETE_CONSTRAINT_PLAN_SPECIFICATIONS: (user: User | null, plan: PlanWithOwners): boolean => { + return ( + isUserAdmin(user) || + (getPermission(['delete_constraint_specification'], user) && + (isPlanOwner(user, plan) || isPlanCollaborator(user, plan))) + ); + }, DELETE_EXPANSION_RULE: (user: User | null, expansionRule: AssetWithOwner): boolean => { return ( isUserAdmin(user) || (getPermission(['delete_expansion_rule_by_pk'], user) && isUserOwner(user, expansionRule)) @@ -522,7 +550,7 @@ const queryPermissions = { isUserAdmin(user) || (getPermission(['delete_simulation_template_by_pk'], user) && isUserOwner(user, template)) ); }, - DELETE_TAGS: (user: User | null, tag: Tag): boolean => { + DELETE_TAG: (user: User | null, tag: Tag): boolean => { return isUserAdmin(user) || (getPermission(['delete_tags_by_pk'], user) && isUserOwner(user, tag)); }, DELETE_USER_SEQUENCE: (user: User | null, sequence: AssetWithOwner): boolean => { @@ -554,8 +582,8 @@ const queryPermissions = { GET_PLANS_AND_MODELS: (user: User | null): boolean => { return isUserAdmin(user) || getPermission(['mission_model'], user); }, - GET_PLAN_SNAPSHOT: (user: User | null): boolean => { - return isUserAdmin(user) || getPermission(['plan_snapshot_by_pk'], user); + GET_PLAN_SNAPSHOT_ACTIVITY_DIRECTIVES: (user: User | null): boolean => { + return isUserAdmin(user) || getPermission(['plan_snapshot_activities'], user); }, INITIAL_SIMULATION_UPDATE: (user: User | null): boolean => { return isUserAdmin(user) || getPermission(['update_simulation'], user); @@ -669,8 +697,8 @@ const queryPermissions = { SUB_ACTIVITY_PRESETS: (user: User | null): boolean => { return isUserAdmin(user) || getPermission(['activity_presets'], user); }, - SUB_CONSTRAINTS_ALL: (user: User | null): boolean => { - return isUserAdmin(user) || getPermission(['constraint'], user); + SUB_CONSTRAINTS: (user: User | null): boolean => { + return isUserAdmin(user) || getPermission(['constraint_metadata'], user); }, SUB_EXPANSION_RULES: (user: User | null): boolean => { return isUserAdmin(user) || getPermission(['expansion_rule'], user); @@ -681,9 +709,6 @@ const queryPermissions = { SUB_PLAN_SNAPSHOTS: (user: User | null): boolean => { return isUserAdmin(user) || getPermission(['plan_snapshot'], user); }, - SUB_PLAN_SNAPSHOT_ACTIVITY_DIRECTIVES: (user: User | null): boolean => { - return isUserAdmin(user) || getPermission(['plan_snapshot_activities'], user); - }, SUB_SIMULATION: (user: User | null): boolean => { return isUserAdmin(user) || getPermission(['simulation'], user); }, @@ -709,10 +734,24 @@ const queryPermissions = { UPDATE_ACTIVITY_PRESET: (user: User | null, preset: AssetWithOwner): boolean => { return isUserAdmin(user) || (getPermission(['update_activity_presets_by_pk'], user) && isUserOwner(user, preset)); }, - UPDATE_CONSTRAINT: (user: User | null, plan: PlanWithOwners): boolean => { + UPDATE_CONSTRAINT_DEFINITION_TAGS: (user: User | null): boolean => { + return ( + isUserAdmin(user) || + getPermission(['insert_constraint_definition_tags', 'delete_constraint_definition_tags'], user) + ); + }, + UPDATE_CONSTRAINT_METADATA: (user: User | null, constraintMetadata: AssetWithOwner): boolean => { + return ( + isUserAdmin(user) || + (getPermission(['update_constraint_metadata_by_pk', 'insert_constraint_tags', 'delete_constraint_tags'], user) && + (constraintMetadata?.public || isUserOwner(user, constraintMetadata))) + ); + }, + UPDATE_CONSTRAINT_PLAN_SPECIFICATIONS: (user: User | null, plan: PlanWithOwners): boolean => { return ( isUserAdmin(user) || - (getPermission(['update_constraint_by_pk'], user) && (isPlanOwner(user, plan) || isPlanCollaborator(user, plan))) + (getPermission(['insert_constraint_specification', 'delete_constraint_specification'], user) && + (isPlanOwner(user, plan) || isPlanCollaborator(user, plan))) ); }, UPDATE_EXPANSION_RULE: (user: User | null, expansionRule: AssetWithOwner): boolean => { @@ -720,9 +759,6 @@ const queryPermissions = { isUserAdmin(user) || (getPermission(['update_expansion_rule_by_pk'], user) && isUserOwner(user, expansionRule)) ); }, - UPDATE_PLAN: (user: User | null, plan: PlanWithOwners): boolean => { - return isUserAdmin(user) || (getPermission(['update_plan_by_pk'], user) && isPlanOwner(user, plan)); - }, UPDATE_PLAN_SNAPSHOT: (user: User | null): boolean => { return getPermission(['update_plan_snapshot_by_pk'], user); }, @@ -759,6 +795,9 @@ const queryPermissions = { (isPlanOwner(user, plan) || isPlanCollaborator(user, plan))) ); }, + UPDATE_SCHEDULING_SPEC_CONDITION: (user: User | null): boolean => { + return isUserAdmin(user) || getPermission(['update_scheduling_specification_conditions_by_pk'], user); + }, UPDATE_SCHEDULING_SPEC_CONDITION_ID: (user: User | null): boolean => { return isUserAdmin(user) || getPermission(['update_scheduling_specification_conditions_by_pk'], user); }, @@ -775,9 +814,6 @@ const queryPermissions = { (getPermission(['update_simulation_by_pk'], user) && (isPlanOwner(user, plan) || isPlanCollaborator(user, plan))) ); }, - UPDATE_SIMULATION_DATASET: (user: User | null): boolean => { - return isUserAdmin(user) || getPermission(['update_simulation_dataset_by_pk'], user); - }, UPDATE_SIMULATION_TEMPLATE: (user: User | null, plan: PlanWithOwners): boolean => { return isUserAdmin(user) || (getPermission(['update_simulation_template_by_pk'], user) && isUserOwner(user, plan)); }, @@ -868,8 +904,12 @@ interface PlanSnapshotCRUDPermission extends Omit extends PlanAssetCRUDPermission { +interface ConstraintPlanSpecCRUDPermission { canCheck: RolePlanPermissionCheck; + canCreate: (user: User | null, plan: PlanWithOwners) => boolean; + canDelete: (user: User | null, plan: PlanWithOwners) => boolean; + canRead: (user: User | null) => boolean; + canUpdate: (user: User | null, plan: PlanWithOwners) => boolean; } interface ExpansionSetsCRUDPermission extends Omit, 'canCreate'> { @@ -897,7 +937,8 @@ interface FeaturePermissions { activityDirective: PlanAssetCRUDPermission; activityPresets: PlanActivityPresetsCRUDPermission; commandDictionary: CRUDPermission; - constraints: ConstraintCRUDPermission>; + constraintPlanSpec: ConstraintPlanSpecCRUDPermission; + constraints: CRUDPermission>; expansionRules: CRUDPermission; expansionSequences: ExpansionSequenceCRUDPermission>; expansionSets: ExpansionSetsCRUDPermission>; @@ -935,12 +976,18 @@ const featurePermissions: FeaturePermissions = { canRead: () => false, // Not implemented canUpdate: () => false, // Not implemented }, - constraints: { + constraintPlanSpec: { canCheck: (user, plan, model) => queryPermissions.CHECK_CONSTRAINTS(user, plan, model), - canCreate: (user, plan) => queryPermissions.CREATE_CONSTRAINT(user, plan), - canDelete: (user, plan) => queryPermissions.DELETE_CONSTRAINT(user, plan), - canRead: user => queryPermissions.SUB_CONSTRAINTS_ALL(user), - canUpdate: (user, plan) => queryPermissions.UPDATE_CONSTRAINT(user, plan), + canCreate: (user, plan) => queryPermissions.UPDATE_CONSTRAINT_PLAN_SPECIFICATIONS(user, plan), + canDelete: (user, plan) => queryPermissions.DELETE_CONSTRAINT_PLAN_SPECIFICATIONS(user, plan), + canRead: user => queryPermissions.SUB_CONSTRAINTS(user), + canUpdate: (user, plan) => queryPermissions.UPDATE_CONSTRAINT_PLAN_SPECIFICATIONS(user, plan), + }, + constraints: { + canCreate: user => queryPermissions.CREATE_CONSTRAINT(user), + canDelete: (user, constraintMetadata) => queryPermissions.DELETE_CONSTRAINT_METADATA(user, constraintMetadata), + canRead: user => queryPermissions.SUB_CONSTRAINTS(user), + canUpdate: (user, constraintMetadata) => queryPermissions.UPDATE_CONSTRAINT_METADATA(user, constraintMetadata), }, expansionRules: { canCreate: user => queryPermissions.CREATE_EXPANSION_RULE(user), @@ -971,7 +1018,7 @@ const featurePermissions: FeaturePermissions = { canCreate: user => queryPermissions.CREATE_PLAN(user), canDelete: (user, plan) => queryPermissions.DELETE_PLAN(user, plan), canRead: user => queryPermissions.GET_PLAN(user), - canUpdate: (user, plan) => queryPermissions.UPDATE_PLAN(user, plan), + canUpdate: () => false, // no feature to update plan exists }, planBranch: { canCreateBranch: (user, plan, model) => queryPermissions.DUPLICATE_PLAN(user, plan, model), @@ -990,7 +1037,8 @@ const featurePermissions: FeaturePermissions = { planSnapshot: { canCreate: (user, plan, model) => queryPermissions.CREATE_PLAN_SNAPSHOT(user, plan, model), canDelete: user => queryPermissions.DELETE_PLAN_SNAPSHOT(user), - canRead: user => queryPermissions.GET_PLAN_SNAPSHOT(user) && queryPermissions.SUB_PLAN_SNAPSHOTS(user), + canRead: user => + queryPermissions.GET_PLAN_SNAPSHOT_ACTIVITY_DIRECTIVES(user) && queryPermissions.SUB_PLAN_SNAPSHOTS(user), canRestore: (user, plan, model) => queryPermissions.RESTORE_PLAN_SNAPSHOT(user, plan, model), canUpdate: () => false, // no feature to update snapshots exists, }, @@ -1033,7 +1081,7 @@ const featurePermissions: FeaturePermissions = { }, tags: { canCreate: user => queryPermissions.CREATE_TAGS(user), - canDelete: (user, tag) => queryPermissions.DELETE_TAGS(user, tag), + canDelete: (user, tag) => queryPermissions.DELETE_TAG(user, tag), canRead: user => queryPermissions.SUB_TAGS(user), canUpdate: (user, tag) => queryPermissions.UPDATE_TAG(user, tag), }, diff --git a/src/workers/customTS.worker.ts b/src/workers/customTS.worker.ts index 58c8d7b905..227360dc8a 100644 --- a/src/workers/customTS.worker.ts +++ b/src/workers/customTS.worker.ts @@ -70,7 +70,8 @@ self.customTSWorkerFactory = tsw => { async getSemanticDiagnostics(fileName: string): Promise { const diagnostics = await super.getSemanticDiagnostics(fileName); const model_id = getModelId(fileName); - const model_config = model_id !== null ? this.model_configurations?.[model_id] : null; + const model_config: ModelData | null = + model_id !== null && this.model_configurations[model_id] ? this.model_configurations[model_id] : null; if (model_config !== null && model_config.should_inject === true) { diagnostics.push(...generateTimeDiagnostics(fileName, this._languageService));