diff --git a/CHANGELOG.md b/CHANGELOG.md index 1041848a0..b8e75d9fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,4 @@ +- For released and deprecated trafos, the wiring dialog now has a button to reset the wiring to the release wiring. - extend API documentation page to include maintenance endpoints and Blob storage adapter ## 0.14.6 diff --git a/frontend/playwright/test-cases/components/components-reset-to-release-wiring.spec.ts b/frontend/playwright/test-cases/components/components-reset-to-release-wiring.spec.ts new file mode 100644 index 000000000..3c121036b --- /dev/null +++ b/frontend/playwright/test-cases/components/components-reset-to-release-wiring.spec.ts @@ -0,0 +1,149 @@ +import { BackendApi } from '../fixtures/backend-api'; +import { expect, test } from '../fixtures/fixture'; + +const componentCategory = 'Test'; +const componentTag = '0.1.0'; +const inputName = 'input'; +const outputName = 'output'; +const releaseValue = 'release value'; +const changedValue = 'changed value'; + +const wiringWithValue = (value: string) => ({ + input_wirings: [ + { + workflow_input_name: inputName, + adapter_id: 'direct_provisioning', + filters: { value } + } + ], + output_wirings: [] as unknown[] +}); + +// Releasing stores the test wiring as release wiring - releasing has its own +// test, so it happens through the rest api here. +const createReleasedComponent = async ( + backendApi: BackendApi, + componentName: string +): Promise => { + const componentId = await backendApi.createComponent({ + name: componentName, + category: componentCategory, + description: 'Resets to its release wiring', + versionTag: componentTag, + inputs: [{ name: inputName, dataType: 'STRING' }], + outputs: [{ name: outputName, dataType: 'STRING' }], + functionBody: `return {"${outputName}": ${inputName}}`, + documentation: '', + testWiring: wiringWithValue(releaseValue) + }); + await backendApi.releaseComponent(componentId); + return componentId; +}; + +// The test wiring of a released component may still change, so the wiring +// dialog offers to go back to the release wiring. +test('Reset the test wiring of a released component to its release wiring', async ({ + page, + hetidaDesigner, + backendApi, + browserName +}, testInfo) => { + // Arrange + const componentName = `Test reset to release wiring ${browserName} ${testInfo.retry}`; + const componentId = await createReleasedComponent(backendApi, componentName); + await backendApi.updateTestWiring(componentId, wiringWithValue(changedValue)); + + // The navigation is filled on page load, so it has to be told about the + // component that was created through the api afterwards. + await page.reload({ waitUntil: 'domcontentloaded' }); + + await hetidaDesigner.clickComponentsInNavigation(); + await hetidaDesigner.clickCategoryInNavigation(componentCategory); + await hetidaDesigner.doubleClickItemInNavigation( + `${componentName}(${componentTag})` + ); + await hetidaDesigner.openExecuteDialog( + `Execute Component ${componentName} ${componentTag}` + ); + + const valueInput = page.getByTestId(`${inputName}-value-input-wiring-dialog`); + await expect(valueInput).toHaveValue(changedValue); + + // Act + await hetidaDesigner.clickByTestId('reset-to-release-wiring-wiring-dialog'); + + // Assert + await expect(valueInput).toHaveValue(releaseValue); + + // Only confirming the dialog stores the wiring as test wiring. + await hetidaDesigner.clickByTestId('execute-wiring-dialog'); + await expect + .poll( + async () => (await backendApi.getTransformation(componentId)).test_wiring, + { timeout: 15000 } + ) + .toMatchObject(wiringWithValue(releaseValue)); + await expect( + page.locator('hd-protocol-viewer >> .protocol-content') + ).toContainText(releaseValue); +}); + +// The wiring dialog of a schedule offers the same reset, to the release wiring +// of the scheduled component. +test('Reset the wiring of a schedule to the release wiring of its component', async ({ + page, + hetidaDesigner, + backendApi, + browserName +}, testInfo) => { + // Arrange + const componentName = `Test reset schedule to release wiring ${browserName} ${testInfo.retry}`; + const componentId = await createReleasedComponent(backendApi, componentName); + const scheduleId = await backendApi.createSchedule( + componentName, + componentId, + wiringWithValue(changedValue) + ); + + // Schedules and the navigation are loaded on page load. + await page.reload({ waitUntil: 'domcontentloaded' }); + + await hetidaDesigner.clickTabInNavigation(1); + await page + .locator('.scheduling-row', { hasText: componentName }) + .getByRole('button', { name: 'Edit Wiring' }) + .click(); + await expect( + page.locator( + `mat-dialog-container:has-text("Change Wiring — ${componentName} ${componentTag}")` + ) + ).toBeVisible(); + + const valueInput = page.getByTestId(`${inputName}-value-input-wiring-dialog`); + await expect(valueInput).toHaveValue(changedValue); + + // Act + await hetidaDesigner.clickByTestId('reset-to-release-wiring-wiring-dialog'); + + // Assert + await expect(valueInput).toHaveValue(releaseValue); + + // Only confirming the dialog stores the wiring of the schedule. + await hetidaDesigner.clickByTestId('save wiring-wiring-dialog'); + await expect + .poll(async () => (await backendApi.getSchedule(scheduleId)).wiring, { + timeout: 15000 + }) + .toMatchObject(wiringWithValue(releaseValue)); +}); + +// Cleanup goes through the rest api, see components-release.spec.ts. +test.afterEach(async ({ backendApi, browserName }, testInfo) => { + for (const name of [ + `Test reset to release wiring ${browserName} ${testInfo.retry}`, + `Test reset schedule to release wiring ${browserName} ${testInfo.retry}` + ]) { + await backendApi.deleteSchedulesByName(name); + await backendApi.deleteTransformationsByName(name); + } +}); diff --git a/frontend/playwright/test-cases/components/components-schedule-wiring-dialog-cancel.spec.ts b/frontend/playwright/test-cases/components/components-schedule-wiring-dialog-cancel.spec.ts new file mode 100644 index 000000000..52598e043 --- /dev/null +++ b/frontend/playwright/test-cases/components/components-schedule-wiring-dialog-cancel.spec.ts @@ -0,0 +1,63 @@ +import { expect, test } from '../fixtures/fixture'; + +// All wiring dialogs share one config, and the one of a schedule changes the +// text of its confirm button. Cancelling it used to leave that text behind, so +// that every execute dialog afterwards offered "Save Wiring" instead. +test('Cancelling the wiring dialog of a schedule leaves the execute dialog unchanged', async ({ + page, + hetidaDesigner, + backendApi, + browserName +}, testInfo) => { + // Arrange + const componentCategory = 'Test'; + const componentName = `Test cancel schedule wiring ${browserName} ${testInfo.retry}`; + const componentTag = '0.1.0'; + + const componentId = await backendApi.createComponent({ + name: componentName, + category: componentCategory, + description: 'Is scheduled', + versionTag: componentTag, + inputs: [], + // A component without any io is incomplete and cannot be executed. + outputs: [{ name: 'output', dataType: 'INT' }], + functionBody: 'return {"output": 1}', + documentation: '' + }); + await backendApi.createSchedule(componentName, componentId, { + input_wirings: [], + output_wirings: [] + }); + + // Schedules and the navigation are loaded on page load. + await page.reload({ waitUntil: 'domcontentloaded' }); + + // Act + await hetidaDesigner.clickTabInNavigation(1); + await page + .locator('.scheduling-row', { hasText: componentName }) + .getByRole('button', { name: 'Edit Wiring' }) + .click(); + await hetidaDesigner.clickByTestId('cancel-wiring-dialog'); + await expect(page.locator('mat-dialog-container')).toHaveCount(0); + + await hetidaDesigner.clickComponentsInNavigation(); + await hetidaDesigner.clickCategoryInNavigation(componentCategory); + await hetidaDesigner.doubleClickItemInNavigation( + `${componentName}(${componentTag})` + ); + await hetidaDesigner.openExecuteDialog( + `Execute Component ${componentName} ${componentTag}` + ); + + // Assert + await expect(page.getByTestId('execute-wiring-dialog')).toHaveText('Execute'); +}); + +// Cleanup goes through the rest api, see components-release.spec.ts. +test.afterEach(async ({ backendApi, browserName }, testInfo) => { + const name = `Test cancel schedule wiring ${browserName} ${testInfo.retry}`; + await backendApi.deleteSchedulesByName(name); + await backendApi.deleteTransformationsByName(name); +}); diff --git a/frontend/playwright/test-cases/fixtures/backend-api.ts b/frontend/playwright/test-cases/fixtures/backend-api.ts index d3a33ef67..887613560 100644 --- a/frontend/playwright/test-cases/fixtures/backend-api.ts +++ b/frontend/playwright/test-cases/fixtures/backend-api.ts @@ -39,6 +39,17 @@ interface TransformationStub { interface Transformation extends TransformationStub { documentation: string; content: string; + test_wiring: unknown; + release_wiring: unknown; +} + +interface Schedule { + id: string; + name: string; + active: boolean; + cron_expression: string; + transformation_id: string | null; + wiring: unknown; } const withTrailingSlash = (url: string): string => @@ -205,6 +216,27 @@ export class BackendApi { } } + /** + * Replaces the test wiring, which unlike most of a transformation may still + * change once it is released or deprecated. + */ + public async updateTestWiring( + id: string, + testWiring: unknown + ): Promise { + const transformation = await this.getTransformation(id); + const response = await this.request.put( + `${this.apiUrl}/transformations/${id}`, + { data: { ...transformation, test_wiring: testWiring } } + ); + + if (!response.ok()) { + throw new Error( + `Could not update the test wiring of ${id}: ${response.status()} ${await response.text()}` + ); + } + } + public async getTransformation(id: string): Promise { const response = await this.request.get( `${this.apiUrl}/transformations/${id}` @@ -258,4 +290,79 @@ export class BackendApi { return (await response.json()) as TransformationStub[]; } + + /** + * Creates an inactive schedule, so that it never runs during a test. + */ + public async createSchedule( + name: string, + transformationId: string, + wiring: unknown + ): Promise { + const id = randomUUID(); + const response = await this.request.post(`${this.apiUrl}/schedules`, { + data: { + id, + name, + active: false, + cron_expression: '*/5 * * * *', + transformation_id: transformationId, + wiring + } + }); + + if (!response.ok()) { + throw new Error( + `Could not create schedule ${name}: ${response.status()} ${await response.text()}` + ); + } + + return id; + } + + public async getSchedule(id: string): Promise { + const schedule = (await this.getSchedules()).find( + candidate => candidate.id === id + ); + if (schedule === undefined) { + throw new Error(`Could not find schedule ${id}`); + } + + return schedule; + } + + /** + * Removes every schedule with exactly this name. Does nothing when there is + * none, so it is safe to call from an afterEach hook. + */ + public async deleteSchedulesByName(name: string): Promise { + const schedules = (await this.getSchedules()).filter( + schedule => schedule.name === name + ); + for (const schedule of schedules) { + const response = await this.request.delete( + `${this.apiUrl}/schedules/${schedule.id}` + ); + + if (!response.ok()) { + throw new Error( + `Could not delete schedule ${schedule.name}, id ${schedule.id}: ` + + `${response.status()} ${response.statusText()}` + ); + } + } + } + + // The backend has no endpoint for a single schedule. + private async getSchedules(): Promise { + const response = await this.request.get(`${this.apiUrl}/schedules`); + + if (!response.ok()) { + throw new Error( + `Could not read schedules: ${response.status()} ${response.statusText()}` + ); + } + + return (await response.json()) as Schedule[]; + } } diff --git a/frontend/src/app/components/scheduling-tab/scheduling-tab.component.ts b/frontend/src/app/components/scheduling-tab/scheduling-tab.component.ts index 5bd9c7af8..d01f541cd 100644 --- a/frontend/src/app/components/scheduling-tab/scheduling-tab.component.ts +++ b/frontend/src/app/components/scheduling-tab/scheduling-tab.component.ts @@ -9,7 +9,14 @@ import { } from '@angular/core'; import { Store } from '@ngrx/store'; import { Observable, of, combineLatest, Subject, EMPTY } from 'rxjs'; -import { tap, finalize, switchMap, first, map } from 'rxjs/operators'; +import { + tap, + finalize, + switchMap, + first, + map, + takeUntil +} from 'rxjs/operators'; import { MatDialog } from '@angular/material/dialog'; import { ExecutionDialogData, WiringDialogComponent } from 'hd-wiring'; @@ -145,9 +152,16 @@ export class SchedulingTabComponent implements OnInit { if (schedule.transformation_id) { combineLatest([ this.transformationHttpService.getAdapterList(), - this.transformationStore - .select(selectTransformationById(schedule.transformation_id)) - .pipe(first()) + this.getScheduleTrafo(schedule).pipe( + // The store mostly holds stubs, which lack the release wiring. + switchMap(transformation => + transformation + ? this.transformationService.ensureFullTransformation( + transformation + ) + : of(transformation) + ) + ) ]) .pipe( switchMap(([adapterList, transformation]) => { @@ -168,6 +182,7 @@ export class SchedulingTabComponent implements OnInit { wiringItem: { name: transformation.name, test_wiring: schedule.wiring, + release_wiring: transformation.release_wiring, id: transformation.id, version_tag: transformation.version_tag, io_interface: transformation.io_interface @@ -179,14 +194,9 @@ export class SchedulingTabComponent implements OnInit { this.wiringConfigService.confirmationButtonText = 'Save Wiring'; - dialogRef.componentInstance.cancelDialogClick.subscribe( - () => { - dialogRef.close(); - }, - finalize(() => { - this.wiringConfigService.resetToDefaults(); - }) - ); + dialogRef.componentInstance.cancelDialogClick.subscribe(() => { + dialogRef.close(); + }); return dialogRef.componentInstance.confirmClick.pipe( first(), @@ -197,6 +207,9 @@ export class SchedulingTabComponent implements OnInit { dialogRef.close(); } }), + // The config is shared with all other wiring dialogs, so the + // button text must be reset however this one is closed. + takeUntil(dialogRef.afterClosed()), finalize(() => { this.wiringConfigService.resetToDefaults(); }) diff --git a/frontend/src/libs/hd-wiring/lib/wiring-dialog/wiring-dialog.component.html b/frontend/src/libs/hd-wiring/lib/wiring-dialog/wiring-dialog.component.html index e77252b68..5258fe28b 100644 --- a/frontend/src/libs/hd-wiring/lib/wiring-dialog/wiring-dialog.component.html +++ b/frontend/src/libs/hd-wiring/lib/wiring-dialog/wiring-dialog.component.html @@ -4,13 +4,25 @@

