diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 44374e64..c6bfc746 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -99,6 +99,15 @@ jobs: if: ${{ !cancelled() }} run: npm run check:examples + # every complete app class either carries a Run button or a marker on its + # page saying why it cannot run in the playground. The rules that offer + # the button fail towards NOT offering one, so without this an example + # nobody measured is indistinguishable from an example that can never + # run - and the hand-kept ledger of that difference had already gone stale + - name: Run-button coverage + if: ${{ !cancelled() }} + run: npm run check:playground + # every `client->` name on the site - in the prose and in the snippets # check:examples cannot compile because they are not whole classes - # against z2ui5_if_client at the release this site names. Four pages @@ -107,6 +116,15 @@ jobs: if: ${{ !cancelled() }} run: npm run check:api-names + # the committed API reference (resources/api.md and the JSON next to it), + # regenerated from z2ui5_if_client at the release the site names and + # compared against what is committed. The reference is generated so it + # cannot drift from the interface by hand - this is what makes that true + # after a release too + - name: API reference freshness + if: ${{ !cancelled() }} + run: npm run check:api-reference + # the Working Samples blocks: still generated from what the catalogue says # today, still pointing at classes that exist, and still pointed back at # from those classes. A link into another repository is the one kind that diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 1b83e017..f6c89c3f 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -103,9 +103,15 @@ jobs: - name: ABAP examples if: ${{ !cancelled() }} run: npm run check:examples + - name: Run-button coverage + if: ${{ !cancelled() }} + run: npm run check:playground - name: API names if: ${{ !cancelled() }} run: npm run check:api-names + - name: API reference freshness + if: ${{ !cancelled() }} + run: npm run check:api-reference - name: sample links if: ${{ !cancelled() }} run: npm run check:samples diff --git a/AGENTS.md b/AGENTS.md index af7729cb..cc3e6209 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,18 +17,21 @@ person reads the page. Do not put "as an AI, …" prose back into `docs/`. | `scripts/check-examples.mjs` | Extracts every fenced ABAP block that builds a view, compiles it against the real framework and lints the view it produces | | `scripts/link-samples.mjs` | Generates the *Working Samples* block on a page from its `samples:` frontmatter plus `SAMPLES.md` in an `abap2UI5/samples` checkout, and checks the link in both directions | | `scripts/generate-llms.mjs` | Builds `llms.txt` / `llms-full.txt` / per-page markdown from the sidebar. Runs inside `docs:build`, so the deploy publishes them | +| `scripts/generate-api-reference.mjs` | Generates the client API reference — the block in `docs/resources/api.md` and `docs/public/api/client-api.json` — from `z2ui5_if_client` at the pinned release; `--check` is the freshness gate | +| `scripts/lib/client-interface.mjs` | Where `z2ui5_if_client` is fetched from (the release pin, shared with `check-api-names.mjs`) and the full parser `generate-api-reference.mjs` renders from | | `scripts/check-version.mjs` | The release number in the nav bar, the deprecations page and the changelog, against the newest release tag of the framework | | `docs/.vitepress/playground.mjs` | Decides which fenced ABAP example gets a **Run** button, and wraps the fence; `theme/playground.js` is the browser half | -| `scripts/lib/catalogue.mjs` | Parses and counts a sample catalogue's rows, for `link-samples.mjs` and for the figures `generate-llms.mjs` writes into `llms.txt`; pinned by `test/catalogue.test.mjs`, because it has stopped matching twice and both times answered wrongly instead of failing | +| `scripts/check-playground.mjs` | The Run-button bookkeeping: every complete app class either gets a button from `playground.mjs` or carries a `` marker above its fence saying why it cannot run; a stale marker fails as loudly as a missing one. `--list` prints the deliberate exclusions with both reasons | +| `scripts/lib/catalogue.mjs` | Parses and counts a sample catalogue, for `link-samples.mjs` and for the figures `generate-llms.mjs` writes into `llms.txt` — from a sibling checkout when one is here, else from the `catalogue.json` each sample repository commits at its root; pinned by `test/catalogue.test.mjs`, because it has stopped matching twice and both times answered wrongly instead of failing | ## Build & verify — run before every commit ```bash -npm run check # test + check:version + docs:build + check:examples + check:api-names + check:samples +npm run check # test + check:version + docs:build + check:examples + check:playground + check:api-names + check:api-reference + check:samples ``` -A documentation repository has no compiler for its prose, but six things in it -are decidable, and all six are decided before a merge: +A documentation repository has no compiler for its prose, but eight things in +it are decidable, and all eight are decided before a merge: | | | |---|---| @@ -37,9 +40,11 @@ are decidable, and all six are decided before a merge: | `docs:build` | a page that does not build is a page nobody can read | | `check:examples` | the ABAP in the fenced blocks, against the real framework: does it compile, and does the view it builds name controls and properties that exist on the UI5 floor this documentation targets | | `check:api-names` | every `client->` name on the site — method, parameter, `cs_*` constant — against `z2ui5_if_client` at the release this site names, plus every `blob/main/` link into the framework's tree. `check:examples` compiles the fenced blocks that are whole CLASSES; this is the rest of the page: the sentence, the two-line snippet, the constant block a page reproduces, the source link. Four pages taught API that 1.143.0 had deleted and nothing was red | +| `check:api-reference` | the committed client API reference — the generated block in `resources/api.md` and `docs/public/api/client-api.json` — regenerated from `z2ui5_if_client` at the release this site names and compared byte for byte. Goes stale the same way `check:version` does: a release happens over there, and the committed reference still describes the one before it. `npm run generate:api` rewrites both | +| `check:playground` | every complete app class on the site either carries a **Run** button or a marker on its page saying why it cannot run. The rules that offer the button fail towards *not* offering one, so without this an example nobody ever measured is indistinguishable from an example that can never run — which is exactly how the coverage ledger below went stale. What stays undecidable by CI — does a *buttoned* example actually start — is the measurement the Run-button section describes | | `check:samples` | the **Working Samples** blocks, against [abap2UI5/samples](https://github.com/abap2UI5/samples) | -`.github/workflows/check.yml` runs the same six, in the same order. Keep the +`.github/workflows/check.yml` runs the same eight, in the same order. Keep the two in step: a step that exists only in `package.json` is a step no pull request has to pass, which is how `npm test` — the pin added *because* the catalogue parser broke twice in silence — went a release without CI. @@ -54,11 +59,24 @@ There used to be one more, `check:counts`, holding four figures on a gone — the home page opens [the samples page](https://abap2ui5.github.io/samples/) directly and each catalogue introduces itself — and with it the only prose copy of a number this repository does not own. `generate-llms.mjs` still counts -the three catalogues into `llms.txt`, which is why CI sparse-checks out +the sample catalogues into `llms.txt`, which is why CI sparse-checks out `SAMPLES.md` from `samples-controls` and `samples-stack`; both are `continue-on-error`, because an unreachable repository must cost a figure and not the run, and a generated line can simply leave the number out. +A count comes down a chain, first answer wins, all of it in +`scripts/lib/catalogue.mjs` and pinned by `test/catalogue.test.mjs`: a sibling +checkout's `catalogue.json` (the machine-readable catalogue each sample +repository commits at its root), then the checkout's `SAMPLES.md` through the +same parser the sample links go through, then the `catalogue.json` the +repository publishes on its default branch, fetched — then no number. The two +`catalogue.json` steps read the same file, so a build with a checkout and a +build without one publish the same figure; and every step **counts entries** +rather than repeating a `counts` field, so no path can hand `llms.txt` a claim +instead of a count. The fetch is allowed to fail — 404 before the file is +committed over there, timeout, no network — and every failure costs the +figure, never the build. + ## What the site publishes for machines `docs:build` runs `scripts/generate-llms.mjs` first, which writes three things @@ -71,12 +89,27 @@ are a projection of the pages next to them: | [`/docs/llms-full.txt`](https://abap2ui5.github.io/docs/llms-full.txt) | the whole documentation as one markdown document | | `/docs/.md` | each page as raw markdown, next to its `.html` | +One more file is published for machines and — unlike the three above — +**committed**: [`/docs/api/client-api.json`](https://abap2ui5.github.io/docs/api/client-api.json), +the client API as one JSON document. It is not a projection of the pages next +to it but a claim about the framework at a pinned release, which is the +samples-block case, not the llms.txt case — so it is generated by +`npm run generate:api` and held fresh by `check:api-reference`. + This is for the reader nothing else reaches: an agent that is simply *asked* about abap2UI5, with no MCP server and no checkout. Without it, it falls back on training data — where abap2UI5 still looks like `z2ui5_cl_xml_view`. Nothing needs maintaining. Adding a page to the sidebar adds it here. +The reader with a checkout and no build gets the inverse problem: the three +files are gitignored, so a clone contains none of them. `llms.txt` at the +repository ROOT is the answer — a committed pointer naming the published URLs, +maintained by hand because it names URLs and nothing else. Do not "fix" it by +committing the generated files instead: they would be stale on every commit +that touches a page, and a wrong committed copy outranks a right generated one +in every tool that reads the tree. + ## Things that will trip you up - **The nav bar and the sidebar contain the same two entries.** @@ -103,7 +136,11 @@ Nothing needs maintaining. Adding a page to the sidebar adds it here. - **A generated block in a page is committed.** The *Working Samples* blocks are written into the markdown so the site builds without a samples checkout. Run `npm run link:samples` after changing a page's `samples:` frontmatter; - `check:samples` fails if a rewrite would change anything. + `check:samples` fails if a rewrite would change anything. The client API + reference on `resources/api.md` works the same way: everything between its + markers, plus `docs/public/api/client-api.json` next to it, comes from + `npm run generate:api` — edit the intro around the block by hand, never the + block, and regenerate after a release bump or `check:api-reference` fails. ## The Run button, and why its rules are hand-maintained @@ -113,12 +150,11 @@ The code travels in the playground's URL fragment, read out of the rendered block at click time — so nothing is hosted here, and the example that runs is the text on the page rather than a copy of it. -**This is the seventh decidable thing in this repository and the only one CI -cannot decide.** Whether an example runs is a question only a playground can -answer, and a playground is a three-minute build of another repository. So the -rules in `docs/.vitepress/playground.mjs` are an approximation, they fail -towards *no button*, and every one of them came from an example watched failing -in a real one: +**Whether an example runs is the one question CI cannot answer** — only a +playground can, and a playground is a three-minute build of another repository. +So the rules in `docs/.vitepress/playground.mjs` are an approximation, they +fail towards *no button*, and every one of them came from an example watched +failing in a real one: | | | | --- | --- | @@ -135,6 +171,24 @@ shapes a rule written one word wider would have swallowed**: a `SELECT` in a comment, the word FROM inside a string, `INSERT VALUE #( )` into an internal table. +**The bookkeeping half of the question, however, is decidable, and +`check:playground` decides it.** An example the rules refuse carries a marker +on its page, directly above the fence: + +```md + +``` + +A complete app class with neither a button nor a marker fails the gate: either +it can run — then measure it (below) and let it have its button — or it cannot, +and the marker records why in the page's own words, next to the code it is +about. A marker above an example that *has* a button fails just as loudly, so +intent cannot outlive a fix. `npm run check:playground -- --list` prints every +deliberate exclusion with both reasons — the engine's and the page's — which is +also the worklist for the next measurement. What the gate cannot decide, and +says so, is whether a buttoned example actually starts; that stays a +measurement. + **To redo the measurement** — after adding examples, or after the playground changes — build the playground, serve it, and open each fenced example in an embedded one, checking that the status line reaches `running` and that the app @@ -146,10 +200,18 @@ npm ci && npm run build && npm run serve # the first build is a few minutes # then, for each example: /?embed=1&view=app# ``` -The last measurement: **61 complete app classes, 39 with a button, all 39 -started and rendered.** The home page has since dropped its example, so the -figure today is 60 and 38; the 22 without a button each have a reason the module -prints. +The last full measurement: **61 complete app classes, 39 with a button, all 39 +started and rendered.** The site has since grown, and the hand-kept copy of +those figures here went stale without anyone noticing — which is what +`check:playground` now exists to prevent. The bookkeeping today, printed by the +gate on every run: **68 complete app classes, 47 with a button, 21 excluded on +purpose**, every exclusion a marker on its page. The growth is examples whose +shapes the measured rules already covered, plus one page completed so its +example could run at all; a spot-check of six buttoned examples in a served +playground build — the newly buttoned life-cycle class driven through its whole +event roundtrip, the quickstart and About classes, tutorial Step 12, the +tables page, and the `SELECT FROM t100` example — started and rendered, every +one. The next full measurement opens all 47. **The published playground is what readers get**, not the checkout you tested against. A change to the rules here can ship on its own; a change that depends diff --git a/CLAUDE.md b/CLAUDE.md index cfcc4f3a..a61e3ed0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,6 @@ # CLAUDE.md All project guidance lives in **[AGENTS.md](AGENTS.md)** — the single source of -truth for this repository (how the site is built, the six gates, the playground rule engine, and what may be written where). +truth for this repository (how the site is built, the seven gates, the playground rule engine, and what may be written where). Read `AGENTS.md` before making any change. diff --git a/README.md b/README.md index 56f0018b..d8013a89 100644 --- a/README.md +++ b/README.md @@ -15,18 +15,21 @@ Every contribution makes the documentation better for the community! ```sh npm ci npm run docs:dev # the site, with hot reload -npm run check # what CI runs, all five steps +npm run check # what CI runs, all seven steps ``` ### What CI checks -A documentation repository has no compiler for its prose, but five things in it -are decidable, and `npm run check` decides all five before a merge — the prose -builds (`docs:build`), the fenced ABAP examples compile and the views they -build name real UI5 API (`check:examples`), the sample links still match the -sample repositories (`check:samples`), the release number in the nav bar still -matches the framework (`check:version`), and the catalogue parser still parses -(`test`). +A documentation repository has no compiler for its prose, but seven things in +it are decidable, and `npm run check` decides all seven before a merge — the +prose builds (`docs:build`), the fenced ABAP examples compile and the views +they build name real UI5 API (`check:examples`), every `client->` name and +`cs_*` constant the prose and snippets mention still exists in the release the +site names (`check:api-names`), the generated client API reference still +matches the interface at that release (`check:api-reference`), the sample +links still match the sample repositories (`check:samples`), the release +number in the nav bar still matches the framework (`check:version`), and the +catalogue parser still parses (`test`). `.github/workflows/check.yml` runs the same list in the same order, so a green `npm run check` locally is a green pull request — and `deploy.yml` runs it again before it publishes, so the site is only ever built from a tree that @@ -34,7 +37,7 @@ passed. Several of these go stale without anybody touching this repository (a release is published elsewhere, a sample class is renamed elsewhere), which is why the deploy re-runs them rather than trusting the merge. -**[AGENTS.md](AGENTS.md) describes each of the five**, what a failure means and +**[AGENTS.md](AGENTS.md) describes each of the seven**, what a failure means and which of them need a sibling checkout to say anything at all — read it before changing anything beyond prose. @@ -48,6 +51,17 @@ generated on every build and gitignored — never edit them, and nothing needs maintaining: the page list comes from the **sidebar**, so adding a page there adds it here. [AGENTS.md](AGENTS.md) has the details. +Because they are gitignored, a **clone** of this repository carries none of +them — so [`llms.txt`](llms.txt) at the repository root is a committed pointer +naming the published URLs, for the agent that lands in the checkout rather +than on the site. + +The client API is published for the same reader as one JSON document, +[client-api.json](https://abap2ui5.github.io/docs/api/client-api.json) — +generated from `z2ui5_if_client` at the pinned release by +`npm run generate:api`, committed, and held fresh by `check:api-reference`. +The human-readable half is the [Client API page](https://abap2ui5.github.io/docs/resources/api.html). + ### Running an example from the page An ABAP example that the [playground](https://github.com/abap2UI5/playground) diff --git a/docs/.vitepress/config.mjs b/docs/.vitepress/config.mjs index f4ff492a..25d8d49e 100644 --- a/docs/.vitepress/config.mjs +++ b/docs/.vitepress/config.mjs @@ -222,6 +222,8 @@ export default defineConfig({ { text: "8. Selection Screen", link: "/tutorials/walkthrough/step-8" }, { text: "9. Tables", link: "/tutorials/walkthrough/step-9" }, { text: "10. App Structure", link: "/tutorials/walkthrough/step-10" }, + { text: "11. To Production", link: "/tutorials/walkthrough/step-11" }, + { text: "12. Unit Tests", link: "/tutorials/walkthrough/step-12" }, ], }, // The sheet you keep open WHILE working through the steps. It keeps @@ -586,6 +588,11 @@ export default defineConfig({ collapsed: true, items: [ { text: "References", link: "/resources/references" }, + // Generated from z2ui5_if_client at the pinned release by + // scripts/generate-api-reference.mjs — the entry lives here rather + // than in the Cookbook because it is a lookup destination, not a + // reading path: the cookbook chapters explain, this page lists. + { text: "Client API", link: "/resources/api" }, // No "Sample Catalogues" entry here any more. A page that only // described the three catalogues put a stop between the reader and // the corpus, and had to be kept true about counts and facets it diff --git a/docs/.vitepress/playground.mjs b/docs/.vitepress/playground.mjs index fdb040ac..c0aa9ba6 100644 --- a/docs/.vitepress/playground.mjs +++ b/docs/.vitepress/playground.mjs @@ -39,7 +39,11 @@ * button. * * An example that trips one of them is still printed, still checked by - * `check:examples` and still copied by readers. It simply has no button. + * `check:examples` and still copied by readers. It simply has no button — and + * its page says why, in a `` marker + * above the fence. `scripts/check-playground.mjs` refuses an app example that + * has neither button nor marker, and a marker whose example got its button, so + * nothing here is ever buttonless by accident. * * The code the button runs is read from the rendered block at click time * (`theme/playground.js`), not copied into an attribute here: the reader runs diff --git a/docs/advanced/vscode.md b/docs/advanced/vscode.md index 3e77cb00..fa0fabfd 100644 --- a/docs/advanced/vscode.md +++ b/docs/advanced/vscode.md @@ -87,6 +87,18 @@ is not supported — use `external` mode there. *"abap2UI5: Clear Stored SAP Credentials"* forgets the password again. ::: +### When the preview stays white: the connection check + +The most common first-run failure is a launch URL that is slightly wrong, and +its symptom in the preview is a white rectangle that says nothing. *"abap2UI5: +Check System Connection"* diagnoses it: the command walks the exact route F9 +takes — the same URL expansion, the same stored credentials, the same proxy — +and reports step by step where a launch would end: the URL's shape, the host, +the logon, the ICF path, the page itself, each with the fix next to the +failing step. The full report lands in the **abap2UI5** output channel. This +also works for a system installed five minutes ago, which makes it the +diagnosis step of the [Quickstart](/get_started/quickstart#_5-verify). + ### Reload on activation, not on save Saving an ABAP class does not change what the server runs — only **activation** diff --git a/docs/configuration/authorization.md b/docs/configuration/authorization.md index 9a2e6df4..15d77da3 100644 --- a/docs/configuration/authorization.md +++ b/docs/configuration/authorization.md @@ -73,6 +73,7 @@ Alternatively, handle authorization within individual app classes. This approach ### Example: Authorization Check in an App Class In this approach, each app checks the user's permissions, like traditional ABAP apps. + ```abap CLASS z2ui5_cl_app DEFINITION PUBLIC. diff --git a/docs/configuration/launchpad.md b/docs/configuration/launchpad.md index 77e5e76a..76f54fca 100644 --- a/docs/configuration/launchpad.md +++ b/docs/configuration/launchpad.md @@ -121,6 +121,7 @@ INTERFACE z2ui5_if_lp_kpi ENDINTERFACE. ``` (2/3) Implement the interface in your app class next to `z2ui5_if_app`. The `count` method holds your KPI logic (e.g., counting open items from the database): + ```abap CLASS z2ui5_cl_lp_kpi_hello_world DEFINITION PUBLIC. diff --git a/docs/cookbook/device_capabilities/spreadsheet.md b/docs/cookbook/device_capabilities/spreadsheet.md index 053a2012..3d6563d9 100644 --- a/docs/cookbook/device_capabilities/spreadsheet.md +++ b/docs/cookbook/device_capabilities/spreadsheet.md @@ -11,6 +11,7 @@ Convert uploaded XLSX files into an internal table: ::: code-group + ```abap CLASS z2ui5_cl_sample_upload DEFINITION PUBLIC. diff --git a/docs/cookbook/eml_cds_sql/abap_sql.md b/docs/cookbook/eml_cds_sql/abap_sql.md index f9375c51..e8bd4d8c 100644 --- a/docs/cookbook/eml_cds_sql/abap_sql.md +++ b/docs/cookbook/eml_cds_sql/abap_sql.md @@ -8,6 +8,7 @@ ABAP SQL is the standard way to read and change data in the database directly fr ## Read Data The example below selects flights from the `sflight` table and shows them in a UI5 table: + ```abap CLASS z2ui5_cl_sample_sql DEFINITION PUBLIC. diff --git a/docs/cookbook/eml_cds_sql/cds.md b/docs/cookbook/eml_cds_sql/cds.md index 050bccdb..1af6930d 100644 --- a/docs/cookbook/eml_cds_sql/cds.md +++ b/docs/cookbook/eml_cds_sql/cds.md @@ -7,6 +7,7 @@ All examples in these docs work without CDS. On a recent ABAP release, you can a ## ABAP CDS ABAP Core Data Services (CDS) let you define structured views and read data straight from the database. The example below fetches sales orders from the `I_SalesOrder` view of the Virtual Data Model (VDM) and shows them in a UI5 table: + ```abap CLASS z2ui5_cl_sample_cds DEFINITION PUBLIC. diff --git a/docs/cookbook/eml_cds_sql/draft_handling.md b/docs/cookbook/eml_cds_sql/draft_handling.md index ef541351..cee5ce5c 100644 --- a/docs/cookbook/eml_cds_sql/draft_handling.md +++ b/docs/cookbook/eml_cds_sql/draft_handling.md @@ -138,6 +138,7 @@ COMMIT ENTITIES. Before the full-featured version, here is the **smallest app that actually works**. It does exactly three things: read the record, let the user edit, and save with one button. Start here — once this makes sense, the advanced version is just more buttons. + ```abap CLASS z2ui5_cl_sample_draft_min DEFINITION PUBLIC. PUBLIC SECTION. @@ -493,6 +494,7 @@ The view itself is a single `simple_form` whose `editable` and per-input `enable ### Full Snippet ::: details Full working example — `z2ui5_cl_sample_draft` + ```abap CLASS z2ui5_cl_sample_draft DEFINITION PUBLIC. PUBLIC SECTION. diff --git a/docs/cookbook/eml_cds_sql/eml.md b/docs/cookbook/eml_cds_sql/eml.md index 36a59eb0..bf73db71 100644 --- a/docs/cookbook/eml_cds_sql/eml.md +++ b/docs/cookbook/eml_cds_sql/eml.md @@ -10,6 +10,7 @@ The Entity Manipulation Language simplifies work with RAP business objects by gi ### Read Use `READ ENTITIES` to fetch sales orders and show them in a UI5 table: + ```abap CLASS z2ui5_cl_sample_eml_read DEFINITION PUBLIC. diff --git a/docs/cookbook/eml_cds_sql/fuzzy_search.md b/docs/cookbook/eml_cds_sql/fuzzy_search.md index 507be7ff..5791b1cd 100644 --- a/docs/cookbook/eml_cds_sql/fuzzy_search.md +++ b/docs/cookbook/eml_cds_sql/fuzzy_search.md @@ -9,6 +9,7 @@ Wire it to a UI5 `search_field` in the table toolbar and you get an ALV-style se ## Minimal Example + ```abap CLASS z2ui5_cl_sample_fuzzy DEFINITION PUBLIC. diff --git a/docs/cookbook/event_navigation/life_cycle.md b/docs/cookbook/event_navigation/life_cycle.md index 5576576b..88ae23d9 100644 --- a/docs/cookbook/event_navigation/life_cycle.md +++ b/docs/cookbook/event_navigation/life_cycle.md @@ -33,6 +33,7 @@ CLASS z2ui5_cl_demo_app_001 IMPLEMENTATION. CASE abap_true. WHEN client->check_on_init( ). + value = `World`. render_main( ). WHEN client->check_on_event( `POST` ). on_post( ). @@ -42,6 +43,32 @@ CLASS z2ui5_cl_demo_app_001 IMPLEMENTATION. ENDMETHOD. + METHOD render_main. + + DATA(view) = z2ui5_cl_ui5_view_builder=>factory( + )->ele( n = `View` ns = `mvc` + )->a( n = `xmlns` v = `sap.m` + )->a( n = `xmlns:mvc` v = `sap.ui.core.mvc` + + )->ele( `Page` + )->a( n = `title` v = `Life Cycle` + + )->tag( `Input` + )->a( n = `value` v = client->_bind( value ) + )->tag( `Button` + )->a( n = `text` v = `Post` + )->a( n = `press` v = client->_event( `POST` ) ). + + client->view_display( view->stringify( ) ). + + ENDMETHOD. + + METHOD on_post. + + client->message_toast_display( |POST received: { value }| ). + + ENDMETHOD. + ENDCLASS. ``` diff --git a/docs/cookbook/expert_more/email.md b/docs/cookbook/expert_more/email.md index 1e6ccb3b..40bdd3e8 100644 --- a/docs/cookbook/expert_more/email.md +++ b/docs/cookbook/expert_more/email.md @@ -7,6 +7,7 @@ abap2UI5 has no e-mail control of its own — sending mail is plain ABAP via `cl ## Plain Text Mail + ```abap CLASS z2ui5_cl_sample_email DEFINITION PUBLIC. diff --git a/docs/cookbook/expert_more/lock.md b/docs/cookbook/expert_more/lock.md index 26d302b1..390364ec 100644 --- a/docs/cookbook/expert_more/lock.md +++ b/docs/cookbook/expert_more/lock.md @@ -18,6 +18,7 @@ The minimal starting point — the user edits and saves, no lock and no conflict
Full source — Z2UI5_CL_SAMPLE_LOCK_1 + ```abap * Scenario 1 — Naive editing (no locking) * @@ -181,6 +182,7 @@ The lock exists for milliseconds, so this scales — but two parallel saves can
Full source — Z2UI5_CL_SAMPLE_LOCK_2 + ```abap * Scenario 2 — Edit + Enqueue at save * @@ -348,6 +350,7 @@ Pick a column that *always* updates on writes. If anyone writes the table bypass
Full source — Z2UI5_CL_SAMPLE_LOCK_3 + ```abap * Scenario 3 — Optimistic locking (timestamp check) * @@ -492,6 +495,7 @@ ENDCLASS.
Full source — Z2UI5_CL_SAMPLE_LOCK_4 + ```abap * Scenario 4 — Combined (the recommended default) * @@ -702,6 +706,7 @@ Each active user pins a work process. Use stateful sessions only for low-traffic
Full source — Z2UI5_CL_SAMPLE_LOCK_5 + ```abap * Scenario 5 — Stateful session with a persistent enqueue * @@ -897,6 +902,7 @@ A user closing the browser without pressing *Release* leaves the row behind, so
Full source — Z2UI5_CL_SAMPLE_LOCK_6 + ```abap * Scenario 6 — Soft lock (advisory only) * diff --git a/docs/cookbook/expert_more/snippets.md b/docs/cookbook/expert_more/snippets.md index 06b6374b..d7a1bbed 100644 --- a/docs/cookbook/expert_more/snippets.md +++ b/docs/cookbook/expert_more/snippets.md @@ -130,6 +130,7 @@ The quickest way to surface ABAP data on screen: build the HTML with `cl_demo_ou See [Demo Output](/cookbook/expert_more/demo_output) for the full CSS block. The minimal version: + ```abap CLASS z2ui5_cl_app_write_output DEFINITION PUBLIC. diff --git a/docs/cookbook/expert_more/value_help.md b/docs/cookbook/expert_more/value_help.md index 735aae94..1118fdf2 100644 --- a/docs/cookbook/expert_more/value_help.md +++ b/docs/cookbook/expert_more/value_help.md @@ -59,6 +59,7 @@ one because that is what existing code contains. ::: + ```abap CLASS z2ui5_cl_sample_f4 DEFINITION PUBLIC. diff --git a/docs/cookbook/troubleshooting/common_failures.md b/docs/cookbook/troubleshooting/common_failures.md index 5b70e310..16971ac2 100644 --- a/docs/cookbook/troubleshooting/common_failures.md +++ b/docs/cookbook/troubleshooting/common_failures.md @@ -120,6 +120,122 @@ Where to look: - **Fix**: move surviving state out of the method into an attribute with a concrete, serializable type — it does not have to be public for that; only `_bind( )` needs public. If serialization itself is the problem the roundtrip says so, with `APP_SERIALIZATION_ERROR`. For resources that genuinely need to live server-side across events (file handles, persistent locks, expensive caches), see [Statefulness](/cookbook/expert_more/statefulness). - **The draft expires.** Four hours by default — an app left open longer starts fresh rather than restoring. +## Error Index + +The sections above start from a behavior — nothing renders, data does not +update. This index starts from the other end: a literal message in front of +you, in the error view of a failed roundtrip or in the browser console. Every +message below is one the framework or UI5 actually produces; find yours, and +the entry says what caused it and where the fix is explained. + +#### `The app 'ZCL_...' does not exist in the system.` + +The framework could not instantiate the class named in `?app_start=` — a typo +in the URL, or the class exists but is not activated. The roundtrip answers +500 and shows this message. Fix the name or activate the class; the +[Quickstart's verify step](/get_started/quickstart#_5-verify) covers the other +first-launch failures around it. + +#### `BINDING_ERROR - No class attribute for binding found - Please check if the bound values are public attributes of your class` + +A `_bind( )` on an attribute that is not in the `PUBLIC SECTION`, or on a +local variable. Covered in full under +[Bound Attribute Not Public](#bound-attribute-not-public) above. + +#### `BINDING_ERROR_TAB_CELL_LEVEL - Row index out of range` + +A cell binding — `client->_bind( val = … tab = … tab_index = … )` — names a +row the table does not have: the index is off (it is 1-based, like every ABAP +index), or the table was refilled or shortened after the index was computed +and before the view was built. Rebuild the view from the current table state. + +#### `Binding Error - component '...' not found in the bound row` + +The same cell binding, but `val` is not a component of `tab`'s row type — the +classic case is passing a field of a *different* structure (a copy, a work +area of another type) as the cell value, or a renamed column that the view +code still names. Bind the field of the row type the table actually has. + +#### `APP_SERIALIZATION_ERROR - the app state could not be serialized. Please check if all generic data references are public attributes of your class` + +Between two events the app instance is serialized, and one attribute cannot +be: typically a `REF TO` a non-serializable object or a generic data +reference. The chained previous exception names the attribute that gave up. +Move non-serializable resources out of attributes, or see +[State Lost Between Events](#state-lost-between-events) above and +[Statefulness](/cookbook/expert_more/statefulness) for resources that must +live across events. + +#### `Dispatch limit of 1000 app navigations in one request reached - check for an endless nav_app_call/nav_app_leave loop in main( )` + +Two apps hand control to each other forever inside a single request — most +often a `nav_app_call( )` that runs unconditionally in `main( )` instead of +inside an event or `check_on_navigated( )` branch, so the called app's first +roundtrip immediately navigates again. Guard the navigation; see +[Navigation](/cookbook/event_navigation/navigation). + +#### `failed to load 'sap/m/....js'` — browser console, view does not appear + +UI5 resolved a tag in your view as a *control class* and requested a file +that does not exist. Two ways to get there: a control name that is not on the +UI5 release your system serves, or a generic child tag (`ele( 'footer' )`) +naming an aggregation the parent does not have **on that release** — UI5 then +tries to load it as a control. `sap.m.Dialog`'s `footer`, for example, is +public only in newer releases; on an older one, use `buttons`. The +[linter](/advanced/linter) decides both against the release you target, +without a system. + +#### `Binding "/PATH" was not found in model` — browser console warning, control renders empty + +Not an error — the control simply stays empty. Covered in full under +[Binding-Path Mismatch](#binding-path-mismatch) above. + +#### `"" is of type string, expected for property "..."` — browser console, app dies when a table empties + +An enum-typed property (`type`, `state`, `valueState`, …) inside an +aggregation template is bound to a field that arrives as an empty string — +ABAP has no null, so an unfilled `TYPE string` serializes as `""`, and `""` +is a member of no UI5 enum. The first render passes; the failure comes when +the bound table is *emptied*, because UI5 then evaluates the template with no +row behind it. Keep initial values out of the model with `_bind( )`'s +`omit_initial_paths` (see the [Client API](/resources/api)), or give the +binding an explicit fallback to the enum's default value. + +#### `EvalError: Evaluating a string as JavaScript violates the following Content Security Policy directive ...` — page loads, component does not start + +A hardened CSP without `'unsafe-eval'` meets an old UI5 release: the `1.71` +ui5loader still evaluates module source as a string. Either bootstrap a +modern UI5 release, or keep `'unsafe-eval'` in the policy — see +[Security → Hardening](/configuration/security#hardening-dropping-unsafe-eval). + +#### `403 ICFEUCONFORBIDDEN` — separately deployed frontend, every action fails + +The deployed UI5 app posts to the HTTP service path written in its +`manifest.json`, and that service does not exist under this ID on your +system. The error does not say which URL it tried. Align the +`sap.app.dataSources.http.uri` entry with the service you created — see +[S/4 Public Cloud](/configuration/s4_public_cloud) for the paths each +frontend branch ships with. + +#### `Literals across more than one line are not allowed` — abapGit pull, and the app class is empty afterwards + +An import-time failure, not a runtime one: a source line longer than 255 +characters. abapGit reports the error for that object and **carries on**, so +what stays behind is an empty class stub — the tree looks imported, the app +is gone. Pull again after the line is split (in your own code: break long +literals into `&&` chunks); check the abapGit log rather than the package +tree to see which objects really arrived. + +#### An icon is simply missing — no message anywhere + +An unknown `sap-icon://` name is not an error: the icon pool finds nothing +and the control renders without an icon, silently. Either the name does not +exist at all (icon names are effectively case-insensitive and matched +lower-cased, so a camelCase name matches nothing), or it entered the icon +font *after* the UI5 release your system serves. The +[linter](/advanced/linter) checks every icon name against the release you +target. + --- For EML-specific failure handling (`FAILED` / `REPORTED`, transactional behavior, `cx_abap_behv`, `cx_abap_lock_failure`, defensive `TRY/CATCH` patterns), see the [EML](/cookbook/eml_cds_sql/eml) page. diff --git a/docs/get_started/about.md b/docs/get_started/about.md index 4590cc4f..2f002d22 100644 --- a/docs/get_started/about.md +++ b/docs/get_started/about.md @@ -27,7 +27,7 @@ A finished app looks like any other UI5 app, because it is one — a selection s ![An abap2UI5 app: a selection form above a table of invoices, with a dialog editing one row](/tutorials/walkthrough-preview.png) -→ *The [Tutorial](/tutorials/overview) grows exactly this app in ten steps, each one a complete class you can run in the browser* +→ *The [Tutorial](/tutorials/overview) grows exactly this app in ten steps, each one a complete class you can run in the browser — then takes it to production, and under unit tests* ## Background Since launching in 2023, abap2UI5 has grown from a small side project into a community-driven framework used by ABAP developers worldwide. The framework absorbs frontend complexity, so you can focus on business logic with your existing ABAP skills. @@ -139,6 +139,9 @@ Getting started is easy: 2. Set up an HTTP service for browser communication 3. Start building! +No system at hand? The Quickstart opens with two ways to try abap2UI5 in the +browser — the playground and the live demo — before anything is installed. + → *See the [Quickstart Guide](/get_started/quickstart) for full instructions* ## Tooling diff --git a/docs/get_started/quickstart.md b/docs/get_started/quickstart.md index 6949f08c..f1e3f9fe 100644 --- a/docs/get_started/quickstart.md +++ b/docs/get_started/quickstart.md @@ -3,26 +3,79 @@ outline: [2, 4] --- # Quickstart -::: tip No system at hand? -Try abap2UI5 first in the -[**live demo**](https://abap2ui5.github.io/web-abap2UI5-build/): the complete +There are three ways to meet abap2UI5, and they differ in what you need: a +browser, a browser, or a system of your own. Decide first, then follow one +path — the first two need no installation at all. + +**Try it in the browser — nothing to install.** Every step of the +[Tutorial](/tutorials/overview) is a complete class with a **Run** button under +it, and the [playground](https://abap2ui5.github.io/playground/) behind that +button is an ABAP editor with the running app beside it — write a class, press +run, see the app. The sample catalogues open there too: every card on the +[Learn](https://abap2ui5.github.io/samples/) and +[Controls](https://abap2ui5.github.io/samples-controls/) pages starts that +sample in the playground. + +**Explore the live demo.** The +[live demo](https://abap2ui5.github.io/web-abap2UI5-build/) is the complete stack — framework, backend and sample apps — downported, transpiled to JavaScript and running inside your browser tab, against an in-memory database. -No installation, no SAP system, no login. It is rebuilt daily from `main`, so -what you click there is the current framework. Come back here when you want the -same apps on a real system. -::: +It is rebuilt daily from `main`, so what you click there is the current +framework. Where the playground runs one class at a time, the live demo is the +whole delivered system, startup page included. + +**Install on your own system.** The rest of this page: the same apps on a real +ABAP system, from the abapGit pull to a first app you wrote yourself, with a +verification step at the end that says what you should be seeing. -## 1. Installation via abapGit +## 1. Install the Framework via abapGit -Install [abap2UI5](https://github.com/abap2UI5/abap2UI5) with [abapGit](https://abapgit.org). (New to abapGit? Install it first — see [abapGit](/technical/tools/abapgit); it's the one-time tool used to pull abap2UI5 into your system.) +Pull [abap2UI5](https://github.com/abap2UI5/abap2UI5) with +[abapGit](https://abapgit.org). (New to abapGit? Install it first — see +[abapGit](/technical/tools/abapgit); it's the one-time tool used to pull +abap2UI5 into your system.) For anything beyond a first look, pull a +[release](https://github.com/abap2UI5/abap2UI5/releases/) rather than `main` — +see [Productive Usage](/configuration/productive_usage) for why. ![abapGit repository installation screen for abap2UI5](/get_started/image.png) ::: details ABAP Cloud +On BTP ABAP Environment and S/4 Public Cloud, use abapGit for Eclipse (ADT) and +mass-activate the pulled objects afterwards — the +[S/4 Public Cloud](/configuration/s4_public_cloud) page walks through it +screenshot by screenshot, including the two link choices that cannot be changed +later. + ![abapGit installation for ABAP Cloud environments](/get_started/image-4.png) ::: +The framework is everything you need: the HTTP endpoint you create next serves +the UI5 frontend itself, so there is no separate frontend to deploy. + +::: details When a separate frontend app is wanted anyway +Some production scenarios install one later — on S/4 Public Cloud the HTTP +endpoint needs `S_DEVELOP` authorization, so business users reach the app +through a separately deployed frontend and a tile instead (see +[S/4 Public Cloud](/configuration/s4_public_cloud)). For those cases the +[frontend](https://github.com/abap2UI5/frontend) repository publishes the same +frontend as ready-made deployment branches. Pull the one that matches your +system: + +| Branch | System | UI5 | +|---|---|---| +| `cloud` | S/4 Public Cloud, BTP ABAP Environment | classic | +| `cloud_v2` | S/4 Public Cloud, BTP ABAP Environment | legacy-free (UI5 2.x) | +| `standard` | S/4 Private Cloud, S/4 On-Premise, R/3 NetWeaver > 7.50 | classic | +| `standard_v2` | S/4 Private Cloud, S/4 On-Premise | legacy-free (UI5 2.x) | + +The `cloud` branches carry a complete Fiori source project plus the HTTP +service artifacts; the `standard` branches carry the frontend as BSP `Z2UI5` +with its ICF handler. The `_v2` variants differ only in the bootstrap — pick +one when your system serves UI5 2.x. None of this is needed today: finish this +page first, and come back via [Installation](/configuration/installation) when +production planning starts. +::: + ## 2. Set Up HTTP Handler and Service Create a package and define an HTTP handler class. Use the **ABAP** tab for Standard ABAP systems (R/3 NetWeaver, S/4 On-Premise / Private Cloud); use the **ABAP Cloud** tab only on BTP ABAP Environment or S/4 Public Cloud: @@ -73,7 +126,7 @@ abap2UI5 talks only to the HTTP service you define, giving you full control over ## 3. First Launch Open the HTTP endpoint in your browser — in `SICF`, right-click your service node and choose **Test Service** (the URL looks like `https://:/sap/bc/`). This startup page is also where you will launch your own apps later: abap2UI5 startup page with check button and test app launcher -Press `check` to verify your installation, then launch the bundled test app to confirm everything works. That's it — you can now build your own abap2UI5 apps. +Press `check` to verify your installation, then launch the bundled test app to confirm everything works. ## 4. Your First App Build a class on your system: @@ -89,12 +142,54 @@ CLASS zcl_my_app IMPLEMENTATION. ENDMETHOD. ENDCLASS. ``` -Back on the startup page, enter your class name `ZCL_MY_APP` in the input field and launch it — that's it: you've built your first abap2UI5 app. +Back on the startup page, enter your class name `ZCL_MY_APP` in the input +field and launch it. The startup page is a convenience around a URL parameter +you will use from now on: `?app_start=` appended to your service URL +starts that app directly — + +``` +https://:/sap/bc/?app_start=zcl_my_app +``` + +— which is the form a browser bookmark, a Launchpad tile and every sample +catalogue use. ::: tip **Naming** Name your own apps in your customer namespace (`Z...`/`Y...`). The `Z2UI5_` prefix is reserved for the framework and its samples. ::: +## 5. Verify + +You should now see an empty page with a **Hello World** message box on it, at +`…?app_start=zcl_my_app`. That is the whole install verified: abapGit pull, +handler, service and app class. If you see something else instead: + +- **The browser shows an ICF error page or a plain 404** — the request never + reached the handler. In `SICF`, check that the service node is *activated* + (right-click → Activate Service) and that the URL path matches the node. +- **A logon prompt you did not expect, or a 401/403** — authentication is the + ICF node's job, exactly as for any other service. Check the node's **Logon + Data** tab, and see [Security](/configuration/security) for how access to + the endpoint is controlled. +- **The page loads, but launching the app reports `The app 'ZCL_MY_APP' does + not exist in the system.`** — the framework could not instantiate the class: + a typo in the name, or the class is not activated yet. +- **The startup page never appears, or stays white** — open the browser + console (`F12`); a bootstrap problem such as a blocked UI5 CDN logs there. + Systems without internet access must serve UI5 themselves — see + [Bootstrapping](/configuration/setup/ui5_bootstrapping). + +Anything that goes wrong *after* this point — an app that renders empty, a +binding that does not update, an error view on a roundtrip — is catalogued +with symptom, cause and fix in +[Common Failures](/cookbook/troubleshooting/common_failures). + +If you use VS Code: the [abap2UI5 extension](/advanced/vscode) ships a command +**"abap2UI5: Check System Connection"** that probes your service URL step by +step — URL shape, host, logon, the page itself — and reports where a launch +would fail, with the fix next to the failing step. It is the fastest way to +diagnose a first-run problem without clicking through `SICF`. + ## Next Steps [Hello World](/get_started/hello_world) explains what that class actually did. diff --git a/docs/index.md b/docs/index.md index 5ac3f1df..ac6ac156 100644 --- a/docs/index.md +++ b/docs/index.md @@ -43,7 +43,7 @@ hero: features: - title: Tutorial icon: - details: Learn by building — ten steps that grow one runnable app, from a message box to a table. + details: Learn by building — steps that grow one runnable app, from a message box to a tested app in production. link: /tutorials/overview - title: Configuration icon: diff --git a/docs/public/api/client-api.json b/docs/public/api/client-api.json new file mode 100644 index 00000000..f6a25344 --- /dev/null +++ b/docs/public/api/client-api.json @@ -0,0 +1,1074 @@ +{ + "description": "The abap2UI5 client API: everything an app may call on `client`, read from z2ui5_if_client at the release this documentation names.", + "release": "1.143.0", + "source": "https://github.com/abap2UI5/abap2UI5/blob/1.143.0/src/02/z2ui5_if_client.intf.abap", + "documentation": "https://abap2ui5.github.io/docs/resources/api.html", + "methods": [ + { + "name": "view_destroy", + "group": "Main view", + "parameters": [] + }, + { + "name": "view_display", + "group": "Main view", + "doc": [ + "Display the MAIN view. A new main view is a new screen, so an open popup and popover go with it - re-open one in the same roundtrip if it is meant to survive ( the frontend builds MAIN first, then the popup )." + ], + "parameters": [ + { + "name": "val", + "type": "clike" + }, + { + "name": "switch_default_model_anno_uri", + "type": "clike", + "optional": true + }, + { + "name": "switch_default_model_path", + "type": "clike", + "optional": true + } + ] + }, + { + "name": "view_model_update", + "group": "Main view", + "doc": [ + "obsolete - does NOTHING. An event round-trip that changes bound data pushes the model AUTOMATICALLY: the framework compares the model state before and after main( ) and, when it differs, sends it to every open view slot (see z2ui5_cl_ui5_handler=>main_end). A handler can therefore no longer render stale by forgetting a call, and there is nothing left for this method to do. It stays in the interface so existing apps keep compiling - remove the calls at your leisure." + ], + "parameters": [] + }, + { + "name": "set_session_stateful", + "group": "Session and app state", + "parameters": [ + { + "name": "val", + "type": "abap_bool", + "default": "abap_true" + } + ] + }, + { + "name": "set_app_state_active", + "group": "Session and app state", + "parameters": [ + { + "name": "val", + "type": "abap_bool", + "default": "abap_true" + } + ] + }, + { + "name": "set_push_state", + "group": "Session and app state", + "parameters": [ + { + "name": "val", + "type": "string", + "optional": true + } + ] + }, + { + "name": "nest_view_display", + "group": "Nested views", + "parameters": [ + { + "name": "val", + "type": "clike" + }, + { + "name": "id", + "type": "clike" + }, + { + "name": "method_insert", + "type": "clike" + }, + { + "name": "method_destroy", + "type": "clike", + "optional": true + } + ] + }, + { + "name": "nest_view_destroy", + "group": "Nested views", + "parameters": [] + }, + { + "name": "nest_view_model_update", + "group": "Nested views", + "doc": [ + "obsolete - does NOTHING, see view_model_update. A nested view inherits the MAIN view's model anyway, and that model is pushed automatically" + ], + "parameters": [] + }, + { + "name": "nest2_view_display", + "group": "Nested views", + "parameters": [ + { + "name": "val", + "type": "clike" + }, + { + "name": "id", + "type": "clike" + }, + { + "name": "method_insert", + "type": "clike" + }, + { + "name": "method_destroy", + "type": "clike", + "optional": true + } + ] + }, + { + "name": "nest2_view_destroy", + "group": "Nested views", + "parameters": [] + }, + { + "name": "nest2_view_model_update", + "group": "Nested views", + "doc": [ + "obsolete - does NOTHING, see view_model_update. A nested view inherits the MAIN view's model anyway, and that model is pushed automatically" + ], + "parameters": [] + }, + { + "name": "popup_display", + "group": "Popups and popovers", + "parameters": [ + { + "name": "val", + "type": "clike" + } + ] + }, + { + "name": "popup_model_update", + "group": "Popups and popovers", + "doc": [ + "obsolete - does NOTHING, see view_model_update. The automatic push reaches the POPUP slot too, so an open popup refreshes on its own" + ], + "parameters": [] + }, + { + "name": "popup_destroy", + "group": "Popups and popovers", + "parameters": [] + }, + { + "name": "popover_model_update", + "group": "Popups and popovers", + "doc": [ + "obsolete - does NOTHING, see view_model_update. The automatic push reaches the POPOVER slot too, so an open popover refreshes on its own" + ], + "parameters": [] + }, + { + "name": "popover_display", + "group": "Popups and popovers", + "parameters": [ + { + "name": "xml", + "type": "clike" + }, + { + "name": "by_id", + "type": "clike" + } + ] + }, + { + "name": "popover_destroy", + "group": "Popups and popovers", + "parameters": [] + }, + { + "name": "get", + "group": "Reading the request", + "parameters": [], + "returns": "z2ui5_if_types=>ty_s_get" + }, + { + "name": "get_event", + "group": "Reading the request", + "doc": [ + "The name of the event that triggered this roundtrip - empty when no event is being handled (e.g. on the initial call). Shortcut for get( )-event, made for the dispatcher idiom CASE client->get_event( )." + ], + "parameters": [], + "returns": "string" + }, + { + "name": "get_event_arg", + "group": "Reading the request", + "parameters": [ + { + "name": "v", + "type": "i", + "default": "1" + } + ], + "returns": "string" + }, + { + "name": "get_app", + "group": "App navigation", + "parameters": [ + { + "name": "id", + "type": "clike", + "optional": true + } + ], + "returns": "REF TO z2ui5_if_app" + }, + { + "name": "_event_nav_app_leave", + "group": "App navigation", + "parameters": [], + "returns": "string" + }, + { + "name": "nav_app_leave", + "group": "App navigation", + "preferred": "app", + "parameters": [ + { + "name": "app", + "type": "REF TO z2ui5_if_app", + "optional": true + }, + { + "name": "event", + "type": "clike", + "optional": true + }, + { + "name": "r_data", + "type": "data", + "optional": true + } + ], + "returns": "string" + }, + { + "name": "nav_app_call", + "group": "App navigation", + "parameters": [ + { + "name": "app", + "type": "REF TO z2ui5_if_app" + } + ], + "returns": "string" + }, + { + "name": "message_box_display", + "group": "Messages", + "parameters": [ + { + "name": "text", + "type": "any" + }, + { + "name": "type", + "type": "clike", + "default": "`information`" + }, + { + "name": "title", + "type": "clike", + "optional": true + }, + { + "name": "styleclass", + "type": "clike", + "optional": true + }, + { + "name": "onclose", + "type": "clike", + "optional": true + }, + { + "name": "actions", + "type": "string_table", + "optional": true + }, + { + "name": "emphasizedaction", + "type": "clike", + "optional": true + }, + { + "name": "initialfocus", + "type": "clike", + "optional": true + }, + { + "name": "textdirection", + "type": "clike", + "optional": true + }, + { + "name": "icon", + "type": "clike", + "optional": true + }, + { + "name": "details", + "type": "clike", + "optional": true + }, + { + "name": "closeonnavigation", + "type": "abap_bool", + "default": "abap_true" + }, + { + "name": "dependenton", + "type": "clike", + "optional": true + }, + { + "name": "contentwidth", + "type": "clike", + "optional": true + } + ] + }, + { + "name": "message_toast_display", + "group": "Messages", + "parameters": [ + { + "name": "text", + "type": "clike" + }, + { + "name": "duration", + "type": "clike", + "optional": true + }, + { + "name": "width", + "type": "clike", + "optional": true + }, + { + "name": "my", + "type": "clike", + "optional": true + }, + { + "name": "at", + "type": "clike", + "optional": true + }, + { + "name": "of", + "type": "clike", + "optional": true + }, + { + "name": "offset", + "type": "clike", + "optional": true + }, + { + "name": "collision", + "type": "clike", + "optional": true + }, + { + "name": "onclose", + "type": "clike", + "default": "``" + }, + { + "name": "autoclose", + "type": "abap_bool", + "default": "abap_true" + }, + { + "name": "animationtimingfunction", + "type": "clike", + "optional": true + }, + { + "name": "animationduration", + "type": "clike", + "optional": true + }, + { + "name": "closeonbrowsernavigation", + "type": "abap_bool", + "default": "abap_true" + }, + { + "name": "class", + "type": "clike", + "optional": true + } + ] + }, + { + "name": "_event", + "group": "Events and frontend actions", + "doc": [ + "Register a backend event and return the handler expression for a view attribute (press = client->_event( `SAVE` )). s_ctrl carries the optional event flags: check_allow_multi_req sends the event while another roundtrip is still running, check_prevent_default cancels the control's built-in default for this event (oEvent.preventDefault(), e.g. a sap.tnt NavigationListItem press that must not select the item) before the roundtrip - the event is still sent, so the backend stays in charge of what happens instead. That flag is baked per WIRE at render time; prevent_default_expr is the same veto decided per FIRING - a client expression evaluated when the event fires, so one wire can protect one row/column and let the rest through (`${$parameters>/column}.getId().indexOf('COL_DATE') >= 0`). It wins over the flag when both are set." + ], + "preferred": "val", + "parameters": [ + { + "name": "val", + "type": "clike", + "optional": true + }, + { + "name": "t_arg", + "type": "string_table", + "optional": true + }, + { + "name": "s_ctrl", + "type": "z2ui5_if_types=>ty_s_event_control", + "optional": true + } + ], + "returns": "string" + }, + { + "name": "_event_client", + "group": "Events and frontend actions", + "doc": [ + "obsolete - use follow_up_action( ), which is the same call in the same position now. Since follow_up_action( ) has a RETURNING parameter, a call whose result is CONSUMED - the view-attribute form `v = client->follow_up_action( val = ... t_arg = ... )` - takes its IF result IS SUPPLIED branch straight to get_event_client( ), which is this method's entire body: the identical roundtrip-free wire, byte for byte. One method therefore both schedules a frontend action and wires one, and this one is a second name for half of it.", + "The one difference is follow_up_action( )'s leading CASE, which claims cs_event-set_nav_routing / set_push_state / set_app_state_active before that branch. Those three are backend-side navigation options rather than frontend handlers, so wiring one into a view attribute never dispatched anything here either.", + "It stays in the interface so existing apps keep compiling - rename the calls at your leisure." + ], + "parameters": [ + { + "name": "val", + "type": "clike" + }, + { + "name": "view", + "type": "clike", + "default": "cs_view-main" + }, + { + "name": "t_arg", + "type": "string_table", + "optional": true + } + ], + "returns": "string" + }, + { + "name": "_bind", + "group": "Data binding", + "parameters": [ + { + "name": "val", + "type": "data" + }, + { + "name": "path", + "type": "abap_bool", + "default": "abap_false" + }, + { + "name": "view", + "type": "clike", + "default": "cs_view-main", + "doc": "obsolete - inactive, not passed on internally" + }, + { + "name": "custom_mapper", + "type": "REF TO z2ui5_if_ajson_mapping", + "optional": true, + "doc": "obsolete - still evaluated, but do not use in new code. Both hand an app a reference to the bundled AJSON library (src/00/01), which is a MIRRORED copy of an external project, not a contract this framework owns: an app implementing z2ui5_if_ajson_mapping / _filter binds itself to whatever that mirror looks like today. Everything they were reached for has a declarative counterpart on this method now - omit_initial / omit_initial_paths drop initial fields, json splices a JSON node - and the ABAP side can shape the value before it is bound" + }, + { + "name": "custom_filter", + "type": "REF TO z2ui5_if_ajson_filter", + "optional": true + }, + { + "name": "tab", + "type": "data", + "optional": true + }, + { + "name": "tab_index", + "type": "i", + "optional": true + }, + { + "name": "switch_default_model", + "type": "abap_bool", + "default": "abap_false" + }, + { + "name": "omit_initial", + "type": "abap_bool", + "default": "abap_false", + "doc": "keep INITIAL fields out of the serialized model instead of sending them as `` / 0. An ABAP field is never absent - it is initial - so by default every field reaches the client as an explicit value, which overrides the UI5 property default the original view relies on (and an enum-typed property rejects the empty string outright). Set it when a bound template's rows fill different subsets of the same properties." + }, + { + "name": "omit_initial_paths", + "type": "string_table", + "optional": true, + "doc": "the same omission SCOPED to the listed fields (upper-cased column names, the last path segment). Use it when the blanket flag is too coarse: an abap_false that MUST reach the client is itself initial, so omit_initial would drop it and the control would fall back to its own default - list the numeric/enum columns instead and leave the booleans." + }, + { + "name": "json", + "type": "abap_bool", + "default": "abap_false", + "doc": "the bound string already CONTAINS JSON - splice it into the model as a JSON node instead of sending it as a quoted string. For a control property that must receive an OBJECT, which no typed ABAP value can be (a sap.ui.integration Card manifest: its keys `sap.app`/`sap.card` are not valid ABAP field names, and a string is read as a manifest URL). Outbound only - see z2ui5_cl_ui5_srv_model." + } + ], + "returns": "string" + }, + { + "name": "_bind_edit", + "group": "Data binding", + "doc": [ + "obsolete - alias of _bind with identical behaviour, please use _bind. custom_mapper_back / custom_filter_back are still accepted for source compatibility but are no longer evaluated." + ], + "parameters": [ + { + "name": "val", + "type": "data" + }, + { + "name": "path", + "type": "abap_bool", + "default": "abap_false" + }, + { + "name": "view", + "type": "clike", + "default": "cs_view-main", + "doc": "obsolete - inactive, not passed on internally" + }, + { + "name": "custom_mapper", + "type": "REF TO z2ui5_if_ajson_mapping", + "optional": true + }, + { + "name": "custom_mapper_back", + "type": "REF TO z2ui5_if_ajson_mapping", + "optional": true + }, + { + "name": "custom_filter", + "type": "REF TO z2ui5_if_ajson_filter", + "optional": true + }, + { + "name": "custom_filter_back", + "type": "REF TO z2ui5_if_ajson_filter", + "optional": true + }, + { + "name": "tab", + "type": "data", + "optional": true + }, + { + "name": "tab_index", + "type": "i", + "optional": true + }, + { + "name": "switch_default_model", + "type": "abap_bool", + "default": "abap_false" + } + ], + "returns": "string" + }, + { + "name": "follow_up_action", + "group": "Events and frontend actions", + "doc": [ + "Schedule a frontend action to run after the backend response is processed. Two ways to call it: pass a frontend event as val (e.g. cs_event-set_title) with its arguments in t_arg and the framework builds the event call; or pass a raw JavaScript expression as val (without t_arg) to run it as-is. The control/binding calls are frontend events too; their t_arg is positional (an empty argument between filled ones keeps its slot as ``): cs_event-control_by_id - call a method on a control resolved by id: t_arg = id, method, params. Any public control method works unless it is on the frontend denylist (methods that would break framework invariants). The named per-aggregation mutators are on the allowed side of that line - addItem, removeItem, removeAllItems, destroyContent - and only the GENERIC reflection variants that take the member name as an argument are denied (addAggregation, removeAllAggregation, setAssociation, ...). The view is passed as the separate view parameter (default cs_view-main resolves the id across all open views; pass cs_view-popup/popover/... to scope the lookup to that view). Two entries are NOT UI5 methods but frontend capabilities in method form: `css` sets ONE whitelisted CSS declaration on the control's own DOM node (t_arg = id, `css`, property, value) - for a value the control has no property for at all, e.g. the width of a sap.m.Page; prefer a bound property wherever one exists. `toggleBy` opens/closes a popup anchored to a control (t_arg = id, `toggleBy`, anchor id). An association setter (setSelectedSection, setSelectedItem) clears the association when its argument is EMPTY. Wherever an argument takes a CONTROL ID, it also takes an aggregation ITEM, addressed positionally as `//` (`carousel/pages/2`, 0-based). A control cloned from an aggregation template has no id the backend can spell - UI5 mints it from the template id, the parent id and the index, and the parent id carries the view prefix assigned at runtime - so this is the only way to reach one. It is the equivalent of the UI5 controller idiom `oCarousel.setActivePage( oCarousel.getPages()[ i ] )`. A plain id (no slashes) resolves exactly as before. cs_event-control_global - call a whitelisted method on a global object (MESSAGE_TOAST, MESSAGE_BOX, BUSY_INDICATOR, THEMING, POPUP, INVISIBLE_MESSAGE, FORMATTING): t_arg = object, method, params. POPUP-setWithinArea confines every popup to the control whose id is passed (sap.ui.core.Popup.setWithinArea, needs UI5 >= 1.89) instead of to the window; an EMPTY argument releases the restriction again. INVISIBLE_MESSAGE-announce reads a text out to a screen reader without rendering it (sap.ui.core.InvisibleMessage, needs UI5 >= 1.78): t_arg = text, mode (Polite, default, or Assertive). It is a singleton, so there is no control id - this is the only way to announce a change the backend made. FORMATTING-setCustomCurrencies registers currency codes the standard sap.ui.model.type.Currency does not know, or overrides their digit count (sap.ui.core.Formatting, needs UI5 >= 1.120): t_arg = JSON object, e.g. {\"BGN4\":{\"digits\":4}}. It REPLACES the whole registration - addCustomCurrency ADDS a single code to it instead (t_arg = code, JSON object). Reaching for the wrong one is silent: an app that registers currencies as it loads more data and calls setCustomCurrencies drops what it registered before, and the symptom is a wrong digit count in a table, never an error. cs_event-smart_variant_init - run the initialise( ) handshake sap.ui.comp variant management needs (a controller would call oSmartVariantManagement.initialise( fnCallback, oPersonalizableControl )). Without it the control keeps no personalizable control, saving a view fails inside sap.ui.fl and stored variants are never loaded: t_arg = SmartVariantManagement id, personalizable control id (optional, default: the first control that registered itself). The action waits for that registration, which the smart controls do once their OData metadata has loaded. cs_event-filter_bar_variant_init - wire a classic sap.ui.comp.filterbar.FilterBar to a SmartVariantManagement: t_arg = SmartVariantManagement id, FilterBar id. A SmartFilterBar knows its own fields and registers itself (see smart_variant_init above); a classic FilterBar does not, so a list report normally hand-writes the same controller boilerplate - registerFetchData / registerApplyData / registerGetFiltersWithValues, addPersonalizableControl( ) with a PersonalizableInfo, and a change handler per filter field that marks the variant as modified. This action does all of it, so saving, selecting and restoring a variant works without a single line of JavaScript. The restored values reach the backend through the binding of the filter fields, no extra roundtrip needed. cs_event-keyboard_shortcut - bind a key combination to a named backend event, the declarative equivalent of a sap.ui.core.CommandExecution shortcut: t_arg = combination, event name. The combination is spelled like the UI5 one (`Ctrl+S`, `Ctrl+Shift+D`, `F2`; ctrl/shift/alt/meta in any order, cmd/command/option/control accepted as aliases). Pressing it fires the event exactly like a button press and suppresses the browser's own default for the combination. Registering the same combination again rebinds it; an empty event name removes it. The registrations belong to the running app and are dropped when another app takes over. An optional THIRD t_arg SCOPES the shortcut: the scoped registration wins while its scope is OPEN and the unscoped one applies otherwise, which is how a UI5 CommandExecution in a Popover's dependents shadows the page-level one for the same command. A scope is either a view slot (cs_view-popover/popup/nested/nested2/main) or the ID OF A CONTROL that can be open or closed - a Popover/Dialog declared in the view and opened with control_by_id openBy, which never enters a framework slot. A control scope beats a slot scope (it is the more specific statement), then the innermost open slot wins. An empty event name removes the registration of THAT scope only. cs_event-binding_call - apply a declarative filter/sorter to an aggregation binding, the client-side equivalent of the UI5 controller pattern getBinding('items').filter(...); the model data stays untouched: t_arg = id, aggregation, method, params. method `filter`: params = path, operator, value1, value2 (empty values clear the filter); method `sort`: params = path, descending, group (abap_bool as `X`/``). Each of these events also works roundtrip-free when WIRED IN THE VIEW: write the same call where its result is consumed (`)->a( n = `press` v = client->follow_up_action( val = ... t_arg = ... ) )`) and the action runs in the browser without a server call. That is what the obsolete _event_client( ) did, and the only thing it did." + ], + "parameters": [ + { + "name": "val", + "type": "string" + }, + { + "name": "view", + "type": "clike", + "default": "cs_view-main" + }, + { + "name": "t_arg", + "type": "string_table", + "optional": true + } + ], + "returns": "string" + }, + { + "name": "check_on_event", + "group": "App lifecycle", + "parameters": [ + { + "name": "val", + "type": "clike", + "optional": true + } + ], + "returns": "abap_bool" + }, + { + "name": "check_on_init", + "group": "App lifecycle", + "parameters": [], + "returns": "abap_bool" + }, + { + "name": "check_app_prev_stack", + "group": "App navigation", + "parameters": [], + "returns": "abap_bool" + }, + { + "name": "check_on_navigated", + "group": "App lifecycle", + "parameters": [], + "returns": "abap_bool" + }, + { + "name": "get_app_prev", + "group": "App navigation", + "parameters": [], + "returns": "REF TO z2ui5_if_app" + } + ], + "constants": [ + { + "name": "cs_device", + "doc": [], + "members": [ + { + "name": "system", + "doc": [], + "members": [ + { + "name": "phone", + "type": "string", + "value": "phone" + }, + { + "name": "tablet", + "type": "string", + "value": "tablet" + }, + { + "name": "desktop", + "type": "string", + "value": "desktop" + }, + { + "name": "combi", + "type": "string", + "value": "combi" + } + ] + }, + { + "name": "browser", + "doc": [], + "members": [ + { + "name": "chrome", + "type": "string", + "value": "cr" + }, + { + "name": "firefox", + "type": "string", + "value": "ff" + }, + { + "name": "safari", + "type": "string", + "value": "sf" + }, + { + "name": "edge", + "type": "string", + "value": "ed" + } + ] + }, + { + "name": "os", + "doc": [], + "members": [ + { + "name": "windows", + "type": "string", + "value": "win" + }, + { + "name": "macintosh", + "type": "string", + "value": "mac" + }, + { + "name": "linux", + "type": "string", + "value": "linux" + }, + { + "name": "ios", + "type": "string", + "value": "ios" + }, + { + "name": "android", + "type": "string", + "value": "android" + } + ] + }, + { + "name": "orientation", + "doc": [], + "members": [ + { + "name": "portrait", + "type": "string", + "value": "portrait" + }, + { + "name": "landscape", + "type": "string", + "value": "landscape" + } + ] + } + ] + }, + { + "name": "cs_event", + "doc": [], + "members": [ + { + "name": "popup_close", + "type": "string", + "value": "POPUP_CLOSE" + }, + { + "name": "popover_close", + "type": "string", + "value": "POPOVER_CLOSE" + }, + { + "name": "set_size_limit", + "type": "string", + "value": "SET_SIZE_LIMIT" + }, + { + "name": "set_odata_model", + "type": "string", + "value": "SET_ODATA_MODEL" + }, + { + "name": "cross_app_nav_to_ext", + "type": "string", + "value": "CROSS_APP_NAV_TO_EXT" + }, + { + "name": "cross_app_nav_to_prev_app", + "type": "string", + "value": "CROSS_APP_NAV_TO_PREV_APP" + }, + { + "name": "clipboard_copy", + "type": "string", + "value": "CLIPBOARD_COPY" + }, + { + "name": "set_title", + "type": "string", + "value": "SET_TITLE" + }, + { + "name": "set_favicon", + "type": "string", + "value": "SET_FAVICON" + }, + { + "name": "set_focus", + "type": "string", + "value": "SET_FOCUS" + }, + { + "name": "scroll_to", + "type": "string", + "value": "SCROLL_TO" + }, + { + "name": "scroll_into_view", + "type": "string", + "value": "SCROLL_INTO_VIEW" + }, + { + "name": "start_timer", + "type": "string", + "value": "START_TIMER" + }, + { + "name": "system_logout", + "type": "string", + "value": "SYSTEM_LOGOUT" + }, + { + "name": "keyboard_set_mode", + "type": "string", + "value": "KEYBOARD_SET_MODE" + }, + { + "name": "keyboard_shortcut", + "type": "string", + "value": "KEYBOARD_SHORTCUT" + }, + { + "name": "open_new_tab", + "type": "string", + "value": "OPEN_NEW_TAB" + }, + { + "name": "location_reload", + "type": "string", + "value": "LOCATION_RELOAD" + }, + { + "name": "set_title_launchpad", + "type": "string", + "value": "SET_TITLE_LAUNCHPAD" + }, + { + "name": "download_b64_file", + "type": "string", + "value": "DOWNLOAD_B64_FILE" + }, + { + "name": "urlhelper", + "type": "string", + "value": "URLHELPER" + }, + { + "name": "clipboard_app_state", + "type": "string", + "value": "CLIPBOARD_APP_STATE" + }, + { + "name": "store_data", + "type": "string", + "value": "STORE_DATA" + }, + { + "name": "play_audio", + "type": "string", + "value": "PLAY_AUDIO" + }, + { + "name": "smart_variant_init", + "type": "string", + "value": "SMART_VARIANT_INIT" + }, + { + "name": "filter_bar_variant_init", + "type": "string", + "value": "FILTER_BAR_VARIANT_INIT" + }, + { + "name": "control_by_id", + "type": "string", + "value": "CONTROL_BY_ID", + "label": "Control" + }, + { + "name": "control_global", + "type": "string", + "value": "CONTROL_GLOBAL", + "label": "Control" + }, + { + "name": "binding_call", + "type": "string", + "value": "BINDING_CALL", + "label": "Control" + }, + { + "name": "bind_element", + "type": "string", + "value": "BIND_ELEMENT", + "label": "Control" + }, + { + "name": "set_app_state_active", + "type": "string", + "value": "SET_APP_STATE_ACTIVE", + "label": "experimental" + }, + { + "name": "set_push_state", + "type": "string", + "value": "SET_PUSH_STATE", + "label": "experimental" + }, + { + "name": "set_nav_routing", + "type": "string", + "value": "SET_NAV_ROUTING", + "label": "experimental" + }, + { + "name": "image_editor_popup_close", + "type": "string", + "value": "IMAGE_EDITOR_POPUP_CLOSE", + "label": "obsolet" + }, + { + "name": "nav_container_to", + "type": "string", + "value": "NAV_CONTAINER_TO", + "label": "obsolet" + }, + { + "name": "nest_nav_container_to", + "type": "string", + "value": "NEST_NAV_CONTAINER_TO", + "label": "obsolet" + }, + { + "name": "nest2_nav_container_to", + "type": "string", + "value": "NEST2_NAV_CONTAINER_TO", + "label": "obsolet" + }, + { + "name": "popup_nav_container_to", + "type": "string", + "value": "POPUP_NAV_CONTAINER_TO", + "label": "obsolet" + }, + { + "name": "popover_nav_container_to", + "type": "string", + "value": "POPOVER_NAV_CONTAINER_TO", + "label": "obsolet" + }, + { + "name": "z2ui5", + "type": "string", + "value": "Z2UI5", + "label": "obsolet" + }, + { + "name": "wizard_set_next_step", + "type": "string", + "value": "WIZARD_SET_NEXT_STEP", + "label": "obsolet" + } + ] + }, + { + "name": "cs_view", + "doc": [], + "members": [ + { + "name": "main", + "type": "string", + "value": "MAIN" + }, + { + "name": "nested", + "type": "string", + "value": "NEST" + }, + { + "name": "nested2", + "type": "string", + "value": "NEST2" + }, + { + "name": "popup", + "type": "string", + "value": "POPUP" + }, + { + "name": "popover", + "type": "string", + "value": "POPOVER" + } + ] + }, + { + "name": "cs_nav_mode", + "doc": [ + "Hash-based app routing modes (see set_nav_routing). The mode decides how much of the running app the URL hash carries, and therefore what the browser Back/Forward buttons (and a reload / bookmark) restore: default - no routing: the hash is left untouched, exactly as before this feature. Back/Forward leave the abap2UI5 page (framework default). fresh - route '#/app/' (class only): Back/Forward/reload/bookmark start the app FRESH (a clean instance, no preserved input). keep - route '#/app//' (class + server draft): the exact preserved state is restored (all user input), falling back to a fresh start once the draft has expired." + ], + "members": [ + { + "name": "default", + "type": "string", + "value": "DEFAULT" + }, + { + "name": "fresh", + "type": "string", + "value": "FRESH" + }, + { + "name": "keep", + "type": "string", + "value": "KEEP" + } + ] + } + ], + "types": [ + { + "name": "ty_s_name_value", + "doc": [], + "members": [ + { + "name": "n", + "type": "string" + }, + { + "name": "v", + "type": "string" + } + ] + }, + { + "name": "ty_t_name_value", + "doc": [], + "definition": "STANDARD TABLE OF ty_s_name_value WITH EMPTY KEY" + } + ] +} diff --git a/docs/resources/api.md b/docs/resources/api.md new file mode 100644 index 00000000..adc1486b --- /dev/null +++ b/docs/resources/api.md @@ -0,0 +1,434 @@ +--- +outline: 2 +description: The complete abap2UI5 client API — every method, parameter and constant of z2ui5_if_client at the current release, generated from the source. +--- + +# Client API + +Everything an app calls at runtime comes through one interface: +[`z2ui5_if_client`](https://github.com/abap2UI5/abap2UI5/blob/main/src/02/z2ui5_if_client.intf.abap), +handed to the app as `client` on every roundtrip. This page **is** that +interface, generated from its source at the release named in the nav bar: +every method with its parameters, types and defaults, every `cs_*` constant +structure, and the documentation written in the source itself. What is printed +here is what your install has — not what `main` already promises. + +The interface is deliberately small and deliberately stable: obsolete methods +stay in it so existing apps keep compiling, and they are marked as such below, +each one naming its replacement. How the pieces fit together — the lifecycle, +binding, events — is what the [Tutorial](/tutorials/overview) and the +[Cookbook](/cookbook/view/definition) explain; this page is the contract they +explain it against. + + + +Generated from [`z2ui5_if_client`](https://github.com/abap2UI5/abap2UI5/blob/1.143.0/src/02/z2ui5_if_client.intf.abap) +at release **1.143.0** — 37 methods, 4 constant +structures. The same reference as one JSON document: [client-api.json](https://abap2ui5.github.io/docs/api/client-api.json). + +## App lifecycle + +### `check_on_event` + +| Parameter | Type | Default | Description | +|---|---|---|---| +| `val` | `clike` | *optional* | | + +Returns `abap_bool`. + +### `check_on_init` + +Returns `abap_bool`. + +### `check_on_navigated` + +Returns `abap_bool`. + +## Main view + +### `view_destroy` + +### `view_display` + +Display the MAIN view. A new main view is a new screen, so an open popup and popover go with it - re-open one in the same roundtrip if it is meant to survive ( the frontend builds MAIN first, then the popup ). + +| Parameter | Type | Default | Description | +|---|---|---|---| +| `val` | `clike` | | | +| `switch_default_model_anno_uri` | `clike` | *optional* | | +| `switch_default_model_path` | `clike` | *optional* | | + +### `view_model_update` + +obsolete - does NOTHING. An event round-trip that changes bound data pushes the model AUTOMATICALLY: the framework compares the model state before and after main( ) and, when it differs, sends it to every open view slot (see z2ui5_cl_ui5_handler=>main_end). A handler can therefore no longer render stale by forgetting a call, and there is nothing left for this method to do. It stays in the interface so existing apps keep compiling - remove the calls at your leisure. + +## Nested views + +### `nest_view_display` + +| Parameter | Type | Default | Description | +|---|---|---|---| +| `val` | `clike` | | | +| `id` | `clike` | | | +| `method_insert` | `clike` | | | +| `method_destroy` | `clike` | *optional* | | + +### `nest_view_destroy` + +### `nest_view_model_update` + +obsolete - does NOTHING, see view_model_update. A nested view inherits the MAIN view's model anyway, and that model is pushed automatically + +### `nest2_view_display` + +| Parameter | Type | Default | Description | +|---|---|---|---| +| `val` | `clike` | | | +| `id` | `clike` | | | +| `method_insert` | `clike` | | | +| `method_destroy` | `clike` | *optional* | | + +### `nest2_view_destroy` + +### `nest2_view_model_update` + +obsolete - does NOTHING, see view_model_update. A nested view inherits the MAIN view's model anyway, and that model is pushed automatically + +## Popups and popovers + +### `popup_display` + +| Parameter | Type | Default | Description | +|---|---|---|---| +| `val` | `clike` | | | + +### `popup_model_update` + +obsolete - does NOTHING, see view_model_update. The automatic push reaches the POPUP slot too, so an open popup refreshes on its own + +### `popup_destroy` + +### `popover_model_update` + +obsolete - does NOTHING, see view_model_update. The automatic push reaches the POPOVER slot too, so an open popover refreshes on its own + +### `popover_display` + +| Parameter | Type | Default | Description | +|---|---|---|---| +| `xml` | `clike` | | | +| `by_id` | `clike` | | | + +### `popover_destroy` + +## Data binding + +### `_bind` + +| Parameter | Type | Default | Description | +|---|---|---|---| +| `val` | `data` | | | +| `path` | `abap_bool` | `abap_false` | | +| `view` | `clike` | `cs_view-main` | obsolete - inactive, not passed on internally | +| `custom_mapper` | `REF TO z2ui5_if_ajson_mapping` | *optional* | obsolete - still evaluated, but do not use in new code. Both hand an app a reference to the bundled AJSON library (src/00/01), which is a MIRRORED copy of an external project, not a contract this framework owns: an app implementing z2ui5_if_ajson_mapping / _filter binds itself to whatever that mirror looks like today. Everything they were reached for has a declarative counterpart on this method now - omit_initial / omit_initial_paths drop initial fields, json splices a JSON node - and the ABAP side can shape the value before it is bound | +| `custom_filter` | `REF TO z2ui5_if_ajson_filter` | *optional* | | +| `tab` | `data` | *optional* | | +| `tab_index` | `i` | *optional* | | +| `switch_default_model` | `abap_bool` | `abap_false` | | +| `omit_initial` | `abap_bool` | `abap_false` | keep INITIAL fields out of the serialized model instead of sending them as `` / 0. An ABAP field is never absent - it is initial - so by default every field reaches the client as an explicit value, which overrides the UI5 property default the original view relies on (and an enum-typed property rejects the empty string outright). Set it when a bound template's rows fill different subsets of the same properties. | +| `omit_initial_paths` | `string_table` | *optional* | the same omission SCOPED to the listed fields (upper-cased column names, the last path segment). Use it when the blanket flag is too coarse: an abap_false that MUST reach the client is itself initial, so omit_initial would drop it and the control would fall back to its own default - list the numeric/enum columns instead and leave the booleans. | +| `json` | `abap_bool` | `abap_false` | the bound string already CONTAINS JSON - splice it into the model as a JSON node instead of sending it as a quoted string. For a control property that must receive an OBJECT, which no typed ABAP value can be (a sap.ui.integration Card manifest: its keys `sap.app`/`sap.card` are not valid ABAP field names, and a string is read as a manifest URL). Outbound only - see z2ui5_cl_ui5_srv_model. | + +Returns `string`. + +### `_bind_edit` + +obsolete - alias of _bind with identical behaviour, please use _bind. custom_mapper_back / custom_filter_back are still accepted for source compatibility but are no longer evaluated. + +| Parameter | Type | Default | Description | +|---|---|---|---| +| `val` | `data` | | | +| `path` | `abap_bool` | `abap_false` | | +| `view` | `clike` | `cs_view-main` | obsolete - inactive, not passed on internally | +| `custom_mapper` | `REF TO z2ui5_if_ajson_mapping` | *optional* | | +| `custom_mapper_back` | `REF TO z2ui5_if_ajson_mapping` | *optional* | | +| `custom_filter` | `REF TO z2ui5_if_ajson_filter` | *optional* | | +| `custom_filter_back` | `REF TO z2ui5_if_ajson_filter` | *optional* | | +| `tab` | `data` | *optional* | | +| `tab_index` | `i` | *optional* | | +| `switch_default_model` | `abap_bool` | `abap_false` | | + +Returns `string`. + +## Events and frontend actions + +### `_event` + +Register a backend event and return the handler expression for a view attribute (press = client->_event( `SAVE` )). s_ctrl carries the optional event flags: check_allow_multi_req sends the event while another roundtrip is still running, check_prevent_default cancels the control's built-in default for this event (oEvent.preventDefault(), e.g. a sap.tnt NavigationListItem press that must not select the item) before the roundtrip - the event is still sent, so the backend stays in charge of what happens instead. That flag is baked per WIRE at render time; prevent_default_expr is the same veto decided per FIRING - a client expression evaluated when the event fires, so one wire can protect one row/column and let the rest through (`${$parameters>/column}.getId().indexOf('COL_DATE') >= 0`). It wins over the flag when both are set. + +| Parameter | Type | Default | Description | +|---|---|---|---| +| `val` | `clike` | *optional* | | +| `t_arg` | `string_table` | *optional* | | +| `s_ctrl` | `z2ui5_if_types=>ty_s_event_control` | *optional* | | + +Preferred parameter: `val` — a positional call passes it. + +Returns `string`. + +### `_event_client` + +obsolete - use follow_up_action( ), which is the same call in the same position now. Since follow_up_action( ) has a RETURNING parameter, a call whose result is CONSUMED - the view-attribute form `v = client->follow_up_action( val = ... t_arg = ... )` - takes its IF result IS SUPPLIED branch straight to get_event_client( ), which is this method's entire body: the identical roundtrip-free wire, byte for byte. One method therefore both schedules a frontend action and wires one, and this one is a second name for half of it. + +The one difference is follow_up_action( )'s leading CASE, which claims cs_event-set_nav_routing / set_push_state / set_app_state_active before that branch. Those three are backend-side navigation options rather than frontend handlers, so wiring one into a view attribute never dispatched anything here either. + +It stays in the interface so existing apps keep compiling - rename the calls at your leisure. + +| Parameter | Type | Default | Description | +|---|---|---|---| +| `val` | `clike` | | | +| `view` | `clike` | `cs_view-main` | | +| `t_arg` | `string_table` | *optional* | | + +Returns `string`. + +### `follow_up_action` + +Schedule a frontend action to run after the backend response is processed. Two ways to call it: pass a frontend event as val (e.g. cs_event-set_title) with its arguments in t_arg and the framework builds the event call; or pass a raw JavaScript expression as val (without t_arg) to run it as-is. The control/binding calls are frontend events too; their t_arg is positional (an empty argument between filled ones keeps its slot as ``): cs_event-control_by_id - call a method on a control resolved by id: t_arg = id, method, params. Any public control method works unless it is on the frontend denylist (methods that would break framework invariants). The named per-aggregation mutators are on the allowed side of that line - addItem, removeItem, removeAllItems, destroyContent - and only the GENERIC reflection variants that take the member name as an argument are denied (addAggregation, removeAllAggregation, setAssociation, ...). The view is passed as the separate view parameter (default cs_view-main resolves the id across all open views; pass cs_view-popup/popover/... to scope the lookup to that view). Two entries are NOT UI5 methods but frontend capabilities in method form: `css` sets ONE whitelisted CSS declaration on the control's own DOM node (t_arg = id, `css`, property, value) - for a value the control has no property for at all, e.g. the width of a sap.m.Page; prefer a bound property wherever one exists. `toggleBy` opens/closes a popup anchored to a control (t_arg = id, `toggleBy`, anchor id). An association setter (setSelectedSection, setSelectedItem) clears the association when its argument is EMPTY. Wherever an argument takes a CONTROL ID, it also takes an aggregation ITEM, addressed positionally as `//` (`carousel/pages/2`, 0-based). A control cloned from an aggregation template has no id the backend can spell - UI5 mints it from the template id, the parent id and the index, and the parent id carries the view prefix assigned at runtime - so this is the only way to reach one. It is the equivalent of the UI5 controller idiom `oCarousel.setActivePage( oCarousel.getPages()[ i ] )`. A plain id (no slashes) resolves exactly as before. cs_event-control_global - call a whitelisted method on a global object (MESSAGE_TOAST, MESSAGE_BOX, BUSY_INDICATOR, THEMING, POPUP, INVISIBLE_MESSAGE, FORMATTING): t_arg = object, method, params. POPUP-setWithinArea confines every popup to the control whose id is passed (sap.ui.core.Popup.setWithinArea, needs UI5 >= 1.89) instead of to the window; an EMPTY argument releases the restriction again. INVISIBLE_MESSAGE-announce reads a text out to a screen reader without rendering it (sap.ui.core.InvisibleMessage, needs UI5 >= 1.78): t_arg = text, mode (Polite, default, or Assertive). It is a singleton, so there is no control id - this is the only way to announce a change the backend made. FORMATTING-setCustomCurrencies registers currency codes the standard sap.ui.model.type.Currency does not know, or overrides their digit count (sap.ui.core.Formatting, needs UI5 >= 1.120): t_arg = JSON object, e.g. {"BGN4":{"digits":4}}. It REPLACES the whole registration - addCustomCurrency ADDS a single code to it instead (t_arg = code, JSON object). Reaching for the wrong one is silent: an app that registers currencies as it loads more data and calls setCustomCurrencies drops what it registered before, and the symptom is a wrong digit count in a table, never an error. cs_event-smart_variant_init - run the initialise( ) handshake sap.ui.comp variant management needs (a controller would call oSmartVariantManagement.initialise( fnCallback, oPersonalizableControl )). Without it the control keeps no personalizable control, saving a view fails inside sap.ui.fl and stored variants are never loaded: t_arg = SmartVariantManagement id, personalizable control id (optional, default: the first control that registered itself). The action waits for that registration, which the smart controls do once their OData metadata has loaded. cs_event-filter_bar_variant_init - wire a classic sap.ui.comp.filterbar.FilterBar to a SmartVariantManagement: t_arg = SmartVariantManagement id, FilterBar id. A SmartFilterBar knows its own fields and registers itself (see smart_variant_init above); a classic FilterBar does not, so a list report normally hand-writes the same controller boilerplate - registerFetchData / registerApplyData / registerGetFiltersWithValues, addPersonalizableControl( ) with a PersonalizableInfo, and a change handler per filter field that marks the variant as modified. This action does all of it, so saving, selecting and restoring a variant works without a single line of JavaScript. The restored values reach the backend through the binding of the filter fields, no extra roundtrip needed. cs_event-keyboard_shortcut - bind a key combination to a named backend event, the declarative equivalent of a sap.ui.core.CommandExecution shortcut: t_arg = combination, event name. The combination is spelled like the UI5 one (`Ctrl+S`, `Ctrl+Shift+D`, `F2`; ctrl/shift/alt/meta in any order, cmd/command/option/control accepted as aliases). Pressing it fires the event exactly like a button press and suppresses the browser's own default for the combination. Registering the same combination again rebinds it; an empty event name removes it. The registrations belong to the running app and are dropped when another app takes over. An optional THIRD t_arg SCOPES the shortcut: the scoped registration wins while its scope is OPEN and the unscoped one applies otherwise, which is how a UI5 CommandExecution in a Popover's dependents shadows the page-level one for the same command. A scope is either a view slot (cs_view-popover/popup/nested/nested2/main) or the ID OF A CONTROL that can be open or closed - a Popover/Dialog declared in the view and opened with control_by_id openBy, which never enters a framework slot. A control scope beats a slot scope (it is the more specific statement), then the innermost open slot wins. An empty event name removes the registration of THAT scope only. cs_event-binding_call - apply a declarative filter/sorter to an aggregation binding, the client-side equivalent of the UI5 controller pattern getBinding('items').filter(...); the model data stays untouched: t_arg = id, aggregation, method, params. method `filter`: params = path, operator, value1, value2 (empty values clear the filter); method `sort`: params = path, descending, group (abap_bool as `X`/``). Each of these events also works roundtrip-free when WIRED IN THE VIEW: write the same call where its result is consumed (`)->a( n = `press` v = client->follow_up_action( val = ... t_arg = ... ) )`) and the action runs in the browser without a server call. That is what the obsolete _event_client( ) did, and the only thing it did. + +| Parameter | Type | Default | Description | +|---|---|---|---| +| `val` | `string` | | | +| `view` | `clike` | `cs_view-main` | | +| `t_arg` | `string_table` | *optional* | | + +Returns `string`. + +## Reading the request + +### `get` + +Returns `z2ui5_if_types=>ty_s_get`. + +### `get_event` + +The name of the event that triggered this roundtrip - empty when no event is being handled (e.g. on the initial call). Shortcut for get( )-event, made for the dispatcher idiom CASE client->get_event( ). + +Returns `string`. + +### `get_event_arg` + +| Parameter | Type | Default | Description | +|---|---|---|---| +| `v` | `i` | `1` | | + +Returns `string`. + +## Messages + +### `message_box_display` + +| Parameter | Type | Default | Description | +|---|---|---|---| +| `text` | `any` | | | +| `type` | `clike` | `information` | | +| `title` | `clike` | *optional* | | +| `styleclass` | `clike` | *optional* | | +| `onclose` | `clike` | *optional* | | +| `actions` | `string_table` | *optional* | | +| `emphasizedaction` | `clike` | *optional* | | +| `initialfocus` | `clike` | *optional* | | +| `textdirection` | `clike` | *optional* | | +| `icon` | `clike` | *optional* | | +| `details` | `clike` | *optional* | | +| `closeonnavigation` | `abap_bool` | `abap_true` | | +| `dependenton` | `clike` | *optional* | | +| `contentwidth` | `clike` | *optional* | | + +### `message_toast_display` + +| Parameter | Type | Default | Description | +|---|---|---|---| +| `text` | `clike` | | | +| `duration` | `clike` | *optional* | | +| `width` | `clike` | *optional* | | +| `my` | `clike` | *optional* | | +| `at` | `clike` | *optional* | | +| `of` | `clike` | *optional* | | +| `offset` | `clike` | *optional* | | +| `collision` | `clike` | *optional* | | +| `onclose` | `clike` | *(empty)* | | +| `autoclose` | `abap_bool` | `abap_true` | | +| `animationtimingfunction` | `clike` | *optional* | | +| `animationduration` | `clike` | *optional* | | +| `closeonbrowsernavigation` | `abap_bool` | `abap_true` | | +| `class` | `clike` | *optional* | | + +## App navigation + +### `get_app` + +| Parameter | Type | Default | Description | +|---|---|---|---| +| `id` | `clike` | *optional* | | + +Returns `REF TO z2ui5_if_app`. + +### `_event_nav_app_leave` + +Returns `string`. + +### `nav_app_leave` + +| Parameter | Type | Default | Description | +|---|---|---|---| +| `app` | `REF TO z2ui5_if_app` | *optional* | | +| `event` | `clike` | *optional* | | +| `r_data` | `data` | *optional* | | + +Preferred parameter: `app` — a positional call passes it. + +Returns `string`. + +### `nav_app_call` + +| Parameter | Type | Default | Description | +|---|---|---|---| +| `app` | `REF TO z2ui5_if_app` | | | + +Returns `string`. + +### `check_app_prev_stack` + +Returns `abap_bool`. + +### `get_app_prev` + +Returns `REF TO z2ui5_if_app`. + +## Session and app state + +### `set_session_stateful` + +| Parameter | Type | Default | Description | +|---|---|---|---| +| `val` | `abap_bool` | `abap_true` | | + +### `set_app_state_active` + +| Parameter | Type | Default | Description | +|---|---|---|---| +| `val` | `abap_bool` | `abap_true` | | + +### `set_push_state` + +| Parameter | Type | Default | Description | +|---|---|---|---| +| `val` | `string` | *optional* | | + +## Constants + +The values an app compares against or passes on: `cs_event` names every +frontend action, `cs_view` the view slots, `cs_device` what `get( )` reports +about the device, `cs_nav_mode` the routing modes. + +### `cs_device` + +| Constant | Value | +|---|---| +| `cs_device-system-phone` | `phone` | +| `cs_device-system-tablet` | `tablet` | +| `cs_device-system-desktop` | `desktop` | +| `cs_device-system-combi` | `combi` | +| `cs_device-browser-chrome` | `cr` | +| `cs_device-browser-firefox` | `ff` | +| `cs_device-browser-safari` | `sf` | +| `cs_device-browser-edge` | `ed` | +| `cs_device-os-windows` | `win` | +| `cs_device-os-macintosh` | `mac` | +| `cs_device-os-linux` | `linux` | +| `cs_device-os-ios` | `ios` | +| `cs_device-os-android` | `android` | +| `cs_device-orientation-portrait` | `portrait` | +| `cs_device-orientation-landscape` | `landscape` | + +### `cs_event` + +| Constant | Value | | +|---|---|---| +| `cs_event-popup_close` | `POPUP_CLOSE` | | +| `cs_event-popover_close` | `POPOVER_CLOSE` | | +| `cs_event-set_size_limit` | `SET_SIZE_LIMIT` | | +| `cs_event-set_odata_model` | `SET_ODATA_MODEL` | | +| `cs_event-cross_app_nav_to_ext` | `CROSS_APP_NAV_TO_EXT` | | +| `cs_event-cross_app_nav_to_prev_app` | `CROSS_APP_NAV_TO_PREV_APP` | | +| `cs_event-clipboard_copy` | `CLIPBOARD_COPY` | | +| `cs_event-set_title` | `SET_TITLE` | | +| `cs_event-set_favicon` | `SET_FAVICON` | | +| `cs_event-set_focus` | `SET_FOCUS` | | +| `cs_event-scroll_to` | `SCROLL_TO` | | +| `cs_event-scroll_into_view` | `SCROLL_INTO_VIEW` | | +| `cs_event-start_timer` | `START_TIMER` | | +| `cs_event-system_logout` | `SYSTEM_LOGOUT` | | +| `cs_event-keyboard_set_mode` | `KEYBOARD_SET_MODE` | | +| `cs_event-keyboard_shortcut` | `KEYBOARD_SHORTCUT` | | +| `cs_event-open_new_tab` | `OPEN_NEW_TAB` | | +| `cs_event-location_reload` | `LOCATION_RELOAD` | | +| `cs_event-set_title_launchpad` | `SET_TITLE_LAUNCHPAD` | | +| `cs_event-download_b64_file` | `DOWNLOAD_B64_FILE` | | +| `cs_event-urlhelper` | `URLHELPER` | | +| `cs_event-clipboard_app_state` | `CLIPBOARD_APP_STATE` | | +| `cs_event-store_data` | `STORE_DATA` | | +| `cs_event-play_audio` | `PLAY_AUDIO` | | +| `cs_event-smart_variant_init` | `SMART_VARIANT_INIT` | | +| `cs_event-filter_bar_variant_init` | `FILTER_BAR_VARIANT_INIT` | | +| `cs_event-control_by_id` | `CONTROL_BY_ID` | *Control* | +| `cs_event-control_global` | `CONTROL_GLOBAL` | *Control* | +| `cs_event-binding_call` | `BINDING_CALL` | *Control* | +| `cs_event-bind_element` | `BIND_ELEMENT` | *Control* | +| `cs_event-set_app_state_active` | `SET_APP_STATE_ACTIVE` | *experimental* | +| `cs_event-set_push_state` | `SET_PUSH_STATE` | *experimental* | +| `cs_event-set_nav_routing` | `SET_NAV_ROUTING` | *experimental* | +| `cs_event-image_editor_popup_close` | `IMAGE_EDITOR_POPUP_CLOSE` | *obsolet* | +| `cs_event-nav_container_to` | `NAV_CONTAINER_TO` | *obsolet* | +| `cs_event-nest_nav_container_to` | `NEST_NAV_CONTAINER_TO` | *obsolet* | +| `cs_event-nest2_nav_container_to` | `NEST2_NAV_CONTAINER_TO` | *obsolet* | +| `cs_event-popup_nav_container_to` | `POPUP_NAV_CONTAINER_TO` | *obsolet* | +| `cs_event-popover_nav_container_to` | `POPOVER_NAV_CONTAINER_TO` | *obsolet* | +| `cs_event-z2ui5` | `Z2UI5` | *obsolet* | +| `cs_event-wizard_set_next_step` | `WIZARD_SET_NEXT_STEP` | *obsolet* | + +### `cs_view` + +| Constant | Value | +|---|---| +| `cs_view-main` | `MAIN` | +| `cs_view-nested` | `NEST` | +| `cs_view-nested2` | `NEST2` | +| `cs_view-popup` | `POPUP` | +| `cs_view-popover` | `POPOVER` | + +### `cs_nav_mode` + +Hash-based app routing modes (see set_nav_routing). The mode decides how much of the running app the URL hash carries, and therefore what the browser Back/Forward buttons (and a reload / bookmark) restore: default - no routing: the hash is left untouched, exactly as before this feature. Back/Forward leave the abap2UI5 page (framework default). fresh - route '#/app/<CLASS>' (class only): Back/Forward/reload/bookmark start the app FRESH (a clean instance, no preserved input). keep - route '#/app/<CLASS>/<DRAFT>' (class + server draft): the exact preserved state is restored (all user input), falling back to a fresh start once the draft has expired. + +| Constant | Value | +|---|---| +| `cs_nav_mode-default` | `DEFAULT` | +| `cs_nav_mode-fresh` | `FRESH` | +| `cs_nav_mode-keep` | `KEEP` | + +## Types + +### `ty_s_name_value` + +| Field | Type | +|---|---| +| `n` | `string` | +| `v` | `string` | + +### `ty_t_name_value` + +Defined as `STANDARD TABLE OF ty_s_name_value WITH EMPTY KEY`. + + diff --git a/docs/resources/contribution.md b/docs/resources/contribution.md index 81135b46..f54f308b 100644 --- a/docs/resources/contribution.md +++ b/docs/resources/contribution.md @@ -14,5 +14,17 @@ There are several ways to get involved and help: - Improve the documentation and polish code snippets to make abap2UI5 easier to learn - Propose something new — every contribution is appreciated, no matter the size +## Turn Answers into Documentation +The cheapest contribution is one you have already half-made: when a question +comes up on Slack or in an issue for the second time, the answer wants to +become permanent — an entry in +[Common Failures](/cookbook/troubleshooting/common_failures) when it starts +from an error message, a paragraph on the page that should have answered it, +or, when a script can decide it, a check that makes the mistake impossible to +merge. That is how the whole project works internally — every defect found by +hand becomes a rule, a reference example or a gate, so nobody has to discover +it twice — and a distilled answer helps every future reader, where a Slack +thread helps one. + ## Need Help? If you hit any issues or have questions, open an [issue](https://github.com/abap2UI5/abap2UI5/issues). diff --git a/docs/technical/cloud.md b/docs/technical/cloud.md index d52d4a9a..7eef037d 100644 --- a/docs/technical/cloud.md +++ b/docs/technical/cloud.md @@ -40,6 +40,7 @@ While the framework itself is cloud-ready, you also need to build each app follo 1. Example: Display Sales Orders (Cloud-Ready): + ```abap CLASS z2ui5_cl_demo_app_003 DEFINITION PUBLIC. PUBLIC SECTION. @@ -83,6 +84,7 @@ ENDCLASS. This example uses only released APIs, like the CDS View `I_SalesOrder`. 2. Example: Display Sales Orders (not Cloud-Ready): + ```abap CLASS z2ui5_cl_demo_app_004 DEFINITION PUBLIC. PUBLIC SECTION. diff --git a/docs/technical/tools/srtti.md b/docs/technical/tools/srtti.md index 2085f1f2..2cb9e350 100644 --- a/docs/technical/tools/srtti.md +++ b/docs/technical/tools/srtti.md @@ -49,6 +49,7 @@ ENDCLASS. ## Transformation with S-RTTI When working with data typed dynamically at runtime via local types, S-RTTI is needed: + ```abap CLASS z2ui5_cl_app DEFINITION PUBLIC. diff --git a/docs/tutorials/overview.md b/docs/tutorials/overview.md index 36e94875..1d92e55c 100644 --- a/docs/tutorials/overview.md +++ b/docs/tutorials/overview.md @@ -7,21 +7,23 @@ description: Learn abap2UI5 by building — a step-by-step walkthrough where eve Learn abap2UI5 by building something. The **Walkthrough** grows a small invoice app from a single message box into a complete application, one concept per step: the app class and its lifecycle, views written in ABAP, events, data -binding, lists, tables, a selection screen and popups. +binding, lists, tables, a selection screen and popups — and then takes the +finished app out of the playground: to production in Step 11, and under unit +tests in Step 12. -Each step is a **complete, runnable class** — the whole app as it stands at -that point, not a fragment. Press the Run button under the code to start it in -the browser, or copy the class into your system and launch it like any -abap2UI5 app (see the [Quickstart](/get_started/quickstart)). No step depends -on anything outside its own code, so you can also jump straight to the step -you are interested in. +Each building step (1–10) is a **complete, runnable class** — the whole app as +it stands at that point, not a fragment. Press the Run button under the code +to start it in the browser, or copy the class into your system and launch it +like any abap2UI5 app (see the [Quickstart](/get_started/quickstart)). No step +depends on anything outside its own code, so you can also jump straight to the +step you are interested in. ## Preview ![The finished walkthrough app: a table of invoices with a selection form above it, and a dialog editing one row](/tutorials/walkthrough-preview.png) -This is the app after the last step — a few hundred lines of ABAP, no frontend -project, no OData service. +This is the app after the last building step — a few hundred lines of ABAP, +no frontend project, no OData service. ## Steps @@ -35,6 +37,8 @@ project, no OData service. - **[Step 8: Selection Screen](/tutorials/walkthrough/step-8)** — a form above the list, and reading the data it asks for. - **[Step 9: Tables](/tutorials/walkthrough/step-9)** — swap the list for a real table with columns, cells and row actions. - **[Step 10: App Structure](/tutorials/walkthrough/step-10)** — refactor into the structure real apps use. +- **[Step 11: From Playground to Production](/tutorials/walkthrough/step-11)** — real data, the transport order, authorization, and the URL users start from. +- **[Step 12: Unit Tests](/tutorials/walkthrough/step-12)** — test the app class like any ABAP class; no UI5 runtime involved. ## What You Should Know diff --git a/docs/tutorials/walkthrough/step-10.md b/docs/tutorials/walkthrough/step-10.md index 07508fd1..7ae08a26 100644 --- a/docs/tutorials/walkthrough/step-10.md +++ b/docs/tutorials/walkthrough/step-10.md @@ -9,7 +9,7 @@ samples: Everything so far lived in one `main` method, and by now that method does five different jobs. Real apps — the framework's own, and the sample catalogues' — -separate the phases into methods. This last step changes no behavior at all: it +separate the phases into methods. This step changes no behavior at all: it puts the code where a reader expects it, and assembles every part of the tutorial into the complete app. @@ -294,6 +294,16 @@ ENDCLASS. ## Where to Go From Here +The app is built — the walkthrough's last two steps take it out of the +playground: + +- **[Step 11: From Playground to Production](/tutorials/walkthrough/step-11)** — + real data, the transport order, authorization, and the URL users start from, +- **[Step 12: Unit Tests](/tutorials/walkthrough/step-12)** — the structure of + this step pays off: the data methods are testable without any UI. + +And for everything beyond the walkthrough: + - the [Cookbook](/cookbook/view/definition) — every topic of this walkthrough as a reference chapter, from [value helps](/cookbook/expert_more/value_help) to [navigation between apps](/cookbook/event_navigation/navigation), diff --git a/docs/tutorials/walkthrough/step-11.md b/docs/tutorials/walkthrough/step-11.md new file mode 100644 index 00000000..5a3f3c9e --- /dev/null +++ b/docs/tutorials/walkthrough/step-11.md @@ -0,0 +1,114 @@ +--- +outline: [2, 4] +description: Take the finished walkthrough app to a real system — real data, a pinned release, the transport order, authorization, and the URL users start it from. +--- +# Step 11: From Playground to Production + +The app from [Step 10](/tutorials/walkthrough/step-10) is finished — and so +far it has run in the playground, or in your development system against demo +data. This step takes it to the system your users work on. Unlike the ten +steps before it, there is nothing new to build here: everything this step +needs already has a configuration chapter, and what follows is those chapters +in the order the invoice app meets them. + +If your framework installation is not done yet, that comes first — the +[Quickstart](/get_started/quickstart) is the end-to-end path, including the +verify step at its end. + +## Make the Data Real + +Step 10 separated the app into seams on purpose, and this is the step where +that pays off the first time: `data_read` and `data_update` are the **only** +two methods that touch data. Each carries the real statement as a comment — +replace the `VALUE #( … )` demo table with the `SELECT` printed above it, and +the in-memory update with the `UPDATE`, and no other method changes. View, +events and popup never knew the data was fake. + +One caution before the class leaves your machine: only `SELECT` from tables +that exist on every system the app will reach. A dependency on an SD table, +for example, compiles fine on your system and is a syntax error on a system +without SD. + +## Pin the Framework + +Your app class travels through the landscape; the framework underneath it +should hold still while it does. There is no fixed "stable" version — instead, +pin the installation to a [release](https://github.com/abap2UI5/abap2UI5/releases/) +rather than tracking `main`, and update deliberately: +[Productive Usage](/configuration/productive_usage) explains the policy, the +[Release Notes](/resources/changelog) list every change, and the +[Deprecations](/resources/deprecations) page names what is on its way out and +what replaces it. If other abap2UI5 apps already run in production and you +want new development decoupled from them, that page also points to the +[renaming feature](/advanced/renaming) — a second, independently versioned +installation of the framework. + +## Transport, in Order + +abap2UI5 ships as ABAP objects, so the way to production is the transport +process you already have — with one ordering rule, spelled out on the +[Transport](/configuration/transport) page: + +1. Transport the framework and the HTTP service first. +2. On the target system, activate the service if needed, and adjust the + [UI5 bootstrap source](/configuration/setup/ui5_bootstrapping) if + production should load UI5 from somewhere else — a system without internet + access serves UI5 itself. +3. Confirm the installation with a Hello World class before your app arrives — + the same check as the Quickstart's verify step, on the production system. +4. Then transport the invoice app. + +That order exists so that when something fails, you know which layer failed: +a broken step 3 is installation, not your app. + +## Decide Who May Do What + +Two different questions, answered in two different places — the +[Security](/configuration/security) page is the map: + +**Authentication** — who gets in at all — is the ICF node's job, exactly as +for any other service on your system: logon procedure, visibility, all of it +on the service node you created in the Quickstart. The framework's own +defaults (a Content-Security-Policy, security response headers, CSRF +protection on every POST) are already on; nothing to configure. + +**Authorization** — who may do *what* — stays yours, and the invoice app +makes it concrete: whoever reaches the service can press *Read Invoices*, and +whoever can open the edit dialog can press *Save*. The framework runs no +check in between — the [Authorization](/configuration/authorization) page +shows both places to put one: in the HTTP handler, gating which app classes a +user may start at all, and in the app class, where an `AUTHORITY-CHECK` at +the top of `main` — or one in `data_update`, if reading and writing separate — +protects the operation itself. If you check only at the service level, make +sure users cannot reach the app through navigation from another one. + +## Give Users a Way In + +Your users do not open `SICF`. What they need is the URL from the Quickstart: + +``` +https://:/sap/bc/?app_start=zcl_app_walkthrough +``` + +That URL is a bookmark, an intranet link — or a tile: the +[Installation](/configuration/installation) page says what launching looks +like per system, from the [Fiori Launchpad](/configuration/launchpad) on +S/4 Private Cloud and On-Premise to the tile chain on +[S/4 Public Cloud](/configuration/s4_public_cloud). On a phone, the app can +be [added to the home screen](/configuration/mobile_start) like any web app. + +## What to Take Away + +- The seams from Step 10 are production seams: making the data real touched + two methods and nothing else +- Pin a release, and read the changelog when you move it +- Transport bottom-up — framework, service, Hello World, then the app — so a + failure names its layer +- Authentication is configuration on the ICF node; authorization is code you + write, at the service level, in the app, or both +- Users start the app from a URL; everything else — tile, bookmark, home + screen — is a wrapper around `?app_start=` + +The app is live. What keeps it safe from the *next* change is +[Step 12](/tutorials/walkthrough/step-12) — unit tests against the app class, +which the structure from Step 10 makes plain ABAP. diff --git a/docs/tutorials/walkthrough/step-12.md b/docs/tutorials/walkthrough/step-12.md new file mode 100644 index 00000000..b6fc3b95 --- /dev/null +++ b/docs/tutorials/walkthrough/step-12.md @@ -0,0 +1,382 @@ +--- +outline: [2, 4] +description: Unit-test the app class like any ABAP class — the data methods are plain ABAP, the test class never touches the UI, and the structure from Step 10 is what makes that possible. +--- +# Step 12: Unit Tests + +The app is in production since [Step 11](/tutorials/walkthrough/step-11), and +changes will keep coming — a new filter, a second editable field, the next +framework release. What lets the next transport leave with confidence is the +same thing as in every other ABAP project: unit tests. This step adds them to +the walkthrough app, and the point of it is how little abap2UI5 gets in the +way — the methods worth testing are plain ABAP, so the test class is one you +could have written before ever hearing of this framework. + +## The Class Under Test + +The app is unchanged from [Step 10](/tutorials/walkthrough/step-10) — printed +here in full so this step stands on its own: + +```abap +CLASS zcl_app_walkthrough DEFINITION PUBLIC. + + PUBLIC SECTION. + INTERFACES z2ui5_if_app. + + TYPES: + BEGIN OF ty_s_invoice, + product TYPE string, + supplier TYPE string, + quantity TYPE string, + delivery_date TYPE string, + END OF ty_s_invoice. + + DATA: + BEGIN OF s_search, + supplier TYPE string, + date_from TYPE string, + date_to TYPE string, + END OF s_search. + + DATA t_invoices TYPE STANDARD TABLE OF ty_s_invoice WITH EMPTY KEY. + DATA s_edit TYPE ty_s_invoice. + + PROTECTED SECTION. + DATA client TYPE REF TO z2ui5_if_client. + + METHODS on_event. + METHODS view_display. + METHODS popup_edit_display. + METHODS data_read. + METHODS data_update. + + PRIVATE SECTION. +ENDCLASS. + +CLASS zcl_app_walkthrough IMPLEMENTATION. + + METHOD z2ui5_if_app~main. + + me->client = client. + IF client->check_on_navigated( ). + view_display( ). + ELSEIF client->check_on_event( ). + on_event( ). + ENDIF. + + ENDMETHOD. + + + METHOD on_event. + + " get_event( ) holds the name passed to _event( ); + " get_event_arg( ) returns the extra argument attached via t_arg + CASE client->get_event( ). + WHEN `READ`. + data_read( ). + WHEN `EDIT`. + s_edit = VALUE #( t_invoices[ product = client->get_event_arg( ) ] OPTIONAL ). + popup_edit_display( ). + WHEN `SAVE`. + data_update( ). + client->popup_destroy( ). + client->message_toast_display( |{ s_edit-product } updated.| ). + WHEN `CANCEL`. + client->popup_destroy( ). + ENDCASE. + + ENDMETHOD. + + + METHOD view_display. + + DATA(view) = z2ui5_cl_ui5_view_builder=>factory( + )->ele( n = `View` ns = `mvc` + )->a( n = `xmlns` v = `sap.m` + )->a( n = `xmlns:mvc` v = `sap.ui.core.mvc` + )->a( n = `xmlns:form` v = `sap.ui.layout.form` ). + + DATA(page) = view->ele( `Shell` + )->ele( `Page` + )->a( n = `title` v = `Walkthrough - Step 10` + )->a( n = `navButtonPress` v = client->_event_nav_app_leave( ) + )->a( n = `showNavButton` b = client->check_app_prev_stack( ) ). + + page->ele( n = `SimpleForm` ns = `form` + )->a( n = `title` v = `Selection` + )->a( n = `editable` v = `true` + + )->ele( n = `content` ns = `form` + + )->tag( `Label` + )->a( n = `text` v = `Supplier` + )->tag( `Input` + )->a( n = `value` v = client->_bind( s_search-supplier ) + )->tag( `Label` + )->a( n = `text` v = `Delivery Date From` + )->tag( `DatePicker` + )->a( n = `value` v = client->_bind( s_search-date_from ) + )->a( n = `valueFormat` v = `yyyy-MM-dd` + )->tag( `Label` + )->a( n = `text` v = `Delivery Date To` + )->tag( `DatePicker` + )->a( n = `value` v = client->_bind( s_search-date_to ) + )->a( n = `valueFormat` v = `yyyy-MM-dd` + )->tag( `Button` + )->a( n = `text` v = `Read Invoices` + )->a( n = `press` v = client->_event( `READ` ) + )->a( n = `type` v = `Emphasized` ). + + DATA(tab) = page->ele( `Table` + )->a( n = `headerText` v = `Invoices` + )->a( n = `items` v = client->_bind( t_invoices ) ). + + tab->ele( `columns` + + )->ele( `Column` + )->tag( `Text` + )->a( n = `text` v = `Product` + + )->end( + )->ele( `Column` + )->tag( `Text` + )->a( n = `text` v = `Supplier` + + )->end( + )->ele( `Column` + )->tag( `Text` + )->a( n = `text` v = `Quantity` + + )->end( + )->ele( `Column` + )->tag( `Text` + )->a( n = `text` v = `Delivery Date` + + )->end( + )->ele( `Column` + )->a( n = `width` v = `10%` ). + + tab->ele( `items` + )->ele( `ColumnListItem` + )->ele( `cells` + + )->tag( `Text` + )->a( n = `text` v = `{PRODUCT}` + )->tag( `Text` + )->a( n = `text` v = `{SUPPLIER}` + )->tag( `Text` + )->a( n = `text` v = `{QUANTITY}` + )->tag( `Text` + )->a( n = `text` v = `{DELIVERY_DATE}` + )->tag( `Button` + )->a( n = `icon` v = `sap-icon://edit` + )->a( n = `tooltip` v = `Edit delivery date` + )->a( n = `press` v = client->_event( val = `EDIT` + t_arg = VALUE #( ( `${PRODUCT}` ) ) ) ). + + client->view_display( view->stringify( ) ). + + ENDMETHOD. + + + METHOD popup_edit_display. + + DATA(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` + + )->ele( `Dialog` + )->a( n = `title` v = |Edit { s_edit-product }| + + )->ele( `content` + + )->tag( `Label` + )->a( n = `text` v = `Delivery Date` + )->tag( `DatePicker` + )->a( n = `value` v = client->_bind( s_edit-delivery_date ) + )->a( n = `valueFormat` v = `yyyy-MM-dd` + + )->end( + + )->ele( `buttons` + + )->tag( `Button` + )->a( n = `text` v = `Cancel` + )->a( n = `press` v = client->_event( `CANCEL` ) + )->tag( `Button` + )->a( n = `text` v = `Save` + )->a( n = `press` v = client->_event( `SAVE` ) + )->a( n = `type` v = `Emphasized` ). + + client->popup_display( popup->stringify( ) ). + + ENDMETHOD. + + + METHOD data_read. + + " demo data — in your system, replace this with a SELECT, e.g.: + " SELECT product, supplier, quantity, delivery_date + " FROM zinvoice + " WHERE supplier LIKE @s_search-supplier + " AND delivery_date BETWEEN @s_search-date_from AND @s_search-date_to + " INTO TABLE @t_invoices. + t_invoices = VALUE #( + ( product = `Pineapple` supplier = `ACME` quantity = `21` delivery_date = `2026-07-15` ) + ( product = `Milk` supplier = `Green Growers` quantity = `4` delivery_date = `2026-07-20` ) + ( product = `Canned Beans` supplier = `Corner Deli` quantity = `3` delivery_date = `2026-08-01` ) + ( product = `Salad` supplier = `Green Growers` quantity = `2` delivery_date = `2026-08-10` ) + ( product = `Bread` supplier = `Corner Deli` quantity = `1` delivery_date = `2026-08-12` ) ). + + IF s_search-supplier IS NOT INITIAL. + " NS = `contains no string` — drop the rows whose supplier does not match + DELETE t_invoices WHERE supplier NS s_search-supplier. + ENDIF. + + IF s_search-date_from IS NOT INITIAL. + DELETE t_invoices WHERE delivery_date < s_search-date_from. + ENDIF. + + IF s_search-date_to IS NOT INITIAL. + DELETE t_invoices WHERE delivery_date > s_search-date_to. + ENDIF. + + ENDMETHOD. + + + METHOD data_update. + + " in your system, persist the change with an UPDATE, e.g.: + " UPDATE zinvoice SET delivery_date = @s_edit-delivery_date + " WHERE product = @s_edit-product. + t_invoices[ product = s_edit-product ]-delivery_date = s_edit-delivery_date. + + ENDMETHOD. + +ENDCLASS. +``` + +## The Test Class + +Tests live where they always live: in the class's **Test Classes** include +(the *Test Classes* tab in ADT). Nothing abap2UI5-specific is in them: + +```abap +CLASS ltcl_walkthrough DEFINITION DEFERRED. +CLASS zcl_app_walkthrough DEFINITION LOCAL FRIENDS ltcl_walkthrough. + +CLASS ltcl_walkthrough DEFINITION FINAL + FOR TESTING RISK LEVEL HARMLESS DURATION SHORT. + + PRIVATE SECTION. + METHODS read_filters_by_supplier FOR TESTING. + METHODS read_filters_by_date FOR TESTING. + METHODS update_writes_back FOR TESTING. + +ENDCLASS. + +CLASS ltcl_walkthrough IMPLEMENTATION. + + METHOD read_filters_by_supplier. + + DATA(cut) = NEW zcl_app_walkthrough( ). + cut->s_search-supplier = `Green Growers`. + + cut->data_read( ). + + cl_abap_unit_assert=>assert_equals( + act = lines( cut->t_invoices ) + exp = 2 + msg = `expected exactly the two Green Growers invoices` ). + + ENDMETHOD. + + + METHOD read_filters_by_date. + + DATA(cut) = NEW zcl_app_walkthrough( ). + cut->s_search-date_from = `2026-08-01`. + + cut->data_read( ). + + LOOP AT cut->t_invoices INTO DATA(ls_invoice). + cl_abap_unit_assert=>assert_true( + act = xsdbool( ls_invoice-delivery_date >= `2026-08-01` ) + msg = |{ ls_invoice-product } lies before the date filter| ). + ENDLOOP. + + ENDMETHOD. + + + METHOD update_writes_back. + + DATA(cut) = NEW zcl_app_walkthrough( ). + cut->data_read( ). + + cut->s_edit = VALUE #( product = `Milk` delivery_date = `2026-09-01` ). + cut->data_update( ). + + cl_abap_unit_assert=>assert_equals( + act = cut->t_invoices[ product = `Milk` ]-delivery_date + exp = `2026-09-01` ). + + ENDMETHOD. + +ENDCLASS. +``` + +Run them as always — in ADT with `Ctrl+Shift+F10`. Three tests, three +different seams: + +- **`read_filters_by_supplier`** fills the selection screen's backing + structure `s_search` — a plain public attribute — calls `data_read` and + counts the result. No button was pressed and no view was built: the test + enters through the same attribute the UI binds. +- **`read_filters_by_date`** does the same for the date filter and checks a + property of every row rather than a count. +- **`update_writes_back`** plays the popup workflow without the popup: fill + `s_edit` the way the dialog's bindings would, call `data_update`, and check + the table. When the demo data becomes a real `UPDATE` in + [Step 11](/tutorials/walkthrough/step-11), this is the test that grows a + test double for the database layer — the seam is already in place. + +## Why This Worked + +- **The tests never mock the framework.** `main` is a dispatcher and the + handler methods do not take `client` as a parameter — so the logic under + test is reachable without a single framework object. That is the payoff of + Step 10's structure. +- **`LOCAL FRIENDS` opens the protected section.** `data_read` and + `data_update` are protected — implementation, not model. The two lines + above the test class (`DEFERRED`, then `LOCAL FRIENDS`) let the test class + call them anyway, without making them public for everyone. Do not skip + them: without `LOCAL FRIENDS` the class fails to activate on a real system + even where a linter stays quiet. +- **Public attributes are the natural test interface.** The same attributes + the framework serializes and the view binds — `s_search`, `t_invoices`, + `s_edit` — are what tests fill and assert on. The UI enters the class the + same way the test does. + +What the unit tests deliberately do not cover is the view: whether `Table` +has an `items` aggregation is not a question ABAP can answer. That check +exists too, without a system — the [abap2UI5 linter](/advanced/linter) +reconstructs the view from the builder chain and validates it against UI5, +and the [tooling page](/get_started/tooling) shows how it runs in CI next to +these tests. + +## What to Take Away + +- An abap2UI5 app is testable like any ABAP class, because it *is* one — + no UI5 runtime, no HTTP, no mocks +- Test through the same public attributes the view binds; call the data + methods directly +- `DEFERRED` + `LOCAL FRIENDS` is the pattern for testing protected methods — + and a missing `LOCAL FRIENDS` is an activation error, not a style issue +- Views are checked by the linter, logic by unit tests; together they run + without an SAP system + +That is the end of the walkthrough: one class, grown from a message box to a +tested app in production. The [Cookbook](/cookbook/view/definition) covers +every topic again as a reference chapter, and the +[sample catalogues](https://abap2ui5.github.io/samples/) continue from here. diff --git a/llms.txt b/llms.txt new file mode 100644 index 00000000..80c09ad3 --- /dev/null +++ b/llms.txt @@ -0,0 +1,21 @@ +# abap2UI5 documentation — you are in the source, the site is the reading copy + +> This repository builds https://abap2ui5.github.io/docs — the abap2UI5 +> documentation site. The machine-readable projection of that site is +> generated at build time and is deliberately NOT committed here (it is a +> projection of the pages next to it, so the only correct copy is the one the +> build just made). Fetch it from the site: + +- https://abap2ui5.github.io/docs/llms.txt — the map: every page with its + title and one line of what it covers, plus the repositories around it +- https://abap2ui5.github.io/docs/llms-full.txt — the whole documentation as + one markdown document +- https://abap2ui5.github.io/docs/.md — any single page as raw markdown + (drop the `.md` for the rendered version) +- https://abap2ui5.github.io/docs/api/client-api.json — the client API + (z2ui5_if_client at the pinned release) as one JSON document; this one IS + committed, at docs/public/api/client-api.json + +Working in this checkout instead? The pages are under docs/, the sidebar in +docs/.vitepress/config.mjs, and AGENTS.md is the briefing — read it before +changing anything. diff --git a/package.json b/package.json index 76395928..2c0537e7 100644 --- a/package.json +++ b/package.json @@ -16,11 +16,14 @@ "docs:build": "node scripts/generate-llms.mjs && vitepress build docs", "docs:preview": "vitepress preview docs", "check:examples": "node scripts/check-examples.mjs", + "check:playground": "node scripts/check-playground.mjs", "check:api-names": "node scripts/check-api-names.mjs", + "generate:api": "node scripts/generate-api-reference.mjs", + "check:api-reference": "node scripts/generate-api-reference.mjs --check", "link:samples": "node scripts/link-samples.mjs", "check:samples": "node scripts/link-samples.mjs --check", "test": "node --test test/*.test.mjs", - "check": "npm run test && npm run check:version && npm run docs:build && npm run check:examples && npm run check:api-names && npm run check:samples", + "check": "npm run test && npm run check:version && npm run docs:build && npm run check:examples && npm run check:playground && npm run check:api-names && npm run check:api-reference && npm run check:samples", "llms": "node scripts/generate-llms.mjs", "check:version": "node scripts/check-version.mjs" }, diff --git a/scripts/check-api-names.mjs b/scripts/check-api-names.mjs index 003a173c..28bf1492 100644 --- a/scripts/check-api-names.mjs +++ b/scripts/check-api-names.mjs @@ -51,6 +51,7 @@ import fs from 'fs'; import path from 'path'; import { fileURLToPath } from 'url'; import { declaredRelease } from './lib/release.mjs'; +import { fetchInterface } from './lib/client-interface.mjs'; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const PAGES = path.join(ROOT, 'docs'); @@ -69,13 +70,9 @@ if (!REF) { process.exit(0); } -const SOURCE = `https://raw.githubusercontent.com/abap2UI5/abap2UI5/${REF}/src/02/z2ui5_if_client.intf.abap`; - let iface; try { - const res = await fetch(SOURCE, { signal: AbortSignal.timeout(20000) }); - if (!res.ok) throw new Error(`HTTP ${res.status}`); - iface = await res.text(); + iface = await fetchInterface(REF); } catch (err) { console.log(`z2ui5_if_client at ${REF}: not resolved (${err.message})`); console.log('SKIPPED: nothing was verified.'); @@ -98,7 +95,17 @@ const groups = new Map(); const line = raw.replace(/^\s*"[!]?.*$/, '').replace(/\s"\s.*$/, '').trimEnd(); const begin = /^\s*BEGIN OF ([a-z_0-9]+),?\s*$/i.exec(line); - if (begin) { open.push(begin[1].toLowerCase()); groups.set(open.at(-1), new Set()); continue; } + if (begin) { + const name = begin[1].toLowerCase(); + // the nested group is itself a member of what encloses it: a page + // spelling the full path (`cs_device-system-phone`) trips the + // two-segment match `cs_device-system` first, and that spelling is + // exactly how an app writes the constant - it has to resolve + for (const g of open) groups.get(g).add(name); + open.push(name); + groups.set(name, new Set()); + continue; + } if (/^\s*END OF ([a-z_0-9]+)/i.test(line)) { open.pop(); continue; } if (open.length) { // a member of every group it is nested in, so cs_device-system-phone diff --git a/scripts/check-playground.mjs b/scripts/check-playground.mjs new file mode 100644 index 00000000..03c2ef7b --- /dev/null +++ b/scripts/check-playground.mjs @@ -0,0 +1,101 @@ +#!/usr/bin/env node +// Is every example without a Run button buttonless ON PURPOSE? +// +// `docs/.vitepress/playground.mjs` decides which fenced example the reader can +// start in an embedded playground, and its rules deliberately fail towards no +// button. The gap that leaves — an example nobody ever measured looking +// exactly like an example that can never run — used to live in a hand-written +// ledger in AGENTS.md, which is to say it went stale. +// +// Now it is decided: every complete `z2ui5_if_app` class on a page either has +// a button from the rule engine, or carries a marker directly above its fence +// naming what it needs that a browser has not got: +// +// +// +// One without either fails this check. So does a stale marker — one above an +// example that HAS a button, or attached to nothing — so intent can never +// quietly outlive the example it was written about. +// +// What stays a hand measurement, and says so in AGENTS.md: whether a BUTTONED +// example actually starts in a real playground. This script keeps the +// bookkeeping between those measurements honest; it cannot run ABAP. +// +// Usage: node scripts/check-playground.mjs [--list] +// --list also prints every deliberate exclusion with both its reasons +// (the engine's, and the page's) — the measurement worklist. + +import { readFileSync, readdirSync, statSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { auditPage } from './lib/playground-coverage.mjs'; + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); +const DOCS = join(ROOT, 'docs'); +const LIST = process.argv.includes('--list'); + +/* The same walk as check-examples.mjs: `public` holds a generated raw-markdown + * copy of every page, and judging those would double every count. */ +const walk = (dir) => + readdirSync(dir).flatMap((e) => { + const p = join(dir, e); + if (e === '.vitepress' || e === 'node_modules' || e === 'public') return []; + return statSync(p).isDirectory() ? walk(p) : [p]; + }); + +const total = { classes: 0, apps: 0, buttons: 0, excluded: 0 }; +const undecided = []; +const stale = []; +const excluded = []; + +for (const file of walk(DOCS).filter((f) => f.endsWith('.md')).sort()) { + const page = file.slice(ROOT.length + 1); + const result = auditPage(readFileSync(file, 'utf8')); + total.classes += result.classes; + total.apps += result.apps; + total.buttons += result.buttons; + total.excluded += result.excluded.length; + for (const one of result.undecided) undecided.push({ page, ...one }); + for (const one of result.stale) stale.push({ page, what: one }); + for (const one of result.excluded) excluded.push({ page, ...one }); +} + +console.log( + `check-playground: ${total.apps} complete app class(es) on the site — ` + + `${total.buttons} with a Run button, ${total.excluded} excluded on purpose` + + ` (${total.classes - total.apps} more complete class(es) are not apps)`, +); + +if (LIST) { + console.log('\nexcluded on purpose:'); + for (const { page, name, why, reason } of excluded) { + console.log(` - ${page}: ${name}`); + console.log(` the engine: ${why}`); + console.log(` the page: ${reason}`); + } +} + +let failed = false; + +if (undecided.length) { + failed = true; + console.error(`\n${undecided.length} app example(s) have no Run button and no marker saying why:\n`); + for (const { page, name, why } of undecided) { + console.error(` - ${page}: ${name}\n the engine refuses it: ${why}`); + } + console.error('\nEither the example can run — then measure it in a real playground (AGENTS.md'); + console.error('says how) and adjust it or the rules until it gets its button — or it cannot,'); + console.error('and the page says so, directly above the fence:'); + console.error('\n '); +} + +if (stale.length) { + failed = true; + console.error(`\n${stale.length} stale marker(s):\n`); + for (const { page, what } of stale) console.error(` - ${page}: ${what}`); + console.error('\nA marker records why an example cannot run. When that stops being true —'); + console.error('the example got its button, or moved, or went — the marker goes with it.'); +} + +if (failed) process.exit(1); +console.log('every buttonless app example carries its reason. Nothing is excluded by accident.'); diff --git a/scripts/generate-api-reference.mjs b/scripts/generate-api-reference.mjs new file mode 100644 index 00000000..d91ea70e --- /dev/null +++ b/scripts/generate-api-reference.mjs @@ -0,0 +1,299 @@ +#!/usr/bin/env node +/* + * generate-api-reference — the client API as a page, generated from its source. + * + * The one artefact every abap2UI5 app is written against is a single + * interface: z2ui5_if_client, handed to the app as `client` on every + * roundtrip. The interface carries its own documentation - hundreds of + * ABAP-Doc lines on the methods, the parameters and the constants - and until + * now the only way to read any of it was to open the source file. No page on + * this site listed the API; a reader (or an agent) asking "what can client-> + * do" had to reverse it out of the cookbook examples. + * + * So the reference is GENERATED, the way the Working Samples blocks are: + * nothing about the interface is retyped here, because a hand-written API + * page is wrong the release after it is written. Two artefacts, both + * committed: + * + * docs/resources/api.md the page - methods grouped and ordered, + * parameters with types and defaults, the + * cs_* constants, all of it between markers; + * the intro outside the markers is prose + * and is written by hand + * docs/public/api/client-api.json the same model as one JSON document, so + * tooling fetches one URL instead of + * parsing a page + * + * TRUTH is the release, never main - the same rule check-api-names and + * check-examples follow, reading the same pin (lib/release.mjs) and the same + * source file (lib/client-interface.mjs). A2UI5_REF overrides it for a canary + * run against main. + * + * Committed rather than build-time (like llms.txt) because it is a claim + * about ANOTHER repository at a pinned release, not a projection of the pages + * next to it - exactly the samples-block case, and it gets the same freshness + * gate: --check regenerates and fails when the committed copy differs. When + * the interface cannot be fetched, --check SAYS SO and passes - a + * documentation gate must not go red because github.com is unreachable, and + * must not claim to have verified something it did not. Generation without + * --check fails instead: there is nothing to write. + * + * node scripts/generate-api-reference.mjs rewrite page + JSON + * node scripts/generate-api-reference.mjs --check fail if a rewrite + * would change anything + */ +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import { declaredRelease } from './lib/release.mjs'; +import { fetchInterface, parseInterface, interfacePath } from './lib/client-interface.mjs'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const PAGE = path.join(ROOT, 'docs', 'resources', 'api.md'); +const JSON_OUT = path.join(ROOT, 'docs', 'public', 'api', 'client-api.json'); +const CHECK = process.argv.includes('--check'); + +const START = ''; +const END = ''; +const SITE = 'https://abap2ui5.github.io/docs'; + +/* ------------------------------------------------------------- the source */ + +const REF = process.env.A2UI5_REF || declaredRelease(ROOT); +if (!REF) { + console.log('the three places naming the release DISAGREE, so there is nothing to pin to.'); + console.log('SKIPPED: run npm run check:version.'); + process.exit(0); +} + +let iface; +try { + iface = await fetchInterface(REF); +} catch (err) { + console.log(`z2ui5_if_client at ${REF}: not resolved (${err.message})`); + if (CHECK) { + console.log('SKIPPED: nothing was verified.'); + process.exit(0); + } + console.error('nothing to generate from - try again with a network.'); + process.exit(1); +} + +const model = parseInterface(iface); +if (model.methods.length < 20 || !model.constants.some((c) => c.name === 'cs_event' && c.members.length > 10)) { + console.log(`z2ui5_if_client at ${REF} parsed to almost nothing - the interface changed shape.`); + console.log('Fix the parser in scripts/lib/client-interface.mjs, or this reference silently thins out.'); + process.exit(1); +} + +/* ------------------------------------------------------- grouping the methods */ + +/* Derived from the interface's own naming, not invented here: the prefixes ARE + * its structure (view_*, nest*, popup_* and popover_*, _bind*, message_*, …). + * First match wins, so the narrower pattern stands before the wider one. */ +const RULES = [ + ['App lifecycle', /^check_on_/], + ['Data binding', /^_bind/], + ['Main view', /^view_/], + ['Nested views', /^nest/], + ['Popups and popovers', /^(popup|popover)_/], + ['Messages', /^message_/], + ['App navigation', /^(_event_nav|nav_app_|get_app|check_app)/], + ['Events and frontend actions', /^(_event|follow_up_action)/], + ['Reading the request', /^get(_event(_arg)?)?$/], +]; + +/* The order a reader meets them in an app: the dispatcher first, then the + * view, then what the view is wired to. */ +const ORDER = [ + 'App lifecycle', + 'Main view', + 'Nested views', + 'Popups and popovers', + 'Data binding', + 'Events and frontend actions', + 'Reading the request', + 'Messages', + 'App navigation', + 'Session and app state', + 'Other', +]; + +const groupOf = (name) => { + for (const [group, re] of RULES) if (re.test(name)) return group; + if (name.startsWith('set_')) return 'Session and app state'; + return 'Other'; +}; +for (const m of model.methods) m.group = groupOf(m.name); + +/* ---------------------------------------------------------------- markdown */ + +/** `<` becomes an HTML tag in VitePress prose, but stays literal inside an + * inline-code span - so escape around the spans, never inside them. */ +const prose = (s) => s + .split(/(`[^`]*`)/) + .map((part, i) => (i % 2 ? part : part.replace(/ prose(s).replace(/\|/g, '\\|').replace(/\s*\r?\n\s*/g, ' '); + +const isObsolete = (doc) => /^obsolete\b/i.test(doc[0] ?? ''); + +const defaultCell = (p) => { + if (p.default === undefined) return p.optional ? '*optional*' : ''; + const literal = /^`(.*)`$/.exec(p.default); + if (literal) return literal[1] === '' ? '*(empty)*' : `\`${literal[1]}\``; + return `\`${p.default}\``; +}; + +function renderMethod(m) { + const out = []; + const badge = isObsolete(m.doc) ? ' ' : ''; + out.push(`### \`${m.name}\`${badge}`, ''); + for (const para of m.doc) out.push(prose(para), ''); + if (m.params.length) { + out.push('| Parameter | Type | Default | Description |', '|---|---|---|---|'); + for (const p of m.params) { + out.push(`| \`${p.name}\` | \`${cell(p.type)}\` | ${defaultCell(p)} | ${p.doc ? cell(p.doc) : ''} |`); + } + out.push(''); + if (m.preferred) out.push(`Preferred parameter: \`${m.preferred}\` — a positional call passes it.`, ''); + } + if (m.returning) out.push(`Returns \`${m.returning.type}\`.`, ''); + return out; +} + +/** Every constant of a group as its full spelled form, nested groups + * flattened the way a page writes them: cs_device-system-phone. */ +function constantRows(group, prefix, rows) { + for (const member of group.members) { + if (member.members) constantRows(member, `${prefix}-${member.name}`, rows); + else rows.push({ name: `${prefix}-${member.name}`, value: member.value ?? '', label: member.label }); + } + return rows; +} + +function renderConstants(group) { + const out = [`### \`${group.name}\``, '']; + for (const para of group.doc) out.push(prose(para), ''); + const rows = constantRows(group, group.name, []); + const labelled = rows.some((r) => r.label); + out.push(labelled ? '| Constant | Value | |' : '| Constant | Value |'); + out.push(labelled ? '|---|---|---|' : '|---|---|'); + for (const r of rows) { + const value = r.value === '' ? '*(empty)*' : `\`${cell(r.value)}\``; + out.push(`| \`${r.name}\` | ${value} |${labelled ? ` ${r.label ? `*${cell(r.label)}*` : ''} |` : ''}`); + } + out.push(''); + return out; +} + +function renderType(t) { + const out = [`### \`${t.name}\``, '']; + for (const para of t.doc ?? []) out.push(prose(para), ''); + if (t.members) { + out.push('| Field | Type |', '|---|---|'); + for (const f of t.members) out.push(`| \`${f.name}\` | \`${cell(f.type)}\` |`); + out.push(''); + } else { + out.push(`Defined as \`${t.definition}\`.`, ''); + } + return out; +} + +const groups = new Map(); +for (const name of ORDER) groups.set(name, []); +for (const m of model.methods) groups.get(m.group).push(m); + +const body = [ + START, + '', + `Generated from [\`z2ui5_if_client\`](https://github.com/abap2UI5/abap2UI5/blob/${REF}/${interfacePath})`, + `at release **${REF}** — ${model.methods.length} methods, ${model.constants.length} constant`, + `structures. The same reference as one JSON document: [client-api.json](${SITE}/api/client-api.json).`, + '', + ...[...groups].filter(([, list]) => list.length).flatMap(([name, list]) => [ + `## ${name}`, + '', + ...list.flatMap(renderMethod), + ]), + '## Constants', + '', + 'The values an app compares against or passes on: `cs_event` names every', + 'frontend action, `cs_view` the view slots, `cs_device` what `get( )` reports', + 'about the device, `cs_nav_mode` the routing modes.', + '', + ...model.constants.flatMap(renderConstants), + '## Types', + '', + ...model.types.flatMap(renderType), + END, +].join('\n'); + +if (body.includes('{{')) { + // `{{` is Vue interpolation in a VitePress page, even in prose + console.error('the generated markdown contains `{{`, which VitePress hands to Vue - fix the renderer.'); + process.exit(1); +} + +/* -------------------------------------------------------------------- JSON */ + +const json = `${JSON.stringify({ + description: 'The abap2UI5 client API: everything an app may call on `client`, ' + + 'read from z2ui5_if_client at the release this documentation names.', + release: REF, + source: `https://github.com/abap2UI5/abap2UI5/blob/${REF}/${interfacePath}`, + documentation: `${SITE}/resources/api.html`, + methods: model.methods.map(({ name, group, doc, preferred, params, returning }) => ({ + name, + group, + ...(doc.length ? { doc } : {}), + ...(preferred ? { preferred } : {}), + parameters: params, + ...(returning ? { returns: returning.type } : {}), + })), + constants: model.constants, + types: model.types, +}, null, 2)}\n`; + +/* --------------------------------------------------------------------- run */ + +if (!fs.existsSync(PAGE)) { + console.error(`${path.relative(ROOT, PAGE)}: gone - the intro around the generated block is`); + console.error('hand-written, so this script does not create the page, only the block in it.'); + process.exit(1); +} +const page = fs.readFileSync(PAGE, 'utf8'); +const from = page.indexOf(START); +const to = page.indexOf(END, from); +if (from === -1 || to === -1) { + console.error(`${path.relative(ROOT, PAGE)}: no ${from === -1 ? 'start' : 'end'} marker - restore the marker pair, then rerun.`); + process.exit(1); +} +const nextPage = page.slice(0, from) + body + page.slice(to + END.length); +const currentJson = fs.existsSync(JSON_OUT) ? fs.readFileSync(JSON_OUT, 'utf8') : null; + +console.log(`z2ui5_if_client at ${REF}: ${model.methods.length} methods in ${[...groups.values()].filter((l) => l.length).length} groups, ` + + `${model.constants.length} constant structures, ${model.types.length} types`); + +if (CHECK) { + const stale = []; + if (nextPage !== page) stale.push(path.relative(ROOT, PAGE)); + if (json !== currentJson) stale.push(path.relative(ROOT, JSON_OUT)); + if (stale.length) { + console.error(`\nout of date against abap2UI5 ${REF}:`); + for (const f of stale) console.error(` ${f}`); + console.error('\nThe interface changed under the committed reference - or the reference was'); + console.error('edited by hand. Run `npm run generate:api` and commit what it writes.'); + process.exit(1); + } + console.log('the committed API reference matches the interface at that release - OK'); +} else { + fs.mkdirSync(path.dirname(JSON_OUT), { recursive: true }); + const wrote = []; + if (nextPage !== page) { fs.writeFileSync(PAGE, nextPage); wrote.push(path.relative(ROOT, PAGE)); } + if (json !== currentJson) { fs.writeFileSync(JSON_OUT, json); wrote.push(path.relative(ROOT, JSON_OUT)); } + console.log(wrote.length ? `rewritten: ${wrote.join(', ')}` : 'already up to date - nothing written.'); +} diff --git a/scripts/generate-llms.mjs b/scripts/generate-llms.mjs index db694ee8..fe17bc18 100644 --- a/scripts/generate-llms.mjs +++ b/scripts/generate-llms.mjs @@ -138,15 +138,23 @@ const title = (body, fallback) => * A wrong count in the one file written to be quoted verbatim is the worst * place in the repository to keep one. * - * So it is counted, through the same parser the sample links go through - and - * when the catalogue is not at hand the phrase simply carries no number. The - * deploy workflow checks out no sample repository, so that is the normal case - * for the published file, and it is the right outcome: the sentence an agent - * needs is "ask SAMPLES.md before writing an app", not the size of the - * haystack. */ + * So it is counted, never typed: from a sibling checkout when the workflow has + * one, else from the catalogue.json each sample repository publishes at its + * root - the same file, so the two paths cannot answer differently - and when + * neither is reachable the phrase simply carries no number. The checkout steps + * are continue-on-error and the fetch can 404 or time out; both cost only the + * figure, and that is the right trade: the sentence an agent needs is "ask + * SAMPLES.md before writing an app", not the size of the haystack. + * + * Counted ONCE per repository, here, so the phrase in llms.txt and the log + * line at the bottom cannot come from two different attempts. */ +const corpus = new Map(); +for (const repo of ['samples', 'samples-controls']) { + corpus.set(repo, await countCatalogue(repo, ROOT)); +} const counted = (repo, phrase) => { - const n = countCatalogue(repo, ROOT); - return n === null ? phrase : `${n} ${phrase}`; + const c = corpus.get(repo); + return c === null ? phrase : `${c.count} ${phrase}`; }; const pages = sidebarPages(); @@ -285,12 +293,13 @@ fs.writeFileSync(path.join(PUBLIC, 'llms-full.txt'), full); const kb = (s) => `${Math.round(s / 1024)} kB`; console.log(`llms.txt: ${pages.length} pages in ${bySection.size} sections (${kb(index.length)})`); -/* Say it out loud, because "no checkout" and "counted" produce different files - * and both are valid - the only way to notice a number went missing that - * should have been there is to be told which ones were taken. */ +/* Say it out loud, because "no catalogue" and "counted" produce different + * files and both are valid - the only way to notice a number went missing that + * should have been there, or arrived from the fallback when a checkout was + * expected, is to be told which ones were taken and from where. */ for (const repo of ['samples', 'samples-controls']) { - const n = countCatalogue(repo, ROOT); - console.log(` ${repo}: ${n === null ? 'no catalogue here — published without a count' : `${n} apps`}`); + const c = corpus.get(repo); + console.log(` ${repo}: ${c === null ? 'no catalogue anywhere — published without a count' : `${c.count} apps (${c.source})`}`); } console.log(`llms-full.txt: ${kb(full.length)}`); console.log(`${written} page(s) published as raw markdown under docs/public/`); diff --git a/scripts/lib/catalogue.mjs b/scripts/lib/catalogue.mjs index 6b69dbea..8f4478b0 100644 --- a/scripts/lib/catalogue.mjs +++ b/scripts/lib/catalogue.mjs @@ -1,5 +1,7 @@ /* - * catalogue — the one parser for a SAMPLES.md row. + * catalogue — the one parser for a SAMPLES.md row, and the one place a sample + * corpus is counted (from a checkout, or from the catalogue.json each sample + * repository publishes, in that order - see countCatalogue). * * Extracted from link-samples.mjs so it can be tested without running it: the * script resolves a samples checkout and rewrites pages at import time, and a @@ -42,32 +44,93 @@ const ROW = /^\|\s*(?:\*\*(?[^*]+)\*\*\s*(?:—|--)\s*)?(?<sub>[^|<]*?)\s * checkout that works for one works for the other; CI puts it in `.samples`. * * Nothing here is required. A count that cannot be taken is reported as `null` - * and the caller leaves the number out - the deploy workflow checks out no - * sample repository whatsoever, and a stale number in a file an agent cites is - * worse than no number at all. */ + * and the caller leaves the number out - a checkout step in CI is + * continue-on-error, a contributor's clone has no siblings at all, and a stale + * number in a file an agent cites is worse than no number at all. */ const HOMES = { samples: ['SAMPLES_HOME', '.samples', '../samples', '../abap2UI5-samples'], 'samples-controls': ['SAMPLES_CONTROLS_HOME', '.samples-controls', '../samples-controls'], 'samples-stack': ['SAMPLES_STACK_HOME', '.samples-stack', '../samples-stack'], }; -/** How many apps `repo` lists today, or null if its catalogue is not here. +/* Each sample repository also commits the same catalogue as one JSON document, + * `catalogue.json` at its root, generated over there from the same scan that + * writes its SAMPLES.md. That file is reachable without a checkout, which the + * human page's raw URL also is - but the JSON one declares its rows instead of + * asking us to re-parse a rendered table, so it is the one fetched. */ +const PUBLISHED = (repo) => `https://raw.githubusercontent.com/abap2UI5/${repo}/main/catalogue.json`; + +/** How many samples a parsed catalogue.json lists - by counting its entries, + * NEVER by reading a `counts` field. The three repositories keep their + * entries under different keys (`samples` here and in samples-stack, `ports` + * in samples-controls), so what identifies an entry is its shape, the same + * way the SAMPLES.md parser identifies a row: something with a class name and + * a pointer to its source file. A `counts` field is a claim about the + * entries; where the entries themselves are in hand, counting them is the + * answer that cannot have gone stale separately. */ +export function countEntries(catalogue) { + if (!catalogue || typeof catalogue !== 'object') return 0; + let n = 0; + for (const value of Object.values(catalogue)) { + if (!Array.isArray(value)) continue; + for (const entry of value) { + if (!entry || typeof entry !== 'object') continue; + const cls = entry.class; + const file = entry.file ?? entry.path; + if (typeof cls === 'string' && cls.trim() && typeof file === 'string' && file.trim()) n++; + } + } + return n; +} + +/** How many apps `repo` lists today: `{ count, source }`, or null if no + * catalogue could be reached at all. + * + * The chain, first answer wins: + * + * 1. a local checkout's `catalogue.json` - the repository's own + * machine-readable self-description, counted entry by entry + * 2. a local checkout's `SAMPLES.md`, counted through the same parser the + * sample links go through - the fallback for a checkout from before + * `catalogue.json` existed, and for the sparse CI checkouts that fetch + * only `SAMPLES.md` + * 3. the `catalogue.json` the repository publishes on its default branch, + * fetched - so the file built with no checkout at hand carries the same + * figure, not no figure + * 4. null - the caller leaves the number out * - * Counted through the same parser the sample links go through, so the number - * is "what this repository can resolve", never a second opinion: a catalogue - * lists supporting classes in tables of their own (samples-stack has eight), - * and those are not apps and do not parse into a pointer. */ -export function countCatalogue(repo, root) { + * Within a checkout, `catalogue.json` outranks the SAMPLES.md parse + * deliberately: the two answer slightly different questions (in + * abap2UI5/samples, SAMPLES.md also lists the src/00 system area and the + * helper classes; its catalogue.json scopes itself to the portable src/01 + * set), and steps 1 and 3 read the SAME file, so whichever path runs, the + * published figure is the one the repository claims for itself. Step 3 must + * never fail a build: a 404 (the file not committed yet), a timeout or an + * unreachable network all cost the figure, nothing else. */ +export async function countCatalogue(repo, root, { fetchFn = globalThis.fetch } = {}) { const dirs = HOMES[repo]; if (!dirs) throw new Error(`no catalogue location known for ${repo}`); for (const dir of dirs) { const at = dir.endsWith('_HOME') ? process.env[dir] : path.join(root, dir); if (!at) continue; + const json = path.join(at, 'catalogue.json'); + if (fs.existsSync(json)) { + let count = 0; + try { count = countEntries(JSON.parse(fs.readFileSync(json, 'utf8'))); } catch { /* fall through */ } + if (count > 0) return { count, source: 'checkout catalogue.json' }; + } const file = path.join(at, 'SAMPLES.md'); if (!fs.existsSync(file)) continue; const size = parseCatalogue(fs.readFileSync(file, 'utf8')).size; - if (size > 0) return size; + if (size > 0) return { count: size, source: 'checkout SAMPLES.md' }; } + try { + const res = await fetchFn(PUBLISHED(repo), { signal: AbortSignal.timeout(10_000) }); + if (res && res.ok) { + const count = countEntries(JSON.parse(await res.text())); + if (count > 0) return { count, source: 'published catalogue.json' }; + } + } catch { /* no network, no number - never a broken build */ } return null; } diff --git a/scripts/lib/client-interface.mjs b/scripts/lib/client-interface.mjs new file mode 100644 index 00000000..8856730d --- /dev/null +++ b/scripts/lib/client-interface.mjs @@ -0,0 +1,236 @@ +/* + * client-interface — where z2ui5_if_client comes from, and how to read it. + * + * Two gates need the same file and must not drift apart in how they get it: + * + * check-api-names holds every `client->` name on the site against + * the interface, so a page cannot teach API the + * reader's install does not have + * generate-api-reference renders the interface INTO a page (and a JSON), + * and in --check mode holds the committed copy + * against the interface + * + * Both fetch the SAME source at the SAME pin: the raw file at the release the + * site names (lib/release.mjs), never main - a reader installs a release, and + * main is ahead of it by definition. + * + * The full parser lives here too, used by generate-api-reference. It answers + * a richer question than check-api-names asks - not "does this name exist" + * but "what is everything, with its types, defaults, values and the ABAP-Doc + * written on it". check-api-names keeps its own narrow parse on purpose: its + * failure mode is pinned by 1543 real names on the pages, and a shared + * document model would couple the gate that guards the prose to the renderer + * that produces some of it. + */ + +/** The one file this documentation calls "the client API". */ +export const interfacePath = 'src/02/z2ui5_if_client.intf.abap'; + +export const interfaceUrl = (ref) => + `https://raw.githubusercontent.com/abap2UI5/abap2UI5/${ref}/${interfacePath}`; + +/** The interface source at a pinned ref. Throws on any network or HTTP + * failure - the CALLER decides whether that skips or fails, because a gate + * must not go red over an unreachable github.com and must not claim to have + * verified something it did not. */ +export async function fetchInterface(ref) { + const res = await fetch(interfaceUrl(ref), { signal: AbortSignal.timeout(20000) }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + return res.text(); +} + +/* ---------------------------------------------------------------- parsing */ + +/** ABAP-Doc escapes, undone. The parsed model carries PLAIN text; whoever + * renders it into markdown or HTML escapes for that target again. */ +const decode = (s) => s + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/&/g, '&') + .replace(/\\([{}|])/g, '$1'); + +/** Hard-wrapped ABAP-Doc lines, folded back into paragraphs: an empty + * `"!` line is a paragraph break, everything else joins with a space. */ +function paragraphs(lines) { + const out = []; + let cur = []; + for (const line of lines) { + if (line.trim() === '') { + if (cur.length) { out.push(cur.join(' ')); cur = []; } + continue; + } + cur.push(line.trim()); + } + if (cur.length) out.push(cur.join(' ')); + return out; +} + +/** An ABAP-Doc block, split into the general text and its `@parameter x | …` + * entries (each entry runs until the next tag). */ +function splitDoc(rawLines) { + const lines = rawLines.map(decode); + const general = []; + const params = new Map(); + let current = null; + for (const line of lines) { + const tag = /^@parameter\s+([a-z0-9_]+)\s*\|\s*(.*)$/i.exec(line.trim()); + if (tag) { + current = tag[1].toLowerCase(); + params.set(current, [tag[2]]); + continue; + } + if (current) params.get(current).push(line.trim()); + else general.push(line); + } + return { + text: paragraphs(general), + params: new Map([...params].map(([k, v]) => [k, v.join(' ').replace(/\s+/g, ' ').trim()])), + }; +} + +/** + * The whole interface as a document model: + * + * { + * constants: [ { name, doc: [para…], members: [member|group…] } ] + * types: [ { name, doc, members } | { name, doc, definition } ] + * methods: [ { name, doc: [para…], preferred, + * params: [ { name, type, default, optional, doc } ], + * returning: { name, type } } ] + * } + * + * A nested BEGIN OF inside a group is a member with `members` of its own. A + * plain `"…` comment line inside a constant group is a LABEL for the members + * after it (cs_event marks its runs `Control` / `experimental` / `obsolet` + * that way) and lands on each of them as `label`; the same comment before a + * method parameter is a note on that one parameter and lands as `doc`. + */ +export function parseInterface(text) { + const model = { constants: [], types: [], methods: [] }; + let doc = []; // pending "! lines + let note = []; // pending plain " lines (method-parameter notes) + let mode = null; // 'constants' | 'types' after the introducing keyword + const stack = []; // open BEGIN OF groups + let label = null; // current run label inside the innermost group + let method = null; // the method being read + let section = null; // 'importing' | 'returning' | … + + const takeDoc = () => { const d = doc; doc = []; return d; }; + const takeNote = () => { const n = note; note = []; return n.map(decode); }; + + const closeMethod = () => { + note = []; + if (!method) return; + const { text: docText, params } = splitDoc(method.rawDoc); + delete method.rawDoc; + method.doc = docText; + for (const p of method.params) { + const tagged = params.get(p.name); + if (tagged) p.doc = p.doc ? `${p.doc} ${tagged}` : tagged; + } + model.methods.push(method); + method = null; + section = null; + }; + + for (const raw of text.split(/\r?\n/)) { + const abapDoc = /^\s*"!\s?(.*)$/.exec(raw); + if (abapDoc) { doc.push(abapDoc[1]); continue; } + const comment = /^\s*"(?!!)\s?(.*)$/.exec(raw); + if (comment) { + if (stack.length) label = decode(comment[1].trim()); + else note.push(comment[1].trim()); + continue; + } + + const line = raw.trimEnd(); + if (!line.trim()) continue; + const ends = /\.\s*$/.test(line); // the statement closes here + const stmt = line.replace(/[,.]\s*$/, ''); // …either way, drop the mark + + const begin = /^\s*BEGIN OF ([a-z0-9_]+)$/i.exec(stmt); + if (begin) { + const group = { name: begin[1].toLowerCase(), doc: paragraphs(takeDoc().map(decode)), members: [] }; + if (stack.length) stack.at(-1).members.push(group); + stack.push(group); + label = null; + continue; + } + const end = /^\s*END OF ([a-z0-9_]+)$/i.exec(stmt); + if (end) { + const group = stack.pop(); + label = null; + if (!stack.length) (mode === 'types' ? model.types : model.constants).push(group); + continue; + } + if (stack.length) { + const member = /^\s*([a-z0-9_]+)\s+TYPE\s+(.+?)(?:\s+VALUE\s+`([^`]*)`)?$/i.exec(stmt); + if (member) { + stack.at(-1).members.push({ + name: member[1].toLowerCase(), + type: member[2].trim(), + ...(member[3] !== undefined ? { value: member[3] } : {}), + ...(label ? { label } : {}), + }); + } + continue; + } + + if (/^\s*CONSTANTS:?$/i.test(stmt)) { mode = 'constants'; continue; } + if (/^\s*TYPES:?$/i.test(stmt)) { mode = 'types'; continue; } + const flatType = /^\s*TYPES\s+([a-z0-9_]+)\s+TYPE\s+(.+)$/i.exec(stmt); + if (flatType) { + model.types.push({ + name: flatType[1].toLowerCase(), + doc: paragraphs(takeDoc().map(decode)), + definition: flatType[2].trim(), + }); + continue; + } + + const decl = /^\s*METHODS\s+([a-z0-9_]+)$/i.exec(stmt); + if (decl) { + closeMethod(); + method = { name: decl[1].toLowerCase(), rawDoc: takeDoc(), params: [], returning: null, preferred: null }; + section = null; + if (ends) closeMethod(); + continue; + } + if (method) { + if (/^\s*IMPORTING$/i.test(stmt)) { section = 'importing'; continue; } + if (/^\s*(RETURNING|EXPORTING|CHANGING|RAISING)$/i.test(stmt)) { section = stmt.trim().toLowerCase(); if (ends) closeMethod(); continue; } + const preferred = /^\s*PREFERRED PARAMETER\s+([a-z0-9_]+)$/i.exec(stmt); + if (preferred) { method.preferred = preferred[1].toLowerCase(); if (ends) closeMethod(); continue; } + const param = /^\s*(?:VALUE\(([a-z0-9_]+)\)|([a-z0-9_]+))\s+TYPE\s+(.+)$/i.exec(stmt); + if (param) { + const name = (param[1] || param[2]).toLowerCase(); + let rest = param[3].trim(); + let optional = false; + let dflt = null; + const opt = /^(.*?)\s+OPTIONAL$/i.exec(rest); + if (opt) { optional = true; rest = opt[1]; } + const def = /^(.*?)\s+DEFAULT\s+(.+)$/i.exec(rest); + if (def) { dflt = def[2].trim(); rest = def[1]; } + // a plain comment standing before a parameter is a note on THAT + // parameter (`_bind` marks `view` and `custom_mapper` obsolete this way) + const noteText = takeNote().join(' ').replace(/\s+/g, ' ').trim(); + const entry = { + name, + type: rest.trim(), + ...(dflt !== null ? { default: dflt } : {}), + ...(optional ? { optional: true } : {}), + ...(noteText ? { doc: noteText } : {}), + }; + if (section === 'returning') method.returning = { name: entry.name, type: entry.type }; + else method.params.push(entry); + if (ends) closeMethod(); + continue; + } + if (ends) { closeMethod(); continue; } + } + + if (/^\s*ENDINTERFACE/i.test(stmt)) closeMethod(); + } + closeMethod(); + return model; +} diff --git a/scripts/lib/playground-coverage.mjs b/scripts/lib/playground-coverage.mjs new file mode 100644 index 00000000..639d86d0 --- /dev/null +++ b/scripts/lib/playground-coverage.mjs @@ -0,0 +1,117 @@ +/* + * Whether every example without a Run button is without one ON PURPOSE. + * + * `docs/.vitepress/playground.mjs` decides which fenced ABAP example gets a + * Run button, and it fails towards NO button. That is the right direction for + * the reader — but it leaves a silent gap: an example that could run and + * simply never got measured looks exactly like an example that can never run. + * The two used to be told apart by hand, in a ledger in AGENTS.md, and the + * ledger went stale the first time somebody added examples without re-reading + * it. + * + * This module makes the gap decidable. Every complete `z2ui5_if_app` class + * printed on a page either + * + * - gets a button from the rule engine, or + * - carries a marker, written directly above its fence, saying why it + * cannot run: + * + * <!-- playground: no Run button — SELECTs from VBAK, which no browser database has --> + * + * An app example with neither is an undecided one: either it runs — then + * measure it in a real playground (AGENTS.md says how) and let the engine give + * it its button — or it does not, and the marker records why. A marker above + * an example that HAS a button is stale and refused just as loudly, so a + * marker can never quietly outlive a fix that made its example runnable. + * + * What this cannot decide — and does not claim to — is whether a buttoned + * example actually starts. Only a playground answers that, and re-measuring + * against one is still the rule when examples are added. This gate holds the + * *bookkeeping* honest between measurements: no example is ever buttonless by + * accident. + */ +import { playgroundExample } from '../../docs/.vitepress/playground.mjs'; + +/** The marker, exactly: intent first, then the reason a page editor needs. */ +export const MARKER = /^<!--\s*playground:\s*no Run button — (.+?)\s*-->$/; + +const IS_AN_APP = /INTERFACES\s+z2ui5_if_app\b/i; +const COMPLETE = + (code) => /CLASS\s+\S+\s+DEFINITION/i.test(code) && /CLASS\s+\S+\s+IMPLEMENTATION/i.test(code); + +/** + * One page's fences, judged. + * + * @param {string} md the page's markdown + * @returns {{ + * classes: number, apps: number, buttons: number, + * excluded: {name: string, why: string, reason: string}[], + * undecided: {name: string, why: string}[], + * stale: string[], + * }} + */ +export function auditPage(md) { + const out = { classes: 0, apps: 0, buttons: 0, excluded: [], undecided: [], stale: [] }; + const lines = md.split('\n'); + const markerAt = new Map(); // line number -> the reason it carries + lines.forEach((line, i) => { + const m = MARKER.exec(line.trim()); + if (m) markerAt.set(i, m[1]); + }); + const used = new Set(); + + /* Fence positions, walked line by line so a marker attaches by line number + * rather than by text — two identical markers stay two markers. */ + const startLine = []; + lines.forEach((line, i) => { + if (line.startsWith('```abap')) startLine.push(i); + }); + + for (const start of startLine) { + /* the nearest non-blank line above the fence */ + let above = start - 1; + while (above >= 0 && lines[above].trim() === '') above--; + const reason = markerAt.get(above); + if (reason !== undefined) used.add(above); + + /* the fence body */ + const end = lines.indexOf('```', start + 1); + if (end === -1) continue; + const code = lines.slice(start + 1, end).join('\n') + '\n'; + + if (!COMPLETE(code)) { + if (reason !== undefined) { + out.stale.push('a marker above a fence that is not a complete class — the engine never offers those a button'); + } + continue; + } + out.classes++; + const name = /CLASS\s+(\S+)\s+DEFINITION/i.exec(code)?.[1]?.toLowerCase() ?? '?'; + if (!IS_AN_APP.test(code)) { + if (reason !== undefined) { + out.stale.push(`${name} is not an app — the engine never offers it a button, so its marker only adds noise`); + } + continue; + } + out.apps++; + const decision = playgroundExample(code); + if ('name' in decision) { + if (reason !== undefined) { + out.stale.push(`${name} HAS a Run button, and a marker saying it must not — one of the two is wrong`); + } else { + out.buttons++; + } + } else if (reason !== undefined) { + out.excluded.push({ name, why: decision.why, reason }); + } else { + out.undecided.push({ name, why: decision.why }); + } + } + + /* A marker no fence attached to: it sits above prose, above a non-ABAP + * fence, or was orphaned by an edit. It records an intent about nothing. */ + for (const [i, reason] of markerAt) { + if (!used.has(i)) out.stale.push(`a marker attached to no ABAP fence: "${reason}" (line ${i + 1})`); + } + return out; +} diff --git a/test/catalogue.test.mjs b/test/catalogue.test.mjs index 05228cfb..16a00808 100644 --- a/test/catalogue.test.mjs +++ b/test/catalogue.test.mjs @@ -22,7 +22,7 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import { parseCatalogue, countCatalogue } from '../scripts/lib/catalogue.mjs'; +import { parseCatalogue, countCatalogue, countEntries } from '../scripts/lib/catalogue.mjs'; const ROWS = [ '## Basics', @@ -70,21 +70,125 @@ test('a page link survives a block this parser has never seen', () => { }); /* The corpus sizes in the generated llms.txt are counted rather than typed, - * and the deploy workflow may fail to reach a catalogue. Both outcomes are - * legitimate; what must never happen is a number that is not the count. */ -test('a corpus size is counted where the catalogue is, and absent where it is not', () => { + * and a build may fail to reach a catalogue. Both outcomes are legitimate; + * what must never happen is a number that is not the count. + * + * The chain a count comes down (see countCatalogue): a checkout's + * catalogue.json, then its SAMPLES.md, then the catalogue.json the repository + * publishes on its default branch, then no number. The tests below hold one + * fixture against every link, and against the two ways the last link is + * allowed to fail: a 404 and no network at all. None of them may ever touch + * the real network - every one passes its own fetchFn. */ + +/* catalogue.json, one fixture per repository shape. These mirror the real + * files: the entries live under a different key in each repository + * (`samples`, `ports`, `samples` again), other top-level arrays are not + * entries (`family`, `packages`), and each `counts` field DELIBERATELY LIES + * here - the parser must count the entries, never repeat a claim about them. */ +const JSON_SAMPLES = JSON.stringify({ + repository: 'abap2UI5/samples', + family: [{ repository: 'abap2UI5/samples', classPrefix: 'z2ui5_cl_smp_app_' }], + counts: { samples: 999, categories: 23 }, + samples: [ + { class: 'Z2UI5_CL_SMP_APP_493', file: 'src/01/z2ui5_cl_smp_app_493.clas.abap', title: 'Hello World' }, + { class: 'Z2UI5_CL_SMP_APP_494', file: 'src/01/z2ui5_cl_smp_app_494.clas.abap', title: 'Data Binding' }, + ], +}); +const JSON_CONTROLS = JSON.stringify({ + repo: 'abap2UI5/samples-controls', + counts: { entries: 999, byStatus: { checked: 61 } }, + ports: [ + { class: 'Z2UI5_CL_SMPC_APP_001', file: 'src/01/z2ui5_cl_smpc_app_001.clas.abap', deviations: [] }, + { class: 'Z2UI5_CL_SMPC_APP_002', file: 'src/01/z2ui5_cl_smpc_app_002.clas.abap', deviations: [] }, + { class: 'Z2UI5_CL_SMPC_APP_003', file: 'src/01/z2ui5_cl_smpc_app_003.clas.abap', deviations: [] }, + ], +}); +const JSON_STACK = JSON.stringify({ + repo: 'abap2UI5/samples-stack', + packages: [{ package: 'src/odata_v2', technology: 'OData' }], + samples: [ + { class: 'Z2UI5_CL_SMPS_APP_100', path: 'src/odata_v2/z2ui5_cl_smps_app_100.clas.abap' }, + ], +}); + +// a fetchFn is a fixture too: what it returns, and whether it was asked at all +const served = (body) => async () => ({ ok: true, status: 200, text: async () => body }); +const missing = async () => ({ ok: false, status: 404, text: async () => 'Not Found' }); +const offline = async () => { throw new TypeError('fetch failed'); }; +const forbidden = async (url) => { throw new Error(`network reached for ${url} although a checkout was at hand`); }; + +const scratch = () => { // an explicit checkout wins over the sibling directories, and a contributor - // who has one set would otherwise be told the wrong number by this test + // who has one set would otherwise be told the wrong number by these tests delete process.env.SAMPLES_HOME; delete process.env.SAMPLES_CONTROLS_HOME; + delete process.env.SAMPLES_STACK_HOME; + return fs.mkdtempSync(path.join(os.tmpdir(), 'a2ui5-catalogue-')); +}; - const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'a2ui5-catalogue-')); +test('a catalogue.json is counted entry by entry, and its counts field is never believed', () => { + assert.equal(countEntries(JSON.parse(JSON_SAMPLES)), 2); + assert.equal(countEntries(JSON.parse(JSON_CONTROLS)), 3); + assert.equal(countEntries(JSON.parse(JSON_STACK)), 1); + // no entries in hand and a counts field full of claims: the count is 0, + // which the chain treats as "this file answered nothing", not as a figure + assert.equal(countEntries({ counts: { samples: 40 }, samples: [] }), 0); + assert.equal(countEntries({ samples: [{ class: '', file: 'x' }, { class: 'Z', file: '' }, { title: 'no pointer' }] }), 0); +}); + +test('a corpus size is counted where the checkout is, without asking the network', async (t) => { + const dir = scratch(); + t.after(() => fs.rmSync(dir, { recursive: true, force: true })); fs.mkdirSync(path.join(dir, '.samples')); fs.writeFileSync(path.join(dir, '.samples', 'SAMPLES.md'), ROWS); - assert.equal(countCatalogue('samples', dir), 5); - // no checkout at all - the caller leaves the number out rather than guessing - assert.equal(countCatalogue('samples-controls', dir), null); + assert.deepEqual( + await countCatalogue('samples', dir, { fetchFn: forbidden }), + { count: 5, source: 'checkout SAMPLES.md' }, + ); +}); + +test("a checkout's own catalogue.json outranks the SAMPLES.md parse", async (t) => { + /* Deliberate, and the reason the checkout path and the fetch path cannot + * disagree: both read the file the repository generates about itself. In + * abap2UI5/samples the two sources really differ - SAMPLES.md also lists + * the src/00 system area and the helpers, catalogue.json scopes itself to + * the portable src/01 set - so this ordering IS the published figure. */ + const dir = scratch(); + t.after(() => fs.rmSync(dir, { recursive: true, force: true })); + fs.mkdirSync(path.join(dir, '.samples')); + fs.writeFileSync(path.join(dir, '.samples', 'SAMPLES.md'), ROWS); // 5 rows + fs.writeFileSync(path.join(dir, '.samples', 'catalogue.json'), JSON_SAMPLES); // 2 entries + + assert.deepEqual( + await countCatalogue('samples', dir, { fetchFn: forbidden }), + { count: 2, source: 'checkout catalogue.json' }, + ); +}); + +test('no checkout: the published catalogue.json is fetched and counted the same way', async (t) => { + const dir = scratch(); + t.after(() => fs.rmSync(dir, { recursive: true, force: true })); + + assert.deepEqual( + await countCatalogue('samples-controls', dir, { fetchFn: served(JSON_CONTROLS) }), + { count: 3, source: 'published catalogue.json' }, + ); + assert.deepEqual( + await countCatalogue('samples-stack', dir, { fetchFn: served(JSON_STACK) }), + { count: 1, source: 'published catalogue.json' }, + ); +}); + +test('an unreachable published catalogue costs the figure, never the build', async (t) => { + const dir = scratch(); + t.after(() => fs.rmSync(dir, { recursive: true, force: true })); - fs.rmSync(dir, { recursive: true, force: true }); + // the file is not committed yet, or the repository moved: 404 + assert.equal(await countCatalogue('samples', dir, { fetchFn: missing }), null); + // no network at all - the shape every deploy had before the fallback + assert.equal(await countCatalogue('samples', dir, { fetchFn: offline }), null); + // a half-written or wrong file must count for nothing, not throw + assert.equal(await countCatalogue('samples', dir, { fetchFn: served('not json {') }), null); + assert.equal(await countCatalogue('samples', dir, { fetchFn: served('{"counts":{"samples":97}}') }), null); }); diff --git a/test/playground-coverage.test.mjs b/test/playground-coverage.test.mjs new file mode 100644 index 00000000..066a97b1 --- /dev/null +++ b/test/playground-coverage.test.mjs @@ -0,0 +1,106 @@ +/* + * The bookkeeping half of the Run button: every complete app class on a page + * either has a button from the rule engine, or carries a marker saying why it + * cannot run — and a marker that stops being true is refused as loudly as a + * missing one. One fixture per verdict `auditPage` can reach, so the gate in + * scripts/check-playground.mjs cannot drift into passing what it exists to + * catch. + * + * node --test test/ + */ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { auditPage } from '../scripts/lib/playground-coverage.mjs'; + +/** A complete app class, with `body` as the whole of `main`. */ +const app = (name, body) => `CLASS ${name} DEFINITION PUBLIC. + PUBLIC SECTION. + INTERFACES z2ui5_if_app. +ENDCLASS. + +CLASS ${name} IMPLEMENTATION. + METHOD z2ui5_if_app~main. +${body} + ENDMETHOD. +ENDCLASS.`; + +const RUNS = app('zcl_app_hello', ' client->message_box_display( `Hello` ).'); +const REFUSED = app('zcl_app_sql', ` SELECT FROM sflight FIELDS * INTO TABLE @DATA(rows). + client->message_box_display( \`Hello\` ).`); +const MARKER = '<!-- playground: no Run button — SELECTs from SFLIGHT, which no browser database has -->'; + +const fence = (code) => '```abap\n' + code + '\n```'; + +test('an example with a button needs no marker', () => { + const r = auditPage(`# Page\n\n${fence(RUNS)}\n`); + assert.deepEqual( + { apps: r.apps, buttons: r.buttons, undecided: r.undecided, stale: r.stale }, + { apps: 1, buttons: 1, undecided: [], stale: [] }, + ); +}); + +test('a refused example with a marker is excluded on purpose, both reasons kept', () => { + const r = auditPage(`# Page\n\n${MARKER}\n${fence(REFUSED)}\n`); + assert.equal(r.undecided.length, 0); + assert.equal(r.stale.length, 0); + assert.equal(r.excluded.length, 1); + assert.equal(r.excluded[0].name, 'zcl_app_sql'); + assert.match(r.excluded[0].why, /sflight/); + assert.match(r.excluded[0].reason, /SFLIGHT/); +}); + +test('a blank line between marker and fence does not detach it', () => { + const r = auditPage(`# Page\n\n${MARKER}\n\n${fence(REFUSED)}\n`); + assert.equal(r.excluded.length, 1); +}); + +test('a refused example without a marker is the gap this gate exists for', () => { + const r = auditPage(`# Page\n\n${fence(REFUSED)}\n`); + assert.equal(r.undecided.length, 1); + assert.equal(r.undecided[0].name, 'zcl_app_sql'); + assert.match(r.undecided[0].why, /sflight/); +}); + +test('a marker above an example that HAS a button is stale', () => { + // The example was fixed, the marker stayed. Left standing, it would let the + // next edit take the button away again without anybody deciding that. + const r = auditPage(`# Page\n\n${MARKER}\n${fence(RUNS)}\n`); + assert.equal(r.buttons, 0, 'the contradiction is reported, not counted over'); + assert.equal(r.stale.length, 1); + assert.match(r.stale[0], /HAS a Run button/); +}); + +test('a marker attached to nothing is stale', () => { + const r = auditPage(`# Page\n\n${MARKER}\n\nSome prose instead of a fence.\n`); + assert.equal(r.stale.length, 1); + assert.match(r.stale[0], /attached to no ABAP fence/); +}); + +test('a marker above a class that is not an app is stale', () => { + // The engine never offers a helper class a button; a marker there records an + // intent about a decision nobody makes. + const helper = `CLASS zcl_helper DEFINITION PUBLIC. + PUBLIC SECTION. + METHODS do. +ENDCLASS. +CLASS zcl_helper IMPLEMENTATION. + METHOD do. + ENDMETHOD. +ENDCLASS.`; + const r = auditPage(`# Page\n\n${MARKER}\n${fence(helper)}\n`); + assert.equal(r.stale.length, 1); + assert.match(r.stale[0], /not an app/); +}); + +test('a fragment never needs a marker, and a marker on one is stale', () => { + const fragment = 'view->ele( `Page` )->a( n = `title` v = `x` ).'; + assert.equal(auditPage(`# Page\n\n${fence(fragment)}\n`).undecided.length, 0); + const r = auditPage(`# Page\n\n${MARKER}\n${fence(fragment)}\n`); + assert.equal(r.stale.length, 1); +}); + +test('two identical markers are two markers - an orphan does not hide behind a used one', () => { + const r = auditPage(`# Page\n\n${MARKER}\n${fence(REFUSED)}\n\n${MARKER}\n\nprose\n`); + assert.equal(r.excluded.length, 1); + assert.equal(r.stale.length, 1); +}); diff --git a/test/playground.test.mjs b/test/playground.test.mjs index 761830ea..5085ff8c 100644 --- a/test/playground.test.mjs +++ b/test/playground.test.mjs @@ -149,8 +149,10 @@ ${DISPLAY}`)), /on-premise/, call); }); test('a method that is declared and never implemented gets nothing', () => { - // cookbook/event_navigation/life_cycle.md. This is not a playground limit - // either - the class does not activate in any system. + // Watched failing on cookbook/event_navigation/life_cycle.md, whose skeleton + // declared render_main and on_post and implemented neither; the page has + // since been completed and runs. The shape stays guarded because it is not a + // playground limit - such a class does not activate in any system. const code = `CLASS z2ui5_cl_demo_app_001 DEFINITION PUBLIC. PUBLIC SECTION. INTERFACES z2ui5_if_app.