diff --git a/e2e/TEST_COVERAGE.md b/e2e/TEST_COVERAGE.md index 1b0d738ed9..88761a08c4 100644 --- a/e2e/TEST_COVERAGE.md +++ b/e2e/TEST_COVERAGE.md @@ -4,11 +4,13 @@ | Category | Features | Functions | ✅ Covered | ❌ Not Covered | |-----------------------------|----------|-----------|------------|----------------| -| User Features (ROLE_USER) | 5 | 21 | 12 | 9 | -| Admin Features (ROLE_ADMIN) | 15 | 335 | 239 | 96 | -| **Total** | **20** | **356** | **251** | **105** | +| User Features (ROLE_USER) | 5 | 25 | 20 | 4 | +| Admin Features (ROLE_ADMIN) | 15 | 365 | 352 | 7 | +| **Total** | **20** | **390** | **372** | **11** | -**Coverage:** `251 / 356` — `70.5%` +**Coverage:** `372 / 390` — `95.4%` + +> Counts are one per numbered row. The remainder of each category is `N/A` (6) or `⏳` (1). --- @@ -29,11 +31,14 @@ ### Feature 1 — Dashboard -| # | Function | Test Scenarios | Coverage | Notes | -|:----|:--------------------------------|:---------------------------------------------------------------|:--------:|:------------------------------------------------| -| 1.1 | Display widget-based dashboard | Load dashboard with multiple widgets · Display empty dashboard | ❌ | | -| 1.2 | Configure widgets per user/role | Add widget to dashboard · Remove widget from dashboard | ❌ | | -| 1.3 | Real-time data updates (SSE) | Receive SSE update and refresh widget | ❌ | | +| # | Function | Test Scenarios | Coverage | Notes | +|:----|:-------------------------------------|:-----------------------------------------------------------------------------------------------------|:--------:|:-------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| 1.1 | Display widget-based dashboard | Load dashboard with multiple widgets · Render each widget's display title · Display empty dashboard | ✅ | user-dashboard.spec.ts | +| 1.2 | Configure widgets per user/role | Add widget to dashboard · Remove widget from dashboard · Dashboard without a view permission is hidden | ✅ | user-dashboard.spec.ts (dashboard visibility is role-scoped by an access-control permission on the dashboard key) | +| 1.3 | Real-time data updates (SSE) | Receive SSE update and refresh widget | `N/A` | The widget dashboard does not subscribe to SSE — `@valtimo/dashboard` has no SSE usage, and `/api/v1/dashboard/{key}/data` is verified not to be re-requested after a case is created. Refresh is covered by 1.4 | +| 1.4 | Refresh widget data on open | Widget data is re-fetched on open and reflects newly created cases | ✅ | user-dashboard.spec.ts | +| 1.5 | Navigate from widget to linked route | Clicking a widget with a configured URL navigates to that route | ✅ | user-dashboard.spec.ts | +| 1.6 | Skip unsupported widget display type | Widget with an unregistered display type is skipped and the dashboard still renders | ✅ | user-dashboard.spec.ts (failure scenario) | --- @@ -65,11 +70,12 @@ ### Feature 4 — Objects -| # | Function | Test Scenarios | Coverage | Notes | -|:----|:----------------------|:----------------------------------------------------------|:--------:|:------------------------------------------------| -| 4.1 | View objects per type | Display objects filtered by type | ❌ | | -| 4.2 | View object details | Open and view object details | ❌ | | -| 4.3 | Search/filter objects | Search objects by criteria · Filter objects using filters | ❌ | | +| # | Function | Test Scenarios | Coverage | Notes | +|:-----|:----------------------|:-----------------------------------------------------------------------------------------|:--------:|:-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| 4.1 | View objects per type | Lists the objects of one type · Columns match the API · List is scoped to the type | ✅ | user-objects.spec.ts — read-only against the seeded type. With no list columns configured the component falls back to `Record index` / `Object URL`; while loading it renders a **second** `valtimo-carbon-list`, so waits must be for that placeholder to go | +| 4.2 | View object details | Row click opens the object · Direct URL works · Summary form with edit/delete actions | ✅ | user-objects.spec.ts — the row navigates on the plain object id, not the full Objecten API URL | +| 4.2a | Handle unknown object | An unknown object id renders no summary form | ✅ | user-objects.spec.ts (failure scenario) | +| 4.3 | Search/filter objects | Search objects by criteria · Filter objects using filters | ❌ | **Blocked.** The search panel is only wired when the type has *list columns* configured (`columnType === CUSTOM`); otherwise the component issues a GET that ignores the filters. With a list column configured, `POST /api/v1/object-management/configuration/{id}/object` returns **400 with an empty body** — even for an empty `{}` filter body — and `catchError` turns it into an empty list, so the UI silently shows "no results". Needs a backend look before it can be tested | --- @@ -199,7 +205,7 @@ | 6.64 | Rearrange task list columns | Rearrange task list columns | ✅ | case-details-management-tasks.spec.ts | | 6.65 | View task search fields | View task list search fields | ✅ | case-details-management-tasks.spec.ts | | 6.66 | Add task search field | Add task list search field | ✅ | case-details-management-tasks.spec.ts | -| 6.67 | Toggle JSON/table view | Toggle task list JSON/table view | ❌ | | +| 6.67 | Toggle JSON/table view | Toggle task list JSON/table view | `N/A` | The Tasks tab has no JSON view — neither `valtimo-task-management-columns` nor `-search-fields` renders a switch-view control or JSON editor. Only the Case list and Search fields tabs offer one | #### 6J · Case List @@ -256,7 +262,8 @@ | 6.91 | Set widget density | Set widget density | ✅ | case-details-management-widgets.spec.ts | | 6.92 | Set widget style | Set widget style | ✅ | case-details-management-widgets.spec.ts | | 6.93 | Configure widget content | Configure widget content | ✅ | case-details-management-widgets.spec.ts | -| 6.94 | Set widget conditions | Set widget display conditions | ❌ | | +| 6.94 | Set widget conditions | Add a display condition in the wizard's last step · Condition round-trips when reopened | ✅ | case-details-management-widgets.spec.ts (path entered in manual mode; the "Equal to" label is stored as `==`) | +| 6.94a | Reject incomplete condition | Save disabled while a condition row is empty; removing the row re-enables it | ✅ | case-details-management-widgets.spec.ts (failure scenario) | | 6.95 | Add widget separator | Add widget separator | ✅ | case-details-management-widgets.spec.ts | | 6.96 | Rearrange widgets | Rearrange widgets order | ✅ | case-details-management-widgets.spec.ts | | 6.97 | Use widget JSON editor | Use widget JSON editor | ✅ | case-details-management-widgets.spec.ts | @@ -299,13 +306,19 @@ ### Feature 7 — Process Management -| # | Function | Test Scenarios | Coverage | Notes | -|:----|:------------------------|:------------------------------|:--------:|:------------------------------------------------| -| 7.1 | View process overview | Display process overview list | ❌ | | -| 7.2 | Create new process | Create new BPMN process | ❌ | | -| 7.3 | Edit BPMN process | Edit existing BPMN process | ❌ | | -| 7.4 | Deploy process | Deploy process definition | ❌ | | -| 7.5 | Manage process versions | Manage process versions | ❌ | | +Covers the standalone `/processes` admin page (the *independent* process context — process definitions not linked to a case). The case-scoped equivalent is Feature 6B. + +| # | Function | Test Scenarios | Coverage | Notes | +|:-----|:-----------------------------|:-----------------------------------------------------------------------------------|:--------:|:-----------------------------------------------------------------------------| +| 7.1 | View process overview | Display process overview list · Name/Key/Status columns · List matches the API | ✅ | process-management.spec.ts | +| 7.2 | Create new process | Create new BPMN process from the empty diagram and deploy it | ✅ | process-management.spec.ts | +| 7.3 | Edit BPMN process | Edit existing BPMN process in the modeler (append element) | ✅ | process-management.spec.ts | +| 7.4 | Deploy process | Deploy via BPMN upload · Save an edit as a new version · Deploy a new definition | ✅ | process-management.spec.ts | +| 7.5 | Manage process versions | View all versions · Switch version · Disabled while only one version exists | ✅ | process-management.spec.ts | +| 7.6 | Delete process | Delete process definition after confirming | ✅ | process-management.spec.ts | +| 7.6a | Reject upload without file | Upload stays disabled until a BPMN file is selected | ✅ | process-management.spec.ts (failure scenario) | +| 7.6b | Handle duplicate process key | Re-upload of the same key returns 409 and offers replace · cancel deploys nothing | ✅ | process-management.spec.ts (failure scenario) | +| 7.6c | Block invalid process deploy | Non-draft deploy is validated first; errors are highlighted and nothing deploys | ✅ | process-management.spec.ts (failure scenario) | --- @@ -313,10 +326,13 @@ | # | Function | Test Scenarios | Coverage | Notes | |:----|:------------------------------|:--------------------------------------|:--------:|:------------------------------------------------| -| 8.1 | View decision tables overview | Display decision tables overview list | ❌ | | -| 8.2 | Create decision table | Create new decision table | ❌ | | -| 8.3 | Edit decision table | Edit decision table in DMN modeler | ❌ | | -| 8.4 | Test decision table | Test decision table execution | ❌ | | +| 8.1 | View decision tables overview | Overview list visible · Key/Name/Version columns · List matches the API (latest version per key) | ✅ | decision-table-management.spec.ts | +| 8.2 | Create decision table | Name + input variables, then land in the DMN modeler | ✅ | decision-table-management.spec.ts — deliberately stops short of Deploy: a deployed standalone table cannot be removed again (see 8.4c), so deploying would leak state on every run. Deployment is covered case-scoped in 6.40 | +| 8.3 | Edit decision table | Open an existing table in the DMN modeler (editable, content loaded) | ✅ | decision-table-management.spec.ts | +| 8.4 | Test decision table | Test decision table execution | `N/A` | No evaluate/test UI exists — the DMN modeler route has no such control or copy | +| 8.4a | Reject unnamed decision table | Create submit stays disabled while the name is empty | ✅ | decision-table-management.spec.ts (failure scenario) | +| 8.4b | Reject upload without file | Upload submit stays disabled until a DMN file is chosen | ✅ | decision-table-management.spec.ts (failure scenario) | +| 8.4c | Delete unavailable standalone | Delete row action is present but **disabled** outside a case context | ✅ | decision-table-management.spec.ts (failure scenario) — delete is only implemented for case-scoped tables | --- @@ -342,27 +358,29 @@ | 9.9 | Configure plugin (2-step wizard) | Complete 2-step wizard flow | ✅ | plugin.spec.ts | | 9.10 | Choose plugin type from catalog | Select plugin type in step 1 | ✅ | plugin.spec.ts | | 9.11 | Enter plugin data (step 2) | Fill in all required fields in step 2 | ✅ | plugin.spec.ts | -| 9.12 | Auto-generate configuration ID | Verify UUID is auto-generated | ❌ | | +| 9.12 | Auto-generate configuration ID | Configuration ID is optional with a UUID placeholder · Saving it blank generates a UUID | ✅ | plugin.spec.ts (the field is not prefilled; the backend assigns the UUID on save) | | 9.13 | Enter configuration name | Enter and validate required configuration name · Cannot save without configuration name | ✅ | plugin.spec.ts | | 9.14 | Enter RSIN | Enter RSIN for plugins that require it | ✅ | plugin.spec.ts | | 9.15 | Enter plugin API URL | Enter and validate required API URL · Cannot save without API URL · Show validation error for invalid URL format | ✅ | plugin.spec.ts | | 9.16 | Select authentication plugin | Select required authentication plugin configuration · Cannot save without authentication plugin | ✅ | plugin.spec.ts | -| 9.17 | View authentication options | View available authentication options in dropdown | ❌ | | +| 9.17 | View authentication options | Dropdown lists every compatible authentication configuration, labelled ` - <plugin>` | ✅ | plugin.spec.ts (creates a second OpenZaak auth config so the dropdown has more than one option) | | 9.18 | Save plugin configuration | Successfully save new plugin configuration · Show success message after save · Redirect to plugin list after save | ✅ | plugin.spec.ts | -| 9.19 | Cancel plugin configuration | Cancel wizard without saving · Show confirmation dialog before canceling | ❌ | | +| 9.19 | Cancel plugin configuration | Cancel wizard without saving (closes immediately, nothing persisted) | ✅ | plugin.spec.ts — there is no "discard changes?" confirmation dialog, so that sub-scenario is `N/A` | #### 9C · Edit Plugin Config | # | Function | Test Scenarios | Coverage | Notes | |:-----|:-----------------------------------|:---------------------------------------------------------------------------------------------------------------------------|:--------:|:------------------------------------------------| | 9.20 | Open existing plugin configuration | Open plugin configuration for editing | ✅ | plugin.spec.ts | -| 9.21 | View configuration ID | Verify read-only UUID is displayed | ❌ | | +| 9.21 | View configuration ID | Edit modal shows the assigned UUID | ✅ | plugin.spec.ts — the field is **editable**, not read-only, so the test asserts the value only | | 9.22 | Edit configuration name | Update configuration name | ✅ | plugin.spec.ts | -| 9.23 | Edit RSIN | Update RSIN value | ❌ | | -| 9.24 | Edit API URL | Update plugin API URL | ❌ | | -| 9.25 | Change authentication plugin | Change selected authentication plugin | ❌ | | +| 9.23 | Edit RSIN | Update RSIN value (persisted value verified via the API) | ✅ | plugin.spec.ts | +| 9.24 | Edit API URL | Update plugin API URL (persisted value verified via the API) | ✅ | plugin.spec.ts | +| 9.25 | Change authentication plugin | Switch to a different authentication configuration (stored UUID verified via the API) | ✅ | plugin.spec.ts | | 9.26 | Save configuration changes | Successfully save changes to existing configuration | ✅ | plugin.spec.ts | | 9.27 | Delete plugin configuration | Delete plugin configuration with confirmation · Show confirmation dialog before delete · Show success message after delete | ✅ | plugin.spec.ts | +| 9.27a | Reject invalid RSIN on edit | Update with an invalid RSIN returns 500 and the modal stays open | ✅ | plugin.spec.ts (failure scenario) — no error toast is shown and the invalid value is still persisted; neither is asserted so a fix won't break the test | +| 9.27b | Reject edit without a name | Save stays disabled while the configuration name is empty | ✅ | plugin.spec.ts (failure scenario) | --- @@ -436,28 +454,30 @@ | 11.3 | Enter role name | Enter role name | ✅ | access-control.spec.ts | | 11.4 | Create role | Create role | ✅ | access-control.spec.ts | | 11.5 | View role details | Select and view role details | ✅ | access-control.spec.ts | -| 11.6 | Edit role metadata | Edit role metadata | ❌ | | -| 11.7 | Export role configuration | Export role configuration | ❌ | | +| 11.6 | Edit role metadata | Rename the role via the detail page's "Edit metadata" modal, then rename it back | ✅ | access-control.spec.ts (a role's only metadata is its name; the detail route is keyed on it) | +| 11.7 | Export role configuration | Export selected role as one JSON file (`combined.permission.json`) · Confirm disabled until an export type is chosen | ✅ | access-control.spec.ts | | 11.8 | Delete role | Delete role | ✅ | access-control.spec.ts | #### 11B · Permissions | # | Function | Test Scenarios | Coverage | Notes | |:------|:-------------------------------|:---------------------------------------------|:--------:|:------------------------------------------------| -| 11.9 | View role permissions | View role permissions (JSON / visual toggle) | ❌ | | -| 11.10 | Edit permissions JSON | Edit permissions in JSON format | ❌ | | -| 11.11 | Configure resource permissions | Configure resource-level permissions | ❌ | | -| 11.12 | Set permission conditions | Set permission conditions | ❌ | | -| 11.13 | Save role permissions | Save role permissions | ❌ | | +| 11.9 | View role permissions | Editor / Summary / JSON editor tabs · toggle to Monaco and back | ✅ | access-control.spec.ts | +| 11.10 | Edit permissions JSON | Write a permission array in the JSON editor and save it | ✅ | access-control.spec.ts | +| 11.11 | Configure resource permissions | Pick a resource type and tick an allowed action | ✅ | access-control.spec.ts (actions are a checkbox grid, not a dropdown) | +| 11.12 | Set permission conditions | Add a field/operator/value condition to a permission | ✅ | access-control.spec.ts (the "equals" label is stored as `==`) | +| 11.13 | Save role permissions | Save from both the JSON editor and the form editor; verified via the API | ✅ | access-control.spec.ts | --- ### Feature 12 — Object Management -| # | Function | Test Scenarios | Coverage | Notes | -|:-----|:-------------------------------|:-------------------------------|:--------:|:------------------------------------------------| -| 12.1 | Manage object types | Manage object types | ❌ | | -| 12.2 | Edit object type configuration | Edit object type configuration | ❌ | | +| # | Function | Test Scenarios | Coverage | Notes | +|:------|:-------------------------------|:----------------------------------------------------------------------------------------------|:--------:|:-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| 12.1 | Manage object types | Overview lists every configuration · Create one through the modal · It is persisted | ✅ | object-management.spec.ts — `objecttype_id` is **unique** across configurations, so each run generates its own id rather than reusing a seeded one | +| 12.2 | Edit object type configuration | Detail page with its tabs and actions · Edit the configuration · The change survives a reload | ✅ | object-management.spec.ts — the List tab renders even without list columns (its `*ngIf` gets an empty array, which is truthy). "Show in menu" is a Carbon checkbox: the real input is hidden, so its label is clicked | +| 12.1a | Reject incomplete object type | Save stays disabled until every required field is filled, and again when one is cleared | ✅ | object-management.spec.ts (failure scenario) | +| 12.1b | Reject duplicate objecttype ID | A taken objecttype ID returns 500, the modal stays open, nothing is created | ✅ | object-management.spec.ts (failure scenario) — no error is surfaced to the user today; that is deliberately not asserted, so a fix adding one will not break the test | --- @@ -465,73 +485,88 @@ #### 13A · BB Overview -| # | Function | Test Scenarios | Coverage | Notes | -|:-----|:------------------------------|:---------------------------------------------------|:--------:|:------------------------------------------------| -| 13.1 | View building blocks list | Display list of building blocks | ❌ | | -| 13.2 | View BB metadata | View building block name, key, and version | ❌ | | -| 13.3 | Upload BB definition | Upload building block definition (ZIP, max 500 kb) | ❌ | | -| 13.4 | Acknowledge overwrite warning | Acknowledge overwrite warning for existing blocks | ❌ | | -| 13.5 | Create new building block | Create new building block | ❌ | | -| 13.6 | Enter BB name | Enter building block name | ❌ | | -| 13.7 | Auto-generate BB key | Auto-generate building block key | ❌ | | -| 13.8 | Enter BB version | Enter building block version | ❌ | | -| 13.9 | Enter BB description | Enter building block description | ❌ | | +| # | Function | Test Scenarios | Coverage | Notes | +|:------|:------------------------------|:-------------------------------------------------------------------|:--------:|:------------------------------------------------| +| 13.1 | View building blocks list | Display list of building blocks | ✅ | building-block-management.spec.ts | +| 13.2 | View BB metadata | View building block name, key, and version | ✅ | building-block-management.spec.ts | +| 13.3 | Upload BB definition | Upload building block definition (ZIP, max 500 kb) | ✅ | building-block-management.spec.ts | +| 13.4 | Acknowledge overwrite warning | Acknowledge overwrite warning for existing blocks | ✅ | building-block-management.spec.ts | +| 13.5 | Create new building block | Create new building block | ✅ | building-block-management.spec.ts | +| 13.6 | Enter BB name | Enter building block name | ✅ | building-block-management.spec.ts | +| 13.7 | Auto-generate BB key | Auto-generate building block key | ✅ | building-block-management.spec.ts | +| 13.8 | Enter BB version | Enter building block version | ✅ | building-block-management.spec.ts | +| 13.9 | Enter BB description | Enter building block description | ✅ | building-block-management.spec.ts | +| 13.9a | Reject invalid BB key | Duplicate key blocked · Key with invalid characters blocked | ✅ | building-block-management.spec.ts | +| 13.9b | Reject incomplete BB form | Save disabled while name or version is missing | ✅ | building-block-management.spec.ts | +| 13.9c | Block unacknowledged upload | Upload disabled until overwrite warning is acknowledged | ✅ | building-block-management.spec.ts | +| 13.9d | Reject invalid BB archive | Archive without a BB definition shows the import error | ✅ | building-block-management.spec.ts | #### 13B · BB Details -| # | Function | Test Scenarios | Coverage | Notes | -|:------|:--------------------------|:-----------------------------------------------------|:--------:|:------------------------------------------------| -| 13.10 | View BB general info | View building block general information | ❌ | | -| 13.11 | View BB document tab | View building block document tab | ❌ | | -| 13.12 | View BB processes tab | View building block processes tab | ❌ | | -| 13.13 | View plugin configuration | View plugin configuration used (e.g. Zaak API) | ❌ | | -| 13.14 | Upload/update BB artwork | Upload or update building block artwork | ❌ | | -| 13.15 | Delete BB artwork | Delete building block artwork | ❌ | | -| 13.16 | Save BB metadata | Save building block metadata | ❌ | | -| 13.17 | Export BB as ZIP | Export building block as ZIP | ❌ | | -| 13.18 | Create draft version | Create draft version | ❌ | | -| 13.19 | Enter version tag | Enter new version tag for draft | ❌ | | -| 13.20 | View version status badge | View version status badge (`DRAFT` / `RELEASE`) | ❌ | | -| 13.21 | Switch versions | Switch between versions via dropdown | ❌ | | -| 13.22 | Finalize draft version | Finalize draft version (convert `DRAFT` → `RELEASE`) | ❌ | | +| # | Function | Test Scenarios | Coverage | Notes | +|:-------|:----------------------------------|:------------------------------------------------------------------------|:--------:|:--------------------------------------------------| +| 13.10 | View BB general info | View building block general information | ✅ | building-block-details-management.spec.ts | +| 13.11 | View BB document tab | View building block document tab | ✅ | building-block-details-management.spec.ts | +| 13.12 | View BB processes tab | View building block processes tab | ✅ | building-block-details-management.spec.ts | +| 13.13 | View plugin configuration | Plugins used listed · Empty message when none are used | ✅ | building-block-details-management.spec.ts | +| 13.14 | Upload/update BB artwork | Upload building block artwork | ✅ | building-block-details-management.spec.ts | +| 13.15 | Delete BB artwork | Delete artwork after confirming | ✅ | building-block-details-management.spec.ts | +| 13.16 | Save BB metadata | Save building block name and description | ✅ | building-block-details-management.spec.ts | +| 13.17 | Export BB as ZIP | Export building block as ZIP | ✅ | building-block-details-management.spec.ts | +| 13.18 | Create draft version | Create draft version from a finalized version | ✅ | building-block-details-management.spec.ts | +| 13.19 | Enter version tag | Enter new version tag for draft | ✅ | building-block-details-management.spec.ts | +| 13.20 | View version status badge | Draft badged `DRAFT: <tag>` · final badged `<tag>` (no `RELEASE` label) | ✅ | building-block-details-management.spec.ts | +| 13.21 | Switch versions | Switch between versions via dropdown | ✅ | building-block-details-management.spec.ts | +| 13.22 | Finalize draft version | Finalize draft version, which becomes read-only | ✅ | building-block-details-management.spec.ts | +| 13.22a | Reject incomplete BB metadata | Save disabled while pristine or with an empty name | ✅ | building-block-details-management.spec.ts | +| 13.22b | Reject artwork upload without file | Upload disabled until a file is selected | ✅ | building-block-details-management.spec.ts | +| 13.22c | Reject draft without version tag | Create draft disabled while the version tag is empty | ✅ | building-block-details-management.spec.ts | #### 13C · BB Document -| # | Function | Test Scenarios | Coverage | Notes | -|:------|:---------------------------|:-------------------------------------------------------------|:--------:|:------------------------------------------------| -| 13.23 | View BB document structure | View building block document structure (JSON) | ❌ | | -| 13.24 | Edit document structure | Edit document structure in JSON editor | ❌ | | -| 13.25 | Manage required fields | Manage required fields with checkboxes | ❌ | | -| 13.26 | View field types | View field types (`string`, `object`, `array`, `boolean`, …) | ❌ | | -| 13.27 | View field descriptions | View field descriptions | ❌ | | -| 13.28 | Search/filter fields | Search and filter document fields | ❌ | | -| 13.29 | Save document config | Save document configuration | ❌ | | +| # | Function | Test Scenarios | Coverage | Notes | +|:-------|:----------------------------------|:----------------------------------------------------------------------------|:--------:|:--------------------------------------------------| +| 13.23 | View BB document structure | View building block document structure (JSON) | ✅ | building-block-document.spec.ts | +| 13.24 | Edit document structure | Edit document structure in the JSON editor | ✅ | building-block-document.spec.ts | +| 13.25 | Manage required fields | Panel grouped per object level · Mark and unmark required | ✅ | building-block-document.spec.ts | +| 13.26 | View field types | View field types (`string`, `integer`, `boolean`, `object`, `array`) | ✅ | building-block-document.spec.ts | +| 13.27 | View field descriptions | View field descriptions | ✅ | building-block-document.spec.ts | +| 13.28 | Search/filter fields | Search highlights matches and reports the match count | ✅ | building-block-document.spec.ts | +| 13.29 | Save document config | Save document configuration (edit and required-field changes) | ✅ | building-block-document.spec.ts | +| 13.29a | Reject unchanged document save | Save disabled until the document is changed | ✅ | building-block-document.spec.ts | +| 13.29b | Reject invalid document JSON | Parse error shown · Save disabled · document untouched | ✅ | building-block-document.spec.ts | +| 13.29c | Block edits on finalized version | Document readable but Save and required checkboxes disabled | ✅ | building-block-document.spec.ts | #### 13D · BB Processes -| # | Function | Test Scenarios | Coverage | Notes | -|:------|:---------------------------|:---------------------------------------------|:--------:|:------------------------------------------------| -| 13.30 | View processes list | View processes list in building block | ❌ | | -| 13.31 | View process metadata | View process name and key | ❌ | | -| 13.32 | Manage process definitions | Manage process definitions | ❌ | | -| 13.33 | View process diagram | View process diagram/modeler | ❌ | | -| 13.34 | Select process step | Select process step in diagram | ❌ | | -| 13.35 | View step properties | View step properties panel | ❌ | | -| 13.36 | Configure step settings | Configure step-specific settings | ❌ | | -| 13.37 | Link steps to actions | Link process steps to building block actions | ❌ | | -| 13.38 | Save process config | Save process configuration | ❌ | | +| # | Function | Test Scenarios | Coverage | Notes | +|:-------|:-----------------------------|:----------------------------------------------------------------------------------------------|:--------:|:-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| 13.30 | View processes list | Name/Key/Status columns · List matches the API | ✅ | building-block-processes.spec.ts | +| 13.31 | View process metadata | View process name and key · Main-process and Draft status tags | ✅ | building-block-processes.spec.ts — creating a building block generates a main process definition whose key *and* name are those of the building block itself | +| 13.32 | Manage process definitions | Upload a BPMN · Mark a process as main · Delete a process after confirming · Create from the empty diagram | ✅ | building-block-processes.spec.ts — a process created from the empty diagram always lands under the fixed key `Process_1` with an empty Name cell, so rows are located by key | +| 13.33 | View process diagram | Row click opens the process in the BPMN modeler | ✅ | building-block-processes.spec.ts — the row navigates to `.../process-definition/{processDefinitionId}`; the building-block context omits the version dropdown and the Back button the case context shows | +| 13.34 | Select process step | Clicking a shape switches the properties panel to that element | ✅ | building-block-processes.spec.ts — selection is confirmed through the panel's ID entry; the panel *header* renders the element name and falls back to the id only while it is unnamed | +| 13.35 | View step properties | Panel offers General / Process link / Documentation · ID and Name entries | ✅ | building-block-processes.spec.ts — panel groups start collapsed and keep their entries in the DOM but hidden, so they must be expanded before they can be read | +| 13.36 | Configure step settings | Rename a step through the properties panel | ✅ | building-block-processes.spec.ts | +| 13.37 | Link steps to actions | Panel's "Create process link" opens the wizard, which offers the available link types | ✅ | building-block-processes.spec.ts — inside a building block a start event may be linked to **Form / FormFlow / UI Component**; there is deliberately no "Building block" link type. Panel buttons carry `PROCESS_LINK_PANEL_TEST_IDS` | +| 13.38 | Save process config | Saving deploys a new version of the definition, and the change survives a reload | ✅ | building-block-processes.spec.ts — saved as a *draft*: a non-draft save is validated first and, because the seeded diagram has an unlinked start event, waits on a "Process has warnings" confirmation instead of deploying | +| 13.38a | Reject upload without file | Upload stays disabled until a BPMN file is selected; cancelling persists nothing | ✅ | building-block-processes.spec.ts (failure scenario) | +| 13.38b | Protect the only process | Delete and "Make main process" are both disabled while one definition exists | ✅ | building-block-processes.spec.ts (failure scenario) — a building block must keep exactly one main process | +| 13.38c | Block edits on final version | Upload and Create are disabled on a finalized version | ✅ | building-block-processes.spec.ts (failure scenario) — uses a second, finalized building block so the shared one stays a draft and its processes can still be cleaned up | #### 13E · BB Plugin Integration -| # | Function | Test Scenarios | Coverage | Notes | -|:------|:-------------------------------|:-------------------------------------------|:--------:|:------------------------------------------------| -| 13.39 | View available plugins | View available process plugins | ❌ | | -| 13.40 | Select plugin for step | Select plugin for process step | ❌ | | -| 13.41 | Configure plugin properties | Configure plugin-specific properties | ❌ | | -| 13.42 | View plugin requirements | View plugin requirements and descriptions | ❌ | | -| 13.43 | Link action to plugin | Link action to plugin definition | ❌ | | -| 13.44 | Configure execution properties | Configure execution properties for plugins | ❌ | | -| 13.45 | View plugin warnings | View plugin configuration warnings | ❌ | | +| # | Function | Test Scenarios | Coverage | Notes | +|:-------|:-------------------------------|:----------------------------------------------------------------------------------------------------|:--------:|:---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| 13.39 | View available plugins | Service task lists the plugin definitions it can be linked to · List is a subset of the API | ✅ | building-block-plugins.spec.ts — a service task can only carry a plugin link, so the wizard skips the link type chooser. The UI drops definitions without a frontend plugin specification, so it lists 8 of the API's 12 | +| 13.40 | Select plugin for step | Picking a plugin enables Next and reveals its actions · A call activity reaches it via the chooser | ✅ | building-block-plugins.spec.ts — the definitions offered are filtered by activity type: a service task gets 8, a user task only `portaaltaak` | +| 13.41 | Configure plugin properties | Action without properties is completable at once · Action with properties renders its form | ✅ | building-block-plugins.spec.ts | +| 13.42 | View plugin requirements | Logo / name / description columns · Wizard step titles · Action description and required labels | ✅ | building-block-plugins.spec.ts — the step names plugin **definitions**, so the column reads "Plugin name" rather than "Configuration name" | +| 13.43 | Link action to plugin | Complete swaps the panel to Edit/Unlink · Save persists the link · Reopening shows the stored action | ✅ | building-block-plugins.spec.ts — the link lives in the modeler until the diagram is saved, and saving deploys a **new** definition, so the persisted link must be read with the id the save produced | +| 13.44 | Configure execution properties | Properties are filled, stored on the link, and prefilled when reopened | ✅ | building-block-plugins.spec.ts — stored as `actionProperties`; the link carries `pluginConfigurationId: null` and `referenceType: BUILDING_BLOCK`, because the configuration is only bound when a case links the block | +| 13.45 | View plugin warnings | View plugin configuration warnings | `N/A` | No warning UI exists inside a building block — the plugin dependency warning is raised on the **case** side, in the wizard's building block plugins step, and is covered by 6.33 | +| 13.45a | Reject incomplete action config | Complete stays disabled until *every* required property is filled, and again when one is cleared | ✅ | building-block-plugins.spec.ts (failure scenario) | +| 13.45b | Block unsupported link type | A user task offers `ui-component` but renders it disabled | ✅ | building-block-plugins.spec.ts (failure scenario) — see `UNSUPPORTED_PROCESS_LINK_TYPES_IN_BUILDING_BLOCK` | --- @@ -541,23 +576,25 @@ | # | Function | Test Scenarios | Coverage | Notes | |:-----|:--------------------------|:--------------------------------------------|:--------:|:------------------------------------------------| -| 14.1 | View translations table | View translations table | ❌ | | -| 14.2 | View translation keys | View translation keys column | ❌ | | -| 14.3 | View language columns | View language columns (English, Nederlands) | ❌ | | -| 14.4 | Add translation row | Add translation row | ❌ | | -| 14.5 | Enter translation key | Enter translation key | ❌ | | -| 14.6 | Enter English translation | Enter English translation | ❌ | | -| 14.7 | Enter Dutch translation | Enter Dutch (Nederlands) translation | ❌ | | -| 14.8 | Delete translation row | Delete translation row | ❌ | | +| 14.1 | View translations table | Table lists the configured translations | ✅ | translation-management.spec.ts — the dev app seeds **no** localizations, so `beforeAll` merges its own keys in via the API and `afterAll` writes the captured content back | +| 14.2 | View translation keys | View translation keys column | ✅ | translation-management.spec.ts — row order follows the stored JSON object's key order, so rows are resolved by key, never by index | +| 14.3 | View language columns | View language columns (English, Nederlands) | ✅ | translation-management.spec.ts — titles render as `cds-label`s on the first row only | +| 14.4 | Add translation row | Add translation row | ✅ | translation-management.spec.ts | +| 14.5 | Enter translation key | Enter translation key | ✅ | translation-management.spec.ts | +| 14.6 | Enter English translation | Enter English translation | ✅ | translation-management.spec.ts | +| 14.7 | Enter Dutch translation | Enter Dutch (Nederlands) translation | ✅ | translation-management.spec.ts | +| 14.8 | Delete translation row | Delete a row, then save to remove the translation | ✅ | translation-management.spec.ts — deleting only removes the row from the form; the removal is persisted by the following save | #### 14B · Save -| # | Function | Test Scenarios | Coverage | Notes | -|:------|:-----------------------|:------------------------------|:--------:|:------------------------------------------------| -| 14.9 | Save translations | Save translations | ❌ | | -| 14.10 | Save and reload app | Save and reload application | ❌ | | -| 14.11 | View save confirmation | View save confirmation dialog | ❌ | | -| 14.12 | Cancel save operation | Cancel save operation | ❌ | | +| # | Function | Test Scenarios | Coverage | Notes | +|:-------|:----------------------------|:--------------------------------------------------------------------------|:--------:|:------------------------------------------------| +| 14.9 | Save translations | Save without reloading; persisted value verified via the API | ✅ | translation-management.spec.ts — the modal's *optional* button is "Save" | +| 14.10 | Save and reload app | Save and reload; the value survives the reload and is re-rendered | ✅ | translation-management.spec.ts — the modal's *confirm* button is "Save and reload"; the component calls `location.reload()` once the request resolves | +| 14.11 | View save confirmation | Dialog shows its heading, body, and all three actions | ✅ | translation-management.spec.ts | +| 14.12 | Cancel save operation | Cancel dismisses the dialog, keeps the edit in the form, persists nothing | ✅ | translation-management.spec.ts | +| 14.12a | Reject unchanged save | Save stays disabled while the table is pristine | ✅ | translation-management.spec.ts (failure scenario) | +| 14.12b | Reject incomplete row | Save stays disabled until *every* column of a row is filled | ✅ | translation-management.spec.ts (failure scenario) — the multi-input only counts a row as valid when no cell is empty | --- @@ -696,10 +733,11 @@ ### Feature 18 — Logs -| # | Function | Test Scenarios | Coverage | Notes | -|:-----|:----------------------|:-----------------------|:--------:|:------------------------------------------------| -| 18.1 | View application logs | View application logs | ❌ | | -| 18.2 | Filter/search logs | Filter and search logs | ❌ | | +| # | Function | Test Scenarios | Coverage | Notes | +|:------|:----------------------|:--------------------------------------------------------------------------------------------------|:--------:|:------------------------------------------------| +| 18.1 | View application logs | List visible with entries · Timestamp/Log level/Message columns · Paginated · Row count matches the API · Open a row to see the details modal | ✅ | logging.spec.ts — the page is read-only, so there is nothing to clean up. `/api/management/v1/logging` is a **POST** (only POST is granted to ADMIN); a GET on the same path returns 403 | +| 18.2 | Filter/search logs | Filter by message text · Filter by log level · Clear restores the unfiltered list | ✅ | logging.spec.ts — the panel has no submit button; it re-queries on change (debounced 500ms), so the page object waits on the response and the assertions poll. The level dropdown is a **minimum** level, not an exact match (`LoggingEventService` uses `byMinimumLevel`), so selecting WARN legitimately still lists ERROR rows | +| 18.2a | Handle no matches | A filter matching nothing empties the list and shows the no-results state, which clears back | ✅ | logging.spec.ts (failure scenario) — the empty state is a `<tr data-test-id="carbonListNoResults">` inside the same `tbody`, so use `CarbonList.rows`, which excludes it | --- @@ -726,9 +764,9 @@ | Metric | Count | |:-------------------------|:-------:| | Total Features | 20 | -| Total Functions | 356 | -| ✅ Covered by Playwright | 251 | -| ❌ Not covered | 105 | +| Total Functions | 390 | +| ✅ Covered by Playwright | 372 | +| ❌ Not covered | 11 | | ⏳ In progress | 1 | -| `N/A` Not applicable | 2 | -| **Coverage %** | **70.5%** | +| `N/A` Not applicable | 6 | +| **Coverage %** | **95.4%** | diff --git a/e2e/api/endpoints.ts b/e2e/api/endpoints.ts index d600acc786..c47a4f85be 100644 --- a/e2e/api/endpoints.ts +++ b/e2e/api/endpoints.ts @@ -11,6 +11,16 @@ export const endpoints = { dataSources: '/api/management/v1/dashboard/widget-data-sources', }, + /** + * User-facing dashboard endpoints (as consumed by the dashboard shown on `/`). + * These are filtered by the caller's role via access-control permissions, so they + * only return dashboards the current user is allowed to view. + */ + userDashboard: { + getAll: '/api/v1/dashboard', + data: (dashboardKey: string) => `/api/v1/dashboard/${dashboardKey}/data`, + }, + formFlow: { getAll: '/api/management/v1/form-flow/definition', getByKeyAndVersion: (key: string, version: number) => diff --git a/e2e/assets/building-block-archives/e2e-building-block-artwork.png b/e2e/assets/building-block-archives/e2e-building-block-artwork.png new file mode 100644 index 0000000000..5d6e331d35 Binary files /dev/null and b/e2e/assets/building-block-archives/e2e-building-block-artwork.png differ diff --git a/e2e/assets/building-block-archives/e2e-building-block-import-invalid-file.zip b/e2e/assets/building-block-archives/e2e-building-block-import-invalid-file.zip new file mode 100644 index 0000000000..bac87c658b Binary files /dev/null and b/e2e/assets/building-block-archives/e2e-building-block-import-invalid-file.zip differ diff --git a/e2e/assets/building-block-archives/e2e-building-block-import-success_1.0.0.zip b/e2e/assets/building-block-archives/e2e-building-block-import-success_1.0.0.zip new file mode 100644 index 0000000000..e4f549250b Binary files /dev/null and b/e2e/assets/building-block-archives/e2e-building-block-import-success_1.0.0.zip differ diff --git a/e2e/assets/e2e-plugin-steps-process.bpmn b/e2e/assets/e2e-plugin-steps-process.bpmn new file mode 100644 index 0000000000..4a4b227064 --- /dev/null +++ b/e2e/assets/e2e-plugin-steps-process.bpmn @@ -0,0 +1,74 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + A process whose steps cover the element types that can carry a process link: + a service task (plugin only), a user task (form / form-flow / plugin) and a + call activity (plugin / building block). Used by the building block plugin + integration e2e test, which needs real shapes in the diagram so the link + wizard can be opened from the properties panel. + + Every step carries an implementation (expression / form key / called element). + `ProcessDefinitionValidator` warns about steps that have neither a process link + nor an implementation, and the building block process upload rejects a diagram + on warnings too — so without these attributes the upload would 422. +--> +<bpmn:definitions xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL" xmlns:bpmndi="http://www.omg.org/spec/BPMN/20100524/DI" xmlns:dc="http://www.omg.org/spec/DD/20100524/DC" xmlns:di="http://www.omg.org/spec/DD/20100524/DI" xmlns:operaton="http://operaton.org/schema/1.0/bpmn" targetNamespace="http://bpmn.io/schema/bpmn" id="Definitions_PluginSteps"> + <bpmn:process id="e2e-plugin-steps-process" name="E2E Plugin Steps Process" isExecutable="true"> + <bpmn:startEvent id="StartEvent_1" name="Start"> + <bpmn:outgoing>Flow_1</bpmn:outgoing> + </bpmn:startEvent> + <bpmn:serviceTask id="ServiceTask_1" name="Service step" operaton:expression="${true}"> + <bpmn:incoming>Flow_1</bpmn:incoming> + <bpmn:outgoing>Flow_2</bpmn:outgoing> + </bpmn:serviceTask> + <bpmn:userTask id="UserTask_1" name="User step" operaton:formKey="e2e-plugin-steps-form"> + <bpmn:incoming>Flow_2</bpmn:incoming> + <bpmn:outgoing>Flow_3</bpmn:outgoing> + </bpmn:userTask> + <bpmn:callActivity id="CallActivity_1" name="Call step" calledElement="e2e-test-process"> + <bpmn:incoming>Flow_3</bpmn:incoming> + <bpmn:outgoing>Flow_4</bpmn:outgoing> + </bpmn:callActivity> + <bpmn:endEvent id="EndEvent_1" name="End"> + <bpmn:incoming>Flow_4</bpmn:incoming> + </bpmn:endEvent> + <bpmn:sequenceFlow id="Flow_1" sourceRef="StartEvent_1" targetRef="ServiceTask_1"/> + <bpmn:sequenceFlow id="Flow_2" sourceRef="ServiceTask_1" targetRef="UserTask_1"/> + <bpmn:sequenceFlow id="Flow_3" sourceRef="UserTask_1" targetRef="CallActivity_1"/> + <bpmn:sequenceFlow id="Flow_4" sourceRef="CallActivity_1" targetRef="EndEvent_1"/> + </bpmn:process> + <bpmndi:BPMNDiagram id="BPMNDiagram_1"> + <bpmndi:BPMNPlane id="BPMNPlane_1" bpmnElement="e2e-plugin-steps-process"> + <bpmndi:BPMNShape id="StartEvent_1_di" bpmnElement="StartEvent_1"> + <dc:Bounds height="36.0" width="36.0" x="173.0" y="102.0"/> + </bpmndi:BPMNShape> + <bpmndi:BPMNShape id="ServiceTask_1_di" bpmnElement="ServiceTask_1"> + <dc:Bounds height="80.0" width="100.0" x="260.0" y="80.0"/> + </bpmndi:BPMNShape> + <bpmndi:BPMNShape id="UserTask_1_di" bpmnElement="UserTask_1"> + <dc:Bounds height="80.0" width="100.0" x="420.0" y="80.0"/> + </bpmndi:BPMNShape> + <bpmndi:BPMNShape id="CallActivity_1_di" bpmnElement="CallActivity_1"> + <dc:Bounds height="80.0" width="100.0" x="580.0" y="80.0"/> + </bpmndi:BPMNShape> + <bpmndi:BPMNShape id="EndEvent_1_di" bpmnElement="EndEvent_1"> + <dc:Bounds height="36.0" width="36.0" x="742.0" y="102.0"/> + </bpmndi:BPMNShape> + <bpmndi:BPMNEdge id="Flow_1_di" bpmnElement="Flow_1"> + <di:waypoint x="209" y="120"/> + <di:waypoint x="260" y="120"/> + </bpmndi:BPMNEdge> + <bpmndi:BPMNEdge id="Flow_2_di" bpmnElement="Flow_2"> + <di:waypoint x="360" y="120"/> + <di:waypoint x="420" y="120"/> + </bpmndi:BPMNEdge> + <bpmndi:BPMNEdge id="Flow_3_di" bpmnElement="Flow_3"> + <di:waypoint x="520" y="120"/> + <di:waypoint x="580" y="120"/> + </bpmndi:BPMNEdge> + <bpmndi:BPMNEdge id="Flow_4_di" bpmnElement="Flow_4"> + <di:waypoint x="680" y="120"/> + <di:waypoint x="742" y="120"/> + </bpmndi:BPMNEdge> + </bpmndi:BPMNPlane> + </bpmndi:BPMNDiagram> +</bpmn:definitions> diff --git a/e2e/constants/access-control-test-ids.ts b/e2e/constants/access-control-test-ids.ts new file mode 100644 index 0000000000..43160b16a8 --- /dev/null +++ b/e2e/constants/access-control-test-ids.ts @@ -0,0 +1,20 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// Import directly from the constants files — importing from public-api pulls in +// Angular component code that Node cannot parse at test runtime. +export {ACCESS_CONTROL_EDITOR_TEST_IDS} from '../../frontend/projects/valtimo/access-control-management/src/lib/constants/access-control-editor.test-ids'; +export {ACCESS_CONTROL_ROLES_TEST_IDS} from '../../frontend/projects/valtimo/access-control-management/src/lib/constants/access-control-roles.test-ids'; diff --git a/e2e/constants/building-block-management-test-ids.ts b/e2e/constants/building-block-management-test-ids.ts new file mode 100644 index 0000000000..f06756ae0f --- /dev/null +++ b/e2e/constants/building-block-management-test-ids.ts @@ -0,0 +1,13 @@ +export { + BUILDING_BLOCK_MANAGEMENT_LIST_TEST_IDS, + BUILDING_BLOCK_MANAGEMENT_CREATE_TEST_IDS, + BUILDING_BLOCK_MANAGEMENT_UPLOAD_TEST_IDS, + BUILDING_BLOCK_MANAGEMENT_DETAIL_TEST_IDS, + BUILDING_BLOCK_MANAGEMENT_PROCESSES_TEST_IDS, + BUILDING_BLOCK_MANAGEMENT_PROCESS_UPLOAD_TEST_IDS, + BUILDING_BLOCK_MANAGEMENT_METADATA_TEST_IDS, + BUILDING_BLOCK_MANAGEMENT_ARTWORK_TEST_IDS, + BUILDING_BLOCK_MANAGEMENT_PLUGINS_TEST_IDS, + BUILDING_BLOCK_MANAGEMENT_DETAIL_ACTIONS_TEST_IDS, + BUILDING_BLOCK_VERSION_OPTION_TEST_ID_PREFIX, +} from '../../frontend/projects/valtimo/building-block-management/src/lib/constants/building-block-management.test-ids'; diff --git a/e2e/constants/components-test-ids.ts b/e2e/constants/components-test-ids.ts index e84155dd6c..ecac28224a 100644 --- a/e2e/constants/components-test-ids.ts +++ b/e2e/constants/components-test-ids.ts @@ -20,6 +20,7 @@ export { STEPPER_FOOTER_STEP_TEST_IDS, JSON_EDITOR_TEST_IDS, ARBITRARY_AMOUNT_VALUE_TEST_IDS, + MULTI_INPUT_TEST_IDS, VALUE_PATH_SELECTOR_DROPDOWN_VALUE_TEST_IDS, KEY_VALUE_TEST_IDS, KEY_VALUE_PATH_SELECTOR_TEST_IDS, @@ -28,4 +29,8 @@ export { SINGLE_VALUE_TEST_IDS, AUTO_KEY_INPUT_TEST_IDS, RIGHT_SIDEBAR_TEST_IDS, + SCHEMA_EDITOR_TEST_IDS, + SCHEMA_EDITOR_REQUIRED_PROPERTY_TEST_ID_PREFIX, + SEARCH_FIELDS_TEST_IDS, + SEARCH_FIELD_TEST_ID_PREFIX, } from '../../frontend/projects/valtimo/components/src/lib/constants/components.test-ids'; diff --git a/e2e/constants/dashboard-test-ids.ts b/e2e/constants/dashboard-test-ids.ts new file mode 100644 index 0000000000..e4b443502a --- /dev/null +++ b/e2e/constants/dashboard-test-ids.ts @@ -0,0 +1,20 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { + DASHBOARD_TEST_IDS, + DASHBOARD_WIDGET_TEST_ID_PREFIX, +} from '../../frontend/projects/valtimo/dashboard/src/lib/constants/dashboard.test-ids'; diff --git a/e2e/constants/decision-test-ids.ts b/e2e/constants/decision-test-ids.ts index acbf7bfaa0..b096b88fa1 100644 --- a/e2e/constants/decision-test-ids.ts +++ b/e2e/constants/decision-test-ids.ts @@ -1,5 +1,6 @@ export { DECISION_LIST_TEST_IDS, DECISION_UPLOAD_TEST_IDS, + DECISION_FORM_TEST_IDS, DECISION_MODELER_TEST_IDS, } from '../../frontend/projects/valtimo/decision/src/lib/constants/decision.test-ids'; diff --git a/e2e/constants/index.ts b/e2e/constants/index.ts index 47a564bb3b..179771ebe1 100644 --- a/e2e/constants/index.ts +++ b/e2e/constants/index.ts @@ -1,10 +1,16 @@ +export * from './access-control-test-ids'; +export * from './building-block-management-test-ids'; export * from './case-management-test-ids'; export * from './dashboard-management-test-ids'; +export * from './dashboard-test-ids'; export * from './components-test-ids'; export * from './decision-test-ids'; export * from './form-management-test-ids'; export * from './iko-test-ids'; export * from './layout-test-ids'; +export * from './logging-test-ids'; +export * from './object-management-test-ids'; +export * from './object-test-ids'; export * from './plugin-test-ids'; export * from './process-link-test-ids'; export * from './process-management-test-ids'; diff --git a/e2e/constants/layout-test-ids.ts b/e2e/constants/layout-test-ids.ts index b049d6b351..3fa67b838e 100644 --- a/e2e/constants/layout-test-ids.ts +++ b/e2e/constants/layout-test-ids.ts @@ -15,6 +15,7 @@ */ export { + TRANSLATION_MANAGEMENT_TEST_IDS, WIDGET_EDITOR_TEST_IDS, WIDGET_WIZARD_TEST_IDS, WIDGET_WIZARD_TYPE_TEST_IDS, diff --git a/e2e/constants/logging-test-ids.ts b/e2e/constants/logging-test-ids.ts new file mode 100644 index 0000000000..0acbf1e58e --- /dev/null +++ b/e2e/constants/logging-test-ids.ts @@ -0,0 +1,23 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// Import directly from the constants file — importing from public_api pulls in Angular +// component code that Node cannot parse at test runtime. +export { + LOGGING_LIST_TEST_IDS, + LOG_SEARCH_TEST_IDS, + LOG_DETAILS_TEST_IDS, +} from '../../frontend/projects/valtimo/logging/src/lib/constants/logging.test-ids'; diff --git a/e2e/constants/object-management-test-ids.ts b/e2e/constants/object-management-test-ids.ts new file mode 100644 index 0000000000..8c9474ee7c --- /dev/null +++ b/e2e/constants/object-management-test-ids.ts @@ -0,0 +1,21 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { + OBJECT_MANAGEMENT_LIST_TEST_IDS, + OBJECT_MANAGEMENT_MODAL_TEST_IDS, + OBJECT_MANAGEMENT_DETAIL_TEST_IDS, +} from '../../frontend/projects/valtimo/object-management/src/lib/constants/object-management.test-ids'; diff --git a/e2e/constants/object-test-ids.ts b/e2e/constants/object-test-ids.ts new file mode 100644 index 0000000000..274f84d2d7 --- /dev/null +++ b/e2e/constants/object-test-ids.ts @@ -0,0 +1,20 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { + OBJECT_LIST_TEST_IDS, + OBJECT_DETAIL_TEST_IDS, +} from '../../frontend/projects/valtimo/object/src/lib/constants/object.test-ids'; diff --git a/e2e/constants/plugin-test-ids.ts b/e2e/constants/plugin-test-ids.ts index cb80eadcda..a1b9626e94 100644 --- a/e2e/constants/plugin-test-ids.ts +++ b/e2e/constants/plugin-test-ids.ts @@ -15,5 +15,7 @@ export { SMART_DOCUMENTS_CONFIGURATION_TEST_IDS, VERZOEK_CONFIGURATION_TEST_IDS, ZAKEN_API_CONFIGURATION_TEST_IDS, + ZAKEN_API_CREATE_ZAAK_ACTION_TEST_IDS, PLUGIN_CATALOG_TEST_IDS, + PLUGIN_EDIT_MODAL_TEST_IDS, } from '../../frontend/projects/valtimo/plugin/src/lib/constants/plugin.test-ids'; diff --git a/e2e/constants/process-link-test-ids.ts b/e2e/constants/process-link-test-ids.ts index ebdc005375..ea22007e5d 100644 --- a/e2e/constants/process-link-test-ids.ts +++ b/e2e/constants/process-link-test-ids.ts @@ -1,3 +1,11 @@ export { BB_MAPPINGS_TEST_IDS, + PLUGIN_ACTION_CONFIGURATION_TEST_IDS, + PROCESS_LINK_MODAL_TEST_IDS, + PROCESS_LINK_TYPE_BUTTON_TEST_ID_PREFIX, + PROCESS_LINK_TYPE_CHOOSER_TEST_IDS, + SELECT_PLUGIN_ACTION_TEST_IDS, + SELECT_PLUGIN_ACTION_TILE_TEST_ID_PREFIX, + SELECT_PLUGIN_CONFIGURATION_ROW_TEST_ID_PREFIX, + SELECT_PLUGIN_CONFIGURATION_TEST_IDS, } from '../../frontend/projects/valtimo/process-link/src/lib/constants/process-link.test-ids'; diff --git a/e2e/constants/process-management-test-ids.ts b/e2e/constants/process-management-test-ids.ts index 36ca97db47..db7f285347 100644 --- a/e2e/constants/process-management-test-ids.ts +++ b/e2e/constants/process-management-test-ids.ts @@ -1,4 +1,6 @@ export { PROCESS_MANAGEMENT_LIST_TEST_IDS, + PROCESS_MANAGEMENT_UPLOAD_TEST_IDS, PROCESS_MANAGEMENT_BUILDER_TEST_IDS, + PROCESS_LINK_PANEL_TEST_IDS, } from '../../frontend/projects/valtimo/process-management/src/lib/constants/process-management.test-ids'; diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts index c887b74823..784314ec27 100644 --- a/e2e/playwright.config.ts +++ b/e2e/playwright.config.ts @@ -30,7 +30,7 @@ export default defineConfig({ }, baseURL: process.env.qa_url ?? 'http://localhost:4200', screenshot: process.env.CI ? 'only-on-failure' : 'on', - video: process.env.CI ? 'on' : 'on-first-retry', + video: process.env.CI ? 'retain-on-failure' : 'on-first-retry', trace: { mode: process.env.CI ? 'retain-on-failure' : 'on-first-retry', snapshots: true, diff --git a/e2e/shared/bpmn-modeler/bpmn-modeler.utils.ts b/e2e/shared/bpmn-modeler/bpmn-modeler.utils.ts new file mode 100644 index 0000000000..92155ad48a --- /dev/null +++ b/e2e/shared/bpmn-modeler/bpmn-modeler.utils.ts @@ -0,0 +1,166 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {expect, type Locator, type Page} from '@playwright/test'; + +/** + * Wrapper for the BPMN modeler used by `valtimo-process-management-builder` — + * the canvas (bpmn-js) and the properties panel (bpmn-js-properties-panel). + * + * Both are third-party libraries that render their own DOM, so this is the one + * place in the suite that has to rely on their class names and `data-*` + * contracts instead of `data-test-id`. The Valtimo-owned parts of the panel (the + * "Process link" group) *do* carry test ids — see `PROCESS_LINK_PANEL_TEST_IDS`. + */ +export class BpmnModeler { + constructor(private readonly page: Page) {} + + // ─── Canvas ─────────────────────────────────────────────────────── + + get container(): Locator { + return this.page.locator('.bpmn__container'); + } + + get palette(): Locator { + return this.page.locator('.djs-palette:visible'); + } + + /** + * Both the editable modeler and the read-only viewer render + * `.bpmn__modeler-canvas`; the inactive one sits inside a `display: none` + * parent, so `:visible` resolves to whichever is currently in use. + */ + get activeCanvas(): Locator { + return this.page.locator('.bpmn__modeler-canvas:visible'); + } + + elementShape(elementId: string): Locator { + return this.activeCanvas.locator(`g[data-element-id="${elementId}"]`); + } + + get taskShapes(): Locator { + return this.activeCanvas.locator('g.djs-shape[data-element-id^="Activity_"]'); + } + + get appendTaskContextPadAction(): Locator { + return this.page.locator('.djs-context-pad [data-action="append.append-task"]'); + } + + /** Wait until the diagram is rendered and interactive. */ + async waitForLoaded() { + await expect(this.container).toBeVisible(); + await expect(this.palette).toBeVisible(); + } + + /** + * Select an element so the properties panel switches to it. + * + * Confirmed through the panel's ID entry, which always holds the id of the + * selected element. The panel *header* cannot be used: it renders the element's + * name and falls back to the id only while the element is unnamed. The click is + * retried because a first click right after the diagram loads is sometimes only + * registered as a hover, leaving the selection unchanged. + */ + async selectElement(elementId: string) { + const shape = this.elementShape(elementId); + await expect(shape).toBeVisible(); + + await expect(async () => { + await shape.click(); + await expect(this.idInput).toHaveValue(elementId, {timeout: 2_000}); + }).toPass({timeout: 20_000}); + } + + /** Append a task to an element through its context pad. */ + async appendTaskTo(elementId: string): Promise<string> { + const before = await this.taskShapes.count(); + await this.elementShape(elementId).click(); + await expect(this.appendTaskContextPadAction).toBeVisible(); + await this.appendTaskContextPadAction.click(); + // The new shape enters direct-editing mode; leave it to commit the change. + await this.page.keyboard.press('Escape'); + await expect(this.taskShapes).toHaveCount(before + 1); + return (await this.taskShapes.last().getAttribute('data-element-id')) ?? ''; + } + + // ─── Properties panel ───────────────────────────────────────────── + + /** Panel of the active (editable or read-only) modeler. */ + get panel(): Locator { + return this.page.locator('.bpmn__modeler-panel:visible').first(); + } + + /** + * The element type shown in the panel header, e.g. "Start Event". + * + * Read through the `title` attribute: the rendered text is uppercased by CSS, + * so `innerText` would return "START EVENT". + */ + get panelHeaderType(): Locator { + return this.panel.locator('.bio-properties-panel-header-type'); + } + + /** The element name — or its id while the element is still unnamed. */ + get panelHeaderLabel(): Locator { + return this.panel.locator('.bio-properties-panel-header-label'); + } + + /** A collapsible group of the panel, located by its group title. */ + group(title: string): Locator { + return this.panel + .locator('.bio-properties-panel-group') + .filter({has: this.page.locator(`[data-title="${title}"]`)}); + } + + /** Titles of the groups the panel currently offers, in render order. */ + async groupTitles(): Promise<string[]> { + return this.panel + .locator('.bio-properties-panel-group-header-title') + .evaluateAll(headers => headers.map(header => header.getAttribute('title') ?? '')); + } + + /** + * Expand a group. Every group starts collapsed, and a collapsed group keeps its + * entries in the DOM but hidden, so they have to be expanded before they can be + * read or filled. + */ + async expandGroup(title: string) { + const group = this.group(title); + await expect(group).toBeVisible(); + await group.locator('.bio-properties-panel-group-header').click(); + await expect(group.locator('.bio-properties-panel-group-entries')).toBeVisible(); + } + + /** An entry of the (expanded) panel, located by its bpmn-js entry id. */ + entryInput(entryId: string): Locator { + return this.panel.locator(`#bio-properties-panel-${entryId}`); + } + + get nameInput(): Locator { + return this.entryInput('name'); + } + + get idInput(): Locator { + return this.entryInput('id'); + } + + /** Rename the selected element through the panel's General group. */ + async renameSelectedElement(name: string) { + await this.expandGroup('General'); + await this.nameInput.fill(name); + await expect(this.panelHeaderLabel).toHaveAttribute('title', name); + } +} diff --git a/e2e/shared/carbon-list/carbon-list.utils.ts b/e2e/shared/carbon-list/carbon-list.utils.ts index e76ad637ab..32566a8d80 100644 --- a/e2e/shared/carbon-list/carbon-list.utils.ts +++ b/e2e/shared/carbon-list/carbon-list.utils.ts @@ -68,10 +68,24 @@ export class CarbonListRow { * Note: menu items are rendered in a portal/overlay, so we use page-scoped getByRole. */ async clickAction(actionName: string) { - await this.locator.locator('.v-overflow-menu__trigger').click(); + await this.openActionMenu(); await this.page.getByRole('menu').getByRole('menuitem', {name: actionName}).click(); } + /** + * Open the overflow action menu without selecting anything — for asserting which actions are + * offered, and whether they are enabled. + */ + async openActionMenu() { + await this.locator.locator('.v-overflow-menu__trigger').click(); + await expect(this.page.getByRole('menu')).toBeVisible(); + } + + /** An item in this row's (already open) overflow menu. */ + actionMenuItem(actionName: string): Locator { + return this.page.getByRole('menu').getByRole('menuitem', {name: actionName}); + } + // ─── Selection (Checkboxes) ───────────────────────────────────── async select() { diff --git a/e2e/shared/carbon-toggle/carbon-toggle.utils.ts b/e2e/shared/carbon-toggle/carbon-toggle.utils.ts new file mode 100644 index 0000000000..c216b4e5c2 --- /dev/null +++ b/e2e/shared/carbon-toggle/carbon-toggle.utils.ts @@ -0,0 +1,84 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {expect, type Locator} from '@playwright/test'; + +/** + * Wrapper for Carbon's `cds-toggle`. + * + * Two things make these toggles awkward to drive from a test: + * + * - The clickable target is not obvious. The underlying `role="switch"` input is + * rendered 1x1px and visually hidden, and clicking the `.cds--toggle__switch` + * graphic is a no-op in some Carbon versions. The reliable targets are the + * `cds-toggle` host itself and its `<label>`, so `set()` alternates between + * them until the state actually flips. + * - The initial state is data-driven (it reflects whatever the backend returned), + * so a blind `click()` toggles in an unknown direction. `set()` always reads + * the current state first and only clicks when a change is needed, which also + * makes it idempotent. + */ +export class CarbonToggle { + constructor(private readonly host: Locator) {} + + /** The visually hidden `role="switch"` input that carries the checked state. */ + get switchControl(): Locator { + return this.host.getByRole('switch'); + } + + async isChecked(): Promise<boolean> { + return this.switchControl.isChecked(); + } + + async assertChecked(checked: boolean) { + if (checked) { + await expect(this.switchControl).toBeChecked(); + } else { + await expect(this.switchControl).not.toBeChecked(); + } + } + + async assertDisabled() { + await expect(this.switchControl).toBeDisabled(); + } + + async assertEnabled() { + await expect(this.switchControl).toBeEnabled(); + } + + /** + * Drive the toggle to `checked`. No-op when it is already in that state. + */ + async set(checked: boolean) { + const targets = [this.host, this.host.locator('label').first()]; + let attempt = 0; + + await expect(async () => { + if ((await this.isChecked()) !== checked) { + await targets[attempt++ % targets.length].click(); + } + expect(await this.isChecked()).toBe(checked); + }).toPass({timeout: 15_000}); + } + + async enable() { + await this.set(true); + } + + async disable() { + await this.set(false); + } +} diff --git a/e2e/shared/overflow-menu/overflow-menu.utils.ts b/e2e/shared/overflow-menu/overflow-menu.utils.ts new file mode 100644 index 0000000000..cc11c1abb9 --- /dev/null +++ b/e2e/shared/overflow-menu/overflow-menu.utils.ts @@ -0,0 +1,69 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {expect, type Locator, type Page} from '@playwright/test'; + +/** + * Wrapper for the Valtimo `v-overflow-menu` component (the "More" menus in + * admin detail headers and carbon-list rows). + * + * The menu content is rendered in an overlay outside the trigger, so options are + * looked up page-wide rather than inside the trigger element. Options are only + * present in the DOM while the menu is open. + */ +export class OverflowMenu { + constructor( + private readonly page: Page, + private readonly triggerTestId: string + ) {} + + get trigger(): Locator { + return this.page.getByTestId(this.triggerTestId); + } + + get menu(): Locator { + return this.page.getByRole('menu'); + } + + option(optionTestId: string): Locator { + return this.page.getByTestId(optionTestId); + } + + async open() { + await expect(this.trigger).toBeEnabled(); + await this.trigger.click(); + await expect(this.menu).toBeVisible(); + } + + async close() { + await this.page.keyboard.press('Escape'); + await expect(this.menu).not.toBeVisible(); + } + + /** Open the menu and click one of its options. */ + async selectOption(optionTestId: string) { + await this.open(); + const option = this.option(optionTestId); + await expect(option).toBeVisible(); + await option.click(); + } + + /** Labels of the options currently offered, in render order. */ + async optionLabels(): Promise<string[]> { + const labels = await this.menu.getByRole('menuitem').allInnerTexts(); + return labels.map(label => label.trim()); + } +} diff --git a/e2e/shared/process-link-wizard/process-link-wizard.utils.ts b/e2e/shared/process-link-wizard/process-link-wizard.utils.ts new file mode 100644 index 0000000000..8ecb34bca4 --- /dev/null +++ b/e2e/shared/process-link-wizard/process-link-wizard.utils.ts @@ -0,0 +1,237 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {expect, type Locator, type Page} from '@playwright/test'; +import { + PLUGIN_ACTION_CONFIGURATION_TEST_IDS, + PROCESS_LINK_MODAL_TEST_IDS, + PROCESS_LINK_TYPE_BUTTON_TEST_ID_PREFIX, + PROCESS_LINK_TYPE_CHOOSER_TEST_IDS, + SELECT_PLUGIN_ACTION_TEST_IDS, + SELECT_PLUGIN_ACTION_TILE_TEST_ID_PREFIX, + SELECT_PLUGIN_CONFIGURATION_ROW_TEST_ID_PREFIX, + SELECT_PLUGIN_CONFIGURATION_TEST_IDS, +} from '../../constants'; + +/** + * Wrapper for `valtimo-process-link-modal` — the wizard that links a BPMN step to + * a form, form flow, plugin action, building block or UI component. + * + * The wizard adapts to the step it was opened for: when the activity type offers + * a single link type the chooser is skipped entirely (a service task, for + * instance, lands straight on the plugin selection step), and the footer swaps + * Next for Complete on the last step. + */ +export class ProcessLinkWizard { + constructor(private readonly page: Page) {} + + // ─── Shell ──────────────────────────────────────────────────────── + + get host(): Locator { + return this.page.locator('valtimo-process-link-modal'); + } + + /** Renders `Process step: <name>`. */ + get heading(): Locator { + return this.page.getByTestId(PROCESS_LINK_MODAL_TEST_IDS.heading); + } + + get progressIndicator(): Locator { + return this.page.getByTestId(PROCESS_LINK_MODAL_TEST_IDS.progressIndicator); + } + + /** + * Shown while editing an existing link. Inside a building block it reads + * `Plugin configuration: -`: the link points at a plugin *definition*, so no + * configuration is bound to it yet. + */ + get pluginConfigurationLabel(): Locator { + return this.page.getByTestId(PROCESS_LINK_MODAL_TEST_IDS.pluginConfigurationLabel); + } + + get cancelButton(): Locator { + return this.page.getByTestId(PROCESS_LINK_MODAL_TEST_IDS.cancelButton); + } + + get backButton(): Locator { + return this.page.getByTestId(PROCESS_LINK_MODAL_TEST_IDS.backButton); + } + + get nextButton(): Locator { + return this.page.getByTestId(PROCESS_LINK_MODAL_TEST_IDS.nextButton); + } + + get completeButton(): Locator { + return this.page.getByTestId(PROCESS_LINK_MODAL_TEST_IDS.completeButton); + } + + get unlinkButton(): Locator { + return this.page.getByTestId(PROCESS_LINK_MODAL_TEST_IDS.unlinkButton); + } + + async waitForOpen() { + await expect(this.heading).toBeVisible(); + } + + async waitForClosed() { + await expect(this.heading).not.toBeVisible(); + } + + async cancel() { + await this.cancelButton.click(); + await this.waitForClosed(); + } + + async next() { + await expect(this.nextButton).toBeEnabled(); + await this.nextButton.click(); + } + + async back() { + await expect(this.backButton).toBeEnabled(); + await this.backButton.click(); + } + + async complete() { + await expect(this.completeButton).toBeEnabled(); + await this.completeButton.click(); + await this.waitForClosed(); + } + + /** Titles of the wizard steps, as rendered by the progress indicator. */ + async stepTitles(): Promise<string[]> { + const text = await this.progressIndicator.innerText(); + // Each step renders its state ("Current" / "Complete" / "Incomplete") above + // its title, plus the chosen value once a step is done. + return text + .split('\n') + .map(line => line.trim()) + .filter(Boolean); + } + + // ─── Choose link type step ──────────────────────────────────────── + + get typeChooser(): Locator { + return this.page.getByTestId(PROCESS_LINK_TYPE_CHOOSER_TEST_IDS.container); + } + + /** @param processLinkType e.g. `form`, `form-flow`, `plugin`, `building-block`. */ + typeButton(processLinkType: string): Locator { + return this.page.getByTestId(`${PROCESS_LINK_TYPE_BUTTON_TEST_ID_PREFIX}${processLinkType}`); + } + + /** The link types this step offers, as `processLinkType` ids. */ + async offeredLinkTypes(): Promise<string[]> { + return this.page + .locator(`[data-test-id^="${PROCESS_LINK_TYPE_BUTTON_TEST_ID_PREFIX}"]`) + .evaluateAll((buttons, prefix) => + buttons.map(button => (button.getAttribute('data-test-id') ?? '').replace(prefix, '')), + PROCESS_LINK_TYPE_BUTTON_TEST_ID_PREFIX); + } + + async chooseLinkType(processLinkType: string) { + const button = this.typeButton(processLinkType); + await expect(button).toBeEnabled(); + await button.click(); + } + + // ─── Select plugin step ─────────────────────────────────────────── + + get pluginStep(): Locator { + return this.page.getByTestId(SELECT_PLUGIN_CONFIGURATION_TEST_IDS.container); + } + + get pluginList(): Locator { + return this.page.getByTestId(SELECT_PLUGIN_CONFIGURATION_TEST_IDS.list); + } + + get pluginRows(): Locator { + return this.page.locator(`[data-test-id^="${SELECT_PLUGIN_CONFIGURATION_ROW_TEST_ID_PREFIX}"]`); + } + + /** + * @param id plugin *definition* key in a building block, plugin *configuration* + * id in a case — the step lists definitions in the one and configurations in + * the other. + */ + pluginRow(id: string): Locator { + return this.page.getByTestId(`${SELECT_PLUGIN_CONFIGURATION_ROW_TEST_ID_PREFIX}${id}`); + } + + /** The ids offered by the plugin selection step. */ + async offeredPluginIds(): Promise<string[]> { + return this.pluginRows.evaluateAll((rows, prefix) => + rows.map(row => (row.getAttribute('data-test-id') ?? '').replace(prefix, '')), + SELECT_PLUGIN_CONFIGURATION_ROW_TEST_ID_PREFIX); + } + + async selectPlugin(id: string) { + const row = this.pluginRow(id); + await expect(row).toBeVisible(); + await row.click(); + } + + // ─── Choose action step ─────────────────────────────────────────── + + get actionStep(): Locator { + return this.page.getByTestId(SELECT_PLUGIN_ACTION_TEST_IDS.container); + } + + get noActionsMessage(): Locator { + return this.page.getByTestId(SELECT_PLUGIN_ACTION_TEST_IDS.noActionsMessage); + } + + get actionTiles(): Locator { + return this.page.locator(`[data-test-id^="${SELECT_PLUGIN_ACTION_TILE_TEST_ID_PREFIX}"]`); + } + + actionTile(actionKey: string): Locator { + return this.page.getByTestId(`${SELECT_PLUGIN_ACTION_TILE_TEST_ID_PREFIX}${actionKey}`); + } + + /** The action keys the step offers. */ + async offeredActionKeys(): Promise<string[]> { + return this.actionTiles.evaluateAll((tiles, prefix) => + tiles.map(tile => (tile.getAttribute('data-test-id') ?? '').replace(prefix, '')), + SELECT_PLUGIN_ACTION_TILE_TEST_ID_PREFIX); + } + + async selectAction(actionKey: string) { + const tile = this.actionTile(actionKey); + await expect(tile).toBeVisible(); + await tile.click(); + } + + // ─── Configure action step ──────────────────────────────────────── + + get actionConfigurationStep(): Locator { + return this.page.getByTestId(PLUGIN_ACTION_CONFIGURATION_TEST_IDS.container); + } + + /** + * Advance a plugin link from the plugin selection step to the configuration + * step: pick the plugin, then the action. + */ + async advanceToActionConfiguration(pluginId: string, actionKey: string) { + await this.selectPlugin(pluginId); + await this.next(); + await expect(this.actionStep).toBeVisible(); + + await this.selectAction(actionKey); + await this.next(); + await expect(this.completeButton).toBeVisible(); + } +} diff --git a/e2e/shared/schema-editor/schema-editor.utils.ts b/e2e/shared/schema-editor/schema-editor.utils.ts new file mode 100644 index 0000000000..862af5ccb2 --- /dev/null +++ b/e2e/shared/schema-editor/schema-editor.utils.ts @@ -0,0 +1,219 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {expect, type Locator, type Page} from '@playwright/test'; +import { + CONFIRMATION_MODAL_TEST_IDS, + SCHEMA_EDITOR_REQUIRED_PROPERTY_TEST_ID_PREFIX, + SCHEMA_EDITOR_TEST_IDS, +} from '../../constants'; + +/** + * Wrapper for `valtimo-schema-editor` (`@valtimo/components`), the JSON-schema + * editor used on the document tabs of case and building block definitions. + * + * The editor embeds `vanilla-jsoneditor`, a third-party component whose controls + * cannot carry `data-test-id` attributes. Those parts (mode switch, search, + * parse-error panel, tree nodes) are therefore addressed by their accessible + * name, title or the library's own class names; everything Valtimo owns — the + * save button, the required-fields panel and its checkboxes — uses test ids. + */ +export class SchemaEditor { + constructor(private readonly page: Page) {} + + // ─── Root ───────────────────────────────────────────────────────── + + get root(): Locator { + return this.page.getByTestId(SCHEMA_EDITOR_TEST_IDS.editor); + } + + get saveButton(): Locator { + return this.page.getByTestId(SCHEMA_EDITOR_TEST_IDS.saveButton); + } + + get manageRequiredFieldsButton(): Locator { + return this.page.getByTestId(SCHEMA_EDITOR_TEST_IDS.manageRequiredFieldsButton); + } + + get confirmSaveButton(): Locator { + return this.page.getByTestId(CONFIRMATION_MODAL_TEST_IDS.confirmButton); + } + + // ─── Tree mode (third-party markup) ─────────────────────────────── + + /** Property/attribute names rendered by the tree view. */ + get keys(): Locator { + return this.root.locator('.jse-key'); + } + + /** Values rendered by the tree view — field types and descriptions included. */ + get values(): Locator { + return this.root.locator('.jse-value'); + } + + async keyTexts(): Promise<string[]> { + return (await this.keys.allInnerTexts()).map(text => text.trim()); + } + + async valueTexts(): Promise<string[]> { + return (await this.values.allInnerTexts()).map(text => text.trim()); + } + + async waitForLoaded() { + await expect(this.root).toBeVisible(); + await expect(this.keys.first()).toBeVisible(); + } + + // ─── Mode switching ─────────────────────────────────────────────── + + async switchToTextMode() { + await this.root.getByRole('button', {name: 'text', exact: true}).click(); + await expect(this.textContent).toBeVisible(); + } + + async switchToTreeMode() { + await this.root.getByRole('button', {name: 'tree', exact: true}).click(); + await expect(this.keys.first()).toBeVisible(); + } + + /** The CodeMirror surface of the editor's text mode. */ + get textContent(): Locator { + return this.root.locator('.cm-content').first(); + } + + // ─── Editing ────────────────────────────────────────────────────── + + /** + * Replace the whole document with `content` by typing it in text mode. + * + * Typing rather than pasting is deliberate: the editor only emits its change + * event on real input, and a clipboard paste does not reliably reach the + * CodeMirror instance in headless Chromium. + */ + async replaceContent(content: unknown) { + await this.switchToTextMode(); + await this.textContent.click(); + await this.page.keyboard.press('ControlOrMeta+a'); + await this.page.keyboard.type(typeof content === 'string' ? content : JSON.stringify(content)); + } + + /** Save and confirm. Returns nothing — await the API response in the caller. */ + async save() { + await expect(this.saveButton).toBeEnabled(); + await this.saveButton.click(); + await expect(this.confirmSaveButton).toBeVisible(); + await this.confirmSaveButton.click(); + } + + // ─── Parse errors ───────────────────────────────────────────────── + + /** Panel the embedded editor shows when the text is not parseable JSON. */ + get parseError(): Locator { + return this.root.locator('.jse-message.jse-error'); + } + + get autoRepairButton(): Locator { + return this.root.getByRole('button', {name: 'Auto repair'}); + } + + // ─── Search ─────────────────────────────────────────────────────── + + get searchBox(): Locator { + return this.root.locator('.jse-search-box'); + } + + get searchInput(): Locator { + return this.searchBox.locator('input').first(); + } + + /** Matches highlighted in the tree for the active search term. */ + get searchHighlights(): Locator { + return this.root.locator('.jse-highlight'); + } + + async openSearch() { + await this.root.getByTitle(/^Search/).click(); + await expect(this.searchInput).toBeVisible(); + } + + async closeSearch() { + await this.page.keyboard.press('Escape'); + await expect(this.searchBox).toHaveCount(0); + } + + async search(term: string) { + await this.searchInput.fill(term); + } + + /** + * The editor's match counter: `"<current>/<total>"` while there are matches and + * `"0"` when there are none. + */ + async searchResultCount(): Promise<string> { + return (await this.searchBox.innerText()).trim(); + } + + // ─── Required fields panel ──────────────────────────────────────── + + get requiredFieldsPanel(): Locator { + return this.page.getByTestId(SCHEMA_EDITOR_TEST_IDS.requiredFieldsPanel); + } + + get requiredFieldsPanelCloseButton(): Locator { + return this.page.getByTestId(SCHEMA_EDITOR_TEST_IDS.requiredFieldsPanelCloseButton); + } + + /** + * Checkbox of a required field, addressed by its path within the schema: + * `requiredFieldCheckbox('applicantName')` for a root property, + * `requiredFieldCheckbox('address', 'street')` for a nested one. + */ + requiredFieldCheckbox(...path: string[]): Locator { + return this.page.getByTestId( + `${SCHEMA_EDITOR_REQUIRED_PROPERTY_TEST_ID_PREFIX}${path.join('.')}` + ); + } + + async openRequiredFieldsPanel() { + await this.manageRequiredFieldsButton.click(); + await expect(this.requiredFieldsPanel).toBeVisible(); + } + + async closeRequiredFieldsPanel() { + await this.requiredFieldsPanelCloseButton.click(); + } + + /** Carbon's `cds-checkbox` only emits `checkedChange` for a click on its label. */ + async toggleRequiredField(...path: string[]) { + await this.requiredFieldCheckbox(...path) + .locator('label') + .click(); + } + + async isRequiredFieldChecked(...path: string[]): Promise<boolean> { + return this.requiredFieldCheckbox(...path) + .locator('input') + .isChecked(); + } + + async assertRequiredFieldChecked(...path: string[]) { + await expect(this.requiredFieldCheckbox(...path).locator('input')).toBeChecked(); + } + + async assertRequiredFieldNotChecked(...path: string[]) { + await expect(this.requiredFieldCheckbox(...path).locator('input')).not.toBeChecked(); + } +} diff --git a/e2e/shared/v-select/v-select.utils.ts b/e2e/shared/v-select/v-select.utils.ts new file mode 100644 index 0000000000..ab790a7cf5 --- /dev/null +++ b/e2e/shared/v-select/v-select.utils.ts @@ -0,0 +1,80 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {expect, type Locator, type Page} from '@playwright/test'; + +/** + * Wrapper for `v-select` — Valtimo's dropdown, which renders a Carbon + * `cds-combo-box` internally. + * + * The test id belongs on the `v-select` host, so the combo box and its input are + * one level down. Options are only in the DOM while the box is open. + */ +export class VSelect { + constructor( + private readonly page: Page, + private readonly host: Locator + ) {} + + get comboBox(): Locator { + return this.host.locator('cds-combo-box'); + } + + get input(): Locator { + return this.host.locator('input'); + } + + get options(): Locator { + return this.host.getByRole('option'); + } + + async open() { + await this.comboBox.click(); + await expect(this.options.first()).toBeVisible(); + } + + async close() { + await this.page.keyboard.press('Escape'); + } + + /** Labels the dropdown currently offers. */ + async optionLabels(): Promise<string[]> { + await this.open(); + const labels = await this.options.allInnerTexts(); + await this.close(); + return labels.map(label => label.trim()); + } + + async selectByLabel(optionLabel: string) { + await this.open(); + await this.options.getByText(optionLabel, {exact: true}).click(); + await expect(this.input).toHaveValue(optionLabel); + } + + /** Select whichever option sits at `index` — useful when any valid value will do. */ + async selectByIndex(index: number): Promise<string> { + await this.open(); + const option = this.options.nth(index); + const label = (await option.innerText()).trim(); + await option.click(); + await expect(this.input).toHaveValue(label); + return label; + } + + async assertValue(expected: string) { + await expect(this.input).toHaveValue(expected); + } +} diff --git a/e2e/tests/access-control/access-control.spec.ts b/e2e/tests/access-control/access-control.spec.ts index 9e4dcc2cf9..df7e248380 100644 --- a/e2e/tests/access-control/access-control.spec.ts +++ b/e2e/tests/access-control/access-control.spec.ts @@ -15,7 +15,7 @@ */ import {expect, test} from '@playwright/test'; -import {roleTestData} from './access-control'; +import {jsonPermission, permissionTestData, roleTestData} from './access-control'; import {AccessControlPage} from './page'; test.describe('Access Control Management', () => { @@ -32,8 +32,13 @@ test.describe('Access Control Management', () => { }); test.afterAll(async ({request}) => { - // Best-effort cleanup — the role may already be deleted by the 11.8 test. - await request.delete('/api/management/v1/roles', {data: [roleTestData.key]}).catch(() => {}); + // Best-effort cleanup — the role may already be deleted by the 11.8 test. Both keys are + // listed because 11.6 renames the role and renames it back. + await request + .delete('/api/management/v1/roles', { + data: [roleTestData.key, roleTestData.updatedKey], + }) + .catch(() => {}); }); // ─── 11.1 View roles list ───────────────────────────────────────── @@ -73,6 +78,141 @@ test.describe('Access Control Management', () => { }); }); + // ─── 11.6 Edit role metadata ────────────────────────────────────── + + test.describe('11.6 — Edit role metadata', () => { + /** + * A role's only metadata is its name, and the detail route is keyed on it, so renaming is + * the whole flow. The role is renamed back afterwards so 11.7 and 11.8 keep working with + * `roleTestData.key`. + */ + test('Rename the role via the detail page', async () => { + // Act + await accessControlPage.editRoleMetadata(roleTestData.key, roleTestData.updatedKey); + + // Assert — the renamed role replaces the original in the list + await accessControlPage.goToAccessControlList(); + await accessControlPage.assertRoleExists(roleTestData.updatedKey); + await accessControlPage.assertRoleNotExists(roleTestData.key); + + // Restore the original name for the remaining tests + await accessControlPage.editRoleMetadata(roleTestData.updatedKey, roleTestData.key); + await accessControlPage.goToAccessControlList(); + await accessControlPage.assertRoleExists(roleTestData.key); + }); + }); + + // ─── 11.7 Export role configuration ─────────────────────────────── + + test.describe('11.7 — Export role configuration', () => { + test('Export the role as a single JSON file', async () => { + // Act + await accessControlPage.openExportModal(roleTestData.key); + const download = await accessControlPage.exportSelectedRoleAsSingleFile(); + + // Assert — a combined permission file is downloaded + expect(download.suggestedFilename()).toBe('combined.permission.json'); + }); + }); + + // ─── 11.9–11.13 Permissions ─────────────────────────────────────── + // + // Ordered deliberately: 11.9 switches tabs first, while the editor has no unsaved changes. + // With pending edits a "leave page" alert modal intercepts every click, so any test that + // dirties the form must save (or be the last one) before tabs are touched again. + + test.describe('11.9 — View role permissions', () => { + test('Editor, Summary and JSON editor tabs are available', async () => { + await accessControlPage.openRole(roleTestData.key); + + // Assert — all three views of the same permissions are reachable + await expect(accessControlPage.editorTab).toBeVisible(); + await expect(accessControlPage.summaryTab).toBeVisible(); + await expect(accessControlPage.jsonEditorTab).toBeVisible(); + + // The form editor is shown first and offers the add-permission action + await expect(accessControlPage.addPermissionButton).toBeVisible(); + + // Toggling to the JSON view shows the raw permissions in a Monaco editor + await accessControlPage.jsonEditorTab.click(); + await expect(accessControlPage.monacoEditor.first()).toBeVisible(); + + // ...and back to the visual editor + await accessControlPage.editorTab.click(); + await expect(accessControlPage.addPermissionButton).toBeVisible(); + }); + }); + + test.describe('11.10, 11.13 — Edit and save permissions JSON', () => { + test('Write permissions in the JSON editor and save them', async () => { + test.setTimeout(60_000); + await accessControlPage.openRole(roleTestData.key); + await accessControlPage.jsonEditorTab.click(); + + // Act + await accessControlPage.setPermissionsJson([jsonPermission]); + await accessControlPage.savePermissions(roleTestData.key); + + // Assert — the permission is persisted + await accessControlPage.assertRolePermissions( + roleTestData.key, + permissions => + permissions.some( + p => + p.resourceType === jsonPermission.resourceType && + p.actions.includes(jsonPermission.actions[0]) + ), + `permission ${jsonPermission.resourceType}/${jsonPermission.actions[0]} was not saved` + ); + }); + }); + + test.describe('11.11, 11.12, 11.13 — Configure resource permissions and conditions', () => { + /** + * One test on purpose: the permission card is transient form state. Splitting the build-up + * across tests would lose it (and under retries the page is re-navigated). + */ + test('Add a permission with a resource type, action and condition, then save', async () => { + test.setTimeout(90_000); + await accessControlPage.openRole(roleTestData.key); + + // Act — 11.11: resource type + action + await accessControlPage.addPermissionButton.click(); + await expect(accessControlPage.permissionCards.first()).toBeVisible(); + await accessControlPage.selectResourceType(permissionTestData.resourceType); + await accessControlPage.toggleAction(permissionTestData.actionLabel); + await accessControlPage.assertActionChecked(permissionTestData.actionLabel); + + // Act — 11.12: a condition on that permission + await accessControlPage.addPermissionCondition({ + field: permissionTestData.conditionField, + operator: permissionTestData.conditionOperator, + value: permissionTestData.conditionValue, + }); + + // Act — 11.13: save + await accessControlPage.savePermissions(roleTestData.key); + + // Assert — the new permission is stored with a condition attached + await accessControlPage.assertRolePermissions( + roleTestData.key, + permissions => + permissions.some( + p => + p.resourceType === permissionTestData.resourceTypeFqn && + p.actions.includes(permissionTestData.action) && + p.conditions.some( + c => + c.field === permissionTestData.conditionField && + c.operator === permissionTestData.conditionOperatorSymbol && + c.value === permissionTestData.conditionValue + ) + ), + 'no permission with a condition was saved' + ); + }); + }); + // ─── 11.8 Delete role ───────────────────────────────────────────── test.describe('11.8 — Delete role', () => { diff --git a/e2e/tests/access-control/access-control.ts b/e2e/tests/access-control/access-control.ts index 7272df4b0a..356c3eb785 100644 --- a/e2e/tests/access-control/access-control.ts +++ b/e2e/tests/access-control/access-control.ts @@ -18,3 +18,30 @@ export const roleTestData = { key: 'e2e-test-role', updatedKey: 'e2e-updated-role', }; + +/** + * A permission written straight through the JSON editor (11.10). Stored permissions have the + * shape `{resourceType, actions[], conditions[]}`. + */ +export const jsonPermission = { + resourceType: 'com.ritense.dashboard.domain.Dashboard', + actions: ['view'], + conditions: [], +}; + +/** + * Data for the form editor (11.11–11.13). + * + * `resourceType` is the dropdown label ("Name (fully.qualified.Name)"), `resourceTypeFqn` is what + * gets stored. Operator labels are words in the UI and symbols in the payload ("equals" → `==`). + */ +export const permissionTestData = { + resourceType: 'CaseDefinition (com.ritense.case_.domain.definition.CaseDefinition)', + resourceTypeFqn: 'com.ritense.case_.domain.definition.CaseDefinition', + actionLabel: 'View list', + action: 'view_list', + conditionField: 'id.key', + conditionOperator: 'equals', + conditionOperatorSymbol: '==', + conditionValue: 'bezwaar', +}; diff --git a/e2e/tests/access-control/page.ts b/e2e/tests/access-control/page.ts index 6771ab5266..7b2ae672e8 100644 --- a/e2e/tests/access-control/page.ts +++ b/e2e/tests/access-control/page.ts @@ -14,8 +14,25 @@ * limitations under the License. */ -import {APIRequestContext, expect, Page} from '@playwright/test'; +import {APIRequestContext, expect, Locator, Page} from '@playwright/test'; import {CarbonList} from '../../shared/carbon-list/carbon-list.utils'; +import {ACCESS_CONTROL_EDITOR_TEST_IDS, ACCESS_CONTROL_ROLES_TEST_IDS} from '../../constants'; +import * as ApiUtils from '../../utils/api.utils'; +import {pasteToMonacoEditor} from '../../utils/monaco.utils'; + +/** A permission as returned by `GET /api/management/v1/roles/{key}/permissions`. */ +export interface RolePermissionCondition { + type: string; + field: string; + operator: string; + value: string; +} + +export interface RolePermission { + resourceType: string; + actions: string[]; + conditions: RolePermissionCondition[]; +} export class AccessControlPage { constructor( @@ -59,37 +76,39 @@ export class AccessControlPage { return this.page.getByLabel('Table action bar').getByRole('button', {name: 'Add new role'}); } - // Role metadata modal: plain <input formControlName="key">, no data-test-ids. - // It is only rendered in manual mode — see openAddRoleModal(). + // Role metadata modal (shared by add and edit). + // The key input is only rendered in manual mode — see openAddRoleModal(). get roleNameInput() { - return this.page.locator('cds-modal input[formcontrolname="key"]'); + return this.page.getByTestId(ACCESS_CONTROL_ROLES_TEST_IDS.metadataKeyInput); } /** - * The modal defaults to a "choose from list" v-select of existing role keys; the free + * The add modal defaults to a "choose from list" v-select of existing role keys; the free * text input only appears after switching to manual mode. The same button switches back - * ("Choose from list"), so only click it while it still offers manual entry. + * ("Choose from list"), so only click it while it still offers manual entry. The edit modal + * already opens in manual mode. */ get manualEntryButton() { return this.page.locator('cds-modal').getByRole('button', {name: 'Enter manually'}); } + /** Primary button of the role metadata modal — reads "Create" when adding, "Save" when editing. */ + get roleMetadataConfirmButton() { + return this.page.getByTestId(ACCESS_CONTROL_ROLES_TEST_IDS.metadataConfirmButton); + } + get createRoleButton() { - return this.page.locator('cds-modal-footer').getByRole('button', {name: 'Create'}); + return this.roleMetadataConfirmButton; } get cancelRoleButton() { - return this.page.locator('cds-modal-footer').getByRole('button', {name: 'Cancel'}); + return this.page.getByTestId(ACCESS_CONTROL_ROLES_TEST_IDS.metadataCancelButton); } get permissionsEditor() { return this.page.locator('valtimo-editor'); } - get savePermissionsButton() { - return this.page.getByRole('button', {name: 'Save'}); - } - // ─── Actions ────────────────────────────────────────────────────── /** Opens the add-role modal and switches it to manual key entry. */ @@ -115,6 +134,225 @@ export class AccessControlPage { await this.page.getByTestId('confirmationModalConfirm').click(); } + // ─── 11.6 Edit role metadata ────────────────────────────────────── + + /** Direct navigation to a role's detail page, where the metadata and export actions live. */ + async goToRoleDetail(roleKey: string) { + await this.page.goto(`/access-control/${roleKey}`); + await expect(this.roleDetailMoreMenuTrigger).toBeVisible(); + } + + /** + * The page-header overflow menu ("more" menu). Scoped to the header button container: permission + * cards in the editor tab each render their own overflow trigger too. + */ + get roleDetailMoreMenuTrigger(): Locator { + return this.page.locator('.buttons-container .v-overflow-menu__trigger'); + } + + get editMetadataMenuOption(): Locator { + return this.page.getByTestId(ACCESS_CONTROL_ROLES_TEST_IDS.moreMenuEditMetadata); + } + + async openRoleDetailMoreMenu() { + await this.roleDetailMoreMenuTrigger.click(); + await expect(this.page.getByRole('menu')).toBeVisible(); + } + + /** + * Renames a role via the detail page's "Edit metadata" modal. The modal opens in manual entry + * mode, prefilled with the current key. + */ + async editRoleMetadata(currentKey: string, newKey: string) { + await this.goToRoleDetail(currentKey); + await this.openRoleDetailMoreMenu(); + await expect(this.editMetadataMenuOption).toBeEnabled(); + await this.editMetadataMenuOption.click(); + + await expect(this.roleNameInput).toHaveValue(currentKey); + await this.roleNameInput.fill(newKey); + + await expect(this.roleMetadataConfirmButton).toBeEnabled(); + await Promise.all([ + this.page.waitForResponse( + res => + res.url().includes(`/api/management/v1/roles/${currentKey}`) && + res.request().method() === 'PUT' && + res.ok() + ), + this.roleMetadataConfirmButton.click(), + ]); + + // The detail route is keyed on the role name, so a rename navigates to the new URL. + await this.page.waitForURL(`**/access-control/${newKey}`); + } + + // ─── 11.7 Export role configuration ─────────────────────────────── + + get exportRolesButton(): Locator { + return this.page.getByTestId(ACCESS_CONTROL_ROLES_TEST_IDS.exportButton); + } + + get exportSingleFileButton(): Locator { + return this.page.getByTestId(ACCESS_CONTROL_ROLES_TEST_IDS.exportModalSingleFile); + } + + get exportSeparateFilesButton(): Locator { + return this.page.getByTestId(ACCESS_CONTROL_ROLES_TEST_IDS.exportModalSeparateFiles); + } + + get exportConfirmButton(): Locator { + return this.page.getByTestId(ACCESS_CONTROL_ROLES_TEST_IDS.exportModalConfirmButton); + } + + /** Selects a role in the list and opens the export modal from the batch action bar. */ + async openExportModal(roleKey: string) { + const list = new CarbonList(this.page); + await list.row(roleKey).select(); + await expect(this.exportRolesButton).toBeVisible(); + await this.exportRolesButton.click(); + await expect(this.exportSingleFileButton).toBeVisible(); + } + + /** + * Confirms the export as a single combined JSON file and returns the triggered download. + * The export type must be chosen first — the confirm button stays disabled until then. + */ + async exportSelectedRoleAsSingleFile() { + await expect(this.exportConfirmButton).toBeDisabled(); + await this.exportSingleFileButton.click(); + await expect(this.exportConfirmButton).toBeEnabled(); + + const [download] = await Promise.all([ + this.page.waitForEvent('download'), + this.exportConfirmButton.click(), + ]); + return download; + } + + // ─── 11.9–11.13 Permissions ─────────────────────────────────────── + + /** Role detail tabs. "Editor" needs `exact` — "JSON editor" also contains it. */ + get editorTab() { + return this.page.getByRole('tab', {name: 'Editor', exact: true}); + } + + get summaryTab() { + return this.page.getByRole('tab', {name: 'Summary'}); + } + + get jsonEditorTab() { + return this.page.getByRole('tab', {name: 'JSON editor'}); + } + + get monacoEditor() { + return this.page.locator('.monaco-editor'); + } + + get savePermissionsButton() { + return this.page.getByTestId(ACCESS_CONTROL_ROLES_TEST_IDS.saveButton); + } + + get addPermissionButton() { + return this.page.getByTestId(ACCESS_CONTROL_EDITOR_TEST_IDS.addPermissionButton); + } + + get permissionCards() { + return this.page.getByTestId(ACCESS_CONTROL_EDITOR_TEST_IDS.permissionCard); + } + + get resourceTypeSelect() { + return this.page.getByTestId(ACCESS_CONTROL_EDITOR_TEST_IDS.resourceTypeSelect); + } + + get actionsGrid() { + return this.page.getByTestId(ACCESS_CONTROL_EDITOR_TEST_IDS.actionsSelect); + } + + get addConditionButton() { + return this.page.getByTestId(ACCESS_CONTROL_EDITOR_TEST_IDS.addConditionButton); + } + + get conditionFieldSelect() { + return this.page.getByTestId(ACCESS_CONTROL_EDITOR_TEST_IDS.conditionFieldSelect); + } + + get conditionOperatorSelect() { + return this.page.getByTestId(ACCESS_CONTROL_EDITOR_TEST_IDS.conditionOperatorSelect); + } + + get conditionValueInput() { + return this.page.getByTestId(ACCESS_CONTROL_EDITOR_TEST_IDS.conditionValueInput); + } + + /** + * Expands one of a permission card's accordion sections. Only one section is open at a time, + * so controls in the others are in the DOM but not visible. + */ + async expandPermissionSection(section: 'sectionResourceActions' | 'sectionConditions') { + await this.page.getByTestId(ACCESS_CONTROL_EDITOR_TEST_IDS[section]).getByRole('button').first().click(); + } + + /** Picks an option from a Carbon combo box. Options render in an overlay, so resolve page-wide. */ + private async selectComboBoxOption(comboBox: Locator, optionLabel: string) { + await comboBox.click(); + await this.page.getByRole('option', {name: optionLabel, exact: true}).click(); + } + + async selectResourceType(resourceType: string) { + await this.selectComboBoxOption(this.resourceTypeSelect, resourceType); + // The available actions are derived from the resource type, so wait for them to render. + await expect(this.actionsGrid.locator('cds-checkbox').first()).toBeVisible(); + } + + /** + * Actions are a checkbox grid, not a dropdown. The label must match exactly: "View" is also a + * substring of "View list", so a loose match ticks the wrong action. + */ + private actionCheckbox(actionLabel: string): Locator { + return this.actionsGrid.locator('cds-checkbox').filter({ + has: this.page.getByText(actionLabel, {exact: true}), + }); + } + + async toggleAction(actionLabel: string) { + await this.actionCheckbox(actionLabel).click(); + } + + async assertActionChecked(actionLabel: string) { + await expect(this.actionCheckbox(actionLabel).locator('input[type="checkbox"]')).toBeChecked(); + } + + async addPermissionCondition(condition: {field: string; operator: string; value: string}) { + await this.expandPermissionSection('sectionConditions'); + await expect(this.addConditionButton).toBeVisible(); + await this.addConditionButton.click(); + + await this.selectComboBoxOption(this.conditionFieldSelect, condition.field); + await this.selectComboBoxOption(this.conditionOperatorSelect, condition.operator); + await this.conditionValueInput.fill(condition.value); + await expect(this.conditionValueInput).toHaveValue(condition.value); + } + + /** Replaces the whole permissions array through the JSON editor. */ + async setPermissionsJson(permissions: unknown[]) { + await expect(this.monacoEditor.first()).toBeVisible(); + await pasteToMonacoEditor(this.page, JSON.stringify(permissions, null, 2)); + } + + async savePermissions(roleKey: string) { + await expect(this.savePermissionsButton).toBeEnabled(); + await Promise.all([ + this.page.waitForResponse( + res => + res.url().includes(`/api/management/v1/roles/${roleKey}/permissions`) && + res.request().method() === 'PUT' && + res.ok() + ), + this.savePermissionsButton.click(), + ]); + } + // ─── Assertions ─────────────────────────────────────────────────── async assertRoleExists(roleKey: string) { @@ -129,6 +367,31 @@ export class AccessControlPage { await expect(this.permissionsEditor).toBeVisible(); } + // ─── 11.9–11.13 API Helpers ─────────────────────────────────────── + + async getRolePermissionsViaApi(roleKey: string): Promise<RolePermission[]> { + return ApiUtils.apiGet<RolePermission[]>(`/api/management/v1/roles/${roleKey}/permissions`); + } + + /** Polls until the saved permissions satisfy `predicate`. */ + async assertRolePermissions( + roleKey: string, + predicate: (permissions: RolePermission[]) => boolean, + message: string + ) { + await expect + .poll(async () => predicate(await this.getRolePermissionsViaApi(roleKey)), {message}) + .toBe(true); + } + + async setRolePermissionsViaApi(roleKey: string, permissions: unknown[]) { + try { + await ApiUtils.apiPut(`/api/management/v1/roles/${roleKey}/permissions`, permissions); + } catch { + // best effort — used to reset state during teardown + } + } + // ─── API Cleanup ────────────────────────────────────────────────── async deleteRolesViaApi(roleKeys: string[]) { diff --git a/e2e/tests/building-block-details-management-document/building-block-document-config.ts b/e2e/tests/building-block-details-management-document/building-block-document-config.ts new file mode 100644 index 0000000000..fa8053c48c --- /dev/null +++ b/e2e/tests/building-block-details-management-document/building-block-document-config.ts @@ -0,0 +1,119 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * The building block the tests operate on. Created through the API in `beforeAll` + * with a unique key: the document tab is read-only on finalized versions, so the + * suite needs a draft it fully controls. + */ +export const TEST_BUILDING_BLOCK = { + keyPrefix: 'e2e-bb-doc', + namePrefix: 'E2E BB Document', + description: 'Building block created by the e2e building block document test.', + versionTag: '1.0.0', +} as const; + +/** + * Document schema seeded before the tests run. It deliberately covers every field + * type the coverage list mentions, carries a description on each field, and marks + * exactly one root property as required. + */ +export function buildSeedSchema(key: string) { + return { + $id: `${key}.schema`, + $schema: 'http://json-schema.org/draft-07/schema#', + type: 'object', + required: [SEED_FIELDS.applicantName.name], + properties: { + applicantName: {type: 'string', description: SEED_FIELDS.applicantName.description}, + applicantAge: {type: 'integer', description: SEED_FIELDS.applicantAge.description}, + hasPartner: {type: 'boolean', description: SEED_FIELDS.hasPartner.description}, + address: { + type: 'object', + description: SEED_FIELDS.address.description, + properties: { + street: {type: 'string', description: SEED_FIELDS.street.description}, + houseNumber: {type: 'integer', description: SEED_FIELDS.houseNumber.description}, + }, + }, + attachments: { + type: 'array', + description: SEED_FIELDS.attachments.description, + items: {type: 'string'}, + }, + }, + }; +} + +export const SEED_FIELDS = { + applicantName: { + name: 'applicantName', + type: 'string', + description: 'Full name of the applicant', + }, + applicantAge: {name: 'applicantAge', type: 'integer', description: 'Age in whole years'}, + hasPartner: { + name: 'hasPartner', + type: 'boolean', + description: 'Whether the applicant has a partner', + }, + address: {name: 'address', type: 'object', description: 'Home address of the applicant'}, + street: {name: 'street', type: 'string', description: 'Street name'}, + houseNumber: {name: 'houseNumber', type: 'integer', description: 'House number'}, + attachments: {name: 'attachments', type: 'array', description: 'Uploaded attachments'}, +} as const; + +/** Every field type the seeded schema exercises. */ +export const SEED_FIELD_TYPES = ['string', 'integer', 'boolean', 'object', 'array'] as const; + +/** Schema typed into the editor by the edit test. */ +export function buildEditedSchema(key: string) { + return { + $id: `${key}.schema`, + $schema: 'http://json-schema.org/draft-07/schema#', + type: 'object', + properties: { + [EDITED_FIELD.name]: {type: EDITED_FIELD.type, description: EDITED_FIELD.description}, + }, + }; +} + +export const EDITED_FIELD = { + name: 'editedField', + type: 'string', + description: 'Field added by the e2e document test', +} as const; + +/** Not valid JSON — used to check the editor refuses to save it. */ +export const INVALID_SCHEMA_TEXT = '{"type": '; + +export const SEARCH_TERMS = { + /** Appears once in the schema. */ + singleMatch: 'applicantAge', + singleMatchCount: '1/1', + /** Appears as a property name and inside its description. */ + multipleMatches: 'street', + multipleMatchesCount: '1/2', + noMatch: 'zzz-not-a-field', + noMatchCount: '0', +} as const; + +export const DOCUMENT_TEXTS = { + saveConfirmation: + 'Are you sure you want to save? Removing or changing an attribute can be breaking.', + requiredFieldsPanelTitle: 'Required fields', + rootObjectLevel: 'Root level', +} as const; diff --git a/e2e/tests/building-block-details-management-document/building-block-document.spec.ts b/e2e/tests/building-block-details-management-document/building-block-document.spec.ts new file mode 100644 index 0000000000..5e0e987ccd --- /dev/null +++ b/e2e/tests/building-block-details-management-document/building-block-document.spec.ts @@ -0,0 +1,273 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {expect, test} from '@playwright/test'; +import {generateId} from '../../utils/dataGenerator'; +import { + DOCUMENT_TEXTS, + EDITED_FIELD, + INVALID_SCHEMA_TEXT, + SEARCH_TERMS, + SEED_FIELDS, + SEED_FIELD_TYPES, + TEST_BUILDING_BLOCK, + buildEditedSchema, + buildSeedSchema, +} from './building-block-document-config'; +import {BuildingBlockDocumentPage} from './page'; + +test.use({storageState: undefined}); + +test.describe('Building block management — document tab', () => { + let context; + let page; + let request; + let documentPage: BuildingBlockDocumentPage; + + const uniqueId = generateId(); + const buildingBlockKey = `${TEST_BUILDING_BLOCK.keyPrefix}-${uniqueId}`; + const buildingBlockName = `${TEST_BUILDING_BLOCK.namePrefix} ${uniqueId}`; + const versionTag = TEST_BUILDING_BLOCK.versionTag; + const seedSchema = buildSeedSchema(buildingBlockKey); + + test.beforeAll(async ({browser, baseURL}) => { + context = await browser.newContext({baseURL}); + page = await context.newPage(); + request = context.request; + + documentPage = new BuildingBlockDocumentPage(page, request); + + await documentPage.createBuildingBlockViaApi({ + key: buildingBlockKey, + name: buildingBlockName, + versionTag, + description: TEST_BUILDING_BLOCK.description, + }); + + await page.goto('/'); + }); + + /** + * Reset the schema before every test. Several tests save changes — one replaces + * the document wholesale — and the embedded JSON editor also keeps an unsaved + * buffer, so each test needs the seeded schema back to be independent of the + * order it runs in. + */ + test.beforeEach(async () => { + await documentPage.setDocumentSchemaViaApi(buildingBlockKey, versionTag, seedSchema); + }); + + test.afterAll(async () => { + // The building block cannot be removed: the management API has no DELETE for + // building block definitions (see `deleteBuildingBlockViaApi`). It stays + // behind until that endpoint exists. + await documentPage.deleteBuildingBlockViaApi(buildingBlockKey, versionTag); + + await context.close(); + }); + + test.describe('13.23, 13.26, 13.27 — View the document structure', () => { + test('13.23 — Document tab renders the schema and all of its fields', async () => { + await documentPage.goToDocumentTab(buildingBlockKey, versionTag); + + await documentPage.assertValueVisible(`${buildingBlockKey}.schema`); + + for (const field of Object.values(SEED_FIELDS)) { + await documentPage.assertFieldVisible(field.name); + } + }); + + test('13.26 — Field types are shown for every field', async () => { + await documentPage.goToDocumentTab(buildingBlockKey, versionTag); + + await documentPage.assertFieldTypesVisible(SEED_FIELD_TYPES); + }); + + test('13.27 — Field descriptions are shown for every field', async () => { + await documentPage.goToDocumentTab(buildingBlockKey, versionTag); + + await documentPage.assertFieldDescriptionsVisible( + Object.values(SEED_FIELDS).map(field => field.description) + ); + }); + }); + + test.describe('13.28 — Search and filter document fields', () => { + test('13.28 — Searching a field name highlights its matches', async () => { + await documentPage.goToDocumentTab(buildingBlockKey, versionTag); + const editor = documentPage.schemaEditor; + + await editor.openSearch(); + + // A field that occurs once in the schema. + await editor.search(SEARCH_TERMS.singleMatch); + await expect(editor.searchHighlights).toHaveCount(1); + expect(await editor.searchResultCount()).toBe(SEARCH_TERMS.singleMatchCount); + + // A term that occurs in a field name and in a description. + await editor.search(SEARCH_TERMS.multipleMatches); + await expect(editor.searchHighlights).toHaveCount(2); + expect(await editor.searchResultCount()).toBe(SEARCH_TERMS.multipleMatchesCount); + + await editor.closeSearch(); + }); + + test.describe('Failure scenarios', () => { + test('Searching a term that does not occur reports no matches', async () => { + await documentPage.goToDocumentTab(buildingBlockKey, versionTag); + const editor = documentPage.schemaEditor; + + await editor.openSearch(); + await editor.search(SEARCH_TERMS.noMatch); + + await expect(editor.searchHighlights).toHaveCount(0); + expect(await editor.searchResultCount()).toBe(SEARCH_TERMS.noMatchCount); + + await editor.closeSearch(); + }); + }); + }); + + test.describe('13.25, 13.29 — Manage required fields', () => { + test('13.25 — Required fields panel groups checkboxes per object level', async () => { + await documentPage.goToDocumentTab(buildingBlockKey, versionTag); + const editor = documentPage.schemaEditor; + + await editor.openRequiredFieldsPanel(); + + await expect(editor.requiredFieldsPanel).toContainText( + DOCUMENT_TEXTS.requiredFieldsPanelTitle + ); + // Root properties and the nested object each get their own group. + await expect(editor.requiredFieldsPanel).toContainText(DOCUMENT_TEXTS.rootObjectLevel); + await expect(editor.requiredFieldsPanel).toContainText(SEED_FIELDS.address.name); + + // The checkboxes reflect the `required` list of the seeded schema. + await editor.assertRequiredFieldChecked(SEED_FIELDS.applicantName.name); + await editor.assertRequiredFieldNotChecked(SEED_FIELDS.applicantAge.name); + await editor.assertRequiredFieldNotChecked(SEED_FIELDS.address.name, SEED_FIELDS.street.name); + }); + + test('13.25, 13.29 — Marking a field required is saved to the document', async () => { + await documentPage.goToDocumentTab(buildingBlockKey, versionTag); + const editor = documentPage.schemaEditor; + + await editor.openRequiredFieldsPanel(); + await editor.toggleRequiredField(SEED_FIELDS.applicantAge.name); + + await editor.assertRequiredFieldChecked(SEED_FIELDS.applicantAge.name); + + const response = await documentPage.saveDocument(buildingBlockKey, versionTag); + expect(response.status()).toBe(200); + + const stored = await documentPage.getDocumentSchemaViaApi(buildingBlockKey, versionTag); + expect(stored.required).toEqual( + expect.arrayContaining([SEED_FIELDS.applicantName.name, SEED_FIELDS.applicantAge.name]) + ); + }); + + test('13.25, 13.29 — Unmarking a field required is saved to the document', async () => { + await documentPage.goToDocumentTab(buildingBlockKey, versionTag); + const editor = documentPage.schemaEditor; + + await editor.openRequiredFieldsPanel(); + await editor.toggleRequiredField(SEED_FIELDS.applicantName.name); + + await editor.assertRequiredFieldNotChecked(SEED_FIELDS.applicantName.name); + + const response = await documentPage.saveDocument(buildingBlockKey, versionTag); + expect(response.status()).toBe(200); + + const stored = await documentPage.getDocumentSchemaViaApi(buildingBlockKey, versionTag); + expect(stored.required ?? []).not.toContain(SEED_FIELDS.applicantName.name); + }); + }); + + test.describe('13.24, 13.29 — Edit and save the document structure', () => { + test('13.24, 13.29 — Edits made in the JSON editor are saved', async () => { + await documentPage.goToDocumentTab(buildingBlockKey, versionTag); + const editor = documentPage.schemaEditor; + + await editor.replaceContent(buildEditedSchema(buildingBlockKey)); + + const response = await documentPage.saveDocument(buildingBlockKey, versionTag); + expect(response.status()).toBe(200); + + const stored = await documentPage.getDocumentSchemaViaApi(buildingBlockKey, versionTag); + expect(stored.properties).toHaveProperty(EDITED_FIELD.name); + expect(stored.properties[EDITED_FIELD.name]).toMatchObject({ + type: EDITED_FIELD.type, + description: EDITED_FIELD.description, + }); + // The replaced schema no longer holds the seeded fields. + expect(stored.properties).not.toHaveProperty(SEED_FIELDS.applicantName.name); + + // Saving reloads the editor in tree mode with the stored document. + await documentPage.goToDocumentTab(buildingBlockKey, versionTag); + await documentPage.assertFieldVisible(EDITED_FIELD.name); + await documentPage.assertValueVisible(EDITED_FIELD.description); + }); + + test.describe('Failure scenarios', () => { + test('Save stays disabled until the document is changed', async () => { + await documentPage.goToDocumentTab(buildingBlockKey, versionTag); + + await expect(documentPage.schemaEditor.saveButton).toBeDisabled(); + }); + + test('Invalid JSON cannot be saved and leaves the document untouched', async () => { + await documentPage.goToDocumentTab(buildingBlockKey, versionTag); + const editor = documentPage.schemaEditor; + + const before = await documentPage.getDocumentSchemaViaApi(buildingBlockKey, versionTag); + + await editor.replaceContent(INVALID_SCHEMA_TEXT); + + // The editor reports the parse error and offers to repair it. + await expect(editor.parseError).toBeVisible(); + await expect(editor.autoRepairButton).toBeVisible(); + + // Unparseable content never marks the schema valid, so saving stays off. + await expect(editor.saveButton).toBeDisabled(); + + const after = await documentPage.getDocumentSchemaViaApi(buildingBlockKey, versionTag); + expect(after).toEqual(before); + }); + + /** + * Runs last: finalizing a version is irreversible, and the tests above need + * the version to still be a draft. + */ + test('The document of a finalized version cannot be edited', async () => { + await documentPage.finalizeVersionViaApi(buildingBlockKey, versionTag); + + await documentPage.goToDocumentTab(buildingBlockKey, versionTag); + const editor = documentPage.schemaEditor; + + // The schema is still readable... + await documentPage.assertFieldVisible(SEED_FIELDS.applicantName.name); + + // ...but nothing can be saved and the required fields are locked. + await expect(editor.saveButton).toBeDisabled(); + + await editor.openRequiredFieldsPanel(); + await expect( + editor.requiredFieldCheckbox(SEED_FIELDS.applicantAge.name).locator('input') + ).toBeDisabled(); + }); + }); + }); +}); diff --git a/e2e/tests/building-block-details-management-document/page.ts b/e2e/tests/building-block-details-management-document/page.ts new file mode 100644 index 0000000000..df235d58e3 --- /dev/null +++ b/e2e/tests/building-block-details-management-document/page.ts @@ -0,0 +1,155 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {type APIRequestContext, type Page, type Response, expect} from '@playwright/test'; +import {BUILDING_BLOCK_MANAGEMENT_DETAIL_ACTIONS_TEST_IDS} from '../../constants'; +import {SchemaEditor} from '../../shared/schema-editor/schema-editor.utils'; +import {apiDelete, apiGet, apiPost, apiPut} from '../../utils/api.utils'; + +const BUILDING_BLOCK_API_URL = '/api/management/v1/building-block'; + +export interface BuildingBlockDefinition { + key: string; + name: string; + versionTag: string; + description: string; + final: boolean; +} + +export type DocumentSchema = Record<string, any>; + +export class BuildingBlockDocumentPage { + readonly schemaEditor: SchemaEditor; + + constructor( + private readonly page: Page, + private readonly request: APIRequestContext + ) { + this.schemaEditor = new SchemaEditor(page); + } + + // ─── Navigation ─────────────────────────────────────────────────── + + get versionDropdown() { + return this.page.getByTestId( + BUILDING_BLOCK_MANAGEMENT_DETAIL_ACTIONS_TEST_IDS.versionSelectDropdown + ); + } + + documentUrl(key: string, versionTag: string) { + return `/building-block-management/building-block/${key}/version/${versionTag}/document`; + } + + /** + * Navigate straight to the document tab. Each test re-navigates rather than + * reusing editor state: the embedded JSON editor keeps its mode, search box and + * unsaved buffer between interactions, and a reload is the only way to get a + * predictable starting point. + */ + async goToDocumentTab(key: string, versionTag: string) { + await this.page.goto(this.documentUrl(key, versionTag)); + await this.page.waitForURL(new RegExp(`/version/${versionTag}/document$`)); + await expect(this.versionDropdown).toBeVisible(); + await this.schemaEditor.waitForLoaded(); + } + + // ─── Assertions ─────────────────────────────────────────────────── + + /** Assert the tree view renders a property name. */ + async assertFieldVisible(fieldName: string) { + await expect(this.schemaEditor.keys.filter({hasText: fieldName}).first()).toBeVisible(); + expect(await this.schemaEditor.keyTexts()).toContain(fieldName); + } + + /** Assert the tree view renders a value — a field type or a description. */ + async assertValueVisible(value: string) { + expect(await this.schemaEditor.valueTexts()).toContain(value); + } + + async assertFieldTypesVisible(types: readonly string[]) { + const values = await this.schemaEditor.valueTexts(); + for (const type of types) { + expect(values, `field type "${type}" is not rendered`).toContain(type); + } + } + + async assertFieldDescriptionsVisible(descriptions: readonly string[]) { + const values = await this.schemaEditor.valueTexts(); + for (const description of descriptions) { + expect(values, `description "${description}" is not rendered`).toContain(description); + } + } + + // ─── Save ───────────────────────────────────────────────────────── + + /** Save the editor contents and wait for the document PUT to come back. */ + async saveDocument(key: string, versionTag: string): Promise<Response> { + const [response] = await Promise.all([ + this.page.waitForResponse( + res => + res.url().includes(`${BUILDING_BLOCK_API_URL}/${key}/version/${versionTag}/document`) && + res.request().method() === 'PUT' + ), + this.schemaEditor.save(), + ]); + return response; + } + + // ─── API helpers ────────────────────────────────────────────────── + + async createBuildingBlockViaApi(definition: { + key: string; + name: string; + versionTag: string; + description: string; + }): Promise<BuildingBlockDefinition> { + return apiPost<BuildingBlockDefinition>(BUILDING_BLOCK_API_URL, definition); + } + + async getDocumentSchemaViaApi(key: string, versionTag: string): Promise<DocumentSchema> { + return apiGet<DocumentSchema>( + `${BUILDING_BLOCK_API_URL}/${key}/version/${versionTag}/document` + ); + } + + async setDocumentSchemaViaApi( + key: string, + versionTag: string, + schema: DocumentSchema + ): Promise<void> { + await apiPut(`${BUILDING_BLOCK_API_URL}/${key}/version/${versionTag}/document`, schema); + } + + async finalizeVersionViaApi(key: string, versionTag: string): Promise<void> { + await apiPost(`${BUILDING_BLOCK_API_URL}/${key}/version/${versionTag}/finalize`, {}); + } + + /** + * Best-effort cleanup of the building block created for this suite. + * + * The management API exposes no DELETE for building block definitions + * (`BuildingBlockManagementResource` only serves GET/POST/PUT), so this call + * fails and is swallowed — the building block stays behind. The helper is kept + * so cleanup starts working as soon as the endpoint is added. + */ + async deleteBuildingBlockViaApi(key: string, versionTag: string) { + try { + await apiDelete(`${BUILDING_BLOCK_API_URL}/${key}/version/${versionTag}`); + } catch { + // No DELETE endpoint yet — nothing to do. + } + } +} diff --git a/e2e/tests/building-block-details-management/building-block-details-config.ts b/e2e/tests/building-block-details-management/building-block-details-config.ts new file mode 100644 index 0000000000..d35adfc9ea --- /dev/null +++ b/e2e/tests/building-block-details-management/building-block-details-config.ts @@ -0,0 +1,66 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * The building block the tests operate on. It is created through the API in + * `beforeAll` with a unique key, because the version lifecycle tests finalize a + * version — an irreversible action that would leave a shared building block + * unusable for the next run. + */ +export const TEST_BUILDING_BLOCK = { + keyPrefix: 'e2e-bb-details', + /** Suffixed with the same unique id as the key, so no two runs share a name. */ + namePrefix: 'E2E BB Details', + description: 'Building block created by the e2e building block details test.', + /** Version created together with the building block; starts out as a draft. */ + initialVersionTag: '1.0.0', + /** Draft created from the finalized initial version. */ + draftVersionTag: '1.0.1', +} as const; + +/** Values entered when saving the metadata form. */ +export const UPDATED_METADATA = { + namePrefix: 'E2E BB Details updated', + description: 'Description updated by the e2e building block details test.', +} as const; + +/** + * Seeded building block that references plugins — used to assert the "Plugins + * used" section actually renders the plugins of a building block. + */ +export const SEEDED_BUILDING_BLOCK_WITH_PLUGINS = { + key: 'upload-and-link-document', + versionTag: '1.0.0', + pluginTitles: ['Documenten API', 'Zaken API'], +} as const; + +export const ARTWORK_FILE_NAME = 'e2e-building-block-artwork.png'; + +export const BUILDING_BLOCK_DETAIL_TEXTS = { + tabs: ['General', 'Document', 'Processes', 'Forms', 'Form flows', 'Decision tables'], + metadataTitle: 'General information', + artworkTitle: 'Artwork', + noPluginsUsed: 'This building block does not use any plugins', + processColumns: ['Name', 'Key', 'Status', ''], + mainProcessTag: 'Main process', + makeFinalAction: 'Make version final', + createDraftAction: 'Create a new draft', + finalizeSuccess: 'Draft version has been finalized', + draftSuccess: 'Draft version created', + exportStarted: 'Download will start shortly', + deleteArtworkConfirmation: + 'Are you sure you want to delete this artwork? This action cannot be undone.', +} as const; diff --git a/e2e/tests/building-block-details-management/building-block-details-management.spec.ts b/e2e/tests/building-block-details-management/building-block-details-management.spec.ts new file mode 100644 index 0000000000..e1834ab37d --- /dev/null +++ b/e2e/tests/building-block-details-management/building-block-details-management.spec.ts @@ -0,0 +1,335 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {expect, test} from '@playwright/test'; +import {generateId} from '../../utils/dataGenerator'; +import {expectNotificationMessage} from '../../utils/ui.utils'; +import { + ARTWORK_FILE_NAME, + BUILDING_BLOCK_DETAIL_TEXTS, + SEEDED_BUILDING_BLOCK_WITH_PLUGINS, + TEST_BUILDING_BLOCK, + UPDATED_METADATA, +} from './building-block-details-config'; +import {BUILDING_BLOCK_TABS, BuildingBlockDetailsPage} from './page'; + +test.use({storageState: undefined}); + +test.describe('Building block management — building block details', () => { + let context; + let page; + let request; + let detailsPage: BuildingBlockDetailsPage; + + /** + * The building block created for this suite. Key *and* name carry the same + * unique id: the overview list identifies rows by key, but a duplicated name + * would still make name-based lookups in other specs ambiguous. + */ + const uniqueId = generateId(); + const buildingBlockKey = `${TEST_BUILDING_BLOCK.keyPrefix}-${uniqueId}`; + const buildingBlockName = `${TEST_BUILDING_BLOCK.namePrefix} ${uniqueId}`; + const updatedName = `${UPDATED_METADATA.namePrefix} ${uniqueId}`; + const initialVersion = TEST_BUILDING_BLOCK.initialVersionTag; + const draftVersion = TEST_BUILDING_BLOCK.draftVersionTag; + + test.beforeAll(async ({browser, baseURL}) => { + context = await browser.newContext({baseURL}); + page = await context.newPage(); + request = context.request; + + detailsPage = new BuildingBlockDetailsPage(page, request); + + // Create the building block through the API so the UI tests start from a + // known state: one draft version, no artwork, no plugins. + await detailsPage.createBuildingBlockViaApi({ + key: buildingBlockKey, + name: buildingBlockName, + versionTag: initialVersion, + description: TEST_BUILDING_BLOCK.description, + }); + + await page.goto('/'); + }); + + test.afterAll(async () => { + // Artwork can be removed; the building block itself cannot — there is no + // DELETE endpoint for building block definitions (see + // `deleteBuildingBlockViaApi`). Both versions created here stay behind until + // that endpoint exists. + for (const versionTag of [initialVersion, draftVersion]) { + await detailsPage.deleteArtworkViaApi(buildingBlockKey, versionTag); + } + + for (const versionTag of [draftVersion, initialVersion]) { + await detailsPage.deleteBuildingBlockViaApi(buildingBlockKey, versionTag); + } + + await context.close(); + }); + + test.describe('13.10, 13.13 — View general information', () => { + test('13.10 — General tab shows the building block name, key and description', async () => { + await detailsPage.goToGeneralTab(buildingBlockKey, initialVersion); + + await detailsPage.assertTabsVisible(BUILDING_BLOCK_DETAIL_TEXTS.tabs); + await detailsPage.assertMetadata({ + name: buildingBlockName, + key: buildingBlockKey, + description: TEST_BUILDING_BLOCK.description, + }); + }); + + test('13.13 — A building block without plugins shows the empty plugins message', async () => { + await detailsPage.goToGeneralTab(buildingBlockKey, initialVersion); + + await detailsPage.assertNoPluginsUsed(BUILDING_BLOCK_DETAIL_TEXTS.noPluginsUsed); + }); + + test('13.13 — The plugins used by a building block are listed', async () => { + await detailsPage.goToGeneralTab( + SEEDED_BUILDING_BLOCK_WITH_PLUGINS.key, + SEEDED_BUILDING_BLOCK_WITH_PLUGINS.versionTag + ); + + await detailsPage.assertUsedPlugins(SEEDED_BUILDING_BLOCK_WITH_PLUGINS.pluginTitles); + }); + }); + + test.describe('13.11, 13.12 — View the document and processes tabs', () => { + test('13.11 — Document tab shows the document schema of the building block', async () => { + await detailsPage.goToTab(buildingBlockKey, initialVersion, BUILDING_BLOCK_TABS.document); + + // Creating a building block generates a `<key>.schema` document definition. + await detailsPage.assertSchemaEditorShows(`${buildingBlockKey}.schema`); + }); + + test('13.12 — Processes tab lists the process of the building block', async () => { + await detailsPage.goToTab(buildingBlockKey, initialVersion, BUILDING_BLOCK_TABS.processes); + await detailsPage.processList.waitForLoaded(); + + const headers = await detailsPage.processList.table.locator('thead th').allInnerTexts(); + expect(headers.map(header => header.trim())).toEqual([ + ...BUILDING_BLOCK_DETAIL_TEXTS.processColumns, + ]); + + // Creating a building block also generates its main process definition. + const processes = await detailsPage.getBuildingBlockProcessesViaApi( + buildingBlockKey, + initialVersion + ); + expect(processes.length).toBeGreaterThan(0); + + const row = detailsPage.processList.row(buildingBlockKey); + await row.assertVisible(); + await expect(row.tags).toContainText(BUILDING_BLOCK_DETAIL_TEXTS.mainProcessTag); + }); + }); + + test.describe('13.16 — Save building block metadata', () => { + test('13.16 — Saves an updated name and description', async () => { + await detailsPage.goToGeneralTab(buildingBlockKey, initialVersion); + + await detailsPage.fillMetadata({ + name: updatedName, + description: UPDATED_METADATA.description, + }); + + const response = await detailsPage.saveMetadata(buildingBlockKey, initialVersion); + expect(response.status()).toBe(200); + + const definition = await detailsPage.getBuildingBlockViaApi(buildingBlockKey, initialVersion); + expect(definition).toMatchObject({ + key: buildingBlockKey, + name: updatedName, + description: UPDATED_METADATA.description, + }); + + // The saved values survive a reload. + await detailsPage.goToGeneralTab(buildingBlockKey, initialVersion); + await detailsPage.assertMetadata({ + name: updatedName, + key: buildingBlockKey, + description: UPDATED_METADATA.description, + }); + }); + + test.describe('Failure scenarios', () => { + test('Save is disabled while nothing has changed and when the name is cleared', async () => { + await detailsPage.goToGeneralTab(buildingBlockKey, initialVersion); + + // Pristine form. + await expect(detailsPage.metadataSaveButton).toBeDisabled(); + + // Name is required. + await detailsPage.fillMetadata({name: ''}); + await expect(detailsPage.metadataSaveButton).toBeDisabled(); + + // Restoring a value re-enables saving. + await detailsPage.fillMetadata({name: updatedName}); + await expect(detailsPage.metadataSaveButton).toBeEnabled(); + }); + }); + }); + + test.describe('13.14, 13.15 — Building block artwork', () => { + test('13.14, 13.15 — Uploads artwork and deletes it again', async () => { + await detailsPage.goToGeneralTab(buildingBlockKey, initialVersion); + + // 13.14 — upload + await expect(detailsPage.artworkFileUploader).toBeVisible(); + await expect(detailsPage.artworkImage).toHaveCount(0); + + const uploadResponse = await detailsPage.uploadArtwork( + buildingBlockKey, + initialVersion, + ARTWORK_FILE_NAME + ); + expect(uploadResponse.status()).toBe(200); + + await expect(detailsPage.artworkImage).toBeVisible(); + // Once artwork exists the uploader is replaced by a delete action, so + // replacing artwork means deleting it first. + await expect(detailsPage.artworkFileUploader).toHaveCount(0); + expect(await detailsPage.getArtworkViaApi(buildingBlockKey, initialVersion)).not.toBeNull(); + + // 13.15 — delete, which asks for confirmation first + await detailsPage.openArtworkDeleteConfirmation(); + await expect(detailsPage.openModal).toContainText( + BUILDING_BLOCK_DETAIL_TEXTS.deleteArtworkConfirmation + ); + + const deleteResponse = await detailsPage.confirmArtworkDeletion( + buildingBlockKey, + initialVersion + ); + expect(deleteResponse.status()).toBe(204); + + await expect(detailsPage.artworkFileUploader).toBeVisible(); + await expect(detailsPage.artworkImage).toHaveCount(0); + expect(await detailsPage.getArtworkViaApi(buildingBlockKey, initialVersion)).toBeNull(); + }); + + test.describe('Failure scenarios', () => { + test('Upload is disabled until a file is selected', async () => { + await detailsPage.goToGeneralTab(buildingBlockKey, initialVersion); + + await expect(detailsPage.artworkFileUploader).toBeVisible(); + await expect(detailsPage.artworkUploadButton).toBeDisabled(); + }); + }); + }); + + test.describe('13.17 — Export the building block', () => { + test('13.17 — Exports the building block as a ZIP', async () => { + await detailsPage.goToGeneralTab(buildingBlockKey, initialVersion); + + const download = await detailsPage.exportBuildingBlock(); + + expect(download.suggestedFilename()).toContain(buildingBlockKey); + expect(download.suggestedFilename()).toMatch(/\.zip$/); + await expectNotificationMessage(page, BUILDING_BLOCK_DETAIL_TEXTS.exportStarted); + }); + }); + + /** + * These tests share one building block and run in declaration order: the draft + * has to be finalized before a new draft can be created, and both versions have + * to exist before they can be switched between. + */ + test.describe('13.18–13.22 — Version lifecycle', () => { + test('13.20 — A draft version is badged as DRAFT and can be edited', async () => { + await detailsPage.goToGeneralTab(buildingBlockKey, initialVersion); + + await detailsPage.assertDraftVersionSelected(initialVersion); + // A single version leaves nothing to switch to. + await expect(detailsPage.versionDropdownButton).toBeDisabled(); + + // Draft versions are editable. + await detailsPage.fillMetadata({name: updatedName}); + await expect(detailsPage.metadataSaveButton).toBeEnabled(); + }); + + test('13.22 — Finalizes the draft version, which becomes read-only', async () => { + await detailsPage.goToGeneralTab(buildingBlockKey, initialVersion); + + // A draft offers "Make version final"; creating a draft is only offered on + // a version that is already final. + const draftActions = await detailsPage.readMoreMenuOptions(); + expect(draftActions).toContain(BUILDING_BLOCK_DETAIL_TEXTS.makeFinalAction); + expect(draftActions).not.toContain(BUILDING_BLOCK_DETAIL_TEXTS.createDraftAction); + + const response = await detailsPage.finalizeVersion(buildingBlockKey, initialVersion); + expect(response.status()).toBe(200); + + await expectNotificationMessage(page, BUILDING_BLOCK_DETAIL_TEXTS.finalizeSuccess); + + // 13.20 — the DRAFT badge is gone once the version is final. + await detailsPage.assertFinalVersionSelected(initialVersion); + await expect(detailsPage.metadataSaveButton).toBeDisabled(); + + const versions = await detailsPage.getVersionsViaApi(buildingBlockKey); + expect(versions).toEqual(expect.arrayContaining([{versionTag: initialVersion, final: true}])); + }); + + test('13.18, 13.19 — Creates a draft version from the finalized version', async () => { + await detailsPage.goToGeneralTab(buildingBlockKey, initialVersion); + + // Now that the version is final, the draft action replaces "Make final". + const finalActions = await detailsPage.readMoreMenuOptions(); + expect(finalActions).toContain(BUILDING_BLOCK_DETAIL_TEXTS.createDraftAction); + expect(finalActions).not.toContain(BUILDING_BLOCK_DETAIL_TEXTS.makeFinalAction); + + await detailsPage.openDraftModal(); + + // 13.19 — a version tag is required. + await expect(detailsPage.draftConfirmButton).toBeDisabled(); + + const response = await detailsPage.createDraft( + buildingBlockKey, + initialVersion, + draftVersion + ); + expect(response.status()).toBe(200); + + await expectNotificationMessage(page, BUILDING_BLOCK_DETAIL_TEXTS.draftSuccess); + + // Creating the draft navigates to it. + await page.waitForURL(new RegExp(`/version/${draftVersion}/`)); + await detailsPage.assertDraftVersionSelected(draftVersion); + + const versions = await detailsPage.getVersionsViaApi(buildingBlockKey); + expect(versions).toEqual(expect.arrayContaining([{versionTag: draftVersion, final: false}])); + }); + + test('13.21 — Switches between versions through the dropdown', async () => { + await detailsPage.goToGeneralTab(buildingBlockKey, draftVersion); + + // Two versions now exist, so the dropdown offers both. + await detailsPage.openVersionDropdown(); + await expect(detailsPage.versionOption(initialVersion)).toBeVisible(); + await expect(detailsPage.versionOption(draftVersion)).toBeVisible(); + await detailsPage.versionOption(initialVersion).click(); + + await page.waitForURL(new RegExp(`/version/${initialVersion}/`)); + await detailsPage.assertFinalVersionSelected(initialVersion); + + // ...and back to the draft. + await detailsPage.switchToVersion(draftVersion); + await detailsPage.assertDraftVersionSelected(draftVersion); + }); + }); +}); diff --git a/e2e/tests/building-block-details-management/page.ts b/e2e/tests/building-block-details-management/page.ts new file mode 100644 index 0000000000..612c10a389 --- /dev/null +++ b/e2e/tests/building-block-details-management/page.ts @@ -0,0 +1,469 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + type APIRequestContext, + type Download, + type Locator, + type Page, + type Response, + expect, +} from '@playwright/test'; +import path from 'path'; +import { + BUILDING_BLOCK_MANAGEMENT_ARTWORK_TEST_IDS, + BUILDING_BLOCK_MANAGEMENT_DETAIL_ACTIONS_TEST_IDS, + BUILDING_BLOCK_MANAGEMENT_DETAIL_TEST_IDS, + BUILDING_BLOCK_MANAGEMENT_METADATA_TEST_IDS, + BUILDING_BLOCK_MANAGEMENT_PLUGINS_TEST_IDS, + BUILDING_BLOCK_VERSION_OPTION_TEST_ID_PREFIX, + CONFIRMATION_MODAL_TEST_IDS, + SCHEMA_EDITOR_TEST_IDS, +} from '../../constants'; +import {CarbonList} from '../../shared/carbon-list/carbon-list.utils'; +import {OverflowMenu} from '../../shared/overflow-menu/overflow-menu.utils'; +import {apiDelete, apiGet, apiPost} from '../../utils/api.utils'; + +const ARCHIVES_DIR = 'building-block-archives'; +const BUILDING_BLOCK_API_URL = '/api/management/v1/building-block'; + +export const BUILDING_BLOCK_TABS = { + general: 'general', + document: 'document', + processes: 'process-definition', +} as const; + +export type BuildingBlockTab = (typeof BUILDING_BLOCK_TABS)[keyof typeof BUILDING_BLOCK_TABS]; + +export interface BuildingBlockDefinition { + key: string; + name: string; + versionTag: string; + description: string; + final: boolean; +} + +export interface BuildingBlockVersion { + versionTag: string; + final: boolean; +} + +export class BuildingBlockDetailsPage { + readonly moreMenu: OverflowMenu; + readonly processList: CarbonList; + + constructor( + private readonly page: Page, + private readonly request: APIRequestContext + ) { + this.moreMenu = new OverflowMenu( + page, + BUILDING_BLOCK_MANAGEMENT_DETAIL_ACTIONS_TEST_IDS.moreButton + ); + this.processList = new CarbonList( + page, + page.locator('valtimo-building-block-management-processes') + ); + } + + // ─── Tabs ───────────────────────────────────────────────────────── + + get tabs() { + return this.page.getByTestId(BUILDING_BLOCK_MANAGEMENT_DETAIL_TEST_IDS.tabs); + } + + // ─── Plugins section (General tab) ──────────────────────────────── + + get usedPlugins() { + return this.page.getByTestId(BUILDING_BLOCK_MANAGEMENT_PLUGINS_TEST_IDS.usedPlugins); + } + + get noPluginsUsedMessage() { + return this.page.getByTestId(BUILDING_BLOCK_MANAGEMENT_PLUGINS_TEST_IDS.noPluginsUsed); + } + + // ─── Metadata section (General tab) ─────────────────────────────── + + get metadataNameInput() { + return this.page.getByTestId(BUILDING_BLOCK_MANAGEMENT_METADATA_TEST_IDS.nameInput); + } + + /** + * Read-only in the UI through the `valtimoReadOnly` directive, which only adds + * a CSS class — the input stays editable in the DOM, so assert its value + * rather than its editability. + */ + get metadataKeyInput() { + return this.page.getByTestId(BUILDING_BLOCK_MANAGEMENT_METADATA_TEST_IDS.keyInput); + } + + get metadataDescriptionInput() { + return this.page.getByTestId(BUILDING_BLOCK_MANAGEMENT_METADATA_TEST_IDS.descriptionInput); + } + + get metadataSaveButton() { + return this.page.getByTestId(BUILDING_BLOCK_MANAGEMENT_METADATA_TEST_IDS.saveButton); + } + + // ─── Artwork section (General tab) ──────────────────────────────── + + get artworkFileUploader() { + return this.page.getByTestId(BUILDING_BLOCK_MANAGEMENT_ARTWORK_TEST_IDS.fileUploader); + } + + get artworkImage() { + return this.page.getByTestId(BUILDING_BLOCK_MANAGEMENT_ARTWORK_TEST_IDS.image); + } + + get artworkUploadButton() { + return this.page.getByTestId(BUILDING_BLOCK_MANAGEMENT_ARTWORK_TEST_IDS.uploadButton); + } + + get artworkDeleteButton() { + return this.page.getByTestId(BUILDING_BLOCK_MANAGEMENT_ARTWORK_TEST_IDS.deleteButton); + } + + get confirmDeleteButton() { + return this.page.getByTestId(CONFIRMATION_MODAL_TEST_IDS.confirmButton); + } + + /** The Carbon modal that is currently open, used for content assertions. */ + get openModal() { + return this.page.locator('.cds--modal.is-visible'); + } + + // ─── Document tab ───────────────────────────────────────────────── + + get schemaEditor() { + return this.page.getByTestId(SCHEMA_EDITOR_TEST_IDS.editor); + } + + // ─── Detail actions ─────────────────────────────────────────────── + + get versionDropdown() { + return this.page.getByTestId( + BUILDING_BLOCK_MANAGEMENT_DETAIL_ACTIONS_TEST_IDS.versionSelectDropdown + ); + } + + /** + * Carbon renders the disabled state on the dropdown's inner field button, not + * on the `cds-dropdown` host, so enabled/disabled has to be asserted here. + */ + get versionDropdownButton() { + return this.versionDropdown.getByRole('button').first(); + } + + /** The version badge rendered inside the closed dropdown. */ + get selectedVersionTag() { + return this.versionDropdownButton.locator('cds-tag').first(); + } + + /** + * The same version template renders both the dropdown's selected value and its + * list items, so a version's test id exists twice while the dropdown is open — + * scope to the listbox to get the selectable option. + */ + versionOption(versionTag: string): Locator { + return this.versionDropdown + .getByRole('listbox') + .getByTestId(`${BUILDING_BLOCK_VERSION_OPTION_TEST_ID_PREFIX}${versionTag}`); + } + + get draftVersionInput() { + return this.page.getByTestId( + BUILDING_BLOCK_MANAGEMENT_DETAIL_ACTIONS_TEST_IDS.draftVersionInput + ); + } + + get draftConfirmButton() { + return this.page.getByTestId( + BUILDING_BLOCK_MANAGEMENT_DETAIL_ACTIONS_TEST_IDS.draftConfirmButton + ); + } + + get draftCancelButton() { + return this.page.getByTestId( + BUILDING_BLOCK_MANAGEMENT_DETAIL_ACTIONS_TEST_IDS.draftCancelButton + ); + } + + // ─── Navigation ─────────────────────────────────────────────────── + + detailUrl(key: string, versionTag: string, tab: BuildingBlockTab = BUILDING_BLOCK_TABS.general) { + return `/building-block-management/building-block/${key}/version/${versionTag}/${tab}`; + } + + async goToTab(key: string, versionTag: string, tab: BuildingBlockTab) { + await this.page.goto(this.detailUrl(key, versionTag, tab)); + await this.page.waitForURL(new RegExp(`/version/${versionTag}/${tab}$`)); + await expect(this.versionDropdown).toBeVisible(); + } + + async goToGeneralTab(key: string, versionTag: string) { + await this.goToTab(key, versionTag, BUILDING_BLOCK_TABS.general); + // The metadata form is populated from the definition request. + await expect(this.metadataKeyInput).toHaveValue(key); + } + + // ─── Assertions ─────────────────────────────────────────────────── + + async assertTabsVisible(expectedTabs: readonly string[]) { + await expect(this.tabs).toBeVisible(); + const headings = await this.tabs.getByRole('tab').allInnerTexts(); + expect(headings.map(heading => heading.trim())).toEqual( + expect.arrayContaining([...expectedTabs]) + ); + } + + async assertMetadata(definition: {name: string; key: string; description: string}) { + await expect(this.metadataNameInput).toHaveValue(definition.name); + await expect(this.metadataKeyInput).toHaveValue(definition.key); + await expect(this.metadataDescriptionInput).toHaveValue(definition.description); + } + + async assertUsedPlugins(pluginTitles: readonly string[]) { + await expect(this.usedPlugins).toBeVisible(); + for (const pluginTitle of pluginTitles) { + await expect(this.usedPlugins).toContainText(pluginTitle); + } + } + + async assertNoPluginsUsed(message: string) { + await expect(this.noPluginsUsedMessage).toHaveText(message); + await expect(this.usedPlugins).toHaveCount(0); + } + + async assertSchemaEditorShows(schemaId: string) { + await expect(this.schemaEditor).toBeVisible(); + await expect(this.schemaEditor).toContainText(schemaId); + } + + /** + * Draft versions render as `DRAFT: <tag>`; finalized versions render the tag on + * its own — there is no literal "RELEASE" label in the UI. + */ + async assertDraftVersionSelected(versionTag: string) { + await expect(this.selectedVersionTag).toHaveText(`DRAFT: ${versionTag}`); + } + + async assertFinalVersionSelected(versionTag: string) { + await expect(this.selectedVersionTag).toHaveText(versionTag); + } + + // ─── Metadata actions ───────────────────────────────────────────── + + async fillMetadata(values: {name?: string; description?: string}) { + if (values.name !== undefined) { + await this.metadataNameInput.fill(values.name); + } + if (values.description !== undefined) { + await this.metadataDescriptionInput.fill(values.description); + } + } + + async saveMetadata(key: string, versionTag: string): Promise<Response> { + await expect(this.metadataSaveButton).toBeEnabled(); + const [response] = await Promise.all([ + this.page.waitForResponse( + res => + res.url().includes(`${BUILDING_BLOCK_API_URL}/${key}/version/${versionTag}`) && + res.request().method() === 'PUT' + ), + this.metadataSaveButton.click(), + ]); + return response; + } + + // ─── Artwork actions ────────────────────────────────────────────── + + async selectArtwork(fileName: string) { + const filePath = path.resolve(process.cwd(), 'assets', ARCHIVES_DIR, fileName); + await this.artworkFileUploader + .locator('input.cds--file-input[type="file"]') + .setInputFiles(filePath); + } + + async uploadArtwork(key: string, versionTag: string, fileName: string): Promise<Response> { + await this.selectArtwork(fileName); + await expect(this.artworkUploadButton).toBeEnabled(); + const [response] = await Promise.all([ + this.page.waitForResponse( + res => + res.url().includes(`${BUILDING_BLOCK_API_URL}/${key}/version/${versionTag}/artwork`) && + res.request().method() === 'POST' + ), + this.artworkUploadButton.click(), + ]); + return response; + } + + async openArtworkDeleteConfirmation() { + await this.artworkDeleteButton.click(); + await expect(this.confirmDeleteButton).toBeVisible(); + } + + async confirmArtworkDeletion(key: string, versionTag: string): Promise<Response> { + const [response] = await Promise.all([ + this.page.waitForResponse( + res => + res.url().includes(`${BUILDING_BLOCK_API_URL}/${key}/version/${versionTag}/artwork`) && + res.request().method() === 'DELETE' + ), + this.confirmDeleteButton.click(), + ]); + return response; + } + + // ─── Export ─────────────────────────────────────────────────────── + + async exportBuildingBlock(): Promise<Download> { + const [download] = await Promise.all([ + this.page.waitForEvent('download'), + this.moreMenu.selectOption(BUILDING_BLOCK_MANAGEMENT_DETAIL_ACTIONS_TEST_IDS.exportButton), + ]); + return download; + } + + // ─── Version lifecycle ──────────────────────────────────────────── + + async finalizeVersion(key: string, versionTag: string): Promise<Response> { + const [response] = await Promise.all([ + this.page.waitForResponse( + res => + res.url().includes(`${BUILDING_BLOCK_API_URL}/${key}/version/${versionTag}/finalize`) && + res.request().method() === 'POST' + ), + this.moreMenu.selectOption(BUILDING_BLOCK_MANAGEMENT_DETAIL_ACTIONS_TEST_IDS.makeFinalButton), + ]); + return response; + } + + async openDraftModal() { + await this.moreMenu.selectOption( + BUILDING_BLOCK_MANAGEMENT_DETAIL_ACTIONS_TEST_IDS.createDraftButton + ); + await expect(this.draftVersionInput).toBeVisible(); + } + + async createDraft( + key: string, + basedOnVersionTag: string, + newVersionTag: string + ): Promise<Response> { + await this.draftVersionInput.fill(newVersionTag); + await expect(this.draftConfirmButton).toBeEnabled(); + const [response] = await Promise.all([ + this.page.waitForResponse( + res => + res + .url() + .includes(`${BUILDING_BLOCK_API_URL}/${key}/version/${basedOnVersionTag}/draft`) && + res.request().method() === 'POST' + ), + this.draftConfirmButton.click(), + ]); + return response; + } + + async switchToVersion(versionTag: string) { + await this.openVersionDropdown(); + const option = this.versionOption(versionTag); + await expect(option).toBeVisible(); + await option.click(); + await this.page.waitForURL(new RegExp(`/version/${versionTag}/`)); + } + + async openVersionDropdown() { + await expect(this.versionDropdownButton).toBeEnabled(); + await this.versionDropdownButton.click(); + } + + /** Open the "More" menu, read the actions it offers, and close it again. */ + async readMoreMenuOptions(): Promise<string[]> { + await this.moreMenu.open(); + const labels = await this.moreMenu.optionLabels(); + await this.moreMenu.close(); + return labels; + } + + // ─── API helpers ────────────────────────────────────────────────── + + async createBuildingBlockViaApi(definition: { + key: string; + name: string; + versionTag: string; + description: string; + }): Promise<BuildingBlockDefinition> { + return apiPost<BuildingBlockDefinition>(BUILDING_BLOCK_API_URL, definition); + } + + async getBuildingBlockViaApi(key: string, versionTag: string): Promise<BuildingBlockDefinition> { + return apiGet<BuildingBlockDefinition>( + `${BUILDING_BLOCK_API_URL}/${key}/version/${versionTag}` + ); + } + + async getVersionsViaApi(key: string): Promise<BuildingBlockVersion[]> { + const page = await apiGet<{content: BuildingBlockVersion[]}>( + `${BUILDING_BLOCK_API_URL}/${key}/version?all=true` + ); + return page.content; + } + + async getBuildingBlockProcessesViaApi( + key: string, + versionTag: string + ): Promise<{key: string; name: string; main: boolean}[]> { + return apiGet<{key: string; name: string; main: boolean}[]>( + `${BUILDING_BLOCK_API_URL}/${key}/version/${versionTag}/process-definition` + ); + } + + async getArtworkViaApi(key: string, versionTag: string): Promise<{imageBase64: string} | null> { + try { + return await apiGet<{imageBase64: string}>( + `${BUILDING_BLOCK_API_URL}/${key}/version/${versionTag}/artwork` + ); + } catch { + return null; + } + } + + async deleteArtworkViaApi(key: string, versionTag: string) { + try { + await apiDelete(`${BUILDING_BLOCK_API_URL}/${key}/version/${versionTag}/artwork`); + } catch { + // No artwork to remove. + } + } + + /** + * Best-effort cleanup of the building block created for this suite. + * + * The management API exposes no DELETE for building block definitions + * (`BuildingBlockManagementResource` only serves GET/POST/PUT), so this call + * fails and is swallowed — the building block and its versions stay behind. The + * helper is kept so cleanup starts working as soon as the endpoint is added. + */ + async deleteBuildingBlockViaApi(key: string, versionTag: string) { + try { + await apiDelete(`${BUILDING_BLOCK_API_URL}/${key}/version/${versionTag}`); + } catch { + // No DELETE endpoint yet — nothing to do. + } + } +} diff --git a/e2e/tests/building-block-management/building-block-config.ts b/e2e/tests/building-block-management/building-block-config.ts new file mode 100644 index 0000000000..d41e90f980 --- /dev/null +++ b/e2e/tests/building-block-management/building-block-config.ts @@ -0,0 +1,73 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * A building block that is part of the seeded content of the dev app. Used for + * the read-only list assertions and as the source of a key collision when + * exercising the auto-key generator. + */ +export const SEEDED_BUILDING_BLOCK = { + key: 'income-check', + name: 'Inkomenstoets', + /** + * Slugifies to `income-check`, i.e. the key of the seeded building block + * above, so the auto-key input has to de-duplicate it. + */ + collidingName: 'income check', + deduplicatedKey: 'income-check-1', +} as const; + +/** + * Archive imported by the upload tests. + * + * It deliberately contains only a building block definition and a document + * definition — no BPMN. Archives that carry a process definition can only be + * imported once per key: the second import deploys a second process definition + * for the same key and the backend then fails with "Only one process definition + * should be found for key ...". Without a BPMN the import is idempotent, so this + * archive can be re-imported on every run and the environment never gains more + * than this single building block. + */ +export const IMPORT_ARCHIVE = { + fileName: 'e2e-building-block-import-success_1.0.0.zip', + key: 'e2e-bb-import', + name: 'E2E Imported Building Block', + versionTag: '1.0.0', +} as const; + +/** A ZIP that holds no building block definition at all. */ +export const INVALID_IMPORT_ARCHIVE_FILE_NAME = 'e2e-building-block-import-invalid-file.zip'; + +/** Values used when creating a building block through the create modal. */ +export const NEW_BUILDING_BLOCK = { + namePrefix: 'e2e bb', + versionTag: '1.0.0', + description: 'Building block created by the e2e building block management test.', +} as const; + +export const BUILDING_BLOCK_TEXTS = { + listColumns: ['Name', 'Key', 'Version'], + duplicateKeyError: 'This key is already in use. Please change to a unique key.', + pluginStepTitle: 'Plugin Configuration', + fileSelectStepTitle: 'Upload file', + fileSizeHint: 'Max file size is 500kb. Supported file types are ZIP and JSON.', + overwriteWarning: + 'If your building block definition contains configurations, models and/or definitions with the same name or identifier as already on your system, they will be overwritten.', + overwriteCheckbox: 'I understand that configurations may be overwritten', + uploadSuccess: 'Building block definition successfully imported', + uploadError: + 'Unable to import building block definition. Nothing was imported due to an error at our end.', +} as const; diff --git a/e2e/tests/building-block-management/building-block-management.spec.ts b/e2e/tests/building-block-management/building-block-management.spec.ts new file mode 100644 index 0000000000..4cb631e72d --- /dev/null +++ b/e2e/tests/building-block-management/building-block-management.spec.ts @@ -0,0 +1,308 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {expect, test} from '@playwright/test'; +import {generateId} from '../../utils/dataGenerator'; +import { + BUILDING_BLOCK_TEXTS, + IMPORT_ARCHIVE, + INVALID_IMPORT_ARCHIVE_FILE_NAME, + NEW_BUILDING_BLOCK, + SEEDED_BUILDING_BLOCK, +} from './building-block-config'; +import {BuildingBlockDefinition, BuildingBlockManagementPage} from './page'; + +test.use({storageState: undefined}); + +test.describe('Building block management — building block overview', () => { + let context; + let page; + let request; + let buildingBlockPage: BuildingBlockManagementPage; + let existingBuildingBlocks: BuildingBlockDefinition[]; + + /** Building blocks created through the UI, tracked for cleanup. */ + const createdBuildingBlocks: {key: string; versionTag: string}[] = []; + + test.beforeAll(async ({browser, baseURL}) => { + context = await browser.newContext({baseURL}); + page = await context.newPage(); + request = context.request; + + buildingBlockPage = new BuildingBlockManagementPage(page, request); + + await page.goto('/'); + await buildingBlockPage.goToBuildingBlockManagement(); + + existingBuildingBlocks = await buildingBlockPage.getBuildingBlocksViaApi(); + }); + + test.afterAll(async () => { + // Building block definitions cannot be removed: the management API has no + // DELETE endpoint for them (see `deleteBuildingBlockViaApi`). The calls below + // are no-ops today and will clean up once that endpoint exists. Until then + // the building block created by the create test stays in the environment. + for (const buildingBlock of createdBuildingBlocks) { + await buildingBlockPage.deleteBuildingBlockViaApi( + buildingBlock.key, + buildingBlock.versionTag + ); + } + + await context.close(); + }); + + test.describe('13.1, 13.2 — View building blocks and their metadata', () => { + test('13.1 — Building blocks list is visible and loaded', async () => { + await buildingBlockPage.goToBuildingBlockManagement(); + await buildingBlockPage.assertListLoaded(); + }); + + test('13.1 — List shows every building block returned by the API', async () => { + const buildingBlocks = await buildingBlockPage.getBuildingBlocksViaApi(); + + await buildingBlockPage.goToBuildingBlockManagement(); + + await expect(buildingBlockPage.carbonList.rows).toHaveCount(buildingBlocks.length); + + for (const buildingBlock of buildingBlocks) { + await buildingBlockPage.assertBuildingBlockVisibleByKey(buildingBlock.key); + } + }); + + test('13.2 — List shows the name, key and version columns', async () => { + await buildingBlockPage.goToBuildingBlockManagement(); + await buildingBlockPage.assertColumnHeaders(BUILDING_BLOCK_TEXTS.listColumns); + }); + + test('13.2 — Row shows the name, key and version tag of a building block', async () => { + // Resolve the version from the API instead of hardcoding it, so the test + // does not break when the seeded content moves to another version. + const seeded = existingBuildingBlocks.find( + buildingBlock => buildingBlock.key === SEEDED_BUILDING_BLOCK.key + ); + expect( + seeded, + `seeded building block "${SEEDED_BUILDING_BLOCK.key}" is missing` + ).toBeDefined(); + + await buildingBlockPage.goToBuildingBlockManagement(); + await buildingBlockPage.assertBuildingBlockMetadata({ + name: seeded!.name, + key: seeded!.key, + versionTag: seeded!.versionTag, + }); + }); + }); + + test.describe('13.5–13.9 — Create a new building block', () => { + test('13.6, 13.7 — Key is auto-generated from the name and de-duplicated', async () => { + await buildingBlockPage.openCreateModal(); + + // The key is derived from the name, not typed by the user. + await expect(buildingBlockPage.keyInput).toHaveAttribute('readonly', ''); + await expect(buildingBlockPage.keyInput).toHaveValue(''); + + await buildingBlockPage.nameInput.fill(SEEDED_BUILDING_BLOCK.collidingName); + + // "income check" slugifies to the key of an existing building block, so + // the generator appends a counter instead of producing a duplicate. + await expect(buildingBlockPage.keyInput).toHaveValue(SEEDED_BUILDING_BLOCK.deduplicatedKey); + + await buildingBlockPage.closeCreateModal(); + }); + + test('13.5, 13.8, 13.9 — Creates a building block and opens its detail page', async () => { + const name = `${NEW_BUILDING_BLOCK.namePrefix} ${generateId()}`; + + await buildingBlockPage.openCreateModal(); + await buildingBlockPage.fillCreateForm({ + name, + versionTag: NEW_BUILDING_BLOCK.versionTag, + description: NEW_BUILDING_BLOCK.description, + }); + + const key = await buildingBlockPage.keyInput.inputValue(); + createdBuildingBlocks.push({key, versionTag: NEW_BUILDING_BLOCK.versionTag}); + + const response = await buildingBlockPage.saveCreateForm(); + expect(response.status()).toBe(200); + + // Saving navigates straight to the new building block's General tab. + await buildingBlockPage.assertOnBuildingBlockDetail(key, NEW_BUILDING_BLOCK.versionTag); + + // Name, version and description were persisted as entered. + const created = await buildingBlockPage.getBuildingBlockViaApi( + key, + NEW_BUILDING_BLOCK.versionTag + ); + expect(created).toMatchObject({ + key, + name, + versionTag: NEW_BUILDING_BLOCK.versionTag, + description: NEW_BUILDING_BLOCK.description, + }); + + // And it shows up in the overview. + await buildingBlockPage.goToBuildingBlockManagement(); + await buildingBlockPage.assertBuildingBlockMetadata({ + name, + key, + versionTag: NEW_BUILDING_BLOCK.versionTag, + }); + }); + + test.describe('Failure scenarios', () => { + test('Cannot save without a name or a version', async () => { + await buildingBlockPage.openCreateModal(); + await expect(buildingBlockPage.createSaveButton).toBeDisabled(); + + // Name only — version is still missing. + await buildingBlockPage.nameInput.fill(`${NEW_BUILDING_BLOCK.namePrefix} ${generateId()}`); + await expect(buildingBlockPage.keyInput).not.toHaveValue(''); + await expect(buildingBlockPage.createSaveButton).toBeDisabled(); + + // Version only — clearing the name also clears the generated key. + await buildingBlockPage.versionInput.fill(NEW_BUILDING_BLOCK.versionTag); + await buildingBlockPage.nameInput.fill(''); + await expect(buildingBlockPage.createSaveButton).toBeDisabled(); + + await buildingBlockPage.closeCreateModal(); + }); + + test('Cannot save with a manually entered duplicate key', async () => { + await buildingBlockPage.openCreateModal(); + await buildingBlockPage.fillCreateForm({ + name: `${NEW_BUILDING_BLOCK.namePrefix} ${generateId()}`, + versionTag: NEW_BUILDING_BLOCK.versionTag, + }); + await expect(buildingBlockPage.createSaveButton).toBeEnabled(); + + await buildingBlockPage.enterKeyManually(SEEDED_BUILDING_BLOCK.key); + + await expect(buildingBlockPage.duplicateKeyError).toBeVisible(); + await expect(buildingBlockPage.createSaveButton).toBeDisabled(); + + await buildingBlockPage.closeCreateModal(); + + // Nothing was created. + const buildingBlocks = await buildingBlockPage.getBuildingBlocksViaApi(); + expect( + buildingBlocks.filter(buildingBlock => buildingBlock.key === SEEDED_BUILDING_BLOCK.key) + ).toHaveLength(1); + }); + + test('Cannot save with a key that contains invalid characters', async () => { + await buildingBlockPage.openCreateModal(); + await buildingBlockPage.fillCreateForm({ + name: `${NEW_BUILDING_BLOCK.namePrefix} ${generateId()}`, + versionTag: NEW_BUILDING_BLOCK.versionTag, + }); + await expect(buildingBlockPage.createSaveButton).toBeEnabled(); + + // Only [A-Za-z0-9-] is accepted. + await buildingBlockPage.enterKeyManually('invalid key!'); + await expect(buildingBlockPage.createSaveButton).toBeDisabled(); + + await buildingBlockPage.closeCreateModal(); + }); + }); + }); + + test.describe('13.3, 13.4 — Upload a building block definition', () => { + test('13.3 — Wizard starts on the plugin step and advances to file select', async () => { + await buildingBlockPage.openUploadModal(); + + // Step 1 only informs about plugins — no file input yet. + await expect(buildingBlockPage.fileUploader).not.toBeVisible(); + + await buildingBlockPage.goToFileSelectStep(); + + await expect(buildingBlockPage.fileUploader).toContainText( + BUILDING_BLOCK_TEXTS.fileSelectStepTitle + ); + await expect(buildingBlockPage.fileUploader).toContainText(BUILDING_BLOCK_TEXTS.fileSizeHint); + // The file select step is the only step that can be stepped back from. + await expect(buildingBlockPage.uploadBackButton).toBeVisible(); + + await buildingBlockPage.uploadCancelButton.click(); + }); + + test('13.4 — Upload is blocked until the overwrite warning is acknowledged', async () => { + await buildingBlockPage.openUploadModal(); + await buildingBlockPage.goToFileSelectStep(); + + await expect(buildingBlockPage.overwriteWarning).toContainText( + BUILDING_BLOCK_TEXTS.overwriteWarning + ); + await expect(buildingBlockPage.overwriteCheckbox).toContainText( + BUILDING_BLOCK_TEXTS.overwriteCheckbox + ); + + // No file and no acknowledgement. + await expect(buildingBlockPage.uploadNextButton).toBeDisabled(); + + // A file on its own is not enough. + await buildingBlockPage.selectArchive(IMPORT_ARCHIVE.fileName); + await expect(buildingBlockPage.uploadNextButton).toBeDisabled(); + + // Acknowledging the warning unlocks the upload. + await buildingBlockPage.acknowledgeOverwriteWarning(); + await expect(buildingBlockPage.uploadNextButton).toBeEnabled(); + + await buildingBlockPage.uploadCancelButton.click(); + }); + + test('13.3, 13.4 — Imports a building block archive', async () => { + const response = await buildingBlockPage.importArchive(IMPORT_ARCHIVE.fileName); + expect(response.status()).toBe(200); + + await buildingBlockPage.assertUploadSucceeded(); + await buildingBlockPage.finishUpload(); + + // The imported building block is available through the API... + const imported = await buildingBlockPage.getBuildingBlockViaApi( + IMPORT_ARCHIVE.key, + IMPORT_ARCHIVE.versionTag + ); + expect(imported).toMatchObject({ + key: IMPORT_ARCHIVE.key, + name: IMPORT_ARCHIVE.name, + versionTag: IMPORT_ARCHIVE.versionTag, + }); + + // ...and listed in the overview. + await buildingBlockPage.goToBuildingBlockManagement(); + await buildingBlockPage.assertBuildingBlockMetadata(IMPORT_ARCHIVE); + }); + + test.describe('Failure scenarios', () => { + test('An archive without a building block definition fails the import', async () => { + const response = await buildingBlockPage.importArchive(INVALID_IMPORT_ARCHIVE_FILE_NAME); + expect(response.ok()).toBe(false); + + await buildingBlockPage.assertUploadFailed(); + await buildingBlockPage.finishUpload(); + + // No building block was added. + const buildingBlocks = await buildingBlockPage.getBuildingBlocksViaApi(); + expect(buildingBlocks.map(buildingBlock => buildingBlock.key)).not.toContain( + 'not-a-building-block' + ); + }); + }); + }); +}); diff --git a/e2e/tests/building-block-management/page.ts b/e2e/tests/building-block-management/page.ts new file mode 100644 index 0000000000..53b1fcc889 --- /dev/null +++ b/e2e/tests/building-block-management/page.ts @@ -0,0 +1,360 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + type APIRequestContext, + type Locator, + type Page, + type Response, + expect, +} from '@playwright/test'; +import path from 'path'; +import { + AUTO_KEY_INPUT_TEST_IDS, + BUILDING_BLOCK_MANAGEMENT_CREATE_TEST_IDS, + BUILDING_BLOCK_MANAGEMENT_LIST_TEST_IDS, + BUILDING_BLOCK_MANAGEMENT_UPLOAD_TEST_IDS, +} from '../../constants'; +import {CarbonList} from '../../shared/carbon-list/carbon-list.utils'; +import {apiDelete, apiGet} from '../../utils/api.utils'; +import {BUILDING_BLOCK_TEXTS} from './building-block-config'; + +const ARCHIVES_DIR = 'building-block-archives'; +const BUILDING_BLOCK_API_URL = '/api/management/v1/building-block'; + +function escapeForRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +export interface BuildingBlockDefinition { + key: string; + name: string; + versionTag: string; + description: string; +} + +export interface CreateBuildingBlockValues { + name: string; + versionTag: string; + description?: string; +} + +export class BuildingBlockManagementPage { + readonly carbonList: CarbonList; + private readonly listScope: Locator; + + constructor( + private readonly page: Page, + private readonly request: APIRequestContext + ) { + this.listScope = page.locator('valtimo-building-block-management-list'); + this.carbonList = new CarbonList(page, this.listScope); + } + + // ─── List locators ──────────────────────────────────────────────── + + get uploadButton() { + return this.page.getByTestId(BUILDING_BLOCK_MANAGEMENT_LIST_TEST_IDS.uploadButton); + } + + get createButton() { + return this.page.getByTestId(BUILDING_BLOCK_MANAGEMENT_LIST_TEST_IDS.createButton); + } + + // ─── Create modal locators ──────────────────────────────────────── + + get createModal() { + return this.page.locator('valtimo-building-block-management-create-modal'); + } + + get nameInput() { + return this.page.getByTestId(BUILDING_BLOCK_MANAGEMENT_CREATE_TEST_IDS.nameInput); + } + + get versionInput() { + return this.page.getByTestId(BUILDING_BLOCK_MANAGEMENT_CREATE_TEST_IDS.versionInput); + } + + get descriptionInput() { + return this.page.getByTestId(BUILDING_BLOCK_MANAGEMENT_CREATE_TEST_IDS.descriptionInput); + } + + get keyInput() { + return this.page.getByTestId(AUTO_KEY_INPUT_TEST_IDS.input); + } + + get keyEditButton() { + return this.page.getByTestId(AUTO_KEY_INPUT_TEST_IDS.editButton); + } + + get createSaveButton() { + return this.page.getByTestId(BUILDING_BLOCK_MANAGEMENT_CREATE_TEST_IDS.saveButton); + } + + get createCancelButton() { + return this.page.getByTestId(BUILDING_BLOCK_MANAGEMENT_CREATE_TEST_IDS.cancelButton); + } + + /** + * Validation message of the auto-key input. Carbon renders `invalidText` + * inside its own `cds-label` markup, so there is no project-owned element to + * hang a test id on — the message is asserted by text, scoped to the modal. + */ + get duplicateKeyError() { + return this.createModal.getByText(BUILDING_BLOCK_TEXTS.duplicateKeyError); + } + + // ─── Upload modal locators ──────────────────────────────────────── + + get fileUploader() { + return this.page.getByTestId(BUILDING_BLOCK_MANAGEMENT_UPLOAD_TEST_IDS.fileUploader); + } + + get overwriteWarning() { + return this.page.getByTestId(BUILDING_BLOCK_MANAGEMENT_UPLOAD_TEST_IDS.overwriteWarning); + } + + get overwriteCheckbox() { + return this.page.getByTestId(BUILDING_BLOCK_MANAGEMENT_UPLOAD_TEST_IDS.overwriteCheckbox); + } + + get uploadProgressBar() { + return this.page.getByTestId(BUILDING_BLOCK_MANAGEMENT_UPLOAD_TEST_IDS.progressBar); + } + + get uploadNextButton() { + return this.page.getByTestId(BUILDING_BLOCK_MANAGEMENT_UPLOAD_TEST_IDS.nextButton); + } + + get uploadBackButton() { + return this.page.getByTestId(BUILDING_BLOCK_MANAGEMENT_UPLOAD_TEST_IDS.backButton); + } + + get uploadCancelButton() { + return this.page.getByTestId(BUILDING_BLOCK_MANAGEMENT_UPLOAD_TEST_IDS.cancelButton); + } + + get uploadFinishButton() { + return this.page.getByTestId(BUILDING_BLOCK_MANAGEMENT_UPLOAD_TEST_IDS.finishButton); + } + + // ─── Navigation ─────────────────────────────────────────────────── + + /** + * Navigate straight to the route instead of going through the Admin menu: + * loading the dashboard first crashes the Chromium renderer on heavy admin + * routes. + */ + async goToBuildingBlockManagement() { + await this.page.goto('/building-block-management'); + await this.page.waitForURL(/\/building-block-management$/); + await this.carbonList.waitForLoaded(); + } + + // ─── List assertions ────────────────────────────────────────────── + + async assertListLoaded() { + await this.carbonList.waitForLoaded(); + await expect(this.carbonList.table).toBeVisible(); + await expect(this.uploadButton).toBeVisible(); + await expect(this.createButton).toBeVisible(); + } + + async assertColumnHeaders(expectedHeaders: readonly string[]) { + const headers = await this.carbonList.table.locator('thead th').allInnerTexts(); + expect(headers.map(header => header.trim())).toEqual([...expectedHeaders]); + } + + async assertBuildingBlockVisible(name: string) { + await this.carbonList.row(name).assertVisible(); + } + + /** + * Locate a row by its key column. Names are not unique — two building blocks + * may share a name — and substring matching would make a name that is a prefix + * of another match several rows, so identify rows by their exact key. + */ + async assertBuildingBlockVisibleByKey(key: string) { + await this.carbonList.row(new RegExp(`^${escapeForRegExp(key)}$`)).assertVisible(); + } + + /** + * Assert the name, key and version tag rendered for a single building block. + * The version is rendered as a Carbon tag rather than plain cell text. + */ + async assertBuildingBlockMetadata(definition: {name: string; key: string; versionTag: string}) { + const row = this.carbonList.row(new RegExp(`^${escapeForRegExp(definition.key)}$`)); + await row.assertVisible(); + await expect(row.cellByIndex(0)).toHaveText(definition.name); + await expect(row.cellByIndex(1)).toHaveText(definition.key); + await row.assertTagCount(1); + await expect(row.tags).toHaveText(definition.versionTag); + } + + // ─── Create modal ───────────────────────────────────────────────── + + /** + * Always reload the list before opening the modal. A Carbon modal resets its + * reactive form 240 ms after closing, so re-opening a modal within the same + * page state can have that pending reset wipe the freshly filled form. + */ + async openCreateModal() { + await this.goToBuildingBlockManagement(); + await this.createButton.click(); + await expect(this.nameInput).toBeVisible(); + await expect(this.createSaveButton).toBeVisible(); + } + + async fillCreateForm(values: CreateBuildingBlockValues) { + await this.nameInput.fill(values.name); + // The key is derived from the name asynchronously — wait for it to land + // before touching the rest of the form. + await expect(this.keyInput).not.toHaveValue(''); + await this.versionInput.fill(values.versionTag); + + if (values.description !== undefined) { + await this.descriptionInput.fill(values.description); + } + } + + /** Switch the auto-key input to manual mode and type a key. */ + async enterKeyManually(key: string) { + await this.keyEditButton.click(); + await expect(this.keyEditButton).not.toBeVisible(); + await this.keyInput.fill(key); + } + + async saveCreateForm(): Promise<Response> { + await expect(this.createSaveButton).toBeEnabled(); + const [response] = await Promise.all([ + this.page.waitForResponse( + res => res.url().endsWith(BUILDING_BLOCK_API_URL) && res.request().method() === 'POST' + ), + this.createSaveButton.click(), + ]); + return response; + } + + async closeCreateModal() { + await this.createCancelButton.click(); + await expect(this.page.locator('.cds--modal.is-visible')).not.toBeVisible(); + } + + async assertOnBuildingBlockDetail(key: string, versionTag: string) { + await this.page.waitForURL( + `**/building-block-management/building-block/${key}/version/${versionTag}/general` + ); + } + + // ─── Upload modal ───────────────────────────────────────────────── + + /** Opens the wizard, which starts on the plugin configuration step. */ + async openUploadModal() { + await this.goToBuildingBlockManagement(); + await this.uploadButton.click(); + await expect(this.page.getByText(BUILDING_BLOCK_TEXTS.pluginStepTitle).first()).toBeVisible(); + } + + async goToFileSelectStep() { + await expect(this.uploadNextButton).toBeEnabled(); + await this.uploadNextButton.click(); + await expect(this.fileUploader).toBeVisible(); + } + + async selectArchive(fileName: string) { + const filePath = path.resolve(process.cwd(), 'assets', ARCHIVES_DIR, fileName); + await this.fileUploader.locator('input.cds--file-input[type="file"]').setInputFiles(filePath); + } + + /** + * Tick the overwrite acknowledgement. The inner label has to be clicked — a + * click on the `cds-checkbox` host does not emit Carbon's `checkedChange`. + */ + async acknowledgeOverwriteWarning() { + await this.overwriteCheckbox.locator('label').click(); + } + + async startUpload(): Promise<Response> { + await expect(this.uploadNextButton).toBeEnabled(); + const [response] = await Promise.all([ + this.page.waitForResponse( + res => + res.url().includes(`${BUILDING_BLOCK_API_URL}/import`) && + res.request().method() === 'POST' + ), + this.uploadNextButton.click(), + ]); + return response; + } + + async finishUpload() { + await expect(this.uploadFinishButton).toBeVisible(); + await this.uploadFinishButton.click(); + await expect(this.page.locator('.cds--modal.is-visible')).not.toBeVisible(); + } + + /** Full happy-path import: plugin step → file select → acknowledge → upload. */ + async importArchive(fileName: string): Promise<Response> { + await this.openUploadModal(); + await this.goToFileSelectStep(); + await this.selectArchive(fileName); + await this.acknowledgeOverwriteWarning(); + return this.startUpload(); + } + + async assertUploadSucceeded() { + await expect(this.uploadProgressBar).toContainText(BUILDING_BLOCK_TEXTS.uploadSuccess); + await expect(this.uploadProgressBar).toHaveClass(/cds--progress-bar--finished/); + } + + async assertUploadFailed() { + await expect(this.uploadProgressBar).toContainText(BUILDING_BLOCK_TEXTS.uploadError); + await expect(this.uploadProgressBar).toHaveClass(/cds--progress-bar--error/); + // The failed step only offers "Finish" — no retry, cancel or back. + await expect(this.uploadFinishButton).toBeVisible(); + await expect(this.uploadCancelButton).not.toBeVisible(); + await expect(this.uploadBackButton).not.toBeVisible(); + } + + // ─── API helpers ────────────────────────────────────────────────── + + async getBuildingBlocksViaApi(): Promise<BuildingBlockDefinition[]> { + return apiGet<BuildingBlockDefinition[]>(BUILDING_BLOCK_API_URL); + } + + async getBuildingBlockViaApi(key: string, versionTag: string): Promise<BuildingBlockDefinition> { + return apiGet<BuildingBlockDefinition>( + `${BUILDING_BLOCK_API_URL}/${key}/version/${versionTag}` + ); + } + + /** + * Best-effort cleanup of a building block created during a test. + * + * The management API currently exposes no DELETE for building block + * definitions (`BuildingBlockManagementResource` only serves GET/POST/PUT), so + * this call fails and is swallowed — building blocks created through the UI + * stay behind. The helper is kept in place so cleanup starts working as soon + * as the endpoint is added. + */ + async deleteBuildingBlockViaApi(key: string, versionTag: string) { + try { + await apiDelete(`${BUILDING_BLOCK_API_URL}/${key}/version/${versionTag}`); + } catch { + // No DELETE endpoint yet — nothing to do. + } + } +} diff --git a/e2e/tests/building-block-processes/building-block-plugins-config.ts b/e2e/tests/building-block-processes/building-block-plugins-config.ts new file mode 100644 index 0000000000..ae7c3a114f --- /dev/null +++ b/e2e/tests/building-block-processes/building-block-plugins-config.ts @@ -0,0 +1,132 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * The building block used by the plugin integration tests. Separate from the + * one in `building-block-processes-config.ts` so the two suites cannot disturb + * each other's process definitions. + */ +export const PLUGIN_TEST_BUILDING_BLOCK = { + keyPrefix: 'e2e-bb-plugins', + namePrefix: 'E2E BB Plugins', + description: 'Building block created by the e2e building block plugin integration test.', + versionTag: '1.0.0', +} as const; + +/** + * The BPMN uploaded for these tests. Only some element types can carry a process + * link, and which link types they offer differs per type — hence one process with + * one of each. + * + * Every step carries an implementation, because the building block process upload + * rejects a diagram that `ProcessDefinitionValidator` raises *warnings* for, and + * a step without a process link or implementation is exactly such a warning. + */ +export const PLUGIN_STEPS_PROCESS = { + fileName: 'e2e-plugin-steps-process.bpmn', + key: 'e2e-plugin-steps-process', + name: 'E2E Plugin Steps Process', + /** Offers only the Plugin link type, so the wizard skips the chooser step. */ + serviceTaskId: 'ServiceTask_1', + serviceTaskName: 'Service step', + /** Offers Form / FormFlow / Plugin, plus UI Component (disabled here). */ + userTaskId: 'UserTask_1', + /** The only type that additionally offers the Building block link type. */ + callActivityId: 'CallActivity_1', +} as const; + +/** + * The plugin the tests link to, and two of its actions. + * + * A building block links a step to a plugin **definition**, not to a plugin + * *configuration* — the configuration is bound later, when a case links to the + * building block. So the selection step lists definitions and the saved link + * carries `pluginConfigurationId: null`. + */ +export const LINKED_PLUGIN = { + definitionKey: 'zakenapi', + title: 'Zaken API', + /** Requires no action properties, so Complete is enabled immediately. */ + actionWithoutRequiredProperties: { + key: 'link-uploaded-document-to-zaak', + label: 'Link uploaded document to zaak', + configurationMessage: 'Linking an uploaded document to a zaak requires no configuration.', + }, + /** Has two required properties, so Complete stays disabled until they are filled. */ + actionWithRequiredProperties: { + key: 'create-zaak', + label: 'Create zaak', + description: + 'This action creates a zaak in the Zaken API and links the new zaak with the case.', + requiredPropertyLabels: ['RSIN (required)', 'Zaaktype URL (required)'], + /** + * Values the required properties are filled with. Never executed — the link + * only has to be *configurable*, so the zaaktype URL need not resolve. + */ + values: { + rsin: '002564440', + zaaktypeUrl: 'http://localhost:8001/catalogi/api/v1/zaaktypen/e2e-building-block', + }, + }, +} as const; + +/** + * A second plugin definition, used to assert the list is not a single row. + * + * Both plugins are picked for a *service task*: the definitions on offer are + * filtered by activity type, and a user task is only offered `portaaltaak`. + */ +export const OTHER_PLUGIN = { + definitionKey: 'documentenapi', + title: 'Documenten API', +} as const; + +export const BUILDING_BLOCK_PLUGIN_TEXTS = { + /** Steps of the plugin branch of the wizard, in order. */ + wizardSteps: ['Select plugin definition', 'Choose your action', 'Configure your action'], + modalHeading: (stepName: string) => `Process step: ${stepName}`, + /** Description of the plugin selection step, which names *definitions*. */ + selectPluginDescription: 'Choose the plugin definition you want to link to the process step.', + /** + * Columns of the plugin selection list. The first one holds the logo and has no + * header text. + */ + selectPluginColumns: ['', 'Plugin name', 'Plugin description'], + /** + * Link types a user task offers. `ui-component` is listed too but rendered + * disabled — see `UNSUPPORTED_PROCESS_LINK_TYPES_IN_BUILDING_BLOCK`. + */ + userTaskLinkTypes: ['form', 'form-flow', 'plugin', 'ui-component'], + userTaskEnabledLinkTypes: ['form', 'form-flow', 'plugin'], + unsupportedLinkType: 'ui-component', + /** A call activity is the only step type that may point at another building block. */ + callActivityLinkTypes: ['plugin', 'building-block'], + /** + * Header of the wizard while an existing plugin link is being edited. Inside a + * building block the link points at a plugin *definition*, so there is no + * configuration title to show and the placeholder is rendered instead. + */ + noPluginConfigurationLabel: 'Plugin configuration: -', +} as const; + +export const BUILDING_BLOCK_PLUGIN_API = { + /** Plugin definitions that can be linked to a given activity type. */ + pluginDefinitions: (activityType: string) => + `/api/v1/plugin/definition?activityType=${encodeURIComponent(activityType)}`, + processLinks: (processDefinitionId: string) => + `/api/v1/process-link?processDefinitionId=${encodeURIComponent(processDefinitionId)}`, + serviceTaskActivityType: 'bpmn:ServiceTask:start', +} as const; diff --git a/e2e/tests/building-block-processes/building-block-plugins.spec.ts b/e2e/tests/building-block-processes/building-block-plugins.spec.ts new file mode 100644 index 0000000000..0da4c9d67f --- /dev/null +++ b/e2e/tests/building-block-processes/building-block-plugins.spec.ts @@ -0,0 +1,392 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {expect, test} from '@playwright/test'; +import {generateId} from '../../utils/dataGenerator'; +import { + BUILDING_BLOCK_PLUGIN_API, + BUILDING_BLOCK_PLUGIN_TEXTS, + LINKED_PLUGIN, + OTHER_PLUGIN, + PLUGIN_STEPS_PROCESS, + PLUGIN_TEST_BUILDING_BLOCK, +} from './building-block-plugins-config'; +import {BuildingBlockProcessesPage} from './page'; + +test.use({storageState: undefined}); + +/** + * Feature 13E — linking a building block's process steps to plugins. + * + * Inside a building block a step is linked to a plugin **definition**, not to a + * plugin *configuration*: the configuration is bound later, when a case links to + * the building block. So the selection step lists definitions, and a saved link + * carries `pluginConfigurationId: null` with `referenceType: BUILDING_BLOCK`. + * + * All tests share one building block whose only extra process is the seeded + * plugin-steps diagram. That diagram is re-read before every test: a link made + * in the modeler lives in memory until the diagram is saved, and saving deploys + * a *new* definition, so the process definition id changes underneath the suite. + */ +test.describe('Building block management — plugin integration (13E)', () => { + let context; + let page; + let request; + let pluginsPage: BuildingBlockProcessesPage; + + const uniqueId = generateId(); + const buildingBlockKey = `${PLUGIN_TEST_BUILDING_BLOCK.keyPrefix}-${uniqueId}`; + const versionTag = PLUGIN_TEST_BUILDING_BLOCK.versionTag; + + /** Latest definition of the seeded plugin-steps process. */ + const currentProcess = async () => { + const process = await pluginsPage.getProcessByKeyViaApi( + buildingBlockKey, + versionTag, + PLUGIN_STEPS_PROCESS.key + ); + expect(process, 'the plugin steps process is deployed').toBeTruthy(); + return process!; + }; + + const openWizardFor = async (elementId: string) => { + const process = await currentProcess(); + await pluginsPage.openLinkWizardForStep(buildingBlockKey, versionTag, process.id, elementId); + return process; + }; + + test.beforeAll(async ({browser, baseURL}) => { + context = await browser.newContext({baseURL}); + page = await context.newPage(); + request = context.request; + + pluginsPage = new BuildingBlockProcessesPage(page, request); + + await pluginsPage.createBuildingBlockViaApi({ + key: buildingBlockKey, + name: `${PLUGIN_TEST_BUILDING_BLOCK.namePrefix} ${uniqueId}`, + versionTag, + description: PLUGIN_TEST_BUILDING_BLOCK.description, + }); + + await page.goto('/'); + + // The diagram carries one of every step type that can hold a process link. + await pluginsPage.goToProcessesTab(buildingBlockKey, versionTag); + const upload = await pluginsPage.uploadProcess( + buildingBlockKey, + versionTag, + PLUGIN_STEPS_PROCESS.fileName + ); + expect(upload.status(), 'the plugin steps process uploaded').toBe(204); + }); + + test.afterAll(async () => { + // Removes the uploaded process and every version a save deployed. The + // generated main definition stays — it cannot be deleted — and so does the + // building block itself: the management API exposes no DELETE for it. + await pluginsPage.deleteAddedProcessesViaApi(buildingBlockKey, versionTag, buildingBlockKey); + await pluginsPage.deleteBuildingBlockViaApi(buildingBlockKey, versionTag); + + await context.close(); + }); + + test.describe('13.39, 13.42 — Available plugins', () => { + test('13.39 — A service task lists the plugin definitions it can be linked to', async () => { + await openWizardFor(PLUGIN_STEPS_PROCESS.serviceTaskId); + + // A service task can only carry a plugin link, so the wizard skips the + // link type chooser and opens straight on the plugin selection step. + await expect(pluginsPage.linkWizard.typeChooser).toBeHidden(); + await expect(pluginsPage.linkWizard.pluginStep).toBeVisible(); + await expect(pluginsPage.linkWizard.heading).toHaveText( + BUILDING_BLOCK_PLUGIN_TEXTS.modalHeading(PLUGIN_STEPS_PROCESS.serviceTaskName) + ); + + // The step names definitions, not configurations. + await expect(pluginsPage.linkWizard.pluginStep).toHaveText( + BUILDING_BLOCK_PLUGIN_TEXTS.selectPluginDescription + ); + + const offered = await pluginsPage.linkWizard.offeredPluginIds(); + expect(offered).toEqual(expect.arrayContaining([LINKED_PLUGIN.definitionKey])); + // More than one plugin is offered, so the list is a real choice. + expect(offered).toEqual(expect.arrayContaining([OTHER_PLUGIN.definitionKey])); + + // Every offered definition is one the backend serves for this activity + // type. The UI additionally drops definitions without a frontend plugin + // specification, so it is a subset rather than an exact match. + const definitions = await pluginsPage.getPluginDefinitionsViaApi( + BUILDING_BLOCK_PLUGIN_API.serviceTaskActivityType + ); + const definitionKeys = definitions.map(definition => definition.key); + expect(definitionKeys).toEqual(expect.arrayContaining(offered)); + + await pluginsPage.closeProcessLinkModal(); + }); + + test('13.42 — The list shows each plugin with its description, and the wizard its steps', async () => { + await openWizardFor(PLUGIN_STEPS_PROCESS.serviceTaskId); + + expect( + await pluginsPage.linkWizard.pluginList.locator('cds-list-header cds-list-column') + .allInnerTexts() + ).toEqual([...BUILDING_BLOCK_PLUGIN_TEXTS.selectPluginColumns]); + + // Logo, name and a non-empty description, in that column order. + const row = pluginsPage.linkWizard.pluginRow(LINKED_PLUGIN.definitionKey); + const columns = row.locator('cds-list-column'); + await expect(columns.nth(0).locator('img')).toBeVisible(); + await expect(columns.nth(1)).toHaveText(LINKED_PLUGIN.title); + await expect(columns.nth(2)).not.toBeEmpty(); + + expect(await pluginsPage.linkWizard.stepTitles()).toEqual( + expect.arrayContaining([...BUILDING_BLOCK_PLUGIN_TEXTS.wizardSteps]) + ); + + await pluginsPage.closeProcessLinkModal(); + }); + }); + + test.describe('13.40 — Select a plugin for a step', () => { + test('13.40 — Picking a plugin enables Next and reveals its actions', async () => { + await openWizardFor(PLUGIN_STEPS_PROCESS.serviceTaskId); + + // Nothing is preselected, so the wizard cannot be advanced yet. + await expect(pluginsPage.linkWizard.nextButton).toBeDisabled(); + + await pluginsPage.linkWizard.selectPlugin(LINKED_PLUGIN.definitionKey); + await expect(pluginsPage.linkWizard.nextButton).toBeEnabled(); + + await pluginsPage.linkWizard.next(); + await expect(pluginsPage.linkWizard.actionStep).toBeVisible(); + + // The chosen plugin is echoed by the progress indicator. + expect(await pluginsPage.linkWizard.stepTitles()).toEqual( + expect.arrayContaining([LINKED_PLUGIN.title]) + ); + + const actionKeys = await pluginsPage.linkWizard.offeredActionKeys(); + expect(actionKeys).toEqual( + expect.arrayContaining([ + LINKED_PLUGIN.actionWithoutRequiredProperties.key, + LINKED_PLUGIN.actionWithRequiredProperties.key, + ]) + ); + await expect(pluginsPage.linkWizard.noActionsMessage).toHaveCount(0); + + await pluginsPage.closeProcessLinkModal(); + }); + + test('13.40 — A call activity offers the plugin link type alongside the building block one', async () => { + await openWizardFor(PLUGIN_STEPS_PROCESS.callActivityId); + + // A call activity can hold more than one link type, so the chooser is shown. + await expect(pluginsPage.linkWizard.typeChooser).toBeVisible(); + expect(await pluginsPage.linkWizard.offeredLinkTypes()).toEqual([ + ...BUILDING_BLOCK_PLUGIN_TEXTS.callActivityLinkTypes, + ]); + + await pluginsPage.linkWizard.chooseLinkType('plugin'); + await expect(pluginsPage.linkWizard.pluginStep).toBeVisible(); + + await pluginsPage.closeProcessLinkModal(); + }); + }); + + test.describe('13.41, 13.44 — Configure the action', () => { + test('13.41 — An action without properties is configurable straight away', async () => { + await openWizardFor(PLUGIN_STEPS_PROCESS.serviceTaskId); + + await pluginsPage.linkWizard.advanceToActionConfiguration( + LINKED_PLUGIN.definitionKey, + LINKED_PLUGIN.actionWithoutRequiredProperties.key + ); + + await expect(pluginsPage.linkWizard.actionConfigurationStep).toBeVisible(); + // 13.42 — the step explains that this action needs no configuration. + await expect(pluginsPage.linkWizard.host).toContainText( + LINKED_PLUGIN.actionWithoutRequiredProperties.configurationMessage + ); + await expect(pluginsPage.linkWizard.completeButton).toBeEnabled(); + + await pluginsPage.closeProcessLinkModal(); + }); + + test('13.41, 13.44 — An action with properties describes them and accepts values', async () => { + await openWizardFor(PLUGIN_STEPS_PROCESS.serviceTaskId); + + await pluginsPage.linkWizard.advanceToActionConfiguration( + LINKED_PLUGIN.definitionKey, + LINKED_PLUGIN.actionWithRequiredProperties.key + ); + + // 13.42 — the action explains what it does and which properties it needs. + await expect(pluginsPage.linkWizard.host).toContainText( + LINKED_PLUGIN.actionWithRequiredProperties.description + ); + for (const label of LINKED_PLUGIN.actionWithRequiredProperties.requiredPropertyLabels) { + await expect(pluginsPage.linkWizard.host.getByText(label)).toBeVisible(); + } + + // 13.44 — the execution properties are filled in. + await pluginsPage.fillCreateZaakProperties(LINKED_PLUGIN.actionWithRequiredProperties.values); + await expect(pluginsPage.createZaakRsinInput).toHaveValue( + LINKED_PLUGIN.actionWithRequiredProperties.values.rsin + ); + await expect(pluginsPage.linkWizard.completeButton).toBeEnabled(); + + await pluginsPage.closeProcessLinkModal(); + }); + + test.describe('Failure scenarios', () => { + test('13.45a — Complete stays disabled while a required property is empty', async () => { + await openWizardFor(PLUGIN_STEPS_PROCESS.serviceTaskId); + + await pluginsPage.linkWizard.advanceToActionConfiguration( + LINKED_PLUGIN.definitionKey, + LINKED_PLUGIN.actionWithRequiredProperties.key + ); + + // Both properties are required, so an empty form cannot be completed. + await expect(pluginsPage.linkWizard.completeButton).toBeDisabled(); + + // Filling only one of the two is not enough either. + await pluginsPage.createZaakRsinInput.fill( + LINKED_PLUGIN.actionWithRequiredProperties.values.rsin + ); + await expect(pluginsPage.linkWizard.completeButton).toBeDisabled(); + + await pluginsPage.createZaakZaaktypeUrlInput.fill( + LINKED_PLUGIN.actionWithRequiredProperties.values.zaaktypeUrl + ); + await expect(pluginsPage.linkWizard.completeButton).toBeEnabled(); + + // Clearing one again disables it, so the guard is not a one-way latch. + await pluginsPage.createZaakRsinInput.fill(''); + await expect(pluginsPage.linkWizard.completeButton).toBeDisabled(); + + // Cancelling stores nothing. + await pluginsPage.closeProcessLinkModal(); + await expect(pluginsPage.createProcessLinkButton).toBeVisible(); + }); + + test('13.45b — A user task cannot be linked to a UI component inside a building block', async () => { + await openWizardFor(PLUGIN_STEPS_PROCESS.userTaskId); + + await expect(pluginsPage.linkWizard.typeChooser).toBeVisible(); + expect(await pluginsPage.linkWizard.offeredLinkTypes()).toEqual([ + ...BUILDING_BLOCK_PLUGIN_TEXTS.userTaskLinkTypes, + ]); + + // The UI component type is offered but rendered disabled: it is not + // supported inside a building block. + await expect( + pluginsPage.linkWizard.typeButton(BUILDING_BLOCK_PLUGIN_TEXTS.unsupportedLinkType) + ).toBeDisabled(); + for (const linkType of BUILDING_BLOCK_PLUGIN_TEXTS.userTaskEnabledLinkTypes) { + await expect(pluginsPage.linkWizard.typeButton(linkType)).toBeEnabled(); + } + + await pluginsPage.closeProcessLinkModal(); + }); + }); + }); + + test.describe('13.43 — Link an action to a plugin definition', () => { + test('13.43, 13.44 — Completing the wizard links the step, and saving persists the link', async () => { + const process = await openWizardFor(PLUGIN_STEPS_PROCESS.serviceTaskId); + + await pluginsPage.linkWizard.advanceToActionConfiguration( + LINKED_PLUGIN.definitionKey, + LINKED_PLUGIN.actionWithRequiredProperties.key + ); + await pluginsPage.fillCreateZaakProperties(LINKED_PLUGIN.actionWithRequiredProperties.values); + await pluginsPage.linkWizard.complete(); + + // The panel swaps Create for Edit/Unlink once the step carries a link. + await expect(pluginsPage.editProcessLinkButton).toBeVisible(); + await expect(pluginsPage.unlinkProcessLinkButton).toBeVisible(); + await expect(pluginsPage.createProcessLinkButton).toHaveCount(0); + + // The link only lives in the modeler until the diagram is saved. + expect(await pluginsPage.getProcessLinksViaApi(process.id)).toEqual([]); + + // Saved as a draft: a non-draft save is validated first and would wait on + // a confirmation instead of deploying. + await pluginsPage.enableDraft(); + const response = await pluginsPage.saveProcess(buildingBlockKey, versionTag); + expect(response.status()).toBe(204); + + // Saving deploys a new version of the definition, which carries the link. + const saved = await currentProcess(); + expect(saved.id).not.toBe(process.id); + + const links = await pluginsPage.getProcessLinksViaApi(saved.id); + expect(links).toHaveLength(1); + expect(links[0]).toMatchObject({ + activityId: PLUGIN_STEPS_PROCESS.serviceTaskId, + activityType: BUILDING_BLOCK_PLUGIN_API.serviceTaskActivityType, + processLinkType: 'plugin', + pluginDefinitionKey: LINKED_PLUGIN.definitionKey, + pluginActionDefinitionKey: LINKED_PLUGIN.actionWithRequiredProperties.key, + // A building block links to a plugin *definition*: the configuration is + // only bound when a case links to the building block. + pluginConfigurationId: null, + referenceType: 'BUILDING_BLOCK', + }); + // 13.44 — the execution properties are stored with the link. + expect(links[0].actionProperties).toMatchObject({ + rsin: LINKED_PLUGIN.actionWithRequiredProperties.values.rsin, + zaaktypeUrl: LINKED_PLUGIN.actionWithRequiredProperties.values.zaaktypeUrl, + }); + }); + + test('13.43, 13.44 — Reopening the link shows the stored action and its properties', async () => { + // Reopens the link the previous test saved. + const saved = await currentProcess(); + await pluginsPage.goToProcessBuilder(buildingBlockKey, versionTag, saved.id); + await pluginsPage.modeler.selectElement(PLUGIN_STEPS_PROCESS.serviceTaskId); + await pluginsPage.modeler.expandGroup('Process link'); + await pluginsPage.editProcessLinkButton.click(); + await pluginsPage.linkWizard.waitForOpen(); + + // Editing opens on the last step, with the earlier choices marked complete. + await expect(pluginsPage.linkWizard.actionConfigurationStep).toBeVisible(); + expect(await pluginsPage.linkWizard.stepTitles()).toEqual( + expect.arrayContaining([...BUILDING_BLOCK_PLUGIN_TEXTS.wizardSteps]) + ); + + // No plugin configuration is bound: the link points at a definition. + await expect(pluginsPage.linkWizard.pluginConfigurationLabel).toHaveText( + BUILDING_BLOCK_PLUGIN_TEXTS.noPluginConfigurationLabel + ); + + // 13.44 — the stored execution properties are prefilled. + await expect(pluginsPage.createZaakRsinInput).toHaveValue( + LINKED_PLUGIN.actionWithRequiredProperties.values.rsin + ); + await expect(pluginsPage.createZaakZaaktypeUrlInput).toHaveValue( + LINKED_PLUGIN.actionWithRequiredProperties.values.zaaktypeUrl + ); + + // An existing link can be removed again from here. + await expect(pluginsPage.linkWizard.unlinkButton).toBeVisible(); + + await pluginsPage.closeProcessLinkModal(); + }); + }); +}); diff --git a/e2e/tests/building-block-processes/building-block-processes-config.ts b/e2e/tests/building-block-processes/building-block-processes-config.ts new file mode 100644 index 0000000000..6c085c6b6e --- /dev/null +++ b/e2e/tests/building-block-processes/building-block-processes-config.ts @@ -0,0 +1,90 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * The building block this suite operates on. It is created through the API in + * `beforeAll` with a unique key because the tests finalize its version — an + * irreversible action that would leave a shared building block unusable for the + * next run. + * + * Creating a building block also generates a main process definition whose key + * and name are those of the building block itself. + */ +export const TEST_BUILDING_BLOCK = { + keyPrefix: 'e2e-bb-processes', + namePrefix: 'E2E BB Processes', + description: 'Building block created by the e2e building block processes test.', + versionTag: '1.0.0', +} as const; + +/** The BPMN uploaded through the Processes tab's Upload modal. */ +export const UPLOADED_PROCESS = { + fileName: 'e2e-test-process.bpmn', + key: 'e2e-test-process', + name: 'E2E Test Process', + /** `<bpmn:startEvent id="StartEvent_1" name="Start">` in the asset. */ + startEventId: 'StartEvent_1', +} as const; + +/** + * A process created from scratch with the Create button. + * + * The builder seeds a new diagram with `EMPTY_BPMN` + * (`process-management/src/lib/constants/bpmn.constants.ts`), which declares a + * fixed `<bpmn:process id="Process_1">` and no name — so the key is always + * `Process_1` and the Name cell renders empty. Rows for it must be located by + * key, never by name. + */ +export const CREATED_PROCESS = { + key: 'Process_1', + startEventId: 'StartEvent_1', +} as const; + +export const BUILDING_BLOCK_PROCESS_TEXTS = { + /** `actionItems` adds a trailing unlabelled header for the row overflow menu. */ + columns: ['Name', 'Key', 'Status'], + mainProcessTag: 'Main process', + draftTag: 'Draft', + markAsMainAction: 'Make main process', + deleteAction: 'Delete', + uploadModalTitle: 'Upload process definition', + deleteModalTitle: 'Delete process', + deleteModalContent: 'Are you sure you want to delete this process?', + uploadSuccess: 'Deployment successful', + /** Heading of the process link wizard opened from the properties panel. */ + processLinkModalHeading: 'Configure process step', + processLinkChooseTypeStep: 'Choose link type', + /** + * The link types a start event inside a building block may be linked to. There + * is deliberately no "Building block" type here — a building block cannot link + * one of its own steps to another building block. + */ + processLinkTypes: ['Form', 'FormFlow', 'UI Component'], + createLinkButton: 'Create process link', + /** Panel groups a start event offers; the middle one is Valtimo's own. */ + startEventPanelGroups: ['General', 'Process link', 'Documentation'], + startEventPanelType: 'Start Event', +} as const; + +export const BUILDING_BLOCK_PROCESS_API = { + buildingBlock: '/api/management/v1/building-block', + processDefinitions: (key: string, versionTag: string) => + `/api/management/v1/building-block/${key}/version/${versionTag}/process-definition`, + processDefinition: (key: string, versionTag: string, id: string) => + `/api/management/v1/building-block/${key}/version/${versionTag}/process-definition/${id}`, + main: (key: string, versionTag: string, id: string) => + `/api/management/v1/building-block/${key}/version/${versionTag}/process-definition/${id}/main`, +} as const; diff --git a/e2e/tests/building-block-processes/building-block-processes.spec.ts b/e2e/tests/building-block-processes/building-block-processes.spec.ts new file mode 100644 index 0000000000..3538f6c2e8 --- /dev/null +++ b/e2e/tests/building-block-processes/building-block-processes.spec.ts @@ -0,0 +1,366 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {expect, test} from '@playwright/test'; +import {generateId} from '../../utils/dataGenerator'; +import {expectNotificationMessage} from '../../utils/ui.utils'; +import { + BUILDING_BLOCK_PROCESS_TEXTS, + CREATED_PROCESS, + TEST_BUILDING_BLOCK, + UPLOADED_PROCESS, +} from './building-block-processes-config'; +import {BuildingBlockProcessesPage, escapeForRegExp} from './page'; + +test.use({storageState: undefined}); + +/** + * Feature 13D — the Processes tab of a building block. + * + * The tests share one building block and run in declaration order: the list + * assertions need the freshly generated single main process, and the delete and + * "make main" actions are only enabled once a second process exists. + * + * The read-only assertions use a *second*, finalized building block. Finalizing + * cannot be undone and would block the process cleanup in `afterAll`, so the + * building block the other tests write to is kept a draft throughout. + */ +test.describe('Building block management — processes (13D)', () => { + let context; + let page; + let request; + let processesPage: BuildingBlockProcessesPage; + + const uniqueId = generateId(); + const buildingBlockKey = `${TEST_BUILDING_BLOCK.keyPrefix}-${uniqueId}`; + const buildingBlockName = `${TEST_BUILDING_BLOCK.namePrefix} ${uniqueId}`; + const finalBuildingBlockKey = `${TEST_BUILDING_BLOCK.keyPrefix}-final-${uniqueId}`; + const versionTag = TEST_BUILDING_BLOCK.versionTag; + + test.beforeAll(async ({browser, baseURL}) => { + context = await browser.newContext({baseURL}); + page = await context.newPage(); + request = context.request; + + processesPage = new BuildingBlockProcessesPage(page, request); + + // Created through the API so the UI tests start from a known state: a draft + // version whose only process definition is the generated main one, which + // carries the key and name of the building block itself. + await processesPage.createBuildingBlockViaApi({ + key: buildingBlockKey, + name: buildingBlockName, + versionTag, + description: TEST_BUILDING_BLOCK.description, + }); + + await processesPage.createBuildingBlockViaApi({ + key: finalBuildingBlockKey, + name: `${buildingBlockName} (final)`, + versionTag, + description: TEST_BUILDING_BLOCK.description, + }); + await processesPage.finalizeVersionViaApi(finalBuildingBlockKey, versionTag); + + await page.goto('/'); + }); + + test.afterAll(async () => { + // Every process definition added by these tests is removed again. The + // generated main definition cannot be deleted, and neither can the building + // blocks themselves — there is no DELETE endpoint for building block + // definitions (see `deleteBuildingBlockViaApi`), so they stay behind. + await processesPage.deleteAddedProcessesViaApi(buildingBlockKey, versionTag, buildingBlockKey); + + for (const key of [buildingBlockKey, finalBuildingBlockKey]) { + await processesPage.deleteBuildingBlockViaApi(key, versionTag); + } + + await context.close(); + }); + + test.describe('13.30, 13.31 — View the processes list', () => { + test('13.30, 13.31 — Lists the generated main process with its name, key and status', async () => { + await processesPage.goToProcessesTab(buildingBlockKey, versionTag); + + await processesPage.assertColumnHeaders(BUILDING_BLOCK_PROCESS_TEXTS.columns); + + // Creating a building block generates its main process definition, named + // and keyed after the building block. + await processesPage.assertProcessMetadata({ + name: buildingBlockName, + key: buildingBlockKey, + }); + await processesPage.assertProcessTag( + buildingBlockKey, + BUILDING_BLOCK_PROCESS_TEXTS.mainProcessTag + ); + + // The list matches the API. + const processes = await processesPage.getProcessesViaApi(buildingBlockKey, versionTag); + expect(processes).toHaveLength(1); + expect(processes[0]).toMatchObject({key: buildingBlockKey, main: true}); + await processesPage.carbonList.assertRowCount(1); + }); + + test.describe('Failure scenarios', () => { + test('The only process cannot be deleted or re-marked as main', async () => { + await processesPage.goToProcessesTab(buildingBlockKey, versionTag); + + const actions = await processesPage.readRowActions(buildingBlockKey); + expect(actions).toEqual([ + BUILDING_BLOCK_PROCESS_TEXTS.markAsMainAction, + BUILDING_BLOCK_PROCESS_TEXTS.deleteAction, + ]); + + const row = processesPage.rowByKey(buildingBlockKey); + // A building block must keep exactly one main process, so with a single + // definition both actions are unavailable. + await expect( + row.actionMenuItem(BUILDING_BLOCK_PROCESS_TEXTS.markAsMainAction) + ).toBeDisabled(); + await expect(row.actionMenuItem(BUILDING_BLOCK_PROCESS_TEXTS.deleteAction)).toBeDisabled(); + + await processesPage.closeRowActionMenu(); + }); + }); + }); + + test.describe('13.32 — Manage process definitions', () => { + test('13.32 — Uploads a BPMN file as an additional process definition', async () => { + await processesPage.goToProcessesTab(buildingBlockKey, versionTag); + + const response = await processesPage.uploadProcess( + buildingBlockKey, + versionTag, + UPLOADED_PROCESS.fileName + ); + expect(response.status()).toBe(204); + + await expectNotificationMessage(page, BUILDING_BLOCK_PROCESS_TEXTS.uploadSuccess); + + await processesPage.assertProcessMetadata({ + name: UPLOADED_PROCESS.name, + key: UPLOADED_PROCESS.key, + }); + // An uploaded process joins the building block as a regular definition. + await processesPage.assertProcessHasNoTags(UPLOADED_PROCESS.key); + + const uploaded = await processesPage.getProcessByKeyViaApi( + buildingBlockKey, + versionTag, + UPLOADED_PROCESS.key + ); + expect(uploaded).toMatchObject({key: UPLOADED_PROCESS.key, main: false}); + }); + + test.describe('Failure scenarios', () => { + test('Upload stays disabled until a BPMN file is selected', async () => { + await processesPage.goToProcessesTab(buildingBlockKey, versionTag); + + await processesPage.openUploadModal(); + await expect(processesPage.uploadFileUploader).toBeVisible(); + await expect(processesPage.uploadSubmitButton).toBeDisabled(); + + await processesPage.selectBpmnFile(UPLOADED_PROCESS.fileName); + await expect(processesPage.uploadSubmitButton).toBeEnabled(); + + // Cancelling leaves the list untouched. + await processesPage.closeUploadModal(); + const processes = await processesPage.getProcessesViaApi(buildingBlockKey, versionTag); + expect(processes.filter(process => process.key === UPLOADED_PROCESS.key)).toHaveLength(1); + }); + }); + + test('13.32 — Marks the uploaded process as the main process', async () => { + await processesPage.goToProcessesTab(buildingBlockKey, versionTag); + + const uploaded = await processesPage.getProcessByKeyViaApi( + buildingBlockKey, + versionTag, + UPLOADED_PROCESS.key + ); + const response = await processesPage.markProcessAsMain( + buildingBlockKey, + versionTag, + UPLOADED_PROCESS.key, + uploaded!.id + ); + expect(response.status()).toBe(204); + + // The tag moves across with the flag. + await processesPage.assertProcessTag( + UPLOADED_PROCESS.key, + BUILDING_BLOCK_PROCESS_TEXTS.mainProcessTag + ); + await processesPage.assertProcessHasNoTags(buildingBlockKey); + + const processes = await processesPage.getProcessesViaApi(buildingBlockKey, versionTag); + expect(processes.filter(process => process.main).map(process => process.key)).toEqual([ + UPLOADED_PROCESS.key, + ]); + }); + + test('13.32 — Deletes a process definition after confirming', async () => { + await processesPage.goToProcessesTab(buildingBlockKey, versionTag); + + // The generated process is no longer the main one, so it can be removed. + const generated = await processesPage.getProcessByKeyViaApi( + buildingBlockKey, + versionTag, + buildingBlockKey + ); + + await processesPage.openDeleteConfirmation(buildingBlockKey); + await expect(processesPage.deleteModal).toContainText( + BUILDING_BLOCK_PROCESS_TEXTS.deleteModalContent + ); + + const response = await processesPage.confirmDelete( + buildingBlockKey, + versionTag, + generated!.id + ); + expect(response.status()).toBe(204); + + await processesPage.rowByKey(buildingBlockKey).assertNotVisible(); + const processes = await processesPage.getProcessesViaApi(buildingBlockKey, versionTag); + expect(processes.map(process => process.key)).toEqual([UPLOADED_PROCESS.key]); + }); + + test('13.32 — Creates a new process from the empty diagram', async () => { + await processesPage.goToProcessesTab(buildingBlockKey, versionTag); + await processesPage.goToCreateProcess(buildingBlockKey, versionTag); + + // Nothing to deploy until the diagram changes. + await expect(processesPage.saveButton).toBeDisabled(); + + await processesPage.enableDraft(); + await processesPage.modeler.appendTaskTo(CREATED_PROCESS.startEventId); + await expect(processesPage.saveButton).toBeEnabled(); + + const response = await processesPage.saveProcess(buildingBlockKey, versionTag); + expect(response.status()).toBe(204); + + // Deploying returns to the Processes tab, where the new definition shows up + // as a draft under the fixed `Process_1` key of the seeded diagram. + await page.waitForURL(new RegExp(`/version/${versionTag}/process-definition$`)); + await processesPage.carbonList.waitForLoaded(); + await processesPage.rowByKey(CREATED_PROCESS.key).assertVisible(); + await processesPage.assertProcessTag( + CREATED_PROCESS.key, + BUILDING_BLOCK_PROCESS_TEXTS.draftTag + ); + + const created = await processesPage.getProcessByKeyViaApi( + buildingBlockKey, + versionTag, + CREATED_PROCESS.key + ); + expect(created).toMatchObject({key: CREATED_PROCESS.key, main: false, draft: true}); + }); + }); + + test.describe('13.33–13.38 — Process diagram and step configuration', () => { + test('13.33 — Opens a process in the BPMN modeler from its row', async () => { + await processesPage.goToProcessesTab(buildingBlockKey, versionTag); + + const main = await processesPage.getMainProcessViaApi(buildingBlockKey, versionTag); + await processesPage.rowByKey(UPLOADED_PROCESS.key).click(); + + await page.waitForURL(new RegExp(`/process-definition/${escapeForRegExp(main.id)}$`)); + await processesPage.modeler.waitForLoaded(); + await expect(processesPage.modeler.elementShape(UPLOADED_PROCESS.startEventId)).toBeVisible(); + }); + + test('13.34, 13.35 — Selects a step and shows its properties', async () => { + const main = await processesPage.getMainProcessViaApi(buildingBlockKey, versionTag); + await processesPage.goToProcessBuilder(buildingBlockKey, versionTag, main.id); + + // 13.34 — selecting the shape switches the panel to that element. + await processesPage.modeler.selectElement(UPLOADED_PROCESS.startEventId); + await expect(processesPage.modeler.panelHeaderType).toHaveAttribute( + 'title', + BUILDING_BLOCK_PROCESS_TEXTS.startEventPanelType + ); + + // 13.35 — the panel groups, including Valtimo's own "Process link" group. + expect(await processesPage.modeler.groupTitles()).toEqual( + expect.arrayContaining([...BUILDING_BLOCK_PROCESS_TEXTS.startEventPanelGroups]) + ); + + await processesPage.modeler.expandGroup('General'); + await expect(processesPage.modeler.idInput).toHaveValue(UPLOADED_PROCESS.startEventId); + await expect(processesPage.modeler.nameInput).toBeVisible(); + }); + + test('13.36, 13.38 — Configures a step and saves the process', async () => { + const main = await processesPage.getMainProcessViaApi(buildingBlockKey, versionTag); + await processesPage.goToProcessBuilder(buildingBlockKey, versionTag, main.id); + + const stepName = `Renamed start ${uniqueId}`; + await processesPage.modeler.selectElement(UPLOADED_PROCESS.startEventId); + // 13.36 — a step setting is changed through the properties panel. + await processesPage.modeler.renameSelectedElement(stepName); + + // Saved as a draft: a non-draft save validates first and, because the + // seeded diagram has an unlinked start event, waits on a "Process has + // warnings" confirmation instead of deploying. + await processesPage.enableDraft(); + + // 13.38 — saving deploys a new version of the definition. + const response = await processesPage.saveProcess(buildingBlockKey, versionTag); + expect(response.status()).toBe(204); + + // The change survives a reload of the newly deployed version. + const saved = await processesPage.getMainProcessViaApi(buildingBlockKey, versionTag); + expect(saved.id).not.toBe(main.id); + await processesPage.goToProcessBuilder(buildingBlockKey, versionTag, saved.id); + await expect(processesPage.modeler.elementShape(UPLOADED_PROCESS.startEventId)).toBeVisible(); + await processesPage.modeler.selectElement(UPLOADED_PROCESS.startEventId); + await expect(processesPage.modeler.panelHeaderLabel).toHaveAttribute('title', stepName); + }); + + test('13.37 — Opens the link wizard for a step and offers the available link types', async () => { + const main = await processesPage.getMainProcessViaApi(buildingBlockKey, versionTag); + await processesPage.goToProcessBuilder(buildingBlockKey, versionTag, main.id); + + await processesPage.modeler.selectElement(UPLOADED_PROCESS.startEventId); + await processesPage.openProcessLinkModalFromPanel(); + + await expect(processesPage.processLinkModal).toContainText( + BUILDING_BLOCK_PROCESS_TEXTS.processLinkChooseTypeStep + ); + for (const linkType of BUILDING_BLOCK_PROCESS_TEXTS.processLinkTypes) { + await expect(processesPage.processLinkModal).toContainText(linkType); + } + + // Cancelling links nothing. + await processesPage.closeProcessLinkModal(); + await expect(processesPage.createProcessLinkButton).toBeVisible(); + }); + + test.describe('Failure scenarios', () => { + test('Upload and create are unavailable on a finalized version', async () => { + await processesPage.goToProcessesTab(finalBuildingBlockKey, versionTag); + + // A finalized version is read-only, so no process can be added to it. + await expect(processesPage.uploadButton).toBeDisabled(); + await expect(processesPage.createButton).toBeDisabled(); + }); + }); + }); +}); diff --git a/e2e/tests/building-block-processes/page.ts b/e2e/tests/building-block-processes/page.ts new file mode 100644 index 0000000000..3b649b2387 --- /dev/null +++ b/e2e/tests/building-block-processes/page.ts @@ -0,0 +1,560 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + type APIRequestContext, + type Locator, + type Page, + type Response, + expect, +} from '@playwright/test'; +import path from 'path'; +import { + BUILDING_BLOCK_MANAGEMENT_PROCESSES_TEST_IDS, + BUILDING_BLOCK_MANAGEMENT_PROCESS_UPLOAD_TEST_IDS, + CONFIRMATION_MODAL_TEST_IDS, + PROCESS_LINK_PANEL_TEST_IDS, + PROCESS_MANAGEMENT_BUILDER_TEST_IDS, + ZAKEN_API_CREATE_ZAAK_ACTION_TEST_IDS, +} from '../../constants'; +import {BpmnModeler} from '../../shared/bpmn-modeler/bpmn-modeler.utils'; +import {CarbonList, CarbonListRow} from '../../shared/carbon-list/carbon-list.utils'; +import {CarbonToggle} from '../../shared/carbon-toggle/carbon-toggle.utils'; +import {ProcessLinkWizard} from '../../shared/process-link-wizard/process-link-wizard.utils'; +import {apiDelete, apiGet, apiPost} from '../../utils/api.utils'; +import {BUILDING_BLOCK_PLUGIN_API} from './building-block-plugins-config'; +import { + BUILDING_BLOCK_PROCESS_API, + BUILDING_BLOCK_PROCESS_TEXTS, +} from './building-block-processes-config'; + +/** One process definition of a building block version, as the management API returns it. */ +export interface BuildingBlockProcessDefinition { + id: string; + key: string; + name: string | null; + versionTag: string; + main: boolean; + draft: boolean; +} + +/** + * A stored process link. Inside a building block the link points at a plugin + * *definition* — `pluginConfigurationId` stays null and `referenceType` is + * `BUILDING_BLOCK`, because the configuration is only bound later, when a case + * links to the building block. + */ +export interface ProcessLink { + id: string; + processDefinitionId: string; + activityId: string; + activityType: string; + processLinkType: string; + pluginConfigurationId: string | null; + referenceType: string; + pluginDefinitionKey: string; + pluginActionDefinitionKey: string; + actionProperties: Record<string, unknown>; +} + +export function escapeForRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +export class BuildingBlockProcessesPage { + readonly carbonList: CarbonList; + readonly modeler: BpmnModeler; + readonly linkWizard: ProcessLinkWizard; + + constructor( + private readonly page: Page, + private readonly request: APIRequestContext + ) { + this.carbonList = new CarbonList( + page, + page.locator('valtimo-building-block-management-processes') + ); + this.modeler = new BpmnModeler(page); + this.linkWizard = new ProcessLinkWizard(page); + } + + // ─── Processes tab locators ─────────────────────────────────────── + + get uploadButton(): Locator { + return this.page.getByTestId(BUILDING_BLOCK_MANAGEMENT_PROCESSES_TEST_IDS.uploadButton); + } + + get createButton(): Locator { + return this.page.getByTestId(BUILDING_BLOCK_MANAGEMENT_PROCESSES_TEST_IDS.createButton); + } + + // ─── Upload modal locators ──────────────────────────────────────── + + get uploadFileUploader(): Locator { + return this.page.getByTestId(BUILDING_BLOCK_MANAGEMENT_PROCESS_UPLOAD_TEST_IDS.fileUploader); + } + + get uploadSubmitButton(): Locator { + return this.page.getByTestId(BUILDING_BLOCK_MANAGEMENT_PROCESS_UPLOAD_TEST_IDS.submitButton); + } + + get uploadCancelButton(): Locator { + return this.page.getByTestId(BUILDING_BLOCK_MANAGEMENT_PROCESS_UPLOAD_TEST_IDS.cancelButton); + } + + // ─── Confirmation modal ─────────────────────────────────────────── + + /** + * Scope a confirmation modal by its heading. + * + * Two `valtimo-confirmation-modal`s are reachable from the Processes tab — the + * row delete modal and the upload "replace existing process" modal — and both + * render the same `confirmationModal*` test ids. The replace modal is wrapped in + * `valtimo-render-in-body`, which moves it to `document.body`, so it cannot be + * scoped by its owning component either. The heading is the only thing that + * distinguishes them. + * + * The returned locator is the inner `.cds--modal` rather than the component + * host: the host wraps a `position: fixed` modal, so it collapses to a + * zero-size bounding box and Playwright always reports it as hidden. + */ + private confirmationModal(title: string): Locator { + return this.page + .locator('valtimo-confirmation-modal') + .filter({has: this.page.getByRole('heading', {name: title})}) + .locator('.cds--modal'); + } + + get deleteModal(): Locator { + return this.confirmationModal(BUILDING_BLOCK_PROCESS_TEXTS.deleteModalTitle); + } + + get deleteConfirmButton(): Locator { + return this.deleteModal.getByTestId(CONFIRMATION_MODAL_TEST_IDS.confirmButton); + } + + // ─── Builder locators ───────────────────────────────────────────── + + /** Labelled "Save" in the UI; it deploys the diagram. */ + get saveButton(): Locator { + return this.page.getByTestId(PROCESS_MANAGEMENT_BUILDER_TEST_IDS.deployButton); + } + + get draftToggle(): CarbonToggle { + return new CarbonToggle(this.page.getByTestId(PROCESS_MANAGEMENT_BUILDER_TEST_IDS.draftToggle)); + } + + /** The Valtimo-owned "Process link" group inside the bpmn-js properties panel. */ + get createProcessLinkButton(): Locator { + return this.page.getByTestId(PROCESS_LINK_PANEL_TEST_IDS.createButton); + } + + /** Replaces the create button once the selected step carries a link. */ + get editProcessLinkButton(): Locator { + return this.page.getByTestId(PROCESS_LINK_PANEL_TEST_IDS.editButton); + } + + get unlinkProcessLinkButton(): Locator { + return this.page.getByTestId(PROCESS_LINK_PANEL_TEST_IDS.unlinkButton); + } + + get processLinkModal(): Locator { + return this.page.locator('valtimo-process-link-modal'); + } + + // ─── Navigation ─────────────────────────────────────────────────── + + processesTabUrl(key: string, versionTag: string) { + return `/building-block-management/building-block/${key}/version/${versionTag}/process-definition`; + } + + /** + * Navigate straight to the route instead of driving the Admin menu: loading the + * dashboard first and then opening a heavy admin route crashes the Chromium + * renderer. + */ + async goToProcessesTab(key: string, versionTag: string) { + await this.page.goto(this.processesTabUrl(key, versionTag)); + await this.page.waitForURL( + new RegExp(`/version/${escapeForRegExp(versionTag)}/process-definition$`) + ); + await this.carbonList.waitForLoaded(); + await expect(this.carbonList.table).toBeVisible(); + } + + /** Open one process definition of the building block in the BPMN modeler. */ + async goToProcessBuilder(key: string, versionTag: string, processDefinitionId: string) { + await this.page.goto(`${this.processesTabUrl(key, versionTag)}/${processDefinitionId}`); + await this.modeler.waitForLoaded(); + } + + // ─── List ───────────────────────────────────────────────────────── + + /** + * Locate a row by its Key cell. Names are not unique — and a process created + * from the empty diagram has no name at all — so rows are identified by key. + */ + rowByKey(key: string): CarbonListRow { + return this.carbonList.row(new RegExp(`^${escapeForRegExp(key)}$`)); + } + + /** + * `actionItems` on the carbon list appends an unlabelled `<th>` for the row + * overflow menu, so the empty headers are dropped before comparing. + */ + async assertColumnHeaders(expectedHeaders: readonly string[]) { + const headers = await this.carbonList.table.locator('thead th').allInnerTexts(); + expect(headers.map(header => header.trim()).filter(Boolean)).toEqual([...expectedHeaders]); + } + + async assertProcessMetadata(definition: {name: string; key: string}) { + const row = this.rowByKey(definition.key); + await row.assertVisible(); + await expect(row.cellByIndex(0)).toHaveText(definition.name); + await expect(row.cellByIndex(1)).toHaveText(definition.key); + } + + async assertProcessTag(key: string, tag: string) { + await expect(this.rowByKey(key).tags).toContainText(tag); + } + + async assertProcessHasNoTags(key: string) { + await expect(this.rowByKey(key).tags).toHaveCount(0); + } + + /** Open a row's overflow menu and read which actions it offers. */ + async readRowActions(key: string): Promise<string[]> { + const row = this.rowByKey(key); + await row.openActionMenu(); + const labels = await this.page.getByRole('menu').getByRole('menuitem').allInnerTexts(); + return labels.map(label => label.trim()); + } + + async closeRowActionMenu() { + await this.page.keyboard.press('Escape'); + await expect(this.page.getByRole('menu')).not.toBeVisible(); + } + + // ─── Upload ─────────────────────────────────────────────────────── + + async openUploadModal() { + await expect(this.uploadButton).toBeEnabled(); + await this.uploadButton.click(); + await expect(this.uploadSubmitButton).toBeVisible(); + } + + async closeUploadModal() { + await this.uploadCancelButton.click(); + await expect(this.uploadSubmitButton).not.toBeVisible(); + } + + async selectBpmnFile(fileName: string) { + const filePath = path.resolve(process.cwd(), 'assets', fileName); + await this.uploadFileUploader.locator('input[type="file"]').setInputFiles(filePath); + } + + /** + * Submit the upload and return the deploy response. Matched on the exact + * collection path so the `POST .../process-definition/validate` call the + * builder makes elsewhere can never be picked up instead. + */ + async submitUpload(key: string, versionTag: string): Promise<Response> { + await expect(this.uploadSubmitButton).toBeEnabled(); + const [response] = await Promise.all([ + this.page.waitForResponse( + res => + new URL(res.url()).pathname === + BUILDING_BLOCK_PROCESS_API.processDefinitions(key, versionTag) && + res.request().method() === 'POST' + ), + this.uploadSubmitButton.click(), + ]); + return response; + } + + /** Full happy-path upload: open the modal, pick the file, submit. */ + async uploadProcess(key: string, versionTag: string, fileName: string): Promise<Response> { + await this.openUploadModal(); + await this.selectBpmnFile(fileName); + const response = await this.submitUpload(key, versionTag); + await this.carbonList.waitForLoaded(); + return response; + } + + // ─── Create ─────────────────────────────────────────────────────── + + async goToCreateProcess(key: string, versionTag: string) { + await expect(this.createButton).toBeEnabled(); + await this.createButton.click(); + await this.page.waitForURL(/\/process-definition\/create$/); + await this.modeler.waitForLoaded(); + } + + /** + * Deploy the diagram currently open in the builder. + * + * The Save button POSTs a new definition on the `create` route and PUTs an + * existing one otherwise, so both methods are accepted. Only a *draft* deploy + * goes straight through: a non-draft one is validated first and, when the + * diagram has warnings, waits on a "Process has warnings" confirmation. + */ + async saveProcess(key: string, versionTag: string): Promise<Response> { + await expect(this.saveButton).toBeEnabled(); + const [response] = await Promise.all([ + this.page.waitForResponse( + res => + new URL(res.url()).pathname.startsWith( + BUILDING_BLOCK_PROCESS_API.processDefinitions(key, versionTag) + ) && ['POST', 'PUT'].includes(res.request().method()) + ), + this.saveButton.click(), + ]); + return response; + } + + /** + * Mark the diagram as a draft, which skips validation on save. Without it the + * seeded diagrams — a bare start/end event pair — trip the "None start event + * has no process link or form" warning and the save waits on a confirmation. + */ + async enableDraft() { + await this.draftToggle.enable(); + } + + // ─── Row actions ────────────────────────────────────────────────── + + async markProcessAsMain( + key: string, + versionTag: string, + processKey: string, + processDefinitionId: string + ): Promise<Response> { + const [response] = await Promise.all([ + this.page.waitForResponse( + res => + new URL(res.url()).pathname === + BUILDING_BLOCK_PROCESS_API.main(key, versionTag, processDefinitionId) && + res.request().method() === 'POST' + ), + this.rowByKey(processKey).clickAction(BUILDING_BLOCK_PROCESS_TEXTS.markAsMainAction), + ]); + await this.carbonList.waitForLoaded(); + return response; + } + + async openDeleteConfirmation(processKey: string) { + await this.rowByKey(processKey).clickAction(BUILDING_BLOCK_PROCESS_TEXTS.deleteAction); + await expect(this.deleteModal).toBeVisible(); + } + + async confirmDelete( + key: string, + versionTag: string, + processDefinitionId: string + ): Promise<Response> { + const [response] = await Promise.all([ + this.page.waitForResponse( + res => + new URL(res.url()).pathname === + BUILDING_BLOCK_PROCESS_API.processDefinition(key, versionTag, processDefinitionId) && + res.request().method() === 'DELETE' + ), + this.deleteConfirmButton.click(), + ]); + await this.carbonList.waitForLoaded(); + return response; + } + + // ─── Process link modal ─────────────────────────────────────────── + + /** Open the process link wizard from the panel's "Process link" group. */ + async openProcessLinkModalFromPanel() { + await this.modeler.expandGroup('Process link'); + await expect(this.createProcessLinkButton).toBeVisible(); + await this.createProcessLinkButton.click(); + await expect( + this.processLinkModal.getByRole('heading', { + name: BUILDING_BLOCK_PROCESS_TEXTS.processLinkModalHeading, + }) + ).toBeVisible(); + } + + async closeProcessLinkModal() { + await this.processLinkModal.getByRole('button', {name: 'Cancel'}).click(); + await expect( + this.processLinkModal.getByRole('heading', { + name: BUILDING_BLOCK_PROCESS_TEXTS.processLinkModalHeading, + }) + ).not.toBeVisible(); + } + + /** + * Select a step and open the link wizard for it in one go — every plugin test + * starts this way. + */ + async openLinkWizardForStep( + key: string, + versionTag: string, + processDefinitionId: string, + elementId: string + ) { + await this.goToProcessBuilder(key, versionTag, processDefinitionId); + await this.modeler.selectElement(elementId); + await this.openProcessLinkModalFromPanel(); + } + + // ─── Plugin action configuration ────────────────────────────────── + + /** + * A required property of the "Create zaak" action. The test id sits on the + * `v-input` host, so the field itself is one level down — the inner `<input>` + * carries no `name` attribute of its own. + */ + private createZaakInput(testId: string): Locator { + return this.page.getByTestId(testId).locator('input'); + } + + get createZaakRsinInput(): Locator { + return this.createZaakInput(ZAKEN_API_CREATE_ZAAK_ACTION_TEST_IDS.rsin); + } + + get createZaakZaaktypeUrlInput(): Locator { + return this.createZaakInput(ZAKEN_API_CREATE_ZAAK_ACTION_TEST_IDS.zaaktypeUrl); + } + + /** Fill both required properties of the "Create zaak" action. */ + async fillCreateZaakProperties(values: {rsin: string; zaaktypeUrl: string}) { + await this.createZaakRsinInput.fill(values.rsin); + await this.createZaakZaaktypeUrlInput.fill(values.zaaktypeUrl); + } + + // ─── Process link API ───────────────────────────────────────────── + + /** + * Process links stored for a process definition. + * + * A link made in the modeler only lives in memory until the diagram is saved, + * and saving deploys a *new* definition — so this is always read with the id + * the save produced, never the one the wizard was opened on. + */ + async getProcessLinksViaApi(processDefinitionId: string): Promise<ProcessLink[]> { + return apiGet<ProcessLink[]>(BUILDING_BLOCK_PLUGIN_API.processLinks(processDefinitionId)); + } + + /** Plugin definitions the backend offers for an activity type. */ + async getPluginDefinitionsViaApi(activityType: string): Promise<{key: string}[]> { + return apiGet<{key: string}[]>(BUILDING_BLOCK_PLUGIN_API.pluginDefinitions(activityType)); + } + + // ─── API helpers ────────────────────────────────────────────────── + + async createBuildingBlockViaApi(definition: { + key: string; + name: string; + versionTag: string; + description: string; + }) { + return apiPost(BUILDING_BLOCK_PROCESS_API.buildingBlock, definition); + } + + async finalizeVersionViaApi(key: string, versionTag: string) { + try { + await apiPost( + `${BUILDING_BLOCK_PROCESS_API.buildingBlock}/${key}/version/${versionTag}/finalize`, + {} + ); + } catch { + // Already final. + } + } + + async getProcessesViaApi( + key: string, + versionTag: string + ): Promise<BuildingBlockProcessDefinition[]> { + return apiGet<BuildingBlockProcessDefinition[]>( + BUILDING_BLOCK_PROCESS_API.processDefinitions(key, versionTag) + ); + } + + async getMainProcessViaApi( + key: string, + versionTag: string + ): Promise<BuildingBlockProcessDefinition> { + const processes = await this.getProcessesViaApi(key, versionTag); + const main = processes.find(process => process.main); + expect(main, 'the building block has a main process definition').toBeDefined(); + return main!; + } + + /** + * Latest definition for a process key. Saving a diagram deploys a new version, + * so the id changes on every save and has to be re-read before it is used. + */ + async getProcessByKeyViaApi( + key: string, + versionTag: string, + processKey: string + ): Promise<BuildingBlockProcessDefinition | undefined> { + const processes = await this.getProcessesViaApi(key, versionTag); + return processes.filter(process => process.key === processKey).pop(); + } + + async deleteProcessViaApi(key: string, versionTag: string, processDefinitionId: string) { + try { + await apiDelete( + BUILDING_BLOCK_PROCESS_API.processDefinition(key, versionTag, processDefinitionId) + ); + } catch { + // Already gone, or it is the main process and cannot be removed. + } + } + + /** + * Remove every process definition this suite added, i.e. all of them except the + * one generated together with the building block. The main definition cannot be + * deleted, so `keepKey` is skipped. + */ + async deleteAddedProcessesViaApi(key: string, versionTag: string, keepKey: string) { + let processes: BuildingBlockProcessDefinition[] = []; + try { + processes = await this.getProcessesViaApi(key, versionTag); + } catch { + return; + } + + for (const process of processes) { + if (process.key === keepKey || process.main) continue; + await this.deleteProcessViaApi(key, versionTag, process.id); + } + } + + /** + * Best-effort cleanup of the building block itself. + * + * The management API exposes no DELETE for building block definitions + * (`BuildingBlockManagementResource` only serves GET/POST/PUT), so this call + * fails and is swallowed — the building block stays behind. The helper is kept + * so cleanup starts working as soon as the endpoint is added. + */ + async deleteBuildingBlockViaApi(key: string, versionTag: string) { + try { + await apiDelete(`${BUILDING_BLOCK_PROCESS_API.buildingBlock}/${key}/version/${versionTag}`); + } catch { + // No DELETE endpoint yet — nothing to do. + } + } +} diff --git a/e2e/tests/case-details-management-form-flows/case-details-management-form-flows.spec.ts b/e2e/tests/case-details-management-form-flows/case-details-management-form-flows.spec.ts index 21e00345d0..fd90be3917 100644 --- a/e2e/tests/case-details-management-form-flows/case-details-management-form-flows.spec.ts +++ b/e2e/tests/case-details-management-form-flows/case-details-management-form-flows.spec.ts @@ -20,6 +20,12 @@ import {CaseDetailsManagementFormFlowsPage} from './page'; test.use({storageState: undefined}); +// Tests share a single context/page created in beforeAll and depend on each other +// (create → open → edit → delete). Serial mode keeps them in one group (so +// beforeAll/afterAll run exactly once despite fullyParallel) and skips the remaining +// tests when one fails, instead of cascading "context closed" errors. +test.describe.configure({mode: 'serial'}); + test.describe('Case details management — Form Flows', () => { let context; let page; @@ -45,7 +51,7 @@ test.describe('Case details management — Form Flows', () => { test.afterAll(async () => { await formFlowsPage.deleteFormFlowViaApi(formFlowTestData.key); - await context.close(); + if (context) await context.close(); }); // ─── 6.58 View form flows list ──────────────────────────────────── diff --git a/e2e/tests/case-details-management-search-fields/page.ts b/e2e/tests/case-details-management-search-fields/page.ts index a3ce3895d1..c417dede72 100644 --- a/e2e/tests/case-details-management-search-fields/page.ts +++ b/e2e/tests/case-details-management-search-fields/page.ts @@ -122,7 +122,14 @@ export class CaseDetailsManagementSearchFieldsPage { // Dropdown selection helper async selectDropdownItem(dropdownLocator: ReturnType<Page['locator']>, itemText: string) { - const listbox = this.page.getByRole('listbox'); + // The v-select uses appendInline, so each cds-combo-box renders its own + // listbox as a descendant of the dropdown element. Scope the lookup to + // THIS dropdown: a page-global getByRole('listbox') races against the + // other combo boxes in the modal — isVisible() would reflect a sibling + // dropdown's state, so the open/click retry could oscillate (clicking the + // target closed) until the toPass timeout. Scoping also keeps `option` + // from matching the field's displayed value instead of a menu entry. + const listbox = dropdownLocator.getByRole('listbox'); const option = listbox.getByText(itemText, {exact: true}); // The cds-combo-box can re-render (detach) right as it is opened, which // detaches the click target mid-action and hangs the click until the test diff --git a/e2e/tests/case-details-management-tabs/case-details-management-tabs.spec.ts b/e2e/tests/case-details-management-tabs/case-details-management-tabs.spec.ts index 5257242394..d7cdc383b0 100644 --- a/e2e/tests/case-details-management-tabs/case-details-management-tabs.spec.ts +++ b/e2e/tests/case-details-management-tabs/case-details-management-tabs.spec.ts @@ -20,6 +20,12 @@ import {CaseDetailsManagementTabsPage} from './page'; test.use({storageState: undefined}); +// Tests share a single context/page created in beforeAll and depend on each other +// (add → delete, add two → reorder → clean up). Serial mode keeps them in one group +// (so beforeAll/afterAll run exactly once despite fullyParallel) and skips the +// remaining tests when one fails, instead of cascading "context closed" errors. +test.describe.configure({mode: 'serial'}); + test.describe('Case details management — Tabs', () => { let context; let page; @@ -61,7 +67,7 @@ test.describe('Case details management — Tabs', () => { await tabsPage.deleteTabViaApi(CASE_IDENTIFIER, draftVersion, reorderKeyA); await tabsPage.deleteTabViaApi(CASE_IDENTIFIER, draftVersion, reorderKeyB); - await context.close(); + if (context) await context.close(); }); // ─── 6.74 View tabs configuration ───────────────────────────────── diff --git a/e2e/tests/case-details-management-tasks/case-details-management-tasks.spec.ts b/e2e/tests/case-details-management-tasks/case-details-management-tasks.spec.ts index dd5eae385b..21f448bbc9 100644 --- a/e2e/tests/case-details-management-tasks/case-details-management-tasks.spec.ts +++ b/e2e/tests/case-details-management-tasks/case-details-management-tasks.spec.ts @@ -25,6 +25,12 @@ import {CaseDetailsManagementTasksPage} from './page'; test.use({storageState: undefined}); +// Tests share a single context/page created in beforeAll and depend on each other +// (add → delete, add two → reorder → clean up). Serial mode keeps them in one group +// (so beforeAll/afterAll run exactly once despite fullyParallel) and skips the +// remaining tests when one fails, instead of cascading "context closed" errors. +test.describe.configure({mode: 'serial'}); + test.describe('Case details management — Tasks', () => { let context; let page; @@ -56,7 +62,7 @@ test.describe('Case details management — Tasks', () => { await tasksPage.deleteColumnViaApi(CASE_IDENTIFIER, taskColumnReorderTestData.keyB); await tasksPage.deleteSearchFieldViaApi(CASE_IDENTIFIER, taskSearchFieldTestData.key); - await context.close(); + if (context) await context.close(); }); // ─── 6.62 View task list columns ────────────────────────────────── diff --git a/e2e/tests/case-details-management-widgets/case-details-management-widgets.spec.ts b/e2e/tests/case-details-management-widgets/case-details-management-widgets.spec.ts index 2d3cb1fbdb..dc7b6a0542 100644 --- a/e2e/tests/case-details-management-widgets/case-details-management-widgets.spec.ts +++ b/e2e/tests/case-details-management-widgets/case-details-management-widgets.spec.ts @@ -15,12 +15,25 @@ */ import {expect, test} from '@playwright/test'; -import {CASE_IDENTIFIER, createWidgetTestData, createDividerTestData, createJsonEditorDividerData, createReorderTestData} from './case-details-management-widgets'; +import { + CASE_IDENTIFIER, + createWidgetTestData, + createConditionWidgetTestData, + createDividerTestData, + createJsonEditorDividerData, + createReorderTestData, +} from './case-details-management-widgets'; import {CaseDetailsManagementWidgetsPage} from './page'; import {CarbonList} from '../../shared/carbon-list/carbon-list.utils'; test.use({storageState: undefined}); +// Tests share a single context/page created in beforeAll and depend on each other +// (create → assert properties → reorder → delete). Serial mode keeps them in one +// group (so beforeAll/afterAll run exactly once despite fullyParallel) and skips the +// remaining tests when one fails, instead of cascading "context closed" errors. +test.describe.configure({mode: 'serial'}); + test.describe('Case details management — Widgets', () => { let context; let page; @@ -32,6 +45,7 @@ test.describe('Case details management — Widgets', () => { const dividerData = createDividerTestData(); const jsonDividerData = createJsonEditorDividerData(); const reorderData = createReorderTestData(); + const conditionData = createConditionWidgetTestData(); test.beforeAll(async ({browser, baseURL}) => { test.setTimeout(120_000); @@ -53,6 +67,7 @@ test.describe('Case details management — Widgets', () => { await widgetsPage.removeTestWidgetsViaApi(CASE_IDENTIFIER, draftVersion, widgetTabKey, 'E2e Test Divider'); await widgetsPage.removeTestWidgetsViaApi(CASE_IDENTIFIER, draftVersion, widgetTabKey, 'E2e JSON Divider'); await widgetsPage.removeTestWidgetsViaApi(CASE_IDENTIFIER, draftVersion, widgetTabKey, 'E2e Reorder Widget'); + await widgetsPage.removeTestWidgetsViaApi(CASE_IDENTIFIER, draftVersion, widgetTabKey, 'E2e Condition Widget'); }); test.afterAll(async () => { @@ -81,8 +96,14 @@ test.describe('Case details management — Widgets', () => { widgetTabKey, 'E2e Reorder Widget' ); + await widgetsPage.removeTestWidgetsViaApi( + CASE_IDENTIFIER, + draftVersion, + widgetTabKey, + 'E2e Condition Widget' + ); - await context.close(); + if (context) await context.close(); }); // ─── 6.88 Add widget ───────────────────────────────────────────── @@ -234,6 +255,101 @@ test.describe('Case details management — Widgets', () => { // ─── 6.96 Rearrange widgets ─────────────────────────────────────── + // ─── 6.94 Set widget conditions ─────────────────────────────────── + + test.describe('6.94 — Set widget conditions', () => { + test('Add a widget with a display condition', async () => { + // Act — the wizard's last step configures when the widget is shown + await widgetsPage.addFieldsWidget({ + title: conditionData.widgetTitle, + fieldTitle: conditionData.fieldTitle, + valuePath: conditionData.valuePath, + condition: { + path: conditionData.conditionPath, + operatorLabel: conditionData.operatorLabel, + value: conditionData.conditionValue, + }, + }); + + // Assert — the widget exists and the condition is persisted with the operator symbol + await widgetsPage.assertWidgetVisible(conditionData.widgetTitle); + await widgetsPage.assertWidgetDisplayConditions( + CASE_IDENTIFIER, + draftVersion, + widgetTabKey, + conditionData.widgetTitle, + [ + { + path: conditionData.conditionPath, + operator: conditionData.operator, + value: conditionData.conditionValue, + }, + ] + ); + }); + + test('Condition is prefilled when the widget is reopened', async () => { + test.setTimeout(60_000); + // Act — reopen the edit wizard and jump to the conditions step + await widgetsPage.openWidgetEditWizard(conditionData.widgetTitle); + await widgetsPage.goToDisplayConditionsStep(); + + // Assert — the saved condition round-trips into the form + await expect(widgetsPage.conditionRow(0)).toBeVisible(); + await expect(widgetsPage.conditionValueInput).toHaveValue(conditionData.conditionValue); + await expect(widgetsPage.conditionOperatorDropdown).toContainText( + conditionData.operatorLabel + ); + + await widgetsPage.wizardCancelButton.click(); + await widgetsPage.waitForWizardClosed(); + }); + + test('Delete the condition widget', async () => { + await widgetsPage.deleteWidgetViaOverflowMenu(conditionData.widgetTitle); + await widgetsPage.assertWidgetNotVisible(conditionData.widgetTitle); + }); + }); + + test.describe('6.94 — Failure scenarios', () => { + test('Save is disabled while a condition row is incomplete', async () => { + // Get to the conditions step of a fresh widget + await widgetsPage.addWidgetButton.click(); + await widgetsPage.selectWidgetType('tileFields'); + await widgetsPage.wizardNextButton.click(); + await widgetsPage.selectWidgetWidth('tileMedium'); + await widgetsPage.wizardNextButton.click(); + await widgetsPage.selectWidgetDensity('tileDefault'); + await widgetsPage.wizardNextButton.click(); + await widgetsPage.selectWidgetColor('tileWhite'); + await widgetsPage.wizardNextButton.click(); + await widgetsPage.fillWidgetTitle(`${conditionData.widgetTitle} Invalid`); + await widgetsPage.fillFieldTitle(conditionData.fieldTitle); + await widgetsPage.selectDisplayType('Text'); + await widgetsPage.selectValuePath(conditionData.valuePath); + await widgetsPage.wizardNextButton.click(); + + // Sanity check — with no conditions at all the widget is always shown, so save is allowed + await expect(widgetsPage.wizardSaveButton).toBeEnabled(); + + // Act — add an empty condition row + await widgetsPage.conditionsAddButton.click(); + await expect(widgetsPage.conditionRow(0)).toBeVisible(); + + // Assert — an incomplete condition blocks the save + await expect(widgetsPage.wizardSaveButton).toBeDisabled(); + + // Removing the row makes it valid again + await widgetsPage.conditionDeleteButton(0).click(); + await expect(widgetsPage.wizardSaveButton).toBeEnabled(); + + // Discard — nothing should be persisted + await widgetsPage.wizardCancelButton.click(); + await widgetsPage.waitForWizardClosed(); + await widgetsPage.assertWidgetNotVisible(`${conditionData.widgetTitle} Invalid`); + }); + }); + test.describe('6.96 — Rearrange widgets', () => { test('Add two dividers for reordering', async () => { await widgetsPage.addDivider(reorderData.titleA); diff --git a/e2e/tests/case-details-management-widgets/case-details-management-widgets.ts b/e2e/tests/case-details-management-widgets/case-details-management-widgets.ts index 40082bd76c..7e25a9bc19 100644 --- a/e2e/tests/case-details-management-widgets/case-details-management-widgets.ts +++ b/e2e/tests/case-details-management-widgets/case-details-management-widgets.ts @@ -46,6 +46,25 @@ export function createJsonEditorDividerData() { }; } +/** + * Data for the display-conditions test (6.94). + * + * `operatorLabel` is what the dropdown shows; `operator` is what the backend stores for it — + * the test asserts the stored value, so both are needed. + */ +export function createConditionWidgetTestData() { + const id = generateId(); + return { + widgetTitle: `E2e Condition Widget ${id}`, + fieldTitle: 'Condition Field', + valuePath: 'case:definitionId.key', + conditionPath: 'case:createdBy', + operatorLabel: 'Equal to', + operator: '==', + conditionValue: 'admin', + }; +} + export function createReorderTestData() { const idA = generateId(); const idB = generateId(); diff --git a/e2e/tests/case-details-management-widgets/page.ts b/e2e/tests/case-details-management-widgets/page.ts index e46c21d705..70b48b2e2e 100644 --- a/e2e/tests/case-details-management-widgets/page.ts +++ b/e2e/tests/case-details-management-widgets/page.ts @@ -28,7 +28,25 @@ import { WIDGET_WIZARD_APPEARANCE_TEST_IDS, WIDGET_CONTENT_FIELDS_TEST_IDS, WIDGET_DIVIDER_MODAL_TEST_IDS, + VALUE_PATH_SELECTOR_DROPDOWN_VALUE_TEST_IDS, } from '../../constants'; +import {fillValuePathManually} from '../../utils/value-path-selector.utils'; + +/** A single display condition as stored on a widget. */ +export interface WidgetDisplayCondition { + path: string; + operator: string; + value: string; +} + +export interface WidgetConfigResponse { + widgets: Array<{ + title: string; + key: string; + displayConditions?: WidgetDisplayCondition[]; + [k: string]: unknown; + }>; +} export class CaseDetailsManagementWidgetsPage { constructor( @@ -134,8 +152,10 @@ export class CaseDetailsManagementWidgetsPage { title: string; fieldTitle: string; valuePath: string; + /** Optional display condition, set on the final wizard step before saving (6.94). */ + condition?: {path: string; operatorLabel: string; value: string}; }) { - const {title, fieldTitle, valuePath} = opts; + const {title, fieldTitle, valuePath, condition} = opts; // Step 1: Select type await this.addWidgetButton.click(); @@ -166,58 +186,183 @@ export class CaseDetailsManagementWidgetsPage { await expect(this.wizardNextButton).toBeEnabled(); await this.wizardNextButton.click(); - // Step 6: Display conditions — skip, just save + // Step 6: Display conditions — optional; with no conditions the widget always shows + if (condition) { + await this.addDisplayCondition(condition); + } await expect(this.wizardSaveButton).toBeEnabled(); await this.wizardSaveButton.click(); // Wait for the wizard modal to close and the list to refresh - await expect(this.page.locator('cds-modal[open]')).toHaveCount(0, {timeout: 10_000}); + await this.waitForWizardClosed(); } - // ─── Display Conditions (Edit Wizard) ────────────────────────────── + // ─── 6.94 Display Conditions ─────────────────────────────────────── + // + // The conditions step renders a `valtimo-carbon-multi-input` named `displayConditions`; each + // row is a value-path-selector (path) + dropdown (operator) + text input (value). + + get conditionsAddButton() { + return this.visibleWizard.getByTestId('multiInputAddButton-displayConditions'); + } + + conditionRow(index = 0) { + return this.visibleWizard.getByTestId(`multiInputValuePathSelectorDropdownValue-${index}`); + } + + conditionDeleteButton(index = 0) { + return this.visibleWizard.getByTestId(`multiInputDeleteButton-${index}`); + } + + get conditionOperatorDropdown() { + return this.visibleWizard.getByTestId(VALUE_PATH_SELECTOR_DROPDOWN_VALUE_TEST_IDS.dropdown); + } + + get conditionValueInput() { + return this.visibleWizard.getByTestId(VALUE_PATH_SELECTOR_DROPDOWN_VALUE_TEST_IDS.valueInput); + } /** - * Opens the edit wizard for a widget, navigates to the display conditions step, - * adds a single condition (path, operator, value), and saves. + * The currently open modal. + * + * Scoping matters here: the wizard component stays in the DOM when closed, so unscoped + * `getByTestId` also matches the hidden wizard. Note `cds-modal[open]` never matches anything — + * Carbon toggles the `is-visible` class instead — so it cannot be used to detect open modals. */ - async setDisplayCondition( - widgetTitle: string, - opts: {path: string; operator: string; value: string} - ) { - // Click the widget row to open the edit wizard (opens at Content step) + get visibleWizard() { + return this.page.locator('.cds--modal.is-visible'); + } + + get wizardHeading() { + return this.visibleWizard.getByTestId(WIDGET_WIZARD_TEST_IDS.heading); + } + + async waitForWizardClosed() { + await expect(this.visibleWizard).toHaveCount(0, {timeout: 10_000}); + } + + /** + * Opens the edit wizard for an existing widget by clicking its row. + * + * Clicking a row while the previous wizard is still closing can re-open the *create* wizard + * instead, so wait for a clean slate first and retry until the edit wizard is actually up. + */ + async openWidgetEditWizard(widgetTitle: string) { const list = new CarbonList(this.page); - const row = list.row(widgetTitle); - await row.click(); + await this.waitForWizardClosed(); + + await expect(async () => { + if ((await this.wizardHeading.count()) > 0) { + if ((await this.wizardHeading.innerText()).trim() === 'Edit widget') return; + // A create wizard opened instead — dismiss it and try again. + await this.wizardCancelButton.click(); + await this.waitForWizardClosed(); + } + await list.row(widgetTitle).click(); + await expect(this.wizardHeading).toHaveText('Edit widget', {timeout: 5_000}); + }).toPass({timeout: 30_000}); + } + + /** + * Jumps to the display-conditions step via the wizard's progress indicator. + * + * The steps are rendered internally by Carbon's `cds-progress-indicator` from a `steps` input, + * which offers no hook for a `data-test-id`, so the step is located by its (English) label — + * global setup pins the UI language to English. + */ + async goToDisplayConditionsStep() { + const step = this.visibleWizard.getByRole('button', {name: /Set display conditions/}); - // Navigate to the "Set display conditions" step in the progress indicator - await this.page.getByRole('button', {name: /Set display conditions/}).click(); + // The progress indicator can swallow a click while the previous step's content is still + // being torn down, leaving the wizard on the old step. Retry until the step's content is up. + await expect(async () => { + await step.click(); + await expect(this.conditionsAddButton).toBeVisible({timeout: 3_000}); + }).toPass({timeout: 30_000}); + } - // Add a condition row - await this.page.getByTestId('multiInputAddButton-undefined').click(); + /** + * Fills condition row `index`. The path is entered in manual mode via the shared helper: the + * selector's dropdown renders its options in an overlay outside the modal and the available + * paths vary per environment, so typing the path is both simpler and more stable. + */ + async fillDisplayCondition( + condition: {path: string; operatorLabel: string; value: string}, + index = 0 + ) { + const row = this.conditionRow(index); + await expect(row).toBeVisible(); + + await fillValuePathManually(row, condition.path); - // Select the path via the value-path-selector combobox (scoped to first row) - const conditionRow = this.page.getByTestId('multiInputValuePathSelectorDropdownValue-0'); - await conditionRow.getByRole('combobox').click(); - await this.page.getByText(opts.path).click(); + await this.conditionOperatorDropdown.click(); + // Carbon renders dropdown options in an overlay outside the modal, and the modal header can + // overlap them, so the option is resolved page-wide and clicked with force. + await this.page + .getByRole('option', {name: condition.operatorLabel, exact: true}) + .click({force: true}); - // Select the operator from the dropdown - // Use force:true because the modal header can overlap the dropdown options - await this.page.getByTestId('valuePathSelectorDropdownValueDropDown').click(); - await this.page.getByText(opts.operator, {exact: true}).click({force: true}); + await this.conditionValueInput.fill(condition.value); + await expect(this.conditionValueInput).toHaveValue(condition.value); + } - // Fill in the value - const valueInput = this.page.getByTestId('valuePathSelectorDropdownValueValueInput'); - await valueInput.click(); - await valueInput.fill(opts.value); + /** Adds a condition row and fills it. */ + async addDisplayCondition( + condition: {path: string; operatorLabel: string; value: string}, + index = 0 + ) { + await this.conditionsAddButton.click(); + await this.fillDisplayCondition(condition, index); + } + + /** + * Opens the edit wizard for a widget, navigates to the display conditions step, + * adds a single condition, and saves. + */ + async setDisplayCondition( + widgetTitle: string, + condition: {path: string; operatorLabel: string; value: string} + ) { + await this.openWidgetEditWizard(widgetTitle); + await this.goToDisplayConditionsStep(); + await this.addDisplayCondition(condition); - // Save await expect(this.wizardSaveButton).toBeEnabled(); await this.wizardSaveButton.click(); - - // Wait for the wizard modal to close await expect(this.page.locator('cds-modal[open]')).toHaveCount(0, {timeout: 10_000}); } + // ─── 6.94 Assertions ─────────────────────────────────────────────── + + async getWidgetConfigViaApi( + caseDefinitionKey: string, + versionTag: string, + tabKey: string + ): Promise<WidgetConfigResponse> { + return ApiUtils.apiGet<WidgetConfigResponse>( + `/api/management/v1/case-definition/${caseDefinitionKey}/version/${versionTag}/widget-tab/${tabKey}` + ); + } + + /** Asserts the persisted `displayConditions` of a widget, polling until the save lands. */ + async assertWidgetDisplayConditions( + caseDefinitionKey: string, + versionTag: string, + tabKey: string, + widgetTitle: string, + expected: WidgetDisplayCondition[] + ) { + await expect + .poll( + async () => { + const config = await this.getWidgetConfigViaApi(caseDefinitionKey, versionTag, tabKey); + return config.widgets.find(w => w.title === widgetTitle)?.displayConditions ?? null; + }, + {message: `displayConditions of "${widgetTitle}" never matched`} + ) + .toEqual(expected); + } + // ─── Divider Modal ───────────────────────────────────────────────── get addDividerButton() { diff --git a/e2e/tests/case-details-management-zgw-general/page.ts b/e2e/tests/case-details-management-zgw-general/page.ts index a82c7cc350..1dd7381ff1 100644 --- a/e2e/tests/case-details-management-zgw-general/page.ts +++ b/e2e/tests/case-details-management-zgw-general/page.ts @@ -253,8 +253,13 @@ export class CaseDetailsManagementZgwGeneralPage { // ─── Case type link assertions ─────────────────────────────────── + /** + * The card title renders the omschrijving followed by the zaak type validity + * period, e.g. "Bezwaar behandelen (01-01-2021 - present)", so only assert + * that the omschrijving is present. + */ async assertCaseTypeLinkTitle(title: string) { - await expect(this.caseTypeLinkTitle).toHaveText(title); + await expect(this.caseTypeLinkTitle).toContainText(title); } async assertCaseTypeLinkRsinValue(rsin: string) { diff --git a/e2e/tests/decision-table-management/decision-table-management.spec.ts b/e2e/tests/decision-table-management/decision-table-management.spec.ts new file mode 100644 index 0000000000..454e88e1c0 --- /dev/null +++ b/e2e/tests/decision-table-management/decision-table-management.spec.ts @@ -0,0 +1,169 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {expect, test} from '@playwright/test'; +import {createDecisionTestData, SEEDED_DECISION_KEY} from './decision-table-management'; +import {DecisionTableManagementPage} from './page'; + +test.use({storageState: undefined}); + +// Tests share one context/page created in beforeAll. Serial mode keeps them in a single group +// (so beforeAll/afterAll run once) and skips the rest when one fails. +test.describe.configure({mode: 'serial'}); + +/** + * Covers the standalone `/decision-tables` admin page — decision tables that are *not* linked to a + * case or building block definition. The case-scoped equivalent is Feature 6E. + * + * Nothing here deploys a decision table. Deployment is the step that persists a definition, and a + * standalone decision table cannot be removed again: the list's Delete row action calls the + * case-scoped endpoint and is disabled outside a case context (asserted below). A deploying test + * would therefore permanently add a definition on every run. Creating and editing are covered up + * to — but not including — the deploy click; deployment itself is covered case-scoped in 6.40. + */ +test.describe('Decision table management', () => { + let context; + let page; + let decisionPage: DecisionTableManagementPage; + + const testData = createDecisionTestData(); + + test.beforeAll(async ({browser, baseURL}) => { + context = await browser.newContext({baseURL}); + page = await context.newPage(); + + decisionPage = new DecisionTableManagementPage(page, context.request); + + await page.goto('/'); + await decisionPage.goToDecisionTables(); + }); + + test.afterAll(async () => { + // No cleanup needed: these tests never deploy, so nothing is persisted. + if (context) await context.close(); + }); + + // ─── 8.1 View decision tables overview ──────────────────────────── + + test.describe('8.1 — View decision tables overview', () => { + test('Overview list is visible', async () => { + await expect(decisionPage.decisionList).toBeVisible(); + }); + + test('List shows the Key, Name and Version columns', async () => { + await decisionPage.assertColumnHeaders(['Key', 'Name', 'Version']); + }); + + test('List matches the decision definitions from the API', async () => { + // The list shows the latest version per key + await decisionPage.assertListMatchesApi(); + }); + + test('Seeded decision table is listed', async () => { + await decisionPage.assertDecisionVisible(SEEDED_DECISION_KEY); + }); + }); + + // ─── 8.3 Edit decision table ────────────────────────────────────── + + test.describe('8.3 — Edit decision table', () => { + test('Open an existing decision table in the DMN modeler', async () => { + test.setTimeout(60_000); + + // Act + await decisionPage.openDecisionInModeler(SEEDED_DECISION_KEY); + + // Assert — the modeler mounted, is editable, and offers a deploy action + await expect(decisionPage.modelerCanvas).toBeVisible(); + await expect(decisionPage.readOnlyTag).toHaveCount(0); + await expect(decisionPage.deployButton).toBeEnabled(); + // The table's own content is rendered, not an empty diagram + await expect(decisionPage.modeler).toContainText('betrokkene-type mapping'); + + // Leave without deploying + await decisionPage.goToDecisionTables(); + }); + }); + + // ─── 8.2 Create decision table ──────────────────────────────────── + + test.describe('8.2 — Create decision table', () => { + /** + * Kept as one test: the create modal is transient state, and submitting it navigates to the + * modeler. Splitting would lose the modal between blocks. + */ + test('Create a decision table and land in the DMN modeler', async () => { + test.setTimeout(60_000); + + // Act + await decisionPage.openCreateModal(); + await decisionPage.nameInput.fill(testData.name); + await decisionPage.addInputVariable(testData.inputVariable, testData.inputVariableLabel); + await decisionPage.submitCreateModal(); + + // Assert — an empty, editable table is opened in the modeler, ready to be deployed + await expect(decisionPage.modelerCanvas).toBeVisible(); + await expect(decisionPage.deployButton).toBeVisible(); + expect(page.url()).toContain('/decision-tables/edit/create'); + + // Leave without deploying, so nothing is persisted + await decisionPage.goToDecisionTables(); + await expect(decisionPage.decisionList).toBeVisible(); + }); + }); + + // ─── Failure scenarios ──────────────────────────────────────────── + + test.describe('Failure scenarios', () => { + test('Cannot submit the create modal without a name', async () => { + await decisionPage.openCreateModal(); + + // Assert — the name is required + await expect(decisionPage.nameInput).toHaveValue(''); + await expect(decisionPage.createSubmitButton).toBeDisabled(); + + // Filling it in enables the submit + await decisionPage.nameInput.fill(testData.name); + await expect(decisionPage.createSubmitButton).toBeEnabled(); + + // Clearing it disables it again, and nothing is created + await decisionPage.nameInput.fill(''); + await expect(decisionPage.createSubmitButton).toBeDisabled(); + + await decisionPage.visibleModal.getByRole('button', {name: 'Cancel'}).click(); + await expect(decisionPage.visibleModal).toHaveCount(0); + }); + + test('Upload stays disabled until a DMN file is selected', async () => { + await decisionPage.uploadButton.click(); + await expect(decisionPage.uploadSubmitButton).toBeDisabled(); + + await decisionPage.visibleModal.getByRole('button', {name: 'Cancel'}).click(); + await expect(decisionPage.visibleModal).toHaveCount(0); + }); + + test('Delete is offered but disabled for standalone decision tables', async () => { + // Deleting is only implemented for case-scoped decision tables, so the action is present + // in the row menu but disabled here. This is why no test in this file deploys anything. + await decisionPage.openRowMenu(SEEDED_DECISION_KEY); + + await expect(decisionPage.menuItem(SEEDED_DECISION_KEY, 'Edit')).toBeEnabled(); + await expect(decisionPage.menuItem(SEEDED_DECISION_KEY, 'Delete')).toBeDisabled(); + + await page.keyboard.press('Escape'); + }); + }); +}); diff --git a/e2e/tests/decision-table-management/decision-table-management.ts b/e2e/tests/decision-table-management/decision-table-management.ts new file mode 100644 index 0000000000..dedab2cf71 --- /dev/null +++ b/e2e/tests/decision-table-management/decision-table-management.ts @@ -0,0 +1,32 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {generateId} from '../../utils/dataGenerator'; + +/** + * A decision table that ships with the dev app and is never modified by these tests — used as the + * subject for the read-only and "open in modeler" scenarios. + */ +export const SEEDED_DECISION_KEY = 'betrokkene-type-mapping'; + +export function createDecisionTestData() { + const id = generateId(); + return { + name: `E2e Decision Table ${id}`, + inputVariable: 'e2eInputVariable', + inputVariableLabel: 'E2e input variable', + }; +} diff --git a/e2e/tests/decision-table-management/page.ts b/e2e/tests/decision-table-management/page.ts new file mode 100644 index 0000000000..1471d368dc --- /dev/null +++ b/e2e/tests/decision-table-management/page.ts @@ -0,0 +1,192 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {APIRequestContext, expect, Locator, Page} from '@playwright/test'; +import {CarbonList, CarbonListRow} from '../../shared/carbon-list/carbon-list.utils'; +import { + DECISION_FORM_TEST_IDS, + DECISION_LIST_TEST_IDS, + DECISION_MODELER_TEST_IDS, + DECISION_UPLOAD_TEST_IDS, +} from '../../constants'; +import * as ApiUtils from '../../utils/api.utils'; + +/** A decision definition as returned by `GET /api/management/v1/decision-definition`. */ +export interface DecisionDefinition { + id: string; + key: string; + name: string; + version: number; +} + +export class DecisionTableManagementPage { + constructor( + private readonly page: Page, + private readonly request: APIRequestContext + ) {} + + // ─── Navigation ─────────────────────────────────────────────────── + + async goToDecisionTables() { + await this.page.goto('/decision-tables'); + await this.page.waitForSelector('valtimo-carbon-list'); + } + + // ─── List ───────────────────────────────────────────────────────── + + get list() { + return new CarbonList(this.page); + } + + get decisionList() { + return this.page.locator('valtimo-carbon-list'); + } + + get createButton() { + return this.page.getByTestId(DECISION_LIST_TEST_IDS.createButton); + } + + get uploadButton() { + return this.page.getByTestId(DECISION_LIST_TEST_IDS.uploadButton); + } + + get uploadSubmitButton() { + return this.visibleModal.getByTestId(DECISION_UPLOAD_TEST_IDS.submitButton); + } + + row(key: string): CarbonListRow { + return this.list.row(key); + } + + // ─── Create modal ───────────────────────────────────────────────── + // + // The create and edit variants of the form modal are both in the DOM, so they share the same + // test ids. Every locator is scoped to the modal that is actually visible. + + get visibleModal(): Locator { + return this.page.locator('.cds--modal.is-visible'); + } + + get nameInput() { + return this.visibleModal.getByTestId(DECISION_FORM_TEST_IDS.nameInput); + } + + get inputVariables() { + return this.visibleModal.getByTestId(DECISION_FORM_TEST_IDS.inputVariables); + } + + get createSubmitButton() { + return this.visibleModal.getByTestId(DECISION_FORM_TEST_IDS.submitButton); + } + + async openCreateModal() { + await this.createButton.click(); + await expect(this.nameInput).toBeVisible(); + } + + /** Adds an input variable row to the create modal's multi-input. */ + async addInputVariable(processVariable: string, label: string) { + await this.inputVariables.getByTestId('multiInputAddButton').click(); + const keyInput = this.inputVariables.getByTestId('keyValueKeyInput').first(); + const valueInput = this.inputVariables.getByTestId('keyValueValueInput').first(); + await keyInput.fill(processVariable); + await valueInput.fill(label); + await expect(keyInput).toHaveValue(processVariable); + } + + /** Submits the create modal; this opens the DMN modeler but does not deploy anything yet. */ + async submitCreateModal() { + await expect(this.createSubmitButton).toBeEnabled(); + await this.createSubmitButton.click(); + await this.page.waitForURL('**/decision-tables/edit/create'); + } + + // ─── DMN modeler ────────────────────────────────────────────────── + + get modeler() { + return this.page.locator('valtimo-decision-modeler'); + } + + /** The dmn-js canvas — proof the modeler actually mounted rather than just the page shell. */ + get modelerCanvas() { + return this.page.locator('.dmn-js-parent'); + } + + get deployButton() { + return this.page.getByTestId(DECISION_MODELER_TEST_IDS.deployButton); + } + + get readOnlyTag() { + return this.page.getByTestId(DECISION_MODELER_TEST_IDS.readOnlyTag); + } + + async openDecisionInModeler(key: string) { + await this.row(key).click(); + await this.page.waitForURL('**/decision-tables/edit/**'); + await expect(this.modelerCanvas).toBeVisible(); + } + + // ─── Row actions ────────────────────────────────────────────────── + + async openRowMenu(key: string) { + await this.row(key).openActionMenu(); + } + + menuItem(key: string, name: string) { + return this.row(key).actionMenuItem(name); + } + + // ─── Assertions ─────────────────────────────────────────────────── + + async assertColumnHeaders(expected: string[]) { + const headers = (await this.page.locator('valtimo-carbon-list thead th').allInnerTexts()).map(h => + h.trim() + ); + for (const header of expected) expect(headers).toContain(header); + } + + async assertDecisionVisible(key: string) { + await expect(this.page.locator(`td:has-text("${key}")`).first()).toBeVisible(); + } + + /** The list shows the latest version per key, so compare against the highest version per key. */ + async assertListMatchesApi() { + const definitions = await this.getDecisionDefinitionsViaApi(); + const latestByKey = new Map<string, number>(); + for (const definition of definitions) { + latestByKey.set(definition.key, Math.max(latestByKey.get(definition.key) ?? 0, definition.version)); + } + + expect(latestByKey.size).toBeGreaterThan(0); + for (const [key, version] of latestByKey) { + const row = this.row(key); + await row.assertVisible(); + await expect(row.cell(String(version))).toBeVisible(); + } + } + + // ─── API ────────────────────────────────────────────────────────── + + /** Only the "global" decision tables: those not linked to a case or building block. */ + async getDecisionDefinitionsViaApi(): Promise<DecisionDefinition[]> { + return ApiUtils.apiGet<DecisionDefinition[]>('/api/management/v1/decision-definition'); + } + + async getDecisionKeysViaApi(): Promise<string[]> { + const definitions = await this.getDecisionDefinitionsViaApi(); + return [...new Set(definitions.map(d => d.key))]; + } +} diff --git a/e2e/tests/logging/logging.spec.ts b/e2e/tests/logging/logging.spec.ts new file mode 100644 index 0000000000..f2b67cc778 --- /dev/null +++ b/e2e/tests/logging/logging.spec.ts @@ -0,0 +1,156 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {expect, test} from '@playwright/test'; +import {LoggingPage} from './page'; + +test.use({storageState: undefined}); + +test.describe.configure({mode: 'serial'}); + +/** + * Covers the `/logging` admin page. The page is read-only: it lists log events the running + * application produced, so there is nothing to create or clean up. The filter panel applies + * itself on change (debounced) rather than via a submit button. + */ +test.describe('Logging', () => { + let context; + let page; + let loggingPage: LoggingPage; + + test.beforeAll(async ({browser, baseURL}) => { + context = await browser.newContext({baseURL}); + page = await context.newPage(); + + loggingPage = new LoggingPage(page, context.request); + + await page.goto('/'); + await loggingPage.goToLogging(); + }); + + test.afterAll(async () => { + // Nothing to clean up — the page only reads log events. + if (context) await context.close(); + }); + + // ─── 18.1 View application logs ─────────────────────────────────── + + test.describe('18.1 — View application logs', () => { + test('Log list is visible with log entries', async () => { + await expect(loggingPage.logList).toBeVisible(); + expect(await loggingPage.rows.count()).toBeGreaterThan(0); + }); + + test('List shows the Timestamp, Log level and Message columns', async () => { + await loggingPage.assertColumnHeaders(['Timestamp', 'Log level', 'Message']); + }); + + test('List is paginated', async () => { + await expect(loggingPage.pagination).toBeVisible(); + }); + + test('Row count matches the page size requested from the API', async () => { + const apiPage = await loggingPage.getLogsViaApi(10, 0); + expect(apiPage.content.length).toBeGreaterThan(0); + expect(await loggingPage.rows.count()).toBe(apiPage.content.length); + }); + + test('Opening a row shows the log details', async () => { + const messages = await loggingPage.messageCells(); + + // Act + await loggingPage.openLogDetails(0); + + // Assert — the details modal shows the same entry, plus its timestamp + await expect(loggingPage.detailsMessage).toBeVisible(); + await expect(loggingPage.detailsTimestamp).not.toHaveText('-'); + const detailMessage = (await loggingPage.detailsMessage.innerText()).trim(); + expect(detailMessage.length).toBeGreaterThan(0); + // The list truncates long messages, so compare on a prefix + expect(messages[0].startsWith(detailMessage.slice(0, 20))).toBeTruthy(); + + await loggingPage.closeLogDetails(); + }); + }); + + // ─── 18.2 Filter/search logs ────────────────────────────────────── + + test.describe('18.2 — Filter and search logs', () => { + test('Filter by message text narrows the list', async () => { + test.setTimeout(60_000); + + // Arrange — pick a word from an existing entry so the filter is guaranteed to match + const messages = await loggingPage.messageCells(); + const searchTerm = messages + .flatMap(m => m.split(/\s+/)) + .find(word => word.length > 5 && /^[A-Za-z]+$/.test(word)); + expect(searchTerm, 'no suitable search term found in the visible log messages').toBeTruthy(); + + // Act + await loggingPage.openSearchPanel(); + await loggingPage.filterByMessage(searchTerm!); + + // Assert — every remaining row matches the filter + await loggingPage.assertAllMessagesContain(searchTerm!); + }); + + test('Clearing the filter restores the unfiltered list', async () => { + // Act + await loggingPage.clearSearch(); + + // Assert — the message filter is empty again and rows are listed + await expect(loggingPage.messageInput).toHaveValue(''); + expect(await loggingPage.rows.count()).toBeGreaterThan(0); + }); + + test('Filter by log level narrows the list to that level and above', async () => { + test.setTimeout(60_000); + + // Arrange — WARN sits in the middle of the severity scale, so filtering on it has to drop + // something (INFO/DEBUG/TRACE) while keeping ERROR + const unfiltered = await loggingPage.getLogsViaApi(1, 0); + + // Act + await loggingPage.selectLevel('WARN'); + + // Assert — the dropdown is a *minimum* level, so WARN and ERROR both belong in the result + await loggingPage.assertAllLevelsAreAtLeast('WARN'); + + const filtered = await loggingPage.getLogsViaApi(1, 0, {level: 'WARN'}); + expect(filtered.totalElements).toBeLessThan(unfiltered.totalElements); + + await loggingPage.clearSearch(); + }); + }); + + // ─── Failure scenarios ──────────────────────────────────────────── + + test.describe('Failure scenarios', () => { + test('A filter that matches nothing shows the no-results state', async () => { + test.setTimeout(60_000); + + // Act — a message no log line can contain + await loggingPage.filterByMessage('zzz-no-log-line-matches-this-zzz'); + + // Assert — no data rows are left and the empty-state row takes their place + await expect(loggingPage.rows).toHaveCount(0); + await expect(loggingPage.noResultsRow).toBeVisible(); + + await loggingPage.clearSearch(); + expect(await loggingPage.rows.count()).toBeGreaterThan(0); + }); + }); +}); diff --git a/e2e/tests/logging/page.ts b/e2e/tests/logging/page.ts new file mode 100644 index 0000000000..389b2a6c31 --- /dev/null +++ b/e2e/tests/logging/page.ts @@ -0,0 +1,276 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {APIRequestContext, expect, Page} from '@playwright/test'; +import {CarbonList} from '../../shared/carbon-list/carbon-list.utils'; +import {LOG_DETAILS_TEST_IDS, LOG_SEARCH_TEST_IDS, LOGGING_LIST_TEST_IDS} from '../../constants'; +import * as ApiUtils from '../../utils/api.utils'; + +export interface LogEventPage { + content: Array<{formattedMessage: string; level: string; timestamp: string}>; + totalElements: number; +} + +/** Severity order, mirroring `LoggingEventSpecificationHelper.LOG_LEVELS` in the backend. */ +export const LOG_LEVELS = ['TRACE', 'DEBUG', 'INFO', 'WARN', 'ERROR']; + +/** Mirrors `LoggingEventSearchRequest` in `@valtimo/logging`. */ +export interface LogEventSearchRequest { + afterTimestamp?: string; + beforeTimestamp?: string; + level?: string; + likeFormattedMessage?: string; + properties?: Array<{key: string; value: string}>; +} + +export class LoggingPage { + constructor( + private readonly page: Page, + private readonly request: APIRequestContext + ) {} + + // ─── Navigation ─────────────────────────────────────────────────── + + async goToLogging() { + await this.page.goto('/logging'); + await this.page.waitForSelector('valtimo-carbon-list'); + await this.list.waitForLoaded(); + } + + // ─── List ───────────────────────────────────────────────────────── + + get list() { + return new CarbonList(this.page); + } + + get logList() { + return this.page.locator('valtimo-carbon-list'); + } + + /** + * Data rows only. The empty state is rendered as a `<tr data-test-id="carbonListNoResults">` + * inside the same `tbody`, so a plain `tbody tr` locator counts it as a row and never reaches + * zero — `CarbonList.rows` filters it out. + */ + get rows() { + return this.list.rows; + } + + get noResultsRow() { + return this.list.noResultsRow; + } + + get pagination() { + return this.list.pagination; + } + + // ─── Search panel ───────────────────────────────────────────────── + // + // The search panel lives behind a funnel button in the table toolbar and applies itself on + // change (debounced 500ms) — there is no submit button. + + get filterButton() { + return this.page.getByTestId(LOGGING_LIST_TEST_IDS.filterButton); + } + + get searchPanel() { + return this.page.locator('valtimo-log-search'); + } + + get messageInput() { + return this.page.getByTestId(LOG_SEARCH_TEST_IDS.messageInput); + } + + get levelDropdown() { + return this.page.getByTestId(LOG_SEARCH_TEST_IDS.levelDropdown); + } + + get propertyKeyInput() { + return this.page.getByTestId(LOG_SEARCH_TEST_IDS.propertyKeyInput).first(); + } + + get propertyValueInput() { + return this.page.getByTestId(LOG_SEARCH_TEST_IDS.propertyValueInput).first(); + } + + get addPropertyButton() { + return this.page.getByTestId(LOG_SEARCH_TEST_IDS.addPropertyButton); + } + + get clearSearchButton() { + return this.page.getByTestId(LOG_SEARCH_TEST_IDS.clearButton); + } + + async openSearchPanel() { + await this.filterButton.click(); + await expect(this.messageInput).toBeVisible(); + } + + /** + * Types into the message filter and waits for the resulting request. The panel debounces for + * 500ms and then re-queries, so waiting on the response is more reliable than a fixed delay. + */ + async filterByMessage(text: string) { + await Promise.all([ + this.page.waitForResponse( + res => res.url().includes('/api/management/v1/logging') && res.ok(), + {timeout: 15_000} + ), + this.messageInput.fill(text), + ]); + await this.list.waitForLoaded(); + } + + async selectLevel(level: string) { + await this.levelDropdown.click(); + await Promise.all([ + this.waitForLogQuery(), + this.page.getByRole('option', {name: level, exact: true}).click(), + ]); + // Confirm the dropdown really took the value before anything asserts on the filtered list + await expect(this.levelDropdown).toContainText(level); + await this.list.waitForLoaded(); + } + + /** + * Clears the search panel. Like the other filter controls, clearing re-queries asynchronously, + * so waiting only on the skeleton would leave the previous (possibly empty) result set on + * screen. If the panel was already clean no request is fired, hence the tolerated timeout. + */ + async clearSearch() { + await Promise.all([this.waitForLogQuery(), this.clearSearchButton.click()]); + await this.list.waitForLoaded(); + } + + /** Resolves on the next successful logging query, or after the timeout if none is fired. */ + private async waitForLogQuery(timeout = 15_000) { + return this.page + .waitForResponse(res => res.url().includes('/api/management/v1/logging') && res.ok(), { + timeout, + }) + .catch(() => undefined); + } + + // ─── Details modal ──────────────────────────────────────────────── + + get detailsModal() { + return this.page.locator('.cds--modal.is-visible'); + } + + get detailsMessage() { + return this.page.getByTestId(LOG_DETAILS_TEST_IDS.message); + } + + get detailsTimestamp() { + return this.page.getByTestId(LOG_DETAILS_TEST_IDS.timestamp); + } + + async openLogDetails(rowIndex = 0) { + await this.rows.nth(rowIndex).click(); + await expect(this.detailsMessage).toBeVisible(); + } + + async closeLogDetails() { + await this.detailsModal.locator('button.cds--modal-close').click(); + await expect(this.detailsModal).toHaveCount(0); + } + + // ─── Assertions ─────────────────────────────────────────────────── + + async assertColumnHeaders(expected: string[]) { + const headers = (await this.page.locator('valtimo-carbon-list thead th').allInnerTexts()).map( + h => h.trim() + ); + for (const header of expected) expect(headers).toContain(header); + } + + // The filter panel re-queries asynchronously and the table re-renders after the response has + // already landed, so both assertions below poll instead of reading a single snapshot of the DOM. + + /** Every visible message cell contains `text` (case-insensitive). */ + async assertAllMessagesContain(text: string) { + await expect + .poll( + async () => { + const messages = await this.messageCells(); + const needle = text.toLowerCase(); + return messages.length > 0 && messages.every(m => m.toLowerCase().includes(needle)); + }, + { + timeout: 15_000, + message: `expected every visible log message to contain "${text}"`, + } + ) + .toBe(true); + } + + /** + * Every visible row is logged at `level` **or more severe**. The level dropdown is a minimum, + * not an exact match: the backend filters with `byMinimumLevel`, which keeps every level from + * the selected one upwards (see `LoggingEventSpecificationHelper.LOG_LEVELS`). Selecting WARN + * therefore legitimately still lists ERROR rows. + */ + async assertAllLevelsAreAtLeast(level: string) { + const minimum = LOG_LEVELS.indexOf(level); + expect(minimum, `unknown log level ${level}`).toBeGreaterThanOrEqual(0); + + await expect + .poll( + async () => { + const levels = await this.levelCells(); + return levels.length > 0 && levels.every(value => LOG_LEVELS.indexOf(value) >= minimum); + }, + { + timeout: 15_000, + message: `expected every visible log row to be logged at ${level} or more severe`, + } + ) + .toBe(true); + } + + // Both readers take the whole column in one call. Counting the rows and then indexing into + // them row by row races with the table re-rendering after a filter change: the row that was + // counted can be detached by the time it is read. + + /** Text of the message column (third column) for every visible row. */ + async messageCells(): Promise<string[]> { + return this.columnCells(3); + } + + /** Text of the log level column (second column) for every visible row. */ + async levelCells(): Promise<string[]> { + return this.columnCells(2); + } + + private async columnCells(nthChild: number): Promise<string[]> { + const cells = await this.rows.locator(`td:nth-child(${nthChild})`).allInnerTexts(); + return cells.map(cell => cell.trim()); + } + + // ─── API ────────────────────────────────────────────────────────── + + /** + * The logging endpoint is a POST — the search criteria travel in the body while paging stays in + * the query string. Only POST is granted to ADMIN (see `LoggingHttpSecurityConfigurer`), so a + * GET on the same path returns 403. + */ + async getLogsViaApi(size = 10, page = 0, search: LogEventSearchRequest = {}) { + return ApiUtils.apiPost<LogEventPage>( + `/api/management/v1/logging?size=${size}&page=${page}`, + search + ); + } +} diff --git a/e2e/tests/object-management/object-management-config.ts b/e2e/tests/object-management/object-management-config.ts new file mode 100644 index 0000000000..5c0ba87df3 --- /dev/null +++ b/e2e/tests/object-management/object-management-config.ts @@ -0,0 +1,74 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export const OBJECT_MANAGEMENT_API = { + /** Admin CRUD. The management resource only serves GET; this one is the full set. */ + configurations: '/api/v1/object/management/configuration', + configuration: (id: string) => `/api/v1/object/management/configuration/${id}`, + /** The slim, PBAC-gated list the user-facing pages read. */ + userConfigurations: '/api/v1/object-management/configuration', +} as const; + +/** + * The object type the tests create. + * + * `objecttypeId` is generated per run rather than pointing at a real objecttype: + * `object_management_configuration` has a unique constraint on the column, so + * reusing a seeded id makes the insert fail. The tests never read objects + * through this configuration, so an id that resolves to nothing is fine. + */ +export const TEST_OBJECT_TYPE = { + titlePrefix: 'E2E Object type', + /** + * The title an edit renames to. Deliberately *not* the original title plus a + * suffix: carbon list rows are matched on substring, so a superstring title + * would keep matching the original row as well. + */ + editedTitlePrefix: 'E2E Edited object type', + objecttypeVersion: '2', +} as const; + +/** An objecttype id that is already taken, used by the duplicate failure test. */ +export const SEEDED_OBJECT_TYPE = { + title: 'Bomen', + objecttypeId: 'feeaa795-d212-4fa2-bb38-2c34996e5702', +} as const; + +/** + * Objecttype ids are UUIDs and must be unique across configurations, so each run + * makes its own. + */ +export function generateObjecttypeId(seed: string): string { + const hex = seed.replace(/[^0-9a-f]/gi, '').padEnd(12, '0'); + return `e2e00000-0000-4000-8000-${hex.slice(0, 12)}`; +} + +export const OBJECT_MANAGEMENT_TEXTS = { + /** The overview lists one column only. */ + columns: ['Title'], + createModalHeading: 'Create objecttype', + editModalHeading: 'Edit objecttype', + detailTabs: ['General', 'Search Fields', 'List'], + visibleInMenuTag: 'Visible in menu', + /** Required fields of the add/edit modal, in render order. */ + requiredFieldLabels: [ + 'Title (required)', + 'Objects API plugin configuration (required)', + 'Objecttypen API plugin configuration (required)', + 'Objecttype ID (required)', + 'Objecttype version (required)', + ], +} as const; diff --git a/e2e/tests/object-management/object-management.spec.ts b/e2e/tests/object-management/object-management.spec.ts new file mode 100644 index 0000000000..79f1702d53 --- /dev/null +++ b/e2e/tests/object-management/object-management.spec.ts @@ -0,0 +1,253 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {expect, test} from '@playwright/test'; +import {generateId} from '../../utils/dataGenerator'; +import { + generateObjecttypeId, + OBJECT_MANAGEMENT_TEXTS, + SEEDED_OBJECT_TYPE, + TEST_OBJECT_TYPE, +} from './object-management-config'; +import {ObjectManagementPage} from './page'; + +test.use({storageState: undefined}); + +/** + * Feature 12 — the object type configurations at `/object-management`. + * + * The tests run in declaration order and share one configuration: it is created + * through the UI by the first test and edited by the ones after it. + */ +test.describe('Feature 12 — Object management', () => { + let context; + let page; + let request; + let objectManagementPage: ObjectManagementPage; + + const uniqueId = generateId(); + const objectTypeTitle = `${TEST_OBJECT_TYPE.titlePrefix} ${uniqueId}`; + const editedTitle = `${TEST_OBJECT_TYPE.editedTitlePrefix} ${uniqueId}`; + const objecttypeId = generateObjecttypeId(uniqueId); + + /** Every configuration these tests create, so `afterAll` can remove them. */ + const createdIds: string[] = []; + + test.beforeAll(async ({browser, baseURL}) => { + context = await browser.newContext({baseURL}); + page = await context.newPage(); + request = context.request; + + objectManagementPage = new ObjectManagementPage(page, request); + + await page.goto('/'); + }); + + test.afterAll(async () => { + for (const id of createdIds) { + await objectManagementPage.deleteConfigurationViaApi(id); + } + + await context.close(); + }); + + test.describe('12.1 — Manage object types', () => { + test('12.1 — Lists the configured object types', async () => { + await objectManagementPage.goToObjectManagement(); + + const headers = await objectManagementPage.carbonList.table + .locator('thead th') + .allInnerTexts(); + expect(headers.map(header => header.trim()).filter(Boolean)).toEqual([ + ...OBJECT_MANAGEMENT_TEXTS.columns, + ]); + + // The overview matches the API, one row per configuration. + const configurations = await objectManagementPage.getConfigurationsViaApi(); + expect(configurations.length).toBeGreaterThan(0); + await objectManagementPage.carbonList.assertRowCount(configurations.length); + + for (const configuration of configurations) { + await objectManagementPage.rowByTitle(configuration.title).assertVisible(); + } + }); + + test('12.1 — Creates an object type through the modal', async () => { + await objectManagementPage.goToObjectManagement(); + await objectManagementPage.openCreateModal(); + + await expect(objectManagementPage.modalHeading).toHaveText( + OBJECT_MANAGEMENT_TEXTS.createModalHeading + ); + + await objectManagementPage.fillObjectTypeForm({ + title: objectTypeTitle, + objecttypeId, + objecttypeVersion: TEST_OBJECT_TYPE.objecttypeVersion, + }); + + const response = await objectManagementPage.submitModal('POST'); + expect(response.ok()).toBe(true); + + // It joins the overview… + await objectManagementPage.carbonList.waitForLoaded(); + await objectManagementPage.rowByTitle(objectTypeTitle).assertVisible(); + + // …and is persisted with the values the modal was filled with. + const created = await objectManagementPage.getConfigurationByTitleViaApi(objectTypeTitle); + expect(created).toBeDefined(); + createdIds.push(created!.id); + expect(created).toMatchObject({ + title: objectTypeTitle, + objecttypeId, + objecttypeVersion: Number(TEST_OBJECT_TYPE.objecttypeVersion), + // Left untouched in the modal, so both keep their default. + showInDataMenu: false, + suppressOutbox: false, + }); + }); + + test.describe('Failure scenarios', () => { + test('12.1a — Save stays disabled until every required field is filled', async () => { + await objectManagementPage.goToObjectManagement(); + const before = await objectManagementPage.getConfigurationsViaApi(); + + await objectManagementPage.openCreateModal(); + + // Nothing filled in yet. + await expect(objectManagementPage.modalSaveButton).toBeDisabled(); + + // Every required field is marked as such, and a title alone is not enough. + for (const label of OBJECT_MANAGEMENT_TEXTS.requiredFieldLabels) { + await expect(page.getByText(label, {exact: true}).first()).toBeVisible(); + } + await objectManagementPage.titleInput.fill(`Incomplete ${uniqueId}`); + await expect(objectManagementPage.modalSaveButton).toBeDisabled(); + + await objectManagementPage.fillObjectTypeForm({ + title: `Incomplete ${uniqueId}`, + objecttypeId, + objecttypeVersion: TEST_OBJECT_TYPE.objecttypeVersion, + }); + await expect(objectManagementPage.modalSaveButton).toBeEnabled(); + + // Clearing a required field disables it again. + await objectManagementPage.titleInput.fill(''); + await expect(objectManagementPage.modalSaveButton).toBeDisabled(); + + // Cancelling persists nothing. + await objectManagementPage.closeModal(); + const after = await objectManagementPage.getConfigurationsViaApi(); + expect(after).toHaveLength(before.length); + }); + + test('12.1b — A duplicate objecttype ID is rejected and nothing is created', async () => { + await objectManagementPage.goToObjectManagement(); + const before = await objectManagementPage.getConfigurationsViaApi(); + + await objectManagementPage.openCreateModal(); + await objectManagementPage.fillObjectTypeForm({ + title: `Duplicate ${uniqueId}`, + // Already used by a seeded configuration, and the column is unique. + objecttypeId: SEEDED_OBJECT_TYPE.objecttypeId, + objecttypeVersion: TEST_OBJECT_TYPE.objecttypeVersion, + }); + + const response = await objectManagementPage.submitModal('POST'); + expect(response.status()).toBe(500); + + // The modal stays open so the value can be corrected. No error is + // surfaced to the user today; that is deliberately not asserted, so a + // fix that adds one will not break this test. + await expect(objectManagementPage.modalSaveButton).toBeVisible(); + + await objectManagementPage.closeModal(); + const after = await objectManagementPage.getConfigurationsViaApi(); + expect(after).toHaveLength(before.length); + expect( + after.find(configuration => configuration.title === `Duplicate ${uniqueId}`) + ).toBeUndefined(); + }); + }); + }); + + test.describe('12.2 — Edit object type configuration', () => { + test('12.2 — Opens the detail page with its tabs and actions', async () => { + const created = await objectManagementPage.getConfigurationByTitleViaApi(objectTypeTitle); + expect(created, 'the object type created earlier still exists').toBeDefined(); + + await objectManagementPage.goToObjectTypeDetail(created!.id); + + await expect(objectManagementPage.generalTab).toHaveText( + OBJECT_MANAGEMENT_TEXTS.detailTabs[0] + ); + await expect(objectManagementPage.searchFieldsTab).toHaveText( + OBJECT_MANAGEMENT_TEXTS.detailTabs[1] + ); + // The List tab is rendered too: its `*ngIf` is fed an observable that + // emits an empty array when no list columns are configured, which is + // truthy. + await expect(objectManagementPage.listTab).toHaveText( + OBJECT_MANAGEMENT_TEXTS.detailTabs[2] + ); + + await expect(objectManagementPage.downloadButton).toBeEnabled(); + await expect(objectManagementPage.editButton).toBeEnabled(); + + // The tag is bound to showInDataMenu, which is still false. + await expect(objectManagementPage.visibleInMenuTag).toHaveCount(0); + }); + + test('12.2 — Edits the configuration and persists the change', async () => { + const created = await objectManagementPage.getConfigurationByTitleViaApi(objectTypeTitle); + await objectManagementPage.goToObjectTypeDetail(created!.id); + + await objectManagementPage.openEditModal(); + await expect(objectManagementPage.modalHeading).toHaveText( + OBJECT_MANAGEMENT_TEXTS.editModalHeading + ); + + // The modal opens prefilled with the stored configuration. + await expect(objectManagementPage.titleInput).toHaveValue(objectTypeTitle); + await expect(objectManagementPage.objecttypeIdInput).toHaveValue(objecttypeId); + + await objectManagementPage.titleInput.fill(editedTitle); + // Also flip the menu toggle, so the change is visible on the page itself. + await objectManagementPage.setShowInDataMenu(true); + + const response = await objectManagementPage.submitModal('PUT'); + expect(response.ok()).toBe(true); + + const updated = await objectManagementPage.getConfigurationByTitleViaApi(editedTitle); + expect(updated).toMatchObject({ + id: created!.id, + title: editedTitle, + showInDataMenu: true, + }); + + // The detail page reflects it after a reload. + await objectManagementPage.goToObjectTypeDetail(created!.id); + await expect(objectManagementPage.visibleInMenuTag).toHaveText( + OBJECT_MANAGEMENT_TEXTS.visibleInMenuTag + ); + + // And so does the overview. + await objectManagementPage.goToObjectManagement(); + await objectManagementPage.rowByTitle(editedTitle).assertVisible(); + await objectManagementPage.rowByTitle(objectTypeTitle).assertNotVisible(); + }); + }); +}); diff --git a/e2e/tests/object-management/page.ts b/e2e/tests/object-management/page.ts new file mode 100644 index 0000000000..7917aed317 --- /dev/null +++ b/e2e/tests/object-management/page.ts @@ -0,0 +1,245 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {type APIRequestContext, type Locator, type Page, expect} from '@playwright/test'; +import { + OBJECT_MANAGEMENT_DETAIL_TEST_IDS, + OBJECT_MANAGEMENT_LIST_TEST_IDS, + OBJECT_MANAGEMENT_MODAL_TEST_IDS, +} from '../../constants'; +import {CarbonList, CarbonListRow} from '../../shared/carbon-list/carbon-list.utils'; +import {VSelect} from '../../shared/v-select/v-select.utils'; +import {apiDelete, apiGet} from '../../utils/api.utils'; +import {OBJECT_MANAGEMENT_API} from './object-management-config'; + +/** One object type configuration, as the management API returns it. */ +export interface ObjectTypeConfiguration { + id: string; + title: string; + objecttypenApiPluginConfigurationId: string; + objecttypeId: string; + objecttypeVersion: number; + objectenApiPluginConfigurationId: string; + showInDataMenu: boolean; + formDefinitionView: string | null; + formDefinitionEdit: string | null; + suppressOutbox: boolean; +} + +export class ObjectManagementPage { + readonly carbonList: CarbonList; + + constructor( + private readonly page: Page, + private readonly request: APIRequestContext + ) { + this.carbonList = new CarbonList(page); + } + + // ─── List ───────────────────────────────────────────────────────── + + get uploadButton(): Locator { + return this.page.getByTestId(OBJECT_MANAGEMENT_LIST_TEST_IDS.uploadButton); + } + + get createButton(): Locator { + return this.page.getByTestId(OBJECT_MANAGEMENT_LIST_TEST_IDS.createButton); + } + + rowByTitle(title: string): CarbonListRow { + return this.carbonList.row(title); + } + + // ─── Modal ──────────────────────────────────────────────────────── + + get modalHeading(): Locator { + return this.page.getByTestId(OBJECT_MANAGEMENT_MODAL_TEST_IDS.heading); + } + + get modalCancelButton(): Locator { + return this.page.getByTestId(OBJECT_MANAGEMENT_MODAL_TEST_IDS.cancelButton); + } + + get modalSaveButton(): Locator { + return this.page.getByTestId(OBJECT_MANAGEMENT_MODAL_TEST_IDS.saveButton); + } + + /** The test id is on the `v-input` host, so the field itself is one level down. */ + private modalInput(testId: string): Locator { + return this.page.getByTestId(testId).locator('input'); + } + + get titleInput(): Locator { + return this.modalInput(OBJECT_MANAGEMENT_MODAL_TEST_IDS.titleInput); + } + + get objecttypeIdInput(): Locator { + return this.modalInput(OBJECT_MANAGEMENT_MODAL_TEST_IDS.objecttypeIdInput); + } + + get objecttypeVersionInput(): Locator { + return this.modalInput(OBJECT_MANAGEMENT_MODAL_TEST_IDS.objecttypeVersionInput); + } + + private select(testId: string): VSelect { + return new VSelect(this.page, this.page.getByTestId(testId)); + } + + get objectenApiSelect(): VSelect { + return this.select(OBJECT_MANAGEMENT_MODAL_TEST_IDS.objectenApiSelect); + } + + get objecttypenApiSelect(): VSelect { + return this.select(OBJECT_MANAGEMENT_MODAL_TEST_IDS.objecttypenApiSelect); + } + + /** + * The "Show in menu" checkbox. + * + * Carbon hides the real `<input>` and paints the visible box onto the label, + * so the input can be read but never clicked — `check()` on it times out. + */ + private get showInDataMenu(): Locator { + return this.page.getByTestId(OBJECT_MANAGEMENT_MODAL_TEST_IDS.showInDataMenuCheckbox); + } + + get showInDataMenuInput(): Locator { + return this.showInDataMenu.locator('input[type="checkbox"]'); + } + + /** Toggle "Show in menu" by clicking its label, then wait for the new state. */ + async setShowInDataMenu(checked: boolean) { + if ((await this.showInDataMenuInput.isChecked()) === checked) return; + // Two labels sit under the host: the field title and the checkbox's own. + await this.showInDataMenu.locator('cds-checkbox label').click(); + await expect(this.showInDataMenuInput).toBeChecked({checked}); + } + + // ─── Detail ─────────────────────────────────────────────────────── + + get visibleInMenuTag(): Locator { + return this.page.getByTestId(OBJECT_MANAGEMENT_DETAIL_TEST_IDS.visibleInMenuTag); + } + + get generalTab(): Locator { + return this.page.getByTestId(OBJECT_MANAGEMENT_DETAIL_TEST_IDS.generalTab); + } + + get searchFieldsTab(): Locator { + return this.page.getByTestId(OBJECT_MANAGEMENT_DETAIL_TEST_IDS.searchFieldsTab); + } + + get listTab(): Locator { + return this.page.getByTestId(OBJECT_MANAGEMENT_DETAIL_TEST_IDS.listTab); + } + + get downloadButton(): Locator { + return this.page.getByTestId(OBJECT_MANAGEMENT_DETAIL_TEST_IDS.downloadButton); + } + + get editButton(): Locator { + return this.page.getByTestId(OBJECT_MANAGEMENT_DETAIL_TEST_IDS.editButton); + } + + // ─── Navigation ─────────────────────────────────────────────────── + + /** + * Navigate straight to the route rather than driving the Admin menu: loading + * the dashboard first and then opening a heavy admin route crashes the + * Chromium renderer. + */ + async goToObjectManagement() { + await this.page.goto('/object-management'); + await this.page.waitForURL(/\/object-management$/); + await this.carbonList.waitForLoaded(); + await expect(this.carbonList.table).toBeVisible(); + } + + async goToObjectTypeDetail(id: string) { + await this.page.goto(`/object-management/object/${id}`); + await this.page.waitForURL(new RegExp(`/object-management/object/${id}$`)); + await expect(this.generalTab).toBeVisible(); + } + + // ─── Actions ────────────────────────────────────────────────────── + + async openCreateModal() { + await expect(this.createButton).toBeEnabled(); + await this.createButton.click(); + await expect(this.modalSaveButton).toBeVisible(); + } + + async openEditModal() { + await expect(this.editButton).toBeEnabled(); + await this.editButton.click(); + await expect(this.modalSaveButton).toBeVisible(); + } + + async closeModal() { + await this.modalCancelButton.click(); + await expect(this.modalSaveButton).not.toBeVisible(); + } + + /** + * Fill every required field of the add/edit modal. The two plugin dropdowns + * are picked by index: the environment seeds exactly one configuration of each + * API, and which one it is does not matter to these tests. + */ + async fillObjectTypeForm(values: { + title: string; + objecttypeId: string; + objecttypeVersion: string; + }) { + await this.titleInput.fill(values.title); + await this.objectenApiSelect.selectByIndex(0); + await this.objecttypenApiSelect.selectByIndex(0); + await this.objecttypeIdInput.fill(values.objecttypeId); + await this.objecttypeVersionInput.fill(values.objecttypeVersion); + } + + /** Submit the modal and wait for the write it triggers. */ + async submitModal(method: 'POST' | 'PUT') { + await expect(this.modalSaveButton).toBeEnabled(); + const [response] = await Promise.all([ + this.page.waitForResponse( + res => + new URL(res.url()).pathname === OBJECT_MANAGEMENT_API.configurations && + res.request().method() === method + ), + this.modalSaveButton.click(), + ]); + return response; + } + + // ─── API helpers ────────────────────────────────────────────────── + + async getConfigurationsViaApi(): Promise<ObjectTypeConfiguration[]> { + return apiGet<ObjectTypeConfiguration[]>(OBJECT_MANAGEMENT_API.configurations); + } + + async getConfigurationByTitleViaApi(title: string): Promise<ObjectTypeConfiguration | undefined> { + const configurations = await this.getConfigurationsViaApi(); + return configurations.find(configuration => configuration.title === title); + } + + async deleteConfigurationViaApi(id: string) { + try { + await apiDelete(OBJECT_MANAGEMENT_API.configuration(id)); + } catch { + // Already gone. + } + } +} diff --git a/e2e/tests/plugins/page.ts b/e2e/tests/plugins/page.ts index f2aba8a9a8..e4cad48a6f 100644 --- a/e2e/tests/plugins/page.ts +++ b/e2e/tests/plugins/page.ts @@ -14,10 +14,30 @@ * limitations under the License. */ -import {APIRequestContext, expect, Page} from '@playwright/test'; -import {PluginFieldMap, pluginTestConfiguration, pluginTypes} from './plugin-config'; -import {STEPPER_FOOTER_STEP_TEST_IDS} from '../../constants'; +import {APIRequestContext, expect, Locator, Page} from '@playwright/test'; +import { + PluginFieldMap, + pluginDetailTestData, + pluginTestConfiguration, + pluginTypes, +} from './plugin-config'; +import { + DEFAULT_PLUGIN_CONFIGURATION_TEST_IDS, + PLUGIN_CATALOG_TEST_IDS, + PLUGIN_EDIT_MODAL_TEST_IDS, + STEPPER_FOOTER_STEP_TEST_IDS, +} from '../../constants'; import {CarbonList} from '../../shared/carbon-list/carbon-list.utils'; +import * as ApiUtils from '../../utils/api.utils'; + +export interface PluginConfigurationResponse { + id: string; + title: string; + properties: Record<string, string>; + pluginDefinition: {key: string; title: string; description: string}; +} + +const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; export class PluginPage { constructor(private readonly page: Page, private readonly request: APIRequestContext) {} @@ -379,4 +399,198 @@ export class PluginPage { // TODO: fill subform } + + // ─── Configuration Details (9.12, 9.17, 9.19, 9.21, 9.23–9.25) ──── + // + // Both the add wizard and the edit modal render the same plugin configuration form, so all + // field locators are scoped to whichever modal is currently visible. + + get visibleModal(): Locator { + return this.page.locator('.cds--modal.is-visible'); + } + + get configurationIdInput(): Locator { + return this.visibleModal + .getByTestId(DEFAULT_PLUGIN_CONFIGURATION_TEST_IDS.configurationId) + .locator('input'); + } + + get editModalSaveButton(): Locator { + return this.visibleModal.getByTestId(PLUGIN_EDIT_MODAL_TEST_IDS.saveButton); + } + + get cancelWizardButton(): Locator { + return this.visibleModal.getByTestId(STEPPER_FOOTER_STEP_TEST_IDS.cancelButton); + } + + /** + * Selects a catalog tile by its exact title. `selectPluginType` matches anywhere in the tile + * (title *and* description), so it picks the wrong tile for short names such as "OpenZaak", + * which also appears in other plugins' descriptions. + */ + async selectPluginTypeByTitle(title: string): Promise<void> { + const tile = this.catalogTiles.filter({ + has: this.page.getByTestId(PLUGIN_CATALOG_TEST_IDS.tileTitle).filter({ + hasText: new RegExp(`^\\s*${title.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*$`), + }), + }); + await tile.first().click(); + await this.enterDataButton.click(); + await this.verifyStepperStep2(); + } + + /** Returns the labels currently offered by an authentication (or any other) v-select. */ + async getSelectOptionLabels(testId: string): Promise<string[]> { + const select = this.visibleModal.getByTestId(testId); + await select.locator('cds-combo-box').click(); + const options = select.getByRole('option'); + await expect(options.first()).toBeVisible(); + const labels = await options.allInnerTexts(); + await this.page.keyboard.press('Escape'); + return labels.map(label => label.trim()); + } + + async selectSelectOption(testId: string, optionLabel: string): Promise<void> { + const select = this.visibleModal.getByTestId(testId); + await select.locator('cds-combo-box').click(); + await select.getByRole('option').getByText(optionLabel, {exact: true}).click(); + await expect(select.locator('input')).toHaveValue(optionLabel); + } + + async fillModalInput(testId: string, value: string): Promise<void> { + const input = this.visibleModal.getByTestId(testId).locator('input'); + await input.fill(value); + await expect(input).toHaveValue(value); + } + + /** + * Creates a second OpenZaak authentication configuration so that authentication dropdowns + * offer more than the single seeded option. + */ + async createOpenZaakAuthConfiguration( + titleTestId: string, + clientIdTestId: string, + clientSecretTestId: string + ): Promise<void> { + await this.openWizard(); + await this.selectPluginTypeByTitle('OpenZaak'); + await this.fillModalInput(titleTestId, pluginDetailTestData.altAuthTitle); + await this.fillModalInput(clientIdTestId, pluginDetailTestData.clientId); + await this.fillModalInput(clientSecretTestId, pluginDetailTestData.clientSecret); + await this.saveConfiguration(); + } + + /** Opens the edit modal for an existing configuration by clicking its row. */ + async openEditModal(configurationTitle: string): Promise<void> { + await this.page.locator(`tr:has(td:has-text("${configurationTitle}"))`).first().click(); + await expect(this.configurationIdInput).toBeVisible(); + } + + /** Dismisses the edit modal via its header close button, discarding any changes. */ + async closeEditModal(): Promise<void> { + await this.visibleModal.locator('button.cds--modal-close').click(); + await expect(this.visibleModal).toHaveCount(0); + } + + /** + * Clicks save in the edit modal expecting the backend to reject the update, and asserts the + * modal stays open (the save did not go through). + * + * Note: unlike the create path, the update path shows no error toast — the only user-visible + * signal is that the modal does not close. The assertion therefore does not look for one. + */ + async expectEditSaveError(): Promise<void> { + await expect(this.editModalSaveButton).toBeEnabled(); + const [response] = await Promise.all([ + this.page.waitForResponse( + res => + res.url().includes('/api/v1/plugin/configuration') && + res.request().method() === 'PUT' && + !res.ok() + ), + this.editModalSaveButton.click(), + ]); + expect(response.status()).toBe(500); + await expect(this.visibleModal).toHaveCount(1); + } + + async saveEditModal(): Promise<void> { + await expect(this.editModalSaveButton).toBeEnabled(); + await Promise.all([ + this.page.waitForResponse( + res => + res.url().includes('/api/v1/plugin/configuration') && + res.request().method() === 'PUT' && + res.ok() + ), + this.editModalSaveButton.click(), + ]); + await expect(this.visibleModal).toHaveCount(0); + } + + // ─── Configuration Detail Assertions ────────────────────────────── + + async assertConfigurationIdIsGeneratedUuid(configurationTitle: string): Promise<string> { + const configuration = await this.getConfigurationByTitle(configurationTitle); + expect(configuration, `no plugin configuration titled "${configurationTitle}"`).toBeTruthy(); + expect(configuration!.id).toMatch(UUID_PATTERN); + return configuration!.id; + } + + async assertConfigurationIdDisplayed(expectedId: string): Promise<void> { + await expect(this.configurationIdInput).toHaveValue(expectedId); + expect(expectedId).toMatch(UUID_PATTERN); + } + + async assertConfigurationProperty( + configurationTitle: string, + property: string, + expectedValue: string + ): Promise<void> { + // The list refreshes after a save, so poll until the API reflects the new value. + await expect + .poll( + async () => (await this.getConfigurationByTitle(configurationTitle))?.properties[property], + {message: `plugin configuration property "${property}" never became "${expectedValue}"`} + ) + .toBe(expectedValue); + } + + async assertConfigurationCount(expectedCount: number): Promise<void> { + const configurations = await this.getAllConfigurations(); + expect(configurations.length).toBe(expectedCount); + } + + // ─── Configuration Detail API Helpers ───────────────────────────── + + async getAllConfigurations(): Promise<PluginConfigurationResponse[]> { + return ApiUtils.apiGet<PluginConfigurationResponse[]>('/api/v1/plugin/configuration'); + } + + async getConfigurationByTitle(title: string): Promise<PluginConfigurationResponse | undefined> { + const configurations = await this.getAllConfigurations(); + return configurations.find(configuration => configuration.title === title); + } + + /** + * Deletes every configuration with one of the given titles. Safe to call repeatedly. + * Titles are processed in the order given, so dependants (e.g. an API configuration) must be + * listed before the authentication configuration they reference. + */ + async deleteConfigurationsByTitleViaApi(titles: string[]): Promise<void> { + try { + const configurations = await this.getAllConfigurations(); + for (const title of titles) { + for (const configuration of configurations.filter(({title: t}) => t === title)) { + try { + await ApiUtils.apiDelete(`/api/v1/plugin/configuration/${configuration.id}`); + } catch { + // configuration may already be gone, or is still referenced by another config + } + } + } + } catch { + // the API may be unavailable during teardown + } + } } diff --git a/e2e/tests/plugins/plugin-config.ts b/e2e/tests/plugins/plugin-config.ts index e3815cf23a..3e0acf7c97 100644 --- a/e2e/tests/plugins/plugin-config.ts +++ b/e2e/tests/plugins/plugin-config.ts @@ -63,6 +63,34 @@ export const pluginTypes = [ // 'Zaken API', ]; +/** + * Data for the configuration-detail tests (9.12–9.25). These need two plugin + * configurations: a Besluiten API configuration to edit, and a *second* OpenZaak + * authentication configuration so that the authentication dropdown has more than one + * option to choose from (the seeded environment ships only one). + * + * RSIN values must pass the backend's 11-proof validation — see the `expectInvalidRSINError` + * test for what happens when they don't. + */ +export const pluginDetailTestData = { + altAuthTitle: 'E2e Alt OpenZaak Auth', + altAuthOptionLabel: 'E2e Alt OpenZaak Auth - OpenZaak', + seededAuthOptionLabel: 'OpenZaak Authentication - OpenZaak', + besluitenTitle: 'E2e Besluiten Detail Plugin', + rsin: '328674989', + updatedRsin: '051845623', + url: 'http://localhost:8001/besluiten/api/v1/', + updatedUrl: 'http://localhost:8001/besluiten/api/v2/', + clientId: 'valtimo_client', + clientSecret: '129870443512908776534', +} as const; + +/** Titles created by the detail tests, cleaned up via the API in afterAll. */ +export const pluginDetailTitles = [ + pluginDetailTestData.besluitenTitle, + pluginDetailTestData.altAuthTitle, +]; + export const pluginTestConfiguration = { 'Besluiten API': { fieldMap: [ diff --git a/e2e/tests/plugins/plugin.spec.ts b/e2e/tests/plugins/plugin.spec.ts index efe0992293..5ce5b3899f 100644 --- a/e2e/tests/plugins/plugin.spec.ts +++ b/e2e/tests/plugins/plugin.spec.ts @@ -16,8 +16,16 @@ import {test, expect} from '@playwright/test'; import {PluginPage} from './page'; -import {pluginTestConfiguration, pluginTypes} from './plugin-config'; -import {BESLUITEN_API_CONFIGURATION_TEST_IDS} from '../../constants'; +import { + pluginDetailTestData, + pluginDetailTitles, + pluginTestConfiguration, + pluginTypes, +} from './plugin-config'; +import { + BESLUITEN_API_CONFIGURATION_TEST_IDS, + OPEN_ZAAK_CONFIGURATION_TEST_IDS, +} from '../../constants'; test.use({storageState: undefined}); @@ -229,3 +237,265 @@ test.describe('9.9–9.27 — Plugin management', () => { // }); }); }); + +// The configuration ID, authentication dropdown and edit-form fields are all part of one +// configuration lifecycle: create without an ID → inspect → edit each field. Serial mode keeps +// them in order and shares a single browser context. +test.describe.configure({mode: 'serial'}); + +test.describe('9.12–9.25 — Plugin configuration details', () => { + let context; + let page; + let pluginPage: PluginPage; + + /** UUID assigned by the backend to the configuration created in 9.12. */ + let generatedConfigurationId: string; + + test.beforeAll(async ({browser, baseURL}) => { + context = await browser.newContext({baseURL}); + page = await context.newPage(); + + pluginPage = new PluginPage(page, context.request); + + await page.goto('/'); + await pluginPage.goToPluginManagement(); + + // Remove leftovers from an interrupted previous run before creating anything. + await pluginPage.deleteConfigurationsByTitleViaApi(pluginDetailTitles); + await pluginPage.goToPluginManagement(); + + // A second authentication configuration, so the authentication dropdown has a real choice + // to offer (9.17) and something to switch to (9.25). + await pluginPage.createOpenZaakAuthConfiguration( + OPEN_ZAAK_CONFIGURATION_TEST_IDS.configurationTitle, + OPEN_ZAAK_CONFIGURATION_TEST_IDS.clientId, + OPEN_ZAAK_CONFIGURATION_TEST_IDS.clientSecret + ); + }); + + test.afterAll(async () => { + // Delete the Besluiten configuration before the authentication configuration it references. + await pluginPage.deleteConfigurationsByTitleViaApi(pluginDetailTitles); + + if (context) await context.close(); + }); + + // ─── 9.12 Auto-generate configuration ID ────────────────────────── + + test.describe('9.12 — Auto-generate configuration ID', () => { + test('Configuration ID is optional and shows a UUID placeholder', async () => { + await pluginPage.openWizard(); + await pluginPage.selectPluginTypeByTitle('Besluiten API'); + + // Assert — the field starts empty; the placeholder shows the expected UUID shape + await expect(pluginPage.configurationIdInput).toHaveValue(''); + await expect(pluginPage.configurationIdInput).toHaveAttribute( + 'placeholder', + '00000000-0000-0000-0000-000000000000' + ); + }); + + test('Saving without a configuration ID generates a UUID', async () => { + // Act — fill every required field but leave the configuration ID blank + await pluginPage.fillModalInput( + BESLUITEN_API_CONFIGURATION_TEST_IDS.configurationTitle, + pluginDetailTestData.besluitenTitle + ); + await pluginPage.fillModalInput( + BESLUITEN_API_CONFIGURATION_TEST_IDS.rsin, + pluginDetailTestData.rsin + ); + await pluginPage.fillModalInput( + BESLUITEN_API_CONFIGURATION_TEST_IDS.url, + pluginDetailTestData.url + ); + await pluginPage.selectSelectOption( + BESLUITEN_API_CONFIGURATION_TEST_IDS.authenticationPluginConfiguration, + pluginDetailTestData.seededAuthOptionLabel + ); + await expect(pluginPage.configurationIdInput).toHaveValue(''); + await pluginPage.saveConfiguration(); + + // Assert — the configuration is created and the backend assigned it a UUID + await pluginPage.assertPluginExists(pluginDetailTestData.besluitenTitle); + generatedConfigurationId = await pluginPage.assertConfigurationIdIsGeneratedUuid( + pluginDetailTestData.besluitenTitle + ); + }); + }); + + // ─── 9.17 View authentication options ───────────────────────────── + + test.describe('9.17 — View authentication options', () => { + test('Dropdown lists every compatible authentication configuration', async () => { + await pluginPage.openWizard(); + await pluginPage.selectPluginTypeByTitle('Besluiten API'); + + // Act + const options = await pluginPage.getSelectOptionLabels( + BESLUITEN_API_CONFIGURATION_TEST_IDS.authenticationPluginConfiguration + ); + + // Assert — both the seeded and the test authentication configuration are offered, + // each labelled "<configuration title> - <plugin title>" + expect(options).toContain(pluginDetailTestData.seededAuthOptionLabel); + expect(options).toContain(pluginDetailTestData.altAuthOptionLabel); + for (const option of options) expect(option).not.toBe(''); + + await pluginPage.closeWizard(); + }); + }); + + // ─── 9.19 Cancel plugin configuration ───────────────────────────── + + test.describe('9.19 — Cancel plugin configuration', () => { + test('Cancelling the wizard closes it without saving', async () => { + const countBefore = (await pluginPage.getAllConfigurations()).length; + + // Act — get to step 2, enter data, then cancel + await pluginPage.openWizard(); + await pluginPage.selectPluginTypeByTitle('Besluiten API'); + await pluginPage.fillModalInput( + BESLUITEN_API_CONFIGURATION_TEST_IDS.configurationTitle, + 'E2e Cancelled Plugin' + ); + await pluginPage.cancelWizardButton.click(); + + // Assert — the wizard closed immediately (there is no "discard changes?" confirmation) + // and nothing was persisted + await expect(pluginPage.visibleModal).toHaveCount(0); + await pluginPage.assertConfigurationCount(countBefore); + await expect(page.getByText('E2e Cancelled Plugin')).toHaveCount(0); + }); + }); + + // ─── 9.21 View configuration ID ─────────────────────────────────── + + test.describe('9.21 — View configuration ID', () => { + test('Edit modal shows the assigned UUID', async () => { + // Act + await pluginPage.openEditModal(pluginDetailTestData.besluitenTitle); + + // Assert — the generated UUID from 9.12 is prefilled + await pluginPage.assertConfigurationIdDisplayed(generatedConfigurationId); + + await pluginPage.closeEditModal(); + }); + }); + + // ─── 9.23 Edit RSIN ─────────────────────────────────────────────── + + test.describe('9.23 — Edit RSIN', () => { + test('RSIN change is persisted', async () => { + // Act + await pluginPage.openEditModal(pluginDetailTestData.besluitenTitle); + await pluginPage.fillModalInput( + BESLUITEN_API_CONFIGURATION_TEST_IDS.rsin, + pluginDetailTestData.updatedRsin + ); + await pluginPage.saveEditModal(); + + // Assert + await pluginPage.assertConfigurationProperty( + pluginDetailTestData.besluitenTitle, + 'rsin', + pluginDetailTestData.updatedRsin + ); + }); + }); + + // ─── 9.24 Edit API URL ──────────────────────────────────────────── + + test.describe('9.24 — Edit API URL', () => { + test('API URL change is persisted', async () => { + // Act + await pluginPage.openEditModal(pluginDetailTestData.besluitenTitle); + await pluginPage.fillModalInput( + BESLUITEN_API_CONFIGURATION_TEST_IDS.url, + pluginDetailTestData.updatedUrl + ); + await pluginPage.saveEditModal(); + + // Assert + await pluginPage.assertConfigurationProperty( + pluginDetailTestData.besluitenTitle, + 'url', + pluginDetailTestData.updatedUrl + ); + }); + }); + + // ─── 9.25 Change authentication plugin ──────────────────────────── + + test.describe('9.25 — Change authentication plugin', () => { + test('Authentication configuration can be switched', async () => { + const altAuthConfiguration = await pluginPage.getConfigurationByTitle( + pluginDetailTestData.altAuthTitle + ); + expect(altAuthConfiguration).toBeTruthy(); + + // Act — switch from the seeded authentication configuration to the test one + await pluginPage.openEditModal(pluginDetailTestData.besluitenTitle); + await pluginPage.selectSelectOption( + BESLUITEN_API_CONFIGURATION_TEST_IDS.authenticationPluginConfiguration, + pluginDetailTestData.altAuthOptionLabel + ); + await pluginPage.saveEditModal(); + + // Assert — the stored property now points at the other configuration's UUID + await pluginPage.assertConfigurationProperty( + pluginDetailTestData.besluitenTitle, + 'authenticationPluginConfiguration', + altAuthConfiguration!.id + ); + }); + }); + + // ─── Failure scenarios ──────────────────────────────────────────── + + test.describe('Failure scenarios', () => { + /** + * The backend rejects an invalid RSIN on update with a 500 and the modal stays open. + * + * Two defects are deliberately not asserted here, so that fixing either does not fail this + * test: + * - no error toast is shown (the create path does show one — see `expectInvalidRSINError`); + * - the invalid value is persisted anyway, i.e. the failed update is not rolled back. + * The test therefore restores a valid RSIN afterwards instead of asserting the old value + * survived. + */ + test('Cannot save an edit with an invalid RSIN', async () => { + await pluginPage.openEditModal(pluginDetailTestData.besluitenTitle); + + // Act — an RSIN that fails the backend's 11-proof validation + await pluginPage.fillModalInput(BESLUITEN_API_CONFIGURATION_TEST_IDS.rsin, '1'); + + // Assert — the update is rejected and the modal does not close + await pluginPage.expectEditSaveError(); + + // Restore a valid RSIN so the remaining tests start from a consistent configuration + await pluginPage.fillModalInput( + BESLUITEN_API_CONFIGURATION_TEST_IDS.rsin, + pluginDetailTestData.updatedRsin + ); + await pluginPage.saveEditModal(); + await pluginPage.assertConfigurationProperty( + pluginDetailTestData.besluitenTitle, + 'rsin', + pluginDetailTestData.updatedRsin + ); + }); + + test('Cannot save an edit without a configuration name', async () => { + await pluginPage.openEditModal(pluginDetailTestData.besluitenTitle); + + // Act + await pluginPage.fillModalInput(BESLUITEN_API_CONFIGURATION_TEST_IDS.configurationTitle, ''); + + // Assert — the save button stays disabled while a required field is empty + await expect(pluginPage.editModalSaveButton).toBeDisabled(); + + await pluginPage.closeEditModal(); + }); + }); +}); diff --git a/e2e/tests/process-management/page.ts b/e2e/tests/process-management/page.ts new file mode 100644 index 0000000000..9e37ecdf57 --- /dev/null +++ b/e2e/tests/process-management/page.ts @@ -0,0 +1,486 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + type APIRequestContext, + type Locator, + type Page, + type Response, + expect, +} from '@playwright/test'; +import path from 'path'; +import { + CONFIRMATION_MODAL_TEST_IDS, + PROCESS_MANAGEMENT_BUILDER_TEST_IDS, + PROCESS_MANAGEMENT_LIST_TEST_IDS, + PROCESS_MANAGEMENT_UPLOAD_TEST_IDS, +} from '../../constants'; +import {CarbonList} from '../../shared/carbon-list/carbon-list.utils'; +import {CarbonToggle} from '../../shared/carbon-toggle/carbon-toggle.utils'; +import {OverflowMenu} from '../../shared/overflow-menu/overflow-menu.utils'; +import {apiDelete, apiGet} from '../../utils/api.utils'; +import {PROCESS_MANAGEMENT_API, PROCESS_MANAGEMENT_TEXTS} from './process-management-config'; + +function escapeForRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +/** One deployed version of a process definition, as returned by the management API. */ +export interface ProcessDefinitionResult { + bpmn20Xml: string; + draft?: boolean; + processDefinition: { + id: string; + key: string; + name: string; + version: number; + readOnly: boolean; + }; +} + +export class ProcessManagementPage { + readonly carbonList: CarbonList; + readonly overflowMenu: OverflowMenu; + private readonly listScope: Locator; + + constructor( + private readonly page: Page, + private readonly request: APIRequestContext + ) { + this.listScope = page.locator('valtimo-process-management-list'); + this.carbonList = new CarbonList(page, this.listScope); + this.overflowMenu = new OverflowMenu(page, PROCESS_MANAGEMENT_BUILDER_TEST_IDS.moreButton); + } + + // ─── List locators ──────────────────────────────────────────────── + + get uploadButton() { + return this.page.getByTestId(PROCESS_MANAGEMENT_LIST_TEST_IDS.uploadButton); + } + + get createProcessButton() { + return this.page.getByTestId(PROCESS_MANAGEMENT_LIST_TEST_IDS.createProcessButton); + } + + // ─── Upload modal locators ──────────────────────────────────────── + + get uploadFileUploader() { + return this.page.getByTestId(PROCESS_MANAGEMENT_UPLOAD_TEST_IDS.fileUploader); + } + + get uploadSubmitButton() { + return this.page.getByTestId(PROCESS_MANAGEMENT_UPLOAD_TEST_IDS.submitButton); + } + + get uploadCancelButton() { + return this.page.getByTestId(PROCESS_MANAGEMENT_UPLOAD_TEST_IDS.cancelButton); + } + + // ─── Confirmation modals ────────────────────────────────────────── + + /** + * Scope a confirmation modal by its heading. + * + * Two `valtimo-confirmation-modal`s are reachable from the process list — the + * row delete modal and the upload "replace existing process" modal — and both + * render the same `confirmationModal*` test ids. The replace modal is also + * wrapped in `valtimo-render-in-body`, which moves it to `document.body`, so it + * cannot be scoped by its owning component either. The heading is the only + * thing that distinguishes them. + * + * The returned locator is the inner `.cds--modal`, not the component host: the + * host wraps a `position: fixed` modal, so it collapses to a zero-size bounding + * box and Playwright always reports it as hidden. Carbon toggles open/closed + * through `visibility` on `.cds--modal`, which `toBeVisible()` reads correctly. + */ + private confirmationModal(title: string): Locator { + return this.page + .locator('valtimo-confirmation-modal') + .filter({has: this.page.getByRole('heading', {name: title})}) + .locator('.cds--modal'); + } + + get deleteModal() { + return this.confirmationModal(PROCESS_MANAGEMENT_TEXTS.deleteModalTitle); + } + + get deleteConfirmButton() { + return this.deleteModal.getByTestId(CONFIRMATION_MODAL_TEST_IDS.confirmButton); + } + + get replaceModal() { + return this.confirmationModal(PROCESS_MANAGEMENT_TEXTS.replaceModalTitle); + } + + get replaceCancelButton() { + return this.replaceModal.getByTestId(CONFIRMATION_MODAL_TEST_IDS.closeButton); + } + + // ─── Builder locators ───────────────────────────────────────────── + + get builderContainer() { + return this.page.locator('.bpmn__container'); + } + + get deployButton() { + return this.page.getByTestId(PROCESS_MANAGEMENT_BUILDER_TEST_IDS.deployButton); + } + + get validateButton() { + return this.page.getByTestId(PROCESS_MANAGEMENT_BUILDER_TEST_IDS.validateButton); + } + + get validationErrors() { + return this.page.getByTestId(PROCESS_MANAGEMENT_BUILDER_TEST_IDS.validationErrors); + } + + get readOnlyTag() { + return this.page.getByTestId(PROCESS_MANAGEMENT_BUILDER_TEST_IDS.readOnlyTag); + } + + get systemProcessTag() { + return this.page.getByTestId(PROCESS_MANAGEMENT_BUILDER_TEST_IDS.systemProcessTag); + } + + /** The Export entry of the builder header's overflow menu (only while open). */ + get exportOption() { + return this.overflowMenu.option(PROCESS_MANAGEMENT_BUILDER_TEST_IDS.exportButton); + } + + get draftToggle() { + return new CarbonToggle( + this.page.getByTestId(PROCESS_MANAGEMENT_BUILDER_TEST_IDS.draftToggle) + ); + } + + /** Case-only toggles: they must not render in the standalone builder. */ + get startsCaseToggle() { + return this.page.getByTestId(PROCESS_MANAGEMENT_BUILDER_TEST_IDS.startsCaseToggle); + } + + get startableByUserToggle() { + return this.page.getByTestId(PROCESS_MANAGEMENT_BUILDER_TEST_IDS.startableByUserToggle); + } + + // ─── Version dropdown ───────────────────────────────────────────── + + get versionDropdown() { + return this.page.getByTestId(PROCESS_MANAGEMENT_BUILDER_TEST_IDS.versionDropdown); + } + + /** + * Carbon renders the disabled state on the dropdown's inner field button rather + * than on the `cds-dropdown` host, so enabled/disabled has to be asserted here. + */ + get versionDropdownButton() { + return this.versionDropdown.getByRole('button').first(); + } + + /** + * A selectable version in the open dropdown. + * + * The items come from an `[items]` binding (plain `ListItem`s), not a template, + * so they cannot carry test ids — they are matched on their rendered label. + * Scope to the listbox: the selected value renders the same label outside it. + */ + versionOption(version: number): Locator { + return this.versionDropdown + .getByRole('listbox') + .getByText(`${PROCESS_MANAGEMENT_TEXTS.versionPrefix}${version}`, {exact: true}); + } + + // ─── BPMN modeler (bpmn-js library DOM, no test ids available) ───── + + get bpmnPalette() { + return this.page.locator('.djs-palette:visible'); + } + + /** + * Both the editable modeler and the read-only viewer render + * `.bpmn__modeler-canvas`; the inactive one sits inside a `display: none` + * parent, so `:visible` resolves to whichever is currently in use. + */ + get activeBpmnCanvas() { + return this.page.locator('.bpmn__modeler-canvas:visible'); + } + + elementShape(elementId: string) { + return this.activeBpmnCanvas.locator(`g[data-element-id="${elementId}"]`); + } + + get appendTaskContextPadAction() { + return this.page.locator('.djs-context-pad [data-action="append.append-task"]'); + } + + get taskShapes() { + return this.activeBpmnCanvas.locator('g.djs-shape[data-element-id^="Activity_"]'); + } + + // ─── Navigation ─────────────────────────────────────────────────── + + /** + * Navigate straight to the route instead of driving the Admin menu: loading the + * dashboard first and then opening a heavy admin route crashes the Chromium + * renderer. + */ + async goToProcessManagement() { + await this.page.goto('/processes'); + await this.page.waitForURL(/\/processes$/); + await this.carbonList.waitForLoaded(); + } + + async goToProcessBuilder(processKey: string) { + await this.page.goto(`/processes/${processKey}`); + await this.page.waitForURL(new RegExp(`/processes/${escapeForRegExp(processKey)}$`)); + await expect(this.builderContainer).toBeVisible(); + await expect(this.bpmnPalette).toBeVisible(); + } + + async goToCreateProcess() { + await this.goToProcessManagement(); + await this.createProcessButton.click(); + await this.page.waitForURL(/\/processes\/create$/); + await expect(this.builderContainer).toBeVisible(); + await expect(this.bpmnPalette).toBeVisible(); + } + + // ─── List assertions ────────────────────────────────────────────── + + async assertListLoaded() { + await this.carbonList.waitForLoaded(); + await expect(this.carbonList.table).toBeVisible(); + await expect(this.uploadButton).toBeVisible(); + await expect(this.createProcessButton).toBeVisible(); + } + + /** + * `actionItems` on the carbon list appends an unlabelled `<th>` for the row + * overflow menu, so the empty headers are dropped before comparing. + */ + async assertColumnHeaders(expectedHeaders: readonly string[]) { + const headers = await this.carbonList.table.locator('thead th').allInnerTexts(); + expect(headers.map(header => header.trim()).filter(Boolean)).toEqual([...expectedHeaders]); + } + + /** + * Locate a row by its Key cell. Names are not unique — and a process created + * from the empty diagram has no name at all — so rows are identified by key. + */ + rowByKey(key: string) { + return this.carbonList.row(new RegExp(`^${escapeForRegExp(key)}$`)); + } + + async assertProcessVisible(key: string) { + await this.rowByKey(key).assertVisible(); + } + + async assertProcessNotVisible(key: string) { + await this.rowByKey(key).assertNotVisible(); + } + + async assertProcessMetadata(definition: {name: string; key: string}) { + const row = this.rowByKey(definition.key); + await row.assertVisible(); + await expect(row.cellByIndex(0)).toHaveText(definition.name); + await expect(row.cellByIndex(1)).toHaveText(definition.key); + } + + async assertProcessHasDraftTag(key: string) { + await expect(this.rowByKey(key).tags).toContainText(PROCESS_MANAGEMENT_TEXTS.draftTag); + } + + // ─── Upload modal ───────────────────────────────────────────────── + + /** + * Reload the list before opening the modal: a Carbon modal resets its reactive + * form 240 ms after closing, so re-opening within the same page state can let a + * pending reset wipe the file that was just selected. + */ + async openUploadModal() { + await this.goToProcessManagement(); + await this.uploadButton.click(); + await expect(this.uploadSubmitButton).toBeVisible(); + } + + async closeUploadModal() { + await this.uploadCancelButton.click(); + await expect(this.uploadSubmitButton).not.toBeVisible(); + } + + async selectBpmnFile(fileName: string) { + const filePath = path.resolve(process.cwd(), 'assets', fileName); + await this.uploadFileUploader.locator('input[type="file"]').setInputFiles(filePath); + } + + /** + * Submit the upload and return the deploy response. + * + * Matched on the exact collection path so the `POST .../process-definition/validate` + * call made elsewhere in the builder can never be picked up instead. + */ + async submitUpload(): Promise<Response> { + await expect(this.uploadSubmitButton).toBeEnabled(); + const [response] = await Promise.all([ + this.page.waitForResponse( + res => + new URL(res.url()).pathname === PROCESS_MANAGEMENT_API.processDefinition && + res.request().method() === 'POST' + ), + this.uploadSubmitButton.click(), + ]); + return response; + } + + /** Full happy-path upload: open modal → select file → submit. */ + async uploadProcess(fileName: string): Promise<Response> { + await this.openUploadModal(); + await this.selectBpmnFile(fileName); + return this.submitUpload(); + } + + // ─── Builder actions ────────────────────────────────────────────── + + async appendTaskToStartEvent(startEventId: string) { + const startEvent = this.elementShape(startEventId); + await expect(startEvent).toBeVisible(); + await startEvent.click(); + + await expect(this.appendTaskContextPadAction).toBeVisible(); + await this.appendTaskContextPadAction.click(); + await this.page.keyboard.press('Escape'); + } + + /** Save an existing process definition; returns the PUT response. */ + async saveProcess(): Promise<Response> { + await expect(this.deployButton).toBeEnabled(); + const [response] = await Promise.all([ + this.page.waitForResponse( + res => + new URL(res.url()).pathname === PROCESS_MANAGEMENT_API.processDefinition && + res.request().method() === 'PUT' + ), + this.deployButton.click(), + ]); + return response; + } + + /** Deploy a brand-new process definition; returns the POST response. */ + async deployNewProcess(): Promise<Response> { + await expect(this.deployButton).toBeEnabled(); + const [response] = await Promise.all([ + this.page.waitForResponse( + res => + new URL(res.url()).pathname === PROCESS_MANAGEMENT_API.processDefinition && + res.request().method() === 'POST' + ), + this.deployButton.click(), + ]); + return response; + } + + /** + * Click Save and wait for the validation round-trip that precedes a non-draft + * deploy, returning the validation response. Used to assert that an invalid + * diagram is stopped before it is ever deployed. + */ + async saveExpectingValidation(): Promise<Response> { + await expect(this.deployButton).toBeEnabled(); + const [response] = await Promise.all([ + this.page.waitForResponse( + res => + new URL(res.url()).pathname === PROCESS_MANAGEMENT_API.validate && + res.request().method() === 'POST' + ), + this.deployButton.click(), + ]); + return response; + } + + async validateProcess(): Promise<Response> { + await expect(this.validateButton).toBeEnabled(); + const [response] = await Promise.all([ + this.page.waitForResponse( + res => + new URL(res.url()).pathname === PROCESS_MANAGEMENT_API.validate && + res.request().method() === 'POST' + ), + this.validateButton.click(), + ]); + return response; + } + + async openVersionDropdown() { + await expect(this.versionDropdownButton).toBeEnabled(); + await this.versionDropdownButton.click(); + await expect(this.versionDropdown.getByRole('listbox')).toBeVisible(); + } + + async selectVersion(version: number) { + await this.openVersionDropdown(); + const option = this.versionOption(version); + await expect(option).toBeVisible(); + await option.click(); + await expect(this.versionDropdownButton).toContainText( + `${PROCESS_MANAGEMENT_TEXTS.versionPrefix}${version}` + ); + } + + // ─── Delete ─────────────────────────────────────────────────────── + + async deleteProcess(key: string): Promise<Response> { + await this.rowByKey(key).clickAction('Delete'); + await expect(this.deleteConfirmButton).toBeVisible(); + + const [response] = await Promise.all([ + this.page.waitForResponse( + res => + new URL(res.url()).pathname === PROCESS_MANAGEMENT_API.byKey(key) && + res.request().method() === 'DELETE' + ), + this.deleteConfirmButton.click(), + ]); + return response; + } + + // ─── API helpers ────────────────────────────────────────────────── + + /** Every unlinked process definition, one entry per deployed version. */ + async getProcessesViaApi(): Promise<ProcessDefinitionResult[]> { + return apiGet<ProcessDefinitionResult[]>(PROCESS_MANAGEMENT_API.processDefinition); + } + + /** All deployed versions of a single process definition key. */ + async getProcessVersionsViaApi(key: string): Promise<ProcessDefinitionResult[]> { + try { + return await apiGet<ProcessDefinitionResult[]>(PROCESS_MANAGEMENT_API.byKey(key)); + } catch { + // The key has no deployed versions. + return []; + } + } + + /** + * Best-effort cleanup. Deleting an unlinked process definition removes every + * version of the key at once, so one call is enough per key. + */ + async deleteProcessViaApi(key: string) { + try { + await apiDelete(PROCESS_MANAGEMENT_API.byKey(key)); + } catch { + // Already gone, or never deployed. + } + } +} diff --git a/e2e/tests/process-management/process-management-config.ts b/e2e/tests/process-management/process-management-config.ts new file mode 100644 index 0000000000..beb2be18f9 --- /dev/null +++ b/e2e/tests/process-management/process-management-config.ts @@ -0,0 +1,65 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * The BPMN file uploaded by the deploy tests. It is the same asset the + * case-scoped processes test uses, but here it is deployed through the + * standalone Process management page, so it becomes an *unlinked* process + * definition (not tied to any case definition). + */ +export const UPLOADED_PROCESS = { + fileName: 'e2e-test-process.bpmn', + key: 'e2e-test-process', + name: 'E2E Test Process', + /** `<bpmn:startEvent id="StartEvent_1">` in the asset. */ + startEventId: 'StartEvent_1', +} as const; + +/** + * A process created from scratch via "Create process". + * + * The builder seeds a brand-new diagram with `EMPTY_BPMN` + * (`process-management/src/lib/constants/bpmn.constants.ts`), which declares a + * fixed `<bpmn:process id="Process_1">` and no name. The key of a process + * created without renaming it in the properties panel is therefore always + * `Process_1`, and its Name cell renders empty — so rows for it have to be + * located by key, never by name. + */ +export const CREATED_PROCESS = { + key: 'Process_1', + startEventId: 'StartEvent_1', +} as const; + +export const PROCESS_MANAGEMENT_TEXTS = { + /** Standalone context omits the two case-link columns, so only these remain. */ + listColumns: ['Name', 'Key', 'Status'], + draftTag: 'Draft', + deleteModalTitle: 'Delete process', + replaceModalTitle: 'Replace existing process?', + uploadSuccess: 'Deployment successful', + deploySuccess: 'Process deployed successfully', + deleteSuccess: 'Process deleted successfully', + validationSuccess: 'Process definition is valid', + /** Prefix of the version dropdown items, from `processManagement.version`. */ + versionPrefix: 'Version: ', +} as const; + +export const PROCESS_MANAGEMENT_API = { + /** Collection endpoint: GET lists, POST deploys new, PUT saves changes. */ + processDefinition: '/api/management/v1/process-definition', + byKey: (key: string) => `/api/management/v1/process-definition/key/${key}`, + validate: '/api/management/v1/process-definition/validate', +} as const; diff --git a/e2e/tests/process-management/process-management.spec.ts b/e2e/tests/process-management/process-management.spec.ts new file mode 100644 index 0000000000..ce6628156d --- /dev/null +++ b/e2e/tests/process-management/process-management.spec.ts @@ -0,0 +1,341 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {expect, test} from '@playwright/test'; +import {expectNotificationMessage} from '../../utils/ui.utils'; +import {ProcessManagementPage} from './page'; +import { + CREATED_PROCESS, + PROCESS_MANAGEMENT_TEXTS, + UPLOADED_PROCESS, +} from './process-management-config'; + +test.use({storageState: undefined}); + +/** + * Feature 7 — Process Management: the standalone `/processes` admin page. + * + * This is the *independent* process context: process definitions that are not + * linked to a case definition. The case-scoped equivalent (the Processes tab of a + * case) is covered by `case-details-management-processes.spec.ts`; the two share + * the same list and builder components but differ in routing, available columns, + * header actions (only the standalone context has version management) and API + * endpoints. + * + * The suite builds up its own state in order: upload seeds version 1 of + * `e2e-test-process`, the edit test saves version 2, and the version tests then + * exercise the dropdown against those two versions. + */ +test.describe('Process management — standalone processes', () => { + let context; + let page; + let request; + let processPage: ProcessManagementPage; + + /** Every process key this suite may create, deleted in `afterAll`. */ + const createdProcessKeys: string[] = [UPLOADED_PROCESS.key, CREATED_PROCESS.key]; + + test.beforeAll(async ({browser, baseURL}) => { + context = await browser.newContext({baseURL}); + page = await context.newPage(); + request = context.request; + + processPage = new ProcessManagementPage(page, request); + + await page.goto('/'); + + // Remove leftovers from an interrupted previous run: a stale + // `e2e-test-process` would turn the first upload into a replace flow and + // shift every version number the version tests assert on. + for (const key of createdProcessKeys) { + await processPage.deleteProcessViaApi(key); + } + + await processPage.goToProcessManagement(); + }); + + test.afterAll(async () => { + for (const key of createdProcessKeys) { + await processPage.deleteProcessViaApi(key); + } + + await context.close(); + }); + + test.describe('7.1 — View process overview', () => { + test('7.1 — Process list is visible with its toolbar actions', async () => { + await processPage.goToProcessManagement(); + await processPage.assertListLoaded(); + }); + + test('7.1 — Process list shows the Name, Key and Status columns', async () => { + await processPage.goToProcessManagement(); + await processPage.assertColumnHeaders(PROCESS_MANAGEMENT_TEXTS.listColumns); + }); + + test('7.1 — Process list shows every unlinked process returned by the API', async () => { + const processes = await processPage.getProcessesViaApi(); + + await processPage.goToProcessManagement(); + + await expect(processPage.carbonList.rows).toHaveCount(processes.length); + + for (const process of processes) { + await processPage.assertProcessVisible(process.processDefinition.key); + } + }); + }); + + test.describe('7.4 — Deploy a process definition', () => { + test('7.4 — Deploys a BPMN file and lists the new process', async () => { + const response = await processPage.uploadProcess(UPLOADED_PROCESS.fileName); + // The deploy endpoints answer 204 No Content on success, so assert on + // success rather than on a specific status code. + expect(response.ok()).toBe(true); + + await expectNotificationMessage(page, PROCESS_MANAGEMENT_TEXTS.uploadSuccess); + + // Deployed as a single version, through the standalone (unlinked) endpoint. + const versions = await processPage.getProcessVersionsViaApi(UPLOADED_PROCESS.key); + expect(versions).toHaveLength(1); + expect(versions[0].processDefinition).toMatchObject({ + key: UPLOADED_PROCESS.key, + name: UPLOADED_PROCESS.name, + version: 1, + }); + + // And listed in the overview with its name and key. + await processPage.goToProcessManagement(); + await processPage.assertProcessMetadata({ + name: UPLOADED_PROCESS.name, + key: UPLOADED_PROCESS.key, + }); + }); + + test.describe('Failure scenarios', () => { + test('Upload cannot be submitted without a file', async () => { + await processPage.openUploadModal(); + + await expect(processPage.uploadSubmitButton).toBeDisabled(); + + // Picking a file is the only thing that unlocks it. + await processPage.selectBpmnFile(UPLOADED_PROCESS.fileName); + await expect(processPage.uploadSubmitButton).toBeEnabled(); + + await processPage.closeUploadModal(); + }); + + test('Re-uploading the same key offers to replace instead of deploying a duplicate', async () => { + const versionsBefore = await processPage.getProcessVersionsViaApi(UPLOADED_PROCESS.key); + expect(versionsBefore.length).toBeGreaterThan(0); + + const response = await processPage.uploadProcess(UPLOADED_PROCESS.fileName); + + // The backend rejects the duplicate key and the UI turns the conflict into + // an explicit replace confirmation rather than a failure toast. + expect(response.status()).toBe(409); + await expect(processPage.replaceModal).toBeVisible(); + await expect(processPage.replaceModal).toContainText(UPLOADED_PROCESS.key); + + // Declining leaves the deployment untouched. + await processPage.replaceCancelButton.click(); + await expect(processPage.replaceModal).not.toBeVisible(); + await processPage.closeUploadModal(); + + const versionsAfter = await processPage.getProcessVersionsViaApi(UPLOADED_PROCESS.key); + expect(versionsAfter).toHaveLength(versionsBefore.length); + }); + }); + }); + + test.describe('7.3, 7.4, 7.5 — Edit, save and version an existing process', () => { + test('7.5 — Version dropdown is disabled while the process has a single version', async () => { + await processPage.goToProcessBuilder(UPLOADED_PROCESS.key); + + // Version management is exclusive to the standalone builder, but a single + // deployed version leaves nothing to switch to. + await expect(processPage.versionDropdownButton).toBeVisible(); + await expect(processPage.versionDropdownButton).toBeDisabled(); + await expect(processPage.versionDropdownButton).toContainText( + `${PROCESS_MANAGEMENT_TEXTS.versionPrefix}1` + ); + + // The case-link toggles belong to the case context only. + await expect(processPage.startsCaseToggle).not.toBeVisible(); + await expect(processPage.startableByUserToggle).not.toBeVisible(); + + // An editable, non-system process carries neither status tag. + await expect(processPage.readOnlyTag).not.toBeVisible(); + await expect(processPage.systemProcessTag).not.toBeVisible(); + + // Export is offered from the header's overflow menu. + await processPage.overflowMenu.open(); + await expect(processPage.exportOption).toBeVisible(); + await processPage.overflowMenu.close(); + + // Nothing has changed yet, so there is nothing to save. + await expect(processPage.deployButton).toBeDisabled(); + }); + + test('7.3, 7.4 — Edits the BPMN and saves it as a new version', async () => { + await processPage.goToProcessBuilder(UPLOADED_PROCESS.key); + await expect(processPage.deployButton).toBeDisabled(); + + // 7.3 — edit the diagram through the modeler. + const initialTaskCount = await processPage.taskShapes.count(); + await processPage.appendTaskToStartEvent(UPLOADED_PROCESS.startEventId); + await expect(processPage.taskShapes).toHaveCount(initialTaskCount + 1); + + // A pending change is what enables Save. + await expect(processPage.deployButton).toBeEnabled(); + + // The appended task has no outgoing flow and no process link, so a non-draft + // deploy would be stopped by backend validation. Saving as a draft skips + // validation, which is what this test is about. + await processPage.draftToggle.enable(); + + // 7.4 — save, which deploys a new version of the same key. + const response = await processPage.saveProcess(); + expect(response.ok()).toBe(true); + await expectNotificationMessage(page, PROCESS_MANAGEMENT_TEXTS.deploySuccess); + + const versions = await processPage.getProcessVersionsViaApi(UPLOADED_PROCESS.key); + expect(versions).toHaveLength(2); + expect(versions.map(version => version.processDefinition.version).sort()).toEqual([1, 2]); + + // Saving as a draft marks the process in the overview. + await processPage.goToProcessManagement(); + await processPage.assertProcessHasDraftTag(UPLOADED_PROCESS.key); + }); + + test('7.5 — Version dropdown lists every version and switches between them', async () => { + await processPage.goToProcessBuilder(UPLOADED_PROCESS.key); + + // The builder opens on the latest version. + await expect(processPage.versionDropdownButton).toBeEnabled(); + await expect(processPage.versionDropdownButton).toContainText( + `${PROCESS_MANAGEMENT_TEXTS.versionPrefix}2` + ); + + await processPage.openVersionDropdown(); + await expect(processPage.versionDropdown.getByRole('listbox').getByRole('option')).toHaveCount( + 2 + ); + await expect(processPage.versionOption(2)).toBeVisible(); + await expect(processPage.versionOption(1)).toBeVisible(); + + // Switching version reloads that version's diagram into the modeler. + await processPage.versionOption(1).click(); + await expect(processPage.versionDropdownButton).toContainText( + `${PROCESS_MANAGEMENT_TEXTS.versionPrefix}1` + ); + await expect(processPage.elementShape(UPLOADED_PROCESS.startEventId)).toBeVisible(); + + // Switching version discards pending changes, so Save is disabled again. + await expect(processPage.deployButton).toBeDisabled(); + }); + }); + + test.describe('7.2 — Create a new process', () => { + test('7.2 — Create process opens an empty diagram without version management', async () => { + await processPage.goToCreateProcess(); + + // A process that does not exist yet has no versions to manage. + await expect(processPage.versionDropdown).not.toBeVisible(); + + // The seeded diagram holds a single start event and nothing else. + await expect(processPage.elementShape(CREATED_PROCESS.startEventId)).toBeVisible(); + await expect(processPage.taskShapes).toHaveCount(0); + + // Nothing drawn yet, so nothing to deploy. + await expect(processPage.deployButton).toBeDisabled(); + }); + + test.describe('Failure scenarios', () => { + test('A new process that fails validation is not deployed', async () => { + const versionsBefore = await processPage.getProcessVersionsViaApi(CREATED_PROCESS.key); + + await processPage.goToCreateProcess(); + await processPage.appendTaskToStartEvent(CREATED_PROCESS.startEventId); + + // Deploying without the draft toggle validates first. The appended task has + // no outgoing sequence flow and no process link, so validation fails and + // the deploy never happens. + await processPage.draftToggle.disable(); + + const validation = await processPage.saveExpectingValidation(); + const result = await validation.json(); + expect(result.isValid).toBe(false); + expect(result.errors.length).toBeGreaterThan(0); + + // The failures are surfaced on the canvas and the user stays in the builder. + await expect(processPage.validationErrors).toBeVisible(); + expect(page.url()).toContain('/processes/create'); + + // And nothing was deployed. + const versionsAfter = await processPage.getProcessVersionsViaApi(CREATED_PROCESS.key); + expect(versionsAfter).toHaveLength(versionsBefore.length); + }); + }); + + test('7.2, 7.4 — Creates and deploys a new process definition', async () => { + await processPage.goToCreateProcess(); + await processPage.appendTaskToStartEvent(CREATED_PROCESS.startEventId); + await expect(processPage.deployButton).toBeEnabled(); + + // Deploy as a draft so the incomplete diagram is accepted. + await processPage.draftToggle.enable(); + + const response = await processPage.deployNewProcess(); + expect(response.ok()).toBe(true); + + // Creating navigates back to the overview. + await page.waitForURL(/\/processes$/); + await expectNotificationMessage(page, PROCESS_MANAGEMENT_TEXTS.deploySuccess); + await processPage.carbonList.waitForLoaded(); + + // The new definition exists as version 1 and is listed as a draft. Its key + // comes from the seeded empty diagram, and it has no name — hence the + // key-based row lookup. + const versions = await processPage.getProcessVersionsViaApi(CREATED_PROCESS.key); + expect(versions).toHaveLength(1); + expect(versions[0].processDefinition).toMatchObject({ + key: CREATED_PROCESS.key, + version: 1, + }); + + await processPage.assertProcessVisible(CREATED_PROCESS.key); + await processPage.assertProcessHasDraftTag(CREATED_PROCESS.key); + }); + }); + + test.describe('Delete a process definition', () => { + test('Deletes a process and removes it from the overview', async () => { + await processPage.goToProcessManagement(); + await processPage.assertProcessVisible(UPLOADED_PROCESS.key); + + const response = await processPage.deleteProcess(UPLOADED_PROCESS.key); + expect(response.ok()).toBe(true); + + await expectNotificationMessage(page, PROCESS_MANAGEMENT_TEXTS.deleteSuccess); + await processPage.assertProcessNotVisible(UPLOADED_PROCESS.key); + + // Deleting an unlinked process removes every deployed version of the key. + const versions = await processPage.getProcessVersionsViaApi(UPLOADED_PROCESS.key); + expect(versions).toHaveLength(0); + }); + }); +}); diff --git a/e2e/tests/task-list/page.ts b/e2e/tests/task-list/page.ts index 610ef5fd65..fced428c4c 100644 --- a/e2e/tests/task-list/page.ts +++ b/e2e/tests/task-list/page.ts @@ -88,7 +88,16 @@ export class TaskListPage { async claimTask() { const assignButton = this.page.getByText('Assign this task'); - await expect(assignButton.or(this.assignmentPill)).toBeVisible({timeout: 15_000}); + // On the shared nightly env — and because the "Auto assign test" process + // auto-assigns — the task can already be assigned when the detail opens. + // The assignment component always keeps a hidden "Assign this task" button + // in the DOM for layout, so `assignButton.or(pill)` trips strict mode once a + // pill is present. Wait for either state to settle, then branch on the pill. + await expect(async () => { + const settled = + (await this.assignmentPill.isVisible()) || (await assignButton.isVisible()); + expect(settled).toBe(true); + }).toPass({timeout: 15_000}); // Already assigned — nothing to claim. if (await this.assignmentPill.isVisible()) { diff --git a/e2e/tests/translation-management/page.ts b/e2e/tests/translation-management/page.ts new file mode 100644 index 0000000000..f0a3f70d29 --- /dev/null +++ b/e2e/tests/translation-management/page.ts @@ -0,0 +1,255 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {expect, Page} from '@playwright/test'; +import { + ARBITRARY_AMOUNT_VALUE_TEST_IDS, + CONFIRMATION_MODAL_TEST_IDS, + MULTI_INPUT_TEST_IDS, + TRANSLATION_MANAGEMENT_TEST_IDS, +} from '../../constants'; +import * as ApiUtils from '../../utils/api.utils'; + +/** One `{languageKey, content}` entry as returned by `/api/v1/localization`. */ +export interface Localization { + languageKey: string; + content: Record<string, unknown>; +} + +/** Column order of the translations table: key first, then one column per language. */ +export const KEY_COLUMN = 0; +export const EN_COLUMN = 1; +export const NL_COLUMN = 2; + +export class TranslationManagementPage { + constructor(private readonly page: Page) {} + + // ─── Navigation ─────────────────────────────────────────────────── + + async goToTranslationManagement() { + await this.page.goto('/translation-management'); + await this.rows.first().waitFor({state: 'visible', timeout: 60_000}); + } + + /** Re-reads the page from scratch — used to prove that a save actually persisted. */ + async reload() { + await this.page.reload(); + await this.rows.first().waitFor({state: 'visible', timeout: 60_000}); + } + + // ─── Table ──────────────────────────────────────────────────────── + // + // The table is a `valtimo-carbon-multi-input` of type `arbitraryAmount`: every row is a set of + // plain text inputs (key + one per language) plus a delete button. There is no `<table>`, so + // rows are addressed by index through their cells' test IDs. + + get rows() { + return this.page.locator('.v-multi-input__row'); + } + + get addRowButton() { + return this.page.getByTestId(MULTI_INPUT_TEST_IDS.addButton); + } + + get saveButton() { + return this.page.getByTestId(TRANSLATION_MANAGEMENT_TEST_IDS.saveButton); + } + + /** + * Column titles. The multi-input renders its `cds-label`s on the first row only, so this + * doubles as the header row. + */ + get columnTitles() { + return this.page.locator('cds-label'); + } + + cell(row: number, column: number) { + return this.page.getByTestId(ARBITRARY_AMOUNT_VALUE_TEST_IDS.inputAt(row, column)); + } + + deleteRowButton(row: number) { + return this.page.getByTestId(MULTI_INPUT_TEST_IDS.deleteButtonAt(row)); + } + + async rowCount() { + return this.rows.count(); + } + + /** + * Index of the row holding `key`. The backend stores translations in a JSON object, so row + * order follows the object's key order rather than insertion order — never assume an index. + */ + async rowIndexForKey(key: string): Promise<number> { + const total = await this.rowCount(); + for (let row = 0; row < total; row++) { + if ((await this.cell(row, KEY_COLUMN).inputValue()) === key) return row; + } + return -1; + } + + async keys(): Promise<string[]> { + const total = await this.rowCount(); + const keys: string[] = []; + for (let row = 0; row < total; row++) keys.push(await this.cell(row, KEY_COLUMN).inputValue()); + return keys; + } + + // ─── Editing ────────────────────────────────────────────────────── + + /** Appends a row and returns its index. New rows are always appended last. */ + async addRow(): Promise<number> { + const before = await this.rowCount(); + await this.addRowButton.click(); + await expect(this.rows).toHaveCount(before + 1); + return before; + } + + /** + * Fills a whole row. The multi-input only treats a row as valid once *every* cell is filled, + * so a partially filled row leaves Save disabled. + */ + async fillRow(row: number, values: string[]) { + for (let column = 0; column < values.length; column++) { + await this.cell(row, column).fill(values[column]); + } + // The Save button reacts to the value stream; give it the tick it needs to settle. + await expect(this.cell(row, KEY_COLUMN)).toHaveValue(values[KEY_COLUMN]); + } + + async deleteRow(row: number) { + const before = await this.rowCount(); + await this.deleteRowButton(row).click(); + await expect(this.rows).toHaveCount(before - 1); + } + + // ─── Confirmation modal ─────────────────────────────────────────── + // + // Saving always goes through a confirmation modal offering three outcomes: cancel, save, or + // save and reload the application. + + get modalHeading() { + return this.page.getByTestId(CONFIRMATION_MODAL_TEST_IDS.heading); + } + + get modalContent() { + return this.page.getByTestId(CONFIRMATION_MODAL_TEST_IDS.content); + } + + /** "Cancel" — dismisses without saving. */ + get modalCancelButton() { + return this.page.getByTestId(CONFIRMATION_MODAL_TEST_IDS.closeButton); + } + + /** "Save" — persists without reloading the application. */ + get modalSaveButton() { + return this.page.getByTestId(CONFIRMATION_MODAL_TEST_IDS.optionalButton); + } + + /** "Save and reload" — persists and then reloads the window. */ + get modalSaveAndReloadButton() { + return this.page.getByTestId(CONFIRMATION_MODAL_TEST_IDS.confirmButton); + } + + async openSaveModal() { + await expect(this.saveButton).toBeEnabled(); + await this.saveButton.click(); + await expect(this.modalHeading).toBeVisible(); + } + + async cancelSave() { + await this.modalCancelButton.click(); + await expect(this.modalHeading).toBeHidden(); + } + + /** Confirms with "Save" and waits for the update request to land. */ + async confirmSave() { + await Promise.all([this.waitForUpdate(), this.modalSaveButton.click()]); + await expect(this.modalHeading).toBeHidden(); + } + + /** + * Confirms with "Save and reload". The component calls `location.reload()` right after the + * request resolves, so wait for the response first and then for the page to come back. + */ + async confirmSaveAndReload() { + await Promise.all([this.waitForUpdate(), this.modalSaveAndReloadButton.click()]); + await this.rows.first().waitFor({state: 'visible', timeout: 60_000}); + } + + private waitForUpdate() { + return this.page.waitForResponse( + res => + res.url().includes('/api/management/v1/localization') && + res.request().method() === 'PUT' && + res.ok(), + {timeout: 30_000} + ); + } + + // ─── API ────────────────────────────────────────────────────────── + + async getLocalizationsViaApi(): Promise<Localization[]> { + return ApiUtils.apiGet<Localization[]>('/api/v1/localization'); + } + + async setLocalizationsViaApi(localizations: Localization[]) { + await ApiUtils.apiPut('/api/management/v1/localization', localizations); + } + + /** Translations for `languageKey`, flattened back to dotted keys as the UI shows them. */ + async translationsViaApi(languageKey: string): Promise<Record<string, string>> { + const localizations = await this.getLocalizationsViaApi(); + const content = localizations.find(l => l.languageKey === languageKey)?.content ?? {}; + return flatten(content); + } + + /** + * The stored translation for a single key, or `undefined` when it is absent. Prefer this over + * asserting on the whole map: translation keys contain dots, which `toHaveProperty` would read + * as a nested path rather than as one literal key. + */ + async translationViaApi(languageKey: string, key: string): Promise<string | undefined> { + return (await this.translationsViaApi(languageKey))[key]; + } + + /** + * Restores the localizations captured before the suite ran. There is no delete endpoint — the + * update is an upsert per language key — so restoring means writing the original content back + * over every language the suite touched. + */ + async restoreLocalizationsViaApi(original: Localization[], languageKeys: string[]) { + try { + await this.setLocalizationsViaApi( + languageKeys.map(languageKey => ({ + languageKey, + content: original.find(l => l.languageKey === languageKey)?.content ?? {}, + })) + ); + } catch { + // Best effort — a failed restore must not mask a test failure. + } + } +} + +/** `{a: {b: 'c'}}` → `{'a.b': 'c'}`, matching how the UI flattens nested translations. */ +function flatten(object: Record<string, unknown>, prefix = ''): Record<string, string> { + return Object.entries(object).reduce((acc, [key, value]) => { + const path = prefix ? `${prefix}.${key}` : key; + return typeof value === 'object' && value !== null + ? {...acc, ...flatten(value as Record<string, unknown>, path)} + : {...acc, [path]: value as string}; + }, {}); +} diff --git a/e2e/tests/translation-management/translation-config.ts b/e2e/tests/translation-management/translation-config.ts new file mode 100644 index 0000000000..da00d9c18d --- /dev/null +++ b/e2e/tests/translation-management/translation-config.ts @@ -0,0 +1,37 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Namespace for every key this suite writes. Translations are stored as one nested JSON object + * per language, so keeping all test data under a single top-level key makes both the assertions + * and the cleanup unambiguous. + */ +export const TRANSLATION_PREFIX = 'e2eTranslationManagement'; + +/** Languages the dev application is configured with, in table column order. */ +export const LANGUAGE_KEYS = ['en', 'nl']; + +export const SEEDED = { + alpha: `${TRANSLATION_PREFIX}.alpha`, + beta: `${TRANSLATION_PREFIX}.beta`, +}; + +export const SEEDED_CONTENT = { + en: {alpha: 'Alpha English', beta: 'Beta English'}, + nl: {alpha: 'Alpha Nederlands', beta: 'Beta Nederlands'}, +}; + +export const EXPECTED_COLUMN_TITLES = ['Key', 'English', 'Nederlands']; diff --git a/e2e/tests/translation-management/translation-management.spec.ts b/e2e/tests/translation-management/translation-management.spec.ts new file mode 100644 index 0000000000..a345839edb --- /dev/null +++ b/e2e/tests/translation-management/translation-management.spec.ts @@ -0,0 +1,224 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {expect, test} from '@playwright/test'; +import {EN_COLUMN, KEY_COLUMN, Localization, NL_COLUMN, TranslationManagementPage} from './page'; +import { + EXPECTED_COLUMN_TITLES, + LANGUAGE_KEYS, + SEEDED, + SEEDED_CONTENT, + TRANSLATION_PREFIX, +} from './translation-config'; + +test.use({storageState: undefined}); + +test.describe.configure({mode: 'serial'}); + +/** + * Covers the `/translation-management` admin page — a single editable table of translation keys + * with one column per configured language. + * + * State handling: translations are global application state and the update endpoint is an upsert + * of a whole language at a time (there is no delete). `beforeAll` therefore captures the current + * localizations, merges this suite's keys into them, and `afterAll` writes the captured content + * back over every language the suite touched. + */ +test.describe('Translation Management', () => { + let context; + let page; + let translationPage: TranslationManagementPage; + let originalLocalizations: Localization[] = []; + + test.beforeAll(async ({browser, baseURL}) => { + context = await browser.newContext({baseURL}); + page = await context.newPage(); + translationPage = new TranslationManagementPage(page); + + // Capture the untouched state, then merge the suite's own keys into it so the table has + // deterministic content without discarding whatever the environment already had. + originalLocalizations = await translationPage.getLocalizationsViaApi(); + await translationPage.setLocalizationsViaApi( + LANGUAGE_KEYS.map(languageKey => ({ + languageKey, + content: { + ...(originalLocalizations.find(l => l.languageKey === languageKey)?.content ?? {}), + [TRANSLATION_PREFIX]: SEEDED_CONTENT[languageKey], + }, + })) + ); + + await page.goto('/'); + await translationPage.goToTranslationManagement(); + }); + + test.afterAll(async () => { + await translationPage.restoreLocalizationsViaApi(originalLocalizations, LANGUAGE_KEYS); + + if (context) await context.close(); + }); + + // ─── 14.1–14.3 Viewing ──────────────────────────────────────────── + + test.describe('14.1–14.3 — View the translations table', () => { + test('14.1 · The table lists the configured translations', async () => { + expect(await translationPage.rowCount()).toBeGreaterThanOrEqual(2); + }); + + test('14.2 · The key column shows the translation keys', async () => { + const keys = await translationPage.keys(); + + expect(keys).toEqual(expect.arrayContaining([SEEDED.alpha, SEEDED.beta])); + }); + + test('14.3 · There is a key column and one column per language', async () => { + await expect(translationPage.columnTitles).toHaveText(EXPECTED_COLUMN_TITLES); + + // Each seeded row carries the English and Dutch value it was seeded with + const row = await translationPage.rowIndexForKey(SEEDED.alpha); + await expect(translationPage.cell(row, EN_COLUMN)).toHaveValue(SEEDED_CONTENT.en.alpha); + await expect(translationPage.cell(row, NL_COLUMN)).toHaveValue(SEEDED_CONTENT.nl.alpha); + }); + }); + + // ─── 14.4–14.7, 14.11, 14.12 Editing and cancelling ─────────────── + + test.describe('14.4–14.7 · 14.11–14.12 — Add a row and cancel the save', () => { + // Adding a row, filling it and opening the modal is one continuous piece of unsaved form + // state, so it stays in a single test — splitting it would lose the row between tests. + test('A new row can be filled in, and cancelling the dialog saves nothing', async () => { + const key = `${TRANSLATION_PREFIX}.gamma`; + + // 14.4 — Add translation row + const row = await translationPage.addRow(); + + // 14.5, 14.6, 14.7 — key, English and Dutch values + await translationPage.fillRow(row, [key, 'Gamma English', 'Gamma Nederlands']); + await expect(translationPage.cell(row, KEY_COLUMN)).toHaveValue(key); + await expect(translationPage.cell(row, EN_COLUMN)).toHaveValue('Gamma English'); + await expect(translationPage.cell(row, NL_COLUMN)).toHaveValue('Gamma Nederlands'); + + // 14.11 — the confirmation dialog offers cancel, save, and save-with-reload + await translationPage.openSaveModal(); + await expect(translationPage.modalHeading).toHaveText('Save translations'); + await expect(translationPage.modalContent).toContainText('page reload'); + await expect(translationPage.modalCancelButton).toHaveText('Cancel'); + await expect(translationPage.modalSaveButton).toHaveText('Save'); + await expect(translationPage.modalSaveAndReloadButton).toHaveText('Save and reload'); + + // 14.12 — cancelling leaves the row in the form but persists nothing + await translationPage.cancelSave(); + await expect(translationPage.cell(row, KEY_COLUMN)).toHaveValue(key); + expect(await translationPage.translationViaApi('en', key)).toBeUndefined(); + + // Drop the unsaved row so the next test starts from the persisted state + await translationPage.deleteRow(row); + }); + }); + + // ─── 14.9 Save ──────────────────────────────────────────────────── + + test.describe('14.9 — Save translations', () => { + test('Saving without a reload persists the new translation', async () => { + const key = `${TRANSLATION_PREFIX}.delta`; + + const row = await translationPage.addRow(); + await translationPage.fillRow(row, [key, 'Delta English', 'Delta Nederlands']); + + await translationPage.openSaveModal(); + await translationPage.confirmSave(); + + // Assert — stored for both languages, and the form is pristine again + expect(await translationPage.translationViaApi('en', key)).toBe('Delta English'); + expect(await translationPage.translationViaApi('nl', key)).toBe('Delta Nederlands'); + await expect(translationPage.saveButton).toBeDisabled(); + }); + }); + + // ─── 14.10 Save and reload ──────────────────────────────────────── + + test.describe('14.10 — Save and reload the application', () => { + test('Saving with a reload persists the translation and re-renders the page', async () => { + test.setTimeout(90_000); + const key = `${TRANSLATION_PREFIX}.epsilon`; + + const row = await translationPage.addRow(); + await translationPage.fillRow(row, [key, 'Epsilon English', 'Epsilon Nederlands']); + + await translationPage.openSaveModal(); + await translationPage.confirmSaveAndReload(); + + // Assert — persisted, and the reloaded page shows the row it was saved with + expect(await translationPage.translationViaApi('en', key)).toBe('Epsilon English'); + const reloadedRow = await translationPage.rowIndexForKey(key); + expect(reloadedRow).toBeGreaterThanOrEqual(0); + await expect(translationPage.cell(reloadedRow, NL_COLUMN)).toHaveValue('Epsilon Nederlands'); + }); + }); + + // ─── 14.8 Delete ────────────────────────────────────────────────── + + test.describe('14.8 — Delete translation row', () => { + test('Deleting a row and saving removes the translation', async () => { + const row = await translationPage.rowIndexForKey(SEEDED.beta); + expect(row, `seeded key ${SEEDED.beta} should be in the table`).toBeGreaterThanOrEqual(0); + + await translationPage.deleteRow(row); + await translationPage.openSaveModal(); + await translationPage.confirmSave(); + + // Assert — gone from the table and from both languages + expect(await translationPage.keys()).not.toContain(SEEDED.beta); + expect(await translationPage.translationViaApi('en', SEEDED.beta)).toBeUndefined(); + expect(await translationPage.translationViaApi('nl', SEEDED.beta)).toBeUndefined(); + }); + }); + + // ─── Failure scenarios ──────────────────────────────────────────── + + test.describe('Failure scenarios', () => { + test('14.12a · Save is disabled while nothing has been changed', async () => { + await translationPage.reload(); + + await expect(translationPage.saveButton).toBeDisabled(); + }); + + test('14.12b · An incomplete row keeps Save disabled until every column is filled', async () => { + const key = `${TRANSLATION_PREFIX}.incomplete`; + + // Act — an empty row is not a change the form will accept + const row = await translationPage.addRow(); + await expect(translationPage.saveButton).toBeDisabled(); + + // ...nor is a row with only a key... + await translationPage.cell(row, KEY_COLUMN).fill(key); + await expect(translationPage.saveButton).toBeDisabled(); + + // ...nor one still missing its Dutch translation + await translationPage.cell(row, EN_COLUMN).fill('Incomplete English'); + await expect(translationPage.saveButton).toBeDisabled(); + + // Completing the row finally enables Save + await translationPage.cell(row, NL_COLUMN).fill('Incomplete Nederlands'); + await expect(translationPage.saveButton).toBeEnabled(); + + // Assert — nothing was persisted along the way + expect(await translationPage.translationViaApi('en', key)).toBeUndefined(); + + await translationPage.deleteRow(row); + }); + }); +}); diff --git a/e2e/tests/user-dashboard/page.ts b/e2e/tests/user-dashboard/page.ts new file mode 100644 index 0000000000..0da68b38e3 --- /dev/null +++ b/e2e/tests/user-dashboard/page.ts @@ -0,0 +1,273 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {expect, type Locator, type Page} from '@playwright/test'; +import {endpoints} from '../../api/endpoints'; +import {DASHBOARD_TEST_IDS, DASHBOARD_WIDGET_TEST_ID_PREFIX} from '../../constants'; +import {apiDelete, apiGet, apiPost, apiPut} from '../../utils/api.utils'; +import {USER_DASHBOARD_CONFIG} from './user-dashboard-config'; + +/** Widget configuration as returned by the management API. */ +export interface WidgetConfiguration { + key: string; + title: string; + dataSourceKey: string; + displayType: string; + dataSourceProperties: Record<string, unknown>; + displayTypeProperties: Record<string, unknown>; + url: string | null; +} + +/** Dashboard as returned by the user-facing API, including its widgets. */ +export interface UserDashboard { + key: string; + title: string; + widgets: Array<{key: string; title: string; displayType: string; url: string | null}> | null; +} + +/** One widget's data point as returned by the user-facing data endpoint. */ +export interface WidgetData { + key: string; + data: Record<string, unknown>; +} + +export class UserDashboardPage { + constructor(private readonly page: Page) {} + + // ─── Locators ───────────────────────────────────────────────────── + + get container(): Locator { + return this.page.getByTestId(DASHBOARD_TEST_IDS.container); + } + + get title(): Locator { + return this.page.getByTestId(DASHBOARD_TEST_IDS.title); + } + + get widgetContainer(): Locator { + return this.page.getByTestId(DASHBOARD_TEST_IDS.widgetContainer); + } + + get noWidgetsMessage(): Locator { + return this.page.getByTestId(DASHBOARD_TEST_IDS.noWidgets); + } + + get noDashboardsMessage(): Locator { + return this.page.getByTestId(DASHBOARD_TEST_IDS.noDashboards); + } + + /** A single rendered widget, addressed by its widget key. */ + widget(widgetKey: string): Locator { + return this.page.getByTestId(`${DASHBOARD_WIDGET_TEST_ID_PREFIX}${widgetKey}`); + } + + /** All rendered widgets on the currently displayed dashboard. */ + get widgets(): Locator { + return this.widgetContainer.locator( + '[data-test-id^="' + DASHBOARD_WIDGET_TEST_ID_PREFIX + '"]' + ); + } + + /** The clickable inner surface of a widget — this is what carries the click handler. */ + widgetContent(widgetKey: string): Locator { + return this.widget(widgetKey).locator('.widget-configuration-content'); + } + + // ─── Navigation ─────────────────────────────────────────────────── + + /** + * Opens the user dashboard (the application root) and waits until the widget data of the + * selected dashboard has been fetched, so widgets are rendered before assertions run. + * + * This always performs a fresh navigation, so it doubles as a reload that picks up widget + * configuration changes made through the API. It deliberately does not use `page.reload()`: + * a test that clicked through to a widget's linked route would otherwise reload that route + * instead of the dashboard, and the widget data would never be requested. + * + * Returns the widget data the dashboard received, which lets tests assert on the numbers + * the widgets report without reading them out of chart SVG internals. + */ + async openDashboard(): Promise<WidgetData[]> { + const dataResponse = this.page.waitForResponse( + res => + res.url().includes(endpoints.userDashboard.data(USER_DASHBOARD_CONFIG.dashboardKey)) && + res.request().method() === 'GET' && + res.ok(), + {timeout: 30_000} + ); + + await this.page.goto('/'); + + const data = (await (await dataResponse).json()) as WidgetData[]; + await expect(this.container).toBeVisible({timeout: 15_000}); + return data; + } + + // ─── Assertions ─────────────────────────────────────────────────── + + async assertWidgetVisible(widgetKey: string) { + await expect(this.widget(widgetKey)).toBeVisible({timeout: 15_000}); + } + + async assertWidgetNotRendered(widgetKey: string) { + await expect(this.widget(widgetKey)).toHaveCount(0); + } + + /** Waits until exactly `count` widgets are rendered — Muuri mounts them one by one. */ + async assertWidgetCount(count: number) { + await expect(this.widgets).toHaveCount(count, {timeout: 15_000}); + } + + // ─── Widget interactions ────────────────────────────────────────── + + /** + * Clicks a widget that has a `url` configured and waits for the resulting navigation. + * Clicks near the widget's top-left corner so the click lands on the widget surface + * rather than on chart internals that swallow the event. + */ + async clickWidget(widgetKey: string) { + await this.widgetContent(widgetKey).click({position: {x: 4, y: 4}}); + } + + // ─── API helpers: dashboards ────────────────────────────────────── + + async getUserDashboardsViaApi(): Promise<UserDashboard[]> { + return apiGet<UserDashboard[]>(endpoints.userDashboard.getAll); + } + + async createDashboardViaApi(title: string, description: string): Promise<string> { + const result = await apiPost<{key: string}>(endpoints.dashboard.create, {title, description}); + return result.key; + } + + async deleteDashboardViaApi(key: string) { + try { + await apiDelete(endpoints.dashboard.delete(key)); + } catch { + // Already deleted or never created + } + } + + // ─── API helpers: widgets ───────────────────────────────────────── + + async getWidgetConfigurationsViaApi(): Promise<WidgetConfiguration[]> { + return apiGet<WidgetConfiguration[]>( + endpoints.dashboard.widgetConfigurations(USER_DASHBOARD_CONFIG.dashboardKey) + ); + } + + /** + * Adds a widget to the seeded dashboard and returns its generated key. + * The key is derived from the title by the backend, so it is read back from the response + * instead of being guessed. + */ + async createWidgetViaApi(widget: { + title: string; + dataSourceKey: string; + displayType: string; + dataSourceProperties?: Record<string, unknown>; + displayTypeProperties?: Record<string, unknown>; + url?: string | null; + }): Promise<string> { + const result = await apiPost<{key: string}>( + endpoints.dashboard.widgetConfigurations(USER_DASHBOARD_CONFIG.dashboardKey), + { + title: widget.title, + dataSourceKey: widget.dataSourceKey, + displayType: widget.displayType, + dataSourceProperties: widget.dataSourceProperties ?? { + documentDefinition: USER_DASHBOARD_CONFIG.caseDefinitionKey, + queryConditions: [], + }, + displayTypeProperties: widget.displayTypeProperties ?? {}, + url: widget.url ?? null, + } + ); + return result.key; + } + + async deleteWidgetViaApi(widgetKey: string) { + try { + await apiDelete( + endpoints.dashboard.widgetConfiguration(USER_DASHBOARD_CONFIG.dashboardKey, widgetKey) + ); + } catch { + // Already deleted or never created + } + } + + /** + * Replaces the full widget list of the seeded dashboard. Passing an empty array removes + * every widget, which is how the "no widgets" empty state is reached; passing a + * previously captured list restores the dashboard exactly, including widget order. + */ + async replaceWidgetsViaApi(widgets: WidgetConfiguration[]) { + await apiPut( + endpoints.dashboard.widgetConfigurations(USER_DASHBOARD_CONFIG.dashboardKey), + widgets + ); + } + + /** Restores the seeded widget configuration, tolerating a partially broken state. */ + async restoreWidgetsViaApi(widgets: WidgetConfiguration[]) { + try { + await this.replaceWidgetsViaApi(widgets); + } catch { + // Leave cleanup of the remaining resources to run + } + } + + // ─── API helpers: cases ─────────────────────────────────────────── + + /** Creates a bezwaar case, which increases the counts the seeded widgets report. */ + async createCaseViaApi(): Promise<string> { + const response = await apiPost<{document: {id: string}}>( + USER_DASHBOARD_CONFIG.processDocumentEndpoint, + { + processDefinitionKey: USER_DASHBOARD_CONFIG.processDefinitionKey, + request: { + definition: USER_DASHBOARD_CONFIG.caseDefinitionKey, + caseDefinitionKey: USER_DASHBOARD_CONFIG.caseDefinitionKey, + caseDefinitionVersionTag: USER_DASHBOARD_CONFIG.caseDefinitionVersionTag, + content: {}, + }, + } + ); + return response.document.id; + } + + async deleteCaseViaApi(documentId: string) { + try { + await apiDelete(`${USER_DASHBOARD_CONFIG.documentEndpoint}/${documentId}`); + } catch { + // Already deleted or permission denied + } + } + + // ─── Data helpers ───────────────────────────────────────────────── + + /** Reads the `total` a `case-count` widget reported in a widget data payload. */ + static widgetTotal(data: WidgetData[], widgetKey: string): number | undefined { + const entry = data.find(item => item.key === widgetKey); + return entry?.data?.total as number | undefined; + } + + /** Reads the `value` a `case-count` widget reported in a widget data payload. */ + static widgetValue(data: WidgetData[], widgetKey: string): number | undefined { + const entry = data.find(item => item.key === widgetKey); + return entry?.data?.value as number | undefined; + } +} diff --git a/e2e/tests/user-dashboard/user-dashboard-config.ts b/e2e/tests/user-dashboard/user-dashboard-config.ts new file mode 100644 index 0000000000..63c758e3fb --- /dev/null +++ b/e2e/tests/user-dashboard/user-dashboard-config.ts @@ -0,0 +1,74 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export const USER_DASHBOARD_CONFIG = { + /** + * The only dashboard a ROLE_USER may view. The user-facing dashboard list is filtered + * by an access-control permission that is scoped to this exact key (see + * `backend/apps/dev/src/main/resources/config/global/permission/dashboard.permission.json`), + * so any other dashboard is invisible on the user dashboard regardless of its content. + */ + dashboardKey: 'user-dashboard', + /** Title of the seeded dashboard, rendered as the heading in the single-dashboard layout. */ + dashboardTitle: 'My dashboard', + + /** Widget keys seeded by `user-dashboard.dashboard.json`. */ + seededWidgets: { + gauge: 'gauge_chart', + donut: 'donut_chart', + }, + + /** Display type titles rendered inside the seeded widgets. */ + seededWidgetTitles: { + gauge: 'Zaken zonder behandelaar', + donut: 'Zaakstatussen', + }, + + /** + * Widget added during the tests to prove a newly configured widget shows up for the + * user. `number` is the registered display type key of the "Big number" widget — the + * backend accepts any string, but the frontend only renders registered display types. + */ + addedWidget: { + titlePrefix: 'E2E User Dashboard Widget', + dataSourceKey: 'case-count', + displayType: 'number', + displayTitle: 'E2E widget total', + displaySubtitle: 'bezwaar', + /** Route the widget navigates to when clicked. */ + url: '/cases/bezwaar', + }, + + /** + * Widget with a display type that is not registered in the frontend. The backend stores + * it, but the dashboard must silently skip it instead of rendering a broken widget. + */ + unsupportedWidget: { + titlePrefix: 'E2E Unsupported Widget', + dataSourceKey: 'case-count', + displayType: 'not-a-registered-display-type', + }, + + /** Case definition used to change the numbers the widgets report. */ + caseDefinitionKey: 'bezwaar', + caseDefinitionVersionTag: '1.0.1', + processDefinitionKey: 'bezwaar', + processDocumentEndpoint: '/api/v1/process-document/operation/new-document-and-start-process', + documentEndpoint: '/api/v1/document', + + /** Copy of the "no widgets" empty state (`dashboard.noWidgets` in `en.json`). */ + noWidgetsTitle: 'No widgets', +} as const; diff --git a/e2e/tests/user-dashboard/user-dashboard.spec.ts b/e2e/tests/user-dashboard/user-dashboard.spec.ts new file mode 100644 index 0000000000..236404f009 --- /dev/null +++ b/e2e/tests/user-dashboard/user-dashboard.spec.ts @@ -0,0 +1,277 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {expect, test} from '@playwright/test'; +import {generateId} from '../../utils/dataGenerator'; +import {UserDashboardPage, type WidgetConfiguration} from './page'; +import {USER_DASHBOARD_CONFIG} from './user-dashboard-config'; + +test.use({storageState: 'playwright/.auth/uiState.json'}); + +test.describe('Feature 1 — Dashboard (User)', () => { + let context; + let page; + let dashboardPage: UserDashboardPage; + + /** Widget configuration of the seeded dashboard, restored after the suite. */ + let seededWidgets: WidgetConfiguration[] = []; + const createdWidgetKeys: string[] = []; + const createdDashboardKeys: string[] = []; + const createdCaseIds: string[] = []; + + test.beforeAll(async ({browser, baseURL}) => { + context = await browser.newContext({ + baseURL, + storageState: 'playwright/.auth/uiState.json', + }); + page = await context.newPage(); + dashboardPage = new UserDashboardPage(page); + + // Capture the seeded widget configuration so every test can restore the dashboard to + // the state it found it in — the tests below add, remove and clear widgets. + seededWidgets = await dashboardPage.getWidgetConfigurationsViaApi(); + expect( + seededWidgets.length, + 'expected the seeded user-dashboard to have widgets configured' + ).toBeGreaterThan(0); + }); + + test.afterAll(async () => { + // Remove anything the tests created, children before parents. + for (const widgetKey of createdWidgetKeys) { + await dashboardPage.deleteWidgetViaApi(widgetKey); + } + for (const dashboardKey of createdDashboardKeys) { + await dashboardPage.deleteDashboardViaApi(dashboardKey); + } + for (const documentId of createdCaseIds) { + await dashboardPage.deleteCaseViaApi(documentId); + } + + // Restore the seeded widget configuration last, so a test that failed midway through a + // mutation cannot leave the dashboard empty for the rest of the suite. + if (seededWidgets.length) { + await dashboardPage.restoreWidgetsViaApi(seededWidgets); + } + + await context.close(); + }); + + test.describe('1.1 — Display widget-based dashboard', () => { + test('loads the dashboard with its title and every configured widget', async () => { + await dashboardPage.openDashboard(); + + await expect(dashboardPage.title).toHaveText(USER_DASHBOARD_CONFIG.dashboardTitle); + await expect(dashboardPage.widgetContainer).toBeVisible(); + + await dashboardPage.assertWidgetVisible(USER_DASHBOARD_CONFIG.seededWidgets.gauge); + await dashboardPage.assertWidgetVisible(USER_DASHBOARD_CONFIG.seededWidgets.donut); + await dashboardPage.assertWidgetCount(seededWidgets.length); + + // The empty state must not be shown while widgets are present. + await expect(dashboardPage.noWidgetsMessage).toHaveCount(0); + }); + + test('renders the configured display title inside each widget', async () => { + await dashboardPage.openDashboard(); + + await expect(dashboardPage.widget(USER_DASHBOARD_CONFIG.seededWidgets.gauge)).toContainText( + USER_DASHBOARD_CONFIG.seededWidgetTitles.gauge + ); + await expect(dashboardPage.widget(USER_DASHBOARD_CONFIG.seededWidgets.donut)).toContainText( + USER_DASHBOARD_CONFIG.seededWidgetTitles.donut + ); + }); + + test('shows the "No widgets" empty state when the dashboard has no widgets', async () => { + try { + // Clearing the widget list is the only way to reach this state: the dashboard the + // user may view is seeded with widgets. + await dashboardPage.replaceWidgetsViaApi([]); + await dashboardPage.openDashboard(); + + await expect(dashboardPage.noWidgetsMessage).toBeVisible({timeout: 15_000}); + await expect(dashboardPage.noWidgetsMessage).toContainText( + USER_DASHBOARD_CONFIG.noWidgetsTitle + ); + await dashboardPage.assertWidgetCount(0); + + // The dashboard itself is still shown — only its content is empty. + await expect(dashboardPage.title).toHaveText(USER_DASHBOARD_CONFIG.dashboardTitle); + } finally { + await dashboardPage.restoreWidgetsViaApi(seededWidgets); + } + }); + }); + + test.describe('1.2 — Configure widgets per user/role', () => { + test('adding a widget shows it to the user, removing it hides it again', async () => { + const widgetTitle = `${USER_DASHBOARD_CONFIG.addedWidget.titlePrefix} ${generateId()}`; + let widgetKey: string | undefined; + + try { + await test.step('a newly configured widget appears on the dashboard', async () => { + widgetKey = await dashboardPage.createWidgetViaApi({ + title: widgetTitle, + dataSourceKey: USER_DASHBOARD_CONFIG.addedWidget.dataSourceKey, + displayType: USER_DASHBOARD_CONFIG.addedWidget.displayType, + displayTypeProperties: { + title: USER_DASHBOARD_CONFIG.addedWidget.displayTitle, + subtitle: USER_DASHBOARD_CONFIG.addedWidget.displaySubtitle, + }, + }); + createdWidgetKeys.push(widgetKey); + + await dashboardPage.openDashboard(); + + await dashboardPage.assertWidgetVisible(widgetKey); + await expect(dashboardPage.widget(widgetKey)).toContainText( + USER_DASHBOARD_CONFIG.addedWidget.displayTitle + ); + await dashboardPage.assertWidgetCount(seededWidgets.length + 1); + }); + + await test.step('removing the widget hides it from the dashboard', async () => { + await dashboardPage.deleteWidgetViaApi(widgetKey!); + createdWidgetKeys.splice(createdWidgetKeys.indexOf(widgetKey!), 1); + + await dashboardPage.openDashboard(); + + await dashboardPage.assertWidgetNotRendered(widgetKey!); + await dashboardPage.assertWidgetCount(seededWidgets.length); + // The widgets that were there before are untouched. + await dashboardPage.assertWidgetVisible(USER_DASHBOARD_CONFIG.seededWidgets.gauge); + }); + } finally { + if (widgetKey) await dashboardPage.deleteWidgetViaApi(widgetKey); + } + }); + + test('a dashboard the user has no view permission for is not shown', async () => { + // Dashboard visibility is role-scoped: ROLE_USER may only view the dashboard whose key + // the seeded access-control permission names. A dashboard created without such a + // permission must stay invisible on the user dashboard. + const dashboardTitle = `E2E Unpermitted Dashboard ${generateId()}`; + const dashboardKey = await dashboardPage.createDashboardViaApi( + dashboardTitle, + 'Created by the user-dashboard e2e test' + ); + createdDashboardKeys.push(dashboardKey); + + const userDashboards = await dashboardPage.getUserDashboardsViaApi(); + expect(userDashboards.map(dashboard => dashboard.key)).not.toContain(dashboardKey); + + await dashboardPage.openDashboard(); + + // Still the single-dashboard layout showing only the permitted dashboard. + await expect(dashboardPage.title).toHaveText(USER_DASHBOARD_CONFIG.dashboardTitle); + await expect(page.getByRole('tab', {name: dashboardTitle})).toHaveCount(0); + await expect(dashboardPage.container).not.toContainText(dashboardTitle); + }); + }); + + test.describe('1.4 — Refresh widget data on open', () => { + test('widget data is re-fetched and reflects new cases when the dashboard is re-opened', async () => { + const gaugeKey = USER_DASHBOARD_CONFIG.seededWidgets.gauge; + + const dataBefore = await dashboardPage.openDashboard(); + const totalBefore = UserDashboardPage.widgetTotal(dataBefore, gaugeKey); + expect(totalBefore, `expected widget data for "${gaugeKey}"`).not.toBeUndefined(); + + // Change what the widget counts by adding a case to the underlying case definition. + const documentId = await dashboardPage.createCaseViaApi(); + createdCaseIds.push(documentId); + + // Re-opening re-requests the widget data — openDashboard fails if it is not requested. + const dataAfter = await dashboardPage.openDashboard(); + const totalAfter = UserDashboardPage.widgetTotal(dataAfter, gaugeKey); + + expect(totalAfter).toBeGreaterThan(totalBefore!); + await dashboardPage.assertWidgetVisible(gaugeKey); + }); + }); + + test.describe('1.5 — Navigate from a widget to its linked route', () => { + test('clicking a widget with a configured URL navigates to that route', async () => { + const widgetTitle = `${USER_DASHBOARD_CONFIG.addedWidget.titlePrefix} link ${generateId()}`; + let widgetKey: string | undefined; + + try { + widgetKey = await dashboardPage.createWidgetViaApi({ + title: widgetTitle, + dataSourceKey: USER_DASHBOARD_CONFIG.addedWidget.dataSourceKey, + displayType: USER_DASHBOARD_CONFIG.addedWidget.displayType, + displayTypeProperties: {title: USER_DASHBOARD_CONFIG.addedWidget.displayTitle}, + url: USER_DASHBOARD_CONFIG.addedWidget.url, + }); + createdWidgetKeys.push(widgetKey); + + await dashboardPage.openDashboard(); + await dashboardPage.assertWidgetVisible(widgetKey); + + await dashboardPage.clickWidget(widgetKey); + + await page.waitForURL( + url => url.pathname.startsWith(USER_DASHBOARD_CONFIG.addedWidget.url), + {timeout: 15_000} + ); + } finally { + if (widgetKey) { + await dashboardPage.deleteWidgetViaApi(widgetKey); + const index = createdWidgetKeys.indexOf(widgetKey); + if (index >= 0) createdWidgetKeys.splice(index, 1); + } + } + }); + }); + + test.describe('Failure scenarios (1.6)', () => { + test('a widget with an unsupported display type is skipped without breaking the dashboard', async () => { + // The backend accepts any display type string, but the frontend only renders display + // types it has registered. An unknown one must be dropped silently — the rest of the + // dashboard has to keep working. + const widgetTitle = `${USER_DASHBOARD_CONFIG.unsupportedWidget.titlePrefix} ${generateId()}`; + let widgetKey: string | undefined; + + try { + widgetKey = await dashboardPage.createWidgetViaApi({ + title: widgetTitle, + dataSourceKey: USER_DASHBOARD_CONFIG.unsupportedWidget.dataSourceKey, + displayType: USER_DASHBOARD_CONFIG.unsupportedWidget.displayType, + }); + createdWidgetKeys.push(widgetKey); + + // The widget is stored, so this really exercises the frontend filter. + const stored = await dashboardPage.getWidgetConfigurationsViaApi(); + expect(stored.map(widget => widget.key)).toContain(widgetKey); + + await dashboardPage.openDashboard(); + + await dashboardPage.assertWidgetNotRendered(widgetKey); + await dashboardPage.assertWidgetCount(seededWidgets.length); + await dashboardPage.assertWidgetVisible(USER_DASHBOARD_CONFIG.seededWidgets.gauge); + await dashboardPage.assertWidgetVisible(USER_DASHBOARD_CONFIG.seededWidgets.donut); + await expect(dashboardPage.noWidgetsMessage).toHaveCount(0); + } finally { + if (widgetKey) { + await dashboardPage.deleteWidgetViaApi(widgetKey); + const index = createdWidgetKeys.indexOf(widgetKey); + if (index >= 0) createdWidgetKeys.splice(index, 1); + } + } + }); + }); +}); diff --git a/e2e/tests/user-objects/page.ts b/e2e/tests/user-objects/page.ts new file mode 100644 index 0000000000..863fe44a1b --- /dev/null +++ b/e2e/tests/user-objects/page.ts @@ -0,0 +1,127 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {type Locator, type Page, expect} from '@playwright/test'; +import {OBJECT_DETAIL_TEST_IDS, OBJECT_LIST_TEST_IDS} from '../../constants'; +import {CarbonList} from '../../shared/carbon-list/carbon-list.utils'; +import {apiGet} from '../../utils/api.utils'; +import {USER_OBJECTS_API} from './user-objects-config'; + +/** A configuration as the user-facing endpoint returns it. */ +export interface UserObjectConfiguration { + id: string; + title: string; + formDefinitionView: string | null; + formDefinitionEdit: string | null; +} + +/** One page of objects. */ +export interface ObjectPage { + content: Array<{id: string; items: Array<{key: string; value: unknown}>}>; + totalElements: number; +} + +export class UserObjectsPage { + readonly carbonList: CarbonList; + + constructor(private readonly page: Page) { + this.carbonList = new CarbonList(page); + } + + // ─── List ───────────────────────────────────────────────────────── + + get createButton(): Locator { + return this.page.getByTestId(OBJECT_LIST_TEST_IDS.createButton); + } + + // ─── Detail ─────────────────────────────────────────────────────── + + get editButton(): Locator { + return this.page.getByTestId(OBJECT_DETAIL_TEST_IDS.editButton); + } + + get deleteButton(): Locator { + return this.page.getByTestId(OBJECT_DETAIL_TEST_IDS.deleteButton); + } + + get summaryForm(): Locator { + return this.page.getByTestId(OBJECT_DETAIL_TEST_IDS.summaryForm); + } + + // ─── Navigation ─────────────────────────────────────────────────── + + /** + * While the objects are still being fetched the component renders a *second* + * `valtimo-carbon-list` as its loading placeholder, and that one has no + * columns. `CarbonList` resolves to the first match, so the wait is for the + * placeholder to go away — not just for a table to appear. + */ + async goToObjectList(objectManagementId: string) { + await this.page.goto(`/objects/${objectManagementId}`); + await this.page.waitForURL(new RegExp(`/objects/${objectManagementId}$`)); + await expect(this.page.locator('valtimo-carbon-list')).toHaveCount(1); + await this.carbonList.waitForLoaded(); + await expect(this.carbonList.table).toBeVisible(); + await expect(this.carbonList.rows.first()).toBeVisible(); + } + + async goToObjectDetail(objectManagementId: string, objectId: string) { + await this.page.goto(`/objects/${objectManagementId}/${objectId}`); + await this.page.waitForURL(new RegExp(`/objects/${objectManagementId}/${objectId}$`)); + } + + // ─── Assertions ─────────────────────────────────────────────────── + + /** + * Polled: on a cold load the header cells are appended a tick after the rows, + * so a straight read can still come back empty. + */ + async assertColumnHeaders(expectedHeaders: readonly string[]) { + await expect + .poll(async () => { + const headers = await this.carbonList.table.locator('thead th').allInnerTexts(); + return headers.map(header => header.trim()).filter(Boolean); + }) + .toEqual([...expectedHeaders]); + } + + // ─── API helpers ────────────────────────────────────────────────── + + async getConfigurationsViaApi(): Promise<UserObjectConfiguration[]> { + return apiGet<UserObjectConfiguration[]>(USER_OBJECTS_API.configurations); + } + + /** + * The configuration the tests read. Only configurations flagged + * `showInDataMenu` are served here, so a missing one means the environment is + * not seeded as expected rather than a broken test. + */ + async getConfigurationByTitleViaApi(title: string): Promise<UserObjectConfiguration | undefined> { + const configurations = await this.getConfigurationsViaApi(); + return configurations.find(configuration => configuration.title === title); + } + + async getObjectsViaApi(objectManagementId: string, size = 10): Promise<ObjectPage> { + return apiGet<ObjectPage>( + `${USER_OBJECTS_API.objects(objectManagementId)}?page=0&size=${size}` + ); + } + + /** The plain object id, which the detail route uses, from the record's URL. */ + static objectIdFromUrl(objectUrl: string): string { + return objectUrl.split('/').pop() ?? ''; + } +} diff --git a/e2e/tests/user-objects/user-objects-config.ts b/e2e/tests/user-objects/user-objects-config.ts new file mode 100644 index 0000000000..0d09c7e5fb --- /dev/null +++ b/e2e/tests/user-objects/user-objects-config.ts @@ -0,0 +1,43 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export const USER_OBJECTS_API = { + /** The slim, PBAC-gated configuration list the user pages read. */ + configurations: '/api/v1/object-management/configuration', + objects: (objectManagementId: string) => + `/api/v1/object-management/configuration/${objectManagementId}/object`, +} as const; + +/** + * The object type these tests read. + * + * Resolved by title through the user-facing configuration endpoint rather than + * by a hardcoded id: only configurations with `showInDataMenu` are returned + * there, and the seeded ids differ per environment. + */ +export const USER_OBJECT_TYPE = { + title: 'Bomen', +} as const; + +export const USER_OBJECTS_TEXTS = { + /** + * Columns of the object list while the type has **no** list columns + * configured. `ObjectListComponent.setDefaultFields()` falls back to these + * two; a type with list columns configured renders those instead. + */ + defaultColumns: ['Record index', 'Object URL'], + detailTab: 'General', +} as const; diff --git a/e2e/tests/user-objects/user-objects.spec.ts b/e2e/tests/user-objects/user-objects.spec.ts new file mode 100644 index 0000000000..7e84fc090d --- /dev/null +++ b/e2e/tests/user-objects/user-objects.spec.ts @@ -0,0 +1,146 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {expect, test} from '@playwright/test'; +import {USER_OBJECT_TYPE, USER_OBJECTS_TEXTS} from './user-objects-config'; +import {UserObjectsPage} from './page'; + +test.use({storageState: 'playwright/.auth/uiState.json'}); + +/** + * Feature 4 — the object pages a user reads at `/objects`. + * + * These tests are deliberately read-only: they assert against the seeded object + * type rather than creating objects, so nothing has to be cleaned up and the + * shared Objecten API is left untouched. + */ +test.describe('Feature 4 — Objects (User)', () => { + let context; + let page; + let userObjectsPage: UserObjectsPage; + + let objectManagementId: string; + + test.beforeAll(async ({browser, baseURL}) => { + context = await browser.newContext({ + baseURL, + storageState: 'playwright/.auth/uiState.json', + }); + page = await context.newPage(); + userObjectsPage = new UserObjectsPage(page); + + const configuration = await userObjectsPage.getConfigurationByTitleViaApi( + USER_OBJECT_TYPE.title + ); + if (!configuration) { + test.skip( + true, + `No object type "${USER_OBJECT_TYPE.title}" is exposed to users in this environment; ` + + 'the object pages cannot be reached without one.' + ); + } + objectManagementId = configuration!.id; + + await page.goto('/'); + }); + + test.afterAll(async () => { + // Read-only suite — nothing to clean up. + await context.close(); + }); + + test.describe('4.1 — View objects per type', () => { + test('4.1 — Lists the objects of one type', async () => { + await userObjectsPage.goToObjectList(objectManagementId); + + // The type has no list columns configured, so the component falls back to + // its two default fields. + await userObjectsPage.assertColumnHeaders(USER_OBJECTS_TEXTS.defaultColumns); + + const objects = await userObjectsPage.getObjectsViaApi(objectManagementId); + expect(objects.totalElements).toBeGreaterThan(0); + + // The first page is shown, capped at the page size. + await userObjectsPage.carbonList.assertRowCount(objects.content.length); + await expect(userObjectsPage.carbonList.pagination).toBeVisible(); + + // Every row carries the object URL its record was keyed on. + const firstUrl = objects.content[0].items.find(item => item.key === 'objectUrl') + ?.value as string; + await expect(userObjectsPage.carbonList.rows.first()).toContainText(firstUrl); + }); + + test('4.1 — The list is scoped to the requested type', async () => { + await userObjectsPage.goToObjectList(objectManagementId); + + // Only the configurations flagged for the data menu are reachable, and + // the route renders exactly the one it was given. + const configurations = await userObjectsPage.getConfigurationsViaApi(); + const requested = configurations.find( + configuration => configuration.id === objectManagementId + ); + expect(requested?.title).toBe(USER_OBJECT_TYPE.title); + + const objects = await userObjectsPage.getObjectsViaApi(objectManagementId); + await userObjectsPage.carbonList.assertRowCount(objects.content.length); + }); + }); + + test.describe('4.2 — View object details', () => { + test('4.2 — Opens an object from its row and shows the summary form', async () => { + await userObjectsPage.goToObjectList(objectManagementId); + + const objects = await userObjectsPage.getObjectsViaApi(objectManagementId); + const firstUrl = objects.content[0].items.find(item => item.key === 'objectUrl') + ?.value as string; + const objectId = UserObjectsPage.objectIdFromUrl(firstUrl); + + await userObjectsPage.carbonList.rows.first().click(); + + // The row navigates to the object, keyed on the plain id rather than the + // full Objecten API URL. + await page.waitForURL(new RegExp(`/objects/${objectManagementId}/${objectId}$`)); + + // The configured view form renders the object read-only, with the edit and + // delete actions beside it. + await expect(userObjectsPage.summaryForm).toBeVisible(); + await expect(userObjectsPage.editButton).toBeVisible(); + await expect(userObjectsPage.deleteButton).toBeVisible(); + }); + + test('4.2 — The detail page can be opened directly by URL', async () => { + const objects = await userObjectsPage.getObjectsViaApi(objectManagementId); + const secondUrl = objects.content[1].items.find(item => item.key === 'objectUrl') + ?.value as string; + const objectId = UserObjectsPage.objectIdFromUrl(secondUrl); + + await userObjectsPage.goToObjectDetail(objectManagementId, objectId); + await expect(userObjectsPage.summaryForm).toBeVisible(); + }); + + test.describe('Failure scenarios', () => { + test('4.2a — An unknown object id renders no summary form', async () => { + await userObjectsPage.goToObjectDetail( + objectManagementId, + '00000000-0000-4000-8000-000000000000' + ); + + // The route still resolves, but there is no object to render a form for. + await expect(userObjectsPage.summaryForm).toHaveCount(0); + }); + }); + }); +}); diff --git a/frontend/projects/valtimo/access-control-management/src/lib/components/editor/access-control-editor.component.html b/frontend/projects/valtimo/access-control-management/src/lib/components/editor/access-control-editor.component.html index 72af65c10b..6e70c2380e 100644 --- a/frontend/projects/valtimo/access-control-management/src/lib/components/editor/access-control-editor.component.html +++ b/frontend/projects/valtimo/access-control-management/src/lib/components/editor/access-control-editor.component.html @@ -82,13 +82,19 @@ > <v-overflow-menu class="overflow-button" placement="bottom-end"> <v-overflow-menu-trigger overflowTrigger></v-overflow-menu-trigger> - <v-overflow-menu-option [disabled]="obs.moreDisabled" (selected)="showEditModal()">{{ - 'accessControl.roles.editMetadata' | translate - }}</v-overflow-menu-option> + <v-overflow-menu-option + [testId]="testIds.moreMenuEditMetadata" + [disabled]="obs.moreDisabled" + (selected)="showEditModal()" + >{{ 'accessControl.roles.editMetadata' | translate }}</v-overflow-menu-option + > - <v-overflow-menu-option [disabled]="obs.moreDisabled" (selected)="exportPermissions()">{{ - 'interface.export' | translate - }}</v-overflow-menu-option> + <v-overflow-menu-option + [testId]="testIds.moreMenuExport" + [disabled]="obs.moreDisabled" + (selected)="exportPermissions()" + >{{ 'interface.export' | translate }}</v-overflow-menu-option + > <v-overflow-menu-option [disabled]="obs.moreDisabled" @@ -101,6 +107,7 @@ <button [disabled]="saveDisabled$ | async" cdsButton="primary" + [attr.data-test-id]="testIds.saveButton" [size]="obs.compactMode ? 'sm' : 'md'" (click)="updatePermissions()" > diff --git a/frontend/projects/valtimo/access-control-management/src/lib/components/editor/access-control-editor.component.ts b/frontend/projects/valtimo/access-control-management/src/lib/components/editor/access-control-editor.component.ts index b0252e07d2..4ae6fe5e98 100644 --- a/frontend/projects/valtimo/access-control-management/src/lib/components/editor/access-control-editor.component.ts +++ b/frontend/projects/valtimo/access-control-management/src/lib/components/editor/access-control-editor.component.ts @@ -39,6 +39,7 @@ import {AccessControlEditorTab, Permission, Role} from '../../models'; import {TranslateService} from '@ngx-translate/core'; import {AccessControlExportService} from '../../services/access-control-export.service'; import {GlobalNotificationService} from '@valtimo/shared'; +import {ACCESS_CONTROL_ROLES_TEST_IDS} from '../../constants'; @Component({ standalone: false, @@ -58,6 +59,7 @@ export class AccessControlEditorComponent public readonly moreDisabled$ = new BehaviorSubject<boolean>(true); public readonly showDeleteModal$ = new BehaviorSubject<boolean>(false); public readonly showEditModal$ = new BehaviorSubject<boolean>(false); + public readonly testIds = ACCESS_CONTROL_ROLES_TEST_IDS; public readonly selectedRowKeys$ = new BehaviorSubject<Array<string> | null>(null); public readonly compactMode$ = this.pageHeaderService.compactMode$; diff --git a/frontend/projects/valtimo/access-control-management/src/lib/components/export-role-modal/export-role-modal.component.html b/frontend/projects/valtimo/access-control-management/src/lib/components/export-role-modal/export-role-modal.component.html index 349fb7de21..87c233721c 100644 --- a/frontend/projects/valtimo/access-control-management/src/lib/components/export-role-modal/export-role-modal.component.html +++ b/frontend/projects/valtimo/access-control-management/src/lib/components/export-role-modal/export-role-modal.component.html @@ -31,13 +31,23 @@ <h3 cdsModalHeaderHeading> <section cdsModalContent [cdsLayer]="1"> <div class="export-buttons"> - <button [disabled]="disabled" cdsButton="secondary" (click)="selectType('unified')"> + <button + [disabled]="disabled" + cdsButton="secondary" + [attr.data-test-id]="testIds.exportModalSingleFile" + (click)="selectType('unified')" + > {{ 'accessControl.roles.singleExport' | translate }} <svg class="cds--btn__icon" cdsIcon="document" size="16"></svg> </button> - <button [disabled]="disabled" cdsButton="secondary" (click)="selectType('separate')"> + <button + [disabled]="disabled" + cdsButton="secondary" + [attr.data-test-id]="testIds.exportModalSeparateFiles" + (click)="selectType('separate')" + > {{ 'accessControl.roles.multipleExport' | translate }} <svg class="cds--btn__icon" cdsIcon="copy--file" size="16"></svg> @@ -53,6 +63,7 @@ <h3 cdsModalHeaderHeading> <button [disabled]="!vars.selectedType || disabled" cdsButton="primary" + [attr.data-test-id]="testIds.exportModalConfirmButton" (click)="onConfirm(vars.selectedType)" > {{ vars.title }} diff --git a/frontend/projects/valtimo/access-control-management/src/lib/components/export-role-modal/export-role-modal.component.ts b/frontend/projects/valtimo/access-control-management/src/lib/components/export-role-modal/export-role-modal.component.ts index df514a9f45..2e1e477204 100644 --- a/frontend/projects/valtimo/access-control-management/src/lib/components/export-role-modal/export-role-modal.component.ts +++ b/frontend/projects/valtimo/access-control-management/src/lib/components/export-role-modal/export-role-modal.component.ts @@ -26,6 +26,7 @@ import { import {ExportRoleOutput, RoleExport} from '../../models'; import {BehaviorSubject, Observable, Subscription} from 'rxjs'; import {CARBON_CONSTANTS} from '@valtimo/components'; +import {ACCESS_CONTROL_ROLES_TEST_IDS} from '../../constants'; @Component({ standalone: false, @@ -44,6 +45,7 @@ export class ExportRoleModalComponent implements OnInit, OnDestroy { @Output() closeEvent = new EventEmitter<any>(); public readonly selectedType$ = new BehaviorSubject<RoleExport | null>(null); + public readonly testIds = ACCESS_CONTROL_ROLES_TEST_IDS; private _resetSubscription!: Subscription; diff --git a/frontend/projects/valtimo/access-control-management/src/lib/components/overview/access-control-overview.component.html b/frontend/projects/valtimo/access-control-management/src/lib/components/overview/access-control-overview.component.html index 2da160eda3..c333e7fc37 100644 --- a/frontend/projects/valtimo/access-control-management/src/lib/components/overview/access-control-overview.component.html +++ b/frontend/projects/valtimo/access-control-management/src/lib/components/overview/access-control-overview.component.html @@ -35,7 +35,11 @@ <svg class="cds--btn__icon" cdsIcon="trash-can" size="16"></svg> </button> - <button cdsButton="primary" (click)="showExportModal()"> + <button + cdsButton="primary" + [attr.data-test-id]="testIds.exportButton" + (click)="showExportModal()" + > {{ 'interface.export' | translate }} <svg class="cds--btn__icon" cdsIcon="download" size="16"></svg> diff --git a/frontend/projects/valtimo/access-control-management/src/lib/components/overview/access-control-overview.component.ts b/frontend/projects/valtimo/access-control-management/src/lib/components/overview/access-control-overview.component.ts index c41fd60e91..e8a91e73b7 100644 --- a/frontend/projects/valtimo/access-control-management/src/lib/components/overview/access-control-overview.component.ts +++ b/frontend/projects/valtimo/access-control-management/src/lib/components/overview/access-control-overview.component.ts @@ -20,6 +20,7 @@ import {BehaviorSubject, delay, finalize, map, Observable, Subject, tap} from 'r import {ExportRoleOutput, Role} from '../../models'; import {AccessControlExportService} from '../../services/access-control-export.service'; import {AccessControlService} from '../../services/access-control.service'; +import {ACCESS_CONTROL_ROLES_TEST_IDS} from '../../constants'; @Component({ standalone: false, @@ -50,6 +51,7 @@ export class AccessControlOverviewComponent implements OnInit { public readonly selectedRowKeys$ = new BehaviorSubject<Array<string>>([]); public readonly resetExportType$ = new Subject<null>(); public readonly exportDisabled$ = new BehaviorSubject<boolean>(false); + public readonly testIds = ACCESS_CONTROL_ROLES_TEST_IDS; constructor( private readonly accessControlService: AccessControlService, diff --git a/frontend/projects/valtimo/access-control-management/src/lib/components/permission-form/permission-form.component.html b/frontend/projects/valtimo/access-control-management/src/lib/components/permission-form/permission-form.component.html index f9b2d5f646..f7da45990b 100644 --- a/frontend/projects/valtimo/access-control-management/src/lib/components/permission-form/permission-form.component.html +++ b/frontend/projects/valtimo/access-control-management/src/lib/components/permission-form/permission-form.component.html @@ -99,6 +99,7 @@ <h4 class="permission-form__preview-title"> <cds-accordion class="permission-form__accordion"> <cds-accordion-item + [attr.data-test-id]="testIds.sectionResourceActions" [expanded]="$openSection() === 'resourceActions'" [title]="'accessControl.editor.resourceAndActions' | translate" (selected)="onSectionToggle('resourceActions', $event)" @@ -172,6 +173,7 @@ <h4 class="permission-form__preview-title"> </cds-accordion-item> <cds-accordion-item + [attr.data-test-id]="testIds.sectionConditions" [class.permission-form__section-configured]="conditionsArray.length > 0" [expanded]="$openSection() === 'conditions'" [title]="'accessControl.editor.conditions' | translate" @@ -194,6 +196,7 @@ <h4 class="permission-form__preview-title"> </cds-accordion-item> <cds-accordion-item + [attr.data-test-id]="testIds.sectionContext" [class.permission-form__section-configured]="hasContext" [expanded]="$openSection() === 'context'" [title]="'accessControl.editor.contextSection' | translate" diff --git a/frontend/projects/valtimo/access-control-management/src/lib/components/role-metadata-modal/role-metadata-modal.component.html b/frontend/projects/valtimo/access-control-management/src/lib/components/role-metadata-modal/role-metadata-modal.component.html index 9a8b7a110a..9baef2fd0b 100644 --- a/frontend/projects/valtimo/access-control-management/src/lib/components/role-metadata-modal/role-metadata-modal.component.html +++ b/frontend/projects/valtimo/access-control-management/src/lib/components/role-metadata-modal/role-metadata-modal.component.html @@ -45,6 +45,7 @@ <h3 cdsModalHeaderHeading> <input formControlName="key" cdsText + [attr.data-test-id]="testIds.metadataKeyInput" placeholder="{{ 'accessControl.roles.name' | translate }}" [attr.modal-primary-focus]="true" [invalid]="key.dirty && key.invalid" @@ -66,6 +67,7 @@ <h3 cdsModalHeaderHeading> <button class="role-metadata-modal__mode-switch" cdsButton="ghost" + [attr.data-test-id]="testIds.metadataKeyModeSwitch" size="sm" type="button" (click)="onKeyManualToggle(!$keyManual())" @@ -82,11 +84,20 @@ <h3 cdsModalHeaderHeading> </section> <cds-modal-footer [cdsLayer]="1"> - <button cdsButton="secondary" (click)="onCancel()"> + <button + cdsButton="secondary" + [attr.data-test-id]="testIds.metadataCancelButton" + (click)="onCancel()" + > {{ 'interface.cancel' | translate }} </button> - <button [disabled]="this.form.invalid" (click)="onConfirm()" cdsButton="primary"> + <button + [disabled]="this.form.invalid" + [attr.data-test-id]="testIds.metadataConfirmButton" + (click)="onConfirm()" + cdsButton="primary" + > {{ vars.isAdd ? ('interface.create' | translate) : ('interface.save' | translate) }} </button> </cds-modal-footer> diff --git a/frontend/projects/valtimo/access-control-management/src/lib/components/role-metadata-modal/role-metadata-modal.component.ts b/frontend/projects/valtimo/access-control-management/src/lib/components/role-metadata-modal/role-metadata-modal.component.ts index 544bfc4215..fb4909a60d 100644 --- a/frontend/projects/valtimo/access-control-management/src/lib/components/role-metadata-modal/role-metadata-modal.component.ts +++ b/frontend/projects/valtimo/access-control-management/src/lib/components/role-metadata-modal/role-metadata-modal.component.ts @@ -27,6 +27,7 @@ import {FormBuilder, Validators} from '@angular/forms'; import {CARBON_CONSTANTS, SelectItem} from '@valtimo/components'; import {Role, RoleMetadataModal} from '../../models'; import {AccessControlService} from '../../services'; +import {ACCESS_CONTROL_ROLES_TEST_IDS} from '../../constants'; @Component({ standalone: false, @@ -58,6 +59,7 @@ export class RoleMetadataModalComponent implements OnInit { // only lists these; a custom key is entered via manual mode. public readonly $roleKeyItems = signal<SelectItem[]>([]); public readonly $loadingRoles = signal<boolean>(false); + public readonly testIds = ACCESS_CONTROL_ROLES_TEST_IDS; // Whether the key is typed manually rather than picked from the dropdown. public readonly $keyManual = signal<boolean>(false); diff --git a/frontend/projects/valtimo/access-control-management/src/lib/constants/access-control-editor.test-ids.ts b/frontend/projects/valtimo/access-control-management/src/lib/constants/access-control-editor.test-ids.ts index 7cd906bd3d..8616e76397 100644 --- a/frontend/projects/valtimo/access-control-management/src/lib/constants/access-control-editor.test-ids.ts +++ b/frontend/projects/valtimo/access-control-management/src/lib/constants/access-control-editor.test-ids.ts @@ -16,6 +16,11 @@ const ACCESS_CONTROL_EDITOR_TEST_IDS = { addPermissionButton: 'accessControlEditorAddPermission', + // Accordion sections of a permission card; only one is expanded at a time, so the + // controls inside a collapsed section are present but not visible. + sectionResourceActions: 'accessControlEditorSectionResourceActions', + sectionConditions: 'accessControlEditorSectionConditions', + sectionContext: 'accessControlEditorSectionContext', permissionListItem: 'accessControlEditorPermissionListItem', permissionCard: 'accessControlEditorPermissionCard', removePermissionButton: 'accessControlEditorRemovePermission', diff --git a/frontend/projects/valtimo/access-control-management/src/lib/constants/access-control-roles.test-ids.ts b/frontend/projects/valtimo/access-control-management/src/lib/constants/access-control-roles.test-ids.ts new file mode 100644 index 0000000000..6550b3c944 --- /dev/null +++ b/frontend/projects/valtimo/access-control-management/src/lib/constants/access-control-roles.test-ids.ts @@ -0,0 +1,36 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const ACCESS_CONTROL_ROLES_TEST_IDS = { + // Roles overview — toolbar batch actions (only rendered once a row is selected) + exportButton: 'accessControlRolesExport', + // Role detail — page-header overflow menu + moreMenuEditMetadata: 'accessControlRoleEditMetadata', + moreMenuExport: 'accessControlRoleExport', + // Role metadata modal (shared by add and edit) + metadataKeyInput: 'accessControlRoleMetadataKey', + metadataKeyModeSwitch: 'accessControlRoleMetadataKeyModeSwitch', + metadataConfirmButton: 'accessControlRoleMetadataConfirm', + metadataCancelButton: 'accessControlRoleMetadataCancel', + // Role detail — page header + saveButton: 'accessControlRoleSave', + // Export role modal + exportModalSingleFile: 'accessControlRoleExportSingleFile', + exportModalSeparateFiles: 'accessControlRoleExportSeparateFiles', + exportModalConfirmButton: 'accessControlRoleExportConfirm', +} as const; + +export {ACCESS_CONTROL_ROLES_TEST_IDS}; diff --git a/frontend/projects/valtimo/access-control-management/src/lib/constants/index.ts b/frontend/projects/valtimo/access-control-management/src/lib/constants/index.ts index 3937dc2bec..963baa1881 100644 --- a/frontend/projects/valtimo/access-control-management/src/lib/constants/index.ts +++ b/frontend/projects/valtimo/access-control-management/src/lib/constants/index.ts @@ -15,4 +15,5 @@ */ export * from './access-control-editor.test-ids'; +export * from './access-control-roles.test-ids'; export * from './access-control-labels'; diff --git a/frontend/projects/valtimo/building-block-management/src/lib/components/building-block-management-artwork/building-block-management-artwork.component.html b/frontend/projects/valtimo/building-block-management/src/lib/components/building-block-management-artwork/building-block-management-artwork.component.html index d6ca694e95..66123c5fc4 100644 --- a/frontend/projects/valtimo/building-block-management/src/lib/components/building-block-management-artwork/building-block-management-artwork.component.html +++ b/frontend/projects/valtimo/building-block-management/src/lib/components/building-block-management-artwork/building-block-management-artwork.component.html @@ -34,13 +34,19 @@ <h4 class="building-block-management-artwork__title"> @if (!obs.loadingArtwork && obs.artwork) { <div class="building-block-management-artwork__preview"> <img + [attr.data-test-id]="testIds.image" class="building-block-management-artwork__image" [src]="'data:image/png;base64,' + obs.artwork.imageBase64" /> </div> <div class="building-block-management-artwork__buttons"> - <button cdsButton="danger" (click)="onDeleteButtonClick()" [disabled]="formGroup.disabled"> + <button + [attr.data-test-id]="testIds.deleteButton" + cdsButton="danger" + (click)="onDeleteButtonClick()" + [disabled]="formGroup.disabled" + > {{ 'interface.delete' | translate }} <svg class="cds--btn__icon" cdsIcon="trash-can" size="16"></svg> @@ -51,6 +57,7 @@ <h4 class="building-block-management-artwork__title"> @if (!obs.loadingArtwork && !obs.artwork) { <cds-file-uploader *ngIf="!obs.loadingArtwork && !obs.artwork" + [attr.data-test-id]="testIds.fileUploader" [accept]="ACCEPTED_FILES" [multiple]="false" buttonType="primary" @@ -63,6 +70,7 @@ <h4 class="building-block-management-artwork__title"> <div class="building-block-management-artwork__buttons"> <button + [attr.data-test-id]="testIds.uploadButton" cdsButton="primary" [disabled]="formGroup.disabled || formGroup.invalid || formGroup.pristine" (click)="onSave()" diff --git a/frontend/projects/valtimo/building-block-management/src/lib/components/building-block-management-artwork/building-block-management-artwork.component.ts b/frontend/projects/valtimo/building-block-management/src/lib/components/building-block-management-artwork/building-block-management-artwork.component.ts index ce66df216b..35bf4d6c7e 100644 --- a/frontend/projects/valtimo/building-block-management/src/lib/components/building-block-management-artwork/building-block-management-artwork.component.ts +++ b/frontend/projects/valtimo/building-block-management/src/lib/components/building-block-management-artwork/building-block-management-artwork.component.ts @@ -37,6 +37,7 @@ import { } from 'carbon-components-angular'; import {BehaviorSubject, Subscription, switchMap, tap} from 'rxjs'; import {TrashCan16, Upload16} from '@carbon/icons'; +import {BUILDING_BLOCK_MANAGEMENT_ARTWORK_TEST_IDS} from '../../constants'; @Component({ standalone: true, @@ -59,6 +60,8 @@ import {TrashCan16, Upload16} from '@carbon/icons'; ], }) export class BuildingBlockManagementArtworkComponent implements OnInit, OnDestroy { + protected readonly testIds = BUILDING_BLOCK_MANAGEMENT_ARTWORK_TEST_IDS; + public readonly ACCEPTED_FILES: string[] = ['png']; public readonly formGroup = this.formBuilder.group({ diff --git a/frontend/projects/valtimo/building-block-management/src/lib/components/building-block-management-create-modal/building-block-management-create-modal.component.html b/frontend/projects/valtimo/building-block-management/src/lib/components/building-block-management-create-modal/building-block-management-create-modal.component.html index 0a9a59ccf5..f2528ea488 100644 --- a/frontend/projects/valtimo/building-block-management/src/lib/components/building-block-management-create-modal/building-block-management-create-modal.component.html +++ b/frontend/projects/valtimo/building-block-management/src/lib/components/building-block-management-create-modal/building-block-management-create-modal.component.html @@ -47,6 +47,7 @@ <h3 cdsModalHeaderHeading> </span> <input + [attr.data-test-id]="testIds.nameInput" formControlName="name" cdsText placeholder="{{ 'buildingBlockManagement.createDefinition.namePlaceholder' | translate }}" @@ -80,6 +81,7 @@ <h3 cdsModalHeaderHeading> </span> <input + [attr.data-test-id]="testIds.versionInput" formControlName="versionTag" cdsText placeholder="{{ @@ -104,6 +106,7 @@ <h3 cdsModalHeaderHeading> </span> <textarea + [attr.data-test-id]="testIds.descriptionInput" cdsTextArea formControlName="description" placeholder="{{ @@ -117,11 +120,20 @@ <h3 cdsModalHeaderHeading> </form> <cds-modal-footer> - <button cdsButton="secondary" (click)="onCloseModal()"> + <button + [attr.data-test-id]="testIds.cancelButton" + cdsButton="secondary" + (click)="onCloseModal()" + > {{ 'interface.cancel' | translate }} </button> - <button cdsButton="primary" [disabled]="formGroup.invalid" (click)="onSave()"> + <button + [attr.data-test-id]="testIds.saveButton" + cdsButton="primary" + [disabled]="formGroup.invalid" + (click)="onSave()" + > {{ 'interface.save' | translate }} </button> </cds-modal-footer> diff --git a/frontend/projects/valtimo/building-block-management/src/lib/components/building-block-management-create-modal/building-block-management-create-modal.component.ts b/frontend/projects/valtimo/building-block-management/src/lib/components/building-block-management-create-modal/building-block-management-create-modal.component.ts index 7a502bd0e6..13a9e48fd6 100644 --- a/frontend/projects/valtimo/building-block-management/src/lib/components/building-block-management-create-modal/building-block-management-create-modal.component.ts +++ b/frontend/projects/valtimo/building-block-management/src/lib/components/building-block-management-create-modal/building-block-management-create-modal.component.ts @@ -41,7 +41,10 @@ import { import {BuildingBlockManagementApiService, BuildingBlockManagementService} from '../../services'; import {catchError, of} from 'rxjs'; import {Router} from '@angular/router'; -import {BUILDING_BLOCK_MANAGEMENT_TABS} from '../../constants'; +import { + BUILDING_BLOCK_MANAGEMENT_CREATE_TEST_IDS, + BUILDING_BLOCK_MANAGEMENT_TABS, +} from '../../constants'; @Component({ standalone: true, @@ -65,6 +68,8 @@ import {BUILDING_BLOCK_MANAGEMENT_TABS} from '../../constants'; ], }) export class BuildingBlockManagementCreateModalComponent { + protected readonly testIds = BUILDING_BLOCK_MANAGEMENT_CREATE_TEST_IDS; + public readonly showModal$ = this.buildingBlockManagementService.showCreateModal$; public readonly usedKeys$ = this.buildingBlockManagementService.usedKeys$; diff --git a/frontend/projects/valtimo/building-block-management/src/lib/components/building-block-management-detail-actions/building-block-management-detail-actions.component.html b/frontend/projects/valtimo/building-block-management/src/lib/components/building-block-management-detail-actions/building-block-management-detail-actions.component.html index 2db3bdbd88..e60f5603c6 100644 --- a/frontend/projects/valtimo/building-block-management/src/lib/components/building-block-management-detail-actions/building-block-management-detail-actions.component.html +++ b/frontend/projects/valtimo/building-block-management/src/lib/components/building-block-management-detail-actions/building-block-management-detail-actions.component.html @@ -30,12 +30,13 @@ [menuWidth]="250" placement="bottom-end" > - <button overflowTrigger cdsButton="tertiary"> + <button [attr.data-test-id]="testIds.moreButton" overflowTrigger cdsButton="tertiary"> {{ 'buildingBlockManagement.actions.more' | translate }} <svg class="cds--btn__icon" cdsIcon="overflow-menu--vertical" size="16"></svg> </button> <v-overflow-menu-option + [attr.data-test-id]="testIds.exportButton" optionId="exportBuildingBlock" (selected)="export()" [disabled]="(actionInProgress$ | async) || (exporting$ | async)" @@ -48,6 +49,7 @@ <v-overflow-menu-option *ngIf="!obs.isFinal" + [attr.data-test-id]="testIds.makeFinalButton" [disabled]="actionInProgress$ | async" (selected)="finalizeDraft()" > @@ -56,6 +58,7 @@ <v-overflow-menu-option *ngIf="obs.isFinal" + [attr.data-test-id]="testIds.createDraftButton" [disabled]="actionInProgress$ | async" (selected)="openDraftModal()" > @@ -98,6 +101,7 @@ <h3 cdsModalHeaderHeading> </span> <input + [attr.data-test-id]="testIds.draftVersionInput" formControlName="versionTag" cdsText placeholder="{{ 'buildingBlockManagement.actions.draft.placeholder' | translate }}" @@ -107,11 +111,16 @@ <h3 cdsModalHeaderHeading> </form> <cds-modal-footer> - <button cdsButton="secondary" (click)="closeDraftModal()"> + <button + [attr.data-test-id]="testIds.draftCancelButton" + cdsButton="secondary" + (click)="closeDraftModal()" + > {{ 'interface.cancel' | translate }} </button> <button + [attr.data-test-id]="testIds.draftConfirmButton" cdsButton="primary" [disabled]="draftForm.invalid || (actionInProgress$ | async)" (click)="createDraft()" diff --git a/frontend/projects/valtimo/building-block-management/src/lib/components/building-block-management-detail-actions/building-block-management-detail-actions.component.ts b/frontend/projects/valtimo/building-block-management/src/lib/components/building-block-management-detail-actions/building-block-management-detail-actions.component.ts index 10e160a8b3..8b53963525 100644 --- a/frontend/projects/valtimo/building-block-management/src/lib/components/building-block-management-detail-actions/building-block-management-detail-actions.component.ts +++ b/frontend/projects/valtimo/building-block-management/src/lib/components/building-block-management-detail-actions/building-block-management-detail-actions.component.ts @@ -41,6 +41,7 @@ import { BuildingBlockManagementDetailService, } from '../../services'; import {BuildingBlockManagementVersionSelectorComponent} from '../building-block-management-version-selector/building-block-management-version-selector.component'; +import {BUILDING_BLOCK_MANAGEMENT_DETAIL_ACTIONS_TEST_IDS} from '../../constants'; @Component({ standalone: true, @@ -65,6 +66,8 @@ import {BuildingBlockManagementVersionSelectorComponent} from '../building-block ], }) export class BuildingBlockManagementDetailActionsComponent { + protected readonly testIds = BUILDING_BLOCK_MANAGEMENT_DETAIL_ACTIONS_TEST_IDS; + @ViewChild('exportingMessage') private readonly _exportMessageTemplateRef: TemplateRef<HTMLDivElement>; diff --git a/frontend/projects/valtimo/building-block-management/src/lib/components/building-block-management-detail/building-block-management-detail.component.html b/frontend/projects/valtimo/building-block-management/src/lib/components/building-block-management-detail/building-block-management-detail.component.html index 928280742e..e0468b82d4 100644 --- a/frontend/projects/valtimo/building-block-management/src/lib/components/building-block-management-detail/building-block-management-detail.component.html +++ b/frontend/projects/valtimo/building-block-management/src/lib/components/building-block-management-detail/building-block-management-detail.component.html @@ -15,7 +15,7 @@ --> @if (activeTabKey$ | async; as activeTabKey) { - <cds-tabs type="line"> + <cds-tabs [attr.data-test-id]="testIds.tabs" type="line"> @if ({active: activeTabKey === BUILDING_BLOCK_MANAGEMENT_TABS.GENERAL}; as obs) { <cds-tab [active]="obs.active" diff --git a/frontend/projects/valtimo/building-block-management/src/lib/components/building-block-management-detail/building-block-management-detail.component.ts b/frontend/projects/valtimo/building-block-management/src/lib/components/building-block-management-detail/building-block-management-detail.component.ts index 94eb477098..8ff43ed19c 100644 --- a/frontend/projects/valtimo/building-block-management/src/lib/components/building-block-management-detail/building-block-management-detail.component.ts +++ b/frontend/projects/valtimo/building-block-management/src/lib/components/building-block-management-detail/building-block-management-detail.component.ts @@ -20,7 +20,10 @@ import {ButtonModule, DialogModule, IconModule, TabsModule} from 'carbon-compone import {ActivatedRoute} from '@angular/router'; import {BuildingBlockManagementDetailService} from '../../services'; import {TranslatePipe} from '@ngx-translate/core'; -import {BUILDING_BLOCK_MANAGEMENT_TABS} from '../../constants'; +import { + BUILDING_BLOCK_MANAGEMENT_DETAIL_TEST_IDS, + BUILDING_BLOCK_MANAGEMENT_TABS, +} from '../../constants'; import {BuildingBlockManagementGeneralComponent} from '../building-block-management-general/building-block-management-general.component'; import {BuildingBlockManagementDocumentComponent} from '../building-block-management-document/building-block-management-document.component'; import { @@ -61,6 +64,8 @@ import {BuildingBlockManagementDecisionsComponent} from '../building-block-manag }) export class BuildingBlockManagementDetailComponent implements OnInit, OnDestroy { public readonly BUILDING_BLOCK_MANAGEMENT_TABS = BUILDING_BLOCK_MANAGEMENT_TABS; + + protected readonly testIds = BUILDING_BLOCK_MANAGEMENT_DETAIL_TEST_IDS; public readonly activeTabKey$ = this.buildingBlockManagementDetailService.activeTabKey$; constructor( diff --git a/frontend/projects/valtimo/building-block-management/src/lib/components/building-block-management-list/building-block-management-list.component.html b/frontend/projects/valtimo/building-block-management/src/lib/components/building-block-management-list/building-block-management-list.component.html index 1590b4e871..aca3e3b02a 100644 --- a/frontend/projects/valtimo/building-block-management/src/lib/components/building-block-management-list/building-block-management-list.component.html +++ b/frontend/projects/valtimo/building-block-management/src/lib/components/building-block-management-list/building-block-management-list.component.html @@ -36,13 +36,21 @@ <ng-template #buildingBlockDefinitionActions> <div class="valtimo-building-block-management-list__actions"> - <button cdsButton="ghost" (click)="showUploadModal()"> + <button + [attr.data-test-id]="testIds.uploadButton" + cdsButton="ghost" + (click)="showUploadModal()" + > {{ 'Upload' | translate }} <svg cdsIcon="upload" size="16" class="cds--btn__icon"></svg> </button> - <button cdsButton="primary" (click)="showCreateModal()"> + <button + [attr.data-test-id]="testIds.createButton" + cdsButton="primary" + (click)="showCreateModal()" + > {{ 'interface.create' | translate }} <svg cdsIcon="add" size="16" class="cds--btn__icon"></svg> diff --git a/frontend/projects/valtimo/building-block-management/src/lib/components/building-block-management-list/building-block-management-list.component.ts b/frontend/projects/valtimo/building-block-management/src/lib/components/building-block-management-list/building-block-management-list.component.ts index 58042abda1..3f555c0fe0 100644 --- a/frontend/projects/valtimo/building-block-management/src/lib/components/building-block-management-list/building-block-management-list.component.ts +++ b/frontend/projects/valtimo/building-block-management/src/lib/components/building-block-management-list/building-block-management-list.component.ts @@ -26,7 +26,10 @@ import { import {BuildingBlockDefinitionDto} from '@valtimo/shared'; import {Upload16} from '@carbon/icons'; import {Router} from '@angular/router'; -import {BUILDING_BLOCK_MANAGEMENT_TABS} from '../../constants'; +import { + BUILDING_BLOCK_MANAGEMENT_LIST_TEST_IDS, + BUILDING_BLOCK_MANAGEMENT_TABS, +} from '../../constants'; import { BuildingBlockManagementUploadModalComponent, } from '../building-block-management-upload-modal/building-block-management-upload-modal.component'; @@ -48,6 +51,8 @@ import { providers: [BuildingBlockManagementService], }) export class BuildingBlockManagementListComponent { + protected readonly testIds = BUILDING_BLOCK_MANAGEMENT_LIST_TEST_IDS; + public readonly $loading = signal<boolean>(true); public readonly buildingBlockDefinitions$ = this.buildingBlockManagementService.reload$.pipe( diff --git a/frontend/projects/valtimo/building-block-management/src/lib/components/building-block-management-metadata/building-block-management-metadata.component.html b/frontend/projects/valtimo/building-block-management/src/lib/components/building-block-management-metadata/building-block-management-metadata.component.html index 7bb697f6fb..8355ed68f4 100644 --- a/frontend/projects/valtimo/building-block-management/src/lib/components/building-block-management-metadata/building-block-management-metadata.component.html +++ b/frontend/projects/valtimo/building-block-management/src/lib/components/building-block-management-metadata/building-block-management-metadata.component.html @@ -31,6 +31,7 @@ <h4 class="building-block-management-metadata__title"> </span> <input + [attr.data-test-id]="testIds.nameInput" formControlName="name" cdsText placeholder="{{ 'buildingBlockManagement.createDefinition.namePlaceholder' | translate }}" @@ -44,7 +45,13 @@ <h4 class="building-block-management-metadata__title"> {{ 'buildingBlockManagement.createDefinition.keyTitle' | translate }} </span> - <input formControlName="key" cdsText valtimoReadOnly [isReadOnly]="true" /> + <input + [attr.data-test-id]="testIds.keyInput" + formControlName="key" + cdsText + valtimoReadOnly + [isReadOnly]="true" + /> </cds-label> </div> @@ -63,6 +70,7 @@ <h4 class="building-block-management-metadata__title"> </span> <textarea + [attr.data-test-id]="testIds.descriptionInput" cdsTextArea formControlName="description" placeholder="{{ @@ -76,6 +84,7 @@ <h4 class="building-block-management-metadata__title"> <div class="building-block-management-metadata__buttons"> <button + [attr.data-test-id]="testIds.saveButton" cdsButton="primary" [disabled]="formGroup.disabled || formGroup.invalid || formGroup.pristine" (click)="onSave()" diff --git a/frontend/projects/valtimo/building-block-management/src/lib/components/building-block-management-metadata/building-block-management-metadata.component.ts b/frontend/projects/valtimo/building-block-management/src/lib/components/building-block-management-metadata/building-block-management-metadata.component.ts index 94541afd02..eed02f7523 100644 --- a/frontend/projects/valtimo/building-block-management/src/lib/components/building-block-management-metadata/building-block-management-metadata.component.ts +++ b/frontend/projects/valtimo/building-block-management/src/lib/components/building-block-management-metadata/building-block-management-metadata.component.ts @@ -24,6 +24,7 @@ import {FormBuilder, FormControl, FormGroup, ReactiveFormsModule, Validators} fr import {ReadOnlyDirective, TooltipIconModule} from '@valtimo/components'; import {ButtonModule, InputModule, LayerModule} from 'carbon-components-angular'; import {combineLatest, Subscription} from 'rxjs'; +import {BUILDING_BLOCK_MANAGEMENT_METADATA_TEST_IDS} from '../../constants'; @Component({ standalone: true, @@ -42,6 +43,8 @@ import {combineLatest, Subscription} from 'rxjs'; ], }) export class BuildingBlockManagementMetadataComponent implements OnInit, OnDestroy { + protected readonly testIds = BUILDING_BLOCK_MANAGEMENT_METADATA_TEST_IDS; + public formGroup: FormGroup = this.fb.group({ name: this.fb.control('', Validators.required), key: this.fb.control('', Validators.required), diff --git a/frontend/projects/valtimo/building-block-management/src/lib/components/building-block-management-plugins/building-block-management-plugins.component.html b/frontend/projects/valtimo/building-block-management/src/lib/components/building-block-management-plugins/building-block-management-plugins.component.html index 8919fb7e7e..8890b355e3 100644 --- a/frontend/projects/valtimo/building-block-management/src/lib/components/building-block-management-plugins/building-block-management-plugins.component.html +++ b/frontend/projects/valtimo/building-block-management/src/lib/components/building-block-management-plugins/building-block-management-plugins.component.html @@ -27,7 +27,7 @@ <h4 class="building-block-management-plugins__title"> } @if (!obs.loading && obs.usedPluginTitles.length > 0) { - <div class="building-block-management-plugins__tags"> + <div [attr.data-test-id]="testIds.usedPlugins" class="building-block-management-plugins__tags"> @for (pluginTitle of obs.usedPluginTitles; track pluginTitle) { <cds-tag type="blue">{{ pluginTitle }}</cds-tag> } @@ -35,7 +35,10 @@ <h4 class="building-block-management-plugins__title"> } @if (!obs.loading && obs.usedPluginTitles.length === 0) { - <p class="building-block-management-plugins__description cds--label-description"> + <p + [attr.data-test-id]="testIds.noPluginsUsed" + class="building-block-management-plugins__description cds--label-description" + > {{ 'buildingBlockManagement.plugins.noPluginsUsed' | translate }} </p> } diff --git a/frontend/projects/valtimo/building-block-management/src/lib/components/building-block-management-plugins/building-block-management-plugins.component.ts b/frontend/projects/valtimo/building-block-management/src/lib/components/building-block-management-plugins/building-block-management-plugins.component.ts index c86893a3d6..58227df9b5 100644 --- a/frontend/projects/valtimo/building-block-management/src/lib/components/building-block-management-plugins/building-block-management-plugins.component.ts +++ b/frontend/projects/valtimo/building-block-management/src/lib/components/building-block-management-plugins/building-block-management-plugins.component.ts @@ -23,6 +23,7 @@ import {BehaviorSubject, combineLatest, map, Observable, tap} from 'rxjs'; import {PluginManagementService} from '@valtimo/plugin'; import {LoadingModule, TagModule} from 'carbon-components-angular'; import {TranslatePipe} from '@ngx-translate/core'; +import {BUILDING_BLOCK_MANAGEMENT_PLUGINS_TEST_IDS} from '../../constants'; @Component({ standalone: true, @@ -32,6 +33,8 @@ import {TranslatePipe} from '@ngx-translate/core'; imports: [CommonModule, LoadingModule, TranslatePipe, TagModule], }) export class BuildingBlockManagementPluginsComponent { + protected readonly testIds = BUILDING_BLOCK_MANAGEMENT_PLUGINS_TEST_IDS; + public readonly loading$ = new BehaviorSubject<boolean>(true); public readonly usedPluginTitles$: Observable<string[]> = combineLatest([ diff --git a/frontend/projects/valtimo/building-block-management/src/lib/components/building-block-management-process-upload/building-block-management-process-upload.component.html b/frontend/projects/valtimo/building-block-management/src/lib/components/building-block-management-process-upload/building-block-management-process-upload.component.html index 1e5e4095b7..7a38a4e248 100644 --- a/frontend/projects/valtimo/building-block-management/src/lib/components/building-block-management-process-upload/building-block-management-process-upload.component.html +++ b/frontend/projects/valtimo/building-block-management/src/lib/components/building-block-management-process-upload/building-block-management-process-upload.component.html @@ -21,6 +21,7 @@ <h3 cdsModalHeaderHeading>{{ 'processManagement.upload.modalTitle' | translate } <section cdsModalContent [cdsLayer]="1" [formGroup]="form"> <cds-file-uploader + [attr.data-test-id]="testIds.fileUploader" [accept]="ACCEPTED_FILES" [buttonText]="'processManagement.upload.buttonText' | translate" [description]="'processManagement.upload.description' | translate" @@ -33,12 +34,13 @@ <h3 cdsModalHeaderHeading>{{ 'processManagement.upload.modalTitle' | translate } </section> <cds-modal-footer> - <button cdsButton="secondary" (click)="closeModal()"> + <button cdsButton="secondary" [attr.data-test-id]="testIds.cancelButton" (click)="closeModal()"> {{ 'interface.cancel' | translate }} </button> <button cdsButton="primary" + [attr.data-test-id]="testIds.submitButton" [disabled]="(fileSelected$ | async) === false" (click)="uploadProcessBpmn()" > diff --git a/frontend/projects/valtimo/building-block-management/src/lib/components/building-block-management-process-upload/building-block-management-process-upload.component.ts b/frontend/projects/valtimo/building-block-management/src/lib/components/building-block-management-process-upload/building-block-management-process-upload.component.ts index def66e6dbc..11218bdbd6 100644 --- a/frontend/projects/valtimo/building-block-management/src/lib/components/building-block-management-process-upload/building-block-management-process-upload.component.ts +++ b/frontend/projects/valtimo/building-block-management/src/lib/components/building-block-management-process-upload/building-block-management-process-upload.component.ts @@ -32,6 +32,7 @@ import { ModalModule, } from 'carbon-components-angular'; import {BehaviorSubject, from, map, startWith, switchMap} from 'rxjs'; +import {BUILDING_BLOCK_MANAGEMENT_PROCESS_UPLOAD_TEST_IDS} from '../../constants'; import {BuildingBlockManagementDetailService} from '../../services'; import {BuildingBlockProcessDefinitionConflictResponse, ProcessLinkService} from '@valtimo/process-link'; @@ -54,6 +55,8 @@ import {BuildingBlockProcessDefinitionConflictResponse, ProcessLinkService} from ], }) export class BuildingBlockManagementProcessUploadComponent { + protected readonly testIds = BUILDING_BLOCK_MANAGEMENT_PROCESS_UPLOAD_TEST_IDS; + public readonly modalOpen$ = this.buildingBlockManagementDetailService.showProcessDefinitionUploadModal$; public readonly showReplaceConfirmationModal$ = new BehaviorSubject<boolean>(false); diff --git a/frontend/projects/valtimo/building-block-management/src/lib/components/building-block-management-processes/building-block-management-processes.component.html b/frontend/projects/valtimo/building-block-management/src/lib/components/building-block-management-processes/building-block-management-processes.component.html index 10e7cb7172..39aaf819c2 100644 --- a/frontend/projects/valtimo/building-block-management/src/lib/components/building-block-management-processes/building-block-management-processes.component.html +++ b/frontend/projects/valtimo/building-block-management/src/lib/components/building-block-management-processes/building-block-management-processes.component.html @@ -25,6 +25,7 @@ <div class="valtimo-building-block-management-list__actions"> <button cdsButton="ghost" + [attr.data-test-id]="testIds.uploadButton" (click)="showUploadModal()" [disabled]="(isFinal$ | async) || $loading()" > @@ -35,6 +36,7 @@ <button cdsButton="primary" + [attr.data-test-id]="testIds.createButton" (click)="onCreateClick()" [disabled]="(isFinal$ | async) || $loading()" > diff --git a/frontend/projects/valtimo/building-block-management/src/lib/components/building-block-management-processes/building-block-management-processes.component.ts b/frontend/projects/valtimo/building-block-management/src/lib/components/building-block-management-processes/building-block-management-processes.component.ts index f3200dcbb6..793e62a571 100644 --- a/frontend/projects/valtimo/building-block-management/src/lib/components/building-block-management-processes/building-block-management-processes.component.ts +++ b/frontend/projects/valtimo/building-block-management/src/lib/components/building-block-management-processes/building-block-management-processes.component.ts @@ -42,7 +42,10 @@ import {TranslateModule, TranslateService} from '@ngx-translate/core'; import {BuildingBlockProcessDefinitionItem} from '../../models'; import {ButtonModule, IconModule, IconService} from 'carbon-components-angular'; import {Upload16} from '@carbon/icons'; -import {BUILDING_BLOCK_MANAGEMENT_TABS} from '../../constants'; +import { + BUILDING_BLOCK_MANAGEMENT_PROCESSES_TEST_IDS, + BUILDING_BLOCK_MANAGEMENT_TABS, +} from '../../constants'; import {Router} from '@angular/router'; import {BuildingBlockManagementProcessUploadComponent} from '../building-block-management-process-upload/building-block-management-process-upload.component'; @@ -62,6 +65,8 @@ import {BuildingBlockManagementProcessUploadComponent} from '../building-block-m ], }) export class BuildingBlockManagementProcessesComponent implements OnInit, OnDestroy { + protected readonly testIds = BUILDING_BLOCK_MANAGEMENT_PROCESSES_TEST_IDS; + public readonly $loading = signal<boolean>(true); private readonly _buildingBlockProcessDefinitions$ = new BehaviorSubject< diff --git a/frontend/projects/valtimo/building-block-management/src/lib/components/building-block-management-upload-modal/building-block-management-upload-modal.component.html b/frontend/projects/valtimo/building-block-management/src/lib/components/building-block-management-upload-modal/building-block-management-upload-modal.component.html index ff8786247f..c934820796 100644 --- a/frontend/projects/valtimo/building-block-management/src/lib/components/building-block-management-upload-modal/building-block-management-upload-modal.component.html +++ b/frontend/projects/valtimo/building-block-management/src/lib/components/building-block-management-upload-modal/building-block-management-upload-modal.component.html @@ -58,6 +58,7 @@ <h3 cdsModalHeaderHeading> <form *ngSwitchCase="UPLOAD_STEP.FILE_SELECT" [formGroup]="form"> <cds-file-uploader + [attr.data-test-id]="testIds.fileUploader" [accept]="acceptedFiles" [buttonText]="'buildingBlockManagement.importDefinition.upload.buttonText' | translate" [description]="'buildingBlockManagement.importDefinition.upload.description' | translate" @@ -70,10 +71,14 @@ <h3 cdsModalHeaderHeading> </cds-file-uploader> <ng-container *ngIf="obs.activeStep === UPLOAD_STEP.FILE_SELECT"> - <cds-inline-notification [notificationObj]="obs.notificationObj"></cds-inline-notification> + <cds-inline-notification + [attr.data-test-id]="testIds.overwriteWarning" + [notificationObj]="obs.notificationObj" + ></cds-inline-notification> <div class="valtimo-uploader__checkbox"> <cds-checkbox + [attr.data-test-id]="testIds.overwriteCheckbox" [class.valtimo-definition-uploader__checkbox--invalid]="obs.showCheckboxError" (checkedChange)="onCheckedChange($event)" > @@ -90,6 +95,7 @@ <h3 cdsModalHeaderHeading> <cds-progress-bar *ngSwitchCase="UPLOAD_STEP.FILE_UPLOAD" + [attr.data-test-id]="testIds.progressBar" class="valtimo-definition-uploader__progress-bar" [label]="'buildingBlockManagement.importDefinition.progressBar.label' | translate" [status]="obs.uploadStatus" @@ -105,6 +111,7 @@ <h3 cdsModalHeaderHeading> > <button *ngIf="obs.uploadStatus !== UPLOAD_STATUS.ERROR && obs.activeStep !== UPLOAD_STEP.FILE_UPLOAD" + [attr.data-test-id]="testIds.cancelButton" class="valtimo-definition-uploader__cancel" cdsButton="ghost" (click)="onCloseModal(obs.isStepAfterUpload)" @@ -115,6 +122,7 @@ <h3 cdsModalHeaderHeading> <div class="valtimo-definition-uploader__actions"> <button *ngIf="obs.backButtonEnabled" + [attr.data-test-id]="testIds.backButton" cdsButton="secondary" (click)="onBackClick(obs.activeStep)" > @@ -125,6 +133,7 @@ <h3 cdsModalHeaderHeading> *ngIf=" obs.uploadStatus !== UPLOAD_STATUS.ERROR && obs.activeStep !== UPLOAD_STEP.FILE_UPLOAD " + [attr.data-test-id]="testIds.nextButton" cdsButton="primary" [disabled]="obs.nextButtonDisabled" (click)="onNextClick(obs.activeStep)" @@ -141,6 +150,7 @@ <h3 cdsModalHeaderHeading> *ngIf=" obs.uploadStatus === UPLOAD_STATUS.ERROR || obs.activeStep === UPLOAD_STEP.FILE_UPLOAD " + [attr.data-test-id]="testIds.finishButton" class="valtimo-definition-uploader__cancel" cdsButton="primary" (click)="onCloseModal(true)" diff --git a/frontend/projects/valtimo/building-block-management/src/lib/components/building-block-management-upload-modal/building-block-management-upload-modal.component.ts b/frontend/projects/valtimo/building-block-management/src/lib/components/building-block-management-upload-modal/building-block-management-upload-modal.component.ts index cc13801079..7c143b195a 100644 --- a/frontend/projects/valtimo/building-block-management/src/lib/components/building-block-management-upload-modal/building-block-management-upload-modal.component.ts +++ b/frontend/projects/valtimo/building-block-management/src/lib/components/building-block-management-upload-modal/building-block-management-upload-modal.component.ts @@ -40,7 +40,12 @@ import { ProgressBarModule, } from 'carbon-components-angular'; import {BehaviorSubject, combineLatest, map, Observable, Subscription, switchMap, take} from 'rxjs'; -import {STEPS, UPLOAD_STATUS, UPLOAD_STEP} from '../../constants'; +import { + BUILDING_BLOCK_MANAGEMENT_UPLOAD_TEST_IDS, + STEPS, + UPLOAD_STATUS, + UPLOAD_STEP, +} from '../../constants'; import {BuildingBlockManagementApiService, BuildingBlockManagementService} from '../../services'; import {CommonModule} from '@angular/common'; import {BuildingBlockManagementUploadStepComponent} from './step/building-block-management-upload-step.component'; @@ -67,6 +72,8 @@ import {toObservable} from '@angular/core/rxjs-interop'; ], }) export class BuildingBlockManagementUploadModalComponent implements OnInit, OnDestroy { + protected readonly testIds = BUILDING_BLOCK_MANAGEMENT_UPLOAD_TEST_IDS; + public readonly acceptedFiles: string[] = ['.zip']; public readonly showUploadModal$ = this.buildingBlockManagementService.showUploadModal$; diff --git a/frontend/projects/valtimo/building-block-management/src/lib/components/building-block-management-version-selector/building-block-management-version-selector.component.html b/frontend/projects/valtimo/building-block-management/src/lib/components/building-block-management-version-selector/building-block-management-version-selector.component.html index 76f69f9414..efb19ff749 100644 --- a/frontend/projects/valtimo/building-block-management/src/lib/components/building-block-management-version-selector/building-block-management-version-selector.component.html +++ b/frontend/projects/valtimo/building-block-management/src/lib/components/building-block-management-version-selector/building-block-management-version-selector.component.html @@ -17,6 +17,7 @@ <ng-container *ngIf="{versionListItems: (versionListItems$ | async) || []} as obs"> <cds-dropdown class="version-selector" + [attr.data-test-id]="testIds.versionSelectDropdown" [disabled]="obs.versionListItems.length === 1" [displayValue]="versionTemplate" (selected)="onVersionSelected($event)" @@ -29,7 +30,7 @@ </ng-container> <ng-template #versionTemplate let-item="item"> - <div class="version-selector-dropdown-template"> + <div [attr.data-test-id]="item?.['data-test-id']" class="version-selector-dropdown-template"> <cds-tag class="cds-tag--no-margin" [type]="!item?.final ? 'red' : 'green'" diff --git a/frontend/projects/valtimo/building-block-management/src/lib/components/building-block-management-version-selector/building-block-management-version-selector.component.ts b/frontend/projects/valtimo/building-block-management/src/lib/components/building-block-management-version-selector/building-block-management-version-selector.component.ts index 300dd899c1..f364fbe73f 100644 --- a/frontend/projects/valtimo/building-block-management/src/lib/components/building-block-management-version-selector/building-block-management-version-selector.component.ts +++ b/frontend/projects/valtimo/building-block-management/src/lib/components/building-block-management-version-selector/building-block-management-version-selector.component.ts @@ -21,6 +21,10 @@ import { } from '../../services'; import {combineLatest, map, Observable, switchMap} from 'rxjs'; import {DropdownModule, ListItem, TagModule} from 'carbon-components-angular'; +import { + BUILDING_BLOCK_MANAGEMENT_DETAIL_ACTIONS_TEST_IDS, + BUILDING_BLOCK_VERSION_OPTION_TEST_ID_PREFIX, +} from '../../constants'; @Component({ standalone: true, @@ -30,6 +34,8 @@ import {DropdownModule, ListItem, TagModule} from 'carbon-components-angular'; imports: [CommonModule, DropdownModule, TagModule], }) export class BuildingBlockManagementVersionSelectorComponent { + protected readonly testIds = BUILDING_BLOCK_MANAGEMENT_DETAIL_ACTIONS_TEST_IDS; + private readonly _versions$ = combineLatest([ this.buildingBlockManagementDetailService.buildingBlockDefinitionKey$, this.buildingBlockManagementDetailService.reloadVersions$, @@ -49,6 +55,7 @@ export class BuildingBlockManagementVersionSelectorComponent { content: version.versionTag, selected: versionTag === version.versionTag, final: version.final, + 'data-test-id': `${BUILDING_BLOCK_VERSION_OPTION_TEST_ID_PREFIX}${version.versionTag}`, })) ) ); diff --git a/frontend/projects/valtimo/building-block-management/src/lib/constants/building-block-management.test-ids.ts b/frontend/projects/valtimo/building-block-management/src/lib/constants/building-block-management.test-ids.ts new file mode 100644 index 0000000000..6cdfbb3874 --- /dev/null +++ b/frontend/projects/valtimo/building-block-management/src/lib/constants/building-block-management.test-ids.ts @@ -0,0 +1,90 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export const BUILDING_BLOCK_MANAGEMENT_LIST_TEST_IDS = { + uploadButton: 'buildingBlockUploadButton', + createButton: 'buildingBlockCreateButton', +} as const; + +export const BUILDING_BLOCK_MANAGEMENT_CREATE_TEST_IDS = { + nameInput: 'buildingBlockNameInput', + versionInput: 'buildingBlockVersionInput', + descriptionInput: 'buildingBlockDescriptionInput', + cancelButton: 'buildingBlockCreateCancelButton', + saveButton: 'buildingBlockCreateSaveButton', +} as const; + +export const BUILDING_BLOCK_MANAGEMENT_UPLOAD_TEST_IDS = { + fileUploader: 'buildingBlockFileUploader', + overwriteWarning: 'buildingBlockOverwriteWarning', + overwriteCheckbox: 'buildingBlockOverwriteCheckbox', + progressBar: 'buildingBlockUploadProgressBar', + cancelButton: 'buildingBlockUploadCancelButton', + backButton: 'buildingBlockUploadBackButton', + nextButton: 'buildingBlockUploadNextButton', + finishButton: 'buildingBlockUploadFinishButton', +} as const; + +export const BUILDING_BLOCK_MANAGEMENT_DETAIL_TEST_IDS = { + tabs: 'buildingBlockTabs', +} as const; + +export const BUILDING_BLOCK_MANAGEMENT_PROCESSES_TEST_IDS = { + uploadButton: 'buildingBlockProcessesUploadButton', + createButton: 'buildingBlockProcessesCreateButton', +} as const; + +export const BUILDING_BLOCK_MANAGEMENT_PROCESS_UPLOAD_TEST_IDS = { + fileUploader: 'buildingBlockProcessUploadFileUploader', + cancelButton: 'buildingBlockProcessUploadCancelButton', + submitButton: 'buildingBlockProcessUploadSubmitButton', +} as const; + +export const BUILDING_BLOCK_MANAGEMENT_METADATA_TEST_IDS = { + nameInput: 'buildingBlockMetadataNameInput', + keyInput: 'buildingBlockMetadataKeyInput', + descriptionInput: 'buildingBlockMetadataDescriptionInput', + saveButton: 'buildingBlockMetadataSaveButton', +} as const; + +export const BUILDING_BLOCK_MANAGEMENT_ARTWORK_TEST_IDS = { + fileUploader: 'buildingBlockArtworkFileUploader', + image: 'buildingBlockArtworkImage', + uploadButton: 'buildingBlockArtworkUploadButton', + deleteButton: 'buildingBlockArtworkDeleteButton', +} as const; + +export const BUILDING_BLOCK_MANAGEMENT_PLUGINS_TEST_IDS = { + usedPlugins: 'buildingBlockUsedPlugins', + noPluginsUsed: 'buildingBlockNoPluginsUsed', +} as const; + +export const BUILDING_BLOCK_MANAGEMENT_DETAIL_ACTIONS_TEST_IDS = { + versionSelectDropdown: 'buildingBlockVersionSelectDropdown', + moreButton: 'buildingBlockMoreButton', + exportButton: 'buildingBlockExportButton', + makeFinalButton: 'buildingBlockMakeFinalButton', + createDraftButton: 'buildingBlockCreateDraftButton', + draftVersionInput: 'buildingBlockDraftVersionInput', + draftCancelButton: 'buildingBlockDraftCancelButton', + draftConfirmButton: 'buildingBlockDraftConfirmButton', +} as const; + +/** + * Prefix for the per-version options of the version dropdown. The full test id is + * `buildingBlockVersion-<versionTag>`, built in the version selector component. + */ +export const BUILDING_BLOCK_VERSION_OPTION_TEST_ID_PREFIX = 'buildingBlockVersion-'; diff --git a/frontend/projects/valtimo/building-block-management/src/lib/constants/index.ts b/frontend/projects/valtimo/building-block-management/src/lib/constants/index.ts index 174e65ffa9..62ea7e231e 100644 --- a/frontend/projects/valtimo/building-block-management/src/lib/constants/index.ts +++ b/frontend/projects/valtimo/building-block-management/src/lib/constants/index.ts @@ -16,3 +16,4 @@ export * from './building-block-management.constants'; export * from './building-block-management-upload.constants'; +export * from './building-block-management.test-ids'; diff --git a/frontend/projects/valtimo/building-block-management/src/public-api.ts b/frontend/projects/valtimo/building-block-management/src/public-api.ts index 2d22c219ed..d13f95717a 100644 --- a/frontend/projects/valtimo/building-block-management/src/public-api.ts +++ b/frontend/projects/valtimo/building-block-management/src/public-api.ts @@ -19,4 +19,5 @@ */ export * from './lib/building-block-management.module'; +export * from './lib/constants'; export * from './lib/services'; diff --git a/frontend/projects/valtimo/components/src/lib/components/confirmation-modal/confirmation-modal.component.html b/frontend/projects/valtimo/components/src/lib/components/confirmation-modal/confirmation-modal.component.html index b987699f46..08021b27e2 100644 --- a/frontend/projects/valtimo/components/src/lib/components/confirmation-modal/confirmation-modal.component.html +++ b/frontend/projects/valtimo/components/src/lib/components/confirmation-modal/confirmation-modal.component.html @@ -30,7 +30,7 @@ (close)="closeModal()" > <cds-modal-header [showCloseButton]="true" (closeSelect)="closeModal()"> - <h3 cdsModalHeaderHeading> + <h3 cdsModalHeaderHeading [attr.data-test-id]="testIds.heading"> {{ (titleTranslationKey !== obs.titleTranslation ? obs.titleTranslation : '') || title || @@ -39,7 +39,7 @@ <h3 cdsModalHeaderHeading> </h3> </cds-modal-header> - <section cdsModalContent> + <section cdsModalContent [attr.data-test-id]="testIds.content"> {{ (contentTranslationKey !== obs.contentTranslation ? obs.contentTranslation : '') || content || diff --git a/frontend/projects/valtimo/components/src/lib/components/multi-input/carbon-multi-input.component.html b/frontend/projects/valtimo/components/src/lib/components/multi-input/carbon-multi-input.component.html index 6937e4c41a..0738f75e27 100644 --- a/frontend/projects/valtimo/components/src/lib/components/multi-input/carbon-multi-input.component.html +++ b/frontend/projects/valtimo/components/src/lib/components/multi-input/carbon-multi-input.component.html @@ -166,7 +166,7 @@ size="md" (click)="deleteRow(value.uuid)" [title]="vars.deleteRowText" - [attr.data-test-id]="'multiInputDeleteButton-' + i" + [attr.data-test-id]="testIds.deleteButtonAt(i)" > <svg cdsIcon="trash-can" size="16"></svg> </button> @@ -179,7 +179,7 @@ size="md" [disabled]="(addRowEnabled$ | async) === false || $disabled()" (click)="addRow()" - [attr.data-test-id]="'multiInputAddButton-' + i" + [attr.data-test-id]="name ? testIds.addButtonNamed(name) : testIds.addButton" > {{ vars.addRowText }} diff --git a/frontend/projects/valtimo/components/src/lib/components/multi-input/carbon-multi-input.component.ts b/frontend/projects/valtimo/components/src/lib/components/multi-input/carbon-multi-input.component.ts index ad0ad45541..f495b4ee6f 100644 --- a/frontend/projects/valtimo/components/src/lib/components/multi-input/carbon-multi-input.component.ts +++ b/frontend/projects/valtimo/components/src/lib/components/multi-input/carbon-multi-input.component.ts @@ -40,6 +40,7 @@ import {BehaviorSubject, combineLatest, Observable, Subscription} from 'rxjs'; import {map, take} from 'rxjs/operators'; import {v4 as uuidv4} from 'uuid'; import {ControlValueAccessor, NG_VALUE_ACCESSOR} from '@angular/forms'; +import {MULTI_INPUT_TEST_IDS} from '../../constants'; @Component({ selector: 'valtimo-carbon-multi-input', @@ -109,6 +110,8 @@ export class CarbonMultiInputComponent implements ControlValueAccessor, OnInit, @Input() public readonly valueColumnFlex = 1; @Output() public valueChange: EventEmitter<MultiInputOutput> = new EventEmitter(); + + public readonly testIds = MULTI_INPUT_TEST_IDS; @Output() public allValuesValidEvent: EventEmitter<boolean> = new EventEmitter(); private _amountOfArbitraryValues!: number; diff --git a/frontend/projects/valtimo/components/src/lib/components/multi-input/components/arbitrary-amount-value/arbitrary-amount-value.component.html b/frontend/projects/valtimo/components/src/lib/components/multi-input/components/arbitrary-amount-value/arbitrary-amount-value.component.html index 28ce91d3c5..ae8bfbe447 100644 --- a/frontend/projects/valtimo/components/src/lib/components/multi-input/components/arbitrary-amount-value/arbitrary-amount-value.component.html +++ b/frontend/projects/valtimo/components/src/lib/components/multi-input/components/arbitrary-amount-value/arbitrary-amount-value.component.html @@ -25,7 +25,7 @@ [defaultValue]="value ? value['' + i] : ''" [disabled]="disabled" (input)="onInputChange(i, $event.target.value)" - [attr.data-test-id]="testIds.input" + [attr.data-test-id]="testIds.inputAt(index, i)" /> </div> diff --git a/frontend/projects/valtimo/components/src/lib/components/schema-editor/schema-editor.component.html b/frontend/projects/valtimo/components/src/lib/components/schema-editor/schema-editor.component.html index 6810ce2e98..a53e6967dd 100644 --- a/frontend/projects/valtimo/components/src/lib/components/schema-editor/schema-editor.component.html +++ b/frontend/projects/valtimo/components/src/lib/components/schema-editor/schema-editor.component.html @@ -13,17 +13,22 @@ ~ limitations under the License. --> -<div class="schema-editor"> +<div [attr.data-test-id]="testIds.editor" class="schema-editor"> <div class="cds--toolbar-content"> <ng-content></ng-content> - <button cdsButton="ghost" (click)="onRequiredPanelToggle()"> + <button + [attr.data-test-id]="testIds.manageRequiredFieldsButton" + cdsButton="ghost" + (click)="onRequiredPanelToggle()" + > {{ 'schemaEditor.manageRequiredFields' | translate }} <svg class="cds--btn__icon" cdsIcon="document--requirements" size="16"></svg> </button> <button + [attr.data-test-id]="testIds.saveButton" cdsButton="primary" [disabled]="disabled || (valid$ | async) === false" (click)="onSaveClick()" @@ -58,7 +63,11 @@ </valtimo-confirmation-modal> <ng-template #requiredPropertiesPanel> - <div class="schema-editor__required-properties" (click)="$event.stopPropagation()"> + <div + [attr.data-test-id]="testIds.requiredFieldsPanel" + class="schema-editor__required-properties" + (click)="$event.stopPropagation()" + > <header class="cds--modal-header default schema-editor__required-properties-header"> <h5> {{ 'schemaEditor.requiredFields' | translate }} @@ -66,6 +75,7 @@ <h5> <div class="cds--modal-close-button"> <button + [attr.data-test-id]="testIds.requiredFieldsPanelCloseButton" cdsButton="primary" class="cds--modal-close cds--btn--icon-only cds--btn--lg" type="button" @@ -94,6 +104,7 @@ <h5> @for (property of objectLevel.properties; track property) { <div class="schema-editor__property"> <cds-checkbox + [attr.data-test-id]="requiredPropertyTestId(objectLevel.path, property)" [disabled]="disabled" [checked]="isRequired$(objectLevel.path, property) | async" (checkedChange)="onRequiredPropertyChange(objectLevel.path, property, $event)" diff --git a/frontend/projects/valtimo/components/src/lib/components/schema-editor/schema-editor.component.ts b/frontend/projects/valtimo/components/src/lib/components/schema-editor/schema-editor.component.ts index 3546e930a4..d45661a255 100644 --- a/frontend/projects/valtimo/components/src/lib/components/schema-editor/schema-editor.component.ts +++ b/frontend/projects/valtimo/components/src/lib/components/schema-editor/schema-editor.component.ts @@ -48,6 +48,10 @@ import {ConfirmationModalModule} from '../confirmation-modal/confirmation-modal. import {ObjectLevel} from '../../models'; import {collectObjectLevels, setRequiredOnSchema} from '../../utils'; import {DocumentRequirements16} from '@carbon/icons'; +import { + SCHEMA_EDITOR_REQUIRED_PROPERTY_TEST_ID_PREFIX, + SCHEMA_EDITOR_TEST_IDS, +} from '../../constants'; @Component({ selector: 'valtimo-schema-editor', @@ -69,6 +73,8 @@ import {DocumentRequirements16} from '@carbon/icons'; changeDetection: ChangeDetectionStrategy.OnPush, }) export class SchemaEditorComponent implements AfterViewInit, OnChanges, OnDestroy { + protected readonly testIds = SCHEMA_EDITOR_TEST_IDS; + @ViewChild('host', {static: true}) public readonly hostEl!: ElementRef<HTMLDivElement>; @Input() public schemaJson = '{ "type": "object", "properties": {} }'; @@ -223,6 +229,14 @@ export class SchemaEditorComponent implements AfterViewInit, OnChanges, OnDestro this.showRequiredPanel$.next(!this.showRequiredPanel$.getValue()); } + /** + * Test id for a required-field checkbox, keyed on the property's full path so + * that a property name repeated at another object level stays unique. + */ + protected requiredPropertyTestId(path: string[], property: string): string { + return `${SCHEMA_EDITOR_REQUIRED_PROPERTY_TEST_ID_PREFIX}${[...(path ?? []), property].join('.')}`; + } + private setObjectLevels(schema: string): void { this._objectLevels$.next(collectObjectLevels(JSON.parse(schema))); } diff --git a/frontend/projects/valtimo/components/src/lib/components/search-fields/search-fields.component.html b/frontend/projects/valtimo/components/src/lib/components/search-fields/search-fields.component.html index 345d60d973..f35e3d6fc9 100644 --- a/frontend/projects/valtimo/components/src/lib/components/search-fields/search-fields.component.html +++ b/frontend/projects/valtimo/components/src/lib/components/search-fields/search-fields.component.html @@ -20,6 +20,7 @@ <ng-container *ngIf="!loading"> <cds-accordion> <cds-accordion-item + [attr.data-test-id]="testIds.accordionItem" [title]="'searchFields.searchButtonText' | translate" (selected)="toggleExpanded()" [expanded]="expanded$ | async" @@ -226,6 +227,7 @@ <v-input *ngIf="searchField.dataType === 'text' && searchField.fieldType === 'single'" [dataTestId]="'search-field-' + searchField.key" + [attr.data-test-id]="searchFieldTestIdPrefix + searchField.key" [name]="searchField.key" [title]="title" [fullWidth]="true" @@ -609,6 +611,7 @@ *ngIf="obs.expanded" cdsButton="tertiary" data-testid="search-fields-clear-button" + [attr.data-test-id]="testIds.clearButton" (click)="clear()" [disabled]="loading || noSearchFields || inputDisabled" type="button" @@ -623,6 +626,7 @@ *ngIf="obs.expanded && canSaveSearch" cdsButton="secondary" data-testid="search-fields-save-button" + [attr.data-test-id]="testIds.saveButton" [disabled]="disableSaveSearch && obs.isSearchEmpty" (click)="saveSearch()" type="button" @@ -637,6 +641,7 @@ *ngIf="obs.expanded" cdsButton="primary" data-testid="search-fields-search-button" + [attr.data-test-id]="testIds.searchButton" (click)="search()" [disabled]=" loading || noSearchFields || (hasValidValues$ | async) === false || inputDisabled @@ -654,7 +659,7 @@ <ng-template #noSearchFieldsTemplate let-noSearchFields="noSearchFields"> <div class="no-search-fields-message" *ngIf="noSearchFields"> - <v-paragraph dataTestId="search-fields-no-fields" [fullWidth]="true" [center]="true">{{ + <v-paragraph dataTestId="search-fields-no-fields" [attr.data-test-id]="testIds.noFieldsMessage" [fullWidth]="true" [center]="true">{{ 'searchFields.noneConfigured' | translate }}</v-paragraph> </div> diff --git a/frontend/projects/valtimo/components/src/lib/components/search-fields/search-fields.component.ts b/frontend/projects/valtimo/components/src/lib/components/search-fields/search-fields.component.ts index cff4df434f..ae1fbf97e2 100644 --- a/frontend/projects/valtimo/components/src/lib/components/search-fields/search-fields.component.ts +++ b/frontend/projects/valtimo/components/src/lib/components/search-fields/search-fields.component.ts @@ -27,6 +27,7 @@ import {TranslateService} from '@ngx-translate/core'; import {DocumentService} from '@valtimo/document'; import {IconService} from 'carbon-components-angular'; import {ChevronDown16, ChevronUp16} from '@carbon/icons'; +import {SEARCH_FIELD_TEST_ID_PREFIX, SEARCH_FIELDS_TEST_IDS} from '../../constants'; @Component({ selector: 'valtimo-search-fields', @@ -35,6 +36,9 @@ import {ChevronDown16, ChevronUp16} from '@carbon/icons'; standalone: false, }) export class SearchFieldsComponent implements OnInit, OnDestroy { + protected readonly testIds = SEARCH_FIELDS_TEST_IDS; + protected readonly searchFieldTestIdPrefix = SEARCH_FIELD_TEST_ID_PREFIX; + @Input() public loading!: boolean; @Input() public set searchFields(fields: Array<SearchField>) { this.searchFields$.next(fields); diff --git a/frontend/projects/valtimo/components/src/lib/components/select/select.component.html b/frontend/projects/valtimo/components/src/lib/components/select/select.component.html index ab5212ea1f..fc644b5421 100644 --- a/frontend/projects/valtimo/components/src/lib/components/select/select.component.html +++ b/frontend/projects/valtimo/components/src/lib/components/select/select.component.html @@ -41,8 +41,12 @@ > </v-input-label> + <!-- The test id is emitted twice on purpose: `data-test-id` is the project's Playwright + testIdAttribute (so only that form is reachable via getByTestId), while the legacy + `data-testid` is kept because several existing e2e page objects still select on it. --> <cds-combo-box [attr.data-testid]="dataTestId" + [attr.data-test-id]="dataTestId" [appendInline]="appendInline" [clearSelectionTitle]="vars.clearText" [clearSelectionsTitle]="vars.clearAllText" diff --git a/frontend/projects/valtimo/components/src/lib/constants/components.test-ids.ts b/frontend/projects/valtimo/components/src/lib/constants/components.test-ids.ts index 91a15feb92..8043532bd5 100644 --- a/frontend/projects/valtimo/components/src/lib/constants/components.test-ids.ts +++ b/frontend/projects/valtimo/components/src/lib/constants/components.test-ids.ts @@ -15,11 +15,29 @@ */ export const CONFIRMATION_MODAL_TEST_IDS = { + heading: 'confirmationModalHeading', + content: 'confirmationModalContent', closeButton: 'confirmationModalClose', optionalButton: 'confirmationModalOptional', confirmButton: 'confirmationModalConfirm', } as const; +export const SCHEMA_EDITOR_TEST_IDS = { + editor: 'schemaEditor', + saveButton: 'schemaEditorSaveButton', + manageRequiredFieldsButton: 'schemaEditorManageRequiredFieldsButton', + requiredFieldsPanel: 'schemaEditorRequiredFieldsPanel', + requiredFieldsPanelCloseButton: 'schemaEditorRequiredFieldsPanelCloseButton', +} as const; + +/** + * Prefix for the required-field checkboxes of the schema editor. The full test id + * is `schemaEditorRequiredProperty-<dot separated path>`, e.g. + * `schemaEditorRequiredProperty-applicantName` for a root property and + * `schemaEditorRequiredProperty-address.street` for a nested one. + */ +export const SCHEMA_EDITOR_REQUIRED_PROPERTY_TEST_ID_PREFIX = 'schemaEditorRequiredProperty-'; + export const VALUE_PATH_SELECTOR_TEST_IDS = { toggle: 'valuePathSelectorToggle', path: 'valueValuePathSelectorPath', @@ -38,8 +56,21 @@ export const JSON_EDITOR_TEST_IDS = { cancelConfirmationModal: 'jsonEditorCancelConfirmationModal', } as const; +export const MULTI_INPUT_TEST_IDS = { + /** The add button is suffixed with the multi-input's `name`, when one is set. */ + addButton: 'multiInputAddButton', + addButtonNamed: (name: string) => `multiInputAddButton-${name}`, + deleteButtonAt: (row: number) => `multiInputDeleteButton-${row}`, +} as const; + export const ARBITRARY_AMOUNT_VALUE_TEST_IDS = { input: 'arbitraryAmountInput', + /** + * Every cell of an `arbitraryAmount` multi-input renders the same kind of text input, so the + * bare `input` id matches every cell of every row. Scope it by row and column to address a + * single cell — `row` is the multi-input row index, `column` the arbitrary value index. + */ + inputAt: (row: number, column: number) => `arbitraryAmountInput-${row}-${column}`, } as const; export const VALUE_PATH_SELECTOR_DROPDOWN_VALUE_TEST_IDS = { @@ -86,3 +117,26 @@ export const COLOR_PICKER_TEST_IDS = { container: 'colorPickerContainer', trigger: 'colorPickerTrigger', } as const; + +/** + * The shared `valtimo-search-fields` panel, used by the case list, the object + * list and the IKO views. + * + * The component already carries a few `data-testid` attributes (no dash), which + * do not match the `data-test-id` attribute Playwright is configured with, so + * these are bound alongside them rather than replacing them. + */ +export const SEARCH_FIELDS_TEST_IDS = { + accordionItem: 'searchFieldsAccordionItem', + clearButton: 'searchFieldsClearButton', + saveButton: 'searchFieldsSaveButton', + searchButton: 'searchFieldsSearchButton', + noFieldsMessage: 'searchFieldsNoFieldsMessage', +} as const; + +/** + * Prefix for one search field of the panel. The full test id is + * `searchField-<searchFieldKey>`, and it sits on the field *wrapper* — the input + * itself is one level down. + */ +export const SEARCH_FIELD_TEST_ID_PREFIX = 'searchField-'; diff --git a/frontend/projects/valtimo/dashboard/src/lib/components/widget-dashboard-content/widget-dashboard-content.component.html b/frontend/projects/valtimo/dashboard/src/lib/components/widget-dashboard-content/widget-dashboard-content.component.html index 43487e13eb..534c232f93 100644 --- a/frontend/projects/valtimo/dashboard/src/lib/components/widget-dashboard-content/widget-dashboard-content.component.html +++ b/frontend/projects/valtimo/dashboard/src/lib/components/widget-dashboard-content/widget-dashboard-content.component.html @@ -15,7 +15,7 @@ --> <ng-container *ngIf="{loaded: loaded$ | async} as obs"> - <div *ngIf="!obs.loaded" class="loading-container"> + <div *ngIf="!obs.loaded" class="loading-container" [attr.data-test-id]="testIds.loading"> <cds-loading></cds-loading> </div> </ng-container> @@ -23,11 +23,17 @@ <div #widgetContainer class="widget-container" + [attr.data-test-id]="testIds.widgetContainer" [ngClass]="{'widget-container__visible': loaded$ | async}" > <ng-container *ngIf="widgetConfigurations$ | async as widgetConfigurations"> <ng-container *ngFor="let config of widgetConfigurations"> - <div #widgetConfiguration [id]="config.key" class="widget-configuration"> + <div + #widgetConfiguration + [id]="config.key" + [attr.data-test-id]="widgetTestId(config.key)" + class="widget-configuration" + > <div (click)="navigateToRoute(config, $event)" [ngClass]="{ @@ -42,6 +48,7 @@ <valtimo-no-results *ngIf="!widgetConfigurations.length" + [attr.data-test-id]="testIds.noWidgets" [title]="'dashboard.noWidgets' | translate" [description]="'dashboard.noWidgetsDescription' | translate" illustration="valtimo-layout/img/no-widgets.svg" diff --git a/frontend/projects/valtimo/dashboard/src/lib/components/widget-dashboard-content/widget-dashboard-content.component.ts b/frontend/projects/valtimo/dashboard/src/lib/components/widget-dashboard-content/widget-dashboard-content.component.ts index ba44d14779..8ec65de50b 100644 --- a/frontend/projects/valtimo/dashboard/src/lib/components/widget-dashboard-content/widget-dashboard-content.component.ts +++ b/frontend/projects/valtimo/dashboard/src/lib/components/widget-dashboard-content/widget-dashboard-content.component.ts @@ -32,7 +32,11 @@ import {Dashboard, DashboardWidgetConfiguration, DisplayComponent, WidgetData} f import {WidgetService} from '../../services'; import {WidgetLayoutService} from '../../services/widget-layout.service'; import {resolveWidgetLayout, ResolvedWidgetLayout} from '@valtimo/components'; -import {WIDGET_1X_HEIGHT} from '../../constants'; +import { + DASHBOARD_TEST_IDS, + DASHBOARD_WIDGET_TEST_ID_PREFIX, + WIDGET_1X_HEIGHT, +} from '../../constants'; import Muuri from 'muuri'; import {Router} from '@angular/router'; @@ -68,6 +72,12 @@ export class WidgetDashboardContentComponent implements AfterViewInit, OnDestroy public readonly widgetConfigurations$ = new BehaviorSubject<Array<DashboardWidgetConfiguration> | null>(null); + public readonly testIds = DASHBOARD_TEST_IDS; + + public widgetTestId(widgetKey: string): string { + return `${DASHBOARD_WIDGET_TEST_ID_PREFIX}${widgetKey}`; + } + private _observer!: ResizeObserver; private _subscriptions = new Subscription(); diff --git a/frontend/projects/valtimo/dashboard/src/lib/components/widget-dashboard/widget-dashboard.component.html b/frontend/projects/valtimo/dashboard/src/lib/components/widget-dashboard/widget-dashboard.component.html index 96aa57c0fe..da7164c33b 100644 --- a/frontend/projects/valtimo/dashboard/src/lib/components/widget-dashboard/widget-dashboard.component.html +++ b/frontend/projects/valtimo/dashboard/src/lib/components/widget-dashboard/widget-dashboard.component.html @@ -14,16 +14,21 @@ ~ limitations under the License. --> -<div *ngIf="dashboards$ | async as dashboards" class="dashboards-container"> +<div + *ngIf="dashboards$ | async as dashboards" + class="dashboards-container" + [attr.data-test-id]="testIds.container" +> <valtimo-no-results *ngIf="!dashboards.length" + [attr.data-test-id]="testIds.noDashboards" [title]="'dashboard.noDashboards' | translate" [description]="'dashboard.noDashboardsDescription' | translate" illustration="valtimo-layout/img/no-dashboards.svg" ></valtimo-no-results> <div *ngIf="dashboards.length === 1"> - <h4>{{ dashboards[0].title }}</h4> + <h4 [attr.data-test-id]="testIds.title">{{ dashboards[0].title }}</h4> <valtimo-widget-dashboard-content [dashboard]="dashboards[0]" diff --git a/frontend/projects/valtimo/dashboard/src/lib/components/widget-dashboard/widget-dashboard.component.ts b/frontend/projects/valtimo/dashboard/src/lib/components/widget-dashboard/widget-dashboard.component.ts index b7e1941d83..f8186bd9f3 100644 --- a/frontend/projects/valtimo/dashboard/src/lib/components/widget-dashboard/widget-dashboard.component.ts +++ b/frontend/projects/valtimo/dashboard/src/lib/components/widget-dashboard/widget-dashboard.component.ts @@ -15,6 +15,7 @@ */ import {Component, ViewEncapsulation} from '@angular/core'; import {BehaviorSubject, Observable, tap} from 'rxjs'; +import {DASHBOARD_TEST_IDS} from '../../constants'; import {Dashboard, WidgetData} from '../../models'; import {DashboardService} from '../../services'; import {WidgetApiService} from '../../services/widget-api.service'; @@ -41,6 +42,8 @@ export class WidgetDashboardComponent { public readonly selectedDashboardKey$ = new BehaviorSubject<string>(''); + public readonly testIds = DASHBOARD_TEST_IDS; + constructor( private readonly dashboardService: DashboardService, private readonly widgetApiService: WidgetApiService diff --git a/frontend/projects/valtimo/dashboard/src/lib/constants/dashboard.test-ids.ts b/frontend/projects/valtimo/dashboard/src/lib/constants/dashboard.test-ids.ts new file mode 100644 index 0000000000..2f9599603a --- /dev/null +++ b/frontend/projects/valtimo/dashboard/src/lib/constants/dashboard.test-ids.ts @@ -0,0 +1,39 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export const DASHBOARD_TEST_IDS = { + /** Wrapper around the whole user-facing dashboard view. */ + container: 'dashboardContainer', + /** Title shown when the user has access to exactly one dashboard. */ + title: 'dashboardTitle', + /** Empty state shown when the user has access to no dashboards at all. */ + noDashboards: 'dashboardNoDashboards', + /** Grid that holds the rendered widgets of the selected dashboard. */ + widgetContainer: 'dashboardWidgetContainer', + /** Empty state shown when the selected dashboard has no (supported) widgets. */ + noWidgets: 'dashboardNoWidgets', + /** Spinner shown while the widget data of the selected dashboard is loading. */ + loading: 'dashboardLoading', +} as const; + +/** + * Prefix for a single rendered widget on the user-facing dashboard. The full test id + * is `dashboardWidget-<widget key>`, e.g. `dashboardWidget-gauge_chart`. + * + * Only widgets whose `displayType` is registered in the frontend are rendered, so a + * widget with an unsupported display type has no element and therefore no test id. + */ +export const DASHBOARD_WIDGET_TEST_ID_PREFIX = 'dashboardWidget-'; diff --git a/frontend/projects/valtimo/dashboard/src/lib/constants/index.ts b/frontend/projects/valtimo/dashboard/src/lib/constants/index.ts index 6bccf71d8e..0745344b54 100644 --- a/frontend/projects/valtimo/dashboard/src/lib/constants/index.ts +++ b/frontend/projects/valtimo/dashboard/src/lib/constants/index.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +export * from './dashboard.test-ids'; export * from './data-features.constants'; export * from './injection-tokens'; export * from './layout.constants'; diff --git a/frontend/projects/valtimo/layout/src/lib/components/translation-management/translation-management.component.html b/frontend/projects/valtimo/layout/src/lib/components/translation-management/translation-management.component.html index 9ad9033365..3b5f05c5ab 100644 --- a/frontend/projects/valtimo/layout/src/lib/components/translation-management/translation-management.component.html +++ b/frontend/projects/valtimo/layout/src/lib/components/translation-management/translation-management.component.html @@ -68,6 +68,7 @@ [size]="obs.compactMode ? 'sm' : 'md'" (click)="showModal()" [disabled]="!obs.allChangedValuesValid || obs.disabled || !obs.valuesChanged" + [attr.data-test-id]="testIds.saveButton" > {{ 'interface.save' | translate }} diff --git a/frontend/projects/valtimo/layout/src/lib/components/translation-management/translation-management.component.ts b/frontend/projects/valtimo/layout/src/lib/components/translation-management/translation-management.component.ts index abf150ff45..a3f46ce514 100644 --- a/frontend/projects/valtimo/layout/src/lib/components/translation-management/translation-management.component.ts +++ b/frontend/projects/valtimo/layout/src/lib/components/translation-management/translation-management.component.ts @@ -26,6 +26,7 @@ import { } from '@valtimo/components'; import {isEqual} from 'lodash'; import {DOCUMENT} from '@angular/common'; +import {TRANSLATION_MANAGEMENT_TEST_IDS} from '../../constants'; @Component({ selector: 'valtimo-translation-management', @@ -94,6 +95,8 @@ export class TranslationManagementComponent implements OnInit { public readonly compactMode$ = this.pageHeaderService.compactMode$; + public readonly testIds = TRANSLATION_MANAGEMENT_TEST_IDS; + constructor( private readonly translateService: TranslateService, private readonly localizationService: LocalizationService, diff --git a/frontend/projects/valtimo/layout/src/lib/components/widget-management/management-wizard/steps/widget-wizard-display-conditions-step/widget-wizard-display-conditions-step.component.html b/frontend/projects/valtimo/layout/src/lib/components/widget-management/management-wizard/steps/widget-wizard-display-conditions-step/widget-wizard-display-conditions-step.component.html index be3f4ad3ad..d390fc18ea 100644 --- a/frontend/projects/valtimo/layout/src/lib/components/widget-management/management-wizard/steps/widget-wizard-display-conditions-step/widget-wizard-display-conditions-step.component.html +++ b/frontend/projects/valtimo/layout/src/lib/components/widget-management/management-wizard/steps/widget-wizard-display-conditions-step/widget-wizard-display-conditions-step.component.html @@ -20,6 +20,7 @@ <valtimo-carbon-multi-input [type]="$multiInputType()" + name="displayConditions" class="valtimo-widget-wizard-condition-step__multi-input" [addRowText]="'widgetTabManagement.displayConditions.addCondition' | translate" addButtonType="tertiary" diff --git a/frontend/projects/valtimo/layout/src/lib/components/widget-management/management-wizard/widget-management-wizard.component.html b/frontend/projects/valtimo/layout/src/lib/components/widget-management/management-wizard/widget-management-wizard.component.html index be75817625..064d6b5664 100644 --- a/frontend/projects/valtimo/layout/src/lib/components/widget-management/management-wizard/widget-management-wizard.component.html +++ b/frontend/projects/valtimo/layout/src/lib/components/widget-management/management-wizard/widget-management-wizard.component.html @@ -23,7 +23,7 @@ (close)="onClose()" > <cds-modal-header [showCloseButton]="true" (closeSelect)="onClose()"> - <h3 cdsModalHeaderHeading> + <h3 cdsModalHeaderHeading [attr.data-test-id]="testIds.heading"> {{ 'widgetTabManagement.wizard.' + (editMode ? 'edit' : 'create') | translate }} </h3> </cds-modal-header> diff --git a/frontend/projects/valtimo/layout/src/lib/constants/layout.test-ids.ts b/frontend/projects/valtimo/layout/src/lib/constants/layout.test-ids.ts index d8b50f5b8c..82c8dc4ade 100644 --- a/frontend/projects/valtimo/layout/src/lib/constants/layout.test-ids.ts +++ b/frontend/projects/valtimo/layout/src/lib/constants/layout.test-ids.ts @@ -14,6 +14,11 @@ * limitations under the License. */ +export const TRANSLATION_MANAGEMENT_TEST_IDS = { + /** Rendered into the page header, not into the component's own template. */ + saveButton: 'translationManagementSaveButton', +} as const; + export const WIDGET_EDITOR_TEST_IDS = { addWidgetButton: 'widgetEditorAddWidgetButton', addDividerButton: 'widgetEditorAddDividerButton', @@ -24,6 +29,8 @@ export const WIDGET_WIZARD_TEST_IDS = { saveButton: 'widgetWizardSaveButton', cancelButton: 'widgetWizardCancelButton', backButton: 'widgetWizardBackButton', + /** Reads "Create widget" or "Edit widget" — lets tests tell the two modes apart. */ + heading: 'widgetWizardHeading', } as const; export const WIDGET_WIZARD_TYPE_TEST_IDS = { diff --git a/frontend/projects/valtimo/logging/src/lib/components/log-details/log-details.component.html b/frontend/projects/valtimo/logging/src/lib/components/log-details/log-details.component.html index 0a83ba3ff1..a314653be8 100644 --- a/frontend/projects/valtimo/logging/src/lib/components/log-details/log-details.component.html +++ b/frontend/projects/valtimo/logging/src/lib/components/log-details/log-details.component.html @@ -14,7 +14,12 @@ ~ limitations under the License. --> -<cds-modal [open]="open" size="lg" (close)="onCloseSelect()"> +<cds-modal + [open]="open" + size="lg" + [attr.data-test-id]="testIds.modal" + (close)="onCloseSelect()" +> <cds-modal-header [showCloseButton]="true" (closeSelect)="onCloseSelect()"> {{ 'logging.detailsTitle' | translate }} </cds-modal-header> @@ -24,13 +29,15 @@ <h4 class="log-header"> {{ 'logging.formattedMessage' | translate }} </h4> - <div class="log-text">{{ logEventFormatted?.formattedMessage || '-' }}</div> + <div class="log-text" [attr.data-test-id]="testIds.message"> + {{ logEventFormatted?.formattedMessage || '-' }} + </div> <h4 class="log-header"> {{ 'logging.timestamp' | translate }} </h4> - <div> + <div [attr.data-test-id]="testIds.timestamp"> {{ logEventFormatted?.timestamp || '-' }} </div> diff --git a/frontend/projects/valtimo/logging/src/lib/components/log-details/log-details.component.ts b/frontend/projects/valtimo/logging/src/lib/components/log-details/log-details.component.ts index dc32dde79d..4aaed3c27f 100644 --- a/frontend/projects/valtimo/logging/src/lib/components/log-details/log-details.component.ts +++ b/frontend/projects/valtimo/logging/src/lib/components/log-details/log-details.component.ts @@ -18,6 +18,7 @@ import {ChangeDetectionStrategy, Component, EventEmitter, Input, Output} from '@ import {TranslateModule} from '@ngx-translate/core'; import {ModalModule} from 'carbon-components-angular'; import {LoggingEvent} from '../../models'; +import {LOG_DETAILS_TEST_IDS} from '../../constants'; @Component({ selector: 'valtimo-log-details', @@ -28,6 +29,7 @@ import {LoggingEvent} from '../../models'; changeDetection: ChangeDetectionStrategy.OnPush, }) export class LogDetailsComponent { + public readonly testIds = LOG_DETAILS_TEST_IDS; @Input() public open = false; @Input() public set logEvent(logEvent: LoggingEvent) { diff --git a/frontend/projects/valtimo/logging/src/lib/components/log-search/log-search.component.html b/frontend/projects/valtimo/logging/src/lib/components/log-search/log-search.component.html index 2c73d6f895..ca42d959c0 100644 --- a/frontend/projects/valtimo/logging/src/lib/components/log-search/log-search.component.html +++ b/frontend/projects/valtimo/logging/src/lib/components/log-search/log-search.component.html @@ -26,6 +26,7 @@ <input cdsText [attr.data-carbon-theme]="theme" + [attr.data-test-id]="testIds.messageInput" formControlName="likeFormattedMessage" [placeholder]="'logging.columns.formattedMessage' | translate" /> @@ -35,6 +36,7 @@ class="valtimo-log-search__level" formControlName="level" [attr.data-carbon-theme]="theme" + [attr.data-test-id]="testIds.levelDropdown" [appendInline]="true" [label]="'logging.columns.level' | translate" [placeholder]="'logging.columns.level' | translate" @@ -82,6 +84,7 @@ <input cdsText [attr.data-carbon-theme]="theme" + [attr.data-test-id]="testIds.propertyKeyInput" formControlName="key" [placeholder]="'interface.key' | translate" /> @@ -91,6 +94,7 @@ <input cdsText [attr.data-carbon-theme]="theme" + [attr.data-test-id]="testIds.propertyValueInput" formControlName="value" [placeholder]="'interface.value' | translate" /> @@ -98,6 +102,7 @@ <button cdsButton="danger--ghost" + [attr.data-test-id]="testIds.removePropertyButton" [disabled]="$count === 1" iconOnly="true" type="button" @@ -108,14 +113,25 @@ </form> } - <button cdsButton="primary" size="sm" type="button" (click)="addPropertySearchField()"> + <button + cdsButton="primary" + size="sm" + type="button" + [attr.data-test-id]="testIds.addPropertyButton" + (click)="addPropertySearchField()" + > {{ 'logging.search.addProperty' | translate }} <svg class="cds--btn__icon" cdsIcon="add" size="16"></svg> </button> </section> - <button cdsButton="tertiary" type="button" (click)="onClearFilter()"> + <button + cdsButton="tertiary" + type="button" + [attr.data-test-id]="testIds.clearButton" + (click)="onClearFilter()" + > {{ 'interface.clear' | translate }} <svg class="cds--btn__icon" cdsIcon="trash-can" size="16"></svg> diff --git a/frontend/projects/valtimo/logging/src/lib/components/log-search/log-search.component.ts b/frontend/projects/valtimo/logging/src/lib/components/log-search/log-search.component.ts index a85fd42a8f..56f6d77eab 100644 --- a/frontend/projects/valtimo/logging/src/lib/components/log-search/log-search.component.ts +++ b/frontend/projects/valtimo/logging/src/lib/components/log-search/log-search.component.ts @@ -42,6 +42,7 @@ import { } from 'carbon-components-angular'; import flatpickr from 'flatpickr'; import {debounceTime, map, Observable, Subscription} from 'rxjs'; +import {LOG_SEARCH_TEST_IDS} from '../../constants'; import { LoggingEventProperty, LoggingEventSearchFormValue, @@ -69,6 +70,7 @@ import { ], }) export class LogSearchComponent implements OnInit, AfterViewInit, OnDestroy { + public readonly testIds = LOG_SEARCH_TEST_IDS; @ViewChild('afterTimestamp') private readonly _afterTimestampDatePicker: DatePicker; @ViewChild('beforeTimestamp') private readonly _beforeTimestampDatePicker: DatePicker; diff --git a/frontend/projects/valtimo/logging/src/lib/components/logging-list/logging-list.component.html b/frontend/projects/valtimo/logging/src/lib/components/logging-list/logging-list.component.html index 7859310e9f..4f23d9ca45 100644 --- a/frontend/projects/valtimo/logging/src/lib/components/logging-list/logging-list.component.html +++ b/frontend/projects/valtimo/logging/src/lib/components/logging-list/logging-list.component.html @@ -38,7 +38,12 @@ placement="bottom-end" [closeOnSelect]="false" > - <button overflowTrigger cdsButton="ghost" [iconOnly]="true"> + <button + overflowTrigger + cdsButton="ghost" + [attr.data-test-id]="testIds.filterButton" + [iconOnly]="true" + > <svg cdsIcon="filter" size="16"></svg> </button> @@ -62,7 +67,11 @@ </ng-template> <ng-template #clearSearchButton> - <button cdsButton="primary" (click)="onClearFilter()"> + <button + cdsButton="primary" + [attr.data-test-id]="testIds.clearSearchButton" + (click)="onClearFilter()" + > {{ 'interface.clear' | translate }} <svg class="cds--btn__icon" cdsIcon="trash-can" size="16"></svg> diff --git a/frontend/projects/valtimo/logging/src/lib/components/logging-list/logging-list.component.ts b/frontend/projects/valtimo/logging/src/lib/components/logging-list/logging-list.component.ts index f6a880ed58..731de28d20 100644 --- a/frontend/projects/valtimo/logging/src/lib/components/logging-list/logging-list.component.ts +++ b/frontend/projects/valtimo/logging/src/lib/components/logging-list/logging-list.component.ts @@ -58,6 +58,7 @@ import { import {LoggingApiService} from '../../services'; import {LogDetailsComponent} from '../log-details/log-details.component'; import {LogSearchComponent} from '../log-search/log-search.component'; +import {LOGGING_LIST_TEST_IDS} from '../../constants'; @Component({ templateUrl: './logging-list.component.html', @@ -78,6 +79,7 @@ import {LogSearchComponent} from '../log-search/log-search.component'; providers: [LoggingApiService], }) export class LoggingListComponent implements OnInit, OnDestroy { + public readonly testIds = LOGGING_LIST_TEST_IDS; public readonly loading$ = new BehaviorSubject<boolean>(true); public readonly logItems$: Observable<CarbonListItem> = this.activatedRoute.queryParamMap.pipe( tap(() => this.loading$.next(true)), diff --git a/frontend/projects/valtimo/logging/src/lib/constants/index.ts b/frontend/projects/valtimo/logging/src/lib/constants/index.ts new file mode 100644 index 0000000000..60036b6931 --- /dev/null +++ b/frontend/projects/valtimo/logging/src/lib/constants/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export * from './logging.test-ids'; diff --git a/frontend/projects/valtimo/logging/src/lib/constants/logging.test-ids.ts b/frontend/projects/valtimo/logging/src/lib/constants/logging.test-ids.ts new file mode 100644 index 0000000000..b812f83324 --- /dev/null +++ b/frontend/projects/valtimo/logging/src/lib/constants/logging.test-ids.ts @@ -0,0 +1,42 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const LOGGING_LIST_TEST_IDS = { + /** Funnel icon in the table toolbar that reveals the search panel. */ + filterButton: 'loggingListFilterButton', + clearSearchButton: 'loggingListClearSearchButton', +} as const; + +/** + * The search panel has no submit button — it applies itself on change, debounced. + */ +const LOG_SEARCH_TEST_IDS = { + messageInput: 'logSearchMessageInput', + levelDropdown: 'logSearchLevelDropdown', + propertyKeyInput: 'logSearchPropertyKeyInput', + propertyValueInput: 'logSearchPropertyValueInput', + addPropertyButton: 'logSearchAddPropertyButton', + removePropertyButton: 'logSearchRemovePropertyButton', + clearButton: 'logSearchClearButton', +} as const; + +const LOG_DETAILS_TEST_IDS = { + modal: 'logDetailsModal', + message: 'logDetailsMessage', + timestamp: 'logDetailsTimestamp', +} as const; + +export {LOGGING_LIST_TEST_IDS, LOG_SEARCH_TEST_IDS, LOG_DETAILS_TEST_IDS}; diff --git a/frontend/projects/valtimo/logging/src/public_api.ts b/frontend/projects/valtimo/logging/src/public_api.ts index be979c49ec..c10e809d7f 100644 --- a/frontend/projects/valtimo/logging/src/public_api.ts +++ b/frontend/projects/valtimo/logging/src/public_api.ts @@ -18,4 +18,5 @@ * Public API Surface of documenten-api */ +export * from './lib/constants'; export * from './logging.module'; diff --git a/frontend/projects/valtimo/object-management/src/lib/components/object-management-detail-container/object-management-detail-container.component.html b/frontend/projects/valtimo/object-management/src/lib/components/object-management-detail-container/object-management-detail-container.component.html index e99476c347..0bc9b3ba62 100644 --- a/frontend/projects/valtimo/object-management/src/lib/components/object-management-detail-container/object-management-detail-container.component.html +++ b/frontend/projects/valtimo/object-management/src/lib/components/object-management-detail-container/object-management-detail-container.component.html @@ -31,7 +31,10 @@ <div class="bg-light dossier-header" *ngIf="object.showInDataMenu"> <h3 class="dossier-title"> <div *ngIf="object.showInDataMenu" class="pull-right"> - <span class="badge badge-pill badge-info increase-size">{{ + <span + [attr.data-test-id]="testIds.visibleInMenuTag" + class="badge badge-pill badge-info increase-size" + >{{ 'objectManagement.visibleInMenu' | translate }}</span> </div> @@ -45,6 +48,7 @@ <h3 class="dossier-title"> <li class="nav-item"> <a id="general-tab" + [attr.data-test-id]="testIds.generalTab" class="nav-link clickable active" data-toggle="tab" (click)="displayBodyComponent(TabEnum.GENERAL)" @@ -55,6 +59,7 @@ <h3 class="dossier-title"> <li class="nav-item"> <a id="search-tab" + [attr.data-test-id]="testIds.searchFieldsTab" class="nav-link clickable" data-toggle="tab" (click)="displayBodyComponent(TabEnum.SEARCH)" @@ -65,6 +70,7 @@ <h3 class="dossier-title"> <li class="nav-item" *ngIf="caseListColumn$ | async"> <a id="list-tab" + [attr.data-test-id]="testIds.listTab" class="nav-link clickable" data-toggle="tab" (click)="displayBodyComponent(TabEnum.LIST)" diff --git a/frontend/projects/valtimo/object-management/src/lib/components/object-management-detail-container/object-management-detail-container.component.ts b/frontend/projects/valtimo/object-management/src/lib/components/object-management-detail-container/object-management-detail-container.component.ts index 1a92601eee..f959122ea9 100644 --- a/frontend/projects/valtimo/object-management/src/lib/components/object-management-detail-container/object-management-detail-container.component.ts +++ b/frontend/projects/valtimo/object-management/src/lib/components/object-management-detail-container/object-management-detail-container.component.ts @@ -24,6 +24,7 @@ import {ObjectManagementService} from '../../services/object-management.service' import {ObjectManagementStateService} from '../../services/object-management-state.service'; import {PageTitleService} from '@valtimo/components'; import {tap} from 'rxjs/operators'; +import {OBJECT_MANAGEMENT_DETAIL_TEST_IDS} from '../../constants'; @Component({ standalone: false, @@ -32,6 +33,8 @@ import {tap} from 'rxjs/operators'; styleUrls: ['./object-management-detail-container.component.css'], }) export class ObjectManagementDetailContainerComponent implements OnInit, OnDestroy { + protected readonly testIds = OBJECT_MANAGEMENT_DETAIL_TEST_IDS; + public currentTab: TabEnum; public readonly caseListColumn$ = this.configService.getFeatureToggleObservable('caseListColumn', true); diff --git a/frontend/projects/valtimo/object-management/src/lib/components/object-management-detail-container/tabs/object-management-detail/object-management-detail.component.html b/frontend/projects/valtimo/object-management/src/lib/components/object-management-detail-container/tabs/object-management-detail/object-management-detail.component.html index 4e8db69ce6..761a0b47fa 100644 --- a/frontend/projects/valtimo/object-management/src/lib/components/object-management-detail-container/tabs/object-management-detail/object-management-detail.component.html +++ b/frontend/projects/valtimo/object-management/src/lib/components/object-management-detail-container/tabs/object-management-detail/object-management-detail.component.html @@ -33,6 +33,7 @@ <ng-template #buttons let-object="object" let-loading="loading"> <div class="btn-group mt-m3px mb-3 pr-2 float-right"> <button + [attr.data-test-id]="testIds.downloadButton" (click)="downloadDefinition(object)" cdsButton="secondary" size="md" @@ -44,6 +45,7 @@ </button> <a id="downloadAnchorElement" class="d-none"></a> <button + [attr.data-test-id]="testIds.editButton" (click)="showEditModal()" cdsButton="primary" size="md" diff --git a/frontend/projects/valtimo/object-management/src/lib/components/object-management-detail-container/tabs/object-management-detail/object-management-detail.component.ts b/frontend/projects/valtimo/object-management/src/lib/components/object-management-detail-container/tabs/object-management-detail/object-management-detail.component.ts index fbe91a2416..871beb4c01 100644 --- a/frontend/projects/valtimo/object-management/src/lib/components/object-management-detail-container/tabs/object-management-detail/object-management-detail.component.ts +++ b/frontend/projects/valtimo/object-management/src/lib/components/object-management-detail-container/tabs/object-management-detail/object-management-detail.component.ts @@ -19,6 +19,7 @@ import {Objecttype} from '../../../../models/object-management.model'; import {ObjectManagementService} from '../../../../services/object-management.service'; import {ObjectManagementStateService} from '../../../../services/object-management-state.service'; import {BehaviorSubject, Observable} from 'rxjs'; +import {OBJECT_MANAGEMENT_DETAIL_TEST_IDS} from '../../../../constants'; @Component({ standalone: false, @@ -27,6 +28,8 @@ import {BehaviorSubject, Observable} from 'rxjs'; styleUrls: ['./object-management-detail.component.scss'], }) export class ObjectManagementDetailComponent { + protected readonly testIds = OBJECT_MANAGEMENT_DETAIL_TEST_IDS; + @Input() object$: Observable<Objecttype>; readonly loading$ = new BehaviorSubject<boolean>(false); diff --git a/frontend/projects/valtimo/object-management/src/lib/components/object-management-list/object-management-list.component.html b/frontend/projects/valtimo/object-management/src/lib/components/object-management-list/object-management-list.component.html index d21a736787..55982532cd 100644 --- a/frontend/projects/valtimo/object-management/src/lib/components/object-management-list/object-management-list.component.html +++ b/frontend/projects/valtimo/object-management/src/lib/components/object-management-list/object-management-list.component.html @@ -18,11 +18,20 @@ <div class="container-fluid"> <div class="col-12 px-0 mb-5"> <div class="text-right mt-m3px mb-3"> - <button (click)="showUploadModal()" class="btn btn-secondary btn-space"> + <button + [attr.data-test-id]="testIds.uploadButton" + (click)="showUploadModal()" + class="btn btn-secondary btn-space" + > <i class="icon mdi mdi-upload mr-1"></i> {{ 'objectManagement.uploadObjecttype' | translate }} </button> - <button (click)="showAddModal()" type="button" class="btn btn-space btn-primary mr-0"> + <button + [attr.data-test-id]="testIds.createButton" + (click)="showAddModal()" + type="button" + class="btn btn-space btn-primary mr-0" + > <i class="icon mdi mdi-plus mr-1"></i> {{ 'objectManagement.createObjecttype' | translate }} </button> diff --git a/frontend/projects/valtimo/object-management/src/lib/components/object-management-list/object-management-list.component.ts b/frontend/projects/valtimo/object-management/src/lib/components/object-management-list/object-management-list.component.ts index 25fd153381..9ca4f04947 100644 --- a/frontend/projects/valtimo/object-management/src/lib/components/object-management-list/object-management-list.component.ts +++ b/frontend/projects/valtimo/object-management/src/lib/components/object-management-list/object-management-list.component.ts @@ -22,6 +22,7 @@ import {ObjectManagementService} from '../../services/object-management.service' import {Objecttype} from '../../models/object-management.model'; import {ObjectManagementStateService} from '../../services/object-management-state.service'; import {Router} from '@angular/router'; +import {OBJECT_MANAGEMENT_LIST_TEST_IDS} from '../../constants'; @Component({ standalone: false, @@ -30,6 +31,8 @@ import {Router} from '@angular/router'; styleUrls: ['./object-management-list.component.scss'], }) export class ObjectManagementListComponent { + protected readonly testIds = OBJECT_MANAGEMENT_LIST_TEST_IDS; + readonly loading$ = new BehaviorSubject<boolean>(true); readonly fields$ = new BehaviorSubject<Array<{key: string; label: string}>>([]); diff --git a/frontend/projects/valtimo/object-management/src/lib/components/object-management-modal/object-management-modal.component.html b/frontend/projects/valtimo/object-management/src/lib/components/object-management-modal/object-management-modal.component.html index 8fd497c356..43067e9ed0 100644 --- a/frontend/projects/valtimo/object-management/src/lib/components/object-management-modal/object-management-modal.component.html +++ b/frontend/projects/valtimo/object-management/src/lib/components/object-management-modal/object-management-modal.component.html @@ -25,7 +25,7 @@ > <div role="header"> <div class="add-title"> - <h4> + <h4 [attr.data-test-id]="testIds.heading"> {{ (obs.modalType === 'add' ? 'objectManagement.createObjecttype' @@ -45,11 +45,21 @@ <h4> </div> <div role="footer"> <div class="buttons"> - <button cdsButton="secondary" (click)="cancel()" [disabled]="obs.disabled"> + <button + [attr.data-test-id]="testIds.cancelButton" + cdsButton="secondary" + (click)="cancel()" + [disabled]="obs.disabled" + > {{ 'objectManagement.cancel' | translate }} </button> - <button cdsButton="primary" (click)="save()" [disabled]="obs.disabled || !obs.valid"> + <button + [attr.data-test-id]="testIds.saveButton" + cdsButton="primary" + (click)="save()" + [disabled]="obs.disabled || !obs.valid" + > {{ (obs.modalType === 'add' ? 'objectManagement.add' : 'objectManagement.edit') | translate }} @@ -77,6 +87,7 @@ <h4> [widthPx]="350" [defaultValue]="prefillObject?.title" name="title" + [attr.data-test-id]="testIds.titleInput" ></v-input> <ng-container @@ -91,6 +102,7 @@ <h4> [margin]="true" [widthInPx]="350" name="objectenApiPluginConfigurationId" + [attr.data-test-id]="testIds.objectenApiSelect" [title]="'objectManagement.labels.objects' | translate" [defaultSelectionId]="prefillObject?.objectenApiPluginConfigurationId" [required]="true" @@ -101,6 +113,7 @@ <h4> [margin]="true" [widthInPx]="350" name="objecttypenApiPluginConfigurationId" + [attr.data-test-id]="testIds.objecttypenApiSelect" [title]="'objectManagement.labels.objecttypes' | translate" [defaultSelectionId]="prefillObject?.objecttypenApiPluginConfigurationId" [required]="true" @@ -116,6 +129,7 @@ <h4> [widthPx]="350" [defaultValue]="prefillObject?.objecttypeId" name="objecttypeId" + [attr.data-test-id]="testIds.objecttypeIdInput" ></v-input> <v-input @@ -128,6 +142,7 @@ <h4> [min]="1" type="number" name="objecttypeVersion" + [attr.data-test-id]="testIds.objecttypeVersionInput" ></v-input> <ng-container *ngIf="{formDefinitions: formDefinitions$ | async} as formObs"> @@ -136,6 +151,7 @@ <h4> [margin]="true" [widthInPx]="350" name="formDefinitionView" + [attr.data-test-id]="testIds.formDefinitionViewSelect" [title]="'objectManagement.labels.formDefinitionView' | translate" [tooltip]="'objectManagement.tooltips.formDefinitionView' | translate" [defaultSelectionId]="prefillObject?.formDefinitionView" @@ -146,6 +162,7 @@ <h4> [margin]="true" [widthInPx]="350" name="formDefinitionEdit" + [attr.data-test-id]="testIds.formDefinitionEditSelect" [title]="'objectManagement.labels.formDefinitionEdit' | translate" [tooltip]="'objectManagement.tooltips.formDefinitionEdit' | translate" [defaultSelectionId]="prefillObject?.formDefinitionEdit" @@ -158,6 +175,7 @@ <h4> [title]="'objectManagement.labels.showInDataMenu' | translate" [tooltip]="'objectManagement.tooltips.showInDataMenu' | translate" name="showInDataMenu" + [attr.data-test-id]="testIds.showInDataMenuCheckbox" [defaultValue]="prefillObject?.showInDataMenu" ></v-input> @@ -167,6 +185,7 @@ <h4> [title]="'objectManagement.labels.suppressOutbox' | translate" [tooltip]="'objectManagement.tooltips.suppressOutbox' | translate" name="suppressOutbox" + [attr.data-test-id]="testIds.suppressOutboxCheckbox" [defaultValue]="prefillObject?.suppressOutbox" ></v-input> </v-form> diff --git a/frontend/projects/valtimo/object-management/src/lib/components/object-management-modal/object-management-modal.component.ts b/frontend/projects/valtimo/object-management/src/lib/components/object-management-modal/object-management-modal.component.ts index 9d9e85a695..07d0c80188 100644 --- a/frontend/projects/valtimo/object-management/src/lib/components/object-management-modal/object-management-modal.component.ts +++ b/frontend/projects/valtimo/object-management/src/lib/components/object-management-modal/object-management-modal.component.ts @@ -23,6 +23,7 @@ import {ObjectManagementService} from '../../services/object-management.service' import {Objecttype} from '../../models/object-management.model'; import {FormService} from '@valtimo/form'; import {VModalComponent, ModalService} from '@valtimo/components'; +import {OBJECT_MANAGEMENT_MODAL_TEST_IDS} from '../../constants'; @Component({ standalone: false, @@ -31,6 +32,8 @@ import {VModalComponent, ModalService} from '@valtimo/components'; styleUrls: ['./object-management-modal.component.scss'], }) export class ObjectManagementModalComponent implements AfterViewInit, OnDestroy { + protected readonly testIds = OBJECT_MANAGEMENT_MODAL_TEST_IDS; + @ViewChild('objectManagementModal') objectManagementModal: VModalComponent; @Input() prefillObject$!: Observable<Objecttype>; diff --git a/frontend/projects/valtimo/object-management/src/lib/constants/index.ts b/frontend/projects/valtimo/object-management/src/lib/constants/index.ts new file mode 100644 index 0000000000..59c095e16d --- /dev/null +++ b/frontend/projects/valtimo/object-management/src/lib/constants/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export * from './object-management.test-ids'; diff --git a/frontend/projects/valtimo/object-management/src/lib/constants/object-management.test-ids.ts b/frontend/projects/valtimo/object-management/src/lib/constants/object-management.test-ids.ts new file mode 100644 index 0000000000..cbcd6f00d7 --- /dev/null +++ b/frontend/projects/valtimo/object-management/src/lib/constants/object-management.test-ids.ts @@ -0,0 +1,50 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** The object type overview at `/object-management`. */ +export const OBJECT_MANAGEMENT_LIST_TEST_IDS = { + uploadButton: 'objectManagementUploadButton', + createButton: 'objectManagementCreateButton', +} as const; + +/** + * The add/edit object type modal. The same component serves both, so the save + * button is labelled "Add object" while adding and "Edit object" while editing. + */ +export const OBJECT_MANAGEMENT_MODAL_TEST_IDS = { + heading: 'objectManagementModalHeading', + titleInput: 'objectManagementModalTitleInput', + objectenApiSelect: 'objectManagementModalObjectenApiSelect', + objecttypenApiSelect: 'objectManagementModalObjecttypenApiSelect', + objecttypeIdInput: 'objectManagementModalObjecttypeIdInput', + objecttypeVersionInput: 'objectManagementModalObjecttypeVersionInput', + formDefinitionViewSelect: 'objectManagementModalFormDefinitionViewSelect', + formDefinitionEditSelect: 'objectManagementModalFormDefinitionEditSelect', + showInDataMenuCheckbox: 'objectManagementModalShowInDataMenuCheckbox', + suppressOutboxCheckbox: 'objectManagementModalSuppressOutboxCheckbox', + cancelButton: 'objectManagementModalCancelButton', + saveButton: 'objectManagementModalSaveButton', +} as const; + +/** The object type detail page at `/object-management/object/{id}`. */ +export const OBJECT_MANAGEMENT_DETAIL_TEST_IDS = { + visibleInMenuTag: 'objectManagementVisibleInMenuTag', + generalTab: 'objectManagementGeneralTab', + searchFieldsTab: 'objectManagementSearchFieldsTab', + listTab: 'objectManagementListTab', + downloadButton: 'objectManagementDownloadButton', + editButton: 'objectManagementEditButton', +} as const; diff --git a/frontend/projects/valtimo/object-management/src/public-api.ts b/frontend/projects/valtimo/object-management/src/public-api.ts index a47224ff5d..5aa9cdb742 100644 --- a/frontend/projects/valtimo/object-management/src/public-api.ts +++ b/frontend/projects/valtimo/object-management/src/public-api.ts @@ -18,6 +18,7 @@ * Public API Surface of object-management */ +export * from './lib/constants'; export * from './lib/models/object-management.model'; export * from './lib/services/object-management.service'; export * from './lib/permissions/object-management.permissions'; diff --git a/frontend/projects/valtimo/object/src/lib/components/object-detail-container/tabs/object-detail/object-detail.component.html b/frontend/projects/valtimo/object/src/lib/components/object-detail-container/tabs/object-detail/object-detail.component.html index 220c214fe8..78e91bcb55 100644 --- a/frontend/projects/valtimo/object/src/lib/components/object-detail-container/tabs/object-detail/object-detail.component.html +++ b/frontend/projects/valtimo/object/src/lib/components/object-detail-container/tabs/object-detail/object-detail.component.html @@ -38,12 +38,24 @@ </ng-container> <ng-template #buttons let-obs="obs"> - <button [disabled]="obs.disableInput" (click)="openModal()" cdsButton="primary" size="md"> + <button + [attr.data-test-id]="testIds.editButton" + [disabled]="obs.disableInput" + (click)="openModal()" + cdsButton="primary" + size="md" + > {{ 'object.editObject' | translate }} <svg class="cds--btn__icon" cdsIcon="add" size="16"></svg> </button> - <button (click)="deleteObject()" cdsButton="danger" size="md" class="ml-3"> + <button + [attr.data-test-id]="testIds.deleteButton" + (click)="deleteObject()" + cdsButton="danger" + size="md" + class="ml-3" + > {{ 'object.deleteObject' | translate }} <svg class="cds--btn__icon" cdsIcon="trash-can" size="16"></svg> </button> @@ -51,6 +63,7 @@ <ng-template #widget let-obs="obs"> <valtimo-form-io + [attr.data-test-id]="testIds.summaryForm" *ngIf="obs.formioFormSummary" [form]="obs.formioFormSummary" [readOnly]="true" @@ -92,6 +105,7 @@ <h3 cdsModalHeaderHeading> <cds-modal-footer> <ng-container> <button + [attr.data-test-id]="testIds.modalCloseButton" cdsButton="ghost" [attr.modal-primary-focus]="true" (click)="closeModal()" @@ -100,6 +114,7 @@ <h3 cdsModalHeaderHeading> {{ 'object.close' | translate }} </button> <button + [attr.data-test-id]="testIds.modalSaveButton" cdsButton="primary" [attr.modal-primary-focus]="true" (click)="saveObject()" diff --git a/frontend/projects/valtimo/object/src/lib/components/object-detail-container/tabs/object-detail/object-detail.component.ts b/frontend/projects/valtimo/object/src/lib/components/object-detail-container/tabs/object-detail/object-detail.component.ts index 7792af3a95..19a818a2ac 100644 --- a/frontend/projects/valtimo/object/src/lib/components/object-detail-container/tabs/object-detail/object-detail.component.ts +++ b/frontend/projects/valtimo/object/src/lib/components/object-detail-container/tabs/object-detail/object-detail.component.ts @@ -23,6 +23,7 @@ import {BehaviorSubject, combineLatest, map, Observable, of, Subject, throwError import {catchError, finalize, switchMap, take, tap} from 'rxjs/operators'; import {FormType} from '../../../../models/object.model'; import {ObjectService} from '../../../../services/object.service'; +import {OBJECT_DETAIL_TEST_IDS} from '../../../../constants'; @Component({ standalone: false, @@ -31,6 +32,8 @@ import {ObjectService} from '../../../../services/object.service'; styleUrls: ['./object-detail.component.scss'], }) export class ObjectDetailComponent implements OnDestroy { + protected readonly testIds = OBJECT_DETAIL_TEST_IDS; + readonly loading$ = new BehaviorSubject<boolean>(true); readonly submission$ = new BehaviorSubject<any>({}); readonly formValid$ = new BehaviorSubject<boolean>(false); diff --git a/frontend/projects/valtimo/object/src/lib/components/object-list/object-list.component.html b/frontend/projects/valtimo/object/src/lib/components/object-list/object-list.component.html index 6b5b178544..f0f40cf3ae 100644 --- a/frontend/projects/valtimo/object/src/lib/components/object-list/object-list.component.html +++ b/frontend/projects/valtimo/object/src/lib/components/object-list/object-list.component.html @@ -74,7 +74,11 @@ (sortChanged)="sortChanged($event)" > <ng-container carbonToolbarContent> - <button cdsButton="primary" (click)="openModal()"> + <button + [attr.data-test-id]="testIds.createButton" + cdsButton="primary" + (click)="openModal()" + > {{ 'object.createObject' | translate }} <svg class="cds--btn__icon" cdsIcon="add" size="16"></svg> @@ -118,6 +122,7 @@ <h3 cdsModalHeaderHeading> <cds-modal-footer> <ng-container> <button + [attr.data-test-id]="testIds.modalCloseButton" cdsButton="ghost" [attr.modal-primary-focus]="true" (click)="closeModal()" @@ -126,6 +131,7 @@ <h3 cdsModalHeaderHeading> {{ 'object.close' | translate }} </button> <button + [attr.data-test-id]="testIds.modalSaveButton" cdsButton="primary" [attr.modal-primary-focus]="true" (click)="addObject()" diff --git a/frontend/projects/valtimo/object/src/lib/components/object-list/object-list.component.ts b/frontend/projects/valtimo/object/src/lib/components/object-list/object-list.component.ts index fb8a1aaff4..426cf488b5 100644 --- a/frontend/projects/valtimo/object/src/lib/components/object-list/object-list.component.ts +++ b/frontend/projects/valtimo/object/src/lib/components/object-list/object-list.component.ts @@ -42,6 +42,7 @@ import {catchError, finalize, shareReplay, switchMap, take, takeUntil, tap} from import {ColumnType, FormType} from '../../models/object.model'; import {ObjectColumnService} from '../../services/object-column.service'; import {ObjectService} from '../../services/object.service'; +import {OBJECT_LIST_TEST_IDS} from '../../constants'; @Component({ standalone: false, @@ -50,6 +51,8 @@ import {ObjectService} from '../../services/object.service'; styleUrls: ['./object-list.component.scss'], }) export class ObjectListComponent implements OnInit, OnDestroy { + protected readonly testIds = OBJECT_LIST_TEST_IDS; + private readonly _destroy$ = new Subject<void>(); readonly loading$ = new BehaviorSubject<boolean>(true); diff --git a/frontend/projects/valtimo/object/src/lib/constants/index.ts b/frontend/projects/valtimo/object/src/lib/constants/index.ts new file mode 100644 index 0000000000..ea8b878df7 --- /dev/null +++ b/frontend/projects/valtimo/object/src/lib/constants/index.ts @@ -0,0 +1,18 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +export * from './object.test-ids'; diff --git a/frontend/projects/valtimo/object/src/lib/constants/object.test-ids.ts b/frontend/projects/valtimo/object/src/lib/constants/object.test-ids.ts new file mode 100644 index 0000000000..ead2777887 --- /dev/null +++ b/frontend/projects/valtimo/object/src/lib/constants/object.test-ids.ts @@ -0,0 +1,31 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** The object overview at `/objects/{objectManagementId}`. */ +export const OBJECT_LIST_TEST_IDS = { + createButton: 'objectListCreateButton', + modalCloseButton: 'objectListModalCloseButton', + modalSaveButton: 'objectListModalSaveButton', +} as const; + +/** The object detail page at `/objects/{objectManagementId}/{objectId}`. */ +export const OBJECT_DETAIL_TEST_IDS = { + editButton: 'objectDetailEditButton', + deleteButton: 'objectDetailDeleteButton', + summaryForm: 'objectDetailSummaryForm', + modalCloseButton: 'objectDetailModalCloseButton', + modalSaveButton: 'objectDetailModalSaveButton', +} as const; diff --git a/frontend/projects/valtimo/object/src/public-api.ts b/frontend/projects/valtimo/object/src/public-api.ts index e5cad11783..9147da9f82 100644 --- a/frontend/projects/valtimo/object/src/public-api.ts +++ b/frontend/projects/valtimo/object/src/public-api.ts @@ -18,5 +18,6 @@ * Public API Surface of object */ +export * from './lib/constants'; export * from './lib/services/object.service'; export * from './lib/object.module'; diff --git a/frontend/projects/valtimo/plugin-management/src/lib/components/plugin-edit-modal/plugin-edit-modal.component.html b/frontend/projects/valtimo/plugin-management/src/lib/components/plugin-edit-modal/plugin-edit-modal.component.html index 581deecbf0..331f93557b 100644 --- a/frontend/projects/valtimo/plugin-management/src/lib/components/plugin-edit-modal/plugin-edit-modal.component.html +++ b/frontend/projects/valtimo/plugin-management/src/lib/components/plugin-edit-modal/plugin-edit-modal.component.html @@ -35,11 +35,17 @@ ></valtimo-plugin-edit> </section> <cds-modal-footer> - <button cdsButton="danger" (click)="delete()" [disabled]="inputDisabled$ | async"> + <button + cdsButton="danger" + [attr.data-test-id]="testIds.deleteButton" + (click)="delete()" + [disabled]="inputDisabled$ | async" + > {{ 'pluginManagement.remove' | translate }} </button> <button cdsButton="primary" + [attr.data-test-id]="testIds.saveButton" [disabled]="(configurationValid$ | async) === false || (inputDisabled$ | async)" (click)="save()" > diff --git a/frontend/projects/valtimo/plugin-management/src/lib/components/plugin-edit-modal/plugin-edit-modal.component.ts b/frontend/projects/valtimo/plugin-management/src/lib/components/plugin-edit-modal/plugin-edit-modal.component.ts index dcc0e7cb2a..9dc5cca80c 100644 --- a/frontend/projects/valtimo/plugin-management/src/lib/components/plugin-edit-modal/plugin-edit-modal.component.ts +++ b/frontend/projects/valtimo/plugin-management/src/lib/components/plugin-edit-modal/plugin-edit-modal.component.ts @@ -19,6 +19,7 @@ import {PluginManagementStateService} from '../../services'; import {take} from 'rxjs/operators'; import {BehaviorSubject, Observable} from 'rxjs'; import { + PLUGIN_EDIT_MODAL_TEST_IDS, PluginConfiguration, PluginConfigurationData, PluginManagementService, @@ -41,6 +42,7 @@ export class PluginEditModalComponent { public readonly selectedPluginConfiguration$: Observable<PluginConfiguration> = this.stateService.selectedPluginConfiguration$; public readonly configurationValid$ = new BehaviorSubject<boolean>(false); + public readonly testIds = PLUGIN_EDIT_MODAL_TEST_IDS; constructor( private readonly stateService: PluginManagementStateService, diff --git a/frontend/projects/valtimo/plugin/src/lib/constants/plugin.test-ids.ts b/frontend/projects/valtimo/plugin/src/lib/constants/plugin.test-ids.ts index 5076f19577..8b02618d64 100644 --- a/frontend/projects/valtimo/plugin/src/lib/constants/plugin.test-ids.ts +++ b/frontend/projects/valtimo/plugin/src/lib/constants/plugin.test-ids.ts @@ -106,6 +106,21 @@ export const ZAKEN_API_CONFIGURATION_TEST_IDS = { authenticationPluginConfiguration: 'zakenApiAuthenticationPluginConfiguration', } as const; +/** + * Required properties of the Zaken API "Create zaak" *action*, configured in the + * last step of the process link wizard. Both are bound to the `v-input` host, so + * a test reaches the field itself through `.locator('input')`. + */ +export const ZAKEN_API_CREATE_ZAAK_ACTION_TEST_IDS = { + rsin: 'zakenApiCreateZaakRsin', + zaaktypeUrl: 'zakenApiCreateZaakZaaktypeUrl', +} as const; + +export const PLUGIN_EDIT_MODAL_TEST_IDS = { + saveButton: 'pluginEditModalSaveButton', + deleteButton: 'pluginEditModalDeleteButton', +} as const; + export const PLUGIN_CATALOG_TEST_IDS = { tileGrid: 'pluginCatalogTileGrid', tileLogo: 'pluginCatalogTileLogo', diff --git a/frontend/projects/valtimo/plugin/src/lib/plugins/zaken-api/components/create-zaak/create-zaak-configuration.component.html b/frontend/projects/valtimo/plugin/src/lib/plugins/zaken-api/components/create-zaak/create-zaak-configuration.component.html index d68bc09221..56ee9e7dd2 100644 --- a/frontend/projects/valtimo/plugin/src/lib/plugins/zaken-api/components/create-zaak/create-zaak-configuration.component.html +++ b/frontend/projects/valtimo/plugin/src/lib/plugins/zaken-api/components/create-zaak/create-zaak-configuration.component.html @@ -34,6 +34,7 @@ <v-form (valueChange)="onFormValueChanged($event)"> <v-input name="rsin" + [attr.data-test-id]="testIds.rsin" [title]="'rsin' | pluginTranslate: pluginId | async" [tooltip]="'rsinTooltip' | pluginTranslate: pluginId | async" [defaultValue]="obs.prefill?.rsin" @@ -59,6 +60,7 @@ <v-input *ngIf="obs.selectedInputOption === 'text'" name="zaaktypeUrl" + [attr.data-test-id]="testIds.zaaktypeUrl" [title]="'zaakTypeUrl' | pluginTranslate: pluginId | async" [tooltip]="'zaakTypeTooltip' | pluginTranslate: pluginId | async" [defaultValue]="obs.prefill?.zaaktypeUrl" diff --git a/frontend/projects/valtimo/plugin/src/lib/plugins/zaken-api/components/create-zaak/create-zaak-configuration.component.ts b/frontend/projects/valtimo/plugin/src/lib/plugins/zaken-api/components/create-zaak/create-zaak-configuration.component.ts index 9d7352341d..4ac143ce68 100644 --- a/frontend/projects/valtimo/plugin/src/lib/plugins/zaken-api/components/create-zaak/create-zaak-configuration.component.ts +++ b/frontend/projects/valtimo/plugin/src/lib/plugins/zaken-api/components/create-zaak/create-zaak-configuration.component.ts @@ -44,6 +44,7 @@ import {PAYMENT_INDICATION_TYPES} from '../../models/payment-indication-types'; import {CONFIDENTIALITY_TYPES} from '../../models/confidentiality-types'; import {ARCHIVE_NOMINATION_TYPES} from '../../models/archive-nomination-types'; import {ARCHIVE_STATUS_TYPES} from '../../models/archive-status-types'; +import {ZAKEN_API_CREATE_ZAAK_ACTION_TEST_IDS} from '../../../../constants'; @Component({ standalone: false, @@ -55,6 +56,8 @@ import {ARCHIVE_STATUS_TYPES} from '../../models/archive-status-types'; export class CreateZaakConfigurationComponent implements FunctionConfigurationComponent, OnInit, OnDestroy { + protected readonly testIds = ZAKEN_API_CREATE_ZAAK_ACTION_TEST_IDS; + @Input() context$: Observable<[ManagementContext, CaseManagementParams]>; @Input() disabled$: Observable<boolean>; @Input() pluginId: string; diff --git a/frontend/projects/valtimo/process-link/src/lib/components/choose-process-link-type/choose-process-link-type.component.html b/frontend/projects/valtimo/process-link/src/lib/components/choose-process-link-type/choose-process-link-type.component.html index a0e1cbbac8..12df0a5710 100644 --- a/frontend/projects/valtimo/process-link/src/lib/components/choose-process-link-type/choose-process-link-type.component.html +++ b/frontend/projects/valtimo/process-link/src/lib/components/choose-process-link-type/choose-process-link-type.component.html @@ -22,13 +22,14 @@ <p class="step-description"> {{ 'processLinkConfiguration.chooseProcessLinkTypeDescription' | translate }} </p> - <div class="process-link-grid"> + <div class="process-link-grid" [attr.data-test-id]="testIds.container"> <div class="process-link-item" *ngFor="let availableProcessLinkType of obs.availableProcessLinkTypes" > <ng-container *ngIf="availableProcessLinkType.processLinkType as processLinkTypeId"> <button + [attr.data-test-id]="testIdPrefix + processLinkTypeId" (click)="selectProcessLinkType(processLinkTypeId)" cdsButton="secondary" size="xl" diff --git a/frontend/projects/valtimo/process-link/src/lib/components/choose-process-link-type/choose-process-link-type.component.ts b/frontend/projects/valtimo/process-link/src/lib/components/choose-process-link-type/choose-process-link-type.component.ts index f30070bdda..ca788f32c9 100644 --- a/frontend/projects/valtimo/process-link/src/lib/components/choose-process-link-type/choose-process-link-type.component.ts +++ b/frontend/projects/valtimo/process-link/src/lib/components/choose-process-link-type/choose-process-link-type.component.ts @@ -16,7 +16,11 @@ import {Component} from '@angular/core'; import {ProcessLinkStateService} from '../../services'; -import {UNSUPPORTED_PROCESS_LINK_TYPES_IN_BUILDING_BLOCK} from '../../constants'; +import { + PROCESS_LINK_TYPE_BUTTON_TEST_ID_PREFIX, + PROCESS_LINK_TYPE_CHOOSER_TEST_IDS, + UNSUPPORTED_PROCESS_LINK_TYPES_IN_BUILDING_BLOCK, +} from '../../constants'; @Component({ standalone: false, @@ -25,6 +29,9 @@ import {UNSUPPORTED_PROCESS_LINK_TYPES_IN_BUILDING_BLOCK} from '../../constants' styleUrls: ['./choose-process-link-type.component.scss'], }) export class ChooseProcessLinkTypeComponent { + protected readonly testIds = PROCESS_LINK_TYPE_CHOOSER_TEST_IDS; + protected readonly testIdPrefix = PROCESS_LINK_TYPE_BUTTON_TEST_ID_PREFIX; + public readonly availableProcessLinkTypes$ = this.processLinkStateService.availableProcessLinkTypes$; diff --git a/frontend/projects/valtimo/process-link/src/lib/components/plugin-action-configuration/plugin-action-configuration.component.html b/frontend/projects/valtimo/process-link/src/lib/components/plugin-action-configuration/plugin-action-configuration.component.html index 9c6b37766c..3ca9f0cf47 100644 --- a/frontend/projects/valtimo/process-link/src/lib/components/plugin-action-configuration/plugin-action-configuration.component.html +++ b/frontend/projects/valtimo/process-link/src/lib/components/plugin-action-configuration/plugin-action-configuration.component.html @@ -15,6 +15,7 @@ --> <valtimo-import-plugin-configuration + [attr.data-test-id]="testIds.container" [pluginActionKey]="functionKey$ | async" (configurationEvent)="onImportConfiguration($event)" ></valtimo-import-plugin-configuration> diff --git a/frontend/projects/valtimo/process-link/src/lib/components/plugin-action-configuration/plugin-action-configuration.component.ts b/frontend/projects/valtimo/process-link/src/lib/components/plugin-action-configuration/plugin-action-configuration.component.ts index 8a1b42704c..08f12d51a1 100644 --- a/frontend/projects/valtimo/process-link/src/lib/components/plugin-action-configuration/plugin-action-configuration.component.ts +++ b/frontend/projects/valtimo/process-link/src/lib/components/plugin-action-configuration/plugin-action-configuration.component.ts @@ -31,6 +31,7 @@ import { PluginProcessLinkUpdateDto, ProcessLink, } from '../../models'; +import {PLUGIN_ACTION_CONFIGURATION_TEST_IDS} from '../../constants'; @Component({ standalone: false, @@ -39,6 +40,8 @@ import { styleUrls: ['./plugin-action-configuration.component.scss'], }) export class PluginActionConfigurationComponent implements OnInit, OnDestroy { + protected readonly testIds = PLUGIN_ACTION_CONFIGURATION_TEST_IDS; + @Input() selectedPluginConfiguration$: Observable<PluginConfiguration>; @Output() valid: EventEmitter<boolean> = new EventEmitter<boolean>(); @Output() configuration: EventEmitter<PluginConfigurationData> = @@ -133,7 +136,8 @@ export class PluginActionConfigurationComponent implements OnInit, OnDestroy { ? (selectedProcessLink.pluginConfigurationId ?? '') : undefined; // Use the currently selected function key (user may have changed it) - const actionKey = selectedFunction?.key ?? selectedProcessLink.pluginActionDefinitionKey ?? ''; + const actionKey = + selectedFunction?.key ?? selectedProcessLink.pluginActionDefinitionKey ?? ''; const updateProcessLinkRequest: PluginProcessLinkUpdateDto = { id: selectedProcessLink.id, pluginConfigurationId, diff --git a/frontend/projects/valtimo/process-link/src/lib/components/process-link-modal/process-link-modal.component.html b/frontend/projects/valtimo/process-link/src/lib/components/process-link-modal/process-link-modal.component.html index fb4096fcd1..52d10da376 100644 --- a/frontend/projects/valtimo/process-link/src/lib/components/process-link-modal/process-link-modal.component.html +++ b/frontend/projects/valtimo/process-link/src/lib/components/process-link-modal/process-link-modal.component.html @@ -37,22 +37,31 @@ > <cds-modal valtimoCdsModal [open]="obs.showModal" size="lg" (close)="closeModal()"> <cds-modal-header (closeSelect)="closeModal()"> - <h2 *ngIf="!obs.isAdHocBuildingBlock" cdsModalHeaderLabel>{{ 'processLinkConfiguration.configureStep' | translate }}</h2> - <h3 cdsModalHeaderHeading> + <h2 *ngIf="!obs.isAdHocBuildingBlock" cdsModalHeaderLabel> + {{ 'processLinkConfiguration.configureStep' | translate }} + </h2> + <h3 cdsModalHeaderHeading [attr.data-test-id]="testIds.heading"> <ng-container *ngIf="obs.isAdHocBuildingBlock; else defaultModalTitle"> - {{ 'processLinkConfiguration.buildingBlockActionTitle' | translate: {buildingBlockName: obs.processStepName || '-'} }} + {{ + 'processLinkConfiguration.buildingBlockActionTitle' + | translate: {buildingBlockName: obs.processStepName || '-'} + }} </ng-container> <ng-template #defaultModalTitle> {{ 'processLinkConfiguration.modalTitle' - | translate: {processStepName: obs.processStepName || '-'} + | translate: {processStepName: obs.processStepName || '-'} }} </ng-template> </h3> - <h4 cdsModalHeaderLabel *ngIf="obs.typeOfSelectedProgressLink === 'plugin'"> + <h4 + cdsModalHeaderLabel + *ngIf="obs.typeOfSelectedProgressLink === 'plugin'" + [attr.data-test-id]="testIds.pluginConfigurationLabel" + > {{ 'processLinkConfiguration.pluginConfiguration' - | translate: {pluginConfigTitle: obs.selectedPluginConfiguration?.title || '-'} + | translate: {pluginConfigTitle: obs.selectedPluginConfiguration?.title || '-'} }} </h4> <div class="process-link-progress"> @@ -69,6 +78,7 @@ <h4 cdsModalHeaderLabel *ngIf="obs.typeOfSelectedProgressLink === 'plugin'"> <ng-template #progressIndicator let-obs="obs"> <cds-progress-indicator *ngIf="obs.steps && !obs.hideProgressIndicator" + [attr.data-test-id]="testIds.progressIndicator" [steps]="obs.steps" [current]="obs.currentStepIndex" spacing="equal" @@ -140,13 +150,19 @@ <h4 cdsModalHeaderLabel *ngIf="obs.typeOfSelectedProgressLink === 'plugin'"> <ng-template #footer let-obs="obs"> <cds-modal-footer *ngIf="!obs.saving"> - <button cdsButton="ghost" (click)="closeModal()" [disabled]="obs.disabled"> + <button + cdsButton="ghost" + [attr.data-test-id]="testIds.cancelButton" + (click)="closeModal()" + [disabled]="obs.disabled" + > {{ 'processLinkConfiguration.cancel' | translate }} </button> <div class="footer-spacer"></div> <button cdsButton="danger--primary" *ngIf="obs.isEditing" + [attr.data-test-id]="testIds.unlinkButton" (click)="unlinkButtonClick()" > {{ 'processLinkConfiguration.unlink' | translate }} @@ -154,6 +170,7 @@ <h4 cdsModalHeaderLabel *ngIf="obs.typeOfSelectedProgressLink === 'plugin'"> <button *ngIf="obs.showBackButton && !obs.saving" cdsButton="secondary" + [attr.data-test-id]="testIds.backButton" (click)="backButtonClick()" > {{ 'processLinkConfiguration.back' | translate }} @@ -161,6 +178,7 @@ <h4 cdsModalHeaderLabel *ngIf="obs.typeOfSelectedProgressLink === 'plugin'"> <button cdsButton="primary" *ngIf="obs.showNextButton && !obs.saving" + [attr.data-test-id]="testIds.nextButton" [disabled]="!obs.enableNextButton" (click)="nextButtonClick()" > @@ -169,6 +187,7 @@ <h4 cdsModalHeaderLabel *ngIf="obs.typeOfSelectedProgressLink === 'plugin'"> <button cdsButton="primary" *ngIf="obs.showSaveButton && !obs.saving" + [attr.data-test-id]="testIds.completeButton" [disabled]="!obs.enableSaveButton" (click)="saveButtonClick()" > diff --git a/frontend/projects/valtimo/process-link/src/lib/components/process-link-modal/process-link-modal.component.ts b/frontend/projects/valtimo/process-link/src/lib/components/process-link-modal/process-link-modal.component.ts index 4cf12f7eab..74f2497d3b 100644 --- a/frontend/projects/valtimo/process-link/src/lib/components/process-link-modal/process-link-modal.component.ts +++ b/frontend/projects/valtimo/process-link/src/lib/components/process-link-modal/process-link-modal.component.ts @@ -23,6 +23,7 @@ import { } from '../../services'; import {take} from 'rxjs/operators'; import {ConfigService} from '@valtimo/shared'; +import {PROCESS_LINK_MODAL_TEST_IDS} from '../../constants'; @Component({ standalone: false, @@ -31,6 +32,8 @@ import {ConfigService} from '@valtimo/shared'; styleUrls: ['./process-link-modal.component.scss'], }) export class ProcessLinkModalComponent { + protected readonly testIds = PROCESS_LINK_MODAL_TEST_IDS; + public readonly showModal$ = this.stateService.showModal$; public readonly processStepName$ = this.stateService.elementName$; public readonly steps$ = this.stepService.steps$; diff --git a/frontend/projects/valtimo/process-link/src/lib/components/select-plugin-action/select-plugin-action.component.html b/frontend/projects/valtimo/process-link/src/lib/components/select-plugin-action/select-plugin-action.component.html index 1821f2fde3..1f8e17f78b 100644 --- a/frontend/projects/valtimo/process-link/src/lib/components/select-plugin-action/select-plugin-action.component.html +++ b/frontend/projects/valtimo/process-link/src/lib/components/select-plugin-action/select-plugin-action.component.html @@ -15,18 +15,24 @@ --> <ng-container *ngIf="pluginFunctions$ | async as pluginFunctions; else loading"> - <div class="choose-plugin-action" *ngIf="pluginFunctions?.length > 0"> + <div + class="choose-plugin-action" + *ngIf="pluginFunctions?.length > 0" + [attr.data-test-id]="testIds.container" + > <p class="step-description"> {{ 'processLinkConfiguration.choosePluginActionDescription' | translate }} </p> <div class="action-tiles"> <cds-tile-group + [attr.data-test-id]="testIds.tileGroup" [multiple]="false" *ngIf="{selectedPluginDefinition: selectedPluginDefinition$ | async} as obs" (selected)="selected($event)" > <cds-selection-tile *ngFor="let pluginFunction of pluginFunctions" + [attr.data-test-id]="tileTestIdPrefix + pluginFunction.key" [value]="stringify(pluginFunction)" [selected]="false" >{{ @@ -42,7 +48,7 @@ </ng-container> <ng-template #noPluginFunctionsTemplate> - <p class="step-description"> + <p class="step-description" [attr.data-test-id]="testIds.noActionsMessage"> {{ 'processLink.noPluginFunctions' | translate }} </p> </ng-template> diff --git a/frontend/projects/valtimo/process-link/src/lib/components/select-plugin-action/select-plugin-action.component.ts b/frontend/projects/valtimo/process-link/src/lib/components/select-plugin-action/select-plugin-action.component.ts index 58c777ac17..c50d2a1092 100644 --- a/frontend/projects/valtimo/process-link/src/lib/components/select-plugin-action/select-plugin-action.component.ts +++ b/frontend/projects/valtimo/process-link/src/lib/components/select-plugin-action/select-plugin-action.component.ts @@ -14,7 +14,12 @@ * limitations under the License. */ import {Component, Injector, OnDestroy, OnInit} from '@angular/core'; -import {PluginDefinition, PluginFunction, PluginManagementService, PluginService} from '@valtimo/plugin'; +import { + PluginDefinition, + PluginFunction, + PluginManagementService, + PluginService, +} from '@valtimo/plugin'; import {combineLatest, forkJoin, Observable, of, Subscription} from 'rxjs'; import {filter, map, switchMap, take, withLatestFrom} from 'rxjs/operators'; @@ -24,6 +29,10 @@ import { ProcessLinkStateService, ProcessLinkStepService, } from '../../services'; +import { + SELECT_PLUGIN_ACTION_TEST_IDS, + SELECT_PLUGIN_ACTION_TILE_TEST_ID_PREFIX, +} from '../../constants'; @Component({ standalone: false, @@ -32,6 +41,9 @@ import { styleUrls: ['./select-plugin-action.component.scss'], }) export class SelectPluginActionComponent implements OnInit, OnDestroy { + protected readonly testIds = SELECT_PLUGIN_ACTION_TEST_IDS; + protected readonly tileTestIdPrefix = SELECT_PLUGIN_ACTION_TILE_TEST_ID_PREFIX; + public readonly pluginFunctions$: Observable<Array<PluginFunction> | undefined> = combineLatest([ this.stateService.selectedPluginDefinition$, this.processLinkStateService.modalParams$, diff --git a/frontend/projects/valtimo/process-link/src/lib/components/select-plugin-configuration/select-plugin-configuration.component.html b/frontend/projects/valtimo/process-link/src/lib/components/select-plugin-configuration/select-plugin-configuration.component.html index b3e67b1948..1ef24ca019 100644 --- a/frontend/projects/valtimo/process-link/src/lib/components/select-plugin-configuration/select-plugin-configuration.component.html +++ b/frontend/projects/valtimo/process-link/src/lib/components/select-plugin-configuration/select-plugin-configuration.component.html @@ -15,11 +15,15 @@ --> <ng-container *ngIf="listItems$ | async as items; else loading"> - <p class="step-description"> + <p class="step-description" [attr.data-test-id]="testIds.container"> {{ pageHeaderText$ | async | translate }} </p> <div class="list-container"> - <cds-structured-list (selected)="selected($event)" selection="true"> + <cds-structured-list + [attr.data-test-id]="testIds.list" + (selected)="selected($event)" + selection="true" + > <cds-list-header> <cds-list-column nowrap="true"></cds-list-column> <cds-list-column nowrap="true"> @@ -29,7 +33,11 @@ {{ 'processLinkConfiguration.pluginDescription' | translate }} </cds-list-column> </cds-list-header> - <cds-list-row *ngFor="let item of items" [value]="item.payload"> + <cds-list-row + *ngFor="let item of items" + [attr.data-test-id]="rowTestIdPrefix + item.id" + [value]="item.payload" + > <cds-list-column> <div *ngIf="item.logo"> <img [src]="item.logo" [title]="item.title" class="plugin-configuration-logo" /> diff --git a/frontend/projects/valtimo/process-link/src/lib/components/select-plugin-configuration/select-plugin-configuration.component.ts b/frontend/projects/valtimo/process-link/src/lib/components/select-plugin-configuration/select-plugin-configuration.component.ts index c0265027b0..6667fc8a12 100644 --- a/frontend/projects/valtimo/process-link/src/lib/components/select-plugin-configuration/select-plugin-configuration.component.ts +++ b/frontend/projects/valtimo/process-link/src/lib/components/select-plugin-configuration/select-plugin-configuration.component.ts @@ -31,6 +31,10 @@ import { ProcessLinkStepService, } from '../../services'; import {PluginListItem} from '../../models'; +import { + SELECT_PLUGIN_CONFIGURATION_ROW_TEST_ID_PREFIX, + SELECT_PLUGIN_CONFIGURATION_TEST_IDS, +} from '../../constants'; @Component({ standalone: false, @@ -39,6 +43,9 @@ import {PluginListItem} from '../../models'; styleUrls: ['./select-plugin-configuration.component.scss'], }) export class SelectPluginConfigurationComponent implements OnInit, OnDestroy { + protected readonly testIds = SELECT_PLUGIN_CONFIGURATION_TEST_IDS; + protected readonly rowTestIdPrefix = SELECT_PLUGIN_CONFIGURATION_ROW_TEST_ID_PREFIX; + readonly isBuildingBlockContext$ = this.stateService.context$.pipe( map(context => context === 'buildingBlock') ); diff --git a/frontend/projects/valtimo/process-link/src/lib/constants/process-link.test-ids.ts b/frontend/projects/valtimo/process-link/src/lib/constants/process-link.test-ids.ts index 635891b542..caeb8b21c8 100644 --- a/frontend/projects/valtimo/process-link/src/lib/constants/process-link.test-ids.ts +++ b/frontend/projects/valtimo/process-link/src/lib/constants/process-link.test-ids.ts @@ -14,6 +14,57 @@ * limitations under the License. */ +/** The process link wizard shell: header and footer navigation. */ +export const PROCESS_LINK_MODAL_TEST_IDS = { + heading: 'processLinkModalHeading', + pluginConfigurationLabel: 'processLinkModalPluginConfigurationLabel', + progressIndicator: 'processLinkModalProgressIndicator', + cancelButton: 'processLinkModalCancelButton', + unlinkButton: 'processLinkModalUnlinkButton', + backButton: 'processLinkModalBackButton', + nextButton: 'processLinkModalNextButton', + completeButton: 'processLinkModalCompleteButton', +} as const; + +export const PROCESS_LINK_TYPE_CHOOSER_TEST_IDS = { + container: 'processLinkTypeChooser', +} as const; + +/** + * Prefix for the buttons of the "Choose link type" step. The full test id is + * `processLinkTypeButton-<processLinkType>`, e.g. `processLinkTypeButton-plugin`. + */ +export const PROCESS_LINK_TYPE_BUTTON_TEST_ID_PREFIX = 'processLinkTypeButton-'; + +export const SELECT_PLUGIN_CONFIGURATION_TEST_IDS = { + container: 'selectPluginConfiguration', + list: 'selectPluginConfigurationList', +} as const; + +/** + * Prefix for the rows of the plugin selection step. The full test id is + * `selectPluginConfigurationRow-<id>`, where the id is the plugin *definition* + * key in a building block (e.g. `zakenapi`) and the plugin *configuration* id in + * a case — the step lists definitions in the one and configurations in the other. + */ +export const SELECT_PLUGIN_CONFIGURATION_ROW_TEST_ID_PREFIX = 'selectPluginConfigurationRow-'; + +export const SELECT_PLUGIN_ACTION_TEST_IDS = { + container: 'selectPluginAction', + tileGroup: 'selectPluginActionTileGroup', + noActionsMessage: 'selectPluginActionNoActions', +} as const; + +/** + * Prefix for the action tiles. The full test id is + * `selectPluginActionTile-<pluginFunctionKey>`. + */ +export const SELECT_PLUGIN_ACTION_TILE_TEST_ID_PREFIX = 'selectPluginActionTile-'; + +export const PLUGIN_ACTION_CONFIGURATION_TEST_IDS = { + container: 'pluginActionConfiguration', +} as const; + export const BB_MAPPINGS_TEST_IDS = { inputSection: 'bbMappingsInputSection', outputSection: 'bbMappingsOutputSection', diff --git a/frontend/projects/valtimo/process-management/src/lib/components/process-management-builder/panel/valtimo-properties-provider.ts b/frontend/projects/valtimo/process-management/src/lib/components/process-management-builder/panel/valtimo-properties-provider.ts index b58c109740..3ab657d5e1 100644 --- a/frontend/projects/valtimo/process-management/src/lib/components/process-management-builder/panel/valtimo-properties-provider.ts +++ b/frontend/projects/valtimo/process-management/src/lib/components/process-management-builder/panel/valtimo-properties-provider.ts @@ -17,6 +17,7 @@ import {useService} from 'bpmn-js-properties-panel'; import {html} from 'htm/preact'; import {is} from 'bpmn-js/lib/util/ModelUtil'; +import {PROCESS_LINK_PANEL_TEST_IDS} from '../../../constants'; import {ProcessManagementEditorService} from '../../../services'; import { BpmnElement, @@ -218,6 +219,7 @@ const CustomRootElement = (props: { <div class="process-link-properties-panel__buttons"> <button class="cds--btn cds--btn--danger cds--btn--sm cds--layout--side-md" + data-test-id=${PROCESS_LINK_PANEL_TEST_IDS.unlinkButton} onClick=${handleUnlinkClick} > ${unlinkText} @@ -225,6 +227,7 @@ const CustomRootElement = (props: { <button class="cds--btn cds--btn--primary cds--btn--sm cds--layout--size-md" + data-test-id=${PROCESS_LINK_PANEL_TEST_IDS.editButton} onClick=${handleEditClick} > ${editProcessLinkText} @@ -253,6 +256,7 @@ const CustomRootElement = (props: { <div class="process-link-properties-panel__buttons"> <button class="cds--btn cds--btn--danger cds--btn--sm cds--layout--side-md" + data-test-id=${PROCESS_LINK_PANEL_TEST_IDS.unlinkButton} onClick=${handleUnlinkClick} > ${unlinkText} @@ -260,6 +264,7 @@ const CustomRootElement = (props: { <button class="cds--btn cds--btn--primary cds--btn--sm cds--layout--size-md" + data-test-id=${PROCESS_LINK_PANEL_TEST_IDS.editButton} onClick=${handleEditClick} > ${editProcessLinkText} @@ -289,6 +294,7 @@ const CustomRootElement = (props: { <div class="process-link-properties-panel__buttons"> <button class="cds--btn cds--btn--danger cds--btn--sm cds--layout--side-md" + data-test-id=${PROCESS_LINK_PANEL_TEST_IDS.unlinkButton} onClick=${handleUnlinkClick} > ${unlinkText} @@ -296,6 +302,7 @@ const CustomRootElement = (props: { <button class="cds--btn cds--btn--primary cds--btn--sm cds--layout--size-md" + data-test-id=${PROCESS_LINK_PANEL_TEST_IDS.editButton} onClick=${handleEditClick} > ${editProcessLinkText} @@ -330,6 +337,7 @@ const CustomRootElement = (props: { <div class="process-link-properties-panel__buttons"> <button class="cds--btn cds--btn--danger cds--btn--sm cds--layout--side-md" + data-test-id=${PROCESS_LINK_PANEL_TEST_IDS.unlinkButton} onClick=${handleUnlinkClick} > ${unlinkText} @@ -337,6 +345,7 @@ const CustomRootElement = (props: { <button class="cds--btn cds--btn--primary cds--btn--sm cds--layout--size-md" + data-test-id=${PROCESS_LINK_PANEL_TEST_IDS.editButton} onClick=${handleEditClick} > ${editProcessLinkText} @@ -363,6 +372,7 @@ const CustomRootElement = (props: { <div class="process-link-properties-panel__buttons"> <button class="cds--btn cds--btn--danger cds--btn--sm cds--layout--side-md" + data-test-id=${PROCESS_LINK_PANEL_TEST_IDS.unlinkButton} onClick=${handleUnlinkClick} > ${unlinkText} @@ -370,6 +380,7 @@ const CustomRootElement = (props: { <button class="cds--btn cds--btn--primary cds--btn--sm cds--layout--size-md" + data-test-id=${PROCESS_LINK_PANEL_TEST_IDS.editButton} onClick=${handleEditClick} > ${editProcessLinkText} @@ -382,6 +393,7 @@ const CustomRootElement = (props: { <div class="process-link-properties-panel__buttons"> <button class="cds--btn cds--btn--danger cds--btn--sm cds--layout--side-md" + data-test-id=${PROCESS_LINK_PANEL_TEST_IDS.unlinkButton} onClick=${handleUnlinkClick} > ${unlinkText} @@ -389,6 +401,7 @@ const CustomRootElement = (props: { <button class="cds--btn cds--btn--primary cds--btn--sm cds--layout--size-md" + data-test-id=${PROCESS_LINK_PANEL_TEST_IDS.editButton} onClick=${handleEditClick} > ${editProcessLinkText} @@ -400,6 +413,7 @@ const CustomRootElement = (props: { <div class="process-link-properties-panel__buttons"> <button class="cds--btn cds--btn--primary cds--btn--sm cds--layout--size-md" + data-test-id=${PROCESS_LINK_PANEL_TEST_IDS.createButton} onClick=${handleCreateClick} > ${createText} diff --git a/frontend/projects/valtimo/process-management/src/lib/components/process-management-builder/process-management-builder.component.html b/frontend/projects/valtimo/process-management/src/lib/components/process-management-builder/process-management-builder.component.html index 84df2e26ad..f7cb5e66be 100644 --- a/frontend/projects/valtimo/process-management/src/lib/components/process-management-builder/process-management-builder.component.html +++ b/frontend/projects/valtimo/process-management/src/lib/components/process-management-builder/process-management-builder.component.html @@ -36,6 +36,7 @@ class="validation-errors-list" *ngIf="validationErrors$ | async as validationErrors" [style.display]="validationErrors.length > 0 ? '' : 'none'" + [attr.data-test-id]="testIds.validationErrors" > <details open> <summary class="validation-errors-list__header"> @@ -135,6 +136,7 @@ <cds-dropdown *ngIf="!actionsObs.creatingNewProcess && actionsObs?.context === 'independent'" class="page-header-actions__version-dropdown" + [attr.data-test-id]="testIds.versionDropdown" (selected)="selectedVersionChange($event)" [disabled]=" actionsObs.loading || @@ -159,12 +161,16 @@ <cds-tag *ngIf="actionsObs.isReadOnlyProcess || !actionsObs.hasEditPermissions" type="blue" + [attr.data-test-id]="testIds.readOnlyTag" >{{ 'processManagement.readOnly' | translate }}</cds-tag > - <cds-tag *ngIf="actionsObs.isSystemProcess" type="red">{{ - 'processManagement.systemProcess' | translate - }}</cds-tag> + <cds-tag + *ngIf="actionsObs.isSystemProcess" + type="red" + [attr.data-test-id]="testIds.systemProcessTag" + >{{ 'processManagement.systemProcess' | translate }}</cds-tag + > </div> <div class="page-header-actions__toggles"> @@ -230,9 +236,13 @@ <div class="page-header-actions__buttons"> <v-overflow-menu class="overflow-button" placement="bottom-end"> - <v-overflow-menu-trigger overflowTrigger></v-overflow-menu-trigger> + <v-overflow-menu-trigger + overflowTrigger + [attr.data-test-id]="testIds.moreButton" + ></v-overflow-menu-trigger> <v-overflow-menu-option [disabled]="actionsObs.loading" + [attr.data-test-id]="testIds.exportButton" (selected)="export(actionsObs.isReadOnlyProcess)" >{{ 'interface.export' | translate }} </v-overflow-menu-option> @@ -252,6 +262,7 @@ cdsButton="secondary" [size]="actionsObs.compactMode ? 'sm' : 'md'" [disabled]="actionsObs.loading" + [attr.data-test-id]="testIds.validateButton" (click)="validateProcessDefinition(actionsObs.isReadOnlyProcess)" > {{ 'processManagement.validate' | translate }} diff --git a/frontend/projects/valtimo/process-management/src/lib/components/process-management-upload/process-management-upload.component.html b/frontend/projects/valtimo/process-management/src/lib/components/process-management-upload/process-management-upload.component.html index 1e5e4095b7..30a015ccd7 100644 --- a/frontend/projects/valtimo/process-management/src/lib/components/process-management-upload/process-management-upload.component.html +++ b/frontend/projects/valtimo/process-management/src/lib/components/process-management-upload/process-management-upload.component.html @@ -28,17 +28,19 @@ <h3 cdsModalHeaderHeading>{{ 'processManagement.upload.modalTitle' | translate } [title]="'processManagement.upload.title' | translate" buttonType="primary" formControlName="file" + [attr.data-test-id]="testIds.fileUploader" > </cds-file-uploader> </section> <cds-modal-footer> - <button cdsButton="secondary" (click)="closeModal()"> + <button cdsButton="secondary" [attr.data-test-id]="testIds.cancelButton" (click)="closeModal()"> {{ 'interface.cancel' | translate }} </button> <button cdsButton="primary" + [attr.data-test-id]="testIds.submitButton" [disabled]="(fileSelected$ | async) === false" (click)="uploadProcessBpmn()" > diff --git a/frontend/projects/valtimo/process-management/src/lib/components/process-management-upload/process-management-upload.component.ts b/frontend/projects/valtimo/process-management/src/lib/components/process-management-upload/process-management-upload.component.ts index fe0ae0b96b..c84ae708e7 100644 --- a/frontend/projects/valtimo/process-management/src/lib/components/process-management-upload/process-management-upload.component.ts +++ b/frontend/projects/valtimo/process-management/src/lib/components/process-management-upload/process-management-upload.component.ts @@ -33,6 +33,7 @@ import { ModalModule, } from 'carbon-components-angular'; import {BehaviorSubject, from, map, startWith, switchMap} from 'rxjs'; +import {PROCESS_MANAGEMENT_UPLOAD_TEST_IDS} from '../../constants'; import {ProcessManagementService, ProcessManagementStateService} from '../../services'; @Component({ @@ -63,6 +64,8 @@ export class ProcessManagementUploadComponent { public readonly ACCEPTED_FILES: string[] = ['bpmn']; + public readonly testIds = PROCESS_MANAGEMENT_UPLOAD_TEST_IDS; + public readonly form = this.formBuilder.group({ file: this.formBuilder.control(new Set<any>(), [Validators.required]), }); diff --git a/frontend/projects/valtimo/process-management/src/lib/constants/process-management.test-ids.ts b/frontend/projects/valtimo/process-management/src/lib/constants/process-management.test-ids.ts index 1d0e3782f2..b0f7a1d6f3 100644 --- a/frontend/projects/valtimo/process-management/src/lib/constants/process-management.test-ids.ts +++ b/frontend/projects/valtimo/process-management/src/lib/constants/process-management.test-ids.ts @@ -19,9 +19,33 @@ export const PROCESS_MANAGEMENT_LIST_TEST_IDS = { createProcessButton: 'processManagementCreateProcessButton', } as const; +export const PROCESS_MANAGEMENT_UPLOAD_TEST_IDS = { + fileUploader: 'processManagementUploadFileUploader', + cancelButton: 'processManagementUploadCancelButton', + submitButton: 'processManagementUploadSubmitButton', +} as const; + +/** + * The "Process link" group the Valtimo properties provider adds to the bpmn-js + * properties panel. The surrounding panel is rendered by bpmn-js itself and + * cannot carry test ids — only this group is ours. + */ +export const PROCESS_LINK_PANEL_TEST_IDS = { + createButton: 'processLinkPanelCreateButton', + editButton: 'processLinkPanelEditButton', + unlinkButton: 'processLinkPanelUnlinkButton', +} as const; + export const PROCESS_MANAGEMENT_BUILDER_TEST_IDS = { draftToggle: 'processManagementBuilderDraftToggle', startsCaseToggle: 'processManagementBuilderStartsCaseToggle', startableByUserToggle: 'processManagementBuilderStartableByUserToggle', deployButton: 'processManagementBuilderDeployButton', + versionDropdown: 'processManagementBuilderVersionDropdown', + validateButton: 'processManagementBuilderValidateButton', + moreButton: 'processManagementBuilderMoreButton', + exportButton: 'processManagementBuilderExportButton', + readOnlyTag: 'processManagementBuilderReadOnlyTag', + systemProcessTag: 'processManagementBuilderSystemProcessTag', + validationErrors: 'processManagementBuilderValidationErrors', } as const;