{{ title }} {{ wiringItem.name }} {{ wiringItem.version_tag }}

- +
+ @if (_hasReleaseWiring()) { + + } + +
} diff --git a/frontend/src/libs/hd-wiring/lib/wiring-dialog/wiring-dialog.component.spec.ts b/frontend/src/libs/hd-wiring/lib/wiring-dialog/wiring-dialog.component.spec.ts index c84f46c77..8c9e32b32 100644 --- a/frontend/src/libs/hd-wiring/lib/wiring-dialog/wiring-dialog.component.spec.ts +++ b/frontend/src/libs/hd-wiring/lib/wiring-dialog/wiring-dialog.component.spec.ts @@ -574,4 +574,123 @@ describe('WiringDialogComponent', () => { 'Plant A / Pickling Unit / Influx' ); }); + + it('should not offer a reset without release wiring', () => { + // Act + fixture = TestBed.createComponent(WiringDialogComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + + // Assert + expect(component._hasReleaseWiring()).toBeFalse(); + expect( + fixture.nativeElement.querySelector( + '[data-testid="reset-to-release-wiring-wiring-dialog"]' + ) + ).toBeNull(); + }); + + it('should reset the form to the release wiring', () => { + // Arrange + const MOCK_INPUT_WIRING_NAME = 'mockWiringInput'; + + const adapterList: Adapter[] = [ + { + id: 'testid', + name: 'my test adapter', + url: 'https://dummy.de' + } + ]; + + const mockTransformation: WiringItem = { + id: 'mockWiringId1', + test_wiring: { + input_wirings: [ + { + workflow_input_name: MOCK_INPUT_WIRING_NAME, + adapter_id: 'direct_provisioning', + filters: { + value: 'testValue' + } + } + ], + output_wirings: [] + }, + release_wiring: { + input_wirings: [ + { + workflow_input_name: MOCK_INPUT_WIRING_NAME, + adapter_id: adapterList[0].id, + ref_id: 'releaseNodeId', + ref_id_type: 'SOURCE', + type: AdapterDataType.STRING, + filters: {} + } + ], + output_wirings: [] + }, + io_interface: { + inputs: [ + { + id: 'mockInput1Id', + name: MOCK_INPUT_WIRING_NAME, + data_type: IOType.STRING, + type: IOTypeOption.REQUIRED + } + ], + outputs: [] + }, + name: 'mockWiring', + version_tag: '1.1.1.mock' + }; + + TestBed.overrideProvider(MAT_DIALOG_DATA, { + useValue: { + title: 'test20', + wiringItem: mockTransformation, + adapterList + } + }); + + mockAdapterService.getOneSource.and.returnValue( + of({ + id: 'releaseNodeId', + name: 'releaseNode', + thingNodeId: 'oneTestThingNodeId', + type: AdapterDataType.STRING, + visible: true, + path: 'release/node/path' + } as unknown as SourceSinkNode) + ); + + const fixtureLocal = TestBed.createComponent(WiringDialogComponent); + const componentLocal = fixtureLocal.componentInstance; + fixtureLocal.detectChanges(); + + componentLocal.inputFormArray.controls[0] + .get('rawValue') + ?.setValue('changedValue'); + + // Act + const resetButton: HTMLButtonElement = + fixtureLocal.nativeElement.querySelector( + '[data-testid="reset-to-release-wiring-wiring-dialog"]' + ); + resetButton.click(); + + const uiWiring: UiItemWiring = ( + componentLocal.inputFormArray.controls[0] as FormGroup + ).getRawValue(); + + // Assert + expect(mockAdapterService.getOneSource).toHaveBeenCalledWith( + 'releaseNodeId', + adapterList[0].url + ); + expect(uiWiring.adapterId).toBe(adapterList[0].id); + expect(uiWiring.nodeId).toBe('releaseNodeId'); + expect(uiWiring.displayName).toBe('releaseNode'); + expect(uiWiring.nodePath).toBe('release/node/path'); + expect(uiWiring.rawValue).toBeNull(); + }); }); diff --git a/frontend/src/libs/hd-wiring/lib/wiring-dialog/wiring-dialog.component.ts b/frontend/src/libs/hd-wiring/lib/wiring-dialog/wiring-dialog.component.ts index 6f4d07712..ca004911e 100644 --- a/frontend/src/libs/hd-wiring/lib/wiring-dialog/wiring-dialog.component.ts +++ b/frontend/src/libs/hd-wiring/lib/wiring-dialog/wiring-dialog.component.ts @@ -21,7 +21,7 @@ import { import { MAT_DIALOG_DATA, MatDialog } from '@angular/material/dialog'; import { IOType, IOTypeOption } from 'hetida-flowchart'; import moment, { Moment } from 'moment'; -import { combineLatest, Observable, of, Subject } from 'rxjs'; +import { combineLatest, Observable, of, Subject, Subscription } from 'rxjs'; import { catchError, map, startWith, switchMap, tap } from 'rxjs/operators'; import { Adapter, @@ -109,6 +109,9 @@ export interface IoInterface { export interface WiringItem { id: string; test_wiring: TestWiring; + // Stored by the backend on release, so only released and deprecated + // transformations have one. The dialog offers to reset to it if present. + release_wiring?: TestWiring | null; io_interface: IoInterface; name?: string; version_tag?: string; @@ -184,6 +187,8 @@ export class WiringDialogComponent implements OnInit { private _saveAdapterIdInput = ''; private _saveAdapterIdOutput = ''; + private _loadWiringSubscription?: Subscription; + private readonly SOURCE_TYPE: SourceType = 'INPUT_WIRING'; private readonly SINK_TYPE: SourceType = 'OUTPUT_WIRING'; @@ -211,9 +216,32 @@ export class WiringDialogComponent implements OnInit { 'no adapters are provided, if you don`t have any adapter implemented yet, you can pass an empty array' ); - const standardWiring: TestWiring | undefined = this.wiringItem.test_wiring; + this._loadWiring(this.wiringItem.test_wiring); + } + + public _hasReleaseWiring(): boolean { + return Utils.isDefined(this.wiringItem.release_wiring); + } + + /** + * Replaces whatever is entered in the dialog with the wiring stored on + * release. Nothing is saved until the dialog is confirmed. + */ + public _resetToReleaseWiring(): void { + const releaseWiring = this.wiringItem.release_wiring; + Utils.assert(releaseWiring, 'no release wiring to reset to'); + this._jsonImportErrorStatus$.next(''); + this._loadWiring(releaseWiring); + } - of(this.adapterList) + /** + * Builds the form from the given wiring. The sources and sinks it refers to + * are fetched from their adapters first, to show their names and filters. + */ + private _loadWiring(standardWiring: TestWiring | undefined): void { + // A form still being built from a previous wiring must not replace this one. + this._loadWiringSubscription?.unsubscribe(); + this._loadWiringSubscription = of(this.adapterList) .pipe( tap(availableAdapters => { this._availableAdapters = availableAdapters;