diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..46749f2 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,29 @@ +name: test + +on: + push: + branches: [main] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version-file: .nvmrc + # Until cap2ui5 0.2.0 is on npm, the samples run against cap2UI5's main: + # its plugin packed as `npm publish` packs it, and installed alone - + # which brings the other dependencies along, as the README says for a + # local checkout. Once 0.2.0 is published this goes, a package-lock.json + # pins it, and `cache: npm` and npm ci come back. + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: cap2UI5/cap2UI5 + path: .cap2ui5 + persist-credentials: false + - run: npm pack --pack-destination "$RUNNER_TEMP" + working-directory: .cap2ui5/plugin + - run: npm install --no-save --no-audit --no-fund "$RUNNER_TEMP"/cap2ui5-*.tgz + - run: npm test diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cf0d054 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +*.sqlite +*.sqlite-* +*.log +.DS_Store diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..2bd5a0a --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +22 diff --git a/README.md b/README.md index 9f0d6f2..876ae9a 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,102 @@ -# samples -Samples of cap2UI5 +# cap2UI5 samples + +The [abap2UI5 samples](https://github.com/abap2UI5/samples) as +[cap2UI5](https://github.com/cap2UI5/cap2UI5) apps: every sample a plain +JavaScript class in `srv/apps/`, in an ordinary CAP project - and line for +line the ABAP original. The client is abap2UI5's `z2ui5_if_client` by its own +names, the view is built with `z2ui5_cl_ui5_view_builder`, called the way ABAP +calls it: `client->check_app_prev_stack( )` is +`client.check_app_prev_stack()`. + +> [!NOTE] +> **Status: first examples.** Nine of the 129 apps are ported, to agree on the +> conventions below before the rest follows. They need **cap2ui5 0.2.0**, +> which is not on npm yet (the client under its ABAP names and the view +> builder are in its `Unreleased` changelog). Until it is, a plain +> `npm install` answers `ETARGET`; install the plugin packed from a cap2UI5 +> checkout instead, which brings the other dependencies along - CI does the +> same with cap2UI5's `main`: +> +> ```bash +> (cd ../cap2UI5 && scripts/assemble-runtime.sh --package 1.145.0 && npm install \ +> && npm pack --workspace plugin --pack-destination /tmp) +> npm install --no-save /tmp/cap2ui5-*.tgz +> ``` + +## Run + +```bash +npm install +npm run watch # cds watch - prints the address of every sample +npm test # every sample driven over the wire +``` + +Log in as `alice` with an empty password (CAP's mocked development user), then +open e.g. . + +## The samples so far + +| Sample | App | ABAP original | +|---|---|---| +| Basics I - Hello World, the Smallest App | [`Z2UI5_CL_SMP_APP_493`](srv/apps/z2ui5_cl_smp_app_493.js) | [abap](https://github.com/abap2UI5/samples/blob/main/src/z2ui5_cl_smp_app_493.clas.abap) | +| Basics II - Data Binding: Input and Button | [`Z2UI5_CL_SMP_APP_494`](srv/apps/z2ui5_cl_smp_app_494.js) | [abap](https://github.com/abap2UI5/samples/blob/main/src/z2ui5_cl_smp_app_494.clas.abap) | +| Basics III - Lifecycle: Init, Event, Navigated | [`Z2UI5_CL_SMP_APP_495`](srv/apps/z2ui5_cl_smp_app_495.js) | [abap](https://github.com/abap2UI5/samples/blob/main/src/z2ui5_cl_smp_app_495.clas.abap) | +| Table - Editable Cells, Add and Delete Rows | [`Z2UI5_CL_SMP_APP_011`](srv/apps/z2ui5_cl_smp_app_011.js) | [abap](https://github.com/abap2UI5/samples/blob/main/src/z2ui5_cl_smp_app_011.clas.abap) | +| Event - Extra Arguments with t_arg | [`Z2UI5_CL_SMP_APP_167`](srv/apps/z2ui5_cl_smp_app_167.js) | [abap](https://github.com/abap2UI5/samples/blob/main/src/z2ui5_cl_smp_app_167.clas.abap) | +| Popup - Dialog inside a Dialog | [`Z2UI5_CL_SMP_APP_161`](srv/apps/z2ui5_cl_smp_app_161.js) | [abap](https://github.com/abap2UI5/samples/blob/main/src/z2ui5_cl_smp_app_161.clas.abap) | +| Navigation - Return Data and Events to the Caller | [`Z2UI5_CL_SMP_APP_488`](srv/apps/z2ui5_cl_smp_app_488.js) (+ [`_489`](srv/apps/z2ui5_cl_smp_app_489.js)) | [abap](https://github.com/abap2UI5/samples/blob/main/src/z2ui5_cl_smp_app_488.clas.abap) | +| Browser - Set the Tab Title | [`Z2UI5_CL_SMP_APP_125`](srv/apps/z2ui5_cl_smp_app_125.js) | [abap](https://github.com/abap2UI5/samples/blob/main/src/z2ui5_cl_smp_app_125.clas.abap) | + +## How a sample is ported + +**1:1, so that it is recognized at a glance.** Put the ABAP original and the +port side by side and every line has its counterpart: + +- **One ABAP class, one file, the same name.** `z2ui5_cl_smp_app_493.clas.abap` + becomes `srv/apps/z2ui5_cl_smp_app_493.js`, registered as + `Z2UI5_CL_SMP_APP_493` - so `?app_start=` is the same on both sides, a + sample that calls another one keeps calling it by its name. The header + keeps the original's `@keywords`, `@summary` and `@docs`, and `@origin` + points back at it. +- **The same class.** The same attributes under the same names, the same + methods (`view_display( )`, `on_event( )`, …) in the same order, the same + dispatcher in `main( )`, `me->client = client` as `this.client = client`. +- **The same calls.** The client's methods and the view builder's are the ABAP + ones; a method's preferred parameter is its one positional argument and + parameters by name are one object with the ABAP names. The view keeps the + original's tree and layout: one call per line, four spaces per tree level, + `end()` in the column of the `ele()` it closes, the `v =` column aligned. +- **The same texts.** Only the page title says `cap2UI5 - …`, and a text that + names an ABAP construct (`z2ui5_if_app`, `client->…`) names the JavaScript + one. + +| abap2UI5 | cap2UI5 | +|---|---| +| `DATA name TYPE string.` | `name = "";` - the initial value is the type | +| `DATA t_tab TYPE STANDARD TABLE OF ty_s_row WITH EMPTY KEY.` | `t_tab = t.table({ …one row… });` | +| `METHOD z2ui5_if_app~main.` | `main(client) {` | +| `me->client = client.` / `client->…` in a method | `this.client = client;` / `this.client.…` | +| `IF client->check_on_navigated( ).` | `if (client.check_on_navigated()) {` | +| `client->_bind( name )`, `client->_bind( s_result-product )` | `client._bind("name")`, `client._bind("s_result-product")` | +| `client->_bind( val = t_tab path = abap_true )` | `client._bind({ val: "t_tab", path: true })` | +| ``client->_event( val = `X` arg = `…` )`` | `client._event({ val: "X", arg: "…" })` | +| ``z2ui5_cl_ui5_view_builder=>factory( )->ele( n = `View` ns = `mvc` )`` | `z2ui5_cl_ui5_view_builder.factory().ele({ n: "View", ns: "mvc" })` | +| ``)->a( n = `title` v = `…` )``, `b = abap_true`, `t = …` | `.a({ n: "title", v: "…" })`, `b: true`, `t: …` | +| `client->view_display( view->stringify( ) ).` | `client.view_display(view.stringify());` | +| `z2ui5_if_client=>cs_event-set_title` | `z2ui5_if_client.cs_event.set_title` | +| `client->nav_app_call( NEW z2ui5_cl_smp_app_493( ) )` | `client.nav_app_call("Z2UI5_CL_SMP_APP_493")` | +| `client->nav_app_leave( event = … r_data = … )` | `client.nav_app_leave({ event: …, r_data: … })` | +| `ASSIGN client->get( )-r_event_data->* TO …` | `client.get().r_event_data` - the data itself | +| ``CASE client->get_event( ). WHEN `A` OR `B`.`` | `switch (client.get_event()) { case "A": case "B":` | + +### What differs from ABAP + +1. **A field is bound by its name**, `client._bind("name")`: ABAP's `_bind( )` + finds the attribute by reference, which a JavaScript value cannot carry. +2. **`this` reads plain copies.** Assign to write: + `this.t_tab = [...this.t_tab, row]` for `INSERT … INTO TABLE t_tab`. A + `push` on the copy is lost. Assigning an object replaces a structure, so + `this.s_result = {}` is `s_result = VALUE #( )`. +3. **Every field is part of the model.** ABAP keeps a `PROTECTED` attribute out + of it; a JavaScript field with an initial value is always bound. The client + is the exception: assigned in `main( )` and not declared, it stays out. diff --git a/package.json b/package.json new file mode 100644 index 0000000..16ba91c --- /dev/null +++ b/package.json @@ -0,0 +1,33 @@ +{ + "name": "cap2ui5-samples", + "private": true, + "description": "The abap2UI5 samples as cap2UI5 apps: plain JavaScript classes in a CAP project, views built with ViewBuilder.", + "type": "module", + "engines": { + "node": ">=22" + }, + "dependencies": { + "@cap-js/sqlite": "^3", + "@sap/cds": "^10", + "cap2ui5": "^0.2.0" + }, + "devDependencies": { + "@cap-js/cds-test": "^1.0.2", + "fast-xml-parser": "^5.11.1" + }, + "scripts": { + "start": "cds-serve", + "watch": "cds watch", + "test": "node --test test/*.test.mjs" + }, + "cds": { + "requires": { + "db": { + "kind": "sqlite", + "credentials": { + "url": ":memory:" + } + } + } + } +} diff --git a/srv/apps/z2ui5_cl_smp_app_011.js b/srv/apps/z2ui5_cl_smp_app_011.js new file mode 100644 index 0000000..68ddc9e --- /dev/null +++ b/srv/apps/z2ui5_cl_smp_app_011.js @@ -0,0 +1,151 @@ +// @keywords edit input add row delete multiselect toolbar +// @summary An editable table: input cells, adding and deleting rows, multi-select and a toolbar over them. +// @docs https://abap2ui5.github.io/docs/cookbook/model/tables https://abap2ui5.github.io/docs/tutorials/walkthrough/step-8 https://abap2ui5.github.io/docs/tutorials/walkthrough/step-10 +// @origin abap2UI5/samples src/z2ui5_cl_smp_app_011.clas.abap +import { defineApp, t, z2ui5_cl_ui5_view_builder } from "cap2ui5"; + +defineApp("Z2UI5_CL_SMP_APP_011", class { + + t_tab = t.table({ + selkz: false, + title: "", + value: "", + descr: "", + icon: "", + info: "", + editable: false, + checkbox: false, + }); + + check_editable_active = false; + + view_display() { + + const view = z2ui5_cl_ui5_view_builder.factory() + .ele({ n: "View", ns: "mvc" }) + .a({ n: "displayBlock", v: "true" }) + .a({ n: "height", v: "100%" }) + .a({ n: "xmlns", v: "sap.m" }) + .a({ n: "xmlns:mvc", v: "sap.ui.core.mvc" }); + const page = view.ele("Shell") + .ele("Page") + .a({ n: "title", v: "cap2UI5 - Table - Editable Cells, Add and Delete Rows" }) + .a({ n: "showNavButton", b: this.client.check_app_prev_stack() }) + .a({ n: "navButtonPress", v: this.client._event_nav_app_leave() }) + .a({ n: "id", v: "test2" }); + + page.tag("MessageStrip") + .a({ n: "text", v: "A MultiSelect table whose input cells switch between display and edit mode via the " + + "toolbar, which also adds new rows and deletes the currently selected ones." }) + .a({ n: "type", v: "Information" }) + .a({ n: "showIcon", b: true }) + .a({ n: "class", v: "sapUiSmallMargin" }); + + const tab = page.ele("Table") + .a({ n: "items", v: `{path: '${this.client._bind({ val: "t_tab", path: true })}', templateShareable: false}` }) + .a({ n: "mode", v: "MultiSelect" }) + .ele("headerToolbar") + .ele("OverflowToolbar") + .tag("Title") + .a({ n: "text", v: "title of the table" }) + .tag("Button") + // shows an OverflowToolbar filling up - the press is a plain roundtrip + .a({ n: "press", v: this.client._event("BUTTON_TEST") }) + .a({ n: "text", v: "test" }) + .tag("ToolbarSpacer") + .tag("Button") + .a({ n: "press", v: this.client._event("BUTTON_DELETE") }) + .a({ n: "text", v: "delete selected row" }) + .a({ n: "icon", v: "sap-icon://delete" }) + .tag("Button") + .a({ n: "press", v: this.client._event("BUTTON_ADD") }) + .a({ n: "text", v: "add" }) + .a({ n: "icon", v: "sap-icon://add" }) + .tag("Button") + .a({ n: "press", v: this.client._event("BUTTON_EDIT") }) + .a({ n: "text", t: this.check_editable_active ? "display" : "edit" }) + .a({ n: "tooltip", v: "Switch the cells between display and edit mode" }) + .a({ n: "icon", v: "sap-icon://edit" }) + .end() + .end(); + + tab.ele("columns") + .ele("Column") + .tag("Text") + .a({ n: "text", v: "Title" }) + .end() + .ele("Column") + .tag("Text") + .a({ n: "text", v: "Color" }) + .end() + .ele("Column") + .tag("Text") + .a({ n: "text", v: "Info" }) + .end() + .ele("Column") + .tag("Text") + .a({ n: "text", v: "Description" }) + .end() + .ele("Column") + .tag("Text") + .a({ n: "text", v: "Checkbox" }); + + tab.ele("items") + .ele("ColumnListItem") + .a({ n: "selected", v: "{SELKZ}" }) + .ele("cells") + .tag("Input") + .a({ n: "id", v: "test" }) + .a({ n: "enabled", v: "{EDITABLE}" }) + .a({ n: "value", v: "{TITLE}" }) + .tag("Input") + .a({ n: "enabled", v: "{EDITABLE}" }) + .a({ n: "value", v: "{VALUE}" }) + .tag("Input") + .a({ n: "enabled", v: "{EDITABLE}" }) + .a({ n: "value", v: "{INFO}" }) + .tag("Input") + .a({ n: "enabled", v: "{EDITABLE}" }) + .a({ n: "value", v: "{DESCR}" }) + .tag("CheckBox") + .a({ n: "selected", v: "{CHECKBOX}" }) + .a({ n: "enabled", v: "{EDITABLE}" }); + + this.client.view_display(view.stringify()); + + } + + main(client) { + + this.client = client; + + if (client.check_on_init()) { + + this.check_editable_active = false; + this.t_tab = [ + { title: "entry 01", value: "red", info: "completed", descr: "this is a description", checkbox: true }, + { title: "entry 02", value: "blue", info: "completed", descr: "this is a description", checkbox: true }, + { title: "entry 03", value: "green", info: "completed", descr: "this is a description", checkbox: true }, + { title: "entry 04", value: "orange", info: "completed", descr: "", checkbox: true }, + { title: "entry 05", value: "grey", info: "completed", descr: "this is a description", checkbox: true }, + { } ]; + + this.view_display(); + } else if (client.check_on_navigated()) { + this.view_display(); + + } else if (client.check_on_event("BUTTON_EDIT")) { + this.check_editable_active = !this.check_editable_active; + // a read of t_tab is a copy: the changed rows are written back whole + this.t_tab = this.t_tab.map((row) => ({ ...row, editable: this.check_editable_active })); + + } else if (client.check_on_event("BUTTON_DELETE")) { + this.t_tab = this.t_tab.filter((row) => !row.selkz); + + } else if (client.check_on_event("BUTTON_ADD")) { + + this.t_tab = [...this.t_tab, { editable: this.check_editable_active }]; + } + + } +}); diff --git a/srv/apps/z2ui5_cl_smp_app_125.js b/srv/apps/z2ui5_cl_smp_app_125.js new file mode 100644 index 0000000..905b295 --- /dev/null +++ b/srv/apps/z2ui5_cl_smp_app_125.js @@ -0,0 +1,57 @@ +// @keywords document.title tab caption headline set_title +// @summary Sets the browser tab title from the app, so a bookmarked or duplicated window says which app it holds. +// @docs https://abap2ui5.github.io/docs/cookbook/browser_interaction/title +// @origin abap2UI5/samples src/z2ui5_cl_smp_app_125.clas.abap +import { defineApp, z2ui5_cl_ui5_view_builder, z2ui5_if_client } from "cap2ui5"; + +defineApp("Z2UI5_CL_SMP_APP_125", class { + + title = "my title"; + + main(client) { + + if (client.check_on_navigated()) { + + const view = z2ui5_cl_ui5_view_builder.factory() + .ele({ n: "View", ns: "mvc" }) + .a({ n: "displayBlock", v: "true" }) + .a({ n: "height", v: "100%" }) + .a({ n: "xmlns", v: "sap.m" }) + .a({ n: "xmlns:mvc", v: "sap.ui.core.mvc" }) + .a({ n: "xmlns:form", v: "sap.ui.layout.form" }); + const page = view.ele("Shell") + .ele("Page") + .a({ n: "title", v: "cap2UI5 - Browser - Set the Tab Title" }) + .a({ n: "showNavButton", b: client.check_app_prev_stack() }) + .a({ n: "navButtonPress", v: client._event_nav_app_leave() }); + + page.tag("MessageStrip") + .a({ n: "text", v: "Enter a title and press the button to run the set_title front-end action, which updates " + + "the browser tab title (document.title) without reloading the page." }) + .a({ n: "type", v: "Information" }) + .a({ n: "showIcon", b: true }) + .a({ n: "class", v: "sapUiSmallMargin" }); + + page.ele({ n: "SimpleForm", ns: "form" }) + .a({ n: "title", v: "Form Title" }) + .a({ n: "editable", b: true }) + .ele({ n: "content", ns: "form" }) + .tag("Label") + .a({ n: "text", v: "title" }) + .tag("Input") + .a({ n: "value", v: client._bind("title") }) + .tag("Button") + .a({ n: "press", v: client._event("SET_TITLE") }) + .a({ n: "text", v: "Set Title" }); + client.view_display(view.stringify()); + + } else if (client.check_on_event("SET_TITLE")) { + + client.follow_up_action({ + val: z2ui5_if_client.cs_event.set_title, + t_arg: [this.title] }); + + } + + } +}); diff --git a/srv/apps/z2ui5_cl_smp_app_161.js b/srv/apps/z2ui5_cl_smp_app_161.js new file mode 100644 index 0000000..865f9ee --- /dev/null +++ b/srv/apps/z2ui5_cl_smp_app_161.js @@ -0,0 +1,123 @@ +// @keywords nested stack popup in popup second dialog +// @summary A dialog opened from inside a dialog, and what closing the inner one does to the stack. +// @docs https://abap2ui5.github.io/docs/cookbook/popup_popover/popup +// @origin abap2UI5/samples src/z2ui5_cl_smp_app_161.clas.abap +import { defineApp, z2ui5_cl_ui5_view_builder } from "cap2ui5"; + +defineApp("Z2UI5_CL_SMP_APP_161", class { + + simple_popup1() { + + const popup = z2ui5_cl_ui5_view_builder.factory() + .ele({ n: "FragmentDefinition", ns: "core" }) + .a({ n: "xmlns", v: "sap.m" }) + .a({ n: "xmlns:core", v: "sap.ui.core" }); + + const dialog = popup.ele("Dialog") + .a({ n: "afterClose", v: this.client._event("BTN_OK_1ND") }) + .ele("content"); + + dialog.tag("Button") + .a({ n: "press", v: this.client._event("GOTO_2ND") }) + .a({ n: "text", v: "Open 2nd popup" }); + + dialog.end() + .ele("buttons") + .tag("Button") + .a({ n: "press", v: this.client._event("BTN_OK_1ND") }) + .a({ n: "text", v: "OK" }) + .a({ n: "type", v: "Emphasized" }); + + this.client.popup_display(popup.stringify()); + + } + + simple_popup2() { + + const popup = z2ui5_cl_ui5_view_builder.factory() + .ele({ n: "FragmentDefinition", ns: "core" }) + .a({ n: "xmlns", v: "sap.m" }) + .a({ n: "xmlns:core", v: "sap.ui.core" }); + + const dialog = popup.ele("Dialog") + .a({ n: "afterClose", v: this.client._event("BTN_OK_2ND") }) + .ele("content"); + + dialog.tag("Label") + .a({ n: "text", v: "this is a second popup" }); + + dialog.end() + .ele("buttons") + .tag("Button") + .a({ n: "press", v: this.client._event("BTN_OK_2ND") }) + .a({ n: "text", v: "GOTO 1ST POPUP" }) + .a({ n: "type", v: "Emphasized" }); + + this.client.popup_display(popup.stringify()); + + } + + view_display() { + + const view = z2ui5_cl_ui5_view_builder.factory() + .ele({ n: "View", ns: "mvc" }) + .a({ n: "displayBlock", v: "true" }) + .a({ n: "height", v: "100%" }) + .a({ n: "xmlns", v: "sap.m" }) + .a({ n: "xmlns:mvc", v: "sap.ui.core.mvc" }); + const page = view.ele("Shell") + .ele("Page") + .a({ n: "title", v: "cap2UI5 - Popup - Dialog inside a Dialog" }) + .a({ n: "showNavButton", b: this.client.check_app_prev_stack() }) + .a({ n: "navButtonPress", v: this.client._event_nav_app_leave() }); + + page.tag("MessageStrip") + .a({ n: "text", v: "This sample opens a popup from a button and then chains to a second popup " + + "from within the first one." }) + .a({ n: "type", v: "Information" }) + .a({ n: "showIcon", b: true }) + .a({ n: "class", v: "sapUiSmallMargin" }); + + page.tag("Button") + .a({ n: "press", v: this.client._event("POPUP") }) + .a({ n: "text", v: "Open Popup..." }); + + this.client.view_display(view.stringify()); + + } + + on_event() { + + switch (this.client.get_event()) { + case "GOTO_2ND": + this.simple_popup2(); + break; + + case "BTN_OK_2ND": + this.client.popup_destroy(); + this.simple_popup1(); + break; + + case "BTN_OK_1ND": + this.client.popup_destroy(); + break; + + case "POPUP": + this.simple_popup1(); + break; + } + + } + + main(client) { + + this.client = client; + if (client.check_on_navigated()) { + this.view_display(); + return; + } + this.on_event(); + + } + +}); diff --git a/srv/apps/z2ui5_cl_smp_app_167.js b/srv/apps/z2ui5_cl_smp_app_167.js new file mode 100644 index 0000000..531e477 --- /dev/null +++ b/srv/apps/z2ui5_cl_smp_app_167.js @@ -0,0 +1,80 @@ +// @keywords argument parameter payload event data fixed value +// @summary Sends extra arguments with an event (t_arg), so a handler knows which row, which value or which fixed payload it was called for. +// @docs https://abap2ui5.github.io/docs/cookbook/event_navigation/backend https://abap2ui5.github.io/docs/tutorials/walkthrough/step-6 +// @origin abap2UI5/samples src/z2ui5_cl_smp_app_167.clas.abap +import { defineApp, z2ui5_cl_ui5_view_builder } from "cap2ui5"; + +defineApp("Z2UI5_CL_SMP_APP_167", class { + + mv_value = ""; + + view_display() { + + const view = z2ui5_cl_ui5_view_builder.factory() + .ele({ n: "View", ns: "mvc" }) + .a({ n: "displayBlock", v: "true" }) + .a({ n: "height", v: "100%" }) + .a({ n: "xmlns", v: "sap.m" }) + .a({ n: "xmlns:mvc", v: "sap.ui.core.mvc" }); + const page = view.ele("Shell") + .ele("Page") + .a({ n: "title", v: "cap2UI5 - Event - Extra Arguments with t_arg" }) + .a({ n: "showNavButton", b: this.client.check_app_prev_stack() }) + .a({ n: "navButtonPress", v: this.client._event_nav_app_leave() }); + + page.tag("MessageStrip") + .a({ n: "text", v: "This sample shows how to pass extra arguments to an event via t_arg - fixed " + + "values, model values, or client-side expressions - and read them in the backend." }) + .a({ n: "type", v: "Information" }) + .a({ n: "showIcon", b: true }) + .a({ n: "class", v: "sapUiSmallMargin" }); + + page.tag("Link") + .a({ n: "text", v: "More information..." }) + .a({ n: "target", v: "_blank" }) + .a({ n: "href", v: "https://sdk.openui5.org/topic/b0fb4de7364f4bcbb053a99aa645affe" }); + + page.tag("Button") + .a({ n: "press", v: this.client._event({ val: "EVENT_FIX_VAL", arg: "FIX_VAL" }) }) + .a({ n: "text", v: "EVENT_FIX_VAL" }); + + page.tag("Input") + .a({ n: "value", v: this.client._bind("mv_value") }); + page.tag("Button") + .a({ n: "press", v: this.client._event({ val: "EVENT_MODEL_VALUE", arg: "$" + this.client._bind("mv_value") }) }) + .a({ n: "text", v: "EVENT_MODEL_VALUE" }); + + page.tag("Button") + .a({ n: "press", v: this.client._event({ val: "SOURCE_PROPERTY_TEXT", arg: "${$source>/text}" }) }) + .a({ n: "text", v: "SOURCE_PROPERTY_TEXT" }); + + page.tag("Input") + .a({ n: "description", v: "make an input and press enter - " }) + .a({ n: "submit", v: this.client._event({ val: "EVENT_PROPERTY_VALUE", arg: "${$parameters>/value}" }) }); + + page.tag("Button") + .a({ n: "press", v: this.client._event({ val: "PARENT_PROPERTY_ID", arg: "$event.oSource.oParent.sId" }) }) + .a({ n: "text", v: "PARENT_PROPERTY_ID" }); + + this.client.view_display(view.stringify()); + + } + + main(client) { + + this.client = client; + + if (client.check_on_init()) { + this.mv_value = "my value"; + this.view_display(); + } else if (client.check_on_navigated()) { + this.view_display(); + } + + switch (client.get_event()) { + case "EVENT_FIX_VAL": case "EVENT_MODEL_VALUE": case "SOURCE_PROPERTY_TEXT": case "EVENT_PROPERTY_VALUE": case "PARENT_PROPERTY_ID": + client.message_box_display(`backend event: ${client.get_event_arg()}`); + } + + } +}); diff --git a/srv/apps/z2ui5_cl_smp_app_488.js b/srv/apps/z2ui5_cl_smp_app_488.js new file mode 100644 index 0000000..91655a7 --- /dev/null +++ b/srv/apps/z2ui5_cl_smp_app_488.js @@ -0,0 +1,131 @@ +// @keywords r_data result get_app_prev return event payload +// @summary The way back carries data: the called app returns an event name and a payload (r_data) that the caller reads from get_app_prev. +// @docs https://abap2ui5.github.io/docs/cookbook/event_navigation/navigation/inner_app +// @origin abap2UI5/samples src/z2ui5_cl_smp_app_488.clas.abap +// +// Calls a second app (z2ui5_cl_smp_app_489) via client.nav_app_call( ). The +// called app comes back with client.nav_app_leave( { event, r_data } ), +// handing an event name and a data payload to its caller without knowing who +// called it. On return this app enters main( ) via check_on_navigated( ) and +// reads both from client.get( ): the event name from .event, the payload from +// .r_event_data. +import { defineApp, z2ui5_cl_ui5_view_builder } from "cap2ui5"; + +defineApp("Z2UI5_CL_SMP_APP_488", class { + + s_result = { product: "", quantity: "" }; // z2ui5_cl_smp_app_489=>ty_s_result + returned_event = ""; + + main(client) { + + this.client = client; + + if (client.check_on_init()) { + this.view_display(); + + } else if (client.check_on_navigated()) { + this.on_navigation(); + + } else if (client.check_on_event("CALL_APP")) { + client.nav_app_call("Z2UI5_CL_SMP_APP_489"); + } + + } + + on_navigation() { + + const ls_get = this.client.get(); + this.returned_event = ls_get.event; + + switch (this.returned_event) { + + case "DATA_CONFIRMED": { + + // the payload handed over by nav_app_leave( r_data = ... ) arrives as + // plain data - the receiver decides what it is + const s_result = ls_get.r_event_data; + + if (s_result) { + + this.s_result = s_result; + this.client.message_toast_display(`Returned event ${this.returned_event}, ` + + `product ${this.s_result.product}, quantity ${this.s_result.quantity}`); + + } + break; + } + + case "DATA_CANCELLED": + + this.s_result = {}; + this.client.message_toast_display("Returned event DATA_CANCELLED, no data passed"); + break; + + } + + this.view_display(); + + } + + view_display() { + + const view = z2ui5_cl_ui5_view_builder.factory() + .ele({ n: "View", ns: "mvc" }) + .a({ n: "displayBlock", v: "true" }) + .a({ n: "height", v: "100%" }) + .a({ n: "xmlns", v: "sap.m" }) + .a({ n: "xmlns:mvc", v: "sap.ui.core.mvc" }) + .a({ n: "xmlns:form", v: "sap.ui.layout.form" }) + .a({ n: "xmlns:layout", v: "sap.ui.layout" }); + const page = view.ele("Shell") + .ele("Page") + .a({ n: "title", v: "cap2UI5 - Navigation - Return Data and Events to the Caller" }) + .a({ n: "showNavButton", b: this.client.check_app_prev_stack() }) + .a({ n: "navButtonPress", v: this.client._event_nav_app_leave() }); + + page.tag("MessageStrip") + .a({ n: "text", v: "Calls a second app that returns via nav_app_leave with an event and a data " + + "payload (r_data). On return this app reads both from client.get( ) in its " + + "check_on_navigated( ) branch and shows them below." }) + .a({ n: "type", v: "Information" }) + .a({ n: "showIcon", b: true }) + .a({ n: "class", v: "sapUiSmallMargin" }); + + const form = page.ele({ n: "Grid", ns: "layout" }) + .a({ n: "defaultSpan", v: "L6 M12 S12" }) + .ele({ n: "content", ns: "layout" }) + .ele({ n: "SimpleForm", ns: "form" }) + .a({ n: "title", v: "Result returned by the called app" }) + .a({ n: "editable", b: true }) + .ele({ n: "content", ns: "form" }); + + form.tag("Label") + .a({ n: "text", v: "Open the input app" }); + form.tag("Button") + .a({ n: "press", v: this.client._event("CALL_APP") }) + .a({ n: "text", v: "call app (nav_app_call)" }) + .a({ n: "type", v: "Emphasized" }); + + form.tag("Label") + .a({ n: "text", v: "Returned event" }); + form.tag("Input") + .a({ n: "enabled", b: false }) + .a({ n: "value", v: this.client._bind("returned_event") }); + + form.tag("Label") + .a({ n: "text", v: "Returned product" }); + form.tag("Input") + .a({ n: "enabled", b: false }) + .a({ n: "value", v: this.client._bind("s_result-product") }); + + form.tag("Label") + .a({ n: "text", v: "Returned quantity" }); + form.tag("Input") + .a({ n: "enabled", b: false }) + .a({ n: "value", v: this.client._bind("s_result-quantity") }); + + this.client.view_display(view.stringify()); + + } + +}); diff --git a/srv/apps/z2ui5_cl_smp_app_489.js b/srv/apps/z2ui5_cl_smp_app_489.js new file mode 100644 index 0000000..8c50400 --- /dev/null +++ b/srv/apps/z2ui5_cl_smp_app_489.js @@ -0,0 +1,92 @@ +// @origin abap2UI5/samples src/z2ui5_cl_smp_app_489.clas.abap +// +// Input page called by z2ui5_cl_smp_app_488. It returns to its caller with +// client.nav_app_leave( { event, r_data } ) - handing back an event name and +// the entered data without knowing which app called it (no get_app_prev( ), +// no look at the caller's fields). This app is a hidden helper (never listed +// on its own in the overview). +import { defineApp, z2ui5_cl_ui5_view_builder } from "cap2ui5"; + +defineApp("Z2UI5_CL_SMP_APP_489", class { + + s_result = { product: "", quantity: "" }; // ty_s_result + + main(client) { + + this.client = client; + + if (client.check_on_init()) { + + this.s_result = { product: "Notebook Basic 15", quantity: "2" }; + + this.view_display(); + + } else if (client.check_on_navigated()) { + this.view_display(); + + } else if (client.check_on_event("CONFIRM")) { + client.nav_app_leave({ event: "DATA_CONFIRMED", r_data: this.s_result }); + + } else if (client.check_on_event("CANCEL")) { + client.nav_app_leave({ event: "DATA_CANCELLED" }); + } + + } + + view_display() { + + const view = z2ui5_cl_ui5_view_builder.factory() + .ele({ n: "View", ns: "mvc" }) + .a({ n: "displayBlock", v: "true" }) + .a({ n: "height", v: "100%" }) + .a({ n: "xmlns", v: "sap.m" }) + .a({ n: "xmlns:mvc", v: "sap.ui.core.mvc" }) + .a({ n: "xmlns:form", v: "sap.ui.layout.form" }) + .a({ n: "xmlns:layout", v: "sap.ui.layout" }); + const page = view.ele("Shell") + .ele("Page") + .a({ n: "title", v: "cap2UI5 - Navigation - Data Input App" }) + .a({ n: "showNavButton", b: this.client.check_app_prev_stack() }) + .a({ n: "navButtonPress", v: this.client._event_nav_app_leave() }); + + page.tag("MessageStrip") + .a({ n: "text", v: "Change the data and return: 'confirm' leaves with event DATA_CONFIRMED plus the " + + "entered data as r_data, 'cancel' leaves with event DATA_CANCELLED and no data. " + + "The nav-back button of the page leaves without an event." }) + .a({ n: "type", v: "Information" }) + .a({ n: "showIcon", b: true }) + .a({ n: "class", v: "sapUiSmallMargin" }); + + const form = page.ele({ n: "Grid", ns: "layout" }) + .a({ n: "defaultSpan", v: "L6 M12 S12" }) + .ele({ n: "content", ns: "layout" }) + .ele({ n: "SimpleForm", ns: "form" }) + .a({ n: "title", v: "Data returned to the caller" }) + .a({ n: "editable", b: true }) + .ele({ n: "content", ns: "form" }); + + form.tag("Label") + .a({ n: "text", v: "Product" }); + form.tag("Input") + .a({ n: "value", v: this.client._bind("s_result-product") }); + + form.tag("Label") + .a({ n: "text", v: "Quantity" }); + form.tag("Input") + .a({ n: "value", v: this.client._bind("s_result-quantity") }); + + form.tag("Label") + .a({ n: "text", v: "Return to the caller" }); + form.tag("Button") + .a({ n: "press", v: this.client._event("CONFIRM") }) + .a({ n: "text", v: "confirm (event + r_data)" }) + .a({ n: "type", v: "Emphasized" }); + form.tag("Button") + .a({ n: "press", v: this.client._event("CANCEL") }) + .a({ n: "text", v: "cancel (event only)" }); + + this.client.view_display(view.stringify()); + + } + +}); diff --git a/srv/apps/z2ui5_cl_smp_app_493.js b/srv/apps/z2ui5_cl_smp_app_493.js new file mode 100644 index 0000000..bc7732e --- /dev/null +++ b/srv/apps/z2ui5_cl_smp_app_493.js @@ -0,0 +1,44 @@ +// @keywords hello world smallest first app minimal start here template +// @summary The smallest app that runs: one class, one view_display( ), a Page with a title - the shape every other sample starts from. +// @docs https://abap2ui5.github.io/docs/get_started/hello_world https://abap2ui5.github.io/docs/cookbook/view/definition https://abap2ui5.github.io/docs/cookbook/expert_more/snippets https://abap2ui5.github.io/docs/tutorials/walkthrough/step-1 +// @origin abap2UI5/samples src/z2ui5_cl_smp_app_493.clas.abap +import { defineApp, z2ui5_cl_ui5_view_builder } from "cap2ui5"; + +defineApp("Z2UI5_CL_SMP_APP_493", class { + + main(client) { + + if (client.check_on_navigated()) { + + const view = z2ui5_cl_ui5_view_builder.factory() + .ele({ n: "View", ns: "mvc" }) + .a({ n: "displayBlock", v: "true" }) + .a({ n: "height", v: "100%" }) + .a({ n: "xmlns", v: "sap.m" }) + .a({ n: "xmlns:mvc", v: "sap.ui.core.mvc" }); + const page = view.ele("Shell") + .ele("Page") + .a({ n: "title", v: "cap2UI5 - Basics I - Hello World, the Smallest App" }) + .a({ n: "showNavButton", b: client.check_app_prev_stack() }) + .a({ n: "navButtonPress", v: client._event_nav_app_leave() }); + + page.tag("MessageStrip") + .a({ n: "text", v: "The whole app is what you see below: a class handed to defineApp( ), " + + "one main( ) method, a view built as XML and handed to client.view_display( ). " + + "cap2UI5 calls main( ) on every roundtrip - here only the display matters, " + + "which is what check_on_navigated( ) asks: true on the first start and whenever " + + "the app is shown again. Copy this file as the starting point for your own app." }) + .a({ n: "type", v: "Information" }) + .a({ n: "showIcon", b: true }) + .a({ n: "class", v: "sapUiSmallMargin" }); + + page.tag("Title") + .a({ n: "text", v: "Hello World" }) + .a({ n: "class", v: "sapUiSmallMargin" }) + .a({ n: "level", v: "H2" }); + client.view_display(view.stringify()); + + } + + } +}); diff --git a/srv/apps/z2ui5_cl_smp_app_494.js b/srv/apps/z2ui5_cl_smp_app_494.js new file mode 100644 index 0000000..618898e --- /dev/null +++ b/srv/apps/z2ui5_cl_smp_app_494.js @@ -0,0 +1,75 @@ +// @keywords binding _bind model attribute value input button roundtrip messagebox serialize +// @summary Binds a class attribute to an Input with _bind( ), so what the user types is in the variable on the next roundtrip - a Text shows it back and a MessageBox confirms the roundtrip. +// @docs https://abap2ui5.github.io/docs/cookbook/model/binding https://abap2ui5.github.io/docs/tutorials/walkthrough/step-4 +// @origin abap2UI5/samples src/z2ui5_cl_smp_app_494.clas.abap +import { defineApp, z2ui5_cl_ui5_view_builder } from "cap2ui5"; + +defineApp("Z2UI5_CL_SMP_APP_494", class { + + name = ""; + greeting = ""; + + main(client) { + + this.client = client; + if (client.check_on_init()) { + this.name = "World"; + this.view_display(); + } else if (client.check_on_navigated()) { + this.view_display(); + } else if (client.check_on_event("GREET")) { + this.greeting = `Hello ${this.name}!`; + client.message_box_display(`Roundtrip done: the backend read NAME = '${this.name}' and wrote GREETING back into the view.`); + } + + } + + view_display() { + + const view = z2ui5_cl_ui5_view_builder.factory() + .ele({ n: "View", ns: "mvc" }) + .a({ n: "displayBlock", v: "true" }) + .a({ n: "height", v: "100%" }) + .a({ n: "xmlns", v: "sap.m" }) + .a({ n: "xmlns:mvc", v: "sap.ui.core.mvc" }) + .a({ n: "xmlns:form", v: "sap.ui.layout.form" }); + const page = view.ele("Shell") + .ele("Page") + .a({ n: "title", v: "cap2UI5 - Basics II - Data Binding: Input and Button" }) + .a({ n: "showNavButton", b: this.client.check_app_prev_stack() }) + .a({ n: "navButtonPress", v: this.client._event_nav_app_leave() }); + + page.tag("MessageStrip") + .a({ n: "text", v: "client._bind( 'name' ) connects the field NAME with the input " + + "below. Type a name and leave the field: the text " + + "next to it changes without any backend code, because both are bound to the " + + "same field. Press Greet and the backend reads NAME - already filled " + + "in, no event argument needed -, writes GREETING back into the view and " + + "confirms the roundtrip with a MessageBox." }) + .a({ n: "type", v: "Information" }) + .a({ n: "showIcon", b: true }) + .a({ n: "class", v: "sapUiSmallMargin" }); + + page.ele({ n: "SimpleForm", ns: "form" }) + .a({ n: "title", v: "Data Binding" }) + .a({ n: "editable", b: true }) + .ele({ n: "content", ns: "form" }) + .tag("Label") + .a({ n: "text", v: "your name" }) + .tag("Input") + .a({ n: "value", v: this.client._bind("name") }) + .tag("Label") + .a({ n: "text", v: "bound to the same field" }) + .tag("Text") + .a({ n: "text", v: this.client._bind("name") }) + .tag("Label") + .a({ n: "text", v: "written by the backend" }) + .tag("Text") + .a({ n: "text", v: this.client._bind("greeting") }) + .tag("Button") + .a({ n: "press", v: this.client._event("GREET") }) + .a({ n: "text", v: "Greet" }); + this.client.view_display(view.stringify()); + + } +}); diff --git a/srv/apps/z2ui5_cl_smp_app_495.js b/srv/apps/z2ui5_cl_smp_app_495.js new file mode 100644 index 0000000..038d54d --- /dev/null +++ b/srv/apps/z2ui5_cl_smp_app_495.js @@ -0,0 +1,89 @@ +// @keywords lifecycle roundtrip main dispatcher state serialize check_on_init check_on_event check_on_navigated +// @summary The three questions main( ) asks - init, event, navigated - as one dispatcher, showing what survives a roundtrip and what a navigation does to it. +// @docs https://abap2ui5.github.io/docs/cookbook/event_navigation/life_cycle https://abap2ui5.github.io/docs/cookbook/expert_more/snippets https://abap2ui5.github.io/docs/tutorials/walkthrough/step-3 +// @origin abap2UI5/samples src/z2ui5_cl_smp_app_495.clas.abap +import { defineApp, t, z2ui5_cl_ui5_view_builder } from "cap2ui5"; + +defineApp("Z2UI5_CL_SMP_APP_495", class { + + t_log = t.table({ no: "", check: "" }); + + main(client) { + + this.client = client; + if (client.check_on_init()) { + + this.log_step("check_on_init( ) - the very first call, nothing exists yet"); + this.view_display(); + + } else if (client.check_on_navigated()) { + + this.log_step("check_on_navigated( ) - the sub-app returned, re-display the view"); + this.view_display(); + + } else if (client.check_on_event("LOG")) { + this.log_step("check_on_event( ) - a button was pressed, the view stays as it is"); + + } else if (client.check_on_event("CALL")) { + + this.log_step("check_on_event( ) - calling Basics I as a sub-app"); + client.nav_app_call("Z2UI5_CL_SMP_APP_493"); + + } + + } + + log_step(val) { + + // a read of t_log is a copy: the table with the new row is written back whole + this.t_log = [...this.t_log, { no: String(this.t_log.length + 1), check: val }]; + + } + + view_display() { + + const view = z2ui5_cl_ui5_view_builder.factory() + .ele({ n: "View", ns: "mvc" }) + .a({ n: "displayBlock", v: "true" }) + .a({ n: "height", v: "100%" }) + .a({ n: "xmlns", v: "sap.m" }) + .a({ n: "xmlns:mvc", v: "sap.ui.core.mvc" }); + const page = view.ele("Shell") + .ele("Page") + .a({ n: "title", v: "cap2UI5 - Basics III - Lifecycle: Init, Event, Navigated" }) + .a({ n: "showNavButton", b: this.client.check_app_prev_stack() }) + .a({ n: "navButtonPress", v: this.client._event_nav_app_leave() }); + + page.tag("MessageStrip") + .a({ n: "text", v: "main( ) runs on every roundtrip - the three checks tell it what the " + + "roundtrip is about. The list logs each call, and it survives them all: " + + "every field is serialized between the roundtrips, so the app " + + "keeps its state without a table of its own. Press Log - only the model is pushed, " + + "the view is not rebuilt. Call the sub-app and come back with its back " + + "button - that is the roundtrip check_on_navigated( ) answers." }) + .a({ n: "type", v: "Information" }) + .a({ n: "showIcon", b: true }) + .a({ n: "class", v: "sapUiSmallMargin" }); + + page.ele("HBox") + .a({ n: "class", v: "sapUiSmallMargin" }) + .tag("Button") + .a({ n: "press", v: this.client._event("LOG") }) + .a({ n: "text", v: "Log an Event" }) + .tag("Button") + .a({ n: "press", v: this.client._event("CALL") }) + .a({ n: "text", v: "Call a Sub-App" }) + .a({ n: "class", v: "sapUiTinyMarginBegin" }); + + page.ele("List") + .a({ n: "headerText", v: "Calls of main( )" }) + .a({ n: "items", v: this.client._bind("t_log") }) + .a({ n: "class", v: "sapUiSmallMargin" }) + .tag("StandardListItem") + .a({ n: "title", v: "{CHECK}" }) + .a({ n: "description", v: "call {NO}" }); + + this.client.view_display(view.stringify()); + + } +}); diff --git a/test/samples.test.mjs b/test/samples.test.mjs new file mode 100644 index 0000000..9d7721e --- /dev/null +++ b/test/samples.test.mjs @@ -0,0 +1,168 @@ +// Every sample, driven over the wire: it starts, its view is well-formed XML, +// and its events do what the sample says they do - including the navigation +// round trips between two apps, where the tests send back the event the +// page's back button carries, as the browser does. +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import { test } from "node:test"; +import { actions, destroys, post, ROOT, serve, slot, wellFormed } from "./server.mjs"; + +const s = serve(); +const P = (o) => post(s.url, o); +const ok = (r) => assert.equal(r.status, 200, r.text.slice(0, 500)); + +/** the event a handler attribute's wire fires, as the browser sends it back */ +const firedBy = (xml, attr) => xml.match(new RegExp(`${attr}="\\.eB\\(\\['([^']+)'`))?.[1]; + +const APPS = fs.readdirSync(path.join(ROOT, "srv/apps")) + .filter((f) => /^z2ui5_cl_smp_app_\d+\.js$/.test(f)) + .map((f) => f.replace(/\.js$/, "").toUpperCase()); + +test("every sample starts, and its view is well-formed XML titled cap2UI5", async () => { + assert.ok(APPS.length > 0); + for (const app of APPS) { + const r = await P({ app }); + ok(r); + const xml = slot(r, "MAIN"); + assert.ok(xml, `${app}: no MAIN view on the start`); + assert.equal(wellFormed(xml), true, `${app}: ${wellFormed(xml)}`); + assert.match(xml, /title="cap2UI5 - /, `${app}: the page title`); + assert.match(xml, /showNavButton="false"/, `${app}: started directly, there is nothing to go back to`); + assert.ok(firedBy(xml, "navButtonPress"), `${app}: the back button carries the nav-back wire`); + } +}); + +test("493 Hello World: one view, no state", async () => { + const r = await P({ app: "Z2UI5_CL_SMP_APP_493" }); + ok(r); + assert.match(slot(r, "MAIN"), / { + const start = await P({ app: "Z2UI5_CL_SMP_APP_494" }); + assert.deepEqual(start.json.MODEL, { NAME: "World", GREETING: "" }); + assert.match(slot(start, "MAIN"), /<Input value="\{\/NAME\}"\/>/); + + const greet = await P({ app: "Z2UI5_CL_SMP_APP_494", id: start.id, event: "GREET", model: { NAME: "Ada" } }); + ok(greet); + assert.deepEqual(greet.json.MODEL, { NAME: "Ada", GREETING: "Hello Ada!" }); + assert.deepEqual(actions(greet)[0].slice(0, 3), + ["MESSAGE_BOX", "show", "Roundtrip done: the backend read NAME = 'Ada' and wrote GREETING back into the view."]); +}); + +test("495 Lifecycle: init, event, and the return from a called app", async () => { + const APP = "Z2UI5_CL_SMP_APP_495"; + const start = await P({ app: APP }); + assert.deepEqual(start.json.MODEL.T_LOG.map((r) => r.NO), ["1"]); + + const log = await P({ app: APP, id: start.id, event: "LOG" }); + assert.equal(slot(log, "MAIN"), undefined, "an event does not rebuild the view"); + assert.equal(log.json.MODEL.T_LOG.length, 2, "only the model is pushed"); + + const call = await P({ app: APP, id: log.id, event: "CALL" }); + assert.equal(call.app, "Z2UI5_CL_SMP_APP_493", "nav_app_call( ) hands the screen to Basics I"); + assert.match(slot(call, "MAIN"), /Basics I - Hello World/); + assert.match(slot(call, "MAIN"), /showNavButton="true"/, "the called app can go back"); + + // the called app's back button: _event_nav_app_leave( ), no branch in its main( ) + const back = await P({ app: call.app, id: call.id, event: firedBy(slot(call, "MAIN"), "navButtonPress") }); + assert.equal(back.app, APP, "the back button returns to the caller"); + assert.match(slot(back, "MAIN") ?? "", /Basics III/, "the caller renders again (check_on_navigated)"); + assert.deepEqual(back.json.MODEL.T_LOG.map((r) => r.CHECK.split(" - ")[0]), + ["check_on_init( )", "check_on_event( )", "check_on_event( )", "check_on_navigated( )"], + "the log survived the navigation, and the return is check_on_navigated( ) without check_on_init( )"); +}); + +test("011 Editable Table: edit mode, delete the selected rows, add a row", async () => { + const APP = "Z2UI5_CL_SMP_APP_011"; + const start = await P({ app: APP }); + assert.match(slot(start, "MAIN"), /items="\{path: '\/T_TAB', templateShareable: false\}"/, + "the bare path of _bind( { val: t_tab, path: true } ) in a composed binding"); + const rows = start.json.MODEL.T_TAB; + assert.equal(rows.length, 6); + assert.deepEqual(rows[0], { SELKZ: false, TITLE: "entry 01", VALUE: "red", DESCR: "this is a description", + ICON: "", INFO: "completed", EDITABLE: false, CHECKBOX: true }); + + const edit = await P({ app: APP, id: start.id, event: "BUTTON_EDIT" }); + assert.ok(edit.json.MODEL.T_TAB.every((r) => r.EDITABLE), "every row is editable"); + + // the user selects rows 2 and 4 - the frontend sends the table back + const selected = edit.json.MODEL.T_TAB.map((r, i) => ({ ...r, SELKZ: i === 1 || i === 3 })); + const del = await P({ app: APP, id: edit.id, event: "BUTTON_DELETE", model: { T_TAB: selected } }); + ok(del); + assert.deepEqual(del.json.MODEL.T_TAB.map((r) => r.TITLE), ["entry 01", "entry 03", "entry 05", ""]); + + const add = await P({ app: APP, id: del.id, event: "BUTTON_ADD" }); + assert.equal(add.json.MODEL.T_TAB.length, 5); + assert.equal(add.json.MODEL.T_TAB.at(-1).EDITABLE, true, "a new row follows the edit mode"); +}); + +test("167 Event Arguments: fixed values and client-side expressions travel with the event", async () => { + const APP = "Z2UI5_CL_SMP_APP_167"; + const start = await P({ app: APP }); + // > is legal in an attribute; whether it arrives escaped depends on the cap2ui5 release + const press = slot(start, "MAIN").replaceAll(">", ">").match(/(press|submit)="[^"]*"/g); + assert.deepEqual(press, [ + `press=".eB(['EVENT_FIX_VAL'], 'FIX_VAL')"`, + `press=".eB(['EVENT_MODEL_VALUE'], \${/MV_VALUE})"`, + `press=".eB(['SOURCE_PROPERTY_TEXT'], \${$source>/text})"`, + `submit=".eB(['EVENT_PROPERTY_VALUE'], \${$parameters>/value})"`, + `press=".eB(['PARENT_PROPERTY_ID'], $event.oSource.oParent.sId)"`, + ]); + + const fix = await P({ app: APP, id: start.id, event: "EVENT_FIX_VAL", args: ["FIX_VAL"] }); + assert.deepEqual(actions(fix)[0].slice(0, 3), ["MESSAGE_BOX", "show", "backend event: FIX_VAL"]); +}); + +test("161 Dialog inside a Dialog: open, chain to the second, back to the first, close", async () => { + const APP = "Z2UI5_CL_SMP_APP_161"; + const start = await P({ app: APP }); + + const open = await P({ app: APP, id: start.id, event: "POPUP" }); + assert.match(slot(open, "POPUP") ?? "", /Open 2nd popup/); + assert.equal(wellFormed(slot(open, "POPUP")), true); + + const second = await P({ app: APP, id: open.id, event: "GOTO_2ND" }); + assert.match(slot(second, "POPUP") ?? "", /this is a second popup/); + + const first = await P({ app: APP, id: second.id, event: "BTN_OK_2ND" }); + assert.match(slot(first, "POPUP") ?? "", /Open 2nd popup/, "closing the second shows the first again"); + + const close = await P({ app: APP, id: first.id, event: "BTN_OK_1ND" }); + assert.ok(destroys(close, "POPUP"), "the popup is closed"); +}); + +test("488/489 Navigation: the called app returns an event and data, the caller reads both", async () => { + const start = await P({ app: "Z2UI5_CL_SMP_APP_488" }); + assert.deepEqual(start.json.MODEL, { S_RESULT: { PRODUCT: "", QUANTITY: "" }, RETURNED_EVENT: "" }); + assert.match(slot(start, "MAIN"), /value="\{\/S_RESULT\/PRODUCT\}"/, "a structure component, bound by name"); + + const called = await P({ app: "Z2UI5_CL_SMP_APP_488", id: start.id, event: "CALL_APP" }); + assert.equal(called.app, "Z2UI5_CL_SMP_APP_489"); + assert.deepEqual(called.json.MODEL, { S_RESULT: { PRODUCT: "Notebook Basic 15", QUANTITY: "2" } }); + + // the user edits both fields, then confirms: the data travels with nav_app_leave( r_data ) + const confirm = await P({ app: called.app, id: called.id, event: "CONFIRM", + model: { S_RESULT: { PRODUCT: "Notebook Pro 17", QUANTITY: "7" } } }); + assert.equal(confirm.app, "Z2UI5_CL_SMP_APP_488"); + assert.deepEqual(confirm.json.MODEL, + { S_RESULT: { PRODUCT: "Notebook Pro 17", QUANTITY: "7" }, RETURNED_EVENT: "DATA_CONFIRMED" }); + assert.match(slot(confirm, "MAIN") ?? "", /Result returned by the called app/, "the caller renders again"); + assert.deepEqual(actions(confirm).find((a) => a[0] === "MESSAGE_TOAST")?.[2], + "Returned event DATA_CONFIRMED, product Notebook Pro 17, quantity 7"); + + const again = await P({ app: "Z2UI5_CL_SMP_APP_488", id: confirm.id, event: "CALL_APP" }); + const cancel = await P({ app: again.app, id: again.id, event: "CANCEL" }); + assert.deepEqual(cancel.json.MODEL, + { S_RESULT: { PRODUCT: "", QUANTITY: "" }, RETURNED_EVENT: "DATA_CANCELLED" }); +}); + +test("125 Tab Title: the set_title front-end action", async () => { + const APP = "Z2UI5_CL_SMP_APP_125"; + const start = await P({ app: APP }); + const set = await P({ app: APP, id: start.id, event: "SET_TITLE", model: { TITLE: "Invoices" } }); + ok(set); + assert.deepEqual(actions(set), [["SET_TITLE", "Invoices"]]); +}); diff --git a/test/server.mjs b/test/server.mjs new file mode 100644 index 0000000..0ba6d4e --- /dev/null +++ b/test/server.mjs @@ -0,0 +1,59 @@ +// The samples project served in this process by cds.test, and the abap2UI5 +// wire to talk to it: the tests play the frontend's part, one POST per +// roundtrip, the way cap2UI5's own suite does (examples/bookshop/test). +import cds from "@sap/cds"; +import { XMLValidator } from "fast-xml-parser"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +export const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); + +/** Serve the project for the calling test file (cds.test registers the hooks). */ +export function serve() { + const t = cds.test(ROOT); + return { get url() { return `${t.url}/rest/root/z2ui5`; } }; +} + +/** + * One roundtrip. No `id` starts `app`; with `id` it answers an event on the + * draft the previous response named. `model` is what the frontend sends back: + * the bound values the user changed. + */ +export async function post(url, { app, id = "", event = "", args = [], model = {} } = {}) { + const body = { value: { S_FRONT: { + ID: id, APP: app, EVENT: event, T_EVENT_ARG: args, + ORIGIN: "http://127.0.0.1", PATHNAME: "/rest/root/z2ui5", + SEARCH: id ? "" : `?app_start=${app}`, HASH: "", CONFIG: {} }, + XX: {}, MODEL: model } }; + const r = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: "Basic " + Buffer.from("alice:").toString("base64"), // CAP's mocked user + }, + body: JSON.stringify(body), + }); + const text = await r.text(); + let json = null; + try { json = JSON.parse(text); } catch { /* an error page, not a wire envelope */ } + return { status: r.status, text, json, id: json?.S_FRONT?.ID, app: json?.S_FRONT?.APP }; +} + +/** Every action of a response, system and custom. */ +export const actions = (r) => [ + ...(r.json?.S_FRONT?.S_ACTION?.T_SYSTEM ?? []), + ...(r.json?.S_FRONT?.S_ACTION?.T_CUSTOM ?? []), +]; + +/** The XML a response displays into a view slot (MAIN, POPUP, NEST, …), or undefined. */ +export const slot = (r, name) => actions(r) + .find((a) => a[0] === "VIEW_SLOTS" && a[1] === "display" && a[2] === name)?.[3]; + +export const destroys = (r, name) => actions(r) + .some((a) => a[0] === "VIEW_SLOTS" && a[1] === "destroy" && a[2] === name); + +/** true, or the parser's complaint - a template literal typo shows up here, not only in the browser */ +export const wellFormed = (xml) => { + const v = XMLValidator.validate(xml); + return v === true ? true : `${v.err.code} at ${v.err.line}:${v.err.col} - ${v.err.msg}`; +};