From ebfee0201e4ae569ee0b1969df7065bf97b3766c Mon Sep 17 00:00:00 2001 From: stewit Date: Tue, 6 Oct 2026 17:23:51 +0200 Subject: [PATCH] add reset to release wiring button to wiring dialog Released and deprecated transformations keep the wiring stored on release. The wiring dialog now offers to reset to it, both when executing and when editing the wiring of a schedule. Nothing is saved until the dialog is confirmed. Also fix the confirm button text of the schedule wiring dialog leaking into all later wiring dialogs: after cancelling it, the execute dialog offered "Save Wiring" instead of "Execute". Assisted-by: Claude Code:claude-opus-5-5 --- CHANGELOG.md | 1 + ...components-reset-to-release-wiring.spec.ts | 149 ++++++++++++++++++ ...ents-schedule-wiring-dialog-cancel.spec.ts | 63 ++++++++ .../test-cases/fixtures/backend-api.ts | 107 +++++++++++++ .../scheduling-tab.component.ts | 37 +++-- .../wiring-dialog.component.html | 26 ++- .../wiring-dialog.component.spec.ts | 119 ++++++++++++++ .../wiring-dialog/wiring-dialog.component.ts | 34 +++- 8 files changed, 514 insertions(+), 22 deletions(-) create mode 100644 frontend/playwright/test-cases/components/components-reset-to-release-wiring.spec.ts create mode 100644 frontend/playwright/test-cases/components/components-schedule-wiring-dialog-cancel.spec.ts 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;